summaryrefslogtreecommitdiff
path: root/src/macos/Sources/PardesView.swift
blob: 3d841a607ee2bf44772a1fc5e6cd6b38c9e4b92f (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
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
// The AppKit half of the macOS backend: NSEvent in, pardes_* out, one
// CoreGraphics pass per frame back.
//
// This file keeps no model of the screen. The core owns the grid and hands it
// over whole through src/macos/pardes.h, so everything here is translation, and
// the only state worth holding is the font metrics — which are expensive to
// measure and never change — plus the two things a gesture needs remembered
// between events: which button a click started with, and how hard it is being
// pressed.
//
// Every NSEvent override decodes and then calls one of the post-decode entry
// points below (press/release/drag/click/scroll/rotate/typeKey). That split is
// not decoration: NSTouch, pressure stages and rotation have no public
// constructors, so a test can never synthesize them, and the only way the
// trackpad behaviour is reachable by anything but a finger is for the decision
// to live one call below the event. test/macos_e2e.swift drives exactly those
// entry points.
//
// Every C constant below is wrapped in an explicit conversion (UInt16(...),
// UInt32(...)) rather than used bare. A macro's imported Swift type is decided
// by the importer, not by us, and this file should not have an opinion about it.

import AppKit
import CoreText

/// Posted after every call this view makes into the core, and answered by
/// AppDelegate.pump(). The core only queues what it was told and does nothing
/// until it is pumped, so an input without one of these is an input that
/// visibly did nothing. Declared here, where the posts are.
let pardesDidInputNotification = Notification.Name("pardesDidInput")

/// A cell, already clamped to the frame the core last rendered.
struct GridPoint {
    var col: UInt16
    var row: UInt16
}

protocol PardesViewDelegate: AnyObject {
    func pardesViewDidResize(_ view: PardesView)
    func pardesViewRequestsPaste(_ view: PardesView)
}

/// What a click means, from the fingers resting on the trackpad and the button
/// stream AppKit chose to deliver it on. Pure on purpose: NSTouch cannot be
/// constructed, so this is the part of the gesture a test can reach.
///
/// acme's three buttons are the whole vocabulary — 1 selects, 2 executes,
/// 3 looks — and a trackpad has one surface. Two fingers is the gesture macOS
/// itself spells "secondary", so it is Look; three is the one left over, so it
/// is Exec, the heavier verb, which is also what a deep press means.
///
/// The finger count has to win over the stream, and that is not a preference.
/// macOS's secondary click is "click or tap with TWO OR MORE fingers": with it
/// on, a three-finger click is delivered as rightMouseDown exactly like a
/// two-finger one, and a view that trusts the stream cannot tell them apart —
/// three fingers silently did Look. Measured on real hardware, which is the
/// only way this was ever going to be found: `rightMouseDown: resting=3`.
///
/// So the stream is only the fallback, for when there are no fingers to count:
/// a real mouse's right button is Look and its middle button is Exec, and both
/// arrive with an empty touch set.
enum Trackpad {
    static func button(stream: pardes_mouse_button_e, fingers: Int) -> pardes_mouse_button_e {
        switch fingers {
        case 2: return PARDES_MOUSE_RIGHT
        case 3...: return PARDES_MOUSE_MIDDLE
        // One finger, or none to count: the stream is the answer. A trackpad
        // single click comes in on the left stream and stays left; a real
        // mouse's right and middle buttons keep their acme meanings.
        default: return stream
        }
    }

    /// A force click is a deliberate second gesture on top of an ordinary one,
    /// so it gets the verb that does something rather than the one that
    /// navigates.
    static let forceClickButton: pardes_mouse_button_e = PARDES_MOUSE_MIDDLE
}

// Matches bg_default/fg_default in src/gui/gui.zig and DEFAULT_FG/DEFAULT_BG in
// src/web/app.mjs. Three shells render the same core; if these drift, comparing
// a screenshot across backends stops meaning anything.
let pardesDefaultFG: UInt32 = 0xCC_CC_CC
let pardesDefaultBG: UInt32 = 0x12_12_12

/// Pinned sRGB for rasterized attachments, so a PDF page's bytes mean the same
/// thing here as they do in the SDL shell. DeviceRGB is the fallback rather
/// than a crash: a machine with no sRGB profile is not a reason to stop
/// drawing pages.
private let sRGB: CGColorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()

// UNVERIFIED: kCTFontAttributeName bridged through NSAttributedString.Key. It is
// the same string as .font, but spelling the CoreText key means the value stays
// a CTFont instead of being bridged to NSFont on the way in.
private let fontAttribute = NSAttributedString.Key(kCTFontAttributeName as String)

// The named sixteen, then xterm's 6x6x6 cube, then the 24-step grey ramp. These
// sixteen are app.mjs's, not gui.zig's: gui.zig borrows ghostty's palette
// because it already links it, and the two disagree on the base colors.
private let ansi16: [UInt32] = [
    0x00_00_00, 0xCC_00_00, 0x4E_9A_06, 0xC4_A0_00,
    0x34_65_A4, 0x75_50_7B, 0x06_98_9A, 0xD3_D7_CF,
    0x55_57_53, 0xEF_29_29, 0x8A_E2_34, 0xFC_E9_4F,
    0x72_9F_CF, 0xAD_7F_A8, 0x34_E2_E2, 0xEE_EE_EC,
]

private func paletteColor(_ index: UInt8) -> UInt32 {
    if index < 16 { return ansi16[Int(index)] }
    if index >= 232 {
        let grey = UInt32(8 + (Int(index) - 232) * 10)
        return grey << 16 | grey << 8 | grey
    }
    let n = Int(index) - 16
    func level(_ part: Int) -> UInt32 { part == 0 ? 0 : UInt32(55 + part * 40) }
    return level(n / 36) << 16 | level(n / 6 % 6) << 8 | level(n % 6)
}

private func decodeColor(_ encoded: UInt32, _ fallback: UInt32) -> UInt32 {
    if encoded == UInt32(PARDES_COLOR_DEFAULT) { return fallback }
    if encoded & UInt32(PARDES_COLOR_TAG_MASK) == UInt32(PARDES_COLOR_INDEXED) {
        return paletteColor(UInt8(encoded & 0xFF))
    }
    return encoded & UInt32(PARDES_COLOR_RGB_MASK)
}

/// The four faces, indexed by the two attribute bits that pick one. Also the
/// glyph cache's first key, which is why it is an ordinal and not four fields.
private enum Face: Int, CaseIterable {
    case regular = 0, bold = 1, italic = 2, boldItalic = 3

    init(bold: Bool, italic: Bool) {
        self = Face(rawValue: (bold ? 1 : 0) | (italic ? 2 : 0))!
    }
}

/// A background run that must not be painted at all, so the window's own
/// backdrop shows through. Outside the 24-bit RGB range, so it can never
/// collide with a real colour, and distinct from the `.max` the run loop
/// flushes on.
let bgClear: UInt32 = 0x0100_0000

/// `block` means the filled cursor sits on this cell. `ground` is what a
/// DEFAULT background resolves to, and `clearGround` asks for those cells to
/// come back as `bgClear` instead of a colour.
private func resolve(
    _ cell: pardes_cell_s,
    block: Bool,
    ground: UInt32,
    clearGround: Bool
) -> (fg: UInt32, bg: UInt32, alpha: CGFloat, visible: Bool) {
    // The core never painted this cell, which is most of the screen most of the
    // time, so this branch is the one that has to stay cheap.
    if cell.flags & UInt8(PARDES_CELL_DEFAULT) != 0 {
        return block
            ? (ground, pardesDefaultFG, 1, false)
            : (pardesDefaultFG, clearGround ? bgClear : ground, 1, false)
    }
    let bgDefault = cell.bg == UInt32(PARDES_COLOR_DEFAULT)
    var fg = decodeColor(cell.fg, pardesDefaultFG)
    var bg = decodeColor(cell.bg, ground)
    // The block cursor is a second reverse, so a cell that is already reversed
    // cancels back to normal underneath it. Same rule as emitInstance in
    // src/gui/gui.zig; the two must not drift.
    var reverse = block
    if cell.attrs & UInt16(PARDES_ATTR_REVERSE) != 0 { reverse = !reverse }
    if reverse { swap(&fg, &bg) }
    // ponytail: PARDES_ATTR_BLINK is decoded into nothing. Honouring it costs a
    // timer plus a repaint budget for a bit nothing in pardes emits today; drive
    // setNeedsDisplay from an NSTimer here when something does.
    let visible = cell.attrs & UInt16(PARDES_ATTR_INVISIBLE) == 0 && cell.len > 0
    // The other shells scale the channels by 6/10. Over a dark background alpha
    // lands in the same place and costs one blend instead of three multiplies.
    let alpha: CGFloat = cell.attrs & UInt16(PARDES_ATTR_DIM) != 0 ? 0.6 : 1
    // Only an UNREVERSED default background is the ground. A reverse puts the
    // text colour there, and text is a real colour that paints.
    if clearGround && bgDefault && !reverse { bg = bgClear }
    return (fg, bg, alpha, visible)
}

private func advance(_ font: CTFont, _ character: UniChar) -> CGFloat {
    var input = character
    var glyph = CGGlyph(0)
    guard CTFontGetGlyphsForCharacters(font, &input, &glyph, 1) else { return 0 }
    var size = CGSize.zero
    CTFontGetAdvancesForGlyphs(font, .horizontal, &glyph, &size, 1)
    return size.width
}

/// The size the window opens at and Cmd+0 returns to. Named here rather than
/// passed in because zoomReset has to know it too, and two spellings of one
/// number is how "actual size" stops being the size it actually opened at.
let defaultFontSize: CGFloat = 14

/// Everything that changes when the face or its size does, in one value so
/// that changing either is one assignment and cannot leave half the numbers
/// describing the old font.
///
/// Built at init and again for a `Font` command or a zoom. The glyph caches
/// belong here for the same reason: a CGGlyph is an index into a particular
/// face, so carrying one across a font change draws the wrong character
/// rather than none.
private struct Metrics {
    let fonts: [CTFont]
    let ascent: CGFloat
    let cellWidth: CGFloat
    let cellHeight: CGFloat
    let ruleThickness: CGFloat
    let underlineOffset: CGFloat
    /// ASCII is very nearly the whole screen, so its glyphs are resolved once
    /// per face here and never looked up again.
    let asciiGlyphs: [[CGGlyph]]

    /// Round `v` onto the backing grid: `scale` is the display's
    /// backingScaleFactor, so at 2x this lands on half-points, which are whole
    /// device pixels.
    private static func snap(_ v: CGFloat, _ scale: CGFloat, _ rule: FloatingPointRoundingRule) -> CGFloat {
        (v * scale).rounded(rule) / scale
    }

    init(size: CGFloat, path: String?, scale: CGFloat) {
        let face = Metrics.face(size: size, path: path)
        let scale = max(1, scale)

        // UNVERIFIED: CTFontSymbolicTraits member spelling (.traitBold/.traitItalic).
        // A face with no italic cut returns nil here, hence the fallback to `face`.
        func variant(_ traits: CTFontSymbolicTraits) -> CTFont {
            CTFontCreateCopyWithSymbolicTraits(face, size, nil, traits, traits) ?? face
        }
        let faces = [face, variant(.traitBold), variant(.traitItalic), variant([.traitBold, .traitItalic])]

        // The grid has to land on WHOLE DEVICE PIXELS, and that is the whole
        // constraint — a fractional column boundary makes the background pass
        // (which runs with antialiasing off, or touching fills seam) wobble by
        // a pixel from column to column, and on a screen made of tag bars and
        // selections that stripe is visible.
        //
        // Whole POINTS is how that used to be spelled, and on a Retina display
        // it asks for twice what it needs: half a point IS a whole pixel at 2x.
        // The difference is not academic — Monaco advances 8.4014pt at 14, so
        // ceiling to 9 spaced every column 7.1% wider than the face was drawn
        // for, which is loose, washed-out text that reads as bad rendering.
        // Snapped to the backing grid it is 8.5, i.e. +1.2%.
        //
        // Width rounds to NEAREST — a monospace glyph is drawn to fit its own
        // advance, so the half-pixel either way is slack — while height rounds
        // UP, because losing a pixel off a descender is clipping.
        let snap = Metrics.snap
        fonts = faces
        // The ascent lands on a pixel for a second reason: it is the baseline's
        // offset inside the cell, so the rules hung off it are whole-pixel
        // fills rather than one-pixel bars smeared across two rows.
        ascent = max(1 / scale, snap(CTFontGetAscent(face), scale, .toNearestOrAwayFromZero))
        cellWidth = max(1 / scale, snap(advance(face, 0x4D), scale, .toNearestOrAwayFromZero))
        cellHeight = max(1 / scale, snap(CTFontGetAscent(face) + CTFontGetDescent(face) + CTFontGetLeading(face), scale, .up))
        ruleThickness = max(1 / scale, snap(CTFontGetUnderlineThickness(face), scale, .toNearestOrAwayFromZero))
        underlineOffset = snap(CTFontGetUnderlinePosition(face), scale, .toNearestOrAwayFromZero)
        asciiGlyphs = faces.map { font in
            var chars = Array(UniChar(0)..<UniChar(128))
            var glyphs = [CGGlyph](repeating: 0, count: 128)
            _ = CTFontGetGlyphsForCharacters(font, &chars, &glyphs, 128)
            return glyphs
        }
    }

    /// The regular cut to build the other three from: the file the core asked
    /// for, or the system monospace face when it asked for nothing — or when
    /// what it asked for turned out not to be wearable.
    private static func face(size: CGFloat, path: String?) -> CTFont {
        if let path, let picked = Metrics.fromFile(path, size) { return picked }
        let system = NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
        // Through the descriptor, not through CTFontCreateWithName(fontName):
        // the system monospace face has a dot-prefixed internal name that a
        // by-name lookup can miss entirely, and NSFontDescriptor is toll-free
        // bridged, so this cannot resolve to a different font than AppKit just
        // handed us.
        let face = CTFontCreateWithFontDescriptor(system.fontDescriptor as CTFontDescriptor, size, nil)
        // The whole layout is a fixed grid, so a proportional face is not a
        // cosmetic problem, it is a broken screen. "M" and "i" disagreeing on
        // advance is the cheapest possible proof that we got one.
        return Metrics.isFixedPitch(face) ? face : CTFontCreateWithName("Menlo" as CFString, size, nil)
    }

    /// A face out of a font FILE, which is what the core hands over — it found
    /// the path by walking the font directories itself, so nothing here asks
    /// CoreText to resolve a name that a different shell might resolve
    /// differently.
    ///
    /// Nil rather than a substitute for anything wrong with the file, because
    /// the caller's fallback is the face already on screen: a font that cannot
    /// be measured would otherwise leave a terminal with no way back out.
    private static func fromFile(_ path: String, _ size: CGFloat) -> CTFont? {
        let url = URL(fileURLWithPath: path) as CFURL
        guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url) as? [CTFontDescriptor],
              !descriptors.isEmpty else { return nil }
        // A .ttc holds a family's four cuts in one file. Take the one with
        // neither trait set — the regular — because the bold and italic ones
        // are derived from it below; falling back to the first face keeps a
        // collection whose cuts are all styled from being unusable.
        let plain = descriptors.first { descriptor in
            let traits = CTFontDescriptorCopyAttribute(descriptor, kCTFontTraitsAttribute) as? [CFString: Any]
            let symbolic = (traits?[kCTFontSymbolicTrait] as? UInt32) ?? 0
            return symbolic & UInt32(CTFontSymbolicTraits.traitBold.rawValue | CTFontSymbolicTraits.traitItalic.rawValue) == 0
        }
        let face = CTFontCreateWithFontDescriptor(plain ?? descriptors[0], size, nil)
        // The core already filtered for fixed pitch by reading the file's own
        // advances. This is the same question asked of the face CoreText
        // actually built, which is the one that will be drawn with.
        return Metrics.isFixedPitch(face) ? face : nil
    }

    private static func isFixedPitch(_ face: CTFont) -> Bool {
        let em = advance(face, 0x4D)
        return em > 0 && abs(em - advance(face, 0x69)) <= 0.01
    }
}

