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

613 lines
21 KiB
Swift

import CodexBarCore
#if os(macOS)
import AppKit
#endif
import SwiftUI
struct QuotaWarningSettingsVisibility: Equatable {
let showsThresholdControls: Bool
let showsDeliveryControls: Bool
init(thresholdWarningsEnabled: Bool, predictiveWarningsEnabled: Bool) {
self.showsThresholdControls = thresholdWarningsEnabled
self.showsDeliveryControls = thresholdWarningsEnabled || predictiveWarningsEnabled
}
}
@MainActor
struct GlobalQuotaWarningSettingsView: View {
@Bindable var settings: SettingsStore
let showsThresholdControls: Bool
init(settings: SettingsStore, showsThresholdControls: Bool = true) {
self.settings = settings
self.showsThresholdControls = showsThresholdControls
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
if self.showsThresholdControls {
QuotaWarningWindowThresholdRows(settings: self.settings)
Text(L("quota_warning_global_threshold_subtitle"))
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Toggle(isOn: self.$settings.quotaWarningSoundEnabled) {
Text(L("quota_warning_sound"))
.font(.footnote)
}
.toggleStyle(.checkbox)
Toggle(isOn: self.$settings.quotaWarningOnScreenAlertEnabled) {
Text(L("quota_warning_onscreen_alert"))
.font(.footnote)
}
.toggleStyle(.checkbox)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 22)
.background(FocusResigningBackground())
.listRowSeparator(.hidden)
}
}
@MainActor
struct ProviderQuotaWarningSettingsView: View {
private static let windowRowMinHeight: CGFloat = 26
private static let thresholdFieldWidth: CGFloat = 40
let provider: UsageProvider
@Bindable var settings: SettingsStore
var body: some View {
Section {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
self.windowRow(.session)
self.windowRow(.weekly)
}
.frame(maxWidth: .infinity, alignment: .leading)
.listRowSeparator(.hidden)
.disabled(!self.controlsEnabled)
.opacity(self.controlsEnabled ? 1 : 0.45)
} header: {
Text(L("quota_warnings_title"))
} footer: {
SettingsSectionFooter(self.footerText)
}
.background(FocusResigningBackground())
}
var controlsEnabled: Bool {
self.settings.quotaWarningNotificationsEnabled || self.settings.quotaWarningMarkersVisible
}
var footerText: String {
if self.settings.quotaWarningNotificationsEnabled {
return L("quota_warning_provider_inherits")
}
if self.settings.quotaWarningMarkersVisible {
return L("quota_warning_provider_markers_only")
}
return L("quota_warning_provider_disabled")
}
private func windowRow(_ window: QuotaWarningWindow) -> some View {
GridRow(alignment: .firstTextBaseline) {
Text(window.localizedCapitalizedDisplayName)
.font(.subheadline.weight(.semibold))
.fixedSize(horizontal: true, vertical: false)
.frame(minHeight: Self.windowRowMinHeight, alignment: .center)
.gridColumnAlignment(.leading)
Picker(window.localizedCapitalizedDisplayName, selection: self.overrideModeBinding(for: window)) {
Text(L("quota_warning_global")).tag(ProviderQuotaWarningOverrideMode.global)
Text(L("Custom")).tag(ProviderQuotaWarningOverrideMode.custom)
Text(L("quota_warning_off")).tag(ProviderQuotaWarningOverrideMode.off)
}
.labelsHidden()
.pickerStyle(.segmented)
.controlSize(.small)
.fixedSize()
.frame(minHeight: Self.windowRowMinHeight, alignment: .center)
.gridColumnAlignment(.leading)
self.windowDetail(window)
.frame(minHeight: Self.windowRowMinHeight, alignment: .leading)
.gridColumnAlignment(.leading)
}
}
@ViewBuilder
private func windowDetail(_ window: QuotaWarningWindow) -> some View {
switch self.overrideMode(for: window) {
case .custom:
QuotaWarningThresholdField(
title: "",
subtitle: "",
accessibilityContext: window.localizedCapitalizedDisplayName,
shouldCommitOnDisappear: {
self.shouldCommitThresholdEditorOnDisappear(for: window)
},
thresholds: {
self.settings.resolvedQuotaWarningThresholds(provider: self.provider, window: window)
},
setThresholds: {
self.settings.setQuotaWarningThresholdsIfOverridden(
provider: self.provider,
window: window,
thresholds: $0)
},
fieldWidth: Self.thresholdFieldWidth,
controlFont: .subheadline)
.fixedSize(horizontal: true, vertical: false)
case .off:
EmptyView()
case .global:
Text(String(format: L("quota_warning_inherited"), Self.thresholdText(
self.settings.quotaWarningThresholds(window),
enabled: self.settings.quotaWarningWindowEnabled(window))))
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
func overrideModeBinding(for window: QuotaWarningWindow) -> Binding<ProviderQuotaWarningOverrideMode> {
Binding(
get: { self.overrideMode(for: window) },
set: { mode in
let currentMode = self.overrideMode(for: window)
guard mode != currentMode else { return }
switch mode {
case .custom:
self.settings.setQuotaWarningOverride(
provider: self.provider,
window: window,
thresholds: self.settings.explicitQuotaWarningThresholds(
provider: self.provider,
window: window),
enabled: true)
case .off:
self.settings.setQuotaWarningOverride(
provider: self.provider,
window: window,
thresholds: currentMode == .custom
? self.settings.explicitQuotaWarningThresholds(
provider: self.provider,
window: window)
: nil,
enabled: false)
case .global:
self.settings.setQuotaWarningOverride(
provider: self.provider,
window: window,
thresholds: nil,
enabled: nil)
}
})
}
func overrideMode(for window: QuotaWarningWindow) -> ProviderQuotaWarningOverrideMode {
guard self.settings.hasQuotaWarningOverride(provider: self.provider, window: window) else {
return .global
}
return self.settings.quotaWarningEnabled(provider: self.provider, window: window) ? .custom : .off
}
func shouldCommitThresholdEditorOnDisappear(for window: QuotaWarningWindow) -> Bool {
let mode = self.overrideMode(for: window)
return mode == .custom || mode == .off
}
static func thresholdText(_ thresholds: [Int], enabled: Bool) -> String {
guard enabled else { return L("quota_warning_off") }
let activeThresholds = QuotaWarningThresholds.active(thresholds)
guard let upperThreshold = activeThresholds.first else {
return L("quota_warning_depleted_only")
}
var parts: [String] = []
parts.append("\(L("quota_warning_warning")) \(upperThreshold)%")
if let lowerThreshold = activeThresholds.dropFirst().first {
parts.append("\(L("quota_warning_critical")) \(lowerThreshold)%")
}
parts.append(contentsOf: activeThresholds.dropFirst(2).map { "\($0)%" })
return parts.joined(separator: ", ")
}
}
enum ProviderQuotaWarningOverrideMode: Hashable {
case global
case custom
case off
}
struct FocusResigningBackground: View {
var body: some View {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
#if os(macOS)
NSApplication.shared.keyWindow?.makeFirstResponder(nil)
#endif
}
}
}
extension QuotaWarningWindow {
var localizedCapitalizedDisplayName: String {
switch self {
case .session: L("quota_warning_session_capitalized")
case .weekly: L("quota_warning_weekly_capitalized")
}
}
}
@MainActor
private struct QuotaWarningWindowThresholdRows: View {
@Bindable var settings: SettingsStore
var body: some View {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
self.windowThresholdRow(.session)
self.windowThresholdRow(.weekly)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func windowThresholdRow(_ window: QuotaWarningWindow) -> some View {
GridRow(alignment: .firstTextBaseline) {
Toggle(isOn: Binding(
get: { self.settings.quotaWarningWindowEnabled(window) },
set: { self.settings.setQuotaWarningWindowEnabled(window, enabled: $0) }))
{
Text(window.localizedCapitalizedDisplayName)
.font(.footnote.weight(.semibold))
.fixedSize(horizontal: true, vertical: false)
}
.toggleStyle(.checkbox)
.gridColumnAlignment(.leading)
QuotaWarningThresholdField(
title: "",
subtitle: "",
accessibilityContext: window.localizedCapitalizedDisplayName,
thresholds: { self.settings.quotaWarningThresholds(window) },
setThresholds: { self.settings.setQuotaWarningThresholds(window, thresholds: $0) })
.disabled(!self.settings.quotaWarningWindowEnabled(window))
.opacity(self.settings.quotaWarningWindowEnabled(window) ? 1 : 0.45)
.gridColumnAlignment(.leading)
}
}
}
@MainActor
private struct QuotaWarningThresholdField: View {
private static let defaultFieldWidth: CGFloat = 44
let title: String
let subtitle: String
var accessibilityContext: String = ""
var shouldCommitOnDisappear: () -> Bool = { true }
let thresholds: () -> [Int]
let setThresholds: ([Int]) -> Void
var fieldWidth: CGFloat = Self.defaultFieldWidth
var controlFont: Font = .footnote
var titleFont: Font = .footnote.weight(.semibold)
@State private var draft = QuotaWarningThresholdEditorText.Draft()
@FocusState private var focusedField: QuotaWarningThresholdEditorText.Field?
var body: some View {
VStack(alignment: .leading, spacing: 7) {
self.horizontalEditor
if !self.subtitle.isEmpty {
Text(self.subtitle)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.onAppear { self.updateText(from: self.thresholds()) }
.onChange(of: self.focusedField) { previous, current in
if previous != nil, current == nil {
self.commit()
}
}
.onChange(of: self.thresholds()) { _, value in
if self.focusedField == nil {
self.updateText(from: value)
}
}
.onDisappear {
if self.shouldCommitOnDisappear() {
self.commit()
}
}
.background(self.focusMonitor)
}
private var horizontalEditor: some View {
HStack(alignment: .firstTextBaseline, spacing: 10) {
self.titleView
self.upperField
self.lowerField
}
.fixedSize(horizontal: true, vertical: false)
}
@ViewBuilder
private var titleView: some View {
if !self.title.isEmpty {
Text(self.title)
.font(self.titleFont)
.frame(width: 110, alignment: .leading)
}
}
private var upperField: some View {
self.thresholdInput(
label: L("quota_warning_warning"),
placeholder: "50",
text: self.thresholdTextBinding(.upper),
field: .upper)
}
private var lowerField: some View {
self.thresholdInput(
label: L("quota_warning_critical"),
placeholder: "20",
text: self.thresholdTextBinding(.lower),
field: .lower)
}
private func thresholdInput(
label: String,
placeholder: String,
text: Binding<String>,
field: QuotaWarningThresholdEditorText.Field) -> some View
{
HStack(alignment: .firstTextBaseline, spacing: 5) {
Text(label)
.font(self.controlFont)
.foregroundStyle(.secondary)
TextField(label, text: text, prompt: Text(verbatim: placeholder))
.labelsHidden()
.textFieldStyle(.roundedBorder)
.font(self.controlFont)
.multilineTextAlignment(.trailing)
.frame(width: self.fieldWidth)
.focused(self.$focusedField, equals: field)
.onSubmit {
self.commit()
self.focusedField = nil
}
.accessibilityLabel(Text(self.accessibilityLabel(for: label)))
Text(verbatim: "%")
.font(self.controlFont)
.foregroundStyle(.secondary)
}
}
private func thresholdTextBinding(_ field: QuotaWarningThresholdEditorText.Field) -> Binding<String> {
Binding(
get: { self.draft.text(for: field) },
set: { self.draft.setText($0, for: field) })
}
private func commit() {
guard let sanitized = self.draft.takeResolvedThresholds() else { return }
self.setThresholds(sanitized)
self.updateText(from: sanitized)
}
private func updateText(from thresholds: [Int]) {
self.draft.update(from: thresholds)
}
private func accessibilityLabel(for label: String) -> String {
let context = self.title.isEmpty ? self.accessibilityContext : self.title
guard !context.isEmpty else { return label }
return "\(context), \(label)"
}
@ViewBuilder
private var focusMonitor: some View {
#if os(macOS)
QuotaWarningFocusMonitor(isActive: self.focusedField != nil) {
NSApplication.shared.keyWindow?.makeFirstResponder(nil)
self.focusedField = nil
}
#else
EmptyView()
#endif
}
}
#if os(macOS)
private struct QuotaWarningFocusMonitor: NSViewRepresentable {
let isActive: Bool
let onOutsideClick: () -> Void
func makeNSView(context: Context) -> QuotaWarningFocusMonitorView {
let view = QuotaWarningFocusMonitorView()
view.isActive = self.isActive
view.onOutsideClick = self.onOutsideClick
return view
}
func updateNSView(_ nsView: QuotaWarningFocusMonitorView, context: Context) {
nsView.isActive = self.isActive
nsView.onOutsideClick = self.onOutsideClick
}
static func dismantleNSView(_ nsView: QuotaWarningFocusMonitorView, coordinator: ()) {
nsView.invalidate()
}
}
private final class QuotaWarningFocusMonitorView: NSView {
var onOutsideClick: (() -> Void)?
var isActive: Bool = false {
didSet { self.updateMonitor() }
}
private var monitor: Any?
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
self.wantsLayer = false
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func invalidate() {
self.isActive = false
self.onOutsideClick = nil
}
private func updateMonitor() {
if self.isActive {
self.installMonitor()
} else {
self.removeMonitor()
}
}
private func installMonitor() {
guard self.monitor == nil else { return }
self.monitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in
self?.handle(event)
return event
}
}
private func removeMonitor() {
if let monitor {
NSEvent.removeMonitor(monitor)
self.monitor = nil
}
}
private func handle(_ event: NSEvent) {
guard self.isActive else { return }
guard let window = self.window, event.window === window else { return }
let location = self.convert(event.locationInWindow, from: nil)
guard !self.bounds.contains(location) else { return }
guard !Self.eventHitsTextInput(event) else { return }
DispatchQueue.main.async { [weak self] in
self?.onOutsideClick?()
}
}
private static func eventHitsTextInput(_ event: NSEvent) -> Bool {
guard let contentView = event.window?.contentView else { return false }
let location = contentView.convert(event.locationInWindow, from: nil)
guard let hitView = contentView.hitTest(location) else { return false }
return hitView.hasAncestor(of: NSTextField.self) || hitView.hasAncestor(of: NSTextView.self)
}
}
extension NSView {
fileprivate func hasAncestor<T: NSView>(of type: T.Type) -> Bool {
var view: NSView? = self
while let current = view {
if current is T {
return true
}
view = current.superview
}
return false
}
}
#endif
enum QuotaWarningThresholdEditorText {
enum Field: Hashable {
case upper
case lower
}
struct Draft {
private(set) var upperText: String
private(set) var lowerText: String
private let initialUpperText: String
private let initialLowerText: String
var isDirty: Bool {
self.upperText != self.initialUpperText || self.lowerText != self.initialLowerText
}
init(thresholds: [Int] = QuotaWarningThresholds.defaults) {
let pair = QuotaWarningThresholdEditorText.displayText(from: thresholds)
let upperText = pair.upper.map(String.init) ?? ""
let lowerText = pair.lower.map(String.init) ?? ""
self.upperText = upperText
self.lowerText = lowerText
self.initialUpperText = upperText
self.initialLowerText = lowerText
}
func text(for field: Field) -> String {
switch field {
case .upper: self.upperText
case .lower: self.lowerText
}
}
mutating func setText(_ value: String, for field: Field) {
let filtered = QuotaWarningThresholdEditorText.filteredIntegerText(value)
guard self.text(for: field) != filtered else { return }
switch field {
case .upper: self.upperText = filtered
case .lower: self.lowerText = filtered
}
}
mutating func update(from thresholds: [Int]) {
self = Draft(thresholds: thresholds)
}
mutating func takeResolvedThresholds() -> [Int]? {
guard self.isDirty else { return nil }
let thresholds = QuotaWarningThresholdEditorText.resolvedThresholds(
upperText: self.upperText,
lowerText: self.lowerText)
self.update(from: thresholds)
return thresholds
}
}
static func displayText(from thresholds: [Int]) -> (upper: Int?, lower: Int?) {
let sanitized = QuotaWarningThresholds.sanitized(thresholds)
return (sanitized.first, sanitized.dropFirst().first)
}
static func resolvedThresholds(upperText: String, lowerText: String) -> [Int] {
QuotaWarningThresholds.resolved(
upper: self.integer(from: upperText),
lower: self.integer(from: lowerText))
}
static func filteredIntegerText(_ text: String) -> String {
String(text.filter(\.isNumber).prefix(2))
}
private static func integer(from text: String) -> Int? {
guard !text.isEmpty else { return nil }
return Int(text)
}
}