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 }