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

commitac4d5e3d56790ba60566cf7f76491a9b90a5c93c
parent05540be57a
authorLucas Galante <[email protected]>
date2026-07-12 14:44
refactor: JsonControl — the launcher's controls are concretely typed (Phase 6bb part 3)

The JSON-config-constructible controls become a typed enum
(Label/Checkbox/Button/Spinbox/Color/Slider over Adapted<T>) replacing
JsonWidget's Box<dyn Element> — the last owned type-erased widget storage
in the workspace. The aggregation and routing paths dispatch through
as_dyn/as_dyn_mut; the label walk drops its as_ptr round-trip unsafe for a
plain reborrow.

Verified: 8/8 tests; JSON-mode A/B overlay clean (the 8% mask is the
terminal behind the translucent plate); live checkbox click toggles
through the enum routing (972 blue px -> 0).

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018u7qTwzX95dd5ysAkaSCLk

 src/json_layout.rs | 106 +++++++++++++++++++++++++++++++++++------------------
 src/main.rs        |  22 +++++------
 2 files changed, 82 insertions(+), 46 deletions(-)

diff --git a/src/json_layout.rs b/src/json_layout.rs
index 935ebd2..70c2ad0 100644
--- a/src/json_layout.rs
+++ b/src/json_layout.rs
@@ -45,11 +45,47 @@ pub struct JsonLayoutConfig {
     pub justify: Option<Justification>,
 }
 
