edamame 0.1.1

A fast TUI Markdown editor and viewer
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
use ratatui::text::Line;

use crate::document::CellBand;
use crate::editor::table_edit;
use crate::editor::{EditorState, Mode};
use crate::markdown::list_layout::{
    list_rendered_col_to_raw_col_marker, raw_list_marker_char_width,
    rendered_list_marker_char_width,
};
use crate::markdown::table_layout;
use crate::ui::line_render;
use crate::ui::table_view::HEADER_ROWS;

/// Look up the rendered `Line` and the visual column within it that
/// correspond to document-area row `row`.  Accounts for scroll and wrap; the
/// returned visual col is `row - y_of_first_row_of_line`.
pub(super) fn rendered_line_at_row(
    state: &EditorState,
    row: usize,
) -> Option<(Line<'static>, usize)> {
    let lines = &state.parsed.lines;
    if lines.is_empty() {
        return None;
    }
    let (mut line_idx, mut first_sub_row) =
        state.rendered_line_at_visual_row(state.scroll.saturating_add(row), state.viewport_width);
    let mut y = 0usize;
    while let Some(line) = lines.get(line_idx) {
        let rows_used = line_render::visual_rows_for_line(line, state.viewport_width).max(1);
        let visible_rows = rows_used.saturating_sub(first_sub_row).max(1);
        if y < visible_rows {
            return Some((line.clone(), first_sub_row));
        }
        y += visible_rows;
        line_idx += 1;
        first_sub_row = 0;
    }
    None
}

/// Translate a click at `(col, row)` in the document area to a buffer char
/// offset.  Accounts for current scroll and visual-row wrap.
///
/// Returns `None` only when the buffer is empty — all clicks are clamped to
/// the nearest valid position (end of line / end of buffer) so the caller
/// never has to handle "click landed in whitespace past the document".
pub(super) fn click_to_char_offset(
    state: &EditorState,
    col: usize,
    row: usize,
    viewport_width: usize,
) -> Option<usize> {
    match state.mode {
        Mode::Raw => Some(raw_click_to_offset(state, col, row, viewport_width)),
        Mode::Preview | Mode::Rendered => {
            Some(rendered_click_to_offset(state, col, row, viewport_width))
        }
        // Diff mode handles its own clicks via `DiffView` — `mouse_ops`
        // doesn't reach this helper while `Mode::Diff` is active (a
        // future diff-Edit mode would wire cursor placement
        // separately).  Return None so any inadvertent call no-ops
        // cleanly.
        Mode::Diff => None,
    }
}

/// Raw-mode click: walk buffer lines from `state.scroll`, accumulating each
/// line's wrapped visual-row count, and translate the click into a char
/// offset on the appropriate visual sub-row.  Cell-aware so wide chars
/// align and the cursor lands where the user sees it.
fn raw_click_to_offset(
    state: &EditorState,
    col: usize,
    row: usize,
    viewport_width: usize,
) -> usize {
    let line_count = state.buffer.line_count();
    let width = viewport_width.max(1);
    let (mut target_line, mut first_sub_row) = state.raw_line_at_visual_row(state.scroll, width);
    let mut y = 0usize;
    while target_line < line_count {
        let text = state
            .buffer
            .line(target_line)
            .map(|s| s.trim_end_matches('\n').to_owned())
            .unwrap_or_default();
        let rows = line_render::visual_rows_of_str(&text, width);
        let used = rows.len().max(1).saturating_sub(first_sub_row).max(1);
        if row < y + used {
            let sub_row = first_sub_row + row - y;
            let line_start = state.buffer.line_to_char(target_line);
            let row_tuple = rows.get(sub_row).copied().unwrap_or((0, 0, 0));
            let raw_col = char_in_row_at_cell(&text, row_tuple, col, 0, sub_row + 1 == rows.len());
            return line_start + raw_col;
        }
        y += used;
        target_line += 1;
        first_sub_row = 0;
    }
    state.buffer.len_chars()
}

/// Cell-aware "click landed in this visual row" → char column on the
/// logical line.  Mirrors `state::raw_col_for_visual_cells` but lives here
/// because the public copy belongs to mouse hit-testing.  See that
/// function for the wide-char snap-past rule and forbidden indent zone.
fn char_in_row_at_cell(
    text: &str,
    row: (usize, usize, usize),
    target_cell: usize,
    indent: usize,
    is_last_row: bool,
) -> usize {
    let (start, end, next_start) = row;
    let max_char_in_row = if is_last_row {
        end
    } else {
        next_start.saturating_sub(1).max(start)
    };
    let row_chars = text.chars().skip(start).take(end - start);
    let in_row = line_render::char_idx_at_cell_col(row_chars, target_cell, indent);
    (start + in_row).min(max_char_in_row)
}

/// Rendered/Preview click: walk through rendered lines from `state.scroll`,
/// accumulating each line's visual-row count, and find which rendered line
/// and sub-row the click landed on.  Then map that rendered sub-line back to
/// a source byte using the source map.
fn rendered_click_to_offset(
    state: &EditorState,
    col: usize,
    row: usize,
    viewport_width: usize,
) -> usize {
    match walk_rendered_rows(state, row, viewport_width) {
        Some((idx, sub_row)) => {
            rendered_sub_line_to_offset(state, idx, sub_row, col, viewport_width)
        }
        None => state.buffer.len_chars(),
    }
}

