markdown-tui-explorer 1.34.70

A terminal-based markdown file browser and viewer with search, syntax highlighting, and live reload
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
use std::fs;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};

use crate::theme::Theme;

const APP_NAME: &str = "markdown-reader";
const CONFIG_FILE: &str = "config.toml";

/// Default value for [`Config::mermaid_max_height`].
///
/// 30 lines is a comfortable default — large enough to show a typical diagram
/// without consuming the entire viewport.
fn default_mermaid_max_height() -> u32 {
    30
}

/// Default value for [`UpdatesConfig::check_for_updates`].
///
/// Returns `true` so the feature is on by default for new installs.
/// Users who prefer no network activity can set `check_for_updates = false`
/// in the `[updates]` section of `config.toml`.
fn default_check_for_updates() -> bool {
    true
}

/// Settings that control the automatic update-notification feature.
///
/// Serialised as the `[updates]` TOML table inside `config.toml`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdatesConfig {
    /// When `true` (the default), the app checks crates.io once per 24 hours
    /// in a background thread.  If a newer version is found, a brief upgrade
    /// banner is printed to stderr when you quit.
    ///
    /// Set to `false` to disable the check entirely (no network activity).
    #[serde(default = "default_check_for_updates")]
    pub check_for_updates: bool,
}

impl Default for UpdatesConfig {
    fn default() -> Self {
        Self {
            check_for_updates: default_check_for_updates(),
        }
    }
}

/// Default value for [`Config::use_hybrid_by_default`].
///
/// Returns `true` so that lowercase `i` opens hybrid live-preview mode for
/// new installs.  Users who prefer the old fullscreen edtui behaviour can set
/// `use_hybrid_by_default = false` in `config.toml` to restore the pre-1.33.0
/// mapping while regressions are still being filed.
fn default_use_hybrid_by_default() -> bool {
    true
}

/// Which side of the viewer the file-tree panel is rendered on.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TreePosition {
    #[default]
    Left,
    Right,
}

/// Controls how mermaid diagrams are rendered in the viewer.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MermaidMode {
    /// Try image rendering (when graphics are available), then figurehead
    /// Unicode box-drawing text, then raw source as a last resort.
    ///
    /// For diagram types with known image-render issues (e.g. `stateDiagram`),
    /// figurehead is tried first; the image pipeline is skipped for those types.
    Auto,
    /// Always use figurehead Unicode text rendering. Never spawns image tasks.
    ///
    /// The default mode. CPU-lighter than `Auto`, works inside tmux and any
    /// terminal without graphics protocol support.  Existing config files with
    /// `mermaid_mode = "auto"` keep that setting; only users with no explicit
    /// `mermaid_mode` in their TOML are affected by this default change.
    #[default]
    Text,
    /// Only use the image pipeline when a graphics protocol is available.
    ///
    /// Falls back directly to raw source when graphics are not available —
    /// figurehead is not tried. Useful when you want images or nothing.
    Image,
}

/// Which layered-layout backend to use when rendering text-mode flowchart and
/// state diagrams via `mermaid-text`.
///
/// `mermaid-text` ships two backends. `Sugiyama` (the historical default since
/// the library's 0.17.0 release) is `ascii-dag`-backed with proper crossing
/// minimisation and Brandes-Köpf coordinate assignment — it tends to produce
/// the cleanest layouts for flat dependency graphs. `Native` is the in-house
/// layered layout that has fuller coverage of subgraph-heavy diagrams,
/// parallel-edge groups, and nested direction overrides.
///
/// `Auto` is a conservative selector that picks `Native` only for the one
/// shape where Sugiyama is known to render less compactly — a `subgraph`
/// block with an inner `direction` override — and falls back to `Sugiyama`
/// for every other diagram.  It is opt-in for now; the plan is to promote it
/// to the default once it has a release cycle of real-world exercise.
///
/// This setting only affects text-mode flowchart and state diagrams; sequence,
/// pie, ER, mindmap and the various beta diagram types have their own
/// pipelines and are unaffected. Image-mode rendering (which goes through
/// `mermaid-rs-renderer`) is also unaffected.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MermaidTextBackend {
    /// Conservative selector — `Native` for subgraphs with inner `direction`
    /// overrides, `Sugiyama` everywhere else.
    Auto,
    /// `ascii-dag`-backed Sugiyama layout. The historical default.
    #[default]
    Sugiyama,
    /// In-house layered layout with fuller subgraph and parallel-edge coverage.
    Native,
}

