edamame 0.1.6

A Terminal Markdown editor that stays rendered while you edit
Documentation
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
//! Per-frame layout snapshots for clickable-link hit testing, analogous to `ui::table_view`
//! and `ui::image_view`.
//!
//! Snapshots are AST-backed: every link-styled run ([`LinkRun`]) is collected in document order
//! and paired, by index, with the UNDERLINED span runs on the block's rendered lines.  An
//! inline image's placeholder is underlined too, so it gets a `LinkRun` of its own that
//! consumes a run without emitting a snapshot; otherwise every link after an inline image pairs
//! with the wrong URL.  For a raw-revealed cursor block, callers fall back to
//! `mouse_ops::link_at_offset` against the source instead.

use std::path::Path;

use ratatui::layout::Rect;
use ratatui::style::Modifier;
use ratatui::text::Line;

use crate::editor::link::LinkTarget;
use crate::editor::EditorState;
use crate::markdown::renderer::{paragraph_reflows, paragraph_row_inlines};
use crate::markdown::table_layout::cells_of;
use crate::markdown::{Block, Inline};

/// One link-styled run the renderer emits, in document order.  The image-placeholder variant
/// keeps run indices aligned with the AST; see [`collect_link_runs_from_block`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LinkRun {
    Link {
        url: String,
        /// The optional `[text](url "title")` title.
        title: Option<String>,
    },
    /// An `Inline::Image` placeholder: styled like a link, but not one.
    ImagePlaceholder,
}

/// Per-frame geometry for one visible link, in terminal cells.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinkLayoutSnapshot {
    pub rect: Rect,
    /// Classified once at build time so every click path consults the same answer.
    pub target: LinkTarget,
    /// Raw URL, kept for hover tooltips and open-failure messages.
    pub url: String,
    /// Optional link title, surfaced as a hover tooltip.
    pub title: Option<String>,
}

impl LinkLayoutSnapshot {
    /// `Some(self)` if `(col, row)` falls within `rect`.  Used by tests in this module.
    #[allow(dead_code)]
    pub fn hit_test(&self, col: u16, row: u16) -> Option<&Self> {
        if col >= self.rect.x
            && col < self.rect.x + self.rect.width
            && row >= self.rect.y
            && row < self.rect.y + self.rect.height
        {
            Some(self)
        } else {
            None
        }
    }
}

/// Rebuild `snapshots` only when `(scroll, area, parsed_version)` changed since the previous
/// frame.  Mirrors `image_view::build_snapshots_cached`.
pub fn build_snapshots_cached(
    state: &EditorState,
    area: Rect,
    scroll: usize,
    snapshots: &mut Vec<LinkLayoutSnapshot>,
    cache_key: &mut Option<(usize, Rect, u64)>,
) {
    let key = (scroll, area, state.parsed_version);
    if *cache_key == Some(key) {
        return;
    }
    *snapshots = build_snapshots(state, area, scroll);
    *cache_key = Some(key);
}

/// Build snapshots for every visible link, in document order (so `find_map` hit-testing favors
/// the earlier of two overlapping snapshots).  `scroll` is the first visual row on screen.
pub fn build_snapshots(state: &EditorState, area: Rect, scroll: usize) -> Vec<LinkLayoutSnapshot> {
    if area.width == 0 || area.height == 0 {
        return Vec::new();
    }
    let base_dir = state
        .buffer
        .path()
        .and_then(|p| p.parent())
        .map(Path::to_owned);

    let mut out = Vec::new();
    for (block, range) in state
        .parsed
        .blocks
        .iter()
        .zip(state.parsed.real_ranges.iter())
    {
        let rendered_range = state.parsed.source_map.rendered_lines_for_byte(range.start);
        if rendered_range.is_empty() {
            continue;
        }
        let block_end_rows = state
            .parsed
            .visual_rows_before(rendered_range.end, area.width as usize);
        if block_end_rows <= scroll {
            continue;
        }
        extract_block_links(
            block,
            &rendered_range,
            state,
            area,
            scroll,
            base_dir.as_deref(),
            &mut out,
        );
    }
    out
}

