pub mod detail;
pub mod input;
pub mod list;
pub mod theme;
use std::io::{self, Stdout};
use std::time::{Duration, Instant};
use crossterm::event::{self, Event};
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use crossterm::execute;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::widgets::Clear;
use ratatui::{Frame, Terminal};
use ratatui_sci_fi::widgets::{
AlertPopup, AlertPopupState, Divider, GlitchText, GlitchTextState, PopupShape, Spinner,
SpinnerState,
};
use ratatui_sci_fi::Theme;
use crate::app::{Action, App, Mode, PassKind};
use crate::error::Result;
type Tui = Terminal<CrosstermBackend<Stdout>>;
const TICKS_PER_FRAME: u64 = 6;
#[derive(Default)]
struct Anim {
tick: u64,
glitch: GlitchTextState,
alert: AlertPopupState,
spinner: SpinnerState,
prev_mode: Option<Mode>,
}
impl Anim {
fn step(&mut self) {
self.tick = self.tick.wrapping_add(TICKS_PER_FRAME);
self.glitch.tick();
self.alert.tick();
self.spinner.tick();
}
fn arm_alert_on_enter(&mut self, mode: &Mode) {
if self.prev_mode.as_ref() != Some(mode) && *mode == Mode::ConfirmDelete {
self.alert.flash(8);
}
self.prev_mode = Some(mode.clone());
}
}
pub fn run(mut app: App) -> Result<()> {
enable_raw_mode().map_err(map_io)?;
let mut stdout = io::stdout();
if execute!(stdout, EnterAlternateScreen).is_err() {
let _ = disable_raw_mode();
return Err(crate::error::Error::Other("tui: enter alternate screen failed".into()));
}
let backend = CrosstermBackend::new(stdout);
let mut terminal = match Terminal::new(backend) {
Ok(t) => t,
Err(e) => {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
return Err(map_io(e));
}
};
let _guard = TerminalGuard;
let mut anim = Anim::default();
let result = main_loop(&mut terminal, &mut app, &mut anim);
let _ = terminal.draw(|f| {
let _ = f;
});
result
}
fn main_loop(terminal: &mut Tui, app: &mut App, anim: &mut Anim) -> Result<()> {
let lock_secs = lock_timeout_secs();
let mut last_activity = Instant::now();
loop {
anim.step();
anim.arm_alert_on_enter(&app.mode);
if app.boot_rx.is_some() {
app.pump_boot()?;
}
maybe_auto_lock(app, lock_secs, &last_activity);
if terminal.draw(|f| draw(f, app, anim)).is_err() {
break;
}
let poll_ms = if matches!(app.mode, Mode::Booting) { 50 } else { 250 };
let Ok(true) = event::poll(Duration::from_millis(poll_ms)) else {
continue;
};
let ev = match event::read() {
Ok(ev) => ev,
Err(_) => break,
};
if let Event::Key(key) = ev {
last_activity = Instant::now();
let action = app.handle_key(key)?;
if matches!(action, Action::Quit) || app.quit {
break;
}
}
}
Ok(())
}
fn lock_timeout_secs() -> u64 {
const DEFAULT: u64 = 300;
let Some(raw) = std::env::var_os("ZKV_LOCK_SECS") else {
return DEFAULT;
};
match raw.to_string_lossy().parse::<u64>() {
Ok(v) => v,
Err(_) => DEFAULT,
}
}
fn maybe_auto_lock(app: &mut App, lock_secs: u64, last_activity: &Instant) {
if lock_secs == 0 {
return;
}
if app.db.is_none() {
return;
}
if last_activity.elapsed() >= Duration::from_secs(lock_secs) {
app.lock();
}
}
fn draw(frame: &mut Frame, app: &App, anim: &Anim) {
if let Mode::PromptPassphrase(kind) = &app.mode {
draw_passphrase(frame, app, *kind, anim);
return;
}
if matches!(app.mode, Mode::Booting) {
draw_loading(frame, app, anim);
return;
}
let whole = frame.area();
let vert = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(3),
])
.split(whole);
let header_area = vert[0];
let body_area = vert[1];
let footer_area = vert[2];
render_header(frame, header_area, app, anim);
frame.render_widget(
ratatui::widgets::Block::default().style(theme::bg()),
body_area,
);
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(42), Constraint::Percentage(58)])
.spacing(1)
.split(body_area);
list::render_list(frame, cols[0], app, anim.tick);
detail::render_detail(frame, cols[1], app);
render_footer(frame, footer_area, app);
match app.mode {
Mode::ConfirmDelete => draw_confirm_delete(frame, app, anim),
Mode::ImportTotp => draw_import_totp(frame, app, anim),
Mode::PickTemplate => draw_pick_template(frame, app, anim),
Mode::CategoryMgr => draw_category_mgr(frame, app, anim),
Mode::TagMgr => draw_tag_mgr(frame, app, anim),
Mode::Attachments => draw_attachments(frame, app, anim),
_ => {}
}
}
fn draw_passphrase(frame: &mut Frame, app: &App, kind: PassKind, anim: &Anim) {
let area = centered_rect(62, 72, frame.area());
frame.render_widget(Clear, area);
let title = match kind {
PassKind::Open => "Unlock Vault",
PassKind::Create => "Create New Vault",
};
let path = app
.path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "(no path)".into());
let inner = theme::panel_frame(frame, area, Some(title));
let field = input::InputField {
value: app.input.clone(),
mask: app.input_mask,
};
let msg = app.message.as_deref().unwrap_or("");
if inner.height >= 13 {
let chunks = Layout::vertical([
Constraint::Min(0),
Constraint::Length(5), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(3), Constraint::Length(1), Constraint::Min(0),
])
.split(inner);
render_lock_art(frame, chunks[1]);
render_brand(frame, chunks[2], anim);
render_tagline(frame, chunks[3]);
frame.render_widget(
Divider::new().label("SECURE ACCESS").theme(Theme::Cyberpunk),
chunks[4],
);
render_path_line(frame, chunks[5], &path);
input::render_input(frame, chunks[6], &field, " passphrase ", anim.tick);
render_passphrase_hints(frame, chunks[7], msg);
} else if inner.height >= 7 {
let chunks = Layout::vertical([
Constraint::Min(0),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(3),
Constraint::Length(1),
Constraint::Min(0),
])
.split(inner);
render_brand(frame, chunks[1], anim);
render_tagline(frame, chunks[2]);
render_path_line(frame, chunks[3], &path);
input::render_input(frame, chunks[4], &field, " passphrase ", anim.tick);
render_passphrase_hints(frame, chunks[5], msg);
} else {
let sub =
Layout::vertical([Constraint::Length(1), Constraint::Length(3), Constraint::Min(0)])
.split(inner);
render_path_line(frame, sub[0], &path);
input::render_input(frame, sub[1], &field, " passphrase ", anim.tick);
frame.render_widget(
ratatui::widgets::Paragraph::new(msg).style(msg_style(msg)),
sub[2],
);
}
}
fn render_lock_art(frame: &mut Frame, area: Rect) {
use ratatui::text::{Line, Span};
let shackle = theme::accent2();
let body = theme::accent2();
let dial = theme::accent();
let lines = vec![
Line::styled("╭─╮", shackle),
Line::styled("│ │", shackle),
Line::styled("╭─┴─┴─╮", body),
Line::from(vec![
Span::styled("│ ", body),
Span::styled("◉◉◉", dial),
Span::styled(" │", body),
]),
Line::styled("╰─────╯", body),
];
let p = ratatui::widgets::Paragraph::new(lines)
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(p, area);
}
fn render_brand(frame: &mut Frame, area: Rect, anim: &Anim) {
let text = "zkv";
let w = text.chars().count() as u16;
if area.width < w || area.height == 0 {
return;
}
let x = area.x + (area.width - w) / 2;
let mut glitch = anim.glitch.clone();
let brand = GlitchText::new(text).intensity(0.15).theme(Theme::Cyberpunk);
frame.render_stateful_widget(brand, Rect::new(x, area.y, w, 1), &mut glitch);
}
fn render_tagline(frame: &mut Frame, area: Rect) {
let line = ratatui::text::Line::styled("ZERO·KNOWLEDGE VAULT", theme::muted());
frame.render_widget(
ratatui::widgets::Paragraph::new(line).alignment(ratatui::layout::Alignment::Center),
area,
);
}
fn render_path_line(frame: &mut Frame, area: Rect, path: &str) {
let line = ratatui::text::Line::from(vec![
ratatui::text::Span::styled("◆ ", theme::accent()),
ratatui::text::Span::styled(path, theme::muted()),
]);
frame.render_widget(
ratatui::widgets::Paragraph::new(line).alignment(ratatui::layout::Alignment::Center),
area,
);
}
fn render_passphrase_hints(frame: &mut Frame, area: Rect, msg: &str) {
let line = if msg.is_empty() {
ratatui::text::Line::from(vec![
ratatui::text::Span::styled("[ENTER]", theme::accent2()),
ratatui::text::Span::styled(" unlock ", theme::fg()),
ratatui::text::Span::styled("[ESC]", theme::muted()),
ratatui::text::Span::styled(" quit", theme::muted()),
])
} else {
ratatui::text::Line::styled(msg.to_string(), msg_style(msg))
};
frame.render_widget(
ratatui::widgets::Paragraph::new(line).alignment(ratatui::layout::Alignment::Center),
area,
);
}
fn msg_style(msg: &str) -> ratatui::style::Style {
let lower = msg.to_ascii_lowercase();
if lower.contains("fail") || lower.contains("locked") || lower.contains("panic") {
theme::error()
} else {
theme::accent2()
}
}
fn draw_loading(frame: &mut Frame, _app: &App, anim: &Anim) {
let whole = frame.area();
let label = "unlocking vault…";
let label_w = label.chars().count() as u16;
let total_w = label_w.saturating_add(2);
let spinner_area = if whole.height >= 7 {
let panel_w = ((whole.width as u32 * 38 / 100) as u16).max(total_w.saturating_add(4));
let w = panel_w.min(whole.width);
let h = 7u16.min(whole.height);
let x = whole.x + (whole.width - w) / 2;
let y = whole.y + (whole.height - h) / 2;
let area = Rect::new(x, y, w, h);
frame.render_widget(Clear, area);
let inner = theme::panel_frame(frame, area, Some("LOADING"));
let row_y = inner.y + inner.height / 2;
let sx = inner.x + inner.width.saturating_sub(total_w) / 2;
Rect::new(sx, row_y, total_w.min(inner.width), 1)
} else {
frame.render_widget(Clear, whole);
let row_y = whole.y + whole.height / 2;
let sx = whole.x + whole.width.saturating_sub(total_w) / 2;
Rect::new(sx, row_y, total_w.min(whole.width), 1)
};
let mut state = anim.spinner.clone();
let spinner = Spinner::new().label(label).theme(Theme::Cyberpunk);
frame.render_stateful_widget(spinner, spinner_area, &mut state);
}
fn draw_confirm_delete(frame: &mut Frame, app: &App, anim: &Anim) {
let area = centered_rect(56, 20, frame.area());
frame.render_widget(Clear, area);
let title = app
.selected_item()
.map(|i| i.title.clone())
.unwrap_or_default();
let msg = format!("Delete \"{title}\"? · y confirm n/Esc cancel");
let popup = AlertPopup::new(msg)
.title(" Confirm Delete ")
.shape(PopupShape::Thick)
.theme(Theme::Cyberpunk);
let mut state = anim.alert.clone();
frame.render_stateful_widget(popup, area, &mut state);
}
fn draw_import_totp(frame: &mut Frame, app: &App, anim: &Anim) {
let area = centered_rect(64, 60, frame.area());
frame.render_widget(Clear, area);
let inner = theme::panel_frame(frame, area, Some(" Set TOTP / 2FA "));
let chunks = Layout::vertical([
Constraint::Length(4),
Constraint::Length(3),
Constraint::Min(0),
])
.split(inner);
let info = ratatui::widgets::Paragraph::new(vec![
ratatui::text::Line::from("paste one of:").style(theme::muted()),
ratatui::text::Line::from(" • otpauth://totp/...?secret=...").style(theme::muted()),
ratatui::text::Line::from(" • local QR image path").style(theme::muted()),
ratatui::text::Line::from(" • http(s)/data: image url").style(theme::muted()),
]);
frame.render_widget(info, chunks[0]);
let field = input::InputField {
value: app.input.clone(),
mask: false,
};
input::render_input(frame, chunks[1], &field, " totp source ", anim.tick);
let msg = app.message.as_deref().unwrap_or("");
let bottom = if msg.is_empty() {
"Enter:confirm Esc:cancel (url fetch may pause briefly)".to_string()
} else {
msg.to_string()
};
let hint_p = ratatui::widgets::Paragraph::new(bottom).style(theme::muted());
frame.render_widget(hint_p, chunks[2]);
}
fn draw_pick_template(frame: &mut Frame, app: &App, anim: &Anim) {
let entries: Vec<String> = crate::model::builtin_templates()
.iter()
.map(|t| t.name.clone())
.collect();
draw_mgr(
frame,
app,
anim,
"New Item · Pick Template",
&entries,
app.tpl_selected,
"j/k select Enter confirm Esc back",
"(no templates)",
);
}
fn draw_category_mgr(frame: &mut Frame, app: &App, anim: &Anim) {
let entries: Vec<String> = app
.categories
.iter()
.map(|c| {
if let Some(pid) = c.parent_id {
let pname = app
.categories
.iter()
.find(|p| p.id == Some(pid))
.map(|p| p.name.clone())
.unwrap_or_else(|| format!("#{pid}"));
format!("{} (parent: {})", c.name, pname)
} else {
c.name.clone()
}
})
.collect();
draw_mgr(
frame,
app,
anim,
"Categories",
&entries,
app.mgr_selected,
"a:add r:rename x:del Esc:back",
"(none, press a to add)",
);
}
fn draw_tag_mgr(frame: &mut Frame, app: &App, anim: &Anim) {
let entries: Vec<String> = app.tags.to_vec();
draw_mgr(
frame,
app,
anim,
"Tags",
&entries,
app.mgr_selected,
"a:add r:rename x:del Esc:back",
"(none, press a to add)",
);
}
fn draw_attachments(frame: &mut Frame, app: &App, anim: &Anim) {
let title = app
.items
.iter()
.find(|i| i.id == app.att_item_id)
.map(|i| i.title.clone())
.or_else(|| app.att_item_id.map(|id| format!("#{}", id)))
.unwrap_or_default();
let panel_title = format!("Attachments · {}", title);
let area = centered_rect(64, 60, frame.area());
frame.render_widget(Clear, area);
let editing = app.att_edit.is_some();
let inner = theme::panel_frame(frame, area, Some(&panel_title));
let constraints = if editing {
vec![
Constraint::Min(1),
Constraint::Length(3),
Constraint::Length(1),
]
} else {
vec![Constraint::Min(1), Constraint::Length(1)]
};
let chunks = Layout::vertical(constraints).split(inner);
let list_area = chunks[0];
if editing {
let field = input::InputField {
value: app.input.clone(),
mask: false,
};
let label = match app.att_edit {
Some(crate::app::AttEdit::Add) => " path: ",
Some(crate::app::AttEdit::Export) => " out: ",
None => " ",
};
input::render_input(frame, chunks[1], &field, label, anim.tick);
}
if app.att_list.is_empty() {
let empty = ratatui::widgets::Paragraph::new("(no attachments, press a to add)")
.style(theme::muted());
frame.render_widget(empty, list_area);
} else {
let items: Vec<ratatui::widgets::ListItem> = app
.att_list
.iter()
.enumerate()
.map(|(i, a)| {
let style = if i == app.att_selected {
theme::selected_bar()
} else {
theme::fg()
};
let mark = if i == app.att_selected { "▸ " } else { " " };
let mime = a.mime_type.clone().unwrap_or_else(|| "-".into());
let text = format!("{:<5} {:<24} {:<22} {}", a.id, a.filename, mime, a.size);
ratatui::widgets::ListItem::new(ratatui::text::Line::from(vec![
ratatui::text::Span::raw(mark),
ratatui::text::Span::styled(text, style),
]))
})
.collect();
let list = ratatui::widgets::List::new(items);
frame.render_widget(list, list_area);
}
let hint = if editing {
"Enter:confirm Esc:cancel"
} else {
"a:add e:export x:del Esc:back"
};
let hint_idx = if editing { 2 } else { 1 };
let hint_p = ratatui::widgets::Paragraph::new(hint).style(theme::muted());
frame.render_widget(hint_p, chunks[hint_idx]);
}
#[allow(clippy::too_many_arguments)]
fn draw_mgr(
frame: &mut Frame,
app: &App,
anim: &Anim,
title: &str,
entries: &[String],
selected: usize,
browse_hint: &str,
empty_hint: &str,
) {
let area = centered_rect(56, 56, frame.area());
frame.render_widget(Clear, area);
let editing = app.mgr_edit.is_some();
let inner = theme::panel_frame(frame, area, Some(title));
let constraints = if editing {
vec![
Constraint::Min(1),
Constraint::Length(3),
Constraint::Length(1),
]
} else {
vec![Constraint::Min(1), Constraint::Length(1)]
};
let chunks = Layout::vertical(constraints).split(inner);
let list_area = chunks[0];
if editing {
let field = input::InputField {
value: app.input.clone(),
mask: false,
};
let label = match app.mgr_edit {
Some(crate::app::MgrEdit::Add) => " add ",
Some(crate::app::MgrEdit::Rename) => " rename ",
None => " ",
};
input::render_input(frame, chunks[1], &field, label, anim.tick);
}
if entries.is_empty() {
let empty = ratatui::widgets::Paragraph::new(empty_hint).style(theme::muted());
frame.render_widget(empty, list_area);
} else {
let items: Vec<ratatui::widgets::ListItem> = entries
.iter()
.enumerate()
.map(|(i, name)| {
let style = if i == selected {
theme::selected_bar()
} else {
theme::fg()
};
let mark = if i == selected { "▸ " } else { " " };
ratatui::widgets::ListItem::new(ratatui::text::Line::from(vec![
ratatui::text::Span::raw(mark),
ratatui::text::Span::styled(name.clone(), style),
]))
})
.collect();
let list = ratatui::widgets::List::new(items);
frame.render_widget(list, list_area);
}
let hint = if editing {
"Enter:confirm Esc:cancel"
} else {
browse_hint
};
let hint_idx = if editing { 2 } else { 1 };
let hint_p = ratatui::widgets::Paragraph::new(hint).style(theme::muted());
frame.render_widget(hint_p, chunks[hint_idx]);
}
fn render_header(frame: &mut Frame, area: Rect, app: &App, anim: &Anim) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(6),
Constraint::Min(1),
Constraint::Length(34),
])
.split(area);
frame.render_widget(ratatui::widgets::Block::default().style(theme::bar()), chunks[0]);
let mut glitch = anim.glitch.clone();
let brand = GlitchText::new(" zkv ").intensity(0.15).theme(Theme::Cyberpunk);
frame.render_stateful_widget(brand, chunks[0], &mut glitch);
let (center_text, center_style) = match app.message.as_deref() {
Some(m) if !m.is_empty() => (m.to_string(), theme::accent2()),
_ => (
app.path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default(),
theme::muted(),
),
};
let center = ratatui::widgets::Paragraph::new(
ratatui::text::Line::from(center_text).style(center_style),
)
.style(theme::bar());
frame.render_widget(center, chunks[1]);
let unlocked = app.db.is_some();
let (state_sym, state_label) = if unlocked { ("◆", "UNLOCKED") } else { ("■", "LOCKED") };
let state_style = if unlocked {
theme::accent()
} else if (anim.tick / 8) % 2 == 0 {
theme::error()
} else {
theme::muted()
};
let right = ratatui::widgets::Paragraph::new(ratatui::text::Line::from(vec![
ratatui::text::Span::styled(
format!(
" {} items ▸ {}c ▸ {}t ▸ ",
app.items.len(),
app.categories.len(),
app.tags.len()
),
theme::muted(),
),
ratatui::text::Span::styled(format!("{state_sym} "), state_style),
ratatui::text::Span::styled(state_label, state_style),
]))
.style(theme::bar())
.alignment(ratatui::layout::Alignment::Right);
frame.render_widget(right, chunks[2]);
}
fn render_footer(frame: &mut Frame, area: Rect, _app: &App) {
let hints: &[(&str, &str)] = &[
("N", "NEW"),
("E", "EDIT"),
("X", "DEL"),
("/", "SEARCH"),
("Y", "COPY"),
("O", "OTP"),
("L", "LOCK"),
("C", "CAT"),
("T", "TAG"),
("Q", "QUIT"),
];
let mut spans: Vec<ratatui::text::Span<'_>> = vec![ratatui::text::Span::raw(" ")];
for (i, (k, label)) in hints.iter().enumerate() {
if i > 0 {
spans.push(ratatui::text::Span::styled(" ▸ ", theme::muted()));
}
spans.push(ratatui::text::Span::styled(format!("[{k}]"), theme::accent2()));
spans.push(ratatui::text::Span::styled(*label, theme::muted()));
}
let block = ratatui::widgets::Block::default()
.borders(ratatui::widgets::Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(theme::border())
.style(theme::bar());
frame.render_widget(
ratatui::widgets::Paragraph::new(ratatui::text::Line::from(spans)).block(block),
area,
);
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let pop_h = area.height * percent_y / 100;
let pop_w = area.width * percent_x / 100;
let y = area.y + (area.height.saturating_sub(pop_h)) / 2;
let x = area.x + (area.width.saturating_sub(pop_w)) / 2;
Rect::new(x, y, pop_w, pop_h)
}
fn map_io(e: io::Error) -> crate::error::Error {
crate::error::Error::Other(format!("tui io: {e}"))
}
struct TerminalGuard;
impl Drop for TerminalGuard {
fn drop(&mut self) {
let mut out = io::stdout();
let _ = execute!(out, LeaveAlternateScreen);
let _ = disable_raw_mode();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
use std::sync::Mutex;
static LOCK: Mutex<()> = Mutex::new(());
LOCK.lock().unwrap()
}
#[test]
fn lock_timeout_secs_reads_env() {
let _g = env_lock();
unsafe {
std::env::set_var("ZKV_LOCK_SECS", "0");
}
assert_eq!(lock_timeout_secs(), 0);
unsafe {
std::env::set_var("ZKV_LOCK_SECS", "60");
}
assert_eq!(lock_timeout_secs(), 60);
unsafe {
std::env::set_var("ZKV_LOCK_SECS", "not-a-number");
}
assert_eq!(lock_timeout_secs(), 300);
unsafe {
std::env::set_var("ZKV_LOCK_SECS", "-5");
}
assert_eq!(lock_timeout_secs(), 300);
unsafe {
std::env::remove_var("ZKV_LOCK_SECS");
}
assert_eq!(lock_timeout_secs(), 300);
}
#[test]
fn passphrase_input_keeps_content_row_on_short_terminal() {
use ratatui::backend::TestBackend;
for h in [16u16, 20, 24] {
let mut app = App::for_open(std::path::PathBuf::from("/tmp/x.zkv"));
app.input = "mySecretPass".into();
app.input_mask = true;
let backend = TestBackend::new(80, h);
let mut term = ratatui::Terminal::new(backend).unwrap();
let anim = Anim::default();
term.draw(|f| draw_passphrase(f, &app, PassKind::Open, &anim)).unwrap();
let buf = term.backend().buffer();
let rendered: String = (0..h)
.flat_map(|y| (0..80u16).map(move |x| buf[(x, y)].symbol().to_string()))
.collect();
assert!(
rendered.contains('•'),
"passphrase input lost its content row at height {h}"
);
}
}
}