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 }