diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-10 09:58:31 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-11 09:58:59 -0300 |
| commit | eb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c (patch) | |
| tree | cc993ad239451adb6f23645ee5ca001802832ed0 /src/macos/Sources/PardesView.swift | |
| parent | 38e9919a9ea9055538409b388d580c4e4c838434 (diff) | |
| download | pardes-eb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c.tar.gz pardes-eb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c.zip | |
macos: the AppKit shell, its icon, and the offscreen e2e harness
Diffstat (limited to 'src/macos/Sources/PardesView.swift')
| -rw-r--r-- | src/macos/Sources/PardesView.swift | 776 |
1 files changed, 647 insertions, 129 deletions
diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift index 2dfa4305..c7152bd3 100644 --- a/src/macos/Sources/PardesView.swift +++ b/src/macos/Sources/PardesView.swift @@ -4,7 +4,17 @@ // 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. +// measure and never change — plus the two things a gesture needs remembered +// between events: which button a click started with, and how hard it is being +// pressed. +// +// Every NSEvent override decodes and then calls one of the post-decode entry +// points below (press/release/drag/click/scroll/rotate/typeKey). That split is +// not decoration: NSTouch, pressure stages and rotation have no public +// constructors, so a test can never synthesize them, and the only way the +// trackpad behaviour is reachable by anything but a finger is for the decision +// to live one call below the event. test/macos_e2e.swift drives exactly those +// entry points. // // Every C constant below is wrapped in an explicit conversion (UInt16(...), // UInt32(...)) rather than used bare. A macro's imported Swift type is decided @@ -13,18 +23,65 @@ import AppKit import CoreText +/// Posted after every call this view makes into the core, and answered by +/// AppDelegate.pump(). The core only queues what it was told and does nothing +/// until it is pumped, so an input without one of these is an input that +/// visibly did nothing. Declared here, where the posts are. +let pardesDidInputNotification = Notification.Name("pardesDidInput") + +/// A cell, already clamped to the frame the core last rendered. +struct GridPoint { + var col: UInt16 + var row: UInt16 +} + protocol PardesViewDelegate: AnyObject { func pardesViewDidResize(_ view: PardesView) func pardesViewRequestsPaste(_ view: PardesView) } +/// What a click means, from the fingers resting on the trackpad and the button +/// stream AppKit chose to deliver it on. Pure on purpose: NSTouch cannot be +/// constructed, so this is the part of the gesture a test can reach. +/// +/// acme's three buttons are the whole vocabulary — 1 selects, 2 executes, +/// 3 looks — and a trackpad has one surface. Two fingers is the gesture macOS +/// itself spells "secondary", so it is Look; three is the one left over, so it +/// is Exec, the heavier verb, which is also what a deep press means. +/// +/// The finger count has to win over the stream, and that is not a preference. +/// macOS's secondary click is "click or tap with TWO OR MORE fingers": with it +/// on, a three-finger click is delivered as rightMouseDown exactly like a +/// two-finger one, and a view that trusts the stream cannot tell them apart — +/// three fingers silently did Look. Measured on real hardware, which is the +/// only way this was ever going to be found: `rightMouseDown: resting=3`. +/// +/// So the stream is only the fallback, for when there are no fingers to count: +/// a real mouse's right button is Look and its middle button is Exec, and both +/// arrive with an empty touch set. +enum Trackpad { + static func button(stream: pardes_mouse_button_e, fingers: Int) -> pardes_mouse_button_e { + switch fingers { + case 2: return PARDES_MOUSE_RIGHT + case 3...: return PARDES_MOUSE_MIDDLE + // One finger, or none to count: the stream is the answer. A trackpad + // single click comes in on the left stream and stays left; a real + // mouse's right and middle buttons keep their acme meanings. + default: return stream + } + } + + /// A force click is a deliberate second gesture on top of an ordinary one, + /// so it gets the verb that does something rather than the one that + /// navigates. + static let forceClickButton: pardes_mouse_button_e = PARDES_MOUSE_MIDDLE +} + // 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") +let pardesDefaultFG: UInt32 = 0xCC_CC_CC +let pardesDefaultBG: UInt32 = 0x12_12_12 // UNVERIFIED: kCTFontAttributeName bridged through NSAttributedString.Key. It is // the same string as .font, but spelling the CoreText key means the value stays @@ -60,6 +117,16 @@ private func decodeColor(_ encoded: UInt32, _ fallback: UInt32) -> UInt32 { return encoded & UInt32(PARDES_COLOR_RGB_MASK) } +/// The four faces, indexed by the two attribute bits that pick one. Also the +/// glyph cache's first key, which is why it is an ordinal and not four fields. +private enum Face: Int, CaseIterable { + case regular = 0, bold = 1, italic = 2, boldItalic = 3 + + init(bold: Bool, italic: Bool) { + self = Face(rawValue: (bold ? 1 : 0) | (italic ? 2 : 0))! + } +} + /// `block` means the filled cursor sits on this cell. private func resolve( _ cell: pardes_cell_s, @@ -68,10 +135,12 @@ private func resolve( // 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) + return block + ? (pardesDefaultBG, pardesDefaultFG, 1, false) + : (pardesDefaultFG, pardesDefaultBG, 1, false) } - var fg = decodeColor(cell.fg, defaultFG) - var bg = decodeColor(cell.bg, defaultBG) + var fg = decodeColor(cell.fg, pardesDefaultFG) + var bg = decodeColor(cell.bg, pardesDefaultBG) // 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. @@ -97,6 +166,113 @@ private func advance(_ font: CTFont, _ character: UniChar) -> CGFloat { return size.width } +/// The size the window opens at and Cmd+0 returns to. Named here rather than +/// passed in because zoomReset has to know it too, and two spellings of one +/// number is how "actual size" stops being the size it actually opened at. +let defaultFontSize: CGFloat = 14 + +/// Everything that changes when the face or its size does, in one value so +/// that changing either is one assignment and cannot leave half the numbers +/// describing the old font. +/// +/// Built at init and again for a `Font` command or a zoom. The glyph caches +/// belong here for the same reason: a CGGlyph is an index into a particular +/// face, so carrying one across a font change draws the wrong character +/// rather than none. +private struct Metrics { + let fonts: [CTFont] + let ascent: CGFloat + let cellWidth: CGFloat + let cellHeight: CGFloat + let ruleThickness: CGFloat + let underlineOffset: CGFloat + /// ASCII is very nearly the whole screen, so its glyphs are resolved once + /// per face here and never looked up again. + let asciiGlyphs: [[CGGlyph]] + + init(size: CGFloat, path: String?) { + let face = Metrics.face(size: size, path: path) + + // 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, size, nil, traits, traits) ?? face + } + let faces = [face, variant(.traitBold), variant(.traitItalic), variant([.traitBold, .traitItalic])] + + // Whole points, rounded UP. A fractional cell width puts every column + // boundary on a fraction of a pixel, and with antialiasing off — which + // the background pass needs, or touching fills seam — the rounding + // wobbles by a pixel from column to column. On a screen made of tag + // bars and selections that stripe is visible. Rounding up rather than + // to nearest because down can clip a glyph, and a slightly airy grid is + // not a bug. + fonts = faces + ascent = CTFontGetAscent(face) + cellWidth = max(1, advance(face, 0x4D).rounded(.up)) + cellHeight = max(1, (CTFontGetAscent(face) + CTFontGetDescent(face) + CTFontGetLeading(face)).rounded(.up)) + ruleThickness = max(1, CTFontGetUnderlineThickness(face)) + underlineOffset = CTFontGetUnderlinePosition(face) + asciiGlyphs = faces.map { font in + var chars = Array(UniChar(0)..<UniChar(128)) + var glyphs = [CGGlyph](repeating: 0, count: 128) + _ = CTFontGetGlyphsForCharacters(font, &chars, &glyphs, 128) + return glyphs + } + } + + /// The regular cut to build the other three from: the file the core asked + /// for, or the system monospace face when it asked for nothing — or when + /// what it asked for turned out not to be wearable. + private static func face(size: CGFloat, path: String?) -> CTFont { + if let path, let picked = Metrics.fromFile(path, size) { return picked } + let system = NSFont.monospacedSystemFont(ofSize: size, weight: .regular) + // Through the descriptor, not through CTFontCreateWithName(fontName): + // the system monospace face has a dot-prefixed internal name that a + // by-name lookup can miss entirely, and NSFontDescriptor is toll-free + // bridged, so this cannot resolve to a different font than AppKit just + // handed us. + let face = CTFontCreateWithFontDescriptor(system.fontDescriptor as CTFontDescriptor, size, nil) + // The whole layout is a fixed grid, so a proportional face is not a + // cosmetic problem, it is a broken screen. "M" and "i" disagreeing on + // advance is the cheapest possible proof that we got one. + return Metrics.isFixedPitch(face) ? face : CTFontCreateWithName("Menlo" as CFString, size, nil) + } + + /// A face out of a font FILE, which is what the core hands over — it found + /// the path by walking the font directories itself, so nothing here asks + /// CoreText to resolve a name that a different shell might resolve + /// differently. + /// + /// Nil rather than a substitute for anything wrong with the file, because + /// the caller's fallback is the face already on screen: a font that cannot + /// be measured would otherwise leave a terminal with no way back out. + private static func fromFile(_ path: String, _ size: CGFloat) -> CTFont? { + let url = URL(fileURLWithPath: path) as CFURL + guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url) as? [CTFontDescriptor], + !descriptors.isEmpty else { return nil } + // A .ttc holds a family's four cuts in one file. Take the one with + // neither trait set — the regular — because the bold and italic ones + // are derived from it below; falling back to the first face keeps a + // collection whose cuts are all styled from being unusable. + let plain = descriptors.first { descriptor in + let traits = CTFontDescriptorCopyAttribute(descriptor, kCTFontTraitsAttribute) as? [CFString: Any] + let symbolic = (traits?[kCTFontSymbolicTrait] as? UInt32) ?? 0 + return symbolic & UInt32(CTFontSymbolicTraits.traitBold.rawValue | CTFontSymbolicTraits.traitItalic.rawValue) == 0 + } + let face = CTFontCreateWithFontDescriptor(plain ?? descriptors[0], size, nil) + // The core already filtered for fixed pitch by reading the file's own + // advances. This is the same question asked of the face CoreText + // actually built, which is the one that will be drawn with. + return Metrics.isFixedPitch(face) ? face : nil + } + + private static func isFixedPitch(_ face: CTFont) -> Bool { + let em = advance(face, 0x4D) + return em > 0 && abs(em - advance(face, 0x69)) <= 0.01 + } +} + private func modifiers(_ flags: NSEvent.ModifierFlags) -> UInt32 { var mods: UInt32 = 0 if flags.contains(.control) { mods |= UInt32(PARDES_MOD_CTRL) } @@ -106,59 +282,135 @@ private func modifiers(_ flags: NSEvent.ModifierFlags) -> UInt32 { } 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 + // The face and the numbers off it, replaced whole by `wear`. + private var metrics: Metrics + /// What `metrics` was built from, so a zoom keeps the face and a font + /// change keeps the size. + private var fontSize: CGFloat + private var fontPath: String? + + var cellWidth: CGFloat { metrics.cellWidth } + var cellHeight: CGFloat { metrics.cellHeight } + + // Glyphs the ASCII table above did not answer for. A stored 0 is .notdef, + // meaning "this face does not have it", which is a cache hit too — the + // CTLine fallback below is far more expensive than the lookup it would + // repeat. Keyed by face and codepoint, and thrown away with the face. + private var glyphCache: [UInt32: CGGlyph] = [:] + // Scratch for one batched run of glyphs. Held rather than made per row so a + // full redraw does not allocate 24 times. + private var runGlyphs: [CGGlyph] = [] + private var runPositions: [CGPoint] = [] + 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) - } + // The button a left-stream click actually started with. mouseDown decides + // it from the fingers on the trackpad, and mouseDragged/mouseUp must use + // the same one: a press of right followed by a release of left leaves the + // core holding a drag nothing will ever end. + private var latchedButton: pardes_mouse_button_e? + private var latchedCell: GridPoint? + // Force-click stage, reset per press. AppKit repeats stage-2 events for as + // long as the finger stays down, and only the transition is the gesture. + private var pressureStage: Int = 0 + // Fingers currently resting on the trackpad, kept from the touch stream. + // + // mouseDown was originally trusted to carry its own touch set, and on this + // hardware it does not always: AppKit routes NSTouch through the four + // touchesXxx callbacks, and the touch set hanging off a *mouse* event can + // come back empty depending on how the click was produced. Empty reads as + // one finger, which is a two-finger Look silently degrading into a select + // — the exact failure this was supposed to avoid. So the count is + // maintained here and the mouse event's own set is preferred only when it + // has something in it. + private var restingFingers: Int = 0 - // 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) + init(fontSize size: CGFloat) { + let built = Metrics(size: size, path: nil) + metrics = built + fontSize = size + fontPath = nil // 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)) + super.init(frame: NSRect(x: 0, y: 0, width: built.cellWidth * 80, height: built.cellHeight * 24)) + + runGlyphs.reserveCapacity(256) + runPositions.reserveCapacity(256) + // Indirect touches are the trackpad's. Without this the touch set is + // always empty and every click looks like one finger, which is exactly + // the bug that would make two-finger Look silently never fire. + allowedTouchTypes = [.indirect] + // Without a pressure configuration the deep-press stages are the + // system's business and stage 2 may never be delivered here. + // .primaryDeepClick is the one that means "a harder press is a second + // gesture", which is what it is being used for. + pressureConfiguration = NSPressureConfiguration(pressureBehavior: .primaryDeepClick) } required init?(coder: NSCoder) { fatalError("PardesView is built in code, not a nib") } + // MARK: - the face + + /// The PostScript name of the face on screen. The only way anything + /// outside this file can find out which font is being drawn with — the + /// core has no font, so a cell-buffer snapshot cannot see one. + var faceName: String { CTFontCopyPostScriptName(metrics.fonts[0]) as String } + + /// Put on a face, or the same face at a different size, and tell the host + /// the grid moved under it. + /// + /// A cell that changed size means a different number of columns fit the + /// same window, so this is a resize as far as the core is concerned — and + /// the delegate's resize path is already the one that reports both the + /// grid and the physical cell the PDF placement reads. Nothing here + /// second-guesses a file it could not load: `Metrics` falls back to the + /// system face, and asking for a font that is not wearable leaves the + /// screen exactly as it was rather than blank. + private func wear(size: CGFloat, path: String?) { + let next = Metrics(size: size, path: path) + // A CGGlyph is an index into a particular face. Kept across a change + // it would draw a different character, not a missing one. + glyphCache.removeAll(keepingCapacity: true) + metrics = next + fontSize = size + fontPath = path + delegate?.pardesViewDidResize(self) + needsDisplay = true + } + + /// The file `Font <name>` resolved to, straight from the core. + func adoptFont(path: String) { + wear(size: fontSize, path: path) + } + + /// Cmd+ and Cmd-. Whole points, because the cell is rounded to whole + /// points anyway: a tenth-of-a-point step would spend several keystrokes + /// landing on the same grid and look like the key had stopped working. + /// The range is what stays legible at the bottom and still fits a useful + /// number of columns at the top. + func zoom(by step: CGFloat) { + let next = min(max(fontSize + step, 6), 72) + guard next != fontSize else { return } + wear(size: next, path: fontPath) + } + + func zoomReset() { + guard fontSize != defaultFontSize else { return } + wear(size: defaultFontSize, path: fontPath) + } + // 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 } + // A click that focuses the window should also land in the grid: this is a + // text surface, and having to click twice after switching apps is the kind + // of thing that makes an app feel foreign. + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } var gridSize: (cols: UInt16, rows: UInt16) { let cols = min(max((bounds.width / cellWidth).rounded(.down), 1), CGFloat(UInt16.max)) @@ -176,7 +428,7 @@ final class PardesView: NSView { let count = pardes_frame() let cols = Int(pardes_frame_cols()) let rows = Int(pardes_frame_rows()) - fill(ctx, bounds, defaultBG, 1) + fill(ctx, bounds, pardesDefaultBG, 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 @@ -215,20 +467,20 @@ final class PardesView: NSView { // 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) + // Every glyph sits at an exact multiple of cellWidth, so letting + // CoreText place it on a subpixel would blur a grid that is aligned by + // construction. Quantizing keeps the rasterizer's own cache hitting. + ctx.setShouldSubpixelPositionFonts(false) + ctx.setShouldSubpixelQuantizeFonts(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) - } + drawRow(ctx, cells, base: row * cols, cols: cols, + baseline: height - (CGFloat(row) * cellHeight + metrics.ascent), + blockCol: blockY == row ? blockX : -1) } ctx.restoreGState() @@ -238,73 +490,140 @@ final class PardesView: NSView { // 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 + ctx.setShouldAntialias(false) fill(ctx, CGRect(x: CGFloat(x) * cellWidth, y: CGFloat(y) * cellHeight, - width: max(1, cellWidth / 8), height: cellHeight), fg, 1) + width: max(1, (cellWidth / 8).rounded(.up)), height: cellHeight), fg, 1) + } + } + } + + /// One row of glyphs, batched. Consecutive cells that share a face and a + /// colour go to CoreText as a single call with a position array: a row of + /// plain text is then one draw instead of eighty, which is the difference + /// between a full redraw being free and being felt. + private func drawRow( + _ ctx: CGContext, + _ cells: UnsafePointer<pardes_cell_s>, + base: Int, + cols: Int, + baseline: CGFloat, + blockCol: Int + ) { + var runFace = Face.regular + var runColor: UInt32 = 0 + var runAlpha: CGFloat = 1 + runGlyphs.removeAll(keepingCapacity: true) + runPositions.removeAll(keepingCapacity: true) + + func flush() { + guard !runGlyphs.isEmpty else { return } + setFill(ctx, runColor, runAlpha) + CTFontDrawGlyphs(metrics.fonts[runFace.rawValue], runGlyphs, runPositions, runGlyphs.count, ctx) + runGlyphs.removeAll(keepingCapacity: true) + runPositions.removeAll(keepingCapacity: true) + } + + for col in 0..<cols { + let cell = cells[base + col] + if cell.flags & UInt8(PARDES_CELL_DEFAULT) != 0 { continue } + let style = resolve(cell, block: blockCol == col) + let x = CGFloat(col) * cellWidth + + // Rules before the glyph, and independent of it: an underlined space + // is a real thing and so is an underlined invisible cell. They are + // fills, not glyphs, so they interrupt the run. + if cell.attrs >> UInt16(PARDES_ATTR_UL_SHIFT) != 0 + || cell.attrs & UInt16(PARDES_ATTR_STRIKETHROUGH) != 0 { + flush() + drawRules(ctx, cell, style, x: x, baseline: baseline) + } + guard style.visible else { continue } + + // 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 { continue } + + let face = Face(bold: cell.attrs & UInt16(PARDES_ATTR_BOLD) != 0, + italic: cell.attrs & UInt16(PARDES_ATTR_ITALIC) != 0) + let units = text.utf16 + let known = units.count == 1 ? glyph(face, units.first!) : 0 + if known != 0 { + if !runGlyphs.isEmpty + && (face != runFace || style.fg != runColor || style.alpha != runAlpha) { + flush() + } + runFace = face + runColor = style.fg + runAlpha = style.alpha + runGlyphs.append(known) + runPositions.append(CGPoint(x: x, y: baseline)) + continue } + + // 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. + flush() + setFill(ctx, style.fg, style.alpha) + let attributed = NSAttributedString(string: text, attributes: [fontAttribute: metrics.fonts[face.rawValue]]) + ctx.textPosition = CGPoint(x: x, y: baseline) + CTLineDraw(CTLineCreateWithAttributedString(attributed as CFAttributedString), ctx) + // CTLineDraw leaves the text position at the END of what it drew, + // and textPosition IS the translation of the text matrix, which + // CTFontDrawGlyphs then applies to every position it is handed. So + // one fallback glyph silently displaces the entire rest of the + // frame by that glyph's advance, down and to the right — and since + // the wrap marker and the em dash take this path, that is most + // files. Put it back before anything else draws. + ctx.textMatrix = .identity } + flush() + } + + /// 0 is .notdef, i.e. "this face does not have it" — a real answer, cached + /// like any other, because the CTLine fallback it sends the caller to costs + /// far more than the lookup it would otherwise repeat every frame. + private func glyph(_ face: Face, _ character: UniChar) -> CGGlyph { + if character < 128 { return metrics.asciiGlyphs[face.rawValue][Int(character)] } + let key = UInt32(face.rawValue) << 16 | UInt32(character) + if let cached = glyphCache[key] { return cached } + var input = character + var found = CGGlyph(0) + _ = CTFontGetGlyphsForCharacters(metrics.fonts[face.rawValue], &input, &found, 1) + glyphCache[key] = found + return found } - private func drawCell( + private func drawRules( _ 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) + let y = baseline + metrics.underlineOffset + fill(ctx, CGRect(x: x, y: y, width: cellWidth, height: metrics.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), + fill(ctx, CGRect(x: x, y: y - metrics.ruleThickness * 2, width: cellWidth, height: metrics.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), + fill(ctx, CGRect(x: x, y: baseline + metrics.ascent * 0.3, width: cellWidth, height: metrics.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) { @@ -319,12 +638,78 @@ final class PardesView: NSView { ctx.fill(rect) } + // MARK: - the core, and the pump + + /// Everything below ends here. Posting the notification in one place is + /// what guarantees no entry point can feed the core and forget to ask for + /// the tick that performs it. + private func fed() { + NotificationCenter.default.post(name: pardesDidInputNotification, object: self) + } + + func typeKey(_ cp: UInt32, text: String, mods: UInt32) { + // The pointer is borrowed for the call and nowhere else, which is the only + // thing the header promises about it. + text.withCString { pardes_key(cp, $0, text.utf8.count, mods) } + fed() + } + + // `mods` defaults to none because a synthesized gesture carries no + // keyboard state; the NSEvent overrides always pass the real mask. Ctrl is + // the one the core actually consults — a left press with it held is + // goto-definition — so dropping it here would silently delete a feature. + func press(_ button: pardes_mouse_button_e, at cell: GridPoint, mods: UInt32 = 0) { + pardes_mouse(button, PARDES_MOUSE_PRESS, cell.col, cell.row, mods) + fed() + } + + func release(_ button: pardes_mouse_button_e, at cell: GridPoint, mods: UInt32 = 0) { + pardes_mouse(button, PARDES_MOUSE_RELEASE, cell.col, cell.row, mods) + fed() + } + + func drag(_ button: pardes_mouse_button_e, to cell: GridPoint, mods: UInt32 = 0) { + pardes_mouse(button, PARDES_MOUSE_DRAG, cell.col, cell.row, mods) + fed() + } + + func motion(to cell: GridPoint, mods: UInt32 = 0) { + pardes_mouse(PARDES_MOUSE_NONE, PARDES_MOUSE_MOTION, cell.col, cell.row, mods) + fed() + } + + /// A press and its release with nothing in between, which is what every + /// synthesized click is: a trackpad gesture we recognised rather than a + /// button the user held. + func click(_ button: pardes_mouse_button_e, at cell: GridPoint, mods: UInt32 = 0) { + press(button, at: cell, mods: mods) + release(button, at: cell, mods: mods) + } + + /// One discrete wheel notch, as opposed to the continuous travel below. + func wheel(_ button: pardes_mouse_button_e, at cell: GridPoint, mods: UInt32 = 0) { + pardes_mouse(button, PARDES_MOUSE_PRESS, cell.col, cell.row, mods) + fed() + } + + func scroll(rows: CGFloat, cols: CGFloat = 0, at cell: GridPoint) { + guard rows != 0 || cols != 0 else { return } + pardes_scroll(Float(rows), Float(cols), cell.col, cell.row) + fed() + } + + func rotate(degrees: CGFloat) { + guard degrees != 0 else { return } + pardes_rotate(Float(degrees)) + fed() + } + // 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 + // Anything the main menu claims never reaches here; the rest is swallowed // rather than delivered as the bare keystroke the core would insert. if flags.contains(.command) { if event.charactersIgnoringModifiers?.lowercased() == "v" { @@ -376,52 +761,179 @@ final class PardesView: NSView { // 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) + // Typing is the moment the pointer stops being interesting and starts + // sitting on top of the words. It comes back on the next mouse move. + NSCursor.setHiddenUntilMouseMoves(true) + typeKey(codepoint, text: text, mods: modifiers(flags)) + } + + // MARK: - trackpad + + // NSTouch arrives through these four and nowhere else. They are the only + // reliable source of "how many fingers are down right now": the touch set + // on a mouse event is an accident of how the click was produced, and an + // empty one is indistinguishable from one finger. + override func touchesBegan(with event: NSEvent) { countTouches(event) } + override func touchesMoved(with event: NSEvent) { countTouches(event) } + override func touchesEnded(with event: NSEvent) { countTouches(event) } + override func touchesCancelled(with event: NSEvent) { countTouches(event) } + + private func countTouches(_ event: NSEvent) { + restingFingers = event.touches(matching: .touching, in: nil).count + trace("touch: resting=\(restingFingers)") } // 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) } + // All three button streams land in the same three functions, because which + // stream a trackpad click arrives on is not something the app gets to know + // in advance: with macOS's secondary click on, two AND three fingers both + // come in as rightMouseDown. The button is therefore decided once, at the + // press, from the fingers plus the stream, and then LATCHED — the core is + // tracking a drag keyed by button, and answering a press of 3 with a + // release of 1 leaves it holding a sweep nothing will ever end. + // + // The count itself is maintained by the touchesXxx callbacks above; see + // beginClick for why it can only come from there. + private func beginClick(_ stream: pardes_mouse_button_e, _ event: NSEvent) { + guard let at = cell(for: event) else { return } + pressureStage = 0 + // The count comes from the touch stream and NEVER from the mouse event. + // Asking a mouse event for its touches is not merely unreliable, it + // raises: -[NSEvent touchesMatchingPhase:inView:] is defined for + // gesture and touch events, and on anything else AppKit throws, catches + // it inside its own event dispatch, and abandons the rest of this + // method. Nothing crashes and nothing is logged — every click just + // silently stops working, a plain drag included, while rotation and + // scrolling carry on as if the backend were fine. That is exactly how + // this presented, and it is why restingFingers exists. + let button = Trackpad.button(stream: stream, fingers: restingFingers) + trace("press: stream=\(stream.rawValue) fingers=\(restingFingers) -> button=\(button.rawValue)") + latchedButton = button + latchedCell = at + press(button, at: at, mods: modifiers(event.modifierFlags)) + } + + private func continueClick(_ event: NSEvent) { + guard let button = latchedButton, let at = cell(for: event) else { return } + latchedCell = at + drag(button, to: at, mods: modifiers(event.modifierFlags)) + } + + private func endClick(_ event: NSEvent) { + pressureStage = 0 + // Already nil when the force click below converted this press: it + // released the button itself and there is nothing left to end. + guard let button = latchedButton else { return } + latchedButton = nil + guard let at = cell(for: event) else { return } + latchedCell = at + release(button, at: at, mods: modifiers(event.modifierFlags)) + } + + override func mouseDown(with event: NSEvent) { beginClick(PARDES_MOUSE_LEFT, event) } + override func mouseDragged(with event: NSEvent) { continueClick(event) } + override func mouseUp(with event: NSEvent) { endClick(event) } + // Where a two-finger click lands with macOS's own secondary click on, and + // where a three-finger one lands too — hence the finger count in + // Trackpad.button rather than a hardcoded RIGHT here. + override func rightMouseDown(with event: NSEvent) { beginClick(PARDES_MOUSE_RIGHT, event) } + override func rightMouseDragged(with event: NSEvent) { continueClick(event) } + override func rightMouseUp(with event: NSEvent) { endClick(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 otherMouseDown(with event: NSEvent) { beginClick(PARDES_MOUSE_MIDDLE, event) } + override func otherMouseDragged(with event: NSEvent) { continueClick(event) } + override func otherMouseUp(with event: NSEvent) { endClick(event) } + + /// A deep press, which is a second gesture layered on the click already in + /// flight. AppKit keeps sending stage-2 events while the finger stays down, + /// so only the transition counts. + /// + /// Whatever button is in flight is released before the middle one goes out: + /// a middle press arriving while the core holds a left select-drag is + /// acme's 1-2 chord, which is Cut. Releasing first costs a cursor move at + /// the click point — which is what clicking there would have done anyway. + /// + /// Not gated on the press being a LEFT one, which is what stopped this + /// working: on a Force Touch trackpad the deep press is just as likely to + /// have arrived on the right stream, and an in-flight Look upgraded by + /// pressing harder is precisely the gesture. Already-Exec is the only case + /// with nothing to do. + override func pressureChange(with event: NSEvent) { + trace("pressure: stage=\(event.stage) latched=\(String(describing: latchedButton?.rawValue))") + guard pressureStage < 2 else { return } + pressureStage = event.stage + guard event.stage == 2 else { return } + guard let at = latchedCell, let current = latchedButton, + current != Trackpad.forceClickButton else { return } + latchedButton = nil + release(current, at: at, mods: modifiers(event.modifierFlags)) + click(Trackpad.forceClickButton, at: at, mods: modifiers(event.modifierFlags)) + } + + override func mouseMoved(with event: NSEvent) { + guard let at = cell(for: event) else { return } + motion(to: at, mods: modifiers(event.modifierFlags)) + } 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 + // The core scrolls a cell at a time, so libpardes accumulates the + // sub-cell 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 { + scroll(rows: -event.scrollingDeltaY / cellHeight, + cols: -event.scrollingDeltaX / cellWidth, + at: at) + } else { // 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 + if event.scrollingDeltaY != 0 { + wheel(event.scrollingDeltaY > 0 ? PARDES_MOUSE_WHEEL_UP : PARDES_MOUSE_WHEEL_DOWN, + at: at, mods: modifiers(event.modifierFlags)) + } + if event.scrollingDeltaX != 0 { + wheel(event.scrollingDeltaX > 0 ? PARDES_MOUSE_WHEEL_LEFT : PARDES_MOUSE_WHEEL_RIGHT, + at: at, mods: modifiers(event.modifierFlags)) + } } - 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) + /// Two fingers twisted on the trackpad are the search-step keys: clockwise + /// walks forward through the matches, counterclockwise back. It is a dial, + /// and n/N is what a dial over a list of hits means. libpardes owns the + /// quantizing, exactly as it does for scroll. + override func rotate(with event: NSEvent) { + trace("rotate: degrees=\(event.rotation) phase=\(event.phase.rawValue)") + // A gesture starting drops whatever the last one left banked, so the + // first degree of a new twist cannot inherit a nearly-complete notch. + if event.phase == .began { pardes_rotate(0) } + rotate(degrees: CGFloat(event.rotation)) } - private func cell(for event: NSEvent) -> (col: UInt16, row: UInt16)? { + /// What the trackpad actually delivered, under PARDES_LOG — the same + /// variable the Zig side gates its logger on (src/macos.zig). + /// + /// This is not scaffolding left behind. Which events a trackpad produces is + /// decided by the hardware and by four different System Settings switches + /// (secondary click, three-finger drag, force click, "look up"), none of + /// which this process can read, and every one of which turns a gesture into + /// a different NSEvent or into none at all. When someone reports that + /// two-finger Look does nothing, this is the only thing that can answer + /// whether AppKit saw two fingers, one, or no click at all. + private func trace(_ message: @autoclosure () -> String) { + guard PardesView.tracing else { return } + FileHandle.standardError.write(Data(("pardes: " + message() + "\n").utf8)) + } + + private static let tracing = ProcessInfo.processInfo.environment["PARDES_LOG"] != nil + + private func cell(for event: NSEvent) -> GridPoint? { + cellAt(convert(event.locationInWindow, from: nil)) + } + + func cellAt(_ point: CGPoint) -> GridPoint? { // 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 @@ -429,10 +941,9 @@ final class PardesView: NSView { 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)) + return GridPoint(col: UInt16(col), row: UInt16(row)) } // MARK: - geometry @@ -448,6 +959,12 @@ final class PardesView: NSView { userInfo: nil)) } + override func resetCursorRects() { + // Every cell in this view is text, including the tags. An arrow over a + // grid you can sweep and click words in is the wrong affordance. + addCursorRect(bounds, cursor: .iBeam) + } + override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(newSize) // AppKit resizes a view many times over one drag and almost all of those @@ -475,4 +992,5 @@ final class PardesView: NSView { // 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. +// someone needs to type Japanese, and it needs the core to be able to render an +// underlined preedit run first. |
