use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
use super::theme;
use crate::app::App;
pub const FOOTER_GAP: &str = " ";
pub const COL_GAP: u16 = 2;
pub const OVERLAY_W: u16 = 70;
pub const OVERLAY_H: u16 = 80;
pub const PICKER_MIN_W: u16 = 60;
pub const PICKER_MAX_W: u16 = 72;
pub const PICKER_MAX_H: u16 = 18;
pub const TOAST_INSET_X: u16 = 2;
pub const TOAST_INSET_Y: u16 = 2;
pub const TIMEOUT_MIN_MS: u64 = 2500;
pub const TIMEOUT_MIN_WARNING_MS: u64 = 4000;
pub const MS_PER_WORD: u64 = 750;
pub const WORD_CAP: usize = 30;
pub const TOAST_QUEUE_MAX: usize = 3;
pub const ICON_ONLINE: &str = "\u{25CF}";
pub const ICON_SUCCESS: &str = "\u{2713}";
pub const ICON_WARNING: &str = "\u{26A0}";
pub const ICON_ERROR: &str = "\u{2716}";
pub const LIST_HIGHLIGHT: &str = " ";
pub const HOST_HIGHLIGHT: &str = "\u{258C}";
pub const SECTION_LABEL_W: u16 = 14;
pub const DIM_FG_RGB: (u8, u8, u8) = (70, 70, 70);
pub fn overlay_block(title: &str) -> Block<'static> {
overlay_block_line(Line::from(Span::styled(
format!(" {title} "),
theme::brand(),
)))
}
pub fn overlay_block_line(title: Line<'static>) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(theme::accent())
.title(title)
}
pub fn plain_overlay_block() -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(theme::accent())
}
pub fn danger_block(title: &str) -> Block<'static> {
danger_block_line(Line::from(Span::styled(
format!(" {title} "),
theme::danger(),
)))
}
pub fn danger_block_line(title: Line<'static>) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(theme::border_danger())
.title(title)
}
pub fn main_block(title: &str) -> Block<'static> {
main_block_line(Line::from(Span::styled(
format!(" {title} "),
theme::brand(),
)))
}
pub fn main_block_line(title: Line<'static>) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(theme::border())
.title(title)
}
pub fn search_block_line(title: Line<'static>) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(theme::border_search())
.title(title)
}
pub fn overlay_area(frame: &Frame, w_pct: u16, h_pct: u16, height: u16) -> Rect {
let area = frame.area();
let pct_area = super::centered_rect(w_pct, h_pct, area);
super::centered_rect_fixed(pct_area.width, height.min(pct_area.height), area)
}
pub fn form_footer(block_area: Rect, block_height: u16) -> Rect {
Rect::new(
block_area.x,
block_area.y + block_height,
block_area.width,
1,
)
}
pub fn render_overlay_footer(frame: &mut Frame, block_area: Rect) -> Rect {
let footer_area = form_footer(block_area, block_area.height);
frame.render_widget(Clear, footer_area);
footer_area
}
pub fn form_divider_y(inner: Rect, index: usize) -> u16 {
inner.y + (index as u16) * 2
}
pub fn picker_width(frame: &Frame) -> u16 {
frame.area().width.clamp(PICKER_MIN_W, PICKER_MAX_W)
}
pub struct Footer {
spans: Vec<Span<'static>>,
}
impl Footer {
pub fn new() -> Self {
Self { spans: Vec::new() }
}
#[allow(deprecated)]
pub fn primary(mut self, key: &str, label: &str) -> Self {
if !self.spans.is_empty() {
self.spans.push(Span::raw(FOOTER_GAP));
}
let [k, l] = super::footer_primary(key, label);
self.spans.push(k);
self.spans.push(l);
self
}
pub fn action(mut self, key: &str, label: &str) -> Self {
if !self.spans.is_empty() {
self.spans.push(Span::raw(FOOTER_GAP));
}
let [k, l] = super::footer_action(key, label);
self.spans.push(k);
self.spans.push(l);
self
}
pub fn render_with_status(self, frame: &mut Frame, area: Rect, app: &App) {
super::render_footer_with_status(frame, area, self.spans, app);
}
#[allow(clippy::wrong_self_convention)]
pub fn to_line(self) -> Line<'static> {
Line::from(self.spans)
}
pub fn into_spans(self) -> Vec<Span<'static>> {
self.spans
}
}
impl Default for Footer {
fn default() -> Self {
Self::new()
}
}
fn muted_line(message: &str) -> Line<'static> {
Line::from(vec![
Span::raw(" "),
Span::styled(message.to_string(), theme::muted()),
])
}
fn render_muted_message(frame: &mut Frame, area: Rect, message: &str) {
frame.render_widget(Paragraph::new(muted_line(message)), area);
}
pub fn render_empty(frame: &mut Frame, area: Rect, message: &str) {
render_muted_message(frame, area, message);
}
pub fn render_loading(frame: &mut Frame, area: Rect, message: &str) {
render_muted_message(frame, area, message);
}
pub fn render_error(frame: &mut Frame, area: Rect, message: &str) {
let line = Line::from(vec![
Span::raw(" "),
Span::styled(message.to_string(), theme::error()),
]);
frame.render_widget(Paragraph::new(line), area);
}
pub fn section_divider() -> Line<'static> {
Line::from(Span::styled(" ────────────────────────", theme::muted()))
}
pub fn padded_usize(w: usize) -> usize {
if w == 0 { 0 } else { w + w / 10 + 1 }
}
pub const COLUMN_HEADER_PREFIX: &str = " ";
pub const COL_GAP_STR: &str = " ";
pub fn kv_line(label: &str, value: &str, label_width: usize) -> Line<'static> {
Line::from(vec![
Span::styled(
format!(" {:<width$}", label, width = label_width),
theme::muted(),
),
Span::styled(value.to_string(), theme::bold()),
])
}
pub const KV_LABEL_WIDE: usize = 22;
pub fn content_section(label: &str) -> [Line<'static>; 2] {
[
Line::from(vec![
Span::raw(" "),
Span::styled(label.to_string(), theme::section_header()),
]),
section_divider(),
]
}
pub fn render_empty_with_hint(
frame: &mut Frame,
area: Rect,
message: &str,
key: &str,
action: &str,
) {
let line = Line::from(vec![
Span::raw(" "),
Span::styled(message.to_string(), theme::muted()),
Span::raw(" "),
Span::styled(format!(" {} ", key), theme::footer_key()),
Span::styled(format!(" {}", action), theme::muted()),
]);
frame.render_widget(Paragraph::new(line), area);
}
pub const PICKER_ARROW: &str = "\u{25B8}";
pub const TOGGLE_HINT: &str = "\u{2423}";
pub fn empty_line(message: &str) -> Line<'static> {
muted_line(message)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FieldKind {
Text,
Toggle,
Picker,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FormFooterMode {
Collapsed,
Expanded(FieldKind),
}
pub fn form_save_footer(mode: FormFooterMode) -> Footer {
let mut footer = Footer::new().primary("Enter", " save ");
match mode {
FormFooterMode::Collapsed => {
footer = footer.action("\u{2193}", " more options ");
}
FormFooterMode::Expanded(FieldKind::Text) => {
footer = footer.action("Tab", " next ");
}
FormFooterMode::Expanded(FieldKind::Toggle) => {
footer = footer.action("Space", " toggle ").action("Tab", " next ");
}
FormFooterMode::Expanded(FieldKind::Picker) => {
footer = footer.action("Space", " pick ").action("Tab", " next ");
}
}
footer.action("Esc", " cancel")
}
pub fn confirm_footer_destructive(yes_verb: &str, no_verb: &str) -> Footer {
Footer::new()
.primary("y", &format!(" {} ", yes_verb))
.action("n/Esc", &format!(" {}", no_verb))
}
pub fn discard_footer() -> Footer {
confirm_footer_destructive("discard", "keep")
}
pub fn render_discard_prompt(frame: &mut Frame, footer_area: Rect, app: &App) {
let mut spans = vec![Span::styled(" Discard changes? ", theme::error())];
spans.extend(discard_footer().into_spans());
super::render_footer_with_status(frame, footer_area, spans, app);
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use ratatui::widgets::Widget;
fn make_app() -> (App, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let config = crate::ssh_config::model::SshConfigFile {
elements: crate::ssh_config::model::SshConfigFile::parse_content(""),
path: dir.path().join("test_design"),
crlf: false,
bom: false,
};
(App::new(config), dir)
}
fn buffer_contains(buf: &Buffer, needle: &str) -> bool {
for y in 0..buf.area.height {
let mut row = String::new();
for x in 0..buf.area.width {
row.push_str(buf[(x, y)].symbol());
}
if row.contains(needle) {
return true;
}
}
false
}
fn render_block_title(block: Block<'static>, title: &str) -> bool {
let area = Rect::new(0, 0, 30, 5);
let mut buf = Buffer::empty(area);
block.render(area, &mut buf);
buffer_contains(&buf, title)
}
#[test]
fn overlay_block_title_is_padded() {
assert!(render_block_title(overlay_block("Hello"), " Hello "));
}
#[test]
fn danger_block_title_is_padded() {
assert!(render_block_title(danger_block("Delete"), " Delete "));
}
#[test]
fn main_block_title_is_padded() {
assert!(render_block_title(main_block("Hosts"), " Hosts "));
}
#[test]
fn overlay_area_stays_within_frame() {
let backend = TestBackend::new(100, 40);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let rect = overlay_area(frame, 70, 80, 20);
let area = frame.area();
assert!(rect.x >= area.x);
assert!(rect.y >= area.y);
assert!(rect.x + rect.width <= area.x + area.width);
assert!(rect.y + rect.height <= area.y + area.height);
assert!(rect.height <= 20);
})
.unwrap();
}
#[test]
fn form_footer_sits_directly_below_block() {
let block_area = Rect::new(5, 2, 30, 8);
let rect = form_footer(block_area, 8);
assert_eq!(rect.x, 5);
assert_eq!(rect.y, 10);
assert_eq!(rect.width, 30);
assert_eq!(rect.height, 1);
}
#[test]
fn form_divider_y_steps_by_two() {
let inner = Rect::new(2, 3, 20, 10);
assert_eq!(form_divider_y(inner, 0), 3);
assert_eq!(form_divider_y(inner, 1), 5);
assert_eq!(form_divider_y(inner, 2), 7);
}
#[test]
fn footer_builder_inserts_gaps_between_entries_only() {
let spans = Footer::new()
.primary("Enter", "save")
.action("Esc", "cancel")
.action("Tab", "next")
.into_spans();
assert_eq!(spans.len(), 8);
assert_eq!(spans[2].content, FOOTER_GAP);
assert_eq!(spans[5].content, FOOTER_GAP);
}
#[test]
fn empty_footer_has_no_spans() {
assert!(Footer::new().into_spans().is_empty());
}
#[test]
fn footer_to_line_preserves_span_count() {
let footer = Footer::new()
.primary("Enter", "save")
.action("Esc", "cancel");
let spans_len = {
let clone = Footer::new()
.primary("Enter", "save")
.action("Esc", "cancel");
clone.into_spans().len()
};
let line = footer.to_line();
assert_eq!(line.spans.len(), spans_len);
}
#[test]
fn picker_width_is_clamped() {
let backend = TestBackend::new(100, 40);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let w = picker_width(frame);
assert!(w >= PICKER_MIN_W);
assert!(w <= PICKER_MAX_W);
})
.unwrap();
}
#[test]
fn picker_width_clamps_narrow_terminal_to_min() {
let backend = TestBackend::new(30, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
assert_eq!(picker_width(frame), PICKER_MIN_W);
})
.unwrap();
}
#[test]
fn picker_width_clamps_wide_terminal_to_max() {
let backend = TestBackend::new(200, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
assert_eq!(picker_width(frame), PICKER_MAX_W);
})
.unwrap();
}
#[test]
fn picker_width_passes_midrange_through() {
let backend = TestBackend::new(66, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
assert_eq!(picker_width(frame), 66);
})
.unwrap();
}
#[test]
fn plain_overlay_block_has_no_title() {
let area = Rect::new(0, 0, 20, 3);
let mut buf = Buffer::empty(area);
plain_overlay_block().render(area, &mut buf);
let mut top = String::new();
for x in 0..area.width {
top.push_str(buf[(x, 0)].symbol());
}
assert!(top.starts_with('\u{256D}'));
assert!(top.ends_with('\u{256E}'));
for ch in top.chars().skip(1).take((area.width as usize) - 2) {
assert_eq!(ch, '\u{2500}');
}
}
#[test]
fn section_divider_contains_dashes() {
let line = section_divider();
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.contains("────"),
"section divider should contain dash characters"
);
}
#[test]
fn padded_usize_matches_expected_values() {
assert_eq!(padded_usize(0), 0);
assert_eq!(padded_usize(10), 12);
assert_eq!(padded_usize(20), 23);
}
#[test]
fn kv_line_format_has_two_spans() {
let line = kv_line("Label", "Value", KV_LABEL_WIDE);
assert_eq!(line.spans.len(), 2);
let label_text = &line.spans[0].content;
assert!(
label_text.starts_with(" "),
"label should be 2-space indented"
);
assert!(label_text.contains("Label"));
assert_eq!(line.spans[1].content.as_ref(), "Value");
}
#[test]
fn kv_line_label_is_padded_to_width() {
let line = kv_line("X", "Y", 22);
let label = &line.spans[0].content;
assert_eq!(label.len(), 24);
}
#[test]
fn content_section_returns_header_and_divider() {
let [header, divider] = content_section("Directives");
let h_text: String = header.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(h_text.contains("Directives"));
let d_text: String = divider.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(d_text.contains("────"));
}
#[test]
fn render_empty_with_hint_does_not_panic() {
let backend = TestBackend::new(60, 3);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = Rect::new(0, 0, 60, 1);
render_empty_with_hint(frame, area, "No tags yet.", "+", "add");
})
.unwrap();
}
#[test]
fn column_header_prefix_is_three_spaces() {
assert_eq!(COLUMN_HEADER_PREFIX, " ");
assert_eq!(COLUMN_HEADER_PREFIX.len(), 3);
}
#[test]
fn col_gap_str_is_two_spaces() {
assert_eq!(COL_GAP_STR, " ");
assert_eq!(COL_GAP_STR.len(), 2);
}
#[test]
fn picker_arrow_renders_as_single_glyph() {
assert_eq!(PICKER_ARROW.chars().count(), 1);
assert!(!PICKER_ARROW.starts_with(char::is_whitespace));
}
#[test]
fn toggle_hint_renders_as_single_glyph() {
assert_eq!(TOGGLE_HINT.chars().count(), 1);
assert!(!TOGGLE_HINT.starts_with(char::is_whitespace));
}
#[test]
fn empty_line_has_indent_and_muted_style() {
let line = empty_line("No results.");
assert_eq!(line.spans.len(), 2);
assert_eq!(line.spans[0].content.as_ref(), " ");
assert_eq!(line.spans[1].content.as_ref(), "No results.");
}
#[test]
fn render_empty_loading_error_do_not_panic() {
let backend = TestBackend::new(40, 3);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = Rect::new(0, 0, 40, 1);
render_empty(frame, area, "no hosts");
render_loading(frame, area, "loading...");
render_error(frame, area, "something broke");
})
.unwrap();
}
#[test]
fn footer_render_with_status_does_not_panic() {
let (app, _dir) = make_app();
let backend = TestBackend::new(60, 3);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = Rect::new(0, 0, 60, 1);
Footer::new()
.primary("Enter", "save")
.action("Esc", "cancel")
.render_with_status(frame, area, &app);
})
.unwrap();
}
fn footer_text(footer: Footer) -> String {
footer
.into_spans()
.iter()
.map(|s| s.content.as_ref())
.collect()
}
#[test]
fn form_save_footer_collapsed_shows_more_options() {
let text = footer_text(form_save_footer(FormFooterMode::Collapsed));
assert!(text.contains("Enter"));
assert!(text.contains("save"));
assert!(text.contains("more options"));
assert!(text.contains("Esc"));
assert!(text.contains("cancel"));
assert!(!text.contains("Space"));
}
#[test]
fn form_save_footer_expanded_text_omits_space_hint() {
let text = footer_text(form_save_footer(FormFooterMode::Expanded(FieldKind::Text)));
assert!(text.contains("Enter"));
assert!(text.contains("save"));
assert!(text.contains("Tab"));
assert!(text.contains("Esc"));
assert!(!text.contains("Space"));
}
#[test]
fn form_save_footer_expanded_toggle_shows_space_toggle() {
let text = footer_text(form_save_footer(FormFooterMode::Expanded(
FieldKind::Toggle,
)));
assert!(text.contains("Space"));
assert!(text.contains("toggle"));
assert!(!text.contains("pick"));
}
#[test]
fn form_save_footer_expanded_picker_shows_space_pick() {
let text = footer_text(form_save_footer(FormFooterMode::Expanded(
FieldKind::Picker,
)));
assert!(text.contains("Space"));
assert!(text.contains("pick"));
assert!(!text.contains("toggle"));
}
#[test]
fn confirm_footer_destructive_uses_action_verbs() {
let text = footer_text(confirm_footer_destructive("delete", "keep"));
assert!(text.contains("y"));
assert!(text.contains("delete"));
assert!(text.contains("n/Esc"));
assert!(text.contains("keep"));
assert!(!text.contains("yes"));
assert!(!text.contains(" no"));
}
#[test]
fn confirm_footers_advertise_n_alongside_esc() {
for footer_text_str in [
footer_text(confirm_footer_destructive("delete", "keep")),
footer_text(discard_footer()),
] {
assert!(
footer_text_str.contains("n/Esc"),
"footer must show both n and Esc as cancel keys: {}",
footer_text_str
);
}
}
#[test]
fn discard_footer_uses_discard_keep_verbs() {
let text = footer_text(discard_footer());
assert!(text.contains("discard"));
assert!(text.contains("keep"));
}
}