1//! Controls built on the popup layer: menus and filterable lists, a colour grid, a gallery
2//! and a command palette. Each builds while its popup is open and returns what was chosen the
3//! frame it is, closing the popup.
4
5use crate::{
6 Anchor, Axis, Event, Flags, ICON, ICON_GAP, Id, List, Menu, Overflow, Popup, Row, Rows, Spec,
7 TIP_DELAY, TIP_FADE, TIP_WARM, Tip, Ui, children, fill, fit, list::GUTTER, mix, px, text_field,
8};
9use accesskit::Role;
10use nucleo_matcher::{
11 Config, Matcher, Utf32Str,
12 pattern::{AtomKind, CaseMatching, Normalization, Pattern},
13};
14use std::{cell::RefCell, time::Duration};
15use winit::keyboard::{Key, NamedKey};
16
17pub(crate) const CHECK: &[&str] = &[include_str!("../assets/check.svg")];
18/// Inset of a popup's contents, and its distance from what it drops down from.
19pub(crate) const PAD: f32 = 4.0;
20/// A palette's rows and filter field, and Snowbound's more compact menu rows.
21const ROW: f32 = 26.0;
22pub(crate) const MENU_ROW: f32 = 22.0;
23/// Rows a palette or a dialog's list shows before it scrolls; a menu runs to the window's edge.
24const ROWS: f32 = 12.0;
25/// How long the pointer rests on a row before its submenu opens, as Windows waits by default.
26const SUBMENU_DELAY: Duration = Duration::from_millis(200);
27const NARROWEST: f32 = 140.0;
28const PALETTE: f32 = 560.0;
29/// Where a tooltip's description wraps.
30const TIP_WIDTH: f32 = 280.0;
31/// A colour grid's cell, around its swatch.
32const CELL: f32 = 22.0;
33/// Inset of an item's text from the highlight it previews.
34const SAMPLE_PAD: f32 = 2.0;
35thread_local! {
36 /// Scratch space for ranking, a few hundred kilobytes, reused across frames.
37 static MATCHER: RefCell<Matcher> = RefCell::new(Matcher::new(Config::DEFAULT));
38}
39/// The keys a menu's list takes, and Enter to choose.
40const KEYS: [NamedKey; 7] = [
41 NamedKey::ArrowUp,
42 NamedKey::ArrowDown,
43 NamedKey::PageUp,
44 NamedKey::PageDown,
45 NamedKey::Home,
46 NamedKey::End,
47 NamedKey::Enter,
48];
49
50/// A row of a menu or palette.
51#[derive(Clone, Copy, Debug, Default)]
52pub struct Item<'a> {
53 pub text: &'a str,
54 /// The family the text previews, as in a font menu.
55 pub font: Option<&'a str>,
56 /// The colour and highlight the text previews, as a tag without a symbol shows in
57 /// OneNote's Tags menu.
58 pub ink: Option<[f32; 4]>,
59 pub highlight: Option<[f32; 4]>,
60 pub icon: Option<&'static [&'static str]>,
61 /// What the icon's `currentColor` paints in place of the text's colour, as a section's
62 /// icon takes its colour; white leaves an icon with colours of its own as drawn.
63 pub tint: Option<[f32; 4]>,
64 /// Keys that run the item, shown dim at the trailing edge.
65 pub shortcut: &'a str,
66 /// Whether a check marks it in place of its icon; `None` where it is not a toggle.
67 pub checked: Option<bool>,
68 /// Opens a further menu, as a trailing arrow shows.
69 pub submenu: bool,
70 /// The value a picker opens on, highlighted and scrolled into view; a command menu
71 /// has none and opens at its top, however its items are checked.
72 pub current: bool,
73 pub disabled: bool,
74 /// Starts a group, ruled off from the one above while unfiltered.
75 pub separated: bool,
76 /// Names the group it starts: shown only while unfiltered, never chosen.
77 pub heading: bool,
78 /// Repeats an item listed further on, so shows only while unfiltered.
79 pub repeated: bool,
80 /// Offered only when a query leaves nothing else, which it never filters out.
81 pub fallback: bool,
82 /// A tag after the text, as `badge` draws one.
83 pub badge: Option<&'a str>,
84}
85
86impl Item<'_> {
87 /// What the row shows at its trailing edge: an arrow where it opens a menu, else its keys.
88 fn trailing(&self) -> &str {
89 if self.submenu { "›" } else { self.shortcut }
90 }
91}
92
93/// Whether `items`' rows leave room for an icon or a check.
94fn icons(items: &[Item]) -> bool {
95 items
96 .iter()
97 .any(|item| item.icon.is_some() || item.checked == Some(true))
98}
99
100/// Builds popup `id` as a menu of `items` beside `anchor` while it is open, under a
101/// filter field showing `filter` while empty when given. Returns the index of the item
102/// chosen.
103pub fn menu(
104 ui: &mut Ui,
105 id: Id,
106 anchor: Anchor,
107 items: &[Item],
108 filter: Option<&str>,
109) -> Option<usize> {
110 if !ui.popup_open(id) {
111 return None;
112 }
113 let style = ui.theme.menu();
114 let badges: Vec<f32> = (items.iter())
115 .map(|item| {
116 item.badge
117 .map_or(0.0, |badge| crate::badge_width(ui, badge) + ICON_GAP)
118 })
119 .collect();
120 let window = ui.size()[1];
121 let field = if filter.is_some() {
122 ROW + style.pad
123 } else {
124 0.0
125 };
126 let mut measure = |text| ui.texts.label(text, style.font_size, ui.frame).size[0];
127 let [text, shortcut] =
128 items
129 .iter()
130 .zip(&badges)
131 .fold([0.0_f32; 2], |[text, shortcut], (item, badge)| {
132 [
133 text.max(
134 measure(item.text)
135 + badge
136 + if item.highlight.is_some() {
137 2.0 * SAMPLE_PAD
138 } else {
139 0.0
140 },
141 ),
142 shortcut.max(measure(item.trailing())),
143 ]
144 });
145 let width =
146 text + if shortcut > 0.0 {
147 3.0 * ICON_GAP + shortcut
148 } else {
149 0.0
150 } + if icons(items) { ICON + ICON_GAP } else { 0.0 }
151 + if items.len() as f32 * style.row > window - 4.0 * PAD - field {
152 GUTTER
153 } else {
154 0.0
155 }
156 + 2.0 * (style.pad + style.row_pad);
157 choose(
158 ui,
159 id,
160 Role::Menu,
161 anchor,
162 &[("", items)],
163 filter,
164 width.max(NARROWEST),
165 style.row,
166 )
167 .map(|(_, index)| index)
168}
169
170/// Opens the submenus of open menu `id` of `items` beside their rows, as `submenu` names
171/// them by item: a row's once the pointer rests on it for `SUBMENU_DELAY`, or at once on
172/// Right or a click. Resting on another row closes it.
173pub fn submenus(ui: &mut Ui, id: Id, items: &[Item], submenu: impl Fn(usize) -> Option<Id>) {
174 let Some(highlight) = ui
175 .popups
176 .iter()
177 .find(|popup| popup.id == id)
178 .map(|popup| popup.highlight)
179 else {
180 return;
181 };
182 let rows = id.child("rows");
183 let opens = |key: u64| {
184 items
185 .get(key as usize)
186 .is_some_and(|item| item.submenu && !item.disabled)
187 };
188 let right =
189 highlight.is_some_and(opens) && !navigation(ui, &[id], &[NamedKey::ArrowRight]).is_empty();
190 let under = highlight.filter(|key| {
191 ui.pointer
192 .zip(ui.laid_out(rows.child(*key)))
193 .is_some_and(|(point, rect)| crate::contains(rect, point))
194 });
195 let now = ui.now;
196 let popup = state(ui, id);
197 if right {
198 popup.submenu = highlight.map(|key| (key, Some(now)));
199 } else if let Some(key) = under
200 && popup.submenu.is_none_or(|(rested, _)| rested != key)
201 {
202 popup.submenu = Some((key, Some(now + SUBMENU_DELAY)));
203 }
204 let Some((key, Some(due))) = popup.submenu else {
205 return;
206 };
207 if now < due {
208 ui.wake_by(due);
209 return;
210 }
211 popup.submenu = Some((key, None));
212 match submenu(key as usize).filter(|_| opens(key)) {
213 Some(child) => ui.open_submenu(child, id, rows.child(key)),
214 None => {
215 if let Some(at) = ui.popups.iter().position(|popup| popup.id == id) {
216 ui.close_from(at + 1);
217 }
218 }
219 }
220}
221
222/// Shows `title`, with the `keys` that run it and a `description` under it, in a tooltip
223/// centred below the box built last while the pointer rests on it or on a box inside it:
224/// after a delay, or at once while another has just shown. A press or the wheel hides it
225/// until the pointer leaves. It names the box to assistive technology, or where the box has
226/// no role, the unnamed controls inside it.
227pub fn tooltip(ui: &mut Ui, title: &str, keys: &str, description: Option<&str>) {
228 tooltip_below(ui, None, title, keys, description);
229}
230
231/// A `tooltip` below `part`, a box built within the box built last, as a custom box names
232/// part of what it draws.
233pub fn tooltip_over(ui: &mut Ui, part: Id, title: &str, description: Option<&str>) {
234 tooltip_below(ui, Some(part), title, "", description);
235}
236
237fn tooltip_below(
238 ui: &mut Ui,
239 part: Option<Id>,
240 title: &str,
241 keys: &str,
242 description: Option<&str>,
243) {
244 let Some(&index) = ui.nodes[*ui.stack.last().unwrap()].children.last() else {
245 return;
246 };
247 if part.is_none() {
248 let own = ui.nodes[index].access.is_some();
249 let controls = ui.nodes[index..].iter_mut().filter_map(|node| {
250 node.access
251 .as_mut()
252 .filter(|access| own || !crate::access::holds(access.role()))
253 });
254 for (count, node) in controls.take(if own { 1 } else { usize::MAX }).enumerate() {
255 if node.label().is_none() {
256 node.set_label(title);
257 }
258 if count == 0 {
259 if !keys.is_empty() {
260 node.set_keyboard_shortcut(keys);
261 }
262 if let Some(description) = description {
263 node.set_description(description);
264 }
265 }
266 }
267 }
268 // The boxes built since are the box's own.
269 let hovered = ui
270 .hover
271 .is_some_and(|hover| ui.nodes[index..].iter().any(|node| node.id == hover));
272 if !hovered || ui.active.is_some() {
273 return;
274 }
275 let id = ui.nodes[index].id;
276 let now = ui.now;
277 let showing = ui
278 .tip
279 .is_some_and(|tip| tip.due.is_some_and(|due| due <= now));
280 let warm = showing
281 || ui
282 .warm
283 .is_some_and(|warm| now.saturating_duration_since(warm).as_secs_f32() < TIP_WARM);
284 let due = match ui.tip {
285 Some(tip) if tip.id == id => tip.due,
286 // Shown at once, already faded in.
287 _ if warm => Some(
288 now.checked_sub(Duration::from_secs_f32(TIP_FADE))
289 .unwrap_or(now),
290 ),
291 _ => Some(now + Duration::from_secs_f32(TIP_DELAY)),
292 };
293 ui.tip = Some(Tip {
294 id,
295 frame: ui.frame,
296 due,
297 });
298 let Some(due) = due else {
299 return;
300 };
301 if now < due {
302 ui.wake_by(due);
303 return;
304 }
305 let title = if keys.is_empty() {
306 title.to_owned()
307 } else {
308 format!("{title} ({keys})")
309 };
310 let theme = &ui.theme;
311 let spec = Spec {
312 axis: Axis::Y,
313 fill: Some(theme.popup),
314 border: Some(theme.chip),
315 shadow: Some(theme.shadow),
316 radius: 4.0,
317 pad: [8.0, 5.0],
318 gap: 3.0,
319 anchor: Some(Anchor::Tip(part.unwrap_or(id))),
320 ..Spec::default()
321 };
322 ui.open_as(id.child("tooltip"), spec);
323 ui.leaf(
324 "title",
325 Spec {
326 size: [fit(), fit()],
327 text: Some(&title),
328 bold: true,
329 ..Spec::default()
330 },
331 );
332 if let Some(description) = description {
333 let width = ui.measure(description)[0].min(TIP_WIDTH);
334 ui.leaf(
335 "description",
336 Spec {
337 size: [px(width), fit()],
338 text: Some(description),
339 overflow: Overflow::Wrap,
340 ..Spec::default()
341 },
342 );
343 }
344 ui.close();
345}
346
347/// A palette's item picked, by mode and index: to run, or for its actions, which the menu
348/// `actions` names lists beside its row.
349#[derive(Clone, Copy, Debug, PartialEq)]
350pub enum Pick {
351 Run(usize, usize),
352 Actions(usize, usize),
353}
354
355/// The menu of the actions on palette `id`'s item, which Escape closes back to the palette.
356pub fn actions(id: Id) -> Id {
357 id.child("actions")
358}
359
360/// Builds popup `id` as a command palette across the top of the window while it is open,
361/// under a filter field showing `placeholder` while empty. What is typed picks the first of
362/// `modes` whose prefix it starts with, and the rest of it narrows that mode's items, most
363/// relevant first. Enter or a click runs the item; ⌘K (Ctrl+K elsewhere) on the highlighted
364/// one or a right-click on any opens its `actions` menu.
365pub fn palette(ui: &mut Ui, id: Id, modes: &[(&str, &[Item])], placeholder: &str) -> Option<Pick> {
366 if !ui.popup_open(id) {
367 return None;
368 }
369 let rows = id.child("rows");
370 let clicked = (ui.lists.get(&rows).into_iter())
371 .flat_map(crate::list::State::shown)
372 .find(|key| {
373 (ui.signals.get(&rows.child(*key))).is_some_and(|signal| signal.context.is_some())
374 });
375 let chord = chord(ui, id.child("filter"), "k");
376 let width = PALETTE.min(ui.size()[0] - 8.0 * PAD).max(NARROWEST);
377 if let Some((mode, index)) = choose(
378 ui,
379 id,
380 Role::Dialog,
381 Anchor::Top,
382 modes,
383 Some(placeholder),
384 width,
385 ROW,
386 ) {
387 return Some(Pick::Run(mode, index));
388 }
389 if !ui.popup_open(id) {
390 return None;
391 }
392 let popup = state(ui, id);
393 popup.highlight = clicked.or(popup.highlight);
394 let key = clicked.or(popup.highlight.filter(|_| chord))?;
395 let mode = modes
396 .iter()
397 .position(|(prefix, _)| popup.query.starts_with(prefix))?;
398 if modes[mode].1.get(key as usize)?.heading {
399 return None;
400 }
401 ui.open_submenu(actions(id), id, rows.child(key));
402 Some(Pick::Actions(mode, key as usize))
403}
404
405/// Takes a press of `character` with the shortcut modifier, ⌘ or Ctrl, routed to `owner`.
406fn chord(ui: &mut Ui, owner: Id, character: &str) -> bool {
407 if !crate::edit_modifiers(ui.modifiers).command {
408 return false;
409 }
410 let Some(signal) = ui.signals.get_mut(&owner) else {
411 return false;
412 };
413 let before = signal.events.len();
414 signal.events.retain(|event| {
415 !matches!(event, Event::Key { key: Key::Character(typed), .. }
416 if typed.eq_ignore_ascii_case(character))
417 });
418 signal.events.len() != before
419}
420
421/// Opens popup `id` with `query` typed in its filter field and the caret after it, or
422/// retypes it where the popup is open.
423pub fn open_with(ui: &mut Ui, id: Id, query: &str) {
424 ui.open_popup(id);
425 let popup = state(ui, id);
426 popup.query = query.to_owned();
427 popup.highlight = None;
428 ui.states.entry(id.child("filter")).or_default().select = Some([usize::MAX; 2]);
429}
430
431/// Builds popup `id` as a `role` under a filter field showing `filter` while empty, listing
432/// the items of the first of `modes` whose prefix the query starts with: a menu of items, or
433/// a dialog of a list box of options.
434#[allow(clippy::too_many_arguments)]
435fn choose(
436 ui: &mut Ui,
437 id: Id,
438 role: Role,
439 anchor: Anchor,
440 modes: &[(&str, &[Item])],
441 filter: Option<&str>,
442 width: f32,
443 row: f32,
444) -> Option<(usize, usize)> {
445 let field = id.child("filter");
446 if filter.is_some() && ui.focus == Some(id) {
447 ui.focus = Some(field);
448 }
449 let keys = navigation(ui, &[id, field], &KEYS);
450 let rows = id.child("rows");
451 let popup = state(ui, id);
452 let mut query = std::mem::take(&mut popup.query);
453 let mut highlight = popup.highlight;
454 let theme = ui.theme.clone();
455 let style = theme.menu();
456 // Over a combo box, the field takes the box's place at once, widening with the popup.
457 let flags = match anchor {
458 Anchor::Over(_) => Flags::STILL,
459 _ => Flags::default(),
460 };
461 surface(ui, id, role, anchor, width);
462 if let Some(placeholder) = filter {
463 let before = query.clone();
464 text_field(
465 ui,
466 field,
467 &mut query,
468 placeholder,
469 Spec {
470 flags,
471 size: [fill(), px(ROW)],
472 fill: Some(theme.base),
473 border: Some(theme.accent),
474 radius: 4.0,
475 pad: [8.0, 0.0],
476 role: Some(Role::SearchInput),
477 ..Spec::default()
478 },
479 );
480 // New results show from their top at once.
481 if query != before {
482 highlight = None;
483 ui.lists.remove(&rows);
484 }
485 }
486 let (mode, (prefix, items)) = modes
487 .iter()
488 .enumerate()
489 .find(|(_, (prefix, _))| query.starts_with(prefix))
490 .expect("a mode takes any query");
491 if role == Role::Dialog
492 && let Some(node) = ui.access(id)
493 {
494 node.set_label(filter.unwrap_or_default());
495 }
496 let typed = &query[prefix.len()..];
497 let matches = Matches::new(items, typed, style.rule_band);
498 // Unfiltered, the current item starts highlighted; filtered, the best match, unless it
499 // is disabled, so Enter never runs a worse one.
500 highlight = highlight.or_else(|| {
501 let first = if typed.is_empty() {
502 matches.order.iter().position(|index| items[*index].current)
503 } else {
504 Some(0).filter(|row| *row < matches.count() && matches.selectable(*row))
505 };
506 first.map(|row| matches.key(row))
507 });
508 let window = ui.size()[1];
509 let field = if filter.is_some() {
510 ROW + style.pad
511 } else {
512 0.0
513 };
514 let content = matches.count() as f32 * row + matches.space_before(matches.count());
515 let room = window - 4.0 * PAD - field;
516 let most = if role == Role::Menu {
517 room
518 } else {
519 (ROWS * row).min(room)
520 };
521 let view = content.max(row).min(most.max(row));
522 let chosen = if matches.count() == 0 {
523 ui.leaf(
524 "empty",
525 Spec {
526 size: [fill(), px(row)],
527 text: Some("No matches"),
528 font_size: Some(style.font_size),
529 color: Some(style.dim),
530 pad: [8.0, 0.0],
531 ..Spec::default()
532 },
533 );
534 None
535 } else {
536 let list = List {
537 rows: &matches,
538 row,
539 keys: &keys,
540 hover_selects: true,
541 };
542 let clicked = crate::list(
543 ui,
544 rows,
545 Spec {
546 size: [fill(), px(view)],
547 fill: Some(style.fill),
548 role: (role == Role::Dialog).then_some(Role::ListBox),
549 ..Spec::default()
550 },
551 list,
552 &mut highlight,
553 |ui, row| menu_row(ui, &style, &matches, role, row),
554 );
555 // The keys choose from the list while the focus stays where they are typed.
556 let active = highlight.filter(|key| matches.find(*key).is_some());
557 for owner in [id, id.child("filter")] {
558 highlighted(ui, owner, active.map(|key| rows.child(key)));
559 }
560 let entered = keys.contains(&NamedKey::Enter);
561 clicked.or_else(|| {
562 highlight
563 .and_then(|key| matches.find(key))
564 .filter(|row| entered && matches.selectable(*row))
565 })
566 };
567 ui.close();
568 if let Some(row) = chosen {
569 let index = matches.order[row];
570 // A row opening a submenu opens it beside itself, keeping the menu, as `submenus` builds.
571 if !items[index].submenu {
572 ui.close_popup(id);
573 return Some((mode, index));
574 }
575 state(ui, id).submenu = Some((matches.key(row), Some(ui.now)));
576 } else if role == Role::Menu && !typed.is_empty() && keys.contains(&NamedKey::Enter) {
577 // Enter on a menu offering nothing for what was typed leaves things as they were.
578 ui.close_popup(id);
579 return None;
580 }
581 let popup = state(ui, id);
582 popup.query = query;
583 popup.highlight = highlight;
584 None
585}
586
587/// Builds a menu's row: the item's icon or check, its text and its shortcut, on the
588/// highlight when selected, under a rule when it starts a group. In a dialog's list it is
589/// an option.
590fn menu_row(ui: &mut Ui, style: &Menu, matches: &Matches, owner: Role, row: Row) {
591 let item = &matches.items[row.key as usize];
592 let current = ui.current();
593 if let Some(node) = ui.access(current) {
594 node.set_role(match owner {
595 _ if item.heading => Role::Heading,
596 Role::Dialog => Role::ListBoxOption,
597 _ if item.checked.is_some() => Role::MenuItemCheckBox,
598 _ => Role::MenuItem,
599 });
600 node.set_label(item.text);
601 if let Some(badge) = item.badge {
602 node.set_description(badge);
603 }
604 if let Some(checked) = item.checked {
605 node.set_toggled(checked.into());
606 }
607 if item.submenu {
608 node.set_has_popup(accesskit::HasPopup::Menu);
609 node.set_expanded(false);
610 }
611 if row.selected {
612 node.set_selected(true);
613 }
614 if item.disabled {
615 node.set_disabled();
616 }
617 match item.shortcut {
618 "" => {}
619 // A palette's trailing text may say where an option lies rather than its keys.
620 trailing if owner == Role::Dialog => node.set_description(trailing),
621 keys => node.set_keyboard_shortcut(keys),
622 }
623 }
624 if row.index > 0 && matches.ruled && item.separated {
625 ui.leaf(
626 "rule",
627 Spec {
628 flags: Flags::FLOAT,
629 size: [fill(), px(1.0)],
630 position: [0.0, -(style.rule_band + 1.0) / 2.0],
631 inset: [style.rule_inset, 0.0, style.rule_inset, 0.0],
632 fill: Some(style.rule),
633 ..Spec::default()
634 },
635 );
636 }
637 let color = if item.heading {
638 style.dim
639 } else if item.disabled {
640 style.disabled
641 } else {
642 style.text
643 };
644 ui.open(
645 "item",
646 Spec {
647 size: [fill(), fill()],
648 fill: row.selected.then_some(style.highlight),
649 border: style.highlight_border.filter(|_| row.selected),
650 radius: style.row_radius,
651 pad: [style.row_pad, 0.0],
652 gap: ICON_GAP,
653 ..Spec::default()
654 },
655 );
656 if matches.icons {
657 let checked = item.checked == Some(true);
658 let tint = match item.tint {
659 Some([red, green, blue, _]) if !checked => [red, green, blue, 1.0],
660 _ if item.disabled => style.text,
661 _ => color,
662 };
663 // Fading the whole icon dims art in its own colours too, as the toolbar does.
664 let [red, green, blue, alpha] = tint;
665 ui.leaf(
666 "icon",
667 Spec {
668 size: [px(ICON), fill()],
669 icon: if checked { Some(CHECK) } else { item.icon },
670 color: Some([
671 red,
672 green,
673 blue,
674 alpha * if item.disabled { 0.4 } else { 1.0 },
675 ]),
676 ..Spec::default()
677 },
678 );
679 }
680 let text = Spec {
681 size: [fill(), fill()],
682 text: Some(item.text),
683 font: item.font,
684 font_size: Some(style.font_size - if item.heading { 2.0 } else { 0.0 }),
685 bold: item.heading,
686 color: Some(match (item.ink, item.highlight) {
687 (None, None) => color,
688 // Automatic text on a highlight is black, as on OneNote's page.
689 (ink, highlight) => {
690 let ink = ink.unwrap_or([0.0, 0.0, 0.0, 1.0]);
691 if item.disabled {
692 mix(ink, highlight.unwrap_or(style.fill), 0.5)
693 } else {
694 ink
695 }
696 }
697 }),
698 ..Spec::default()
699 };
700 match item.highlight {
701 Some(highlight) => {
702 ui.leaf(
703 "text",
704 Spec {
705 size: [fit(), px(style.row - 4.0)],
706 fill: Some(highlight),
707 pad: [SAMPLE_PAD, 0.0],
708 ..text
709 },
710 );
711 ui.leaf(
712 "rest",
713 Spec {
714 size: [fill(), fill()],
715 ..Spec::default()
716 },
717 );
718 }
719 None if item.badge.is_some() => {
720 ui.leaf(
721 "text",
722 Spec {
723 size: [fit(), fill()],
724 ..text
725 },
726 );
727 if let Some(badge) = item.badge {
728 crate::badge(ui, "badge", badge, style.row);
729 }
730 ui.leaf(
731 "rest",
732 Spec {
733 size: [fill(), fill()],
734 ..Spec::default()
735 },
736 );
737 }
738 None => {
739 ui.leaf("text", text);
740 }
741 }
742 if !item.trailing().is_empty() {
743 ui.leaf(
744 "shortcut",
745 Spec {
746 size: [fit(), fill()],
747 text: Some(item.trailing()),
748 font_size: Some(style.font_size),
749 color: Some(style.dim),
750 ..Spec::default()
751 },
752 );
753 }
754 ui.close();
755}
756
757/// Builds popup `id` as a grid of `swatches`, each a colour and its name or `""` to be
758/// named by its hex, in rows of `columns` beside `anchor` while it is open, under a button
759/// labelled `none` for no colour of its own. Returns the swatch chosen, or None for the
760/// button.
761pub fn colors(
762 ui: &mut Ui,
763 id: Id,
764 anchor: Anchor,
765 none: &str,
766 swatches: &[([f32; 4], &str)],
767 columns: usize,
768) -> Option<Option<[f32; 4]>> {
769 color_grid(ui, id, anchor, &[none], swatches, columns).map(Result::ok)
770}
771
772/// `colors` under a button for each of `buttons`: returns the swatch chosen, or the place of
773/// the button.
774pub fn color_grid(
775 ui: &mut Ui,
776 id: Id,
777 anchor: Anchor,
778 buttons: &[&str],
779 swatches: &[([f32; 4], &str)],
780 columns: usize,
781) -> Option<Result<[f32; 4], usize>> {
782 if !ui.popup_open(id) {
783 return None;
784 }
785 // The buttons are the first cells and the swatches follow them.
786 let cell = |index: usize| id.child(("cell", index));
787 let first = buttons.len();
788 let count = swatches.len() + first;
789 let (highlight, chosen) = pick_cell(ui, id, count, cell, |key, highlight| {
790 match (key, highlight) {
791 (_, None) => 0,
792 (NamedKey::ArrowLeft, Some(at)) => at.saturating_sub(1),
793 (NamedKey::ArrowRight, Some(at)) => (at + 1).min(count - 1),
794 (NamedKey::ArrowUp, Some(at)) if at < first + columns => {
795 at.min(first).saturating_sub(1)
796 }
797 (NamedKey::ArrowUp, Some(at)) => at - columns,
798 (_, Some(at)) if at < first => at + 1,
799 (_, Some(at)) if at + columns < count => at + columns,
800 (_, Some(at)) => at,
801 }
802 });
803 if let Some(index) = chosen {
804 ui.close_popup(id);
805 return Some(match index.checked_sub(first) {
806 Some(swatch) => Ok(swatches[swatch].0),
807 None => Err(index),
808 });
809 }
810
811 let theme = ui.theme.clone();
812 let lit = |index: usize| (highlight == Some(index)).then(|| theme.hover());
813 surface(
814 ui,
815 id,
816 Role::Menu,
817 anchor,
818 columns as f32 * CELL + 2.0 * ui.theme.menu().pad,
819 );
820 for (index, button) in buttons.iter().enumerate() {
821 ui.open_as(
822 cell(index),
823 Spec {
824 flags: Flags::CLICKABLE,
825 size: [fill(), px(ROW)],
826 text: Some(button),
827 fill: lit(index),
828 radius: 4.0,
829 pad: [8.0, 0.0],
830 role: Some(Role::MenuItem),
831 ..Spec::default()
832 },
833 );
834 ui.close();
835 }
836 for (row, colors) in swatches.chunks(columns).enumerate() {
837 ui.open(
838 ("row", row),
839 Spec {
840 size: [fill(), px(CELL)],
841 ..Spec::default()
842 },
843 );
844 for (column, (color, name)) in colors.iter().enumerate() {
845 let index = first + row * columns + column;
846 ui.open_as(
847 cell(index),
848 Spec {
849 flags: Flags::CLICKABLE,
850 size: [px(CELL), px(CELL)],
851 fill: lit(index),
852 radius: 4.0,
853 pad: [3.0, 3.0],
854 role: Some(Role::MenuItem),
855 ..Spec::default()
856 },
857 );
858 if let Some(node) = ui.access(cell(index)) {
859 if name.is_empty() {
860 let [red, green, blue] = draw::srgb_bytes(*color);
861 node.set_label(format!("#{red:02X}{green:02X}{blue:02X}"));
862 } else {
863 node.set_label(*name);
864 }
865 }
866 ui.leaf(
867 "swatch",
868 Spec {
869 size: [fill(), fill()],
870 fill: Some(*color),
871 border: Some(mix(*color, theme.text, 0.25)),
872 radius: 2.0,
873 ..Spec::default()
874 },
875 );
876 ui.close();
877 }
878 ui.close();
879 }
880 highlighted(ui, id, highlight.map(cell));
881 ui.close();
882 None
883}
884
885/// Tells assistive technology the keys of `id` highlight `cell`, as a focus within it.
886fn highlighted(ui: &mut Ui, id: Id, cell: Option<Id>) {
887 if let (Some(cell), Some(node)) = (cell, ui.access(id)) {
888 node.set_active_descendant(cell.node());
889 }
890}
891
892/// Routes the pointer and keys over open popup `id`'s `count` cells, keeping its highlight,
893/// which `step` moves for each arrow and the pointer leaving the popup clears. Returns the
894/// highlight and the cell a click or Enter chose.
895fn pick_cell(
896 ui: &mut Ui,
897 id: Id,
898 count: usize,
899 cell: impl Fn(usize) -> Id,
900 step: impl Fn(NamedKey, Option<usize>) -> usize,
901) -> (Option<usize>, Option<usize>) {
902 let keys = navigation(
903 ui,
904 &[id],
905 &[
906 NamedKey::ArrowLeft,
907 NamedKey::ArrowRight,
908 NamedKey::ArrowUp,
909 NamedKey::ArrowDown,
910 NamedKey::Enter,
911 ],
912 );
913 let mut highlight = state(ui, id).highlight.map(|cell| cell as usize);
914 let outside =
915 ui.pointer()
916 .zip(ui.laid_out(id))
917 .is_some_and(|([x, y], [left, top, right, bottom])| {
918 x < left || x >= right || y < top || y >= bottom
919 });
920 if outside && ui.moved {
921 highlight = None;
922 }
923 let mut chosen = None;
924 for index in 0..count {
925 let signal = ui.signal(cell(index));
926 if signal.hovered && ui.moved {
927 highlight = Some(index);
928 }
929 if signal.clicked {
930 chosen = Some(index);
931 }
932 }
933 for key in keys {
934 match key {
935 NamedKey::Enter => chosen = chosen.or(highlight),
936 _ => highlight = Some(step(key, highlight)),
937 }
938 }
939 state(ui, id).highlight = highlight.map(|cell| cell as u64);
940 (highlight, chosen)
941}
942
943/// The cell open popup `id`, a grid or gallery, highlights: under the pointer or reached by
944/// the arrows.
945pub fn highlight(ui: &Ui, id: Id) -> Option<usize> {
946 let popup = ui.popups.iter().find(|popup| popup.id == id)?;
947 popup.highlight.map(|cell| cell as usize)
948}
949
950/// Builds popup `id` as a grid of `size` columns and rows beside `anchor` while it is open,
951/// lit from its corner to the cell pointed at, as Office's table picker is. Returns the
952/// columns and rows chosen.
953pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Option<[usize; 2]> {
954 if !ui.popup_open(id) {
955 return None;
956 }
957 let cell = |index: usize| id.child(("cell", index));
958 let [columns, rows] = size;
959 let (highlight, chosen) = pick_cell(ui, id, columns * rows, cell, |key, highlight| {
960 let Some(at) = highlight else {
961 return 0;
962 };
963 let [column, row] = [at % columns, at / columns];
964 match key {
965 NamedKey::ArrowLeft => at - usize::from(column > 0),
966 NamedKey::ArrowRight => at + usize::from(column + 1 < columns),
967 NamedKey::ArrowUp => at - if row > 0 { columns } else { 0 },
968 _ => at + if row + 1 < rows { columns } else { 0 },
969 }
970 });
971 let extent = |index: usize| [index % columns + 1, index / columns + 1];
972 if let Some(index) = chosen {
973 ui.close_popup(id);
974 return Some(extent(index));
975 }
976 let theme = ui.theme.clone();
977 let reach = highlight.map_or([0, 0], extent);
978 surface(
979 ui,
980 id,
981 Role::Menu,
982 anchor,
983 columns as f32 * CELL + 2.0 * ui.theme.menu().pad,
984 );
985 let heading = match highlight {
986 Some(_) => format!("{}x{} Table", reach[0], reach[1]),
987 None => "Insert Table".to_owned(),
988 };
989 ui.leaf(
990 "heading",
991 Spec {
992 size: [fill(), px(ROW)],
993 text: Some(&heading),
994 bold: true,
995 pad: [8.0, 0.0],
996 ..Spec::default()
997 },
998 );
999 ui.open(
1000 "grid",
1001 Spec {
1002 axis: Axis::Y,
1003 size: [fill(), children()],
1004 ..Spec::default()
1005 },
1006 );
1007 for row in 0..rows {
1008 ui.open(
1009 ("row", row),
1010 Spec {
1011 size: [fill(), px(CELL)],
1012 ..Spec::default()
1013 },
1014 );
1015 for column in 0..columns {
1016 let lit = column < reach[0] && row < reach[1];
1017 ui.open_as(
1018 cell(row * columns + column),
1019 Spec {
1020 flags: Flags::CLICKABLE,
1021 size: [px(CELL), px(CELL)],
1022 pad: [3.0, 3.0],
1023 role: Some(Role::MenuItem),
1024 ..Spec::default()
1025 },
1026 );
1027 if let Some(node) = ui.access(cell(row * columns + column)) {
1028 node.set_label(format!("{}x{} Table", column + 1, row + 1));
1029 }
1030 ui.leaf(
1031 "square",
1032 Spec {
1033 size: [fill(), fill()],
1034 fill: Some(if lit { theme.hover() } else { theme.base }),
1035 border: Some(if lit {
1036 theme.accent
1037 } else {
1038 mix(theme.text, theme.popup, 0.7)
1039 }),
1040 radius: 2.0,
1041 ..Spec::default()
1042 },
1043 );
1044 ui.close();
1045 }
1046 ui.close();
1047 }
1048 ui.close();
1049 highlighted(ui, id, highlight.map(cell));
1050 ui.close();
1051 None
1052}
1053
1054/// A gallery's run of `cells` under `heading`, if any, in rows of `columns` cells `size` large.
1055#[derive(Clone, Copy, Debug)]
1056pub struct Group<'a> {
1057 pub heading: &'a str,
1058 /// Starts under a rule, as a gallery's command after its choices.
1059 pub ruled: bool,
1060 pub cells: usize,
1061 pub columns: usize,
1062 pub size: [f32; 2],
1063}
1064
1065/// Builds popup `id` as a gallery beside `anchor` while it is open: `groups` of cells, with
1066/// the cells in `current` outlined. `cell` builds a cell's contents from its index, counted
1067/// through the groups. Returns the index of the cell chosen.
1068pub fn gallery(
1069 ui: &mut Ui,
1070 id: Id,
1071 anchor: Anchor,
1072 groups: &[Group],
1073 current: &[usize],
1074 mut cell: impl FnMut(&mut Ui, usize),
1075) -> Option<usize> {
1076 if !ui.popup_open(id) {
1077 return None;
1078 }
1079 let cell_id = |index: usize| id.child(("cell", index));
1080 let count: usize = groups.iter().map(|group| group.cells).sum();
1081 // The columns of the group holding cell `at`.
1082 let columns = |at: usize| {
1083 let mut start = 0;
1084 groups
1085 .iter()
1086 .find(|group| {
1087 start += group.cells;
1088 at < start
1089 })
1090 .map_or(1, |group| group.columns)
1091 };
1092 let (highlight, chosen) = pick_cell(ui, id, count, cell_id, |key, highlight| {
1093 let at = highlight.or(current.first().copied()).unwrap_or(0);
1094 match key {
1095 NamedKey::ArrowLeft => at.saturating_sub(1),
1096 NamedKey::ArrowRight => (at + 1).min(count - 1),
1097 NamedKey::ArrowUp => at.saturating_sub(columns(at)),
1098 _ => (at + columns(at)).min(count - 1),
1099 }
1100 });
1101 if let Some(index) = chosen {
1102 ui.close_popup(id);
1103 return Some(index);
1104 }
1105 let theme = ui.theme.clone();
1106 let width = groups
1107 .iter()
1108 .map(|group| group.columns as f32 * group.size[0])
1109 .fold(0.0, f32::max);
1110 surface(
1111 ui,
1112 id,
1113 Role::Menu,
1114 anchor,
1115 width + 2.0 * ui.theme.menu().pad,
1116 );
1117 let mut index = 0;
1118 let style = theme.menu();
1119 for (number, group) in groups.iter().enumerate() {
1120 if group.ruled {
1121 ui.open(
1122 ("rule", number),
1123 Spec {
1124 size: [fill(), px(style.rule_band)],
1125 ..Spec::default()
1126 },
1127 );
1128 ui.leaf(
1129 "line",
1130 Spec {
1131 flags: Flags::FLOAT,
1132 size: [fill(), px(1.0)],
1133 position: [0.0, (style.rule_band - 1.0) / 2.0],
1134 inset: [style.rule_inset, 0.0, style.rule_inset, 0.0],
1135 fill: Some(style.rule),
1136 ..Spec::default()
1137 },
1138 );
1139 ui.close();
1140 }
1141 if !group.heading.is_empty() {
1142 ui.leaf(
1143 ("heading", number),
1144 Spec {
1145 size: [fill(), px(MENU_ROW)],
1146 text: Some(group.heading),
1147 font_size: Some(theme.font_size - 2.0),
1148 bold: true,
1149 color: Some(theme.text_dim),
1150 pad: [PAD, 0.0],
1151 ..Spec::default()
1152 },
1153 );
1154 }
1155 for row in 0..group.cells.div_ceil(group.columns) {
1156 ui.open(
1157 ("row", number, row),
1158 Spec {
1159 size: [fill(), px(group.size[1])],
1160 ..Spec::default()
1161 },
1162 );
1163 for _ in 0..group.columns.min(group.cells - row * group.columns) {
1164 ui.open_as(
1165 cell_id(index),
1166 Spec {
1167 flags: Flags::CLICKABLE,
1168 axis: Axis::Y,
1169 size: [px(group.size[0]), px(group.size[1])],
1170 fill: (highlight == Some(index)).then(|| theme.hover()),
1171 border: current.contains(&index).then_some(theme.accent),
1172 radius: 4.0,
1173 pad: [PAD, PAD],
1174 role: Some(Role::MenuItem),
1175 ..Spec::default()
1176 },
1177 );
1178 if current.contains(&index)
1179 && let Some(node) = ui.access(cell_id(index))
1180 {
1181 node.set_selected(true);
1182 }
1183 cell(ui, index);
1184 ui.close();
1185 index += 1;
1186 }
1187 ui.close();
1188 }
1189 }
1190 highlighted(ui, id, highlight.map(cell_id));
1191 ui.close();
1192 None
1193}
1194
1195/// A colour picker's hue and lightness field, and its saturation bar beneath.
1196const FIELD: [f32; 2] = [256.0, 128.0];
1197const BAR: f32 = 14.0;
1198/// Columns the field is drawn in, each a gradient from white to the hue and to black.
1199const HUES: usize = 64;
1200
1201/// Builds popup `id` beside `anchor` while it is open as a colour picker titled `title`,
1202/// starting from `initial`, sRGB: hue across a field drawn fully saturated, lightness down
1203/// it, saturation along a bar, and a preview of the colour as `preview` shows it, linear
1204/// RGBA. Returns the colour applied.
1205pub fn color_picker(
1206 ui: &mut Ui,
1207 id: Id,
1208 anchor: Anchor,
1209 title: &str,
1210 initial: [u8; 3],
1211 preview: impl Fn([u8; 3]) -> [f32; 4],
1212) -> Option<[u8; 3]> {
1213 if !ui.popup_open(id) {
1214 return None;
1215 }
1216 let [field, bar] = ["field", "saturation"].map(|part| id.child(part));
1217 let apply = id.child("footer").child("apply");
1218 let mut picked = state(ui, id).picked.unwrap_or_else(|| to_hsl(initial));
1219 let pointer = ui.pointer();
1220 let along = |ui: &Ui, part: Id| {
1221 let [left, top, right, bottom] = ui.laid_out(part)?;
1222 let [x, y] = pointer?;
1223 Some([
1224 ((x - left) / (right - left)).clamp(0.0, 1.0),
1225 ((y - top) / (bottom - top)).clamp(0.0, 1.0),
1226 ])
1227 };
1228 let held = |ui: &mut Ui, part: Id| {
1229 let signal = ui.signal(part);
1230 signal.pressed || signal.dragging
1231 };
1232 if held(ui, field)
1233 && let Some([x, y]) = along(ui, field)
1234 {
1235 picked[0] = x * 360.0;
1236 picked[2] = 1.0 - y;
1237 }
1238 if held(ui, bar)
1239 && let Some([x, _]) = along(ui, bar)
1240 {
1241 picked[1] = x;
1242 }
1243 let keys = navigation(ui, &[id], &[NamedKey::Enter]);
1244 state(ui, id).picked = Some(picked);
1245 let rgb = from_hsl(picked);
1246 if ui.signal(apply).clicked || !keys.is_empty() {
1247 ui.close_popup(id);
1248 return Some(rgb);
1249 }
1250
1251 let theme = ui.theme.clone();
1252 surface(
1253 ui,
1254 id,
1255 Role::Dialog,
1256 anchor,
1257 FIELD[0] + 2.0 * ui.theme.menu().pad,
1258 );
1259 if let Some(node) = ui.access(id) {
1260 node.set_label(title);
1261 }
1262 let color = |hsl| {
1263 let [red, green, blue] = from_hsl(hsl);
1264 draw::srgb(red, green, blue)
1265 };
1266 let [hue, saturation, lightness] = picked;
1267 ui.leaf(
1268 "heading",
1269 Spec {
1270 size: [fill(), px(MENU_ROW)],
1271 text: Some(title),
1272 font_size: Some(theme.font_size - 2.0),
1273 bold: true,
1274 color: Some(theme.text_dim),
1275 pad: [PAD, 0.0],
1276 ..Spec::default()
1277 },
1278 );
1279 ui.open_as(
1280 field,
1281 Spec {
1282 flags: Flags::CLICKABLE,
1283 size: [px(FIELD[0]), px(FIELD[1])],
1284 ..Spec::default()
1285 },
1286 );
1287 let column = FIELD[0] / HUES as f32;
1288 for index in 0..HUES {
1289 let shade = (index as f32 + 0.5) / HUES as f32 * 360.0;
1290 for (half, [top, bottom]) in [[1.0, 0.5], [0.5, 0.0]].into_iter().enumerate() {
1291 ui.leaf(
1292 (index, half),
1293 Spec {
1294 flags: Flags::FLOAT,
1295 position: [index as f32 * column, half as f32 * FIELD[1] / 2.0],
1296 size: [px(column + 0.5), px(FIELD[1] / 2.0)],
1297 fill: Some(color([shade, 1.0, top])),
1298 gradient: Some(color([shade, 1.0, bottom])),
1299 ..Spec::default()
1300 },
1301 );
1302 }
1303 }
1304 // A white ring inside a dark one, seen on any colour.
1305 let ring = |ui: &mut Ui, part: &str, at: [f32; 2]| {
1306 for (index, (radius, color)) in [(6.0, [0.0, 0.0, 0.0, 0.6]), (5.0, [1.0; 4])]
1307 .into_iter()
1308 .enumerate()
1309 {
1310 ui.leaf(
1311 (part, index),
1312 Spec {
1313 flags: Flags::FLOAT,
1314 position: [at[0] - radius, at[1] - radius],
1315 size: [px(2.0 * radius), px(2.0 * radius)],
1316 border: Some(color),
1317 radius,
1318 ..Spec::default()
1319 },
1320 );
1321 }
1322 };
1323 ring(
1324 ui,
1325 "ring",
1326 [hue / 360.0 * FIELD[0], (1.0 - lightness) * FIELD[1]],
1327 );
1328 ui.close();
1329 ui.open_as(
1330 bar,
1331 Spec {
1332 flags: Flags::CLICKABLE,
1333 size: [px(FIELD[0]), px(BAR)],
1334 radius: 2.0,
1335 ..Spec::default()
1336 },
1337 );
1338 let steps = 32;
1339 let step = FIELD[0] / steps as f32;
1340 for index in 0..steps {
1341 ui.leaf(
1342 index,
1343 Spec {
1344 flags: Flags::FLOAT,
1345 position: [index as f32 * step, 0.0],
1346 size: [px(step + 0.5), px(BAR)],
1347 fill: Some(color([hue, index as f32 / (steps - 1) as f32, lightness])),
1348 ..Spec::default()
1349 },
1350 );
1351 }
1352 ring(ui, "ring", [saturation * FIELD[0], BAR / 2.0]);
1353 ui.close();
1354 ui.open(
1355 "footer",
1356 Spec {
1357 size: [fill(), px(ROW)],
1358 gap: 8.0,
1359 ..Spec::default()
1360 },
1361 );
1362 ui.leaf(
1363 "preview",
1364 Spec {
1365 size: [px(2.0 * ROW), px(ROW)],
1366 fill: Some(preview(rgb)),
1367 border: Some(theme.chip),
1368 radius: 4.0,
1369 ..Spec::default()
1370 },
1371 );
1372 let [red, green, blue] = rgb;
1373 let hex = format!("#{red:02X}{green:02X}{blue:02X}");
1374 ui.leaf(
1375 "hex",
1376 Spec {
1377 size: [fill(), px(ROW)],
1378 text: Some(&hex),
1379 color: Some(theme.text_dim),
1380 ..Spec::default()
1381 },
1382 );
1383 crate::button(ui, "apply", "Apply");
1384 ui.close();
1385 ui.close();
1386 None
1387}
1388
1389/// The colour open colour picker `id` shows, sRGB, before it is applied.
1390pub fn picking(ui: &Ui, id: Id) -> Option<[u8; 3]> {
1391 let popup = ui.popups.iter().find(|popup| popup.id == id)?;
1392 popup.picked.map(from_hsl)
1393}
1394
1395/// Hue in degrees, saturation and lightness of an sRGB colour.
1396fn to_hsl(rgb: [u8; 3]) -> [f32; 3] {
1397 let [red, green, blue] = rgb.map(|channel| f32::from(channel) / 255.0);
1398 let (most, least) = (red.max(green).max(blue), red.min(green).min(blue));
1399 let lightness = (most + least) / 2.0;
1400 let chroma = most - least;
1401 if chroma == 0.0 {
1402 return [0.0, 0.0, lightness];
1403 }
1404 let saturation = chroma / (1.0 - (2.0 * lightness - 1.0).abs());
1405 let sector = if most == red {
1406 ((green - blue) / chroma).rem_euclid(6.0)
1407 } else if most == green {
1408 (blue - red) / chroma + 2.0
1409 } else {
1410 (red - green) / chroma + 4.0
1411 };
1412 [sector * 60.0, saturation, lightness]
1413}
1414
1415fn from_hsl([hue, saturation, lightness]: [f32; 3]) -> [u8; 3] {
1416 let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation;
1417 let sector = (hue / 60.0).rem_euclid(6.0);
1418 let second = chroma * (1.0 - (sector % 2.0 - 1.0).abs());
1419 let [red, green, blue] = match sector as u32 {
1420 0 => [chroma, second, 0.0],
1421 1 => [second, chroma, 0.0],
1422 2 => [0.0, chroma, second],
1423 3 => [0.0, second, chroma],
1424 4 => [second, 0.0, chroma],
1425 _ => [chroma, 0.0, second],
1426 };
1427 let base = lightness - chroma / 2.0;
1428 [red, green, blue].map(|channel| ((channel + base) * 255.0).round().clamp(0.0, 255.0) as u8)
1429}
1430
1431/// Opens popup `id`'s panel, a `role` `width` wide beside `anchor`, or as a submenu beside
1432/// its row; the caller closes it.
1433fn surface(ui: &mut Ui, id: Id, role: Role, anchor: Anchor, width: f32) {
1434 let style = ui.theme.menu();
1435 let beside = ui
1436 .popups
1437 .iter()
1438 .find(|popup| popup.id == id)
1439 .and_then(|popup| popup.beside);
1440 let spec = Spec {
1441 axis: Axis::Y,
1442 size: [px(width), children()],
1443 fill: Some(style.fill),
1444 border: Some(style.border),
1445 shadow: Some(ui.theme.shadow),
1446 radius: style.radius,
1447 pad: [style.pad; 2],
1448 gap: style.pad,
1449 anchor: Some(beside.map_or(anchor, Anchor::Right)),
1450 role: Some(role),
1451 ..Spec::default()
1452 };
1453 ui.open_as(id, spec);
1454}
1455
1456/// What is typed in open popup `id`'s filter field.
1457pub fn query(ui: &Ui, id: Id) -> Option<&str> {
1458 ui.popups
1459 .iter()
1460 .find(|popup| popup.id == id)
1461 .map(|popup| popup.query.as_str())
1462}
1463
1464fn state(ui: &mut Ui, id: Id) -> &mut Popup {
1465 ui.popups
1466 .iter_mut()
1467 .find(|popup| popup.id == id)
1468 .expect("the popup is open")
1469}
1470
1471/// Takes the presses of `keys` routed this frame to the focus when it is one of `owners`.
1472pub fn navigation(ui: &mut Ui, owners: &[Id], keys: &[NamedKey]) -> Vec<NamedKey> {
1473 let Some(signal) = ui
1474 .focus
1475 .filter(|focus| owners.contains(focus))
1476 .and_then(|focus| ui.signals.get_mut(&focus))
1477 else {
1478 return Vec::new();
1479 };
1480 let mut taken = Vec::new();
1481 signal.events.retain(|event| match event {
1482 Event::Key {
1483 key: Key::Named(key),
1484 ..
1485 } if keys.contains(key) => {
1486 taken.push(*key);
1487 false
1488 }
1489 _ => true,
1490 });
1491 taken
1492}
1493
1494/// The items fuzzily matching a query as fzf does, best first, keyed by their index in
1495/// `items`.
1496pub(crate) struct Matches<'a> {
1497 items: &'a [Item<'a>],
1498 pub(crate) order: Vec<usize>,
1499 /// Each item's row, or None when it does not match.
1500 rows: Vec<Option<usize>>,
1501 /// Rules above each row and the end, counted from the top; only while unfiltered.
1502 rules: Vec<u32>,
1503 ruled: bool,
1504 /// Height of the band a rule takes.
1505 rule: f32,
1506 icons: bool,
1507}
1508
1509impl<'a> Matches<'a> {
1510 pub(crate) fn new(items: &'a [Item<'a>], query: &str, rule: f32) -> Self {
1511 let pattern = Pattern::new(
1512 query,
1513 CaseMatching::Ignore,
1514 Normalization::Smart,
1515 AtomKind::Fuzzy,
1516 );
1517 let mut order: Vec<usize> = if pattern.atoms.is_empty() {
1518 (0..items.len())
1519 .filter(|index| !items[*index].fallback)
1520 .collect()
1521 } else {
1522 let mut chars = Vec::new();
1523 let mut ranked: Vec<_> = MATCHER.with_borrow_mut(|matcher| {
1524 items
1525 .iter()
1526 .enumerate()
1527 .filter(|(_, item)| !item.heading && !item.repeated && !item.fallback)
1528 .filter_map(|(index, item)| {
1529 let text = Utf32Str::new(item.text, &mut chars);
1530 Some((pattern.score(text, matcher)?, index))
1531 })
1532 .collect()
1533 });
1534 // Stable, so equal matches keep the items' order; the text itself typed leads.
1535 let typed = |index: usize| items[index].text.eq_ignore_ascii_case(query.trim());
1536 ranked.sort_by_key(|(score, index)| (!typed(*index), std::cmp::Reverse(*score)));
1537 ranked.into_iter().map(|(_, index)| index).collect()
1538 };
1539 if order.is_empty() && !pattern.atoms.is_empty() {
1540 order.extend((0..items.len()).filter(|index| items[*index].fallback));
1541 }
1542 let mut rows = vec![None; items.len()];
1543 for (row, index) in order.iter().enumerate() {
1544 rows[*index] = Some(row);
1545 }
1546 let ruled = query.is_empty();
1547 let mut rules = vec![0];
1548 for (row, index) in order.iter().enumerate().skip(1) {
1549 let rule = u32::from(ruled && items[*index].separated);
1550 rules.push(rules[row - 1] + rule);
1551 }
1552 rules.push(*rules.last().unwrap());
1553 Self {
1554 items,
1555 order,
1556 rows,
1557 rules,
1558 ruled,
1559 rule,
1560 icons: icons(items),
1561 }
1562 }
1563}
1564
1565impl Rows for Matches<'_> {
1566 fn count(&self) -> usize {
1567 self.order.len()
1568 }
1569
1570 fn key(&self, index: usize) -> u64 {
1571 self.order[index] as u64
1572 }
1573
1574 fn find(&self, key: u64) -> Option<usize> {
1575 *self.rows.get(key as usize)?
1576 }
1577
1578 fn selectable(&self, index: usize) -> bool {
1579 let item = &self.items[self.order[index]];
1580 !item.disabled && !item.heading
1581 }
1582
1583 fn space_before(&self, index: usize) -> f32 {
1584 self.rules[index] as f32 * self.rule
1585 }
1586}