use crate::{
Component,
InputResult,
RenderError,
Rendered,
events::Event,
layout::Rect,
theme::{
Color,
Palette,
Style,
Theme,
stylize_padded,
},
};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ButtonVariant {
#[default]
Dark,
Cream,
Ghost,
Text,
Primary,
}
pub struct Button {
label: String,
variant: ButtonVariant,
pad: usize,
}
impl Button {
pub fn new(label: impl Into<String>, variant: ButtonVariant) -> Self {
Self {
label: label.into(),
variant,
pad: 1,
}
}
pub fn primary(label: impl Into<String>) -> Self {
Self::new(label, ButtonVariant::Primary)
}
pub fn dark(label: impl Into<String>) -> Self {
Self::new(label, ButtonVariant::Dark)
}
pub fn cream(label: impl Into<String>) -> Self {
Self::new(label, ButtonVariant::Cream)
}
pub fn ghost(label: impl Into<String>) -> Self {
Self::new(label, ButtonVariant::Ghost)
}
pub fn text(label: impl Into<String>) -> Self {
Self::new(label, ButtonVariant::Text)
}
pub fn pad(mut self, pad: usize) -> Self {
self.pad = pad;
self
}
fn build_style(&self) -> Style {
let theme = Theme::current();
match self.variant {
| ButtonVariant::Primary => Style::new().fg(Color::WHITE).bg(theme.accent()).bold(),
| ButtonVariant::Dark => match theme {
| Theme::Light => Style::new()
.fg(Color::WHITE)
.bg(Color::SUNBEAM_BLACK)
.bold(),
| Theme::Dark => Style::new().fg(Color::WHITE).bg(Color::CARD_DARK).bold(),
},
| ButtonVariant::Cream => Style::new()
.fg(Color::SUNBEAM_BLACK)
.bg(Color::CREAM)
.bold(),
| ButtonVariant::Ghost => Style::new().fg(theme.accent()).bold(),
| ButtonVariant::Text => Style::new().fg(theme.accent()).underline(),
}
}
}
impl Component for Button {
fn render(&self, _width: u16) -> Result<Rendered, RenderError> {
let style = self.build_style();
let line = match self.variant {
| ButtonVariant::Ghost => {
let theme = Theme::current();
let bracket_style = Style::new().fg(theme.border_default());
let bracket_open = crate::theme::stylize("[", &bracket_style);
let bracket_close = crate::theme::stylize("]", &bracket_style);
let inner = stylize_padded(&self.label, &style, self.pad);
format!("{}{}{}", bracket_open, inner, bracket_close)
},
| _ => stylize_padded(&self.label, &style, self.pad),
};
Ok(Rendered {
lines: vec![line],
cursor: None,
images: Vec::new(),
})
}
fn render_rect(&self, rect: Rect) -> Result<Rendered, RenderError> {
let mut rendered = self.render(rect.width)?;
let height = rendered.lines.len();
let pad_top = (rect.height as usize).saturating_sub(height) / 2;
let mut lines = Vec::new();
for _ in 0..pad_top {
lines.push(String::new());
}
lines.extend(rendered.lines);
while lines.len() < rect.height as usize {
lines.push(String::new());
}
rendered.lines = lines;
Ok(rendered)
}
fn handle_input(&mut self, _event: &Event) -> InputResult {
InputResult::Ignored
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::Theme;
#[test]
fn primary_button_renders() {
Theme::with(Theme::Light, || {
let btn = Button::primary("Click me");
let rendered = btn.render(80).unwrap();
assert_eq!(rendered.lines.len(), 1);
assert!(rendered.lines[0].contains("Click me"));
assert!(rendered.lines[0].starts_with('\x1b'));
});
}
#[test]
fn dark_button_renders() {
Theme::with(Theme::Light, || {
let btn = Button::dark("Submit");
let rendered = btn.render(80).unwrap();
assert!(rendered.lines[0].contains("Submit"));
});
}
#[test]
fn ghost_button_has_brackets() {
Theme::with(Theme::Light, || {
let btn = Button::ghost("Cancel");
let rendered = btn.render(80).unwrap();
let line = &rendered.lines[0];
assert!(line.contains('['));
assert!(line.contains(']'));
assert!(line.contains("Cancel"));
});
}
#[test]
fn text_button_is_underlined() {
Theme::with(Theme::Light, || {
let btn = Button::text("Link");
let rendered = btn.render(80).unwrap();
assert!(rendered.lines[0].contains("\x1b[4m"));
});
}
#[test]
fn button_padding() {
Theme::with(Theme::Light, || {
let btn = Button::primary("OK").pad(2);
let rendered = btn.render(80).unwrap();
assert!(rendered.lines[0].contains(" OK "));
});
}
#[test]
fn button_respects_theme() {
let light_line = Theme::with(Theme::Light, || {
Button::primary("Test").render(80).unwrap().lines[0].clone()
});
let dark_line = Theme::with(Theme::Dark, || {
Button::primary("Test").render(80).unwrap().lines[0].clone()
});
assert!(light_line.contains("Test"));
assert!(dark_line.contains("Test"));
}
#[test]
fn dark_button_not_white_on_white_in_dark_mode() {
let line = Theme::with(Theme::Dark, || {
Button::dark("Dark").render(80).unwrap().lines[0].clone()
});
assert!(
!line.contains("\x1b[48;2;255;255;255m"),
"Dark button must not have white bg in dark mode"
);
assert!(
line.contains("\x1b[48;2;42;42;42m"),
"Dark button should use CARD_DARK (#2a2a2a) bg in dark mode"
);
assert!(
line.contains("\x1b[38;2;255;255;255m"),
"Dark button should have white text"
);
}
#[test]
fn dark_button_uses_black_bg_in_light_mode() {
let line = Theme::with(Theme::Light, || {
Button::dark("Dark").render(80).unwrap().lines[0].clone()
});
assert!(
line.contains("\x1b[48;2;31;31;31m"),
"Dark button should use SUNBEAM_BLACK (#1f1f1f) bg in light mode"
);
}
#[test]
fn cream_button_always_cream_colored() {
let light_line = Theme::with(Theme::Light, || {
Button::cream("Cream").render(80).unwrap().lines[0].clone()
});
let dark_line = Theme::with(Theme::Dark, || {
Button::cream("Cream").render(80).unwrap().lines[0].clone()
});
let cream_bg = "\x1b[48;2;255;240;194m";
assert!(
light_line.contains(cream_bg),
"Cream button bg in light mode"
);
assert!(dark_line.contains(cream_bg), "Cream button bg in dark mode");
let dark_fg = "\x1b[38;2;31;31;31m";
assert!(
light_line.contains(dark_fg),
"Cream button fg in light mode"
);
assert!(dark_line.contains(dark_fg), "Cream button fg in dark mode");
}
}