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

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

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