use ratatui::{
style::{Color, Modifier, Style},
text::{Line, Span},
};
use crate::ui::theme::Theme;
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,
}
#[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 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 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),
};
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)),
};
out.push(Span::styled(icon.to_string(), 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 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");
}
}