/// How to render the inline preview for a content-search result.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SearchPreview {
    /// Show the full matched line (trimmed).  More readable; may wrap on narrow
    /// terminals.
    #[default]
    FullLine,
    /// Show an ~80-character window centred on the first match occurrence.
    /// Compact, uniform row height.
    Snippet,
}

/// All persisted user settings.
///
/// `#[serde(default)]` on every field ensures that config files written by
/// older versions of the app (missing newer fields) still parse correctly.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
    #[serde(default)]
    pub theme: Theme,
    #[serde(default)]
    pub show_line_numbers: bool,
    #[serde(default)]
    pub tree_position: TreePosition,
    #[serde(default)]
    pub search_preview: SearchPreview,
    /// How mermaid diagrams are rendered. See [`MermaidMode`] for details.
    #[serde(default)]
    pub mermaid_mode: MermaidMode,
    /// Maximum height of a mermaid diagram block in display lines.
    ///
    /// Diagrams taller than this are clamped. Tune this if your most common
    /// diagrams are either clipped or consuming too much viewport space.
    /// The minimum is always 8 lines regardless of this setting.
    ///
    /// There is no UI widget for this field — edit `config.toml` directly.
    #[serde(default = "default_mermaid_max_height")]
    pub mermaid_max_height: u32,
    /// Which layered-layout backend to use when rendering text-mode flowchart
    /// and state diagrams.  See [`MermaidTextBackend`] for the trade-offs.
    #[serde(default)]
    pub mermaid_text_backend: MermaidTextBackend,
    /// When `true` (the default), `i` opens hybrid live-preview mode and `I`
    /// opens the legacy fullscreen edtui.  Set to `false` to restore the
    /// pre-1.33.0 behaviour (`i` → fullscreen, `I` → hybrid) as an opt-out
    /// while regressions are being filed.
    #[serde(default = "default_use_hybrid_by_default")]
    pub use_hybrid_by_default: bool,
    /// Automatic update-notification settings.
    ///
    /// Serialised as `[updates]` in `config.toml`.
    #[serde(default)]
    pub updates: UpdatesConfig,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            theme: Theme::default(),
            show_line_numbers: false,
            tree_position: TreePosition::default(),
            search_preview: SearchPreview::default(),
            mermaid_mode: MermaidMode::default(),
            mermaid_max_height: default_mermaid_max_height(),
            mermaid_text_backend: MermaidTextBackend::default(),
            use_hybrid_by_default: default_use_hybrid_by_default(),
            updates: UpdatesConfig::default(),
        }
    }
}

impl Config {
    /// Load settings from disk, returning defaults on any I/O or parse failure.
    pub fn load() -> Self {
        let Some(path) = config_path() else {
            return Self::default();
        };
        let Ok(text) = fs::read_to_string(&path) else {
            return Self::default();
        };
        toml::from_str(&text).unwrap_or_default()
    }

    /// Persist settings to disk. Silently swallows any I/O error.
    pub fn save(&self) {
        let Some(path) = config_path() else {
            return;
        };
        if let Some(parent) = path.parent()
            && fs::create_dir_all(parent).is_err()
        {
            return;
        }
        let Ok(text) = toml::to_string_pretty(self) else {
            return;
        };
        let _ = fs::write(&path, text);
    }

    /// Return a [`MermaidMode`] label suitable for display (e.g. in the UI).
    pub fn mermaid_mode_label(mode: MermaidMode) -> &'static str {
        match mode {
            MermaidMode::Auto => "Auto",
            MermaidMode::Text => "Text only",
            MermaidMode::Image => "Image only",
        }
    }

    /// Return a [`MermaidTextBackend`] label suitable for display (e.g. in the UI).
    pub fn mermaid_text_backend_label(backend: MermaidTextBackend) -> &'static str {
        match backend {
            MermaidTextBackend::Auto => "Backend: Auto (subgraph-aware)",
            MermaidTextBackend::Sugiyama => "Backend: Sugiyama (default)",
            MermaidTextBackend::Native => "Backend: Native (subgraph-friendly)",
        }
    }
}

