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

commita975144590ce2cef3a3bcf8223b209c9c8b7517c
parentd2c7bccd2d
authorLucas Galante <[email protected]>
date2026-07-08 08:40
feat(widget): migrate Separator, StatusDot, UsageBar leaves (Phase 5d)

Adapter machinery added for this round: Deref/DerefMut to the wrapped
widget (call sites keep dot.set_status(..)); per-prim reverse bridges
(Quad -> extra_quads, RoundedRect -> all_rounded_quads, Circle/Arc ->
extra_circles/extra_arcs) so apps reading both all_quads and
all_rounded_quads draw each prim once; Input::blocks_backplate_drag;
and mirrored by-value builders (Adapted<UsageBar>::with_colors) since
builders cannot flow through Deref.

Deliberate behavior fix: legacy StatusDot emitted zero geometry on
every render path (probe-confirmed), so the settings Processes-page
dots were invisible. The narrow Paint default emits the color quad;
the dots now render — verified in the live app's render dump (10x10
rects in exact status colors). UsageBar verified byte-identical in the
same dump. Separator's rect moved from public fields onto the adapter
base (status-interface rotation loop updated in its own repo).

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

 docs/rfc-core-rebuild.md         |  16 +++++++
 src/widget/display/separator.rs  |  62 +++++++++++++++---------
 src/widget/display/status_dot.rs |  77 ++++++++++++++++++++++++-----
 src/widget/display/usage_bar.rs  |  85 +++++++++++++++++++++++---------
 src/widget/model.rs              | 101 ++++++++++++++++++++++++++++++++++-----
 5 files changed, 273 insertions(+), 68 deletions(-)

diff --git a/docs/rfc-core-rebuild.md b/docs/rfc-core-rebuild.md
index bd165ff..477887b 100644
--- a/docs/rfc-core-rebuild.md
+++ b/docs/rfc-core-rebuild.md
@@ -385,6 +385,22 @@ Constraint respected: **each crate still builds standalone** — the new core is
     the demo on the live compositor (via `ccectl center-window` + `grim`); an A/B pixel diff of
     the demo against the pre-migration build showed the two frames identical except a 19×20
     compositor corner artifact — zero differing pixels at any widget. 141 tests pass.
