// 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..= 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.