/// Preview-mode click translator.  Returns the `(rendered_line_idx,
/// char_col)` pair used to seed Preview's `VisualSelection`, applying the
/// same scroll-aware visual-row walk as `rendered_click_to_offset` so
/// wrapped lines (long list items, paragraphs) map clicks to the correct
/// line, and the same per-sub-row wrap layout as
/// `paint_preview_selection` so `char_col` is the cumulative position
/// within the flat rendered line — required for drag-selection across
/// wrapped sub-rows to highlight the correct range.
pub(super) fn rendered_click_to_line_col(
    state: &EditorState,
    col: usize,
    row: usize,
    viewport_width: usize,
) -> Option<(usize, usize)> {
    let (idx, sub_row) = walk_rendered_rows(state, row, viewport_width)?;
    let line = state.parsed.lines.get(idx)?;
    let chars: Vec<(char, ratatui::style::Style)> = line
        .spans
        .iter()
        .flat_map(|span| {
            let style = span.style;
            span.content.chars().map(move |c| (c, style))
        })
        .collect();
    if chars.is_empty() {
        return Some((idx, 0));
    }
    let indent = line_render::compute_hanging_indent(line);
    let rows = line_render::visual_rows_of_chars(&chars, viewport_width.max(1), indent);
    let sub = sub_row.min(rows.len().saturating_sub(1));
    let (row_start, row_end, _) = rows
        .get(sub)
        .copied()
        .unwrap_or((0, chars.len(), chars.len()));
    // Continuation rows render with `indent` blank cells of left-padding;
    // subtract them so `col=indent` maps to the first content char of the
    // sub-row, matching `paint_preview_selection`'s x_off calculation.
    let row_indent = if sub == 0 { 0 } else { indent };
    let local_col = col.saturating_sub(row_indent);
    let char_col = (row_start + local_col).min(row_end);
    Some((idx, char_col))
}

/// Walk rendered lines from `state.scroll`, accumulating per-line wrapped
/// visual-row counts (with reveal corrections), and find which
/// `(rendered_line_idx, sub_row_within_line)` the document-relative `row`
/// falls on.  Shared by `rendered_click_to_offset` and
/// `rendered_click_to_line_col` so the two cannot drift.
fn walk_rendered_rows(
    state: &EditorState,
    row: usize,
    viewport_width: usize,
) -> Option<(usize, usize)> {
    let lines = &state.parsed.lines;
    if lines.is_empty() {
        return None;
    }
    let (mut idx, mut first_sub_row) =
        state.rendered_line_at_visual_row(state.scroll, viewport_width);
    let mut y = 0usize;
    while idx < lines.len() {
        let rows_used = revealed_raw_row_count(state, idx, viewport_width)
            .unwrap_or_else(|| state.parsed.visual_rows_for_line_at(idx, viewport_width))
            .max(1);
        let used = rows_used.saturating_sub(first_sub_row).max(1);
        if row < y + used {
            return Some((idx, first_sub_row + row - y));
        }
        y += used;
        idx += 1;
        first_sub_row = 0;
    }
    None
}

