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_notitle(lines: &mut Vec<Line<'static>>, width: usize) {
1306 let fill = width.saturating_sub(2);
1307 lines.push(Line::from(vec![
1308 Span::styled(BOX_TL, theme::border()),
1309 Span::styled(BOX_H.repeat(fill), theme::border()),
1310 Span::styled(BOX_TR, theme::border()),
1311 ]));
1312}
1313
1314pub fn section_line(lines: &mut Vec<Line<'static>>, spans: Vec<Span<'static>>, width: usize) {
1316 use unicode_width::UnicodeWidthStr;
1317 let mut full_spans: Vec<Span<'static>> =
1318 vec![Span::styled(format!("{} ", BOX_V), theme::border())];
1319 let content_width: usize = full_spans.iter().map(|s| s.content.width()).sum::<usize>()
1320 + spans.iter().map(|s| s.content.width()).sum::<usize>();
1321 full_spans.extend(spans);
1322 let closing_offset = 1;
1323 let padding = width
1324 .saturating_sub(content_width)
1325 .saturating_sub(closing_offset);
1326 if padding > 0 {
1327 full_spans.push(Span::raw(" ".repeat(padding)));
1328 }
1329 full_spans.push(Span::styled(BOX_V, theme::border()));
1330 lines.push(Line::from(full_spans));
1331}
1332
1333pub fn section_close(lines: &mut Vec<Line<'static>>, width: usize) {
1335 let fill = width.saturating_sub(2);
1336 lines.push(Line::from(vec![
1337 Span::styled(BOX_BL, theme::border()),
1338 Span::styled(BOX_H.repeat(fill), theme::border()),
1339 Span::styled(BOX_BR, theme::border()),
1340 ]));
1341}
1342
1343pub fn section_empty_line(width: usize) -> Line<'static> {
1347 let fill = width.saturating_sub(2);
1348 Line::from(vec![
1349 Span::styled(BOX_V, theme::border()),
1350 Span::raw(" ".repeat(fill)),
1351 Span::styled(BOX_V, theme::border()),
1352 ])
1353}
1354
1355pub fn stretch_last_card(lines: &mut Vec<Line<'static>>, available_rows: usize, box_width: usize) {
1360 if lines.len() >= available_rows {
1361 return;
1362 }
1363 let extra = available_rows - lines.len();
1364 let last_close = lines.iter().rposition(|line| {
1365 line.spans
1366 .first()
1367 .map(|s| s.content.starts_with(BOX_BL))
1368 .unwrap_or(false)
1369 });
1370 let Some(idx) = last_close else {
1371 return;
1372 };
1373 for _ in 0..extra {
1374 lines.insert(idx, section_empty_line(box_width));
1375 }
1376}
1377
1378pub fn section_field(
1381 lines: &mut Vec<Line<'static>>,
1382 label: &str,
1383 value: &str,
1384 max_value_width: usize,
1385 box_width: usize,
1386) {
1387 use unicode_width::UnicodeWidthStr;
1388 let display = if max_value_width > 0 && value.width() > max_value_width {
1389 super::truncate(value, max_value_width)
1390 } else {
1391 value.to_string()
1392 };
1393 let spans = vec![
1394 Span::styled(
1395 format!("{:<width$}", label, width = SECTION_LABEL_W as usize),
1396 theme::muted(),
1397 ),
1398 Span::styled(display, theme::bold()),
1399 ];
1400 section_line(lines, spans, box_width);
1401}
1402
1403pub fn section_field_styled(
1406 lines: &mut Vec<Line<'static>>,
1407 label: &str,
1408 value: &str,
1409 value_style: ratatui::style::Style,
1410 max_value_width: usize,
1411 box_width: usize,
1412) {
1413 use unicode_width::UnicodeWidthStr;
1414 let display = if max_value_width > 0 && value.width() > max_value_width {
1415 super::truncate(value, max_value_width)
1416 } else {
1417 value.to_string()
1418 };
1419 let spans = vec![
1420 Span::styled(
1421 format!("{:<width$}", label, width = SECTION_LABEL_W as usize),
1422 theme::muted(),
1423 ),
1424 Span::styled(display, value_style),
1425 ];
1426 section_line(lines, spans, box_width);
1427}
1428
1429#[cfg(test)]
1434mod tests {
1435 use super::*;
1436 use ratatui::Terminal;
1437 use ratatui::backend::TestBackend;
1438 use ratatui::buffer::Buffer;
1439 use ratatui::widgets::Widget;
1440
1441 fn make_app() -> (App, tempfile::TempDir) {
1442 let dir = tempfile::tempdir().unwrap();
1443 let config = crate::ssh_config::model::SshConfigFile {
1444 elements: crate::ssh_config::model::SshConfigFile::parse_content(""),
1445 path: dir.path().join("test_design"),
1446 crlf: false,
1447 bom: false,
1448 };
1449 (App::new(config), dir)
1450 }
1451
1452 fn buffer_contains(buf: &Buffer, needle: &str) -> bool {
1453 for y in 0..buf.area.height {
1454 let mut row = String::new();
1455 for x in 0..buf.area.width {
1456 row.push_str(buf[(x, y)].symbol());
1457 }
1458 if row.contains(needle) {
1459 return true;
1460 }
1461 }
1462 false
1463 }
1464
1465 fn render_block_title(block: Block<'static>, title: &str) -> bool {
1466 let area = Rect::new(0, 0, 30, 5);
1467 let mut buf = Buffer::empty(area);
1468 block.render(area, &mut buf);
1469 buffer_contains(&buf, title)
1470 }
1471
1472 #[test]
1473 fn overlay_block_title_is_padded() {
1474 assert!(render_block_title(overlay_block("Hello"), " Hello "));
1475 }
1476
1477 #[test]
1478 fn danger_block_title_is_padded() {
1479 assert!(render_block_title(danger_block("Delete"), " Delete "));
1480 }
1481
1482 #[test]
1483 fn overlay_area_stays_within_frame() {
1484 let backend = TestBackend::new(100, 40);
1485 let mut terminal = Terminal::new(backend).unwrap();
1486 terminal
1487 .draw(|frame| {
1488 let rect = overlay_area(frame, 70, 80, 20);
1489 let area = frame.area();
1490 assert!(rect.x >= area.x);
1491 assert!(rect.y >= area.y);
1492 assert!(rect.x + rect.width <= area.x + area.width);
1493 assert!(rect.y + rect.height <= area.y + area.height);
1494 assert!(rect.height <= 20);
1495 })
1496 .unwrap();
1497 }
1498
1499 #[test]
1500 fn form_footer_sits_directly_below_block() {
1501 let block_area = Rect::new(5, 2, 30, 8);
1502 let rect = form_footer(block_area, 8);
1503 assert_eq!(rect.x, 5);
1504 assert_eq!(rect.y, 10);
1505 assert_eq!(rect.width, 30);
1506 assert_eq!(rect.height, 1);
1507 }
1508
1509 #[test]
1510 fn form_divider_y_steps_by_two() {
1511 let inner = Rect::new(2, 3, 20, 10);
1512 assert_eq!(form_divider_y(inner, 0), 3);
1513 assert_eq!(form_divider_y(inner, 1), 5);
1514 assert_eq!(form_divider_y(inner, 2), 7);
1515 }
1516
1517 #[test]
1518 fn footer_builder_inserts_gaps_between_entries_only() {
1519 let spans = Footer::new()
1520 .primary("Enter", "save")
1521 .action("Esc", "cancel")
1522 .action("Tab", "next")
1523 .into_spans();
1524 assert_eq!(spans.len(), 8);
1526 assert_eq!(spans[2].content, FOOTER_GAP);
1527 assert_eq!(spans[5].content, FOOTER_GAP);
1528 }
1529
1530 #[test]
1531 fn empty_footer_has_no_spans() {
1532 assert!(Footer::new().into_spans().is_empty());
1533 }
1534
1535 #[test]
1536 fn footer_to_line_preserves_span_count() {
1537 let footer = Footer::new()
1538 .primary("Enter", "save")
1539 .action("Esc", "cancel");
1540 let spans_len = {
1541 let clone = Footer::new()
1542 .primary("Enter", "save")
1543 .action("Esc", "cancel");
1544 clone.into_spans().len()
1545 };
1546 let line = footer.to_line();
1547 assert_eq!(line.spans.len(), spans_len);
1548 }
1549
1550 #[test]
1551 fn picker_width_is_clamped() {
1552 let backend = TestBackend::new(100, 40);
1553 let mut terminal = Terminal::new(backend).unwrap();
1554 terminal
1555 .draw(|frame| {
1556 let w = picker_width(frame);
1557 assert!(w >= PICKER_MIN_W);
1558 assert!(w <= PICKER_MAX_W);
1559 })
1560 .unwrap();
1561 }
1562
1563 #[test]
1564 fn picker_width_clamps_narrow_terminal_to_min() {
1565 let backend = TestBackend::new(30, 20);
1566 let mut terminal = Terminal::new(backend).unwrap();
1567 terminal
1568 .draw(|frame| {
1569 assert_eq!(picker_width(frame), PICKER_MIN_W);
1570 })
1571 .unwrap();
1572 }
1573
1574 #[test]
1575 fn picker_width_clamps_wide_terminal_to_max() {
1576 let backend = TestBackend::new(200, 20);
1577 let mut terminal = Terminal::new(backend).unwrap();
1578 terminal
1579 .draw(|frame| {
1580 assert_eq!(picker_width(frame), PICKER_MAX_W);
1581 })
1582 .unwrap();
1583 }
1584
1585 #[test]
1586 fn picker_width_passes_midrange_through() {
1587 let backend = TestBackend::new(66, 20);
1589 let mut terminal = Terminal::new(backend).unwrap();
1590 terminal
1591 .draw(|frame| {
1592 assert_eq!(picker_width(frame), 66);
1593 })
1594 .unwrap();
1595 }
1596
1597 #[test]
1598 fn plain_overlay_block_has_no_title() {
1599 let area = Rect::new(0, 0, 20, 3);
1603 let mut buf = Buffer::empty(area);
1604 plain_overlay_block().render(area, &mut buf);
1605 let mut top = String::new();
1606 for x in 0..area.width {
1607 top.push_str(buf[(x, 0)].symbol());
1608 }
1609 assert!(top.starts_with('\u{256D}'));
1610 assert!(top.ends_with('\u{256E}'));
1611 for ch in top.chars().skip(1).take((area.width as usize) - 2) {
1613 assert_eq!(ch, '\u{2500}');
1614 }
1615 }
1616
1617 #[test]
1618 fn section_divider_contains_dashes() {
1619 let line = section_divider();
1620 let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
1621 assert!(
1622 text.contains("────"),
1623 "section divider should contain dash characters"
1624 );
1625 }
1626
1627 #[test]
1628 fn padded_usize_matches_expected_values() {
1629 assert_eq!(padded_usize(0), 0);
1630 assert_eq!(padded_usize(10), 12);
1631 assert_eq!(padded_usize(20), 23);
1632 }
1633
1634 #[test]
1635 fn kv_line_format_has_two_spans() {
1636 let line = kv_line("Label", "Value", KV_LABEL_WIDE);
1637 assert_eq!(line.spans.len(), 2);
1638 let label_text = &line.spans[0].content;
1639 assert!(
1640 label_text.starts_with(" "),
1641 "label should be 2-space indented"
1642 );
1643 assert!(label_text.contains("Label"));
1644 assert_eq!(line.spans[1].content.as_ref(), "Value");
1645 }
1646
1647 #[test]
1648 fn kv_line_label_is_padded_to_width() {
1649 let line = kv_line("X", "Y", 22);
1650 let label = &line.spans[0].content;
1651 assert_eq!(label.len(), 24);
1653 }
1654
1655 #[test]
1656 fn content_section_returns_header_and_divider() {
1657 let [header, divider] = content_section("Directives");
1658 let h_text: String = header.spans.iter().map(|s| s.content.as_ref()).collect();
1659 assert!(h_text.contains("Directives"));
1660 let d_text: String = divider.spans.iter().map(|s| s.content.as_ref()).collect();
1661 assert!(d_text.contains("────"));
1662 }
1663
1664 #[test]
1665 fn render_empty_with_hint_does_not_panic() {
1666 let backend = TestBackend::new(60, 3);
1667 let mut terminal = Terminal::new(backend).unwrap();
1668 terminal
1669 .draw(|frame| {
1670 let area = Rect::new(0, 0, 60, 1);
1671 render_empty_with_hint(frame, area, "No tags yet.", "+", "add");
1672 })
1673 .unwrap();
1674 }
1675
1676 #[test]
1677 fn column_header_prefix_is_three_spaces() {
1678 assert_eq!(COLUMN_HEADER_PREFIX, " ");
1679 assert_eq!(COLUMN_HEADER_PREFIX.len(), 3);
1680 }
1681
1682 #[test]
1683 fn col_gap_str_is_two_spaces() {
1684 assert_eq!(COL_GAP_STR, " ");
1685 assert_eq!(COL_GAP_STR.len(), 2);
1686 }
1687
1688 #[test]
1689 fn picker_arrow_renders_as_single_glyph() {
1690 assert_eq!(PICKER_ARROW.chars().count(), 1);
1695 assert!(!PICKER_ARROW.starts_with(char::is_whitespace));
1696 }
1697
1698 #[test]
1699 fn toggle_hint_renders_as_single_glyph() {
1700 assert_eq!(TOGGLE_HINT.chars().count(), 1);
1701 assert!(!TOGGLE_HINT.starts_with(char::is_whitespace));
1702 }
1703
1704 #[test]
1705 fn empty_line_has_indent_and_muted_style() {
1706 let line = empty_line("No results.");
1707 assert_eq!(line.spans.len(), 2);
1708 assert_eq!(line.spans[0].content.as_ref(), " ");
1709 assert_eq!(line.spans[1].content.as_ref(), "No results.");
1710 }
1711
1712 #[test]
1713 fn render_empty_loading_error_do_not_panic() {
1714 let backend = TestBackend::new(40, 3);
1715 let mut terminal = Terminal::new(backend).unwrap();
1716 terminal
1717 .draw(|frame| {
1718 let area = Rect::new(0, 0, 40, 1);
1719 render_empty(frame, area, "no hosts");
1720 render_loading(frame, area, "loading...");
1721 render_error(frame, area, "something broke");
1722 })
1723 .unwrap();
1724 }
1725
1726 #[test]
1727 fn footer_render_with_status_does_not_panic() {
1728 let (app, _dir) = make_app();
1729 let backend = TestBackend::new(60, 3);
1730 let mut terminal = Terminal::new(backend).unwrap();
1731 terminal
1732 .draw(|frame| {
1733 let area = Rect::new(0, 0, 60, 1);
1734 Footer::new()
1735 .primary("Enter", "save")
1736 .action("Esc", "cancel")
1737 .render_with_status(frame, area, &app);
1738 })
1739 .unwrap();
1740 }
1741
1742 fn footer_text(footer: Footer) -> String {
1743 footer
1744 .into_spans()
1745 .iter()
1746 .map(|s| s.content.as_ref())
1747 .collect()
1748 }
1749
1750 #[test]
1751 fn form_save_footer_collapsed_shows_more_options() {
1752 let text = footer_text(form_save_footer(FormFooterMode::Collapsed));
1753 assert!(text.contains("Enter"));
1754 assert!(text.contains("save"));
1755 assert!(text.contains("more options"));
1756 assert!(text.contains("Esc"));
1757 assert!(text.contains("cancel"));
1758 assert!(!text.contains("Space"));
1760 }
1761
1762 #[test]
1763 fn form_save_footer_expanded_text_omits_space_hint() {
1764 let text = footer_text(form_save_footer(FormFooterMode::Expanded(FieldKind::Text)));
1765 assert!(text.contains("Enter"));
1766 assert!(text.contains("save"));
1767 assert!(text.contains("Tab"));
1768 assert!(text.contains("Esc"));
1769 assert!(!text.contains("Space"));
1771 }
1772
1773 #[test]
1774 fn form_save_footer_expanded_toggle_shows_space_toggle() {
1775 let text = footer_text(form_save_footer(FormFooterMode::Expanded(
1776 FieldKind::Toggle,
1777 )));
1778 assert!(text.contains("Space"));
1779 assert!(text.contains("toggle"));
1780 assert!(!text.contains("pick"));
1782 }
1783
1784 #[test]
1785 fn form_save_footer_expanded_picker_shows_space_pick() {
1786 let text = footer_text(form_save_footer(FormFooterMode::Expanded(
1787 FieldKind::Picker,
1788 )));
1789 assert!(text.contains("Space"));
1790 assert!(text.contains("pick"));
1791 assert!(!text.contains("toggle"));
1793 }
1794
1795 #[test]
1796 fn confirm_footer_destructive_uses_action_verbs() {
1797 let text = footer_text(confirm_footer_destructive("delete", "keep"));
1798 assert!(text.contains("y"));
1799 assert!(text.contains("delete"));
1800 assert!(text.contains("n/Esc"));
1801 assert!(text.contains("keep"));
1802 assert!(!text.contains("yes"));
1804 assert!(!text.contains(" no"));
1805 }
1806
1807 #[test]
1808 fn confirm_footers_advertise_n_alongside_esc() {
1809 for footer_text_str in [
1812 footer_text(confirm_footer_destructive("delete", "keep")),
1813 footer_text(discard_footer()),
1814 ] {
1815 assert!(
1816 footer_text_str.contains("n/Esc"),
1817 "footer must show both n and Esc as cancel keys: {}",
1818 footer_text_str
1819 );
1820 }
1821 }
1822
1823 #[test]
1824 fn discard_footer_uses_discard_keep_verbs() {
1825 let text = footer_text(discard_footer());
1826 assert!(text.contains("discard"));
1827 assert!(text.contains("keep"));
1828 }
1829
1830 #[test]
1831 fn is_container_running_is_case_insensitive() {
1832 assert!(is_container_running("running"));
1833 assert!(is_container_running("Running"));
1834 assert!(is_container_running("RUNNING"));
1835 assert!(!is_container_running("exited"));
1836 assert!(!is_container_running("paused"));
1837 assert!(!is_container_running(""));
1838 }
1839
1840 #[test]
1841 fn parse_container_exit_code_extracts_docker_format() {
1842 assert_eq!(
1843 parse_container_exit_code("Exited (0) 2 minutes ago"),
1844 Some(0)
1845 );
1846 assert_eq!(
1847 parse_container_exit_code("Exited (137) just now"),
1848 Some(137)
1849 );
1850 assert_eq!(parse_container_exit_code("Up 5 minutes"), None);
1851 assert_eq!(parse_container_exit_code(""), None);
1852 assert_eq!(parse_container_exit_code("Exited (abc) bad"), None);
1853 }
1854
1855 #[test]
1856 fn container_state_style_running_uses_online_icon() {
1857 let (icon, _) = container_state_style("running", None, "", None, 0);
1858 assert_eq!(icon, ICON_ONLINE);
1859 }
1860
1861 #[test]
1862 fn container_state_style_dead_uses_error_icon() {
1863 let (icon, _) = container_state_style("dead", None, "", None, 0);
1864 assert_eq!(icon, ICON_ERROR);
1865 }
1866
1867 #[test]
1868 fn container_state_style_paused_uses_paused_icon() {
1869 let (icon, _) = container_state_style("paused", None, "", None, 0);
1870 assert_eq!(icon, ICON_PAUSED);
1871 let (icon, _) = container_state_style("restarting", None, "", None, 0);
1872 assert_eq!(icon, ICON_PAUSED);
1873 }
1874
1875 #[test]
1876 fn container_state_style_clean_exit_uses_stopped_icon() {
1877 let (icon, _) = container_state_style("exited", None, "Exited (0) ago", None, 0);
1878 assert_eq!(icon, ICON_STOPPED);
1879 let (icon, _) = container_state_style("exited", None, "", None, 0);
1881 assert_eq!(icon, ICON_STOPPED);
1882 }
1883
1884 #[test]
1885 fn container_state_style_nonzero_exit_uses_error_icon() {
1886 let (icon, _) = container_state_style("exited", None, "Exited (137) ago", None, 0);
1887 assert_eq!(icon, ICON_ERROR);
1888 let (icon, _) = container_state_style("stopped", None, "", Some(1), 0);
1890 assert_eq!(icon, ICON_ERROR);
1891 }
1892
1893 #[test]
1894 fn container_state_style_unknown_state_falls_back_to_stopped() {
1895 let (icon, _) = container_state_style("created", None, "", None, 0);
1896 assert_eq!(icon, ICON_STOPPED);
1897 let (icon, _) = container_state_style("removing", None, "", None, 0);
1898 assert_eq!(icon, ICON_STOPPED);
1899 }
1900
1901 #[test]
1902 fn container_state_style_running_with_unhealthy_uses_error_style() {
1903 let (_, style) = container_state_style("running", Some("unhealthy"), "", None, 0);
1904 assert!(style.fg.is_some());
1906 }
1907
1908 #[test]
1909 fn body_area_insets_block_border_plus_right_margin() {
1910 let block_area = Rect::new(10, 5, 40, 12);
1911 let body = body_area(block_area);
1912 assert_eq!(body.x, 11);
1914 assert_eq!(body.width, 40 - 2 - BODY_RIGHT_PAD);
1915 assert_eq!(body.y, 6);
1917 assert_eq!(body.height, 10);
1918 }
1919
1920 #[test]
1921 fn body_area_collapses_safely_in_tiny_blocks() {
1922 let body = body_area(Rect::new(0, 0, 1, 1));
1925 assert_eq!(body.width, 0);
1926 assert_eq!(body.height, 0);
1927 }
1928
1929 #[test]
1930 fn ellipsize_returns_text_unchanged_when_it_fits() {
1931 assert_eq!(ellipsize("hello", 10), "hello");
1932 assert_eq!(ellipsize("hello", 5), "hello");
1933 }
1934
1935 #[test]
1936 fn ellipsize_appends_single_glyph_when_text_overflows() {
1937 assert_eq!(ellipsize("hello world", 8), "hello w…");
1938 }
1939
1940 #[test]
1941 fn ellipsize_handles_extreme_widths() {
1942 assert_eq!(ellipsize("hello", 0), "");
1943 assert_eq!(ellipsize("hello", 1), "…");
1944 assert_eq!(ellipsize("", 5), "");
1945 }
1946
1947 #[test]
1948 fn wrap_indented_keeps_prefix_on_continuation_rows() {
1949 let text = "alpha beta gamma delta epsilon zeta eta theta iota kappa";
1950 let rows = wrap_indented(text, " ", 18);
1951 assert!(rows.len() > 1, "long text must wrap");
1952 for row in &rows {
1953 assert!(row.starts_with(" "), "every row keeps indent: {row:?}");
1954 assert!(row.len() <= 18 + 2, "row exceeds budget: {row:?}");
1955 }
1956 }
1957
1958 #[test]
1959 fn wrap_indented_hard_breaks_oversized_words() {
1960 let text = "ohabsurdlylongwordthatdoesnotfit ok";
1961 let rows = wrap_indented(text, " ", 10);
1962 assert!(rows.len() >= 2);
1963 for row in &rows {
1965 assert!(row.starts_with(" "));
1966 }
1967 }
1968
1969 #[test]
1970 fn wrap_indented_returns_empty_for_zero_inputs() {
1971 assert!(wrap_indented("", " ", 10).is_empty());
1972 assert!(wrap_indented("hi", " ", 0).is_empty());
1973 }
1974
1975 #[test]
1976 fn render_body_wrapped_preserves_hanging_indent_on_continuation() {
1977 let backend = TestBackend::new(20, 6);
1984 let mut terminal = Terminal::new(backend).unwrap();
1985 terminal
1986 .draw(|frame| {
1987 let area = Rect::new(0, 0, 20, 6);
1988 let block = Block::default().borders(Borders::ALL);
1989 let text = vec![
1990 Line::from(""),
1991 Line::from(Span::styled(
1992 " alpha beta gamma delta epsilon".to_string(),
1993 theme::muted(),
1994 )),
1995 ];
1996 render_body_wrapped(frame, area, block, text);
1997 })
1998 .unwrap();
1999 let buf = terminal.backend().buffer().clone();
2000 let mut content_rows: Vec<String> = Vec::new();
2002 for y in 1..(buf.area.height - 1) {
2003 let mut row = String::new();
2004 for x in 1..(buf.area.width - 1) {
2005 row.push_str(buf[(x, y)].symbol());
2006 }
2007 if !row.trim().is_empty() {
2008 content_rows.push(row);
2009 }
2010 }
2011 assert!(
2012 content_rows.len() >= 2,
2013 "the body must wrap to at least two rows: {content_rows:?}"
2014 );
2015 for row in &content_rows {
2016 assert!(
2017 row.starts_with(" "),
2018 "every wrapped row keeps the 2-space hanging indent: {row:?}"
2019 );
2020 }
2021 }
2022
2023 fn trailing_inner_row(buf: &ratatui::buffer::Buffer) -> Option<String> {
2027 let mut top_y: Option<u16> = None;
2028 let mut bottom_y: Option<u16> = None;
2029 for y in 0..buf.area.height {
2030 let mut row = String::new();
2031 for x in 0..buf.area.width {
2032 row.push_str(buf[(x, y)].symbol());
2033 }
2034 if top_y.is_none() && row.contains('\u{256D}') {
2035 top_y = Some(y);
2036 }
2037 if row.contains('\u{2570}') {
2038 bottom_y = Some(y);
2039 }
2040 }
2041 let (top, bottom) = (top_y?, bottom_y?);
2042 if bottom <= top + 1 {
2043 return None;
2044 }
2045 let trailing_y = bottom - 1;
2046 let mut left_border_x: Option<u16> = None;
2047 for x in 0..buf.area.width {
2048 if buf[(x, trailing_y)].symbol() == "\u{2502}" {
2049 left_border_x = Some(x);
2050 break;
2051 }
2052 }
2053 let left = left_border_x?;
2054 let mut row = String::new();
2055 for x in (left + 1)..buf.area.width {
2056 let sym = buf[(x, trailing_y)].symbol();
2057 if sym == "\u{2502}" {
2058 break;
2059 }
2060 row.push_str(sym);
2061 }
2062 Some(row)
2063 }
2064
2065 #[test]
2066 fn render_confirm_popup_keeps_trailing_blank_when_body_wraps() {
2067 let backend = TestBackend::new(70, 14);
2075 let mut terminal = Terminal::new(backend).unwrap();
2076 let (app, _dir) = make_app();
2077 terminal
2078 .draw(|frame| {
2079 render_destructive_popup(
2080 frame,
2081 "Remove provider?",
2082 "Remove the \"Linode\" config labelled \"default\"?",
2083 "Synced hosts stay in ~/.ssh/config. The integration is gone after save.",
2084 "remove",
2085 "keep",
2086 &app,
2087 );
2088 })
2089 .unwrap();
2090 let buf = terminal.backend().buffer().clone();
2091
2092 let mut top_y: Option<u16> = None;
2094 let mut bottom_y: Option<u16> = None;
2095 for y in 0..buf.area.height {
2096 let mut row = String::new();
2097 for x in 0..buf.area.width {
2098 row.push_str(buf[(x, y)].symbol());
2099 }
2100 if top_y.is_none() && row.contains('\u{256D}') {
2101 top_y = Some(y);
2102 }
2103 if row.contains('\u{2570}') {
2104 bottom_y = Some(y);
2105 }
2106 }
2107 let top = top_y.expect("popup must render a top border");
2108 let bottom = bottom_y.expect("popup must render a bottom border");
2109 assert!(bottom > top + 2, "popup must have at least one body row");
2110
2111 let trailing_y = bottom - 1;
2114 let mut left_border_x: Option<u16> = None;
2115 for x in 0..buf.area.width {
2116 if buf[(x, trailing_y)].symbol() == "\u{2502}" {
2117 left_border_x = Some(x);
2118 break;
2119 }
2120 }
2121 let left = left_border_x.expect("trailing row must have a left side border");
2122 let mut trailing = String::new();
2123 for x in (left + 1)..buf.area.width {
2124 let sym = buf[(x, trailing_y)].symbol();
2125 if sym == "\u{2502}" {
2126 break;
2127 }
2128 trailing.push_str(sym);
2129 }
2130 assert!(
2131 trailing.chars().all(|c| c == ' '),
2132 "trailing inner row above bottom border must be blank, got {trailing:?}"
2133 );
2134 }
2135
2136 #[test]
2137 fn render_confirm_popup_keeps_trailing_blank_when_body_fits_on_one_row() {
2138 let backend = TestBackend::new(60, 12);
2143 let mut terminal = Terminal::new(backend).unwrap();
2144 let (app, _dir) = make_app();
2145 terminal
2146 .draw(|frame| {
2147 render_destructive_popup(
2148 frame,
2149 "Confirm Delete",
2150 "Delete \"foo\"?",
2151 "",
2152 "delete",
2153 "keep",
2154 &app,
2155 );
2156 })
2157 .unwrap();
2158 let buf = terminal.backend().buffer().clone();
2159 let trailing = trailing_inner_row(&buf).expect("popup must have a trailing row");
2160 assert!(
2161 trailing.chars().all(|c| c == ' '),
2162 "trailing inner row above bottom border must be blank, got {trailing:?}"
2163 );
2164 }
2165
2166 #[test]
2167 fn render_confirm_popup_neutral_kind_keeps_trailing_blank() {
2168 let backend = TestBackend::new(60, 12);
2172 let mut terminal = Terminal::new(backend).unwrap();
2173 let (app, _dir) = make_app();
2174 terminal
2175 .draw(|frame| {
2176 let content = vec![Line::from(Span::styled(
2177 " Import 12 hosts from known_hosts?".to_string(),
2178 theme::bold(),
2179 ))];
2180 let footer_spans = confirm_footer_destructive("import", "skip").to_line().spans;
2181 render_confirm_popup(
2182 frame,
2183 52,
2184 PopupKind::Neutral,
2185 "Import",
2186 content,
2187 footer_spans,
2188 &app,
2189 );
2190 })
2191 .unwrap();
2192 let buf = terminal.backend().buffer().clone();
2193 let trailing = trailing_inner_row(&buf).expect("popup must have a trailing row");
2194 assert!(
2195 trailing.chars().all(|c| c == ' '),
2196 "neutral popup trailing row must be blank, got {trailing:?}"
2197 );
2198 }
2199
2200 #[test]
2201 fn wrap_block_lines_preserves_hanging_indent_on_multi_span_pattern() {
2202 let input = vec![Line::from(vec![
2208 Span::raw(" "),
2209 Span::styled(
2210 "Sends SIGTERM, waits 10s, then SIGKILL. Live connections will drop.".to_string(),
2211 theme::muted(),
2212 ),
2213 ])];
2214 let out = wrap_block_lines(input, 32);
2215 assert!(
2216 out.len() >= 2,
2217 "long body must wrap, got {} rows",
2218 out.len()
2219 );
2220 for line in &out {
2221 let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
2222 assert!(
2223 rendered.starts_with(" "),
2224 "every wrapped row keeps the 2-space hanging indent: {rendered:?}"
2225 );
2226 }
2227 }
2228
2229 #[test]
2230 fn wrap_block_lines_bypasses_aligned_lines_verbatim() {
2231 use ratatui::layout::Alignment;
2237 let aligned = Line::from(Span::styled(
2238 "Your SSH config, supercharged.".to_string(),
2239 theme::muted(),
2240 ))
2241 .alignment(Alignment::Center);
2242 let out = wrap_block_lines(vec![aligned], 60);
2243 assert_eq!(out.len(), 1, "aligned line stays a single row");
2244 assert_eq!(out[0].alignment, Some(Alignment::Center));
2245 let rendered: String = out[0].spans.iter().map(|s| s.content.as_ref()).collect();
2246 assert_eq!(rendered, "Your SSH config, supercharged.");
2247 }
2248
2249 #[test]
2250 fn render_body_wrapped_passes_blank_lines_through_unchanged() {
2251 let backend = TestBackend::new(20, 6);
2254 let mut terminal = Terminal::new(backend).unwrap();
2255 terminal
2256 .draw(|frame| {
2257 let area = Rect::new(0, 0, 20, 6);
2258 let block = Block::default().borders(Borders::ALL);
2259 let text = vec![
2260 Line::from(""),
2261 Line::from(Span::styled(" hello".to_string(), theme::bold())),
2262 Line::from(""),
2263 Line::from(Span::styled(" world".to_string(), theme::muted())),
2264 ];
2265 render_body_wrapped(frame, area, block, text);
2266 })
2267 .unwrap();
2268 let buf = terminal.backend().buffer().clone();
2269 let row = |y: u16| -> String {
2270 let mut s = String::new();
2271 for x in 1..(buf.area.width - 1) {
2272 s.push_str(buf[(x, y)].symbol());
2273 }
2274 s
2275 };
2276 assert!(row(1).trim().is_empty(), "row 1 stays blank");
2277 assert!(row(2).contains("hello"), "row 2 holds question");
2278 assert!(row(3).trim().is_empty(), "row 3 stays blank");
2279 assert!(row(4).contains("world"), "row 4 holds detail");
2280 }
2281
2282 #[test]
2283 fn tab_empty_falls_back_to_single_line_on_narrow_areas() {
2284 let backend = ratatui::backend::TestBackend::new(40, 6);
2288 let mut terminal = ratatui::Terminal::new(backend).unwrap();
2289 terminal
2290 .draw(|frame| {
2291 let e = TabEmpty {
2292 card_title: "X",
2293 headline: "Cache is empty.",
2294 explainer: "Nothing yet.",
2295 hints: &[("R", "refresh")],
2296 };
2297 render_tab_empty(frame, Rect::new(0, 0, 40, 6), &e);
2298 })
2299 .unwrap();
2300 }
2301
2302 #[test]
2303 fn tab_empty_card_renders_on_wide_areas() {
2304 let backend = ratatui::backend::TestBackend::new(100, 20);
2305 let mut terminal = ratatui::Terminal::new(backend).unwrap();
2306 terminal
2307 .draw(|frame| {
2308 let e = TabEmpty {
2309 card_title: "Containers",
2310 headline: "No containers cached yet.",
2311 explainer: "Containers are fetched per host on demand and cached locally.",
2312 hints: &[("Enter", "open a shell"), ("R", "refresh hosts")],
2313 };
2314 render_tab_empty(frame, Rect::new(0, 0, 100, 20), &e);
2315 })
2316 .unwrap();
2317 }
2318
2319 #[test]
2320 fn ellipsize_respects_unicode_display_width() {
2321 let s = "東京京都大阪";
2323 let out = ellipsize(s, 9);
2325 assert!(out.ends_with('…'));
2326 let inner = &out[..out.len() - '…'.len_utf8()];
2327 assert!(unicode_width::UnicodeWidthStr::width(inner) <= 8);
2328 }
2329}