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