/// Map `(rendered_line_idx, sub_row_within_line, col)` to a buffer char
/// offset.
///
/// Strategy:
/// 1. Look up the block that produced the rendered line.
/// 2. Compute which raw source line within the block corresponds to this
///    rendered line (skipping the table-top border row when relevant).
/// 3. Within that raw source line, advance `col` chars and convert to a char
///    offset on the rope.
///
/// For blocks with inline formatting (`**bold**` rendering as `bold`), the
/// rendered column may diverge slightly from the raw column.  Given the
/// reveal semantics turn the cursor's line into raw text within
/// `RAW_REVEAL_DELAY`, the click lands at an approximate position that the
/// user can refine with a second click if needed.
pub fn rendered_sub_line_to_offset(
    state: &EditorState,
    rendered_line_idx: usize,
    sub_row_within_line: usize,
    col: usize,
    viewport_width: usize,
) -> usize {
    let buffer_len = state.buffer.len_chars();
    let source = state.buffer.contents();
    let Some(block) = locate_block(state, rendered_line_idx) else {
        return buffer_len;
    };
    let block_text = source
        .get(block.range.start..block.range.end.min(source.len()))
        .unwrap_or("");

    let is_table = table_edit::is_table_block(block_text);
    // For tables, `table_sub` is the wrap-chunk index of the clicked
    // rendered sub-line within its logical row (0 elsewhere).
    let mut table_sub = 0usize;
    let raw_line_idx = if is_table {
        let (raw_idx, sub) = table_raw_line_idx(state, &block, block_text);
        table_sub = sub;
        raw_idx
    } else if state.parsed.is_image_block(block.idx) && !state.parsed.is_mermaid_block(block.idx) {
        // A real `![alt](url)` image reserves many rendered rows for its
        // single source line, but only the placeholder row carries text.
        // Mapping a reserved row through `sub_idx` would index a phantom
        // empty raw line (the block range can absorb a trailing blank) and
        // poison the inline-map cache for an unrelated buffer line, so pin
        // every reserved row to raw line 0.  Mermaid blocks are excluded:
        // their reveal overlay paints the raw source 1:1 onto the reserved
        // rows, so `sub_idx` IS the correct source line there — and the
        // mermaid branch below consumes `line_text` derived from it.
        0
    } else {
        block.sub_idx
    };

    // Blank-line "virtual blocks" have no content.  The renderer produces
    // a single empty line for them; place the cursor at block start.
    if block_text.is_empty() {
        return state.buffer.rope().byte_to_char(block.range.start);
    }

    let (line_byte_start, line_byte_end) = raw_line_byte_range(block_text, raw_line_idx);
    let line_text = &block_text[line_byte_start..line_byte_end];
    let rendered_line = &state.parsed.lines[rendered_line_idx];

    // Mermaid diagram blocks render as an image placeholder + blank
    // reserved rows, but `RenderedView` overlays the raw mermaid source
    // 1:1 on those rows when the cursor is inside the block.  The
    // rendered `Line`s therefore don't reflect what the user clicks
    // on — walk the raw line's own wrap layout and map `col` directly
    // against it.
    //
    // The same shortcut applies to the cursor's own line in any block
    // when the hybrid-edit reveal is active: `RenderedView` paints raw
    // source over that one rendered row, so a click on dropped markers
    // like the `](url)` portion of a link must map against the raw
    // chars the user sees, not the rendered span set.
    //
    // Tables are excluded: even when the cursor's row is "revealed",
    // `RenderedView` keeps the table chrome (pipes and padding) painted
    // for layout, so clicks must continue to use the pipe-aware mapping
    // in the table branch below.
    //
    // So are code-block body rows.  `cursor_block_revealed()` answers a
    // block-level, time-based question and knows nothing about which
    // *lines* the view actually de-renders — a code body row never does
    // (only the two fence rows do), so without `line_allows_raw_reveal`
    // this shortcut maps the click 1:1 against raw text while the screen
    // is showing the padded rendered row, landing one char past the glyph
    // the user aimed at.  The code branch below handles those rows.
    let block_kind = state.parsed.real_block_for_byte(block.range.start);
    let content_lines = crate::ui::rendered_view::raw_source_lines(block_text);
    let line_reveals = crate::markdown::code_layout::line_allows_raw_reveal(
        block_kind,
        raw_line_idx,
        &content_lines,
    );
    let revealed_cursor_line = !is_table
        && line_reveals
        && state.cursor_block_revealed()
        && rendered_line_idx == crate::editor::state::cursor_rendered_line_idx(state);
    if state.parsed.is_mermaid_block(block.idx) || revealed_cursor_line {
        let (rows, indent) = revealed_raw_rows(line_text, viewport_width);
        let sub = sub_row_within_line.min(rows.len().saturating_sub(1));
        let (start, end, next_start) = rows.get(sub).copied().unwrap_or((0, 0, 0));
        let is_last_row = sub + 1 == rows.len();
        let max_in_row = if is_last_row {
            end
        } else {
            next_start.saturating_sub(1).max(start)
        };
        // Continuation rows are painted `indent` cells to the right; the
        // first row is flush.  Mirror `render_line`'s x-offset so a click
        // maps to the char actually under the pointer.
        let row_indent = if sub == 0 { 0 } else { indent };
        let row_chars = line_text.chars().skip(start).take(end - start);
        let in_row = line_render::char_idx_at_cell_col(row_chars, col, row_indent);
        let raw_col = (start + in_row).min(max_in_row);
        return raw_col_to_buffer_char(state, &block, line_byte_start, line_text, raw_col);
    }

    let raw_col = if is_table && rendered_line.spans.iter().any(|s| s.content.contains('│')) {
        // Tables: rendered cells are padded to layout width, so a simple col
        // → char mapping lands clicks on the wrong cell whenever the
        // rendered cell is wider than its raw counterpart.  Map through the
        // pipe positions instead so the click stays inside the cell the
        // user clicked on.
        let row_width = line_row_width(rendered_line, sub_row_within_line);
        let clamped_col = col.min(row_width);
        table_click_to_raw_col(line_text, rendered_line, clamped_col, table_sub)
            .unwrap_or(clamped_col)
    } else if let Some(crate::markdown::Block::CodeBlock { fenced, .. }) =
        block_kind.filter(|_| !line_reveals)
    {
        // A code body row shows the raw text behind one leading pad cell
        // (and, for an indented block, minus the indent pulldown-cmark
        // stripped), so the rendered column is not the raw column — the
        // generic mapping below would return it verbatim and land the
        // cursor one char late (issue #28).  Fence rows are excluded by
        // `line_reveals`: they de-render, so the shortcut above already
        // took them.
        let rendered_chars: Vec<(char, ratatui::style::Style)> = rendered_line
            .spans
            .iter()
            .flat_map(|span| span.content.chars().map(move |c| (c, span.style)))
            .collect();
        let rendered_idx = click_to_rendered_char_idx(
            rendered_line,
            &rendered_chars,
            col,
            sub_row_within_line,
            viewport_width,
        );
        let stripped = line_text.strip_suffix('\n').unwrap_or(line_text);
        crate::markdown::code_layout::code_rendered_col_to_raw_col(stripped, *fenced, rendered_idx)
    } else {
        let buffer_line_idx = state
            .buffer
            .block_line_to_buffer_line(block.range.start, raw_line_idx);
        let stripped = line_text.strip_suffix('\n').unwrap_or(line_text);
        let inline_map = state.inline_map_for(buffer_line_idx, stripped);
        // List blocks take a marker-aware mapping: the rendered marker can
        // be wider than the raw one (right-align-padded ordered numbers,
        // renderer-normalized nesting indent), which the generic prefix
        // inference below can't represent.  Resolved via the AST — a
        // paragraph line that merely looks like `2. ...` stays generic.
        let is_list = matches!(
            state
                .parsed
                .real_block_for_byte(block.range.start + line_byte_start),
            Some(crate::markdown::Block::List { .. })
        );
        non_table_click_to_raw_col(
            rendered_line,
            line_text,
            col,
            sub_row_within_line,
            viewport_width,
            &inline_map,
            is_list,
        )
    };

    raw_col_to_buffer_char(state, &block, line_byte_start, line_text, raw_col)
}

/// Resolved location of the block that produced a rendered line: byte
/// range in the source, the rendered-line range the block occupies, and
/// the click's offset within those rendered lines.
struct BlockLocation {
    idx: usize,
    range: std::ops::Range<usize>,
    rendered_span: std::ops::Range<usize>,
    sub_idx: usize,
}

/// Look up the source block that owns `rendered_line_idx`.  Returns `None`
/// when the rendered line is past the document's source map (off-by-one
/// during a pending edit, etc.).
fn locate_block(state: &EditorState, rendered_line_idx: usize) -> Option<BlockLocation> {
    let block_start_byte = state
        .parsed
        .source_map
        .original_byte_for_rendered_line(rendered_line_idx)?;
    let idx = state.parsed.source_map.block_for_byte(block_start_byte)?;
    let range = state
        .parsed
        .source_map
        .original_range_for_byte(block_start_byte)?;
    let rendered_span = state
        .parsed
        .source_map
        .rendered_lines_for_byte(block_start_byte);
    let sub_idx = rendered_line_idx.saturating_sub(rendered_span.start);
    Some(BlockLocation {
        idx,
        range,
        rendered_span,
        sub_idx,
    })
}

/// Translate a click on a table block's rendered sub-line to the raw
/// `info.rows[..]` row index plus the wrap-chunk index within that row
/// (which of the row's rendered sub-lines was clicked — 0 for the first).
/// Classifies the rendered line by its leading box-drawing glyph rather
/// than the alternating-line pattern, because data rows may span multiple
/// rendered lines after cell-wrap.
fn table_raw_line_idx(
    state: &EditorState,
    block: &BlockLocation,
    block_text: &str,
) -> (usize, usize) {
    use crate::ui::table_view::TableSubLineKind;
    let block_lines = state
        .parsed
        .lines
        .get(block.rendered_span.start..block.rendered_span.end.min(state.parsed.lines.len()))
        .unwrap_or(&[]);
    let kinds = crate::ui::table_view::classify_table_sub_lines(block_lines);
    match kinds.get(block.sub_idx) {
        Some(TableSubLineKind::TopBorder) => (0, 0),
        Some(TableSubLineKind::Header { sub }) => (0, *sub),
        Some(TableSubLineKind::ThickSeparator) => (HEADER_ROWS, 0),
        Some(TableSubLineKind::DataRow { row, sub }) => (row + HEADER_ROWS, *sub),
        Some(TableSubLineKind::ThinSeparator) => {
            // A separator click snaps to the data row immediately
            // preceding it.  Walk back through `kinds` to find it.
            let row = kinds[..block.sub_idx]
                .iter()
                .rev()
                .find_map(|k| {
                    if let TableSubLineKind::DataRow { row, .. } = k {
                        Some(*row)
                    } else {
                        None
                    }
                })
                .unwrap_or(0);
            (row + HEADER_ROWS, 0)
        }
        Some(TableSubLineKind::BottomBorder) | None => {
            // Bottom border or out-of-range — snap to the last data
            // row.  Total data rows = info.rows.len() - HEADER_ROWS
            // (header + alignment).  Tables always have at least one
            // data row for `is_table_block` to be true.
            let last_data = block_text.split('\n').count().saturating_sub(HEADER_ROWS);
            (last_data.max(HEADER_ROWS), 0)
        }
    }
}

