Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions Sources/CodexBar/QuotaWarningAlertOverlayController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import AppKit
import CodexBarCore
import SwiftUI

/// 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 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 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))
// A newer alert cancels this task in `show()`; bail out so we don't
// tear down the replacement overlay that took our place.
guard !Task.isCancelled else { return }
self?.dismiss()
Comment on lines +60 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid dismissing replacement alerts from canceled tasks

When another warning arrives while an overlay is visible, show cancels the previous dismissalTask before assigning the replacement window; because this task swallows CancellationError from Task.sleep and then still calls dismiss(), the canceled old task can resume after the new window is stored and immediately close the new alert. This is reachable for back-to-back session/weekly threshold crossings in one refresh, so the on-screen alert can flash and disappear instead of staying up for 4.5 seconds; return on cancellation or gate dismissal to the task/window that scheduled it.

Useful? React with 👍 / 👎.

}
}

func dismiss() {
self.dismissalTask?.cancel()
self.dismissalTask = nil

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

@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.appeared ? 1 : 0.92)
.opacity(self.appeared ? 1 : 0)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.padding(40)
.allowsHitTesting(false)
.task {
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
self.appeared = true
}
}
}
}
6 changes: 6 additions & 0 deletions Sources/CodexBar/QuotaWarningSettingsViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ struct GlobalQuotaWarningSettingsView: View {
.font(.footnote)
}
.toggleStyle(.checkbox)

Toggle(isOn: self.$settings.quotaWarningOnScreenAlertEnabled) {
Text(L("quota_warning_onscreen_alert"))
.font(.footnote)
}
.toggleStyle(.checkbox)
}
.padding(.leading, 20)
}
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,7 @@
"quota_warning_warn_at" = "Warn at";
"quota_warning_global_threshold_subtitle" = "Remaining percentages for session and weekly windows unless a provider overrides them.";
"quota_warning_sound" = "Play notification sound";
"quota_warning_onscreen_alert" = "Show on-screen text alert";
"quota_warning_provider_inherits" = "Uses the global quota warning settings unless a window is customized here.";
"quota_warning_customize_thresholds" = "Customize %@ thresholds";
"quota_warning_enable_warnings" = "Enable %@ warnings";
Expand Down
17 changes: 15 additions & 2 deletions Sources/CodexBar/SessionQuotaNotifications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -194,12 +194,17 @@ extension UsageStore {
@MainActor
protocol SessionQuotaNotifying: AnyObject {
func post(transition: SessionQuotaTransition, provider: UsageProvider, badge: NSNumber?)
func postQuotaWarning(event: QuotaWarningEvent, provider: UsageProvider, soundEnabled: Bool)
func postQuotaWarning(
event: QuotaWarningEvent,
provider: UsageProvider,
soundEnabled: Bool,
onScreenAlertEnabled: Bool)
}

@MainActor
final class SessionQuotaNotifier: SessionQuotaNotifying {
private let logger = CodexBarLog.logger(LogCategories.sessionQuotaNotifications)
private lazy var alertOverlay = QuotaWarningAlertOverlayController()

init() {}

Expand All @@ -219,7 +224,12 @@ final class SessionQuotaNotifier: SessionQuotaNotifying {
AppNotifications.shared.post(idPrefix: idPrefix, title: title, body: body, badge: badge)
}

func postQuotaWarning(event: QuotaWarningEvent, provider: UsageProvider, soundEnabled: Bool = true) {
func postQuotaWarning(
event: QuotaWarningEvent,
provider: UsageProvider,
soundEnabled: Bool = true,
onScreenAlertEnabled: Bool = false)
{
let providerName = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName
let threshold = event.threshold
let copy = QuotaWarningNotificationLogic.notificationCopy(
Expand All @@ -233,6 +243,9 @@ final class SessionQuotaNotifier: SessionQuotaNotifying {
if soundEnabled {
(NSSound(named: "Glass") ?? NSSound(named: "Ping"))?.play()
}
if onScreenAlertEnabled {
self.alertOverlay.show(title: copy.title, message: copy.body)
}
NotificationCenter.default.post(
name: .codexbarQuotaWarningDidPost,
object: QuotaWarningPostedEvent(
Expand Down
8 changes: 8 additions & 0 deletions Sources/CodexBar/SettingsStore+Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,14 @@ extension SettingsStore {
}
}

var quotaWarningOnScreenAlertEnabled: Bool {
get { self.defaultsState.quotaWarningOnScreenAlertEnabled }
set {
self.defaultsState.quotaWarningOnScreenAlertEnabled = newValue
self.userDefaults.set(newValue, forKey: "quotaWarningOnScreenAlertEnabled")
}
}

var quotaWarningMarkersVisible: Bool {
get { self.defaultsState.quotaWarningMarkersVisible }
set {
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/SettingsStore+MenuObservation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ extension SettingsStore {
_ = self.quotaWarningWindowEnabled(.session)
_ = self.quotaWarningWindowEnabled(.weekly)
_ = self.quotaWarningSoundEnabled
_ = self.quotaWarningOnScreenAlertEnabled
_ = self.quotaWarningMarkersVisible
_ = self.weeklyProgressWorkDays
_ = self.usageBarsShowUsed
Expand Down
11 changes: 10 additions & 1 deletion Sources/CodexBar/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,7 @@ extension SettingsStore {
quotaWarningSessionEnabled: quotaWarnings.sessionEnabled,
quotaWarningWeeklyEnabled: quotaWarnings.weeklyEnabled,
quotaWarningSoundEnabled: quotaWarnings.soundEnabled,
quotaWarningOnScreenAlertEnabled: quotaWarnings.onScreenAlertEnabled,
quotaWarningMarkersVisible: quotaWarningMarkersVisible,
weeklyProgressWorkDays: weeklyProgressWorkDays,
usageBarsShowUsed: usageBarsShowUsed,
Expand Down Expand Up @@ -543,6 +544,7 @@ extension SettingsStore {
var sessionEnabled: Bool
var weeklyEnabled: Bool
var soundEnabled: Bool
var onScreenAlertEnabled: Bool
}

private static func loadQuotaWarningDefaults(userDefaults: UserDefaults) -> LoadedQuotaWarningDefaults {
Expand Down Expand Up @@ -581,14 +583,21 @@ extension SettingsStore {
userDefaults.set(true, forKey: "quotaWarningSoundEnabled")
}

let onScreenAlertDefault = userDefaults.object(forKey: "quotaWarningOnScreenAlertEnabled") as? Bool
let onScreenAlertEnabled = onScreenAlertDefault ?? false
if Self.isRunningTests, onScreenAlertDefault == nil {
userDefaults.set(false, forKey: "quotaWarningOnScreenAlertEnabled")
}

return LoadedQuotaWarningDefaults(
notificationsEnabled: notificationsEnabled,
thresholdsRaw: thresholdsRaw,
sessionThresholdsRaw: sessionThresholdsRaw,
weeklyThresholdsRaw: weeklyThresholdsRaw,
sessionEnabled: sessionEnabled,
weeklyEnabled: weeklyEnabled,
soundEnabled: soundEnabled)
soundEnabled: soundEnabled,
onScreenAlertEnabled: onScreenAlertEnabled)
}
}

Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/SettingsStoreState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ struct SettingsDefaultsState {
var quotaWarningSessionEnabled: Bool
var quotaWarningWeeklyEnabled: Bool
var quotaWarningSoundEnabled: Bool
var quotaWarningOnScreenAlertEnabled: Bool
var quotaWarningMarkersVisible: Bool
var weeklyProgressWorkDays: Int?
var usageBarsShowUsed: Bool
Expand Down
3 changes: 2 additions & 1 deletion Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -785,7 +785,8 @@ final class UsageStore {
self.sessionQuotaNotifier.postQuotaWarning(
event: event,
provider: provider,
soundEnabled: self.settings.quotaWarningSoundEnabled)
soundEnabled: self.settings.quotaWarningSoundEnabled,
onScreenAlertEnabled: self.settings.quotaWarningOnScreenAlertEnabled)
}

func handleSessionQuotaTransition(provider: UsageProvider, snapshot: UsageSnapshot) {
Expand Down
7 changes: 6 additions & 1 deletion Tests/CodexBarTests/CommandCodeQuotaTransitionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,12 @@ struct CommandCodeQuotaTransitionTests {
self.posts.append((transition, provider))
}

func postQuotaWarning(event: QuotaWarningEvent, provider _: UsageProvider, soundEnabled _: Bool) {
func postQuotaWarning(
event: QuotaWarningEvent,
provider _: UsageProvider,
soundEnabled _: Bool,
onScreenAlertEnabled _: Bool)
{
self.quotaWarningPosts.append(event)
}
}
Expand Down
16 changes: 13 additions & 3 deletions Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,24 @@ struct UsageStoreSessionQuotaTransitionTests {
private(set) var quotaWarningPosts: [(
event: QuotaWarningEvent,
provider: UsageProvider,
soundEnabled: Bool)] = []
soundEnabled: Bool,
onScreenAlertEnabled: Bool)] = []

func post(transition: SessionQuotaTransition, provider: UsageProvider, badge _: NSNumber?) {
self.posts.append((transition: transition, provider: provider))
}

func postQuotaWarning(event: QuotaWarningEvent, provider: UsageProvider, soundEnabled: Bool) {
self.quotaWarningPosts.append((event: event, provider: provider, soundEnabled: soundEnabled))
func postQuotaWarning(
event: QuotaWarningEvent,
provider: UsageProvider,
soundEnabled: Bool,
onScreenAlertEnabled: Bool)
{
self.quotaWarningPosts.append((
event: event,
provider: provider,
soundEnabled: soundEnabled,
onScreenAlertEnabled: onScreenAlertEnabled))
}
}

Expand Down