summaryrefslogtreecommitdiff
path: root/src/file_pane.zig
blob: e0150de297fc1ea40ea0634c964f789a90afa80c (plain) (blame)
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
//! File panes: everything a Pane does BECAUSE it has `file: ?State` set — the
//! disk read, the content swap undo/redo commits through, the tree-sitter
//! highlight window, and the two render passes only a file has (the line
//! number gutter and the syntax recolor). The rest of a file pane's behaviour
//! is the pane machinery in pardes.zig, which does not care what kind it is.
const std = @import("std");
const vaxis = @import("vaxis");
const pardes = @import("pardes.zig");
const config = @import("config.zig");
const Pardes = pardes.Pardes;
const Pane = pardes.Pane;
const modal = @import("modal.zig");
const look = @import("look.zig");
const output_pane = @import("output_pane.zig");
const syntax = @import("syntax.zig");
const tracy = @import("tracy.zig");
const term_pane = @import("term_pane.zig");
const dump = @import("dump.zig");

const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2;
/// EDIT BOUNDARIES REMEMBERED PER FILE PANE. Every entry owns a gpa copy of
/// the WHOLE file, so this number multiplies heap, not just the pane: 256 of
/// them is not a bound a 384 KiB board could ever reach anyway. `pushHistory`
/// evicts and frees the oldest once full, so the smaller ring loses the
/// deepest undo steps and nothing else — no truncation, no dropped edit.
const undo_max = if (@import("pardes_config").platform == .p4) 16 else 256;

/// Content and primary selection at one file edit boundary. Keeping only the
/// primary avoids putting pardes.MAX_SELS ranges in every history entry.
pub const Snapshot = struct {
    content: []u8,
    cur_row: i32,
    cur_col: i32,
    vsel: pardes.CharSel,
};

/// A file pane's backing: owned content, its derived caches, and undo history.
pub const State = struct {
    path: []u8,
    content: []u8,
    /// Monotonic content identity for asynchronous edits. Every content swap
    /// goes through setContent, which bumps this; a pipe completion accepted
    /// against another revision would overwrite intervening work.
    revision: u32 = 0,
    /// Revision last known to match disk, after Save or an external reload.
    /// Equal means the screen matches disk.
    saved_revision: u32 = 0,
    /// Non-null for a generated output buffer rather than an on-disk file.
    output: ?output_pane.Output = null,
    scroll: usize = 0,
    /// `line_starts[i]` is line i's byte offset. Empty means not built yet.
    line_starts: []usize = &.{},
    /// One tree_sitter_gpa-owned syntax.Syn byte per highlighted source byte.
    highlights: []u8 = &.{},
    highlight_start: usize = 0,
    syntax_dirty: bool = true,
    undo: [undo_max]Snapshot = undefined,
    undo_len: usize = 0,
    redo: [undo_max]Snapshot = undefined,
    redo_len: usize = 0,
};

/// Serialize file-owned bytes and identity; output origin vocabulary is
/// supplied by output_pane at the core dispatch edge.
pub fn dumpPane(
    arena: std.mem.Allocator,
    pane: *const Pane,
    file: *const State,
    tag: []const u8,
    body: []const u8,
    scroll: usize,
    origin: []const u8,
    origin_arg: []const u8,
) !dump.Pane {
    return .{
        .kind = .file,
        .tag = tag,
        .body = body,
        .scroll = scroll,
        .cols = pane.cols,
        .rows = pane.rows,
        .vweight = pane.vweight,
        .file = .{
            .path = file.path,
            .content = file.content,
            .content_b64 = try dump.encodeBytes(arena, file.content),
            .origin = origin,
            .origin_arg = origin_arg,
        },
    };
}

pub fn graphemeDisplayWidth(grapheme: []const u8) usize {
    if (std.mem.eql(u8, grapheme, "\t")) return config.tab_width;
    // A one-byte printable ASCII grapheme is one cell, and saying so here rather than asking
    // `gwidth` costs a comparison instead of a Unicode table walk. `gwidth` was 6.9% of a profiled
    // keystroke at the P4's geometry, essentially all of it answering this question about `y`.
    // Bounded to 0x20..0x7e on purpose: DEL and the C0 controls are not one printable cell, and
    // `gwidth` is still the authority on them.
    if (grapheme.len == 1 and grapheme[0] >= 0x20 and grapheme[0] < 0x7f) return 1;
    return @max(1, @as(usize, vaxis.gwidth.gwidth(grapheme, .unicode)));
}

pub fn byteDisplayWidth(byte: u8) usize {
    return if (byte == '\t') config.tab_width else 1;
}

pub fn displayWidth(text: []const u8) usize {
    var width: usize = 0;
    var at: usize = 0;
    while (at < text.len) {
        const end = modal.nextGrapheme(text, at);
        width +|= graphemeDisplayWidth(text[at..end]);
        at = end;
    }
    return width;
}

/// Source byte at a zero-based display column. Every cell occupied by a tab
/// maps back to that one tab byte.
pub fn byteAtDisplay(text: []const u8, display_col: usize) usize {
    var col: usize = 0;
    var at: usize = 0;
    while (at < text.len) {
        const end = modal.nextGrapheme(text, at);
        const next = col +| graphemeDisplayWidth(text[at..end]);
        if (display_col < next) return at;
        col = next;
        at = end;
    }
    return text.len;
}

/// File cursor columns may live past EOL. Tabs expand before that boundary;
/// every virtual column after it remains one screen cell.
pub fn rawDisplayCol(line_text: []const u8, raw_col: usize) usize {
    const bounded = modal.graphemeStart(line_text, @min(raw_col, line_text.len));
    return displayWidth(line_text[0..bounded]) +| (raw_col -| line_text.len);
}

pub fn rawAtDisplay(line_text: []const u8, display_col: usize) usize {
    const width = displayWidth(line_text);
    if (display_col > width) return line_text.len +| (display_col - width);
    return byteAtDisplay(line_text, display_col);
}

pub fn byteAtDisplayFrom(line_text: []const u8, from_raw: usize, display_col: usize) usize {
    if (from_raw >= line_text.len) return from_raw +| display_col;
    const from = modal.graphemeStart(line_text, from_raw);
    return from +| rawAtDisplay(line_text[from..], display_col);
}

pub fn lineDisplayOffset(line_text: []const u8, from_raw: usize, to_raw: usize) i32 {
    const from_display = rawDisplayCol(line_text, from_raw);
    const to_display = rawDisplayCol(line_text, to_raw);
    if (to_display >= from_display) return @intCast(to_display - from_display);
    return -@as(i32, @intCast(from_display - to_display));
}

pub fn lineDisplayEndOffset(line_text: []const u8, from_raw: usize, at_raw: usize) i32 {
    const start = lineDisplayOffset(line_text, from_raw, at_raw);
    if (at_raw >= line_text.len) return start;
    const at = modal.graphemeStart(line_text, at_raw);
    const end = modal.nextGrapheme(line_text, at);
    return start + @as(i32, @intCast(graphemeDisplayWidth(line_text[at..end]))) - 1;
}

pub fn sourceLine(pane: *const Pane, row: i32) []const u8 {
    const f = pane.file orelse return "";
    if (row < 0) return "";
    return modal.lineSlice(f.content, @intCast(row));
}

pub fn displayOffset(pane: *const Pane, row: i32, from_raw: i32, to_raw: i32) i32 {
    const line_text = sourceLine(pane, row);
    const from: usize = @intCast(@max(0, from_raw));
    const to: usize = @intCast(@max(0, to_raw));
    return lineDisplayOffset(line_text, from, to);
}

pub fn displayEndOffset(pane: *const Pane, row: i32, from_raw: i32, at_raw: i32) i32 {
    const line_text = sourceLine(pane, row);
    return lineDisplayEndOffset(line_text, @intCast(@max(0, from_raw)), @intCast(@max(0, at_raw)));
}

pub fn byteAtRowDisplay(pane: *const Pane, row: i32, from_raw: i32, display_col: i32) i32 {
    const line_text = sourceLine(pane, row);
    return @intCast(byteAtDisplayFrom(line_text, @intCast(@max(0, from_raw)), @intCast(@max(0, display_col))));
}

/// Convert between rendered cells and UTF-8 byte columns. Tag rows always
/// need grapheme conversion; file body rows additionally skip PREFIX_W.
pub fn renderedLineByteCol(pane: *const Pane, row: i32, line_text: []const u8, display_col: usize) usize {
    if (row < pardes.BOX_H) return rawAtDisplay(line_text, display_col);
    if (pane.file == null) return rawAtDisplay(line_text, display_col);
    const prefix = @min(@as(usize, config.PREFIX_W), line_text.len);
    if (display_col <= prefix) return display_col;
    return prefix +| rawAtDisplay(line_text[prefix..], display_col - prefix);
}

pub fn renderedLineDisplayCol(pane: *const Pane, row: i32, line_text: []const u8, byte_col: usize) usize {
    if (row < pardes.BOX_H) return rawDisplayCol(line_text, byte_col);
    if (pane.file == null) return rawDisplayCol(line_text, byte_col);
    const prefix = @min(@as(usize, config.PREFIX_W), line_text.len);
    if (byte_col <= prefix) return byte_col;
    return prefix +| rawDisplayCol(line_text[prefix..], byte_col - prefix);
}

test "display columns map complete Unicode graphemes" {
    const text = "é界e\u{301}x";
    try std.testing.expectEqual(@as(usize, 5), displayWidth(text));
    try std.testing.expectEqual(@as(usize, 0), byteAtDisplay(text, 0));
    try std.testing.expectEqual(@as(usize, 2), byteAtDisplay(text, 1));
    try std.testing.expectEqual(@as(usize, 2), byteAtDisplay(text, 2));
    try std.testing.expectEqual(@as(usize, 5), byteAtDisplay(text, 3));
    try std.testing.expectEqual(@as(usize, 8), byteAtDisplay(text, 4));
    try std.testing.expectEqual(text.len, byteAtDisplay(text, 5));
    try std.testing.expectEqual(@as(usize, 3), rawDisplayCol(text, 5));
    try std.testing.expectEqual(@as(usize, 5), rawAtDisplay(text, 3));
    try std.testing.expectEqual(@as(usize, 2), graphemeDisplayWidth("👩\u{200d}🚀"));
}

