use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
Frame,
};
use crate::app::{AppAction, AppState, NavDir, ViewState};
use crate::ui::card::{render_card, CardData, CARD_HEIGHT, CARD_MIN_WIDTH};
use crate::ui::{host_list, popup};
const CARD_GAP: u16 = 1;
pub fn render(frame: &mut Frame, area: Rect, state: &AppState, view: &ViewState) {
if area.width < 40 || area.height < 10 {
frame.render_widget(
Paragraph::new("Terminal too small for dashboard.")
.style(Style::default().fg(view.theme.text_error)),
area,
);
return;
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(area);
let header_area = chunks[0];
let grid_area = chunks[1];
render_header(frame, header_area, state, view);
render_grid(frame, grid_area, state, view);
let hlv = &view.host_list;
if let Some(popup) = &hlv.popup {
use crate::app::HostPopup;
match popup {
HostPopup::Add(form) => popup::render_host_form(frame, form, "Add Host", &view.theme),
HostPopup::Edit { form, .. } => {
popup::render_host_form(frame, form, "Edit Host", &view.theme)
}
HostPopup::DeleteConfirm(idx) => {
let name = state
.hosts
.get(*idx)
.map(|h| h.name.as_str())
.unwrap_or("?");
popup::render_delete_confirm(frame, name, &view.theme);
}
HostPopup::KeySetupConfirm(idx) => {
let host = state.hosts.get(*idx);
popup::render_key_setup_confirm(frame, host, &view.theme);
}
HostPopup::KeySetupProgress {
host_name,
current_step,
..
} => {
popup::render_key_setup_progress(
frame,
host_name,
current_step.as_ref(),
&view.theme,
);
}
}
}
if hlv.tag_popup_open {
popup::render_tag_filter_popup(
frame,
&hlv.available_tags,
hlv.tag_popup_selected,
hlv.tag_filter.as_deref(),
&view.theme,
);
}
}
fn render_header(frame: &mut Frame, area: Rect, state: &AppState, view: &ViewState) {
let hlv = &view.host_list;
let mut spans = vec![
Span::styled(
" Dashboard ",
Style::default()
.fg(view.theme.title)
.add_modifier(Modifier::BOLD),
),
Span::styled(" ", Style::default()),
Span::styled(
format!("[sort: {}]", hlv.sort_order.label()),
Style::default().fg(view.theme.accent),
),
Span::styled(" ", Style::default()),
];
if let Some(tag) = &hlv.tag_filter {
spans.push(Span::styled(
format!("[filter: {}]", tag),
Style::default().fg(view.theme.text_warning),
));
spans.push(Span::styled(" ", Style::default()));
}
if !hlv.search_query.is_empty() {
spans.push(Span::styled(
format!("[search: {}]", hlv.search_query),
Style::default().fg(view.theme.highlight),
));
spans.push(Span::styled(" ", Style::default()));
}
let mut hints = String::from("r:refresh s:sort t:tags /:search a:add x:execute");
if let Some(idx) = hlv.selected_host_idx() {
if let Some(host) = state.hosts.get(idx) {
if host.password.is_some() && host.identity_file.is_none() {
hints.push_str(" Shift+K:ssh-setup");
}
}
}
spans.push(Span::styled(
hints,
Style::default().fg(view.theme.text_muted),
));
frame.render_widget(
Paragraph::new(Line::from(spans)).style(Style::default().bg(Color::Reset)),
area,
);
}
fn render_grid(frame: &mut Frame, area: Rect, state: &AppState, view: &ViewState) {
let hlv = &view.host_list;
if state.hosts.is_empty() {
frame.render_widget(
Paragraph::new(
"\n No hosts configured.\n\n Press a to add a host, or r to reload from ~/.ssh/config.",
)
.style(Style::default().fg(view.theme.text_muted)),
area,
);
return;
}
if hlv.filtered_indices.is_empty() {
frame.render_widget(
Paragraph::new("\n No hosts match the current filter.")
.style(Style::default().fg(view.theme.text_muted)),
area,
);
return;
}
if area.height < CARD_HEIGHT + 2 {
return;
}
let cols = compute_columns(area.width);
let card_w = compute_card_width(area.width, cols);
let selected = hlv.selected;
let total = hlv.filtered_indices.len();
let rows_visible = (area.height / (CARD_HEIGHT + CARD_GAP)).max(1);
let selected_row = (selected / cols as usize) as u16;
let scroll_rows = selected_row.saturating_sub(rows_visible.saturating_sub(1));
let skip_cards = scroll_rows as usize * cols as usize;
let total_rows = (total as u16).div_ceil(cols);
if total_rows > rows_visible {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight);
let mut sb_state = ScrollbarState::new(total_rows as usize).position(scroll_rows as usize);
let sb_area = Rect {
x: area.x + area.width.saturating_sub(1),
y: area.y,
width: 1,
height: area.height,
};
frame.render_stateful_widget(scrollbar, sb_area, &mut sb_state);
}
let mut y = area.y;
let mut card_idx = skip_cards;
'outer: while y + CARD_HEIGHT <= area.y + area.height {
let mut x = area.x;
for col in 0..cols {
if card_idx >= total {
break 'outer;
}
let host_idx = hlv.filtered_indices[card_idx];
let host = &state.hosts[host_idx];
let metrics = state.metrics.get(&host.name);
let status = state.connection_statuses.get(&host.name);
let is_selected = card_idx == selected;
let card_rect = Rect {
x,
y,
width: card_w,
height: CARD_HEIGHT,
};
if card_rect.x + card_rect.width <= area.x + area.width {
render_card(
frame,
card_rect,
&CardData {
host_name: &host.name,
hostname: &host.hostname,
user: &host.user,
port: host.port,
tags: &host.tags,
metrics,
status,
services: state.services.get(&host.name).map(|s| s.as_slice()),
alerts: state.alerts.get(&host.name).map(|a| a.as_slice()),
},
is_selected,
&view.theme,
);
}
x += card_w + CARD_GAP;
card_idx += 1;
let _ = col;
}
y += CARD_HEIGHT + CARD_GAP;
}
let _ = Block::default().borders(Borders::NONE);
}
fn compute_columns(width: u16) -> u16 {
((width + CARD_GAP) / (CARD_MIN_WIDTH + CARD_GAP)).max(1)
}
fn compute_card_width(total_width: u16, cols: u16) -> u16 {
if cols == 0 {
return total_width;
}
(total_width.saturating_sub((cols - 1) * CARD_GAP)) / cols
}
pub fn handle_input(key: KeyEvent, view: &mut ViewState) -> Option<AppAction> {
let hlv = &mut view.host_list;
if hlv.search_mode {
return host_list::handle_input(key, view);
}
if hlv.popup.is_some() {
return host_list::handle_input(key, view);
}
match key.code {
KeyCode::Char('j') | KeyCode::Down => Some(AppAction::DashboardNav(NavDir::Down)),
KeyCode::Char('k') | KeyCode::Up => Some(AppAction::DashboardNav(NavDir::Up)),
KeyCode::Char('h') | KeyCode::Left => Some(AppAction::DashboardNav(NavDir::Left)),
KeyCode::Char('l') | KeyCode::Right => Some(AppAction::DashboardNav(NavDir::Right)),
KeyCode::Enter => Some(AppAction::OpenDetailView),
KeyCode::Char('a') => {
use crate::app::{HostForm, HostPopup};
view.host_list.popup = Some(HostPopup::Add(HostForm::empty()));
None
}
KeyCode::Char('e') => Some(AppAction::OpenEditPopup),
KeyCode::Char('d') => {
use crate::app::HostPopup;
if let Some(idx) = view.host_list.selected_host_idx() {
view.host_list.popup = Some(HostPopup::DeleteConfirm(idx));
}
None
}
kc if kc == KeyCode::Char('/') || kc == view.keybindings.search => {
view.host_list.search_mode = true;
None
}
KeyCode::Char('r') => Some(AppAction::RefreshMetrics),
KeyCode::Char('s') => Some(AppAction::CycleSortOrder),
KeyCode::Char('t') => Some(AppAction::OpenTagFilter),
KeyCode::Char('x') => Some(AppAction::OpenQuickExecute),
KeyCode::Char('K') => Some(AppAction::StartKeySetup),
KeyCode::Esc => {
if !view.host_list.search_query.is_empty() {
view.host_list.search_query.clear();
return Some(AppAction::SearchQueryChanged);
}
None
}
_ => None,
}
}
pub fn handle_tag_popup_input(key: KeyEvent, view: &mut ViewState) -> Option<AppAction> {
let hlv = &mut view.host_list;
let total = hlv.available_tags.len() + 1;
match key.code {
KeyCode::Esc | KeyCode::Char('t') => {
hlv.tag_popup_open = false;
None
}
KeyCode::Char('j') | KeyCode::Down => {
hlv.tag_popup_selected = (hlv.tag_popup_selected + 1).min(total.saturating_sub(1));
None
}
KeyCode::Char('k') | KeyCode::Up => {
hlv.tag_popup_selected = hlv.tag_popup_selected.saturating_sub(1);
None
}
KeyCode::Enter => {
let sel = hlv.tag_popup_selected;
let chosen = if sel == 0 {
None } else {
hlv.available_tags.get(sel - 1).cloned()
};
Some(AppAction::TagFilterSelected(chosen))
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
hlv.tag_popup_open = false;
None
}
_ => None,
}
}