use std::io;
use std::rc::Rc;
use crossterm::{
ExecutableCommand,
event::{
DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind, KeyboardEnhancementFlags,
MouseButton, MouseEventKind, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
},
terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
supports_keyboard_enhancement,
},
};
use ratatui::{
prelude::*,
widgets::{Block, Borders, Paragraph, Wrap},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TabStatus {
Completed,
Current,
Future,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeyResult<S, T> {
Continue(S),
Complete(T),
Cancelled,
}
pub struct ButtonDef {
pub label: String,
pub selected: bool,
pub color: Option<Color>,
}
pub fn button_style(selected: bool) -> Style {
button_style_colored(selected, Color::Green)
}
pub fn button_style_colored(selected: bool, color: Color) -> Style {
if selected {
Style::default()
.fg(color)
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
Style::default().fg(Color::Gray)
}
}
pub fn paragraph_height(text: &str, area_width: u16, border: u16) -> u16 {
let inner = area_width.saturating_sub(4 + border);
let lines = Paragraph::new(text)
.wrap(Wrap { trim: false })
.line_count(inner);
let lines = u16::try_from(lines).unwrap_or(u16::MAX);
(lines + border).max(1 + border)
}
pub fn render_question(frame: &mut Frame, area: Rect, text: &str, color: Color) {
let question = Paragraph::new(text)
.style(Style::default().fg(color))
.wrap(Wrap { trim: false })
.block(Block::default().borders(Borders::ALL));
frame.render_widget(question, area);
}
pub fn render_help(frame: &mut Frame, area: Rect, text: &str) {
let help = Paragraph::new(text)
.style(Style::default().fg(Color::DarkGray))
.wrap(Wrap { trim: false });
frame.render_widget(help, area);
}
pub fn render_tabs(frame: &mut Frame, area: Rect, tabs: &[(&str, TabStatus)]) {
if tabs.is_empty() {
return;
}
let Ok(n) = u16::try_from(tabs.len()) else {
return;
};
let constraints: Vec<Constraint> = (0..n).map(|_| Constraint::Fill(1)).collect();
let cells = Layout::horizontal(constraints).split(area);
for ((label, status), &cell) in tabs.iter().zip(cells.iter()) {
let style = match status {
TabStatus::Completed => Style::default().fg(Color::White).bg(Color::Green),
TabStatus::Current => Style::default()
.fg(Color::White)
.bg(Color::Blue)
.add_modifier(Modifier::BOLD),
TabStatus::Future => Style::default().fg(Color::White).bg(Color::DarkGray),
};
frame.render_widget(
Paragraph::new(Text::from(vec![
Line::from(""),
Line::from(*label),
Line::from(""),
]))
.alignment(Alignment::Center)
.style(style),
cell,
);
}
}
pub fn render_buttons(frame: &mut Frame, area: Rect, buttons: &[ButtonDef]) {
if buttons.is_empty() {
return;
}
let n = buttons.len();
let constraints: Vec<Constraint> = (0..n).map(|_| Constraint::Fill(1)).collect();
let cells = Layout::horizontal(constraints).spacing(1).split(area);
for (btn, &cell) in buttons.iter().zip(cells.iter()) {
let style = if btn.selected {
match btn.color {
Some(color) => button_style_colored(true, color),
None => button_style(true),
}
} else {
Style::default().fg(Color::Gray).bg(Color::DarkGray)
};
let content = Text::from(vec![
Line::from(""),
Line::from(btn.label.as_str()),
Line::from(""),
]);
let para = Paragraph::new(content)
.alignment(Alignment::Center)
.style(style);
frame.render_widget(para, cell);
}
}
pub fn wizard_layout(area: Rect, constraints: &[Constraint]) -> Rc<[Rect]> {
Layout::default()
.direction(Direction::Vertical)
.margin(2)
.constraints(constraints.iter().copied())
.split(area)
}
struct TerminalGuard {
raw_mode: bool,
alt_screen: bool,
mouse_capture: bool,
kbd_enhancement: bool,
}
impl TerminalGuard {
fn new() -> Self {
Self {
raw_mode: false,
alt_screen: false,
mouse_capture: false,
kbd_enhancement: false,
}
}
fn enable_raw_mode(&mut self) -> io::Result<()> {
enable_raw_mode()?;
self.raw_mode = true;
Ok(())
}
fn enter_alternate_screen(&mut self) -> io::Result<()> {
io::stdout().execute(EnterAlternateScreen)?;
self.alt_screen = true;
Ok(())
}
fn enable_mouse_capture(&mut self) -> io::Result<()> {
io::stdout().execute(EnableMouseCapture)?;
self.mouse_capture = true;
Ok(())
}
fn push_keyboard_enhancement(&mut self) -> io::Result<()> {
io::stdout().execute(PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES,
))?;
self.kbd_enhancement = true;
Ok(())
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
if self.kbd_enhancement {
io::stdout().execute(PopKeyboardEnhancementFlags).ok();
}
if self.mouse_capture {
io::stdout().execute(DisableMouseCapture).ok();
}
if self.alt_screen {
io::stdout().execute(LeaveAlternateScreen).ok();
}
if self.raw_mode {
disable_raw_mode().ok();
}
}
}
pub fn run_tui<S, T, DrawFn, HandleFn>(
mut state: S,
mut draw_fn: DrawFn,
mut handle_fn: HandleFn,
) -> anyhow::Result<Option<T>>
where
DrawFn: FnMut(&mut Frame, &S),
HandleFn: FnMut(S, Event, Rect) -> anyhow::Result<KeyResult<S, T>>,
{
let mut guard = TerminalGuard::new();
guard.enable_raw_mode()?;
guard.enter_alternate_screen()?;
guard.enable_mouse_capture()?;
if supports_keyboard_enhancement().unwrap_or(false) {
guard.push_keyboard_enhancement()?;
}
let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
let result: anyhow::Result<Option<T>> = loop {
let frame_area = match terminal.draw(|frame| draw_fn(frame, &state)) {
Err(e) => break Err(e.into()),
Ok(completed) => completed.area,
};
let event = match crossterm::event::read() {
Err(e) => break Err(e.into()),
Ok(e) => e,
};
let forward = match &event {
Event::Key(key) if key.kind == KeyEventKind::Press => true,
Event::Mouse(me) => matches!(me.kind, MouseEventKind::Down(MouseButton::Left)),
_ => false,
};
if forward {
match handle_fn(state, event, frame_area) {
Err(e) => break Err(e),
Ok(KeyResult::Continue(new_state)) => state = new_state,
Ok(KeyResult::Complete(value)) => break Ok(Some(value)),
Ok(KeyResult::Cancelled) => break Ok(None),
}
}
};
result
}
pub fn button_click_index(
content_area: Rect,
question: &str,
n_buttons: u16,
col: u16,
row: u16,
) -> Option<usize> {
if n_buttons == 0 {
return None;
}
let q_height = paragraph_height(question, content_area.width, 2);
let chunks = wizard_layout(
content_area,
&[
Constraint::Length(q_height),
Constraint::Length(3),
Constraint::Length(1),
Constraint::Min(1),
],
);
let buttons_area = chunks[1];
if row < buttons_area.y || row >= buttons_area.y + buttons_area.height {
return None;
}
if col < buttons_area.x || col >= buttons_area.x + buttons_area.width {
return None;
}
let constraints: Vec<Constraint> = (0..n_buttons).map(|_| Constraint::Fill(1)).collect();
let cells = Layout::horizontal(constraints)
.spacing(1)
.split(buttons_area);
for (i, cell) in cells.iter().enumerate() {
if col >= cell.x && col < cell.x + cell.width {
return Some(i);
}
}
None
}
#[cfg(test)]
mod tests {
use ratatui::backend::TestBackend;
use super::super::test_utils::render_to_string;
use super::*;
fn make_terminal() -> Terminal<TestBackend> {
Terminal::new(TestBackend::new(80, 5)).unwrap()
}
#[test]
fn paragraph_height_short_text_with_border_returns_minimum() {
assert_eq!(paragraph_height("Hello", 80, 2), 3);
}
#[test]
fn paragraph_height_wrapping_text_with_border_grows() {
let long = "Git strategy? Push: commit to current branch. Branch: create release branch (for PRs).";
assert_eq!(paragraph_height(long, 80, 2), 4);
}
#[test]
fn paragraph_height_zero_width_with_border_returns_minimum() {
assert_eq!(paragraph_height("anything", 0, 2), 3);
}
#[test]
fn paragraph_height_no_border_short_text_returns_one() {
assert_eq!(paragraph_height("help", 80, 0), 1);
}
#[test]
fn paragraph_height_no_border_zero_width_returns_one() {
assert_eq!(paragraph_height("help", 0, 0), 1);
}
#[test]
fn button_style_selected_is_green_bold_reversed() {
assert_eq!(
button_style(true),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
);
}
#[test]
fn button_style_unselected_is_gray() {
assert_eq!(button_style(false), Style::default().fg(Color::Gray));
}
#[test]
fn button_style_colored_selected_uses_given_color() {
assert_eq!(
button_style_colored(true, Color::Red),
Style::default()
.fg(Color::Red)
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
);
}
#[test]
fn button_style_colored_unselected_is_gray_regardless_of_color() {
assert_eq!(
button_style_colored(false, Color::Red),
Style::default().fg(Color::Gray)
);
}
#[test]
fn render_question_shows_text() {
let mut terminal = make_terminal();
let content = render_to_string(&mut terminal, |frame| {
render_question(frame, frame.area(), "Is this correct?", Color::Yellow);
});
assert!(content.contains("Is this correct?"));
}
#[test]
fn render_question_renders_border() {
let mut terminal = make_terminal();
let content = render_to_string(&mut terminal, |frame| {
render_question(frame, frame.area(), "Q", Color::Red);
});
assert!(content.contains('─') || content.contains('│') || content.contains('┌'));
}
#[test]
fn render_help_shows_text() {
let mut terminal = make_terminal();
let content = render_to_string(&mut terminal, |frame| {
render_help(frame, frame.area(), "Press Esc to cancel");
});
assert!(content.contains("Press Esc to cancel"));
}
#[test]
fn render_buttons_shows_labels() {
let backend = TestBackend::new(80, 5);
let mut terminal = Terminal::new(backend).unwrap();
let content = render_to_string(&mut terminal, |frame| {
render_buttons(
frame,
frame.area(),
&[
ButtonDef {
label: "Yes".to_string(),
selected: true,
color: None,
},
ButtonDef {
label: "No".to_string(),
selected: false,
color: Some(Color::Red),
},
],
);
});
assert!(content.contains("Yes"));
assert!(content.contains("No"));
}
#[test]
fn render_buttons_empty_does_not_panic() {
let mut terminal = make_terminal();
terminal
.draw(|frame| render_buttons(frame, frame.area(), &[]))
.unwrap();
}
#[test]
fn render_tabs_shows_all_labels() {
let mut terminal = make_terminal();
let content = render_to_string(&mut terminal, |frame| {
render_tabs(
frame,
frame.area(),
&[
("Managers", TabStatus::Current),
("Git", TabStatus::Future),
("GitHub", TabStatus::Future),
],
);
});
assert!(content.contains("Managers"));
assert!(content.contains("Git"));
assert!(content.contains("GitHub"));
}
#[test]
fn render_tabs_empty_does_not_panic() {
let mut terminal = make_terminal();
terminal
.draw(|frame| render_tabs(frame, frame.area(), &[]))
.unwrap();
}
#[test]
fn button_click_index_hits_first_button() {
let area = Rect::new(0, 0, 80, 24);
let idx = button_click_index(area, "test?", 2, 10, 6);
assert_eq!(idx, Some(0));
}
#[test]
fn button_click_index_hits_second_button() {
let area = Rect::new(0, 0, 80, 24);
let idx = button_click_index(area, "test?", 2, 65, 6);
assert_eq!(idx, Some(1));
}
#[test]
fn button_click_index_misses_above_buttons() {
let area = Rect::new(0, 0, 80, 24);
let idx = button_click_index(area, "test?", 2, 10, 2);
assert_eq!(idx, None);
}
#[test]
fn button_click_index_misses_below_buttons() {
let area = Rect::new(0, 0, 80, 24);
let idx = button_click_index(area, "test?", 2, 10, 15);
assert_eq!(idx, None);
}
#[test]
fn button_click_index_zero_buttons_returns_none() {
let area = Rect::new(0, 0, 80, 24);
assert_eq!(button_click_index(area, "test?", 0, 10, 6), None);
}
#[test]
fn wizard_layout_returns_correct_chunk_count() {
let area = Rect::new(0, 0, 80, 24);
let chunks = wizard_layout(
area,
&[
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(1),
],
);
assert_eq!(chunks.len(), 3);
}
#[test]
fn wizard_layout_applies_margin() {
let area = Rect::new(0, 0, 80, 24);
let chunks = wizard_layout(area, &[Constraint::Min(0)]);
assert!(chunks[0].x >= area.x + 2);
assert!(chunks[0].y >= area.y + 2);
}
}