git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit5d29115d075fa52eee99d2da7ce3d0ffd7a8f95b
parenta4ad8e644c
authorLucas Galante <[email protected]>
date2026-07-13 08:38
refactor(widget)!: the value/polling block leaves WidgetHost — 59->52 (6bd)

take_click / take_change / get_value_string / set_value_string / value /
set_text / set_selected are now inherent Adapted<W> methods forwarding to
the narrow Input hooks; the serialize.rs inspector feed reads value through
a concrete downcast chain over the five Input::value implementors (pinned
by a unit test). This resolves the §3.5 "typed messages" bullet the 5k way:
the drains stay concrete, what dies is reaching them through the host trait.

Verified: 165 tests; settings audio render stream byte-identical vs stashed
baseline; TI live probe (dropdown popover -> Grid layout switch end-to-end).

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

 CLAUDE.md                        |  8 +++--
 docs/rfc-core-rebuild.md         | 33 ++++++++++++++++++++
 src/widget/display/label.rs      |  4 +--
 src/widget/display/serialize.rs  | 48 +++++++++++++++++++++++++++-
 src/widget/display/status_bar.rs |  2 +-
 src/widget/input/button.rs       |  6 ++--
 src/widget/input/checkbox.rs     | 20 ++++++------
 src/widget/input/slider.rs       |  2 +-
 src/widget/input/spinbox.rs      |  8 ++---
 src/widget/mod.rs                | 14 +++------
 src/widget/model.rs              | 67 +++++++++++++++++++++++++---------------
 11 files changed, 153 insertions(+), 59 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index b87c45f..09d1147 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -107,13 +107,15 @@ Modules:
 
 ### `WidgetHost` (formerly the `Element` god-trait)
 
-`WidgetHost` (`src/widget/mod.rs`) is the single ~65-method host surface the machinery
+`WidgetHost` (`src/widget/mod.rs`) is the single ~52-method host surface the machinery
 (context routing, paint walk, render loop, app dyn broadcasts) sees, produced by the RFC's 6bd
 shrink-then-rename of the old ~125-method `Element` god-trait. Its ONE production implementor
 is `Adapted<W>`; concrete widget behavior lives on the narrow `Layout`/`Paint`/`Input` traits
 (`src/widget/model.rs`). `base()` is guaranteed (`&Widget`, no Option). The direct-dispatch
-block (mouse/key/drag) and value block shrink further as apps adopt routed events and
-concrete slots — see the RFC's blueprint notes before adding anything to this trait.
+block (mouse/key/drag) and the value/polling block (`take_click`/`take_change`/value strings)
+are GONE from the trait — events route through `handle_event`, and apps drain widget state
+through the concrete inherent `Adapted<W>` methods. See the RFC's blueprint notes before
+adding anything to this trait.
 
 **Runtime verification matters here.** Several scene changes are "compiles + tests pass; runtime
 verification pending" per the RFC — the headless tests can't catch paint/event regressions. When
diff --git a/docs/rfc-core-rebuild.md b/docs/rfc-core-rebuild.md
index 1a59a5b..57b89df 100644
--- a/docs/rfc-core-rebuild.md
+++ b/docs/rfc-core-rebuild.md
@@ -2064,6 +2064,39 @@ Constraint respected: **each crate still builds standalone** — the new core is
        blueprint addition, not a deletion), `set_parent` (flip
        material, with the parent-ptr snapshot change), `value`,
        `preferred_height`.
