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(super) enum TabStatus {
Completed,
Current,
Future,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum KeyResult<S, T> {
Continue(S),
Complete(T),
Cancelled,
}
pub(super) struct ButtonDef {
pub(super) label: String,
pub(super) selected: bool,
pub(super) color: Option<Color>,
}
pub(super) fn button_style(selected: bool) -> Style {
button_style_colored(selected, Color::Green)
}
pub(super) 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(super) 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(super) 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(super) 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(super) 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(super) 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(super) 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 {
#[cfg_attr(coverage_nightly, coverage(off))]
fn new() -> Self {
Self {
raw_mode: false,
alt_screen: false,
mouse_capture: false,
kbd_enhancement: false,
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn enable_raw_mode(&mut self) -> io::Result<()> {
enable_raw_mode()?;
self.raw_mode = true;
Ok(())
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn enter_alternate_screen(&mut self) -> io::Result<()> {
io::stdout().execute(EnterAlternateScreen)?;
self.alt_screen = true;
Ok(())
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn enable_mouse_capture(&mut self) -> io::Result<()> {
io::stdout().execute(EnableMouseCapture)?;
self.mouse_capture = true;
Ok(())
}
#[cfg_attr(coverage_nightly, coverage(off))]
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 {
#[cfg_attr(coverage_nightly, coverage(off))]
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();
}
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
pub(super) 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(super) 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
}