purple-ssh 1.24.0

Manage SSH configs and launch connections from the terminal. TUI host manager with search, tags, tunnels, command snippets, password management (keychain, 1Password, Bitwarden, pass, Vault), cloud sync (AWS EC2, DigitalOcean, Vultr, Linode, Hetzner, UpCloud, Proxmox VE), self-update and round-trip fidelity for ~/.ssh/config.
Documentation
mod confirm_dialog;
mod detail_panel;
mod help;
mod host_detail;
pub mod host_form;
mod host_list;
mod key_detail;
mod key_list;
mod provider_list;
mod snippet_form;
mod snippet_picker;
mod tag_picker;
pub mod theme;
mod tunnel_form;
mod tunnel_list;

use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;

use crate::app::{App, Screen};

const MIN_WIDTH: u16 = 50;
const MIN_HEIGHT: u16 = 10;

/// Top-level render dispatcher.
pub fn render(frame: &mut Frame, app: &mut App) {
    let area = frame.area();

    // Terminal too small guard
    if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
        let msg = Paragraph::new(Line::from(vec![
            Span::styled("\u{26A0}", theme::error()),
            Span::raw(" Terminal too small. Need at least 50x10."),
        ]));
        frame.render_widget(msg, area);
        return;
    }

    match &app.screen {
        Screen::HostList => host_list::render(frame, app),
        Screen::AddHost | Screen::EditHost { .. } => {
            host_list::render(frame, app);
            host_form::render(frame, app);
        }
        Screen::ConfirmDelete { alias } => {
            let alias = alias.clone();
            host_list::render(frame, app);
            confirm_dialog::render(frame, app, &alias);
        }
        Screen::Help => {
            host_list::render(frame, app);
            help::render(frame, app);
        }
        Screen::KeyList => {
            host_list::render(frame, app);
            key_list::render(frame, app);
        }
        Screen::KeyDetail { index } => {
            let index = *index;
            host_list::render(frame, app);
            key_list::render(frame, app);
            key_detail::render(frame, app, index);
        }
        Screen::HostDetail { index } => {
            let index = *index;
            host_list::render(frame, app);
            host_detail::render(frame, app, index);
        }
        Screen::TagPicker => {
            host_list::render(frame, app);
            tag_picker::render(frame, app);
        }
        Screen::Providers => {
            host_list::render(frame, app);
            provider_list::render_provider_list(frame, app);
        }
        Screen::ProviderForm { provider } => {
            let provider = provider.clone();
            host_list::render(frame, app);
            provider_list::render_provider_form(frame, app, &provider);
        }
        Screen::TunnelList { alias } => {
            let alias = alias.clone();
            host_list::render(frame, app);
            tunnel_list::render(frame, app, &alias);
        }
        Screen::TunnelForm { alias, .. } => {
            let alias = alias.clone();
            host_list::render(frame, app);
            tunnel_list::render(frame, app, &alias);
            tunnel_form::render(frame, app);
        }
        Screen::SnippetPicker { .. } => {
            host_list::render(frame, app);
            snippet_picker::render(frame, app);
        }
        Screen::SnippetForm { .. } => {
            host_list::render(frame, app);
            snippet_picker::render(frame, app);
            snippet_form::render(frame, app);
        }
        Screen::ConfirmHostKeyReset { hostname, .. } => {
            let hostname = hostname.clone();
            host_list::render(frame, app);
            confirm_dialog::render_host_key_reset(frame, app, &hostname);
        }
    }
}

/// Render footer with shortcuts always visible and optional status right-aligned.
pub fn render_footer_with_status(
    frame: &mut Frame,
    area: Rect,
    mut footer_spans: Vec<Span<'_>>,
    app: &App,
) {
    if let Some(ref status) = app.status {
        use unicode_width::UnicodeWidthStr;
        let shortcuts_width: usize = footer_spans.iter().map(|s| s.width()).sum();
        let total_width = area.width as usize;
        let (icon, icon_style, text) = if status.is_error {
            ("\u{26A0}", theme::error(), format!(" {} ", status.text))
        } else {
            ("\u{2713} ", theme::success(), format!("{} ", status.text))
        };
        let status_width = icon.width() + text.width();
        let gap = total_width.saturating_sub(shortcuts_width + status_width);
        if gap > 0 {
            footer_spans.push(Span::raw(" ".repeat(gap)));
            footer_spans.push(Span::styled(icon, icon_style));
            footer_spans.push(Span::raw(text));
        }
    }
    frame.render_widget(Paragraph::new(Line::from(footer_spans)), area);
}

/// Create a centered rect of given percentage within the parent rect.
pub fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
    let vertical = Layout::vertical([
        Constraint::Percentage((100 - percent_y) / 2),
        Constraint::Percentage(percent_y),
        Constraint::Percentage((100 - percent_y) / 2),
    ])
    .split(area);

    Layout::horizontal([
        Constraint::Percentage((100 - percent_x) / 2),
        Constraint::Percentage(percent_x),
        Constraint::Percentage((100 - percent_x) / 2),
    ])
    .split(vertical[1])[1]
}

/// Truncate a string to fit within `max_cols` display columns (unicode-width-aware).
pub(crate) fn truncate(s: &str, max_cols: usize) -> String {
    use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
    if s.width() <= max_cols {
        return s.to_string();
    }
    if max_cols <= 1 {
        return String::new();
    }
    let target = max_cols - 1;
    let mut col = 0;
    let mut byte_end = 0;
    for ch in s.chars() {
        let w = UnicodeWidthChar::width(ch).unwrap_or(0);
        if col + w > target {
            break;
        }
        col += w;
        byte_end += ch.len_utf8();
    }
    format!("{}", &s[..byte_end])
}

/// Create a centered rect with fixed dimensions.
pub fn centered_rect_fixed(width: u16, height: u16, area: Rect) -> Rect {
    let x = area.x + area.width.saturating_sub(width) / 2;
    let y = area.y + area.height.saturating_sub(height) / 2;
    Rect::new(x, y, width.min(area.width), height.min(area.height))
}