Files
steipete--codexbar/Sources/CodexBar/QuotaWarningAlertOverlayController.swift
T
2026-07-13 12:22:33 +08:00

179 lines
5.9 KiB
Swift

import AppKit
import CodexBarCore
import SwiftUI
struct QuotaWarningAlertPresentationState {
struct Presentation: Equatable {
let generation: UInt
let title: String
let message: String
}
private(set) var current: Presentation?
private var nextGeneration: UInt = 0
mutating func present(title: String, message: String) -> Presentation {
self.nextGeneration &+= 1
let presentation = Presentation(
generation: self.nextGeneration,
title: title,
message: message)
self.current = presentation
return presentation
}
mutating func dismiss(generation: UInt) -> Bool {
guard self.current?.generation == generation else { return false }
self.current = nil
return true
}
mutating func dismiss() {
self.current = nil
}
}
/// Presents a transient, centered text alert when a quota warning threshold is crossed.
///
/// Modeled after ``ScreenConfettiOverlayController``: it shows a borderless, click-through
/// panel above all spaces and auto-dismisses after a short lifetime, so it never steals focus
/// or blocks the user's work.
@MainActor
final class QuotaWarningAlertOverlayController {
private static let overlayLifetime: TimeInterval = 4.5
private let logger = CodexBarLog.logger(LogCategories.sessionQuotaNotifications)
private var presentationState = QuotaWarningAlertPresentationState()
private var window: NSWindow?
private var dismissalTask: Task<Void, Never>?
func show(title: String, message: String) {
self.dismiss()
guard let screen = NSScreen.main ?? NSScreen.screens.first else {
self.logger.error("Cannot present quota warning overlay because no screens were found")
return
}
let presentation = self.presentationState.present(title: title, message: message)
let frame = screen.frame
let contentView = QuotaWarningAlertOverlayView(title: title, message: message)
.allowsHitTesting(false)
let hostingView = NSHostingView(rootView: contentView)
hostingView.wantsLayer = true
hostingView.layer?.backgroundColor = NSColor.clear.cgColor
let window = ClickThroughAlertPanel(
contentRect: frame,
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false,
screen: screen)
window.contentView = hostingView
window.level = .statusBar
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle, .stationary]
window.backgroundColor = .clear
window.isOpaque = false
window.hasShadow = false
window.ignoresMouseEvents = true
window.acceptsMouseMovedEvents = false
window.isMovable = false
window.isReleasedWhenClosed = false
window.canHide = false
window.hidesOnDeactivate = false
window.becomesKeyOnlyIfNeeded = false
window.isExcludedFromWindowsMenu = true
window.setFrame(frame, display: false)
window.orderFrontRegardless()
self.window = window
self.logger.info("Presenting quota warning overlay")
self.dismissalTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(Self.overlayLifetime))
guard !Task.isCancelled else { return }
guard let self, self.presentationState.dismiss(generation: presentation.generation) else { return }
self.closeWindow()
}
}
func dismiss() {
self.dismissalTask?.cancel()
self.dismissalTask = nil
self.presentationState.dismiss()
self.closeWindow()
}
private func closeWindow() {
guard let window = self.window else { return }
window.orderOut(nil)
window.close()
self.window = nil
}
}
private final class ClickThroughAlertPanel: NSPanel {
override var canBecomeKey: Bool {
false
}
override var canBecomeMain: Bool {
false
}
override var acceptsFirstResponder: Bool {
false
}
}
private struct QuotaWarningAlertOverlayView: View {
let title: String
let message: String
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var appeared = false
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.title2)
.foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 4) {
Text(self.title)
.font(.headline)
Text(self.message)
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.vertical, 16)
.padding(.horizontal, 20)
.frame(maxWidth: 420, alignment: .leading)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(Color.primary.opacity(0.08)))
.shadow(color: .black.opacity(0.25), radius: 24, y: 8)
.scaleEffect(self.reduceMotion || self.appeared ? 1 : 0.92)
.opacity(self.reduceMotion || self.appeared ? 1 : 0)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.padding(40)
.allowsHitTesting(false)
.accessibilityElement(children: .combine)
.accessibilityLabel(self.title)
.accessibilityValue(self.message)
.task {
guard !self.reduceMotion else {
self.appeared = true
return
}
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
self.appeared = true
}
}
}
}