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

commita3dc7eab2a0e238e4b211ee8b2bd2913f90c7dcb
parentbe1a4c4033
authorLucas Galante <[email protected]>
date2026-07-08 12:35
feat(widget): migrate Breadcrumb — first controller widget (Phase 5k)

PathController rides the new Input capability hooks, so cce-designer's
as_path_controller downcasts and UiContext::handle_right_click's as_any
downcast keep working unchanged. Segment geometry (hit zones, hover
overlay, text run) consolidated into one segs_with_x helper (legacy had
three copies). The right-press records the clicked segment, then opens
the shared menu via EventCtx::open_context_menu — same order as legacy,
so the menu header still shows that segment's path; "Copy Path" moves to
the Input::copy_path forward.

Verified on the live compositor against the stashed legacy build:
idle and hover captures pixel-identical at the widget (remaining diff is
the app's preview pane displaying this very source file, which changed);
hover behavior identical (the inert overlay is pre-existing cce-files
behavior); clicking the cce/ segment navigates correctly through the
adapter's direct-dispatch mouse_input -> on_event -> path_click chain.

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

 src/widget/container/breadcrumb.rs | 242 ++++++++++++++++++++++---------------
 1 file changed, 147 insertions(+), 95 deletions(-)

diff --git a/src/widget/container/breadcrumb.rs b/src/widget/container/breadcrumb.rs
index e554a6d..c9633fe 100644
--- a/src/widget/container/breadcrumb.rs
+++ b/src/widget/container/breadcrumb.rs
@@ -1,12 +1,21 @@
-use crate::widget::*;
+//! Narrow-trait `Breadcrumb` (Phase 5k) — the first controller widget across: it re-exposes its
+//! [`PathController`] impl through the `Input` capability hooks, so the legacy
+//! `Element::as_path_controller` downcasts (cce-designer's `path_mut`) keep working. Segment
+//! geometry (hit zones, hover overlay, per-segment text) is derived from the paint rect in one
+//! place; the right-press records the clicked segment *before* opening the shared context menu
+//! via [`EventCtx::open_context_menu`], so the menu header shows that segment's path.
+
+use crate::scene::layout::Rect;
+use crate::scene::paint::PaintCtx;
 use crate::widget::input::{BREADCRUMB_PADDING, SEGMENT_GAP};
-use crate::widget::display::TextLabel;
+use crate::widget::{
+    Adapted, ElementState, Event, EventCtx, Input, Layout, MouseButton, Paint, PathController,
+};
 
 #[derive(Debug, Clone)]
 pub struct Breadcrumb {
-    pub base: Widget,
-    hovered: bool,
     pub path: Vec<String>,
+    hovered: bool,
     hovered_seg: Option<usize>,
     clicked_seg: Option<usize>,
     pub right_clicked_seg: Option<usize>,
@@ -14,13 +23,19 @@ pub struct Breadcrumb {
 }
 
 impl Breadcrumb {
-    pub fn set_network_opacity(&mut self, opacity: f32) {
-        self.network_opacity = opacity;
+    pub fn new() -> Adapted<Breadcrumb> {
+        Adapted::new(Breadcrumb {
+            path: Vec::new(),
+            hovered: false,
+            hovered_seg: None,
+            clicked_seg: None,
+            right_clicked_seg: None,
+            network_opacity: 1.0,
+        })
     }
 
-    pub fn new() -> Self {
-        Self { base: Widget::new(), hovered: false,
-               path: Vec::new(), hovered_seg: None, clicked_seg: None, right_clicked_seg: None, network_opacity: 1.0 }
+    pub fn set_network_opacity(&mut self, opacity: f32) {
+        self.network_opacity = opacity;
     }
 
     pub fn path_to_seg(&self, idx: usize) -> String {
@@ -37,6 +52,7 @@ impl Breadcrumb {
         path_str
     }
 
+    /// The displayed segments: a root "/" then each path component with a trailing slash.
     fn virtual_segs(&self) -> Vec<String> {
         let mut segs = vec!["/".to_string()];
         for s in &self.path {
@@ -45,65 +61,44 @@ impl Breadcrumb {
         segs
     }
 
-    fn seg_at(&self, px: f32) -> Option<usize> {
-        let mut cx = self.base.x + BREADCRUMB_PADDING;
-        let segs = self.virtual_segs();
-        for (i, seg) in segs.iter().enumerate() {
-            let w = seg.len() as f32 * 7.5;
-            if px >= cx && px < cx + w {
-                return Some(i);
-            }
-            cx += w + SEGMENT_GAP;
-        }
-        None
+    /// Each segment with its left edge and width, derived from the widget's left edge — the one
+    /// source for hit-testing, the hover overlay, and the text run (legacy had three copies).
+    fn segs_with_x(&self, left: f32) -> Vec<(String, f32, f32)> {
+        let mut cx = left + BREADCRUMB_PADDING;
+        self.virtual_segs()
+            .into_iter()
+            .map(|seg| {
+                let w = seg.len() as f32 * 7.5;
+                let x = cx;
+                cx += w + SEGMENT_GAP;
+                (seg, x, w)
+            })
+            .collect()
     }
-}
-
-impl Element for Breadcrumb {
-    crate::impl_widget_base!(Breadcrumb);
 
-    fn rounded_corners(&self) -> (bool, bool, bool, bool) { (true, true, false, false) }
+    fn seg_at(&self, left: f32, px: f32) -> Option<usize> {
+        self.segs_with_x(left)
+            .iter()
+            .position(|(_, x, w)| px >= *x && px < *x + *w)
+    }
 
-    fn color(&self) -> [f32; 4] {
+    fn bg_color(&self) -> [f32; 4] {
         let c = crate::color::breadcrumb_bg_color();
         [c[0], c[1], c[2], self.network_opacity]
     }
-    fn set_hovered(&mut self, v: bool) { self.hovered = v; }
-    fn hovered(&self) -> bool { self.hovered }
+}
 
-    fn on_cursor_moved(&mut self, px: f32, py: f32, ctx: &mut UiContext) -> bool {
-        let was = self.hovered;
-        self.hovered = self.hit_test(px, py, ctx);
-        let old = self.hovered_seg;
-        self.hovered_seg = if self.hovered { self.seg_at(px) } else { None };
-        was != self.hovered || old != self.hovered_seg
-    }
+impl Layout for Breadcrumb {}
 
-    fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ctx: &mut UiContext) -> bool {
-        if button == MouseButton::Right && state == ElementState::Pressed {
-            self.right_clicked_seg = self.seg_at(px);
-            ctx.handle_right_click(self.as_ptr_mut(), px, py);
-            return true;
-        }
-        if button != MouseButton::Left || state != ElementState::Pressed { return false; }
-        if let Some(i) = self.seg_at(px) {
-            if i < self.path.len() {
-                self.clicked_seg = Some(i);
-                return true;
-            }
-        }
-        false
+impl Paint for Breadcrumb {
+    fn color(&self) -> [f32; 4] {
+        self.bg_color()
     }
 
-    fn copy_path(&self) {
-        let idx = self.right_clicked_seg.unwrap_or(self.path.len());
-        let path_str = self.path_to_seg(idx);
-        crate::widget::clipboard::copy_to_clipboard(&path_str);
+    fn corner_style(&self) -> Option<(f32, (bool, bool, bool, bool))> {
+        Some((crate::layout::breadcrumb_corner_radius(), (true, true, false, false)))
     }
 
-    fn as_path_controller(&self) -> Option<&dyn PathController> { Some(self) }
-    fn as_path_controller_mut(&mut self) -> Option<&mut dyn PathController> { Some(self) }
-
     fn widget_font(&self) -> Option<String> {
         let font = crate::layout::breadcrumb_font();
         if font.is_empty() {
@@ -113,44 +108,93 @@ impl Element for Breadcrumb {
         }
     }
 
-    fn corner_radius(&self) -> f32 {
-        crate::layout::breadcrumb_corner_radius()
+    fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
+        // Background, top corners rounded — replicating the legacy render path's corner
+        // resolution (a radius at or below 0.1 rendered sharp).
+        let radius = crate::layout::breadcrumb_corner_radius();
+        if radius <= 0.1 {
+            ctx.quad(rect, self.bg_color());
+        } else {
+            ctx.rounded_rect(rect, radius, (true, true, false, false), self.bg_color());
+        }
+
+        let segs = self.segs_with_x(rect.x);
+        if let Some((_, x, w)) = self.hovered_seg.and_then(|i| segs.get(i)) {
+            ctx.quad(
+                Rect { x: *x, y: rect.y, width: *w, height: rect.height },
+                [1.0, 1.0, 1.0, 0.06],
+            );
+        }
+
+        let last = segs.len().saturating_sub(1);
+        for (i, (seg, x, _)) in segs.into_iter().enumerate() {
+            let color = if i == last { [0xcc, 0xcc, 0xd4] } else { [0x88, 0x88, 0x99] };
+            ctx.text(seg, x, rect.y + 6.0, 12.0, color);
+        }
     }
+}
 
-    fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
-        let mut quads = Vec::new();
-        let (x, y, w, h) = self.rect();
-        quads.push((x, y, w, h, self.color()));
-        if let Some(i) = self.hovered_seg {
-            let mut cx = x + BREADCRUMB_PADDING;
-            let segs = self.virtual_segs();
-            for j in 0..i {
-                let w = segs[j].len() as f32 * 7.5;
-                cx += w + SEGMENT_GAP;
+impl Input for Breadcrumb {
+    fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
+        match event {
+            Event::PointerMove { x: px, y: py, .. } => {
+                let r = ectx.rect;
+                let was = self.hovered;
+                self.hovered =
+                    *px >= r.x && *px <= r.x + r.width && *py >= r.y && *py <= r.y + r.height;
+                let old = self.hovered_seg;
+                self.hovered_seg = if self.hovered { self.seg_at(r.x, *px) } else { None };
+                was != self.hovered || old != self.hovered_seg
             }
-            let w = segs[i].len() as f32 * 7.5;
-            quads.push((cx, y, w, h, [1.0, 1.0, 1.0, 0.06]));
-        }
-        quads
-    }
-
-    fn text_labels(&self) -> Vec<TextLabel> {
-        let mut labels = Vec::new();
-        let (x, y, _, _) = self.rect();
-        let mut cx = x + BREADCRUMB_PADDING;
-        let segs = self.virtual_segs();
-        let len = segs.len();
-        for (i, seg) in segs.iter().enumerate() {
-            labels.push(TextLabel {
-                text: seg.clone(),
-                x: cx,
-                y: y + 6.0,
-                font_size: 12.0,
-                color: if i == len - 1 { [0xcc, 0xcc, 0xd4] } else { [0x88, 0x88, 0x99] },
-            });
-            cx += seg.len() as f32 * 7.5 + SEGMENT_GAP;
+            Event::MouseLeave => {
+                let changed = self.hovered || self.hovered_seg.is_some();
+                self.hovered = false;
+                self.hovered_seg = None;
+                changed
+            }
+            Event::MouseButton {
+                button: MouseButton::Right,
+                state: ElementState::Pressed,
+                x: px,
+                y: py,
+                ..
+            } => {
+                // Record the segment first: the shared menu's header reads it (via the
+                // `as_any` downcast in `UiContext::handle_right_click`) to title itself with
+                // that segment's path, and "Copy Path" copies it.
+                self.right_clicked_seg = self.seg_at(ectx.rect.x, *px);
+                ectx.open_context_menu(*px, *py);
+                true
+            }
+            Event::MouseButton {
+                button: MouseButton::Left,
+                state: ElementState::Pressed,
+                x: px,
+                ..
+            } => {
+                if let Some(i) = self.seg_at(ectx.rect.x, *px) {
+                    if i < self.path.len() {
+                        self.clicked_seg = Some(i);
+                        return true;
+                    }
+                }
+                false
+            }
+            _ => false,
         }
-        labels
+    }
+
+    fn path_controller(&self) -> Option<&dyn PathController> {
+        Some(self)
+    }
+    fn path_controller_mut(&mut self) -> Option<&mut dyn PathController> {
+        Some(self)
+    }
+
+    fn copy_path(&self) {
+        let idx = self.right_clicked_seg.unwrap_or(self.path.len());
+        let path_str = self.path_to_seg(idx);
+        crate::widget::clipboard::copy_to_clipboard(&path_str);
     }
 }
 
@@ -189,7 +233,8 @@ mod tests {
         // GAP = 4.0. next = 67.0 + 4.0 = 71.0
         // Segment 2 ("lsgalante/"): length 10. width = 10 * 7.5 = 75.0. range: [71.0, 146.0)
 
-        // Click in segment 0 (/)
+        // Click in segment 0 (/) — through the adapter's direct-dispatch mouse_input, the
+        // same entry cce-files drives.
         let mut ui_ctx = UiContext::new();
         assert!(breadcrumb.mouse_input(crate::widget::MouseButton::Left, crate::widget::ElementState::Pressed, 20.0, 25.0, &mut ui_ctx));
         assert_eq!(breadcrumb.path_click(), Some(0));
@@ -198,9 +243,7 @@ mod tests {
         assert!(breadcrumb.mouse_input(crate::widget::MouseButton::Left, crate::widget::ElementState::Pressed, 50.0, 25.0, &mut ui_ctx));
         assert_eq!(breadcrumb.path_click(), Some(1));
 
-        // Click in segment 2 (lsgalante/)
-        // Wait, path.len() is 2. i = 2. 2 < 2 is false.
-        // So clicking the last segment should return false.
+        // Click in segment 2 (lsgalante/) — the last segment is the current dir, not a link.
         assert!(!breadcrumb.mouse_input(crate::widget::MouseButton::Left, crate::widget::ElementState::Pressed, 100.0, 25.0, &mut ui_ctx));
         assert_eq!(breadcrumb.path_click(), None);
     }
@@ -223,5 +266,14 @@ mod tests {
         assert_eq!(breadcrumb.path_to_seg(1), "/home");
         assert_eq!(breadcrumb.path_to_seg(2), "/home/lsgalante");
     }
-}
 
+    #[test]
+    fn path_controller_reachable_through_element() {
+        let mut breadcrumb = Breadcrumb::new();
+        let elem: &mut dyn Element = &mut breadcrumb;
+        elem.as_path_controller_mut()
+            .expect("Breadcrumb exposes PathController through the adapter")
+            .set_path(&["a".to_string()]);
+        assert_eq!(breadcrumb.path, vec!["a".to_string()]);
+    }
+}