+  - **5d — Leaf sweep: `Separator`, `StatusDot`, `UsageBar`. DONE (runtime-verified via the
+    settings app's render stream).** New adapter machinery this round: `Deref`/`DerefMut` to the
+    wrapped widget (call sites keep `dot.set_status(..)` / `bar.value`); per-prim reverse
+    bridges (`Prim::Quad` → `extra_quads`, `RoundedRect` → `all_rounded_quads`, `Circle`/`Arc` →
+    `extra_circles`/`extra_arcs`) so apps reading BOTH `all_quads` and `all_rounded_quads` draw
+    each prim exactly once; `Input::blocks_backplate_drag`; and the mirrored-by-value-builder
+    pattern (`Adapted<UsageBar>::with_colors`) since builders can't flow through `Deref`.
+    `Separator` had no `Widget` base (public x/y/w/h fields) — its rect now lives on the adapter
+    base, and cce-status-interface's rotation loop was updated to transpose via `rect`/`set_rect`.
+    **One deliberate behavior fix:** legacy `StatusDot` emitted **zero** geometry on every render
+    path (probe-confirmed — `render_widget` reads only `all_quads`/`all_rounded_quads`, both
+    empty for it), so the Processes-page dots were invisible; the narrow `Paint` default emits
+    the color quad, and the dots now render (verified in the live app's render dump: 10×10 rects
+    in exact status colors). UsageBar verified byte-identical in the same dump (bg+fill rects at
+    the exact `with_colors` colors). 147 tests pass; status-interface, system-settings, and
+    test-interface all build.
   - **Still to do:** migrate remaining widgets
     off `impl Element` onto the narrow traits (per-widget, Phase 6 flavour); replace the 8 live
     `as_*_controller` downcast pairs (called by `cce-designer`, `cce-test-interface`, and ~10
diff --git a/src/widget/display/separator.rs b/src/widget/display/separator.rs
index 92f7875..5bb38cb 100644
--- a/src/widget/display/separator.rs
+++ b/src/widget/display/separator.rs
@@ -1,40 +1,56 @@
-use crate::widget::*;
+//! Narrow-trait separator line (Phase 5c leaf sweep). The legacy struct carried its own public
+//! x/y/w/h fields instead of a `Widget` base; the rect now lives on the [`Adapted`] base, so
+//! callers position it via `set_rect`/`rect` (cce-status-interface's rotation loop was updated
+//! accordingly).
+
+use crate::widget::{Adapted, Element, Input, Layout, Paint};
 
 #[derive(Debug, Clone)]
 pub struct Separator {
-    pub x: f32,
-    pub y: f32,
-    pub w: f32,
-    pub h: f32,
     pub color: [f32; 4],
 }
 
 impl Separator {
-    pub fn new(x: f32, y: f32, w: f32, h: f32, color: [f32; 4]) -> Self {
-        Self { x, y, w, h, color }
+    pub fn new(x: f32, y: f32, w: f32, h: f32, color: [f32; 4]) -> Adapted<Separator> {
+        let mut sep = Adapted::new(Separator { color });
+        Element::set_rect(&mut sep, x, y, w, h);
+        sep
     }
 }
 
-impl Element for Separator {
-    fn rect(&self) -> (f32, f32, f32, f32) {
-        (self.x, self.y, self.w, self.h)
-    }
-    fn blocks_backplate_drag(&self) -> bool { false }
-    fn as_ptr(&self) -> *mut (dyn Element + 'static) {
-        self as *const Self as *mut Self as *mut (dyn Element + 'static)
+impl Layout for Separator {}
+
+impl Paint for Separator {
+    fn color(&self) -> [f32; 4] {
+        self.color
     }
-    fn as_ptr_mut(&mut self) -> *mut (dyn Element + 'static) {
-        self as *mut Self as *mut (dyn Element + 'static)
+}
+
+impl Input for Separator {
+    fn blocks_backplate_drag(&self) -> bool {
+        false
     }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
 
-    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
-        self.x = x;
-        self.y = y;
-        self.w = w;
-        self.h = h;
+    #[test]
+    fn constructor_places_the_rect_and_bridge_emits_it() {
+        let sep = Separator::new(100.0, 0.0, 1.0, 24.0, [0.3, 0.3, 0.3, 1.0]);
+        assert_eq!(Element::rect(&sep), (100.0, 0.0, 1.0, 24.0));
+        assert_eq!(Element::color(&sep), [0.3, 0.3, 0.3, 1.0]);
+        assert_eq!(Element::extra_quads(&sep), vec![(100.0, 0.0, 1.0, 24.0, [0.3, 0.3, 0.3, 1.0])]);
+        assert!(!Element::blocks_backplate_drag(&sep));
     }
 
-    fn color(&self) -> [f32; 4] {
-        self.color
+    /// The status bar's vertical-rotation pattern, post-migration: transpose via rect/set_rect.
+    #[test]
+    fn rotation_via_set_rect() {
+        let mut sep = Separator::new(100.0, 0.0, 1.0, 24.0, [0.3, 0.3, 0.3, 1.0]);
+        let (x, y, w, h) = Element::rect(&sep);
+        Element::set_rect(&mut sep, y, x, h, w);
+        assert_eq!(Element::rect(&sep), (0.0, 100.0, 24.0, 1.0));
     }
 }
diff --git a/src/widget/display/status_dot.rs b/src/widget/display/status_dot.rs
index cd92b50..77f0c64 100644
--- a/src/widget/display/status_dot.rs
+++ b/src/widget/display/status_dot.rs
@@ -1,4 +1,13 @@
-use crate::widget::*;
+//! Narrow-trait status dot (Phase 5c leaf sweep).
+//!
+//! **Deliberate behavior fix:** the legacy `Element` impl only set `color()` and never emitted
+//! geometry on any render path (`all_quads` and `all_rounded_quads` were both empty for it, and
+//! `render_widget` never reads `color()` directly), so the dot was **invisible** — a probe test
+//! against the legacy widget confirmed zero rects emitted through `render_widget`. The narrow
+//! [`Paint`] default emits the color quad, so the dot now actually shows. The probe test at the
+//! bottom documents the fix.
+
+use crate::widget::{Adapted, Input, Layout, Paint};
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum DotStatus {
@@ -10,16 +19,12 @@ pub enum DotStatus {
 
 #[derive(Debug, Clone)]
 pub struct StatusDot {
-    base: Widget,
     pub status: DotStatus,
 }
 
 impl StatusDot {
-    pub fn new(status: DotStatus) -> Self {
-        Self {
-            base: Widget::new(),
-            status,
-        }
+    pub fn new(status: DotStatus) -> Adapted<StatusDot> {
+        Adapted::new(StatusDot { status })
     }
 
     pub fn set_status(&mut self, status: DotStatus) {
@@ -27,12 +32,12 @@ impl StatusDot {
     }
 }
 
-impl Element for StatusDot {
-    crate::impl_widget_base!(StatusDot);
-    fn blocks_backplate_drag(&self) -> bool { false }
-    fn highlight_quad(&self, _ctx: &UiContext) -> Option<(f32, f32, f32, f32, [f32; 4])>{ None }
+impl Layout for StatusDot {}
 
+impl Paint for StatusDot {
     fn color(&self) -> [f32; 4] {
+        // `Paint::paint`'s default emits this as a plain quad — which is the fix: the legacy
+        // impl never got its color onto any render path.
         match self.status {
             DotStatus::Active => [0.20, 0.70, 0.35, 1.0],
             DotStatus::Inactive => [0.50, 0.50, 0.55, 1.0],
@@ -41,3 +46,53 @@ impl Element for StatusDot {
         }
     }
 }
+
+impl Input for StatusDot {
+    fn blocks_backplate_drag(&self) -> bool {
+        false
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::widget::{Element, UiContext};
+
+    #[test]
+    fn emits_its_color_quad_through_the_bridge() {
+        let mut dot = StatusDot::new(DotStatus::Warning);
+        Element::set_rect(&mut dot, 5.0, 6.0, 10.0, 10.0);
+        assert_eq!(
+            Element::extra_quads(&dot),
+            vec![(5.0, 6.0, 10.0, 10.0, [0.90, 0.60, 0.10, 1.0])],
+        );
+        // Drags pass through, as legacy declared.
+        assert!(!Element::blocks_backplate_drag(&dot));
+        // State mutation through Deref, as call sites write it.
+        dot.set_status(DotStatus::Error);
+        assert_eq!(dot.status, DotStatus::Error);
+    }
+
+    /// Documents the behavior fix: the legacy `StatusDot` emitted **zero** rects through
+    /// `render_widget` (probe run against the pre-migration widget), i.e. the dot was invisible
+    /// wherever it was used. The migrated widget emits exactly one.
+    #[test]
+    fn render_widget_now_draws_the_dot() {
+        struct Probe {
+            rects: Vec<(f32, f32, f32, f32, [f32; 4])>,
+        }
+        impl crate::layout::RenderTarget for Probe {
+            fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32) {
+                self.rects.push((x, y, w, h, color));
+            }
+            fn text(&mut self, _c: &str, _x: f32, _y: f32, _s: f32, _col: [f32; 4]) {}
+        }
+
+        let mut ctx = UiContext::new();
+        let mut probe = Probe { rects: Vec::new() };
+        let mut dot = StatusDot::new(DotStatus::Active);
+        crate::layout::render_widget(&mut probe, &mut dot, 10.0, 10.0, 10.0, 10.0, &mut ctx);
+        assert_eq!(probe.rects.len(), 1, "the dot is visible now (legacy emitted 0 here)");
+        assert_eq!(probe.rects[0].4, [0.20, 0.70, 0.35, 1.0], "active-status green");
+    }
+}
diff --git a/src/widget/display/usage_bar.rs b/src/widget/display/usage_bar.rs
index 2732914..e8c6071 100644
--- a/src/widget/display/usage_bar.rs
+++ b/src/widget/display/usage_bar.rs
@@ -1,44 +1,85 @@
-use crate::widget::*;
+//! Narrow-trait usage bar (Phase 5c leaf sweep). Legacy geometry lived in `extra_quads` (plain
+//! bg + fill quads); the narrow [`Paint::paint`] emits the same two quads, which reach legacy
+//! render loops byte-identically through the adapter's `extra_quads` reverse bridge.
+
+use crate::scene::layout::Rect;
+use crate::scene::paint::PaintCtx;
+use crate::widget::{Adapted, Input, Layout, Paint};
 
 #[derive(Debug, Clone)]
 pub struct UsageBar {
-    base: Widget,
     pub value: f32, // 0.0 to 1.0
     pub fill_color: [f32; 4],
     pub bg_color: [f32; 4],
 }
 
 impl UsageBar {
-    pub fn new(value: f32) -> Self {
-        Self {
-            base: Widget::new(),
+    pub fn new(value: f32) -> Adapted<UsageBar> {
+        Adapted::new(UsageBar {
             value: value.clamp(0.0, 1.0),
             fill_color: [0.30, 0.50, 0.32, 1.0], // green-ish
-            bg_color: [0.15, 0.15, 0.24, 1.0], // dark-ish
-        }
+            bg_color: [0.15, 0.15, 0.24, 1.0],   // dark-ish
+        })
+    }
+
+    pub fn set_value(&mut self, value: f32) {
+        self.value = value.clamp(0.0, 1.0);
     }
-    
+}
+
+/// By-value builders can't flow through `Deref`, so the legacy `with_colors` chain
+/// (`UsageBar::new(v).with_colors(..)`) is mirrored on the wrapped type.
+impl Adapted<UsageBar> {
     pub fn with_colors(mut self, fill: [f32; 4], bg: [f32; 4]) -> Self {
         self.fill_color = fill;
         self.bg_color = bg;
         self
     }
-    
-    pub fn set_value(&mut self, value: f32) {
-        self.value = value.clamp(0.0, 1.0);
+}
+
+impl Layout for UsageBar {}
+
+impl Paint for UsageBar {
+    fn color(&self) -> [f32; 4] {
+        [0.0, 0.0, 0.0, 0.0]
+    }
+
+    fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
+        ctx.quad(rect, self.bg_color);
+        ctx.quad(Rect { width: rect.width * self.value, ..rect }, self.fill_color);
     }
 }
 
-impl Element for UsageBar {
-    crate::impl_widget_base!(UsageBar);
-    fn color(&self) -> [f32; 4] { [0.0, 0.0, 0.0, 0.0] }
-    fn highlight_quad(&self, _ctx: &UiContext) -> Option<(f32, f32, f32, f32, [f32; 4])>{ None }
-
-    fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
-        let (x, y, w, h) = self.rect();
-        vec![
-            (x, y, w, h, self.bg_color),
-            (x, y, w * self.value, h, self.fill_color),
-        ]
+impl Input for UsageBar {}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::widget::Element;
+
+    /// Byte-identical to the legacy `extra_quads` override: full-width bg quad, then a fill quad
+    /// scaled by the clamped value.
+    #[test]
+    fn bridge_matches_legacy_extra_quads() {
+        let mut bar = UsageBar::new(0.5).with_colors([0.1, 0.2, 0.3, 1.0], [0.4, 0.5, 0.6, 1.0]);
+        Element::set_rect(&mut bar, 12.0, 30.0, 200.0, 8.0);
+        assert_eq!(
+            Element::extra_quads(&bar),
+            vec![
+                (12.0, 30.0, 200.0, 8.0, [0.4, 0.5, 0.6, 1.0]),
+                (12.0, 30.0, 100.0, 8.0, [0.1, 0.2, 0.3, 1.0]),
+            ],
+        );
+        // Nothing leaks onto the rounded path (apps read both getters).
+        assert!(Element::all_rounded_quads(&bar, &crate::widget::UiContext::new()).is_empty());
+    }
+
+    #[test]
+    fn value_clamps_on_both_paths() {
+        let bar = UsageBar::new(7.0);
+        assert_eq!(bar.value, 1.0, "constructor clamps");
+        let mut bar = UsageBar::new(0.5);
+        bar.set_value(-3.0);
+        assert_eq!(bar.value, 0.0, "setter clamps (through Deref)");
     }
 }
diff --git a/src/widget/model.rs b/src/widget/model.rs
index e1742a1..5857bfa 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -109,6 +109,13 @@ pub trait Input {
     fn on_event(&mut self, _event: &Event, _rect: Rect) -> bool {
         false
     }
+
+    /// Whether pressing on this widget blocks dragging the movable backplate under it. Passive
+    /// display widgets (separators, status dots) return `false` so drags pass through them.
+    /// Default: `true`, matching the legacy `Element` default.
+    fn blocks_backplate_drag(&self) -> bool {
+        true
+    }
 }
 
 /// Wraps a narrow-trait widget `W` so it lives in the legacy `*mut dyn Element` tree. Carries the
@@ -161,6 +168,34 @@ impl<W> Adapted<W> {
     }
 }
 
+impl<W: Paint> Adapted<W> {
+    /// Run the wrapped widget's [`Paint::paint`] against its content rect and return the emitted
+    /// prims — the shared source for the reverse bridges (`extra_quads`, `all_rounded_quads`,
+    /// `extra_circles`, `extra_arcs`) that legacy render loops read.
+    fn painted_prims(&self) -> Vec<Prim> {
+        let mut pc = PaintCtx::new();
+        Paint::paint(&self.inner, self.content_rect(), &mut pc);
+        pc.finish().items.into_iter().map(|item| item.prim).collect()
+    }
+}
+
+/// Auto-deref to the wrapped widget, so call sites keep using a migrated widget's own state and
+/// methods directly (`dot.status`, `dot.set_status(..)`) without knowing about the wrapper.
+/// (By-value builders can't flow through `Deref` — those get mirrored per-widget, like
+/// `with_label` here or `UsageBar::with_colors`.)
+impl<W> std::ops::Deref for Adapted<W> {
+    type Target = W;
+    fn deref(&self) -> &W {
+        &self.inner
+    }
+}
+
+impl<W> std::ops::DerefMut for Adapted<W> {
+    fn deref_mut(&mut self) -> &mut W {
+        &mut self.inner
+    }
+}
+
 impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
     fn base(&self) -> Option<&Widget> {
         Some(&self.base)
@@ -243,26 +278,64 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
         }
     }
 
-    /// Reverse bridge for legacy render loops that read geometry via `all_rounded_quads` (e.g.
-    /// cce-test-interface's view path): the wrapped widget's [`Paint::paint`] output, converted
-    /// back to the legacy tuples. Covers the widget's OWN geometry only — adapted widgets are
-    /// leaves for now; the recursive child walk belongs to `scene::painter`.
+    // --- Reverse bridges: [`Paint::paint`] output converted back to the legacy geometry
+    // getters external render loops read (cce-test-interface's `all_*` calls, `render_widget`'s
+    // `all_quads` loop, the demo's `extra_*` loops). Each prim kind maps to the getter legacy
+    // widgets used for it — plain quads to `extra_quads` (→ `all_quads`), rounded to
+    // `all_rounded_quads` — so apps that read BOTH getters draw each prim exactly once. Covers
+    // the widget's OWN geometry only: adapted widgets are leaves for now; recursion belongs to
+    // `scene::painter`.
+
     fn all_rounded_quads(&self, _ctx: &UiContext) -> Vec<(f32, f32, f32, f32, f32, [f32; 4], (bool, bool, bool, bool))> {
         if !self.visible() {
             return Vec::new();
         }
-        let mut pc = PaintCtx::new();
-        Paint::paint(&self.inner, self.content_rect(), &mut pc);
-        pc.finish()
-            .items
+        self.painted_prims()
             .into_iter()
-            .filter_map(|item| match item.prim {
+            .filter_map(|prim| match prim {
                 Prim::RoundedRect { rect, radius, corners, color } => {
                     Some((rect.x, rect.y, rect.width, rect.height, radius, color, corners))
                 }
-                // Radius 0 routes through the backend's plain-quad branch, byte-for-byte.
-                Prim::Quad { rect, color } => {
-                    Some((rect.x, rect.y, rect.width, rect.height, 0.0, color, (false, false, false, false)))
+                _ => None,
+            })
+            .collect()
+    }
+
+    fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+        if !self.visible() {
+            return Vec::new();
+        }
+        self.painted_prims()
+            .into_iter()
+            .filter_map(|prim| match prim {
+                Prim::Quad { rect, color } => Some((rect.x, rect.y, rect.width, rect.height, color)),
+                _ => None,
+            })
+            .collect()
+    }
+
+    fn extra_circles(&self) -> Vec<(f32, f32, f32, [f32; 4])> {
+        if !self.visible() {
+            return Vec::new();
+        }
+        self.painted_prims()
+            .into_iter()
+            .filter_map(|prim| match prim {
+                Prim::Circle { cx, cy, radius, color } => Some((cx, cy, radius, color)),
+                _ => None,
+            })
+            .collect()
+    }
+
+    fn extra_arcs(&self) -> Vec<(f32, f32, f32, f32, f32, f32, [f32; 4])> {
+        if !self.visible() {
+            return Vec::new();
+        }
+        self.painted_prims()
+            .into_iter()
+            .filter_map(|prim| match prim {
+                Prim::Arc { cx, cy, radius, thickness, start, end, color } => {
+                    Some((cx, cy, radius, thickness, start, end, color))
                 }
                 _ => None,
             })
@@ -270,6 +343,10 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
     }
 
     // --- Input concern -> `Input` ---
+    fn blocks_backplate_drag(&self) -> bool {
+        Input::blocks_backplate_drag(&self.inner)
+    }
+
     fn hit_test(&self, px: f32, py: f32, ctx: &UiContext) -> bool {
         // Preserve the legacy occlusion check (a covering layer swallows the hit), then delegate
         // the geometric test to the narrow trait instead of the row/label-offset machinery.