-
-Type `i` to get some instruction. Otherwise just enter `cotp --help`.
-In the first run you will be prompted to insert a password to initialize the database.
+
+
+Run `cotp` to open your vault. The dashboard adapts to your terminal, with searchable
+accounts, a countdown for the selected code, grouped codes, and an account detail panel
+on wide screens. On your first run, choose a password to initialize the encrypted database.
+
+Use the keyboard for every action, or click accounts, the search bar, and the visible
+shortcuts. Keyboard and mouse-wheel navigation stop at the first and last account.
+The `?` menu also offers clickable actions. No special icon font is required. The dashboard needs an interactive terminal;
+use `cotp list` or `cotp list --json` when piping output.
+
+| Key | Action |
+| --- | --- |
+| `↑` / `↓`, `k` / `j` | Select an account |
+| `Home` / `End`, `PgUp` / `PgDn` | First / last account, or move ten accounts |
+| `/` or `Ctrl+F` | Filter by issuer or account label |
+| `Enter` | Copy the selected code; on the QR screen, copy its OTP URI |
+| `a` / `e` | Add / edit an account |
+| `Space` | Show or close the selected account's QR code |
+| `h` | Hide / reveal codes |
+| `+` / `-` | Increase / decrease an HOTP counter |
+| `d` or `Delete` | Delete an account, with confirmation |
+| `?` or `i` | Open the shortcuts and actions menu |
+| `Esc` | Close the current screen, finish searching, clear a filter, or quit |
+| `q`, `Ctrl+C`, `Ctrl+D` | Quit; account changes prompt you to save or discard |
+
+### Add and edit accounts
+
+Press `a` or click **Add** to enter an issuer, account label, and secret. You can also
+paste an `otpauth://` URI into the secret field to populate the form. Choose the OTP
+type and algorithm, then adjust digits, period, counter, or PIN as required. Validation
+focuses the field that needs attention.
+
+
+
+Use `Tab` / `Shift+Tab` or `↑` / `↓` to move through fields and buttons. Text fields
+support cursor movement, `Home`, `End`, and `Ctrl+U` to clear. Click a type or algorithm
+field, or use `←` / `→` or `Space`, to change it. `Ctrl+S` or **Save account** applies the
+form; `Esc` or **Cancel** discards it. Secret keys and PINs are masked; when editing,
+leave either blank to keep its existing value.
+
+Account additions, edits, deletions, and counter changes remain pending until you
+quit and confirm **save**. Choosing **discard** leaves the on-disk vault unchanged.
+CLI subcommands remain available for scripting; run `cotp --help` for examples.
## TL;DR
@@ -24,6 +64,8 @@ In the first run you will be prompted to insert a password to initialize the dat
# Display all the OTP codes in the interactive dashboard
cotp # select any code with arrow keys, press enter to copy into the clipboard, even in an SSH remote shell
+# In the dashboard, press a to add or e to edit an account
+
# Add a new TOTP code from a BASE32 secret key
cotp add --label myaccount@gmail.com --issuer Google
@@ -166,3 +208,26 @@ an issue.
I created this project for my own needs, but I would be happy if this little program is useful to someone else, and I
gratefully accept any pull requests.
+
+## UI development
+
+The repository pins Rust and Cargo in `rust-toolchain.toml`; rustup installs the selected
+toolchain automatically. The interface uses Ratatui, Crossterm, and tui-input. Rendering
+and interaction tests cover responsive layouts, keyboard and mouse actions, forms,
+search, code timing, and masked fields.
+
+To regenerate the documentation previews from the real widgets and demonstration data:
+
+```bash
+COTP_UI_PREVIEW=img/cotp-dashboard.svg COTP_FORM_PREVIEW=img/cotp-editor.svg cargo test interface::tests
+```
+
+To rebuild the animated demo with deterministic example accounts, install ImageMagick
+(`magick`) and run:
+
+```bash
+python3 ci/render_ui_demo.py
+```
+
+The recording uses the actual dashboard widgets and keyboard/mouse handlers, with
+synthetic accounts and masked secrets. Tutorial captions appear below the application.
diff --git a/ci/render_ui_demo.py b/ci/render_ui_demo.py
new file mode 100644
index 00000000..dd3379c9
--- /dev/null
+++ b/ci/render_ui_demo.py
@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+"""Render the README GIF using real cotp widgets and deterministic demo accounts.
+
+Requires the repository Rust toolchain and ImageMagick (magick) on PATH.
+Run from any directory: python3 ci/render_ui_demo.py
+"""
+
+import concurrent.futures
+import html
+import json
+from pathlib import Path
+import os
+import shutil
+import subprocess
+import tempfile
+
+
+def main():
+ root = Path(__file__).resolve().parents[1]
+ if shutil.which("magick") is None:
+ raise SystemExit("ImageMagick is required: install it so 'magick' is on PATH.")
+ (root / "target").mkdir(exist_ok=True)
+ with tempfile.TemporaryDirectory(prefix="ui-demo-", dir=root / "target") as temp:
+ directory = Path(temp)
+ subprocess.run(
+ ["cargo", "test", "--locked", "interface::tests::record_ui_demo", "--", "--ignored"],
+ cwd=root,
+ env={**os.environ, "COTP_UI_DEMO_DIR": temp},
+ check=True,
+ )
+ frames = json.loads((directory / "frames.json").read_text())
+
+ def render(frame):
+ source = directory / frame["file"]
+ svg = source.read_text()
+ # Place tutorial captions beneath the application, outside its viewport.
+ svg = svg.replace('height="612"', 'height="654"', 1)
+ svg = svg.replace('viewBox="0 0 1080 612"', 'viewBox="0 0 1080 654"', 1)
+ caption = html.escape(frame["caption"])
+ svg = svg.replace(
+ "",
+ ''
+ '{caption}',
+ )
+ source.write_text(svg)
+ target = source.with_suffix(".png")
+ subprocess.run(["magick", str(source), str(target)], check=True)
+ return target
+
+ with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
+ images = list(pool.map(render, frames))
+ command = ["magick"]
+ for frame, image in zip(frames, images):
+ command.extend(["-delay", str(frame["delay"]), str(image)])
+ output = root / "img" / "cotp_demo.gif"
+ command.extend(["-loop", "0", "-layers", "Optimize", str(output)])
+ subprocess.run(command, check=True)
+ duration = sum(frame["delay"] for frame in frames) / 100
+ print(f"Rendered {len(frames)} frames, {duration:.1f}s, {output.stat().st_size / 1024:.0f} KiB: {output}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/img/cotp-dashboard.svg b/img/cotp-dashboard.svg
new file mode 100644
index 00000000..c9181361
--- /dev/null
+++ b/img/cotp-dashboard.svg
@@ -0,0 +1 @@
+
diff --git a/img/cotp-editor.svg b/img/cotp-editor.svg
new file mode 100644
index 00000000..8c5708d6
--- /dev/null
+++ b/img/cotp-editor.svg
@@ -0,0 +1 @@
+
diff --git a/img/cotp_demo.gif b/img/cotp_demo.gif
index f4bdb975..dd0941b4 100644
Binary files a/img/cotp_demo.gif and b/img/cotp_demo.gif differ
diff --git a/requirements.txt b/requirements.txt
index 38168007..505f3999 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,16 +1,16 @@
-astroid
-attrs
-iniconfig
-isort
-lazy-object-proxy
-mccabe
-packaging
-pluggy
-py
-pylint
-pyparsing
-pytest
-six
-toml
-wrapt
-requests
\ No newline at end of file
+astroid==4.3.4
+attrs==26.1.0
+iniconfig==2.3.1
+isort==9.0.2
+lazy-object-proxy==1.13.0
+mccabe==0.7.0
+packaging==26.3
+pluggy==1.7.0
+py==1.11.0
+pylint==4.1.3
+pyparsing==3.3.3
+pytest==9.1.1
+six==1.17.0
+toml==0.10.2
+wrapt==2.5.1
+requests==2.34.2
diff --git a/rust-toolchain.toml b/rust-toolchain.toml
index 8565ca6b..9ec55b3b 100644
--- a/rust-toolchain.toml
+++ b/rust-toolchain.toml
@@ -1,3 +1,3 @@
[toolchain]
-channel = "1.97.1"
+channel = "1.99.1"
components = ["rustfmt", "clippy"]
diff --git a/src/arguments/mod.rs b/src/arguments/mod.rs
index d4de5148..3646cddb 100644
--- a/src/arguments/mod.rs
+++ b/src/arguments/mod.rs
@@ -27,7 +27,17 @@ pub trait SubcommandExecutor {
/// Main structure defining the Clap argument for the cotp commandline utility
#[derive(Parser)]
-#[command(author, version = env!("COTP_VERSION"), about, long_about = None)]
+#[command(author, version = env!("COTP_VERSION"), about, long_about = None,
+ styles = cli_styles(),
+ after_help = "QUICK START
+ cotp Open the interactive vault
+ cotp add --issuer GitHub --label you Add an account
+ cotp import --help Migrate from another authenticator
+ cotp list --json Output codes for scripts
+
+DASHBOARD
+ a add · e edit · / search · Enter copy · Space QR code · ? help"
+)]
pub struct CotpArgs {
#[command(subcommand)]
command: Option,
@@ -74,3 +84,13 @@ fn verify_cli() {
use clap::CommandFactory;
CotpArgs::command().debug_assert();
}
+
+fn cli_styles() -> clap::builder::Styles {
+ use clap::builder::styling::{AnsiColor, Effects};
+ clap::builder::Styles::styled()
+ .header(AnsiColor::Cyan.on_default() | Effects::BOLD)
+ .usage(AnsiColor::Cyan.on_default() | Effects::BOLD)
+ .literal(AnsiColor::Green.on_default() | Effects::BOLD)
+ .placeholder(AnsiColor::Yellow.on_default())
+ .error(AnsiColor::Red.on_default() | Effects::BOLD)
+}
diff --git a/src/interface/app.rs b/src/interface/app.rs
index 757cea1b..ce4a6d54 100644
--- a/src/interface/app.rs
+++ b/src/interface/app.rs
@@ -1,315 +1,92 @@
-use std::error;
-
-use crate::interface::enums::Focus;
-use crate::interface::enums::Page;
-use crate::interface::enums::Page::{Main, Qrcode};
+use std::{
+ error,
+ time::{Duration, Instant, SystemTime, UNIX_EPOCH},
+};
+
+use super::{
+ enums::{Action, Focus, Page, PopupAction},
+ stateful_table::StatefulTable,
+};
use crate::otp::otp_element::OTPDatabase;
-use ratatui::Frame;
-use ratatui::layout::Rect;
-use ratatui::layout::{Alignment, Constraint, Direction, Layout};
-use ratatui::style::{Color, Modifier, Style};
-use ratatui::widgets::{Block, Borders, Cell, Clear, Gauge, Paragraph, Row, Table, Wrap};
-
-use crate::interface::stateful_table::{StatefulTable, fill_table};
-use crate::utils::percentage;
-
-use super::enums::PopupAction;
-use super::popup::centered_rect;
-
-const LARGE_APPLICATION_WIDTH: u16 = 75;
-/// Application result type.
pub type AppResult = Result>;
-const DEFAULT_QRCODE_LABEL: &str = "Press enter to copy the OTP URI code";
-
-/// Application.
pub struct App<'a> {
- /// Is the application running?
pub running: bool,
- title: String,
pub(crate) table: StatefulTable,
pub(crate) database: &'a mut OTPDatabase,
- progress: u16,
- /// Text to print replacing the percentage
- pub(crate) label_text: String,
- pub(crate) print_percentage: bool,
pub(crate) current_page: Page,
pub(crate) search_query: String,
pub(crate) focus: Focus,
pub(crate) popup: Popup,
-
- /// Info text in the `QRCode` page
- pub(crate) qr_code_page_label: &'static str,
+ pub(crate) popup_scroll: u16,
+ pub(crate) notification: Option<(String, bool, Instant)>,
+ pub(crate) hide_codes: bool,
+ pub(crate) now: u64,
+ pub(crate) form: Option,
+ pub(crate) hitboxes: Vec<(ratatui::layout::Rect, Action)>,
}
pub struct Popup {
pub(crate) text: String,
pub(crate) action: PopupAction,
- pub(crate) percent_x: u16,
- pub(crate) percent_y: u16,
}
impl<'a> App<'a> {
- /// Constructs a new instance of [`App`].
pub fn new(database: &'a mut OTPDatabase) -> Self {
- let mut title = String::from(env!("CARGO_PKG_NAME"));
- title.push_str(" v");
- // Settings cotp version from env var defined in build.rs
- title.push_str(env!("COTP_VERSION"));
Self {
running: true,
- title,
table: StatefulTable::new(database.elements_ref()),
database,
- progress: percentage(),
- label_text: String::new(),
- print_percentage: true,
current_page: Page::default(),
search_query: String::new(),
focus: Focus::MainPage,
popup: Popup {
text: String::new(),
- action: PopupAction::EditOtp,
- percent_x: 60,
- percent_y: 20,
+ action: PopupAction::GeneralInfo,
},
- qr_code_page_label: DEFAULT_QRCODE_LABEL,
+ popup_scroll: 0,
+ notification: None,
+ hide_codes: false,
+ now: unix_seconds(),
+ form: None,
+ hitboxes: Vec::new(),
}
}
pub(crate) fn reset(&mut self) {
- self.current_page = Page::default();
- self.print_percentage = true;
- self.qr_code_page_label = DEFAULT_QRCODE_LABEL;
+ self.current_page = Page::Main;
+ self.focus = Focus::MainPage;
}
- /// Handles the tick event of the terminal.
- pub fn tick(&mut self, force_update: bool) {
- // Update progress bar
- let new_progress = percentage();
- // Check for new cycle
- if force_update || new_progress < self.progress {
- // Update codes
- self.table.items.clear();
- fill_table(&mut self.table, self.database.elements_ref());
- }
- self.progress = new_progress;
+ pub(crate) fn notify(&mut self, message: impl Into, error: bool) {
+ self.notification = Some((message.into(), error, Instant::now()));
}
- /// Renders the user interface widgets.
- pub fn render(&mut self, frame: &mut Frame<'_>) {
- match &self.current_page {
- Main => self.render_main_page(frame),
- Qrcode => self.render_qrcode_page(frame),
- }
- }
-
- fn render_qrcode_page(&self, frame: &mut Frame<'_>) {
- let paragraph = self
- .table
- .state
- .selected()
- .and_then(|index| self.database.elements_ref().get(index))
- .map_or_else(
- || {
- Paragraph::new("No element is selected")
- .block(Block::default().title("Nope").borders(Borders::ALL))
- .style(Style::default().fg(Color::White).bg(Color::Reset))
- .alignment(Alignment::Center)
- .wrap(Wrap { trim: true })
- },
- |element| {
- let title = if element.label.is_empty() {
- element.issuer.clone()
- } else {
- format!("{} - {}", element.issuer, element.label)
- };
- Paragraph::new(format!(
- "{}\n{}",
- element.get_qrcode(),
- self.qr_code_page_label
- ))
- .block(Block::default().title(title).borders(Borders::ALL))
- .style(Style::default().fg(Color::White).bg(Color::Reset))
- .alignment(Alignment::Center)
- .wrap(Wrap { trim: true })
- },
- );
- Self::render_paragraph(frame, paragraph);
+ pub(crate) fn filter(&mut self) {
+ self.table
+ .refresh(self.database.elements_ref(), &self.search_query);
}
- fn render_paragraph(frame: &mut Frame<'_>, paragraph: Paragraph) {
- let rects = Layout::default()
- .direction(Direction::Vertical)
- .constraints([Constraint::Percentage(100)].as_ref())
- .split(frame.area());
-
- frame.render_widget(paragraph, rects[0]);
- }
-
- fn render_main_page(&mut self, frame: &mut Frame<'_>) {
- let height = frame.area().height;
- let rects = Layout::default()
- .direction(Direction::Vertical)
- .constraints(
- [
- Constraint::Length(3), // Search bar
- Constraint::Length(height - 8), // Table + Info Box
- Constraint::Length(1), // Progress bar
- ]
- .as_ref(),
- )
- .margin(2)
- .split(frame.area());
-
- let search_bar_title = "Press CTRL + F to search a code...";
- let search_bar = Paragraph::new(&*self.search_query)
- .block(
- Block::default()
- .title(search_bar_title)
- .borders(Borders::ALL)
- .border_style(Style::default().fg(if self.focus == Focus::SearchBar {
- Color::LightRed
- } else {
- Color::White
- })),
- )
- .style(Style::default().fg(Color::White).bg(Color::Reset))
- .alignment(Alignment::Center)
- .wrap(Wrap { trim: true });
-
- let progress_label = if self.print_percentage {
- format!("{}%", self.progress)
- } else {
- self.label_text.clone()
- };
- let progress_bar = Gauge::default()
- .block(Block::default())
- .gauge_style(
- Style::default()
- .bg(Color::White)
- .fg(Color::DarkGray)
- .add_modifier(Modifier::BOLD),
- )
- .percent(self.progress)
- .label(progress_label);
-
- frame.render_widget(search_bar, rects[0]);
- self.render_table_box(frame, rects[1]);
- frame.render_widget(progress_bar, rects[2]);
- if self.focus == Focus::Popup {
- self.render_alert(frame);
+ pub fn tick(&mut self, force_update: bool) {
+ let now = unix_seconds();
+ if force_update || now != self.now {
+ self.filter();
}
- }
-
- fn render_alert(&mut self, frame: &mut Frame<'_>) {
- let block = Block::default().title("Alert").borders(Borders::ALL);
- let paragraph = Paragraph::new(&*self.popup.text)
- .block(block)
- .alignment(Alignment::Center)
- .wrap(Wrap { trim: true });
- let area = centered_rect(self.popup.percent_x, self.popup.percent_y, frame.area());
- frame.render_widget(Clear, area);
- //this clears out the background
- frame.render_widget(paragraph, area);
- }
-
- fn render_table_box(&mut self, frame: &mut Frame<'_>, area: Rect) {
- let constraints = if Self::is_large_application(frame) {
- vec![Constraint::Percentage(80), Constraint::Percentage(20)]
- } else {
- vec![Constraint::Percentage(100)]
- };
- let chunks = Layout::default()
- .constraints(constraints)
- .direction(Direction::Horizontal)
- .split(area);
-
- let header_cells = ["Id", "Issuer", "Label", "OTP"]
- .iter()
- .map(|h| Cell::from(*h).style(Style::default().fg(Color::Black)));
- let header = Row::new(header_cells)
- .style(
- Style::default()
- .bg(Color::White)
- .add_modifier(Modifier::BOLD),
- )
- .height(1)
- .bottom_margin(1);
- let rows = self.table.items.iter().map(|item| {
- Row::new(item.cells())
- .height(item.height())
- .bottom_margin(1)
- });
-
- const TABLE_WIDTHS: &[Constraint] = &[
- Constraint::Percentage(5),
- Constraint::Percentage(35),
- Constraint::Percentage(35),
- Constraint::Percentage(25),
- ];
-
- let t = Table::new(rows, TABLE_WIDTHS)
- .header(header)
- .block(
- Block::default()
- .borders(Borders::TOP | Borders::BOTTOM)
- .title(self.title.as_str()),
- )
- .row_highlight_style(
- Style::default()
- .bg(Color::White)
- .fg(Color::Black)
- .add_modifier(Modifier::BOLD),
- )
- .highlight_symbol("-> ");
-
- let selected_element = self
- .table
- .state
- .selected()
- .and_then(|i| self.database.get_element(i));
-
- let mut text = if let Some(element) = selected_element {
- format!(
- "
- Type: {}
- Algorithm: {}
- Period: {} {}
- Counter: {}
- Pin: {}
- ",
- element.type_,
- element.algorithm,
- element.period,
- if element.period == 1u64 {
- "second"
- } else {
- "seconds"
- },
- element
- .counter
- .map_or_else(|| String::from("N/A"), |e| e.to_string()),
- element.pin.clone().unwrap_or_else(|| String::from("N/A"))
- )
- } else {
- String::new()
- };
-
- text.push_str("\n\n Press '?' to get help\n");
- let paragraph = Paragraph::new(text)
- .block(Block::default().title("Code info").borders(Borders::ALL))
- .style(Style::default().fg(Color::White).bg(Color::Reset))
- .alignment(Alignment::Left)
- .wrap(Wrap { trim: true });
- frame.render_stateful_widget(t, chunks[0], &mut self.table.state);
- if Self::is_large_application(frame) {
- frame.render_widget(paragraph, chunks[1]);
+ self.now = now;
+ if self
+ .notification
+ .as_ref()
+ .is_some_and(|(_, _, start)| start.elapsed() >= Duration::from_secs(4))
+ {
+ self.notification = None;
}
}
+}
- fn is_large_application(frame: &mut Frame<'_>) -> bool {
- frame.area().width >= LARGE_APPLICATION_WIDTH
- }
+fn unix_seconds() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs()
}
diff --git a/src/interface/enums.rs b/src/interface/enums.rs
index 373bb849..4337beb5 100644
--- a/src/interface/enums.rs
+++ b/src/interface/enums.rs
@@ -1,21 +1,33 @@
-#[derive(Eq, PartialEq, Debug)]
-pub enum Focus {
- MainPage,
- SearchBar,
- Popup,
-}
-
-#[derive(Eq, PartialEq, Debug)]
-pub enum PopupAction {
- EditOtp,
- DeleteOtp,
- GeneralInfo,
- SaveBeforeQuit,
-}
-
-#[derive(Eq, PartialEq, Debug, Default)]
-pub enum Page {
- #[default]
- Main,
- Qrcode,
-}
+#[derive(Eq, PartialEq, Debug)]
+pub enum Focus {
+ MainPage,
+ SearchBar,
+ Popup,
+ Form,
+}
+
+#[derive(Eq, PartialEq, Debug)]
+pub enum PopupAction {
+ DeleteOtp,
+ GeneralInfo,
+ SaveBeforeQuit,
+}
+
+#[derive(Eq, PartialEq, Debug, Default)]
+pub enum Page {
+ #[default]
+ Main,
+ Qrcode,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub(crate) enum Action {
+ Key(crossterm::event::KeyCode),
+ Command(crossterm::event::KeyCode),
+ Search,
+ ClearSearch,
+ Select(usize),
+ Field(usize),
+ Save,
+ Cancel,
+}
diff --git a/src/interface/event.rs b/src/interface/event.rs
index 2726fe17..9b7f30eb 100644
--- a/src/interface/event.rs
+++ b/src/interface/event.rs
@@ -1,88 +1,49 @@
-use std::sync::mpsc;
-use std::thread;
-use std::time::{Duration, Instant};
-
-use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, KeyEventKind};
-
use crate::interface::app::AppResult;
+use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, KeyEventKind, MouseEvent};
+use std::time::{Duration, Instant};
-/// Terminal events.
#[derive(Clone, Debug)]
pub enum Event {
- /// Terminal tick.
Tick,
- /// Key press.
Key(KeyEvent),
- /// Mouse click/scroll.
- Mouse(()),
- /// Terminal resize.
- Resize((), ()),
- /// Focus gained
- FocusGained(),
- /// Focus lost
- FocusLost(),
- /// Paste text
- Paste(()),
+ Mouse(MouseEvent),
+ Resize,
+ Paste(String),
}
-/// Terminal event handler.
+/// Poll on the UI thread so terminal errors propagate and no reader outlives the TUI.
#[derive(Debug)]
pub struct EventHandler {
- // Event sender channel.
- //sender: mpsc::Sender,
- /// Event receiver channel.
- receiver: mpsc::Receiver,
- // Event handler thread.
- //handler: thread::JoinHandle<()>,
+ tick_rate: Duration,
+ last_tick: Instant,
}
impl EventHandler {
- /// Constructs a new instance of [`EventHandler`].
pub fn new(tick_rate: u64) -> Self {
- let tick_rate = Duration::from_millis(tick_rate);
- let (sender, receiver) = mpsc::channel();
- thread::spawn(move || {
- let mut last_tick = Instant::now();
- loop {
- let timeout = tick_rate
- .checked_sub(last_tick.elapsed())
- .unwrap_or(tick_rate);
+ Self {
+ tick_rate: Duration::from_millis(tick_rate),
+ last_tick: Instant::now(),
+ }
+ }
- if event::poll(timeout).expect("no events available") {
- match event::read().expect("unable to read event") {
- CrosstermEvent::Key(e) => {
- // Workaround to fix double input on Windows
- // Please check https://github.com/crossterm-rs/crossterm/issues/752
- if e.kind == KeyEventKind::Press {
- sender.send(Event::Key(e))
- } else {
- Ok(())
- }
- }
- CrosstermEvent::Mouse(_e) => sender.send(Event::Mouse(())),
- CrosstermEvent::Resize(_w, _h) => sender.send(Event::Resize((), ())),
- CrosstermEvent::FocusGained => sender.send(Event::FocusGained()),
- CrosstermEvent::FocusLost => sender.send(Event::FocusLost()),
- CrosstermEvent::Paste(_e) => sender.send(Event::Paste(())),
+ pub fn next(&mut self) -> AppResult {
+ loop {
+ let timeout = self.tick_rate.saturating_sub(self.last_tick.elapsed());
+ if timeout.is_zero() {
+ self.last_tick = Instant::now();
+ return Ok(Event::Tick);
+ }
+ if event::poll(timeout)? {
+ match event::read()? {
+ CrosstermEvent::Key(key) if key.kind != KeyEventKind::Release => {
+ return Ok(Event::Key(key));
}
- .expect("failed to send terminal event");
- }
-
- if last_tick.elapsed() >= tick_rate {
- sender.send(Event::Tick).expect("failed to send tick event");
- last_tick = Instant::now();
+ CrosstermEvent::Mouse(mouse) => return Ok(Event::Mouse(mouse)),
+ CrosstermEvent::Paste(text) => return Ok(Event::Paste(text)),
+ CrosstermEvent::Resize(_, _) => return Ok(Event::Resize),
+ _ => {}
}
}
- });
-
- Self { receiver }
- }
-
- /// Receive the next event from the handler thread.
- ///
- /// This function will always block the current thread if
- /// there is no data available and it's possible for more data to be sent.
- pub fn next(&self) -> AppResult {
- Ok(self.receiver.recv()?)
+ }
}
}
diff --git a/src/interface/form.rs b/src/interface/form.rs
new file mode 100644
index 00000000..0ff24224
--- /dev/null
+++ b/src/interface/form.rs
@@ -0,0 +1,448 @@
+//! Account editor. Metadata uses tui-input; secret buffers are masked and zeroized.
+use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
+use tui_input::{Input, InputRequest, backend::crossterm::EventHandler};
+use zeroize::{Zeroize, Zeroizing};
+
+use super::{
+ app::App,
+ enums::{Focus, Page},
+ row::code_for,
+};
+use crate::otp::{
+ from_otp_uri::FromOtpUri, otp_algorithm::OTPAlgorithm, otp_element::OTPElement,
+ otp_type::OTPType,
+};
+
+pub(crate) const SAVE: usize = 9;
+pub(crate) const CANCEL: usize = 10;
+const TYPES: [&str; 5] = ["TOTP", "HOTP", "STEAM", "YANDEX", "MOTP"];
+const ALGORITHMS: [&str; 4] = ["SHA1", "SHA256", "SHA512", "MD5"];
+pub(crate) const LABELS: [&str; 9] = [
+ "Issuer",
+ "Account label",
+ "Secret",
+ "Type",
+ "Algorithm",
+ "Digits",
+ "Period (seconds)",
+ "Counter",
+ "PIN",
+];
+
+#[derive(Default)]
+struct SecretInput {
+ value: Zeroizing,
+ cursor: usize,
+}
+
+impl SecretInput {
+ // Secrets and OTP URIs are ASCII. Rebuild on mutation so removed text is zeroized too.
+ fn replace(&mut self, start: usize, end: usize, text: &str) {
+ let value = format!("{}{}{}", &self.value[..start], text, &self.value[end..]);
+ self.value = Zeroizing::new(value);
+ self.cursor = start + text.len();
+ }
+
+ fn handle(&mut self, key: KeyEvent) {
+ match key.code {
+ KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
+ self.value.zeroize();
+ self.cursor = 0;
+ }
+ KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => self.cursor = 0,
+ KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
+ self.cursor = self.value.len()
+ }
+ KeyCode::Home => self.cursor = 0,
+ KeyCode::End => self.cursor = self.value.len(),
+ KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
+ KeyCode::Right => self.cursor = (self.cursor + 1).min(self.value.len()),
+ KeyCode::Backspace if self.cursor > 0 => self.replace(self.cursor - 1, self.cursor, ""),
+ KeyCode::Delete if self.cursor < self.value.len() => {
+ self.replace(self.cursor, self.cursor + 1, "")
+ }
+ KeyCode::Char(c)
+ if c.is_ascii()
+ && !c.is_control()
+ && !key
+ .modifiers
+ .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
+ {
+ self.replace(self.cursor, self.cursor, &c.to_string());
+ }
+ _ => {}
+ }
+ }
+}
+
+pub(crate) struct AccountForm {
+ pub(crate) editing: Option,
+ original: Option,
+ pub(crate) inputs: [Input; 9],
+ secret: SecretInput,
+ pin: SecretInput,
+ pub(crate) active: usize,
+ pub(crate) error: Option,
+}
+
+impl AccountForm {
+ pub(crate) fn new(editing: Option, element: Option<&OTPElement>) -> Self {
+ let mut form = Self {
+ editing,
+ original: element.cloned(),
+ inputs: std::array::from_fn(|_| Input::default()),
+ secret: SecretInput::default(),
+ pin: SecretInput::default(),
+ active: 0,
+ error: None,
+ };
+ let defaults = ["", "", "", "TOTP", "SHA1", "6", "30", "0", ""];
+ for (input, value) in form.inputs.iter_mut().zip(defaults) {
+ *input = Input::from(value);
+ }
+ if let Some(element) = element {
+ form.populate_metadata(element);
+ }
+ form
+ }
+
+ fn populate_metadata(&mut self, element: &OTPElement) {
+ for (index, value) in [
+ (0, element.issuer.clone()),
+ (1, element.label.clone()),
+ (3, element.type_.to_string()),
+ (4, element.algorithm.to_string()),
+ (5, element.digits.to_string()),
+ (6, element.period.to_string()),
+ (7, element.counter.unwrap_or(0).to_string()),
+ ] {
+ self.inputs[index] = Input::new(value);
+ }
+ }
+
+ pub(crate) fn fields(&self) -> Vec {
+ let kind = self.inputs[3].value();
+ (0..9)
+ .filter(|index| match index {
+ 4 => kind != "MOTP",
+ 6 => kind != "HOTP" && kind != "STEAM",
+ 7 => kind == "HOTP",
+ 8 => kind == "YANDEX" || kind == "MOTP",
+ _ => true,
+ })
+ .collect()
+ }
+
+ pub(crate) fn navigate(&mut self, backwards: bool) {
+ let mut order = self.fields();
+ order.extend([SAVE, CANCEL]);
+ let position = order
+ .iter()
+ .position(|index| *index == self.active)
+ .unwrap_or(0);
+ self.active = order[(position + if backwards { order.len() - 1 } else { 1 }) % order.len()];
+ }
+
+ pub(crate) fn is_choice(index: usize) -> bool {
+ index == 3 || index == 4
+ }
+
+ pub(crate) fn cycle(&mut self, backwards: bool) {
+ let options: &[&str] = if self.active == 3 {
+ &TYPES
+ } else {
+ &ALGORITHMS
+ };
+ let current = self.inputs[self.active].value();
+ let position = options
+ .iter()
+ .position(|value| *value == current)
+ .unwrap_or(0);
+ let next = (position + if backwards { options.len() - 1 } else { 1 }) % options.len();
+ self.inputs[self.active] = Input::from(options[next]);
+ if self.active == 3 {
+ let digits = match options[next] {
+ "STEAM" => "5",
+ "YANDEX" => "8",
+ _ => "6",
+ };
+ self.inputs[5] = Input::from(digits);
+ if options[next] == "YANDEX" {
+ self.inputs[4] = Input::from("SHA256");
+ }
+ if options[next] == "MOTP" {
+ self.inputs[6] = Input::from("10");
+ }
+ }
+ }
+
+ pub(crate) fn display_input(&self, index: usize) -> Input {
+ match index {
+ 2 => Input::new("•".repeat(self.secret.value.len())).with_cursor(self.secret.cursor),
+ 8 => Input::new("•".repeat(self.pin.value.len())).with_cursor(self.pin.cursor),
+ _ => self.inputs[index].clone(),
+ }
+ }
+
+ pub(crate) fn hint(&self, index: usize) -> &'static str {
+ match index {
+ 0 => "Service name, for example GitHub",
+ 1 => "Email, username, or account name",
+ 2 if self.editing.is_some() => "Leave blank to keep the current secret",
+ 2 => "Secret key; you can also paste an otpauth:// URI",
+ 3 | 4 => "Click, ← / → or Space to change",
+ 5 => "Number of characters in the generated code (1–10)",
+ 6 => "Must match the service's refresh period",
+ 7 => "Must match the service's HOTP counter",
+ 8 if self.editing.is_some() => "Leave blank to keep the current PIN",
+ 8 => "Required for Yandex and MOTP",
+ _ => "",
+ }
+ }
+
+ pub(crate) fn paste(&mut self, text: &str) {
+ if self.active >= SAVE || Self::is_choice(self.active) {
+ return;
+ }
+ if self.active == 2 && text.trim().starts_with("otpauth://") {
+ if !["totp", "hotp", "steam", "yandex", "motp"]
+ .iter()
+ .any(|kind| text.trim().starts_with(&format!("otpauth://{kind}/")))
+ {
+ self.error = Some("Unsupported OTP URI account type.".into());
+ return;
+ }
+ match OTPElement::from_otp_uri(text.trim()) {
+ Ok(element) => {
+ self.populate_metadata(&element);
+ self.secret.value = Zeroizing::new(element.secret.clone());
+ self.secret.cursor = self.secret.value.len();
+ self.error = None;
+ }
+ Err(_) => {
+ self.error =
+ Some("Invalid OTP URI. Check the account and secret parameters.".into())
+ }
+ }
+ return;
+ }
+ if self.active == 2 || self.active == 8 {
+ let clean = Zeroizing::new(
+ text.chars()
+ .filter(|c| c.is_ascii() && !c.is_control())
+ .collect::(),
+ );
+ let input = if self.active == 2 {
+ &mut self.secret
+ } else {
+ &mut self.pin
+ };
+ input.replace(input.cursor, input.cursor, &clean);
+ } else {
+ for c in text.chars().filter(|c| !c.is_control()) {
+ self.inputs[self.active].handle(InputRequest::InsertChar(c));
+ }
+ }
+ }
+
+ pub(crate) fn input(&mut self, key: KeyEvent) {
+ if self.active >= SAVE {
+ return;
+ }
+ if Self::is_choice(self.active) {
+ if matches!(
+ key.code,
+ KeyCode::Left | KeyCode::Right | KeyCode::Char(' ')
+ ) {
+ self.cycle(key.code == KeyCode::Left);
+ }
+ } else if self.active == 2 {
+ self.secret.handle(key);
+ } else if self.active == 8 {
+ self.pin.handle(key);
+ } else {
+ self.inputs[self.active].handle_event(&Event::Key(key));
+ }
+ }
+
+ pub(crate) fn build(&self) -> Result {
+ let fail = |field, message: &str| (field, message.to_string());
+ let label = self.inputs[1].value().trim();
+ if label.is_empty() {
+ return Err(fail(1, "Enter an account label."));
+ }
+ let digits = self.inputs[5]
+ .value()
+ .parse::()
+ .ok()
+ .filter(|n| (1..=10).contains(n))
+ .ok_or_else(|| fail(5, "Digits must be a number from 1 to 10."))?;
+ let kind = OTPType::from(self.inputs[3].value());
+ let period = match kind {
+ OTPType::Steam | OTPType::Hotp => 30,
+ _ => self.inputs[6]
+ .value()
+ .parse::()
+ .ok()
+ .filter(|n| *n > 0)
+ .ok_or_else(|| fail(6, "Period must be a whole number greater than zero."))?,
+ };
+ let counter = if kind == OTPType::Hotp {
+ Some(
+ self.inputs[7]
+ .value()
+ .parse::()
+ .map_err(|_| fail(7, "Counter must be a non-negative whole number."))?,
+ )
+ } else {
+ None
+ };
+ let secret = if self.secret.value.is_empty() {
+ self.original
+ .as_ref()
+ .map(|e| e.secret.as_str())
+ .unwrap_or("")
+ } else {
+ &self.secret.value
+ };
+ let secret = Zeroizing::new(
+ secret
+ .chars()
+ .filter(|c| !c.is_whitespace())
+ .collect::(),
+ );
+ if secret.is_empty() {
+ return Err(fail(2, "Enter the account's secret key."));
+ }
+ let pin = if matches!(kind, OTPType::Yandex | OTPType::Motp) {
+ let pin = if self.pin.value.is_empty() {
+ self.original
+ .as_ref()
+ .and_then(|e| e.pin.as_deref())
+ .unwrap_or("")
+ } else {
+ &self.pin.value
+ };
+ if pin.is_empty() {
+ return Err(fail(8, "A PIN is required for this account type."));
+ }
+ Some(pin.to_string())
+ } else {
+ None
+ };
+ let element = OTPElement {
+ secret: if kind == OTPType::Motp {
+ secret.to_lowercase()
+ } else {
+ secret.to_uppercase()
+ },
+ issuer: self.inputs[0].value().trim().into(),
+ label: label.into(),
+ digits,
+ type_: kind,
+ algorithm: OTPAlgorithm::from(self.inputs[4].value()),
+ period,
+ counter,
+ pin,
+ };
+ let decoded = if kind == OTPType::Motp {
+ hex::decode(&element.secret)
+ .map_err(|_| fail(2, "MOTP requires a hexadecimal secret key."))?
+ } else {
+ data_encoding::BASE32_NOPAD
+ .decode(element.secret.as_bytes())
+ .map_err(|_| fail(2, "Enter a valid Base32 secret key (A–Z and 2–7)."))?
+ };
+ let _decoded = Zeroizing::new(decoded);
+ code_for(&element).map_err(|error| fail(2, &format!("Cannot generate a code: {error}")))?;
+ Ok(element)
+ }
+}
+
+impl App<'_> {
+ pub(crate) fn open_form(&mut self, edit: bool) {
+ let index = if edit {
+ self.table.selected_index()
+ } else {
+ None
+ };
+ if edit && index.is_none() {
+ self.notify("Select an account to edit", true);
+ return;
+ }
+ self.form = Some(AccountForm::new(
+ index,
+ index.and_then(|i| self.database.get_element(i)),
+ ));
+ self.current_page = Page::Main;
+ self.focus = Focus::Form;
+ }
+
+ pub(crate) fn cancel_form(&mut self) {
+ self.form = None;
+ self.focus = Focus::MainPage;
+ }
+
+ pub(crate) fn save_form(&mut self) {
+ let Some(form) = self.form.as_mut() else {
+ return;
+ };
+ match form.build() {
+ Ok(element) => {
+ let editing = form.editing;
+ let index = editing.unwrap_or(self.database.elements_ref().len());
+ if let Some(existing) = editing.and_then(|i| self.database.mut_element(i)) {
+ *existing = element;
+ self.database.mark_modified();
+ } else {
+ self.database.add_element(element);
+ }
+ self.cancel_form();
+ self.search_query.clear();
+ self.filter();
+ self.table.state.select(
+ self.table
+ .items
+ .iter()
+ .position(|row| row.database_index == index),
+ );
+ self.notify(
+ if editing.is_some() {
+ "Account updated · changes pending save"
+ } else {
+ "Account added · changes pending save"
+ },
+ false,
+ );
+ }
+ Err((field, error)) => {
+ form.active = field;
+ form.error = Some(error);
+ }
+ }
+ }
+}
+
+pub(crate) fn handle_form_key(key: KeyEvent, app: &mut App) {
+ if key.code == KeyCode::Esc
+ || key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)
+ {
+ app.cancel_form();
+ return;
+ }
+ if key.code == KeyCode::Char('s') && key.modifiers.contains(KeyModifiers::CONTROL) {
+ app.save_form();
+ return;
+ }
+ let Some(form) = app.form.as_mut() else {
+ return;
+ };
+ match key.code {
+ KeyCode::Tab | KeyCode::Down => form.navigate(false),
+ KeyCode::BackTab | KeyCode::Up => form.navigate(true),
+ KeyCode::Enter if form.active == SAVE => app.save_form(),
+ KeyCode::Enter if form.active == CANCEL => app.cancel_form(),
+ KeyCode::Enter => form.navigate(false),
+ _ => form.input(key),
+ }
+}
diff --git a/src/interface/form_view.rs b/src/interface/form_view.rs
new file mode 100644
index 00000000..9bb6e8b2
--- /dev/null
+++ b/src/interface/form_view.rs
@@ -0,0 +1,157 @@
+use super::{
+ app::App,
+ enums::Action,
+ form::{AccountForm, CANCEL, LABELS, SAVE},
+ popup::centered_rect,
+ theme::*,
+};
+use ratatui::{
+ Frame,
+ layout::{Constraint, Layout, Margin, Rect},
+ style::{Modifier, Style},
+ text::{Line, Span},
+ widgets::{Clear, Paragraph, Wrap},
+};
+
+impl App<'_> {
+ pub(crate) fn render_form(&mut self, frame: &mut Frame<'_>) {
+ self.hitboxes.clear();
+ let Some(form) = self.form.as_ref() else {
+ return;
+ };
+ let screen = frame.area();
+ frame.buffer_mut().set_style(
+ screen,
+ Style::default().fg(MUTED).add_modifier(Modifier::DIM),
+ );
+ let height = (form.fields().len() as u16 * 3 + 9).min(34);
+ let area = centered_rect(78, height, screen);
+ frame.render_widget(Clear, area);
+ let block = panel(if form.editing.is_some() {
+ "Edit account"
+ } else {
+ "Add account"
+ })
+ .border_style(Style::default().fg(ACCENT));
+ let inner = block.inner(area).inner(Margin::new(1, 0));
+ frame.render_widget(block, area);
+ let [intro, fields_area, hint, message, buttons, shortcuts] = Layout::vertical([
+ Constraint::Length(2),
+ Constraint::Fill(1),
+ Constraint::Length(1),
+ Constraint::Length(2),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ ])
+ .areas(inner);
+ frame.render_widget(
+ Paragraph::new("Account details\nChanges are written to your vault when you quit.")
+ .style(Style::default().fg(MUTED)),
+ intro,
+ );
+ let fields = form.fields();
+ let visible = (fields_area.height / 3).max(1) as usize;
+ let active_position = fields
+ .iter()
+ .position(|index| *index == form.active)
+ .unwrap_or(fields.len().saturating_sub(1));
+ let offset = active_position.saturating_sub(visible - 1);
+ for (row, index) in fields.iter().skip(offset).take(visible).enumerate() {
+ let index = *index;
+ let rect = Rect::new(
+ fields_area.x,
+ fields_area.y + row as u16 * 3,
+ fields_area.width,
+ 3.min(fields_area.height),
+ );
+ if rect.bottom() > fields_area.bottom() {
+ break;
+ }
+ let active = form.active == index;
+ let input = form.display_input(index);
+ let block = panel(format!(
+ "{}{}",
+ LABELS[index],
+ if index == 2 || index == 8 {
+ " · masked"
+ } else {
+ ""
+ }
+ ))
+ .border_style(Style::default().fg(if active { ACCENT } else { BORDER }));
+ let input_area = block.inner(rect);
+ let width = input_area.width.saturating_sub(2) as usize;
+ let scroll = input.visual_scroll(width);
+ let value = if AccountForm::is_choice(index) {
+ format!("‹ {} ›", input.value())
+ } else if input.value().is_empty()
+ && form.editing.is_some()
+ && (index == 2 || index == 8)
+ {
+ "Unchanged".into()
+ } else {
+ input.value().to_owned()
+ };
+ frame.render_widget(
+ Paragraph::new(value)
+ .style(Style::default().fg(if active { TEXT } else { MUTED }))
+ .scroll((0, scroll.min(u16::MAX as usize) as u16))
+ .block(block),
+ rect,
+ );
+ self.hitboxes.push((rect, Action::Field(index)));
+ if active && !AccountForm::is_choice(index) && input_area.width > 0 {
+ frame.set_cursor_position((
+ input_area.x + input.visual_cursor().saturating_sub(scroll).min(width) as u16,
+ input_area.y,
+ ));
+ }
+ }
+ frame.render_widget(
+ Paragraph::new(form.hint(form.active)).style(Style::default().fg(MUTED)),
+ hint,
+ );
+ let message_text = form
+ .error
+ .as_deref()
+ .unwrap_or("Tab moves between fields and buttons. Enter activates a button.");
+ frame.render_widget(
+ Paragraph::new(message_text)
+ .style(Style::default().fg(if form.error.is_some() { DANGER } else { MUTED }))
+ .wrap(Wrap { trim: true }),
+ message,
+ );
+ let [save, cancel, _] = Layout::horizontal([
+ Constraint::Length(18),
+ Constraint::Length(12),
+ Constraint::Fill(1),
+ ])
+ .spacing(1)
+ .areas(buttons);
+ for (rect, label, index, action) in [
+ (save, "Save account", SAVE, Action::Save),
+ (cancel, "Cancel", CANCEL, Action::Cancel),
+ ] {
+ let active = form.active == index;
+ frame.render_widget(
+ Paragraph::new(
+ Line::from(Span::styled(
+ format!("[ {label} ]"),
+ Style::default()
+ .fg(if active { BACKGROUND } else { ACCENT })
+ .bg(if active { ACCENT } else { SELECTED })
+ .bold(),
+ ))
+ .centered(),
+ ),
+ rect,
+ );
+ self.hitboxes.push((rect, action));
+ }
+ frame.render_widget(
+ Paragraph::new("Ctrl+S save · Esc cancel · Tab / Shift+Tab move")
+ .style(Style::default().fg(MUTED)),
+ shortcuts,
+ );
+ }
+}
diff --git a/src/interface/handlers/main_window.rs b/src/interface/handlers/main_window.rs
index ec9ca955..e55cc413 100644
--- a/src/interface/handlers/main_window.rs
+++ b/src/interface/handlers/main_window.rs
@@ -1,145 +1,114 @@
-use super::{
- super::enums::Page::{Main, Qrcode},
- copy_selected_code_to_clipboard,
-};
-use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
-
+use super::{copy_selected, handle_exit, show_popup};
use crate::{
- clipboard::copy_string_to_clipboard,
interface::{
app::{App, Popup},
enums::{Focus, Page, PopupAction},
},
otp::otp_type::OTPType,
};
-
-use super::{handle_exit, show_popup};
+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
pub(super) fn main_handler(key_event: KeyEvent, app: &mut App) {
match key_event.code {
- // exit application on ESC or Q
- KeyCode::Esc | KeyCode::Char('q' | 'Q') if app.focus != Focus::SearchBar => {
- handle_exit(app);
- }
-
- // exit application on Ctrl-D
- KeyCode::Char('d' | 'D' | 'c') => {
- if key_event.modifiers == KeyModifiers::CONTROL {
+ KeyCode::Char('a') => app.open_form(false),
+ KeyCode::Char('e') => app.open_form(true),
+ KeyCode::Esc => {
+ if app.current_page == Page::Qrcode {
+ app.reset();
+ } else if !app.search_query.is_empty() {
+ app.search_query.clear();
+ app.filter();
+ } else {
handle_exit(app);
- } else if app.table.state.selected().is_some() {
- // Ask the user if he wants to delete the OTP Code
+ }
+ }
+ KeyCode::Char('q' | 'Q') => handle_exit(app),
+ KeyCode::Char('d' | 'D') | KeyCode::Delete if app.current_page == Page::Main => {
+ if let Some(index) = app.table.selected_index() {
+ let element = &app.database.elements_ref()[index];
show_popup(
Popup {
- text: String::from("Do you want to delete the selected OTP Code? [Y/N]"),
- percent_x: 60,
- percent_y: 20,
+ text: format!(
+ "Remove {} / {} from your vault?\n\nThis removes the saved secret for this account. Changes are saved when you quit.",
+ element.issuer, element.label
+ ),
action: PopupAction::DeleteOtp,
},
app,
);
}
}
-
- // Move into the table
KeyCode::Up | KeyCode::Char('k') => {
- app.print_percentage = true;
- app.current_page = Main;
+ app.reset();
app.table.previous();
}
-
KeyCode::Down | KeyCode::Char('j') => {
- app.print_percentage = true;
- app.current_page = Main;
+ app.reset();
app.table.next();
}
-
- KeyCode::Char('+') => {
- app.current_page = Main;
- handle_counter_switch(app, true);
+ KeyCode::Home => {
+ app.reset();
+ app.table.move_by(isize::MIN);
}
-
- KeyCode::Char('-') => {
- app.current_page = Main;
- handle_counter_switch(app, false);
+ KeyCode::End => {
+ app.reset();
+ app.table.move_by(isize::MAX);
}
-
- KeyCode::Char(' ') => handle_switch_page(app, Qrcode),
-
- KeyCode::Char('?') => {
- let info_text = String::from(
- "
- Press:
- d -> Delete selected code
- + -> Increment the HOTP counter
- - -> Decrement the HOTP counter
- Space -> Show QRCode of the selected element
- Enter -> Copy the OTP Code to the clipboard
- CTRL-F | '/' -> Search codes
- CTRL-W | CTRL-U -> Clear the search query
- q, CTRL-D, Esc -> Exit the application
- ",
- );
- show_popup(
- Popup {
- text: info_text,
- percent_x: 40,
- percent_y: 50,
- action: PopupAction::GeneralInfo,
- },
- app,
- );
+ KeyCode::PageUp => {
+ app.reset();
+ app.table.move_by(-10);
}
-
- KeyCode::Char('f' | 'F') if key_event.modifiers == KeyModifiers::CONTROL => {
- app.focus = Focus::SearchBar;
+ KeyCode::PageDown => {
+ app.reset();
+ app.table.move_by(10);
}
-
- KeyCode::Char('/') => app.focus = Focus::SearchBar,
-
- KeyCode::Enter => match app.current_page {
- Main => {
- app.label_text = copy_selected_code_to_clipboard(app);
- app.print_percentage = false;
- }
- Qrcode => {
- let selected_element = app
- .table
- .state
- .selected()
- .and_then(|index| app.database.elements_ref().get(index));
-
- if let Some(element) = selected_element {
- let otp_uri = element.get_otpauth_uri();
- let _ = copy_string_to_clipboard(&otp_uri);
- app.qr_code_page_label = "OTP URI Copied to clipboard";
- }
+ KeyCode::Char('+') => handle_counter_switch(app, true),
+ KeyCode::Char('-') => handle_counter_switch(app, false),
+ KeyCode::Char('h') => {
+ app.hide_codes = !app.hide_codes;
+ app.reset();
+ }
+ KeyCode::Char(' ') => {
+ if app.current_page == Page::Qrcode {
+ app.reset();
+ } else if app.table.selected_index().is_some() {
+ app.current_page = Page::Qrcode;
}
- },
+ }
+ KeyCode::Char('?' | 'i') => show_popup(
+ Popup {
+ text: String::new(),
+ action: PopupAction::GeneralInfo,
+ },
+ app,
+ ),
+ KeyCode::Char('f' | 'F') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
+ app.current_page = Page::Main;
+ app.focus = Focus::SearchBar;
+ }
+ KeyCode::Char('/') => {
+ app.current_page = Page::Main;
+ app.focus = Focus::SearchBar;
+ }
+ KeyCode::Enter => copy_selected(app, app.current_page == Page::Qrcode),
_ => {}
}
}
fn handle_counter_switch(app: &mut App, increment: bool) {
- if let Some(selected) = app.table.state.selected()
+ if let Some(selected) = app.table.selected_index()
&& let Some(element) = app.database.mut_element(selected)
&& element.type_ == OTPType::Hotp
{
- // safe to unwrap because the element type is HOTP
- let counter = element.counter.unwrap();
- element.counter = if increment {
- Some(counter.saturating_add(1))
+ let counter = element.counter.unwrap_or_default();
+ let next = if increment {
+ counter.saturating_add(1)
} else {
- Some(counter.saturating_sub(1))
+ counter.saturating_sub(1)
};
+ element.counter = Some(next);
app.database.mark_modified();
app.tick(true);
- }
-}
-
-fn handle_switch_page(app: &mut App, page: Page) {
- if app.current_page == page {
- app.reset();
- } else {
- app.current_page = page;
+ app.notify(format!("HOTP counter updated to {next}"), false);
}
}
diff --git a/src/interface/handlers/mod.rs b/src/interface/handlers/mod.rs
index 41fdd32a..988e9cd8 100644
--- a/src/interface/handlers/mod.rs
+++ b/src/interface/handlers/mod.rs
@@ -1,63 +1,160 @@
-use crossterm::event::KeyEvent;
-
-use crate::clipboard::{CopyType, copy_string_to_clipboard};
+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use self::{main_window::main_handler, popup::popup_handler, search_bar::search_bar_handler};
-
use super::{
app::{App, Popup},
- enums::{Focus, PopupAction},
+ enums::{Action, Focus, Page, PopupAction},
+ row::code_for,
};
+use crate::clipboard::{CopyType, copy_string_to_clipboard};
mod main_window;
mod popup;
mod search_bar;
-/// Handles the key events and updates the state of [`App`].
pub fn handle_key_events(key_event: KeyEvent, app: &mut App) {
+ if app.focus == Focus::Form {
+ super::form::handle_form_key(key_event, app);
+ return;
+ }
+ if key_event.modifiers.contains(KeyModifiers::CONTROL)
+ && matches!(key_event.code, KeyCode::Char('c' | 'd'))
+ {
+ handle_exit(app);
+ return;
+ }
match app.focus {
Focus::MainPage => main_handler(key_event, app),
Focus::SearchBar => search_bar_handler(key_event, app),
Focus::Popup => popup_handler(key_event, app),
+ Focus::Form => super::form::handle_form_key(key_event, app),
+ }
+}
+
+pub fn handle_mouse_events(event: MouseEvent, app: &mut App) {
+ if event.kind == MouseEventKind::Down(MouseButton::Left) {
+ let action = app
+ .hitboxes
+ .iter()
+ .rev()
+ .find(|(area, _)| area.contains((event.column, event.row).into()))
+ .map(|(_, action)| *action);
+ match action {
+ Some(Action::Key(code)) => {
+ handle_key_events(KeyEvent::new(code, KeyModifiers::NONE), app)
+ }
+ Some(Action::Command(code)) => {
+ app.focus = Focus::MainPage;
+ main_handler(KeyEvent::new(code, KeyModifiers::NONE), app);
+ }
+ Some(Action::ClearSearch) => {
+ app.search_query.clear();
+ app.filter();
+ }
+ Some(Action::Search) => {
+ app.current_page = Page::Main;
+ app.focus = Focus::SearchBar;
+ }
+ Some(Action::Select(index)) => {
+ app.table.state.select(Some(index));
+ app.focus = Focus::MainPage;
+ }
+ Some(Action::Field(index)) => {
+ if let Some(form) = app.form.as_mut() {
+ form.active = index;
+ if super::form::AccountForm::is_choice(index) {
+ form.cycle(false);
+ }
+ }
+ }
+ Some(Action::Save) => app.save_form(),
+ Some(Action::Cancel) => app.cancel_form(),
+ None => {}
+ }
+ return;
+ }
+ let backwards = match event.kind {
+ MouseEventKind::ScrollUp => true,
+ MouseEventKind::ScrollDown => false,
+ _ => return,
+ };
+ match app.focus {
+ Focus::Form => {
+ if let Some(form) = app.form.as_mut() {
+ form.navigate(backwards);
+ }
+ }
+ Focus::Popup => {
+ popup_handler(
+ KeyEvent::new(
+ if backwards {
+ KeyCode::Up
+ } else {
+ KeyCode::Down
+ },
+ KeyModifiers::NONE,
+ ),
+ app,
+ );
+ }
+ _ if app.current_page == Page::Main => {
+ if backwards {
+ app.table.previous();
+ } else {
+ app.table.next();
+ }
+ }
+ _ => {}
+ }
+}
+
+pub fn handle_paste(text: &str, app: &mut App) {
+ if let Some(form) = app.form.as_mut() {
+ form.paste(text);
+ return;
+ }
+ if app.focus == Focus::SearchBar {
+ app.search_query
+ .extend(text.chars().filter(|c| !c.is_control()));
+ app.filter();
}
}
pub(super) fn handle_exit(app: &mut App) {
if app.database.is_modified() {
- show_popup(
- Popup {
- text: String::from("Save changes? [Y/N]"),
- percent_x: 60,
- percent_y: 20,
- action: PopupAction::SaveBeforeQuit,
- },
- app,
- );
+ show_popup(Popup {
+ text: "Your account changes have not been saved.\n\nSave them to your encrypted vault before quitting?".into(),
+ action: PopupAction::SaveBeforeQuit,
+ }, app);
} else {
app.running = false;
}
}
-pub(crate) fn copy_selected_code_to_clipboard(app: &mut App) -> String {
- match app.table.state.selected() {
- Some(selected) => match app.table.items.get(selected) {
- Some(element) => match element.values.get(3) {
- Some(otp_code) => match copy_string_to_clipboard(otp_code) {
- Ok(result) => match result {
- CopyType::Native => "Copied!".to_string(),
- CopyType::OSC52 => "Remote copied!".to_string(),
- },
- _ => "Cannot copy".to_string(),
- },
- None => "Cannot get OTP Code column".to_string(),
- },
- None => format!("Cannot fetch element from index: {selected}"),
- },
- None => "No code selected".to_string(),
+pub(crate) fn copy_selected(app: &mut App, uri: bool) {
+ let result = app
+ .table
+ .selected_index()
+ .and_then(|index| app.database.get_element(index))
+ .ok_or_else(|| "No account selected".to_string())
+ .and_then(|element| {
+ if uri {
+ Ok(element.get_otpauth_uri())
+ } else {
+ code_for(element)
+ }
+ })
+ .and_then(|text| copy_string_to_clipboard(&text).map_err(|error| error.to_string()));
+ let noun = if uri { "OTP URI" } else { "Code" };
+ match result {
+ Ok(CopyType::Native) => app.notify(format!("✓ {noun} copied to clipboard"), false),
+ Ok(CopyType::OSC52) => app.notify(format!("✓ {noun} sent to terminal clipboard"), false),
+ Err(error) => app.notify(format!("Could not copy: {error}"), true),
}
}
fn show_popup(popup: Popup, app: &mut App) {
app.focus = Focus::Popup;
app.popup = popup;
+ app.popup_scroll = 0;
}
diff --git a/src/interface/handlers/popup.rs b/src/interface/handlers/popup.rs
index da032310..94a0f7d2 100644
--- a/src/interface/handlers/popup.rs
+++ b/src/interface/handlers/popup.rs
@@ -1,66 +1,51 @@
-use crossterm::event::{KeyCode, KeyEvent};
-
use crate::interface::{
app::App,
enums::{Focus, PopupAction},
};
+use crossterm::event::{KeyCode, KeyEvent};
pub(super) fn popup_handler(key_event: KeyEvent, app: &mut App) {
match app.popup.action {
- PopupAction::EditOtp => todo!(),
PopupAction::DeleteOtp => match key_event.code {
KeyCode::Char('y' | 'Y') => {
- if let Err(e) = delete_selected_code(app) {
- app.popup.text = e;
- return;
+ if let Some(index) = app.table.selected_index() {
+ app.database.delete_element(index);
+ // Database indices shift after deletion. Select the next visible account.
+ let position = app.table.state.selected().unwrap_or(0);
+ app.table.items.clear();
+ app.filter();
+ if !app.table.items.is_empty() {
+ app.table
+ .state
+ .select(Some(position.min(app.table.items.len() - 1)));
+ }
+ app.notify("Account removed · changes pending save", false);
}
app.focus = Focus::MainPage;
- // Force table render
- app.tick(true);
- }
- KeyCode::Char('n' | 'N') | KeyCode::Esc => {
- app.focus = Focus::MainPage;
}
+ KeyCode::Char('n' | 'N') | KeyCode::Esc => app.focus = Focus::MainPage,
_ => {}
},
PopupAction::GeneralInfo => match key_event.code {
- KeyCode::Char('I' | 'i') | KeyCode::Esc | KeyCode::Enter => {
- app.focus = Focus::MainPage;
+ KeyCode::Down | KeyCode::Char('j') => {
+ app.popup_scroll = app.popup_scroll.saturating_add(1).min(14)
+ }
+ KeyCode::Up | KeyCode::Char('k') => {
+ app.popup_scroll = app.popup_scroll.saturating_sub(1)
+ }
+ KeyCode::Char('I' | 'i' | '?') | KeyCode::Esc | KeyCode::Enter => {
+ app.focus = Focus::MainPage
}
_ => {}
},
PopupAction::SaveBeforeQuit => match key_event.code {
- KeyCode::Char('y' | 'Y') => {
- app.running = false;
- }
+ KeyCode::Char('y' | 'Y') => app.running = false,
KeyCode::Char('n' | 'N') => {
app.database.needs_modification = false;
app.running = false;
}
- KeyCode::Esc => {
- app.focus = Focus::MainPage;
- }
+ KeyCode::Esc => app.focus = Focus::MainPage,
_ => {}
},
}
}
-
-fn delete_selected_code(app: &mut App) -> Result {
- match app.table.state.selected() {
- Some(selected) => {
- if app.database.elements_ref().len() > selected {
- app.database.delete_element(selected);
- app.table.items.remove(selected);
- if selected >= app.database.elements_ref().len() {
- app.table.previous();
- } else if app.database.elements_ref().is_empty() {
- app.table.state.select(None);
- }
- Ok("Done".to_string())
- } else {
- Err("Index out of bounds".to_string())
- }
- }
- None => Err("No code selected".to_string()),
- }
-}
diff --git a/src/interface/handlers/search_bar.rs b/src/interface/handlers/search_bar.rs
index eaa00d4d..3b0cf496 100644
--- a/src/interface/handlers/search_bar.rs
+++ b/src/interface/handlers/search_bar.rs
@@ -1,102 +1,34 @@
-use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
-
+use super::copy_selected;
use crate::interface::{app::App, enums::Focus};
-
-use super::{copy_selected_code_to_clipboard, handle_exit, main_window::main_handler};
+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
pub(super) fn search_bar_handler(key_event: KeyEvent, app: &mut App) {
match key_event.code {
- KeyCode::Char(c) => {
- if key_event.modifiers == KeyModifiers::CONTROL {
- match c.to_ascii_lowercase() {
- 'f' => {
- app.search_query.clear();
- app.focus = Focus::MainPage;
- }
- 'c' => handle_exit(app),
- 'w' | 'u' => app.search_query.clear(),
- _ => {}
+ KeyCode::Char(c) if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
+ match c.to_ascii_lowercase() {
+ 'f' => app.focus = Focus::MainPage,
+ 'w' | 'u' => {
+ app.search_query.clear();
+ app.filter();
}
- } else {
- app.search_query.push(c);
- search_and_select(app);
+ _ => {}
}
}
- KeyCode::Enter => {
- app.label_text = copy_selected_code_to_clipboard(app);
- app.print_percentage = false;
- app.focus = Focus::MainPage;
+ KeyCode::Char(c) if !key_event.modifiers.contains(KeyModifiers::ALT) => {
+ app.search_query.push(c);
+ app.filter();
}
- KeyCode::Esc => {
+ KeyCode::Enter => {
+ copy_selected(app, false);
app.focus = Focus::MainPage;
}
+ KeyCode::Esc | KeyCode::Tab => app.focus = Focus::MainPage,
KeyCode::Backspace => {
app.search_query.pop();
+ app.filter();
}
- KeyCode::Up | KeyCode::Down => {
- app.focus = Focus::MainPage;
- main_handler(key_event, app);
- }
+ KeyCode::Up => app.table.previous(),
+ KeyCode::Down => app.table.next(),
_ => {}
}
}
-
-fn search_and_select(app: &mut App) {
- // Check for issuer
- for iter in app.table.items.iter().enumerate() {
- let (index, row) = iter;
- if row
- .values
- .get(1)
- .unwrap()
- .to_lowercase()
- .starts_with(&app.search_query.to_lowercase())
- {
- app.table.state.select(Some(index));
- return;
- }
- }
- // Check for label
- for iter in app.table.items.iter().enumerate() {
- let (index, row) = iter;
- if row
- .values
- .get(2)
- .unwrap()
- .to_lowercase()
- .starts_with(&app.search_query.to_lowercase())
- {
- app.table.state.select(Some(index));
- return;
- }
- }
- // Check if issuer contains the query
- for iter in app.table.items.iter().enumerate() {
- let (index, row) = iter;
- if row
- .values
- .get(1)
- .unwrap()
- .to_lowercase()
- .contains(&app.search_query.to_lowercase())
- {
- app.table.state.select(Some(index));
- return;
- }
- }
- // Check if label contains the query
- for iter in app.table.items.iter().enumerate() {
- let (index, row) = iter;
- if row
- .values
- .get(2)
- .unwrap()
- .to_lowercase()
- .contains(&app.search_query.to_lowercase())
- {
- app.table.state.select(Some(index));
- return;
- }
- }
- // TODO Handle if no search results
-}
diff --git a/src/interface/mod.rs b/src/interface/mod.rs
index e7be174a..00ddc4f4 100644
--- a/src/interface/mod.rs
+++ b/src/interface/mod.rs
@@ -1,8 +1,15 @@
-pub mod app;
-mod enums;
-pub mod event;
-pub mod handlers;
-mod popup;
-mod row;
-pub mod stateful_table;
-pub mod ui;
+pub mod app;
+mod enums;
+pub mod event;
+pub mod handlers;
+mod popup;
+mod row;
+pub mod stateful_table;
+#[cfg(test)]
+mod tests;
+mod theme;
+pub mod ui;
+mod view;
+
+mod form;
+mod form_view;
diff --git a/src/interface/popup.rs b/src/interface/popup.rs
index f542e345..02697d09 100644
--- a/src/interface/popup.rs
+++ b/src/interface/popup.rs
@@ -1,29 +1,10 @@
-use ratatui::layout::{Constraint, Direction};
-use ratatui::layout::{Layout, Rect};
-
-/// helper function to create a centered rect using up certain percentage of the available rect `r`
-pub(crate) fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
- let popup_layout = Layout::default()
- .direction(Direction::Vertical)
- .constraints(
- [
- Constraint::Percentage((100 - percent_y) / 2),
- Constraint::Percentage(percent_y),
- Constraint::Percentage((100 - percent_y) / 2),
- ]
- .as_ref(),
- )
- .split(r);
-
- Layout::default()
- .direction(Direction::Horizontal)
- .constraints(
- [
- Constraint::Percentage((100 - percent_x) / 2),
- Constraint::Percentage(percent_x),
- Constraint::Percentage((100 - percent_x) / 2),
- ]
- .as_ref(),
- )
- .split(popup_layout[1])[1]
-}
+use ratatui::layout::{Constraint, Flex, Layout, Rect};
+
+pub(crate) fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
+ let [area] = Layout::vertical([Constraint::Length(height.min(area.height))])
+ .flex(Flex::Center)
+ .areas(area);
+ Layout::horizontal([Constraint::Length(width.min(area.width))])
+ .flex(Flex::Center)
+ .areas::<1>(area)[0]
+}
diff --git a/src/interface/row.rs b/src/interface/row.rs
index b6d164c7..00009c50 100644
--- a/src/interface/row.rs
+++ b/src/interface/row.rs
@@ -1,37 +1,56 @@
-use ratatui::style::Color::{Black, Yellow};
-use ratatui::style::Style;
-use ratatui::widgets::Cell;
+use crate::otp::{otp_element::OTPElement, otp_type::OTPType};
pub(crate) struct Row {
- pub(crate) values: Vec,
- has_error: bool,
+ /// Stable database position, independent of the filtered table position.
+ pub(crate) database_index: usize,
+ pub(crate) issuer: String,
+ pub(crate) label: String,
+ pub(crate) code: String,
+ pub(crate) has_error: bool,
}
impl Row {
- pub(crate) fn new(values: Vec, has_error: bool) -> Self {
- Row { values, has_error }
+ pub(crate) fn new(database_index: usize, element: &OTPElement) -> Self {
+ let result = code_for(element);
+ Self {
+ database_index,
+ issuer: element.issuer.clone(),
+ label: element.label.clone(),
+ has_error: result.is_err(),
+ code: result.unwrap_or_else(|error| error),
+ }
}
- pub fn height(&self) -> u16 {
- (self
- .values
- .iter()
- .map(|content| content.chars().filter(|c| *c == '\n').count())
- .max()
- .unwrap_or(0)
- + 1) as u16
+}
+
+pub(crate) fn code_for(element: &OTPElement) -> Result {
+ if period_for(element) == Some(0) {
+ return Err("Invalid period: must be greater than zero".into());
}
+ element.get_otp_code().map_err(|error| error.to_string())
+}
- pub fn cells(&self) -> Vec> {
- self.values
- .iter()
- .map(|c| {
- let style = if self.has_error {
- Style::default().bg(Yellow).fg(Black)
- } else {
- Style::default()
- };
- Cell::from(c.as_str()).style(style)
- })
- .collect()
+pub(crate) fn period_for(element: &OTPElement) -> Option {
+ match element.type_ {
+ OTPType::Hotp => None,
+ OTPType::Steam => Some(30),
+ _ => Some(element.period),
+ }
+}
+
+pub(crate) fn remaining(element: &OTPElement, now: u64) -> Option<(u64, u64)> {
+ period_for(element)
+ .filter(|period| *period > 0)
+ .map(|period| (period - now % period, period))
+}
+
+pub(crate) fn display_code(code: &str, hidden: bool) -> String {
+ if hidden {
+ return "••• •••".into();
+ }
+ if code.len() >= 6 && code.bytes().all(|byte| byte.is_ascii_digit()) {
+ let (left, right) = code.split_at(code.len() / 2);
+ format!("{left} {right}")
+ } else {
+ code.to_owned()
}
}
diff --git a/src/interface/stateful_table.rs b/src/interface/stateful_table.rs
index 3d399d95..a7ec0938 100644
--- a/src/interface/stateful_table.rs
+++ b/src/interface/stateful_table.rs
@@ -1,84 +1,71 @@
use crate::interface::row::Row;
+use crate::otp::otp_element::OTPElement;
use ratatui::widgets::TableState;
-use crate::otp::{otp_element::OTPElement, otp_type::OTPType};
-
pub struct StatefulTable {
pub(crate) state: TableState,
pub(crate) items: Vec,
}
impl StatefulTable {
- pub fn new(elements: &[OTPElement]) -> StatefulTable {
- let mut table = StatefulTable {
+ pub fn new(elements: &[OTPElement]) -> Self {
+ let mut table = Self {
state: TableState::default(),
items: vec![],
};
- fill_table(&mut table, elements);
+ table.refresh(elements, "");
table
}
- pub fn next(&mut self) {
- let selected = if self.items.is_empty() {
- None
- } else {
- Some(match self.state.selected() {
- Some(i) => {
- if i >= self.items.len() - 1 {
- 0
- } else {
- i + 1
- }
- }
- None => 0,
+
+ pub(crate) fn selected_index(&self) -> Option {
+ self.state
+ .selected()
+ .and_then(|index| self.items.get(index))
+ .map(|row| row.database_index)
+ }
+
+ pub(crate) fn refresh(&mut self, elements: &[OTPElement], query: &str) {
+ let selected = self.selected_index();
+ let previous_position = self.state.selected().unwrap_or(0);
+ let query = query.to_lowercase();
+ self.items = elements
+ .iter()
+ .enumerate()
+ .filter(|(_, element)| {
+ format!("{} {}", element.issuer, element.label)
+ .to_lowercase()
+ .contains(&query)
})
- };
- self.state.select(selected);
+ .map(|(index, element)| Row::new(index, element))
+ .collect();
+ let position = selected
+ .and_then(|index| {
+ self.items
+ .iter()
+ .position(|row| row.database_index == index)
+ })
+ .or_else(|| {
+ (!self.items.is_empty()).then(|| previous_position.min(self.items.len() - 1))
+ });
+ self.state.select(position);
}
- pub fn previous(&mut self) {
- let i = match self.state.selected() {
- Some(i) => {
- if i == 0 {
- self.items.len().checked_sub(1)
- } else {
- Some(i - 1)
- }
- }
- None => {
- if self.items.is_empty() {
- None
- } else {
- Some(0)
- }
- }
- };
- self.state.select(i);
+ pub fn next(&mut self) {
+ self.move_by(1);
}
-}
-pub fn fill_table(table: &mut StatefulTable, elements: &[OTPElement]) {
- for (i, element) in elements.iter().enumerate() {
- let label = match element.type_ {
- OTPType::Hotp => match element.counter {
- Some(result) => element.label.clone() + (format!(" ({result} counter)").as_str()),
- None => element.label.clone(),
- },
- _ => element.label.clone(),
- };
- let result = element.get_otp_code();
+ pub fn previous(&mut self) {
+ self.move_by(-1);
+ }
- let error = result.is_err();
- table.items.push(Row::new(
- vec![
- (i + 1).to_string(),
- element.issuer.clone(),
- label,
- match result {
- Ok(code) => code,
- Err(e) => e.to_string(),
- },
- ],
- error,
- ));
+ pub(crate) fn move_by(&mut self, delta: isize) {
+ if !self.items.is_empty() {
+ let index = self
+ .state
+ .selected()
+ .map_or(0, |index| index.saturating_add_signed(delta))
+ .min(self.items.len() - 1);
+ self.state.select(Some(index));
+ }
}
}
diff --git a/src/interface/tests.rs b/src/interface/tests.rs
new file mode 100644
index 00000000..8eca81b1
--- /dev/null
+++ b/src/interface/tests.rs
@@ -0,0 +1,744 @@
+use super::{
+ app::App,
+ enums::{Focus, Page},
+ handlers::{handle_key_events, handle_mouse_events, handle_paste},
+ row::{code_for, remaining},
+};
+use crate::otp::{
+ otp_algorithm::OTPAlgorithm,
+ otp_element::{OTPDatabase, OTPElement},
+ otp_type::OTPType,
+};
+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
+use ratatui::{Terminal, backend::TestBackend, buffer::Buffer};
+
+fn account(issuer: &str, label: &str) -> OTPElement {
+ OTPElement {
+ secret: "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ".into(),
+ issuer: issuer.into(),
+ label: label.into(),
+ digits: 6,
+ type_: OTPType::Totp,
+ algorithm: OTPAlgorithm::Sha1,
+ period: 30,
+ counter: None,
+ pin: None,
+ }
+}
+
+fn database() -> OTPDatabase {
+ OTPDatabase {
+ elements: vec![
+ account("GitHub", "alex@studio.dev"),
+ account("Google", "alex@example.com"),
+ account("Linear", "Design team"),
+ ],
+ ..Default::default()
+ }
+}
+
+fn key(app: &mut App, code: KeyCode) {
+ handle_key_events(KeyEvent::new(code, KeyModifiers::NONE), app);
+}
+
+fn render(app: &mut App, width: u16, height: u16) -> Buffer {
+ let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
+ terminal.draw(|frame| app.render(frame)).unwrap();
+ terminal.backend().buffer().clone()
+}
+
+fn text(buffer: &Buffer) -> String {
+ buffer.content.iter().map(|cell| cell.symbol()).collect()
+}
+
+#[test]
+fn filtering_tracks_database_identity_and_handles_no_results() {
+ let mut db = database();
+ let mut app = App::new(&mut db);
+ key(&mut app, KeyCode::Char('/'));
+ handle_paste("GOOGLE", &mut app);
+ assert_eq!(app.table.items.len(), 1);
+ assert_eq!(app.table.selected_index(), Some(1));
+ app.tick(true);
+ assert_eq!(app.table.selected_index(), Some(1));
+ key(&mut app, KeyCode::Char('x'));
+ assert!(app.table.items.is_empty());
+ assert_eq!(app.table.selected_index(), None);
+ assert!(text(&render(&mut app, 100, 24)).contains("No matching accounts"));
+ key(&mut app, KeyCode::Backspace);
+ assert_eq!(app.table.selected_index(), Some(1));
+ key(&mut app, KeyCode::Esc);
+ key(&mut app, KeyCode::Esc);
+ assert_eq!(app.table.items.len(), 3);
+ assert_eq!(app.table.selected_index(), Some(1));
+}
+
+#[test]
+fn deletion_and_counter_updates_target_the_filtered_account() {
+ let mut db = database();
+ db.elements[1].type_ = OTPType::Hotp;
+ db.elements[1].counter = Some(4);
+ let mut app = App::new(&mut db);
+ app.search_query = "Google".into();
+ app.filter();
+ key(&mut app, KeyCode::Char('+'));
+ assert_eq!(app.database.elements[1].counter, Some(5));
+ assert_eq!(app.database.elements[0].counter, None);
+ key(&mut app, KeyCode::Char('d'));
+ assert_eq!(app.focus, Focus::Popup);
+ key(&mut app, KeyCode::Char('y'));
+ assert_eq!(app.database.elements.len(), 2);
+ assert_eq!(app.database.elements[1].issuer, "Linear");
+ assert!(app.table.items.is_empty());
+ assert_eq!(app.table.state.selected(), None);
+ assert!(app.database.is_modified());
+}
+
+#[test]
+fn deleting_last_account_leaves_a_safe_empty_state() {
+ let mut db = OTPDatabase {
+ elements: vec![account("Example", "alice")],
+ ..Default::default()
+ };
+ let mut app = App::new(&mut db);
+ key(&mut app, KeyCode::Char('d'));
+ key(&mut app, KeyCode::Char('y'));
+ key(&mut app, KeyCode::Up);
+ key(&mut app, KeyCode::Down);
+ assert_eq!(app.table.selected_index(), None);
+ assert!(text(&render(&mut app, 120, 30)).contains("Your vault is ready"));
+}
+
+#[test]
+fn countdown_uses_the_account_period_and_steam_fixed_period() {
+ let mut element = account("Example", "alice");
+ element.period = 45;
+ assert_eq!(remaining(&element, 44), Some((1, 45)));
+ assert_eq!(remaining(&element, 45), Some((45, 45)));
+ element.type_ = OTPType::Steam;
+ assert_eq!(remaining(&element, 45), Some((15, 30)));
+ element.type_ = OTPType::Hotp;
+ assert_eq!(remaining(&element, 45), None);
+ element.type_ = OTPType::Totp;
+ element.period = 0;
+ assert_eq!(remaining(&element, 45), None);
+ assert!(code_for(&element).is_err());
+}
+
+#[test]
+fn refresh_updates_codes_without_resetting_filter_or_selection() {
+ let mut db = database();
+ let mut app = App::new(&mut db);
+ app.search_query = "alex".into();
+ app.filter();
+ app.table.next();
+ app.table.items[1].code = "stale".into();
+ app.now = 0;
+ app.tick(false);
+ assert_eq!(app.table.selected_index(), Some(1));
+ assert_eq!(app.table.items.len(), 2);
+ assert_ne!(app.table.items[1].code, "stale");
+}
+
+#[test]
+fn qr_escape_returns_to_accounts_and_copy_errors_are_reported() {
+ let mut db = database();
+ let mut app = App::new(&mut db);
+ key(&mut app, KeyCode::Char(' '));
+ assert_eq!(app.current_page, Page::Qrcode);
+ key(&mut app, KeyCode::Esc);
+ assert!(app.running);
+ assert_eq!(app.current_page, Page::Main);
+ app.database.elements[0].secret = "INVALID!".into();
+ key(&mut app, KeyCode::Enter);
+ assert!(app.notification.as_ref().unwrap().1);
+ assert!(
+ app.notification
+ .as_ref()
+ .unwrap()
+ .0
+ .starts_with("Could not copy:")
+ );
+}
+
+#[test]
+fn plain_c_does_not_delete_and_save_confirmation_can_be_cancelled() {
+ let mut db = database();
+ let mut app = App::new(&mut db);
+ key(&mut app, KeyCode::Char('c'));
+ assert_eq!(app.focus, Focus::MainPage);
+ app.database.mark_modified();
+ key(&mut app, KeyCode::Char('q'));
+ assert_eq!(app.focus, Focus::Popup);
+ key(&mut app, KeyCode::Esc);
+ assert!(app.running);
+ key(&mut app, KeyCode::Char('q'));
+ key(&mut app, KeyCode::Char('n'));
+ assert!(!app.running);
+ assert!(!app.database.is_modified());
+}
+
+#[test]
+fn search_supports_unicode_paste_and_mouse_navigation() {
+ let mut db = database();
+ db.elements[0].label = "東京のアカウント".into();
+ let mut app = App::new(&mut db);
+ key(&mut app, KeyCode::Char('/'));
+ handle_paste("東京\n", &mut app);
+ assert_eq!(app.search_query, "東京");
+ assert_eq!(app.table.items.len(), 1);
+ handle_key_events(
+ KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
+ &mut app,
+ );
+ assert_eq!(app.table.items.len(), 3);
+ handle_mouse_events(
+ MouseEvent {
+ kind: MouseEventKind::ScrollDown,
+ column: 10,
+ row: 10,
+ modifiers: KeyModifiers::NONE,
+ },
+ &mut app,
+ );
+ assert_eq!(app.table.selected_index(), Some(1));
+ assert_eq!(app.focus, Focus::SearchBar);
+}
+
+#[test]
+fn hidden_codes_and_pins_never_appear_on_dashboard() {
+ let mut db = database();
+ db.elements[0].pin = Some("sensitive-pin".into());
+ let mut app = App::new(&mut db);
+ app.table.items[0].code = "123456".into();
+ key(&mut app, KeyCode::Char('h'));
+ let output = text(&render(&mut app, 120, 30));
+ assert!(!output.contains("123 456"));
+ assert!(!output.contains("sensitive-pin"));
+ assert!(output.contains("Codes hidden"));
+}
+
+#[test]
+fn layouts_render_across_sizes_and_overlay_states() {
+ let mut db = database();
+ let mut app = App::new(&mut db);
+ for (width, height) in [
+ (1, 1),
+ (20, 5),
+ (36, 12),
+ (60, 18),
+ (80, 24),
+ (120, 30),
+ (180, 50),
+ ] {
+ render(&mut app, width, height);
+ key(&mut app, KeyCode::Char('?'));
+ render(&mut app, width, height);
+ key(&mut app, KeyCode::Esc);
+ key(&mut app, KeyCode::Char(' '));
+ render(&mut app, width, height);
+ key(&mut app, KeyCode::Esc);
+ key(&mut app, KeyCode::Char('/'));
+ app.search_query = "東京👩💻".repeat(100);
+ render(&mut app, width, height);
+ app.search_query.clear();
+ key(&mut app, KeyCode::Esc);
+ }
+}
+
+#[test]
+fn account_scroll_keeps_last_selection_visible() {
+ let mut db = database();
+ for i in 0..40 {
+ db.elements
+ .push(account(&format!("Account {i:02}"), "alice"));
+ }
+ let mut app = App::new(&mut db);
+ key(&mut app, KeyCode::End);
+ assert!(text(&render(&mut app, 80, 24)).contains("Account 39"));
+ key(&mut app, KeyCode::Home);
+ assert!(text(&render(&mut app, 80, 24)).contains("GitHub"));
+}
+
+#[test]
+fn dashboard_preview() {
+ let mut db = database();
+ db.elements.extend([
+ account("AWS", "Production · engineering"),
+ account("Figma", "alex@studio.dev"),
+ account("Proton", "Personal"),
+ ]);
+ let mut app = App::new(&mut db);
+ app.now = 12;
+ for (i, item) in app.table.items.iter_mut().enumerate() {
+ item.code = format!("{:06}", 751109 + i * 7319);
+ }
+ let buffer = render(&mut app, 120, 30);
+ let output = text(&buffer);
+ assert!(output.contains("751 109"));
+ assert!(output.contains("Selected account"));
+ assert!(output.contains("18s"));
+ if let Ok(path) = std::env::var("COTP_UI_PREVIEW") {
+ write_preview_svg(&buffer, &path);
+ }
+}
+
+// Optional documentation preview generated from the actual rendered buffer and fake accounts.
+fn write_preview_svg(buffer: &Buffer, path: &str) {
+ use ratatui::style::Color;
+ fn color(value: Color) -> String {
+ match value {
+ Color::Rgb(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"),
+ Color::Black => "#000000".into(),
+ _ => "#e0e9f2".into(),
+ }
+ }
+ let mut svg = format!(
+ "\n");
+ std::fs::write(path, svg).unwrap();
+}
+
+fn control(app: &mut App, c: char) {
+ handle_key_events(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL), app);
+}
+
+fn click(app: &mut App, action: super::enums::Action) {
+ let area = app
+ .hitboxes
+ .iter()
+ .find(|(_, candidate)| *candidate == action)
+ .expect("action must be visible")
+ .0;
+ handle_mouse_events(
+ MouseEvent {
+ kind: MouseEventKind::Down(crossterm::event::MouseButton::Left),
+ column: area.x,
+ row: area.y,
+ modifiers: KeyModifiers::NONE,
+ },
+ app,
+ );
+}
+
+#[test]
+fn add_account_entirely_with_keyboard_and_save_button() {
+ let mut db = OTPDatabase::default();
+ let mut app = App::new(&mut db);
+ key(&mut app, KeyCode::Char('a'));
+ assert_eq!(app.focus, Focus::Form);
+ handle_paste("Example", &mut app);
+ key(&mut app, KeyCode::Tab);
+ handle_paste("alice@example.com", &mut app);
+ key(&mut app, KeyCode::Tab);
+ handle_paste("gezd gnbv gy3t qojq gezd gnbv gy3t qojq", &mut app);
+ // Tab through type, algorithm, digits and period to the explicit Save button.
+ for _ in 0..5 {
+ key(&mut app, KeyCode::Tab);
+ }
+ assert_eq!(app.form.as_ref().unwrap().active, super::form::SAVE);
+ key(&mut app, KeyCode::Enter);
+ assert_eq!(app.focus, Focus::MainPage);
+ assert!(app.form.is_none());
+ assert_eq!(app.database.elements.len(), 1);
+ assert_eq!(app.database.elements[0].issuer, "Example");
+ assert_eq!(app.database.elements[0].label, "alice@example.com");
+ assert_eq!(
+ app.database.elements[0].secret,
+ "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
+ );
+ assert!(app.database.is_modified());
+}
+
+#[test]
+fn editing_filtered_account_keeps_secret_and_pin_and_supports_cursor_editing() {
+ let mut db = database();
+ db.elements[1].type_ = OTPType::Motp;
+ db.elements[1].secret = "e3152afee62599c8".into();
+ db.elements[1].pin = Some("1234".into());
+ let mut app = App::new(&mut db);
+ app.search_query = "Google".into();
+ app.filter();
+ key(&mut app, KeyCode::Char('e'));
+ key(&mut app, KeyCode::Home);
+ handle_paste("My ", &mut app);
+ control(&mut app, 's');
+ assert_eq!(app.database.elements[1].issuer, "My Google");
+ assert_eq!(app.database.elements[0].issuer, "GitHub");
+ assert_eq!(app.database.elements[1].secret, "e3152afee62599c8");
+ assert_eq!(app.database.elements[1].pin.as_deref(), Some("1234"));
+ assert_eq!(app.table.selected_index(), Some(1));
+}
+
+#[test]
+fn invalid_form_focuses_problem_and_never_mutates_database() {
+ let mut db = database();
+ let mut app = App::new(&mut db);
+ key(&mut app, KeyCode::Char('e'));
+ app.form.as_mut().unwrap().inputs[6] = tui_input::Input::from("0");
+ control(&mut app, 's');
+ let form = app.form.as_ref().unwrap();
+ assert_eq!(form.active, 6);
+ assert!(form.error.as_ref().unwrap().contains("Period"));
+ assert!(!app.database.is_modified());
+ app.form.as_mut().unwrap().inputs[6] = tui_input::Input::from("45");
+ app.form.as_mut().unwrap().inputs[5] = tui_input::Input::from("11");
+ control(&mut app, 's');
+ assert_eq!(app.form.as_ref().unwrap().active, 5);
+ assert!(!app.database.is_modified());
+ key(&mut app, KeyCode::Esc);
+ assert_eq!(app.database.elements[0].period, 30);
+ assert!(app.form.is_none());
+}
+
+#[test]
+fn hotp_form_validates_counter_and_applies_it() {
+ let mut db = database();
+ let mut app = App::new(&mut db);
+ app.open_form(true);
+ let form = app.form.as_mut().unwrap();
+ form.active = 3;
+ form.cycle(false);
+ assert!(form.fields().contains(&7));
+ assert!(!form.fields().contains(&6));
+ form.inputs[7] = tui_input::Input::from("-1");
+ control(&mut app, 's');
+ assert_eq!(app.form.as_ref().unwrap().active, 7);
+ app.form.as_mut().unwrap().inputs[7] = tui_input::Input::from("12");
+ control(&mut app, 's');
+ assert_eq!(app.database.elements[0].type_, OTPType::Hotp);
+ assert_eq!(app.database.elements[0].counter, Some(12));
+}
+
+#[test]
+fn pasted_uri_populates_form_and_secret_remains_masked() {
+ let mut db = OTPDatabase::default();
+ let mut app = App::new(&mut db);
+ app.open_form(false);
+ app.form.as_mut().unwrap().active = 2;
+ handle_paste(
+ "otpauth://totp/Example:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&period=45&digits=8",
+ &mut app,
+ );
+ let output = text(&render(&mut app, 100, 30));
+ assert!(!output.contains("GEZDGNBV"));
+ assert!(output.contains("masked"));
+ control(&mut app, 's');
+ assert_eq!(app.database.elements[0].period, 45);
+ assert_eq!(app.database.elements[0].digits, 8);
+ assert_eq!(app.database.elements[0].issuer, "Example");
+}
+
+#[test]
+fn form_rejects_invalid_secret_and_missing_pin_without_saving() {
+ let mut db = database();
+ let mut app = App::new(&mut db);
+ app.open_form(true);
+ app.form.as_mut().unwrap().active = 2;
+ handle_paste("invalid!", &mut app);
+ control(&mut app, 's');
+ assert_eq!(app.form.as_ref().unwrap().active, 2);
+ control(&mut app, 'u');
+ app.form.as_mut().unwrap().inputs[3] = tui_input::Input::from("YANDEX");
+ control(&mut app, 's');
+ assert_eq!(app.form.as_ref().unwrap().active, 8);
+ assert!(!app.database.is_modified());
+ control(&mut app, 'c');
+ assert!(app.running);
+ assert!(app.form.is_none());
+}
+
+#[test]
+fn mouse_actions_select_search_edit_and_cancel_without_clicking_through() {
+ use super::enums::Action;
+ let mut db = database();
+ let mut app = App::new(&mut db);
+ render(&mut app, 120, 30);
+ click(&mut app, Action::Select(1));
+ assert_eq!(app.table.selected_index(), Some(1));
+ click(&mut app, Action::Search);
+ handle_paste("Google", &mut app);
+ render(&mut app, 120, 30);
+ click(&mut app, Action::Command(KeyCode::Char('e')));
+ assert_eq!(app.focus, Focus::Form);
+ render(&mut app, 120, 30);
+ assert!(
+ !app.hitboxes
+ .iter()
+ .any(|(_, action)| matches!(action, Action::Select(_)))
+ );
+ click(&mut app, Action::Field(1));
+ control(&mut app, 'u');
+ handle_paste("modified", &mut app);
+ click(&mut app, Action::Cancel);
+ assert_eq!(app.database.elements[1].label, "alex@example.com");
+ assert!(!app.database.is_modified());
+}
+
+#[test]
+fn mouse_can_add_fill_and_save_an_account() {
+ use super::enums::Action;
+ let mut db = OTPDatabase::default();
+ let mut app = App::new(&mut db);
+ render(&mut app, 120, 34);
+ click(&mut app, Action::Command(KeyCode::Char('a')));
+ render(&mut app, 120, 34);
+ click(&mut app, Action::Field(1));
+ handle_paste("alice", &mut app);
+ click(&mut app, Action::Field(2));
+ handle_paste("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", &mut app);
+ click(&mut app, Action::Save);
+ assert_eq!(app.database.elements.len(), 1);
+ assert_eq!(app.database.elements[0].label, "alice");
+}
+
+#[test]
+fn forms_keep_focused_fields_visible_at_every_supported_size() {
+ let mut db = database();
+ let mut app = App::new(&mut db);
+ app.open_form(true);
+ for (width, height) in [(36, 12), (60, 18), (80, 24), (120, 34)] {
+ for index in app.form.as_ref().unwrap().fields() {
+ app.form.as_mut().unwrap().active = index;
+ let output = text(&render(&mut app, width, height));
+ assert!(
+ output.contains(super::form::LABELS[index]),
+ "focused field {index} missing at {width}x{height}"
+ );
+ }
+ let output = text(&render(&mut app, width, height));
+ assert!(output.contains("Save account"));
+ assert!(output.contains("Cancel"));
+ }
+}
+
+#[test]
+fn editor_preview() {
+ let mut db = database();
+ let mut app = App::new(&mut db);
+ app.now = 12;
+ for (i, item) in app.table.items.iter_mut().enumerate() {
+ item.code = format!("{:06}", 751109 + i * 7319);
+ }
+ app.open_form(true);
+ app.form.as_mut().unwrap().active = 1;
+ let buffer = render(&mut app, 120, 34);
+ if let Ok(path) = std::env::var("COTP_FORM_PREVIEW") {
+ write_preview_svg(&buffer, &path);
+ }
+}
+
+#[test]
+fn clickable_help_exposes_actions_on_narrow_terminals() {
+ use super::enums::Action;
+ let mut db = database();
+ let mut app = App::new(&mut db);
+ key(&mut app, KeyCode::Char('?'));
+ app.popup_scroll = 10;
+ render(&mut app, 36, 18);
+ click(&mut app, Action::Command(KeyCode::Char(' ')));
+ assert_eq!(app.current_page, Page::Qrcode);
+ assert_eq!(app.focus, Focus::MainPage);
+ render(&mut app, 36, 18);
+ click(&mut app, Action::Key(KeyCode::Esc));
+ assert_eq!(app.current_page, Page::Main);
+ assert!(app.running);
+}
+
+#[test]
+fn minimum_terminal_size_still_displays_a_selected_code() {
+ let mut db = database();
+ let mut app = App::new(&mut db);
+ app.table.items[0].code = "123456".into();
+ let output = text(&render(&mut app, 36, 12));
+ assert!(output.contains("GitHub"));
+ assert!(output.contains("123 456"));
+}
+
+#[test]
+fn keyboard_and_mouse_navigation_stop_at_both_boundaries() {
+ let mut db = database();
+ let mut app = App::new(&mut db);
+ for backwards in [true, false] {
+ key(
+ &mut app,
+ if backwards {
+ KeyCode::Home
+ } else {
+ KeyCode::End
+ },
+ );
+ let expected = if backwards {
+ 0
+ } else {
+ app.table.items.len() - 1
+ };
+ for _ in 0..3 {
+ key(
+ &mut app,
+ if backwards {
+ KeyCode::Up
+ } else {
+ KeyCode::Down
+ },
+ );
+ handle_mouse_events(
+ MouseEvent {
+ kind: if backwards {
+ MouseEventKind::ScrollUp
+ } else {
+ MouseEventKind::ScrollDown
+ },
+ column: 10,
+ row: 10,
+ modifiers: KeyModifiers::NONE,
+ },
+ &mut app,
+ );
+ assert_eq!(app.table.state.selected(), Some(expected));
+ }
+ }
+ app.search_query = "Google".into();
+ app.filter();
+ app.table.next();
+ app.table.previous();
+ assert_eq!(app.table.selected_index(), Some(1));
+}
+
+#[test]
+fn countdown_is_shown_once_and_header_does_not_clip_status() {
+ let mut db = database();
+ let mut app = App::new(&mut db);
+ app.now = 12;
+ for width in [36, 80, 120] {
+ let output = text(&render(&mut app, width, 24));
+ assert_eq!(output.matches("18s").count(), 1);
+ assert!(!output.contains("VALID FOR"));
+ assert!(!output.contains("vault open"));
+ assert!(output.contains("3 accounts"));
+ }
+ app.database.mark_modified();
+ let buffer = render(&mut app, 36, 24);
+ let header: String = (0..36).map(|x| buffer[(x, 1)].symbol()).collect();
+ assert!(header.contains("3 accounts ● unsaved"));
+}
+
+/// Scripted recording of the real widgets and handlers with demonstration data only.
+/// Run through ci/render_ui_demo.py; excluded from ordinary verification runs.
+#[test]
+#[ignore = "generates frames for the README animation"]
+fn record_ui_demo() {
+ let directory = std::env::var("COTP_UI_DEMO_DIR").expect("set the frame output directory");
+ let mut db = database();
+ db.elements.extend([
+ account("AWS", "Production · engineering"),
+ account("Figma", "alex@studio.dev"),
+ ]);
+ let mut app = App::new(&mut db);
+ let mut frames = Vec::new();
+ let mut elapsed = 0;
+ let mut record = |app: &mut App, caption: &str, delay: u64| {
+ app.now = 12 + elapsed / 100;
+ for item in &mut app.table.items {
+ item.code = format!(
+ "{:06}",
+ 751109 + item.database_index * 7319 + (app.now / 30) as usize * 1023
+ );
+ }
+ let filename = format!("frame-{:03}.svg", frames.len());
+ let buffer = render(app, 120, 34);
+ write_preview_svg(&buffer, &format!("{directory}/{filename}"));
+ frames.push(serde_json::json!({"file": filename, "caption": caption, "delay": delay}));
+ elapsed += delay;
+ };
+
+ record(&mut app, "Your accounts, at a glance", 200);
+ key(&mut app, KeyCode::Down);
+ record(
+ &mut app,
+ "Arrow keys or mouse wheel to select an account",
+ 100,
+ );
+ key(&mut app, KeyCode::Char('/'));
+ record(&mut app, "Press / to find an account", 50);
+ for c in "google".chars() {
+ key(&mut app, KeyCode::Char(c));
+ record(&mut app, "Filter accounts as you type", 12);
+ }
+ record(&mut app, "Filter accounts as you type", 100);
+ key(&mut app, KeyCode::Esc);
+ key(&mut app, KeyCode::Char('e'));
+ record(&mut app, "Press e to edit the selected account", 100);
+ key(&mut app, KeyCode::Tab);
+ control(&mut app, 'u');
+ handle_paste("work@example.com", &mut app);
+ record(
+ &mut app,
+ "Tab between fields; existing secrets stay masked",
+ 150,
+ );
+ control(&mut app, 's');
+ record(&mut app, "Ctrl+S applies your changes", 150);
+ app.notification = None;
+
+ // Use the same hitboxes as a real mouse click to open the Add form.
+ render(&mut app, 120, 34);
+ click(&mut app, super::enums::Action::Command(KeyCode::Char('a')));
+ record(&mut app, "Click Add, or press a, to create an account", 80);
+ handle_paste("Proton", &mut app);
+ key(&mut app, KeyCode::Tab);
+ record(&mut app, "Enter the service and account name", 60);
+ handle_paste("personal@example.com", &mut app);
+ key(&mut app, KeyCode::Tab);
+ record(&mut app, "Enter the service and account name", 60);
+ handle_paste("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", &mut app);
+ record(&mut app, "Paste a secret key or an otpauth:// URI", 100);
+ control(&mut app, 's');
+ record(&mut app, "Your new account is ready", 180);
+ app.notification = None;
+ key(&mut app, KeyCode::Char('h'));
+ record(&mut app, "Press h to hide codes", 100);
+ key(&mut app, KeyCode::Char('h'));
+ record(&mut app, "Press h again to reveal them", 70);
+ key(&mut app, KeyCode::Char('?'));
+ record(&mut app, "Press ? for shortcuts and clickable actions", 140);
+ key(&mut app, KeyCode::Esc);
+ key(&mut app, KeyCode::Char('q'));
+ record(
+ &mut app,
+ "Confirm on exit to save changes to the encrypted vault",
+ 200,
+ );
+ key(&mut app, KeyCode::Esc);
+ key(&mut app, KeyCode::Home);
+ record(&mut app, "cotp · built for your terminal", 180);
+ std::fs::write(
+ format!("{directory}/frames.json"),
+ serde_json::to_string_pretty(&frames).unwrap(),
+ )
+ .unwrap();
+}
diff --git a/src/interface/theme.rs b/src/interface/theme.rs
new file mode 100644
index 00000000..2e0e72a6
--- /dev/null
+++ b/src/interface/theme.rs
@@ -0,0 +1,27 @@
+//! Shared palette and component styling for the terminal dashboard.
+use ratatui::{
+ style::{Color, Style},
+ text::Line,
+ widgets::{Block, BorderType},
+};
+
+pub const BACKGROUND: Color = Color::Rgb(13, 19, 29);
+pub const SURFACE: Color = Color::Rgb(20, 29, 42);
+pub const SELECTED: Color = Color::Rgb(28, 57, 67);
+pub const BORDER: Color = Color::Rgb(48, 65, 83);
+pub const TEXT: Color = Color::Rgb(224, 233, 242);
+pub const MUTED: Color = Color::Rgb(144, 162, 181);
+pub const ACCENT: Color = Color::Rgb(94, 234, 212);
+pub const WARNING: Color = Color::Rgb(251, 191, 91);
+pub const DANGER: Color = Color::Rgb(251, 113, 133);
+
+pub fn panel(title: impl Into) -> Block<'static> {
+ Block::bordered()
+ .border_type(BorderType::Rounded)
+ .border_style(Style::default().fg(BORDER))
+ .style(Style::default().fg(TEXT).bg(SURFACE))
+ .title(Line::styled(
+ format!(" {} ", title.into()),
+ Style::default().fg(MUTED),
+ ))
+}
diff --git a/src/interface/ui.rs b/src/interface/ui.rs
index e822bad3..943d3ff5 100644
--- a/src/interface/ui.rs
+++ b/src/interface/ui.rs
@@ -1,6 +1,8 @@
use std::io;
-use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
+use crossterm::event::{
+ DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
+};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use ratatui::Terminal;
use ratatui::prelude::Backend;
@@ -18,12 +20,17 @@ pub struct Tui {
terminal: Terminal,
/// Terminal event handler.
pub events: EventHandler,
+ active: bool,
}
impl Tui {
/// Constructs a new instance of [`Tui`].
pub fn new(terminal: Terminal, events: EventHandler) -> Self {
- Self { terminal, events }
+ Self {
+ terminal,
+ events,
+ active: false,
+ }
}
/// Initializes the terminal interface.
@@ -34,7 +41,18 @@ impl Tui {
::Error: 'static,
{
terminal::enable_raw_mode()?;
- crossterm::execute!(io::stderr(), EnterAlternateScreen, EnableMouseCapture)?;
+ self.active = true;
+ let previous_hook = std::panic::take_hook();
+ std::panic::set_hook(Box::new(move |info| {
+ restore_terminal();
+ previous_hook(info);
+ }));
+ crossterm::execute!(
+ io::stderr(),
+ EnterAlternateScreen,
+ EnableMouseCapture,
+ EnableBracketedPaste
+ )?;
self.terminal.hide_cursor()?;
self.terminal.clear()?;
Ok(())
@@ -60,8 +78,33 @@ impl Tui {
::Error: 'static,
{
terminal::disable_raw_mode()?;
- crossterm::execute!(io::stderr(), LeaveAlternateScreen, DisableMouseCapture)?;
+ crossterm::execute!(
+ io::stderr(),
+ LeaveAlternateScreen,
+ DisableMouseCapture,
+ DisableBracketedPaste
+ )?;
self.terminal.show_cursor()?;
+ self.active = false;
Ok(())
}
}
+
+fn restore_terminal() {
+ let _ = terminal::disable_raw_mode();
+ let _ = crossterm::execute!(
+ io::stderr(),
+ LeaveAlternateScreen,
+ DisableMouseCapture,
+ DisableBracketedPaste,
+ crossterm::cursor::Show
+ );
+}
+
+impl Drop for Tui {
+ fn drop(&mut self) {
+ if self.active {
+ restore_terminal();
+ }
+ }
+}
diff --git a/src/interface/view.rs b/src/interface/view.rs
new file mode 100644
index 00000000..0ebee393
--- /dev/null
+++ b/src/interface/view.rs
@@ -0,0 +1,636 @@
+use crossterm::event::KeyCode;
+use ratatui::{
+ Frame,
+ layout::{Alignment, Constraint, Layout, Margin, Rect},
+ style::{Color, Modifier, Style},
+ text::{Line, Span, Text},
+ widgets::{
+ Cell, Clear, HighlightSpacing, LineGauge, Paragraph, Row, Scrollbar, ScrollbarOrientation,
+ ScrollbarState, Table, Wrap,
+ },
+};
+
+use super::{
+ app::App,
+ enums::{Action, Focus, Page, PopupAction},
+ popup::centered_rect,
+ row::{display_code, remaining},
+ theme::*,
+};
+
+impl App<'_> {
+ pub fn render(&mut self, frame: &mut Frame<'_>) {
+ self.hitboxes.clear();
+ frame.render_widget(
+ ratatui::widgets::Block::default().style(Style::default().bg(BACKGROUND).fg(TEXT)),
+ frame.area(),
+ );
+ if frame.area().width < 36 || frame.area().height < 12 {
+ frame.render_widget(
+ Paragraph::new("cotp\n\nEnlarge the terminal to 36 × 12.\nq quit")
+ .style(Style::default().fg(ACCENT))
+ .wrap(Wrap { trim: true }),
+ frame.area(),
+ );
+ return;
+ }
+ let area = frame.area().inner(Margin::new(1, 1));
+ let [header, search, content, status, shortcuts] = Layout::vertical([
+ Constraint::Length(2),
+ Constraint::Length(3),
+ Constraint::Fill(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ ])
+ .areas(area);
+ self.render_header(frame, header);
+ self.render_search(frame, search);
+ if self.current_page == Page::Qrcode {
+ self.render_qrcode(frame, content);
+ } else if content.width >= 100 {
+ let [accounts, detail] =
+ Layout::horizontal([Constraint::Fill(1), Constraint::Length(34)])
+ .spacing(1)
+ .areas(content);
+ self.render_accounts(frame, accounts);
+ self.render_detail(frame, detail);
+ } else {
+ self.render_accounts(frame, content);
+ }
+ self.render_status(frame, status);
+ let hints = if self.current_page == Page::Qrcode && area.width < 65 {
+ vec![("↵", "URI"), ("Esc", "back"), ("q", "quit")]
+ } else if self.current_page == Page::Qrcode {
+ vec![
+ ("Enter", "copy URI"),
+ ("Esc / Space", "back"),
+ ("q", "quit"),
+ ]
+ } else if self.focus == Focus::SearchBar {
+ vec![
+ ("↑↓", "select"),
+ ("Enter", "copy"),
+ ("Esc", "done"),
+ ("Ctrl+U", "clear"),
+ ]
+ } else if area.width < 65 {
+ vec![
+ ("↑", ""),
+ ("↓", ""),
+ ("↵", "copy"),
+ ("/", "find"),
+ ("?", ""),
+ ]
+ } else {
+ vec![
+ ("↑↓", "move"),
+ ("Enter", "copy"),
+ ("/", "search"),
+ ("Space", "QR"),
+ ("h", "hide"),
+ ("?", "help"),
+ ("q", "quit"),
+ ]
+ };
+ self.render_actions(frame, shortcuts, &hints, false);
+ if self.focus == Focus::Popup {
+ self.render_popup(frame);
+ } else if self.focus == Focus::Form {
+ self.render_form(frame);
+ }
+ }
+
+ pub(crate) fn render_actions(
+ &mut self,
+ frame: &mut Frame<'_>,
+ area: Rect,
+ hints: &[(&str, &str)],
+ commands: bool,
+ ) {
+ let mut x = area.x;
+ let mut y = area.y;
+ for (key, label) in hints {
+ let line = shortcut_line(&[(*key, *label)]);
+ let width = line.width() as u16;
+ if x + width > area.right() {
+ x = area.x;
+ y += 1;
+ }
+ if y >= area.bottom() {
+ break;
+ }
+ let rect = Rect::new(x, y, width.min(area.width), 1);
+ frame.render_widget(Paragraph::new(line), rect);
+ let action = match *key {
+ "Ctrl+U" => Some(Action::ClearSearch),
+ "↑" => Some(Action::Key(KeyCode::Up)),
+ "↓" | "↑↓" => Some(Action::Key(KeyCode::Down)),
+ "Enter" | "↵" => Some(Action::Key(KeyCode::Enter)),
+ "Esc" | "Esc / Enter" | "n / Esc" | "Esc / Space" => {
+ Some(Action::Key(KeyCode::Esc))
+ }
+ "Space" => Some(Action::Key(KeyCode::Char(' '))),
+ key if key.len() == 1 => key.chars().next().map(|c| {
+ if commands {
+ Action::Command(KeyCode::Char(c))
+ } else {
+ Action::Key(KeyCode::Char(c))
+ }
+ }),
+ _ => None,
+ };
+ if let Some(action) = action {
+ self.hitboxes.push((rect, action));
+ }
+ x += width;
+ }
+ }
+
+ fn render_header(&mut self, frame: &mut Frame<'_>, area: Rect) {
+ let count = self.database.elements_ref().len();
+ let mut summary_text = vec![Span::styled(
+ format!(
+ "{count} {}",
+ if count == 1 { "account" } else { "accounts" }
+ ),
+ Style::default().fg(MUTED),
+ )];
+ if self.database.is_modified() {
+ summary_text.push(Span::styled(" ● unsaved", Style::default().fg(WARNING)));
+ }
+ let summary_text = Line::from(summary_text);
+ let summary_width = summary_text
+ .width()
+ .min(area.width.saturating_sub(9) as usize) as u16;
+ let [brand, summary] =
+ Layout::horizontal([Constraint::Fill(1), Constraint::Length(summary_width)])
+ .areas(area);
+ frame.render_widget(
+ Paragraph::new(Span::styled(
+ " cotp ",
+ Style::default().fg(BACKGROUND).bg(ACCENT).bold(),
+ )),
+ brand,
+ );
+ frame.render_widget(
+ Paragraph::new(summary_text).alignment(Alignment::Right),
+ summary,
+ );
+ if area.height > 1 {
+ let toolbar = Rect::new(area.x, area.y + 1, area.width, 1);
+ self.render_actions(
+ frame,
+ toolbar,
+ &[("a", "Add"), ("e", "Edit"), ("d", "Delete")],
+ true,
+ );
+ }
+ }
+
+ fn render_search(&mut self, frame: &mut Frame<'_>, area: Rect) {
+ self.hitboxes.push((area, Action::Search));
+ let active = self.focus == Focus::SearchBar;
+ let block = panel(if active {
+ "Search · Esc to finish"
+ } else {
+ "Search · / or Ctrl+F"
+ })
+ .border_style(Style::default().fg(if active { ACCENT } else { BORDER }));
+ let inner = block.inner(area);
+ // Display the end of long queries; width() accounts for wide Unicode characters.
+ let mut query = self.search_query.as_str();
+ let available = inner.width.saturating_sub(2) as usize;
+ while Line::from(query).width() > available {
+ query = &query[query.chars().next().map_or(0, char::len_utf8)..];
+ }
+ let text = if self.search_query.is_empty() && !active {
+ Span::styled(" Find an issuer or account…", Style::default().fg(MUTED))
+ } else {
+ Span::styled(format!(" {query}"), Style::default().fg(TEXT))
+ };
+ frame.render_widget(Paragraph::new(text).block(block), area);
+ if active && inner.width > 1 {
+ frame.set_cursor_position((inner.x + 1 + Line::from(query).width() as u16, inner.y));
+ }
+ }
+
+ fn render_accounts(&mut self, frame: &mut Frame<'_>, area: Rect) {
+ let count = self.table.items.len();
+ let title = if self.search_query.is_empty() {
+ "Accounts".to_string()
+ } else {
+ format!("Accounts · {count} matches")
+ };
+ let position = self.table.state.selected().map_or(0, |i| i + 1);
+ let block = panel(title)
+ .title_bottom(Line::from(format!(" {position} / {count} ")).right_aligned());
+ if count == 0 {
+ let text = if self.database.elements_ref().is_empty() {
+ vec![
+ Line::from(""),
+ Line::from("Your vault is ready").style(Style::default().fg(ACCENT).bold()),
+ Line::from(""),
+ Line::from("Press a or click Add to create your first account."),
+ Line::from("Paste a secret key or an otpauth:// URI into the form."),
+ Line::from(""),
+ Line::from("Already use an authenticator? Try cotp import --help."),
+ ]
+ } else {
+ vec![
+ Line::from(""),
+ Line::from("No matching accounts").style(Style::default().fg(ACCENT).bold()),
+ Line::from(""),
+ Line::from("Try another issuer or account name."),
+ Line::from("Esc, then Esc again to clear the filter."),
+ ]
+ };
+ frame.render_widget(
+ Paragraph::new(text)
+ .block(block)
+ .alignment(Alignment::Center)
+ .wrap(Wrap { trim: true }),
+ area,
+ );
+ return;
+ }
+ let compact = area.height < 8;
+ let row_height = if compact { 1 } else { 2 };
+ let row_margin = if compact { 0 } else { 1 };
+ let header_height = if compact { 0 } else { 2 };
+ let rows = self.table.items.iter().enumerate().map(|(index, item)| {
+ let element = &self.database.elements_ref()[item.database_index];
+ let issuer = if item.issuer.is_empty() {
+ "Personal account"
+ } else {
+ &item.issuer
+ };
+ let account = Text::from(vec![
+ Line::from(Span::styled(issuer, Style::default().fg(TEXT).bold())),
+ Line::from(Span::styled(
+ item.label.as_str(),
+ Style::default().fg(MUTED),
+ )),
+ ]);
+ let code = if item.has_error {
+ "Unavailable".into()
+ } else {
+ display_code(&item.code, self.hide_codes)
+ };
+ let timing = remaining(element, self.now);
+ let code_color = if item.has_error {
+ DANGER
+ } else if timing.is_some_and(|(left, _)| left <= 5) {
+ WARNING
+ } else {
+ ACCENT
+ };
+ let cells = vec![
+ Cell::from(account),
+ Cell::from(code).style(Style::default().fg(code_color).bold()),
+ ];
+ Row::new(cells)
+ .height(row_height)
+ .bottom_margin(row_margin)
+ .style(Style::default().bg(if index % 2 == 0 { SURFACE } else { BACKGROUND }))
+ });
+ let widths = [Constraint::Fill(1), Constraint::Length(13)];
+ let headers = ["ACCOUNT", "CODE"];
+ let mut table = Table::new(rows, widths)
+ .block(block)
+ .column_spacing(2)
+ .row_highlight_style(Style::default().bg(SELECTED))
+ .highlight_symbol(Span::styled("▎ ", Style::default().fg(ACCENT)))
+ .highlight_spacing(HighlightSpacing::Always);
+ if !compact {
+ table = table.header(
+ Row::new(headers)
+ .style(Style::default().fg(MUTED))
+ .bottom_margin(1),
+ );
+ }
+ frame.render_stateful_widget(table, area, &mut self.table.state);
+ let viewport = area
+ .height
+ .saturating_sub(2 + header_height)
+ .saturating_add(row_margin) as usize
+ / (row_height + row_margin) as usize;
+ for (visible, index) in (self.table.state.offset()..count)
+ .take(viewport)
+ .enumerate()
+ {
+ self.hitboxes.push((
+ Rect::new(
+ area.x + 1,
+ area.y + 1 + header_height + visible as u16 * (row_height + row_margin),
+ area.width.saturating_sub(2),
+ row_height,
+ ),
+ Action::Select(index),
+ ));
+ }
+ if count > viewport {
+ let mut state = ScrollbarState::new(count.saturating_sub(viewport))
+ .position(self.table.state.offset());
+ frame.render_stateful_widget(
+ Scrollbar::new(ScrollbarOrientation::VerticalRight)
+ .begin_symbol(None)
+ .end_symbol(None)
+ .track_symbol(Some("│"))
+ .thumb_style(Style::default().fg(ACCENT))
+ .track_style(Style::default().fg(BORDER)),
+ area.inner(Margin::new(0, 1)),
+ &mut state,
+ );
+ }
+ }
+
+ fn render_detail(&self, frame: &mut Frame<'_>, area: Rect) {
+ let block = panel("Selected account");
+ let inner = block.inner(area).inner(Margin::new(1, 0));
+ frame.render_widget(block, area);
+ let Some(item) = self
+ .table
+ .state
+ .selected()
+ .and_then(|index| self.table.items.get(index))
+ else {
+ frame.render_widget(
+ Paragraph::new("Select an account to see its details.")
+ .wrap(Wrap { trim: true })
+ .style(Style::default().fg(MUTED)),
+ inner,
+ );
+ return;
+ };
+ let element = &self.database.elements_ref()[item.database_index];
+ let [identity, code_area, gauge_area, metadata] = Layout::vertical([
+ Constraint::Length(3),
+ Constraint::Length(4),
+ Constraint::Length(2),
+ Constraint::Fill(1),
+ ])
+ .areas(inner);
+ frame.render_widget(
+ Paragraph::new(vec![
+ Line::from(if item.issuer.is_empty() {
+ "Personal account"
+ } else {
+ &item.issuer
+ })
+ .style(Style::default().fg(TEXT).bold()),
+ Line::from(item.label.as_str()).style(Style::default().fg(MUTED)),
+ ]),
+ identity,
+ );
+ if item.has_error {
+ frame.render_widget(
+ Paragraph::new(item.code.as_str())
+ .style(Style::default().fg(DANGER))
+ .wrap(Wrap { trim: true }),
+ code_area,
+ );
+ } else {
+ frame.render_widget(
+ Paragraph::new(large_code(&item.code, self.hide_codes, code_area.width))
+ .style(Style::default().fg(ACCENT).bold())
+ .alignment(Alignment::Center),
+ code_area,
+ );
+ }
+ if let Some((left, period)) = remaining(element, self.now) {
+ frame.render_widget(
+ LineGauge::default()
+ .ratio(left as f64 / period as f64)
+ .filled_symbol("━")
+ .unfilled_symbol("─")
+ .filled_style(Style::default().fg(if left <= 5 { WARNING } else { ACCENT }))
+ .unfilled_style(Style::default().fg(BORDER))
+ .label(Span::styled(
+ format!("{left}s "),
+ Style::default().fg(MUTED),
+ )),
+ gauge_area,
+ );
+ } else {
+ frame.render_widget(
+ Paragraph::new("Counter based · + / − to change").style(Style::default().fg(MUTED)),
+ gauge_area,
+ );
+ }
+ let mut lines = vec![
+ detail_line("Type", element.type_.to_string()),
+ detail_line("Algorithm", element.algorithm.to_string()),
+ detail_line("Digits", element.digits.to_string()),
+ ];
+ if let Some((_, period)) = remaining(element, self.now) {
+ lines.push(detail_line("Period", format!("{period} seconds")));
+ }
+ if let Some(counter) = element.counter {
+ lines.push(detail_line("Counter", counter.to_string()));
+ }
+ lines.extend([
+ Line::from(""),
+ shortcut_line(&[("Enter", "copy code")]),
+ shortcut_line(&[("Space", "show QR code")]),
+ ]);
+ frame.render_widget(Paragraph::new(lines), metadata);
+ }
+
+ fn render_status(&self, frame: &mut Frame<'_>, area: Rect) {
+ let (text, color) = if let Some((text, error, _)) = &self.notification {
+ (text.clone(), if *error { DANGER } else { ACCENT })
+ } else if self.hide_codes {
+ ("Codes hidden · press h to reveal".into(), WARNING)
+ } else if self.current_page == Page::Qrcode {
+ ("This QR code contains the account secret.".into(), WARNING)
+ } else if self.database.is_modified() {
+ (
+ "Unsaved changes · you will be asked to save when you quit".into(),
+ WARNING,
+ )
+ } else if area.width < 100
+ && let Some((left, _)) = self
+ .table
+ .selected_index()
+ .and_then(|index| self.database.get_element(index))
+ .and_then(|element| remaining(element, self.now))
+ {
+ (
+ format!("Selected code · {left}s remaining"),
+ if left <= 5 { WARNING } else { MUTED },
+ )
+ } else {
+ ("Ready · codes refresh automatically".into(), MUTED)
+ };
+ frame.render_widget(Paragraph::new(text).style(Style::default().fg(color)), area);
+ }
+
+ fn render_qrcode(&self, frame: &mut Frame<'_>, area: Rect) {
+ let block = panel("Transfer account · QR code");
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+ let Some(element) = self
+ .table
+ .selected_index()
+ .and_then(|i| self.database.get_element(i))
+ else {
+ return;
+ };
+ let qr = element.get_qrcode();
+ let width = qr
+ .lines()
+ .map(|line| Line::from(line).width())
+ .max()
+ .unwrap_or(0) as u16;
+ let height = qr.lines().count() as u16;
+ if inner.width < width || inner.height < height {
+ frame.render_widget(Paragraph::new(format!("Enlarge the terminal to display the QR code ({width} × {height} cells).\n\nEnter copies the OTP URI.\nEsc returns to accounts."))
+ .alignment(Alignment::Center).wrap(Wrap { trim: true }).style(Style::default().fg(MUTED)), inner);
+ } else {
+ frame.render_widget(
+ Paragraph::new(qr).style(Style::default().fg(Color::White).bg(Color::Black)),
+ centered_rect(width, height, inner),
+ );
+ }
+ }
+
+ fn render_help_body(&mut self, frame: &mut Frame<'_>, area: Rect) {
+ let actions = [
+ ("↑ / k", "Previous account", KeyCode::Up),
+ ("↓ / j", "Next account", KeyCode::Down),
+ ("Home", "First account", KeyCode::Home),
+ ("End", "Last account", KeyCode::End),
+ ("PgUp / PgDn", "Move ten accounts", KeyCode::PageDown),
+ ("a", "Add account", KeyCode::Char('a')),
+ ("e", "Edit account", KeyCode::Char('e')),
+ ("Enter", "Copy code / URI", KeyCode::Enter),
+ ("/ or Ctrl+F", "Filter accounts", KeyCode::Char('/')),
+ ("h", "Hide / reveal codes", KeyCode::Char('h')),
+ ("Space", "Show / close QR", KeyCode::Char(' ')),
+ ("+", "Increase HOTP counter", KeyCode::Char('+')),
+ ("−", "Decrease HOTP counter", KeyCode::Char('-')),
+ ("d / Delete", "Delete account", KeyCode::Char('d')),
+ ("q / Ctrl+D", "Quit", KeyCode::Char('q')),
+ ];
+ for (row, (key, label, code)) in actions
+ .iter()
+ .skip(self.popup_scroll as usize)
+ .take(area.height as usize)
+ .enumerate()
+ {
+ let rect = Rect::new(area.x, area.y + row as u16, area.width, 1);
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![
+ Span::styled(format!("{key:<12}"), Style::default().fg(ACCENT)),
+ Span::styled(*label, Style::default().fg(TEXT)),
+ ])),
+ rect,
+ );
+ self.hitboxes.push((rect, Action::Command(*code)));
+ }
+ }
+
+ fn render_popup(&mut self, frame: &mut Frame<'_>) {
+ self.hitboxes.clear();
+ let (title, color, height, hints) = match self.popup.action {
+ PopupAction::GeneralInfo => (
+ "Shortcuts · click an action",
+ ACCENT,
+ 25,
+ vec![("↑↓", "scroll"), ("Esc / Enter", "close")],
+ ),
+ PopupAction::DeleteOtp => (
+ "Delete account?",
+ DANGER,
+ 11,
+ vec![("y", "delete"), ("n / Esc", "cancel")],
+ ),
+ PopupAction::SaveBeforeQuit => (
+ "Save your changes?",
+ WARNING,
+ 10,
+ vec![("y", "save & quit"), ("n", "discard"), ("Esc", "cancel")],
+ ),
+ };
+ // Dim the dashboard while retaining its spatial context.
+ let screen = frame.area();
+ frame.buffer_mut().set_style(
+ screen,
+ Style::default().fg(MUTED).add_modifier(Modifier::DIM),
+ );
+ let area = centered_rect(62, height, frame.area());
+ frame.render_widget(Clear, area);
+ let block = panel(title).border_style(Style::default().fg(color));
+ let inner = block.inner(area).inner(Margin::new(2, 1));
+ frame.render_widget(block, area);
+ let [body, footer] =
+ Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]).areas(inner);
+ if self.popup.action == PopupAction::GeneralInfo {
+ self.render_help_body(frame, body);
+ } else {
+ frame.render_widget(
+ Paragraph::new(self.popup.text.as_str()).wrap(Wrap { trim: false }),
+ body,
+ );
+ }
+ self.render_actions(frame, footer, &hints, false);
+ }
+}
+
+fn detail_line(label: &str, value: String) -> Line<'static> {
+ Line::from(vec![
+ Span::styled(format!("{label:<12}"), Style::default().fg(MUTED)),
+ Span::styled(value, Style::default().fg(TEXT)),
+ ])
+}
+
+fn shortcut_line(items: &[(&str, &str)]) -> Line<'static> {
+ let mut spans = Vec::new();
+ for (key, action) in items {
+ spans.push(Span::styled(
+ format!(" {key} "),
+ Style::default().fg(ACCENT).bold(),
+ ));
+ spans.push(Span::styled(
+ format!("{action} "),
+ Style::default().fg(MUTED),
+ ));
+ }
+ Line::from(spans)
+}
+
+fn large_code(code: &str, hidden: bool, width: u16) -> Text<'static> {
+ if hidden
+ || !code.bytes().all(|byte| byte.is_ascii_digit())
+ || code.len() * 4 + 1 > width as usize
+ {
+ return Text::from(format!("\n{}", display_code(code, hidden)));
+ }
+ const DIGITS: [[&str; 3]; 10] = [
+ ["█▀█", "█ █", "▀▀▀"],
+ ["▀█ ", " █ ", "▀▀▀"],
+ ["▀▀█", "█▀▀", "▀▀▀"],
+ ["▀▀█", " ▀█", "▀▀▀"],
+ ["█ █", "▀▀█", " ▀"],
+ ["█▀▀", "▀▀█", "▀▀▀"],
+ ["█▀▀", "█▀█", "▀▀▀"],
+ ["▀▀█", " █", " ▀"],
+ ["█▀█", "█▀█", "▀▀▀"],
+ ["█▀█", "▀▀█", "▀▀▀"],
+ ];
+ Text::from(
+ (0..3)
+ .map(|row| {
+ let mut line = String::new();
+ for (i, byte) in code.bytes().enumerate() {
+ if i == code.len() / 2 {
+ line.push(' ');
+ }
+ line.push_str(DIGITS[(byte - b'0') as usize][row]);
+ line.push(' ');
+ }
+ Line::from(line)
+ })
+ .collect::>(),
+ )
+}
diff --git a/src/main.rs b/src/main.rs
index f8fdef8b..716e22cd 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4,14 +4,17 @@ use clap::Parser;
use color_eyre::eyre::eyre;
use interface::app::AppResult;
use interface::event::{Event, EventHandler};
-use interface::handlers::handle_key_events;
+use interface::handlers::{handle_key_events, handle_mouse_events, handle_paste};
use interface::ui::Tui;
use otp::otp_element::{CURRENT_DATABASE_VERSION, OTPDatabase};
use path::init_path;
use ratatui::Terminal;
use ratatui::prelude::CrosstermBackend;
use reading::{ReadResult, get_elements_from_input, get_elements_from_stdin};
-use std::{io, vec};
+use std::{
+ io::{self, IsTerminal},
+ vec,
+};
use zeroize::Zeroize;
mod arguments;
@@ -92,38 +95,34 @@ fn main() -> AppResult<()> {
}
fn dashboard(mut database: OTPDatabase) -> AppResult {
- if database.elements_ref().is_empty() {
- println!("No codes, type \"cotp -h\" to get help");
- } else {
- // Create an application.
- let mut app = interface::app::App::new(&mut database);
-
- // Initialize the terminal user interface.
- let backend = CrosstermBackend::new(io::stderr());
- let terminal = Terminal::new(backend)?;
- let events = EventHandler::new(250);
- let mut tui = Tui::new(terminal, events);
- tui.init()?;
-
- // Start the main loop.
- while app.running {
- // Render the user interface.
- tui.draw(&mut app)?;
- // Handle events.
- match tui.events.next()? {
- Event::Tick => app.tick(false),
- Event::Key(key_event) => handle_key_events(key_event, &mut app),
- Event::Mouse(())
- | Event::Resize((), ())
- | Event::FocusGained()
- | Event::FocusLost()
- | Event::Paste(()) => {}
+ if !io::stderr().is_terminal() || !io::stdin().is_terminal() {
+ if database.elements_ref().is_empty() {
+ println!("No codes, type \"cotp -h\" to get help");
+ return Ok(database);
+ }
+ return Err(
+ "The dashboard needs an interactive terminal. Use cotp list or cotp list --json."
+ .into(),
+ );
+ }
+ let mut app = interface::app::App::new(&mut database);
+ let backend = CrosstermBackend::new(io::stderr());
+ let terminal = Terminal::new(backend)?;
+ let mut tui = Tui::new(terminal, EventHandler::new(250));
+ tui.init()?;
+ while app.running {
+ tui.draw(&mut app)?;
+ match tui.events.next()? {
+ Event::Tick => app.tick(false),
+ Event::Key(key) => handle_key_events(key, &mut app),
+ Event::Mouse(mouse) => handle_mouse_events(mouse, &mut app),
+ Event::Paste(mut text) => {
+ handle_paste(&text, &mut app);
+ text.zeroize();
}
+ Event::Resize => {}
}
-
- // Exit the user interface.
- tui.exit()?;
}
-
+ tui.exit()?;
Ok(database)
}
diff --git a/src/otp/algorithms/yandex_otp_maker.rs b/src/otp/algorithms/yandex_otp_maker.rs
index b5bad7f7..a60ecad3 100644
--- a/src/otp/algorithms/yandex_otp_maker.rs
+++ b/src/otp/algorithms/yandex_otp_maker.rs
@@ -84,7 +84,11 @@ where
period_hash[offset] &= 0x7f;
// calculate code
- let code_bytes: [u8; 8] = match period_hash[offset..offset + 8].try_into() {
+ let code_bytes: [u8; 8] = match period_hash
+ .get(offset..offset + 8)
+ .ok_or(OtpError::InvalidDigest)?
+ .try_into()
+ {
Ok(x) => x,
Err(_) => return Err(OtpError::InvalidDigest),
};
@@ -114,6 +118,20 @@ mod tests {
use super::calculate_yandex_code;
+ #[test]
+ fn short_digest_returns_an_error_instead_of_panicking() {
+ // SHA1 may not have eight bytes left at the dynamic offset.
+ for seconds in (0..3000).step_by(30) {
+ let _ = calculate_yandex_code::(
+ "6SB2IKNM6OBZPAVBVTOHDKS4FAAAAAAADFUTQMBTRY",
+ "5239",
+ 30,
+ 8,
+ seconds,
+ );
+ }
+ }
+
#[test]
fn test_yandex() {
let seconds: u64 = 1641559648;
diff --git a/src/utils.rs b/src/utils.rs
index fb328dc6..3cca40ce 100644
--- a/src/utils.rs
+++ b/src/utils.rs
@@ -1,5 +1,3 @@
-use std::time::{SystemTime, UNIX_EPOCH};
-
use crate::path::DATABASE_PATH;
pub fn init_app() -> Result {
@@ -14,17 +12,6 @@ pub fn init_app() -> Result {
Ok(!db_path.exists())
}
-pub fn millis_before_next_step() -> u64 {
- let now = SystemTime::now();
- let since_the_epoch = now.duration_since(UNIX_EPOCH).unwrap();
- let in_ms = since_the_epoch.as_secs() * 1000 + since_the_epoch.subsec_nanos() as u64 / 1000000;
- in_ms % 30000
-}
-
-pub fn percentage() -> u16 {
- (millis_before_next_step() * 100 / 30000) as u16
-}
-
pub fn password(message: &str, minimum_length: usize) -> String {
loop {
let password = rpassword::prompt_password(message).unwrap();