diff --git a/.gitignore b/.gitignore index 4a248a2..306064c 100644 --- a/.gitignore +++ b/.gitignore @@ -71,4 +71,6 @@ boringnotch + meteo + airpods + traduzione modificabile segmentedhud.swift.ref gemini-apireference.md -ScreenshotApp-main \ No newline at end of file +ScreenshotApp-main +stats +boring.notch \ No newline at end of file diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index a3fce63..ff95e6b 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -30,25 +30,34 @@ struct ContentView: View { @Default(.showNetworkGraph) var showNetworkGraph @Default(.showDiskGraph) var showDiskGraph + // Dynamic sizing based on view type and graph count with smooth transitions var dynamicNotchSize: CGSize { - // Only apply dynamic sizing when on stats tab and stats are enabled - guard coordinator.currentView == .stats && enableStatsFeature else { - return openNotchSize + // Always return the target size for the current view to ensure smooth transitions + let targetSize: CGSize + + 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 - - // 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) + return targetSize } @@ -57,6 +66,9 @@ struct ContentView: View { @State private var debounceWorkItem: DispatchWorkItem? @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 @@ -68,7 +80,7 @@ struct ContentView: View { @Default(.showNotHumanFace) var showNotHumanFace @Default(.useModernCloseAnimation) var useModernCloseAnimation - + private let extendedHoverPadding: CGFloat = 30 private let zeroHeightHoverPadding: CGFloat = 10 @@ -98,18 +110,22 @@ struct ContentView: View { .conditionalModifier(!useModernCloseAnimation) { view in let hoverAnimationAnimation = Animation.bouncy.speed(1.2) let notchStateAnimation = Animation.spring.speed(1.2) + let viewTransitionAnimation = Animation.easeInOut(duration: 0.4) return view .animation(hoverAnimationAnimation, value: isHovering) .animation(notchStateAnimation, value: vm.notchState) + .animation(viewTransitionAnimation, value: coordinator.currentView) .animation(.smooth, value: gestureProgress) .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) } .conditionalModifier(useModernCloseAnimation) { view in let hoverAnimationAnimation = Animation.bouncy.speed(1.2) let notchStateAnimation = Animation.spring.speed(1.2) + let viewTransitionAnimation = Animation.easeInOut(duration: 0.4) return view .animation(hoverAnimationAnimation, value: isHovering) .animation(notchStateAnimation, value: vm.notchState) + .animation(viewTransitionAnimation, value: coordinator.currentView) } .conditionalModifier(Defaults[.openNotchOnHover]) { view in view.onHover { hovering in @@ -119,26 +135,11 @@ struct ContentView: View { .conditionalModifier(!Defaults[.openNotchOnHover]) { view in view .onHover { hovering in - withAnimation(vm.animation) { - 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() - } - } - } + handleSimpleHover(hovering) } .onTapGesture { if (vm.notchState == .closed) && Defaults[.enableHaptics] { - haptics.toggle() + triggerHapticIfAllowed() } doOpen() } @@ -180,7 +181,14 @@ struct ContentView: View { } .onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in 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() } } @@ -188,7 +196,7 @@ struct ContentView: View { .onChange(of: vm.shouldRecheckHover) { _, _ in // Recheck hover state when popovers are closed 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() } } @@ -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) .contextMenu { SettingsLink(label: { @@ -514,7 +551,7 @@ struct ContentView: View { // Only provide haptic feedback if notch is closed if vm.notchState == .closed && Defaults[.enableHaptics] { - haptics.toggle() + triggerHapticIfAllowed() } // Delay opening the notch @@ -553,7 +590,9 @@ struct ContentView: View { } // 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() } } @@ -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 private func handleDownGesture(translation: CGFloat, phase: NSEvent.Phase) { @@ -581,7 +706,7 @@ struct ContentView: View { if translation > Defaults[.gestureSensitivity] { if Defaults[.enableHaptics] { - haptics.toggle() + triggerHapticIfAllowed() } withAnimation(.smooth) { gestureProgress = .zero @@ -610,7 +735,7 @@ struct ContentView: View { vm.close() if Defaults[.enableHaptics] { - haptics.toggle() + triggerHapticIfAllowed() } } } diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 91c901f..655bb42 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -40,12 +40,17 @@ }, "%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%%" : { }, "•" : { - + "comment" : "A period used to separate two related items.", + "isCommentAutoGenerated" : true }, "• Bug fixes" : { @@ -332,6 +337,10 @@ }, "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" : { @@ -564,7 +573,8 @@ }, "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" : { "extractionState" : "stale", @@ -749,9 +759,6 @@ }, "LinkedIn" : { - }, - "Live" : { - }, "Live Monitoring" : { @@ -943,7 +950,8 @@ }, "No Graphs Enabled" : { - + "comment" : "A label displayed when no graphs are enabled in the Stats tab.", + "isCommentAutoGenerated" : true }, "No results" : { @@ -968,9 +976,6 @@ }, "Notch Height" : { - }, - "Off" : { - }, "OK" : { @@ -1058,6 +1063,10 @@ }, "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" : { @@ -1140,6 +1149,10 @@ }, "Recording Status" : { + }, + "Refresh" : { + "comment" : "A button to refresh the displayed data.", + "isCommentAutoGenerated" : true }, "Release name" : { "localizations" : { @@ -1461,6 +1474,10 @@ }, "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" : { @@ -1546,5 +1563,5 @@ } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file diff --git a/DynamicIsland/components/Notch/NotchStatsView.swift b/DynamicIsland/components/Notch/NotchStatsView.swift index 94e81e5..3b695dd 100644 --- a/DynamicIsland/components/Notch/NotchStatsView.swift +++ b/DynamicIsland/components/Notch/NotchStatsView.swift @@ -2,8 +2,8 @@ // NotchStatsView.swift // DynamicIsland // -// Stats tab view for system performance monitoring -// Created by Hariharan Mudaliar +// Adapted from boring.notch StatsView +// Stats tab view for system performance monitoring with clickable process popovers import SwiftUI import Defaults @@ -53,6 +53,14 @@ struct NotchStatsView: View { @Default(.showGpuGraph) var showGpuGraph @Default(.showNetworkGraph) var showNetworkGraph @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 graphs: [GraphData] = [] @@ -130,11 +138,7 @@ struct NotchStatsView: View { spacing: 12 ) { ForEach(0.. 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( + insertion: .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 { + 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 @ViewBuilder func graphView(for graphData: GraphData) -> some View { @@ -232,125 +323,129 @@ struct NotchStatsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() } else { - // Stats content with improved animation coordination - ZStack(alignment: .topTrailing) { - VStack(spacing: 12) { - // Simplified consistent layout - statsGridLayout - } - .padding(16) - .animation(.easeInOut(duration: 0.4), value: availableGraphs.count) - .transition(.asymmetric( - insertion: .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) + // Stats content with smart grid layout + VStack(spacing: 8) { + statsGridLayout } + .padding(12) + .animation(.easeInOut(duration: 0.4), value: availableGraphs.count) + .transition(.asymmetric( + insertion: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4)), + removal: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4)) + )) } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(NSColor.windowBackgroundColor).opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 12)) .onAppear { - if enableStatsFeature && !statsManager.isMonitoring { + if enableStatsFeature && Defaults[.autoStartStatsMonitoring] && !statsManager.isMonitoring { statsManager.startMonitoring() } + // Protect against hover interference during view transition + isResizingForStats = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { + isResizingForStats = false + } } .onDisappear { // Keep monitoring running when tab is not visible } .animation(.easeInOut(duration: 0.4), value: enableStatsFeature) .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() + } + } + + 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 +// Unified Stats Card Component - handles both single and dual data types, matches boring.notch sizing struct UnifiedStatsCard: View { let graphData: GraphData + @State private var isHovered = false var body: some View { - VStack(spacing: 6) { + VStack(spacing: 3) { // Match boring.notch spacing // Header - consistent across all card types HStack(spacing: 4) { Image(systemName: graphData.icon) .foregroundColor(graphData.color) - .font(.caption2) + .font(.caption) // Match boring.notch font size Text(graphData.title) - .font(.caption2) + .font(.caption) // Match boring.notch font size .fontWeight(.medium) .foregroundColor(.secondary) Spacer() - } - - // Values section - adapts based on graph type - Group { + + // Show value on right for single graphs like boring.notch if let singleData = graphData as? SingleGraphData { Text(singleData.value) - .font(.title3) + .font(.caption) // Match boring.notch font size .fontWeight(.bold) .foregroundColor(.primary) - } else if let dualData = graphData as? DualGraphData { - HStack(spacing: 6) { - Text(dualData.positiveValue) - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(dualData.positiveColor) - - Text("•") - .font(.caption2) - .foregroundColor(.secondary) - - Text(dualData.negativeValue) - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(dualData.negativeColor) - } } } - .frame(height: 22) // Fixed height for consistent card sizing + + // Values section for dual graphs only + if let dualData = graphData as? DualGraphData { + HStack(spacing: 6) { + Text(dualData.positiveValue) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(dualData.positiveColor) + + Text("•") + .font(.caption2) + .foregroundColor(.secondary) + + Text(dualData.negativeValue) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(dualData.negativeColor) + } + .frame(height: 18) // Smaller for dual graphs + } // Graph section - adapts based on graph type 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)) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding(8) // Match boring.notch padding - reduced from 10 + .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) + .onHover { hovering in + isHovered = hovering + } } } @@ -429,6 +544,8 @@ struct MiniGraph: View { } } + + struct DualQuadrantGraph: View { let positiveData: [Double] let negativeData: [Double] diff --git a/DynamicIsland/managers/StatsManager.swift b/DynamicIsland/managers/StatsManager.swift index deb6fb6..4391cdf 100644 --- a/DynamicIsland/managers/StatsManager.swift +++ b/DynamicIsland/managers/StatsManager.swift @@ -11,6 +11,7 @@ import SwiftUI import IOKit import IOKit.ps import Darwin +import AppKit //import Network class StatsManager: ObservableObject { @@ -403,4 +404,61 @@ class StatsManager: ObservableObject { diskReadHistory = 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.size)) + + if result == MemoryLayout.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 + } } diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index b865bde..c30515d 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -298,6 +298,7 @@ extension Defaults.Keys { // MARK: Stats Feature static let enableStatsFeature = Key("enableStatsFeature", default: false) + static let autoStartStatsMonitoring = Key("autoStartStatsMonitoring", default: true) static let showCpuGraph = Key("showCpuGraph", default: true) static let showMemoryGraph = Key("showMemoryGraph", default: true) static let showGpuGraph = Key("showGpuGraph", default: true) diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift index 85fe418..54e1aec 100644 --- a/DynamicIsland/models/DynamicIslandViewModel.swift +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -31,6 +31,7 @@ class DynamicIslandViewModel: NSObject, ObservableObject { @Published var isBatteryPopoverActive: Bool = false @Published var isClipboardPopoverActive: Bool = false @Published var isColorPickerPopoverActive: Bool = false + @Published var isStatsPopoverActive: Bool = false @Published var shouldRecheckHover: Bool = false let webcamManager = WebcamManager.shared