summaryrefslogtreecommitdiff
path: root/src/macos/Sources/PardesView.swift
diff options
context:
space:
mode:
Diffstat (limited to 'src/macos/Sources/PardesView.swift')
-rw-r--r--src/macos/Sources/PardesView.swift478
1 files changed, 478 insertions, 0 deletions
diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift
new file mode 100644
index 00000000..2dfa4305
--- /dev/null
+++ b/src/macos/Sources/PardesView.swift
@@ -0,0 +1,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.