use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::Style,
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Padding, Paragraph},
};
use crate::app::App;
use crate::input::settings::{AUTO_LOCK_ROW, DATABASE_ROW, KEYFILE_ROW};
use crate::util::wrap_help_items;
fn centered_cursor_x(input_area: Rect, typed_len: u16) -> u16 {
let inner = Block::default().borders(Borders::ALL).inner(input_area);
inner.x + (inner.width / 2).saturating_sub(typed_len / 2) + typed_len
}
fn nav_help_items(slim_mode: bool) -> &'static [&'static str] {
if slim_mode {
&["[↑↓]", "[Enter]", "[Esc]"]
} else {
&["[↑↓] navigate", "[Enter] edit / choose theme", "[Esc] back"]
}
}
fn field_help_items(slim_mode: bool) -> &'static [&'static str] {
if slim_mode { &["[Enter]", "[Esc]"] } else { &["[Enter] save", "[Esc] cancel"] }
}
fn change_password_help_items(slim_mode: bool) -> &'static [&'static str] {
if slim_mode { &["[Enter]", "[Esc]"] } else { &["[Enter] confirm", "[Esc] cancel"] }
}
fn export_help_items(slim_mode: bool) -> &'static [&'static str] {
if slim_mode { &["[Enter]", "[Esc]"] } else { &["[Enter] continue", "[Esc] cancel"] }
}
fn import_help_items(slim_mode: bool) -> &'static [&'static str] {
if slim_mode { &["[Enter]", "[Esc]"] } else { &["[Enter] continue", "[Esc] cancel"] }
}
pub fn draw_settings(frame: &mut Frame, app: &mut App) {
if app.exporting_database {
draw_export(frame, app);
} else if app.importing_database {
draw_import(frame, app);
} else if app.changing_password {
draw_change_password(frame, app);
} else if app.choosing_theme {
draw_theme_picker(frame, app);
} else {
draw_main_settings(frame, app);
}
}
fn draw_main_settings(frame: &mut Frame, app: &mut App) {
let full_area = frame.area();
let editing_field = app.editing_field;
let field_buffer = app.field_buffer.clone();
let selected = app.settings_state.selected().unwrap_or(0);
let help_items = if editing_field { field_help_items(app.slim_mode) } else { nav_help_items(app.slim_mode) };
let help_width = full_area.width.saturating_sub(2);
let help_lines = wrap_help_items(help_items, help_width);
let help_height = help_lines.len() as u16;
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(help_height)])
.split(full_area);
let theme = &app.theme;
let normal = Style::new().fg(theme.text);
let accent = Style::new().fg(theme.accent);
let warning = Style::new().fg(theme.warning);
let border_style = Style::new().fg(theme.border);
let placeholder = Style::new().fg(theme.border).italic();
let editing_style = Style::new().fg(theme.selection_fg).bg(theme.selection_bg);
let label_style = Style::new().fg(theme.header).bold();
let render_value = |field_index: usize, value: String| -> Line<'static> {
if editing_field && selected == field_index {
return Line::from(vec![Span::styled(field_buffer.clone(), editing_style), Span::styled("▏", accent)]);
}
if value.is_empty() {
return Line::from(Span::styled("(not set)", placeholder));
}
Line::from(Span::styled(value, normal))
};
let field = |label: &'static str, value: Line<'static>| -> ListItem<'static> {
ListItem::new(vec![
Line::from(Span::styled(label, label_style)),
value,
Line::from(""), ])
};
let database_value = app.config.default_database.as_ref().map(|p| p.display().to_string()).unwrap_or_default();
let keyfile_value = app.config.keyfile.as_ref().map(|p| p.display().to_string()).unwrap_or_default();
let auto_lock_value = app.config.auto_lock.as_secs().to_string();
let theme_value = if app.available_themes.is_empty() {
Line::from(Span::styled("no themes found in ~/.config/kptui/themes", warning))
} else {
match app.config.theme.as_deref() {
Some(name) => Line::from(Span::styled(crate::theme::slugify(name), normal)),
None => Line::from(Span::styled("(not set)", placeholder)),
}
};
let items = vec![
field("Default database", render_value(DATABASE_ROW, database_value)),
field("Keyfile (optional)", render_value(KEYFILE_ROW, keyfile_value)),
field("Auto-lock (seconds)", render_value(AUTO_LOCK_ROW, auto_lock_value)),
field("Theme", theme_value),
field("Change master password", Line::from(Span::styled("[Enter] to change", placeholder))),
field("Import database", Line::from(Span::styled("[Enter] to import", placeholder))),
field("Export database", Line::from(Span::styled("[Enter] to export", placeholder))),
];
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).padding(Padding::horizontal(1)).border_style(border_style).title(" Settings "))
.highlight_style(editing_style.bold());
frame.render_stateful_widget(list, vertical[0], &mut app.settings_state);
let help = if let Some(status) = &app.status {
Paragraph::new(format!(" {status}")).style(warning)
} else {
let help_text = help_lines.iter().map(|line| format!(" {line}")).collect::<Vec<_>>().join("\n");
Paragraph::new(help_text).style(accent)
};
frame.render_widget(help, vertical[1]);
}
fn draw_change_password(frame: &mut Frame, app: &mut App) {
let full_area = frame.area();
let help_width = full_area.width.saturating_sub(2);
let help_lines = wrap_help_items(change_password_help_items(app.slim_mode), help_width);
let help_height = help_lines.len() as u16;
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(3), Constraint::Fill(1), Constraint::Length(help_height)])
.split(full_area);
let input_row = vertical[1];
let horizontal = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Fill(1), Constraint::Length(50), Constraint::Fill(1)])
.split(input_row);
let input_area = horizontal[1];
app.max_len = (input_area.width - 2) as usize;
let title = match app.password_change_step {
crate::app::PasswordChangeStep::Current => " Current master password ",
crate::app::PasswordChangeStep::New => " New master password ",
crate::app::PasswordChangeStep::ConfirmNew => " Confirm new master password ",
};
let buf = match app.password_change_step {
crate::app::PasswordChangeStep::Current => &app.current_password_buffer,
crate::app::PasswordChangeStep::New => &app.new_password_buffer,
crate::app::PasswordChangeStep::ConfirmNew => &app.new_password_confirm,
};
let input = Paragraph::new("•".repeat(buf.chars().count()))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(app.theme.border)).title(title));
frame.render_widget(input, input_area);
let hint_area = Rect { x: input_area.x, y: input_area.y + input_area.height, width: input_area.width, height: 1 };
let default_hint = match app.password_change_step {
crate::app::PasswordChangeStep::Current => "verify current master password",
crate::app::PasswordChangeStep::New | crate::app::PasswordChangeStep::ConfirmNew => "this re-encrypts your database file with the new password",
};
let hint_text = app.status.clone().unwrap_or_else(|| default_hint.to_string());
let hint_style = if app.status.is_some() { Style::new().fg(app.theme.error) } else { Style::new().fg(app.theme.warning) };
let hint = Paragraph::new(hint_text).alignment(Alignment::Center).style(hint_style);
frame.render_widget(hint, hint_area);
let typed_len = buf.chars().count() as u16;
let cursor_x = centered_cursor_x(input_area, typed_len);
frame.set_cursor_position((cursor_x, input_area.y + 1));
let accent = Style::new().fg(app.theme.accent);
let help_text = help_lines.iter().map(|line| format!(" {line}")).collect::<Vec<_>>().join("\n");
frame.render_widget(Paragraph::new(help_text).style(accent), vertical[3]);
}
fn draw_import(frame: &mut Frame, app: &mut App) {
let full_area = frame.area();
let help_width = full_area.width.saturating_sub(2);
let help_lines = wrap_help_items(import_help_items(app.slim_mode), help_width);
let help_height = help_lines.len() as u16;
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(3), Constraint::Fill(1), Constraint::Length(help_height)])
.split(full_area);
let input_row = vertical[1];
let horizontal = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Fill(1), Constraint::Length(60), Constraint::Fill(1)])
.split(input_row);
let input_area = horizontal[1];
app.max_len = (input_area.width - 2) as usize;
let masked = matches!(app.import_step, crate::app::ImportStep::KdbxPassword);
let title = match app.import_step {
crate::app::ImportStep::Path => " Import from file (.kdbx / .csv / .json) ",
crate::app::ImportStep::KdbxPassword => " Password for that vault ",
};
let buf = match app.import_step {
crate::app::ImportStep::Path => &app.import_path_buffer,
crate::app::ImportStep::KdbxPassword => &app.import_kdbx_password_buffer,
};
let display_text = if masked { "•".repeat(buf.chars().count()) } else { buf.clone() };
let input = Paragraph::new(display_text)
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(app.theme.border)).title(title));
frame.render_widget(input, input_area);
let hint_area = Rect { x: input_area.x, y: input_area.y + input_area.height, width: input_area.width, height: 1 };
let default_hint = match app.import_step {
crate::app::ImportStep::Path => "only Name, User, Password, URL, TOTP and Notes are imported",
crate::app::ImportStep::KdbxPassword => "that file's master password, not this vault's",
};
let hint_text = app.status.clone().unwrap_or_else(|| default_hint.to_string());
let hint_style = if app.status.is_some() { Style::new().fg(app.theme.error) } else { Style::new().fg(app.theme.warning) };
let hint = Paragraph::new(hint_text).alignment(Alignment::Center).style(hint_style);
frame.render_widget(hint, hint_area);
let typed_len = buf.chars().count() as u16;
let cursor_x = centered_cursor_x(input_area, typed_len);
frame.set_cursor_position((cursor_x, input_area.y + 1));
let accent = Style::new().fg(app.theme.accent);
let help_text = help_lines.iter().map(|line| format!(" {line}")).collect::<Vec<_>>().join("\n");
frame.render_widget(Paragraph::new(help_text).style(accent), vertical[3]);
}
fn draw_export(frame: &mut Frame, app: &mut App) {
let full_area = frame.area();
let help_width = full_area.width.saturating_sub(2);
let help_lines = wrap_help_items(export_help_items(app.slim_mode), help_width);
let help_height = help_lines.len() as u16;
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(3), Constraint::Fill(1), Constraint::Length(help_height)])
.split(full_area);
let input_row = vertical[1];
let horizontal = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Fill(1), Constraint::Length(60), Constraint::Fill(1)])
.split(input_row);
let input_area = horizontal[1];
app.max_len = (input_area.width - 2) as usize;
let title = match app.export_step {
crate::app::ExportStep::Path => " Export to file (.kdbx / .csv / .json) ",
crate::app::ExportStep::Confirm => " Confirm export ",
};
let input = Paragraph::new(app.export_path_buffer.clone())
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(app.theme.border)).title(title));
frame.render_widget(input, input_area);
let hint_area = Rect { x: input_area.x, y: input_area.y + input_area.height, width: input_area.width, height: 1 };
let default_hint = match app.export_step {
crate::app::ExportStep::Path => "only Name, User, Password, URL, and TOTP are exported".to_string(),
crate::app::ExportStep::Confirm => {
let path = app.pending_export_path.as_deref();
let plaintext = path
.and_then(|p| crate::export::detect_format(p).ok())
.map(|f| matches!(f, crate::export::ExportFormat::Csv | crate::export::ExportFormat::Json))
.unwrap_or(false);
let overwrite = path.map(|p| p.exists()).unwrap_or(false);
match (plaintext, overwrite) {
(true, true) => "this overwrites an existing file with all passwords in PLAINTEXT — press Enter to confirm".to_string(),
(true, false) => "this writes all passwords in PLAINTEXT to disk — press Enter to confirm".to_string(),
(false, true) => "this overwrites an existing file — press Enter to confirm".to_string(),
(false, false) => "press Enter to confirm".to_string(),
}
}
};
let hint_text = app.status.clone().unwrap_or(default_hint);
let hint_style = if app.status.is_some() { Style::new().fg(app.theme.error) } else { Style::new().fg(app.theme.warning) };
let hint = Paragraph::new(hint_text).alignment(Alignment::Center).style(hint_style);
frame.render_widget(hint, hint_area);
let typed_len = app.export_path_buffer.chars().count() as u16;
let cursor_x = centered_cursor_x(input_area, typed_len);
frame.set_cursor_position((cursor_x, input_area.y + 1));
let accent = Style::new().fg(app.theme.accent);
let help_text = help_lines.iter().map(|line| format!(" {line}")).collect::<Vec<_>>().join("\n");
frame.render_widget(Paragraph::new(help_text).style(accent), vertical[3]);
}
fn draw_theme_picker(frame: &mut Frame, app: &mut App) {
let full_area = frame.area();
let help_width = full_area.width.saturating_sub(2);
let help_lines = wrap_help_items(export_help_items(app.slim_mode), help_width);
let help_height = help_lines.len() as u16;
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(help_height)])
.split(full_area);
let theme = &app.theme;
let normal = Style::new().fg(theme.text);
let accent = Style::new().fg(theme.accent);
let warning = Style::new().fg(theme.warning);
let border_style = Style::new().fg(theme.border);
let current_theme = app.config.theme.as_deref().map(crate::theme::slugify);
let items: Vec<ListItem> = app
.available_themes
.iter()
.map(|name| {
let is_active = current_theme.as_deref().is_some_and(|current| current == crate::theme::slugify(name));
let marker = if is_active { " ◉" } else { " ○" };
let marker_style = if is_active { accent } else { normal };
ListItem::new(Line::from(vec![Span::styled(format!("{marker} "), marker_style), Span::styled(name.clone(), normal)]))
})
.collect();
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).padding(Padding::horizontal(1)).border_style(border_style).title(" Theme "))
.highlight_style(Style::new().fg(theme.selection_fg).bg(theme.selection_bg).bold());
frame.render_stateful_widget(list, vertical[0], &mut app.theme_state);
let help = if let Some(status) = &app.status {
Paragraph::new(format!(" {status}")).style(warning)
} else {
let help_text = help_lines.iter().map(|line| format!(" {line}")).collect::<Vec<_>>().join("\n");
Paragraph::new(help_text).style(accent)
};
frame.render_widget(help, vertical[1]);
}