markdown-tui-explorer 1.27.0

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
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
use super::highlight::apply_block_highlight;
use super::state::VisualRange;
use crate::app::App;
use crate::theme::{Palette, Tokens};
use ratatui::{
    Frame,
    layout::{Alignment, Rect},
    style::Style,
    text::{Line, Span, Text},
    widgets::{Block, Borders, Paragraph, Wrap},
};
// `RefCell` is used to populate the per-entry styled-text cache without
// requiring `&mut MermaidEntry`.  Interior mutability is safe here because
// the TUI render loop is single-threaded.
use std::cell::RefCell;

/// All parameters needed to draw a single mermaid block.
///
/// Bundles the per-block rendering state and cursor context into one struct so
/// [`draw_mermaid_block`] stays within clippy's 7-argument limit.
pub struct MermaidDrawParams<'a> {
    /// Whether the image is fully visible in the viewport.
    pub fully_visible: bool,
    /// Opaque block identifier used to look up the cache entry.
    pub id: crate::markdown::MermaidBlockId,
    /// Raw mermaid source, displayed when the image is not available.
    pub source: &'a str,
    /// Whether the viewer panel currently has keyboard focus.
    pub focused: bool,
    /// Absolute logical-line index of the cursor.
    pub cursor_line: u32,
    /// Inclusive start of the block in absolute logical lines.
    pub block_start: u32,
    /// Exclusive end of the block in absolute logical lines.
    pub block_end: u32,
    /// Lines into the block above the viewport. Text-mode renderers
    /// (`AsciiDiagram`, `SourceOnly`, `Failed`) slice their content by
    /// this offset so scrolling inside a tall diagram reveals lower rows
    /// instead of always showing the top.
    pub clip_start: u32,
    /// Active visual-line selection, or `None` in normal mode.
    pub visual_mode: Option<VisualRange>,
}

/// Draw a mermaid block at the given rect, looking up the cache entry.
///
/// When `params.fully_visible` is false (the block is partially scrolled on-
/// or off-screen), skip image rendering and show a placeholder; otherwise the
/// image widget would re-fit to the shrinking rect and visibly jitter.
pub fn draw_mermaid_block(
    f: &mut Frame,
    app: &mut App,
    rect: Rect,
    p: &Palette,
    params: &MermaidDrawParams,
) {
    use crate::mermaid::MermaidEntry;

    let entry = app.mermaid_cache.get_mut(params.id);

    // Helper: true when the cursor is inside this block and the viewer is focused.
    let cursor_in_block = params.focused
        && params.cursor_line >= params.block_start
        && params.cursor_line < params.block_end;

    match entry {
        None => {
            render_mermaid_placeholder(f, rect, "mermaid diagram", p);
        }
        Some(MermaidEntry::Pending) => {
            render_mermaid_placeholder(f, rect, "rendering\u{2026}", p);
        }
        Some(MermaidEntry::Ready { protocol, .. }) => {
            if params.fully_visible {
                use ratatui_image::{Resize, StatefulImage};
                f.render_widget(
                    Block::default().style(Style::default().bg(p.background)),
                    rect,
                );
                // Render background bars BEFORE the image so they sit underneath.
                // In visual mode draw a bar for every selected row; in normal mode
                // draw one bar for the cursor row.  The image overwrites most of
                // each bar, leaving only a thin coloured strip around the padding.
                let highlighted_rows: Vec<u32> = match params.visual_mode {
                    Some(range) => (0..params.block_end.saturating_sub(params.block_start))
                        .filter(|&offset| range.contains(params.block_start + offset))
                        .collect(),
                    None if cursor_in_block => {
                        vec![params.cursor_line - params.block_start]
                    }
                    None => vec![],
                };
                for row_offset in highlighted_rows {
                    let row_offset = crate::cast::u16_from_u32(row_offset);
                    if row_offset < rect.height {
                        let bar_rect = Rect {
                            x: rect.x,
                            y: rect.y + row_offset,
                            width: rect.width,
                            height: 1,
                        };
                        f.render_widget(
                            Block::default()
                                .style(Style::default().bg(app.tokens.state.selection_bg)),
                            bar_rect,
                        );
                    }
                }
                let padded = padded_rect(rect, 4, 1);
                let image = StatefulImage::new().resize(Resize::Fit(None));
                f.render_stateful_widget(image, padded, protocol.as_mut());
            } else {
                render_mermaid_placeholder(f, rect, "scroll to view diagram", p);
            }
        }
        Some(MermaidEntry::Failed {
            msg,
            styled_text_cache,
        }) => {
            let footer = format!("[mermaid \u{2014} {}]", truncate(msg.as_str(), 60));
            let text = get_or_build_cache(styled_text_cache, || {
                render_mermaid_source_text(params.source, &footer, &app.tokens, p)
            });
            render_mermaid_text_block(f, rect, text, &app.tokens, p, params);
        }
        Some(MermaidEntry::SourceOnly {
            reason,
            styled_text_cache,
        }) => {
            let footer = format!("[mermaid \u{2014} {reason}]");
            let text = get_or_build_cache(styled_text_cache, || {
                render_mermaid_source_text(params.source, &footer, &app.tokens, p)
            });
            render_mermaid_text_block(f, rect, text, &app.tokens, p, params);
        }
        Some(MermaidEntry::AsciiDiagram {
            diagram,
            reason,
            styled_text_cache,
        }) => {
            // figurehead rendered a Unicode box-drawing diagram — show it
            // instead of the raw mermaid source.
            let footer = format!("[mermaid \u{2014} {reason}, text-mode diagram]");
            let text = get_or_build_cache(styled_text_cache, || {
                render_mermaid_source_text(diagram.as_str(), &footer, &app.tokens, p)
            });
            render_mermaid_text_block(f, rect, text, &app.tokens, p, params);
        }
    }
}

