diff --git a/MeowBar/Sources/OnboardingView.swift b/MeowBar/Sources/OnboardingView.swift index 3ca8940..e6f0201 100644 --- a/MeowBar/Sources/OnboardingView.swift +++ b/MeowBar/Sources/OnboardingView.swift @@ -10,25 +10,45 @@ struct OnboardingView: View { } var onDone: (Choices) -> Void + @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var alwaysFresh = true @State private var autoClock = true @State private var launchAtLogin = true + @State private var appeared = false var body: some View { VStack(alignment: .leading, spacing: 16) { - HStack(spacing: 12) { - CatView(mood: .napping, size: 30) + HStack(spacing: 14) { + ZStack { + Circle().fill(Color.accentColor.opacity(0.14)) + CatView(mood: .napping, size: 25, animating: false) + .offset(x: 6) + } + .frame(width: 58, height: 58) VStack(alignment: .leading, spacing: 2) { - Text("Welcome to MeowBar").font(.title2.weight(.bold)) - Text("Your Claude and Codex limits, always in the menu bar.") + Text("Welcome to MeowBar") + .font(.title2.weight(.bold)) + Text("Your AI limits, one glance away.") .font(.callout).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 16) + .fill(LinearGradient(colors: [Color.accentColor.opacity(0.13), + Color.accentColor.opacity(0.035)], + startPoint: .topLeading, endPoint: .bottomTrailing)) + } + .overlay { + RoundedRectangle(cornerRadius: 16) + .strokeBorder(Color.accentColor.opacity(0.18), lineWidth: 0.7) + } HStack(spacing: 8) { MenuBarMock() - Text("— it lives up here") + Text("Lives quietly in your menu bar") .font(.caption2.weight(.medium)) .foregroundStyle(.tertiary) } @@ -38,18 +58,26 @@ struct OnboardingView: View { .font(.callout) .fixedSize(horizontal: false, vertical: true) } icon: { - Image(systemName: "key.fill").foregroundStyle(.secondary) + Image(systemName: "key.fill") + .foregroundStyle(Color.accentColor) } - .padding(10) + .padding(12) .frame(maxWidth: .infinity, alignment: .leading) - .background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 8)) + .background(.quaternary.opacity(0.45), in: .rect(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.6) + } - VStack(alignment: .leading, spacing: 12) { - option($alwaysFresh, "Always-fresh Claude numbers", + VStack(alignment: .leading, spacing: 8) { + Text("Recommended setup") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + option($alwaysFresh, icon: "bolt.horizontal.fill", "Always-fresh Claude numbers", "Claude Code reports its limits to MeowBar through its status line. Your current status line keeps working.") - option($autoClock, "Start Claude's 5h clock automatically", + option($autoClock, icon: "clock.arrow.circlepath", "Start Claude's 5h clock automatically", "When the session is idle and you're at your Mac, MeowBar sends a one-word prompt so the window — and its reset — starts sooner.") - option($launchAtLogin, "Launch at login", nil) + option($launchAtLogin, icon: "power", "Launch at login", nil) } Text("Optional cat sounds — a meow on reset, a meow on task done, a sad mewl when a limit maxes — live in Settings → Alerts. Off until you want them.") @@ -64,16 +92,32 @@ struct OnboardingView: View { } .keyboardShortcut(.defaultAction) .controlSize(.large) + .buttonStyle(.borderedProminent) } } .padding(24) - .frame(width: 420) + .frame(width: 440) + .background(Color(nsColor: .windowBackgroundColor)) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 8) + .onAppear { + if reduceMotion { + appeared = true + } else { + withAnimation(.easeOut(duration: 0.35)) { appeared = true } + } + } } - private func option(_ isOn: Binding, _ title: String, _ detail: String?) -> some View { + private func option(_ isOn: Binding, icon: String, _ title: String, _ detail: String?) -> some View { HStack(alignment: .top, spacing: 12) { + Image(systemName: icon) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.accentColor) + .frame(width: 28, height: 28) + .background(Color.accentColor.opacity(0.10), in: .circle) VStack(alignment: .leading, spacing: 2) { - Text(title) + Text(title).font(.callout.weight(.medium)) if let detail { Text(detail).font(.caption).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -85,6 +129,12 @@ struct OnboardingView: View { .toggleStyle(.switch) .controlSize(.small) } + .padding(11) + .background(Color.primary.opacity(0.045), in: .rect(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.6) + } } } diff --git a/MeowBar/Sources/PopoverView.swift b/MeowBar/Sources/PopoverView.swift index db4ad8e..88e5899 100644 --- a/MeowBar/Sources/PopoverView.swift +++ b/MeowBar/Sources/PopoverView.swift @@ -1,6 +1,5 @@ import SwiftUI import AppKit -import Combine // Derive from the semantic foreground instead of a fixed gray. It remains // legible when the glass moves over light, dark, or tinted UI. @@ -14,15 +13,43 @@ private func successColor(_ scheme: ColorScheme) -> Color { : Color(red: 0.00, green: 0.43, blue: 0.19) } +// One curve for every popover resize: SwiftUI animates the layout with it +// and PopoverHostingController animates the window with it, so the glass +// edge and the content below it move as one. +enum PopoverMotion { + static let duration: TimeInterval = 0.25 + private static let curve: (Float, Float, Float, Float) = (0.25, 0.1, 0.25, 1.0) + static let resize = Animation.timingCurve(Double(curve.0), Double(curve.1), + Double(curve.2), Double(curve.3), + duration: duration) + static var timingFunction: CAMediaTimingFunction { + CAMediaTimingFunction(controlPoints: curve.0, curve.1, curve.2, curve.3) + } +} + struct PopoverView: View { @ObservedObject var store: UsageStore + // Natural size, reported on every layout change; AppDelegate sizes the + // popover from it (animated while open). + var onSizeChange: (CGSize) -> Void = { _ in } @Environment(\.colorScheme) private var colorScheme + @Environment(\.accessibilityReduceMotion) private var reduceMotion // Expansion lives here so the 1/2/3 keys (posted from the keyDown // monitor in main.swift) can toggle providers from outside the blocks. @State private var expandedProviders: Set = [] - @ViewBuilder var body: some View { + surface + // Natural height, pinned to the top: while the popover window + // glides to a new height, any slack is at the bottom edge where + // the motion is — content is never re-centered or squeezed. + .fixedSize(horizontal: false, vertical: true) + .onGeometryChange(for: CGSize.self) { $0.size } action: { onSizeChange($0) } + .frame(maxHeight: .infinity, alignment: .top) + } + + @ViewBuilder + private var surface: some View { if #available(macOS 26.0, *) { // Keep the surface untinted so Liquid Glass can sample the desktop. // Semantic foreground colours below retain contrast as it changes. @@ -39,31 +66,26 @@ struct PopoverView: View { } private var content: some View { - // Flat list, battery-menu style: slim status header, providers - // separated by hairline dividers, quiet footer. - VStack(alignment: .leading, spacing: 9) { + VStack(alignment: .leading, spacing: 8) { header - Divider() ForEach(Array(providers.enumerated()), id: \.offset) { i, usage in - if i > 0 { Divider() } ProviderBlock(usage: usage, running: runningFlag(usage.name), store: store, expanded: expandedBinding(usage.name), delay: 0.04 + Double(i) * 0.05) } - Divider() footer } - .padding(12) - .frame(width: 290) + .padding(10) + .frame(width: 304) .foregroundStyle(.primary) .onReceive(NotificationCenter.default.publisher(for: .init("meowbar.toggleProvider"))) { note in guard let s = note.object as? String, let n = Int(s), providers.indices.contains(n - 1) else { return } let name = providers[n - 1].name guard providers[n - 1].hasData else { return } - withAnimation(.snappy(duration: 0.25)) { + withAnimation(reduceMotion ? nil : PopoverMotion.resize) { if expandedProviders.contains(name) { expandedProviders.remove(name) } else { @@ -93,15 +115,34 @@ struct PopoverView: View { // MARK: Header — the cat plus the one line you'd read first private var header: some View { - HStack(spacing: 7) { - CatView(mood: catMood, animating: store.popoverShown) - Text(statusLine.text) - .font(.caption.weight(.semibold)) - .foregroundStyle(statusLine.color) - .textCase(.uppercase) - .lineLimit(1) + HStack(spacing: 10) { + ZStack { + Circle() + .fill(statusLine.color.opacity(colorScheme == .dark ? 0.18 : 0.10)) + CatView(mood: catMood, size: 15, animating: store.popoverShown) + .offset(x: 4) + } + .frame(width: 34, height: 34) + + VStack(alignment: .leading, spacing: 1) { + Text(statusLine.text) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(statusLine.color) + .lineLimit(1) + Text("Live usage and pace") + .font(.caption2.weight(.medium)) + .foregroundStyle(supportingText(colorScheme)) + } Spacer() } + .padding(.horizontal, 9) + .padding(.vertical, 8) + .background(Color.primary.opacity(colorScheme == .dark ? 0.075 : 0.045), + in: .rect(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12) + .strokeBorder(statusLine.color.opacity(0.18), lineWidth: 0.7) + } } // What the whole popover boils down to, worst news first. @@ -148,13 +189,18 @@ struct PopoverView: View { } Spacer() Button { store.refresh() } label: { - Image(systemName: "arrow.clockwise").font(.caption) + Image(systemName: "arrow.clockwise") + .font(.caption.weight(.semibold)) + .frame(width: 22, height: 22) + .background(Color.primary.opacity(0.07), in: .circle) } - .buttonStyle(.borderless) + .buttonStyle(.plain) .controlSize(.small) .help("Refresh now (r)") settingsMenu } + .padding(.horizontal, 3) + .padding(.top, 2) } private var settingsMenu: some View { @@ -178,7 +224,10 @@ struct PopoverView: View { Button("Quit MeowBar") { NSApp.terminate(nil) } .keyboardShortcut("q") } label: { - Image(systemName: "gearshape").font(.caption) + Image(systemName: "gearshape") + .font(.caption.weight(.semibold)) + .frame(width: 22, height: 22) + .background(Color.primary.opacity(0.07), in: .circle) .overlay(alignment: .topTrailing) { if store.update != nil { Circle().fill(.red).frame(width: 5, height: 5).offset(x: 2.5, y: -2.5) @@ -200,7 +249,9 @@ struct ProviderBlock: View { var expanded: Binding = .constant(false) var delay: Double = 0 @State private var appeared = false + @State private var hovering = false @Environment(\.colorScheme) private var colorScheme + @Environment(\.accessibilityReduceMotion) private var reduceMotion private var brandColor: Color { switch usage.name { @@ -244,13 +295,17 @@ struct ProviderBlock: View { if let icon = providerIcon { Image(nsImage: icon).renderingMode(.template) .resizable().aspectRatio(contentMode: .fit) - .frame(width: 13, height: 13) - .foregroundStyle(.primary) + .frame(width: 12, height: 12) + .foregroundStyle(brandColor) + .frame(width: 22, height: 22) + .background(brandColor.opacity(colorScheme == .dark ? 0.17 : 0.10), in: .circle) } let name = Text(usage.name).font(.subheadline.weight(.bold)).foregroundStyle(.primary) if canExpand { Button { - withAnimation(.snappy(duration: 0.25)) { expanded.wrappedValue.toggle() } + withAnimation(reduceMotion ? nil : PopoverMotion.resize) { + expanded.wrappedValue.toggle() + } } label: { HStack(spacing: 4) { name @@ -317,7 +372,7 @@ struct ProviderBlock: View { if expanded.wrappedValue && canExpand { HistoryStrip(samples: store?.history[usage.name] ?? [], tint: brandColor) - .transition(.opacity.combined(with: .move(edge: .top))) + .transition(reduceMotion ? .opacity : .opacity.combined(with: .move(edge: .top))) } if let err = usage.error { @@ -340,10 +395,21 @@ struct ProviderBlock: View { if !unlimited && (usage.hasData || usage.error == nil) { let sessionLabel = usage.name == "Devin" ? "Daily · 24h" : "Session · 5h" WindowRow(provider: usage.name, title: sessionLabel, w: usage.session, tint: brandColor, - clockStarting: usage.name == "Claude" && store?.clockStarting == true) - WindowRow(provider: usage.name, title: "Weekly · 7d", w: usage.weekly, tint: brandColor) + clockStarting: usage.name == "Claude" && store?.clockStarting == true, + animating: store?.popoverShown == true) + WindowRow(provider: usage.name, title: "Weekly · 7d", w: usage.weekly, tint: brandColor, + animating: store?.popoverShown == true) } } + .padding(.horizontal, 10) + .padding(.vertical, 9) + .background(Color.primary.opacity(hovering ? 0.090 : (colorScheme == .dark ? 0.060 : 0.035)), + in: .rect(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12) + .strokeBorder(running ? brandColor.opacity(0.34) : Color.primary.opacity(0.09), + lineWidth: running ? 1 : 0.6) + } .contextMenu { if let action = openAction { Button(openTitle) { UsageService.perform(action) } @@ -360,11 +426,17 @@ struct ProviderBlock: View { .onChange(of: store?.popoverShown ?? false) { _, shown in if shown { replayEntrance() } else { appeared = false } } + .onHover { hovering = $0 } + .animation(reduceMotion ? nil : .easeOut(duration: 0.14), value: hovering) } // Brief rise-and-fade when the popover opens; each block waits a touch // longer than the one before it (stagger via `delay`). private func replayEntrance() { + guard !reduceMotion else { + appeared = true + return + } appeared = false withAnimation(.easeOut(duration: 0.26).delay(delay)) { appeared = true } } @@ -376,19 +448,17 @@ struct ProviderBlock: View { private struct LiveDot: View { let color: Color var live: Bool - @State private var dim = false - var body: some View { - Circle().fill(color).frame(width: 4.5, height: 4.5) - .opacity(live && dim ? 0.3 : 1) - .onAppear { if live { pulse() } } - .onChange(of: live) { _, live in - if live { pulse() } else { dim = false } - } - } - - private func pulse() { - withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { dim = true } + if live { + Circle().fill(color).frame(width: 4.5, height: 4.5) + .phaseAnimator([1.0, 0.3]) { dot, opacity in + dot.opacity(opacity) + } animation: { _ in + .easeInOut(duration: 0.9) + } + } else { + Circle().fill(color).frame(width: 4.5, height: 4.5) + } } } @@ -529,7 +599,9 @@ struct WindowRow: View { let w: UsageWindow? var tint: Color = .accentColor var clockStarting = false + var animating = true @Environment(\.colorScheme) private var colorScheme + @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { if let w { @@ -549,7 +621,8 @@ struct WindowRow: View { } Capsule().fill(barGradient) .frame(width: max(0, geo.size.width * min(w.usedPercent, 100) / 100)) - .animation(.spring(duration: 0.5, bounce: 0.15), value: w.usedPercent) + .animation(animating && !reduceMotion ? .spring(duration: 0.5, bounce: 0.15) : nil, + value: w.usedPercent) // Even-pace marker: where usage would sit if burned // evenly. Fill past it = ahead of pace. if let f = w.elapsedFraction(), f > 0.02, f < 0.98 { @@ -559,7 +632,7 @@ struct WindowRow: View { } } } - .frame(height: 7) + .frame(height: 8) .help(paceHelp) HStack { Text(bottomText).font(.caption2.weight(.medium)).foregroundStyle(supportingText(colorScheme)).lineLimit(1) @@ -603,7 +676,8 @@ struct WindowRow: View { .foregroundStyle(supportingText(colorScheme))) .monospacedDigit() .contentTransition(.numericText()) - .animation(.easeOut(duration: 0.35), value: w.usedPercent) + .animation(animating && !reduceMotion ? .easeOut(duration: 0.35) : nil, + value: w.usedPercent) } } @@ -676,8 +750,6 @@ struct CatView: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.colorScheme) private var colorScheme @State private var blinking = false - // A quick eyelid squash every few seconds — the cheapest life there is. - private let blinker = Timer.publish(every: 3.8, on: .main, in: .common).autoconnect() private var live: Bool { animating && !reduceMotion } @@ -724,15 +796,26 @@ struct CatView: View { private var catWithBlink: some View { cat .scaleEffect(y: blinking ? 0.12 : 1, anchor: .center) - .onReceive(blinker) { _ in blink() } + .task(id: live) { + guard live else { + blinking = false + return + } + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(3.8)) + guard !Task.isCancelled else { return } + await blink() + } + } } - private func blink() { + @MainActor + private func blink() async { guard live else { return } withAnimation(.easeIn(duration: 0.07)) { blinking = true } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.09) { - withAnimation(.easeOut(duration: 0.12)) { blinking = false } - } + try? await Task.sleep(for: .milliseconds(90)) + guard !Task.isCancelled else { return } + withAnimation(.easeOut(duration: 0.12)) { blinking = false } } private var caption: String { diff --git a/MeowBar/Sources/SettingsView.swift b/MeowBar/Sources/SettingsView.swift index 3a2e197..7d64443 100644 --- a/MeowBar/Sources/SettingsView.swift +++ b/MeowBar/Sources/SettingsView.swift @@ -8,6 +8,7 @@ extension Notification.Name { struct SettingsView: View { @ObservedObject var store: UsageStore + @Environment(\.accessibilityReduceMotion) private var reduceMotion @AppStorage("meowbar.position") var position = "right" // right | middle @AppStorage("meowbar.displayMode") var displayMode = "percent" // percent | maxed | countdown | eta @AppStorage("meowbar.alwaysFresh") var alwaysFresh = false @@ -31,11 +32,14 @@ struct SettingsView: View { @State private var checkingUpdate = false var body: some View { - // ScrollView + cap: the form grew past smaller screens' height — - // it scrolls instead of clipping the bottom off-screen. - ScrollView { + VStack(spacing: 0) { + settingsHeader + .zIndex(1) + Divider() + // Form owns the scrolling. Nesting it in another ScrollView makes + // AppKit restore an arbitrary inner offset when the window opens. Form { - Section("Menu bar") { + Section { Picker("Position", selection: $position) { Text("Right — with the other icons").tag("right") Text("Middle — like the notch").tag("middle") @@ -49,6 +53,7 @@ struct SettingsView: View { if displayMode == "eta" { Text("Shows how long until the limit runs out at your current pace — ~2h means you'd hit it in about 2 hours. When you're not on pace to run out, it falls back to the reset countdown.") .font(.caption).foregroundStyle(.secondary) + .transition(.opacity.combined(with: .move(edge: .top))) } Toggle(isOn: $compact) { VStack(alignment: .leading, spacing: 2) { @@ -60,9 +65,11 @@ struct SettingsView: View { Toggle("Show Claude in the menu bar", isOn: $showClaude) Toggle("Show Codex in the menu bar", isOn: $showCodex) Toggle("Show Devin in the menu bar", isOn: $showDevin) + } header: { + sectionHeader("Menu bar", systemImage: "menubar.rectangle") } - Section("Alerts") { + Section { Toggle("Nudge at 75% used", isOn: $alert75) Toggle("Nudge at 90% used", isOn: $alert90) Toggle("Nudge at 95% used", isOn: $alert95) @@ -77,9 +84,11 @@ struct SettingsView: View { Toggle("Meow when a limit resets", isOn: $meowSound) Toggle("Meow when a task finishes", isOn: $purrSound) Toggle("Sad meow when a limit maxes", isOn: $sadSound) + } header: { + sectionHeader("Alerts", systemImage: "bell.badge") } - Section("Claude") { + Section { Toggle(isOn: $alwaysFresh) { VStack(alignment: .leading, spacing: 2) { Text("Always fresh") @@ -92,6 +101,7 @@ struct SettingsView: View { ? "Tracking on — numbers arrive after your next Claude Code message." : (bridgeError ?? "Installing…")) .font(.caption).foregroundStyle(.secondary) + .transition(.opacity.combined(with: .move(edge: .top))) } Toggle(isOn: $autoClock) { VStack(alignment: .leading, spacing: 2) { @@ -100,17 +110,21 @@ struct SettingsView: View { .font(.caption).foregroundStyle(.secondary) } } + } header: { + sectionHeader("Claude", systemImage: "bolt.horizontal.circle") } - Section("App") { + Section { Toggle("Launch at login", isOn: $launchAtLogin) Picker("Open MeowBar with", selection: $hotkey) { Text("Off").tag("off") ForEach(HotKey.choices, id: \.id) { Text($0.combo.label).tag($0.id) } } + } header: { + sectionHeader("App", systemImage: "slider.horizontal.3") } - Section("About") { + Section { HStack { Text("Version") Spacer() @@ -144,13 +158,20 @@ struct SettingsView: View { } } } + } header: { + sectionHeader("About", systemImage: "info.circle") + } } - } .formStyle(.grouped) - .padding(4) + .padding(.horizontal, 6) + .padding(.bottom, 10) + .scrollContentBackground(.hidden) + .clipped() } - .scrollContentBackground(.hidden) - .frame(width: 420, height: 620) + .background(.background) + .frame(width: 440, height: 650) + .animation(reduceMotion ? nil : .snappy(duration: 0.24), value: displayMode) + .animation(reduceMotion ? nil : .snappy(duration: 0.24), value: alwaysFresh) .onAppear { bridgeInstalled = StatuslineBridge.isInstalled alwaysFresh = bridgeInstalled // reflect real state, even if enabled elsewhere @@ -186,6 +207,41 @@ struct SettingsView: View { } } + private var settingsHeader: some View { + HStack(spacing: 12) { + ZStack { + Circle().fill(Color.accentColor.opacity(0.13)) + CatView(mood: .napping, size: 17, animating: false) + .offset(x: 4) + } + .frame(width: 42, height: 42) + + VStack(alignment: .leading, spacing: 2) { + Text("MeowBar Settings") + .font(.title3.weight(.semibold)) + Text("Make the menu bar useful without making it noisy.") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Text("v\(UpdateCheck.currentVersion)") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.quaternary, in: .capsule) + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + } + + private func sectionHeader(_ title: String, systemImage: String) -> some View { + Label(title, systemImage: systemImage) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(nil) + } + private func changed() { NotificationCenter.default.post(name: .meowbarSettingsChanged, object: nil) } diff --git a/MeowBar/Sources/main.swift b/MeowBar/Sources/main.swift index 998972a..963efdf 100644 --- a/MeowBar/Sources/main.swift +++ b/MeowBar/Sources/main.swift @@ -594,14 +594,9 @@ enum CatSound { final class AppDelegate: NSObject, NSApplicationDelegate { private var statusItem: NSStatusItem! private let popover = NSPopover() - // Clicking the status item while the popover is open can auto-dismiss it - // AND fire the action — without this guard the click reopens instead of - // closing. 0.4s is below human double-tap speed for a deliberate reopen. - private var popoverClosedAt = Date.distantPast - // The close animation runs ~0.5s with isShown still true. If an outside - // click started it and you then click the bar, that click means "open". + // True from the start of a close until popoverDidClose. AppKit's close + // transaction outlasts the visible zoom-out by ~0.4s (see togglePopover). private var popoverClosing = false - private var closedByOutsideClick = false private var reopenAfterClose = false // The view the popover's arrow points at: the status button, or the // island capsule in "middle" mode. @@ -622,10 +617,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate { statusItem.button?.target = self statusItem.button?.sendAction(on: [.leftMouseUp, .rightMouseUp]) - popover.behavior = .semitransient + // Dismissal is managed explicitly below (outside clicks, keys, + // sleep/lock, display changes). + popover.behavior = .applicationDefined + // AppKit's own animation zooms the whole surface — glass, arrow and + // shadow — out of the menu-bar item and back into it. Don't replace + // it with a snapshot morph: the glass is composited by the window + // server, so a snapshot holds only bare text floating on the desktop. + popover.animates = true popover.delegate = self - let host = NSHostingController(rootView: PopoverView(store: store)) - host.sizingOptions = .preferredContentSize + let host = NSHostingController(rootView: PopoverView(store: store) { [weak self] size in + self?.fitPopover(to: size) + }) + // Sized by fitPopover, not .preferredContentSize: AppKit applies that + // in one frame while SwiftUI animates the layout over 0.25s, so the + // glass edge jumped and the content was clipped mid-expand. + host.sizingOptions = [] popover.contentViewController = host store.onUpdate = { [weak self] in @@ -669,6 +676,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if ProcessInfo.processInfo.environment["MEOWBAR_SETTINGS_SHOT"] != nil { DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { self.openSettings() } } + // Motion QA: open, close, then reopen through the real status-item + // anchor so a short screen recording can inspect every keyframe. + if ProcessInfo.processInfo.environment["MEOWBAR_MOTION_DEMO"] != nil { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { self.togglePopover() } + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { self.closePopover() } + DispatchQueue.main.asyncAfter(deadline: .now() + 2.7) { self.togglePopover() } + DispatchQueue.main.asyncAfter(deadline: .now() + 3.7) { self.closePopover() } + } NotificationCenter.default.addObserver(forName: .meowbarSettingsChanged, object: nil, queue: .main) { [weak self] _ in self?.applyPosition() @@ -678,7 +693,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } NotificationCenter.default.addObserver(forName: .init("meowbar.openSettings"), object: nil, queue: .main) { [weak self] _ in - self?.openSettings() + guard let self else { return } + self.closePopover() + self.openSettings() } NotificationCenter.default.addObserver(forName: .init("meowbar.checkUpdates"), object: nil, queue: .main) { [weak self] _ in @@ -686,7 +703,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } NotificationCenter.default.addObserver(forName: .init("meowbar.openAbout"), object: nil, queue: .main) { [weak self] _ in - self?.openAbout() + guard let self else { return } + self.closePopover() + self.openAbout() } // .transient is unreliable for accessory apps — explicitly close on @@ -701,9 +720,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return } if self.clearOrphanedPopover() { return } - guard self.popover.isShown else { return } - if !self.popoverClosing { self.closedByOutsideClick = true } - self.popover.performClose(nil) + self.closePopover() + } + // Global monitors don't see clicks delivered to our own Settings or + // About windows. With application-defined popover behavior, close on + // those local clicks too while leaving the popover and its menus alone. + NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in + guard let self, self.popover.isShown else { return event } + if event.window === self.popoverWindow + || event.window === self.statusItem.button?.window + || event.window === self.islandPanel + || event.window?.level == .popUpMenu { + return event + } + self.closePopover() + return event } // A popover left open across sleep, screen lock or a display change // can lose its anchor: AppKit then reports it closed while its window @@ -741,7 +772,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { NSApp.terminate(nil) return nil case ",": - self.popover.performClose(nil) + self.closePopover() self.openSettings() return nil case "1", "2", "3": @@ -751,7 +782,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { break } } - self.popover.performClose(nil) + self.closePopover() return nil } @@ -823,6 +854,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let w = NSWindow(contentViewController: host) w.title = "MeowBar Settings" w.styleMask = [.titled, .closable] + w.titleVisibility = .hidden w.isReleasedWhenClosed = false w.level = .floating settingsWindow = w @@ -926,7 +958,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Immediate close (no animation) for sleep, lock and display changes. private func dismissPopover() { reopenAfterClose = false - if popover.isShown { popover.close() } + if popover.isShown { closePopover(animated: false) } clearOrphanedPopover() store.popoverShown = false } @@ -982,16 +1014,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @objc private func togglePopover() { if clearOrphanedPopover() { return } if popover.isShown { - // Clicked away, then straight back on the bar mid-fade: reopen - // once the close lands instead of swallowing the click. - if popoverClosing && closedByOutsideClick { + if popoverClosing { + // The zoom-out is over in ~0.1s, but AppKit keeps isShown + // true until didClose ~0.5s later and won't show before then. + // A click in that gap means "open": do it once the close lands. reopenAfterClose = true - return + } else { + closePopover() } - popover.performClose(nil) return } - if Date().timeIntervalSince(popoverClosedAt) < 0.4 { return } store.acknowledgeAlerts() store.refresh() if let bg = islandPanel?.contentView, islandPanel?.isVisible == true { @@ -999,6 +1031,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } else { popoverAnchor = statusItem.button } + // Open at the right size: while closed, nothing re-measures the view. + if let host = popover.contentViewController as? NSHostingController { + fitPopover(to: host.sizeThatFits(in: NSSize(width: 0, height: 0))) + } if let anchor = popoverAnchor { popover.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .minY) } @@ -1012,6 +1048,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate { popover.contentViewController?.view.window?.makeKey() } + // Animated: AppKit zooms the popover back into its anchor, isShown + // staying true until popoverDidClose. Unanimated: gone this instant, + // for sleep, lock and display changes. + private func closePopover(animated: Bool = true) { + guard popover.isShown else { return } + if animated { + guard !popoverClosing else { return } + popoverClosing = true + popover.performClose(nil) + } else { + popover.animates = false + popover.close() + popover.animates = true + } + } + + // While open, glide to a new size on the curve SwiftUI animates the + // layout with (PopoverMotion), so the glass edge and content move as one. + private func fitPopover(to size: NSSize) { + guard size.width > 0, size.height > 0, size != popover.contentSize else { return } + guard popover.isShown, !popoverClosing, + !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else { + popover.contentSize = size + return + } + NSAnimationContext.runAnimationGroup { context in + context.duration = PopoverMotion.duration + context.timingFunction = PopoverMotion.timingFunction + context.allowsImplicitAnimation = true + popover.contentSize = size + } + } + // Triple fade-pulse on the status item (and island) so a new alert — // "task done", "limit reached" — catches the eye without a banner. private func flashMenuItem() { @@ -1127,12 +1196,9 @@ extension AppDelegate: NSPopoverDelegate { func popoverDidClose(_ notification: Notification) { store.popoverShown = false - popoverClosedAt = Date() popoverClosing = false - closedByOutsideClick = false if reopenAfterClose { reopenAfterClose = false - popoverClosedAt = .distantPast togglePopover() } }