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

commitd2c7bccd2d1248f7984a4b4917ecef5cb02dbb9a
parent6fd7419e82
authorLucas Galante <[email protected]>
date2026-07-08 08:22
feat(widget): migrate ProgressBar to the narrow traits (Phase 5c)

First real widget off the Element god-trait: ProgressBar implements
only Layout + Paint + Input, and its constructor returns
Adapted<ProgressBar> so construction sites compile unchanged.

The migration teaches Adapted the legacy surface external render loops
actually read: an all_rounded_quads reverse bridge (Paint::paint output
converted to legacy tuples), Paint::corner_style for style-property
painters (transitional), the detached-label convention (set_rect
inflation + with_label + content-rect inset), preferred_height from
intrinsic_size, suppressed highlight_quad, and type_name reporting the
inner type for layout.rs' span-full string matching.

Runtime-verified on the live compositor: cce-test-interface shows the
labeled bar with correct 43% fill via the reverse bridge, and an A/B
pixel diff of the demo against the pre-migration build is identical at
every widget (only a 19x20 compositor corner artifact differs).

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

 docs/rfc-core-rebuild.md           |  18 ++++-
 src/widget/display/progress_bar.rs | 136 +++++++++++++++++++++++++------------
 src/widget/model.rs                | 100 ++++++++++++++++++++++++++-
 3 files changed, 206 insertions(+), 48 deletions(-)

diff --git a/docs/rfc-core-rebuild.md b/docs/rfc-core-rebuild.md
index 5343ba8..bd165ff 100644
--- a/docs/rfc-core-rebuild.md
+++ b/docs/rfc-core-rebuild.md
@@ -369,7 +369,23 @@ Constraint respected: **each crate still builds standalone** — the new core is
     `Clicker` through the *real* `UiContext::propagate_event` router: in-rect click consumed +
     counted, out-of-rect click gated out, hover enter/leave transitions observed on both the
     narrow widget and the base flag. 138 cce-ui tests pass.
-  - **Still to do:** migrate real widgets
+  - **5c — First real widget migrated: `ProgressBar`. DONE (runtime-verified, pixel-identical).**
+    `widget/display/progress_bar.rs` now implements only `Layout` + `Paint` + `Input`;
+    `ProgressBar::new` returns `Adapted<ProgressBar>`, so both construction sites
+    (`cce-ui` demo, `cce-test-interface`, incl. `.with_label`) compile unchanged. The migration
+    forced the adapter to absorb the legacy surface external render loops actually read, all
+    added to `Adapted` in this step: an `all_rounded_quads` **reverse bridge** (the widget's
+    `Paint::paint` output converted back to legacy tuples — cce-test-interface renders via this),
+    `Paint::corner_style` → `corner_radius`/`rounded_corners` (for style-property painters like
+    the demo's `widget_vertices`; transitional, dies with those paths), the detached-label
+    convention (`set_rect` inflation + `with_label` builder + content-rect inset),
+    `preferred_height` ← `intrinsic_size`, `highlight_quad → None` (narrow widgets own their
+    pixels), and `type_name` reporting the *inner* type (layout.rs string-matches
+    `"ProgressBar"` for span-full sizing). **Runtime verification:** ran cce-test-interface and
+    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.
+  - **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
     cce-ui widgets) with a typed message/command channel; delete `Element` + `Adapted` once the
diff --git a/src/widget/display/progress_bar.rs b/src/widget/display/progress_bar.rs
index 1337de2..0907fdf 100644
--- a/src/widget/display/progress_bar.rs
+++ b/src/widget/display/progress_bar.rs
@@ -1,68 +1,116 @@
+//! The first widget migrated off `Element` onto the narrow traits (Phase 5c). `ProgressBar`
+//! implements only [`Layout`] + [`Paint`] + [`Input`]; [`ProgressBar::new`] returns it already
+//! wrapped in [`Adapted`], so construction sites (`Box::new(ProgressBar::new(0.65))`, optionally
+//! `.with_label(..)`) are unchanged by the migration.
+
 use crate::colors;