fn fitEnd(text: []const u8, start: usize, width: usize) usize {
    var end = start;
    var used: usize = 0;
    // ASCII RUN. This is the loop a wrapped line pays per character, and it asks two function calls
    // to learn what arithmetic knows: `modal.nextGrapheme` and `graphemeDisplayWidth` each answer
    // ASCII in constant time, but they answer once per character and a 640-column line asks 640
    // times. A printable ASCII byte whose successor is also ASCII is a complete grapheme cluster one
    // column wide - the same guard, and the same reason, as `Surface.print` and `modal.nextGrapheme`
    // - so consume the run here and leave anything else to the general path below.
    while (used < width and end < text.len) {
        const b = text[end];
        if (b < 0x20 or b >= 0x7f) break;
        if (end + 1 < text.len and text[end + 1] >= 0x80) break;
        used += 1;
        end += 1;
    }
    while (end < text.len) {
        const next_end = modal.nextGrapheme(text, end);
        const next_used = used +| graphemeDisplayWidth(text[end..next_end]);
        if (next_used > width) return if (end == start) next_end else end;
        used = next_used;
        end = next_end;
    }
    return end;
}

test "the ASCII run in fitEnd cuts where the grapheme walk would" {
    // fitEnd decides where a wrapped row BREAKS, so a fast path that is off by one column moves
    // text on screen. This pins it to the general walk it replaces rather than to a transcribed
    // expectation: same inputs, both routes, every width from 0 past the end of the string.
    const reference = struct {
        fn fitEnd(text: []const u8, start: usize, width: usize) usize {
            var end = start;
            var used: usize = 0;
            while (end < text.len) {
                const next_end = modal.nextGrapheme(text, end);
                const next_used = used +| graphemeDisplayWidth(text[end..next_end]);
                if (next_used > width) return if (end == start) next_end else end;
                used = next_used;
                end = next_end;
            }
            return end;
        }
    }.fitEnd;

    const cases = [_][]const u8{
        "",
        "hello world",
        // the fast path must hand over at the first non-ASCII byte, mid-run
        "abc\u{00e9}def",
        // a wide glyph is two columns, so a width boundary can land inside it
        "ab\u{4e16}\u{754c}cd",
        // a cluster the fast path must not split
        "a\u{0301}bc",
        // tabs and controls are excluded from the fast path by the range test
        "ab\tcd",
        "ab\rcd",
        // an ASCII byte followed by a continuation byte is NOT its own cluster
        "e\u{0301}x",
        "\u{1f1e6}\u{1f1e7}ok",
    };
    for (cases) |text| {
        var width: usize = 0;
        while (width <= text.len + 3) : (width += 1) {
            var start: usize = 0;
            while (start <= text.len) : (start += 1) {
                try std.testing.expectEqual(
                    reference(text, start, width),
                    fitEnd(text, start, width),
                );
            }
        }
    }
}

pub fn lineCount(content: []const u8) usize {
    return std.mem.count(u8, content, "\n") + 1;
}

/// THE LINE INDEX, built on demand: `line_starts[i]` is the byte offset where
/// line i begins and its length is the line count. Without it, every question
/// about lines is a scan from byte 0, and a file pane asks several of them per
/// keystroke — the scrollbar's total, the scroll clamp, the syntax window's
/// bounds, the body's first visible line. On a 300k-line file that was ~35% of
/// the whole frame, and it is what made a single `j` cost 25ms.
///
/// INVALIDATION — the part that rots if nobody says it out loud. The index is
/// dropped in EXACTLY ONE PLACE: setContent, immediately below, which is the
/// funnel every content swap in the editor already goes through (typing, undo,
/// redo, a save's normalisation, an output buffer refilling itself). A State
/// built by a struct literal starts with an empty index, and empty reads as
/// "not built yet" — a real index always has at least one entry, because a
/// file always has at least one line. So there is one and only one way to make
/// this wrong: assign `f.content` without going through setContent. Don't.
///
/// Fails only when the index could not be allocated. nlines and lineStart
/// swallow that and scan the old way, so OOM there is slow rather than wrong;
/// callers that need the whole table say `try` and drop the keystroke, which
/// is what they already did when their own arena ran out.
pub fn lineIndex(gpa: std.mem.Allocator, f: *State) ![]const usize {
    if (f.line_starts.len > 0) return f.line_starts;
    // Exact allocation: deinitPane frees `line_starts` itself, so the stored
    // slice must span the complete allocation rather than spare capacity.
    const starts = try gpa.alloc(usize, lineCount(f.content));
    starts[0] = 0;
    var i: usize = 1;
    var off: usize = 0;
    while (std.mem.indexOfScalarPos(u8, f.content, off, '\n')) |nl| {
        off = nl + 1;
        starts[i] = off;
        i += 1;
    }
    f.line_starts = starts;
    return starts;
}

/// line count, O(1) once the index is warm
pub fn nlines(gpa: std.mem.Allocator, f: *State) usize {
    const idx = lineIndex(gpa, f) catch return lineCount(f.content);
    return idx.len;
}

/// byte offset of line `row`, or content.len past the end — modal
/// .lineStartOffset's contract exactly, without its walk
pub fn lineStart(gpa: std.mem.Allocator, f: *State, row: usize) usize {
    const idx = lineIndex(gpa, f) catch return modal.lineStartOffset(f.content, row);
    return if (row >= idx.len) f.content.len else idx[row];
}

pub fn cursorLines(arena: std.mem.Allocator, pane: *Pane, f: *State) ![]const []const u8 {
    const index = try lineIndex(pane.gpa, f);
    const lines = try arena.alloc([]const u8, index.len);
    for (index, 0..) |start, i| {
        const end = if (i + 1 < index.len) index[i + 1] - 1 else f.content.len;
        lines[i] = f.content[start..end];
    }
    return lines;
}

/// Use the file's line index only when `text` is its complete live content.
/// Edit-buffer fragments and other temporary text retain modal's scan path.
fn contentIndex(pane: *Pane, text: []const u8) ?[]const usize {
    const f = if (pane.file) |*file| file else return null;
    if (text.ptr != f.content.ptr or text.len != f.content.len) return null;
    return lineIndex(pane.gpa, f) catch null;
}

