use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, List, ListItem, Paragraph, Wrap},
};
use crate::app::{
App, AppMode, ConfigItem, ScpFormField, ScpState, TunnelFormField, TunnelOverlayState,
WallixSelectorState,
};
use crate::fl;
use crate::ssh::sftp::ScpDirection;
use crate::ssh::tunnel::TunnelStatus;
use crate::ui::theme::Theme;
fn centered_rect(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))
}
pub(crate) fn draw_error_overlay(f: &mut Frame, msg: String, area: Rect, theme: &Theme) {
let lines: Vec<&str> = msg.lines().collect();
let inner_h = (lines.len() as u16).max(1);
let popup_h = inner_h + 5;
let popup_w = (msg.lines().map(|l| l.len()).max().unwrap_or(20) as u16 + 6)
.clamp(40, area.width.saturating_sub(4));
let popup_area = centered_rect(popup_w, popup_h, area);
f.render_widget(Clear, popup_area);
let block = Block::default()
.title(fl!("error-title"))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.red))
.style(Style::default().bg(theme.bg));
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(inner);
let text: Vec<Line> = lines
.iter()
.map(|l| Line::from(Span::styled(*l, Style::default().fg(theme.fg))))
.collect();
let paragraph = Paragraph::new(text).wrap(Wrap { trim: false });
f.render_widget(paragraph, chunks[0]);
let hint = Paragraph::new(fl!("error-dismiss")).style(Style::default().fg(theme.subtext0));
f.render_widget(hint, chunks[1]);
}
pub(crate) fn draw_wallix_selector_overlay(f: &mut Frame, app: &mut App, area: Rect) {
let Some(state) = &app.wallix_selector else {
return;
};
let popup_area = centered_rect(86, 18, area);
f.render_widget(Clear, popup_area);
let block = Block::default()
.title(fl!("wallix-selector-title"))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(app.theme.sapphire))
.style(Style::default().bg(app.theme.bg));
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
match state {
WallixSelectorState::Loading { server, .. } => {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2),
Constraint::Min(1),
Constraint::Length(1),
])
.split(inner);
f.render_widget(
Paragraph::new(fl!(
"wallix-selector-loading",
server = server.name.as_str()
))
.style(Style::default().fg(app.theme.fg)),
chunks[0],
);
f.render_widget(
Paragraph::new(fl!("wallix-selector-loading-hint"))
.style(Style::default().fg(app.theme.subtext0))
.wrap(Wrap { trim: true }),
chunks[1],
);
f.render_widget(
Paragraph::new(fl!("wallix-selector-cancel-hint"))
.style(Style::default().fg(app.theme.subtext0)),
chunks[2],
);
}
WallixSelectorState::Error { server, message } => {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2),
Constraint::Min(1),
Constraint::Length(1),
])
.split(inner);
f.render_widget(
Paragraph::new(fl!("wallix-selector-error", server = server.name.as_str())).style(
Style::default()
.fg(app.theme.red)
.add_modifier(Modifier::BOLD),
),
chunks[0],
);
f.render_widget(
Paragraph::new(message.clone())
.style(Style::default().fg(app.theme.fg))
.wrap(Wrap { trim: true }),
chunks[1],
);
f.render_widget(
Paragraph::new(fl!("wallix-selector-close-hint"))
.style(Style::default().fg(app.theme.subtext0)),
chunks[2],
);
}
WallixSelectorState::List {
server,
entries,
selected,
} => {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2),
Constraint::Min(6),
Constraint::Length(2),
])
.split(inner);
f.render_widget(
Paragraph::new(fl!(
"wallix-selector-choose",
server = server.name.as_str(),
host = server.host.as_str()
))
.style(Style::default().fg(app.theme.fg)),
chunks[0],
);
let items: Vec<ListItem> = entries
.iter()
.enumerate()
.map(|(index, entry)| {
let is_selected = index == *selected;
let bg = if is_selected {
app.theme.selection_bg
} else {
app.theme.bg
};
let fg = if is_selected {
app.theme.selection_fg
} else {
app.theme.fg
};
ListItem::new(Line::from(vec![
Span::styled(
format!("#{:<3} ", entry.id),
Style::default().fg(app.theme.sapphire).bg(bg),
),
Span::styled(entry.target.clone(), Style::default().fg(fg).bg(bg)),
Span::styled(" → ", Style::default().fg(app.theme.subtext0).bg(bg)),
Span::styled(entry.group.clone(), Style::default().fg(fg).bg(bg)),
]))
})
.collect();
f.render_widget(List::new(items), chunks[1]);
f.render_widget(
Paragraph::new(fl!("wallix-selector-list-hint"))
.style(Style::default().fg(app.theme.subtext0)),
chunks[2],
);
}
}
}
pub(crate) fn draw_tunnel_overlay(f: &mut Frame, app: &mut App, area: Rect) {
match &app.tunnel_overlay {
Some(TunnelOverlayState::Form(_)) => {
draw_tunnel_form(f, app, area);
return;
}
Some(TunnelOverlayState::List { .. }) => {}
None => return,
}
let items = app.get_visible_items();
let server = match items.get(app.selected_index) {
Some(ConfigItem::Server(s)) => (**s).clone(),
_ => return,
};
let overlay_selected = match &app.tunnel_overlay {
Some(TunnelOverlayState::List { selected }) => *selected,
_ => return,
};
let tunnels = app.effective_tunnels(&server);
let server_key = App::server_key(&server);
let n_tunnels = tunnels.len();
let content_h = (n_tunnels as u16 + 1).max(1);
let popup_h = (content_h + 5).min(area.height.saturating_sub(4));
let popup_w: u16 = 64.min(area.width.saturating_sub(4));
let popup_area = centered_rect(popup_w, popup_h, area);
f.render_widget(Clear, popup_area);
let title = format!(" Tunnels — {} ", server.name);
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(app.theme.sapphire))
.style(Style::default().bg(app.theme.bg));
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1),
Constraint::Length(1),
Constraint::Length(2),
])
.split(inner);
let mut list_items: Vec<ListItem> = Vec::new();
for (i, et) in tunnels.iter().enumerate() {
let handle = app
.active_tunnels
.get(&server_key)
.and_then(|hs| hs.iter().find(|h| h.user_idx == i));
let (icon, icon_color) = match handle {
Some(h) if h.is_running() => ("✔", app.theme.green),
Some(h) if matches!(h.status, TunnelStatus::Dead(_)) => ("✗", app.theme.red),
_ => ("✖", app.theme.subtext0),
};
let label = if et.config.label.is_empty() {
format!("{}:{}", et.config.remote_host, et.config.remote_port)
} else {
et.config.label.clone()
};
let route = format!(
"localhost:{} → {}:{}",
et.config.local_port, et.config.remote_host, et.config.remote_port
);
let is_sel = i == overlay_selected;
let bg = if is_sel {
app.theme.selection_bg
} else {
app.theme.bg
};
let fg = if is_sel {
app.theme.selection_fg
} else {
app.theme.fg
};
let route_fg = if is_sel {
app.theme.selection_fg
} else {
app.theme.subtext0
};
let line = Line::from(vec![
Span::styled(format!("{} ", icon), Style::default().fg(icon_color).bg(bg)),
Span::styled(format!("{:<20}", label), Style::default().fg(fg).bg(bg)),
Span::styled(route, Style::default().fg(route_fg).bg(bg)),
]);
list_items.push(ListItem::new(line));
}
let plus_sel = overlay_selected == n_tunnels;
let plus_bg = if plus_sel {
app.theme.selection_bg
} else {
app.theme.bg
};
let plus_fg = if plus_sel {
app.theme.selection_fg
} else {
app.theme.green
};
list_items.push(ListItem::new(Line::from(Span::styled(
fl!("tunnel-overlay-new"),
Style::default().fg(plus_fg).bg(plus_bg),
))));
f.render_widget(List::new(list_items), chunks[0]);
let hint_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Length(1)])
.split(chunks[2]);
let s = Style::default().fg(app.theme.subtext0);
f.render_widget(
Paragraph::new(fl!("tunnel-overlay-hints1")).style(s),
hint_chunks[0],
);
f.render_widget(
Paragraph::new(fl!("tunnel-overlay-hints2")).style(s),
hint_chunks[1],
);
}
fn draw_tunnel_form(f: &mut Frame, app: &mut App, area: Rect) {
let items = app.get_visible_items();
let server = match items.get(app.selected_index) {
Some(ConfigItem::Server(s)) => (**s).clone(),
_ => return,
};
let form = match &app.tunnel_overlay {
Some(TunnelOverlayState::Form(form)) => form.clone(),
_ => return,
};
let is_edit = form.editing_index.is_some();
let title = if is_edit {
fl!("tunnel-form-edit-title", server = server.name.as_str())
} else {
fl!("tunnel-form-new-title", server = server.name.as_str())
};
let popup_h: u16 = 11;
let popup_w: u16 = 62.min(area.width.saturating_sub(4));
let popup_area = centered_rect(popup_w, popup_h, area);
f.render_widget(Clear, popup_area);
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(app.theme.sapphire))
.style(Style::default().bg(app.theme.bg));
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(inner);
let label_label = fl!("tunnel-form-field-label");
let label_lp = fl!("tunnel-form-field-local-port");
let label_rh = fl!("tunnel-form-field-remote-host");
let label_rp = fl!("tunnel-form-field-remote-port");
let fields: &[(&str, &str, TunnelFormField)] = &[
(&label_label, &form.label, TunnelFormField::Label),
(&label_lp, &form.local_port, TunnelFormField::LocalPort),
(&label_rh, &form.remote_host, TunnelFormField::RemoteHost),
(&label_rp, &form.remote_port, TunnelFormField::RemotePort),
];
for (i, (label, value, field)) in fields.iter().enumerate() {
let focused = *field == form.focus;
let (label_fg, value_bg, cursor) = if focused {
(app.theme.sapphire, app.theme.selection_bg, "█")
} else {
(app.theme.subtext0, app.theme.bg, "")
};
let line = Line::from(vec![
Span::styled(*label, Style::default().fg(label_fg)),
Span::styled(
format!("{}{}", value, cursor),
Style::default().fg(app.theme.fg).bg(value_bg),
),
]);
f.render_widget(Paragraph::new(line), chunks[i]);
}
if !form.error.is_empty() {
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" ✗ ", Style::default().fg(app.theme.red)),
Span::styled(form.error.as_str(), Style::default().fg(app.theme.red)),
])),
chunks[4],
);
}
f.render_widget(
Paragraph::new(fl!("tunnel-form-hint")).style(Style::default().fg(app.theme.subtext0)),
chunks[6],
);
}
pub(crate) fn draw_credential_input_overlay(f: &mut Frame, app: &App, area: Rect) {
let AppMode::CredentialInput {
server,
is_passphrase,
input,
..
} = &app.app_mode
else {
return;
};
let popup_h: u16 = 6;
let popup_w: u16 = 56.min(area.width.saturating_sub(4));
let popup_area = centered_rect(popup_w, popup_h, area);
f.render_widget(Clear, popup_area);
let title = if *is_passphrase {
fl!(
"credential-input-title-passphrase",
server = server.name.as_str()
)
} else {
fl!(
"credential-input-title-password",
server = server.name.as_str()
)
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(app.theme.yellow))
.style(Style::default().bg(app.theme.bg));
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(inner);
let label = if *is_passphrase {
fl!("credential-input-prompt-passphrase")
} else {
fl!("credential-input-prompt-password")
};
let masked: String = "*".repeat(input.len());
let line = Line::from(vec![
Span::styled(label, Style::default().fg(app.theme.yellow)),
Span::styled(
format!("{}█", masked),
Style::default().fg(app.theme.fg).bg(app.theme.selection_bg),
),
]);
f.render_widget(Paragraph::new(line), chunks[1]);
f.render_widget(
Paragraph::new(fl!("credential-input-hint")).style(Style::default().fg(app.theme.subtext0)),
chunks[3],
);
}
pub(crate) fn draw_scp_overlay(f: &mut Frame, app: &mut App, area: Rect) {
match &app.scp_state {
ScpState::SelectingDirection => draw_scp_direction_select(f, app, area),
ScpState::FillingForm { .. } => draw_scp_form(f, app, area),
ScpState::Done { .. } | ScpState::Error(_) => draw_scp_result(f, app, area),
_ => {}
}
}
fn draw_scp_direction_select(f: &mut Frame, app: &mut App, area: Rect) {
let items = app.get_visible_items();
let server = match items.get(app.selected_index) {
Some(ConfigItem::Server(s)) => s.name.clone(),
_ => return,
};
let popup_h: u16 = 7;
let popup_w: u16 = 38.min(area.width.saturating_sub(4));
let popup_area = centered_rect(popup_w, popup_h, area);
f.render_widget(Clear, popup_area);
let block = Block::default()
.title(fl!("scp-direction-title", server = server.as_str()))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(app.theme.sapphire))
.style(Style::default().bg(app.theme.bg));
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(inner);
let s_active = Style::default()
.fg(app.theme.fg)
.add_modifier(Modifier::BOLD);
let s_label = Style::default().fg(app.theme.sky);
let s_sub = Style::default().fg(app.theme.subtext0);
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" ↑ ", s_active),
Span::styled(format!("{} ", fl!("scp-direction-upload-label")), s_label),
Span::styled(fl!("scp-direction-upload"), s_sub),
])),
chunks[0],
);
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" ↓ ", s_active),
Span::styled(
format!("{} ", fl!("scp-direction-download-label")),
s_label,
),
Span::styled(fl!("scp-direction-download"), s_sub),
])),
chunks[1],
);
f.render_widget(
Paragraph::new(fl!("scp-direction-hint")).style(s_sub),
chunks[3],
);
}
fn draw_scp_form(f: &mut Frame, app: &mut App, area: Rect) {
let (direction, local, remote, focus, error) = match &app.scp_state {
ScpState::FillingForm {
direction,
local,
remote,
focus,
error,
} => (
direction.clone(),
local.clone(),
remote.clone(),
focus.clone(),
error.clone(),
),
_ => return,
};
let items = app.get_visible_items();
let server_name = match items.get(app.selected_index) {
Some(ConfigItem::Server(s)) => s.name.clone(),
_ => return,
};
let dir_label = if direction == ScpDirection::Upload {
fl!("scp-direction-upload-label")
} else {
fl!("scp-direction-download-label")
};
let title = fl!(
"scp-form-title",
direction = dir_label.as_str(),
server = server_name.as_str()
);
let popup_h: u16 = 8;
let popup_w: u16 = 64.min(area.width.saturating_sub(4));
let popup_area = centered_rect(popup_w, popup_h, area);
f.render_widget(Clear, popup_area);
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(app.theme.sapphire))
.style(Style::default().bg(app.theme.bg));
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(inner);
let label_local = fl!("scp-form-field-local");
let label_remote = fl!("scp-form-field-remote");
let fields: &[(&str, &str, ScpFormField)] = &[
(&label_local, &local, ScpFormField::Local),
(&label_remote, &remote, ScpFormField::Remote),
];
let inner_w = popup_area.width.saturating_sub(2) as usize;
for (i, (label, value, field)) in fields.iter().enumerate() {
let focused = *field == focus;
let (label_fg, value_bg, cursor) = if focused {
(app.theme.sapphire, app.theme.selection_bg, "█")
} else {
(app.theme.subtext0, app.theme.bg, "")
};
let label_w = label.chars().count();
let cursor_w = if focused { 1 } else { 0 };
let max_value_w = inner_w.saturating_sub(label_w + cursor_w);
let display_value: String = if value.len() > max_value_w && max_value_w > 1 {
format!(
"\u{2026}{}",
&value[value.len().saturating_sub(max_value_w.saturating_sub(1))..]
)
} else {
value.to_string()
};
let line = Line::from(vec![
Span::styled(*label, Style::default().fg(label_fg)),
Span::styled(
format!("{}{}", display_value, cursor),
Style::default().fg(app.theme.fg).bg(value_bg),
),
]);
f.render_widget(Paragraph::new(line), chunks[i]);
}
if !error.is_empty() {
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" ✗ ", Style::default().fg(app.theme.red)),
Span::styled(error, Style::default().fg(app.theme.red)),
])),
chunks[2],
);
}
f.render_widget(
Paragraph::new(fl!("scp-form-hint")).style(Style::default().fg(app.theme.subtext0)),
chunks[4],
);
}
fn draw_scp_result(f: &mut Frame, app: &mut App, area: Rect) {
let (icon, color, msg) = match &app.scp_state {
ScpState::Done { direction, exit_ok } => {
let dir_label = if *direction == ScpDirection::Upload {
fl!("scp-direction-upload-label")
} else {
fl!("scp-direction-download-label")
};
let icon = if *exit_ok { "✔" } else { "✗" };
let color = if *exit_ok {
app.theme.green
} else {
app.theme.red
};
let msg = if *exit_ok {
fl!("scp-result-success", direction = dir_label.as_str())
} else {
fl!("scp-result-errors", direction = dir_label.as_str())
};
(icon, color, msg)
}
ScpState::Error(e) => (
"✗",
app.theme.red,
fl!("scp-result-fail", error = e.as_str()),
),
_ => return,
};
let popup_h: u16 = 5;
let popup_w: u16 = (msg.len() as u16 + 8).clamp(36, area.width.saturating_sub(4));
let popup_area = centered_rect(popup_w, popup_h, area);
f.render_widget(Clear, popup_area);
let block = Block::default()
.title(fl!("scp-result-title"))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(color))
.style(Style::default().bg(app.theme.bg));
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(inner);
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(format!(" {} ", icon), Style::default().fg(color)),
Span::styled(msg, Style::default().fg(app.theme.fg)),
])),
chunks[0],
);
f.render_widget(
Paragraph::new(fl!("scp-result-hint")).style(Style::default().fg(app.theme.subtext0)),
chunks[2],
);
}
pub(crate) fn draw_help_overlay(f: &mut Frame, area: Rect, theme: &Theme) {
const ENTRIES: &[(&str, &str)] = &[
("j / ↓", "Descendre"),
("k / ↑", "Monter"),
("Enter", "Connecter"),
("Tab", "Changer de mode (Direct / Jump / Wallix)"),
("1 / 2 / 3", "Sélectionner le mode directement"),
("/", "Rechercher"),
("Esc", "Quitter la recherche / fermer"),
("q", "Quitter susshi"),
("Space", "Développer / réduire un groupe"),
("f", "Ajouter / retirer des favoris"),
("F", "Afficher uniquement les favoris"),
("H", "Trier par dernière connexion"),
("C", "Réduire tous les groupes"),
("r", "Recharger la configuration"),
("v", "Mode verbeux SSH"),
("y", "Copier la commande SSH"),
("p", "Saisir credential (passphrase / mot de passe)"),
("x", "Exécuter une commande ad-hoc (↑/↓ pour l'historique)"),
("T", "Gérer les tunnels SSH"),
("s", "Transfert SCP"),
("d", "Diagnostic SSH (probe)"),
("o", "Dashboard overview du groupe sélectionné"),
("|", "Épingler / dés-épingler le serveur (split pane)"),
("h", "Afficher / masquer cette aide"),
("g / G", "Aller en haut / bas de la liste"),
("PgUp / PgDn", "Page précédente / suivante"),
];
let col_w: u16 = 70;
let col_h: u16 = ENTRIES.len() as u16 + 4;
let popup_area = centered_rect(col_w, col_h, area);
f.render_widget(Clear, popup_area);
let block = Block::default()
.title(" Aide — raccourcis clavier (h / Esc pour fermer) ")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.sapphire))
.style(Style::default().bg(theme.bg));
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
let lines: Vec<Line> = ENTRIES
.iter()
.map(|(key, desc)| {
Line::from(vec![
Span::styled(
format!("{key:>15}"),
Style::default()
.fg(theme.sapphire)
.add_modifier(Modifier::BOLD),
),
Span::styled(" ", Style::default()),
Span::styled(*desc, Style::default().fg(theme.fg)),
])
})
.collect();
f.render_widget(Paragraph::new(lines), inner);
}
pub(crate) fn draw_overview_overlay(f: &mut Frame, app: &App, area: Rect) {
use crate::app::OverviewStatus;
let Some(ov) = &app.overview else { return };
let popup_w: u16 = area.width.saturating_sub(4).max(90).min(area.width);
let popup_h: u16 = (ov.entries.len() as u16 + 3).min(area.height.saturating_sub(2));
let popup_area = centered_rect(popup_w, popup_h, area);
let inner_w = popup_w.saturating_sub(2) as usize;
let name_w = (inner_w / 4).clamp(16, 30);
let host_w = (inner_w / 5).clamp(14, 25);
let detail_w = inner_w.saturating_sub(2 + name_w + 1 + host_w + 2);
f.render_widget(Clear, popup_area);
let block = Block::default()
.title(format!(
" Overview : {} (j/k défiler · Esc fermer) ",
ov.group_name
))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(app.theme.sapphire))
.style(Style::default().bg(app.theme.bg));
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
let visible_h = inner.height as usize;
let scroll = ov.scroll.min(ov.entries.len().saturating_sub(1));
let lines: Vec<Line> = ov
.entries
.iter()
.skip(scroll)
.take(visible_h)
.map(|entry| {
let (icon, color, detail) = match &entry.status {
OverviewStatus::Pending => ("…", app.theme.subtext0, String::new()),
OverviewStatus::Ok {
load,
ram_pct,
disk_pct,
} => (
"✓",
app.theme.green,
format!("load: {load} RAM: {ram_pct}% Disk: {disk_pct}%"),
),
OverviewStatus::Error(e) => {
let short: String = e
.lines()
.next()
.unwrap_or(e)
.chars()
.take(detail_w)
.collect();
("✗", app.theme.red, short)
}
};
Line::from(vec![
Span::styled(format!("{icon} "), Style::default().fg(color)),
Span::styled(
format!("{:<name_w$}", entry.server_name),
Style::default()
.fg(app.theme.fg)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {:<host_w$} ", entry.host),
Style::default().fg(app.theme.subtext0),
),
Span::styled(detail, Style::default().fg(color)),
])
})
.collect();
f.render_widget(Paragraph::new(lines), inner);
}