// 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 — expensive to measure and // replaced only by Font/TaglineSize/zoom — plus the gesture state 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 // by the importer, not by us, and this file should not have an opinion about it. 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. More than one finger is Exec, /// because running something is what a hand does over and over and it gets /// the gesture that costs nothing; Look is the deep press, one deliberate /// push past the click for the verb that goes somewhere. /// /// Two and three fingers are the same verb here, and that is the hardware /// talking rather than a shrug. macOS's secondary click is "click or tap with /// TWO OR MORE fingers": with it on — the default — a three-finger click is /// delivered as rightMouseDown exactly like a two-finger one, and a view that /// trusts the stream calls both of them Look. Measured on real hardware, which /// is the only way this was ever going to be found: /// `rightMouseDown: resting=3`. So the count is what keeps a multi-finger /// click off Look, and the stream cannot be trusted to do it. /// /// 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_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 goes somewhere rather than the one that runs /// something: press harder and you Look. static let forceClickButton: pardes_mouse_button_e = PARDES_MOUSE_RIGHT } // 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. let pardesDefaultFG: UInt32 = 0xCC_CC_CC let pardesDefaultBG: UInt32 = 0x12_12_12 /// Pinned sRGB for rasterized attachments, so a PDF page's bytes mean the same /// thing here as they do in the SDL shell. DeviceRGB is the fallback rather /// than a crash: a machine with no sRGB profile is not a reason to stop /// drawing pages. private let sRGB: CGColorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB() // 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) } /// 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))! } } /// A background run that must not be painted at all, so the window's own /// backdrop shows through. Outside the 24-bit RGB range, so it can never /// collide with a real colour, and distinct from the `.max` the run loop /// flushes on. let bgClear: UInt32 = 0x0100_0000 /// `block` means the filled cursor sits on this cell. `ground` is what a /// DEFAULT background resolves to, and `clearGround` asks for those cells to /// come back as `bgClear` instead of a colour. private func resolve( _ cell: pardes_cell_s, block: Bool, ground: UInt32, clearGround: 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 ? (ground, pardesDefaultFG, 1, false) : (pardesDefaultFG, clearGround ? bgClear : ground, 1, false) } let bgDefault = cell.bg == UInt32(PARDES_COLOR_DEFAULT) var fg = decodeColor(cell.fg, pardesDefaultFG) var bg = decodeColor(cell.bg, ground) // 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 // Only an UNREVERSED default background is the ground. A reverse puts the // text colour there, and text is a real colour that paints. if clearGround && bgDefault && !reverse { bg = bgClear } 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 } /// 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 /// The faces CoreText should reach for when the grid face has no glyph. /// /// The SDL shell loads a chain of fallback faces itself and rasterizes from /// whichever one has the codepoint. This shell draws its non-ASCII through /// CTLine, which already walks a cascade — but the SYSTEM cascade, and the /// system cascade has never heard of a Nerd Font. Measured on a machine with /// Mononoki Nerd Font installed: U+E0B0, U+E5FF, U+E700 and U+F015 all resolved /// to `LastResort`, which is the tofu box. Braille, emoji and CJK resolved /// fine, which is exactly why this went unnoticed — everything Unicode has an /// opinion about already worked, and only the Private Use Area did not. /// /// Two sources, in this order: /// /// 1. `fonts.fallback_names` over the C ABI, so the PREFERENCE ORDER is the /// one the SDL shell uses and lives in one file. /// 2. Installed Nerd Font families, found by name and then CONFIRMED BY /// COVERAGE. Both halves are load-bearing. Coverage alone is not enough: a /// scan of all 250 families here put `Hannotate TC` and `HanziPen TC` at /// 4 of 6 probes, because CJK faces map the PUA for their own purposes and /// would answer a powerline request with an unrelated ideograph. A name /// alone is not enough either, because "Nerd Font" in a family name is a /// convention, not a guarantee. So the name decides what a codepoint MEANS /// and coverage decides whether the face can actually draw it. /// /// Computed once. The result is a list of descriptors, which carry no size, so /// a zoom or a `Font` command reuses it; only installing a font invalidates it, /// and that is a relaunch. private enum FontFallbacks { /// Representative codepoints, one per Nerd Font block that matters: /// powerline separators, Seti file icons, devicons, Font Awesome. A face /// answering all four is patched; a face answering one is a coincidence. private static let nerdProbes: [UInt32] = [0xE0B0, 0xE5FF, 0xE700, 0xF015] static let descriptors: [CTFontDescriptor] = build() private static func build() -> [CTFontDescriptor] { var out: [CTFontDescriptor] = [] var seen = Set() func take(_ family: String, _ descriptor: CTFontDescriptor) { guard seen.insert(family).inserted else { return } out.append(descriptor) } // 1. The shared preference order. for index in 0.. 0 else { continue } let name = String(decoding: UnsafeRawBufferPointer(start: bytes, count: Int(length)), as: UTF8.self) guard let found = resolve(name) else { continue } take(found.family, found.descriptor) } // 2. Nerd Fonts that are installed AND cover the blocks. `Mono` cuts // first: this is a fixed grid, and the propo/variable cuts of the // same family are drawn to different advances. let families = (CTFontManagerCopyAvailableFontFamilyNames() as? [String]) ?? [] let nerd = families.filter { $0.range(of: "nerd font", options: .caseInsensitive) != nil } for family in nerd.sorted(by: { rank($0) < rank($1) }) { let descriptor = CTFontDescriptorCreateWithAttributes( [kCTFontFamilyNameAttribute: family] as CFDictionary) guard coverage(descriptor, nerdProbes) == nerdProbes.count else { continue } take(family, descriptor) } return out } /// "Mono" before "Propo" before the proportional cut. private static func rank(_ family: String) -> Int { if family.range(of: "nerd font mono", options: .caseInsensitive) != nil { return 0 } if family.range(of: "nerd font propo", options: .caseInsensitive) != nil { return 2 } return 1 } /// A descriptor that really is the font asked for. CoreText SUBSTITUTES /// rather than failing — asking it for an uninstalled `SymbolsNerdFont- /// Regular` hands back Helvetica, and a cascade seeded with Helvetica is a /// cascade that answers every missing glyph with the wrong one. private static func resolve(_ name: String) -> (family: String, descriptor: CTFontDescriptor)? { for attribute in [kCTFontNameAttribute, kCTFontFamilyNameAttribute] { let query = CTFontDescriptorCreateWithAttributes([attribute: name] as CFDictionary) guard let match = CTFontDescriptorCreateMatchingFontDescriptor(query, nil) else { continue } let postScript = CTFontDescriptorCopyAttribute(match, kCTFontNameAttribute) as? String ?? "" let family = CTFontDescriptorCopyAttribute(match, kCTFontFamilyNameAttribute) as? String ?? "" guard postScript.compare(name, options: .caseInsensitive) == .orderedSame || family.compare(name, options: .caseInsensitive) == .orderedSame else { continue } return (family.isEmpty ? postScript : family, match) } return nil } /// How many of `codepoints` this face can actually draw. Size is irrelevant /// to coverage, so the probe face is built at a nominal one. private static func coverage(_ descriptor: CTFontDescriptor, _ codepoints: [UInt32]) -> Int { let font = CTFontCreateWithFontDescriptor(descriptor, 12, nil) var hits = 0 for codepoint in codepoints { guard let scalar = UnicodeScalar(codepoint) else { continue } var units = Array(String(scalar).utf16) var glyphs = [CGGlyph](repeating: 0, count: units.count) if CTFontGetGlyphsForCharacters(font, &units, &glyphs, units.count) { hits += 1 } } return hits } /// `face` with the chain attached. Every face pardes draws with goes through /// here exactly once, at the base: `CTFontCreateCopyWithSymbolicTraits` and /// `CTFontCreateCopyWithAttributes` both carry the cascade into the copy, so /// the bold/italic cuts and the smaller tagline cuts inherit it. static func attach(to face: CTFont, size: CGFloat) -> CTFont { guard !descriptors.isEmpty else { return face } let descriptor = CTFontDescriptorCreateCopyWithAttributes( CTFontCopyFontDescriptor(face), [kCTFontCascadeListAttribute: descriptors] as CFDictionary) return CTFontCreateWithFontDescriptor(descriptor, size, nil) } } /// 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 view clears its /// body/tagline glyph caches beside replacing this value: a CGGlyph is an index /// into a particular face, so carrying one across a font change is invalid. private struct Metrics { let fonts: [CTFont] /// False only when a non-nil requested file could not produce a fixed-pitch /// CoreText face. The caller rejects that request and keeps old Metrics. let acceptedPath: Bool 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]] /// Round `v` onto the backing grid: `scale` is the display's /// backingScaleFactor, so at 2x this lands on half-points, which are whole /// device pixels. private static func snap(_ v: CGFloat, _ scale: CGFloat, _ rule: FloatingPointRoundingRule) -> CGFloat { (v * scale).rounded(rule) / scale } init(size: CGFloat, path: String?, scale: CGFloat) { let requested = path.flatMap { Metrics.fromFile($0, size) } // Attached ONCE, at the base: the trait and size copies below inherit // the cascade, so every cut and the tagline's smaller cuts reach the // same fallbacks. Metrics is measured from `face` too, and a cascade // changes no metric — CoreText measures the primary face and only // consults the chain for a codepoint it lacks. let face = FontFallbacks.attach(to: requested ?? Metrics.defaultFace(size: size), size: size) let scale = max(1, scale) // 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])] // The grid has to land on WHOLE DEVICE PIXELS, and that is the whole // constraint — a fractional column boundary makes the background pass // (which runs with antialiasing off, or touching fills seam) wobble by // a pixel from column to column, and on a screen made of tag bars and // selections that stripe is visible. // // Whole POINTS is how that used to be spelled, and on a Retina display // it asks for twice what it needs: half a point IS a whole pixel at 2x. // The difference is not academic — Monaco advances 8.4014pt at 14, so // ceiling to 9 spaced every column 7.1% wider than the face was drawn // for, which is loose, washed-out text that reads as bad rendering. // Snapped to the backing grid it is 8.5, i.e. +1.2%. // // Width rounds to NEAREST — a monospace glyph is drawn to fit its own // advance, so the half-pixel either way is slack — while height rounds // UP, because losing a pixel off a descender is clipping. let snap = Metrics.snap fonts = faces acceptedPath = path == nil || requested != nil // The ascent lands on a pixel for a second reason: it is the baseline's // offset inside the cell, so the rules hung off it are whole-pixel // fills rather than one-pixel bars smeared across two rows. let bodyAscent = max(1 / scale, snap(CTFontGetAscent(face), scale, .toNearestOrAwayFromZero)) let bodyWidth = max(1 / scale, snap(advance(face, 0x4D), scale, .toNearestOrAwayFromZero)) let bodyHeight = max(1 / scale, snap(CTFontGetAscent(face) + CTFontGetDescent(face) + CTFontGetLeading(face), scale, .up)) ascent = bodyAscent cellWidth = bodyWidth cellHeight = bodyHeight ruleThickness = max(1 / scale, snap(CTFontGetUnderlineThickness(face), scale, .toNearestOrAwayFromZero)) underlineOffset = snap(CTFontGetUnderlinePosition(face), scale, .toNearestOrAwayFromZero) asciiGlyphs = faces.map { font in var chars = Array(UniChar(0).. CTFont { 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 } } /// The independently replaceable half of font state. A TaglineSize command /// rebuilds only this value and its non-ASCII cache; body metrics and therefore /// the core grid never move. `height` is also the painted tag-band height, not /// merely a smaller glyph centred inside a body-height colour bar. private struct TaglineMetrics { let percent: UInt8 let fonts: [CTFont] /// The tagline face's OWN ascent. Deliberately not folded together with the /// band's top offset the way it used to be: that offset is per-row now, and /// a baseline carrying one row's offset would pin every band back to centre. let ascent: CGFloat /// The tagline face's OWN advance, clamped to the body cell it sits in. /// A tag row steps by THIS, not by the body cell width: the band behind it /// is still pane-wide on the body grid, but the text on top of it tracks at /// the smaller face's own pitch. Centring a smaller glyph inside a /// body-width cell instead — which is what this shell used to do — leaves /// the tag text visibly looser than the same session in an SDL window. let width: CGFloat let height: CGFloat let asciiGlyphs: [[CGGlyph]] /// Kept so the band rules below can work in the physical pixels the core /// states them in, and hand back points. private let scale: CGFloat private let bodyCellHeight: CGFloat init(body: Metrics, percent: UInt8, scale rawScale: CGFloat) { let scale = max(1, rawScale) func snap(_ value: CGFloat, _ rule: FloatingPointRoundingRule) -> CGFloat { (value * scale).rounded(rule) / scale } self.percent = percent self.scale = scale bodyCellHeight = body.cellHeight let size = CTFontGetSize(body.fonts[0]) * CGFloat(percent) / 100 let faces = body.fonts.map { CTFontCreateCopyWithAttributes($0, size, nil, nil) } fonts = faces let face = faces[Face.regular.rawValue] let measuredHeight = max(1 / scale, snap( CTFontGetAscent(face) + CTFontGetDescent(face) + CTFontGetLeading(face), .up)) // Runtime configuration caps the percentage at the body face. Keep the // clamp here too: a malformed/custom host value must not paint a tag // over the body row below it. height = min(body.cellHeight, measuredHeight) ascent = max(1 / scale, snap(CTFontGetAscent(face), .toNearestOrAwayFromZero)) width = min(body.cellWidth, max(1 / scale, snap(advance(face, 0x4D), .toNearestOrAwayFromZero))) asciiGlyphs = faces.map { font in var chars = Array(UniChar(0).. CGFloat { CGFloat(pardes_tagline_band_offset( UInt16(clamping: row), Float(canvasHeight * scale), cellPixels, bandPixels)) / scale } /// Canonical tag cells are physical grip slots; TagLayer positions text. func glyphX(col: Int, row: Int, bodyCellWidth: CGFloat) -> CGFloat { // Canonical tag cells are physical grips; TagLayer owns compact text. CGFloat(col) * bodyCellWidth } /// Thickness of the rule joining the topbar band to the first pane-tag band, /// zero when the two are meant to join directly. var borderThickness: CGFloat { CGFloat(pardes_topbar_pane_border_px(cellPixels, bandPixels)) / scale } /// Top of that rule. Placed the way the SDL shell places it, in WHOLE /// pixels: half the rule above the topbar/pane boundary, floored, so a /// one-pixel rule lands on the boundary instead of smeared across two rows. var borderTop: CGFloat { let px = pardes_topbar_pane_border_px(cellPixels, bandPixels) return (CGFloat(cellPixels * UInt32(PARDES_TOPBAR_H)) - CGFloat(px / 2)) / scale } } 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 { weak var delegate: PardesViewDelegate? // The face and the numbers off it, replaced whole by `wear`. private var metrics: Metrics private var taglines: TaglineMetrics /// 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 role, face and codepoint, and thrown away with both // body and tagline variants whenever the body face changes. private var bodyGlyphCache: [UInt32: CGGlyph] = [:] private var taglineGlyphCache: [UInt32: CGGlyph] = [:] private struct GripGlyph { let line: CTLine let ink: CGRect } private var gripGlyphCache: [String: GripGlyph] = [:] // 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] = [] /// Constructed lazily because the ordinary path is still a direct /// CoreText/CoreGraphics frame. No scene bit and no panel track means no /// bitmap, no Core Image context and no Metal work. private lazy var scenePostprocessor: ScenePostprocessor? = ScenePostprocessor() /// Kernel compilation is permanent for one executable. Runtime allocation /// and device failures get three consecutive attempts before this session /// stops advertising effects its GPU path cannot present. private var postprocessorUnavailable = false private var postprocessRetries = 0 /// The scene mapping of the pixels AppKit most recently accepted. Pointer /// events use this snapshot, not a clock that may already be one tick ahead. private var lastPresentedScene: pardes_scene_s? /// Whether the last frame reached the optional Metal owner successfully. /// Ordinary direct draws and a runtime-kernel failure leave this false. private(set) var lastFrameUsedPostprocessor = false /// Monotonic count of frames AppKit actually let this view present. The /// host animation clock uses it as a permit: no new sample is consumed /// while an occluded/coalesced frame is still waiting to be drawn. private(set) var presentationSerial: UInt64 = 0 private var reportedCols: UInt16 = 0 private var reportedRows: UInt16 = 0 /// The backingScaleFactor `metrics` was snapped to, so a move between a /// Retina and a 1x display re-measures the cell instead of leaving the grid /// aligned to the other screen's pixels. private var metricsScale: CGFloat = 2 /// The active theme's own background, or nil when it declares none — the /// `*_transparent` themes and the curated `dark`. Nil is not a colour to /// substitute but a decision: the ground stops being painted at all, the /// view stops being opaque, and AppDelegate's NSVisualEffectView shows /// through it. Read off pardes_theme_bg() once per pump, which is also /// what makes a `Theme` command take hold without a relaunch. private(set) var themeBG: UInt32? = pardesDefaultBG /// Adopt what the core is wearing. Returns whether anything moved, so the /// host only reconfigures the window when it has to. @discardableResult func adoptThemeBG(_ encoded: UInt32) -> Bool { let wanted: UInt32? = encoded == UInt32(PARDES_COLOR_DEFAULT) ? nil : encoded & UInt32(PARDES_COLOR_RGB_MASK) guard wanted != themeBG else { return false } themeBG = wanted needsDisplay = true return true } // 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? /// Last physical point delivered by AppKit and its mapping through the /// accepted scene. Ripple/glitch can move a source cell under a stationary /// hand, so successful scene presentations reconcile this point again. private var pointerPoint: CGPoint? private var pointerCell: GridPoint? private var pointerMapped = false private var pointerModifiers: UInt32 = 0 // 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 Exec 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 init(fontSize size: CGFloat) { // No window yet, so no backing scale to ask for: 2x is the guess every // Mac shipped this decade would give, and viewDidChangeBackingProperties // below re-measures the moment there is a real answer — including the // 1x case, which a bare guess would otherwise leave wrong forever. let built = Metrics(size: size, path: nil, scale: 2) metrics = built taglines = TaglineMetrics( body: built, percent: pardes_gui_tagline_font_percent(), scale: 2) 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: built.cellWidth * 80, height: built.cellHeight * 24)) runGlyphs.reserveCapacity(256) runPositions.reserveCapacity(256) // Files dropped ON the grid. Finder and the Dock already reach the app // through application(_:open:), but that path cannot say WHERE — and // where is the whole difference between "a file opened somewhere" and // acme's "a file opened next to the pane I pointed at". registerForDraggedTypes([.fileURL]) // 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 Exec 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 } var effectivePointSize: CGFloat { CTFontGetSize(metrics.fonts[0]) } /// 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. A requested file /// which cannot produce a fixed-pitch face is rejected before assignment, /// leaving the screen exactly as it was rather than substituting a face. private func wear(size: CGFloat, path: String?, resolvesRequest: Bool = false) { let scale = window?.backingScaleFactor ?? metricsScale let next = Metrics(size: size, path: path, scale: scale) guard next.acceptedPath else { // The requested file never became the fixed-pitch face the grid // requires. Keep every old metric and resolve the pending request // as rejected rather than claiming the system fallback is it. if resolvesRequest { pardes_font_reject() } return } // A CGGlyph is an index into a particular face. Kept across a change // it would draw a different character, not a missing one. bodyGlyphCache.removeAll(keepingCapacity: true) taglineGlyphCache.removeAll(keepingCapacity: true) gripGlyphCache.removeAll(keepingCapacity: true) metrics = next taglines = TaglineMetrics(body: next, percent: taglines.percent, scale: scale) metricsScale = scale fontSize = size fontPath = path delegate?.pardesViewDidResize(self) needsDisplay = true reportEffectiveFont(resolvesRequest: resolvesRequest) } /// Apply the core's live TaglineSize setting without touching body metrics, /// the window grid, or PDF cell geometry. Called every pump; equality makes /// the ordinary path one byte comparison. @discardableResult func adoptTaglinePercent(_ percent: UInt8) -> Bool { guard percent != taglines.percent else { return false } taglines = TaglineMetrics(body: metrics, percent: percent, scale: metricsScale) taglineGlyphCache.removeAll(keepingCapacity: true) gripGlyphCache.removeAll(keepingCapacity: true) pardes_row_metrics(UInt16(max(1,(cellWidth*metricsScale).rounded())),UInt16(max(1,(cellHeight*metricsScale).rounded())), UInt16(max(1,(taglines.width*metricsScale).rounded())),UInt16(max(1,(taglines.height*metricsScale).rounded()))) needsDisplay = true return true } var taglinePercent: UInt8 { taglines.percent } var taglineBandHeight: CGFloat { taglines.height } var taglineCellWidth: CGFloat { taglines.width } /// Seed runtime Config after init and update it after every successful /// adopt/zoom. Points are the native owner's unit; the cell's backing-pixel /// dimensions continue to travel through pardes_resize separately. func reportEffectiveFont(resolvesRequest: Bool = false) { let hundredths = UInt16(min( max((effectivePointSize * 100).rounded(), 1), CGFloat(UInt16.max))) let name = faceName name.withCString { pointer in if resolvesRequest { _ = pardes_font_ack(pointer, name.utf8.count, hundredths) } else { _ = pardes_font_observe(pointer, name.utf8.count, hundredths) } } } /// The file `Font ` resolved to, straight from the core. func adoptFont(path: String, sizeHundredths: UInt16) { let size = sizeHundredths == 0 ? fontSize : CGFloat(sizeHundredths) / 100 wear(size: size, path: path, resolvesRequest: true) } /// 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 } // Opaque only while the theme brings its own background. A transparent // theme has none, and an opaque view over a visual-effect backdrop is a // grey rectangle where the blur should be. override var isOpaque: Bool { themeBG != nil } 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)) 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 destination = NSGraphicsContext.current?.cgContext else { return } lastFrameUsedPostprocessor = false // 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 nextLinkPointer = pardes_pointer_shape() == 1 if nextLinkPointer != linkPointer { linkPointer = nextLinkPointer window?.invalidateCursorRects(for: self) if let window, bounds.contains(convert(window.mouseLocationOutsideOfEventStream, from: nil)) { (linkPointer ? NSCursor.pointingHand : NSCursor.iBeam).set() } } let scene = pardes_scene() let trackCount = Int(pardes_frame_panel_tracks()) let tracks: UnsafeBufferPointer if trackCount > 0, let list = pardes_frame_panel_track_list() { tracks = UnsafeBufferPointer(start: list, count: trackCount) } else { tracks = UnsafeBufferPointer(start: nil, count: 0) } let wantsPostprocess = scene.flags != 0 || !tracks.isEmpty if wantsPostprocess { if postprocessorUnavailable { pardes_postprocessor_unavailable() } else if let postprocessor = scenePostprocessor { if let sceneContext = postprocessor.renderContext( size: bounds.size, scale: window?.backingScaleFactor ?? metricsScale ) { drawFrame(sceneContext, count: count, hideCursor: !tracks.isEmpty) let needsPrevious = tracks.contains { $0.effect == UInt8(PARDES_PANEL_DISSOLVE) || $0.effect == UInt8(PARDES_PANEL_VERTICAL) } var oldReady = !needsPrevious let previousCells = pardes_frame_previous_cells() let changedCells = pardes_frame_changed_cells() if needsPrevious, let previousCells, changedCells != nil, let oldContext = postprocessor.previousRenderContext( size: bounds.size, scale: window?.backingScaleFactor ?? metricsScale ) { drawFrame( oldContext, count: count, cells: previousCells, frozenImages: presentedImages, hideCursor: true) oldReady = true } let outcome: ScenePostprocessor.CompositeOutcome = oldReady ? postprocessor.composite( scene: scene, tracks: tracks, changedCells: changedCells, changedAttachmentRects: changedAttachmentRects(), gridSize: ( cols: Int(pardes_frame_cols()), rows: Int(pardes_frame_rows())), cellSize: CGSize(width: cellWidth, height: cellHeight), ground: themeBG, size: bounds.size, into: destination) : .unavailable switch outcome { case .presented: postprocessRetries = 0 lastPresentedScene = scene lastFrameUsedPostprocessor = true acknowledgePresentation(animatedPanels: !tracks.isEmpty) return case .retry: postprocessRetries += 1 case .unavailable: postprocessorUnavailable = true } } else { postprocessRetries += 1 } // This draw now falls back to canonical content. Scene effects // may retry, but a panel must not resume from a later sample // after its final geometry has already been shown. if !tracks.isEmpty { pardes_panel_animation_failed() } if postprocessRetries >= 3 { postprocessorUnavailable = true } if postprocessorUnavailable { pardes_postprocessor_unavailable() } } else { // The source, Metal device, or fixed kernel set was absent at // lazy construction. None can recover within this process. postprocessorUnavailable = true pardes_postprocessor_unavailable() } } else { postprocessRetries = 0 } lastPresentedScene = nil drawFrame(destination, count: count, hideCursor: false) acknowledgePresentation(animatedPanels: false) } /// A presentation can materialize a Look target under a stationary mouse /// after AppDelegate already decided whether to run its 60 Hz clock. Wake /// the ordinary pump only for that null->active edge; repeated frame acks /// remain inert and cannot create a redraw loop. private func acknowledgePresentation(animatedPanels: Bool) { // Only a canonical presentation advances the attachment baseline. // During a transition deleted panes keep their cropped CGImages alive // here even after both the core placement and image cache entry vanish. if !animatedPanels { presentedImages = frameImages presentedBodyLayers = snapshotBodyLayers() presentedTagLayers = snapshotTagLayers() } presentationSerial &+= 1 let animationWoke = pardes_frame_presented(animatedPanels) let pointerMoved = refreshPresentedPointer() guard animationWoke || pointerMoved else { return } DispatchQueue.main.async { NotificationCenter.default.post(name: pardesDidInputNotification, object: self) } } /// Re-feed only a changed mapping. Sending motion on every 60 Hz scene /// frame would keep resetting the core's hover debounce forever. private func refreshPresentedPointer() -> Bool { guard let point = pointerPoint else { return false } guard let at = cellAt(point) else { guard pointerMapped else { return false } pointerMapped = false pardes_pointer_leave() return true } let changed: Bool if let previous = pointerCell { changed = !pointerMapped || previous.col != at.col || previous.row != at.row } else { changed = true } pointerMapped = true pointerCell = at guard changed else { return false } if let button = latchedButton { latchedCell = at sendMouse(button, PARDES_MOUSE_DRAG, at.col, at.row, pointerModifiers) } else { sendMouse(PARDES_MOUSE_NONE, PARDES_MOUSE_MOTION, at.col, at.row, pointerModifiers) } return true } /// The canonical CoreText frame, independent of where it lands. Scene /// effects change only the destination: direct view context when off, the /// postprocessor's same-sized retained bitmap when on. private func drawFrame( _ ctx: CGContext, count: UInt32, cells suppliedCells: UnsafePointer? = nil, frozenImages: [FrozenImage]? = nil, hideCursor: Bool ) { let cols = Int(pardes_frame_cols()) let rows = Int(pardes_frame_rows()) // The ground, from the core rather than from a constant agreed by hand. // A transparent theme has none: CLEAR rather than fill, because AppKit // does not blank a non-opaque view and last frame's pixels would // otherwise pile up on themselves. let ground = themeBG ?? pardesDefaultBG let clearGround = themeBG == nil if clearGround { ctx.clear(bounds) } else { fill(ctx, bounds, ground, 1) } if frozenImages == nil { frameImages.removeAll(keepingCapacity: true) } guard cols > 0, rows > 0, Int(count) == cols * rows, let cells = suppliedCells ?? 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. // A cursor has no pane/phase record. Match SDL and TTY by hiding it for // the short panel transition instead of guessing whether it should // move, stay fixed, or cover an overlapping opening pane. let bar = !hideCursor && pardes_cursor_bar() let blockX = hideCursor || bar ? -1 : Int(pardes_cursor_x()) let blockY = hideCursor || 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) // Band geometry is stated against the whole canvas: the last row's rule // depends on where the window edge is, not just on the row index. let canvasHeight = bounds.height for row in 0.. 0, ruleColor != UInt32(PARDES_COLOR_DEFAULT), rows > Int(PARDES_TOPBAR_H) { let base = Int(PARDES_TOPBAR_H) * cols let joins = (0.. 0 { let base = (rows - 1) * cols for col in 0..() if frozenImages == nil { for index in 0..= 0 && x < cols && y >= 0 && y < rows { paneGripCells.insert(y * cols + x) } } } else { for layer in presentedTagLayers where layer.values[1] == 2 { let x = layer.values[4] - Int(pardes_tag_text_inset()), y = layer.values[5] if x >= 0 && x < cols && y >= 0 && y < rows { paneGripCells.insert(y * cols + x) } } } for row in 0..= 0, y >= 0, x < cols, y < rows { // gui.zig paints U+258F here. A rect is the same picture without // asking the font for a glyph it may not carry. let cell = cells[y * cols + x] let fg = resolve(cell, block: false, ground: themeBG ?? pardesDefaultBG, clearGround: false).fg let tagline = cell.flags & UInt8(PARDES_CELL_TAGLINE) != 0 let caretY = CGFloat(y) * cellHeight + (tagline ? taglines.top(row: y, canvasHeight: bounds.height) : 0) let caretHeight = tagline ? taglines.height : cellHeight // On the compact grid when the cell is, or the caret sits a // growing distance to the left of the character it marks as the // row runs on. The SDL shell puts its cursor bar through the // same layout for the same reason. let caretX = tagline ? taglines.glyphX(col: x, row: y, bodyCellWidth: cellWidth) : CGFloat(x) * cellWidth let caretWidth = max(1, ((tagline ? taglines.width : cellWidth) / 8).rounded(.up)) ctx.setShouldAntialias(false) fill(ctx, CGRect(x: caretX, y: caretY, width: caretWidth, height: caretHeight), fg, 1) } } if frozenImages == nil { drawBodyLayers(ctx, hideCursor:hideCursor) drawTagLayers(ctx, hideCursor:hideCursor, canonical:cells, cols:cols, rows:rows) } else { for layer in presentedTagLayers { drawTagGrip(ctx, values:layer.values, canonical:cells, cols:cols, rows:rows) layer.cells.withUnsafeBufferPointer { cells in if let base = cells.baseAddress { drawTagLayer(ctx, values:layer.values, cells:base, hideCursor:true) } } } for layer in presentedBodyLayers { layer.cells.withUnsafeBufferPointer { cells in if let base = cells.baseAddress { drawBodyLayer(ctx, values:layer.values, cells:base, hideCursor:true) } } } } } private var presentedTagLayers: [FrozenBodyLayer] = [] private func snapshotTagLayers() -> [FrozenBodyLayer] { var result: [FrozenBodyLayer] = [] for index in 0.., cols: Int, rows: Int) { for index in 0.. GripGlyph? { let key = "\(face.rawValue):\(text)" if let cached = gripGlyphCache[key] { return cached } let attributed = NSAttributedString(string: text, attributes: [fontAttribute: taglines.fonts[face.rawValue], NSAttributedString.Key(kCTForegroundColorFromContextAttributeName as String): true]) let line = CTLineCreateWithAttributedString(attributed as CFAttributedString) let ink = CTLineGetBoundsWithOptions(line, [.useGlyphPathBounds]) guard !ink.isNull, !ink.isInfinite, !ink.isEmpty else { return nil } let measured = GripGlyph(line: line, ink: ink) gripGlyphCache[key] = measured return measured } private func drawTagGrip(_ ctx: CGContext, values: [Int], canonical: UnsafePointer, cols: Int, rows: Int) { let width = Int(pardes_grip_columns()) let x = values[4] - Int(pardes_tag_text_inset()), y = values[5] if values[1] != 2 || x < 0 || x >= cols || y < 0 || y >= rows { return } var cell = canonical[y * cols + x] let text = withUnsafeBytes(of: &cell.text) { String(decoding: $0.prefix(Int(cell.len)), as: UTF8.self) } if text.isEmpty { return } let style = resolve(cell, block:false, ground:themeBG ?? pardesDefaultBG, clearGround:false) let centerX = (CGFloat(x) + CGFloat(width) / 2) * cellWidth let centerY = CGFloat(y) * cellHeight + taglines.top(row:y, canvasHeight:bounds.height) + taglines.height / 2 if text != " " { let face = Face(bold: cell.attrs & UInt16(PARDES_ATTR_BOLD) != 0, italic: cell.attrs & UInt16(PARDES_ATTR_ITALIC) != 0) guard style.visible, let measured = gripGlyph(text, face: face) else { return } let originX = ((centerX - measured.ink.midX) * metricsScale).rounded() / metricsScale let baseline = ((bounds.height - centerY - measured.ink.midY) * metricsScale).rounded() / metricsScale ctx.saveGState() ctx.clip(to: CGRect(x: CGFloat(x) * cellWidth, y: CGFloat(y) * cellHeight + taglines.top(row:y, canvasHeight:bounds.height), width: CGFloat(width) * cellWidth, height: taglines.height)) ctx.translateBy(x: 0, y: bounds.height) ctx.scaleBy(x: 1, y: -1) ctx.textMatrix = .identity ctx.textPosition = CGPoint(x: originX, y: baseline) setFill(ctx, style.fg, style.alpha) CTLineDraw(measured.line, ctx) ctx.restoreGState() return } if taglines.height < 6 || taglines.width < 3 { return } let halfWidth = max(1, min(3, cellWidth - 2)) fill(ctx, CGRect(x:floor(centerX-halfWidth), y:floor(centerY-2), width:2*halfWidth, height:1), style.fg, 1) fill(ctx, CGRect(x:floor(centerX-halfWidth), y:floor(centerY+1), width:2*halfWidth, height:1), style.fg, 1) } private func drawTagLayer(_ ctx: CGContext, values: [Int], cells: UnsafePointer, hideCursor: Bool) { let left = CGFloat(values[4]) * cellWidth let y = CGFloat(values[5]) * cellHeight + taglines.top(row: values[5], canvasHeight: bounds.height) let viewport = CGRect(x: left, y: y, width: CGFloat(values[6]) * cellWidth, height: taglines.height) ctx.saveGState() ctx.clip(to: viewport) fill(ctx, viewport, UInt32(values[10]), 1) for pass in 0...1 { for col in 0.. [FrozenBodyLayer] { var result: [FrozenBodyLayer] = [] for index in 0.., hideCursor: Bool) { func value(_ field: Int) -> Int { values[field] } let rows = value(0), cols = value(1), contexts = value(2) let left = CGFloat(value(3)) * cellWidth, top = CGFloat(value(4)) * cellHeight let viewport = CGRect(x: left, y: top, width: CGFloat(value(5))*cellWidth, height: CGFloat(value(6))*cellHeight) ctx.saveGState() ctx.clip(to: viewport) fill(ctx, viewport, themeBG ?? pardesDefaultBG, 1) for row in 0..= viewport.maxY { break } let rowStyle = resolve(cells[row*cols], block: false, ground: themeBG ?? pardesDefaultBG, clearGround: false) fill(ctx, CGRect(x: left, y: y, width: viewport.width, height: height), rowStyle.bg, 1) for pass in 0...1 { for col in 0.. 0, let list = pardes_frame_image_list() else { // Nothing on screen owns pixels any more: the pages a closed pane // rendered would otherwise sit in here for the rest of the session. if !imageCache.isEmpty { imageCache.removeAll(keepingCapacity: true) } return } // The core computed every rectangle in PHYSICAL pixels, because that is // what pardes_resize handed it. The view draws in points. let scale = max(1, metricsScale) var live = Set() live.reserveCapacity(count) ctx.setShouldAntialias(true) for i in 0.. live.count { imageCache = imageCache.filter { live.contains($0.key) } } } private func drawFrozenImages(_ ctx: CGContext, _ images: [FrozenImage]) { ctx.setShouldAntialias(true) for image in images { drawFrozenImage(ctx, image) } } /// Native attachments do not occupy Cell graphemes, so the core's semantic /// text/style mask cannot see a page appearing, disappearing, rerasterizing /// or moving. Add only those changed placements to the shader mask; an /// identical key and geometry remains immediate like any unchanged cell. private func changedAttachmentRects() -> [CGRect] { var oldByKey: [ImageKey: FrozenImage] = [:] for image in presentedImages { oldByKey[image.key] = image } var changed: [CGRect] = [] changed.reserveCapacity(frameImages.count + presentedImages.count) for image in frameImages { guard let old = oldByKey.removeValue(forKey: image.key) else { changed.append(image.destination.intersection(image.body)) continue } if old.source != image.source || old.body != image.body || old.destination != image.destination { changed.append(old.destination.intersection(old.body)) changed.append(image.destination.intersection(image.body)) } } for old in oldByKey.values { changed.append(old.destination.intersection(old.body)) } return changed.filter { !$0.isNull && !$0.isEmpty } } private func drawFrozenImage(_ ctx: CGContext, _ placed: FrozenImage) { ctx.saveGState() ctx.clip(to: placed.body) // isFlipped gives us a y-down CTM and CGImage draws +y up, so flip // about this destination rather than changing the grid arithmetic. ctx.translateBy(x: placed.destination.minX, y: placed.destination.maxY) ctx.scaleBy(x: 1, y: -1) ctx.interpolationQuality = .high ctx.draw(placed.image, in: CGRect( x: 0, y: 0, width: placed.destination.width, height: placed.destination.height)) ctx.restoreGState() } /// The decoded raster for one attachment, made once per generation. private func image(for place: pardes_image_s, key: ImageKey) -> CGImage? { if let cached = imageCache[key] { return cached } let bytes = Int(place.iw) * Int(place.ih) * 4 guard bytes > 0, let rgba = place.rgba else { return nil } // Copied, not referenced: the core lends these bytes until the next // pardes_frame and this image outlives many of them. guard let data = CFDataCreate(nil, rgba, bytes), let provider = CGDataProvider(data: data) else { return nil } // Straight alpha, R,G,B,A in memory — the same bytes the SDL shell // uploads as R8G8B8A8_UNORM and blends with ONE_MINUS_SRC_ALPHA. let made = CGImage( width: Int(place.iw), height: Int(place.ih), bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: Int(place.iw) * 4, space: sRGB, bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue | CGBitmapInfo.byteOrder32Big.rawValue), provider: provider, decode: nil, shouldInterpolate: true, intent: .defaultIntent) if let made { imageCache[key] = made } return made } /// 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, base: Int, cols: Int, /// This row's grid index. Needed per cell rather than per row for the /// compact tagline anchor: a column split puts two panes' tags side by /// side on ONE row, so the origin is a question about the cell. row: Int, baseline: CGFloat, /// Where this row's tagline band starts, from the row's top. Passed in /// rather than recomputed per cell: it is one answer per row, and the /// glyph has to sit in the band the background pass painted. taglineTop: CGFloat, blockCol: Int, paneGripCells: Set ) { var runFace = Face.regular var runTagline = false 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) let font = runTagline ? taglines.fonts[runFace.rawValue] : metrics.fonts[runFace.rawValue] CTFontDrawGlyphs(font, runGlyphs, runPositions, runGlyphs.count, ctx) runGlyphs.removeAll(keepingCapacity: true) runPositions.removeAll(keepingCapacity: true) } for col in 0..> UInt16(PARDES_ATTR_UL_SHIFT) != 0 || cell.attrs & UInt16(PARDES_ATTR_STRIKETHROUGH) != 0 { flush() drawRules(ctx, cell, style, x: x, width: advanceWidth, baseline: cellBaseline) } 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) if paneGripCells.contains(base + col) && gripGlyph(text, face: face) != nil { // The layer pass centers this symbol by its actual ink bounds. continue } let units = text.utf16 let known = units.count == 1 ? glyph(face, units.first!, tagline: tagline) : 0 if known != 0 { if !runGlyphs.isEmpty && (face != runFace || tagline != runTagline || style.fg != runColor || style.alpha != runAlpha) { flush() } runFace = face runTagline = tagline runColor = style.fg runAlpha = style.alpha runGlyphs.append(known) runPositions.append(CGPoint(x: x, y: cellBaseline)) 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 font = tagline ? taglines.fonts[face.rawValue] : metrics.fonts[face.rawValue] let attributed = NSAttributedString(string: text, attributes: [fontAttribute: font]) ctx.textPosition = CGPoint(x: x, y: cellBaseline) 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, tagline: Bool) -> CGGlyph { if character < 128 { let ascii = tagline ? taglines.asciiGlyphs : metrics.asciiGlyphs return ascii[face.rawValue][Int(character)] } let key = UInt32(face.rawValue) << 16 | UInt32(character) if tagline, let cached = taglineGlyphCache[key] { return cached } if !tagline, let cached = bodyGlyphCache[key] { return cached } var input = character var found = CGGlyph(0) let font = tagline ? taglines.fonts[face.rawValue] : metrics.fonts[face.rawValue] _ = CTFontGetGlyphsForCharacters(font, &input, &found, 1) if tagline { taglineGlyphCache[key] = found } else { bodyGlyphCache[key] = found } return found } private func drawRules( _ ctx: CGContext, _ cell: pardes_cell_s, _ style: (fg: UInt32, bg: UInt32, alpha: CGFloat, visible: Bool), x: CGFloat, /// The cell's advance: the body cell, or the narrower tagline one. A /// rule is as wide as the character it belongs to, and on a tag row /// that stopped being the body cell when the text compacted. width: CGFloat, baseline: CGFloat ) { let underline = Int(cell.attrs >> PARDES_ATTR_UL_SHIFT) & 7 if underline != Int(PARDES_UL_OFF) { let y = baseline + metrics.underlineOffset fill(ctx, CGRect(x: x, y: y, width: width, 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 - metrics.ruleThickness * 2, width: width, height: metrics.ruleThickness), style.fg, style.alpha) } } if cell.attrs & UInt16(PARDES_ATTR_STRIKETHROUGH) != 0 { // Rounded like every other rule offset: a third of the ascent is a // fraction, and a fractional one-pixel bar is a two-pixel smear. fill(ctx, CGRect(x: x, y: baseline + (metrics.ascent * 0.3).rounded(), width: width, height: metrics.ruleThickness), style.fg, style.alpha) } } 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: - 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) } /// A point inside the NSView can still be outside the CRT's curved tube. /// For input that is the same as leaving the grid: discard a pending hover /// preview, but do not manufacture a clamped click or wheel gesture. private func leftGrid() { pardes_pointer_leave() fed() } 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. private var sampledBodyPointer: CGPoint? private func sendMouse(_ button: pardes_mouse_button_e, _ kind: pardes_mouse_kind_e, _ col: UInt16, _ row: UInt16, _ mods: UInt32) { guard let point = sampledBodyPointer else { pardes_mouse(button, kind, col, row, mods) return } let scale = metricsScale pardes_mouse_pixel(button, kind, col, row, mods, Float(point.x*scale), Float(point.y*scale), Float(cellWidth*scale), Float(cellHeight*scale), Float(taglines.width*scale), Float(taglines.height*scale)) } func press(_ button: pardes_mouse_button_e, at cell: GridPoint, mods: UInt32 = 0) { sendMouse(button, PARDES_MOUSE_PRESS, cell.col, cell.row, mods) fed() } func release(_ button: pardes_mouse_button_e, at cell: GridPoint, mods: UInt32 = 0) { sendMouse(button, PARDES_MOUSE_RELEASE, cell.col, cell.row, mods) fed() } func drag(_ button: pardes_mouse_button_e, to cell: GridPoint, mods: UInt32 = 0) { sendMouse(button, PARDES_MOUSE_DRAG, cell.col, cell.row, mods) fed() } func motion(to cell: GridPoint, mods: UInt32 = 0) { sendMouse(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) { sendMouse(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() } /// The fingers came off the trackpad. A post-decode entry point of its own /// so the e2e harness can throw the dial: NSEvent phases have no public /// constructor, and a fling nothing can synthesize is a fling nothing can /// assert. func rotateEnd() { pardes_rotate_end() 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. // 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" { 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 // 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 // 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 { leftGrid() 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 else { return } guard let at = cell(for: event) else { // The drag is still latched, but the curved CRT edge displays no // source cell here. Keep its last real endpoint for mouseUp and // clear hover/seam state without clamping into the grid. leftGrid() 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 if let at = cell(for: event) { latchedCell = nil release(button, at: at, mods: modifiers(event.modifierFlags)) return } // A release must balance its press even when the hand ends in a black // CRT corner. The last mapped drag point is an observed source cell, // not a clamp of the unmappable release point. Send both state changes // before one pump notification so no frame can observe a held button // whose pointer is already outside the displayed scene. if let last = latchedCell { sendMouse(button, PARDES_MOUSE_RELEASE, last.col, last.row, modifiers(event.modifierFlags)) } latchedCell = nil pardes_pointer_leave() fed() } 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) } // Thumb navigation has its own stream so it cannot replace a held // selection button or inherit a resting trackpad's finger count. override func otherMouseDown(with event: NSEvent) { if event.buttonNumber == 2 { beginClick(PARDES_MOUSE_MIDDLE, event); return } guard event.buttonNumber == 3 || event.buttonNumber == 4, let at = cell(for: event) else { return } press(event.buttonNumber == 3 ? PARDES_MOUSE_BACK : PARDES_MOUSE_FORWARD, at: at, mods: pointerModifiers) } override func otherMouseDragged(with event: NSEvent) { if event.buttonNumber == 2 { continueClick(event) } } override func otherMouseUp(with event: NSEvent) { if event.buttonNumber == 2 { endClick(event); return } guard event.buttonNumber == 3 || event.buttonNumber == 4, let at = cell(for: event) else { return } release(event.buttonNumber == 3 ? PARDES_MOUSE_BACK : PARDES_MOUSE_FORWARD, at: at, mods: pointerModifiers) } /// 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 look one goes out: /// a right press arriving while the core holds a left select-drag is /// acme's 1-3 chord, which is Paste. Releasing first costs a cursor move /// at the click point — which is what clicking there would have done /// anyway; a multi-finger press released this way fires its own Exec, /// which is what the fingers already asked for. /// /// 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 Exec upgraded by /// pressing harder is precisely the gesture. Already-Look 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 latchedCell = 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 { leftGrid() return } motion(to: at, mods: modifiers(event.modifierFlags)) } override func mouseExited(with event: NSEvent) { pointerPoint = nil pointerMapped = false leftGrid() } override func scrollWheel(with event: NSEvent) { guard let at = cell(for: event) else { leftGrid() return } if event.hasPreciseScrollingDeltas { // 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. 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. 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)) } } } /// 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 and the momentum, exactly as it owns the scroll accumulator. /// /// AppKit gives rotation no momentum phase of its own — `momentumPhase` is /// scroll's alone — so the fling is measured from the release speed on the /// Zig side rather than handed to us. All this has to get right is telling /// it where the gesture starts and stops. 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 — // and stops a fling still coasting, because a finger back down is how // a hand catches a dial. if event.phase == .began { pardes_rotate(0) } rotate(degrees: CGFloat(event.rotation)) // .cancelled too: a gesture the system took away should not fling. if event.phase == .ended || event.phase == .cancelled { rotateEnd() } } /// 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 Exec 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? { let point = convert(event.locationInWindow, from: nil) pointerPoint = point pointerModifiers = modifiers(event.modifierFlags) let at = cellAt(point) pointerMapped = at != nil if let at { pointerCell = at } return at } 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 // 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 sampled: CGPoint if let scene = lastPresentedScene { guard let mapped = ScenePostprocessor.sourcePoint(point, size: bounds.size, scene: scene) else { return nil } sampled = mapped } else { sampled = point } sampledBodyPointer = sampled let row = min(max(Int(sampled.y / cellHeight), 0), rows - 1) let col = min(max(Int(sampled.x / cellWidth), 0), cols - 1) return GridPoint(col: UInt16(col), row: UInt16(row)) } // MARK: - files dropped on the grid /// A drop is a CLICK followed by `Look`, and that is the whole definition. /// /// The core has no notion of a drop and is not being given one: the pointer /// lands where it landed, which focuses that pane exactly as a left click /// there would, and then the ordinary `Look` builtin runs in it — so the /// document opens beside the pane you pointed at rather than beside /// whichever one happened to be focused. Drop on a tag and you clicked a /// tag; there is no case to special-case, and nothing here the hand could /// not have done itself. override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { // AppKit reuses this answer for draggingUpdated when that is not // implemented, so the cursor stays right for the whole drag. droppedFiles(sender).isEmpty ? [] : .copy } override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { let paths = droppedFiles(sender) guard !paths.isEmpty else { return false } drop(paths, at: cellAt(convert(sender.draggingLocation, from: nil))) return true } /// The drop, decoded: paths and a cell, nothing AppKit left in it. /// /// Split out for the reason every gesture here is — `NSDraggingInfo` is a /// protocol with a dozen members and no public conformer, so a test that /// had to build one would be testing its own stub. The decision lives one /// call below the event, and `drop` in test/macos_e2e.swift drives exactly /// this. /// /// A nil cell is a drop before the first frame, which has no grid to point /// at: the files still open, they just open where focus already was. func drop(_ paths: [String], at target: GridPoint?) { if let target { press(PARDES_MOUSE_LEFT, at: target) release(PARDES_MOUSE_LEFT, at: target) } // Whole tail, unquoted: executeBuiltinLine takes everything after the // first word as the argument, so a path with spaces in it needs no // escaping and would in fact break under any. for path in paths { let line = "Look \(path)" line.withCString { pardes_command($0, line.utf8.count) } } fed() } /// File paths on the drag pasteboard, in order. Empty for anything else, /// which is also how draggingEntered decides whether to accept at all. private func droppedFiles(_ sender: NSDraggingInfo) -> [String] { let options: [NSPasteboard.ReadingOptionKey: Any] = [.urlReadingFileURLsOnly: true] guard let urls = sender.draggingPasteboard.readObjects( forClasses: [NSURL.self], options: options) as? [URL] else { return [] } return urls.map(\.path) } // 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, .mouseEnteredAndExited, .inVisibleRect, .activeInKeyWindow], owner: self, userInfo: nil)) } private var linkPointer = false 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: linkPointer ? .pointingHand : .iBeam) } 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. 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.) // // TWO things depend on the scale: the physical cell metrics the core uses // to place PDF pages, and the cell itself, which is snapped to whole // DEVICE pixels (see Metrics) and is therefore aligned to the display it // was measured on. Re-measuring reports the resize on its own, so the // delegate call is the else-branch and not an extra one. override func viewDidChangeBackingProperties() { super.viewDidChangeBackingProperties() if let scale = window?.backingScaleFactor, scale != metricsScale { wear(size: fontSize, path: fontPath) } else { 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, and it needs the core to be able to render an // underlined preedit run first.