-use crate::widget::*;
+use crate::scene::layout::{Rect, Size};
+use crate::scene::paint::PaintCtx;
+use crate::widget::{Adapted, Input, Layout, Paint};
 
 pub struct ProgressBar {
-    base: Widget,
-    _value: f32,
+    value: f32,
 }
 
 impl ProgressBar {
-    pub fn new(value: f32) -> Self {
-        Self {
-            base: Widget::new(),
-            _value: value,
-        }
+    pub fn new(value: f32) -> Adapted<ProgressBar> {
+        Adapted::new(ProgressBar { value })
     }
+}
 
-    pub fn with_label(mut self, label: &str) -> Self {
-        self.base.label = Some(label.to_string());
-        self
+impl Layout for ProgressBar {
+    fn intrinsic_size(&self) -> Option<Size> {
+        // Height is the bar's own; width comes from the container (legacy `preferred_height`).
+        Some(Size::new(0.0, crate::layout::progressbar_height()))
     }
 }
 
-impl Element for ProgressBar {
-    crate::impl_widget_base!(ProgressBar);
+impl Paint for ProgressBar {
+    fn color(&self) -> [f32; 4] {
+        colors::progress_bg()
+    }
 
-    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
-        self.base.x = x;
-        self.base.y = y;
-        self.base.w = w;
-        self.base.h = h + self.base.label_offset();
+    fn corner_style(&self) -> Option<(f32, (bool, bool, bool, bool))> {
+        Some((crate::layout::slider_corner_radius(), (true, true, true, true)))
     }
 
-    fn preferred_height(&self) -> Option<f32> {
-        Some(crate::layout::progressbar_height())
+    fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
+        let radius = crate::layout::slider_corner_radius();
+        // Track.
+        ctx.rounded_rect(rect, radius, (true, true, true, true), colors::progress_bg());
+        // Fill.
+        let fill_w = rect.width * self.value.clamp(0.0, 1.0);
+        if fill_w > 0.0 {
+            ctx.rounded_rect(
+                Rect { x: rect.x, y: rect.y, width: fill_w, height: rect.height },
+                radius.min(rect.height / 2.0),
+                (true, true, true, true),
+                colors::progress_fill(),
+            );
+        }
     }
+}
+
+impl Input for ProgressBar {}
 
-    fn highlight_quad(&self, _ctx: &UiContext) -> Option<(f32, f32, f32, f32, [f32; 4])>{ None }
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::widget::{Element, UiContext};
 
-    fn color(&self) -> [f32; 4] { colors::progress_bg() }
+    /// The reverse bridge reproduces the legacy `all_rounded_quads` output: track quad at the
+    /// content rect, fill quad at `w * value` with the radius clamped to half the height.
+    #[test]
+    fn reverse_bridge_matches_legacy_geometry() {
+        let ctx = UiContext::new();
+        let mut bar = ProgressBar::new(0.5);
+        Element::set_rect(&mut bar, 10.0, 20.0, 100.0, 8.0);
 
-    fn corner_radius(&self) -> f32 {
-        crate::layout::slider_corner_radius()
+        let quads = Element::all_rounded_quads(&bar, &ctx);
+        let radius = crate::layout::slider_corner_radius();
+        assert_eq!(quads.len(), 2, "track + fill");
+        assert_eq!(quads[0], (10.0, 20.0, 100.0, 8.0, radius, colors::progress_bg(), (true, true, true, true)));
+        assert_eq!(
+            quads[1],
+            (10.0, 20.0, 50.0, 8.0, radius.min(4.0), colors::progress_fill(), (true, true, true, true)),
+        );
     }
 
-    fn rounded_corners(&self) -> (bool, bool, bool, bool) {
-        (true, true, true, true)
+    /// Value is clamped like the legacy widget: over 1.0 fills the whole track, 0 emits no fill.
+    #[test]
+    fn fill_clamps_to_track() {
+        let ctx = UiContext::new();
+        let mut over = ProgressBar::new(2.0);
+        Element::set_rect(&mut over, 0.0, 0.0, 100.0, 8.0);
+        let quads = Element::all_rounded_quads(&over, &ctx);
+        assert_eq!(quads[1].2, 100.0, "over-1 value fills the whole track");
+
+        let mut empty = ProgressBar::new(0.0);
+        Element::set_rect(&mut empty, 0.0, 0.0, 100.0, 8.0);
+        assert_eq!(Element::all_rounded_quads(&empty, &ctx).len(), 1, "zero value emits track only");
     }
 
-    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 quads = Vec::new();
-        let top = self.base.label_offset();
-        let visual_h = self.base.h - top;
-        let radius = self.corner_radius();
-        
-        // Progress bar background
-        quads.push((self.base.x, self.base.y + top, self.base.w, visual_h, radius, colors::progress_bg(), (true, true, true, true)));
-        
-        // Progress fill
-        let fill_w = self.base.w * self._value.clamp(0.0, 1.0);
-        if fill_w > 0.0 {
-            quads.push((self.base.x, self.base.y + top, fill_w, visual_h, radius.min(visual_h / 2.0), colors::progress_fill(), (true, true, true, true)));
-        }
-        quads
+    /// The detached-label convention survives the adapter: `set_rect` grows the widget by the
+    /// label offset, and painting is inset below the label region (config-independent: the
+    /// expected offset is derived from the observed rect).
+    #[test]
+    fn label_inflates_rect_and_insets_paint() {
+        let ctx = UiContext::new();
+        let mut bar = ProgressBar::new(0.5).with_label("Progress");
+        Element::set_rect(&mut bar, 0.0, 10.0, 100.0, 8.0);
+
+        let (_, y, _, h) = Element::rect(&bar);
+        let offset = h - 8.0;
+        assert!(offset >= 0.0, "rect grew by the label offset");
+        assert_eq!(y, 10.0, "origin is unchanged");
+
+        let quads = Element::all_rounded_quads(&bar, &ctx);
+        assert_eq!(quads[0].1, 10.0 + offset, "track is painted below the label region");
+        assert_eq!(quads[0].3, 8.0, "track keeps the assigned height");
+
+        // preferred_height forwards from the narrow intrinsic size.
+        assert_eq!(Element::preferred_height(&bar), Some(crate::layout::progressbar_height()));
+        // Runtime type-name matching still sees "ProgressBar", not Adapted<..>.
+        assert_eq!(Element::type_name(&bar), "ProgressBar");
     }
 }
diff --git a/src/widget/model.rs b/src/widget/model.rs
index 1cbe300..e1742a1 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -27,7 +27,7 @@
 //! concern follows in its own commit.
 
 use crate::scene::layout::{Rect, Size, Style};
-use crate::scene::paint::PaintCtx;
+use crate::scene::paint::{PaintCtx, Prim};
 use crate::widget::{Element, Event, UiContext, Widget, WidgetId};
 
 /// Layout inputs for the scene layout engine — the RFC's `Widget` concern, named `Layout` here to
@@ -77,6 +77,15 @@ pub trait Paint {
     fn clips_children(&self) -> bool {
         false
     }
+
+    /// Corner rounding `(radius, per-corner flags)` of the widget's background. **Transitional:**
+    /// this exists only for legacy render paths that draw widget backgrounds themselves from
+    /// style properties (`widget_vertices` / `push_widget_vertices` readers of
+    /// `Element::corner_radius` + `rounded_corners`) — the widget's real geometry is whatever
+    /// [`paint`](Paint::paint) emits. Dies with those paths. Default: sharp corners.
+    fn corner_style(&self) -> Option<(f32, (bool, bool, bool, bool))> {
+        None
+    }
 }
 
 /// The input concern — hit-testing and event handling against the laid-out rect. Mirrors the
@@ -130,6 +139,26 @@ impl<W> Adapted<W> {
     pub fn id(&self) -> WidgetId {
         self.base.id()
     }
+
+    /// Attach a detached control label (drawn above the widget by the shared `text_labels`
+    /// machinery). Mirrors the `with_label` builders legacy control widgets carry, so
+    /// construction sites keep their shape when a widget migrates.
+    pub fn with_label(mut self, label: &str) -> Self {
+        self.base.label = Some(label.to_string());
+        self
+    }
+
+    /// 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 — `Widget::label_offset`).
+    fn content_rect(&self) -> Rect {
+        let top = self.base.label_offset();
+        Rect {
+            x: self.base.x,
+            y: self.base.y + top,
+            width: self.base.w,
+            height: (self.base.h - top).max(0.0),
+        }
+    }
 }
 
 impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
@@ -163,6 +192,35 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
         Layout::layout_children(&self.inner)
     }
 
+    // --- Legacy structural conventions the adapter owns on the widget's behalf ---
+
+    /// The detached-label convention shared by legacy control widgets: the widget grows past the
+    /// rect its parent assigns to make room for the label above (`ProgressBar`/`Slider`-style
+    /// `set_rect` overrides). Zero-cost when no label is set.
+    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
+        self.base.x = x;
+        self.base.y = y;
+        self.base.w = w;
+        self.base.h = h + self.base.label_offset();
+    }
+
+    fn preferred_height(&self) -> Option<f32> {
+        Layout::intrinsic_size(&self.inner).map(|s| s.height)
+    }
+
+    /// Narrow widgets own every pixel they draw through [`Paint::paint`]; the legacy shared
+    /// hover-highlight overlay is suppressed (matching what most control widgets' `None`
+    /// overrides do today).
+    fn highlight_quad(&self, _ctx: &UiContext) -> Option<(f32, f32, f32, f32, [f32; 4])> {
+        None
+    }
+
+    /// Report the *inner* type's name, not `Adapted<W>`: runtime type-name matching (e.g.
+    /// `layout.rs`' span-full widget list) must keep seeing the widget it knows.
+    fn type_name(&self) -> &'static str {
+        std::any::type_name::<W>().split("::").last().unwrap_or("Widget")
+    }
+
     // --- Paint concern -> `Paint` ---
     fn color(&self) -> [f32; 4] {
         Paint::color(&self.inner)
@@ -170,9 +228,45 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
     fn clips_children(&self) -> bool {
         Paint::clips_children(&self.inner)
     }
+    fn corner_radius(&self) -> f32 {
+        // 12.0 mirrors the `Element` default for widgets without a corner style.
+        Paint::corner_style(&self.inner).map_or(12.0, |(r, _)| r)
+    }
+    fn rounded_corners(&self) -> (bool, bool, bool, bool) {
+        Paint::corner_style(&self.inner).map_or((false, false, false, false), |(_, c)| c)
+    }
     fn paint_self(&self, _ui: &UiContext, ctx: &mut PaintCtx) {
-        let (x, y, w, h) = self.rect();
-        Paint::paint(&self.inner, Rect { x, y, width: w, height: h }, ctx);
+        Paint::paint(&self.inner, self.content_rect(), ctx);
+        // The detached label, exactly as the legacy default `paint_self` emits it.
+        for tl in self.text_labels() {
+            ctx.text(tl.text, tl.x, tl.y, tl.font_size, tl.color);
+        }
+    }
+
+    /// 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`.
+    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
+            .into_iter()
+            .filter_map(|item| match item.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()
     }
 
     // --- Input concern -> `Input` ---