Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
11 changes: 7 additions & 4 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -742,17 +742,20 @@ 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
follows Settings on later exports unless it was changed by hand in the
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
Expand Down
5 changes: 3 additions & 2 deletions site/content/_index.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
36 changes: 32 additions & 4 deletions src/tui/editor/markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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() {
Expand Down Expand Up @@ -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<Range<usize>>,
bold_title: bool,
}

impl Styler {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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");
Expand Down
5 changes: 4 additions & 1 deletion src/tui/editor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ pub struct Editor {
context: Option<ContextMenu<Cmd>>,
/// 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.
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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;
}
}
Expand Down
26 changes: 21 additions & 5 deletions src/tui/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const MAX_AUTHOR: usize = 80;
enum Field {
Author,
LineWidth,
BoldTitle,
NerdFont,
TouchId,
}
Expand Down Expand Up @@ -51,7 +52,7 @@ pub struct Settings {

impl Settings {
pub fn new(values: Values, touch_id: Option<bool>) -> 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);
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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))),
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -254,7 +261,7 @@ impl Settings {
return Action::None;
}
},
Field::NerdFont | Field::TouchId => {}
Field::BoldTitle | Field::NerdFont | Field::TouchId => {}
}
self.save(values)
}
Expand Down Expand Up @@ -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")
}
}
}
}
Expand Down Expand Up @@ -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);
Expand All @@ -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"));
Expand Down
16 changes: 14 additions & 2 deletions src/vault/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
}

Expand Down Expand Up @@ -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,
})
}

Expand All @@ -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(())
}
Expand Down Expand Up @@ -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();
Expand Down
Loading