/// Shrink `rect` by `h` cells on the left/right and `v` cells on the top/bottom.
/// If the rect is smaller than the total padding, returns it unchanged.
pub fn padded_rect(rect: Rect, h: u16, v: u16) -> Rect {
    if rect.width <= h * 2 || rect.height <= v * 2 {
        return rect;
    }
    Rect {
        x: rect.x + h,
        y: rect.y + v,
        width: rect.width - h * 2,
        height: rect.height - v * 2,
    }
}

/// Render a placeholder box with a centered status message.
pub fn render_mermaid_placeholder(f: &mut Frame, rect: Rect, msg: &str, p: &Palette) {
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(p.border_style())
        .style(Style::default().bg(p.background));
    let inner = block.inner(rect);
    f.render_widget(block, rect);

    if inner.height > 0 {
        let line = Line::from(Span::styled(msg.to_string(), p.dim_style()));
        let para = Paragraph::new(Text::from(vec![line])).alignment(Alignment::Center);
        // Center vertically.
        let y_offset = inner.height / 2;
        let target = Rect {
            y: inner.y + y_offset,
            height: 1,
            ..inner
        };
        f.render_widget(para, target);
    }
}

/// Return the cached styled `Text`, building and storing it on first access.
///
/// `build` is called only when the cache cell holds `None`.  On subsequent
/// frames the cached `Text` is cloned — one clone per frame is far cheaper
/// than re-allocating one `String` + `Span` + `Line` per source line.
///
/// The cache is invalidated implicitly when `MermaidCache::clear()` drops the
/// whole entry (theme change or mode switch), so stale theme-coloured spans
/// never persist across a theme toggle.
fn get_or_build_cache(
    cache: &RefCell<Option<Text<'static>>>,
    build: impl FnOnce() -> Text<'static>,
) -> Text<'static> {
    // Borrow immutably first (fast path — cache already populated).
    if let Some(text) = cache.borrow().as_ref() {
        return text.clone();
    }
    // Cache is empty — build and store.
    let text = build();
    *cache.borrow_mut() = Some(text.clone());
    text
}

/// Build the styled `Text` for a mermaid source-fallback display.
///
/// Separating text construction from rendering lets callers mutate the lines
/// (e.g., apply cursor highlight) before committing to the frame buffer.
pub fn render_mermaid_source_text(
    source: &str,
    footer: &str,
    tokens: &Tokens,
    p: &Palette,
) -> Text<'static> {
    // `surface.raised` — code-fallback text shares the raised surface tier with
    // code blocks and the status bar (same sourcing as `render_code_block`).
    let code_style = Style::default()
        .fg(tokens.syntax.code_fg)
        .bg(tokens.surface.raised);
    let dim_style = p.dim_style();

    let mut lines: Vec<Line<'static>> = source
        .lines()
        .map(|l| Line::from(Span::styled(l.to_string(), code_style)))
        .collect();
    lines.push(Line::from(Span::styled(footer.to_string(), dim_style)));
    Text::from(lines)
}

