1use 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
14const PAD: u16 = 1;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19enum Part {
20 Segment(usize),
22 More,
24}
25
26#[derive(Debug, Default)]
27struct CrumbMemory {
28 cursor: Option<Part>,
29 hidden: Vec<usize>,
30}
31
32pub struct Breadcrumb<Msg> {
44 segments: Vec<String>,
45 on_select: Option<IndexMessage<Msg>>,
46}
47
48impl<Msg: 'static> Breadcrumb<Msg> {
49 #[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 #[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 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 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 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}