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
143 changes: 143 additions & 0 deletions Sources/OpenUsage/Stores/LayoutStore+Grouping.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
extension LayoutStore {
func provider(id: String) -> Provider? { registry.provider(id: id) }

func descriptor(for widget: PlacedWidget) -> WidgetDescriptor? {
registry.descriptor(id: widget.descriptorID)
}

private func providerID(of widget: PlacedWidget) -> String? {
registry.descriptor(id: widget.descriptorID)?.providerID
}

var visiblePlaced: [PlacedWidget] {
placed.filter { widget in
guard let providerID = providerID(of: widget) else { return true }
return isProviderEnabled(providerID)
}
}

var availableToAdd: [WidgetDescriptor] {
let placedIDs = Set(placed.map(\.descriptorID))
return registry.descriptors.filter { !placedIDs.contains($0.id) && isProviderEnabled($0.providerID) }
}

func isMetricEnabled(_ descriptorID: String) -> Bool {
placed.contains { $0.descriptorID == descriptorID }
}

/// Whether any enabled provider ships the spend-history tiles — the capability gate for the
/// Total Spend card. Keyed off the registry's descriptors, not off refreshed data, so the card
/// can show its "No spend data" state on a fresh morning instead of vanishing.
var hasSpendCapableProvider: Bool {
!spendCapableProviders.isEmpty
}

/// Enabled providers that ship the spend-history tiles (`WidgetDescriptor.spendTiles`), in the
/// user's provider order — the exact set the Total Spend card aggregates. Deliberately *not*
/// `displayGroups`: a provider whose every metric is hidden in Customize still spends money and
/// must still count, and look-alike dollar rows from other providers (OpenRouter's API-spend
/// "Today") must not.
var spendCapableProviders: [Provider] {
let capableIDs = Set(registry.descriptors.filter(\.isSpendTile).map(\.providerID))
return orderedProviders().filter { capableIDs.contains($0.id) && isProviderEnabled($0.id) }
}

// MARK: - Provider grouping

func orderedProviders() -> [Provider] {
providerOrder.compactMap { registry.provider(id: $0) }
}

/// Enabled (and provider-enabled) widgets grouped by provider, in the user's provider order, each
/// provider's metrics kept in the provider's custom metric order. Drives the grouped dashboard list; providers with
/// no visible metric are dropped so the dashboard only shows groups that have something to show.
var displayGroups: [ProviderGroup] {
orderedProviders().compactMap { provider in
let widgetsByDescriptor = Dictionary(
visiblePlaced
.filter { providerID(of: $0) == provider.id }
.map { ($0.descriptorID, $0) },
uniquingKeysWith: { first, _ in first }
)
let widgets = metricOrder(for: provider.id).compactMap { widgetsByDescriptor[$0] }
guard !widgets.isEmpty else { return nil }
let alwaysShown = widgets.filter { !expandedMetricIDs.contains($0.descriptorID) }
let expanded = widgets.filter { expandedMetricIDs.contains($0.descriptorID) }
// A provider whose only enabled metrics are all marked expanded would otherwise render an
// empty card with a caret — promote them to always-shown so the card always has rows.
if alwaysShown.isEmpty {
return ProviderGroup(provider: provider, alwaysShownWidgets: expanded, expandedWidgets: [])
}
return ProviderGroup(provider: provider, alwaysShownWidgets: alwaysShown, expandedWidgets: expanded)
}
}

/// Every enabled provider with *all* the metrics it supports, in its saved metric order. Enabled and
/// disabled rows stay in-place; the switch only controls visibility.
var customizeGroups: [ProviderMetrics] {
orderedProviders().compactMap { provider in
guard isProviderEnabled(provider.id) else { return nil }
let metrics = orderedSupportedMetrics(for: provider.id)
guard !metrics.isEmpty else { return nil }
return ProviderMetrics(
provider: provider,
alwaysShownMetrics: metrics.filter { !expandedMetricIDs.contains($0.id) },
expandedMetrics: metrics.filter { expandedMetricIDs.contains($0.id) }
)
}
}

/// The L1 Customize list: every known provider in the user's saved order, regardless of enablement.
/// Disabled providers appear here (greyed in the UI) so the user can re-enable them or open their
/// detail — unlike `customizeGroups`, which filters them out for the dashboard and the old flat
/// Customize. Each row carries the enablement flag, the total metric count (the badge number), and
/// the pinned count.
var customizeProviderRows: [ProviderRow] {
orderedProviders().map { provider in
ProviderRow(
provider: provider,
isEnabled: isProviderEnabled(provider.id),
metricCount: metricCount(for: provider.id),
pinnedCount: pinnedCount(forProvider: provider.id)
)
}
}

/// Total metrics a provider supports — the L1 row's badge number. Registry descriptor count,
/// independent of how many the user has enabled.
func metricCount(for providerID: String) -> Int {
registry.descriptors(for: providerID).count
}

/// The L2 Customize detail for one provider: every metric it supports, split across the
/// "Always Visible" / "On Demand" divider, in its saved metric order. Available even when the
/// provider is disabled so L2 can render dimmed-but-editable. nil for an unknown provider or one
/// with no metrics — the per-provider slice of `customizeGroups` without the enablement guard.
func customizeDetail(for providerID: String) -> ProviderMetrics? {
guard let provider = registry.provider(id: providerID) else { return nil }
let metrics = orderedSupportedMetrics(for: providerID)
guard !metrics.isEmpty else { return nil }
return ProviderMetrics(
provider: provider,
alwaysShownMetrics: metrics.filter { !expandedMetricIDs.contains($0.id) },
expandedMetrics: metrics.filter { expandedMetricIDs.contains($0.id) }
)
}

/// A provider's supported metrics in custom order, independent of whether each metric is enabled.
func orderedSupportedMetrics(for providerID: String) -> [WidgetDescriptor] {
metricOrder(for: providerID).compactMap { registry.descriptor(id: $0) }
}

func metricOrderWithDivider(for providerID: String, dividerID: String) -> [String] {
let ordered = orderedSupportedMetrics(for: providerID).map(\.id)
return ordered.filter { !expandedMetricIDs.contains($0) }
+ [dividerID]
+ ordered.filter { expandedMetricIDs.contains($0) }
}

func isProviderExpanded(_ providerID: String) -> Bool {
expandedProviderIDs.contains(providerID)
}

}
56 changes: 56 additions & 0 deletions Sources/OpenUsage/Stores/LayoutStore+Ordering.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
extension LayoutStore {
func metricOrder(for providerID: String) -> [String] {
metricOrderByProvider[providerID] ?? []
}

static func mergingMissingMetrics(into ordered: [String], previous: [String]) -> [String] {
let orderedSet = Set(ordered)
var result: [String] = []
var emitted = Set<String>()
var orderedIndex = ordered.startIndex

func emitDesiredRows(through id: String) {
while orderedIndex < ordered.endIndex {
let next = ordered[orderedIndex]
orderedIndex = ordered.index(after: orderedIndex)
if emitted.insert(next).inserted {
result.append(next)
}
if next == id { break }
}
}

for id in previous {
if orderedSet.contains(id) {
emitDesiredRows(through: id)
} else if emitted.insert(id).inserted {
result.append(id)
}
}

while orderedIndex < ordered.endIndex {
let next = ordered[orderedIndex]
orderedIndex = ordered.index(after: orderedIndex)
if emitted.insert(next).inserted {
result.append(next)
}
}

return result
}

/// Pure reorder: remove `dragged`, reinsert it adjacent to `target` (after it when moving down, before
/// it when moving up). Returns nil when either id is missing or they're identical. Mirrors the proven
/// macOS drag-reorder math from crafcat7/Peakmon (Apache-2.0).
static func reordered(_ ids: [String], dragged: String, target: String) -> [String]? {
guard dragged != target,
let from = ids.firstIndex(of: dragged),
let to = ids.firstIndex(of: target) else { return nil }
var next = ids
next.remove(at: from)
guard let adjusted = next.firstIndex(of: target) else { return nil }
let insert = from < to ? adjusted + 1 : adjusted
next.insert(dragged, at: min(insert, next.count))
return next
}
}
62 changes: 62 additions & 0 deletions Sources/OpenUsage/Stores/LayoutStore+Pins.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
extension LayoutStore {
// MARK: - Menu bar pins

/// Per-provider cap is a rendering constraint — the Text strip stacks a provider's values two to a
/// column, so a third would not fit the menu bar height.
static let maxPinsPerProvider = 2

func isPinned(_ descriptorID: String) -> Bool { pinnedMetricIDs.contains(descriptorID) }

func togglePin(_ descriptorID: String) {
setPinned(!isPinned(descriptorID), for: descriptorID)
}

var pinnedCount: Int { pinnedMetricIDs.count }

func pinnedCount(forProvider providerID: String) -> Int {
pinnedMetricIDs.count { registry.descriptor(id: $0)?.providerID == providerID }
}

/// Whether `descriptorID` can be newly pinned without breaking a cap. Already-pinned ids return
/// `true`, so the toggle stays active for unpinning.
func canPin(_ descriptorID: String) -> Bool {
if pinnedMetricIDs.contains(descriptorID) { return true }
guard let descriptor = registry.descriptor(id: descriptorID), descriptor.pinnable else { return false }
if pinnedCount(forProvider: descriptor.providerID) >= Self.maxPinsPerProvider { return false }
return true
}

/// Why `descriptorID` can't be pinned right now, or `nil` when it can. The single source for the
/// pin button's tooltip and the denied-click feedback, so both always state the same rule.
func pinDenialReason(_ descriptorID: String) -> String? {
guard !canPin(descriptorID) else { return nil }
if let providerID = registry.descriptor(id: descriptorID)?.providerID,
pinnedCount(forProvider: providerID) >= Self.maxPinsPerProvider {
return "Up to \(Self.maxPinsPerProvider) stars per provider"
}
return nil
}

/// Record a denied pin attempt so the footer can explain the cap (shown for a few seconds,
/// with a deny shake on every attempt).
func notePinDenied(_ descriptorID: String) {
guard let reason = pinDenialReason(descriptorID) else { return }
pinNotice.present(reason)
}

/// Pinned metrics grouped by provider, in the user's Customize order (provider order, then each
/// provider's metric order). A temporarily disabled provider is excluded from the rendered groups
/// but keeps its pins. Drives the menu-bar strip.
var pinnedGroups: [ProviderMetrics] {
orderedProviders().compactMap { provider in
guard isProviderEnabled(provider.id) else { return nil }
// Keep the strip order matching Customize: always-shown pins first, then expanded ones.
let metrics = orderedSupportedMetrics(for: provider.id).filter { pinnedMetricIDs.contains($0.id) }
return metrics.isEmpty ? nil : ProviderMetrics(
provider: provider,
alwaysShownMetrics: metrics.filter { !expandedMetricIDs.contains($0.id) },
expandedMetrics: metrics.filter { expandedMetricIDs.contains($0.id) }
)
}
}
}
73 changes: 73 additions & 0 deletions Sources/OpenUsage/Stores/LayoutStore+Presentation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
extension LayoutStore {
/// Which in-popover screen is showing. Drives the footer buttons, the Esc handler, and the
/// popover-closed reset alike.
var screen: PopoverScreen {
get { navigation.screen }
set { navigation.screen = newValue }
}
/// The screen being left plus a per-switch counter, for DashboardView's horizontal slide.
var screenSlideFrom: PopoverScreen { navigation.screenSlideFrom }
var screenSlideID: Int { navigation.screenSlideID }
/// Whether the Customize screen is showing — a bridge over `screen` for edit-mode call sites.
var isEditing: Bool {
get { navigation.isEditing }
set { navigation.isEditing = newValue }
}
/// The provider whose Customize detail (L2) is showing (nil shows the L1 list).
var customizeProviderID: String? {
get { navigation.customizeProviderID }
set { navigation.customizeProviderID = newValue }
}

/// Transient explanation for a denied pin attempt; the popover footer renders it in place of the pin
/// counter. Set by `notePinDenied`, auto-cleared a few seconds later.
var pinLimitNotice: String? { pinNotice.value }
/// Bumped on every denied pin click so the footer notice plays its deny shake each time.
var pinNoticeShakeTrigger: Int { pinNotice.trigger }

/// Transient "Copied to clipboard" confirmation for the floating pill above the footer.
var shareConfirmation: Bool { shareNotice.value }
/// Bumped on every successful share so the pill replays its pop-in even on a repeat copy.
var shareConfirmationTrigger: Int { shareNotice.trigger }

/// Transient in-Customize notice (e.g. "Starred for menu bar", or the orange cap denial).
var customizationNotice: String? { customizeNotice.value?.message }
/// The notice's tone: `.positive` (green checkmark) or `.notice` (orange denial). Falls back to
/// `.positive` once cleared (tone is only read while `customizationNotice` is non-nil, so the
/// snap-back is unobservable — message and tone now clear atomically, which the old split state
/// machine couldn't guarantee).
var customizationNoticeTone: CustomizationNoticeTone { customizeNotice.value?.tone ?? .positive }
/// Bumped on every present so the pill replays its pop-in even when the same notice repeats.
var customizationNoticeTrigger: Int { customizeNotice.trigger }

/// Record a successful "Share Screenshot" copy so the floating "Copied to clipboard" pill can
/// confirm it. Shown for a couple of seconds then cleared — the success-side counterpart to
/// `notePinDenied`'s transient denial notice, with the same lifecycle.
func presentShareConfirmation() {
shareNotice.present(true)
}

/// Clear any showing "Copied to clipboard" confirmation and cancel its auto-clear task. Called when
/// the popover closes so a pill mid-countdown can't reappear stale on the next open — the timer is
/// otherwise the only clearer, and the layout store outlives the popover.
func clearShareConfirmation() {
shareNotice.clear()
}

/// Show a transient in-Customize pill (the floating confirmation above the Customize content).
/// `tone` picks the green success style or the orange denial style. Auto-clears after a couple of
/// seconds; also cleared on popover close via `clearCustomizationNotice`.
func presentCustomizationNotice(_ message: String, tone: CustomizationNoticeTone = .positive) {
customizeNotice.present(CustomizationNoticeContent(message: message, tone: tone))
}

/// Clear any showing Customize pill and cancel its auto-clear task. Called when the popover closes
/// so a pill mid-countdown can't reappear stale on the next open.
func clearCustomizationNotice() {
customizeNotice.clear()
}

func cancelDrag() {
draggingID = nil
}
}
Loading