/// Extract a block's links and pair each with a rendered-line rect.  `rendered_range` is the
/// rendered-line range the block occupies.
fn extract_block_links(
    block: &Block,
    rendered_range: &std::ops::Range<usize>,
    state: &EditorState,
    area: Rect,
    scroll: usize,
    base_dir: Option<&Path>,
    out: &mut Vec<LinkLayoutSnapshot>,
) {
    // Collecting runs first skips the per-line geometry walk for the (common) link-free block.
    let mut link_runs: Vec<LinkRun> = Vec::new();
    collect_link_runs_from_block(block, state.parsed.reflow_paragraphs, &mut link_runs);
    if !link_runs
        .iter()
        .any(|run| matches!(run, LinkRun::Link { .. }))
    {
        return;
    }

    // First-line screen y in O(1) via the prefix-sum cache; a per-line walk was quadratic.
    let width = area.width as usize;
    let total = state.parsed.lines.len();
    let block_start_rows = state
        .parsed
        .visual_rows_before(rendered_range.start.min(total), width);
    let mut y_cursor: isize = block_start_rows as isize - scroll as isize;

    let mut line_positions: Vec<(usize, u16, u16)> = Vec::new(); // (line_idx, y_start, rows_used)
    for idx in rendered_range.start..rendered_range.end.min(total) {
        let rows_used = state.parsed.visual_rows_for_line_at(idx, width).max(1);
        let y_start = y_cursor;
        y_cursor += rows_used as isize;
        if y_cursor <= 0 {
            continue;
        }
        if y_start >= area.height as isize {
            break;
        }
        line_positions.push((idx, y_start.max(0) as u16, rows_used as u16));
    }

    let mut link_iter = link_runs.into_iter().peekable();
    for (line_idx, y_start, rows_used) in line_positions {
        let Some(line) = state.parsed.lines.get(line_idx) else {
            continue;
        };
        let chars: Vec<char> = line.spans.iter().flat_map(|s| s.content.chars()).collect();
        for (start_col, end_col) in underlined_char_ranges(line) {
            // Every run consumes an entry, image placeholders included, to keep the pairing
            // aligned past an inline image.
            let Some(run) = link_iter.next() else {
                return;
            };
            let LinkRun::Link { url, title } = run else {
                continue;
            };
            let target = LinkTarget::parse(&url, base_dir);
            // One snapshot covering the flat range, in cells, at the line's full row height;
            // per-row precision for wrapped links is not needed yet.
            let width = cells_of(&chars[start_col..end_col]);
            if width == 0 {
                continue;
            }
            let rect = Rect {
                x: area.x + cells_of(&chars[..start_col]) as u16,
                y: area.y + y_start,
                width: width as u16,
                height: rows_used,
            };
            out.push(LinkLayoutSnapshot {
                rect,
                target,
                url,
                title,
            });
        }
    }
}

/// Char-column ranges of every run of consecutive `UNDERLINED` spans in `line`; adjacent spans
/// coalesce so bold/italic substyling inside a link still yields one run.  Two links side by side
/// stay two: the renderer ends the first with an empty unstyled span (`render_inlines`).
fn underlined_char_ranges(line: &Line<'_>) -> Vec<(usize, usize)> {
    let mut out = Vec::new();
    let mut col = 0usize;
    let mut in_link: Option<usize> = None;
    for span in &line.spans {
        let span_len = span.content.chars().count();
        let underlined = span.style.add_modifier.contains(Modifier::UNDERLINED);
        if underlined {
            if in_link.is_none() {
                in_link = Some(col);
            }
        } else if let Some(start) = in_link.take() {
            out.push((start, col));
        }
        col += span_len;
    }
    if let Some(start) = in_link {
        out.push((start, col));
    }
    out
}

/// Public wrapper around [`collect_link_runs_from_block`] for `mouse_ops::links`.
pub fn collect_link_runs_from_block_public(block: &Block, reflow: bool, out: &mut Vec<LinkRun>) {
    collect_link_runs_from_block(block, reflow, out);
}

