diff --git a/panel/CompactView.swift b/panel/CompactView.swift index 5e39c2f..1e0f7c6 100644 --- a/panel/CompactView.swift +++ b/panel/CompactView.swift @@ -19,6 +19,7 @@ struct CompactView: View { @State private var rippleScale: CGFloat = 0.3 @State private var rippleOpacity: Double = 0 + @State private var isHovering: Bool = false private static let glowColor = Color(red: 0.4, green: 0.85, blue: 1.0) private static let recentEventWindow: TimeInterval = 5 * 60 @@ -39,6 +40,14 @@ struct CompactView: View { .contentShape(Capsule()) .onTapGesture(count: 2) { onExitCompact() } .onChange(of: store.events.first?.id) { _ in triggerRipple() } + // Hover state drives per-mascot reactions (robot antenna flick, + // cat wink + ear twitch, eye pupil dilate-and-dart, ghost + // pop-and-yawn). Gated on pill mode inside each mascot. + .onHover { isHovering = $0 } + } + + private var mascotHovered: Bool { + isHovering && !nav.compactExpanded } // Soft outward wave on event arrival. Capsule scales from 0.3 → 1.15 @@ -79,7 +88,8 @@ struct CompactView: View { hasSeven: nav.quota?.sevenDay != nil, polling: nav.quotaSyncing, anyBusy: anyBusy, - paused: nav.compactDragging + paused: nav.compactDragging, + showRemaining: nav.quotaShowRemaining ) .frame(width: 42, height: 42) } @@ -97,7 +107,7 @@ struct CompactView: View { @ViewBuilder private var headline: some View { HStack(spacing: 6) { - BotMascot(state: botState, kind: nav.mascot, paused: nav.compactDragging) + BotMascot(state: botState, kind: nav.mascot, paused: nav.compactDragging, hovered: mascotHovered) .frame(width: 26, height: 24) headlineText } @@ -351,6 +361,7 @@ private struct QuotaGauge: View { let polling: Bool let anyBusy: Bool let paused: Bool + let showRemaining: Bool private static let cyan = Color(red: 0.30, green: 0.92, blue: 1.0) private static let outerLineWidth: CGFloat = 4.0 @@ -443,7 +454,11 @@ private struct QuotaGauge: View { } private var centerReadout: some View { - Text(hasFive ? "\(Int(fivePct.rounded()))" : "—") + // Ring still fills with "used" so the urgency-colored gradient + // (green→red as it grows) keeps its meaning. Only the number in + // the middle flips: 30% used ↔ 70% (remaining). + let pct = showRemaining ? max(0, 100 - fivePct) : fivePct + return Text(hasFive ? "\(Int(pct.rounded()))" : "—") .font(.system(size: 14, weight: .semibold).monospacedDigit()) .foregroundStyle(.primary) } @@ -474,13 +489,14 @@ private struct BotMascot: View { let state: BotState let kind: MascotKind let paused: Bool + let hovered: Bool var body: some View { switch kind { - case .robot: RobotMascot(state: state, paused: paused) - case .cat: CatMascot(state: state, paused: paused) - case .eye: EyeMascot(state: state, paused: paused) - case .ghost: GhostMascot(state: state, paused: paused) + case .robot: RobotMascot(state: state, paused: paused, hovered: hovered) + case .cat: CatMascot(state: state, paused: paused, hovered: hovered) + case .eye: EyeMascot(state: state, paused: paused, hovered: hovered) + case .ghost: GhostMascot(state: state, paused: paused, hovered: hovered) } } } @@ -489,6 +505,7 @@ private struct RobotMascot: View { let state: BotState let paused: Bool + let hovered: Bool private static let cyan = Color(red: 0.4, green: 0.85, blue: 1.0) private static let outline = Color.secondary.opacity(0.7) @@ -521,9 +538,11 @@ private struct RobotMascot: View { VStack(spacing: 0) { Circle().fill(antennaColor.opacity(0.9)) .frame(width: 3.5, height: 3.5) + .scaleEffect(hovered ? 1.5 : 1.0) Rectangle().fill(Self.outline).frame(width: 1, height: 3) } - .offset(y: -8.5) + .offset(y: hovered ? -10.5 : -8.5) + .animation(.spring(response: 0.25, dampingFraction: 0.5), value: hovered) } private var staticEyes: some View { @@ -560,11 +579,13 @@ private struct RobotMascot: View { Circle() .fill(antennaColor.opacity(0.6 + 0.4 * blink)) .frame(width: 3.5, height: 3.5) + .scaleEffect(hovered ? 1.5 : 1.0) Rectangle() .fill(Self.outline) .frame(width: 1, height: 3) } - .offset(y: -8.5) + .offset(y: hovered ? -10.5 : -8.5) + .animation(.spring(response: 0.25, dampingFraction: 0.5), value: hovered) } private var antennaColor: Color { @@ -647,7 +668,21 @@ private struct RobotMascot: View { .frame(width: 4.5, height: 1.2) .offset(y: 6) default: - EmptyView() + // Hover-only smile arc — the robot grins back when you reach + // for it, regardless of its current state. + if hovered { + Path { p in + p.move(to: CGPoint(x: 0, y: 0)) + p.addQuadCurve(to: CGPoint(x: 6, y: 0), + control: CGPoint(x: 3, y: 2.2)) + } + .stroke(Self.cyan, style: StrokeStyle(lineWidth: 1.2, lineCap: .round)) + .frame(width: 6, height: 2.2) + .offset(y: 6) + .transition(.scale.combined(with: .opacity)) + } else { + EmptyView() + } } } } @@ -658,23 +693,41 @@ private struct CatMascot: View { let state: BotState let paused: Bool + let hovered: Bool private static let cyan = Color(red: 0.4, green: 0.85, blue: 1.0) private static let outline = Color.secondary.opacity(0.7) var body: some View { if paused { - ZStack { head; staticEyes; mouth; whiskers } + ZStack { head; staticEyes; mouth; whiskers; blep } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { TimelineView(.animation(minimumInterval: 0.05)) { tl in let t = tl.date.timeIntervalSinceReferenceDate - ZStack { head; eyes(at: t); mouth; whiskers } + ZStack { head; eyes(at: t); mouth; whiskers; blep } .frame(maxWidth: .infinity, maxHeight: .infinity) } } } + // Ears perked = head + ear-hints lift slightly while hovered. + private var earLift: CGFloat { hovered ? -1.5 : 0 } + + // Tiny tongue blep — a pink rounded triangle peeking out below the + // mouth on hover. Pure decoration; appears only while hovered. + @ViewBuilder + private var blep: some View { + if hovered { + Capsule() + .fill(Color(red: 1.0, green: 0.55, blue: 0.7)) + .frame(width: 2.2, height: 2.8) + .offset(y: 7.5) + .transition(.scale.combined(with: .opacity)) + .animation(.spring(response: 0.25, dampingFraction: 0.55), value: hovered) + } + } + private var head: some View { ZStack { // Face + ears combined as a single path so the ears feel @@ -683,16 +736,19 @@ private struct CatMascot: View { .fill(headFill) .overlay(CatHeadShape().stroke(Self.outline, lineWidth: 1.2)) .frame(width: 20, height: 18) - .offset(y: 1) + .offset(y: 1 + earLift) + .animation(.spring(response: 0.28, dampingFraction: 0.6), value: hovered) // Inner ear hints (small triangles inside each ear) Triangle() .fill(Self.outline.opacity(0.4)) .frame(width: 2, height: 2.2) - .offset(x: -5.5, y: -5.5) + .offset(x: -5.5, y: -5.5 + earLift) + .animation(.spring(response: 0.28, dampingFraction: 0.6), value: hovered) Triangle() .fill(Self.outline.opacity(0.4)) .frame(width: 2, height: 2.2) - .offset(x: 5.5, y: -5.5) + .offset(x: 5.5, y: -5.5 + earLift) + .animation(.spring(response: 0.28, dampingFraction: 0.6), value: hovered) // Nose (tiny triangle) Triangle() .rotation(.degrees(180)) @@ -715,14 +771,24 @@ private struct CatMascot: View { let cycle = t.truncatingRemainder(dividingBy: 3.5) let blinking = state != .alert && cycle < 0.15 let scaleY: CGFloat = blinking ? 0.15 : 1.0 - return HStack(spacing: 5) { eyeShape; eyeShape } - .scaleEffect(x: 1, y: scaleY) - .animation(.easeInOut(duration: 0.08), value: blinking) - .offset(y: -0.5) + // While hovered, scale the left eye flat → looks like a wink. + let leftScale: CGFloat = hovered ? 0.15 : scaleY + return HStack(spacing: 5) { + eyeShape.scaleEffect(x: 1, y: leftScale) + eyeShape.scaleEffect(x: 1, y: scaleY) + } + .animation(.easeInOut(duration: 0.18), value: hovered) + .animation(.easeInOut(duration: 0.08), value: blinking) + .offset(y: -0.5) } private var staticEyes: some View { - HStack(spacing: 5) { eyeShape; eyeShape }.offset(y: -0.5) + HStack(spacing: 5) { + eyeShape.scaleEffect(x: 1, y: hovered ? 0.15 : 1) + eyeShape + } + .animation(.easeInOut(duration: 0.18), value: hovered) + .offset(y: -0.5) } @ViewBuilder @@ -843,23 +909,42 @@ private struct EyeMascot: View { let state: BotState let paused: Bool + let hovered: Bool private static let cyan = Color(red: 0.4, green: 0.85, blue: 1.0) private static let outline = Color.secondary.opacity(0.7) var body: some View { if paused { - ZStack { lens; staticPupil } + ZStack { lens; staticPupil; eyebrow } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { TimelineView(.animation(minimumInterval: 0.05)) { tl in let t = tl.date.timeIntervalSinceReferenceDate - ZStack { lens; pupil(at: t) } + ZStack { lens; pupil(at: t); eyebrow } .frame(maxWidth: .infinity, maxHeight: .infinity) } } } + // Hover-only eyebrow arc above the lens — gives the sentinel a + // questioning, "oh hey you" expression when the cursor approaches. + @ViewBuilder + private var eyebrow: some View { + if hovered { + Path { p in + p.move(to: CGPoint(x: 0, y: 2)) + p.addQuadCurve(to: CGPoint(x: 9, y: 2), + control: CGPoint(x: 4.5, y: -1.5)) + } + .stroke(Self.outline, style: StrokeStyle(lineWidth: 1.4, lineCap: .round)) + .frame(width: 9, height: 3) + .offset(y: -10) + .transition(.scale.combined(with: .opacity)) + .animation(.spring(response: 0.25, dampingFraction: 0.6), value: hovered) + } + } + private var lens: some View { ZStack { Circle() @@ -884,15 +969,27 @@ private struct EyeMascot: View { } private func pupil(at t: TimeInterval) -> some View { - let scan = state == .watching ? sin(t * 1.5) * 3 : 0 + // Hovered: faster, wider dart — the eye is "tracking" the cursor. + // Otherwise: the slow watching scan, or static. + let scan: Double + if hovered { scan = sin(t * 6.0) * 4.5 } + else if state == .watching { scan = sin(t * 1.5) * 3 } + else { scan = 0 } return Circle() .fill(pupilColor) - .frame(width: pupilSize, height: pupilSize) + .frame(width: pupilSize + (hovered ? 2.0 : 0), + height: pupilSize + (hovered ? 2.0 : 0)) .offset(x: scan, y: 1) + .animation(.easeInOut(duration: 0.18), value: hovered) } private var staticPupil: some View { - Circle().fill(pupilColor).frame(width: pupilSize, height: pupilSize).offset(y: 1) + Circle() + .fill(pupilColor) + .frame(width: pupilSize + (hovered ? 2.0 : 0), + height: pupilSize + (hovered ? 2.0 : 0)) + .offset(y: 1) + .animation(.easeInOut(duration: 0.18), value: hovered) } private var pupilSize: CGFloat { @@ -919,25 +1016,61 @@ private struct GhostMascot: View { let state: BotState let paused: Bool + let hovered: Bool private static let cyan = Color(red: 0.4, green: 0.85, blue: 1.0) private static let outline = Color.secondary.opacity(0.7) var body: some View { if paused { - ZStack { body_; staticEyes; mouth } - .frame(maxWidth: .infinity, maxHeight: .infinity) + ZStack { + sparkles + ZStack { body_; staticEyes; mouth } + .offset(y: hovered ? -4 : 0) + .scaleEffect(hovered ? 1.08 : 1.0) + .animation(.spring(response: 0.32, dampingFraction: 0.55), value: hovered) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { TimelineView(.animation(minimumInterval: 0.05)) { tl in let t = tl.date.timeIntervalSinceReferenceDate let bob = sin(t * 1.3) * 1.0 - ZStack { body_; eyes(at: t); mouth } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .offset(y: bob) + ZStack { + sparkles + ZStack { body_; eyes(at: t); mouth } + .offset(y: bob + (hovered ? -4 : 0)) + .scaleEffect(hovered ? 1.08 : 1.0) + .animation(.spring(response: 0.32, dampingFraction: 0.55), value: hovered) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } } } + // Three little sparkles around the ghost on hover. They fade in via + // transition, then a subtle pulse via TimelineView would be overkill + // — keeping them static for clarity and frame budget. + @ViewBuilder + private var sparkles: some View { + if hovered { + ZStack { + sparkle.offset(x: -10, y: -8) + sparkle.offset(x: 10, y: -3).scaleEffect(0.7) + sparkle.offset(x: 8, y: 9).scaleEffect(0.85) + } + .transition(.scale.combined(with: .opacity)) + .animation(.spring(response: 0.3, dampingFraction: 0.55), value: hovered) + } + } + + private var sparkle: some View { + // Four-point star drawn as two crossed thin capsules. + ZStack { + Capsule().fill(Self.cyan).frame(width: 1.2, height: 4) + Capsule().fill(Self.cyan).frame(width: 4, height: 1.2) + } + } + private var body_: some View { GhostShape() .stroke(Self.outline, lineWidth: 1.2) @@ -1006,7 +1139,16 @@ private struct GhostMascot: View { .frame(width: 2.4, height: 3.5) .offset(y: 4) default: - EmptyView() + if hovered { + // Yawning "boo" while hovered — open oval mouth. + Capsule() + .fill(Self.outline.opacity(0.85)) + .frame(width: 3, height: 4) + .offset(y: 4) + .transition(.scale.combined(with: .opacity)) + } else { + EmptyView() + } } } } diff --git a/panel/MenuBar.swift b/panel/MenuBar.swift index fca5ba9..98fda3f 100644 --- a/panel/MenuBar.swift +++ b/panel/MenuBar.swift @@ -113,7 +113,12 @@ final class MenuBarController: NSObject, NSMenuDelegate { let banner = ConfigFile.bool(config, "STACKNUDGE_BANNER", default: true) let voice = ConfigFile.bool(config, "STACKNUDGE_VOICE", default: false) let mute = ConfigFile.bool(config, "STACKNUDGE_MUTE_WHEN_FOCUSED", default: true) - let hotkey = config["STACKNUDGE_PANEL_HOTKEY"] ?? "cmd+shift+n" + // Source of truth is nav.hotkeyDisplay so Settings recordings and + // unset-default fallback agree. Reading raw config here used to + // show a different default ("cmd+shift+n") than what Settings + // displayed ("cmd+opt+n") and never reflected new combos until + // the user manually edited the config file. + let hotkey = panelController?.nav.hotkeyDisplay ?? "cmd+opt+n" let status = NSMenuItem(title: "Hotkey · \(hotkey)", action: nil, keyEquivalent: "") status.isEnabled = false diff --git a/panel/Panel.swift b/panel/Panel.swift index efeca8f..cfd3415 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -489,10 +489,14 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, UNUserNotificationCenterDelegate { private var panel: FloatingPanel! + // Held so applyCompactLayout can swap its corner radius between the + // full-panel value and the pill's capsule radius — otherwise the + // smaller-radius rect corners poke past the SwiftUI capsule curve. + private weak var contentBlurView: NSVisualEffectView? private var hotkey: Hotkey? private let store = EventStore() private let sessions = SessionStore() - private let nav = PanelNav() + let nav = PanelNav() private let phrases = PhrasesViewModel() private var listener: EventListener? private var menuBar: MenuBarController? @@ -563,6 +567,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, blur.wantsLayer = true blur.layer?.cornerRadius = 12 blur.layer?.masksToBounds = true + contentBlurView = blur let host = NSHostingView(rootView: PanelContentView( store: store, sessions: sessions, nav: nav, phrases: phrases, @@ -679,6 +684,10 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, .dropFirst() .sink { [weak self] _ in self?.applyCompactLayout() } .store(in: &cancellables) + nav.$compactAlpha + .removeDuplicates() + .sink { [weak self] _ in self?.applyCompactAlpha() } + .store(in: &cancellables) applyCompactLayout() // If a previous panel instance was pkilled mid-update by install.sh, @@ -782,6 +791,11 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, nav.postUpdateVersion = result.version.isEmpty ? "?" : result.version nav.postUpdateNotes = nil nav.mode = .postUpdate + // Expand out of the pill so the changelog renders in the full + // panel rather than getting clipped into the widget frame. + if nav.compactMode, !nav.compactExpanded { + nav.compactExpanded = true + } // Auto-open the panel so the user immediately sees the // "what shipped" view rather than discovering it via hotkey. DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in @@ -843,6 +857,10 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, ignoringProgrammaticMove = true panel.setFrame(frame, display: true, animate: false) ignoringProgrammaticMove = false + // Match the SwiftUI Capsule's corner radius (half the pill + // height) so the blur backing doesn't poke out beyond the + // capsule curve and show as dark squares in the corners. + contentBlurView?.layer?.cornerRadius = size.height / 2 panel.level = .statusBar panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary, .ignoresCycle] @@ -862,16 +880,18 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // Restore the original layout-protecting minimum so SwiftUI's // full panel content (Settings, Sessions, etc.) has room. panel.contentMinSize = NSSize(width: 560, height: 260) + contentBlurView?.layer?.cornerRadius = 12 panel.level = .floating panel.collectionBehavior = [] panel.hasShadow = true - panel.isMovableByWindowBackground = false + panel.isMovableByWindowBackground = true positionPanel() if nav.compactExpanded { NSApp.activate(ignoringOtherApps: true) panel.makeKeyAndOrderFront(nil) } } + applyCompactAlpha() } // Called from the widget's expand button. Sets the expanded flag, @@ -883,12 +903,38 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, applyCompactLayout() } + // Applies the user-configured pill opacity to the window. Only takes + // effect in pill mode; expanded panel + full-mode are always fully + // opaque so the user can actually read content. + private func applyCompactAlpha() { + if nav.compactMode, !nav.compactExpanded { + panel.alphaValue = CGFloat(nav.compactAlpha) + } else { + panel.alphaValue = 1.0 + } + } + + // Wraps expandFromCompact for entry points that come from mouse/tap + // events on the pill itself — the SwiftUI expand button and the + // double-tap gesture. Notification banners sit at the same screen + // corner as the pill, so a click meant for the banner can leak + // through and activate these. Hotkey + keyboard paths (M, global + // toggle) bypass this veto because keyboard input is unambiguous. + private func expandFromCompactUserGesture() { + if Date().timeIntervalSince(lastEventArrivalAt) < 2 { return } + expandFromCompact() + } + // Compact mode is always on, so what used to be "exit compact" // (double-click, expand button) now means "expand to full panel // temporarily." Calling expandFromCompact lets the existing wiring // and Settings actions keep working without renaming. private func exitCompactMode() { - expandFromCompact() + // exitCompactMode is wired into the SwiftUI expand button and the + // pill's double-tap gesture — both ambiguous in the banner window. + // The hotkey/M paths call expandFromCompact directly and skip this + // veto, so a deliberate keystroke still expands instantly. + expandFromCompactUserGesture() } // Called from the "M" keystroke in Events/Sessions/Usage tabs to @@ -1716,6 +1762,18 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in guard let self else { return } + // Compact mode: the pill is the resting state and is always + // visible. Unsolicited reopen events (Dock click, Spotlight, + // AppleEvent activations from other apps, banner side-effects + // not caught by the bannerActivationUntil veto) used to expand + // the pill into the full panel — surfacing as "panel randomly + // appears for a few seconds before collapsing." Keep the pill + // at rest; just raise it in case another full-screen app + // covered it. + if self.nav.compactMode, !self.nav.compactExpanded { + self.panel.orderFront(nil) + return + } if Date() < self.bannerActivationUntil { return } // Suppress if a banner just posted — macOS sometimes routes a // reopen through us as a side effect of the notification arriving, diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index 2a4ca1d..b132cf1 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -164,6 +164,7 @@ final class PanelNav: ObservableObject { // all tiers — banner fires once per period when any tier reaches it. @Published var quotaTrackingEnabled: Bool = true @Published var quotaAlertsEnabled: Bool = true + @Published var quotaShowRemaining: Bool = false @Published var quotaAlertThreshold: Int = 80 // Background poll interval in minutes when the panel is hidden. // Visible-panel polling is fixed at 60s (see Panel.swift). Cycle @@ -186,6 +187,12 @@ final class PanelNav: ObservableObject { @Published var compactMode: Bool = true @Published var compactCorner: CompactCorner = .topRight @Published var mascot: MascotKind = .robot + // Pill window alpha when at rest. 1.0 = fully opaque; lower values + // let the desktop show through so the widget recedes. Applied + // window-level so the NSVisualEffectView blur fades with it. Only + // takes effect while in pill mode (compact + !expanded). + @Published var compactAlpha: Double = 1.0 + static let compactAlphaOptions: [Double] = [0.4, 0.6, 0.8, 1.0] // Transient: true when the widget was clicked → render full panel // at full size; resignKey resets this. Not persisted — purely a // session-local "the user wants details right now" flag. @@ -280,24 +287,26 @@ final class PanelNav: ObservableObject { // 4 Launch at login toggle // 5 Widget corner cycle // 6 Mascot cycle - // 7 Sound enabled toggle (gates rows 8 + 9) - // 8 Agent done sound cycle - // 9 Permission sound cycle - // 10 Voice notifications toggle (gates rows 11 + 12) - // 11 Voice cycle (or "Download model" action) - // 12 Speed cycle - // 13 Quota tracking toggle (master; gates rows 14-16) - // 14 Quota alerts toggle - // 15 Alert threshold cycle - // 16 Poll frequency cycle - // 17 Context alert at cycle (per-session token thresholds) - // 18 Edit phrases… action - // 19 Check permissions… action - // 20 Open config file… action - // 21 View release notes… action - // 22 Check for updates… action - // 23 Uninstall stack-nudge action - // 24 Quit panel action + // 7 Pill opacity cycle (40/60/80/100%; applied window-level) + // 8 Sound enabled toggle (gates rows 9 + 10) + // 9 Agent done sound cycle + // 10 Permission sound cycle + // 11 Voice notifications toggle (gates rows 12 + 13) + // 12 Voice cycle (or "Download model" action) + // 13 Speed cycle + // 14 Quota tracking toggle (master; gates rows 15-17) + // 15 Quota alerts toggle + // 16 Alert threshold cycle + // 17 Poll frequency cycle + // 18 Context alert at cycle (per-session token thresholds) + // 19 Show remaining toggle (invert gauge readout: 70% left vs 30% used) + // 20 Edit phrases… action + // 21 Check permissions… action + // 22 Open config file… action + // 23 View release notes… action + // 24 Check for updates… action + // 25 Uninstall stack-nudge action + // 26 Quit panel action // MARK: - Disk I/O @@ -321,6 +330,7 @@ final class PanelNav: ObservableObject { voiceSpeed = Double(config["STACKNUDGE_VOICE_SPEED"] ?? "") ?? 1.1 quotaTrackingEnabled = ConfigFile.bool(config, "STACKNUDGE_QUOTA_TRACKING", default: true) quotaAlertsEnabled = ConfigFile.bool(config, "STACKNUDGE_QUOTA_ALERTS", default: true) + quotaShowRemaining = ConfigFile.bool(config, "STACKNUDGE_QUOTA_SHOW_REMAINING", default: false) // Coerce out-of-list values to the nearest valid threshold so a // hand-edited config can't desync the cycle row's selection. let rawThreshold = Int(config["STACKNUDGE_QUOTA_THRESHOLD"] ?? "") ?? 80 @@ -334,6 +344,8 @@ final class PanelNav: ObservableObject { compactCorner = CompactCorner(rawValue: config["STACKNUDGE_COMPACT_CORNER"] ?? "") ?? .topRight mascot = MascotKind(rawValue: config["STACKNUDGE_MASCOT"] ?? "") ?? .robot + let rawAlpha = Double(config["STACKNUDGE_COMPACT_ALPHA"] ?? "") ?? 1.0 + compactAlpha = Self.compactAlphaOptions.min(by: { abs($0 - rawAlpha) < abs($1 - rawAlpha) }) ?? 1.0 } // MARK: - Agent reconciliation @@ -526,13 +538,13 @@ final class PanelNav: ObservableObject { } else { startVoiceModelDownload() } - case 18: actions?.editPhrases() - case 19: actions?.checkPermissions() - case 20: actions?.openConfig() - case 21: actions?.openReleaseNotes() - case 22: actions?.checkForUpdates() - case 23: actions?.beginUninstall() - case 24: actions?.quit() + case 20: actions?.editPhrases() + case 21: actions?.checkPermissions() + case 22: actions?.openConfig() + case 23: actions?.openReleaseNotes() + case 24: actions?.checkForUpdates() + case 25: actions?.beginUninstall() + case 26: actions?.quit() default: applyCycle(forward: true) } } @@ -601,16 +613,23 @@ final class PanelNav: ObservableObject { mascot = list[next] ConfigFile.write(key: "STACKNUDGE_MASCOT", value: mascot.rawValue) case 7: + let list = Self.compactAlphaOptions + let idx = list.firstIndex(of: compactAlpha) ?? (list.count - 1) + let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count + compactAlpha = list[next] + ConfigFile.write(key: "STACKNUDGE_COMPACT_ALPHA", + value: String(format: "%.2f", compactAlpha)) + case 8: soundEnabled.toggle() ConfigFile.write(key: "STACKNUDGE_SOUND", value: soundEnabled ? "true" : "false") - case 8: - soundStop = step(soundStop, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_STOP", preview: true) case 9: - soundPermission = step(soundPermission, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_PERMISSION", preview: true) + soundStop = step(soundStop, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_STOP", preview: true) case 10: + soundPermission = step(soundPermission, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_PERMISSION", preview: true) + case 11: voiceEnabled.toggle() ConfigFile.write(key: "STACKNUDGE_VOICE", value: voiceEnabled ? "true" : "false") - case 11: + case 12: // Pre-download: the row is an action, not a cycle. Treat // left/right arrow as a trigger so a user discovering the // row keyboard-only can still start the download. @@ -622,17 +641,17 @@ final class PanelNav: ObservableObject { voice = step(voice, in: voicesAvailable, forward: forward, key: "STACKNUDGE_VOICE_NAME", preview: false) let phrase = Self.voicePreviewPhrases.randomElement() ?? "Hello." Speaker.speak(phrase, voice: voice, speed: String(format: "%.2f", voiceSpeed)) - case 12: + case 13: let next = forward ? voiceSpeed + Self.speedStep : voiceSpeed - Self.speedStep voiceSpeed = max(Self.speedMin, min(Self.speedMax, (next * 100).rounded() / 100)) ConfigFile.write(key: "STACKNUDGE_VOICE_SPEED", value: String(format: "%.2f", voiceSpeed)) - case 13: - toggleQuotaTracking() case 14: + toggleQuotaTracking() + case 15: quotaAlertsEnabled.toggle() ConfigFile.write(key: "STACKNUDGE_QUOTA_ALERTS", value: quotaAlertsEnabled ? "true" : "false") - case 15: + case 16: // Cycle through the static thresholds list. Index wraps in both // directions so the user can dial in either way. let list = Self.quotaThresholds @@ -641,20 +660,24 @@ final class PanelNav: ObservableObject { quotaAlertThreshold = list[next] ConfigFile.write(key: "STACKNUDGE_QUOTA_THRESHOLD", value: String(quotaAlertThreshold)) - case 16: + case 17: let list = Self.quotaPollMinuteOptions let idx = list.firstIndex(of: quotaPollMinutes) ?? 2 let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count quotaPollMinutes = list[next] ConfigFile.write(key: "STACKNUDGE_USAGE_POLL_MIN", value: String(quotaPollMinutes)) - case 17: + case 18: let list = Self.contextAlertThresholdOptions let idx = list.firstIndex(of: contextAlertThresholdK) ?? 0 let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count contextAlertThresholdK = list[next] ConfigFile.write(key: "STACKNUDGE_CONTEXT_ALERT_THRESHOLD", value: String(contextAlertThresholdK)) + case 19: + quotaShowRemaining.toggle() + ConfigFile.write(key: "STACKNUDGE_QUOTA_SHOW_REMAINING", + value: quotaShowRemaining ? "true" : "false") default: break } diff --git a/panel/SessionUsage.swift b/panel/SessionUsage.swift index 1ae4f0b..27a21a7 100644 --- a/panel/SessionUsage.swift +++ b/panel/SessionUsage.swift @@ -269,10 +269,16 @@ struct UsageView: View { } private func tierRow(_ tier: QuotaTier) -> some View { - VStack(alignment: .leading, spacing: 4) { + // Show "30% used" or "70% remaining" depending on the toggle. Bar + // still represents utilization so the color ramp keeps its meaning. + let display = nav.quotaShowRemaining + ? max(0, 100 - tier.utilization) + : tier.utilization + let suffix = nav.quotaShowRemaining ? "% left" : "%" + return VStack(alignment: .leading, spacing: 4) { HStack(alignment: .firstTextBaseline, spacing: 8) { Spacer() - Text("\(Int(tier.utilization.rounded()))%") + Text("\(Int(display.rounded()))\(suffix)") .font(.caption.monospacedDigit().weight(.semibold)) .foregroundStyle(barColor(tier.utilization)) } diff --git a/panel/Settings.swift b/panel/Settings.swift index 21e7380..c53ec7a 100644 --- a/panel/Settings.swift +++ b/panel/Settings.swift @@ -55,40 +55,42 @@ struct SettingsView: View { section("Widget") { row(5 + off, label: "Widget corner", kind: .cycle, value: nav.compactCorner.label) row(6 + off, label: "Mascot", kind: .cycle, value: nav.mascot.label) + row(7 + off, label: "Pill opacity", kind: .cycle, value: "\(Int(nav.compactAlpha * 100))%") } section("Sounds") { - row(7 + off, label: "Sound enabled", kind: .toggle, value: nav.soundEnabled ? "On" : "Off") - row(8 + off, label: "Agent done", kind: .cycle, value: nav.soundStop, enabled: nav.soundEnabled) - row(9 + off, label: "Permission", kind: .cycle, value: nav.soundPermission, enabled: nav.soundEnabled) + row(8 + off, label: "Sound enabled", kind: .toggle, value: nav.soundEnabled ? "On" : "Off") + row(9 + off, label: "Agent done", kind: .cycle, value: nav.soundStop, enabled: nav.soundEnabled) + row(10 + off, label: "Permission", kind: .cycle, value: nav.soundPermission, enabled: nav.soundEnabled) } section("Voice") { - row(10 + off, label: "Voice notifications", kind: .toggle, value: nav.voiceEnabled ? "On" : "Off") + row(11 + off, label: "Voice notifications", kind: .toggle, value: nav.voiceEnabled ? "On" : "Off") if nav.voiceModelCached { - row(11 + off, label: "Voice", kind: .cycle, value: voiceLabel, enabled: nav.voiceEnabled) - row(12 + off, label: "Speed", kind: .cycle, value: String(format: "%.2f×", nav.voiceSpeed), enabled: nav.voiceEnabled) + row(12 + off, label: "Voice", kind: .cycle, value: voiceLabel, enabled: nav.voiceEnabled) + row(13 + off, label: "Speed", kind: .cycle, value: String(format: "%.2f×", nav.voiceSpeed), enabled: nav.voiceEnabled) } else { - voiceModelDownloadRow(index: 11 + off) + voiceModelDownloadRow(index: 12 + off) } } section("Usage") { - row(13 + off, label: "Quota tracking", kind: .toggle, value: nav.quotaTrackingEnabled ? "On" : "Off") - row(14 + off, label: "Quota alerts", kind: .toggle, value: nav.quotaAlertsEnabled ? "On" : "Off", enabled: nav.quotaTrackingEnabled) - row(15 + off, label: "Alert threshold", kind: .cycle, value: "\(nav.quotaAlertThreshold)%", enabled: nav.quotaTrackingEnabled && nav.quotaAlertsEnabled) - row(16 + off, label: "Poll frequency", kind: .cycle, value: "\(nav.quotaPollMinutes) min", enabled: nav.quotaTrackingEnabled) - row(17 + off, label: "Context alert at", kind: .cycle, value: contextAlertLabel) + row(14 + off, label: "Quota tracking", kind: .toggle, value: nav.quotaTrackingEnabled ? "On" : "Off") + row(15 + off, label: "Quota alerts", kind: .toggle, value: nav.quotaAlertsEnabled ? "On" : "Off", enabled: nav.quotaTrackingEnabled) + row(16 + off, label: "Alert threshold", kind: .cycle, value: "\(nav.quotaAlertThreshold)%", enabled: nav.quotaTrackingEnabled && nav.quotaAlertsEnabled) + row(17 + off, label: "Poll frequency", kind: .cycle, value: "\(nav.quotaPollMinutes) min", enabled: nav.quotaTrackingEnabled) + row(18 + off, label: "Context alert at", kind: .cycle, value: contextAlertLabel) + row(19 + off, label: "Show remaining", kind: .toggle, value: nav.quotaShowRemaining ? "On" : "Off", enabled: nav.quotaTrackingEnabled) } section("Actions") { - row(18 + off, label: "Edit phrases…", kind: .action, value: "") - row(19 + off, label: "Check permissions…", kind: .action, value: "") - row(20 + off, label: "Open config file…", kind: .action, value: "") - row(21 + off, label: "View release notes…", kind: .action, value: "") - row(22 + off, label: "Check for updates…", kind: .action, value: checkForUpdatesStatus) - row(23 + off, label: "Uninstall StackNudge…", kind: .action, value: "") - row(24 + off, label: "Quit panel", kind: .action, value: "") + row(20 + off, label: "Edit phrases…", kind: .action, value: "") + row(21 + off, label: "Check permissions…", kind: .action, value: "") + row(22 + off, label: "Open config file…", kind: .action, value: "") + row(23 + off, label: "View release notes…", kind: .action, value: "") + row(24 + off, label: "Check for updates…", kind: .action, value: checkForUpdatesStatus) + row(25 + off, label: "Uninstall StackNudge…", kind: .action, value: "") + row(26 + off, label: "Quit panel", kind: .action, value: "") } aboutFooter