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

commit7776a2bcf9f7d23f58e203a0752c08b5c00631e3
parent865841686e
authorLucas Galante <[email protected]>
date2026-07-08 12:00
feat(widget): migrate InteractiveListItem (Phase 5j)

Button-pattern press/release with themed selected/hover/press overlays
and title/subtitle text. Verified in the live settings app's render
dump: service rows and status dots render at legacy positions.

StatusBar surveyed and deferred: it reads its parent's backplate
state/rect/radius at paint time — belongs with the container/children
design, not the leaf recipe.

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

 docs/rfc-core-rebuild.md        |   7 ++
 src/widget/display/list_item.rs | 170 ++++++++++++++++++----------------------
 2 files changed, 85 insertions(+), 92 deletions(-)

diff --git a/docs/rfc-core-rebuild.md b/docs/rfc-core-rebuild.md
index cd0fc66..f5395cd 100644
--- a/docs/rfc-core-rebuild.md
+++ b/docs/rfc-core-rebuild.md
@@ -475,6 +475,13 @@ Constraint respected: **each crate still builds standalone** — the new core is
     raw-pointer parent/children fields. Skipped for later: `PreviewState` (needs per-label fonts
     on the `Text` prim), `StatusBar` (bigger custom surface), Float3/LayoutPreview (time).
     156 tests pass; workspace builds; test-interface pixel-diff vs the 5h baseline: 1 pixel.
+  - **5j — `InteractiveListItem`. DONE (verified in the live settings render dump: service-row
+    titles/subtitles + themed overlays through the prim bridge).** Button-pattern press/release
+    with themed selected/hover/press overlays. `StatusBar` was surveyed and DEFERRED: it is
+    parent-coupled (reads its parent's backplate state/rect/radius at paint time and owns
+    `parent`/`set_parent`) — that belongs with the container/children design, not the leaf
+    recipe. Also still pending from the leaf tier: Float3, LayoutPreview (time), PreviewState
+    (needs per-label fonts on `Prim::Text`).
   - **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/list_item.rs b/src/widget/display/list_item.rs
index 00a0998..a1a398a 100644
--- a/src/widget/display/list_item.rs
+++ b/src/widget/display/list_item.rs
@@ -1,6 +1,11 @@
+//! `TextItem` (retained glyphon buffer holder, unchanged) and the narrow-trait
+//! `InteractiveListItem` (Phase 5j): Button-style press/release with themed
+//! selected/hover/press overlays and title/subtitle text.
+
 use crate::colors;