+    7. **The value/polling block — DONE (2026-07-13): WidgetHost
+       59→52.** This is the §3.5 "typed messages" resolution, and it
+       lands the way 5k's controller half did: no app-defined message
+       channel is needed — the polling drains stay concrete (inherent
+       `Adapted<W>` forwards to the narrow `Input` hooks), and what
+       dies is reaching them through the host trait. Seven methods
+       left: `take_click`, `take_change`, `get_value_string`,
+       `set_value_string`, `value`, `set_text`, `set_selected`.
+       Census: five had ZERO non-test dyn consumers (the old
+       designer-side serialize consumer of `value` is gone; the
+       in-crate `widget/display/serialize.rs` inspector feed was the
+       one live reader — now a concrete downcast chain over the five
+       `Input::value` implementors Checkbox/Dropdown/Slider/
+       RangeSlider/Spinbox, pinned by a unit test that fails if a new
+       implementor is missed). The dyn readers of the rest went
+       concrete-slot: TI's index-driven roster reads route through
+       app-local `Roster::take_click/value/get_value_string/set_text
+       (idx)` matches onto the concrete gallery slots (arms exist per
+       drained slot; an unwired slot panics loudly); cloud's
+       `JsonControl` grew an inherent variant-matched `take_click`
+       (both call sites already gate on the button type); designer's
+       pane-focus menubar loop writes `set_selected` on its five
+       concrete `Adapted<MenuBar>` fields. The UFCS test forms became
+       dot calls resolving to the inherent methods. STILL on the
+       trait, each with live dyn consumers: `draggable`/`is_dragging`
+       (designer's deferred press/move cascade + TI's ControlPanel
+       child pointers), `preferred_height` (layout.rs container
+       machinery) — these ride the designer event redesign / CP
+       dissolution. Verified: 165 tests (new serialize pin);
+       settings audio render stream byte-identical vs the stashed
+       baseline; TI live probe — Button/Toggle clicks, Layout
+       dropdown popover open, and a "Grid" selection re-laying out
+       the gallery through the new roster drains end-to-end.
     Former slices 4/5 fold in: the app `as_ptr_mut` dispatch sites
     are rewritten by whichever of routed-events (per app) or the
     phase-4 flip reaches them first; no standalone pointer-to-id
diff --git a/src/widget/display/label.rs b/src/widget/display/label.rs
index 35a2575..824d74d 100644
--- a/src/widget/display/label.rs
+++ b/src/widget/display/label.rs
@@ -23,7 +23,7 @@ impl Label {
             color: colors::control_label_color_u8(),
         });
         // Keep the base copy in step too (context menus, fallback machinery).
-        WidgetHost::set_text(&mut l, text);
+        l.set_text(text);
         l
     }
 
