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

commit994da2d0cc0f295d0ece74f049de967a3dcd08d3
parentea9841ffab
authorLucas Galante <[email protected]>
date2026-07-12 11:12
refactor(widget)!: ButtonStrip onto the narrow traits — Element has ONE implementor (Phase 6az complete)

The last production raw impl converts to Layout/Paint/Input; the embedders'
fields become Adapted<ButtonStrip> (MenuBar's menus, Paginator's
sidebar_menu). Field and inherent access ride Deref; Element dispatch rides
the wrapper — except two name collisions where the wrapper's Element methods
shadow the model's richer inherent ones (set_selected(bool) vs
Option<usize>, take_click() bool vs Option<usize>): those sites go through
inner_mut(). Paginator's raw-pointer registration becomes as_ptr_mut/id;
gates_presses stays off (a press on another tab must land while a dropdown
popover covers the strip, as the ungated legacy dispatch allowed); the
config-watch label regeneration moves to Input::tick, the rect-change
regeneration to rect_assigned, and the rotated-SVG tab quads + state tints +
labels into one paint.

With this, every widget in the workspace — cce-ui and all apps — reaches the
tree through Adapted<W>: `Element` has exactly one production implementor,
the precondition for retyping the *mut dyn Element machinery.

Verified: full workspace builds; 168 cce-ui tests; cce-layout-interface
(Paginator vertical strip, rotated SVG tabs) static A/B AE=0 and cross-build
AE=0 after an identical tab click that switches panels; cce-email (MenuBar
strip) cross-build AE=0 after an identical menu-open click, initial diff =
the tab hover tint under the parked cursor.

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

 src/widget/container/menu.rs      |  10 +-
 src/widget/container/paginator.rs |  26 ++--
 src/widget/input/button_strip.rs  | 278 ++++++++++++++++++++------------------
 3 files changed, 169 insertions(+), 145 deletions(-)

