Fix tmux notification attention routing
This commit is contained in:
parent
d4811650d7
commit
656786fb71
11 changed files with 1151 additions and 64 deletions
181
CLI/cmux.swift
181
CLI/cmux.swift
|
|
@ -1863,12 +1863,33 @@ struct CMUXCLI {
|
|||
|
||||
case "trigger-flash":
|
||||
let tfWsFlag = optionValue(commandArgs, name: "--workspace")
|
||||
let workspaceArg = tfWsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
|
||||
let surfaceArg = optionValue(commandArgs, name: "--surface") ?? optionValue(commandArgs, name: "--panel") ?? (tfWsFlag == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
|
||||
let explicitWorkspaceArg = tfWsFlag
|
||||
let callerWorkspaceArg = windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil
|
||||
let workspaceArg = explicitWorkspaceArg ?? callerWorkspaceArg
|
||||
let explicitSurfaceArg = optionValue(commandArgs, name: "--surface") ?? optionValue(commandArgs, name: "--panel")
|
||||
let callerSurfaceArg = explicitWorkspaceArg == nil && windowId == nil
|
||||
? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"]
|
||||
: nil
|
||||
let surfaceArg = explicitSurfaceArg ?? callerSurfaceArg
|
||||
var params: [String: Any] = [:]
|
||||
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
|
||||
let wsId = try {
|
||||
if explicitWorkspaceArg != nil {
|
||||
return try normalizeWorkspaceHandle(workspaceArg, client: client)
|
||||
}
|
||||
return try resolveWorkspaceIdAllowingFallback(workspaceArg, client: client)
|
||||
}()
|
||||
if let wsId { params["workspace_id"] = wsId }
|
||||
let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId)
|
||||
let sfId = try {
|
||||
if explicitSurfaceArg != nil {
|
||||
return try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId)
|
||||
}
|
||||
guard let wsId else { return nil }
|
||||
return try resolveSurfaceIdAllowingFallback(
|
||||
surfaceArg,
|
||||
workspaceId: wsId,
|
||||
client: client
|
||||
)
|
||||
}()
|
||||
if let sfId { params["surface_id"] = sfId }
|
||||
let payload = try client.sendV2(method: "surface.trigger_flash", params: params)
|
||||
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat))
|
||||
|
|
@ -2062,12 +2083,31 @@ struct CMUXCLI {
|
|||
let subtitle = optionValue(commandArgs, name: "--subtitle") ?? ""
|
||||
let body = optionValue(commandArgs, name: "--body") ?? ""
|
||||
|
||||
let notifyWsFlag = optionValue(commandArgs, name: "--workspace")
|
||||
let workspaceArg = notifyWsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
|
||||
let surfaceArg = optionValue(commandArgs, name: "--surface") ?? (notifyWsFlag == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
|
||||
let explicitWorkspaceArg = optionValue(commandArgs, name: "--workspace")
|
||||
let callerWorkspaceArg = windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil
|
||||
let workspaceArg = explicitWorkspaceArg ?? callerWorkspaceArg
|
||||
let explicitSurfaceArg = optionValue(commandArgs, name: "--surface")
|
||||
let callerSurfaceArg = explicitWorkspaceArg == nil && windowId == nil
|
||||
? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"]
|
||||
: nil
|
||||
let surfaceArg = explicitSurfaceArg ?? callerSurfaceArg
|
||||
|
||||
let targetWorkspace = try resolveWorkspaceId(workspaceArg, client: client)
|
||||
let targetSurface = try resolveSurfaceId(surfaceArg, workspaceId: targetWorkspace, client: client)
|
||||
let targetWorkspace = try {
|
||||
if explicitWorkspaceArg != nil {
|
||||
return try resolveWorkspaceId(workspaceArg, client: client)
|
||||
}
|
||||
return try resolveWorkspaceIdAllowingFallback(workspaceArg, client: client)
|
||||
}()
|
||||
let targetSurface = try {
|
||||
if explicitSurfaceArg != nil {
|
||||
return try resolveSurfaceId(surfaceArg, workspaceId: targetWorkspace, client: client)
|
||||
}
|
||||
return try resolveSurfaceIdAllowingFallback(
|
||||
surfaceArg,
|
||||
workspaceId: targetWorkspace,
|
||||
client: client
|
||||
)
|
||||
}()
|
||||
|
||||
let payload = "\(title)|\(subtitle)|\(body)"
|
||||
let response = try sendV1Command("notify_target \(targetWorkspace) \(targetSurface) \(payload)", client: client)
|
||||
|
|
@ -10482,13 +10522,7 @@ struct CMUXCLI {
|
|||
}
|
||||
|
||||
private func resolveWorkspaceIdForClaudeHook(_ raw: String?, client: SocketClient) throws -> String {
|
||||
if let raw, !raw.isEmpty, let candidate = try? resolveWorkspaceId(raw, client: client) {
|
||||
let probe = try? client.sendV2(method: "surface.list", params: ["workspace_id": candidate])
|
||||
if probe != nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return try resolveWorkspaceId(nil, client: client)
|
||||
try resolveWorkspaceIdAllowingFallback(raw, client: client)
|
||||
}
|
||||
|
||||
private func resolveSurfaceIdForClaudeHook(
|
||||
|
|
@ -10496,12 +10530,125 @@ struct CMUXCLI {
|
|||
workspaceId: String,
|
||||
client: SocketClient
|
||||
) throws -> String {
|
||||
if let raw, !raw.isEmpty, let candidate = try? resolveSurfaceId(raw, workspaceId: workspaceId, client: client) {
|
||||
try resolveSurfaceIdAllowingFallback(raw, workspaceId: workspaceId, client: client)
|
||||
}
|
||||
|
||||
private func resolveWorkspaceIdAllowingFallback(
|
||||
_ raw: String?,
|
||||
client: SocketClient
|
||||
) throws -> String {
|
||||
if let raw,
|
||||
!raw.isEmpty,
|
||||
let candidate = try? resolveWorkspaceId(raw, client: client),
|
||||
(try? client.sendV2(method: "surface.list", params: ["workspace_id": candidate])) != nil {
|
||||
return candidate
|
||||
}
|
||||
if let callerWorkspaceId = resolveCallerWorkspaceIdByTTY(client: client),
|
||||
(try? client.sendV2(method: "surface.list", params: ["workspace_id": callerWorkspaceId])) != nil {
|
||||
return callerWorkspaceId
|
||||
}
|
||||
return try resolveWorkspaceId(nil, client: client)
|
||||
}
|
||||
|
||||
private func resolveSurfaceIdAllowingFallback(
|
||||
_ raw: String?,
|
||||
workspaceId: String,
|
||||
client: SocketClient
|
||||
) throws -> String {
|
||||
if let raw,
|
||||
!raw.isEmpty,
|
||||
let candidate = try? resolveSurfaceId(raw, workspaceId: workspaceId, client: client),
|
||||
let listed = try? client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId]) {
|
||||
let items = listed["surfaces"] as? [[String: Any]] ?? []
|
||||
if items.contains(where: {
|
||||
($0["id"] as? String) == candidate || ($0["ref"] as? String) == candidate
|
||||
}) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
if let callerSurfaceId = resolveCallerSurfaceIdByTTY(workspaceId: workspaceId, client: client),
|
||||
let listed = try? client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId]) {
|
||||
let items = listed["surfaces"] as? [[String: Any]] ?? []
|
||||
if items.contains(where: {
|
||||
($0["id"] as? String) == callerSurfaceId || ($0["ref"] as? String) == callerSurfaceId
|
||||
}) {
|
||||
return callerSurfaceId
|
||||
}
|
||||
}
|
||||
return try resolveSurfaceId(nil, workspaceId: workspaceId, client: client)
|
||||
}
|
||||
|
||||
private struct CallerTerminalBinding {
|
||||
let workspaceId: String
|
||||
let surfaceId: String
|
||||
}
|
||||
|
||||
private func resolveCallerWorkspaceIdByTTY(client: SocketClient) -> String? {
|
||||
resolveCallerTerminalBindingByTTY(client: client)?.workspaceId
|
||||
}
|
||||
|
||||
private func resolveCallerSurfaceIdByTTY(workspaceId: String, client: SocketClient) -> String? {
|
||||
guard let binding = resolveCallerTerminalBindingByTTY(client: client),
|
||||
binding.workspaceId == workspaceId else {
|
||||
return nil
|
||||
}
|
||||
return binding.surfaceId
|
||||
}
|
||||
|
||||
private func resolveCallerTerminalBindingByTTY(client: SocketClient) -> CallerTerminalBinding? {
|
||||
guard let ttyName = resolveCallerTTYName() else {
|
||||
return nil
|
||||
}
|
||||
guard let payload = try? client.sendV2(method: "debug.terminals") else {
|
||||
return nil
|
||||
}
|
||||
let terminals = payload["terminals"] as? [[String: Any]] ?? []
|
||||
for terminal in terminals {
|
||||
guard normalizedTTYName(terminal["tty"] as? String) == ttyName,
|
||||
let workspaceId = normalizedHandleValue(terminal["workspace_id"] as? String),
|
||||
let surfaceId = normalizedHandleValue(terminal["surface_id"] as? String) else {
|
||||
continue
|
||||
}
|
||||
return CallerTerminalBinding(workspaceId: workspaceId, surfaceId: surfaceId)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveCallerTTYName() -> String? {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
for key in ["CMUX_CLI_TTY_NAME", "CMUX_TTY_NAME", "TTY", "SSH_TTY"] {
|
||||
if let ttyName = normalizedTTYName(env[key]) {
|
||||
return ttyName
|
||||
}
|
||||
}
|
||||
for fileDescriptor in [STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO] {
|
||||
if let rawTTYName = ttyname(fileDescriptor),
|
||||
let ttyName = normalizedTTYName(String(cString: rawTTYName)) {
|
||||
return ttyName
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func normalizedTTYName(_ raw: String?) -> String? {
|
||||
guard let trimmed = normalizedHandleValue(raw == "not a tty" ? nil : raw) else {
|
||||
return nil
|
||||
}
|
||||
let components = trimmed.split(separator: "/")
|
||||
if let last = components.last, !last.isEmpty {
|
||||
return String(last)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private func normalizedHandleValue(_ raw: String?) -> String? {
|
||||
guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!raw.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
private func parseClaudeHookInput(rawInput: String) -> ClaudeHookParsedInput {
|
||||
let trimmed = rawInput.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6470,13 +6470,32 @@ func shouldAllowEnsureFocusWindowActivation(
|
|||
|
||||
final class GhosttySurfaceScrollView: NSView {
|
||||
enum FlashStyle {
|
||||
case standardFocus
|
||||
case notificationDismiss
|
||||
case navigation
|
||||
case notification
|
||||
}
|
||||
|
||||
static func flashStyle(for reason: WorkspaceAttentionFlashReason) -> FlashStyle {
|
||||
switch reason {
|
||||
case .navigation:
|
||||
return .navigation
|
||||
case .notificationArrival, .notificationDismiss, .manualUnreadDismiss, .debug:
|
||||
return .notification
|
||||
}
|
||||
}
|
||||
|
||||
private static func flashPresentation(for style: FlashStyle) -> WorkspaceAttentionFlashPresentation {
|
||||
switch style {
|
||||
case .navigation:
|
||||
return WorkspaceAttentionCoordinator.flashStyle(for: .navigation)
|
||||
case .notification:
|
||||
return WorkspaceAttentionCoordinator.flashStyle(for: .notificationArrival)
|
||||
}
|
||||
}
|
||||
|
||||
private enum NotificationRingMetrics {
|
||||
static let inset: CGFloat = 2
|
||||
static let cornerRadius: CGFloat = 6
|
||||
static let inset = PanelOverlayRingMetrics.inset
|
||||
static let cornerRadius = PanelOverlayRingMetrics.cornerRadius
|
||||
static let lineWidth = PanelOverlayRingMetrics.lineWidth
|
||||
}
|
||||
|
||||
private let backgroundView: NSView
|
||||
|
|
@ -6489,6 +6508,7 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
private let notificationRingLayer: CAShapeLayer
|
||||
private let flashOverlayView: GhosttyFlashOverlayView
|
||||
private let flashLayer: CAShapeLayer
|
||||
private var lastFlashStyle: FlashStyle = .navigation
|
||||
private let keyboardCopyModeBadgeContainerView: GhosttyFlashOverlayView
|
||||
private let keyboardCopyModeBadgeView: GhosttyPassthroughVisualEffectView
|
||||
private let keyboardCopyModeBadgeIconView: NSImageView
|
||||
|
|
@ -6743,7 +6763,7 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
notificationRingOverlayView.autoresizingMask = [.width, .height]
|
||||
notificationRingLayer.fillColor = NSColor.clear.cgColor
|
||||
notificationRingLayer.strokeColor = NSColor.systemBlue.cgColor
|
||||
notificationRingLayer.lineWidth = 2.5
|
||||
notificationRingLayer.lineWidth = NotificationRingMetrics.lineWidth
|
||||
notificationRingLayer.lineJoin = .round
|
||||
notificationRingLayer.lineCap = .round
|
||||
notificationRingLayer.shadowColor = NSColor.systemBlue.cgColor
|
||||
|
|
@ -6759,13 +6779,13 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
flashOverlayView.layer?.masksToBounds = false
|
||||
flashOverlayView.autoresizingMask = [.width, .height]
|
||||
flashLayer.fillColor = NSColor.clear.cgColor
|
||||
flashLayer.strokeColor = NSColor.systemBlue.cgColor
|
||||
flashLayer.lineWidth = 3
|
||||
flashLayer.strokeColor = WorkspaceAttentionCoordinator.flashStyle(for: .navigation).accent.strokeColor.cgColor
|
||||
flashLayer.lineWidth = NotificationRingMetrics.lineWidth
|
||||
flashLayer.lineJoin = .round
|
||||
flashLayer.lineCap = .round
|
||||
flashLayer.shadowColor = NSColor.systemBlue.cgColor
|
||||
flashLayer.shadowOpacity = 0.6
|
||||
flashLayer.shadowRadius = 6
|
||||
flashLayer.shadowColor = WorkspaceAttentionCoordinator.flashStyle(for: .navigation).accent.strokeColor.cgColor
|
||||
flashLayer.shadowOpacity = Float(WorkspaceAttentionCoordinator.flashStyle(for: .navigation).glowOpacity)
|
||||
flashLayer.shadowRadius = WorkspaceAttentionCoordinator.flashStyle(for: .navigation).glowRadius
|
||||
flashLayer.shadowOffset = .zero
|
||||
flashLayer.opacity = 0
|
||||
flashOverlayView.layer?.addSublayer(flashLayer)
|
||||
|
|
@ -7069,7 +7089,8 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
// which makes interactive width changes arrive a queue turn late on Sequoia.
|
||||
scrollView.layoutSubtreeIfNeeded()
|
||||
updateNotificationRingPath()
|
||||
updateFlashPath(style: .standardFocus)
|
||||
updateFlashPath(style: lastFlashStyle)
|
||||
updateFlashAppearance(style: lastFlashStyle)
|
||||
synchronizeScrollView()
|
||||
synchronizeSurfaceView()
|
||||
let didCoreSurfaceChange = synchronizeCoreSurface()
|
||||
|
|
@ -7801,15 +7822,17 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
}
|
||||
#endif
|
||||
|
||||
func triggerFlash(style: FlashStyle = .standardFocus) {
|
||||
func triggerFlash(style: FlashStyle = .navigation) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
#if DEBUG
|
||||
self.lastFlashStyle = style
|
||||
#if DEBUG
|
||||
if let surfaceId = self.surfaceView.terminalSurface?.id {
|
||||
Self.recordFlash(for: surfaceId)
|
||||
}
|
||||
#endif
|
||||
self.updateFlashPath(style: style)
|
||||
self.updateFlashAppearance(style: style)
|
||||
self.flashLayer.removeAllAnimations()
|
||||
self.flashLayer.opacity = 0
|
||||
let animation = CAKeyframeAnimation(keyPath: "opacity")
|
||||
|
|
@ -8873,10 +8896,7 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
let inset: CGFloat
|
||||
let radius: CGFloat
|
||||
switch style {
|
||||
case .standardFocus:
|
||||
inset = CGFloat(FocusFlashPattern.ringInset)
|
||||
radius = CGFloat(FocusFlashPattern.ringCornerRadius)
|
||||
case .notificationDismiss:
|
||||
case .navigation, .notification:
|
||||
inset = NotificationRingMetrics.inset
|
||||
radius = NotificationRingMetrics.cornerRadius
|
||||
}
|
||||
|
|
@ -8888,6 +8908,15 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
)
|
||||
}
|
||||
|
||||
private func updateFlashAppearance(style: FlashStyle) {
|
||||
let presentation = Self.flashPresentation(for: style)
|
||||
let strokeColor = presentation.accent.strokeColor
|
||||
flashLayer.strokeColor = strokeColor.cgColor
|
||||
flashLayer.shadowColor = strokeColor.cgColor
|
||||
flashLayer.shadowOpacity = Float(presentation.glowOpacity)
|
||||
flashLayer.shadowRadius = presentation.glowRadius
|
||||
}
|
||||
|
||||
private func updateOverlayRingPath(
|
||||
layer: CAShapeLayer,
|
||||
bounds: CGRect,
|
||||
|
|
@ -8899,7 +8928,7 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
layer.path = nil
|
||||
return
|
||||
}
|
||||
let rect = bounds.insetBy(dx: inset, dy: inset)
|
||||
let rect = PanelOverlayRingMetrics.pathRect(in: bounds)
|
||||
layer.path = CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2858,7 +2858,8 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
return true
|
||||
}
|
||||
|
||||
func triggerFlash() {
|
||||
func triggerFlash(reason: WorkspaceAttentionFlashReason) {
|
||||
_ = reason
|
||||
guard NotificationPaneFlashSettings.isEnabled() else { return }
|
||||
focusFlashToken &+= 1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@ final class MarkdownPanel: Panel, ObservableObject {
|
|||
stopFileWatcher()
|
||||
}
|
||||
|
||||
func triggerFlash() {
|
||||
func triggerFlash(reason: WorkspaceAttentionFlashReason) {
|
||||
_ = reason
|
||||
guard NotificationPaneFlashSettings.isEnabled() else { return }
|
||||
focusFlashToken += 1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,11 +26,134 @@ public enum PanelFocusIntent: Equatable {
|
|||
case browser(BrowserPanelFocusIntent)
|
||||
}
|
||||
|
||||
public enum WorkspaceAttentionFlashReason: String, Equatable, Sendable {
|
||||
case navigation
|
||||
case notificationArrival
|
||||
case notificationDismiss
|
||||
case manualUnreadDismiss
|
||||
case debug
|
||||
}
|
||||
|
||||
enum WorkspaceAttentionFlashAccent: Equatable, Sendable {
|
||||
case notificationBlue
|
||||
case navigationNeutral
|
||||
|
||||
var strokeColor: NSColor {
|
||||
switch self {
|
||||
case .notificationBlue:
|
||||
return .systemBlue
|
||||
case .navigationNeutral:
|
||||
return .systemGray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkspaceAttentionFlashPresentation: Equatable, Sendable {
|
||||
let accent: WorkspaceAttentionFlashAccent
|
||||
let glowOpacity: Double
|
||||
let glowRadius: CGFloat
|
||||
}
|
||||
|
||||
struct WorkspaceAttentionPersistentState: Equatable, Sendable {
|
||||
var unreadPanelIDs: Set<UUID> = []
|
||||
var focusedReadPanelID: UUID?
|
||||
var manualUnreadPanelIDs: Set<UUID> = []
|
||||
|
||||
var indicatorPanelIDs: Set<UUID> {
|
||||
var ids = unreadPanelIDs.union(manualUnreadPanelIDs)
|
||||
if let focusedReadPanelID {
|
||||
ids.insert(focusedReadPanelID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func hasCompetingIndicator(for panelID: UUID) -> Bool {
|
||||
indicatorPanelIDs.contains(where: { $0 != panelID })
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkspaceAttentionFlashDecision: Equatable, Sendable {
|
||||
let panelID: UUID
|
||||
let reason: WorkspaceAttentionFlashReason
|
||||
let isAllowed: Bool
|
||||
}
|
||||
|
||||
enum WorkspaceAttentionCoordinator {
|
||||
static func flashStyle(for reason: WorkspaceAttentionFlashReason) -> WorkspaceAttentionFlashPresentation {
|
||||
switch reason {
|
||||
case .navigation:
|
||||
return WorkspaceAttentionFlashPresentation(
|
||||
accent: .navigationNeutral,
|
||||
glowOpacity: 0.14,
|
||||
glowRadius: 3
|
||||
)
|
||||
case .notificationArrival, .notificationDismiss, .manualUnreadDismiss, .debug:
|
||||
return WorkspaceAttentionFlashPresentation(
|
||||
accent: .notificationBlue,
|
||||
glowOpacity: 0.6,
|
||||
glowRadius: 6
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static func decideFlash(
|
||||
targetPanelID: UUID,
|
||||
reason: WorkspaceAttentionFlashReason,
|
||||
persistentState: WorkspaceAttentionPersistentState
|
||||
) -> WorkspaceAttentionFlashDecision {
|
||||
let isAllowed: Bool
|
||||
switch reason {
|
||||
case .navigation:
|
||||
isAllowed = !persistentState.hasCompetingIndicator(for: targetPanelID)
|
||||
case .notificationArrival, .notificationDismiss, .manualUnreadDismiss, .debug:
|
||||
isAllowed = true
|
||||
}
|
||||
|
||||
return WorkspaceAttentionFlashDecision(
|
||||
panelID: targetPanelID,
|
||||
reason: reason,
|
||||
isAllowed: isAllowed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum FocusFlashCurve: Equatable {
|
||||
case easeIn
|
||||
case easeOut
|
||||
}
|
||||
|
||||
enum PanelOverlayRingMetrics {
|
||||
static let inset: CGFloat = 2
|
||||
static let cornerRadius: CGFloat = 6
|
||||
static let lineWidth: CGFloat = 2.5
|
||||
|
||||
static func pathRect(in bounds: CGRect) -> CGRect {
|
||||
bounds.insetBy(dx: inset, dy: inset)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func cmuxFlashDebugID(_ id: UUID?) -> String {
|
||||
guard let id else { return "nil" }
|
||||
return String(id.uuidString.prefix(6))
|
||||
}
|
||||
|
||||
func cmuxFlashDebugRect(_ rect: CGRect?) -> String {
|
||||
guard let rect else { return "nil" }
|
||||
return String(
|
||||
format: "%.1f,%.1f %.1fx%.1f",
|
||||
rect.origin.x,
|
||||
rect.origin.y,
|
||||
rect.size.width,
|
||||
rect.size.height
|
||||
)
|
||||
}
|
||||
|
||||
func cmuxFlashDebugBool(_ value: Bool) -> Int {
|
||||
value ? 1 : 0
|
||||
}
|
||||
#endif
|
||||
|
||||
struct FocusFlashSegment: Equatable {
|
||||
let delay: TimeInterval
|
||||
let duration: TimeInterval
|
||||
|
|
@ -43,8 +166,8 @@ enum FocusFlashPattern {
|
|||
static let keyTimes: [Double] = [0, 0.25, 0.5, 0.75, 1]
|
||||
static let duration: TimeInterval = 0.9
|
||||
static let curves: [FocusFlashCurve] = [.easeOut, .easeIn, .easeOut, .easeIn]
|
||||
static let ringInset: Double = 6
|
||||
static let ringCornerRadius: Double = 10
|
||||
static let ringInset: Double = Double(PanelOverlayRingMetrics.inset)
|
||||
static let ringCornerRadius: Double = Double(PanelOverlayRingMetrics.cornerRadius)
|
||||
|
||||
static var segments: [FocusFlashSegment] {
|
||||
let stepCount = min(curves.count, values.count - 1, keyTimes.count - 1)
|
||||
|
|
@ -59,6 +182,37 @@ enum FocusFlashPattern {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
static func opacity(at elapsed: TimeInterval) -> Double {
|
||||
guard elapsed >= 0, elapsed <= duration else { return 0 }
|
||||
|
||||
for index in 0..<segments.count {
|
||||
let startTime = keyTimes[index] * duration
|
||||
let endTime = keyTimes[index + 1] * duration
|
||||
if elapsed > endTime {
|
||||
continue
|
||||
}
|
||||
|
||||
let segmentDuration = max(endTime - startTime, 0.0001)
|
||||
let rawProgress = max(0, min(1, (elapsed - startTime) / segmentDuration))
|
||||
let curvedProgress = interpolatedProgress(rawProgress, curve: curves[index])
|
||||
let startOpacity = values[index]
|
||||
let endOpacity = values[index + 1]
|
||||
return startOpacity + ((endOpacity - startOpacity) * curvedProgress)
|
||||
}
|
||||
|
||||
return values.last ?? 0
|
||||
}
|
||||
|
||||
private static func interpolatedProgress(_ progress: Double, curve: FocusFlashCurve) -> Double {
|
||||
switch curve {
|
||||
case .easeIn:
|
||||
return progress * progress
|
||||
case .easeOut:
|
||||
let inverse = 1 - progress
|
||||
return 1 - (inverse * inverse)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Protocol for all panel types (terminal, browser, etc.)
|
||||
|
|
@ -89,7 +243,7 @@ public protocol Panel: AnyObject, Identifiable, ObservableObject where ID == UUI
|
|||
func unfocus()
|
||||
|
||||
/// Trigger a focus flash animation for this panel.
|
||||
func triggerFlash()
|
||||
func triggerFlash(reason: WorkspaceAttentionFlashReason)
|
||||
|
||||
/// Capture the panel-local focus target that should be restored later.
|
||||
func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent
|
||||
|
|
@ -149,4 +303,8 @@ extension Panel {
|
|||
_ = window
|
||||
return false
|
||||
}
|
||||
|
||||
func triggerFlash() {
|
||||
triggerFlash(reason: .navigation)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ final class TerminalPanel: Panel, ObservableObject {
|
|||
/// Published directory from the terminal
|
||||
@Published private(set) var directory: String = ""
|
||||
|
||||
@Published private(set) var tmuxLayoutReport: TmuxPaneLayoutReport?
|
||||
|
||||
/// Search state for find functionality
|
||||
@Published var searchState: TerminalSurface.SearchState? {
|
||||
didSet {
|
||||
|
|
@ -36,6 +38,8 @@ final class TerminalPanel: Panel, ObservableObject {
|
|||
/// (hostedView.window == nil) until the user switches workspaces.
|
||||
@Published var viewReattachToken: UInt64 = 0
|
||||
|
||||
var onRequestWorkspacePaneFlash: ((WorkspaceAttentionFlashReason) -> Void)?
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
var displayTitle: String {
|
||||
|
|
@ -125,6 +129,11 @@ final class TerminalPanel: Panel, ObservableObject {
|
|||
surface.updateWorkspaceId(newWorkspaceId)
|
||||
}
|
||||
|
||||
func updateTmuxLayoutReport(_ report: TmuxPaneLayoutReport?) {
|
||||
guard tmuxLayoutReport != report else { return }
|
||||
tmuxLayoutReport = report
|
||||
}
|
||||
|
||||
func focus() {
|
||||
surface.setFocus(true)
|
||||
// `unfocus()` force-disables active state to stop stale retries from stealing focus.
|
||||
|
|
@ -200,14 +209,23 @@ final class TerminalPanel: Panel, ObservableObject {
|
|||
!surface.needsConfirmClose()
|
||||
}
|
||||
|
||||
func triggerFlash() {
|
||||
func triggerFlash(reason: WorkspaceAttentionFlashReason) {
|
||||
guard NotificationPaneFlashSettings.isEnabled() else { return }
|
||||
hostedView.triggerFlash()
|
||||
|
||||
switch TmuxOverlayExperimentSettings.target() {
|
||||
case .bonsplitPane:
|
||||
if let onRequestWorkspacePaneFlash {
|
||||
onRequestWorkspacePaneFlash(reason)
|
||||
return
|
||||
}
|
||||
hostedView.triggerFlash(style: GhosttySurfaceScrollView.flashStyle(for: reason))
|
||||
case .surface, .tmuxActivePane:
|
||||
hostedView.triggerFlash(style: GhosttySurfaceScrollView.flashStyle(for: reason))
|
||||
}
|
||||
}
|
||||
|
||||
func triggerNotificationDismissFlash() {
|
||||
guard NotificationPaneFlashSettings.isEnabled() else { return }
|
||||
hostedView.triggerFlash(style: .notificationDismiss)
|
||||
triggerFlash(reason: .notificationDismiss)
|
||||
}
|
||||
|
||||
func applyWindowBackgroundIfActive() {
|
||||
|
|
|
|||
|
|
@ -2760,6 +2760,7 @@ class TabManager: ObservableObject {
|
|||
if let tab = tabs.first(where: { $0.id == tabId }) {
|
||||
tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false)
|
||||
}
|
||||
notificationStore.setFocusedReadIndicator(forTabId: tabId, surfaceId: panelId)
|
||||
notificationStore.markRead(forTabId: tabId, surfaceId: panelId)
|
||||
}
|
||||
|
||||
|
|
@ -2768,12 +2769,17 @@ class TabManager: ObservableObject {
|
|||
guard selectedTabId == tabId else { return false }
|
||||
guard AppFocusState.isAppActive() else { return false }
|
||||
guard let notificationStore = AppDelegate.shared?.notificationStore else { return false }
|
||||
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return false }
|
||||
let hasUnreadNotification = notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId)
|
||||
let hasFocusedIndicator = notificationStore.hasVisibleNotificationIndicator(forTabId: tabId, surfaceId: surfaceId)
|
||||
guard hasUnreadNotification || hasFocusedIndicator else { return false }
|
||||
if hasUnreadNotification {
|
||||
notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId)
|
||||
}
|
||||
notificationStore.clearFocusedReadIndicator(forTabId: tabId, surfaceId: surfaceId)
|
||||
if let panelId = surfaceId,
|
||||
let tab = tabs.first(where: { $0.id == tabId }) {
|
||||
tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false)
|
||||
tab.triggerNotificationDismissFlash(panelId: panelId)
|
||||
}
|
||||
notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId)
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -697,6 +697,7 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
refreshDockBadge()
|
||||
}
|
||||
}
|
||||
@Published private(set) var focusedReadIndicatorByTabId: [UUID: UUID] = [:]
|
||||
@Published private(set) var authorizationState: NotificationAuthorizationState = .unknown
|
||||
|
||||
private let center = UNUserNotificationCenter.current()
|
||||
|
|
@ -876,10 +877,19 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
indexes.unreadByTabSurface.contains(TabSurfaceKey(tabId: tabId, surfaceId: surfaceId))
|
||||
}
|
||||
|
||||
func hasVisibleNotificationIndicator(forTabId tabId: UUID, surfaceId: UUID?) -> Bool {
|
||||
hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) ||
|
||||
focusedReadIndicatorByTabId[tabId] == surfaceId
|
||||
}
|
||||
|
||||
func latestNotification(forTabId tabId: UUID) -> TerminalNotification? {
|
||||
indexes.latestUnreadByTabId[tabId] ?? indexes.latestByTabId[tabId]
|
||||
}
|
||||
|
||||
func focusedReadIndicatorSurfaceId(forTabId tabId: UUID) -> UUID? {
|
||||
focusedReadIndicatorByTabId[tabId]
|
||||
}
|
||||
|
||||
func addNotification(tabId: UUID, surfaceId: UUID?, title: String, subtitle: String, body: String) {
|
||||
var updated = notifications
|
||||
var idsToClear: [String] = []
|
||||
|
|
@ -889,12 +899,20 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
return true
|
||||
}
|
||||
|
||||
if let existingIndicatorSurfaceId = focusedReadIndicatorByTabId[tabId],
|
||||
existingIndicatorSurfaceId != surfaceId {
|
||||
focusedReadIndicatorByTabId.removeValue(forKey: tabId)
|
||||
}
|
||||
|
||||
let isActiveTab = AppDelegate.shared?.tabManager?.selectedTabId == tabId
|
||||
let focusedSurfaceId = AppDelegate.shared?.tabManager?.focusedSurfaceId(for: tabId)
|
||||
let isFocusedSurface = surfaceId == nil || focusedSurfaceId == surfaceId
|
||||
let isFocusedPanel = isActiveTab && isFocusedSurface
|
||||
let isAppFocused = AppFocusState.isAppFocused()
|
||||
let shouldSuppressExternalDelivery = isAppFocused && isFocusedPanel
|
||||
if shouldSuppressExternalDelivery {
|
||||
setFocusedReadIndicator(forTabId: tabId, surfaceId: surfaceId)
|
||||
}
|
||||
|
||||
if WorkspaceAutoReorderSettings.isEnabled() {
|
||||
AppDelegate.shared?.tabManager?.moveTabToTopForNotification(tabId)
|
||||
|
|
@ -979,6 +997,24 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func setFocusedReadIndicator(forTabId tabId: UUID, surfaceId: UUID?) {
|
||||
guard let surfaceId else { return }
|
||||
guard focusedReadIndicatorByTabId[tabId] != surfaceId else { return }
|
||||
focusedReadIndicatorByTabId[tabId] = surfaceId
|
||||
}
|
||||
|
||||
func clearFocusedReadIndicator(forTabId tabId: UUID, surfaceId: UUID? = nil) {
|
||||
guard let existingSurfaceId = focusedReadIndicatorByTabId[tabId] else { return }
|
||||
guard surfaceId == nil || existingSurfaceId == surfaceId else { return }
|
||||
focusedReadIndicatorByTabId.removeValue(forKey: tabId)
|
||||
}
|
||||
|
||||
func clearFocusedReadIndicatorIfSurfaceChanged(forTabId tabId: UUID, surfaceId: UUID?) {
|
||||
guard let existingSurfaceId = focusedReadIndicatorByTabId[tabId] else { return }
|
||||
guard existingSurfaceId != surfaceId else { return }
|
||||
focusedReadIndicatorByTabId.removeValue(forKey: tabId)
|
||||
}
|
||||
|
||||
func markAllRead() {
|
||||
var updated = notifications
|
||||
var idsToClear: [String] = []
|
||||
|
|
@ -997,17 +1033,22 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
|
||||
func remove(id: UUID) {
|
||||
var updated = notifications
|
||||
let removed = updated.first(where: { $0.id == id })
|
||||
let originalCount = updated.count
|
||||
updated.removeAll { $0.id == id }
|
||||
guard updated.count != originalCount else { return }
|
||||
notifications = updated
|
||||
if let removed {
|
||||
clearFocusedReadIndicator(forTabId: removed.tabId, surfaceId: removed.surfaceId)
|
||||
}
|
||||
center.removeDeliveredNotificationsOffMain(withIdentifiers: [id.uuidString])
|
||||
}
|
||||
|
||||
func clearAll() {
|
||||
guard !notifications.isEmpty else { return }
|
||||
guard !notifications.isEmpty || !focusedReadIndicatorByTabId.isEmpty else { return }
|
||||
let ids = notifications.map { $0.id.uuidString }
|
||||
notifications.removeAll()
|
||||
focusedReadIndicatorByTabId.removeAll()
|
||||
center.removeDeliveredNotificationsOffMain(withIdentifiers: ids)
|
||||
center.removePendingNotificationRequestsOffMain(withIdentifiers: ids)
|
||||
}
|
||||
|
|
@ -1025,6 +1066,7 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
}
|
||||
guard !idsToClear.isEmpty else { return }
|
||||
notifications = updated
|
||||
clearFocusedReadIndicator(forTabId: tabId, surfaceId: surfaceId)
|
||||
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
|
||||
center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear)
|
||||
}
|
||||
|
|
@ -1042,6 +1084,7 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
}
|
||||
guard !idsToClear.isEmpty else { return }
|
||||
notifications = updated
|
||||
clearFocusedReadIndicator(forTabId: tabId)
|
||||
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
|
||||
center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear)
|
||||
}
|
||||
|
|
@ -1334,6 +1377,7 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
|
||||
func replaceNotificationsForTesting(_ notifications: [TerminalNotification]) {
|
||||
self.notifications = notifications
|
||||
focusedReadIndicatorByTabId.removeAll()
|
||||
}
|
||||
#endif
|
||||
|
||||
|
|
|
|||
|
|
@ -5201,6 +5201,10 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
@Published private(set) var panelCustomTitles: [UUID: String] = [:]
|
||||
@Published private(set) var pinnedPanelIds: Set<UUID> = []
|
||||
@Published private(set) var manualUnreadPanelIds: Set<UUID> = []
|
||||
@Published private(set) var tmuxLayoutSnapshot: LayoutSnapshot?
|
||||
@Published private(set) var tmuxWorkspaceFlashPanelId: UUID?
|
||||
@Published private(set) var tmuxWorkspaceFlashReason: WorkspaceAttentionFlashReason?
|
||||
@Published private(set) var tmuxWorkspaceFlashToken: UInt64 = 0
|
||||
private var manualUnreadMarkedAt: [UUID: Date] = [:]
|
||||
nonisolated private static let manualUnreadFocusGraceInterval: TimeInterval = 0.2
|
||||
nonisolated private static let manualUnreadClearDelayAfterFocusFlash: TimeInterval = 0.2
|
||||
|
|
@ -5437,6 +5441,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
initialCommand: initialTerminalCommand,
|
||||
initialEnvironmentOverrides: initialTerminalEnvironment
|
||||
)
|
||||
configureTerminalPanel(terminalPanel)
|
||||
panels[terminalPanel.id] = terminalPanel
|
||||
panelTitles[terminalPanel.id] = terminalPanel.displayTitle
|
||||
seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate)
|
||||
|
|
@ -5487,6 +5492,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
bonsplitController.selectTab(initialTabId)
|
||||
}
|
||||
tmuxLayoutSnapshot = bonsplitController.layoutSnapshot()
|
||||
}
|
||||
|
||||
deinit {
|
||||
|
|
@ -5607,6 +5613,19 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
surfaceIdToPanelId.first { $0.value == panelId }?.key
|
||||
}
|
||||
|
||||
private func configureTerminalPanel(_ terminalPanel: TerminalPanel) {
|
||||
terminalPanel.onRequestWorkspacePaneFlash = { [weak self, weak terminalPanel] reason in
|
||||
guard let self, let terminalPanel else { return }
|
||||
self.triggerWorkspacePaneFlash(panelId: terminalPanel.id, reason: reason)
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerWorkspacePaneFlash(panelId: UUID, reason: WorkspaceAttentionFlashReason) {
|
||||
tmuxWorkspaceFlashPanelId = panelId
|
||||
tmuxWorkspaceFlashReason = reason
|
||||
tmuxWorkspaceFlashToken &+= 1
|
||||
}
|
||||
|
||||
|
||||
private func installBrowserPanelSubscription(_ browserPanel: BrowserPanel) {
|
||||
let subscription = Publishers.CombineLatest3(
|
||||
|
|
@ -5768,7 +5787,31 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
|
||||
private func hasUnreadNotification(panelId: UUID) -> Bool {
|
||||
AppDelegate.shared?.notificationStore?.hasUnreadNotification(forTabId: id, surfaceId: panelId) ?? false
|
||||
AppDelegate.shared?.notificationStore?.hasVisibleNotificationIndicator(forTabId: id, surfaceId: panelId) ?? false
|
||||
}
|
||||
|
||||
private func attentionPersistentState() -> WorkspaceAttentionPersistentState {
|
||||
let notificationStore = AppDelegate.shared?.notificationStore
|
||||
let unreadPanelIDs = Set(
|
||||
panels.keys.filter {
|
||||
notificationStore?.hasUnreadNotification(forTabId: id, surfaceId: $0) ?? false
|
||||
}
|
||||
)
|
||||
return WorkspaceAttentionPersistentState(
|
||||
unreadPanelIDs: unreadPanelIDs,
|
||||
focusedReadPanelID: notificationStore?.focusedReadIndicatorSurfaceId(forTabId: id),
|
||||
manualUnreadPanelIDs: manualUnreadPanelIds
|
||||
)
|
||||
}
|
||||
|
||||
private func requestAttentionFlash(panelId: UUID, reason: WorkspaceAttentionFlashReason) {
|
||||
let decision = WorkspaceAttentionCoordinator.decideFlash(
|
||||
targetPanelID: panelId,
|
||||
reason: reason,
|
||||
persistentState: attentionPersistentState()
|
||||
)
|
||||
guard decision.isAllowed else { return }
|
||||
panels[panelId]?.triggerFlash(reason: reason)
|
||||
}
|
||||
|
||||
private func syncUnreadBadgeStateForPanel(_ panelId: UUID) {
|
||||
|
|
@ -6983,6 +7026,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
portOrdinal: portOrdinal,
|
||||
initialCommand: remoteTerminalStartupCommand
|
||||
)
|
||||
configureTerminalPanel(newPanel)
|
||||
panels[newPanel.id] = newPanel
|
||||
panelTitles[newPanel.id] = newPanel.displayTitle
|
||||
if remoteTerminalStartupCommand != nil {
|
||||
|
|
@ -7072,6 +7116,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
initialCommand: remoteTerminalStartupCommand,
|
||||
additionalEnvironment: startupEnvironment
|
||||
)
|
||||
configureTerminalPanel(newPanel)
|
||||
panels[newPanel.id] = newPanel
|
||||
panelTitles[newPanel.id] = newPanel.displayTitle
|
||||
if remoteTerminalStartupCommand != nil {
|
||||
|
|
@ -8184,8 +8229,10 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
|
||||
func moveFocus(direction: NavigationDirection) {
|
||||
let previousFocusedPanelId = focusedPanelId
|
||||
|
||||
// Unfocus the currently-focused panel before navigating.
|
||||
if let prevPanelId = focusedPanelId, let prev = panels[prevPanelId] {
|
||||
if let prevPanelId = previousFocusedPanelId, let prev = panels[prevPanelId] {
|
||||
prev.unfocus()
|
||||
}
|
||||
|
||||
|
|
@ -8197,6 +8244,10 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
let tabId = bonsplitController.selectedTab(inPane: paneId)?.id {
|
||||
applyTabSelection(tabId: tabId, inPane: paneId)
|
||||
}
|
||||
|
||||
if let focusedPanelId, focusedPanelId != previousFocusedPanelId {
|
||||
triggerFocusFlash(panelId: focusedPanelId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Surface Navigation
|
||||
|
|
@ -8301,7 +8352,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
// MARK: - Flash/Notification Support
|
||||
|
||||
func triggerFocusFlash(panelId: UUID) {
|
||||
panels[panelId]?.triggerFlash()
|
||||
requestAttentionFlash(panelId: panelId, reason: .navigation)
|
||||
}
|
||||
|
||||
func triggerNotificationFocusFlash(
|
||||
|
|
@ -8309,7 +8360,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
requiresSplit: Bool = false,
|
||||
shouldFocus: Bool = true
|
||||
) {
|
||||
guard let terminalPanel = terminalPanel(for: panelId) else { return }
|
||||
guard terminalPanel(for: panelId) != nil else { return }
|
||||
if shouldFocus {
|
||||
focusPanel(panelId)
|
||||
}
|
||||
|
|
@ -8317,11 +8368,18 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
if requiresSplit && !isSplit {
|
||||
return
|
||||
}
|
||||
terminalPanel.triggerFlash()
|
||||
requestAttentionFlash(panelId: panelId, reason: .notificationArrival)
|
||||
}
|
||||
|
||||
func triggerNotificationDismissFlash(panelId: UUID) {
|
||||
guard terminalPanel(for: panelId) != nil else { return }
|
||||
requestAttentionFlash(panelId: panelId, reason: .notificationDismiss)
|
||||
}
|
||||
|
||||
func triggerDebugFlash(panelId: UUID) {
|
||||
triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: true)
|
||||
guard panels[panelId] != nil else { return }
|
||||
focusPanel(panelId)
|
||||
requestAttentionFlash(panelId: panelId, reason: .debug)
|
||||
}
|
||||
|
||||
// MARK: - Portal Lifecycle
|
||||
|
|
@ -8359,6 +8417,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
configTemplate: inheritedConfig,
|
||||
portOrdinal: portOrdinal
|
||||
)
|
||||
configureTerminalPanel(newPanel)
|
||||
panels[newPanel.id] = newPanel
|
||||
panelTitles[newPanel.id] = newPanel.displayTitle
|
||||
seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig)
|
||||
|
|
@ -10040,6 +10099,7 @@ extension Workspace: BonsplitDelegate {
|
|||
configTemplate: inheritedConfig,
|
||||
portOrdinal: portOrdinal
|
||||
)
|
||||
configureTerminalPanel(replacementPanel)
|
||||
panels[replacementPanel.id] = replacementPanel
|
||||
panelTitles[replacementPanel.id] = replacementPanel.displayTitle
|
||||
seedTerminalInheritanceFontPoints(panelId: replacementPanel.id, configTemplate: inheritedConfig)
|
||||
|
|
@ -10106,6 +10166,7 @@ extension Workspace: BonsplitDelegate {
|
|||
configTemplate: inheritedConfig,
|
||||
portOrdinal: portOrdinal
|
||||
)
|
||||
configureTerminalPanel(newPanel)
|
||||
panels[newPanel.id] = newPanel
|
||||
panelTitles[newPanel.id] = newPanel.displayTitle
|
||||
seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig)
|
||||
|
|
@ -10200,7 +10261,7 @@ extension Workspace: BonsplitDelegate {
|
|||
}
|
||||
|
||||
func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) {
|
||||
_ = snapshot
|
||||
tmuxLayoutSnapshot = snapshot
|
||||
scheduleTerminalGeometryReconcile()
|
||||
if !isDetachingCloseTransaction {
|
||||
scheduleFocusReconcile()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,217 @@ import Foundation
|
|||
import AppKit
|
||||
import Bonsplit
|
||||
|
||||
enum TmuxOverlayExperimentTarget: String, CaseIterable, Codable, Sendable {
|
||||
case surface
|
||||
case bonsplitPane
|
||||
case tmuxActivePane
|
||||
|
||||
var usesWorkspacePaneOverlay: Bool {
|
||||
self == .bonsplitPane
|
||||
}
|
||||
|
||||
var usesTmuxActivePaneOverlay: Bool {
|
||||
self == .tmuxActivePane
|
||||
}
|
||||
}
|
||||
|
||||
struct TmuxOverlayExperimentSettings {
|
||||
static let enabledKey = "tmuxOverlayExperimentEnabled"
|
||||
static let targetKey = "tmuxOverlayExperimentTarget"
|
||||
static let defaultEnabled = false
|
||||
static let defaultTarget: TmuxOverlayExperimentTarget = .surface
|
||||
|
||||
static func isEnabled(defaults: UserDefaults = .standard) -> Bool {
|
||||
defaults.object(forKey: enabledKey) as? Bool ?? defaultEnabled
|
||||
}
|
||||
|
||||
static func target(defaults: UserDefaults = .standard) -> TmuxOverlayExperimentTarget {
|
||||
target(
|
||||
enabled: isEnabled(defaults: defaults),
|
||||
rawValue: defaults.string(forKey: targetKey)
|
||||
)
|
||||
}
|
||||
|
||||
static func target(enabled: Bool, rawValue: String?) -> TmuxOverlayExperimentTarget {
|
||||
guard enabled else { return .surface }
|
||||
guard let rawValue,
|
||||
let target = TmuxOverlayExperimentTarget(rawValue: rawValue) else {
|
||||
return defaultTarget
|
||||
}
|
||||
return target
|
||||
}
|
||||
}
|
||||
|
||||
struct TmuxPaneLayoutPane: Codable, Equatable, Sendable {
|
||||
let paneId: String
|
||||
let left: Int
|
||||
let top: Int
|
||||
let width: Int
|
||||
let height: Int
|
||||
let isActive: Bool
|
||||
}
|
||||
|
||||
struct TmuxPaneLayoutReport: Codable, Equatable, Sendable {
|
||||
let panes: [TmuxPaneLayoutPane]
|
||||
|
||||
var activePane: TmuxPaneLayoutPane? {
|
||||
panes.first(where: \.isActive) ?? panes.first
|
||||
}
|
||||
}
|
||||
|
||||
func tmuxActivePaneOverlayRect(
|
||||
surfaceFrame: CGRect,
|
||||
cellSize: CGSize,
|
||||
pane: TmuxPaneLayoutPane
|
||||
) -> CGRect? {
|
||||
guard cellSize.width > 0,
|
||||
cellSize.height > 0,
|
||||
pane.width > 0,
|
||||
pane.height > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return CGRect(
|
||||
x: surfaceFrame.origin.x + (CGFloat(pane.left) * cellSize.width),
|
||||
y: surfaceFrame.origin.y + (CGFloat(pane.top) * cellSize.height),
|
||||
width: CGFloat(pane.width) * cellSize.width,
|
||||
height: CGFloat(pane.height) * cellSize.height
|
||||
)
|
||||
}
|
||||
|
||||
private extension PixelRect {
|
||||
var cgRect: CGRect {
|
||||
CGRect(x: x, y: y, width: width, height: height)
|
||||
}
|
||||
}
|
||||
|
||||
struct TmuxWorkspacePaneOverlayRenderState: Equatable {
|
||||
let workspaceId: UUID
|
||||
let unreadRects: [CGRect]
|
||||
let flashRect: CGRect?
|
||||
let flashToken: UInt64
|
||||
let flashReason: WorkspaceAttentionFlashReason?
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class TmuxWorkspacePaneOverlayModel: ObservableObject {
|
||||
@Published private(set) var unreadRects: [CGRect] = []
|
||||
@Published private(set) var flashRect: CGRect?
|
||||
@Published private(set) var flashStartedAt: Date?
|
||||
@Published private(set) var flashReason: WorkspaceAttentionFlashReason?
|
||||
|
||||
private var lastWorkspaceId: UUID?
|
||||
private var lastFlashToken: UInt64?
|
||||
|
||||
func apply(
|
||||
_ state: TmuxWorkspacePaneOverlayRenderState,
|
||||
now: () -> Date = Date.init
|
||||
) {
|
||||
unreadRects = state.unreadRects
|
||||
flashRect = state.flashRect
|
||||
flashReason = state.flashReason
|
||||
|
||||
let didChangeWorkspace = lastWorkspaceId != state.workspaceId
|
||||
if didChangeWorkspace {
|
||||
lastWorkspaceId = state.workspaceId
|
||||
lastFlashToken = state.flashToken
|
||||
flashStartedAt = nil
|
||||
return
|
||||
}
|
||||
|
||||
if let lastFlashToken,
|
||||
state.flashToken != lastFlashToken,
|
||||
state.flashRect != nil {
|
||||
flashStartedAt = now()
|
||||
}
|
||||
self.lastFlashToken = state.flashToken
|
||||
}
|
||||
|
||||
func clear() {
|
||||
unreadRects = []
|
||||
flashRect = nil
|
||||
flashStartedAt = nil
|
||||
flashReason = nil
|
||||
lastWorkspaceId = nil
|
||||
lastFlashToken = nil
|
||||
}
|
||||
}
|
||||
|
||||
struct TmuxWorkspacePaneOverlayView: View {
|
||||
let unreadRects: [CGRect]
|
||||
let flashRect: CGRect?
|
||||
let flashStartedAt: Date?
|
||||
let flashReason: WorkspaceAttentionFlashReason?
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.animation) { timeline in
|
||||
Canvas { context, _ in
|
||||
for rect in unreadRects {
|
||||
drawUnreadRing(in: &context, rect: rect)
|
||||
}
|
||||
|
||||
guard let flashRect,
|
||||
let flashStartedAt else { return }
|
||||
let elapsed = timeline.date.timeIntervalSince(flashStartedAt)
|
||||
let opacity = FocusFlashPattern.opacity(at: elapsed)
|
||||
guard opacity > 0.001 else { return }
|
||||
drawFlashRing(
|
||||
in: &context,
|
||||
rect: flashRect,
|
||||
opacity: opacity,
|
||||
reason: flashReason ?? .notificationArrival
|
||||
)
|
||||
}
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func drawUnreadRing(in context: inout GraphicsContext, rect: CGRect) {
|
||||
guard let path = ringPath(for: rect) else { return }
|
||||
var glowContext = context
|
||||
glowContext.addFilter(.shadow(color: Color.blue.opacity(0.35), radius: 3))
|
||||
glowContext.stroke(
|
||||
path,
|
||||
with: .color(Color.blue),
|
||||
style: StrokeStyle(lineWidth: PanelOverlayRingMetrics.lineWidth, lineJoin: .round)
|
||||
)
|
||||
}
|
||||
|
||||
private func drawFlashRing(
|
||||
in context: inout GraphicsContext,
|
||||
rect: CGRect,
|
||||
opacity: Double,
|
||||
reason: WorkspaceAttentionFlashReason
|
||||
) {
|
||||
guard let path = ringPath(for: rect) else { return }
|
||||
let presentation = WorkspaceAttentionCoordinator.flashStyle(for: reason)
|
||||
let strokeColor = Color(nsColor: presentation.accent.strokeColor)
|
||||
|
||||
var glowContext = context
|
||||
glowContext.addFilter(
|
||||
.shadow(
|
||||
color: strokeColor.opacity(opacity * presentation.glowOpacity),
|
||||
radius: presentation.glowRadius
|
||||
)
|
||||
)
|
||||
glowContext.stroke(
|
||||
path,
|
||||
with: .color(strokeColor.opacity(opacity)),
|
||||
style: StrokeStyle(lineWidth: PanelOverlayRingMetrics.lineWidth, lineJoin: .round)
|
||||
)
|
||||
}
|
||||
|
||||
private func ringPath(for rect: CGRect) -> Path? {
|
||||
guard rect.width > PanelOverlayRingMetrics.inset * 2,
|
||||
rect.height > PanelOverlayRingMetrics.inset * 2 else { return nil }
|
||||
return Path(
|
||||
roundedRect: PanelOverlayRingMetrics.pathRect(in: rect),
|
||||
cornerRadius: PanelOverlayRingMetrics.cornerRadius
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// View that renders a Workspace's content using BonsplitView
|
||||
struct WorkspaceContentView: View {
|
||||
@ObservedObject var workspace: Workspace
|
||||
|
|
@ -40,6 +251,7 @@ struct WorkspaceContentView: View {
|
|||
let appearance = PanelAppearance.fromConfig(config)
|
||||
let isSplit = workspace.bonsplitController.allPaneIds.count > 1 ||
|
||||
workspace.panels.count > 1
|
||||
let usesWorkspacePaneOverlay = TmuxOverlayExperimentSettings.target().usesWorkspacePaneOverlay
|
||||
|
||||
// Inactive workspaces are kept alive in a ZStack (for state preservation) but their
|
||||
// AppKit-backed views can still intercept drags. Disable drop acceptance for them.
|
||||
|
|
@ -69,8 +281,11 @@ struct WorkspaceContentView: View {
|
|||
isSelectedInPane: isSelectedInPane,
|
||||
isFocused: isFocused
|
||||
)
|
||||
let hasUnreadNotification = Workspace.shouldShowUnreadIndicator(
|
||||
hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id),
|
||||
let showsNotificationRing = Workspace.shouldShowUnreadIndicator(
|
||||
hasUnreadNotification: notificationStore.hasVisibleNotificationIndicator(
|
||||
forTabId: workspace.id,
|
||||
surfaceId: panel.id
|
||||
),
|
||||
isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id)
|
||||
)
|
||||
PanelContentView(
|
||||
|
|
@ -82,7 +297,7 @@ struct WorkspaceContentView: View {
|
|||
portalPriority: workspacePortalPriority,
|
||||
isSplit: isSplit,
|
||||
appearance: appearance,
|
||||
hasUnreadNotification: hasUnreadNotification,
|
||||
hasUnreadNotification: showsNotificationRing && !usesWorkspacePaneOverlay,
|
||||
onFocus: {
|
||||
// Keep bonsplit focus in sync with the AppKit first responder for the
|
||||
// active workspace. This prevents divergence between the blue focused-tab
|
||||
|
|
@ -165,11 +380,6 @@ struct WorkspaceContentView: View {
|
|||
}
|
||||
|
||||
private func syncBonsplitNotificationBadges() {
|
||||
let unreadFromNotifications: Set<UUID> = Set(
|
||||
notificationStore.notifications
|
||||
.filter { $0.tabId == workspace.id && !$0.isRead }
|
||||
.compactMap { $0.surfaceId }
|
||||
)
|
||||
let manualUnread = workspace.manualUnreadPanelIds
|
||||
|
||||
for paneId in workspace.bonsplitController.allPaneIds {
|
||||
|
|
@ -177,7 +387,10 @@ struct WorkspaceContentView: View {
|
|||
let panelId = workspace.panelIdFromSurfaceId(tab.id)
|
||||
let expectedKind = panelId.flatMap { workspace.panelKind(panelId: $0) }
|
||||
let expectedPinned = panelId.map { workspace.isPanelPinned($0) } ?? false
|
||||
let shouldShow = panelId.map { unreadFromNotifications.contains($0) || manualUnread.contains($0) } ?? false
|
||||
let shouldShow = panelId.map {
|
||||
notificationStore.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: $0) ||
|
||||
manualUnread.contains($0)
|
||||
} ?? false
|
||||
let kindUpdate: String?? = expectedKind.map { .some($0) }
|
||||
|
||||
if tab.showsNotificationBadge != shouldShow ||
|
||||
|
|
@ -198,6 +411,176 @@ struct WorkspaceContentView: View {
|
|||
workspace.bonsplitController.zoomedPaneId.map { "zoom:\($0.id.uuidString)" } ?? "unzoomed"
|
||||
}
|
||||
|
||||
private static let tmuxWorkspacePaneTopChromeHeight: CGFloat = 30
|
||||
|
||||
private enum TmuxWorkspacePaneOverlayTrimMode {
|
||||
case workspaceLocal
|
||||
case windowContent
|
||||
}
|
||||
|
||||
private static func tmuxWorkspacePaneContentRect(
|
||||
_ rect: CGRect,
|
||||
trimMode: TmuxWorkspacePaneOverlayTrimMode
|
||||
) -> CGRect {
|
||||
let topInset = min(tmuxWorkspacePaneTopChromeHeight, max(0, rect.height - 1))
|
||||
switch trimMode {
|
||||
case .workspaceLocal, .windowContent:
|
||||
return CGRect(
|
||||
x: rect.origin.x,
|
||||
y: rect.origin.y + topInset,
|
||||
width: rect.width,
|
||||
height: max(0, rect.height - topInset)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func tmuxWorkspacePaneRect(
|
||||
layoutSnapshot: LayoutSnapshot?,
|
||||
paneId: PaneID?,
|
||||
includeContainerOffset: Bool,
|
||||
trimMode: TmuxWorkspacePaneOverlayTrimMode
|
||||
) -> CGRect? {
|
||||
guard let layoutSnapshot,
|
||||
let paneId,
|
||||
let paneRect = layoutSnapshot.panes
|
||||
.first(where: { $0.paneId == paneId.id.uuidString })?
|
||||
.frame
|
||||
.cgRect else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let rect: CGRect
|
||||
if includeContainerOffset {
|
||||
rect = paneRect.offsetBy(
|
||||
dx: 0,
|
||||
dy: -CGFloat(layoutSnapshot.containerFrame.y)
|
||||
)
|
||||
} else {
|
||||
rect = paneRect.offsetBy(
|
||||
dx: -CGFloat(layoutSnapshot.containerFrame.x),
|
||||
dy: -CGFloat(layoutSnapshot.containerFrame.y)
|
||||
)
|
||||
}
|
||||
return tmuxWorkspacePaneContentRect(rect, trimMode: trimMode)
|
||||
}
|
||||
|
||||
private static func tmuxWorkspacePaneRects(
|
||||
workspace: Workspace,
|
||||
notificationStore: TerminalNotificationStore,
|
||||
layoutSnapshot: LayoutSnapshot?,
|
||||
includeContainerOffset: Bool,
|
||||
trimMode: TmuxWorkspacePaneOverlayTrimMode
|
||||
) -> [CGRect] {
|
||||
guard let layoutSnapshot else { return [] }
|
||||
|
||||
return layoutSnapshot.panes.compactMap { pane in
|
||||
guard let selectedTabId = pane.selectedTabId,
|
||||
let tabUUID = UUID(uuidString: selectedTabId),
|
||||
let panelId = workspace.panelIdFromSurfaceId(TabID(uuid: tabUUID)) 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 = pane.frame.cgRect
|
||||
let rect: CGRect
|
||||
if includeContainerOffset {
|
||||
rect = paneRect.offsetBy(
|
||||
dx: 0,
|
||||
dy: -CGFloat(layoutSnapshot.containerFrame.y)
|
||||
)
|
||||
} else {
|
||||
rect = paneRect.offsetBy(
|
||||
dx: -CGFloat(layoutSnapshot.containerFrame.x),
|
||||
dy: -CGFloat(layoutSnapshot.containerFrame.y)
|
||||
)
|
||||
}
|
||||
return tmuxWorkspacePaneContentRect(rect, trimMode: trimMode)
|
||||
}
|
||||
}
|
||||
|
||||
static func tmuxWorkspacePaneOverlayRect(
|
||||
layoutSnapshot: LayoutSnapshot?,
|
||||
paneId: PaneID?
|
||||
) -> CGRect? {
|
||||
tmuxWorkspacePaneRect(
|
||||
layoutSnapshot: layoutSnapshot,
|
||||
paneId: paneId,
|
||||
includeContainerOffset: false,
|
||||
trimMode: .workspaceLocal
|
||||
)
|
||||
}
|
||||
|
||||
static func tmuxWorkspacePaneWindowOverlayRect(
|
||||
layoutSnapshot: LayoutSnapshot?,
|
||||
paneId: PaneID?
|
||||
) -> CGRect? {
|
||||
tmuxWorkspacePaneRect(
|
||||
layoutSnapshot: layoutSnapshot,
|
||||
paneId: paneId,
|
||||
includeContainerOffset: true,
|
||||
trimMode: .windowContent
|
||||
)
|
||||
}
|
||||
|
||||
static func effectiveTmuxLayoutSnapshot(
|
||||
cachedSnapshot: LayoutSnapshot?,
|
||||
liveSnapshot: LayoutSnapshot?
|
||||
) -> LayoutSnapshot? {
|
||||
if let liveSnapshot,
|
||||
tmuxLayoutSnapshotHasRenderableGeometry(liveSnapshot) {
|
||||
return liveSnapshot
|
||||
}
|
||||
if let cachedSnapshot,
|
||||
tmuxLayoutSnapshotHasRenderableGeometry(cachedSnapshot) {
|
||||
return cachedSnapshot
|
||||
}
|
||||
return cachedSnapshot ?? liveSnapshot
|
||||
}
|
||||
|
||||
static func tmuxWorkspacePaneUnreadRects(
|
||||
workspace: Workspace,
|
||||
notificationStore: TerminalNotificationStore,
|
||||
layoutSnapshot: LayoutSnapshot?
|
||||
) -> [CGRect] {
|
||||
tmuxWorkspacePaneRects(
|
||||
workspace: workspace,
|
||||
notificationStore: notificationStore,
|
||||
layoutSnapshot: layoutSnapshot,
|
||||
includeContainerOffset: false,
|
||||
trimMode: .workspaceLocal
|
||||
)
|
||||
}
|
||||
|
||||
static func tmuxWorkspacePaneWindowUnreadRects(
|
||||
workspace: Workspace,
|
||||
notificationStore: TerminalNotificationStore,
|
||||
layoutSnapshot: LayoutSnapshot?
|
||||
) -> [CGRect] {
|
||||
tmuxWorkspacePaneRects(
|
||||
workspace: workspace,
|
||||
notificationStore: notificationStore,
|
||||
layoutSnapshot: layoutSnapshot,
|
||||
includeContainerOffset: true,
|
||||
trimMode: .windowContent
|
||||
)
|
||||
}
|
||||
|
||||
private static func tmuxLayoutSnapshotHasRenderableGeometry(_ snapshot: LayoutSnapshot) -> Bool {
|
||||
snapshot.containerFrame.width > 1 &&
|
||||
snapshot.containerFrame.height > 1 &&
|
||||
snapshot.panes.contains { pane in
|
||||
pane.frame.width > 1 && pane.frame.height > 1
|
||||
}
|
||||
}
|
||||
|
||||
static func resolveGhosttyAppearanceConfig(
|
||||
reason: String = "unspecified",
|
||||
backgroundOverride: NSColor? = nil,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue