writ 0.16.0

A hybrid markdown editor combining raw text editing with live inline rendering
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
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
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
//! Per-line styling for the new render path (see MIGRATION-PLAN.md, Phase 3).
//!
//! Turns a [`RenderSnapshot`] line into the inputs `TextEngine::build_line` needs:
//! a display string, a font size (headings are larger → variable line heights),
//! and a set of [`StyleRun`]s carrying color + bold/italic/mono/underline/strike.
//!
//! Phase 3b: markdown markers (emphasis `**`/`*`/`` ` ``, heading `# `) are HIDDEN
//! when the cursor is off their region, and the returned [`SegmentMap`] maps the
//! display text back to buffer bytes. Style runs are mapped onto display offsets
//! through that map. Still verbatim: list/blockquote prefix markers (they want
//! bullet/bar rendering, a later chrome concern).

use std::borrow::Cow;
use std::ops::Range;

use vello::peniko::Color;

use crate::buffer::RenderSnapshot;
use crate::editor::EditorTheme;
use crate::inline::{MathSpan, StyledRegion, TextStyle};
use crate::marker::MarkerKind;
use crate::segment_map::{SegmentMap, Special};
use crate::table::RowKind;
use crate::text_engine::{StyleRun, peniko_color};
use crate::tokenize;

/// Half-open-on-the-left, closed-on-the-right containment: the cursor is "in" a
/// region when sitting anywhere from its start through its end (so a cursor at the
/// closing delimiter still reveals the region for editing).
fn cursor_in(range: &Range<usize>, cursor: usize) -> bool {
    cursor >= range.start && cursor <= range.end
}

/// A standalone image on a line: a paragraph whose only content is `![alt](url)`.
/// Drawn as an actual image block; `alt` is shown in the pending/broken placeholder.
#[derive(Clone)]
pub struct ImageRef {
    pub url: String,
    pub alt: String,
}

/// An image appearing inline within a line (e.g. a badge row, or mixed with text) —
/// as opposed to a standalone-image paragraph. Its `![alt](url)` span is collapsed to
/// a zero-width point in the display; the layout reserves a Parley inline box there and
/// the draw path paints the image (or a placeholder) at the box's position.
#[derive(Clone)]
pub struct InlineImageRef {
    pub url: String,
    pub alt: String,
    /// Display byte offset of the collapsed image's zero-width point (where the inline
    /// box sits). Ordered left-to-right across a line.
    pub display_offset: usize,
}

/// An inline `$…$` math span collapsed to a zero-width display point on a line. The draw
/// path reserves an inline box there and paints the rendered math image on the baseline.
#[derive(Clone)]
pub struct InlineMathRef {
    pub latex: String,
    pub display_offset: usize,
}

/// Context for a buffer line that belongs to a GFM table, passed into
/// [`build_line_render`]. Present only when the table is small enough to grid-render
/// (the caller applies the size cap); the block range drives the caret-reveal test.
#[derive(Clone)]
pub struct TableCtx {
    /// Byte range of the whole table block. Grid mode is active only while the cursor
    /// is *not* inside this range.
    pub block: Range<usize>,
    pub kind: RowKind,
}

/// Marks a [`LineRender`] as a grid-rendered table row. Set only in grid mode (cursor
/// off the block); the draw path uses `table_start` to fetch the shared `TableLayout`
/// and `kind` to pick the row.
#[derive(Clone, Copy)]
pub struct TableLineRef {
    /// Byte offset of the table block's start — the `TableLayout` cache identity.
    pub table_start: usize,
    pub kind: RowKind,
}

/// Fully-resolved styling for one line, ready to hand to `TextEngine::build_line`.
#[derive(Clone)]
pub struct LineRender {
    pub text: String,
    pub font_size: f32,
    pub runs: Vec<StyleRun>,
    /// Maps the display `text` back to buffer bytes (for cursor/click/diff).
    pub map: SegmentMap,
    /// Display byte offset where the line's content begins (after list/quote/heading
    /// markers). Wrapped rows hang-indent under this column; 0 means no marker prefix.
    pub content_start: usize,
    /// Display byte offset of each blockquote marker on the line (outermost first), one
    /// per nesting level. The draw path measures each to an x and paints a continuous
    /// vertical rule there, so the quote bar spans wrapped rows and adjacent lines.
    pub quote_bar_bytes: Vec<usize>,
    /// This line is a thematic break rendered as a horizontal rule (its `---` text is
    /// hidden). False while the cursor is on the line, so the `---` is editable.
    pub is_hr: bool,
    /// Display byte ranges of inline `code` spans, for painting a subtle background
    /// chip behind them (they'd otherwise be indistinguishable in a monospace body).
    pub code_ranges: Vec<Range<usize>>,
    /// This line is a standalone image (`![alt](url)` as the whole paragraph): its
    /// markdown text is hidden and an image block is drawn instead. `None` while the
    /// cursor is on the line, so the raw markdown is revealed for editing.
    pub image: Option<ImageRef>,
    /// Inline images on the line (`![alt](url)` mixed with text or other images), each
    /// collapsed to a zero-width point where the draw path reserves an inline box. Empty
    /// while the cursor is on the line (raw markdown revealed) and on standalone-image
    /// lines (mutually exclusive with `image`). Ordered by `display_offset`.
    pub inline_images: Vec<InlineImageRef>,
    /// Inline `$…$` math spans on the line, each collapsed to a zero-width point where the
    /// draw path reserves an inline box for the rendered math. Empty for a span the cursor
    /// is inside (raw `$…$` revealed) and when the `math` feature is off.
    pub inline_math: Vec<InlineMathRef>,
    /// This line is a grid-rendered table row: its raw pipe text is hidden (`text`
    /// empty) and the draw path paints the cell grid instead. `None` when the line is
    /// not a table, or when the cursor is inside the table block (raw text revealed).
    pub table: Option<TableLineRef>,
}

/// Font-size multiplier for fenced code blocks — slightly smaller than body, matching
/// how GitHub/most editors render code denser than prose.
const CODE_BLOCK_SCALE: f32 = 0.9;

/// Font-size multiplier for a heading level (1 = largest). 0 = body text. A modular type
/// scale on the Major Third ratio (1.25), anchored so H6 = body size (the floor — no
/// heading is ever smaller than body) and each step up is 1.25× the last, giving large,
/// evenly-exaggerated jumps that keep adjacent levels clearly distinct.
fn heading_scale(level: u8) -> f32 {
    match level {
        1 => 3.05, // 1.25^5
        2 => 2.44, // 1.25^4
        3 => 1.95, // 1.25^3
        4 => 1.56, // 1.25^2
        5 => 1.25, // 1.25^1
        _ => 1.0,  // H6+ = body
    }
}

/// Build the display text (markdown markers hidden when the cursor is off the
/// region), a segment map back to buffer bytes, and style runs over the display,
/// for `line_idx`. `cursor_offset` is the absolute buffer cursor position; markers
/// on the cursor's own region/line stay revealed for editing.
///
/// `extra_regions` are additional inline styled regions to merge in (validated
/// GitHub refs → cyan/underline links, and naked-URL shortening via `display_text`).
/// They carry absolute buffer ranges, like the snapshot's own inline styles.
/// `line_styles` are this line's tree inline styles (bucketed by the caller via
/// `RenderSnapshot::inline_styles_by_line`, without checkbox regions — this fn
/// injects those from `markers`). Passing them in avoids the O(n²) per-line
/// `styles_in_range` scan when laying out a whole document.
#[allow(clippy::too_many_arguments)]
/// The style run + a "this is an inline-code chip" flag for one inline region's display
/// range. `fallback` colors plain content (foreground, or dimmed on a completed task).
/// Shared by `build_line_render` and `build_cell_render`; the line path layers heading/
/// checkbox bold on top of the returned run.
fn styled_run_for(
    region: &StyledRegion,
    theme: &EditorTheme,
    display: Range<usize>,
    fallback: Color,
) -> (StyleRun, bool) {
    let style = &region.style;
    let color = match region.checkbox {
        // A checked box reads as "done" (green); an empty box is muted.
        Some(true) => peniko_color(theme.green),
        Some(false) => peniko_color(theme.comment),
        None if region.link_url.is_some() => peniko_color(theme.cyan),
        None if style.code => peniko_color(theme.green),
        None => fallback,
    };
    // Inline code (not a link/checkbox) gets a background chip behind its span.
    let is_code_chip = style.code && region.checkbox.is_none() && region.link_url.is_none();
    let mut run = StyleRun::new(display, color);
    run.bold = style.bold;
    run.italic = style.italic;
    run.mono = style.code;
    run.strikethrough = style.strikethrough;
    run.underline = region.link_url.is_some();
    (run, is_code_chip)
}

pub fn build_line_render(
    snapshot: &RenderSnapshot,
    line_idx: usize,
    theme: &EditorTheme,
    base_font_size: f32,
    cursor_offset: usize,
    line_styles: &[StyledRegion],
    extra_regions: &[StyledRegion],
    // Table membership for this line, when it belongs to a small-enough table. Grid
    // mode (hidden text + `table` ref) engages only while the cursor is off the block;
    // otherwise the line renders as ordinary raw pipe text via the normal path.
    table_ctx: Option<TableCtx>,
    // Inline `$…$` math spans on this line (empty when the `math` feature is off). Each is
    // collapsed to an inline box unless the caret is inside it (then the raw `$…$` shows).
    math_spans: &[MathSpan],
    // Buffer byte ranges on this line whose text should be LaTeX-syntax-highlighted: the
    // content of a revealed inline `$…$` span or a revealed `$$…$$` block line. Empty when
    // no math is revealed here (and always when the `math` feature is off). Computed by the
    // caller, which knows the reveal state; this just tokenizes + colors them.
    latex_ranges: &[Range<usize>],
) -> LineRender {
    let markers = snapshot.line_markers(line_idx);
    let range = markers.range.clone();
    let line_start = range.start;

    let rope = &snapshot.rope;
    // Slice once, excluding a trailing '\n', to avoid a second (trim) allocation.
    let byte_end = if range.end > range.start && rope.get_byte(range.end - 1) == Some(b'\n') {
        range.end - 1
    } else {
        range.end
    };
    let line_text = rope
        .slice(rope.byte_to_char(range.start)..rope.byte_to_char(byte_end))
        .to_string();
    let line_end = line_start + line_text.len();

    let heading_level = markers.heading_level().unwrap_or(0);
    let in_code_block = markers.in_code_block || markers.is_fence();
    // Fenced code renders slightly smaller (GitHub-style) so a code block reads as a
    // denser, distinct block instead of competing with body prose. Headings and code are
    // mutually exclusive, so the two scales never combine.
    let font_size = if in_code_block {
        base_font_size * CODE_BLOCK_SCALE
    } else {
        base_font_size * heading_scale(heading_level)
    };
    let cursor_on_line = cursor_in(&range, cursor_offset);

    // Grid table row: the cursor is off the whole table block, so this line hides its
    // raw pipe text and the draw path paints the cell grid. While the cursor is inside
    // the block, `table_ref` stays `None` and the line renders as ordinary text (reveal).
    // Membership is line-based (the cursor's line start), matching the editor's
    // `table_context_at_line`, so a caret at a row's left edge or at the last row's end —
    // where `offset == block.end` for a table ending at EOF with no trailing newline —
    // still counts as inside the block and reveals.
    let table_ref = table_ctx.as_ref().and_then(|tc| {
        // Only table lines need the cursor's line start (a rope line lookup), so compute it
        // here rather than for every laid-out line.
        let cursor_line_start = snapshot
            .line_byte_range(rope.byte_to_line(cursor_offset.min(rope.len_bytes())))
            .start;
        (!tc.block.contains(&cursor_line_start)).then_some(TableLineRef {
            table_start: tc.block.start,
            kind: tc.kind,
        })
    });
    let is_grid_table = table_ref.is_some();

    // Pre-bucketed tree styles (already sorted by start, since `inline_styles` is)
    // plus a synthetic checkbox region from markers, then GitHub extras. The common
    // case — no checkbox, no extras — borrows `line_styles` untouched, skipping the
    // per-line clone+sort on every laid-out line.
    let has_checkbox_style = markers
        .markers
        .iter()
        .any(|m| matches!(m.kind, MarkerKind::Checkbox { .. }));
    let inline: Cow<[StyledRegion]> = if !has_checkbox_style && extra_regions.is_empty() {
        Cow::Borrowed(line_styles)
    } else {
        let mut inline: Vec<StyledRegion> = line_styles.to_vec();
        for marker in &markers.markers {
            if let MarkerKind::Checkbox { checked } = marker.kind {
                // The checkbox marker range is "[ ] " (4 bytes); style only "[ ]" (3).
                let checkbox_range = marker.range.start..marker.range.start + 3;
                inline.push(StyledRegion {
                    full_range: checkbox_range.clone(),
                    content_range: checkbox_range,
                    style: TextStyle::default(),
                    link_url: None,
                    is_image: false,
                    checkbox: Some(checked),
                    display_text: None,
                });
            }
        }
        inline.sort_by_key(|s| s.full_range.start);
        inline.extend_from_slice(extra_regions);
        Cow::Owned(inline)
    };

    // Each region's cursor-inside verdict, computed once and reused by the specials
    // and style-run passes below (both would otherwise recompute it).
    let cursor_inside: Vec<bool> = inline
        .iter()
        .map(|r| cursor_in(&r.full_range, cursor_offset))
        .collect();

    // Standalone image: the line's whole content (ignoring surrounding whitespace) is a
    // single `![alt](url)` region. Rendered as a drawn image block, with the markdown
    // text hidden — except while the cursor is on the line (revealed for editing). The
    // "covers the trimmed content" test naturally excludes list/quote-prefixed images
    // (their marker sits before the image, outside its range).
    let trimmed_start = line_start + (line_text.len() - line_text.trim_start().len());
    let trimmed_end = line_start + line_text.trim_end().len();
    let image = if !in_code_block && !cursor_on_line && trimmed_start < trimmed_end {
        let mut images = inline.iter().filter(|r| r.is_image);
        match (images.next(), images.next()) {
            (Some(r), None)
                if r.full_range.start == trimmed_start
                    && r.full_range.end == trimmed_end
                    && r.content_range.start >= line_start
                    && r.content_range.end <= line_end =>
            {
                r.link_url.as_ref().map(|url| {
                    let alt = line_text
                        [r.content_range.start - line_start..r.content_range.end - line_start]
                        .to_string();
                    ImageRef {
                        url: url.clone(),
                        alt,
                    }
                })
            }
            _ => None,
        }
    } else {
        None
    };
    let is_standalone_image = image.is_some();

    // Collect the buffer ranges hidden or collapsed on the way to the display.
    // Code blocks show their markers verbatim; thematic breaks hide their `---` and
    // render as a drawn rule (unless the cursor is on the line, for editing).
    let is_hr = markers.is_thematic_break() && !cursor_on_line && !in_code_block;
    let mut specials: Vec<Special> = Vec::new();
    // Inline images collected during the specials pass: (buffer offset of the collapsed
    // `![alt](url)` span, url, alt). Resolved to display offsets once the map is built.
    let mut inline_image_specs: Vec<(usize, String, String)> = Vec::new();
    if is_grid_table {
        // Grid mode: hide the whole line; the draw path paints the cell grid instead.
        if line_end > line_start {
            specials.push(Special::Hidden(line_start..line_end));
        }
    } else if !in_code_block {
        // Heading `# ` prefix hides when the cursor is elsewhere.
        if heading_level > 0
            && !cursor_on_line
            && let Some(mr) = markers.marker_range()
            && mr.end <= line_end
        {
            specials.push(Special::Hidden(mr));
        }
        // Thematic break: hide the `---` text; the draw path paints a horizontal rule.
        if is_hr && line_end > line_start {
            specials.push(Special::Hidden(line_start..line_end));
        }
        // Standalone image: hide the whole `![alt](url)` line; the draw path paints the
        // image block. Hiding the entire content leaves `text` empty (no double render).
        if is_standalone_image && line_end > line_start {
            specials.push(Special::Hidden(line_start..line_end));
        }
        // Prefix markers render as bullets / blockquote gutters (always on — they're
        // structural), substituting `- `→`• `, `> `→`  ` (the blockquote bar is painted
        // as a continuous rule in the draw path, not a per-line glyph). Indent whitespace and
        // ordered-list numbers stay literal; checkbox stays as `[ ]`/`[x]`.
        // On a task item the checkbox is the marker, so suppress the list bullet
        // (`has_checkbox_style`, computed above, is the same scan).
        for marker in &markers.markers {
            let sub = match &marker.kind {
                // Task item: hide the `- ` so the checkbox is the marker.
                MarkerKind::ListItem { ordered: false, .. } if has_checkbox_style => Some(""),
                MarkerKind::ListItem {
                    ordered: false,
                    unordered_marker,
                    ..
                } => Some(unordered_marker.as_ref().map_or("", |m| m.bullet())),
                MarkerKind::BlockQuote => Some("  "),
                _ => None,
            };
            if let Some(display) = sub
                && marker.range.start >= line_start
                && marker.range.end <= line_end
            {
                specials.push(Special::Collapsed {
                    buffer: marker.range.clone(),
                    display: display.to_string(),
                });
            }
        }
        // A standalone-image line is fully hidden (the image block draws in its place), so
        // skip the whole inline-collapse pass rather than testing the flag every iteration.
        let collapse_regions: &[StyledRegion] = if is_standalone_image { &[] } else { &inline };
        for (i, region) in collapse_regions.iter().enumerate() {
            if cursor_inside[i] {
                continue; // reveal the whole region (markers included) for editing
            }
            // Inline image: collapse the whole `![alt](url)` span to a zero-width point
            // and record it. The draw path reserves an inline box there and paints the
            // image; unlike the delimiter-hiding fallback, no alt/link text shows.
            // Reveal is per-image, not per-line (the `cursor_inside[i]` skip above
            // already reveals the one the caret is in), so editing one badge in a row
            // leaves the others rendered.
            if region.is_image
                && region.content_range.start >= line_start
                && region.content_range.end <= line_end
                && let Some(url) = &region.link_url
            {
                specials.push(Special::Hidden(region.full_range.clone()));
                let alt = line_text[region.content_range.start - line_start
                    ..region.content_range.end - line_start]
                    .to_string();
                inline_image_specs.push((region.full_range.start, url.clone(), alt));
                continue;
            }
            if let Some(dt) = &region.display_text {
                specials.push(Special::Collapsed {
                    buffer: region.full_range.clone(),
                    display: dt.clone(),
                });
            } else {
                // Hide the opening and closing delimiters (e.g. `**` … `**`).
                if region.content_range.start > region.full_range.start {
                    specials.push(Special::Hidden(
                        region.full_range.start..region.content_range.start,
                    ));
                }
                if region.full_range.end > region.content_range.end {
                    specials.push(Special::Hidden(
                        region.content_range.end..region.full_range.end,
                    ));
                }
            }
        }
    }

    // Inline `$…$` math: collapse each span to a zero-width point (the draw path reserves
    // an inline box + paints the rendered math), unless the caret is inside it — then the
    // raw `$…$` shows for editing. Grid tables / standalone-image lines have no text flow.
    let mut inline_math_specs: Vec<(usize, String)> = Vec::new();
    if !is_grid_table && !is_standalone_image {
        for span in math_spans {
            if span.full_range.start < line_start
                || span.full_range.end > line_end
                || cursor_in(&span.full_range, cursor_offset)
            {
                continue;
            }
            specials.push(Special::Hidden(span.full_range.clone()));
            let latex = line_text
                [span.content_range.start - line_start..span.content_range.end - line_start]
                .to_string();
            inline_math_specs.push((span.full_range.start, latex));
        }
    }

    let (text, map) = SegmentMap::build(&line_text, line_start, &specials);

    // Resolve each collapsed inline image to its zero-width display point, ordered
    // left-to-right. The `![alt](url)` span was hidden, so this offset is where the
    // draw path anchors the inline box.
    let mut inline_images: Vec<InlineImageRef> = inline_image_specs
        .into_iter()
        .map(|(buffer_start, url, alt)| InlineImageRef {
            url,
            alt,
            display_offset: map.buffer_to_display(buffer_start),
        })
        .collect();
    inline_images.sort_by_key(|i| i.display_offset);
    let mut inline_math: Vec<InlineMathRef> = inline_math_specs
        .into_iter()
        .map(|(buffer_start, latex)| InlineMathRef {
            latex,
            display_offset: map.buffer_to_display(buffer_start),
        })
        .collect();
    inline_math.sort_by_key(|m| m.display_offset);

    // Display offsets of each blockquote marker (outermost first), for the drawn gutter
    // rules. Skipped inside code blocks, where `> ` isn't a quote marker.
    let quote_bar_bytes: Vec<usize> = if in_code_block {
        Vec::new()
    } else {
        let mut bars: Vec<usize> = markers
            .markers
            .iter()
            .filter(|m| matches!(m.kind, MarkerKind::BlockQuote))
            .map(|m| map.buffer_to_display(m.range.start))
            .collect();
        bars.sort_unstable(); // markers are innermost-first; want outermost (leftmost) first
        bars
    };

    let fg = peniko_color(theme.foreground);
    let mut runs = Vec::new();
    let mut code_ranges: Vec<Range<usize>> = Vec::new();

    if is_grid_table {
        // Grid mode: text is hidden, so there are no runs to build (cells are styled
        // separately by `build_cell_render` in the draw path).
    } else if in_code_block {
        // Monospace everywhere, tree-sitter capture colors on top.
        if !text.is_empty() {
            let mut base = StyleRun::new(0..text.len(), fg);
            base.mono = true;
            runs.push(base);
        }
        if markers.is_fence() {
            // Fence line: the ```/~~~ delimiter reads as comment, the language/info
            // string as green (matching the old gpui rendering). Overlays the base run.
            let bytes = text.as_bytes();
            if let Some(start) = bytes.iter().position(|&b| b == b'`' || b == b'~') {
                let fc = bytes[start];
                let end = start + bytes[start..].iter().take_while(|&&b| b == fc).count();
                let mut delim = StyleRun::new(start..end, peniko_color(theme.comment));
                delim.mono = true;
                runs.push(delim);
                if end < text.len() {
                    let mut lang = StyleRun::new(end..text.len(), peniko_color(theme.green));
                    lang.mono = true;
                    runs.push(lang);
                }
            }
        }
        for span in snapshot.code_highlights_for_line(line_idx) {
            let r = map.buffer_range_to_display(span.range.clone());
            if !r.is_empty() {
                let mut run = StyleRun::new(
                    r,
                    peniko_color(theme.color_for_highlight(span.highlight_id)),
                );
                run.mono = true;
                runs.push(run);
            }
        }
    } else {
        // A completed task's content reads as "done": dimmed (its checkbox stays green).
        // A base run under everything catches plain text between styled spans.
        let dim = markers.checkbox() == Some(true);
        let content_color = if dim { peniko_color(theme.comment) } else { fg };
        if dim && !text.is_empty() {
            runs.push(StyleRun::new(0..text.len(), content_color));
        }
        if heading_level > 0 && !text.is_empty() {
            let mut h = StyleRun::new(0..text.len(), peniko_color(theme.purple));
            h.bold = true;
            runs.push(h);
        }
        for (i, region) in inline.iter().enumerate() {
            // Style the visible content; when revealed, style the whole region.
            let style_range = if cursor_inside[i] {
                region.full_range.clone()
            } else {
                region.content_range.clone()
            };
            let r = map.buffer_range_to_display(style_range);
            if r.is_empty() {
                continue;
            }
            let (mut run, is_code_chip) = styled_run_for(region, theme, r.clone(), content_color);
            if is_code_chip {
                code_ranges.push(r);
            }
            // A heading's inline content and a checked task's content read bold.
            run.bold = run.bold || heading_level > 0 || region.checkbox == Some(true);
            runs.push(run);
        }
    }

    // Revealed math: overlay LaTeX-token colors on the raw source (mono, on top of the
    // base runs). Routed through the same `latex` tokenizer + theme color map as a
    // ```latex code fence, so revealed inline/block math reads like the other languages.
    for lr in latex_ranges {
        let (Some(rel_start), Some(rel_end)) = (
            lr.start.checked_sub(line_start),
            lr.end.checked_sub(line_start),
        ) else {
            continue;
        };
        let Some(slice) = line_text.get(rel_start..rel_end.min(line_text.len())) else {
            continue;
        };
        for span in tokenize::highlight_latex(slice) {
            let buf = (lr.start + span.range.start)..(lr.start + span.range.end);
            let r = map.buffer_range_to_display(buf);
            if r.is_empty() {
                continue;
            }
            let mut run = StyleRun::new(
                r,
                peniko_color(theme.color_for_highlight(span.highlight_id)),
            );
            run.mono = true;
            runs.push(run);
        }
    }

    // Display column where content begins (after markers) — drives the hanging indent
    // so wrapped rows align under it. Hidden markers (e.g. heading `# `) collapse to 0.
    let content_start = map.buffer_to_display(markers.content_start());

    LineRender {
        text,
        font_size,
        runs,
        map,
        content_start,
        quote_bar_bytes,
        is_hr,
        image,
        code_ranges,
        inline_images,
        inline_math,
        table: table_ref,
    }
}

