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/// Style keys: `crumb` with `hover`, `focus`; `crumb.current`; `crumb-separator`;
42/// `popup-menu`, `popup-item`, `popup-check` for the hidden levels. Icon: `crumb-separator`.
43pub struct Breadcrumb<Msg> {
44    segments: Vec<String>,
45    on_select: Option<IndexMessage<Msg>>,
46}
47
48impl<Msg: 'static> Breadcrumb<Msg> {
49    /// A path of `segments` from the root to the current place.
50    #[must_use]
51    pub fn new(segments: impl IntoIterator<Item = impl Into<String>>) -> Self {
52        Self { segments: segments.into_iter().map(Into::into).collect(), on_select: None }
53    }
54
55    /// Message for opening segment `index`.
56    #[must_use]
57    pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
58        self.on_select = Some(Box::new(message));
59        self
60    }
61
62    fn segment_width(&self, index: usize) -> u16 {
63        text::width(&self.segments[index]).saturating_add(PAD * 2)
64    }
65
66    fn width_of(&self, parts: &[Part]) -> u16 {
67        let count = u16::try_from(parts.len()).unwrap_or(u16::MAX);
68        parts
69            .iter()
70            .map(|part| match part {
71                Part::Segment(index) => self.segment_width(*index),
72                Part::More => 1 + PAD * 2,
73            })
74            .fold(0u16, u16::saturating_add)
75            .saturating_add(count.saturating_sub(1))
76    }
77
78    /// The parts that fit in `width`: everything, or the root, `…` and as many of the last
79    /// levels as fit, or at worst `…` and the current place.
80    fn parts(&self, width: u16) -> Vec<Part> {
81        let count = self.segments.len();
82        let all: Vec<Part> = (0..count).map(Part::Segment).collect();
83        if count <= 2 || self.width_of(&all) <= width {
84            return all;
85        }
86        let last = count - 1;
87        let mut tail = vec![Part::Segment(last)];
88        for index in (1..last).rev() {
89            let mut candidate = vec![Part::Segment(0), Part::More, Part::Segment(index)];
90            candidate.extend(&tail);
91            if self.width_of(&candidate) > width {
92                break;
93            }
94            tail.insert(0, Part::Segment(index));
95        }
96        let mut with_root = vec![Part::Segment(0), Part::More];
97        with_root.extend(&tail);
98        if self.width_of(&with_root) <= width {
99            return with_root;
100        }
101        vec![Part::More, Part::Segment(last)]
102    }
103
104    /// Screen rectangles of `parts` laid out from `area`'s left edge.
105    fn layout(&self, parts: &[Part], area: Rect) -> Vec<(Part, Rect)> {
106        let mut x = area.x;
107        parts
108            .iter()
109            .map(|part| {
110                let width = match part {
111                    Part::Segment(index) => self.segment_width(*index),
112                    Part::More => 1 + PAD * 2,
113                };
114                let rect = Rect::new(x, area.y, width, 1).intersect(area);
115                x += i32::from(width) + 1;
116                (*part, rect)
117            })
118            .collect()
119    }
120
121    fn hidden(parts: &[Part], count: usize) -> Vec<usize> {
122        (0..count).filter(|index| !parts.contains(&Part::Segment(*index))).collect()
123    }
124
125    fn is_current(&self, part: Part) -> bool {
126        part == Part::Segment(self.segments.len().saturating_sub(1))
127    }
128
129    fn active(&self) -> bool {
130        self.on_select.is_some() && self.segments.len() > 1
131    }
132
133    /// The parts the keyboard can rest on, in order.
134    fn stops(&self, parts: &[Part]) -> Vec<Part> {
135        parts.iter().copied().filter(|part| !self.is_current(*part)).collect()
136    }
137
138    fn activate(&self, cx: &mut EventCx<'_, Msg>, part: Part, parts: &[Part]) {
139        match part {
140            Part::Segment(index) => {
141                if let Some(message) = &self.on_select
142                    && !self.is_current(part)
143                {
144                    cx.flash();
145                    cx.emit(message(index));
146                }
147            }
148            Part::More => {
149                let hidden = Self::hidden(parts, self.segments.len());
150                let highlight = hidden.len().saturating_sub(1);
151                cx.memory::<CrumbMemory>().hidden = hidden;
152                PopupMenu::open(cx, highlight);
153            }
154        }
155    }
156}
157
158impl<Msg: 'static> Widget<Msg> for Breadcrumb<Msg> {
159    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
160        let all: Vec<Part> = (0..self.segments.len()).map(Part::Segment).collect();
161        Size::new(self.width_of(&all), 1).min(available)
162    }
163
164    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
165        if self.segments.is_empty() || area.is_empty() {
166            return;
167        }
168        let active = self.active();
169        if active {
170            cx.register_hit(area);
171        }
172        let focused = active && cx.is_focused();
173        let pointer = if active { cx.pointer() } else { None };
174        let parts = self.parts(area.width);
175        let stops = self.stops(&parts);
176        let cursor = {
177            let memory = cx.memory::<CrumbMemory>();
178            memory.cursor.filter(|part| stops.contains(part)).or_else(|| stops.last().copied())
179        };
180        let separator = cx.env().icons().glyph("crumb-separator").into_owned();
181        let separator_style = cx.style("crumb-separator", None, &[]).text();
182        for (position, (part, rect)) in self.layout(&parts, area).into_iter().enumerate() {
183            if position > 0 {
184                cx.text(rect.x - 1, rect.y, &separator, separator_style, 1);
185            }
186            if rect.is_empty() {
187                continue;
188            }
189            let current = self.is_current(part);
190            let mut states = Vec::new();
191            if !current && pointer.is_some_and(|(x, y)| rect.contains(x, y)) {
192                states.push(State::Hover);
193            }
194            if focused && cursor == Some(part) {
195                states.push(State::Focus);
196            }
197            if part == Part::More && PopupMenu::is_open_paint(cx) {
198                states.push(State::Active);
199                cx.request_overlay(rect);
200            }
201            let style = cx.style("crumb", current.then_some("current"), &states).text();
202            if let Some(bg) = style.bg {
203                cx.clear(rect, bg);
204            }
205            let label = match part {
206                Part::Segment(index) => self.segments[index].as_str(),
207                Part::More => text::ELLIPSIS,
208            };
209            let budget = rect.width.saturating_sub(PAD * 2);
210            let shown = text::truncate(label, budget).into_owned();
211            cx.text(rect.x + i32::from(PAD), rect.y, &shown, CellStyle { bg: None, ..style }, budget);
212        }
213        cx.memory::<CrumbMemory>().hidden = Self::hidden(&parts, self.segments.len());
214    }
215
216    fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
217        let hidden = cx.memory::<CrumbMemory>().hidden.clone();
218        let labels: Vec<String> = hidden.iter().map(|index| self.segments[*index].clone()).collect();
219        PopupMenu::paint(cx, anchor, &labels, None);
220    }
221
222    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
223        if !self.active() {
224            return false;
225        }
226        if PopupMenu::is_open(cx) {
227            let hidden = cx.memory::<CrumbMemory>().hidden.clone();
228            let labels: Vec<String> = hidden.iter().map(|index| self.segments[*index].clone()).collect();
229            match PopupMenu::event(cx, event, &labels) {
230                PopupAction::Chosen(row) => {
231                    if let Some(message) = &self.on_select {
232                        cx.emit(message(hidden[row]));
233                    }
234                    return true;
235                }
236                PopupAction::Used | PopupAction::Closed => return true,
237                PopupAction::Ignored => {}
238            }
239        }
240        let area = cx.area();
241        let parts = self.parts(area.width);
242        let stops = self.stops(&parts);
243        match event {
244            Event::Key(key) => {
245                let remembered = cx.memory::<CrumbMemory>().cursor;
246                let Some(position) = remembered
247                    .and_then(|part| stops.iter().position(|stop| *stop == part))
248                    .or_else(|| stops.len().checked_sub(1))
249                else {
250                    return false;
251                };
252                let target = if key.is_plain(Key::Left) {
253                    position.saturating_sub(1)
254                } else if key.is_plain(Key::Right) {
255                    (position + 1).min(stops.len() - 1)
256                } else if key.is_plain(Key::Home) {
257                    0
258                } else if key.is_plain(Key::End) {
259                    stops.len() - 1
260                } else if key.is_plain(Key::Enter) || key.is_plain(Key::Space) {
261                    self.activate(cx, stops[position], &parts);
262                    return true;
263                } else {
264                    return false;
265                };
266                cx.memory::<CrumbMemory>().cursor = Some(stops[target]);
267                true
268            }
269            Event::Mouse(mouse) if mouse.kind == MouseKind::Down(MouseButton::Left) => {
270                let hit = self.layout(&parts, area).into_iter().find(|(_, rect)| rect.contains(mouse.x, mouse.y));
271                match hit {
272                    Some((part, _)) if !self.is_current(part) => {
273                        cx.memory::<CrumbMemory>().cursor = Some(part);
274                        self.activate(cx, part, &parts);
275                        true
276                    }
277                    _ => false,
278                }
279            }
280            _ => false,
281        }
282    }
283
284    fn focusable(&self) -> bool {
285        self.active()
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::icons::GlyphMode;
293    use crate::runtime::{App, Command, Harness};
294    use crate::widget::{Length, View};
295
296    struct Files {
297        path: Vec<&'static str>,
298        width: u16,
299    }
300
301    impl App for Files {
302        type Msg = usize;
303        fn update(&mut self, index: usize) -> Command<usize> {
304            self.path.truncate(index + 1);
305            Command::none()
306        }
307        fn view(&self, ui: &mut View<'_, usize>) {
308            ui.add(Breadcrumb::new(self.path.clone()).on_select(|i| i)).width(Length::Cells(self.width)).id("path");
309        }
310    }
311
312    fn files(width: u16) -> Files {
313        Files { path: vec!["workspace", "quvyta", "crates", "framework", "src", "widgets"], width }
314    }
315
316    #[test]
317    fn segments_open_levels_and_the_current_one_is_bold() {
318        let mut h = Harness::new(files(80), 80, 6);
319        assert_eq!(h.screen().lines().next(), Some(" workspace › quvyta › crates › framework › src › widgets"));
320        assert!(h.is_bold(50, 0), "the current place is bold");
321        assert!(!h.is_bold(2, 0));
322        h.click_text("crates");
323        assert_eq!(h.app().path, vec!["workspace", "quvyta", "crates"]);
324        h.click_text("crates");
325        assert_eq!(h.app().path.len(), 3, "the current place is not a link");
326        h.set_glyph_mode(GlyphMode::Ascii);
327        assert_eq!(h.screen().lines().next(), Some(" workspace : quvyta : crates"));
328    }
329
330    #[test]
331    fn narrow_paths_collapse_the_middle_and_list_it() {
332        let mut h = Harness::new(files(34), 34, 8);
333        assert_eq!(h.screen().lines().next(), Some(" workspace › … › src › widgets"));
334        h.click_text("…").advance(std::time::Duration::from_millis(300));
335        let screen = h.screen();
336        assert!(screen.contains("quvyta") && screen.contains("framework"), "{screen}");
337        h.click_text("framework");
338        assert_eq!(h.app().path.last(), Some(&"framework"));
339        let tiny = Harness::new(files(14), 14, 1);
340        assert_eq!(tiny.screen(), " … › widgets\n");
341    }
342
343    #[test]
344    fn keyboard_moves_between_segments() {
345        let mut h = Harness::new(files(80), 80, 6);
346        h.press("tab").press("left").press("left").press("enter");
347        assert_eq!(h.app().path, vec!["workspace", "quvyta", "crates"]);
348        h.press("home").press("enter");
349        assert_eq!(h.app().path, vec!["workspace"]);
350        assert!(!h.is_focused("path"), "a single segment has nothing to open");
351    }
352
353    #[test]
354    fn segments_wider_than_any_screen_do_not_overflow() {
355        let long: &'static str = "d".repeat(70_000).leak();
356        let h = Harness::new(Files { path: vec!["workspace", long, long, "src"], width: 24 }, 24, 1);
357        assert_eq!(h.screen(), " workspace › … › src\n");
358    }
359}