1use ratatui::Frame;
2use ratatui::layout::Rect;
3use ratatui::text::{Line, Span};
4use ratatui::widgets::{Clear, ListItem, Paragraph};
5use unicode_width::UnicodeWidthStr;
6
7use super::design;
8use super::theme;
9use crate::app::{App, FormField, Screen};
10
11fn placeholder_for(field: FormField, is_pattern: bool) -> String {
12 use crate::messages::hints;
13 match field {
14 FormField::AskPass => {
15 if let Some(default) = crate::preferences::load_askpass_default() {
16 hints::askpass_default(&default)
17 } else {
18 hints::HOST_ASKPASS_PICK.to_string()
19 }
20 }
21 FormField::Alias if is_pattern => hints::HOST_ALIAS_PATTERN.to_string(),
22 FormField::Alias => hints::HOST_ALIAS.to_string(),
23 FormField::Hostname => hints::HOST_HOSTNAME.to_string(),
24 FormField::User => hints::DEFAULT_SSH_USER.to_string(),
25 FormField::Port => hints::HOST_PORT.to_string(),
26 FormField::IdentityFile => hints::IDENTITY_FILE_PICK.to_string(),
27 FormField::ProxyJump => hints::HOST_PROXY_JUMP.to_string(),
28 FormField::VaultSsh => hints::HOST_VAULT_SSH.to_string(),
31 FormField::VaultAddr => hints::HOST_VAULT_ADDR.to_string(),
32 FormField::Tags => hints::HOST_TAGS.to_string(),
33 }
34}
35
36const REQUIRED_FIELDS: &[(FormField, bool)] =
38 &[(FormField::Alias, true), (FormField::Hostname, true)];
39
40const ALL_FIELDS: &[(FormField, bool)] = &[
46 (FormField::Alias, true),
47 (FormField::Hostname, true),
48 (FormField::User, false),
49 (FormField::Port, false),
50 (FormField::IdentityFile, false),
51 (FormField::VaultSsh, false),
52 (FormField::VaultAddr, false),
53 (FormField::ProxyJump, false),
54 (FormField::AskPass, false),
55 (FormField::Tags, false),
56];
57
58pub fn render(frame: &mut Frame, app: &mut App) {
59 let expanded = app.forms.host.expanded;
67 let role_set = !app.forms.host.vault_ssh.trim().is_empty();
68 let base: &[(FormField, bool)] = if expanded {
69 ALL_FIELDS
70 } else {
71 REQUIRED_FIELDS
72 };
73 let filtered: Vec<(FormField, bool)> = base
74 .iter()
75 .copied()
76 .filter(|(f, _)| *f != FormField::VaultAddr || role_set)
77 .collect();
78 let visible_fields: &[(FormField, bool)] = &filtered;
79 let block_height = 2 + visible_fields.len() as u16 * 2;
81 let total_height = block_height + 1; let form_area = design::overlay_area(frame, design::OVERLAY_W, design::OVERLAY_H, total_height);
84
85 let title = if app.forms.host.is_pattern {
86 match &app.screen {
87 Screen::AddHost => "Add Pattern".to_string(),
88 Screen::EditHost { alias } => {
89 let max_alias = (form_area.width as usize).saturating_sub(14);
90 let truncated = super::truncate(alias, max_alias);
91 format!("Edit: {}", truncated)
92 }
93 _ => "Pattern".to_string(),
94 }
95 } else {
96 match &app.screen {
97 Screen::AddHost => "Add New Host".to_string(),
98 Screen::EditHost { alias } => {
99 let max_alias = (form_area.width as usize).saturating_sub(12);
100 let truncated = super::truncate(alias, max_alias);
101 format!("Edit: {}", truncated)
102 }
103 _ => "Host".to_string(),
104 }
105 };
106 frame.render_widget(Clear, form_area);
107
108 let block_area = Rect::new(form_area.x, form_area.y, form_area.width, block_height);
109
110 let block = design::overlay_block(&title);
111 let inner = block.inner(block_area);
112 frame.render_widget(block, block_area);
113
114 let picker_open = app.ui.key_picker.open
116 || app.ui.proxyjump_picker.open
117 || app.ui.password_picker.open
118 || app.ui.vault_role_picker.open;
119 let has_vault_roles = !app.vault_role_candidates().is_empty();
120
121 let vault_provider_hint: Option<(String, String)> =
123 if let Screen::EditHost { alias } = &app.screen {
124 app.hosts_state
125 .list
126 .iter()
127 .find(|h| h.alias == *alias)
128 .and_then(|h| h.provider.as_ref())
129 .and_then(|prov| {
130 app.providers.config.section(prov).and_then(|s| {
131 if s.vault_role.is_empty() {
132 None
133 } else {
134 Some((s.vault_role.clone(), prov.clone()))
135 }
136 })
137 })
138 } else {
139 None
140 };
141
142 let vault_addr_provider_hint: Option<(String, String)> =
147 if let Screen::EditHost { alias } = &app.screen {
148 app.hosts_state
149 .list
150 .iter()
151 .find(|h| h.alias == *alias)
152 .and_then(|h| h.provider.as_ref())
153 .and_then(|prov| {
154 app.providers.config.section(prov).and_then(|s| {
155 if s.vault_addr.is_empty() {
156 None
157 } else {
158 Some((s.vault_addr.clone(), prov.clone()))
159 }
160 })
161 })
162 } else {
163 None
164 };
165
166 for (idx, &(field, field_required)) in visible_fields.iter().enumerate() {
167 let divider_y = design::form_divider_y(inner, idx);
168 let content_y = divider_y + 1;
169
170 let is_focused = app.forms.host.focused_field == field;
171 let label_style = if is_focused {
172 theme::accent_bold()
173 } else {
174 theme::muted()
175 };
176 let field_label = if app.forms.host.is_pattern && field == FormField::Alias {
177 "Pattern"
178 } else {
179 field.label()
180 };
181 let is_required = if app.forms.host.is_pattern && field == FormField::Hostname {
182 false
183 } else {
184 field_required
185 };
186 let label = if is_required {
187 format!(" {}* ", field_label)
188 } else {
189 format!(" {} ", field_label)
190 };
191 super::render_divider(
192 frame,
193 block_area,
194 divider_y,
195 &label,
196 label_style,
197 theme::border_dim(),
198 );
199
200 let content_area = Rect::new(inner.x + 1, content_y, inner.width.saturating_sub(1), 1);
201 render_field_content(
202 frame,
203 content_area,
204 field,
205 &app.forms.host,
206 picker_open,
207 vault_provider_hint.as_ref(),
208 vault_addr_provider_hint.as_ref(),
209 has_vault_roles,
210 );
211 }
212
213 let footer_area = design::render_overlay_footer(frame, block_area);
217 if app.forms.is_discard_pending() {
218 design::render_discard_prompt(frame, footer_area, app);
219 } else {
220 let mode = if !expanded {
221 design::FormFooterMode::Collapsed
222 } else {
223 design::FormFooterMode::Expanded(app.forms.host.focused_field.kind())
224 };
225 let mut footer_spans = design::form_save_footer(mode).into_spans();
226 if let Some(ref hint) = app.forms.host.form_hint {
227 let hint_width: usize = hint.width() + 4; let shortcuts_width: usize = footer_spans.iter().map(|s| s.width()).sum();
229 let total = footer_area.width as usize;
230 let gap = total.saturating_sub(shortcuts_width + hint_width);
231 if gap > 0 {
232 footer_spans.push(Span::raw(" ".repeat(gap)));
233 footer_spans.push(Span::styled(
234 format!("{} {} ", design::ICON_WARNING, hint),
235 theme::warning(),
236 ));
237 }
238 frame.render_widget(Paragraph::new(Line::from(footer_spans)), footer_area);
241 } else {
242 super::render_footer_with_status(frame, footer_area, footer_spans, app);
243 }
244 }
245
246 if app.ui.key_picker.open {
248 render_key_picker_overlay(frame, app);
249 }
250
251 if app.ui.proxyjump_picker.open {
253 render_proxyjump_picker_overlay(frame, app);
254 }
255
256 if app.ui.password_picker.open {
258 render_password_picker_overlay(frame, app);
259 }
260
261 if app.ui.vault_role_picker.open {
263 render_vault_role_picker_overlay(frame, app);
264 }
265}
266
267pub fn render_key_picker_overlay(frame: &mut Frame, app: &mut App) {
269 if app.keys.list.is_empty() {
270 super::render_picker_empty_overlay(frame, "Select Key", "No keys found in ~/.ssh/");
271 return;
272 }
273
274 let width = super::picker_overlay_width(frame);
280 let usable = (width as usize).saturating_sub(6);
283 let gap: usize = design::COL_GAP as usize;
284
285 let name_w = design::padded_usize(
286 app.keys
287 .list
288 .iter()
289 .map(|k| k.name.len())
290 .max()
291 .unwrap_or(4)
292 .max(4),
293 );
294 let type_w = design::padded_usize(
295 app.keys
296 .list
297 .iter()
298 .map(|k| k.type_display().len())
299 .max()
300 .unwrap_or(4)
301 .max(4),
302 );
303 let left = name_w + gap + type_w;
304 let comment_w = usable.saturating_sub(left + gap);
305 let gap_str = design::COL_GAP_STR;
306
307 let items: Vec<ListItem> = app
308 .keys
309 .list
310 .iter()
311 .map(|key| {
312 let type_display = key.type_display();
313 let comment = if key.comment.is_empty() {
314 String::new()
315 } else {
316 super::truncate(&key.comment, comment_w.saturating_sub(1))
317 };
318 let mut spans = vec![
319 Span::styled(format!(" {:<name_w$}", key.name), theme::bold()),
320 Span::raw(gap_str),
321 Span::styled(format!("{:<type_w$}", type_display), theme::muted()),
322 ];
323 if comment_w > 0 {
324 spans.push(Span::raw(gap_str));
325 spans.push(Span::styled(comment, theme::muted()));
326 }
327 let line = Line::from(spans);
328 ListItem::new(line)
329 })
330 .collect();
331
332 super::render_picker_overlay(
333 frame,
334 "Select Key",
335 None,
336 items,
337 &mut app.ui.key_picker.list,
338 );
339}
340
341fn render_proxyjump_picker_overlay(frame: &mut Frame, app: &mut App) {
342 let candidates = app.proxyjump_candidates();
343
344 if candidates.is_empty() {
345 super::render_picker_empty_overlay(frame, "ProxyJump", "No other hosts configured");
346 return;
347 }
348
349 let width = super::picker_overlay_width(frame);
350 let inner = (width as usize).saturating_sub(6);
356 let alias_col = 20;
357 let min_gap = 2;
358 let host_max = inner.saturating_sub(alias_col + min_gap);
359
360 let items: Vec<ListItem> = candidates
361 .iter()
362 .map(|candidate| match candidate {
363 crate::app::ProxyJumpCandidate::SectionLabel(label) => ListItem::new(Line::from(
364 Span::styled(format!(" {}", label.to_ascii_uppercase()), theme::muted()),
365 )),
366 crate::app::ProxyJumpCandidate::Separator => ListItem::new(Line::from(Span::styled(
367 " ".to_string() + &"─".repeat(inner.saturating_sub(2)),
372 theme::muted(),
373 ))),
374 crate::app::ProxyJumpCandidate::Host {
375 alias, hostname, ..
376 } => {
377 let alias_display = super::truncate(alias, alias_col);
378 let host_display = super::truncate(hostname, host_max);
379 let host_width = host_display.width();
387 let alias_width = inner
388 .saturating_sub(host_width)
389 .saturating_sub(1)
390 .max(alias_col);
391 let line = Line::from(vec![
392 Span::styled(
393 format!(" {:<width$}", alias_display, width = alias_width),
394 theme::bold(),
395 ),
396 Span::styled(host_display, theme::muted()),
397 ]);
398 ListItem::new(line)
399 }
400 })
401 .collect();
402
403 super::render_picker_overlay(
404 frame,
405 "ProxyJump",
406 None,
407 items,
408 &mut app.ui.proxyjump_picker.list,
409 );
410}
411
412fn render_vault_role_picker_overlay(frame: &mut Frame, app: &mut App) {
413 let candidates = app.vault_role_candidates();
414
415 let width = super::picker_overlay_width(frame);
416 let max_role = (width as usize).saturating_sub(6);
417 let items: Vec<ListItem> = candidates
418 .iter()
419 .map(|role| {
420 ListItem::new(Line::from(Span::styled(
421 format!(" {}", super::truncate(role, max_role)),
422 theme::bold(),
423 )))
424 })
425 .collect();
426
427 super::render_picker_overlay(
428 frame,
429 "Vault SSH Role",
430 None,
431 items,
432 &mut app.ui.vault_role_picker.list,
433 );
434}
435
436fn render_password_picker_overlay(frame: &mut Frame, app: &mut App) {
437 let sources = crate::askpass::PASSWORD_SOURCES;
438 let width = super::picker_overlay_width(frame);
439 let inner_width = (width as usize).saturating_sub(6);
442 let items: Vec<ListItem> = sources
443 .iter()
444 .map(|src| {
445 let hint_width = src.hint.len();
446 let label_width = inner_width.saturating_sub(hint_width).saturating_sub(1);
447 let line = Line::from(vec![
448 Span::styled(
449 format!(" {:<width$}", src.label, width = label_width),
450 theme::bold(),
451 ),
452 Span::styled(src.hint, theme::muted()),
453 ]);
454 ListItem::new(line)
455 })
456 .collect();
457
458 super::render_picker_overlay(
459 frame,
460 "Password Source",
461 Some("Ctrl+D: global default"),
462 items,
463 &mut app.ui.password_picker.list,
464 );
465}
466
467#[cfg(test)]
469pub fn placeholder_text(field: FormField) -> String {
470 placeholder_for(field, false)
471}
472
473#[cfg(test)]
474pub fn placeholder_text_pattern(field: FormField) -> String {
475 placeholder_for(field, true)
476}
477
478#[allow(clippy::too_many_arguments)]
480fn render_field_content(
481 frame: &mut Frame,
482 area: Rect,
483 field: FormField,
484 form: &crate::app::HostForm,
485 picker_open: bool,
486 vault_provider_hint: Option<&(String, String)>,
487 vault_addr_provider_hint: Option<&(String, String)>,
488 has_vault_roles: bool,
489) {
490 use crate::messages::hints;
491 let is_focused = form.focused_field == field;
492
493 let value = match field {
494 FormField::Alias => &form.alias,
495 FormField::Hostname => &form.hostname,
496 FormField::User => &form.user,
497 FormField::Port => &form.port,
498 FormField::IdentityFile => &form.identity_file,
499 FormField::ProxyJump => &form.proxy_jump,
500 FormField::AskPass => &form.askpass,
501 FormField::VaultSsh => &form.vault_ssh,
502 FormField::VaultAddr => &form.vault_addr,
503 FormField::Tags => &form.tags,
504 };
505
506 let is_picker = matches!(
507 field,
508 FormField::IdentityFile | FormField::ProxyJump | FormField::AskPass
509 ) || (field == FormField::VaultSsh && has_vault_roles);
510
511 let inherited_hint = match field {
513 FormField::ProxyJump => form.inherited.proxy_jump.as_ref(),
514 FormField::User => form.inherited.user.as_ref(),
515 FormField::IdentityFile => form.inherited.identity_file.as_ref(),
516 _ => None,
517 };
518
519 let content = if let (true, Some((inh_val, inh_src))) = (value.is_empty(), inherited_hint) {
522 let inner_width = area.width as usize;
523 let is_loop = field == FormField::ProxyJump
525 && crate::ssh_config::model::proxy_jump_contains_self(inh_val, &form.alias);
526 if is_loop {
527 let msg = format!("loops via {}", inh_src);
528 let display = super::truncate(&msg, inner_width);
529 Line::from(vec![Span::styled(display, theme::error())])
530 } else {
531 let source_suffix = format!(" \u{2190} {}", inh_src);
532 let val_budget = inner_width.saturating_sub(source_suffix.width());
533 let display = super::truncate(inh_val, val_budget);
534 if is_picker && is_focused {
535 let arrow_pos = inner_width.saturating_sub(1);
536 let used = display.width() + source_suffix.width();
537 let gap = arrow_pos.saturating_sub(used);
538 Line::from(vec![
539 Span::styled(display, theme::muted()),
540 Span::styled(source_suffix, theme::muted()),
541 Span::raw(" ".repeat(gap)),
542 Span::styled(design::PICKER_ARROW, theme::muted()),
543 ])
544 } else {
545 Line::from(vec![
546 Span::styled(display, theme::muted()),
547 Span::styled(source_suffix, theme::muted()),
548 ])
549 }
550 }
551 } else if let (true, FormField::VaultSsh, Some((role, prov))) =
552 (value.is_empty(), field, vault_provider_hint)
553 {
554 let hint = hints::inherits_from(role, prov);
555 Line::from(Span::styled(hint, theme::muted()))
556 } else if let (true, FormField::VaultAddr, Some((addr, prov))) =
557 (value.is_empty(), field, vault_addr_provider_hint)
558 {
559 let hint = hints::inherits_from(addr, prov);
560 Line::from(Span::styled(hint, theme::muted()))
561 } else if value.is_empty() && is_focused && !is_picker {
562 let ph = placeholder_for(field, form.is_pattern);
563 Line::from(Span::styled(ph, theme::muted()))
564 } else if is_picker && is_focused {
565 let inner_width = area.width as usize;
566 let arrow_pos = inner_width.saturating_sub(1);
567 let (display, display_style) = if value.is_empty() {
568 let ph = if field == FormField::VaultSsh {
569 hints::HOST_VAULT_SSH_PICKER.to_string()
570 } else {
571 placeholder_for(field, form.is_pattern)
572 };
573 (ph, theme::muted())
574 } else {
575 (value.to_string(), theme::bold())
576 };
577 let val_width = display.width();
578 let gap = arrow_pos.saturating_sub(val_width);
579 Line::from(vec![
580 Span::styled(display, display_style),
581 Span::raw(" ".repeat(gap)),
582 Span::styled(design::PICKER_ARROW, theme::muted()),
583 ])
584 } else if value.is_empty() {
585 Line::from(Span::raw(""))
586 } else {
587 Line::from(Span::styled(value.to_string(), theme::bold()))
588 };
589
590 frame.render_widget(Paragraph::new(content), area);
591
592 if is_focused && !picker_open {
593 let prefix: String = value.chars().take(form.cursor_pos).collect();
594 let cursor_x = area
595 .x
596 .saturating_add(prefix.width().min(u16::MAX as usize) as u16);
597 let cursor_y = area.y;
598 if area.width > 0 && cursor_x < area.x.saturating_add(area.width) {
599 frame.set_cursor_position((cursor_x, cursor_y));
600 }
601 }
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607 use ratatui::Terminal;
608 use ratatui::backend::TestBackend;
609
610 fn make_app() -> App {
611 let config = crate::ssh_config::model::SshConfigFile {
612 elements: crate::ssh_config::model::SshConfigFile::parse_content(""),
613 path: tempfile::tempdir()
614 .expect("tempdir")
615 .keep()
616 .join("test_config"),
617 crlf: false,
618 bom: false,
619 };
620 App::new(config)
621 }
622
623 fn buffer_dump(buf: &ratatui::buffer::Buffer) -> String {
624 let mut out = String::new();
625 for y in 0..buf.area.height {
626 for x in 0..buf.area.width {
627 out.push_str(buf[(x, y)].symbol());
628 }
629 out.push('\n');
630 }
631 out
632 }
633
634 #[test]
640 fn render_password_picker_overlay_shows_ctrl_d_hint_in_title() {
641 let backend = TestBackend::new(80, 20);
642 let mut terminal = Terminal::new(backend).unwrap();
643 let mut app = make_app();
644 app.ui.password_picker.open_at(0);
645 terminal
646 .draw(|frame| {
647 render_password_picker_overlay(frame, &mut app);
648 let dump = buffer_dump(frame.buffer_mut());
649 assert!(
650 dump.contains("Password Source · Ctrl+D: global default"),
651 "password picker must surface Ctrl+D hint in title, got:\n{dump}"
652 );
653 })
654 .unwrap();
655 }
656
657 #[test]
663 fn render_password_picker_overlay_has_no_footer_row_with_ctrl_d() {
664 let backend = TestBackend::new(80, 20);
665 let mut terminal = Terminal::new(backend).unwrap();
666 let mut app = make_app();
667 app.ui.password_picker.open_at(0);
668 terminal
669 .draw(|frame| {
670 render_password_picker_overlay(frame, &mut app);
671 let buf = frame.buffer_mut();
672 let mut title_row: Option<u16> = None;
676 for y in 0..buf.area.height {
677 let mut row = String::new();
678 for x in 0..buf.area.width {
679 row.push_str(buf[(x, y)].symbol());
680 }
681 if row.contains("Password Source") {
682 title_row = Some(y);
683 break;
684 }
685 }
686 let title_row = title_row.expect("title row must exist");
687 for y in 0..buf.area.height {
688 if y == title_row {
689 continue;
690 }
691 let mut row = String::new();
692 for x in 0..buf.area.width {
693 row.push_str(buf[(x, y)].symbol());
694 }
695 assert!(
696 !row.contains("Ctrl+D"),
697 "row {y} must not contain 'Ctrl+D' (footer regression): {row:?}"
698 );
699 }
700 })
701 .unwrap();
702 }
703
704 fn proxyjump_picker_fixture(config_text: &str, editing_alias: &str) -> App {
709 let cfg = crate::ssh_config::model::SshConfigFile {
710 elements: crate::ssh_config::model::SshConfigFile::parse_content(config_text),
711 path: tempfile::tempdir()
712 .expect("tempdir")
713 .keep()
714 .join("test_config"),
715 crlf: false,
716 bom: false,
717 };
718 let mut app = App::new(cfg);
719 app.screen = Screen::EditHost {
720 alias: editing_alias.to_string(),
721 };
722 app.ui.proxyjump_picker.open = true;
723 app.ui
724 .proxyjump_picker
725 .list
726 .select(app.proxyjump_first_host_index());
727 app
728 }
729
730 fn find_needle_in_buffer(
736 buf: &ratatui::buffer::Buffer,
737 needle: &str,
738 ) -> Option<(u16, u16, u16)> {
739 let chars: Vec<String> = needle.chars().map(|c| c.to_string()).collect();
740 let len = chars.len() as u16;
741 if len == 0 || buf.area.width < len {
742 return None;
743 }
744 for y in 0..buf.area.height {
745 for start_x in 0..=buf.area.width - len {
746 let matches = (0..len).all(|i| buf[(start_x + i, y)].symbol() == chars[i as usize]);
747 if matches {
748 return Some((y, start_x, start_x + len - 1));
749 }
750 }
751 }
752 None
753 }
754
755 fn right_border_col(buf: &ratatui::buffer::Buffer, y: u16) -> Option<u16> {
757 for x in (0..buf.area.width).rev() {
758 let s = buf[(x, y)].symbol();
759 if s == "│" || s == "╮" || s == "╯" {
760 return Some(x);
761 }
762 }
763 None
764 }
765
766 #[test]
772 fn render_proxyjump_picker_host_column_is_right_aligned() {
773 let backend = TestBackend::new(80, 20);
774 let mut terminal = Terminal::new(backend).unwrap();
775 let mut app = proxyjump_picker_fixture(
776 concat!(
777 "Host editing\n HostName 9.9.9.9\n",
778 "Host plain\n HostName 1.1.1.1\n",
779 ),
780 "editing",
781 );
782 terminal
783 .draw(|frame| {
784 render_proxyjump_picker_overlay(frame, &mut app);
785 let buf = frame.buffer_mut();
786 let (y, _start, end_col) = find_needle_in_buffer(buf, "1.1.1.1")
787 .expect("candidate host row must render '1.1.1.1'");
788 let border = right_border_col(buf, y).expect("right border on host row");
789 let gap = border.saturating_sub(end_col);
790 assert!(
791 end_col < border && gap <= 3,
792 "hostname must end flush with right border (end_col={end_col}, border_x={border}, gap={gap})"
793 );
794 })
795 .unwrap();
796 }
797
798 #[test]
803 fn render_proxyjump_picker_long_hostname_does_not_overflow() {
804 let backend = TestBackend::new(80, 20);
805 let mut terminal = Terminal::new(backend).unwrap();
806 let mut app = proxyjump_picker_fixture(
809 concat!(
810 "Host editing\n HostName 9.9.9.9\n",
811 "Host plain\n HostName very-long-hostname-that-should-be-truncated.example.com\n",
812 ),
813 "editing",
814 );
815 terminal
816 .draw(|frame| {
817 render_proxyjump_picker_overlay(frame, &mut app);
818 let buf = frame.buffer_mut();
819 let (y, _start, end_col) = find_needle_in_buffer(buf, "very-long-hostname")
823 .expect("truncated hostname prefix must render");
824 let border = right_border_col(buf, y).expect("right border on host row");
825 assert!(
826 end_col < border,
827 "truncated hostname must not overflow right border (end_col={end_col}, border_x={border})"
828 );
829 })
830 .unwrap();
831 }
832
833 #[test]
837 fn render_proxyjump_picker_right_aligns_on_narrow_terminal() {
838 let backend = TestBackend::new(60, 20);
841 let mut terminal = Terminal::new(backend).unwrap();
842 let mut app = proxyjump_picker_fixture(
843 concat!(
844 "Host editing\n HostName 9.9.9.9\n",
845 "Host plain\n HostName 1.1.1.1\n",
846 ),
847 "editing",
848 );
849 terminal
850 .draw(|frame| {
851 render_proxyjump_picker_overlay(frame, &mut app);
852 let buf = frame.buffer_mut();
853 let (y, _start, end_col) = find_needle_in_buffer(buf, "1.1.1.1")
854 .expect("hostname must render on narrow terminal");
855 let border = right_border_col(buf, y).expect("right border present");
856 assert!(
857 end_col < border && border - end_col <= 3,
858 "right-align must hold on narrow terminal (end_col={end_col}, border_x={border})"
859 );
860 })
861 .unwrap();
862 }
863
864 #[test]
868 fn render_proxyjump_picker_right_aligns_suggested_host_below_label() {
869 let backend = TestBackend::new(80, 20);
870 let mut terminal = Terminal::new(backend).unwrap();
871 let mut app = proxyjump_picker_fixture(
874 concat!(
875 "Host editing\n HostName 9.9.9.9\n",
876 "Host bastion\n HostName 1.2.3.4\n",
877 "Host plain\n HostName 5.6.7.8\n",
878 ),
879 "editing",
880 );
881 terminal
882 .draw(|frame| {
883 render_proxyjump_picker_overlay(frame, &mut app);
884 let buf = frame.buffer_mut();
885 let (label_y, _, _) = find_needle_in_buffer(buf, "SUGGESTIONS")
888 .expect("SectionLabel must render above the suggested host");
889 let (y, _start, end_col) = find_needle_in_buffer(buf, "1.2.3.4")
890 .expect("suggested host must render");
891 assert!(
892 y > label_y,
893 "suggested host must render below the SectionLabel (label_y={label_y}, host_y={y})"
894 );
895 let border = right_border_col(buf, y).expect("right border on host row");
896 assert!(
897 end_col < border && border - end_col <= 3,
898 "suggested host must right-align (end_col={end_col}, border_x={border})"
899 );
900 })
901 .unwrap();
902 }
903
904 #[test]
908 fn render_proxyjump_picker_multiple_hosts_share_right_edge() {
909 let backend = TestBackend::new(80, 20);
910 let mut terminal = Terminal::new(backend).unwrap();
911 let mut app = proxyjump_picker_fixture(
912 concat!(
913 "Host editing\n HostName 9.9.9.9\n",
914 "Host host-a\n HostName 1.1.1.1\n",
915 "Host host-b\n HostName 2.2.2.2\n",
916 ),
917 "editing",
918 );
919 terminal
920 .draw(|frame| {
921 render_proxyjump_picker_overlay(frame, &mut app);
922 let buf = frame.buffer_mut();
923 let (y1, _, end1) =
924 find_needle_in_buffer(buf, "1.1.1.1").expect("host-a row must render");
925 let (y2, _, end2) =
926 find_needle_in_buffer(buf, "2.2.2.2").expect("host-b row must render");
927 assert_ne!(y1, y2, "two distinct rows expected");
928 assert_eq!(
929 end1, end2,
930 "both hostnames must end at the same column (end1={end1}, end2={end2})"
931 );
932 })
933 .unwrap();
934 }
935
936 #[test]
941 fn render_proxyjump_picker_preserves_minimum_gap_between_columns() {
942 let backend = TestBackend::new(80, 20);
943 let mut terminal = Terminal::new(backend).unwrap();
944 let mut app = proxyjump_picker_fixture(
945 concat!(
946 "Host editing\n HostName 9.9.9.9\n",
947 "Host a\n HostName 1.1.1.1\n",
948 ),
949 "editing",
950 );
951 terminal
952 .draw(|frame| {
953 render_proxyjump_picker_overlay(frame, &mut app);
954 let buf = frame.buffer_mut();
955 let (y, host_start, _) = find_needle_in_buffer(buf, "1.1.1.1")
956 .expect("hostname must render for gap check");
957 let mut gap = 0_u16;
961 let mut x = host_start;
962 while x > 0 {
963 x -= 1;
964 if buf[(x, y)].symbol() == " " {
965 gap += 1;
966 } else {
967 break;
968 }
969 }
970 assert!(
971 gap >= 2,
972 "at least two spaces must separate alias and hostname columns (gap={gap})"
973 );
974 })
975 .unwrap();
976 }
977}