/// Collect one [`LinkRun`] per link-styled run the renderer will emit for `block`, in document
/// order, so the N-th entry pairs with the N-th underlined run on the rendered lines.
///
/// `Inline::Image` gets an entry because its placeholder is painted with the link fg and an
/// underlined alt text, so consumers see a run there.  An image *inside* a link is not counted
/// twice: the walk does not descend into a link's own text, and the renderer emits one run.
/// A link a paragraph's rows cut in two at a break (any paragraph that doesn't reflow, per
/// `reflow`) is two runs on two rows, so it gets an entry per half.
fn collect_link_runs_from_block(block: &Block, reflow: bool, out: &mut Vec<LinkRun>) {
    match block {
        Block::Heading { inlines, .. } => collect_link_runs_from_inlines(inlines, out),
        Block::Paragraph { inlines, .. } => {
            let join = paragraph_reflows(reflow, inlines);
            collect_link_runs_from_inlines(&paragraph_row_inlines(inlines, join), out);
        }
        Block::BlockQuote { blocks, .. } | Block::FootnoteDefinition { blocks, .. } => {
            for inner in blocks {
                collect_link_runs_from_block(inner, reflow, out);
            }
        }
        Block::List { items, .. } => {
            for item in items {
                for inner in &item.blocks {
                    collect_link_runs_from_block(inner, reflow, out);
                }
            }
        }
        Block::Table { headers, rows, .. } => {
            for cell in headers {
                collect_link_runs_from_inlines(cell, out);
            }
            for row in rows {
                for cell in row {
                    collect_link_runs_from_inlines(cell, out);
                }
            }
        }
        Block::CodeBlock { .. }
        | Block::HorizontalRule { .. }
        | Block::Html(..)
        | Block::HtmlComment(..)
        | Block::MetadataBlock { .. }
        | Block::ImageBlock { .. } => {}
    }
}

fn collect_link_runs_from_inlines(inlines: &[Inline], out: &mut Vec<LinkRun>) {
    for inline in inlines {
        match inline {
            Inline::Link { url, title, .. } => {
                out.push(LinkRun::Link {
                    url: url.clone(),
                    title: title.clone(),
                });
            }
            Inline::Image { .. } => out.push(LinkRun::ImagePlaceholder),
            Inline::Bold(inner)
            | Inline::Italic(inner)
            | Inline::Strikethrough(inner)
            | Inline::Highlight(inner) => {
                collect_link_runs_from_inlines(inner, out);
            }
            Inline::Text(_)
            | Inline::Code(_)
            | Inline::Html(_)
            | Inline::HtmlComment(_)
            | Inline::FootnoteReference { .. }
            | Inline::Math { .. }
            | Inline::SoftBreak
            | Inline::HardBreak => {}
        }
    }
}

