Skip to content
Merged
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
4 changes: 4 additions & 0 deletions Cotabby.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
0A658BF137DBD0898E40B87F /* AcknowledgementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B7A28471B8526C2693FFF65 /* AcknowledgementsView.swift */; };
0AF568AB234033BA2DE4CAA7 /* SuggestionModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 386C98FFCF76EC1C8C7E82BB /* SuggestionModels.swift */; };
0B6E28D1CBDF657F71548A3C /* EmojiPickerControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62BD2ADED33249F5BA53D0AD /* EmojiPickerControllerTests.swift */; };
0BEBB33EB75B59EE83C6FE44 /* MenuBarPopoverDismisser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44595B534DD7323F0AD60825 /* MenuBarPopoverDismisser.swift */; };
0C06CAD62975E87B2C852191 /* ScreenTextExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59E299BE2E9D42A33D5D2F5D /* ScreenTextExtractor.swift */; };
0C98ECB5BCEBA72C693AC1C9 /* SuggestionTextNormalizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B55A4362AB7F0528C661C4C /* SuggestionTextNormalizerTests.swift */; };
0D15CBF45EB1DB725B9F1A6A /* EmojiQueryRunTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75396860978E81EFAA506CD4 /* EmojiQueryRunTests.swift */; };
Expand Down Expand Up @@ -286,6 +287,7 @@
41BBD5A4BA08CABE77860886 /* HardwareCapabilityProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardwareCapabilityProbe.swift; sourceTree = "<group>"; };
43E37A7E835D3BDE6265843C /* TerminalAppDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAppDetectorTests.swift; sourceTree = "<group>"; };
4451D6673112575DF24C4A48 /* OnboardingTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTemplate.swift; sourceTree = "<group>"; };
44595B534DD7323F0AD60825 /* MenuBarPopoverDismisser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarPopoverDismisser.swift; sourceTree = "<group>"; };
4696A84D17890B154533A08F /* PromptPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptPolicyTests.swift; sourceTree = "<group>"; };
4793D4EA5D36D7E5CC216C27 /* LanguageSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageSupportTests.swift; sourceTree = "<group>"; };
51020F8CD58338BD643FBF63 /* ModelDownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelDownloadManager.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -735,6 +737,7 @@
78E49BDA7F3A42455C4C5350 /* HuggingFaceModelBrowserView.swift */,
5A567677424A82D9EEF47495 /* KeyRecorderView.swift */,
9A7CDA90E128350BFF1A9D66 /* LanguageTagsEditor.swift */,
44595B534DD7323F0AD60825 /* MenuBarPopoverDismisser.swift */,
00824BDD8D0E9B3063827C78 /* MenuBarPresentationObserver.swift */,
83A810F9D28A18BA6F2066C7 /* MenuBarSections.swift */,
BD42C7E2852F59BEF7972663 /* MenuBarStatusLabelView.swift */,
Expand Down Expand Up @@ -1048,6 +1051,7 @@
2197B68F1E4D0C3497DAC061 /* LlamaSuggestionEngine.swift in Sources */,
EDA8E8250FC2F70B206B4894 /* LlamaVisualContextSummarizer.swift in Sources */,
5C119807B84F84B0B1B1C2D5 /* MarkerSelectionSynthesizer.swift in Sources */,
0BEBB33EB75B59EE83C6FE44 /* MenuBarPopoverDismisser.swift in Sources */,
F08C139B246C1EC7BB435455 /* MenuBarPresentationObserver.swift in Sources */,
0333B3CE8F189DD1BEC4AD26 /* MenuBarSections.swift in Sources */,
AECC7289DA796B071B4FE3C0 /* MenuBarStatusLabelView.swift in Sources */,
Expand Down
89 changes: 89 additions & 0 deletions Cotabby/UI/MenuBarPopoverDismisser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import AppKit
import Combine
import SwiftUI
Comment thread
greptile-apps[bot] marked this conversation as resolved.

/// Captures the `NSWindow` that backs a `MenuBarExtra(.window)` popover so SwiftUI buttons can
/// programmatically dismiss the popover the same way a `Link` would.
///
/// Why this exists:
/// `MenuBarExtra` with `.window` style does not surface its host window through SwiftUI's
/// `@Environment(\.dismiss)` action, so a `Button` action stays open after firing. `Link` gets
/// dismissal for free because `NSWorkspace.shared.open` resigns key from the popover. Every other
/// button that triggers an app-window transition (Settings, eventually Onboarding/Welcome) needs
/// to dismiss the popover itself or it sits on top of the just-opened window.
///
/// The dismisser hooks into the same `viewDidMoveToWindow` lifecycle that
/// `MenuBarPresentationObserver` uses to grab the popover's real `NSWindow`, then stores a weak
/// reference so the SwiftUI side can call `dismiss()` on demand.
@MainActor
final class MenuBarPopoverDismisser: ObservableObject {
/// Weak so it dies with the popover; the SwiftUI view never owns the AppKit window.
fileprivate weak var hostWindow: NSWindow?

/// Closes the captured popover and clears the status bar button's pressed-state highlight.
/// Safe to call when the popover isn't visible — `orderOut` is a no-op on a hidden window.
func dismiss() {
// `resignKey` first so any responder-chain bookkeeping (e.g. a focused text field) flushes
// before the window goes off-screen; `orderOut` then matches the system's own popover
// dismissal animation path.
hostWindow?.resignKey()
hostWindow?.orderOut(nil)
// The status bar button keeps its highlighted/pressed appearance after a programmatic
// `orderOut` because AppKit only clears that state when the popover dismisses through its
// own click-toggle path. Walk our own windows for the `NSStatusBarButton` and reset the
// highlight so the menu bar icon doesn't look stuck in the "open" state once Settings is up.
Self.unhighlightStatusBarButton()
}

private static func unhighlightStatusBarButton() {
for window in NSApp.windows {
guard let contentView = window.contentView else { continue }
if let button = findStatusBarButton(in: contentView) {
button.highlight(false)
return
}
}
}

private static func findStatusBarButton(in view: NSView) -> NSStatusBarButton? {
if let button = view as? NSStatusBarButton {
return button
}
for subview in view.subviews {
if let button = findStatusBarButton(in: subview) {
return button
}
}
return nil
}
}

/// Invisible `NSView` whose only job is to forward its host `NSWindow` to a
/// `MenuBarPopoverDismisser`. Attached as a `.background` modifier so it inherits the popover's
/// real backing window without affecting layout.
struct MenuBarPopoverDismisserBinder: NSViewRepresentable {
let dismisser: MenuBarPopoverDismisser

func makeNSView(context: Context) -> WindowBindingView {
let view = WindowBindingView()
view.dismisser = dismisser
return view
}

func updateNSView(_ nsView: WindowBindingView, context: Context) {
nsView.dismisser = dismisser
}

final class WindowBindingView: NSView {
weak var dismisser: MenuBarPopoverDismisser?

override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
// `window` is nil while the popover is being torn down. Skipping the update in that
// case keeps a stale reference from outliving the actual popover instance.
if let window {
dismisser?.hostWindow = window
}
}
}
}
14 changes: 12 additions & 2 deletions Cotabby/UI/MenuBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ struct MenuBarView: View {
let onOpenSettings: () -> Void
let onReportFeedback: () -> Void

/// Captures the popover's host window so `Button` actions that open another window can dismiss
/// the popover behind them. SwiftUI's `\.dismiss` does not work for `MenuBarExtra(.window)`.
@StateObject private var popoverDismisser = MenuBarPopoverDismisser()

var body: some View {
VStack(alignment: .leading, spacing: 0) {
headerSection
Expand All @@ -41,6 +45,7 @@ struct MenuBarView: View {
runtimeModel.refreshAvailableModels()
}
)
.background(MenuBarPopoverDismisserBinder(dismisser: popoverDismisser))
.onAppear {
permissionManager.refresh()
runtimeModel.refreshAvailableModels()
Expand Down Expand Up @@ -238,8 +243,13 @@ struct MenuBarView: View {
.padding(.bottom, 10)

HStack {
Button("Settings", action: onOpenSettings)
.buttonStyle(.borderless)
Button("Settings") {
// Dismiss the popover before opening the Settings window so the popover doesn't
// remain on top of (and obscure) the Settings pane. See issue #455.
popoverDismisser.dismiss()
onOpenSettings()
}
.buttonStyle(.borderless)

Button("Check for Updates") {
appUpdateManager.checkForUpdates()
Expand Down