Skip to main content

qframe/widgets/
breadcrumb.rs

1//! Breadcrumbs: the path to the current place, one clickable segment per level.
2
3use crate::event::{Event, MouseButton, MouseKind};
4use crate::geometry::{Rect, Size};
5use crate::keymap::Key;
6use crate::style::CellStyle;
7use crate::text;
8use crate::theme::State;
9use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
10
11use super::IndexMessage;
12use super::popup_menu::{PopupAction, PopupMenu};
13
14/// Cells of padding on each side of a segment's label.
15const PAD: u16 = 1;
16
17/// A piece of a laid-out breadcrumb.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19enum Part {
20    /// Segment `index`.
21    Segment(usize),
22    /// The `…` standing for the hidden middle.
23    More,
24}
25
26#[derive(Debug, Default)]
27struct CrumbMemory {
28    cursor: Option<Part>,
29    hidden: Vec<usize>,
30}
31
32/// The path to the current place, such as `workspace › quvyta › crates › src`.
33///
34/// Every segment but the last opens its level; the last is the current place, bold and not
35/// clickable. Segments are bare text that rises on hover; the separator is a faint chevron icon.
36/// When the path does not fit, the middle collapses into `…`, which lists the hidden levels.
37///
38/// Keys while focused: ←/→ move between segments, Home/End jump to the ends, Enter or Space
39/// opens the segment (or the list behind `…`).
40///
41/// A faint path, see [`faint`](Self::faint), is drawn a step quieter for a place the person
42/// cannot read, and its segments still open their levels.
43///
44/// Style keys: `crumb` with `hover`, `focus`; `crumb.current`; `crumb.faint` and
45/// `crumb.faint-current` for a faint path; `crumb-separator`;
46/// `popup-menu`, `popup-item`, `popup-check` for the hidden levels. Icon: `crumb-separator`.
47pub struct Breadcrumb<Msg> {
48    segments: Vec<String>,
49    on_select: Option<IndexMessage<Msg>>,
50    faint: bool,
51}
52
53impl<Msg: 'static> Breadcrumb<Msg> {
54    /// A path of `segments` from the root to the current place.
55    #[must_use]
56    pub fn new(segments: impl IntoIterator<Item = impl Into<String>>) -> Self {
57        Self { segments: segments.into_iter().map(Into::into).collect(), on_select: None, faint: false }
58    }
59
60    /// Message for opening segment `index`.
61    #[must_use]
62    pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
63        self.on_select = Some(Box::new(message));
64        self
65    }
66
67    /// Draws the path faint: every segment a step quieter, the current place too, for a place
68    /// shown but not open to the person, such as a folder that cannot be read.
69    ///
70    /// Only the tone changes. The segments still rise on hover and focus and still open their
71    /// levels, so the way back out of the place stays where it always is.
72    #[must_use]
73    pub fn faint(mut self, faint: bool) -> Self {
74        self.faint = faint;
75        self
76    }
77
78    /// The style variant of a segment: the current place or another, faint or not.
79    fn variant(&self, current: bool) -> Option<&'static str> {
80        match (self.faint, current) {
81            (false, false) => None,
82            (false, true) => Some("current"),
83            (true, false) => Some("faint"),
84            (true, true) => Some("faint-current"),
85        }
86    }
87
88    fn segment_width(&self, index: usize) -> u16 {
89        text::width(&self.segments[index]).saturating_add(PAD * 2)
90    }
91
92    fn width_of(&self, parts: &[Part]) -> u16 {
93        let count = u16::try_from(parts.len()).unwrap_or(u16::MAX);
94        parts
95            .iter()
96            .map(|part| match part {
97                Part::Segment(index) => self.segment_width(*index),
98                Part::More => 1 + PAD * 2,
99            })
100            .fold(0u16, u16::saturating_add)
101            .saturating_add(count.saturating_sub(1))
102    }
103
104    /// The parts that fit in `width`: everything, or the root, `…` and as many of the last
105    /// levels as fit, or at worst `…` and the current place.
106    fn parts(&self, width: u16) -> Vec<Part> {
107        let count = self.segments.len();
108        let all: Vec<Part> = (0..count).map(Part::Segment).collect();
109        if count <= 2 || self.width_of(&all) <= width {
110            return all;
111        }
112        let last = count - 1;
113        let mut tail = vec![Part::Segment(last)];
114        for index in (1..last).rev() {
115            let mut candidate = vec![Part::Segment(0), Part::More, Part::Segment(index)];
116            candidate.extend(&tail);
117            if self.width_of(&candidate) > width {
118                break;
119            }
120            tail.insert(0, Part::Segment(index));
121        }
122        let mut with_root = vec![Part::Segment(0), Part::More];
123        with_root.extend(&tail);
124        if self.width_of(&with_root) <= width {
125            return with_root;
126        }
127        vec![Part::More, Part::Segment(last)]
128    }
129
130    /// Screen rectangles of `parts` laid out from `area`'s left edge.
131    fn layout(&self, parts: &[Part], area: Rect) -> Vec<(Part, Rect)> {
132        let mut x = area.x;
133        parts
134            .iter()
135            .map(|part| {
136                let width = match part {
137                    Part::Segment(index) => self.segment_width(*index),
138                    Part::More => 1 + PAD * 2,
139                };
140                let rect = Rect::new(x, area.y, width, 1).intersect(area);
141                x += i32::from(width) + 1;
142                (*part, rect)
143            })
144            .collect()
145    }
146
147    fn hidden(parts: &[Part], count: usize) -> Vec<usize> {
148        (0..count).filter(|index| !parts.contains(&Part::Segment(*index))).collect()
149    }
150
151    fn is_current(&self, part: Part) -> bool {
152        part == Part::Segment(self.segments.len().saturating_sub(1))
153    }
154
155    fn active(&self) -> bool {
156        self.on_select.is_some() && self.segments.len() > 1
157    }
158
159    /// The parts the keyboard can rest on, in order.
160    fn stops(&self, parts: &[Part]) -> Vec<Part> {
161        parts.iter().copied().filter(|part| !self.is_current(*part)).collect()
162    }
163
164    fn activate(&self, cx: &mut EventCx<'_, Msg>, part: Part, parts: &[Part]) {
165        match part {
166            Part::Segment(index) => {
167                if let Some(message) = &self.on_select
168                    && !self.is_current(part)
169                {
170                    cx.flash();
171                    cx.emit(message(index));
172                }
173            }
174            Part::More => {
175                let hidden = Self::hidden(parts, self.segments.len());
176                let highlight = hidden.len().saturating_sub(1);
177                cx.memory::<CrumbMemory>().hidden = hidden;
178                PopupMenu::open(cx, highlight);
179            }
180        }
181    }
182}
183
184impl<Msg: 'static> Widget<Msg> for Breadcrumb<Msg> {
185    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
186        let all: Vec<Part> = (0..self.segments.len()).map(Part::Segment).collect();
187        Size::new(self.width_of(&all), 1).min(available)
188    }
189
190    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
191        if self.segments.is_empty() || area.is_empty() {
192            return;
193        }
194        let active = self.active();
195        if active {
196            cx.register_hit(area);
197        }
198        let focused = active && cx.is_focused();
199        let pointer = if active { cx.pointer() } else { None };
200        let parts = self.parts(area.width);
201        let stops = self.stops(&parts);
202        let cursor = {
203            let memory = cx.memory::<CrumbMemory>();
204            memory.cursor.filter(|part| stops.contains(part)).or_else(|| stops.last().copied())
205        };
206        let separator = cx.env().icons().glyph("crumb-separator").into_owned();
207        let separator_style = cx.style("crumb-separator", None, &[]).text();
208        for (position, (part, rect)) in self.layout(&parts, area).into_iter().enumerate() {
209            if position > 0 {
210                cx.text(rect.x - 1, rect.y, &separator, separator_style, 1);
211            }
212            if rect.is_empty() {
213                continue;
214            }
215            let current = self.is_current(part);
216            let mut states = Vec::new();
217            if !current && pointer.is_some_and(|(x, y)| rect.contains(x, y)) {
218                states.push(State::Hover);
219            }
220            if focused && cursor == Some(part) {
221                states.push(State::Focus);
222            }
223            if part == Part::More && PopupMenu::is_open_paint(cx) {
224                states.push(State::Active);
225                cx.request_overlay(rect);
226            }
227            let style = cx.style("crumb", self.variant(current), &states).text();
228            if let Some(bg) = style.bg {
229                cx.clear(rect, bg);
230            }
231            let label = match part {
232                Part::Segment(index) => self.segments[index].as_str(),
233                Part::More => text::ELLIPSIS,
234            };
235            let budget = rect.width.saturating_sub(PAD * 2);
236            let shown = text::truncate(label, budget).into_owned();
237            cx.text(rect.x + i32::from(PAD), rect.y, &shown, CellStyle { bg: None, ..style }, budget);
238        }
239        cx.memory::<CrumbMemory>().hidden = Self::hidden(&parts, self.segments.len());
240    }
241
242    fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
243        let hidden = cx.memory::<CrumbMemory>().hidden.clone();
244        let labels: Vec<String> = hidden.iter().map(|index| self.segments[*index].clone()).collect();
245        PopupMenu::paint(cx, anchor, &labels, None);
246    }
247
248    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
249        if !self.active() {
250            return false;
251        }
252        if PopupMenu::is_open(cx) {
253            let hidden = cx.memory::<CrumbMemory>().hidden.clone();
254            let labels: Vec<String> = hidden.iter().map(|index| self.segments[*index].clone()).collect();
255            match PopupMenu::event(cx, event, &labels) {
256                PopupAction::Chosen(row) => {
257                    if let Some(message) = &self.on_select {
258                        cx.emit(message(hidden[row]));
259                    }
260                    return true;
261                }
262                PopupAction::Used | PopupAction::Closed => return true,
263                PopupAction::Ignored => {}
264            }
265        }
266        let area = cx.area();
267        let parts = self.parts(area.width);
268        let stops = self.stops(&parts);
269        match event {
270            Event::Key(key) => {
271                let remembered = cx.memory::<CrumbMemory>().cursor;
272                let Some(position) = remembered
273                    .and_then(|part| stops.iter().position(|stop| *stop == part))
274                    .or_else(|| stops.len().checked_sub(1))
275                else {
276                    return false;
277                };
278                let target = if key.is_plain(Key::Left) {
279                    position.saturating_sub(1)
280                } else if key.is_plain(Key::Right) {
281                    (position + 1).min(stops.len() - 1)
282                } else if key.is_plain(Key::Home) {
283                    0
284                } else if key.is_plain(Key::End) {
285                    stops.len() - 1
286                } else if key.is_plain(Key::Enter) || key.is_plain(Key::Space) {
287                    self.activate(cx, stops[position], &parts);
288                    return true;
289                } else {
290                    return false;
291                };
292                cx.memory::<CrumbMemory>().cursor = Some(stops[target]);
293                true
294            }
295            Event::Mouse(mouse) if mouse.kind == MouseKind::Down(MouseButton::Left) => {
296                let hit = self.layout(&parts, area).into_iter().find(|(_, rect)| rect.contains(mouse.x, mouse.y));
297                match hit {
298                    Some((part, _)) if !self.is_current(part) => {
299                        cx.memory::<CrumbMemory>().cursor = Some(part);
300                        self.activate(cx, part, &parts);
301                        true
302                    }
303                    _ => false,
304                }
305            }
306            _ => false,
307        }
308    }
309
310    fn focusable(&self) -> bool {
311        self.active()
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::icons::GlyphMode;
319    use crate::runtime::{App, Command, Harness};
320    use crate::widget::{Length, View};
321
322    struct Files {
323        path: Vec<&'static str>,
324        width: u16,
325        faint: bool,
326    }
327
328    impl App for Files {
329        type Msg = usize;
330        fn update(&mut self, index: usize) -> Command<usize> {
331            self.path.truncate(index + 1);
332            Command::none()
333        }
334        fn view(&self, ui: &mut View<'_, usize>) {
335            ui.add(Breadcrumb::new(self.path.clone()).on_select(|i| i).faint(self.faint))
336                .width(Length::Cells(self.width))
337                .id("path");
338        }
339    }
340
341    fn files(width: u16) -> Files {
342        Files { path: vec!["workspace", "quvyta", "crates", "framework", "src", "widgets"], width, faint: false }
343    }
344
345    #[test]
346    fn segments_open_levels_and_the_current_one_is_bold() {
347        let mut h = Harness::new(files(80), 80, 6);
348        assert_eq!(h.screen().lines().next(), Some(" workspace › quvyta › crates › framework › src › widgets"));
349        assert!(h.is_bold(50, 0), "the current place is bold");
350        assert!(!h.is_bold(2, 0));
351        h.click_text("crates");
352        assert_eq!(h.app().path, vec!["workspace", "quvyta", "crates"]);
353        h.click_text("crates");
354        assert_eq!(h.app().path.len(), 3, "the current place is not a link");
355        h.set_glyph_mode(GlyphMode::Ascii);
356        assert_eq!(h.screen().lines().next(), Some(" workspace : quvyta : crates"));
357    }
358
359    #[test]
360    fn narrow_paths_collapse_the_middle_and_list_it() {
361        let mut h = Harness::new(files(34), 34, 8);
362        assert_eq!(h.screen().lines().next(), Some(" workspace › … › src › widgets"));
363        h.click_text("…").advance(std::time::Duration::from_millis(300));
364        let screen = h.screen();
365        assert!(screen.contains("quvyta") && screen.contains("framework"), "{screen}");
366        h.click_text("framework");
367        assert_eq!(h.app().path.last(), Some(&"framework"));
368        let tiny = Harness::new(files(14), 14, 1);
369        assert_eq!(tiny.screen(), " … › widgets\n");
370    }
371
372    #[test]
373    fn keyboard_moves_between_segments() {
374        let mut h = Harness::new(files(80), 80, 6);
375        h.press("tab").press("left").press("left").press("enter");
376        assert_eq!(h.app().path, vec!["workspace", "quvyta", "crates"]);
377        h.press("home").press("enter");
378        assert_eq!(h.app().path, vec!["workspace"]);
379        assert!(!h.is_focused("path"), "a single segment has nothing to open");
380    }
381
382    #[test]
383    fn segments_wider_than_any_screen_do_not_overflow() {
384        let long: &'static str = "d".repeat(70_000).leak();
385        let h = Harness::new(Files { path: vec!["workspace", long, long, "src"], width: 24, faint: false }, 24, 1);
386        assert_eq!(h.screen(), " workspace › … › src\n");
387    }
388
389    #[test]
390    fn a_faint_path_is_drawn_in_the_themes_faint_tone_and_still_opens_its_levels() {
391        let plain = Harness::new(files(80), 80, 6);
392        let mut faint = Harness::new(Files { faint: true, ..files(80) }, 80, 6);
393        let theme = faint.env().theme();
394        let (muted, dim) = (theme.color("muted"), theme.color("dim"));
395        assert!(muted.is_some() && muted != dim);
396        let (x, y) = faint.find("quvyta").expect("a segment");
397        let (x, y) = (u16::try_from(x).expect("on screen"), u16::try_from(y).expect("on screen"));
398        assert_eq!(faint.fg(x, y), muted, "a faint segment takes the theme's faint tone");
399        assert_eq!(plain.fg(x, y), dim, "a plain one keeps its own");
400        assert_ne!(faint.fg(50, 0), plain.fg(50, 0), "the current place is quieter too");
401        assert!(faint.is_bold(50, 0), "and still bold");
402
403        faint.click_text("crates");
404        assert_eq!(faint.app().path, vec!["workspace", "quvyta", "crates"], "a faint segment still opens its level");
405    }
406}