markdown-tui-explorer 1.20.2

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
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
use crate::markdown::{DocBlock, TableBlockId};
use crate::theme::Palette;
use ratatui::text::Text;
use std::collections::HashMap;
use std::path::PathBuf;
use unicode_width::UnicodeWidthStr;

/// Whether the visual selection is character-wise or line-wise.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VisualMode {
    /// `v` — selection spans a character range across lines.
    Char,
    /// `V` — selection spans full logical lines.
    Line,
}

/// Anchor and cursor bounds of a visual selection in the viewer.
///
/// Absolute logical line indices are in the same coordinate space as
/// `cursor_line`. For line mode the selected range is
/// `min(anchor_line, cursor_line)..=max(anchor_line, cursor_line)` covering
/// every column. For char mode the first and last lines may be partial.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VisualRange {
    /// Whether the selection is character-wise or line-wise.
    pub mode: VisualMode,
    /// The line where the selection began (does not move during extension).
    pub anchor_line: u32,
    /// The display column where the selection began (0-based terminal cells).
    pub anchor_col: u16,
    /// The current end line, tracking the cursor.
    pub cursor_line: u32,
    /// The current end column, tracking the cursor (0-based terminal cells).
    pub cursor_col: u16,
}

impl VisualRange {
    /// Smaller of `anchor_line` and `cursor_line` — the top line of the selection.
    pub fn top_line(&self) -> u32 {
        self.anchor_line.min(self.cursor_line)
    }

    /// Larger of `anchor_line` and `cursor_line` — the bottom line of the selection.
    pub fn bottom_line(&self) -> u32 {
        self.anchor_line.max(self.cursor_line)
    }

    /// `true` if the absolute logical `line` is inside the selection range
    /// (inclusive on both ends).
    ///
    /// This checks line containment only — use [`char_range_on_line`] for
    /// column-precise testing in char mode.
    pub fn contains(&self, line: u32) -> bool {
        line >= self.top_line() && line <= self.bottom_line()
    }

    /// Return the `(start_col, end_col_exclusive)` range on `line` that is
    /// selected, or `None` if `line` is outside the selection.
    ///
    /// `line_width` is the display width (in terminal cells) of the logical
    /// line at `line`; it is used to compute the end of a full-line selection.
    pub fn char_range_on_line(&self, line: u32, line_width: u16) -> Option<(u16, u16)> {
        if line < self.top_line() || line > self.bottom_line() {
            return None;
        }
        match self.mode {
            VisualMode::Line => Some((0, line_width)),
            VisualMode::Char => {
                let (start_line, start_col, end_line, end_col) = self.ordered();
                if self.top_line() == self.bottom_line() {
                    // Same-line selection: highlight [start_col, end_col] inclusive.
                    Some((start_col, end_col + 1))
                } else if line == start_line {
                    Some((start_col, line_width))
                } else if line == end_line {
                    Some((0, end_col + 1))
                } else {
                    Some((0, line_width)) // middle line — fully selected
                }
            }
        }
    }

    /// Return `(start_line, start_col, end_line, end_col)` ordered so that
    /// `(start_line, start_col)` is positionally before `(end_line, end_col)`.
    fn ordered(&self) -> (u32, u16, u32, u16) {
        let anchor_before = self.anchor_line < self.cursor_line
            || (self.anchor_line == self.cursor_line && self.anchor_col <= self.cursor_col);
        if anchor_before {
            (
                self.anchor_line,
                self.anchor_col,
                self.cursor_line,
                self.cursor_col,
            )
        } else {
            (
                self.cursor_line,
                self.cursor_col,
                self.anchor_line,
                self.anchor_col,
            )
        }
    }
}

/// A hyperlink with an absolute display-line position (after block offsets are applied).
#[derive(Debug, Clone)]
pub struct AbsoluteLink {
    /// Absolute 0-indexed display line within the document.
    pub line: u32,
    pub col_start: u16,
    pub col_end: u16,
    pub url: String,
    pub text: String,
}

