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 LOGO: [&str; 5] = [
" ╮ ",
"╭─╮╷ ╷╭─ ╭─╮ │ ╭─╮ ",
"│ ││ ││ │ │ │ ├─╯ ",
"├─╯╰─╯╵ ├─╯╶┴╴╰─╴ ▪",
"╵ ╵ ",
];
pub const LOGO_DOT_COL_START: usize = 19;
pub const LOGO_DOT_COL_END: usize = 20;
pub fn logo_line(
i: usize,
word_style: ratatui::style::Style,
dot_style: ratatui::style::Style,
) -> ratatui::text::Line<'static> {
use ratatui::text::Span;
let chars: Vec<char> = LOGO[i].chars().collect();
let before: String = chars
.get(..LOGO_DOT_COL_START)
.unwrap_or(&[])
.iter()
.collect();
let dot: String = chars
.get(LOGO_DOT_COL_START..LOGO_DOT_COL_END.min(chars.len()))
.unwrap_or(&[])
.iter()
.collect();
let after: String = chars
.get(LOGO_DOT_COL_END..)
.unwrap_or(&[])
.iter()
.collect();
ratatui::text::Line::from(vec![
Span::styled(before, word_style),
Span::styled(dot, dot_style),
Span::styled(after, word_style),
])
}
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 ICON_PAUSED: &str = "\u{25D0}";
pub const ICON_STOPPED: &str = "\u{25CB}";
pub const ICON_SLOW: &str = "\u{25B2}";
pub const ICON_PENDING: &str = "\u{00B7}";
pub const ICON_TARGET: &str = "\u{25C9}";
pub const ROUTE_BRANCH: &str = "\u{250A}";
pub fn is_container_running(state: &str) -> bool {
state.eq_ignore_ascii_case("running")
}
pub fn parse_container_exit_code(status: &str) -> Option<i32> {
let prefix = "Exited (";
let start = status.find(prefix)?;
let after = &status[start + prefix.len()..];
let end = after.find(')')?;
after[..end].parse().ok()
}
pub fn container_state_style(
state: &str,
health: Option<&str>,
status: &str,
inspect_exit_code: Option<i32>,
spinner_tick: u64,
) -> (&'static str, ratatui::style::Style) {
if is_container_running(state) {
return match health {
Some("unhealthy") => (ICON_ONLINE, theme::error()),
Some("starting") => (ICON_ONLINE, theme::warning()),
_ => (ICON_ONLINE, theme::online_dot_pulsing(spinner_tick)),
};
}
match state {
"dead" => (ICON_ERROR, theme::error()),
"exited" | "stopped" => {
let exit_code = parse_container_exit_code(status).or(inspect_exit_code);
match exit_code {
Some(code) if code != 0 => (ICON_ERROR, theme::warning()),
_ => (ICON_STOPPED, theme::muted()),
}
}
"paused" | "restarting" => (ICON_PAUSED, theme::warning()),
_ => (ICON_STOPPED, theme::muted()),
}
}
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::border_dim())
.title(title)
}
pub fn search_overlay_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 plain_overlay_block() -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(theme::border_dim())
}
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_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);
}
#[allow(dead_code)]
pub fn body_text_area(inner: Rect, y: u16, height: u16) -> Rect {
Rect::new(
inner.x.saturating_add(1),
y,
inner.width.saturating_sub(1),
height,
)
}
pub const BODY_RIGHT_PAD: u16 = 2;
pub fn body_area(block_area: Rect) -> Rect {
let inner_x = block_area.x.saturating_add(1);
let inner_y = block_area.y.saturating_add(1);
let inner_w = block_area.width.saturating_sub(2);
let inner_h = block_area.height.saturating_sub(2);
let pad_x = BODY_RIGHT_PAD.min(inner_w);
Rect::new(inner_x, inner_y, inner_w.saturating_sub(pad_x), inner_h)
}
#[allow(dead_code)]
pub fn render_body<'a>(
frame: &mut Frame,
block_area: Rect,
block: Block<'a>,
lines: Vec<Line<'a>>,
) {
frame.render_widget(block, block_area);
frame.render_widget(Paragraph::new(lines), body_area(block_area));
}
pub fn render_body_wrapped<'a>(
frame: &mut Frame,
block_area: Rect,
block: Block<'a>,
lines: Vec<Line<'a>>,
) {
use ratatui::widgets::Wrap;
frame.render_widget(block, block_area);
let body = body_area(block_area);
let max_w = body.width as usize;
let out = wrap_block_lines(lines, max_w);
frame.render_widget(Paragraph::new(out).wrap(Wrap { trim: false }), body);
}
pub fn wrap_block_lines<'a>(lines: Vec<Line<'a>>, max_w: usize) -> Vec<Line<'static>> {
use unicode_width::UnicodeWidthStr;
let mut out: Vec<Line<'static>> = Vec::with_capacity(lines.len());
for line in lines {
if line.alignment.is_some() {
let alignment = line.alignment;
let owned: Vec<Span<'static>> = line
.spans
.into_iter()
.map(|s| Span::styled(s.content.into_owned(), s.style))
.collect();
let mut new_line = Line::from(owned);
if let Some(a) = alignment {
new_line = new_line.alignment(a);
}
out.push(new_line);
continue;
}
let mut indent_w = 0usize;
let mut body_span: Option<Span<'a>> = None;
let mut leading_only = true;
let total_spans = line.spans.len();
for (i, span) in line.spans.iter().enumerate() {
let content: &str = span.content.as_ref();
if content.chars().all(|c| c == ' ') {
indent_w += content.len();
continue;
}
if i == total_spans - 1 {
body_span = Some(span.clone());
} else {
leading_only = false;
}
break;
}
if leading_only {
if let Some(span) = body_span {
let content = span.content.into_owned();
let trimmed = content.trim_start_matches(' ');
let extra_indent = content.len() - trimmed.len();
let total_indent = indent_w + extra_indent;
let full_width = indent_w + content.width();
let needs_wrap = full_width > max_w;
if total_indent > 0 && !trimmed.is_empty() && needs_wrap {
let indent = " ".repeat(total_indent);
let body_text = trimmed.to_string();
for wrapped in wrap_indented(&body_text, &indent, max_w) {
out.push(Line::from(Span::styled(wrapped, span.style)));
}
continue;
}
let mut spans: Vec<Span<'static>> = Vec::new();
if indent_w > 0 {
spans.push(Span::raw(" ".repeat(indent_w)));
}
spans.push(Span::styled(content, span.style));
out.push(Line::from(spans));
continue;
}
out.push(Line::from(""));
continue;
}
let owned: Vec<Span<'static>> = line
.spans
.into_iter()
.map(|s| Span::styled(s.content.into_owned(), s.style))
.collect();
out.push(Line::from(owned));
}
out
}
pub struct TabEmpty<'a> {
pub card_title: &'a str,
pub headline: &'a str,
pub explainer: &'a str,
pub hints: &'a [(&'a str, &'a str)],
}
pub fn render_tab_empty(frame: &mut Frame, area: Rect, e: &TabEmpty) {
use unicode_width::UnicodeWidthStr;
if area.width < 44 || area.height < 8 {
if let Some((key, action)) = e.hints.first() {
render_empty_with_hint(frame, area, e.headline, key, action);
} else {
render_empty(frame, area, e.headline);
}
return;
}
let body = body_area(area);
let card_w_max = 78u16.min(body.width.saturating_sub(2));
let card_w_min = 40u16;
let card_w = card_w_max.max(card_w_min).min(body.width);
let card_x = body.x + (body.width.saturating_sub(card_w)) / 2;
let inner_card_w = card_w as usize;
let prose_w = inner_card_w.saturating_sub(4); let mut card_lines: Vec<Line<'static>> = Vec::new();
section_open(&mut card_lines, e.card_title, inner_card_w);
section_line(&mut card_lines, vec![Span::raw("")], inner_card_w);
section_line(
&mut card_lines,
vec![
Span::raw(" "),
Span::styled(e.headline.to_string(), theme::bold()),
],
inner_card_w,
);
if !e.explainer.is_empty() {
section_line(&mut card_lines, vec![Span::raw("")], inner_card_w);
for row in wrap_indented(e.explainer, " ", prose_w) {
section_line(
&mut card_lines,
vec![Span::styled(row, theme::muted())],
inner_card_w,
);
}
}
if !e.hints.is_empty() {
section_line(&mut card_lines, vec![Span::raw("")], inner_card_w);
let key_w = e.hints.iter().map(|(k, _)| k.width()).max().unwrap_or(1);
for (key, action) in e.hints {
let key_pad = format!(" {:>width$} ", key, width = key_w);
section_line(
&mut card_lines,
vec![
Span::styled(key_pad, theme::accent_bold()),
Span::styled(action.to_string(), theme::muted()),
],
inner_card_w,
);
}
}
section_line(&mut card_lines, vec![Span::raw("")], inner_card_w);
section_close(&mut card_lines, inner_card_w);
let card_h = card_lines.len() as u16;
let top_pad = body.height.saturating_sub(card_h) / 2;
let card_y = body.y + top_pad;
let card_rect = Rect::new(card_x, card_y, card_w, card_h.min(body.height));
frame.render_widget(Paragraph::new(card_lines), card_rect);
}
pub fn render_tab_empty_detail(frame: &mut Frame, detail_area: Rect) {
frame.render_widget(main_block_line(Line::default()), detail_area);
}
pub fn wrap_indented(text: &str, indent: &str, max_width: usize) -> Vec<String> {
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
if text.is_empty() || max_width == 0 {
return Vec::new();
}
let indent_w = indent.width();
if indent_w >= max_width {
return wrap_indented(text, "", max_width);
}
let content_max = max_width - indent_w;
let mut out: Vec<String> = Vec::new();
let mut current = String::new();
let mut current_w = 0usize;
let push_current = |out: &mut Vec<String>, current: &mut String, current_w: &mut usize| {
if !current.is_empty() {
out.push(format!("{}{}", indent, current));
current.clear();
*current_w = 0;
}
};
for word in text.split(' ') {
let word_w = word.width();
if word_w == 0 {
if current_w < content_max {
current.push(' ');
current_w += 1;
}
continue;
}
if current_w > 0 && current_w + 1 + word_w > content_max {
push_current(&mut out, &mut current, &mut current_w);
}
if word_w > content_max {
push_current(&mut out, &mut current, &mut current_w);
let mut chunk = String::new();
let mut chunk_w = 0usize;
for ch in word.chars() {
let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
if chunk_w + cw > content_max {
out.push(format!("{}{}", indent, chunk));
chunk.clear();
chunk_w = 0;
}
chunk.push(ch);
chunk_w += cw;
}
if !chunk.is_empty() {
current = chunk;
current_w = chunk_w;
}
continue;
}
if current_w > 0 {
current.push(' ');
current_w += 1;
}
current.push_str(word);
current_w += word_w;
}
push_current(&mut out, &mut current, &mut current_w);
out
}
#[allow(dead_code)]
pub fn ellipsize(text: &str, max_width: usize) -> String {
use unicode_width::UnicodeWidthStr;
if max_width == 0 {
return String::new();
}
if text.width() <= max_width {
return text.to_string();
}
if max_width == 1 {
return "…".to_string();
}
let mut out = String::new();
let mut width = 0usize;
for ch in text.chars() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if width + cw + 1 > max_width {
break;
}
width += cw;
out.push(ch);
}
out.push('…');
out
}
pub const PICKER_ARROW: &str = "\u{25B8}";
pub const TOGGLE_HINT: &str = "\u{2423}";
pub const TREE_EXPANDED: &str = "\u{25BE}";
pub const SORT_DESC: &str = "\u{25BE}";
pub const TREE_COLLAPSED: &str = "\u{25B8}";
pub const TREE_BRANCH: &str = "\u{2514}";
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 {
use crate::messages::footer as f;
let mut footer = Footer::new().primary("Enter", f::ENTER_SAVE);
match mode {
FormFooterMode::Collapsed => {
footer = footer.action("\u{2193}", " more options ");
}
FormFooterMode::Expanded(FieldKind::Text) => {
footer = footer.action("Tab", f::TAB_NEXT);
}
FormFooterMode::Expanded(FieldKind::Toggle) => {
footer = footer
.action("Space", f::SPACE_TOGGLE)
.action("Tab", f::TAB_NEXT);
}
FormFooterMode::Expanded(FieldKind::Picker) => {
footer = footer
.action("Space", f::SPACE_PICK)
.action("Tab", f::TAB_NEXT);
}
}
footer.action("Esc", f::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")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PopupKind {
Destructive,
Neutral,
}
pub fn render_confirm_popup<'a>(
frame: &mut Frame,
popup_w: u16,
kind: PopupKind,
title: &str,
content_lines: Vec<Line<'a>>,
footer_spans: Vec<Span<'static>>,
app: &App,
) {
let probe = super::centered_rect_fixed(popup_w, 7, frame.area());
let inner_w = body_area(probe).width as usize;
let wrapped = wrap_block_lines(content_lines, inner_w);
let body_rows = wrapped.len() as u16;
let frame_h = frame.area().height;
let max_h = frame_h.saturating_sub(2); let height = (2 + 1 + body_rows + 1).min(max_h);
let area = super::centered_rect_fixed(popup_w, height, frame.area());
frame.render_widget(Clear, area);
let block = match kind {
PopupKind::Destructive => danger_block(title),
PopupKind::Neutral => overlay_block(title),
};
let mut text: Vec<Line<'static>> = Vec::with_capacity(wrapped.len() + 1);
text.push(Line::from(""));
text.extend(wrapped);
render_body(frame, area, block, text);
let footer_area = render_overlay_footer(frame, area);
super::render_footer_with_status(frame, footer_area, footer_spans, app);
}
pub fn render_destructive_popup(
frame: &mut Frame,
title: &str,
body_question: &str,
body_detail: &str,
yes_verb: &str,
no_verb: &str,
app: &App,
) {
let mut content: Vec<Line<'static>> = vec![Line::from(Span::styled(
format!(" {}", body_question),
theme::bold(),
))];
if !body_detail.is_empty() {
content.push(Line::from(""));
content.push(Line::from(Span::styled(
format!(" {}", body_detail),
theme::muted(),
)));
}
let footer_spans = confirm_footer_destructive(yes_verb, no_verb)
.to_line()
.spans;
render_confirm_popup(
frame,
56,
PopupKind::Destructive,
title,
content,
footer_spans,
app,
);
}
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);
}
pub const BOX_TL: &str = "\u{256D}";
pub const BOX_TR: &str = "\u{256E}";
pub const BOX_BL: &str = "\u{2570}";
pub const BOX_BR: &str = "\u{256F}";
pub const BOX_H: &str = "\u{2500}";
pub const BOX_V: &str = "\u{2502}";
pub fn section_open(lines: &mut Vec<Line<'static>>, title: &str, width: usize) {
use unicode_width::UnicodeWidthStr;
let border_prefix = format!("{}{} ", BOX_TL, BOX_H);
let title_suffix = " ";
let prefix_width = border_prefix.width() + title.width() + title_suffix.width();
let fill = width.saturating_sub(prefix_width).saturating_sub(1);
lines.push(Line::from(vec![
Span::styled(border_prefix, theme::border()),
Span::styled(title.to_string(), theme::bold()),
Span::styled(title_suffix, theme::border()),
Span::styled(BOX_H.repeat(fill), theme::border()),
Span::styled(BOX_TR, theme::border()),
]));
}
pub fn section_open_notitle(lines: &mut Vec<Line<'static>>, width: usize) {
let fill = width.saturating_sub(2);
lines.push(Line::from(vec![
Span::styled(BOX_TL, theme::border()),
Span::styled(BOX_H.repeat(fill), theme::border()),
Span::styled(BOX_TR, theme::border()),
]));
}
pub fn section_line(lines: &mut Vec<Line<'static>>, spans: Vec<Span<'static>>, width: usize) {
use unicode_width::UnicodeWidthStr;
let mut full_spans: Vec<Span<'static>> =
vec![Span::styled(format!("{} ", BOX_V), theme::border())];
let content_width: usize = full_spans.iter().map(|s| s.content.width()).sum::<usize>()
+ spans.iter().map(|s| s.content.width()).sum::<usize>();
full_spans.extend(spans);
let closing_offset = 1;
let padding = width
.saturating_sub(content_width)
.saturating_sub(closing_offset);
if padding > 0 {
full_spans.push(Span::raw(" ".repeat(padding)));
}
full_spans.push(Span::styled(BOX_V, theme::border()));
lines.push(Line::from(full_spans));
}
pub fn section_close(lines: &mut Vec<Line<'static>>, width: usize) {
let fill = width.saturating_sub(2);
lines.push(Line::from(vec![
Span::styled(BOX_BL, theme::border()),
Span::styled(BOX_H.repeat(fill), theme::border()),
Span::styled(BOX_BR, theme::border()),
]));
}
pub fn section_empty_line(width: usize) -> Line<'static> {
let fill = width.saturating_sub(2);
Line::from(vec![
Span::styled(BOX_V, theme::border()),
Span::raw(" ".repeat(fill)),
Span::styled(BOX_V, theme::border()),
])
}
pub fn stretch_last_card(lines: &mut Vec<Line<'static>>, available_rows: usize, box_width: usize) {
if lines.len() >= available_rows {
return;
}
let extra = available_rows - lines.len();
let last_close = lines.iter().rposition(|line| {
line.spans
.first()
.map(|s| s.content.starts_with(BOX_BL))
.unwrap_or(false)
});
let Some(idx) = last_close else {
return;
};
for _ in 0..extra {
lines.insert(idx, section_empty_line(box_width));
}
}
pub fn section_field(
lines: &mut Vec<Line<'static>>,
label: &str,
value: &str,
max_value_width: usize,
box_width: usize,
) {
use unicode_width::UnicodeWidthStr;
let display = if max_value_width > 0 && value.width() > max_value_width {
super::truncate(value, max_value_width)
} else {
value.to_string()
};
let spans = vec![
Span::styled(
format!("{:<width$}", label, width = SECTION_LABEL_W as usize),
theme::muted(),
),
Span::styled(display, theme::bold()),
];
section_line(lines, spans, box_width);
}
pub fn section_field_styled(
lines: &mut Vec<Line<'static>>,
label: &str,
value: &str,
value_style: ratatui::style::Style,
max_value_width: usize,
box_width: usize,
) {
use unicode_width::UnicodeWidthStr;
let display = if max_value_width > 0 && value.width() > max_value_width {
super::truncate(value, max_value_width)
} else {
value.to_string()
};
let spans = vec![
Span::styled(
format!("{:<width$}", label, width = SECTION_LABEL_W as usize),
theme::muted(),
),
Span::styled(display, value_style),
];
section_line(lines, spans, box_width);
}
#[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 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"));
}
#[test]
fn is_container_running_is_case_insensitive() {
assert!(is_container_running("running"));
assert!(is_container_running("Running"));
assert!(is_container_running("RUNNING"));
assert!(!is_container_running("exited"));
assert!(!is_container_running("paused"));
assert!(!is_container_running(""));
}
#[test]
fn parse_container_exit_code_extracts_docker_format() {
assert_eq!(
parse_container_exit_code("Exited (0) 2 minutes ago"),
Some(0)
);
assert_eq!(
parse_container_exit_code("Exited (137) just now"),
Some(137)
);
assert_eq!(parse_container_exit_code("Up 5 minutes"), None);
assert_eq!(parse_container_exit_code(""), None);
assert_eq!(parse_container_exit_code("Exited (abc) bad"), None);
}
#[test]
fn container_state_style_running_uses_online_icon() {
let (icon, _) = container_state_style("running", None, "", None, 0);
assert_eq!(icon, ICON_ONLINE);
}
#[test]
fn container_state_style_dead_uses_error_icon() {
let (icon, _) = container_state_style("dead", None, "", None, 0);
assert_eq!(icon, ICON_ERROR);
}
#[test]
fn container_state_style_paused_uses_paused_icon() {
let (icon, _) = container_state_style("paused", None, "", None, 0);
assert_eq!(icon, ICON_PAUSED);
let (icon, _) = container_state_style("restarting", None, "", None, 0);
assert_eq!(icon, ICON_PAUSED);
}
#[test]
fn container_state_style_clean_exit_uses_stopped_icon() {
let (icon, _) = container_state_style("exited", None, "Exited (0) ago", None, 0);
assert_eq!(icon, ICON_STOPPED);
let (icon, _) = container_state_style("exited", None, "", None, 0);
assert_eq!(icon, ICON_STOPPED);
}
#[test]
fn container_state_style_nonzero_exit_uses_error_icon() {
let (icon, _) = container_state_style("exited", None, "Exited (137) ago", None, 0);
assert_eq!(icon, ICON_ERROR);
let (icon, _) = container_state_style("stopped", None, "", Some(1), 0);
assert_eq!(icon, ICON_ERROR);
}
#[test]
fn container_state_style_unknown_state_falls_back_to_stopped() {
let (icon, _) = container_state_style("created", None, "", None, 0);
assert_eq!(icon, ICON_STOPPED);
let (icon, _) = container_state_style("removing", None, "", None, 0);
assert_eq!(icon, ICON_STOPPED);
}
#[test]
fn container_state_style_running_with_unhealthy_uses_error_style() {
let (_, style) = container_state_style("running", Some("unhealthy"), "", None, 0);
assert!(style.fg.is_some());
}
#[test]
fn body_area_insets_block_border_plus_right_margin() {
let block_area = Rect::new(10, 5, 40, 12);
let body = body_area(block_area);
assert_eq!(body.x, 11);
assert_eq!(body.width, 40 - 2 - BODY_RIGHT_PAD);
assert_eq!(body.y, 6);
assert_eq!(body.height, 10);
}
#[test]
fn body_area_collapses_safely_in_tiny_blocks() {
let body = body_area(Rect::new(0, 0, 1, 1));
assert_eq!(body.width, 0);
assert_eq!(body.height, 0);
}
#[test]
fn ellipsize_returns_text_unchanged_when_it_fits() {
assert_eq!(ellipsize("hello", 10), "hello");
assert_eq!(ellipsize("hello", 5), "hello");
}
#[test]
fn ellipsize_appends_single_glyph_when_text_overflows() {
assert_eq!(ellipsize("hello world", 8), "hello w…");
}
#[test]
fn ellipsize_handles_extreme_widths() {
assert_eq!(ellipsize("hello", 0), "");
assert_eq!(ellipsize("hello", 1), "…");
assert_eq!(ellipsize("", 5), "");
}
#[test]
fn wrap_indented_keeps_prefix_on_continuation_rows() {
let text = "alpha beta gamma delta epsilon zeta eta theta iota kappa";
let rows = wrap_indented(text, " ", 18);
assert!(rows.len() > 1, "long text must wrap");
for row in &rows {
assert!(row.starts_with(" "), "every row keeps indent: {row:?}");
assert!(row.len() <= 18 + 2, "row exceeds budget: {row:?}");
}
}
#[test]
fn wrap_indented_hard_breaks_oversized_words() {
let text = "ohabsurdlylongwordthatdoesnotfit ok";
let rows = wrap_indented(text, " ", 10);
assert!(rows.len() >= 2);
for row in &rows {
assert!(row.starts_with(" "));
}
}
#[test]
fn wrap_indented_returns_empty_for_zero_inputs() {
assert!(wrap_indented("", " ", 10).is_empty());
assert!(wrap_indented("hi", " ", 0).is_empty());
}
#[test]
fn render_body_wrapped_preserves_hanging_indent_on_continuation() {
let backend = TestBackend::new(20, 6);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = Rect::new(0, 0, 20, 6);
let block = Block::default().borders(Borders::ALL);
let text = vec![
Line::from(""),
Line::from(Span::styled(
" alpha beta gamma delta epsilon".to_string(),
theme::muted(),
)),
];
render_body_wrapped(frame, area, block, text);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let mut content_rows: Vec<String> = Vec::new();
for y in 1..(buf.area.height - 1) {
let mut row = String::new();
for x in 1..(buf.area.width - 1) {
row.push_str(buf[(x, y)].symbol());
}
if !row.trim().is_empty() {
content_rows.push(row);
}
}
assert!(
content_rows.len() >= 2,
"the body must wrap to at least two rows: {content_rows:?}"
);
for row in &content_rows {
assert!(
row.starts_with(" "),
"every wrapped row keeps the 2-space hanging indent: {row:?}"
);
}
}
fn trailing_inner_row(buf: &ratatui::buffer::Buffer) -> Option<String> {
let mut top_y: Option<u16> = None;
let mut bottom_y: Option<u16> = None;
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 top_y.is_none() && row.contains('\u{256D}') {
top_y = Some(y);
}
if row.contains('\u{2570}') {
bottom_y = Some(y);
}
}
let (top, bottom) = (top_y?, bottom_y?);
if bottom <= top + 1 {
return None;
}
let trailing_y = bottom - 1;
let mut left_border_x: Option<u16> = None;
for x in 0..buf.area.width {
if buf[(x, trailing_y)].symbol() == "\u{2502}" {
left_border_x = Some(x);
break;
}
}
let left = left_border_x?;
let mut row = String::new();
for x in (left + 1)..buf.area.width {
let sym = buf[(x, trailing_y)].symbol();
if sym == "\u{2502}" {
break;
}
row.push_str(sym);
}
Some(row)
}
#[test]
fn render_confirm_popup_keeps_trailing_blank_when_body_wraps() {
let backend = TestBackend::new(70, 14);
let mut terminal = Terminal::new(backend).unwrap();
let (app, _dir) = make_app();
terminal
.draw(|frame| {
render_destructive_popup(
frame,
"Remove provider?",
"Remove the \"Linode\" config labelled \"default\"?",
"Synced hosts stay in ~/.ssh/config. The integration is gone after save.",
"remove",
"keep",
&app,
);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let mut top_y: Option<u16> = None;
let mut bottom_y: Option<u16> = None;
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 top_y.is_none() && row.contains('\u{256D}') {
top_y = Some(y);
}
if row.contains('\u{2570}') {
bottom_y = Some(y);
}
}
let top = top_y.expect("popup must render a top border");
let bottom = bottom_y.expect("popup must render a bottom border");
assert!(bottom > top + 2, "popup must have at least one body row");
let trailing_y = bottom - 1;
let mut left_border_x: Option<u16> = None;
for x in 0..buf.area.width {
if buf[(x, trailing_y)].symbol() == "\u{2502}" {
left_border_x = Some(x);
break;
}
}
let left = left_border_x.expect("trailing row must have a left side border");
let mut trailing = String::new();
for x in (left + 1)..buf.area.width {
let sym = buf[(x, trailing_y)].symbol();
if sym == "\u{2502}" {
break;
}
trailing.push_str(sym);
}
assert!(
trailing.chars().all(|c| c == ' '),
"trailing inner row above bottom border must be blank, got {trailing:?}"
);
}
#[test]
fn render_confirm_popup_keeps_trailing_blank_when_body_fits_on_one_row() {
let backend = TestBackend::new(60, 12);
let mut terminal = Terminal::new(backend).unwrap();
let (app, _dir) = make_app();
terminal
.draw(|frame| {
render_destructive_popup(
frame,
"Confirm Delete",
"Delete \"foo\"?",
"",
"delete",
"keep",
&app,
);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let trailing = trailing_inner_row(&buf).expect("popup must have a trailing row");
assert!(
trailing.chars().all(|c| c == ' '),
"trailing inner row above bottom border must be blank, got {trailing:?}"
);
}
#[test]
fn render_confirm_popup_neutral_kind_keeps_trailing_blank() {
let backend = TestBackend::new(60, 12);
let mut terminal = Terminal::new(backend).unwrap();
let (app, _dir) = make_app();
terminal
.draw(|frame| {
let content = vec![Line::from(Span::styled(
" Import 12 hosts from known_hosts?".to_string(),
theme::bold(),
))];
let footer_spans = confirm_footer_destructive("import", "skip").to_line().spans;
render_confirm_popup(
frame,
52,
PopupKind::Neutral,
"Import",
content,
footer_spans,
&app,
);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let trailing = trailing_inner_row(&buf).expect("popup must have a trailing row");
assert!(
trailing.chars().all(|c| c == ' '),
"neutral popup trailing row must be blank, got {trailing:?}"
);
}
#[test]
fn wrap_block_lines_preserves_hanging_indent_on_multi_span_pattern() {
let input = vec![Line::from(vec![
Span::raw(" "),
Span::styled(
"Sends SIGTERM, waits 10s, then SIGKILL. Live connections will drop.".to_string(),
theme::muted(),
),
])];
let out = wrap_block_lines(input, 32);
assert!(
out.len() >= 2,
"long body must wrap, got {} rows",
out.len()
);
for line in &out {
let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
rendered.starts_with(" "),
"every wrapped row keeps the 2-space hanging indent: {rendered:?}"
);
}
}
#[test]
fn wrap_block_lines_bypasses_aligned_lines_verbatim() {
use ratatui::layout::Alignment;
let aligned = Line::from(Span::styled(
"Your SSH config, supercharged.".to_string(),
theme::muted(),
))
.alignment(Alignment::Center);
let out = wrap_block_lines(vec![aligned], 60);
assert_eq!(out.len(), 1, "aligned line stays a single row");
assert_eq!(out[0].alignment, Some(Alignment::Center));
let rendered: String = out[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(rendered, "Your SSH config, supercharged.");
}
#[test]
fn render_body_wrapped_passes_blank_lines_through_unchanged() {
let backend = TestBackend::new(20, 6);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = Rect::new(0, 0, 20, 6);
let block = Block::default().borders(Borders::ALL);
let text = vec![
Line::from(""),
Line::from(Span::styled(" hello".to_string(), theme::bold())),
Line::from(""),
Line::from(Span::styled(" world".to_string(), theme::muted())),
];
render_body_wrapped(frame, area, block, text);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let row = |y: u16| -> String {
let mut s = String::new();
for x in 1..(buf.area.width - 1) {
s.push_str(buf[(x, y)].symbol());
}
s
};
assert!(row(1).trim().is_empty(), "row 1 stays blank");
assert!(row(2).contains("hello"), "row 2 holds question");
assert!(row(3).trim().is_empty(), "row 3 stays blank");
assert!(row(4).contains("world"), "row 4 holds detail");
}
#[test]
fn tab_empty_falls_back_to_single_line_on_narrow_areas() {
let backend = ratatui::backend::TestBackend::new(40, 6);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let e = TabEmpty {
card_title: "X",
headline: "Cache is empty.",
explainer: "Nothing yet.",
hints: &[("R", "refresh")],
};
render_tab_empty(frame, Rect::new(0, 0, 40, 6), &e);
})
.unwrap();
}
#[test]
fn tab_empty_card_renders_on_wide_areas() {
let backend = ratatui::backend::TestBackend::new(100, 20);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let e = TabEmpty {
card_title: "Containers",
headline: "No containers cached yet.",
explainer: "Containers are fetched per host on demand and cached locally.",
hints: &[("Enter", "open a shell"), ("R", "refresh hosts")],
};
render_tab_empty(frame, Rect::new(0, 0, 100, 20), &e);
})
.unwrap();
}
#[test]
fn ellipsize_respects_unicode_display_width() {
let s = "東京京都大阪";
let out = ellipsize(s, 9);
assert!(out.ends_with('…'));
let inner = &out[..out.len() - '…'.len_utf8()];
assert!(unicode_width::UnicodeWidthStr::width(inner) <= 8);
}
}