diff --git a/.github/file-size-baseline.txt b/.github/file-size-baseline.txt index bcc48285..3568cc67 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 2071 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 +697 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/CHANGELOG.md b/CHANGELOG.md index 51db1149..655268f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ 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. - **"Open With β†’ outl" on the desktop β€” a `.md` or `.txt` from anywhere becomes a page.** Right-click a file in Finder / Explorer / a Linux file manager, pick outl, and the file lands as a page titled `open-in/`, built out of ordinary ops like everything else. `bundle.fileAssociations` registers the four extensions with `role: "Viewer"` and `rank: "Alternate"` β€” outl **imports a copy and never writes back to the file**, and it must not quietly become the system handler for every `.txt` on the machine. 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..4c36eb6f 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 @@ -61,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, - 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 2a7f26b3..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,24 +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 { - /// 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, -} - /// 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)] 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-desktop/src-tauri/src/settings.rs b/crates/outl-desktop/src-tauri/src/settings.rs index ba76f09e..8f1f2c1d 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,18 @@ 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(); + // `[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 @@ -400,4 +413,49 @@ 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_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!( + 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" + ); + 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" + ); + } } diff --git a/crates/outl-tui/src/actions/autocomplete.rs b/crates/outl-tui/src/actions/autocomplete.rs index dca88e76..918ba444 100644 --- a/crates/outl-tui/src/actions/autocomplete.rs +++ b/crates/outl-tui/src/actions/autocomplete.rs @@ -656,7 +656,7 @@ 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, diff --git a/crates/outl-tui/src/actions/block/metadata.rs b/crates/outl-tui/src/actions/block/metadata.rs index 4288fd1c..5d7e4dc6 100644 --- a/crates/outl-tui/src/actions/block/metadata.rs +++ b/crates/outl-tui/src/actions/block/metadata.rs @@ -169,7 +169,7 @@ 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, diff --git a/crates/outl-tui/src/actions/block/template.rs b/crates/outl-tui/src/actions/block/template.rs index 41c5f748..aa25b226 100644 --- a/crates/outl-tui/src/actions/block/template.rs +++ b/crates/outl-tui/src/actions/block/template.rs @@ -132,7 +132,7 @@ 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, diff --git a/crates/outl-tui/src/actions/exec.rs b/crates/outl-tui/src/actions/exec.rs index 02716d88..c69975fd 100644 --- a/crates/outl-tui/src/actions/exec.rs +++ b/crates/outl-tui/src/actions/exec.rs @@ -482,7 +482,7 @@ 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, 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..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,7 +48,7 @@ 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, diff --git a/crates/outl-tui/src/actions/lifecycle/loading.rs b/crates/outl-tui/src/actions/lifecycle/loading.rs index f2199745..6750c991 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() ); } @@ -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 83b50438..b39e0249 100644 --- a/crates/outl-tui/src/actions/lifecycle/mod.rs +++ b/crates/outl-tui/src/actions/lifecycle/mod.rs @@ -48,9 +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 { + // 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, @@ -172,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 31c149c0..81380771 100644 --- a/crates/outl-tui/src/actions/lifecycle/persistence.rs +++ b/crates/outl-tui/src/actions/lifecycle/persistence.rs @@ -254,7 +254,7 @@ 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, diff --git a/crates/outl-tui/src/actions/nav.rs b/crates/outl-tui/src/actions/nav.rs index dbd53bcb..bd94d7a2 100644 --- a/crates/outl-tui/src/actions/nav.rs +++ b/crates/outl-tui/src/actions/nav.rs @@ -750,7 +750,7 @@ 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, diff --git a/crates/outl-tui/src/actions/overlay.rs b/crates/outl-tui/src/actions/overlay.rs index 20137ec9..d8bdb362 100644 --- a/crates/outl-tui/src/actions/overlay.rs +++ b/crates/outl-tui/src/actions/overlay.rs @@ -688,7 +688,7 @@ 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, diff --git a/crates/outl-tui/src/actions/plugins.rs b/crates/outl-tui/src/actions/plugins.rs index edab0792..f5665b30 100644 --- a/crates/outl-tui/src/actions/plugins.rs +++ b/crates/outl-tui/src/actions/plugins.rs @@ -729,7 +729,7 @@ 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, 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/actions/text_ops.rs b/crates/outl-tui/src/actions/text_ops.rs index fb160f77..67ceff19 100644 --- a/crates/outl-tui/src/actions/text_ops.rs +++ b/crates/outl-tui/src/actions/text_ops.rs @@ -249,7 +249,7 @@ 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, diff --git a/crates/outl-tui/src/actions/visual.rs b/crates/outl-tui/src/actions/visual.rs index 0505a6b9..0ecd8e38 100644 --- a/crates/outl-tui/src/actions/visual.rs +++ b/crates/outl-tui/src/actions/visual.rs @@ -214,7 +214,7 @@ 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, diff --git a/crates/outl-tui/src/actions/zoom.rs b/crates/outl-tui/src/actions/zoom.rs index 4945e230..dc257597 100644 --- a/crates/outl-tui/src/actions/zoom.rs +++ b/crates/outl-tui/src/actions/zoom.rs @@ -144,7 +144,7 @@ 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, 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/commands/builtins/template.rs b/crates/outl-tui/src/commands/builtins/template.rs index d75b8810..31f0ccfa 100644 --- a/crates/outl-tui/src/commands/builtins/template.rs +++ b/crates/outl-tui/src/commands/builtins/template.rs @@ -84,7 +84,7 @@ 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, diff --git a/crates/outl-tui/src/icons.rs b/crates/outl-tui/src/icons.rs new file mode 100644 index 00000000..11a76e9b --- /dev/null +++ b/crates/outl-tui/src/icons.rs @@ -0,0 +1,394 @@ +//! 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"`. +//! +//! 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. +/// +/// `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, + pub(crate) search: &'static str, + 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, + /// 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, + /// 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 { + pub(crate) fn new(style: TuiIconStyle) -> Self { + match style { + TuiIconStyle::Emoji => Self::emoji(), + TuiIconStyle::NerdFont => Self::nerd_font(), + } + } + + 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(self.play), + "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, + _ => "β€’", + } + } + + /// 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, + "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.week, + "stamp" => self.stamp, + _ => "Β·", + } + } + + fn emoji() -> Self { + Self { + calendar: "πŸ“…", + week: "πŸ“†", + file: "πŸ“„", + image: "πŸ–Ό", + clock: "πŸ•", + stamp: "πŸ•’", + star: "⭐", + history: "πŸ•˜", + bolt: "⚑", + search: "πŸ”Ž", + cog: "βš™", + paint_brush: "🎨", + warning: "⚠", + success: "βœ“", + info: "β„Ή", + error: "βœ•", + save: "πŸ’Ύ", + clipboard: "πŸ“‹", + snooze: "πŸ’€", + hashtag: "πŸ”’", + bell: "⏰", + play: "β–Ά", + todo_chip: "β˜‘", + editing: "●", + freshness: "⟳", + workspace: "β—Œ", + backlinks: "⇇", + fold_open: "β–Ό ", + fold_closed: "β–Ά ", + fold_legend: " (β–Ό expanded Β· β–Ά collapsed Β· synced via op log)", + } + } + + 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{f07c0}", + warning: "\u{f071}", + success: "\u{f00c}", + info: "\u{f05a}", + error: "\u{f00d}", + save: "\u{f0c7}", + clipboard: "\u{f0ea}", + snooze: "\u{f1f6}", + 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)", + } + } +} + +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)))); + } + + #[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); + 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}"); + } + + #[test] + fn emoji_preserves_the_pre_iconset_glyphs() { + let emoji = IconSet::new(TuiIconStyle::Emoji); + + // 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] + 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/input/overlay.rs b/crates/outl-tui/src/input/overlay.rs index 3af2d3e0..33e317db 100644 --- a/crates/outl-tui/src/input/overlay.rs +++ b/crates/outl-tui/src/input/overlay.rs @@ -360,7 +360,7 @@ 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, diff --git a/crates/outl-tui/src/input/plugin_chord.rs b/crates/outl-tui/src/input/plugin_chord.rs index 58931613..00bdeeb2 100644 --- a/crates/outl-tui/src/input/plugin_chord.rs +++ b/crates/outl-tui/src/input/plugin_chord.rs @@ -274,7 +274,7 @@ 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, 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..92d589c9 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,16 @@ 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)?; + 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. diff --git a/crates/outl-tui/src/state.rs b/crates/outl-tui/src/state.rs index 916412e8..e33df923 100644 --- a/crates/outl-tui/src/state.rs +++ b/crates/outl-tui/src/state.rs @@ -532,8 +532,8 @@ pub(crate) struct AutocompleteState { pub(crate) selected: usize, } -/// Application state. pub(crate) struct App { + 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.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/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..cfd15709 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) => { @@ -164,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) @@ -183,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(" ")); @@ -258,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(" "), @@ -269,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), @@ -297,13 +302,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 +321,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 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. if has_auto_run { - head.push(Span::styled("⚑", app.theme.hint)); + head.push(Span::styled(app.icons.bolt, app.theme.hint)); } head.push(Span::styled("- ", bullet_style)); } @@ -488,9 +484,13 @@ pub(crate) fn emit_block_lines( // function the embed expansion uses, so the // chrome stays in lockstep between bullet and // embed root. - content.extend(render_pretty_block_text(row.text, &app.theme, &app.index)); + content.extend(render_pretty_block_text( + row.text, &app.theme, &app.index, &app.icons, + )); } - _ => content.extend(render_markdown_inline(row.text, &app.theme, &app.index)), + _ => content.extend(render_markdown_inline( + row.text, &app.theme, &app.index, &app.icons, + )), } } @@ -551,22 +551,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) -> Option<&'static str> { - match key.to_ascii_lowercase().as_str() { - outl_md::remind::REMIND_KEY => Some("⏰"), - "auto-run" => Some("β–Ά"), - "template" => Some("πŸ“‹"), - _ => None, - } -} - #[cfg(test)] mod tests { use super::*; @@ -578,7 +562,7 @@ 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, diff --git a/crates/outl-tui/src/view/overlays.rs b/crates/outl-tui/src/view/overlays.rs index 3bce0938..03f4e721 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!(" {} {} ", app.icons.category_glyph(cat), 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} ", + app.icons.command_glyph(&c.name), + c.name + ), style, ), Span::styled(c.description.to_string(), app.theme.dim), @@ -549,36 +553,6 @@ pub(crate) fn visual_order(candidates: &[crate::state::SlashCommand]) -> Vec &'static str { - match cat { - "Actions" => "⚑", - "Navigation" => "β†ͺ", - "Search" => "πŸ”Ž", - "Settings" => "βš™", - "Dates & time" => "πŸ“…", - _ => "β€’", - } -} - -/// Per-command leading glyph. Falls back to a dot for anything we -/// haven't curated. -fn command_icon(name: &str) -> &'static str { - match name { - "run" => "β–Ά", - "prop" => "≑", - "search" | "find" => "πŸ”Ž", - "theme" => "🎨", - "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" => "πŸ•’", - _ => "Β·", - } -} - pub(crate) fn render_template_picker( f: &mut ratatui::Frame<'_>, full: Rect, @@ -594,7 +568,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 +586,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 +643,9 @@ pub(crate) fn render_reminders( None => "β€”".to_string(), }; let snoozed = if r.snoozed_until_ms.is_some() { - " πŸ’€" + format!(" {}", app.icons.snooze) } else { - "" + String::new() }; let label = format!( " {:<18} {:<34} {}{}", @@ -930,7 +908,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 +949,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)), @@ -984,7 +962,7 @@ fn help_tab_body(tab: usize, theme: &Theme) -> Vec> { 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(""), @@ -1068,10 +1046,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 +1109,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..e35c8395 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,12 +76,12 @@ 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::Error => ("βœ•", Color::LightRed), + ToastKind::Success => (icons.success, Color::LightGreen), + ToastKind::Info => (icons.info, Color::LightCyan), + ToastKind::Warning => (icons.warning, Color::LightYellow), + ToastKind::Error => (icons.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..2f32fdae 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -14,6 +14,10 @@ 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. +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 @@ -73,6 +77,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