diff --git a/Cargo.lock b/Cargo.lock index f2e599d..77e4a0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2917,7 +2917,7 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "writui" -version = "0.16.0" +version = "0.17.0" dependencies = [ "anyhow", "arboard", diff --git a/Cargo.toml b/Cargo.toml index 9a4a449..5e05428 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "writui" -version = "0.16.0" +version = "0.17.0" edition = "2024" description = "A terminal writing app. No nonsense. Markdown. Local. Encrypted." license = "MIT" diff --git a/README.md b/README.md index 9759b7f..1168473 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ the password each time it starts. - **Posts** are listed with the most recently changed first. Open one with Enter or a click, start a new one with `n` (or Enter: "New" is highlighted when the list opens). The list's shortcuts are single keys, - shown next to "New" and "Import" and at the bottom. Esc asks before quitting. writui opens + shown next to "New" and "Import" and at the bottom. Esc quits. writui opens where you left it: on the post you were writing, cursor and all. - **Search:** press `/` (or click "Type to filter (/)") and type. Posts with every word you type, anywhere in their text or front matter, are listed, diff --git a/docs/PLAN.md b/docs/PLAN.md index 988d15e..0376bcf 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -21,7 +21,9 @@ The creator's batch of list / editor ideas shipped in `0.12.0`, and the `writui info` and Select All in `0.14.0`, Tab cycling the sort and Ctrl/Cmd+arrow jumps in `0.15.0`, and in `0.16.0` the logo as a big banner on the password screens, boxed password fields, Esc to clear or quit, and -the whole repo run through `cargo fmt` (checked in CI). Next: +the whole repo run through `cargo fmt` (checked in CI). `0.17.0` narrows +the logo, adds an Unlock button, trims the setup screen, and makes Esc on +the list quit without asking. Next: 1. **Try online** (writui.com/try), below: the creator wants it next, and it must work on a phone. Probably two or three PRs, in the order listed. diff --git a/docs/SPEC.md b/docs/SPEC.md index 9f7800a..84a2aed 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -170,6 +170,12 @@ in [DECISIONS.md](DECISIONS.md); the build order lives in [PLAN.md](PLAN.md). column. Windows narrower than that, or too short to fit it, get the plain "writui" title instead. + > can you try making "writui" less wide? also, i think some space is + > needed between the logo and the password box. think of the aesthetics + + The letters are narrow (the name is 42 columns, not 54), and a blank line + under the logo leaves two rows before what follows. + > put a box around the password > and esc to clear password, and if clear esc to quit @@ -178,6 +184,12 @@ in [DECISIONS.md](DECISIONS.md); the build order lives in [PLAN.md](PLAN.md). vault" puts its two side by side, so the banner still fits 30 rows). Esc clears what's typed; with nothing typed, Esc quits. + > and to the right of the password box add a clickable "Unlock" button + +- The unlock screen has an "Unlock" button right of the password box, as + tall as it, dimmed until something's typed. A window too narrow for it + leaves it out (Enter still unlocks). + > and finally, on the password screen, let's add an "Overview" describing > how the app works in no more than 5-6 points, each no longer than one > line. this is for new users and returning ones, run the copy by me once @@ -186,10 +198,15 @@ in [DECISIONS.md](DECISIONS.md); the build order lives in [PLAN.md](PLAN.md). "Overview" (creator-approved copy): - All your writing lives in one file, encrypted with this password. - Forget the password and the writing is gone: there's no recovery. - - Write in markdown. Every post starts with a # title. + - Write your posts in markdown. Every post starts with a # title. - Posts save as you type; Ctrl+S also keeps a version to go back to. - While writing, Ctrl+K shows every command and its shortcut. - Ctrl+O locks writui; unlocking takes you back where you were. + + > you know, with the overview in palce, we dont need the initla para + + So "Create your vault" no longer has its own paragraph about the + password being unrecoverable: the Overview's first two points say it. - Change password — planned. ## The screens @@ -245,9 +262,14 @@ in [DECISIONS.md](DECISIONS.md); the build order lives in [PLAN.md](PLAN.md). > can you add j/k support for down and up on the list page (while i'm > not typing in the filter box)? - > no need to mention them in the bottom menu Ctrl+N and Ctrl+D still work but aren't shown. Esc asks "Quit - writui?" (Quit is the selected button, `y`); Ctrl+Q and Ctrl+C still quit - straight away, unshown. + > no need to mention them in the bottom menu Ctrl+N and Ctrl+D still work but aren't shown. Esc quits; Ctrl+Q + and Ctrl+C also quit, unshown. + + > also remove the quit confirmation on th elist screen on pressing esc, i + > think it's not necessary + + (Esc used to ask "Quit writui?" first. Everything is saved as you type, + so there's nothing to lose.) > add a line gap above type to filter. and below type to filter, i want > "Sort by": "Updated", "Created", "Title". for filter use the maginifier @@ -282,7 +304,7 @@ in [DECISIONS.md](DECISIONS.md); the build order lives in [PLAN.md](PLAN.md). matches, Delete deletes it. Esc empties the field, then leaves it (to the first post). The empty field is just a row ↑/↓ pass through; a click on a post leaves the field too. On the posts, Esc empties the - search before it asks to quit. + search before it quits. > let's put "Sort by: Updated " on the right of the "type to filter" > (within the main viewport), and make it a dropdown that i can click and diff --git a/src/tui/list.rs b/src/tui/list.rs index 823e5af..f0daa21 100644 --- a/src/tui/list.rs +++ b/src/tui/list.rs @@ -130,9 +130,7 @@ enum Cmd { UpdateImported, ImportAsNew, Delete, - /// Esc on the list: asks first. Quit, - ConfirmQuit, Back, ConfirmDelete, CancelDialog, @@ -838,21 +836,7 @@ impl List { fn run(&mut self, cmd: Cmd) -> Action { match cmd { - Cmd::Quit => { - let dialog = Dialog::new( - "Quit", - vec![Line::from("Quit writui?"), Line::from("Your writing is saved.".dim())], - vec![ - Button { label: "Quit", key: "y", cmd: Cmd::ConfirmQuit, danger: false }, - Button { label: "Cancel", key: "n", cmd: Cmd::CancelDialog, danger: false }, - ], - 0, - 1, - ); - self.dialog = Some((0, dialog)); - Action::None - } - Cmd::ConfirmQuit => Action::Quit, + Cmd::Quit => Action::Quit, Cmd::Lock => Action::Lock, Cmd::New if !self.trash_view() => Action::NewPost, Cmd::Settings if !self.trash_view() => Action::ShowSettings, @@ -1189,7 +1173,7 @@ mod tests { } #[test] - fn single_keys_and_esc_asks_before_quitting() { + fn single_keys_and_esc_quits() { let posts = vec![post(1, "One"), post(2, "Two")]; let mut list = List::new(Mode::Posts { deleted_count: 0 }, posts, None); let text = screen(&mut list); @@ -1215,13 +1199,9 @@ mod tests { press(&mut list, KeyCode::Char('d'), KeyModifiers::NONE); assert!(screen(&mut list).contains("Move “One” to the Trash?")); press(&mut list, KeyCode::Esc, KeyModifiers::NONE); - // Esc: asks; n keeps going, y quits. - assert!(matches!(press(&mut list, KeyCode::Esc, KeyModifiers::NONE), Action::None)); - assert!(screen(&mut list).contains("Quit writui?")); - assert!(matches!(press(&mut list, KeyCode::Char('n'), KeyModifiers::NONE), Action::None)); assert!(list.dialog.is_none()); - press(&mut list, KeyCode::Esc, KeyModifiers::NONE); - assert!(matches!(press(&mut list, KeyCode::Char('y'), KeyModifiers::NONE), Action::Quit)); + // Esc quits straight away: the writing is already saved. + assert!(matches!(press(&mut list, KeyCode::Esc, KeyModifiers::NONE), Action::Quit)); } #[test] diff --git a/src/tui/setup.rs b/src/tui/setup.rs index bb6b112..62715ff 100644 --- a/src/tui/setup.rs +++ b/src/tui/setup.rs @@ -70,10 +70,6 @@ impl Setup { Line::default(), Line::from("Create your vault"), Line::default(), - Line::from("Everything you write in writui is encrypted with this password."), - Line::from("There is no way to recover it: if you forget the password, your"), - Line::from("writing is gone for good."), - Line::default(), // The two password boxes go here, side by side. Line::default(), Line::default(), @@ -88,7 +84,7 @@ impl Setup { rest.extend([Line::default(), Line::from(note.dim())]); } let mut lines = title(body, rest.len()); - let first_field_line = lines.len() as u16 + 7; + let first_field_line = lines.len() as u16 + 3; lines.extend(rest); let area = centered(body, COLUMN_WIDTH, lines.len() as u16); diff --git a/src/tui/unlock.rs b/src/tui/unlock.rs index 642f6e0..8ef118c 100644 --- a/src/tui/unlock.rs +++ b/src/tui/unlock.rs @@ -2,10 +2,10 @@ use ratatui::Frame; use ratatui::crossterm::event::{Event, KeyCode, MouseButton, MouseEventKind}; -use ratatui::layout::{Constraint, Layout, Rect}; -use ratatui::style::Stylize; +use ratatui::layout::{Constraint, Layout, Position, Rect}; +use ratatui::style::{Style, Stylize}; use ratatui::text::Line; -use ratatui::widgets::Paragraph; +use ratatui::widgets::{Block, Paragraph}; use super::Action; use super::hints::{HintBar, hint}; @@ -27,6 +27,8 @@ pub struct Unlock { locked: bool, error: Option<&'static str>, busy: bool, + /// Where the Unlock button was drawn, for clicks. + button: Rect, hints: HintBar, } @@ -84,6 +86,27 @@ impl Unlock { frame.set_cursor_position(cursor); } + // The Unlock button, right of the password box and as tall as it, + // left out if the window is too narrow for all of it. Dimmed while + // there's no password for it to try. + let button = Rect::new(field_area.right() + 2, field_area.y, 12, height); + self.button = if area.contains(Position::new(button.right() - 1, button.bottom() - 1)) { + button + } else { + Rect::default() + }; + if !self.button.is_empty() { + let style = if self.field.is_empty() || self.busy { + Style::new().dim() + } else { + Style::new().bold() + }; + let block = Block::bordered().border_style(style); + let label = Paragraph::new(Line::from("Unlock").style(style)).centered(); + frame.render_widget(label, block.inner(button)); + frame.render_widget(block, button); + } + // Esc clears the password, or quits if there's nothing to clear. let mut hints = vec![hint("Enter", "unlock", Cmd::Unlock)]; if self.field.is_empty() { @@ -114,6 +137,9 @@ impl Unlock { Action::None } Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left) => { + if self.button.contains(Position::new(mouse.column, mouse.row)) { + return self.run(Cmd::Unlock); + } match self.hints.hit(mouse.column, mouse.row) { Some(cmd) => self.run(cmd), None => Action::None, diff --git a/src/tui/widgets.rs b/src/tui/widgets.rs index d6ff8de..bf2331f 100644 --- a/src/tui/widgets.rs +++ b/src/tui/widgets.rs @@ -28,7 +28,7 @@ pub fn overview() -> Vec> { const POINTS: [&str; 6] = [ "All your writing lives in one file, encrypted with this password.", "Forget the password and the writing is gone: there's no recovery.", - "Write in markdown. Every post starts with a # title.", + "Write your posts in markdown. Every post starts with a # title.", "Posts save as you type; Ctrl+S also keeps a version to go back to.", "While writing, Ctrl+K shows every command and its shortcut.", "Ctrl+O locks writui; unlocking takes you back where you were.", @@ -43,24 +43,27 @@ pub fn overview() -> Vec> { /// into those three parts. #[rustfmt::skip] const BANNER: [[&str; 3]; 6] = [ - [" ", " ", " ██ ██"], - ["▄▄▄ ", "▄▄", " ██"], - [" ▀██▄ ", "██", " ██ ██ ██ ▄████ █████ ███████ ██ ██ █████"], - [" ▀██▄ ", "██", " ██ ▄▄ ██ ███▀ ██ ██ ██ ██ ██"], - [" ▄██▀ ", "██", " ██▄██▄██ ██ ██ ██ ██ ██ ██"], - ["▄██▀ ", "██", " ▀█▀ ▀█▀ ██ ████████ ▀█████ ▀██▄▄▄██ ████████"], + [" ", " ", " ██ ██"], + ["▄▄▄ ", "▄▄", " ██"], + [" ▀██▄ ", "██", " ██ ██ ██ ▄██ ▀██ █████ ██ ██ ▀██"], + [" ▀██▄ ", "██", " ██ ▄ ██ ███▀ ██ ██ ██ ██ ██"], + [" ▄██▀ ", "██", " ██▄█▄██ ██ ██ ██ ██ ██ ██"], + ["▄██▀ ", "██", " ▀█▀ ▀█▀ ██ ▄██▄ ▀███ ▀████▀ ▄██▄"], ]; -/// The password screens' title: the banner, or just "writui" when `area` -/// is too small to fit the banner above `other_lines` more lines. +/// The password screens' title: the banner and a blank line under it, or +/// just "writui" when `area` is too small to fit those above `other_lines` +/// more lines. pub fn title(area: Rect, other_lines: usize) -> Vec> { - if area.width < COLUMN_WIDTH || (area.height as usize) < BANNER.len() + other_lines { + if area.width < COLUMN_WIDTH || (area.height as usize) < BANNER.len() + 1 + other_lines { return vec![Line::from("writui".bold())]; } - BANNER + let mut lines: Vec = BANNER .iter() .map(|[mark, bar, name]| Line::from(vec![Span::raw(*mark), bar.green(), Span::raw(*name)])) - .collect() + .collect(); + lines.push(Line::default()); + lines } /// A column of at most `width`, horizontally centred in `area`. @@ -180,10 +183,23 @@ mod tests { #[test] fn the_banner_fits_the_column() { let lines = title(Rect::new(0, 0, 100, 30), 10); - assert_eq!(lines.len(), BANNER.len()); + assert_eq!(lines.len(), BANNER.len() + 1); assert!(lines.iter().all(|line| line.width() <= COLUMN_WIDTH as usize)); } + #[test] + fn the_dots_of_the_is_sit_over_their_stems() { + let columns = |line: &str, piece: &str| -> Vec { + let chars: Vec = line.chars().collect(); + let piece: Vec = piece.chars().collect(); + (0..chars.len()).filter(|&i| chars[i..].starts_with(&piece)).collect() + }; + let dots = columns(BANNER[0][2], "██"); + let stems: Vec = + columns(BANNER[2][2], "▀██").into_iter().filter(|&i| i > 10).map(|i| i + 1).collect(); + assert_eq!(dots, stems); + } + #[test] fn small_windows_get_the_plain_title() { assert_eq!(title(Rect::new(0, 0, 60, 30), 10).len(), 1);