From c3c2cb71a8af44ceafda8ff8e8ccedeb99ffef0e Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 7 Oct 2026 17:37:27 -0700 Subject: [PATCH] Slab: give handles and rocks stable native hover cursors --- .../SlabMenubar/AeselHandleOverlay.swift | 37 ++++++++++++++++--- .../SlabMenubar/PromptSigilOverlay.swift | 9 +++++ .../Sources/SlabMenubar/TerminalCursor.swift | 23 +++++++++--- 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/slab/menubar-swift/Sources/SlabMenubar/AeselHandleOverlay.swift b/slab/menubar-swift/Sources/SlabMenubar/AeselHandleOverlay.swift index e24858de5c..d1dbc8084b 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/AeselHandleOverlay.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AeselHandleOverlay.swift @@ -48,6 +48,8 @@ final class AeselHandleOverlays { private final class Surface { let panel: NSPanel + let interactionPanel: NSPanel + let interactionView: PromptRockInteractionView let root = CALayer() let view = HandleView() let slot: Slot @@ -66,8 +68,8 @@ final class AeselHandleOverlays { panel = NSPanel(contentRect: initial, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false) panel.isReleasedWhenClosed = false panel.isOpaque = false; panel.backgroundColor = .clear; panel.hasShadow = false - // Terminal owns mouse reporting and Slab's existing cursor bridge. - // A transparent native label must not intercept the footer click. + // The moving lettering is separate from a stable, cell-sized hit + // area. AppKit owns the pointer here instead of racing Terminal. panel.ignoresMouseEvents = true; panel.hidesOnDeactivate = false panel.level = .floating panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle] @@ -82,6 +84,21 @@ final class AeselHandleOverlays { view.setAccessibilityRole(.link) view.setAccessibilityLabel("Open \(slot.text)") panel.contentView = view + interactionPanel = NSPanel(contentRect: CGRect(origin: initial.origin, size: rect.size), + styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false) + interactionPanel.isReleasedWhenClosed = false + interactionPanel.isOpaque = false; interactionPanel.backgroundColor = .clear + interactionPanel.hasShadow = false; interactionPanel.ignoresMouseEvents = false + interactionPanel.hidesOnDeactivate = false; interactionPanel.level = .floating + interactionPanel.collectionBehavior = panel.collectionBehavior + interactionView = PromptRockInteractionView(frame: CGRect(origin: .zero, size: rect.size)) + interactionView.onClick = view.onClick + interactionView.onHoverChange = { [weak view] hovering in view?.setHovered(hovering) } + interactionView.setAccessibilityElement(true) + interactionView.setAccessibilityRole(.link) + interactionView.setAccessibilityLabel("Open \(slot.text)") + view.setAccessibilityElement(false) + interactionPanel.contentView = interactionView root.frame = CGRect(x: 0, y: 0, width: rect.width + 12, height: rect.height + 12) let natural = min(18, rect.height * 0.8) let size = min(natural, natural * max(1, rect.width - 3) / max(1, AeselRock.width(slot.text, size: natural))) @@ -149,7 +166,10 @@ final class AeselHandleOverlays { } private func remove(_ id: String) { - surfaces.removeValue(forKey: id)?.panel.close() + if let surface = surfaces.removeValue(forKey: id) { + surface.interactionPanel.close() + surface.panel.close() + } try? FileManager.default.removeItem(at: directory.appendingPathComponent(id + ".json.ack")) } @@ -160,6 +180,7 @@ final class AeselHandleOverlays { let id = session.sessionId guard let number = bindings[session.overlayBindingKey], let b = windows[number] else { surfaces[id]?.panel.orderOut(nil) + surfaces[id]?.interactionPanel.orderOut(nil) continue } let window = CGRect(x: b.0, y: b.1, width: b.2, height: b.3) @@ -172,8 +193,11 @@ final class AeselHandleOverlays { if visible { let frame = CGRect(x: rect.minX - 6, y: screenHeight - rect.maxY - 6, width: rect.width + 12, height: rect.height + 12) if surface.panel.frame != frame { surface.panel.setFrame(frame, display: false) } + let hit = frame.insetBy(dx: 6, dy: 6) + if surface.interactionPanel.frame != hit { surface.interactionPanel.setFrame(hit, display: false) } surface.root.speed = eco || NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : surface.view.hovered ? 2.5 : 1 if !surface.panel.isVisible { surface.panel.orderFrontRegardless() } + if !surface.interactionPanel.isVisible { surface.interactionPanel.orderFrontRegardless() } if now.timeIntervalSince(surface.lastAck) >= 1 { let ack: [String: Any] = ["schema": 1, "sessionId": id, "token": surface.slot.token, "at": now.timeIntervalSince1970 * 1000] @@ -182,7 +206,11 @@ final class AeselHandleOverlays { surface.lastAck = now } } - } else { surface.panel.orderOut(nil) } + } else { + surface.interactionPanel.orderOut(nil) + surface.panel.orderOut(nil) + surface.view.setHovered(false) + } } guard !pending.contains(id), now >= (nextRead[id] ?? .distantPast) else { continue } pending.insert(id); nextRead[id] = now.addingTimeInterval(0.5) @@ -213,7 +241,6 @@ final class AeselHandleOverlays { self.surfaces[id] = Surface(slot: slot, windowID: number, window: window, rect: rect) } } else { self.surfaces[id]?.lastSeen = Date() } - self.surfaces[id]?.view.setHovered(slot.hovered ?? false) } } } diff --git a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift index c69131321c..ba668b32d4 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift @@ -347,6 +347,14 @@ final class PromptRockInteractionView: NSView { var onClick: (() -> Void)? private var tracking: NSTrackingArea? + override func draw(_ dirtyRect: NSRect) { + // WindowServer passes fully transparent pixels through to Terminal, + // even with ignoresMouseEvents=false. Keep the whole hit area present + // between glyphs so AppKit owns both the cursor and the first click. + NSColor.black.withAlphaComponent(0.01).setFill() + bounds.fill() + } + override func updateTrackingAreas() { if let tracking { removeTrackingArea(tracking) } let next = NSTrackingArea( @@ -378,6 +386,7 @@ final class PromptRockInteractionView: NSView { override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } override func mouseDown(with event: NSEvent) { onClick?() } + override func accessibilityPerformPress() -> Bool { onClick?(); return true } } /// One borderless, click-through badge window holding a session's per-prompt diff --git a/slab/menubar-swift/Sources/SlabMenubar/TerminalCursor.swift b/slab/menubar-swift/Sources/SlabMenubar/TerminalCursor.swift index 73968adb69..607ab8e0d7 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/TerminalCursor.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/TerminalCursor.swift @@ -137,9 +137,16 @@ final class TerminalCursor { } private func assertHand(recheck: Bool = true) { + // A native rock, handle or preview owns its own cursor. In particular, + // a queued Terminal retry must not paint an arrow over its hand. + let number = windowUnderPointer() + if NSApp.windows.contains(where: { $0.windowNumber == number && !$0.ignoresMouseEvents }) { + showing = false; over = nil; checked = .distantPast + return + } if recheck, Date().timeIntervalSince(checked) > 0.02 { checked = Date() - over = claimUnderPointer() + over = claimUnderPointer(window: number) } if let id = over, let hand = hands[id] { if !background { @@ -159,19 +166,23 @@ final class TerminalCursor { /// The hand whose Terminal window is the topmost window under the pointer, /// with Terminal frontmost (a background terminal reports no motion, so its /// hover could be stale). - private func claimUnderPointer() -> Int? { + private func claimUnderPointer(window number: Int) -> Int? { guard !hands.isEmpty, Self.terminals.contains(NSWorkspace.shared.frontmostApplication?.bundleIdentifier ?? "") else { return nil } + guard number > 0 else { return nil } + return hands.first { PromptSigilOverlayController.shared.terminalWindowID(tty: $0.value.tty) == number }?.key + } + + private func windowUnderPointer() -> Int { let point = NSEvent.mouseLocation - let ours = Set(NSApp.windows.map(\.windowNumber)) + let transparent = Set(NSApp.windows.filter(\.ignoresMouseEvents).map(\.windowNumber)) var below = 0, number = 0 for _ in 0..<8 { number = NSWindow.windowNumber(at: point, belowWindowWithWindowNumber: below) - guard ours.contains(number), number > 0 else { break } + guard transparent.contains(number), number > 0 else { break } below = number } - guard number > 0 else { return nil } - return hands.first { PromptSigilOverlayController.shared.terminalWindowID(tty: $0.value.tty) == number }?.key + return number } } -- 2.51.2