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