From bd3c35e09b7bf023d167a7536482262879cbc7af Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sun, 28 Sep 2025 00:42:18 +0530 Subject: [PATCH 1/4] Working three graphs and vertical expansion --- .gitignore | 4 +- DynamicIsland/ContentView.swift | 15 +- DynamicIsland/Localizable.xcstrings | 37 +- .../components/Notch/NotchStatsView.swift | 577 +++++++----------- DynamicIsland/managers/StatsManager.swift | 58 ++ DynamicIsland/models/Constants.swift | 1 + .../models/DynamicIslandViewModel.swift | 1 + 7 files changed, 301 insertions(+), 392 deletions(-) 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..636f001 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -124,13 +124,13 @@ struct ContentView: View { } // 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 { + if !hovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isStatsPopoverActive { // 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 { + if !isHovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isStatsPopoverActive { vm.close() } } @@ -180,7 +180,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 +195,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() } } diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 91c901f..c5828dd 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -41,10 +41,11 @@ "%lld colors" : { }, - "%lld%%" : { - + "%lld processes" : { + "comment" : "A label at the bottom of the ranked processes popover showing the total number of processes being monitored.", + "isCommentAutoGenerated" : true }, - "•" : { + "%lld%%" : { }, "• Bug fixes" : { @@ -332,6 +333,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" : { @@ -562,9 +567,6 @@ }, "Enable global keyboard shortcuts above to customize your shortcuts." : { - }, - "Enable graph visibility in Settings → Stats to view performance data." : { - }, "Enable haptics" : { "extractionState" : "stale", @@ -749,9 +751,6 @@ }, "LinkedIn" : { - }, - "Live" : { - }, "Live Monitoring" : { @@ -941,9 +940,6 @@ }, "No favorites" : { - }, - "No Graphs Enabled" : { - }, "No results" : { @@ -968,9 +964,6 @@ }, "Notch Height" : { - }, - "Off" : { - }, "OK" : { @@ -1058,6 +1051,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 +1137,10 @@ }, "Recording Status" : { + }, + "Refresh" : { + "comment" : "A button to refresh the displayed data.", + "isCommentAutoGenerated" : true }, "Release name" : { "localizations" : { @@ -1461,6 +1462,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 +1551,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..6ee65bc 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 @@ -31,160 +31,146 @@ struct SingleGraphData: GraphData { let type: GraphType = .single } -// Dual value graph data (for network/disk) -struct DualGraphData: GraphData { - let title: String - let positiveValue: String - let negativeValue: String - let positiveData: [Double] - let negativeData: [Double] - let positiveColor: Color - let negativeColor: Color - let color: Color // Primary color for the component - let icon: String - let type: GraphType = .dual -} - struct NotchStatsView: View { @ObservedObject var statsManager = StatsManager.shared @Default(.enableStatsFeature) var enableStatsFeature - @Default(.showCpuGraph) var showCpuGraph - @Default(.showMemoryGraph) var showMemoryGraph - @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 + @EnvironmentObject var vm: DynamicIslandViewModel var availableGraphs: [GraphData] { var graphs: [GraphData] = [] - if showCpuGraph { - graphs.append(SingleGraphData( - title: "CPU", - value: statsManager.cpuUsageString, - data: statsManager.cpuHistory, - color: .blue, - icon: "cpu" - )) - } + // Only CPU, Memory, and GPU as in boring.notch - no network/disk + graphs.append(SingleGraphData( + title: "CPU", + value: statsManager.cpuUsageString, + data: statsManager.cpuHistory, + color: .blue, + icon: "cpu" + )) - if showMemoryGraph { - graphs.append(SingleGraphData( - title: "Memory", - value: statsManager.memoryUsageString, - data: statsManager.memoryHistory, - color: .green, - icon: "memorychip" - )) - } + graphs.append(SingleGraphData( + title: "Memory", + value: statsManager.memoryUsageString, + data: statsManager.memoryHistory, + color: .green, + icon: "memorychip" + )) - if showGpuGraph { - graphs.append(SingleGraphData( - title: "GPU", - value: statsManager.gpuUsageString, - data: statsManager.gpuHistory, - color: .purple, - icon: "display" - )) - } - - if showNetworkGraph { - graphs.append(DualGraphData( - title: "Network", - positiveValue: "↓" + statsManager.networkDownloadString, - negativeValue: "↑" + statsManager.networkUploadString, - positiveData: statsManager.networkDownloadHistory, - negativeData: statsManager.networkUploadHistory, - positiveColor: .orange, - negativeColor: .red, - color: .orange, - icon: "network" - )) - } - - if showDiskGraph { - graphs.append(DualGraphData( - title: "Disk", - positiveValue: "R " + statsManager.diskReadString, - negativeValue: "W " + statsManager.diskWriteString, - positiveData: statsManager.diskReadHistory, - negativeData: statsManager.diskWriteHistory, - positiveColor: .cyan, - negativeColor: .yellow, - color: .cyan, - icon: "internaldrive" - )) - } + graphs.append(SingleGraphData( + title: "GPU", + value: statsManager.gpuUsageString, + data: statsManager.gpuHistory, + color: .purple, + icon: "display" + )) return graphs } - // Smart grid layout system for different graph counts + // Restored original 3-graph layout from boring.notch @ViewBuilder var statsGridLayout: some View { - let graphCount = availableGraphs.count - - if graphCount <= 3 { - // 1-3 graphs: Single row with equal spacing - LazyVGrid( - columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: graphCount), - spacing: 12 - ) { - ForEach(0.. 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 { @@ -212,88 +198,22 @@ struct NotchStatsView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() - } else if availableGraphs.isEmpty { - // No graphs enabled state - VStack(spacing: 12) { - Image(systemName: "chart.xyaxis.line") - .font(.system(size: 40)) - .foregroundColor(.secondary) - - Text("No Graphs Enabled") - .font(.headline) - .foregroundColor(.primary) - - Text("Enable graph visibility in Settings → Stats to view performance data.") - .font(.caption) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - .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 - restored to original boring.notch 3-graph 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() } } @@ -302,75 +222,105 @@ struct NotchStatsView: View { } .animation(.easeInOut(duration: 0.4), value: enableStatsFeature) .animation(.easeInOut(duration: 0.4), value: availableGraphs.count) + .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 + let newState = (showingCPUPopover && isHoveringCPUPopover) || + (showingMemoryPopover && isHoveringMemoryPopover) || + (showingGPUPopover && isHoveringGPUPopover) + 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)") + #endif + } } } -// Unified Stats Card Component - handles both single and dual data types +// Unified Stats Card Component - now clickable for popovers, matches boring.notch sizing struct UnifiedStatsCard: View { let graphData: GraphData + @State private var isHovered = false var body: some View { - VStack(spacing: 6) { - // Header - consistent across all card types + VStack(spacing: 3) { + // Header with title on left and percentage on right - matches boring.notch layout HStack(spacing: 4) { - Image(systemName: graphData.icon) - .foregroundColor(graphData.color) - .font(.caption2) - - Text(graphData.title) - .font(.caption2) - .fontWeight(.medium) - .foregroundColor(.secondary) + // Title and icon on left + HStack(spacing: 3) { + Image(systemName: graphData.icon) + .foregroundColor(graphData.color) + .font(.caption) + + Text(graphData.title) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.secondary) + } Spacer() - } - - // Values section - adapts based on graph type - Group { + + // Percentage value on right if let singleData = graphData as? SingleGraphData { Text(singleData.value) - .font(.title3) + .font(.caption) .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 - // Graph section - adapts based on graph type + // Graph section - full height since no separate value section, matches boring.notch Group { if let singleData = graphData as? SingleGraphData { MiniGraph(data: singleData.data, color: singleData.color) - } else if let dualData = graphData as? DualGraphData { - DualQuadrantGraph( - positiveData: dualData.positiveData, - negativeData: dualData.negativeData, - positiveColor: dualData.positiveColor, - negativeColor: dualData.negativeColor - ) } } - .frame(height: 50) // Fixed height for consistent card sizing + .frame(height: 36) // Matches boring.notch exactly - reduced from 50px + + // Click hint + 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) // Reduced padding to match boring.notch + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(NSColor.controlBackgroundColor).opacity(0.7)) + .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,122 +379,7 @@ struct MiniGraph: View { } } -struct DualQuadrantGraph: View { - let positiveData: [Double] - let negativeData: [Double] - let positiveColor: Color - let negativeColor: Color - - var body: some View { - GeometryReader { geometry in - let maxPositive = positiveData.max() ?? 1.0 - let maxNegative = negativeData.max() ?? 1.0 - let maxValue = max(maxPositive, maxNegative) - - let normalizedPositive = maxValue > 0 ? positiveData.map { $0 / maxValue } : positiveData - let normalizedNegative = maxValue > 0 ? negativeData.map { $0 / maxValue } : negativeData - - let centerY = geometry.size.height / 2 - - ZStack { - // Center dividing line - Path { path in - path.move(to: CGPoint(x: 0, y: centerY)) - path.addLine(to: CGPoint(x: geometry.size.width, y: centerY)) - } - .stroke(Color.secondary.opacity(0.3), lineWidth: 1) - - // Positive quadrant (upper half) - Path { path in - guard !normalizedPositive.isEmpty else { return } - - let stepX = geometry.size.width / CGFloat(normalizedPositive.count - 1) - - for (index, value) in normalizedPositive.enumerated() { - let x = CGFloat(index) * stepX - let y = centerY - (centerY * CGFloat(value)) // Above center - - if index == 0 { - path.move(to: CGPoint(x: x, y: y)) - } else { - path.addLine(to: CGPoint(x: x, y: y)) - } - } - } - .stroke(positiveColor, lineWidth: 2) - - // Positive fill - Path { path in - guard !normalizedPositive.isEmpty else { return } - - let stepX = geometry.size.width / CGFloat(normalizedPositive.count - 1) - - path.move(to: CGPoint(x: 0, y: centerY)) - - for (index, value) in normalizedPositive.enumerated() { - let x = CGFloat(index) * stepX - let y = centerY - (centerY * CGFloat(value)) - path.addLine(to: CGPoint(x: x, y: y)) - } - - path.addLine(to: CGPoint(x: geometry.size.width, y: centerY)) - path.closeSubpath() - } - .fill( - LinearGradient( - gradient: Gradient(colors: [positiveColor.opacity(0.3), positiveColor.opacity(0.1)]), - startPoint: .top, - endPoint: .center - ) - ) - - // Negative quadrant (lower half) - Path { path in - guard !normalizedNegative.isEmpty else { return } - - let stepX = geometry.size.width / CGFloat(normalizedNegative.count - 1) - - for (index, value) in normalizedNegative.enumerated() { - let x = CGFloat(index) * stepX - let y = centerY + (centerY * CGFloat(value)) // Below center - - if index == 0 { - path.move(to: CGPoint(x: x, y: y)) - } else { - path.addLine(to: CGPoint(x: x, y: y)) - } - } - } - .stroke(negativeColor, lineWidth: 2) - - // Negative fill - Path { path in - guard !normalizedNegative.isEmpty else { return } - - let stepX = geometry.size.width / CGFloat(normalizedNegative.count - 1) - - path.move(to: CGPoint(x: 0, y: centerY)) - - for (index, value) in normalizedNegative.enumerated() { - let x = CGFloat(index) * stepX - let y = centerY + (centerY * CGFloat(value)) - path.addLine(to: CGPoint(x: x, y: y)) - } - - path.addLine(to: CGPoint(x: geometry.size.width, y: centerY)) - path.closeSubpath() - } - .fill( - LinearGradient( - gradient: Gradient(colors: [negativeColor.opacity(0.3), negativeColor.opacity(0.1)]), - startPoint: .bottom, - endPoint: .center - ) - ) - } - } - } -} + #Preview { NotchStatsView() 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 From cc53e73cd10a70ca512bbe1b72ac12cc30dfa567 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sun, 28 Sep 2025 00:57:04 +0530 Subject: [PATCH 2/4] Graph size fixed --- DynamicIsland/Localizable.xcstrings | 12 + .../components/Notch/NotchStatsView.swift | 457 ++++++++++++++---- 2 files changed, 379 insertions(+), 90 deletions(-) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index c5828dd..655bb42 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -47,6 +47,10 @@ }, "%lld%%" : { + }, + "•" : { + "comment" : "A period used to separate two related items.", + "isCommentAutoGenerated" : true }, "• Bug fixes" : { @@ -567,6 +571,10 @@ }, "Enable global keyboard shortcuts above to customize your shortcuts." : { + }, + "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", @@ -940,6 +948,10 @@ }, "No favorites" : { + }, + "No Graphs Enabled" : { + "comment" : "A label displayed when no graphs are enabled in the Stats tab.", + "isCommentAutoGenerated" : true }, "No results" : { diff --git a/DynamicIsland/components/Notch/NotchStatsView.swift b/DynamicIsland/components/Notch/NotchStatsView.swift index 6ee65bc..0209d93 100644 --- a/DynamicIsland/components/Notch/NotchStatsView.swift +++ b/DynamicIsland/components/Notch/NotchStatsView.swift @@ -31,104 +31,209 @@ struct SingleGraphData: GraphData { let type: GraphType = .single } +// Dual value graph data (for network/disk) +struct DualGraphData: GraphData { + let title: String + let positiveValue: String + let negativeValue: String + let positiveData: [Double] + let negativeData: [Double] + let positiveColor: Color + let negativeColor: Color + let color: Color // Primary color for the component + let icon: String + let type: GraphType = .dual +} + struct NotchStatsView: View { @ObservedObject var statsManager = StatsManager.shared @Default(.enableStatsFeature) var enableStatsFeature + @Default(.showCpuGraph) var showCpuGraph + @Default(.showMemoryGraph) var showMemoryGraph + @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] = [] - // Only CPU, Memory, and GPU as in boring.notch - no network/disk - graphs.append(SingleGraphData( - title: "CPU", - value: statsManager.cpuUsageString, - data: statsManager.cpuHistory, - color: .blue, - icon: "cpu" - )) + if showCpuGraph { + graphs.append(SingleGraphData( + title: "CPU", + value: statsManager.cpuUsageString, + data: statsManager.cpuHistory, + color: .blue, + icon: "cpu" + )) + } - graphs.append(SingleGraphData( - title: "Memory", - value: statsManager.memoryUsageString, - data: statsManager.memoryHistory, - color: .green, - icon: "memorychip" - )) + if showMemoryGraph { + graphs.append(SingleGraphData( + title: "Memory", + value: statsManager.memoryUsageString, + data: statsManager.memoryHistory, + color: .green, + icon: "memorychip" + )) + } - graphs.append(SingleGraphData( - title: "GPU", - value: statsManager.gpuUsageString, - data: statsManager.gpuHistory, - color: .purple, - icon: "display" - )) + if showGpuGraph { + graphs.append(SingleGraphData( + title: "GPU", + value: statsManager.gpuUsageString, + data: statsManager.gpuHistory, + color: .purple, + icon: "display" + )) + } + + if showNetworkGraph { + graphs.append(DualGraphData( + title: "Network", + positiveValue: "↓" + statsManager.networkDownloadString, + negativeValue: "↑" + statsManager.networkUploadString, + positiveData: statsManager.networkDownloadHistory, + negativeData: statsManager.networkUploadHistory, + positiveColor: .orange, + negativeColor: .red, + color: .orange, + icon: "network" + )) + } + + if showDiskGraph { + graphs.append(DualGraphData( + title: "Disk", + positiveValue: "R " + statsManager.diskReadString, + negativeValue: "W " + statsManager.diskWriteString, + positiveData: statsManager.diskReadHistory, + negativeData: statsManager.diskWriteHistory, + positiveColor: .cyan, + negativeColor: .yellow, + color: .cyan, + icon: "internaldrive" + )) + } return graphs } - // Restored original 3-graph layout from boring.notch + // Smart grid layout system for different graph counts @ViewBuilder var statsGridLayout: some View { - // 3 graphs: Single row with proper spacing - matches boring.notch exactly - LazyVGrid( - columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), - spacing: 8 - ) { - 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 = false + isHoveringCPUPopover = hovering case "Memory": - isHoveringMemoryPopover = false + isHoveringMemoryPopover = hovering case "GPU": - isHoveringGPUPopover = false + isHoveringGPUPopover = hovering default: break } - // Ensure popover state is updated when popover disappears - DispatchQueue.main.async { - updateStatsPopoverState() - } + } + ) + .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)) )) - } } } @@ -198,8 +303,27 @@ struct NotchStatsView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() + } else if availableGraphs.isEmpty { + // No graphs enabled state + VStack(spacing: 12) { + Image(systemName: "chart.xyaxis.line") + .font(.system(size: 40)) + .foregroundColor(.secondary) + + Text("No Graphs Enabled") + .font(.headline) + .foregroundColor(.primary) + + Text("Enable graph visibility in Settings → Stats to view performance data.") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() } else { - // Stats content - restored to original boring.notch 3-graph layout + // Stats content with smart grid layout VStack(spacing: 8) { statsGridLayout } @@ -222,6 +346,13 @@ struct NotchStatsView: View { } .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.6) { + isResizingForStats = false + } + } .onChange(of: showingCPUPopover) { _, _ in updateStatsPopoverState() } @@ -244,9 +375,11 @@ struct NotchStatsView: View { 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) + (showingGPUPopover && isHoveringGPUPopover) || + isResizingForStats if vm.isStatsPopoverActive != newState { vm.isStatsPopoverActive = newState #if DEBUG @@ -254,62 +387,89 @@ struct NotchStatsView: View { 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 - now clickable for popovers, matches boring.notch sizing +// 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: 3) { - // Header with title on left and percentage on right - matches boring.notch layout + VStack(spacing: 3) { // Match boring.notch spacing + // Header - consistent across all card types HStack(spacing: 4) { - // Title and icon on left - HStack(spacing: 3) { - Image(systemName: graphData.icon) - .foregroundColor(graphData.color) - .font(.caption) - - Text(graphData.title) - .font(.caption) - .fontWeight(.medium) - .foregroundColor(.secondary) - } + Image(systemName: graphData.icon) + .foregroundColor(graphData.color) + .font(.caption) // Match boring.notch font size + + Text(graphData.title) + .font(.caption) // Match boring.notch font size + .fontWeight(.medium) + .foregroundColor(.secondary) Spacer() - // Percentage value on right + // Show value on right for single graphs like boring.notch if let singleData = graphData as? SingleGraphData { Text(singleData.value) - .font(.caption) + .font(.caption) // Match boring.notch font size .fontWeight(.bold) .foregroundColor(.primary) } } - // Graph section - full height since no separate value section, matches boring.notch + // 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 { if let singleData = graphData as? SingleGraphData { MiniGraph(data: singleData.data, color: singleData.color) + } else if let dualData = graphData as? DualGraphData { + DualQuadrantGraph( + positiveData: dualData.positiveData, + negativeData: dualData.negativeData, + positiveColor: dualData.positiveColor, + negativeColor: dualData.negativeColor + ) } } - .frame(height: 36) // Matches boring.notch exactly - reduced from 50px + .frame(height: 36) // Match boring.notch exactly - reduced from 50 - // Click hint - Text("Click for details") - .font(.caption2) - .foregroundColor(.secondary) - .opacity(isHovered ? 1.0 : 0.0) - .animation(.easeInOut(duration: 0.2), value: isHovered) + // 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(8) // Reduced padding to match boring.notch + .padding(8) // Match boring.notch padding - reduced from 10 .background( RoundedRectangle(cornerRadius: 8) - .fill(Color(NSColor.controlBackgroundColor).opacity(0.7)) + .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) @@ -381,6 +541,123 @@ struct MiniGraph: View { +struct DualQuadrantGraph: View { + let positiveData: [Double] + let negativeData: [Double] + let positiveColor: Color + let negativeColor: Color + + var body: some View { + GeometryReader { geometry in + let maxPositive = positiveData.max() ?? 1.0 + let maxNegative = negativeData.max() ?? 1.0 + let maxValue = max(maxPositive, maxNegative) + + let normalizedPositive = maxValue > 0 ? positiveData.map { $0 / maxValue } : positiveData + let normalizedNegative = maxValue > 0 ? negativeData.map { $0 / maxValue } : negativeData + + let centerY = geometry.size.height / 2 + + ZStack { + // Center dividing line + Path { path in + path.move(to: CGPoint(x: 0, y: centerY)) + path.addLine(to: CGPoint(x: geometry.size.width, y: centerY)) + } + .stroke(Color.secondary.opacity(0.3), lineWidth: 1) + + // Positive quadrant (upper half) + Path { path in + guard !normalizedPositive.isEmpty else { return } + + let stepX = geometry.size.width / CGFloat(normalizedPositive.count - 1) + + for (index, value) in normalizedPositive.enumerated() { + let x = CGFloat(index) * stepX + let y = centerY - (centerY * CGFloat(value)) // Above center + + if index == 0 { + path.move(to: CGPoint(x: x, y: y)) + } else { + path.addLine(to: CGPoint(x: x, y: y)) + } + } + } + .stroke(positiveColor, lineWidth: 2) + + // Positive fill + Path { path in + guard !normalizedPositive.isEmpty else { return } + + let stepX = geometry.size.width / CGFloat(normalizedPositive.count - 1) + + path.move(to: CGPoint(x: 0, y: centerY)) + + for (index, value) in normalizedPositive.enumerated() { + let x = CGFloat(index) * stepX + let y = centerY - (centerY * CGFloat(value)) + path.addLine(to: CGPoint(x: x, y: y)) + } + + path.addLine(to: CGPoint(x: geometry.size.width, y: centerY)) + path.closeSubpath() + } + .fill( + LinearGradient( + gradient: Gradient(colors: [positiveColor.opacity(0.3), positiveColor.opacity(0.1)]), + startPoint: .top, + endPoint: .center + ) + ) + + // Negative quadrant (lower half) + Path { path in + guard !normalizedNegative.isEmpty else { return } + + let stepX = geometry.size.width / CGFloat(normalizedNegative.count - 1) + + for (index, value) in normalizedNegative.enumerated() { + let x = CGFloat(index) * stepX + let y = centerY + (centerY * CGFloat(value)) // Below center + + if index == 0 { + path.move(to: CGPoint(x: x, y: y)) + } else { + path.addLine(to: CGPoint(x: x, y: y)) + } + } + } + .stroke(negativeColor, lineWidth: 2) + + // Negative fill + Path { path in + guard !normalizedNegative.isEmpty else { return } + + let stepX = geometry.size.width / CGFloat(normalizedNegative.count - 1) + + path.move(to: CGPoint(x: 0, y: centerY)) + + for (index, value) in normalizedNegative.enumerated() { + let x = CGFloat(index) * stepX + let y = centerY + (centerY * CGFloat(value)) + path.addLine(to: CGPoint(x: x, y: y)) + } + + path.addLine(to: CGPoint(x: geometry.size.width, y: centerY)) + path.closeSubpath() + } + .fill( + LinearGradient( + gradient: Gradient(colors: [negativeColor.opacity(0.3), negativeColor.opacity(0.1)]), + startPoint: .bottom, + endPoint: .center + ) + ) + } + } + } +} + #Preview { NotchStatsView() .frame(width: 400, height: 300) From bf9ecef9c570a8a4283947cd0c0e1bd9c898c7f0 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sun, 28 Sep 2025 13:17:38 +0530 Subject: [PATCH 3/4] Somewhat bearable --- DynamicIsland/ContentView.swift | 152 +++++++++++++----- .../components/Notch/NotchStatsView.swift | 7 +- 2 files changed, 120 insertions(+), 39 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 636f001..7651385 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,8 @@ struct ContentView: View { @State private var debounceWorkItem: DispatchWorkItem? @State private var isHoverStateChanging: Bool = false + @State private var isStatsTransitioning: Bool = false + @State private var lastHapticTime: Date = Date() @State private var gestureProgress: CGFloat = .zero @@ -68,7 +79,7 @@ struct ContentView: View { @Default(.showNotHumanFace) var showNotHumanFace @Default(.useModernCloseAnimation) var useModernCloseAnimation - + private let extendedHoverPadding: CGFloat = 30 private let zeroHeightHoverPadding: CGFloat = 10 @@ -98,18 +109,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 +134,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 && !vm.isStatsPopoverActive { - // 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.isStatsPopoverActive { - vm.close() - } - } - } + handleSimpleHover(hovering) } .onTapGesture { if (vm.notchState == .closed) && Defaults[.enableHaptics] { - haptics.toggle() + triggerHapticIfAllowed() } doOpen() } @@ -210,6 +210,19 @@ struct ContentView: View { } } } + .onChange(of: coordinator.currentView) { _, newView in + // Cancel any pending hover actions during view transitions + hoverWorkItem?.cancel() + debounceWorkItem?.cancel() + + // Set transition state briefly to prevent immediate closure + isStatsTransitioning = true + let delay = newView == .stats ? 1.0 : 0.5 + + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + isStatsTransitioning = false + } + } .sensoryFeedback(.alignment, trigger: haptics) .contextMenu { SettingsLink(label: { @@ -521,7 +534,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 @@ -560,7 +573,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() } } @@ -571,6 +586,67 @@ 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 + withAnimation(vm.animation) { + isHovering = true + } + return + } + + // Mouse exited - use debounce to prevent rapid firing + let debounce = DispatchWorkItem { + withAnimation(vm.animation) { + isHovering = false + } + + // Check if we should close after a delay + let hasPopovers = hasAnyActivePopovers() + + if vm.notchState == .open && !hasPopovers { + let isStatsTab = coordinator.currentView == .stats + let delay: Double = isStatsTab ? 0.8 : 0.2 + + let closeTask = DispatchWorkItem { + if !isHovering && vm.notchState == .open && !hasAnyActivePopovers() { + vm.close() + } + } + + hoverWorkItem = closeTask + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: closeTask) + } + } + + debounceWorkItem = debounce + // Longer debounce for stats tab to prevent rapid hover events + let debounceDelay = coordinator.currentView == .stats ? 0.2 : 0.05 + DispatchQueue.main.asyncAfter(deadline: .now() + debounceDelay, execute: debounce) + } + + // 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 + } + } + // MARK: - Gesture Handling private func handleDownGesture(translation: CGFloat, phase: NSEvent.Phase) { @@ -588,7 +664,7 @@ struct ContentView: View { if translation > Defaults[.gestureSensitivity] { if Defaults[.enableHaptics] { - haptics.toggle() + triggerHapticIfAllowed() } withAnimation(.smooth) { gestureProgress = .zero @@ -617,7 +693,7 @@ struct ContentView: View { vm.close() if Defaults[.enableHaptics] { - haptics.toggle() + triggerHapticIfAllowed() } } } diff --git a/DynamicIsland/components/Notch/NotchStatsView.swift b/DynamicIsland/components/Notch/NotchStatsView.swift index 0209d93..3b695dd 100644 --- a/DynamicIsland/components/Notch/NotchStatsView.swift +++ b/DynamicIsland/components/Notch/NotchStatsView.swift @@ -340,6 +340,11 @@ struct NotchStatsView: View { 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 @@ -349,7 +354,7 @@ struct NotchStatsView: View { .onChange(of: availableGraphs.count) { _, newCount in // Protect against hover interference during dynamic sizing isResizingForStats = true - DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { isResizingForStats = false } } From 3b8e1b2eecd6fe040f9d53331abe714d1371b7f8 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sun, 28 Sep 2025 13:37:24 +0530 Subject: [PATCH 4/4] Still working on it --- DynamicIsland/ContentView.swift | 112 ++++++++++++++++++++++---------- 1 file changed, 77 insertions(+), 35 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 7651385..ff95e6b 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -67,6 +67,7 @@ struct ContentView: View { @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 @@ -210,17 +211,33 @@ struct ContentView: View { } } } - .onChange(of: coordinator.currentView) { _, newView in - // Cancel any pending hover actions during view transitions + .onChange(of: coordinator.currentView) { oldValue, newValue in + // Cancel any pending close actions during view transitions hoverWorkItem?.cancel() - debounceWorkItem?.cancel() - // Set transition state briefly to prevent immediate closure - isStatsTransitioning = true - let delay = newView == .stats ? 1.0 : 0.5 + // Set flags for transition tracking + isSwitchingToStats = (oldValue != .stats && newValue == .stats) - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { - isStatsTransitioning = false + // 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) @@ -593,41 +610,55 @@ struct ContentView: View { debounceWorkItem?.cancel() if hovering { - // Mouse entered + // Mouse entered - always update visual state immediately withAnimation(vm.animation) { isHovering = true } return } - // Mouse exited - use debounce to prevent rapid firing - let debounce = DispatchWorkItem { - withAnimation(vm.animation) { - isHovering = false - } - - // Check if we should close after a delay - let hasPopovers = hasAnyActivePopovers() - - if vm.notchState == .open && !hasPopovers { - let isStatsTab = coordinator.currentView == .stats - let delay: Double = isStatsTab ? 0.8 : 0.2 - - let closeTask = DispatchWorkItem { - if !isHovering && vm.notchState == .open && !hasAnyActivePopovers() { - vm.close() - } - } - - hoverWorkItem = closeTask - DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: closeTask) - } + // Mouse exited - always update visual state immediately + withAnimation(vm.animation) { + isHovering = false } - debounceWorkItem = debounce - // Longer debounce for stats tab to prevent rapid hover events - let debounceDelay = coordinator.currentView == .stats ? 0.2 : 0.05 - DispatchQueue.main.asyncAfter(deadline: .now() + debounceDelay, execute: debounce) + // 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 @@ -647,6 +678,17 @@ struct ContentView: View { } } + // 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) {