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
mod cell_overlay;
mod paint;
mod raw_text;
use ratatui::{buffer::Buffer as TuiBuf, layout::Rect, style::Style, widgets::StatefulWidget};
use crate::config::Theme;
use crate::document::detect_setext;
use crate::editor::table_edit;
use crate::editor::vim_ops::VisualKind;
use crate::editor::EditorState;
use crate::markdown::table_layout::{compute_cell_overlay, table_raw_col_to_rendered_col};
use super::image_view::{self, ImageLayoutSnapshot};
use super::line_render::{
render_line_from_visual, render_line_reporting_cursor, render_line_with_cursor_from_visual,
};
use super::link_view::{self, LinkLayoutSnapshot};
use super::table_view::{self, TableLayoutSnapshot};
use self::cell_overlay::{compute_cell_chunk_overlay, compute_wrapped_cell_overlay};
use self::paint::{
make_code_styled_body_line, make_raw_line_over, make_raw_line_with_selection, overlay_raw_cell,
paint_byte_range_overlay,
};
use crate::markdown::list_layout::list_raw_col_to_rendered_col;
pub(crate) use self::paint::{
paint_search_overlays, paint_substitute_preview_overlays, paint_yank_flash,
};
use self::raw_text::raw_line_byte_start;
pub(crate) use self::raw_text::{
raw_block_cursor, raw_source_lines, revealed_source_line_count, revealed_source_lines,
};
/// State for the `RenderedView` widget; owned by `EditorViewState`, updated every frame.
#[derive(Debug, Default)]
pub struct RenderedViewState {
/// First visible rendered line (scroll offset).
pub scroll: usize,
/// Every visible table, captured at the end of the last render, for mouse hit-testing.
pub table_snapshots: Vec<TableLayoutSnapshot>,
/// Every visible `Block::ImageBlock`, captured at the end of the last render, for
/// mouse hit-testing.
pub image_snapshots: Vec<ImageLayoutSnapshot>,
/// Cache key for `image_snapshots`: `(scroll, area, parsed_version)`. A match reuses the
/// vector instead of repeating the O(lines × images) geometry scan.
pub image_snapshots_key: Option<(usize, Rect, u64)>,
/// Every visible Markdown link, captured at the end of the last render, for mouse
/// hit-testing (plain click in Preview, Ctrl-click in Rendered/Raw).
pub link_snapshots: Vec<LinkLayoutSnapshot>,
/// Cache key for `link_snapshots`, as `image_snapshots_key`. The uncached build calls
/// `visual_rows_for_line` for every visible line, which dominated idle CPU.
pub link_snapshots_key: Option<(usize, Rect, u64)>,
/// Cache key for `table_snapshots`: `(scroll, area, parsed_version, show_handles)`.
pub table_snapshots_key: Option<(usize, Rect, u64, bool)>,
/// Absolute `(x, y)` cell where the cursor indicator was painted this frame, or `None`
/// when a raw-reveal / cell-overlay path composited the cursor itself. `EditorView`
/// re-stamps this cell after the search / selection overlays so they can't bury it.
pub cursor_screen: Option<(u16, u16)>,
}
/// Hybrid rendered/raw editing view: every block is styled Markdown except the cursor's,
/// which shows raw source with an inline cursor.
pub struct RenderedView<'a> {
pub state: &'a EditorState,
pub theme: &'a Theme,
/// Paint the `⠿` / `⇔` / `✕` table buttons. Reflects `config.table.show_buttons` AND
/// `capabilities.mouse` (the App zeros the first when the second is false). Buttons paint
/// only on the cursor's table (`paint_handles_for_cursor_table` in `table_view`).
pub show_table_buttons: bool,
/// In-progress table drag to highlight after the handles are painted.
pub drop_indicator: Option<crate::ui::table_view::DropIndicator>,
/// Active vim Visual flavor: the half-open `selection` is widened for the overlay paint
/// via `vim_ops::visual_span` (inclusive of the cursor char in charwise, whole rows in
/// VisualLine); `selection` itself is never snapped.
pub visual_kind: Option<VisualKind>,
/// Block-cursor style for this frame, already resolved for view mode and vim sub-mode
/// (`app::cursor_style`).
pub cursor_style: Style,
}
impl<'a> StatefulWidget for RenderedView<'a> {
type State = RenderedViewState;
fn render(self, area: Rect, buf: &mut TuiBuf, view_state: &mut Self::State) {
if area.height == 0 {
return;
}
let height = area.height as usize;
let editor = self.state;
let cursor_offset = editor.cursor.offset;
let cursor_byte = editor.buffer.rope().char_to_byte(cursor_offset);
// With `parsed_dirty` set, `source_map` ranges are stale, so use the cached
// `cursor_block_idx` / `cursor_block_line_range` (in-line edits don't cross a block
// boundary). When fresh, consult `source_map` directly so tests that set
// `cursor.offset` without `update_cursor_block` see the real block.
let use_cache = editor.parsed_dirty;
let cursor_block_idx = if use_cache {
editor.cursor_block_idx.unwrap_or_else(|| {
editor
.parsed
.source_map
.block_for_byte(cursor_byte)
.unwrap_or(0)
})
} else {
editor
.parsed
.source_map
.block_for_byte(cursor_byte)
.unwrap_or(0)
};
let cursor_block_lines = editor
.parsed
.source_map
.rendered_lines_for_block(cursor_block_idx);
let cursor_block_own = editor.parsed.block_own_line_count(cursor_block_idx);
// The raw line index is an index *into* this source, so derive both together. A stale
// parse rebuilds from the cached buffer-line range so unparsed typing is visible;
// otherwise `raw_block_cursor` is shared with `cursor_rendered_line_idx`.
let (raw_block_source, cursor_raw_line, cursor_col) =
match (use_cache, editor.cursor_block_line_range.clone()) {
(true, Some(range)) => {
let mut out = String::new();
for line in range.clone() {
if let Some(text) = editor.buffer.line(line) {
out.push_str(&text);
}
}
while out.ends_with('\n') {
out.pop();
}
let (buffer_line, col) = editor.cursor.line_col(&editor.buffer);
(out, buffer_line.saturating_sub(range.start), col)
}
(true, None) => (String::new(), 0, 0),
_ => {
let raw = raw_block_cursor(editor, cursor_byte);
(raw.source, raw.raw_line, raw.col)
}
};
let raw_lines: Vec<&str> = raw_source_lines(&raw_block_source);
let is_table = table_edit::is_table_block(&raw_block_source);
let is_setext = detect_setext(&raw_block_source).is_some();
// In a fenced code block only the fence lines de-render (body rows already render
// 1:1); the rule lives in `markdown::code_layout::line_allows_raw_reveal`.
let cursor_block_ast = editor
.parsed
.real_ranges
.iter()
.position(|r| r.start <= cursor_byte && cursor_byte < r.end)
.and_then(|i| editor.parsed.blocks.get(i));
// Diagram blocks (mermaid fences and `$$...$$` math) are synthetic `Block::ImageBlock`s;
// with the cursor inside, every reserved row shows the corresponding raw line, like a
// fenced code block.
let is_diagram_block = editor.parsed.is_diagram_reveal_block(cursor_block_idx);
let is_mermaid_block = editor.parsed.is_mermaid_block(cursor_block_idx);
let is_latex_block = editor.parsed.is_latex_block(cursor_block_idx);
// Big-text H1 (4 big-text rows + rule vs. the plain 2-line H1) collapses to the raw
// `# Title` line plus the rendered rule while the cursor is inside.
let is_big_h1_block = matches!(
cursor_block_ast,
Some(crate::markdown::Block::Heading {
level: pulldown_cmark::HeadingLevel::H1,
..
})
) && cursor_block_own > 2;
// A revealed row inside a blockquote keeps the quote's wash, or the line being edited
// drops out of the quote it visibly belongs to.
let reveal_base = if matches!(
cursor_block_ast,
Some(crate::markdown::Block::BlockQuote { .. })
) {
self.theme.blockquote_text
} else {
self.theme.normal
};
// Single derivation shared with the mouse hit-test: a click mapped against raw text on
// a row the view never revealed lands on the wrong character.
let code_block_allows_reveal = crate::markdown::code_layout::line_allows_raw_reveal(
cursor_block_ast,
cursor_raw_line,
&raw_lines,
);
// Shared with `cursor_rendered_line_idx` (and the mouse hit-test's revealed-line
// shortcut) so the three agree on which row shows raw source.
let cursor_in_block = crate::editor::state::cursor_sub_line_in_block(
&editor.parsed,
cursor_byte,
cursor_block_idx,
cursor_block_own,
&raw_block_source,
&raw_lines,
cursor_raw_line,
);
// Data-row cell in a row that wraps: one raw chunk per rendered sub. `None` for
// non-data and single-sub rows, which the single-line overlays handle.
let wrapped_cell = if is_table && cursor_raw_line >= 2 {
compute_wrapped_cell_overlay(
editor,
cursor_block_lines.clone(),
cursor_raw_line - 2,
cursor_col,
&raw_block_source,
)
} else {
None
};
let cursor_rendered_line = match &wrapped_cell {
Some(w) => w.row_first_line_idx + w.cursor_sub,
None => cursor_block_lines.start + cursor_in_block,
};
// Recorded by the indicator path below so `EditorView` can re-stamp it over overlays.
view_state.cursor_screen = None;
view_state.scroll = editor.scroll;
let scroll = view_state.scroll;
// Reflow-reveal-aware start: `EffectiveRows` is the identity unless the cursor rests in a
// reflowed, revealed paragraph, in which case the block's one rendered flow line is
// replaced by its `M` raw source lines. A viewport that opens *inside* that block then
// starts on one of those raw lines (`start_raw`), not the rendered line.
let effective = editor.effective_rows(area.width as usize);
let reflow_reveal_range = effective.block_rendered();
let (mut virtual_idx, mut first_sub_row, mut start_raw): (
usize,
usize,
Option<(usize, usize)>,
) = match effective.line_at_visual_row(scroll) {
crate::editor::effective_rows::RowHit::Rendered { line, sub } => (line, sub, None),
crate::editor::effective_rows::RowHit::Raw { raw_line, sub } => (
reflow_reveal_range.as_ref().map(|r| r.start).unwrap_or(0),
0,
Some((raw_line, sub)),
),
};
// Jitter suppression: keep the block rendered until the reveal delay elapses.
let reveal_raw = editor.cursor_block_revealed();
let cursor_visible = editor.cursor_visible();
let cursor_indicator_style = self.cursor_style;
let total_rendered = editor.parsed.lines.len();
let wrap = true;
// Selected byte range, intersected per line below.
let selection_bytes = editor.selection.map(|s| {
let r = crate::editor::vim_ops::visual_span(&s, &editor.buffer, self.visual_kind);
let rope = editor.buffer.rope();
(rope.char_to_byte(r.start), rope.char_to_byte(r.end))
});
let block_range_for_cursor = editor
.parsed
.source_map
.original_range_for_byte(cursor_byte);
let mut vis_y: usize = 0;
while vis_y < height {
if virtual_idx >= total_rendered {
break;
}
let skip_rows = first_sub_row;
let rows_used;
let in_cursor_block =
virtual_idx >= cursor_block_lines.start && virtual_idx < cursor_block_lines.end;
// Index into `wrapped_cell.subs` when `virtual_idx` is one of the wrapped cell's
// chunks.
let wrapped_sub_idx_opt: Option<usize> = wrapped_cell.as_ref().and_then(|w| {
let end = w.row_first_line_idx + w.subs.len();
if virtual_idx >= w.row_first_line_idx && virtual_idx < end {
Some(virtual_idx - w.row_first_line_idx)
} else {
None
}
});
if reveal_raw && in_cursor_block && effective.has_reveal() {
// A reflowed paragraph reveals as its stacked raw source lines: the rendered form
// was one wrapped flow, the raw form is `M` source lines, so paint them all in
// this one iteration (the block occupies a single rendered line, so `virtual_idx`
// then advances straight past it). `EffectiveRows` already made scroll, gutter,
// and mouse count these rows. Only the first painted raw line honors `skip_rows`,
// for a viewport opening mid-block.
let (first_raw, first_sub) = start_raw.take().unwrap_or((0, 0));
let block_start = block_range_for_cursor.as_ref().map(|r| r.start);
// Trailing blanks absorbed into the block range own their own rows, so stack only
// the content lines (matches `EffectiveRows` and `revealed_raw_row_count`).
let reveal_count =
revealed_source_line_count(&raw_block_source).min(raw_lines.len());
let mut used = 0usize;
for (raw_idx, &raw_text) in raw_lines
.iter()
.enumerate()
.take(reveal_count)
.skip(first_raw)
{
if vis_y + used >= height {
break;
}
let sub_skip = if raw_idx == first_raw { first_sub } else { 0 };
let sel_cols = selection_bytes.zip(block_start).and_then(|((sa, sb), bs)| {
let raw_line_start_in_block =
raw_line_byte_start(&raw_block_source, raw_idx);
let raw_line_start_abs = bs + raw_line_start_in_block;
let raw_line_end_abs = raw_line_start_abs + raw_text.len();
let start_byte = sa.max(raw_line_start_abs).min(raw_line_end_abs);
let end_byte = sb.max(raw_line_start_abs).min(raw_line_end_abs);
if start_byte >= end_byte {
return None;
}
let start_col = raw_text[..start_byte - raw_line_start_abs].chars().count();
let end_col = raw_text[..end_byte - raw_line_start_abs].chars().count();
Some((start_col, end_col))
});
let styled = make_raw_line_over(raw_text, sel_cols, self.theme, reveal_base);
let cursor_override = (cursor_visible && raw_idx == cursor_raw_line)
.then_some((cursor_col, cursor_indicator_style));
let rows = render_line_with_cursor_from_visual(
&styled,
area,
buf,
(vis_y + used) as u16,
wrap,
cursor_override,
sub_skip,
) as usize;
used += rows;
}
rows_used = used;
} else if reveal_raw && is_big_h1_block && in_cursor_block {
// `# Title` on the first sub-line, the other big-text rows blanked, the last
// sub-line keeps the rendered rule.
let sub = virtual_idx - cursor_block_lines.start;
let last_sub = cursor_block_own.saturating_sub(1);
if sub == last_sub {
if let Some(line) = editor.parsed.lines.get(virtual_idx) {
rows_used =
render_line_from_visual(line, area, buf, vis_y as u16, wrap, skip_rows)
as usize;
} else {
rows_used = 1;
}
} else {
let raw_text = if sub == 0 {
raw_lines.first().copied().unwrap_or("")
} else {
""
};
let cursor_on_this = sub == 0 && cursor_raw_line == 0;
let styled = make_raw_line_with_selection(raw_text, None, self.theme);
let cursor_override = (cursor_on_this && cursor_visible)
.then_some((cursor_col, cursor_indicator_style));
rows_used = render_line_with_cursor_from_visual(
&styled,
area,
buf,
vis_y as u16,
wrap,
cursor_override,
skip_rows,
) as usize;
}
} else if reveal_raw && is_setext && in_cursor_block {
// Every rendered row of the block reveals its matching raw line.
let sub = virtual_idx - cursor_block_lines.start;
let raw_text = raw_lines.get(sub).copied().unwrap_or("");
let cursor_on_this = cursor_raw_line == sub;
let sel_cols = selection_bytes.and_then(|(sa, sb)| {
let block_start = block_range_for_cursor.as_ref()?.start;
let raw_line_start_in_block = raw_line_byte_start(&raw_block_source, sub);
let raw_line_start_abs = block_start + raw_line_start_in_block;
let raw_line_end_abs = raw_line_start_abs + raw_text.len();
let start_byte = sa.max(raw_line_start_abs).min(raw_line_end_abs);
let end_byte = sb.max(raw_line_start_abs).min(raw_line_end_abs);
if start_byte >= end_byte {
return None;
}
let start_col = raw_text[..start_byte - raw_line_start_abs].chars().count();
let end_col = raw_text[..end_byte - raw_line_start_abs].chars().count();
Some((start_col, end_col))
});
let styled = make_raw_line_with_selection(raw_text, sel_cols, self.theme);
let cursor_override = (cursor_on_this && cursor_visible)
.then_some((cursor_col, cursor_indicator_style));
rows_used = render_line_with_cursor_from_visual(
&styled,
area,
buf,
vis_y as u16,
wrap,
cursor_override,
skip_rows,
) as usize;
} else if reveal_raw && is_mermaid_block && in_cursor_block {
// Reveal as a regular fenced code block: row 0 shows the language label (or
// the raw fence when the cursor is on it), body rows raw source on the code
// background, the closing fence a padded placeholder (or the raw fence with
// cursor), and rows past the source padded so the reservation reads as one
// block.
let sub = virtual_idx - cursor_block_lines.start;
let raw_text = raw_lines.get(sub).copied().unwrap_or("");
let cursor_on_this = cursor_raw_line == sub;
let last_raw_idx = raw_lines.len().saturating_sub(1);
let is_opening_fence_row = sub == 0 && raw_lines.len() >= 2;
let is_closing_fence_row =
sub == last_raw_idx && raw_lines.len() >= 2 && raw_text.trim() == "```";
let in_source = sub < raw_lines.len();
let width = area.width as usize;
let sel_cols = selection_bytes.and_then(|(sa, sb)| {
let block_start = block_range_for_cursor.as_ref()?.start;
let raw_line_start_in_block = raw_line_byte_start(&raw_block_source, sub);
let raw_line_start_abs = block_start + raw_line_start_in_block;
let raw_line_end_abs = raw_line_start_abs + raw_text.len();
let start_byte = sa.max(raw_line_start_abs).min(raw_line_end_abs);
let end_byte = sb.max(raw_line_start_abs).min(raw_line_end_abs);
if start_byte >= end_byte {
return None;
}
let start_col = raw_text[..start_byte - raw_line_start_abs].chars().count();
let end_col = raw_text[..end_byte - raw_line_start_abs].chars().count();
Some((start_col, end_col))
});
let styled = if cursor_on_this && (is_opening_fence_row || is_closing_fence_row) {
make_raw_line_with_selection(raw_text, sel_cols, self.theme)
} else if is_opening_fence_row {
// Falls back to raw source for a non-`mermaid` language.
let lang = raw_text.trim_start_matches(['`', '~']);
let lang = if lang.is_empty() { "mermaid" } else { lang };
ratatui::text::Line::styled(format!(" {} ", lang), self.theme.code_block_lang)
} else if is_closing_fence_row {
ratatui::text::Line::styled(
"\u{00A0}".repeat(width.max(1)),
self.theme.code_block_text,
)
} else if in_source {
make_code_styled_body_line(raw_text, sel_cols, self.theme)
} else {
ratatui::text::Line::styled(
"\u{00A0}".repeat(width.max(1)),
self.theme.code_block_text,
)
};
// The cursor is painted onto the resolved cell so the wrap comes from the
// bare source text.
let cursor_override = (cursor_on_this && cursor_visible)
.then_some((cursor_col, cursor_indicator_style));
rows_used = render_line_with_cursor_from_visual(
&styled,
area,
buf,
vis_y as u16,
wrap,
cursor_override,
skip_rows,
) as usize;
} else if reveal_raw && is_latex_block && in_cursor_block {
// `$$...$$` math blocks reveal as a code block, styled like
// the mermaid fence: the opening `$$` becomes a ` math `
// language header (`code_block_lang`), the body rows carry
// the code surface (`code_block_text`), and the closing `$$`
// is a padded blank row on that same surface. The opening /
// closing rows reveal their literal `$$` only when the
// cursor lands on them (like a fence's edges).
//
// With the math preview on, the reveal reserves a top band
// for the rendered formula and paints the source *below* it
// (so the image keeps the block's top edge and doesn't
// jump): band rows paint empty — `image_view` overlays the
// formula there — and the source rows shift down by `band`
// (`latex_source_offset`). With the preview off, `band` is
// 0 and the source paints from row 0. Either way the
// formula's URL hashes its source, so moving the cursor out
// collapses the block back to a freshly rendered image.
let band = editor.parsed.latex_source_offset(cursor_block_idx);
let sub = virtual_idx - cursor_block_lines.start;
// Source line under this rendered row, or `None` for a
// band row (which paints empty behind the formula overlay).
let src_idx = sub.checked_sub(band).filter(|&s| s < raw_lines.len());
let raw_text = src_idx
.and_then(|s| raw_lines.get(s))
.copied()
.unwrap_or("");
let cursor_on_this = src_idx == Some(cursor_raw_line);
// Delimiter rows only when the block has separate opening /
// closing lines (a one-line `$$x$$` is neither). The
// closing `$$` is matched by its text, not by `len - 1`: a
// math paragraph's byte range can absorb the blank line that
// follows it, so `raw_lines` may carry a trailing empty
// entry past the real closing delimiter — indexing the last
// entry would miss it and leave the closing `$$` styled as a
// body row.
let is_opening = src_idx == Some(0) && raw_lines.len() >= 2;
let is_closing = !is_opening
&& src_idx.is_some()
&& raw_lines.len() >= 2
&& raw_text.trim() == "$$";
let width = area.width as usize;
let sel_cols = src_idx.zip(selection_bytes).and_then(|(s, (sa, sb))| {
let block_start = block_range_for_cursor.as_ref()?.start;
let raw_line_start_in_block = raw_line_byte_start(&raw_block_source, s);
let raw_line_start_abs = block_start + raw_line_start_in_block;
let raw_line_end_abs = raw_line_start_abs + raw_text.len();
let start_byte = sa.max(raw_line_start_abs).min(raw_line_end_abs);
let end_byte = sb.max(raw_line_start_abs).min(raw_line_end_abs);
if start_byte >= end_byte {
return None;
}
let start_col = raw_text[..start_byte - raw_line_start_abs].chars().count();
let end_col = raw_text[..end_byte - raw_line_start_abs].chars().count();
Some((start_col, end_col))
});
let styled = if src_idx.is_none() {
// Band row: transparent so the formula image shows.
make_raw_line_with_selection("", None, self.theme)
} else if cursor_on_this && (is_opening || is_closing) {
// Cursor on a delimiter row: reveal the literal `$$`.
make_raw_line_with_selection(raw_text, sel_cols, self.theme)
} else if is_opening {
// No cursor: ` math ` header, code-block language surface.
ratatui::text::Line::styled(" math ", self.theme.code_block_lang)
} else if is_closing {
// No cursor: padded blank row on the code surface.
ratatui::text::Line::styled(
"\u{00A0}".repeat(width.max(1)),
self.theme.code_block_text,
)
} else {
// Body row: code surface, cursor / selection per char.
make_code_styled_body_line(raw_text, sel_cols, self.theme)
};
let cursor_override = (cursor_on_this && cursor_visible)
.then_some((cursor_col, cursor_indicator_style));
rows_used = render_line_with_cursor_from_visual(
&styled,
area,
buf,
vis_y as u16,
wrap,
cursor_override,
skip_rows,
) as usize;
} else if let (true, Some(sub_idx)) = (reveal_raw, wrapped_sub_idx_opt) {
// Paint the rendered row first (neighboring cells and borders stay), then
// overlay this sub's raw chunk into the active cell.
let w = wrapped_cell
.as_ref()
.expect("wrapped_sub_idx implies wrapped_cell");
let overlay = &w.subs[sub_idx];
if let Some(line) = editor.parsed.lines.get(virtual_idx) {
rows_used =
render_line_from_visual(line, area, buf, vis_y as u16, wrap, skip_rows)
as usize;
let sel_in_cell = selection_bytes.and_then(|(sa, sb)| {
let block_start = block_range_for_cursor.as_ref()?.start;
// Every chunk is a slice of the single raw row `cursor_raw_line`.
let raw_line_start_in_block =
raw_line_byte_start(&raw_block_source, cursor_raw_line);
let cell_byte_start =
block_start + raw_line_start_in_block + overlay.raw_cell_byte_start;
let cell_byte_end = cell_byte_start + overlay.raw_text.len();
let lo = sa.max(cell_byte_start).min(cell_byte_end);
let hi = sb.max(cell_byte_start).min(cell_byte_end);
if lo >= hi {
return None;
}
let start_col = overlay.raw_text[..lo - cell_byte_start].chars().count();
let end_col = overlay.raw_text[..hi - cell_byte_start].chars().count();
Some((start_col, end_col))
});
overlay_raw_cell(
buf,
area,
vis_y as u16,
overlay,
sel_in_cell,
self.theme,
cursor_visible.then_some(self.cursor_style),
);
} else {
rows_used = 1;
}
} else if reveal_raw && virtual_idx == cursor_rendered_line && code_block_allows_reveal
{
let raw_text = raw_lines.get(cursor_raw_line).copied().unwrap_or("");
// Table rows prefer a cell-scoped reveal, keeping borders and neighboring
// cells rendered: `compute_cell_overlay` when the raw text fits, else
// `compute_cell_chunk_overlay`, which scrolls the cell horizontally.
let line_opt = editor.parsed.lines.get(virtual_idx);
let cell_overlay = if is_table {
line_opt.and_then(|line| compute_cell_overlay(raw_text, line, cursor_col))
} else {
None
};
let chunk_overlay = if is_table && cell_overlay.is_none() {
line_opt.and_then(|line| compute_cell_chunk_overlay(raw_text, line, cursor_col))
} else {
None
};
if let Some(overlay) = cell_overlay.or(chunk_overlay) {
let line = &editor.parsed.lines[virtual_idx];
rows_used =
render_line_from_visual(line, area, buf, vis_y as u16, wrap, skip_rows)
as usize;
let sel_in_cell = selection_bytes.and_then(|(sa, sb)| {
let block_start = block_range_for_cursor.as_ref()?.start;
let raw_line_start_in_block =
raw_line_byte_start(&raw_block_source, cursor_raw_line);
let cell_byte_start =
block_start + raw_line_start_in_block + overlay.raw_cell_byte_start;
let cell_byte_end = cell_byte_start + overlay.raw_text.len();
let lo = sa.max(cell_byte_start).min(cell_byte_end);
let hi = sb.max(cell_byte_start).min(cell_byte_end);
if lo >= hi {
return None;
}
let start_col = overlay.raw_text[..lo - cell_byte_start].chars().count();
let end_col = overlay.raw_text[..hi - cell_byte_start].chars().count();
Some((start_col, end_col))
});
overlay_raw_cell(
buf,
area,
vis_y as u16,
&overlay,
sel_in_cell,
self.theme,
cursor_visible.then_some(self.cursor_style),
);
} else {
// Non-table block, or a pipe-mismatched table line (mid-edit alignment row).
let sel_cols = selection_bytes.and_then(|(sa, sb)| {
let block_start = block_range_for_cursor.as_ref()?.start;
let raw_line_start_in_block =
raw_line_byte_start(&raw_block_source, cursor_raw_line);
let raw_line_start_abs = block_start + raw_line_start_in_block;
let raw_line_end_abs = raw_line_start_abs + raw_text.len();
let start_byte = sa.max(raw_line_start_abs).min(raw_line_end_abs);
let end_byte = sb.max(raw_line_start_abs).min(raw_line_end_abs);
if start_byte >= end_byte {
return None;
}
let start_col = raw_text[..start_byte - raw_line_start_abs].chars().count();
let end_col = raw_text[..end_byte - raw_line_start_abs].chars().count();
Some((start_col, end_col))
});
let styled = make_raw_line_over(raw_text, sel_cols, self.theme, reveal_base);
let cursor_override =
cursor_visible.then_some((cursor_col, cursor_indicator_style));
rows_used = render_line_with_cursor_from_visual(
&styled,
area,
buf,
vis_y as u16,
wrap,
cursor_override,
skip_rows,
) as usize;
}
} else if virtual_idx == cursor_rendered_line
&& (!reveal_raw || !code_block_allows_reveal)
{
// Rendered line plus a cursor indicator: the jitter-delay window before
// `reveal_raw` (drawing it now avoids a column jump when the reveal fires),
// or a code-block body / closing-fence line that never de-renders.
if let Some(line) = editor.parsed.lines.get(virtual_idx) {
let raw_text = raw_lines.get(cursor_raw_line).copied().unwrap_or("");
// Inline links / code spans shift the rendered column; the inverse of the
// click handler's map keeps the indicator where the click landed. `None`
// for non-paragraph lines.
let inline_col = block_range_for_cursor.as_ref().and_then(|br| {
let actual_rendered: usize =
line.spans.iter().map(|s| s.content.chars().count()).sum();
let buffer_line_idx = editor
.buffer
.block_line_to_buffer_line(br.start, cursor_raw_line);
editor
.inline_map_for(buffer_line_idx, raw_text)
.raw_to_rendered_checked(cursor_col, actual_rendered)
});
let visual_col = if let Some(w) = &wrapped_cell {
w.visual_col
} else if is_table {
// Padded cells shift the column; land on the same visual col the cell
// overlay will use on reveal.
table_raw_col_to_rendered_col(raw_text, line, cursor_col)
.unwrap_or(cursor_col)
} else if let Some(crate::markdown::Block::CodeBlock { fenced, .. }) =
cursor_block_ast
{
// Body rows never de-render, so this is the steady state: the renderer
// paints raw text behind one pad cell (minus the stripped indent for an
// indented block) — without the shift the indicator sits one cell LEFT
// on every code line (issue #28). Fence rows reveal within
// `RAW_REVEAL_DELAY`, so stay 1:1. This arm precedes the list arm on
// purpose: a code line reading `- foo` must not be claimed by the
// list-marker sniff.
if crate::markdown::code_layout::is_code_fence_row(
*fenced,
cursor_raw_line,
&raw_lines,
) {
cursor_col
} else {
crate::markdown::code_layout::code_raw_col_to_rendered_col(
raw_text, *fenced, cursor_col,
)
}
} else if matches!(
cursor_block_ast,
Some(crate::markdown::Block::MetadataBlock { .. })
) {
// Verbatim, so raw col == rendered col. Precedes the list sniff: a YAML
// sequence entry (` - tag`) reads as a list marker.
cursor_col
} else if let Some(col) =
list_raw_col_to_rendered_col(raw_text, line, cursor_col)
{
col
} else if let Some(col) = inline_col {
col
} else {
cursor_col
};
let (rows, cursor_cell) = render_line_reporting_cursor(
line,
area,
buf,
vis_y as u16,
wrap,
if cursor_visible {
Some((visual_col, cursor_indicator_style))
} else {
None
},
skip_rows,
);
rows_used = rows as usize;
view_state.cursor_screen = cursor_cell;
} else {
rows_used = 1;
}
} else {
if let Some(line) = editor.parsed.lines.get(virtual_idx) {
rows_used =
render_line_from_visual(line, area, buf, vis_y as u16, wrap, skip_rows)
as usize;
} else {
break;
}
}
// Lines that painted their own selection (the revealed line, setext / mermaid /
// wrapped-cell subs) are skipped.
if let Some((sa, sb)) = selection_bytes {
let setext_revealed = reveal_raw && is_setext && in_cursor_block;
let diagram_revealed = reveal_raw && is_diagram_block && in_cursor_block;
let wrapped_revealed = reveal_raw && wrapped_sub_idx_opt.is_some();
let reflow_revealed = reveal_raw && in_cursor_block && effective.has_reveal();
// Separate suppression cases; clippy's collapse hides which is which.
#[allow(clippy::nonminimal_bool)]
if !(reveal_raw && virtual_idx == cursor_rendered_line && code_block_allows_reveal)
&& !setext_revealed
&& !diagram_revealed
&& !wrapped_revealed
&& !reflow_revealed
{
paint_byte_range_overlay(
editor,
buf,
area,
vis_y as u16,
rows_used as u16,
skip_rows,
virtual_idx,
sa,
sb,
self.theme.selection,
);
}
}
if rows_used == 0 {
break;
}
vis_y += rows_used;
virtual_idx += 1;
first_sub_row = 0;
}
// Snapshots are captured for every visible table (mouse hit-testing on adjacent
// tables), but handles paint only on the cursor's table.
table_view::build_snapshots_cached(
self.state,
area,
self.show_table_buttons,
&mut view_state.table_snapshots,
&mut view_state.table_snapshots_key,
);
let cursor_table_start = if self.show_table_buttons {
cursor_table_block_start(self.state, &view_state.table_snapshots)
} else {
None
};
table_view::paint_handles(
&view_state.table_snapshots,
area,
buf,
self.theme,
cursor_table_start,
);
if let Some(indicator) = self.drop_indicator {
table_view::paint_drop_indicator(
&view_state.table_snapshots,
&indicator,
area,
buf,
self.theme,
);
}
// Image painting itself happens in `EditorView::render`, which has the cache.
image_view::build_snapshots_cached(
self.state,
area,
self.state.scroll,
&mut view_state.image_snapshots,
&mut view_state.image_snapshots_key,
);
link_view::build_snapshots_cached(
self.state,
area,
self.state.scroll,
&mut view_state.link_snapshots,
&mut view_state.link_snapshots_key,
);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/// Byte offset of the table block the cursor is inside, or `None`; gates the drag-handle
/// painter. Walks the snapshots rather than reparsing.
fn cursor_table_block_start(
state: &EditorState,
snapshots: &[crate::ui::table_view::TableLayoutSnapshot],
) -> Option<usize> {
let cursor_byte = state.buffer.rope().char_to_byte(state.cursor.offset);
snapshots
.iter()
.find(|s| cursor_byte >= s.table_byte_start && cursor_byte < s.table_byte_end)
.map(|s| s.table_byte_start)
}