| 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 | |
| 5 | use 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 | }; |
| 9 | use accesskit::Role; |
| 10 | use nucleo_matcher::{ |
| 11 | Config, Matcher, Utf32Str, |
| 12 | pattern::{AtomKind, CaseMatching, Normalization, Pattern}, |
| 13 | }; |
| 14 | use std::{cell::RefCell, time::Duration}; |
| 15 | use winit::keyboard::{Key, NamedKey}; |
| 16 | |
| 17 | pub(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. |
| 19 | pub(crate) const PAD: f32 = 4.0; |
| 20 | /// A palette's rows and filter field, and Snowbound's more compact menu rows. |
| 21 | const ROW: f32 = 26.0; |
| 22 | pub(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. |
| 24 | const ROWS: f32 = 12.0; |
| 25 | /// How long the pointer rests on a row before its submenu opens, as Windows waits by default. |
| 26 | const SUBMENU_DELAY: Duration = Duration::from_millis(200); |
| 27 | const NARROWEST: f32 = 140.0; |
| 28 | const PALETTE: f32 = 560.0; |
| 29 | /// Where a tooltip's description wraps. |
| 30 | const TIP_WIDTH: f32 = 280.0; |
| 31 | /// A colour grid's cell, around its swatch. |
| 32 | const CELL: f32 = 22.0; |
| 33 | /// Inset of an item's text from the highlight it previews. |
| 34 | const SAMPLE_PAD: f32 = 2.0; |
| 35 | thread_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. |
| 40 | const 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)] |
| 52 | pub 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 | |
| 86 | impl 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. |
| 94 | fn 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. |
| 103 | pub 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. |
| 173 | pub 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. |
| 227 | pub 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. |
| 233 | pub fn tooltip_over(ui: &mut Ui, part: Id, title: &str, description: Option<&str>) { |
| 234 | tooltip_below(ui, Some(part), title, "", description); |
| 235 | } |
| 236 | |
| 237 | fn 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)] |
| 350 | pub 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. |
| 356 | pub 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. |
| 365 | pub 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`. |
| 406 | fn 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. |
| 423 | pub 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)] |
| 435 | fn 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. |
| 590 | fn 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. |
| 761 | pub 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. |
| 774 | pub 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. |
| 886 | fn 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. |
| 895 | fn 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. |
| 945 | pub 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. |
| 953 | pub 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)] |
| 1056 | pub 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. |
| 1068 | pub 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. |
| 1196 | const FIELD: [f32; 2] = [256.0, 128.0]; |
| 1197 | const BAR: f32 = 14.0; |
| 1198 | /// Columns the field is drawn in, each a gradient from white to the hue and to black. |
| 1199 | const 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. |
| 1205 | pub 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. |
| 1390 | pub 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. |
| 1396 | fn 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 | |
| 1415 | fn 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. |
| 1433 | fn 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. |
| 1457 | pub 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 | |
| 1464 | fn 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`. |
| 1472 | pub 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`. |
| 1496 | pub(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 | |
| 1509 | impl<'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 | |
| 1565 | impl 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 | } |