+/// The config-constructible controls, concretely typed (Phase 6bb part 3): this was the
+/// last owned type-erased widget storage in the workspace. `as_dyn`/`as_dyn_mut` serve the
+/// aggregation/routing paths that dispatch heterogeneously.
+pub enum JsonControl {
+    Label(cce_ui::widget::Adapted<Label>),
+    Checkbox(cce_ui::widget::Adapted<Checkbox>),
+    Button(cce_ui::widget::Adapted<Button>),
+    Spinbox(cce_ui::widget::Adapted<Spinbox>),
+    Color(cce_ui::widget::Adapted<ColorSelector>),
+    Slider(cce_ui::widget::Adapted<Slider>),
+}
+
+impl JsonControl {
+    pub fn as_dyn(&self) -> &(dyn Element + 'static) {
+        match self {
+            JsonControl::Label(w) => w,
+            JsonControl::Checkbox(w) => w,
+            JsonControl::Button(w) => w,
+            JsonControl::Spinbox(w) => w,
+            JsonControl::Color(w) => w,
+            JsonControl::Slider(w) => w,
+        }
+    }
+
+    pub fn as_dyn_mut(&mut self) -> &mut (dyn Element + 'static) {
+        match self {
+            JsonControl::Label(w) => w,
+            JsonControl::Checkbox(w) => w,
+            JsonControl::Button(w) => w,
+            JsonControl::Spinbox(w) => w,
+            JsonControl::Color(w) => w,
+            JsonControl::Slider(w) => w,
+        }
+    }
+}
+
 pub struct JsonWidget {
     pub id: String,
     pub widget_type: String,
     pub text: String,
-    pub widget: Box<dyn Element>,
+    pub widget: JsonControl,
     pub x: f32,
     pub y: f32,
     pub w: f32,
@@ -82,23 +118,23 @@ impl JsonLayoutWidget {
                     let widget_type = w_conf.widget_type.clone();
                     let text = w_conf.text.clone();
 
-                    let widget: Box<dyn Element> = match widget_type.as_str() {
+                    let widget: JsonControl = match widget_type.as_str() {
                         "checkbox" => {
                             let mut cb = Checkbox::new();
                             if let Some(ch) = w_conf.checked {
                                 cb.set_checked(ch);
                             }
-                            Box::new(cb)
+                            JsonControl::Checkbox(cb)
                         }
                         "button" => {
-                            Box::new(Button::new(0.0, 0.0, 0.0, 0.0)
+                            JsonControl::Button(Button::new(0.0, 0.0, 0.0, 0.0)
                                 .with_label(&text)
                                 .with_justify(page_justify)
                                 .with_bg([0.0, 0.0, 0.0, 0.0])
                                 .with_hover_bg([0.20, 0.35, 0.65, 0.9]))
                         }
                         "label" => {
-                            Box::new(Label::new(&text).with_font_size(13.0).with_color([0xcc, 0xcc, 0xd4]))
+                            JsonControl::Label(Label::new(&text).with_font_size(13.0).with_color([0xcc, 0xcc, 0xd4]))
                         }
                         "spinbox" => {
                             let min_val = w_conf.min.unwrap_or(0);
@@ -109,12 +145,12 @@ impl JsonLayoutWidget {
                             if let Some(dec) = w_conf.decimals {
                                 sb = sb.with_decimals(dec);
                             }
-                            Box::new(sb)
+                            JsonControl::Spinbox(sb)
                         }
                         "color" | "rgb" | "rgba" => {
                             let col = w_conf.color.unwrap_or([255, 255, 255]);
                             let cs = ColorSelector::new(col).with_label(&text);
-                            Box::new(cs)
+                            JsonControl::Color(cs)
                         }
                         "slider" => {
                             let min_val = w_conf.min_f32.unwrap_or(0.0);
@@ -127,9 +163,9 @@ impl JsonLayoutWidget {
                                 let pct = if max_val > min_val { (val - min_val) / (max_val - min_val) } else { 0.0 };
                                 sl = sl.with_value(pct);
                             }
-                            Box::new(sl)
+                            JsonControl::Slider(sl)
                         }
-                        _ => Box::new(Button::new(0.0, 0.0, 0.0, 0.0)),
+                        _ => JsonControl::Button(Button::new(0.0, 0.0, 0.0, 0.0)),
                     };
 
                     widgets.push(JsonWidget {
@@ -154,23 +190,23 @@ impl JsonLayoutWidget {
                 let widget_type = w_conf.widget_type.clone();
                 let text = w_conf.text.clone();
 
-                let widget: Box<dyn Element> = match widget_type.as_str() {
+                let widget: JsonControl = match widget_type.as_str() {
                     "checkbox" => {
                         let mut cb = Checkbox::new();
                         if let Some(ch) = w_conf.checked {
                             cb.set_checked(ch);
                         }
-                        Box::new(cb)
+                        JsonControl::Checkbox(cb)
                     }
                     "button" => {
-                        Box::new(Button::new(0.0, 0.0, 0.0, 0.0)
+                        JsonControl::Button(Button::new(0.0, 0.0, 0.0, 0.0)
                             .with_label(&text)
                             .with_justify(global_justify)
                             .with_bg([0.0, 0.0, 0.0, 0.0])
                             .with_hover_bg([0.20, 0.35, 0.65, 0.9]))
                     }
                     "label" => {
-                        Box::new(Label::new(&text).with_font_size(13.0).with_color([0xcc, 0xcc, 0xd4]))
+                        JsonControl::Label(Label::new(&text).with_font_size(13.0).with_color([0xcc, 0xcc, 0xd4]))
                     }
                     "spinbox" => {
                         let min_val = w_conf.min.unwrap_or(0);
@@ -181,12 +217,12 @@ impl JsonLayoutWidget {
                         if let Some(dec) = w_conf.decimals {
                             sb = sb.with_decimals(dec);
                         }
-                        Box::new(sb)
+                        JsonControl::Spinbox(sb)
                     }
                     "color" | "rgb" | "rgba" => {
                         let col = w_conf.color.unwrap_or([255, 255, 255]);
                         let cs = ColorSelector::new(col).with_label(&text);
-                        Box::new(cs)
+                        JsonControl::Color(cs)
                     }
                     "slider" => {
                         let min_val = w_conf.min_f32.unwrap_or(0.0);
@@ -199,9 +235,9 @@ impl JsonLayoutWidget {
                             let pct = if max_val > min_val { (val - min_val) / (max_val - min_val) } else { 0.0 };
                             sl = sl.with_value(pct);
                         }
-                        Box::new(sl)
+                        JsonControl::Slider(sl)
                     }
-                    _ => Box::new(Button::new(0.0, 0.0, 0.0, 0.0)),
+                    _ => JsonControl::Button(Button::new(0.0, 0.0, 0.0, 0.0)),
                 };
 
                 widgets.push(JsonWidget {
@@ -247,7 +283,7 @@ impl JsonLayoutWidget {
             let current_y = &mut page_current_y[p_idx];
             w_state.x = bx + pad_x;
 
-            let top_room = cce_ui::widget::label_offset(w_state.widget.as_ref());
+            let top_room = cce_ui::widget::label_offset(w_state.widget.as_dyn());
 
             let scroll_offset = self.page_scroll_y.get(p_idx).cloned().unwrap_or(0.0);
             w_state.y = by + *current_y - scroll_offset;
@@ -256,7 +292,7 @@ impl JsonLayoutWidget {
             if w_state.widget_type == "checkbox" {
                 // set_rect is an Element method; call it on the box directly (the Phase 5
                 // Checkbox is an Adapted widget — as_any downcasts reach the model, not Element).
-                w_state.widget.set_rect(w_state.x, w_state.y + 2.0, 18.0, 18.0);
+                w_state.widget.as_dyn_mut().set_rect(w_state.x, w_state.y + 2.0, 18.0, 18.0);
                 w_state.h = 22.0;
                 w_state.label_text = Some(TextLabel {
                     text: w_state.text.clone(),
@@ -274,7 +310,7 @@ impl JsonLayoutWidget {
                     "slider" => 22.0 + top_room,
                     _ => 24.0,
                 };
-                w_state.widget.set_rect(w_state.x, w_state.y, usable_w, h);
+                w_state.widget.as_dyn_mut().set_rect(w_state.x, w_state.y, usable_w, h);
                 w_state.h = h;
             }
 
@@ -327,18 +363,18 @@ impl JsonLayoutWidget {
             if w.page_idx != active_page {
                 continue;
             }
-            let (wx, wy, ww, wh) = w.widget.rect();
-            let has_rounded = w.widget.rounded_corners() != (false, false, false, false);
+            let (wx, wy, ww, wh) = w.widget.as_dyn().rect();
+            let has_rounded = w.widget.as_dyn().rounded_corners() != (false, false, false, false);
             if !has_rounded {
-                push_clipped(wx, wy, ww, wh, w.widget.color(), &mut quads);
+                push_clipped(wx, wy, ww, wh, w.widget.as_dyn().color(), &mut quads);
             }
-            for q in w.widget.all_quads(ctx) {
+            for q in w.widget.as_dyn().all_quads(ctx) {
                 if has_rounded && (q.0 - wx).abs() < 0.1 && (q.1 - wy).abs() < 0.1 && (q.2 - ww).abs() < 0.1 && (q.3 - wh).abs() < 0.1 {
                     continue;
                 }
                 push_clipped(q.0, q.1, q.2, q.3, q.4, &mut quads);
             }
-            if let Some(hq) = w.widget.highlight_quad(ctx) {
+            if let Some(hq) = w.widget.as_dyn().highlight_quad(ctx) {
                 push_clipped(hq.0, hq.1, hq.2, hq.3, hq.4, &mut quads);
             }
         }
@@ -353,7 +389,7 @@ impl JsonLayoutWidget {
             if w.page_idx != active_page {
                 continue;
             }
-            if w.widget.tick(dt, ctx) {
+            if w.widget.as_dyn_mut().tick(dt, ctx) {
                 changed = true;
             }
         }
@@ -373,7 +409,7 @@ impl JsonLayoutWidget {
                     if let Some(w) = self.widgets.get_mut(idx) {
                         // drag_update is an Element method (the Adapted forward supplies the
                         // widget's rect); call it on the box, not a concrete downcast.
-                        if w.widget.drag_update(*x, *y) {
+                        if w.widget.as_dyn_mut().drag_update(*x, *y) {
                             changed = true;
                         }
                     }
@@ -383,7 +419,7 @@ impl JsonLayoutWidget {
                 if *button == MouseButton::Left && *state == ElementState::Released {
                     if let Some(idx) = self.dragging_slider_idx {
                         if let Some(w) = self.widgets.get_mut(idx) {
-                            w.widget.handle_event(event, ctx);
+                            w.widget.as_dyn_mut().handle_event(event, ctx);
                             changed = true;
                         }
                         self.dragging_slider_idx = None;
@@ -412,7 +448,7 @@ impl JsonLayoutWidget {
             if w.widget_type == "checkbox" {
                 match event {
                     Event::PointerMove { x, y, .. } => {
-                        if let Some(cb) = w.widget.as_any_mut().downcast_mut::<Checkbox>() {
+                        if let Some(cb) = w.widget.as_dyn_mut().as_any_mut().downcast_mut::<Checkbox>() {
                             let was = cb.hovered();
                             let hit = *x >= w.x && *x <= w.x + w.w && *y >= w.y && *y <= w.y + w.h;
                             cb.set_hovered(hit);
@@ -428,7 +464,7 @@ impl JsonLayoutWidget {
                                 if *state == ElementState::Pressed {
                                     changed = true;
                                 } else if *state == ElementState::Released {
-                                    if let Some(cb) = w.widget.as_any_mut().downcast_mut::<Checkbox>() {
+                                    if let Some(cb) = w.widget.as_dyn_mut().as_any_mut().downcast_mut::<Checkbox>() {
                                         let new_checked = !cb.checked();
                                         cb.set_checked(new_checked);
                                     }
@@ -440,13 +476,13 @@ impl JsonLayoutWidget {
                     _ => {}
                 }
             } else {
-                if w.widget.handle_event(event, ctx) {
+                if w.widget.as_dyn_mut().handle_event(event, ctx) {
                     changed = true;
                 }
                 if w.widget_type == "button" {
                     // take_click is an Element method; the Phase 5 Button is Adapted, so call it
                     // on the box directly rather than through a concrete downcast.
-                    if w.target_page.is_some() && w.widget.take_click() {
+                    if w.target_page.is_some() && w.widget.as_dyn_mut().take_click() {
                         page_switch = Some(w.target_page.unwrap());
                     }
                 }
@@ -525,7 +561,7 @@ impl JsonLayoutWidget {
                 if w.page_idx != active_page {
                     continue;
                 }
-                if w.widget.tick(*dt, ctx) {
+                if w.widget.as_dyn_mut().tick(*dt, ctx) {
                     changed = true;
                 }
             }
@@ -570,7 +606,7 @@ impl cce_ui::widget::Paint for JsonLayoutWidget {
         // Rounded: the deleted Element default's shape — no own background (transparent,
         // sharp corners), every child unfiltered, in `widgets` order.
         for w in &self.widgets {
-            for (x, y, qw, qh, r, c, corners) in w.widget.all_rounded_quads(&dummy) {
+            for (x, y, qw, qh, r, c, corners) in w.widget.as_dyn().all_rounded_quads(&dummy) {
                 pc.rounded_rect(Rect { x, y, width: qw, height: qh }, r, corners, c);
             }
         }
@@ -623,7 +659,7 @@ impl JsonLayoutWidget {
                 // walk (same prims, fonts dropped — this consumer shapes with its own
                 // control font, as the legacy getter path did).
                 let mut scratch = cce_ui::scene::paint::PaintCtx::new();
-                cce_ui::scene::painter::append_widget_text(ctx, unsafe { &*w.widget.as_ptr() }, &mut scratch);
+                cce_ui::scene::painter::append_widget_text(ctx, w.widget.as_dyn(), &mut scratch);
                 scratch
                     .finish()
                     .items
diff --git a/src/main.rs b/src/main.rs
index 0ab7945..60ceace 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1804,7 +1804,7 @@ impl PointerHandler for AppState {
                                     for w in &mut jl.widgets {
                                         // take_click is an Element method; Phase 5 Buttons are
                                         // Adapted, so ask the box directly.
-                                        if w.widget_type == "button" && w.widget.take_click() {
+                                        if w.widget_type == "button" && w.widget.as_dyn_mut().take_click() {
                                             clicked_btn_id = Some(w.id.clone());
                                             break;
                                         }
@@ -1821,13 +1821,13 @@ impl PointerHandler for AppState {
                                     let mut sliders = std::collections::HashMap::new();
                                     if let Some(jl) = &st.json_layout {
                                         for w in &jl.widgets {
-                                            if let Some(cb) = w.widget.as_any().downcast_ref::<cce_ui::widget::Checkbox>() {
+                                            if let Some(cb) = w.widget.as_dyn().as_any().downcast_ref::<cce_ui::widget::Checkbox>() {
                                                 checkboxes.insert(w.id.clone(), cb.checked());
-                                            } else if let Some(sb) = w.widget.as_any().downcast_ref::<cce_ui::widget::Spinbox>() {
+                                            } else if let Some(sb) = w.widget.as_dyn().as_any().downcast_ref::<cce_ui::widget::Spinbox>() {
                                                 spinboxes.insert(w.id.clone(), sb.value);
-                                            } else if let Some(cs) = w.widget.as_any().downcast_ref::<cce_ui::widget::ColorSelector>() {
+                                            } else if let Some(cs) = w.widget.as_dyn().as_any().downcast_ref::<cce_ui::widget::ColorSelector>() {
                                                 colors.insert(w.id.clone(), cs.color);
-                                            } else if let Some(sl) = w.widget.as_any().downcast_ref::<cce_ui::widget::Slider>() {
+                                            } else if let Some(sl) = w.widget.as_dyn().as_any().downcast_ref::<cce_ui::widget::Slider>() {
                                                 sliders.insert(w.id.clone(), sl.get_scaled_value());
                                             }
                                         }
@@ -3093,9 +3093,9 @@ mod tests {
         let w_btn_x = layout.widgets[2].x;
         let w_btn_w = layout.widgets[2].w;
 
-        assert!(layout.widgets[0].widget.as_any().downcast_ref::<cce_ui::widget::Label>().is_some());
-        assert!(layout.widgets[1].widget.as_any().downcast_ref::<cce_ui::widget::Checkbox>().is_some());
-        assert!(layout.widgets[2].widget.as_any().downcast_ref::<cce_ui::widget::Button>().is_some());
+        assert!(layout.widgets[0].widget.as_dyn().as_any().downcast_ref::<cce_ui::widget::Label>().is_some());
+        assert!(layout.widgets[1].widget.as_dyn().as_any().downcast_ref::<cce_ui::widget::Checkbox>().is_some());
+        assert!(layout.widgets[2].widget.as_dyn().as_any().downcast_ref::<cce_ui::widget::Button>().is_some());
 
         // Check vertical sequence positions
         assert_eq!(w_label_y, 16.0);
@@ -3116,7 +3116,7 @@ mod tests {
         assert_eq!(w_btn_w, 268.0);
 
         // Verify Checkbox initial state
-        assert_eq!(layout.widgets[1].widget.as_any().downcast_ref::<cce_ui::widget::Checkbox>().unwrap().checked(), false);
+        assert_eq!(layout.widgets[1].widget.as_dyn().as_any().downcast_ref::<cce_ui::widget::Checkbox>().unwrap().checked(), false);
 
         // Simulate click on Checkbox row
         let changed = layout.mouse_input(
@@ -3127,13 +3127,13 @@ mod tests {
             &mut ctx,
         );
         assert!(changed);
-        assert_eq!(layout.widgets[1].widget.as_any().downcast_ref::<cce_ui::widget::Checkbox>().unwrap().checked(), true);
+        assert_eq!(layout.widgets[1].widget.as_dyn().as_any().downcast_ref::<cce_ui::widget::Checkbox>().unwrap().checked(), true);
 
         // Simulate hover on button
         let changed_hover = layout.on_cursor_moved(w_btn_x + 10.0, w_btn_y + 10.0, &mut ctx);
         assert!(changed_hover);
         // Phase 5: hover state lives on the Button model (it drives the color matrix), not the base.
-        assert!(layout.widgets[2].widget.as_any().downcast_ref::<cce_ui::widget::Button>().unwrap().hovered());
+        assert!(layout.widgets[2].widget.as_dyn().as_any().downcast_ref::<cce_ui::widget::Button>().unwrap().hovered());
     }
 
     #[test]