GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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)
}