Merge pull request #27 from Ebullioscopic/feature/stats

Feature/stats
This commit is contained in:
Hariharan Mudaliar 2025-09-28 13:39:24 +05:30 • committed by GitHub
commit 74b9d2afad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 480 additions and 159 deletions

2
.gitignore vendored
View file

@ -72,3 +72,5 @@ segmentedhud.swift.ref
gemini-apireference.md gemini-apireference.md
ScreenshotApp-main ScreenshotApp-main
stats
boring.notch

View file

@ -30,25 +30,34 @@ struct ContentView: View {
@Default(.showNetworkGraph) var showNetworkGraph @Default(.showNetworkGraph) var showNetworkGraph
@Default(.showDiskGraph) var showDiskGraph @Default(.showDiskGraph) var showDiskGraph
// Dynamic sizing based on view type and graph count with smooth transitions
var dynamicNotchSize: CGSize { var dynamicNotchSize: CGSize {
// Only apply dynamic sizing when on stats tab and stats are enabled // Always return the target size for the current view to ensure smooth transitions
guard coordinator.currentView == .stats && enableStatsFeature else { let targetSize: CGSize
return openNotchSize
if coordinator.currentView == .stats {
// Count enabled graphs using the same logic as NotchStatsView
var enabledGraphsCount = 0
if showCpuGraph { enabledGraphsCount += 1 }
if showMemoryGraph { enabledGraphsCount += 1 }
if showGpuGraph { enabledGraphsCount += 1 }
if showNetworkGraph { enabledGraphsCount += 1 }
if showDiskGraph { enabledGraphsCount += 1 }
if enabledGraphsCount > 3 {
// Expand height for 4+ graphs
targetSize = CGSize(
width: openNotchSize.width,
height: openNotchSize.height + 65 // Add extra height for second row
)
} else {
targetSize = openNotchSize
}
} else {
targetSize = openNotchSize
} }
let enabledGraphsCount = [showCpuGraph, showMemoryGraph, showGpuGraph, showNetworkGraph, showDiskGraph].filter { $0 }.count return targetSize
// Calculate height based on layout: 1-3 graphs = single row, 4+ graphs = two rows
var requiredHeight = openNotchSize.height
if enabledGraphsCount >= 4 {
// Two rows needed - add height for second row plus spacing
let extraHeight: CGFloat = 120 + 12 // Graph height + spacing
requiredHeight = openNotchSize.height + extraHeight
}
// Width stays constant - no horizontal expansion
return CGSize(width: openNotchSize.width, height: requiredHeight)
} }
@ -57,6 +66,9 @@ struct ContentView: View {
@State private var debounceWorkItem: DispatchWorkItem? @State private var debounceWorkItem: DispatchWorkItem?
@State private var isHoverStateChanging: Bool = false @State private var isHoverStateChanging: Bool = false
@State private var isStatsTransitioning: Bool = false
@State private var isSwitchingToStats: Bool = false
@State private var lastHapticTime: Date = Date()
@State private var gestureProgress: CGFloat = .zero @State private var gestureProgress: CGFloat = .zero
@ -98,18 +110,22 @@ struct ContentView: View {
.conditionalModifier(!useModernCloseAnimation) { view in .conditionalModifier(!useModernCloseAnimation) { view in
let hoverAnimationAnimation = Animation.bouncy.speed(1.2) let hoverAnimationAnimation = Animation.bouncy.speed(1.2)
let notchStateAnimation = Animation.spring.speed(1.2) let notchStateAnimation = Animation.spring.speed(1.2)
let viewTransitionAnimation = Animation.easeInOut(duration: 0.4)
return view return view
.animation(hoverAnimationAnimation, value: isHovering) .animation(hoverAnimationAnimation, value: isHovering)
.animation(notchStateAnimation, value: vm.notchState) .animation(notchStateAnimation, value: vm.notchState)
.animation(viewTransitionAnimation, value: coordinator.currentView)
.animation(.smooth, value: gestureProgress) .animation(.smooth, value: gestureProgress)
.transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2)))
} }
.conditionalModifier(useModernCloseAnimation) { view in .conditionalModifier(useModernCloseAnimation) { view in
let hoverAnimationAnimation = Animation.bouncy.speed(1.2) let hoverAnimationAnimation = Animation.bouncy.speed(1.2)
let notchStateAnimation = Animation.spring.speed(1.2) let notchStateAnimation = Animation.spring.speed(1.2)
let viewTransitionAnimation = Animation.easeInOut(duration: 0.4)
return view return view
.animation(hoverAnimationAnimation, value: isHovering) .animation(hoverAnimationAnimation, value: isHovering)
.animation(notchStateAnimation, value: vm.notchState) .animation(notchStateAnimation, value: vm.notchState)
.animation(viewTransitionAnimation, value: coordinator.currentView)
} }
.conditionalModifier(Defaults[.openNotchOnHover]) { view in .conditionalModifier(Defaults[.openNotchOnHover]) { view in
view.onHover { hovering in view.onHover { hovering in
@ -119,26 +135,11 @@ struct ContentView: View {
.conditionalModifier(!Defaults[.openNotchOnHover]) { view in .conditionalModifier(!Defaults[.openNotchOnHover]) { view in
view view
.onHover { hovering in .onHover { hovering in
withAnimation(vm.animation) { handleSimpleHover(hovering)
isHovering = hovering
}
// Only close if mouse leaves and the notch is open and no popovers are active
if !hovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive {
// Add slight delay for stats tab to prevent accidental closure during layout changes
let delay: Double = (coordinator.currentView == .stats) ? 0.2 : 0.0
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
// Double-check conditions after delay
if !isHovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive {
vm.close()
}
}
}
} }
.onTapGesture { .onTapGesture {
if (vm.notchState == .closed) && Defaults[.enableHaptics] { if (vm.notchState == .closed) && Defaults[.enableHaptics] {
haptics.toggle() triggerHapticIfAllowed()
} }
doOpen() doOpen()
} }
@ -180,7 +181,14 @@ struct ContentView: View {
} }
.onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in .onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if !newPopoverState && !isHovering && vm.notchState == .open { if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isStatsPopoverActive {
vm.close()
}
}
}
.onChange(of: vm.isStatsPopoverActive) { _, newPopoverState in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive {
vm.close() vm.close()
} }
} }
@ -188,7 +196,7 @@ struct ContentView: View {
.onChange(of: vm.shouldRecheckHover) { _, _ in .onChange(of: vm.shouldRecheckHover) { _, _ in
// Recheck hover state when popovers are closed // Recheck hover state when popovers are closed
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !isHovering { if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isStatsPopoverActive && !isHovering {
vm.close() vm.close()
} }
} }
@ -203,6 +211,35 @@ struct ContentView: View {
} }
} }
} }
.onChange(of: coordinator.currentView) { oldValue, newValue in
// Cancel any pending close actions during view transitions
hoverWorkItem?.cancel()
// Set flags for transition tracking
isSwitchingToStats = (oldValue != .stats && newValue == .stats)
// Provide protection when switching to stats tab
if newValue == .stats {
isStatsTransitioning = true
// Longer delay if stats tab has expanded height (4+ graphs)
let hasExpandedHeight = statsTabHasExpandedHeight()
let protectionDelay: Double = hasExpandedHeight ? 1.5 : 0.8
DispatchQueue.main.asyncAfter(deadline: .now() + protectionDelay) {
isStatsTransitioning = false
isSwitchingToStats = false
}
}
// Also provide brief protection when switching FROM stats to prevent premature closure
if oldValue == .stats && newValue != .stats {
isStatsTransitioning = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
isStatsTransitioning = false
}
}
}
.sensoryFeedback(.alignment, trigger: haptics) .sensoryFeedback(.alignment, trigger: haptics)
.contextMenu { .contextMenu {
SettingsLink(label: { SettingsLink(label: {
@ -514,7 +551,7 @@ struct ContentView: View {
// Only provide haptic feedback if notch is closed // Only provide haptic feedback if notch is closed
if vm.notchState == .closed && Defaults[.enableHaptics] { if vm.notchState == .closed && Defaults[.enableHaptics] {
haptics.toggle() triggerHapticIfAllowed()
} }
// Delay opening the notch // Delay opening the notch
@ -553,7 +590,9 @@ struct ContentView: View {
} }
// Close the notch if it's open and no popovers are active // Close the notch if it's open and no popovers are active
if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive { let hasActivePopovers = vm.isBatteryPopoverActive || vm.isClipboardPopoverActive || vm.isColorPickerPopoverActive || vm.isStatsPopoverActive
if vm.notchState == .open && !hasActivePopovers {
vm.close() vm.close()
} }
} }
@ -564,6 +603,92 @@ struct ContentView: View {
} }
} }
// Simple hover handler for non-openNotchOnHover mode
private func handleSimpleHover(_ hovering: Bool) {
// Cancel any existing work items first
hoverWorkItem?.cancel()
debounceWorkItem?.cancel()
if hovering {
// Mouse entered - always update visual state immediately
withAnimation(vm.animation) {
isHovering = true
}
return
}
// Mouse exited - always update visual state immediately
withAnimation(vm.animation) {
isHovering = false
}
// Apply stronger protection when switching to stats, especially with 4+ graphs
if isStatsTransitioning || (isSwitchingToStats && statsTabHasExpandedHeight()) {
return // Skip closing during transitions, but allow visual updates
}
// Check if we should close after a delay
let hasPopovers = hasAnyActivePopovers()
if vm.notchState == .open && !hasPopovers {
let isStatsTab = coordinator.currentView == .stats
let hasExpandedHeight = isStatsTab && statsTabHasExpandedHeight()
// Longer delays for stats tab, especially when it has expanded height
let delay: Double
if hasExpandedHeight {
delay = 1.0 // Extra long for stats with 4+ graphs
} else if isStatsTab {
delay = 0.6 // Medium for stats with 1-3 graphs
} else {
delay = 0.2 // Short for other tabs
}
let closeTask = DispatchWorkItem {
// Triple-check conditions including transition state
let stillNotHovering = !isHovering
let stillOpen = vm.notchState == .open
let stillNoPopovers = !hasAnyActivePopovers()
let notTransitioning = !isStatsTransitioning && !isSwitchingToStats
if stillNotHovering && stillOpen && stillNoPopovers && notTransitioning {
vm.close()
}
}
hoverWorkItem = closeTask
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: closeTask)
}
}
// Helper function to check if any popovers are active
private func hasAnyActivePopovers() -> Bool {
return vm.isBatteryPopoverActive ||
vm.isClipboardPopoverActive ||
vm.isColorPickerPopoverActive ||
vm.isStatsPopoverActive
}
// Helper to prevent rapid haptic feedback
private func triggerHapticIfAllowed() {
let now = Date()
if now.timeIntervalSince(lastHapticTime) > 0.3 { // Minimum 300ms between haptics
haptics.toggle()
lastHapticTime = now
}
}
// Helper to check if stats tab has 4+ graphs (needs expanded height)
private func statsTabHasExpandedHeight() -> Bool {
var enabledCount = 0
if showCpuGraph { enabledCount += 1 }
if showMemoryGraph { enabledCount += 1 }
if showGpuGraph { enabledCount += 1 }
if showNetworkGraph { enabledCount += 1 }
if showDiskGraph { enabledCount += 1 }
return enabledCount > 3
}
// MARK: - Gesture Handling // MARK: - Gesture Handling
private func handleDownGesture(translation: CGFloat, phase: NSEvent.Phase) { private func handleDownGesture(translation: CGFloat, phase: NSEvent.Phase) {
@ -581,7 +706,7 @@ struct ContentView: View {
if translation > Defaults[.gestureSensitivity] { if translation > Defaults[.gestureSensitivity] {
if Defaults[.enableHaptics] { if Defaults[.enableHaptics] {
haptics.toggle() triggerHapticIfAllowed()
} }
withAnimation(.smooth) { withAnimation(.smooth) {
gestureProgress = .zero gestureProgress = .zero
@ -610,7 +735,7 @@ struct ContentView: View {
vm.close() vm.close()
if Defaults[.enableHaptics] { if Defaults[.enableHaptics] {
haptics.toggle() triggerHapticIfAllowed()
} }
} }
} }

View file

@ -40,12 +40,17 @@
}, },
"%lld colors" : { "%lld colors" : {
},
"%lld processes" : {
"comment" : "A label at the bottom of the ranked processes popover showing the total number of processes being monitored.",
"isCommentAutoGenerated" : true
}, },
"%lld%%" : { "%lld%%" : {
}, },
"•" : { "•" : {
"comment" : "A period used to separate two related items.",
"isCommentAutoGenerated" : true
}, },
"• Bug fixes" : { "• Bug fixes" : {
@ -332,6 +337,10 @@
}, },
"Click 'Pick Color' or use Cmd+Shift+P to start picking colors" : { "Click 'Pick Color' or use Cmd+Shift+P to start picking colors" : {
},
"Click for details" : {
"comment" : "A hint text displayed below the graph in the Stats Card, indicating that tapping it will reveal more details.",
"isCommentAutoGenerated" : true
}, },
"Clipboard" : { "Clipboard" : {
@ -564,7 +573,8 @@
}, },
"Enable graph visibility in Settings → Stats to view performance data." : { "Enable graph visibility in Settings → Stats to view performance data." : {
"comment" : "A message displayed when no graphs are enabled in the Stats tab.",
"isCommentAutoGenerated" : true
}, },
"Enable haptics" : { "Enable haptics" : {
"extractionState" : "stale", "extractionState" : "stale",
@ -749,9 +759,6 @@
}, },
"LinkedIn" : { "LinkedIn" : {
},
"Live" : {
}, },
"Live Monitoring" : { "Live Monitoring" : {
@ -943,7 +950,8 @@
}, },
"No Graphs Enabled" : { "No Graphs Enabled" : {
"comment" : "A label displayed when no graphs are enabled in the Stats tab.",
"isCommentAutoGenerated" : true
}, },
"No results" : { "No results" : {
@ -968,9 +976,6 @@
}, },
"Notch Height" : { "Notch Height" : {
},
"Off" : {
}, },
"OK" : { "OK" : {
@ -1058,6 +1063,10 @@
}, },
"Picking Status" : { "Picking Status" : {
},
"PID: %d" : {
"comment" : "A small label displaying the process ID of a particular app. The argument is the process ID of the app.",
"isCommentAutoGenerated" : true
}, },
"Pin items from history to save them here" : { "Pin items from history to save them here" : {
@ -1140,6 +1149,10 @@
}, },
"Recording Status" : { "Recording Status" : {
},
"Refresh" : {
"comment" : "A button to refresh the displayed data.",
"isCommentAutoGenerated" : true
}, },
"Release name" : { "Release name" : {
"localizations" : { "localizations" : {
@ -1461,6 +1474,10 @@
}, },
"Toggle the Dynamic Island open or closed from anywhere." : { "Toggle the Dynamic Island open or closed from anywhere." : {
},
"Top Processes by %@" : {
"comment" : "A heading that announces the top processes by a specific metric. The text inside the parentheses is replaced with the actual metric name.",
"isCommentAutoGenerated" : true
}, },
"Transform your notch truly yours" : { "Transform your notch truly yours" : {
@ -1546,5 +1563,5 @@
} }
}, },
"version" : "1.0" "version" : "1.1"
} }