pub fn textOffset(pane: *Pane, text: []const u8, cursor: modal.Cursor) usize {
    const index = contentIndex(pane, text) orelse return modal.hxOff(text, cursor);
    const row = @min(cursor.row, index.len - 1);
    const start = index[row];
    const end = if (row + 1 < index.len) index[row + 1] - 1 else text.len;
    return start + modal.graphemeStart(text[start..end], @min(cursor.col, end - start));
}

pub fn textLineStart(pane: *Pane, text: []const u8, row: usize) usize {
    const index = contentIndex(pane, text) orelse return modal.lineStartOffset(text, row);
    return if (row >= index.len) text.len else index[row];
}

pub fn textLineCount(pane: *Pane, text: []const u8) usize {
    const index = contentIndex(pane, text) orelse return modal.hxLineCount(text);
    return index.len;
}

pub fn textPosition(pane: *Pane, text: []const u8, offset: usize) modal.Cursor {
    const index = contentIndex(pane, text) orelse return modal.hxPos(text, offset);
    const bounded = @min(offset, text.len);
    const row = std.sort.upperBound(usize, index, bounded, struct {
        fn cmp(key: usize, item: usize) std.math.Order {
            return std.math.order(key, item);
        }
    }.cmp) - 1;
    const start = index[row];
    const end = if (row + 1 < index.len) index[row + 1] - 1 else text.len;
    return .{ .row = row, .col = modal.graphemeStart(text[start..end], @min(bounded - start, end - start)) };
}

pub fn open(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane {
    const content = try look.readFile(p.gpa, path);
    errdefer p.gpa.free(content);
    const path_copy = try p.gpa.dupe(u8, path);
    errdefer p.gpa.free(path_copy);
    const pane = try p.newDocPane(id);
    const total = lineCount(content);
    const scroll: usize = if (line > 0 and line <= total) line - 1 else 0;
    pane.file = .{ .path = path_copy, .content = content, .scroll = scroll };
    pane.cur_pinned = true;
    pane.cur_row = @intCast(scroll);
    // watches follow pane lifetime: this is the only place a real file is read
    // off disk, and deinitPane is the only place one goes away
    p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } });
    return pane;
}

/// Rebuild a dumped file or output buffer. Byte ownership, output identity,
/// cursor projection, and file watching are all properties of this payload;
/// column registration and custom tag restoration remain core invariants.
pub fn restore(p: *Pardes, id: usize, src: dump.Pane) !*Pane {
    const saved = src.file.?;
    const content: []u8 = if (saved.content_b64.len > 0)
        try dump.decodeBytes(p.gpa, saved.content_b64)
    else
        try p.gpa.dupe(u8, saved.content);
    errdefer p.gpa.free(content);
    const path = try p.gpa.dupe(u8, saved.path);
    errdefer p.gpa.free(path);

    const output: ?output_pane.Output = if (output_pane.fromWord(saved.origin)) |origin| blk: {
        var value: output_pane.Output = .{ .from = origin };
        try output_pane.setArg(&value, saved.origin_arg);
        break :blk value;
    } else null;

    const pane = try p.newDocPane(id);
    pane.file = .{ .path = path, .content = content, .output = output, .scroll = src.scroll };
    pane.cur_pinned = true;
    pane.cur_row = @intCast(src.scroll);
    pane.cols = @max(1, src.cols);
    pane.rows = @max(1, src.rows);
    // A restored file is watched exactly like one opened from disk. Its dump
    // bytes may differ from disk; the first external write reconciles them and
    // leaves the restored version one undo away. Output buffers have no file.
    if (output == null) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } });
    return pane;
}

/// Release the complete file payload while the owning pane is still installed
/// (the slot is needed to identify a disappearing file watch). Common pane
/// overlays and the shared terminal stub remain the core's responsibility.
pub fn deinit(p: *Pardes, pane: *Pane, file: *State) void {
    if (file.output == null) for (p.panes, 0..) |slot, id| {
        if (slot == pane) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } });
    };
    p.gpa.free(file.path);
    p.gpa.free(file.content);
    if (file.line_starts.len > 0) p.gpa.free(file.line_starts);
    if (file.highlights.len > 0) p.tree_sitter_gpa.free(file.highlights);
    for (file.undo[0..file.undo_len]) |snap| p.gpa.free(snap.content);
    for (file.redo[0..file.redo_len]) |snap| p.gpa.free(snap.content);
}

/// TELL A SCRIPT WHAT CHANGED, when one is listening.
///
/// acme reports edits from the two places that make them — `textinsert` and
/// `textdelete`, which already know their range — so a replacement arrives as
/// a `D` record and then an `I`. pardes has no such pair: every edit lands
/// here as a whole new buffer, so the range is recovered by DIFFING, and
/// `acmefs.noteReplace` owns both the diff and the D-then-I order.
///
/// The cost is two vectorised scans of the content, and it is paid only while
/// a script holds an `event` file open (`p.fs.listeners`); the editor nobody
/// is scripting does one branch. The pane lookup is a walk of at most
/// MAX_PANES slots comparing the FILE pointer — a file pane's state is stored
/// inline in its pane, so that identifies the pane exactly.
fn reportEdit(p: *Pardes, f: *State, new: []const u8) void {
    if (p.fs.listeners == 0) return;
    const id = for (p.panes, 0..) |slot, i| {
        const pane = slot orelse continue;
        if (pane.file) |*state| if (state == f) break i;
    } else return;
    pardes.acmefs.noteReplace(p, id, false, f.content, new);
}

