use std::cell::RefCell;
use std::marker::PhantomData;
use std::rc::Rc;
use std::time::{Duration, Instant};
use rich::cells::cell_len;
use rich::{Segment, Style};
use crate::component::{Component, Context, Flow, View};
use crate::event::{Event, Key, KeyCode};
use crate::keymap::Keymap;
use crate::kit::{self, Theme};
fn inline(context: &Context<'_>, markup: &str) -> Vec<Segment> {
let text = rich::Text::from_markup(markup).unwrap_or_else(|_| rich::Text::new(markup));
let options = context.console.options().update_width(4096);
context
.console
.render_lines(&text, &options, false)
.into_iter()
.next()
.unwrap_or_default()
}
fn style(definition: &str) -> Style {
Style::parse(definition).unwrap_or_default()
}
pub fn key_hint(key: Key) -> String {
let short = match key.code {
KeyCode::Up => "↑",
KeyCode::Down => "↓",
KeyCode::Left => "←",
KeyCode::Right => "→",
KeyCode::Escape => "esc",
KeyCode::PageUp => "pgup",
KeyCode::PageDown => "pgdn",
KeyCode::BackTab => "shift+tab",
_ => return key.to_string(),
};
let mut hint = String::new();
if key.modifiers.ctrl {
hint.push_str("ctrl+");
}
if key.modifiers.alt {
hint.push_str("alt+");
}
if key.modifiers.shift && key.code != KeyCode::BackTab {
hint.push_str("shift+");
}
hint.push_str(short);
hint
}
pub fn keys_hint(keys: &[Key]) -> String {
keys_hint_of(keys, false)
}
pub fn keys_hint_of(keys: &[Key], sequence: bool) -> String {
keys.iter()
.map(|key| key_hint(*key))
.collect::<Vec<_>>()
.join(if sequence { " " } else { "/" })
}
#[derive(Clone, Debug, PartialEq)]
pub enum StatusItem {
Text(String),
Hints(usize),
Spinner { name: String, text: String },
Badge { text: String, style: Style },
Icon { icon: Vec<Segment>, text: String },
}
impl StatusItem {
pub fn text(markup: impl Into<String>) -> StatusItem {
StatusItem::Text(markup.into())
}
pub fn hints(max: usize) -> StatusItem {
StatusItem::Hints(max)
}
pub fn spinner(name: impl Into<String>, markup: impl Into<String>) -> StatusItem {
StatusItem::Spinner {
name: name.into(),
text: markup.into(),
}
}
pub fn icon(icon: &rich::Text, markup: impl Into<String>) -> StatusItem {
StatusItem::Icon {
icon: kit::icon(icon),
text: markup.into(),
}
}
#[cfg(feature = "micro")]
pub fn micro(asset: &rich_micro::MicroAsset, markup: impl Into<String>) -> StatusItem {
StatusItem::icon(
&rich_micro::placeholder(asset, rich_micro::FallbackPreference::Emoji),
markup,
)
}
pub fn badge(text: impl Into<String>, style_definition: &str) -> StatusItem {
StatusItem::Badge {
text: text.into(),
style: style(style_definition),
}
}
}
#[derive(Clone, Debug)]
struct Entry {
id: String,
item: StatusItem,
right: bool,
}
#[derive(Default)]
struct Shared {
entries: Vec<Entry>,
keymap: Option<Keymap>,
}
#[derive(Clone, Default)]
pub struct StatusHandle {
shared: Rc<RefCell<Shared>>,
}
impl StatusHandle {
pub fn set(&self, id: &str, item: StatusItem) {
self.put(id, item, None);
}
pub fn put(&self, id: &str, item: StatusItem, right: Option<bool>) {
let mut shared = self.shared.borrow_mut();
match shared.entries.iter().position(|entry| entry.id == id) {
Some(index) if right.is_none_or(|right| right == shared.entries[index].right) => {
shared.entries[index].item = item;
}
found => {
if let Some(index) = found {
shared.entries.remove(index);
}
shared.entries.push(Entry {
id: id.to_string(),
item,
right: right.unwrap_or(false),
});
}
}
}
pub fn remove(&self, id: &str) -> bool {
let mut shared = self.shared.borrow_mut();
let before = shared.entries.len();
shared.entries.retain(|entry| entry.id != id);
shared.entries.len() != before
}
pub fn get(&self, id: &str) -> Option<StatusItem> {
let shared = self.shared.borrow();
shared
.entries
.iter()
.find(|entry| entry.id == id)
.map(|entry| entry.item.clone())
}
pub fn set_keymap(&self, keymap: Keymap) {
self.shared.borrow_mut().keymap = Some(keymap);
}
}
#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
pub struct StatusBar<M = ()> {
handle: StatusHandle,
style: Style,
separator: String,
theme: Theme,
clock: Rc<dyn Fn() -> Duration>,
animate: bool,
_output: PhantomData<fn() -> M>,
}
impl<M> Default for StatusBar<M> {
fn default() -> Self {
StatusBar::new()
}
}
impl<M> StatusBar<M> {
pub fn new() -> StatusBar<M> {
let started = Instant::now();
StatusBar {
handle: StatusHandle::default(),
style: Style::default(),
separator: "│".to_string(),
theme: Theme::default(),
clock: Rc::new(move || started.elapsed()),
animate: animation_allowed(&rich_ext::capabilities::SystemEnvironment),
_output: PhantomData,
}
}
pub fn left(self, id: &str, item: StatusItem) -> Self {
self.handle.put(id, item, Some(false));
self
}
pub fn right(self, id: &str, item: StatusItem) -> Self {
self.handle.put(id, item, Some(true));
self
}
pub fn keymap(self, keymap: Keymap) -> Self {
self.handle.set_keymap(keymap);
self
}
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
pub fn separator(mut self, separator: impl Into<String>) -> Self {
self.separator = separator.into();
self
}
pub fn theme(mut self, theme: Theme) -> Self {
self.theme = theme;
self
}
pub fn clock(mut self, clock: impl Fn() -> Duration + 'static) -> Self {
self.clock = Rc::new(clock);
self
}
pub fn animate(mut self, on: bool) -> Self {
self.animate = on;
self
}
pub fn handle(&self) -> StatusHandle {
self.handle.clone()
}
pub fn line(&self, context: &Context<'_>, keymap: Option<&Keymap>) -> Vec<Segment> {
let shared = self.handle.shared.borrow();
let keymap = keymap.or(shared.keymap.as_ref());
let now = if self.animate {
(self.clock)()
} else {
Duration::ZERO
};
let part = |entry: &Entry| -> Vec<Segment> {
match &entry.item {
StatusItem::Text(markup) => inline(context, markup),
StatusItem::Hints(max) => self.hints(keymap, *max),
StatusItem::Spinner { name, text } => {
let mut line = vec![kit::text(spinner_frame(name, now), &style("green"))];
if !text.is_empty() {
line.push(kit::plain(" "));
line.extend(inline(context, text));
}
line
}
StatusItem::Badge { text, style } => vec![kit::text(format!(" {text} "), style)],
StatusItem::Icon { icon, text } => {
let mut line = icon.clone();
if !text.is_empty() {
line.push(kit::plain(" "));
line.extend(inline(context, text));
}
line
}
}
};
let join = |right: bool| -> Vec<Segment> {
let mut line = Vec::new();
for entry in shared.entries.iter().filter(|entry| entry.right == right) {
let part = part(entry);
if part.is_empty() {
continue;
}
if !line.is_empty() {
line.push(kit::text(format!(" {} ", self.separator), &self.theme.hint));
}
line.extend(part);
}
line
};
let (left, right) = (join(false), join(true));
let width = context.width;
let right_width = kit::width(&right);
let mut line = if right_width + 1 < width {
kit::pad(kit::fit(left, width - right_width - 1), width - right_width)
} else {
kit::fit(left, width)
};
if right_width + 1 < width {
line.extend(right);
}
let line = kit::pad(line, width);
if self.style == Style::default() {
line
} else {
kit::restyle(&line, &self.style)
}
}
pub fn line_text(&self, width: usize) -> String {
let console = rich::Console::new();
let context = Context {
console: &console,
width,
height: 1,
};
self.line(&context, None)
.iter()
.map(|segment| segment.text.as_str())
.collect::<String>()
.trim_end()
.to_string()
}
fn hints(&self, keymap: Option<&Keymap>, max: usize) -> Vec<Segment> {
let Some(keymap) = keymap else {
return Vec::new();
};
let mut line = Vec::new();
for binding in keymap
.bindings()
.into_iter()
.filter(|binding| !binding.keys.is_empty())
.take(max)
{
if !line.is_empty() {
line.push(kit::text(" · ", &self.theme.hint));
}
line.push(kit::text(key_hint(binding.keys[0]), &style("bold")));
line.push(kit::text(
format!(" {}", binding.description),
&self.theme.hint,
));
}
line
}
fn spinner_interval(&self) -> Option<Duration> {
if !self.animate {
return None;
}
let shared = self.handle.shared.borrow();
shared
.entries
.iter()
.filter_map(|entry| match &entry.item {
StatusItem::Spinner { name, .. } => Some(spinner(name).0),
_ => None,
})
.min()
}
}
fn animation_allowed(env: &dyn rich_ext::capabilities::Environment) -> bool {
if let Some(on) = env
.var("RICH_ANIMATION")
.and_then(|value| rich_ext::capabilities::parse_bool(&value))
{
return on;
}
let policy = rich_ext::a11y::AccessibilityPolicy::from_env(env);
!(policy.no_animation || policy.reduced_motion)
}
fn spinner(name: &str) -> (Duration, &'static [&'static str]) {
let (interval, frames) = rich::spinner::spinner_frames(name)
.or_else(|| rich::spinner::spinner_frames("dots"))
.expect("the dots spinner exists");
(Duration::from_secs_f64(interval / 1000.0), frames)
}
pub fn spinner_frame(name: &str, time: Duration) -> &'static str {
let (interval, frames) = spinner(name);
let index = time.as_nanos() / interval.as_nanos().max(1);
frames[(index % frames.len() as u128) as usize]
}
impl<M> Component for StatusBar<M> {
type Output = M;
fn handle(&mut self, event: &Event, _: &Context<'_>) -> Flow<M> {
match event {
Event::Tick => Flow::Continue,
_ => Flow::Ignored,
}
}
fn render(&self, context: &Context<'_>) -> View {
View::new(vec![self.line(context, None)])
}
fn tick(&self) -> Option<Duration> {
self.spinner_interval()
}
fn focusable(&self) -> bool {
false
}
}
#[derive(Clone, Default, Debug)]
pub struct Crumbs {
path: Rc<RefCell<Vec<String>>>,
}
impl Crumbs {
pub fn push(&self, crumb: impl Into<String>) {
self.path.borrow_mut().push(crumb.into());
}
pub fn pop(&self) -> Option<String> {
self.path.borrow_mut().pop()
}
pub fn truncate(&self, len: usize) {
self.path.borrow_mut().truncate(len);
}
pub fn set<I, S>(&self, path: I)
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
*self.path.borrow_mut() = path.into_iter().map(Into::into).collect();
}
pub fn get(&self) -> Vec<String> {
self.path.borrow().clone()
}
}
type PickHandler<'a, M> = Box<dyn FnMut(usize, &Crumbs) -> Flow<M> + 'a>;
type IconFor<'a> = Box<dyn Fn(usize, &str) -> Option<rich::Text> + 'a>;
#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
pub struct Breadcrumbs<'a, M = ()> {
crumbs: Crumbs,
icons: Option<IconFor<'a>>,
separator: String,
theme: Theme,
current: Style,
mouse: bool,
on_pick: Option<PickHandler<'a, M>>,
}
impl<'a, M> Breadcrumbs<'a, M> {
pub fn new<I, S>(path: I) -> Breadcrumbs<'a, M>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let crumbs = Crumbs::default();
crumbs.set(path);
Breadcrumbs {
crumbs,
icons: None,
separator: "›".to_string(),
theme: Theme::default(),
current: style("bold"),
mouse: false,
on_pick: None,
}
}
pub fn icons(mut self, icon: impl Fn(usize, &str) -> Option<rich::Text> + 'a) -> Self {
self.icons = Some(Box::new(icon));
self
}
pub fn separator(mut self, separator: impl Into<String>) -> Self {
self.separator = separator.into();
self
}
pub fn theme(mut self, theme: Theme) -> Self {
self.theme = theme;
self
}
pub fn with_mouse(mut self, on: bool) -> Self {
self.mouse = on;
self
}
pub fn on_pick(mut self, handler: impl FnMut(usize, &Crumbs) -> Flow<M> + 'a) -> Self {
self.on_pick = Some(Box::new(handler));
self.mouse = true;
self
}
pub fn crumbs(&self) -> Crumbs {
self.crumbs.clone()
}
pub fn layout(&self, width: usize) -> (Vec<Segment>, Vec<(usize, std::ops::Range<usize>)>) {
let path: Vec<String> = self
.crumbs
.get()
.iter()
.map(|crumb| kit::shown(crumb))
.collect();
let separator = format!(" {} ", self.separator);
let gap = cell_len(&separator);
let icons: Vec<Option<Vec<Segment>>> = path
.iter()
.enumerate()
.map(|(index, crumb)| {
self.icons
.as_ref()
.and_then(|icon| icon(index, crumb))
.map(|text| kit::icon(&text))
.filter(|icon| !icon.is_empty())
})
.collect();
let widths: Vec<usize> = path
.iter()
.zip(&icons)
.map(|(crumb, icon)| cell_len(crumb) + icon.as_ref().map_or(0, |i| kit::width(i) + 1))
.collect();
let mut first = 0;
let total = |from: usize| -> usize {
let shown: usize = widths[from..].iter().sum::<usize>() + gap * (path.len() - from - 1);
if from > 0 { shown + 1 + gap } else { shown }
};
while first + 1 < path.len() && total(first) > width {
first += 1;
}
let mut line = Vec::new();
let mut spans = Vec::new();
let mut column = 0;
if first > 0 {
line.push(kit::text("…", &self.theme.hint));
line.push(kit::text(separator.clone(), &self.theme.hint));
column += 1 + gap;
}
for (index, crumb) in path.iter().enumerate().skip(first) {
if index > first {
line.push(kit::text(separator.clone(), &self.theme.hint));
column += gap;
}
let last = index + 1 == path.len();
if let Some(icon) = &icons[index] {
line.extend(icon.iter().cloned());
line.push(kit::plain(" "));
}
line.push(if last {
kit::text(crumb.clone(), &self.current)
} else {
kit::plain(crumb.clone())
});
spans.push((index, column..column + widths[index]));
column += widths[index];
}
(kit::fit(line, width), spans)
}
}
impl<M> Component for Breadcrumbs<'_, M> {
type Output = M;
fn handle(&mut self, event: &Event, context: &Context<'_>) -> Flow<M> {
let Some(mouse) = event
.mouse()
.filter(|mouse| mouse.is_click() && mouse.row == 0)
else {
return Flow::Ignored;
};
let (_, spans) = self.layout(context.width);
let column = mouse.column as usize;
let Some((index, _)) = spans.into_iter().find(|(_, span)| span.contains(&column)) else {
return Flow::Ignored;
};
match &mut self.on_pick {
Some(handler) => handler(index, &self.crumbs),
None => {
self.crumbs.truncate(index + 1);
Flow::Continue
}
}
}
fn render(&self, context: &Context<'_>) -> View {
View::new(vec![self.layout(context.width).0])
}
fn mouse(&self) -> bool {
self.mouse
}
fn focusable(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
fn text(line: &[Segment]) -> String {
line.iter().map(|segment| segment.text.as_str()).collect()
}
#[test]
fn hints_shorten_keys() {
let keys = crate::keymap::keys("up ctrl+p escape pagedown shift+tab alt+left f1");
assert_eq!(keys_hint(&keys), "↑/ctrl+p/esc/pgdn/shift+tab/alt+←/f1");
}
#[test]
fn spinners_are_indexed_by_time() {
assert_eq!(spinner_frame("line", Duration::ZERO), "-");
assert_eq!(spinner_frame("line", Duration::from_millis(130)), "\\");
assert_eq!(spinner_frame("line", Duration::from_millis(4 * 130)), "-");
assert_eq!(spinner_frame("no-such", Duration::ZERO), "⠋");
let bar: StatusBar = StatusBar::new()
.left("work", StatusItem::spinner("line", "building"))
.clock(|| Duration::from_millis(270))
.animate(true);
assert_eq!(bar.line_text(30), "| building");
assert_eq!(Component::tick(&bar), Some(Duration::from_millis(130)));
}
#[test]
fn spinners_hold_still_under_reduced_motion() {
use rich_ext::capabilities::MapEnvironment;
let env = |pairs: &[(&str, &str)]| {
let mut env = MapEnvironment::tty();
for (name, value) in pairs {
env.vars.insert(name.to_string(), value.to_string());
}
env
};
assert!(animation_allowed(&env(&[])));
assert!(!animation_allowed(&env(&[("RICH_A11Y", "reduced-motion")])));
assert!(!animation_allowed(&env(&[("RICH_A11Y", "screen-reader")])));
assert!(!animation_allowed(&env(&[("RICH_ANIMATION", "0")])));
assert!(animation_allowed(&env(&[
("RICH_A11Y", "reduced-motion"),
("RICH_ANIMATION", "1")
])));
let bar: StatusBar = StatusBar::new()
.left("work", StatusItem::spinner("line", "building"))
.clock(|| Duration::from_millis(270))
.animate(false);
assert_eq!(bar.line_text(30), "- building");
assert_eq!(Component::tick(&bar), None);
}
#[test]
fn items_update_through_the_handle_and_the_right_side_fits() {
let bar: StatusBar = StatusBar::new()
.left("a", StatusItem::text("one"))
.right("b", StatusItem::badge("OK", "green"));
let handle = bar.handle();
handle.set("a", StatusItem::text("[bold]two[/]"));
handle.set("c", StatusItem::text("three"));
assert_eq!(bar.line_text(24), "two │ three OK");
assert!(handle.remove("c"));
assert_eq!(bar.line_text(12), "two OK");
assert_eq!(bar.line_text(3), "two");
}
#[test]
fn breadcrumbs_elide_from_the_left() {
let crumbs: Breadcrumbs = Breadcrumbs::new(["home", "project", "src", "main.rs"]);
assert_eq!(text(&crumbs.layout(40).0), "home › project › src › main.rs");
assert_eq!(text(&crumbs.layout(18).0), "… › src › main.rs");
let (_, spans) = crumbs.layout(40);
assert_eq!(spans[1], (1, 7..14));
let crumbs: Breadcrumbs = Breadcrumbs::new(["home", "src"])
.icons(|index, _| (index == 1).then(|| rich::Text::new("[]")));
let (line, spans) = crumbs.layout(40);
assert_eq!(text(&line), "home › [] src");
assert_eq!(spans[1], (1, 7..13));
}
#[test]
fn breadcrumbs_measure_controls_as_painted() {
let crumbs: Breadcrumbs = Breadcrumbs::new(["a\u{1b}\u{1b}\u{1b}", "bb"]);
let (line, spans) = crumbs.layout(40);
assert_eq!(text(&line), "a␛␛␛ › bb");
assert_eq!(spans, [(0, 0..4), (1, 7..9)]);
}
#[test]
fn icons_keep_their_metadata() {
let mut meta = rich::style::Meta::new();
meta.insert("rich.micro", rich::style::MetaValue::Str("x".into()));
let mut icon = rich::Text::new("");
icon.append("ok", Some(Style::from_meta(meta).into()));
let bar: StatusBar = StatusBar::new().left("a", StatusItem::icon(&icon, "deployed"));
assert_eq!(bar.line_text(20), "ok deployed");
let StatusItem::Icon { icon, .. } = StatusItem::icon(&icon, "") else {
unreachable!()
};
assert!(icon[0].style.as_ref().unwrap().meta_ref().is_some());
}
}