Skip to content
Closed
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
446 changes: 446 additions & 0 deletions Sources/CodexBar/FloatingDashboardView.swift

Large diffs are not rendered by default.

179 changes: 179 additions & 0 deletions Sources/CodexBar/FloatingDashboardWindowController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import AppKit
import Observation
import SwiftUI

@MainActor
final class FloatingDashboardWindowController {
private var panel: NSPanel?
private let store: UsageStore
private let settings: SettingsStore
private nonisolated(unsafe) var moveObserver: NSObjectProtocol?
private var lastHorizontal: Bool

init(store: UsageStore, settings: SettingsStore) {
self.store = store
self.settings = settings
self.lastHorizontal = settings.floatingDashboardHorizontal
}

func show() {
if let panel {
panel.orderFront(nil)
return
}

let panel = NSPanel(
contentRect: .zero,
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: true)
panel.level = .floating
panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
panel.isMovableByWindowBackground = true
panel.backgroundColor = .clear
panel.hasShadow = true
panel.isOpaque = false

let view = FloatingDashboardView(store: self.store, settings: self.settings)
let hosting = NSHostingView(rootView: view)
panel.contentView = hosting

let size = hosting.fittingSize
panel.setContentSize(size)
self.applyPosition(to: panel, size: size)

panel.orderFront(nil)
self.panel = panel

self.moveObserver = NotificationCenter.default.addObserver(
forName: NSWindow.didMoveNotification,
object: panel,
queue: .main)
{ [weak self] _ in
Task { @MainActor [weak self] in
self?.savePosition()
}
}

self.observeStoreChanges()
}

func hide() {
if let observer = self.moveObserver {
NotificationCenter.default.removeObserver(observer)
self.moveObserver = nil
}
self.panel?.orderOut(nil)
self.panel = nil
}

func toggle() {
if self.panel != nil {
self.hide()
} else {
self.show()
}
}

private func applyPosition(to panel: NSPanel, size: NSSize) {
if let saved = self.settings.floatingDashboardPosition,
let x = saved["x"],
let y = saved["y"]
{
panel.setFrameOrigin(NSPoint(x: x, y: y))
} else {
let screen = NSScreen.main ?? NSScreen.screens.first
let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
let x = visibleFrame.maxX - size.width - 20
let y = visibleFrame.minY + 20
panel.setFrameOrigin(NSPoint(x: x, y: y))
}
}

private func savePosition() {
guard let frame = self.panel?.frame else { return }
self.settings.floatingDashboardPosition = [
"x": Double(frame.origin.x),
"y": Double(frame.origin.y),
]
}

private func observeStoreChanges() {
withObservationTracking {
_ = self.store.menuObservationToken
_ = self.settings.floatingDashboardHorizontal
} onChange: { [weak self] in
Task { @MainActor [weak self] in
guard let self, self.panel != nil else { return }
self.resizePanel()
self.observeStoreChanges()
}
}
}

private func resizePanel() {
guard self.panel != nil else { return }
// If the layout orientation changed, recreate the panel to avoid constraint crashes
let currentHorizontal = self.settings.floatingDashboardHorizontal
if currentHorizontal != self.lastHorizontal {
self.lastHorizontal = currentHorizontal
self.recreatePanel()
return
}
guard let panel, let hosting = panel.contentView as? NSHostingView<FloatingDashboardView> else { return }
let origin = panel.frame.origin
let newSize = hosting.fittingSize
panel.setContentSize(newSize)
panel.setFrameOrigin(origin)
}

private func recreatePanel() {
let savedOrigin = self.panel?.frame.origin
self.hide()

let panel = NSPanel(
contentRect: .zero,
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: true)
panel.level = .floating
panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
panel.isMovableByWindowBackground = true
panel.backgroundColor = .clear
panel.hasShadow = true
panel.isOpaque = false

let view = FloatingDashboardView(store: self.store, settings: self.settings)
let hosting = NSHostingView(rootView: view)
panel.contentView = hosting

let size = hosting.fittingSize
panel.setContentSize(size)
if let origin = savedOrigin {
panel.setFrameOrigin(origin)
} else {
self.applyPosition(to: panel, size: size)
}

panel.orderFront(nil)
self.panel = panel

self.moveObserver = NotificationCenter.default.addObserver(
forName: NSWindow.didMoveNotification,
object: panel,
queue: .main)
{ [weak self] _ in
Task { @MainActor [weak self] in
self?.savePosition()
}
}

self.observeStoreChanges()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid double-subscribing store observation on panel recreate

The onChange path in observeStoreChanges() already re-registers observation after each change, but recreatePanel() registers again here; when users toggle layout (which calls recreatePanel()), each toggle leaves an extra active tracker, so later store updates invoke duplicate resize/recreate work and create avoidable UI churn.

Useful? React with 👍 / 👎.

}

deinit {
if let observer = self.moveObserver {
NotificationCenter.default.removeObserver(observer)
}
}
}
3 changes: 3 additions & 0 deletions Sources/CodexBar/MenuContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ struct MenuContent: View {
self.actions.quit()
case let .copyError(message):
self.actions.copyError(message)
case .floatingDashboard:
self.actions.toggleFloatingDashboard()
}
}
}
Expand All @@ -118,6 +120,7 @@ struct MenuActions {
let openAbout: () -> Void
let quit: () -> Void
let copyError: (String) -> Void
let toggleFloatingDashboard: () -> Void
}