/// A heading anchor with an absolute display-line position.
#[derive(Debug, Clone)]
pub struct AbsoluteAnchor {
    pub anchor: String,
    /// Absolute 0-indexed display line within the document.
    pub line: u32,
}

/// Runtime state for the markdown preview panel.
#[derive(Debug, Default)]
pub struct MarkdownViewState {
    /// Raw markdown source of the currently displayed file.
    pub content: String,
    /// Pre-rendered block sequence produced by the markdown renderer.
    pub rendered: Vec<DocBlock>,
    /// Current scroll offset in display lines.
    pub scroll_offset: u32,
    /// Current cursor position as an absolute rendered logical-line index.
    /// Same coordinate space as `scroll_offset`. Defaults to 0.
    pub cursor_line: u32,
    /// Horizontal cursor position within the current logical line, measured in
    /// terminal display cells (0-based). Used for char-wise visual mode (`v`)
    /// and displayed in the status bar. Clamped to the line width on vertical
    /// moves. Defaults to 0.
    pub cursor_col: u16,
    /// Display name shown in the panel title.
    pub file_name: String,
    /// Absolute path of the loaded file.
    pub current_path: Option<PathBuf>,
    /// Total number of display lines across all blocks.
    pub total_lines: u32,
    /// The inner width used for the last layout pass; cached layouts are invalid
    /// when this changes.
    pub layout_width: u16,
    /// Per-table rendering cache keyed by `TableBlockId`.
    pub table_layouts: HashMap<TableBlockId, TableLayout>,
    /// All hyperlinks in the document with absolute display-line positions.
    pub links: Vec<AbsoluteLink>,
    /// All heading anchors in the document with absolute display-line positions.
    pub heading_anchors: Vec<AbsoluteAnchor>,
    /// Active visual-line selection; `None` when the viewer is in normal mode.
    ///
    /// Reset to `None` by [`load`] so switching files always clears any
    /// dangling selection from the previous document.
    pub visual_mode: Option<VisualRange>,
}

impl MarkdownViewState {
    /// Recompute absolute link and heading-anchor positions from the current
    /// block heights. Call this after any operation that changes block heights
    /// (table layout, mermaid height update, text wrap recompute) so click
    /// targets and the link picker stay aligned with what the user sees on
    /// screen.
    ///
    /// `content_width` is the effective viewer width (excluding the gutter
    /// when line numbers are on); used to translate logical line indices
    /// stored on `LinkInfo` / `HeadingAnchor` into the visual rows actually
    /// occupied by those lines after wrapping.
    pub fn recompute_positions(&mut self, content_width: u16) {
        use super::visual_rows::line_visual_rows;
        let mut abs_links: Vec<AbsoluteLink> = Vec::new();
        let mut abs_anchors: Vec<AbsoluteAnchor> = Vec::new();
        let mut block_offset = 0u32;
        for block in &self.rendered {
            if let DocBlock::Text {
                text,
                links,
                heading_anchors,
                ..
            } = block
            {
                // Cumulative visual-row offset of each logical line within
                // this block. `visual_offset_of_logical[i]` is the visual
                // row at which `text.lines[i]` begins.
                let mut visual_offset_of_logical: Vec<u32> =
                    Vec::with_capacity(text.lines.len() + 1);
                let mut acc = 0u32;
                visual_offset_of_logical.push(acc);
                for line in &text.lines {
                    acc = acc.saturating_add(line_visual_rows(line, content_width));
                    visual_offset_of_logical.push(acc);
                }
                for link in links {
                    let visual_row = visual_offset_of_logical
                        .get(link.line as usize)
                        .copied()
                        .unwrap_or(link.line);
                    abs_links.push(AbsoluteLink {
                        line: block_offset + visual_row,
                        col_start: link.col_start,
                        col_end: link.col_end,
                        url: link.url.clone(),
                        text: link.text.clone(),
                    });
                }
                for ha in heading_anchors {
                    let visual_row = visual_offset_of_logical
                        .get(ha.line as usize)
                        .copied()
                        .unwrap_or(ha.line);
                    abs_anchors.push(AbsoluteAnchor {
                        anchor: ha.anchor.clone(),
                        line: block_offset + visual_row,
                    });
                }
            }
            block_offset += block.height();
        }
        self.links = abs_links;
        self.heading_anchors = abs_anchors;
    }