/// The ONE content swap. Everything that edits a file pane lands here, which
/// is what lets the line index above have a single invalidation point — and
/// is why one diff HERE is every body edit a script can be told about.
pub fn setContent(p: *Pardes, f: *State, new: []u8) void {
    reportEdit(p, f, new);
    p.gpa.free(f.content);
    f.content = new;
    f.revision +%= 1;
    if (f.line_starts.len > 0) p.gpa.free(f.line_starts);
    f.line_starts = &.{};
    // the highlights go too, and not just because they are stale: their byte
    // range is what refreshHighlights tests a scroll against, and a range
    // measured on the OLD content would let it skip a re-parse it needs
    if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights);
    f.highlights = &.{};
    f.highlight_start = 0;
    f.syntax_dirty = true;
}

/// undo/redo restores the selection recorded with the snapshot (helix keeps
/// selections in its history transactions), clamped: the content it was taken
/// against may be shorter than the one it is being restored onto.
pub fn restoreSnap(pane: *Pane, f: *State, snap: Snapshot) void {
    const n = nlines(pane.gpa, f);
    const row: usize = @min(@as(usize, @intCast(@max(0, snap.cur_row))), n - 1);
    const llen = modal.lineSlice(f.content, row).len;
    pane.cur_row = @intCast(row);
    pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, snap.cur_col))), llen));
    pane.vsel = snap.vsel;
    pane.msel.active = false;
    pane.cur_pinned = true;
    pane.sticky_col = -1;
    pane.ensureCursorVisible();
}

fn pushHistory(gpa: std.mem.Allocator, slots: []Snapshot, len: *usize, snap: Snapshot) void {
    if (len.* == slots.len) {
        gpa.free(slots[0].content);
        std.mem.copyForwards(Snapshot, slots[0 .. slots.len - 1], slots[1..]);
        len.* -= 1;
    }
    slots[len.*] = snap;
    len.* += 1;
}

pub fn pushUndo(p: *Pardes, pane: *Pane) void {
    const f = if (pane.file) |*file| file else return;
    if (f.undo_len > 0 and std.mem.eql(u8, f.undo[f.undo_len - 1].content, f.content)) return;
    const snap: Snapshot = .{
        .content = p.gpa.dupe(u8, f.content) catch return,
        .cur_row = pane.cur_row,
        .cur_col = pane.cur_col,
        .vsel = pane.vsel,
    };
    pushHistory(p.gpa, &f.undo, &f.undo_len, snap);
    for (f.redo[0..f.redo_len]) |item| p.gpa.free(item.content);
    f.redo_len = 0;
}

pub fn undo(p: *Pardes, pane: *Pane) void {
    const f = if (pane.file) |*file| file else return;
    if (f.undo_len == 0) return;
    const current: Snapshot = .{
        .content = p.gpa.dupe(u8, f.content) catch return,
        .cur_row = pane.cur_row,
        .cur_col = pane.cur_col,
        .vsel = pane.vsel,
    };
    pushHistory(p.gpa, &f.redo, &f.redo_len, current);
    f.undo_len -= 1;
    const previous = f.undo[f.undo_len];
    setContent(p, f, previous.content);
    restoreSnap(pane, f, previous);
}

pub fn redo(p: *Pardes, pane: *Pane) void {
    const f = if (pane.file) |*file| file else return;
    if (f.redo_len == 0) return;
    const current: Snapshot = .{
        .content = p.gpa.dupe(u8, f.content) catch return,
        .cur_row = pane.cur_row,
        .cur_col = pane.cur_col,
        .vsel = pane.vsel,
    };
    pushHistory(p.gpa, &f.undo, &f.undo_len, current);
    f.redo_len -= 1;
    const next = f.redo[f.redo_len];
    setContent(p, f, next.content);
    restoreSnap(pane, f, next);
}

/// Commit an externally rewritten file onto the same undo history as typed
/// edits. Unsaved work remains one `u` away; there is no third merge state.
pub fn changed(p: *Pardes, id: u8, bytes: []const u8) void {
    const pane = p.panes[id] orelse return;
    const f = if (pane.file) |*file| file else return;
    if (std.mem.eql(u8, f.content, bytes)) return;
    const new = p.gpa.dupe(u8, bytes) catch return;
    pushUndo(p, pane);
    setContent(p, f, new);
    // These bytes came from the watched path, so the new on-screen revision
    // is already saved. Undoing back to displaced local work bumps revision
    // again and makes that restored edit dirty, as it should.
    f.saved_revision = f.revision;
    // restoreSnap only consumes cursor/selection from this synthetic snapshot.
    restoreSnap(pane, f, .{
        .content = undefined,
        .cur_row = pane.cur_row,
        .cur_col = pane.cur_col,
        .vsel = pane.vsel,
    });
}

