1use ratatui::Frame;
12use ratatui::layout::Rect;
13use ratatui::text::{Line, Span};
14use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
15
16use super::theme;
17use crate::app::App;
18
19pub const FOOTER_GAP: &str = " ";
25pub const COL_GAP: u16 = 2;
27
28pub const LOGO: [&str; 5] = [
31 " ╮ ",
32 "╭─╮╷ ╷╭─ ╭─╮ │ ╭─╮ ",
33 "│ ││ ││ │ │ │ ├─╯ ",
34 "├─╯╰─╯╵ ├─╯╶┴╴╰─╴ ▪",
35 "╵ ╵ ",
36];
37
38pub const LOGO_DOT_COL_START: usize = 19;
41pub const LOGO_DOT_COL_END: usize = 20;
42
43pub fn logo_line(
46 i: usize,
47 word_style: ratatui::style::Style,
48 dot_style: ratatui::style::Style,
49) -> ratatui::text::Line<'static> {
50 use ratatui::text::Span;
51 let chars: Vec<char> = LOGO[i].chars().collect();
52 let before: String = chars
53 .get(..LOGO_DOT_COL_START)
54 .unwrap_or(&[])
55 .iter()
56 .collect();
57 let dot: String = chars
58 .get(LOGO_DOT_COL_START..LOGO_DOT_COL_END.min(chars.len()))
59 .unwrap_or(&[])
60 .iter()
61 .collect();
62 let after: String = chars
63 .get(LOGO_DOT_COL_END..)
64 .unwrap_or(&[])
65 .iter()
66 .collect();
67 ratatui::text::Line::from(vec![
68 Span::styled(before, word_style),
69 Span::styled(dot, dot_style),
70 Span::styled(after, word_style),
71 ])
72}
73
74pub const OVERLAY_W: u16 = 70;
80pub const OVERLAY_H: u16 = 80;
82pub const PICKER_MIN_W: u16 = 60;
87pub const PICKER_MAX_W: u16 = 72;
89pub const PICKER_MAX_H: u16 = 18;
92
93pub const TOAST_INSET_X: u16 = 2;
99pub const TOAST_INSET_Y: u16 = 2;
101
102pub const TIMEOUT_MIN_MS: u64 = 2500;
109pub const TIMEOUT_MIN_WARNING_MS: u64 = 4000;
111pub const MS_PER_WORD: u64 = 750;
115pub const WORD_CAP: usize = 30;
118pub const TOAST_QUEUE_MAX: usize = 3;
122
123pub const ICON_ONLINE: &str = "\u{25CF}";
129pub const ICON_SUCCESS: &str = "\u{2713}";
131pub const ICON_WARNING: &str = "\u{26A0}";
133pub const ICON_ERROR: &str = "\u{2716}";
137pub const ICON_PAUSED: &str = "\u{25D0}";
141pub const ICON_STOPPED: &str = "\u{25CB}";
144pub const ICON_SLOW: &str = "\u{25B2}";
148pub const ICON_PENDING: &str = "\u{00B7}";
152pub const ICON_TARGET: &str = "\u{25C9}";
156
157pub const ROUTE_BRANCH: &str = "\u{250A}";
164
165pub fn is_container_running(state: &str) -> bool {
173 state.eq_ignore_ascii_case("running")
174}
175
176pub fn parse_container_exit_code(status: &str) -> Option<i32> {
181 let prefix = "Exited (";
182 let start = status.find(prefix)?;
183 let after = &status[start + prefix.len()..];
184 let end = after.find(')')?;
185 after[..end].parse().ok()
186}
187
188pub fn container_state_style(
207 state: &str,
208 health: Option<&str>,
209 status: &str,
210 inspect_exit_code: Option<i32>,
211 spinner_tick: u64,
212) -> (&'static str, ratatui::style::Style) {
213 if is_container_running(state) {
214 return match health {
215 Some("unhealthy") => (ICON_ONLINE, theme::error()),
216 Some("starting") => (ICON_ONLINE, theme::warning()),
217 _ => (ICON_ONLINE, theme::online_dot_pulsing(spinner_tick)),
218 };
219 }
220 match state {
221 "dead" => (ICON_ERROR, theme::error()),
222 "exited" | "stopped" => {
223 let exit_code = parse_container_exit_code(status).or(inspect_exit_code);
224 match exit_code {
225 Some(code) if code != 0 => (ICON_ERROR, theme::warning()),
226 _ => (ICON_STOPPED, theme::muted()),
227 }
228 }
229 "paused" | "restarting" => (ICON_PAUSED, theme::warning()),
230 _ => (ICON_STOPPED, theme::muted()),
231 }
232}
233
234pub const LIST_HIGHLIGHT: &str = " ";
240pub const HOST_HIGHLIGHT: &str = "\u{258C}";
242
243pub const SECTION_LABEL_W: u16 = 14;
249
250pub const DIM_FG_RGB: (u8, u8, u8) = (70, 70, 70);
256
257pub fn overlay_block(title: &str) -> Block<'static> {
263 overlay_block_line(Line::from(Span::styled(
264 format!(" {title} "),
265 theme::brand(),
266 )))
267}
268
269pub fn overlay_block_line(title: Line<'static>) -> Block<'static> {
273 Block::default()
274 .borders(Borders::ALL)
275 .border_type(BorderType::Rounded)
276 .border_style(theme::border_dim())
277 .title(title)
278}
279
280pub fn search_overlay_block_line(title: Line<'static>) -> Block<'static> {
285 Block::default()
286 .borders(Borders::ALL)
287 .border_type(BorderType::Rounded)
288 .border_style(theme::border_search())
289 .title(title)
290}
291
292pub fn plain_overlay_block() -> Block<'static> {
296 Block::default()
297 .borders(Borders::ALL)
298 .border_type(BorderType::Rounded)
299 .border_style(theme::border_dim())
300}
301
302pub fn danger_block(title: &str) -> Block<'static> {
305 danger_block_line(Line::from(Span::styled(
306 format!(" {title} "),
307 theme::danger(),
308 )))
309}
310
311pub fn danger_block_line(title: Line<'static>) -> Block<'static> {
313 Block::default()
314 .borders(Borders::ALL)
315 .border_type(BorderType::Rounded)
316 .border_style(theme::border_danger())
317 .title(title)
318}
319
320pub fn main_block_line(title: Line<'static>) -> Block<'static> {
325 Block::default()
326 .borders(Borders::ALL)
327 .border_type(BorderType::Rounded)
328 .border_style(theme::border())
329 .title(title)
330}
331
332pub fn search_block_line(title: Line<'static>) -> Block<'static> {
335 Block::default()
336 .borders(Borders::ALL)
337 .border_type(BorderType::Rounded)
338 .border_style(theme::border_search())
339 .title(title)
340}
341
342pub fn overlay_area(frame: &Frame, w_pct: u16, h_pct: u16, height: u16) -> Rect {
348 let area = frame.area();
349 let pct_area = super::centered_rect(w_pct, h_pct, area);
353 super::centered_rect_fixed(pct_area.width, height.min(pct_area.height), area)
354}
355
356pub fn form_footer(block_area: Rect, block_height: u16) -> Rect {
368 Rect::new(
369 block_area.x,
370 block_area.y + block_height,
371 block_area.width,
372 1,
373 )
374}
375
376pub fn render_overlay_footer(frame: &mut Frame, block_area: Rect) -> Rect {
380 let footer_area = form_footer(block_area, block_area.height);
381 frame.render_widget(Clear, footer_area);
382 footer_area
383}
384
385pub fn form_divider_y(inner: Rect, index: usize) -> u16 {
387 inner.y + (index as u16) * 2
388}
389
390pub fn picker_width(frame: &Frame) -> u16 {
395 frame.area().width.clamp(PICKER_MIN_W, PICKER_MAX_W)
396}
397
398pub struct Footer {
404 spans: Vec<Span<'static>>,
405}
406
407impl Footer {
408 pub fn new() -> Self {
410 Self { spans: Vec::new() }
411 }
412
413 #[allow(deprecated)]
415 pub fn primary(mut self, key: &str, label: &str) -> Self {
416 if !self.spans.is_empty() {
417 self.spans.push(Span::raw(FOOTER_GAP));
418 }
419 let [k, l] = super::footer_primary(key, label);
420 self.spans.push(k);
421 self.spans.push(l);
422 self
423 }
424
425 pub fn action(mut self, key: &str, label: &str) -> Self {
427 if !self.spans.is_empty() {
428 self.spans.push(Span::raw(FOOTER_GAP));
429 }
430 let [k, l] = super::footer_action(key, label);
431 self.spans.push(k);
432 self.spans.push(l);
433 self
434 }
435
436 pub fn render_with_status(self, frame: &mut Frame, area: Rect, app: &App) {
438 super::render_footer_with_status(frame, area, self.spans, app);
439 }
440
441 #[allow(clippy::wrong_self_convention)]
443 pub fn to_line(self) -> Line<'static> {
444 Line::from(self.spans)
445 }
446
447 pub fn into_spans(self) -> Vec<Span<'static>> {
449 self.spans
450 }
451}
452
453impl Default for Footer {
454 fn default() -> Self {
455 Self::new()
456 }
457}
458
459fn muted_line(message: &str) -> Line<'static> {
467 Line::from(vec![
468 Span::raw(" "),
469 Span::styled(message.to_string(), theme::muted()),
470 ])
471}
472
473fn render_muted_message(frame: &mut Frame, area: Rect, message: &str) {
475 frame.render_widget(Paragraph::new(muted_line(message)), area);
476}
477
478pub fn render_empty(frame: &mut Frame, area: Rect, message: &str) {
480 render_muted_message(frame, area, message);
481}
482
483pub fn render_loading(frame: &mut Frame, area: Rect, message: &str) {
485 render_muted_message(frame, area, message);
486}
487
488pub fn render_error(frame: &mut Frame, area: Rect, message: &str) {
490 let line = Line::from(vec![
491 Span::raw(" "),
492 Span::styled(message.to_string(), theme::error()),
493 ]);
494 frame.render_widget(Paragraph::new(line), area);
495}
496
497pub fn section_divider() -> Line<'static> {
500 Line::from(Span::styled(" ────────────────────────", theme::muted()))
501}
502
503pub fn padded_usize(w: usize) -> usize {
509 if w == 0 { 0 } else { w + w / 10 + 1 }
510}
511
512pub const COLUMN_HEADER_PREFIX: &str = " ";
514
515pub const COL_GAP_STR: &str = " ";
517
518pub fn kv_line(label: &str, value: &str, label_width: usize) -> Line<'static> {
520 Line::from(vec![
521 Span::styled(
522 format!(" {:<width$}", label, width = label_width),
523 theme::muted(),
524 ),
525 Span::styled(value.to_string(), theme::bold()),
526 ])
527}
528
529pub const KV_LABEL_WIDE: usize = 22;
531
532pub fn content_section(label: &str) -> [Line<'static>; 2] {
534 [
535 Line::from(vec![
536 Span::raw(" "),
537 Span::styled(label.to_string(), theme::section_header()),
538 ]),
539 section_divider(),
540 ]
541}
542
543pub fn render_empty_with_hint(
545 frame: &mut Frame,
546 area: Rect,
547 message: &str,
548 key: &str,
549 action: &str,
550) {
551 let line = Line::from(vec![
552 Span::raw(" "),
553 Span::styled(message.to_string(), theme::muted()),
554 Span::raw(" "),
555 Span::styled(format!(" {} ", key), theme::footer_key()),
556 Span::styled(format!(" {}", action), theme::muted()),
557 ]);
558 frame.render_widget(Paragraph::new(line), area);
559}
560
561#[allow(dead_code)]
571pub fn body_text_area(inner: Rect, y: u16, height: u16) -> Rect {
572 Rect::new(
573 inner.x.saturating_add(1),
574 y,
575 inner.width.saturating_sub(1),
576 height,
577 )
578}
579
580pub const BODY_RIGHT_PAD: u16 = 2;
591
592pub fn body_area(block_area: Rect) -> Rect {
603 let inner_x = block_area.x.saturating_add(1);
604 let inner_y = block_area.y.saturating_add(1);
605 let inner_w = block_area.width.saturating_sub(2);
606 let inner_h = block_area.height.saturating_sub(2);
607 let pad_x = BODY_RIGHT_PAD.min(inner_w);
608 Rect::new(inner_x, inner_y, inner_w.saturating_sub(pad_x), inner_h)
609}
610
611#[allow(dead_code)]
621pub fn render_body<'a>(
622 frame: &mut Frame,
623 block_area: Rect,
624 block: Block<'a>,
625 lines: Vec<Line<'a>>,
626) {
627 frame.render_widget(block, block_area);
628 frame.render_widget(Paragraph::new(lines), body_area(block_area));
629}
630
631pub fn render_body_wrapped<'a>(
648 frame: &mut Frame,
649 block_area: Rect,
650 block: Block<'a>,
651 lines: Vec<Line<'a>>,
652) {
653 use ratatui::widgets::Wrap;
654 frame.render_widget(block, block_area);
655 let body = body_area(block_area);
656 let max_w = body.width as usize;
657 let out = wrap_block_lines(lines, max_w);
658 frame.render_widget(Paragraph::new(out).wrap(Wrap { trim: false }), body);
659}
660
661pub fn wrap_block_lines<'a>(lines: Vec<Line<'a>>, max_w: usize) -> Vec<Line<'static>> {
679 use unicode_width::UnicodeWidthStr;
680 let mut out: Vec<Line<'static>> = Vec::with_capacity(lines.len());
681 for line in lines {
682 if line.alignment.is_some() {
686 let alignment = line.alignment;
687 let owned: Vec<Span<'static>> = line
688 .spans
689 .into_iter()
690 .map(|s| Span::styled(s.content.into_owned(), s.style))
691 .collect();
692 let mut new_line = Line::from(owned);
693 if let Some(a) = alignment {
694 new_line = new_line.alignment(a);
695 }
696 out.push(new_line);
697 continue;
698 }
699
700 let mut indent_w = 0usize;
702 let mut body_span: Option<Span<'a>> = None;
703 let mut leading_only = true;
704 let total_spans = line.spans.len();
705 for (i, span) in line.spans.iter().enumerate() {
706 let content: &str = span.content.as_ref();
707 if content.chars().all(|c| c == ' ') {
708 indent_w += content.len();
709 continue;
710 }
711 if i == total_spans - 1 {
712 body_span = Some(span.clone());
713 } else {
714 leading_only = false;
715 }
716 break;
717 }
718
719 if leading_only {
720 if let Some(span) = body_span {
721 let content = span.content.into_owned();
722 let trimmed = content.trim_start_matches(' ');
723 let extra_indent = content.len() - trimmed.len();
724 let total_indent = indent_w + extra_indent;
725 let full_width = indent_w + content.width();
726 let needs_wrap = full_width > max_w;
727 if total_indent > 0 && !trimmed.is_empty() && needs_wrap {
728 let indent = " ".repeat(total_indent);
729 let body_text = trimmed.to_string();
730 for wrapped in wrap_indented(&body_text, &indent, max_w) {
731 out.push(Line::from(Span::styled(wrapped, span.style)));
732 }
733 continue;
734 }
735 let mut spans: Vec<Span<'static>> = Vec::new();
736 if indent_w > 0 {
737 spans.push(Span::raw(" ".repeat(indent_w)));
738 }
739 spans.push(Span::styled(content, span.style));
740 out.push(Line::from(spans));
741 continue;
742 }
743 out.push(Line::from(""));
744 continue;
745 }
746
747 let owned: Vec<Span<'static>> = line
751 .spans
752 .into_iter()
753 .map(|s| Span::styled(s.content.into_owned(), s.style))
754 .collect();
755 out.push(Line::from(owned));
756 }
757 out
758}
759
760pub struct TabEmpty<'a> {
777 pub card_title: &'a str,
778 pub headline: &'a str,
779 pub explainer: &'a str,
780 pub hints: &'a [(&'a str, &'a str)],
781}
782
783pub fn render_tab_empty(frame: &mut Frame, area: Rect, e: &TabEmpty) {
796 use unicode_width::UnicodeWidthStr;
797
798 if area.width < 44 || area.height < 8 {
801 if let Some((key, action)) = e.hints.first() {
802 render_empty_with_hint(frame, area, e.headline, key, action);
803 } else {
804 render_empty(frame, area, e.headline);
805 }
806 return;
807 }
808
809 let body = body_area(area);
810 let card_w_max = 78u16.min(body.width.saturating_sub(2));
811 let card_w_min = 40u16;
812 let card_w = card_w_max.max(card_w_min).min(body.width);
813 let card_x = body.x + (body.width.saturating_sub(card_w)) / 2;
814
815 let inner_card_w = card_w as usize;
819 let prose_w = inner_card_w.saturating_sub(4); let mut card_lines: Vec<Line<'static>> = Vec::new();
821 section_open(&mut card_lines, e.card_title, inner_card_w);
822
823 section_line(&mut card_lines, vec![Span::raw("")], inner_card_w);
826 section_line(
827 &mut card_lines,
828 vec![
829 Span::raw(" "),
830 Span::styled(e.headline.to_string(), theme::bold()),
831 ],
832 inner_card_w,
833 );
834
835 if !e.explainer.is_empty() {
837 section_line(&mut card_lines, vec![Span::raw("")], inner_card_w);
838 for row in wrap_indented(e.explainer, " ", prose_w) {
839 section_line(
840 &mut card_lines,
841 vec![Span::styled(row, theme::muted())],
842 inner_card_w,
843 );
844 }
845 }
846
847 if !e.hints.is_empty() {
848 section_line(&mut card_lines, vec![Span::raw("")], inner_card_w);
849 let key_w = e.hints.iter().map(|(k, _)| k.width()).max().unwrap_or(1);
852 for (key, action) in e.hints {
853 let key_pad = format!(" {:>width$} ", key, width = key_w);
854 section_line(
855 &mut card_lines,
856 vec![
857 Span::styled(key_pad, theme::accent_bold()),
858 Span::styled(action.to_string(), theme::muted()),
859 ],
860 inner_card_w,
861 );
862 }
863 }
864
865 section_line(&mut card_lines, vec![Span::raw("")], inner_card_w);
867 section_close(&mut card_lines, inner_card_w);
868
869 let card_h = card_lines.len() as u16;
872 let top_pad = body.height.saturating_sub(card_h) / 2;
873 let card_y = body.y + top_pad;
874 let card_rect = Rect::new(card_x, card_y, card_w, card_h.min(body.height));
875 frame.render_widget(Paragraph::new(card_lines), card_rect);
876}
877
878pub fn render_tab_empty_detail(frame: &mut Frame, detail_area: Rect) {
884 frame.render_widget(main_block_line(Line::default()), detail_area);
885}
886
887pub fn wrap_indented(text: &str, indent: &str, max_width: usize) -> Vec<String> {
901 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
902 if text.is_empty() || max_width == 0 {
903 return Vec::new();
904 }
905 let indent_w = indent.width();
906 if indent_w >= max_width {
907 return wrap_indented(text, "", max_width);
910 }
911 let content_max = max_width - indent_w;
912 let mut out: Vec<String> = Vec::new();
913 let mut current = String::new();
914 let mut current_w = 0usize;
915 let push_current = |out: &mut Vec<String>, current: &mut String, current_w: &mut usize| {
916 if !current.is_empty() {
917 out.push(format!("{}{}", indent, current));
918 current.clear();
919 *current_w = 0;
920 }
921 };
922 for word in text.split(' ') {
923 let word_w = word.width();
924 if word_w == 0 {
925 if current_w < content_max {
927 current.push(' ');
928 current_w += 1;
929 }
930 continue;
931 }
932 if current_w > 0 && current_w + 1 + word_w > content_max {
934 push_current(&mut out, &mut current, &mut current_w);
935 }
936 if word_w > content_max {
938 push_current(&mut out, &mut current, &mut current_w);
939 let mut chunk = String::new();
940 let mut chunk_w = 0usize;
941 for ch in word.chars() {
942 let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
943 if chunk_w + cw > content_max {
944 out.push(format!("{}{}", indent, chunk));
945 chunk.clear();
946 chunk_w = 0;
947 }
948 chunk.push(ch);
949 chunk_w += cw;
950 }
951 if !chunk.is_empty() {
952 current = chunk;
953 current_w = chunk_w;
954 }
955 continue;
956 }
957 if current_w > 0 {
958 current.push(' ');
959 current_w += 1;
960 }
961 current.push_str(word);
962 current_w += word_w;
963 }
964 push_current(&mut out, &mut current, &mut current_w);
965 out
966}
967
968#[allow(dead_code)]
979pub fn ellipsize(text: &str, max_width: usize) -> String {
980 use unicode_width::UnicodeWidthStr;
981 if max_width == 0 {
982 return String::new();
983 }
984 if text.width() <= max_width {
985 return text.to_string();
986 }
987 if max_width == 1 {
988 return "…".to_string();
989 }
990 let mut out = String::new();
991 let mut width = 0usize;
992 for ch in text.chars() {
993 let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
994 if width + cw + 1 > max_width {
995 break;
996 }
997 width += cw;
998 out.push(ch);
999 }
1000 out.push('…');
1001 out
1002}
1003
1004pub const PICKER_ARROW: &str = "\u{25B8}";
1006
1007pub const TOGGLE_HINT: &str = "\u{2423}";
1009
1010pub const TREE_EXPANDED: &str = "\u{25BE}";
1012
1013pub const SORT_DESC: &str = "\u{25BE}";
1017
1018pub const TREE_COLLAPSED: &str = "\u{25B8}";
1020
1021pub const TREE_BRANCH: &str = "\u{2514}";
1023
1024pub fn empty_line(message: &str) -> Line<'static> {
1027 muted_line(message)
1028}
1029
1030#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1050pub enum FieldKind {
1051 Text,
1053 Toggle,
1055 Picker,
1057}
1058
1059#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1066pub enum FormFooterMode {
1067 Collapsed,
1069 Expanded(FieldKind),
1071}
1072
1073pub fn form_save_footer(mode: FormFooterMode) -> Footer {
1085 use crate::messages::footer as f;
1086 let mut footer = Footer::new().primary("Enter", f::ENTER_SAVE);
1087 match mode {
1088 FormFooterMode::Collapsed => {
1089 footer = footer.action("\u{2193}", " more options ");
1090 }
1091 FormFooterMode::Expanded(FieldKind::Text) => {
1092 footer = footer.action("Tab", f::TAB_NEXT);
1093 }
1094 FormFooterMode::Expanded(FieldKind::Toggle) => {
1095 footer = footer
1096 .action("Space", f::SPACE_TOGGLE)
1097 .action("Tab", f::TAB_NEXT);
1098 }
1099 FormFooterMode::Expanded(FieldKind::Picker) => {
1100 footer = footer
1101 .action("Space", f::SPACE_PICK)
1102 .action("Tab", f::TAB_NEXT);
1103 }
1104 }
1105 footer.action("Esc", f::ESC_CANCEL)
1106}
1107
1108pub fn confirm_footer_destructive(yes_verb: &str, no_verb: &str) -> Footer {
1124 Footer::new()
1125 .primary("y", &format!(" {} ", yes_verb))
1126 .action("n/Esc", &format!(" {}", no_verb))
1127}
1128
1129pub fn discard_footer() -> Footer {
1135 confirm_footer_destructive("discard", "keep")
1136}
1137
1138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1142pub enum PopupKind {
1143 Destructive,
1145 Neutral,
1148}
1149
1150pub fn render_confirm_popup<'a>(
1177 frame: &mut Frame,
1178 popup_w: u16,
1179 kind: PopupKind,
1180 title: &str,
1181 content_lines: Vec<Line<'a>>,
1182 footer_spans: Vec<Span<'static>>,
1183 app: &App,
1184) {
1185 let probe = super::centered_rect_fixed(popup_w, 7, frame.area());
1188 let inner_w = body_area(probe).width as usize;
1189
1190 let wrapped = wrap_block_lines(content_lines, inner_w);
1191 let body_rows = wrapped.len() as u16;
1192
1193 let frame_h = frame.area().height;
1195 let max_h = frame_h.saturating_sub(2); let height = (2 + 1 + body_rows + 1).min(max_h);
1197
1198 let area = super::centered_rect_fixed(popup_w, height, frame.area());
1199 frame.render_widget(Clear, area);
1200
1201 let block = match kind {
1202 PopupKind::Destructive => danger_block(title),
1203 PopupKind::Neutral => overlay_block(title),
1204 };
1205
1206 let mut text: Vec<Line<'static>> = Vec::with_capacity(wrapped.len() + 1);
1207 text.push(Line::from(""));
1208 text.extend(wrapped);
1209 render_body(frame, area, block, text);
1213
1214 let footer_area = render_overlay_footer(frame, area);
1215 super::render_footer_with_status(frame, footer_area, footer_spans, app);
1216}
1217
1218pub fn render_destructive_popup(
1226 frame: &mut Frame,
1227 title: &str,
1228 body_question: &str,
1229 body_detail: &str,
1230 yes_verb: &str,
1231 no_verb: &str,
1232 app: &App,
1233) {
1234 let mut content: Vec<Line<'static>> = vec![Line::from(Span::styled(
1235 format!(" {}", body_question),
1236 theme::bold(),
1237 ))];
1238 if !body_detail.is_empty() {
1239 content.push(Line::from(""));
1240 content.push(Line::from(Span::styled(
1241 format!(" {}", body_detail),
1242 theme::muted(),
1243 )));
1244 }
1245 let footer_spans = confirm_footer_destructive(yes_verb, no_verb)
1246 .to_line()
1247 .spans;
1248 render_confirm_popup(
1249 frame,
1250 56,
1251 PopupKind::Destructive,
1252 title,
1253 content,
1254 footer_spans,
1255 app,
1256 );
1257}
1258
1259pub fn render_discard_prompt(frame: &mut Frame, footer_area: Rect, app: &App) {
1267 let mut spans = vec![Span::styled(" Discard changes? ", theme::error())];
1268 spans.extend(discard_footer().into_spans());
1269 super::render_footer_with_status(frame, footer_area, spans, app);
1270}
1271
1272pub const BOX_TL: &str = "\u{256D}";
1282pub const BOX_TR: &str = "\u{256E}";
1283pub const BOX_BL: &str = "\u{2570}";
1284pub const BOX_BR: &str = "\u{256F}";
1285pub const BOX_H: &str = "\u{2500}";
1286pub const BOX_V: &str = "\u{2502}";
1287
1288pub fn section_open(lines: &mut Vec<Line<'static>>, title: &str, width: usize) {
1290 use unicode_width::UnicodeWidthStr;
1291 let border_prefix = format!("{}{} ", BOX_TL, BOX_H);
1292 let title_suffix = " ";
1293 let prefix_width = border_prefix.width() + title.width() + title_suffix.width();
1294 let fill = width.saturating_sub(prefix_width).saturating_sub(1);
1295 lines.push(Line::from(vec![
1296 Span::styled(border_prefix, theme::border()),
1297 Span::styled(title.to_string(), theme::bold()),
1298 Span::styled(title_suffix, theme::border()),
1299 Span::styled(BOX_H.repeat(fill), theme::border()),
1300 Span::styled(BOX_TR, theme::border()),
1301 ]));
1302}
1303
1304pub fn section_open_with_status(
1309 lines: &mut Vec<Line<'static>>,
1310 title: &str,
1311 status: Vec<Span<'static>>,
1312 width: usize,
1313) {
1314 use unicode_width::UnicodeWidthStr;
1315 let border_prefix = format!("{}{} ", BOX_TL, BOX_H); let title_suffix = " ";
1317 let status_w: usize = status.iter().map(|s| s.content.width()).sum();
1318 let reserved = border_prefix.width() + title.width() + title_suffix.width() + status_w + 3;
1321 let fill = width.saturating_sub(reserved).saturating_sub(1);
1322 let mut spans = vec![
1323 Span::styled(border_prefix, theme::border()),
1324 Span::styled(title.to_string(), theme::bold()),
1325 Span::styled(title_suffix, theme::border()),
1326 Span::styled(BOX_H.repeat(fill), theme::border()),
1327 Span::styled(" ", theme::border()),
1328 ];
1329 spans.extend(status);
1330 spans.push(Span::styled(format!(" {BOX_H}"), theme::border()));
1331 spans.push(Span::styled(BOX_TR, theme::border()));
1332 lines.push(Line::from(spans));
1333}
1334
1335pub fn section_open_notitle(lines: &mut Vec<Line<'static>>, width: usize) {
1337 let fill = width.saturating_sub(2);
1338 lines.push(Line::from(vec![
1339 Span::styled(BOX_TL, theme::border()),
1340 Span::styled(BOX_H.repeat(fill), theme::border()),
1341 Span::styled(BOX_TR, theme::border()),
1342 ]));
1343}
1344
1345pub fn section_line(lines: &mut Vec<Line<'static>>, spans: Vec<Span<'static>>, width: usize) {
1347 use unicode_width::UnicodeWidthStr;
1348 let mut full_spans: Vec<Span<'static>> =
1349 vec![Span::styled(format!("{} ", BOX_V), theme::border())];
1350 let content_width: usize = full_spans.iter().map(|s| s.content.width()).sum::<usize>()
1351 + spans.iter().map(|s| s.content.width()).sum::<usize>();
1352 full_spans.extend(spans);
1353 let closing_offset = 1;
1354 let padding = width
1355 .saturating_sub(content_width)
1356 .saturating_sub(closing_offset);
1357 if padding > 0 {
1358 full_spans.push(Span::raw(" ".repeat(padding)));
1359 }
1360 full_spans.push(Span::styled(BOX_V, theme::border()));
1361 lines.push(Line::from(full_spans));
1362}
1363
1364pub fn section_close(lines: &mut Vec<Line<'static>>, width: usize) {
1366 let fill = width.saturating_sub(2);
1367 lines.push(Line::from(vec![
1368 Span::styled(BOX_BL, theme::border()),
1369 Span::styled(BOX_H.repeat(fill), theme::border()),
1370 Span::styled(BOX_BR, theme::border()),
1371 ]));
1372}
1373
1374pub fn section_empty_line(width: usize) -> Line<'static> {
1378 let fill = width.saturating_sub(2);
1379 Line::from(vec![
1380 Span::styled(BOX_V, theme::border()),
1381 Span::raw(" ".repeat(fill)),
1382 Span::styled(BOX_V, theme::border()),
1383 ])
1384}
1385
1386pub fn stretch_last_card(lines: &mut Vec<Line<'static>>, available_rows: usize, box_width: usize) {
1391 if lines.len() >= available_rows {
1392 return;
1393 }
1394 let extra = available_rows - lines.len();
1395 let last_close = lines.iter().rposition(|line| {
1396 line.spans
1397 .first()
1398 .map(|s| s.content.starts_with(BOX_BL))
1399 .unwrap_or(false)
1400 });
1401 let Some(idx) = last_close else {
1402 return;
1403 };
1404 for _ in 0..extra {
1405 lines.insert(idx, section_empty_line(box_width));
1406 }
1407}
1408
1409pub fn section_field(
1412 lines: &mut Vec<Line<'static>>,
1413 label: &str,
1414 value: &str,
1415 max_value_width: usize,
1416 box_width: usize,
1417) {
1418 use unicode_width::UnicodeWidthStr;
1419 let display = if max_value_width > 0 && value.width() > max_value_width {
1420 super::truncate(value, max_value_width)
1421 } else {
1422 value.to_string()
1423 };
1424 let spans = vec![
1425 Span::styled(
1426 format!("{:<width$}", label, width = SECTION_LABEL_W as usize),
1427 theme::muted(),
1428 ),
1429 Span::styled(display, theme::bold()),
1430 ];
1431 section_line(lines, spans, box_width);
1432}
1433
1434pub fn section_field_styled(
1437 lines: &mut Vec<Line<'static>>,
1438 label: &str,
1439 value: &str,
1440 value_style: ratatui::style::Style,
1441 max_value_width: usize,
1442 box_width: usize,
1443) {
1444 use unicode_width::UnicodeWidthStr;
1445 let display = if max_value_width > 0 && value.width() > max_value_width {
1446 super::truncate(value, max_value_width)
1447 } else {
1448 value.to_string()
1449 };
1450 let spans = vec![
1451 Span::styled(
1452 format!("{:<width$}", label, width = SECTION_LABEL_W as usize),
1453 theme::muted(),
1454 ),
1455 Span::styled(display, value_style),
1456 ];
1457 section_line(lines, spans, box_width);
1458}
1459
1460#[cfg(test)]
1465mod tests {
1466 use super::*;
1467 use ratatui::Terminal;
1468 use ratatui::backend::TestBackend;
1469 use ratatui::buffer::Buffer;
1470 use ratatui::widgets::Widget;
1471
1472 fn make_app() -> (App, tempfile::TempDir) {
1473 let dir = tempfile::tempdir().unwrap();
1474 let config = crate::ssh_config::model::SshConfigFile {
1475 elements: crate::ssh_config::model::SshConfigFile::parse_content(""),
1476 path: dir.path().join("test_design"),
1477 crlf: false,
1478 bom: false,
1479 };
1480 (App::new(config), dir)
1481 }
1482
1483 fn buffer_contains(buf: &Buffer, needle: &str) -> bool {
1484 for y in 0..buf.area.height {
1485 let mut row = String::new();
1486 for x in 0..buf.area.width {
1487 row.push_str(buf[(x, y)].symbol());
1488 }
1489 if row.contains(needle) {
1490 return true;
1491 }
1492 }
1493 false
1494 }
1495
1496 fn render_block_title(block: Block<'static>, title: &str) -> bool {
1497 let area = Rect::new(0, 0, 30, 5);
1498 let mut buf = Buffer::empty(area);
1499 block.render(area, &mut buf);
1500 buffer_contains(&buf, title)
1501 }
1502
1503 #[test]
1504 fn overlay_block_title_is_padded() {
1505 assert!(render_block_title(overlay_block("Hello"), " Hello "));
1506 }
1507
1508 #[test]
1509 fn danger_block_title_is_padded() {
1510 assert!(render_block_title(danger_block("Delete"), " Delete "));
1511 }
1512
1513 #[test]
1514 fn overlay_area_stays_within_frame() {
1515 let backend = TestBackend::new(100, 40);
1516 let mut terminal = Terminal::new(backend).unwrap();
1517 terminal
1518 .draw(|frame| {
1519 let rect = overlay_area(frame, 70, 80, 20);
1520 let area = frame.area();
1521 assert!(rect.x >= area.x);
1522 assert!(rect.y >= area.y);
1523 assert!(rect.x + rect.width <= area.x + area.width);
1524 assert!(rect.y + rect.height <= area.y + area.height);
1525 assert!(rect.height <= 20);
1526 })
1527 .unwrap();
1528 }
1529
1530 #[test]
1531 fn form_footer_sits_directly_below_block() {
1532 let block_area = Rect::new(5, 2, 30, 8);
1533 let rect = form_footer(block_area, 8);
1534 assert_eq!(rect.x, 5);
1535 assert_eq!(rect.y, 10);
1536 assert_eq!(rect.width, 30);
1537 assert_eq!(rect.height, 1);
1538 }
1539
1540 #[test]
1541 fn form_divider_y_steps_by_two() {
1542 let inner = Rect::new(2, 3, 20, 10);
1543 assert_eq!(form_divider_y(inner, 0), 3);
1544 assert_eq!(form_divider_y(inner, 1), 5);
1545 assert_eq!(form_divider_y(inner, 2), 7);
1546 }
1547
1548 #[test]
1549 fn footer_builder_inserts_gaps_between_entries_only() {
1550 let spans = Footer::new()
1551 .primary("Enter", "save")
1552 .action("Esc", "cancel")
1553 .action("Tab", "next")
1554 .into_spans();
1555 assert_eq!(spans.len(), 8);
1557 assert_eq!(spans[2].content, FOOTER_GAP);
1558 assert_eq!(spans[5].content, FOOTER_GAP);
1559 }
1560
1561 #[test]
1562 fn empty_footer_has_no_spans() {
1563 assert!(Footer::new().into_spans().is_empty());
1564 }
1565
1566 #[test]
1567 fn footer_to_line_preserves_span_count() {
1568 let footer = Footer::new()
1569 .primary("Enter", "save")
1570 .action("Esc", "cancel");
1571 let spans_len = {
1572 let clone = Footer::new()
1573 .primary("Enter", "save")
1574 .action("Esc", "cancel");
1575 clone.into_spans().len()
1576 };
1577 let line = footer.to_line();
1578 assert_eq!(line.spans.len(), spans_len);
1579 }
1580
1581 #[test]
1582 fn picker_width_is_clamped() {
1583 let backend = TestBackend::new(100, 40);
1584 let mut terminal = Terminal::new(backend).unwrap();
1585 terminal
1586 .draw(|frame| {
1587 let w = picker_width(frame);
1588 assert!(w >= PICKER_MIN_W);
1589 assert!(w <= PICKER_MAX_W);
1590 })
1591 .unwrap();
1592 }
1593
1594 #[test]
1595 fn picker_width_clamps_narrow_terminal_to_min() {
1596 let backend = TestBackend::new(30, 20);
1597 let mut terminal = Terminal::new(backend).unwrap();
1598 terminal
1599 .draw(|frame| {
1600 assert_eq!(picker_width(frame), PICKER_MIN_W);
1601 })
1602 .unwrap();
1603 }
1604
1605 #[test]
1606 fn picker_width_clamps_wide_terminal_to_max() {
1607 let backend = TestBackend::new(200, 20);
1608 let mut terminal = Terminal::new(backend).unwrap();
1609 terminal
1610 .draw(|frame| {
1611 assert_eq!(picker_width(frame), PICKER_MAX_W);
1612 })
1613 .unwrap();
1614 }
1615
1616 #[test]
1617 fn picker_width_passes_midrange_through() {
1618 let backend = TestBackend::new(66, 20);
1620 let mut terminal = Terminal::new(backend).unwrap();
1621 terminal
1622 .draw(|frame| {
1623 assert_eq!(picker_width(frame), 66);
1624 })
1625 .unwrap();
1626 }
1627
1628 #[test]
1629 fn plain_overlay_block_has_no_title() {
1630 let area = Rect::new(0, 0, 20, 3);
1634 let mut buf = Buffer::empty(area);
1635 plain_overlay_block().render(area, &mut buf);
1636 let mut top = String::new();
1637 for x in 0..area.width {
1638 top.push_str(buf[(x, 0)].symbol());
1639 }
1640 assert!(top.starts_with('\u{256D}'));
1641 assert!(top.ends_with('\u{256E}'));
1642 for ch in top.chars().skip(1).take((area.width as usize) - 2) {
1644 assert_eq!(ch, '\u{2500}');
1645 }
1646 }
1647
1648 #[test]
1649 fn section_divider_contains_dashes() {
1650 let line = section_divider();
1651 let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
1652 assert!(
1653 text.contains("────"),
1654 "section divider should contain dash characters"
1655 );
1656 }
1657
1658 #[test]
1659 fn padded_usize_matches_expected_values() {
1660 assert_eq!(padded_usize(0), 0);
1661 assert_eq!(padded_usize(10), 12);
1662 assert_eq!(padded_usize(20), 23);
1663 }
1664
1665 #[test]
1666 fn kv_line_format_has_two_spans() {
1667 let line = kv_line("Label", "Value", KV_LABEL_WIDE);
1668 assert_eq!(line.spans.len(), 2);
1669 let label_text = &line.spans[0].content;
1670 assert!(
1671 label_text.starts_with(" "),
1672 "label should be 2-space indented"
1673 );
1674 assert!(label_text.contains("Label"));
1675 assert_eq!(line.spans[1].content.as_ref(), "Value");
1676 }
1677
1678 #[test]
1679 fn kv_line_label_is_padded_to_width() {
1680 let line = kv_line("X", "Y", 22);
1681 let label = &line.spans[0].content;
1682 assert_eq!(label.len(), 24);
1684 }
1685
1686 #[test]
1687 fn content_section_returns_header_and_divider() {
1688 let [header, divider] = content_section("Directives");
1689 let h_text: String = header.spans.iter().map(|s| s.content.as_ref()).collect();
1690 assert!(h_text.contains("Directives"));
1691 let d_text: String = divider.spans.iter().map(|s| s.content.as_ref()).collect();
1692 assert!(d_text.contains("────"));
1693 }
1694
1695 #[test]
1696 fn render_empty_with_hint_does_not_panic() {
1697 let backend = TestBackend::new(60, 3);
1698 let mut terminal = Terminal::new(backend).unwrap();
1699 terminal
1700 .draw(|frame| {
1701 let area = Rect::new(0, 0, 60, 1);
1702 render_empty_with_hint(frame, area, "No tags yet.", "+", "add");
1703 })
1704 .unwrap();
1705 }
1706
1707 #[test]
1708 fn column_header_prefix_is_three_spaces() {
1709 assert_eq!(COLUMN_HEADER_PREFIX, " ");
1710 assert_eq!(COLUMN_HEADER_PREFIX.len(), 3);
1711 }
1712
1713 #[test]
1714 fn col_gap_str_is_two_spaces() {
1715 assert_eq!(COL_GAP_STR, " ");
1716 assert_eq!(COL_GAP_STR.len(), 2);
1717 }
1718
1719 #[test]
1720 fn picker_arrow_renders_as_single_glyph() {
1721 assert_eq!(PICKER_ARROW.chars().count(), 1);
1726 assert!(!PICKER_ARROW.starts_with(char::is_whitespace));
1727 }
1728
1729 #[test]
1730 fn toggle_hint_renders_as_single_glyph() {
1731 assert_eq!(TOGGLE_HINT.chars().count(), 1);
1732 assert!(!TOGGLE_HINT.starts_with(char::is_whitespace));
1733 }
1734
1735 #[test]
1736 fn empty_line_has_indent_and_muted_style() {
1737 let line = empty_line("No results.");
1738 assert_eq!(line.spans.len(), 2);
1739 assert_eq!(line.spans[0].content.as_ref(), " ");
1740 assert_eq!(line.spans[1].content.as_ref(), "No results.");
1741 }
1742
1743 #[test]
1744 fn render_empty_loading_error_do_not_panic() {
1745 let backend = TestBackend::new(40, 3);
1746 let mut terminal = Terminal::new(backend).unwrap();
1747 terminal
1748 .draw(|frame| {
1749 let area = Rect::new(0, 0, 40, 1);
1750 render_empty(frame, area, "no hosts");
1751 render_loading(frame, area, "loading...");
1752 render_error(frame, area, "something broke");
1753 })
1754 .unwrap();
1755 }
1756
1757 #[test]
1758 fn footer_render_with_status_does_not_panic() {
1759 let (app, _dir) = make_app();
1760 let backend = TestBackend::new(60, 3);
1761 let mut terminal = Terminal::new(backend).unwrap();
1762 terminal
1763 .draw(|frame| {
1764 let area = Rect::new(0, 0, 60, 1);
1765 Footer::new()
1766 .primary("Enter", "save")
1767 .action("Esc", "cancel")
1768 .render_with_status(frame, area, &app);
1769 })
1770 .unwrap();
1771 }
1772
1773 fn footer_text(footer: Footer) -> String {
1774 footer
1775 .into_spans()
1776 .iter()
1777 .map(|s| s.content.as_ref())
1778 .collect()
1779 }
1780
1781 #[test]
1782 fn form_save_footer_collapsed_shows_more_options() {
1783 let text = footer_text(form_save_footer(FormFooterMode::Collapsed));
1784 assert!(text.contains("Enter"));
1785 assert!(text.contains("save"));
1786 assert!(text.contains("more options"));
1787 assert!(text.contains("Esc"));
1788 assert!(text.contains("cancel"));
1789 assert!(!text.contains("Space"));
1791 }
1792
1793 #[test]
1794 fn form_save_footer_expanded_text_omits_space_hint() {
1795 let text = footer_text(form_save_footer(FormFooterMode::Expanded(FieldKind::Text)));
1796 assert!(text.contains("Enter"));
1797 assert!(text.contains("save"));
1798 assert!(text.contains("Tab"));
1799 assert!(text.contains("Esc"));
1800 assert!(!text.contains("Space"));
1802 }
1803
1804 #[test]
1805 fn form_save_footer_expanded_toggle_shows_space_toggle() {
1806 let text = footer_text(form_save_footer(FormFooterMode::Expanded(
1807 FieldKind::Toggle,
1808 )));
1809 assert!(text.contains("Space"));
1810 assert!(text.contains("toggle"));
1811 assert!(!text.contains("pick"));
1813 }
1814
1815 #[test]
1816 fn form_save_footer_expanded_picker_shows_space_pick() {
1817 let text = footer_text(form_save_footer(FormFooterMode::Expanded(
1818 FieldKind::Picker,
1819 )));
1820 assert!(text.contains("Space"));
1821 assert!(text.contains("pick"));
1822 assert!(!text.contains("toggle"));
1824 }
1825
1826 #[test]
1827 fn confirm_footer_destructive_uses_action_verbs() {
1828 let text = footer_text(confirm_footer_destructive("delete", "keep"));
1829 assert!(text.contains("y"));
1830 assert!(text.contains("delete"));
1831 assert!(text.contains("n/Esc"));
1832 assert!(text.contains("keep"));
1833 assert!(!text.contains("yes"));
1835 assert!(!text.contains(" no"));
1836 }
1837
1838 #[test]
1839 fn confirm_footers_advertise_n_alongside_esc() {
1840 for footer_text_str in [
1843 footer_text(confirm_footer_destructive("delete", "keep")),
1844 footer_text(discard_footer()),
1845 ] {
1846 assert!(
1847 footer_text_str.contains("n/Esc"),
1848 "footer must show both n and Esc as cancel keys: {}",
1849 footer_text_str
1850 );
1851 }
1852 }
1853
1854 #[test]
1855 fn discard_footer_uses_discard_keep_verbs() {
1856 let text = footer_text(discard_footer());
1857 assert!(text.contains("discard"));
1858 assert!(text.contains("keep"));
1859 }
1860
1861 #[test]
1862 fn is_container_running_is_case_insensitive() {
1863 assert!(is_container_running("running"));
1864 assert!(is_container_running("Running"));
1865 assert!(is_container_running("RUNNING"));
1866 assert!(!is_container_running("exited"));
1867 assert!(!is_container_running("paused"));
1868 assert!(!is_container_running(""));
1869 }
1870
1871 #[test]
1872 fn parse_container_exit_code_extracts_docker_format() {
1873 assert_eq!(
1874 parse_container_exit_code("Exited (0) 2 minutes ago"),
1875 Some(0)
1876 );
1877 assert_eq!(
1878 parse_container_exit_code("Exited (137) just now"),
1879 Some(137)
1880 );
1881 assert_eq!(parse_container_exit_code("Up 5 minutes"), None);
1882 assert_eq!(parse_container_exit_code(""), None);
1883 assert_eq!(parse_container_exit_code("Exited (abc) bad"), None);
1884 }
1885
1886 #[test]
1887 fn container_state_style_running_uses_online_icon() {
1888 let (icon, _) = container_state_style("running", None, "", None, 0);
1889 assert_eq!(icon, ICON_ONLINE);
1890 }
1891
1892 #[test]
1893 fn container_state_style_dead_uses_error_icon() {
1894 let (icon, _) = container_state_style("dead", None, "", None, 0);
1895 assert_eq!(icon, ICON_ERROR);
1896 }
1897
1898 #[test]
1899 fn container_state_style_paused_uses_paused_icon() {
1900 let (icon, _) = container_state_style("paused", None, "", None, 0);
1901 assert_eq!(icon, ICON_PAUSED);
1902 let (icon, _) = container_state_style("restarting", None, "", None, 0);
1903 assert_eq!(icon, ICON_PAUSED);
1904 }
1905
1906 #[test]
1907 fn container_state_style_clean_exit_uses_stopped_icon() {
1908 let (icon, _) = container_state_style("exited", None, "Exited (0) ago", None, 0);
1909 assert_eq!(icon, ICON_STOPPED);
1910 let (icon, _) = container_state_style("exited", None, "", None, 0);
1912 assert_eq!(icon, ICON_STOPPED);
1913 }
1914
1915 #[test]
1916 fn container_state_style_nonzero_exit_uses_error_icon() {
1917 let (icon, _) = container_state_style("exited", None, "Exited (137) ago", None, 0);
1918 assert_eq!(icon, ICON_ERROR);
1919 let (icon, _) = container_state_style("stopped", None, "", Some(1), 0);
1921 assert_eq!(icon, ICON_ERROR);
1922 }
1923
1924 #[test]
1925 fn container_state_style_unknown_state_falls_back_to_stopped() {
1926 let (icon, _) = container_state_style("created", None, "", None, 0);
1927 assert_eq!(icon, ICON_STOPPED);
1928 let (icon, _) = container_state_style("removing", None, "", None, 0);
1929 assert_eq!(icon, ICON_STOPPED);
1930 }
1931
1932 #[test]
1933 fn container_state_style_running_with_unhealthy_uses_error_style() {
1934 let (_, style) = container_state_style("running", Some("unhealthy"), "", None, 0);
1935 assert!(style.fg.is_some());
1937 }
1938
1939 #[test]
1940 fn body_area_insets_block_border_plus_right_margin() {
1941 let block_area = Rect::new(10, 5, 40, 12);
1942 let body = body_area(block_area);
1943 assert_eq!(body.x, 11);
1945 assert_eq!(body.width, 40 - 2 - BODY_RIGHT_PAD);
1946 assert_eq!(body.y, 6);
1948 assert_eq!(body.height, 10);
1949 }
1950
1951 #[test]
1952 fn body_area_collapses_safely_in_tiny_blocks() {
1953 let body = body_area(Rect::new(0, 0, 1, 1));
1956 assert_eq!(body.width, 0);
1957 assert_eq!(body.height, 0);
1958 }
1959
1960 #[test]
1961 fn ellipsize_returns_text_unchanged_when_it_fits() {
1962 assert_eq!(ellipsize("hello", 10), "hello");
1963 assert_eq!(ellipsize("hello", 5), "hello");
1964 }
1965
1966 #[test]
1967 fn ellipsize_appends_single_glyph_when_text_overflows() {
1968 assert_eq!(ellipsize("hello world", 8), "hello w…");
1969 }
1970
1971 #[test]
1972 fn ellipsize_handles_extreme_widths() {
1973 assert_eq!(ellipsize("hello", 0), "");
1974 assert_eq!(ellipsize("hello", 1), "…");
1975 assert_eq!(ellipsize("", 5), "");
1976 }
1977
1978 #[test]
1979 fn wrap_indented_keeps_prefix_on_continuation_rows() {
1980 let text = "alpha beta gamma delta epsilon zeta eta theta iota kappa";
1981 let rows = wrap_indented(text, " ", 18);
1982 assert!(rows.len() > 1, "long text must wrap");
1983 for row in &rows {
1984 assert!(row.starts_with(" "), "every row keeps indent: {row:?}");
1985 assert!(row.len() <= 18 + 2, "row exceeds budget: {row:?}");
1986 }
1987 }
1988
1989 #[test]
1990 fn wrap_indented_hard_breaks_oversized_words() {
1991 let text = "ohabsurdlylongwordthatdoesnotfit ok";
1992 let rows = wrap_indented(text, " ", 10);
1993 assert!(rows.len() >= 2);
1994 for row in &rows {
1996 assert!(row.starts_with(" "));
1997 }
1998 }
1999
2000 #[test]
2001 fn wrap_indented_returns_empty_for_zero_inputs() {
2002 assert!(wrap_indented("", " ", 10).is_empty());
2003 assert!(wrap_indented("hi", " ", 0).is_empty());
2004 }
2005
2006 #[test]
2007 fn render_body_wrapped_preserves_hanging_indent_on_continuation() {
2008 let backend = TestBackend::new(20, 6);
2015 let mut terminal = Terminal::new(backend).unwrap();
2016 terminal
2017 .draw(|frame| {
2018 let area = Rect::new(0, 0, 20, 6);
2019 let block = Block::default().borders(Borders::ALL);
2020 let text = vec![
2021 Line::from(""),
2022 Line::from(Span::styled(
2023 " alpha beta gamma delta epsilon".to_string(),
2024 theme::muted(),
2025 )),
2026 ];
2027 render_body_wrapped(frame, area, block, text);
2028 })
2029 .unwrap();
2030 let buf = terminal.backend().buffer().clone();
2031 let mut content_rows: Vec<String> = Vec::new();
2033 for y in 1..(buf.area.height - 1) {
2034 let mut row = String::new();
2035 for x in 1..(buf.area.width - 1) {
2036 row.push_str(buf[(x, y)].symbol());
2037 }
2038 if !row.trim().is_empty() {
2039 content_rows.push(row);
2040 }
2041 }
2042 assert!(
2043 content_rows.len() >= 2,
2044 "the body must wrap to at least two rows: {content_rows:?}"
2045 );
2046 for row in &content_rows {
2047 assert!(
2048 row.starts_with(" "),
2049 "every wrapped row keeps the 2-space hanging indent: {row:?}"
2050 );
2051 }
2052 }
2053
2054 fn trailing_inner_row(buf: &ratatui::buffer::Buffer) -> Option<String> {
2058 let mut top_y: Option<u16> = None;
2059 let mut bottom_y: Option<u16> = None;
2060 for y in 0..buf.area.height {
2061 let mut row = String::new();
2062 for x in 0..buf.area.width {
2063 row.push_str(buf[(x, y)].symbol());
2064 }
2065 if top_y.is_none() && row.contains('\u{256D}') {
2066 top_y = Some(y);
2067 }
2068 if row.contains('\u{2570}') {
2069 bottom_y = Some(y);
2070 }
2071 }
2072 let (top, bottom) = (top_y?, bottom_y?);
2073 if bottom <= top + 1 {
2074 return None;
2075 }
2076 let trailing_y = bottom - 1;
2077 let mut left_border_x: Option<u16> = None;
2078 for x in 0..buf.area.width {
2079 if buf[(x, trailing_y)].symbol() == "\u{2502}" {
2080 left_border_x = Some(x);
2081 break;
2082 }
2083 }
2084 let left = left_border_x?;
2085 let mut row = String::new();
2086 for x in (left + 1)..buf.area.width {
2087 let sym = buf[(x, trailing_y)].symbol();
2088 if sym == "\u{2502}" {
2089 break;
2090 }
2091 row.push_str(sym);
2092 }
2093 Some(row)
2094 }
2095
2096 #[test]
2097 fn render_confirm_popup_keeps_trailing_blank_when_body_wraps() {
2098 let backend = TestBackend::new(70, 14);
2106 let mut terminal = Terminal::new(backend).unwrap();
2107 let (app, _dir) = make_app();
2108 terminal
2109 .draw(|frame| {
2110 render_destructive_popup(
2111 frame,
2112 "Remove provider?",
2113 "Remove the \"Linode\" config labelled \"default\"?",
2114 "Synced hosts stay in ~/.ssh/config. The integration is gone after save.",
2115 "remove",
2116 "keep",
2117 &app,
2118 );
2119 })
2120 .unwrap();
2121 let buf = terminal.backend().buffer().clone();
2122
2123 let mut top_y: Option<u16> = None;
2125 let mut bottom_y: Option<u16> = None;
2126 for y in 0..buf.area.height {
2127 let mut row = String::new();
2128 for x in 0..buf.area.width {
2129 row.push_str(buf[(x, y)].symbol());
2130 }
2131 if top_y.is_none() && row.contains('\u{256D}') {
2132 top_y = Some(y);
2133 }
2134 if row.contains('\u{2570}') {
2135 bottom_y = Some(y);
2136 }
2137 }
2138 let top = top_y.expect("popup must render a top border");
2139 let bottom = bottom_y.expect("popup must render a bottom border");
2140 assert!(bottom > top + 2, "popup must have at least one body row");
2141
2142 let trailing_y = bottom - 1;
2145 let mut left_border_x: Option<u16> = None;
2146 for x in 0..buf.area.width {
2147 if buf[(x, trailing_y)].symbol() == "\u{2502}" {
2148 left_border_x = Some(x);
2149 break;
2150 }
2151 }
2152 let left = left_border_x.expect("trailing row must have a left side border");
2153 let mut trailing = String::new();
2154 for x in (left + 1)..buf.area.width {
2155 let sym = buf[(x, trailing_y)].symbol();
2156 if sym == "\u{2502}" {
2157 break;
2158 }
2159 trailing.push_str(sym);
2160 }
2161 assert!(
2162 trailing.chars().all(|c| c == ' '),
2163 "trailing inner row above bottom border must be blank, got {trailing:?}"
2164 );
2165 }
2166
2167 #[test]
2168 fn render_confirm_popup_keeps_trailing_blank_when_body_fits_on_one_row() {
2169 let backend = TestBackend::new(60, 12);
2174 let mut terminal = Terminal::new(backend).unwrap();
2175 let (app, _dir) = make_app();
2176 terminal
2177 .draw(|frame| {
2178 render_destructive_popup(
2179 frame,
2180 "Confirm Delete",
2181 "Delete \"foo\"?",
2182 "",
2183 "delete",
2184 "keep",
2185 &app,
2186 );
2187 })
2188 .unwrap();
2189 let buf = terminal.backend().buffer().clone();
2190 let trailing = trailing_inner_row(&buf).expect("popup must have a trailing row");
2191 assert!(
2192 trailing.chars().all(|c| c == ' '),
2193 "trailing inner row above bottom border must be blank, got {trailing:?}"
2194 );
2195 }
2196
2197 #[test]
2198 fn render_confirm_popup_neutral_kind_keeps_trailing_blank() {
2199 let backend = TestBackend::new(60, 12);
2203 let mut terminal = Terminal::new(backend).unwrap();
2204 let (app, _dir) = make_app();
2205 terminal
2206 .draw(|frame| {
2207 let content = vec![Line::from(Span::styled(
2208 " Import 12 hosts from known_hosts?".to_string(),
2209 theme::bold(),
2210 ))];
2211 let footer_spans = confirm_footer_destructive("import", "skip").to_line().spans;
2212 render_confirm_popup(
2213 frame,
2214 52,
2215 PopupKind::Neutral,
2216 "Import",
2217 content,
2218 footer_spans,
2219 &app,
2220 );
2221 })
2222 .unwrap();
2223 let buf = terminal.backend().buffer().clone();
2224 let trailing = trailing_inner_row(&buf).expect("popup must have a trailing row");
2225 assert!(
2226 trailing.chars().all(|c| c == ' '),
2227 "neutral popup trailing row must be blank, got {trailing:?}"
2228 );
2229 }
2230
2231 #[test]
2232 fn wrap_block_lines_preserves_hanging_indent_on_multi_span_pattern() {
2233 let input = vec![Line::from(vec![
2239 Span::raw(" "),
2240 Span::styled(
2241 "Sends SIGTERM, waits 10s, then SIGKILL. Live connections will drop.".to_string(),
2242 theme::muted(),
2243 ),
2244 ])];
2245 let out = wrap_block_lines(input, 32);
2246 assert!(
2247 out.len() >= 2,
2248 "long body must wrap, got {} rows",
2249 out.len()
2250 );
2251 for line in &out {
2252 let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
2253 assert!(
2254 rendered.starts_with(" "),
2255 "every wrapped row keeps the 2-space hanging indent: {rendered:?}"
2256 );
2257 }
2258 }
2259
2260 #[test]
2261 fn wrap_block_lines_bypasses_aligned_lines_verbatim() {
2262 use ratatui::layout::Alignment;
2268 let aligned = Line::from(Span::styled(
2269 "Your SSH config, supercharged.".to_string(),
2270 theme::muted(),
2271 ))
2272 .alignment(Alignment::Center);
2273 let out = wrap_block_lines(vec![aligned], 60);
2274 assert_eq!(out.len(), 1, "aligned line stays a single row");
2275 assert_eq!(out[0].alignment, Some(Alignment::Center));
2276 let rendered: String = out[0].spans.iter().map(|s| s.content.as_ref()).collect();
2277 assert_eq!(rendered, "Your SSH config, supercharged.");
2278 }
2279
2280 #[test]
2281 fn render_body_wrapped_passes_blank_lines_through_unchanged() {
2282 let backend = TestBackend::new(20, 6);
2285 let mut terminal = Terminal::new(backend).unwrap();
2286 terminal
2287 .draw(|frame| {
2288 let area = Rect::new(0, 0, 20, 6);
2289 let block = Block::default().borders(Borders::ALL);
2290 let text = vec![
2291 Line::from(""),
2292 Line::from(Span::styled(" hello".to_string(), theme::bold())),
2293 Line::from(""),
2294 Line::from(Span::styled(" world".to_string(), theme::muted())),
2295 ];
2296 render_body_wrapped(frame, area, block, text);
2297 })
2298 .unwrap();
2299 let buf = terminal.backend().buffer().clone();
2300 let row = |y: u16| -> String {
2301 let mut s = String::new();
2302 for x in 1..(buf.area.width - 1) {
2303 s.push_str(buf[(x, y)].symbol());
2304 }
2305 s
2306 };
2307 assert!(row(1).trim().is_empty(), "row 1 stays blank");
2308 assert!(row(2).contains("hello"), "row 2 holds question");
2309 assert!(row(3).trim().is_empty(), "row 3 stays blank");
2310 assert!(row(4).contains("world"), "row 4 holds detail");
2311 }
2312
2313 #[test]
2314 fn tab_empty_falls_back_to_single_line_on_narrow_areas() {
2315 let backend = ratatui::backend::TestBackend::new(40, 6);
2319 let mut terminal = ratatui::Terminal::new(backend).unwrap();
2320 terminal
2321 .draw(|frame| {
2322 let e = TabEmpty {
2323 card_title: "X",
2324 headline: "Cache is empty.",
2325 explainer: "Nothing yet.",
2326 hints: &[("R", "refresh")],
2327 };
2328 render_tab_empty(frame, Rect::new(0, 0, 40, 6), &e);
2329 })
2330 .unwrap();
2331 }
2332
2333 #[test]
2334 fn tab_empty_card_renders_on_wide_areas() {
2335 let backend = ratatui::backend::TestBackend::new(100, 20);
2336 let mut terminal = ratatui::Terminal::new(backend).unwrap();
2337 terminal
2338 .draw(|frame| {
2339 let e = TabEmpty {
2340 card_title: "Containers",
2341 headline: "No containers cached yet.",
2342 explainer: "Containers are fetched per host on demand and cached locally.",
2343 hints: &[("Enter", "open a shell"), ("R", "refresh hosts")],
2344 };
2345 render_tab_empty(frame, Rect::new(0, 0, 100, 20), &e);
2346 })
2347 .unwrap();
2348 }
2349
2350 #[test]
2351 fn ellipsize_respects_unicode_display_width() {
2352 let s = "東京京都大阪";
2354 let out = ellipsize(s, 9);
2356 assert!(out.ends_with('…'));
2357 let inner = &out[..out.len() - '…'.len_utf8()];
2358 assert!(unicode_width::UnicodeWidthStr::width(inner) <= 8);
2359 }
2360}