use ratatui::{
style::{Color, Modifier, Style},
text::{Line, Span},
};
use crate::ui::theme::Theme;
pub(crate) const CHIP_LABEL_FG: Color = Color::Rgb(0, 0, 0);
#[inline]
pub fn primary(t: &Theme) -> Style {
Style::default()
.fg(CHIP_LABEL_FG)
.bg(t.green)
.add_modifier(Modifier::BOLD)
}
#[inline]
pub fn secondary(t: &Theme) -> Style {
Style::default()
.fg(CHIP_LABEL_FG)
.bg(t.purple)
.add_modifier(Modifier::BOLD)
}
#[inline]
pub fn link(t: &Theme, bg: Color) -> Style {
Style::default()
.fg(t.green)
.bg(bg)
.add_modifier(Modifier::BOLD)
}
pub fn chip_line(label: &str, role_style: Style) -> Line<'_> {
Line::from(vec![
Span::styled(" ", role_style),
Span::styled(label.to_string(), role_style),
Span::styled(" ", role_style),
])
}
#[inline]
pub fn chip_width(label: &str) -> u16 {
(label.chars().count() as u16).saturating_add(2)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ButtonState {
#[default]
Normal,
Disabled,
Active,
Busy,
}
#[derive(Debug, Clone)]
pub struct Button<'a> {
pub icon: Option<&'a str>,
pub label: &'a str,
pub fill: Color,
pub text: Color,
pub accent: Option<Color>,
pub bold: bool,
pub state: ButtonState,
}
impl<'a> Button<'a> {
pub fn toolbar(t: &Theme, label: &'a str) -> Self {
Self {
icon: None,
label,
fill: t.bg2,
text: t.fg,
accent: None,
bold: true,
state: ButtonState::Normal,
}
}
pub fn compact(self) -> Self {
if self.icon.is_none() {
return self;
}
Self { label: "", ..self }
}
pub fn refresh(t: &Theme, ascii: bool, label: Option<&'a str>) -> Self {
Self {
icon: Some(crate::ui::refresh_glyph::for_ascii(ascii)),
label: label.unwrap_or(""),
fill: t.bg2,
text: t.fg,
accent: Some(t.blue),
bold: false,
state: ButtonState::Normal,
}
}
pub fn primary(t: &Theme, label: &'a str) -> Self {
Self {
icon: None,
label,
fill: t.green,
text: CHIP_LABEL_FG,
accent: None,
bold: true,
state: ButtonState::Normal,
}
}
pub fn secondary(t: &Theme, label: &'a str) -> Self {
Self {
icon: None,
label,
fill: t.purple,
text: CHIP_LABEL_FG,
accent: None,
bold: true,
state: ButtonState::Normal,
}
}
pub fn icon(mut self, icon: &'a str) -> Self {
self.icon = Some(icon);
self
}
pub fn accent(mut self, c: Color) -> Self {
self.accent = Some(c);
self
}
pub fn fill(mut self, c: Color) -> Self {
self.fill = c;
self
}
pub fn text(mut self, c: Color) -> Self {
self.text = c;
self
}
pub fn bold(mut self, on: bool) -> Self {
self.bold = on;
self
}
pub fn state(mut self, s: ButtonState) -> Self {
self.state = s;
self
}
pub fn width(&self) -> u16 {
let icon_w = self.icon.map(|i| i.chars().count() as u16 + 1).unwrap_or(0);
icon_w
.saturating_add(self.label.chars().count() as u16)
.saturating_add(2)
}
pub fn fit_row(buttons: Vec<Button<'a>>, avail: u16, sep: u16) -> (Vec<Button<'a>>, u16) {
let width_of = |bs: &[Button<'a>]| -> u16 {
let inner: u16 = bs.iter().map(|b| b.width()).sum();
inner.saturating_add(sep.saturating_mul(bs.len().saturating_sub(1) as u16))
};
let full = width_of(&buttons);
if full <= avail {
return (buttons, full);
}
let compact: Vec<Button<'a>> = buttons.into_iter().map(|b| b.compact()).collect();
let w = width_of(&compact);
(compact, w)
}
pub fn spans(&self, t: &Theme) -> Vec<Span<'static>> {
let (fill, text, dim) = match self.state {
ButtonState::Normal => (self.fill, self.text, false),
ButtonState::Disabled => (self.fill, t.comment, true),
ButtonState::Active => (t.blue, CHIP_LABEL_FG, false),
ButtonState::Busy => (self.fill, self.text, false),
};
let mut base = Style::default().fg(text).bg(fill);
if self.bold {
base = base.add_modifier(Modifier::BOLD);
}
if dim {
base = base.add_modifier(Modifier::DIM);
}
let mut out = vec![Span::styled(" ".to_string(), base)];
if let Some(icon) = self.icon {
let icon_style = match self.state {
ButtonState::Disabled => base,
_ => base.fg(self.accent.unwrap_or(text)),
};
let glyph = if self.state == ButtonState::Busy {
crate::ui::spinner::frame().to_string()
} else {
icon.to_string()
};
out.push(Span::styled(glyph, icon_style));
out.push(Span::styled(" ".to_string(), base));
}
out.push(Span::styled(self.label.to_string(), base));
out.push(Span::styled(" ".to_string(), base));
out
}
}
pub fn centred_row(buttons: &[Button<'_>], width: u16, gap: u16) -> (u16, Vec<u16>) {
let total: u16 = buttons
.iter()
.map(|b| b.width())
.sum::<u16>()
.saturating_add(gap.saturating_mul(buttons.len().saturating_sub(1) as u16));
let lead = width.saturating_sub(total) / 2;
let mut xs = Vec::with_capacity(buttons.len());
let mut x = lead;
for b in buttons {
xs.push(x);
x += b.width() + gap;
}
(lead, xs)
}
#[cfg(test)]
mod button_tests {
use super::*;
use crate::ui::theme;
#[test]
fn both_refresh_modes_use_one_glyph() {
let t = crate::ui::theme::cur();
let compact = Button::refresh(&t, false, None);
let expanded = Button::refresh(&t, false, Some("Refresh"));
assert_eq!(
compact.icon, expanded.icon,
"the compact and expanded refresh buttons disagree on the glyph"
);
assert_eq!(
compact.icon,
Some(crate::ui::refresh_glyph::NERD),
"the refresh button stopped using the canonical glyph"
);
}
#[test]
fn refresh_honours_ascii_mode() {
let t = crate::ui::theme::cur();
assert_eq!(
Button::refresh(&t, true, Some("Refresh")).icon,
Some(crate::ui::refresh_glyph::ASCII)
);
}
#[test]
fn expanded_is_wider_than_compact_by_its_label() {
let t = crate::ui::theme::cur();
let compact = Button::refresh(&t, false, None);
let expanded = Button::refresh(&t, false, Some("Refresh"));
assert_eq!(
expanded.width() - compact.width(),
"Refresh".chars().count() as u16,
"the expanded button is not exactly its label wider"
);
}
#[test]
fn width_counts_the_pads_and_the_icon_space() {
let t = theme::cur();
assert_eq!(
Button::toolbar(&t, "Pull").width(),
6,
"` Pull ` = 4 + 2 pads"
);
assert_eq!(
Button::toolbar(&t, "Pull").icon("x").width(),
8,
"` x Pull ` = icon + space + label + 2 pads"
);
}
#[test]
fn rendered_width_matches_the_declared_width() {
let t = theme::cur();
for b in [
Button::toolbar(&t, "Pull"),
Button::toolbar(&t, "Pull").icon("\u{F0450}"),
Button::primary(&t, "+ New session"),
Button::secondary(&t, "+ from PR").icon("*"),
] {
let painted: usize = b.spans(&t).iter().map(|s| s.content.chars().count()).sum();
assert_eq!(
painted as u16,
b.width(),
"label {:?}: painted {painted} cells but width() said {}",
b.label,
b.width()
);
}
}
#[test]
fn centred_row_leaves_equal_margins() {
let t = theme::cur();
let bs = [Button::toolbar(&t, "A"), Button::toolbar(&t, "B")];
let (lead, xs) = centred_row(&bs, 21, 1);
assert_eq!(lead, 7);
assert_eq!(xs, vec![7, 11]);
let right_margin = 21 - (xs[1] + bs[1].width());
assert_eq!(
lead, right_margin,
"margins are not equal: {lead} vs {right_margin}"
);
}
#[test]
fn centred_row_degrades_to_zero_lead_when_too_narrow() {
let t = theme::cur();
let bs = [Button::toolbar(&t, "Wide label here")];
let (lead, xs) = centred_row(&bs, 4, 1);
assert_eq!(lead, 0);
assert_eq!(xs, vec![0]);
}
#[test]
fn disabled_keeps_its_width_but_drops_the_accent() {
let t = theme::cur();
let normal = Button::toolbar(&t, "Pop").icon("x").accent(t.yellow);
let off = normal.clone().state(ButtonState::Disabled);
assert_eq!(normal.width(), off.width());
let accent_used = off.spans(&t).iter().any(|s| s.style.fg == Some(t.yellow));
assert!(!accent_used, "disabled button still painted its accent");
}
#[test]
fn a_busy_button_keeps_its_width() {
let th = crate::ui::theme::cur();
let idle = Button::refresh(&th, false, Some("Refresh"));
let busy = Button {
state: ButtonState::Busy,
..Button::refresh(&th, false, Some("Refresh"))
};
assert_eq!(idle.width(), busy.width(), "the button resized while busy");
let cells =
|b: &Button| -> usize { b.spans(&th).iter().map(|s| s.content.chars().count()).sum() };
assert_eq!(
cells(&idle),
cells(&busy),
"painted cell count changed while busy"
);
}
#[test]
fn busy_replaces_the_icon_rather_than_adding_one() {
let th = crate::ui::theme::cur();
let busy = Button {
state: ButtonState::Busy,
..Button::refresh(&th, false, Some("Refresh"))
};
let painted: String = busy
.spans(&th)
.iter()
.map(|s| s.content.to_string())
.collect();
assert!(
!painted.contains(crate::ui::refresh_glyph::NERD),
"the refresh glyph is still painted alongside the spinner: {painted:?}"
);
assert!(
crate::ui::spinner::FRAMES
.iter()
.any(|f| painted.contains(f)),
"no spinner frame painted: {painted:?}"
);
}
#[test]
fn the_icon_is_separated_from_the_label() {
let th = crate::ui::theme::cur();
let b = Button::refresh(&th, false, Some("Refresh"));
let painted: String = b.spans(&th).iter().map(|s| s.content.to_string()).collect();
let after: String = painted
.split(crate::ui::refresh_glyph::NERD)
.nth(1)
.unwrap_or_default()
.chars()
.take(1)
.collect();
assert_eq!(after, " ", "icon runs straight into the label: {painted:?}");
}
#[test]
fn a_row_that_fits_keeps_its_labels() {
let th = crate::ui::theme::cur();
let btns = vec![
Button::refresh(&th, false, Some("Refresh")),
Button::refresh(&th, false, Some("Reload")),
];
let want: u16 = btns.iter().map(|b| b.width()).sum::<u16>() + 1;
let (out, w) = Button::fit_row(btns, 200, 1);
assert_eq!(w, want);
assert!(
out.iter().all(|b| !b.label.is_empty()),
"labels were dropped with room to spare"
);
}
#[test]
fn a_row_that_does_not_fit_goes_compact() {
let th = crate::ui::theme::cur();
let btns = vec![
Button::refresh(&th, false, Some("Refresh")),
Button::refresh(&th, false, Some("Reload")),
];
let (out, w) = Button::fit_row(btns, 12, 1);
assert!(
out.iter().all(|b| b.label.is_empty()),
"labels survived a too-narrow row"
);
assert!(w <= 12, "compact row still overflows: {w} > 12");
assert!(
out.iter().all(|b| b.icon.is_some()),
"a compact button lost its icon too"
);
}
#[test]
fn fitting_never_drops_a_button() {
let th = crate::ui::theme::cur();
for avail in [0u16, 1, 3, 7, 40] {
let btns = vec![
Button::refresh(&th, false, Some("Refresh")),
Button::refresh(&th, false, Some("Reload")),
Button::refresh(&th, false, Some("Retry")),
];
let (out, _) = Button::fit_row(btns, avail, 1);
assert_eq!(out.len(), 3, "a button vanished at avail={avail}");
}
}
#[test]
fn an_iconless_button_keeps_its_label_when_compacted() {
let th = crate::ui::theme::cur();
let b = Button::primary(&th, "Install").compact();
assert_eq!(b.label, "Install");
}
}