private func modifiers(_ flags: NSEvent.ModifierFlags) -> UInt32 {
    var mods: UInt32 = 0
    if flags.contains(.control) { mods |= UInt32(PARDES_MOD_CTRL) }
    if flags.contains(.option) { mods |= UInt32(PARDES_MOD_ALT) }
    if flags.contains(.shift) { mods |= UInt32(PARDES_MOD_SHIFT) }
    return mods
}

final class PardesView: NSView {
    weak var delegate: PardesViewDelegate?

    // The face and the numbers off it, replaced whole by `wear`.
    private var metrics: Metrics
    /// What `metrics` was built from, so a zoom keeps the face and a font
    /// change keeps the size.
    private var fontSize: CGFloat
    private var fontPath: String?

    var cellWidth: CGFloat { metrics.cellWidth }
    var cellHeight: CGFloat { metrics.cellHeight }

    // Glyphs the ASCII table above did not answer for. A stored 0 is .notdef,
    // meaning "this face does not have it", which is a cache hit too — the
    // CTLine fallback below is far more expensive than the lookup it would
    // repeat. Keyed by face and codepoint, and thrown away with the face.
    private var glyphCache: [UInt32: CGGlyph] = [:]
    // Scratch for one batched run of glyphs. Held rather than made per row so a
    // full redraw does not allocate 24 times.
    private var runGlyphs: [CGGlyph] = []
    private var runPositions: [CGPoint] = []

