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 }