/// Build the display text, style runs, and segment map for a single table cell's
/// inline content, off-cursor (grid tables are caret-free by construction, so markers
/// are always hidden). Shared by the column-width pre-pass (measuring) and the grid
/// draw path, so a cell measures and renders identically. `cell_range` is the cell's
/// buffer byte range; `line_styles` are the inline styled regions bucketed for the
/// cell's line (the caller passes the right bucket).
pub fn build_cell_render(
    snapshot: &RenderSnapshot,
    theme: &EditorTheme,
    cell_range: Range<usize>,
    line_styles: &[StyledRegion],
) -> (String, Vec<StyleRun>, SegmentMap, Vec<Range<usize>>) {
    let rope = &snapshot.rope;
    let start = cell_range.start;
    let end = cell_range.end.max(start);
    let cell_text = rope
        .slice(rope.byte_to_char(start)..rope.byte_to_char(end))
        .to_string();

    // Inline styled regions fully contained in the cell (pipes break spans, so a cell's
    // emphasis/code/link regions never straddle its boundary).
    let regions: Vec<&StyledRegion> = line_styles
        .iter()
        .filter(|r| r.full_range.start >= start && r.full_range.end <= end)
        .collect();

    // Hide each region's delimiters (or collapse to its display text), so the shown cell
    // text is just the styled content — mirroring the off-cursor path in build_line_render.
    let mut specials: Vec<Special> = Vec::new();
    for r in &regions {
        if let Some(dt) = &r.display_text {
            specials.push(Special::Collapsed {
                buffer: r.full_range.clone(),
                display: dt.clone(),
            });
        } else {
            if r.content_range.start > r.full_range.start {
                specials.push(Special::Hidden(r.full_range.start..r.content_range.start));
            }
            if r.full_range.end > r.content_range.end {
                specials.push(Special::Hidden(r.content_range.end..r.full_range.end));
            }
        }
    }
    let (text, map) = SegmentMap::build(&cell_text, start, &specials);

    let fg = peniko_color(theme.foreground);
    let mut runs = Vec::new();
    let mut code_ranges = Vec::new();
    for r in &regions {
        let dr = map.buffer_range_to_display(r.content_range.clone());
        if dr.is_empty() {
            continue;
        }
        let (run, is_code_chip) = styled_run_for(r, theme, dr.clone(), fg);
        if is_code_chip {
            code_ranges.push(dr);
        }
        runs.push(run);
    }
    (text, runs, map, code_ranges)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::buffer::Buffer;
    use crate::inline::TextStyle;
    use std::ops::Range;

    fn link_region(range: Range<usize>) -> StyledRegion {
        StyledRegion {
            full_range: range.clone(),
            content_range: range,
            style: TextStyle::default(),
            link_url: Some("https://github.com/o/r/issues/1".to_string()),
            is_image: false,
            checkbox: None,
            display_text: None,
        }
    }

    /// An extra region carrying `link_url` (a validated GitHub ref) colors its span
    /// as an underlined link — the render half of the GitHub coloring feature.
    #[test]
    fn extra_link_region_styles_underlined_link() {
        let mut buffer: Buffer = "See #1 today\n".parse().unwrap();
        let snapshot = buffer.render_snapshot();
        let theme = EditorTheme::dracula();
        // "#1" is bytes 4..6. Cursor off the line so nothing is revealed.
        let extra = [link_region(4..6)];
        let lr = build_line_render(
            &snapshot,
            0,
            &theme,
            18.0,
            usize::MAX,
            &[],
            &extra,
            None,
            &[],
            &[],
        );
        let link_run = lr
            .runs
            .iter()
            .find(|r| r.underline)
            .expect("a link run should exist");
        assert_eq!(link_run.color, peniko_color(theme.cyan));
        // The colored display range corresponds to "#1".
        let ds = lr.map.buffer_to_display(4);
        let de = lr.map.buffer_to_display(6);
        assert_eq!(link_run.range, ds..de);
    }

    /// Without any extra regions the same line has no link run (unvalidated refs
    /// stay plain text).
    #[test]
    fn no_extra_regions_no_link() {
        let mut buffer: Buffer = "See #1 today\n".parse().unwrap();
        let snapshot = buffer.render_snapshot();
        let theme = EditorTheme::dracula();
        let lr = build_line_render(
            &snapshot,
            0,
            &theme,
            18.0,
            usize::MAX,
            &[],
            &[],
            None,
            &[],
            &[],
        );
        assert!(!lr.runs.iter().any(|r| r.underline));
    }

    /// A completed task dims its content (muted, spanning the line) while its checkbox
    /// stays green; an active task's content is not dimmed.
    #[test]
    fn completed_task_dims_content() {
        let theme = EditorTheme::dracula();
        let comment = peniko_color(theme.comment);
        let mut buf: Buffer = "- [x] done\n- [ ] todo\n".parse().unwrap();
        let snap = buf.render_snapshot();

        let done = build_line_render(&snap, 0, &theme, 18.0, usize::MAX, &[], &[], None, &[], &[]);
        assert!(
            done.runs
                .iter()
                .any(|r| r.color == comment && r.range.len() >= done.text.len()),
            "completed task content is dimmed across the line"
        );
        assert!(
            done.runs
                .iter()
                .any(|r| r.color == peniko_color(theme.green)),
            "the checkbox itself stays green"
        );

        let todo = build_line_render(&snap, 1, &theme, 18.0, usize::MAX, &[], &[], None, &[], &[]);
        assert!(
            !todo
                .runs
                .iter()
                .any(|r| r.color == comment && r.range.len() >= todo.text.len()),
            "active task content is not dimmed (only its empty box is muted)"
        );
    }

    /// Inline code spans expose their display ranges (for the background chip); a line
    /// with no inline code exposes none.
    #[test]
    fn inline_code_exposes_code_ranges() {
        let theme = EditorTheme::dracula();
        let mut buf: Buffer = "call `foo()` now\nplain line\n".parse().unwrap();
        let snap = buf.render_snapshot();
        let styles = snap.inline_styles_by_line();

        let coded = build_line_render(
            &snap,
            0,
            &theme,
            18.0,
            usize::MAX,
            &styles[0],
            &[],
            None,
            &[],
            &[],
        );
        assert_eq!(coded.code_ranges.len(), 1, "one code span");
        // The chip covers the visible `foo()` (backticks hidden off-cursor).
        let (s, e) = (coded.code_ranges[0].start, coded.code_ranges[0].end);
        assert_eq!(&coded.text[s..e], "foo()");

        let plain = build_line_render(
            &snap,
            1,
            &theme,
            18.0,
            usize::MAX,
            &styles[1],
            &[],
            None,
            &[],
            &[],
        );
        assert!(plain.code_ranges.is_empty(), "no code span, no chip");
    }

    /// A thematic break hides its `---` and flags `is_hr` for the drawn rule — but
    /// reveals the text (no rule) while the cursor is on the line, for editing.
    #[test]
    fn thematic_break_hides_text_and_flags_rule() {
        let theme = EditorTheme::dracula();
        // Blank lines around `---` so it parses as a thematic break, not a setext
        // heading underline (`a\n---` would make "a" an H2). The break is line 2.
        let mut buffer: Buffer = "a\n\n---\n\nb\n".parse().unwrap();
        let snap = buffer.render_snapshot();

        // Cursor off the `---` line: text hidden, rule flagged.
        let lr = build_line_render(&snap, 2, &theme, 18.0, 0, &[], &[], None, &[], &[]);
        assert!(lr.is_hr, "off-line thematic break renders as a rule");
        assert!(lr.text.is_empty(), "the `---` text is hidden");

        // Cursor on the `---` line: text revealed, no rule.
        let on = snap.line_markers(2).range.start;
        let lr = build_line_render(&snap, 2, &theme, 18.0, on, &[], &[], None, &[], &[]);
        assert!(!lr.is_hr, "revealed for editing when cursor is on it");
        assert_eq!(lr.text, "---");
    }

    /// A paragraph that is only `![alt](url)` flags `image: Some` (with url + alt) and
    /// hides its markdown text, so the draw path paints an image block instead. When the
    /// cursor is on the line, `image` is `None` and the raw markdown is revealed.
    #[test]
    fn standalone_image_detected_off_line_revealed_on_line() {
        let theme = EditorTheme::dracula();
        let mut buffer: Buffer = "![a badge](img/badge.png)\n".parse().unwrap();
        let snap = buffer.render_snapshot();
        let styles = snap.inline_styles_by_line();

        let off = build_line_render(
            &snap,
            0,
            &theme,
            18.0,
            usize::MAX,
            &styles[0],
            &[],
            None,
            &[],
            &[],
        );
        let img = off
            .image
            .as_ref()
            .expect("standalone image detected off-line");
        assert_eq!(img.url, "img/badge.png");
        assert_eq!(img.alt, "a badge");
        assert!(off.text.is_empty(), "the `![alt](url)` markdown is hidden");

        // Cursor on the line: reveal the raw markdown, no image block.
        let on = build_line_render(&snap, 0, &theme, 18.0, 0, &styles[0], &[], None, &[], &[]);
        assert!(
            on.image.is_none(),
            "cursor on the line reveals raw markdown"
        );
        assert_eq!(on.text, "![a badge](img/badge.png)");
    }

    /// An image that is only part of a paragraph (surrounding text) is not standalone:
    /// it stays inline text/link, `image` is `None`.
    #[test]
    fn inline_image_is_not_standalone() {
        let theme = EditorTheme::dracula();
        let mut buffer: Buffer = "see ![a](x.png) here\n".parse().unwrap();
        let snap = buffer.render_snapshot();
        let styles = snap.inline_styles_by_line();
        let lr = build_line_render(
            &snap,
            0,
            &theme,
            18.0,
            usize::MAX,
            &styles[0],
            &[],
            None,
            &[],
            &[],
        );
        assert!(
            lr.image.is_none(),
            "image amid other text is not standalone"
        );
    }

    /// Two images on one line (a badge row) become inline-image refs off-cursor: their
    /// `![...](...)` markdown is hidden and their display offsets ascend. Reveal is
    /// per-image: the caret inside one reveals only that one (the other stays a box); a
    /// caret elsewhere on the line keeps both rendered.
    #[test]
    fn inline_images_reveal_per_image() {
        let theme = EditorTheme::dracula();
        // "![a](x.png) and ![b](y.png)": image 1 spans 0..11, " and " 11..16, image 2 16..27.
        let mut buffer: Buffer = "![a](x.png) and ![b](y.png)\n".parse().unwrap();
        let snap = buffer.render_snapshot();
        let styles = snap.inline_styles_by_line();

        let off = build_line_render(
            &snap,
            0,
            &theme,
            18.0,
            usize::MAX,
            &styles[0],
            &[],
            None,
            &[],
            &[],
        );
        assert_eq!(off.inline_images.len(), 2, "both inline images detected");
        assert!(
            off.inline_images[0].display_offset <= off.inline_images[1].display_offset,
            "display offsets ascend left-to-right"
        );
        assert!(
            !off.text.contains("!["),
            "markdown hidden, got {:?}",
            off.text
        );
        assert!(off.image.is_none(), "not a standalone image");

        // Caret inside the first image: it reveals, the second stays a box.
        let in_first =
            build_line_render(&snap, 0, &theme, 18.0, 2, &styles[0], &[], None, &[], &[]);
        assert_eq!(
            in_first.inline_images.len(),
            1,
            "only the other image stays a box"
        );
        assert!(in_first.text.contains("![a]"), "caret's image shown raw");

        // Caret on the line but between the images: both stay boxes.
        let between =
            build_line_render(&snap, 0, &theme, 18.0, 13, &styles[0], &[], None, &[], &[]);
        assert_eq!(
            between.inline_images.len(),
            2,
            "off-caret images stay boxes"
        );
    }

    /// The blockquote bar is no longer a `▎` glyph in the text — the marker collapses
    /// to spacing and one gutter offset per nesting level is exposed for the drawn rule.
    #[test]
    fn blockquote_exposes_gutter_offsets_not_glyph() {
        let theme = EditorTheme::dracula();

        let mut single: Buffer = "> quoted line\n".parse().unwrap();
        let snap = single.render_snapshot();
        let lr = build_line_render(&snap, 0, &theme, 18.0, usize::MAX, &[], &[], None, &[], &[]);
        assert!(!lr.text.contains(''), "bar must not be a text glyph");
        assert_eq!(lr.quote_bar_bytes, vec![0], "one gutter at the line start");

        let mut nested: Buffer = "> > deeply quoted\n".parse().unwrap();
        let snap = nested.render_snapshot();
        let lr = build_line_render(&snap, 0, &theme, 18.0, usize::MAX, &[], &[], None, &[], &[]);
        assert_eq!(lr.quote_bar_bytes.len(), 2, "one gutter per nesting level");
        assert_eq!(lr.quote_bar_bytes[0], 0, "outer gutter at line start");
        assert!(
            lr.quote_bar_bytes[1] > lr.quote_bar_bytes[0],
            "inner gutter sits to the right of the outer"
        );
    }

    /// A grid-table row (cursor off the block) hides its raw pipe text and flags a
    /// `table` ref; with the cursor inside the block it renders as ordinary raw text.
    #[test]
    fn table_row_hides_off_cursor_reveals_on_cursor() {
        let theme = EditorTheme::dracula();
        let mut buf: Buffer = "| a | b |\n|---|---|\n| 1 | 2 |\n".parse().unwrap();
        let snap = buf.render_snapshot();
        let styles = snap.inline_styles_by_line();
        let (table, _) = snap.table_row_at_line(0).unwrap();
        let ctx = TableCtx {
            block: table.block.clone(),
            kind: RowKind::Header,
        };

        // Cursor off the block: grid mode — text hidden, table ref set.
        let off = build_line_render(
            &snap,
            0,
            &theme,
            18.0,
            usize::MAX,
            &styles[0],
            &[],
            Some(ctx.clone()),
            &[],
            &[],
        );
        assert!(off.table.is_some(), "off-cursor header is a grid row");
        assert!(off.text.is_empty(), "grid row text hidden");

        // Cursor inside the block: reveal — raw pipe text, no table ref.
        let on = build_line_render(
            &snap,
            0,
            &theme,
            18.0,
            2,
            &styles[0],
            &[],
            Some(ctx),
            &[],
            &[],
        );
        assert!(on.table.is_none(), "cursor in block reveals raw text");
        assert_eq!(on.text, "| a | b |");
    }

    /// A caret at the end of the last row of a table that ends at EOF with no trailing
    /// newline (`offset == block.end`) still counts as inside the block, so the last row
    /// reveals its raw text rather than staying a hidden grid row. Line-based membership.
    #[test]
    fn table_reveals_at_last_row_end_without_trailing_newline() {
        let theme = EditorTheme::dracula();
        let mut buf: Buffer = "| a | b |\n|---|---|\n| 1 | 2 |".parse().unwrap();
        let snap = buf.render_snapshot();
        let styles = snap.inline_styles_by_line();
        let last = snap.line_count() - 1;
        let (table, kind) = snap.table_row_at_line(last).unwrap();
        let eof = snap.rope.len_bytes();
        assert_eq!(eof, table.block.end, "table block ends at EOF");
        let ctx = TableCtx {
            block: table.block.clone(),
            kind,
        };
        let lr = build_line_render(
            &snap,
            last,
            &theme,
            18.0,
            eof,
            &styles[last],
            &[],
            Some(ctx),
            &[],
            &[],
        );
        assert!(
            lr.table.is_none(),
            "caret at block.end reveals the last row"
        );
        assert_eq!(lr.text, "| 1 | 2 |");
    }

    /// `build_cell_render` styles a cell's inline content off-cursor: `**bold**` hides
    /// its markers and the shown text is bold; a plain cell has no runs.
    #[test]
    fn cell_render_styles_bold_and_hides_markers() {
        let theme = EditorTheme::dracula();
        let mut buf: Buffer = "| **hi** | plain |\n|---|---|\n| 1 | 2 |\n"
            .parse()
            .unwrap();
        let snap = buf.render_snapshot();
        let styles = snap.inline_styles_by_line();
        let (table, _) = snap.table_row_at_line(0).unwrap();

        let bold_cell = table.header.cells[0].content.clone();
        let (text, runs, _map, _code) = build_cell_render(&snap, &theme, bold_cell, &styles[0]);
        assert_eq!(text, "hi", "markers hidden, content shown");
        assert!(runs.iter().any(|r| r.bold), "cell content is bold");

        let plain_cell = table.header.cells[1].content.clone();
        let (ptext, pruns, _, _) = build_cell_render(&snap, &theme, plain_cell, &styles[0]);
        assert_eq!(ptext, "plain");
        assert!(pruns.is_empty(), "a plain cell needs no style runs");
    }
}