Make panel flash reliable
This commit is contained in:
parent
4c326d6abd
commit
6b8475cc59
5 changed files with 176 additions and 32 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue