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

src/widget/core.rs (36.4K)

  1 use crate::widget::WidgetHost;
  2 
  3 pub mod focus {
  4     use super::WidgetHost;
  5     use crate::widget::WidgetId;
  6     use std::cell::Cell;
  7 
  8     // Phase 6bc: the thread-local focus store keys by id, not pointer. Dispatching to the
  9     // previous holder (`unfocus`) resolves through the caller's generational tree, so a
 10     // stale id is skipped instead of dereferencing freed memory (the 6w settings UAF class).
 11     thread_local! {
 12         static FOCUSED_WIDGET: Cell<Option<WidgetId>> = Cell::new(None);
 13     }
 14 
 15     /// Resolve `id` in `ctx`'s tree (when a ctx is in reach) and call `unfocus()` on it.
 16     fn unfocus_via(ctx: Option<&mut crate::context::UiContext>, id: WidgetId) {
 17         if let Some(ctx) = ctx {
 18             if let Some(ptr) = ctx.tree.get_ptr(id) {
 19                 unsafe {
 20                     (*ptr).unfocus();
 21                 }
 22             }
 23         }
 24     }
 25 
 26     pub fn set_focused(w: &mut dyn WidgetHost, ctx: Option<&mut crate::context::UiContext>) {
 27         set_focused_id(w.base().id(), ctx);
 28     }
 29 
 30     pub fn set_focused_id(id: WidgetId, ctx: Option<&mut crate::context::UiContext>) {
 31         let old = FOCUSED_WIDGET.with(|cell| cell.get());
 32         if let Some(old_id) = old {
 33             if old_id != id {
 34                 unfocus_via(ctx, old_id);
 35                 FOCUSED_WIDGET.with(|cell| cell.set(Some(id)));
 36             }
 37         } else {
 38             FOCUSED_WIDGET.with(|cell| cell.set(Some(id)));
 39         }
 40     }
 41 
 42     pub fn is_focused(w: &dyn WidgetHost) -> bool {
 43         is_focused_id(w.base().id())
 44     }
 45 
 46     pub fn is_focused_id(id: WidgetId) -> bool {
 47         FOCUSED_WIDGET.with(|cell| cell.get() == Some(id))
 48     }
 49 
 50     pub fn clear_focus(ctx: Option<&mut crate::context::UiContext>) {
 51         if let Some(id) = FOCUSED_WIDGET.with(|cell| cell.take()) {
 52             unfocus_via(ctx, id);
 53         }
 54     }
 55 
 56     pub fn clear_if_matches(w: &dyn WidgetHost) {
 57         clear_if_matches_id(w.base().id());
 58     }
 59 
 60     pub fn clear_if_matches_id(id: WidgetId) {
 61         FOCUSED_WIDGET.with(|cell| {
 62             if cell.get() == Some(id) {
 63                 cell.set(None);
 64             }
 65         });
 66     }
 67 
 68     pub fn has_focus() -> bool {
 69         FOCUSED_WIDGET.with(|cell| cell.get().is_some())
 70     }
 71 
 72     pub fn link_parent_child(parent: &mut dyn WidgetHost, child: &mut dyn WidgetHost, ctx: &mut crate::context::UiContext) {
 73         let parent_ptr = unsafe {
 74             std::mem::transmute::<*mut dyn WidgetHost, *mut (dyn WidgetHost + 'static)>(parent as *mut dyn WidgetHost)
 75         };
 76         let child_ptr = unsafe {
 77             std::mem::transmute::<*mut dyn WidgetHost, *mut (dyn WidgetHost + 'static)>(child as *mut dyn WidgetHost)
 78         };
 79         let (p_id, c_id) = (parent.base().id(), child.base().id());
 80         ctx.register_widget(p_id, parent_ptr);
 81         ctx.register_widget(c_id, child_ptr);
 82         // The old add_child + set_parent pair, as the tree ops they always were.
 83         ctx.tree.link(p_id, c_id);
 84         ctx.tree.set_parent(c_id, Some(p_id));
 85     }
 86 
 87     // `navigate_focus` is DELETED (the plumbing retype): it resolved parent/children
 88     // through a freshly-made EMPTY UiContext, so the parent-based arms (ctrl+u/j/k) could
 89     // never fire and ctrl+i only fired for a focused container-children widget (Paginator
 90     // — never focusable). Its one caller (settings) already runs its own section nav.
 91 }
 92 
 93 pub mod hover_animation {
 94     use std::cell::RefCell;
 95 
 96     #[derive(Debug, Clone)]
 97     pub struct HoverState {
 98         pub current_x: f32,
 99         pub current_y: f32,
100         pub current_w: f32,
101         pub current_h: f32,
102         pub current_alpha: f32,
103 
104         pub target_x: Option<f32>,
105         pub target_y: Option<f32>,
106         pub target_w: Option<f32>,
107         pub target_h: Option<f32>,
108         pub target_alpha: f32,
109 
110         pub registered_this_frame: bool,
111         pub scroll_offset: f32,
112     }
113 
114     impl HoverState {
115         pub fn new() -> Self {
116             Self {
117                 current_x: 0.0,
118                 current_y: 0.0,
119                 current_w: 0.0,
120                 current_h: 0.0,
121                 current_alpha: 0.0,
122 
123                 target_x: None,
124                 target_y: None,
125                 target_w: None,
126                 target_h: None,
127                 target_alpha: 0.0,
128 
129                 registered_this_frame: false,
130                 scroll_offset: 0.0,
131             }
132         }
133     }
134 
135     thread_local! {
136         pub static HOVER_STATE: RefCell<HoverState> = RefCell::new(HoverState::new());
137         pub static CURSOR_POS: RefCell<(f32, f32)> = RefCell::new((0.0, 0.0));
138     }
139 
140     pub fn set_cursor_pos(x: f32, y: f32) {
141         CURSOR_POS.with(|pos| {
142             *pos.borrow_mut() = (x, y);
143         });
144     }
145 
146     pub fn reset_frame_registration() {
147         HOVER_STATE.with(|state| {
148             state.borrow_mut().registered_this_frame = false;
149         });
150     }
151 
152     pub fn set_scroll_offset(offset: f32) {
153         HOVER_STATE.with(|state| {
154             state.borrow_mut().scroll_offset = offset;
155         });
156     }
157 
158     pub fn get_scroll_offset() -> f32 {
159         HOVER_STATE.with(|state| {
160             state.borrow().scroll_offset
161         })
162     }
163 
164     pub fn register_hovered(x: f32, y: f32, w: f32, h: f32, color: [f32; 4]) {
165         HOVER_STATE.with(|state| {
166             let mut s = state.borrow_mut();
167             s.target_x = Some(x);
168             s.target_y = Some(y);
169             s.target_w = Some(w);
170             s.target_h = Some(h);
171             s.target_alpha = color[3];
172             s.registered_this_frame = true;
173         });
174     }
175 
176     pub fn post_render_check() {
177         HOVER_STATE.with(|state| {
178             let mut s = state.borrow_mut();
179             if !s.registered_this_frame {
180                 s.target_alpha = 0.0;
181                 let (cx, cy) = CURSOR_POS.with(|pos| *pos.borrow());
182                 s.target_x = Some(cx);
183                 s.target_y = Some(cy + s.scroll_offset);
184                 s.target_w = Some(0.0);
185                 s.target_h = Some(0.0);
186             }
187         });
188     }
189 
190     pub fn tick(dt: f32) -> bool {
191         HOVER_STATE.with(|state| {
192             let mut s = state.borrow_mut();
193             let decay = 15.0;
194             // The per-tick approach fraction; 1 lands on the target at once,
195             // which is the whole of animations-off for the highlight.
196             let k = if crate::motion::enabled() { 1.0 - (-decay * dt).exp() } else { 1.0 };
197             let mut changed = false;
198 
199             if s.current_alpha <= 0.001 && s.target_alpha > 0.0 {
200                 if let (Some(tx), Some(ty), Some(tw), Some(th)) = (s.target_x, s.target_y, s.target_w, s.target_h) {
201                     s.current_x = tx;
202                     s.current_y = ty;
203                     s.current_w = tw;
204                     s.current_h = th;
205                 }
206             }
207 
208             if (s.current_alpha - s.target_alpha).abs() > 0.001 {
209                 s.current_alpha += (s.target_alpha - s.current_alpha) * k;
210                 changed = true;
211             } else if s.current_alpha != s.target_alpha {
212                 s.current_alpha = s.target_alpha;
213                 changed = true;
214             }
215 
216             if let (Some(tx), Some(ty), Some(tw), Some(th)) = (s.target_x, s.target_y, s.target_w, s.target_h) {
217                 if (s.current_x - tx).abs() > 0.1 {
218                     s.current_x += (tx - s.current_x) * k;
219                     changed = true;
220                 } else if s.current_x != tx {
221                     s.current_x = tx;
222                     changed = true;
223                 }
224 
225                 if (s.current_y - ty).abs() > 0.1 {
226                     s.current_y += (ty - s.current_y) * k;
227                     changed = true;
228                 } else if s.current_y != ty {
229                     s.current_y = ty;
230                     changed = true;
231                 }
232 
233                 if (s.current_w - tw).abs() > 0.1 {
234                     s.current_w += (tw - s.current_w) * k;
235                     changed = true;
236                 } else if s.current_w != tw {
237                     s.current_w = tw;
238                     changed = true;
239                 }
240 
241                 if (s.current_h - th).abs() > 0.1 {
242                     s.current_h += (th - s.current_h) * k;
243                     changed = true;
244                 } else if s.current_h != th {
245                     s.current_h = th;
246                     changed = true;
247                 }
248             }
249 
250             changed
251         })
252     }
253 
254     pub fn get_quad() -> Option<(f32, f32, f32, f32, [f32; 4])> {
255         HOVER_STATE.with(|state| {
256             let s = state.borrow();
257             if s.current_alpha > 0.001 {
258                 Some((
259                     s.current_x,
260                     s.current_y,
261                     s.current_w,
262                     s.current_h,
263                     [1.0, 1.0, 1.0, s.current_alpha],
264                 ))
265             } else {
266                 None
267             }
268         })
269     }
270 }
271 
272 pub mod clipboard {
273     pub fn copy_to_clipboard(text: &str) {
274         let text = text.to_string();
275         std::thread::spawn(move || {
276             if let Ok(mut child) = std::process::Command::new("wl-copy")
277                 .stdin(std::process::Stdio::piped())
278                 .spawn()
279             {
280                 if let Some(mut stdin) = child.stdin.take() {
281                     use std::io::Write;
282                     let _ = stdin.write_all(text.as_bytes());
283                 }
284                 let _ = child.wait();
285             } else if let Ok(mut child) = std::process::Command::new("xclip")
286                 .arg("-selection")
287                 .arg("clipboard")
288                 .stdin(std::process::Stdio::piped())
289                 .spawn()
290             {
291                 if let Some(mut stdin) = child.stdin.take() {
292                     use std::io::Write;
293                     let _ = stdin.write_all(text.as_bytes());
294                 }
295                 let _ = child.wait();
296             }
297         });
298     }
299 
300     pub fn read_from_clipboard() -> Option<String> {
301         match std::process::Command::new("wl-paste")
302             .arg("-n")
303             .output()
304         {
305             Ok(output) => {
306                 if output.status.success() {
307                     if let Ok(text) = String::from_utf8(output.stdout) {
308                         return Some(text);
309                     }
310                 }
311             }
312             Err(_) => {}
313         }
314         match std::process::Command::new("xclip")
315             .arg("-selection")
316             .arg("clipboard")
317             .arg("-o")
318             .output()
319         {
320             Ok(output) => {
321                 if output.status.success() {
322                     if let Ok(text) = String::from_utf8(output.stdout) {
323                         return Some(text);
324                     }
325                 }
326             }
327             Err(_) => {}
328         }
329         None
330     }
331 }
332 
333 pub mod context_menu {
334     use crate::widget::*;
335     use std::cell::RefCell;
336 
337     /// Height of one menu row. Sizing, both hit tests, the label run and the
338     /// plate's hover fill all step by this — they were five copies of a bare
339     /// `24.0`, and a menu whose rows are measured differently from where they
340     /// are drawn selects the entry above the one under the cursor.
341     pub const ROW_H: f32 = 24.0;
342     /// The plate's padding, the same on every side: the rows start this far
343     /// below the top edge and end this far above the bottom, and the labels
344     /// sit this far in from the left, with the widest label this far from
345     /// the right. Before 2026-09-21 the rows ran flush to the top and bottom
346     /// and the labels had 8px on the left against 16px on the right.
347     pub const PAD: f32 = 8.0;
348 
349     /// The face a menu label is drawn in: the DE's menu font, family and
350     /// size — the same `menubar_font` the menubar's own drop-downs use
351     /// ([`crate::widget::container::menu`]). A menu that hardcodes 12.0 and
352     /// leaves the family unset renders in the default sans while the list or
353     /// breadcrumb beneath it wears the configured face.
354     ///
355     /// Consumers need the family too: a [`TextLabel`] carries only a size, so
356     /// whoever turns these labels into text prims passes this family alongside
357     /// them (`pc.text_with(.., Some(family), ..)`).
358     pub fn label_font() -> (String, f32) {
359         crate::layout::menubar_font_parsed()
360     }
361 
362     /// A toolkit color as the `[u8; 3]` a [`TextLabel`] carries.
363     fn rgb8(c: [f32; 4]) -> [u8; 3] {
364         [
365             (c[0] * 255.0).round().clamp(0.0, 255.0) as u8,
366             (c[1] * 255.0).round().clamp(0.0, 255.0) as u8,
367             (c[2] * 255.0).round().clamp(0.0, 255.0) as u8,
368         ]
369     }
370 
371     #[derive(Debug, Clone)]
372     pub struct ContextMenuState {
373         pub x: f32,
374         pub y: f32,
375         pub w: f32,
376         pub h: f32,
377         pub visible: bool,
378         pub options: Vec<String>,
379         pub hovered_item: Option<usize>,
380         /// The action target, id-keyed (Phase 6bc slice 2): dispatch resolves it through the
381         /// caller's generational tree, so a stale target is a no-op, not a UAF.
382         pub target: Option<WidgetId>,
383         pub header_count: usize,
384     }
385 
386     impl ContextMenuState {
387         pub fn new() -> Self {
388             Self {
389                 x: 0.0,
390                 y: 0.0,
391                 w: 120.0,
392                 h: 0.0,
393                 visible: false,
394                 options: Vec::new(),
395                 hovered_item: None,
396                 target: None,
397                 header_count: 0,
398             }
399         }
400 
401         pub fn show(&mut self, x: f32, y: f32, options: Vec<String>, header_count: usize, target: WidgetId) {
402             self.x = x;
403             self.y = y;
404             self.options = options;
405             self.h = self.options.len() as f32 * ROW_H + 2.0 * PAD;
406             // Width from the widest label as the RENDERER shapes it —
407             // `shaped_cluster_offsets`, the same cosmic-text buffer cache the
408             // draw reads — not `measure_text_width`. That one rasterizes an
409             // SVG through fontdb and reports inked extent in the named face
410             // alone: a glyph the face lacks (the radio marks "●" / "○" the
411             // designer's pin rows carry, which Berkeley Mono has not) measures
412             // as next to nothing while the draw lands it from a fallback face
413             // a full advance wide, and the label ran off the plate's right
414             // edge (2026-09-21). The inked measure is kept as a floor, so a
415             // host whose font system has no bundled faces never measures
416             // narrower than before.
417             let (family, size) = label_font();
418             let widest = self
419                 .options
420                 .iter()
421                 .map(|s| {
422                     let inked = crate::widget::display::measure_text_width(s, &family, size);
423                     let shaped = crate::geometry_font_system()
424                         .lock()
425                         .ok()
426                         .and_then(|mut fs| {
427                             crate::backend::window_runner::shaped_cluster_offsets(&mut fs, s, size, Some(&family))
428                                 .last()
429                                 .map(|&(_, total)| total)
430                         })
431                         .unwrap_or(0.0);
432                     inked.max(shaped)
433                 })
434                 .fold(0.0f32, f32::max);
435             self.w = (widest + 2.0 * PAD).max(120.0);
436             self.visible = true;
437             self.hovered_item = None;
438             self.target = Some(target);
439             self.header_count = header_count;
440         }
441 
442         pub fn hide(&mut self) {
443             self.visible = false;
444             self.target = None;
445         }
446 
447         pub fn hit_test(&self, px: f32, py: f32) -> bool {
448             if !self.visible { return false; }
449             px >= self.x && px <= self.x + self.w && py >= self.y && py <= self.y + self.h
450         }
451 
452         /// The top of row `idx`.
453         pub fn row_y(&self, idx: usize) -> f32 {
454             self.y + PAD + idx as f32 * ROW_H
455         }
456 
457         /// The row under `(px, py)`, or `None` outside the plate or in its
458         /// padding — the padding is plate, not a row, so a press there
459         /// neither hovers nor fires row 0.
460         pub fn row_at(&self, px: f32, py: f32) -> Option<usize> {
461             if px < self.x || px > self.x + self.w {
462                 return None;
463             }
464             let rel = py - self.y - PAD;
465             if rel < 0.0 {
466                 return None;
467             }
468             let idx = (rel / ROW_H) as usize;
469             (idx < self.options.len()).then_some(idx)
470         }
471 
472         pub fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
473             if !self.visible { return false; }
474             let was_hovered = self.hovered_item;
475             self.hovered_item = None;
476             if let Some(idx) = self.row_at(px, py) {
477                 // A "-" row is a SEPARATOR (the dropdown's convention):
478                 // engraved, never hovered, never an action.
479                 if idx >= self.header_count && self.options[idx] != "-" {
480                     self.hovered_item = Some(idx);
481                 }
482             }
483             self.hovered_item != was_hovered
484         }
485 
486         pub fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ctx: Option<&mut crate::context::UiContext>) -> bool {
487             if !self.visible { return false; }
488             if button != MouseButton::Left || state != ElementState::Pressed {
489                 if state == ElementState::Pressed {
490                     self.hide();
491                     return true;
492                 }
493                 return false;
494             }
495 
496             if self.hit_test(px, py) {
497                 if let Some(idx) = self.row_at(px, py) {
498                     if idx >= self.header_count {
499                         let opt = self.options[idx].clone();
500                         if let (Some(target_id), Some(ctx)) = (self.target, ctx) {
501                             if let Some(target_ptr) = ctx.tree.get_ptr(target_id) {
502                                 unsafe {
503                                     let target = &mut *target_ptr;
504                                     use crate::widget::ContextAction as CA;
505                                     let action = match opt.as_str() {
506                                         "Cut" => Some(CA::Cut),
507                                         "Copy" => Some(CA::Copy),
508                                         "Paste" => Some(CA::Paste),
509                                         "Select All" => Some(CA::SelectAll),
510                                         "Undo" => Some(CA::Undo),
511                                         "Redo" => Some(CA::Redo),
512                                         "Clear" => Some(CA::ClearText),
513                                         "Copy Key" => Some(CA::CopyKey),
514                                         "Copy Value" => Some(CA::CopyValue),
515                                         "Delete" => Some(CA::DeleteKey),
516                                         "Expand" => Some(CA::ExpandNode),
517                                         "Collapse" => Some(CA::CollapseNode),
518                                         "Expand All" => Some(CA::ExpandAll),
519                                         "Collapse All" => Some(CA::CollapseAll),
520                                         "Copy Path" => Some(CA::CopyPath),
521                                         // The Ramp toggle carries its check state in the label.
522                                         "✓ Collapse controls" | "Collapse controls" => Some(CA::ToggleRampControls),
523                                         _ => None,
524                                     };
525                                     if let Some(action) = action {
526                                         let _ = target.context_action(action);
527                                     }
528                                 }
529                             }
530                         }
531                     }
532                 }
533                 self.hide();
534                 return true;
535             } else {
536                 self.hide();
537                 return true;
538             }
539         }
540 
541         /// Paint the menu as a lit plate: a rounded face in the DE's plate color,
542         /// translucent and frosted (the negative-alpha blur-behind sentinel), with
543         /// the rolled perimeter — the material every other floating surface in the
544         /// DE wears. Hosts on the display-list path call this INSTEAD of iterating
545         /// [`ContextMenuState::extra_quads`], then draw [`text_labels`] over it.
546         ///
547         /// A transparent configured plate color degrades to the edges-only boss,
548         /// as the breadcrumb's raised run does: with no face to tint, a plate
549         /// would paint a hole.
550         ///
551         /// [`text_labels`]: ContextMenuState::text_labels
552         pub fn paint(&self, ctx: &mut crate::scene::paint::PaintCtx) {
553             if !self.visible {
554                 return;
555             }
556             let rect = crate::scene::layout::Rect {
557                 x: self.x,
558                 y: self.y,
559                 width: self.w,
560                 height: self.h,
561             };
562             let r = crate::layout::menu_corner_radius();
563             let depth = crate::layout::bevel_width().min(self.h * 0.2);
564             let face = crate::color::page_low_color();
565             if face[3] > 0.001 {
566                 // The popover material: the page colour at menu_opacity,
567                 // frosted (an opaque page colour would resolve the frost
568                 // to a solid tint, invisible).
569                 ctx.plate(rect, (r, r, r, r), &crate::scene::material::Material::popover(face), depth);
570             } else {
571                 let (plateau, radii) = crate::layout::carve_inside(rect, (r, r, r, r), depth);
572                 ctx.boss(plateau, radii, depth);
573             }
574 
575             if let Some(h_idx) = self.hovered_item {
576                 // Inset off the roll so the fill sits on the face instead of
577                 // climbing the lit edge, and round the corners it actually meets:
578                 // the first and last rows touch the plate's, and a header row is
579                 // never hovered, so the top pair only rounds when there is no
580                 // header above.
581                 // Inside the padding on every side — the rows no longer
582                 // touch the plate's edge, so the fill is its own rounded
583                 // tablet on the face rather than a band that meets the roll.
584                 let iy = self.row_y(h_idx);
585                 let inset = (depth * 0.5).max(2.0).max(PAD * 0.5);
586                 ctx.rounded_rect(
587                     crate::scene::layout::Rect {
588                         x: self.x + inset,
589                         y: iy + 2.0,
590                         width: self.w - 2.0 * inset,
591                         height: ROW_H - 4.0,
592                     },
593                     (r - inset).max(0.0),
594                     (true, true, true, true),
595                     [0.20, 0.40, 0.65, 0.6],
596                 );
597             }
598 
599             // Separator rows ("-"): an engraved line across the face at the
600             // row's vertical centre — the breadcrumb seam's language, cut
601             // into the menu plate instead of a printed dash.
602             for (idx, opt) in self.options.iter().enumerate() {
603                 if opt == "-" {
604                     let cy = self.row_y(idx) + ROW_H * 0.5;
605                     let inset = (depth * 0.5).max(PAD);
606                     ctx.groove(
607                         (self.x + inset, cy),
608                         (self.x + self.w - inset, cy),
609                         0.75,
610                         depth,
611                         rect,
612                     );
613                 }
614             }
615         }
616 
617         /// The flat-quad menu: a 1px border rect, a near-black fill and the hover
618         /// row. Superseded by [`ContextMenuState::paint`], which draws the menu as
619         /// the lit plate the rest of the DE's floating surfaces wear; this stays
620         /// for hosts that have not migrated, and renders as it always has.
621         pub fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
622             let mut quads = Vec::new();
623             if !self.visible { return quads; }
624 
625             // border
626             quads.push((self.x, self.y, self.w, self.h, [0.22, 0.22, 0.28, 1.0]));
627             // bg
628             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]));
629 
630             if let Some(h_idx) = self.hovered_item {
631                 let iy = self.row_y(h_idx);
632                 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]));
633             }
634 
635             // Separator rows ("-"): a hairline in place of the engraved
636             // groove the plate path cuts.
637             for (idx, opt) in self.options.iter().enumerate() {
638                 if opt == "-" {
639                     let cy = self.row_y(idx) + ROW_H * 0.5;
640                     quads.push((self.x + PAD, cy, self.w - 2.0 * PAD, 1.0, [0.22, 0.22, 0.28, 1.0]));
641                 }
642             }
643 
644             quads
645         }
646 
647         /// [`paint`](Self::paint) plus the label run, in the menu font.
648         ///
649         /// A [`TextLabel`] carries a size but no family, so a consumer that
650         /// hand-rolls `paint()` + a `text_labels()` loop has to remember to
651         /// pass [`label_font`]'s family itself — and every one of them passed
652         /// `None`, which is why menus rendered in the default sans over lists
653         /// wearing the configured face. This is the call that cannot forget
654         /// it; prefer it over the pair.
655         pub fn paint_with_labels(&self, ctx: &mut crate::scene::paint::PaintCtx) {
656             self.paint(ctx);
657             if !self.visible {
658                 return;
659             }
660             let (family, _) = label_font();
661             // The menu's own rect: the engine's popover clamp exempts exactly
662             // these bounds, so the labels render inside the plate instead of
663             // being clipped to the page content beneath it.
664             let bounds = Some([self.x, self.y, self.x + self.w, self.y + self.h]);
665             for label in self.text_labels() {
666                 ctx.text_with(
667                     label.text,
668                     label.x,
669                     label.y,
670                     label.font_size,
671                     label.color,
672                     Some(family.clone()),
673                     bounds,
674                 );
675             }
676         }
677 
678         pub fn text_labels(&self) -> Vec<TextLabel> {
679             let mut labels = Vec::new();
680             if !self.visible { return labels; }
681 
682             for (idx, opt) in self.options.iter().enumerate() {
683                 if opt == "-" {
684                     continue;
685                 }
686                 let (_, label_size) = label_font();
687                 let iy = self.row_y(idx) + (ROW_H - label_size) / 2.0;
688                 // The toolkit's semantic colors rather than greys hand-mixed
689                 // against the old near-black fill: on the plate's mid-slate the
690                 // header's 0x70 was a step above its background and read as
691                 // nothing.
692                 let text_color = if idx < self.header_count {
693                     rgb8(crate::color::TEXT_DIM)
694                 } else if self.hovered_item == Some(idx) {
695                     rgb8(crate::color::TEXT_HEADER)
696                 } else {
697                     rgb8(crate::color::TEXT_FG)
698                 };
699 
700                 labels.push(TextLabel {
701                     text: opt.clone(),
702                     x: self.x + PAD,
703                     y: iy,
704                     font_size: label_size,
705                     color: text_color,
706                 });
707             }
708             labels
709         }
710     }
711 
712     thread_local! {
713         pub static CONTEXT_MENU: RefCell<ContextMenuState> = RefCell::new(ContextMenuState::new());
714     }
715 
716     pub fn is_visible() -> bool {
717         CONTEXT_MENU.with(|m| m.borrow().visible)
718     }
719 
720     pub fn show(x: f32, y: f32, options: Vec<String>, header_count: usize, target: WidgetId) {
721         CONTEXT_MENU.with(|m| m.borrow_mut().show(x, y, options, header_count, target));
722     }
723 
724     pub fn hide() {
725         CONTEXT_MENU.with(|m| m.borrow_mut().hide());
726     }
727 
728     pub fn clear_if_matches(w: &dyn WidgetHost) {
729         let id = w.base().id();
730         CONTEXT_MENU.with(|m| {
731             let mut menu = m.borrow_mut();
732             if menu.target == Some(id) {
733                 menu.target = None;
734                 menu.visible = false;
735             }
736         });
737     }
738 
739     pub fn x() -> f32 { CONTEXT_MENU.with(|m| m.borrow().x) }
740     pub fn y() -> f32 { CONTEXT_MENU.with(|m| m.borrow().y) }
741     pub fn w() -> f32 { CONTEXT_MENU.with(|m| m.borrow().w) }
742     pub fn h() -> f32 { CONTEXT_MENU.with(|m| m.borrow().h) }
743     pub fn hovered_item() -> Option<usize> { CONTEXT_MENU.with(|m| m.borrow().hovered_item) }
744     pub fn options() -> Vec<String> { CONTEXT_MENU.with(|m| m.borrow().options.clone()) }
745 
746     /// The row under a point, PAD-aware — the ONE row hit test. Every host
747     /// that dispatches the menu itself should ask this rather than divide
748     /// `(py - y()) / ROW_H`: the rows start `PAD` below the plate's top, so
749     /// that division names the row below over the bottom third of every
750     /// row, and runs off the end on the last one (2026-09-22 audit: six
751     /// call sites across five apps had it).
752     pub fn row_at(px: f32, py: f32) -> Option<usize> {
753         CONTEXT_MENU.with(|m| m.borrow().row_at(px, py))
754     }
755     /// A row's top, PAD-aware — for a host painting the rows itself.
756     pub fn row_y(idx: usize) -> f32 {
757         CONTEXT_MENU.with(|m| m.borrow().row_y(idx))
758     }
759     pub fn hit_test(px: f32, py: f32) -> bool {
760         CONTEXT_MENU.with(|m| m.borrow().hit_test(px, py))
761     }
762 
763     pub fn cursor_moved(px: f32, py: f32) -> bool {
764         CONTEXT_MENU.with(|m| m.borrow_mut().cursor_moved(px, py))
765     }
766 
767     pub fn mouse_input(button: MouseButton, state: ElementState, px: f32, py: f32, ctx: Option<&mut crate::context::UiContext>) -> bool {
768         CONTEXT_MENU.with(|m| m.borrow_mut().mouse_input(button, state, px, py, ctx))
769     }
770 
771     /// Paint the menu as a lit plate — see [`ContextMenuState::paint`]. Hosts on
772     /// the display-list path call this in place of the [`extra_quads`] loop.
773     pub fn paint(ctx: &mut crate::scene::paint::PaintCtx) {
774         CONTEXT_MENU.with(|m| m.borrow().paint(ctx));
775     }
776 
777     pub fn extra_quads() -> Vec<(f32, f32, f32, f32, [f32; 4])> {
778         CONTEXT_MENU.with(|m| m.borrow().extra_quads())
779     }
780 
781     pub fn text_labels() -> Vec<TextLabel> {
782         CONTEXT_MENU.with(|m| m.borrow().text_labels())
783     }
784 
785     /// Plate and labels in one call — see
786     /// [`ContextMenuState::paint_with_labels`].
787     pub fn paint_with_labels(ctx: &mut crate::scene::paint::PaintCtx) {
788         CONTEXT_MENU.with(|m| m.borrow().paint_with_labels(ctx));
789     }
790 }
791 
792 #[derive(Debug, Clone)]
793 pub struct Widget {
794     pub x: f32,
795     pub y: f32,
796     pub w: f32,
797     pub h: f32,
798     pub label: Option<String>,
799     pub hovered: bool,
800     pub row_x: f32,
801     pub row_w: f32,
802     pub focused: bool,
803     pub id: std::cell::Cell<Option<crate::widget::WidgetId>>,
804     pub dirty: bool,
805     pub config_file: Option<String>,
806     pub config_key: Option<String>,
807 }
808 
809 impl Widget {
810     pub fn new() -> Self {
811         Self {
812             x: 0.0,
813             y: 0.0,
814             w: 0.0,
815             h: 0.0,
816             label: None,
817             hovered: false,
818             row_x: 0.0,
819             row_w: 0.0,
820             focused: false,
821             id: std::cell::Cell::new(None),
822             dirty: true,
823             config_file: None,
824             config_key: None,
825         }
826     }
827 
828     pub fn new_rect(x: f32, y: f32, w: f32, h: f32) -> Self {
829         Self {
830             x,
831             y,
832             w,
833             h,
834             label: None,
835             hovered: false,
836             row_x: 0.0,
837             row_w: 0.0,
838             focused: false,
839             id: std::cell::Cell::new(None),
840             dirty: true,
841             config_file: None,
842             config_key: None,
843         }
844     }
845 
846     pub fn id(&self) -> crate::widget::WidgetId {
847         let current = self.id.get();
848         if let Some(id) = current {
849             id
850         } else {
851             let next = crate::widget::NEXT_WIDGET_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
852             let id = crate::widget::WidgetId(next);
853             self.id.set(Some(id));
854             id
855         }
856     }
857 
858     /// The detached-label strip this widget carries above its content: the one
859     /// control-label formula (`layout::control_label_strip`) when a label is set,
860     /// zero otherwise.
861     pub fn label_offset(&self) -> f32 {
862         if self.label.is_some() { crate::layout::control_label_strip() } else { 0.0 }
863     }
864 }
865 
866 
867 pub fn clear_widget_references(w: &dyn WidgetHost) {
868     focus::clear_if_matches(w);
869     context_menu::clear_if_matches(w);
870 }
871 
872 #[macro_export]
873 macro_rules! impl_widget_base {
874     ($name:ident) => {
875         fn base(&self) -> &$crate::widget::Widget { &self.base }
876         fn base_mut(&mut self) -> &mut $crate::widget::Widget { &mut self.base }
877         fn as_any(&self) -> &dyn std::any::Any { self }
878         fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
879     };
880 }
881 
882 #[cfg(test)]
883 mod context_menu_padding_tests {
884     use super::context_menu::{self, ContextMenuState, PAD, ROW_H};
885     use crate::widget::WidgetId;
886 
887     /// The shared menu is a popover for the window-drag question too: a
888     /// press on one of its rows must never start a window move, whatever
889     /// sits under the menu. It has no widget id to register, so the veto
890     /// asks the thread-local directly. And the free `row_at` is the
891     /// PAD-aware row hit test hosts dispatch by.
892     #[test]
893     fn an_open_menu_vetoes_window_drags_under_it() {
894         let ctx = crate::context::UiContext::new();
895         context_menu::show(100.0, 200.0, vec!["Copy".into(), "Paste".into()], 0, WidgetId(1));
896         assert!(!ctx.drag_allowed_at(110.0, 200.0 + PAD + ROW_H * 0.5), "a press on a row is not a drag");
897         assert_eq!(context_menu::row_at(110.0, 200.0 + PAD + ROW_H * 1.5), Some(1));
898         assert_eq!(context_menu::row_y(1), 200.0 + PAD + ROW_H);
899         assert!(ctx.drag_allowed_at(10.0, 10.0), "away from the menu the drag question is the widgets'");
900         context_menu::hide();
901         assert!(ctx.drag_allowed_at(110.0, 200.0 + PAD + ROW_H * 0.5), "hidden, it vetoes nothing");
902     }
903 
904     /// The plate pads its rows evenly: the height is the rows plus a pad
905     /// above and below, the labels sit one pad in from the left with the
906     /// widest one a pad from the right, and the padding is plate — a pointer
907     /// in it hovers no row, and a pointer a row down from the top pad is on
908     /// row 1, not row 0 plus a fraction.
909     #[test]
910     fn rows_sit_inside_an_even_pad() {
911         let mut m = ContextMenuState::new();
912         m.show(100.0, 200.0, vec!["Hide Geometry".into(), "-".into(), "Delete".into()], 0, WidgetId(1));
913         assert_eq!(m.h, 3.0 * ROW_H + 2.0 * PAD);
914         assert!(m.w >= 2.0 * PAD);
915         let labels = m.text_labels();
916         assert!(labels.iter().all(|l| l.x == 100.0 + PAD), "labels start one pad in");
917         assert_eq!(labels[0].y, 200.0 + PAD + (ROW_H - labels[0].font_size) / 2.0, "row 0 starts under the top pad");
918 
919         assert_eq!(m.row_at(110.0, 200.0 + PAD * 0.5), None, "the top pad is no row");
920         assert_eq!(m.row_at(110.0, 200.0 + PAD + ROW_H * 0.5), Some(0));
921         assert_eq!(m.row_at(110.0, 200.0 + PAD + ROW_H * 2.5), Some(2));
922         assert_eq!(m.row_at(110.0, 200.0 + m.h - PAD * 0.5), None, "the bottom pad is no row");
923 
924         m.cursor_moved(110.0, 200.0 + PAD * 0.5);
925         assert_eq!(m.hovered_item, None);
926         m.cursor_moved(110.0, 200.0 + PAD + ROW_H * 1.5);
927         assert_eq!(m.hovered_item, None, "a separator row never hovers");
928         m.cursor_moved(110.0, 200.0 + PAD + ROW_H * 2.5);
929         assert_eq!(m.hovered_item, Some(2));
930     }
931 
932     /// A label with a glyph the menu face lacks — the radio marks the
933     /// designer's pin rows carry — is measured as the renderer shapes it,
934     /// fallback face and all, so the plate is wide enough for what is drawn.
935     /// The SVG-inked measure alone called the mark next to nothing.
936     #[test]
937     fn a_fallback_glyph_widens_the_plate_as_drawn() {
938         let mut m = ContextMenuState::new();
939         m.show(0.0, 0.0, vec!["● Follow Active Editor".into()], 0, WidgetId(1));
940         let (family, size) = super::context_menu::label_font();
941         let drawn = {
942             let mut fs = crate::geometry_font_system().lock().unwrap();
943             crate::backend::window_runner::shaped_cluster_offsets(&mut fs, "● Follow Active Editor", size, Some(&family))
944                 .last()
945                 .map(|&(_, t)| t)
946                 .unwrap()
947         };
948         assert!(drawn > 0.0);
949         assert!(m.w >= drawn + 2.0 * PAD, "plate {} narrower than the drawn label {} plus pads", m.w, drawn);
950     }
951 }