View file

@ -2,8 +2,8 @@
// NotchStatsView.swift // NotchStatsView.swift
// DynamicIsland // DynamicIsland
// //
// Stats tab view for system performance monitoring // Adapted from boring.notch StatsView
// Created by Hariharan Mudaliar // Stats tab view for system performance monitoring with clickable process popovers
import SwiftUI import SwiftUI
import Defaults import Defaults
@ -53,6 +53,14 @@ struct NotchStatsView: View {
@Default(.showGpuGraph) var showGpuGraph @Default(.showGpuGraph) var showGpuGraph
@Default(.showNetworkGraph) var showNetworkGraph @Default(.showNetworkGraph) var showNetworkGraph
@Default(.showDiskGraph) var showDiskGraph @Default(.showDiskGraph) var showDiskGraph
@State private var showingCPUPopover = false
@State private var showingMemoryPopover = false
@State private var showingGPUPopover = false
@State private var isHoveringCPUPopover = false
@State private var isHoveringMemoryPopover = false
@State private var isHoveringGPUPopover = false
@State private var isResizingForStats = false
@EnvironmentObject var vm: DynamicIslandViewModel
var availableGraphs: [GraphData] { var availableGraphs: [GraphData] {
var graphs: [GraphData] = [] var graphs: [GraphData] = []
@ -130,11 +138,7 @@ struct NotchStatsView: View {
spacing: 12 spacing: 12
) { ) {
ForEach(0..<graphCount, id: \.self) { index in ForEach(0..<graphCount, id: \.self) { index in
UnifiedStatsCard(graphData: availableGraphs[index]) graphViewForIndex(index)
.transition(.asymmetric(
insertion: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4)),
removal: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4))
))
} }
} }
} else if graphCount == 4 { } else if graphCount == 4 {
@ -144,11 +148,7 @@ struct NotchStatsView: View {
spacing: 12 spacing: 12
) { ) {
ForEach(0..<graphCount, id: \.self) { index in ForEach(0..<graphCount, id: \.self) { index in
UnifiedStatsCard(graphData: availableGraphs[index]) graphViewForIndex(index)
.transition(.asymmetric(
insertion: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4)),
removal: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4))
))
} }
} }
} else { } else {
@ -160,11 +160,7 @@ struct NotchStatsView: View {
spacing: 8 spacing: 8
) { ) {
ForEach(0..<3, id: \.self) { index in ForEach(0..<3, id: \.self) { index in
UnifiedStatsCard(graphData: availableGraphs[index]) graphViewForIndex(index)
.transition(.asymmetric(
insertion: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4)),
removal: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4))
))
} }
} }
@ -174,15 +170,110 @@ struct NotchStatsView: View {
spacing: 8 spacing: 8
) { ) {
ForEach(3..<graphCount, id: \.self) { index in ForEach(3..<graphCount, id: \.self) { index in
UnifiedStatsCard(graphData: availableGraphs[index]) graphViewForIndex(index)
}
}
}
}
}
@ViewBuilder
private func graphViewForIndex(_ index: Int) -> some View {
let graphData = availableGraphs[index]
// Only make CPU, Memory, GPU clickable for process popovers
if graphData.title == "CPU" || graphData.title == "Memory" || graphData.title == "GPU" {
Button(action: {
handleGraphClick(for: graphData)
}) {
UnifiedStatsCard(graphData: graphData)
}
.buttonStyle(PlainButtonStyle())
.popover(isPresented: bindingForGraph(graphData)) {
RankedProcessPopover(
rankingType: rankingTypeForGraph(graphData),
onHoverChange: { hovering in
switch graphData.title {
case "CPU":
isHoveringCPUPopover = hovering
case "Memory":
isHoveringMemoryPopover = hovering
case "GPU":
isHoveringGPUPopover = hovering
default:
break
}
}
)
.onDisappear {
// Reset hover states when popover disappears
switch graphData.title {
case "CPU":
isHoveringCPUPopover = false
case "Memory":
isHoveringMemoryPopover = false
case "GPU":
isHoveringGPUPopover = false
default:
break
}
// Ensure popover state is updated when popover disappears
DispatchQueue.main.async {
updateStatsPopoverState()
}
}
}
.transition(.asymmetric(
insertion: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4)),
removal: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4))
))
} else {
// Network and Disk graphs are not clickable
UnifiedStatsCard(graphData: graphData)
.transition(.asymmetric( .transition(.asymmetric(
insertion: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4)), insertion: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4)),
removal: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4)) removal: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4))
)) ))
} }
} }
private func handleGraphClick(for graphData: GraphData) {
switch graphData.title {
case "CPU":
showingCPUPopover = true
case "Memory":
showingMemoryPopover = true
case "GPU":
showingGPUPopover = true
default:
break
} }
} }
private func bindingForGraph(_ graphData: GraphData) -> Binding<Bool> {
switch graphData.title {
case "CPU":
return $showingCPUPopover
case "Memory":
return $showingMemoryPopover
case "GPU":
return $showingGPUPopover
default:
return .constant(false)
}
}
private func rankingTypeForGraph(_ graphData: GraphData) -> ProcessRankingType {
switch graphData.title {
case "CPU":
return .cpu
case "Memory":
return .memory
case "GPU":
return .gpu
default:
return .cpu
}
} }
// Helper function to create graph views using unified component // Helper function to create graph views using unified component
@ -232,107 +323,112 @@ struct NotchStatsView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.padding() .padding()
} else { } else {
// Stats content with improved animation coordination // Stats content with smart grid layout
ZStack(alignment: .topTrailing) { VStack(spacing: 8) {
VStack(spacing: 12) {
// Simplified consistent layout
statsGridLayout statsGridLayout
} }
.padding(16) .padding(12)
.animation(.easeInOut(duration: 0.4), value: availableGraphs.count) .animation(.easeInOut(duration: 0.4), value: availableGraphs.count)
.transition(.asymmetric( .transition(.asymmetric(
insertion: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4)), insertion: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4)),
removal: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4)) removal: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4))
)) ))
// Live indicator and controls in top-right corner
HStack(spacing: 8) {
// Control buttons
HStack(spacing: 4) {
if statsManager.isMonitoring {
Button("Stop") {
statsManager.stopMonitoring()
}
.buttonStyle(.bordered)
.foregroundColor(.red)
.controlSize(.mini)
} else {
Button("Start") {
statsManager.startMonitoring()
}
.buttonStyle(.borderedProminent)
.controlSize(.mini)
}
Button("Clear") {
statsManager.clearHistory()
}
.buttonStyle(.bordered)
.disabled(statsManager.isMonitoring)
.controlSize(.mini)
}
.font(.caption2)
// Live indicator
HStack(spacing: 4) {
Circle()
.fill(statsManager.isMonitoring ? .green : .red)
.frame(width: 6, height: 6)
Text(statsManager.isMonitoring ? "Live" : "Off")
.font(.caption2)
.foregroundColor(.secondary)
}
}
.padding(.top, 8)
.padding(.trailing, 12)
}
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(NSColor.windowBackgroundColor).opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 12))
.onAppear { .onAppear {
if enableStatsFeature && !statsManager.isMonitoring { if enableStatsFeature && Defaults[.autoStartStatsMonitoring] && !statsManager.isMonitoring {
statsManager.startMonitoring() statsManager.startMonitoring()
} }
// Protect against hover interference during view transition
isResizingForStats = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
isResizingForStats = false
}
} }
.onDisappear { .onDisappear {
// Keep monitoring running when tab is not visible // Keep monitoring running when tab is not visible
} }
.animation(.easeInOut(duration: 0.4), value: enableStatsFeature) .animation(.easeInOut(duration: 0.4), value: enableStatsFeature)
.animation(.easeInOut(duration: 0.4), value: availableGraphs.count) .animation(.easeInOut(duration: 0.4), value: availableGraphs.count)
.onChange(of: availableGraphs.count) { _, newCount in
// Protect against hover interference during dynamic sizing
isResizingForStats = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
isResizingForStats = false
}
}
.onChange(of: showingCPUPopover) { _, _ in
updateStatsPopoverState()
}
.onChange(of: showingMemoryPopover) { _, _ in
updateStatsPopoverState()
}
.onChange(of: showingGPUPopover) { _, _ in
updateStatsPopoverState()
}
.onChange(of: isHoveringCPUPopover) { _, _ in
updateStatsPopoverState()
}
.onChange(of: isHoveringMemoryPopover) { _, _ in
updateStatsPopoverState()
}
.onChange(of: isHoveringGPUPopover) { _, _ in
updateStatsPopoverState()
} }
} }
// Unified Stats Card Component - handles both single and dual data types private func updateStatsPopoverState() {
// Use the same logic as battery popover: active only when shown AND hovered
// Also consider resize protection to prevent closing during layout changes
let newState = (showingCPUPopover && isHoveringCPUPopover) ||
(showingMemoryPopover && isHoveringMemoryPopover) ||
(showingGPUPopover && isHoveringGPUPopover) ||
isResizingForStats
if vm.isStatsPopoverActive != newState {
vm.isStatsPopoverActive = newState
#if DEBUG
print("📊 Stats popover state updated: \(newState)")
print(" CPU: shown=\(showingCPUPopover), hovering=\(isHoveringCPUPopover)")
print(" Memory: shown=\(showingMemoryPopover), hovering=\(isHoveringMemoryPopover)")
print(" GPU: shown=\(showingGPUPopover), hovering=\(isHoveringGPUPopover)")
print(" Resizing: \(isResizingForStats)")
#endif
}
}
}
// Unified Stats Card Component - handles both single and dual data types, matches boring.notch sizing
struct UnifiedStatsCard: View { struct UnifiedStatsCard: View {
let graphData: GraphData let graphData: GraphData
@State private var isHovered = false
var body: some View { var body: some View {
VStack(spacing: 6) { VStack(spacing: 3) { // Match boring.notch spacing
// Header - consistent across all card types // Header - consistent across all card types
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: graphData.icon) Image(systemName: graphData.icon)
.foregroundColor(graphData.color) .foregroundColor(graphData.color)
.font(.caption2) .font(.caption) // Match boring.notch font size
Text(graphData.title) Text(graphData.title)
.font(.caption2) .font(.caption) // Match boring.notch font size
.fontWeight(.medium) .fontWeight(.medium)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Spacer() Spacer()
}
// Values section - adapts based on graph type // Show value on right for single graphs like boring.notch
Group {
if let singleData = graphData as? SingleGraphData { if let singleData = graphData as? SingleGraphData {
Text(singleData.value) Text(singleData.value)
.font(.title3) .font(.caption) // Match boring.notch font size
.fontWeight(.bold) .fontWeight(.bold)
.foregroundColor(.primary) .foregroundColor(.primary)
} else if let dualData = graphData as? DualGraphData { }
}
// Values section for dual graphs only
if let dualData = graphData as? DualGraphData {
HStack(spacing: 6) { HStack(spacing: 6) {
Text(dualData.positiveValue) Text(dualData.positiveValue)
.font(.caption) .font(.caption)
@ -348,9 +444,8 @@ struct UnifiedStatsCard: View {
.fontWeight(.semibold) .fontWeight(.semibold)
.foregroundColor(dualData.negativeColor) .foregroundColor(dualData.negativeColor)
} }
.frame(height: 18) // Smaller for dual graphs
} }
}
.frame(height: 22) // Fixed height for consistent card sizing
// Graph section - adapts based on graph type // Graph section - adapts based on graph type
Group { Group {
@ -365,12 +460,32 @@ struct UnifiedStatsCard: View {
) )
} }
} }
.frame(height: 50) // Fixed height for consistent card sizing .frame(height: 36) // Match boring.notch exactly - reduced from 50
// Click hint - only for CPU, Memory, GPU
if graphData.title == "CPU" || graphData.title == "Memory" || graphData.title == "GPU" {
Text("Click for details")
.font(.caption2)
.foregroundColor(.secondary)
.opacity(isHovered ? 1.0 : 0.0)
.animation(.easeInOut(duration: 0.2), value: isHovered)
} }
.padding(10) }
.background(Color(NSColor.controlBackgroundColor).opacity(0.5)) .padding(8) // Match boring.notch padding - reduced from 10
.clipShape(RoundedRectangle(cornerRadius: 8)) .background(
RoundedRectangle(cornerRadius: 8)
.fill(Color(NSColor.controlBackgroundColor).opacity(0.7)) // Match boring.notch opacity
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(graphData.color.opacity(isHovered ? 0.5 : 0.2), lineWidth: 1)
)
)
.scaleEffect(isHovered ? 1.02 : 1.0)
.animation(.easeInOut(duration: 0.2), value: isHovered)
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.onHover { hovering in
isHovered = hovering
}
} }
} }
@ -429,6 +544,8 @@ struct MiniGraph: View {
} }
} }
struct DualQuadrantGraph: View { struct DualQuadrantGraph: View {
let positiveData: [Double] let positiveData: [Double]
let negativeData: [Double] let negativeData: [Double]

View file

@ -11,6 +11,7 @@ import SwiftUI
import IOKit import IOKit
import IOKit.ps import IOKit.ps
import Darwin import Darwin
import AppKit
//import Network //import Network
class StatsManager: ObservableObject { class StatsManager: ObservableObject {
@ -403,4 +404,61 @@ class StatsManager: ObservableObject {
diskReadHistory = Array(repeating: 0.0, count: maxHistoryPoints) diskReadHistory = Array(repeating: 0.0, count: maxHistoryPoints)
diskWriteHistory = Array(repeating: 0.0, count: maxHistoryPoints) diskWriteHistory = Array(repeating: 0.0, count: maxHistoryPoints)
} }
// MARK: - Process Monitoring Methods
func getProcessesRankedByCPU() -> [ProcessStats] {
return getRunningProcesses().sorted { $0.cpuUsage > $1.cpuUsage }
}
func getProcessesRankedByMemory() -> [ProcessStats] {
return getRunningProcesses().sorted { $0.memoryUsage > $1.memoryUsage }
}
func getProcessesRankedByGPU() -> [ProcessStats] {
// For now, rank by CPU usage as GPU per-process stats are complex to obtain
return getRunningProcesses().sorted { $0.cpuUsage > $1.cpuUsage }
}
private func getRunningProcesses() -> [ProcessStats] {
var processes: [ProcessStats] = []
// Get all running applications from NSWorkspace
let runningApps = NSWorkspace.shared.runningApplications
for app in runningApps {
// Skip system processes and processes without bundle identifier
guard let bundleId = app.bundleIdentifier,
!bundleId.hasPrefix("com.apple.") || bundleId == "com.apple.finder" else {
continue
}
let pid = app.processIdentifier
var cpuUsage: Double = 0.0
var memoryUsage: UInt64 = 0
// Get CPU and memory usage using proc_pidinfo
var taskInfo = proc_taskinfo()
let result = proc_pidinfo(pid, PROC_PIDTASKINFO, 0, &taskInfo, Int32(MemoryLayout<proc_taskinfo>.size))
if result == MemoryLayout<proc_taskinfo>.size {
// Calculate CPU usage (basic approximation)
cpuUsage = Double(taskInfo.pti_total_user + taskInfo.pti_total_system) / 1000000.0
// Get memory usage
memoryUsage = taskInfo.pti_resident_size
}
let processStats = ProcessStats(
pid: pid,
name: app.localizedName ?? bundleId,
cpuUsage: cpuUsage,
memoryUsage: memoryUsage,
icon: app.icon
)
processes.append(processStats)
}
return processes
}
} }

View file

@ -298,6 +298,7 @@ extension Defaults.Keys {
// MARK: Stats Feature // MARK: Stats Feature
static let enableStatsFeature = Key<Bool>("enableStatsFeature", default: false) static let enableStatsFeature = Key<Bool>("enableStatsFeature", default: false)
static let autoStartStatsMonitoring = Key<Bool>("autoStartStatsMonitoring", default: true)
static let showCpuGraph = Key<Bool>("showCpuGraph", default: true) static let showCpuGraph = Key<Bool>("showCpuGraph", default: true)
static let showMemoryGraph = Key<Bool>("showMemoryGraph", default: true) static let showMemoryGraph = Key<Bool>("showMemoryGraph", default: true)
static let showGpuGraph = Key<Bool>("showGpuGraph", default: true) static let showGpuGraph = Key<Bool>("showGpuGraph", default: true)

View file

@ -31,6 +31,7 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
@Published var isBatteryPopoverActive: Bool = false @Published var isBatteryPopoverActive: Bool = false
@Published var isClipboardPopoverActive: Bool = false @Published var isClipboardPopoverActive: Bool = false
@Published var isColorPickerPopoverActive: Bool = false @Published var isColorPickerPopoverActive: Bool = false
@Published var isStatsPopoverActive: Bool = false
@Published var shouldRecheckHover: Bool = false @Published var shouldRecheckHover: Bool = false
let webcamManager = WebcamManager.shared let webcamManager = WebcamManager.shared