    /// Load a file into the viewer, resetting the scroll position.
    ///
    /// # Arguments
    ///
    /// * `path`      – filesystem path of the file being loaded.
    /// * `file_name` – display name shown in the tab bar.
    /// * `content`   – raw markdown source.
    /// * `palette` – color palette for the active UI theme.
    /// * `theme` – the active UI theme; forwarded to the markdown renderer
    ///   to select the matching syntect highlighting theme for fenced code blocks.
    pub fn load(
        &mut self,
        path: PathBuf,
        file_name: String,
        content: String,
        palette: &Palette,
        theme: crate::theme::Theme,
    ) {
        let blocks = crate::markdown::renderer::render_markdown(&content, palette, theme);
        self.total_lines = blocks.iter().map(crate::markdown::DocBlock::height).sum();
        self.rendered = blocks;
        // No layout width yet — pass 0 so `line_visual_rows` short-circuits
        // to "1 row per logical line", matching the pre-wrap heights stored
        // by the renderer. The first `draw` call will recompute with the
        // real width once the viewport rect is known.
        self.recompute_positions(0);
        self.content = content;
        self.file_name = file_name;
        self.current_path = Some(path);
        self.scroll_offset = 0;
        self.cursor_line = 0;
        self.cursor_col = 0;
        // Always clear visual-line selection when loading a new file so the
        // previous document's selection doesn't appear in the new one.
        self.visual_mode = None;
        // Invalidate table layout cache. The fresh DocBlock::Table values carry
        // a pessimistic rendered_height that only becomes accurate once the
        // draw loop runs layout_table; forcing a rebuild keeps the hint line
        // and doc-search line numbers in sync after re-renders (e.g. on theme
        // change, live reload, or session restore).
        self.layout_width = 0;
        self.table_layouts.clear();
    }

    /// Move the cursor down by `n` logical lines, clamped to the last line.
    ///
    /// When visual mode is active, the selection's `cursor` end is extended to
    /// track the new cursor position; the `anchor` stays fixed. `cursor_col`
    /// is clamped to the new line's display width (vim-style column clamping).
    ///
    /// Does not update `scroll_offset`; call [`scroll_to_cursor`] afterward.
    pub fn cursor_down(&mut self, n: u32) {
        let max = self.total_lines.saturating_sub(1);
        self.cursor_line = self.cursor_line.saturating_add(n).min(max);
        self.clamp_cursor_col();
        if let Some(range) = self.visual_mode.as_mut() {
            range.cursor_line = self.cursor_line;
            range.cursor_col = self.cursor_col;
        }
    }

    /// Move the cursor up by `n` logical lines, saturating at 0.
    ///
    /// When visual mode is active, the selection's `cursor` end is extended to
    /// track the new cursor position; the `anchor` stays fixed. `cursor_col`
    /// is clamped to the new line's display width.
    ///
    /// Does not update `scroll_offset`; call [`scroll_to_cursor`] afterward.
    pub fn cursor_up(&mut self, n: u32) {
        self.cursor_line = self.cursor_line.saturating_sub(n);
        self.clamp_cursor_col();
        if let Some(range) = self.visual_mode.as_mut() {
            range.cursor_line = self.cursor_line;
            range.cursor_col = self.cursor_col;
        }
    }

    /// Jump the cursor to the first line and reset the scroll to the top.
    ///
    /// When visual mode is active, extends the selection to the top of the document.
    pub fn cursor_to_top(&mut self) {
        self.cursor_line = 0;
        self.scroll_offset = 0;
        self.clamp_cursor_col();
        if let Some(range) = self.visual_mode.as_mut() {
            range.cursor_line = 0;
            range.cursor_col = self.cursor_col;
        }
    }

    /// Jump the cursor to the last line and scroll so it is visible.
    ///
    /// When visual mode is active, extends the selection to the bottom of the document.
    ///
    /// # Arguments
    ///
    /// * `view_height` – visible viewport height in display lines.
    pub fn cursor_to_bottom(&mut self, view_height: u32) {
        self.cursor_line = self.total_lines.saturating_sub(1);
        self.scroll_to_cursor(view_height);
        self.clamp_cursor_col();
        if let Some(range) = self.visual_mode.as_mut() {
            range.cursor_line = self.cursor_line;
            range.cursor_col = self.cursor_col;
        }
    }

    /// Clamp `cursor_col` to `min(cursor_col, line_width - 1)` for the
    /// current `cursor_line`. Called after every vertical move to match vim's
    /// column-clamping behaviour.
    pub fn clamp_cursor_col(&mut self) {
        let width = self.current_line_width();
        if width == 0 {
            self.cursor_col = 0;
        } else {
            self.cursor_col = self.cursor_col.min(width - 1);
        }
    }

    /// Display width (in terminal cells) of the logical line at `cursor_line`.
    ///
    /// Walks the rendered blocks to find the [`Line`] at `cursor_line` and sums
    /// the display width of each span's content via `UnicodeWidthStr`. Returns 0
    /// for empty lines or when `cursor_line` is out of range.
    ///
    /// Not cached — only called on key events, not per frame.
    pub fn current_line_width(&self) -> u16 {
        let mut offset = 0u32;
        for block in &self.rendered {
            let h = block.height();
            if self.cursor_line < offset + h {
                let local_visual = self.cursor_line - offset;
                let width = match block {
                    DocBlock::Text { text, .. } => {
                        // `cursor_line` is in visual rows now (1.18.4) but
                        // `text.lines` is still indexed by logical line —
                        // walk the lines to convert. Without this, a cursor
                        // sitting on a wrapped paragraph mapped past the
                        // end of `text.lines`, returning width 0, which
                        // forced `clamp_cursor_col` to reset `cursor_col`
                        // to 0 on every `j`/`k` and clamped the Right arrow
                        // to a no-op.
                        let logical_idx = super::visual_rows::visual_row_to_logical_in_block(
                            text,
                            self.layout_width,
                            local_visual,
                        ) as usize;
                        text.lines.get(logical_idx).map_or(0, |l| {
                            l.spans
                                .iter()
                                .map(|s| UnicodeWidthStr::width(s.content.as_ref()))
                                .sum::<usize>()
                        })
                    }
                    // Mermaid and Table blocks have opaque content — treat them
                    // as having no horizontal extent for cursor purposes.
                    DocBlock::Mermaid { .. } | DocBlock::Table(_) => 0,
                };
                return crate::cast::u16_sat(width);
            }
            offset += h;
        }
        0
    }

    /// Joined span text of the logical line under the cursor, or `None` for
    /// Mermaid / Table blocks (which have no per-cell cursor concept) and
    /// blank/out-of-range positions.
    ///
    /// Used by the word-jump helpers to find whitespace-separated word
    /// boundaries. Indexed by char position (display column on
    /// ASCII-only lines, which covers the vast majority of prose markdown).
    fn current_line_text(&self) -> Option<String> {
        let mut offset = 0u32;
        for block in &self.rendered {
            let h = block.height();
            if self.cursor_line < offset + h {
                let local_visual = self.cursor_line - offset;
                return match block {
                    DocBlock::Text { text, .. } => {
                        let logical_idx = super::visual_rows::visual_row_to_logical_in_block(
                            text,
                            self.layout_width,
                            local_visual,
                        ) as usize;
                        text.lines.get(logical_idx).map(|l| {
                            l.spans
                                .iter()
                                .map(|s| s.content.as_ref())
                                .collect::<String>()
                        })
                    }
                    DocBlock::Mermaid { .. } | DocBlock::Table(_) => None,
                };
            }
            offset += h;
        }
        None
    }