    private var reportedCols: UInt16 = 0
    private var reportedRows: UInt16 = 0

    /// The backingScaleFactor `metrics` was snapped to, so a move between a
    /// Retina and a 1x display re-measures the cell instead of leaving the grid
    /// aligned to the other screen's pixels.
    private var metricsScale: CGFloat = 2

    /// The active theme's own background, or nil when it declares none — the
    /// `*_transparent` themes and the curated `dark`. Nil is not a colour to
    /// substitute but a decision: the ground stops being painted at all, the
    /// view stops being opaque, and AppDelegate's NSVisualEffectView shows
    /// through it. Read off pardes_theme_bg() once per pump, which is also
    /// what makes a `Theme` command take hold without a relaunch.
    private(set) var themeBG: UInt32? = pardesDefaultBG

    /// Adopt what the core is wearing. Returns whether anything moved, so the
    /// host only reconfigures the window when it has to.
    @discardableResult
    func adoptThemeBG(_ encoded: UInt32) -> Bool {
        let wanted: UInt32? =
            encoded == UInt32(PARDES_COLOR_DEFAULT) ? nil : encoded & UInt32(PARDES_COLOR_RGB_MASK)
        guard wanted != themeBG else { return false }
        themeBG = wanted
        needsDisplay = true
        return true
    }

    // The button a left-stream click actually started with. mouseDown decides
    // it from the fingers on the trackpad, and mouseDragged/mouseUp must use
    // the same one: a press of right followed by a release of left leaves the
    // core holding a drag nothing will ever end.
    private var latchedButton: pardes_mouse_button_e?
    private var latchedCell: GridPoint?
    // Force-click stage, reset per press. AppKit repeats stage-2 events for as
    // long as the finger stays down, and only the transition is the gesture.
    private var pressureStage: Int = 0
    // Fingers currently resting on the trackpad, kept from the touch stream.
    //
    // mouseDown was originally trusted to carry its own touch set, and on this
    // hardware it does not always: AppKit routes NSTouch through the four
    // touchesXxx callbacks, and the touch set hanging off a *mouse* event can
    // come back empty depending on how the click was produced. Empty reads as
    // one finger, which is a two-finger Look silently degrading into a select
    // — the exact failure this was supposed to avoid. So the count is
    // maintained here and the mouse event's own set is preferred only when it
    // has something in it.
    private var restingFingers: Int = 0

    init(fontSize size: CGFloat) {
        // No window yet, so no backing scale to ask for: 2x is the guess every
        // Mac shipped this decade would give, and viewDidChangeBackingProperties
        // below re-measures the moment there is a real answer — including the
        // 1x case, which a bare guess would otherwise leave wrong forever.
        let built = Metrics(size: size, path: nil, scale: 2)
        metrics = built
        fontSize = size
        fontPath = nil

        // 80x24 only so the window has a size to open at; the AppDelegate reads
        // gridSize back and boots the core with whatever it actually got.
        super.init(frame: NSRect(x: 0, y: 0, width: built.cellWidth * 80, height: built.cellHeight * 24))

        runGlyphs.reserveCapacity(256)
        runPositions.reserveCapacity(256)
        // Files dropped ON the grid. Finder and the Dock already reach the app
        // through application(_:open:), but that path cannot say WHERE — and
        // where is the whole difference between "a file opened somewhere" and
        // acme's "a file opened next to the pane I pointed at".
        registerForDraggedTypes([.fileURL])
        // Indirect touches are the trackpad's. Without this the touch set is
        // always empty and every click looks like one finger, which is exactly
        // the bug that would make two-finger Look silently never fire.
        allowedTouchTypes = [.indirect]
        // Without a pressure configuration the deep-press stages are the
        // system's business and stage 2 may never be delivered here.
        // .primaryDeepClick is the one that means "a harder press is a second
        // gesture", which is what it is being used for.
        pressureConfiguration = NSPressureConfiguration(pressureBehavior: .primaryDeepClick)
    }

    required init?(coder: NSCoder) { fatalError("PardesView is built in code, not a nib") }

    // MARK: - the face

    /// The PostScript name of the face on screen. The only way anything
    /// outside this file can find out which font is being drawn with — the
    /// core has no font, so a cell-buffer snapshot cannot see one.
    var faceName: String { CTFontCopyPostScriptName(metrics.fonts[0]) as String }

    /// Put on a face, or the same face at a different size, and tell the host
    /// the grid moved under it.
    ///
    /// A cell that changed size means a different number of columns fit the
    /// same window, so this is a resize as far as the core is concerned — and
    /// the delegate's resize path is already the one that reports both the
    /// grid and the physical cell the PDF placement reads. Nothing here
    /// second-guesses a file it could not load: `Metrics` falls back to the
    /// system face, and asking for a font that is not wearable leaves the
    /// screen exactly as it was rather than blank.
    private func wear(size: CGFloat, path: String?) {
        let scale = window?.backingScaleFactor ?? metricsScale
        let next = Metrics(size: size, path: path, scale: scale)
        // A CGGlyph is an index into a particular face. Kept across a change
        // it would draw a different character, not a missing one.
        glyphCache.removeAll(keepingCapacity: true)
        metrics = next
        metricsScale = scale
        fontSize = size
        fontPath = path
        delegate?.pardesViewDidResize(self)
        needsDisplay = true
    }

    /// The file `Font <name>` resolved to, straight from the core.
    func adoptFont(path: String) {
        wear(size: fontSize, path: path)
    }

