Skip to main content

rich_interact/
chrome.rs

1//! Chrome: a status bar (#481) and breadcrumbs (#482), the lines round a
2//! view that say where you are and what you can do (0.0.14 workstream 2).
3//!
4//! Both are components that take no focus, so they sit in any container
5//! beside the components they describe, and both are updated from outside
6//! through a shared handle while the view runs:
7//!
8//! - a [`StatusBar`] is one line of [`StatusItem`]s, on the left or the
9//!   right: console markup, key hints read from a [`Keymap`], spinners
10//!   that are indexed by time as core's are, and badges;
11//! - [`Breadcrumbs`] show a path (`project › src › main.rs`), eliding it
12//!   from the left when it does not fit, and a click on a crumb goes back
13//!   to it.
14//!
15//! [`Overlays`](crate::overlay::Overlays) puts them above and below any
16//! component and keeps the status bar's hints in step with the component's
17//! keymap.
18//!
19//! ```
20//! use rich_interact::chrome::{StatusBar, StatusItem};
21//! use rich_interact::keymap::{keys, Keymap};
22//! use rich_interact::{headless, Flow, Outcome};
23//!
24//! let bar: StatusBar = StatusBar::new()
25//!     .left("mode", StatusItem::badge("NORMAL", "bold reverse"))
26//!     .left("file", StatusItem::text("[bold]main.rs[/]"))
27//!     .right("keys", StatusItem::hints(2))
28//!     .keymap(Keymap::new("demo").bind("save", keys("ctrl+s"), "save"));
29//! let status = bar.handle();
30//! status.set("file", StatusItem::text("lib.rs"));
31//! assert!(bar.line_text(40).starts_with(" NORMAL  │ lib.rs"));
32//! assert!(bar.line_text(40).ends_with("ctrl+s save"));
33//! ```
34
35use std::cell::RefCell;
36use std::marker::PhantomData;
37use std::rc::Rc;
38use std::time::{Duration, Instant};
39
40use rich::cells::cell_len;
41use rich::{Segment, Style};
42
43use crate::component::{Component, Context, Flow, View};
44use crate::event::{Event, Key, KeyCode};
45use crate::keymap::Keymap;
46use crate::kit::{self, Theme};
47
48/// Rendered markup, on one line and unwrapped, at no particular width.
49fn inline(context: &Context<'_>, markup: &str) -> Vec<Segment> {
50    let text = rich::Text::from_markup(markup).unwrap_or_else(|_| rich::Text::new(markup));
51    let options = context.console.options().update_width(4096);
52    context
53        .console
54        .render_lines(&text, &options, false)
55        .into_iter()
56        .next()
57        .unwrap_or_default()
58}
59
60fn style(definition: &str) -> Style {
61    Style::parse(definition).unwrap_or_default()
62}
63
64/// A key as a hint shows it, shorter than its name: arrows as arrows,
65/// `esc`, `pgup`, `pgdn`, `shift+tab`; anything else by its name.
66pub fn key_hint(key: Key) -> String {
67    let short = match key.code {
68        KeyCode::Up => "↑",
69        KeyCode::Down => "↓",
70        KeyCode::Left => "←",
71        KeyCode::Right => "→",
72        KeyCode::Escape => "esc",
73        KeyCode::PageUp => "pgup",
74        KeyCode::PageDown => "pgdn",
75        KeyCode::BackTab => "shift+tab",
76        _ => return key.to_string(),
77    };
78    let mut hint = String::new();
79    if key.modifiers.ctrl {
80        hint.push_str("ctrl+");
81    }
82    if key.modifiers.alt {
83        hint.push_str("alt+");
84    }
85    if key.modifiers.shift && key.code != KeyCode::BackTab {
86        hint.push_str("shift+");
87    }
88    hint.push_str(short);
89    hint
90}
91
92/// Keys as hints, joined by `/`: `↑/ctrl+p`.
93pub fn keys_hint(keys: &[Key]) -> String {
94    keys_hint_of(keys, false)
95}
96
97/// [`keys_hint`], or for keys pressed one after another, joined with
98/// spaces: `g g`.
99pub fn keys_hint_of(keys: &[Key], sequence: bool) -> String {
100    keys.iter()
101        .map(|key| key_hint(*key))
102        .collect::<Vec<_>>()
103        .join(if sequence { " " } else { "/" })
104}
105
106// ---------------------------------------------------------------------------
107// StatusBar.
108
109/// One part of a [`StatusBar`].
110#[derive(Clone, Debug, PartialEq)]
111pub enum StatusItem {
112    /// Console markup: `[bold]main.rs[/] · 12 lines`.
113    Text(String),
114    /// Up to this many key hints (`ctrl+s save · esc cancel`), read from
115    /// the keymap the bar is given, or the component's under
116    /// [`Overlays`](crate::overlay::Overlays).
117    Hints(usize),
118    /// A spinner (one of core's, by name: `dots`, `line`, ...) and markup
119    /// after it. Its frame is picked by the time since the bar was made, as
120    /// core's `Spinner` picks it, and the bar ticks while one shows.
121    Spinner { name: String, text: String },
122    /// A short label in a style of its own, padded by a space each side:
123    /// a mode, a count, a state.
124    Badge { text: String, style: Style },
125    /// An icon, then markup: a micro asset's badge (`✅ deployed`). The
126    /// icon is one line of cells ([`kit::icon`]) with its style metadata,
127    /// so a micro asset's placeholder is drawn over by the painter's
128    /// graphics where the terminal can show images, and shows its emoji or
129    /// text fallback anywhere else.
130    Icon { icon: Vec<Segment>, text: String },
131}
132
133impl StatusItem {
134    pub fn text(markup: impl Into<String>) -> StatusItem {
135        StatusItem::Text(markup.into())
136    }
137
138    pub fn hints(max: usize) -> StatusItem {
139        StatusItem::Hints(max)
140    }
141
142    pub fn spinner(name: impl Into<String>, markup: impl Into<String>) -> StatusItem {
143        StatusItem::Spinner {
144            name: name.into(),
145            text: markup.into(),
146        }
147    }
148
149    /// `icon` (one line of text, such as a micro asset's placeholder),
150    /// then `markup`.
151    pub fn icon(icon: &rich::Text, markup: impl Into<String>) -> StatusItem {
152        StatusItem::Icon {
153            icon: kit::icon(icon),
154            text: markup.into(),
155        }
156    }
157
158    /// A micro asset (its placeholder, drawn by the painter's graphics
159    /// where the terminal can, its emoji or text elsewhere), then `markup`.
160    /// Needs the `micro` feature.
161    #[cfg(feature = "micro")]
162    pub fn micro(asset: &rich_micro::MicroAsset, markup: impl Into<String>) -> StatusItem {
163        StatusItem::icon(
164            &rich_micro::placeholder(asset, rich_micro::FallbackPreference::Emoji),
165            markup,
166        )
167    }
168
169    /// A badge in `style`, a style definition (`bold white on blue`); one
170    /// that does not parse leaves the badge unstyled.
171    pub fn badge(text: impl Into<String>, style_definition: &str) -> StatusItem {
172        StatusItem::Badge {
173            text: text.into(),
174            style: style(style_definition),
175        }
176    }
177}
178
179#[derive(Clone, Debug)]
180struct Entry {
181    id: String,
182    item: StatusItem,
183    right: bool,
184}
185
186#[derive(Default)]
187struct Shared {
188    entries: Vec<Entry>,
189    keymap: Option<Keymap>,
190}
191
192/// Changes a [`StatusBar`]'s items from anywhere while it shows: a
193/// component's handler, a timer, another thread's result as it arrives.
194#[derive(Clone, Default)]
195pub struct StatusHandle {
196    shared: Rc<RefCell<Shared>>,
197}
198
199impl StatusHandle {
200    /// Set item `id` to `item`, where it is; a new id goes last on the
201    /// left.
202    pub fn set(&self, id: &str, item: StatusItem) {
203        self.put(id, item, None);
204    }
205
206    /// Set item `id` on the left or (`right`) the right, moving it there.
207    pub fn put(&self, id: &str, item: StatusItem, right: Option<bool>) {
208        let mut shared = self.shared.borrow_mut();
209        match shared.entries.iter().position(|entry| entry.id == id) {
210            Some(index) if right.is_none_or(|right| right == shared.entries[index].right) => {
211                shared.entries[index].item = item;
212            }
213            found => {
214                if let Some(index) = found {
215                    shared.entries.remove(index);
216                }
217                shared.entries.push(Entry {
218                    id: id.to_string(),
219                    item,
220                    right: right.unwrap_or(false),
221                });
222            }
223        }
224    }
225
226    /// Remove item `id`; `false` when there was none.
227    pub fn remove(&self, id: &str) -> bool {
228        let mut shared = self.shared.borrow_mut();
229        let before = shared.entries.len();
230        shared.entries.retain(|entry| entry.id != id);
231        shared.entries.len() != before
232    }
233
234    /// Item `id`, if the bar has it.
235    pub fn get(&self, id: &str) -> Option<StatusItem> {
236        let shared = self.shared.borrow();
237        shared
238            .entries
239            .iter()
240            .find(|entry| entry.id == id)
241            .map(|entry| entry.item.clone())
242    }
243
244    /// The keymap [`StatusItem::Hints`] read.
245    pub fn set_keymap(&self, keymap: Keymap) {
246        self.shared.borrow_mut().keymap = Some(keymap);
247    }
248}
249
250/// A status line (#481): [`StatusItem`]s on the left and on the right,
251/// separated by `│`. It takes no focus and uses no events but ticks, so it
252/// sits under anything in a container, or under any component with
253/// [`Overlays::status_bar`](crate::overlay::Overlays::status_bar), which
254/// also feeds its hints the component's keymap. Change it while it shows
255/// through its [`handle`](Self::handle).
256#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
257pub struct StatusBar<M = ()> {
258    handle: StatusHandle,
259    style: Style,
260    separator: String,
261    theme: Theme,
262    clock: Rc<dyn Fn() -> Duration>,
263    /// Whether spinners turn; off, each shows its first frame.
264    animate: bool,
265    _output: PhantomData<fn() -> M>,
266}
267
268impl<M> Default for StatusBar<M> {
269    fn default() -> Self {
270        StatusBar::new()
271    }
272}
273
274impl<M> StatusBar<M> {
275    pub fn new() -> StatusBar<M> {
276        let started = Instant::now();
277        StatusBar {
278            handle: StatusHandle::default(),
279            style: Style::default(),
280            separator: "│".to_string(),
281            theme: Theme::default(),
282            clock: Rc::new(move || started.elapsed()),
283            animate: animation_allowed(&rich_ext::capabilities::SystemEnvironment),
284            _output: PhantomData,
285        }
286    }
287
288    /// Add `item` as `id` on the left.
289    pub fn left(self, id: &str, item: StatusItem) -> Self {
290        self.handle.put(id, item, Some(false));
291        self
292    }
293
294    /// Add `item` as `id` on the right.
295    pub fn right(self, id: &str, item: StatusItem) -> Self {
296        self.handle.put(id, item, Some(true));
297        self
298    }
299
300    /// The keymap hints read (see [`StatusHandle::set_keymap`]).
301    pub fn keymap(self, keymap: Keymap) -> Self {
302        self.handle.set_keymap(keymap);
303        self
304    }
305
306    /// The style of the whole line, padded to the width (default: none;
307    /// `on grey23` for a bar with a background).
308    pub fn style(mut self, style: Style) -> Self {
309        self.style = style;
310        self
311    }
312
313    /// What goes between items (default `│`).
314    pub fn separator(mut self, separator: impl Into<String>) -> Self {
315        self.separator = separator.into();
316        self
317    }
318
319    pub fn theme(mut self, theme: Theme) -> Self {
320        self.theme = theme;
321        self
322    }
323
324    /// Where spinners read the time from (default: the time since the bar
325    /// was made): a fixed clock makes a test's frames exact.
326    pub fn clock(mut self, clock: impl Fn() -> Duration + 'static) -> Self {
327        self.clock = Rc::new(clock);
328        self
329    }
330
331    /// Whether spinners turn (default: unless `RICH_ANIMATION=0`, or
332    /// `RICH_A11Y` asks for reduced motion, no animation or a screen
333    /// reader, as for every other animation). Off, a spinner shows its
334    /// first frame and the bar never ticks.
335    pub fn animate(mut self, on: bool) -> Self {
336        self.animate = on;
337        self
338    }
339
340    /// A handle that changes this bar's items while it shows.
341    pub fn handle(&self) -> StatusHandle {
342        self.handle.clone()
343    }
344
345    /// The line at `context`'s width, with hints read from `keymap` when
346    /// given, else from the bar's own.
347    pub fn line(&self, context: &Context<'_>, keymap: Option<&Keymap>) -> Vec<Segment> {
348        let shared = self.handle.shared.borrow();
349        let keymap = keymap.or(shared.keymap.as_ref());
350        let now = if self.animate {
351            (self.clock)()
352        } else {
353            Duration::ZERO
354        };
355        let part = |entry: &Entry| -> Vec<Segment> {
356            match &entry.item {
357                StatusItem::Text(markup) => inline(context, markup),
358                StatusItem::Hints(max) => self.hints(keymap, *max),
359                StatusItem::Spinner { name, text } => {
360                    let mut line = vec![kit::text(spinner_frame(name, now), &style("green"))];
361                    if !text.is_empty() {
362                        line.push(kit::plain(" "));
363                        line.extend(inline(context, text));
364                    }
365                    line
366                }
367                StatusItem::Badge { text, style } => vec![kit::text(format!(" {text} "), style)],
368                StatusItem::Icon { icon, text } => {
369                    let mut line = icon.clone();
370                    if !text.is_empty() {
371                        line.push(kit::plain(" "));
372                        line.extend(inline(context, text));
373                    }
374                    line
375                }
376            }
377        };
378        let join = |right: bool| -> Vec<Segment> {
379            let mut line = Vec::new();
380            for entry in shared.entries.iter().filter(|entry| entry.right == right) {
381                let part = part(entry);
382                if part.is_empty() {
383                    continue;
384                }
385                if !line.is_empty() {
386                    line.push(kit::text(format!(" {} ", self.separator), &self.theme.hint));
387                }
388                line.extend(part);
389            }
390            line
391        };
392        let (left, right) = (join(false), join(true));
393        let width = context.width;
394        let right_width = kit::width(&right);
395        let mut line = if right_width + 1 < width {
396            kit::pad(kit::fit(left, width - right_width - 1), width - right_width)
397        } else {
398            kit::fit(left, width)
399        };
400        if right_width + 1 < width {
401            line.extend(right);
402        }
403        let line = kit::pad(line, width);
404        if self.style == Style::default() {
405            line
406        } else {
407            kit::restyle(&line, &self.style)
408        }
409    }
410
411    /// The line at `width`, as plain text, for tests and logs.
412    pub fn line_text(&self, width: usize) -> String {
413        let console = rich::Console::new();
414        let context = Context {
415            console: &console,
416            width,
417            height: 1,
418        };
419        self.line(&context, None)
420            .iter()
421            .map(|segment| segment.text.as_str())
422            .collect::<String>()
423            .trim_end()
424            .to_string()
425    }
426
427    fn hints(&self, keymap: Option<&Keymap>, max: usize) -> Vec<Segment> {
428        let Some(keymap) = keymap else {
429            return Vec::new();
430        };
431        let mut line = Vec::new();
432        for binding in keymap
433            .bindings()
434            .into_iter()
435            .filter(|binding| !binding.keys.is_empty())
436            .take(max)
437        {
438            if !line.is_empty() {
439                line.push(kit::text(" · ", &self.theme.hint));
440            }
441            line.push(kit::text(key_hint(binding.keys[0]), &style("bold")));
442            line.push(kit::text(
443                format!(" {}", binding.description),
444                &self.theme.hint,
445            ));
446        }
447        line
448    }
449
450    /// The fastest spinner's interval, while one shows and turns.
451    fn spinner_interval(&self) -> Option<Duration> {
452        if !self.animate {
453            return None;
454        }
455        let shared = self.handle.shared.borrow();
456        shared
457            .entries
458            .iter()
459            .filter_map(|entry| match &entry.item {
460                StatusItem::Spinner { name, .. } => Some(spinner(name).0),
461                _ => None,
462            })
463            .min()
464    }
465}
466
467/// Whether animation may run in `env`: `RICH_ANIMATION` when set, else not
468/// when `RICH_A11Y` lists `reduced-motion`, `no-animation` or
469/// `screen-reader` (the same reading as the capability report's).
470fn animation_allowed(env: &dyn rich_ext::capabilities::Environment) -> bool {
471    if let Some(on) = env
472        .var("RICH_ANIMATION")
473        .and_then(|value| rich_ext::capabilities::parse_bool(&value))
474    {
475        return on;
476    }
477    let policy = rich_ext::a11y::AccessibilityPolicy::from_env(env);
478    !(policy.no_animation || policy.reduced_motion)
479}
480
481/// A spinner's interval and frames, by name; `dots` for one unknown.
482fn spinner(name: &str) -> (Duration, &'static [&'static str]) {
483    let (interval, frames) = rich::spinner::spinner_frames(name)
484        .or_else(|| rich::spinner::spinner_frames("dots"))
485        .expect("the dots spinner exists");
486    (Duration::from_secs_f64(interval / 1000.0), frames)
487}
488
489/// The frame of spinner `name` at `time`: the one core's `Spinner`
490/// renders at that time since it started.
491pub fn spinner_frame(name: &str, time: Duration) -> &'static str {
492    let (interval, frames) = spinner(name);
493    let index = time.as_nanos() / interval.as_nanos().max(1);
494    frames[(index % frames.len() as u128) as usize]
495}
496
497impl<M> Component for StatusBar<M> {
498    type Output = M;
499
500    fn handle(&mut self, event: &Event, _: &Context<'_>) -> Flow<M> {
501        match event {
502            // Repainted with the spinner's next frame.
503            Event::Tick => Flow::Continue,
504            _ => Flow::Ignored,
505        }
506    }
507
508    fn render(&self, context: &Context<'_>) -> View {
509        View::new(vec![self.line(context, None)])
510    }
511
512    fn tick(&self) -> Option<Duration> {
513        self.spinner_interval()
514    }
515
516    fn focusable(&self) -> bool {
517        false
518    }
519}
520
521// ---------------------------------------------------------------------------
522// Breadcrumbs.
523
524/// Changes a [`Breadcrumbs`]' path from anywhere while it shows.
525#[derive(Clone, Default, Debug)]
526pub struct Crumbs {
527    path: Rc<RefCell<Vec<String>>>,
528}
529
530impl Crumbs {
531    /// Go into `crumb`.
532    pub fn push(&self, crumb: impl Into<String>) {
533        self.path.borrow_mut().push(crumb.into());
534    }
535
536    /// Go back out of the last crumb.
537    pub fn pop(&self) -> Option<String> {
538        self.path.borrow_mut().pop()
539    }
540
541    /// Keep the first `len` crumbs.
542    pub fn truncate(&self, len: usize) {
543        self.path.borrow_mut().truncate(len);
544    }
545
546    /// Replace the whole path.
547    pub fn set<I, S>(&self, path: I)
548    where
549        I: IntoIterator<Item = S>,
550        S: Into<String>,
551    {
552        *self.path.borrow_mut() = path.into_iter().map(Into::into).collect();
553    }
554
555    pub fn get(&self) -> Vec<String> {
556        self.path.borrow().clone()
557    }
558}
559
560type PickHandler<'a, M> = Box<dyn FnMut(usize, &Crumbs) -> Flow<M> + 'a>;
561type IconFor<'a> = Box<dyn Fn(usize, &str) -> Option<rich::Text> + 'a>;
562
563/// Where you are, as a path of crumbs (#482): `project › src › main.rs`,
564/// the last in bold. A path wider than the line loses crumbs from the
565/// left, behind `…`. With the mouse on, a click on a crumb goes back to it
566/// (the path is cut after it), or does what [`on_pick`](Self::on_pick)
567/// says. It takes no focus and no keys.
568#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
569pub struct Breadcrumbs<'a, M = ()> {
570    crumbs: Crumbs,
571    icons: Option<IconFor<'a>>,
572    separator: String,
573    theme: Theme,
574    current: Style,
575    mouse: bool,
576    on_pick: Option<PickHandler<'a, M>>,
577}
578
579impl<'a, M> Breadcrumbs<'a, M> {
580    pub fn new<I, S>(path: I) -> Breadcrumbs<'a, M>
581    where
582        I: IntoIterator<Item = S>,
583        S: Into<String>,
584    {
585        let crumbs = Crumbs::default();
586        crumbs.set(path);
587        Breadcrumbs {
588            crumbs,
589            icons: None,
590            separator: "›".to_string(),
591            theme: Theme::default(),
592            current: style("bold"),
593            mouse: false,
594            on_pick: None,
595        }
596    }
597
598    /// An icon before a crumb: `icon(index, crumb)` returns one line of
599    /// text (a micro asset's placeholder, say) or `None`. It counts in the
600    /// crumb's width and clicks on it pick the crumb.
601    pub fn icons(mut self, icon: impl Fn(usize, &str) -> Option<rich::Text> + 'a) -> Self {
602        self.icons = Some(Box::new(icon));
603        self
604    }
605
606    /// What goes between crumbs (default `›`).
607    pub fn separator(mut self, separator: impl Into<String>) -> Self {
608        self.separator = separator.into();
609        self
610    }
611
612    pub fn theme(mut self, theme: Theme) -> Self {
613        self.theme = theme;
614        self
615    }
616
617    /// Clicks go back to a crumb (default: off).
618    pub fn with_mouse(mut self, on: bool) -> Self {
619        self.mouse = on;
620        self
621    }
622
623    /// What a click on crumb `index` does, instead of cutting the path
624    /// after it: given the index and the path's handle.
625    pub fn on_pick(mut self, handler: impl FnMut(usize, &Crumbs) -> Flow<M> + 'a) -> Self {
626        self.on_pick = Some(Box::new(handler));
627        self.mouse = true;
628        self
629    }
630
631    /// A handle that changes the path while it shows.
632    pub fn crumbs(&self) -> Crumbs {
633        self.crumbs.clone()
634    }
635
636    /// The line at `width`, and each crumb shown: its index and the cells
637    /// it takes.
638    pub fn layout(&self, width: usize) -> (Vec<Segment>, Vec<(usize, std::ops::Range<usize>)>) {
639        // As painted: a control in a crumb shows as a one-cell picture, so
640        // the spans clicks are matched against count it.
641        let path: Vec<String> = self
642            .crumbs
643            .get()
644            .iter()
645            .map(|crumb| kit::shown(crumb))
646            .collect();
647        let separator = format!(" {} ", self.separator);
648        let gap = cell_len(&separator);
649        let icons: Vec<Option<Vec<Segment>>> = path
650            .iter()
651            .enumerate()
652            .map(|(index, crumb)| {
653                self.icons
654                    .as_ref()
655                    .and_then(|icon| icon(index, crumb))
656                    .map(|text| kit::icon(&text))
657                    .filter(|icon| !icon.is_empty())
658            })
659            .collect();
660        let widths: Vec<usize> = path
661            .iter()
662            .zip(&icons)
663            .map(|(crumb, icon)| cell_len(crumb) + icon.as_ref().map_or(0, |i| kit::width(i) + 1))
664            .collect();
665        // Drop crumbs from the left until the rest fits after `… › `.
666        let mut first = 0;
667        let total = |from: usize| -> usize {
668            let shown: usize = widths[from..].iter().sum::<usize>() + gap * (path.len() - from - 1);
669            if from > 0 { shown + 1 + gap } else { shown }
670        };
671        while first + 1 < path.len() && total(first) > width {
672            first += 1;
673        }
674        let mut line = Vec::new();
675        let mut spans = Vec::new();
676        let mut column = 0;
677        if first > 0 {
678            line.push(kit::text("…", &self.theme.hint));
679            line.push(kit::text(separator.clone(), &self.theme.hint));
680            column += 1 + gap;
681        }
682        for (index, crumb) in path.iter().enumerate().skip(first) {
683            if index > first {
684                line.push(kit::text(separator.clone(), &self.theme.hint));
685                column += gap;
686            }
687            let last = index + 1 == path.len();
688            if let Some(icon) = &icons[index] {
689                line.extend(icon.iter().cloned());
690                line.push(kit::plain(" "));
691            }
692            line.push(if last {
693                kit::text(crumb.clone(), &self.current)
694            } else {
695                kit::plain(crumb.clone())
696            });
697            spans.push((index, column..column + widths[index]));
698            column += widths[index];
699        }
700        (kit::fit(line, width), spans)
701    }
702}
703
704impl<M> Component for Breadcrumbs<'_, M> {
705    type Output = M;
706
707    fn handle(&mut self, event: &Event, context: &Context<'_>) -> Flow<M> {
708        let Some(mouse) = event
709            .mouse()
710            .filter(|mouse| mouse.is_click() && mouse.row == 0)
711        else {
712            return Flow::Ignored;
713        };
714        let (_, spans) = self.layout(context.width);
715        let column = mouse.column as usize;
716        let Some((index, _)) = spans.into_iter().find(|(_, span)| span.contains(&column)) else {
717            return Flow::Ignored;
718        };
719        match &mut self.on_pick {
720            Some(handler) => handler(index, &self.crumbs),
721            None => {
722                self.crumbs.truncate(index + 1);
723                Flow::Continue
724            }
725        }
726    }
727
728    fn render(&self, context: &Context<'_>) -> View {
729        View::new(vec![self.layout(context.width).0])
730    }
731
732    fn mouse(&self) -> bool {
733        self.mouse
734    }
735
736    fn focusable(&self) -> bool {
737        false
738    }
739}
740
741#[cfg(test)]
742mod tests {
743    use super::*;
744
745    fn text(line: &[Segment]) -> String {
746        line.iter().map(|segment| segment.text.as_str()).collect()
747    }
748
749    #[test]
750    fn hints_shorten_keys() {
751        let keys = crate::keymap::keys("up ctrl+p escape pagedown shift+tab alt+left f1");
752        assert_eq!(keys_hint(&keys), "↑/ctrl+p/esc/pgdn/shift+tab/alt+←/f1");
753    }
754
755    #[test]
756    fn spinners_are_indexed_by_time() {
757        // `line` is `-\|/` at 130ms a frame.
758        assert_eq!(spinner_frame("line", Duration::ZERO), "-");
759        assert_eq!(spinner_frame("line", Duration::from_millis(130)), "\\");
760        assert_eq!(spinner_frame("line", Duration::from_millis(4 * 130)), "-");
761        assert_eq!(spinner_frame("no-such", Duration::ZERO), "⠋");
762        let bar: StatusBar = StatusBar::new()
763            .left("work", StatusItem::spinner("line", "building"))
764            .clock(|| Duration::from_millis(270))
765            .animate(true);
766        assert_eq!(bar.line_text(30), "| building");
767        assert_eq!(Component::tick(&bar), Some(Duration::from_millis(130)));
768    }
769
770    /// Reduced motion stills a spinner on its first frame, and the bar
771    /// stops ticking, like every other animation (0.0.14 release-test
772    /// audit B).
773    #[test]
774    fn spinners_hold_still_under_reduced_motion() {
775        use rich_ext::capabilities::MapEnvironment;
776        let env = |pairs: &[(&str, &str)]| {
777            let mut env = MapEnvironment::tty();
778            for (name, value) in pairs {
779                env.vars.insert(name.to_string(), value.to_string());
780            }
781            env
782        };
783        assert!(animation_allowed(&env(&[])));
784        assert!(!animation_allowed(&env(&[("RICH_A11Y", "reduced-motion")])));
785        assert!(!animation_allowed(&env(&[("RICH_A11Y", "screen-reader")])));
786        assert!(!animation_allowed(&env(&[("RICH_ANIMATION", "0")])));
787        assert!(animation_allowed(&env(&[
788            ("RICH_A11Y", "reduced-motion"),
789            ("RICH_ANIMATION", "1")
790        ])));
791        let bar: StatusBar = StatusBar::new()
792            .left("work", StatusItem::spinner("line", "building"))
793            .clock(|| Duration::from_millis(270))
794            .animate(false);
795        assert_eq!(bar.line_text(30), "- building");
796        assert_eq!(Component::tick(&bar), None);
797    }
798
799    #[test]
800    fn items_update_through_the_handle_and_the_right_side_fits() {
801        let bar: StatusBar = StatusBar::new()
802            .left("a", StatusItem::text("one"))
803            .right("b", StatusItem::badge("OK", "green"));
804        let handle = bar.handle();
805        handle.set("a", StatusItem::text("[bold]two[/]"));
806        handle.set("c", StatusItem::text("three"));
807        assert_eq!(bar.line_text(24), "two │ three          OK");
808        assert!(handle.remove("c"));
809        assert_eq!(bar.line_text(12), "two      OK");
810        // Too narrow for both: the left side only, cut.
811        assert_eq!(bar.line_text(3), "two");
812    }
813
814    #[test]
815    fn breadcrumbs_elide_from_the_left() {
816        let crumbs: Breadcrumbs = Breadcrumbs::new(["home", "project", "src", "main.rs"]);
817        assert_eq!(text(&crumbs.layout(40).0), "home › project › src › main.rs");
818        assert_eq!(text(&crumbs.layout(18).0), "… › src › main.rs");
819        let (_, spans) = crumbs.layout(40);
820        assert_eq!(spans[1], (1, 7..14));
821        let crumbs: Breadcrumbs = Breadcrumbs::new(["home", "src"])
822            .icons(|index, _| (index == 1).then(|| rich::Text::new("[]")));
823        let (line, spans) = crumbs.layout(40);
824        assert_eq!(text(&line), "home › [] src");
825        assert_eq!(spans[1], (1, 7..13));
826    }
827
828    /// A crumb holding controls is measured as painted (each control a
829    /// one-cell picture), so a click lands on the crumb under it (0.0.14
830    /// release-test audit B).
831    #[test]
832    fn breadcrumbs_measure_controls_as_painted() {
833        let crumbs: Breadcrumbs = Breadcrumbs::new(["a\u{1b}\u{1b}\u{1b}", "bb"]);
834        let (line, spans) = crumbs.layout(40);
835        assert_eq!(text(&line), "a␛␛␛ › bb");
836        assert_eq!(spans, [(0, 0..4), (1, 7..9)]);
837    }
838
839    #[test]
840    fn icons_keep_their_metadata() {
841        let mut meta = rich::style::Meta::new();
842        meta.insert("rich.micro", rich::style::MetaValue::Str("x".into()));
843        let mut icon = rich::Text::new("");
844        icon.append("ok", Some(Style::from_meta(meta).into()));
845        let bar: StatusBar = StatusBar::new().left("a", StatusItem::icon(&icon, "deployed"));
846        assert_eq!(bar.line_text(20), "ok deployed");
847        let StatusItem::Icon { icon, .. } = StatusItem::icon(&icon, "") else {
848            unreachable!()
849        };
850        assert!(icon[0].style.as_ref().unwrap().meta_ref().is_some());
851    }
852}