diff --git a/CHANGELOG.md b/CHANGELOG.md index dda40843d..e8e649f70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Localizations: add Spanish and Catalan language packs and fill missing localization keys (#1041). Thanks @seifreed! ### Fixed +- Codex: accept the first click in the account switcher inside menu popovers (#1079). Thanks @ptstory! - Codex/Claude: terminate PTY child process trees during probe cleanup so wrapper-launched CLI descendants do not linger after sessions finish (#1085). Thanks @mickobizzle! - OpenAI: parse Wednesday and Saturday dashboard reset lines so rate-limit reset times are not dropped on those days (#1080). Thanks @m1qaweb! - Localization: translate provider-detail labels and empty states when Simplified Chinese is selected (#1051). Thanks @wang93wei! diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index a5fa0ae16..90dd05c2c 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -1036,6 +1036,7 @@ final class CodexAccountSwitcherView: NSView { private let accounts: [CodexVisibleAccount] private let onSelect: (CodexVisibleAccount) -> Void private var selectedAccountID: String + private var pressedAccountID: String? private var buttons: [NSButton] = [] private let preferredSize: NSSize private let rowSpacing: CGFloat = 4 @@ -1278,10 +1279,50 @@ final class CodexAccountSwitcherView: NSView { } } + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } + + override func hitTest(_ point: NSPoint) -> NSView? { + let descendant = super.hitTest(point) + if descendant != nil, descendant !== self { + self.toolTip = (descendant as? NSButton)?.toolTip + return self + } + self.toolTip = nil + return descendant + } + + override func mouseDown(with event: NSEvent) { + let location = self.convert(event.locationInWindow, from: nil) + self.pressedAccountID = self.accountID(at: location) + } + + override func mouseUp(with event: NSEvent) { + defer { self.pressedAccountID = nil } + guard let pressedAccountID = self.pressedAccountID else { return } + let location = self.convert(event.locationInWindow, from: nil) + guard let releasedAccountID = self.accountID(at: location), + releasedAccountID == pressedAccountID, + let account = self.accounts.first(where: { $0.id == pressedAccountID }) + else { + return + } + self.applySelection(account) + } + + private func accountID(at pointInSelf: NSPoint) -> String? { + self.buttons.first(where: { self.convert($0.bounds, from: $0).contains(pointInSelf) })?.identifier?.rawValue + } + @objc private func handleSelect(_ sender: NSButton) { - guard let accountID = sender.identifier?.rawValue else { return } - guard let account = self.accounts.first(where: { $0.id == accountID }) else { return } - self.selectedAccountID = accountID + guard let accountID = sender.identifier?.rawValue, + let account = self.accounts.first(where: { $0.id == accountID }) else { return } + self.applySelection(account) + } + + private func applySelection(_ account: CodexVisibleAccount) { + self.selectedAccountID = account.id self.updateButtonStyles() self.onSelect(account) } @@ -1296,8 +1337,58 @@ final class CodexAccountSwitcherView: NSView { } func _test_selectAccount(id: String) { - guard let button = self.buttons.first(where: { $0.identifier?.rawValue == id }) else { return } - self.handleSelect(button) + guard let account = self.accounts.first(where: { $0.id == id }) else { return } + self.applySelection(account) + } + + func _test_simulateRuntimeClick(id: String) -> Bool { + guard let button = self.buttons.first(where: { $0.identifier?.rawValue == id }) else { return false } + self.updateConstraintsForSubtreeIfNeeded() + self.layoutSubtreeIfNeeded() + let point = self.convert(NSPoint(x: button.bounds.midX, y: button.bounds.midY), from: button) + guard let mouseDownEvent = NSEvent.mouseEvent( + with: .leftMouseDown, + location: point, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + eventNumber: 1, + clickCount: 1, + pressure: 1), + let mouseUpEvent = NSEvent.mouseEvent( + with: .leftMouseUp, + location: point, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + eventNumber: 2, + clickCount: 1, + pressure: 0) + else { + return false + } + self.mouseDown(with: mouseDownEvent) + self.mouseUp(with: mouseUpEvent) + return self.selectedAccountID == id + } + + func _test_hitTestSwallowsChildButton(id: String) -> Bool { + guard let button = self.buttons.first(where: { $0.identifier?.rawValue == id }) else { return false } + self.updateConstraintsForSubtreeIfNeeded() + self.layoutSubtreeIfNeeded() + let point = self.convert(NSPoint(x: button.bounds.midX, y: button.bounds.midY), from: button) + return self.hitTest(point) === self + } + + func _test_toolTipAfterHitTest(id: String) -> String? { + guard let button = self.buttons.first(where: { $0.identifier?.rawValue == id }) else { return nil } + self.updateConstraintsForSubtreeIfNeeded() + self.layoutSubtreeIfNeeded() + let point = self.convert(NSPoint(x: button.bounds.midX, y: button.bounds.midY), from: button) + _ = self.hitTest(point) + return self.toolTip } #endif } diff --git a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift index a312dceaf..4ad6ce803 100644 --- a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift +++ b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift @@ -872,6 +872,129 @@ struct StatusMenuCodexSwitcherTests { } } +extension StatusMenuCodexSwitcherTests { + @Test + func `codex account switcher swallows child button hit testing for first click`() throws { + let managedID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let accounts = [ + CodexVisibleAccount( + id: "live@example.com", + email: "live@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false), + CodexVisibleAccount( + id: "managed@example.com", + email: "managed@example.com", + storedAccountID: managedID, + selectionSource: .managedAccount(id: managedID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + ] + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts.first?.id, + width: 220, + onSelect: { _ in }) + + #expect(view.acceptsFirstMouse(for: nil) == true) + #expect(view._test_hitTestSwallowsChildButton(id: "managed@example.com") == true) + #expect(view._test_toolTipAfterHitTest(id: "managed@example.com") == "managed@example.com") + } + + @Test + func `codex account switcher routes runtime click path to selected account`() throws { + let managedID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let accounts = [ + CodexVisibleAccount( + id: "live@example.com", + email: "live@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false), + CodexVisibleAccount( + id: "managed@example.com", + email: "managed@example.com", + storedAccountID: managedID, + selectionSource: .managedAccount(id: managedID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + ] + var selectedAccount: CodexVisibleAccount? + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts.first?.id, + width: 220, + onSelect: { selectedAccount = $0 }) + + #expect(view._test_simulateRuntimeClick(id: "managed@example.com") == true) + #expect(selectedAccount == accounts[1]) + } + + @Test + func `codex account switcher runtime click resolves second row buttons`() throws { + let managedID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let secondManagedID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-222222222222")) + let accounts = [ + CodexVisibleAccount( + id: "live@example.com", + email: "live@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false), + CodexVisibleAccount( + id: "managed@example.com", + email: "managed@example.com", + storedAccountID: managedID, + selectionSource: .managedAccount(id: managedID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + CodexVisibleAccount( + id: "team@example.com", + email: "team@example.com", + storedAccountID: secondManagedID, + selectionSource: .managedAccount(id: secondManagedID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + CodexVisibleAccount( + id: "second-row@example.com", + email: "second-row@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: false, + isLive: true, + canReauthenticate: true, + canRemove: false), + ] + var selectedAccount: CodexVisibleAccount? + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts.first?.id, + width: 220, + onSelect: { selectedAccount = $0 }) + + #expect(view._test_simulateRuntimeClick(id: "second-row@example.com") == true) + #expect(selectedAccount == accounts[3]) + } +} + @MainActor extension StatusMenuCodexSwitcherTests { @Test