git.lucas.co / cce-gallery
widget gallery and compositor test bench

src/main.rs (74.9K)

   1 use cce_ui::context::UiContext;
   2 use cce_ui::widget::{Handle, WidgetId};
   3 use cce_ui::widget::{Button, Checkbox, ContentBg, Dropdown, Label, ProgressBar, RangeSlider, Slider, Spinbox, StatusBar, Toggle, WidgetHost, Trackpad, hover_animation, TextBox, MenuBar, Group, Ramp, RampKey, ColorRamp, MouseButton, ElementState, Key, NamedKey, KeyEvent, MouseScrollDelta, ColorSelector, FontSelector, KeybindRecorder, ButtonStrip, Float3, UsageBar, StatusDot, DotStatus, InfoBox, InteractiveListItem, Breadcrumb, TreeList, BevelPreview, RampPreview, Separator, Splitter, Paginator, VerticalLayout, ColumnsLayout, GridLayout, AdaptiveGridLayout, MosaicLayout, ReverseMosaicLayout, OverlayLayout, ScrollBox, WidgetHostExt};
   4 mod gallery_widgets;
   5 use gallery_widgets::{RootPlate, Plate};
   6 use cce_ui::widget::{Adapted, LayoutConstraints, Point};
   7 use cce_ui::widget::input::Slider2D;
   8 use cce_ui::engine::{LogicalSize, LogicalPosition, LayerAnchor, LayerKeyboardInteractivity, LayerKind, LayerSettings};
   9 use cce_ui::scene::paint::Prim;
  10 
  11 /// What a child window is, from `--type`. The first seven are the kinds of surface a
  12 /// toolkit client can be under cce; the last two are the gallery's editor windows.
  13 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  14 enum ChildKind {
  15     Floating,
  16     Fullscreen,
  17     Utility,
  18     LayerTop,
  19     LayerOverlay,
  20     LayerBackground,
  21     Status,
  22     Ramp,
  23     ColorRamp,
  24 }
  25 
  26 impl ChildKind {
  27     fn from_arg(s: &str) -> Option<ChildKind> {
  28         Some(match s {
  29             "Floating" => ChildKind::Floating,
  30             "Fullscreen" => ChildKind::Fullscreen,
  31             "Utility" => ChildKind::Utility,
  32             "LayerTop" => ChildKind::LayerTop,
  33             "LayerOverlay" => ChildKind::LayerOverlay,
  34             "LayerBackground" => ChildKind::LayerBackground,
  35             "Status" => ChildKind::Status,
  36             "Ramp" => ChildKind::Ramp,
  37             "ColorRamp" => ChildKind::ColorRamp,
  38             _ => return None,
  39         })
  40     }
  41 
  42     /// The `--type` spelling.
  43     fn arg(self) -> &'static str {
  44         match self {
  45             ChildKind::Floating => "Floating",
  46             ChildKind::Fullscreen => "Fullscreen",
  47             ChildKind::Utility => "Utility",
  48             ChildKind::LayerTop => "LayerTop",
  49             ChildKind::LayerOverlay => "LayerOverlay",
  50             ChildKind::LayerBackground => "LayerBackground",
  51             ChildKind::Status => "Status",
  52             ChildKind::Ramp => "Ramp",
  53             ChildKind::ColorRamp => "ColorRamp",
  54         }
  55     }
  56 
  57     fn title(self) -> &'static str {
  58         match self {
  59             ChildKind::Floating => "Floating Window",
  60             ChildKind::Fullscreen => "Fullscreen Window",
  61             ChildKind::Utility => "Utility Window",
  62             ChildKind::LayerTop => "Layer Shell (Top) Surface",
  63             ChildKind::LayerOverlay => "Layer Shell (Overlay) Surface",
  64             ChildKind::LayerBackground => "Layer Shell (Background) Surface",
  65             ChildKind::Status => "Status Segment",
  66             ChildKind::Ramp => "Ramp Editor",
  67             ChildKind::ColorRamp => "Color Ramp Editor",
  68         }
  69     }
  70 
  71     fn is_editor(self) -> bool {
  72         matches!(self, ChildKind::Ramp | ChildKind::ColorRamp)
  73     }
  74 
  75     fn default_size(self) -> (f32, f32) {
  76         match self {
  77             ChildKind::Floating | ChildKind::Fullscreen => (400.0, 250.0),
  78             ChildKind::Utility | ChildKind::LayerOverlay => (300.0, 180.0),
  79             ChildKind::LayerTop => (800.0, 40.0),
  80             ChildKind::LayerBackground => (800.0, 600.0),
  81             ChildKind::Status => (140.0, 24.0),
  82             // 390 tall: the editor keeps the 260px it had at hand-set margins now that
  83             // the title line, one root gap and the root insets stand around it.
  84             ChildKind::Ramp | ChildKind::ColorRamp => (450.0, 390.0),
  85         }
  86     }
  87 
  88     /// `cce-status-*` is the compositor's status-bar convention (`WindowRole::from_app_id`),
  89     /// with the edge read from the `-left-` / `-right-` infix; everything else is ours.
  90     fn app_id(self) -> String {
  91         match self {
  92             ChildKind::Status => "cce-status-right-gallery".to_string(),
  93             kind => format!("cce-gallery-child-{}", kind.arg().to_lowercase()),
  94         }
  95     }
  96 
  97     /// The wlr-layer-shell configuration for the three layer kinds.
  98     fn layer(self, height: i32) -> Option<LayerSettings> {
  99         let (layer, anchor, exclusive_zone) = match self {
 100             ChildKind::LayerTop => (LayerKind::Top, LayerAnchor::TOP | LayerAnchor::LEFT | LayerAnchor::RIGHT, height),
 101             ChildKind::LayerOverlay => (LayerKind::Overlay, LayerAnchor::empty(), 0),
 102             ChildKind::LayerBackground => (
 103                 LayerKind::Background,
 104                 LayerAnchor::TOP | LayerAnchor::BOTTOM | LayerAnchor::LEFT | LayerAnchor::RIGHT,
 105                 -1,
 106             ),
 107             _ => return None,
 108         };
 109         Some(LayerSettings {
 110             layer,
 111             anchor,
 112             exclusive_zone,
 113             keyboard_interactivity: LayerKeyboardInteractivity::None,
 114             margin: (0, 0, 0, 0),
 115             namespace: "cce-gallery".to_string(),
 116         })
 117     }
 118 
 119     /// The line inside the child window.
 120     fn child_text(self) -> &'static str {
 121         match self {
 122             ChildKind::Floating => "A plain xdg_toplevel, mapped Floating by cce.",
 123             ChildKind::Fullscreen => "An xdg_toplevel that asked for fullscreen at map.",
 124             ChildKind::Utility => "A toplevel declared UTILITY over the cce protocol.",
 125             ChildKind::LayerTop => "A Top-layer wlr-layer-shell surface with an exclusive zone.",
 126             ChildKind::LayerOverlay => "An Overlay-layer wlr-layer-shell surface, unanchored.",
 127             ChildKind::LayerBackground => "A Background-layer wlr-layer-shell surface.",
 128             ChildKind::Status => "A cce-status-* toplevel, docked into the status bar.",
 129             ChildKind::Ramp | ChildKind::ColorRamp => "",
 130         }
 131     }
 132 }
 133 
 134 /// What the roster's visibility filter needs, copied out of `State` so the dispatch
 135 /// loops can hold `&mut self.roster` while asking. The gallery shows every slot; a
 136 /// child window shows its menu bar and status bar only when asked to.
 137 #[derive(Clone, Copy)]
 138 struct Visibility {
 139     is_child: bool,
 140     use_menubar: bool,
 141     use_statusbar: bool,
 142 }
 143 
 144 impl Visibility {
 145     fn is_visible(self, index: usize) -> bool {
 146         if self.is_child {
 147             return match index {
 148                 0..=2 => true, // background, main, Close
 149                 3 => self.use_menubar,
 150                 4 => self.use_statusbar,
 151                 _ => false,
 152             };
 153         }
 154         true
 155     }
 156 }
 157 
 158 /// The gallery roster: 32 named slots. The numeric indexes used by the positions
 159 /// table, the visibility filter and the dispatch loops address these slots through
 160 /// `Roster::id` / `get_dyn` / `get_dyn_mut`.
 161 pub struct GallerySlots {
 162     pub menu_bar: Handle<Adapted<MenuBar>>,
 163     pub status_bar: Handle<Adapted<StatusBar>>,
 164     pub button_demo: Handle<Adapted<Button>>,
 165     pub checkbox_demo: Handle<Adapted<Checkbox>>,
 166     pub toggle_demo: Handle<Adapted<Toggle>>,
 167     pub progress_demo: Handle<Adapted<ProgressBar>>,
 168     pub slider_demo: Handle<Adapted<Slider>>,
 169     pub spinbox_demo: Handle<Adapted<Spinbox>>,
 170     pub range_slider_demo: Handle<Adapted<RangeSlider>>,
 171     pub trackpad_demo: Handle<Adapted<Trackpad>>,
 172     pub textbox_demo: Handle<Adapted<TextBox>>,
 173     pub plate_demo: Handle<Adapted<Plate>>,
 174     pub color_ramp_btn: Handle<Adapted<Button>>,
 175     pub bevel_ramp: Handle<Adapted<Ramp>>,
 176     pub ramp_btn: Handle<Adapted<Button>>,
 177     pub layout_dd: Handle<Adapted<Dropdown>>,
 178     pub color_selector_demo: Handle<Adapted<ColorSelector>>,
 179     pub font_selector_demo: Handle<Adapted<FontSelector>>,
 180     pub keybind_demo: Handle<Adapted<KeybindRecorder>>,
 181     pub button_strip_demo: Handle<Adapted<ButtonStrip>>,
 182     pub slider2d_demo: Handle<Adapted<Slider2D>>,
 183     pub float3_demo: Handle<Adapted<Float3>>,
 184     pub usage_bar_demo: Handle<Adapted<UsageBar>>,
 185     pub status_dot_demo: Handle<Adapted<StatusDot>>,
 186     pub info_box_demo: Handle<Adapted<InfoBox>>,
 187     pub list_item_demo: Handle<Adapted<InteractiveListItem>>,
 188     pub breadcrumb_demo: Handle<Adapted<Breadcrumb>>,
 189     pub tree_list_demo: Handle<Adapted<TreeList>>,
 190     pub bevel_preview_demo: Handle<Adapted<BevelPreview>>,
 191     pub ramp_preview_demo: Handle<Adapted<RampPreview>>,
 192     pub separator_demo: Handle<Adapted<Separator>>,
 193     pub splitter_demo: Handle<Adapted<Splitter>>,
 194     /// Two `Group` lassos over other exhibits (slots 32 and 33): overlays, not
 195     /// exhibits — laid out by their members, drawn under the exhibit clip.
 196     pub group_loose: Handle<Adapted<Group>>,
 197     pub group_fitted: Handle<Adapted<Group>>,
 198     /// The Style dropdown (slot 34), beside Layout in the header: Relief or Flat
 199     /// for every control at once (`cce_ui::layout::set_control_relief`).
 200     pub style_dd: Handle<Adapted<Dropdown>>,
 201     /// The variant exhibits (`variant_exhibits`): every further style of a widget one of
 202     /// the named slots already shows, addressed as slots `GALLERY_COUNT..`.
 203     pub extra: Vec<Exhibit>,
 204 }
 205 
 206 /// The number of NAMED gallery slots; the variant exhibits follow them, so the roster's
 207 /// `len` is this plus `GallerySlots::extra.len()`.
 208 pub const GALLERY_COUNT: usize = 35;
 209 
 210 /// A variant exhibit: a widget in a style other than the one its named slot shows, with
 211 /// the content size `layout_exhibits` resets it to.
 212 pub struct Exhibit {
 213     /// The widget, which the context owns.
 214     pub id: WidgetId,
 215     pub w: f32,
 216     pub h: f32,
 217     /// A content width narrower than `w`: the strategy lays the exhibit out at `w` (room
 218     /// for its label) and the widget is then given this width — a StatusDot stays a dot.
 219     pub content_w: Option<f32>,
 220 }
 221 
 222 impl Exhibit {
 223     /// Sized by the widget's own preferred (content) height, `fallback_h` when it declares none.
 224     fn new<W: WidgetHost + 'static>(ctx: &mut UiContext, widget: W, w: f32, fallback_h: f32) -> Self {
 225         let h = widget.preferred_height().unwrap_or(fallback_h);
 226         Exhibit { id: ctx.insert(widget).id(), w, h, content_w: None }
 227     }
 228 
 229     /// Sized by hand: for widgets whose declared height is a single row (a multiline
 230     /// TextBox, a vertical ButtonStrip) when the exhibit wants several.
 231     fn sized<W: WidgetHost + 'static>(ctx: &mut UiContext, widget: W, w: f32, h: f32) -> Self {
 232         Exhibit { id: ctx.insert(widget).id(), w, h, content_w: None }
 233     }
 234 
 235     fn with_content_width(mut self, w: f32) -> Self {
 236         self.content_w = Some(w);
 237         self
 238     }
 239 }
 240 
 241 impl GallerySlots {
 242     /// The widget in slot `idx`.
 243     pub fn id(&self, idx: usize) -> WidgetId {
 244         if idx >= GALLERY_COUNT {
 245             return self.extra[idx - GALLERY_COUNT].id;
 246         }
 247         match idx {
 248             0 => self.menu_bar.id(),
 249             1 => self.status_bar.id(),
 250             2 => self.button_demo.id(),
 251             3 => self.checkbox_demo.id(),
 252             4 => self.toggle_demo.id(),
 253             5 => self.progress_demo.id(),
 254             6 => self.slider_demo.id(),
 255             7 => self.spinbox_demo.id(),
 256             8 => self.range_slider_demo.id(),
 257             9 => self.trackpad_demo.id(),
 258             10 => self.textbox_demo.id(),
 259             11 => self.plate_demo.id(),
 260             12 => self.color_ramp_btn.id(),
 261             13 => self.bevel_ramp.id(),
 262             14 => self.ramp_btn.id(),
 263             15 => self.layout_dd.id(),
 264             16 => self.color_selector_demo.id(),
 265             17 => self.font_selector_demo.id(),
 266             18 => self.keybind_demo.id(),
 267             19 => self.button_strip_demo.id(),
 268             20 => self.slider2d_demo.id(),
 269             21 => self.float3_demo.id(),
 270             22 => self.usage_bar_demo.id(),
 271             23 => self.status_dot_demo.id(),
 272             24 => self.info_box_demo.id(),
 273             25 => self.list_item_demo.id(),
 274             26 => self.breadcrumb_demo.id(),
 275             27 => self.tree_list_demo.id(),
 276             28 => self.bevel_preview_demo.id(),
 277             29 => self.ramp_preview_demo.id(),
 278             30 => self.separator_demo.id(),
 279             31 => self.splitter_demo.id(),
 280             32 => self.group_loose.id(),
 281             33 => self.group_fitted.id(),
 282             34 => self.style_dd.id(),
 283             _ => panic!("gallery slot index out of range: {idx}"),
 284         }
 285     }
 286 }
 287 
 288 /// A child window's five slots — background, main, Close, and the optional menu bar
 289 /// and status bar rows (3, 4) — as the ids of the widgets the context owns.
 290 pub struct ChildSlots {
 291     pub ids: [WidgetId; CHILD_COUNT],
 292     pub close: Handle<Adapted<Button>>,
 293 }
 294 
 295 pub const CHILD_COUNT: usize = 5;
 296 
 297 /// The two roster modes. The widgets are the context's; a roster names them by slot.
 298 pub enum Roster {
 299     Gallery(Box<GallerySlots>),
 300     Child(Box<ChildSlots>),
 301 }
 302 
 303 impl Roster {
 304     pub fn len(&self) -> usize {
 305         match self {
 306             Roster::Gallery(s) => GALLERY_COUNT + s.extra.len(),
 307             Roster::Child(_) => CHILD_COUNT,
 308         }
 309     }
 310 
 311     /// The widget in slot `idx`.
 312     pub fn id(&self, idx: usize) -> WidgetId {
 313         match self {
 314             Roster::Gallery(s) => s.id(idx),
 315             Roster::Child(s) => *s.ids.get(idx).unwrap_or_else(|| panic!("child slot index out of range: {idx}")),
 316         }
 317     }
 318 
 319     pub fn get_dyn<'a>(&self, ui: &'a UiContext, idx: usize) -> &'a (dyn WidgetHost + 'static) {
 320         ui.get_widget(self.id(idx)).expect("a roster slot's widget is in the context")
 321     }
 322 
 323     pub fn get_dyn_mut<'a>(&self, ui: &'a mut UiContext, idx: usize) -> &'a mut (dyn WidgetHost + 'static) {
 324         ui.get_widget_mut(self.id(idx)).expect("a roster slot's widget is in the context")
 325     }
 326 
 327     /// A slot that is never dragged or drained: the variant exhibits are looked at, and
 328     /// the lassos (32, 33) are frames.
 329     fn inert(&self, idx: usize) -> bool {
 330         matches!(self, Roster::Gallery(_)) && (idx >= GALLERY_COUNT || matches!(idx, 32 | 33))
 331     }
 332 
 333     pub fn draggable(&self, ui: &UiContext, idx: usize) -> bool {
 334         if self.inert(idx) {
 335             return false;
 336         }
 337         let w = self.get_dyn(ui, idx);
 338         w.input_model().draggable(w.content_rect())
 339     }
 340 
 341     pub fn is_dragging(&self, ui: &UiContext, idx: usize) -> bool {
 342         !self.inert(idx) && self.get_dyn(ui, idx).input_model().is_dragging()
 343     }
 344 
 345     /// The gallery slots; panics in child mode (gallery-only paths assert their mode).
 346     pub fn gallery(&self) -> &GallerySlots {
 347         match self {
 348             Roster::Gallery(s) => s,
 349             Roster::Child(_) => panic!("gallery slots requested in child mode"),
 350         }
 351     }
 352 
 353     // --- Value drains: route a slot index to its widget's `take_click` / `value`.
 354 
 355     pub fn take_click(&self, ui: &mut UiContext, idx: usize) -> bool {
 356         match self {
 357             // The child roster drains one slot: its Close button.
 358             Roster::Child(c) => idx == 2 && ui[c.close].take_click(),
 359             Roster::Gallery(_) => !self.inert(idx) && self.get_dyn_mut(ui, idx).input_model_mut().take_click(),
 360         }
 361     }
 362 
 363     pub fn value(&self, ui: &UiContext, idx: usize) -> i32 {
 364         let s = self.gallery();
 365         match idx {
 366             15 => ui[s.layout_dd].value(),
 367             34 => ui[s.style_dd].value(),
 368             _ => panic!("value: unwired gallery slot {idx}"),
 369         }
 370     }
 371 }
 372 
 373 struct State {
 374     roster: Roster,
 375     positions: Vec<(f32, f32, f32, f32)>,
 376 
 377     status_text: String,
 378 
 379     focused_widget: Option<usize>,
 380 
 381     width: f32,
 382     height: f32,
 383     scale: f64,
 384 
 385     is_child: bool,
 386     opacity: bool,
 387     transparency: f32,
 388     use_root_plate: bool,
 389     use_menubar: bool,
 390     use_statusbar: bool,
 391     border_enabled: bool,
 392     child_kind: Option<ChildKind>,
 393     ui_context: cce_ui::context::UiContext,
 394 
 395     layout_idx: usize,
 396     /// The scroll frame the exhibits below the Layout dropdown scroll inside.
 397     exhibit_scroll: ScrollBox,
 398 }
 399 
 400 fn save_bevel_ramp(keys: &[RampKey], line_type: &str) {
 401     let mut s = String::new();
 402     s.push_str("keys {\n");
 403     for k in keys {
 404         s.push_str(&format!("    key pos={} val={}\n", k.pos, k.value));
 405     }
 406     s.push_str("}\n");
 407     s.push_str(&format!("line_type \"{}\"\n", line_type));
 408     let path = cce_ui::config::get_config_path().parent().unwrap().join("bevel_ramp.kdl");
 409     let _ = std::fs::write(path, s);
 410 }
 411 
 412 fn load_bevel_ramp() -> (Vec<RampKey>, String) {
 413     let path = cce_ui::config::get_config_path().parent().unwrap().join("bevel_ramp.kdl");
 414     let mut line_type = "linear".to_string();
 415     let mut keys = Vec::new();
 416     if let Ok(content) = std::fs::read_to_string(path) {
 417         for line in content.lines() {
 418             let line = line.trim();
 419             if line.starts_with("key ") {
 420                 let mut pos = 0.0;
 421                 let mut val = 0.5;
 422                 for part in line.split_whitespace() {
 423                     if let Some(rest) = part.strip_prefix("pos=") {
 424                         pos = rest.parse().unwrap_or(0.0);
 425                     } else if let Some(rest) = part.strip_prefix("val=") {
 426                         val = rest.parse().unwrap_or(0.5);
 427                     }
 428                 }
 429                 keys.push(RampKey { pos, value: val });
 430             } else if line.starts_with("line_type ") {
 431                 if let Some(val_str) = line.split_whitespace().nth(1) {
 432                     line_type = val_str.trim_matches('"').to_string();
 433                 }
 434             }
 435         }
 436         if !keys.is_empty() {
 437             keys.sort_by(|a, b| a.pos.partial_cmp(&b.pos).unwrap());
 438             return (keys, line_type);
 439         }
 440     }
 441     (
 442         vec![
 443             RampKey { pos: 0.0, value: 0.5 },
 444             RampKey { pos: 0.2, value: 1.0 },
 445             RampKey { pos: 0.8, value: 1.0 },
 446             RampKey { pos: 1.0, value: 0.5 },
 447         ],
 448         "linear".to_string()
 449     )
 450 }
 451 
 452 /// Every further style of a widget the named slots show once: the toolkit's own
 453 /// variants — a constructor (`Button::new_reset`) or a builder (`with_band`) —
 454 /// so the page shows each look a widget can take, in the toolkit's default size
 455 /// for it.
 456 ///
 457 /// The relief-off look is NOT a variant here: the header's Style dropdown switches
 458 /// every control between Relief and Flat at once (`set_control_relief`), so each
 459 /// exhibit shows both, and no widget appears twice for its style alone.
 460 fn variant_exhibits(ctx: &mut UiContext) -> Vec<Exhibit> {
 461     const W: f32 = 190.0;
 462     let bh = cce_ui::layout::button_height();
 463     let slh = cce_ui::layout::slider_height();
 464     let tbh = cce_ui::layout::textbox_height();
 465     let ddh = cce_ui::layout::dropdown_height();
 466     let csh = cce_ui::layout::color_selector_height();
 467     // A StatusDot is 12px square; its exhibit is as wide as its label.
 468     const DOT_W: f32 = 200.0;
 469     // A column of three rotated tabs, and the sidebar width a vertical strip is drawn for.
 470     const TABS_H: f32 = 120.0;
 471     const TAB_COLUMN_W: f32 = 40.0;
 472     let three = || vec!["One".to_string(), "Two".to_string(), "Three".to_string()];
 473     vec![
 474         // Button: the other four kinds.
 475         Exhibit::new(ctx, Button::new_reset(0.0, 0.0, W, bh).with_label("Button (reset)"), W, bh),
 476         Exhibit::new(ctx, Button::new_list_row(0.0, 0.0, W, bh).with_label("Button (list row)"), W, bh),
 477         Exhibit::new(ctx, Button::new_menu_item(0.0, 0.0, W, bh).with_label("Button (menu item)"), W, bh),
 478         Exhibit::new(ctx, Button::new_copy_icon(0.0, 0.0, bh, bh), bh, bh),
 479         // Slider: with a readout.
 480         Exhibit::new(ctx, Slider::new().with_label("Slider (readout)").with_readout(true), W, slh),
 481         // TextBox: multiline, chromeless, password.
 482         Exhibit::sized(
 483             ctx,
 484             TextBox::new("TextBox (multiline)\nA second line of text.".to_string()).with_multiline(true),
 485             W,
 486             3.0 * tbh,
 487         ),
 488         Exhibit::new(ctx, TextBox::new("TextBox (chromeless)".to_string()).with_draw_bg_border(false), W, tbh),
 489         Exhibit::new(ctx, TextBox::new("hunter2".to_string()).with_password(true).with_label("TextBox (password)"), W, tbh),
 490         // ButtonStrip: the vertical column (rotated tabs), and the Paginator sidebar built on it.
 491         Exhibit::sized(
 492             ctx,
 493             Adapted::new(ButtonStrip::new(0.0, 0.0, W, TABS_H).with_buttons(three()).with_selected(Some(0)).with_vertical(true))
 494                 .with_label("ButtonStrip (vertical)"),
 495             W,
 496             TABS_H,
 497         )
 498         .with_content_width(TAB_COLUMN_W),
 499         Exhibit::sized(ctx, Paginator::new(three()).with_label("Paginator"), W, TABS_H),
 500         // ColorSelector: the alpha swatch.
 501         Exhibit::new(ctx, ColorSelector::new_rgba([64, 128, 255, 128]).with_label("ColorSelector (alpha)"), W, csh),
 502         // Label: the plain text widget.
 503         Exhibit::new(ctx, Label::new("Label"), W, ddh),
 504         // StatusDot: the other three statuses.
 505         Exhibit::new(ctx, StatusDot::new(DotStatus::Inactive).with_label("StatusDot (inactive)"), DOT_W, StatusDot::SIZE)
 506             .with_content_width(StatusDot::SIZE),
 507         Exhibit::new(ctx, StatusDot::new(DotStatus::Warning).with_label("StatusDot (warning)"), DOT_W, StatusDot::SIZE)
 508             .with_content_width(StatusDot::SIZE),
 509         Exhibit::new(ctx, StatusDot::new(DotStatus::Error).with_label("StatusDot (error)"), DOT_W, StatusDot::SIZE)
 510             .with_content_width(StatusDot::SIZE),
 511     ]
 512 }
 513 
 514 /// The exhibits: every slot but the chrome (0, 1), the lassos (32, 33) and the
 515 /// header dropdowns (15 Layout, 34 Style), which `layout_exhibits` lays out under
 516 /// the header row.
 517 fn is_exhibit(i: usize) -> bool {
 518     matches!(i, 2..=14 | 16..=31) || i >= GALLERY_COUNT
 519 }
 520 
 521 /// The Group lassos: drawn under the exhibit clip like exhibits, laid out by
 522 /// their members rather than by the strategy.
 523 fn is_overlay(i: usize) -> bool {
 524     matches!(i, 32 | 33)
 525 }
 526 
 527 impl State {
 528     fn visibility(&self) -> Visibility {
 529         Visibility {
 530             is_child: self.is_child,
 531             use_menubar: self.use_menubar,
 532             use_statusbar: self.use_statusbar,
 533         }
 534     }
 535 
 536     fn is_widget_visible(&self, index: usize) -> bool {
 537         self.visibility().is_visible(index)
 538     }
 539 
 540     /// Recompute the positions table for the current size, mode and layout.
 541     fn relayout(&mut self) {
 542         self.positions = if self.is_child {
 543             child_positions(self.width, self.height, self.use_menubar, self.use_statusbar, self.child_kind.is_some_and(ChildKind::is_editor))
 544         } else {
 545             demo_positions(self.width, self.height, self.roster.len())
 546         };
 547     }
 548 
 549     /// The Controls exhibits in layout order, each with the content size it is reset to
 550     /// before a strategy runs: the toolkit layouts read a child's own rect for its width
 551     /// and preferred height, and a previous strategy may have stretched it.
 552     fn exhibit_sizes(&self) -> Vec<(usize, f32, f32)> {
 553         // Every height below is the toolkit's default for that control — its
 554         // configured `style.control.<name>.height`, or the intrinsic size the
 555         // widget declares — so the page is a record of the defaults, not of
 556         // numbers chosen here. The strategies read a widget's own intrinsic
 557         // height anyway; the table only has to agree with it.
 558         let bh = cce_ui::layout::button_height();
 559         let tgh = cce_ui::layout::toggle_height();
 560         let slh = cce_ui::layout::slider_height();
 561         let rsh = cce_ui::layout::rangeslider_height();
 562         let pbh = cce_ui::layout::progressbar_height();
 563         let sph = cce_ui::layout::spinbox_height();
 564         let ddh = cce_ui::layout::dropdown_height();
 565         let tbh = cce_ui::layout::textbox_height();
 566         let csh = cce_ui::layout::color_selector_height();
 567         let fsh = cce_ui::layout::font_selector_height();
 568         let rmh = cce_ui::layout::ramp_height();
 569         // The few exhibits with no toolkit default are canvases: an area to draw
 570         // or drag in, sized here and only here.
 571         const CANVAS_H: f32 = 120.0;
 572         const W: f32 = 190.0;
 573         let s2d_w = self
 574             .roster
 575             .get_dyn(&self.ui_context, 20)
 576             .measure(LayoutConstraints::new(0.0, f32::MAX, 0.0, f32::MAX), &self.ui_context)
 577             .width;
 578         // A StatusDot is 12px square; its exhibit is as wide as its label
 579         // (`content_width` hands the dot its own size back after layout).
 580         const DOT_W: f32 = 200.0;
 581         let raw: [(usize, f32, f32); 29] = [
 582             (2, W, bh),                                   // Button
 583             (19, W, bh),                                  // ButtonStrip
 584             (3, W, tgh),                                  // Checkbox (a toggle row)
 585             (4, W, tgh),                                  // Toggle
 586             (6, W, slh),                                  // Slider
 587             (8, W, rsh),                                 // RangeSlider
 588             (20, s2d_w, 64.0),                            // Slider2D (a 64px pad, wide enough for its label)
 589             (7, W, sph),                                  // Spinbox
 590             (21, W, Float3::preferred_height(false)),     // Float3 (three slider rows)
 591             (10, W, tbh),                                 // TextBox
 592             (18, W, tbh),                                 // KeybindRecorder (a textbox)
 593             (16, W, csh),                                 // ColorSelector
 594             (17, W, fsh),                                 // FontSelector
 595             (5, W, pbh),                                  // ProgressBar
 596             (22, W, pbh),                                 // UsageBar
 597             (23, DOT_W, StatusDot::SIZE),                 // StatusDot (its label needs the width)
 598             (30, W, 1.0),                                 // Separator (a rule)
 599             (31, 6.0, CANVAS_H),                          // Splitter (a vertical grip)
 600             (24, W, 3.0 * ddh),                           // InfoBox (title + two lines)
 601             (25, W, 2.0 * ddh),                           // InteractiveListItem (title + subtitle)
 602             (26, W, bh),                                  // Breadcrumb (button plates)
 603             (27, W, CANVAS_H),                            // TreeList
 604             (9, W, CANVAS_H),                            // Trackpad
 605             (11, W, CANVAS_H),                            // Plate
 606             (28, W, CANVAS_H),                            // BevelPreview
 607             (29, W, 2.0 * rmh),                           // RampPreview (a ramp's curve)
 608             (13, W, CANVAS_H),                            // Ramp (its editor declares 150)
 609             (12, W, bh),                                  // Color Ramp...
 610             (14, W, bh),                                  // Ramp...
 611         ];
 612         let mut sizes = raw.to_vec();
 613         let extra = &self.roster.gallery().extra;
 614         sizes.extend(extra.iter().enumerate().map(|(k, e)| (GALLERY_COUNT + k, e.w, e.h)));
 615         sizes
 616     }
 617 
 618     /// Lay the Controls exhibits out with the toolkit strategy the Layout dropdown selects
 619     /// (`LAYOUTS`), then clip whatever runs past the status bar.
 620     /// The exhibits' viewport: below the Layout dropdown, above the status bar.
 621     /// The exhibit area: a well sunk into the root plate, standing on it like the
 622     /// header dropdowns — their inset in from the window's sides (`x` is the Layout
 623     /// dropdown's), one root gap below them and one root gap above the status band.
 624     fn exhibit_viewport(&self) -> (f32, f32, f32, f32) {
 625         let gap = cce_ui::layout::root_plate_gap();
 626         let (dx, dy, _, dh) = self.roster.get_dyn(&self.ui_context, 15).rect();
 627         let (x, y) = (dx, dy + dh + gap);
 628         let w = (self.width - 2.0 * x).max(300.0);
 629         let h = ((self.height - 24.0 - gap) - y).max(100.0);
 630         (x, y, w, h)
 631     }
 632 
 633     /// The well's rect, outer corner radius and wall depth. The wall is every
 634     /// well's rule (`well_rim`: the DE bevel width capped at a fifth of the
 635     /// height), taken in both styles so the page lays out the same whichever the
 636     /// Style dropdown selects — flat, it is the margin inside the hairline frame.
 637     fn exhibit_well(&self) -> (cce_ui::scene::layout::Rect, f32, f32) {
 638         let (x, y, w, h) = self.exhibit_viewport();
 639         let rect = cce_ui::scene::layout::Rect { x, y, width: w, height: h };
 640         let radius = cce_ui::layout::plate_corner_radius();
 641         let wall = cce_ui::layout::bevel_width().min(h * 0.2);
 642         (rect, radius, wall)
 643     }
 644 
 645     /// The well's floor inside its wall — the plate the exhibits sit on and the
 646     /// fitted lasso snaps to — with the floor's corner radius.
 647     fn exhibit_floor(&self) -> (cce_ui::scene::layout::Rect, f32) {
 648         let (rect, radius, wall) = self.exhibit_well();
 649         let (floor, radii) = cce_ui::layout::carve_inside(rect, (radius, radius, radius, radius), wall);
 650         (floor, radii.0)
 651     }
 652 
 653     fn in_exhibit_viewport(&self, px: f32, py: f32) -> bool {
 654         let (x, y, w, h) = self.exhibit_viewport();
 655         px >= x && px <= x + w && py >= y && py <= y + h
 656     }
 657 
 658     /// Lay the Controls exhibits out with the toolkit strategy the Layout dropdown selects
 659     /// (`LAYOUTS`), then shift them by the scroll frame's offset; painting clips them to
 660     /// the viewport. Runs from apply_layout and from display_list, which is what
 661     /// re-arranges after a scroll.
 662     fn layout_exhibits(&mut self) {
 663         let (x, y, w, h) = self.exhibit_viewport();
 664         self.exhibit_scroll.set_rect(x, y, w, h);
 665         // The exhibits sit on the well's floor, inside its wall; the fitted lasso's
 666         // plate is that floor, so its sides snap to the foot of the wall.
 667         let (floor, floor_r) = self.exhibit_floor();
 668         if let Roster::Gallery(s) = &mut self.roster {
 669             self.ui_context[s.group_fitted].inner_mut().set_plate(floor, floor_r);
 670         }
 671         let mut children: Vec<*mut (dyn WidgetHost + 'static)> = Vec::new();
 672         let mut indices: Vec<usize> = Vec::new();
 673         for (idx, cw, ch) in self.exhibit_sizes() {
 674             if self.roster.is_dragging(&self.ui_context, idx) {
 675                 continue;
 676             }
 677             let widget = self.roster.get_dyn_mut(&mut self.ui_context, idx);
 678             // Seed the block: the content height plus the label strip above it.
 679             let strip = widget.label_strip();
 680             widget.set_rect(0.0, 0.0, cw, ch + strip);
 681             children.push(widget as *mut (dyn WidgetHost + 'static));
 682             indices.push(idx);
 683         }
 684         // The exhibits start at the fitted lasso's seat: one padding in from the
 685         // area's sides for the frame plus one for the members inside it, and the
 686         // lasso's headroom (padding + title tab) plus a padding down. A fitted group
 687         // snaps to the area's edges one padding in, and its tab needs room inside
 688         // the area above its members — laid out flush with the corner, the row had
 689         // the frame's wall on its left edge and the tab over its labels.
 690         let (inset_x, inset_top) = match &self.roster {
 691             Roster::Gallery(s) => {
 692                 let g = self.ui_context[s.group_fitted].inner();
 693                 (2.0 * g.padding(), g.padding() + g.headroom())
 694             }
 695             _ => (0.0, 0.0),
 696         };
 697         let strategy = (LAYOUTS.get(self.layout_idx).unwrap_or(&LAYOUTS[DEFAULT_LAYOUT]).1)();
 698         let (lx, ly) = (floor.x + inset_x, floor.y + inset_top);
 699         let content_h = strategy.layout(lx, ly, floor.width - 2.0 * inset_x, floor.y + floor.height - ly, &children, &mut self.ui_context);
 700         self.exhibit_scroll.update_bounds(content_h + (ly - y), y, h);
 701         let scroll_y = self.exhibit_scroll.scroll_y;
 702         for (&child, &idx) in children.iter().zip(&indices) {
 703             let widget = unsafe { &mut *child };
 704             // The strategy placed each exhibit's content box (its label hanging in the
 705             // strip above); re-land it through `layout` shifted by the scroll — the
 706             // landed rect is the occupied one, so the content origin is `strip` below
 707             // its top and the content height is the rest.
 708             let (cx, cy, cw, ch) = widget.rect();
 709             let strip = widget.label_strip();
 710             let content = ch - strip;
 711             let cw = self.content_width(idx).unwrap_or(cw);
 712             widget.layout(
 713                 Point { x: cx, y: cy + strip - scroll_y },
 714                 LayoutConstraints::new(cw, cw, content, content),
 715                 &mut self.ui_context,
 716             );
 717         }
 718     }
 719 
 720     /// An exhibit's content width where it is narrower than the width it is laid out at
 721     /// (`Exhibit::content_w`; the named StatusDot slot likewise).
 722     fn content_width(&self, idx: usize) -> Option<f32> {
 723         if idx == 23 {
 724             return Some(StatusDot::SIZE);
 725         }
 726         idx.checked_sub(GALLERY_COUNT).and_then(|k| self.roster.gallery().extra[k].content_w)
 727     }
 728 
 729     /// Register every roster widget in the ui_context (idempotent — `register` is
 730     /// id-keyed and the boxed slots keep pointers stable). The id-rooted router
 731     /// resolves roots through this registry; the gallery's own paint loop never goes
 732     /// through `render_widget`, where other apps pick registration up as a side effect.
 733     fn register_roster(&mut self) {
 734     }
 735 
 736     /// Drain the header dropdowns' selections and apply them: Layout (15) picks the
 737     /// strategy, Style (34) switches every control between Relief and Flat
 738     /// (`set_control_relief` — the toolkit reads it live at paint, so a rebuild is
 739     /// all it takes). Called after mouse AND key input: a Dropdown selects from the
 740     /// keyboard too (Down, Enter), and a selection must not wait for the next click.
 741     fn apply_header_dropdowns(&mut self) -> bool {
 742         let mut applied = false;
 743         if self.roster.take_click(&mut self.ui_context, 15) {
 744             self.layout_idx = self.roster.value(&self.ui_context, 15) as usize;
 745             applied = true;
 746         }
 747         if self.roster.take_click(&mut self.ui_context, 34) {
 748             cce_ui::layout::set_control_relief(self.roster.value(&self.ui_context, 34) == 0);
 749             applied = true;
 750         }
 751         if applied {
 752             self.relayout();
 753             self.apply_layout();
 754         }
 755         applied
 756     }
 757 
 758     fn apply_layout(&mut self) {
 759         for i in 0..self.roster.len() {
 760             if self.roster.is_dragging(&self.ui_context, i) {
 761                 continue;
 762             }
 763             // The exhibits are laid out by layout_exhibits below.
 764             if !self.is_child && is_exhibit(i) {
 765                 continue;
 766             }
 767             let visible = self.is_widget_visible(i);
 768             let (x, y, w, h) = self.positions[i];
 769             if visible && (i == 15 || i == 34) {
 770                 // The header dropdowns land through `layout` at their own preferred
 771                 // height: `y` is where the label goes, the content sits a strip below.
 772                 self.ui_context.lend(self.roster.id(i), |widget, ctx| {
 773                     let h = widget.preferred_height().unwrap_or(h);
 774                     let strip = widget.label_strip();
 775                     widget.layout(Point { x, y: y + strip }, LayoutConstraints::new(w, w, h, h), ctx);
 776                 });
 777                 continue;
 778             }
 779             let widget = self.roster.get_dyn_mut(&mut self.ui_context, i);
 780             if visible {
 781                 widget.set_rect(x, y, w, h);
 782             } else {
 783                 widget.set_rect(-1000.0, -1000.0, 0.0, 0.0);
 784             }
 785         }
 786         if !self.is_child {
 787             self.layout_exhibits();
 788         }
 789     }
 790 
 791     fn update_status_text(&mut self, text: &str) {
 792         self.status_text = text.to_string();
 793     }
 794 }
 795 
 796 impl cce_ui::engine::Application for State {
 797     type Message = String;
 798 
 799     fn create(_sender: cce_ui::engine::AppSender<Self::Message>) -> Self {
 800         let args: Vec<String> = std::env::args().collect();
 801         let flag = |name: &str| args.iter().any(|a| a == name);
 802         let value = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned();
 803         let number = |name: &str| value(name).and_then(|s| s.parse::<f32>().ok());
 804 
 805         let is_child = flag("--child");
 806         let use_root_plate = if is_child { flag("--root-plate") } else { !flag("--no-root-plate") };
 807         let use_menubar = flag("--menubar");
 808         let use_statusbar = flag("--statusbar");
 809         let child_kind = if is_child {
 810             Some(value("--type").and_then(|t| ChildKind::from_arg(&t)).unwrap_or(ChildKind::Floating))
 811         } else {
 812             None
 813         };
 814         let opacity = flag("--opacity");
 815         let transparency = number("--transparency").unwrap_or(1.0);
 816         let border_enabled = !flag("--no-border");
 817         let custom_width = number("--width");
 818         let custom_height = number("--height");
 819 
 820         let (c_w, c_h): (f32, f32) = match (child_kind, custom_width, custom_height) {
 821             (_, Some(w), Some(h)) => (w, h),
 822             (Some(kind), _, _) => kind.default_size(),
 823             (None, _, _) => (1000.0, 680.0),
 824         };
 825 
 826         let opacity = opacity || child_kind.is_some_and(ChildKind::is_editor);
 827         let status_text = "Ready.".to_string();
 828 
 829         // The context owns the widgets; the roster names them by slot.
 830         let mut ui_context = cce_ui::context::UiContext::new();
 831         let ctx = &mut ui_context;
 832         let roster = if let Some(kind) = child_kind {
 833             let bg = if use_root_plate {
 834                 ctx.insert(RootPlate::new(0.0, 0.0, c_w, c_h)).id()
 835             } else {
 836                 ctx.insert(ContentBg::new()).id()
 837             };
 838             let (main, aux3, aux4) = if kind == ChildKind::ColorRamp {
 839                 (
 840                     ctx.insert(ColorRamp::new()).id(),
 841                     ctx.insert(Label::new("").with_font_size(12.0)).id(),
 842                     ctx.insert(Label::new("").with_font_size(12.0)).id(),
 843                 )
 844             } else if kind == ChildKind::Ramp {
 845                 (
 846                     ctx.insert({
 847                         let mut ramp = Ramp::new();
 848                         let (loaded_keys, loaded_type) = load_bevel_ramp();
 849                         ramp.keys = loaded_keys;
 850                         ramp.line_type_dropdown.selected = match loaded_type.as_str() {
 851                             "bezier" => 1,
 852                             _ => 0,
 853                         };
 854                         ramp
 855                     })
 856                     .id(),
 857                     ctx.insert(Label::new("").with_font_size(12.0)).id(),
 858                     ctx.insert(Label::new("").with_font_size(12.0)).id(),
 859                 )
 860             } else {
 861                 let menu_bar = MenuBar::new(0.0, 0.0, c_w, 40.0)
 862                     .with_item("File", &["New", "Open", "Save", "Exit"])
 863                     .with_item("Edit", &["Undo", "Redo", "Cut", "Copy", "Paste"])
 864                     .with_right_aligned_title(true);
 865                 (
 866                     ctx.insert(Label::new(kind.child_text()).with_font_size(12.0).with_color([0xcc, 0xcc, 0xd4])).id(),
 867                     ctx.insert(menu_bar).id(),
 868                     ctx.insert(StatusBar::new()).id(),
 869                 )
 870             };
 871             let close = ctx.insert(Button::new(0.0, 0.0, 100.0, cce_ui::layout::button_height()).with_label("Close"));
 872             Roster::Child(Box::new(ChildSlots { ids: [bg, main, close.id(), aux3, aux4], close }))
 873         } else {
 874             let menu_bar = MenuBar::new(0.0, 0.0, c_w, 40.0)
 875                 .with_item("File", &["Exit"])
 876                 .with_item("Edit", &["Settings"])
 877                 .with_item("Help", &["About"])
 878                 .with_right_aligned_title(true);
 879             Roster::Gallery(Box::new(GallerySlots {
 880                 menu_bar: ctx.insert(menu_bar),
 881                 status_bar: ctx.insert(StatusBar::new()),
 882                 button_demo: ctx.insert(Button::new(0.0, 0.0, 140.0, cce_ui::layout::button_height()).with_label("Button")),
 883                 checkbox_demo: ctx.insert(Checkbox::new().with_label("Checkbox")),
 884                 toggle_demo: ctx.insert(Toggle::new().with_label("Toggle")),
 885                 progress_demo: ctx.insert(ProgressBar::new(0.43).with_label("ProgressBar")),
 886                 slider_demo: ctx.insert(Slider::new().with_label("Slider")),
 887                 spinbox_demo: ctx.insert(Spinbox::new(10, 1, 100, 5).with_label("Spinbox")),
 888                 range_slider_demo: ctx.insert(RangeSlider::new().with_label("RangeSlider")),
 889                 trackpad_demo: ctx.insert(Trackpad::new().with_label("Trackpad")),
 890                 textbox_demo: ctx.insert(TextBox::new("Interactive TextBox".to_string())),
 891                 plate_demo: ctx.insert(Plate::new(0.0, 0.0, 120.0, 120.0, true).with_label("Plate")),
 892                 color_ramp_btn: ctx.insert(Button::new(0.0, 0.0, 120.0, cce_ui::layout::button_height()).with_label("Color Ramp...")),
 893                 bevel_ramp: ctx.insert(Ramp::new()),
 894                 ramp_btn: ctx.insert(Button::new(0.0, 0.0, 120.0, cce_ui::layout::button_height()).with_label("Ramp...")),
 895                 layout_dd: ctx.insert(Dropdown::new(
 896                     LAYOUTS.iter().map(|(name, _)| name.to_string()).collect(),
 897                     DEFAULT_LAYOUT,
 898                 ).with_label("Layout")),
 899                 style_dd: ctx.insert(Dropdown::new(
 900                     vec!["Relief".to_string(), "Flat".to_string()],
 901                     if cce_ui::layout::control_relief() { 0 } else { 1 },
 902                 ).with_label("Style")),
 903                 color_selector_demo: ctx.insert(ColorSelector::new([64, 128, 255]).with_label("ColorSelector")),
 904                 font_selector_demo: ctx.insert(FontSelector::new("Sans".to_string()).with_label("FontSelector")),
 905                 keybind_demo: ctx.insert(KeybindRecorder::new("ctrl+1".to_string()).with_label("KeybindRecorder")),
 906                 button_strip_demo: ctx.insert(Adapted::new(
 907                     ButtonStrip::new(0.0, 0.0, 200.0, cce_ui::layout::button_height())
 908                         .with_buttons(vec!["One".to_string(), "Two".to_string(), "Three".to_string()])
 909                         .with_selected(Some(0)),
 910                 ).with_label("ButtonStrip")),
 911                 slider2d_demo: ctx.insert(Slider2D::new().with_label("Slider2D")),
 912                 float3_demo: ctx.insert(Float3::new().with_label("Float3")),
 913                 usage_bar_demo: ctx.insert(UsageBar::new(0.62).with_label("UsageBar")),
 914                 status_dot_demo: ctx.insert(StatusDot::new(DotStatus::Active).with_label("StatusDot")),
 915                 info_box_demo: ctx.insert(InfoBox::new("InfoBox", vec!["A titled box of".to_string(), "plain text lines.".to_string()])),
 916                 list_item_demo: ctx.insert(InteractiveListItem::new("InteractiveListItem")),
 917                 breadcrumb_demo: {
 918                     let mut b = Breadcrumb::new();
 919                     b.path = vec!["home".to_string(), "lsgalante".to_string(), "projects".to_string()];
 920                     ctx.insert(b.with_label("Breadcrumb"))
 921                 },
 922                 tree_list_demo: {
 923                     let mut t = TreeList::new();
 924                     t.set_flat_keys(vec![
 925                         ("layout/bar_height".to_string(), serde_json::json!(24)),
 926                         ("layout/gap".to_string(), serde_json::json!(12)),
 927                         ("theme/name".to_string(), serde_json::json!("cce")),
 928                     ]);
 929                     t.rebuild_tree();
 930                     ctx.insert(t.with_label("TreeList"))
 931                 },
 932                 bevel_preview_demo: ctx.insert(BevelPreview::new().with_label("BevelPreview")),
 933                 ramp_preview_demo: ctx.insert(RampPreview::new().with_label("RampPreview")),
 934                 separator_demo: ctx.insert(Separator::new(0.0, 0.0, 200.0, 1.0, [0.5, 0.5, 0.6, 1.0]).with_label("Separator")),
 935                 splitter_demo: ctx.insert(Splitter::new(200.0).with_label("Splitter")),
 936                 // Members are wired below, once the slots have ids.
 937                 // style: deliberate — tight padding: the gallery packs its rows closer than a
 938                 // settings page, and the fitted lasso's padding is what insets the exhibits.
 939                 group_loose: ctx.insert(Group::new(Vec::new()).with_label("Group").with_padding(6.0)),
 940                 group_fitted: ctx.insert(Group::new(Vec::new()).with_label("Group (fitted)").with_fit(true).with_padding(6.0)),
 941                 extra: variant_exhibits(ctx),
 942             }))
 943         };
 944 
 945         let roster = {
 946             let mut roster = roster;
 947             if let Roster::Gallery(s) = &mut roster {
 948                 // The lassos: a loose one around the FontSelector and the StatusDot, and
 949                 // one around the top row (Button, ButtonStrip, Checkbox, Toggle) that
 950                 // fits the exhibit area's edges — its top snaps to the area's top with
 951                 // the title tab kept inside, its left to the area's left edge.
 952                 let ids = |s: &GallerySlots, idx: &[usize]| idx.iter().map(|&i| s.id(i)).collect::<Vec<_>>();
 953                 let loose = ids(s, &[17, 23]);
 954                 let fitted = ids(s, &[2, 19, 3, 4]);
 955                 ui_context[s.group_loose].inner_mut().set_members(loose);
 956                 ui_context[s.group_fitted].inner_mut().set_members(fitted);
 957             }
 958             roster
 959         };
 960         let mut state = Self {
 961             roster,
 962             positions: Vec::new(),
 963             status_text,
 964             focused_widget: None,
 965             width: c_w,
 966             height: c_h,
 967             scale: 1.0,
 968             is_child,
 969             opacity,
 970             transparency,
 971             use_root_plate,
 972             use_menubar,
 973             use_statusbar,
 974             border_enabled,
 975             child_kind,
 976             ui_context,
 977             layout_idx: DEFAULT_LAYOUT,
 978             exhibit_scroll: {
 979                 let mut sb = ScrollBox::new();
 980                 sb.show_border = false;
 981                 sb.show_background = false;
 982                 // The DE's one scrollbar: down the area's centre line, idling
 983                 // behind the well's floor until a scroll raises it.
 984                 sb.sink_behind = true;
 985                 sb
 986             },
 987         };
 988 
 989         let main = state.roster.get_dyn_mut(&mut state.ui_context, 1);
 990         if main.as_any_mut().downcast_mut::<Ramp>().is_some() && is_child {
 991             // Only a `--type Ramp` child hosts a Ramp in slot 1: it opens with the
 992             // preset dropdown focused so the keyboard drives it at once — the ramp
 993             // takes the window's focus and gives its first field, the preset
 994             // dropdown, the keyboard. Every other child kind (ColorRamp, or the
 995             // description Label of the Toplevel / Popup / Layer* windows) starts with
 996             // nothing focused — the key sweep in handle_key reaches every visible
 997             // child slot anyway, and a click focuses whatever it lands on. This used
 998             // to downcast unconditionally and panic for those kinds ("child ramp
 999             // widget"), which is why Create Window on the Windows page spawned
1000             // children that died at startup.
1001             state.focused_widget = Some(1);
1002             let ramp_id = state.roster.id(1);
1003             state.ui_context.set_focused_id(ramp_id);
1004         }
1005 
1006         state.relayout();
1007         state.apply_layout();
1008         state
1009     }
1010 
1011     /// The gallery navigates in plate terms: Tab walks the exhibits.
1012     fn plate_navigation(&self) -> bool {
1013         true
1014     }
1015 
1016     fn settings(&self) -> cce_ui::engine::WindowSettings {
1017         let title = match self.child_kind {
1018             Some(kind) => kind.title().to_string(),
1019             None => "Gallery".to_string(),
1020         };
1021 
1022         let mut app_id = match self.child_kind {
1023             Some(kind) => kind.app_id(),
1024             None => "cce-gallery".to_string(),
1025         };
1026         if !self.border_enabled {
1027             app_id.push_str("-noborder");
1028         }
1029 
1030         cce_ui::engine::WindowSettings {
1031             title,
1032             app_id,
1033             width: self.width as u32,
1034             height: self.height as u32,
1035             fullscreen: self.child_kind == Some(ChildKind::Fullscreen),
1036             min_size: if self.is_child {
1037                 Some((self.width as u32, self.height as u32))
1038             } else {
1039                 Some((100, 100))
1040             },
1041         }
1042     }
1043 
1044     fn layer(&self) -> Option<LayerSettings> {
1045         self.child_kind.and_then(|kind| kind.layer(self.height as i32))
1046     }
1047 
1048     fn utility(&self) -> bool {
1049         self.child_kind == Some(ChildKind::Utility)
1050     }
1051 
1052     fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool) {
1053         if msg == "exit" {
1054             *exit = true;
1055         } else {
1056             self.update_status_text(&msg);
1057             *needs_rebuild = true;
1058         }
1059     }
1060 
1061     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
1062         let mut changed = false;
1063         if hover_animation::tick(dt) {
1064             changed = true;
1065         }
1066         // True while the area's bar is held up or fading, as well as while a
1067         // glide moves it, so the frames keep coming until the sink renders.
1068         if !self.is_child && self.exhibit_scroll.tick(dt, &mut self.ui_context) {
1069             changed = true;
1070         }
1071         let vis = self.visibility();
1072         let is_visible = move |index: usize| vis.is_visible(index);
1073         for i in 0..self.roster.len() {
1074             if is_visible(i) {
1075                 let ramp_child = self.child_kind == Some(ChildKind::Ramp) && i == 1;
1076                 let ticked = self.ui_context.lend(self.roster.id(i), |w, ctx| {
1077                     let ticked = w.tick(dt, ctx);
1078                     if ticked && ramp_child {
1079                         if let Some(ramp) = w.as_any().downcast_ref::<Ramp>() {
1080                             let line_type_str = match ramp.line_type_dropdown.selected {
1081                                 1 => "bezier",
1082                                 _ => "linear",
1083                             };
1084                             save_bevel_ramp(&ramp.keys, line_type_str);
1085                         }
1086                     }
1087                     ticked
1088                 });
1089                 if ticked == Some(true) {
1090                     changed = true;
1091                 }
1092             }
1093         }
1094         if changed {
1095             *needs_rebuild = true;
1096         }
1097     }
1098 
1099     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<cce_ui::scene::paint::DisplayList> {
1100         // The whole frame — rounded geometry, plain geometry, popovers, then text — is
1101         // one display list, rebuilt each frame.
1102         use cce_ui::scene::layout::Rect;
1103         self.register_roster();
1104         // Panel children follow the scroll offset at layout time: re-arrange every frame
1105         // so a wheel scroll moves the content on the frame it repaints. Idempotent and
1106         // cheap (~20 set_rects).
1107         if !self.is_child {
1108             self.layout_exhibits();
1109         }
1110         if (self.width - size.width as f32).abs() > 0.001 || (self.height - size.height as f32).abs() > 0.001 || (self.scale - scale).abs() > 0.001 {
1111             self.width = size.width as f32;
1112             self.height = size.height as f32;
1113             self.scale = scale;
1114             cce_ui::scale::set_scale_factor(scale as f32);
1115 
1116             self.relayout();
1117             self.apply_layout();
1118             let text = self.status_text.clone();
1119             self.update_status_text(&text);
1120         }
1121 
1122         let sw = self.width;
1123         let sh = self.height;
1124         let mut pc = cce_ui::scene::paint::PaintCtx::new();
1125 
1126         // ── Rounded geometry ──
1127         if !self.is_child && self.use_root_plate {
1128             // The standard root plate (cce-ui PlateSpec::window).
1129             pc.root_plate(sw, sh);
1130         }
1131         // The exhibit area is a well in the root plate: its floor under the
1132         // exhibits here, its rim over them below (`well_rim`), so an exhibit
1133         // scrolled to the edge slides under the wall rather than sitting on it.
1134         // The area's scrollbar idles BEHIND the floor: its idle copy goes down
1135         // first, every frame and at full strength — raised or not, since the
1136         // fore copy fades in over it and dropping it at the latch would blink.
1137         if !self.is_child {
1138             self.exhibit_scroll.paint_scrollbar_pills(&mut pc, 1.0);
1139             let (well, radius, _) = self.exhibit_well();
1140             pc.well_floor(well, radius, &cce_ui::scene::Material::pane(), false);
1141         }
1142 
1143         // ── Geometry: each root widget's paint walk, everything but its text ──
1144         // A widget with a ui-tree parent (the page selector under the status bar) is
1145         // painted by that parent's walk. The text goes in the text pass below, which
1146         // culls it under open popovers and cuts it at the foot of the well's wall.
1147         // (Until 2026-10-08 this was the legacy tuple views — every rounded quad, then
1148         // every plain quad, then the other prims replayed — which put a widget's quads
1149         // over everything else it painted, whatever its own order.)
1150         for i in 0..self.roster.len() {
1151             let w = self.roster.get_dyn(&self.ui_context, i);
1152             if !self.is_widget_visible(i) || self.ui_context.tree.parent_id(w.base().id()).is_some_and(|p| self.ui_context.tree.is_registered(p)) {
1153                 continue;
1154             }
1155             let mut walk = cce_ui::scene::paint::PaintCtx::new();
1156             cce_ui::scene::painter::paint_root_into(&self.ui_context, w, &mut walk);
1157             let geometry = walk.finish().items.into_iter().filter(|it| !matches!(it.prim, Prim::Text { .. }));
1158             if !self.is_child && (is_exhibit(i) || is_overlay(i)) {
1159                 let (vx, vy, vw, vh) = self.exhibit_viewport();
1160                 pc.clip(Rect { x: vx, y: vy, width: vw, height: vh }, |pc| pc.append_items(geometry));
1161             } else {
1162                 pc.append_items(geometry);
1163             }
1164         }
1165         // The well's rim over the exhibits, then the scrollbar's FORE copy over
1166         // the rim, at the fade a scroll raised it to (nothing while it is sunk:
1167         // then only the idle copy under the floor shows). Flat pills both
1168         // times — shader-lit relief does not fade with a vertex alpha.
1169         if !self.is_child {
1170             let (well, radius, _) = self.exhibit_well();
1171             pc.well_rim(well, radius, cce_ui::layout::control_relief());
1172             self.exhibit_scroll.paint_scrollbar_pills(&mut pc, self.exhibit_scroll.scrollbar_fade());
1173         }
1174 
1175         // ── Popovers: in-frame, on top of everything. PaintCtx is a
1176         // RenderTarget — real prims (the dropdown's expanded inset-plate
1177         // surface) with per-label bounds; glyphs render in the later text pass
1178         // regardless of emission order. ──
1179         for i in 0..self.roster.len() {
1180             let w = self.roster.get_dyn(&self.ui_context, i);
1181             if !self.is_widget_visible(i) {
1182                 continue;
1183             }
1184             if w.popover_rect().is_some() {
1185                 w.render_popover(&mut pc);
1186             }
1187         }
1188 
1189         // ── Text ──
1190         let label_text = match self.child_kind {
1191             Some(kind) => kind.title().to_string(),
1192             None => "Gallery".to_string(),
1193         };
1194         let mut has_menu_bar = false;
1195         for i in 0..self.roster.len() {
1196             let w = self.roster.get_dyn_mut(&mut self.ui_context, i);
1197             if let Some(menu_bar) = w.as_any_mut().downcast_mut::<MenuBar>() {
1198                 menu_bar.title = label_text.clone();
1199                 has_menu_bar = true;
1200             }
1201         }
1202         if !has_menu_bar {
1203             // The title stands on the root plate: one inset in from the window's corner.
1204             let inset = cce_ui::layout::root_plate_inset();
1205             pc.text_with(label_text, inset, inset, CHILD_TITLE_SIZE, [255, 255, 255], Some(cce_ui::layout::statusbar_font()), None);
1206         }
1207 
1208         let mut popover_rects = Vec::new();
1209         for i in 0..self.roster.len() {
1210             let w = self.roster.get_dyn(&self.ui_context, i);
1211             if !self.is_widget_visible(i) {
1212                 continue;
1213             }
1214             if let Some(rect) = w.popover_rect() {
1215                 popover_rects.push(rect);
1216             }
1217         }
1218         let in_any_popover = |lx: f32, ly: f32| -> bool {
1219             for &(px, py, pw, ph) in &popover_rects {
1220                 if lx >= px - 5.0 && lx <= px + pw + 5.0 && ly >= py - 5.0 && ly <= py + ph + 5.0 {
1221                     return true;
1222                 }
1223             }
1224             false
1225         };
1226 
1227         for i in 0..self.roster.len() {
1228             let w = self.roster.get_dyn(&self.ui_context, i);
1229             if !self.is_widget_visible(i) {
1230                 continue;
1231             }
1232             // A widget with a ui-tree parent (the page selector under the status bar) is
1233             // covered by that parent's walk — emitting it here too would draw its text
1234             // twice. Text inside a popover is culled; panel children clip to the panel.
1235             if self.ui_context.tree.parent_id(w.base().id()).is_some_and(|p| self.ui_context.tree.is_registered(p)) {
1236                 continue;
1237             }
1238             // Text is cut at the foot of the wall: geometry slides under the rim,
1239             // words do not sit on it (the TreeList's rule).
1240             let cp_clip = if !self.is_child && (is_exhibit(i) || is_overlay(i)) {
1241                 let (f, _) = self.exhibit_floor();
1242                 let (vx, vy, vw, vh) = (f.x, f.y, f.width, f.height);
1243                 Some([vx, vy, vx + vw, vy + vh])
1244             } else {
1245                 None
1246             };
1247             let mut scratch = cce_ui::scene::paint::PaintCtx::new();
1248             cce_ui::scene::painter::append_widget_text(&self.ui_context, w, &mut scratch);
1249             for item in scratch.finish().items {
1250                 if let cce_ui::scene::paint::Prim::Text { text, x, y, font_size, color, font, bounds, .. } = item.prim {
1251                     if in_any_popover(x, y) {
1252                         continue;
1253                     }
1254                     let bounds = match (bounds, cp_clip) {
1255                         (Some([l, t, r, b]), Some([pl, pt, pr, pb])) => {
1256                             Some([l.max(pl), t.max(pt), r.min(pr), b.min(pb)])
1257                         }
1258                         (None, Some(clip)) => Some(clip),
1259                         (b, None) => b,
1260                     };
1261                     pc.text_with(text, x, y, font_size, color, font, bounds);
1262                 }
1263             }
1264         }
1265 
1266 
1267         // The status line.
1268         if !self.is_child {
1269             let (_, status_font_size) = cce_ui::layout::statusbar_font_parsed();
1270             let status_size = if status_font_size > 0.0 { status_font_size } else { 12.0 };
1271             let scol = cce_ui::color::root_plate_statusbar_text_color();
1272             // style: deliberate — 12px in from the bar's edge is the toolkit StatusBar's
1273             // own default text offset, so the line sits where a StatusBar's text would.
1274             pc.text_with(
1275                 self.status_text.clone(),
1276                 12.0,
1277                 self.height - 24.0,
1278                 status_size,
1279                 [
1280                     (scol[0] * 255.0) as u8,
1281                     (scol[1] * 255.0) as u8,
1282                     (scol[2] * 255.0) as u8,
1283                 ],
1284                 Some(cce_ui::layout::statusbar_font()),
1285                 None,
1286             );
1287         }
1288 
1289         Some(pc.finish())
1290     }
1291 
1292     fn display_list_text(&self) -> bool {
1293         true
1294     }
1295 
1296     fn clear_color(&self) -> [f32; 4] {
1297         let clear_alpha = if self.opacity || self.use_root_plate {
1298             if self.use_root_plate {
1299                 0.0
1300             } else {
1301                 self.transparency
1302             }
1303         } else {
1304             1.0
1305         };
1306         [0.05 * clear_alpha, 0.05 * clear_alpha, 0.08 * clear_alpha, clear_alpha]
1307     }
1308 
1309     fn desired_size(&self) -> Option<(u32, u32)> {
1310         Some((self.width as u32, self.height as u32))
1311     }
1312 
1313     fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
1314         Some(&self.ui_context)
1315     }
1316 
1317     fn ui_context_mut(&mut self) -> Option<&mut cce_ui::context::UiContext> {
1318         Some(&mut self.ui_context)
1319     }
1320 
1321     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
1322         let (lx, ly) = (pos.x as f32, pos.y as f32);
1323 
1324         // The shared context menu (any exhibit's) gets the pointer to itself
1325         // while open: its row highlight and slider rows.
1326         if cce_ui::widget::context_menu::is_visible() {
1327             if cce_ui::widget::context_menu::cursor_moved(lx, ly) {
1328                 *needs_rebuild = true;
1329             }
1330             return;
1331         }
1332 
1333         let mut changed = false;
1334         if !self.is_child && self.exhibit_scroll.cursor_moved(lx, ly, &mut self.ui_context)
1335         {
1336             changed = true;
1337         }
1338         // The router owns the drag lifecycle — one
1339         // PointerMove per visible root forwards DragUpdate to a live drag target and
1340         // runs hover bookkeeping otherwise.
1341         let mv = cce_ui::widget::Event::PointerMove { x: lx, y: ly, local_x: lx, local_y: ly };
1342         {
1343             let vis = self.visibility();
1344             let is_visible = move |index: usize| vis.is_visible(index);
1345             for i in 0..self.roster.len() {
1346                 if !is_visible(i) {
1347                     continue;
1348                 }
1349                 let root = self.roster.id(i);
1350                 if self.ui_context.propagate_event(&mv, root) {
1351                     changed = true;
1352                 }
1353             }
1354             if self.ui_context.is_dragging {
1355                 changed = true;
1356             }
1357         }
1358         if changed {
1359             *needs_rebuild = true;
1360         }
1361     }
1362 
1363     fn handle_mouse_input(&mut self, button: MouseButton, state: ElementState, pos: LogicalPosition, needs_rebuild: &mut bool) -> Option<Self::Message> {
1364         let (lx, ly) = (pos.x as f32, pos.y as f32);
1365 
1366         // The shared context menu a right-click on an exhibit opens takes every
1367         // press while open, ahead of the exhibit scrollbar: a row runs, a press
1368         // anywhere else dismisses it. A release it does not use (its slider
1369         // rows take theirs) still reaches the exhibits, whose drags end on it.
1370         // The toolkit leaves this routing to the app; without it the menu could
1371         // not be closed by clicking outside it, and its rows did nothing.
1372         if cce_ui::widget::context_menu::is_visible() {
1373             let used = cce_ui::widget::context_menu::mouse_input(button, state, lx, ly, Some(&mut self.ui_context));
1374             if used {
1375                 *needs_rebuild = true;
1376             }
1377             if used || state == ElementState::Pressed {
1378                 return None;
1379             }
1380         }
1381 
1382         let mut changed = false;
1383         let vis = self.visibility();
1384         let is_visible = move |index: usize| vis.is_visible(index);
1385 
1386         if state == ElementState::Pressed {
1387             let mut clicked_idx = None;
1388             // The exhibit area's scrollbar takes a press on its track before any
1389             // exhibit — while it is RAISED. A sunk bar is behind the well's floor
1390             // and is not hit, so a press on its lane falls through to the exhibit
1391             // under it (`ScrollBox::hit_test_scrollbar` gates on the latch).
1392             let exhibit_bar = !self.is_child && self.exhibit_scroll.mouse_input(button, state, lx, ly, &mut self.ui_context);
1393             if exhibit_bar {
1394                 changed = true;
1395             }
1396             if clicked_idx.is_none() && !exhibit_bar {
1397                 for i in (0..self.roster.len()).rev() {
1398                     if !is_visible(i) {
1399                         continue;
1400                     }
1401                     // An exhibit scrolled out of the viewport is not there to click.
1402                     if !self.is_child && is_exhibit(i) && !self.in_exhibit_viewport(lx, ly) {
1403                         continue;
1404                     }
1405                     if self.roster.get_dyn(&self.ui_context, i).hit_test(lx, ly, &self.ui_context) {
1406                         clicked_idx = Some(i);
1407                         break;
1408                     }
1409                 }
1410             }
1411             if button == MouseButton::Left {
1412                 if let Some(old) = self.focused_widget {
1413                     if Some(old) != clicked_idx {
1414                         self.ui_context.unfocus_id(self.roster.id(old));
1415                         self.focused_widget = None;
1416                     }
1417                 }
1418             }
1419             if let Some(i) = clicked_idx {
1420                 // The router records the drag target on a handled press and synthesizes
1421                 // DragStart past its threshold; a draggable slot whose press handler
1422                 // returned false is armed explicitly.
1423                 let ev = cce_ui::widget::Event::MouseButton { button, state, x: lx, y: ly, local_x: lx, local_y: ly };
1424                 let root = self.roster.id(i);
1425                 let press_handled = self.ui_context.propagate_event(&ev, root);
1426                 if press_handled {
1427                     changed = true;
1428                 }
1429                 if button == MouseButton::Left && !press_handled && self.roster.draggable(&self.ui_context, i) {
1430                     let id = self.roster.id(i);
1431                     self.ui_context.drag_target = Some(id);
1432                 }
1433                 if button == MouseButton::Left {
1434                     self.ui_context.focus_id(self.roster.id(i));
1435                     self.focused_widget = Some(i);
1436                 }
1437             }
1438         } else {
1439             // The router delivers DragEnd to the drag target on the first propagate call
1440             // of a release; every visible root then sees the release (commit contract).
1441             if self.ui_context.is_dragging {
1442                 changed = true;
1443             }
1444             let ev = cce_ui::widget::Event::MouseButton { button, state, x: lx, y: ly, local_x: lx, local_y: ly };
1445             if !self.is_child {
1446                 self.exhibit_scroll.mouse_input(button, state, lx, ly, &mut self.ui_context);
1447             }
1448             for i in 0..self.roster.len() {
1449                 if !is_visible(i) {
1450                     continue;
1451                 }
1452                 let root = self.roster.id(i);
1453                 if self.ui_context.propagate_event(&ev, root) {
1454                     changed = true;
1455                 }
1456             }
1457 
1458             if button == MouseButton::Left {
1459                 if self.is_child {
1460                     if self.roster.take_click(&mut self.ui_context, 2) {
1461                         return Some("exit".to_string());
1462                     }
1463                 } else {
1464                     if self.apply_header_dropdowns() {
1465                         changed = true;
1466                     } else if self.roster.take_click(&mut self.ui_context, 12) {
1467                         spawn_editor("ColorRamp");
1468                     } else if self.roster.take_click(&mut self.ui_context, 14) {
1469                         spawn_editor("Ramp");
1470                     } else {
1471                         for i in [2, 3, 4, 6, 7, 8, 9, 10, 11, 16, 17, 18, 19, 20, 21, 25, 26, 27, 28, 29] {
1472                             if self.roster.take_click(&mut self.ui_context, i) {
1473                                 changed = true;
1474                             }
1475                         }
1476                     }
1477                 }
1478             }
1479         }
1480         if changed {
1481             *needs_rebuild = true;
1482         }
1483         None
1484     }
1485 
1486     fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
1487         let (lx, ly) = (pos.x as f32, pos.y as f32);
1488         let mut changed = false;
1489         let vis = self.visibility();
1490         let is_visible = move |index: usize| vis.is_visible(index);
1491         let ev = cce_ui::widget::Event::MouseWheel { delta: *delta, x: lx, y: ly, local_x: lx, local_y: ly };
1492         // The control under the pointer gets the wheel first (wheel events are
1493         // hit-gated in the toolkit, so only it can take one): a slider, spinbox
1494         // or scrolling list adjusts itself and the page stays put. Only an
1495         // unclaimed wheel scrolls the exhibit area.
1496         let in_exhibits = !self.is_child && self.in_exhibit_viewport(lx, ly);
1497         let mut widget_took_wheel = false;
1498         for i in 0..self.roster.len() {
1499             if !is_visible(i) {
1500                 continue;
1501             }
1502             if !self.is_child && is_exhibit(i) && !in_exhibits {
1503                 continue;
1504             }
1505             let root = self.roster.id(i);
1506             if self.ui_context.propagate_event(&ev, root) {
1507                 widget_took_wheel = true;
1508                 changed = true;
1509             }
1510         }
1511         if !widget_took_wheel
1512             && !self.is_child
1513             && self.exhibit_scroll.mouse_wheel(delta, lx, ly, &mut self.ui_context)
1514         {
1515             changed = true;
1516         }
1517         if changed {
1518             *needs_rebuild = true;
1519         }
1520     }
1521 
1522     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
1523         let mut changed = false;
1524         let vis = self.visibility();
1525         let is_visible = move |index: usize| vis.is_visible(index);
1526         let mut handled = false;
1527         let key_ev = cce_ui::widget::Event::KeyInput(event.clone());
1528         if let Some(focused) = self.focused_widget {
1529             let root = self.roster.id(focused);
1530             if self.ui_context.propagate_event(&key_ev, root) {
1531                 changed = true;
1532                 handled = true;
1533             }
1534         }
1535 
1536         if !handled {
1537             for i in 0..self.roster.len() {
1538                 if Some(i) == self.focused_widget {
1539                     continue;
1540                 }
1541                 if !is_visible(i) {
1542                     continue;
1543                 }
1544                 // Panel children take keys only through the focused path above.
1545                 let root = self.roster.id(i);
1546                 if self.ui_context.propagate_event(&key_ev, root) {
1547                     changed = true;
1548                     // A consumed key is HANDLED, not just repaint-worthy: the
1549                     // Escape-quits-app fallback below is gated on !handled,
1550                     // and without this a dropdown that took Escape through
1551                     // this sweep closed its menu AND exited the app.
1552                     handled = true;
1553                     // And delivered ONCE: the context routes a key to the
1554                     // focused widget from ANY root, so without this break a
1555                     // Tab-focused slider took one Right press 58 times over —
1556                     // once per roster root.
1557                     break;
1558                 }
1559             }
1560         }
1561 
1562         if !handled && event.state == ElementState::Pressed && event.logical_key == Key::Named(NamedKey::Escape) {
1563             return Some("exit".to_string());
1564         }
1565 
1566         // A header dropdown driven by the keyboard selects on Enter: apply it now.
1567         if !self.is_child && self.apply_header_dropdowns() {
1568             changed = true;
1569         }
1570 
1571         if changed {
1572             *needs_rebuild = true;
1573         }
1574         None
1575     }
1576 
1577 }
1578 
1579 /// A `Command` that re-runs this binary as a child window; the caller adds the flags.
1580 fn child_command() -> Option<std::process::Command> {
1581     let exe = std::env::current_exe().ok()?;
1582     let mut cmd = std::process::Command::new(exe);
1583     cmd.arg("--child");
1584     Some(cmd)
1585 }
1586 
1587 /// Opens a Ramp or ColorRamp editor window. Untracked on purpose: an editor outlives
1588 /// the gallery, unlike the simulated windows Create Window spawns.
1589 fn spawn_editor(kind: &str) {
1590     if let Some(mut cmd) = child_command() {
1591         // The editor kinds' `default_size`, spelled out for the child's argv.
1592         cmd.args(["--type", kind, "--width", "450", "--height", "390", "--root-plate"]);
1593         let _ = cmd.spawn();
1594     }
1595 }
1596 
1597 /// The toolkit container layouts the Layout dropdown offers, by name.
1598 /// The strategies at the toolkit's own spacing (`layout::control_gap()`, every strategy's
1599 /// default gap), no padding — the exhibit viewport is already inset — so the page shows
1600 /// the rhythm a container gets by default.
1601 const LAYOUTS: [(&str, fn() -> Box<dyn cce_ui::widget::ContainerLayout>); 7] = [
1602     ("Vertical", || Box::new(VerticalLayout::default())),
1603     ("Columns", || Box::new(ColumnsLayout { padding_x: 0.0, padding_y: 0.0, ..ColumnsLayout::default() })),
1604     ("Grid", || Box::new(GridLayout { columns: 3, gap: cce_ui::layout::control_gap(), padding_x: 0.0, padding_y: 0.0 })),
1605     ("Adaptive Grid", || Box::new(AdaptiveGridLayout { min_col_width: 190.0, gap: cce_ui::layout::control_gap(), padding_x: 0.0, padding_y: 0.0 })),
1606     ("Mosaic", || Box::new(MosaicLayout { padding_x: 0.0, padding_y: 0.0, ..MosaicLayout::default() })),
1607     ("Reverse Mosaic", || Box::new(ReverseMosaicLayout { padding_x: 0.0, padding_y: 0.0, ..ReverseMosaicLayout::default() })),
1608     ("Overlay", || Box::new(OverlayLayout)),
1609 ];
1610 const DEFAULT_LAYOUT: usize = 4;
1611 
1612 /// The fixed positions: the chrome and the Layout dropdown. The exhibits are laid out
1613 /// by `layout_exhibits` instead.
1614 fn demo_positions(sw: f32, sh: f32, count: usize) -> Vec<(f32, f32, f32, f32)> {
1615     let mut vec = vec![(0.0, 0.0, 0.0, 0.0); count];
1616     vec[0] = (0.0, 0.0, sw, 40.0); // MenuBar
1617     vec[1] = (0.0, sh - 24.0, sw, 24.0); // StatusBar
1618     // The header dropdowns; the exhibits below them are laid out by
1619     // `layout_exhibits` with the strategy Layout selects. Their heights are the
1620     // widgets' own preferred heights (`apply_layout`); the ones here are placeholders.
1621     // Both stand on the root plate: one inset in from the window's side, one root
1622     // gap below the menu bar's band.
1623     let inset = cce_ui::layout::root_plate_inset();
1624     let gap = cce_ui::layout::root_plate_gap();
1625     let header_y = 40.0 + gap;
1626     vec[15] = (inset, header_y, 190.0, 0.0);
1627     // The Style dropdown, one root gap to its right.
1628     vec[34] = (inset + 190.0 + gap, header_y, 190.0, 0.0);
1629     vec
1630 }
1631 
1632 /// A child window's title, painted by `display_list` when it has no MenuBar to
1633 /// carry it; the description below leaves this line of headroom.
1634 const CHILD_TITLE_SIZE: f32 = 16.0;
1635 
1636 /// The five `ChildSlots` rects for a child window of `sw`x`sh`: 0 background,
1637 /// 1 main (editor or description), 2 Close, 3 and 4 the MenuBar / StatusBar of a
1638 /// simulated window or the two aux Labels of a Ramp / ColorRamp editor.
1639 /// Everything stands on the child's root plate: one root inset in from the
1640 /// window's edges, one root gap between the main slot and Close.
1641 fn child_positions(sw: f32, sh: f32, use_menubar: bool, use_statusbar: bool, editor: bool) -> Vec<(f32, f32, f32, f32)> {
1642     let dy = if use_menubar { 40.0 } else { 0.0 };
1643     let dh = if use_statusbar { 24.0 } else { 0.0 };
1644     let inner_h = sh - dy - dh;
1645     let inset = cce_ui::layout::root_plate_inset();
1646     let gap = cce_ui::layout::root_plate_gap();
1647     let (close_w, close_h) = (100.0, cce_ui::layout::button_height());
1648     let mut vec = vec![(-1000.0, -1000.0, 0.0, 0.0); CHILD_COUNT];
1649 
1650     if use_menubar {
1651         vec[3] = (0.0, 0.0, sw, 40.0);
1652     }
1653     if use_statusbar {
1654         vec[4] = (0.0, sh - 24.0, sw, 24.0);
1655     }
1656 
1657     vec[0] = (0.0, dy, sw, inner_h);
1658     // Close is centred, one inset up from the bottom edge; the main slot fills the
1659     // room above it, one root gap off, under the title line's headroom (kept whether
1660     // the title is painted or a MenuBar carries it).
1661     let close_y = dy + inner_h - inset - close_h;
1662     vec[2] = ((sw - close_w) / 2.0, close_y, close_w, close_h);
1663     let main_y = dy + inset + CHILD_TITLE_SIZE + gap;
1664     let main_h = close_y - gap - main_y;
1665     vec[1] = (inset, main_y, sw - 2.0 * inset, main_h);
1666     if editor {
1667         // TODO(style): the two aux labels' 20px row lies over the editor's bottom
1668         // strip, as it always has; that overlap is the editor's layout, not a rung.
1669         let label_y = main_y + main_h - 20.0;
1670         vec[3] = (inset, label_y, 200.0, 20.0);
1671         vec[4] = (sw - inset - 200.0, label_y, 200.0, 20.0);
1672     }
1673 
1674     vec
1675 }
1676 
1677 fn main() {
1678     cce_ui::engine::run::<State>();
1679 }