Skip to content
Merged
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
23 changes: 18 additions & 5 deletions Sources/Juice/JuiceApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,10 @@ struct JuiceApp: App {

init() {
let isMacMini = MacHardware.isCurrentMacMini
_model = StateObject(wrappedValue: BatteryViewModel(
let initialModel = BatteryViewModel(
onReading: JuiceApp.handleReading,
isMacMini: isMacMini))
isMacMini: isMacMini)
_model = StateObject(wrappedValue: initialModel)

// Menu bar only: no Dock icon, no main window.
NSApplication.shared.setActivationPolicy(.accessory)
Expand All @@ -74,16 +75,28 @@ struct JuiceApp: App {
for: .menuBar(Self.menuBarConsumerID))
}

#if DEV_HELPER || DEBUG
#if DEV_BUILD || DEV_HELPER || DEBUG
// Deterministic native-window entry point for development UI
// verification. It is absent from production builds and does nothing
// unless explicitly requested on the command line.
if isMacMini, CommandLine.arguments.contains("--show-server-stats") {
let shouldShowStats = CommandLine.arguments.contains("--show-stats")
|| (isMacMini && CommandLine.arguments.contains("--show-server-stats"))
if shouldShowStats {
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(500))
StatsWindowPresenter.shared.showServer(store: Self.sampler?.store)
if isMacMini {
StatsWindowPresenter.shared.showServer(store: Self.sampler?.store)
} else {
StatsWindowPresenter.shared.show(
selector: EnergySourceSelector(),
timelineSource: nil,
model: initialModel)
}
}
}
#endif

#if DEV_HELPER || DEBUG
if isMacMini, CommandLine.arguments.contains("--show-popover") {
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(500))
Expand Down
266 changes: 80 additions & 186 deletions Sources/Juice/Stats/MacMiniStatsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import SwiftUI
import Charts
import JuiceCore

/// Full server dashboard. Unlike the battery Stats window, current app watts
/// remain visible for every selected history range.
struct MacMiniStatsView: View {
/// Mac mini-specific state and data loading for the shared Stats page. Unlike
/// battery mode, current app watts remain visible for every history range.
struct MacMiniStatsDashboard: View {
static let minimumContentWidth: CGFloat = 860
// The header, chart's 180-point floor, and footer need this much vertical
// space together. Keeping the old 500-point minimum let an autosaved frame
Expand Down Expand Up @@ -97,24 +97,15 @@ struct MacMiniStatsView: View {
}

var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider()

HStack(alignment: .top, spacing: 0) {
appPane
.frame(minWidth: 500)
Divider()
powerPane
.frame(minWidth: 320)
}

Divider()
footer
}
.frame(
minWidth: Self.minimumContentWidth,
minHeight: Self.minimumContentHeight)
StatsDashboardLayout(
minimumContentWidth: Self.minimumContentWidth,
minimumAppPaneWidth: 500,
minimumDetailPaneWidth: 320,
minimumContentHeight: Self.minimumContentHeight,
header: { header },
appPane: { appPane },
detailPane: { powerPane },
footer: { footer })
.task(id: LoadRequest(range: range, retryGeneration: retryGeneration)) {
attachLive()
await load()
Expand All @@ -137,16 +128,10 @@ struct MacMiniStatsView: View {
}

private var header: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 2) {
Text("Mac mini Stats")
.font(.title2.weight(.semibold))
Text("Current app watts and \(rangeDescription) energy")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
StatsDashboardHeader(
title: "Mac mini Stats",
subtitle: "Current app watts and \(rangeDescription) energy",
actions: {
if let watts = live.reading?.totalMeteredWatts {
VStack(alignment: .trailing, spacing: 1) {
Text(liveWattsText(watts))
Expand All @@ -164,25 +149,24 @@ struct MacMiniStatsView: View {
retryGeneration &+= 1
}
.controlSize(.small)
}
},
controls: {
if isCustomizingRanges {
StatsRangeSettings(
availableRanges: macMiniPowerRanges,
fallbackRanges: macMiniPowerRanges,
selection: $range,
storageValue: $rangeVisibilityStorage)
.transition(.move(edge: .top).combined(with: .opacity))
}

if isCustomizingRanges {
StatsRangeSettings(
availableRanges: macMiniPowerRanges,
fallbackRanges: macMiniPowerRanges,
StatsRangePickerRow(
title: "Server history range",
selection: $range,
storageValue: $rangeVisibilityStorage)
.transition(.move(edge: .top).combined(with: .opacity))
}

StatsRangePickerRow(
title: "Server history range",
selection: $range,
ranges: visibleRanges,
pickerWidth: 360,
label: \.macMiniPickerLabel)
}
.padding(16)
ranges: visibleRanges,
pickerWidth: 360,
label: \.macMiniPickerLabel)
})
}

private func preferredVisibleRange() -> EnergyRange {
Expand All @@ -199,81 +183,62 @@ struct MacMiniStatsView: View {
}

private var appPane: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Apps using power")
.font(.headline)
if live.status == .sampling || live.status == .warmingUp {
LiveHint()
}
Spacer()
}

HStack(spacing: 10) {
Text("APP")
.frame(maxWidth: .infinity, alignment: .leading)
Text("LIVE W")
.frame(width: 64, alignment: .trailing)
Text("ENERGY / COST")
.frame(width: 72, alignment: .trailing)
Text("PEAK W")
.frame(width: 64, alignment: .trailing)
Color.clear.frame(width: 10, height: 1)
}
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.tertiary)

