diff options
Diffstat (limited to 'src/macos/Sources')
| -rw-r--r-- | src/macos/Sources/AppDelegate.swift | 63 | ||||
| -rw-r--r-- | src/macos/Sources/PardesView.swift | 237 |
2 files changed, 277 insertions, 23 deletions
diff --git a/src/macos/Sources/AppDelegate.swift b/src/macos/Sources/AppDelegate.swift index 0cecd5da..2d411873 100644 --- a/src/macos/Sources/AppDelegate.swift +++ b/src/macos/Sources/AppDelegate.swift @@ -22,6 +22,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { // call pump(), but none advances animation; they only observe this flag and // leave the already-scheduled frame alone. private var pumpScheduled: Bool = false + /// The display's own clock, when the OS will lend us one. + /// + /// The fallback below is a `DispatchQueue.asyncAfter(0.016)` chain re-armed + /// AFTER each frame's tick, drain and draw, so its real period is 16 ms plus + /// all of that — comfortably slower than 60 Hz, and jittery, and never in + /// phase with the refresh. That is what makes an animation look choppy even + /// when nothing is dropping frames. A display link fires once per refresh, + /// phase-locked to vsync, which is the cadence the picture is actually + /// presented at. + /// + /// Cadence is now only a SMOOTHNESS question: `pardes_animation_tick` + /// spends measured elapsed time, so a 120 Hz link does not double-speed an + /// animation and a slow one does not halve it. + /// Held as `AnyObject` because a stored property cannot carry + /// `@available`, and this file still deploys to macOS 13. + private var displayLink: AnyObject? // The core is a singleton with no "is it alive" query, and Finder can hand // us documents before applicationDidFinishLaunching runs. Every entry point // that would call into libpardes from outside the launch sequence checks @@ -585,25 +601,62 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { } private func scheduleAnimationFrame() { - guard pardes_animating() && !pumpScheduled else { return } + guard pardes_animating() else { + stopDisplayLink() + return + } + if #available(macOS 14.0, *) { + // One link for the whole animating run, not one callback armed per + // frame: re-arming after the work is what put the period at 16 ms + // PLUS the work, and no amount of tuning that constant fixes a + // clock that is not the display's. + if displayLink == nil { + let link = view.displayLink(target: self, selector: #selector(displayLinkFired)) + link.add(to: .main, forMode: .common) + displayLink = link + } + return + } + // macOS 13 has no NSView display link. Keep the old chain, which is now + // only a cadence compromise rather than a correctness one — the tick + // spends measured time either way. + guard !pumpScheduled else { return } let awaitingPresentation = view.presentationSerial pumpScheduled = true DispatchQueue.main.asyncAfter(deadline: .now() + 0.016) { + self.pumpScheduled = false self.animationFrame(after: awaitingPresentation) } } + private func stopDisplayLink() { + if #available(macOS 14.0, *) { (displayLink as? CADisplayLink)?.invalidate() } + displayLink = nil + } + + @objc private func displayLinkFired() { + guard coreIsUp else { return } + animationFrame(after: pendingPresentation) + } + + /// The presentation serial the last dirtied sample is waiting on. Held + /// across display-link callbacks because the link, unlike the old chain, + /// does not carry it in a closure. + private var pendingPresentation: UInt64 = 0 + private func animationFrame(after awaitingPresentation: UInt64) { - pumpScheduled = false guard view.presentationSerial != awaitingPresentation else { // Keep the dirty sample pending. AppKit may have coalesced this - // draw or the window may be occluded; retry the clock without - // advancing until draw(_:) supplies the presentation permit. + // draw or the window may be occluded; retry on the next refresh + // without advancing, until draw(_:) supplies the presentation + // permit. On the link that costs ONE refresh; the old chain paid a + // fresh 16 ms for it, which is where the visible hitching came from. view.needsDisplay = true - scheduleAnimationFrame() + if #available(macOS 14.0, *) {} else { scheduleAnimationFrame() } return } _ = pardes_animation_tick() + pendingPresentation = view.presentationSerial pump() } diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift index 10c7fd8b..3f5f1346 100644 --- a/src/macos/Sources/PardesView.swift +++ b/src/macos/Sources/PardesView.swift @@ -191,6 +191,125 @@ private func advance(_ font: CTFont, _ character: UniChar) -> CGFloat { /// 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<String>() + + 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..<pardes_fallback_font_count() { + var length: UInt32 = 0 + guard let bytes = pardes_fallback_font_name(index, &length), length > 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. @@ -221,7 +340,12 @@ private struct Metrics { init(size: CGFloat, path: String?, scale: CGFloat) { let requested = path.flatMap { Metrics.fromFile($0, size) } - let face = requested ?? Metrics.defaultFace(size: 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). @@ -331,7 +455,13 @@ private struct TaglineMetrics { /// 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 - let xOffset: 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 @@ -358,7 +488,7 @@ private struct TaglineMetrics { // over the body row below it. height = min(body.cellHeight, measuredHeight) ascent = max(1 / scale, snap(CTFontGetAscent(face), .toNearestOrAwayFromZero)) - xOffset = snap(max(0, (body.cellWidth - advance(face, 0x4D)) / 2), .toNearestOrAwayFromZero) + width = min(body.cellWidth, max(1 / scale, snap(advance(face, 0x4D), .toNearestOrAwayFromZero))) asciiGlyphs = faces.map { font in var chars = Array(UniChar(0)..<UniChar(128)) var glyphs = [CGGlyph](repeating: 0, count: 128) @@ -380,6 +510,20 @@ private struct TaglineMetrics { UInt16(clamping: row), Float(canvasHeight * scale), cellPixels, bandPixels)) / scale } + /// Where a tagline cell's GLYPH sits, on the compact grid. + /// + /// Only the text compacts. The colour band behind it was already painted + /// pane-wide on the body grid, which is what keeps a tag bar the width of + /// its pane; this steps the characters on top of it at the tagline face's + /// own pitch, anchored so the compact grid's column zero is the pane's + /// physical left edge. The anchor is the core's — the SDL shell reaches the + /// identical rule through `pardes.taglineOriginCol`, and pane rects are not + /// otherwise on this shell's ABI at all. + func glyphX(col: Int, row: Int, bodyCellWidth: CGFloat) -> CGFloat { + let origin = CGFloat(pardes_tagline_origin_col(UInt16(clamping: col), UInt16(clamping: row))) + return origin * bodyCellWidth + (CGFloat(col) - origin) * width + } + /// 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 { @@ -830,6 +974,14 @@ final class PardesView: NSView { // 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 + // The tag band's own colour, fetched once per frame. A compact tag row + // is painted in TWO passes — pane-wide band on the body grid, then each + // cell's own background on the narrower grid its glyph uses — which is + // what the SDL shell spends its second quad per tagline cell on. Before + // there is a session to ask there is no base and the single body-grid + // fill below is all there is. + let taglineBaseBG = pardes_tagline_bg() + let taglineTwoPass = taglineBaseBG != UInt32(PARDES_COLOR_DEFAULT) for row in 0..<rows { let base = row * cols let y = CGFloat(row) * cellHeight @@ -855,9 +1007,29 @@ final class PardesView: NSView { if color != bgClear { let bandY = tagline ? y + bandTop : y let bandHeight = tagline ? taglines.height : cellHeight - fill(ctx, CGRect(x: CGFloat(start) * cellWidth, y: bandY, - width: CGFloat(col - start) * cellWidth, height: bandHeight), - color, 1) + if tagline && taglineTwoPass { + // The band keeps the BODY grid so it spans its pane + // exactly. Anything that is NOT the band — a hovered + // word, a selection, the block cursor — belongs to the + // cell and has to land under the glyph, which moved to + // the narrower grid. Runs are painted left to right and + // a compact overlay always sits left of its own run's + // right edge, so no later band can cover one. + fill(ctx, CGRect(x: CGFloat(start) * cellWidth, y: bandY, + width: CGFloat(col - start) * cellWidth, height: bandHeight), + taglineBaseBG, 1) + if color != taglineBaseBG { + fill(ctx, CGRect( + x: taglines.glyphX(col: start, row: row, bodyCellWidth: cellWidth), + y: bandY, + width: CGFloat(col - start) * taglines.width, + height: bandHeight), color, 1) + } + } else { + fill(ctx, CGRect(x: CGFloat(start) * cellWidth, y: bandY, + width: CGFloat(col - start) * cellWidth, height: bandHeight), + color, 1) + } } start = col color = next @@ -933,7 +1105,7 @@ final class PardesView: NSView { ctx.scaleBy(x: 1, y: -1) let height = bounds.height for row in 0..<rows { - drawRow(ctx, cells, base: row * cols, cols: cols, + drawRow(ctx, cells, base: row * cols, cols: cols, row: row, baseline: height - (CGFloat(row) * cellHeight + metrics.ascent), taglineTop: taglines.top(row: row, canvasHeight: canvasHeight), blockCol: blockY == row ? blockX : -1) @@ -963,9 +1135,17 @@ final class PardesView: NSView { 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: CGFloat(x) * cellWidth, y: caretY, - width: max(1, (cellWidth / 8).rounded(.up)), height: caretHeight), fg, 1) + fill(ctx, CGRect(x: caretX, y: caretY, + width: caretWidth, height: caretHeight), fg, 1) } } } @@ -1128,6 +1308,10 @@ final class PardesView: NSView { _ cells: UnsafePointer<pardes_cell_s>, 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 @@ -1164,7 +1348,14 @@ final class PardesView: NSView { // never the hole in the ground. let style = resolve(cell, block: blockCol == col, ground: themeBG ?? pardesDefaultBG, clearGround: false) - let x = CGFloat(col) * cellWidth + // Tagline cells step on the smaller face's own pitch, anchored at + // their pane; everything else on the body grid. The rules go with + // the glyph, not with the body cell, or an underlined tag word ends + // up underlining its neighbour. + let x = tagline + ? taglines.glyphX(col: col, row: row, bodyCellWidth: cellWidth) + : CGFloat(col) * cellWidth + let advanceWidth = tagline ? taglines.width : 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 @@ -1172,7 +1363,7 @@ final class PardesView: NSView { if cell.attrs >> UInt16(PARDES_ATTR_UL_SHIFT) != 0 || cell.attrs & UInt16(PARDES_ATTR_STRIKETHROUGH) != 0 { flush() - drawRules(ctx, cell, style, x: x, baseline: cellBaseline) + drawRules(ctx, cell, style, x: x, width: advanceWidth, baseline: cellBaseline) } guard style.visible else { continue } @@ -1190,7 +1381,6 @@ final class PardesView: NSView { italic: cell.attrs & UInt16(PARDES_ATTR_ITALIC) != 0) let units = text.utf16 let known = units.count == 1 ? glyph(face, units.first!, tagline: tagline) : 0 - let glyphX = x + (tagline ? taglines.xOffset : 0) if known != 0 { if !runGlyphs.isEmpty && (face != runFace || tagline != runTagline @@ -1202,7 +1392,7 @@ final class PardesView: NSView { runColor = style.fg runAlpha = style.alpha runGlyphs.append(known) - runPositions.append(CGPoint(x: glyphX, y: cellBaseline)) + runPositions.append(CGPoint(x: x, y: cellBaseline)) continue } @@ -1214,7 +1404,7 @@ final class PardesView: NSView { 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: glyphX, y: cellBaseline) + 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 @@ -1256,24 +1446,28 @@ final class PardesView: NSView { _ 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: cellWidth, height: metrics.ruleThickness), style.fg, style.alpha) + 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: cellWidth, height: metrics.ruleThickness), + 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: cellWidth, height: metrics.ruleThickness), + fill(ctx, CGRect(x: x, y: baseline + (metrics.ascent * 0.3).rounded(), width: width, height: metrics.ruleThickness), style.fg, style.alpha) } } @@ -1670,8 +1864,15 @@ final class PardesView: NSView { } else { sampled = point } - let col = min(max(Int(sampled.x / cellWidth), 0), cols - 1) let row = min(max(Int(sampled.y / cellHeight), 0), rows - 1) + // The column comes from the core, because a tag row's glyphs step at + // the tagline face's narrower pitch and a body-pitch click drifts one + // word further right for every word along the row. The SDL shell asks + // the identical rule (`pardes.gridColAt`). Points on both sides: only + // the ratio of x to the two widths is read. + let col = min(max(Int(pardes_grid_col_at( + Float(sampled.x), UInt16(clamping: row), + Float(cellWidth), Float(taglines.width))), 0), cols - 1) return GridPoint(col: UInt16(col), row: UInt16(row)) } |
