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

src/main.rs (29.6K)

  1 //! The reference `Application` (Phase 6ad) — a small widget gallery on the target
  2 //! architecture, end to end. This is the file to copy when starting a new `cce-*` client.
  3 //!
  4 //! The shape every migrated app shares:
  5 //!
  6 //! 1. **One paint path.** The whole frame — geometry AND text — is built in
  7 //!    [`Application::display_list`] as prims on a [`PaintCtx`], with
  8 //!    [`Application::display_list_text`] returning `true`. There is no `view*`/`text_items`
  9 //!    pair, no app-side `FontSystem`, no cosmic-text buffers: text is a `Prim::Text` shaped by
 10 //!    the engine's shared cache.
 11 //! 2. **Layout via the scene solver.** The frame is a plain `Arena<LayoutBox>` tree the
 12 //!    app builds and solves with [`compute_layout`]; widgets get their rects from the
 13 //!    solved leaves. No container widgets, no hand-summed offsets.
 14 //! 3. **Routed events.** Each input handler builds one [`Event`] and routes it through
 15 //!    `UiContext::propagate_event` per widget root. The router owns press hit-gating,
 16 //!    Enter/Leave synthesis, drag-target recording, and KeyInput-to-focused delivery;
 17 //!    the app keeps only state-gated `take_*` plumbing.
 18 //! 4. **A modal dialog is a lasso.** The Options dialog's members are ordinary widgets the app
 19 //!    owns and lays out; [`Dialog`] is the plate under them, and opening it traps the Tab
 20 //!    walk among them and covers the window behind (`UiContext::open_modal`). Closed, its
 21 //!    members are hidden, so they are neither Tab stops nor in the accessibility tree.
 22 //! 5. **The context owns the widgets.** The app holds a [`Handle`] to each (all [`Adapted`])
 23 //!    and reaches it through the context — `ui[h]`, `ui.get_mut(h)`, `ui.lend_h(h, ..)` when the
 24 //!    widget and the context are both needed — so the compiler keeps the app's access and the
 25 //!    context's own from overlapping (`docs/rfc-owning-registry.md`). The window plate is prims,
 26 //!    not a root plate container; popovers draw INTO the frame (there is no popup surface); app
 27 //!    state — not any widget tree — is the source of truth.
 28 
 29 use cce_ui::context::UiContext;
 30 use cce_ui::engine::{Application, AppSender, LogicalPosition, LogicalSize, WindowSettings};
 31 use cce_ui::scene::arena::Arena;
 32 use cce_ui::scene::layout::{
 33     compute_layout, FitMode, LayoutBox, Length, Rect, Size as LSize, Style,
 34 };
 35 use cce_ui::scene::paint::{DisplayList, PaintCtx};
 36 use cce_ui::widget::{
 37     Adapted, Button, Dialog, Dropdown, ElementState, Event, Handle, ImageView, KeyEvent, MouseButton,
 38     MouseScrollDelta, NamedKey, RadioGroup, Slider, TextBox, Toggle, WidgetHost, WidgetId,
 39 };
 40 
 41 #[derive(Debug, Clone)]
 42 pub(crate) enum DemoMessage {
 43     Exit,
 44 }
 45 
 46 /// Chrome typography: the title/status font sizes, and the layout leaves that
 47 /// hold them derived as one line-height (×1.2, the toolkit convention) — so the
 48 /// header and status bands, which anchor to those solved rects, resize with the
 49 /// typography instead of relying on magic leaf heights.
 50 const TITLE_FONT_SIZE: f32 = 15.0;
 51 const STATUS_FONT_SIZE: f32 = 12.0;
 52 /// Vertical padding on each side of the status band's text line.
 53 const STATUS_BAND_PAD: f32 = 5.0;
 54 /// The Options dialog's choices.
 55 const TEXT_SIZES: [&str; 3] = ["Small", "Medium", "Large"];
 56 /// Its buttons' width.
 57 const DIALOG_BUTTON_W: f32 = 88.0;
 58 fn text_leaf_height(font_size: f32) -> f32 {
 59     (font_size * 1.2).ceil()
 60 }
 61 
 62 pub(crate) struct DemoApp {
 63     // ── Widgets: owned by `ui_context`, named here by handle (narrow-trait adapters).
 64     button: Handle<Adapted<Button>>,
 65     toggle: Handle<Adapted<Toggle>>,
 66     slider: Handle<Adapted<Slider>>,
 67     name_box: Handle<Adapted<TextBox>>,
 68     theme_dropdown: Handle<Adapted<Dropdown>>,
 69     // ImageView pair sharing ONE uploaded texture (the widget borrows ids —
 70     // upload/free stay app-side): Contain letterboxes, Stretch fills.
 71     image_contain: Handle<Adapted<ImageView>>,
 72     image_stretch: Handle<Adapted<ImageView>>,
 73     // The Options dialog: a button that opens it, the plate, and what stands on it.
 74     options_button: Handle<Adapted<Button>>,
 75     dialog: Handle<Adapted<Dialog>>,
 76     text_size: Handle<Adapted<RadioGroup>>,
 77     dialog_cancel: Handle<Adapted<Button>>,
 78     dialog_ok: Handle<Adapted<Button>>,
 79 
 80     // ── App state: the source of truth. Widgets are re-asserted from it every rebuild
 81     // (`set_toggled` below); `take_*` changes flow back into it, never the reverse.
 82     toggle_on: bool,
 83     clicks: u32,
 84     status: String,
 85     /// The text size chosen in the Options dialog (an index into `TEXT_SIZES`).
 86     text_size_choice: usize,
 87 
 88     ui_context: UiContext,
 89     width: u32,
 90     height: u32,
 91     scale_factor: f64,
 92     needs_rebuild: bool,
 93     /// The dialog's members are laid out once, on the first frame.
 94     laid_out: bool,
 95     /// Set while `open_dialog` lays the members out before the dialog is open.
 96     dialog_pending: bool,
 97     title_rect: Rect,
 98     status_rect: Rect,
 99 }
