Skip to main content

omnyssh/ui/
popup.rs

1use std::ops::Range;
2
3use ratatui::{
4    layout::{Alignment, Constraint, Direction, Layout, Rect},
5    style::{Color, Modifier, Style},
6    text::{Line, Span},
7    widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph},
8    Frame,
9};
10
11use crate::app::{
12    FormField, HostForm, PassphrasePrompt, PasswordPrompt, SnippetForm, SnippetResultEntry,
13    UpdateButton, UpdatePopup, UpdatePopupPhase, FORM_FIELD_LABELS, SNIPPET_FORM_FIELD_LABELS,
14    UPDATE_BUTTONS,
15};
16use crate::ui::theme::Theme;
17use omnyssh_core::ssh::client::Host;
18
19// ---------------------------------------------------------------------------
20// Helpers
21// ---------------------------------------------------------------------------
22
23/// Computes a centred rectangle of the given percentage dimensions inside
24/// `area`. Used to position popups.
25pub fn centred_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
26    let layout_v = Layout::default()
27        .direction(Direction::Vertical)
28        .constraints([
29            Constraint::Percentage((100 - percent_y) / 2),
30            Constraint::Percentage(percent_y),
31            Constraint::Percentage((100 - percent_y) / 2),
32        ])
33        .split(area);
34
35    Layout::default()
36        .direction(Direction::Horizontal)
37        .constraints([
38            Constraint::Percentage((100 - percent_x) / 2),
39            Constraint::Percentage(percent_x),
40            Constraint::Percentage((100 - percent_x) / 2),
41        ])
42        .split(layout_v[1])[1]
43}
44
45// ---------------------------------------------------------------------------
46// Help popup
47// ---------------------------------------------------------------------------
48
49/// Renders the built-in help popup listing all key bindings organized by screen.
50pub fn render_help(frame: &mut Frame, theme: &Theme) {
51    let area = centred_rect(95, 85, frame.area());
52    frame.render_widget(Clear, area);
53
54    let block = Block::default()
55        .title(" Help — Keyboard Shortcuts ")
56        .title_alignment(Alignment::Center)
57        .borders(Borders::ALL)
58        .border_type(BorderType::Rounded)
59        .border_style(Style::default().fg(theme.warning_border));
60
61    let inner = block.inner(area);
62    frame.render_widget(block, area);
63
64    // Section header style
65    let section_style = Style::default()
66        .fg(theme.text_warning)
67        .add_modifier(Modifier::BOLD);
68    let key_style = Style::default()
69        .fg(theme.accent)
70        .add_modifier(Modifier::BOLD);
71    let desc_style = Style::default().fg(theme.text_primary);
72
73    // Split into 3 columns
74    let columns = Layout::default()
75        .direction(Direction::Horizontal)
76        .constraints([
77            Constraint::Percentage(33),
78            Constraint::Percentage(33),
79            Constraint::Percentage(34),
80        ])
81        .split(inner);
82
83    // Column 1: Global Navigation + Dashboard
84    let mut col1_lines = vec![Line::from("")];
85    col1_lines.push(Line::from(Span::styled(
86        " GLOBAL NAVIGATION",
87        section_style,
88    )));
89    col1_lines.push(Line::from(vec![
90        Span::styled("  1", key_style),
91        Span::styled("        Dashboard", desc_style),
92    ]));
93    col1_lines.push(Line::from(vec![
94        Span::styled("  2", key_style),
95        Span::styled("        File Manager", desc_style),
96    ]));
97    col1_lines.push(Line::from(vec![
98        Span::styled("  3", key_style),
99        Span::styled("        Snippets", desc_style),
100    ]));
101    col1_lines.push(Line::from(vec![
102        Span::styled("  4", key_style),
103        Span::styled("        Terminal", desc_style),
104    ]));
105    col1_lines.push(Line::from(vec![
106        Span::styled("  ?", key_style),
107        Span::styled("        Help", desc_style),
108    ]));
109    col1_lines.push(Line::from(vec![
110        Span::styled("  q", key_style),
111        Span::styled("        Quit", desc_style),
112    ]));
113    col1_lines.push(Line::from(""));
114
115    col1_lines.push(Line::from(Span::styled(" DASHBOARD", section_style)));
116    col1_lines.push(Line::from(vec![
117        Span::styled("  Enter", key_style),
118        Span::styled("    Open details", desc_style),
119    ]));
120    col1_lines.push(Line::from(vec![
121        Span::styled("  a", key_style),
122        Span::styled("        Add host", desc_style),
123    ]));
124    col1_lines.push(Line::from(vec![
125        Span::styled("  e", key_style),
126        Span::styled("        Edit host", desc_style),
127    ]));
128    col1_lines.push(Line::from(vec![
129        Span::styled("  d", key_style),
130        Span::styled("        Delete host", desc_style),
131    ]));
132    col1_lines.push(Line::from(vec![
133        Span::styled("  r", key_style),
134        Span::styled("        Refresh", desc_style),
135    ]));
136    col1_lines.push(Line::from(vec![
137        Span::styled("  s", key_style),
138        Span::styled("        Sort", desc_style),
139    ]));
140    col1_lines.push(Line::from(vec![
141        Span::styled("  t", key_style),
142        Span::styled("        Filter tags", desc_style),
143    ]));
144    col1_lines.push(Line::from(vec![
145        Span::styled("  g", key_style),
146        Span::styled("        Group by tag", desc_style),
147    ]));
148    col1_lines.push(Line::from(vec![
149        Span::styled("  /", key_style),
150        Span::styled("        Search", desc_style),
151    ]));
152    col1_lines.push(Line::from(vec![
153        Span::styled("  x", key_style),
154        Span::styled("        Quick exec", desc_style),
155    ]));
156    col1_lines.push(Line::from(vec![
157        Span::styled("  K", key_style),
158        Span::styled("        Setup SSH key", desc_style),
159    ]));
160    col1_lines.push(Line::from(vec![
161        Span::styled("  f", key_style),
162        Span::styled("        Tunnel on/off", desc_style),
163    ]));
164    col1_lines.push(Line::from(vec![
165        Span::styled("  hjkl", key_style),
166        Span::styled("    Navigate", desc_style),
167    ]));
168
169    frame.render_widget(Paragraph::new(col1_lines), columns[0]);
170
171    // Column 2: Detail View + File Manager + Snippets
172    let mut col2_lines = vec![Line::from("")];
173    col2_lines.push(Line::from(Span::styled(" DETAIL VIEW", section_style)));
174    col2_lines.push(Line::from(vec![
175        Span::styled("  Enter", key_style),
176        Span::styled("    Connect", desc_style),
177    ]));
178    col2_lines.push(Line::from(vec![
179        Span::styled("  r", key_style),
180        Span::styled("        Refresh", desc_style),
181    ]));
182    col2_lines.push(Line::from(vec![
183        Span::styled("  Esc", key_style),
184        Span::styled("      Back", desc_style),
185    ]));
186    col2_lines.push(Line::from(vec![
187        Span::styled("  4-9", key_style),
188        Span::styled("      Quick view", desc_style),
189    ]));
190    col2_lines.push(Line::from(vec![
191        Span::styled("  f", key_style),
192        Span::styled("        Tunnel on/off", desc_style),
193    ]));
194    col2_lines.push(Line::from(""));
195
196    col2_lines.push(Line::from(Span::styled(" FILE MANAGER", section_style)));
197    col2_lines.push(Line::from(vec![
198        Span::styled("  hjkl", key_style),
199        Span::styled("    Navigate", desc_style),
200    ]));
201    col2_lines.push(Line::from(vec![
202        Span::styled("  Tab", key_style),
203        Span::styled("      Switch panel", desc_style),
204    ]));
205    col2_lines.push(Line::from(vec![
206        Span::styled("  Space", key_style),
207        Span::styled("    Mark file", desc_style),
208    ]));
209    col2_lines.push(Line::from(vec![
210        Span::styled("  c", key_style),
211        Span::styled("        Copy", desc_style),
212    ]));
213    col2_lines.push(Line::from(vec![
214        Span::styled("  p", key_style),
215        Span::styled("        Paste", desc_style),
216    ]));
217    col2_lines.push(Line::from(vec![
218        Span::styled("  n", key_style),
219        Span::styled("        New dir", desc_style),
220    ]));
221    col2_lines.push(Line::from(vec![
222        Span::styled("  R", key_style),
223        Span::styled("        Rename", desc_style),
224    ]));
225    col2_lines.push(Line::from(vec![
226        Span::styled("  D", key_style),
227        Span::styled("        Delete", desc_style),
228    ]));
229    col2_lines.push(Line::from(vec![
230        Span::styled("  H", key_style),
231        Span::styled("        Connect", desc_style),
232    ]));
233    col2_lines.push(Line::from(vec![
234        Span::styled("  .", key_style),
235        Span::styled("        Toggle hidden", desc_style),
236    ]));
237    if cfg!(windows) {
238        col2_lines.push(Line::from(vec![
239            Span::styled("  d", key_style),
240            Span::styled("        Next drive", desc_style),
241        ]));
242    }
243    col2_lines.push(Line::from(""));
244
245    col2_lines.push(Line::from(Span::styled(" SNIPPETS", section_style)));
246    col2_lines.push(Line::from(vec![
247        Span::styled("  Enter", key_style),
248        Span::styled("    Run snippet", desc_style),
249    ]));
250    col2_lines.push(Line::from(vec![
251        Span::styled("  n", key_style),
252        Span::styled("        New", desc_style),
253    ]));
254    col2_lines.push(Line::from(vec![
255        Span::styled("  e", key_style),
256        Span::styled("        Edit", desc_style),
257    ]));
258    col2_lines.push(Line::from(vec![
259        Span::styled("  d", key_style),
260        Span::styled("        Delete", desc_style),
261    ]));
262    col2_lines.push(Line::from(vec![
263        Span::styled("  b", key_style),
264        Span::styled("        Broadcast", desc_style),
265    ]));
266    col2_lines.push(Line::from(vec![
267        Span::styled("  /", key_style),
268        Span::styled("        Search", desc_style),
269    ]));
270
271    frame.render_widget(Paragraph::new(col2_lines), columns[1]);
272
273    // Column 3: Terminal + Footer
274    let mut col3_lines = vec![Line::from("")];
275    col3_lines.push(Line::from(Span::styled(" TERMINAL", section_style)));
276    col3_lines.push(Line::from(vec![
277        Span::styled("  Ctrl+T", key_style),
278        Span::styled("   New tab", desc_style),
279    ]));
280    col3_lines.push(Line::from(vec![
281        Span::styled("  Ctrl+W", key_style),
282        Span::styled("   Close tab", desc_style),
283    ]));
284    col3_lines.push(Line::from(vec![
285        Span::styled("  Ctrl+N", key_style),
286        Span::styled("   Next tab", desc_style),
287    ]));
288    col3_lines.push(Line::from(vec![
289        Span::styled("  Ctrl+\\", key_style),
290        Span::styled("   V-split", desc_style),
291    ]));
292    col3_lines.push(Line::from(vec![
293        Span::styled("  Ctrl+]", key_style),
294        Span::styled("   H-split", desc_style),
295    ]));
296    col3_lines.push(Line::from(vec![
297        Span::styled("  Ctrl+Q", key_style),
298        Span::styled("   Exit", desc_style),
299    ]));
300    col3_lines.push(Line::from(""));
301    col3_lines.push(Line::from(Span::styled(" COPY TEXT", section_style)));
302    col3_lines.push(Line::from(vec![
303        Span::styled("  Mouse drag", key_style),
304        Span::styled(" Select text", desc_style),
305    ]));
306    col3_lines.push(Line::from(vec![
307        Span::styled("  Cmd+C/Ctrl+C", key_style),
308        Span::styled(" Copy", desc_style),
309    ]));
310    col3_lines.push(Line::from(vec![Span::styled(
311        "  (Terminal screen only)",
312        Style::default()
313            .fg(theme.text_secondary)
314            .add_modifier(Modifier::ITALIC),
315    )]));
316    col3_lines.push(Line::from(""));
317    col3_lines.push(Line::from(""));
318    col3_lines.push(Line::from(""));
319    col3_lines.push(Line::from(""));
320    col3_lines.push(Line::from(""));
321    col3_lines.push(Line::from(Span::styled(
322        " Press Esc or ? to close",
323        Style::default()
324            .fg(theme.text_muted)
325            .add_modifier(Modifier::ITALIC),
326    )));
327
328    frame.render_widget(Paragraph::new(col3_lines), columns[2]);
329}
330
331// ---------------------------------------------------------------------------
332// Host form (Add / Edit)
333// ---------------------------------------------------------------------------
334
335/// Renders the host add/edit form popup.
336///
337/// `title` is either `"Add Host"` or `"Edit Host"`.
338pub fn render_host_form(frame: &mut Frame, form: &HostForm, title: &str, theme: &Theme) {
339    // Taller popup to fit all fields.
340    // One row for each label and input, plus top padding, the hint and its spacer,
341    // and the border. Sizing to that rather than to a fixed share of the screen
342    // keeps the last field on screen when the terminal is short.
343    let frame_area = frame.area();
344    let needed = u32::from(FORM_FIELD_LABELS.len() as u16) * 2 + 5;
345    let percent = (needed * 100)
346        .div_ceil(u32::from(frame_area.height.max(1)))
347        .clamp(50, 100) as u16;
348    let area = centred_rect(70, percent, frame_area);
349    frame.render_widget(Clear, area);
350
351    let block = Block::default()
352        .title(format!(" {} ", title))
353        .title_alignment(Alignment::Center)
354        .borders(Borders::ALL)
355        .border_type(BorderType::Rounded)
356        .border_style(Style::default().fg(theme.success_border));
357
358    // Split: inner area = top padding + one row per field + bottom hint.
359    let inner = block.inner(area);
360    frame.render_widget(block, area);
361
362    // A terminal too short for every field shows the ones around the focus;
363    // the padding and spacer rows point at the ones scrolled out.
364    let capacity = usize::from(inner.height.saturating_sub(3) / 2);
365    let window = field_window(FORM_FIELD_LABELS.len(), form.focused_field, capacity);
366    let num_fields = window.len();
367    // 1 blank line top + 2 lines per field (label + input) + 2 hint lines
368    let mut constraints: Vec<Constraint> = Vec::with_capacity(num_fields * 2 + 3);
369    constraints.push(Constraint::Length(1)); // top padding
370    for _ in 0..num_fields {
371        constraints.push(Constraint::Length(1)); // label
372        constraints.push(Constraint::Length(1)); // input box
373    }
374    constraints.push(Constraint::Length(1)); // spacer
375    constraints.push(Constraint::Length(1)); // hint
376    constraints.push(Constraint::Min(0)); // remainder
377
378    let rows = Layout::default()
379        .direction(Direction::Vertical)
380        .constraints(constraints)
381        .split(inner);
382
383    let focused_style = Style::default()
384        .fg(theme.form_focused_fg)
385        .bg(theme.accent)
386        .add_modifier(Modifier::BOLD);
387    let normal_style = Style::default()
388        .fg(theme.text_primary)
389        .bg(theme.selected_bg);
390    let label_style = Style::default().fg(theme.text_secondary);
391    let focused_label_style = Style::default()
392        .fg(theme.accent)
393        .add_modifier(Modifier::BOLD);
394
395    let more_style = Style::default().fg(theme.text_muted);
396    if window.start > 0 {
397        let more = format!("  ↑ {} more", window.start);
398        frame.render_widget(Paragraph::new(Span::styled(more, more_style)), rows[0]);
399    }
400    if window.end < FORM_FIELD_LABELS.len() {
401        let more = format!("  ↓ {} more", FORM_FIELD_LABELS.len() - window.end);
402        frame.render_widget(
403            Paragraph::new(Span::styled(more, more_style)),
404            rows[1 + num_fields * 2],
405        );
406    }
407
408    for (row, i) in window.enumerate() {
409        let label = FORM_FIELD_LABELS[i];
410        let label_row = rows[1 + row * 2];
411        let input_row = rows[2 + row * 2];
412        let is_focused = i == form.focused_field;
413
414        // Label
415        let lbl_span = Span::styled(
416            format!("  {}: ", label),
417            if is_focused {
418                focused_label_style
419            } else {
420                label_style
421            },
422        );
423        frame.render_widget(Paragraph::new(Line::from(lbl_span)), label_row);
424
425        // Input value with simulated cursor
426        let field = &form.fields[i];
427        let value_style = if is_focused {
428            focused_style
429        } else {
430            normal_style
431        };
432
433        let display = if is_focused {
434            // Show cursor as a blinking block by inserting '|' at cursor pos.
435            let (before, after) = field.value.split_at(field.cursor.min(field.value.len()));
436            format!("  {}|{} ", before, after)
437        } else {
438            format!("  {} ", field.value)
439        };
440
441        frame.render_widget(
442            Paragraph::new(Line::from(Span::styled(display, value_style))),
443            input_row,
444        );
445    }
446
447    // Hint row at bottom.
448    let hint_row_idx = 1 + num_fields * 2 + 1;
449    if hint_row_idx < rows.len() {
450        let hint = Line::from(vec![
451            Span::styled(
452                "  Tab",
453                Style::default()
454                    .fg(theme.accent)
455                    .add_modifier(Modifier::BOLD),
456            ),
457            Span::styled(":next  ", Style::default().fg(theme.text_muted)),
458            Span::styled(
459                "Enter",
460                Style::default()
461                    .fg(theme.text_success)
462                    .add_modifier(Modifier::BOLD),
463            ),
464            Span::styled(":save  ", Style::default().fg(theme.text_muted)),
465            Span::styled(
466                "Esc",
467                Style::default()
468                    .fg(theme.text_warning)
469                    .add_modifier(Modifier::BOLD),
470            ),
471            Span::styled(":cancel", Style::default().fg(theme.text_muted)),
472        ]);
473        frame.render_widget(Paragraph::new(hint), rows[hint_row_idx]);
474    }
475}
476
477/// The run of `count` form fields that fits in `capacity` slots and holds the
478/// `focused` one. Scrolls only once the focus passes the last slot.
479fn field_window(count: usize, focused: usize, capacity: usize) -> Range<usize> {
480    let capacity = capacity.clamp(1, count.max(1));
481    let start = focused
482        .saturating_sub(capacity - 1)
483        .min(count.saturating_sub(capacity));
484    start..start + capacity.min(count)
485}
486
487// ---------------------------------------------------------------------------
488// Delete confirmation popup
489// ---------------------------------------------------------------------------
490
491// ---------------------------------------------------------------------------
492// Tag filter popup
493// ---------------------------------------------------------------------------
494
495/// Renders the tag filter picker popup.
496///
497/// `selected_idx` is 0-based within the list (0 = "All", 1+ = tag entries).
498/// `active_filter` is the currently active tag filter (highlighted in title).
499pub fn render_tag_filter_popup(
500    frame: &mut Frame,
501    tags: &[String],
502    selected_idx: usize,
503    active_filter: Option<&str>,
504    theme: &Theme,
505) {
506    let area = centred_rect(40, 60, frame.area());
507    frame.render_widget(Clear, area);
508
509    let title = match active_filter {
510        Some(t) => format!(" Filter by tag [{}] ", t),
511        None => " Filter by tag ".to_string(),
512    };
513
514    let block = Block::default()
515        .title(title)
516        .title_alignment(Alignment::Center)
517        .borders(Borders::ALL)
518        .border_type(BorderType::Rounded)
519        .border_style(Style::default().fg(theme.popup_border));
520
521    let inner = block.inner(area);
522    frame.render_widget(block, area);
523
524    // Build list items: "All" first, then each tag.
525    let mut items: Vec<ListItem> = vec![ListItem::new(Line::from(vec![
526        Span::styled("  ", Style::default()),
527        Span::styled(
528            "All (clear filter)",
529            Style::default()
530                .fg(theme.text_secondary)
531                .add_modifier(Modifier::ITALIC),
532        ),
533    ]))];
534
535    for tag in tags {
536        items.push(ListItem::new(Line::from(vec![
537            Span::styled("  ", Style::default()),
538            Span::styled(tag.as_str(), Style::default().fg(theme.text_primary)),
539        ])));
540    }
541
542    let list = List::new(items)
543        .highlight_style(
544            Style::default()
545                .fg(theme.form_focused_fg)
546                .bg(theme.accent)
547                .add_modifier(Modifier::BOLD),
548        )
549        .highlight_symbol("▶ ");
550
551    let mut list_state = ListState::default().with_selected(Some(selected_idx));
552    frame.render_stateful_widget(list, inner, &mut list_state);
553
554    // Hint at the bottom if space allows.
555    if inner.height > 3 {
556        let hint_area = Rect {
557            x: inner.x,
558            y: inner.y + inner.height.saturating_sub(1),
559            width: inner.width,
560            height: 1,
561        };
562        frame.render_widget(
563            Paragraph::new(Line::from(vec![
564                Span::styled(
565                    "Enter",
566                    Style::default()
567                        .fg(theme.text_success)
568                        .add_modifier(Modifier::BOLD),
569                ),
570                Span::styled(":select  ", Style::default().fg(theme.text_muted)),
571                Span::styled(
572                    "Esc",
573                    Style::default()
574                        .fg(theme.text_warning)
575                        .add_modifier(Modifier::BOLD),
576                ),
577                Span::styled(":close", Style::default().fg(theme.text_muted)),
578            ])),
579            hint_area,
580        );
581    }
582}
583
584/// Renders a small delete-confirmation popup.
585pub fn render_delete_confirm(frame: &mut Frame, host_name: &str, theme: &Theme) {
586    let area = centred_rect(50, 25, frame.area());
587    frame.render_widget(Clear, area);
588
589    let block = Block::default()
590        .title(" Confirm Delete ")
591        .title_alignment(Alignment::Center)
592        .borders(Borders::ALL)
593        .border_type(BorderType::Rounded)
594        .border_style(Style::default().fg(theme.danger_border));
595
596    let inner = block.inner(area);
597    frame.render_widget(block, area);
598
599    let rows = Layout::default()
600        .direction(Direction::Vertical)
601        .constraints([
602            Constraint::Length(1),
603            Constraint::Length(1),
604            Constraint::Length(1),
605            Constraint::Length(1),
606            Constraint::Min(0),
607        ])
608        .split(inner);
609
610    frame.render_widget(
611        Paragraph::new(Line::from(Span::styled(
612            format!("  Delete host '{}'?", host_name),
613            Style::default()
614                .fg(theme.text_primary)
615                .add_modifier(Modifier::BOLD),
616        ))),
617        rows[1],
618    );
619
620    frame.render_widget(
621        Paragraph::new(Line::from(vec![Span::styled(
622            "  This cannot be undone.  ",
623            Style::default().fg(theme.text_muted),
624        )])),
625        rows[2],
626    );
627
628    frame.render_widget(
629        Paragraph::new(Line::from(vec![
630            Span::styled("  ", Style::default()),
631            Span::styled(
632                "y",
633                Style::default()
634                    .fg(theme.text_error)
635                    .add_modifier(Modifier::BOLD),
636            ),
637            Span::styled(":Yes  ", Style::default().fg(theme.text_muted)),
638            Span::styled(
639                "n / Esc",
640                Style::default()
641                    .fg(theme.text_success)
642                    .add_modifier(Modifier::BOLD),
643            ),
644            Span::styled(":No", Style::default().fg(theme.text_muted)),
645        ])),
646        rows[3],
647    );
648}
649
650// ---------------------------------------------------------------------------
651// Snippet popups
652// ---------------------------------------------------------------------------
653
654/// Renders the snippet add/edit form popup.
655pub fn render_snippet_form(frame: &mut Frame, form: &SnippetForm, title: &str, theme: &Theme) {
656    let area = centred_rect(70, 85, frame.area());
657    frame.render_widget(Clear, area);
658
659    let block = Block::default()
660        .title(format!(" {} ", title))
661        .title_alignment(Alignment::Center)
662        .borders(Borders::ALL)
663        .border_type(BorderType::Rounded)
664        .border_style(Style::default().fg(theme.success_border));
665
666    let inner = block.inner(area);
667    frame.render_widget(block, area);
668
669    let num_fields = SNIPPET_FORM_FIELD_LABELS.len();
670    // 1 blank + 2 lines per field (label + input) + spacer + hint
671    let mut constraints: Vec<Constraint> = Vec::with_capacity(num_fields * 2 + 3);
672    constraints.push(Constraint::Length(1));
673    for _ in 0..num_fields {
674        constraints.push(Constraint::Length(1));
675        constraints.push(Constraint::Length(1));
676    }
677    constraints.push(Constraint::Length(1));
678    constraints.push(Constraint::Length(1));
679    constraints.push(Constraint::Min(0));
680
681    let rows = Layout::default()
682        .direction(Direction::Vertical)
683        .constraints(constraints)
684        .split(inner);
685
686    let focused_style = Style::default()
687        .fg(theme.form_focused_fg)
688        .bg(theme.accent)
689        .add_modifier(Modifier::BOLD);
690    let normal_style = Style::default()
691        .fg(theme.text_primary)
692        .bg(theme.selected_bg);
693    let label_style = Style::default().fg(theme.text_secondary);
694    let focused_label_style = Style::default()
695        .fg(theme.accent)
696        .add_modifier(Modifier::BOLD);
697
698    for (i, label) in SNIPPET_FORM_FIELD_LABELS.iter().enumerate() {
699        let label_row = rows[1 + i * 2];
700        let input_row = rows[2 + i * 2];
701        let is_focused = i == form.focused_field;
702
703        frame.render_widget(
704            Paragraph::new(Line::from(Span::styled(
705                format!("  {}: ", label),
706                if is_focused {
707                    focused_label_style
708                } else {
709                    label_style
710                },
711            ))),
712            label_row,
713        );
714
715        let field = &form.fields[i];
716        let display = if is_focused {
717            let (before, after) = field.value.split_at(field.cursor.min(field.value.len()));
718            format!("  {}|{} ", before, after)
719        } else {
720            format!("  {} ", field.value)
721        };
722
723        frame.render_widget(
724            Paragraph::new(Line::from(Span::styled(
725                display,
726                if is_focused {
727                    focused_style
728                } else {
729                    normal_style
730                },
731            ))),
732            input_row,
733        );
734    }
735
736    let hint_row_idx = 1 + num_fields * 2 + 1;
737    if hint_row_idx < rows.len() {
738        frame.render_widget(
739            Paragraph::new(Line::from(vec![
740                Span::styled(
741                    "  Tab",
742                    Style::default()
743                        .fg(theme.accent)
744                        .add_modifier(Modifier::BOLD),
745                ),
746                Span::styled(":next  ", Style::default().fg(theme.text_muted)),
747                Span::styled(
748                    "Enter",
749                    Style::default()
750                        .fg(theme.text_success)
751                        .add_modifier(Modifier::BOLD),
752                ),
753                Span::styled(":save  ", Style::default().fg(theme.text_muted)),
754                Span::styled(
755                    "Esc",
756                    Style::default()
757                        .fg(theme.text_warning)
758                        .add_modifier(Modifier::BOLD),
759                ),
760                Span::styled(":cancel", Style::default().fg(theme.text_muted)),
761            ])),
762            rows[hint_row_idx],
763        );
764    }
765}
766
767/// Renders a delete-confirmation popup for a snippet.
768pub fn render_snippet_delete_confirm(frame: &mut Frame, snippet_name: &str, theme: &Theme) {
769    let area = centred_rect(55, 25, frame.area());
770    frame.render_widget(Clear, area);
771
772    let block = Block::default()
773        .title(" Confirm Delete Snippet ")
774        .title_alignment(Alignment::Center)
775        .borders(Borders::ALL)
776        .border_type(BorderType::Rounded)
777        .border_style(Style::default().fg(theme.danger_border));
778
779    let inner = block.inner(area);
780    frame.render_widget(block, area);
781
782    let rows = Layout::default()
783        .direction(Direction::Vertical)
784        .constraints([
785            Constraint::Length(1),
786            Constraint::Length(1),
787            Constraint::Length(1),
788            Constraint::Length(1),
789            Constraint::Min(0),
790        ])
791        .split(inner);
792
793    frame.render_widget(
794        Paragraph::new(Line::from(Span::styled(
795            format!("  Delete snippet '{}'?", snippet_name),
796            Style::default()
797                .fg(theme.text_primary)
798                .add_modifier(Modifier::BOLD),
799        ))),
800        rows[1],
801    );
802
803    frame.render_widget(
804        Paragraph::new(Line::from(Span::styled(
805            "  This cannot be undone.",
806            Style::default().fg(theme.text_muted),
807        ))),
808        rows[2],
809    );
810
811    frame.render_widget(
812        Paragraph::new(Line::from(vec![
813            Span::styled("  ", Style::default()),
814            Span::styled(
815                "y",
816                Style::default()
817                    .fg(theme.text_error)
818                    .add_modifier(Modifier::BOLD),
819            ),
820            Span::styled(":Yes  ", Style::default().fg(theme.text_muted)),
821            Span::styled(
822                "n / Esc",
823                Style::default()
824                    .fg(theme.text_success)
825                    .add_modifier(Modifier::BOLD),
826            ),
827            Span::styled(":No", Style::default().fg(theme.text_muted)),
828        ])),
829        rows[3],
830    );
831}
832
833/// Renders the parameter input form for parameterised snippets.
834pub fn render_param_input(
835    frame: &mut Frame,
836    snippet_name: &str,
837    param_names: &[String],
838    param_fields: &[FormField],
839    focused_field: usize,
840    theme: &Theme,
841) {
842    let area = centred_rect(60, 70, frame.area());
843    frame.render_widget(Clear, area);
844
845    let block = Block::default()
846        .title(format!(" Parameters — {} ", snippet_name))
847        .title_alignment(Alignment::Center)
848        .borders(Borders::ALL)
849        .border_type(BorderType::Rounded)
850        .border_style(Style::default().fg(theme.text_warning));
851
852    let inner = block.inner(area);
853    frame.render_widget(block, area);
854
855    let n = param_names.len();
856    let mut constraints = vec![Constraint::Length(1)];
857    for _ in 0..n {
858        constraints.push(Constraint::Length(1));
859        constraints.push(Constraint::Length(1));
860    }
861    constraints.push(Constraint::Length(1));
862    constraints.push(Constraint::Length(1));
863    constraints.push(Constraint::Min(0));
864
865    let rows = Layout::default()
866        .direction(Direction::Vertical)
867        .constraints(constraints)
868        .split(inner);
869
870    let focused_style = Style::default()
871        .fg(theme.form_focused_fg)
872        .bg(theme.warning_border)
873        .add_modifier(Modifier::BOLD);
874    let normal_style = Style::default()
875        .fg(theme.text_primary)
876        .bg(theme.selected_bg);
877
878    for i in 0..n {
879        let label_row = rows[1 + i * 2];
880        let input_row = rows[2 + i * 2];
881        let is_focused = i == focused_field;
882
883        frame.render_widget(
884            Paragraph::new(Line::from(Span::styled(
885                format!("  {{{{{}}}}}:", param_names[i]),
886                Style::default()
887                    .fg(if is_focused {
888                        Color::Yellow
889                    } else {
890                        Color::Gray
891                    })
892                    .add_modifier(if is_focused {
893                        Modifier::BOLD
894                    } else {
895                        Modifier::empty()
896                    }),
897            ))),
898            label_row,
899        );
900
901        let field = &param_fields[i];
902        let display = if is_focused {
903            let (before, after) = field.value.split_at(field.cursor.min(field.value.len()));
904            format!("  {}|{} ", before, after)
905        } else {
906            format!("  {} ", field.value)
907        };
908
909        frame.render_widget(
910            Paragraph::new(Line::from(Span::styled(
911                display,
912                if is_focused {
913                    focused_style
914                } else {
915                    normal_style
916                },
917            ))),
918            input_row,
919        );
920    }
921
922    let hint_row_idx = 1 + n * 2 + 1;
923    if hint_row_idx < rows.len() {
924        frame.render_widget(
925            Paragraph::new(Line::from(vec![
926                Span::styled(
927                    "  Tab",
928                    Style::default()
929                        .fg(theme.text_warning)
930                        .add_modifier(Modifier::BOLD),
931                ),
932                Span::styled(":next  ", Style::default().fg(theme.text_muted)),
933                Span::styled(
934                    "Enter",
935                    Style::default()
936                        .fg(theme.text_success)
937                        .add_modifier(Modifier::BOLD),
938                ),
939                Span::styled(":run  ", Style::default().fg(theme.text_muted)),
940                Span::styled(
941                    "Esc",
942                    Style::default()
943                        .fg(theme.text_warning)
944                        .add_modifier(Modifier::BOLD),
945                ),
946                Span::styled(":cancel", Style::default().fg(theme.text_muted)),
947            ])),
948            rows[hint_row_idx],
949        );
950    }
951}
952
953/// Renders the broadcast host-picker popup.
954pub fn render_broadcast_picker(
955    frame: &mut Frame,
956    hosts: &[Host],
957    selected_host_indices: &[usize],
958    cursor: usize,
959    theme: &Theme,
960) {
961    let area = centred_rect(60, 75, frame.area());
962    frame.render_widget(Clear, area);
963
964    let block = Block::default()
965        .title(" Broadcast — Select Hosts ")
966        .title_alignment(Alignment::Center)
967        .borders(Borders::ALL)
968        .border_type(BorderType::Rounded)
969        .border_style(Style::default().fg(theme.popup_border));
970
971    let inner = block.inner(area);
972    frame.render_widget(block, area);
973
974    // Reserve bottom 1 line for hint.
975    let list_height = inner.height.saturating_sub(1) as usize;
976    let list_area = Rect {
977        height: inner.height.saturating_sub(1),
978        ..inner
979    };
980    let hint_area = Rect {
981        y: inner.y + inner.height.saturating_sub(1),
982        height: 1,
983        ..inner
984    };
985
986    // Scroll to keep cursor visible.
987    let offset = if cursor >= list_height {
988        cursor - list_height + 1
989    } else {
990        0
991    };
992
993    let items: Vec<ListItem> = hosts
994        .iter()
995        .enumerate()
996        .skip(offset)
997        .take(list_height)
998        .map(|(i, h)| {
999            let checked = selected_host_indices.contains(&i);
1000            let is_cursor = i == cursor;
1001            let checkbox = if checked {
1002                Span::styled(
1003                    "[x] ",
1004                    Style::default()
1005                        .fg(theme.text_success)
1006                        .add_modifier(Modifier::BOLD),
1007                )
1008            } else {
1009                Span::styled("[ ] ", Style::default().fg(theme.text_muted))
1010            };
1011            let name = Span::styled(
1012                h.name.as_str(),
1013                if is_cursor {
1014                    Style::default()
1015                        .fg(theme.text_primary)
1016                        .add_modifier(Modifier::BOLD)
1017                } else {
1018                    Style::default().fg(theme.text_secondary)
1019                },
1020            );
1021            let item = ListItem::new(Line::from(vec![Span::raw("  "), checkbox, name]));
1022            if is_cursor {
1023                item.style(Style::default().bg(theme.selected_bg))
1024            } else {
1025                item
1026            }
1027        })
1028        .collect();
1029
1030    let list = List::new(items);
1031    frame.render_widget(list, list_area);
1032
1033    frame.render_widget(
1034        Paragraph::new(Line::from(vec![
1035            Span::styled(
1036                "Space",
1037                Style::default()
1038                    .fg(theme.accent)
1039                    .add_modifier(Modifier::BOLD),
1040            ),
1041            Span::styled(":toggle  ", Style::default().fg(theme.text_muted)),
1042            Span::styled(
1043                "Enter",
1044                Style::default()
1045                    .fg(theme.text_success)
1046                    .add_modifier(Modifier::BOLD),
1047            ),
1048            Span::styled(":run  ", Style::default().fg(theme.text_muted)),
1049            Span::styled(
1050                "Esc",
1051                Style::default()
1052                    .fg(theme.text_warning)
1053                    .add_modifier(Modifier::BOLD),
1054            ),
1055            Span::styled(":cancel", Style::default().fg(theme.text_muted)),
1056        ])),
1057        hint_area,
1058    );
1059}
1060
1061/// Renders the passphrase prompt for an encrypted identity file.
1062pub fn render_passphrase_prompt(frame: &mut Frame, prompt: &PassphrasePrompt, theme: &Theme) {
1063    let input = if prompt.unlocking {
1064        String::from("  Unlocking…")
1065    } else {
1066        masked(&prompt.field)
1067    };
1068    render_secret_prompt(
1069        frame,
1070        theme,
1071        SecretPrompt {
1072            title: format!(" Unlock SSH key — {} ", prompt.host_name),
1073            detail: &prompt.key_path,
1074            label: "  Passphrase (kept in memory until OmnySSH exits):",
1075            input,
1076            error: prompt.error.clone(),
1077            action: ":unlock  ",
1078        },
1079    );
1080}
1081
1082/// Renders the prompt for a login password a connection waits on.
1083pub fn render_password_prompt(frame: &mut Frame, prompt: &PasswordPrompt, theme: &Theme) {
1084    render_secret_prompt(
1085        frame,
1086        theme,
1087        SecretPrompt {
1088            title: format!(" SSH login — {} ", prompt.host_name),
1089            detail: &prompt.login,
1090            label: "  Password (kept in memory until OmnySSH exits):",
1091            input: masked(&prompt.field),
1092            error: prompt
1093                .retry
1094                .then(|| String::from("Permission denied, please try again."))
1095                .or_else(|| {
1096                    prompt
1097                        .new_host_key
1098                        .as_ref()
1099                        .map(|key| format!("New host key recorded: {key}"))
1100                }),
1101            action: ":log in  ",
1102        },
1103    );
1104}
1105
1106fn masked(field: &FormField) -> String {
1107    format!("  {}| ", "*".repeat(field.value.chars().count()))
1108}
1109
1110/// What a prompt for a secret shows; the secret itself only as stars.
1111struct SecretPrompt<'a> {
1112    title: String,
1113    detail: &'a str,
1114    label: &'a str,
1115    input: String,
1116    error: Option<String>,
1117    /// The Enter hint, e.g. `":unlock  "`.
1118    action: &'a str,
1119}
1120
1121fn render_secret_prompt(frame: &mut Frame, theme: &Theme, prompt: SecretPrompt<'_>) {
1122    // Six text rows plus the border, whatever the frame height.
1123    let screen = frame.area();
1124    let width = (screen.width * 7 / 10).max(60).min(screen.width);
1125    let height = 8.min(screen.height);
1126    let area = Rect::new(
1127        screen.x + (screen.width - width) / 2,
1128        screen.y + (screen.height - height) / 2,
1129        width,
1130        height,
1131    );
1132    frame.render_widget(Clear, area);
1133
1134    let block = Block::default()
1135        .title(prompt.title)
1136        .title_alignment(Alignment::Center)
1137        .borders(Borders::ALL)
1138        .border_type(BorderType::Rounded)
1139        .border_style(Style::default().fg(theme.accent));
1140
1141    let inner = block.inner(area);
1142    frame.render_widget(block, area);
1143
1144    let rows = Layout::default()
1145        .direction(Direction::Vertical)
1146        .constraints([Constraint::Length(1); 6])
1147        .split(inner);
1148
1149    frame.render_widget(
1150        Paragraph::new(Line::from(Span::styled(
1151            format!("  {}", prompt.detail),
1152            Style::default().fg(theme.text_secondary),
1153        ))),
1154        rows[0],
1155    );
1156    frame.render_widget(
1157        Paragraph::new(Line::from(Span::styled(
1158            prompt.label,
1159            Style::default().fg(theme.text_primary),
1160        ))),
1161        rows[1],
1162    );
1163    frame.render_widget(
1164        Paragraph::new(Line::from(Span::styled(
1165            prompt.input,
1166            Style::default()
1167                .fg(theme.form_focused_fg)
1168                .bg(theme.success_border)
1169                .add_modifier(Modifier::BOLD),
1170        ))),
1171        rows[2],
1172    );
1173
1174    if let Some(error) = prompt.error {
1175        frame.render_widget(
1176            Paragraph::new(Line::from(Span::styled(
1177                format!("  {error}"),
1178                Style::default().fg(theme.text_error),
1179            ))),
1180            rows[3],
1181        );
1182    }
1183
1184    frame.render_widget(
1185        Paragraph::new(Line::from(vec![
1186            Span::styled(
1187                "  Enter",
1188                Style::default()
1189                    .fg(theme.text_success)
1190                    .add_modifier(Modifier::BOLD),
1191            ),
1192            Span::styled(prompt.action, Style::default().fg(theme.text_muted)),
1193            Span::styled(
1194                "Esc",
1195                Style::default()
1196                    .fg(theme.text_warning)
1197                    .add_modifier(Modifier::BOLD),
1198            ),
1199            Span::styled(":cancel", Style::default().fg(theme.text_muted)),
1200        ])),
1201        rows[5],
1202    );
1203}
1204
1205/// Renders the single-line quick-execute command-input popup.
1206pub fn render_quick_execute_input(
1207    frame: &mut Frame,
1208    host_name: &str,
1209    command_field: &FormField,
1210    theme: &Theme,
1211) {
1212    let area = centred_rect(65, 20, frame.area());
1213    frame.render_widget(Clear, area);
1214
1215    let block = Block::default()
1216        .title(format!(" Quick Execute — {} ", host_name))
1217        .title_alignment(Alignment::Center)
1218        .borders(Borders::ALL)
1219        .border_type(BorderType::Rounded)
1220        .border_style(Style::default().fg(theme.success_border));
1221
1222    let inner = block.inner(area);
1223    frame.render_widget(block, area);
1224
1225    if inner.height < 2 {
1226        return;
1227    }
1228
1229    let rows = Layout::default()
1230        .direction(Direction::Vertical)
1231        .constraints([
1232            Constraint::Length(1),
1233            Constraint::Length(1),
1234            Constraint::Min(0),
1235        ])
1236        .split(inner);
1237
1238    // Command input row with cursor.
1239    let (before, after) = command_field
1240        .value
1241        .split_at(command_field.cursor.min(command_field.value.len()));
1242    let display = format!("  {}|{} ", before, after);
1243    frame.render_widget(
1244        Paragraph::new(Line::from(Span::styled(
1245            display,
1246            Style::default()
1247                .fg(theme.form_focused_fg)
1248                .bg(theme.success_border)
1249                .add_modifier(Modifier::BOLD),
1250        ))),
1251        rows[0],
1252    );
1253
1254    // Hint row.
1255    frame.render_widget(
1256        Paragraph::new(Line::from(vec![
1257            Span::styled(
1258                "  Enter",
1259                Style::default()
1260                    .fg(theme.text_success)
1261                    .add_modifier(Modifier::BOLD),
1262            ),
1263            Span::styled(":run  ", Style::default().fg(theme.text_muted)),
1264            Span::styled(
1265                "Esc",
1266                Style::default()
1267                    .fg(theme.text_warning)
1268                    .add_modifier(Modifier::BOLD),
1269            ),
1270            Span::styled(":cancel", Style::default().fg(theme.text_muted)),
1271        ])),
1272        rows[1],
1273    );
1274}
1275
1276/// Spinner frames for pending execution indicator.
1277const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1278const _: () = assert!(
1279    !SPINNER_FRAMES.is_empty(),
1280    "SPINNER_FRAMES must not be empty"
1281);
1282
1283/// Renders the snippet execution results popup.
1284pub fn render_snippet_results(
1285    frame: &mut Frame,
1286    entries: &[SnippetResultEntry],
1287    scroll: usize,
1288    tick_count: u64,
1289    theme: &Theme,
1290) {
1291    let area = centred_rect(80, 85, frame.area());
1292    frame.render_widget(Clear, area);
1293
1294    let spinner = SPINNER_FRAMES[(tick_count as usize / 2) % SPINNER_FRAMES.len()];
1295
1296    let block = Block::default()
1297        .title(" Results ")
1298        .title_alignment(Alignment::Center)
1299        .borders(Borders::ALL)
1300        .border_type(BorderType::Rounded)
1301        .border_style(Style::default().fg(theme.popup_border));
1302
1303    let inner = block.inner(area);
1304    frame.render_widget(block, area);
1305
1306    if entries.is_empty() {
1307        return;
1308    }
1309
1310    if entries.len() == 1 {
1311        // Single host — full inner area with scrollable text.
1312        render_single_result(frame, inner, &entries[0], scroll, spinner, theme);
1313    } else {
1314        // Multiple hosts — split vertically, one section per host.
1315        let n = entries.len().min(6); // cap at 6 to avoid tiny sections
1316        let constraints: Vec<Constraint> = (0..n).map(|_| Constraint::Min(3)).collect();
1317        let sections = Layout::default()
1318            .direction(Direction::Vertical)
1319            .constraints(constraints)
1320            .split(inner);
1321        for (i, entry) in entries.iter().take(n).enumerate() {
1322            render_single_result(frame, sections[i], entry, 0, spinner, theme);
1323        }
1324    }
1325}
1326
1327/// Helper: renders one host's result into `area`.
1328fn render_single_result(
1329    frame: &mut Frame,
1330    area: Rect,
1331    entry: &SnippetResultEntry,
1332    scroll: usize,
1333    spinner: char,
1334    theme: &Theme,
1335) {
1336    if area.height < 2 {
1337        return;
1338    }
1339
1340    // Header line.
1341    let header_area = Rect { height: 1, ..area };
1342    let body_area = Rect {
1343        y: area.y + 1,
1344        height: area.height.saturating_sub(1),
1345        ..area
1346    };
1347
1348    let status_span = if entry.pending {
1349        Span::styled(
1350            format!(" {} Running… ", spinner),
1351            Style::default().fg(theme.text_warning),
1352        )
1353    } else if entry.output.is_ok() {
1354        Span::styled(" ✓ Done ", Style::default().fg(theme.text_success))
1355    } else {
1356        Span::styled(" ✗ Error ", Style::default().fg(theme.text_error))
1357    };
1358
1359    frame.render_widget(
1360        Paragraph::new(Line::from(vec![
1361            Span::styled(
1362                format!(" {} — {} ", entry.host_name, entry.snippet_name),
1363                Style::default()
1364                    .fg(theme.text_primary)
1365                    .add_modifier(Modifier::BOLD),
1366            ),
1367            status_span,
1368        ])),
1369        header_area,
1370    );
1371
1372    if area.height < 3 {
1373        return;
1374    }
1375
1376    // Body: output text or error message.
1377    let text = match &entry.output {
1378        Ok(out) if !out.is_empty() => out.as_str(),
1379        Ok(_) if entry.pending => "",
1380        Ok(_) => "(no output)",
1381        Err(err) => err.as_str(),
1382    };
1383
1384    let text_color = if entry.output.is_err() {
1385        theme.text_error
1386    } else {
1387        theme.text_secondary
1388    };
1389
1390    let lines: Vec<Line> = text
1391        .lines()
1392        .skip(scroll)
1393        .map(|l| {
1394            Line::from(Span::styled(
1395                format!(" {}", l),
1396                Style::default().fg(text_color),
1397            ))
1398        })
1399        .collect();
1400
1401    let hint = if !entry.pending {
1402        Line::from(vec![
1403            Span::styled(
1404                "j/k",
1405                Style::default()
1406                    .fg(theme.accent)
1407                    .add_modifier(Modifier::BOLD),
1408            ),
1409            Span::styled(":scroll  ", Style::default().fg(theme.text_muted)),
1410            Span::styled(
1411                "Esc",
1412                Style::default()
1413                    .fg(theme.text_warning)
1414                    .add_modifier(Modifier::BOLD),
1415            ),
1416            Span::styled(":close", Style::default().fg(theme.text_muted)),
1417        ])
1418    } else {
1419        Line::from(Span::styled(
1420            "  Waiting for result…",
1421            Style::default().fg(theme.text_muted),
1422        ))
1423    };
1424
1425    // Reserve last line for hint.
1426    let text_area = Rect {
1427        height: body_area.height.saturating_sub(1),
1428        ..body_area
1429    };
1430    let hint_area = Rect {
1431        y: body_area.y + body_area.height.saturating_sub(1),
1432        height: 1,
1433        ..body_area
1434    };
1435
1436    frame.render_widget(Paragraph::new(lines), text_area);
1437    frame.render_widget(Paragraph::new(hint), hint_area);
1438}
1439
1440// ---------------------------------------------------------------------------
1441// SSH Key Setup popups
1442// ---------------------------------------------------------------------------
1443
1444/// Renders the SSH key setup confirmation dialog.
1445///
1446/// Shows host name, warns about the operation (disabling password auth),
1447/// and offers y/Enter to confirm or n/Esc to cancel.
1448pub fn render_key_setup_confirm(
1449    frame: &mut Frame,
1450    host: Option<&omnyssh_core::ssh::client::Host>,
1451    theme: &Theme,
1452) {
1453    let area = centred_rect(55, 40, frame.area());
1454    frame.render_widget(Clear, area);
1455
1456    let block = Block::default()
1457        .title(" SSH Key Setup ")
1458        .title_alignment(Alignment::Center)
1459        .borders(Borders::ALL)
1460        .border_type(BorderType::Rounded)
1461        .border_style(Style::default().fg(theme.warning_border));
1462
1463    let inner = block.inner(area);
1464    frame.render_widget(block, area);
1465
1466    let rows = Layout::default()
1467        .direction(Direction::Vertical)
1468        .constraints([
1469            Constraint::Length(1), // top padding
1470            Constraint::Length(1), // title
1471            Constraint::Length(1), // blank
1472            Constraint::Length(1), // host name
1473            Constraint::Length(1), // blank
1474            Constraint::Length(1), // warning 1
1475            Constraint::Length(1), // warning 2
1476            Constraint::Length(1), // warning 3
1477            Constraint::Length(1), // blank
1478            Constraint::Length(1), // hint
1479            Constraint::Min(0),
1480        ])
1481        .split(inner);
1482
1483    let host_name = host.map(|h| h.name.as_str()).unwrap_or("?");
1484    let host_addr = host.map(|h| h.hostname.as_str()).unwrap_or("?");
1485
1486    frame.render_widget(
1487        Paragraph::new(Line::from(Span::styled(
1488            "  Configure SSH key authentication",
1489            Style::default()
1490                .fg(theme.text_primary)
1491                .add_modifier(Modifier::BOLD),
1492        ))),
1493        rows[1],
1494    );
1495
1496    frame.render_widget(
1497        Paragraph::new(Line::from(vec![
1498            Span::styled("  Host: ", Style::default().fg(theme.text_secondary)),
1499            Span::styled(
1500                format!("{} ({})", host_name, host_addr),
1501                Style::default()
1502                    .fg(theme.accent)
1503                    .add_modifier(Modifier::BOLD),
1504            ),
1505        ])),
1506        rows[3],
1507    );
1508
1509    frame.render_widget(
1510        Paragraph::new(Line::from(Span::styled(
1511            "  This will:",
1512            Style::default().fg(theme.text_secondary),
1513        ))),
1514        rows[5],
1515    );
1516
1517    frame.render_widget(
1518        Paragraph::new(Line::from(Span::styled(
1519            "  • Generate an Ed25519 key pair",
1520            Style::default().fg(theme.text_secondary),
1521        ))),
1522        rows[6],
1523    );
1524
1525    frame.render_widget(
1526        Paragraph::new(Line::from(Span::styled(
1527            "  • Disable password auth on server (if sudo available)",
1528            Style::default().fg(theme.text_warning),
1529        ))),
1530        rows[7],
1531    );
1532
1533    frame.render_widget(
1534        Paragraph::new(Line::from(vec![
1535            Span::styled("  ", Style::default()),
1536            Span::styled(
1537                "y / Enter",
1538                Style::default()
1539                    .fg(theme.text_success)
1540                    .add_modifier(Modifier::BOLD),
1541            ),
1542            Span::styled(":confirm  ", Style::default().fg(theme.text_muted)),
1543            Span::styled(
1544                "n / Esc",
1545                Style::default()
1546                    .fg(theme.text_warning)
1547                    .add_modifier(Modifier::BOLD),
1548            ),
1549            Span::styled(":cancel", Style::default().fg(theme.text_muted)),
1550        ])),
1551        rows[9],
1552    );
1553}
1554
1555/// Renders the SSH key setup progress dialog.
1556///
1557/// Shows all 6 steps with status indicators:
1558/// - `✓` for completed steps (green)
1559/// - Animated spinner for the current step (yellow)
1560/// - `·` for pending steps (dimmed)
1561pub fn render_key_setup_progress(
1562    frame: &mut Frame,
1563    host_name: &str,
1564    current_step: Option<&omnyssh_core::ssh::key_setup::KeySetupStep>,
1565    theme: &Theme,
1566) {
1567    use omnyssh_core::ssh::key_setup::KeySetupStep;
1568
1569    let area = centred_rect(55, 55, frame.area());
1570    frame.render_widget(Clear, area);
1571
1572    let block = Block::default()
1573        .title(format!(" Key Setup — {} ", host_name))
1574        .title_alignment(Alignment::Center)
1575        .borders(Borders::ALL)
1576        .border_type(BorderType::Rounded)
1577        .border_style(Style::default().fg(theme.warning_border));
1578
1579    let inner = block.inner(area);
1580    frame.render_widget(block, area);
1581
1582    let all_steps = KeySetupStep::all_steps();
1583
1584    // 1 top padding + 1 header + 1 blank + 6 steps + 1 blank + 1 hint + remainder
1585    let mut constraints = vec![
1586        Constraint::Length(1), // top padding
1587        Constraint::Length(1), // header
1588        Constraint::Length(1), // blank
1589    ];
1590    for _ in &all_steps {
1591        constraints.push(Constraint::Length(1));
1592    }
1593    constraints.push(Constraint::Length(1)); // blank
1594    constraints.push(Constraint::Length(1)); // hint
1595    constraints.push(Constraint::Min(0));
1596
1597    let rows = Layout::default()
1598        .direction(Direction::Vertical)
1599        .constraints(constraints)
1600        .split(inner);
1601
1602    // Header
1603    frame.render_widget(
1604        Paragraph::new(Line::from(Span::styled(
1605            "  Setting up SSH key authentication…",
1606            Style::default()
1607                .fg(theme.text_primary)
1608                .add_modifier(Modifier::BOLD),
1609        ))),
1610        rows[1],
1611    );
1612
1613    // Determine the numeric index of the current step (1-based).
1614    let current_idx = current_step.map(|s| *s as usize);
1615
1616    // Spinner character for the active step.
1617    let spinner_char = SPINNER_FRAMES[(frame.count() / 2) % SPINNER_FRAMES.len()];
1618
1619    for (i, step) in all_steps.iter().enumerate() {
1620        let step_num = *step as usize; // 1-based
1621        let row = rows[3 + i];
1622
1623        let (icon, icon_style, desc_style) = if let Some(cur) = current_idx {
1624            if step_num < cur {
1625                // Completed
1626                (
1627                    "  ✓ ".to_string(),
1628                    Style::default()
1629                        .fg(theme.text_success)
1630                        .add_modifier(Modifier::BOLD),
1631                    Style::default().fg(theme.text_secondary),
1632                )
1633            } else if step_num == cur {
1634                // Current — animated spinner
1635                (
1636                    format!("  {} ", spinner_char),
1637                    Style::default()
1638                        .fg(theme.text_warning)
1639                        .add_modifier(Modifier::BOLD),
1640                    Style::default()
1641                        .fg(theme.text_warning)
1642                        .add_modifier(Modifier::BOLD),
1643                )
1644            } else {
1645                // Pending
1646                (
1647                    "  · ".to_string(),
1648                    Style::default().fg(theme.text_muted),
1649                    Style::default().fg(theme.text_muted),
1650                )
1651            }
1652        } else {
1653            // No step started yet — all pending
1654            (
1655                "  · ".to_string(),
1656                Style::default().fg(theme.text_muted),
1657                Style::default().fg(theme.text_muted),
1658            )
1659        };
1660
1661        frame.render_widget(
1662            Paragraph::new(Line::from(vec![
1663                Span::styled(icon, icon_style),
1664                Span::styled(step.description(), desc_style),
1665            ])),
1666            row,
1667        );
1668    }
1669
1670    // Hint at the bottom
1671    let hint_row = rows[3 + all_steps.len() + 1];
1672    let is_done = current_step.is_some_and(|s| *s as usize >= 6);
1673    if is_done {
1674        frame.render_widget(
1675            Paragraph::new(Line::from(vec![
1676                Span::styled(
1677                    "  Esc",
1678                    Style::default()
1679                        .fg(theme.text_warning)
1680                        .add_modifier(Modifier::BOLD),
1681                ),
1682                Span::styled(":close", Style::default().fg(theme.text_muted)),
1683            ])),
1684            hint_row,
1685        );
1686    } else {
1687        frame.render_widget(
1688            Paragraph::new(Line::from(Span::styled(
1689                "  Please wait…",
1690                Style::default()
1691                    .fg(theme.text_muted)
1692                    .add_modifier(Modifier::ITALIC),
1693            ))),
1694            hint_row,
1695        );
1696    }
1697}
1698
1699// ---------------------------------------------------------------------------
1700// Update popup
1701// ---------------------------------------------------------------------------
1702
1703/// Label shown on an update-popup button.
1704fn update_button_label(button: UpdateButton, can_self_update: bool) -> &'static str {
1705    match button {
1706        UpdateButton::Primary if can_self_update => "Update now",
1707        UpdateButton::Primary => "Remind me later",
1708        UpdateButton::Skip => "Skip this version",
1709        UpdateButton::Disable => "Don't check again",
1710    }
1711}
1712
1713/// Renders the startup "update available" popup.
1714pub fn render_update(frame: &mut Frame, popup: &UpdatePopup, theme: &Theme) {
1715    let area = centred_rect(62, 42, frame.area());
1716    frame.render_widget(Clear, area);
1717
1718    let block = Block::default()
1719        .title(" Update Available ")
1720        .title_alignment(Alignment::Center)
1721        .borders(Borders::ALL)
1722        .border_type(BorderType::Rounded)
1723        .border_style(Style::default().fg(theme.accent));
1724
1725    let inner = block.inner(area);
1726    frame.render_widget(block, area);
1727
1728    // Reserve the last two rows for the action buttons and the hint.
1729    let rows = Layout::default()
1730        .direction(Direction::Vertical)
1731        .constraints([
1732            Constraint::Min(1),
1733            Constraint::Length(1),
1734            Constraint::Length(1),
1735        ])
1736        .split(inner);
1737
1738    // ── Body text ──────────────────────────────────────────────
1739    let mut lines: Vec<Line> = vec![
1740        Line::from(""),
1741        Line::from(vec![
1742            Span::styled("  Version  ", Style::default().fg(theme.text_secondary)),
1743            Span::styled(
1744                popup.info.current.clone(),
1745                Style::default().fg(theme.text_muted),
1746            ),
1747            Span::styled("  →  ", Style::default().fg(theme.text_secondary)),
1748            Span::styled(
1749                popup.info.latest.clone(),
1750                Style::default()
1751                    .fg(theme.accent)
1752                    .add_modifier(Modifier::BOLD),
1753            ),
1754        ]),
1755        Line::from(""),
1756    ];
1757
1758    let info_style = Style::default().fg(theme.text_primary);
1759    let emphasis_style = Style::default()
1760        .fg(theme.text_warning)
1761        .add_modifier(Modifier::BOLD);
1762
1763    match &popup.phase {
1764        UpdatePopupPhase::Prompt { .. } => {
1765            if popup.info.can_self_update {
1766                lines.push(Line::from(Span::styled(
1767                    "  This update can be downloaded and installed now.",
1768                    info_style,
1769                )));
1770                lines.push(Line::from(Span::styled(
1771                    "  The archive checksum is verified before installing.",
1772                    Style::default().fg(theme.text_muted),
1773                )));
1774            } else if let Some(cmd) = popup.info.method.upgrade_command() {
1775                lines.push(Line::from(Span::styled(
1776                    "  Update it with your package manager:",
1777                    info_style,
1778                )));
1779                lines.push(Line::from(""));
1780                lines.push(Line::from(Span::styled(
1781                    format!("    {cmd}"),
1782                    emphasis_style,
1783                )));
1784            } else {
1785                lines.push(Line::from(Span::styled(
1786                    "  Download the new release from:",
1787                    info_style,
1788                )));
1789                lines.push(Line::from(""));
1790                lines.push(Line::from(Span::styled(
1791                    format!("    {}", popup.info.release_url()),
1792                    emphasis_style,
1793                )));
1794            }
1795        }
1796        UpdatePopupPhase::Installing => {
1797            let spinner = SPINNER_FRAMES[(frame.count() / 2) % SPINNER_FRAMES.len()];
1798            lines.push(Line::from(Span::styled(
1799                format!("  {spinner} Downloading and installing — please wait…"),
1800                Style::default().fg(theme.text_warning),
1801            )));
1802        }
1803        UpdatePopupPhase::Done { message, ok } => {
1804            let color = if *ok {
1805                theme.text_success
1806            } else {
1807                theme.text_error
1808            };
1809            for line in message.lines() {
1810                lines.push(Line::from(Span::styled(
1811                    format!("  {line}"),
1812                    Style::default().fg(color),
1813                )));
1814            }
1815        }
1816    }
1817
1818    frame.render_widget(Paragraph::new(lines), rows[0]);
1819
1820    // ── Action row ─────────────────────────────────────────────
1821    if let UpdatePopupPhase::Prompt { selected } = &popup.phase {
1822        let mut spans: Vec<Span> = vec![Span::raw("  ")];
1823        for (i, &button) in UPDATE_BUTTONS.iter().enumerate() {
1824            let label = update_button_label(button, popup.info.can_self_update);
1825            let style = if i == *selected {
1826                Style::default()
1827                    .fg(theme.form_focused_fg)
1828                    .bg(theme.accent)
1829                    .add_modifier(Modifier::BOLD)
1830            } else {
1831                Style::default().fg(theme.text_secondary)
1832            };
1833            spans.push(Span::styled(format!(" {label} "), style));
1834            spans.push(Span::raw("  "));
1835        }
1836        frame.render_widget(Paragraph::new(Line::from(spans)), rows[1]);
1837    }
1838
1839    // ── Hint row ───────────────────────────────────────────────
1840    let hint = match &popup.phase {
1841        UpdatePopupPhase::Prompt { .. } => Line::from(vec![
1842            Span::styled(
1843                "  ←/→",
1844                Style::default()
1845                    .fg(theme.accent)
1846                    .add_modifier(Modifier::BOLD),
1847            ),
1848            Span::styled(":select  ", Style::default().fg(theme.text_muted)),
1849            Span::styled(
1850                "Enter",
1851                Style::default()
1852                    .fg(theme.text_success)
1853                    .add_modifier(Modifier::BOLD),
1854            ),
1855            Span::styled(":confirm  ", Style::default().fg(theme.text_muted)),
1856            Span::styled(
1857                "Esc",
1858                Style::default()
1859                    .fg(theme.text_warning)
1860                    .add_modifier(Modifier::BOLD),
1861            ),
1862            Span::styled(":dismiss", Style::default().fg(theme.text_muted)),
1863        ]),
1864        UpdatePopupPhase::Installing => Line::from(Span::styled(
1865            "  Please wait…",
1866            Style::default()
1867                .fg(theme.text_muted)
1868                .add_modifier(Modifier::ITALIC),
1869        )),
1870        UpdatePopupPhase::Done { .. } => Line::from(vec![
1871            Span::styled(
1872                "  Enter / Esc",
1873                Style::default()
1874                    .fg(theme.text_warning)
1875                    .add_modifier(Modifier::BOLD),
1876            ),
1877            Span::styled(":close", Style::default().fg(theme.text_muted)),
1878        ]),
1879    };
1880    frame.render_widget(Paragraph::new(hint), rows[2]);
1881}
1882
1883#[cfg(test)]
1884mod tests {
1885    use super::*;
1886
1887    #[test]
1888    fn the_form_window_always_holds_the_focused_field() {
1889        for count in 1..=12 {
1890            for capacity in 0..=13 {
1891                for focused in 0..count {
1892                    let window = field_window(count, focused, capacity);
1893                    assert!(
1894                        window.contains(&focused),
1895                        "{count} fields, {capacity} slots, focus {focused}: {window:?}"
1896                    );
1897                    assert_eq!(window.len(), capacity.clamp(1, count));
1898                    assert!(window.end <= count);
1899                }
1900            }
1901        }
1902    }
1903
1904    #[test]
1905    fn the_form_window_scrolls_only_past_the_last_slot() {
1906        // 12 fields at the 80x24 minimum leave room for 9.
1907        assert_eq!(field_window(12, 0, 9), 0..9);
1908        assert_eq!(field_window(12, 8, 9), 0..9);
1909        assert_eq!(field_window(12, 9, 9), 1..10);
1910        assert_eq!(field_window(12, 11, 9), 3..12);
1911        assert_eq!(field_window(12, 11, 20), 0..12);
1912    }
1913
1914    #[test]
1915    fn the_passphrase_prompt_fits_the_smallest_screen() {
1916        let prompt = PassphrasePrompt {
1917            host_name: String::from("lab"),
1918            key_path: String::from("/home/me/.ssh/test_key"),
1919            field: FormField::with_value("secret"),
1920            error: Some(String::from("wrong passphrase")),
1921            unlocking: false,
1922        };
1923        let backend = ratatui::backend::TestBackend::new(80, 24);
1924        let mut terminal = ratatui::Terminal::new(backend).expect("terminal");
1925        terminal
1926            .draw(|frame| render_passphrase_prompt(frame, &prompt, &Theme::default()))
1927            .expect("draw");
1928        let screen: String = terminal
1929            .backend()
1930            .buffer()
1931            .content()
1932            .iter()
1933            .map(|cell| cell.symbol())
1934            .collect();
1935        for text in [
1936            "Unlock SSH key — lab",
1937            "/home/me/.ssh/test_key",
1938            "******|",
1939            "wrong passphrase",
1940            "Enter:unlock",
1941        ] {
1942            assert!(screen.contains(text), "{text:?} is not on screen");
1943        }
1944        assert!(!screen.contains("secret"), "the passphrase is never drawn");
1945    }
1946
1947    #[test]
1948    fn the_password_prompt_names_the_login_and_hides_the_password() {
1949        let prompt = PasswordPrompt {
1950            request_id: 1,
1951            host_name: String::from("udm"),
1952            login: String::from("root@192.168.1.1"),
1953            retry: true,
1954            new_host_key: None,
1955            field: FormField::with_value("hunter2"),
1956        };
1957        let backend = ratatui::backend::TestBackend::new(80, 24);
1958        let mut terminal = ratatui::Terminal::new(backend).expect("terminal");
1959        terminal
1960            .draw(|frame| render_password_prompt(frame, &prompt, &Theme::default()))
1961            .expect("draw");
1962        let screen: String = terminal
1963            .backend()
1964            .buffer()
1965            .content()
1966            .iter()
1967            .map(|cell| cell.symbol())
1968            .collect();
1969        for text in [
1970            "SSH login — udm",
1971            "root@192.168.1.1",
1972            "*******|",
1973            "Permission denied, please try again.",
1974            "Enter:log in",
1975        ] {
1976            assert!(screen.contains(text), "{text:?} is not on screen");
1977        }
1978        assert!(!screen.contains("hunter2"), "the password is never drawn");
1979    }
1980}