@MainActor
Expand Down
10 changes: 8 additions & 2 deletions Sources/CodexBar/MenuDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ struct MenuDescriptor {
case switchAccount = "key"
case openTerminal = "terminal"
case loginToProvider = "arrow.right.square"
case floatingDashboard = "rectangle.on.rectangle"
case settings = "gearshape"
case about = "info.circle"
case quit = "xmark.rectangle"
Expand All @@ -41,6 +42,7 @@ struct MenuDescriptor {
case switchAccount(UsageProvider)
case openTerminal(command: String)
case loginToProvider(url: String)
case floatingDashboard
case settings
case about
case quit
Expand Down Expand Up @@ -97,7 +99,9 @@ struct MenuDescriptor {
sections.append(actions)
}
}
sections.append(Self.metaSection(updateReady: updateReady))
sections.append(Self.metaSection(
updateReady: updateReady,
floatingDashboardEnabled: settings.floatingDashboardEnabled))

return MenuDescriptor(sections: sections)
}
Expand Down Expand Up @@ -313,11 +317,12 @@ struct MenuDescriptor {
return Section(entries: entries)
}

private static func metaSection(updateReady: Bool) -> Section {
private static func metaSection(updateReady: Bool, floatingDashboardEnabled: Bool = false) -> Section {
var entries: [Entry] = []
if updateReady {
entries.append(.action("Update ready, restart now?", .installUpdate))
}
entries.append(.action("Floating Dashboard", .floatingDashboard))
entries.append(contentsOf: [
.action("Settings...", .settings),
.action("About CodexBar", .about),
Expand Down Expand Up @@ -416,6 +421,7 @@ extension MenuDescriptor.MenuAction {
case .switchAccount: MenuDescriptor.MenuActionSystemImage.switchAccount.rawValue
case .openTerminal: MenuDescriptor.MenuActionSystemImage.openTerminal.rawValue
case .loginToProvider: MenuDescriptor.MenuActionSystemImage.loginToProvider.rawValue
case .floatingDashboard: MenuDescriptor.MenuActionSystemImage.floatingDashboard.rawValue
case .copyError: MenuDescriptor.MenuActionSystemImage.copyError.rawValue
}
}
Expand Down
4 changes: 4 additions & 0 deletions Sources/CodexBar/MenuHighlightStyle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ extension EnvironmentValues {
}

enum MenuHighlightStyle {
// Solarized Light palette
static let solarizedLightBase3 = NSColor(srgbRed: 253 / 255.0, green: 246 / 255.0, blue: 227 / 255.0, alpha: 1.0)
static let solarizedLightBase3Color = Color(nsColor: solarizedLightBase3)

static let selectionText = Color(nsColor: .selectedMenuItemTextColor)
static let normalPrimaryText = Color(nsColor: .controlTextColor)
static let normalSecondaryText = Color(nsColor: .secondaryLabelColor)
Expand Down
4 changes: 4 additions & 0 deletions Sources/CodexBar/PreferencesDisplayPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ struct DisplayPane: View {
binding: self.$settings.menuBarShowsHighestUsage)
.disabled(!self.settings.mergeIcons)
.opacity(self.settings.mergeIcons ? 1 : 0.5)
PreferenceToggleRow(
title: "Floating dashboard",
subtitle: "Show a floating panel on the desktop with live usage data.",
binding: self.$settings.floatingDashboardEnabled)
PreferenceToggleRow(
title: "Menu bar shows percent",
subtitle: "Replace critter bars with provider branding icons and a percentage.",
Expand Down
28 changes: 28 additions & 0 deletions Sources/CodexBar/SettingsStore+Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,34 @@ extension SettingsStore {
get { self.debugLoadingPatternRaw.flatMap(LoadingPattern.init(rawValue:)) }
set { self.debugLoadingPatternRaw = newValue?.rawValue }
}

var floatingDashboardEnabled: Bool {
get { self.defaultsState.floatingDashboardEnabled }
set {
self.defaultsState.floatingDashboardEnabled = newValue
self.userDefaults.set(newValue, forKey: "floatingDashboardEnabled")
}
}

var floatingDashboardPosition: [String: Double]? {
get { self.defaultsState.floatingDashboardPosition }
set {
self.defaultsState.floatingDashboardPosition = newValue
if let value = newValue {
self.userDefaults.set(value, forKey: "floatingDashboardPosition")
} else {
self.userDefaults.removeObject(forKey: "floatingDashboardPosition")
}
}
}

var floatingDashboardHorizontal: Bool {
get { self.defaultsState.floatingDashboardHorizontal }
set {
self.defaultsState.floatingDashboardHorizontal = newValue
self.userDefaults.set(newValue, forKey: "floatingDashboardHorizontal")
}
}
}

extension SettingsStore {
Expand Down
8 changes: 7 additions & 1 deletion Sources/CodexBar/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,9 @@ extension SettingsStore {
forKey: "mergedOverviewSelectedProviders") as? [String] ?? []
let selectedMenuProviderRaw = userDefaults.string(forKey: "selectedMenuProvider")
let providerDetectionCompleted = userDefaults.object(forKey: "providerDetectionCompleted") as? Bool ?? false
let floatingDashboardEnabled = userDefaults.object(forKey: "floatingDashboardEnabled") as? Bool ?? false
let floatingDashboardPosition = userDefaults.dictionary(forKey: "floatingDashboardPosition") as? [String: Double]
let floatingDashboardHorizontal = userDefaults.object(forKey: "floatingDashboardHorizontal") as? Bool ?? false

return SettingsDefaultsState(
refreshFrequency: refreshFrequency,
Expand Down Expand Up @@ -255,7 +258,10 @@ extension SettingsStore {
mergedMenuLastSelectedWasOverview: mergedMenuLastSelectedWasOverview,
mergedOverviewSelectedProvidersRaw: mergedOverviewSelectedProvidersRaw,
selectedMenuProviderRaw: selectedMenuProviderRaw,
providerDetectionCompleted: providerDetectionCompleted)
providerDetectionCompleted: providerDetectionCompleted,
floatingDashboardEnabled: floatingDashboardEnabled,
floatingDashboardPosition: floatingDashboardPosition,
floatingDashboardHorizontal: floatingDashboardHorizontal)
}
}

Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBar/SettingsStoreState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,7 @@ struct SettingsDefaultsState: Sendable {
var mergedOverviewSelectedProvidersRaw: [String]
var selectedMenuProviderRaw: String?
var providerDetectionCompleted: Bool
var floatingDashboardEnabled: Bool
var floatingDashboardPosition: [String: Double]?
var floatingDashboardHorizontal: Bool
}
4 changes: 4 additions & 0 deletions Sources/CodexBar/StatusItemController+Actions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ extension StatusItemController {
}
}

@objc func toggleFloatingDashboard() {
self.settings.floatingDashboardEnabled.toggle()
}

@objc func showSettingsGeneral() {
self.openSettings(tab: .general)
}
Expand Down
4 changes: 4 additions & 0 deletions Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,9 @@ extension StatusItemController {
image.size = NSSize(width: 16, height: 16)
item.image = image
}
if case .floatingDashboard = action {
item.state = self.settings.floatingDashboardEnabled ? .on : .off
}
if case let .switchAccount(targetProvider) = action,
let subtitle = self.switchAccountSubtitle(for: targetProvider)
{
Expand Down Expand Up @@ -1001,6 +1004,7 @@ extension StatusItemController {
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)
case .floatingDashboard: (#selector(self.toggleFloatingDashboard), nil)
case .settings: (#selector(self.showSettingsGeneral), nil)
case .about: (#selector(self.showSettingsAbout), nil)
case .quit: (#selector(self.quit), nil)
Expand Down
Loading