use std::io;
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table};
use ratatui::{Frame, Terminal};
use crate::config::{Config, ConnectionProfile, ThemeName};
use super::TerminalSession;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StartupChoice {
Profile(ConnectionProfile),
Demo,
}
#[derive(Debug, Default)]
struct StartupState {
selected: usize,
mode: StartupMode,
status: String,
pending_delete: Option<usize>,
}
#[derive(Debug, Default)]
enum StartupMode {
#[default]
List,
Sqlite {
name: String,
path: String,
write: bool,
field: usize,
},
Dynamo {
name: String,
aws_profile: String,
region: String,
endpoint_url: String,
local: bool,
write: bool,
field: usize,
},
}
pub fn run(config: &mut Config) -> io::Result<Option<StartupChoice>> {
let mut stdout = io::stdout();
let mut session = TerminalSession::enter(&mut stdout)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut state = StartupState::default();
let result = loop {
terminal.draw(|frame| draw(frame, &state, config))?;
if !event::poll(Duration::from_millis(100))? {
continue;
}
match event::read()? {
Event::Key(key) if key.kind != KeyEventKind::Release => {
if let Some(choice) = handle_key(&mut state, config, key.code) {
break Ok(choice);
}
}
Event::Paste(text) => handle_paste(&mut state, &text),
_ => {}
}
};
drop(terminal);
let cleanup = session.restore();
match (result, cleanup) {
(Ok(choice), Ok(())) => Ok(choice),
(Err(error), _) | (_, Err(error)) => Err(error),
}
}
fn handle_paste(state: &mut StartupState, text: &str) {
match &mut state.mode {
StartupMode::Sqlite {
name, path, field, ..
} => match *field {
0 => name.push_str(text),
1 => path.push_str(text),
_ => {}
},
StartupMode::Dynamo {
name,
aws_profile,
region,
endpoint_url,
field,
..
} => match *field {
0 => name.push_str(text),
1 => aws_profile.push_str(text),
2 => region.push_str(text),
3 => endpoint_url.push_str(text),
_ => {}
},
StartupMode::List => {}
}
}
fn handle_key(
state: &mut StartupState,
config: &mut Config,
key: KeyCode,
) -> Option<Option<StartupChoice>> {
match &mut state.mode {
StartupMode::List => handle_list_key(state, config, key),
StartupMode::Sqlite {
name,
path,
write,
field,
} => {
if key == KeyCode::Esc {
state.mode = StartupMode::List;
return None;
}
match key {
KeyCode::Tab | KeyCode::Down => *field = (*field + 1).min(3),
KeyCode::BackTab | KeyCode::Up => *field = field.saturating_sub(1),
KeyCode::Char(' ') if *field == 2 => *write = !*write,
KeyCode::Backspace if *field < 2 => {
if *field == 0 {
name.pop();
} else {
path.pop();
}
}
KeyCode::Char(character) if *field < 2 => {
if *field == 0 {
name.push(character);
} else {
path.push(character);
}
}
KeyCode::Enter if *field < 3 => *field = (*field + 1).min(3),
KeyCode::Enter => {
if name.trim().is_empty() || path.trim().is_empty() {
state.status = "name and database path are required".to_string();
return None;
}
let path = match std::fs::canonicalize(path.trim()) {
Ok(path) => path,
Err(error) => {
state.status = format!("database path: {error}");
return None;
}
};
let profile = ConnectionProfile::Sqlite {
name: name.trim().to_string(),
path,
write: *write,
};
return save_and_choose(state, config, profile);
}
_ => {}
}
None
}
StartupMode::Dynamo {
name,
aws_profile,
region,
endpoint_url,
local,
write,
field,
} => {
if key == KeyCode::Esc {
state.mode = StartupMode::List;
return None;
}
match key {
KeyCode::Tab | KeyCode::Down => *field = (*field + 1).min(6),
KeyCode::BackTab | KeyCode::Up => *field = field.saturating_sub(1),
KeyCode::Char(' ') if *field == 4 => *local = !*local,
KeyCode::Char(' ') if *field == 5 => *write = !*write,
KeyCode::Backspace if *field < 4 => match *field {
0 => {
name.pop();
}
1 => {
aws_profile.pop();
}
2 => {
region.pop();
}
_ => {
endpoint_url.pop();
}
},
KeyCode::Char(character) if *field < 4 => match *field {
0 => name.push(character),
1 => aws_profile.push(character),
2 => region.push(character),
_ => endpoint_url.push(character),
},
KeyCode::Enter if *field < 6 => *field = (*field + 1).min(6),
KeyCode::Enter => {
if name.trim().is_empty() {
state.status = "profile name is required".to_string();
return None;
}
let profile = ConnectionProfile::Dynamodb {
name: name.trim().to_string(),
aws_profile: non_empty(aws_profile),
region: non_empty(region),
endpoint_url: non_empty(endpoint_url),
local: *local,
write: *write,
};
return save_and_choose(state, config, profile);
}
_ => {}
}
None
}
}
}
fn handle_list_key(
state: &mut StartupState,
config: &mut Config,
key: KeyCode,
) -> Option<Option<StartupChoice>> {
let item_count = config.profiles.len() + 3;
match key {
KeyCode::Char('q') | KeyCode::Esc => Some(None),
KeyCode::Down | KeyCode::Char('j') => {
state.pending_delete = None;
state.selected = (state.selected + 1).min(item_count.saturating_sub(1));
None
}
KeyCode::Up | KeyCode::Char('k') => {
state.pending_delete = None;
state.selected = state.selected.saturating_sub(1);
None
}
KeyCode::Char('d') if state.selected < config.profiles.len() => {
if state.pending_delete != Some(state.selected) {
state.pending_delete = Some(state.selected);
state.status = format!(
"press d again to delete `{}`",
config.profiles[state.selected].name()
);
return None;
}
let previous = config.profiles.clone();
config.profiles.remove(state.selected);
state.pending_delete = None;
state.selected = state.selected.min(config.profiles.len().saturating_add(2));
match config.save() {
Ok(()) => state.status = "profile removed".to_string(),
Err(error) => {
config.profiles = previous;
state.status = format!("config: {error}");
}
}
None
}
KeyCode::Enter if state.selected < config.profiles.len() => Some(Some(
StartupChoice::Profile(config.profiles[state.selected].clone()),
)),
KeyCode::Enter if state.selected == config.profiles.len() => {
state.mode = StartupMode::Sqlite {
name: String::new(),
path: String::new(),
write: false,
field: 0,
};
state.status.clear();
None
}
KeyCode::Enter if state.selected == config.profiles.len() + 1 => {
state.mode = StartupMode::Dynamo {
name: String::new(),
aws_profile: String::new(),
region: String::new(),
endpoint_url: String::new(),
local: false,
write: false,
field: 0,
};
state.status.clear();
None
}
KeyCode::Enter => Some(Some(StartupChoice::Demo)),
_ => None,
}
}
fn save_and_choose(
state: &mut StartupState,
config: &mut Config,
profile: ConnectionProfile,
) -> Option<Option<StartupChoice>> {
if config.profile(profile.name()).is_some() {
state.status = format!("profile `{}` already exists", profile.name());
return None;
}
let previous = config.profiles.clone();
config.upsert_profile(profile.clone());
match config.save() {
Ok(()) => Some(Some(StartupChoice::Profile(profile))),
Err(error) => {
config.profiles = previous;
state.status = format!("config: {error}");
None
}
}
}
fn non_empty(value: &str) -> Option<String> {
let value = value.trim();
(!value.is_empty()).then(|| value.to_string())
}
fn draw(frame: &mut Frame, state: &StartupState, config: &Config) {
let colors = startup_colors(config.theme);
let area = centered(frame.area(), 76, 24);
frame.render_widget(Clear, area);
match &state.mode {
StartupMode::List => draw_list(frame, area, state, config, colors),
StartupMode::Sqlite {
name,
path,
write,
field,
} => draw_sqlite_form(
frame,
area,
state,
SqliteForm {
name,
path,
write: *write,
field: *field,
},
colors,
),
StartupMode::Dynamo {
name,
aws_profile,
region,
endpoint_url,
local,
write,
field,
} => draw_dynamo_form(
frame,
area,
state,
DynamoForm {
name,
aws_profile,
region,
endpoint_url,
local: *local,
write: *write,
field: *field,
},
colors,
),
}
}
fn draw_list(
frame: &mut Frame,
area: Rect,
state: &StartupState,
config: &Config,
colors: StartupColors,
) {
let visible = area.height.saturating_sub(4) as usize;
let item_count = config.profiles.len() + 3;
let start = state
.selected
.saturating_sub(visible / 2)
.min(item_count.saturating_sub(visible));
let rows = config
.profiles
.iter()
.map(|profile| (profile.name().to_string(), profile.kind_label().to_string()))
.chain([
("+ New SQLite connection".to_string(), "profile".to_string()),
(
"+ New DynamoDB connection".to_string(),
"profile".to_string(),
),
("Open demo database".to_string(), "sample".to_string()),
])
.enumerate()
.skip(start)
.take(visible)
.map(|(index, (name, kind))| {
Row::new([name, kind]).style(selected_style(index == state.selected, colors))
});
let table = Table::new(rows, [Constraint::Min(28), Constraint::Length(14)])
.header(
Row::new(["connection", "type"]).style(
Style::default()
.fg(colors.accent)
.add_modifier(Modifier::BOLD),
),
)
.block(startup_block("tuible - connections", colors));
frame.render_widget(table, area);
draw_footer(
frame,
area,
state,
"Enter: open j/k: select d: delete profile q: quit",
colors,
);
}
struct SqliteForm<'a> {
name: &'a str,
path: &'a str,
write: bool,
field: usize,
}
fn draw_sqlite_form(
frame: &mut Frame,
area: Rect,
state: &StartupState,
form: SqliteForm<'_>,
colors: StartupColors,
) {
let rows = [
("Name", format_input(form.name, form.field == 0)),
("Database path", format_input(form.path, form.field == 1)),
("Write access", checkbox(form.write)),
("", "Save and open".to_string()),
];
draw_form(
frame,
area,
state,
"New SQLite connection",
rows,
form.field,
colors,
);
}
struct DynamoForm<'a> {
name: &'a str,
aws_profile: &'a str,
region: &'a str,
endpoint_url: &'a str,
local: bool,
write: bool,
field: usize,
}
fn draw_dynamo_form(
frame: &mut Frame,
area: Rect,
state: &StartupState,
form: DynamoForm<'_>,
colors: StartupColors,
) {
let rows = [
("Name", format_input(form.name, form.field == 0)),
(
"AWS profile",
format_input(form.aws_profile, form.field == 1),
),
("Region", format_input(form.region, form.field == 2)),
(
"Endpoint URL",
format_input(form.endpoint_url, form.field == 3),
),
("DynamoDB Local", checkbox(form.local)),
("Write access", checkbox(form.write)),
("", "Save and open".to_string()),
];
draw_form(
frame,
area,
state,
"New DynamoDB connection",
rows,
form.field,
colors,
);
}
fn draw_form<const N: usize>(
frame: &mut Frame,
area: Rect,
state: &StartupState,
title: &str,
values: [(&str, String); N],
selected: usize,
colors: StartupColors,
) {
let rows = values
.into_iter()
.enumerate()
.map(|(index, (label, value))| {
Row::new([Cell::from(label), Cell::from(value)])
.style(selected_style(index == selected, colors))
});
let table = Table::new(rows, [Constraint::Length(18), Constraint::Min(30)])
.block(startup_block(title, colors));
frame.render_widget(table, area);
draw_footer(
frame,
area,
state,
"Tab/j/k: field Space: toggle Enter: next/save Esc: back",
colors,
);
}
fn draw_footer(
frame: &mut Frame,
area: Rect,
state: &StartupState,
help: &str,
colors: StartupColors,
) {
let text = if state.status.is_empty() {
help.to_string()
} else {
format!("{} - {help}", state.status)
};
let footer = Rect::new(
area.x + 2,
area.bottom().saturating_sub(2),
area.width.saturating_sub(4),
1,
);
frame.render_widget(
Paragraph::new(Line::from(text)).style(Style::default().fg(colors.muted)),
footer,
);
}
fn format_input(value: &str, selected: bool) -> String {
if selected {
format!("{value}â–ˆ")
} else if value.is_empty() {
"-".to_string()
} else {
value.to_string()
}
}
fn checkbox(value: bool) -> String {
if value {
"[x] enabled".to_string()
} else {
"[ ] disabled".to_string()
}
}
#[derive(Clone, Copy)]
struct StartupColors {
foreground: Color,
muted: Color,
accent: Color,
selection: Color,
}
fn startup_colors(theme: ThemeName) -> StartupColors {
match theme {
ThemeName::Dracula => StartupColors {
foreground: Color::Rgb(248, 248, 242),
muted: Color::Rgb(98, 114, 164),
accent: Color::Rgb(189, 147, 249),
selection: Color::Rgb(68, 71, 90),
},
ThemeName::DraculaSoft => StartupColors {
foreground: Color::Rgb(220, 220, 215),
muted: Color::Rgb(104, 112, 142),
accent: Color::Rgb(154, 132, 190),
selection: Color::Rgb(61, 63, 78),
},
ThemeName::Monochrome => StartupColors {
foreground: Color::White,
muted: Color::DarkGray,
accent: Color::Gray,
selection: Color::DarkGray,
},
}
}
fn startup_block(title: &str, colors: StartupColors) -> Block<'_> {
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(colors.accent))
.title_style(
Style::default()
.fg(colors.accent)
.add_modifier(Modifier::BOLD),
)
}
fn selected_style(selected: bool, colors: StartupColors) -> Style {
if selected {
Style::default()
.fg(colors.foreground)
.bg(colors.selection)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(colors.foreground)
}
}
fn centered(area: Rect, width: u16, height: u16) -> Rect {
let width = width.min(area.width);
let height = height.min(area.height);
let horizontal = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length((area.width - width) / 2),
Constraint::Length(width),
Constraint::Min(0),
])
.split(area);
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length((area.height - height) / 2),
Constraint::Length(height),
Constraint::Min(0),
])
.split(horizontal[1])[1]
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
use super::*;
use tempfile::tempdir;
#[test]
fn selecting_a_saved_profile_returns_it() {
let profile = ConnectionProfile::Sqlite {
name: "northwind".to_string(),
path: "/tmp/northwind.db".into(),
write: false,
};
let mut config = Config::default();
config.profiles.push(profile.clone());
let mut state = StartupState::default();
let choice = handle_list_key(&mut state, &mut config, KeyCode::Enter);
assert_eq!(choice, Some(Some(StartupChoice::Profile(profile))));
}
#[test]
fn sqlite_form_requires_name_and_path() {
let mut config = Config::default();
let mut state = StartupState {
mode: StartupMode::Sqlite {
name: String::new(),
path: String::new(),
write: false,
field: 3,
},
..StartupState::default()
};
assert_eq!(handle_key(&mut state, &mut config, KeyCode::Enter), None);
assert!(state.status.contains("required"));
}
#[test]
fn saved_profiles_use_the_config_store() {
let dir = tempdir().unwrap();
let path = dir.path().join("database.db");
let profile = ConnectionProfile::Sqlite {
name: "demo".to_string(),
path,
write: false,
};
let mut config = Config::default();
config.upsert_profile(profile.clone());
assert_eq!(config.profile("DEMO"), Some(&profile));
}
}