100 
101 impl DemoApp {
102     /// The widget root ids, in paint order — what the router dispatches over.
103     fn root_ids(&self) -> [WidgetId; 12] {
104         // While the dialog is open the rest are still dispatched to: the context answers
105         // every one of them "covered", so none takes a press or a hover.
106         [
107             self.button.id(),
108             self.toggle.id(),
109             self.slider.id(),
110             self.name_box.id(),
111             self.theme_dropdown.id(),
112             self.image_contain.id(),
113             self.image_stretch.id(),
114             self.options_button.id(),
115             self.dialog.id(),
116             self.text_size.id(),
117             self.dialog_cancel.id(),
118             self.dialog_ok.id(),
119         ]
120     }
121 
122     /// The dialog's members, in the order Tab walks them.
123     fn dialog_members(&self) -> Vec<WidgetId> {
124         vec![self.text_size.id(), self.dialog_cancel.id(), self.dialog_ok.id()]
125     }
126 
127     /// Lay the dialog's members out in the middle of the window, shown while it is open.
128     fn layout_dialog(&mut self) {
129         let ui = &mut self.ui_context;
130         let open = ui[self.dialog].inner().is_open() || self.dialog_pending;
131         for id in [self.text_size.id(), self.dialog_cancel.id(), self.dialog_ok.id()] {
132             if let Some(w) = ui.get_widget_mut(id) {
133                 w.set_visible(open);
134             }
135         }
136         if !open {
137             return;
138         }
139         let gap = cce_ui::layout::control_gap();
140         let group_h = ui[self.text_size].intrinsic_size().map_or(0.0, |s| s.height);
141         let (button_w, button_h) = (DIALOG_BUTTON_W, 28.0);
142         let content_w = 2.0 * button_w + gap;
143         let content_h = group_h + gap * 2.0 + button_h;
144         let (pad, top) = (ui[self.dialog].inner().padding(), ui[self.dialog].inner().headroom());
145         let x = (self.width as f32 - content_w) * 0.5;
146         let y = (self.height as f32 - (top + content_h + pad)) * 0.5 + top;
147         ui[self.text_size].set_rect(x, y, content_w, group_h);
148         let by = y + group_h + gap * 2.0;
149         ui[self.dialog_cancel].set_rect(x, by, button_w, button_h);
150         ui[self.dialog_ok].set_rect(x + button_w + gap, by, button_w, button_h);
151         let window = Rect { x: 0.0, y: 0.0, width: self.width as f32, height: self.height as f32 };
152         ui[self.dialog].set_backdrop(Some(window));
153         // The dialog fits itself around its members, which it reads through the context:
154         // lent for the call, so it and the context are both in hand.
155         ui.lend_h(self.dialog, |d, ui| d.fit(ui));
156     }
157 
158     /// Open the Options dialog on the size the app holds.
159     fn open_dialog(&mut self) {
160         self.ui_context[self.text_size].inner_mut().set_selected(self.text_size_choice);
161         self.dialog_pending = true;
162         self.layout_dialog();
163         self.dialog_pending = false;
164         let members = self.dialog_members();
165         self.ui_context.lend_h(self.dialog, |d, ui| d.open(ui, members));
166         self.needs_rebuild = true;
167     }
168 
169     /// Close it: `keep` takes the choice into the app, else it is dropped.
170     fn close_dialog(&mut self, keep: bool) {
171         if keep {
172             self.text_size_choice = self.ui_context[self.text_size].inner().selected();
173             self.status = format!("Text size: {}", TEXT_SIZES[self.text_size_choice]);
174         }
175         self.ui_context.lend_h(self.dialog, |d, ui| d.close(ui));
176         self.layout_dialog();
177         self.needs_rebuild = true;
178     }
179 
180     /// `take_*` plumbing: translate widget changes into app state. Runs after any routed
181     /// dispatch; every check is STATE-gated, so it does not matter which propagate call
182     /// consumed the event (see the KeyInput note in `handle_key_input`).
183     fn drain_widget_changes(&mut self) {
184         let ui = &mut self.ui_context;
185         let (options, ok, cancel) = (
186             ui[self.options_button].take_click(),
187             ui[self.dialog_ok].take_click(),
188             ui[self.dialog_cancel].take_click(),
189         );
190         if ui[self.text_size].take_change() {
191             self.needs_rebuild = true;
192         }
193         if ui[self.button].take_click() {
194             self.clicks += 1;
195             self.status = format!("Button clicked {} time(s)", self.clicks);
196             self.needs_rebuild = true;
197         }
198         if ui[self.toggle].take_change() {
199             self.toggle_on = !self.toggle_on;
200             self.status = format!("Toggle: {}", if self.toggle_on { "on" } else { "off" });
201             self.needs_rebuild = true;
202         }
203         if ui[self.slider].take_change() {
204             self.status = format!("Slider: {:.0}", ui[self.slider].get_scaled_value());
205             self.needs_rebuild = true;
206         }
207         if ui[self.theme_dropdown].take_change() {
208             let dropdown = &ui[self.theme_dropdown];
209             if let Some(opt) = dropdown.options.get(dropdown.selected) {
210                 self.status = format!("Theme: {opt}");
211             }
212             self.needs_rebuild = true;
213         }
214         if ui[self.name_box].take_change() {
215             self.status = format!("Name: {}", ui[self.name_box].text);
216             self.needs_rebuild = true;
217         }
218         if options {
219             self.open_dialog();
220         }
221         if ok {
222             self.close_dialog(true);
223         }
224         if cancel {
225             self.close_dialog(false);
226         }
227     }
228 }
229 
230 impl Application for DemoApp {
231     type Message = DemoMessage;
232 
233     fn create(_sender: AppSender<Self::Message>) -> Self {
234         cce_ui::scale::set_scale_factor(1.0);
235         // One procedurally generated gradient (no asset dependency), uploaded
236         // once and SHARED by both ImageViews — the widget borrows ids;
237         // upload/free stay app-side. upload_rgba queues into the renderer's
238         // pending list, so calling it before the first frame is safe.
239         const GRADIENT_W: u32 = 64;
240         const GRADIENT_H: u32 = 40;
241         let mut gradient = Vec::with_capacity((GRADIENT_W * GRADIENT_H * 4) as usize);
242         for y in 0..GRADIENT_H {
243             for x in 0..GRADIENT_W {
244                 gradient.push((x * 255 / (GRADIENT_W - 1)) as u8);
245                 gradient.push((y * 255 / (GRADIENT_H - 1)) as u8);
246                 gradient.push(160);
247                 gradient.push(255);
248             }
249         }
250         let gradient_id = cce_ui::draw::upload_rgba(gradient, GRADIENT_W, GRADIENT_H);
251         // The context owns every widget; the app keeps the handles.
252         let mut ui = UiContext::new();
253         Self {
254             // Relief styling (raised buttons/toggles/dropdowns, recessed
255             // wells) is the `control_relief` config default — no opt-in.
256             button: ui.insert(Button::new(0.0, 0.0, 0.0, 0.0).with_label("Click me")),
257             toggle: ui.insert(Toggle::new()),
258             // Slider `value` is NORMALIZED 0..1; `with_range` only scales the readout
259             // (`get_scaled_value`). Wheel nudging is an explicit opt-in.
260             slider: ui.insert(Slider::new().with_range(0.0, 100.0).with_value(0.4).with_scroll(true)),
261             name_box: ui.insert(TextBox::new(String::new()).with_placeholder("Type a name...")),
262             theme_dropdown: ui.insert(Dropdown::new(vec!["Forest".into(), "Ocean".into(), "Ember".into()], 0)),
263             image_contain: ui.insert(
264                 ImageView::new()
265                     .with_image(gradient_id, GRADIENT_W, GRADIENT_H)
266                     .with_fit(FitMode::Contain { max_upscale: 4.0 })
267                     .with_bg([0.10, 0.10, 0.16, 1.0]),
268             ),
269             image_stretch: ui.insert(ImageView::new().with_image(gradient_id, GRADIENT_W, GRADIENT_H).with_fit(FitMode::Stretch)),
270             options_button: ui.insert(Button::new(0.0, 0.0, 0.0, 0.0).with_label("Options…")),
271             dialog: ui.insert(Dialog::new().with_label("Text size")),
272             text_size: ui.insert(RadioGroup::new(TEXT_SIZES).with_selected(1)),
273             dialog_cancel: ui.insert(Button::new(0.0, 0.0, 0.0, 0.0).with_label("Cancel")),
274             dialog_ok: ui.insert(Button::new(0.0, 0.0, 0.0, 0.0).with_label("OK")),
275             text_size_choice: 1,
276             dialog_pending: false,
277             toggle_on: false,
278             clicks: 0,
279             status: "Ready.".to_string(),
280             ui_context: ui,
281             width: 560,
282             height: 420,
283             scale_factor: 1.0,
284             needs_rebuild: true,
285             laid_out: false,
286             title_rect: Rect::ZERO,
287             status_rect: Rect::ZERO,
288         }
289     }
290 
291     fn settings(&self) -> WindowSettings {
292         WindowSettings {
293             title: "cce-ui reference gallery".to_string(),
294             app_id: "cce-ui-demo".to_string(),
295             width: 560,
296             height: 420,
297             fullscreen: false,
298             min_size: Some((360, 300)),
299         }
300     }
301 
302     fn update(&mut self, msg: Self::Message, _needs_rebuild: &mut bool, exit: &mut bool) {
303         match msg {
304             DemoMessage::Exit => *exit = true,
305         }
306     }
307 
308     /// Widget animations (cursor blink, hover fades) tick through the UiContext; the
309     /// loop is demand-driven, so returning a redraw request only when something moved
310     /// keeps the app idle otherwise.
311     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
312         if self.ui_context.tick(dt) {
313             *needs_rebuild = true;
314             self.needs_rebuild = true;
315         }
316     }
317 
318     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
319         if !self.laid_out {
320             self.laid_out = true;
321             self.layout_dialog();
322         }
323 
324         let size_changed = self.width != size.width as u32
325             || self.height != size.height as u32
326             || self.scale_factor != scale;
327         if self.needs_rebuild || size_changed {
328             self.width = size.width as u32;
329             self.height = size.height as u32;
330             self.scale_factor = scale;
331             cce_ui::scale::set_scale_factor(scale as f32);
332 
333             // Re-assert widget visuals from app state (the app is the source of truth).
334             let ui = &mut self.ui_context;
335             ui[self.toggle].set_toggled(self.toggle_on);
336             ui[self.toggle].set_label(if self.toggle_on { "ON" } else { "OFF" });
337 
338             // ── Layout: a plain LayoutBox tree, solved in one call. Leaves carry their
339             // intrinsic sizes; `grow` distributes leftover space; the solved rects are
340             // assigned straight onto the widgets.
341             // DE-wide spacing by rung, never by number: the root preset insets
342             // by the plate's roll plus one padding and spaces siblings by the
343             // root gap; the controls preset puts the control gap between a
344             // form's controls (`Style::root_column` / `Style::controls_row`).
345             let mut arena: Arena<LayoutBox> = Arena::new();
346             let root = arena.insert(LayoutBox::container(Style::root_column()));
347             let title = arena.insert(LayoutBox::leaf(
348                 Style::row(),
349                 LSize::new(0.0, text_leaf_height(TITLE_FONT_SIZE)),
350             ));
351             // Clearance under the header band: the recess step rolls over `bevel_width`
352             // past the band's bottom edge, so the first content row must stand off by at
353             // least that or it crowds the carve.
354             let band_gap = arena.insert(LayoutBox::leaf(
355                 Style::row(),
356                 LSize::new(0.0, cce_ui::layout::bevel_width()),
357             ));
358             // One shared height for the whole control row, so the button,
359             // toggle, and dropdown plates land on the same top and bottom edge.
360             const CONTROL_H: f32 = 28.0;
361             let controls = arena.insert(LayoutBox::container(
362                 Style::controls_row().height(Length::Fixed(CONTROL_H)),
363             ));
364             // `shrink` lets the fixed leaves give up width when the window is at its
365             // minimum instead of overflowing the row.
366             let button = arena.insert(LayoutBox::leaf(Style::row().shrink(1.0), LSize::new(120.0, CONTROL_H)));
367             let toggle = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(64.0, CONTROL_H)));
368             let dropdown = arena.insert(LayoutBox::leaf(Style::row().shrink(1.0), LSize::new(150.0, CONTROL_H)));
369             let options = arena.insert(LayoutBox::leaf(Style::row().shrink(1.0), LSize::new(DIALOG_BUTTON_W, CONTROL_H)));
370             let slider = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(0.0, 24.0)));
371             let name_box = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(0.0, 30.0)));
372             // ImageView row: same texture through two fit modes side by side.
373             let images = arena.insert(LayoutBox::container(
374                 Style::row().gap(cce_ui::layout::root_plate_gap()).height(Length::Fixed(72.0)),
375             ));
376             let image_contain = arena.insert(LayoutBox::leaf(Style::row().grow(1.0), LSize::new(0.0, 72.0)));
377             let image_stretch = arena.insert(LayoutBox::leaf(Style::row().grow(1.0), LSize::new(0.0, 72.0)));
378             let spacer = arena.insert(LayoutBox::container(Style::column().grow(1.0)));
379             let status = arena.insert(LayoutBox::leaf(
380                 Style::row(),
381                 LSize::new(0.0, text_leaf_height(STATUS_FONT_SIZE)),
382             ));
383             arena.append_child(root, title);
384             arena.append_child(root, band_gap);
385             arena.append_child(root, controls);
386             arena.append_child(controls, button);
387             arena.append_child(controls, toggle);
388             arena.append_child(controls, dropdown);
389             arena.append_child(controls, options);
390             arena.append_child(root, slider);
391             arena.append_child(root, name_box);
392             arena.append_child(root, images);
393             arena.append_child(images, image_contain);
394             arena.append_child(images, image_stretch);
395             arena.append_child(root, spacer);
396             arena.append_child(root, status);
397             compute_layout(
398                 &mut arena,
399                 root,
400                 LSize::new(self.width as f32, self.height as f32),
401             );
402 
403             // Stretched children fill the column width; fixed leaves keep their size.
404             let r = |id| arena.value(id).unwrap().rect;
405             let placed: [(WidgetId, Rect); 8] = [
406                 (self.button.id(), r(button)),
407                 (self.toggle.id(), r(toggle)),
408                 (self.theme_dropdown.id(), r(dropdown)),
409                 (self.options_button.id(), r(options)),
410                 (self.slider.id(), r(slider)),
411                 (self.name_box.id(), r(name_box)),
412                 (self.image_contain.id(), r(image_contain)),
413                 (self.image_stretch.id(), r(image_stretch)),
414             ];
415             for (id, b) in placed {
416                 if let Some(w) = self.ui_context.get_widget_mut(id) {
417                     w.set_rect(b.x, b.y, b.width, b.height);
418                 }
419             }
420             self.title_rect = r(title);
421             self.status_rect = r(status);
422             self.layout_dialog();
423 
424             self.needs_rebuild = false;
425             self.ui_context.rebuild_spatial_grid();
426         }
427 
428         // ── Popover registration: ui_context ONLY. It drives the engine's display-list
429         // text occlusion clamp (labels under the open popover get clipped); the popover
430         // itself is drawn into this frame below — there is no popup surface.
431         self.ui_context.clear_popovers();
432         if self.ui_context[self.theme_dropdown].popover_rect().is_some() {
433             self.ui_context.register_popover_id(self.theme_dropdown.id());
434         }
435 
436         let mut pc = PaintCtx::new();
437         let w = self.width as f32;
438         let h = self.height as f32;
439 
440         // The standard root plate (`PlateSpec::window`): the DE's root
441         // material at its configured opacity, the shared silhouette arc on
442         // all four corners, the perimeter rolled over `bevel_width`. This
443         // demo is the reference app, so its base is the one every cce app
444         // should paint first.
445         pc.root_plate(w, h);
446 
447         // Header band: the title strip carved one step down into the plate. Flush to the
448         // window's top and sides, so its only real wall is the bottom one facing the
449         // content (the recessed-MenuBar idiom — the other three would fight the plate's
450         // own rolled perimeter).
451         let band_h = self.title_rect.y + self.title_rect.height + 10.0;
452         pc.recess_edges(
453             Rect { x: 0.0, y: 0.0, width: w, height: band_h },
454             (0.0, 0.0, 0.0, 0.0),
455             cce_ui::layout::bar_wall_width(),
456             (false, false, true, false),
457         );
458 
459         // Status band: the header's mirror — carved into the bottom of the
460         // plate, flush to the window's bottom and sides, its only wall the top
461         // one facing the content. (Neither band CSG-groups: edge-suppressed
462         // carves never do — their extended walls would smear across the
463         // plate's whole-surface draw. Both shade through the overlay fallback,
464         // whose host-box fade owns the junction with the roll.) Sized from
465         // the status font plus a symmetric pad (the layout's status leaf only
466         // reserves the space; the band and its text center independently).
467         let status_h = text_leaf_height(STATUS_FONT_SIZE) + 2.0 * STATUS_BAND_PAD;
468         let status_top = h - status_h;
469         pc.recess_edges(
470             Rect { x: 0.0, y: status_top, width: w, height: status_h },
471             (0.0, 0.0, 0.0, 0.0),
472             cce_ui::layout::bar_wall_width(),
473             (true, false, false, false),
474         );
475 
476         // App chrome text: plain prims. `text_with` carries an optional font family and
477         // optional bounds; unbounded text is clamped to the surface by the engine.
478         pc.text_with(
479             "cce-ui reference gallery".to_string(),
480             self.title_rect.x,
481             self.title_rect.y,
482             TITLE_FONT_SIZE,
483             [0xdd, 0xdd, 0xe2],
484             Some("monospace".to_string()),
485             None,
486         );
487         pc.text_with(
488             self.status.clone(),
489             self.status_rect.x,
490             cce_ui::layout::align_text_y(status_top, status_h, STATUS_FONT_SIZE, 0.0),
491             STATUS_FONT_SIZE,
492             [0x9a, 0x9a, 0xa4],
493             // None here falls through fontconfig's unbundled sans alias to the
494             // serif fallback — always name a family.
495             Some("monospace".to_string()),
496             None,
497         );
498 
499         // Widgets: each root walked through the single paint pass. The walk recurses,
500         // clips, and emits each widget's own geometry AND text (`Adapted::paint_self`
501         // serves per-widget fonts and bounds).
502         let ui = &self.ui_context;
503         let roots = [
504             self.button.id(),
505             self.toggle.id(),
506             self.slider.id(),
507             self.name_box.id(),
508             self.image_contain.id(),
509             self.image_stretch.id(),
510             self.theme_dropdown.id(),
511             self.options_button.id(),
512         ];
513         for id in roots {
514             if let Some(w) = ui.get_widget(id) {
515                 cce_ui::scene::painter::paint_root_into(ui, w, &mut pc);
516             }
517         }
518 
519         // The dropdown popover — geometry and labels last, on top of everything, exactly
520         // where it hit-tests. Labels carry bounds equal to the popover rect: that clips
521         // them to the plate AND exempts them from the occlusion clamp (text whose bounds
522         // equal an overlay rect is treated as the overlay's own).
523         if ui[self.theme_dropdown].popover_rect().is_some() {
524             // PaintCtx is a RenderTarget: the popover draws its real prims (the
525             // dropdown's expanded inset-plate surface) with its own bounds.
526             ui[self.theme_dropdown].render_popover(&mut pc);
527         }
528 
529         // The dialog over everything: its backdrop, its plate, and its members on the plate
530         // (it paints them; they are never painted on their own).
531         cce_ui::scene::painter::paint_root_into(ui, &ui[self.dialog], &mut pc);
532 
533         Some(pc.finish())
534     }
535 
536     /// Text prims in the display list ARE the frame's text — no `text_items` twin.
537     fn display_list_text(&self) -> bool {
538         true
539     }
540 
541     fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
542         Some(&self.ui_context)
543     }
544 
545     // Engine-driven animation frames for the dropdown expand/contract.
546     fn ui_context_mut(&mut self) -> Option<&mut cce_ui::context::UiContext> {
547         Some(&mut self.ui_context)
548     }
549 
550     /// Window dragging for a dissolved root: the surface is the movable plate; drag
551     /// anywhere a drag-blocking registered widget isn't.
552     fn is_movable_root_plate_at(&self, px: f32, py: f32) -> bool {
553         self.ui_context.drag_allowed_at(px, py)
554     }
555 
556     fn clear_color(&self) -> [f32; 4] {
557         [0.0, 0.0, 0.0, 0.0]
558     }
559 
560     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
561         let (px, py) = (pos.x, pos.y);
562         let ev = Event::PointerMove { x: px, y: py, local_x: px, local_y: py };
563         let mut changed = false;
564         // PointerMove visits every root: hover bookkeeping everywhere, and the
565         // router forwards DragUpdate to the recorded drag target (slider thumb,
566         // text selection) once its 3px threshold trips.
567         for root in self.root_ids() {
568             if self.ui_context.propagate_event(&ev, root) {
569                 changed = true;
570             }
571         }
572         self.drain_widget_changes();
573         if changed || self.needs_rebuild {
574             *needs_rebuild = true;
575             self.needs_rebuild = true;
576         }
577     }
578 
579     fn handle_mouse_input(
580         &mut self,
581         button: MouseButton,
582         state: ElementState,
583         pos: LogicalPosition,
584         needs_rebuild: &mut bool,
585     ) -> Option<Self::Message> {
586         let (px, py) = (pos.x, pos.y);
587         let ev = Event::MouseButton { button, state, x: px, y: py, local_x: px, local_y: py };
588         let mut changed = false;
589         // Presses are hit-gated per widget by the adapter and releases delivered
590         // everywhere (press-tracking widgets commit or cancel on them) — a straight
591         // loop is correct for pointer-positioned events.
592         for root in self.root_ids() {
593             if self.ui_context.propagate_event(&ev, root) {
594                 changed = true;
595             }
596         }
597         self.drain_widget_changes();
598         if changed || self.needs_rebuild {
599             *needs_rebuild = true;
600             self.needs_rebuild = true;
601         }
602         None
603     }
604 
605     fn handle_mouse_wheel(
606         &mut self,
607         delta: &MouseScrollDelta,
608         pos: LogicalPosition,
609         needs_rebuild: &mut bool,
610     ) {
611         let (px, py) = (pos.x, pos.y);
612         let ev = Event::MouseWheel { delta: *delta, x: px, y: py, local_x: px, local_y: py };
613         let mut changed = false;
614         // Wheel is hit-scoped per widget (the slider nudges its value under the
615         // cursor); roots that miss return false.
616         for root in self.root_ids() {
617             if self.ui_context.propagate_event(&ev, root) {
618                 changed = true;
619             }
620         }
621         self.drain_widget_changes();
622         if changed || self.needs_rebuild {
623             *needs_rebuild = true;
624             self.needs_rebuild = true;
625         }
626     }
627 
628     fn handle_key_input(
629         &mut self,
630         event: &KeyEvent,
631         needs_rebuild: &mut bool,
632     ) -> Option<Self::Message> {
633         // App-level shortcuts before widget routing.
634         if event.ctrl && event.state == ElementState::Pressed {
635             if let cce_ui::widget::Key::Character(ref c) = event.logical_key {
636                 if c == "q" {
637                     return Some(DemoMessage::Exit);
638                 }
639             }
640         }
641 
642         // Escape cancels the open dialog, as its Cancel does.
643         if self.ui_context[self.dialog].inner().is_open()
644             && event.state == ElementState::Pressed
645             && event.logical_key == cce_ui::widget::Key::Named(NamedKey::Escape)
646         {
647             self.close_dialog(false);
648             *needs_rebuild = true;
649             return None;
650         }
651 
652         // KeyInput MUST short-circuit: the router delivers keys to the ctx-focused
653         // widget FIRST on every propagate call, so a non-short-circuited chain would
654         // hand a typed character to the focused widget once per root (N-time
655         // insertion). Plumbing that would key off "which call handled it" belongs in
656         // the state-gated `drain_widget_changes` instead.
657         let ev = Event::KeyInput(event.clone());
658         let mut handled = false;
659         for root in self.root_ids() {
660             if self.ui_context.propagate_event(&ev, root) {
661                 handled = true;
662                 break;
663             }
664         }
665         self.drain_widget_changes();
666         if handled || self.needs_rebuild {
667             *needs_rebuild = true;
668             self.needs_rebuild = true;
669         }
670         None
671     }
672 }
673 
674 fn main() {
675     // In a browser the same app is run by `examples/demo_web.rs`.
676     #[cfg(not(target_arch = "wasm32"))]
677     cce_ui::engine::run::<DemoApp>();
678 }