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