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

src/widget/container/breadcrumb.rs (38.8K)

  1 //! Narrow-trait `Breadcrumb` (Phase 5k) — the first controller widget across: its
  2 //! [`PathController`] impl is reached through the concrete `Adapted<Breadcrumb>` by deref
  3 //! (cce-designer's `path_mut` downcasts the roster entry; Phase 6aw). Segment
  4 //! geometry (hit zones, hover overlay, per-segment text) is derived from the paint rect in one
  5 //! place; the right-press records the clicked segment *before* opening the shared context menu
  6 //! via [`EventCtx::open_context_menu`], so the menu header shows that segment's path.
  7 
  8 use crate::scene::layout::Rect;
  9 use crate::scene::paint::PaintCtx;
 10 use crate::widget::{
 11     Adapted, ElementState, Event, EventCtx, Input, Layout, MouseButton, Paint, PathController,
 12 };
 13 
 14 /// Point size the breadcrumb text is painted at; also the size measured for segment layout so
 15 /// the two stay in lockstep.
 16 const BREADCRUMB_FONT_SIZE: f32 = 12.0;
 17 
 18 /// Gap between segment buttons. Zero: the segments ABUT, and the boundary
 19 /// between two of them is a single slanted seam (see [`SEG_SLANT`]) rather than
 20 /// a strip of the well floor showing through.
 21 const SEG_GAP: f32 = 0.0;
 22 
 23 /// Lean of a seam, as horizontal run per unit of height — the boundary's top is
 24 /// this fraction of the plate height to the RIGHT of its bottom, so it reads as
 25 /// a "/" cut between two segments. 0.36 ≈ 20°, the slope of a "/" glyph in the
 26 /// mono faces the breadcrumb is set in.
 27 const SEG_SLANT: f32 = 0.36;
 28 
 29 /// Horizontal text inset inside each segment button — the dropdown's own
 30 /// label inset (`start_x = x + 8.0` in `Dropdown::paint_text`), so the two
 31 /// controls share a text rhythm. Must still clear the seam's lean at the
 32 /// plate's top and bottom edges (±`SEG_SLANT * h / 2` about mid-height —
 33 /// 4.3px at the 24px control height), not just sit beside the text.
 34 const SEG_PAD_X: f32 = 8.0;
 35 
 36 /// A segment as actually laid out for painting/hit-testing: its text, its BUTTON BOX's left
 37 /// edge and width (text sits `SEG_PAD_X` in), and logical index in
 38 /// [`Breadcrumb::virtual_segs`] (`None` for the leading "…" ellipsis marker).
 39 struct VisibleSeg {
 40     text: String,
 41     x: f32,
 42     w: f32,
 43     logical: Option<usize>,
 44 }
 45 
 46 #[derive(Debug, Clone)]
 47 pub struct Breadcrumb {
 48     pub path: Vec<String>,
 49     hovered: bool,
 50     hovered_seg: Option<usize>,
 51     clicked_seg: Option<usize>,
 52     pub right_clicked_seg: Option<usize>,
 53     pub network_opacity: f32,
 54     /// Relief stance of the segment run. `false` (the default) is the
 55     /// dropdown-mirror trough: the run sits flush, sunk into the surface
 56     /// behind a valley seam. `true` swaps the trough for a boss — the same
 57     /// silhouette raised out of the surface, for hosts whose breadcrumb
 58     /// floats in front of its plate (the designer's network editor) rather
 59     /// than sitting inset into a toolbar. Flat (non-relief) styling and the
 60     /// seams are identical in both stances.
 61     pub raised: bool,
 62     /// Keyboard focus (FocusIn / FocusOut): the run's rim lights and a cursor
 63     /// segment (`focus_seg`, a logical index) wears the wash; the arrows walk
 64     /// the visible segments, Enter / Space navigate to the cursor's.
 65     focused: bool,
 66     focus_seg: Option<usize>,
 67 }
 68 
 69 impl Breadcrumb {
 70     pub fn new() -> Adapted<Breadcrumb> {
 71         Adapted::new(Breadcrumb {
 72             path: Vec::new(),
 73             hovered: false,
 74             hovered_seg: None,
 75             clicked_seg: None,
 76             right_clicked_seg: None,
 77             focused: false,
 78             focus_seg: None,
 79             network_opacity: 1.0,
 80             raised: false,
 81         })
 82     }
 83 
 84     pub fn set_network_opacity(&mut self, opacity: f32) {
 85         self.network_opacity = opacity;
 86     }
 87 
 88     /// See [`Breadcrumb::raised`].
 89     pub fn set_raised(&mut self, raised: bool) {
 90         self.raised = raised;
 91     }
 92 
 93     pub fn path_to_seg(&self, idx: usize) -> String {
 94         let mut path_str = "/".to_string();
 95         for (i, s) in self.path.iter().enumerate() {
 96             if i + 1 > idx {
 97                 break;
 98             }
 99             if path_str != "/" {
100                 path_str.push('/');
101             }
102             path_str.push_str(s);
103         }
104         path_str
105     }
106 
107     /// The displayed segments: a root "/" then each path component. No trailing slashes —
108     /// each segment renders as its own button, so the boxes are the separators.
109     fn virtual_segs(&self) -> Vec<String> {
110         let mut segs = vec!["/".to_string()];
111         for s in &self.path {
112             segs.push(s.clone());
113         }
114         segs
115     }
116 
117     /// The breadcrumb font split into (family, size). The configured font string carries both
118     /// (e.g. "Berkeley Mono 14"); the render path parses the size out of it and shapes at that
119     /// size, so layout must measure with the SAME family and size — passing the whole string
120     /// as a family name (which no font matches) or measuring at a different size mis-sizes
121     /// every segment and makes them overlap or gap.
122     fn font_and_size() -> (String, f32) {
123         let (family, size) = crate::layout::parse_font_string(&crate::layout::breadcrumb_font());
124         (family, size.unwrap_or(BREADCRUMB_FONT_SIZE))
125     }
126 
127     /// The segments to actually paint, each with its BUTTON BOX left edge/width and its
128     /// *logical* index (position in [`virtual_segs`]; `None` marks the leading "…" ellipsis).
129     /// This is the one source for hit-testing, the hover overlay, the plates, and the text
130     /// run.
131     ///
132     /// Each segment is its own button: box width = the segment's measured ink width plus
133     /// `SEG_PAD_X` each side, boxes separated by `SEG_GAP`. (The old abutting-text layout
134     /// measured cumulative prefixes so glyph side bearings cancelled; per-box padding
135     /// absorbs the bearings instead, so per-segment measurement is enough.)
136     ///
137     /// When the full run is wider than the container, leading segments are dropped and
138     /// replaced with a "…" marker button, so the trailing (current) segments stay visible.
139     /// The last segment is always kept.
140     fn visible_segs(&self, rect: Rect) -> Vec<VisibleSeg> {
141         let all = self.virtual_segs();
142         let avail = (rect.width - 2.0 * Self::SEG_INSET).max(0.0);
143 
144         let (font, size) = Self::font_and_size();
145         let box_w =
146             |s: &str| crate::widget::display::measure_text_width(s, &font, size) + 2.0 * SEG_PAD_X;
147 
148         // Lay a list of (text, logical index) out left-to-right from the widget's left edge,
149         // one button box per segment.
150         let place = |items: Vec<(String, Option<usize>)>| -> Vec<VisibleSeg> {
151             let mut x = rect.x + Self::SEG_INSET;
152             items
153                 .into_iter()
154                 .map(|(text, logical)| {
155                     let w = box_w(&text);
156                     let vs = VisibleSeg { text, x, w, logical };
157                     x += w + SEG_GAP;
158                     vs
159                 })
160                 .collect()
161         };
162 
163         let full: f32 = all.iter().map(|s| box_w(s)).sum::<f32>()
164             + SEG_GAP * all.len().saturating_sub(1) as f32;
165 
166         if full <= avail || all.len() <= 1 {
167             return place(all.into_iter().enumerate().map(|(i, s)| (s, Some(i))).collect());
168         }
169 
170         // Keep the last segment, then add trailing segments while the "…" marker button plus
171         // the kept run still fits; finally prepend the marker and restore left-to-right order.
172         let ell = "…".to_string();
173         let mut used = box_w(&ell);
174         let mut kept: Vec<(String, Option<usize>)> = Vec::new();
175         for i in (0..all.len()).rev() {
176             let w = SEG_GAP + box_w(&all[i]);
177             if !kept.is_empty() && used + w > avail {
178                 break;
179             }
180             used += w;
181             kept.push((all[i].clone(), Some(i)));
182         }
183         kept.push((ell, None));
184         kept.reverse();
185         place(kept)
186     }
187 
188     /// The segment under `px` at height `py`. The interior boundaries LEAN
189     /// (see [`SEG_SLANT`]), so the hit zones are parallelograms, not columns —
190     /// testing x alone would put the top-left corner of a segment in its
191     /// neighbor, exactly where the seam is drawn furthest from the nominal edge.
192     ///
193     /// The parallelograms are bounded vertically by the plate band. That bound
194     /// is what makes this usable as [`Input::hit`]: `py` otherwise enters only
195     /// through `lean`, which slants the seams without ever rejecting a point,
196     /// so every segment would claim the full-height column beneath it.
197     fn seg_at(&self, rect: Rect, px: f32, py: f32) -> Option<usize> {
198         let segs = self.visible_segs(rect);
199         let (py0, ph) = Self::plate_band(rect);
200         if py < py0 || py >= py0 + ph {
201             return None;
202         }
203         let mid = py0 + ph * 0.5;
204         // Only interior edges lean; the run's two outer ends stay upright.
205         let lean = |i: usize| -> f32 {
206             if i == 0 || i >= segs.len() { 0.0 } else { SEG_SLANT * (mid - py) }
207         };
208         for (i, s) in segs.iter().enumerate() {
209             let left = s.x + lean(i);
210             let right = s.x + s.w + lean(i + 1);
211             if px >= left && px < right {
212                 return s.logical;
213             }
214         }
215         None
216     }
217 
218     /// Inset between the widget rect and the segment plate. ZERO since the
219     /// dropdown restyle: the plate fills the control box exactly as the
220     /// dropdown's flush face does (its groove ring is carved OUTSIDE the box,
221     /// widget and dropdown alike), so any inset here would render the
222     /// breadcrumb a shorter control than the dropdown beside it. Kept as a
223     /// named constant because the layout, hit zones and tests all share it.
224     const SEG_INSET: f32 = 0.0;
225 
226     /// Face opacity of the raised run, applied over the configured dropdown
227     /// fill's own alpha. Translucent on purpose: the floating stance pairs it
228     /// with the blur-behind frost, so the face reads as glass over the
229     /// content beneath rather than a solid chip.
230     pub const RAISED_FACE_OPACITY: f32 = 0.5;
231 
232     /// Width of a seam's flat floor in px. Zero would meet the two walls in a
233     /// perfect V; a hair of floor keeps the crease from aliasing into a dotted
234     /// line as the seam's subpixel position drifts with the path text. Public
235     /// because flat-path hosts engrave the seams themselves — see [`seams`].
236     ///
237     /// [`seams`]: Breadcrumb::seams
238     pub const SEAM_WIDTH: f32 = 0.75;
239 
240     /// The plate band: (y, height). Full control height since the dropdown
241     /// restyle (SEG_INSET = 0) — the seams, hover tint and hit zones all
242     /// span the plate.
243     fn plate_band(rect: Rect) -> (f32, f32) {
244         (rect.y + Self::SEG_INSET, (rect.height - 2.0 * Self::SEG_INSET).max(0.0))
245     }
246 
247     /// The ONE plate the whole segment run shares — (x, y, w, h), the full
248     /// control height — or `None` when nothing is laid out.
249     ///
250     /// The run is a single plate, not a plate per segment: with the segments
251     /// abutting, per-segment plates would put a boss wall falling and another
252     /// rising within a pixel of each other at every boundary, which stacks two
253     /// lighting evaluations and reads far hotter than one seam (the same reason
254     /// `Prim::Ridge` exists). The divisions are engraved instead — see [`seams`].
255     ///
256     /// For flat-path hosts (cce-files) that mirror the relief app-side — the
257     /// render_widget geometry path drops relief prims, same as the dropdown's.
258     ///
259     /// [`seams`]: Breadcrumb::seams
260     pub fn run_box(&self, rect: Rect) -> Option<(f32, f32, f32, f32)> {
261         let segs = self.visible_segs(rect);
262         let first = segs.first()?;
263         let last = segs.last()?;
264         let (y, h) = Self::plate_band(rect);
265         Some((first.x, y, last.x + last.w - first.x, h))
266     }
267 
268     /// The seam between each pair of abutting segments, as (top, bottom) line
269     /// endpoints. Each leans right at the top by [`SEG_SLANT`], so it reads as a
270     /// "/" between the two names. Only interior boundaries appear here — the
271     /// run's outer ends are the plate's own upright edges.
272     pub fn seams(&self, rect: Rect) -> Vec<((f32, f32), (f32, f32))> {
273         let segs = self.visible_segs(rect);
274         let (y, h) = Self::plate_band(rect);
275         let run = SEG_SLANT * h * 0.5;
276         segs.iter()
277             .skip(1)
278             .map(|s| ((s.x + run, y), (s.x - run, y + h)))
279             .collect()
280     }
281 
282     fn bg_color(&self) -> [f32; 4] {
283         let c = crate::color::breadcrumb_bg_color();
284         [c[0], c[1], c[2], self.network_opacity]
285     }
286 }
287 
288 impl Layout for Breadcrumb {
289     /// The segments are button plates, so the bar is one button row tall.
290     fn intrinsic_size(&self) -> Option<crate::scene::layout::Size> {
291         Some(crate::scene::layout::Size::new(0.0, crate::layout::button_height()))
292     }
293 }
294 
295 impl Paint for Breadcrumb {
296     fn color(&self) -> [f32; 4] {
297         // No whole-widget fill in either style: each segment draws its own
298         // button plate in paint().
299         [0.0; 4]
300     }
301 
302     fn widget_font(&self) -> Option<String> {
303         let font = crate::layout::breadcrumb_font();
304         if font.is_empty() {
305             None
306         } else {
307             Some(font)
308         }
309     }
310 
311     fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
312         // The segment run is ONE flush inset plate hugging its content — the
313         // dropdown trigger's exact relief (`Dropdown::paint_background`'s
314         // raised style: groove ring sunk around the control, its lip rolling
315         // back up, face level with the window plate) — divided into segments
316         // by seams engraved across it at a "/" lean. The old full-width
317         // recessed well is gone: right of the run there is plain window
318         // surface now, just as there is around the dropdown.
319         // ONE knob with the dropdown (style.control.dropdown.corner_radius):
320         // the two controls share a silhouette by construction, not by two
321         // numbers happening to agree.
322         let radius = crate::layout::dropdown_corner_radius();
323         let relief = crate::layout::control_relief();
324 
325         let segs = self.visible_segs(rect);
326         if let Some((rx, ry, rw, rh)) = self.run_box(rect) {
327             let run_rect = Rect { x: rx, y: ry, width: rw, height: rh };
328             let r = radius.min(rh * 0.5);
329             if relief {
330                 let depth = crate::layout::bevel_width().min(rh * 0.2);
331                 // The face comes from the DROPDOWN's fill knob, not one of
332                 // the breadcrumb's own: the two controls sit side by side on a
333                 // toolbar and must read as the same material under any config.
334                 // A transparent configured fill is the dropdown's degraded
335                 // form — edges only, the window plate showing through as the
336                 // face, which is what the boss run always did here; an opaque
337                 // one makes both controls that color. Mirrors
338                 // `Dropdown::paint_background`'s `face` exactly.
339                 let face = crate::scene::Material::control_face(crate::color::dropdown_background_color());
340                 let (stance, face) = if self.raised {
341                     // The floating stance: the run rises out of the surface as
342                     // ONE beveled plate — fill and raised roll in a single
343                     // lighting pass. The face is deliberately translucent
344                     // ([`Self::RAISED_FACE_OPACITY`] over the configured fill)
345                     // and ALWAYS frosted (`Frost::from_style`, the blur-behind
346                     // pass): a floating part shows what is under it, and
347                     // at this translucency the frost is what keeps the names
348                     // legible over live content beneath. A transparent
349                     // configured fill keeps the boss degradation: edges only,
350                     // the surface as the face.
351                     let c = face.map(|m| {
352                         let t = m.tint;
353                         m.with_tint([t[0], t[1], t[2], t[3] * Self::RAISED_FACE_OPACITY])
354                             .with_frost(crate::scene::Frost::from_style())
355                     });
356                     (crate::widget::PlateStance::Raised, c)
357                 } else {
358                     (crate::widget::PlateStance::Flush, face)
359                 };
360                 ctx.control_plate(
361                     &crate::widget::ControlPlate::control(run_rect, r, stance, face)
362                         .with_depth(depth)
363                         .with_tint(self.focused.then(crate::widget::ControlPlate::focus_tint)),
364                 );
365                 for (a, b) in self.seams(rect) {
366                     ctx.groove(a, b, Self::SEAM_WIDTH, depth, run_rect);
367                 }
368             } else if self.focused {
369                 // Flat: no rim to light, so the run wears a hairline ring in the highlight.
370                 let t = crate::widget::ControlPlate::focus_tint();
371                 ctx.border(run_rect, (r, r, r, r), self.bg_color(), [t[0], t[1], t[2], 1.0], 1.0);
372                 for (a, b) in self.seams(rect) {
373                     ctx.vector(a.0, a.1, b.0, b.1, 1.0, [0.0, 0.0, 0.0, 0.25], crate::scene::paint::Cap::Flat);
374                 }
375             } else {
376                 ctx.rounded_rect(run_rect, r, (true, true, true, true), self.bg_color());
377                 for (a, b) in self.seams(rect) {
378                     ctx.vector(a.0, a.1, b.0, b.1, 1.0, [0.0, 0.0, 0.0, 0.25], crate::scene::paint::Cap::Flat);
379                 }
380             }
381         }
382         // Hover wash: the WHOLE segment silhouette — flush to the slanted
383         // seams, and around the run's rounded end arcs on the first/last
384         // segment. No sheared primitive exists, so the wash is BANDED: one
385         // thin quad per logical pixel row, each row's edges sampled from the
386         // same seam-lean and corner-arc math the seams and run box use.
387         // ~24 plain Quads, hover-only — and Quads survive the flat hosts'
388         // rounded-quad bridge, so cce-files' mirror gets the same shape.
389         // The hover wash, and the keyboard cursor's wash in the highlight while
390         // the run holds focus — the same banded silhouette.
391         let mut washes: Vec<(usize, [f32; 4])> = Vec::new();
392         if let Some(h) = self.hovered_seg {
393             washes.push((h, [1.0, 1.0, 1.0, 0.06]));
394         }
395         if let (true, Some(f)) = (self.focused, self.focus_seg) {
396             let t = crate::widget::ControlPlate::focus_tint();
397             washes.push((f, [t[0], t[1], t[2], 0.18]));
398         }
399         for (hovered, wash) in washes {
400             if let (Some((sx0, sw)), Some((rx, ry, rw, rh))) = (
401                 segs.iter().find(|s| s.logical == Some(hovered)).map(|s| (s.x, s.w)),
402                 self.run_box(rect),
403             ) {
404                 let (hy, hh) = Self::plate_band(rect);
405                 let run = SEG_SLANT * hh * 0.5;
406                 let first = segs.first().map(|s| s.x) == Some(sx0);
407                 let last = segs.last().map(|s| s.x + s.w) == Some(sx0 + sw);
408                 let rr = crate::layout::dropdown_corner_radius().min(rh * 0.5);
409                 // The rounded end's horizontal inset at height yc.
410                 let arc = |yc: f32| -> f32 {
411                     let dy = if yc < ry + rr {
412                         rr - (yc - ry)
413                     } else if yc > ry + rh - rr {
414                         yc - (ry + rh - rr)
415                     } else {
416                         return 0.0;
417                     };
418                     rr - (rr * rr - dy * dy).max(0.0).sqrt()
419                 };
420                 let mut y = hy;
421                 while y < hy + hh {
422                     let bh = 1.0f32.min(hy + hh - y);
423                     let yc = y + bh * 0.5;
424                     let t = ((yc - hy) / hh).clamp(0.0, 1.0);
425                     let lean = run * (1.0 - 2.0 * t);
426                     let l = if first { rx + arc(yc) } else { sx0 + lean };
427                     let r_edge = if last { rx + rw - arc(yc) } else { sx0 + sw + lean };
428                     if r_edge > l {
429                         ctx.quad(Rect { x: l, y, width: r_edge - l, height: bh }, wash);
430                     }
431                     y += bh;
432                 }
433             }
434         }
435 
436         // The current directory (last logical segment) is drawn brightly; everything else,
437         // including the "…" ellipsis marker, is dimmed. Paint at the configured font size so
438         // the glyph run matches the widths `visible_segs` measured (and thus its x positions).
439         let (_, size) = Self::font_and_size();
440         let last_logical = self.virtual_segs().len().saturating_sub(1);
441         for vs in segs {
442             let color =
443                 if vs.logical == Some(last_logical) { [0xcc, 0xcc, 0xd4] } else { [0x88, 0x88, 0x99] };
444             // `visible_segs` already drops segments behind a "…" to make the
445             // run fit, but the surviving tail is still measured text against a
446             // fixed bar — bound it so a mismeasure cannot escape the widget.
447             ctx.text_with(
448                 vs.text,
449                 vs.x + SEG_PAD_X,
450                 crate::layout::center_text_y(rect.y, rect.height, size),
451                 size,
452                 color,
453                 None,
454                 Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]),
455             );
456         }
457     }
458 }
459 
460 impl Input for Breadcrumb {
461     /// The widget claims only its segment run, not its laid-out strip: hosts
462     /// float the breadcrumb over live content (the designer's graph runs
463     /// underneath), and presses on the strip's empty remainder must fall
464     /// through to what's beneath. Right-clicks sharpen with it — the
465     /// copy-path menu opens over the run, the content's own menu elsewhere.
466     fn hit(&self, rect: Rect, px: f32, py: f32) -> bool {
467         self.seg_at(rect, px, py).is_some()
468     }
469 
470     fn focus_role(&self) -> crate::widget::FocusRole {
471         crate::widget::FocusRole::Plate
472     }
473 
474     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
475         match event {
476             Event::FocusIn => {
477                 self.focused = true;
478                 // The cursor starts on the current directory (the last segment).
479                 self.focus_seg = self.path.len().checked_sub(1);
480                 true
481             }
482             Event::FocusOut => {
483                 self.focused = false;
484                 self.focus_seg = None;
485                 true
486             }
487             Event::KeyInput(key_event) => {
488                 // Left / Right walk the VISIBLE segments (the ellipsis is not a
489                 // stop); Enter / Space navigate to the cursor's segment, the click.
490                 if !self.focused || key_event.state != crate::widget::ElementState::Pressed {
491                     return false;
492                 }
493                 let logicals: Vec<usize> =
494                     self.visible_segs(ectx.rect).into_iter().filter_map(|s| s.logical).collect();
495                 match key_event.logical_key {
496                     crate::widget::Key::Named(crate::widget::NamedKey::ArrowLeft)
497                     | crate::widget::Key::Named(crate::widget::NamedKey::ArrowRight) => {
498                         if logicals.is_empty() {
499                             return false;
500                         }
501                         let right = key_event.logical_key == crate::widget::Key::Named(crate::widget::NamedKey::ArrowRight);
502                         let pos = self.focus_seg.and_then(|f| logicals.iter().position(|l| *l == f));
503                         let next = match (pos, right) {
504                             (Some(p), true) => (p + 1).min(logicals.len() - 1),
505                             (Some(p), false) => p.saturating_sub(1),
506                             (None, true) => 0,
507                             (None, false) => logicals.len() - 1,
508                         };
509                         self.focus_seg = Some(logicals[next]);
510                         true
511                     }
512                     crate::widget::Key::Named(crate::widget::NamedKey::Enter)
513                     | crate::widget::Key::Named(crate::widget::NamedKey::Space) => {
514                         match self.focus_seg {
515                             Some(i) if i < self.path.len() => {
516                                 self.clicked_seg = Some(i);
517                                 true
518                             }
519                             _ => false,
520                         }
521                     }
522                     _ => false,
523                 }
524             }
525             Event::PointerMove { x: px, y: py, .. } => {
526                 let r = ectx.rect;
527                 let was = self.hovered;
528                 self.hovered =
529                     *px >= r.x && *px <= r.x + r.width && *py >= r.y && *py <= r.y + r.height;
530                 let old = self.hovered_seg;
531                 self.hovered_seg = if self.hovered { self.seg_at(r, *px, *py) } else { None };
532                 was != self.hovered || old != self.hovered_seg
533             }
534             Event::MouseLeave => {
535                 let changed = self.hovered || self.hovered_seg.is_some();
536                 self.hovered = false;
537                 self.hovered_seg = None;
538                 changed
539             }
540             Event::MouseButton {
541                 button: MouseButton::Right,
542                 state: ElementState::Pressed,
543                 x: px,
544                 y: py,
545                 ..
546             } => {
547                 // Record the segment first: the shared menu's header reads it (via the
548                 // `as_any` downcast in `UiContext::handle_right_click`) to title itself with
549                 // that segment's path, and "Copy Path" copies it.
550                 self.right_clicked_seg = self.seg_at(ectx.rect, *px, *py);
551                 ectx.open_context_menu(*px, *py);
552                 true
553             }
554             Event::MouseButton {
555                 button: MouseButton::Left,
556                 state: ElementState::Pressed,
557                 x: px,
558                 y: py,
559                 ..
560             } => {
561                 if let Some(i) = self.seg_at(ectx.rect, *px, *py) {
562                     if i < self.path.len() {
563                         self.clicked_seg = Some(i);
564                         return true;
565                     }
566                 }
567                 false
568             }
569             _ => false,
570         }
571     }
572 
573 
574     fn context_action(&mut self, action: crate::widget::ContextAction) -> bool {
575         if action != crate::widget::ContextAction::CopyPath {
576             return false;
577         }
578         let idx = self.right_clicked_seg.unwrap_or(self.path.len());
579         let path_str = self.path_to_seg(idx);
580         crate::widget::clipboard::copy_to_clipboard(&path_str);
581         true
582     }
583 }
584 
585 impl PathController for Breadcrumb {
586     fn set_path(&mut self, segments: &[String]) {
587         self.path = segments.to_vec();
588     }
589     fn path_click(&mut self) -> Option<usize> {
590         self.clicked_seg.take()
591     }
592 }
593 
594 #[cfg(test)]
595 mod tests {
596     use super::*;
597     use crate::context::UiContext;
598     use crate::widget::WidgetHost;
599 
600     /// Center x of the visible segment with the given logical index, derived from the
601     /// widget's own layout so the tests don't depend on the font's exact metrics.
602     fn seg_center_x(breadcrumb: &Breadcrumb, rect: Rect, logical: usize) -> f32 {
603         let s = breadcrumb
604             .visible_segs(rect)
605             .into_iter()
606             .find(|s| s.logical == Some(logical))
607             .expect("segment visible");
608         s.x + s.w / 2.0
609     }
610 
611     #[test]
612     fn test_breadcrumb_clicks() {
613         let mut breadcrumb = Breadcrumb::new();
614         breadcrumb.set_path(&["home".to_string(), "lsgalante".to_string()]);
615         // Set coordinates: x=10.0, y=20.0, w=300.0, h=24.0
616         let rect = Rect { x: 10.0, y: 20.0, width: 300.0, height: 24.0 };
617         breadcrumb.set_rect(rect.x, rect.y, rect.width, rect.height);
618 
619         let ctx = UiContext::new();
620 
621         // Let's test hit_test
622         assert!(breadcrumb.hit_test(15.0, 25.0, &ctx));
623 
624         // Segments abut (no inter-segment gap) and are sized by real font measurement, so
625         // click coordinates are taken from the widget's own layout rather than hardcoded.
626 
627         // Click in segment 0 (/) — through the adapter's direct-dispatch mouse_input, the
628         // same entry cce-files drives.
629         let mut ui_ctx = UiContext::new();
630         let x0 = seg_center_x(&breadcrumb, rect, 0);
631         assert!(breadcrumb.mouse_input(crate::widget::MouseButton::Left, crate::widget::ElementState::Pressed, x0, 25.0, &mut ui_ctx));
632         assert_eq!(breadcrumb.path_click(), Some(0));
633 
634         // Click in segment 1 (home/)
635         let x1 = seg_center_x(&breadcrumb, rect, 1);
636         assert!(breadcrumb.mouse_input(crate::widget::MouseButton::Left, crate::widget::ElementState::Pressed, x1, 25.0, &mut ui_ctx));
637         assert_eq!(breadcrumb.path_click(), Some(1));
638 
639         // Click in segment 2 (lsgalante/) — the last segment is the current dir, not a link.
640         let x2 = seg_center_x(&breadcrumb, rect, 2);
641         assert!(!breadcrumb.mouse_input(crate::widget::MouseButton::Left, crate::widget::ElementState::Pressed, x2, 25.0, &mut ui_ctx));
642         assert_eq!(breadcrumb.path_click(), None);
643     }
644 
645     #[test]
646     fn test_breadcrumb_right_clicks() {
647         let mut breadcrumb = Breadcrumb::new();
648         breadcrumb.set_path(&["home".to_string(), "lsgalante".to_string()]);
649         let rect = Rect { x: 10.0, y: 20.0, width: 300.0, height: 24.0 };
650         breadcrumb.set_rect(rect.x, rect.y, rect.width, rect.height);
651 
652         let mut ui_ctx = UiContext::new();
653 
654         // Right click segment 1 (home/)
655         let x1 = seg_center_x(&breadcrumb, rect, 1);
656         let handled = breadcrumb.mouse_input(crate::widget::MouseButton::Right, crate::widget::ElementState::Pressed, x1, 25.0, &mut ui_ctx);
657         assert!(handled);
658         assert_eq!(breadcrumb.right_clicked_seg, Some(1));
659 
660         // Test path_to_seg
661         assert_eq!(breadcrumb.path_to_seg(0), "/");
662         assert_eq!(breadcrumb.path_to_seg(1), "/home");
663         assert_eq!(breadcrumb.path_to_seg(2), "/home/lsgalante");
664     }
665 
666     #[test]
667     fn long_path_elides_leading_segments() {
668         let mut breadcrumb = Breadcrumb::new();
669         breadcrumb.set_path(&[
670             "home".to_string(),
671             "lsgalante".to_string(),
672             "Dropbox".to_string(),
673             "cce".to_string(),
674             "cce-ui".to_string(),
675         ]);
676         // Narrow container: the full path can't fit, so leading segments get dropped.
677         breadcrumb.set_rect(0.0, 0.0, 160.0, 24.0);
678 
679         let segs = breadcrumb.visible_segs(Rect { x: 0.0, y: 0.0, width: 160.0, height: 24.0 });
680 
681         // First visible segment is the "…" ellipsis marker (no logical index → not a link).
682         assert_eq!(segs.first().map(|s| s.text.as_str()), Some("…"));
683         assert_eq!(segs.first().and_then(|s| s.logical), None);
684 
685         // The current directory (last logical segment) is always visible.
686         let last_logical = breadcrumb.path.len(); // "/" is index 0, so path.len() == last idx
687         assert_eq!(segs.last().and_then(|s| s.logical), Some(last_logical));
688         assert_eq!(segs.last().map(|s| s.text.as_str()), Some("cce-ui"));
689 
690         // The run hugs the well's bevel on the left and never crosses the
691         // mirrored limit on the right — a segment may reach that edge, but it
692         // stops flush against it rather than running under the bevel.
693         assert_eq!(segs[0].x, Breadcrumb::SEG_INSET);
694         for s in &segs {
695             assert!(
696                 s.x + s.w <= 160.0 - Breadcrumb::SEG_INSET + 0.01,
697                 "segment {:?} crosses the right inset",
698                 s.text
699             );
700         }
701 
702         // A kept trailing segment still hit-tests to its original logical index, so clicking
703         // it navigates to the correct path.
704         let visible_seg = segs.iter().rev().nth(1).unwrap();
705         let hit = breadcrumb.seg_at(
706             Rect { x: 0.0, y: 0.0, width: 160.0, height: 24.0 },
707             visible_seg.x + 6.0,
708             12.0,
709         );
710         assert_eq!(hit, visible_seg.logical);
711     }
712 
713     #[test]
714     fn short_path_is_not_elided() {
715         let mut breadcrumb = Breadcrumb::new();
716         breadcrumb.set_path(&["home".to_string(), "lsgalante".to_string()]);
717         breadcrumb.set_rect(0.0, 0.0, 300.0, 24.0);
718 
719         let segs = breadcrumb.visible_segs(Rect { x: 0.0, y: 0.0, width: 300.0, height: 24.0 });
720         // Root + two components, no ellipsis.
721         assert_eq!(segs.len(), 3);
722         assert!(segs.iter().all(|s| s.logical.is_some()));
723         assert_eq!(segs[0].text, "/");
724     }
725 
726     /// The seams lean like "/" — top edge to the RIGHT of the bottom — and there
727     /// is exactly one per interior boundary, sitting on the shared edge at
728     /// mid-height. The run plate spans all of them.
729     #[test]
730     fn seams_lean_right_at_the_top() {
731         let mut breadcrumb = Breadcrumb::new();
732         breadcrumb.set_path(&["home".to_string(), "lsgalante".to_string()]);
733         let rect = Rect { x: 10.0, y: 20.0, width: 300.0, height: 24.0 };
734         breadcrumb.set_rect(rect.x, rect.y, rect.width, rect.height);
735 
736         let segs = breadcrumb.visible_segs(rect);
737         let seams = breadcrumb.seams(rect);
738         // Root + two components ⇒ two interior boundaries.
739         assert_eq!(seams.len(), 2);
740         assert_eq!(seams.len(), segs.len() - 1);
741 
742         for (i, ((tx, ty), (bx, by))) in seams.iter().enumerate() {
743             assert!(tx > bx, "seam {i} must lean right at the top");
744             assert!(ty < by, "seam {i} top must be above its bottom");
745             // Centered on the boundary it divides.
746             let edge = segs[i + 1].x;
747             assert!(((tx + bx) * 0.5 - edge).abs() < 0.01);
748         }
749 
750         // One plate under the lot, spanning first edge to last.
751         let (rx, _, rw, _) = breadcrumb.run_box(rect).expect("run laid out");
752         assert_eq!(rx, segs[0].x);
753         assert!((rx + rw - (segs[2].x + segs[2].w)).abs() < 0.01);
754     }
755 
756     /// A point in a segment's top-left corner belongs to the segment on the
757     /// LEFT: the seam has leaned right there, so the boundary is no longer the
758     /// nominal edge. This is what an x-only hit test got wrong.
759     #[test]
760     fn hit_test_follows_the_seam_lean() {
761         let mut breadcrumb = Breadcrumb::new();
762         breadcrumb.set_path(&["home".to_string(), "lsgalante".to_string()]);
763         let rect = Rect { x: 10.0, y: 20.0, width: 300.0, height: 24.0 };
764         breadcrumb.set_rect(rect.x, rect.y, rect.width, rect.height);
765 
766         let segs = breadcrumb.visible_segs(rect);
767         let (y, h) = Breadcrumb::plate_band(rect);
768         let edge = segs[1].x; // boundary between "/" and "home"
769         let lean = SEG_SLANT * h * 0.5;
770         assert!(lean > 1.0, "the test needs a lean wide enough to probe");
771 
772         // Just right of the nominal edge, at the TOP: still segment 0.
773         assert_eq!(breadcrumb.seg_at(rect, edge + lean * 0.5, y + 0.5), Some(0));
774         // The same x at the BOTTOM, where the seam has leaned left: segment 1.
775         assert_eq!(breadcrumb.seg_at(rect, edge + lean * 0.5, y + h - 0.5), Some(1));
776         // At mid-height the seam sits on the nominal edge.
777         assert_eq!(breadcrumb.seg_at(rect, edge + 0.5, y + h * 0.5), Some(1));
778         assert_eq!(breadcrumb.seg_at(rect, edge - 0.5, y + h * 0.5), Some(0));
779     }
780 
781     /// A segment claims its plate, not the full-height column under it. `hit()`
782     /// delegates to `seg_at`, so a missing vertical bound there hands the widget
783     /// every press sharing an x with the run — which is how a host's own content
784     /// menu (cce-files' file rows) lost its right-click to the copy-path menu.
785     #[test]
786     fn hit_test_stops_at_the_plate_band() {
787         let mut breadcrumb = Breadcrumb::new();
788         breadcrumb.set_path(&["home".to_string(), "lsgalante".to_string()]);
789         let rect = Rect { x: 10.0, y: 20.0, width: 300.0, height: 24.0 };
790         breadcrumb.set_rect(rect.x, rect.y, rect.width, rect.height);
791 
792         let (y, h) = Breadcrumb::plate_band(rect);
793         let x = seg_center_x(&breadcrumb, rect, 1);
794 
795         // Inside the band the segment answers, at the top and bottom edges too.
796         assert_eq!(breadcrumb.seg_at(rect, x, y + h * 0.5), Some(1));
797         assert_eq!(breadcrumb.seg_at(rect, x, y), Some(1));
798         assert_eq!(breadcrumb.seg_at(rect, x, y + h - 0.5), Some(1));
799 
800         // Above and below it, nothing — however far the seams have leaned.
801         assert_eq!(breadcrumb.seg_at(rect, x, y - 0.5), None);
802         assert_eq!(breadcrumb.seg_at(rect, x, y + h), None);
803         assert_eq!(breadcrumb.seg_at(rect, x, y + 400.0), None);
804 
805         // And the same bound through the `hit_test` hosts actually call.
806         let ctx = UiContext::new();
807         assert!(breadcrumb.hit_test(x, y + h * 0.5, &ctx));
808         assert!(!breadcrumb.hit_test(x, y + 400.0, &ctx));
809     }
810 
811     /// The run's outer ends stay upright — only edges that face another segment
812     /// lean, so the first segment's left edge is a plain vertical boundary.
813     #[test]
814     fn outer_ends_do_not_lean() {
815         let mut breadcrumb = Breadcrumb::new();
816         breadcrumb.set_path(&["home".to_string()]);
817         let rect = Rect { x: 10.0, y: 20.0, width: 300.0, height: 24.0 };
818         breadcrumb.set_rect(rect.x, rect.y, rect.width, rect.height);
819 
820         let segs = breadcrumb.visible_segs(rect);
821         let (y, h) = Breadcrumb::plate_band(rect);
822         let left = segs[0].x;
823         let right = segs[1].x + segs[1].w;
824 
825         for py in [y + 0.5, y + h * 0.5, y + h - 0.5] {
826             assert_eq!(breadcrumb.seg_at(rect, left + 0.5, py), Some(0));
827             assert_eq!(breadcrumb.seg_at(rect, left - 0.5, py), None);
828             assert_eq!(breadcrumb.seg_at(rect, right - 0.5, py), Some(1));
829             assert_eq!(breadcrumb.seg_at(rect, right + 0.5, py), None);
830         }
831     }
832 
833     #[test]
834     fn path_controller_reachable_through_element() {
835         let mut breadcrumb = Breadcrumb::new();
836         PathController::set_path(&mut *breadcrumb, &["a".to_string()]);
837         assert_eq!(breadcrumb.path, vec!["a".to_string()]);
838     }
839 }
840 
841 #[cfg(test)]
842 mod focus_tests {
843     use super::*;
844     use crate::widget::{ElementState, Event, Key, KeyEvent, NamedKey, UiContext, WidgetHost};
845 
846     fn press(key: NamedKey) -> Event {
847         Event::KeyInput(KeyEvent { logical_key: Key::Named(key), state: ElementState::Pressed, text: None, repeat: false, ctrl: false, shift: false, alt: false })
848     }
849 
850     /// Focus lands the cursor on the current directory; Left walks back a
851     /// visible segment; Enter navigates to the cursor's segment (the click).
852     #[test]
853     fn cursor_walks_segments_and_enter_navigates() {
854         let mut ctx = UiContext::new();
855         let mut b = Breadcrumb::new();
856         b.set_path(&["home".to_string(), "lsgalante".to_string(), "projects".to_string()]);
857         WidgetHost::set_rect(&mut b, 10.0, 20.0, 400.0, 26.0);
858         b.handle_event(&Event::FocusIn, &mut ctx);
859         assert_eq!(b.inner().focus_seg, Some(2), "cursor on the current directory");
860         assert!(b.handle_event(&press(NamedKey::ArrowLeft), &mut ctx));
861         assert_eq!(b.inner().focus_seg, Some(1));
862         assert!(b.handle_event(&press(NamedKey::Enter), &mut ctx));
863         assert_eq!(b.inner().clicked_seg, Some(1), "Enter is the click on the cursor's segment");
864         b.handle_event(&Event::FocusOut, &mut ctx);
865         assert_eq!(b.inner().focus_seg, None);
866     }
867 }