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..6b560ca 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ 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. The title can be plain instead of bold in Settings. - **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,8 +197,9 @@ 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 - terminal's font is a [Nerd Font](https://www.nerdfonts.com), so writui + writing (68 characters unless you change it), whether the post's title + is bold, and whether your terminal's font is a + [Nerd Font](https://www.nerdfonts.com), so writui can use its icons. ### Cmd keys on a Mac diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 314aa81..fcb7b38 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -726,3 +726,14 @@ 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. + +## Let writers turn off bold post titles + +On 2026-10-17, the first returning external writer reported that the bold +title was uncomfortably bright in iTerm2's dark theme. Terminal bold can be +shown with a brighter colour even when a bold font face is disabled. Keep the +existing appearance by default, but add a vault-stored **Bold title** switch +in Settings. Turning it off removes bold only from the post's first `#` +heading; later headings and explicit Markdown emphasis retain their styles. +An arbitrary brightness scalar would require nonstandard colour assumptions, +so a plain-title switch works with writui's standard terminal colours. diff --git a/docs/SPEC.md b/docs/SPEC.md index aedd646..aee9350 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -742,10 +742,10 @@ 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. +- 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 opens a dialog for + text fields or directly switches an on/off field. Esc goes back to the + list (its hint just says "back"). Changes are stored right away. - 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,6 +753,9 @@ 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. + - **Bold title**: on by default. Switch it off to show the post's first + `#` heading without bold; later headings and explicit `**bold**` stay + formatted. Enter or a click switches it, no dialog. - **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 diff --git a/site/content/_index.md b/site/content/_index.md index d4aa504..eb09262 100644 --- a/site/content/_index.md +++ b/site/content/_index.md @@ -1,8 +1,9 @@ +++ +++ -- **Just you and your words.** One calm column, in your terminal's colours - and font. Markdown formats as you type, with its marks still visible. +- **Just you and your words.** One calm column in your terminal's colours + and font. Markdown formats as you type, with visible marks and optional + plain titles. - **Pick up where you left off.** Your writing saves automatically. Reopen a post and your cursor is right where you left it. - **Keep your writing private.** Your posts stay on your computer in a diff --git a/src/tui/editor/markdown.rs b/src/tui/editor/markdown.rs index d8694ed..d2daf4a 100644 --- a/src/tui/editor/markdown.rs +++ b/src/tui/editor/markdown.rs @@ -14,7 +14,7 @@ use std::ops::Range; -use pulldown_cmark::{Event, Options, Parser, Tag}; +use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag}; use ratatui::style::Style; use ropey::Rope; @@ -36,9 +36,19 @@ pub struct Markup { impl Markup { pub fn new(rope: &Rope) -> Self { + Self::with_bold_title(rope, true) + } + + pub fn with_bold_title(rope: &Rope, bold_title: bool) -> Self { let text = rope.to_string(); - let mut styler = - Styler { runs: Vec::new(), links: Vec::new(), stack: Vec::new(), pos: 0, text: None }; + let mut styler = Styler { + runs: Vec::new(), + links: Vec::new(), + stack: Vec::new(), + pos: 0, + text: None, + bold_title, + }; let options = Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS | Options::ENABLE_TABLES; for (event, range) in Parser::new_ext(&text, options).into_offset_iter() { @@ -108,6 +118,7 @@ struct Styler { /// Plain text seen since the last other event, to look for web /// addresses in (the parser can split text into several pieces). text: Option>, + bold_title: bool, } impl Styler { @@ -119,6 +130,11 @@ impl Styler { Event::Start(tag) => { self.gap(text, range.start); let (style, link) = match tag { + Tag::Heading { level: HeadingLevel::H1, .. } + if range.start == 0 && !self.bold_title => + { + (Style::new(), None) + } Tag::Heading { .. } | Tag::Strong | Tag::TableHead => { (Style::new().bold(), None) } @@ -272,8 +288,12 @@ 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_bold_title(text, true) + } + + fn marked_with_bold_title(text: &str, bold_title: bool) -> String { let rope = Rope::from_str(text); - let markup = Markup::new(&rope); + let markup = Markup::with_bold_title(&rope, bold_title); let mut out = String::new(); for (range, style) in markup.styles(0..rope.len_chars()) { let piece = rope.slice(range).to_string(); @@ -309,6 +329,14 @@ mod tests { ); } + #[test] + fn plain_title_leaves_later_headings_and_explicit_emphasis_intact() { + assert_eq!( + marked_with_bold_title("# Title\n\n## Part two\n\n# Another\n\n**strong**", false), + "[f:#] Title\n\n[f:##] [b:Part two]\n\n[f:#] [b:Another]\n\n[f:**][b:strong][f:**]" + ); + } + #[test] fn bold_italic_and_struck_through() { assert_eq!(marked("a **b** c"), "a [f:**][b:b][f:**] c"); diff --git a/src/tui/editor/mod.rs b/src/tui/editor/mod.rs index 122b538..c226ec3 100644 --- a/src/tui/editor/mod.rs +++ b/src/tui/editor/mod.rs @@ -166,6 +166,8 @@ pub struct Editor { context: Option>, /// How wide the text is, at most (from Settings). line_width: usize, + /// Whether the post's title is bold (from Settings). + bold_title: bool, /// 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 +224,7 @@ impl Editor { menu: None, context: None, line_width: settings.line_width as usize, + bold_title: settings.bold_title, author: settings.author, history: None, hints: HintBar::default(), @@ -1165,7 +1168,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::with_bold_title(self.buffer.rope(), self.bold_title); self.stale = false; } } diff --git a/src/tui/settings.rs b/src/tui/settings.rs index cfad025..2556fa1 100644 --- a/src/tui/settings.rs +++ b/src/tui/settings.rs @@ -22,6 +22,7 @@ const MAX_AUTHOR: usize = 80; enum Field { Author, LineWidth, + BoldTitle, NerdFont, TouchId, } @@ -51,7 +52,7 @@ pub struct Settings { 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::BoldTitle, Field::NerdFont]; if touch_id.is_some() { fields.push(Field::TouchId); } @@ -90,7 +91,7 @@ impl Settings { /// Fields switched on or off, rather than changed in a dialog. fn switched(field: Field) -> bool { - matches!(field, Field::NerdFont | Field::TouchId) + matches!(field, Field::BoldTitle | Field::NerdFont | Field::TouchId) } pub fn render(&mut self, frame: &mut Frame) { @@ -121,6 +122,7 @@ impl Settings { Field::LineWidth => { ("Line width", Span::raw(format!("{} characters", self.values.line_width))) } + Field::BoldTitle => ("Bold title", on_off(self.values.bold_title)), Field::NerdFont => ("Nerd Font", on_off(self.values.nerd_font)), Field::TouchId => ("Touch ID", on_off(self.touch_id == Some(true))), }; @@ -223,6 +225,11 @@ impl Settings { Cmd::Back => Action::BackToList(None), Cmd::Lock => Action::Lock, // On or off: no need to ask. + Cmd::Change if self.field() == Field::BoldTitle => { + let mut values = self.values.clone(); + values.bold_title = !values.bold_title; + self.save(values) + } Cmd::Change if self.field() == Field::NerdFont => { let mut values = self.values.clone(); values.nerd_font = !values.nerd_font; @@ -254,7 +261,7 @@ impl Settings { return Action::None; } }, - Field::NerdFont | Field::TouchId => {} + Field::BoldTitle | Field::NerdFont | Field::TouchId => {} } self.save(values) } @@ -310,7 +317,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::BoldTitle | Field::NerdFont | Field::TouchId => { + unreachable!("switched on or off, not asked") + } } } } @@ -376,6 +385,13 @@ mod tests { panic!("not saved") }; assert_eq!((values.author.as_str(), values.line_width), ("Ada", 80)); + // Bold title: Enter switches it, no dialog. + press(&mut settings, KeyCode::Down); + assert!(screen(&mut settings).contains("Bold title")); + let Action::SaveSettings(values) = press(&mut settings, KeyCode::Enter) else { + panic!("not saved") + }; + assert!(!values.bold_title && settings.dialog.is_none()); // Nerd Font: Enter switches it, no dialog. press(&mut settings, KeyCode::Down); let text = screen(&mut settings); @@ -396,7 +412,7 @@ mod tests { 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")); diff --git a/src/vault/mod.rs b/src/vault/mod.rs index 382a91c..04fdaa2 100644 --- a/src/vault/mod.rs +++ b/src/vault/mod.rs @@ -120,11 +120,18 @@ pub struct Settings { pub line_width: u16, /// The terminal's font is a Nerd Font: use its icons. pub nerd_font: bool, + /// Render the post's first Markdown heading in bold. + pub bold_title: bool, } 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, + bold_title: true, + } } } @@ -487,10 +494,13 @@ 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 bold_title = + self.value("bold_title")?.map_or(defaults.bold_title, |value| value == "on"); Ok(Settings { author: self.value("author")?.unwrap_or(defaults.author), line_width, nerd_font, + bold_title, }) } @@ -499,6 +509,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("bold_title", if settings.bold_title { "on" } else { "off" })?; tx.commit()?; Ok(()) } @@ -949,7 +960,8 @@ 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, bold_title: false }; vault.set_settings(&settings).unwrap(); vault.set_last_screen("post:3").unwrap(); vault.set_list_sort("title").unwrap();