/// When the reveal is active and `rendered_line_idx` lies inside the
/// cursor's block, returns the wrap count of the raw source line the
/// painter actually paints onto that rendered row.  Returns `None`
/// otherwise — callers fall back to the regular per-line cache.
///
/// Two cases need this correction:
/// - **Mermaid blocks**: `RenderedView` reserves placeholder rendered lines
///   that each report 1 visual row; during reveal the painter overlays raw
///   mermaid source lines that may themselves wrap.
/// - **Non-table cursor line**: `RenderedView` replaces the cursor's
///   rendered line with raw source text.  Inline formatting markers
///   (`**`, `_`, backticks) make the raw line longer than the rendered
///   form, so it can wrap to more visual rows at the same viewport width.
fn revealed_raw_row_count(
    state: &EditorState,
    rendered_line_idx: usize,
    viewport_width: usize,
) -> Option<usize> {
    if !state.cursor_block_revealed() {
        return None;
    }
    let cursor_block_idx = state.cursor_block_idx?;
    let block_lines = state
        .parsed
        .source_map
        .rendered_lines_for_block(cursor_block_idx);
    if !block_lines.contains(&rendered_line_idx) {
        return None;
    }

    let block_start_byte = state
        .parsed
        .source_map
        .original_byte_for_rendered_line(block_lines.start)?;
    let block_range = state
        .parsed
        .source_map
        .original_range_for_byte(block_start_byte)?;
    let source = state.buffer.contents();
    let block_text = source
        .get(block_range.start..block_range.end.min(source.len()))
        .unwrap_or("");

    if state.parsed.is_mermaid_block(cursor_block_idx) {
        let sub = rendered_line_idx - block_lines.start;
        let raw_line = block_text.split('\n').nth(sub).unwrap_or("");
        return Some(revealed_raw_rows(raw_line, viewport_width).0.len().max(1));
    }

    // Non-mermaid: only the cursor's own rendered line gets replaced with
    // raw text.  Tables keep their rendered chrome, so skip them.
    let is_table = table_edit::is_table_block(block_text);
    if is_table {
        return None;
    }
    let cursor_line = crate::editor::state::cursor_rendered_line_idx(state);
    if rendered_line_idx != cursor_line {
        return None;
    }

    // Find the raw source line within the block that corresponds to the
    // cursor's rendered line, matching the render loop's logic.
    let sub = rendered_line_idx - block_lines.start;
    let raw_line = block_text.split('\n').nth(sub).unwrap_or("");
    // A row the view doesn't actually de-render (a code block's body) is
    // still showing its *rendered* line, which the renderer padded to the
    // viewport width — so the raw line's wrap count is the wrong answer and
    // would mis-walk every row below it whenever the two differ.  Fall back
    // to the parsed line's own count.
    if !crate::markdown::code_layout::line_allows_raw_reveal(
        state.parsed.real_block_for_byte(block_range.start),
        sub,
        &crate::ui::rendered_view::raw_source_lines(block_text),
    ) {
        return None;
    }
    Some(revealed_raw_rows(raw_line, viewport_width).0.len().max(1))
}

/// Wrap layout of a raw source line exactly as the reveal painter lays it
/// out, plus the hanging indent it used.
///
/// `RenderedView` builds a plain `Line` from the raw text and hands it to
/// `render_line`, which derives a hanging indent from the line's own leading
/// marker — so a revealed `- item` wraps its continuation rows under the
/// item text, two cells in, not at column 0.  `visual_rows_of_str` assumes
/// indent 0, so using it here would disagree with the paint on both the row
/// count and every continuation row's start column: a click on a wrapped
/// revealed list item would land a couple of chars off, growing with each
/// wrap.  Callers must also shift `col` by the returned indent on any
/// sub-row past the first.
///
/// The returned indent is the *effective* one: when the marker is as wide as
/// the viewport (`indent + 1 >= width`) both `render_line` and
/// `visual_rows_of_chars` fall back to a flat indent-0 layout, so we must
/// report 0 too.  Returning the raw marker width there would shift every
/// continuation row's mapping into `char_idx_at_cell_col`'s forbidden-indent
/// zone, collapsing the whole row onto its first character.
fn revealed_raw_rows(raw_line: &str, viewport_width: usize) -> (Vec<(usize, usize, usize)>, usize) {
    let width = viewport_width.max(1);
    let indent = line_render::compute_hanging_indent_str(raw_line);
    let indent = if indent + 1 >= width { 0 } else { indent };
    let chars: Vec<(char, ratatui::style::Style)> = raw_line
        .chars()
        .map(|c| (c, ratatui::style::Style::default()))
        .collect();
    (
        line_render::visual_rows_of_chars(&chars, width, indent),
        indent,
    )
}

/// Byte range `[start..end)` within `block_text` of raw line `raw_line_idx`,
/// clamped to the block's last line if `raw_line_idx` exceeds the block.
fn raw_line_byte_range(block_text: &str, raw_line_idx: usize) -> (usize, usize) {
    let mut byte_cursor = 0usize;
    for (i, line) in block_text.split('\n').enumerate() {
        if i == raw_line_idx {
            return (byte_cursor, byte_cursor + line.len());
        }
        byte_cursor += line.len() + 1;
        if byte_cursor >= block_text.len() {
            // Clamp when raw_line_idx points past the block's last line.
            return (byte_cursor.saturating_sub(line.len() + 1), block_text.len());
        }
    }
    (0, block_text.len())
}

