From 44ce573e15c773cf5bb0d42941a143b63e44d900 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 21 Sep 2026 14:55:53 -0300 Subject: Make the macOS shell a first-class host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pty children are exec'd with their own TERM/COLORTERM/TERM_PROGRAM instead of inheriting a .app launch's empty environment, and ttyTaken finally answers on darwin — libproc walks the tty's foreground process group — so Escape reaches the child and Exec stops believing every pane sits at its prompt. The occupancy suite runs on both platforms now. The workspace tag row moves into the native menu bar as a Builtins menu. -Dworkspace-tag (default off for -Dplatform=macos, on everywhere else) drives it, and Pardes.topBarHeight replaces the TOPBAR_H constant so the core stops reserving the row. The view pins every variable-font axis to the file's own default (Maple Mono came up Thin otherwise), shapes ligatures, carries per-shape pointer cursors, and draws the look-hover affordance as refracted glass. Tag rows fill edge to edge, with the anchor box painted back on top of that fill and its mode glyph centred on the same square. Theme accents re-saturated across the set. Co-Authored-By: Claude Opus 5 (1M context) --- src/macos/Sources/AppDelegate.swift | 18 +- src/macos/Sources/PardesView.swift | 406 +++++++++++++++++++++++++++++++++--- 2 files changed, 395 insertions(+), 29 deletions(-) (limited to 'src/macos/Sources') diff --git a/src/macos/Sources/AppDelegate.swift b/src/macos/Sources/AppDelegate.swift index cbe9cdc9..3cce7592 100644 --- a/src/macos/Sources/AppDelegate.swift +++ b/src/macos/Sources/AppDelegate.swift @@ -310,6 +310,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { file.addItem(withTitle: "Close Window", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") + // The workspace tag row's commands, in the place macOS keeps window + // commands. The build hands that row to this menu bar (see + // docs/macos.md): the words are the same builtins the tag carried, + // spelled exactly as a tag Exec would type them, and nothing here is + // editable — the menu is the tag's buttons, not the tag. + let builtins = submenu("Builtins", of: main) + for word in ["Newcol", "Joincol", "Find", "Grep", "Changelog", + "Dump", "NextColor", "Debug", "Kill"] { + builtins.addItem(command(word, #selector(menuBuiltin(_:)), "")) + } let edit = submenu("Edit", of: main) // Paste and nothing else. Copy, Cut, Undo and Select All have no // builtin behind them — the core yanks into the clipboard on its own @@ -379,13 +389,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { // here would hide half of what Look does. panel.canChooseDirectories = true panel.allowsMultipleSelection = true - panel.resolvesAliases = true - guard panel.runModal() == .OK else { return } for url in panel.urls where url.isFileURL { look(url.path) } } + /// Any workspace-tag word. The title is the builtin's own spelling, so + /// this is the same `run` a tag Exec performs and there is no second + /// vocabulary to keep in step. + @objc private func menuBuiltin(_ sender: NSMenuItem) { run(sender.title) } + + @objc private func menuNew(_ sender: Any?) { run("New") } @objc private func menuSave(_ sender: Any?) { run("Save") } @objc private func menuHelp(_ sender: Any?) { run("Help") } diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift index 7981cf46..5ad09c38 100644 --- a/src/macos/Sources/PardesView.swift +++ b/src/macos/Sources/PardesView.swift @@ -433,7 +433,26 @@ private struct Metrics { 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) + let chosen = CTFontCreateWithFontDescriptor(plain ?? descriptors[0], size, nil) + // A variable face's descriptor carries no variation, and CoreText + // picks whichever named instance it likes for it — Maple Mono's + // variable file comes up Thin. Pin every axis to its own default: + // the face the file itself calls regular. + var face = chosen + if let axes = CTFontCopyVariationAxes(face) as? [[CFString: Any]] { + var defaults: [CFNumber: Any] = [:] + for axis in axes { + guard let tag = axis[kCTFontVariationAxisIdentifierKey], + let value = axis[kCTFontVariationAxisDefaultValueKey] else { continue } + defaults[tag as! CFNumber] = value + } + if !defaults.isEmpty { + let descriptor = CTFontDescriptorCreateCopyWithAttributes( + CTFontCopyFontDescriptor(face), + [kCTFontVariationAttribute: defaults] as CFDictionary) + face = CTFontCreateWithFontDescriptor(descriptor, 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. @@ -577,7 +596,14 @@ final class PardesView: NSView { // full redraw does not allocate 24 times. private var runGlyphs: [CGGlyph] = [] private var runPositions: [CGPoint] = [] - + /// Ligature shaping, keyed by face and text. A sequence ligates when + /// shaping it produces fewer glyphs than it has characters — the only + /// answer CoreText gives without interrogating the font's own features. + /// Cleared with the glyph caches, because it belongs to one face. + private var ligatureShaped: [String: Bool] = [:] + /// Columns the current row has already consumed as part of a ligature + /// drawn further left. One row's worth at a time. + private var ligatureCovered: Set = [] /// 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. @@ -748,6 +774,7 @@ final class PardesView: NSView { bodyGlyphCache.removeAll(keepingCapacity: true) taglineGlyphCache.removeAll(keepingCapacity: true) gripGlyphCache.removeAll(keepingCapacity: true) + ligatureShaped.removeAll(keepingCapacity: true) metrics = next taglines = TaglineMetrics(body: next, percent: taglines.percent, scale: scale) metricsScale = scale @@ -844,12 +871,12 @@ final class PardesView: NSView { // 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 + let nextPointer = pointerShape() + if nextPointer != pointer { + pointer = nextPointer window?.invalidateCursorRects(for: self) if let window, bounds.contains(convert(window.mouseLocationOutsideOfEventStream, from: nil)) { - (linkPointer ? NSCursor.pointingHand : NSCursor.iBeam).set() + cursorFor(pointer).set() } } let scene = pardes_scene() @@ -1178,10 +1205,13 @@ final class PardesView: NSView { 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) } + layer.cells.withUnsafeBufferPointer { tagCells in + if let base = tagCells.baseAddress { + drawTagLayer(ctx, values: layer.values, cells: base, hideCursor: true, + canonical: cells, cols: cols) + } } + drawTagGrip(ctx, values: layer.values, canonical: cells, cols: cols, rows: rows) } for layer in presentedBodyLayers { layer.cells.withUnsafeBufferPointer { cells in @@ -1190,6 +1220,7 @@ final class PardesView: NSView { } drawTagRules(ctx, layers: presentedTagLayers.map { $0.values }) } + drawHoverGlass(ctx, cells: cells, cols: cols, rows: rows) } private var presentedTagLayers: [FrozenBodyLayer] = [] @@ -1212,16 +1243,22 @@ final class PardesView: NSView { let values = (0...12).map { Int(pardes_tag_layer_value(index, UInt32($0))) } if values[0] == 0 { continue } layers.append(values) - drawTagGrip(ctx, values:values, canonical:canonical, cols:cols, rows:rows) - if let cells = pardes_tag_layer_cells(index) { drawTagLayer(ctx, values: values, cells: cells, hideCursor: hideCursor) } + // The layer's full-row fill must land before the grip: the row + // background now spans the anchor columns, and the grip glyph + // and the anchor fills have to come back on top of it. + if let cells = pardes_tag_layer_cells(index) { + drawTagLayer(ctx, values: values, cells: cells, hideCursor: hideCursor, + canonical: canonical, cols: cols) + } + drawTagGrip(ctx, values: values, canonical: canonical, cols: cols, rows: rows) } drawTagRules(ctx, layers: layers) } /// SDL's chrome overlay is drawn after its compact layers. Keep these - /// one-pixel rules above the full-row tag backgrounds as well. + /// one-point rules above the full-row tag backgrounds as well. private func drawTagRules(_ ctx: CGContext, layers: [[Int]]) { - let pixel = 1 / metricsScale + let pixel = CGFloat(1) for values in layers where values[1] != 0 { let kind = values[1] let bottom = kind == 2 && values[11] != 0 @@ -1252,15 +1289,17 @@ final class PardesView: NSView { } 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 + // The box drawTagLayer filled is this mark's ground, so the mark + // centres on that same square rather than on the grip columns: one + // rect measures both and the two cannot drift apart. + guard let square = anchorSquare(values: values) else { return } + let centerX = square.midX, centerY = square.midY if text != " " { let face = Face(bold: cell.attrs & UInt16(PARDES_ATTR_BOLD) != 0, italic: cell.attrs & UInt16(PARDES_ATTR_ITALIC) != 0) @@ -1268,9 +1307,7 @@ final class PardesView: NSView { 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.clip(to: square) ctx.translateBy(x: 0, y: bounds.height) ctx.scaleBy(x: 1, y: -1) ctx.textMatrix = .identity @@ -1286,16 +1323,36 @@ final class PardesView: NSView { 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) { + private func drawTagLayer(_ ctx: CGContext, values: [Int], cells: UnsafePointer, + hideCursor: Bool, canonical: UnsafePointer, cols: Int) { let left = CGFloat(values[4]) * cellWidth let y = CGFloat(values[5]) * cellHeight + taglines.top(row: values[5], canvasHeight: bounds.height) // SDL gives the tag a full body-row background; only its glyphs - // and per-cell highlights use the compact band. - let viewport = CGRect(x: left, y: CGFloat(values[5]) * cellHeight, - width: CGFloat(values[6]) * cellWidth, height: cellHeight) + // and per-cell highlights use the compact band. The band spans the + // row's whole extent, not just its text: the grip and the mode box + // live in the inset columns to the left of `values[4]`, and their + // own fills cover only the compact band — a row background that + // stops at the text leaves holes of ground under those anchors. + // The rule in drawTagRules spans exactly this left-to-right range. + let rowLeft = CGFloat(max(0, values[4] - Int(pardes_tag_text_inset()))) * cellWidth + let rowRight = CGFloat(values[4] + values[6]) * cellWidth + let viewport = CGRect(x: rowLeft, y: CGFloat(values[5]) * cellHeight, + width: rowRight - rowLeft, height: cellHeight) ctx.saveGState() ctx.clip(to: viewport) fillBackground(ctx, viewport, UInt32(values[10]), backgroundAlpha) + // The anchor (grip, mode box) lives in the inset columns that fill + // just covered, so its own box goes back on top: one solid fill in + // the box colour the anchor cell wears, with drawTagGrip's mark + // drawn over it afterwards. + if let square = anchorSquare(values: values) { + let anchorX = max(0, values[4] - Int(pardes_tag_text_inset())) + let cell = canonical[values[5] * cols + anchorX] + let style = resolve(cell, block: false, + ground: UInt32(values[10]), clearGround: false) + if style.visible { fillBackground(ctx, square.integral, style.bg, backgroundAlpha) } + } + for pass in 0...1 { for col in 0.. CGRect? { + let anchorX = max(0, values[4] - Int(pardes_tag_text_inset())) + guard anchorX < values[4] else { return nil } + let pad = (cellHeight - taglines.height) / 2 + return CGRect(x: CGFloat(anchorX) * cellWidth + pad, + y: CGFloat(values[5]) * cellHeight + taglines.top(row: values[5], canvasHeight: bounds.height), + width: taglines.height, height: taglines.height) + } + + /// The look-hover affordance as liquid glass: the core marks the word's + /// cells (PARDES_CELL_HOVER), and this composes a real material over the + /// run — the drawn frame snapshot refracted through CoreImage (a small + /// blur, then a lens scale about the pill's centre, which is what makes + /// the edges swim), the theme's selection colour as a faint tint, a + /// specular sheen from the top edge, and a light rim — with the word's + /// own glyphs drawn back on top, crisp, the way text sits on a real + /// material rather than behind one. + private func drawHoverGlass(_ ctx: CGContext, cells: UnsafePointer, cols: Int, rows: Int) { + var drew = false + for row in 0..= 0 { + let rect = CGRect(x: CGFloat(start) * cellWidth, y: CGFloat(row) * cellHeight, + width: CGFloat(col - start) * cellWidth, height: cellHeight) + drawLiquidGlass(ctx, rect, tint: tint) + redrawHoverRun(ctx, cells, cols: cols, row: row, start: start, end: col) + drew = true + start = -1 + } + } + } + // No affordance this frame: the held snapshot describes a frame + // that no longer exists, and the next hover must take a fresh one. + if !drew { glassShot = nil } + } + + /// A CoreImage context for the glass refraction, created once. + private lazy var glassContext = CIContext() + /// The snapshot behind the current pill. Refraction reads the already + /// drawn frame, and re-reading it every pump would blur a moving pill + /// out of its own budget, so the shot is taken when the rect changes. + private var glassShot: (rect: CGRect, image: CGImage)? + + private func drawLiquidGlass(_ ctx: CGContext, _ rect: CGRect, tint: UInt32) { + let radius = min(rect.height / 4, 6) + let pill = CGPath(roundedRect: rect.insetBy(dx: 1, dy: 1.5), + cornerWidth: radius, cornerHeight: radius, transform: nil) + // The refraction: the frame as drawn, blurred a little and scaled + // about the pill's centre, so the rim bends what surrounds it. The + // shot extends past the pill for exactly this — the lens needs + // pixels from outside the glass to pull in. + let lens = rect.insetBy(dx: -10, dy: -10) + if refract(ctx, pill: pill, rect: rect, lens: lens, tint: tint) { + // Specular: light caught at the top edge, fading as it falls. + ctx.saveGState() + ctx.addPath(pill) + ctx.clip() + let shine = [CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 0.22), + CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 0.0)] as CFArray + if let gradient = CGGradient(colorsSpace: CGColorSpace(name: CGColorSpace.sRGB), + colors: shine, locations: [0, 0.55]) { + ctx.drawLinearGradient(gradient, + start: CGPoint(x: rect.minX, y: rect.minY), + end: CGPoint(x: rect.minX, y: rect.maxY), options: []) + } + ctx.restoreGState() + } else { + // No refraction without a snapshot: the tint alone, faint. + ctx.saveGState() + ctx.addPath(pill) + ctx.clip() + ctx.setFillColor(CGColor(srgbRed: CGFloat((tint >> 16) & 0xFF) / 255, + green: CGFloat((tint >> 8) & 0xFF) / 255, + blue: CGFloat(tint & 0xFF) / 255, + alpha: 0.18)) + ctx.fill(rect) + ctx.restoreGState() + } + // The lens edge: a light rim and a darker bottom hairline, the way a + // real material catches its own edge. + ctx.addPath(pill) + ctx.setStrokeColor(CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 0.35)) + ctx.setLineWidth(1) + ctx.strokePath() + ctx.addPath(pill) + ctx.setStrokeColor(CGColor(srgbRed: 0, green: 0, blue: 0, alpha: 0.25)) + ctx.setLineWidth(0.5) + ctx.clip() + ctx.stroke(CGRect(x: rect.minX, y: rect.maxY - 2, width: rect.width, height: 1.5)) + } + + /// True when the refraction composited. The snapshot is taken when the + /// pill moves and reused while it sits still. + private func refract(_ ctx: CGContext, pill: CGPath, rect: CGRect, lens: CGRect, tint: UInt32) -> Bool { + guard let shot = snapshotFor(ctx, lens: lens) else { return false } + var image = CIImage(cgImage: shot) + // The blur is grown slightly past the crop and the whole thing is + // pulled toward the centre by a few percent: the rim bends what + // surrounds it, which is the refraction the eye reads as glass. + let blur = image.applyingGaussianBlur(sigma: 2 * metricsScale) + let grown = blur.extent.insetBy(dx: -8 * metricsScale, dy: -8 * metricsScale) + let centre = CGPoint(x: grown.midX, y: grown.midY) + let scale = 1.06 + let lensTransform = CGAffineTransform(translationX: centre.x * (1 - scale), + y: centre.y * (1 - scale)).scaledBy(x: scale, y: scale) + image = blur.transformed(by: lensTransform) + guard let filtered = glassContext.createCGImage(image, from: grown) else { return false } + ctx.saveGState() + ctx.addPath(pill) + ctx.clip() + ctx.draw(filtered, in: rect) + // The glass's own body colour: the theme's selection, faint — the + // refraction and the sheen carry the material, not opacity. + ctx.setFillColor(CGColor(srgbRed: CGFloat((tint >> 16) & 0xFF) / 255, + green: CGFloat((tint >> 8) & 0xFF) / 255, + blue: CGFloat(tint & 0xFF) / 255, + alpha: 0.15)) + ctx.fill(rect) + ctx.restoreGState() + return true + } + + private func snapshotFor(_ ctx: CGContext, lens: CGRect) -> CGImage? { + if let held = glassShot, held.rect == lens { return held.image } + guard let whole = ctx.makeImage() else { return nil } + // makeImage hands back backing pixels; the lens is in view points. + let region = CGRect(x: lens.minX * metricsScale, y: lens.minY * metricsScale, + width: lens.width * metricsScale, height: lens.height * metricsScale) + guard let cropped = whole.cropping(to: region) else { return nil } + glassShot = (lens, cropped) + return cropped + } + + /// The hovered word's glyphs, back on top of the material, crisp: text + /// sits on liquid glass, not behind it. + private func redrawHoverRun(_ ctx: CGContext, _ cells: UnsafePointer, + cols: Int, row: Int, start: Int, end: Int) { + ctx.saveGState() + ctx.textMatrix = .identity + ctx.translateBy(x: 0, y: bounds.height) + ctx.scaleBy(x: 1, y: -1) + let baseline = bounds.height - (CGFloat(row) * cellHeight + metrics.ascent) + for col in start.. = [ + "<", ">", "=", "!", "&", "|", "-", "/", ":", ".", "#", "*", "+", "~", "^", "%", + ] + + private func ligates(_ face: Face, _ text: String) -> Bool { + let key = "\(face.rawValue):\(text)" + if let cached = ligatureShaped[key] { return cached } + let attributed = NSAttributedString(string: text, attributes: [fontAttribute: metrics.fonts[face.rawValue], + NSAttributedString.Key(kCTForegroundColorFromContextAttributeName as String): true]) + let shaped = CTLineCreateWithAttributedString(attributed as CFAttributedString) + var glyphs: [CGGlyph] = [] + for run in (CTLineGetGlyphRuns(shaped) as? [CTRun]) ?? [] { + let count = CTRunGetGlyphCount(run) + var runGlyphs = [CGGlyph](repeating: 0, count: count) + CTRunGetGlyphs(run, CFRange(location: 0, length: count), &runGlyphs) + glyphs.append(contentsOf: runGlyphs) + } + // A ligature is any divergence from per-character mapping. A font may + // collapse the pair into one glyph (JetBrains Mono) or swap each + // character for a joining half kept at the same count (Maple Mono) — + // either way the sequence must be shaped, not mapped. + let singles = text.utf16.map { glyph(face, $0, tagline: false) } + let result = glyphs.count != singles.count + || zip(glyphs, singles).contains { $0 != $1 } + ligatureShaped[key] = result + return result + } + + /// When the cells at `col` ligate under `face`, the text to shape and how + /// many cells the ligature covers. Nil when they do not. Ligature cells + /// must carry the same face, colour and alpha and no rules of their own — + /// a ligature is one glyph of one style, not a style transition. + private func ligatureSpan(_ cells: UnsafePointer, base: Int, cols: Int, col: Int, + face: Face, fg: UInt32, alpha: CGFloat, + blockCol: Int, paneGripCells: Set) -> (text: String, covered: Int)? { + func eligible(_ index: Int) -> String? { + let cell = cells[base + index] + if cell.flags & UInt8(PARDES_CELL_DEFAULT) != 0 { return nil } + if cell.flags & UInt8(PARDES_CELL_TAGLINE) != 0 { return nil } + if cell.attrs >> UInt16(PARDES_ATTR_UL_SHIFT) != 0 + || cell.attrs & UInt16(PARDES_ATTR_STRIKETHROUGH) != 0 { return nil } + if paneGripCells.contains(base + index) { return nil } + let text = withUnsafeBytes(of: cell.text) { raw in + String(decoding: raw.prefix(Int(cell.len)), as: UTF8.self) + } + guard text.utf16.count == 1 else { return nil } + let other = resolve(cell, block: blockCol == index, + ground: themeBG ?? pardesDefaultBG, clearGround: false) + guard other.visible, other.fg == fg, other.alpha == alpha, + Face(bold: cell.attrs & UInt16(PARDES_ATTR_BOLD) != 0, + italic: cell.attrs & UInt16(PARDES_ATTR_ITALIC) != 0) == face else { return nil } + return text + } + guard let head = eligible(col), PardesView.ligatureHeads.contains(head.first!) else { return nil } + guard col + 1 < cols, let second = eligible(col + 1) else { return nil } + let pair = head + second + if col + 2 < cols, let third = eligible(col + 2) { + let triple = pair + third + if ligates(face, triple) { return (triple, 3) } + } + if ligates(face, pair) { return (pair, 2) } + return nil + } + private func drawRules( _ ctx: CGContext, _ cell: pardes_cell_s, @@ -1921,6 +2243,13 @@ final class PardesView: NSView { override func keyDown(with event: NSEvent) { let flags = event.modifierFlags + // Same reason as the trackpad trace below: whether AppKit delivered a + // keystroke at all is not something this process can otherwise answer, + // and "Escape does nothing" has two entirely different causes + // depending on whether this line prints. + trace("key: code=\(event.keyCode) mods=\(flags.rawValue) chars=" + + (event.charactersIgnoringModifiers?.unicodeScalars + .map { String($0.value, radix: 16) }.joined(separator: ",") ?? "-")) // 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. @@ -2328,12 +2657,35 @@ final class PardesView: NSView { userInfo: nil)) } - private var linkPointer = false + private enum Pointer: Equatable { case text, link, target } + private var pointer: Pointer = .text + + /// The core's pointer_shape, in this shell's cursor vocabulary: a link + /// is a PDF hyperlink (the hand), a target is a word the core's own + /// Look-hover plumbing validated as a real Look or Exec operand, and + /// anything else is text — the I-beam. + private func pointerShape() -> Pointer { + switch pardes_pointer_shape() { + case 1: return .link + case 2: return .target + default: return .text + } + } + + private func cursorFor(_ shape: Pointer) -> NSCursor { + switch shape { + case .link: return .pointingHand + case .target: return .arrow + case .text: return .iBeam + } + } 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) + // Every cell in this view is text, including the tags. An arrow over + // plain text is the wrong affordance — but a word the core has + // validated as a Look or Exec target earns it: middle- and + // right-clicking there acts, and the cursor says so before it does. + addCursorRect(bounds, cursor: cursorFor(pointer)) } override func setFrameSize(_ newSize: NSSize) { -- cgit v1.3