@fresh-editor/fresh-editor 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,19 +11,74 @@ const editor = getEditor();
11
11
 
12
12
 
13
13
  interface MarkdownConfig {
14
- composeWidth: number;
14
+ composeWidth: number | null;
15
15
  maxWidth: number;
16
16
  hideLineNumbers: boolean;
17
17
  }
18
18
 
19
19
  const config: MarkdownConfig = {
20
- composeWidth: 80,
20
+ composeWidth: null,
21
21
  maxWidth: 100,
22
22
  hideLineNumbers: true,
23
23
  };
24
24
 
25
- // Track buffers in compose mode (explicit toggle)
26
- const composeBuffers = new Set<number>();
25
+ // Table column widths stored per-buffer-per-split via setViewState/getViewState.
26
+ // Persisted across sessions and independent per split.
27
+ interface TableWidthInfo {
28
+ maxW: number[];
29
+ allocated: number[];
30
+ }
31
+
32
+ // Helper: check whether the active split has compose mode for this buffer
33
+ function isComposing(bufferId: number): boolean {
34
+ const info = editor.getBufferInfo(bufferId);
35
+ return info != null && info.view_mode === "compose";
36
+ }
37
+
38
+ // Helper: check whether ANY split showing this buffer has compose mode.
39
+ // Use this for decoration maintenance (conceals, soft breaks, overlays) since
40
+ // decorations live on the buffer and are filtered per-split at render time.
41
+ function isComposingInAnySplit(bufferId: number): boolean {
42
+ const info = editor.getBufferInfo(bufferId);
43
+ return info != null && info.is_composing_in_any_split;
44
+ }
45
+
46
+ // Helper: get cached table column widths from per-buffer-per-split view state
47
+ function getTableWidths(bufferId: number): Map<number, TableWidthInfo> | undefined {
48
+ const obj = editor.getViewState(bufferId, "table-widths") as Record<string, { maxW: number[]; allocated: number[] }> | undefined;
49
+ if (!obj || typeof obj !== "object") return undefined;
50
+ const map = new Map<number, TableWidthInfo>();
51
+ for (const [k, v] of Object.entries(obj)) {
52
+ map.set(parseInt(k, 10), v);
53
+ }
54
+ return map;
55
+ }
56
+
57
+ // Helper: store cached table column widths in per-buffer-per-split view state
58
+ function setTableWidths(bufferId: number, widthMap: Map<number, TableWidthInfo>): void {
59
+ const obj: Record<string, TableWidthInfo> = {};
60
+ for (const [k, v] of widthMap) {
61
+ obj[String(k)] = v;
62
+ }
63
+ editor.setViewState(bufferId, "table-widths", obj);
64
+ }
65
+
66
+ // Helper: clear cached table column widths
67
+ function clearTableWidths(bufferId: number): void {
68
+ editor.setViewState(bufferId, "table-widths", null);
69
+ }
70
+
71
+ // Static map of named HTML entities to their Unicode replacements
72
+ const HTML_ENTITY_MAP: Record<string, string> = {
73
+ nbsp: "\u00A0", amp: "&", lt: "<", gt: ">", mdash: "\u2014", ndash: "\u2013",
74
+ hellip: "\u2026", rsquo: "\u2019", lsquo: "\u2018", rdquo: "\u201D", ldquo: "\u201C",
75
+ bull: "\u2022", middot: "\u00B7", copy: "\u00A9", reg: "\u00AE", trade: "\u2122",
76
+ times: "\u00D7", divide: "\u00F7", plusmn: "\u00B1", deg: "\u00B0",
77
+ frac12: "\u00BD", frac14: "\u00BC", rarr: "\u2192", larr: "\u2190",
78
+ harr: "\u2194", uarr: "\u2191", darr: "\u2193", euro: "\u20AC", pound: "\u00A3",
79
+ yen: "\u00A5", cent: "\u00A2", sect: "\u00A7", para: "\u00B6",
80
+ laquo: "\u00AB", raquo: "\u00BB", ensp: "\u2002", emsp: "\u2003", thinsp: "\u2009",
81
+ };
27
82
 
28
83
  // =============================================================================
29
84
  // Block-based parser for hanging indent support
@@ -31,7 +86,8 @@ const composeBuffers = new Set<number>();
31
86
 
32
87
  interface ParsedBlock {
33
88
  type: 'paragraph' | 'list-item' | 'ordered-list' | 'checkbox' | 'blockquote' |
34
- 'heading' | 'code-fence' | 'code-content' | 'hr' | 'empty' | 'image';
89
+ 'heading' | 'code-fence' | 'code-content' | 'hr' | 'empty' | 'image' |
90
+ 'table-row';
35
91
  startByte: number; // First byte of the line
36
92
  endByte: number; // Byte after last char (before newline)
37
93
  leadingIndent: number; // Spaces before marker/content
@@ -267,6 +323,24 @@ function parseMarkdownBlocks(text: string): ParsedBlock[] {
267
323
  continue;
268
324
  }
269
325
 
326
+ // Table row: | cell | cell | or separator |---|---|
327
+ if (trimmed.startsWith('|') || trimmed.endsWith('|')) {
328
+ blocks.push({
329
+ type: 'table-row',
330
+ startByte: lineStart,
331
+ endByte: lineEnd,
332
+ leadingIndent: line.length - line.trimStart().length,
333
+ marker: '',
334
+ markerStartByte: lineStart,
335
+ contentStartByte: lineStart,
336
+ content: line,
337
+ hangingIndent: 0,
338
+ forceHardBreak: true,
339
+ });
340
+ byteOffset = lineEnd + 1;
341
+ continue;
342
+ }
343
+
270
344
  // Hard break (trailing spaces or backslash)
271
345
  const hasHardBreak = line.endsWith(' ') || line.endsWith('\\');
272
346
 
@@ -295,46 +369,51 @@ function isMarkdownFile(path: string): boolean {
295
369
  return path.endsWith('.md') || path.endsWith('.markdown');
296
370
  }
297
371
 
298
- // Process a buffer in compose mode - just enables compose mode
299
- // The actual transform happens via view_transform_request hook
300
- function processBuffer(bufferId: number, _splitId?: number): void {
301
- if (!composeBuffers.has(bufferId)) return;
302
372
 
373
+ // Enable full compose mode for a buffer (explicit toggle or restore from session).
374
+ // Idempotent: safe to call when already in compose mode (re-applies line numbers,
375
+ // line wrap, and layout hints — needed after session restore where Rust already has
376
+ // ViewMode::Compose but the plugin hasn't applied its settings yet).
377
+ function enableMarkdownCompose(bufferId: number): void {
303
378
  const info = editor.getBufferInfo(bufferId);
304
379
  if (!info || !isMarkdownFile(info.path)) return;
305
380
 
306
- editor.debug(`processBuffer: enabling compose mode for ${info.path}, buffer_id=${bufferId}`);
381
+ // Tell Rust side this buffer is in compose mode (idempotent)
382
+ editor.setViewMode(bufferId, "compose");
307
383
 
308
- // Trigger a refresh to get the view_transform_request hook called
309
- editor.refreshLines(bufferId);
310
- }
384
+ // Hide line numbers in compose mode
385
+ editor.setLineNumbers(bufferId, false);
311
386
 
312
- // Enable full compose mode for a buffer (explicit toggle)
313
- function enableMarkdownCompose(bufferId: number): void {
314
- const info = editor.getBufferInfo(bufferId);
315
- if (!info || !isMarkdownFile(info.path)) return;
387
+ // Enable native line wrapping so that long lines without whitespace
388
+ // (which the plugin can't soft-break) are force-wrapped by the Rust
389
+ // wrapping transform at the content width.
390
+ editor.setLineWrap(bufferId, null, true);
316
391
 
317
- if (!composeBuffers.has(bufferId)) {
318
- composeBuffers.add(bufferId);
392
+ // Set layout hints for centered margins
393
+ editor.setLayoutHints(bufferId, null, { composeWidth: config.composeWidth });
319
394
 
320
- // Hide line numbers in compose mode
321
- editor.setLineNumbers(bufferId, false);
322
-
323
- processBuffer(bufferId);
324
- editor.debug(`Markdown compose enabled for buffer ${bufferId}`);
325
- }
395
+ // Trigger a refresh so lines_changed hooks fire for visible content
396
+ editor.refreshLines(bufferId);
397
+ editor.debug(`Markdown compose enabled for buffer ${bufferId}`);
326
398
  }
327
399
 
328
400
  // Disable compose mode for a buffer
329
401
  function disableMarkdownCompose(bufferId: number): void {
330
- if (composeBuffers.has(bufferId)) {
331
- composeBuffers.delete(bufferId);
402
+ if (isComposing(bufferId)) {
403
+ editor.setViewState(bufferId, "last-cursor-line", null);
404
+ clearTableWidths(bufferId);
405
+
406
+ // Tell Rust side this buffer is back in source mode
407
+ editor.setViewMode(bufferId, "source");
332
408
 
333
409
  // Re-enable line numbers
334
410
  editor.setLineNumbers(bufferId, true);
335
411
 
336
- // Clear view transform to return to normal rendering
337
- editor.clearViewTransform(bufferId, null);
412
+ // Clear layout hints, emphasis overlays, conceals, and soft breaks
413
+ editor.setLayoutHints(bufferId, null, {});
414
+ editor.clearNamespace(bufferId, "md-emphasis");
415
+ editor.clearConcealNamespace(bufferId, "md-syntax");
416
+ editor.clearSoftBreakNamespace(bufferId, "md-wrap");
338
417
 
339
418
  editor.refreshLines(bufferId);
340
419
  editor.debug(`Markdown compose disabled for buffer ${bufferId}`);
@@ -354,7 +433,7 @@ globalThis.markdownToggleCompose = function(): void {
354
433
  return;
355
434
  }
356
435
 
357
- if (composeBuffers.has(bufferId)) {
436
+ if (isComposing(bufferId)) {
358
437
  disableMarkdownCompose(bufferId);
359
438
  editor.setStatus(editor.t("status.compose_off"));
360
439
  } else {
@@ -436,6 +515,13 @@ function transformMarkdownTokens(
436
515
  // Get hanging indent for current block (default 0)
437
516
  const hangingIndent = currentBlock?.hangingIndent ?? 0;
438
517
 
518
+ // Determine if current block should be soft-wrapped
519
+ const blockType = currentBlock?.type;
520
+ const noWrap = blockType === 'table-row' || blockType === 'code-fence' ||
521
+ blockType === 'code-content' || blockType === 'hr' ||
522
+ blockType === 'heading' || blockType === 'image' ||
523
+ blockType === 'empty';
524
+
439
525
  // Handle different token types
440
526
  if (kind === "Newline") {
441
527
  // Real newlines pass through - they end a block
@@ -465,7 +551,7 @@ function transformMarkdownTokens(
465
551
  }
466
552
 
467
553
  // Check if space + next word would exceed width
468
- if (column + 1 + nextWordLen > width && nextWordLen > 0) {
554
+ if (!noWrap && column + 1 + nextWordLen > width && nextWordLen > 0) {
469
555
  // Wrap: emit soft newline + hanging indent instead of space
470
556
  outputTokens.push({ source_offset: null, kind: "Newline" });
471
557
  for (let j = 0; j < hangingIndent; j++) {
@@ -490,7 +576,7 @@ function transformMarkdownTokens(
490
576
  }
491
577
 
492
578
  // Check if this word alone would exceed width (need to wrap)
493
- if (column > hangingIndent && column + text.length > width) {
579
+ if (!noWrap && column > hangingIndent && column + text.length > width) {
494
580
  // Wrap before this word
495
581
  outputTokens.push({ source_offset: null, kind: "Newline" });
496
582
  for (let j = 0; j < hangingIndent; j++) {
@@ -511,83 +597,968 @@ function transformMarkdownTokens(
511
597
  return outputTokens;
512
598
  }
513
599
 
514
- // Handle view transform request - receives tokens from core for transformation
515
- // Only applies transforms when in compose mode
516
- globalThis.onMarkdownViewTransform = function(data: {
600
+ // =============================================================================
601
+ // Line-level conceal/overlay processing
602
+ // =============================================================================
603
+ // Conceals and overlays are managed per-line using targeted range-based clearing.
604
+ // The lines_changed hook processes newly visible or edited lines.
605
+ // The after_insert/after_delete hooks clear affected byte ranges.
606
+ // The view_transform_request hook handles cursor-aware reveal/conceal updates
607
+ // and soft wrapping.
608
+
609
+ /**
610
+ * Convert a char offset within lineContent to a buffer byte offset.
611
+ * Handles UTF-8 multi-byte characters correctly.
612
+ */
613
+ function charToByte(lineContent: string, charOffset: number, lineByteStart: number): number {
614
+ return lineByteStart + editor.utf8ByteLength(lineContent.slice(0, charOffset));
615
+ }
616
+
617
+ // ---------------------------------------------------------------------------
618
+ // Shared inline span detection — used by both processLineConceals (to apply
619
+ // conceals + overlays) and concealedText (to compute visible table widths).
620
+ // ---------------------------------------------------------------------------
621
+
622
+ interface InlineSpan {
623
+ type: 'code' | 'bold-italic' | 'bold' | 'italic' | 'strikethrough' | 'link' | 'entity';
624
+ matchStart: number; // char offset of full match start
625
+ matchEnd: number; // char offset of full match end
626
+ contentStart: number; // char offset of visible content start
627
+ contentEnd: number; // char offset of visible content end
628
+ concealRanges: Array<{start: number; end: number; replacement: string | null}>;
629
+ linkUrl?: string;
630
+ }
631
+
632
+ /** Find all inline spans that would produce conceals in the given text. */
633
+ function findInlineSpans(text: string): InlineSpan[] {
634
+ const spans: InlineSpan[] = [];
635
+ let m: RegExpExecArray | null;
636
+
637
+ // 1. Code spans (also builds exclusion set)
638
+ const codeSpanCharRanges: [number, number][] = [];
639
+ const codeRe = /(?<!`)(`)((?:[^`]|(?<=\\)`)+)\1(?!`)/g;
640
+ while ((m = codeRe.exec(text)) !== null) {
641
+ const ms = m.index;
642
+ const me = ms + m[0].length;
643
+ codeSpanCharRanges.push([ms, me]);
644
+ spans.push({
645
+ type: 'code',
646
+ matchStart: ms, matchEnd: me,
647
+ contentStart: ms + 1, contentEnd: me - 1,
648
+ concealRanges: [
649
+ { start: ms, end: ms + 1, replacement: null },
650
+ { start: me - 1, end: me, replacement: null },
651
+ ],
652
+ });
653
+ }
654
+
655
+ function inCodeSpan(charPos: number): boolean {
656
+ for (const [s, e] of codeSpanCharRanges) {
657
+ if (charPos >= s && charPos < e) return true;
658
+ }
659
+ return false;
660
+ }
661
+
662
+ // 2. Emphasis
663
+ const emphasisPatterns: [RegExp, InlineSpan['type'], number][] = [
664
+ [/\*{3}([^*]+)\*{3}/g, 'bold-italic', 3],
665
+ [/(?<!\*)\*{2}(?!\*)([^*]+?)(?<!\*)\*{2}(?!\*)/g, 'bold', 2],
666
+ [/(?<!\*)\*(?!\*)([^*]+?)(?<!\*)\*(?!\*)/g, 'italic', 1],
667
+ [/~~([^~]+)~~/g, 'strikethrough', 2],
668
+ ];
669
+ for (const [pattern, type, markerLen] of emphasisPatterns) {
670
+ const re = new RegExp(pattern.source, pattern.flags);
671
+ while ((m = re.exec(text)) !== null) {
672
+ if (inCodeSpan(m.index)) continue;
673
+ const ms = m.index;
674
+ const me = ms + m[0].length;
675
+ spans.push({
676
+ type,
677
+ matchStart: ms, matchEnd: me,
678
+ contentStart: ms + markerLen,
679
+ contentEnd: ms + markerLen + m[1].length,
680
+ concealRanges: [
681
+ { start: ms, end: ms + markerLen, replacement: null },
682
+ { start: me - markerLen, end: me, replacement: null },
683
+ ],
684
+ });
685
+ }
686
+ }
687
+
688
+ // 3. Links
689
+ const linkRe = /(?<!!)\[([^\]]+)\]\(([^)]+)\)/g;
690
+ while ((m = linkRe.exec(text)) !== null) {
691
+ if (inCodeSpan(m.index)) continue;
692
+ const ms = m.index;
693
+ const me = ms + m[0].length;
694
+ const textEnd = ms + 1 + m[1].length;
695
+ spans.push({
696
+ type: 'link',
697
+ matchStart: ms, matchEnd: me,
698
+ contentStart: ms + 1, contentEnd: textEnd,
699
+ concealRanges: [
700
+ { start: ms, end: ms + 1, replacement: null },
701
+ { start: textEnd, end: me, replacement: ` — ${m[2]}` },
702
+ ],
703
+ linkUrl: m[2],
704
+ });
705
+ }
706
+
707
+ // 4. HTML entities
708
+ const namedEntityRe = /&(nbsp|amp|lt|gt|mdash|ndash|hellip|rsquo|lsquo|rdquo|ldquo|bull|middot|copy|reg|trade|times|divide|plusmn|deg|frac12|frac14|rarr|larr|harr|uarr|darr|euro|pound|yen|cent|sect|para|laquo|raquo|ensp|emsp|thinsp);/g;
709
+ while ((m = namedEntityRe.exec(text)) !== null) {
710
+ if (inCodeSpan(m.index)) continue;
711
+ const replacement = HTML_ENTITY_MAP[m[1]];
712
+ if (!replacement) continue;
713
+ spans.push({
714
+ type: 'entity',
715
+ matchStart: m.index, matchEnd: m.index + m[0].length,
716
+ contentStart: m.index, contentEnd: m.index + m[0].length,
717
+ concealRanges: [{ start: m.index, end: m.index + m[0].length, replacement }],
718
+ });
719
+ }
720
+ const numericDecEntityRe = /&#(\d{1,6});/g;
721
+ while ((m = numericDecEntityRe.exec(text)) !== null) {
722
+ if (inCodeSpan(m.index)) continue;
723
+ const cp = parseInt(m[1], 10);
724
+ if (cp < 1 || cp > 0x10FFFF) continue;
725
+ spans.push({
726
+ type: 'entity',
727
+ matchStart: m.index, matchEnd: m.index + m[0].length,
728
+ contentStart: m.index, contentEnd: m.index + m[0].length,
729
+ concealRanges: [{ start: m.index, end: m.index + m[0].length, replacement: String.fromCodePoint(cp) }],
730
+ });
731
+ }
732
+ const numericHexEntityRe = /&#x([0-9a-fA-F]{1,6});/g;
733
+ while ((m = numericHexEntityRe.exec(text)) !== null) {
734
+ if (inCodeSpan(m.index)) continue;
735
+ const cp = parseInt(m[1], 16);
736
+ if (cp < 1 || cp > 0x10FFFF) continue;
737
+ spans.push({
738
+ type: 'entity',
739
+ matchStart: m.index, matchEnd: m.index + m[0].length,
740
+ contentStart: m.index, contentEnd: m.index + m[0].length,
741
+ concealRanges: [{ start: m.index, end: m.index + m[0].length, replacement: String.fromCodePoint(cp) }],
742
+ });
743
+ }
744
+
745
+ return spans;
746
+ }
747
+
748
+ /**
749
+ * Return the visible text of a string after applying all inline conceals.
750
+ * Used for table column width calculation so emphasis/link syntax is not
751
+ * counted towards cell width.
752
+ */
753
+ function concealedText(text: string): string {
754
+ const ranges: Array<{start: number; end: number; replacement: string | null}> = [];
755
+ for (const span of findInlineSpans(text)) {
756
+ ranges.push(...span.concealRanges);
757
+ }
758
+ ranges.sort((a, b) => a.start - b.start);
759
+
760
+ let result = '';
761
+ let pos = 0;
762
+ for (const r of ranges) {
763
+ if (r.start < pos) continue; // overlapping range
764
+ if (r.start > pos) result += text.slice(pos, r.start);
765
+ if (r.replacement !== null) result += r.replacement;
766
+ pos = r.end;
767
+ }
768
+ result += text.slice(pos);
769
+ return result;
770
+ }
771
+
772
+ const MIN_COL_W = 3;
773
+
774
+ /**
775
+ * W3C-inspired column width distribution.
776
+ * Constrains columns to fit within `available` width, distributing space
777
+ * proportionally to each column's natural (max) width.
778
+ */
779
+ function distributeColumnWidths(maxW: number[], available: number): number[] {
780
+ const numCols = maxW.length;
781
+ const total = maxW.reduce((s, w) => s + w, 0);
782
+ if (total <= available) return maxW;
783
+ if (numCols * MIN_COL_W >= available) return maxW.map(() => MIN_COL_W);
784
+
785
+ const remaining = available - numCols * MIN_COL_W;
786
+ const excess = maxW.reduce((s, w) => s + Math.max(0, w - MIN_COL_W), 0);
787
+ return maxW.map(w => {
788
+ const extra = excess > 0 ? Math.floor(remaining * Math.max(0, w - MIN_COL_W) / excess) : 0;
789
+ return MIN_COL_W + extra;
790
+ });
791
+ }
792
+
793
+ /**
794
+ * Wrap text into lines of at most `width` characters, breaking at word boundaries.
795
+ */
796
+ function wrapText(text: string, width: number): string[] {
797
+ if (width <= 0 || text.length <= width) return [text];
798
+ const lines: string[] = [];
799
+ let pos = 0;
800
+ while (pos < text.length) {
801
+ if (pos + width >= text.length) {
802
+ lines.push(text.slice(pos));
803
+ break;
804
+ }
805
+ let breakAt = text.lastIndexOf(' ', pos + width);
806
+ if (breakAt <= pos) {
807
+ breakAt = pos + width;
808
+ lines.push(text.slice(pos, breakAt));
809
+ pos = breakAt;
810
+ } else {
811
+ lines.push(text.slice(pos, breakAt));
812
+ pos = breakAt + 1;
813
+ }
814
+ }
815
+ return lines.length > 0 ? lines : [text];
816
+ }
817
+
818
+ /**
819
+ * Process a single line: add overlays (emphasis, link styling) and conceals
820
+ * (hide markdown syntax markers). Cursor-aware: when cursor is inside a span,
821
+ * markers are revealed instead of concealed.
822
+ */
823
+ function processLineConceals(
824
+ bufferId: number,
825
+ lineContent: string,
826
+ byteStart: number,
827
+ byteEnd: number,
828
+ cursors: number[],
829
+ lineNumber?: number,
830
+ ): void {
831
+ // Clear existing conceals and overlays for this line first.
832
+ // This ensures clear+add commands are sent together from the plugin thread
833
+ // and processed atomically in the same process_commands() batch, avoiding
834
+ // the one-frame glitch where conceals are cleared but not yet rebuilt.
835
+ editor.debug(`[mc] processLine clear+rebuild bytes=${byteStart}..${byteEnd} content="${lineContent.slice(0,40)}"`);
836
+ editor.clearConcealsInRange(bufferId, byteStart, byteEnd);
837
+ editor.clearOverlaysInRange(bufferId, byteStart, byteEnd);
838
+
839
+ const cursorOnLine = cursors.some(c => c >= byteStart && c <= byteEnd);
840
+ // Strict version: excludes the boundary at byteEnd so that the cursor
841
+ // sitting at the start of the *next* line doesn't count as being on
842
+ // *this* line. Used for table row auto-expose to avoid exposing the
843
+ // previous row's emphasis markers.
844
+ const cursorStrictlyOnLine = cursors.some(c => c >= byteStart && c < byteEnd);
845
+
846
+ // Skip lines inside code fences (we'd need multi-line context for this;
847
+ // for now, detect fence lines and code content lines)
848
+ const trimmed = lineContent.trim();
849
+ if (trimmed.startsWith('```')) return; // fence line itself
850
+
851
+ // --- Table row handling ---
852
+ // Always apply table conceals even when cursor is on the line.
853
+ // Tables are structural: pipes → box-drawing, cells padded for alignment.
854
+ // Toggling conceals on/off per cursor line causes visual width shifts that
855
+ // break cursor navigation (stuck cursor, ghost cursors) and lose alignment.
856
+ const truncatedByteRanges: Array<{start: number; end: number}> = [];
857
+ let isTableRow = false;
858
+ if (trimmed.startsWith('|') || trimmed.endsWith('|')) {
859
+ isTableRow = true;
860
+ const isSeparator = /^\|[-:\s|]+\|$/.test(trimmed);
861
+
862
+ // Look up stored column widths for alignment padding
863
+ const bufWidths = lineNumber !== undefined ? getTableWidths(bufferId) : undefined;
864
+ const widthInfo = bufWidths && lineNumber !== undefined ? bufWidths.get(lineNumber) : undefined;
865
+ const colWidths = widthInfo ? widthInfo.allocated : undefined;
866
+
867
+ // Split the line into cells to compute per-cell padding
868
+ let inner = trimmed;
869
+ if (inner.startsWith('|')) inner = inner.slice(1);
870
+ if (inner.endsWith('|')) inner = inner.slice(0, -1);
871
+ const cells = inner.split('|');
872
+
873
+ // Check if any data cell needs multi-line wrapping
874
+ let handledByWrapping = false;
875
+ if (colWidths && !isSeparator && !cursorStrictlyOnLine) {
876
+ const numCols = Math.min(cells.length, colWidths.length);
877
+ const cellWrapped: string[][] = [];
878
+ let maxVisualLines = 1;
879
+ for (let ci = 0; ci < numCols; ci++) {
880
+ // When cursor is on the row, use raw text (emphasis markers revealed).
881
+ const cellText = cursorStrictlyOnLine ? cells[ci].trim() : concealedText(cells[ci]).trim();
882
+ const wrapW = Math.max(1, colWidths[ci] - 2); // 1 leading + 1 trailing space margin
883
+ const wrapped = wrapText(cellText, wrapW);
884
+ cellWrapped.push(wrapped);
885
+ maxVisualLines = Math.max(maxVisualLines, wrapped.length);
886
+ }
887
+ // Cap to available source bytes (excluding trailing newline)
888
+ let effLen = lineContent.length;
889
+ if (effLen > 0 && lineContent[effLen - 1] === '\n') effLen--;
890
+ if (effLen > 0 && lineContent[effLen - 1] === '\r') effLen--;
891
+ maxVisualLines = Math.min(maxVisualLines, effLen);
892
+
893
+ if (maxVisualLines > 1) {
894
+ // Build formatted visual line for each wrapped row
895
+ const visualLines: string[] = [];
896
+ for (let vl = 0; vl < maxVisualLines; vl++) {
897
+ let vline = '│';
898
+ for (let ci = 0; ci < numCols; ci++) {
899
+ const wrapW = Math.max(1, colWidths[ci] - 2);
900
+ const wrapped = cellWrapped[ci] || [];
901
+ const text = vl < wrapped.length ? wrapped[vl] : '';
902
+ vline += ' ' + text + ' '.repeat(Math.max(0, wrapW - text.length)) + ' │';
903
+ }
904
+ visualLines.push(vline);
905
+ }
906
+
907
+ // Divide source bytes into segments, one per visual line.
908
+ // Soft breaks at segment boundaries (added by processLineSoftBreaks)
909
+ // create the visual line breaks; conceals replace each segment.
910
+ //
911
+ // IMPORTANT: break positions MUST land on Space characters.
912
+ // Space tokens have individual source_offset values matching their
913
+ // byte positions, so soft breaks will reliably trigger. Non-space
914
+ // characters inside Text tokens share the token's START offset,
915
+ // so breaks at mid-token positions silently fail.
916
+ // The consumed space (replaced by Newline) must NOT be covered by
917
+ // any segment's conceal range, so segment N+1 starts at spacePos+1.
918
+ // Exclude trailing newline from segment range so the Newline token
919
+ // at the end of the source line is NOT concealed (preserves the
920
+ // line break between adjacent source rows).
921
+ let lineCharLen = lineContent.length;
922
+ if (lineCharLen > 0 && lineContent[lineCharLen - 1] === '\n') lineCharLen--;
923
+ if (lineCharLen > 0 && lineContent[lineCharLen - 1] === '\r') lineCharLen--;
924
+ const spacePositions: number[] = [];
925
+ for (let i = 1; i < lineCharLen; i++) {
926
+ if (lineContent[i] === ' ') spacePositions.push(i);
927
+ }
928
+ const breakChars = spacePositions.slice(0, maxVisualLines - 1);
929
+ // Trim visual lines if we couldn't find enough break positions
930
+ const actualVisualLines = breakChars.length + 1;
931
+ // Segments: first starts at 0, subsequent start AFTER the consumed space
932
+ const segStarts = [0, ...breakChars.map(c => c + 1)];
933
+ const segEnds = [...breakChars, lineCharLen];
934
+ for (let vl = 0; vl < actualVisualLines; vl++) {
935
+ const sByteS = charToByte(lineContent, segStarts[vl], byteStart);
936
+ const sByteE = charToByte(lineContent, segEnds[vl], byteStart);
937
+ editor.addConceal(bufferId, "md-syntax", sByteS, sByteE, visualLines[vl] || '');
938
+ }
939
+ handledByWrapping = true;
940
+ }
941
+ }
942
+
943
+ if (!handledByWrapping) {
944
+ // Find pipe positions for byte-range computation of truncated cells
945
+ const pipePositions: number[] = [];
946
+ for (let i = 0; i < lineContent.length; i++) {
947
+ if (lineContent[i] === '|') pipePositions.push(i);
948
+ }
949
+
950
+ // Track which pipe index we're on (0 = leading pipe)
951
+ let pipeIdx = 0;
952
+ for (let i = 0; i < lineContent.length; i++) {
953
+ if (lineContent[i] === '|') {
954
+ const pipeByte = charToByte(lineContent, i, byteStart);
955
+ const pipeByteEnd = charToByte(lineContent, i + 1, byteStart);
956
+
957
+ // Compute padding or truncation for the cell that just ended.
958
+ // When the cursor is on this row, skip truncation/padding entirely
959
+ // so that only pipe→│ conceals exist. This ensures cursor positioning
960
+ // works correctly (segment conceals break cursor mapping).
961
+ let padding = "";
962
+ const cellIdx = pipeIdx - 1;
963
+ if (!cursorStrictlyOnLine && colWidths && pipeIdx > 0 && cellIdx < cells.length && cellIdx < colWidths.length) {
964
+ const cellText = concealedText(cells[cellIdx]);
965
+ const cellWidth = cellText.length;
966
+ const allocatedWidth = colWidths[cellIdx];
967
+
968
+ if (cellWidth > allocatedWidth) {
969
+ // Truncate: conceal entire cell content and replace with truncated text
970
+ const prevPipeCharPos = pipePositions[pipeIdx - 1];
971
+ const cellByteStart = charToByte(lineContent, prevPipeCharPos + 1, byteStart);
972
+ const cellByteEnd = pipeByte;
973
+ const truncated = cellText.slice(0, allocatedWidth - 1) + '-';
974
+ editor.addConceal(bufferId, "md-syntax", cellByteStart, cellByteEnd, truncated);
975
+ truncatedByteRanges.push({start: cellByteStart, end: cellByteEnd});
976
+ } else {
977
+ const padCount = allocatedWidth - cellWidth;
978
+ if (padCount > 0) {
979
+ padding = isSeparator ? "─".repeat(padCount) : " ".repeat(padCount);
980
+ }
981
+ }
982
+ }
983
+
984
+ if (isSeparator) {
985
+ const pipeIndex = lineContent.substring(0, i + 1).split('|').length - 1;
986
+ const totalPipes = lineContent.split('|').length - 1;
987
+ let replacement = '┼';
988
+ if (pipeIndex === 1) replacement = '├';
989
+ else if (pipeIndex === totalPipes) replacement = '┤';
990
+ editor.addConceal(bufferId, "md-syntax", pipeByte, pipeByteEnd, padding + replacement);
991
+ } else {
992
+ editor.addConceal(bufferId, "md-syntax", pipeByte, pipeByteEnd, padding + "│");
993
+ }
994
+ pipeIdx++;
995
+ } else if (isSeparator && lineContent[i] === '-') {
996
+ const db = charToByte(lineContent, i, byteStart);
997
+ editor.addConceal(bufferId, "md-syntax", db, charToByte(lineContent, i + 1, byteStart), "─");
998
+ }
999
+ }
1000
+ }
1001
+ // For wrapped rows, entire line is concealed — skip emphasis processing.
1002
+ // For non-wrapped rows, fall through to emphasis / link / entity processing.
1003
+ if (handledByWrapping) return;
1004
+ }
1005
+
1006
+ // --- Image links: ![alt](url) → "Image: alt — url" ---
1007
+ const imageRe = /^!\[([^\]]*)\]\(([^)]+)\)$/;
1008
+ const imageMatch = trimmed.match(imageRe);
1009
+ if (imageMatch && !cursorOnLine) {
1010
+ const alt = imageMatch[1];
1011
+ const url = imageMatch[2];
1012
+ editor.addConceal(bufferId, "md-syntax", byteStart, byteEnd, `Image: ${alt} — ${url}`);
1013
+ return;
1014
+ }
1015
+
1016
+ // --- Inline spans: code, emphasis, links, entities ---
1017
+ const spans = findInlineSpans(lineContent);
1018
+ for (const span of spans) {
1019
+ const byteCS = charToByte(lineContent, span.contentStart, byteStart);
1020
+ const byteCE = charToByte(lineContent, span.contentEnd, byteStart);
1021
+ const byteMS = charToByte(lineContent, span.matchStart, byteStart);
1022
+ const byteME = charToByte(lineContent, span.matchEnd, byteStart);
1023
+
1024
+ // Skip overlays and conceals for spans inside truncated table cells —
1025
+ // the cell content has already been fully replaced by truncated text.
1026
+ const inTruncated = truncatedByteRanges.some(r => byteMS >= r.start && byteME <= r.end);
1027
+ if (inTruncated) continue;
1028
+
1029
+ // Overlays (styling)
1030
+ switch (span.type) {
1031
+ case 'code':
1032
+ editor.addOverlay(bufferId, "md-emphasis", byteCS, byteCE, { fg: "syntax.constant" });
1033
+ break;
1034
+ case 'bold':
1035
+ editor.addOverlay(bufferId, "md-emphasis", byteCS, byteCE, { bold: true });
1036
+ break;
1037
+ case 'italic':
1038
+ editor.addOverlay(bufferId, "md-emphasis", byteCS, byteCE, { italic: true });
1039
+ break;
1040
+ case 'bold-italic':
1041
+ editor.addOverlay(bufferId, "md-emphasis", byteCS, byteCE, { bold: true, italic: true });
1042
+ break;
1043
+ case 'strikethrough':
1044
+ editor.addOverlay(bufferId, "md-emphasis", byteCS, byteCE, { strikethrough: true });
1045
+ break;
1046
+ case 'link':
1047
+ editor.addOverlay(bufferId, "md-emphasis", byteCS, byteCE, {
1048
+ fg: "syntax.link",
1049
+ underline: true,
1050
+ url: span.linkUrl,
1051
+ });
1052
+ break;
1053
+ // entities: no overlay
1054
+ }
1055
+
1056
+ // Conceals (cursor-aware).
1057
+ // For table rows: skip ALL emphasis conceals when cursor is on the line,
1058
+ // not just the span the cursor is in. This "auto-expose entire row"
1059
+ // approach keeps the row layout consistent with the raw-text-based
1060
+ // column widths, preventing overflow/wrapping.
1061
+ const cursorInSpan = cursors.some(c => c >= byteMS && c <= byteME);
1062
+ const skipConceal = (isTableRow && cursorStrictlyOnLine) || cursorInSpan;
1063
+ if (!skipConceal) {
1064
+ for (const range of span.concealRanges) {
1065
+ const rStart = charToByte(lineContent, range.start, byteStart);
1066
+ const rEnd = charToByte(lineContent, range.end, byteStart);
1067
+ editor.addConceal(bufferId, "md-syntax", rStart, rEnd, range.replacement);
1068
+ }
1069
+ }
1070
+ }
1071
+ }
1072
+
1073
+ // Last cursor line is tracked per-buffer-per-split via setViewState/getViewState
1074
+
1075
+ // Track viewport width per buffer for resize detection
1076
+ let lastViewportWidth = 0;
1077
+
1078
+ // =============================================================================
1079
+ // Hook handlers
1080
+ // =============================================================================
1081
+
1082
+ /**
1083
+ * Compute soft break points for a single line, using the same block parsing
1084
+ * and word-wrap logic as the old transformMarkdownTokens, but emitting
1085
+ * marker-based soft breaks instead of view_transform tokens.
1086
+ */
1087
+ function processLineSoftBreaks(
1088
+ bufferId: number,
1089
+ lineContent: string,
1090
+ byteStart: number,
1091
+ byteEnd: number,
1092
+ cursors: number[],
1093
+ lineNumber?: number,
1094
+ ): void {
1095
+ // Clear existing soft breaks for this line range
1096
+ editor.clearSoftBreaksInRange(bufferId, byteStart, byteEnd);
1097
+
1098
+ const viewport = editor.getViewport();
1099
+ if (!viewport) return;
1100
+ const width = config.composeWidth ?? viewport.width;
1101
+
1102
+ // Parse this single line to get block structure
1103
+ const blocks = parseMarkdownBlocks(lineContent);
1104
+ if (blocks.length === 0) return;
1105
+
1106
+ const block = blocks[0]; // Single line = single block
1107
+
1108
+ // Determine if this block type should be soft-wrapped
1109
+ const noWrap = block.type === 'table-row' || block.type === 'code-fence' ||
1110
+ block.type === 'code-content' || block.type === 'hr' ||
1111
+ block.type === 'heading' || block.type === 'image' ||
1112
+ block.type === 'empty';
1113
+
1114
+ // Image blocks: add a trailing blank line for visual separation when concealed
1115
+ if (block.type === 'image') {
1116
+ const cursorOnLine = cursors.some(c => c >= byteStart && c <= byteEnd);
1117
+ if (!cursorOnLine) {
1118
+ editor.addSoftBreak(bufferId, "md-wrap", byteEnd - 1, 0);
1119
+ }
1120
+ }
1121
+
1122
+ // Table row wrapping: add soft breaks for multi-line cells
1123
+ if (block.type === 'table-row' && lineNumber !== undefined) {
1124
+ const trimmedLine = lineContent.trim();
1125
+ const isSep = /^\|[-:\s|]+\|$/.test(trimmedLine);
1126
+ if (!isSep) {
1127
+ const bufWidths = getTableWidths(bufferId);
1128
+ const widthInfo = bufWidths ? bufWidths.get(lineNumber) : undefined;
1129
+ const colWidths = widthInfo ? widthInfo.allocated : undefined;
1130
+ if (colWidths) {
1131
+ let innerLine = trimmedLine;
1132
+ if (innerLine.startsWith('|')) innerLine = innerLine.slice(1);
1133
+ if (innerLine.endsWith('|')) innerLine = innerLine.slice(0, -1);
1134
+ const tableCells = innerLine.split('|');
1135
+ let maxVisualLines = 1;
1136
+ const numCols = Math.min(tableCells.length, colWidths.length);
1137
+ const cursorOnTableLine = cursors.some(c => c >= byteStart && c < byteEnd);
1138
+ for (let ci = 0; ci < numCols; ci++) {
1139
+ const cellText = cursorOnTableLine ? tableCells[ci].trim() : concealedText(tableCells[ci]).trim();
1140
+ const wrapW = Math.max(1, colWidths[ci] - 2);
1141
+ const wrapped = wrapText(cellText, wrapW);
1142
+ maxVisualLines = Math.max(maxVisualLines, wrapped.length);
1143
+ }
1144
+ // Exclude trailing newline (same as processLineConceals)
1145
+ let effLineLen = lineContent.length;
1146
+ if (effLineLen > 0 && lineContent[effLineLen - 1] === '\n') effLineLen--;
1147
+ if (effLineLen > 0 && lineContent[effLineLen - 1] === '\r') effLineLen--;
1148
+ maxVisualLines = Math.min(maxVisualLines, effLineLen);
1149
+
1150
+ if (maxVisualLines > 1) {
1151
+ // Must match the break positions from processLineConceals:
1152
+ // pick Space chars (they have individual source_offsets that match).
1153
+ const spacePositions: number[] = [];
1154
+ for (let i = 1; i < effLineLen; i++) {
1155
+ if (lineContent[i] === ' ') spacePositions.push(i);
1156
+ }
1157
+ const breakChars = spacePositions.slice(0, maxVisualLines - 1);
1158
+ for (const charPos of breakChars) {
1159
+ const breakBytePos = byteStart + editor.utf8ByteLength(lineContent.slice(0, charPos));
1160
+ editor.addSoftBreak(bufferId, "md-wrap", breakBytePos, 0);
1161
+ }
1162
+ }
1163
+ }
1164
+ }
1165
+ }
1166
+
1167
+ if (noWrap) return;
1168
+
1169
+ const hangingIndent = block.hangingIndent;
1170
+
1171
+ // Compute per-character visual width so concealed markup (emphasis
1172
+ // markers, link syntax, entities) doesn't count towards line width.
1173
+ const spans = findInlineSpans(lineContent);
1174
+ const charW = new Array<number>(lineContent.length).fill(1);
1175
+ for (const span of spans) {
1176
+ for (const range of span.concealRanges) {
1177
+ for (let c = range.start; c < range.end && c < lineContent.length; c++) {
1178
+ charW[c] = 0;
1179
+ }
1180
+ // Entity replacements contribute their replacement's length
1181
+ if (range.replacement !== null && range.start < lineContent.length) {
1182
+ charW[range.start] = range.replacement.length;
1183
+ }
1184
+ }
1185
+ }
1186
+
1187
+ // Walk through the line content and find word-wrap break points
1188
+ // We need to find Space positions where wrapping should occur
1189
+ let column = 0;
1190
+ let i = 0;
1191
+
1192
+ while (i < lineContent.length) {
1193
+ const ch = lineContent[i];
1194
+
1195
+ if (ch === ' ' && column > 0 && charW[i] > 0) {
1196
+ // Look ahead to find the next word's visual length
1197
+ let nextWordLen = 0;
1198
+ for (let j = i + 1; j < lineContent.length; j++) {
1199
+ if ((lineContent[j] === ' ' || lineContent[j] === '\n') && charW[j] > 0) break;
1200
+ nextWordLen += charW[j];
1201
+ }
1202
+
1203
+ // Check if space + next word would exceed width
1204
+ if (column + 1 + nextWordLen > width && nextWordLen > 0) {
1205
+ // Add a soft break at this space's buffer position
1206
+ const breakBytePos = byteStart + editor.utf8ByteLength(lineContent.slice(0, i));
1207
+ editor.addSoftBreak(bufferId, "md-wrap", breakBytePos, hangingIndent);
1208
+ column = hangingIndent;
1209
+ i++;
1210
+ continue;
1211
+ }
1212
+ }
1213
+
1214
+ column += charW[i];
1215
+ i++;
1216
+ }
1217
+ }
1218
+
1219
+ /**
1220
+ * Pre-compute column widths for table groups in a batch of lines.
1221
+ * Groups consecutive table rows and computes max visible width per column.
1222
+ *
1223
+ * Uses an accumulate-and-grow strategy: widths are merged with previously
1224
+ * cached values (taking the max per column) so that as the user scrolls
1225
+ * through a large table, column widths converge to the true maximum and
1226
+ * never shrink.
1227
+ */
1228
+ function processTableAlignment(
1229
+ bufferId: number,
1230
+ lines: Array<{ line_number: number; byte_start: number; byte_end: number; content: string }>,
1231
+ ): boolean {
1232
+ // Get existing cache (accumulate-and-grow — don't discard previous widths)
1233
+ const widthMap = getTableWidths(bufferId) ?? new Map<number, TableWidthInfo>();
1234
+ let needsRefresh = false;
1235
+
1236
+ // Group consecutive table rows
1237
+ const groups: Array<typeof lines> = [];
1238
+ let currentGroup: typeof lines = [];
1239
+ let lastLineNum = -2;
1240
+
1241
+ for (const line of lines) {
1242
+ const trimmed = line.content.trim();
1243
+ const isTableRow = trimmed.startsWith('|') || trimmed.endsWith('|');
1244
+ if (isTableRow && line.line_number === lastLineNum + 1) {
1245
+ currentGroup.push(line);
1246
+ } else if (isTableRow) {
1247
+ if (currentGroup.length > 0) groups.push(currentGroup);
1248
+ currentGroup = [line];
1249
+ } else {
1250
+ if (currentGroup.length > 0) groups.push(currentGroup);
1251
+ currentGroup = [];
1252
+ }
1253
+ lastLineNum = line.line_number;
1254
+ }
1255
+ if (currentGroup.length > 0) groups.push(currentGroup);
1256
+
1257
+ // For each group, compute max column widths and merge with cache
1258
+ for (const group of groups) {
1259
+ const allCells: string[][] = [];
1260
+
1261
+ for (const line of group) {
1262
+ const trimmed = line.content.trim();
1263
+ // Strip outer pipes and split on inner pipes
1264
+ let inner = trimmed;
1265
+ if (inner.startsWith('|')) inner = inner.slice(1);
1266
+ if (inner.endsWith('|')) inner = inner.slice(0, -1);
1267
+ const cells = inner.split('|');
1268
+ allCells.push(cells);
1269
+ }
1270
+
1271
+ // Find max column count
1272
+ const maxCols = allCells.reduce((max, row) => Math.max(max, row.length), 0);
1273
+
1274
+ // Compute max visible width per column from the currently visible rows
1275
+ const newWidths: number[] = [];
1276
+ for (let col = 0; col < maxCols; col++) {
1277
+ let maxW = 0;
1278
+ for (const row of allCells) {
1279
+ if (col < row.length) {
1280
+ // For separator rows, use 0 width (they adapt to data rows).
1281
+ // Use RAW text width (not concealedText) so that columns are always
1282
+ // sized to accommodate revealed emphasis markers when cursor enters
1283
+ // a row. Concealed rows simply get extra padding.
1284
+ const isSep = /^[-:\s]+$/.test(row[col]);
1285
+ if (!isSep) {
1286
+ maxW = Math.max(maxW, row[col].length);
1287
+ }
1288
+ }
1289
+ }
1290
+ newWidths.push(maxW);
1291
+ }
1292
+
1293
+ // Merge with any previously cached maxW arrays for lines in this group
1294
+ // (they may have been computed from a different visible slice of the
1295
+ // same table). Take the max per column — widths only grow.
1296
+ let merged = newWidths;
1297
+ const mergeWith = (cached: number[]) => {
1298
+ const cols = Math.max(merged.length, cached.length);
1299
+ const wider: number[] = [];
1300
+ for (let c = 0; c < cols; c++) {
1301
+ wider.push(Math.max(merged[c] ?? 0, cached[c] ?? 0));
1302
+ }
1303
+ merged = wider;
1304
+ };
1305
+
1306
+ for (const line of group) {
1307
+ const cached = widthMap.get(line.line_number);
1308
+ if (cached) mergeWith(cached.maxW);
1309
+ }
1310
+
1311
+ // Also merge with adjacent cached lines above/below the group.
1312
+ // When mouse-scrolling, lines_changed only delivers NEW lines (not
1313
+ // previously seen), so the group may not overlap with earlier cached
1314
+ // rows of the same table. Scanning adjacently bridges the gap.
1315
+ const firstLine = group[0].line_number;
1316
+ const lastLine = group[group.length - 1].line_number;
1317
+ for (let ln = firstLine - 1; widthMap.has(ln); ln--) {
1318
+ mergeWith(widthMap.get(ln)!.maxW);
1319
+ }
1320
+ for (let ln = lastLine + 1; widthMap.has(ln); ln++) {
1321
+ mergeWith(widthMap.get(ln)!.maxW);
1322
+ }
1323
+
1324
+ // Compute allocated widths constrained to viewport
1325
+ const viewport = editor.getViewport();
1326
+ const composeW = config.composeWidth ?? (viewport ? viewport.width : 80);
1327
+ const numCols = merged.length;
1328
+ const available = composeW - (numCols + 1); // subtract pipe/box-drawing characters
1329
+ const allocated = distributeColumnWidths(merged, available);
1330
+
1331
+ // Check if adjacent cached lines had narrower allocated widths — if so,
1332
+ // they need their conceals recomputed (they were already rendered with
1333
+ // old widths and won't be re-delivered by lines_changed).
1334
+ const allocGrew = (old: TableWidthInfo) =>
1335
+ allocated.some((w, i) => w > (old.allocated[i] ?? 0));
1336
+ for (let ln = firstLine - 1; widthMap.has(ln); ln--) {
1337
+ if (allocGrew(widthMap.get(ln)!)) { needsRefresh = true; break; }
1338
+ }
1339
+ for (let ln = lastLine + 1; widthMap.has(ln); ln++) {
1340
+ if (allocGrew(widthMap.get(ln)!)) { needsRefresh = true; break; }
1341
+ }
1342
+
1343
+ // Store merged widths for all lines in the group AND propagate
1344
+ // back to adjacent cached lines so they pick up wider columns
1345
+ // without needing to be re-delivered by lines_changed.
1346
+ const info: TableWidthInfo = { maxW: merged, allocated };
1347
+ for (const line of group) {
1348
+ widthMap.set(line.line_number, info);
1349
+ }
1350
+ for (let ln = firstLine - 1; widthMap.has(ln); ln--) {
1351
+ widthMap.set(ln, info);
1352
+ }
1353
+ for (let ln = lastLine + 1; widthMap.has(ln); ln++) {
1354
+ widthMap.set(ln, info);
1355
+ }
1356
+ }
1357
+
1358
+ setTableWidths(bufferId, widthMap);
1359
+ return needsRefresh;
1360
+ }
1361
+
1362
+ // lines_changed: called for newly visible or invalidated lines
1363
+ globalThis.onMarkdownLinesChanged = function(data: {
517
1364
  buffer_id: number;
518
- split_id: number;
519
- viewport_start: number;
520
- viewport_end: number;
521
- tokens: ViewTokenWire[];
1365
+ lines: Array<{
1366
+ line_number: number;
1367
+ byte_start: number;
1368
+ byte_end: number;
1369
+ content: string;
1370
+ }>;
522
1371
  }): void {
523
- // Only transform when in compose mode
524
- if (!composeBuffers.has(data.buffer_id)) return;
1372
+ if (!isComposingInAnySplit(data.buffer_id)) return;
1373
+ const lineNums = data.lines.map(l => `${l.line_number}(${l.byte_start}..${l.byte_end})`).join(', ');
1374
+ editor.debug(`[mc] lines_changed: ${data.lines.length} lines: [${lineNums}]`);
1375
+ // Only use cursor positions for reveal/conceal decisions when the active
1376
+ // split is in compose mode. When a source-mode split is active, the cursor
1377
+ // lives in that source view — it should NOT trigger "reveal" (skip-conceal)
1378
+ // in the compose-mode split, because conceals are buffer-level decorations
1379
+ // shared across splits.
1380
+ const cursors = isComposing(data.buffer_id) ? [editor.getCursorPosition()] : [];
1381
+
1382
+ // Pre-compute table column widths for alignment.
1383
+ // If widths grew from merging with adjacent cached rows (e.g. after a
1384
+ // mouse-scroll jump), force a full re-render so already-visible lines
1385
+ // pick up the wider columns. The second pass will be a no-op (widths
1386
+ // already converged) so this doesn't loop.
1387
+ const tableWidthsGrew = processTableAlignment(data.buffer_id, data.lines);
1388
+
1389
+ for (const line of data.lines) {
1390
+ processLineConceals(data.buffer_id, line.content, line.byte_start, line.byte_end, cursors, line.line_number);
1391
+ processLineSoftBreaks(data.buffer_id, line.content, line.byte_start, line.byte_end, cursors, line.line_number);
1392
+ }
525
1393
 
526
- const info = editor.getBufferInfo(data.buffer_id);
527
- if (!info || !isMarkdownFile(info.path)) return;
1394
+ if (tableWidthsGrew) {
1395
+ editor.refreshLines(data.buffer_id);
1396
+ }
1397
+ };
1398
+
1399
+ // after_insert: no-op for conceals/overlays.
1400
+ // The edit automatically invalidates seen_byte_ranges for affected lines,
1401
+ // causing lines_changed to fire on the next render. processLineConceals
1402
+ // handles clearing and rebuilding atomically.
1403
+ // Marker-based positions auto-adjust with buffer edits, so existing conceals
1404
+ // remain visually correct until lines_changed rebuilds them.
1405
+ globalThis.onMarkdownAfterInsert = function(data: {
1406
+ buffer_id: number;
1407
+ position: number;
1408
+ text: string;
1409
+ affected_start: number;
1410
+ affected_end: number;
1411
+ }): void {
1412
+ if (!isComposingInAnySplit(data.buffer_id)) return;
1413
+ editor.debug(`[mc] after_insert: pos=${data.position} text="${data.text.replace(/\n/g,'\\n')}" affected=${data.affected_start}..${data.affected_end}`);
1414
+ };
1415
+
1416
+ // after_delete: no-op for conceals/overlays (same reasoning as after_insert).
1417
+ globalThis.onMarkdownAfterDelete = function(data: {
1418
+ buffer_id: number;
1419
+ start: number;
1420
+ end: number;
1421
+ deleted_text: string;
1422
+ affected_start: number;
1423
+ deleted_len: number;
1424
+ }): void {
1425
+ if (!isComposingInAnySplit(data.buffer_id)) return;
1426
+ editor.debug(`[mc] after_delete: start=${data.start} end=${data.end} deleted="${data.deleted_text.replace(/\n/g,'\\n')}" affected_start=${data.affected_start} deleted_len=${data.deleted_len}`);
1427
+ };
528
1428
 
529
- editor.debug(`onMarkdownViewTransform: buffer=${data.buffer_id}, split=${data.split_id}, tokens=${data.tokens.length}`);
530
-
531
- // Transform the incoming tokens with markdown-aware wrapping
532
- const transformedTokens = transformMarkdownTokens(
533
- data.tokens,
534
- config.composeWidth,
535
- data.viewport_start
536
- );
537
-
538
- // Submit the transformed tokens - keep composeWidth for margins/centering
539
- const layoutHints: LayoutHints = {
540
- composeWidth: config.composeWidth,
541
- columnGuides: null,
542
- };
543
-
544
- editor.submitViewTransform(
545
- data.buffer_id,
546
- data.split_id,
547
- data.viewport_start,
548
- data.viewport_end,
549
- transformedTokens,
550
- layoutHints
551
- );
1429
+ // cursor_moved: update cursor-aware reveal/conceal for old and new cursor lines
1430
+ globalThis.onMarkdownCursorMoved = function(data: {
1431
+ buffer_id: number;
1432
+ cursor_id: number;
1433
+ old_position: number;
1434
+ new_position: number;
1435
+ line: number;
1436
+ }): void {
1437
+ if (!isComposingInAnySplit(data.buffer_id)) return;
1438
+
1439
+ const prevLine = editor.getViewState(data.buffer_id, "last-cursor-line") as number | undefined;
1440
+ editor.setViewState(data.buffer_id, "last-cursor-line", data.line);
1441
+
1442
+ editor.debug(`[mc] cursor_moved: old_pos=${data.old_position} new_pos=${data.new_position} line=${data.line} prevLine=${prevLine}`);
1443
+
1444
+ // Always refresh: even intra-line movements need conceal updates because
1445
+ // auto-expose is span-level (cursor entering/leaving an emphasis or link
1446
+ // span within the same line must toggle its syntax markers).
1447
+ editor.refreshLines(data.buffer_id);
552
1448
  };
553
1449
 
1450
+ // view_transform_request is no longer needed — soft wrapping is handled by
1451
+ // marker-based soft breaks (computed in lines_changed), and layout hints
1452
+ // are set directly via setLayoutHints. This eliminates the one-frame flicker
1453
+ // caused by the async view_transform round-trip.
1454
+
554
1455
  // Handle buffer close events - clean up compose mode tracking
555
1456
  globalThis.onMarkdownBufferClosed = function(data: { buffer_id: number }): void {
556
- composeBuffers.delete(data.buffer_id);
1457
+ // View state is cleaned up automatically when the buffer is removed from keyed_states
1458
+ };
1459
+
1460
+ // viewport_changed: recalculate table column widths on terminal resize
1461
+ globalThis.onMarkdownViewportChanged = function(data: {
1462
+ split_id: number;
1463
+ buffer_id: number;
1464
+ top_byte: number;
1465
+ width: number;
1466
+ height: number;
1467
+ }): void {
1468
+ if (!isComposingInAnySplit(data.buffer_id)) return;
1469
+ if (data.width === lastViewportWidth) return;
1470
+ lastViewportWidth = data.width;
1471
+
1472
+ // Recompute allocated table column widths for new viewport width
1473
+ const bufWidths = getTableWidths(data.buffer_id);
1474
+ if (bufWidths) {
1475
+ const composeW = config.composeWidth ?? data.width;
1476
+ const seen = new Set<string>(); // Track by JSON key to deduplicate shared TableWidthInfo
1477
+ for (const [lineNum, info] of bufWidths) {
1478
+ const key = info.maxW.join(",");
1479
+ if (seen.has(key)) continue;
1480
+ seen.add(key);
1481
+ const numCols = info.maxW.length;
1482
+ const available = composeW - (numCols + 1);
1483
+ info.allocated = distributeColumnWidths(info.maxW, available);
1484
+ }
1485
+ setTableWidths(data.buffer_id, bufWidths);
1486
+ }
1487
+ editor.refreshLines(data.buffer_id);
1488
+ };
1489
+
1490
+ // Re-enable compose mode for buffers restored from a saved session.
1491
+ // The Rust side restores ViewMode::Compose and compose_width, but the plugin
1492
+ // needs to re-apply line numbers, line wrap, and layout hints when activated.
1493
+ globalThis.onMarkdownBufferActivated = function(data: { buffer_id: number }): void {
1494
+ const bufferId = data.buffer_id;
1495
+
1496
+ const info = editor.getBufferInfo(bufferId);
1497
+ if (!info || !isMarkdownFile(info.path)) return;
1498
+
1499
+ if (info.view_mode === "compose") {
1500
+ // Restore config.composeWidth from the persisted session value
1501
+ // before enabling compose mode, so enableMarkdownCompose uses
1502
+ // the correct width (same path as a fresh toggle).
1503
+ if (info.compose_width != null) {
1504
+ config.composeWidth = info.compose_width;
1505
+ }
1506
+ enableMarkdownCompose(bufferId);
1507
+ }
557
1508
  };
558
1509
 
559
1510
  // Register hooks
560
- editor.on("view_transform_request", "onMarkdownViewTransform");
1511
+ editor.on("lines_changed", "onMarkdownLinesChanged");
1512
+ editor.on("after_insert", "onMarkdownAfterInsert");
1513
+ editor.on("after_delete", "onMarkdownAfterDelete");
1514
+ editor.on("cursor_moved", "onMarkdownCursorMoved");
1515
+ // view_transform_request hook no longer needed — wrapping is handled by soft breaks
561
1516
  editor.on("buffer_closed", "onMarkdownBufferClosed");
1517
+ editor.on("viewport_changed", "onMarkdownViewportChanged");
562
1518
  editor.on("prompt_confirmed", "onMarkdownComposeWidthConfirmed");
1519
+ editor.on("buffer_activated", "onMarkdownBufferActivated");
563
1520
 
564
1521
  // Set compose width command - starts interactive prompt
565
1522
  globalThis.markdownSetComposeWidth = function(): void {
566
- editor.startPrompt(editor.t("prompt.compose_width"), "markdown-compose-width");
1523
+ const currentValue = config.composeWidth === null ? "None" : String(config.composeWidth);
1524
+ editor.startPromptWithInitial(editor.t("prompt.compose_width"), "markdown-compose-width", currentValue);
1525
+ editor.setPromptInputSync(true);
567
1526
  editor.setPromptSuggestions([
568
- { text: "60", description: editor.t("suggestion.narrow") },
569
- { text: "72", description: editor.t("suggestion.classic") },
570
- { text: "80", description: editor.t("suggestion.standard") },
571
- { text: "100", description: editor.t("suggestion.wide") },
1527
+ { text: "None", description: editor.t("suggestion.none") },
1528
+ { text: "120", description: editor.t("suggestion.default") },
572
1529
  ]);
573
1530
  };
574
1531
 
575
1532
  // Handle compose width prompt confirmation
576
1533
  globalThis.onMarkdownComposeWidthConfirmed = function(args: {
577
1534
  prompt_type: string;
578
- text: string;
1535
+ input: string;
579
1536
  }): void {
580
1537
  if (args.prompt_type !== "markdown-compose-width") return;
581
1538
 
582
- const width = parseInt(args.text, 10);
1539
+ const input = args.input.trim();
1540
+ if (input.toLowerCase() === "none") {
1541
+ config.composeWidth = null;
1542
+ editor.setStatus(editor.t("status.width_none"));
1543
+
1544
+ const bufferId = editor.getActiveBufferId();
1545
+ if (isComposing(bufferId)) {
1546
+ editor.setLayoutHints(bufferId, null, { composeWidth: null });
1547
+ editor.refreshLines(bufferId);
1548
+ }
1549
+ return;
1550
+ }
1551
+
1552
+ const width = parseInt(input, 10);
583
1553
  if (!isNaN(width) && width > 20 && width < 300) {
584
1554
  config.composeWidth = width;
585
1555
  editor.setStatus(editor.t("status.width_set", { width: String(width) }));
586
1556
 
587
1557
  // Re-process active buffer if in compose mode
588
1558
  const bufferId = editor.getActiveBufferId();
589
- if (composeBuffers.has(bufferId)) {
590
- editor.refreshLines(bufferId); // Trigger re-transform
1559
+ if (isComposing(bufferId)) {
1560
+ editor.setLayoutHints(bufferId, null, { composeWidth: config.composeWidth });
1561
+ editor.refreshLines(bufferId); // Trigger soft break recomputation
591
1562
  }
592
1563
  } else {
593
1564
  editor.setStatus(editor.t("status.invalid_width"));
@@ -599,14 +1570,14 @@ editor.registerCommand(
599
1570
  "%cmd.toggle_compose",
600
1571
  "%cmd.toggle_compose_desc",
601
1572
  "markdownToggleCompose",
602
- "normal"
1573
+ null
603
1574
  );
604
1575
 
605
1576
  editor.registerCommand(
606
1577
  "%cmd.set_compose_width",
607
1578
  "%cmd.set_compose_width_desc",
608
1579
  "markdownSetComposeWidth",
609
- "normal"
1580
+ null
610
1581
  );
611
1582
 
612
1583
  // Initialization