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

commit1984f5ca9b95b41aa41d17685298f081ff79b6e7
parente0bcd1eb43
authorLucas Galante <[email protected]>
date2026-08-06 13:01
feat: breadcrumb segments are individual beveled buttons

Each directory renders as its own flush inset plate (groove ring +
beveled lip — the Button treatment) with per-segment padding and gaps,
replacing the single recessed well and the abutting slash-joined text
run. Layout measures each segment alone (box padding absorbs the glyph
side bearings the old cumulative-prefix scheme existed to cancel);
overflow keeps the trailing segments behind a boxed … marker.
segment_boxes() exposes the plates for flat-path hosts to mirror.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/widget/container/breadcrumb.rs | 160 +++++++++++++++++++------------------
 1 file changed, 83 insertions(+), 77 deletions(-)

diff --git a/src/widget/container/breadcrumb.rs b/src/widget/container/breadcrumb.rs
index 95ba07c..e2311b0 100644
--- a/src/widget/container/breadcrumb.rs
+++ b/src/widget/container/breadcrumb.rs
@@ -16,8 +16,15 @@ use crate::widget::{
 /// the two stay in lockstep.
 const BREADCRUMB_FONT_SIZE: f32 = 12.0;
 
-/// A segment as actually laid out for painting/hit-testing: its text, left edge, width, and
-/// logical index in [`Breadcrumb::virtual_segs`] (`None` for the leading "…/" ellipsis marker).
+/// Gap between segment buttons.
+const SEG_GAP: f32 = 6.0;
+
+/// Horizontal text inset inside each segment button.
+const SEG_PAD_X: f32 = 8.0;
+
+/// A segment as actually laid out for painting/hit-testing: its text, its BUTTON BOX's left
+/// edge and width (text sits `SEG_PAD_X` in), and logical index in
+/// [`Breadcrumb::virtual_segs`] (`None` for the leading "…" ellipsis marker).
 struct VisibleSeg {
     text: String,
     x: f32,
@@ -65,11 +72,12 @@ impl Breadcrumb {
         path_str
     }
 
-    /// The displayed segments: a root "/" then each path component with a trailing slash.
+    /// The displayed segments: a root "/" then each path component. No trailing slashes —
+    /// each segment renders as its own button, so the boxes are the separators.
     fn virtual_segs(&self) -> Vec<String> {
         let mut segs = vec!["/".to_string()];
         for s in &self.path {
-            segs.push(format!("{}/", s));
+            segs.push(s.clone());
         }
         segs
     }
@@ -84,68 +92,60 @@ impl Breadcrumb {
         (family, size.unwrap_or(BREADCRUMB_FONT_SIZE))
     }
 
-    /// The segments to actually paint, each with left edge, width, and its *logical* index
-    /// (position in [`virtual_segs`]; `None` marks the leading "…/" ellipsis). This is the
-    /// one source for hit-testing, the hover overlay, and the text run.
+    /// The segments to actually paint, each with its BUTTON BOX left edge/width and its
+    /// *logical* index (position in [`virtual_segs`]; `None` marks the leading "…" ellipsis).
+    /// This is the one source for hit-testing, the hover overlay, the plates, and the text
+    /// run.
     ///
-    /// Segments abut with no inter-segment gap so the path renders as one continuous string.
-    /// Widths come from measuring the *cumulative* prefix rather than each segment alone:
-    /// `measure_text_width` reports an ink box (narrower than the glyph advance by the first/
-    /// last side bearings), and every segment ends in `/`, so a segment's width taken as the
-    /// difference of two consecutive prefix measurements has those bearings cancel out —
-    /// yielding the exact advance for proportional and monospace fonts alike. Measuring each
-    /// segment in isolation instead left segments overlapping (advance underestimated) or,
-    /// with a fixed per-char advance on a proportional font, unevenly gapped.
+    /// Each segment is its own button: box width = the segment's measured ink width plus
+    /// `SEG_PAD_X` each side, boxes separated by `SEG_GAP`. (The old abutting-text layout
+    /// measured cumulative prefixes so glyph side bearings cancelled; per-box padding
+    /// absorbs the bearings instead, so per-segment measurement is enough.)
     ///
-    /// When the full path is wider than the container, leading segments are dropped and
-    /// replaced with a "…/" marker, so the trailing (current) segments stay visible. The
-    /// last segment is always kept.
+    /// When the full run is wider than the container, leading segments are dropped and
+    /// replaced with a "…" marker button, so the trailing (current) segments stay visible.
+    /// The last segment is always kept.
     fn visible_segs(&self, rect: Rect) -> Vec<VisibleSeg> {
         let all = self.virtual_segs();
         let avail = (rect.width - 2.0 * BREADCRUMB_PADDING).max(0.0);
 
         let (font, size) = Self::font_and_size();
-        let measure = |s: &str| crate::widget::display::measure_text_width(s, &font, size);
+        let box_w =
+            |s: &str| crate::widget::display::measure_text_width(s, &font, size) + 2.0 * SEG_PAD_X;
 
-        // Lay a list of (text, logical index) out left-to-right from the widget's left edge.
-        // Each segment's x/width is derived from the measured width of the concatenated prefix
-        // up to and including it, so abutting segments reproduce a single-string layout.
+        // Lay a list of (text, logical index) out left-to-right from the widget's left edge,
+        // one button box per segment.
         let place = |items: Vec<(String, Option<usize>)>| -> Vec<VisibleSeg> {
-            let mut prefix = String::new();
-            let mut prev = 0.0f32;
+            let mut x = rect.x + BREADCRUMB_PADDING;
             items
                 .into_iter()
                 .map(|(text, logical)| {
-                    let x = rect.x + BREADCRUMB_PADDING + prev;
-                    prefix.push_str(&text);
-                    let cum = measure(&prefix);
-                    let w = (cum - prev).max(0.0);
-                    prev = cum;
-                    VisibleSeg { text, x, w, logical }
+                    let w = box_w(&text);
+                    let vs = VisibleSeg { text, x, w, logical };
+                    x += w + SEG_GAP;
+                    vs
                 })
                 .collect()
         };
 
-        // Total width of the whole path measured as one string.
-        let full = measure(&all.concat());
+        let full: f32 = all.iter().map(|s| box_w(s)).sum::<f32>()
+            + SEG_GAP * all.len().saturating_sub(1) as f32;
 
         if full <= avail || all.len() <= 1 {
             return place(all.into_iter().enumerate().map(|(i, s)| (s, Some(i))).collect());
         }
 
-        // Keep the last segment, then add trailing segments while the "…/" marker plus the
-        // kept run still fits (measured as the actual concatenated string); finally prepend
-        // the marker and restore left-to-right order.
-        let ell = "…/".to_string();
+        // Keep the last segment, then add trailing segments while the "…" marker button plus
+        // the kept run still fits; finally prepend the marker and restore left-to-right order.
+        let ell = "…".to_string();
+        let mut used = box_w(&ell);
         let mut kept: Vec<(String, Option<usize>)> = Vec::new();
         for i in (0..all.len()).rev() {
-            let trial: String = std::iter::once(ell.as_str())
-                .chain(std::iter::once(all[i].as_str()))
-                .chain(kept.iter().rev().map(|(t, _): &(String, Option<usize>)| t.as_str()))
-                .collect();
-            if !kept.is_empty() && measure(&trial) > avail {
+            let w = SEG_GAP + box_w(&all[i]);
+            if !kept.is_empty() && used + w > avail {
                 break;
             }
+            used += w;
             kept.push((all[i].clone(), Some(i)));
         }
         kept.push((ell, None));
@@ -160,6 +160,16 @@ impl Breadcrumb {
             .and_then(|s| s.logical)
     }
 
+    /// The per-segment button boxes as laid out for `rect`: (x, y, w, h). For flat-path
+    /// hosts (cce-files) that mirror each segment's beveled plate app-side — the
+    /// render_widget geometry path drops relief prims, same as the dropdown's inset.
+    pub fn segment_boxes(&self, rect: Rect) -> Vec<(f32, f32, f32, f32)> {
+        self.visible_segs(rect)
+            .iter()
+            .map(|vs| (vs.x, rect.y, vs.w, rect.height))
+            .collect()
+    }
+
     fn bg_color(&self) -> [f32; 4] {
         let c = crate::color::breadcrumb_bg_color();
         [c[0], c[1], c[2], self.network_opacity]
@@ -170,16 +180,9 @@ impl Layout for Breadcrumb {}
 
 impl Paint for Breadcrumb {
     fn color(&self) -> [f32; 4] {
-        // Relief style: no fill — the recessed carve alone defines the bar.
-        if crate::layout::control_relief() {
-            [0.0; 4]
-        } else {
-            self.bg_color()
-        }
-    }
-
-    fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
-        Some((crate::layout::breadcrumb_corner_radius(), (true, true, false, false)))
+        // No whole-widget fill in either style: each segment draws its own
+        // button plate in paint().
+        [0.0; 4]
     }
 
     fn widget_font(&self) -> Option<String> {
@@ -192,39 +195,42 @@ impl Paint for Breadcrumb {
     }
 
     fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
-        let radius = crate::layout::breadcrumb_corner_radius();
-        if crate::layout::control_relief() {
-            // Recessed well, no fill: the carve alone defines the bar — the
-            // surface below is its floor (the text-box bare idiom).
-            let depth = crate::layout::bevel_width().min(rect.height * 0.2);
-            ctx.recess(rect, (radius, radius, radius, radius), depth);
-        } else if radius <= 0.1 {
-            // Background, top corners rounded — replicating the legacy render
-            // path's corner resolution (a radius at or below 0.1 rendered sharp).
-            ctx.quad(rect, self.bg_color());
-        } else {
-            ctx.rounded_rect(rect, radius, (true, true, false, false), self.bg_color());
-        }
-
+        // Each segment is its own button with beveled edges: under
+        // control_relief the DE's flush inset plate (groove ring + beveled
+        // lip, face level with the surface — the Button treatment); in the
+        // flat fallback a rounded fill per segment.
+        let radius = crate::layout::breadcrumb_corner_radius().min(rect.height * 0.5);
+        let relief = crate::layout::control_relief();
+        let depth = crate::layout::bevel_width().min(rect.height * 0.2);
         let segs = self.visible_segs(rect);
-        if let Some(vs) =
-            self.hovered_seg.and_then(|i| segs.iter().find(|s| s.logical == Some(i)))
-        {
-            ctx.quad(
-                Rect { x: vs.x, y: rect.y, width: vs.w, height: rect.height },
-                [1.0, 1.0, 1.0, 0.06],
-            );
+
+        for vs in &segs {
+            let seg_rect = Rect { x: vs.x, y: rect.y, width: vs.w, height: rect.height };
+            if relief {
+                ctx.inset_plate(seg_rect, (radius, radius, radius, radius), [0.0; 4], depth);
+            } else {
+                ctx.rounded_rect(seg_rect, radius, (true, true, true, true), self.bg_color());
+            }
+            if vs.logical.is_some() && vs.logical == self.hovered_seg {
+                ctx.quad(seg_rect, [1.0, 1.0, 1.0, 0.06]);
+            }
         }
 
         // The current directory (last logical segment) is drawn brightly; everything else,
-        // including the "…/" ellipsis marker, is dimmed. Paint at the configured font size so
+        // including the "…" ellipsis marker, is dimmed. Paint at the configured font size so
         // the glyph run matches the widths `visible_segs` measured (and thus its x positions).
         let (_, size) = Self::font_and_size();
         let last_logical = self.virtual_segs().len().saturating_sub(1);
         for vs in segs {
             let color =
                 if vs.logical == Some(last_logical) { [0xcc, 0xcc, 0xd4] } else { [0x88, 0x88, 0x99] };
-            ctx.text(vs.text, vs.x, rect.y + 6.0, size, color);
+            ctx.text(
+                vs.text,
+                vs.x + SEG_PAD_X,
+                crate::layout::center_text_y(rect.y, rect.height, size),
+                size,
+                color,
+            );
         }
     }
 }
@@ -387,14 +393,14 @@ mod tests {
 
         let segs = breadcrumb.visible_segs(Rect { x: 0.0, y: 0.0, width: 160.0, height: 24.0 });
 
-        // First visible segment is the "…/" ellipsis marker (no logical index → not a link).
-        assert_eq!(segs.first().map(|s| s.text.as_str()), Some("…/"));
+        // First visible segment is the "…" ellipsis marker (no logical index → not a link).
+        assert_eq!(segs.first().map(|s| s.text.as_str()), Some("…"));
         assert_eq!(segs.first().and_then(|s| s.logical), None);
 
         // The current directory (last logical segment) is always visible.
         let last_logical = breadcrumb.path.len(); // "/" is index 0, so path.len() == last idx
         assert_eq!(segs.last().and_then(|s| s.logical), Some(last_logical));
-        assert_eq!(segs.last().map(|s| s.text.as_str()), Some("cce-ui/"));
+        assert_eq!(segs.last().map(|s| s.text.as_str()), Some("cce-ui"));
 
         // Everything painted stays within the container's right edge.
         for s in &segs {