use crossterm::event::MouseButton;
use ratatui::{
layout::{Alignment, Constraint},
style::{Modifier, Style},
text::Line,
};
use crate::{
AnyElement, ComponentTheme, Handler, Hooks, MouseButtons, Palette, PointerKind, PointerOptions,
UseHover, UsePointer, UseTheme,
components::{Border, Text, theme::resolve_style},
input::EventResult,
};
use ratatui_kit_macros::{Props, component};
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ButtonTheme {
pub primary_style: Style,
pub secondary_style: Style,
pub danger_style: Style,
pub disabled_style: Style,
pub hover_modifier: Modifier,
}
impl ComponentTheme for ButtonTheme {
fn from_palette(palette: &Palette) -> Self {
Self {
primary_style: Style::new().bg(palette.accent).fg(palette.on_accent),
secondary_style: Style::new().bg(palette.surface).fg(palette.fg),
danger_style: Style::new().bg(palette.error).fg(palette.on_accent),
disabled_style: Style::new().bg(palette.bg).fg(palette.placeholder),
hover_modifier: Modifier::BOLD,
}
}
}
impl Default for ButtonTheme {
fn default() -> Self {
Self::from_palette(&Palette::default())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ButtonVariant {
#[default]
Primary,
Secondary,
Danger,
}
#[derive(Default, Props)]
pub struct ButtonProps {
pub label: String,
pub on_press: Handler<'static, ()>,
pub variant: ButtonVariant,
pub disabled: bool,
pub width: Option<u16>,
pub style: Option<Style>,
}
fn button_style(
theme: &ButtonTheme,
variant: ButtonVariant,
disabled: bool,
hovered: bool,
override_style: Option<Style>,
) -> Style {
if disabled {
return theme.disabled_style;
}
let base = match variant {
ButtonVariant::Primary => theme.primary_style,
ButtonVariant::Secondary => theme.secondary_style,
ButtonVariant::Danger => theme.danger_style,
};
let style = resolve_style(base, override_style);
if hovered {
style.add_modifier(theme.hover_modifier)
} else {
style
}
}
fn split_border_label(style: Style) -> (Style, Style) {
let mut label = style;
let mut border = style;
if let Some(bg) = style.bg {
label.fg = Some(bg);
label.bg = None;
border.fg = Some(bg);
}
(border, label)
}
#[component]
pub fn Button(props: &mut ButtonProps, mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
let hovered = hooks.use_hover();
let mut on_press = props.on_press.take();
let disabled = props.disabled;
hooks.use_pointer(
PointerOptions {
buttons: MouseButtons::LEFT,
track_move: false,
..Default::default()
},
move |ev| {
if !disabled && matches!(ev.kind, PointerKind::Down(MouseButton::Left)) {
on_press(());
EventResult::Consumed
} else {
EventResult::Ignored
}
},
);
let theme = hooks.use_component_theme::<ButtonTheme>();
let hovered = hovered && !props.disabled;
let style = button_style(&theme, props.variant, props.disabled, hovered, props.style);
let (border_style, label_style) = if props.disabled {
(style, style)
} else {
split_border_label(style)
};
let width = props
.width
.unwrap_or_else(|| (Line::from(props.label.as_str()).width() as u16).saturating_add(4));
let label = format!(" {} ", props.label);
crate::element!(
Border(
width: Constraint::Length(width),
height: Constraint::Length(3),
border_style: border_style,
) {
Text(
text: label,
style: label_style,
alignment: Alignment::Center,
)
}
)
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::{Color, Modifier};
fn theme() -> ButtonTheme {
ButtonTheme::default()
}
#[test]
fn style_by_variant() {
let t = theme();
assert_eq!(
button_style(&t, ButtonVariant::Primary, false, false, None),
t.primary_style
);
assert_eq!(
button_style(&t, ButtonVariant::Secondary, false, false, None),
t.secondary_style
);
assert_eq!(
button_style(&t, ButtonVariant::Danger, false, false, None),
t.danger_style
);
}
#[test]
fn style_disabled_wins_over_everything() {
let t = theme();
let override_style = Style::new().fg(Color::Red);
assert_eq!(
button_style(&t, ButtonVariant::Primary, true, true, Some(override_style)),
t.disabled_style
);
}
#[test]
fn style_hover_adds_modifier() {
let t = theme();
let hovered = button_style(&t, ButtonVariant::Primary, false, true, None);
assert_eq!(
hovered.add_modifier,
t.primary_style.add_modifier | Modifier::BOLD
);
}
#[test]
fn style_override_patches() {
let t = theme();
let style = button_style(
&t,
ButtonVariant::Secondary,
false,
false,
Some(Style::new().fg(Color::Green)),
);
assert_eq!(style.fg, Some(Color::Green));
assert_eq!(style.bg, t.secondary_style.bg);
}
#[test]
fn auto_width_is_label_plus_padding() {
assert_eq!(Line::from("删除").width() as u16 + 4, 8); }
mod interaction {
use super::*;
use crate::{Palette, components::View, mouse_test::Mounted};
use crossterm::event::{MouseButton, MouseEventKind};
use ratatui::layout::Direction;
use ratatui_kit_macros::{Props, component};
use std::sync::{Arc, Mutex};
#[derive(Clone, Default)]
struct PressLog(Arc<Mutex<Vec<String>>>);
#[derive(Default, Props)]
struct ButtonAppProps {
log: PressLog,
disabled: bool,
}
#[component]
fn ButtonApp(props: &ButtonAppProps, _hooks: Hooks) -> impl Into<AnyElement<'static>> {
let log = props.log.clone();
crate::element!(View(flex_direction: Direction::Horizontal) {
Button(
label: "OK",
on_press: move |()| log.0.lock().unwrap().push("pressed".to_string()),
disabled: props.disabled,
)
Filler
})
}
#[component]
fn Filler() -> impl Into<AnyElement<'static>> {
crate::element!(Text(text: "............"))
}
#[test]
fn press_fires_once_on_down() {
let log = PressLog::default();
let mut m = Mounted::mount(
crate::element!(ButtonApp(log: log.clone(), disabled: false)),
true,
);
m.dispatch(MouseEventKind::Down(MouseButton::Left), 2, 1);
m.dispatch(MouseEventKind::Up(MouseButton::Left), 2, 1);
assert_eq!(*log.0.lock().unwrap(), vec!["pressed".to_string()]);
}
#[test]
fn press_outside_does_not_fire() {
let log = PressLog::default();
let mut m = Mounted::mount(
crate::element!(ButtonApp(log: log.clone(), disabled: false)),
true,
);
m.dispatch(MouseEventKind::Down(MouseButton::Left), 30, 5);
assert!(log.0.lock().unwrap().is_empty());
}
#[test]
fn disabled_press_does_not_fire() {
let log = PressLog::default();
let mut m = Mounted::mount(
crate::element!(ButtonApp(log: log.clone(), disabled: true)),
true,
);
m.dispatch(MouseEventKind::Down(MouseButton::Left), 2, 1);
assert!(log.0.lock().unwrap().is_empty());
}
#[test]
fn render_variants_take_theme_colors() {
let palette = Palette::default();
let mut m = Mounted::mount(
crate::element!(ButtonApp(log: PressLog::default(), disabled: false)),
true,
);
let buf = m.buffer();
assert_eq!(buf[(2, 1)].symbol(), "O");
assert_eq!(buf[(3, 1)].symbol(), "K");
assert_eq!(buf[(2, 1)].style().fg, Some(palette.accent));
let mut m = Mounted::mount(
crate::element!(ButtonApp(log: PressLog::default(), disabled: true)),
true,
);
let buf = m.buffer();
assert_eq!(buf[(2, 1)].style().fg, Some(palette.placeholder));
}
}
}