/// Render the text-mode body of a mermaid block (`AsciiDiagram`,
/// `SourceOnly`, or `Failed`), correctly slicing by the scroll offset so
/// tall diagrams reveal their lower rows when scrolled into view.
///
/// Without this slicing, `Paragraph` always renders from the first line
/// of the `Text` regardless of how much the block has been scrolled —
/// the user sees the top of the diagram pinned in place even after
/// scrolling well past it. Compare with the `Text` block path (in
/// `draw.rs`) which already slices visible lines before rendering.
///
/// Width-overflow guard: `Paragraph` wraps long lines onto subsequent
/// terminal rows by default, which fragments box-drawing chars (`┌──┐`
/// becomes scattered chunks across rows). For diagrams whose natural
/// width exceeds the available rect, we substitute an overflow
/// placeholder pointing the user at the full-screen modal (`Enter`).
///
/// The `text` argument is a clone from the per-entry cache.  We avoid
/// mutating the original cache entry so the cached `Text` stays valid
/// across scroll positions and can be reused next frame without rebuilding.
fn render_mermaid_text_block(
    f: &mut Frame,
    rect: Rect,
    mut text: Text<'static>,
    tokens: &Tokens,
    p: &Palette,
    params: &MermaidDrawParams,
) {
    let total = text.lines.len();
    let start = (params.clip_start as usize).min(total);

    // FIX 1: Measure the natural width of the *full* text before any clipping.
    // A chart's overflow is a property of its widest line, not of whichever
    // subset happens to remain after scrolling past the wide top rows.
    // Measuring after the drain caused the placeholder to disappear once the
    // user scrolled far enough for the wide rows to leave the clipped prefix.
    let inner_width = rect.width.saturating_sub(2) as usize;
    if inner_width > 0
        && let Some(natural_width) = max_line_display_width(&text.lines)
        && natural_width > inner_width
    {
        render_mermaid_overflow_placeholder(f, rect, natural_width, inner_width, p);
        return;
    }

    if params.focused {
        // Clone-on-highlight: mutate a local copy only when the cursor or
        // visual selection touches this block, so the shared cached Text is
        // never modified.  The clone covers all lines but is a single
        // Vec<Line> heap allocation — vastly cheaper than per-line String
        // allocations that the old path paid every frame.
        apply_block_highlight(
            &mut text.lines,
            params.visual_mode,
            params.cursor_line,
            params.block_start,
            params.block_end,
            start,
            tokens.state.selection_bg,
        );
    }

    // FIX 2: Use `Paragraph::scroll` instead of `Vec::drain` to skip the
    // clipped prefix.  This avoids mutating (and thus invalidating) the
    // cached Text and eliminates the wasted allocation the old drain path paid
    // after `get_or_build_cache` already returned a clean copy.
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(p.border_style())
        .style(Style::default().bg(p.background));
    let para = Paragraph::new(text)
        .block(block)
        .wrap(Wrap { trim: false })
        .scroll((start as u16, 0));
    f.render_widget(para, rect);
}

