use ratatui::{
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
Frame,
};
use tsafe_core::profile::get_default_profile;
use crate::app::{App, EditFocus, LoginFocus, Screen};
const C_ACCENT: Color = Color::Cyan;
const C_FOCUS: Color = Color::LightCyan;
#[allow(dead_code)] const C_WARN: Color = Color::Yellow;
const C_ERR: Color = Color::Red;
const C_DIM: Color = Color::DarkGray;
const C_OK: Color = Color::Green;
const C_SELECT: Color = Color::Rgb(30, 60, 90);
pub struct ThemeColors {
pub accent: Color,
pub focus: Color,
pub warn: Color,
pub err: Color,
pub dim: Color,
pub ok: Color,
pub select: Color,
}
pub fn theme_colors(theme: crate::app::Theme) -> ThemeColors {
use crate::app::Theme;
match theme {
Theme::Dark => ThemeColors {
accent: Color::Cyan,
focus: Color::LightCyan,
warn: Color::Yellow,
err: Color::Red,
dim: Color::DarkGray,
ok: Color::Green,
select: Color::Rgb(30, 60, 90),
},
Theme::Light => ThemeColors {
accent: Color::Blue,
focus: Color::Rgb(0, 80, 160),
warn: Color::Rgb(180, 130, 0),
err: Color::Red,
dim: Color::Gray,
ok: Color::Rgb(0, 150, 0),
select: Color::Rgb(180, 210, 240),
},
Theme::HighContrast => ThemeColors {
accent: Color::White,
focus: Color::Yellow,
warn: Color::Yellow,
err: Color::LightRed,
dim: Color::Gray,
ok: Color::LightGreen,
select: Color::Rgb(0, 0, 160),
},
}
}
pub fn render(f: &mut Frame, app: &mut App) {
match &app.screen.clone() {
Screen::Login => render_login(f, app),
Screen::Dashboard => render_dashboard(f, app),
Screen::EditSecret { is_new } => {
render_dashboard(f, app);
render_edit_modal(f, app, *is_new);
}
Screen::ConfirmDelete => {
render_dashboard(f, app);
render_confirm_delete(f, app);
}
Screen::RotatePassword => {
render_dashboard(f, app);
render_rotate_modal(f, app);
}
Screen::SnapshotRestore => {
render_dashboard(f, app);
render_snapshot_restore(f, app);
}
Screen::AuditLog => render_audit_log(f, app),
Screen::NewProfile { step } => render_new_profile(f, app, *step),
Screen::History { .. } => render_history_screen(f, app),
Screen::MoveSecret { source } => {
render_dashboard(f, app);
render_move_modal(f, app, source);
}
Screen::NsBulk { copy, from } => {
render_dashboard(f, app);
render_ns_bulk_modal(f, app, *copy, from);
}
Screen::Quitting => {}
}
if app.help_visible {
render_help_overlay(f, app);
}
}
fn render_login(f: &mut Frame, app: &App) {
let area = f.area();
let block = Block::default()
.title(" tsafe — Login ")
.title_style(Style::default().fg(C_ACCENT).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_type(BorderType::Rounded);
f.render_widget(block, area);
let inner = inner_area(area, 2);
let chunks = Layout::vertical([
Constraint::Length(3), Constraint::Length(1), Constraint::Length(3), Constraint::Length(1), Constraint::Min(0), ])
.split(inner);
let default_profile = get_default_profile();
let profile_str = if app.profiles.is_empty() {
"(no profiles — press n to create one)".to_string()
} else {
let selected_profile = app
.profiles
.get(app.profile_cursor)
.map(|s| s.as_str())
.unwrap_or("");
let default_marker = if selected_profile.eq_ignore_ascii_case(&default_profile) {
" *"
} else {
""
};
format!(
" {}{} [{}/{}]",
selected_profile,
default_marker,
app.profile_cursor + 1,
app.profiles.len(),
)
};
let profile_border = if app.login_focus == LoginFocus::Profile {
Style::default().fg(C_FOCUS)
} else {
Style::default().fg(C_ACCENT)
};
let profile_widget = Paragraph::new(profile_str).block(
Block::default()
.title(" Profile (Tab to focus, then ↑↓) ")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(profile_border),
);
f.render_widget(profile_widget, chunks[0]);
let pw_display = "●".repeat(app.password_buf.len());
let pw_border = if app.login_focus == LoginFocus::Password {
Style::default().fg(C_FOCUS)
} else {
Style::default().fg(C_ACCENT)
};
let pw_widget = Paragraph::new(pw_display).block(
Block::default()
.title(" Master Password ")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(pw_border),
);
f.render_widget(pw_widget, chunks[2]);
let hint = if let Some(err) = &app.login_error {
Line::from(Span::styled(err.as_str(), Style::default().fg(C_ERR)))
} else if app.profiles.is_empty() {
Line::from(Span::styled(
"n create profile · Ctrl-C quit",
Style::default().fg(C_DIM),
))
} else if app.login_focus == LoginFocus::Profile {
Line::from(Span::styled(
"↑↓ change profile · n new · ? help · q quit (empty password) · Tab → master password",
Style::default().fg(C_DIM),
))
} else {
Line::from(Span::styled(
"Empty + Enter → Touch ID / keyring (if configured) · Tab → profile row (↑↓ n ? q) · 5m idle exits · Ctrl-C quit",
Style::default().fg(C_DIM),
))
};
f.render_widget(Paragraph::new(hint), chunks[3]);
}
fn render_dashboard(f: &mut Frame, app: &mut App) {
let area = f.area();
let outer = Layout::vertical([
Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
.split(area);
render_title_bar(f, app, outer[0]);
render_status_bar(f, app, outer[2]);
let body = outer[1];
let cols =
Layout::horizontal([Constraint::Percentage(25), Constraint::Percentage(75)]).split(body);
render_profile_panel(f, app, cols[0]);
render_secret_panel(f, app, cols[1]);
}
fn render_title_bar(f: &mut Frame, app: &App, area: Rect) {
let c = theme_colors(app.theme);
let cols = Layout::horizontal([Constraint::Min(0), Constraint::Length(26)]).split(area);
let profile = app.active_profile.as_deref().unwrap_or("(none)");
let prefix = format!(" tsafe · {profile} ");
let available = cols[0].width as usize;
let mut used = prefix.chars().count();
let hints = app.screen.footer_hints();
let mut included = 0usize;
for (key_label, action) in hints {
let pair_len = key_label.chars().count() + 1 + action.chars().count() + 2; if used + pair_len > available.saturating_sub(6) {
break;
}
used += pair_len;
included += 1;
}
let truncated = included < hints.len();
let mut spans: Vec<Span> = vec![
Span::styled(
" tsafe ",
Style::default().fg(c.accent).add_modifier(Modifier::BOLD),
),
Span::styled(" · ", Style::default().fg(c.dim)),
Span::styled(
profile.to_string(),
Style::default().fg(c.ok).add_modifier(Modifier::BOLD),
),
Span::styled(" ", Style::default()),
];
for (key_label, action) in &hints[..included] {
spans.push(Span::styled(
key_label.to_string(),
Style::default().fg(c.focus),
));
spans.push(Span::styled(
format!(" {action} "),
Style::default().fg(c.dim),
));
}
if truncated {
spans.push(Span::styled("· ", Style::default().fg(c.dim)));
spans.push(Span::styled("?", Style::default().fg(c.focus)));
spans.push(Span::styled(" more", Style::default().fg(c.dim)));
}
f.render_widget(Paragraph::new(Line::from(spans)), cols[0]);
let current = tsafe_core::update::display_version();
let badge = if let Some(ref latest) = app.update_available {
Line::from(vec![Span::styled(
format!(" v{current} → v{latest} ↑ "),
Style::default().fg(c.warn).add_modifier(Modifier::BOLD),
)])
} else {
Line::from(vec![Span::styled(
format!(" v{current} "),
Style::default().fg(c.dim),
)])
};
f.render_widget(
Paragraph::new(badge).alignment(ratatui::layout::Alignment::Right),
cols[1],
);
}
fn render_status_bar(f: &mut Frame, app: &App, area: Rect) {
let text = app.status_message.as_deref().unwrap_or("");
let style = if text.starts_with("Error") || text.starts_with("✗") {
Style::default().fg(C_ERR)
} else {
Style::default().fg(C_DIM)
};
f.render_widget(Paragraph::new(Span::styled(text, style)), area);
}
fn render_profile_panel(f: &mut Frame, app: &App, area: Rect) {
let default_profile = get_default_profile();
let items: Vec<ListItem> = app
.profiles
.iter()
.map(|p| {
let style = if Some(p.as_str()) == app.active_profile.as_deref() {
Style::default().fg(C_OK).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let default_marker = if p.eq_ignore_ascii_case(&default_profile) {
" *"
} else {
""
};
ListItem::new(format!(" {p}{default_marker}")).style(style)
})
.collect();
let mut state = ListState::default();
state.select(Some(app.profile_cursor));
let list = List::new(items)
.block(
Block::default()
.title(" Profiles ")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(C_DIM)),
)
.highlight_style(Style::default().bg(C_SELECT).add_modifier(Modifier::BOLD))
.highlight_symbol("▶ ");
f.render_stateful_widget(list, area, &mut state);
}
fn render_secret_panel(f: &mut Frame, app: &mut App, area: Rect) {
let chunks = if app.search_mode {
Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).split(area)
} else {
Layout::vertical([Constraint::Length(0), Constraint::Min(0)]).split(area)
};
if app.search_mode {
let search_bar = Paragraph::new(format!(" /{}", app.search_query)).block(
Block::default()
.title(" Search ")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(C_FOCUS)),
);
f.render_widget(search_bar, chunks[0]);
}
let entries = app.visible_entries();
let items: Vec<ListItem> = entries
.iter()
.map(|entry| match entry {
crate::app::ListEntry::Namespace {
name,
count,
collapsed,
} => {
let icon = if *collapsed { "▶" } else { "▼" };
ListItem::new(Line::from(vec![Span::styled(
format!(" {icon} {name} ({count})"),
Style::default().fg(C_ACCENT).add_modifier(Modifier::BOLD),
)]))
}
crate::app::ListEntry::Key(k) => {
let display = if let Some(pos) = k.find('/') {
&k[pos + 1..]
} else {
k.as_str()
};
let indent = if k.contains('/') { " " } else { "" };
let value_hint = if let Some(r) = app.revealed.get(k) {
Span::styled(format!(" {}", &*r.value), Style::default().fg(C_OK))
} else {
Span::styled(" ••••••", Style::default().fg(C_DIM))
};
ListItem::new(Line::from(vec![
Span::styled(
format!("{indent}{display}"),
Style::default().fg(Color::White),
),
value_hint,
]))
}
})
.collect();
let count = app.filtered_keys().len();
let mut state = ListState::default();
if !entries.is_empty() {
state.select(Some(app.secret_cursor.min(entries.len() - 1)));
}
let list = List::new(items)
.block(
Block::default()
.title(format!(" Secrets ({count}) "))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(C_ACCENT)),
)
.highlight_style(Style::default().bg(C_SELECT).add_modifier(Modifier::BOLD))
.highlight_symbol("▶ ");
f.render_stateful_widget(list, chunks[1], &mut state);
}
fn render_edit_modal(f: &mut Frame, app: &App, is_new: bool) {
let title = if is_new {
" Add Secret "
} else {
" Edit Secret "
};
let area = centered_rect(60, 40, f.area());
f.render_widget(Clear, area);
let block = Block::default()
.title(title)
.title_style(Style::default().fg(C_ACCENT).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_type(BorderType::Rounded);
f.render_widget(block, area);
let inner = inner_area(area, 2);
let chunks = Layout::vertical([
Constraint::Length(3),
Constraint::Length(1),
Constraint::Length(3),
Constraint::Length(1),
Constraint::Min(0),
])
.split(inner);
let key_style = if app.edit_focus == EditFocus::Key {
Style::default().fg(C_FOCUS)
} else {
Style::default().fg(C_DIM)
};
let val_style = if app.edit_focus == EditFocus::Value {
Style::default().fg(C_FOCUS)
} else {
Style::default().fg(C_DIM)
};
let key_widget = Paragraph::new(app.edit_key.as_str()).block(
Block::default()
.title(" KEY ")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(key_style),
);
f.render_widget(key_widget, chunks[0]);
let val_display = if app.edit_focus == EditFocus::Value {
app.edit_value.clone()
} else {
"●".repeat(app.edit_value.len())
};
let val_widget = Paragraph::new(val_display).block(
Block::default()
.title(" VALUE (masked when not focused) ")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(val_style),
);
f.render_widget(val_widget, chunks[2]);
let hint = if let Some(err) = &app.edit_error {
Line::from(Span::styled(err.as_str(), Style::default().fg(C_ERR)))
} else {
Line::from(Span::styled(
"Tab switch field · Enter save · Esc cancel",
Style::default().fg(C_DIM),
))
};
f.render_widget(Paragraph::new(hint), chunks[3]);
}
fn render_confirm_delete(f: &mut Frame, app: &App) {
let key = app.selected_key().unwrap_or_default();
let area = centered_rect(70, 28, f.area());
f.render_widget(Clear, area);
let key_line = if let Some(slash) = key.rfind('/') {
let (ns, leaf) = key.split_at(slash);
Line::from(vec![
Span::raw(" Delete "),
Span::styled(format!("{ns}/"), Style::default().fg(C_DIM)),
Span::styled(
leaf.trim_start_matches('/'),
Style::default()
.fg(C_ERR)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
),
Span::raw(" ?"),
])
} else {
Line::from(vec![
Span::raw(" Delete "),
Span::styled(
key.as_str(),
Style::default()
.fg(C_ERR)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
),
Span::raw(" ?"),
])
};
let text = vec![
Line::from(""),
key_line,
Line::from(""),
Line::from(Span::styled(
" y confirm · n/Esc cancel · u undo (5s)",
Style::default().fg(C_DIM),
)),
];
let block = Block::default()
.title(" Confirm Delete ")
.title_style(Style::default().fg(C_ERR).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(C_ERR));
f.render_widget(Paragraph::new(text).block(block), area);
}
fn render_move_modal(f: &mut Frame, app: &App, source: &str) {
let area = centered_rect(60, 30, f.area());
f.render_widget(Clear, area);
let dest = &app.mv_dest_buf;
let cursor_col = dest.chars().count() as u16;
let input_line = Line::from(vec![
Span::styled(" To: ", Style::default().fg(C_DIM)),
Span::styled(dest.as_str(), Style::default().fg(Color::White)),
]);
let mut lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(" From: ", Style::default().fg(C_DIM)),
Span::styled(
source,
Style::default().fg(C_OK).add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
input_line,
Line::from(""),
Line::from(Span::styled(
" Enter confirm · Esc cancel",
Style::default().fg(C_DIM),
)),
];
if let Some(err) = &app.mv_error {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" {err}"),
Style::default().fg(C_ERR),
)));
}
let block = Block::default()
.title(" Move / Rename Secret ")
.title_style(Style::default().fg(C_ACCENT).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(C_ACCENT));
f.render_widget(Paragraph::new(lines).block(block), area);
let input_row = area.y + 1 + 4; let input_col = area.x + 1 + 7 + cursor_col; if input_row < area.y + area.height && input_col < area.x + area.width {
f.set_cursor_position((input_col, input_row));
}
}
fn render_ns_bulk_modal(f: &mut Frame, app: &App, copy: bool, from: &str) {
let area = centered_rect(62, 34, f.area());
f.render_widget(Clear, area);
let title = if copy {
" Copy Namespace "
} else {
" Move Namespace "
};
let verb = if copy {
"Copy all keys under"
} else {
"Move all keys under"
};
let dest = &app.mv_dest_buf;
let cursor_col = dest.chars().count() as u16;
let input_line = Line::from(vec![
Span::styled(" To namespace: ", Style::default().fg(C_DIM)),
Span::styled(dest.as_str(), Style::default().fg(Color::White)),
]);
let mut lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(format!(" {verb} "), Style::default().fg(C_DIM)),
Span::styled(
format!("{from}/"),
Style::default().fg(C_OK).add_modifier(Modifier::BOLD),
),
]),
Line::from(Span::styled(
" (single segment, no slash — e.g. staging)",
Style::default().fg(C_DIM),
)),
Line::from(""),
input_line,
Line::from(""),
Line::from(Span::styled(
" Enter confirm · Esc cancel",
Style::default().fg(C_DIM),
)),
Line::from(Span::styled(
" If a destination key exists, use CLI: tsafe ns copy|move … --force",
Style::default().fg(C_DIM),
)),
];
if let Some(err) = &app.mv_error {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" {err}"),
Style::default().fg(C_ERR),
)));
}
let block = Block::default()
.title(title)
.title_style(Style::default().fg(C_ACCENT).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(C_ACCENT));
f.render_widget(Paragraph::new(lines).block(block), area);
let input_row = area.y + 1 + 5; let to_ns_prefix = " To namespace: ";
let input_col = area.x + 1 + to_ns_prefix.chars().count() as u16 + cursor_col;
if input_row < area.y + area.height && input_col < area.x + area.width {
f.set_cursor_position((input_col, input_row));
}
}
fn render_rotate_modal(f: &mut Frame, app: &App) {
let area = centered_rect(60, 45, f.area());
f.render_widget(Clear, area);
let block = Block::default()
.title(" Rotate Master Password ")
.title_style(Style::default().fg(C_ACCENT).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_type(BorderType::Rounded);
f.render_widget(block, area);
let inner = inner_area(area, 2);
let chunks = Layout::vertical([
Constraint::Length(3),
Constraint::Length(1),
Constraint::Length(3),
Constraint::Length(1),
Constraint::Min(0),
])
.split(inner);
let new1_style = if app.rotate_step == 0 {
Style::default().fg(C_FOCUS)
} else {
Style::default().fg(C_DIM)
};
let new2_style = if app.rotate_step == 1 {
Style::default().fg(C_FOCUS)
} else {
Style::default().fg(C_DIM)
};
f.render_widget(
Paragraph::new("●".repeat(app.rotate_new1.len())).block(
Block::default()
.title(" New Password ")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(new1_style),
),
chunks[0],
);
f.render_widget(
Paragraph::new("●".repeat(app.rotate_new2.len())).block(
Block::default()
.title(" Confirm New Password ")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(new2_style),
),
chunks[2],
);
let hint = if let Some(err) = &app.rotate_error {
Line::from(Span::styled(err.as_str(), Style::default().fg(C_ERR)))
} else {
let msg = if app.rotate_step == 0 {
"Enter set password · Esc cancel"
} else {
"Enter confirm & rotate · Esc cancel"
};
Line::from(Span::styled(msg, Style::default().fg(C_DIM)))
};
f.render_widget(Paragraph::new(hint), chunks[3]);
}
fn render_snapshot_restore(f: &mut Frame, app: &App) {
let area = centered_rect(70, 60, f.area());
f.render_widget(Clear, area);
let items: Vec<ListItem> = app
.snapshot_paths
.iter()
.enumerate()
.map(|(i, p)| {
let name = p
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("(unknown)");
let style = if i == app.snapshot_cursor {
Style::default().fg(C_ACCENT).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
ListItem::new(format!(" {name}")).style(style)
})
.collect();
let mut list_state = ratatui::widgets::ListState::default();
list_state.select(Some(app.snapshot_cursor));
let list = List::new(items)
.block(
Block::default()
.title(" Restore Snapshot (↑↓ Enter=restore Esc=cancel) ")
.title_style(Style::default().fg(C_ACCENT).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_type(BorderType::Rounded),
)
.highlight_style(Style::default().bg(C_SELECT).add_modifier(Modifier::BOLD))
.highlight_symbol("▶ ");
f.render_stateful_widget(list, area, &mut list_state);
}
fn render_audit_log(f: &mut Frame, app: &App) {
let area = f.area();
let lines: Vec<Line> = app
.audit_lines
.iter()
.skip(app.audit_scroll)
.take(area.height.saturating_sub(4) as usize)
.map(|l| Line::from(Span::raw(l.as_str())))
.collect();
let block = Block::default()
.title(format!(
" Audit Log — {} (↑↓ scroll · q back) ",
app.active_profile.as_deref().unwrap_or("")
))
.title_style(Style::default().fg(C_ACCENT).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_type(BorderType::Rounded);
f.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
area,
);
}
fn render_history_screen(f: &mut Frame, app: &App) {
let area = f.area();
let c = theme_colors(app.theme);
let key_name = match &app.screen {
Screen::History { key } => key.as_str(),
_ => "",
};
let visible_rows = area.height.saturating_sub(4) as usize;
let mut lines: Vec<Line> = Vec::with_capacity(visible_rows + 1);
lines.push(Line::from(Span::styled(
" Version Timestamp (UTC) ",
Style::default().fg(c.dim).add_modifier(Modifier::BOLD),
)));
for (version, ts) in app
.history_entries
.iter()
.skip(app.history_scroll)
.take(visible_rows)
{
let ver_label = if *version == 0 {
" v0 (current) ".to_string()
} else {
format!(" v{version:<14} ")
};
let ts_str = ts.format("%Y-%m-%d %H:%M:%S").to_string();
lines.push(Line::from(vec![
Span::styled(ver_label, Style::default().fg(c.ok)),
Span::styled(ts_str, Style::default().fg(Color::White)),
Span::styled(
" \u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}",
Style::default().fg(c.dim),
),
]));
}
let total = app.history_entries.len();
let block = Block::default()
.title(format!(
" History — {key_name} ({total} version{} \u{2191}\u{2193}/j/k scroll q back) ",
if total == 1 { "" } else { "s" }
))
.title_style(Style::default().fg(c.accent).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_type(BorderType::Rounded);
f.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
area,
);
}
fn render_help_overlay(f: &mut Frame, app: &App) {
let area = centered_rect(72, 88, f.area());
f.render_widget(Clear, area);
let context = match &app.screen {
Screen::Login => "Login",
Screen::Dashboard => "Dashboard",
Screen::EditSecret { is_new } if *is_new => "Add Secret",
Screen::EditSecret { .. } => "Edit Secret",
Screen::ConfirmDelete => "Confirm Delete",
Screen::RotatePassword => "Rotate Password",
Screen::SnapshotRestore => "Snapshot Restore",
Screen::AuditLog => "Audit Log",
Screen::NewProfile { .. } => "New Profile",
Screen::History { .. } => "Secret History",
Screen::MoveSecret { .. } => "Move / Rename Secret",
Screen::NsBulk { copy: true, .. } => "Copy Namespace",
Screen::NsBulk { copy: false, .. } => "Move Namespace",
Screen::Quitting => "Quitting",
};
let block = Block::default()
.title(format!(" tsafe Help — {context} "))
.title_style(Style::default().fg(C_ACCENT).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(C_ACCENT));
f.render_widget(block, area);
let inner = inner_area(area, 2);
fn key(k: &'static str) -> Span<'static> {
Span::styled(
format!("{k:>10}"),
Style::default()
.fg(Color::LightCyan)
.add_modifier(Modifier::BOLD),
)
}
fn desc(d: &'static str) -> Span<'static> {
Span::styled(format!(" {d}"), Style::default().fg(Color::White))
}
fn sep() -> Line<'static> {
Line::from(Span::styled(
" ──────────────────────────────────────",
Style::default().fg(Color::DarkGray),
))
}
fn hdr(h: &'static str) -> Line<'static> {
Line::from(Span::styled(
format!(" {h}"),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))
}
let lines: Vec<Line> = vec![
hdr("Global"),
Line::from(vec![key("?"), desc("Show / hide this help overlay")]),
Line::from(vec![key("Ctrl-C"), desc("Quit immediately")]),
Line::from(Span::styled(
" Also: exits after 5 minutes with no input (idle timeout).",
Style::default().fg(Color::DarkGray),
)),
Line::from(vec![
key("Esc"),
desc("Dismiss this overlay / cancel current action"),
]),
Line::from(Span::raw("")),
sep(),
hdr("Login screen"),
Line::from(vec![
key("Tab"),
desc("Switch focus: profile row ⇄ master password"),
]),
Line::from(vec![
key("↑ ↓"),
desc("Change profile (only when the profile row is focused)"),
]),
Line::from(vec![key("Enter"), desc("Unlock selected profile")]),
Line::from(vec![
key("n / ? / q"),
desc("Commands on profile row; in password field they type literally"),
]),
Line::from(vec![
key("Ctrl-C"),
desc("Quit anytime; 5m idle also exits"),
]),
Line::from(Span::raw("")),
sep(),
hdr("Dashboard"),
Line::from(vec![key("↑ ↓ j k"), desc("Navigate secrets")]),
Line::from(vec![
key("Space"),
desc("On namespace header: expand / collapse group"),
]),
Line::from(vec![key("n"), desc("Add new secret")]),
Line::from(vec![key("e / Enter"), desc("Edit selected secret")]),
Line::from(vec![
key("d"),
desc("Delete selected secret (with confirmation)"),
]),
Line::from(vec![
key("y"),
desc("Copy selected secret value to the system clipboard"),
]),
Line::from(vec![key("r"), desc("Reveal / hide selected secret value")]),
Line::from(vec![key("R"), desc("Rotate master password")]),
Line::from(vec![key("S"), desc("Open snapshot restore picker")]),
Line::from(vec![key("a"), desc("View audit log")]),
Line::from(vec![
key("p"),
desc("Switch to a different profile (re-auth)"),
]),
Line::from(vec![key("/"), desc("Enter search mode")]),
Line::from(vec![
key("h"),
desc("View version history for selected secret"),
]),
Line::from(vec![
key("c"),
desc("With namespace header selected: copy all keys to another namespace"),
]),
Line::from(vec![
key("m"),
desc("With key: move one secret — with namespace header: move all keys under it"),
]),
Line::from(vec![
key("T"),
desc("Cycle colour theme (Dark → Light → High Contrast)"),
]),
Line::from(vec![key("q"), desc("Quit")]),
Line::from(Span::raw("")),
sep(),
hdr("Add / Edit Secret modal"),
Line::from(vec![
key("Tab"),
desc("Switch focus between KEY and VALUE fields"),
]),
Line::from(vec![key("Enter"), desc("Save secret")]),
Line::from(vec![key("Esc"), desc("Cancel — return to Dashboard")]),
Line::from(Span::raw("")),
sep(),
hdr("Copy / Move namespace (modal)"),
Line::from(vec![
key("Enter"),
desc("Run copy or move for all keys under the source prefix"),
]),
Line::from(vec![key("Esc"), desc("Cancel")]),
Line::from(Span::raw("")),
sep(),
hdr("Rotate Password modal"),
Line::from(vec![
key("Enter"),
desc("Step 1: set new password / Step 2: confirm & rotate"),
]),
Line::from(vec![key("Esc"), desc("Cancel")]),
Line::from(Span::raw("")),
sep(),
hdr("Snapshot / Audit Log"),
Line::from(vec![key("↑ ↓ j k"), desc("Scroll / navigate")]),
Line::from(vec![key("Enter"), desc("Restore selected snapshot")]),
Line::from(vec![key("q / Esc"), desc("Return to Dashboard")]),
Line::from(Span::raw("")),
Line::from(Span::styled(
" Press ? or Esc to close this overlay",
Style::default().fg(Color::DarkGray),
)),
];
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
fn render_new_profile(f: &mut Frame, app: &App, step: u8) {
let area = centered_rect(60, 50, f.area());
f.render_widget(Clear, area);
let block = Block::default()
.title(" Create New Profile ")
.title_style(Style::default().fg(C_ACCENT).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_type(BorderType::Rounded);
f.render_widget(block, area);
let inner = inner_area(area, 2);
let chunks = Layout::vertical([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(3), Constraint::Length(1), Constraint::Min(0),
])
.split(inner);
let step_text = match step {
0 => "Step 1/3 — profile name",
1 => "Step 2/3 — master password",
_ => "Step 3/3 — confirm password",
};
f.render_widget(
Paragraph::new(Span::styled(step_text, Style::default().fg(C_DIM))),
chunks[0],
);
let (field_title, field_content) = match step {
0 => (" Profile Name ", app.new_profile_name.clone()),
1 => (" Master Password ", "●".repeat(app.new_profile_pw1.len())),
_ => (" Confirm Password ", "●".repeat(app.new_profile_pw2.len())),
};
let input_widget = Paragraph::new(field_content).block(
Block::default()
.title(field_title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(C_FOCUS)),
);
f.render_widget(input_widget, chunks[2]);
let hint = if let Some(err) = &app.new_profile_error {
Line::from(Span::styled(err.as_str(), Style::default().fg(C_ERR)))
} else {
let msg = if step == 0 {
"Enter next · Esc back to login"
} else {
"Enter next · Esc back"
};
Line::from(Span::styled(msg, Style::default().fg(C_DIM)))
};
f.render_widget(Paragraph::new(hint), chunks[3]);
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let layout = Layout::vertical([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::horizontal([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(layout[1])[1]
}
fn inner_area(r: Rect, margin: u16) -> Rect {
Rect {
x: r.x + margin,
y: r.y + margin,
width: r.width.saturating_sub(margin * 2),
height: r.height.saturating_sub(margin * 2),
}
}
#[cfg(test)]
mod theme_tests {
use super::theme_colors;
use crate::app::{App, Screen, Theme};
use ratatui::style::Color;
use ratatui::{backend::TestBackend, buffer::Buffer, Terminal};
fn buffer_to_plain_string(buf: &Buffer) -> String {
let area = buf.area;
let mut out = String::new();
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
#[test]
fn theme_colors_dark_matches_palette() {
let t = theme_colors(Theme::Dark);
assert_eq!(t.accent, Color::Cyan);
assert_eq!(t.focus, Color::LightCyan);
assert_eq!(t.err, Color::Red);
assert_eq!(t.select, Color::Rgb(30, 60, 90));
}
#[test]
fn theme_colors_light_distinct_from_dark() {
let t = theme_colors(Theme::Light);
assert_eq!(t.accent, Color::Blue);
assert_eq!(t.focus, Color::Rgb(0, 80, 160));
assert_eq!(t.select, Color::Rgb(180, 210, 240));
}
#[test]
fn theme_colors_high_contrast_uses_light_red_for_errors() {
let t = theme_colors(Theme::HighContrast);
assert_eq!(t.accent, Color::White);
assert_eq!(t.err, Color::LightRed);
assert_eq!(t.select, Color::Rgb(0, 0, 160));
}
#[test]
fn help_overlay_mentions_y_clipboard_shortcut() {
let mut app = App::new_for_test(vec!["demo".into()], Screen::Dashboard);
app.active_profile = Some("demo".into());
app.help_visible = true;
app.theme = Theme::Dark;
let backend = TestBackend::new(100, 40);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal
.draw(|f| super::render(f, &mut app))
.expect("draw help overlay");
let plain = buffer_to_plain_string(terminal.backend().buffer());
assert!(
plain.contains("Copy selected secret value to the system clipboard"),
"help overlay missing clipboard text:\n{plain}"
);
assert!(
plain.contains(" y Copy selected secret value to the system clipboard"),
"help overlay missing y hotkey line:\n{plain}"
);
}
}