Make panel flash reliable

This commit is contained in:
Lawrence Chen 2026-01-23 01:50:24 -08:00
parent 4c326d6abd
commit 6b8475cc59
5 changed files with 176 additions and 32 deletions

View file

@ -124,7 +124,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
switch response.actionIdentifier {
case UNNotificationDefaultActionIdentifier, TerminalNotificationStore.actionShowIdentifier:
DispatchQueue.main.async {
self.tabManager?.focusTab(tabId, surfaceId: surfaceId)
self.tabManager?.focusTabFromNotification(tabId, surfaceId: surfaceId)
self.markReadIfFocused(response: response, tabId: tabId, surfaceId: surfaceId)
}
case UNNotificationDismissActionIdentifier:

View file

@ -76,6 +76,9 @@ class GhosttyApp {
}()
private let backgroundLogURL = URL(fileURLWithPath: "/tmp/ghosttytabs-bg.log")
private var appObservers: [NSObjectProtocol] = []
private var displayLink: CVDisplayLink?
private var displayLinkUsers = 0
private let displayLinkLock = NSLock()
private init() {
initializeGhostty()
@ -214,6 +217,49 @@ class GhosttyApp {
AppDelegate.shared?.tabManager?.tickRender()
}
func retainDisplayLink() {
displayLinkLock.lock()
defer { displayLinkLock.unlock() }
displayLinkUsers += 1
if displayLinkUsers == 1 {
startDisplayLink()
}
}
func releaseDisplayLink() {
displayLinkLock.lock()
defer { displayLinkLock.unlock() }
displayLinkUsers = max(0, displayLinkUsers - 1)
if displayLinkUsers == 0 {
stopDisplayLink()
}
}
private func startDisplayLink() {
if displayLink == nil {
var link: CVDisplayLink?
CVDisplayLinkCreateWithActiveCGDisplays(&link)
guard let newLink = link else { return }
displayLink = newLink
let callback: CVDisplayLinkOutputCallback = { _, _, _, _, _, _ -> CVReturn in
DispatchQueue.main.async {
GhosttyApp.shared.tick()
}
return kCVReturnSuccess
}
CVDisplayLinkSetOutputCallback(newLink, callback, nil)
}
if let displayLink, !CVDisplayLinkIsRunning(displayLink) {
CVDisplayLinkStart(displayLink)
}
}
private func stopDisplayLink() {
if let displayLink, CVDisplayLinkIsRunning(displayLink) {
CVDisplayLinkStop(displayLink)
}
}
private func updateDefaultBackground(from config: ghostty_config_t?) {
guard let config else { return }
@ -497,7 +543,6 @@ class GhosttyApp {
class TerminalSurface: Identifiable {
private(set) var surface: ghostty_surface_t?
private var displayLink: CVDisplayLink?
private weak var attachedView: GhosttyNSView?
let id: UUID
let tabId: UUID
@ -505,6 +550,7 @@ class TerminalSurface: Identifiable {
private let configTemplate: ghostty_surface_config_s?
let hostedView: GhosttySurfaceScrollView
private let surfaceView: GhosttyNSView
private var ownsDisplayLink = false
init(tabId: UUID, context: ghostty_surface_context_e, configTemplate: ghostty_surface_config_s?) {
self.id = UUID()
@ -568,8 +614,10 @@ class TerminalSurface: Identifiable {
UInt32(view.bounds.height * scale)
)
ghostty_surface_refresh(surface)
setupDisplayLink()
if !ownsDisplayLink {
GhosttyApp.shared.retainDisplayLink()
ownsDisplayLink = true
}
}
private func updateMetalLayer(for view: GhosttyNSView) {
@ -585,26 +633,6 @@ class TerminalSurface: Identifiable {
}
}
private func setupDisplayLink() {
guard displayLink == nil else { return }
var link: CVDisplayLink?
CVDisplayLinkCreateWithActiveCGDisplays(&link)
guard let newLink = link else { return }
displayLink = newLink
let callback: CVDisplayLinkOutputCallback = { _, _, _, _, _, _ -> CVReturn in
DispatchQueue.main.async {
GhosttyApp.shared.tick()
}
return kCVReturnSuccess
}
CVDisplayLinkSetOutputCallback(newLink, callback, nil)
CVDisplayLinkStart(newLink)
}
func updateSize(width: CGFloat, height: CGFloat, scale: CGFloat) {
guard let surface = surface else { return }
ghostty_surface_set_content_scale(surface, scale, scale)
@ -633,8 +661,8 @@ class TerminalSurface: Identifiable {
}
deinit {
if let displayLink = displayLink {
CVDisplayLinkStop(displayLink)
if ownsDisplayLink {
GhosttyApp.shared.releaseDisplayLink()
}
if let surface = surface {
ghostty_surface_free(surface)
@ -652,6 +680,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
var desiredFocus: Bool = false
var tabId: UUID?
var onFocus: (() -> Void)?
var onTriggerFlash: (() -> Void)?
var backgroundColor: NSColor?
private var eventMonitor: Any?
private var trackingArea: NSTrackingArea?
@ -1092,6 +1121,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, modsFromEvent(event))
let menu = NSMenu()
if onTriggerFlash != nil {
let flashItem = menu.addItem(withTitle: "Trigger Flash", action: #selector(triggerFlash(_:)), keyEquivalent: "")
flashItem.target = self
menu.addItem(.separator())
}
if ghostty_surface_has_selection(surface) {
let item = menu.addItem(withTitle: "Copy", action: #selector(copy(_:)), keyEquivalent: "")
item.target = self
@ -1101,6 +1135,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
return menu
}
@objc private func triggerFlash(_ sender: Any?) {
onTriggerFlash?()
}
override func mouseMoved(with event: NSEvent) {
guard let surface = surface else { return }
let point = convert(event.locationInWindow, from: nil)
@ -1219,10 +1257,20 @@ private final class GhosttyScrollView: NSScrollView {
}
}
private final class GhosttyFlashOverlayView: NSView {
override var acceptsFirstResponder: Bool { false }
override func hitTest(_ point: NSPoint) -> NSView? {
nil
}
}
final class GhosttySurfaceScrollView: NSView {
private let scrollView: GhosttyScrollView
private let documentView: NSView
private let surfaceView: GhosttyNSView
private let flashOverlayView: GhosttyFlashOverlayView
private let flashLayer: CAShapeLayer
private var observers: [NSObjectProtocol] = []
private var windowObservers: [NSObjectProtocol] = []
private var isLiveScrolling = false
@ -1233,6 +1281,8 @@ final class GhosttySurfaceScrollView: NSView {
init(surfaceView: GhosttyNSView) {
self.surfaceView = surfaceView
scrollView = GhosttyScrollView()
flashOverlayView = GhosttyFlashOverlayView(frame: .zero)
flashLayer = CAShapeLayer()
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = false
@ -1254,6 +1304,22 @@ final class GhosttySurfaceScrollView: NSView {
super.init(frame: .zero)
addSubview(scrollView)
flashOverlayView.wantsLayer = true
flashOverlayView.layer?.backgroundColor = NSColor.clear.cgColor
flashOverlayView.layer?.masksToBounds = false
flashOverlayView.autoresizingMask = [.width, .height]
flashLayer.fillColor = NSColor.clear.cgColor
flashLayer.strokeColor = NSColor.systemBlue.cgColor
flashLayer.lineWidth = 3
flashLayer.lineJoin = .round
flashLayer.lineCap = .round
flashLayer.shadowColor = NSColor.systemBlue.cgColor
flashLayer.shadowOpacity = 0.6
flashLayer.shadowRadius = 6
flashLayer.shadowOffset = .zero
flashLayer.opacity = 0
flashOverlayView.layer?.addSublayer(flashLayer)
addSubview(flashOverlayView)
scrollView.contentView.postsBoundsChangedNotifications = true
observers.append(NotificationCenter.default.addObserver(
@ -1326,6 +1392,8 @@ final class GhosttySurfaceScrollView: NSView {
scrollView.frame = bounds
surfaceView.frame.size = scrollView.bounds.size
documentView.frame.size.width = scrollView.bounds.width
flashOverlayView.frame = bounds
updateFlashPath()
synchronizeScrollView()
synchronizeSurfaceView()
}
@ -1362,6 +1430,30 @@ final class GhosttySurfaceScrollView: NSView {
surfaceView.onFocus = handler
}
func setTriggerFlashHandler(_ handler: (() -> Void)?) {
surfaceView.onTriggerFlash = handler
}
func triggerFlash() {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.updateFlashPath()
self.flashLayer.removeAllAnimations()
self.flashLayer.opacity = 0
let animation = CAKeyframeAnimation(keyPath: "opacity")
animation.values = [0, 1, 0, 1, 0]
animation.keyTimes = [0, 0.25, 0.5, 0.75, 1]
animation.duration = 0.9
animation.timingFunctions = [
CAMediaTimingFunction(name: .easeOut),
CAMediaTimingFunction(name: .easeIn),
CAMediaTimingFunction(name: .easeOut),
CAMediaTimingFunction(name: .easeIn)
]
self.flashLayer.add(animation, forKey: "ghosttytabs.flash")
}
}
func setActive(_ active: Bool) {
isActive = active
updateFocusForWindow()
@ -1465,6 +1557,19 @@ final class GhosttySurfaceScrollView: NSView {
surfaceView.frame.origin = visibleRect.origin
}
private func updateFlashPath() {
let inset: CGFloat = 2
let radius: CGFloat = 6
let bounds = flashOverlayView.bounds
flashLayer.frame = bounds
guard bounds.width > inset * 2, bounds.height > inset * 2 else {
flashLayer.path = nil
return
}
let rect = bounds.insetBy(dx: inset, dy: inset)
flashLayer.path = CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil)
}
private func synchronizeScrollView() {
documentView.frame.size.height = documentHeight()
@ -1612,12 +1717,14 @@ struct GhosttyTerminalView: NSViewRepresentable {
let terminalSurface: TerminalSurface
var isActive: Bool = true
var onFocus: ((UUID) -> Void)? = nil
var onTriggerFlash: (() -> Void)? = nil
func makeNSView(context: Context) -> GhosttySurfaceScrollView {
let view = terminalSurface.hostedView
view.attachSurface(terminalSurface)
view.setActive(isActive)
view.setFocusHandler { onFocus?(terminalSurface.id) }
view.setTriggerFlashHandler(onTriggerFlash)
return view
}
@ -1625,5 +1732,6 @@ struct GhosttyTerminalView: NSViewRepresentable {
nsView.attachSurface(terminalSurface)
nsView.setActive(isActive)
nsView.setFocusHandler { onFocus?(terminalSurface.id) }
nsView.setTriggerFlashHandler(onTriggerFlash)
}
}

View file

@ -20,7 +20,7 @@ struct NotificationsPage: View {
notification: notification,
tabTitle: tabTitle(for: notification.tabId),
onOpen: {
tabManager.focusTab(notification.tabId, surfaceId: notification.surfaceId)
tabManager.focusTabFromNotification(notification.tabId, surfaceId: notification.surfaceId)
markReadIfFocused(notification)
selection = .tabs
},

View file

@ -21,6 +21,7 @@ struct TerminalSplitTreeView: View {
focusedSurfaceId: tab.focusedSurfaceId,
appearance: appearance,
onFocus: { tab.focusSurface($0) },
onTriggerFlash: { tab.triggerDebugFlash(surfaceId: $0) },
onResize: { tab.updateSplitRatio(node: $0, ratio: $1) },
onEqualize: { tab.equalizeSplits() }
)
@ -44,6 +45,7 @@ fileprivate struct TerminalSplitSubtreeView: View {
let focusedSurfaceId: UUID?
let appearance: SplitAppearance
let onFocus: (UUID) -> Void
let onTriggerFlash: (UUID) -> Void
let onResize: (SplitTree<TerminalSurface>.Node, Double) -> Void
let onEqualize: () -> Void
@ -55,7 +57,8 @@ fileprivate struct TerminalSplitSubtreeView: View {
GhosttyTerminalView(
terminalSurface: surface,
isActive: isFocused,
onFocus: { _ in onFocus(surface.id) }
onFocus: { _ in onFocus(surface.id) },
onTriggerFlash: { onTriggerFlash(surface.id) }
)
.background(Color.clear)
@ -90,6 +93,7 @@ fileprivate struct TerminalSplitSubtreeView: View {
focusedSurfaceId: focusedSurfaceId,
appearance: appearance,
onFocus: onFocus,
onTriggerFlash: onTriggerFlash,
onResize: onResize,
onEqualize: onEqualize
)
@ -103,6 +107,7 @@ fileprivate struct TerminalSplitSubtreeView: View {
focusedSurfaceId: focusedSurfaceId,
appearance: appearance,
onFocus: onFocus,
onTriggerFlash: onTriggerFlash,
onResize: onResize,
onEqualize: onEqualize
)

View file

@ -40,6 +40,23 @@ class Tab: Identifiable, ObservableObject {
}
}
func triggerNotificationFocusFlash(surfaceId: UUID) {
triggerPanelFlash(surfaceId: surfaceId, requiresSplit: true)
}
func triggerDebugFlash(surfaceId: UUID) {
triggerPanelFlash(surfaceId: surfaceId, requiresSplit: false)
}
private func triggerPanelFlash(surfaceId: UUID, requiresSplit: Bool) {
guard let surface = surface(for: surfaceId) else { return }
focusSurface(surfaceId)
if requiresSplit && !splitTree.isSplit {
return
}
surface.hostedView.triggerFlash()
}
func updateSplitViewSize(_ size: CGSize) {
guard splitViewSize != size else { return }
splitViewSize = size
@ -205,10 +222,10 @@ class TabManager: ObservableObject {
}
func tickRender() {
for tab in tabs {
for surface in tab.splitTree.map({ $0 }) {
surface.renderIfVisible()
}
guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }) else { return }
for surface in tab.splitTree.map({ $0 }) {
surface.renderIfVisible()
}
}
@ -294,6 +311,20 @@ class TabManager: ObservableObject {
}
}
func focusTabFromNotification(_ tabId: UUID, surfaceId: UUID? = nil) {
focusTab(tabId, surfaceId: surfaceId)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
guard let self,
let tab = self.tabs.first(where: { $0.id == tabId }),
tab.splitTree.isSplit else { return }
let targetSurfaceId = surfaceId ?? tab.focusedSurfaceId
guard let targetSurfaceId,
tab.surface(for: targetSurfaceId) != nil else { return }
tab.triggerNotificationFocusFlash(surfaceId: targetSurfaceId)
}
}
func focusSurface(tabId: UUID, surfaceId: UUID) {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
tab.focusSurface(surfaceId)