/// Which char of the *rendered* line the click at cell `col` on wrap row
/// `sub_row_within_line` landed on.
///
/// The wrap layout (hanging indent, wide-char snap-past, the
/// forbidden-indent zone on continuation rows) is `line_render`'s, so
/// everything that maps a click onto a rendered line shares this walk
/// rather than re-deriving it: `non_table_click_to_raw_col`'s generic
/// prefix inference and the code-block branch, whose raw mapping differs
/// but whose geometry is identical.
fn click_to_rendered_char_idx(
    rendered_line: &Line<'_>,
    rendered_chars: &[(char, ratatui::style::Style)],
    col: usize,
    sub_row_within_line: usize,
    viewport_width: usize,
) -> usize {
    let indent = line_render::compute_hanging_indent(rendered_line);
    let viewport = viewport_width.max(1);
    let rows = line_render::visual_rows_of_chars(rendered_chars, viewport, indent);
    let sub = sub_row_within_line.min(rows.len().saturating_sub(1));
    let (start, end, next_start) = rows.get(sub).copied().unwrap_or((0, 0, 0));
    let row_indent = if sub == 0 { 0 } else { indent };
    let is_last_row = sub + 1 == rows.len();
    let max_in_row = if is_last_row {
        end
    } else {
        next_start.saturating_sub(1).max(start)
    };
    let row_chars = rendered_chars
        .iter()
        .skip(start)
        .take(end - start)
        .map(|(c, _)| *c);
    let in_row = line_render::char_idx_at_cell_col(row_chars, col, row_indent);
    (start + in_row).min(max_in_row)
}

/// Non-table click: walk the rendered line's wrap layout to find which
/// sub-row the click landed on, translate the click's cell column into a
/// char position using the cell-aware mapping (wide-char snap-past,
/// hanging-indent forbidden zone), then map that rendered char back to a
/// raw char column on `line_text`.
///
/// The renderer emits a leading prefix (`• ` / `1. ` / `[ ] ` / `▎ ` /
/// heading indent) on lists, tasks, blockquotes and headings; that prefix
/// has no counterpart in pulldown-cmark's `Text` events.  Compare the
/// rendered char count against the map's content count to recover the
/// prefix width, then route clicks on the prefix region into the raw
/// prefix area and clicks past it through the map — so a click on a
/// `**bold**` span inside a list item lands correctly even though the
/// raw `**` markers must be skipped.  Falls back to 1:1 when the prefix
/// width isn't trustworthy.
///
/// Code blocks never reach here: their padding would defeat the prefix
/// inference, so the caller routes them through
/// [`code_layout::code_rendered_col_to_raw_col`](crate::markdown::code_layout::code_rendered_col_to_raw_col)
/// instead.
fn non_table_click_to_raw_col(
    rendered_line: &Line<'_>,
    line_text: &str,
    col: usize,
    sub_row_within_line: usize,
    viewport_width: usize,
    inline_map: &crate::markdown::InlineColMap,
    is_list: bool,
) -> usize {
    let rendered_chars: Vec<(char, ratatui::style::Style)> = rendered_line
        .spans
        .iter()
        .flat_map(|span| span.content.chars().map(move |c| (c, span.style)))
        .collect();
    let rendered_idx = click_to_rendered_char_idx(
        rendered_line,
        &rendered_chars,
        col,
        sub_row_within_line,
        viewport_width,
    );

    let actual_rendered_count = rendered_chars.len();
    let map = inline_map.rendered_to_raw_vec();
    let map_content_count = inline_map.rendered_len();
    let raw_content_start = map.first().copied().unwrap_or(0);

    // List-item lines: mirror the forward marker map used by the overlay
    // painters (`markdown::list_layout`).  The rendered marker can be wider
    // than the raw one (` 1. ` vs `1. `, renderer-normalized nesting
    // indent), so the char-count prefix inference below would bail to a
    // 1:1 mapping that lands clicks 1–2 columns off.  A raw line inside a
    // list block without its own marker (continuation line) falls through
    // to the generic path.
    if is_list {
        if let (Some(rmw), Some(rmw_r)) = (
            raw_list_marker_char_width(line_text),
            rendered_list_marker_char_width(rendered_line),
        ) {
            if rendered_idx < rmw_r {
                // Click inside the rendered marker: right-aligned inverse so
                // the `[ ] ` checkbox cells map byte-exact.
                return list_rendered_col_to_raw_col_marker(rmw, rmw_r, rendered_idx);
            }
            let content_idx = rendered_idx - rmw_r;
            let content_rendered = actual_rendered_count.saturating_sub(rmw_r);
            if map_content_count == content_rendered {
                // Content region through the inline collapse map (the map's
                // raw columns are absolute, marker included).
                return map
                    .get(content_idx)
                    .copied()
                    .unwrap_or_else(|| line_text.chars().count());
            }
            // Inline-map count mismatch: marker shift only — exact for
            // unformatted content, approximate otherwise (the reveal lets
            // the user refine with a second click).
            return (rendered_idx + rmw).saturating_sub(rmw_r);
        }
    }

    if actual_rendered_count >= map_content_count {
        let prefix_len = actual_rendered_count - map_content_count;
        if prefix_len <= raw_content_start {
            return if rendered_idx < prefix_len {
                rendered_idx.min(raw_content_start)
            } else {
                let content_idx = rendered_idx - prefix_len;
                map.get(content_idx)
                    .copied()
                    .unwrap_or_else(|| line_text.chars().count())
            };
        }
    }
    rendered_idx
}