/// Compute the widest display-width across `lines`. Returns `None` for an
/// empty slice — callers treat that as "no overflow possible."
fn max_line_display_width(lines: &[Line<'static>]) -> Option<usize> {
    lines.iter().map(|l| l.width()).max()
}

/// Render a clean placeholder when a text-mode mermaid diagram is wider
/// than the available rect. Tells the user the natural / available
/// dimensions and points them at `Enter` (the full-screen modal).
fn render_mermaid_overflow_placeholder(
    f: &mut Frame,
    rect: Rect,
    natural_width: usize,
    available_width: usize,
    p: &Palette,
) {
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(p.border_style())
        .style(Style::default().bg(p.background));
    let inner = block.inner(rect);
    f.render_widget(block, rect);

    if inner.height == 0 || inner.width == 0 {
        return;
    }

    let lines = vec![
        Line::from(Span::styled(
            "Mermaid diagram too wide to display in place".to_string(),
            p.dim_style(),
        )),
        Line::from(""),
        Line::from(Span::styled(
            format!("Natural width: {natural_width} cells, available: {available_width}"),
            p.dim_style(),
        )),
        Line::from(""),
        Line::from(Span::styled(
            "Press Enter to open in fullscreen".to_string(),
            p.dim_style(),
        )),
    ];

    let para = Paragraph::new(Text::from(lines)).alignment(ratatui::layout::Alignment::Center);

    // Center vertically in the inner rect (placeholder lines = 5).
    let line_count: u16 = 5;
    let y_offset = inner.height.saturating_sub(line_count) / 2;
    let target = Rect {
        x: inner.x,
        y: inner.y + y_offset,
        width: inner.width,
        height: line_count.min(inner.height),
    };
    f.render_widget(para, target);
}

/// Truncate `s` to at most `max` bytes, returning the valid UTF-8 prefix.
pub fn truncate(s: &str, max: usize) -> &str {
    if s.len() <= max { s } else { &s[..max] }
}

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

    #[test]
    fn max_line_display_width_handles_empty_and_unicode() {
        assert_eq!(max_line_display_width(&[]), None);
        let lines = vec![
            Line::from(Span::raw("hi".to_string())),
            Line::from(Span::raw("hello world".to_string())),
            Line::from(Span::raw("┌──┐".to_string())),
        ];
        // "hello world" = 11 cells; box-drawing chars are 1 cell each.
        assert_eq!(max_line_display_width(&lines), Some(11));
    }

    #[test]
    fn max_line_display_width_counts_unicode_box_drawing_correctly() {
        // ┌──┐ is 4 cells (each char is 1 cell wide in monospace).
        let lines = vec![
            Line::from(Span::raw("┌────────┐".to_string())),
            Line::from(Span::raw("│ Worker │".to_string())),
            Line::from(Span::raw("└────────┘".to_string())),
        ];
        assert_eq!(max_line_display_width(&lines), Some(10));
    }

    /// The styled-text cache must be populated on first access and reused on
    /// subsequent frames without re-invoking the build closure.
    #[test]
    fn get_or_build_cache_populates_on_first_call_and_reuses() {
        let cache: RefCell<Option<Text<'static>>> = RefCell::new(None);
        let call_count = std::cell::Cell::new(0u32);

        let build = || {
            call_count.set(call_count.get() + 1);
            Text::from(vec![Line::from(Span::raw("diagram".to_string()))])
        };

        // First call: cache is empty, build runs.
        let t1 = get_or_build_cache(&cache, build);
        assert_eq!(call_count.get(), 1, "build must run on first call");
        assert_eq!(t1.lines.len(), 1);

        // Second call: cache is populated, build does not run.
        let t2 = get_or_build_cache(&cache, || {
            call_count.set(call_count.get() + 1);
            Text::from(vec![Line::from(Span::raw("diagram".to_string()))])
        });
        assert_eq!(call_count.get(), 1, "build must not run on cache hit");
        assert_eq!(t2.lines.len(), 1);
    }

    /// After clearing the cache cell (simulating a theme change that drops the
    /// whole `MermaidEntry` and re-inserts a fresh one), the build closure runs
    /// again on next access.
    #[test]
    fn get_or_build_cache_rebuilds_after_invalidation() {
        let cache: RefCell<Option<Text<'static>>> = RefCell::new(None);
        let call_count = std::cell::Cell::new(0u32);

        // Populate the cache.
        get_or_build_cache(&cache, || {
            call_count.set(call_count.get() + 1);
            Text::from(vec![Line::from(Span::raw("v1".to_string()))])
        });
        assert_eq!(call_count.get(), 1);

        // Simulate cache invalidation: the entry is dropped when MermaidCache::clear()
        // removes the MermaidEntry, and a fresh entry starts with None in its RefCell.
        *cache.borrow_mut() = None;

        let rebuilt = get_or_build_cache(&cache, || {
            call_count.set(call_count.get() + 1);
            Text::from(vec![Line::from(Span::raw("v2".to_string()))])
        });
        assert_eq!(
            call_count.get(),
            2,
            "build must run again after cache reset"
        );
        assert_eq!(rebuilt.lines[0].spans[0].content.as_ref(), "v2");
    }

    /// Regression test for the overflow-check ordering bug.
    ///
    /// A wide diagram must still report its natural width even when measured
    /// only on the prefix rows (wide top, narrow body).  The fix measures the
    /// full text before any clipping so the overflow placeholder fires
    /// consistently regardless of `clip_start`.
    ///
    /// Concretely: if the bug were still present, measuring only the rows that
    /// survive after skipping `clip_start` lines would return the narrower body
    /// width and the overflow check would pass — causing partial diagram content
    /// to appear instead of the "too wide" placeholder.
    #[test]
    fn overflow_check_uses_full_text_width_not_clipped_width() {
        // Wide header row that would be scrolled past when clip_start > 0.
        let wide_row = "A".repeat(200);
        // Narrow body rows that appear after the wide header.
        let narrow_body: Vec<Line<'static>> = (0..10)
            .map(|_| Line::from(Span::raw("narrow".to_string())))
            .collect();

        // Build the full text as `render_mermaid_text_block` receives it from the cache.
        let mut all_lines = vec![Line::from(Span::raw(wide_row.clone()))];
        all_lines.extend(narrow_body.clone());
        let text = Text::from(all_lines);

        // Measure width on the full text (what the fix does).
        let full_width = max_line_display_width(&text.lines);
        assert_eq!(
            full_width,
            Some(200),
            "full text must report the widest row"
        );

        // Simulate what the buggy drain-first path saw: after draining the wide
        // row (clip_start = 1), only the narrow body remains.
        let clipped: Vec<Line<'static>> = narrow_body;
        let clipped_width = max_line_display_width(&clipped);
        assert_eq!(
            clipped_width,
            Some(6),
            "clipped text reports only the narrower body width, demonstrating the old bug"
        );

        // The fix: use `full_width` for the overflow decision, not `clipped_width`.
        // An available_width of 80 is narrower than 200 → overflow placeholder fires.
        let available = 80_usize;
        assert!(
            full_width.is_some_and(|w| w > available),
            "overflow must be detected from full text width even when clip_start > 0"
        );
        assert!(
            clipped_width.is_none_or(|w| w <= available),
            "the old drain-first path would have missed the overflow at this clip_start"
        );
    }
}