    /// Move the cursor to the start of the next word on the current line, or
    /// to end-of-line if no further word exists. Mirrors Option+Right on
    /// macOS and vim's `w` for whitespace-segmented words.
    ///
    /// Updates the visual-mode cursor end too so range selection extends
    /// naturally with word jumps.
    pub fn cursor_word_forward(&mut self) {
        let Some(text) = self.current_line_text() else {
            return;
        };
        self.cursor_col = next_word_col(&text, self.cursor_col);
        if let Some(range) = self.visual_mode.as_mut() {
            range.cursor_col = self.cursor_col;
        }
    }

    /// Move the cursor to the start of the previous word on the current
    /// line, or to col 0 if already at the first word. Mirrors Option+Left
    /// on macOS and vim's `b`.
    pub fn cursor_word_backward(&mut self) {
        let Some(text) = self.current_line_text() else {
            return;
        };
        self.cursor_col = prev_word_col(&text, self.cursor_col);
        if let Some(range) = self.visual_mode.as_mut() {
            range.cursor_col = self.cursor_col;
        }
    }

    /// Jump cursor to col 0 of the current line. Mirrors Home / Cmd+Left
    /// (where the terminal forwards Cmd+Left as Home, e.g. macOS Terminal).
    pub fn cursor_line_start(&mut self) {
        self.cursor_col = 0;
        if let Some(range) = self.visual_mode.as_mut() {
            range.cursor_col = 0;
        }
    }

    /// Jump cursor to the last column of the current line. Mirrors End /
    /// Cmd+Right.
    pub fn cursor_line_end(&mut self) {
        let max = self.current_line_width().saturating_sub(1);
        self.cursor_col = max;
        if let Some(range) = self.visual_mode.as_mut() {
            range.cursor_col = max;
        }
    }

    /// Adjust `scroll_offset` so the cursor sits as close to the vertical
    /// centre of the viewport as possible.
    ///
    /// Intended for long-distance cursor jumps (search-result open, go-to-line)
    /// where the user wants to see context around the target line rather than
    /// landing at the viewport edge.  Short-distance movement (`j`/`k`/etc.)
    /// should continue to use [`scroll_to_cursor`] so the scroll only tracks
    /// the cursor when the cursor would otherwise leave the screen.
    ///
    /// # Arguments
    ///
    /// * `view_height` – visible viewport height in display lines.
    pub fn scroll_to_cursor_centered(&mut self, view_height: u32) {
        let vh = view_height.max(1);
        let half = vh / 2;
        self.scroll_offset = self.cursor_line.saturating_sub(half);
        let max = self.total_lines.saturating_sub(vh / 2);
        self.scroll_offset = self.scroll_offset.min(max);
    }

    /// Adjust `scroll_offset` so the cursor is visible in the viewport.
    ///
    /// Matches vim's default `scrolloff=0` behaviour: the scroll moves only
    /// as much as needed to bring the cursor onto the screen.
    ///
    /// # Arguments
    ///
    /// * `view_height` – visible viewport height in display lines.
    pub fn scroll_to_cursor(&mut self, view_height: u32) {
        let vh = view_height.max(1);
        if self.cursor_line < self.scroll_offset {
            // Cursor went above the viewport top — scroll up.
            self.scroll_offset = self.cursor_line;
        } else if self.cursor_line >= self.scroll_offset + vh {
            // Cursor went below the viewport bottom — scroll down.
            self.scroll_offset = self.cursor_line.saturating_sub(vh - 1);
        }
        // Clamp scroll so we never show an entirely blank viewport at the end.
        let max = self.total_lines.saturating_sub(vh / 2);
        self.scroll_offset = self.scroll_offset.min(max);
    }
}

/// Cached rendering of a single table at a given layout width.
#[derive(Debug)]
pub struct TableLayout {
    pub text: Text<'static>,
}

