summaryrefslogtreecommitdiff
path: root/src/macos/Sources/PardesView.swift
blob: c7152bd3420a69cc8878b8f5a9aab70cdbbbeecf (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
// 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

// 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))!
    }
}

/// `block` means the filled cursor sits on this cell.
private func resolve(
    _ cell: pardes_cell_s,
    block: 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
            ? (pardesDefaultBG, pardesDefaultFG, 1, false)
            : (pardesDefaultFG, pardesDefaultBG, 1, false)
    }
    var fg = decodeColor(cell.fg, pardesDefaultFG)
    var bg = decodeColor(cell.bg, pardesDefaultBG)
    // 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
    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]]

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

        // 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])]

        // Whole points, rounded UP. A fractional cell width puts every column
        // boundary on a fraction of a pixel, and with antialiasing off — which
        // the background pass needs, or touching fills seam — the rounding
        // wobbles by a pixel from column to column. On a screen made of tag
        // bars and selections that stripe is visible. Rounding up rather than
        // to nearest because down can clip a glyph, and a slightly airy grid is
        // not a bug.
        fonts = faces
        ascent = CTFontGetAscent(face)
        cellWidth = max(1, advance(face, 0x4D).rounded(.up))
        cellHeight = max(1, (CTFontGetAscent(face) + CTFontGetDescent(face) + CTFontGetLeading(face)).rounded(.up))
        ruleThickness = max(1, CTFontGetUnderlineThickness(face))
        underlineOffset = CTFontGetUnderlinePosition(face)
        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 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) {
        let built = Metrics(size: size, path: nil)
        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)
        // 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 next = Metrics(size: size, path: path)
        // 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
        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 }
    override var isOpaque: Bool { true }
    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())
        fill(ctx, bounds, pardesDefaultBG, 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).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).bg
                if next == color { continue }
                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, so letting
        // CoreText place it 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)
        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()

        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).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)
            }
        }
    }

    /// 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 }
            let style = resolve(cell, block: blockCol == col)
            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 {
            fill(ctx, CGRect(x: x, y: baseline + metrics.ascent * 0.3, 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()
    }

    // 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, exactly as it does for scroll.
    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.
        if event.phase == .began { pardes_rotate(0) }
        rotate(degrees: CGFloat(event.rotation))
    }

    /// 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: - 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 and the physical cell metrics the core uses to place PDF pages stay
    // at the old scale forever. 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.)
    override func viewDidChangeBackingProperties() {
        super.viewDidChangeBackingProperties()
        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.