diff options
Diffstat (limited to 'src/macos')
| -rw-r--r-- | src/macos/Info.plist | 20 | ||||
| -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 | ||||
| -rwxr-xr-x | src/macos/build-app.sh | 45 | ||||
| -rw-r--r-- | src/macos/pardes.h | 204 |
6 files changed, 942 insertions, 0 deletions
diff --git a/src/macos/Info.plist b/src/macos/Info.plist new file mode 100644 index 00000000..9e8f65f7 --- /dev/null +++ b/src/macos/Info.plist @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>CFBundleName</key> + <string>pardes</string> + <key>CFBundleIdentifier</key> + <string>cafe.0x4200.pardes</string> + <key>CFBundleExecutable</key> + <string>pardes</string> + <key>CFBundlePackageType</key> + <string>APPL</string> + <key>NSHighResolutionCapable</key> + <true/> + <key>LSMinimumSystemVersion</key> + <string>13.0</string> + <key>NSPrincipalClass</key> + <string>NSApplication</string> +</dict> +</plist> 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() diff --git a/src/macos/build-app.sh b/src/macos/build-app.sh new file mode 100755 index 00000000..6734f8d3 --- /dev/null +++ b/src/macos/build-app.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Assemble pardes.app from libpardes.a and the Swift sources. Run it through +# `zig build macos-app -Dplatform=macos`, or by hand with the install prefix as +# $1 once `zig build -Dplatform=macos` has produced the library. +# +# There is no Xcode project on purpose. An .app is a directory with a plist and +# a binary in it, swiftc ships with the Command Line Tools, and a hand-written +# pbxproj would be a second build system to keep in step for no gain at this +# stage. What Xcode buys — an xcframework of universal slices, codesigning, +# notarization, a DMG — is distribution machinery; see docs/macos.md for the +# upgrade path when that day comes. +set -eu + +root=$(cd "$(dirname "$0")/../.." && pwd) +out=${1:-"$root/zig-out"} +app="$out/pardes.app" +lib="$out/lib/libpardes.a" + +[ -f "$lib" ] || { echo "missing $lib — run: zig build -Dplatform=macos" >&2; exit 1; } +command -v swiftc >/dev/null || { echo "swiftc not found (needs macOS + Command Line Tools)" >&2; exit 1; } + +rm -rf "$app" +mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" +cp "$root/src/macos/Info.plist" "$app/Contents/Info.plist" + +# -import-objc-header rather than a module map: the header is consumed straight +# from the source tree, so there is nothing to stage and nothing to keep in +# sync. A module map is what an xcframework needs, and there isn't one. +# +# -lc++ because ghostty-vt pulls in simdutf and highway, which are C++. The Zig +# side bundles compiler_rt/ubsan_rt into the archive (see build.zig), so the +# C++ runtime is the only thing left for this link to supply. +# -target is not optional. Without it swiftc uses the host triple, so +# LC_BUILD_VERSION records whatever macOS built the thing and dyld refuses to +# launch it on anything older — the Info.plist's LSMinimumSystemVersion is a +# claim, not the enforcement. It is also what turns on the availability +# diagnostics that catch a post-13 API before a user does. +swiftc -O -target "$(uname -m)-apple-macos13.0" \ + -import-objc-header "$root/src/macos/pardes.h" \ + -o "$app/Contents/MacOS/pardes" \ + "$root"/src/macos/Sources/*.swift \ + "$lib" -lc++ \ + -framework AppKit -framework CoreText -framework CoreGraphics + +echo "built $app" diff --git a/src/macos/pardes.h b/src/macos/pardes.h new file mode 100644 index 00000000..e0d6fa32 --- /dev/null +++ b/src/macos/pardes.h @@ -0,0 +1,204 @@ +// libpardes — the C ABI the macOS app links against. +// +// Hand-written, and kept honest by a test: build.zig translate-C's this file +// into the unit-test build, and src/macos.zig asserts every constant and +// struct layout below against the Zig side (ghostty's trick — see docs/macos.md). +// Change nothing here without running `zig build unit-test -Dplatform=macos`. +// +// Division of labour, which is the whole design in two lines: +// Zig owns the core, the ptys, every effect, and the worker threads. +// Swift owns NSApplication, the window, input translation, and drawing. +// +// There is exactly one core per process, so there are no handles: the state is +// a file-scoped singleton, same as src/web.zig. Every function below must be +// called from the main thread. The one exception is the `wakeup` callback, +// which fires from a pty reader thread. + +#ifndef PARDES_H +#define PARDES_H + +#include <stdbool.h> +#include <stddef.h> +#include <stdint.h> + +#ifdef __cplusplus +extern "C" { +#endif + +// ---------------------------------------------------------------- frame + +// One rendered cell. Mirrors pardes.Cell (src/pardes.zig) flattened for the +// wire; the identical encoding is spelled a second time for the browser in +// src/web.zig as WebCell. +typedef struct { + // UTF-8 grapheme, `len` bytes. The core caps graphemes at seven bytes; the + // eighth is always zero so a debugger prints something sensible. + uint8_t text[8]; + // 0x01000000 terminal default + // 0x02000000 | index 256-color palette entry + // 0x00RRGGBB direct color + uint32_t fg; + uint32_t bg; + // bit 0 bold, 1 dim, 2 italic, 3 blink, 4 reverse, 5 invisible, + // 6 strikethrough; bits 8.. hold PARDES_UL_*. + uint16_t attrs; + uint8_t len; + // bit 0: the core never painted this cell — draw it as the default cell + // (background only), which is what makes a partial redraw cheap. + uint8_t flags; +} pardes_cell_s; + +#define PARDES_COLOR_DEFAULT 0x01000000u +#define PARDES_COLOR_INDEXED 0x02000000u +#define PARDES_COLOR_TAG_MASK 0xff000000u +#define PARDES_COLOR_RGB_MASK 0x00ffffffu + +#define PARDES_ATTR_BOLD 0x0001u +#define PARDES_ATTR_DIM 0x0002u +#define PARDES_ATTR_ITALIC 0x0004u +#define PARDES_ATTR_BLINK 0x0008u +#define PARDES_ATTR_REVERSE 0x0010u +#define PARDES_ATTR_INVISIBLE 0x0020u +#define PARDES_ATTR_STRIKETHROUGH 0x0040u +#define PARDES_ATTR_UL_SHIFT 8 +// Literals rather than (1u << n): Swift's macro importer is dependable on a +// plain integer and less so on an expression, and nothing here can compile the +// Swift side to find out. The ABI guard asserts each value against the encoder. + +#define PARDES_UL_OFF 0 +#define PARDES_UL_SINGLE 1 +#define PARDES_UL_DOUBLE 2 +#define PARDES_UL_CURLY 3 +#define PARDES_UL_DOTTED 4 +#define PARDES_UL_DASHED 5 + +#define PARDES_CELL_DEFAULT 0x01u + +// ---------------------------------------------------------------- input + +// Modifier bitmask shared by key and mouse events. +#define PARDES_MOD_CTRL 0x0001u +#define PARDES_MOD_ALT 0x0002u +#define PARDES_MOD_SHIFT 0x0004u + +// Keys that carry no text. The four editing keys are their ASCII controls; +// everything else lives in a private-use plane so it can never collide with a +// real codepoint arriving as text. +#define PARDES_KEY_ENTER 0x0Du +#define PARDES_KEY_ESCAPE 0x1Bu +#define PARDES_KEY_TAB 0x09u +#define PARDES_KEY_BACKSPACE 0x7Fu +#define PARDES_KEY_UP 0xF0001u +#define PARDES_KEY_DOWN 0xF0002u +#define PARDES_KEY_LEFT 0xF0003u +#define PARDES_KEY_RIGHT 0xF0004u +#define PARDES_KEY_HOME 0xF0005u +#define PARDES_KEY_END 0xF0006u +#define PARDES_KEY_PAGE_UP 0xF0007u +#define PARDES_KEY_PAGE_DOWN 0xF0008u +#define PARDES_KEY_DELETE 0xF0009u + +// acme's three buttons carry the whole vocabulary: 1 selects, 2 executes, +// 3 looks. Wheel buttons are ordinary buttons, not a separate axis. +typedef enum { + PARDES_MOUSE_LEFT = 0, + PARDES_MOUSE_MIDDLE = 1, + PARDES_MOUSE_RIGHT = 2, + PARDES_MOUSE_WHEEL_UP = 3, + PARDES_MOUSE_WHEEL_DOWN = 4, + PARDES_MOUSE_WHEEL_LEFT = 5, + PARDES_MOUSE_WHEEL_RIGHT = 6, + PARDES_MOUSE_NONE = 7, +} pardes_mouse_button_e; + +typedef enum { + PARDES_MOUSE_PRESS = 0, + PARDES_MOUSE_RELEASE = 1, + PARDES_MOUSE_MOTION = 2, + PARDES_MOUSE_DRAG = 3, +} pardes_mouse_kind_e; + +// ---------------------------------------------------------------- runtime + +// What the host lends the core. Two callbacks, because everything else the +// core wants done it already does itself: it owns the ptys, and it opens URLs +// through /usr/bin/open. Copied by value during pardes_init, so the struct +// need only outlive that call. +typedef struct { + // Passed back to every callback below. Conventionally the AppDelegate. + void *userdata; + + // "Your state moved; please pump me." Called from a pty reader thread, so + // the host must hop to the main thread before calling pardes_tick — see + // AppDelegate.wakeup, which does exactly one DispatchQueue.main.async. + void (*wakeup)(void *userdata); + + // The yank register changed. `text` is borrowed for the duration of the + // call only. Main thread, inside pardes_tick. + void (*set_clipboard)(void *userdata, const char *text, size_t len); +} pardes_runtime_s; + +// ---------------------------------------------------------------- lifecycle + +// Returns 0 on success, or a nonzero opaque error code. Pass the real window +// grid, not a placeholder: init delivers it as the first resize EVENT (the +// core defers each shell's greeting until it has seen one), and the first +// forkpty takes its winsize straight off the core — boot at 80x24 and the +// shell draws its first prompt to a width the window never had. +int pardes_init(const pardes_runtime_s *runtime, uint16_t cols, uint16_t rows); +void pardes_deinit(void); + +// Drain pty output into the core and perform the effects it queued. Call after +// every input function and on every wakeup. Returns true if anything changed +// and the host should mark its view dirty. +bool pardes_tick(void); + +// The core asked to exit (the Exit builtin, or the last pane closing). +bool pardes_should_quit(void); + +// A theme transition is mid-flight and wants ~60 Hz ticks until it settles. +bool pardes_animating(void); + +// ---------------------------------------------------------------- events in + +// `cp` is a codepoint or one of PARDES_KEY_*; `text`/`len` are the host's +// already-composed characters for printable keys and empty for functional +// ones. `text` is borrowed for the call. +void pardes_key(uint32_t cp, const char *text, size_t len, uint32_t mods); +void pardes_paste(const char *text, size_t len); +void pardes_mouse(pardes_mouse_button_e button, pardes_mouse_kind_e kind, + uint16_t col, uint16_t row, uint32_t mods); + +// Trackpad/precision wheel distance in rows, sign following the grid (positive +// scrolls down). The core has no fractional scroll — it moves a row at a time — +// so libpardes accumulates here and emits whole-row wheel presses, keeping the +// remainder. Both other shells do this same accumulation host-side (stepScroll +// in gui.zig, the drain loop in web/app.mjs); it lives in Zig here so the Swift +// side stays a translator. A discrete wheel notch should go through +// pardes_mouse instead. +void pardes_scroll(float delta_rows, uint16_t col, uint16_t row); + +// `cell_w`/`cell_h` are one cell in physical pixels, which only the native PDF +// placement path reads. Pass the backing-store size, not points. +void pardes_resize(uint16_t cols, uint16_t rows, uint16_t cell_w, + uint16_t cell_h); + +// ---------------------------------------------------------------- frame out + +// Render one frame. Returns the cell count (cols * rows), or 0 on failure. +// The buffer returned by pardes_frame_cells is valid until the next call. +uint32_t pardes_frame(void); +const pardes_cell_s *pardes_frame_cells(void); +uint16_t pardes_frame_cols(void); +uint16_t pardes_frame_rows(void); + +// -1 when the cursor is hidden. `bar` asks for a thin insert-mode caret. +int32_t pardes_cursor_x(void); +int32_t pardes_cursor_y(void); +bool pardes_cursor_bar(void); + +#ifdef __cplusplus +} +#endif + +#endif // PARDES_H |
