use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, List, ListItem, Paragraph},
Frame,
};
use crossterm::event::{KeyCode, KeyEvent};
use crate::app::{AppAction, AppState, SplitFocus, TermHostPicker, TermTab, ViewState};
pub fn render(frame: &mut Frame, area: Rect, state: &AppState, view: &ViewState) {
let tv = &view.terminal_view;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(area);
let tab_area = chunks[0];
let content_area = chunks[1];
render_tab_bar(frame, tab_area, tv, &view.theme);
if tv.tabs.is_empty() {
let msg = Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(
" No SSH sessions open.",
Style::default().fg(view.theme.text_muted),
)),
Line::from(""),
Line::from(Span::styled(
" Press Ctrl+T to connect to a host.",
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::ITALIC),
)),
])
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(view.theme.text_muted)),
);
frame.render_widget(msg, content_area);
} else {
match &tv.split {
None => {
if let Some(tab) = tv.tabs.get(tv.active_tab) {
render_pty_pane(frame, content_area, tab, true, &view.theme);
}
}
Some(sv) => {
let (primary_constraint, secondary_constraint) = match sv.direction {
crate::app::SplitDirection::Vertical => {
let half = content_area.width / 2;
(Constraint::Length(half), Constraint::Min(1))
}
crate::app::SplitDirection::Horizontal => {
let half = content_area.height / 2;
(Constraint::Length(half), Constraint::Min(1))
}
};
let split_dir = match sv.direction {
crate::app::SplitDirection::Vertical => Direction::Horizontal,
crate::app::SplitDirection::Horizontal => Direction::Vertical,
};
let pane_areas = Layout::default()
.direction(split_dir)
.constraints([primary_constraint, secondary_constraint])
.split(content_area);
let primary_focused = matches!(tv.split_focus, SplitFocus::Primary);
if let Some(primary_tab) = tv.tabs.get(tv.active_tab) {
render_pty_pane(
frame,
pane_areas[0],
primary_tab,
primary_focused,
&view.theme,
);
}
if let Some(secondary_tab) = tv.tabs.get(sv.secondary_tab) {
render_pty_pane(
frame,
pane_areas[1],
secondary_tab,
!primary_focused,
&view.theme,
);
}
}
}
}
if let Some(picker) = &tv.host_picker {
render_host_picker(frame, area, picker, state, &view.theme);
}
}
fn render_tab_bar(
frame: &mut Frame,
area: Rect,
tv: &crate::app::TerminalView,
theme: &crate::ui::theme::Theme,
) {
let mut spans: Vec<Span> = Vec::new();
let secondary_tab_idx = tv.split.as_ref().map(|sv| sv.secondary_tab);
for (i, tab) in tv.tabs.iter().enumerate() {
let is_primary = i == tv.active_tab;
let is_secondary = secondary_tab_idx == Some(i);
let is_visible_in_split = is_primary || is_secondary;
let label = if tv.tab_select_mode {
if tab.has_activity && !is_visible_in_split {
format!(" [{}] ● {} ", i + 1, tab.host_name)
} else {
format!(" [{}] {} ", i + 1, tab.host_name)
}
} else if tab.has_activity && !is_visible_in_split {
format!(" ● {} ", tab.host_name)
} else {
format!(" {} ", tab.host_name)
};
let style = if is_primary && tv.split_focus == crate::app::SplitFocus::Primary {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else if is_secondary && tv.split_focus == crate::app::SplitFocus::Secondary {
Style::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD)
} else if is_primary || is_secondary {
Style::default()
.fg(Color::White)
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD)
} else if tab.has_activity {
Style::default().fg(theme.text_warning).bg(Color::DarkGray)
} else {
Style::default()
.fg(theme.text_secondary)
.bg(Color::DarkGray)
};
spans.push(Span::styled(label, style));
spans.push(Span::styled("│", Style::default().fg(theme.text_muted)));
}
let hint = if tv.split.is_some() {
" Ctrl+T:new Ctrl+W:close Ctrl+H:switch-pane Ctrl+\\ :split-v Ctrl+]:split-h │ Drag to select & copy text"
} else {
" Ctrl+T:new Ctrl+W:close Ctrl+\\ :split-v Ctrl+]:split-h │ Drag to select & copy text"
};
spans.push(Span::styled(hint, Style::default().fg(theme.text_muted)));
let line = Line::from(spans);
frame.render_widget(
Paragraph::new(line).style(Style::default().bg(Color::Reset)),
area,
);
}
fn render_pty_pane(
frame: &mut Frame,
area: Rect,
tab: &TermTab,
focused: bool,
theme: &crate::ui::theme::Theme,
) {
let border_style = if focused {
Style::default().fg(theme.accent)
} else {
Style::default().fg(theme.text_muted)
};
let title = if tab.scroll_offset > 0 {
format!(" {} ↑ scroll — type to return ", tab.host_name)
} else {
format!(" {} ", tab.host_name)
};
let block = Block::default()
.title(title.as_str())
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style);
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.width == 0 || inner.height == 0 {
return;
}
let render_rows = inner.height;
let render_cols = inner.width;
struct CellSnap {
display: String,
style: Style,
}
let (rows_snap, cursor_pos, hide_cursor) = {
let mut guard = match tab.parser.lock() {
Ok(g) => g,
Err(_) => {
frame.render_widget(
Paragraph::new(" [parser error]").style(Style::default().fg(theme.text_error)),
inner,
);
return;
}
};
guard.set_scrollback(tab.scroll_offset);
let screen = guard.screen();
let mut rows_snap: Vec<Vec<CellSnap>> = Vec::with_capacity(render_rows as usize);
for vt_row in screen.visible_rows_iter().take(render_rows as usize) {
let mut row_snap: Vec<CellSnap> = Vec::with_capacity(render_cols as usize);
for c in 0..render_cols {
let snap = match vt_row.get(c) {
None => CellSnap {
display: " ".into(),
style: Style::default(),
},
Some(cell) => {
let text = cell.contents();
CellSnap {
display: if text.is_empty() { " ".into() } else { text },
style: cell_to_style(cell),
}
}
};
row_snap.push(snap);
}
rows_snap.push(row_snap);
}
while rows_snap.len() < render_rows as usize {
rows_snap.push(
(0..render_cols)
.map(|_| CellSnap {
display: " ".into(),
style: Style::default(),
})
.collect(),
);
}
let hide_cursor = screen.hide_cursor();
let cursor_pos = screen.cursor_position();
(rows_snap, cursor_pos, hide_cursor)
};
for (row, row_cells) in rows_snap.into_iter().enumerate() {
let row = row as u16;
let mut spans: Vec<Span> = Vec::new();
let mut buf_style: Option<Style> = None;
let mut buf_text = String::new();
for cell in row_cells {
if buf_style == Some(cell.style) {
buf_text.push_str(&cell.display);
} else {
if let Some(s) = buf_style {
spans.push(Span::styled(std::mem::take(&mut buf_text), s));
}
buf_style = Some(cell.style);
buf_text = cell.display;
}
}
if let Some(s) = buf_style {
spans.push(Span::styled(buf_text, s));
}
let line_area = Rect {
x: inner.x,
y: inner.y + row,
width: inner.width,
height: 1,
};
frame.render_widget(Paragraph::new(Line::from(spans)), line_area);
}
if focused && tab.scroll_offset == 0 && !hide_cursor {
let (cur_row, cur_col) = cursor_pos;
if cur_row < render_rows && cur_col < render_cols {
frame.set_cursor_position((inner.x + cur_col, inner.y + cur_row));
}
}
}
fn cell_to_style(cell: &vt100::Cell) -> Style {
let mut style = Style::default();
match cell.fgcolor() {
vt100::Color::Default => {}
vt100::Color::Idx(i) => style = style.fg(ansi_idx_to_color(i)),
vt100::Color::Rgb(r, g, b) => style = style.fg(Color::Rgb(r, g, b)),
}
match cell.bgcolor() {
vt100::Color::Default => {}
vt100::Color::Idx(i) => style = style.bg(ansi_idx_to_color(i)),
vt100::Color::Rgb(r, g, b) => style = style.bg(Color::Rgb(r, g, b)),
}
if cell.bold() {
style = style.add_modifier(Modifier::BOLD);
}
if cell.italic() {
style = style.add_modifier(Modifier::ITALIC);
}
if cell.underline() {
style = style.add_modifier(Modifier::UNDERLINED);
}
if cell.inverse() {
style = style.add_modifier(Modifier::REVERSED);
}
style
}
fn ansi_idx_to_color(idx: u8) -> Color {
match idx {
0 => Color::Black,
1 => Color::Red,
2 => Color::Green,
3 => Color::Yellow,
4 => Color::Blue,
5 => Color::Magenta,
6 => Color::Cyan,
7 => Color::White,
8 => Color::DarkGray, 9 => Color::LightRed,
10 => Color::LightGreen,
11 => Color::LightYellow,
12 => Color::LightBlue,
13 => Color::LightMagenta,
14 => Color::LightCyan,
15 => Color::White, _ => Color::Reset,
}
}
fn render_host_picker(
frame: &mut Frame,
area: Rect,
picker: &TermHostPicker,
state: &AppState,
theme: &crate::ui::theme::Theme,
) {
let popup_w = 60u16.min(area.width.saturating_sub(4)).max(20);
let list_h = (state.hosts.len() as u16 + 2)
.min(20)
.min(area.height.saturating_sub(4))
.max(3);
let popup_h = list_h + 2; let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2;
let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2;
let popup_area = Rect {
x: popup_x,
y: popup_y,
width: popup_w,
height: popup_h,
};
frame.render_widget(Clear, popup_area);
let title = if picker.switch_pane_mode {
" Switch pane to host (Enter to switch) "
} else {
" Connect to host (Enter to open tab) "
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.accent));
let inner = block.inner(popup_area);
frame.render_widget(block, popup_area);
if state.hosts.is_empty() {
frame.render_widget(
Paragraph::new(" No hosts configured. Add one on the Dashboard (a)."),
inner,
);
return;
}
let visible = inner.height as usize;
let cursor = picker.cursor.min(state.hosts.len().saturating_sub(1));
let scroll = cursor.saturating_sub(visible.saturating_sub(1));
let items: Vec<ListItem> = state
.hosts
.iter()
.skip(scroll)
.take(visible)
.enumerate()
.map(|(i, h)| {
let actual_idx = i + scroll;
let tag_str = if h.tags.is_empty() {
String::new()
} else {
format!(" [{}]", h.tags.join(", "))
};
let label = format!(
" {} — {}@{}:{}{}",
h.name, h.user, h.hostname, h.port, tag_str
);
let style = if actual_idx == cursor {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text_primary)
};
ListItem::new(label).style(style)
})
.collect();
frame.render_widget(List::new(items), inner);
}
pub fn handle_host_picker_input(key: KeyEvent, view: &mut ViewState) -> Option<AppAction> {
match key.code {
KeyCode::Char('j') | KeyCode::Down => Some(AppAction::TermHostPickerNav(1)),
KeyCode::Char('k') | KeyCode::Up => Some(AppAction::TermHostPickerNav(-1)),
KeyCode::Enter => {
let cursor = view.terminal_view.host_picker.as_ref()?.cursor;
Some(AppAction::TermHostPickerSelect(cursor))
}
KeyCode::Esc => Some(AppAction::TermCloseHostPicker),
_ => None,
}
}