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

src/main.rs (24.7K)

  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. **No embedded bases.** Widgets are app-owned values (all [`Adapted`]); the window
 19 //!    plate is prims, not a root plate container; popovers draw INTO the frame (there is no popup
 20 //!    surface); app state — not any widget tree — is the source of truth.
 21 
 22 use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
 23 use cce_ui::scene::arena::Arena;
 24 use cce_ui::scene::layout::{
 25     compute_layout, FitMode, LayoutBox, Length, Rect, Size as LSize, Style,
 26 };
 27 use cce_ui::scene::paint::{DisplayList, PaintCtx};
 28 use cce_ui::widget::{
 29     Adapted, Button, Dropdown, ImageView, WidgetHost, WidgetId, ElementState, Event, KeyEvent,
 30     MouseButton, MouseScrollDelta, Slider, TextBox, Toggle,
 31 };
 32 use wayland_client::QueueHandle;
 33 
 34 #[derive(Debug, Clone)]
 35 enum DemoMessage {
 36     Exit,
 37 }
 38 
 39 /// Chrome typography: the title/status font sizes, and the layout leaves that
 40 /// hold them derived as one line-height (×1.2, the toolkit convention) — so the
 41 /// header and status bands, which anchor to those solved rects, resize with the
 42 /// typography instead of relying on magic leaf heights.
 43 const TITLE_FONT_SIZE: f32 = 15.0;
 44 const STATUS_FONT_SIZE: f32 = 12.0;
 45 /// Vertical padding on each side of the status band's text line.
 46 const STATUS_BAND_PAD: f32 = 5.0;
 47 fn text_leaf_height(font_size: f32) -> f32 {
 48     (font_size * 1.2).ceil()
 49 }
 50 
 51 struct DemoApp {
 52     // ── Widgets: app-owned values on the narrow-trait adapter. Their addresses must be
 53     // stable across frames (plain struct fields, not Vec elements): the UiContext
 54     // registry and the router's drag-target bookkeeping hold pointers to them.
 55     button: Adapted<Button>,
 56     toggle: Adapted<Toggle>,
 57     slider: Adapted<Slider>,
 58     name_box: Adapted<TextBox>,
 59     theme_dropdown: Adapted<Dropdown>,
 60     // ImageView pair sharing ONE uploaded texture (the widget borrows ids —
 61     // upload/free stay app-side): Contain letterboxes, Stretch fills.
 62     image_contain: Adapted<ImageView>,
 63     image_stretch: Adapted<ImageView>,
 64 
 65     // ── App state: the source of truth. Widgets are re-asserted from it every rebuild
 66     // (`set_toggled` below); `take_*` changes flow back into it, never the reverse.
 67     toggle_on: bool,
 68     clicks: u32,
 69     status: String,
 70 
 71     ui_context: cce_ui::context::UiContext,
 72     width: u32,
 73     height: u32,
 74     scale_factor: f64,
 75     needs_rebuild: bool,
 76     widgets_registered: bool,
 77     title_rect: Rect,
 78     status_rect: Rect,
 79 }
 80 
 81 impl DemoApp {
 82     /// The widget root ids, in paint order — what the router dispatches over.
 83     /// `propagate_event` takes a `WidgetId` and resolves it through the registry, so the
 84     /// event paths need no raw pointers and no unsafe self-alias.
 85     fn root_ids(&self) -> [WidgetId; 7] {
 86         [
 87             self.button.id(),
 88             self.toggle.id(),
 89             self.slider.id(),
 90             self.name_box.id(),
 91             self.theme_dropdown.id(),
 92             self.image_contain.id(),
 93             self.image_stretch.id(),
 94         ]
 95     }
 96 
 97     /// The widget roots as pointers, for the one genuinely pointer-consuming path left:
 98     /// registration (the registry stores them). The paint walk takes shared borrows.
 99     fn roots(&mut self) -> [*mut (dyn WidgetHost + 'static); 7] {
100         [
101             self.button.as_ptr_mut(),
102             self.toggle.as_ptr_mut(),
103             self.slider.as_ptr_mut(),
104             self.name_box.as_ptr_mut(),
105             self.theme_dropdown.as_ptr_mut(),
106             self.image_contain.as_ptr_mut(),
107             self.image_stretch.as_ptr_mut(),
108         ]
109     }
110 
111     /// `take_*` plumbing: translate widget changes into app state. Runs after any routed
112     /// dispatch; every check is STATE-gated, so it does not matter which propagate call
113     /// consumed the event (see the KeyInput note in `handle_key_input`).
114     fn drain_widget_changes(&mut self) {
115         if self.button.take_click() {
116             self.clicks += 1;
117             self.status = format!("Button clicked {} time(s)", self.clicks);
118             self.needs_rebuild = true;
119         }
120         if self.toggle.take_change() {
121             self.toggle_on = !self.toggle_on;
122             self.status = format!("Toggle: {}", if self.toggle_on { "on" } else { "off" });
123             self.needs_rebuild = true;
124         }
125         if self.slider.take_change() {
126             self.status = format!("Slider: {:.0}", self.slider.get_scaled_value());
127             self.needs_rebuild = true;
128         }
129         if self.theme_dropdown.take_change() {
130             let idx = self.theme_dropdown.selected;
131             if let Some(opt) = self.theme_dropdown.options.get(idx) {
132                 self.status = format!("Theme: {opt}");
133             }
134             self.needs_rebuild = true;
135         }
136         if self.name_box.take_change() {
137             self.status = format!("Name: {}", self.name_box.text);
138             self.needs_rebuild = true;
139         }
140     }
141 }
142 
143 impl Application for DemoApp {
144     type Message = DemoMessage;
145 
146     fn new(
147         _qh: &QueueHandle<EngineState<Self>>,
148         _sender: calloop::channel::Sender<Self::Message>,
149     ) -> Self {
150         cce_ui::scale::set_scale_factor(1.0);
151         // One procedurally generated gradient (no asset dependency), uploaded
152         // once and SHARED by both ImageViews — the widget borrows ids;
153         // upload/free stay app-side. upload_rgba queues into the renderer's
154         // pending list, so calling it before the first frame is safe.
155         const GRADIENT_W: u32 = 64;
156         const GRADIENT_H: u32 = 40;
157         let mut gradient = Vec::with_capacity((GRADIENT_W * GRADIENT_H * 4) as usize);
158         for y in 0..GRADIENT_H {
159             for x in 0..GRADIENT_W {
160                 gradient.push((x * 255 / (GRADIENT_W - 1)) as u8);
161                 gradient.push((y * 255 / (GRADIENT_H - 1)) as u8);
162                 gradient.push(160);
163                 gradient.push(255);
164             }
165         }
166         let gradient_id = cce_ui::vk::upload_rgba(gradient, GRADIENT_W, GRADIENT_H);
167         Self {
168             // Relief styling (raised buttons/toggles/dropdowns, recessed
169             // wells) is the `control_relief` config default — no opt-in.
170             button: Button::new(0.0, 0.0, 0.0, 0.0).with_label("Click me"),
171             toggle: Toggle::new(),
172             // Slider `value` is NORMALIZED 0..1; `with_range` only scales the readout
173             // (`get_scaled_value`). Wheel nudging is an explicit opt-in.
174             slider: Slider::new()
175                 .with_range(0.0, 100.0)
176                 .with_value(0.4)
177                 .with_scroll(true),
178             name_box: TextBox::new(String::new())
179                 .with_placeholder("Type a name..."),
180             theme_dropdown: Dropdown::new(
181                 vec!["Forest".into(), "Ocean".into(), "Ember".into()],
182                 0,
183             ),
184             image_contain: ImageView::new()
185                 .with_image(gradient_id, GRADIENT_W, GRADIENT_H)
186                 .with_fit(FitMode::Contain { max_upscale: 4.0 })
187                 .with_bg([0.10, 0.10, 0.16, 1.0]),
188             image_stretch: ImageView::new()
189                 .with_image(gradient_id, GRADIENT_W, GRADIENT_H)
190                 .with_fit(FitMode::Stretch),
191             toggle_on: false,
192             clicks: 0,
193             status: "Ready.".to_string(),
194             ui_context: cce_ui::context::UiContext::new(),
195             width: 560,
196             height: 420,
197             scale_factor: 1.0,
198             needs_rebuild: true,
199             widgets_registered: false,
200             title_rect: Rect::ZERO,
201             status_rect: Rect::ZERO,
202         }
203     }
204 
205     fn settings(&self) -> WindowSettings {
206         WindowSettings {
207             title: "cce-ui reference gallery".to_string(),
208             app_id: "cce-ui-demo".to_string(),
209             width: 560,
210             height: 420,
211             fullscreen: false,
212             min_size: Some((360, 300)),
213         }
214     }
215 
216     fn update(&mut self, msg: Self::Message, _needs_rebuild: &mut bool, exit: &mut bool) {
217         match msg {
218             DemoMessage::Exit => *exit = true,
219         }
220     }
221 
222     /// Widget animations (cursor blink, hover fades) tick through the UiContext; the
223     /// loop is demand-driven, so returning a redraw request only when something moved
224     /// keeps the app idle otherwise.
225     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
226         if self.ui_context.tick(dt) {
227             *needs_rebuild = true;
228             self.needs_rebuild = true;
229         }
230     }
231 
232     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
233         // Register once: the registry backs the router (drag targets are looked up by
234         // widget id) and drag_allowed_at. Pointers into `self` are stable only once
235         // `self` sits at its final address — hence here, not in `new()`.
236         if !self.widgets_registered {
237             self.widgets_registered = true;
238             let self_ptr = self as *mut Self;
239             unsafe {
240                 for w in (*self_ptr).roots() {
241                     let id = (*w).base().id();
242                     self.ui_context.register_widget(id, w);
243                 }
244             }
245         }
246 
247         let size_changed = self.width != size.width as u32
248             || self.height != size.height as u32
249             || self.scale_factor != scale;
250         if self.needs_rebuild || size_changed {
251             self.width = size.width as u32;
252             self.height = size.height as u32;
253             self.scale_factor = scale;
254             cce_ui::scale::set_scale_factor(scale as f32);
255 
256             // Re-assert widget visuals from app state (the app is the source of truth).
257             self.toggle.set_toggled(self.toggle_on);
258             self.toggle.set_label(if self.toggle_on { "ON" } else { "OFF" });
259 
260             // ── Layout: a plain LayoutBox tree, solved in one call. Leaves carry their
261             // intrinsic sizes; `grow` distributes leftover space; the solved rects are
262             // assigned straight onto the widgets.
263             // DE-wide spacing by rung, never by number: the root preset insets
264             // by the plate's roll plus one padding and spaces siblings by the
265             // root gap; the controls preset puts the control gap between a
266             // form's controls (`Style::root_column` / `Style::controls_row`).
267             let mut arena: Arena<LayoutBox> = Arena::new();
268             let root = arena.insert(LayoutBox::container(Style::root_column()));
269             let title = arena.insert(LayoutBox::leaf(
270                 Style::row(),
271                 LSize::new(0.0, text_leaf_height(TITLE_FONT_SIZE)),
272             ));
273             // Clearance under the header band: the recess step rolls over `bevel_width`
274             // past the band's bottom edge, so the first content row must stand off by at
275             // least that or it crowds the carve.
276             let band_gap = arena.insert(LayoutBox::leaf(
277                 Style::row(),
278                 LSize::new(0.0, cce_ui::layout::bevel_width()),
279             ));
280             // One shared height for the whole control row, so the button,
281             // toggle, and dropdown plates land on the same top and bottom edge.
282             const CONTROL_H: f32 = 28.0;
283             let controls = arena.insert(LayoutBox::container(
284                 Style::controls_row().height(Length::Fixed(CONTROL_H)),
285             ));
286             // `shrink` lets the fixed leaves give up width when the window is at its
287             // minimum instead of overflowing the row.
288             let button = arena.insert(LayoutBox::leaf(Style::row().shrink(1.0), LSize::new(120.0, CONTROL_H)));
289             let toggle = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(64.0, CONTROL_H)));
290             let dropdown = arena.insert(LayoutBox::leaf(Style::row().shrink(1.0), LSize::new(150.0, CONTROL_H)));
291             let slider = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(0.0, 24.0)));
292             let name_box = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(0.0, 30.0)));
293             // ImageView row: same texture through two fit modes side by side.
294             let images = arena.insert(LayoutBox::container(
295                 Style::row().gap(cce_ui::layout::root_plate_gap()).height(Length::Fixed(72.0)),
296             ));
297             let image_contain = arena.insert(LayoutBox::leaf(Style::row().grow(1.0), LSize::new(0.0, 72.0)));
298             let image_stretch = arena.insert(LayoutBox::leaf(Style::row().grow(1.0), LSize::new(0.0, 72.0)));
299             let spacer = arena.insert(LayoutBox::container(Style::column().grow(1.0)));
300             let status = arena.insert(LayoutBox::leaf(
301                 Style::row(),
302                 LSize::new(0.0, text_leaf_height(STATUS_FONT_SIZE)),
303             ));
304             arena.append_child(root, title);
305             arena.append_child(root, band_gap);
306             arena.append_child(root, controls);
307             arena.append_child(controls, button);
308             arena.append_child(controls, toggle);
309             arena.append_child(controls, dropdown);
310             arena.append_child(root, slider);
311             arena.append_child(root, name_box);
312             arena.append_child(root, images);
313             arena.append_child(images, image_contain);
314             arena.append_child(images, image_stretch);
315             arena.append_child(root, spacer);
316             arena.append_child(root, status);
317             compute_layout(
318                 &mut arena,
319                 root,
320                 LSize::new(self.width as f32, self.height as f32),
321             );
322 
323             // Stretched children fill the column width; fixed leaves keep their size.
324             let r = |id| arena.value(id).unwrap().rect;
325             let b = r(button);
326             self.button.set_rect(b.x, b.y, b.width, b.height);
327             let t = r(toggle);
328             self.toggle.set_rect(t.x, t.y, t.width, t.height);
329             let d = r(dropdown);
330             self.theme_dropdown.set_rect(d.x, d.y, d.width, d.height);
331             let s = r(slider);
332             self.slider.set_rect(s.x, s.y, s.width, s.height);
333             let n = r(name_box);
334             self.name_box.set_rect(n.x, n.y, n.width, n.height);
335             let ic = r(image_contain);
336             self.image_contain.set_rect(ic.x, ic.y, ic.width, ic.height);
337             let is = r(image_stretch);
338             self.image_stretch.set_rect(is.x, is.y, is.width, is.height);
339             self.title_rect = r(title);
340             self.status_rect = r(status);
341 
342             self.needs_rebuild = false;
343             self.ui_context.rebuild_spatial_grid();
344         }
345 
346         // ── Popover registration: ui_context ONLY. It drives the engine's display-list
347         // text occlusion clamp (labels under the open popover get clipped); the popover
348         // itself is drawn into this frame below — there is no popup surface.
349         self.ui_context.clear_popovers();
350         if self.theme_dropdown.popover_rect().is_some() {
351             self.ui_context.register_popover(&mut self.theme_dropdown);
352         }
353 
354         let mut pc = PaintCtx::new();
355         let w = self.width as f32;
356         let h = self.height as f32;
357 
358         // The standard root plate (`PlateSpec::window`): the DE's root
359         // material at its configured opacity, the shared silhouette arc on
360         // all four corners, the perimeter rolled over `bevel_width`. This
361         // demo is the reference app, so its base is the one every cce app
362         // should paint first.
363         pc.root_plate(w, h);
364 
365         // Header band: the title strip carved one step down into the plate. Flush to the
366         // window's top and sides, so its only real wall is the bottom one facing the
367         // content (the recessed-MenuBar idiom — the other three would fight the plate's
368         // own rolled perimeter).
369         let band_h = self.title_rect.y + self.title_rect.height + 10.0;
370         pc.recess_edges(
371             Rect { x: 0.0, y: 0.0, width: w, height: band_h },
372             (0.0, 0.0, 0.0, 0.0),
373             cce_ui::layout::bar_wall_width(),
374             (false, false, true, false),
375         );
376 
377         // Status band: the header's mirror — carved into the bottom of the
378         // plate, flush to the window's bottom and sides, its only wall the top
379         // one facing the content. (Neither band CSG-groups: edge-suppressed
380         // carves never do — their extended walls would smear across the
381         // plate's whole-surface draw. Both shade through the overlay fallback,
382         // whose host-box fade owns the junction with the roll.) Sized from
383         // the status font plus a symmetric pad (the layout's status leaf only
384         // reserves the space; the band and its text center independently).
385         let status_h = text_leaf_height(STATUS_FONT_SIZE) + 2.0 * STATUS_BAND_PAD;
386         let status_top = h - status_h;
387         pc.recess_edges(
388             Rect { x: 0.0, y: status_top, width: w, height: status_h },
389             (0.0, 0.0, 0.0, 0.0),
390             cce_ui::layout::bar_wall_width(),
391             (true, false, false, false),
392         );
393 
394         // App chrome text: plain prims. `text_with` carries an optional font family and
395         // optional bounds; unbounded text is clamped to the surface by the engine.
396         pc.text_with(
397             "cce-ui reference gallery".to_string(),
398             self.title_rect.x,
399             self.title_rect.y,
400             TITLE_FONT_SIZE,
401             [0xdd, 0xdd, 0xe2],
402             Some("monospace".to_string()),
403             None,
404         );
405         pc.text_with(
406             self.status.clone(),
407             self.status_rect.x,
408             cce_ui::layout::align_text_y(status_top, status_h, STATUS_FONT_SIZE, 0.0),
409             STATUS_FONT_SIZE,
410             [0x9a, 0x9a, 0xa4],
411             // None here falls through fontconfig's unbundled sans alias to the
412             // serif fallback — always name a family.
413             Some("monospace".to_string()),
414             None,
415         );
416 
417         // Widgets: each root walked through the single paint pass. The walk recurses,
418         // clips, and emits each widget's own geometry AND text (`Adapted::paint_self`
419         // serves per-widget fonts and bounds).
420         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.button, &mut pc);
421         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.toggle, &mut pc);
422         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.slider, &mut pc);
423         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.name_box, &mut pc);
424         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.image_contain, &mut pc);
425         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.image_stretch, &mut pc);
426         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.theme_dropdown, &mut pc);
427 
428         // The dropdown popover — geometry and labels last, on top of everything, exactly
429         // where it hit-tests. Labels carry bounds equal to the popover rect: that clips
430         // them to the plate AND exempts them from the occlusion clamp (text whose bounds
431         // equal an overlay rect is treated as the overlay's own).
432         if self.theme_dropdown.popover_rect().is_some() {
433             // PaintCtx is a RenderTarget: the popover draws its real prims (the
434             // dropdown's expanded inset-plate surface) with its own bounds.
435             self.theme_dropdown.render_popover(&mut pc);
436         }
437 
438         Some(pc.finish())
439     }
440 
441     /// Text prims in the display list ARE the frame's text — no `text_items` twin.
442     fn display_list_text(&self) -> bool {
443         true
444     }
445 
446     fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
447         Some(&self.ui_context)
448     }
449 
450     // Engine-driven animation frames for the dropdown expand/contract.
451     fn ui_context_mut(&mut self) -> Option<&mut cce_ui::context::UiContext> {
452         Some(&mut self.ui_context)
453     }
454 
455     /// Window dragging for a dissolved root: the surface is the movable plate; drag
456     /// anywhere a drag-blocking registered widget isn't.
457     fn is_movable_root_plate_at(&self, px: f32, py: f32) -> bool {
458         self.ui_context.drag_allowed_at(px, py)
459     }
460 
461     fn clear_color(&self) -> [f32; 4] {
462         [0.0, 0.0, 0.0, 0.0]
463     }
464 
465     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
466         let (px, py) = (pos.x, pos.y);
467         let ev = Event::PointerMove { x: px, y: py, local_x: px, local_y: py };
468         let mut changed = false;
469         // PointerMove visits every root: hover bookkeeping everywhere, and the
470         // router forwards DragUpdate to the recorded drag target (slider thumb,
471         // text selection) once its 3px threshold trips.
472         for root in self.root_ids() {
473             if self.ui_context.propagate_event(&ev, root) {
474                 changed = true;
475             }
476         }
477         self.drain_widget_changes();
478         if changed || self.needs_rebuild {
479             *needs_rebuild = true;
480             self.needs_rebuild = true;
481         }
482     }
483 
484     fn handle_mouse_input(
485         &mut self,
486         button: MouseButton,
487         state: ElementState,
488         pos: LogicalPosition,
489         needs_rebuild: &mut bool,
490     ) -> Option<Self::Message> {
491         let (px, py) = (pos.x, pos.y);
492         let ev = Event::MouseButton { button, state, x: px, y: py, local_x: px, local_y: py };
493         let mut changed = false;
494         // Presses are hit-gated per widget by the adapter and releases delivered
495         // everywhere (press-tracking widgets commit or cancel on them) — a straight
496         // loop is correct for pointer-positioned events.
497         for root in self.root_ids() {
498             if self.ui_context.propagate_event(&ev, root) {
499                 changed = true;
500             }
501         }
502         self.drain_widget_changes();
503         if changed || self.needs_rebuild {
504             *needs_rebuild = true;
505             self.needs_rebuild = true;
506         }
507         None
508     }
509 
510     fn handle_mouse_wheel(
511         &mut self,
512         delta: &MouseScrollDelta,
513         pos: LogicalPosition,
514         needs_rebuild: &mut bool,
515     ) {
516         let (px, py) = (pos.x, pos.y);
517         let ev = Event::MouseWheel { delta: delta.clone(), x: px, y: py, local_x: px, local_y: py };
518         let mut changed = false;
519         // Wheel is hit-scoped per widget (the slider nudges its value under the
520         // cursor); roots that miss return false.
521         for root in self.root_ids() {
522             if self.ui_context.propagate_event(&ev, root) {
523                 changed = true;
524             }
525         }
526         self.drain_widget_changes();
527         if changed || self.needs_rebuild {
528             *needs_rebuild = true;
529             self.needs_rebuild = true;
530         }
531     }
532 
533     fn handle_key_input(
534         &mut self,
535         event: &KeyEvent,
536         needs_rebuild: &mut bool,
537     ) -> Option<Self::Message> {
538         // App-level shortcuts before widget routing.
539         if event.ctrl && event.state == ElementState::Pressed {
540             if let cce_ui::widget::Key::Character(ref c) = event.logical_key {
541                 if c == "q" {
542                     return Some(DemoMessage::Exit);
543                 }
544             }
545         }
546 
547         // KeyInput MUST short-circuit: the router delivers keys to the ctx-focused
548         // widget FIRST on every propagate call, so a non-short-circuited chain would
549         // hand a typed character to the focused widget once per root (N-time
550         // insertion). Plumbing that would key off "which call handled it" belongs in
551         // the state-gated `drain_widget_changes` instead.
552         let ev = Event::KeyInput(event.clone());
553         let mut handled = false;
554         for root in self.root_ids() {
555             if self.ui_context.propagate_event(&ev, root) {
556                 handled = true;
557                 break;
558             }
559         }
560         self.drain_widget_changes();
561         if handled || self.needs_rebuild {
562             *needs_rebuild = true;
563             self.needs_rebuild = true;
564         }
565         None
566     }
567 }
568 
569 fn main() {
570     cce_ui::engine::run::<DemoApp>();
571 }