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 }