summaryrefslogtreecommitdiff
path: root/src/macos/Sources/PardesView.swift
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-08 10:44:56 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commitc3c8bbd8d8add99088c774c54bc1acf1e39ec895 (patch)
treea602212f59134909532093765a80c87f219c6c4b /src/macos/Sources/PardesView.swift
parent8aafc3fa24c7475a07259eb06cd5d217f510df98 (diff)
downloadpardes-c3c8bbd8d8add99088c774c54bc1acf1e39ec895.tar.gz
pardes-c3c8bbd8d8add99088c774c54bc1acf1e39ec895.zip
a native macOS backend: libpardes plus an AppKit shell
Adds -Dplatform=macos, a fourth backend beside tty, gui and web. Zig keeps the core, the ptys, every effect and the worker threads; Swift owns NSApplication, the window, input translation, and drawing the cell grid with CoreText. They meet at a hand-written C ABI in src/macos/pardes.h, built as a static library the app links. The ABI is src/web.zig's boundary with the wasm removed, because both hosts are the same animal: someone else owns the clock, feeds events in through flat functions, and reads one packed cell buffer out. The browser proved the shape. The one divergence is that the browser has no processes and forwards every effect to JavaScript, whereas forkpty is right here, so src/macos.zig performs them — spawn, write, resize_pty, save_file, new_file, write_dump, open_link, set_clipboard. lsp, pipe and watch are answered with nothing and marked; the core already tolerates that, since the browser answers none of them either. This deliberately inverts ghostty's split, which was studied first and is written up in docs/ghostty-macos-notes.md. Ghostty hands Zig a bare NSView*, installs its own CALayer and owns the frame clock; Swift never renders. Pardes does the opposite because its frame is already a cell grid and CoreText draws one natively — the alternative is a second hand-rolled glyph atlas, which is what most of gui.zig's 4,300 lines already are. It would also have been written blind: the Swift half cannot be compiled here. What makes the scaffold verifiable rather than dead code is that the Zig half is ordinary POSIX and builds and tests on Linux. Borrowing ghostty's best trick, build.zig translate-C's the header into the test build and src/macos.zig asserts every constant, struct layout, and exported function's arity and widths against it. That guard earned its place immediately: pardes_scroll grew a cell coordinate after the Swift view had been written against the older form. Skipped, and named as the upgrade path in docs/macos.md: the Xcode project, xcframework, lipo and codesigning ghostty needs. All four exist for distribution; a dev build is a swiftc invocation and a directory with a plist. The Swift app is a scaffold and says so — every uncertain API spelling carries an UNVERIFIED marker, and no part of it has been compiled. tty is unaffected: 75/75 snapshot scripts and both unit suites pass.
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.