diff options
Diffstat (limited to 'src/macos/Sources/PardesView.swift')
| -rw-r--r-- | src/macos/Sources/PardesView.swift | 478 |
1 files changed, 478 insertions, 0 deletions
diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift new file mode 100644 index 00000000..2dfa4305 --- /dev/null +++ b/src/macos/Sources/PardesView.swift @@ -0,0 +1,478 @@ +// The AppKit half of the macOS backend: NSEvent in, pardes_* out, one +// CoreGraphics pass per frame back. +// +// This file keeps no model of the screen. The core owns the grid and hands it +// over whole through src/macos/pardes.h, so everything here is translation, and +// the only state worth holding is the font metrics — which are expensive to +// measure and never change. +// +// Every C constant below is wrapped in an explicit conversion (UInt16(...), +// UInt32(...)) rather than used bare. A macro's imported Swift type is decided +// by the importer, not by us, and this file should not have an opinion about it. + +import AppKit +import CoreText + +protocol PardesViewDelegate: AnyObject { + func pardesViewDidResize(_ view: PardesView) + func pardesViewRequestsPaste(_ view: PardesView) +} + +// Matches bg_default/fg_default in src/gui/gui.zig and DEFAULT_FG/DEFAULT_BG in +// src/web/app.mjs. Three shells render the same core; if these drift, comparing +// a screenshot across backends stops meaning anything. +private let defaultFG: UInt32 = 0xCC_CC_CC +private let defaultBG: UInt32 = 0x12_12_12 + +private let inputNotification = Notification.Name("pardesDidInput") + +// UNVERIFIED: kCTFontAttributeName bridged through NSAttributedString.Key. It is +// the same string as .font, but spelling the CoreText key means the value stays +// a CTFont instead of being bridged to NSFont on the way in. +private let fontAttribute = NSAttributedString.Key(kCTFontAttributeName as String) + +// The named sixteen, then xterm's 6x6x6 cube, then the 24-step grey ramp. These +// sixteen are app.mjs's, not gui.zig's: gui.zig borrows ghostty's palette +// because it already links it, and the two disagree on the base colors. +private let ansi16: [UInt32] = [ + 0x00_00_00, 0xCC_00_00, 0x4E_9A_06, 0xC4_A0_00, + 0x34_65_A4, 0x75_50_7B, 0x06_98_9A, 0xD3_D7_CF, + 0x55_57_53, 0xEF_29_29, 0x8A_E2_34, 0xFC_E9_4F, + 0x72_9F_CF, 0xAD_7F_A8, 0x34_E2_E2, 0xEE_EE_EC, +] + +private func paletteColor(_ index: UInt8) -> UInt32 { + if index < 16 { return ansi16[Int(index)] } + if index >= 232 { + let grey = UInt32(8 + (Int(index) - 232) * 10) + return grey << 16 | grey << 8 | grey + } + let n = Int(index) - 16 + func level(_ part: Int) -> UInt32 { part == 0 ? 0 : UInt32(55 + part * 40) } + return level(n / 36) << 16 | level(n / 6 % 6) << 8 | level(n % 6) +} + +private func decodeColor(_ encoded: UInt32, _ fallback: UInt32) -> UInt32 { + if encoded == UInt32(PARDES_COLOR_DEFAULT) { return fallback } + if encoded & UInt32(PARDES_COLOR_TAG_MASK) == UInt32(PARDES_COLOR_INDEXED) { + return paletteColor(UInt8(encoded & 0xFF)) + } + return encoded & UInt32(PARDES_COLOR_RGB_MASK) +} + +/// `block` means the filled cursor sits on this cell. +private func resolve( + _ cell: pardes_cell_s, + block: Bool +) -> (fg: UInt32, bg: UInt32, alpha: CGFloat, visible: Bool) { + // The core never painted this cell, which is most of the screen most of the + // time, so this branch is the one that has to stay cheap. + if cell.flags & UInt8(PARDES_CELL_DEFAULT) != 0 { + return block ? (defaultBG, defaultFG, 1, false) : (defaultFG, defaultBG, 1, false) + } + var fg = decodeColor(cell.fg, defaultFG) + var bg = decodeColor(cell.bg, defaultBG) + // The block cursor is a second reverse, so a cell that is already reversed + // cancels back to normal underneath it. Same rule as emitInstance in + // src/gui/gui.zig; the two must not drift. + var reverse = block + if cell.attrs & UInt16(PARDES_ATTR_REVERSE) != 0 { reverse = !reverse } + if reverse { swap(&fg, &bg) } + // ponytail: PARDES_ATTR_BLINK is decoded into nothing. Honouring it costs a + // timer plus a repaint budget for a bit nothing in pardes emits today; drive + // setNeedsDisplay from an NSTimer here when something does. + let visible = cell.attrs & UInt16(PARDES_ATTR_INVISIBLE) == 0 && cell.len > 0 + // The other shells scale the channels by 6/10. Over a dark background alpha + // lands in the same place and costs one blend instead of three multiplies. + let alpha: CGFloat = cell.attrs & UInt16(PARDES_ATTR_DIM) != 0 ? 0.6 : 1 + return (fg, bg, alpha, visible) +} + +private func advance(_ font: CTFont, _ character: UniChar) -> CGFloat { + var input = character + var glyph = CGGlyph(0) + guard CTFontGetGlyphsForCharacters(font, &input, &glyph, 1) else { return 0 } + var size = CGSize.zero + CTFontGetAdvancesForGlyphs(font, .horizontal, &glyph, &size, 1) + return size.width +} + +private func modifiers(_ flags: NSEvent.ModifierFlags) -> UInt32 { + var mods: UInt32 = 0 + if flags.contains(.control) { mods |= UInt32(PARDES_MOD_CTRL) } + if flags.contains(.option) { mods |= UInt32(PARDES_MOD_ALT) } + if flags.contains(.shift) { mods |= UInt32(PARDES_MOD_SHIFT) } + return mods +} + +final class PardesView: NSView { + let cellWidth: CGFloat + let cellHeight: CGFloat + weak var delegate: PardesViewDelegate? + + private let regular: CTFont + private let bold: CTFont + private let italic: CTFont + private let boldItalic: CTFont + private let ascent: CGFloat + private let ruleThickness: CGFloat + private let underlineOffset: CGFloat + private var reportedCols: UInt16 = 0 + private var reportedRows: UInt16 = 0 + + init(fontSize: CGFloat) { + let system = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) + var face = CTFontCreateWithName(system.fontName as CFString, fontSize, nil) + // The whole layout is a fixed grid, so a proportional face is not a + // cosmetic problem, it is a broken screen. Resolving the system monospace + // font by name is a lookup that can miss; "M" and "i" disagreeing on + // advance is the cheapest possible proof that it did. + let em = advance(face, 0x4D) + if em <= 0 || abs(em - advance(face, 0x69)) > 0.01 { + face = CTFontCreateWithName("Menlo" as CFString, fontSize, nil) + } + + // UNVERIFIED: CTFontSymbolicTraits member spelling (.traitBold/.traitItalic). + // A face with no italic cut returns nil here, hence the fallback to `face`. + func variant(_ traits: CTFontSymbolicTraits) -> CTFont { + CTFontCreateCopyWithSymbolicTraits(face, fontSize, nil, traits, traits) ?? face + } + regular = face + bold = variant(.traitBold) + italic = variant(.traitItalic) + boldItalic = variant([.traitBold, .traitItalic]) + + cellWidth = max(1, advance(face, 0x4D)) + ascent = CTFontGetAscent(face) + cellHeight = max(1, (ascent + CTFontGetDescent(face) + CTFontGetLeading(face)).rounded(.up)) + ruleThickness = max(1, CTFontGetUnderlineThickness(face)) + underlineOffset = CTFontGetUnderlinePosition(face) + + // 80x24 only so the window has a size to open at; the AppDelegate reads + // gridSize back and boots the core with whatever it actually got. + super.init(frame: NSRect(x: 0, y: 0, width: cellWidth * 80, height: cellHeight * 24)) + } + + required init?(coder: NSCoder) { fatalError("PardesView is built in code, not a nib") } + + // Row 0 at the top, so the drawing arithmetic reads like the grid it is. + override var isFlipped: Bool { true } + override var isOpaque: Bool { true } + override var acceptsFirstResponder: Bool { true } + + var gridSize: (cols: UInt16, rows: UInt16) { + let cols = min(max((bounds.width / cellWidth).rounded(.down), 1), CGFloat(UInt16.max)) + let rows = min(max((bounds.height / cellHeight).rounded(.down), 1), CGFloat(UInt16.max)) + return (UInt16(cols), UInt16(rows)) + } + + // MARK: - drawing + + override func draw(_ dirtyRect: NSRect) { + guard let ctx = NSGraphicsContext.current?.cgContext else { return } + // ponytail: dirtyRect is ignored. pardes_frame() re-renders the whole grid + // whatever we do, so clipping would save fills and nothing else. Narrow + // the row loops to the dirty band if that ever shows up in a profile. + let count = pardes_frame() + let cols = Int(pardes_frame_cols()) + let rows = Int(pardes_frame_rows()) + fill(ctx, bounds, defaultBG, 1) + guard cols > 0, rows > 0, Int(count) == cols * rows, let cells = pardes_frame_cells() else { return } + + // -1 when hidden, which never matches a real cell, so hidden and "bar, so + // not a block" collapse into the same comparison. + let bar = pardes_cursor_bar() + let blockX = bar ? -1 : Int(pardes_cursor_x()) + let blockY = bar ? -1 : Int(pardes_cursor_y()) + + // Background first, batched into runs of equal color. A full redraw is + // 80x24 cells at the low end and per-cell fills are exactly what makes + // that feel slow. Antialiasing is off because touching rects share an + // edge, and blending that edge twice draws a visible seam. + ctx.setShouldAntialias(false) + for row in 0..<rows { + let base = row * cols + let y = CGFloat(row) * cellHeight + var start = 0 + var color = resolve(cells[base], block: blockY == row && blockX == 0).bg + for col in 1...cols { + // A real color is 24 bits, so .max is a sentinel that cannot + // compare equal and therefore always flushes the last run. + let next: UInt32 = col == cols + ? .max + : resolve(cells[base + col], block: blockY == row && blockX == col).bg + if next == color { continue } + fill(ctx, CGRect(x: CGFloat(start) * cellWidth, y: y, + width: CGFloat(col - start) * cellWidth, height: cellHeight), + color, 1) + start = col + color = next + } + } + + // CTFontDrawGlyphs lays glyph outlines out with +y up, and isFlipped hands + // us a y-down CTM, so drawing text directly in view space renders every + // line mirrored. Un-flip once for the whole glyph pass and convert each + // baseline into it rather than fighting the text matrix per cell. + ctx.setShouldAntialias(true) + ctx.saveGState() + ctx.textMatrix = .identity + ctx.translateBy(x: 0, y: bounds.height) + ctx.scaleBy(x: 1, y: -1) + let height = bounds.height + for row in 0..<rows { + let base = row * cols + let baseline = height - (CGFloat(row) * cellHeight + ascent) + for col in 0..<cols { + let cell = cells[base + col] + if cell.flags & UInt8(PARDES_CELL_DEFAULT) != 0 { continue } + let style = resolve(cell, block: blockY == row && blockX == col) + drawCell(ctx, cell, style, x: CGFloat(col) * cellWidth, baseline: baseline) + } + } + ctx.restoreGState() + + if bar { + let x = Int(pardes_cursor_x()), y = Int(pardes_cursor_y()) + if x >= 0, y >= 0, x < cols, y < rows { + // gui.zig paints U+258F here. A rect is the same picture without + // asking the font for a glyph it may not carry. + let fg = resolve(cells[y * cols + x], block: false).fg + fill(ctx, CGRect(x: CGFloat(x) * cellWidth, y: CGFloat(y) * cellHeight, + width: max(1, cellWidth / 8), height: cellHeight), fg, 1) + } + } + } + + private func drawCell( + _ ctx: CGContext, + _ cell: pardes_cell_s, + _ style: (fg: UInt32, bg: UInt32, alpha: CGFloat, visible: Bool), + x: CGFloat, + baseline: CGFloat + ) { + // Rules before the glyph, and independent of it: an underlined space is a + // real thing and so is an underlined invisible cell. + let underline = Int(cell.attrs >> PARDES_ATTR_UL_SHIFT) & 7 + if underline != Int(PARDES_UL_OFF) { + let y = baseline + underlineOffset + fill(ctx, CGRect(x: x, y: y, width: cellWidth, height: ruleThickness), style.fg, style.alpha) + // ponytail: curly, dotted and dashed all come out solid; only double + // earns its second rule. ctx.setLineDash for two of them and a sine + // path for the third is the upgrade, once anyone notices. + if underline == Int(PARDES_UL_DOUBLE) { + fill(ctx, CGRect(x: x, y: y - ruleThickness * 2, width: cellWidth, height: ruleThickness), + style.fg, style.alpha) + } + } + if cell.attrs & UInt16(PARDES_ATTR_STRIKETHROUGH) != 0 { + fill(ctx, CGRect(x: x, y: baseline + ascent * 0.3, width: cellWidth, height: ruleThickness), + style.fg, style.alpha) + } + guard style.visible else { return } + + // UNVERIFIED: withUnsafeBytes over an imported C fixed-size array, which + // Swift models as an 8-tuple. String(decoding:) substitutes U+FFFD rather + // than trapping, and the core has shipped invalid UTF-8 through here + // before — the renderer must not be the thing that dies over it. prefix + // clamps, so a bogus len cannot walk off the eight bytes either. + let text = withUnsafeBytes(of: cell.text) { raw in + String(decoding: raw.prefix(Int(cell.len)), as: UTF8.self) + } + guard !text.isEmpty, text != " " else { return } + + let heavy = cell.attrs & UInt16(PARDES_ATTR_BOLD) != 0 + let slanted = cell.attrs & UInt16(PARDES_ATTR_ITALIC) != 0 + let font = heavy ? (slanted ? boldItalic : bold) : (slanted ? italic : regular) + setFill(ctx, style.fg, style.alpha) + + // ponytail: no glyph cache — a cmap lookup per cell, and a whole CTLine + // for anything that is not one BMP scalar the face covers. A + // [UnicodeScalar: CGGlyph] map, then an atlas, is the upgrade path when a + // full redraw shows up in Instruments. + let units = text.utf16 + if units.count == 1, var character = units.first { + var glyph = CGGlyph(0) + if CTFontGetGlyphsForCharacters(font, &character, &glyph, 1) { + var position = CGPoint(x: x, y: baseline) + CTFontDrawGlyphs(font, &glyph, &position, 1, ctx) + return + } + } + // Emoji, combining marks and anything the face is missing: CTLine finds a + // fallback font. The position is set explicitly per cell — this is a fixed + // grid, and letting CoreText advance across a row would drift off it. + let attributed = NSAttributedString(string: text, attributes: [fontAttribute: font]) + ctx.textPosition = CGPoint(x: x, y: baseline) + CTLineDraw(CTLineCreateWithAttributedString(attributed as CFAttributedString), ctx) + } + + private func setFill(_ ctx: CGContext, _ rgb: UInt32, _ alpha: CGFloat) { + ctx.setFillColor(red: CGFloat((rgb >> 16) & 0xFF) / 255, + green: CGFloat((rgb >> 8) & 0xFF) / 255, + blue: CGFloat(rgb & 0xFF) / 255, + alpha: alpha) + } + + private func fill(_ ctx: CGContext, _ rect: CGRect, _ rgb: UInt32, _ alpha: CGFloat) { + setFill(ctx, rgb, alpha) + ctx.fill(rect) + } + + // MARK: - keyboard + + override func keyDown(with event: NSEvent) { + let flags = event.modifierFlags + // The ABI has no super bit, so a Command chord cannot be expressed at all. + // Cmd-V is the paste the delegate owns; every other one is swallowed + // rather than delivered as the bare keystroke the core would insert. + if flags.contains(.command) { + if event.charactersIgnoringModifiers?.lowercased() == "v" { + delegate?.pardesViewRequestsPaste(self) + } + return + } + + let control = flags.contains(.control) + let option = flags.contains(.option) + // With ctrl or option down, `characters` is already the composed result — + // Ctrl-A is U+0001, Option-A is "å". The core wants the base key and no + // text, which is what app.mjs does with the same two bits. + let composed = (control || option ? event.charactersIgnoringModifiers : event.characters) ?? "" + guard let scalar = composed.unicodeScalars.first else { return } + + var codepoint = scalar.value + // 0xF700..0xF8FF is AppKit's private-use block for function keys. The nine + // the core names get translated; the rest (F1-F12, Insert, the keypad) are + // dropped, because passing one through paints a stray glyph. + // These constants come from an unnamed C enum, so which width Swift picks + // for them is not something this file should depend on: wrapping each in + // UInt32() compiles whether they import as Int, Int32 or UInt32. + if scalar.value >= 0xF700 && scalar.value <= 0xF8FF { + switch scalar.value { + case UInt32(NSUpArrowFunctionKey): codepoint = UInt32(PARDES_KEY_UP) + case UInt32(NSDownArrowFunctionKey): codepoint = UInt32(PARDES_KEY_DOWN) + case UInt32(NSLeftArrowFunctionKey): codepoint = UInt32(PARDES_KEY_LEFT) + case UInt32(NSRightArrowFunctionKey): codepoint = UInt32(PARDES_KEY_RIGHT) + case UInt32(NSHomeFunctionKey): codepoint = UInt32(PARDES_KEY_HOME) + case UInt32(NSEndFunctionKey): codepoint = UInt32(PARDES_KEY_END) + case UInt32(NSPageUpFunctionKey): codepoint = UInt32(PARDES_KEY_PAGE_UP) + case UInt32(NSPageDownFunctionKey): codepoint = UInt32(PARDES_KEY_PAGE_DOWN) + case UInt32(NSDeleteFunctionKey): codepoint = UInt32(PARDES_KEY_DELETE) + default: return + } + } + + // Enter, Tab, Escape and Backspace already arrive as the ASCII controls the + // header names. Two keys do not: the keypad's Enter is U+0003 and Shift-Tab + // is U+0019, neither of which is in the function-key block above, so without + // this both reach the core as a control it has no binding for and do + // nothing. The browser shell resolves them from the DOM key name and this + // is what keeps the two hosts saying the same thing. + if codepoint == 0x03 { codepoint = UInt32(PARDES_KEY_ENTER) } + if codepoint == 0x19 { codepoint = UInt32(PARDES_KEY_TAB) } + + // A control character is functional, and functional keys must not also + // carry text. + let functional = codepoint < 0x20 || codepoint == 0x7F || codepoint >= 0xF0000 + let text = functional || control || option ? "" : composed + // The pointer is borrowed for the call and nowhere else, which is the only + // thing the header promises about it. + text.withCString { pardes_key(codepoint, $0, text.utf8.count, modifiers(flags)) } + NotificationCenter.default.post(name: inputNotification, object: self) + } + + // MARK: - mouse + + override func mouseDown(with event: NSEvent) { send(PARDES_MOUSE_LEFT, PARDES_MOUSE_PRESS, event) } + override func mouseUp(with event: NSEvent) { send(PARDES_MOUSE_LEFT, PARDES_MOUSE_RELEASE, event) } + override func mouseDragged(with event: NSEvent) { send(PARDES_MOUSE_LEFT, PARDES_MOUSE_DRAG, event) } + override func rightMouseDown(with event: NSEvent) { send(PARDES_MOUSE_RIGHT, PARDES_MOUSE_PRESS, event) } + override func rightMouseUp(with event: NSEvent) { send(PARDES_MOUSE_RIGHT, PARDES_MOUSE_RELEASE, event) } + override func rightMouseDragged(with event: NSEvent) { send(PARDES_MOUSE_RIGHT, PARDES_MOUSE_DRAG, event) } + // otherMouse covers button 2 and up. acme's vocabulary stops at three, so + // every one of them lands on middle rather than being invented into a fourth. + override func otherMouseDown(with event: NSEvent) { send(PARDES_MOUSE_MIDDLE, PARDES_MOUSE_PRESS, event) } + override func otherMouseUp(with event: NSEvent) { send(PARDES_MOUSE_MIDDLE, PARDES_MOUSE_RELEASE, event) } + override func otherMouseDragged(with event: NSEvent) { send(PARDES_MOUSE_MIDDLE, PARDES_MOUSE_DRAG, event) } + override func mouseMoved(with event: NSEvent) { send(PARDES_MOUSE_NONE, PARDES_MOUSE_MOTION, event) } + + override func scrollWheel(with event: NSEvent) { + guard let at = cell(for: event) else { return } + if event.hasPreciseScrollingDeltas { + // The core scrolls a row at a time, so libpardes accumulates the + // sub-row travel and spends it as wheel presses — which is why the + // cell has to travel with the delta. + pardes_scroll(Float(-event.scrollingDeltaY / cellHeight), at.col, at.row) + } else if event.scrollingDeltaY != 0 { + // AppKit's sign is the opposite of the DOM's: positive deltaY means the + // content moved down, which is a scroll back through history. + let button = event.scrollingDeltaY > 0 ? PARDES_MOUSE_WHEEL_UP : PARDES_MOUSE_WHEEL_DOWN + pardes_mouse(button, PARDES_MOUSE_PRESS, at.col, at.row, modifiers(event.modifierFlags)) + } else { + return + } + NotificationCenter.default.post(name: inputNotification, object: self) + } + + private func send(_ button: pardes_mouse_button_e, _ kind: pardes_mouse_kind_e, _ event: NSEvent) { + guard let at = cell(for: event) else { return } + pardes_mouse(button, kind, at.col, at.row, modifiers(event.modifierFlags)) + NotificationCenter.default.post(name: inputNotification, object: self) + } + + private func cell(for event: NSEvent) -> (col: UInt16, row: UInt16)? { + // Clamp against the frame the core last rendered, not against our own + // metrics: a window that has been resized but not yet ticked would + // otherwise report a column the core has no cell for. Before the first + // frame there is no grid to point at and the event is meaningless. + let cols = Int(pardes_frame_cols()) + let rows = Int(pardes_frame_rows()) + guard cols > 0, rows > 0 else { return nil } + let point = convert(event.locationInWindow, from: nil) + let col = min(max(Int(point.x / cellWidth), 0), cols - 1) + let row = min(max(Int(point.y / cellHeight), 0), rows - 1) + return (UInt16(col), UInt16(row)) + } + + // MARK: - geometry + + override func updateTrackingAreas() { + super.updateTrackingAreas() + for area in trackingAreas { removeTrackingArea(area) } + // .inVisibleRect keeps the area correct across resizes on its own, which + // is why the rect argument can be anything. + addTrackingArea(NSTrackingArea(rect: .zero, + options: [.mouseMoved, .inVisibleRect, .activeInKeyWindow], + owner: self, + userInfo: nil)) + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + // AppKit resizes a view many times over one drag and almost all of those + // land inside the same cell. Only a changed grid is news, and the core + // reflows every pty on a resize, so the no-ops are not free. + let grid = gridSize + guard grid.cols != reportedCols || grid.rows != reportedRows else { return } + reportedCols = grid.cols + reportedRows = grid.rows + delegate?.pardesViewDidResize(self) + } + + // Dragging the window between a Retina display and a 1x one changes the + // backing scale without moving a single bound, so setFrameSize above never + // fires and the physical cell metrics the core uses to place PDF pages stay + // at the old scale forever. This is the only notification of it. (Ghostty + // hooks the same one, and additionally re-fires from the window's + // didChangeScreen notification, which AppKit does not always pair with it.) + override func viewDidChangeBackingProperties() { + super.viewDidChangeBackingProperties() + delegate?.pardesViewDidResize(self) + } +} + +// ponytail: no NSTextInputClient, so dead keys and IME composition never reach +// the core — keyDown reads `characters` and that is the whole story. Adopting +// the protocol and routing through interpretKeyEvents is the upgrade when +// someone needs to type Japanese. |
