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 }