-use crate::widget::*;
-use crate::widget::display::TextLabel;
+use crate::scene::layout::Rect;
+use crate::scene::paint::PaintCtx;
+use crate::widget::{Adapted, ElementState, Event, EventCtx, Input, Layout, MouseButton, Paint};
 
 #[derive(Debug, Clone)]
 pub struct TextItem {
@@ -23,41 +28,30 @@ impl TextItem {
         bounds: Option<[f32; 4]>,
     ) -> Self {
         let buffer = crate::backend::window_runner::get_text_buffer(fs, text, size, font);
-        Self {
-            buffer,
-            x,
-            y,
-            color,
-            bounds,
-        }
+        Self { buffer, x, y, color, bounds }
     }
 }
 
 #[derive(Debug, Clone)]
 pub struct InteractiveListItem {
-    base: Widget,
     pub title: String,
     pub subtitle: Option<String>,
     pub selected: bool,
     pub pressed: bool,
     pub just_clicked: bool,
+    hovered: bool,
 }
 
 impl InteractiveListItem {
-    pub fn new(title: &str) -> Self {
-        Self {
-            base: Widget::new(),
+    pub fn new(title: &str) -> Adapted<InteractiveListItem> {
+        Adapted::new(InteractiveListItem {
             title: title.to_string(),
             subtitle: None,
             selected: false,
             pressed: false,
             just_clicked: false,
-        }
-    }
-
-    pub fn with_subtitle(mut self, subtitle: &str) -> Self {
-        self.subtitle = Some(subtitle.to_string());
-        self
+            hovered: false,
+        })
     }
 
     pub fn set_selected(&mut self, selected: bool) {
@@ -65,102 +59,94 @@ impl InteractiveListItem {
     }
 }
 
-impl Element for InteractiveListItem {
-    crate::impl_widget_base!(InteractiveListItem);
-    fn highlight_quad(&self, _ctx: &UiContext) -> Option<(f32, f32, f32, f32, [f32; 4])>{ None }
+impl Adapted<InteractiveListItem> {
+    pub fn with_subtitle(mut self, subtitle: &str) -> Self {
+        self.subtitle = Some(subtitle.to_string());
+        self
+    }
+}
 
-    fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
-        let col = self.color();
-        if col[3] > 0.0 {
-            let (x, y, w, h) = self.rect();
-            vec![(x, y, w, h, col)]
-        } else {
-            vec![]
-        }
+impl Layout for InteractiveListItem {
+    fn inline_label(&self) -> bool {
+        true
     }
+}
 
+impl Paint for InteractiveListItem {
     fn color(&self) -> [f32; 4] {
         let theme = colors::active_theme();
         if self.selected {
             let mut base_color = colors::highlight_primary_color();
-            if self.pressed { base_color[3] = (base_color[3] + theme.press_overlay[3]).min(1.0); }
-            else if self.base.hovered { base_color[3] = (base_color[3] + theme.hover_overlay[3]).min(1.0); }
-            base_color
-        } else {
             if self.pressed {
-                let mut base_color = theme.surface_bg;
                 base_color[3] = (base_color[3] + theme.press_overlay[3]).min(1.0);
-                base_color
-            } else if self.base.hovered {
-                let mut base_color = theme.surface_bg;
+            } else if self.hovered {
                 base_color[3] = (base_color[3] + theme.hover_overlay[3]).min(1.0);
-                base_color
-            } else {
-                [0.0, 0.0, 0.0, 0.0]
             }
+            base_color
+        } else if self.pressed {
+            let mut base_color = theme.surface_bg;
+            base_color[3] = (base_color[3] + theme.press_overlay[3]).min(1.0);
+            base_color
+        } else if self.hovered {
+            let mut base_color = theme.surface_bg;
+            base_color[3] = (base_color[3] + theme.hover_overlay[3]).min(1.0);
+            base_color
+        } else {
+            [0.0, 0.0, 0.0, 0.0]
         }
     }
 
-    fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ctx: &mut UiContext) -> bool {
-        if button != MouseButton::Left { return false; }
-        match state {
-            ElementState::Pressed => {
-                if self.hit_test(px, py, ctx) {
-                    self.pressed = true;
-                    return true;
-                }
-            }
-            ElementState::Released => {
-                if self.pressed && self.hit_test(px, py, ctx) {
-                    self.just_clicked = true;
-                }
-                let was = self.pressed;
-                self.pressed = false;
-                return was;
-            }
+    fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
+        let col = self.color();
+        if col[3] > 0.0 {
+            ctx.quad(rect, col);
         }
-        false
-    }
 
-    fn take_click(&mut self) -> bool {
-        if self.just_clicked { self.just_clicked = false; true } else { false }
-    }
-
-    fn text_labels(&self) -> Vec<TextLabel> {
-        let (x, y, _w, h) = self.rect();
-        let mut labels = Vec::new();
-        
+        let (x, y, h) = (rect.x, rect.y, rect.height);
         let title_y = if self.subtitle.is_some() {
             crate::layout::align_text_y(y, h, 22.0, 0.0)
         } else {
             crate::layout::align_text_y(y, h, 12.0, 0.0)
         };
-
-        let font_col = colors::list_font_color();
-        let title_col = [
-            (font_col[0] * 255.0) as u8,
-            (font_col[1] * 255.0) as u8,
-            (font_col[2] * 255.0) as u8,
-        ];
-
-        labels.push(TextLabel {
-            text: self.title.clone(),
-            x: x + 8.0,
-            y: title_y,
-            font_size: 12.0,
-            color: title_col,
-        });
-
+        let fc = colors::list_font_color();
+        let title_col = [(fc[0] * 255.0) as u8, (fc[1] * 255.0) as u8, (fc[2] * 255.0) as u8];
+        ctx.text(self.title.clone(), x + 8.0, title_y, 12.0, title_col);
         if let Some(ref sub) = self.subtitle {
-            labels.push(TextLabel {
-                text: sub.clone(),
-                x: x + 8.0,
-                y: title_y + 13.0,
-                font_size: 10.0,
-                color: [140, 140, 153],
-            });
+            ctx.text(sub.clone(), x + 8.0, title_y + 13.0, 10.0, [140, 140, 153]);
+        }
+    }
+}
+
+impl Input for InteractiveListItem {
+    fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
+        match event {
+            Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, .. } => {
+                self.pressed = true;
+                true
+            }
+            Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, x, y, .. } => {
+                if self.pressed && self.hit(ectx.rect, *x, *y) {
+                    self.just_clicked = true;
+                }
+                std::mem::take(&mut self.pressed)
+            }
+            Event::MouseEnter => {
+                self.hovered = true;
+                false
+            }
+            Event::MouseLeave => {
+                self.hovered = false;
+                false
+            }
+            _ => false,
         }
+    }
+
+    fn take_click(&mut self) -> bool {
+        std::mem::take(&mut self.just_clicked)
+    }
 
-        labels
+    fn set_selected(&mut self, selected: bool) {
+        self.selected = selected;
     }
 }