diff --git a/src/widget/container/menu.rs b/src/widget/container/menu.rs
index 110a71e..4455649 100644
--- a/src/widget/container/menu.rs
+++ b/src/widget/container/menu.rs
@@ -34,7 +34,7 @@ pub struct MenuBar {
     pub blur: bool,
     pub color: Option<[f32; 4]>,
     pub title: String,
-    pub menus: ButtonStrip,
+    pub menus: Adapted<ButtonStrip>,
     pub menu_items: Vec<String>,
     pub vertical_items: Vec<String>,
     pub menu_dropdowns: Vec<Vec<String>>,
@@ -70,7 +70,7 @@ impl MenuBar {
             blur: false,
             color: None,
             title: String::new(),
-            menus: ButtonStrip::new(x, y, w, h).with_inherit_menubar_font(true),
+            menus: Adapted::new(ButtonStrip::new(x, y, w, h).with_inherit_menubar_font(true)),
             menu_items: Vec::new(),
             vertical_items: Vec::new(),
             menu_dropdowns: Vec::new(),
@@ -329,7 +329,7 @@ impl MenuBar {
         self.context_dropdown_open = false;
         self.context_hovered_item = None;
         self.hovered_dropdown_item = None;
-        self.menus.set_selected(None);
+        self.menus.inner_mut().set_selected(None);
     }
 
     /// The conditional focus claim of the legacy `focus()`: hold the global focus only while
@@ -735,7 +735,7 @@ impl Input for MenuBar {
     fn set_selected(&mut self, selected: bool) {
         self.focused = selected;
         if !selected {
-            self.menus.set_selected(None);
+            self.menus.inner_mut().set_selected(None);
         }
     }
 
@@ -1061,7 +1061,7 @@ impl PageSelector for MenuBar {
     }
 
     fn set_selected_page(&mut self, page: usize) {
-        self.menus.set_selected(Some(page));
+        self.menus.inner_mut().set_selected(Some(page));
     }
 
     fn sidebar_w(&self) -> f32 {
diff --git a/src/widget/container/paginator.rs b/src/widget/container/paginator.rs
index 2221f27..d578c36 100644
--- a/src/widget/container/paginator.rs
+++ b/src/widget/container/paginator.rs
@@ -24,7 +24,7 @@ use crate::widget::{
 };
 
 pub struct Paginator {
-    pub sidebar_menu: ButtonStrip,
+    pub sidebar_menu: Adapted<ButtonStrip>,
     pub selected_page: usize,
     pub sidebar_w: f32,
     pub page_labels: Vec<String>,
@@ -37,7 +37,7 @@ impl Paginator {
         let num_pages = pages.len();
 
         let temp_paginator = Paginator {
-            sidebar_menu: ButtonStrip::new(0.0, 0.0, 0.0, 0.0),
+            sidebar_menu: Adapted::new(ButtonStrip::new(0.0, 0.0, 0.0, 0.0)),
             selected_page: 0,
             sidebar_w: 0.0,
             page_labels: pages.clone(),
@@ -46,11 +46,13 @@ impl Paginator {
         };
         let sidebar_w = temp_paginator.sidebar_w();
 
-        let mut sidebar_menu = ButtonStrip::new(0.0, 0.0, sidebar_w, 0.0)
-            .with_vertical(true)
-            .with_buttons(pages.clone());
+        let mut sidebar_menu = Adapted::new(
+            ButtonStrip::new(0.0, 0.0, sidebar_w, 0.0)
+                .with_vertical(true)
+                .with_buttons(pages.clone()),
+        );
         if num_pages > 0 {
-            sidebar_menu.set_selected(Some(0));
+            sidebar_menu.inner_mut().set_selected(Some(0));
         }
 
         Adapted::new(Paginator {
@@ -103,7 +105,7 @@ impl PageSelector for Paginator {
     fn set_selected_page(&mut self, page: usize) {
         if page < self.page_labels.len() {
             self.selected_page = page;
-            self.sidebar_menu.set_selected(Some(page));
+            self.sidebar_menu.inner_mut().set_selected(Some(page));
             if let Some(ref cb) = self.on_page_changed_cb {
                 cb(page);
             }
@@ -160,9 +162,9 @@ impl Layout for Paginator {
     }
 
     fn register_embedded_children(&mut self, host_id: WidgetId, ctx: &mut UiContext) {
-        let menu_ptr = &mut self.sidebar_menu as *mut ButtonStrip;
-        ctx.register_widget(self.sidebar_menu.base.id(), menu_ptr);
-        ctx.link_ids(host_id, self.sidebar_menu.base.id());
+        let menu_ptr = self.sidebar_menu.as_ptr_mut();
+        ctx.register_widget(self.sidebar_menu.id(), menu_ptr);
+        ctx.link_ids(host_id, self.sidebar_menu.id());
     }
 }
 
@@ -235,7 +237,7 @@ impl Input for Paginator {
                 let mut changed = false;
                 if self.sidebar_menu.mouse_input(*button, *state, *px, *py, ui) {
                     changed = true;
-                    if let Some(idx) = self.sidebar_menu.take_click() {
+                    if let Some(idx) = self.sidebar_menu.inner_mut().take_click() {
                         self.set_selected_page(idx);
                         self.just_clicked = Some(idx);
                     }
@@ -330,7 +332,7 @@ mod tests {
         // The embedded strip + pages land in the registry on tick (the spatial grid feeds off
         // it — the registered strip is what blocks backplate drags over the sidebar).
         Element::tick(&mut p, 0.016, &mut ctx);
-        let strip_id = p.sidebar_menu.base.id();
+        let strip_id = p.sidebar_menu.id();
         assert!(
             ctx.tree.iter_registered().any(|(w_id, _)| w_id == strip_id),
             "strip registered by the tick-path healing"
diff --git a/src/widget/input/button_strip.rs b/src/widget/input/button_strip.rs
index 9e89a06..567876f 100644
--- a/src/widget/input/button_strip.rs
+++ b/src/widget/input/button_strip.rs
@@ -4,7 +4,10 @@ use crate::widget::input::get_font_db;
 
 #[derive(Debug, Clone)]
 pub struct ButtonStrip {
-    pub base: Widget,
+    x: f32,
+    y: f32,
+    w: f32,
+    h: f32,
     pub buttons: Vec<String>,
     pub selected: Option<usize>,
     pub vertical: bool,
@@ -22,7 +25,10 @@ pub struct ButtonStrip {
 impl ButtonStrip {
     pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
         Self {
-            base: Widget::new_rect(x, y, w, h),
+            x,
+            y,
+            w,
+            h,
             buttons: Vec::new(),
             selected: None,
             vertical: false,
@@ -44,6 +50,12 @@ impl ButtonStrip {
         self
     }
 
+    /// The laid-out rect, mirrored from the adapter by `Layout::rect_assigned` (or the
+    /// constructor arguments until the first layout).
+    fn rect(&self) -> (f32, f32, f32, f32) {
+        (self.x, self.y, self.w, self.h)
+    }
+
     fn current_font(&self) -> String {
         if self.inherit_menubar_font {
             crate::layout::menubar_font()
@@ -272,99 +284,33 @@ impl ButtonStrip {
     }
 }
 
-impl Element for ButtonStrip {
-    crate::impl_widget_base!(ButtonStrip);
-
-    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
-        if self.base.x != x || self.base.y != y || self.base.w != w || self.base.h != h {
-            self.base.x = x;
-            self.base.y = y;
-            self.base.w = w;
-            self.base.h = h;
-            self.generate_rotated_labels();
-        }
-    }
-
-    fn tick(&mut self, _dt: f32, _ctx: &mut UiContext) -> bool {
-        let mut changed = false;
-        let current_padding = crate::layout::button_padding();
-        let current_font = self.current_font();
-        let current_scale = crate::scale::scale_factor().max(1.0);
-        if self.last_padding != Some(current_padding)
-            || self.last_font.as_ref() != Some(&current_font)
-            || self.last_scale != Some(current_scale)
-        {
+impl crate::widget::Layout for ButtonStrip {
+    // The legacy set_rect override: regenerate the rotated tab labels only when the rect
+    // actually changed.
+    fn rect_assigned(&mut self, rect: crate::scene::layout::Rect) {
+        if self.x != rect.x || self.y != rect.y || self.w != rect.width || self.h != rect.height {
+            self.x = rect.x;
+            self.y = rect.y;
+            self.w = rect.width;
+            self.h = rect.height;
             self.generate_rotated_labels();
-            changed = true;
         }
-        changed
-    }
-
-    fn wants_tick(&self) -> bool {
-        true
-    }
-
-    fn highlight_quad(&self, _ctx: &UiContext) -> Option<(f32, f32, f32, f32, [f32; 4])> {
-        None
     }
+}
 
+impl crate::widget::Paint for ButtonStrip {
     fn color(&self) -> [f32; 4] {
         [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;
-        }
-        let mut changed = false;
-        match state {
-            ElementState::Pressed => {
-                for i in 0..self.buttons.len() {
-                    let r = self.item_rect(i);
-                    if px >= r.0 && px < r.0 + r.2 && py >= r.1 && py < r.1 + r.3 {
-                        self.pressed_idx = Some(i);
-                        changed = true;
-                        break;
-                    }
-                }
-            }
-            ElementState::Released => {
-                if let Some(pressed) = self.pressed_idx {
-                    let r = self.item_rect(pressed);
-                    if px >= r.0 && px < r.0 + r.2 && py >= r.1 && py < r.1 + r.3 {
-                        if self.selected != Some(pressed) {
-                            self.selected = Some(pressed);
-                            self.just_clicked = Some(pressed);
-                            self.generate_rotated_labels();
-                        } else {
-                            self.selected = None;
-                            self.just_clicked = Some(pressed);
-                            self.generate_rotated_labels();
-                        }
-                    }
-                    changed = true;
-                }
-                self.pressed_idx = None;
-            }
-        }
-        changed
-    }
-
-    fn cursor_moved(&mut self, px: f32, py: f32, _ctx: &mut UiContext) -> bool {
-        let old_hovered = self.hovered_idx;
-        self.hovered_idx = None;
-        for i in 0..self.buttons.len() {
-            let r = self.item_rect(i);
-            if px >= r.0 && px < r.0 + r.2 && py >= r.1 && py < r.1 + r.3 {
-                self.hovered_idx = Some(i);
-                break;
-            }
-        }
-        old_hovered != self.hovered_idx
+    fn widget_font(&self) -> Option<String> {
+        Some(self.current_font())
     }
 
-    fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
-        let mut quads = Vec::new();
+    fn paint(&self, _rect: crate::scene::layout::Rect, pc: &mut crate::scene::paint::PaintCtx) {
+        use crate::scene::layout::Rect;
+        // The legacy extra_quads body: per-item state backgrounds, plus the rotated
+        // (SVG-rasterized) vertical tab text clamped to the strip.
         for i in 0..self.buttons.len() {
             let r = self.item_rect(i);
             let mut bg_color = [0.0, 0.0, 0.0, 0.0];
@@ -376,13 +322,13 @@ impl Element for ButtonStrip {
                 bg_color = colors::PANEL_MENU_HOVER;
             }
             if bg_color != [0.0, 0.0, 0.0, 0.0] {
-                quads.push((r.0, r.1, r.2, r.3, bg_color));
+                pc.quad(Rect { x: r.0, y: r.1, width: r.2, height: r.3 }, bg_color);
             }
 
             if self.vertical {
                 if i < self.tab_text_quads.len() {
-                    let min_y = self.base.y;
-                    let max_y = self.base.y + self.base.h;
+                    let min_y = self.y;
+                    let max_y = self.y + self.h;
                     let page_name = &self.buttons[i];
                     let trimmed = page_name.trim();
                     let space_idx = trimmed.find(' ');
@@ -393,70 +339,146 @@ impl Element for ButtonStrip {
                     for &(qx, qy, qw, qh, qc) in &self.tab_text_quads[i] {
                         let absolute_x = r.0 + qx;
                         let absolute_y = r.1 + y_offset + qy - 15.0;
-                        
+
                         let ry1 = absolute_y.max(min_y);
                         let ry2 = (absolute_y + qh).min(max_y);
                         let rh = ry2 - ry1;
                         if rh > 0.0 {
-                            quads.push((absolute_x, ry1, qw, rh, qc));
+                            pc.quad(Rect { x: absolute_x, y: ry1, width: qw, height: rh }, qc);
                         }
                     }
                 }
             }
         }
-        quads
+
+        // Own labels (horizontal button text / vertical icon glyphs).
+        for tl in self.own_labels() {
+            pc.text(tl.text, tl.x, tl.y, tl.font_size, tl.color);
+        }
     }
+}
 
-    // Leaf legacy widget: own fonted labels via paint_self (the trait text getters
-    // are deleted).
-    fn paint_self(&self, ui: &UiContext, ctx: &mut crate::scene::paint::PaintCtx) {
-        crate::scene::painter::paint_legacy_leaf(
-            self, ui, ctx,
-            crate::scene::painter::fonted_leaf_labels(self, ui, self.own_labels()),
-        );
+impl crate::widget::Input for ButtonStrip {
+    // The legacy dispatch reached mouse_input ungated (the hosts call it directly, and a
+    // press on another tab must land while a dropdown popover covers the strip); the item
+    // scan below is the real gate.
+    fn gates_presses(&self) -> bool {
+        false
     }
 
-    fn keyboard_input(&mut self, event: &KeyEvent, _ctx: &mut UiContext) -> bool {
-        if event.state != ElementState::Pressed { return false; }
-        if self.buttons.is_empty() { return false; }
+    fn wants_tick(&self) -> bool {
+        true
+    }
 
-        let current = self.selected.unwrap_or(0);
-        let next;
+    // Config watch: regenerate the rotated labels when padding/font/scale change.
+    fn tick(&mut self, _dt: f32, _rect: crate::scene::layout::Rect) -> bool {
+        let mut changed = false;
+        let current_padding = crate::layout::button_padding();
+        let current_font = self.current_font();
+        let current_scale = crate::scale::scale_factor().max(1.0);
+        if self.last_padding != Some(current_padding)
+            || self.last_font.as_ref() != Some(&current_font)
+            || self.last_scale != Some(current_scale)
+        {
+            self.generate_rotated_labels();
+            changed = true;
+        }
+        changed
+    }
 
-        match event.logical_key {
-            Key::Named(NamedKey::ArrowLeft) | Key::Named(NamedKey::ArrowUp) => {
-                if current > 0 {
-                    next = current - 1;
-                } else {
-                    next = self.buttons.len() - 1;
+    fn on_event(&mut self, event: &Event, _ectx: &mut crate::widget::EventCtx) -> bool {
+        match event {
+            Event::MouseButton { button, state, x: px, y: py, .. } => {
+                if *button != MouseButton::Left {
+                    return false;
                 }
+                let mut changed = false;
+                match state {
+                    ElementState::Pressed => {
+                        for i in 0..self.buttons.len() {
+                            let r = self.item_rect(i);
+                            if *px >= r.0 && *px < r.0 + r.2 && *py >= r.1 && *py < r.1 + r.3 {
+                                self.pressed_idx = Some(i);
+                                changed = true;
+                                break;
+                            }
+                        }
+                    }
+                    ElementState::Released => {
+                        if let Some(pressed) = self.pressed_idx {
+                            let r = self.item_rect(pressed);
+                            if *px >= r.0 && *px < r.0 + r.2 && *py >= r.1 && *py < r.1 + r.3 {
+                                if self.selected != Some(pressed) {
+                                    self.selected = Some(pressed);
+                                    self.just_clicked = Some(pressed);
+                                    self.generate_rotated_labels();
+                                } else {
+                                    self.selected = None;
+                                    self.just_clicked = Some(pressed);
+                                    self.generate_rotated_labels();
+                                }
+                            }
+                            changed = true;
+                        }
+                        self.pressed_idx = None;
+                    }
+                }
+                changed
             }
-            Key::Named(NamedKey::ArrowRight) | Key::Named(NamedKey::ArrowDown) => {
-                if current + 1 < self.buttons.len() {
-                    next = current + 1;
-                } else {
-                    next = 0;
+            Event::PointerMove { x: px, y: py, .. } => {
+                let old_hovered = self.hovered_idx;
+                self.hovered_idx = None;
+                for i in 0..self.buttons.len() {
+                    let r = self.item_rect(i);
+                    if *px >= r.0 && *px < r.0 + r.2 && *py >= r.1 && *py < r.1 + r.3 {
+                        self.hovered_idx = Some(i);
+                        break;
+                    }
                 }
+                old_hovered != self.hovered_idx
             }
-            _ => return false,
-        }
+            Event::KeyInput(event) => {
+                if event.state != ElementState::Pressed {
+                    return false;
+                }
+                if self.buttons.is_empty() {
+                    return false;
+                }
 
-        if Some(next) != self.selected {
-            self.selected = Some(next);
-            self.just_clicked = Some(next);
-            self.generate_rotated_labels();
-            return true;
-        }
-        false
-    }
+                let current = self.selected.unwrap_or(0);
+                let next;
 
-    fn widget_font(&self) -> Option<String> {
-        Some(self.current_font())
+                match event.logical_key {
+                    Key::Named(NamedKey::ArrowLeft) | Key::Named(NamedKey::ArrowUp) => {
+                        if current > 0 {
+                            next = current - 1;
+                        } else {
+                            next = self.buttons.len() - 1;
+                        }
+                    }
+                    Key::Named(NamedKey::ArrowRight) | Key::Named(NamedKey::ArrowDown) => {
+                        if current + 1 < self.buttons.len() {
+                            next = current + 1;
+                        } else {
+                            next = 0;
+                        }
+                    }
+                    _ => return false,
+                }
+
+                if Some(next) != self.selected {
+                    self.selected = Some(next);
+                    self.just_clicked = Some(next);
+                    self.generate_rotated_labels();
+                    return true;
+                }
+                false
+            }
+            _ => false,
+        }
     }
 }
 
-impl Control for ButtonStrip {}
-
 impl ButtonStrip {
     pub(crate) fn own_labels(&self) -> Vec<TextLabel> {
         let mut labels = Vec::new();