use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{
Block, BorderType, Borders, Cell, Clear, Paragraph, Row, Scrollbar, ScrollbarOrientation,
ScrollbarState, Table, TableState,
},
};
use crate::{
connection::{Database, Engine},
tui::{
AddConnectionForm, AppState, FieldId, FormInputMode, TextMode,
ui::{components::centered_rect::centered_rect_with_min, home::render_dismiss_hint},
},
};
pub fn render_add_connection(frame: &mut Frame, area: Rect, state: &AppState) {
let Some(ref form) = state.form else {
return;
};
let fields = form.visible_fields();
const LEFT_PAD: u16 = 2;
const RIGHT_PAD: u16 = 2;
const LABEL_VALUE_GAP: u16 = 3;
let label_w = fields
.iter()
.map(|f| f.label().len() + 1)
.max()
.unwrap_or(0) as u16;
let max_selector_w = 31u16;
let min_inner_w = LEFT_PAD + label_w + LABEL_VALUE_GAP + max_selector_w + RIGHT_PAD;
let min_w = min_inner_w + 2;
let content_h = 1 + fields.len() as u16 + 1 + 1;
let popup_h_pct = ((content_h + 2) * 100 / area.height.max(1)).clamp(35, 88) as u16;
let popup_area = centered_rect_with_min(40, popup_h_pct, min_w, content_h + 2, area);
frame.render_widget(Clear, popup_area);
let block = Block::default()
.title(Line::from(" Add Connection ").style(Style::default().fg(Color::Blue).bold()))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::White))
.style(Style::default().bg(Color::Reset));
let inner = block.inner(popup_area);
frame.render_widget(block, popup_area);
let [fields_area, hint_area] =
Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(inner);
for (i, field_id) in fields.iter().enumerate() {
let y = fields_area.y + 1 + i as u16; if y >= fields_area.y + fields_area.height {
break;
}
let row = Rect {
x: fields_area.x + LEFT_PAD,
y,
width: fields_area.width.saturating_sub(LEFT_PAD + RIGHT_PAD),
height: 1,
};
let is_focused = i == form.focused;
let [lbl_area, val_area] = Layout::horizontal([
Constraint::Length(label_w + LABEL_VALUE_GAP),
Constraint::Min(0),
])
.areas(row);
let lbl_style = if is_focused {
Style::default().fg(Color::Blue).bold()
} else {
Style::default().fg(Color::DarkGray)
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
format!("{}:", field_id.label()),
lbl_style,
))),
lbl_area,
);
let scroll: usize = if is_focused && field_id.is_text() {
let w = val_area.width as usize;
if form.cursor_pos >= w {
form.cursor_pos + 1 - w
} else {
0
}
} else {
0
};
render_field_value(frame, val_area, field_id, form, is_focused, scroll);
if is_focused && field_id.is_text() && form.text_mode == TextMode::Insert {
let cursor_in_view = form.cursor_pos.saturating_sub(scroll);
let cx = (val_area.x + cursor_in_view as u16)
.min(val_area.x + val_area.width.saturating_sub(1));
frame.set_cursor_position((cx, y));
}
}
render_dismiss_hint(frame, hint_area, "Ctrl+S <save> Esc/? <cancel> ");
}
fn render_field_value(
frame: &mut Frame,
area: Rect,
field: &FieldId,
form: &AddConnectionForm,
focused: bool,
scroll: usize,
) {
match field {
FieldId::Engine => {
let opts = [
("Postgres", matches!(form.engine, Engine::Postgres)),
("MySQL", matches!(form.engine, Engine::Mysql)),
("SQLite", matches!(form.engine, Engine::Sqlite)),
];
frame.render_widget(Paragraph::new(selector_line(&opts, focused)), area);
}
FieldId::InputMode => {
let opts = [
("URL", matches!(form.input_mode, FormInputMode::Url)),
("Config", matches!(form.input_mode, FormInputMode::Config)),
];
frame.render_widget(Paragraph::new(selector_line(&opts, focused)), area);
}
FieldId::Ssl => {
let opts = [("None", !form.ssl_enabled), ("Peer", form.ssl_enabled)];
frame.render_widget(Paragraph::new(selector_line(&opts, focused)), area);
}
FieldId::CreateIfMissing => {
let opts = [
("No", !form.create_if_missing),
("Yes", form.create_if_missing),
];
frame.render_widget(Paragraph::new(selector_line(&opts, focused)), area);
}
FieldId::Password => {
let full: String = "•".repeat(form.password.chars().count());
let (display, local_cur) = if focused {
(
visible_text(&full, scroll, area.width as usize),
form.cursor_pos.saturating_sub(scroll),
)
} else {
(full, 0)
};
let line = if focused {
text_line_with_cursor(display, local_cur, &form.text_mode)
} else {
text_line(display, false)
};
frame.render_widget(Paragraph::new(line), area);
}
other => {
let full = form.text_for(other).unwrap_or("").to_string();
let (display, local_cur) = if focused {
(
visible_text(&full, scroll, area.width as usize),
form.cursor_pos.saturating_sub(scroll),
)
} else {
(full, 0)
};
let line = if focused {
text_line_with_cursor(display, local_cur, &form.text_mode)
} else {
text_line(display, false)
};
frame.render_widget(Paragraph::new(line), area);
}
}
}
fn selector_line(options: &[(&'static str, bool)], focused: bool) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
for (i, (label, selected)) in options.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" "));
}
let style = if *selected {
Style::default().fg(Color::Blue).bold()
} else if focused {
Style::default().fg(Color::Gray)
} else {
Style::default().fg(Color::DarkGray)
};
let bullet: &'static str = if *selected { "●" } else { "○" };
spans.push(Span::styled(bullet, style));
spans.push(Span::styled(format!(" {label}"), style));
}
Line::from(spans)
}
fn text_line_with_cursor(value: String, cursor_pos: usize, mode: &TextMode) -> Line<'static> {
match mode {
TextMode::Insert => {
if value.is_empty() {
Line::from(Span::raw(""))
} else {
Line::from(Span::styled(value, Style::default().fg(Color::White)))
}
}
TextMode::Normal => {
let chars: Vec<char> = value.chars().collect();
let len = chars.len();
if len == 0 {
return Line::from(Span::styled(
" ",
Style::default().bg(Color::Blue).fg(Color::Black),
));
}
let pos = cursor_pos.min(len - 1);
let before: String = chars[..pos].iter().collect();
let at: String = chars[pos..pos + 1].iter().collect();
let after: String = chars[pos + 1..].iter().collect();
let mut spans: Vec<Span<'static>> = Vec::new();
if !before.is_empty() {
spans.push(Span::styled(before, Style::default().fg(Color::White)));
}
spans.push(Span::styled(
at,
Style::default().bg(Color::Blue).fg(Color::Black).bold(),
));
if !after.is_empty() {
spans.push(Span::styled(after, Style::default().fg(Color::White)));
}
Line::from(spans)
}
}
}
pub fn visible_text(text: &str, scroll: usize, width: usize) -> String {
let chars: Vec<char> = text.chars().collect();
let start = scroll.min(chars.len());
let end = (start + width).min(chars.len());
chars[start..end].iter().collect()
}
fn text_line(value: String, focused: bool) -> Line<'static> {
if focused {
Line::from(Span::styled(value, Style::default().fg(Color::White)))
} else if value.is_empty() {
Line::from(Span::styled("—", Style::default().fg(Color::DarkGray)))
} else {
Line::from(Span::styled(value, Style::default().fg(Color::Gray)))
}
}
pub fn render_connection_list(frame: &mut Frame, area: Rect, state: &AppState) {
let viewport_height = area.height as usize;
let total = state.connections.len();
let needs_scrollbar = total > viewport_height;
let offset = state
.selected_connection
.saturating_sub(viewport_height.saturating_sub(1));
let table_area = if needs_scrollbar {
Rect {
width: area.width.saturating_sub(1),
..area
}
} else {
area
};
let rows: Vec<Row> = state
.connections
.iter()
.enumerate()
.map(|(i, db)| connection_row(db, i == state.selected_connection))
.collect();
let widths = [
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(11),
];
let table = Table::new(rows, widths)
.column_spacing(1)
.row_highlight_style(Style::default().bg(Color::Rgb(28, 42, 74)).bold());
let mut table_state = TableState::default()
.with_offset(offset)
.with_selected(Some(state.selected_connection));
frame.render_stateful_widget(table, table_area, &mut table_state);
if needs_scrollbar {
let mut scrollbar_state =
ScrollbarState::new(total.saturating_sub(viewport_height)).position(offset);
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.track_symbol(Some("│"))
.thumb_symbol("█");
frame.render_stateful_widget(scrollbar, area, &mut scrollbar_state);
}
}
fn connection_row(db: &Database, selected: bool) -> Row<'static> {
let bullet = if selected {
Cell::from(Span::styled("●", Style::default().fg(Color::Blue).bold()))
} else {
Cell::from(Span::styled("○", Style::default().fg(Color::DarkGray)))
};
let name = Cell::from(db.name.clone());
let badge = Cell::from(Line::from(db.engine.badge()));
Row::new(vec![bullet, name, badge])
}
pub fn render_empty_connections(frame: &mut Frame, area: Rect) {
let lines = vec![
Line::from(""),
Line::from(Span::styled(
"No saved connections",
Style::default().fg(Color::DarkGray),
))
.centered(),
Line::from(Span::styled(
"Press \'a\' to add one",
Style::default().fg(Color::DarkGray),
))
.centered(),
];
frame.render_widget(Paragraph::new(lines), area);
}
pub fn select_next(state: &mut AppState) {
if state.connections.is_empty() {
return;
}
state.selected_connection = (state.selected_connection + 1) % state.connections.len();
}
pub fn select_prev(state: &mut AppState) {
if state.connections.is_empty() {
return;
}
let len = state.connections.len();
state.selected_connection = (state.selected_connection + len - 1) % len;
}
pub fn goto_top(state: &mut AppState) {
state.selected_connection = 0;
}
pub fn goto_bottom(state: &mut AppState) {
if !state.connections.is_empty() {
state.selected_connection = state.connections.len() - 1;
}
}
pub fn selected_connection(state: &AppState) -> Option<&Database> {
state.connections.get(state.selected_connection)
}
pub fn remove_selected(state: &mut AppState) {
if state.connections.is_empty() {
return;
}
state.connections.remove(state.selected_connection);
if state.selected_connection > 0 && state.selected_connection >= state.connections.len() {
state.selected_connection -= 1;
}
}