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 }