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
|
// 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.
//
// 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
protocol PardesViewDelegate: AnyObject {
func pardesViewDidResize(_ view: PardesView)
func pardesViewRequestsPaste(_ view: PardesView)
}
// 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.
private let defaultFG: UInt32 = 0xCC_CC_CC
private let defaultBG: UInt32 = 0x12_12_12
private let inputNotification = Notification.Name("pardesDidInput")
// 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)
}
/// `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 ? (defaultBG, defaultFG, 1, false) : (defaultFG, defaultBG, 1, false)
}
var fg = decodeColor(cell.fg, defaultFG)
var bg = decodeColor(cell.bg, defaultBG)
// 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
}
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 {
let cellWidth: CGFloat
let cellHeight: CGFloat
weak var delegate: PardesViewDelegate?
private let regular: CTFont
private let bold: CTFont
private let italic: CTFont
private let boldItalic: CTFont
private let ascent: CGFloat
private let ruleThickness: CGFloat
private let underlineOffset: CGFloat
private var reportedCols: UInt16 = 0
private var reportedRows: UInt16 = 0
init(fontSize: CGFloat) {
let system = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
var face = CTFontCreateWithName(system.fontName as CFString, fontSize, nil)
// The whole layout is a fixed grid, so a proportional face is not a
// cosmetic problem, it is a broken screen. Resolving the system monospace
// font by name is a lookup that can miss; "M" and "i" disagreeing on
// advance is the cheapest possible proof that it did.
let em = advance(face, 0x4D)
if em <= 0 || abs(em - advance(face, 0x69)) > 0.01 {
face = CTFontCreateWithName("Menlo" as CFString, fontSize, nil)
}
// 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, fontSize, nil, traits, traits) ?? face
}
regular = face
bold = variant(.traitBold)
italic = variant(.traitItalic)
boldItalic = variant([.traitBold, .traitItalic])
cellWidth = max(1, advance(face, 0x4D))
ascent = CTFontGetAscent(face)
cellHeight = max(1, (ascent + CTFontGetDescent(face) + CTFontGetLeading(face)).rounded(.up))
ruleThickness = max(1, CTFontGetUnderlineThickness(face))
underlineOffset = CTFontGetUnderlinePosition(face)
// 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: cellWidth * 80, height: cellHeight * 24))
}
required init?(coder: NSCoder) { fatalError("PardesView is built in code, not a nib") }
// 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 }
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, defaultBG, 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)
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 {
let base = row * cols
let baseline = height - (CGFloat(row) * cellHeight + ascent)
for col in 0..<cols {
let cell = cells[base + col]
if cell.flags & UInt8(PARDES_CELL_DEFAULT) != 0 { continue }
let style = resolve(cell, block: blockY == row && blockX == col)
drawCell(ctx, cell, style, x: CGFloat(col) * cellWidth, baseline: baseline)
}
}
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
fill(ctx, CGRect(x: CGFloat(x) * cellWidth, y: CGFloat(y) * cellHeight,
width: max(1, cellWidth / 8), height: cellHeight), fg, 1)
}
}
}
private func drawCell(
_ ctx: CGContext,
_ cell: pardes_cell_s,
_ style: (fg: UInt32, bg: UInt32, alpha: CGFloat, visible: Bool),
x: CGFloat,
baseline: CGFloat
) {
// Rules before the glyph, and independent of it: an underlined space is a
// real thing and so is an underlined invisible cell.
let underline = Int(cell.attrs >> PARDES_ATTR_UL_SHIFT) & 7
if underline != Int(PARDES_UL_OFF) {
let y = baseline + underlineOffset
fill(ctx, CGRect(x: x, y: y, width: cellWidth, height: 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 - ruleThickness * 2, width: cellWidth, height: ruleThickness),
style.fg, style.alpha)
}
}
if cell.attrs & UInt16(PARDES_ATTR_STRIKETHROUGH) != 0 {
fill(ctx, CGRect(x: x, y: baseline + ascent * 0.3, width: cellWidth, height: ruleThickness),
style.fg, style.alpha)
}
guard style.visible else { return }
// 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 { return }
let heavy = cell.attrs & UInt16(PARDES_ATTR_BOLD) != 0
let slanted = cell.attrs & UInt16(PARDES_ATTR_ITALIC) != 0
let font = heavy ? (slanted ? boldItalic : bold) : (slanted ? italic : regular)
setFill(ctx, style.fg, style.alpha)
// ponytail: no glyph cache — a cmap lookup per cell, and a whole CTLine
// for anything that is not one BMP scalar the face covers. A
// [UnicodeScalar: CGGlyph] map, then an atlas, is the upgrade path when a
// full redraw shows up in Instruments.
let units = text.utf16
if units.count == 1, var character = units.first {
var glyph = CGGlyph(0)
if CTFontGetGlyphsForCharacters(font, &character, &glyph, 1) {
var position = CGPoint(x: x, y: baseline)
CTFontDrawGlyphs(font, &glyph, &position, 1, ctx)
return
}
}
// 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.
let attributed = NSAttributedString(string: text, attributes: [fontAttribute: font])
ctx.textPosition = CGPoint(x: x, y: baseline)
CTLineDraw(CTLineCreateWithAttributedString(attributed as CFAttributedString), ctx)
}
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: - 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.
// Cmd-V is the paste the delegate owns; every other one 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
// The pointer is borrowed for the call and nowhere else, which is the only
// thing the header promises about it.
text.withCString { pardes_key(codepoint, $0, text.utf8.count, modifiers(flags)) }
NotificationCenter.default.post(name: inputNotification, object: self)
}
// MARK: - mouse
override func mouseDown(with event: NSEvent) { send(PARDES_MOUSE_LEFT, PARDES_MOUSE_PRESS, event) }
override func mouseUp(with event: NSEvent) { send(PARDES_MOUSE_LEFT, PARDES_MOUSE_RELEASE, event) }
override func mouseDragged(with event: NSEvent) { send(PARDES_MOUSE_LEFT, PARDES_MOUSE_DRAG, event) }
override func rightMouseDown(with event: NSEvent) { send(PARDES_MOUSE_RIGHT, PARDES_MOUSE_PRESS, event) }
override func rightMouseUp(with event: NSEvent) { send(PARDES_MOUSE_RIGHT, PARDES_MOUSE_RELEASE, event) }
override func rightMouseDragged(with event: NSEvent) { send(PARDES_MOUSE_RIGHT, PARDES_MOUSE_DRAG, 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) { send(PARDES_MOUSE_MIDDLE, PARDES_MOUSE_PRESS, event) }
override func otherMouseUp(with event: NSEvent) { send(PARDES_MOUSE_MIDDLE, PARDES_MOUSE_RELEASE, event) }
override func otherMouseDragged(with event: NSEvent) { send(PARDES_MOUSE_MIDDLE, PARDES_MOUSE_DRAG, event) }
override func mouseMoved(with event: NSEvent) { send(PARDES_MOUSE_NONE, PARDES_MOUSE_MOTION, event) }
override func scrollWheel(with event: NSEvent) {
guard let at = cell(for: event) else { return }
if event.hasPreciseScrollingDeltas {
// The core scrolls a row at a time, so libpardes accumulates the
// sub-row travel and spends it as wheel presses — which is why the
// cell has to travel with the delta.
pardes_scroll(Float(-event.scrollingDeltaY / cellHeight), at.col, at.row)
} else if event.scrollingDeltaY != 0 {
// AppKit's sign is the opposite of the DOM's: positive deltaY means the
// content moved down, which is a scroll back through history.
let button = event.scrollingDeltaY > 0 ? PARDES_MOUSE_WHEEL_UP : PARDES_MOUSE_WHEEL_DOWN
pardes_mouse(button, PARDES_MOUSE_PRESS, at.col, at.row, modifiers(event.modifierFlags))
} else {
return
}
NotificationCenter.default.post(name: inputNotification, object: self)
}
private func send(_ button: pardes_mouse_button_e, _ kind: pardes_mouse_kind_e, _ event: NSEvent) {
guard let at = cell(for: event) else { return }
pardes_mouse(button, kind, at.col, at.row, modifiers(event.modifierFlags))
NotificationCenter.default.post(name: inputNotification, object: self)
}
private func cell(for event: NSEvent) -> (col: UInt16, row: UInt16)? {
// 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 point = convert(event.locationInWindow, from: nil)
let col = min(max(Int(point.x / cellWidth), 0), cols - 1)
let row = min(max(Int(point.y / cellHeight), 0), rows - 1)
return (UInt16(col), 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 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.
|