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

src/json_layout.rs (34.9K)

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