    /// Cmd+ and Cmd-. Whole points, because the cell is rounded to whole
    /// points anyway: a tenth-of-a-point step would spend several keystrokes
    /// landing on the same grid and look like the key had stopped working.
    /// The range is what stays legible at the bottom and still fits a useful
    /// number of columns at the top.
    func zoom(by step: CGFloat) {
        let next = min(max(fontSize + step, 6), 72)
        guard next != fontSize else { return }
        wear(size: next, path: fontPath)
    }

    func zoomReset() {
        guard fontSize != defaultFontSize else { return }
        wear(size: defaultFontSize, path: fontPath)
    }

    // Row 0 at the top, so the drawing arithmetic reads like the grid it is.
    override var isFlipped: Bool { true }
    // Opaque only while the theme brings its own background. A transparent
    // theme has none, and an opaque view over a visual-effect backdrop is a
    // grey rectangle where the blur should be.
    override var isOpaque: Bool { themeBG != nil }
    override var acceptsFirstResponder: Bool { true }
    // A click that focuses the window should also land in the grid: this is a
    // text surface, and having to click twice after switching apps is the kind
    // of thing that makes an app feel foreign.
    override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }

    var gridSize: (cols: UInt16, rows: UInt16) {
        let cols = min(max((bounds.width / cellWidth).rounded(.down), 1), CGFloat(UInt16.max))
        let rows = min(max((bounds.height / cellHeight).rounded(.down), 1), CGFloat(UInt16.max))
        return (UInt16(cols), UInt16(rows))
    }

    // MARK: - drawing

    override func draw(_ dirtyRect: NSRect) {
        guard let ctx = NSGraphicsContext.current?.cgContext else { return }
        // ponytail: dirtyRect is ignored. pardes_frame() re-renders the whole grid
        // whatever we do, so clipping would save fills and nothing else. Narrow
        // the row loops to the dirty band if that ever shows up in a profile.
        let count = pardes_frame()
        let cols = Int(pardes_frame_cols())
        let rows = Int(pardes_frame_rows())
        // The ground, from the core rather than from a constant agreed by hand.
        // A transparent theme has none: CLEAR rather than fill, because AppKit
        // does not blank a non-opaque view and last frame's pixels would
        // otherwise pile up on themselves.
        let ground = themeBG ?? pardesDefaultBG
        let clearGround = themeBG == nil
        if clearGround { ctx.clear(bounds) } else { fill(ctx, bounds, ground, 1) }
        guard cols > 0, rows > 0, Int(count) == cols * rows, let cells = pardes_frame_cells() else { return }

        // -1 when hidden, which never matches a real cell, so hidden and "bar, so
        // not a block" collapse into the same comparison.
        let bar = pardes_cursor_bar()
        let blockX = bar ? -1 : Int(pardes_cursor_x())
        let blockY = bar ? -1 : Int(pardes_cursor_y())

        // Background first, batched into runs of equal color. A full redraw is
        // 80x24 cells at the low end and per-cell fills are exactly what makes
        // that feel slow. Antialiasing is off because touching rects share an
        // edge, and blending that edge twice draws a visible seam.
        ctx.setShouldAntialias(false)
        for row in 0..<rows {
            let base = row * cols
            let y = CGFloat(row) * cellHeight
            var start = 0
            var color = resolve(cells[base], block: blockY == row && blockX == 0,
                                ground: ground, clearGround: clearGround).bg
            for col in 1...cols {
                // A real color is 24 bits, so .max is a sentinel that cannot
                // compare equal and therefore always flushes the last run.
                let next: UInt32 = col == cols
                    ? .max
                    : resolve(cells[base + col], block: blockY == row && blockX == col,
                              ground: ground, clearGround: clearGround).bg
                if next == color { continue }
                // bgClear runs are the ground showing through, and the ground is
                // already clear — painting them would be painting the hole shut.
                if color != bgClear {
                    fill(ctx, CGRect(x: CGFloat(start) * cellWidth, y: y,
                                     width: CGFloat(col - start) * cellWidth, height: cellHeight),
                         color, 1)
                }
                start = col
                color = next
            }
        }

        // CTFontDrawGlyphs lays glyph outlines out with +y up, and isFlipped hands
        // us a y-down CTM, so drawing text directly in view space renders every
        // line mirrored. Un-flip once for the whole glyph pass and convert each
        // baseline into it rather than fighting the text matrix per cell.
        ctx.setShouldAntialias(true)
        // Every glyph sits at an exact multiple of cellWidth and the ascent is
        // whole points (see Metrics), so every baseline is already on a pixel:
        // letting CoreText place a glyph on a subpixel would blur a grid that
        // is aligned by construction. Quantizing keeps the rasterizer's own
        // cache hitting.
        ctx.setShouldSubpixelPositionFonts(false)
        ctx.setShouldSubpixelQuantizeFonts(true)
        // Grayscale antialiasing, never LCD subpixel. Smoothing needs to know
        // the colour behind the glyph, which over a transparent theme's
        // backdrop it cannot — the result is coloured fringing that reads as
        // blur. macOS has defaulted this off since 10.14, but the user can turn
        // it back on globally and it is not their call to make for this grid.
        ctx.setShouldSmoothFonts(false)
        ctx.saveGState()
        ctx.textMatrix = .identity
        ctx.translateBy(x: 0, y: bounds.height)
        ctx.scaleBy(x: 1, y: -1)
        let height = bounds.height
        for row in 0..<rows {
            drawRow(ctx, cells, base: row * cols, cols: cols,
                    baseline: height - (CGFloat(row) * cellHeight + metrics.ascent),
                    blockCol: blockY == row ? blockX : -1)
        }
        ctx.restoreGState()

        // Pixel attachments over the grid: rasterized PDF pages, and image
        // panes' own pixels. After the glyphs, the way the SDL shell draws them
        // after its cells — a PDF pane's cells are blank, so the order only
        // matters for the tag row an attachment must never reach, and the clip
        // below is what keeps it off.
        drawImages(ctx)

        if bar {
            let x = Int(pardes_cursor_x()), y = Int(pardes_cursor_y())
            if x >= 0, y >= 0, x < cols, y < rows {
                // gui.zig paints U+258F here. A rect is the same picture without
                // asking the font for a glyph it may not carry.
                let fg = resolve(cells[y * cols + x], block: false,
                                 ground: themeBG ?? pardesDefaultBG, clearGround: false).fg
                ctx.setShouldAntialias(false)
                fill(ctx, CGRect(x: CGFloat(x) * cellWidth, y: CGFloat(y) * cellHeight,
                                 width: max(1, (cellWidth / 8).rounded(.up)), height: cellHeight), fg, 1)
            }
        }
    }

    /// What identifies a decoded raster: the pane's lifetime, the page, and the
    /// generation MuPDF last rendered. Panning, zooming to fit and scrolling
    /// deliberately move none of them, so the CGImage survives all three.
    private struct ImageKey: Hashable {
        let serial: UInt32
        let page: UInt32
        let revision: UInt32
    }

    /// Rasterized attachments, decoded once each. The bytes the core lends are
    /// only valid until the next `pardes_frame`, so the CGImage owns a COPY —
    /// which is exactly why the cache has to be keyed well enough that the copy
    /// happens when the pixels change and never on an ordinary scroll.
    private var imageCache: [ImageKey: CGImage] = [:]

    private func drawImages(_ ctx: CGContext) {
        let count = Int(pardes_frame_images())
        guard count > 0, let list = pardes_frame_image_list() else {
            // Nothing on screen owns pixels any more: the pages a closed pane
            // rendered would otherwise sit in here for the rest of the session.
            if !imageCache.isEmpty { imageCache.removeAll(keepingCapacity: true) }
            return
        }

        // The core computed every rectangle in PHYSICAL pixels, because that is
        // what pardes_resize handed it. The view draws in points.
        let scale = max(1, metricsScale)
        var live = Set<ImageKey>()
        live.reserveCapacity(count)

        ctx.setShouldAntialias(true)
        for i in 0..<count {
            let place = list[i]
            let key = ImageKey(serial: place.serial, page: place.page, revision: place.revision)
            live.insert(key)
            guard let full = image(for: place, key: key) else { continue }
            guard let crop = full.cropping(to: CGRect(
                x: Int(place.src_x), y: Int(place.src_y),
                width: Int(place.src_w), height: Int(place.src_h)))
            else { continue }

            // The body is the rectangle nothing may paint past. The core has
            // already clipped the geometry to the viewport, but a tagline is
            // not the viewport — a page one pixel too tall would sit on it.
            let body = CGRect(
                x: CGFloat(place.cell_x) * cellWidth, y: CGFloat(place.cell_y) * cellHeight,
                width: CGFloat(place.cell_w) * cellWidth, height: CGFloat(place.cell_h) * cellHeight)
            let dst = CGRect(
                x: body.minX + CGFloat(place.dst_x) / scale,
                y: body.minY + (CGFloat(place.dst_y) + CGFloat(place.offset_y)) / scale,
                width: CGFloat(place.dst_w) / scale,
                height: CGFloat(place.dst_h) / scale)

            ctx.saveGState()
            ctx.clip(to: body)
            // isFlipped gives us a y-down CTM and CGImage draws +y up, so a
            // plain ctx.draw would land every page upside down. Flip about the
            // destination rather than about the view, so the arithmetic above
            // stays in the grid's own coordinates.
            ctx.translateBy(x: dst.minX, y: dst.maxY)
            ctx.scaleBy(x: 1, y: -1)
            // A page is resampled whenever fit or zoom disagrees with the
            // raster MuPDF last produced; nearest-neighbour text is unreadable.
            ctx.interpolationQuality = .high
            ctx.draw(crop, in: CGRect(x: 0, y: 0, width: dst.width, height: dst.height))
            ctx.restoreGState()
        }

        // Evict what this frame did not place. Scrolling a document past a page
        // is the common case, and holding every page a session ever showed is
        // how a PDF viewer ends up owning a gigabyte of decoded bitmaps.
        if imageCache.count > live.count {
            imageCache = imageCache.filter { live.contains($0.key) }
        }
    }

    /// The decoded raster for one attachment, made once per generation.
    private func image(for place: pardes_image_s, key: ImageKey) -> CGImage? {
        if let cached = imageCache[key] { return cached }
        let bytes = Int(place.iw) * Int(place.ih) * 4
        guard bytes > 0, let rgba = place.rgba else { return nil }
        // Copied, not referenced: the core lends these bytes until the next
        // pardes_frame and this image outlives many of them.
        guard let data = CFDataCreate(nil, rgba, bytes),
              let provider = CGDataProvider(data: data)
        else { return nil }
        // Straight alpha, R,G,B,A in memory — the same bytes the SDL shell
        // uploads as R8G8B8A8_UNORM and blends with ONE_MINUS_SRC_ALPHA.
        let made = CGImage(
            width: Int(place.iw), height: Int(place.ih),
            bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: Int(place.iw) * 4,
            space: sRGB,
            bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue | CGBitmapInfo.byteOrder32Big.rawValue),
            provider: provider, decode: nil, shouldInterpolate: true, intent: .defaultIntent)
        if let made { imageCache[key] = made }
        return made
    }

    /// One row of glyphs, batched. Consecutive cells that share a face and a
    /// colour go to CoreText as a single call with a position array: a row of
    /// plain text is then one draw instead of eighty, which is the difference
    /// between a full redraw being free and being felt.
    private func drawRow(
        _ ctx: CGContext,
        _ cells: UnsafePointer<pardes_cell_s>,
        base: Int,
        cols: Int,
        baseline: CGFloat,
        blockCol: Int
    ) {
        var runFace = Face.regular
        var runColor: UInt32 = 0
        var runAlpha: CGFloat = 1
        runGlyphs.removeAll(keepingCapacity: true)
        runPositions.removeAll(keepingCapacity: true)

        func flush() {
            guard !runGlyphs.isEmpty else { return }
            setFill(ctx, runColor, runAlpha)
            CTFontDrawGlyphs(metrics.fonts[runFace.rawValue], runGlyphs, runPositions, runGlyphs.count, ctx)
            runGlyphs.removeAll(keepingCapacity: true)
            runPositions.removeAll(keepingCapacity: true)
        }

        for col in 0..<cols {
            let cell = cells[base + col]
            if cell.flags & UInt8(PARDES_CELL_DEFAULT) != 0 { continue }
            // clearGround: false — this pass only reads `fg`, and a glyph is
            // never the hole in the ground.
            let style = resolve(cell, block: blockCol == col,
                                ground: themeBG ?? pardesDefaultBG, clearGround: false)
            let x = CGFloat(col) * cellWidth

            // Rules before the glyph, and independent of it: an underlined space
            // is a real thing and so is an underlined invisible cell. They are
            // fills, not glyphs, so they interrupt the run.
            if cell.attrs >> UInt16(PARDES_ATTR_UL_SHIFT) != 0
                || cell.attrs & UInt16(PARDES_ATTR_STRIKETHROUGH) != 0 {
                flush()
                drawRules(ctx, cell, style, x: x, baseline: baseline)
            }
            guard style.visible else { continue }

            // UNVERIFIED: withUnsafeBytes over an imported C fixed-size array, which
            // Swift models as an 8-tuple. String(decoding:) substitutes U+FFFD rather
            // than trapping, and the core has shipped invalid UTF-8 through here
            // before — the renderer must not be the thing that dies over it. prefix
            // clamps, so a bogus len cannot walk off the eight bytes either.
            let text = withUnsafeBytes(of: cell.text) { raw in
                String(decoding: raw.prefix(Int(cell.len)), as: UTF8.self)
            }
            guard !text.isEmpty, text != " " else { continue }

            let face = Face(bold: cell.attrs & UInt16(PARDES_ATTR_BOLD) != 0,
                            italic: cell.attrs & UInt16(PARDES_ATTR_ITALIC) != 0)
            let units = text.utf16
            let known = units.count == 1 ? glyph(face, units.first!) : 0
            if known != 0 {
                if !runGlyphs.isEmpty
                    && (face != runFace || style.fg != runColor || style.alpha != runAlpha) {
                    flush()
                }
                runFace = face
                runColor = style.fg
                runAlpha = style.alpha
                runGlyphs.append(known)
                runPositions.append(CGPoint(x: x, y: baseline))
                continue
            }

            // Emoji, combining marks and anything the face is missing: CTLine finds
            // a fallback font. The position is set explicitly per cell — this is a
            // fixed grid, and letting CoreText advance across a row would drift off
            // it.
            flush()
            setFill(ctx, style.fg, style.alpha)
            let attributed = NSAttributedString(string: text, attributes: [fontAttribute: metrics.fonts[face.rawValue]])
            ctx.textPosition = CGPoint(x: x, y: baseline)
            CTLineDraw(CTLineCreateWithAttributedString(attributed as CFAttributedString), ctx)
            // CTLineDraw leaves the text position at the END of what it drew,
            // and textPosition IS the translation of the text matrix, which
            // CTFontDrawGlyphs then applies to every position it is handed. So
            // one fallback glyph silently displaces the entire rest of the
            // frame by that glyph's advance, down and to the right — and since
            // the wrap marker and the em dash take this path, that is most
            // files. Put it back before anything else draws.
            ctx.textMatrix = .identity
        }
        flush()
    }

    /// 0 is .notdef, i.e. "this face does not have it" — a real answer, cached
    /// like any other, because the CTLine fallback it sends the caller to costs
    /// far more than the lookup it would otherwise repeat every frame.
    private func glyph(_ face: Face, _ character: UniChar) -> CGGlyph {
        if character < 128 { return metrics.asciiGlyphs[face.rawValue][Int(character)] }
        let key = UInt32(face.rawValue) << 16 | UInt32(character)
        if let cached = glyphCache[key] { return cached }
        var input = character
        var found = CGGlyph(0)
        _ = CTFontGetGlyphsForCharacters(metrics.fonts[face.rawValue], &input, &found, 1)
        glyphCache[key] = found
        return found
    }

    private func drawRules(
        _ ctx: CGContext,
        _ cell: pardes_cell_s,
        _ style: (fg: UInt32, bg: UInt32, alpha: CGFloat, visible: Bool),
        x: CGFloat,
        baseline: CGFloat
    ) {
        let underline = Int(cell.attrs >> PARDES_ATTR_UL_SHIFT) & 7
        if underline != Int(PARDES_UL_OFF) {
            let y = baseline + metrics.underlineOffset
            fill(ctx, CGRect(x: x, y: y, width: cellWidth, height: metrics.ruleThickness), style.fg, style.alpha)
            // ponytail: curly, dotted and dashed all come out solid; only double
            // earns its second rule. ctx.setLineDash for two of them and a sine
            // path for the third is the upgrade, once anyone notices.
            if underline == Int(PARDES_UL_DOUBLE) {
                fill(ctx, CGRect(x: x, y: y - metrics.ruleThickness * 2, width: cellWidth, height: metrics.ruleThickness),
                     style.fg, style.alpha)
            }
        }
        if cell.attrs & UInt16(PARDES_ATTR_STRIKETHROUGH) != 0 {
            // Rounded like every other rule offset: a third of the ascent is a
            // fraction, and a fractional one-pixel bar is a two-pixel smear.
            fill(ctx, CGRect(x: x, y: baseline + (metrics.ascent * 0.3).rounded(), width: cellWidth, height: metrics.ruleThickness),
                 style.fg, style.alpha)
        }
    }

    private func setFill(_ ctx: CGContext, _ rgb: UInt32, _ alpha: CGFloat) {
        ctx.setFillColor(red: CGFloat((rgb >> 16) & 0xFF) / 255,
                         green: CGFloat((rgb >> 8) & 0xFF) / 255,
                         blue: CGFloat(rgb & 0xFF) / 255,
                         alpha: alpha)
    }

    private func fill(_ ctx: CGContext, _ rect: CGRect, _ rgb: UInt32, _ alpha: CGFloat) {
        setFill(ctx, rgb, alpha)
        ctx.fill(rect)
    }

    // MARK: - the core, and the pump

    /// Everything below ends here. Posting the notification in one place is
    /// what guarantees no entry point can feed the core and forget to ask for
    /// the tick that performs it.
    private func fed() {
        NotificationCenter.default.post(name: pardesDidInputNotification, object: self)
    }

    func typeKey(_ cp: UInt32, text: String, mods: UInt32) {
        // The pointer is borrowed for the call and nowhere else, which is the only
        // thing the header promises about it.
        text.withCString { pardes_key(cp, $0, text.utf8.count, mods) }
        fed()
    }

    // `mods` defaults to none because a synthesized gesture carries no
    // keyboard state; the NSEvent overrides always pass the real mask. Ctrl is
    // the one the core actually consults — a left press with it held is
    // goto-definition — so dropping it here would silently delete a feature.
    func press(_ button: pardes_mouse_button_e, at cell: GridPoint, mods: UInt32 = 0) {
        pardes_mouse(button, PARDES_MOUSE_PRESS, cell.col, cell.row, mods)
        fed()
    }

    func release(_ button: pardes_mouse_button_e, at cell: GridPoint, mods: UInt32 = 0) {
        pardes_mouse(button, PARDES_MOUSE_RELEASE, cell.col, cell.row, mods)
        fed()
    }

    func drag(_ button: pardes_mouse_button_e, to cell: GridPoint, mods: UInt32 = 0) {
        pardes_mouse(button, PARDES_MOUSE_DRAG, cell.col, cell.row, mods)
        fed()
    }

    func motion(to cell: GridPoint, mods: UInt32 = 0) {
        pardes_mouse(PARDES_MOUSE_NONE, PARDES_MOUSE_MOTION, cell.col, cell.row, mods)
        fed()
    }

    /// A press and its release with nothing in between, which is what every
    /// synthesized click is: a trackpad gesture we recognised rather than a
    /// button the user held.
    func click(_ button: pardes_mouse_button_e, at cell: GridPoint, mods: UInt32 = 0) {
        press(button, at: cell, mods: mods)
        release(button, at: cell, mods: mods)
    }

    /// One discrete wheel notch, as opposed to the continuous travel below.
    func wheel(_ button: pardes_mouse_button_e, at cell: GridPoint, mods: UInt32 = 0) {
        pardes_mouse(button, PARDES_MOUSE_PRESS, cell.col, cell.row, mods)
        fed()
    }

    func scroll(rows: CGFloat, cols: CGFloat = 0, at cell: GridPoint) {
        guard rows != 0 || cols != 0 else { return }
        pardes_scroll(Float(rows), Float(cols), cell.col, cell.row)
        fed()
    }

    func rotate(degrees: CGFloat) {
        guard degrees != 0 else { return }
        pardes_rotate(Float(degrees))
        fed()
    }

    /// The fingers came off the trackpad. A post-decode entry point of its own
    /// so the e2e harness can throw the dial: NSEvent phases have no public
    /// constructor, and a fling nothing can synthesize is a fling nothing can
    /// assert.
    func rotateEnd() {
        pardes_rotate_end()
        fed()
    }

    // MARK: - keyboard

    override func keyDown(with event: NSEvent) {
        let flags = event.modifierFlags
        // The ABI has no super bit, so a Command chord cannot be expressed at all.
        // Anything the main menu claims never reaches here; the rest is swallowed
        // rather than delivered as the bare keystroke the core would insert.
        if flags.contains(.command) {
            if event.charactersIgnoringModifiers?.lowercased() == "v" {
                delegate?.pardesViewRequestsPaste(self)
            }
            return
        }

        let control = flags.contains(.control)
        let option = flags.contains(.option)
        // With ctrl or option down, `characters` is already the composed result —
        // Ctrl-A is U+0001, Option-A is "å". The core wants the base key and no
        // text, which is what app.mjs does with the same two bits.
        let composed = (control || option ? event.charactersIgnoringModifiers : event.characters) ?? ""
        guard let scalar = composed.unicodeScalars.first else { return }

        var codepoint = scalar.value
        // 0xF700..0xF8FF is AppKit's private-use block for function keys. The nine
        // the core names get translated; the rest (F1-F12, Insert, the keypad) are
        // dropped, because passing one through paints a stray glyph.
        // These constants come from an unnamed C enum, so which width Swift picks
        // for them is not something this file should depend on: wrapping each in
        // UInt32() compiles whether they import as Int, Int32 or UInt32.
        if scalar.value >= 0xF700 && scalar.value <= 0xF8FF {
            switch scalar.value {
            case UInt32(NSUpArrowFunctionKey): codepoint = UInt32(PARDES_KEY_UP)
            case UInt32(NSDownArrowFunctionKey): codepoint = UInt32(PARDES_KEY_DOWN)
            case UInt32(NSLeftArrowFunctionKey): codepoint = UInt32(PARDES_KEY_LEFT)
            case UInt32(NSRightArrowFunctionKey): codepoint = UInt32(PARDES_KEY_RIGHT)
            case UInt32(NSHomeFunctionKey): codepoint = UInt32(PARDES_KEY_HOME)
            case UInt32(NSEndFunctionKey): codepoint = UInt32(PARDES_KEY_END)
            case UInt32(NSPageUpFunctionKey): codepoint = UInt32(PARDES_KEY_PAGE_UP)
            case UInt32(NSPageDownFunctionKey): codepoint = UInt32(PARDES_KEY_PAGE_DOWN)
            case UInt32(NSDeleteFunctionKey): codepoint = UInt32(PARDES_KEY_DELETE)
            default: return
            }
        }

        // Enter, Tab, Escape and Backspace already arrive as the ASCII controls the
        // header names. Two keys do not: the keypad's Enter is U+0003 and Shift-Tab
        // is U+0019, neither of which is in the function-key block above, so without
        // this both reach the core as a control it has no binding for and do
        // nothing. The browser shell resolves them from the DOM key name and this
        // is what keeps the two hosts saying the same thing.
        if codepoint == 0x03 { codepoint = UInt32(PARDES_KEY_ENTER) }
        if codepoint == 0x19 { codepoint = UInt32(PARDES_KEY_TAB) }

        // A control character is functional, and functional keys must not also
        // carry text.
        let functional = codepoint < 0x20 || codepoint == 0x7F || codepoint >= 0xF0000
        let text = functional || control || option ? "" : composed
        // Typing is the moment the pointer stops being interesting and starts
        // sitting on top of the words. It comes back on the next mouse move.
        NSCursor.setHiddenUntilMouseMoves(true)
        typeKey(codepoint, text: text, mods: modifiers(flags))
    }

    // MARK: - trackpad

    // NSTouch arrives through these four and nowhere else. They are the only
    // reliable source of "how many fingers are down right now": the touch set
    // on a mouse event is an accident of how the click was produced, and an
    // empty one is indistinguishable from one finger.
    override func touchesBegan(with event: NSEvent) { countTouches(event) }
    override func touchesMoved(with event: NSEvent) { countTouches(event) }
    override func touchesEnded(with event: NSEvent) { countTouches(event) }
    override func touchesCancelled(with event: NSEvent) { countTouches(event) }

    private func countTouches(_ event: NSEvent) {
        restingFingers = event.touches(matching: .touching, in: nil).count
        trace("touch: resting=\(restingFingers)")
    }

    // MARK: - mouse

    // All three button streams land in the same three functions, because which
    // stream a trackpad click arrives on is not something the app gets to know
    // in advance: with macOS's secondary click on, two AND three fingers both
    // come in as rightMouseDown. The button is therefore decided once, at the
    // press, from the fingers plus the stream, and then LATCHED — the core is
    // tracking a drag keyed by button, and answering a press of 3 with a
    // release of 1 leaves it holding a sweep nothing will ever end.
    //
    // The count itself is maintained by the touchesXxx callbacks above; see
    // beginClick for why it can only come from there.
    private func beginClick(_ stream: pardes_mouse_button_e, _ event: NSEvent) {
        guard let at = cell(for: event) else { return }
        pressureStage = 0
        // The count comes from the touch stream and NEVER from the mouse event.
        // Asking a mouse event for its touches is not merely unreliable, it
        // raises: -[NSEvent touchesMatchingPhase:inView:] is defined for
        // gesture and touch events, and on anything else AppKit throws, catches
        // it inside its own event dispatch, and abandons the rest of this
        // method. Nothing crashes and nothing is logged — every click just
        // silently stops working, a plain drag included, while rotation and
        // scrolling carry on as if the backend were fine. That is exactly how
        // this presented, and it is why restingFingers exists.
        let button = Trackpad.button(stream: stream, fingers: restingFingers)
        trace("press: stream=\(stream.rawValue) fingers=\(restingFingers) -> button=\(button.rawValue)")
        latchedButton = button
        latchedCell = at
        press(button, at: at, mods: modifiers(event.modifierFlags))
    }

    private func continueClick(_ event: NSEvent) {
        guard let button = latchedButton, let at = cell(for: event) else { return }
        latchedCell = at
        drag(button, to: at, mods: modifiers(event.modifierFlags))
    }

    private func endClick(_ event: NSEvent) {
        pressureStage = 0
        // Already nil when the force click below converted this press: it
        // released the button itself and there is nothing left to end.
        guard let button = latchedButton else { return }
        latchedButton = nil
        guard let at = cell(for: event) else { return }
        latchedCell = at
        release(button, at: at, mods: modifiers(event.modifierFlags))
    }

    override func mouseDown(with event: NSEvent) { beginClick(PARDES_MOUSE_LEFT, event) }
    override func mouseDragged(with event: NSEvent) { continueClick(event) }
    override func mouseUp(with event: NSEvent) { endClick(event) }
    // Where a two-finger click lands with macOS's own secondary click on, and
    // where a three-finger one lands too — hence the finger count in
    // Trackpad.button rather than a hardcoded RIGHT here.
    override func rightMouseDown(with event: NSEvent) { beginClick(PARDES_MOUSE_RIGHT, event) }
    override func rightMouseDragged(with event: NSEvent) { continueClick(event) }
    override func rightMouseUp(with event: NSEvent) { endClick(event) }
    // otherMouse covers button 2 and up. acme's vocabulary stops at three, so
    // every one of them lands on middle rather than being invented into a fourth.
    override func otherMouseDown(with event: NSEvent) { beginClick(PARDES_MOUSE_MIDDLE, event) }
    override func otherMouseDragged(with event: NSEvent) { continueClick(event) }
    override func otherMouseUp(with event: NSEvent) { endClick(event) }

    /// A deep press, which is a second gesture layered on the click already in
    /// flight. AppKit keeps sending stage-2 events while the finger stays down,
    /// so only the transition counts.
    ///
    /// Whatever button is in flight is released before the middle one goes out:
    /// a middle press arriving while the core holds a left select-drag is
    /// acme's 1-2 chord, which is Cut. Releasing first costs a cursor move at
    /// the click point — which is what clicking there would have done anyway.
    ///
    /// Not gated on the press being a LEFT one, which is what stopped this
    /// working: on a Force Touch trackpad the deep press is just as likely to
    /// have arrived on the right stream, and an in-flight Look upgraded by
    /// pressing harder is precisely the gesture. Already-Exec is the only case
    /// with nothing to do.
    override func pressureChange(with event: NSEvent) {
        trace("pressure: stage=\(event.stage) latched=\(String(describing: latchedButton?.rawValue))")
        guard pressureStage < 2 else { return }
        pressureStage = event.stage
        guard event.stage == 2 else { return }
        guard let at = latchedCell, let current = latchedButton,
              current != Trackpad.forceClickButton else { return }
        latchedButton = nil
        release(current, at: at, mods: modifiers(event.modifierFlags))
        click(Trackpad.forceClickButton, at: at, mods: modifiers(event.modifierFlags))
    }

    override func mouseMoved(with event: NSEvent) {
        guard let at = cell(for: event) else { return }
        motion(to: at, mods: modifiers(event.modifierFlags))
    }

    override func scrollWheel(with event: NSEvent) {
        guard let at = cell(for: event) else { return }
        if event.hasPreciseScrollingDeltas {
            // The core scrolls a cell at a time, so libpardes accumulates the
            // sub-cell travel and spends it as wheel presses — which is why the
            // cell has to travel with the delta.
            scroll(rows: -event.scrollingDeltaY / cellHeight,
                   cols: -event.scrollingDeltaX / cellWidth,
                   at: at)
        } else {
            // AppKit's sign is the opposite of the DOM's: positive deltaY means the
            // content moved down, which is a scroll back through history.
            if event.scrollingDeltaY != 0 {
                wheel(event.scrollingDeltaY > 0 ? PARDES_MOUSE_WHEEL_UP : PARDES_MOUSE_WHEEL_DOWN,
                      at: at, mods: modifiers(event.modifierFlags))
            }
            if event.scrollingDeltaX != 0 {
                wheel(event.scrollingDeltaX > 0 ? PARDES_MOUSE_WHEEL_LEFT : PARDES_MOUSE_WHEEL_RIGHT,
                      at: at, mods: modifiers(event.modifierFlags))
            }
        }
    }

    /// Two fingers twisted on the trackpad are the search-step keys: clockwise
    /// walks forward through the matches, counterclockwise back. It is a dial,
    /// and n/N is what a dial over a list of hits means. libpardes owns the
    /// quantizing and the momentum, exactly as it owns the scroll accumulator.
    ///
    /// AppKit gives rotation no momentum phase of its own — `momentumPhase` is
    /// scroll's alone — so the fling is measured from the release speed on the
    /// Zig side rather than handed to us. All this has to get right is telling
    /// it where the gesture starts and stops.
    override func rotate(with event: NSEvent) {
        trace("rotate: degrees=\(event.rotation) phase=\(event.phase.rawValue)")
        // A gesture starting drops whatever the last one left banked, so the
        // first degree of a new twist cannot inherit a nearly-complete notch —
        // and stops a fling still coasting, because a finger back down is how
        // a hand catches a dial.
        if event.phase == .began { pardes_rotate(0) }
        rotate(degrees: CGFloat(event.rotation))
        // .cancelled too: a gesture the system took away should not fling.
        if event.phase == .ended || event.phase == .cancelled { rotateEnd() }
    }

    /// What the trackpad actually delivered, under PARDES_LOG — the same
    /// variable the Zig side gates its logger on (src/macos.zig).
    ///
    /// This is not scaffolding left behind. Which events a trackpad produces is
    /// decided by the hardware and by four different System Settings switches
    /// (secondary click, three-finger drag, force click, "look up"), none of
    /// which this process can read, and every one of which turns a gesture into
    /// a different NSEvent or into none at all. When someone reports that
    /// two-finger Look does nothing, this is the only thing that can answer
    /// whether AppKit saw two fingers, one, or no click at all.
    private func trace(_ message: @autoclosure () -> String) {
        guard PardesView.tracing else { return }
        FileHandle.standardError.write(Data(("pardes: " + message() + "\n").utf8))
    }

    private static let tracing = ProcessInfo.processInfo.environment["PARDES_LOG"] != nil

    private func cell(for event: NSEvent) -> GridPoint? {
        cellAt(convert(event.locationInWindow, from: nil))
    }

    func cellAt(_ point: CGPoint) -> GridPoint? {
        // Clamp against the frame the core last rendered, not against our own
        // metrics: a window that has been resized but not yet ticked would
        // otherwise report a column the core has no cell for. Before the first
        // frame there is no grid to point at and the event is meaningless.
        let cols = Int(pardes_frame_cols())
        let rows = Int(pardes_frame_rows())
        guard cols > 0, rows > 0 else { return nil }
        let col = min(max(Int(point.x / cellWidth), 0), cols - 1)
        let row = min(max(Int(point.y / cellHeight), 0), rows - 1)
        return GridPoint(col: UInt16(col), row: UInt16(row))
    }

    // MARK: - files dropped on the grid

    /// A drop is a CLICK followed by `Look`, and that is the whole definition.
    ///
    /// The core has no notion of a drop and is not being given one: the pointer
    /// lands where it landed, which focuses that pane exactly as a left click
    /// there would, and then the ordinary `Look` builtin runs in it — so the
    /// document opens beside the pane you pointed at rather than beside
    /// whichever one happened to be focused. Drop on a tag and you clicked a
    /// tag; there is no case to special-case, and nothing here the hand could
    /// not have done itself.
    override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
        // AppKit reuses this answer for draggingUpdated when that is not
        // implemented, so the cursor stays right for the whole drag.
        droppedFiles(sender).isEmpty ? [] : .copy
    }

    override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
        let paths = droppedFiles(sender)
        guard !paths.isEmpty else { return false }
        drop(paths, at: cellAt(convert(sender.draggingLocation, from: nil)))
        return true
    }

    /// The drop, decoded: paths and a cell, nothing AppKit left in it.
    ///
    /// Split out for the reason every gesture here is — `NSDraggingInfo` is a
    /// protocol with a dozen members and no public conformer, so a test that
    /// had to build one would be testing its own stub. The decision lives one
    /// call below the event, and `drop` in test/macos_e2e.swift drives exactly
    /// this.
    ///
    /// A nil cell is a drop before the first frame, which has no grid to point
    /// at: the files still open, they just open where focus already was.
    func drop(_ paths: [String], at target: GridPoint?) {
        if let target {
            press(PARDES_MOUSE_LEFT, at: target)
            release(PARDES_MOUSE_LEFT, at: target)
        }
        // Whole tail, unquoted: executeBuiltinLine takes everything after the
        // first word as the argument, so a path with spaces in it needs no
        // escaping and would in fact break under any.
        for path in paths {
            let line = "Look \(path)"
            line.withCString { pardes_command($0, line.utf8.count) }
        }
        fed()
    }

    /// File paths on the drag pasteboard, in order. Empty for anything else,
    /// which is also how draggingEntered decides whether to accept at all.
    private func droppedFiles(_ sender: NSDraggingInfo) -> [String] {
        let options: [NSPasteboard.ReadingOptionKey: Any] = [.urlReadingFileURLsOnly: true]
        guard let urls = sender.draggingPasteboard.readObjects(
            forClasses: [NSURL.self], options: options) as? [URL]
        else { return [] }
        return urls.map(\.path)
    }

    // MARK: - geometry

    override func updateTrackingAreas() {
        super.updateTrackingAreas()
        for area in trackingAreas { removeTrackingArea(area) }
        // .inVisibleRect keeps the area correct across resizes on its own, which
        // is why the rect argument can be anything.
        addTrackingArea(NSTrackingArea(rect: .zero,
                                       options: [.mouseMoved, .inVisibleRect, .activeInKeyWindow],
                                       owner: self,
                                       userInfo: nil))
    }

    override func resetCursorRects() {
        // Every cell in this view is text, including the tags. An arrow over a
        // grid you can sweep and click words in is the wrong affordance.
        addCursorRect(bounds, cursor: .iBeam)
    }

    override func setFrameSize(_ newSize: NSSize) {
        super.setFrameSize(newSize)
        // AppKit resizes a view many times over one drag and almost all of those
        // land inside the same cell. Only a changed grid is news, and the core
        // reflows every pty on a resize, so the no-ops are not free.
        let grid = gridSize
        guard grid.cols != reportedCols || grid.rows != reportedRows else { return }
        reportedCols = grid.cols
        reportedRows = grid.rows
        delegate?.pardesViewDidResize(self)
    }

    // Dragging the window between a Retina display and a 1x one changes the
    // backing scale without moving a single bound, so setFrameSize above never
    // fires. This is the only notification of it. (Ghostty hooks the same one,
    // and additionally re-fires from the window's didChangeScreen
    // notification, which AppKit does not always pair with it.)
    //
    // TWO things depend on the scale: the physical cell metrics the core uses
    // to place PDF pages, and the cell itself, which is snapped to whole
    // DEVICE pixels (see Metrics) and is therefore aligned to the display it
    // was measured on. Re-measuring reports the resize on its own, so the
    // delegate call is the else-branch and not an extra one.
    override func viewDidChangeBackingProperties() {
        super.viewDidChangeBackingProperties()
        if let scale = window?.backingScaleFactor, scale != metricsScale {
            wear(size: fontSize, path: fontPath)
        } else {
            delegate?.pardesViewDidResize(self)
        }
    }
}

// ponytail: no NSTextInputClient, so dead keys and IME composition never reach
// the core — keyDown reads `characters` and that is the whole story. Adopting
// the protocol and routing through interpretKeyEvents is the upgrade when
// someone needs to type Japanese, and it needs the core to be able to render an
// underlined preedit run first.