git.lucas.co / cce-cloud
cloud storage client
git clone https://git.lucas.co/cce-cloud.git

src/json_layout.rs (42.2K)

  1 //! App-owned copy of the dissolved cce-ui `JsonLayoutWidget` (Phase 6ay): cloud is the
  2 //! only consumer — the KDL/JSON-driven launcher layout host (`LauncherMode::Json`),
  3 //! on the narrow traits wrapped in `Adapted<JsonLayoutWidget>` (Phase 6az): the paint
  4 //! walk reaches it as the adapter, whose subtree text pass-through forwards `paint`'s
  5 //! prims verbatim. `Justification` stayed in cce-ui (Button, cce-files, settings).
  6 
  7 use cce_ui::widget::scroll_motion::{scroll_settings, Bounds, ScrollMotion, LINE_PX};
  8 use cce_ui::widget::{
  9     WidgetHost, Widget, Checkbox, Button, Label, Spinbox, ColorSelector, TextLabel, MouseButton, ElementState, Slider, Event, UiContext,
 10     Key, NamedKey, Justification,
 11 };
 12 use serde::Deserialize;
 13 
 14 /// style: deliberate — how far west of the content inset the panel's paint
 15 /// clip and text bounds begin, so a child drawn out to its own rect edge (a
 16 /// label's side bearing, a well's rim) is not cut at the inset. Slack, not a
 17 /// rung of the spacing ladder.
 18 const CLIP_SLACK: f32 = 4.0;
 19 
 20 /// A button's glyphs, by cce-ui's context-menu conventions, so a JSON menu
 21 /// shows its marks and page turns the way every other menu in the DE does:
 22 ///
 23 /// - a label that BEGINS with `MARK_CHECK` ("✓ "), `MARK_ON` ("● ") or
 24 ///   `MARK_OFF` ("○ ") wears the check, circle or circle-outline glyph at
 25 ///   its left, and the text is drawn without the mark;
 26 /// - a button with a `target_page` LOWER than its own page turns back, and
 27 ///   wears the chevron-left glyph at its left; one with any other target
 28 ///   leads to a page, and wears the chevron-right glyph at its right end.
 29 ///
 30 /// Returns `(left glyph, label without its mark, right glyph)`. Every
 31 /// argument is in the protocol already, so a layout written before the
 32 /// glyphs needs nothing new — though one that spelled its own marks ("< ",
 33 /// " >", "[x] ") would now show them twice, which is why the DE's scripts
 34 /// were rewritten with this.
 35 pub fn button_glyphs(
 36     text: &str,
 37     page_idx: usize,
 38     target_page: Option<usize>,
 39 ) -> (Option<&'static str>, &str, Option<&'static str>) {
 40     let (mark, text) = cce_ui::widget::context_menu::split_mark(text);
 41     match target_page {
 42         Some(t) if t < page_idx => (Some("chevron-left"), text, None),
 43         Some(_) => (mark, text, Some("chevron-right")),
 44         None => (mark, text, None),
 45     }
 46 }
 47 
 48 /// The side of a mark glyph, and the gap after it, at button font size
 49 /// `size` — cce-ui's context-menu proportions: a mark about as tall as a
 50 /// capital, a chevron smaller, since it points rather than labels.
 51 fn mark_side(size: f32) -> f32 {
 52     (size * 0.95).round()
 53 }
 54 fn chevron_side(size: f32) -> f32 {
 55     (size * 0.8).round()
 56 }
 57 const GLYPH_GAP: f32 = 6.0;
 58 /// Where a capital's middle stands in a line box, as a share of the font
 59 /// size from its top: what a glyph beside the text is centred on.
 60 const CAP_MIDDLE: f32 = 0.66;
 61 
 62 /// The room a button's glyphs take beside its text: the left column (on
 63 /// every button of a page where any button has a left glyph, so the labels
 64 /// share one edge) and the right chevron.
 65 pub fn glyph_room(lead_column: bool, trail: bool, size: f32) -> f32 {
 66     let lead = if lead_column { mark_side(size) + GLYPH_GAP } else { 0.0 };
 67     let trail = if trail { GLYPH_GAP + chevron_side(size) } else { 0.0 };
 68     lead + trail
 69 }
 70 
 71 /// The button font's size, which a glyph is sized from.
 72 pub fn button_font_size() -> f32 {
 73     cce_ui::layout::parse_font_string(&cce_ui::layout::button_font()).1.unwrap_or(12.0)
 74 }
 75 
 76 /// Whether a page's buttons reserve the left glyph column: any one of them
 77 /// has a left glyph.
 78 pub fn page_has_lead(widgets: &[JsonWidgetConfig], page_idx: usize) -> bool {
 79     widgets
 80         .iter()
 81         .any(|w| w.widget_type == "button" && button_glyphs(&w.text, page_idx, w.target_page).0.is_some())
 82 }
 83 
 84 #[derive(Deserialize, Debug, Clone)]
 85 pub struct JsonWidgetConfig {
 86     #[serde(rename = "type")]
 87     pub widget_type: String,
 88     pub text: String,
 89     pub id: Option<String>,
 90     pub checked: Option<bool>,
 91     pub value: Option<i32>,
 92     pub min: Option<i32>,
 93     pub max: Option<i32>,
 94     pub step: Option<i32>,
 95     pub decimals: Option<u32>,
 96     pub color: Option<[u8; 3]>,
 97     pub value_f32: Option<f32>,
 98     pub min_f32: Option<f32>,
 99     pub max_f32: Option<f32>,
100     pub target_page: Option<usize>,
101 }
102 
103 #[derive(Deserialize, Debug, Clone)]
104 pub struct JsonPageConfig {
105     pub title: String,
106     pub widgets: Vec<JsonWidgetConfig>,
107     pub justify: Option<Justification>,
108 }
109 
110 #[derive(Deserialize, Debug, Clone)]
111 pub struct JsonLayoutConfig {
112     pub width: Option<u32>,
113     pub height: Option<u32>,
114     pub widgets: Option<Vec<JsonWidgetConfig>>,
115     pub pages: Option<Vec<JsonPageConfig>>,
116     pub justify: Option<Justification>,
117 }
118 
119 /// The config-constructible controls, concretely typed (Phase 6bb part 3): this was the
120 /// last owned type-erased widget storage in the workspace. `as_dyn`/`as_dyn_mut` serve the
121 /// aggregation/routing paths that dispatch heterogeneously.
122 pub enum JsonControl {
123     Label(cce_ui::widget::Adapted<Label>),
124     Checkbox(cce_ui::widget::Adapted<Checkbox>),
125     Button(cce_ui::widget::Adapted<Button>),
126     Spinbox(cce_ui::widget::Adapted<Spinbox>),
127     Color(cce_ui::widget::Adapted<ColorSelector>),
128     Slider(cce_ui::widget::Adapted<Slider>),
129 }
130 
131 impl JsonControl {
132     pub fn as_dyn(&self) -> &(dyn WidgetHost + 'static) {
133         match self {
134             JsonControl::Label(w) => w,
135             JsonControl::Checkbox(w) => w,
136             JsonControl::Button(w) => w,
137             JsonControl::Spinbox(w) => w,
138             JsonControl::Color(w) => w,
139             JsonControl::Slider(w) => w,
140         }
141     }
142 
143     pub fn as_dyn_mut(&mut self) -> &mut (dyn WidgetHost + 'static) {
144         match self {
145             JsonControl::Label(w) => w,
146             JsonControl::Checkbox(w) => w,
147             JsonControl::Button(w) => w,
148             JsonControl::Spinbox(w) => w,
149             JsonControl::Color(w) => w,
150             JsonControl::Slider(w) => w,
151         }
152     }
153 
154     /// Drain the one-shot click flag (6bd value shrink — `take_click` left `WidgetHost`,
155     /// the drains are concrete `Adapted` methods). Only buttons carry one, and both call
156     /// sites already gate on the button widget type.
157     pub fn take_click(&mut self) -> bool {
158         match self {
159             JsonControl::Button(w) => w.take_click(),
160             _ => false,
161         }
162     }
163 }
164 
165 pub struct JsonWidget {
166     pub id: String,
167     pub widget_type: String,
168     pub text: String,
169     pub widget: JsonControl,
170     pub x: f32,
171     pub y: f32,
172     pub w: f32,
173     pub h: f32,
174     pub label_text: Option<TextLabel>,
175     pub page_idx: usize,
176     pub target_page: Option<usize>,
177     /// A button's glyphs (see [`button_glyphs`]); `text` is the label
178     /// without its mark.
179     pub lead: Option<&'static str>,
180     pub trail: Option<&'static str>,
181     /// Whether this button reserves the left glyph column ([`page_has_lead`]).
182     pub lead_column: bool,
183     pub justify: Justification,
184 }
185 
186 pub struct JsonLayoutWidget {
187     base: Widget,
188     pub widgets: Vec<JsonWidget>,
189     pub dragging_slider_idx: Option<usize>,
190     /// Per-page DRAWN scroll offset; `page_scroll` drives it.
191     pub page_scroll_y: Vec<f32>,
192     /// Per-page scroll motion: wheel notches glide, fingers track 1:1 and
193     /// fling on the lift, keyboard pages glide. `tick_scroll` carries the
194     /// drawn offset after it each frame.
195     pub page_scroll: Vec<ScrollMotion>,
196     pub page_total_heights: Vec<f32>,
197     pub active_page: usize,
198 }
199 
200 impl JsonLayoutWidget {
201     pub fn new(config: &JsonLayoutConfig) -> cce_ui::widget::Adapted<JsonLayoutWidget> {
202         let mut widgets = Vec::new();
203         let mut page_titles = Vec::new();
204 
205         if let Some(ref pages_conf) = config.pages {
206             for (page_idx, page) in pages_conf.iter().enumerate() {
207                 page_titles.push(page.title.clone());
208                 let page_justify = page.justify.unwrap_or(Justification::Center);
209                 let lead_column = page_has_lead(&page.widgets, page_idx);
210                 for (idx, w_conf) in page.widgets.iter().enumerate() {
211                     let id = w_conf.id.clone().unwrap_or_else(|| format!("widget_{}_{}", page_idx, idx));
212                     let widget_type = w_conf.widget_type.clone();
213                     let (lead, text, trail) = if widget_type == "button" {
214                         button_glyphs(&w_conf.text, page_idx, w_conf.target_page)
215                     } else {
216                         (None, w_conf.text.as_str(), None)
217                     };
218                     let text = text.to_string();
219 
220                     let widget: JsonControl = match widget_type.as_str() {
221                         "checkbox" => {
222                             let mut cb = Checkbox::new();
223                             if let Some(ch) = w_conf.checked {
224                                 cb.set_checked(ch);
225                             }
226                             JsonControl::Checkbox(cb)
227                         }
228                         "button" => {
229                             JsonControl::Button(Button::new_menu_item(0.0, 0.0, 0.0, 0.0)
230                                 .with_label(&text)
231                                 .with_justify(page_justify)
232                                 .with_bg([0.0, 0.0, 0.0, 0.0])
233                                 .with_hover_bg([0.20, 0.35, 0.65, 0.9]))
234                         }
235                         "label" => {
236                             JsonControl::Label(Label::new(&text).with_font_size(13.0).with_color([0xcc, 0xcc, 0xd4]))
237                         }
238                         "spinbox" => {
239                             let min_val = w_conf.min.unwrap_or(0);
240                             let max_val = w_conf.max.unwrap_or(100);
241                             let step_val = w_conf.step.unwrap_or(1);
242                             let mut sb = Spinbox::new(w_conf.value.unwrap_or(0), min_val, max_val, step_val)
243                                 .with_label(&text);
244                             if let Some(dec) = w_conf.decimals {
245                                 sb = sb.with_decimals(dec);
246                             }
247                             JsonControl::Spinbox(sb)
248                         }
249                         "color" | "rgb" | "rgba" => {
250                             let col = w_conf.color.unwrap_or([255, 255, 255]);
251                             let cs = ColorSelector::new(col).with_label(&text);
252                             JsonControl::Color(cs)
253                         }
254                         "slider" => {
255                             let min_val = w_conf.min_f32.unwrap_or(0.0);
256                             let max_val = w_conf.max_f32.unwrap_or(1.0);
257                             let mut sl = Slider::new()
258                                 .with_range(min_val, max_val)
259                                 .with_label(&text)
260                                 .with_readout(true);
261                             if let Some(val) = w_conf.value_f32 {
262                                 let pct = if max_val > min_val { (val - min_val) / (max_val - min_val) } else { 0.0 };
263                                 sl = sl.with_value(pct);
264                             }
265                             JsonControl::Slider(sl)
266                         }
267                         _ => JsonControl::Button(Button::new(0.0, 0.0, 0.0, 0.0)),
268                     };
269 
270                     widgets.push(JsonWidget {
271                         id,
272                         widget_type,
273                         text,
274                         widget,
275                         x: 0.0,
276                         y: 0.0,
277                         w: 0.0,
278                         h: 0.0,
279                         label_text: None,
280                         page_idx,
281                         target_page: w_conf.target_page,
282                         lead,
283                         trail,
284                         lead_column: lead_column && w_conf.widget_type == "button",
285                         justify: page_justify,
286                     });
287                 }
288             }
289         } else if let Some(ref widgets_conf) = config.widgets {
290             let global_justify = config.justify.unwrap_or(Justification::Center);
291             let lead_column = page_has_lead(widgets_conf, 0);
292             for (idx, w_conf) in widgets_conf.iter().enumerate() {
293                 let id = w_conf.id.clone().unwrap_or_else(|| format!("widget_{}", idx));
294                 let widget_type = w_conf.widget_type.clone();
295                 let (lead, text, trail) = if widget_type == "button" {
296                     button_glyphs(&w_conf.text, 0, w_conf.target_page)
297                 } else {
298                     (None, w_conf.text.as_str(), None)
299                 };
300                 let text = text.to_string();
301 
302                 let widget: JsonControl = match widget_type.as_str() {
303                     "checkbox" => {
304                         let mut cb = Checkbox::new();
305                         if let Some(ch) = w_conf.checked {
306                             cb.set_checked(ch);
307                         }
308                         JsonControl::Checkbox(cb)
309                     }
310                     "button" => {
311                         JsonControl::Button(Button::new_menu_item(0.0, 0.0, 0.0, 0.0)
312                             .with_label(&text)
313                             .with_justify(global_justify)
314                             .with_bg([0.0, 0.0, 0.0, 0.0])
315                             .with_hover_bg([0.20, 0.35, 0.65, 0.9]))
316                     }
317                     "label" => {
318                         JsonControl::Label(Label::new(&text).with_font_size(13.0).with_color([0xcc, 0xcc, 0xd4]))
319                     }
320                     "spinbox" => {
321                         let min_val = w_conf.min.unwrap_or(0);
322                         let max_val = w_conf.max.unwrap_or(100);
323                         let step_val = w_conf.step.unwrap_or(1);
324                         let mut sb = Spinbox::new(w_conf.value.unwrap_or(0), min_val, max_val, step_val)
325                             .with_label(&text);
326                         if let Some(dec) = w_conf.decimals {
327                             sb = sb.with_decimals(dec);
328                         }
329                         JsonControl::Spinbox(sb)
330                     }
331                     "color" | "rgb" | "rgba" => {
332                         let col = w_conf.color.unwrap_or([255, 255, 255]);
333                         let cs = ColorSelector::new(col).with_label(&text);
334                         JsonControl::Color(cs)
335                     }
336                     "slider" => {
337                         let min_val = w_conf.min_f32.unwrap_or(0.0);
338                         let max_val = w_conf.max_f32.unwrap_or(1.0);
339                         let mut sl = Slider::new()
340                             .with_range(min_val, max_val)
341                             .with_label(&text)
342                             .with_readout(true);
343                         if let Some(val) = w_conf.value_f32 {
344                             let pct = if max_val > min_val { (val - min_val) / (max_val - min_val) } else { 0.0 };
345                             sl = sl.with_value(pct);
346                         }
347                         JsonControl::Slider(sl)
348                     }
349                     _ => JsonControl::Button(Button::new(0.0, 0.0, 0.0, 0.0)),
350                 };
351 
352                 widgets.push(JsonWidget {
353                     id,
354                     widget_type,
355                     text,
356                     widget,
357                     x: 0.0,
358                     y: 0.0,
359                     w: 0.0,
360                     h: 0.0,
361                     label_text: None,
362                     page_idx: 0,
363                     target_page: w_conf.target_page,
364                     lead,
365                     trail,
366                     lead_column: lead_column && w_conf.widget_type == "button",
367                     justify: global_justify,
368                 });
369             }
370         }
371 
372         cce_ui::widget::Adapted::new(Self {
373             base: Widget::new(),
374             widgets,
375             dragging_slider_idx: None,
376             page_scroll_y: vec![0.0; 16],
377             page_scroll: vec![ScrollMotion::new(); 16],
378             page_total_heights: vec![0.0; 16],
379             active_page: 0,
380         })
381     }
382 
383     pub fn layout_children(&mut self) {
384         let (bx, by, bw, _) = self.rect();
385 
386         // The widgets stand straight on the popup's root plate: the root-plate
387         // inset from its edge, the root-plate gap between them.
388         let pad_x = cce_ui::layout::root_plate_inset();
389         let usable_w = bw - 2.0 * pad_x;
390 
391         let mut page_current_y = vec![pad_x; 16]; // support up to 16 pages
392         let spacing = cce_ui::layout::root_plate_gap();
393 
394         for i in 0..self.widgets.len() {
395             let p_idx = self.widgets[i].page_idx;
396             if p_idx >= page_current_y.len() {
397                 continue;
398             }
399             // Menu rows in one run share a single recess (see `paint`), so
400             // they butt together inside it; the spacing returns at the run's
401             // end, where the well ends too.
402             let run_continues = self.widgets[i].widget_type == "button"
403                 && self
404                     .widgets
405                     .get(i + 1)
406                     .map(|n| n.page_idx == p_idx && n.widget_type == "button")
407                     .unwrap_or(false);
408             let w_state = &mut self.widgets[i];
409             let current_y = &mut page_current_y[p_idx];
410             w_state.x = bx + pad_x;
411 
412             let top_room = w_state.widget.as_dyn().label_strip();
413 
414             let scroll_offset = self.page_scroll_y.get(p_idx).cloned().unwrap_or(0.0);
415             w_state.y = by + *current_y - scroll_offset;
416             w_state.w = usable_w;
417 
418             if w_state.widget_type == "checkbox" {
419                 // set_rect is an WidgetHost method; call it on the box directly (the Phase 5
420                 // Checkbox is an Adapted widget — as_any downcasts reach the model, not WidgetHost).
421                 // The toolkit's checkbox row: toggle height, the box square
422                 // at it (as a labelled `Checkbox::field` draws it).
423                 let h = cce_ui::layout::toggle_height();
424                 w_state.widget.as_dyn_mut().set_rect(w_state.x, w_state.y, h, h);
425                 w_state.h = h;
426                 w_state.label_text = Some(TextLabel {
427                     text: w_state.text.clone(),
428                     x: w_state.x + h + 10.0,
429                     y: w_state.y + (h - 18.0) / 2.0,
430                     font_size: 13.0,
431                     color: [0xcc, 0xcc, 0xd4],
432                 });
433             } else {
434                 let h = match w_state.widget_type.as_str() {
435                     // Menu-styled rows (`new_menu_item`): the context menu's pitch.
436                     "button" => cce_ui::widget::context_menu::ROW_H + top_room,
437                     "label" => 18.0 + top_room,
438                     "spinbox" => cce_ui::layout::spinbox_height() + top_room,
439                     "color" | "rgb" | "rgba" => cce_ui::layout::color_selector_height() + top_room,
440                     "slider" => 22.0 + top_room,
441                     // An unknown type is built as a bare Button.
442                     _ => cce_ui::layout::button_height(),
443                 };
444                 w_state.widget.as_dyn_mut().set_rect(w_state.x, w_state.y, usable_w, h);
445                 w_state.h = h;
446             }
447 
448             *current_y += w_state.h + if run_continues { 0.0 } else { spacing };
449         }
450 
451         // Store total height of each page: the walk left a gap after the last
452         // widget, and what stands below it is the inset, not a gap.
453         for (i, &height) in page_current_y.iter().enumerate() {
454             if i < self.page_total_heights.len() {
455                 self.page_total_heights[i] = (height - spacing).max(pad_x) + pad_x;
456             }
457         }
458     }
459 }
460 
461 impl JsonLayoutWidget {
462     /// The laid-out rect, mirrored from the adapter by `Layout::rect_assigned`.
463     fn rect(&self) -> (f32, f32, f32, f32) {
464         (self.base.x, self.base.y, self.base.w, self.base.h)
465     }
466 
467     /// The active page's wheel range: `0..=overflow`.
468     fn page_scroll_bounds(&self, page: usize) -> Bounds {
469         let (_, _, _, bh) = self.rect();
470         let total = self.page_total_heights.get(page).copied().unwrap_or(0.0);
471         Bounds::max(total - bh)
472     }
473 
474     /// Copy a page's motion position into its drawn offset; true if it moved
475     /// (the caller re-lays the children out).
476     fn sync_page_scroll(&mut self, page: usize) -> bool {
477         let pos = self.page_scroll[page].y.pos();
478         let moved = (pos - self.page_scroll_y[page]).abs() > 1e-4;
479         self.page_scroll_y[page] = pos;
480         moved
481     }
482 
483     /// Per-frame glide/coast of the active page's scroll. Reached from
484     /// `tick_children`, which the main loop calls (through `Adapted::tick`)
485     /// beside the launcher list's own `ScrollRegion::tick`. True while the
486     /// offset is still moving, so the demand-driven frame loop keeps drawing.
487     fn tick_scroll(&mut self, dt: f32) -> bool {
488         let page = self.active_page;
489         if page >= self.page_scroll.len() || page >= self.page_scroll_y.len() {
490             return false;
491         }
492         let host = self.page_scroll_y[page];
493         self.page_scroll[page].reconcile(0.0, host);
494         if !self.page_scroll[page].is_animating() {
495             return false;
496         }
497         let by = self.page_scroll_bounds(page);
498         let moved = self.page_scroll[page].tick(dt, Bounds::max(0.0), by);
499         if self.sync_page_scroll(page) {
500             self.layout_children();
501         }
502         moved || self.page_scroll[page].is_animating()
503     }
504 
505     /// Per-frame child state (the old `WidgetHost::tick` override): active-page widgets only.
506     fn tick_children(&mut self, dt: f32, ctx: &mut UiContext) -> bool {
507         let mut changed = self.tick_scroll(dt);
508         let active_page = self.active_page;
509         for w in &mut self.widgets {
510             if w.page_idx != active_page {
511                 continue;
512             }
513             if w.widget.as_dyn_mut().tick(dt, ctx) {
514                 changed = true;
515             }
516         }
517         changed
518     }
519 
520     /// The whole-subtree event routing (the old `WidgetHost::handle_event` override,
521     /// verbatim). Every press reaches it (`Input::gates_presses` is off), matching the
522     /// ungated legacy direct-dispatch path — the trailing focus-clear on a missed press
523     /// depends on that.
524     fn route_event(&mut self, event: &Event, ctx: &mut UiContext) -> bool {
525         // MouseEnter targets THIS container (the adapter's base-hover bookkeeping
526         // synthesizes it when a move goes unconsumed). Broadcasting it to every
527         // child marks them all hovered — Button's on_event trusts the router
528         // contract that Enter only reaches the widget under the cursor (the
529         // desktop-menu every-button-lit bug). The next PointerMove re-derives
530         // child hover, so dropping it loses nothing. MouseLeave still broadcasts
531         // below: clearing every child's hover is exactly what leaving the panel
532         // means.
533         if matches!(event, Event::MouseEnter) {
534             return false;
535         }
536         let mut changed = false;
537 
538         match event {
539             Event::PointerMove { x, y, .. } => {
540                 if let Some(idx) = self.dragging_slider_idx {
541                     if let Some(w) = self.widgets.get_mut(idx) {
542                         // Drag* events map onto the Input drag hooks in handle_event (6bd
543                         // collapse); the ctx is unused on that path.
544                         let mut dummy = cce_ui::context::UiContext::new();
545                         let ev = Event::DragUpdate { dx: 0.0, dy: 0.0, x: *x, y: *y, local_x: *x, local_y: *y };
546                         if w.widget.as_dyn_mut().handle_event(&ev, &mut dummy) {
547                             changed = true;
548                         }
549                     }
550                 }
551             }
552             Event::MouseButton { button, state, x: _, y: _, .. } => {
553                 if *button == MouseButton::Left && *state == ElementState::Released {
554                     if let Some(idx) = self.dragging_slider_idx {
555                         if let Some(w) = self.widgets.get_mut(idx) {
556                             w.widget.as_dyn_mut().handle_event(event, ctx);
557                             changed = true;
558                         }
559                         self.dragging_slider_idx = None;
560                     }
561                 }
562             }
563             _ => {}
564         }
565 
566         let active_page = self.active_page;
567         let mut page_switch = None;
568         for (idx, w) in self.widgets.iter_mut().enumerate() {
569             if w.page_idx != active_page {
570                 continue;
571             }
572 
573             if let Event::MouseButton { button, state, x, y, .. } = event {
574                 if *button == MouseButton::Left && *state == ElementState::Pressed {
575                     let hit = *x >= w.x && *x <= w.x + w.w && *y >= w.y && *y <= w.y + w.h;
576                     if hit && w.widget_type == "slider" {
577                         self.dragging_slider_idx = Some(idx);
578                     }
579                 }
580             }
581 
582             if w.widget_type == "checkbox" {
583                 match event {
584                     Event::PointerMove { x, y, .. } => {
585                         if let Some(cb) = w.widget.as_dyn_mut().as_any_mut().downcast_mut::<Checkbox>() {
586                             let was = cb.hovered();
587                             let hit = *x >= w.x && *x <= w.x + w.w && *y >= w.y && *y <= w.y + w.h;
588                             cb.set_hovered(hit);
589                             if was != hit {
590                                 changed = true;
591                             }
592                         }
593                     }
594                     Event::MouseButton { button, state, x, y, .. } => {
595                         if *button == MouseButton::Left {
596                             let hit = *x >= w.x && *x <= w.x + w.w && *y >= w.y && *y <= w.y + w.h;
597                             if hit {
598                                 if *state == ElementState::Pressed {
599                                     changed = true;
600                                 } else if *state == ElementState::Released {
601                                     if let Some(cb) = w.widget.as_dyn_mut().as_any_mut().downcast_mut::<Checkbox>() {
602                                         let new_checked = !cb.checked();
603                                         cb.set_checked(new_checked);
604                                     }
605                                     changed = true;
606                                 }
607                             }
608                         }
609                     }
610                     _ => {}
611                 }
612             } else {
613                 if w.widget.as_dyn_mut().handle_event(event, ctx) {
614                     changed = true;
615                 }
616                 if w.widget_type == "button" {
617                     // take_click is an WidgetHost method; the Phase 5 Button is Adapted, so call it
618                     // on the box directly rather than through a concrete downcast.
619                     if w.target_page.is_some() && w.widget.take_click() {
620                         page_switch = Some(w.target_page.unwrap());
621                     }
622                 }
623             }
624         }
625 
626         if let Some(target) = page_switch {
627             self.active_page = target;
628             self.layout_children();
629             changed = true;
630         }
631 
632         if let Event::MouseWheel { delta, x, y, .. } = event {
633             let (bx, by, bw, bh) = self.rect();
634             if *x >= bx && *x <= bx + bw && *y >= by && *y <= by + bh {
635                 if active_page < self.page_total_heights.len() {
636                     let total_height = self.page_total_heights[active_page];
637                     let visible_h = bh;
638                     let max_scroll_y = (total_height - visible_h).max(0.0);
639                     if max_scroll_y > 0.0 {
640                         // A notch is LINE_PX, pixels are 1:1; the motion
641                         // glides or tracks and `tick_scroll` carries the drawn
642                         // offset after it. A true return is the repaint signal
643                         // (the target moved even if the offset has not yet).
644                         let motion = &mut self.page_scroll[active_page];
645                         motion.reconcile(0.0, self.page_scroll_y[active_page]);
646                         if motion.apply(delta, (LINE_PX, LINE_PX), Bounds::max(0.0), Bounds::max(max_scroll_y)) {
647                             changed = true;
648                         }
649                         if self.sync_page_scroll(active_page) {
650                             self.layout_children();
651                         }
652                     }
653                 }
654             }
655         }
656 
657         if let Event::KeyInput(key_event) = event {
658             if key_event.state == ElementState::Pressed {
659                 if active_page < self.page_total_heights.len() {
660                     let (_, _, _, bh) = self.rect();
661                     let by = self.page_scroll_bounds(active_page);
662                     if by.hi > 0.0 {
663                         // Pages and Home/End glide to their target; the arrows
664                         // step a line and accumulate like wheel notches (the
665                         // toolkit ScrollRegion's keyboard contract).
666                         let s = scroll_settings();
667                         let motion = &mut self.page_scroll[active_page];
668                         motion.reconcile(0.0, self.page_scroll_y[active_page]);
669                         let target = motion.y.target();
670                         let moved = match &key_event.logical_key {
671                             Key::Named(NamedKey::PageDown) => motion.y.scroll_to(target + bh, by, &s),
672                             Key::Named(NamedKey::PageUp) => motion.y.scroll_to(target - bh, by, &s),
673                             Key::Named(NamedKey::Home) => motion.y.scroll_to(0.0, by, &s),
674                             Key::Named(NamedKey::End) => motion.y.scroll_to(by.hi, by, &s),
675                             Key::Named(NamedKey::ArrowDown) => motion.y.wheel(LINE_PX, by, &s),
676                             Key::Named(NamedKey::ArrowUp) => motion.y.wheel(-LINE_PX, by, &s),
677                             _ => false,
678                         };
679                         if moved {
680                             changed = true;
681                         }
682                         if self.sync_page_scroll(active_page) {
683                             self.layout_children();
684                         }
685                     }
686                 }
687             }
688         }
689 
690         if let Event::Tick(dt) = event {
691             for w in &mut self.widgets {
692                 if w.page_idx != active_page {
693                     continue;
694                 }
695                 if w.widget.as_dyn_mut().tick(*dt, ctx) {
696                     changed = true;
697                 }
698             }
699         }
700 
701         if let Event::MouseButton { state, .. } = event {
702             if *state == ElementState::Pressed && !changed {
703                 ctx.clear_focus();
704             }
705         }
706 
707         changed
708     }
709 }
710 
711 impl cce_ui::widget::Layout for JsonLayoutWidget {
712     // The old `set_rect` override: land the rect in the model, then place the children.
713     fn rect_assigned(&mut self, rect: cce_ui::scene::layout::Rect) {
714         self.base.x = rect.x;
715         self.base.y = rect.y;
716         self.base.w = rect.width;
717         self.base.h = rect.height;
718         self.layout_children();
719     }
720 }
721 
722 impl cce_ui::widget::Paint for JsonLayoutWidget {
723     fn color(&self) -> [f32; 4] { [0.0, 0.0, 0.0, 0.0] }
724 
725     // JsonLayout paints its whole subtree: page-filtered aggregates plus the checkbox
726     // side-labels that belong to the container, not to any child widget. The paint walk
727     // emits these once and does not descend (descending would draw inactive pages and
728     // miss the side-labels); the adapter forwards the Text prims verbatim, bounds included.
729     fn paints_own_subtree(&self) -> bool { true }
730 
731     fn paint(&self, _rect: cce_ui::scene::layout::Rect, pc: &mut cce_ui::scene::paint::PaintCtx) {
732         use cce_ui::scene::layout::Rect;
733         use cce_ui::scene::paint::{PaintCtx, Prim};
734         // The children are Phase 5 Adapted leaves: nothing their all_* getters or the
735         // label walk reads comes from the routing context, so a fresh one stands in for
736         // the ctx `Paint::paint` does not carry.
737         let dummy = UiContext::new();
738         // Children through the real paint walk, geometry only: bevel/recess/plate prims
739         // survive to the tessellator where the old quad bridges flattened them to fills.
740         // Text prims are skipped — every label, container-owned and child-owned alike,
741         // is served by `labels_and_glyphs` below, and emitting the children's own
742         // labels here as well would double them. Page filtering and the panel clip
743         // mirror the dissolved `aggregate_quads` bounds.
744         let (bx, by, bw, bh) = self.rect();
745         let pad_x = cce_ui::layout::root_plate_inset();
746         // style: deliberate — the clip starts CLIP_SLACK west of the content
747         // edge so a child painting out to its rect edge is not cut there.
748         let clip = Rect { x: bx + pad_x - CLIP_SLACK, y: by, width: bw - (pad_x - CLIP_SLACK), height: bh };
749 
750         // One recess per run of adjacent menu rows, drawn BEFORE the rows so
751         // they sit inside it. A menu row draws no plate and no border of its
752         // own (ButtonKind::MenuItem) — the group is the carved thing, and the
753         // rows butt against each other within it, which is why the run's
754         // bounding box is a single continuous well rather than one per item.
755         let radius = cce_ui::layout::button_corner_radius();
756         let face = cce_ui::colors::button_background_color();
757         let mut seams: Vec<(Rect, f32)> = Vec::new();
758         let mut i = 0;
759         while i < self.widgets.len() {
760             let w = &self.widgets[i];
761             if w.page_idx != self.active_page || w.widget_type != "button" {
762                 i += 1;
763                 continue;
764             }
765             let start = i;
766             let mut end = i;
767             while let Some(n) = self.widgets.get(end + 1) {
768                 if n.page_idx == self.active_page && n.widget_type == "button" {
769                     end += 1;
770                 } else {
771                     break;
772                 }
773             }
774             let (first, last) = (&self.widgets[start], &self.widgets[end]);
775             let run = Rect {
776                 x: first.x,
777                 y: first.y,
778                 width: first.w,
779                 height: last.y + last.h - first.y,
780             };
781             // Depth from ONE row's height, not the run's: the groove has to
782             // read the same as every other carved control in the DE, and a
783             // tall run would otherwise cut a far deeper channel.
784             let depth = cce_ui::layout::bevel_width().min(first.h * 0.2);
785             pc.clip(clip, |pc| {
786                 pc.inset_plate(run, (radius, radius, radius, radius), cce_ui::scene::Material::face(face).as_ref(), depth);
787             });
788             // Seams are collected, not drawn yet: they are pure shading and
789             // must land ON TOP of the rows. A hovered row fills its whole
790             // rect, and the groove straddles the boundary between two rows —
791             // drawn underneath, the hover would erase half of each groove it
792             // touches.
793             //
794             // Half depth so the two walls MEET at the boundary rather than
795             // leaving flat floor between them: at full depth the seam reads as
796             // two separate hairlines ~9px apart instead of one groove, against
797             // the well's own ring which measures a 4px dark-to-light V.
798             let seam_d = depth * 0.5;
799             for k in start..end {
800                 let seam = self.widgets[k].y + self.widgets[k].h;
801                 seams.push((
802                     Rect { x: run.x, y: seam - seam_d, width: run.width, height: 2.0 * seam_d },
803                     seam_d,
804                 ));
805             }
806             i = end + 1;
807         }
808 
809         for w in &self.widgets {
810             if w.page_idx != self.active_page {
811                 continue;
812             }
813             let mut tmp = PaintCtx::new();
814             w.widget.as_dyn().paint_self(&dummy, &mut tmp);
815             pc.clip(clip, |pc| {
816                 for item in tmp.finish().items {
817                     let clip_circle = item.clip_circle;
818                     if let Some(c) = clip_circle {
819                         pc.push_clip_circle(c);
820                     }
821                     // One forwarding match, in cce-ui: `PaintCtx::replay` emits
822                     // every prim but Text and returns Text for the caller to
823                     // decide. Here the widget's own label bridge supplies the
824                     // text, so the returned prim is dropped.
825                     let _ = pc.replay(item.prim);
826                     if clip_circle.is_some() {
827                         pc.pop_clip_circle();
828                     }
829                 }
830             });
831         }
832         // Seam grooves last: pure shading, composed over the rows so a hovered
833         // row's fill cannot erase the grooves it straddles.
834         for (rect, seam_d) in seams {
835             pc.clip(clip, |pc| {
836                 pc.recess_edges(rect, (0.0, 0.0, 0.0, 0.0), seam_d, (true, false, true, false));
837             });
838         }
839         let (labels, glyphs) = self.labels_and_glyphs(&dummy);
840         // The buttons' glyphs (see `button_glyphs`), tinted the label's own
841         // colour. A missing icon set draws nothing in their place: the label
842         // beside each is the word it always was.
843         pc.clip(clip, |pc| {
844             for (name, rect, color) in glyphs {
845                 pc.icon(name, rect, color);
846             }
847         });
848         for (tl, bounds) in labels {
849             pc.text_with(tl.text, tl.x, tl.y, tl.font_size, tl.color, None, bounds);
850         }
851     }
852 }
853 
854 impl cce_ui::widget::Input for JsonLayoutWidget {
855     fn scrollable(&self) -> bool { true }
856     fn wants_tick(&self) -> bool { true }
857     fn gates_presses(&self) -> bool { false }
858 
859     fn on_event(&mut self, event: &Event, ectx: &mut cce_ui::widget::EventCtx) -> bool {
860         let Some(ctx) = ectx.ui.as_deref_mut() else { return false };
861         self.route_event(event, ctx)
862     }
863 
864     fn tick_ctx(&mut self, dt: f32, ectx: &mut cce_ui::widget::EventCtx) -> bool {
865         let Some(ctx) = ectx.ui.as_deref_mut() else { return false };
866         self.tick_children(dt, ctx)
867     }
868 }
869 
870 /// A glyph to draw: its cce-icons name, rect and tint.
871 type GlyphPrim = (&'static str, cce_ui::scene::layout::Rect, [f32; 4]);
872 
873 impl JsonLayoutWidget {
874     /// Every label on the active page, moved aside for its button's glyphs,
875     /// and the glyphs themselves. A glyph is placed off its label — it is
876     /// sized from the label's font and tinted its colour, and the left one
877     /// stands where the label would have started (left-justified; centred,
878     /// label and glyph are centred together).
879     pub(crate) fn labels_and_glyphs(&self, ctx: &UiContext) -> (Vec<(TextLabel, Option<[f32; 4]>)>, Vec<GlyphPrim>) {
880         let mut labels = Vec::new();
881         let mut glyphs = Vec::new();
882         let (bx, by, bw, bh) = self.rect();
883         let pad_x = cce_ui::layout::root_plate_inset();
884         let content_bounds = Some([bx + pad_x - CLIP_SLACK, by, bx + bw, by + bh]);
885         for w in &self.widgets {
886             if w.page_idx != self.active_page {
887                 continue;
888             }
889             let mut wl = self.widget_labels(ctx, w);
890             self.place_glyphs(w, wl.first_mut(), &mut glyphs);
891             labels.extend(wl.into_iter().map(|l| (l, content_bounds)));
892         }
893         (labels, glyphs)
894     }
895 
896     /// A button's glyphs, placed off its label `tl` (which moves aside for
897     /// the left glyph column).
898     fn place_glyphs(&self, w: &JsonWidget, tl: Option<&mut TextLabel>, glyphs: &mut Vec<GlyphPrim>) {
899         use cce_ui::scene::layout::Rect;
900         if w.widget_type != "button" {
901             return;
902         }
903         let Some(tl) = tl else { return };
904         let size = tl.font_size;
905         let color = [tl.color[0] as f32 / 255.0, tl.color[1] as f32 / 255.0, tl.color[2] as f32 / 255.0, 1.0];
906         let column = if w.lead_column { mark_side(size) + GLYPH_GAP } else { 0.0 };
907         // Level with the text's capitals, not with the row: the button sets
908         // its label a little below the row's middle, and a capital's middle
909         // stands below its line box's (measured in a shadow: the line box's
910         // middle put the glyphs 2 px high at the 12 px button font).
911         let mid = tl.y + size * CAP_MIDDLE;
912         // Where the left column starts, and the label moved aside for it.
913         let col_x = match w.justify {
914             Justification::Left => {
915                 let x = tl.x;
916                 tl.x += column;
917                 x
918             }
919             Justification::Center => {
920                 let x = tl.x - column / 2.0;
921                 tl.x += column / 2.0;
922                 x
923             }
924             Justification::Right => tl.x - column,
925         };
926         if let Some(name) = w.lead {
927             let side = if name == "chevron-left" { chevron_side(size) } else { mark_side(size) };
928             let rect = Rect {
929                 x: col_x + (mark_side(size) - side) / 2.0,
930                 y: mid - side / 2.0,
931                 width: side,
932                 height: side,
933             };
934             glyphs.push((name, rect, color));
935         }
936         if let Some(name) = w.trail {
937             let side = chevron_side(size);
938             // The button's own text inset from its right edge.
939             let rect = Rect { x: w.x + w.w - 8.0 - side, y: mid - side / 2.0, width: side, height: side };
940             glyphs.push((name, rect, color));
941         }
942     }
943 
944     /// One widget's labels, read off the paint walk.
945     fn widget_labels(&self, ctx: &UiContext, w: &JsonWidget) -> Vec<TextLabel> {
946         if w.widget_type == "checkbox" {
947             return w.label_text.iter().cloned().collect();
948         }
949         // The trait text getters are gone: read the child's text off the paint
950         // walk (same prims, fonts dropped — this consumer shapes with its own
951         // control font, as the legacy getter path did).
952         let mut scratch = cce_ui::scene::paint::PaintCtx::new();
953         cce_ui::scene::painter::append_widget_text(ctx, w.widget.as_dyn(), &mut scratch);
954         scratch
955             .finish()
956             .items
957             .into_iter()
958             .filter_map(|item| match item.prim {
959                 cce_ui::scene::paint::Prim::Text { text, x, y, font_size, color, .. } => {
960                     Some(TextLabel { text, x, y, font_size, color })
961                 }
962                 _ => None,
963             })
964             .collect()
965     }
966 }