fn config_path() -> Option<PathBuf> {
    let mut path = dirs::config_dir()?;
    path.push(APP_NAME);
    path.push(CONFIG_FILE);
    Some(path)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// `SearchPreview` must round-trip through TOML with the default value.
    #[test]
    fn search_preview_default_round_trips() {
        let config = Config::default();
        let serialized = toml::to_string_pretty(&config).expect("serialization failed");
        let deserialized: Config = toml::from_str(&serialized).expect("deserialization failed");
        assert_eq!(deserialized.search_preview, SearchPreview::FullLine);
    }

    /// A TOML file that omits `search_preview` must deserialize to `FullLine`.
    #[test]
    fn search_preview_missing_field_defaults_to_full_line() {
        let toml_str = r#"theme = "default""#;
        let config: Config = toml::from_str(toml_str).expect("deserialization failed");
        assert_eq!(config.search_preview, SearchPreview::default());
    }

    /// `mermaid_max_height` must survive a TOML round-trip with a custom value.
    #[test]
    fn mermaid_max_height_config_roundtrip() {
        let config = Config {
            mermaid_max_height: 25,
            ..Config::default()
        };
        let serialized = toml::to_string_pretty(&config).expect("serialization failed");
        let deserialized: Config = toml::from_str(&serialized).expect("deserialization failed");
        assert_eq!(deserialized.mermaid_max_height, 25);
    }

    /// A TOML file without `mermaid_max_height` must use the default (30).
    #[test]
    fn mermaid_max_height_missing_field_defaults_to_30() {
        let toml_str = r#"theme = "default""#;
        let config: Config = toml::from_str(toml_str).expect("deserialization failed");
        assert_eq!(config.mermaid_max_height, 30);
    }

    /// A non-default `MermaidTextBackend` must survive a TOML round-trip.
    /// Catches: a `Default` impl that masks the deserialised value, a
    /// serde-rename mismatch, or a missing `#[serde(default)]` attribute.
    #[test]
    fn mermaid_text_backend_round_trips() {
        let config = Config {
            mermaid_text_backend: MermaidTextBackend::Native,
            ..Config::default()
        };
        let serialized = toml::to_string_pretty(&config).expect("serialization failed");
        let deserialized: Config = toml::from_str(&serialized).expect("deserialization failed");
        assert_eq!(
            deserialized.mermaid_text_backend,
            MermaidTextBackend::Native
        );
    }

    /// A TOML file without `mermaid_text_backend` must default to `Sugiyama`.
    /// This is the user-visible promise that pre-1.34.48 config files keep
    /// rendering identically: Sugiyama has been the in-library default since
    /// `mermaid-text` 0.17.0.
    #[test]
    fn mermaid_text_backend_missing_field_defaults_to_sugiyama() {
        let toml_str = r#"theme = "default""#;
        let config: Config = toml::from_str(toml_str).expect("deserialization failed");
        assert_eq!(config.mermaid_text_backend, MermaidTextBackend::Sugiyama);
    }

    /// An explicit `mermaid_text_backend = "native"` in TOML must be honoured.
    /// Pairs with the round-trip test to guarantee the default doesn't mask a
    /// user-supplied value (a class of bug we have hit before).
    #[test]
    fn mermaid_text_backend_explicit_native_is_honoured() {
        let toml_str = r#"
theme = "default"
mermaid_text_backend = "native"
"#;
        let config: Config = toml::from_str(toml_str).expect("deserialization failed");
        assert_eq!(config.mermaid_text_backend, MermaidTextBackend::Native);
    }

    /// An explicit `mermaid_text_backend = "auto"` in TOML must deserialise to
    /// the `Auto` variant — pins the serde rename and guards against the
    /// new variant being silently dropped by a missing match arm.
    #[test]
    fn mermaid_text_backend_explicit_auto_is_honoured() {
        let toml_str = r#"
theme = "default"
mermaid_text_backend = "auto"
"#;
        let config: Config = toml::from_str(toml_str).expect("deserialization failed");
        assert_eq!(config.mermaid_text_backend, MermaidTextBackend::Auto);
    }

    /// `Auto` must survive a TOML round-trip — the persistence path is the
    /// usual culprit when a new variant breaks down silently.
    #[test]
    fn mermaid_text_backend_auto_round_trips() {
        let config = Config {
            mermaid_text_backend: MermaidTextBackend::Auto,
            ..Config::default()
        };
        let serialized = toml::to_string_pretty(&config).expect("serialization failed");
        let deserialized: Config = toml::from_str(&serialized).expect("deserialization failed");
        assert_eq!(deserialized.mermaid_text_backend, MermaidTextBackend::Auto);
    }

    /// Every variant must have a distinct, non-empty label so the popup
    /// renders a real choice for the user.  Catches a forgotten match arm
    /// in `mermaid_text_backend_label` (which would not be flagged by the
    /// compiler since the return type is `&'static str`).
    #[test]
    fn mermaid_text_backend_label_covers_all_variants() {
        let auto = Config::mermaid_text_backend_label(MermaidTextBackend::Auto);
        let sugiyama = Config::mermaid_text_backend_label(MermaidTextBackend::Sugiyama);
        let native = Config::mermaid_text_backend_label(MermaidTextBackend::Native);
        assert!(!auto.is_empty() && !sugiyama.is_empty() && !native.is_empty());
        assert_ne!(auto, sugiyama);
        assert_ne!(auto, native);
        assert_ne!(sugiyama, native);
    }

    /// `MermaidMode` must round-trip through TOML.
    #[test]
    fn mermaid_mode_round_trips() {
        let config = Config {
            mermaid_mode: MermaidMode::Text,
            ..Config::default()
        };
        let serialized = toml::to_string_pretty(&config).expect("serialization failed");
        let deserialized: Config = toml::from_str(&serialized).expect("deserialization failed");
        assert_eq!(deserialized.mermaid_mode, MermaidMode::Text);
    }

    /// A TOML file without `mermaid_mode` must default to `Text`.
    #[test]
    fn mermaid_mode_missing_field_defaults_to_text() {
        let toml_str = r#"theme = "default""#;
        let config: Config = toml::from_str(toml_str).expect("deserialization failed");
        assert_eq!(config.mermaid_mode, MermaidMode::Text);
    }

    /// `use_hybrid_by_default` must survive a TOML round-trip with the value `false`.
    #[test]
    fn use_hybrid_by_default_roundtrip_false() {
        let config = Config {
            use_hybrid_by_default: false,
            ..Config::default()
        };
        let serialized = toml::to_string_pretty(&config).expect("serialization failed");
        let deserialized: Config = toml::from_str(&serialized).expect("deserialization failed");
        assert!(!deserialized.use_hybrid_by_default);
    }

    /// A TOML file without `use_hybrid_by_default` must default to `true`.
    #[test]
    fn use_hybrid_by_default_missing_field_defaults_to_true() {
        let toml_str = r#"theme = "default""#;
        let config: Config = toml::from_str(toml_str).expect("deserialization failed");
        assert!(config.use_hybrid_by_default);
    }

    /// `[updates]` section must round-trip with `check_for_updates = false`.
    #[test]
    fn updates_check_for_updates_roundtrip_false() {
        let config = Config {
            updates: UpdatesConfig {
                check_for_updates: false,
            },
            ..Config::default()
        };
        let serialized = toml::to_string_pretty(&config).expect("serialization failed");
        let deserialized: Config = toml::from_str(&serialized).expect("deserialization failed");
        assert!(!deserialized.updates.check_for_updates);
    }

    /// A TOML file without an `[updates]` section must default to `check_for_updates = true`.
    #[test]
    fn updates_missing_section_defaults_to_check_enabled() {
        let toml_str = r#"theme = "default""#;
        let config: Config = toml::from_str(toml_str).expect("deserialization failed");
        assert!(config.updates.check_for_updates);
    }

    /// An explicit `[updates] check_for_updates = false` must be honoured.
    #[test]
    fn updates_explicit_false_is_honoured() {
        let toml_str = r#"
theme = "default"

[updates]
check_for_updates = false
"#;
        let config: Config = toml::from_str(toml_str).expect("deserialization failed");
        assert!(!config.updates.check_for_updates);
    }
}