/// re-highlight the visible window of any file whose syntax went stale
/// (edit, scroll, load) — visible-range-first so big files stay snappy
pub fn refreshHighlights(p: *Pardes) void {
    const tz = tracy.zone(@src(), "refreshHighlights");
    defer tz.end();
    for (p.panes) |slot| {
        const pane = slot orelse continue;
        if (pane.file == null) continue;
        const f = &pane.file.?;
        if (!f.syntax_dirty) continue;
        if (!p.settings.colors) {
            if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights);
            f.highlights = &.{};
            f.highlight_start = 0;
            f.syntax_dirty = false;
            continue;
        }
        // What the screen needs coloured right now. If the last parse still
        // covers it, this scroll is free — and that is the whole point of the
        // slack below. Highlights only ever survive while the CONTENT does:
        // setContent throws them away, so these byte offsets cannot be stale.
        const need_start = lineStart(p.gpa, f, f.scroll);
        const need_end = @max(need_start, lineStart(p.gpa, f, f.scroll + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS));
        if (f.highlights.len > 0 and need_start >= f.highlight_start and
            need_end <= f.highlight_start + f.highlights.len)
        {
            f.syntax_dirty = false;
            continue;
        }
        // How much MORE than the screen to parse. An edit or a fresh open has
        // no previous parse to widen (setContent throws the highlights away),
        // and slack would be pure loss there: every keystroke of typing pays
        // this parse and none of it is amortised over anything. A SCROLL that
        // outran the covered range is the opposite case — take a screenful
        // above and below and the next ~pane.rows rows cost nothing at all.
        // Scrolling used to re-parse the visible window on every single row,
        // which on a file with 8000-column lines is a third of a megabyte per
        // keypress. Three screens once beats one screen forty times.
        //
        // The slack also means those lines are parsed with real context above
        // them, so a construct that opens off-screen now colours correctly —
        // a fidelity gain, and one that cannot reach a file shown from the top
        // (scroll 0 clamps the window to exactly what it always was).
        const slack: usize = if (f.highlights.len == 0) 0 else pane.rows;
        const start = lineStart(p.gpa, f, f.scroll -| slack);
        const end = @max(start, lineStart(p.gpa, f, f.scroll + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS + slack));
        const new_highlights = (switch (pane.colorAlgo()) {
            .diff => syntax.highlightDiff(p.tree_sitter_gpa, f.content, start, end),
            else => syntax.highlightFileRange(p.tree_sitter_gpa, f.path, f.content, start, end),
        }) catch {
            f.syntax_dirty = false;
            continue;
        };
        if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights);
        f.highlights = new_highlights;
        f.highlight_start = if (f.highlights.len > 0) start else 0;
        f.syntax_dirty = false;
    }
}

/// The width a wrapped row of THIS pane holds, in cells, or 0 when the pane is
/// not wrapping — the render decision, named once so motion cannot disagree
/// with paint. One column is left for the break marker: a row that filled its
/// last cell would have nowhere to say it continues. A pane taller than the
/// map refuses to wrap rather than record part of itself (see Pane.wrap_line).
pub fn wrapWidth(pane: *const Pane, wrap: bool) usize {
    if (!wrap or pane.rows > pane.wrap_line.len) return 0;
    return @max(1, @as(usize, pane.cols -| config.PREFIX_W) -| 1);
}

pub const VisualRow = struct { start: usize, end: usize };

/// The visual row of `line` holding byte `col`: `[start, end)`, where `end` is
/// where the next visual row of the same line begins and equals `line.len` on
/// the last one. This is the same walk `fillBody` renders with, so `gj`/`gk`
/// step exactly the breaks a reader sees. `width == 0` (not wrapping) makes
/// the whole line one visual row, which is what collapses visual motion onto
/// textual motion instead of special-casing it upstream.
pub fn visualRow(line: []const u8, col: usize, width: usize) VisualRow {
    if (width == 0) return .{ .start = 0, .end = line.len };
    var start: usize = 0;
    while (true) {
        const end = fitEnd(line, start, width);
        if (col < end or end >= line.len) return .{ .start = start, .end = end };
        start = end;
    }
}

test "visual rows partition a line at the breaks the body renders" {
    const line = "abcdefgh";
    try std.testing.expectEqual(VisualRow{ .start = 0, .end = line.len }, visualRow(line, 5, 0));
    try std.testing.expectEqual(VisualRow{ .start = 0, .end = 3 }, visualRow(line, 0, 3));
    try std.testing.expectEqual(VisualRow{ .start = 0, .end = 3 }, visualRow(line, 2, 3));
    try std.testing.expectEqual(VisualRow{ .start = 3, .end = 6 }, visualRow(line, 3, 3));
    // Past the end (a cursor on the newline) names the LAST row, and a short
    // line is one row however narrow the pane is.
    try std.testing.expectEqual(VisualRow{ .start = 6, .end = 8 }, visualRow(line, line.len, 3));
    try std.testing.expectEqual(VisualRow{ .start = 0, .end = 0 }, visualRow("", 0, 3));
    // A grapheme wider than the row still occupies exactly one row.
    try std.testing.expectEqual(VisualRow{ .start = 0, .end = 4 }, visualRow("👩x", 0, 1));
}

/// the body a file pane renders: `pane.rows` SCREEN rows from the scroll
/// offset, each behind its right-aligned line number, then cut by hscroll.
///
/// With `wrap` on a line too long for the pane takes several rows instead of
/// running off the right edge, and this is where that happens — it is a render
/// property, and the one thing the rest of the editor reads back is the map:
/// which line each row showed and at which byte column it began, recorded into
/// pane.wrap_line/wrap_col as the rows are built. `wrap_n` stays 0 for an
/// unwrapped body, and that is the value the readers treat as "rows are
/// lines", so the off path never consults an array.
pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *State, wrap: bool) ![]const u8 {
    const width = wrapWidth(pane, wrap);
    pane.wrap_n = 0;

    // Count the exact rendered bytes first. Unwrapped source lines are not
    // bounded by the pane width, so a rows*cols buffer would either truncate
    // them or quietly restore a growable builder under another name.
    const len = fillBody(null, pane, f, width, false);
    const out = try arena.alloc(u8, len);
    const filled = fillBody(out, pane, f, width, true);
    std.debug.assert(filled == out.len);
    return out;
}

/// Run the file-body row walk. With no destination it is the exact sizing
/// pass; with one it fills that allocation and records the wrapping map.
fn fillBody(dst: ?[]u8, pane: *Pane, f: *State, width: usize, record_wrap: bool) usize {
    if (record_wrap) pane.wrap_n = 0;
    // start ON the first visible line instead of walking the file to it: this
    // walk was O(f.scroll) and recolorSyntax below ran the identical one again
    var flines = std.mem.splitScalar(u8, f.content[lineStart(pane.gpa, f, f.scroll)..], '\n');
    // scrolled past EOF (an edit shortened the file under a stale scroll): the
    // old walk left the iterator dry, so drop the one empty line a slice split
    // still yields, or the body grows a phantom numbered row
    if (f.scroll >= nlines(pane.gpa, f)) _ = flines.next();
    // the line the NEXT row comes from and the byte column of it that row
    // starts at — the two the map records, walked forward by the loop
    var abs: i32 = @intCast(f.scroll);
    var at: usize = 0;
    var cur = flines.next();
    var written: usize = 0;
    for (0..pane.rows) |i| {
        if (i > 0) {
            if (dst) |out| out[written] = '\n';
            written += 1;
        }
        if (width > 0 and record_wrap) {
            pane.wrap_line[i] = abs;
            pane.wrap_col[i] = @intCast(at);
            pane.wrap_n = @intCast(i + 1);
        }
        if (cur) |text| {
            var lbuf: [16]u8 = undefined;
            // unsigned: {d} prints a leading '+' for signed ints
            const lineno: usize = @intCast(abs + 1);
            // the number belongs to the LINE, so only its first row carries
            // one — repeated down a wrapped line it would read as several
            // lines, which is exactly what this is not
            const prefix = if (at > 0)
                "     "
            else
                std.fmt.bufPrint(&lbuf, "{d: >4} ", .{lineno}) catch "     ";
            if (dst) |out| @memcpy(out[written..][0..prefix.len], prefix);
            written += prefix.len;

            // Wrap and horizontal-scroll cuts are always grapheme boundaries.
            // Source columns remain byte offsets, while widths are terminal
            // cells; keeping the conversion here prevents a view operation
            // from manufacturing malformed UTF-8.
            const end = if (width == 0) text.len else fitEnd(text, at, width);
            const take = end - at;
            const cut = if (pane.hscroll > 0 and width == 0)
                modal.graphemeStart(text[at..end], @min(@as(usize, @intCast(pane.hscroll)), take))
            else
                0;
            const shown = text[at + cut .. end];
            if (dst) |out| @memcpy(out[written..][0..shown.len], shown);
            written += shown.len;
            if (width > 0 and end < text.len) {
                at = end;
            } else {
                abs += 1;
                at = 0;
                cur = flines.next();
            }
        } else abs += 1;
    }
    return written;
}

/// line-number gutter: mute the first PREFIX_W columns. Cheap chrome, not
/// gated on settings.colors; selection/cursor passes still win. The cursor row's
/// number takes the tag style (same row math as renderPane's cursor pass) so
/// the eye finds the current line.
pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, active: bool) void {
    const s = &p.surface;
    const ch = p.chromeTheme();
    const goff = pane.scroll();
    const gcur = term_pane.gridCursor(pane);
    const gcrow = if (pane.cur_pinned) pane.cur_row else @as(i32, gcur.y) + goff;
    // the cursor's LINE, not its row: wrapped, one line owns a run of rows and
    // the number sits on the first of them, so the whole run lights up — the
    // gutter is naming the line you are on, and that is still one line
    const cur_line: i32 = if (active and !pane.tag_edit) gcrow else std.math.minInt(i32);
    // the body's first row, the way renderPane derives it (Tagbottom)
    const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
    var vr: u16 = 0;
    while (vr < body_h) : (vr += 1) {
        const row_line: i32 = if (pane.wrap_n == 0)
            goff + @as(i32, vr)
        else if (vr < pane.wrap_n) pane.wrap_line[vr] else std.math.maxInt(i32);
        const on_cursor = row_line == cur_line;
        var c: u16 = 0;
        while (c < config.PREFIX_W and c < tw) : (c += 1) {
            const cell = s.at(tx + c, body_y + vr);
            cell.default = false; // paints blank gutter rows too
            if (on_cursor) {
                cell.style.fg = .{ .rgb = ch.tag_fg };
                cell.style.bg = .{ .rgb = ch.tag_bg };
            } else cell.style.fg = .{ .rgb = ch.lineno };
        }
    }
}

const SynStyle = struct { fg: [3]u8, bold: bool };

fn synStyle(p: *Pardes, sy: syntax.Syn) ?SynStyle {
    return switch (sy) {
        .none => null,
        .keyword => .{ .fg = p.theme().kw, .bold = true },
        .string => .{ .fg = p.theme().str, .bold = false },
        .number => .{ .fg = p.theme().num, .bold = false },
        .comment => .{ .fg = p.theme().comment, .bold = true },
    };
}

/// syntax colors: recolor each content cell from its tree-sitter style byte;
/// content starts after the lineno gutter
pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *State, r: pardes.Rect, tx: u16, tw: u16, body_h: u16) void {
    if (f.highlights.len == 0) return;
    const s = &p.surface;
    const tz_recolor = tracy.zone(@src(), "synRecolor");
    defer tz_recolor.end();
    // indexed start, same as bodyText — an empty tail simply paints nothing
    var flines = std.mem.splitScalar(u8, f.content[lineStart(p.gpa, f, f.scroll)..], '\n');
    const total = nlines(p.gpa, f);
    const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
    var vr: u16 = 0;
    while (vr < body_h) : (vr += 1) {
        // A colour has to land on the byte it belongs to, so this walk reads
        // the same map the body was built from: wrapped, the screen row names
        // its own line and the byte column it began at, and it ends where the
        // NEXT row of that line begins. Unwrapped the rows ARE the lines in
        // order and the split iterator is the cheaper walk.
        var base: usize = undefined;
        var line: []const u8 = undefined;
        var hs: usize = @intCast(@max(0, pane.hscroll));
        var limit: usize = undefined;
        if (pane.wrap_n == 0) {
            line = flines.next() orelse break;
            base = @intFromPtr(line.ptr) - @intFromPtr(f.content.ptr);
            limit = line.len;
        } else {
            if (vr >= pane.wrap_n) break;
            const lrow: usize = @intCast(@max(0, pane.wrap_line[vr]));
            if (lrow >= total) break;
            base = lineStart(p.gpa, f, lrow);
            const lend = if (lrow + 1 < total) lineStart(p.gpa, f, lrow + 1) -| 1 else f.content.len;
            line = f.content[base..lend];
            hs = @intCast(pane.wrap_col[vr]);
            limit = if (vr + 1 < pane.wrap_n and pane.wrap_line[vr + 1] == pane.wrap_line[vr])
                @min(line.len, @as(usize, @intCast(pane.wrap_col[vr + 1])))
            else
                line.len;
        }
        hs = modal.graphemeStart(line, @min(hs, line.len));
        var c: usize = 0;
        var screen_c: usize = 0;
        while (hs + c < limit and config.PREFIX_W + screen_c < tw) {
            const grapheme_end = @min(limit, modal.nextGrapheme(line, hs + c));
            const cells = graphemeDisplayWidth(line[hs + c .. grapheme_end]);
            const idx = base + hs + c;
            if (idx >= f.highlight_start) {
                const hidx = idx - f.highlight_start;
                if (hidx < f.highlights.len) {
                    if (synStyle(p, @enumFromInt(f.highlights[hidx]))) |ss| {
                        var fill: usize = 0;
                        while (fill < cells and config.PREFIX_W + screen_c + fill < tw) : (fill += 1) {
                            const cell = s.at(tx + @as(u16, @intCast(config.PREFIX_W + screen_c + fill)), body_y + vr);
                            if (cell.default) continue;
                            cell.style.fg = .{ .rgb = ss.fg };
                            cell.style.bold = ss.bold;
                        }
                    }
                }
            }
            screen_c += cells;
            c = grapheme_end - hs;
        }
    }
}

/// Mark every visible wrapped row which continues onto the next screen row.
/// This is file chrome: it follows syntax recoloring and precedes the shared
/// selection passes, so neither source ink nor the marker can win over a user
/// selection.
pub fn drawWrapMarkers(
    p: *Pardes,
    pane: *const Pane,
    r: pardes.Rect,
    tx: u16,
    tw: u16,
    body_h: u16,
    pane_bg: pardes.Color,
) void {
    if (tw <= config.PREFIX_W + 1) return;
    const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
    const marker_fg = p.chromeTheme().lineno;
    var row: u16 = 0;
    while (row + 1 < pane.wrap_n and row + 1 < body_h) : (row += 1) {
        if (pane.wrap_line[row + 1] != pane.wrap_line[row]) continue;
        p.surface.set(tx + tw - 1, body_y + row, config.wrap_marker, .{
            .fg = .{ .rgb = marker_fg },
            .bg = pane_bg,
        });
    }
}

/// Paint one logical file word through the last frame's wrap map. Unlike a
/// rectangular mouse selection, a path may cross continuation rows without
/// highlighting unrelated cells between its endpoints.
pub fn paintWordSelection(
    p: *Pardes,
    pane: *Pane,
    r: pardes.Rect,
    row: i32,
    word_lo: i32,
    word_hi: i32,
    bg: [3]u8,
) void {
    const tx = r.x + config.GUTTER;
    const tw = r.w - config.GUTTER;
    const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
    var vr: i32 = 0;
    while (vr + @as(i32, pardes.BOX_H) < @as(i32, r.h)) : (vr += 1) {
        const here = pane.wrapAt(vr);
        if (here.line != row) continue;
        var hi = word_hi;
        const next = pane.wrapAt(vr + 1);
        if (next.line == row) hi = @min(hi, next.at);
        const lo = @max(word_lo, here.at);
        if (hi <= lo) continue;
        const c0 = @as(i32, config.PREFIX_W) + displayOffset(pane, row, here.at, lo);
        const c1 = @as(i32, config.PREFIX_W) + displayEndOffset(pane, row, here.at, hi - 1);
        var col = @max(@as(i32, config.PREFIX_W), c0);
        while (col <= c1 and col < @as(i32, tw)) : (col += 1) {
            const cell = p.surface.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(vr)));
            cell.default = false;
            cell.style.bg = .{ .rgb = bg };
        }
    }
}