Fix tmux notification attention routing

This commit is contained in:
Lawrence Chen 2026-03-20 20:20:54 -07:00
parent d4811650d7
commit 656786fb71
No known key found for this signature in database
11 changed files with 1151 additions and 64 deletions

View file

@ -929,7 +929,9 @@ final class FileDropOverlayView: NSView {
var fileDropOverlayKey: UInt8 = 0
private var commandPaletteWindowOverlayKey: UInt8 = 0
private var tmuxWorkspacePaneWindowOverlayKey: UInt8 = 0
let commandPaletteOverlayContainerIdentifier = NSUserInterfaceItemIdentifier("cmux.commandPalette.overlay.container")
let tmuxWorkspacePaneOverlayContainerIdentifier = NSUserInterfaceItemIdentifier("cmux.tmuxWorkspacePane.overlay.container")
enum CommandPaletteOverlayPromotionPolicy {
static func shouldPromote(previouslyVisible: Bool, isVisible: Bool) -> Bool {
@ -950,6 +952,15 @@ private final class CommandPaletteOverlayContainerView: NSView {
}
}
@MainActor
private final class PassthroughWindowOverlayContainerView: NSView {
override var isOpaque: Bool { false }
override func hitTest(_ point: NSPoint) -> NSView? {
nil
}
}
@MainActor
private final class WindowCommandPaletteOverlayController: NSObject {
private weak var window: NSWindow?
@ -1266,6 +1277,103 @@ private func commandPaletteWindowOverlayController(for window: NSWindow) -> Wind
return controller
}
@MainActor
private final class WindowTmuxWorkspacePaneOverlayController: NSObject {
private weak var window: NSWindow?
private let containerView = PassthroughWindowOverlayContainerView(frame: .zero)
private let model = TmuxWorkspacePaneOverlayModel()
private let hostingView: NSHostingView<TmuxWorkspacePaneOverlayView>
private var installConstraints: [NSLayoutConstraint] = []
init(window: NSWindow) {
self.window = window
self.hostingView = NSHostingView(
rootView: TmuxWorkspacePaneOverlayView(
unreadRects: [],
flashRect: nil,
flashStartedAt: nil,
flashReason: nil
)
)
super.init()
containerView.translatesAutoresizingMaskIntoConstraints = false
containerView.wantsLayer = true
containerView.layer?.backgroundColor = NSColor.clear.cgColor
containerView.isHidden = true
containerView.alphaValue = 0
containerView.identifier = tmuxWorkspacePaneOverlayContainerIdentifier
hostingView.translatesAutoresizingMaskIntoConstraints = false
hostingView.wantsLayer = true
hostingView.layer?.backgroundColor = NSColor.clear.cgColor
containerView.addSubview(hostingView)
NSLayoutConstraint.activate([
hostingView.topAnchor.constraint(equalTo: containerView.topAnchor),
hostingView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
hostingView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
hostingView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
])
_ = ensureInstalled()
}
@discardableResult
private func ensureInstalled() -> Bool {
guard let window,
let contentView = window.contentView,
let themeFrame = contentView.superview else { return false }
if containerView.superview !== themeFrame {
NSLayoutConstraint.deactivate(installConstraints)
installConstraints.removeAll()
containerView.removeFromSuperview()
themeFrame.addSubview(containerView, positioned: .above, relativeTo: contentView)
installConstraints = [
containerView.topAnchor.constraint(equalTo: contentView.topAnchor),
containerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
containerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
containerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
]
NSLayoutConstraint.activate(installConstraints)
}
return true
}
func update(state: TmuxWorkspacePaneOverlayRenderState?) {
guard ensureInstalled() else { return }
if let state {
model.apply(state)
hostingView.rootView = TmuxWorkspacePaneOverlayView(
unreadRects: model.unreadRects,
flashRect: model.flashRect,
flashStartedAt: model.flashStartedAt,
flashReason: model.flashReason
)
containerView.alphaValue = 1
containerView.isHidden = false
} else {
model.clear()
hostingView.rootView = TmuxWorkspacePaneOverlayView(
unreadRects: [],
flashRect: nil,
flashStartedAt: nil,
flashReason: nil
)
containerView.alphaValue = 0
containerView.isHidden = true
}
}
}
@MainActor
private func tmuxWorkspacePaneWindowOverlayController(for window: NSWindow) -> WindowTmuxWorkspacePaneOverlayController {
if let existing = objc_getAssociatedObject(window, &tmuxWorkspacePaneWindowOverlayKey) as? WindowTmuxWorkspacePaneOverlayController {
return existing
}
let controller = WindowTmuxWorkspacePaneOverlayController(window: window)
objc_setAssociatedObject(window, &tmuxWorkspacePaneWindowOverlayKey, controller, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
return controller
}
private func commandPaletteOwningWebView(for responder: NSResponder?) -> WKWebView? {
guard let responder else { return nil }
@ -1622,6 +1730,135 @@ struct ContentView: View {
var lastUsedAt: TimeInterval
}
static func tmuxWorkspacePaneExactRect(
for panel: Panel,
in contentView: NSView
) -> CGRect? {
let targetView: NSView?
switch panel {
case let terminal as TerminalPanel:
targetView = terminal.hostedView
case let browser as BrowserPanel:
targetView = browser.webView
default:
targetView = nil
}
guard let targetView else { return nil }
return tmuxWorkspacePaneExactRect(for: targetView, in: contentView)
}
static func tmuxWorkspacePaneExactRect(
for targetView: NSView,
in contentView: NSView
) -> CGRect? {
guard let contentWindow = contentView.window,
let targetWindow = targetView.window,
contentWindow === targetWindow,
targetView.superview != nil else {
return nil
}
let rectInWindow = targetView.convert(targetView.bounds, to: nil)
let rectInContent = contentView.convert(rectInWindow, from: nil)
guard rectInContent.width > 1, rectInContent.height > 1 else { return nil }
return rectInContent
}
static func preferredTmuxWorkspacePaneWindowOverlayRect(
exactRect: CGRect?,
paneRect: CGRect?
) -> CGRect? {
guard let paneRect else { return exactRect }
guard let exactRect,
exactRect.width > 1,
exactRect.height > 1 else {
return paneRect
}
let tolerance: CGFloat = 0.5
let exactFitsWithinPane =
exactRect.minX >= paneRect.minX - tolerance &&
exactRect.maxX <= paneRect.maxX + tolerance &&
exactRect.minY >= paneRect.minY - tolerance &&
exactRect.maxY <= paneRect.maxY + tolerance
return exactFitsWithinPane ? exactRect : paneRect
}
private func tmuxWorkspacePaneWindowOverlayState(for window: NSWindow) -> TmuxWorkspacePaneOverlayRenderState? {
guard TmuxOverlayExperimentSettings.target().usesWorkspacePaneOverlay,
let workspace = tabManager.selectedWorkspace else { return nil }
let layoutSnapshot = WorkspaceContentView.effectiveTmuxLayoutSnapshot(
cachedSnapshot: workspace.tmuxLayoutSnapshot,
liveSnapshot: workspace.bonsplitController.layoutSnapshot()
)
let contentView = window.contentView
let unreadRects: [CGRect]
if let layoutSnapshot, let contentView {
unreadRects = layoutSnapshot.panes.compactMap { pane in
guard let selectedTabId = pane.selectedTabId,
let tabUUID = UUID(uuidString: selectedTabId),
let panelId = workspace.panelIdFromSurfaceId(TabID(uuid: tabUUID)),
let panel = workspace.panels[panelId] else {
return nil
}
let shouldShowUnread = Workspace.shouldShowUnreadIndicator(
hasUnreadNotification: notificationStore.hasVisibleNotificationIndicator(
forTabId: workspace.id,
surfaceId: panelId
),
isManuallyUnread: workspace.manualUnreadPanelIds.contains(panelId)
)
guard shouldShowUnread else { return nil }
let paneRect = WorkspaceContentView.tmuxWorkspacePaneWindowOverlayRect(
layoutSnapshot: layoutSnapshot,
paneId: workspace.paneId(forPanelId: panelId)
)
let exactRect = Self.tmuxWorkspacePaneExactRect(for: panel, in: contentView)
return Self.preferredTmuxWorkspacePaneWindowOverlayRect(
exactRect: exactRect,
paneRect: paneRect
)
}
} else {
unreadRects = WorkspaceContentView.tmuxWorkspacePaneWindowUnreadRects(
workspace: workspace,
notificationStore: notificationStore,
layoutSnapshot: layoutSnapshot
)
}
let flashRect: CGRect?
if let panelId = workspace.tmuxWorkspaceFlashPanelId,
let panel = workspace.panels[panelId],
let contentView {
let paneRect = WorkspaceContentView.tmuxWorkspacePaneWindowOverlayRect(
layoutSnapshot: layoutSnapshot,
paneId: workspace.paneId(forPanelId: panelId)
)
let exactRect = Self.tmuxWorkspacePaneExactRect(for: panel, in: contentView)
flashRect = Self.preferredTmuxWorkspacePaneWindowOverlayRect(
exactRect: exactRect,
paneRect: paneRect
)
} else {
flashRect = WorkspaceContentView.tmuxWorkspacePaneWindowOverlayRect(
layoutSnapshot: layoutSnapshot,
paneId: workspace.tmuxWorkspaceFlashPanelId.flatMap { workspace.paneId(forPanelId: $0) }
)
}
return TmuxWorkspacePaneOverlayRenderState(
workspaceId: workspace.id,
unreadRects: unreadRects,
flashRect: flashRect,
flashToken: workspace.tmuxWorkspaceFlashToken,
flashReason: workspace.tmuxWorkspaceFlashReason
)
}
private struct CommandPaletteContextSnapshot {
private var boolValues: [String: Bool] = [:]
private var stringValues: [String: String] = [:]
@ -2762,6 +2999,8 @@ struct ContentView: View {
view = AnyView(view.background(WindowAccessor(dedupeByWindow: false) { window in
MainActor.assumeIsolated {
let tmuxOverlayController = tmuxWorkspacePaneWindowOverlayController(for: window)
tmuxOverlayController.update(state: tmuxWorkspacePaneWindowOverlayState(for: window))
let overlayController = commandPaletteWindowOverlayController(for: window)
overlayController.update(rootView: AnyView(commandPaletteOverlay), isVisible: isCommandPalettePresented)
}