git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/widget/core.rs (113K)

   1 use crate::widget::WidgetHost;
   2 
   3 pub mod hover_animation {
   4     use std::cell::RefCell;
   5 
   6     #[derive(Debug, Clone)]
   7     pub struct HoverState {
   8         pub current_x: f32,
   9         pub current_y: f32,
  10         pub current_w: f32,
  11         pub current_h: f32,
  12         pub current_alpha: f32,
  13 
  14         pub target_x: Option<f32>,
  15         pub target_y: Option<f32>,
  16         pub target_w: Option<f32>,
  17         pub target_h: Option<f32>,
  18         pub target_alpha: f32,
  19 
  20         pub registered_this_frame: bool,
  21         pub scroll_offset: f32,
  22     }
  23 
  24     impl HoverState {
  25         pub fn new() -> Self {
  26             Self {
  27                 current_x: 0.0,
  28                 current_y: 0.0,
  29                 current_w: 0.0,
  30                 current_h: 0.0,
  31                 current_alpha: 0.0,
  32 
  33                 target_x: None,
  34                 target_y: None,
  35                 target_w: None,
  36                 target_h: None,
  37                 target_alpha: 0.0,
  38 
  39                 registered_this_frame: false,
  40                 scroll_offset: 0.0,
  41             }
  42         }
  43     }
  44 
  45     // The highlight and the cursor it follows are the current window's
  46     // (`crate::window_state`), not the thread's.
  47     fn hover_state<R>(f: impl FnOnce(&RefCell<HoverState>) -> R) -> R {
  48         crate::window_state::with(|w| f(&w.hover))
  49     }
  50     fn cursor_state<R>(f: impl FnOnce(&RefCell<(f32, f32)>) -> R) -> R {
  51         crate::window_state::with(|w| f(&w.cursor))
  52     }
  53 
  54     pub fn set_cursor_pos(x: f32, y: f32) {
  55         cursor_state(|pos| {
  56             *pos.borrow_mut() = (x, y);
  57         });
  58     }
  59 
  60     pub fn reset_frame_registration() {
  61         hover_state(|state| {
  62             state.borrow_mut().registered_this_frame = false;
  63         });
  64     }
  65 
  66     pub fn set_scroll_offset(offset: f32) {
  67         hover_state(|state| {
  68             state.borrow_mut().scroll_offset = offset;
  69         });
  70     }
  71 
  72     pub fn get_scroll_offset() -> f32 {
  73         hover_state(|state| {
  74             state.borrow().scroll_offset
  75         })
  76     }
  77 
  78     pub fn register_hovered(x: f32, y: f32, w: f32, h: f32, color: [f32; 4]) {
  79         hover_state(|state| {
  80             let mut s = state.borrow_mut();
  81             s.target_x = Some(x);
  82             s.target_y = Some(y);
  83             s.target_w = Some(w);
  84             s.target_h = Some(h);
  85             s.target_alpha = color[3];
  86             s.registered_this_frame = true;
  87         });
  88     }
  89 
  90     pub fn post_render_check() {
  91         hover_state(|state| {
  92             let mut s = state.borrow_mut();
  93             if !s.registered_this_frame {
  94                 s.target_alpha = 0.0;
  95                 let (cx, cy) = cursor_state(|pos| *pos.borrow());
  96                 s.target_x = Some(cx);
  97                 s.target_y = Some(cy + s.scroll_offset);
  98                 s.target_w = Some(0.0);
  99                 s.target_h = Some(0.0);
 100             }
 101         });
 102     }
 103 
 104     pub fn tick(dt: f32) -> bool {
 105         hover_state(|state| {
 106             let mut s = state.borrow_mut();
 107             let decay = 15.0;
 108             // The per-tick approach fraction; 1 lands on the target at once,
 109             // which is the whole of animations-off for the highlight.
 110             let k = if crate::motion::enabled() { 1.0 - (-decay * dt).exp() } else { 1.0 };
 111             let mut changed = false;
 112 
 113             if s.current_alpha <= 0.001 && s.target_alpha > 0.0 {
 114                 if let (Some(tx), Some(ty), Some(tw), Some(th)) = (s.target_x, s.target_y, s.target_w, s.target_h) {
 115                     s.current_x = tx;
 116                     s.current_y = ty;
 117                     s.current_w = tw;
 118                     s.current_h = th;
 119                 }
 120             }
 121 
 122             if (s.current_alpha - s.target_alpha).abs() > 0.001 {
 123                 s.current_alpha += (s.target_alpha - s.current_alpha) * k;
 124                 changed = true;
 125             } else if s.current_alpha != s.target_alpha {
 126                 s.current_alpha = s.target_alpha;
 127                 changed = true;
 128             }
 129 
 130             if let (Some(tx), Some(ty), Some(tw), Some(th)) = (s.target_x, s.target_y, s.target_w, s.target_h) {
 131                 if (s.current_x - tx).abs() > 0.1 {
 132                     s.current_x += (tx - s.current_x) * k;
 133                     changed = true;
 134                 } else if s.current_x != tx {
 135                     s.current_x = tx;
 136                     changed = true;
 137                 }
 138 
 139                 if (s.current_y - ty).abs() > 0.1 {
 140                     s.current_y += (ty - s.current_y) * k;
 141                     changed = true;
 142                 } else if s.current_y != ty {
 143                     s.current_y = ty;
 144                     changed = true;
 145                 }
 146 
 147                 if (s.current_w - tw).abs() > 0.1 {
 148                     s.current_w += (tw - s.current_w) * k;
 149                     changed = true;
 150                 } else if s.current_w != tw {
 151                     s.current_w = tw;
 152                     changed = true;
 153                 }
 154 
 155                 if (s.current_h - th).abs() > 0.1 {
 156                     s.current_h += (th - s.current_h) * k;
 157                     changed = true;
 158                 } else if s.current_h != th {
 159                     s.current_h = th;
 160                     changed = true;
 161                 }
 162             }
 163 
 164             changed
 165         })
 166     }
 167 
 168     pub fn get_quad() -> Option<(f32, f32, f32, f32, [f32; 4])> {
 169         hover_state(|state| {
 170             let s = state.borrow();
 171             if s.current_alpha > 0.001 {
 172                 Some((
 173                     s.current_x,
 174                     s.current_y,
 175                     s.current_w,
 176                     s.current_h,
 177                     [1.0, 1.0, 1.0, s.current_alpha],
 178                 ))
 179             } else {
 180                 None
 181             }
 182         })
 183     }
 184 }
 185 
 186 /// The system clipboard, as text: what every widget's copy, cut and paste go
 187 /// through. One synchronous pair, with a backend per platform:
 188 ///
 189 /// - **Wayland** (Linux and the other non-Apple unixes): `wl-copy` /
 190 ///   `wl-paste`, `xclip` where those are missing.
 191 /// - **macOS**: the general `NSPasteboard`, plain-text type.
 192 /// - **A page**: a browser hands a page the clipboard only inside a `paste`
 193 ///   event, so a read is the text of the last one the browser shell saw (it
 194 ///   holds a ⌘/Ctrl+V back until the event has come; `web::shell`), or what
 195 ///   the page itself copied last. A copy writes through the async Clipboard
 196 ///   API where the page is a secure context, and the shell also answers the
 197 ///   `copy` / `cut` event a ⌘/Ctrl+C or X raises with it, which needs none.
 198 ///   Before this a copy in a page panicked (`std::thread::spawn`).
 199 pub mod clipboard {
 200     /// Put `text` on the clipboard.
 201     pub fn copy_to_clipboard(text: &str) {
 202         imp::copy(text);
 203     }
 204 
 205     /// The clipboard's text, if it has any.
 206     pub fn read_from_clipboard() -> Option<String> {
 207         imp::read()
 208     }
 209 
 210     #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
 211     mod imp {
 212         pub fn copy(text: &str) {
 213             let text = text.to_string();
 214             std::thread::spawn(move || {
 215                 if let Ok(mut child) = std::process::Command::new("wl-copy")
 216                     .stdin(std::process::Stdio::piped())
 217                     .spawn()
 218                 {
 219                     if let Some(mut stdin) = child.stdin.take() {
 220                         use std::io::Write;
 221                         let _ = stdin.write_all(text.as_bytes());
 222                     }
 223                     let _ = child.wait();
 224                 } else if let Ok(mut child) = std::process::Command::new("xclip")
 225                     .arg("-selection")
 226                     .arg("clipboard")
 227                     .stdin(std::process::Stdio::piped())
 228                     .spawn()
 229                 {
 230                     if let Some(mut stdin) = child.stdin.take() {
 231                         use std::io::Write;
 232                         let _ = stdin.write_all(text.as_bytes());
 233                     }
 234                     let _ = child.wait();
 235                 }
 236             });
 237         }
 238 
 239         pub fn read() -> Option<String> {
 240             if let Ok(output) = std::process::Command::new("wl-paste")
 241                 .arg("-n")
 242                 .output() {
 243                 if output.status.success() {
 244                     if let Ok(text) = String::from_utf8(output.stdout) {
 245                         return Some(text);
 246                     }
 247                 }
 248             }
 249             if let Ok(output) = std::process::Command::new("xclip")
 250                 .arg("-selection")
 251                 .arg("clipboard")
 252                 .arg("-o")
 253                 .output() {
 254                 if output.status.success() {
 255                     if let Ok(text) = String::from_utf8(output.stdout) {
 256                         return Some(text);
 257                     }
 258                 }
 259             }
 260             None
 261         }
 262     }
 263 
 264     #[cfg(target_os = "macos")]
 265     mod imp {
 266         use objc2_app_kit::{NSPasteboard, NSPasteboardTypeString};
 267         use objc2_foundation::NSString;
 268 
 269         pub fn copy(text: &str) {
 270             let board = NSPasteboard::generalPasteboard();
 271             board.clearContents();
 272             // SAFETY: an extern static AppKit defines.
 273             let ty = unsafe { NSPasteboardTypeString };
 274             board.setString_forType(&NSString::from_str(text), ty);
 275         }
 276 
 277         pub fn read() -> Option<String> {
 278             // SAFETY: as above.
 279             let ty = unsafe { NSPasteboardTypeString };
 280             NSPasteboard::generalPasteboard().stringForType(ty).map(|s| s.to_string())
 281         }
 282     }
 283 
 284     #[cfg(target_arch = "wasm32")]
 285     mod imp {
 286         use std::cell::RefCell;
 287 
 288         #[derive(Default)]
 289         struct Page {
 290             /// What a read answers: the last paste the shell saw, or the
 291             /// page's own last copy, whichever came later.
 292             text: Option<String>,
 293             /// A copy not yet handed to a `copy` / `cut` event.
 294             copied: Option<String>,
 295         }
 296 
 297         thread_local! {
 298             static PAGE: RefCell<Page> = RefCell::new(Page::default());
 299         }
 300 
 301         pub fn copy(text: &str) {
 302             PAGE.with(|p| {
 303                 let mut p = p.borrow_mut();
 304                 p.text = Some(text.to_string());
 305                 p.copied = Some(text.to_string());
 306             });
 307             write_text(text);
 308         }
 309 
 310         pub fn read() -> Option<String> {
 311             PAGE.with(|p| p.borrow().text.clone())
 312         }
 313 
 314         /// Write through the async Clipboard API, if the page has one: it is
 315         /// undefined outside a secure context, and calling into undefined
 316         /// would throw through the wasm frames. Its promise is awaited only
 317         /// to keep a refusal (no user activation, no focus) off the console
 318         /// as an unhandled rejection; the `copy` event path covers it.
 319         fn write_text(text: &str) {
 320             let Some(nav) = web_sys::window().map(|w| w.navigator()) else { return };
 321             let has = js_sys::Reflect::get(&nav, &"clipboard".into()).is_ok_and(|c| !c.is_undefined() && !c.is_null());
 322             if !has {
 323                 return;
 324             }
 325             let promise = nav.clipboard().write_text(text);
 326             wasm_bindgen_futures::spawn_local(async move {
 327                 let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
 328             });
 329         }
 330 
 331         /// The copy a `copy` / `cut` event should carry, taken.
 332         pub(crate) fn take_copied() -> Option<String> {
 333             PAGE.with(|p| p.borrow_mut().copied.take())
 334         }
 335 
 336         /// A `paste` event's text: what reads answer from now on.
 337         pub(crate) fn pasted(text: String) {
 338             PAGE.with(|p| p.borrow_mut().text = Some(text));
 339         }
 340     }
 341 
 342     /// The browser shell's half: the clipboard events it answers.
 343     #[cfg(target_arch = "wasm32")]
 344     pub(crate) use imp::{pasted, take_copied};
 345 }
 346 
 347 pub mod context_menu {
 348     use crate::widget::*;
 349     use std::cell::RefCell;
 350 
 351     /// Height of one menu row. Sizing, both hit tests, the label run and the
 352     /// plate's hover fill all step by this — they were five copies of a bare
 353     /// `24.0`, and a menu whose rows are measured differently from where they
 354     /// are drawn selects the entry above the one under the cursor.
 355     pub const ROW_H: f32 = 24.0;
 356     /// The plate's padding, the same on every side: the rows start this far
 357     /// below the top edge and end this far above the bottom, and the labels
 358     /// sit this far in from the left, with the widest label this far from
 359     /// the right. Before 2026-09-21 the rows ran flush to the top and bottom
 360     /// and the labels had 8px on the left against 16px on the right.
 361     pub const PAD: f32 = 8.0;
 362 
 363     /// The face a menu label is drawn in: the DE's menu font, family and
 364     /// size — the same `menubar_font` the menubar's own drop-downs use
 365     /// ([`crate::widget::container::menu`]). A menu that hardcodes 12.0 and
 366     /// leaves the family unset renders in the default sans while the list or
 367     /// breadcrumb beneath it wears the configured face.
 368     ///
 369     /// Consumers need the family too: a [`TextLabel`] carries only a size, so
 370     /// whoever turns these labels into text prims passes this family alongside
 371     /// them (`pc.text_with(.., Some(family), ..)`).
 372     pub fn label_font() -> (String, f32) {
 373         crate::layout::menubar_font_parsed()
 374     }
 375 
 376     /// The band a slider row draws its control over, logical px.
 377     pub const SLIDER_W: f32 = 120.0;
 378     /// Gap between a slider row's label, its readout and its band.
 379     pub const SLIDER_GAP: f32 = 10.0;
 380 
 381     /// A row that is a SLIDER rather than an action: set on a shown menu with
 382     /// [`set_row_slider`], it keeps the menu open while it is worked. The
 383     /// wheel over the row steps it by `step` a notch (a trackpad's fractional
 384     /// notches accumulate, so a fine swipe still arrives in whole steps); a
 385     /// press on its band jumps to the pointer and drags until the release.
 386     /// Each change is drained by the host through [`take_slider_change`] —
 387     /// the menu has no idea what the value means, as it has none what an
 388     /// action row does.
 389     #[derive(Debug, Clone, Copy, PartialEq)]
 390     pub struct MenuSlider {
 391         pub value: f32,
 392         pub min: f32,
 393         pub max: f32,
 394         /// One wheel notch's change, and the grid a dragged value snaps to
 395         /// (0 = continuous).
 396         pub step: f32,
 397         /// Decimals in the readout.
 398         pub decimals: usize,
 399         /// Appended to the readout: `"%"`, `" mm"`.
 400         pub suffix: &'static str,
 401     }
 402 
 403     impl MenuSlider {
 404         fn clamp_snap(&self, v: f32) -> f32 {
 405             let (lo, hi) = (self.min.min(self.max), self.min.max(self.max));
 406             let v = if self.step > 0.0 { self.min + ((v - self.min) / self.step).round() * self.step } else { v };
 407             v.clamp(lo, hi)
 408         }
 409 
 410         /// What the row shows to the left of its band.
 411         pub fn readout(&self) -> String {
 412             format!("{:.*}{}", self.decimals, self.value, self.suffix)
 413         }
 414     }
 415 
 416     /// The mark a row that leads to a PAGE carries at its right end. Drawn
 417     /// as the `chevron-right` glyph; the character is what a host that lays
 418     /// out its own rows (the designer's dialog) may reserve room by.
 419     pub const PAGE_MARK: &str = "›";
 420     /// What a page's back band leads with, before the title of the plate it
 421     /// goes back to. Drawn as the `chevron-left` glyph.
 422     pub const BACK_MARK: &str = "‹";
 423 
 424     /// A row whose label BEGINS with one of these wears the mark as a
 425     /// cce-icons glyph at its left, and the label is drawn without it: a
 426     /// checked item (`check`), a switch or radio that is on (`circle`), one
 427     /// that is off (`circle-outline`). The text stays the row's identity —
 428     /// a host matching its own labels still matches `"✓ Show Grid"` — and
 429     /// the toolkit owns how a mark looks, so no menu draws one as a
 430     /// character in whatever face its font falls back to.
 431     pub const MARK_CHECK: &str = "✓ ";
 432     /// See [`MARK_CHECK`]: a switch or radio row that is on.
 433     pub const MARK_ON: &str = "● ";
 434     /// See [`MARK_CHECK`]: a switch or radio row that is off.
 435     pub const MARK_OFF: &str = "○ ";
 436 
 437     /// The glyph a label's leading mark names, and the label without it.
 438     /// A row's action read off its English label: the fallback for a menu built without
 439     /// [`set_row_actions`], as every menu was until 2026-10-08. It breaks the moment a label
 440     /// is translated (`docs/rfc-accessibility-locale.md`), so the toolkit's own menus set
 441     /// their actions and this serves only menus that do not.
 442     pub fn legacy_action_for_label(label: &str) -> Option<crate::widget::ContextAction> {
 443         use crate::widget::ContextAction as CA;
 444         Some(match label {
 445             "Cut" => CA::Cut,
 446             "Copy" => CA::Copy,
 447             "Paste" => CA::Paste,
 448             "Select All" => CA::SelectAll,
 449             "Undo" => CA::Undo,
 450             "Redo" => CA::Redo,
 451             "Clear" => CA::ClearText,
 452             "Copy Key" => CA::CopyKey,
 453             "Copy Value" => CA::CopyValue,
 454             "Delete" => CA::DeleteKey,
 455             "Expand" => CA::ExpandNode,
 456             "Collapse" => CA::CollapseNode,
 457             "Expand All" => CA::ExpandAll,
 458             "Collapse All" => CA::CollapseAll,
 459             "Copy Path" => CA::CopyPath,
 460             // The Ramp toggle carries its check state in the label.
 461             "✓ Collapse controls" | "Collapse controls" => CA::ToggleRampControls,
 462             _ => return None,
 463         })
 464     }
 465 
 466     pub fn split_mark(label: &str) -> (Option<&'static str>, &str) {
 467         for (mark, glyph) in [(MARK_CHECK, "check"), (MARK_ON, "circle"), (MARK_OFF, "circle-outline")] {
 468             if let Some(rest) = label.strip_prefix(mark) {
 469                 return (Some(glyph), rest);
 470             }
 471         }
 472         (None, label)
 473     }
 474 
 475     /// How wide a row mark is drawn, at menu font size `size`, and the gap
 476     /// after it: the glyph box at the font size, so the mark (a disc fills
 477     /// three quarters of its box) stands about as tall as a capital.
 478     fn mark_size(size: f32) -> f32 {
 479         (size * 0.95).round()
 480     }
 481     const MARK_GAP: f32 = 6.0;
 482     /// The page and back chevrons, smaller still: they point, they do not
 483     /// label.
 484     fn chevron_size(size: f32) -> f32 {
 485         (size * 0.8).round()
 486     }
 487 
 488     /// A glyph a menu draws: name, top-left, side, colour.
 489     #[derive(Debug, Clone)]
 490     pub(crate) struct MenuGlyph {
 491         pub(crate) name: &'static str,
 492         x: f32,
 493         y: f32,
 494         side: f32,
 495         color: [f32; 4],
 496     }
 497 
 498     /// Draw `g` (shifted by `dx`, at `alpha`): the glyph tinted to the
 499     /// label colour beside it — `PaintCtx::icon`.
 500     fn paint_glyph(ctx: &mut crate::scene::paint::PaintCtx, g: &MenuGlyph, dx: f32, alpha: f32) {
 501         let r = crate::scene::layout::Rect { x: g.x + dx, y: g.y, width: g.side, height: g.side };
 502         let [cr, cg, cb, ca] = g.color;
 503         ctx.icon(g.name, r, [cr, cg, cb, ca * alpha]);
 504     }
 505 
 506     /// A page turn the menu has been asked for, drained by the host with
 507     /// [`take_turn`] (a swipe) or read with [`turn_at`] (a press). The menu
 508     /// does not turn by itself: what a row leads to may be another list of
 509     /// rows or another plate altogether (the designer's dialog), so the host
 510     /// shows it — a list with [`show_page`], which keeps the plate where it
 511     /// stands, so the menu reads as turning into the page rather than as a
 512     /// second menu arriving.
 513     ///
 514     /// Until 2026-10-02 a row could open a SUBMENU, a second menu flying out
 515     /// beside it on hover, while rows that led to another plate swapped it
 516     /// in place on a click: two kinds of row for one idea. A page row is
 517     /// both, and is the only kind.
 518     #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 519     pub enum PageTurn {
 520         /// Into the page row `n` leads to: a press on it, or a side swipe
 521         /// forward with the pointer on it.
 522         Into(usize),
 523         /// Back to the plate this page was turned to from: a press on the
 524         /// back band, or a side swipe back anywhere over the plate.
 525         Back,
 526     }
 527 
 528     /// How long a page turn takes: the plate grows or shrinks from the size
 529     /// of the one it replaces to its own, the rows it had slide out and fade
 530     /// and the page's slide in from the side the turn comes from.
 531     pub const TURN_MS: f32 = 180.0;
 532     /// [`TURN_MS`], or `CCE_UI_TURN_MS` from the environment — a turn in
 533     /// slow motion, to see one frame by frame (a shadow session's capture
 534     /// takes longer than a whole turn).
 535     pub fn turn_ms() -> f32 {
 536         static MS: std::sync::OnceLock<f32> = std::sync::OnceLock::new();
 537         *MS.get_or_init(|| {
 538             std::env::var("CCE_UI_TURN_MS").ok().and_then(|v| v.parse::<f32>().ok()).filter(|v| *v > 0.0).unwrap_or(TURN_MS)
 539         })
 540     }
 541     /// How far the rows slide in a turn, logical px.
 542     pub const TURN_SLIDE: f32 = 36.0;
 543 
 544     /// A page turn in progress — see [`TURN_MS`].
 545     #[derive(Debug, Clone)]
 546     struct Turning {
 547         /// The plate as it stood when the turn began: its size and its rows.
 548         from: Box<ContextMenuState>,
 549         start: web_time::Instant,
 550         /// +1 forward (the page comes in from the right, where `›` points),
 551         /// -1 back.
 552         dir: f32,
 553     }
 554 
 555     /// The ease of a turn at `t` in 0..=1: fast out of the old plate,
 556     /// settling into the new one.
 557     pub fn turn_ease(t: f32) -> f32 {
 558         let u = 1.0 - t.clamp(0.0, 1.0);
 559         1.0 - u * u * u
 560     }
 561 
 562     /// How strongly the rows a turn leaves and the rows it brings are drawn
 563     /// at eased progress `e`: squared, so the two are seldom both legible at
 564     /// once — the old ones mostly gone before the new ones mostly come.
 565     fn turn_fades(e: f32) -> (f32, f32) {
 566         ((1.0 - e) * (1.0 - e), e * e)
 567     }
 568 
 569     /// A toolkit color as the `[u8; 3]` a [`TextLabel`] carries.
 570     fn rgb8(c: [f32; 4]) -> [u8; 3] {
 571         [
 572             (c[0] * 255.0).round().clamp(0.0, 255.0) as u8,
 573             (c[1] * 255.0).round().clamp(0.0, 255.0) as u8,
 574             (c[2] * 255.0).round().clamp(0.0, 255.0) as u8,
 575         ]
 576     }
 577 
 578     #[derive(Debug, Clone)]
 579     pub struct ContextMenuState {
 580         pub x: f32,
 581         pub y: f32,
 582         pub w: f32,
 583         pub h: f32,
 584         pub visible: bool,
 585         pub options: Vec<String>,
 586         pub hovered_item: Option<usize>,
 587         /// The action target, id-keyed (Phase 6bc slice 2): dispatch resolves it through the
 588         /// caller's generational tree, so a stale target is a no-op, not a UAF.
 589         pub target: Option<WidgetId>,
 590         pub header_count: usize,
 591         /// Slider rows by index, parallel to `options` — see [`MenuSlider`].
 592         /// Emptied by every `show`, so a menu's sliders are the ones its
 593         /// host set this time.
 594         pub sliders: Vec<Option<MenuSlider>>,
 595         /// Rows that lead to a PAGE, parallel to `options` — see
 596         /// [`set_row_page`](Self::set_row_page). Emptied by every `show`.
 597         pub pages: Vec<bool>,
 598         /// What each row DOES, parallel to `options` — see
 599         /// [`set_row_actions`](Self::set_row_actions). A row's action is its identity; its
 600         /// label is only what is shown, so a translated label still runs the action.
 601         /// Emptied by every `show`.
 602         pub actions: Vec<Option<crate::widget::ContextAction>>,
 603         /// On a page: the title of the plate it was turned to from, which
 604         /// the back band across its top reads as `‹ Title`. `None` on a menu
 605         /// that was opened rather than turned to.
 606         pub back: Option<String>,
 607         /// The pointer is on the back band.
 608         pub back_hovered: bool,
 609         /// Shown by [`show_page`](Self::show_page), in place of the plate it
 610         /// turned from: a placement keeps its corner where that plate's
 611         /// was, sliding it on screen rather than flipping it.
 612         pub turned: bool,
 613         /// A turn a swipe asked for, until the host takes it.
 614         turn: Option<PageTurn>,
 615         /// The turn being animated, from the plate this one replaced.
 616         turning: Option<Turning>,
 617         /// When the menu was last hidden: a page shown in the same moment
 618         /// turns from it, as a host that closes one menu and shows the next
 619         /// in one dispatch means it to.
 620         hidden_at: Option<web_time::Instant>,
 621         /// The slider row a press took hold of, until the release.
 622         pub slider_drag: Option<usize>,
 623         /// A trackpad's leftover fraction of a wheel notch.
 624         wheel_accum: f32,
 625         /// The last value a slider was moved to, drained by the host.
 626         slider_change: Option<(usize, f32)>,
 627         /// The point `show` opened the menu at — the anchor a placement
 628         /// flips and slides from. `x`/`y` are where the menu IS.
 629         pub anchor: (f32, f32),
 630         /// Height of every row plus the padding: the menu's natural height.
 631         /// `h` is the height it is SHOWN at, which a placement may cut down
 632         /// to fit the screen; the rows then scroll.
 633         pub content_h: f32,
 634         /// How far the rows are scrolled up, 0..=`content_h - h`.
 635         pub scroll: f32,
 636         /// Bumped by every `show`, so a host mirroring the menu elsewhere (the
 637         /// runner's popup surface) can tell a re-show from a repaint.
 638         pub generation: u64,
 639         /// The menu is drawn in its own popup surface by the runner, so the
 640         /// in-window paint calls ([`paint`](Self::paint), [`text_labels`],
 641         /// [`extra_quads`]) draw nothing — every app still makes them, and a
 642         /// second copy in the window would show through under the popup.
 643         /// Hit testing is unaffected: the popup routes its pointer events
 644         /// back into window coordinates, where the rect is.
 645         ///
 646         /// [`text_labels`]: Self::text_labels
 647         /// [`extra_quads`]: Self::extra_quads
 648         pub hosted: bool,
 649         /// The pointer's last place over the menu, to re-hover after a scroll
 650         /// moves a different row under it.
 651         last_cursor: Option<(f32, f32)>,
 652         /// Being painted into the popup surface, where the plate is the
 653         /// surface's ROOT: frosted by the compositor's blur-behind rather
 654         /// than the in-app pass, which has no backdrop to sample there — the
 655         /// popup's own frame is empty behind the plate, and the in-app frost
 656         /// of nothing is a flat opaque grey.
 657         in_popup: bool,
 658     }
 659 
 660     impl ContextMenuState {
 661         pub fn new() -> Self {
 662             Self {
 663                 x: 0.0,
 664                 y: 0.0,
 665                 w: 120.0,
 666                 h: 0.0,
 667                 visible: false,
 668                 options: Vec::new(),
 669                 hovered_item: None,
 670                 target: None,
 671                 header_count: 0,
 672                 sliders: Vec::new(),
 673                 pages: Vec::new(),
 674                 actions: Vec::new(),
 675                 back: None,
 676                 back_hovered: false,
 677                 turned: false,
 678                 turn: None,
 679                 turning: None,
 680                 hidden_at: None,
 681                 slider_drag: None,
 682                 wheel_accum: 0.0,
 683                 slider_change: None,
 684                 anchor: (0.0, 0.0),
 685                 content_h: 0.0,
 686                 scroll: 0.0,
 687                 generation: 0,
 688                 hosted: false,
 689                 last_cursor: None,
 690                 in_popup: false,
 691             }
 692         }
 693 
 694         pub fn show(&mut self, x: f32, y: f32, options: Vec<String>, header_count: usize, target: WidgetId) {
 695             self.x = x;
 696             self.y = y;
 697             self.anchor = (x, y);
 698             self.options = options;
 699             self.content_h = self.options.len() as f32 * ROW_H + 2.0 * PAD;
 700             self.h = self.content_h;
 701             self.scroll = 0.0;
 702             self.last_cursor = None;
 703             self.generation = self.generation.wrapping_add(1);
 704             // Width from the widest label as the RENDERER shapes it —
 705             // `shaped_cluster_offsets`, the same cosmic-text buffer cache the
 706             // draw reads — not `measure_text_width`. That one rasterizes an
 707             // SVG through fontdb and reports inked extent in the named face
 708             // alone: a glyph the face lacks (the radio marks "●" / "○" the
 709             // designer's pin rows carry, which Berkeley Mono has not) measures
 710             // as next to nothing while the draw lands it from a fallback face
 711             // a full advance wide, and the label ran off the plate's right
 712             // edge (2026-09-21). The inked measure is kept as a floor, so a
 713             // host whose font system has no bundled faces never measures
 714             // narrower than before.
 715             let (family, size) = label_font();
 716             let widest = self
 717                 .options
 718                 .iter()
 719                 .map(|s| {
 720                     let (mark, s) = split_mark(s);
 721                     let mark_w = if mark.is_some() { mark_size(size) + MARK_GAP } else { 0.0 };
 722                     let inked = crate::widget::display::measure_text_width(s, &family, size);
 723                     let shaped = crate::geometry_font_system()
 724                         .lock()
 725                         .ok()
 726                         .and_then(|mut fs| {
 727                             crate::backend::text::shaped_cluster_offsets(&mut fs, s, size, Some(&family))
 728                                 .last()
 729                                 .map(|&(_, total)| total)
 730                         })
 731                         .unwrap_or(0.0);
 732                     mark_w + inked.max(shaped)
 733                 })
 734                 .fold(0.0f32, f32::max);
 735             self.w = (widest + 2.0 * PAD).max(120.0);
 736             self.visible = true;
 737             self.hovered_item = None;
 738             self.target = Some(target);
 739             self.header_count = header_count;
 740             self.sliders = vec![None; self.options.len()];
 741             self.pages = vec![false; self.options.len()];
 742             self.actions = vec![None; self.options.len()];
 743             self.back = None;
 744             self.back_hovered = false;
 745             self.turned = false;
 746             self.turn = None;
 747             self.turning = None;
 748             self.slider_drag = None;
 749             self.wheel_accum = 0.0;
 750             self.slider_change = None;
 751         }
 752 
 753         /// Make row `idx` lead to a PAGE — see [`PageTurn`]. It wears
 754         /// [`PAGE_MARK`] at its right end; the plate widens for it.
 755         /// Give the rows their actions, parallel to `options` (a header or a row the host
 756         /// handles itself is `None`); called after `show`, like `set_row_page`. A press on a
 757         /// row runs its action on the menu's target. Rows with none fall back to matching
 758         /// the label (`legacy_action_for_label`), which only works in English: build
 759         /// menus with actions.
 760         pub fn set_row_actions(&mut self, actions: Vec<Option<crate::widget::ContextAction>>) {
 761             let n = self.options.len();
 762             self.actions = actions;
 763             self.actions.resize(n, None);
 764         }
 765 
 766         pub fn set_row_page(&mut self, idx: usize) {
 767             if idx >= self.options.len() {
 768                 return;
 769             }
 770             let (family, size) = label_font();
 771             let measure = |t: &str| crate::widget::display::measure_text_width(t, &family, size);
 772             let (mark, label) = split_mark(&self.options[idx]);
 773             let mark_w = if mark.is_some() { mark_size(size) + MARK_GAP } else { 0.0 };
 774             let need = PAD + mark_w + measure(label) + SLIDER_GAP + chevron_size(size) + PAD;
 775             self.w = self.w.max(need);
 776             self.pages[idx] = true;
 777         }
 778 
 779         /// Whether row `idx` leads to a page.
 780         pub fn leads_to_page(&self, idx: usize) -> bool {
 781             self.pages.get(idx).copied().unwrap_or(false)
 782         }
 783 
 784         /// Show `options` as a PAGE, the plate's top-left at `(x, y)` — where
 785         /// the plate it turns from stood — with a back band reading `‹ back`
 786         /// across its top when `back` names that plate. The rows are new
 787         /// rows (sliders and page rows are set again after, as after
 788         /// [`show`](Self::show)); what changes is that a placement keeps the
 789         /// corner rather than opening from a pointer.
 790         ///
 791         /// The turn is ANIMATED from the plate that stood there — up, or
 792         /// hidden in the same moment (`TURN_HANDOFF`) — forward for a page
 793         /// with a back band, back for one without (a menu returned to).
 794         pub fn show_page(&mut self, x: f32, y: f32, back: Option<&str>, options: Vec<String>, header_count: usize, target: WidgetId) {
 795             const TURN_HANDOFF: std::time::Duration = std::time::Duration::from_millis(100);
 796             let from = (self.visible || self.hidden_at.is_some_and(|t| t.elapsed() < TURN_HANDOFF)).then(|| {
 797                 let mut from = self.clone();
 798                 from.turning = None;
 799                 from.hovered_item = None;
 800                 from.back_hovered = false;
 801                 Box::new(from)
 802             });
 803             self.show(x, y, options, header_count, target);
 804             self.turned = true;
 805             self.turning = from.map(|from| Turning {
 806                 from,
 807                 start: web_time::Instant::now(),
 808                 dir: if back.is_some() { 1.0 } else { -1.0 },
 809             });
 810             if let Some(title) = back {
 811                 let (family, size) = label_font();
 812                 let need = PAD + chevron_size(size) + MARK_GAP + crate::widget::display::measure_text_width(title, &family, size) + PAD;
 813                 self.w = self.w.max(need);
 814                 self.back = Some(title.to_string());
 815                 self.content_h += ROW_H;
 816                 self.h = self.content_h;
 817             }
 818         }
 819 
 820         /// New labels and slider values for the rows that are showing, in
 821         /// place — the hover, the scroll, the back band, the page rows and a
 822         /// held slider are kept — which is how a host re-marks a row of a
 823         /// menu that stays up after the row ran. `false`, and nothing
 824         /// changed, when the number of rows differs: that is another menu,
 825         /// to be shown afresh.
 826         pub fn refill(&mut self, options: Vec<String>, sliders: &[Option<MenuSlider>]) -> bool {
 827             if options.len() != self.options.len() {
 828                 return false;
 829             }
 830             self.options = options;
 831             for i in 0..self.options.len() {
 832                 // A held slider is the pointer's until the release.
 833                 if self.slider_drag == Some(i) {
 834                     continue;
 835                 }
 836                 self.sliders[i] = sliders.get(i).copied().flatten();
 837             }
 838             true
 839         }
 840 
 841         /// Animate the shown menu as turning from a plate of `w` x `h` at its
 842         /// corner that had no rows of its own to show going — what a host
 843         /// turning back from a plate the menu does not draw (a dialog) asks
 844         /// for. `forward` as for [`Self::show_page`].
 845         pub fn turn_from_size(&mut self, w: f32, h: f32, forward: bool) {
 846             let mut from = self.clone();
 847             from.turning = None;
 848             from.options.clear();
 849             from.sliders.clear();
 850             from.pages.clear();
 851             from.back = None;
 852             from.hovered_item = None;
 853             from.w = w;
 854             from.h = h;
 855             self.turning = Some(Turning { from: Box::new(from), start: web_time::Instant::now(), dir: if forward { 1.0 } else { -1.0 } });
 856         }
 857 
 858         /// How far the turn in progress has gone, eased, 0..1; `None` when
 859         /// there is none or it has finished.
 860         pub fn turn_progress(&self) -> Option<f32> {
 861             let t = self.turning.as_ref()?;
 862             let raw = t.start.elapsed().as_secs_f32() * 1000.0 / turn_ms();
 863             (raw < 1.0).then(|| turn_ease(raw))
 864         }
 865 
 866         /// The rect the plate is drawn at: its own, or on its way there from
 867         /// the one it turned from.
 868         pub fn drawn_rect(&self) -> crate::scene::layout::Rect {
 869             let mut r = crate::scene::layout::Rect { x: self.x, y: self.y, width: self.w, height: self.h };
 870             if let (Some(e), Some(t)) = (self.turn_progress(), self.turning.as_ref()) {
 871                 r.width = t.from.w + (self.w - t.from.w) * e;
 872                 r.height = t.from.h + (self.h - t.from.h) * e;
 873             }
 874             r
 875         }
 876 
 877         /// The size a host surface has to give the plate: its own, or while
 878         /// it turns, the larger of the two it turns between.
 879         pub fn surface_size(&self) -> (f32, f32) {
 880             match (self.turn_progress(), self.turning.as_ref()) {
 881                 (Some(_), Some(t)) => (self.w.max(t.from.w), self.content_h.max(t.from.h)),
 882                 _ => (self.w, self.content_h),
 883             }
 884         }
 885 
 886         /// The band's height above the rows: one row on a page that goes
 887         /// back somewhere, none otherwise.
 888         fn band_h(&self) -> f32 {
 889             if self.back.is_some() { ROW_H } else { 0.0 }
 890         }
 891 
 892         /// The top of the back band, where it is drawn.
 893         pub fn back_band_y(&self) -> f32 {
 894             self.y + PAD - self.scroll
 895         }
 896 
 897         /// Whether `(px, py)` is on the back band.
 898         pub fn on_back_band(&self, px: f32, py: f32) -> bool {
 899             if self.back.is_none() || !self.hit_test(px, py) {
 900                 return false;
 901             }
 902             let top = self.back_band_y();
 903             py >= top && py < top + ROW_H
 904         }
 905 
 906         /// The turn a press at `(px, py)` asks for: the back band goes back,
 907         /// a page row goes into its page.
 908         pub fn turn_at(&self, px: f32, py: f32) -> Option<PageTurn> {
 909             if !self.visible {
 910                 return None;
 911             }
 912             if self.on_back_band(px, py) {
 913                 return Some(PageTurn::Back);
 914             }
 915             self.row_at(px, py).filter(|&i| self.leads_to_page(i)).map(PageTurn::Into)
 916         }
 917 
 918         /// The turn a side swipe asked for since the last call.
 919         pub fn take_turn(&mut self) -> Option<PageTurn> {
 920             self.turn.take()
 921         }
 922 
 923         /// Make row `idx` a slider. Widens the plate to hold the label, the
 924         /// readout at its widest (both ends of the range) and the band.
 925         pub fn set_row_slider(&mut self, idx: usize, slider: MenuSlider) {
 926             if idx >= self.options.len() {
 927                 return;
 928             }
 929             self.sliders[idx] = Some(slider);
 930             let (family, size) = label_font();
 931             let measure = |t: &str| crate::widget::display::measure_text_width(t, &family, size);
 932             let readout_w = [slider.min, slider.max]
 933                 .iter()
 934                 .map(|&v| measure(&MenuSlider { value: v, ..slider }.readout()))
 935                 .fold(0.0f32, f32::max);
 936             let need = PAD + measure(&self.options[idx]) + SLIDER_GAP + readout_w + SLIDER_GAP + SLIDER_W + PAD;
 937             self.w = self.w.max(need);
 938         }
 939 
 940         /// Put the menu at `(x, y)`, shown at most `max_h` tall: the rows
 941         /// scroll when that cuts them off. Never shorter than one row, so a
 942         /// placement that leaves no room still shows something to scroll.
 943         /// Where the popup's configure lands the menu, and the in-window
 944         /// fallback's [`constrain_to`](Self::constrain_to).
 945         pub fn place(&mut self, x: f32, y: f32, max_h: f32) {
 946             self.x = x;
 947             self.y = y;
 948             let floor = self.content_h.min(ROW_H + 2.0 * PAD);
 949             self.h = self.content_h.min(max_h).max(floor);
 950             self.scroll = self.scroll.clamp(0.0, self.max_scroll());
 951         }
 952 
 953         /// Keep the menu inside `(bx, by, bw, bh)` the way an xdg positioner
 954         /// with flip-y, slide-x, slide-y and resize-y does, from the anchor
 955         /// `show` was given: it opens down and right; if it does not fit
 956         /// below, it flips to open UP from the anchor; if it fits neither
 957         /// way it slides to the bottom edge, and if it is taller than the
 958         /// whole box it is cut to the box and scrolls. Recomputed from the
 959         /// anchor every call, so it can run every frame. For hosts with no
 960         /// popup surface (a layer surface, or the popup disabled) — there the
 961         /// window is the only room there is.
 962         pub fn constrain_to(&mut self, bx: f32, by: f32, bw: f32, bh: f32) {
 963             let (ax, ay) = self.anchor;
 964             let x = if ax + self.w > bx + bw { (bx + bw - self.w).max(bx) } else { ax.max(bx) };
 965             let (y, max_h) = if ay + self.content_h <= by + bh {
 966                 (ay.max(by), self.content_h)
 967             } else if !self.turned && ay - self.content_h >= by {
 968                 (ay - self.content_h, self.content_h)
 969             } else {
 970                 ((by + bh - self.content_h).max(by), bh)
 971             };
 972             self.place(x, y, max_h);
 973         }
 974 
 975         /// How far the rows can scroll: zero when the menu shows them all.
 976         pub fn max_scroll(&self) -> f32 {
 977             (self.content_h - self.h).max(0.0)
 978         }
 979 
 980         /// Whether a press on row `idx` could run it: not a header, not a
 981         /// separator.
 982         fn actionable(&self, idx: usize) -> bool {
 983             idx >= self.header_count && self.options.get(idx).is_some_and(|o| o != "-")
 984         }
 985 
 986         /// Highlight row `idx` as the pointer would, scrolled into view —
 987         /// the keyboard's hover. `None`, or a row that cannot run, clears it.
 988         pub fn set_hovered_item(&mut self, idx: Option<usize>) {
 989             self.hovered_item = idx.filter(|&i| self.actionable(i));
 990             if let Some(i) = self.hovered_item {
 991                 self.scroll_into_view(i);
 992             }
 993         }
 994 
 995         /// Move the highlight to the next row that can run, `dir` > 0 down
 996         /// and < 0 up, skipping headers and separators; from no highlight,
 997         /// the first (or last) such row. Stops at either end rather than
 998         /// wrapping. Returns the highlighted row.
 999         pub fn step_hovered(&mut self, dir: i32) -> Option<usize> {
1000             let n = self.options.len() as i32;
1001             let step = if dir < 0 { -1 } else { 1 };
1002             let mut i = match self.hovered_item {
1003                 Some(h) => h as i32,
1004                 None if step > 0 => -1,
1005                 None => n,
1006             };
1007             loop {
1008                 i += step;
1009                 if i < 0 || i >= n {
1010                     return self.hovered_item;
1011                 }
1012                 if self.actionable(i as usize) {
1013                     self.set_hovered_item(Some(i as usize));
1014                     return self.hovered_item;
1015                 }
1016             }
1017         }
1018 
1019         /// Scroll so row `idx` is wholly inside the plate. Unlike
1020         /// [`Self::scroll_by`] this does not re-hover the row under the
1021         /// pointer: the keyboard put the highlight where it is.
1022         fn scroll_into_view(&mut self, idx: usize) {
1023             let top = self.band_h() + idx as f32 * ROW_H;
1024             let bottom = top + ROW_H + 2.0 * PAD;
1025             if top < self.scroll {
1026                 self.scroll = top;
1027             } else if bottom > self.scroll + self.h {
1028                 self.scroll = bottom - self.h;
1029             }
1030             self.scroll = self.scroll.clamp(0.0, self.max_scroll());
1031         }
1032 
1033         /// Scroll the rows by `dy` px (positive shows rows further down),
1034         /// clamped; re-hovers whatever row the pointer now sits on. `true`
1035         /// when anything moved.
1036         pub fn scroll_by(&mut self, dy: f32) -> bool {
1037             let next = (self.scroll + dy).clamp(0.0, self.max_scroll());
1038             if (next - self.scroll).abs() < f32::EPSILON {
1039                 return false;
1040             }
1041             self.scroll = next;
1042             if let Some((px, py)) = self.last_cursor {
1043                 self.rehover(px, py);
1044             }
1045             true
1046         }
1047 
1048         /// The slider on row `idx`, if it is one.
1049         pub fn slider(&self, idx: usize) -> Option<MenuSlider> {
1050             self.sliders.get(idx).copied().flatten()
1051         }
1052 
1053         /// The band row `idx`'s slider draws over and a press grabs.
1054         pub fn slider_band(&self, idx: usize) -> crate::scene::layout::Rect {
1055             crate::scene::layout::Rect {
1056                 x: self.x + self.w - PAD - SLIDER_W,
1057                 y: self.row_y(idx) + 3.0,
1058                 width: SLIDER_W,
1059                 height: ROW_H - 6.0,
1060             }
1061         }
1062 
1063         fn set_slider_value(&mut self, idx: usize, v: f32) -> bool {
1064             let Some(Some(s)) = self.sliders.get_mut(idx) else { return false };
1065             let v = s.clamp_snap(v);
1066             if (v - s.value).abs() < f32::EPSILON {
1067                 return false;
1068             }
1069             s.value = v;
1070             self.slider_change = Some((idx, v));
1071             true
1072         }
1073 
1074         /// The wheel over a slider row steps it; anywhere else it does
1075         /// nothing and says so. Up is more, as on every slider in the DE.
1076         pub fn mouse_wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32) -> bool {
1077             if !self.visible {
1078                 return false;
1079             }
1080             // A side swipe turns a page: forward with the pointer on a page
1081             // row, back from anywhere on a page that has somewhere to go.
1082             if self.hit_test(px, py) && self.slider_drag.is_none() {
1083                 let turn = match crate::widget::side_swipe::feed(delta) {
1084                     Some(crate::widget::SwipeDir::Forward) => {
1085                         self.row_at(px, py).filter(|&i| self.leads_to_page(i)).map(PageTurn::Into)
1086                     }
1087                     Some(crate::widget::SwipeDir::Back) => self.back.is_some().then_some(PageTurn::Back),
1088                     None => None,
1089                 };
1090                 if turn.is_some() {
1091                     self.turn = turn;
1092                     return true;
1093                 }
1094             }
1095             let Some(idx) = self.row_at(px, py) else { return false };
1096             let Some(s) = self.slider(idx) else {
1097                 // Not a slider: a menu cut down to fit scrolls its rows.
1098                 // Up shows the rows above, as every list in the DE does.
1099                 if self.max_scroll() <= 0.0 {
1100                     return false;
1101                 }
1102                 self.scroll_by(-delta.notches_y() * ROW_H);
1103                 return true;
1104             };
1105             self.wheel_accum += delta.value_notches_y();
1106             let whole = self.wheel_accum.trunc();
1107             if whole == 0.0 {
1108                 return false;
1109             }
1110             self.wheel_accum -= whole;
1111             let step = if s.step > 0.0 { s.step } else { (s.max - s.min) * 0.02 };
1112             self.set_slider_value(idx, s.value + whole * step)
1113         }
1114 
1115         /// A left press on a slider row: on the band it takes hold and jumps
1116         /// the value to the pointer; anywhere on the row it is the slider's
1117         /// and the menu stays open. `false` for any other row.
1118         pub fn slider_press(&mut self, px: f32, py: f32) -> bool {
1119             if !self.visible {
1120                 return false;
1121             }
1122             let Some(idx) = self.row_at(px, py) else { return false };
1123             if self.slider(idx).is_none() {
1124                 return false;
1125             }
1126             let band = self.slider_band(idx);
1127             if px >= band.x && px <= band.x + band.width {
1128                 self.slider_drag = Some(idx);
1129                 self.slider_drag_to(px);
1130             }
1131             true
1132         }
1133 
1134         /// Move the held slider to the pointer's place along its band —
1135         /// wherever the pointer is, so a drag that leaves the plate keeps
1136         /// working. `false` when nothing is held or nothing moved.
1137         pub fn slider_drag_to(&mut self, px: f32) -> bool {
1138             let Some(idx) = self.slider_drag else { return false };
1139             let Some(s) = self.slider(idx) else { return false };
1140             let band = self.slider_band(idx);
1141             let t = ((px - band.x) / band.width.max(1.0)).clamp(0.0, 1.0);
1142             self.set_slider_value(idx, s.min + t * (s.max - s.min))
1143         }
1144 
1145         /// End a slider drag; `true` if one was held.
1146         pub fn slider_release(&mut self) -> bool {
1147             self.slider_drag.take().is_some()
1148         }
1149 
1150         pub fn take_slider_change(&mut self) -> Option<(usize, f32)> {
1151             self.slider_change.take()
1152         }
1153 
1154         pub fn hide(&mut self) {
1155             self.visible = false;
1156             self.target = None;
1157             self.last_cursor = None;
1158             self.slider_drag = None;
1159             self.back_hovered = false;
1160             self.turn = None;
1161             self.turning = None;
1162             self.hidden_at = Some(web_time::Instant::now());
1163         }
1164 
1165         pub fn hit_test(&self, px: f32, py: f32) -> bool {
1166             if !self.visible { return false; }
1167             px >= self.x && px <= self.x + self.w && py >= self.y && py <= self.y + self.h
1168         }
1169 
1170         /// The top of row `idx`, where it is drawn: scrolled, so a row above
1171         /// the view lies above `y`.
1172         pub fn row_y(&self, idx: usize) -> f32 {
1173             self.y + PAD + self.band_h() + idx as f32 * ROW_H - self.scroll
1174         }
1175 
1176         /// The row under `(px, py)`, or `None` outside the plate or in its
1177         /// padding — the padding is plate, not a row, so a press there
1178         /// neither hovers nor fires row 0.
1179         pub fn row_at(&self, px: f32, py: f32) -> Option<usize> {
1180             if px < self.x || px > self.x + self.w || py < self.y || py > self.y + self.h {
1181                 return None;
1182             }
1183             let rel = py - self.y - PAD - self.band_h() + self.scroll;
1184             if rel < 0.0 {
1185                 return None;
1186             }
1187             let idx = (rel / ROW_H) as usize;
1188             (idx < self.options.len()).then_some(idx)
1189         }
1190 
1191         pub fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
1192             if !self.visible { return false; }
1193             self.last_cursor = Some((px, py));
1194             if self.slider_drag.is_some() {
1195                 return self.slider_drag_to(px);
1196             }
1197             let was = (self.hovered_item, self.back_hovered);
1198             self.rehover(px, py);
1199             (self.hovered_item, self.back_hovered) != was
1200         }
1201 
1202         fn rehover(&mut self, px: f32, py: f32) -> bool {
1203             let was_hovered = (self.hovered_item, self.back_hovered);
1204             self.hovered_item = None;
1205             self.back_hovered = self.on_back_band(px, py);
1206             if let Some(idx) = self.row_at(px, py) {
1207                 // A "-" row is a SEPARATOR (the dropdown's convention):
1208                 // engraved, never hovered, never an action.
1209                 if idx >= self.header_count && self.options[idx] != "-" {
1210                     self.hovered_item = Some(idx);
1211                 }
1212             }
1213             (self.hovered_item, self.back_hovered) != was_hovered
1214         }
1215 
1216         pub fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ctx: Option<&mut crate::context::UiContext>) -> bool {
1217             if !self.visible { return false; }
1218             if button == MouseButton::Left && state == ElementState::Released && self.slider_release() {
1219                 return true;
1220             }
1221             if button == MouseButton::Left && state == ElementState::Pressed && self.slider_press(px, py) {
1222                 return true;
1223             }
1224             if button != MouseButton::Left || state != ElementState::Pressed {
1225                 if state == ElementState::Pressed {
1226                     self.hide();
1227                     return true;
1228                 }
1229                 return false;
1230             }
1231 
1232             if let Some(turn) = self.turn_at(px, py) {
1233                 self.turn = Some(turn);
1234                 return true;
1235             }
1236             if self.hit_test(px, py) {
1237                 if let Some(idx) = self.row_at(px, py) {
1238                     if idx >= self.header_count {
1239                         let opt = self.options[idx].clone();
1240                         if let (Some(target_id), Some(ctx)) = (self.target, ctx) {
1241                             if let Some(target) = ctx.get_widget_mut(target_id) {
1242                                 let action = self.actions.get(idx).copied().flatten().or_else(|| legacy_action_for_label(&opt));
1243                                 if let Some(action) = action {
1244                                     let _ = target.context_action(action);
1245                                 }
1246                             }
1247                         }
1248                     }
1249                 }
1250                 self.hide();
1251                 true
1252             } else {
1253                 self.hide();
1254                 true
1255             }
1256         }
1257 
1258         /// Paint the menu as a lit plate: a rounded face in the DE's plate color,
1259         /// translucent and frosted (the negative-alpha blur-behind sentinel), with
1260         /// the rolled perimeter — the material every other floating surface in the
1261         /// DE wears. Hosts on the display-list path call this INSTEAD of iterating
1262         /// [`ContextMenuState::extra_quads`], then draw [`text_labels`] over it.
1263         ///
1264         /// A transparent configured plate color degrades to the edges-only boss,
1265         /// as the breadcrumb's raised run does: with no face to tint, a plate
1266         /// would paint a hole.
1267         ///
1268         /// [`text_labels`]: ContextMenuState::text_labels
1269         pub fn paint(&self, ctx: &mut crate::scene::paint::PaintCtx) {
1270             if !self.visible || self.hosted {
1271                 return;
1272             }
1273             let r = crate::layout::menu_corner_radius();
1274             if let (Some(e), Some(t)) = (self.turn_progress(), self.turning.as_ref()) {
1275                 // Turning: the plate on its way between the two sizes; the
1276                 // rows it turned from — sliders, separators — sliding away
1277                 // and fading, the page's sliding in from the side the turn
1278                 // comes from and coming up. Each set is drawn aside and
1279                 // replayed moved and faded (`Prim::faded`); their labels
1280                 // are `paint_with_labels`', at the same strengths.
1281                 let rect = self.drawn_rect();
1282                 let depth = crate::layout::bevel_width().min(rect.height * 0.2);
1283                 paint_menu_plate(ctx, rect, self.in_popup);
1284                 let (out, into) = turn_fades(e);
1285                 let (lo, hi) = (-t.dir * e * TURN_SLIDE, t.dir * (1.0 - e) * TURN_SLIDE);
1286                 ctx.clip_rounded(rect, r, |ctx| {
1287                     for (rows, dx, alpha) in [(&*t.from, lo, out), (self, hi, into)] {
1288                         let mut aside = crate::scene::paint::PaintCtx::new();
1289                         rows.paint_rows(&mut aside, rect, r, depth);
1290                         ctx.translate(dx, 0.0, |ctx| {
1291                             for item in aside.finish().items {
1292                                 if let Some(c) = item.clip {
1293                                     ctx.push_clip(c);
1294                                 }
1295                                 // The rows draw no text; a label would be
1296                                 // doubled with `paint_with_labels`'.
1297                                 let _ = ctx.replay(item.prim.faded(alpha));
1298                                 if item.clip.is_some() {
1299                                     ctx.pop_clip();
1300                                 }
1301                             }
1302                         });
1303                     }
1304                 });
1305                 return;
1306             }
1307             let rect = crate::scene::layout::Rect {
1308                 x: self.x,
1309                 y: self.y,
1310                 width: self.w,
1311                 height: self.h,
1312             };
1313             let depth = crate::layout::bevel_width().min(self.h * 0.2);
1314             paint_menu_plate(ctx, rect, self.in_popup);
1315 
1316             // What the rows draw — hover, separators, slider bands — is cut at
1317             // the plate, so a row scrolled half out of a shortened menu stops
1318             // at its edge instead of hanging off it.
1319             ctx.clip_rounded(rect, r, |ctx| self.paint_rows(ctx, rect, r, depth));
1320             if self.max_scroll() > 0.0 {
1321                 // A scrolled menu says so: a thumb in the right padding, as
1322                 // long against the plate as the view is against the rows.
1323                 let track = (rect.y + PAD, rect.height - 2.0 * PAD);
1324                 let len = (track.1 * self.h / self.content_h).max(12.0).min(track.1);
1325                 let at = track.0 + (track.1 - len) * (self.scroll / self.max_scroll());
1326                 let tw = 3.0;
1327                 let c = crate::color::TEXT_DIM;
1328                 ctx.rounded_rect(
1329                     crate::scene::layout::Rect { x: rect.x + rect.width - PAD * 0.5 - tw * 0.5, y: at, width: tw, height: len },
1330                     tw * 0.5,
1331                     (true, true, true, true),
1332                     [c[0], c[1], c[2], 0.6],
1333                 );
1334             }
1335         }
1336 
1337         fn paint_rows(&self, ctx: &mut crate::scene::paint::PaintCtx, rect: crate::scene::layout::Rect, r: f32, depth: f32) {
1338             if self.back.is_some() {
1339                 // The back band: lit like a row under the pointer, and cut off
1340                 // from the page's rows by the separator's groove.
1341                 let top = self.back_band_y();
1342                 if self.back_hovered {
1343                     let inset = (depth * 0.5).max(2.0).max(PAD * 0.5);
1344                     ctx.rounded_rect(
1345                         crate::scene::layout::Rect { x: self.x + inset, y: top + 2.0, width: self.w - 2.0 * inset, height: ROW_H - 4.0 },
1346                         (r - inset).max(0.0),
1347                         (true, true, true, true),
1348                         [0.20, 0.40, 0.65, 0.6],
1349                     );
1350                 }
1351                 let inset = (depth * 0.5).max(PAD);
1352                 let cy = top + ROW_H;
1353                 ctx.groove((self.x + inset, cy), (self.x + self.w - inset, cy), 0.75, depth, rect);
1354             }
1355             if let Some(h_idx) = self.hovered_item {
1356                 // Inset off the roll so the fill sits on the face instead of
1357                 // climbing the lit edge, and round the corners it actually meets:
1358                 // the first and last rows touch the plate's, and a header row is
1359                 // never hovered, so the top pair only rounds when there is no
1360                 // header above.
1361                 // Inside the padding on every side — the rows no longer
1362                 // touch the plate's edge, so the fill is its own rounded
1363                 // tablet on the face rather than a band that meets the roll.
1364                 let iy = self.row_y(h_idx);
1365                 let inset = (depth * 0.5).max(2.0).max(PAD * 0.5);
1366                 ctx.rounded_rect(
1367                     crate::scene::layout::Rect {
1368                         x: self.x + inset,
1369                         y: iy + 2.0,
1370                         width: self.w - 2.0 * inset,
1371                         height: ROW_H - 4.0,
1372                     },
1373                     (r - inset).max(0.0),
1374                     (true, true, true, true),
1375                     [0.20, 0.40, 0.65, 0.6],
1376                 );
1377             }
1378 
1379             // Separator rows ("-"): an engraved line across the face at the
1380             // row's vertical centre — the breadcrumb seam's language, cut
1381             // into the menu plate instead of a printed dash.
1382             for (idx, opt) in self.options.iter().enumerate() {
1383                 if opt == "-" {
1384                     let cy = self.row_y(idx) + ROW_H * 0.5;
1385                     let inset = (depth * 0.5).max(PAD);
1386                     ctx.groove(
1387                         (self.x + inset, cy),
1388                         (self.x + self.w - inset, cy),
1389                         0.75,
1390                         depth,
1391                         rect,
1392                     );
1393                 }
1394             }
1395             self.paint_sliders(ctx);
1396         }
1397 
1398         /// The slider rows' bands — the toolkit's own `Slider`, one stamp set
1399         /// to each row's range and value, so a slider in a menu is the slider
1400         /// everywhere else. Its readout is off: the menu draws the readout as
1401         /// a label, so it wears the menu font and clears the popover clamp.
1402         fn paint_sliders(&self, ctx: &mut crate::scene::paint::PaintCtx) {
1403             for idx in 0..self.options.len() {
1404                 let Some(s) = self.slider(idx) else { continue };
1405                 let mut stamp = Slider::new().with_readout(false);
1406                 stamp.set_scroll(false);
1407                 stamp.set_range(s.min, s.max);
1408                 stamp.set_scaled_value(s.value);
1409                 crate::widget::model::Paint::paint(&*stamp, self.slider_band(idx), ctx);
1410             }
1411         }
1412 
1413         /// The flat-quad menu: a 1px border rect, a near-black fill and the hover
1414         /// row. Superseded by [`ContextMenuState::paint`], which draws the menu as
1415         /// the lit plate the rest of the DE's floating surfaces wear; this stays
1416         /// for hosts that have not migrated, and renders as it always has.
1417         pub fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
1418             let mut quads = Vec::new();
1419             if !self.visible || self.hosted { return quads; }
1420 
1421             // border
1422             quads.push((self.x, self.y, self.w, self.h, [0.22, 0.22, 0.28, 1.0]));
1423             // bg
1424             quads.push((self.x + 1.0, self.y + 1.0, self.w - 2.0, self.h - 2.0, [0.06, 0.06, 0.09, 1.0]));
1425 
1426             if let Some(h_idx) = self.hovered_item {
1427                 let iy = self.row_y(h_idx);
1428                 quads.push((self.x + PAD * 0.5, iy + 2.0, self.w - PAD, ROW_H - 4.0, [0.20, 0.40, 0.65, 0.6]));
1429             }
1430 
1431             // Separator rows ("-"): a hairline in place of the engraved
1432             // groove the plate path cuts.
1433             for (idx, opt) in self.options.iter().enumerate() {
1434                 if opt == "-" {
1435                     let cy = self.row_y(idx) + ROW_H * 0.5;
1436                     quads.push((self.x + PAD, cy, self.w - 2.0 * PAD, 1.0, [0.22, 0.22, 0.28, 1.0]));
1437                 }
1438             }
1439 
1440             quads
1441         }
1442 
1443         /// [`paint`](Self::paint) plus the label run, in the menu font.
1444         ///
1445         /// A [`TextLabel`] carries a size but no family, so a consumer that
1446         /// hand-rolls `paint()` + a `text_labels()` loop has to remember to
1447         /// pass [`label_font`]'s family itself — and every one of them passed
1448         /// `None`, which is why menus rendered in the default sans over lists
1449         /// wearing the configured face. This is the call that cannot forget
1450         /// it; prefer it over the pair.
1451         pub fn paint_with_labels(&self, ctx: &mut crate::scene::paint::PaintCtx) {
1452             self.paint(ctx);
1453             if !self.visible || self.hosted {
1454                 return;
1455             }
1456             let (family, _) = label_font();
1457             if let (Some(e), Some(t)) = (self.turn_progress(), self.turning.as_ref()) {
1458                 // The labels of both, cut at the plate as it is drawn: the
1459                 // ones it turned from sliding away and fading, the page's
1460                 // sliding in and coming up.
1461                 let rect = self.drawn_rect();
1462                 let bounds = Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]);
1463                 let (lo, hi) = (-t.dir * e * TURN_SLIDE, t.dir * (1.0 - e) * TURN_SLIDE);
1464                 let (out, into) = turn_fades(e);
1465                 for (rows, dx, alpha) in [(&*t.from, lo, out), (self, hi, into)] {
1466                     for label in rows.labels() {
1467                         ctx.text_faded(label.text, label.x + dx, label.y, label.font_size, label.color, alpha, Some(family.clone()), bounds);
1468                     }
1469                     ctx.clip(rect, |ctx| {
1470                         for g in rows.glyphs() {
1471                             paint_glyph(ctx, &g, dx, alpha);
1472                         }
1473                     });
1474                 }
1475                 return;
1476             }
1477             // The menu's own rect: the engine's popover clamp exempts exactly
1478             // these bounds, so the labels render inside the plate instead of
1479             // being clipped to the page content beneath it.
1480             let bounds = Some([self.x, self.y, self.x + self.w, self.y + self.h]);
1481             for label in self.text_labels() {
1482                 ctx.text_with(
1483                     label.text,
1484                     label.x,
1485                     label.y,
1486                     label.font_size,
1487                     label.color,
1488                     Some(family.clone()),
1489                     bounds,
1490                 );
1491             }
1492             let plate = crate::scene::layout::Rect { x: self.x, y: self.y, width: self.w, height: self.h };
1493             ctx.clip(plate, |ctx| {
1494                 for g in self.glyphs() {
1495                     paint_glyph(ctx, &g, 0.0, 1.0);
1496                 }
1497             });
1498         }
1499 
1500         pub fn text_labels(&self) -> Vec<TextLabel> {
1501             if !self.visible || self.hosted {
1502                 return Vec::new();
1503             }
1504             self.labels()
1505         }
1506 
1507         /// The labels as the rows stand, shown or not.
1508         pub(crate) fn labels(&self) -> Vec<TextLabel> {
1509             let mut labels = Vec::new();
1510 
1511             if let Some(title) = &self.back {
1512                 let (_, label_size) = label_font();
1513                 labels.push(TextLabel {
1514                     text: title.clone(),
1515                     x: self.x + PAD + chevron_size(label_size) + MARK_GAP,
1516                     y: self.back_band_y() + (ROW_H - label_size) / 2.0,
1517                     font_size: label_size,
1518                     color: rgb8(if self.back_hovered { crate::color::TEXT_HEADER } else { crate::color::TEXT_DIM }),
1519                 });
1520             }
1521             for (idx, opt) in self.options.iter().enumerate() {
1522                 if opt == "-" {
1523                     continue;
1524                 }
1525                 // Scrolled wholly out of a shortened menu: nothing to draw.
1526                 // A row partly in view is drawn and cut at the plate by the
1527                 // label's bounds.
1528                 let top = self.row_y(idx);
1529                 if top + ROW_H < self.y || top > self.y + self.h {
1530                     continue;
1531                 }
1532                 let (_, label_size) = label_font();
1533                 let iy = self.row_y(idx) + (ROW_H - label_size) / 2.0;
1534                 // The toolkit's semantic colors rather than greys hand-mixed
1535                 // against the old near-black fill: on the plate's mid-slate the
1536                 // header's 0x70 was a step above its background and read as
1537                 // nothing.
1538                 let text_color = if idx < self.header_count {
1539                     rgb8(crate::color::TEXT_DIM)
1540                 } else if self.hovered_item == Some(idx) {
1541                     rgb8(crate::color::TEXT_HEADER)
1542                 } else {
1543                     rgb8(crate::color::TEXT_FG)
1544                 };
1545 
1546                 // A leading mark is drawn as a glyph (see `glyphs`); the
1547                 // label follows it.
1548                 let (mark, text) = split_mark(opt);
1549                 let mark_w = if mark.is_some() { mark_size(label_size) + MARK_GAP } else { 0.0 };
1550                 labels.push(TextLabel {
1551                     text: text.to_string(),
1552                     x: self.x + PAD + mark_w,
1553                     y: iy,
1554                     font_size: label_size,
1555                     color: text_color,
1556                 });
1557                 // A slider row's readout, right-aligned against its band.
1558                 if let Some(s) = self.slider(idx) {
1559                     let (family, _) = label_font();
1560                     let text = s.readout();
1561                     let tw = crate::widget::display::measure_text_width(&text, &family, label_size);
1562                     labels.push(TextLabel {
1563                         text,
1564                         x: self.slider_band(idx).x - SLIDER_GAP - tw,
1565                         y: iy,
1566                         font_size: label_size,
1567                         color: text_color,
1568                     });
1569                 }
1570             }
1571             labels
1572         }
1573 
1574         /// The glyphs the rows wear, as they stand: each row's leading mark
1575         /// (see [`MARK_CHECK`]), the chevron at the right end of a row that
1576         /// leads to a page, and the back band's chevron. Each takes its
1577         /// row's text colour.
1578         pub(crate) fn glyphs(&self) -> Vec<MenuGlyph> {
1579             let mut glyphs = Vec::new();
1580             let (_, size) = label_font();
1581             let (ms, cs) = (mark_size(size), chevron_size(size));
1582             if self.back.is_some() {
1583                 glyphs.push(MenuGlyph {
1584                     name: "chevron-left",
1585                     x: self.x + PAD,
1586                     y: self.back_band_y() + (ROW_H - cs) / 2.0,
1587                     side: cs,
1588                     color: if self.back_hovered { crate::color::TEXT_HEADER } else { crate::color::TEXT_DIM },
1589                 });
1590             }
1591             for (idx, opt) in self.options.iter().enumerate() {
1592                 if opt == "-" {
1593                     continue;
1594                 }
1595                 let top = self.row_y(idx);
1596                 if top + ROW_H < self.y || top > self.y + self.h {
1597                     continue;
1598                 }
1599                 let color = if idx < self.header_count {
1600                     crate::color::TEXT_DIM
1601                 } else if self.hovered_item == Some(idx) {
1602                     crate::color::TEXT_HEADER
1603                 } else {
1604                     crate::color::TEXT_FG
1605                 };
1606                 if let (Some(name), _) = split_mark(opt) {
1607                     glyphs.push(MenuGlyph { name, x: self.x + PAD, y: top + (ROW_H - ms) / 2.0, side: ms, color });
1608                 }
1609                 if self.leads_to_page(idx) {
1610                     glyphs.push(MenuGlyph {
1611                         name: "chevron-right",
1612                         x: self.x + self.w - PAD - cs,
1613                         y: top + (ROW_H - cs) / 2.0,
1614                         side: cs,
1615                         color,
1616                     });
1617                 }
1618             }
1619             glyphs
1620         }
1621     }
1622 
1623     /// The menu PLATE alone, over `rect`: [`Material::menu`] on a rounded
1624     /// face at `style.surface.menu.corner_radius` with the rolled perimeter
1625     /// at the relief width (capped at a fifth of the height) — or, for a
1626     /// transparent configured face, the edges-only boss. What
1627     /// [`ContextMenuState::paint`] draws under its rows, and what any other
1628     /// surface that should look like a menu draws under its own (the
1629     /// designer's command palette): one function, so the two cannot be
1630     /// configured apart.
1631     ///
1632     /// `in_popup` says the plate is being painted into the runner's popup
1633     /// surface, where the compositor's blur frosts but cannot COMPRESS: the
1634     /// in-app pass pulls the backdrop's luminance a fraction `k` toward the
1635     /// plate's key, which is what keeps the labels legible over a bright
1636     /// scene. Over glass that only blurs, the same swing is held with
1637     /// opacity instead — the backdrop reaches the eye at (1 - a)(1 - k)
1638     /// either way — or a menu opened over something white washes out.
1639     ///
1640     /// [`Material::menu`]: crate::scene::material::Material::menu
1641     pub fn paint_menu_plate(ctx: &mut crate::scene::paint::PaintCtx, rect: crate::scene::layout::Rect, in_popup: bool) {
1642         let r = crate::layout::menu_corner_radius();
1643         let depth = crate::layout::bevel_width().min(rect.height * 0.2);
1644         let face = crate::color::menu_color();
1645         if face[3] > 0.001 {
1646             let material = crate::scene::material::Material::menu();
1647             let material = if in_popup {
1648                 let k = crate::color::menu_compression().clamp(0.0, 1.0);
1649                 let mut m = material.for_role(crate::scene::material::PlateRole::Root);
1650                 m.tint[3] = 1.0 - (1.0 - m.tint[3]) * (1.0 - k);
1651                 m
1652             } else {
1653                 material
1654             };
1655             ctx.plate(rect, (r, r, r, r), &material, depth);
1656         } else {
1657             let (plateau, radii) = crate::layout::carve_inside(rect, (r, r, r, r), depth);
1658             ctx.boss(plateau, radii, depth);
1659         }
1660     }
1661 
1662     /// Run `f` on the current window's menu (`crate::window_state`): the one menu every
1663     /// function here acts on. It was a thread-local, `CONTEXT_MENU`, until 2026-10-08;
1664     /// `with_state(..)` callers read it through this now.
1665     pub fn with_state<R>(f: impl FnOnce(&RefCell<ContextMenuState>) -> R) -> R {
1666         crate::window_state::with(|w| f(&w.context_menu))
1667     }
1668 
1669     /// Make row `idx` of the shown menu lead to a page — see [`PageTurn`].
1670     /// Call after [`show`] / [`show_page`], which clear every row back to an
1671     /// action.
1672     /// Give the open menu's rows their actions — see [`ContextMenuState::set_row_actions`].
1673     pub fn set_row_actions(actions: Vec<Option<crate::widget::ContextAction>>) {
1674         with_state(|m| m.borrow_mut().set_row_actions(actions));
1675     }
1676 
1677     /// The action row `idx` of the open menu runs, if it was given one.
1678     pub fn row_action(idx: usize) -> Option<crate::widget::ContextAction> {
1679         with_state(|m| m.borrow().actions.get(idx).copied().flatten())
1680     }
1681 
1682     /// How many of the open menu's first rows are headers (a title, `File:` / `Key:`).
1683     pub fn header_count() -> usize {
1684         with_state(|m| m.borrow().header_count)
1685     }
1686 
1687     pub fn set_row_page(idx: usize) {
1688         with_state(|m| m.borrow_mut().set_row_page(idx));
1689     }
1690     pub fn leads_to_page(idx: usize) -> bool {
1691         with_state(|m| m.borrow().leads_to_page(idx))
1692     }
1693     /// See [`ContextMenuState::show_page`].
1694     pub fn show_page(x: f32, y: f32, back: Option<&str>, options: Vec<String>, header_count: usize, target: WidgetId) {
1695         with_state(|m| m.borrow_mut().show_page(x, y, back, options, header_count, target));
1696     }
1697     /// See [`ContextMenuState::refill`].
1698     pub fn refill(options: Vec<String>, sliders: &[Option<MenuSlider>]) -> bool {
1699         with_state(|m| m.borrow_mut().refill(options, sliders))
1700     }
1701     /// See [`ContextMenuState::turn_at`].
1702     pub fn turn_at(px: f32, py: f32) -> Option<PageTurn> {
1703         with_state(|m| m.borrow().turn_at(px, py))
1704     }
1705     /// See [`ContextMenuState::take_turn`].
1706     pub fn take_turn() -> Option<PageTurn> {
1707         with_state(|m| m.borrow_mut().take_turn())
1708     }
1709     /// Whether the shown menu is a page turned to in place of another plate.
1710     pub fn is_turned() -> bool {
1711         with_state(|m| m.borrow().turned)
1712     }
1713     /// The title of the plate the shown page goes back to.
1714     pub fn back_title() -> Option<String> {
1715         with_state(|m| m.borrow().back.clone())
1716     }
1717 
1718     pub fn is_visible() -> bool {
1719         with_state(|m| m.borrow().visible)
1720     }
1721 
1722     pub fn show(x: f32, y: f32, options: Vec<String>, header_count: usize, target: WidgetId) {
1723         with_state(|m| m.borrow_mut().show(x, y, options, header_count, target));
1724     }
1725 
1726     pub fn hide() {
1727         with_state(|m| m.borrow_mut().hide());
1728     }
1729 
1730     pub fn clear_if_matches(w: &dyn WidgetHost) {
1731         let id = w.base().id();
1732         with_state(|m| {
1733             // Already borrowed means the widget is being dropped from INSIDE
1734             // the menu's own code — the slider rows' paint stamp, dropped at
1735             // the end of `paint` under `paint_with_labels`' borrow. A widget
1736             // the menu made for itself cannot be its target, so there is
1737             // nothing to clear; `borrow_mut` here panicked on every paint of
1738             // a menu with a slider row.
1739             let Ok(mut menu) = m.try_borrow_mut() else { return };
1740             if menu.target == Some(id) {
1741                 menu.hide();
1742             }
1743         });
1744     }
1745 
1746     /// Whether the runner draws the menu in its own popup surface — see
1747     /// [`ContextMenuState::hosted`]. Set by the runner, never by an app.
1748     pub fn set_hosted(hosted: bool) {
1749         with_state(|m| m.borrow_mut().hosted = hosted);
1750     }
1751     pub fn is_hosted() -> bool {
1752         with_state(|m| m.borrow().hosted)
1753     }
1754     /// See [`ContextMenuState::generation`].
1755     pub fn generation() -> u64 {
1756         with_state(|m| m.borrow().generation)
1757     }
1758     /// See [`ContextMenuState::place`].
1759     pub fn place(x: f32, y: f32, max_h: f32) {
1760         with_state(|m| m.borrow_mut().place(x, y, max_h));
1761     }
1762     /// See [`ContextMenuState::constrain_to`].
1763     pub fn constrain_to(bx: f32, by: f32, bw: f32, bh: f32) {
1764         with_state(|m| m.borrow_mut().constrain_to(bx, by, bw, bh));
1765     }
1766     /// `(anchor, w, content_h)` — what a popup positioner is built from.
1767     pub fn natural_geometry() -> ((f32, f32), f32, f32) {
1768         with_state(|m| {
1769             let m = m.borrow();
1770             let (w, h) = m.surface_size();
1771             (m.anchor, w, h)
1772         })
1773     }
1774     /// Whether a page turn is being animated: a host drawing the menu
1775     /// itself asks for frames while it is.
1776     pub fn is_turning() -> bool {
1777         with_state(|m| m.borrow().turn_progress().is_some())
1778     }
1779     /// See [`ContextMenuState::turn_from_size`].
1780     pub fn turn_from_size(w: f32, h: f32, forward: bool) {
1781         with_state(|m| m.borrow_mut().turn_from_size(w, h, forward));
1782     }
1783     /// Paint the menu with its top-left at the origin, whether or not it is
1784     /// [hosted](set_hosted) — the runner's popup surface draws it this way.
1785     /// Paints a COPY, so no borrow of the menu is held while the paint runs
1786     /// (the slider stamp's drop reaches back into this cell).
1787     pub fn paint_hosted(ctx: &mut crate::scene::paint::PaintCtx) {
1788         let mut menu = with_state(|m| m.borrow().clone());
1789         menu.hosted = false;
1790         menu.in_popup = true;
1791         let (x, y) = (menu.x, menu.y);
1792         ctx.translate(-x, -y, |ctx| menu.paint_with_labels(ctx));
1793     }
1794 
1795     pub fn x() -> f32 { with_state(|m| m.borrow().x) }
1796     pub fn y() -> f32 { with_state(|m| m.borrow().y) }
1797     pub fn w() -> f32 { with_state(|m| m.borrow().w) }
1798     pub fn h() -> f32 { with_state(|m| m.borrow().h) }
1799     pub fn hovered_item() -> Option<usize> { with_state(|m| m.borrow().hovered_item) }
1800     pub fn options() -> Vec<String> { with_state(|m| m.borrow().options.clone()) }
1801     /// Highlight a row from the keyboard — see
1802     /// [`ContextMenuState::set_hovered_item`]. A host that walks a menu
1803     /// with the arrow keys (a dropdown) sets the open row with this and
1804     /// moves with [`step_hovered`], and runs [`hovered_item`] on Enter.
1805     pub fn set_hovered_item(idx: Option<usize>) {
1806         with_state(|m| m.borrow_mut().set_hovered_item(idx));
1807     }
1808     /// See [`ContextMenuState::step_hovered`].
1809     pub fn step_hovered(dir: i32) -> Option<usize> {
1810         with_state(|m| m.borrow_mut().step_hovered(dir))
1811     }
1812 
1813     /// The row under a point, PAD-aware — the ONE row hit test. Every host
1814     /// that dispatches the menu itself should ask this rather than divide
1815     /// `(py - y()) / ROW_H`: the rows start `PAD` below the plate's top, so
1816     /// that division names the row below over the bottom third of every
1817     /// row, and runs off the end on the last one (2026-09-22 audit: six
1818     /// call sites across five apps had it).
1819     pub fn row_at(px: f32, py: f32) -> Option<usize> {
1820         with_state(|m| m.borrow().row_at(px, py))
1821     }
1822     /// A row's top, PAD-aware — for a host painting the rows itself.
1823     pub fn row_y(idx: usize) -> f32 {
1824         with_state(|m| m.borrow().row_y(idx))
1825     }
1826     pub fn hit_test(px: f32, py: f32) -> bool {
1827         with_state(|m| m.borrow().hit_test(px, py))
1828     }
1829 
1830     pub fn cursor_moved(px: f32, py: f32) -> bool {
1831         with_state(|m| m.borrow_mut().cursor_moved(px, py))
1832     }
1833 
1834     /// Make row `idx` of the shown menu a slider — see [`MenuSlider`]. Call
1835     /// after [`show`], which clears every row back to an action.
1836     pub fn set_row_slider(idx: usize, slider: MenuSlider) {
1837         with_state(|m| m.borrow_mut().set_row_slider(idx, slider));
1838     }
1839     pub fn slider(idx: usize) -> Option<MenuSlider> {
1840         with_state(|m| m.borrow().slider(idx))
1841     }
1842     /// The wheel, for hosts that route it: steps the slider under the
1843     /// pointer. `false` when no slider row is there — let it scroll the page.
1844     pub fn mouse_wheel(delta: &MouseScrollDelta, px: f32, py: f32) -> bool {
1845         with_state(|m| m.borrow_mut().mouse_wheel(delta, px, py))
1846     }
1847     /// A left press, for hosts that dispatch the menu themselves: `true` when
1848     /// it landed on a slider row, which the host must then NOT treat as an
1849     /// action or a dismissal.
1850     pub fn slider_press(px: f32, py: f32) -> bool {
1851         with_state(|m| m.borrow_mut().slider_press(px, py))
1852     }
1853     pub fn slider_dragging() -> bool {
1854         with_state(|m| m.borrow().slider_drag.is_some())
1855     }
1856     pub fn slider_release() -> bool {
1857         with_state(|m| m.borrow_mut().slider_release())
1858     }
1859     /// `(row, value)` of the last slider change since the last call.
1860     pub fn take_slider_change() -> Option<(usize, f32)> {
1861         with_state(|m| m.borrow_mut().take_slider_change())
1862     }
1863 
1864     pub fn mouse_input(button: MouseButton, state: ElementState, px: f32, py: f32, ctx: Option<&mut crate::context::UiContext>) -> bool {
1865         with_state(|m| m.borrow_mut().mouse_input(button, state, px, py, ctx))
1866     }
1867 
1868     /// Paint the menu as a lit plate — see [`ContextMenuState::paint`]. Hosts on
1869     /// the display-list path call this in place of the [`extra_quads`] loop.
1870     pub fn paint(ctx: &mut crate::scene::paint::PaintCtx) {
1871         with_state(|m| m.borrow().paint(ctx));
1872     }
1873 
1874     pub fn extra_quads() -> Vec<(f32, f32, f32, f32, [f32; 4])> {
1875         with_state(|m| m.borrow().extra_quads())
1876     }
1877 
1878     pub fn text_labels() -> Vec<TextLabel> {
1879         with_state(|m| m.borrow().text_labels())
1880     }
1881 
1882     /// Plate and labels in one call — see
1883     /// [`ContextMenuState::paint_with_labels`].
1884     pub fn paint_with_labels(ctx: &mut crate::scene::paint::PaintCtx) {
1885         with_state(|m| m.borrow().paint_with_labels(ctx));
1886     }
1887 }
1888 
1889 #[derive(Debug, Clone)]
1890 pub struct Widget {
1891     pub x: f32,
1892     pub y: f32,
1893     pub w: f32,
1894     pub h: f32,
1895     pub label: Option<String>,
1896     /// What a screen reader calls the widget when it draws no label of its own
1897     /// (an `aria-label`): the accessibility tree prefers it over [`label`](Self::label),
1898     /// and nothing draws it. For a control whose label stands beside it in the host's
1899     /// own text — a value editor in a table row, an icon button.
1900     pub accessible_name: Option<String>,
1901     pub hovered: bool,
1902     pub row_x: f32,
1903     pub row_w: f32,
1904     pub focused: bool,
1905     pub id: std::cell::Cell<Option<crate::widget::WidgetId>>,
1906     pub dirty: bool,
1907     pub config_file: Option<String>,
1908     pub config_key: Option<String>,
1909 }
1910 
1911 impl Widget {
1912     pub fn new() -> Self {
1913         Self {
1914             x: 0.0,
1915             y: 0.0,
1916             w: 0.0,
1917             h: 0.0,
1918             label: None,
1919             accessible_name: None,
1920             hovered: false,
1921             row_x: 0.0,
1922             row_w: 0.0,
1923             focused: false,
1924             id: std::cell::Cell::new(None),
1925             dirty: true,
1926             config_file: None,
1927             config_key: None,
1928         }
1929     }
1930 
1931     pub fn new_rect(x: f32, y: f32, w: f32, h: f32) -> Self {
1932         Self {
1933             x,
1934             y,
1935             w,
1936             h,
1937             label: None,
1938             accessible_name: None,
1939             hovered: false,
1940             row_x: 0.0,
1941             row_w: 0.0,
1942             focused: false,
1943             id: std::cell::Cell::new(None),
1944             dirty: true,
1945             config_file: None,
1946             config_key: None,
1947         }
1948     }
1949 
1950     pub fn id(&self) -> crate::widget::WidgetId {
1951         let current = self.id.get();
1952         if let Some(id) = current {
1953             id
1954         } else {
1955             let next = crate::widget::NEXT_WIDGET_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1956             let id = crate::widget::WidgetId(next);
1957             self.id.set(Some(id));
1958             id
1959         }
1960     }
1961 
1962     /// The detached-label strip this widget carries above its content: the one
1963     /// control-label formula (`layout::control_label_strip`) when a label is set,
1964     /// zero otherwise.
1965     pub fn label_offset(&self) -> f32 {
1966         if self.label.is_some() { crate::layout::control_label_strip() } else { 0.0 }
1967     }
1968 }
1969 
1970 
1971 pub fn clear_widget_references(w: &dyn WidgetHost) {
1972     // Focus needs no clearing: the context keeps an id, which a dropped widget's
1973     // generation no longer resolves.
1974     context_menu::clear_if_matches(w);
1975 }
1976 
1977 #[macro_export]
1978 macro_rules! impl_widget_base {
1979     ($name:ident) => {
1980         fn base(&self) -> &$crate::widget::Widget { &self.base }
1981         fn base_mut(&mut self) -> &mut $crate::widget::Widget { &mut self.base }
1982         fn as_any(&self) -> &dyn std::any::Any { self }
1983         fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
1984     };
1985 }
1986 
1987 #[cfg(test)]
1988 mod context_menu_slider_tests {
1989     use super::context_menu::{ContextMenuState, MenuSlider, PAD, ROW_H, SLIDER_W};
1990     use crate::widget::{ElementState, MouseButton, MouseScrollDelta, Position, WidgetId};
1991 
1992     fn menu() -> ContextMenuState {
1993         let mut m = ContextMenuState::new();
1994         m.show(100.0, 50.0, vec!["Frame All".into(), "Opacity".into()], 0, WidgetId(7));
1995         m.set_row_slider(1, MenuSlider { value: 50.0, min: 0.0, max: 100.0, step: 5.0, decimals: 0, suffix: "%" });
1996         m
1997     }
1998     fn row_mid(m: &ContextMenuState, idx: usize) -> f32 {
1999         m.row_y(idx) + ROW_H * 0.5
2000     }
2001 
2002     /// Twenty rows: 20 * ROW_H + 2 * PAD tall, far more than the boxes below.
2003     fn long_menu() -> ContextMenuState {
2004         let mut m = ContextMenuState::new();
2005         let rows: Vec<String> = (0..20).map(|i| format!("Row {i}")).collect();
2006         m.show(100.0, 50.0, rows, 0, WidgetId(7));
2007         m
2008     }
2009 
2010     /// The keyboard walks a menu: a step skips the header and the
2011     /// separators, stops at both ends rather than wrapping, and scrolls a
2012     /// shortened menu to the row it lands on.
2013     #[test]
2014     fn the_keyboard_steps_the_highlight_over_what_cannot_run() {
2015         let mut m = ContextMenuState::new();
2016         let rows = ["Header", "A", "-", "B", "C"].map(String::from).to_vec();
2017         m.show(100.0, 50.0, rows, 1, WidgetId(7));
2018         assert_eq!(m.step_hovered(1), Some(1), "the header is skipped");
2019         assert_eq!(m.step_hovered(1), Some(3), "and the separator");
2020         assert_eq!(m.step_hovered(1), Some(4));
2021         assert_eq!(m.step_hovered(1), Some(4), "the last row stays");
2022         assert_eq!(m.step_hovered(-1), Some(3));
2023         assert_eq!(m.step_hovered(-1), Some(1));
2024         assert_eq!(m.step_hovered(-1), Some(1), "the header is not reached");
2025         m.set_hovered_item(Some(2));
2026         assert_eq!(m.hovered_item, None, "a separator cannot be highlighted");
2027         assert_eq!(m.step_hovered(-1), Some(4), "from nothing, up starts at the bottom");
2028 
2029         let mut long = long_menu();
2030         long.place(100.0, 50.0, 200.0);
2031         long.set_hovered_item(Some(15));
2032         assert!(long.row_y(15) >= long.y && long.row_y(15) + ROW_H <= long.y + long.h, "scrolled into view");
2033         long.set_hovered_item(Some(0));
2034         assert_eq!(long.scroll, 0.0);
2035     }
2036 
2037     /// Placed shorter than its rows, the menu scrolls: the wheel moves the
2038     /// rows a row a notch, up shows the rows above, it stops at both ends,
2039     /// and the row under the pointer — hover, press — is the one DRAWN
2040     /// there, scroll included.
2041     #[test]
2042     fn a_shortened_menu_scrolls_its_rows() {
2043         let mut m = long_menu();
2044         let full = m.content_h;
2045         assert_eq!(full, 20.0 * ROW_H + 2.0 * PAD);
2046         m.place(100.0, 50.0, 200.0);
2047         assert_eq!(m.h, 200.0);
2048         assert_eq!(m.max_scroll(), full - 200.0);
2049 
2050         let (px, py) = (130.0, m.y + PAD + ROW_H * 0.5);
2051         m.cursor_moved(px, py);
2052         assert_eq!(m.row_at(px, py), Some(0));
2053         // Wheel down (negative notches): three rows further on.
2054         assert!(m.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), px, py));
2055         assert_eq!(m.scroll, 3.0 * ROW_H);
2056         assert_eq!(m.row_at(px, py), Some(3), "the row under the pointer moved with the scroll");
2057         assert_eq!(m.hovered_item, Some(3), "and the hover followed it without a motion event");
2058         assert_eq!(m.row_y(3), m.y + PAD, "row 3 is drawn where row 0 was");
2059         // Up past the top stops at the top; down past the end stops there.
2060         m.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 10.0), px, py);
2061         assert_eq!(m.scroll, 0.0);
2062         m.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -100.0), px, py);
2063         assert_eq!(m.scroll, m.max_scroll());
2064         // Nothing to scroll: the wheel is not the menu's.
2065         let mut short = menu();
2066         assert!(!short.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -1.0), 110.0, short.row_y(0) + 1.0));
2067     }
2068 
2069     /// A row outside the shown plate is not under the pointer, even though
2070     /// the rows' arithmetic would reach it — the plate ends at `h`.
2071     #[test]
2072     fn rows_below_a_shortened_plate_are_not_hit() {
2073         let mut m = long_menu();
2074         m.place(100.0, 50.0, 200.0);
2075         assert!(m.row_at(130.0, m.y + m.h + 5.0).is_none());
2076         assert!(!m.hit_test(130.0, m.y + m.h + 5.0));
2077     }
2078 
2079     /// The in-window placement, from the anchor: fits below → stays; not
2080     /// below but above → flips to open up from the anchor; neither → slides
2081     /// to the bottom edge; taller than the box → cut to it, scrolling. And
2082     /// the right edge slides the menu left.
2083     #[test]
2084     fn constrain_flips_slides_and_shortens_like_a_positioner() {
2085         // Fits below.
2086         let mut m = menu();
2087         m.constrain_to(0.0, 0.0, 800.0, 600.0);
2088         assert_eq!((m.x, m.y, m.h), (100.0, 50.0, m.content_h));
2089 
2090         // Opened near the bottom: flips up from the anchor.
2091         let mut m = ContextMenuState::new();
2092         m.show(100.0, 580.0, vec!["A".into(), "B".into(), "C".into()], 0, WidgetId(7));
2093         m.constrain_to(0.0, 0.0, 800.0, 600.0);
2094         assert_eq!(m.y, 580.0 - m.content_h, "flipped to open upward");
2095 
2096         // No room either way: slides to the bottom edge, whole.
2097         let mut m = long_menu(); // 496 tall
2098         m.show(100.0, 300.0, (0..20).map(|i| format!("{i}")).collect(), 0, WidgetId(7));
2099         m.constrain_to(0.0, 0.0, 800.0, 600.0);
2100         assert_eq!(m.y + m.h, 600.0);
2101         assert_eq!(m.h, m.content_h);
2102 
2103         // Taller than the box: cut to it, and it scrolls.
2104         m.constrain_to(0.0, 0.0, 800.0, 300.0);
2105         assert_eq!((m.y, m.h), (0.0, 300.0));
2106         assert!(m.max_scroll() > 0.0);
2107 
2108         // The right edge: slides left to fit.
2109         let mut m = menu();
2110         m.show(790.0, 50.0, vec!["A".into()], 0, WidgetId(7));
2111         m.constrain_to(0.0, 0.0, 800.0, 600.0);
2112         assert_eq!(m.x + m.w, 800.0);
2113 
2114         // Re-running is stable: it works from the anchor, not from where
2115         // the last run put it.
2116         let mut m = long_menu();
2117         m.constrain_to(0.0, 0.0, 800.0, 300.0);
2118         let first = (m.x, m.y, m.h);
2119         m.constrain_to(0.0, 0.0, 800.0, 300.0);
2120         assert_eq!((m.x, m.y, m.h), first);
2121     }
2122 
2123     /// Hosted in the popup, the menu draws nothing into the window's list —
2124     /// every app still calls the in-window paint — while the runner's
2125     /// `paint_hosted` draws it at the origin. Hit testing is untouched.
2126     #[test]
2127     fn a_hosted_menu_paints_only_through_the_popup() {
2128         use super::context_menu as cm;
2129         cm::show(100.0, 50.0, vec!["Frame All".into(), "Opacity".into()], 0, WidgetId(7));
2130         cm::set_hosted(true);
2131         let mut pc = crate::scene::paint::PaintCtx::new();
2132         cm::paint_with_labels(&mut pc);
2133         assert!(pc.finish().items.is_empty(), "nothing in the window");
2134         assert!(cm::text_labels().is_empty());
2135         assert!(cm::hit_test(110.0, 60.0), "still hit-tested where it is");
2136 
2137         let mut pc = crate::scene::paint::PaintCtx::new();
2138         cm::paint_hosted(&mut pc);
2139         let dl = pc.finish();
2140         assert!(!dl.items.is_empty(), "the popup draws it");
2141         let texts: Vec<(f32, f32)> = dl
2142             .items
2143             .iter()
2144             .filter_map(|i| match &i.prim {
2145                 crate::scene::paint::Prim::Text { x, y, .. } => Some((*x, *y)),
2146                 _ => None,
2147             })
2148             .collect();
2149         assert!(texts.iter().all(|&(x, y)| x < 100.0 && y < 50.0 + 2.0 * ROW_H), "at the popup's origin, not the window's");
2150         cm::set_hosted(false);
2151         cm::hide();
2152     }
2153 
2154     /// Painted through the thread-local, as every host paints it: the slider
2155     /// stamp is dropped while `CONTEXT_MENU` is borrowed, and its drop clears
2156     /// widget references in that same cell. The tests above paint a bare
2157     /// `ContextMenuState` and never held the borrow.
2158     #[test]
2159     fn a_slider_row_paints_through_the_shared_menu() {
2160         use super::context_menu as cm;
2161         cm::show(100.0, 50.0, vec!["Frame All".into(), "Opacity".into()], 0, WidgetId(7));
2162         cm::set_row_slider(1, MenuSlider { value: 50.0, min: 0.0, max: 100.0, step: 5.0, decimals: 0, suffix: "%" });
2163         let mut pc = crate::scene::paint::PaintCtx::new();
2164         cm::paint_with_labels(&mut pc);
2165         cm::paint(&mut pc);
2166         assert!(cm::is_visible(), "painting leaves the menu up");
2167         cm::hide();
2168     }
2169 
2170     /// A notch over the slider row steps it by `step`, up is more, and the
2171     /// change is reported once; over an action row the wheel is not the
2172     /// menu's. A trackpad's fractions add up to whole steps.
2173     #[test]
2174     fn the_wheel_steps_a_slider_row() {
2175         let mut m = menu();
2176         let y = row_mid(&m, 1);
2177         assert!(m.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 1.0), 150.0, y));
2178         assert_eq!(m.slider(1).unwrap().value, 55.0);
2179         assert_eq!(m.take_slider_change(), Some((1, 55.0)));
2180         assert_eq!(m.take_slider_change(), None, "reported once");
2181         m.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -2.0), 150.0, y);
2182         assert_eq!(m.slider(1).unwrap().value, 45.0);
2183 
2184         assert!(!m.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 1.0), 150.0, row_mid(&m, 0)), "an action row does not take the wheel");
2185 
2186         // 30 px is half a notch: nothing yet, then the second half lands a step.
2187         let half = MouseScrollDelta::PixelDelta(Position { x: 0.0, y: 30.0 });
2188         assert!(!m.mouse_wheel(&half, 150.0, y));
2189         assert!(m.mouse_wheel(&half, 150.0, y));
2190         assert_eq!(m.slider(1).unwrap().value, 50.0);
2191 
2192         // Clamped at the ends, and a clamp that moves nothing reports nothing.
2193         for _ in 0..30 {
2194             m.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 1.0), 150.0, y);
2195         }
2196         assert_eq!(m.slider(1).unwrap().value, 100.0);
2197         m.take_slider_change();
2198         assert!(!m.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 1.0), 150.0, y));
2199         assert_eq!(m.take_slider_change(), None);
2200     }
2201 
2202     /// A press on the band jumps to the pointer (snapped to the step) and
2203     /// drags; the menu stays open through it, and the release ends the drag.
2204     /// A press on an action row still fires and closes, as before.
2205     #[test]
2206     fn a_press_on_the_band_drags_and_keeps_the_menu_open() {
2207         let mut m = menu();
2208         let y = row_mid(&m, 1);
2209         let band = m.slider_band(1);
2210         assert!((band.x + SLIDER_W - (m.x + m.w - PAD)).abs() < 1e-3, "the band ends at the padding");
2211         assert!(m.mouse_input(MouseButton::Left, ElementState::Pressed, band.x + band.width * 0.8, y, None));
2212         assert!(m.visible, "a slider row does not close the menu");
2213         assert_eq!(m.slider(1).unwrap().value, 80.0);
2214         m.cursor_moved(band.x + band.width * 0.21, y + 200.0);
2215         assert_eq!(m.slider(1).unwrap().value, 20.0, "the drag follows off the plate, snapped to 5");
2216         assert!(m.mouse_input(MouseButton::Left, ElementState::Released, 0.0, 0.0, None));
2217         assert!(m.slider_drag.is_none());
2218         m.cursor_moved(band.x, y);
2219         assert_eq!(m.slider(1).unwrap().value, 20.0, "released: motion is hover again");
2220 
2221         // A press on the row's label end: the slider's, nothing moves.
2222         assert!(m.mouse_input(MouseButton::Left, ElementState::Pressed, m.x + PAD + 2.0, y, None));
2223         assert!(m.visible);
2224         assert_eq!(m.slider(1).unwrap().value, 20.0);
2225 
2226         m.mouse_input(MouseButton::Left, ElementState::Pressed, m.x + PAD + 2.0, row_mid(&m, 0), None);
2227         assert!(!m.visible, "an action row still fires and closes");
2228     }
2229 
2230     /// The plate widens for the label, the readout and the band; a fresh
2231     /// `show` clears every slider back to an action row.
2232     #[test]
2233     fn a_slider_row_widens_the_plate_and_show_clears_it() {
2234         let mut m = ContextMenuState::new();
2235         m.show(0.0, 0.0, vec!["Opacity".into()], 0, WidgetId(7));
2236         let narrow = m.w;
2237         m.set_row_slider(0, MenuSlider { value: 1.0, min: 0.0, max: 100.0, step: 1.0, decimals: 0, suffix: "%" });
2238         assert!(m.w >= narrow.max(SLIDER_W + 2.0 * PAD));
2239         let labels = m.text_labels();
2240         assert!(labels.iter().any(|l| l.text == "1%"), "the readout is a label: {:?}", labels.iter().map(|l| &l.text).collect::<Vec<_>>());
2241         m.show(0.0, 0.0, vec!["Opacity".into()], 0, WidgetId(7));
2242         assert!(m.slider(0).is_none());
2243     }
2244 }
2245 
2246 #[cfg(test)]
2247 mod context_menu_padding_tests {
2248     use super::context_menu::{self, split_mark, ContextMenuState, PAD, ROW_H};
2249     use crate::widget::WidgetId;
2250 
2251     /// The shared menu is a popover for the window-drag question too: a
2252     /// press on one of its rows must never start a window move, whatever
2253     /// sits under the menu. It has no widget id to register, so the veto
2254     /// asks the thread-local directly. And the free `row_at` is the
2255     /// PAD-aware row hit test hosts dispatch by.
2256     #[test]
2257     fn an_open_menu_vetoes_window_drags_under_it() {
2258         let ctx = crate::context::UiContext::new();
2259         context_menu::show(100.0, 200.0, vec!["Copy".into(), "Paste".into()], 0, WidgetId(1));
2260         assert!(!ctx.drag_allowed_at(110.0, 200.0 + PAD + ROW_H * 0.5), "a press on a row is not a drag");
2261         assert_eq!(context_menu::row_at(110.0, 200.0 + PAD + ROW_H * 1.5), Some(1));
2262         assert_eq!(context_menu::row_y(1), 200.0 + PAD + ROW_H);
2263         assert!(ctx.drag_allowed_at(10.0, 10.0), "away from the menu the drag question is the widgets'");
2264         context_menu::hide();
2265         assert!(ctx.drag_allowed_at(110.0, 200.0 + PAD + ROW_H * 0.5), "hidden, it vetoes nothing");
2266     }
2267 
2268     /// A row's leading mark is a glyph, not a character: the label is drawn
2269     /// without it and after the glyph's room, the glyph is the mark's
2270     /// (`check`, `circle`, `circle-outline`), and a page row's chevron and a
2271     /// page's back chevron are glyphs too — no row draws "✓", "●", "○", "›"
2272     /// or "‹" as text.
2273     #[test]
2274     fn menu_marks_and_chevrons_are_glyphs() {
2275         let mut m = ContextMenuState::new();
2276         m.show(0.0, 0.0, vec!["✓ Show Grid".into(), "● On".into(), "○ Off".into(), "Plain".into(), "Add Tab".into()], 0, WidgetId(1));
2277         m.set_row_page(4);
2278         let labels = m.labels();
2279         let texts: Vec<&str> = labels.iter().map(|l| l.text.as_str()).collect();
2280         assert_eq!(texts, ["Show Grid", "On", "Off", "Plain", "Add Tab"]);
2281         assert!(labels[0].x > labels[3].x, "a marked label stands after its mark's room");
2282         let names: Vec<&str> = m.glyphs().iter().map(|g| g.name).collect();
2283         assert_eq!(names, ["check", "circle", "circle-outline", "chevron-right"]);
2284         m.show_page(0.0, 0.0, Some("View"), vec!["○ Wireframe".into()], 0, WidgetId(1));
2285         assert_eq!(m.labels()[0].text, "View", "the back band reads its title, its chevron a glyph");
2286         assert_eq!(m.glyphs().iter().map(|g| g.name).collect::<Vec<_>>(), ["chevron-left", "circle-outline"]);
2287         for l in m.labels() {
2288             assert!(!l.text.contains(['✓', '●', '○', '›', '‹']), "{:?} draws a mark as text", l.text);
2289         }
2290         assert_eq!(split_mark("✓ Collapse controls"), (Some("check"), "Collapse controls"));
2291         assert_eq!(split_mark("Collapse controls"), (None, "Collapse controls"));
2292     }
2293 
2294     /// Every glyph the toolkit draws by name is in the icon set: a name
2295     /// with no file draws NOTHING (a missing icon is not an error at paint),
2296     /// so the miss is caught here. Skipped where the icon set is not checked
2297     /// out beside the crate.
2298     #[test]
2299     fn every_glyph_the_toolkit_names_is_in_the_icon_set() {
2300         let dir = std::path::Path::new(&crate::icons_dir()).to_path_buf();
2301         if !dir.is_dir() {
2302             eprintln!("skipped: no icon set at {}", dir.display());
2303             return;
2304         }
2305         let mut names = std::collections::BTreeSet::new();
2306         let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
2307         let mut stack = vec![root];
2308         while let Some(d) = stack.pop() {
2309             for e in std::fs::read_dir(&d).unwrap().flatten() {
2310                 let p = e.path();
2311                 if p.is_dir() {
2312                     stack.push(p);
2313                     continue;
2314                 }
2315                 if p.extension().is_none_or(|x| x != "rs") {
2316                     continue;
2317                 }
2318                 let src = std::fs::read_to_string(&p).unwrap();
2319                 for call in [".icon(\"", "upload_icon(\"", "upload_icon_tinted(\"", "with_icon_name(\"", "new_icon(\""] {
2320                     for (i, _) in src.match_indices(call) {
2321                         let rest = &src[i + call.len()..];
2322                         if let Some(end) = rest.find('"') {
2323                             let n = &rest[..end];
2324                             if !n.is_empty() && n.chars().all(|c| c.is_ascii_lowercase() || c == '-') {
2325                                 names.insert(n.to_string());
2326                             }
2327                         }
2328                     }
2329                 }
2330             }
2331         }
2332         for g in ["check", "circle", "circle-outline", "chevron-left", "chevron-right", "chevron-up", "chevron-down"] {
2333             names.insert(g.to_string());
2334         }
2335         let missing: Vec<_> = names.iter().filter(|n| !dir.join(format!("{n}.svg")).is_file()).collect();
2336         assert!(missing.is_empty(), "named but not in {}: {missing:?}", dir.display());
2337     }
2338 
2339     /// The plate pads its rows evenly: the height is the rows plus a pad
2340     /// above and below, the labels sit one pad in from the left with the
2341     /// widest one a pad from the right, and the padding is plate — a pointer
2342     /// in it hovers no row, and a pointer a row down from the top pad is on
2343     /// row 1, not row 0 plus a fraction.
2344     #[test]
2345     fn rows_sit_inside_an_even_pad() {
2346         let mut m = ContextMenuState::new();
2347         m.show(100.0, 200.0, vec!["Hide Geometry".into(), "-".into(), "Delete".into()], 0, WidgetId(1));
2348         assert_eq!(m.h, 3.0 * ROW_H + 2.0 * PAD);
2349         assert!(m.w >= 2.0 * PAD);
2350         let labels = m.text_labels();
2351         assert!(labels.iter().all(|l| l.x == 100.0 + PAD), "labels start one pad in");
2352         assert_eq!(labels[0].y, 200.0 + PAD + (ROW_H - labels[0].font_size) / 2.0, "row 0 starts under the top pad");
2353 
2354         assert_eq!(m.row_at(110.0, 200.0 + PAD * 0.5), None, "the top pad is no row");
2355         assert_eq!(m.row_at(110.0, 200.0 + PAD + ROW_H * 0.5), Some(0));
2356         assert_eq!(m.row_at(110.0, 200.0 + PAD + ROW_H * 2.5), Some(2));
2357         assert_eq!(m.row_at(110.0, 200.0 + m.h - PAD * 0.5), None, "the bottom pad is no row");
2358 
2359         m.cursor_moved(110.0, 200.0 + PAD * 0.5);
2360         assert_eq!(m.hovered_item, None);
2361         m.cursor_moved(110.0, 200.0 + PAD + ROW_H * 1.5);
2362         assert_eq!(m.hovered_item, None, "a separator row never hovers");
2363         m.cursor_moved(110.0, 200.0 + PAD + ROW_H * 2.5);
2364         assert_eq!(m.hovered_item, Some(2));
2365     }
2366 
2367     /// A label with a glyph the menu face lacks — the radio marks the
2368     /// designer's pin rows carry — is measured as the renderer shapes it,
2369     /// fallback face and all, so the plate is wide enough for what is drawn.
2370     /// The SVG-inked measure alone called the mark next to nothing.
2371     #[test]
2372     fn a_fallback_glyph_widens_the_plate_as_drawn() {
2373         let mut m = ContextMenuState::new();
2374         m.show(0.0, 0.0, vec!["● Follow Active Editor".into()], 0, WidgetId(1));
2375         let (family, size) = super::context_menu::label_font();
2376         let drawn = {
2377             let mut fs = crate::geometry_font_system().lock().unwrap();
2378             crate::backend::text::shaped_cluster_offsets(&mut fs, "● Follow Active Editor", size, Some(&family))
2379                 .last()
2380                 .map(|&(_, t)| t)
2381                 .unwrap()
2382         };
2383         assert!(drawn > 0.0);
2384         assert!(m.w >= drawn + 2.0 * PAD, "plate {} narrower than the drawn label {} plus pads", m.w, drawn);
2385     }
2386 }
2387 
2388 #[cfg(test)]
2389 mod context_menu_page_tests {
2390     use super::context_menu::{self, PageTurn, PAD, ROW_H};
2391     use crate::widget::{MouseScrollDelta, Position, WidgetId};
2392 
2393     fn open() {
2394         context_menu::show(400.0, 200.0, vec!["Frame".into(), "Style".into(), "Markers".into(), "Exit".into()], 0, WidgetId(1));
2395         context_menu::set_row_page(1);
2396         context_menu::set_row_page(2);
2397     }
2398 
2399     fn over(idx: usize) -> (f32, f32) {
2400         (context_menu::x() + 20.0, context_menu::row_y(idx) + ROW_H * 0.5)
2401     }
2402 
2403     /// One swipe, a few events long, the fingers lifted after. The runner's
2404     /// phase is left alone — it is one value for the whole process and
2405     /// other tests set it.
2406     fn swipe(dx: f64, x: f32, y: f32) -> bool {
2407         crate::widget::side_swipe::end_gesture();
2408         let mut took = false;
2409         for _ in 0..4 {
2410             took |= context_menu::mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: dx / 4.0, y: 0.0 }), x, y);
2411         }
2412         took
2413     }
2414 
2415     /// A page row is a row that turns the plate: a press on it, or a swipe
2416     /// forward with the pointer on it, asks for its page, and nothing opens
2417     /// on hover alone.
2418     #[test]
2419     fn a_page_row_turns_on_a_press_or_a_swipe() {
2420         open();
2421         let (x, y) = over(1);
2422         context_menu::cursor_moved(x, y);
2423         assert_eq!(context_menu::take_turn(), None, "hovering turns nothing");
2424         assert_eq!(context_menu::turn_at(x, y), Some(PageTurn::Into(1)));
2425         let (ex, ey) = over(3);
2426         assert_eq!(context_menu::turn_at(ex, ey), None, "an action row is no page");
2427 
2428         assert!(swipe(-80.0, x, y), "the swipe was the menu's");
2429         assert_eq!(context_menu::take_turn(), Some(PageTurn::Into(1)));
2430         assert_eq!(context_menu::take_turn(), None, "taken once");
2431         for _ in 0..4 {
2432             context_menu::mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: -20.0, y: 0.0 }), x, y);
2433         }
2434         assert_eq!(context_menu::take_turn(), None, "one turn a gesture");
2435         open();
2436         assert!(!swipe(-80.0, ex, ey), "forward over an action row turns nothing");
2437         open();
2438         assert!(!swipe(80.0, x, y), "back from a menu that was opened goes nowhere");
2439         assert_eq!(context_menu::take_turn(), None);
2440         context_menu::hide();
2441     }
2442 
2443     /// A turn is animated: the plate grows or shrinks from the size of the
2444     /// one it replaced, a host surface is given room for both meanwhile, and
2445     /// after `TURN_MS` it is the page's own. A plain show does not animate.
2446     #[test]
2447     fn a_page_turn_grows_the_plate_from_the_one_it_replaced() {
2448         context_menu::show(400.0, 200.0, (0..12).map(|i| format!("Row {i}")).collect(), 0, WidgetId(1));
2449         assert!(!context_menu::is_turning(), "a menu opened is not a turn");
2450         let (_, w0, h0) = context_menu::natural_geometry();
2451         let (mx, my) = (context_menu::x(), context_menu::y());
2452         context_menu::show_page(mx, my, Some("View"), vec!["One".into()], 0, WidgetId(1));
2453         assert!(context_menu::is_turning());
2454         let drawn = context_menu::with_state(|m| m.borrow().drawn_rect());
2455         let own = context_menu::h();
2456         assert!(own < h0 && drawn.height > own && drawn.height <= h0, "on its way down: {} between {own} and {h0}", drawn.height);
2457         let (_, w, h) = context_menu::natural_geometry();
2458         assert_eq!((w, h), (w0.max(context_menu::w()), h0), "room for both while it turns");
2459 
2460         std::thread::sleep(std::time::Duration::from_millis(context_menu::TURN_MS as u64 + 40));
2461         assert!(!context_menu::is_turning());
2462         let (_, w, h) = context_menu::natural_geometry();
2463         assert_eq!((w, h), (context_menu::w(), own), "its own size once it has landed");
2464 
2465         // A page shown in the moment the menu was put down turns from it too.
2466         context_menu::hide();
2467         context_menu::show_page(mx, my, None, (0..12).map(|i| format!("Row {i}")).collect(), 0, WidgetId(1));
2468         assert!(context_menu::is_turning(), "handed over from the menu just hidden");
2469         context_menu::hide();
2470     }
2471 
2472     /// A turn fades the rows' geometry too, not only their labels: the
2473     /// separators of the plate it leaves and of the page it brings are both
2474     /// drawn, each at part of its strength, and at the end only the page's,
2475     /// whole.
2476     #[test]
2477     fn a_turn_fades_the_separators_of_both_plates() {
2478         let grooves = || {
2479             let mut pc = crate::scene::paint::PaintCtx::new();
2480             context_menu::paint(&mut pc);
2481             pc.finish()
2482                 .items
2483                 .into_iter()
2484                 .filter_map(|it| match it.prim {
2485                     crate::scene::paint::Prim::Groove { strength, .. } => Some(strength),
2486                     _ => None,
2487                 })
2488                 .collect::<Vec<f32>>()
2489         };
2490         context_menu::show(400.0, 200.0, vec!["A".into(), "-".into(), "B".into()], 0, WidgetId(1));
2491         assert_eq!(grooves(), vec![1.0], "a menu's separator, whole");
2492         let (mx, my) = (context_menu::x(), context_menu::y());
2493         context_menu::show_page(mx, my, Some("View"), vec!["C".into(), "-".into(), "D".into(), "E".into()], 0, WidgetId(1));
2494         // The band's groove is drawn with the page's rows at their strength.
2495         let mid = grooves();
2496         assert!(mid.len() >= 2, "both plates' separators while it turns: {mid:?}");
2497         assert!(mid.iter().all(|s| *s < 1.0), "each faded: {mid:?}");
2498         std::thread::sleep(std::time::Duration::from_millis(context_menu::turn_ms() as u64 + 40));
2499         let after = grooves();
2500         assert!(!after.is_empty() && after.iter().all(|s| *s == 1.0), "the page's alone, whole: {after:?}");
2501         context_menu::hide();
2502     }
2503 
2504     /// A page stands where the menu stood, under a back band that a press or
2505     /// a swipe back turns back from; its rows begin under the band.
2506     #[test]
2507     fn a_page_stands_in_the_menus_place_with_a_way_back() {
2508         open();
2509         let (mx, my) = (context_menu::x(), context_menu::y());
2510         context_menu::show_page(mx, my, Some("View"), vec!["○ Wireframe".into(), "Opacity".into()], 0, WidgetId(1));
2511         assert!(context_menu::is_turned());
2512         assert_eq!((context_menu::x(), context_menu::y()), (mx, my));
2513         assert_eq!(context_menu::back_title().as_deref(), Some("View"));
2514         assert_eq!(context_menu::row_y(0), my + PAD + ROW_H, "the rows begin under the band");
2515         assert_eq!(context_menu::h(), 2.0 * ROW_H + ROW_H + 2.0 * PAD);
2516 
2517         let band = (mx + 20.0, my + PAD + ROW_H * 0.5);
2518         assert_eq!(context_menu::row_at(band.0, band.1), None, "the band is no row");
2519         assert_eq!(context_menu::turn_at(band.0, band.1), Some(PageTurn::Back));
2520         let (x, y) = over(1);
2521         assert_eq!(context_menu::row_at(x, y), Some(1));
2522         assert!(swipe(80.0, x, y));
2523         assert_eq!(context_menu::take_turn(), Some(PageTurn::Back), "back from anywhere on the page");
2524 
2525         // A placement that cannot fit it below slides it, never flips it up
2526         // from the corner it took over.
2527         context_menu::constrain_to(0.0, 0.0, 2000.0, my + 10.0);
2528         assert!(context_menu::y() + context_menu::h() <= my + 10.0 + 0.01);
2529         assert!(context_menu::y() >= 0.0);
2530         context_menu::hide();
2531     }
2532 }
2533 
2534 #[cfg(test)]
2535 mod context_menu_action_tests {
2536     use super::context_menu::{self, ROW_H};
2537     use crate::context::UiContext;
2538     use crate::widget::{ContextAction, ElementState, MouseButton, Widget, WidgetHost};
2539 
2540     /// A widget that remembers the last action a menu ran on it.
2541     struct Recorder {
2542         base: Widget,
2543         got: Option<ContextAction>,
2544     }
2545     impl crate::widget::Input for Recorder {
2546         fn context_action(&mut self, action: ContextAction) -> bool {
2547             self.got = Some(action);
2548             true
2549         }
2550     }
2551     impl WidgetHost for Recorder {
2552         crate::impl_widget_base!(Recorder);
2553         fn input_model_mut(&mut self) -> &mut dyn crate::widget::Input {
2554             self
2555         }
2556     }
2557 
2558     fn press_row(ctx: &mut UiContext, idx: usize) {
2559         let (x, y) = (context_menu::x() + 10.0, context_menu::row_y(idx) + ROW_H * 0.5);
2560         context_menu::mouse_input(MouseButton::Left, ElementState::Pressed, x, y, Some(ctx));
2561     }
2562 
2563     #[test]
2564     fn a_row_runs_its_action_whatever_its_label_says() {
2565         let mut ctx = UiContext::new();
2566         let w = ctx.insert(Recorder { base: Widget::new(), got: None });
2567         let id = w.id();
2568 
2569         // A label the English table has never seen: only the row's action can say what it is.
2570         context_menu::show(0.0, 0.0, vec!["[Recorder]".into(), "Kopieren".into()], 1, id);
2571         context_menu::set_row_actions(vec![None, Some(ContextAction::Copy)]);
2572         press_row(&mut ctx, 1);
2573         assert_eq!(ctx[w].got, Some(ContextAction::Copy));
2574 
2575         // A row with an action and an English label that names ANOTHER: the action wins.
2576         ctx[w].got = None;
2577         context_menu::show(0.0, 0.0, vec!["[Recorder]".into(), "Paste".into()], 1, id);
2578         context_menu::set_row_actions(vec![None, Some(ContextAction::SelectAll)]);
2579         press_row(&mut ctx, 1);
2580         assert_eq!(ctx[w].got, Some(ContextAction::SelectAll));
2581 
2582         // A menu built without actions still works in English, through the fallback.
2583         ctx[w].got = None;
2584         context_menu::show(0.0, 0.0, vec!["[Recorder]".into(), "Paste".into()], 1, id);
2585         press_row(&mut ctx, 1);
2586         assert_eq!(ctx[w].got, Some(ContextAction::Paste));
2587     }
2588 
2589     #[test]
2590     fn the_toolkits_own_menus_carry_their_actions() {
2591         let mut ctx = UiContext::new();
2592         let tb = ctx.insert(crate::widget::TextBox::new(String::new()).with_label("Name"));
2593         ctx.lend_h(tb, |w, ctx| ctx.handle_right_click(w, 5.0, 5.0));
2594         let options = context_menu::options();
2595         let header = context_menu::header_count();
2596         assert!(options.len() > header, "a text box's menu has rows");
2597         for (i, label) in options.iter().enumerate().skip(header) {
2598             assert!(context_menu::row_action(i).is_some(), "row {label:?} has no action of its own");
2599         }
2600         context_menu::hide();
2601     }
2602 }