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> {
48 segments: Vec<String>,
49 on_select: Option<IndexMessage<Msg>>,
50 faint: bool,
51}
52
53impl<Msg: 'static> Breadcrumb<Msg> {
54 #[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 #[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 #[must_use]
73 pub fn faint(mut self, faint: bool) -> Self {
74 self.faint = faint;
75 self
76 }
77
78 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 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 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 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}