// ─── Tests ───────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::Theme;
    use crate::document::Buffer;
    use crate::editor::EditorState;

    fn theme() -> &'static Theme {
        Box::leak(Box::new(Theme::default()))
    }

    fn state(src: &str) -> EditorState {
        EditorState::new(Buffer::from_str(src), theme())
    }

    #[test]
    fn one_link_in_a_paragraph_produces_one_snapshot() {
        let src = "See [docs](https://example.com) for more.\n";
        let st = state(src);
        let area = Rect::new(0, 0, 80, 10);
        let snaps = build_snapshots(&st, area, 0);
        assert_eq!(snaps.len(), 1);
        assert_eq!(snaps[0].url, "https://example.com");
        assert!(matches!(&snaps[0].target, LinkTarget::Url(u) if u == "https://example.com"));
    }

    #[test]
    fn two_links_in_document_order_produce_two_snapshots() {
        let src = "[first](a.md) and [second](b.md)\n";
        let st = state(src);
        let area = Rect::new(0, 0, 80, 10);
        let snaps = build_snapshots(&st, area, 0);
        assert_eq!(snaps.len(), 2);
        assert_eq!(snaps[0].url, "a.md");
        assert_eq!(snaps[1].url, "b.md");
    }

    /// A link a break cuts in two renders a run on each row, and each pairs with the link, so
    /// the link after it still pairs with its own URL.  Reflowed, the link is one run again: the
    /// break's space takes the link's style.
    #[test]
    fn a_link_split_across_rows_pairs_both_halves() {
        let src = "[a\nb](x.md) [c](y.md)\n";
        let area = Rect::new(0, 0, 80, 10);
        // `(url, x, y, width)` per snapshot.
        for (reflow, expected) in [
            (
                false,
                &[("x.md", 0, 0, 1), ("x.md", 0, 1, 1), ("y.md", 2, 1, 1)][..],
            ),
            (true, &[("x.md", 0, 0, 3), ("y.md", 4, 0, 1)][..]),
        ] {
            let mut st = state(src);
            st.mode = crate::editor::Mode::Rendered;
            st.set_reflow(reflow);
            st.sync_reflow_for_mode();
            let snaps = build_snapshots(&st, area, 0);
            let got: Vec<(&str, u16, u16, u16)> = snaps
                .iter()
                .map(|s| (s.url.as_str(), s.rect.x, s.rect.y, s.rect.width))
                .collect();
            assert_eq!(got, expected, "reflow {reflow}");
        }
    }

    /// Two links side by side, in the source or across a break that vanished between CJK
    /// glyphs, are two runs, each pairing with its own URL, and the link after them still pairs
    /// with its own.  Each rect is in cells, a wide glyph two of them.
    #[test]
    fn adjacent_links_pair_with_their_own_urls() {
        let area = Rect::new(0, 0, 80, 10);
        for src in [
            "[日](a.md)\n[本](b.md) [c](c.md)\n",
            "[日](a.md)[本](b.md) [c](c.md)\n",
        ] {
            let mut st = state(src);
            st.mode = crate::editor::Mode::Rendered;
            st.set_reflow(true);
            st.sync_reflow_for_mode();
            let snaps = build_snapshots(&st, area, 0);
            let got: Vec<(&str, u16, u16)> = snaps
                .iter()
                .map(|s| (s.url.as_str(), s.rect.x, s.rect.width))
                .collect();
            assert_eq!(
                got,
                [("a.md", 0, 2), ("b.md", 2, 2), ("c.md", 5, 1)],
                "{src:?}"
            );
        }
    }

    #[test]
    fn no_snapshots_for_plain_paragraph() {
        let src = "no links here, just text\n";
        let st = state(src);
        let area = Rect::new(0, 0, 80, 10);
        assert!(build_snapshots(&st, area, 0).is_empty());
    }

    /// Pins the prefix-sum y math: each paragraph is one line plus a gap line, so the link
    /// sits at rendered line 24 and lands on screen row 4 after scrolling by 20.
    #[test]
    fn link_below_scroll_lands_on_correct_row() {
        let mut src = String::new();
        for i in 0..12 {
            src.push_str(&format!("Paragraph {i}.\n\n"));
        }
        src.push_str("Read [this](https://example.com)\n");
        let st = state(&src);
        let area = Rect::new(0, 0, 80, 6);
        let snaps = build_snapshots(&st, area, 20);
        assert_eq!(snaps.len(), 1, "exactly one link snapshot expected");
        assert_eq!(snaps[0].rect.y, 4, "link y must equal line_index - scroll");
    }

    #[test]
    fn underlined_char_ranges_merge_adjacent_spans() {
        use ratatui::style::{Modifier, Style};
        use ratatui::text::{Line, Span};
        let line = Line::from(vec![
            Span::raw("plain "),
            Span::styled("link", Style::default().add_modifier(Modifier::UNDERLINED)),
            Span::styled(" text", Style::default().add_modifier(Modifier::UNDERLINED)),
            Span::raw(" after"),
        ]);
        let ranges = underlined_char_ranges(&line);
        assert_eq!(ranges, vec![(6, 15)]);
    }
}