diff --git a/CHANGELOG.md b/CHANGELOG.md index da3695fa18..350dec97ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Localizations: add Spanish and Catalan language packs and fill missing localization keys (#1041). Thanks @seifreed! ### Fixed +- Menu: keep lower action rows stable when Refresh is highlighted or pressed (#1071). Thanks @MadanChaollaPark! - Linux CLI: avoid linking JetBrains provider parsing against `libxml2.so.2`, improving compatibility with newer distros that ship libxml2 2.15+ (#1046). Thanks @semsemyonoff! - Claude: remove the obsolete peak-hours indicator and setting now that Anthropic no longer applies peak-hour limits (#1023). Thanks @rohitjavvadi! - Antigravity: verify cloud model lists that report every quota as full against the user quota endpoint before showing remote OAuth usage (#1063). Thanks @devpras22! diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index 65537b3b44..db2010e88a 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -32,6 +32,22 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { } } + nonisolated func performPersistentSettingsAction() { + Task { @MainActor [weak self] in + guard let self else { return } + self.closeOpenMenusFromShortcutIfNeeded() + self.showSettingsGeneral() + } + } + + nonisolated func performPersistentQuitAction() { + Task { @MainActor [weak self] in + guard let self else { return } + self.closeOpenMenusFromShortcutIfNeeded() + self.quit() + } + } + nonisolated func performProviderNavigation(_ direction: StatusItemMenuProviderNavigationDirection) { Task { @MainActor [weak self] in self?.navigateProviderSwitcher(direction) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 795560ea49..b5dc35395d 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -729,8 +729,12 @@ extension StatusItemController { } menu.addItem(item) case let .action(title, action): - if case .refresh = action { - menu.addItem(self.makePersistentMenuActionItem(title: title, action: action, width: width)) + if self.usesPersistentMenuActionItem(for: action) { + menu.addItem(self.makePersistentMenuActionItem( + title: title, + action: action, + menu: menu, + width: width)) continue } @@ -799,16 +803,17 @@ extension StatusItemController { private func makePersistentMenuActionItem( title: String, action: MenuDescriptor.MenuAction, + menu: NSMenu, width: CGFloat) -> NSMenuItem { let shortcut = self.shortcut(for: action) let row = PersistentMenuActionItemView( title: title, - systemImageName: action.systemImageName, + systemImageName: self.persistentMenuActionSystemImageName(for: action), shortcutText: shortcut.map { self.shortcutLabel(for: $0) }, width: width, - onClick: { [weak self] in - self?.performPersistentMenuAction(action) + onClick: { [weak self, weak menu] in + self?.performPersistentMenuAction(action, in: menu) }) let item = NSMenuItem(title: title, action: nil, keyEquivalent: shortcut?.key ?? "") @@ -816,16 +821,13 @@ extension StatusItemController { item.isEnabled = true item.view = row item.toolTip = title - return item - } - - private func performPersistentMenuAction(_ action: MenuDescriptor.MenuAction) { - switch action { - case .refresh: - self.refreshNow() - default: - break + if action != .refresh { + let (selector, represented) = self.selector(for: action) + item.action = selector + item.target = self + item.representedObject = represented } + return item } private func shortcutLabel(for shortcut: (key: String, modifiers: NSEvent.ModifierFlags)) -> String { diff --git a/Sources/CodexBar/StatusItemController+MenuPresentation.swift b/Sources/CodexBar/StatusItemController+MenuPresentation.swift index d2e8d33f29..f1097a50b5 100644 --- a/Sources/CodexBar/StatusItemController+MenuPresentation.swift +++ b/Sources/CodexBar/StatusItemController+MenuPresentation.swift @@ -176,7 +176,7 @@ final class PersistentMenuActionItemView: NSView, MenuCardHighlighting { private let backgroundView = NSView() private let imageView = NSImageView() private let titleField: NSTextField - private let shortcutField: NSTextField? + private let shortcutField: NSTextField private let onClick: () -> Void override var intrinsicContentSize: NSSize { @@ -199,7 +199,7 @@ final class PersistentMenuActionItemView: NSView, MenuCardHighlighting { onClick: @escaping () -> Void) { self.titleField = NSTextField(labelWithString: title) - self.shortcutField = shortcutText.map(NSTextField.init(labelWithString:)) + self.shortcutField = NSTextField(labelWithString: shortcutText ?? "") self.onClick = onClick super.init(frame: NSRect(origin: .zero, size: NSSize(width: width, height: Self.rowHeight))) self.setupView(systemImageName: systemImageName) @@ -225,7 +225,7 @@ final class PersistentMenuActionItemView: NSView, MenuCardHighlighting { let secondaryColor = highlighted ? NSColor.selectedMenuItemTextColor : NSColor.secondaryLabelColor self.backgroundView.isHidden = !highlighted self.titleField.textColor = primaryColor - self.shortcutField?.textColor = secondaryColor + self.shortcutField.textColor = secondaryColor self.imageView.contentTintColor = primaryColor } @@ -250,6 +250,13 @@ final class PersistentMenuActionItemView: NSView, MenuCardHighlighting { self.titleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) self.titleField.translatesAutoresizingMaskIntoConstraints = false + self.shortcutField.font = NSFont.menuFont(ofSize: NSFont.smallSystemFontSize) + self.shortcutField.alignment = .right + self.shortcutField.lineBreakMode = .byTruncatingTail + self.shortcutField.setContentHuggingPriority(.required, for: .horizontal) + self.shortcutField.setContentCompressionResistancePriority(.required, for: .horizontal) + self.shortcutField.translatesAutoresizingMaskIntoConstraints = false + let spacer = NSView() spacer.translatesAutoresizingMaskIntoConstraints = false spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) @@ -262,11 +269,7 @@ final class PersistentMenuActionItemView: NSView, MenuCardHighlighting { stack.addArrangedSubview(self.imageView) stack.addArrangedSubview(self.titleField) stack.addArrangedSubview(spacer) - if let shortcutField { - shortcutField.font = NSFont.menuFont(ofSize: NSFont.smallSystemFontSize) - shortcutField.translatesAutoresizingMaskIntoConstraints = false - stack.addArrangedSubview(shortcutField) - } + stack.addArrangedSubview(self.shortcutField) self.addSubview(stack) NSLayoutConstraint.activate([ @@ -277,6 +280,7 @@ final class PersistentMenuActionItemView: NSView, MenuCardHighlighting { self.imageView.widthAnchor.constraint(equalToConstant: 18), self.imageView.heightAnchor.constraint(equalToConstant: 18), + self.shortcutField.widthAnchor.constraint(equalToConstant: 38), stack.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 12), stack.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -12), diff --git a/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift b/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift new file mode 100644 index 0000000000..6037bc432b --- /dev/null +++ b/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift @@ -0,0 +1,56 @@ +import AppKit + +extension StatusItemController { + func usesPersistentMenuActionItem(for action: MenuDescriptor.MenuAction) -> Bool { + switch action { + case .installUpdate, .refresh, .settings, .about, .quit: + true + default: + false + } + } + + func persistentMenuActionSystemImageName(for action: MenuDescriptor.MenuAction) -> String? { + switch action { + case .installUpdate: + "arrow.down.circle" + case .refresh: + MenuDescriptor.MenuActionSystemImage.refresh.rawValue + case .settings: + MenuDescriptor.MenuActionSystemImage.settings.rawValue + case .about: + MenuDescriptor.MenuActionSystemImage.about.rawValue + case .quit: + MenuDescriptor.MenuActionSystemImage.quit.rawValue + default: + action.systemImageName + } + } + + func performPersistentMenuAction(_ action: MenuDescriptor.MenuAction, in menu: NSMenu?) { + switch action { + case .refresh: + self.refreshNow() + case .installUpdate: + self.closeMenuForPersistentAction(menu) + self.installUpdate() + case .settings: + self.closeMenuForPersistentAction(menu) + self.showSettingsGeneral() + case .about: + self.closeMenuForPersistentAction(menu) + self.showSettingsAbout() + case .quit: + self.closeMenuForPersistentAction(menu) + self.quit() + default: + break + } + } + + private func closeMenuForPersistentAction(_ menu: NSMenu?) { + guard let menu else { return } + menu.cancelTrackingWithoutAnimation() + self.forgetClosedMenu(menu) + } +} diff --git a/Sources/CodexBar/StatusItemMenu.swift b/Sources/CodexBar/StatusItemMenu.swift index e6b1070014..e16a31438c 100644 --- a/Sources/CodexBar/StatusItemMenu.swift +++ b/Sources/CodexBar/StatusItemMenu.swift @@ -7,6 +7,8 @@ enum StatusItemMenuProviderNavigationDirection { protocol StatusItemMenuPersistentActionDelegate: AnyObject { func performPersistentRefreshAction() + func performPersistentSettingsAction() + func performPersistentQuitAction() func performProviderNavigation(_ direction: StatusItemMenuProviderNavigationDirection) } @@ -14,8 +16,15 @@ final class StatusItemMenu: NSMenu { weak var persistentActionDelegate: StatusItemMenuPersistentActionDelegate? override func performKeyEquivalent(with event: NSEvent) -> Bool { - if Self.isRefreshKeyEquivalent(event) { - self.persistentActionDelegate?.performPersistentRefreshAction() + if let action = Self.persistentAction(for: event) { + switch action { + case .refresh: + self.persistentActionDelegate?.performPersistentRefreshAction() + case .settings: + self.persistentActionDelegate?.performPersistentSettingsAction() + case .quit: + self.persistentActionDelegate?.performPersistentQuitAction() + } return true } if let direction = Self.providerNavigationDirection(for: event), @@ -28,12 +37,28 @@ final class StatusItemMenu: NSMenu { return super.performKeyEquivalent(with: event) } - private nonisolated static func isRefreshKeyEquivalent(_ event: NSEvent) -> Bool { - guard event.type == .keyDown else { return false } - guard event.charactersIgnoringModifiers?.lowercased() == "r" else { return false } + private enum PersistentAction { + case refresh + case settings + case quit + } + + private nonisolated static func persistentAction(for event: NSEvent) -> PersistentAction? { + guard event.type == .keyDown else { return nil } let relevantModifiers = event.modifierFlags.intersection([.command, .option, .control, .shift]) - return relevantModifiers == .command + guard relevantModifiers == .command else { return nil } + + switch event.charactersIgnoringModifiers?.lowercased() { + case "r": + return .refresh + case ",": + return .settings + case "q": + return .quit + default: + return nil + } } private nonisolated static func providerNavigationDirection( diff --git a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift index 7e4f5ceac8..dc52083750 100644 --- a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift @@ -5,17 +5,39 @@ import Testing private final class RefreshShortcutRecorder: StatusItemMenuPersistentActionDelegate { var refreshCount = 0 + var settingsCount = 0 + var quitCount = 0 var navigationDirections: [StatusItemMenuProviderNavigationDirection] = [] func performPersistentRefreshAction() { self.refreshCount += 1 } + func performPersistentSettingsAction() { + self.settingsCount += 1 + } + + func performPersistentQuitAction() { + self.quitCount += 1 + } + func performProviderNavigation(_ direction: StatusItemMenuProviderNavigationDirection) { self.navigationDirections.append(direction) } } +@MainActor +private final class UpdateReadyUpdater: UpdaterProviding { + var automaticallyChecksForUpdates = false + var automaticallyDownloadsUpdates = false + let isAvailable = true + let unavailableReason: String? = nil + let updateStatus = UpdateStatus(isUpdateReady: true) + + func checkForUpdates(_: Any?) {} + func installUpdate() {} +} + @MainActor @Suite(.serialized) struct StatusMenuPersistentRefreshTests { @@ -31,21 +53,28 @@ struct StatusMenuPersistentRefreshTests { syntheticTokenStore: NoopSyntheticTokenStore()) } - @Test - func `refresh menu item is view backed so mouse activation keeps the menu open`() throws { - let settings = self.makeSettings() - settings.refreshFrequency = .manual - settings.mergeIcons = false - + private func makeController( + settings: SettingsStore, + updater: UpdaterProviding = DisabledUpdaterController()) -> StatusItemController + { let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) - let controller = StatusItemController( + return StatusItemController( store: store, settings: settings, account: fetcher.loadAccountInfo(), - updater: DisabledUpdaterController(), + updater: updater, preferencesSelection: PreferencesSelection(), statusBar: .system) + } + + @Test + func `refresh menu item is view backed so mouse activation keeps the menu open`() throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) let menu = controller.makeMenu(for: .codex) controller.menuWillOpen(menu) @@ -58,15 +87,65 @@ struct StatusMenuPersistentRefreshTests { #expect(refreshItem.keyEquivalentModifierMask == [.command]) } + @Test + func `meta menu actions use the same stable row implementation`() throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings, updater: UpdateReadyUpdater()) + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + for title in ["Update ready, restart now?", "Refresh", "Settings...", "About CodexBar", "Quit"] { + let item = try #require(menu.items.first { $0.title == title }) + #expect(item.view is PersistentMenuActionItemView) + #expect(item.view?.frame.height == PersistentMenuActionItemView.rowHeight) + if title == "Refresh" { + #expect(item.action == nil) + #expect(item.target == nil) + } else { + #expect(item.action != nil) + #expect(item.target === controller) + } + } + } + @Test func `refresh menu item view keeps fixed metrics while highlighted`() { - let view = PersistentMenuActionItemView( - title: "Refresh", - systemImageName: "arrow.clockwise", - shortcutText: "⌘R", - width: 320, - onClick: {}) + let views = [ + PersistentMenuActionItemView( + title: "Refresh", + systemImageName: "arrow.clockwise", + shortcutText: "⌘R", + width: 320, + onClick: {}), + PersistentMenuActionItemView( + title: "Settings...", + systemImageName: "gearshape", + shortcutText: "⌘,", + width: 320, + onClick: {}), + PersistentMenuActionItemView( + title: "About CodexBar", + systemImageName: "info.circle", + shortcutText: nil, + width: 320, + onClick: {}), + PersistentMenuActionItemView( + title: "Quit", + systemImageName: nil, + shortcutText: nil, + width: 320, + onClick: {}), + ] + + for view in views { + self.assertStableMetrics(view) + } + } + private func assertStableMetrics(_ view: PersistentMenuActionItemView) { #expect(view.frame.height == PersistentMenuActionItemView.rowHeight) #expect(view.intrinsicContentSize.height == PersistentMenuActionItemView.rowHeight) #expect(view.fittingSize.height == PersistentMenuActionItemView.rowHeight) @@ -87,23 +166,31 @@ struct StatusMenuPersistentRefreshTests { } @Test - func `status item menu intercepts refresh shortcut without native item selection`() throws { + func `status item menu intercepts persistent shortcuts without native item selection`() throws { let menu = StatusItemMenu() let recorder = RefreshShortcutRecorder() menu.persistentActionDelegate = recorder - let event = try #require(NSEvent.keyEvent( + + #expect(try menu.performKeyEquivalent(with: self.keyEvent("r", keyCode: 15)) == true) + #expect(try menu.performKeyEquivalent(with: self.keyEvent(",", keyCode: 43)) == true) + #expect(try menu.performKeyEquivalent(with: self.keyEvent("q", keyCode: 12)) == true) + + #expect(recorder.refreshCount == 1) + #expect(recorder.settingsCount == 1) + #expect(recorder.quitCount == 1) + } + + private func keyEvent(_ characters: String, keyCode: UInt16) throws -> NSEvent { + try #require(NSEvent.keyEvent( with: .keyDown, location: .zero, modifierFlags: [.command], timestamp: 0, windowNumber: 0, context: nil, - characters: "r", - charactersIgnoringModifiers: "r", + characters: characters, + charactersIgnoringModifiers: characters, isARepeat: false, - keyCode: 15)) - - #expect(menu.performKeyEquivalent(with: event) == true) - #expect(recorder.refreshCount == 1) + keyCode: keyCode)) } }