use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use ratatui::widgets::{ListItem, ListState, Paragraph};
use unicode_width::UnicodeWidthStr;
use super::theme;
const MAX_VISIBLE_ROWS: u16 = 16;
pub(super) fn render_search_input(frame: &mut Frame, area: Rect, query: &str) {
let line = if query.is_empty() {
Line::from(Span::styled(" type to filter hosts...", theme::muted()))
} else {
Line::from(vec![
Span::styled(" /", theme::accent_bold()),
Span::styled(query.to_string(), theme::brand()),
Span::styled("\u{2588}", theme::accent_bold()),
])
};
frame.render_widget(Paragraph::new(line), area);
}
pub(super) fn render_picker_separator(frame: &mut Frame, area: Rect) {
let sep_width = (area.width as usize).saturating_sub(1);
let sep = Line::from(Span::styled(
format!(" {}", "\u{2500}".repeat(sep_width)),
theme::muted(),
));
frame.render_widget(Paragraph::new(sep), area);
}
pub(super) fn build_alias_hostname_row(
alias: &str,
hostname: &str,
content_w: usize,
) -> ListItem<'static> {
ListItem::new(Line::from(build_alias_hostname_spans(
alias, hostname, content_w,
)))
}
fn build_alias_hostname_spans(alias: &str, hostname: &str, content_w: usize) -> Vec<Span<'static>> {
let leading = 2;
let gap = 2;
let alias_w = alias.width().min(content_w.saturating_sub(leading));
let remaining = content_w
.saturating_sub(leading)
.saturating_sub(alias_w)
.saturating_sub(gap);
let hostname_truncated = super::truncate(hostname, remaining);
vec![
Span::raw(" "),
Span::styled(alias.to_string(), theme::bold()),
Span::raw(" "),
Span::styled(hostname_truncated, theme::muted()),
]
}
pub(super) fn host_picker_overlay_area(frame: &Frame, visible_count: usize) -> Rect {
let frame_area = frame.area();
let (width, height) =
host_picker_overlay_dimensions(frame_area.width, frame_area.height, visible_count);
super::centered_rect_fixed(width, height, frame_area)
}
fn host_picker_overlay_dimensions(term_w: u16, term_h: u16, visible_count: usize) -> (u16, u16) {
let list_height = (visible_count as u16).clamp(1, MAX_VISIBLE_ROWS);
let total_height = 2 + 1 + 1 + list_height;
let dynamic_width = 48u16.max(term_w * 60 / 100);
let overlay_width = dynamic_width.min(term_w.saturating_sub(4));
let height = total_height.min(term_h.saturating_sub(3));
(overlay_width, height)
}
pub(super) fn clamp_picker_selection(state: &mut ListState, visible_count: usize) {
let sel = state.selected();
let new_sel = match sel {
Some(i) if i < visible_count => Some(i),
_ => Some(0),
};
if new_sel != sel {
state.select(new_sel);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clamp_keeps_in_range_selection() {
let mut state = ListState::default();
state.select(Some(2));
clamp_picker_selection(&mut state, 5);
assert_eq!(state.selected(), Some(2));
}
#[test]
fn clamp_resets_out_of_range_selection_to_zero() {
let mut state = ListState::default();
state.select(Some(7));
clamp_picker_selection(&mut state, 3);
assert_eq!(state.selected(), Some(0));
}
#[test]
fn clamp_none_selection_becomes_zero() {
let mut state = ListState::default();
clamp_picker_selection(&mut state, 3);
assert_eq!(state.selected(), Some(0));
}
#[test]
fn clamp_at_boundary_index_equals_count_is_reset() {
let mut state = ListState::default();
state.select(Some(3));
clamp_picker_selection(&mut state, 3);
assert_eq!(state.selected(), Some(0));
}
fn flatten_spans(spans: &[Span<'_>]) -> String {
spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn build_row_pads_alias_and_appends_hostname() {
let spans = build_alias_hostname_spans("prod", "10.0.0.1", 60);
let text = flatten_spans(&spans);
assert!(text.contains("prod"));
assert!(text.contains("10.0.0.1"));
}
#[test]
fn build_row_truncates_hostname_to_remaining_budget() {
let spans = build_alias_hostname_spans("ab", "very-long-host.example.com", 18);
let text = flatten_spans(&spans);
assert!(text.ends_with('\u{2026}'), "expected ellipsis, got: {text}");
}
#[test]
fn build_row_handles_zero_width_budget_without_panic() {
let _ = build_alias_hostname_spans("alias", "host", 0);
}
#[test]
fn overlay_dimensions_clamp_height_at_max_visible_rows() {
let (_w, h) = host_picker_overlay_dimensions(120, 80, 100);
assert_eq!(h, 2 + 1 + 1 + MAX_VISIBLE_ROWS);
}
#[test]
fn overlay_dimensions_floor_height_at_one_row_when_empty() {
let (_w, h) = host_picker_overlay_dimensions(120, 80, 0);
assert_eq!(h, 2 + 1 + 1 + 1);
}
#[test]
fn overlay_dimensions_width_is_60_percent_of_wide_terminal() {
let (w, _h) = host_picker_overlay_dimensions(120, 80, 5);
assert_eq!(w, 72);
}
#[test]
fn overlay_dimensions_width_floors_at_48_on_narrow_terminal() {
let (w, _h) = host_picker_overlay_dimensions(50, 30, 5);
assert_eq!(w, 46);
}
#[test]
fn overlay_dimensions_height_clamped_by_short_terminal() {
let (_w, h) = host_picker_overlay_dimensions(120, 8, 5);
assert_eq!(h, 5);
}
}