Skip to main content

horto_os_ui_tui/
draw.rs

1//! Ratatui draw helpers: layout, footer, panels, help overlay.
2
3use horto_os_ui_shared::{GIT_COMMIT, VERSION};
4use ratatui::{
5    layout::{Constraint, Direction, Layout, Rect},
6    style::{Color, Modifier, Style},
7    text::{Line, Span},
8    widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Tabs, Wrap},
9    Frame,
10};
11
12#[cfg(test)]
13use crate::app::BoxCliView;
14use crate::app::{App, Modal};
15use crate::prompt::{draw_confirm, draw_rebooting, draw_secret_input, draw_text_input};
16use crate::tabs::Screen;
17
18/// Footer label for local tip version and optional box CLI status (tests).
19#[cfg(test)]
20pub fn footer_cli_label(cli_local: &str, remote: bool, box_cli: &BoxCliView) -> String {
21    if remote {
22        format!("local={cli_local} box={}", box_cli.as_label())
23    } else {
24        format!("local={cli_local}")
25    }
26}
27
28/// Session state + last user message (user message in white).
29fn footer_status_line(app: &App) -> Line<'static> {
30    let mode = if app.apply { "APPLY" } else { "PLAN" };
31    let mode_style = if app.apply {
32        Style::default()
33            .fg(Color::LightRed)
34            .add_modifier(Modifier::BOLD)
35    } else {
36        Style::default()
37            .fg(Color::Yellow)
38            .add_modifier(Modifier::BOLD)
39    };
40    let value_style = Style::default().fg(Color::Cyan);
41    let mut spans = vec![
42        Span::styled(format!("[{mode}]"), mode_style),
43        Span::raw(" pipeline="),
44        Span::styled(app.kind.as_str().to_owned(), value_style),
45        Span::raw(" · local="),
46        Span::styled(app.cli_local.clone(), value_style),
47    ];
48    if app.is_remote() {
49        spans.push(Span::raw(" box="));
50        spans.push(Span::styled(app.box_cli.as_label().to_owned(), value_style));
51    }
52    if !app.message.is_empty() {
53        spans.push(Span::raw(" · "));
54        spans.push(Span::styled(
55            app.message.clone(),
56            Style::default()
57                .fg(Color::White)
58                .add_modifier(Modifier::BOLD),
59        ));
60    }
61    Line::from(spans)
62}
63
64fn footer_key(label: &str) -> Span<'static> {
65    Span::styled(
66        label.to_owned(),
67        Style::default()
68            .fg(Color::Magenta)
69            .add_modifier(Modifier::BOLD),
70    )
71}
72
73fn footer_muted(text: &str) -> Span<'static> {
74    Span::styled(text.to_owned(), Style::default().fg(Color::DarkGray))
75}
76
77/// Panel / block title (same orange as the selected tab).
78fn panel_title(name: &str) -> Span<'static> {
79    Span::styled(
80        name.to_owned(),
81        Style::default()
82            .fg(Color::Yellow)
83            .add_modifier(Modifier::BOLD),
84    )
85}
86
87fn footer_hints_line(app: &App) -> Line<'static> {
88    if app.help_open {
89        return Line::from(vec![
90            footer_muted("Esc or "),
91            footer_key("?"),
92            footer_muted(" close help"),
93        ]);
94    }
95    if let Some(line) = footer_for_modal(app.modal.as_ref()) {
96        return line;
97    }
98    footer_for_screen(app.screen)
99}
100
101fn footer_for_modal(modal: Option<&Modal>) -> Option<Line<'static>> {
102    match modal {
103        Some(Modal::Confirm(_)) => Some(Line::from(vec![
104            footer_key("Enter"),
105            footer_muted("/"),
106            footer_key("y"),
107            footer_muted(" confirm · "),
108            footer_key("Esc"),
109            footer_muted("/"),
110            footer_key("n"),
111            footer_muted(" cancel · "),
112            footer_key("Ctrl+C"),
113            footer_muted(" quit"),
114        ])),
115        Some(Modal::TextHost(_)) => Some(Line::from(vec![
116            footer_muted("Type host · "),
117            footer_key("Enter"),
118            footer_muted(" submit · "),
119            footer_key("Esc"),
120            footer_muted(" cancel · "),
121            footer_key("Ctrl+C"),
122            footer_muted(" quit"),
123        ])),
124        Some(Modal::SudoPassword(_)) => Some(Line::from(vec![
125            footer_muted("Sudo password · "),
126            footer_key("Enter"),
127            footer_muted(" submit · "),
128            footer_key("Esc"),
129            footer_muted(" cancel · "),
130            footer_key("Ctrl+C"),
131            footer_muted(" quit"),
132        ])),
133        Some(Modal::Rebooting) => Some(Line::from(vec![
134            footer_muted("Rebooting… · "),
135            footer_key("Esc"),
136            footer_muted(" close · "),
137            footer_key("Ctrl+C"),
138            footer_muted(" quit"),
139        ])),
140        None => None,
141    }
142}
143
144fn footer_for_screen(screen: Screen) -> Line<'static> {
145    match screen {
146        Screen::Setup => footer_key_line(&[
147            ("j/k", "/"),
148            ("↑/↓", " select · "),
149            ("←/→", " tabs · "),
150            ("Enter", " run · "),
151            ("a", " all · "),
152            ("b", " backup · "),
153            ("p", " pipeline · "),
154            ("Tab", " plan/apply · "),
155            ("r", " refresh · "),
156            ("?", " help · "),
157            ("q", " quit"),
158        ]),
159        Screen::Logs => footer_key_line(&[
160            ("c", " clear · "),
161            ("←/→", " tabs · "),
162            ("Tab", " plan/apply · "),
163            ("r", " refresh · "),
164            ("B", " disk · "),
165            ("?", " help · "),
166            ("q", " quit"),
167        ]),
168        Screen::Ssh => footer_key_line(&[
169            ("Enter", " edit host · "),
170            ("f", " fetch · "),
171            ("i", " key · "),
172            ("←/→", " tabs · "),
173            ("Tab", " plan/apply · "),
174            ("r", " refresh all · "),
175            ("?", " help · "),
176            ("q", " quit"),
177        ]),
178        Screen::Cli => footer_key_line(&[
179            ("Enter", " sync CLI · "),
180            ("f", " fetch · "),
181            ("←/→", " tabs · "),
182            ("Tab", " plan/apply · "),
183            ("r", " refresh all · "),
184            ("?", " help · "),
185            ("q", " quit"),
186        ]),
187        Screen::Overview | Screen::Api | Screen::Mcp => footer_key_line(&[
188            ("f", " fetch · "),
189            ("←/→", " tabs · "),
190            ("Tab", " plan/apply · "),
191            ("r", " refresh all · "),
192            ("?", " help · "),
193            ("q", " quit"),
194        ]),
195        Screen::Reboot => footer_key_line(&[
196            ("Enter", " reboot · "),
197            ("←/→", " tabs · "),
198            ("Tab", " plan/apply · "),
199            ("r", " refresh · "),
200            ("?", " help · "),
201            ("q", " quit"),
202        ]),
203    }
204}
205
206fn footer_key_line(parts: &[(&str, &str)]) -> Line<'static> {
207    let mut spans = Vec::with_capacity(parts.len() * 2);
208    for &(key, tail) in parts {
209        spans.push(footer_key(key));
210        spans.push(footer_muted(tail));
211    }
212    Line::from(spans)
213}
214
215fn tab_title_line(title: &str) -> Line<'static> {
216    let mut chars = title.chars();
217    let Some(digit) = chars.next() else {
218        return Line::from(title.to_owned());
219    };
220    let rest: String = chars.collect();
221    Line::from(vec![
222        Span::styled(
223            digit.to_string(),
224            Style::default()
225                .fg(Color::Magenta)
226                .add_modifier(Modifier::BOLD),
227        ),
228        Span::raw(rest),
229    ])
230}
231
232/// Draw the full TUI frame: tabs, active panel, footer, and modal overlays.
233pub fn ui(f: &mut Frame, app: &mut App) {
234    let chunks = Layout::default()
235        .direction(Direction::Vertical)
236        .constraints([
237            Constraint::Length(3),
238            Constraint::Min(5),
239            Constraint::Length(4),
240        ])
241        .split(f.area());
242
243    let remote = app.is_remote();
244    let titles = Screen::titles(remote)
245        .into_iter()
246        .map(tab_title_line)
247        .collect::<Vec<_>>();
248    let idx = app.screen.index(remote);
249    let tabs = Tabs::new(titles)
250        .select(idx)
251        .block(
252            Block::default()
253                .borders(Borders::ALL)
254                .title(format!("horto-tui/{VERSION}/{GIT_COMMIT}")),
255        )
256        .highlight_style(
257            Style::default()
258                .fg(Color::Yellow)
259                .add_modifier(Modifier::BOLD),
260        );
261    f.render_widget(tabs, chunks[0]);
262
263    match app.screen {
264        Screen::Setup => draw_setup(f, app, chunks[1]),
265        Screen::Logs => draw_logs(f, app, chunks[1]),
266        Screen::Overview
267        | Screen::Ssh
268        | Screen::Cli
269        | Screen::Api
270        | Screen::Mcp
271        | Screen::Reboot => draw_panel(f, app, chunks[1]),
272    }
273
274    draw_footer(f, app, chunks[2]);
275
276    if app.help_open {
277        draw_help(f);
278    }
279    match &app.modal {
280        Some(Modal::Confirm(kind)) => {
281            draw_confirm(f, kind.title(), &kind.body());
282        }
283        Some(Modal::TextHost(input)) => {
284            draw_text_input(f, input);
285        }
286        Some(Modal::SudoPassword(input)) => {
287            draw_secret_input(f, input);
288        }
289        Some(Modal::Rebooting) => {
290            draw_rebooting(f);
291        }
292        None => {}
293    }
294}
295
296/// Footer: status (session + white user message) · keys. Cleared each frame.
297fn draw_footer(f: &mut Frame, app: &App, area: Rect) {
298    f.render_widget(Clear, area);
299    let block = Block::default().borders(Borders::ALL).title("Status");
300    let inner = block.inner(area);
301    f.render_widget(block, area);
302    if inner.width == 0 || inner.height == 0 {
303        return;
304    }
305    let status = pad_footer_line(footer_status_line(app), inner.width);
306    f.render_widget(
307        Paragraph::new(status),
308        Rect {
309            x: inner.x,
310            y: inner.y,
311            width: inner.width,
312            height: 1,
313        },
314    );
315    if inner.height >= 2 {
316        let hints = pad_footer_line(footer_hints_line(app), inner.width);
317        f.render_widget(
318            Paragraph::new(hints),
319            Rect {
320                x: inner.x,
321                y: inner.y + 1,
322                width: inner.width,
323                height: 1,
324            },
325        );
326    }
327}
328
329fn pad_footer_line(line: Line<'static>, width: u16) -> Line<'static> {
330    let width = width as usize;
331    if width == 0 {
332        return Line::default();
333    }
334    let used = line.width();
335    if used > width {
336        let s = line.to_string();
337        let mut out = String::new();
338        for ch in s.chars() {
339            if out.chars().count() + 1 >= width {
340                break;
341            }
342            out.push(ch);
343        }
344        if s.chars().count() > width {
345            if out.chars().count() == width {
346                out.pop();
347            }
348            out.push('…');
349        }
350        return Line::from(out);
351    }
352    if used < width {
353        let mut line = line;
354        line.spans.push(Span::raw(" ".repeat(width - used)));
355        return line;
356    }
357    line
358}
359
360fn draw_help(f: &mut Frame) {
361    let area = centered_rect(70, 80, f.area());
362    f.render_widget(Clear, area);
363    let body = [
364        "Horto TUI help",
365        "",
366        "q / Esc / Ctrl+C   Quit",
367        "?                  Toggle this help",
368        "Left / Right       Previous / next tab",
369        "1-8                Jump to tab (remote: 7 Reboot, 8 Logs)",
370        "j k / Up / Down    Move step selection (Setup)",
371        "p                  Toggle full / minimal pipeline",
372        "Tab                Toggle plan / apply",
373        "Enter              Setup: run step · SSH: edit Host · other surfaces: action",
374        "e / i              SSH: edit Host / install key (--install-ssh-key)",
375        "a                  Run all pipeline steps",
376        "b / B              Timestamped /etc backup / disk probe",
377        "r                  Refresh surfaces (SSH/CLI/API/MCP; background)",
378        "c                  Clear Logs (on Logs tab)",
379        "y / n              Confirm / cancel (modals)",
380        "",
381        "Remote open paints first, then refreshes SSH/CLI/API/MCP in the background.",
382        "Press r to refresh without blocking. Confirm and Host edit use on-screen dialogs.",
383        "Mouse capture is off so you can select and copy text.",
384        "Press Esc or ? to close.",
385    ]
386    .join("\n");
387    let p = Paragraph::new(body).wrap(Wrap { trim: false }).block(
388        Block::default()
389            .borders(Borders::ALL)
390            .title("Help")
391            .border_style(Style::default().fg(Color::Cyan)),
392    );
393    f.render_widget(p, area);
394}
395
396fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
397    let popup = Layout::default()
398        .direction(Direction::Vertical)
399        .constraints([
400            Constraint::Percentage((100 - percent_y) / 2),
401            Constraint::Percentage(percent_y),
402            Constraint::Percentage((100 - percent_y) / 2),
403        ])
404        .split(area);
405    Layout::default()
406        .direction(Direction::Horizontal)
407        .constraints([
408            Constraint::Percentage((100 - percent_x) / 2),
409            Constraint::Percentage(percent_x),
410            Constraint::Percentage((100 - percent_x) / 2),
411        ])
412        .split(popup[1])[1]
413}
414
415fn draw_setup(f: &mut Frame, app: &mut App, area: Rect) {
416    let items: Vec<ListItem> = app
417        .status_lines
418        .iter()
419        .map(|l| ListItem::new(setup_step_line(l)))
420        .collect();
421    let list = List::new(items)
422        .block(
423            Block::default()
424                .borders(Borders::ALL)
425                .padding(ratatui::widgets::Padding::new(0, 0, 1, 0))
426                .title(Line::from(vec![
427                    panel_title("Steps"),
428                    Span::raw(" ("),
429                    footer_key("Enter"),
430                    Span::raw(" run · "),
431                    footer_key("a"),
432                    Span::raw(" = all · "),
433                    footer_key("*"),
434                    Span::raw(" = destructive)"),
435                ])),
436        )
437        .highlight_style(
438            Style::default()
439                .bg(Color::DarkGray)
440                .add_modifier(Modifier::BOLD),
441        )
442        .highlight_symbol(">> ");
443    f.render_stateful_widget(list, area, &mut app.step_state);
444}
445
446/// Color step id and status separately: `s0 | pending | title`.
447pub fn setup_step_line(raw: &str) -> Line<'static> {
448    let parts: Vec<&str> = raw.splitn(3, " | ").collect();
449    if parts.len() < 3 {
450        return Line::from(Span::raw(raw.to_owned()));
451    }
452    let id = parts[0];
453    let status = parts[1];
454    let title = parts[2];
455    let status_style = match status {
456        "done" => Style::default()
457            .fg(Color::Green)
458            .add_modifier(Modifier::BOLD),
459        "pending" => Style::default()
460            .fg(Color::White)
461            .add_modifier(Modifier::BOLD),
462        "probing" => Style::default()
463            .fg(Color::Blue)
464            .add_modifier(Modifier::BOLD),
465        "stale" => Style::default()
466            .fg(Color::Rgb(255, 165, 0))
467            .add_modifier(Modifier::BOLD),
468        "failed" | "blocked" => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
469        _ => Style::default()
470            .fg(Color::Gray)
471            .add_modifier(Modifier::BOLD),
472    };
473    Line::from(vec![
474        Span::styled(
475            id.to_owned(),
476            Style::default()
477                .fg(Color::White)
478                .add_modifier(Modifier::BOLD),
479        ),
480        Span::raw(" | "),
481        Span::styled(status.to_owned(), status_style),
482        Span::raw(" | "),
483        Span::styled(title.to_owned(), Style::default().fg(Color::Gray)),
484    ])
485}
486
487fn draw_logs(f: &mut Frame, app: &App, area: Rect) {
488    let n = app.logs.len();
489    let lines: Vec<Line<'static>> = if app.logs.is_empty() {
490        vec![Line::from(Span::styled(
491            "(empty · r refresh · c clear)".to_owned(),
492            Style::default().fg(Color::Cyan),
493        ))]
494    } else {
495        app.logs
496            .iter()
497            .rev()
498            .take(40)
499            .rev()
500            .map(|line| style_log_line(line))
501            .collect()
502    };
503    let p = Paragraph::new(lines).wrap(Wrap { trim: false }).block(
504        Block::default()
505            .borders(Borders::ALL)
506            .border_style(Style::default().fg(Color::DarkGray))
507            .padding(ratatui::widgets::Padding::new(0, 0, 1, 0))
508            .title(Line::from(vec![
509                panel_title("Logs"),
510                Span::styled(format!(" ({n})"), Style::default().fg(Color::Cyan)),
511            ])),
512    );
513    f.render_widget(p, area);
514}
515
516fn style_log_line(line: &str) -> Line<'static> {
517    let (ts, rest) = match line.split_once(' ') {
518        Some((t, r)) if t.len() == 8 && t.chars().filter(|c| *c == ':').count() == 2 => (t, r),
519        _ => {
520            return Line::from(Span::styled(
521                line.to_owned(),
522                Style::default().fg(Color::White),
523            ));
524        }
525    };
526    let lower = rest.to_ascii_lowercase();
527    let msg_style =
528        if lower.contains("error") || lower.contains("failed") || lower.contains("fail:") {
529            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
530        } else {
531            Style::default().fg(Color::White)
532        };
533    Line::from(vec![
534        Span::styled(ts.to_owned(), Style::default().fg(Color::Gray)),
535        Span::raw(" "),
536        Span::styled(rest.to_owned(), msg_style),
537    ])
538}
539
540fn draw_panel(f: &mut Frame, app: &App, area: Rect) {
541    let title = match app.screen {
542        Screen::Overview => "Overview",
543        Screen::Ssh => "SSH",
544        Screen::Cli => "CLI",
545        Screen::Api => "API",
546        Screen::Mcp => "MCP",
547        Screen::Reboot => "Reboot",
548        Screen::Setup | Screen::Logs => "",
549    };
550    let p = Paragraph::new(app.panel_lines.clone())
551        .wrap(Wrap { trim: false })
552        .block(
553            Block::default()
554                .borders(Borders::ALL)
555                .border_style(Style::default().fg(Color::DarkGray))
556                .padding(ratatui::widgets::Padding::new(0, 0, 1, 0))
557                .title(Line::from(panel_title(title))),
558        );
559    f.render_widget(p, area);
560}