1use 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
48fn 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
64pub 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
92pub fn keys_hint(keys: &[Key]) -> String {
94 keys_hint_of(keys, false)
95}
96
97pub 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#[derive(Clone, Debug, PartialEq)]
111pub enum StatusItem {
112 Text(String),
114 Hints(usize),
118 Spinner { name: String, text: String },
122 Badge { text: String, style: Style },
125 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 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 #[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 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#[derive(Clone, Default)]
195pub struct StatusHandle {
196 shared: Rc<RefCell<Shared>>,
197}
198
199impl StatusHandle {
200 pub fn set(&self, id: &str, item: StatusItem) {
203 self.put(id, item, None);
204 }
205
206 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 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 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 pub fn set_keymap(&self, keymap: Keymap) {
246 self.shared.borrow_mut().keymap = Some(keymap);
247 }
248}
249
250#[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 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 pub fn left(self, id: &str, item: StatusItem) -> Self {
290 self.handle.put(id, item, Some(false));
291 self
292 }
293
294 pub fn right(self, id: &str, item: StatusItem) -> Self {
296 self.handle.put(id, item, Some(true));
297 self
298 }
299
300 pub fn keymap(self, keymap: Keymap) -> Self {
302 self.handle.set_keymap(keymap);
303 self
304 }
305
306 pub fn style(mut self, style: Style) -> Self {
309 self.style = style;
310 self
311 }
312
313 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 pub fn clock(mut self, clock: impl Fn() -> Duration + 'static) -> Self {
327 self.clock = Rc::new(clock);
328 self
329 }
330
331 pub fn animate(mut self, on: bool) -> Self {
336 self.animate = on;
337 self
338 }
339
340 pub fn handle(&self) -> StatusHandle {
342 self.handle.clone()
343 }
344
345 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 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 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
467fn 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
481fn 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
489pub 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 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#[derive(Clone, Default, Debug)]
526pub struct Crumbs {
527 path: Rc<RefCell<Vec<String>>>,
528}
529
530impl Crumbs {
531 pub fn push(&self, crumb: impl Into<String>) {
533 self.path.borrow_mut().push(crumb.into());
534 }
535
536 pub fn pop(&self) -> Option<String> {
538 self.path.borrow_mut().pop()
539 }
540
541 pub fn truncate(&self, len: usize) {
543 self.path.borrow_mut().truncate(len);
544 }
545
546 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#[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 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 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 pub fn with_mouse(mut self, on: bool) -> Self {
619 self.mouse = on;
620 self
621 }
622
623 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 pub fn crumbs(&self) -> Crumbs {
633 self.crumbs.clone()
634 }
635
636 pub fn layout(&self, width: usize) -> (Vec<Segment>, Vec<(usize, std::ops::Range<usize>)>) {
639 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 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 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 #[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 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 #[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}