if let loadError {
Text(loadError)
.font(.caption)
.foregroundStyle(.orange)
} else if data == nil {
ProgressView("Loading app energy history…")
.controlSize(.small)
}

if appRows.isEmpty {
if data != nil {
Text("Collecting app energy—live apps appear as soon as they draw measurable power.")
StatsAppTablePane(
title: "Apps using power",
showsLiveActivity: live.status == .sampling || live.status == .warmingUp,
columns: .server,
content: {
if let loadError {
Text(loadError)
.font(.caption)
.foregroundStyle(.tertiary)
.foregroundStyle(.orange)
} else if data == nil {
ProgressView("Loading app energy history…")
.controlSize(.small)
}
Spacer()
} else {
ScrollView {
LazyVStack(spacing: 6) {
ForEach(appRows) { app in
appRow(app)

if appRows.isEmpty {
if data != nil {
Text("Collecting app energy—live apps appear as soon as they draw measurable power.")
.font(.caption)
.foregroundStyle(.tertiary)
}
Spacer()
} else {
ScrollView {
LazyVStack(spacing: 6) {
ForEach(appRows) { app in
appRow(app)
}
}
.padding(.trailing, 4)
}
.padding(.trailing, 4)
}
}

if let reading = live.reading {
Text(serverPowerBreakdownText(
reading,
includesMeteredTotal: true))
.font(.caption2)
.foregroundStyle(.secondary)
.monospacedDigit()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(16)
},
summary: {
if let reading = live.reading {
Text(serverPowerBreakdownText(
reading,
includesMeteredTotal: true))
.font(.caption2)
.foregroundStyle(.secondary)
.monospacedDigit()
}
})
}

private func appRow(_ app: AppRow) -> some View {
Button(action: {
showDetail(app)
}, label: {
appRowLabel(app)
})
.buttonStyle(.plain)
.accessibilityElement(children: .ignore)
.accessibilityLabel(app.displayName)
.accessibilityValue(appAccessibilityValue(app))
.accessibilityHint("Opens app energy details")
StatsAppTableRow(
appKey: app.appKey,
displayName: app.displayName,
share: (app.energyWh ?? 0) / maxAppEnergy,
columns: .server,
liveWattsText: app.liveWatts.map(liveWattsText),
energyText: app.energyWh.map(serverEnergyText),
costText: costText(app.energyWh),
detailText: app.peakWatts.map(liveWattsText),
accessibilityValue: appAccessibilityValue(app),
onTap: { showDetail(app) })
}

private func appAccessibilityValue(_ app: AppRow) -> String {
Expand All @@ -286,77 +251,6 @@ struct MacMiniStatsView: View {
return "\(base), estimated cost \(cost) \(accessibilityRangeDescription)"
}

private func appRowLabel(_ app: AppRow) -> some View {
let isLive = app.liveWatts != nil
let liveText = app.liveWatts.map(liveWattsText) ?? "—"
let barFraction = CGFloat(max(0, min(1, (app.energyWh ?? 0) / maxAppEnergy)))
let rowBackground = isLive ? Color.green.opacity(0.06) : Color.clear

return HStack(spacing: 10) {
AppIconView(bundleId: app.appKey, displayName: app.displayName)
.frame(width: 22, height: 22)

VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 5) {
if isLive {
Circle()
.fill(.green)
.frame(width: 5, height: 5)
}
Text(app.displayName)
.font(.callout)
.lineLimit(1)
}
GeometryReader { geometry in
ZStack(alignment: .leading) {
Capsule().fill(Color.secondary.opacity(0.15))
Capsule()
.fill(isLive
? Color.accentColor
: Color.accentColor.opacity(0.65))
.frame(width: geometry.size.width * barFraction)
}
}
.frame(height: 5)
}
.frame(maxWidth: .infinity, alignment: .leading)

Text(liveText)
.font(isLive ? .callout.weight(.semibold) : .callout)
.foregroundStyle(isLive ? Color.green : Color.secondary)
.monospacedDigit()
.frame(width: 64, alignment: .trailing)

VStack(alignment: .trailing, spacing: 1) {
Text(app.energyWh.map(serverEnergyText) ?? "—")
.font(.callout)
if let cost = costText(app.energyWh) {
Text(cost)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.75)
}
}
.monospacedDigit()
.frame(width: 72, alignment: .trailing)

Text(app.peakWatts.map(liveWattsText) ?? "—")
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
.frame(width: 64, alignment: .trailing)

Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.tertiary)
.frame(width: 10)
}
.padding(.vertical, 5)
.padding(.horizontal, 7)
.background(rowBackground, in: RoundedRectangle(cornerRadius: 7))
}

private func showDetail(_ app: AppRow) {
AppDetailPresenter.shared.show(
appKey: app.appKey,
Expand Down
Loading