Render unread notification ring above portal-hosted terminal view

This commit is contained in:
Lawrence Chen 2026-02-20 02:41:31 -08:00
parent 4588c3b0d1
commit eb5c52d239
3 changed files with 81 additions and 13 deletions

View file

@ -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

View file

@ -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(

View file

@ -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