Skip to content
Open
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
58 changes: 58 additions & 0 deletions Sources/CodexBar/DetachedPanelContentView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import CodexBarCore
import SwiftUI

@MainActor
struct DetachedPanelContentView: View {
@Bindable var store: UsageStore
@Bindable var settings: SettingsStore
let panelWidth: CGFloat
let menuCardModelProvider: (UsageProvider?) -> UsageMenuCardView.Model?
let closePanel: () -> Void

var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Text("Overview")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
Button {
self.closePanel()
} label: {
Image(systemName: "xmark")
}
.buttonStyle(.plain)
.help("Close Panel")
}

Divider()

ScrollView {
VStack(alignment: .leading, spacing: 10) {
if self.overviewProviders.isEmpty {
Text("No usage configured.")
.font(.footnote)
.foregroundStyle(.secondary)
.frame(width: self.panelWidth, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 8)
} else {
ForEach(self.overviewProviders, id: \.self) { provider in
if let model = self.menuCardModelProvider(provider) {
UsageMenuCardView(model: model, width: self.panelWidth)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.scrollIndicators(.visible)
}
.padding(10)
.frame(width: self.panelWidth + 20, alignment: .leading)
}

private var overviewProviders: [UsageProvider] {
self.settings.resolvedMergedOverviewProviders(activeProviders: self.store.enabledProviders())
}
}
123 changes: 123 additions & 0 deletions Sources/CodexBar/DetachedPanelController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import AppKit
import CodexBarCore
import SwiftUI

@MainActor
private final class DetachedPanelWindow: NSPanel {
override var canBecomeKey: Bool {
true
}

override var canBecomeMain: Bool {
false
}
}

@MainActor
final class DetachedPanelController: NSWindowController, NSWindowDelegate {
private static let cardWidth: CGFloat = 310
private static let panelMargin: CGFloat = 8
private static let defaultSize = NSSize(width: 330, height: 560)

private let store: UsageStore
private let settings: SettingsStore
private let menuCardModelProvider: (UsageProvider?) -> UsageMenuCardView.Model?
private let onClose: () -> Void

init(
store: UsageStore,
settings: SettingsStore,
menuCardModelProvider: @escaping (UsageProvider?) -> UsageMenuCardView.Model?,
onClose: @escaping () -> Void)
{
self.store = store
self.settings = settings
self.menuCardModelProvider = menuCardModelProvider
self.onClose = onClose
super.init(window: nil)
self.buildWindow()
}

@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

func show(anchoredTo statusButton: NSStatusBarButton?) {
guard let panel = self.window else { return }
self.positionPanel(panel, anchoredTo: statusButton)
self.showWindow(nil)
panel.orderFrontRegardless()
}

func bringToFront() {
guard let panel = self.window else { return }
self.showWindow(nil)
panel.orderFrontRegardless()
}

private func buildWindow() {
let panel = DetachedPanelWindow(
contentRect: Self.defaultFrame(),
styleMask: [.titled, .closable, .nonactivatingPanel, .utilityWindow, .fullSizeContentView],
backing: .buffered,
defer: false)
panel.title = "CodexBar"
panel.titlebarAppearsTransparent = false
panel.isReleasedWhenClosed = false
panel.hidesOnDeactivate = false
panel.isFloatingPanel = true
panel.collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary]
panel.isMovableByWindowBackground = true
panel.delegate = self

let rootView = DetachedPanelContentView(
store: self.store,
settings: self.settings,
panelWidth: Self.cardWidth,
menuCardModelProvider: self.menuCardModelProvider,
closePanel: { [weak self] in
self?.close()
})
panel.contentView = NSHostingView(rootView: rootView)

self.window = panel
}

private func positionPanel(_ panel: NSWindow, anchoredTo statusButton: NSStatusBarButton?) {
guard let statusButton,
let statusItemWindow = statusButton.window
else {
panel.center()
return
}

let buttonFrameInWindow = statusButton.convert(statusButton.bounds, to: nil)
let buttonFrameOnScreen = statusItemWindow.convertToScreen(buttonFrameInWindow)
let screenFrame = statusItemWindow.screen?.visibleFrame
?? NSScreen.main?.visibleFrame
?? NSRect(origin: .zero, size: Self.defaultSize)

var origin = NSPoint(
x: buttonFrameOnScreen.midX - panel.frame.width / 2,
y: buttonFrameOnScreen.minY - panel.frame.height - Self.panelMargin)
let maxX = screenFrame.maxX - panel.frame.width - Self.panelMargin
let maxY = screenFrame.maxY - panel.frame.height - Self.panelMargin
origin.x = min(max(screenFrame.minX + Self.panelMargin, origin.x), maxX)
origin.y = min(max(screenFrame.minY + Self.panelMargin, origin.y), maxY)
panel.setFrameOrigin(origin)
}

private static func defaultFrame() -> NSRect {
let visible = NSScreen.main?.visibleFrame ?? NSRect(origin: .zero, size: Self.defaultSize)
let width = min(Self.defaultSize.width, visible.width * 0.9)
let height = min(Self.defaultSize.height, visible.height * 0.9)
let origin = NSPoint(x: visible.midX - width / 2, y: visible.midY - height / 2)
return NSRect(origin: origin, size: NSSize(width: width, height: height))
}

func windowWillClose(_ notification: Notification) {
_ = notification
self.onClose()
}
}
3 changes: 3 additions & 0 deletions Sources/CodexBar/MenuContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ struct MenuContent: View {
self.actions.openDashboard()
case .statusPage:
self.actions.openStatusPage()
case .popOut:
self.actions.popOut()
case let .switchAccount(provider):
self.actions.switchAccount(provider)
case let .openTerminal(command):
Expand All @@ -112,6 +114,7 @@ struct MenuActions {
let refreshAugmentSession: () -> Void
let openDashboard: () -> Void
let openStatusPage: () -> Void
let popOut: () -> Void
let switchAccount: (UsageProvider) -> Void
let openTerminal: (String) -> Void
let openSettings: () -> Void
Expand Down
4 changes: 4 additions & 0 deletions Sources/CodexBar/MenuDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ struct MenuDescriptor {
case refresh = "arrow.clockwise"
case dashboard = "chart.bar"
case statusPage = "waveform.path.ecg"
case popOut = "rectangle.portrait.and.arrow.right"
case switchAccount = "key"
case openTerminal = "terminal"
case loginToProvider = "arrow.right.square"
Expand All @@ -38,6 +39,7 @@ struct MenuDescriptor {
case refreshAugmentSession
case dashboard
case statusPage
case popOut
case switchAccount(UsageProvider)
case openTerminal(command: String)
case loginToProvider(url: String)
Expand Down Expand Up @@ -319,6 +321,7 @@ struct MenuDescriptor {
entries.append(.action("Update ready, restart now?", .installUpdate))
}
entries.append(contentsOf: [
.action("Pop Out", .popOut),
.action("Settings...", .settings),
.action("About CodexBar", .about),
.action("Quit", .quit),
Expand Down Expand Up @@ -413,6 +416,7 @@ extension MenuDescriptor.MenuAction {
case .refreshAugmentSession: MenuDescriptor.MenuActionSystemImage.refresh.rawValue
case .dashboard: MenuDescriptor.MenuActionSystemImage.dashboard.rawValue
case .statusPage: MenuDescriptor.MenuActionSystemImage.statusPage.rawValue
case .popOut: MenuDescriptor.MenuActionSystemImage.popOut.rawValue
case .switchAccount: MenuDescriptor.MenuActionSystemImage.switchAccount.rawValue
case .openTerminal: MenuDescriptor.MenuActionSystemImage.openTerminal.rawValue
case .loginToProvider: MenuDescriptor.MenuActionSystemImage.loginToProvider.rawValue
Expand Down
33 changes: 33 additions & 0 deletions Sources/CodexBar/StatusItemController+Actions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,39 @@ extension StatusItemController {
self.openSettings(tab: .about)
}

@objc func popOutPanelFromMenu(_ sender: Any?) {
_ = sender
self.popOutPanel()
}

func popOutPanel() {
NSApp.sendAction(#selector(NSMenu.cancelTracking), to: nil, from: nil)

if let detachedPanel {
detachedPanel.bringToFront()
return
}

let controller = DetachedPanelController(
store: self.store,
settings: self.settings,
menuCardModelProvider: { [weak self] provider in
self?.menuCardModel(for: provider)
},
onClose: { [weak self] in
self?.detachedPanel = nil
})
self.detachedPanel = controller
let anchorButton: NSStatusBarButton? = if self.shouldMergeIcons {
self.statusItem.button
} else if let provider = self.lastMenuProvider {
self.statusItems[provider]?.button
} else {
self.statusItem.button
}
controller.show(anchoredTo: anchorButton)
}

func openMenuFromShortcut() {
if self.shouldMergeIcons {
self.statusItem.button?.performClick(nil)
Expand Down
3 changes: 2 additions & 1 deletion Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,7 @@ extension StatusItemController {
case .refreshAugmentSession: (#selector(self.refreshAugmentSession), nil)
case .dashboard: (#selector(self.openDashboard), nil)
case .statusPage: (#selector(self.openStatusPage), nil)
case .popOut: (#selector(self.popOutPanelFromMenu(_:)), nil)
case let .switchAccount(provider): (#selector(self.runSwitchAccount(_:)), provider.rawValue)
case let .openTerminal(command): (#selector(self.openTerminalCommand(_:)), command)
case let .loginToProvider(url): (#selector(self.openLoginToProvider(_:)), url)
Expand Down Expand Up @@ -1355,7 +1356,7 @@ extension StatusItemController {
}
}

private func menuCardModel(
func menuCardModel(
for provider: UsageProvider?,
snapshotOverride: UsageSnapshot? = nil,
errorOverride: String? = nil) -> UsageMenuCardView.Model?
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin
}

var creditsPurchaseWindow: OpenAICreditsPurchaseWindowController?
var detachedPanel: DetachedPanelController?

var activeLoginProvider: UsageProvider? {
didSet {
Expand Down
32 changes: 32 additions & 0 deletions Tests/CodexBarTests/StatusMenuTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -977,4 +977,36 @@ extension StatusMenuTests {
#expect(ids.contains(where: { $0.hasPrefix("overviewRow-") }) == false)
#expect(self.switcherButtons(in: menu).first(where: { $0.state == .on })?.tag == 2)
}

@Test
func menuIncludesPopOutBeforeSettings() throws {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = false

let registry = ProviderRegistry.shared
for provider in UsageProvider.allCases {
guard let metadata = registry.metadata[provider] else { continue }
settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex)
}

let fetcher = UsageFetcher()
let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
let controller = StatusItemController(
store: store,
settings: settings,
account: fetcher.loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: self.makeStatusBarForTesting())

let menu = controller.makeMenu(for: .codex)
controller.menuWillOpen(menu)

let popOutIndex = try #require(menu.items.firstIndex(where: { $0.title == "Pop Out" }))
let settingsIndex = try #require(menu.items.firstIndex(where: { $0.title == "Settings..." }))
#expect(popOutIndex < settingsIndex)
}
}