Render unread notification ring above portal-hosted terminal view
This commit is contained in:
parent
4588c3b0d1
commit
eb5c52d239
3 changed files with 81 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue