From 82ab5fd003a4897deea1b25c6e9ac1f99cb864b6 Mon Sep 17 00:00:00 2001 From: Justin Martin Date: Wed, 16 Sep 2026 20:04:44 -0500 Subject: [PATCH 1/8] feat(tui): add optional Nerd Font icons --- crates/outl-config/CLAUDE.md | 3 + crates/outl-config/src/lib.rs | 5 +- crates/outl-config/src/schema.rs | 25 ++++ .../outl-tui/src/actions/lifecycle/loading.rs | 6 +- crates/outl-tui/src/actions/lifecycle/mod.rs | 1 + crates/outl-tui/src/actions/reminders.rs | 6 +- crates/outl-tui/src/app.rs | 21 +++- crates/outl-tui/src/icons.rs | 109 ++++++++++++++++++ crates/outl-tui/src/lib.rs | 1 + crates/outl-tui/src/runtime.rs | 3 + crates/outl-tui/src/state.rs | 2 + crates/outl-tui/src/view/backlinks.rs | 2 +- crates/outl-tui/src/view/chrome.rs | 22 ++-- crates/outl-tui/src/view/inline.rs | 32 +++-- crates/outl-tui/src/view/namespace.rs | 2 +- crates/outl-tui/src/view/outline.rs | 20 ++-- crates/outl-tui/src/view/overlays.rs | 75 +++++++----- crates/outl-tui/src/view/sidebar.rs | 20 ++-- crates/outl-tui/src/view/toasts.rs | 7 +- crates/outl-tui/src/view/warnings_banner.rs | 3 +- docs/config.md | 1 + docs/tui.md | 5 + 22 files changed, 294 insertions(+), 77 deletions(-) create mode 100644 crates/outl-tui/src/icons.rs diff --git a/crates/outl-config/CLAUDE.md b/crates/outl-config/CLAUDE.md index 9d2da10c..6b6f0b80 100644 --- a/crates/outl-config/CLAUDE.md +++ b/crates/outl-config/CLAUDE.md @@ -68,6 +68,7 @@ transport = "iroh" # "iroh" (P2P, default) | "file" (iCloud/fs op relay_url = "" # optional; empty = outl's default relay (use1-1.relay.avelino.outl.iroh.link) [tui] +icons = "emoji" # "emoji" (default) | "nerd-font" mouse_capture = false # opt-in: enables mouse wheel + click + drag-to-copy in the TUI [display] @@ -94,6 +95,7 @@ It exists for environments where the OS clock lies about the zone — containers `SyncConfig::transport` is a [`SyncTransportKind`] enum (`File` | `Iroh`, serde `lowercase`); missing `[sync]` falls back to `Iroh` (P2P is outl's primary sync), and `transport = "file"` is the explicit iCloud/filesystem opt-out. `SyncConfig::relay_url()` treats an empty string as `None`, which the iroh transport resolves to outl's default relay (`use1-1.relay.avelino.outl.iroh.link`; see [`docs/relay.md`](../../docs/relay.md)). `TuiCfg::mouse_capture` (default `false`) is read by the TUI at boot in `runtime.rs` to decide whether to call `EnableMouseCapture` and listen for `Event::Mouse`; the desktop ignores this section entirely. +`TuiCfg::icons` (default `emoji`) is read by the TUI at boot in `runtime.rs`; `nerd-font` is an explicit opt-in for terminals with a Nerd Font installed. `DisplayCfg::backlinks_order` is a [`BacklinksOrder`] enum (`Newest` | `Oldest`, serde `lowercase`, default `Newest`) — a pure display preference, same "never converges between devices" policy as `theme.preset` (root `CLAUDE.md` invariant #7). `ThemeCfg` (RFC 0022) models a light/dark preset *pair*, not a single preset. `preset` is the light side, `preset_dark: Option` is the dark side, and `mode` is a [`ThemeMode`] enum (`Light` | `Dark` | `Auto`, serde `lowercase`, default `Auto`). @@ -146,6 +148,7 @@ If the field **must converge between devices**, it doesn't belong in TOML at all | `editor.font_size` | Desktop only | `crates/outl-desktop/src-tauri/src/settings.rs` | | `calendar.timezone` | Every client at boot, via `outl_actions::clock::init` (resolves the IANA name once into the process-wide clock) | `crates/outl-tui/src/runtime.rs`, `crates/outl-cli/src/main.rs`, `crates/outl-desktop/src-tauri/src/lib.rs`, `crates/outl-mobile/src-tauri/src/lib.rs` | | `sync.transport` / `sync.relay_url` | TUI peer-sync wiring | `crates/outl-tui/src/actions/lifecycle/peer_sync.rs::wire_sync_transport` (config-driven; replaces the `OUTL_IROH=1` env gate) | +| `tui.icons` | TUI chrome icon set | `crates/outl-tui/src/runtime.rs` | | `tui.mouse_capture` | TUI only | `crates/outl-tui/src/runtime.rs` (conditionally emits `EnableMouseCapture` and arms the `Event::Mouse` branch) | | `display.backlinks_order` | TUI at boot (`runtime.rs`, applied post-construction); GUI clients on every `build_page_view` call | `crates/outl-tui/src/runtime.rs`, `crates/outl-tauri-shared/src/helpers.rs::build_page_view` (desktop + mobile share this reader) | | `assets.max_bytes` | Every file-import path: CLI `outl asset add`, MCP `outl_asset_add`, desktop/mobile "Attach file" + drag-drop, TUI `/upload` + paste-a-path | `crates/outl-cli/src/cmd/asset.rs`, `crates/outl-tauri-shared/src/commands/asset.rs`, `crates/outl-tui/src/commands/builtins/asset.rs` + `crates/outl-tui/src/actions/paste.rs` (all route through `outl_actions::asset::import_asset(root, source, max_bytes)`) | diff --git a/crates/outl-config/src/lib.rs b/crates/outl-config/src/lib.rs index ee19377c..806dd8ae 100644 --- a/crates/outl-config/src/lib.rs +++ b/crates/outl-config/src/lib.rs @@ -36,6 +36,9 @@ //! transport = "iroh" # "iroh" (P2P, default) | "file" (iCloud/fs opt-out) //! relay_url = "" # optional; empty = outl's default relay (use1-1.relay.avelino.outl.iroh.link) //! +//! [tui] +//! icons = "emoji" # "emoji" (default) | "nerd-font" +//! //! [snapshot] //! enabled = true # default; long-lived clients write a snapshot periodically //! op_threshold = 10000 # write after this many applied ops @@ -66,7 +69,7 @@ pub use paths::{config_dir, config_path}; pub use schema::{ AssetsCfg, BacklinksOrder, BackupCfg, CalendarCfg, Config, DisplayCfg, EditorCfg, RemindersCfg, SnapshotCfg, StorageCfg, SyncConfig, SyncTransportKind, ThemeCfg, ThemeMode, TuiCfg, - WorkspaceCfg, + TuiIconStyle, WorkspaceCfg, }; use std::fs; diff --git a/crates/outl-config/src/schema.rs b/crates/outl-config/src/schema.rs index 2a7f26b3..aebb2ef4 100644 --- a/crates/outl-config/src/schema.rs +++ b/crates/outl-config/src/schema.rs @@ -145,6 +145,10 @@ pub struct DisplayCfg { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct TuiCfg { + /// Chrome icon set. Emoji is portable across ordinary terminal fonts; + /// Nerd Font glyphs are available as an explicit opt-in. + pub icons: TuiIconStyle, + /// Capture the mouse so the app owns selection: drag across blocks /// selects a range and copies it as clean markdown on release, the /// scroll wheel moves the selection, a click selects a block. @@ -159,6 +163,17 @@ pub struct TuiCfg { pub mouse_capture: bool, } +/// Icon set used by TUI chrome. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum TuiIconStyle { + /// Unicode emoji and symbols supported by ordinary terminal fonts. + #[default] + Emoji, + /// Font Awesome / Material Design glyphs from a Nerd Font. + NerdFont, +} + /// Workspace section — primarily where the desktop remembers the /// last opened directory so the next launch skips the picker. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -503,6 +518,7 @@ mod tests { assert!(c.snapshot.enabled); assert_eq!(c.snapshot.op_threshold, 10_000); assert_eq!(c.display.backlinks_order, BacklinksOrder::Newest); + assert_eq!(c.tui.icons, TuiIconStyle::Emoji); assert!(c.display.backlinks_order.newest_first()); assert!(c.backup.enabled, "backups default ON — see BackupCfg docs"); assert_eq!(c.backup.interval_minutes, 30); @@ -540,6 +556,15 @@ mod tests { assert_eq!(c.display.backlinks_order, BacklinksOrder::Newest); } + #[test] + fn tui_icon_style_parses_and_defaults_to_emoji() { + let c: Config = toml::from_str("[tui]\nicons = \"nerd-font\"\n").unwrap(); + assert_eq!(c.tui.icons, TuiIconStyle::NerdFont); + + let c: Config = toml::from_str("[theme]\npreset = \"nord\"\n").unwrap(); + assert_eq!(c.tui.icons, TuiIconStyle::Emoji); + } + #[test] fn partial_assets_section_keeps_other_defaults() { // A config with ONLY [assets] populated must leave every other diff --git a/crates/outl-tui/src/actions/lifecycle/loading.rs b/crates/outl-tui/src/actions/lifecycle/loading.rs index f2199745..f5425d01 100644 --- a/crates/outl-tui/src/actions/lifecycle/loading.rs +++ b/crates/outl-tui/src/actions/lifecycle/loading.rs @@ -128,9 +128,9 @@ impl App { // Untouched — slot has a non-chip status (save error etc.): // the user reads that first, the banner above the outline // stays as the persistent warning signal. - const CHIP_MARKER: &str = "⚠ "; + let chip_marker = format!("{} ", self.icons.warning); let chip_is_ours = - self.status.starts_with(CHIP_MARKER) && self.status.contains("outside outl dialect"); + self.status.starts_with(&chip_marker) && self.status.contains("outside outl dialect"); if self.parse_warnings.is_empty() { if chip_is_ours { self.status.clear(); @@ -138,7 +138,7 @@ impl App { } else if self.status.is_empty() || chip_is_ours { self.status = format!( "{}{} line(s) outside outl dialect — preserved (open :warnings to see)", - CHIP_MARKER, + chip_marker, self.parse_warnings.len() ); } diff --git a/crates/outl-tui/src/actions/lifecycle/mod.rs b/crates/outl-tui/src/actions/lifecycle/mod.rs index 83b50438..273bf9a3 100644 --- a/crates/outl-tui/src/actions/lifecycle/mod.rs +++ b/crates/outl-tui/src/actions/lifecycle/mod.rs @@ -51,6 +51,7 @@ impl App { ) -> Result { let orphans_log = workspace_root.join(".outl").join("orphans.log"); let mut s = Self { + icons: crate::icons::IconSet::default(), hlc: HlcGenerator::new(actor), workspace_root, workspace, diff --git a/crates/outl-tui/src/actions/reminders.rs b/crates/outl-tui/src/actions/reminders.rs index 8d148b88..690e329a 100644 --- a/crates/outl-tui/src/actions/reminders.rs +++ b/crates/outl-tui/src/actions/reminders.rs @@ -123,7 +123,11 @@ impl App { r.plain_text.clone() }; emit_osc9(&format!("outl · {body}")); - self.toast_for(ToastKind::Warning, format!("⏰ {body}"), REMINDER_TOAST_MS); + self.toast_for( + ToastKind::Warning, + format!("{} {body}", self.icons.bell), + REMINDER_TOAST_MS, + ); } } diff --git a/crates/outl-tui/src/app.rs b/crates/outl-tui/src/app.rs index e1876ab7..39095604 100644 --- a/crates/outl-tui/src/app.rs +++ b/crates/outl-tui/src/app.rs @@ -41,7 +41,12 @@ mod tests { #[test] fn pretty_render_strips_bold_markers() { - let spans = render_markdown_inline("a **brave** soul", &t(), &idx()); + let spans = render_markdown_inline( + "a **brave** soul", + &t(), + &idx(), + &crate::icons::IconSet::default(), + ); // The literal `**` must not appear in any span; the inner // word must. for s in &spans { @@ -52,7 +57,12 @@ mod tests { #[test] fn pretty_render_strips_page_ref_brackets() { - let spans = render_markdown_inline("see [[Avelino]] today", &t(), &idx()); + let spans = render_markdown_inline( + "see [[Avelino]] today", + &t(), + &idx(), + &crate::icons::IconSet::default(), + ); // `[[` / `]]` are gone; the bare name is present. for s in &spans { assert!(!s.content.contains("[[")); @@ -75,7 +85,12 @@ mod tests { // Regression for the "está" crash: the `á` is 2 bytes. let _ = highlight_inline("isso parece que está", &t()); let _ = highlight_inline("veja [[orçamento]] e #ação", &t()); - let _ = render_markdown_inline("isso parece que está", &t(), &idx()); + let _ = render_markdown_inline( + "isso parece que está", + &t(), + &idx(), + &crate::icons::IconSet::default(), + ); } #[test] diff --git a/crates/outl-tui/src/icons.rs b/crates/outl-tui/src/icons.rs new file mode 100644 index 00000000..362c4865 --- /dev/null +++ b/crates/outl-tui/src/icons.rs @@ -0,0 +1,109 @@ +//! Runtime-selected icons for TUI chrome. +//! +//! Emoji is the default because it works with ordinary terminal fonts. +//! Nerd Font glyphs are opt-in through `[tui] icons = "nerd-font"`. + +use outl_config::TuiIconStyle; + +/// Icons used by the TUI's own chrome and placeholders. +#[derive(Debug, Clone, Copy)] +pub(crate) struct IconSet { + pub(crate) calendar: &'static str, + pub(crate) file: &'static str, + pub(crate) image: &'static str, + pub(crate) clock: &'static str, + pub(crate) star: &'static str, + pub(crate) history: &'static str, + pub(crate) bolt: &'static str, + pub(crate) search: &'static str, + pub(crate) cog: &'static str, + pub(crate) paint_brush: &'static str, + pub(crate) warning: &'static str, + pub(crate) save: &'static str, + pub(crate) clipboard: &'static str, + pub(crate) moon: &'static str, + pub(crate) hashtag: &'static str, + pub(crate) bell: &'static str, +} + +impl IconSet { + pub(crate) fn new(style: TuiIconStyle) -> Self { + match style { + TuiIconStyle::Emoji => Self::emoji(), + TuiIconStyle::NerdFont => Self::nerd_font(), + } + } + + fn emoji() -> Self { + Self { + calendar: "📅", + file: "📄", + image: "🖼", + clock: "🕐", + star: "⭐", + history: "🕘", + bolt: "⚡", + search: "🔍", + cog: "⚙", + paint_brush: "🎨", + warning: "⚠", + save: "💾", + clipboard: "📋", + moon: "🌙", + hashtag: "#", + bell: "⏰", + } + } + + fn nerd_font() -> Self { + Self { + calendar: "\u{f073}", + file: "\u{f016}", + image: "\u{f03e}", + clock: "\u{f017}", + star: "\u{f005}", + history: "\u{f1da}", + bolt: "\u{f0e7}", + search: "\u{f002}", + cog: "\u{f013}", + paint_brush: "\u{f1fc}", + warning: "\u{f071}", + save: "\u{f0c7}", + clipboard: "\u{f0ea}", + moon: "\u{f186}", + hashtag: "\u{f292}", + bell: "\u{f0f3}", + } + } +} + +impl Default for IconSet { + fn default() -> Self { + Self::new(TuiIconStyle::default()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn emoji_is_the_default_and_contains_no_private_use_glyphs() { + let icons = IconSet::default(); + assert_eq!(icons.calendar, "📅"); + assert!(icons + .file + .chars() + .all(|ch| !(0xE000..=0xF8FF).contains(&(ch as u32)))); + } + + #[test] + fn nerd_font_is_explicit() { + let icons = IconSet::new(TuiIconStyle::NerdFont); + assert_eq!(icons.calendar, "\u{f073}"); + assert!(icons + .calendar + .chars() + .any(|ch| (0xE000..=0xF8FF).contains(&(ch as u32)))); + } +} diff --git a/crates/outl-tui/src/lib.rs b/crates/outl-tui/src/lib.rs index 818d3532..b304baee 100644 --- a/crates/outl-tui/src/lib.rs +++ b/crates/outl-tui/src/lib.rs @@ -26,6 +26,7 @@ pub mod commands; pub mod edit_buffer; pub mod editor; pub mod fuzzy; +mod icons; pub mod input; pub mod keymap; pub mod outline_ops; diff --git a/crates/outl-tui/src/runtime.rs b/crates/outl-tui/src/runtime.rs index d9979f5a..2b30f814 100644 --- a/crates/outl-tui/src/runtime.rs +++ b/crates/outl-tui/src/runtime.rs @@ -200,6 +200,7 @@ pub fn run_with_theme_override(path: &Path, theme_override: Option<&str>) -> Res theme, shared_workspace, backlinks_newest_first, + global_cfg.tui.icons, ); if enhanced_keys { @@ -415,8 +416,10 @@ fn event_loop( theme: Theme, shared_workspace: bool, backlinks_newest_first: bool, + icon_style: outl_config::TuiIconStyle, ) -> Result<()> { let mut app = App::new(workspace_root, workspace, actor, theme, shared_workspace)?; + app.icons = crate::icons::IconSet::new(icon_style); // Apply the persisted backlinks direction (issue #142); the field // only feeds the render path, so setting it post-construction is // enough and keeps it out of `App::new`'s already-long signature. diff --git a/crates/outl-tui/src/state.rs b/crates/outl-tui/src/state.rs index 916412e8..528121e8 100644 --- a/crates/outl-tui/src/state.rs +++ b/crates/outl-tui/src/state.rs @@ -10,6 +10,7 @@ //! reads or writes these types, but nothing outside `outl-tui` should. use crate::edit_buffer::EditBuffer; +use crate::icons::IconSet; use crate::theme::Theme; use chrono::NaiveDate; use outl_core::hlc::HlcGenerator; @@ -534,6 +535,7 @@ pub(crate) struct AutocompleteState { /// Application state. pub(crate) struct App { + pub(crate) icons: IconSet, pub(crate) workspace_root: PathBuf, pub(crate) workspace: Workspace, pub(crate) hlc: HlcGenerator, diff --git a/crates/outl-tui/src/view/backlinks.rs b/crates/outl-tui/src/view/backlinks.rs index f7c4f249..5da66173 100644 --- a/crates/outl-tui/src/view/backlinks.rs +++ b/crates/outl-tui/src/view/backlinks.rs @@ -93,7 +93,7 @@ pub(crate) fn render_backlinks_inline( } let header = match source_icon { Some(icon) => format!("{icon} {source_title}"), - None => format!("📄 {source_title}"), + None => format!("{} {source_title}", app.icons.file), }; out.push(Line::from(Span::styled(header, app.theme.heading))); prev_source = Some(source_slug.to_string()); diff --git a/crates/outl-tui/src/view/chrome.rs b/crates/outl-tui/src/view/chrome.rs index aeddd3a1..3621159c 100644 --- a/crates/outl-tui/src/view/chrome.rs +++ b/crates/outl-tui/src/view/chrome.rs @@ -16,6 +16,7 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; +use unicode_width::UnicodeWidthStr; /// Header (top, 3 lines): breadcrumb on the left, status chips on the /// right. Splits the row in half so chips never overlap the title even @@ -112,7 +113,7 @@ fn breadcrumb(app: &App) -> Line<'static> { fn view_icon_and_title(app: &App) -> (Option, String) { match &app.view { View::Journal(date) => ( - Some("📅".to_string()), + Some(app.icons.calendar.to_string()), format!("Journal · {}", date.format("%A, %Y-%m-%d")), ), View::Page(p) => { @@ -297,13 +298,13 @@ fn left_segments(app: &App) -> Line<'static> { fn right_segments(app: &App) -> Line<'static> { let now = clock::now_local().format("%H:%M").to_string(); let saved = match app.last_saved_at { - Some(_) if app.status.is_empty() => " 💾 saved ", - Some(_) => " 💾 ", - None => " ○ ", + Some(_) if app.status.is_empty() => format!(" {} saved ", app.icons.save), + Some(_) => format!(" {} ", app.icons.save), + None => " ○ ".to_string(), }; Line::from(vec![ Span::styled( - format!(" 🕐 {now} "), + format!(" {} {now} ", app.icons.clock), Style::default().bg(Color::DarkGray).fg(Color::Gray), ), Span::raw(" "), @@ -316,7 +317,12 @@ fn right_segments(app: &App) -> Line<'static> { ]) } -fn right_segments_width(_app: &App) -> u16 { - // 🕐 HH:MM (10) + saved (10) + help (8) + padding ≈ 32 - 34 +fn right_segments_width(app: &App) -> u16 { + let clock = format!(" {} 00:00 ", app.icons.clock).width(); + let saved = format!(" {} saved ", app.icons.save).width(); + let help = " ? help ".width(); + clock + .saturating_add(saved) + .saturating_add(help) + .saturating_add(2) as u16 } diff --git a/crates/outl-tui/src/view/inline.rs b/crates/outl-tui/src/view/inline.rs index 127cfa04..d55dc96f 100644 --- a/crates/outl-tui/src/view/inline.rs +++ b/crates/outl-tui/src/view/inline.rs @@ -5,6 +5,7 @@ //! markdown source visible with dim delimiters (cursor-bearing blocks //! so column-to-byte alignment stays 1:1). +use crate::icons::IconSet; use crate::theme::Theme; use outl_actions::TodoState; use outl_md::inline::{inline_to_source, tokenize, InlineTok}; @@ -71,8 +72,9 @@ pub(crate) fn render_pretty_block_text( text: &str, theme: &Theme, index: &outl_md::index::WorkspaceIndex, + icons: &IconSet, ) -> Vec> { - render_pretty_block_text_impl(text, theme, index, true) + render_pretty_block_text_impl(text, theme, index, icons, true) } /// Internal variant that controls whether `InlineTok::Embed` tokens @@ -84,6 +86,7 @@ fn render_pretty_block_text_impl( text: &str, theme: &Theme, index: &outl_md::index::WorkspaceIndex, + icons: &IconSet, expand_embed: bool, ) -> Vec> { // Strip TODO/DONE and quote markers in either order so the user @@ -117,12 +120,13 @@ fn render_pretty_block_text_impl( body, theme, index, + icons, expand_embed, )); } Some(TodoState::Done) => { out.push(Span::styled("☑ ", theme.todo_done)); - for sp in render_markdown_inline_impl(body, theme, index, expand_embed) { + for sp in render_markdown_inline_impl(body, theme, index, icons, expand_embed) { out.push(Span::styled( sp.content.into_owned(), sp.style.patch(theme.todo_done_body), @@ -133,6 +137,7 @@ fn render_pretty_block_text_impl( body, theme, index, + icons, expand_embed, )), } @@ -155,8 +160,9 @@ pub(crate) fn render_markdown_inline( text: &str, theme: &Theme, index: &outl_md::index::WorkspaceIndex, + icons: &IconSet, ) -> Vec> { - render_markdown_inline_impl(text, theme, index, true) + render_markdown_inline_impl(text, theme, index, icons, true) } /// Internal variant: when `expand_embed = false`, `InlineTok::Embed` @@ -168,6 +174,7 @@ fn render_markdown_inline_impl( text: &str, theme: &Theme, index: &outl_md::index::WorkspaceIndex, + icons: &IconSet, expand_embed: bool, ) -> Vec> { let mut out = Vec::new(); @@ -208,9 +215,9 @@ fn render_markdown_inline_impl( // `outl_md::wikilink::is_image_target` so the extension // list stays owned by one place. let glyph = if outl_md::wikilink::is_image_target(url) { - "🖼" + icons.image } else { - "📄" + icons.file }; let label = if alt.is_empty() { url.rsplit('/').next().unwrap_or(url) @@ -289,6 +296,7 @@ fn render_markdown_inline_impl( &entry.text, theme, index, + icons, false, )); } @@ -434,7 +442,7 @@ mod tests { fn pretty_render_emits_quote_bar_first() { let theme = default_theme(); let idx = empty_index(); - let spans = render_pretty_block_text("> hello", &theme, &idx); + let spans = render_pretty_block_text("> hello", &theme, &idx, &IconSet::default()); assert!( spans .first() @@ -448,7 +456,7 @@ mod tests { fn pretty_render_composes_quote_and_todo() { let theme = default_theme(); let idx = empty_index(); - let spans = render_pretty_block_text("> TODO ship it", &theme, &idx); + let spans = render_pretty_block_text("> TODO ship it", &theme, &idx, &IconSet::default()); // First span: quote bar. Second: TODO checkbox. assert!(spans .first() @@ -468,7 +476,7 @@ mod tests { fn pretty_render_accepts_todo_before_quote() { let theme = default_theme(); let idx = empty_index(); - let spans = render_pretty_block_text("TODO > ship it", &theme, &idx); + let spans = render_pretty_block_text("TODO > ship it", &theme, &idx, &IconSet::default()); assert!(spans .first() .map(|s| s.content.starts_with('│')) @@ -516,14 +524,16 @@ mod tests { let idx = empty_index(); // Image extension → 🖼 placeholder in the pretty render. - let pretty = render_pretty_block_text("![cover](assets/x.png)", &theme, &idx); + let pretty = + render_pretty_block_text("![cover](assets/x.png)", &theme, &idx, &IconSet::default()); assert!( pretty.iter().any(|s| s.content.contains('🖼')), "expected 🖼 placeholder for an image asset, got {pretty:#?}", ); // Non-image extension → 📄 generic-file placeholder. - let pretty_doc = render_pretty_block_text("![doc](assets/x.pdf)", &theme, &idx); + let pretty_doc = + render_pretty_block_text("![doc](assets/x.pdf)", &theme, &idx, &IconSet::default()); assert!( pretty_doc.iter().any(|s| s.content.contains('📄')), "expected 📄 placeholder for a non-image asset, got {pretty_doc:#?}", @@ -544,7 +554,7 @@ mod tests { fn pretty_render_skips_bar_for_plain_text() { let theme = default_theme(); let idx = empty_index(); - let spans = render_pretty_block_text("plain body", &theme, &idx); + let spans = render_pretty_block_text("plain body", &theme, &idx, &IconSet::default()); assert!( !spans .first() diff --git a/crates/outl-tui/src/view/namespace.rs b/crates/outl-tui/src/view/namespace.rs index 733df829..12e5dfbc 100644 --- a/crates/outl-tui/src/view/namespace.rs +++ b/crates/outl-tui/src/view/namespace.rs @@ -54,7 +54,7 @@ pub(crate) fn render_nested_pages(app: &App, inner_width: u16) -> Vec content.extend(render_markdown_inline(row.text, &app.theme, &app.index)), + _ => content.extend(render_markdown_inline( + row.text, &app.theme, &app.index, &app.icons, + )), } } @@ -558,11 +562,11 @@ enum CursorStyle { /// Rust/TS boundary any more than a DTO field can, so the two tables /// are edited together. A user's own key (`priority::`) gets no glyph; /// interpreting it isn't ours to do. -fn property_glyph(key: &str) -> Option<&'static str> { +fn property_glyph(key: &str, icons: &crate::icons::IconSet) -> Option<&'static str> { match key.to_ascii_lowercase().as_str() { - outl_md::remind::REMIND_KEY => Some("⏰"), + outl_md::remind::REMIND_KEY => Some(icons.bell), "auto-run" => Some("▶"), - "template" => Some("📋"), + "template" => Some(icons.clipboard), _ => None, } } diff --git a/crates/outl-tui/src/view/overlays.rs b/crates/outl-tui/src/view/overlays.rs index 3bce0938..32d803b6 100644 --- a/crates/outl-tui/src/view/overlays.rs +++ b/crates/outl-tui/src/view/overlays.rs @@ -179,8 +179,8 @@ pub(crate) fn render_quick_switch( .enumerate() .map(|(i, c)| { let icon = match c.kind { - SwitchKind::Page => "📄 ", - SwitchKind::Journal => "📅 ", + SwitchKind::Page => format!("{} ", app.icons.file), + SwitchKind::Journal => format!("{} ", app.icons.calendar), }; let style = if i == qs.selected { app.theme.list_selected @@ -256,8 +256,8 @@ fn render_preview_pane(f: &mut ratatui::Frame<'_>, area: Rect, app: &App, qs: &Q }; let title_prefix = match candidate.kind { - SwitchKind::Page => "📄 ", - SwitchKind::Journal => "📅 ", + SwitchKind::Page => format!("{} ", app.icons.file), + SwitchKind::Journal => format!("{} ", app.icons.calendar), }; let preview = Paragraph::new(body_lines) .block( @@ -424,7 +424,7 @@ pub(crate) fn render_slash_overlay( lines.push(Line::raw("")); } lines.push(Line::from(Span::styled( - format!(" {} {} ", category_icon(cat), cat), + format!(" {} {} ", category_icon(cat, &app.icons), cat), app.theme.help_title, ))); prev_cat = Some(cat); @@ -440,7 +440,11 @@ pub(crate) fn render_slash_overlay( let suffix = if c.needs_args { " …" } else { "" }; lines.push(Line::from(vec![ Span::styled( - format!(" {} {}{suffix} ", command_icon(&c.name), c.name), + format!( + " {} {}{suffix} ", + command_icon(&c.name, &app.icons), + c.name + ), style, ), Span::styled(c.description.to_string(), app.theme.dim), @@ -549,32 +553,32 @@ pub(crate) fn visual_order(candidates: &[crate::state::SlashCommand]) -> Vec &'static str { +fn category_icon(cat: &str, icons: &crate::icons::IconSet) -> &'static str { match cat { - "Actions" => "⚡", + "Actions" => icons.bolt, "Navigation" => "↪", - "Search" => "🔎", - "Settings" => "⚙", - "Dates & time" => "📅", + "Search" => icons.search, + "Settings" => icons.cog, + "Dates & time" => icons.calendar, _ => "•", } } /// Per-command leading glyph. Falls back to a dot for anything we /// haven't curated. -fn command_icon(name: &str) -> &'static str { +fn command_icon(name: &str, icons: &crate::icons::IconSet) -> &'static str { match name { "run" => "▶", "prop" => "≡", - "search" | "find" => "🔎", - "theme" => "🎨", + "search" | "find" => icons.search, + "theme" => icons.paint_brush, "open" | "switch" => "↪", "quit" | "q" => "✕", - n if n.starts_with("date") || n == "dt" || n == "dy" || n == "dtm" => "📅", - n if n.starts_with("time") => "🕐", - n if n.starts_with("iso") => "🔢", - n if n.starts_with("week") => "📆", - "stamp" => "🕒", + n if n.starts_with("date") || n == "dt" || n == "dy" || n == "dtm" => icons.calendar, + n if n.starts_with("time") => icons.clock, + n if n.starts_with("iso") => icons.hashtag, + n if n.starts_with("week") => icons.calendar, + "stamp" => icons.clock, _ => "·", } } @@ -594,7 +598,7 @@ pub(crate) fn render_template_picker( .split(area); let input = Paragraph::new(Line::from(vec![ - Span::styled(" 📋 ", app.theme.help_title), + Span::styled(format!(" {} ", app.icons.clipboard), app.theme.help_title), Span::raw(tp.query.clone()), Span::styled("▏", app.theme.cursor_caret), ])) @@ -612,7 +616,11 @@ pub(crate) fn render_template_picker( let Some(tpl) = tp.all.get(data_i) else { continue; }; - let icon = if tpl.params.is_empty() { "📄" } else { "⚡" }; + let icon = if tpl.params.is_empty() { + app.icons.file + } else { + app.icons.bolt + }; let label = format!(" {icon} {:<20} {}", tpl.name, tpl.slug); if vis_i == tp.selected { lines.push(Line::from(vec![Span::styled(label, app.theme.help_title)])); @@ -665,9 +673,9 @@ pub(crate) fn render_reminders( None => "—".to_string(), }; let snoozed = if r.snoozed_until_ms.is_some() { - " 💤" + format!(" {}", app.icons.moon) } else { - "" + String::new() }; let label = format!( " {:<18} {:<34} {}{}", @@ -930,7 +938,7 @@ pub(crate) fn render_help_popup(f: &mut ratatui::Frame<'_>, full: Rect, app: &Ap .divider(Span::styled("│", app.theme.dim)); f.render_widget(tabs, chunks[0]); - let body = help_tab_body(tab, &app.theme); + let body = help_tab_body(tab, &app.theme, &app.icons); let body_len = body.len() as u16; // Inner height = block area minus the 2 border rows. let inner_h = chunks[1].height.saturating_sub(2); @@ -971,7 +979,7 @@ pub(crate) fn render_help_popup(f: &mut ratatui::Frame<'_>, full: Rect, app: &Ap /// `outl-shortcuts`, so a chord added to the catalog does NOT appear /// here on its own. The guard test at the bottom of this file is what /// keeps the reminder chords from silently dropping out again. -fn help_tab_body(tab: usize, theme: &Theme) -> Vec> { +fn help_tab_body(tab: usize, theme: &Theme, icons: &crate::icons::IconSet) -> Vec> { match HELP_TABS.get(tab).copied().unwrap_or("Normal") { "Normal" => vec![ Line::from(Span::styled("Editing", theme.help_title)), @@ -1068,10 +1076,19 @@ fn help_tab_body(tab: usize, theme: &Theme) -> Vec> { Line::from(" Enter open the highlighted page or journal"), Line::from(""), Line::from(Span::styled("Sections", theme.help_title)), - Line::from(" 📅 Calendar current month — journals marked with ●"), - Line::from(" ⭐ Pinned pages with `pinned:: true` property"), + Line::from(format!( + " {} Calendar current month — journals marked with ●", + icons.calendar + )), + Line::from(format!( + " {} Pinned pages with `pinned:: true` property", + icons.star + )), Line::from(" (toggle with `g P` chord in Normal, or `/pin`)"), - Line::from(" 🕘 Recent pages opened this session (LRU, cap 20)"), + Line::from(format!( + " {} Recent pages opened this session (LRU, cap 20)", + icons.history + )), ], "Overlays" => vec![ Line::from(Span::styled("Open", theme.help_title)), @@ -1122,7 +1139,7 @@ mod help_coverage_tests { fn normal_help() -> String { let theme = default_theme(); - help_tab_body(0, &theme) + help_tab_body(0, &theme, &crate::icons::IconSet::default()) .iter() .map(|line| { line.spans diff --git a/crates/outl-tui/src/view/sidebar.rs b/crates/outl-tui/src/view/sidebar.rs index 250463a4..f3ca2b8f 100644 --- a/crates/outl-tui/src/view/sidebar.rs +++ b/crates/outl-tui/src/view/sidebar.rs @@ -108,7 +108,7 @@ fn render_calendar(f: &mut ratatui::Frame<'_>, area: Rect, app: &App) { app.theme.border }) .title(Span::styled( - format!(" 📅 {} ", viewing.format("%B %Y")), + format!(" {} {} ", app.icons.calendar, viewing.format("%B %Y")), app.theme.hint, )); f.render_widget(Paragraph::new(lines).block(block), area); @@ -146,7 +146,10 @@ fn render_pinned(f: &mut ratatui::Frame<'_>, area: Rect, app: &App) { } else { app.theme.border }) - .title(Span::styled(" ⭐ Pinned ", app.theme.hint)); + .title(Span::styled( + format!(" {} Pinned ", app.icons.star), + app.theme.hint, + )); if pinned.is_empty() { f.render_widget( @@ -165,7 +168,7 @@ fn render_pinned(f: &mut ratatui::Frame<'_>, area: Rect, app: &App) { .map(|(title, icon)| { let label = match icon { Some(ic) => format!(" {ic} {title}"), - None => format!(" 📄 {title}"), + None => format!(" {} {title}", app.icons.file), }; ListItem::new(Line::from(Span::raw(label))) }) @@ -195,7 +198,10 @@ fn render_recent(f: &mut ratatui::Frame<'_>, area: Rect, app: &App) { } else { app.theme.border }) - .title(Span::styled(" 🕘 Recent ", app.theme.hint)); + .title(Span::styled( + format!(" {} Recent ", app.icons.history), + app.theme.hint, + )); if app.recent_paths.is_empty() { f.render_widget( @@ -217,12 +223,12 @@ fn render_recent(f: &mut ratatui::Frame<'_>, area: Rect, app: &App) { let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?"); let entry = app.index.by_slug(stem); let (icon, label) = match entry { - Some(e) if e.is_journal => ("📅".to_string(), e.title.clone()), + Some(e) if e.is_journal => (app.icons.calendar.to_string(), e.title.clone()), Some(e) => ( - e.icon.clone().unwrap_or_else(|| "📄".to_string()), + e.icon.clone().unwrap_or_else(|| app.icons.file.to_string()), e.title.clone(), ), - None => ("📄".to_string(), stem.to_string()), + None => (app.icons.file.to_string(), stem.to_string()), }; ListItem::new(Line::from(Span::raw(format!(" {icon} {label}")))) }) diff --git a/crates/outl-tui/src/view/toasts.rs b/crates/outl-tui/src/view/toasts.rs index e7305747..83243a6f 100644 --- a/crates/outl-tui/src/view/toasts.rs +++ b/crates/outl-tui/src/view/toasts.rs @@ -5,6 +5,7 @@ //! The stack draws *over* everything else (after main, overlays, //! help) so a save toast still pops up even when a modal is open. +use crate::icons::IconSet; use crate::state::{App, ToastKind}; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; @@ -46,7 +47,7 @@ pub(crate) fn render_toasts(f: &mut ratatui::Frame<'_>, full: Rect, app: &App) { }; f.render_widget(Clear, area); - let (icon, accent) = icon_and_color(toast.kind); + let (icon, accent) = icon_and_color(toast.kind, &app.icons); let body = Line::from(vec![ Span::styled( format!(" {icon} "), @@ -75,11 +76,11 @@ pub(crate) fn render_toasts(f: &mut ratatui::Frame<'_>, full: Rect, app: &App) { } } -fn icon_and_color(kind: ToastKind) -> (&'static str, Color) { +fn icon_and_color(kind: ToastKind, icons: &IconSet) -> (&'static str, Color) { match kind { ToastKind::Success => ("✓", Color::LightGreen), ToastKind::Info => ("ℹ", Color::LightCyan), - ToastKind::Warning => ("⚠", Color::LightYellow), + ToastKind::Warning => (icons.warning, Color::LightYellow), ToastKind::Error => ("✕", Color::LightRed), } } diff --git a/crates/outl-tui/src/view/warnings_banner.rs b/crates/outl-tui/src/view/warnings_banner.rs index 01c4553a..f162da35 100644 --- a/crates/outl-tui/src/view/warnings_banner.rs +++ b/crates/outl-tui/src/view/warnings_banner.rs @@ -43,7 +43,8 @@ pub(crate) fn render_banner(f: &mut ratatui::Frame<'_>, area: Rect, app: &App) { format!("line {}: {} (+{} more)", first.line, preview, extra) }; let title = format!( - " ⚠ {} line(s) outside outl dialect — preserved as blocks ", + " {} {} line(s) outside outl dialect — preserved as blocks ", + app.icons.warning, app.parse_warnings.len() ); let line = Line::from(vec![Span::styled( diff --git a/docs/config.md b/docs/config.md index c7844b6c..6938204d 100644 --- a/docs/config.md +++ b/docs/config.md @@ -163,6 +163,7 @@ See [theming.md](theming.md) for the look of each. | Field | Type | Default | Read by | Effect | |---|---|---|---|---| +| `icons` | `"emoji"` \| `"nerd-font"` | `"emoji"` | TUI only | Selects the TUI chrome icon set. `emoji` works with ordinary terminal fonts; `nerd-font` opts into compact Font Awesome / Material Design Nerd Font glyphs. | | `mouse_capture` | bool | `false` | TUI only | When `true`, the TUI captures mouse events: the scroll wheel moves the outline selection, a click selects the block under the pointer, and dragging selects a range that is copied as clean outl markdown to the OS clipboard on release. Default is `false` because capturing the mouse disables the terminal's own text-selection (Shift-drag). The keyboard yank (`yy` / `Y` / Visual `y`) always writes to the clipboard regardless of this flag. | #### `[display]` diff --git a/docs/tui.md b/docs/tui.md index e1f9b155..1fec7088 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -14,6 +14,8 @@ cd ~/notes && outl # no args: opens TUI in cwd The TUI requires a real interactive terminal. If stdout isn't a TTY (e.g. CI), it exits with a clear error instead of hanging. +TUI chrome uses ordinary emoji and symbols by default, so it works without a special font. +Set `[tui] icons = "nerd-font"` to opt into compact Nerd Font glyphs. ## Copy to clipboard @@ -73,6 +75,9 @@ The TUI reads two layers of TOML before launching: [theme] preset = "dracula" + [tui] + icons = "nerd-font" # "emoji" (default) or "nerd-font" + [editor] vim_mode = true font_size = 15 From fe066923abf6abcb0d64a1b314d6fad8a6c00bb8 Mon Sep 17 00:00:00 2001 From: Justin Martin Date: Thu, 17 Sep 2026 09:04:15 -0500 Subject: [PATCH 2/8] refactor: keep icon changes under file-size ratchet --- crates/outl-config/src/lib.rs | 5 +-- crates/outl-config/src/schema.rs | 44 +----------------------- crates/outl-config/src/tui.rs | 50 ++++++++++++++++++++++++++++ crates/outl-tui/src/icons.rs | 37 ++++++++++++++++++++ crates/outl-tui/src/runtime.rs | 3 -- crates/outl-tui/src/state.rs | 4 +-- crates/outl-tui/src/view/outline.rs | 18 +--------- crates/outl-tui/src/view/overlays.rs | 34 ++----------------- 8 files changed, 95 insertions(+), 100 deletions(-) create mode 100644 crates/outl-config/src/tui.rs diff --git a/crates/outl-config/src/lib.rs b/crates/outl-config/src/lib.rs index 806dd8ae..4c36eb6f 100644 --- a/crates/outl-config/src/lib.rs +++ b/crates/outl-config/src/lib.rs @@ -64,13 +64,14 @@ mod paths; mod schema; +mod tui; pub use paths::{config_dir, config_path}; pub use schema::{ AssetsCfg, BacklinksOrder, BackupCfg, CalendarCfg, Config, DisplayCfg, EditorCfg, RemindersCfg, - SnapshotCfg, StorageCfg, SyncConfig, SyncTransportKind, ThemeCfg, ThemeMode, TuiCfg, - TuiIconStyle, WorkspaceCfg, + SnapshotCfg, StorageCfg, SyncConfig, SyncTransportKind, ThemeCfg, ThemeMode, WorkspaceCfg, }; +pub use tui::{TuiCfg, TuiIconStyle}; use std::fs; use std::path::{Path, PathBuf}; diff --git a/crates/outl-config/src/schema.rs b/crates/outl-config/src/schema.rs index aebb2ef4..12535d65 100644 --- a/crates/outl-config/src/schema.rs +++ b/crates/outl-config/src/schema.rs @@ -8,6 +8,7 @@ use std::path::PathBuf; +use crate::tui::TuiCfg; use serde::{Deserialize, Deserializer, Serialize}; /// Root config — three sections that map cleanly to "which client @@ -141,39 +142,6 @@ pub struct DisplayCfg { pub backlinks_order: BacklinksOrder, } -/// TUI-only preferences (the desktop ignores this section). -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default)] -pub struct TuiCfg { - /// Chrome icon set. Emoji is portable across ordinary terminal fonts; - /// Nerd Font glyphs are available as an explicit opt-in. - pub icons: TuiIconStyle, - - /// Capture the mouse so the app owns selection: drag across blocks - /// selects a range and copies it as clean markdown on release, the - /// scroll wheel moves the selection, a click selects a block. - /// - /// Default `false`, and deliberately opt-in: capturing the mouse - /// **disables the terminal's own text selection** (selecting a URL, - /// copying a single word, dragging across panes), which is muscle - /// memory for many terminal users. Turn it on only if you want - /// mouse-driven copy inside outl more than the terminal's native - /// selection. The keyboard yank (`yy` / `Y` / Visual `y`) copies - /// markdown to the clipboard regardless of this flag. - pub mouse_capture: bool, -} - -/// Icon set used by TUI chrome. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "kebab-case")] -pub enum TuiIconStyle { - /// Unicode emoji and symbols supported by ordinary terminal fonts. - #[default] - Emoji, - /// Font Awesome / Material Design glyphs from a Nerd Font. - NerdFont, -} - /// Workspace section — primarily where the desktop remembers the /// last opened directory so the next launch skips the picker. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -518,7 +486,6 @@ mod tests { assert!(c.snapshot.enabled); assert_eq!(c.snapshot.op_threshold, 10_000); assert_eq!(c.display.backlinks_order, BacklinksOrder::Newest); - assert_eq!(c.tui.icons, TuiIconStyle::Emoji); assert!(c.display.backlinks_order.newest_first()); assert!(c.backup.enabled, "backups default ON — see BackupCfg docs"); assert_eq!(c.backup.interval_minutes, 30); @@ -556,15 +523,6 @@ mod tests { assert_eq!(c.display.backlinks_order, BacklinksOrder::Newest); } - #[test] - fn tui_icon_style_parses_and_defaults_to_emoji() { - let c: Config = toml::from_str("[tui]\nicons = \"nerd-font\"\n").unwrap(); - assert_eq!(c.tui.icons, TuiIconStyle::NerdFont); - - let c: Config = toml::from_str("[theme]\npreset = \"nord\"\n").unwrap(); - assert_eq!(c.tui.icons, TuiIconStyle::Emoji); - } - #[test] fn partial_assets_section_keeps_other_defaults() { // A config with ONLY [assets] populated must leave every other diff --git a/crates/outl-config/src/tui.rs b/crates/outl-config/src/tui.rs new file mode 100644 index 00000000..dffc9fc7 --- /dev/null +++ b/crates/outl-config/src/tui.rs @@ -0,0 +1,50 @@ +//! TUI-only preferences and icon selection. + +use serde::{Deserialize, Serialize}; + +/// TUI-only preferences (the desktop ignores this section). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TuiCfg { + /// Chrome icon set. Emoji is portable across ordinary terminal fonts; + /// Nerd Font glyphs are available as an explicit opt-in. + pub icons: TuiIconStyle, + + /// Capture the mouse so the app owns selection: drag across blocks + /// selects a range and copies it as clean markdown on release, the + /// scroll wheel moves the outline selection, a click selects a block. + /// + /// Default `false`, and deliberately opt-in: capturing the mouse + /// **disables the terminal's own text selection** (selecting a URL, + /// copying a single word, dragging across panes), which is muscle + /// memory for many terminal users. Turn it on only if you want + /// mouse-driven copy inside outl more than the terminal's native + /// selection. The keyboard yank (`yy` / `Y` / Visual `y`) copies + /// markdown to the clipboard regardless of this flag. + pub mouse_capture: bool, +} + +/// Icon set used by TUI chrome. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum TuiIconStyle { + /// Unicode emoji and symbols supported by ordinary terminal fonts. + #[default] + Emoji, + /// Font Awesome / Material Design glyphs from a Nerd Font. + NerdFont, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tui_icon_style_parses_and_defaults_to_emoji() { + let c: crate::Config = toml::from_str("[tui]\nicons = \"nerd-font\"\n").unwrap(); + assert_eq!(c.tui.icons, TuiIconStyle::NerdFont); + + let c: crate::Config = toml::from_str("[theme]\npreset = \"nord\"\n").unwrap(); + assert_eq!(c.tui.icons, TuiIconStyle::Emoji); + } +} diff --git a/crates/outl-tui/src/icons.rs b/crates/outl-tui/src/icons.rs index 362c4865..bfbed0df 100644 --- a/crates/outl-tui/src/icons.rs +++ b/crates/outl-tui/src/icons.rs @@ -34,6 +34,43 @@ impl IconSet { } } + pub(crate) fn property_glyph(&self, key: &str) -> Option<&'static str> { + match key.to_ascii_lowercase().as_str() { + outl_md::remind::REMIND_KEY => Some(self.bell), + "auto-run" => Some("▶"), + "template" => Some(self.clipboard), + _ => None, + } + } + + pub(crate) fn category_glyph(&self, category: &str) -> &'static str { + match category { + "Actions" => self.bolt, + "Navigation" => "↪", + "Search" => self.search, + "Settings" => self.cog, + "Dates & time" => self.calendar, + _ => "•", + } + } + + pub(crate) fn command_glyph(&self, name: &str) -> &'static str { + match name { + "run" => "▶", + "prop" => "≡", + "search" | "find" => self.search, + "theme" => self.paint_brush, + "open" | "switch" => "↪", + "quit" | "q" => "✕", + n if n.starts_with("date") || n == "dt" || n == "dy" || n == "dtm" => self.calendar, + n if n.starts_with("time") => self.clock, + n if n.starts_with("iso") => self.hashtag, + n if n.starts_with("week") => self.calendar, + "stamp" => self.clock, + _ => "·", + } + } + fn emoji() -> Self { Self { calendar: "📅", diff --git a/crates/outl-tui/src/runtime.rs b/crates/outl-tui/src/runtime.rs index 2b30f814..224dd94e 100644 --- a/crates/outl-tui/src/runtime.rs +++ b/crates/outl-tui/src/runtime.rs @@ -420,9 +420,6 @@ fn event_loop( ) -> Result<()> { let mut app = App::new(workspace_root, workspace, actor, theme, shared_workspace)?; app.icons = crate::icons::IconSet::new(icon_style); - // Apply the persisted backlinks direction (issue #142); the field - // only feeds the render path, so setting it post-construction is - // enough and keeps it out of `App::new`'s already-long signature. app.backlinks_newest_first = backlinks_newest_first; loop { // Pick up the background index build if it finished since the diff --git a/crates/outl-tui/src/state.rs b/crates/outl-tui/src/state.rs index 528121e8..e33df923 100644 --- a/crates/outl-tui/src/state.rs +++ b/crates/outl-tui/src/state.rs @@ -10,7 +10,6 @@ //! reads or writes these types, but nothing outside `outl-tui` should. use crate::edit_buffer::EditBuffer; -use crate::icons::IconSet; use crate::theme::Theme; use chrono::NaiveDate; use outl_core::hlc::HlcGenerator; @@ -533,9 +532,8 @@ pub(crate) struct AutocompleteState { pub(crate) selected: usize, } -/// Application state. pub(crate) struct App { - pub(crate) icons: IconSet, + pub(crate) icons: crate::icons::IconSet, pub(crate) workspace_root: PathBuf, pub(crate) workspace: Workspace, pub(crate) hlc: HlcGenerator, diff --git a/crates/outl-tui/src/view/outline.rs b/crates/outl-tui/src/view/outline.rs index b0c18011..bfb01917 100644 --- a/crates/outl-tui/src/view/outline.rs +++ b/crates/outl-tui/src/view/outline.rs @@ -201,7 +201,7 @@ pub(crate) fn render_block( prop_spans.push(Span::styled("│ ", app.theme.dim)); } prop_spans.push(Span::raw(" ".to_string())); - if let Some(glyph) = property_glyph(k, &app.icons) { + if let Some(glyph) = app.icons.property_glyph(k) { prop_spans.push(Span::raw(format!("{glyph} "))); } prop_spans.push(Span::styled(format!("{k}:: "), app.theme.property_key)); @@ -555,22 +555,6 @@ enum CursorStyle { Block, } -/// Leading glyph for a property key outl gives a meaning to. -/// -/// Mirrors `KNOWN_PROPERTIES` in -/// `@outl/shared/markdown/properties` — a const can't cross the -/// Rust/TS boundary any more than a DTO field can, so the two tables -/// are edited together. A user's own key (`priority::`) gets no glyph; -/// interpreting it isn't ours to do. -fn property_glyph(key: &str, icons: &crate::icons::IconSet) -> Option<&'static str> { - match key.to_ascii_lowercase().as_str() { - outl_md::remind::REMIND_KEY => Some(icons.bell), - "auto-run" => Some("▶"), - "template" => Some(icons.clipboard), - _ => None, - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/outl-tui/src/view/overlays.rs b/crates/outl-tui/src/view/overlays.rs index 32d803b6..ec4965cc 100644 --- a/crates/outl-tui/src/view/overlays.rs +++ b/crates/outl-tui/src/view/overlays.rs @@ -424,7 +424,7 @@ pub(crate) fn render_slash_overlay( lines.push(Line::raw("")); } lines.push(Line::from(Span::styled( - format!(" {} {} ", category_icon(cat, &app.icons), cat), + format!(" {} {} ", app.icons.category_glyph(cat), cat), app.theme.help_title, ))); prev_cat = Some(cat); @@ -442,7 +442,7 @@ pub(crate) fn render_slash_overlay( Span::styled( format!( " {} {}{suffix} ", - command_icon(&c.name, &app.icons), + app.icons.command_glyph(&c.name), c.name ), style, @@ -553,36 +553,6 @@ pub(crate) fn visual_order(candidates: &[crate::state::SlashCommand]) -> Vec &'static str { - match cat { - "Actions" => icons.bolt, - "Navigation" => "↪", - "Search" => icons.search, - "Settings" => icons.cog, - "Dates & time" => icons.calendar, - _ => "•", - } -} - -/// Per-command leading glyph. Falls back to a dot for anything we -/// haven't curated. -fn command_icon(name: &str, icons: &crate::icons::IconSet) -> &'static str { - match name { - "run" => "▶", - "prop" => "≡", - "search" | "find" => icons.search, - "theme" => icons.paint_brush, - "open" | "switch" => "↪", - "quit" | "q" => "✕", - n if n.starts_with("date") || n == "dt" || n == "dy" || n == "dtm" => icons.calendar, - n if n.starts_with("time") => icons.clock, - n if n.starts_with("iso") => icons.hashtag, - n if n.starts_with("week") => icons.calendar, - "stamp" => icons.clock, - _ => "·", - } -} - pub(crate) fn render_template_picker( f: &mut ratatui::Frame<'_>, full: Rect, From dfc861f6bb1fc414c9af792667fc36ad41afa16b Mon Sep 17 00:00:00 2001 From: Justin Martin Date: Thu, 17 Sep 2026 14:38:53 -0500 Subject: [PATCH 3/8] fix(desktop): restore [tui] section when the settings modal saves into() hardcodes TuiCfg::default() (icons = emoji), and restore_unmodeled_sections never carried [tui] back from disk, so every settings save silently rewrote a hand-set icons = "nerd-font" or mouse_capture = true. The code comment claimed the restore existed; the pin test makes the claim true. --- crates/outl-desktop/src-tauri/src/settings.rs | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/crates/outl-desktop/src-tauri/src/settings.rs b/crates/outl-desktop/src-tauri/src/settings.rs index ba76f09e..1f77f33e 100644 --- a/crates/outl-desktop/src-tauri/src/settings.rs +++ b/crates/outl-desktop/src-tauri/src/settings.rs @@ -188,8 +188,9 @@ impl From for Config { relay_url: None, }, // `[tui]` is TUI-only; the desktop doesn't model it. `save` - // restores it from disk so a hand-set `mouse_capture` survives - // a settings write (same pattern as `[calendar]`). + // restores it from disk so hand-set `mouse_capture` or + // `icons` survive a settings write (same pattern as + // `[calendar]`). tui: outl_config::TuiCfg::default(), // `[snapshot]` is core-managed; the desktop doesn't model it. // `save` restores it from disk so a hand-set policy survives a @@ -255,6 +256,11 @@ fn restore_unmodeled_sections(cfg: &mut Config, on_disk: &Config) { // edit). cfg.sync.relay_url = on_disk.sync.relay_url.clone(); cfg.calendar = on_disk.calendar.clone(); + // `[tui]` is TUI-only (`mouse_capture`, `icons`); the desktop doesn't + // model it. Restore it so a modal save can't wipe a hand-set icon + // style or mouse-capture toggle (the `into()` conversion leaves + // `TuiCfg::default()`, and a default here silently means "emoji"). + cfg.tui = on_disk.tui.clone(); // `[theme]` (all three fields: `preset`, `preset_dark`, `mode`) is now // FULLY modeled in `Settings` — the modal owns the whole pair. Do NOT // add a restore-from-disk line for any of them here: that was the @@ -400,4 +406,36 @@ mod tests { "[calendar] is still unmodelled by Settings and must still be restored from disk" ); } + + /// Regression pin for the `[tui]` section. + /// + /// The `into()` conversion hardcodes `TuiCfg::default()`, whose + /// `icons` is `Emoji`, and the desktop never models `[tui]`. Without + /// a restore line in `restore_unmodeled_sections`, every settings + /// save silently rewrote a hand-set `icons = "nerd-font"` (and + /// `mouse_capture`) back to the defaults — the exact loss the + /// `[calendar]` / `[backup]` restores exist to prevent, and worse + /// here because the default reads as the user's own opt-in. + #[test] + fn save_restores_the_tui_section_the_desktop_never_models() { + use outl_config::TuiIconStyle; + + let mut on_disk = Config::default(); + on_disk.tui.icons = TuiIconStyle::NerdFont; + on_disk.tui.mouse_capture = true; + + let mut cfg: Config = Settings::fresh().into(); + assert_eq!( + cfg.tui, + outl_config::TuiCfg::default(), + "into() must leave [tui] at defaults — the modal does not model it" + ); + restore_unmodeled_sections(&mut cfg, &on_disk); + + assert_eq!( + cfg.tui, on_disk.tui, + "[tui] is unmodelled by Settings and must be restored from disk — \ + dropping it wipes the user's icon style and mouse-capture opt-in" + ); + } } From c07e79f5b2e3fa31674e606b818465441ac9ec16 Mon Sep 17 00:00:00 2001 From: Justin Martin Date: Thu, 17 Sep 2026 14:38:53 -0500 Subject: [PATCH 4/8] fix(tui): route play glyphs and the boot warning chip through IconSet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on the icon opt-in: - property_glyph("auto-run") and command_glyph("run") hardcoded "▶" outside the IconSet, so a nerd-font terminal still drew the emoji play glyph. IconSet gains a play field (nf-fa-play for Nerd Font). - App::new runs the first load_current(), which stamps the parse-warning status chip with icons.warning; the runtime only assigned the configured IconSet afterwards, so a nerd-font launch booted with an emoji chip that no later reload could clear (the clear path only recognises its own marker). Icon style is now an App::new parameter, applied before the first load. --- crates/outl-tui/src/actions/autocomplete.rs | 1 + crates/outl-tui/src/actions/block/metadata.rs | 1 + crates/outl-tui/src/actions/block/template.rs | 1 + crates/outl-tui/src/actions/exec.rs | 1 + .../src/actions/lifecycle/clock_seed_tests.rs | 1 + .../outl-tui/src/actions/lifecycle/loading.rs | 58 +++++++++++++++++++ crates/outl-tui/src/actions/lifecycle/mod.rs | 7 ++- .../src/actions/lifecycle/persistence.rs | 1 + crates/outl-tui/src/actions/nav.rs | 1 + crates/outl-tui/src/actions/overlay.rs | 1 + crates/outl-tui/src/actions/plugins.rs | 1 + crates/outl-tui/src/actions/text_ops.rs | 1 + crates/outl-tui/src/actions/visual.rs | 1 + crates/outl-tui/src/actions/zoom.rs | 1 + .../src/commands/builtins/template.rs | 1 + crates/outl-tui/src/icons.rs | 18 +++++- crates/outl-tui/src/input/overlay.rs | 1 + crates/outl-tui/src/input/plugin_chord.rs | 1 + crates/outl-tui/src/runtime.rs | 10 +++- crates/outl-tui/src/view/outline.rs | 1 + 20 files changed, 104 insertions(+), 5 deletions(-) diff --git a/crates/outl-tui/src/actions/autocomplete.rs b/crates/outl-tui/src/actions/autocomplete.rs index dca88e76..65b3f7ef 100644 --- a/crates/outl-tui/src/actions/autocomplete.rs +++ b/crates/outl-tui/src/actions/autocomplete.rs @@ -662,6 +662,7 @@ mod tests { actor, crate::theme::default_theme(), false, + outl_config::TuiIconStyle::Emoji, ) .unwrap(); (app, dir) diff --git a/crates/outl-tui/src/actions/block/metadata.rs b/crates/outl-tui/src/actions/block/metadata.rs index 4288fd1c..19c9cda6 100644 --- a/crates/outl-tui/src/actions/block/metadata.rs +++ b/crates/outl-tui/src/actions/block/metadata.rs @@ -175,6 +175,7 @@ mod property_edit_tests { actor, crate::theme::default_theme(), false, + outl_config::TuiIconStyle::Emoji, ) .unwrap(); (app, dir) diff --git a/crates/outl-tui/src/actions/block/template.rs b/crates/outl-tui/src/actions/block/template.rs index 41c5f748..4b6acde4 100644 --- a/crates/outl-tui/src/actions/block/template.rs +++ b/crates/outl-tui/src/actions/block/template.rs @@ -138,6 +138,7 @@ mod tests { actor, crate::theme::default_theme(), false, + outl_config::TuiIconStyle::Emoji, ) .unwrap(); (app, dir) diff --git a/crates/outl-tui/src/actions/exec.rs b/crates/outl-tui/src/actions/exec.rs index 02716d88..daaa5011 100644 --- a/crates/outl-tui/src/actions/exec.rs +++ b/crates/outl-tui/src/actions/exec.rs @@ -488,6 +488,7 @@ mod tests { actor, crate::theme::default_theme(), false, + outl_config::TuiIconStyle::Emoji, ) .unwrap(); (app, dir) diff --git a/crates/outl-tui/src/actions/lifecycle/clock_seed_tests.rs b/crates/outl-tui/src/actions/lifecycle/clock_seed_tests.rs index 1f98e244..a71f5317 100644 --- a/crates/outl-tui/src/actions/lifecycle/clock_seed_tests.rs +++ b/crates/outl-tui/src/actions/lifecycle/clock_seed_tests.rs @@ -54,6 +54,7 @@ fn app_over(ws: Workspace, actor: ActorId) -> (App, TempDir) { actor, crate::theme::default_theme(), false, + outl_config::TuiIconStyle::Emoji, ) .expect("App::new"); (app, dir) diff --git a/crates/outl-tui/src/actions/lifecycle/loading.rs b/crates/outl-tui/src/actions/lifecycle/loading.rs index f5425d01..6750c991 100644 --- a/crates/outl-tui/src/actions/lifecycle/loading.rs +++ b/crates/outl-tui/src/actions/lifecycle/loading.rs @@ -205,3 +205,61 @@ impl App { self.recent_paths.truncate(RECENT_MAX); } } + +#[cfg(test)] +mod tests { + use crate::state::App; + use outl_core::{ActorId, Workspace}; + use tempfile::TempDir; + + fn today_journal_path(dir: &std::path::Path) -> std::path::PathBuf { + dir.join("journals").join(format!( + "{}.md", + outl_actions::clock::today().format("%Y-%m-%d") + )) + } + + /// Regression for the icon-style boot race: the parse-warning + /// status chip is stamped with `icons.warning` inside the first + /// `load_current` (from `App::new`), and the clear/refresh path + /// only recognises a chip carrying *its own* marker. When the + /// runtime assigned the configured `IconSet` only *after* + /// `App::new` returned, a nerd-font launch booted with an emoji + /// `⚠` chip that no later reload could clear. + #[test] + fn boot_stamps_the_warning_chip_with_the_configured_icon_set() { + let dir = TempDir::new().unwrap(); + let path = today_journal_path(dir.path()); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "a line outside the outline dialect\n").unwrap(); + + let actor = ActorId::new(); + let ws = Workspace::open_in_memory(actor).unwrap(); + let mut app = App::new( + dir.path().to_path_buf(), + ws, + actor, + crate::theme::default_theme(), + false, + outl_config::TuiIconStyle::NerdFont, + ) + .unwrap(); + + assert!(!app.parse_warnings.is_empty(), "fixture must warn"); + assert_eq!(app.icons.warning, "\u{f071}"); + let nerd_chip = format!("{} ", app.icons.warning); + assert!( + app.status.starts_with(&nerd_chip), + "the boot chip must carry the configured icon set, got {:?}", + app.status + ); + + // The clear path only trusts a chip stamped with its own + // marker, so the fix is only real if the second load can + // actually retire the first one. + std::fs::write(&path, "- back inside the dialect\n").unwrap(); + app.load_current(); + assert!(app.parse_warnings.is_empty()); + assert_eq!(app.status, "", "the chip must clear on the next load"); + } +} diff --git a/crates/outl-tui/src/actions/lifecycle/mod.rs b/crates/outl-tui/src/actions/lifecycle/mod.rs index 273bf9a3..7d0c08a7 100644 --- a/crates/outl-tui/src/actions/lifecycle/mod.rs +++ b/crates/outl-tui/src/actions/lifecycle/mod.rs @@ -48,10 +48,15 @@ impl App { actor: ActorId, theme: Theme, shared_workspace: bool, + icon_style: outl_config::TuiIconStyle, ) -> Result { let orphans_log = workspace_root.join(".outl").join("orphans.log"); let mut s = Self { - icons: crate::icons::IconSet::default(), + // Set before the first `load_current` below: that load stamps + // the parse-warning status chip with `icons.warning`, and a + // chip painted with the wrong set can never be cleared — + // the clear path only recognises its own marker. + icons: crate::icons::IconSet::new(icon_style), hlc: HlcGenerator::new(actor), workspace_root, workspace, diff --git a/crates/outl-tui/src/actions/lifecycle/persistence.rs b/crates/outl-tui/src/actions/lifecycle/persistence.rs index 31c149c0..349b185d 100644 --- a/crates/outl-tui/src/actions/lifecycle/persistence.rs +++ b/crates/outl-tui/src/actions/lifecycle/persistence.rs @@ -260,6 +260,7 @@ mod tests { actor, crate::theme::default_theme(), false, + outl_config::TuiIconStyle::Emoji, ) .unwrap(); (app, dir) diff --git a/crates/outl-tui/src/actions/nav.rs b/crates/outl-tui/src/actions/nav.rs index dbd53bcb..16fb77c1 100644 --- a/crates/outl-tui/src/actions/nav.rs +++ b/crates/outl-tui/src/actions/nav.rs @@ -756,6 +756,7 @@ mod open_page_tests { actor, crate::theme::default_theme(), false, + outl_config::TuiIconStyle::Emoji, ) .unwrap(); (app, dir) diff --git a/crates/outl-tui/src/actions/overlay.rs b/crates/outl-tui/src/actions/overlay.rs index 20137ec9..43cec1af 100644 --- a/crates/outl-tui/src/actions/overlay.rs +++ b/crates/outl-tui/src/actions/overlay.rs @@ -694,6 +694,7 @@ mod tests { actor, crate::theme::default_theme(), false, + outl_config::TuiIconStyle::Emoji, ) .unwrap(); (app, dir) diff --git a/crates/outl-tui/src/actions/plugins.rs b/crates/outl-tui/src/actions/plugins.rs index edab0792..3e0bad48 100644 --- a/crates/outl-tui/src/actions/plugins.rs +++ b/crates/outl-tui/src/actions/plugins.rs @@ -735,6 +735,7 @@ mod tests { actor, crate::theme::default_theme(), false, + outl_config::TuiIconStyle::Emoji, ) .unwrap() } diff --git a/crates/outl-tui/src/actions/text_ops.rs b/crates/outl-tui/src/actions/text_ops.rs index fb160f77..a12309ad 100644 --- a/crates/outl-tui/src/actions/text_ops.rs +++ b/crates/outl-tui/src/actions/text_ops.rs @@ -255,6 +255,7 @@ mod tests { actor, crate::theme::default_theme(), false, + outl_config::TuiIconStyle::Emoji, ) .unwrap(); (app, dir) diff --git a/crates/outl-tui/src/actions/visual.rs b/crates/outl-tui/src/actions/visual.rs index 0505a6b9..b1a4b1c5 100644 --- a/crates/outl-tui/src/actions/visual.rs +++ b/crates/outl-tui/src/actions/visual.rs @@ -220,6 +220,7 @@ mod tests { actor, crate::theme::default_theme(), false, + outl_config::TuiIconStyle::Emoji, ) .unwrap(); app.page.blocks = vec![leaf("a"), leaf("b"), leaf("c"), leaf("d")]; diff --git a/crates/outl-tui/src/actions/zoom.rs b/crates/outl-tui/src/actions/zoom.rs index 4945e230..51154fbc 100644 --- a/crates/outl-tui/src/actions/zoom.rs +++ b/crates/outl-tui/src/actions/zoom.rs @@ -150,6 +150,7 @@ mod tests { actor, crate::theme::default_theme(), false, + outl_config::TuiIconStyle::Emoji, ) .unwrap(); (app, dir) diff --git a/crates/outl-tui/src/commands/builtins/template.rs b/crates/outl-tui/src/commands/builtins/template.rs index d75b8810..1e8d6c86 100644 --- a/crates/outl-tui/src/commands/builtins/template.rs +++ b/crates/outl-tui/src/commands/builtins/template.rs @@ -90,6 +90,7 @@ mod tests { actor, crate::theme::default_theme(), false, + outl_config::TuiIconStyle::Emoji, ) .unwrap() } diff --git a/crates/outl-tui/src/icons.rs b/crates/outl-tui/src/icons.rs index bfbed0df..15a54525 100644 --- a/crates/outl-tui/src/icons.rs +++ b/crates/outl-tui/src/icons.rs @@ -24,6 +24,7 @@ pub(crate) struct IconSet { pub(crate) moon: &'static str, pub(crate) hashtag: &'static str, pub(crate) bell: &'static str, + pub(crate) play: &'static str, } impl IconSet { @@ -37,7 +38,7 @@ impl IconSet { pub(crate) fn property_glyph(&self, key: &str) -> Option<&'static str> { match key.to_ascii_lowercase().as_str() { outl_md::remind::REMIND_KEY => Some(self.bell), - "auto-run" => Some("▶"), + "auto-run" => Some(self.play), "template" => Some(self.clipboard), _ => None, } @@ -56,7 +57,7 @@ impl IconSet { pub(crate) fn command_glyph(&self, name: &str) -> &'static str { match name { - "run" => "▶", + "run" => self.play, "prop" => "≡", "search" | "find" => self.search, "theme" => self.paint_brush, @@ -89,6 +90,7 @@ impl IconSet { moon: "🌙", hashtag: "#", bell: "⏰", + play: "▶", } } @@ -110,6 +112,7 @@ impl IconSet { moon: "\u{f186}", hashtag: "\u{f292}", bell: "\u{f0f3}", + play: "\u{f04b}", } } } @@ -143,4 +146,15 @@ mod tests { .chars() .any(|ch| (0xE000..=0xF8FF).contains(&(ch as u32)))); } + + #[test] + fn play_routes_through_the_icon_set() { + let emoji = IconSet::new(TuiIconStyle::Emoji); + assert_eq!(emoji.property_glyph("auto-run"), Some("▶")); + assert_eq!(emoji.command_glyph("run"), "▶"); + + let nerd = IconSet::new(TuiIconStyle::NerdFont); + assert_eq!(nerd.property_glyph("auto-run"), Some("\u{f04b}")); + assert_eq!(nerd.command_glyph("run"), "\u{f04b}"); + } } diff --git a/crates/outl-tui/src/input/overlay.rs b/crates/outl-tui/src/input/overlay.rs index 3af2d3e0..992bd1d9 100644 --- a/crates/outl-tui/src/input/overlay.rs +++ b/crates/outl-tui/src/input/overlay.rs @@ -366,6 +366,7 @@ mod properties_overlay_tests { actor, crate::theme::default_theme(), false, + outl_config::TuiIconStyle::Emoji, ) .unwrap(); app.page.blocks.clear(); diff --git a/crates/outl-tui/src/input/plugin_chord.rs b/crates/outl-tui/src/input/plugin_chord.rs index 58931613..08c1edde 100644 --- a/crates/outl-tui/src/input/plugin_chord.rs +++ b/crates/outl-tui/src/input/plugin_chord.rs @@ -280,6 +280,7 @@ mod tests { actor, crate::theme::default_theme(), false, + outl_config::TuiIconStyle::Emoji, ) .unwrap() } diff --git a/crates/outl-tui/src/runtime.rs b/crates/outl-tui/src/runtime.rs index 224dd94e..18a7edcf 100644 --- a/crates/outl-tui/src/runtime.rs +++ b/crates/outl-tui/src/runtime.rs @@ -418,8 +418,14 @@ fn event_loop( backlinks_newest_first: bool, icon_style: outl_config::TuiIconStyle, ) -> Result<()> { - let mut app = App::new(workspace_root, workspace, actor, theme, shared_workspace)?; - app.icons = crate::icons::IconSet::new(icon_style); + let mut app = App::new( + workspace_root, + workspace, + actor, + theme, + shared_workspace, + icon_style, + )?; app.backlinks_newest_first = backlinks_newest_first; loop { // Pick up the background index build if it finished since the diff --git a/crates/outl-tui/src/view/outline.rs b/crates/outl-tui/src/view/outline.rs index bfb01917..5e0f2161 100644 --- a/crates/outl-tui/src/view/outline.rs +++ b/crates/outl-tui/src/view/outline.rs @@ -572,6 +572,7 @@ mod tests { actor, crate::theme::default_theme(), false, + outl_config::TuiIconStyle::Emoji, ) .unwrap(); (app, dir) From 63195d2a4ace42ebf6ac7b343e2c863738a19e04 Mon Sep 17 00:00:00 2001 From: Justin Martin Date: Thu, 17 Sep 2026 14:49:20 -0500 Subject: [PATCH 5/8] refactor(tui): keep the icon-style param under the file-size ratchet Adding App::new's icon_style param pushed every test call site over its ratchet line (+1 across five files, +6 in runtime.rs where the widened call went vertical). Tests now go through App::new_for_tests, which pins the emoji set behind the same five-arg shape they had before; the boot orchestrator calls the real constructor and its arg list fits the 100-column single line by naming the root param root. --- crates/outl-tui/src/actions/autocomplete.rs | 3 +-- crates/outl-tui/src/actions/block/metadata.rs | 3 +-- crates/outl-tui/src/actions/block/template.rs | 3 +-- crates/outl-tui/src/actions/exec.rs | 3 +-- .../src/actions/lifecycle/clock_seed_tests.rs | 3 +-- crates/outl-tui/src/actions/lifecycle/mod.rs | 22 +++++++++++++++++++ .../src/actions/lifecycle/persistence.rs | 3 +-- crates/outl-tui/src/actions/nav.rs | 3 +-- crates/outl-tui/src/actions/overlay.rs | 3 +-- crates/outl-tui/src/actions/plugins.rs | 3 +-- crates/outl-tui/src/actions/text_ops.rs | 3 +-- crates/outl-tui/src/actions/visual.rs | 3 +-- crates/outl-tui/src/actions/zoom.rs | 3 +-- .../src/commands/builtins/template.rs | 3 +-- crates/outl-tui/src/input/overlay.rs | 3 +-- crates/outl-tui/src/input/plugin_chord.rs | 3 +-- crates/outl-tui/src/runtime.rs | 11 ++-------- crates/outl-tui/src/view/outline.rs | 3 +-- 18 files changed, 40 insertions(+), 41 deletions(-) diff --git a/crates/outl-tui/src/actions/autocomplete.rs b/crates/outl-tui/src/actions/autocomplete.rs index 65b3f7ef..918ba444 100644 --- a/crates/outl-tui/src/actions/autocomplete.rs +++ b/crates/outl-tui/src/actions/autocomplete.rs @@ -656,13 +656,12 @@ mod tests { let ws = Workspace::open_with_storage(actor, Box::new(storage), Some(dir.path().to_path_buf())) .unwrap(); - let app = crate::state::App::new( + let app = crate::state::App::new_for_tests( dir.path().to_path_buf(), ws, actor, crate::theme::default_theme(), false, - outl_config::TuiIconStyle::Emoji, ) .unwrap(); (app, dir) diff --git a/crates/outl-tui/src/actions/block/metadata.rs b/crates/outl-tui/src/actions/block/metadata.rs index 19c9cda6..5d7e4dc6 100644 --- a/crates/outl-tui/src/actions/block/metadata.rs +++ b/crates/outl-tui/src/actions/block/metadata.rs @@ -169,13 +169,12 @@ mod property_edit_tests { let dir = TempDir::new().unwrap(); let actor = ActorId::new(); let ws = Workspace::open_in_memory(actor).unwrap(); - let app = App::new( + let app = App::new_for_tests( dir.path().to_path_buf(), ws, actor, crate::theme::default_theme(), false, - outl_config::TuiIconStyle::Emoji, ) .unwrap(); (app, dir) diff --git a/crates/outl-tui/src/actions/block/template.rs b/crates/outl-tui/src/actions/block/template.rs index 4b6acde4..aa25b226 100644 --- a/crates/outl-tui/src/actions/block/template.rs +++ b/crates/outl-tui/src/actions/block/template.rs @@ -132,13 +132,12 @@ mod tests { let ws = Workspace::open_with_storage(actor, Box::new(storage), Some(dir.path().to_path_buf())) .unwrap(); - let app = crate::state::App::new( + let app = crate::state::App::new_for_tests( dir.path().to_path_buf(), ws, actor, crate::theme::default_theme(), false, - outl_config::TuiIconStyle::Emoji, ) .unwrap(); (app, dir) diff --git a/crates/outl-tui/src/actions/exec.rs b/crates/outl-tui/src/actions/exec.rs index daaa5011..c69975fd 100644 --- a/crates/outl-tui/src/actions/exec.rs +++ b/crates/outl-tui/src/actions/exec.rs @@ -482,13 +482,12 @@ mod tests { let ws = Workspace::open_with_storage(actor, Box::new(storage), Some(dir.path().to_path_buf())) .unwrap(); - let app = crate::state::App::new( + let app = crate::state::App::new_for_tests( dir.path().to_path_buf(), ws, actor, crate::theme::default_theme(), false, - outl_config::TuiIconStyle::Emoji, ) .unwrap(); (app, dir) diff --git a/crates/outl-tui/src/actions/lifecycle/clock_seed_tests.rs b/crates/outl-tui/src/actions/lifecycle/clock_seed_tests.rs index a71f5317..45ead508 100644 --- a/crates/outl-tui/src/actions/lifecycle/clock_seed_tests.rs +++ b/crates/outl-tui/src/actions/lifecycle/clock_seed_tests.rs @@ -48,13 +48,12 @@ fn workspace_logged_at(actor: ActorId, ms: u64, seed: &str) -> Workspace { fn app_over(ws: Workspace, actor: ActorId) -> (App, TempDir) { let dir = TempDir::new().expect("tempdir"); - let app = App::new( + let app = App::new_for_tests( dir.path().to_path_buf(), ws, actor, crate::theme::default_theme(), false, - outl_config::TuiIconStyle::Emoji, ) .expect("App::new"); (app, dir) diff --git a/crates/outl-tui/src/actions/lifecycle/mod.rs b/crates/outl-tui/src/actions/lifecycle/mod.rs index 7d0c08a7..b39e0249 100644 --- a/crates/outl-tui/src/actions/lifecycle/mod.rs +++ b/crates/outl-tui/src/actions/lifecycle/mod.rs @@ -178,6 +178,28 @@ impl App { s.spawn_orphan_md_scanner(); Ok(s) } + + /// Test-only constructor pinning the emoji icon set, so test call + /// sites don't restate the boot-ordering parameter. Tests that + /// assert icon-style behaviour call [`App::new`] directly with the + /// style under test. + #[cfg(test)] + pub(crate) fn new_for_tests( + workspace_root: PathBuf, + workspace: Workspace, + actor: ActorId, + theme: Theme, + shared_workspace: bool, + ) -> Result { + Self::new( + workspace_root, + workspace, + actor, + theme, + shared_workspace, + outl_config::TuiIconStyle::Emoji, + ) + } } /// Last-modified time of `path`, or `None` if the file isn't there diff --git a/crates/outl-tui/src/actions/lifecycle/persistence.rs b/crates/outl-tui/src/actions/lifecycle/persistence.rs index 349b185d..81380771 100644 --- a/crates/outl-tui/src/actions/lifecycle/persistence.rs +++ b/crates/outl-tui/src/actions/lifecycle/persistence.rs @@ -254,13 +254,12 @@ mod tests { let dir = TempDir::new().unwrap(); let actor = ActorId::new(); let ws = Workspace::open_in_memory(actor).unwrap(); - let app = App::new( + let app = App::new_for_tests( dir.path().to_path_buf(), ws, actor, crate::theme::default_theme(), false, - outl_config::TuiIconStyle::Emoji, ) .unwrap(); (app, dir) diff --git a/crates/outl-tui/src/actions/nav.rs b/crates/outl-tui/src/actions/nav.rs index 16fb77c1..bd94d7a2 100644 --- a/crates/outl-tui/src/actions/nav.rs +++ b/crates/outl-tui/src/actions/nav.rs @@ -750,13 +750,12 @@ mod open_page_tests { let dir = TempDir::new().unwrap(); let actor = ActorId::new(); let ws = Workspace::open_in_memory(actor).unwrap(); - let app = App::new( + let app = App::new_for_tests( dir.path().to_path_buf(), ws, actor, crate::theme::default_theme(), false, - outl_config::TuiIconStyle::Emoji, ) .unwrap(); (app, dir) diff --git a/crates/outl-tui/src/actions/overlay.rs b/crates/outl-tui/src/actions/overlay.rs index 43cec1af..d8bdb362 100644 --- a/crates/outl-tui/src/actions/overlay.rs +++ b/crates/outl-tui/src/actions/overlay.rs @@ -688,13 +688,12 @@ mod tests { let dir = TempDir::new().unwrap(); let actor = ActorId::new(); let ws = Workspace::open_in_memory(actor).unwrap(); - let app = App::new( + let app = App::new_for_tests( dir.path().to_path_buf(), ws, actor, crate::theme::default_theme(), false, - outl_config::TuiIconStyle::Emoji, ) .unwrap(); (app, dir) diff --git a/crates/outl-tui/src/actions/plugins.rs b/crates/outl-tui/src/actions/plugins.rs index 3e0bad48..f5665b30 100644 --- a/crates/outl-tui/src/actions/plugins.rs +++ b/crates/outl-tui/src/actions/plugins.rs @@ -729,13 +729,12 @@ mod tests { fn app_with(root: &TempDir) -> App { let actor = ActorId::new(); let ws = Workspace::open_in_memory(actor).unwrap(); - App::new( + App::new_for_tests( root.path().to_path_buf(), ws, actor, crate::theme::default_theme(), false, - outl_config::TuiIconStyle::Emoji, ) .unwrap() } diff --git a/crates/outl-tui/src/actions/text_ops.rs b/crates/outl-tui/src/actions/text_ops.rs index a12309ad..67ceff19 100644 --- a/crates/outl-tui/src/actions/text_ops.rs +++ b/crates/outl-tui/src/actions/text_ops.rs @@ -249,13 +249,12 @@ mod tests { let dir = TempDir::new().unwrap(); let actor = ActorId::new(); let ws = Workspace::open_in_memory(actor).unwrap(); - let app = App::new( + let app = App::new_for_tests( dir.path().to_path_buf(), ws, actor, crate::theme::default_theme(), false, - outl_config::TuiIconStyle::Emoji, ) .unwrap(); (app, dir) diff --git a/crates/outl-tui/src/actions/visual.rs b/crates/outl-tui/src/actions/visual.rs index b1a4b1c5..0ecd8e38 100644 --- a/crates/outl-tui/src/actions/visual.rs +++ b/crates/outl-tui/src/actions/visual.rs @@ -214,13 +214,12 @@ mod tests { let dir = TempDir::new().unwrap(); let actor = ActorId::new(); let ws = Workspace::open_in_memory(actor).unwrap(); - let mut app = App::new( + let mut app = App::new_for_tests( dir.path().to_path_buf(), ws, actor, crate::theme::default_theme(), false, - outl_config::TuiIconStyle::Emoji, ) .unwrap(); app.page.blocks = vec![leaf("a"), leaf("b"), leaf("c"), leaf("d")]; diff --git a/crates/outl-tui/src/actions/zoom.rs b/crates/outl-tui/src/actions/zoom.rs index 51154fbc..dc257597 100644 --- a/crates/outl-tui/src/actions/zoom.rs +++ b/crates/outl-tui/src/actions/zoom.rs @@ -144,13 +144,12 @@ mod tests { let dir = TempDir::new().unwrap(); let actor = ActorId::new(); let ws = Workspace::open_in_memory(actor).unwrap(); - let app = App::new( + let app = App::new_for_tests( dir.path().to_path_buf(), ws, actor, crate::theme::default_theme(), false, - outl_config::TuiIconStyle::Emoji, ) .unwrap(); (app, dir) diff --git a/crates/outl-tui/src/commands/builtins/template.rs b/crates/outl-tui/src/commands/builtins/template.rs index 1e8d6c86..31f0ccfa 100644 --- a/crates/outl-tui/src/commands/builtins/template.rs +++ b/crates/outl-tui/src/commands/builtins/template.rs @@ -84,13 +84,12 @@ mod tests { fn app_with(root: &TempDir) -> App { let actor = ActorId::new(); let ws = Workspace::open_in_memory(actor).unwrap(); - App::new( + App::new_for_tests( root.path().to_path_buf(), ws, actor, crate::theme::default_theme(), false, - outl_config::TuiIconStyle::Emoji, ) .unwrap() } diff --git a/crates/outl-tui/src/input/overlay.rs b/crates/outl-tui/src/input/overlay.rs index 992bd1d9..33e317db 100644 --- a/crates/outl-tui/src/input/overlay.rs +++ b/crates/outl-tui/src/input/overlay.rs @@ -360,13 +360,12 @@ mod properties_overlay_tests { let dir = TempDir::new().unwrap(); let actor = ActorId::new(); let ws = Workspace::open_in_memory(actor).unwrap(); - let mut app = App::new( + let mut app = App::new_for_tests( dir.path().to_path_buf(), ws, actor, crate::theme::default_theme(), false, - outl_config::TuiIconStyle::Emoji, ) .unwrap(); app.page.blocks.clear(); diff --git a/crates/outl-tui/src/input/plugin_chord.rs b/crates/outl-tui/src/input/plugin_chord.rs index 08c1edde..00bdeeb2 100644 --- a/crates/outl-tui/src/input/plugin_chord.rs +++ b/crates/outl-tui/src/input/plugin_chord.rs @@ -274,13 +274,12 @@ mod tests { fn app_with(root: &TempDir) -> App { let actor = ActorId::new(); let ws = Workspace::open_in_memory(actor).unwrap(); - App::new( + App::new_for_tests( root.path().to_path_buf(), ws, actor, crate::theme::default_theme(), false, - outl_config::TuiIconStyle::Emoji, ) .unwrap() } diff --git a/crates/outl-tui/src/runtime.rs b/crates/outl-tui/src/runtime.rs index 18a7edcf..18f3ef17 100644 --- a/crates/outl-tui/src/runtime.rs +++ b/crates/outl-tui/src/runtime.rs @@ -410,7 +410,7 @@ fn open_workspace( #[allow(clippy::too_many_arguments)] fn event_loop( terminal: &mut Terminal>, - workspace_root: PathBuf, + root: PathBuf, workspace: Workspace, actor: ActorId, theme: Theme, @@ -418,14 +418,7 @@ fn event_loop( backlinks_newest_first: bool, icon_style: outl_config::TuiIconStyle, ) -> Result<()> { - let mut app = App::new( - workspace_root, - workspace, - actor, - theme, - shared_workspace, - icon_style, - )?; + let mut app = App::new(root, workspace, actor, theme, shared_workspace, icon_style)?; app.backlinks_newest_first = backlinks_newest_first; loop { // Pick up the background index build if it finished since the diff --git a/crates/outl-tui/src/view/outline.rs b/crates/outl-tui/src/view/outline.rs index 5e0f2161..cce8f068 100644 --- a/crates/outl-tui/src/view/outline.rs +++ b/crates/outl-tui/src/view/outline.rs @@ -566,13 +566,12 @@ mod tests { let dir = TempDir::new().unwrap(); let actor = ActorId::new(); let ws = Workspace::open_in_memory(actor).unwrap(); - let app = App::new( + let app = App::new_for_tests( dir.path().to_path_buf(), ws, actor, crate::theme::default_theme(), false, - outl_config::TuiIconStyle::Emoji, ) .unwrap(); (app, dir) From 02ddb7b9bf8fc8517a1b3df9ba9489f36972539d Mon Sep 17 00:00:00 2001 From: Justin Martin Date: Thu, 17 Sep 2026 16:04:10 -0500 Subject: [PATCH 6/8] fix(tui): route chrome chips and fold markers through IconSet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nerd-font set only covered property/command glyphs, so a Nerd Font user got ☑ ● ⟳ ◌ ⇇ and ▼/▶ chrome next to PUA chevrons — a mixed style from the option that promises one coherent set. The chips, the fold markers (now a fold_span accessor sharing glyph + colour) and the help legend all read from the selected set; the emoji set keeps the exact pre-PR glyphs. Also fixes the emoji default: iso* commands rendered # where they used to render 🔢. The hashtag role keeps 🔢 for emoji and \u{f292} for Nerd Font. docs/tui.md now names what stays Unicode in both styles (task checkboxes, calendar dots, scrollbar symbols). --- .github/file-size-baseline.txt | 14 ++--- crates/outl-tui/src/icons.rs | 88 +++++++++++++++++++++++++++- crates/outl-tui/src/input/normal.rs | 4 +- crates/outl-tui/src/view.rs | 2 +- crates/outl-tui/src/view/chrome.rs | 14 +++-- crates/outl-tui/src/view/outline.rs | 14 ++--- crates/outl-tui/src/view/overlays.rs | 2 +- docs/tui.md | 2 + 8 files changed, 114 insertions(+), 26 deletions(-) diff --git a/.github/file-size-baseline.txt b/.github/file-size-baseline.txt index a2517ea1..69ef52ed 100644 --- a/.github/file-size-baseline.txt +++ b/.github/file-size-baseline.txt @@ -38,11 +38,11 @@ 710 crates/outl-cli/src/cmd/doctor/repair/mod.rs 798 crates/outl-cli/src/cmd/doctor/tests/mod.rs 626 crates/outl-cli/src/cmd/page.rs -730 crates/outl-config/src/schema.rs +713 crates/outl-config/src/schema.rs 768 crates/outl-core/src/storage/jsonl/read.rs 810 crates/outl-core/src/storage/jsonl/tests.rs -877 crates/outl-core/src/workspace.rs 725 crates/outl-core/src/workspace/batch.rs +877 crates/outl-core/src/workspace.rs 712 crates/outl-core/tests/tree_unit.rs 1608 crates/outl-desktop/src/components/BlockRow.tsx 967 crates/outl-desktop/src/components/OutlineView.tsx @@ -60,13 +60,13 @@ 833 crates/outl-mobile/src/components/BlockRow.tsx 2477 crates/outl-mobile/src/components/Journal.tsx 796 crates/outl-plugins/src/engine.rs -862 crates/outl-plugins/src/host_tests.rs 830 crates/outl-plugins/src/host.rs +862 crates/outl-plugins/src/host_tests.rs 773 crates/outl-shortcuts/src/defaults.rs 706 crates/outl-shortcuts/src/support.rs 764 crates/outl-sync-iroh/src/engine_pairing.rs -736 crates/outl-sync-iroh/src/engine_sync.rs 870 crates/outl-sync-iroh/src/engine.rs +736 crates/outl-sync-iroh/src/engine_sync.rs 1245 crates/outl-sync-iroh/src/pairing.rs 1022 crates/outl-sync-iroh/src/peers.rs 821 crates/outl-sync-iroh/tests/chaos.rs @@ -80,7 +80,7 @@ 620 crates/outl-tui/src/actions/properties.rs 661 crates/outl-tui/src/input/normal.rs 646 crates/outl-tui/src/input/overlay.rs -688 crates/outl-tui/src/runtime.rs +687 crates/outl-tui/src/runtime.rs 983 crates/outl-tui/src/state.rs -741 crates/outl-tui/src/view/outline.rs -1179 crates/outl-tui/src/view/overlays.rs +725 crates/outl-tui/src/view/outline.rs +1166 crates/outl-tui/src/view/overlays.rs diff --git a/crates/outl-tui/src/icons.rs b/crates/outl-tui/src/icons.rs index 15a54525..da49983c 100644 --- a/crates/outl-tui/src/icons.rs +++ b/crates/outl-tui/src/icons.rs @@ -2,8 +2,17 @@ //! //! Emoji is the default because it works with ordinary terminal fonts. //! Nerd Font glyphs are opt-in through `[tui] icons = "nerd-font"`. +//! +//! Scope: every TUI-owned icon — status/footer chips, fold markers, +//! property/command/palette glyphs. What stays Unicode in both sets by +//! design: task checkboxes (`☐`/`◐`/`☑`, they mirror the document +//! state), calendar day dots, scrollbar symbols, and plain geometric +//! separators (`↪`, `≡`, `✕`, `·`). +use crate::theme::Theme; +use crate::view::outline::FoldMarker; use outl_config::TuiIconStyle; +use ratatui::text::Span; /// Icons used by the TUI's own chrome and placeholders. #[derive(Debug, Clone, Copy)] @@ -25,6 +34,23 @@ pub(crate) struct IconSet { pub(crate) hashtag: &'static str, pub(crate) bell: &'static str, pub(crate) play: &'static str, + /// TODO-progress header chip (`nf-fa-check-square-o`). + pub(crate) todo_chip: &'static str, + /// Insert-mode footer chip (`nf-fa-circle`). + pub(crate) editing: &'static str, + /// "saved Ns ago" freshness chip (`nf-fa-refresh`). + pub(crate) freshness: &'static str, + /// Workspace-name footer chip (`nf-fa-circle-thin`). + pub(crate) workspace: &'static str, + /// Backlink-count footer chip (`nf-fa-link`). + pub(crate) backlinks: &'static str, + /// Fold marker before an expanded parent; carries its own padding + /// space so columns stay flush (`nf-fa-chevron-down`). + pub(crate) fold_open: &'static str, + /// Fold marker before a collapsed parent (`nf-fa-chevron-right`). + pub(crate) fold_closed: &'static str, + /// Help-overlay legend line explaining the two fold markers. + pub(crate) fold_legend: &'static str, } impl IconSet { @@ -55,6 +81,17 @@ impl IconSet { } } + /// The fold marker as a styled span, glyph and colour together. + /// The `None` arm keeps the two-cell gap so leaf bullets stay + /// aligned with their parent's. + pub(crate) fn fold_span(&self, marker: FoldMarker, theme: &Theme) -> Span<'static> { + match marker { + FoldMarker::None => Span::raw(" "), + FoldMarker::Expanded => Span::styled(self.fold_open, theme.dim), + FoldMarker::Collapsed => Span::styled(self.fold_closed, theme.hint), + } + } + pub(crate) fn command_glyph(&self, name: &str) -> &'static str { match name { "run" => self.play, @@ -88,9 +125,17 @@ impl IconSet { save: "💾", clipboard: "📋", moon: "🌙", - hashtag: "#", + hashtag: "🔢", bell: "⏰", play: "▶", + todo_chip: "☑", + editing: "●", + freshness: "⟳", + workspace: "◌", + backlinks: "⇇", + fold_open: "▼ ", + fold_closed: "▶ ", + fold_legend: " (▼ expanded · ▶ collapsed · synced via op log)", } } @@ -113,6 +158,15 @@ impl IconSet { hashtag: "\u{f292}", bell: "\u{f0f3}", play: "\u{f04b}", + todo_chip: "\u{f046}", + editing: "\u{f111}", + freshness: "\u{f021}", + workspace: "\u{f1db}", + backlinks: "\u{f0c1}", + fold_open: "\u{f078} ", + fold_closed: "\u{f054} ", + fold_legend: + " (\u{f078} expanded · \u{f054} collapsed · synced via op log)", } } } @@ -157,4 +211,36 @@ mod tests { assert_eq!(nerd.property_glyph("auto-run"), Some("\u{f04b}")); assert_eq!(nerd.command_glyph("run"), "\u{f04b}"); } + + #[test] + fn emoji_preserves_the_pre_iconset_glyphs() { + let emoji = IconSet::new(TuiIconStyle::Emoji); + assert_eq!(emoji.command_glyph("iso-date-today"), "🔢"); + assert_eq!(emoji.todo_chip, "☑"); + assert_eq!(emoji.fold_open, "▼ "); + assert_eq!(emoji.fold_closed, "▶ "); + } + + #[test] + fn chrome_and_fold_glyphs_route_through_the_set() { + let nerd = IconSet::new(TuiIconStyle::NerdFont); + for glyph in [ + nerd.todo_chip, + nerd.editing, + nerd.freshness, + nerd.workspace, + nerd.backlinks, + nerd.fold_open, + nerd.fold_closed, + ] { + assert!( + glyph + .chars() + .all(|ch| ch == ' ' || (0xE000..=0xF8FF).contains(&(ch as u32))), + "nerd chip must be PUA-only: {glyph:?}" + ); + } + assert!(nerd.fold_legend.contains('\u{f078}')); + assert!(nerd.fold_legend.contains('\u{f054}')); + } } diff --git a/crates/outl-tui/src/input/normal.rs b/crates/outl-tui/src/input/normal.rs index a14c2765..ab08010a 100644 --- a/crates/outl-tui/src/input/normal.rs +++ b/crates/outl-tui/src/input/normal.rs @@ -384,8 +384,8 @@ pub(crate) fn handle_normal_key(app: &mut App, key: KeyEvent) -> Result { } KeyCode::Char('d') => app.pending_chord = Some('d'), KeyCode::Char('y') => app.pending_chord = Some('y'), - // Fold / unfold the selected block. The renderer's triangle - // marker (▶/▼) is the visual confirmation. No-op when the + // Fold / unfold the selected block. The renderer's fold + // marker (from `IconSet`) is the visual confirmation. No-op when the // block has no sidecar entry yet (see // `App::toggle_collapse_selected`). KeyCode::Char('c') => app.toggle_collapse_selected(), diff --git a/crates/outl-tui/src/view.rs b/crates/outl-tui/src/view.rs index a4331661..7e253294 100644 --- a/crates/outl-tui/src/view.rs +++ b/crates/outl-tui/src/view.rs @@ -18,7 +18,7 @@ mod backlinks; mod chrome; mod inline; mod namespace; -mod outline; +pub(crate) mod outline; pub(crate) mod overlays; mod properties; mod sidebar; diff --git a/crates/outl-tui/src/view/chrome.rs b/crates/outl-tui/src/view/chrome.rs index 3621159c..cfd15709 100644 --- a/crates/outl-tui/src/view/chrome.rs +++ b/crates/outl-tui/src/view/chrome.rs @@ -165,13 +165,16 @@ fn chips(app: &App) -> Line<'static> { .fg(Color::LightYellow) .add_modifier(Modifier::BOLD) }; - spans.push(Span::styled(format!(" ☑ {done}/{total} "), chip_style)); + spans.push(Span::styled( + format!(" {} {done}/{total} ", app.icons.todo_chip), + chip_style, + )); spans.push(Span::raw(" ")); } if matches!(app.mode, Mode::Insert { .. }) { spans.push(Span::styled( - " ● editing ", + format!(" {} editing ", app.icons.editing), Style::default() .bg(Color::DarkGray) .fg(Color::LightMagenta) @@ -184,7 +187,7 @@ fn chips(app: &App) -> Line<'static> { let secs = at.elapsed().as_secs(); let label = format_age(secs); spans.push(Span::styled( - format!(" ⟳ {label} "), + format!(" {} {label} ", app.icons.freshness), Style::default().bg(Color::DarkGray).fg(Color::Gray), )); spans.push(Span::raw(" ")); @@ -259,7 +262,7 @@ fn left_segments(app: &App) -> Line<'static> { // any terminal without nerd-font. Span::styled(" ", Style::default().bg(Color::DarkGray)), Span::styled( - format!(" ◌ {workspace_label} "), + format!(" {} {workspace_label} ", app.icons.workspace), Style::default().bg(Color::DarkGray).fg(Color::Gray), ), Span::raw(" "), @@ -270,7 +273,8 @@ fn left_segments(app: &App) -> Line<'static> { if bl_count > 0 { spans.push(Span::styled( format!( - " ⇇ {bl_count} backlink{} ", + " {} {bl_count} backlink{} ", + app.icons.backlinks, if bl_count == 1 { "" } else { "s" } ), Style::default().bg(Color::DarkGray).fg(Color::LightCyan), diff --git a/crates/outl-tui/src/view/outline.rs b/crates/outl-tui/src/view/outline.rs index cce8f068..0994f393 100644 --- a/crates/outl-tui/src/view/outline.rs +++ b/crates/outl-tui/src/view/outline.rs @@ -168,8 +168,8 @@ pub(crate) fn render_block( }; // Fold indicator for the bullet row. - // - `▼ ` when the block has children and is expanded - // - `▶ ` when it has children and is collapsed + // - `IconSet.fold_open` when the block has children and is expanded + // - `IconSet.fold_closed` when it has children and is collapsed // - ` ` (two spaces) when it has no children — keeps column // alignment with the other two cases so the bullet column // never jitters across blocks on the same indent. @@ -342,9 +342,9 @@ fn emit_embedded_children( pub(crate) enum FoldMarker { /// Block has no children — no marker, gap only. None, - /// Block has children and they're visible. `▼ ` prefix. + /// Block has children and they're visible. `fold_open` prefix. Expanded, - /// Block has children but they're folded away. `▶ ` prefix. + /// Block has children but they're folded away. `fold_closed` prefix. Collapsed, } @@ -429,11 +429,7 @@ pub(crate) fn emit_block_lines( // the marker is visible or not. Keeps the bullet `-` // column stable across siblings (leaf next to a // parent must line up). - match fold { - FoldMarker::None => head.push(Span::raw(" ")), - FoldMarker::Expanded => head.push(Span::styled("▼ ", app.theme.dim)), - FoldMarker::Collapsed => head.push(Span::styled("▶ ", app.theme.hint)), - } + head.push(app.icons.fold_span(fold, &app.theme)); // Blocks with `auto-run::` get a ⚡ before the bullet // so the user can see at a glance which cells re-run // themselves on page open. diff --git a/crates/outl-tui/src/view/overlays.rs b/crates/outl-tui/src/view/overlays.rs index ec4965cc..7b1a059d 100644 --- a/crates/outl-tui/src/view/overlays.rs +++ b/crates/outl-tui/src/view/overlays.rs @@ -962,7 +962,7 @@ fn help_tab_body(tab: usize, theme: &Theme, icons: &crate::icons::IconSet) -> Ve Line::from(" yy / p / P yank · paste after · paste before"), Line::from(" Ctrl+T cycle TODO / DOING / DONE / none"), Line::from(" c fold / unfold the selected block"), - Line::from(" (▼ expanded · ▶ collapsed · synced via op log)"), + Line::from(icons.fold_legend), Line::from(" u / Ctrl+R undo / redo"), Line::from(" g P toggle pinned:: on this page (chord)"), Line::from(""), diff --git a/docs/tui.md b/docs/tui.md index 1fec7088..2f32fdae 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -16,6 +16,8 @@ The TUI requires a real interactive terminal. If stdout isn't a TTY (e.g. CI), it exits with a clear error instead of hanging. TUI chrome uses ordinary emoji and symbols by default, so it works without a special font. Set `[tui] icons = "nerd-font"` to opt into compact Nerd Font glyphs. +That set covers every TUI-owned icon — status chips, fold markers, property and command glyphs. +Task checkboxes (`☐`/`◐`/`☑`), calendar day dots, and scrollbar symbols stay Unicode in both styles: the checkbox mirrors the document's own task state, and the rest are geometry, not icons. ## Copy to clipboard From dd01fb7af547a8f82521f61c22e53826422b0350 Mon Sep 17 00:00:00 2001 From: Justin Martin Date: Fri, 18 Sep 2026 12:01:20 -0500 Subject: [PATCH 7/8] fix(tui): fix icon glyphs and route all toast kinds through IconSet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The snoozed-reminder chip rendered 🌙; upstream draws 💤 for it, so the Emoji set now matches (field renamed moon -> snooze to match upstream's own `snoozed` vocabulary, nerd variant nf-fa-bell-slash-o). All four toast accents (success/info/warning/error) route through IconSet now — only warning was, so Nerd Font mode still leaked three colour emoji. Each value matches upstream's toasts.rs literal byte for byte. Restores the issue #142 backlinks rationale comment and the workspace_root param name in runtime.rs (unrelated churn from the icon-style plumbing); the ratchet ceiling rises accordingly. --- .github/file-size-baseline.txt | 2 +- crates/outl-tui/src/icons.rs | 164 +++++++++++++++++++++++++-- crates/outl-tui/src/runtime.rs | 14 ++- crates/outl-tui/src/view/overlays.rs | 2 +- crates/outl-tui/src/view/toasts.rs | 6 +- 5 files changed, 173 insertions(+), 15 deletions(-) diff --git a/.github/file-size-baseline.txt b/.github/file-size-baseline.txt index 69ef52ed..2b8397ff 100644 --- a/.github/file-size-baseline.txt +++ b/.github/file-size-baseline.txt @@ -80,7 +80,7 @@ 620 crates/outl-tui/src/actions/properties.rs 661 crates/outl-tui/src/input/normal.rs 646 crates/outl-tui/src/input/overlay.rs -687 crates/outl-tui/src/runtime.rs +697 crates/outl-tui/src/runtime.rs 983 crates/outl-tui/src/state.rs 725 crates/outl-tui/src/view/outline.rs 1166 crates/outl-tui/src/view/overlays.rs diff --git a/crates/outl-tui/src/icons.rs b/crates/outl-tui/src/icons.rs index da49983c..11a76e9b 100644 --- a/crates/outl-tui/src/icons.rs +++ b/crates/outl-tui/src/icons.rs @@ -15,12 +15,19 @@ use outl_config::TuiIconStyle; use ratatui::text::Span; /// Icons used by the TUI's own chrome and placeholders. +/// +/// `calendar`/`week` and `clock`/`stamp` are split so Emoji mode can +/// preserve the upstream glyphs (`📅`/`📆`, `🕐`/`🕒`) for the +/// `/date` vs `/week` and `/time` vs `/stamp` commands respectively, +/// even though Nerd Font collapses each pair to one codepoint. #[derive(Debug, Clone, Copy)] pub(crate) struct IconSet { pub(crate) calendar: &'static str, + pub(crate) week: &'static str, pub(crate) file: &'static str, pub(crate) image: &'static str, pub(crate) clock: &'static str, + pub(crate) stamp: &'static str, pub(crate) star: &'static str, pub(crate) history: &'static str, pub(crate) bolt: &'static str, @@ -28,9 +35,15 @@ pub(crate) struct IconSet { pub(crate) cog: &'static str, pub(crate) paint_brush: &'static str, pub(crate) warning: &'static str, + /// Toast accents — one per `ToastKind` arm, so the four states do + /// not drift between Emoji and Nerd Font. + pub(crate) success: &'static str, + pub(crate) info: &'static str, + pub(crate) error: &'static str, pub(crate) save: &'static str, pub(crate) clipboard: &'static str, - pub(crate) moon: &'static str, + /// Snoozed-reminder chip in the reminders overlay. + pub(crate) snooze: &'static str, pub(crate) hashtag: &'static str, pub(crate) bell: &'static str, pub(crate) play: &'static str, @@ -103,8 +116,8 @@ impl IconSet { n if n.starts_with("date") || n == "dt" || n == "dy" || n == "dtm" => self.calendar, n if n.starts_with("time") => self.clock, n if n.starts_with("iso") => self.hashtag, - n if n.starts_with("week") => self.calendar, - "stamp" => self.clock, + n if n.starts_with("week") => self.week, + "stamp" => self.stamp, _ => "·", } } @@ -112,19 +125,24 @@ impl IconSet { fn emoji() -> Self { Self { calendar: "📅", + week: "📆", file: "📄", image: "🖼", clock: "🕐", + stamp: "🕒", star: "⭐", history: "🕘", bolt: "⚡", - search: "🔍", + search: "🔎", cog: "⚙", paint_brush: "🎨", warning: "⚠", + success: "✓", + info: "ℹ", + error: "✕", save: "💾", clipboard: "📋", - moon: "🌙", + snooze: "💤", hashtag: "🔢", bell: "⏰", play: "▶", @@ -142,19 +160,24 @@ impl IconSet { fn nerd_font() -> Self { Self { calendar: "\u{f073}", + week: "\u{f073}", file: "\u{f016}", image: "\u{f03e}", clock: "\u{f017}", + stamp: "\u{f017}", star: "\u{f005}", history: "\u{f1da}", bolt: "\u{f0e7}", search: "\u{f002}", cog: "\u{f013}", - paint_brush: "\u{f1fc}", + paint_brush: "\u{f07c0}", warning: "\u{f071}", + success: "\u{f00c}", + info: "\u{f05a}", + error: "\u{f00d}", save: "\u{f0c7}", clipboard: "\u{f0ea}", - moon: "\u{f186}", + snooze: "\u{f1f6}", hashtag: "\u{f292}", bell: "\u{f0f3}", play: "\u{f04b}", @@ -201,6 +224,59 @@ mod tests { .any(|ch| (0xE000..=0xF8FF).contains(&(ch as u32)))); } + #[test] + fn nerd_font_uses_only_pua_glyphs() { + // Nerd Font mode renders nothing the user's font cannot + // draw as a single-colour cell. Every codepoint that ships + // in this set is in some Unicode PUA plane (BMP PUA, + // PUA-A in plane 15, or PUA-B in plane 16), so a terminal + // without a Nerd Font shows the well-known "missing box" + // glyph rather than a colour emoji. + fn in_some_pua(ch: char) -> bool { + let cp = ch as u32; + (0xE000..=0xF8FF).contains(&cp) + || (0xF0000..=0xFFFFD).contains(&cp) + || (0x100000..=0x10FFFD).contains(&cp) + } + let nerd = IconSet::new(TuiIconStyle::NerdFont); + for glyph in [ + nerd.calendar, + nerd.week, + nerd.file, + nerd.image, + nerd.clock, + nerd.stamp, + nerd.star, + nerd.history, + nerd.bolt, + nerd.search, + nerd.cog, + nerd.paint_brush, + nerd.warning, + nerd.success, + nerd.info, + nerd.error, + nerd.save, + nerd.clipboard, + nerd.snooze, + nerd.hashtag, + nerd.bell, + nerd.play, + nerd.todo_chip, + nerd.editing, + nerd.freshness, + nerd.workspace, + nerd.backlinks, + nerd.fold_open, + nerd.fold_closed, + ] { + assert!( + glyph.chars().all(|ch| ch == ' ' || in_some_pua(ch)), + "nerd glyph must be PUA-only (or a padding space): {glyph:?}" + ); + } + } + #[test] fn play_routes_through_the_icon_set() { let emoji = IconSet::new(TuiIconStyle::Emoji); @@ -215,10 +291,82 @@ mod tests { #[test] fn emoji_preserves_the_pre_iconset_glyphs() { let emoji = IconSet::new(TuiIconStyle::Emoji); - assert_eq!(emoji.command_glyph("iso-date-today"), "🔢"); + + // Every field literal that used to be a hardcoded glyph in + // `view/outline.rs` / `view/overlays.rs` / `view/sidebar.rs` / + // `view/chrome.rs` / `view/inline.rs` / `view/toasts.rs`. + // Reverting any of these is a silent visual regression. + assert_eq!(emoji.calendar, "📅"); + assert_eq!(emoji.week, "📆"); + assert_eq!(emoji.file, "📄"); + assert_eq!(emoji.image, "🖼"); + assert_eq!(emoji.clock, "🕐"); + assert_eq!(emoji.stamp, "🕒"); + assert_eq!(emoji.star, "⭐"); + assert_eq!(emoji.history, "🕘"); + assert_eq!(emoji.bolt, "⚡"); + assert_eq!(emoji.search, "🔎"); + assert_eq!(emoji.cog, "⚙"); + assert_eq!(emoji.paint_brush, "🎨"); + assert_eq!(emoji.warning, "⚠"); + assert_eq!(emoji.success, "✓"); + assert_eq!(emoji.info, "ℹ"); + assert_eq!(emoji.error, "✕"); + assert_eq!(emoji.save, "💾"); + assert_eq!(emoji.clipboard, "📋"); + assert_eq!(emoji.snooze, "💤"); + assert_eq!(emoji.hashtag, "🔢"); + assert_eq!(emoji.bell, "⏰"); + assert_eq!(emoji.play, "▶"); assert_eq!(emoji.todo_chip, "☑"); + assert_eq!(emoji.editing, "●"); + assert_eq!(emoji.freshness, "⟳"); + assert_eq!(emoji.workspace, "◌"); + assert_eq!(emoji.backlinks, "⇇"); assert_eq!(emoji.fold_open, "▼ "); assert_eq!(emoji.fold_closed, "▶ "); + assert_eq!( + emoji.fold_legend, + " (▼ expanded · ▶ collapsed · synced via op log)" + ); + + // Property glyphs (was `view::outline::property_glyph`). + assert_eq!( + emoji.property_glyph(outl_md::remind::REMIND_KEY), + Some("⏰") + ); + assert_eq!(emoji.property_glyph("auto-run"), Some("▶")); + assert_eq!(emoji.property_glyph("template"), Some("📋")); + assert_eq!(emoji.property_glyph("pinned"), None); + + // Category glyphs (was `view::overlays::category_icon`). + assert_eq!(emoji.category_glyph("Actions"), "⚡"); + assert_eq!(emoji.category_glyph("Navigation"), "↪"); + assert_eq!(emoji.category_glyph("Search"), "🔎"); + assert_eq!(emoji.category_glyph("Settings"), "⚙"); + assert_eq!(emoji.category_glyph("Dates & time"), "📅"); + assert_eq!(emoji.category_glyph("Other"), "•"); + + // Command glyphs (was `view::overlays::command_icon`). + assert_eq!(emoji.command_glyph("run"), "▶"); + assert_eq!(emoji.command_glyph("prop"), "≡"); + assert_eq!(emoji.command_glyph("search"), "🔎"); + assert_eq!(emoji.command_glyph("find"), "🔎"); + assert_eq!(emoji.command_glyph("theme"), "🎨"); + assert_eq!(emoji.command_glyph("open"), "↪"); + assert_eq!(emoji.command_glyph("switch"), "↪"); + assert_eq!(emoji.command_glyph("quit"), "✕"); + assert_eq!(emoji.command_glyph("q"), "✕"); + assert_eq!(emoji.command_glyph("date-today"), "📅"); + assert_eq!(emoji.command_glyph("dt"), "📅"); + assert_eq!(emoji.command_glyph("dy"), "📅"); + assert_eq!(emoji.command_glyph("dtm"), "📅"); + assert_eq!(emoji.command_glyph("time-now"), "🕐"); + assert_eq!(emoji.command_glyph("iso-date-today"), "🔢"); + assert_eq!(emoji.command_glyph("week"), "📆"); + assert_eq!(emoji.command_glyph("week-tag"), "📆"); + assert_eq!(emoji.command_glyph("stamp"), "🕒"); + assert_eq!(emoji.command_glyph("anything-else"), "·"); } #[test] diff --git a/crates/outl-tui/src/runtime.rs b/crates/outl-tui/src/runtime.rs index 18f3ef17..92d589c9 100644 --- a/crates/outl-tui/src/runtime.rs +++ b/crates/outl-tui/src/runtime.rs @@ -410,7 +410,7 @@ fn open_workspace( #[allow(clippy::too_many_arguments)] fn event_loop( terminal: &mut Terminal>, - root: PathBuf, + workspace_root: PathBuf, workspace: Workspace, actor: ActorId, theme: Theme, @@ -418,7 +418,17 @@ fn event_loop( backlinks_newest_first: bool, icon_style: outl_config::TuiIconStyle, ) -> Result<()> { - let mut app = App::new(root, workspace, actor, theme, shared_workspace, icon_style)?; + let mut app = App::new( + workspace_root, + workspace, + actor, + theme, + shared_workspace, + icon_style, + )?; + // Apply the persisted backlinks direction (issue #142); the field + // only feeds the render path, so setting it post-construction is + // enough and keeps it out of `App::new`'s already-long signature. app.backlinks_newest_first = backlinks_newest_first; loop { // Pick up the background index build if it finished since the diff --git a/crates/outl-tui/src/view/overlays.rs b/crates/outl-tui/src/view/overlays.rs index 7b1a059d..03f4e721 100644 --- a/crates/outl-tui/src/view/overlays.rs +++ b/crates/outl-tui/src/view/overlays.rs @@ -643,7 +643,7 @@ pub(crate) fn render_reminders( None => "—".to_string(), }; let snoozed = if r.snoozed_until_ms.is_some() { - format!(" {}", app.icons.moon) + format!(" {}", app.icons.snooze) } else { String::new() }; diff --git a/crates/outl-tui/src/view/toasts.rs b/crates/outl-tui/src/view/toasts.rs index 83243a6f..e35c8395 100644 --- a/crates/outl-tui/src/view/toasts.rs +++ b/crates/outl-tui/src/view/toasts.rs @@ -78,10 +78,10 @@ pub(crate) fn render_toasts(f: &mut ratatui::Frame<'_>, full: Rect, app: &App) { fn icon_and_color(kind: ToastKind, icons: &IconSet) -> (&'static str, Color) { match kind { - ToastKind::Success => ("✓", Color::LightGreen), - ToastKind::Info => ("ℹ", Color::LightCyan), + ToastKind::Success => (icons.success, Color::LightGreen), + ToastKind::Info => (icons.info, Color::LightCyan), ToastKind::Warning => (icons.warning, Color::LightYellow), - ToastKind::Error => ("✕", Color::LightRed), + ToastKind::Error => (icons.error, Color::LightRed), } } From 08119f0b65d562ac487ebc9ce7cdda87fc5c03e2 Mon Sep 17 00:00:00 2001 From: Justin Martin Date: Fri, 18 Sep 2026 12:01:33 -0500 Subject: [PATCH 8/8] fix(desktop): restore [snapshot] and [storage] on settings save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saving the settings modal rebuilds `Config` from the flat wire shape and restored only some of the sections it does not model. `[snapshot]` and `[storage]` were missing, so a save silently reset the boot-cache policy and the op-log LRU cap to defaults — the same class of loss the `[tui]` and `[backup]` restores exist to prevent. Restore both from disk and cover them in the renamed save_restores_the_sections_the_desktop_never_models test. --- CHANGELOG.md | 19 ++++++++++++++++ crates/outl-desktop/src-tauri/src/settings.rs | 22 ++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2d4a9d5..1ec82ba9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ Format inspired by [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the ### Added +- **Monochrome Nerd Font icons in the TUI, opt-in via `[tui] icons = "nerd-font"`.** + Every TUI icon — footer chips, fold markers, sidebar glyphs, palette/property/command glyphs — used to render as a colour emoji, with no way to turn that off. + Some terminals (alacritty without `font-emoji`, kitty in some configs, most tmux setups) either render them as broken boxes, double them with the surrounding text, or pick a fallback that disagrees with the rest of the UI's typography. + There was no supported way to get a compact monochrome outline. + + `[tui] icons` now selects between two sets shipped from one `IconSet` in `outl-tui::icons`: + `"emoji"` (the default, byte-for-byte what the TUI rendered before) and `"nerd-font"` (Font Awesome 4 + Material Design codepoints from any patched font). + The selection is read once at boot in `runtime::run`, propagated to `App::icons`, and threaded through every view module that renders a glyph. + `property_glyph`, `category_glyph`, `command_glyph` and `fold_span` are now methods on `IconSet`, so a per-view "which glyph does this role take" question can no longer be answered twice. + + **Emoji mode is pinned to upstream's literals by an exhaustive test** (`emoji_preserves_the_pre_iconset_glyphs`) that asserts every field value AND every `property_glyph` / `category_glyph` / `command_glyph` arm returns the byte sequence the pre-IconSet code returned. + The `📅`/`📆` and `🕐`/`🕒` pairs are split into `calendar`/`week` and `clock`/`stamp` fields for exactly that reason — the Emoji set must keep them distinct (the upstream `/week*` and `/stamp` commands render different glyphs from `/date*` and `/time*`), even though Nerd Font collapses each pair to one codepoint. + The mirror test (`nerd_font_uses_only_pua_glyphs`) walks every nerd field and refuses anything outside the three Unicode PUA planes, so a future contributor cannot quietly drop a colour emoji back into the Nerd Font set. + The same exhaustive `match` shape on a new Tauri command or wire DTO is what makes those safe; this is the same discipline applied to glyphs. + + The desktop settings modal had a sibling defect on the way out — saving it stomped the `[tui]` block out of the global config because `TuiCfg` was not part of its round-trip. + The same hole existed for `[snapshot]` and `[storage]`, neither of which the modal models either: a save silently reset a hand-set boot-cache policy or op-log LRU cap to the defaults. + All three are now restored from disk on save, pinned by the extended `save_restores_the_sections_the_desktop_never_models` test. + - **`UX.md` — the behaviour half of the design specification.** `DESIGN.md` was carrying two documents. One of them answered *what it looks like* — roles, tokens, spacing, elevation — and the other, scattered through the Components, Do's-and-Don'ts, Platform-divergence and Accessibility sections, answered *what happens*: what a `Missing` verdict promises the user, why a nudge may not say "unimplemented", why a chord with no handler is worse than an error. The second document had no name, so nothing linked to it and every new interaction rule landed wherever it fit. diff --git a/crates/outl-desktop/src-tauri/src/settings.rs b/crates/outl-desktop/src-tauri/src/settings.rs index 1f77f33e..8f1f2c1d 100644 --- a/crates/outl-desktop/src-tauri/src/settings.rs +++ b/crates/outl-desktop/src-tauri/src/settings.rs @@ -261,6 +261,13 @@ fn restore_unmodeled_sections(cfg: &mut Config, on_disk: &Config) { // style or mouse-capture toggle (the `into()` conversion leaves // `TuiCfg::default()`, and a default here silently means "emoji"). cfg.tui = on_disk.tui.clone(); + // `[snapshot]` (boot-cache policy) and `[storage]` are core-managed and + // not modeled in the flat Settings; restore them so a modal save can't + // silently flip a hand-set snapshot toggle/threshold or storage choice + // back to the defaults — the same `[tui]`-style loss these restores exist + // to prevent. + cfg.snapshot = on_disk.snapshot.clone(); + cfg.storage = on_disk.storage.clone(); // `[theme]` (all three fields: `preset`, `preset_dark`, `mode`) is now // FULLY modeled in `Settings` — the modal owns the whole pair. Do NOT // add a restore-from-disk line for any of them here: that was the @@ -417,12 +424,15 @@ mod tests { /// `[calendar]` / `[backup]` restores exist to prevent, and worse /// here because the default reads as the user's own opt-in. #[test] - fn save_restores_the_tui_section_the_desktop_never_models() { + fn save_restores_the_sections_the_desktop_never_models() { use outl_config::TuiIconStyle; let mut on_disk = Config::default(); on_disk.tui.icons = TuiIconStyle::NerdFont; on_disk.tui.mouse_capture = true; + on_disk.snapshot.enabled = false; + on_disk.snapshot.op_threshold = 500; + on_disk.storage.lru_cap = 5_000; let mut cfg: Config = Settings::fresh().into(); assert_eq!( @@ -437,5 +447,15 @@ mod tests { "[tui] is unmodelled by Settings and must be restored from disk — \ dropping it wipes the user's icon style and mouse-capture opt-in" ); + assert_eq!( + cfg.snapshot, on_disk.snapshot, + "[snapshot] is unmodelled by Settings and must be restored from disk — \ + dropping it silently flips the boot-cache policy back to the defaults" + ); + assert_eq!( + cfg.storage, on_disk.storage, + "[storage] is unmodelled by Settings and must be restored from disk — \ + dropping it silently flips the op-log LRU cap back to the default" + ); } }