/// Convert `raw_col` (a char column on `line_text`) to a buffer-wide char
/// offset, clamped to the buffer length.
fn raw_col_to_buffer_char(
    state: &EditorState,
    block: &BlockLocation,
    line_byte_start: usize,
    line_text: &str,
    raw_col: usize,
) -> usize {
    let line_char_count = line_text.chars().count();
    let raw_col = raw_col.min(line_char_count);
    let byte_offset_in_line: usize = line_text.chars().take(raw_col).map(char::len_utf8).sum();
    let byte_in_block = line_byte_start + byte_offset_in_line.min(line_text.len());
    let block_text_len = block.range.end.saturating_sub(block.range.start);
    let absolute_byte = block.range.start + byte_in_block.min(block_text_len);
    let source_len = state.buffer.contents().len();
    state
        .buffer
        .rope()
        .byte_to_char(absolute_byte.min(source_len))
        .min(state.buffer.len_chars())
}

/// Cell-aware mapping from a rendered column to a raw column for table rows.
///
/// Locates the cell the click falls in by walking the rendered line's `│`
/// positions, then maps the click's position *within* the rendered cell to
/// the matching raw cell:
/// - clicks on actual content chars map 1:1 to the raw content char,
/// - clicks on leading padding land on the first raw content char,
/// - clicks on trailing padding land just past the last non-whitespace char
///   in the raw cell so the cursor never jumps into the next cell.
///
/// `sub` is the wrap-chunk index of the clicked rendered sub-line within
/// its logical table row: a wrapped cell renders one chunk per sub-line,
/// so the click's column maps into the chunk shown on that sub-line, not
/// the start of the cell.  The chunk layout is computed over the *raw*
/// cell text while the renderer wraps marker-stripped chars, so the
/// mapping is approximate for styled cells — consistent with the
/// documented "click-to-offset is approximate" policy.
///
/// Returns `None` when the line doesn't parse as a table row (alignment
/// separator, border) — caller falls back to the default char-by-char map.
fn table_click_to_raw_col(
    raw_line: &str,
    rendered_line: &Line<'_>,
    rendered_col: usize,
    sub: usize,
) -> Option<usize> {
    let raw_pipes = table_layout::raw_pipe_positions(raw_line);
    let rendered_pipes = table_layout::rendered_pipe_positions(rendered_line);
    if raw_pipes.len() < 2 || rendered_pipes.len() != raw_pipes.len() {
        return None;
    }
    let col_count = rendered_pipes.len() - 1;

    // Which cell does `rendered_col` fall in?  Cell `i` spans
    // (rendered_pipes[i] + 1) .. rendered_pipes[i + 1] (content area).
    let cell_idx = (0..col_count)
        .find(|&i| rendered_col < rendered_pipes[i + 1])
        .unwrap_or(col_count - 1);
    let rend_cell_start = rendered_pipes[cell_idx] + 1;
    let rend_cell_end = rendered_pipes[cell_idx + 1];
    let raw_cell_start = raw_pipes[cell_idx] + 1;
    let raw_cell_end = raw_pipes[cell_idx + 1];

    let raw_cell_text: String = raw_line
        .chars()
        .skip(raw_cell_start)
        .take(raw_cell_end - raw_cell_start)
        .collect();

    // Clamp the click into the rendered cell's span so clicks on the opening
    // pipe land at the start of the cell's content.
    let clicked = rendered_col.max(rend_cell_start);
    let rend_offset_in_cell = clicked.saturating_sub(rend_cell_start);

    // Partition the raw cell into leading-ws / content / trailing-ws.
    let raw_chars: Vec<char> = raw_cell_text.chars().collect();
    let raw_leading = raw_chars.iter().take_while(|c| c.is_whitespace()).count();
    let raw_trailing = raw_chars
        .iter()
        .rev()
        .take_while(|c| c.is_whitespace())
        .count();
    let content_chars = raw_chars.len().saturating_sub(raw_leading + raw_trailing);

    // Which chunk of the cell's wrapped content does this sub-line show?
    // The rendered cell span is `│` + space + content (width w) + space,
    // so the content width is the span length minus the two pad spaces.
    let cell_width = rend_cell_end.saturating_sub(rend_cell_start + 2).max(1);
    let trimmed: String = raw_chars[raw_leading..raw_leading + content_chars]
        .iter()
        .collect();
    let chunks = table_layout::wrap_cell_with_indices(&trimmed, cell_width);
    // A cell shorter than the row's tallest cell shows blank padding on
    // the extra sub-lines — map those to the end of the cell's content.
    let (chunk_start, chunk_len) = chunks
        .get(sub)
        .map(|(start, text)| (*start, text.chars().count()))
        .unwrap_or((content_chars, 0));

    // The renderer always emits exactly one leading space before the cell
    // content (see `render_table_row`).  A click on that leading space should
    // land on the first char of this sub-line's chunk; clicks past the
    // chunk's last char clamp to "just past last chunk char" so the cursor
    // never jumps into the next cell (or the next chunk) via trailing
    // padding.
    let raw_offset_in_cell = if rend_offset_in_cell <= 1 {
        raw_leading + chunk_start
    } else {
        let content_col = rend_offset_in_cell - 1;
        raw_leading + chunk_start + content_col.min(chunk_len)
    };

    Some(raw_cell_start + raw_offset_in_cell.min(raw_chars.len()))
}

