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 }