/// Find the start of the next whitespace-separated word at or after
/// `current_col` in `text`. If no further word exists, return the column
/// just past the last char (so the cursor parks at end-of-line).
///
/// Word definition: a maximal run of non-whitespace chars. A "word jump"
/// from inside a word skips the rest of that word + any whitespace,
/// landing on the first char of the next word. From whitespace, it skips
/// the whitespace and lands on the next word.
///
/// Indexed by char position; on ASCII-only lines this matches the display
/// column space `cursor_col` lives in. Multi-byte / wide chars are out of
/// scope (rare in prose markdown; the existing `h`/`l` arrows have the
/// same approximation).
fn next_word_col(text: &str, current_col: u16) -> u16 {
    let chars: Vec<char> = text.chars().collect();
    let len = chars.len();
    let mut col = current_col as usize;
    if col >= len {
        return crate::cast::u16_sat(len.saturating_sub(1));
    }
    // Skip the rest of the current word (if we're inside one).
    while col < len && !chars[col].is_whitespace() {
        col += 1;
    }
    // Skip the whitespace gap.
    while col < len && chars[col].is_whitespace() {
        col += 1;
    }
    if col >= len {
        crate::cast::u16_sat(len.saturating_sub(1))
    } else {
        crate::cast::u16_sat(col)
    }
}

/// Mirror of [`next_word_col`] in the other direction. Returns 0 when
/// already at or before the first word.
fn prev_word_col(text: &str, current_col: u16) -> u16 {
    let chars: Vec<char> = text.chars().collect();
    if current_col == 0 || chars.is_empty() {
        return 0;
    }
    let mut col = (current_col as usize).min(chars.len()).saturating_sub(1);
    // If we started in whitespace, skip back to the previous word's end.
    while col > 0 && chars[col].is_whitespace() {
        col -= 1;
    }
    // If we started inside a word but not at its first char, jump to the
    // word's start. Otherwise we're already at a word boundary; keep
    // walking back through the gap to the previous word.
    if col > 0 && !chars[col - 1].is_whitespace() {
        // Inside a word; back up to its first char.
        while col > 0 && !chars[col - 1].is_whitespace() {
            col -= 1;
        }
    } else {
        // At the first char of a word (or the boundary). Skip the gap…
        while col > 0 && chars[col - 1].is_whitespace() {
            col -= 1;
        }
        // …then back up to the start of that previous word.
        while col > 0 && !chars[col - 1].is_whitespace() {
            col -= 1;
        }
    }
    crate::cast::u16_sat(col)
}

#[cfg(test)]
mod word_jump_tests {
    use super::{next_word_col, prev_word_col};

    /// `next_word_col` from inside a word jumps past the word and gap to the
    /// next word's start.
    #[test]
    fn next_word_from_inside_word() {
        // "hello world foo"
        //       ^   col=4 → next word "world" starts at col 6
        assert_eq!(next_word_col("hello world foo", 4), 6);
    }

    /// From whitespace, `next_word_col` lands on the next word's first char.
    #[test]
    fn next_word_from_whitespace() {
        // "abc   def"
        //     ^^^   col=3 (whitespace) → "def" starts at col 6
        assert_eq!(next_word_col("abc   def", 3), 6);
    }

    /// From inside the last word, `next_word_col` parks at end-of-line.
    #[test]
    fn next_word_from_last_word() {
        // "abc def"  len=7, last index=6 → end-of-line is col 6
        assert_eq!(next_word_col("abc def", 5), 6);
    }

    /// `prev_word_col` from inside a word jumps to that word's start.
    #[test]
    fn prev_word_from_mid_word() {
        // "hello world foo"
        //         ^ col=8 (inside "world") → start of "world" is col 6
        assert_eq!(prev_word_col("hello world foo", 8), 6);
    }

    /// From the first char of a word, `prev_word_col` jumps back to the
    /// previous word's start.
    #[test]
    fn prev_word_from_word_start() {
        // "hello world foo"
        //       ^ col=6 (start of "world") → previous word "hello" starts at 0
        assert_eq!(prev_word_col("hello world foo", 6), 0);
    }

    /// From col 0, `prev_word_col` stays at 0.
    #[test]
    fn prev_word_at_start_stays() {
        assert_eq!(prev_word_col("hello world", 0), 0);
    }

    /// From whitespace, `prev_word_col` jumps to the previous word's start.
    #[test]
    fn prev_word_from_whitespace() {
        // "abc   def"
        //      ^ col=4 (in the gap) → "abc" starts at 0
        assert_eq!(prev_word_col("abc   def", 4), 0);
    }
}