/// The table-cell band under a Preview click at `(rendered_line_idx, col)`:
/// the inclusive rendered-line range of the cell's logical table row plus
/// the half-open char-column range of the cell's content area.  Returns
/// `None` when the click isn't on a header or data sub-line of a table —
/// callers keep the regular full-line selection behavior.
pub(super) fn preview_table_cell_band(
    state: &EditorState,
    rendered_line_idx: usize,
    col: usize,
) -> Option<CellBand> {
    use crate::ui::table_view::TableSubLineKind;
    let block = locate_block(state, rendered_line_idx)?;
    let source = state.buffer.contents();
    let block_text = source.get(block.range.start..block.range.end.min(source.len()))?;
    if !table_edit::is_table_block(block_text) {
        return None;
    }
    let block_lines = state
        .parsed
        .lines
        .get(block.rendered_span.start..block.rendered_span.end.min(state.parsed.lines.len()))?;
    let kinds = crate::ui::table_view::classify_table_sub_lines(block_lines);

    // Only header and data sub-lines carry cell content; borders and
    // separators fall back to full-line selection.
    let same_row = |k: &TableSubLineKind| match (kinds.get(block.sub_idx), k) {
        (Some(TableSubLineKind::Header { .. }), TableSubLineKind::Header { .. }) => true,
        (
            Some(TableSubLineKind::DataRow { row: clicked, .. }),
            TableSubLineKind::DataRow { row, .. },
        ) => row == clicked,
        _ => false,
    };
    if !kinds.get(block.sub_idx).is_some_and(&same_row) {
        return None;
    }

    // Contiguous run of sub-lines belonging to the same logical row.
    let mut first = block.sub_idx;
    while first > 0 && same_row(&kinds[first - 1]) {
        first -= 1;
    }
    let mut last = block.sub_idx;
    while last + 1 < kinds.len() && same_row(&kinds[last + 1]) {
        last += 1;
    }

    // Column band of the clicked cell: the renderer emits `│` + space +
    // content + space per cell, so cell `i`'s content area is
    // `[pipes[i] + 2, pipes[i + 1] - 1)`.
    let pipes = table_layout::rendered_pipe_positions(&state.parsed.lines[rendered_line_idx]);
    if pipes.len() < 2 {
        return None;
    }
    let col_count = pipes.len() - 1;
    let cell_idx = (0..col_count)
        .find(|&i| col < pipes[i + 1])
        .unwrap_or(col_count - 1);
    let cols = (pipes[cell_idx] + 2, (pipes[cell_idx + 1]).saturating_sub(1));
    if cols.0 >= cols.1 {
        return None;
    }
    Some(CellBand {
        lines: (
            block.rendered_span.start + first,
            block.rendered_span.start + last,
        ),
        cols,
    })
}

/// The buffer char range of the table cell containing `char_offset`, used
/// to clamp Rendered-mode drag selection to the cell where the drag began.
/// Returns `None` when the offset isn't inside a header/data cell of a
/// table (including alignment rows, which carry no selectable content).
pub(super) fn table_cell_char_range_at(
    state: &EditorState,
    char_offset: usize,
) -> Option<(usize, usize)> {
    let source = state.buffer.contents();
    let byte = state
        .buffer
        .rope()
        .char_to_byte(char_offset.min(state.buffer.len_chars()));
    let info = table_edit::find_table_at(&source, byte)?;
    let (row_idx, col_idx) = table_edit::cursor_cell(&info, byte)?;
    let row = info.rows.get(row_idx)?;
    if row.kind == table_edit::RowKind::Alignment {
        return None;
    }
    let cell = row.cells.get(col_idx)?;
    let start_byte = row.start + cell.content_start;
    let end_byte = row.start + cell.content_end;
    let rope = state.buffer.rope();
    Some((
        rope.byte_to_char(start_byte.min(source.len())),
        rope.byte_to_char(end_byte.min(source.len())),
    ))
}

/// Width in cells of visual sub-row `sub_row` of the rendered line.  Clicks
/// past the line's content are clamped to this bound before being mapped into
/// the raw source, so the user can click "past the end" and still land at the
/// line's last valid cursor position.
///
/// Currently returns the full character count of the line regardless of
/// sub-row — a conservative upper bound that keeps clicks off the next line.
/// A precise per-row bound would require re-running the line-wrap algorithm
/// here; the conservative bound is correct at the character level and only
/// loses precision for clicks deep in the padding of wrapped lines.
fn line_row_width(line: &Line<'_>, _sub_row: usize) -> usize {
    line.spans.iter().map(|s| s.content.chars().count()).sum()
}