use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::Widget,
};
pub struct Button<'a> {
pub label: &'a str,
pub selected_color: Color,
}
impl<'a> Button<'a> {
#[must_use]
pub const fn new(label: &'a str, selected_color: Color) -> Self {
Self {
label,
selected_color,
}
}
}
pub struct ButtonRow<'a> {
buttons: &'a [Button<'a>],
selected: usize,
centered: bool,
}
impl<'a> ButtonRow<'a> {
#[must_use]
pub const fn new(buttons: &'a [Button<'a>]) -> Self {
Self {
buttons,
selected: 0,
centered: false,
}
}
#[must_use]
pub const fn selected(mut self, index: usize) -> Self {
self.selected = index;
self
}
#[must_use]
pub const fn centered(mut self) -> Self {
self.centered = true;
self
}
}
impl Widget for ButtonRow<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
let mut spans = Vec::new();
let spacing = " ";
for (i, button) in self.buttons.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(spacing));
}
let style = if i == self.selected {
Style::default()
.fg(button.selected_color)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
spans.push(Span::styled(button.label, style));
}
let content_width: usize = self.buttons.iter().map(|b| b.label.len()).sum::<usize>()
+ spacing.len() * self.buttons.len().saturating_sub(1);
#[allow(clippy::cast_possible_truncation)]
let x = if self.centered {
let offset = (area.width as usize).saturating_sub(content_width) / 2;
area.x + offset as u16
} else {
area.x + 2 };
let line = Line::from(spans);
buf.set_line(x, area.y, &line, area.width);
}
}
pub mod presets {
use super::Button;
use ratatui::style::Color;
pub const YES: Button<'static> = Button::new("[Y]es", Color::Red);
pub const NO: Button<'static> = Button::new("[N]o", Color::Green);
pub const YES_UNDERSTAND: Button<'static> =
Button::new("[Y]es, I understand the risks", Color::Red);
pub const NO_CANCEL: Button<'static> = Button::new("[N]o, cancel", Color::Green);
}
#[cfg(test)]
mod tests {
use super::{ButtonRow, presets};
use crate::tui::test_utils::{buffer_to_text, render_to_snapshot};
use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
#[test]
fn renders_buttons() {
let buttons = ButtonRow::new(&[presets::YES, presets::NO]).selected(0);
let area = Rect::new(0, 0, 30, 1);
let mut buf = Buffer::empty(area);
buttons.render(area, &mut buf);
let text = buffer_to_text(&buf);
assert!(text.contains("[Y]es"), "Text:\n{text}");
assert!(text.contains("[N]o"), "Text:\n{text}");
}
#[test]
fn selected_button_has_color() {
let buttons = ButtonRow::new(&[presets::YES, presets::NO]).selected(0);
let snapshot = render_to_snapshot(buttons, 30, 1);
assert!(
snapshot.contains("[R+B]"),
"Selected button should be bold+colored:\n{snapshot}"
);
}
#[test]
fn unselected_button_is_gray() {
let buttons = ButtonRow::new(&[presets::YES, presets::NO]).selected(0);
let snapshot = render_to_snapshot(buttons, 30, 1);
assert!(
snapshot.contains("[Gy]"),
"Unselected button should be gray:\n{snapshot}"
);
}
#[test]
fn second_button_selected() {
let buttons = ButtonRow::new(&[presets::YES, presets::NO]).selected(1);
let snapshot = render_to_snapshot(buttons, 30, 1);
assert!(
snapshot.contains("[G+B]"),
"Second selected button should be bold+green:\n{snapshot}"
);
}
#[test]
fn extended_buttons() {
let buttons = ButtonRow::new(&[presets::YES_UNDERSTAND, presets::NO_CANCEL]).selected(0);
let area = Rect::new(0, 0, 60, 1);
let mut buf = Buffer::empty(area);
buttons.render(area, &mut buf);
let text = buffer_to_text(&buf);
assert!(text.contains("I understand the risks"), "Text:\n{text}");
assert!(text.contains("cancel"), "Text:\n{text}");
}
}