diff options
Diffstat (limited to 'src/macos/Sources')
| -rw-r--r-- | src/macos/Sources/AppDelegate.swift | 179 | ||||
| -rw-r--r-- | src/macos/Sources/PardesView.swift | 478 | ||||
| -rw-r--r-- | src/macos/Sources/main.swift | 16 |
3 files changed, 673 insertions, 0 deletions
diff --git a/src/macos/Sources/AppDelegate.swift b/src/macos/Sources/AppDelegate.swift new file mode 100644 index 00000000..4df5899c --- /dev/null +++ b/src/macos/Sources/AppDelegate.swift @@ -0,0 +1,179 @@ +import AppKit + +// The macOS host: one window, one view, one core. libpardes owns the state +// machine, the ptys and every worker thread; this file owns the window and the +// pump that lets the core move at all. +// +// PardesView translates events and calls pardes_key / pardes_mouse / +// pardes_scroll itself, but never pardes_tick — the core only queues what it +// was told and does nothing until it is pumped. Rather than grow the view's +// delegate a third method for "I just fed the core", the view posts this +// notification after every input call and we answer it with pump(). The string +// below is the whole contract with PardesView.swift; keep the two in step. +private let didInputNotification = Notification.Name("pardesDidInput") + +final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { + private var window: NSWindow! + private var view: PardesView! + // One pump chain at a time. pump() re-arms itself while a theme transition + // is in flight and every input pumps as well, so without this a burst of + // keys during a fade would leave one 60 Hz chain per keystroke, all of them + // ticking until the fade ended. + private var pumpScheduled: Bool = false + + func applicationDidFinishLaunching(_ notification: Notification) { + // ponytail: 14pt, fixed. The SDL shell steps its font on Ctrl+/Ctrl- + // (gui.zig); doing that here means re-measuring the view's metrics and + // pushing a resize behind it, so it waits until the font has to move. + view = PardesView(fontSize: 14) + + let want = NSSize(width: 1000, height: 700) + window = NSWindow( + contentRect: NSRect(origin: .zero, size: want), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false) + window.title = "pardes" + // We hold this window strongly for the life of the app. Leaving + // isReleasedWhenClosed on would have AppKit release it out from under + // that reference the moment the close button is pressed. + window.isReleasedWhenClosed = false + window.contentView = view + + // Trim the content box down to whole cells: a partial column or row is + // dead space the core can never draw into. + // + // ponytail: snapped once, at launch — a live drag lands wherever the + // mouse lets go. window.contentResizeIncrements would snap every + // resize, at the price of arguing with macOS full-screen tiling. + window.setContentSize(NSSize( + width: (want.width / view.cellWidth).rounded(.down) * view.cellWidth, + height: (want.height / view.cellHeight).rounded(.down) * view.cellHeight)) + window.center() + // On screen before pardes_init, so that the backingScaleFactor read + // when seeding the cell metrics below is the one of the screen the + // window actually landed on. Drawing before the core exists costs an + // empty frame; metrics seeded at 1x would stay wrong forever, because + // a scale change moves no bounds and so fires no resize. + window.makeKeyAndOrderFront(nil) + // AppKit may promote a content view that accepts first responder on its + // own, but "may" is not something to bet every keystroke on, and there + // is no click-to-focus path here that would recover it. + window.makeFirstResponder(view) + // ponytail: no main menu, so no Cmd+Q — the close button and the core's + // Exit builtin are the two ways out. An NSMenu is ten lines, but the + // moment one exists it also has to decide which Cmd keys the core is + // allowed to see, and that is a real decision, not boilerplate. + // + // UNVERIFIED: activate(ignoringOtherApps:) is deprecated on the 14 SDK + // in favour of activate(), which does not exist at our 13.0 deployment + // target. Expect a deprecation warning, not an error. + NSApp.activate(ignoringOtherApps: true) + + var runtime = pardes_runtime_s( + userdata: Unmanaged.passUnretained(self).toOpaque(), + wakeup: { ud in + // A pty reader thread. Every other function in pardes.h is + // main-thread-only, so this hop is the entire body — touching + // any core state here would be the race the hop exists to + // avoid. + let host = Unmanaged<AppDelegate>.fromOpaque(ud!).takeUnretainedValue() + DispatchQueue.main.async { host.pump() } + }, + set_clipboard: { _, text, len in + // Main thread, from inside pardes_tick. `text` is borrowed for + // the length of the call, so the String has to be a copy. + var yank: String = "" + if let text = text, len > 0 { + let bytes = UnsafeRawBufferPointer(start: UnsafeRawPointer(text), count: len) + yank = String(decoding: bytes, as: UTF8.self) + } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + _ = pasteboard.setString(yank, forType: .string) + }) + + // The real grid, never a placeholder: the core holds each shell's + // greeting until it has seen a size, so a correction sent afterwards + // arrives with the first prompt already wrapped to the wrong width. + let grid = view.gridSize + let rc = pardes_init(&runtime, grid.cols, grid.rows) + if rc != 0 { + NSLog("pardes: the core failed to boot at \(grid.cols)x\(grid.rows), pardes_init returned \(rc)") + // Not NSApp.terminate — that runs applicationWillTerminate, which + // would call pardes_deinit on a core that never came up. + exit(1) + } + + // Only now, after init. setContentSize above resized the view, and a + // pardesViewDidResize landing before pardes_init would have pushed a + // resize into a core that did not exist yet. + view.delegate = self + NotificationCenter.default.addObserver( + self, selector: #selector(inputArrived(_:)), name: didInputNotification, object: nil) + + // pardes_init takes no cell metrics, so the core's PDF placement would + // have none until the user first dragged the window. This seeds them, + // and pumps. Repeating the size init just saw is safe by design: the + // core compares the effective pixel viewport, not the resize event, so + // duplicate SIGWINCH-shaped notifications are already a no-op. + pardesViewDidResize(view) + } + + @objc private func inputArrived(_ notification: Notification) { + pump() + } + + // Every path into the core ends here. pardes_tick drains the ptys and runs + // the effects the core queued, so nothing the user did takes hold until it + // runs. + private func pump() { + if pardes_tick() { view.needsDisplay = true } + if pardes_should_quit() { + NSApp.terminate(nil) + return + } + // A theme transition is the only thing that moves on its own, and it is + // ten 16 ms steps (animation.zig). Nothing else re-arms this, which is + // the point of an event-driven host: an idle pardes costs no CPU, where + // the SDL shell spins. + if pardes_animating() && !pumpScheduled { + pumpScheduled = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.016) { + self.pumpScheduled = false + self.pump() + } + } + } + + func pardesViewDidResize(_ view: PardesView) { + let grid = view.gridSize + // cell_w/cell_h are physical pixels. The core hands them to the native + // PDF placement path, and a point is two pixels on a Retina display, so + // passing points would place every page at half size. + let scale: CGFloat = window.backingScaleFactor + pardes_resize( + grid.cols, grid.rows, + UInt16((view.cellWidth * scale).rounded()), + UInt16((view.cellHeight * scale).rounded())) + pump() + } + + func pardesViewRequestsPaste(_ view: PardesView) { + guard let text = NSPasteboard.general.string(forType: .string) else { return } + // Swift lends a temporary NUL-terminated UTF-8 buffer for the duration + // of the call, which is exactly as long as the core borrows it. + pardes_paste(text, text.utf8.count) + pump() + } + + // ponytail: one window, no tabs — the core has no multi-window notion, so + // the last window closing really is the end of the process. + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + func applicationWillTerminate(_ notification: Notification) { + pardes_deinit() + } +} 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. diff --git a/src/macos/Sources/main.swift b/src/macos/Sources/main.swift new file mode 100644 index 00000000..6cb2646e --- /dev/null +++ b/src/macos/Sources/main.swift @@ -0,0 +1,16 @@ +import AppKit + +// Ghostty's ordering lesson: whatever must exist before AppKit boots goes above +// app.run(), because after that call the runloop owns this thread forever. +// pardes has nothing in that class — the core cannot come up until there is a +// window to measure, so pardes_init waits for applicationDidFinishLaunching. + +let app = NSApplication.shared +app.setActivationPolicy(.regular) + +// NSApplication does not own its delegate. This is a global, so it lives as +// long as the process does; the same line inside a function would not. +let delegate = AppDelegate() +app.delegate = delegate + +app.run() |