@@ -104,7 +104,7 @@ mod tests {
         assert_eq!(labels[0].font_size, 13.0);
         assert_eq!(labels[0].color, [1, 2, 3]);
 
-        WidgetHost::set_text(&mut l, "CPU: 99%");
+        l.set_text("CPU: 99%");
         assert_eq!(l.own_text_labels()[0].text, "CPU: 99%", "set_text reaches the paint source");
 
         let size = l.intrinsic_size().unwrap();
diff --git a/src/widget/display/serialize.rs b/src/widget/display/serialize.rs
index 9447648..31919e7 100644
--- a/src/widget/display/serialize.rs
+++ b/src/widget/display/serialize.rs
@@ -5,7 +5,18 @@ fn serialize_single_widget(w: &dyn WidgetHost, json: &mut String) {
     let label = w.label().or_else(|| w.base().label.clone()).unwrap_or_default();
     let focused = w.base().focused;
     let hovered = w.base().hovered;
-    let value = w.value();
+    // Concrete value lookup (6bd value shrink — `value` left `WidgetHost`): the
+    // `Input::value` implementors a serialized roster can hold are these five widgets;
+    // everything else always reported the default 0.
+    let a = w.as_any();
+    let value = a
+        .downcast_ref::<Checkbox>()
+        .map(|x| Input::value(x))
+        .or_else(|| a.downcast_ref::<Dropdown>().map(|x| Input::value(x)))
+        .or_else(|| a.downcast_ref::<Slider>().map(|x| Input::value(x)))
+        .or_else(|| a.downcast_ref::<RangeSlider>().map(|x| Input::value(x)))
+        .or_else(|| a.downcast_ref::<Spinbox>().map(|x| Input::value(x)))
+        .unwrap_or(0);
     let type_name = w.type_name();
 
     // Escape JSON label
@@ -101,3 +112,38 @@ pub fn serialize_widgets(widgets: &[&dyn WidgetHost]) -> String {
     json.push(']');
     json
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// The serialized `value` field must keep matching `Input::value` for every
+    /// value-bearing widget (the concrete lookup replaced the deleted
+    /// `WidgetHost::value` — a new `Input::value` implementor must be added to the
+    /// downcast chain in `serialize_single_widget`).
+    #[test]
+    fn serialized_value_matches_input_value() {
+        let mut cb = Checkbox::new();
+        assert!(cb.set_value_string("true"));
+        let dd = Dropdown::new(vec!["a".into(), "b".into(), "c".into()], 2);
+        let mut sl = Slider::new();
+        assert!(sl.set_value_string("0.7"));
+        let sb = Spinbox::new(7, 0, 10, 1);
+        let btn = Button::new(0.0, 0.0, 10.0, 10.0); // no Input::value — always 0
+
+        for (w, expect) in [
+            (&cb as &dyn WidgetHost, 1),
+            (&dd as &dyn WidgetHost, 2),
+            (&sl as &dyn WidgetHost, 70),
+            (&sb as &dyn WidgetHost, 7),
+            (&btn as &dyn WidgetHost, 0),
+        ] {
+            let json = serialize_widgets(&[w]);
+            assert!(
+                json.contains(&format!("\"value\":{expect}")),
+                "{} serialized without value {expect}: {json}",
+                w.type_name()
+            );
+        }
+    }
+}
diff --git a/src/widget/display/status_bar.rs b/src/widget/display/status_bar.rs
index dac85c9..abccb10 100644
--- a/src/widget/display/status_bar.rs
+++ b/src/widget/display/status_bar.rs
@@ -175,7 +175,7 @@ mod tests {
         assert!(bar.text_buf.is_some(), "one shaped buffer");
 
         // set_text drops the stale buffer; prepare_text reshapes.
-        WidgetHost::set_text(&mut bar, "world");
+        bar.set_text("world");
         assert!(bar.text_buf.is_none(), "buffer dropped on text change");
         WidgetHost::prepare_text(&mut bar, &mut fs);
         assert!(bar.text_buf.is_some());
diff --git a/src/widget/input/button.rs b/src/widget/input/button.rs
index 8d6b8a5..8df5562 100644
--- a/src/widget/input/button.rs
+++ b/src/widget/input/button.rs
@@ -404,13 +404,13 @@ mod tests {
         // Press in, release in -> click.
         assert!(ctx.propagate_event(&press(20.0, 20.0), ptr));
         assert!(ctx.propagate_event(&release(25.0, 20.0), ptr), "release consumed (was pressed)");
-        assert!(WidgetHost::take_click(&mut b));
+        assert!(b.take_click());
         assert_eq!(fired.load(std::sync::atomic::Ordering::SeqCst), 1, "callback fired");
 
         // Press in, release OUT -> cancelled, no click, but release still consumed.
         assert!(ctx.propagate_event(&press(20.0, 20.0), ptr));
         assert!(ctx.propagate_event(&release(500.0, 500.0), ptr), "cancelling release consumed");
-        assert!(!WidgetHost::take_click(&mut b), "no click on out-of-rect release");
+        assert!(!b.take_click(), "no click on out-of-rect release");
         assert_eq!(fired.load(std::sync::atomic::Ordering::SeqCst), 1, "callback not re-fired");
 
         // Release without a press is not consumed.
@@ -442,7 +442,7 @@ mod tests {
 
         // Selection state flows through the WidgetHost forward (list hosts push it).
         let mut b = b;
-        WidgetHost::set_selected(&mut b, true);
+        b.set_selected(true);
         assert!(b.selected);
     }
 }
diff --git a/src/widget/input/checkbox.rs b/src/widget/input/checkbox.rs
index fb450f6..071dbfd 100644
--- a/src/widget/input/checkbox.rs
+++ b/src/widget/input/checkbox.rs
@@ -479,9 +479,9 @@ mod tests {
 
         assert!(ctx.propagate_event(&click_at(10.0, 10.0), ptr), "in-rect click consumed");
         assert!(cb.checked(), "click checked it");
-        assert!(WidgetHost::take_click(&mut cb), "take_click reads once");
-        assert!(!WidgetHost::take_click(&mut cb), "...then clears");
-        assert!(WidgetHost::take_change(&mut cb));
+        assert!(cb.take_click(), "take_click reads once");
+        assert!(!cb.take_click(), "...then clears");
+        assert!(cb.take_change());
 
         assert!(!ctx.propagate_event(&click_at(100.0, 100.0), ptr), "miss is not consumed");
         assert!(cb.checked(), "miss does not toggle");
@@ -490,13 +490,13 @@ mod tests {
     #[test]
     fn checkbox_value_string_round_trip() {
         let mut cb = Checkbox::new();
-        assert_eq!(WidgetHost::get_value_string(&cb), Some("false".to_string()));
-        assert!(WidgetHost::set_value_string(&mut cb, "on"));
+        assert_eq!(cb.get_value_string(), Some("false".to_string()));
+        assert!(cb.set_value_string("on"));
         assert!(cb.checked());
-        assert_eq!(WidgetHost::value(&cb), 1);
-        assert!(!WidgetHost::set_value_string(&mut cb, "on"), "unchanged value reports false");
-        assert!(!WidgetHost::set_value_string(&mut cb, "junk"), "unparsable reports false");
-        assert!(WidgetHost::take_change(&mut cb), "set_value_string marked the change");
+        assert_eq!(cb.value(), 1);
+        assert!(!cb.set_value_string("on"), "unchanged value reports false");
+        assert!(!cb.set_value_string("junk"), "unparsable reports false");
+        assert!(cb.take_change(), "set_value_string marked the change");
     }
 
     /// Wide (labeled) mode reproduces the legacy `extra_quads` geometry through the bridge:
@@ -545,7 +545,7 @@ mod tests {
 
         assert!(ctx.propagate_event(&click_at(30.0, 15.0), ptr), "toggle consumed the click");
         assert!(t.toggled());
-        assert!(WidgetHost::take_click(&mut t));
+        assert!(t.take_click());
 
         let after: Vec<_> = WidgetHost::all_rounded_quads(&t, &ctx);
         let after_quads = WidgetHost::extra_quads(&t);
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index 7235c6e..3d66875 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -776,6 +776,6 @@ fn probe_slider_bridge() {
             &mut ctx,
         ));
         assert!(sl.inner().value() < before, "scroll up decreases value");
-        assert!(WidgetHost::take_change(&mut sl));
+        assert!(sl.take_change());
     }
 }
diff --git a/src/widget/input/spinbox.rs b/src/widget/input/spinbox.rs
index a80de47..014f9e0 100644
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@ -453,7 +453,7 @@ mod tests {
         // Legacy test: click at (75, 33) lands in the decrement zone.
         assert!(sb.mouse_input(MouseButton::Left, ElementState::Pressed, 75.0, 33.0, &mut ctx));
         assert_eq!(sb.value, -1);
-        assert!(WidgetHost::take_change(&mut sb));
+        assert!(sb.take_change());
 
         // Increment zone (past 77.5% of the width).
         assert!(sb.mouse_input(MouseButton::Left, ElementState::Pressed, 92.0, 33.0, &mut ctx));
@@ -463,9 +463,9 @@ mod tests {
     #[test]
     fn spinbox_value_string_decimals_round_trip() {
         let mut sb = Spinbox::new(150, 0, 1000, 5).with_decimals(2);
-        assert_eq!(WidgetHost::get_value_string(&sb), Some("1.50".to_string()));
-        assert!(WidgetHost::set_value_string(&mut sb, "2.75"));
+        assert_eq!(sb.get_value_string(), Some("1.50".to_string()));
+        assert!(sb.set_value_string("2.75"));
         assert_eq!(sb.value, 275);
-        assert_eq!(WidgetHost::value(&sb), 275);
+        assert_eq!(sb.value(), 275);
     }
 }
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 204157d..6edc8d6 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -267,9 +267,11 @@ pub trait WidgetHost {
         self.base().label.clone()
     }
 
-    fn get_value_string(&self) -> Option<String> { None }
-    fn set_value_string(&mut self, _val: &str) -> bool { false }
-    fn take_change(&mut self) -> bool { false }
+    // The value/polling block (`get_value_string`/`set_value_string`/`take_change`/
+    // `take_click`/`value`/`set_text`/`set_selected`) is GONE from the trait (6bd value
+    // shrink): apps drain widget state through the concrete inherent `Adapted<W>` methods
+    // (which forward to the narrow `Input` hooks). The last dyn readers went concrete-slot
+    // (TI's roster drain, cloud's JsonControl, designer's pane-focus sync).
 
     /// Dispatch a context-menu action on this widget. Returns whether it was applied.
     /// Default inert; the adapter forwards to `Input::context_action` (whose default gives
@@ -339,7 +341,6 @@ pub trait WidgetHost {
     fn plate_bevel(&self) -> Option<f32> { None }
 
     fn is_dragging(&self) -> bool { false }
-    fn take_click(&mut self) -> bool { false }
     fn draggable(&self) -> bool { false }
 
     fn label_x_offset(&self) -> f32 {
@@ -462,16 +463,12 @@ pub trait WidgetHost {
     // and its scroll-ancestor clamp in scene::painter::scroll_ancestor_text_bounds.
 
     fn widget_font(&self) -> Option<String> { None }
-    fn value(&self) -> i32 { 0 }
     fn type_name(&self) -> &'static str {
         let full_name = std::any::type_name::<Self>();
         full_name.split("::").last().unwrap_or("Widget")
     }
     fn popover_rect(&self) -> Option<(f32, f32, f32, f32)> { None }
     fn render_popover(&self, _pc: &mut dyn crate::layout::RenderTarget) {}
-    fn set_text(&mut self, text: &str) {
-        self.base_mut().label = Some(text.to_string());
-    }
 
     fn focus(&mut self) {
         self.base_mut().focused = true;
@@ -483,7 +480,6 @@ pub trait WidgetHost {
         ctx.is_focused_id(self.base().id())
     }
     fn prepare_text(&mut self, _fs: &mut glyphon::FontSystem) {}
-    fn set_selected(&mut self, _selected: bool) {}
 
     fn set_visible(&mut self, _visible: bool) {}
     fn visible(&self) -> bool { true }
diff --git a/src/widget/model.rs b/src/widget/model.rs
index a45551b..a6be981 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -658,6 +658,48 @@ impl<W: Layout + Paint + Input + 'static> Adapted<W> {
         self.inner.sync_label(label);
     }
 
+    // --- The value/polling drains (off `WidgetHost` in the 6bd value shrink): apps read
+    // widget state through these concrete methods; each forwards to the narrow `Input`
+    // hook. The last dyn readers went concrete-slot instead (TI roster, cloud JsonControl,
+    // designer pane-focus sync).
+
+    /// Drain the one-shot click flag (Button-class widgets).
+    pub fn take_click(&mut self) -> bool {
+        Input::take_click(&mut self.inner)
+    }
+
+    /// Drain the one-shot value-changed flag.
+    pub fn take_change(&mut self) -> bool {
+        Input::take_change(&mut self.inner)
+    }
+
+    /// The widget's value serialized to a string (config writes, context-menu Copy).
+    pub fn get_value_string(&self) -> Option<String> {
+        Input::value_string(&self.inner)
+    }
+
+    /// Parse and apply a value string; returns whether the value changed.
+    pub fn set_value_string(&mut self, val: &str) -> bool {
+        Input::set_value_string(&mut self.inner, val)
+    }
+
+    /// The widget's value as an integer.
+    pub fn value(&self) -> i32 {
+        Input::value(&self.inner)
+    }
+
+    /// Selection state pushed in by list/row hosts.
+    pub fn set_selected(&mut self, selected: bool) {
+        Input::set_selected(&mut self.inner, selected)
+    }
+
+    /// Text-content mutation: keep the base copy and the widget's own copy
+    /// ([`Paint::sync_label`]) in step, like `set_label`.
+    pub fn set_text(&mut self, text: &str) {
+        self.base.label = Some(text.to_string());
+        Paint::sync_label(&mut self.inner, text);
+    }
+
     /// The rect the wrapped widget paints into: the widget's rect minus the detached-label
     /// region at the top (zero inset when there is no label, or when the widget draws its label
     /// inline — `Widget::label_offset` / [`Layout::inline_label`]).
@@ -1144,13 +1186,6 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
         std::any::type_name::<W>().split("::").last().unwrap_or("Widget")
     }
 
-    /// Text-content mutation (legacy `WidgetHost::set_text` wrote only `base.label`): keep the base
-    /// copy and the widget's own copy ([`Paint::sync_label`]) in step, like `set_label`.
-    fn set_text(&mut self, text: &str) {
-        self.base.label = Some(text.to_string());
-        Paint::sync_label(&mut self.inner, text);
-    }
-
     // --- Paint concern -> `Paint` ---
     fn color(&self) -> [f32; 4] {
         Paint::color(&self.inner)
@@ -1360,24 +1395,6 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
     fn blocks_backplate_drag(&self) -> bool {
         Input::blocks_backplate_drag(&self.inner)
     }
-    fn take_click(&mut self) -> bool {
-        Input::take_click(&mut self.inner)
-    }
-    fn take_change(&mut self) -> bool {
-        Input::take_change(&mut self.inner)
-    }
-    fn get_value_string(&self) -> Option<String> {
-        Input::value_string(&self.inner)
-    }
-    fn set_value_string(&mut self, val: &str) -> bool {
-        Input::set_value_string(&mut self.inner, val)
-    }
-    fn value(&self) -> i32 {
-        Input::value(&self.inner)
-    }
-    fn set_selected(&mut self, selected: bool) {
-        Input::set_selected(&mut self.inner, selected)
-    }
     fn context_action(&mut self, action: crate::widget::ContextAction) -> bool {
         Input::context_action(&mut self.inner, action)
     }