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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .github/file-size-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<file name>`, 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.

Expand Down
3 changes: 3 additions & 0 deletions crates/outl-config/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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<String>` is the dark side, and `mode` is a [`ThemeMode`] enum (`Light` | `Dark` | `Auto`, serde `lowercase`, default `Auto`).
Expand Down Expand Up @@ -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)`) |
Expand Down
8 changes: 6 additions & 2 deletions crates/outl-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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};
Expand Down
19 changes: 1 addition & 18 deletions crates/outl-config/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)]
Expand Down
50 changes: 50 additions & 0 deletions crates/outl-config/src/tui.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
62 changes: 60 additions & 2 deletions crates/outl-desktop/src-tauri/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,9 @@ impl From<Settings> 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
Expand Down Expand Up @@ -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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

real fix, thanks. [snapshot] and [storage] have the identical bug though, and the comments at lines 194 and 197 already claim save restores them from disk.

Config has 12 sections, this function restores 6, Settings models 5. a hand-set op_threshold or lru_cap gets reset to the default on every modal save, silently, same as icons did.

Suggested change
cfg.tui = on_disk.tui.clone();
cfg.tui = on_disk.tui.clone();
cfg.snapshot = on_disk.snapshot.clone();
cfg.storage = on_disk.storage.clone();

and extend save_restores_the_tui_section_the_desktop_never_models to cover them.

// `[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
Expand Down Expand Up @@ -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"
);
}
}
2 changes: 1 addition & 1 deletion crates/outl-tui/src/actions/autocomplete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion crates/outl-tui/src/actions/block/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion crates/outl-tui/src/actions/block/template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion crates/outl-tui/src/actions/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion crates/outl-tui/src/actions/lifecycle/clock_seed_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading