diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index d1818956..ba484de9 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2781,6 +2781,8 @@ final class GhosttySurfaceScrollView: NSView { private let surfaceView: GhosttyNSView private let inactiveOverlayView: GhosttyFlashOverlayView private let dropZoneOverlayView: GhosttyFlashOverlayView + private let notificationRingOverlayView: GhosttyFlashOverlayView + private let notificationRingLayer: CAShapeLayer private let flashOverlayView: GhosttyFlashOverlayView private let flashLayer: CAShapeLayer private var observers: [NSObjectProtocol] = [] @@ -2871,6 +2873,8 @@ final class GhosttySurfaceScrollView: NSView { scrollView = GhosttyScrollView() inactiveOverlayView = GhosttyFlashOverlayView(frame: .zero) dropZoneOverlayView = GhosttyFlashOverlayView(frame: .zero) + notificationRingOverlayView = GhosttyFlashOverlayView(frame: .zero) + notificationRingLayer = CAShapeLayer() flashOverlayView = GhosttyFlashOverlayView(frame: .zero) flashLayer = CAShapeLayer() scrollView.hasVerticalScroller = true @@ -2909,6 +2913,23 @@ final class GhosttySurfaceScrollView: NSView { dropZoneOverlayView.layer?.cornerRadius = 8 dropZoneOverlayView.isHidden = true addSubview(dropZoneOverlayView) + notificationRingOverlayView.wantsLayer = true + notificationRingOverlayView.layer?.backgroundColor = NSColor.clear.cgColor + notificationRingOverlayView.layer?.masksToBounds = false + notificationRingOverlayView.autoresizingMask = [.width, .height] + notificationRingLayer.fillColor = NSColor.clear.cgColor + notificationRingLayer.strokeColor = NSColor.systemBlue.cgColor + notificationRingLayer.lineWidth = 2.5 + notificationRingLayer.lineJoin = .round + notificationRingLayer.lineCap = .round + notificationRingLayer.shadowColor = NSColor.systemBlue.cgColor + notificationRingLayer.shadowOpacity = 0.35 + notificationRingLayer.shadowRadius = 3 + notificationRingLayer.shadowOffset = .zero + notificationRingLayer.opacity = 0 + notificationRingOverlayView.layer?.addSublayer(notificationRingLayer) + notificationRingOverlayView.isHidden = true + addSubview(notificationRingOverlayView) flashOverlayView.wantsLayer = true flashOverlayView.layer?.backgroundColor = NSColor.clear.cgColor flashOverlayView.layer?.masksToBounds = false @@ -3025,7 +3046,9 @@ final class GhosttySurfaceScrollView: NSView { if let zone = activeDropZone { dropZoneOverlayView.frame = dropZoneOverlayFrame(for: zone, in: bounds.size) } + notificationRingOverlayView.frame = bounds flashOverlayView.frame = bounds + updateNotificationRingPath() updateFlashPath() synchronizeScrollView() synchronizeSurfaceView() @@ -3083,6 +3106,21 @@ final class GhosttySurfaceScrollView: NSView { CATransaction.commit() } + func setNotificationRing(visible: Bool) { + if !Thread.isMainThread { + DispatchQueue.main.async { [weak self] in + self?.setNotificationRing(visible: visible) + } + return + } + + CATransaction.begin() + CATransaction.setDisableActions(true) + notificationRingOverlayView.isHidden = !visible + notificationRingLayer.opacity = visible ? 1 : 0 + CATransaction.commit() + } + private func dropZoneOverlayFrame(for zone: DropZone, in size: CGSize) -> CGRect { let padding: CGFloat = 4 switch zone { @@ -3295,6 +3333,13 @@ final class GhosttySurfaceScrollView: NSView { ) } + func debugNotificationRingState() -> (isHidden: Bool, opacity: Float) { + ( + notificationRingOverlayView.isHidden, + notificationRingLayer.opacity + ) + } + #endif /// Handle file/URL drops, forwarding to the terminal as shell-escaped paths. @@ -3717,17 +3762,24 @@ final class GhosttySurfaceScrollView: NSView { surfaceView.frame.origin = visibleRect.origin } + private func updateNotificationRingPath() { + updateOverlayRingPath(layer: notificationRingLayer, bounds: notificationRingOverlayView.bounds) + } + private func updateFlashPath() { + updateOverlayRingPath(layer: flashLayer, bounds: flashOverlayView.bounds) + } + + private func updateOverlayRingPath(layer: CAShapeLayer, bounds: CGRect) { let inset: CGFloat = 2 let radius: CGFloat = 6 - let bounds = flashOverlayView.bounds - flashLayer.frame = bounds + layer.frame = bounds guard bounds.width > inset * 2, bounds.height > inset * 2 else { - flashLayer.path = nil + layer.path = nil return } let rect = bounds.insetBy(dx: inset, dy: inset) - flashLayer.path = CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil) + layer.path = CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil) } private func synchronizeScrollView() { @@ -3945,6 +3997,7 @@ struct GhosttyTerminalView: NSViewRepresentable { var isVisibleInUI: Bool = true var portalZPriority: Int = 0 var showsInactiveOverlay: Bool = false + var showsUnreadNotificationRing: Bool = false var inactiveOverlayColor: NSColor = .clear var inactiveOverlayOpacity: Double = 0 var reattachToken: UInt64 = 0 @@ -3987,6 +4040,7 @@ struct GhosttyTerminalView: NSViewRepresentable { // Track the latest desired state so attach retries can re-apply focus after re-parenting. var desiredIsActive: Bool = true var desiredIsVisibleInUI: Bool = true + var desiredShowsUnreadNotificationRing: Bool = false var desiredPortalZPriority: Int = 0 var lastBoundHostId: ObjectIdentifier? weak var hostedView: GhosttySurfaceScrollView? @@ -4009,9 +4063,11 @@ struct GhosttyTerminalView: NSViewRepresentable { let previousDesiredIsActive = coordinator.desiredIsActive #endif let previousDesiredIsVisibleInUI = coordinator.desiredIsVisibleInUI + let previousDesiredShowsUnreadNotificationRing = coordinator.desiredShowsUnreadNotificationRing let previousDesiredPortalZPriority = coordinator.desiredPortalZPriority coordinator.desiredIsActive = isActive coordinator.desiredIsVisibleInUI = isVisibleInUI + coordinator.desiredShowsUnreadNotificationRing = showsUnreadNotificationRing coordinator.desiredPortalZPriority = portalZPriority coordinator.hostedView = hostedView #if DEBUG @@ -4043,6 +4099,7 @@ struct GhosttyTerminalView: NSViewRepresentable { opacity: CGFloat(inactiveOverlayOpacity), visible: showsInactiveOverlay ) + hostedView.setNotificationRing(visible: showsUnreadNotificationRing) hostedView.setFocusHandler { onFocus?(terminalSurface.id) } hostedView.setTriggerFlashHandler(onTriggerFlash) hostedView.setDropZoneOverlay(zone: paneDropZone) @@ -4064,6 +4121,7 @@ struct GhosttyTerminalView: NSViewRepresentable { coordinator.lastBoundHostId = ObjectIdentifier(host) hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI) hostedView.setActive(coordinator.desiredIsActive) + hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing) } host.onGeometryChanged = { [weak host, weak coordinator] in guard let host, let coordinator else { return } @@ -4078,6 +4136,7 @@ struct GhosttyTerminalView: NSViewRepresentable { coordinator.lastBoundHostId != hostId || hostedView.superview == nil || previousDesiredIsVisibleInUI != isVisibleInUI || + previousDesiredShowsUnreadNotificationRing != showsUnreadNotificationRing || previousDesiredPortalZPriority != portalZPriority if shouldBindNow { TerminalWindowPortalRegistry.bind( @@ -4105,6 +4164,7 @@ struct GhosttyTerminalView: NSViewRepresentable { coordinator.attachGeneration += 1 coordinator.desiredIsActive = false coordinator.desiredIsVisibleInUI = false + coordinator.desiredShowsUnreadNotificationRing = false coordinator.desiredPortalZPriority = 0 coordinator.lastBoundHostId = nil let hostedView = coordinator.hostedView diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index 2ccc3c9f..ce0ca87d 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -22,6 +22,7 @@ struct TerminalPanelView: View { isVisibleInUI: isVisibleInUI, portalZPriority: portalPriority, showsInactiveOverlay: isSplit && !isFocused, + showsUnreadNotificationRing: hasUnreadNotification, inactiveOverlayColor: appearance.unfocusedOverlayNSColor, inactiveOverlayOpacity: appearance.unfocusedOverlayOpacity, reattachToken: panel.viewReattachToken, @@ -33,15 +34,6 @@ struct TerminalPanelView: View { .id(panel.id) .background(Color.clear) - // Unread notification indicator - if hasUnreadNotification { - Rectangle() - .stroke(Color(nsColor: .systemBlue), lineWidth: 2.5) - .shadow(color: Color(nsColor: .systemBlue).opacity(0.35), radius: 3) - .padding(2) - .allowsHitTesting(false) - } - // Search overlay if let searchState = panel.searchState { SurfaceSearchOverlay( diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 1630f254..11059b44 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1962,6 +1962,22 @@ final class GhosttySurfaceOverlayTests: XCTestCase { state = hostedView.debugInactiveOverlayState() XCTAssertTrue(state.isHidden) } + + func testUnreadNotificationRingVisibilityTracksRequestedState() { + let hostedView = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 80, height: 50)) + ) + + hostedView.setNotificationRing(visible: true) + var state = hostedView.debugNotificationRingState() + XCTAssertFalse(state.isHidden) + XCTAssertEqual(state.opacity, 1, accuracy: 0.001) + + hostedView.setNotificationRing(visible: false) + state = hostedView.debugNotificationRingState() + XCTAssertTrue(state.isHidden) + XCTAssertEqual(state.opacity, 0, accuracy: 0.001) + } } @MainActor