diff --git a/Cargo.lock b/Cargo.lock index 88e86bc..b82b665 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2937,7 +2937,7 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "writui" -version = "0.18.0" +version = "0.19.0" dependencies = [ "anyhow", "arboard", diff --git a/Cargo.toml b/Cargo.toml index 3274aaa..ad7283d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "writui" -version = "0.18.0" +version = "0.19.0" edition = "2024" description = "A terminal writing app. No nonsense. Markdown. Local. Encrypted." license = "MIT" diff --git a/README.md b/README.md index dd10f43..24af518 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,9 @@ A writing app for the terminal. No nonsense. Markdown. Local. Encrypted. - **Plain markdown.** You write markdown. Each post starts with a `# Title`. It stays plain markdown on screen, formatted as you type: headings and bold in bold, italics in italics, code in green, with the markdown - symbols faded. + symbols faded. (Settings can tone that down to faded symbols, italics + and green code, or turn it off.) Lists carry on as you press Enter, and Tab nests an + item. - **Mouse and keyboard.** Click, scroll and press buttons with the mouse, or use shortcuts. The bar at the bottom of the screen shows the shortcuts for where you are, and you can click those too. While writing, Ctrl+K @@ -197,9 +199,10 @@ can open it after a fingerprint, is kept in `~/.config/writui/touch-id/`. for 30 days. - **Settings** (`s` on the list): your name, added as the `author` in exported front matter, how wide the text is while - writing (68 characters unless you change it), and whether your + writing (68 characters unless you change it), how much markdown + styling you see (full, minimal or off), and whether your terminal's font is a [Nerd Font](https://www.nerdfonts.com), so writui - can use its icons. + can use its icons. Under each setting is a line saying what it does now. ### Cmd keys on a Mac @@ -207,29 +210,12 @@ In the editor, Cmd works like Ctrl (Cmd+Z undoes, Cmd+X cuts, Cmd+A selects all, Cmd+K opens the menu) wherever your terminal passes Cmd on to programs. Cmd+V is your terminal's paste, which writui understands. -Cmd+C is different: terminals keep it to copy *their own* selection, and -writui draws its own selection, so there's nothing for the terminal to -copy. Ctrl+C always works. To make Cmd+C copy in writui too, tell your -terminal to pass it on: - -- **Alacritty**, in `~/.config/alacritty/alacritty.toml`: - - ```toml - [[keyboard.bindings]] - key = "C" - mods = "Command" - action = "ReceiveChar" - ``` - -- **Ghostty**, in its config (it still copies the terminal's own selection - when there is one): - - ``` - keybind = performable:super+c=copy_to_clipboard - ``` - -- **iTerm2** (not tried yet): Settings → Keys → Key Bindings, add Cmd+C - with the action "Send Escape Sequence" and `[99;9u`. +Cmd+C works too, in any terminal, with nothing to set up: whatever you +select in writui goes on the clipboard right away, so it's there when you +press Cmd+C (which the terminal keeps for itself). It doesn't get in the +way of what you'd copied before: typing or pasting over a selection puts +that back on the clipboard, and Cmd+V over a selection pastes it, not +the selection. Ctrl+C copies too. Cmd+K needs nothing in Alacritty (it sends Ctrl+L, which also opens the menu). In Ghostty and iTerm2 it clears the screen unless you pass it on diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 314aa81..2533f9d 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -726,3 +726,62 @@ password unlock quietly makes a new one. anyway), so Touch ID can be turned on from Settings without retyping the password. Opening with the key skips Argon2, so Touch ID unlocks are instant. Changing the password, when it lands, must redo the Touch ID file. + +## Markdown styling: full, minimal, off + +A three-way setting replaced the "bold title" switch proposed in PR #31 +(closed), which the creator didn't like as a one-off. The styling is still +worked out in full by the CommonMark parser; minimal and off only map the +styles at the end (minimal keeps the faded symbols, italics and green +code; off keeps nothing), so links are found, and open, the same in every mode. Stored in +the vault's key/value table as `markdown_styling`, so no migration: a +vault without it reads as `full`. + +## Settings say what each value does + +Each setting takes two rows (name and value, then a faded line saying what +the current value does) and a faded rule between settings. The screen is +drawn by hand rather than with ratatui's `List`, so clicks on the rules do +nothing and each option can be clicked. A reversed block for the selected +setting was too loud, so a bar (`┃`, box drawing, so any font has it) sits +left of the column instead (inside it, moving the settings over, if the +terminal is too narrow). Settings with a few options show them all +(`on | off`), the current one reversed with a space either side and the +rest faded; ←/→ and clicks pick one, Enter goes to the next. + +## Lists carry on: from the line's own prefix + +Enter, Tab and Shift+Tab look only at the current line's prefix (any `>` +quote markers, indent, `-`/`*`/`+` or `1.`/`1)`, an optional task box), +like the hanging indent does, not the markdown parser: it's what the +writer sees, and it works on half-typed lists. Nesting lines an item up +with the text of the item above at its level (2 columns after `- `, 3 +after `1. `), which is what CommonMark needs to make it a child; outdenting +goes back to the nearest item above with a smaller indent. It only looks +back within the paragraph (to the last blank line). A nested numbered item +is renumbered 1: CommonMark only lets a numbered list starting at 1 +interrupt a paragraph, so `3.` nested under an item is just text. Brought +out, it takes the number after the item above at its new level. Nothing +else is renumbered. Enter on an empty item ends the list (or outdents a +nested one), as in most editors. Shift+Enter arrives as Shift+Enter with +the kitty protocol, and as Alt+Enter where the terminal sends ESC CR (the +creator's Alacritty config does), so both are a plain new line. + +## Cmd+C: the selection goes on the clipboard as it's made + +Terminals keep Cmd+C to copy their own selection, and writui (which draws +its own) never sees it, so Cmd+C couldn't work without terminal config. +Mouse capture can't be given up (everything is clickable), and there's no +escape sequence to set the terminal's selection. So selecting text in the +editor puts it on the system clipboard straight away (after a drag ends, +not on every mouse move). To keep that from clobbering what was copied +before, it's provisional: writui remembers the clipboard's text from +before the selection. Typing, deleting or pasting over the selection puts +that back; a paste over the selection whose text is the selection itself +(the terminal's Cmd+V reading the clipboard) pastes the earlier text +instead. Leaving the selection (click, arrow, Esc) keeps it on the +clipboard, as Cmd+C would. Nothing is put back if something else was +copied in the meantime. The trade-off: "select, Cmd+C, then type over the +selection" loses the copy (Cmd+X is the way to do that). Non-text clipboard +contents (an image) can't be remembered, so selecting text replaces them. +Tests use a per-thread fake clipboard rather than the real one. diff --git a/docs/PLAN.md b/docs/PLAN.md index f1c4436..5a82853 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -34,7 +34,9 @@ on the password screens, boxed password fields, Esc to clear or quit, and 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. `0.18.0` unlocks with Touch ID on Macs -(asked for out of order). Next: +(asked for out of order), and `0.19.0` adds the Markdown styling setting, +list continuation and Cmd+C (also out of order; it replaces the closed +PR #31's bold-title switch). 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. @@ -190,6 +192,10 @@ The creator's batch of ideas, after M3. the right-click menu - [x] Tab / Shift+Tab cycle the sort; Ctrl/Cmd+Up/Down to the start / end of the post; Ctrl/Cmd+Left/Right on by sentence or row +- [x] Markdown styling setting (full / minimal / off); each setting says + what it does, with lines between them; lists carry on with Enter, + Tab / Shift+Tab nest them; Cmd+C copies with no terminal setup + (`0.19.0`, asked for out of order) ## Later diff --git a/docs/SPEC.md b/docs/SPEC.md index aedd646..a3a451d 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -125,8 +125,10 @@ in [DECISIONS.md](DECISIONS.md); the build order lives in [PLAN.md](PLAN.md). paste, so it just works. Cmd+C is the terminal's copy of *its own* selection; in nano that's what you see, because nano leaves the mouse to the terminal, but writui draws its own selection (to know what's - selected), so the terminal has nothing to copy. The README shows the - one line of terminal config that passes Cmd+C on to writui. Alacritty + selected), so the terminal has nothing to copy of its own. So writui + puts whatever is selected on the clipboard as soon as it's selected + (see Editing), and Cmd+C works with no setup. Passing Cmd+C on to + writui (the README's terminal config) still works too. Alacritty sends Ctrl+L for Cmd+K, so Ctrl+L opens (and closes) the menu too, and Cmd+K works there without any config. @@ -453,7 +455,31 @@ in [DECISIONS.md](DECISIONS.md); the build order lives in [PLAN.md](PLAN.md). - Home/End go to the start/end of the row on screen, and so do Ctrl+Left/Right (and Cmd+Left/Right on macOS); Ctrl+Home/Ctrl+End to the start/end of the post. PageUp/PageDown move a screenful. - - Tab types two spaces (to be configurable in settings). + - Tab types two spaces (to be configurable in settings), except at the + start of a list item (below). + + > in the editor itself, i want automatic continuation for bullet points, + > checkboxes, numbered lists etc. on pressing enter. i also want tab and + > shift+tab to do the right thing at the start of a list line i.e. indent + > or unindent the item. it doesn't have to be perfect covering every + > case, just good enough for the common cases + +- Lists carry on: Enter in a list item starts the next one with the same + bullet (`-`, `*`, `+`), the next number (`1.` → `2.`, `1)` → `2)`), and + an unticked `[ ] ` if it's a task, at the same indent. Quotes (`> `) + carry on the same way. Enter mid-item splits it; with the cursor before + the bullet, it's a plain new line. + - Enter on an empty item ends the list: the bullet goes, leaving a blank + line to type on. A nested empty item comes out a level instead. + - Shift+Enter (or Option+Enter) is always a plain new line. + - With the cursor at the start of an item (anywhere up to the end of its + bullet, number or task box), Tab nests it under the item above, lined + up with that item's text; Shift+Tab brings it back out to its parent's + level. Elsewhere Tab types two spaces, and Shift+Tab does nothing. + - A numbered item nested this way starts again at 1 (markdown only + takes a nested numbered list that starts at 1 as a list); brought back + out, it carries on from the number above it. Other numbers aren't + redone. - Opening a post puts the cursor back where it was when the post was last closed, as near the middle of the screen as the text allows. A post that has never been opened starts with the cursor at the end. @@ -474,6 +500,21 @@ in [DECISIONS.md](DECISIONS.md); the build order lives in [PLAN.md](PLAN.md). If there's no system clipboard (e.g. no display), they still work inside writui. Pasting from the terminal (Cmd+V) also works. + > finally, please make Cmd+c and Cmd+v work on mac for copy and paste. i + > mean, if it works on nano, why doesn't it work here? + +- Selecting text puts it on the clipboard straight away (once a drag ends), + so Cmd+C needs no terminal setup: the terminal's own Cmd+C finds nothing + of its own to copy, and the selection is already there. Until it's + copied for real (Ctrl+C, Cut, the menu), that copy is provisional: + - Typing, Backspace or Delete over the selection, or pasting over it, + puts back what was on the clipboard before it was selected (unless + something else was copied since). + - Cmd+V (or Ctrl+V) over a selection pastes what was on the clipboard + before it, not the selection itself. + - Moving on from the selection (a click, an arrow key, Esc) leaves it on + the clipboard, as if copied. + > add a context menu for when i right click on a selection to cut, copy, > paste (disabled if clipboard is empty) @@ -574,6 +615,19 @@ in [DECISIONS.md](DECISIONS.md); the build order lives in [PLAN.md](PLAN.md). the text, not the bullet. On screen only; no spaces are added to the text. - Later: syntax highlighting in code blocks, per language. + > add a new setting called markdown styling with values "off", "minimal" + > (no bold text, just markdown markers are muted), and "full" (default, + > current) + + > in the minimal setting, i stil want green for code + + > keep italic in minimal mode + +- All of that is the **full** styling, the default. Settings can make it + **minimal** (the markdown symbols faded, italics italic and code green; + no bold or underlines) or turn it **off** (everything plain). Links open the same either way. The history shows versions in + the same styling. + ## Web preview > A lot of the writing I'm doing is actually for work etc. so I want to be @@ -742,10 +796,28 @@ in [DECISIONS.md](DECISIONS.md); the build order lives in [PLAN.md](PLAN.md). **Details** -- A Settings screen, reached with `s` on the list (it's on the hint bar). Each row is a setting and its value; Enter or a - click changes it in a dialog; Esc goes back to the list (its hint - just says "back"). Changes are - stored right away. + > for each setting add a line below explaning what the currnet behavior + > is in lighter text + + > add a separator line between settings + + > also, in the settings screen, there is too much color. maybe just keep + > a bar on the left to indicate which one wer'e one. also i want the + > selection to appear as on | off with the current selection + > highlighted, this way all options are shown + +- A Settings screen, reached with `s` on the list (it's on the hint bar). + Each setting shows its name and value, and below that, faded, a line on + what the current value does (it changes with the value); a faded line + separates one setting from the next. The selected setting has a bar + (`┃`) to its left; nothing else is highlighted. Settings with a few + options show them all on the right, like `on | off` or + `full | minimal | off`, the current one highlighted (reversed) and the + others faded. ←/→ or a click on an option picks it; Enter or a click + elsewhere on the setting goes to the next option. Others (Author, Line + width) show their value, and Enter or a click changes it in a dialog. + Esc goes back to the list (its hint just says "back"). Changes are stored right away. A short screen + scrolls to keep the selected setting in view. - For now: - **Author**: suggested as `author` in the front matter of exports (after `title`), unless it's empty. Like the description and slug, it @@ -753,10 +825,11 @@ in [DECISIONS.md](DECISIONS.md); the build order lives in [PLAN.md](PLAN.md). front matter. - **Line width**: how wide the text is in the editor (and the history), from 40 to 120 characters; 68 by default. + - **Markdown styling**: full (the default), minimal or off (see Live + markdown formatting), shown as `full | minimal | off`. - **Nerd Font**: on or off (off by default), for terminals whose font is a Nerd Font: writui uses its icons (so far, the search field's - magnifier). Enter or a click switches it, no dialog; the hint says - "Enter switch". + magnifier). Shown as `on | off`. - Later: what Tab types, default preview preset, API keys, default model, auto-lock timeout — all stored in the vault. - The vault file location has to be known *before* unlocking, so it lives diff --git a/site/demo.tape b/site/demo.tape index 8992707..91a4bf6 100644 --- a/site/demo.tape +++ b/site/demo.tape @@ -56,11 +56,13 @@ Type "I write every morning, and lately I do it in a **terminal**. " Type "It's *quiet*: one column of text, nothing else." Enter Enter +# Enter carries the list on; on an empty item, it ends it. Type "- Plain `markdown`, formatted as I type" Enter -Type "- Encrypted with my password" +Type "Encrypted with my password" +Enter +Type "Versions whenever I want them" Enter -Type "- Versions whenever I want them" Enter Enter Type "> The best tool is the one that gets out of the way." diff --git a/site/static/demo.mp4 b/site/static/demo.mp4 index 5268f8c..519f510 100644 Binary files a/site/static/demo.mp4 and b/site/static/demo.mp4 differ diff --git a/site/static/demo.png b/site/static/demo.png index cecb817..e059eb5 100644 Binary files a/site/static/demo.png and b/site/static/demo.png differ diff --git a/src/tui/editor/buffer.rs b/src/tui/editor/buffer.rs index 00baaf1..f4c3424 100644 --- a/src/tui/editor/buffer.rs +++ b/src/tui/editor/buffer.rs @@ -9,6 +9,7 @@ use std::ops::Range; use ropey::Rope; use unicode_segmentation::UnicodeSegmentation; +use super::lists; use crate::vault::{TITLE_PREFIX, with_title_prefix}; /// The first position the cursor can be at: just after `# `. @@ -141,6 +142,62 @@ impl Buffer { } } + /// Enter: a new line, which carries on the list item or quote the + /// cursor is in. On an empty item, it ends the list instead (or, if it's + /// nested, brings it a level out). + pub fn newline(&mut self) { + let line = self.rope.char_to_line(self.cursor); + let start = self.rope.line_to_char(line); + let text = self.line_text(line); + let prefix = lists::prefix(&text).filter(|p| self.cursor >= start + p.len); + match prefix { + Some(prefix) if line > 0 && self.selection().is_none() => { + if !prefix.empty(&text) { + self.insert(&format!("\n{}", prefix.next(&text))); + } else if !self.reindent(line, false) { + self.rope.remove(start..start + text.chars().count()); + self.cursor = start; + } + } + _ => self.insert("\n"), + } + } + + /// Tab or Shift+Tab with the cursor at the start of a list item (in its + /// bullet, number or task box, or before them): nest the item under the + /// one above, or bring it a level out. False if the cursor isn't there. + pub fn indent_item(&mut self, deeper: bool) -> bool { + let line = self.rope.char_to_line(self.cursor); + let start = self.rope.line_to_char(line); + let at_start = lists::prefix(&self.line_text(line)) + .is_some_and(|p| p.is_item() && self.cursor <= start + p.len); + line > 0 && self.selection().is_none() && at_start && self.reindent(line, deeper) + } + + /// Nest the list item on `line` deeper, or bring it out a level. + fn reindent(&mut self, line: usize, deeper: bool) -> bool { + // The lines of its paragraph, up to it. + let mut first = line; + while first > 1 && !self.line_text(first - 1).trim().is_empty() { + first -= 1; + } + let lines: Vec = (first..=line).map(|l| self.line_text(l)).collect(); + let Some((range, indent)) = lists::reindent(&lines, line - first, deeper) else { + return false; + }; + let start = self.rope.line_to_char(line); + let (from, to) = (start + range.start, start + range.end); + self.rope.remove(from..to); + self.rope.insert(from, &indent); + let new_to = from + indent.chars().count(); + if self.cursor >= to { + self.cursor = self.cursor - to + new_to; + } else if self.cursor > from { + self.cursor = new_to.min(self.cursor); + } + true + } + /// Delete the selection, or the grapheme before the cursor (joining /// lines at a line start). pub fn backspace(&mut self) { diff --git a/src/tui/editor/clipboard.rs b/src/tui/editor/clipboard.rs index 600760a..29c2def 100644 --- a/src/tui/editor/clipboard.rs +++ b/src/tui/editor/clipboard.rs @@ -1,40 +1,158 @@ //! The system clipboard. If it can't be reached (no display, say), copying //! and pasting still work within writui, from the last text copied. +//! +//! Selected text goes on the clipboard as soon as it's selected, so the +//! terminal's own copy key (Cmd+C on a Mac, which writui never sees, and +//! which can't see writui's selection) has nothing left to do. Until it's +//! copied for real, that's provisional: typing, deleting or pasting over the +//! selection puts back what was on the clipboard before, and pasting over +//! it pastes that, not the selection itself. -use std::sync::Mutex; - +#[cfg(not(test))] /// Kept for the whole run: on Linux the clipboard's contents are served by /// the program that copied them. -static SYSTEM: Mutex> = Mutex::new(None); +static SYSTEM: std::sync::Mutex> = std::sync::Mutex::new(None); #[derive(Default)] pub struct Clipboard { /// The last text copied in writui. held: String, + /// The selection, put on the clipboard but not copied for real. + selected: Option, +} + +struct Selected { + text: String, + /// What was on the clipboard before (`None`: nothing, or not text). + before: Option, } impl Clipboard { pub fn copy(&mut self, text: &str) { + self.selected = None; self.held = text.to_string(); - with_system(|system| system.set_text(text).ok()); + system_set(text); } /// What's on the clipboard now. pub fn paste(&self) -> String { - with_system(|system| system.get_text().ok()) - .filter(|text| !text.is_empty()) - .unwrap_or_else(|| self.held.clone()) + system_get().filter(|text| !text.is_empty()).unwrap_or_else(|| self.held.clone()) + } + + /// The text selected now (if any): put it on the clipboard. With no + /// selection, the last one stays there, as if copied. + pub fn select(&mut self, text: Option<&str>) { + let Some(text) = text else { + self.selected = None; + return; + }; + if self.selected.as_ref().is_some_and(|s| s.text == text) { + return; + } + let now = system_get(); + // A selection that's grown or shrunk keeps what was there first, + // unless something else has been copied since. + let before = match self.selected.take() { + Some(s) if now.as_deref() == Some(s.text.as_str()) => s.before, + _ => now, + }; + system_set(text); + self.selected = Some(Selected { text: text.to_string(), before }); + } + + /// The selection was typed, deleted or pasted over: put back what was + /// on the clipboard before it (unless something else was copied since). + pub fn restore(&mut self) { + let Some(selected) = self.selected.take() else { return }; + if system_get().as_deref() == Some(selected.text.as_str()) { + match selected.before { + Some(before) => system_set(&before), + None => system_clear(), + } + } } + + /// What pasting `text` over the selection should put there: the + /// terminal reads the clipboard, which holds the selection itself, so + /// it's what was on the clipboard before it. + pub fn over_selection(&self, text: String) -> String { + match &self.selected { + Some(s) if s.text == text => s.before.clone().unwrap_or(text), + _ => text, + } + } +} + +#[cfg(not(test))] +fn system_get() -> Option { + with_system(|system| system.get_text().ok()) +} + +#[cfg(not(test))] +fn system_set(text: &str) { + with_system(|system| system.set_text(text).ok()); +} + +#[cfg(not(test))] +fn system_clear() { + with_system(|system| system.clear().ok()); } -/// Tests never touch the real clipboard. +#[cfg(not(test))] fn with_system(f: impl FnOnce(&mut arboard::Clipboard) -> Option) -> Option { - if cfg!(test) { - return None; - } let mut guard = SYSTEM.lock().ok()?; if guard.is_none() { *guard = arboard::Clipboard::new().ok(); } f(guard.as_mut()?) } + +// Tests never touch the real clipboard: each test thread has its own. +#[cfg(test)] +thread_local! { + static FAKE: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +#[cfg(test)] +fn system_get() -> Option { + FAKE.with(|fake| fake.borrow().clone()) +} + +#[cfg(test)] +fn system_set(text: &str) { + FAKE.with(|fake| *fake.borrow_mut() = Some(text.to_string())); +} + +#[cfg(test)] +fn system_clear() { + FAKE.with(|fake| *fake.borrow_mut() = None); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_selection_is_on_the_clipboard_until_it_is_typed_over() { + let mut clipboard = Clipboard::default(); + system_set("url"); + clipboard.select(Some("wo")); + clipboard.select(Some("word")); + assert_eq!(system_get().as_deref(), Some("word")); + // Pasting over it pastes what was there before. + assert_eq!(clipboard.over_selection("word".into()), "url"); + assert_eq!(clipboard.over_selection("other".into()), "other"); + clipboard.restore(); + assert_eq!(system_get().as_deref(), Some("url")); + // Deselected (the cursor moved on): it stays, as if copied. + clipboard.select(Some("kept")); + clipboard.select(None); + clipboard.restore(); + assert_eq!(system_get().as_deref(), Some("kept")); + // Copied elsewhere since: that's left alone. + clipboard.select(Some("mine")); + system_set("theirs"); + clipboard.restore(); + assert_eq!(system_get().as_deref(), Some("theirs")); + } +} diff --git a/src/tui/editor/history.rs b/src/tui/editor/history.rs index 3589992..0c31655 100644 --- a/src/tui/editor/history.rs +++ b/src/tui/editor/history.rs @@ -21,7 +21,7 @@ use super::super::widgets::{COLUMN_WIDTH, column, truncate}; use super::markdown::Markup; use super::row_line; use super::wrap::{Row, layout}; -use crate::vault::Version; +use crate::vault::{Styling, Version}; /// What the editor should do after an event. pub enum Outcome { @@ -58,6 +58,8 @@ pub struct History { hints: HintBar, /// How wide a version's text is, as in the editor. line_width: usize, + /// How its markdown is formatted, as in the editor. + styling: Styling, } /// A version's full text, laid out like the editor does. @@ -72,7 +74,13 @@ struct Reading { } impl History { - pub fn new(title: String, versions: Vec, changed: bool, line_width: usize) -> Self { + pub fn new( + title: String, + versions: Vec, + changed: bool, + line_width: usize, + styling: Styling, + ) -> Self { let state = ListState::default().with_selected((!versions.is_empty()).then_some(0)); History { title, @@ -84,13 +92,14 @@ impl History { dialog: None, hints: HintBar::default(), line_width, + styling, } } /// Open a version (with its text) to read. pub fn read(&mut self, version: Version) { let rope = Rope::from_str(&version.body); - let markup = Markup::new(&rope); + let markup = Markup::new(&rope, self.styling); self.reading = Some(Reading { version, rope, markup, rows: Vec::new(), width: 0, top: 0, height: 0 }); } diff --git a/src/tui/editor/lists.rs b/src/tui/editor/lists.rs new file mode 100644 index 0000000..98ad6b9 --- /dev/null +++ b/src/tui/editor/lists.rs @@ -0,0 +1,227 @@ +//! Lists as you type: Enter carries a list item's bullet, number or task box +//! on to the next line (or, on an empty item, ends the list), and Tab or +//! Shift+Tab at the start of an item nests it under the one above or brings +//! it back out. Quotes carry on too. Good enough for the usual cases, not a +//! full markdown parser: it looks at the line's own prefix only. + +use std::ops::Range; + +use super::wrap::TAB_WIDTH; + +/// The markdown at the start of a line that makes it a list item or quote. +#[derive(Debug, PartialEq)] +pub struct Prefix { + /// `>` quote markers (with their spaces), before anything else. + quote: String, + /// Spaces (or tabs) before the marker, in chars. + indent: usize, + /// How wide the indent is on screen. + indent_width: usize, + marker: Marker, + /// A task box after the marker: `[ ] `, `[x] `. + task: bool, + /// The whole prefix, in chars. + pub len: usize, +} + +#[derive(Debug, PartialEq)] +enum Marker { + /// Just a quote. + None, + /// `-`, `*` or `+`. + Bullet(char), + /// A number and `.` or `)`. + Number(u64, char), +} + +impl Prefix { + /// What starts the next line of the same list (or quote). + pub fn next(&self, line: &str) -> String { + let indent: String = + line.chars().skip(self.quote.chars().count()).take(self.indent).collect(); + let marker = match self.marker { + Marker::None => String::new(), + Marker::Bullet(ch) => format!("{ch} "), + Marker::Number(n, delim) => format!("{}{delim} ", n + 1), + }; + let task = if self.task { "[ ] " } else { "" }; + format!("{}{indent}{marker}{task}", self.quote) + } + + /// Nothing written after the prefix yet. + pub fn empty(&self, line: &str) -> bool { + line.chars().skip(self.len).all(char::is_whitespace) + } + + /// A list item (rather than just a quote). + pub fn is_item(&self) -> bool { + self.marker != Marker::None + } + + /// Where the item's text starts, on screen: its children are indented + /// this far. + fn content_width(&self) -> usize { + let marker = match self.marker { + Marker::None => 0, + Marker::Bullet(_) => 2, + Marker::Number(n, _) => n.to_string().len() + 2, + }; + self.indent_width + marker + } +} + +/// The list or quote prefix of `line` (without its newline), if it has one. +pub fn prefix(line: &str) -> Option { + let mut rest = line; + while let Some(r) = rest.strip_prefix('>') { + rest = r.strip_prefix(' ').unwrap_or(r); + } + let quote = line[..line.len() - rest.len()].to_string(); + let after_indent = rest.trim_start_matches([' ', '\t']); + let indent_text = &rest[..rest.len() - after_indent.len()]; + let indent_width = indent_text.chars().map(|ch| if ch == '\t' { TAB_WIDTH } else { 1 }).sum(); + let (marker, after) = match marker(after_indent) { + Some(found) => found, + None if !quote.is_empty() => (Marker::None, after_indent), + None => return None, + }; + let (task, after) = match ["[ ] ", "[x] ", "[X] "].iter().find_map(|b| after.strip_prefix(b)) { + Some(r) if marker != Marker::None => (true, r), + _ => (false, after), + }; + // A quote's indent is part of its text, unless a list item follows. + let (indent, after) = match marker { + Marker::None => (0, rest), + _ => (indent_text.chars().count(), after), + }; + let len = line.chars().count() - after.chars().count(); + Some(Prefix { quote, indent, indent_width, marker, task, len }) +} + +/// A list marker at the start of `text`, and what follows it. +fn marker(text: &str) -> Option<(Marker, &str)> { + let digits = text.len() - text.trim_start_matches(|c: char| c.is_ascii_digit()).len(); + let (marker, rest) = match digits { + 0 => { + let ch = text.chars().next().filter(|ch| matches!(ch, '-' | '*' | '+'))?; + (Marker::Bullet(ch), &text[1..]) + } + 1..=9 => { + let delim = text[digits..].chars().next().filter(|ch| matches!(ch, '.' | ')'))?; + (Marker::Number(text[..digits].parse().ok()?, delim), &text[digits + 1..]) + } + _ => return None, + }; + Some((marker, rest.strip_prefix(' ')?)) +} + +/// Nest the list item `lines[at]` one level deeper (under the item above +/// it) or bring it one level out: the chars of the line to replace, and +/// what to put there (the new leading whitespace, and for a numbered item +/// its new number). `None` if it isn't a list item, or can't go that way. +pub fn reindent(lines: &[String], at: usize, deeper: bool) -> Option<(Range, String)> { + let item = prefix(&lines[at]).filter(Prefix::is_item)?; + // The items above, nearest first, in the same quote. + let above = || { + lines[..at] + .iter() + .rev() + .take_while(|line| !line.trim().is_empty()) + .filter_map(|line| prefix(line)) + .filter(|p| p.is_item() && p.quote == item.quote) + }; + let width = if deeper { + // Under the item above at the same level, lined up with its text. + match above().find(|p| p.indent_width <= item.indent_width) { + Some(p) if p.indent_width == item.indent_width => p.content_width(), + _ => item.content_width(), + } + } else { + if item.indent_width == 0 { + return None; + } + // Back to where its parent is. + above().find(|p| p.indent_width < item.indent_width).map_or(0, |p| p.indent_width) + }; + let start = item.quote.chars().count(); + let mut end = start + item.indent; + let mut text = " ".repeat(width); + // A nested numbered list has to start at 1 to be a list at all; one + // brought out carries on from the item above it at its new level. + if let Marker::Number(n, _) = item.marker { + end += n.to_string().len(); + let n = match above().find(|p| p.indent_width <= width) { + _ if deeper => 1, + Some(Prefix { marker: Marker::Number(above, _), indent_width, .. }) + if indent_width == width => + { + above + 1 + } + _ => n, + }; + text.push_str(&n.to_string()); + } + Some((start..end, text)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn next(line: &str) -> Option { + prefix(line).map(|p| p.next(line)) + } + + #[test] + fn items_carry_on() { + assert_eq!(next("- milk").as_deref(), Some("- ")); + assert_eq!(next(" * eggs").as_deref(), Some(" * ")); + assert_eq!(next("+ x").as_deref(), Some("+ ")); + assert_eq!(next("1. one").as_deref(), Some("2. ")); + assert_eq!(next("9) nine").as_deref(), Some("10) ")); + assert_eq!(next("- [x] done").as_deref(), Some("- [ ] ")); + assert_eq!(next(" 3. [ ] task").as_deref(), Some(" 4. [ ] ")); + assert_eq!(next("> quoted").as_deref(), Some("> ")); + assert_eq!(next("> - in a quote").as_deref(), Some("> - ")); + } + + #[test] + fn other_lines_are_not_lists() { + for line in ["plain", "-no space", "---", "**bold**", "1.5 litres", "# Title", ""] { + assert_eq!(prefix(line), None, "{line}"); + } + } + + #[test] + fn empty_items() { + let empty = |line: &str| prefix(line).unwrap().empty(line); + assert!(empty("- ") && empty(" 2. ") && empty("- [ ] ") && empty("> ")); + assert!(!empty("- a") && !empty("> a")); + assert_eq!(prefix("- [ ] task").unwrap().len, 6); + } + + #[test] + fn nesting_lines_up_under_the_item_above() { + let doc: Vec = "- a\n- b\n - c\n - d\n1. one\n2. two\nplain\n> - x\n> - y" + .lines() + .map(String::from) + .collect(); + let indent = |at, deeper| reindent(&doc, at, deeper).map(|(range, s)| (range, s.len())); + assert_eq!(indent(1, true), Some((0..0, 2))); + assert_eq!(indent(3, true), Some((0..2, 4))); + assert_eq!(indent(3, false), Some((0..2, 0))); + assert_eq!(indent(1, false), None); + // Numbered items nest under the number's text, starting again at 1, + // and carry on the numbers above when brought back out. + assert_eq!(reindent(&doc, 5, true), Some((0..1, " 1".into()))); + let nested: Vec = + ["1. one", " 1. a", " 2. b", "2. two"].map(String::from).to_vec(); + assert_eq!(reindent(&nested, 2, false), Some((0..4, "2".into()))); + assert_eq!(reindent(&nested, 3, true), Some((0..1, " 1".into()))); + assert_eq!(indent(6, true), None); + // The first item can still go in, by its own marker. + assert_eq!(indent(0, true), Some((0..0, 2))); + // In a quote, after the `> `. + assert_eq!(indent(8, true), Some((2..2, 2))); + } +} diff --git a/src/tui/editor/markdown.rs b/src/tui/editor/markdown.rs index d8694ed..15d42c0 100644 --- a/src/tui/editor/markdown.rs +++ b/src/tui/editor/markdown.rs @@ -11,13 +11,19 @@ //! //! Links are found here too, so they can be opened: markdown links, `<…>` //! links, and web addresses written out in the text. +//! +//! That's the full styling. Settings can make it minimal (the symbols +//! faded, italics italic and code green, the rest plain) or turn it off +//! (all plain); links open either way. use std::ops::Range; use pulldown_cmark::{Event, Options, Parser, Tag}; -use ratatui::style::Style; +use ratatui::style::{Modifier, Style}; use ropey::Rope; +use crate::vault::Styling; + /// A link in the text: the chars it covers, and where it goes. #[derive(Clone, Debug, PartialEq)] pub struct Link { @@ -35,7 +41,7 @@ pub struct Markup { } impl Markup { - pub fn new(rope: &Rope) -> Self { + pub fn new(rope: &Rope, styling: Styling) -> Self { let text = rope.to_string(); let mut styler = Styler { runs: Vec::new(), links: Vec::new(), stack: Vec::new(), pos: 0, text: None }; @@ -51,6 +57,19 @@ impl Markup { let mut runs: Vec<(usize, Style)> = Vec::with_capacity(styler.runs.len()); for (start, style) in styler.runs { let start = rope.byte_to_char(start); + let style = match styling { + Styling::Full => style, + Styling::Minimal if style.add_modifier.contains(Modifier::DIM) => faded(), + // Code stays green, and italics italic. + Styling::Minimal => { + let mut kept = if style.fg == code().fg { code() } else { Style::new() }; + if style.add_modifier.contains(Modifier::ITALIC) { + kept = kept.italic(); + } + kept + } + Styling::Off => Style::new(), + }; match runs.last_mut() { Some(last) if last.1 == style => {} Some(last) if last.0 == start => last.1 = style, @@ -272,13 +291,16 @@ mod tests { /// italic, `[f:…]` faded, `[u:…]` underlined, `[s:…]` struck through, /// `[g:…]` green (combined, e.g. `[bf:…]`). Plain text is left as it is. fn marked(text: &str) -> String { + marked_with(text, Styling::Full) + } + + fn marked_with(text: &str, styling: Styling) -> String { let rope = Rope::from_str(text); - let markup = Markup::new(&rope); + let markup = Markup::new(&rope, styling); let mut out = String::new(); for (range, style) in markup.styles(0..rope.len_chars()) { let piece = rope.slice(range).to_string(); let m = style.add_modifier; - use ratatui::style::Modifier; let tag: String = [ (Modifier::BOLD, 'b'), (Modifier::ITALIC, 'i'), @@ -317,6 +339,19 @@ mod tests { assert_eq!(marked("~~gone~~"), "[f:~~][s:gone][f:~~]"); } + #[test] + fn minimal_styling_only_fades_the_symbols_and_off_shows_it_plain() { + let text = "# Title\n\n**bold** *it* `code` [link](https://x.com) https://y.com"; + assert_eq!( + marked_with(text, Styling::Minimal), + "[f:#] Title\n\n[f:**]bold[f:**] [f:*][i:it][f:*] [f:`][g:code][f:`] [f:[]link[f:](https://x.com)] https://y.com" + ); + assert_eq!(marked_with(text, Styling::Off), text); + // Links still open. + let markup = Markup::new(&Rope::from_str(text), Styling::Off); + assert_eq!(markup.links.len(), 2); + } + #[test] fn half_typed_formatting_stays_plain() { assert_eq!(marked("a **b"), "a **b"); @@ -350,7 +385,7 @@ mod tests { marked("see [this](https://x.com) now"), "see [f:[][u:this][f:](https://x.com)] now" ); - let markup = Markup::new(&Rope::from_str("see [this](https://x.com) now")); + let markup = Markup::new(&Rope::from_str("see [this](https://x.com) now"), Styling::Full); assert_eq!(markup.link_at(4).map(|l| l.url.as_str()), Some("https://x.com")); assert_eq!(markup.link_at(24).map(|l| l.url.as_str()), Some("https://x.com")); assert_eq!(markup.link_at(25), None); @@ -364,7 +399,7 @@ mod tests { marked(text), "Read [u:https://example.com/a_b(c)]. Or [u:http://x.org], or [f:<][u:https://y.net][f:>]." ); - let markup = Markup::new(&Rope::from_str(text)); + let markup = Markup::new(&Rope::from_str(text), Styling::Full); let urls: Vec<&str> = markup.links.iter().map(|l| l.url.as_str()).collect(); assert_eq!(urls, ["https://example.com/a_b(c)", "http://x.org", "https://y.net"]); // Not in code. @@ -379,7 +414,7 @@ mod tests { #[test] fn styles_cover_a_range_exactly() { - let markup = Markup::new(&Rope::from_str("ab **cd** ef")); + let markup = Markup::new(&Rope::from_str("ab **cd** ef"), Styling::Full); let pieces: Vec> = markup.styles(1..6).into_iter().map(|p| p.0).collect(); assert_eq!(pieces, [1..3, 3..5, 5..6]); assert_eq!(Markup::default().styles(0..3), [(0..3, Style::new())]); @@ -397,7 +432,7 @@ mod tests { ); let rope = Rope::from_str(&text); let start = std::time::Instant::now(); - let markup = Markup::new(&rope); + let markup = Markup::new(&rope, Styling::Full); // ~260 KB. Generous, for slow CI machines and unoptimised builds. assert!(start.elapsed() < std::time::Duration::from_millis(500), "{:?}", start.elapsed()); assert_eq!(markup.links.len(), 2000); diff --git a/src/tui/editor/mod.rs b/src/tui/editor/mod.rs index 122b538..d1a50ca 100644 --- a/src/tui/editor/mod.rs +++ b/src/tui/editor/mod.rs @@ -6,6 +6,7 @@ mod browser; mod buffer; mod clipboard; mod history; +mod lists; mod markdown; mod undo; mod wrap; @@ -33,7 +34,9 @@ use super::list::display_title; use super::menu::{self, Menu, item}; use super::widgets::truncate; use crate::files::{self, front_matter}; -use crate::vault::{ExportSettings, Post, Settings, TITLE_PREFIX, Version, title_from_first_line}; +use crate::vault::{ + ExportSettings, Post, Settings, Styling, TITLE_PREFIX, Version, title_from_first_line, +}; use autosave::Autosave; use buffer::Buffer; use clipboard::Clipboard; @@ -166,6 +169,8 @@ pub struct Editor { context: Option>, /// How wide the text is, at most (from Settings). line_width: usize, + /// How much of the markdown is formatted (from Settings). + styling: Styling, /// The author's name, for the front matter of exports (from Settings). author: String, /// The post's history, shown instead of the post while open. @@ -222,6 +227,7 @@ impl Editor { menu: None, context: None, line_width: settings.line_width as usize, + styling: settings.styling, author: settings.author, history: None, hints: HintBar::default(), @@ -286,6 +292,7 @@ impl Editor { versions, self.changed_since_version(), self.line_width, + self.styling, )); } @@ -478,6 +485,16 @@ impl Editor { } pub fn handle(&mut self, event: Event) -> Action { + let action = self.handle_event(event); + // What's selected is on the clipboard (once a drag is over), for + // the terminal's own copy key. + if !self.dragging { + self.clipboard.select(self.buffer.selected_text().as_deref()); + } + action + } + + fn handle_event(&mut self, event: Event) -> Action { if let Some(history) = &mut self.history { return match history.handle(event) { Outcome::None => Action::None, @@ -554,7 +571,7 @@ impl Editor { match event { Event::Key(key) => return self.key(key), Event::Mouse(mouse) => return self.mouse(mouse), - Event::Paste(text) => self.edit(Kind::Other, |b| b.paste(&text)), + Event::Paste(text) => self.paste(text), _ => {} } Action::None @@ -635,8 +652,23 @@ impl Editor { let kind = Kind::Type { space: ch.is_whitespace() }; self.edit(kind, |b| b.insert(ch.encode_utf8(&mut [0; 4]))) } - KeyCode::Enter => self.edit(Kind::Other, |b| b.insert("\n")), - KeyCode::Tab => self.edit(Kind::Other, |b| b.insert(TAB)), + // Shift+Enter (or Option+Enter) starts a plain line, even in a + // list. + KeyCode::Enter if shift || alt => self.edit(Kind::Other, |b| b.insert("\n")), + KeyCode::Enter => self.edit(Kind::Other, Buffer::newline), + // At the start of a list item, Tab and Shift+Tab nest it and + // bring it back out. + KeyCode::BackTab => self.edit(Kind::Other, |b| { + b.indent_item(false); + }), + KeyCode::Tab if shift => self.edit(Kind::Other, |b| { + b.indent_item(false); + }), + KeyCode::Tab => self.edit(Kind::Other, |b| { + if !b.indent_item(true) { + b.insert(TAB) + } + }), KeyCode::Backspace => self.edit(Kind::Backspace, Buffer::backspace), KeyCode::Delete => self.edit(Kind::Delete, Buffer::delete), // Like Home and End, then on by sentence or row. @@ -776,7 +808,7 @@ impl Editor { Cmd::Paste => { let text = self.clipboard.paste(); if !text.is_empty() { - self.edit(Kind::Other, |b| b.paste(&text)); + self.paste(text); } } Cmd::Deselect => self.buffer.clear_selection(), @@ -979,12 +1011,27 @@ impl Editor { }); } + /// Paste `text` at the cursor, or over the selection. + fn paste(&mut self, text: String) { + // The selection is on the clipboard, so that's what the terminal + // pastes: put what was there before it instead. + let text = match self.buffer.selection() { + Some(_) => self.clipboard.over_selection(text), + None => text, + }; + self.edit(Kind::Other, |b| b.paste(&text)); + } + /// Change the text (recording it for undo), then re-wrap and scroll to /// the cursor. fn edit(&mut self, kind: Kind, f: impl FnOnce(&mut Buffer)) { let before = self.buffer.clone(); // Replacing a selection is a step of its own. let replacing = before.selection().is_some(); + if replacing { + // It wasn't selected to be copied. + self.clipboard.restore(); + } let kind = if replacing { Kind::Other } else { kind }; f(&mut self.buffer); // Every edit changes the length, unless it replaced a selection; if @@ -1111,10 +1158,12 @@ impl Editor { self.dragging = true; } 2 => { + self.dragging = false; let word = self.buffer.word_at(pos); self.buffer.select(word.start, word.end); } _ => { + self.dragging = false; let line = self.buffer.line_at(pos); self.buffer.select(line.start, line.end); } @@ -1165,7 +1214,7 @@ impl Editor { fn refresh(&mut self) { if self.stale { self.rows = layout(self.buffer.rope(), self.width); - self.markup = Markup::new(self.buffer.rope()); + self.markup = Markup::new(self.buffer.rope(), self.styling); self.stale = false; } } @@ -1462,6 +1511,63 @@ mod tests { assert_eq!(h.editor.text(), "# Title\n "); } + #[test] + fn enter_carries_lists_on_and_an_empty_item_ends_them() { + let mut h = Harness::new("# Title\n"); + h.typed("- milk\neggs\n\n\nThen:\n1. [ ] call\nwrite\n\n"); + assert_eq!( + h.editor.text(), + "# Title\n- milk\n- eggs\n\nThen:\n1. [ ] call\n2. [ ] write\n" + ); + // Undo takes back the ending as one step. + h.ctrl('z'); + assert_eq!( + h.editor.text(), + "# Title\n- milk\n- eggs\n\nThen:\n1. [ ] call\n2. [ ] write\n3. [ ] " + ); + // Enter mid-item splits it; before the bullet, it's a plain line. + let mut h = Harness::new("# Title\n> - one two"); + for _ in 0..4 { + h.key(KeyCode::Left); + } + h.key(KeyCode::Enter); + assert_eq!(h.editor.text(), "# Title\n> - one\n> - two"); + h.key(KeyCode::Home); + h.key(KeyCode::Enter); + assert_eq!(h.editor.text(), "# Title\n> - one\n\n> - two"); + // Shift+Enter is always a plain line. + let mut h = Harness::new("# Title\n- one"); + let shift = + KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::SHIFT, KeyEventKind::Press); + h.editor.handle(Event::Key(shift)); + assert_eq!(h.editor.text(), "# Title\n- one\n"); + } + + #[test] + fn tab_and_shift_tab_nest_list_items() { + let mut h = Harness::new("# Title\n- one\n"); + h.typed("- two\n"); + h.key(KeyCode::Tab); + h.typed("nested\n"); + assert_eq!(h.editor.text(), "# Title\n- one\n- two\n - nested\n - "); + // Enter on an empty nested item brings it out a level. + h.key(KeyCode::Enter); + h.typed("three"); + assert_eq!(h.editor.text(), "# Title\n- one\n- two\n - nested\n- three"); + // At the start of the item (or in its bullet), Tab nests it, and + // Shift+Tab brings it out; the cursor stays put in the text. + h.key(KeyCode::Home); + h.key(KeyCode::Tab); + assert_eq!(h.editor.text(), "# Title\n- one\n- two\n - nested\n - three"); + h.key(KeyCode::BackTab); + assert_eq!(h.editor.text(), "# Title\n- one\n- two\n - nested\n- three"); + // Elsewhere, Tab types spaces and Shift+Tab does nothing. + h.key(KeyCode::End); + h.key(KeyCode::Tab); + h.key(KeyCode::BackTab); + assert_eq!(h.editor.text(), "# Title\n- one\n- two\n - nested\n- three "); + } + #[test] fn the_title_prefix_stays() { let mut h = Harness::new("# Hi\nthere"); @@ -2012,6 +2118,35 @@ mod tests { )); } + #[test] + fn selected_text_is_on_the_clipboard_for_the_terminals_copy_key() { + let mut h = Harness::new("# Title\nhello world"); + h.editor.clipboard.copy("a link"); + let click = MouseEventKind::Down(MouseButton::Left); + // Double-click "world": it's on the clipboard, for Cmd+C. + h.mouse(click, LEFT + 8, 2); + h.mouse(click, LEFT + 8, 2); + assert_eq!(h.editor.clipboard.paste(), "world"); + // Cmd+V over it (the terminal pastes the clipboard): it's replaced + // by what was copied before, which is back on the clipboard. + h.editor.handle(Event::Paste("world".into())); + assert_eq!(h.editor.text(), "# Title\nhello a link"); + assert_eq!(h.editor.clipboard.paste(), "a link"); + // Selected, then left alone: it stays on the clipboard. + h.mouse(click, LEFT + 2, 3); + h.mouse(click, LEFT + 2, 2); + h.mouse(click, LEFT + 2, 2); + h.key(KeyCode::Right); + assert_eq!(h.editor.clipboard.paste(), "hello"); + // Selected and typed over: what was there comes back. + h.mouse(click, LEFT + 8, 2); + h.mouse(click, LEFT + 8, 2); + assert_eq!(h.editor.clipboard.paste(), "link"); + h.typed("note"); + assert_eq!(h.editor.text(), "# Title\nhello a note"); + assert_eq!(h.editor.clipboard.paste(), "hello"); + } + #[test] fn copy_as_markdown_copies_the_whole_post() { let mut h = Harness::new("# Title\nSome **bold**"); diff --git a/src/tui/settings.rs b/src/tui/settings.rs index cfad025..f6f976d 100644 --- a/src/tui/settings.rs +++ b/src/tui/settings.rs @@ -1,27 +1,35 @@ //! The Settings screen: a few things to set, kept in the vault (but for -//! Touch ID, which belongs to the Mac). Each row shows a setting's value; -//! Enter (or a click) changes it in a dialog, or switches it on or off. +//! Touch ID, which belongs to the Mac). Each setting shows its value (or +//! all its options, the current one highlighted), and below it, faded, +//! what that value does; lines separate them, and a bar on the left marks +//! the selected one. Enter (or a click) changes it in a dialog, or switches +//! it to its next option; ←/→ or a click on an option picks one. use ratatui::Frame; use ratatui::crossterm::event::{Event, KeyCode, MouseButton, MouseEventKind}; use ratatui::layout::{Constraint, Layout, Position, Rect}; -use ratatui::style::{Style, Stylize}; +use ratatui::style::Stylize; use ratatui::text::{Line, Span}; -use ratatui::widgets::{List as ListWidget, ListItem, ListState, Paragraph}; +use ratatui::widgets::{ListState, Paragraph}; use super::Action; use super::dialog::{Button, Dialog}; use super::hints::{HintBar, hint}; use super::widgets::{COLUMN_WIDTH, column, truncate}; -use crate::vault::{DEFAULT_LINE_WIDTH, LINE_WIDTHS, Settings as Values}; +use crate::vault::{DEFAULT_LINE_WIDTH, LINE_WIDTHS, Settings as Values, Styling}; /// The longest author's name. const MAX_AUTHOR: usize = 80; +/// Screen rows per setting: its name and value, what it does, and the line +/// below it. +const ROWS_EACH: u16 = 3; + #[derive(Clone, Copy, PartialEq)] enum Field { Author, LineWidth, + Styling, NerdFont, TouchId, } @@ -46,12 +54,20 @@ pub struct Settings { notice: Option, error: Option, rows_area: Rect, + /// Where each option was drawn: the field's index, and the option's. + option_hits: Vec<(Rect, usize, usize)>, hints: HintBar, } +/// The bar beside the selected setting. +const BAR: &str = "┃"; + +/// Markdown styling's options, in order. +const STYLINGS: [Styling; 3] = [Styling::Full, Styling::Minimal, Styling::Off]; + impl Settings { pub fn new(values: Values, touch_id: Option) -> Self { - let mut fields = vec![Field::Author, Field::LineWidth, Field::NerdFont]; + let mut fields = vec![Field::Author, Field::LineWidth, Field::Styling, Field::NerdFont]; if touch_id.is_some() { fields.push(Field::TouchId); } @@ -64,6 +80,7 @@ impl Settings { notice: None, error: None, rows_area: Rect::default(), + option_hits: Vec::new(), hints: HintBar::default(), } } @@ -88,9 +105,88 @@ impl Settings { self.fields[self.state.selected().unwrap_or(0).min(self.fields.len() - 1)] } - /// Fields switched on or off, rather than changed in a dialog. - fn switched(field: Field) -> bool { - matches!(field, Field::NerdFont | Field::TouchId) + fn name(field: Field) -> &'static str { + match field { + Field::Author => "Author", + Field::LineWidth => "Line width", + Field::Styling => "Markdown styling", + Field::NerdFont => "Nerd Font", + Field::TouchId => "Touch ID", + } + } + + /// The options of a field that's switched rather than changed in a + /// dialog, and which one it's on. + fn options(&self, field: Field) -> Option<(Vec<&'static str>, usize)> { + let on_off = |on: bool| (vec!["on", "off"], if on { 0 } else { 1 }); + match field { + Field::Author | Field::LineWidth => None, + Field::Styling => Some(( + STYLINGS.iter().map(|s| s.name()).collect(), + STYLINGS.iter().position(|&s| s == self.values.styling).unwrap_or(0), + )), + Field::NerdFont => Some(on_off(self.values.nerd_font)), + Field::TouchId => Some(on_off(self.touch_id == Some(true))), + } + } + + /// The value of a field changed in a dialog. + fn value(&self, field: Field) -> Span<'static> { + match field { + Field::Author if self.values.author.is_empty() => Span::raw("none").dim().italic(), + Field::Author => Span::raw(self.values.author.clone()), + _ => Span::raw(format!("{} characters", self.values.line_width)), + } + } + + /// Switch the selected field to option `i`. + fn choose(&mut self, i: usize) -> Action { + let field = self.field(); + if self.options(field).is_none_or(|(_, current)| current == i) { + return Action::None; + } + let mut values = self.values.clone(); + match field { + Field::Styling => values.styling = STYLINGS[i.min(STYLINGS.len() - 1)], + Field::NerdFont => values.nerd_font = i == 0, + Field::TouchId => return Action::SetTouchId(i == 0), + Field::Author | Field::LineWidth => {} + } + self.save(values) + } + + /// ←/→: the option before or after the current one. + fn step_option(&mut self, delta: isize) -> Action { + let Some((options, current)) = self.options(self.field()) else { return Action::None }; + let i = (current as isize + delta).clamp(0, options.len() as isize - 1); + self.choose(i as usize) + } + + /// What a field's value does, now. + fn describe(&self, field: Field) -> String { + match field { + Field::Author if self.values.author.is_empty() => { + "Exports leave the author out of their front matter.".into() + } + Field::Author => "Exports suggest you as the author in their front matter.".into(), + Field::LineWidth => { + format!("Text is at most {} characters wide as you write.", self.values.line_width) + } + Field::Styling => match self.values.styling { + Styling::Full => "Bold, italics and code are formatted; markdown symbols faded.", + Styling::Minimal => "No bold: just italics, green code and faded markdown symbols.", + Styling::Off => "Markdown shows as plain text, with no formatting.", + } + .into(), + Field::NerdFont if self.values.nerd_font => { + "Uses the font's icons, like the search field's magnifier.".into() + } + Field::NerdFont => "No icons. Switch it on if your font is a Nerd Font.".into(), + Field::TouchId if self.touch_id == Some(true) => { + "Unlocks with your fingerprint when writui starts.".into() + } + Field::TouchId => "Unlocking takes the password.".into(), + } } pub fn render(&mut self, frame: &mut Frame) { @@ -107,41 +203,77 @@ impl Settings { frame.render_widget(Paragraph::new("Settings".bold()), column(header, COLUMN_WIDTH)); let col = column(rows, COLUMN_WIDTH); + // The bar goes left of the column, where there's room; otherwise + // the settings move over for it. + let (bar_x, col) = match col.x.checked_sub(2) { + Some(x) => (x, col), + None => (col.x, Rect { x: col.x + 2, width: col.width.saturating_sub(2), ..col }), + }; let width = col.width as usize; - let on_off = |on| Span::raw(if on { "on" } else { "off" }); - let items: Vec = self - .fields - .iter() - .map(|field| { - let (name, value) = match field { - Field::Author if self.values.author.is_empty() => { - ("Author", Span::raw("none").dim().italic()) - } - Field::Author => ("Author", Span::raw(self.values.author.clone())), - Field::LineWidth => { - ("Line width", Span::raw(format!("{} characters", self.values.line_width))) + // Scroll, if the screen is too short for them all, to keep the + // selected one in view. + let fit = ((col.height + 1) / ROWS_EACH).max(1) as usize; + let selected = self.state.selected().unwrap_or(0); + let first = + (*self.state.offset_mut()).min(selected).max((selected + 1).saturating_sub(fit)); + *self.state.offset_mut() = first; + self.option_hits.clear(); + let mut y = col.y; + for (i, &field) in self.fields.iter().enumerate().skip(first) { + if y >= col.bottom() { + break; + } + if i > first { + let rule = "─".repeat(width).dim(); + frame.render_widget(Paragraph::new(rule), Rect { y, height: 1, ..col }); + y += 1; + } + let name = Self::name(field); + // On the right: the options, the current one highlighted (each + // with a space either side, so the highlight has some room), or + // the value. + let value: Vec = match self.options(field) { + Some((options, current)) => { + let mut spans = Vec::new(); + let total: usize = + options.iter().map(|o| o.len() + 2).sum::() + options.len() - 1; + let mut x = col.right().saturating_sub(total as u16); + for (n, option) in options.iter().enumerate() { + if n > 0 { + spans.push("|".dim()); + x += 1; + } + let text = format!(" {option} "); + let w = text.len() as u16; + spans.push(if n == current { text.reversed() } else { text.dim() }); + self.option_hits.push((Rect::new(x, y, w, 1), i, n)); + x += w; } - Field::NerdFont => ("Nerd Font", on_off(self.values.nerd_font)), - Field::TouchId => ("Touch ID", on_off(self.touch_id == Some(true))), - }; - let value = Span::styled( - truncate(&value.content, width.saturating_sub(name.len() + 2)), - value.style, - ); - let pad = width.saturating_sub(name.len() + value.width()); - ListItem::new(Line::from(vec![ - Span::raw(name).bold(), - Span::raw(" ".repeat(pad)), - value, - ])) - }) - .collect(); - self.rows_area = Rect { height: (self.fields.len() as u16).min(col.height), ..col }; - frame.render_stateful_widget( - ListWidget::new(items).highlight_style(Style::new().reversed()), - self.rows_area, - &mut self.state, - ); + spans + } + None => { + let value = self.value(field); + let text = truncate(&value.content, width.saturating_sub(name.len() + 2)); + vec![Span::styled(text, value.style)] + } + }; + let used: usize = name.len() + value.iter().map(Span::width).sum::(); + let mut first_line = + vec![Span::raw(name).bold(), Span::raw(" ".repeat(width.saturating_sub(used)))]; + first_line.extend(value); + let lines = vec![ + Line::from(first_line), + Line::from(truncate(&self.describe(field), width).dim()), + ]; + let area = Rect { y, height: 2, ..col }.intersection(col); + frame.render_widget(Paragraph::new(lines), area); + if i == selected { + let bar = Paragraph::new(vec![Line::from(BAR), Line::from(BAR)]); + frame.render_widget(bar, Rect { x: bar_x, width: 1, ..area }); + } + y += 2; + } + self.rows_area = Rect { height: y - col.y, ..col }; let message = match (&self.error, &self.notice) { (Some(error), _) => Some(truncate(error, COLUMN_WIDTH as usize).red()), @@ -158,16 +290,14 @@ impl Settings { self.hints.render(frame, hint_area, &hints); return; } - let change = if Self::switched(self.field()) { "switch" } else { "change" }; - self.hints.render( - frame, - hint_area, - &[ - hint("Enter", change, Cmd::Change), - hint("Esc", "back", Cmd::Back), - hint("Ctrl+O", "lock", Cmd::Lock), - ], - ); + let mut hints = match self.options(self.field()) { + Some(_) => { + vec![hint("←/→", "choose", Cmd::Change), hint("Enter", "switch", Cmd::Change)] + } + None => vec![hint("Enter", "change", Cmd::Change)], + }; + hints.extend([hint("Esc", "back", Cmd::Back), hint("Ctrl+O", "lock", Cmd::Lock)]); + self.hints.render(frame, hint_area, &hints); } pub fn handle(&mut self, event: Event) -> Action { @@ -190,6 +320,8 @@ impl Settings { KeyCode::Esc => self.run(Cmd::Back), KeyCode::Up => self.move_by(-1), KeyCode::Down => self.move_by(1), + KeyCode::Left => self.step_option(-1), + KeyCode::Right => self.step_option(1), _ => Action::None, }, Event::Mouse(mouse) => match mouse.kind { @@ -199,10 +331,25 @@ impl Settings { if let Some(cmd) = self.hints.hit(mouse.column, mouse.row) { return self.run(cmd); } + let at = Position::new(mouse.column, mouse.row); + let option = self.option_hits.iter().find(|(rect, ..)| rect.contains(at)); + if let Some(&(_, field, option)) = option { + self.state.select(Some(field)); + return self.choose(option); + } if !self.rows_area.contains(Position::new(mouse.column, mouse.row)) { return Action::None; } - self.state.select(Some((mouse.row - self.rows_area.y) as usize)); + // Not on a line between them. + let row = mouse.row - self.rows_area.y; + if row % ROWS_EACH == ROWS_EACH - 1 { + return Action::None; + } + let index = self.state.offset() + (row / ROWS_EACH) as usize; + if index >= self.fields.len() { + return Action::None; + } + self.state.select(Some(index)); self.run(Cmd::Change) } _ => Action::None, @@ -222,19 +369,14 @@ impl Settings { match cmd { Cmd::Back => Action::BackToList(None), Cmd::Lock => Action::Lock, - // On or off: no need to ask. - Cmd::Change if self.field() == Field::NerdFont => { - let mut values = self.values.clone(); - values.nerd_font = !values.nerd_font; - self.save(values) - } - Cmd::Change if self.field() == Field::TouchId => { - Action::SetTouchId(self.touch_id != Some(true)) - } - Cmd::Change => { - self.dialog = Some(self.dialog_for(self.field())); - Action::None - } + Cmd::Change => match self.options(self.field()) { + // A few options: no need to ask, just go to the next. + Some((options, current)) => self.choose((current + 1) % options.len()), + None => { + self.dialog = Some(self.dialog_for(self.field())); + Action::None + } + }, Cmd::Cancel => { self.dialog = None; Action::None @@ -254,7 +396,7 @@ impl Settings { return Action::None; } }, - Field::NerdFont | Field::TouchId => {} + Field::Styling | Field::NerdFont | Field::TouchId => {} } self.save(values) } @@ -310,7 +452,9 @@ impl Settings { .with_input("68", 3) .with_text(&self.values.line_width.to_string()) } - Field::NerdFont | Field::TouchId => unreachable!("switched on or off, not asked"), + Field::Styling | Field::NerdFont | Field::TouchId => { + unreachable!("switched, not asked") + } } } } @@ -376,6 +520,21 @@ mod tests { panic!("not saved") }; assert_eq!((values.author.as_str(), values.line_width), ("Ada", 80)); + // Markdown styling: Enter switches to the next of three, no dialog, + // and what it does shows below. + press(&mut settings, KeyCode::Down); + let text = screen(&mut settings); + assert!(text.contains("Markdown styling") && text.contains("full"), "{text}"); + assert!(text.contains("Bold, italics and code are formatted"), "{text}"); + let mut seen = Vec::new(); + for _ in 0..3 { + let Action::SaveSettings(values) = press(&mut settings, KeyCode::Enter) else { + panic!("not saved") + }; + assert!(settings.dialog.is_none()); + seen.push(values.styling); + } + assert_eq!(seen, [Styling::Minimal, Styling::Off, Styling::Full]); // Nerd Font: Enter switches it, no dialog. press(&mut settings, KeyCode::Down); let text = screen(&mut settings); @@ -392,11 +551,84 @@ mod tests { assert!(matches!(press(&mut settings, KeyCode::Esc), Action::BackToList(None))); } + #[test] + fn each_setting_says_what_it_does_with_lines_between_and_clicks_change_them() { + let mut settings = Settings::new(Values::default(), None); + let text = screen(&mut settings); + let rows: Vec<&str> = + text.lines().map(|r| r.trim().trim_start_matches(BAR).trim()).collect(); + let author = rows.iter().position(|r| r.starts_with("Author")).unwrap(); + assert!(rows[author + 1].starts_with("Exports leave the author out"), "{text}"); + assert!(rows[author + 2].starts_with("────"), "{text}"); + assert!(rows[author + 3].starts_with("Line width"), "{text}"); + // A click on the styling setting's description switches it; one on + // a line between settings does nothing. + let y = (author + 7) as u16; + let click = |settings: &mut Settings, row| { + settings.handle(Event::Mouse(ratatui::crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 20, + row, + modifiers: KeyModifiers::NONE, + })) + }; + assert!(matches!(click(&mut settings, y - 2), Action::None)); + let Action::SaveSettings(values) = click(&mut settings, y) else { panic!("not saved") }; + assert_eq!(values.styling, Styling::Minimal); + assert!(screen(&mut settings).contains("No bold: just italics")); + } + + #[test] + fn options_show_side_by_side_and_can_be_picked() { + let mut settings = Settings::new(Values::default(), None); + press(&mut settings, KeyCode::Down); + press(&mut settings, KeyCode::Down); + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal.draw(|frame| settings.render(frame)).unwrap(); + let buffer = terminal.backend().buffer().clone(); + let row = |y: u16| (0..80).map(|x| buffer[(x, y)].symbol()).collect::(); + // The cell where `text` starts on row `y`. + let at = |y: u16, text: &str| { + let row = row(y); + row[..row.find(text).unwrap()].chars().count() as u16 + }; + let y = (0..24).find(|&y| row(y).contains("Markdown styling")).unwrap(); + assert!(row(y).trim_end().ends_with(" full | minimal | off"), "{}", row(y)); + // The current option is highlighted, the others faded; a bar marks + // the selected setting, and nothing else is reversed. + let x = at(y, " full ") + 1; + assert!(buffer[(x, y)].modifier.contains(ratatui::style::Modifier::REVERSED)); + assert!(buffer[(x + 7, y)].modifier.contains(ratatui::style::Modifier::DIM)); + let name = at(y, "Markdown"); + assert!(!buffer[(name, y)].modifier.contains(ratatui::style::Modifier::REVERSED)); + assert_eq!(buffer[(name - 2, y)].symbol(), BAR); + assert_eq!(buffer[(name - 2, y + 1)].symbol(), BAR); + assert_ne!(buffer[(name - 2, y - 3)].symbol(), BAR); + // ←/→ pick the option before or after; past the ends, nothing. + let Action::SaveSettings(values) = press(&mut settings, KeyCode::Right) else { + panic!("not saved") + }; + assert_eq!(values.styling, Styling::Minimal); + press(&mut settings, KeyCode::Right); + assert!(matches!(press(&mut settings, KeyCode::Right), Action::None)); + // A click on an option picks it, on any setting. + let x = at(y + 3, " on ") + 1; + let click = settings.handle(Event::Mouse(ratatui::crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: x, + row: y + 3, + modifiers: KeyModifiers::NONE, + })); + let Action::SaveSettings(values) = click else { panic!("not saved") }; + assert!(values.nerd_font); + assert!(settings.field() == Field::NerdFont); + } + #[test] fn touch_id_is_switched_where_the_mac_has_it() { assert!(!screen(&mut Settings::new(Values::default(), None)).contains("Touch ID")); let mut settings = Settings::new(Values::default(), Some(false)); - for _ in 0..3 { + for _ in 0..4 { press(&mut settings, KeyCode::Down); } assert!(screen(&mut settings).contains("Touch ID")); @@ -404,6 +636,7 @@ mod tests { settings.touch_id_set(true); let text = screen(&mut settings); assert!(text.contains("Touch ID is on"), "{text}"); + assert!(text.contains("Unlocks with your fingerprint"), "{text}"); assert!(matches!(press(&mut settings, KeyCode::Enter), Action::SetTouchId(false))); } } diff --git a/src/vault/mod.rs b/src/vault/mod.rs index 382a91c..661f85b 100644 --- a/src/vault/mod.rs +++ b/src/vault/mod.rs @@ -120,11 +120,46 @@ pub struct Settings { pub line_width: u16, /// The terminal's font is a Nerd Font: use its icons. pub nerd_font: bool, + /// How much markdown is formatted in the editor. + pub styling: Styling, } impl Default for Settings { fn default() -> Self { - Settings { author: String::new(), line_width: DEFAULT_LINE_WIDTH, nerd_font: false } + Settings { + author: String::new(), + line_width: DEFAULT_LINE_WIDTH, + nerd_font: false, + styling: Styling::Full, + } + } +} + +/// How much markdown is formatted in the editor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Styling { + /// Plain text: no styles at all. + Off, + /// The markdown symbols faded, and nothing else. + Minimal, + /// Bold, italics, code and so on, with the symbols faded. + Full, +} + +impl Styling { + pub const ALL: [Styling; 3] = [Styling::Off, Styling::Minimal, Styling::Full]; + + /// As stored in the vault (and shown in Settings). + pub fn name(self) -> &'static str { + match self { + Styling::Off => "off", + Styling::Minimal => "minimal", + Styling::Full => "full", + } + } + + fn from_name(name: &str) -> Option { + Styling::ALL.into_iter().find(|s| s.name() == name) } } @@ -487,10 +522,15 @@ impl Vault { .filter(|width| LINE_WIDTHS.contains(width)) .unwrap_or(defaults.line_width); let nerd_font = self.value("nerd_font")?.map_or(defaults.nerd_font, |value| value == "on"); + let styling = self + .value("markdown_styling")? + .and_then(|value| Styling::from_name(&value)) + .unwrap_or(defaults.styling); Ok(Settings { author: self.value("author")?.unwrap_or(defaults.author), line_width, nerd_font, + styling, }) } @@ -499,6 +539,7 @@ impl Vault { self.set_value("author", &settings.author)?; self.set_value("line_width", &settings.line_width.to_string())?; self.set_value("nerd_font", if settings.nerd_font { "on" } else { "off" })?; + self.set_value("markdown_styling", settings.styling.name())?; tx.commit()?; Ok(()) } @@ -949,7 +990,12 @@ mod tests { let vault = Vault::create(&path, "pw").unwrap(); assert_eq!(vault.settings().unwrap(), Settings::default()); assert_eq!(vault.last_screen().unwrap(), None); - let settings = Settings { author: "Ada".into(), line_width: 80, nerd_font: true }; + let settings = Settings { + author: "Ada".into(), + line_width: 80, + nerd_font: true, + styling: Styling::Minimal, + }; vault.set_settings(&settings).unwrap(); vault.set_last_screen("post:3").unwrap(); vault.set_list_sort("title").unwrap();