diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 8c71423..97476c9 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -694,7 +694,7 @@ struct ContentView: View { self.isHovering = false } - if self.vm.notchState == .open && !self.vm.isBatteryPopoverActive { + if self.vm.notchState == .open && !self.hasAnyActivePopovers() { self.vm.close() } } diff --git a/DynamicIsland/components/Notch/NotchStatsView.swift b/DynamicIsland/components/Notch/NotchStatsView.swift index a8ac7f1..9bb3dd7 100644 --- a/DynamicIsland/components/Notch/NotchStatsView.swift +++ b/DynamicIsland/components/Notch/NotchStatsView.swift @@ -64,6 +64,8 @@ struct NotchStatsView: View { @State private var isHoveringNetworkPopover = false @State private var isHoveringDiskPopover = false @State private var isResizingForStats = false + @State private var statsHoverGraceActive = false + @State private var statsHoverGraceWorkItem: DispatchWorkItem? @EnvironmentObject var vm: DynamicIslandViewModel var availableGraphs: [GraphData] { @@ -364,7 +366,13 @@ struct NotchStatsView: View { } } .onDisappear { + statsHoverGraceWorkItem?.cancel() + statsHoverGraceWorkItem = nil + if statsHoverGraceActive { + statsHoverGraceActive = false + } // Keep monitoring running when tab is not visible + updateStatsPopoverState() } .animation(.easeInOut(duration: 0.4), value: enableStatsFeature) .animation(.easeInOut(duration: 0.4), value: availableGraphs.count) @@ -375,19 +383,44 @@ struct NotchStatsView: View { isResizingForStats = false } } - .onChange(of: showingCPUPopover) { _, _ in + .onChange(of: showingCPUPopover) { _, newValue in + if newValue { + activateStatsHoverGrace() + } else { + deactivateStatsHoverGrace() + } updateStatsPopoverState() } - .onChange(of: showingMemoryPopover) { _, _ in + .onChange(of: showingMemoryPopover) { _, newValue in + if newValue { + activateStatsHoverGrace() + } else { + deactivateStatsHoverGrace() + } updateStatsPopoverState() } - .onChange(of: showingGPUPopover) { _, _ in + .onChange(of: showingGPUPopover) { _, newValue in + if newValue { + activateStatsHoverGrace() + } else { + deactivateStatsHoverGrace() + } updateStatsPopoverState() } - .onChange(of: showingNetworkPopover) { _, _ in + .onChange(of: showingNetworkPopover) { _, newValue in + if newValue { + activateStatsHoverGrace() + } else { + deactivateStatsHoverGrace() + } updateStatsPopoverState() } - .onChange(of: showingDiskPopover) { _, _ in + .onChange(of: showingDiskPopover) { _, newValue in + if newValue { + activateStatsHoverGrace() + } else { + deactivateStatsHoverGrace() + } updateStatsPopoverState() } .onChange(of: isHoveringCPUPopover) { _, _ in @@ -406,26 +439,41 @@ struct NotchStatsView: View { updateStatsPopoverState() } } + + private func activateStatsHoverGrace(duration: TimeInterval = 0.45) { + statsHoverGraceWorkItem?.cancel() + statsHoverGraceActive = true + let workItem = DispatchWorkItem { + statsHoverGraceActive = false + statsHoverGraceWorkItem = nil + updateStatsPopoverState() + } + statsHoverGraceWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: workItem) + } + + private func deactivateStatsHoverGrace() { + statsHoverGraceWorkItem?.cancel() + statsHoverGraceWorkItem = nil + if statsHoverGraceActive { + statsHoverGraceActive = false + } + } 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) || - (showingNetworkPopover && isHoveringNetworkPopover) || - (showingDiskPopover && isHoveringDiskPopover) || - isResizingForStats + let anyPopoverOpen = showingCPUPopover || showingMemoryPopover || showingGPUPopover || showingNetworkPopover || showingDiskPopover + let newState = anyPopoverOpen || isResizingForStats || statsHoverGraceActive 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(" Network: shown=\(showingNetworkPopover), hovering=\(isHoveringNetworkPopover)") - print(" Disk: shown=\(showingDiskPopover), hovering=\(isHoveringDiskPopover)") + print(" CPU open=\(showingCPUPopover)") + print(" Memory open=\(showingMemoryPopover)") + print(" GPU open=\(showingGPUPopover)") + print(" Network open=\(showingNetworkPopover)") + print(" Disk open=\(showingDiskPopover)") print(" Resizing: \(isResizingForStats)") + print(" Hover grace: \(statsHoverGraceActive)") #endif } } diff --git a/DynamicIsland/components/Stats/NetworkStatsDetailView.swift b/DynamicIsland/components/Stats/NetworkStatsDetailView.swift index 8b36c72..8be9e7a 100644 --- a/DynamicIsland/components/Stats/NetworkStatsDetailView.swift +++ b/DynamicIsland/components/Stats/NetworkStatsDetailView.swift @@ -73,11 +73,11 @@ private struct NetworkOverview: View { Text("Download") .font(.headline) .foregroundColor(downloadColor) - Text(StatsFormatting.mbPerSecond(download)) + Text(StatsFormatting.throughput(download)) .font(.system(size: 26, weight: .bold, design: .rounded)) .foregroundColor(.primary) - DetailRow(color: downloadColor.opacity(0.6), label: "Average", value: StatsFormatting.mbPerSecond(avgDownload)) - DetailRow(color: downloadColor.opacity(0.45), label: "Peak", value: StatsFormatting.mbPerSecond(peakDownload)) + DetailRow(color: downloadColor.opacity(0.6), label: "Average", value: StatsFormatting.throughput(avgDownload)) + DetailRow(color: downloadColor.opacity(0.45), label: "Peak", value: StatsFormatting.throughput(peakDownload)) } .frame(maxWidth: .infinity, alignment: .leading) @@ -89,11 +89,11 @@ private struct NetworkOverview: View { Text("Upload") .font(.headline) .foregroundColor(uploadColor) - Text(StatsFormatting.mbPerSecond(upload)) + Text(StatsFormatting.throughput(upload)) .font(.system(size: 26, weight: .bold, design: .rounded)) .foregroundColor(.primary) - DetailRow(color: uploadColor.opacity(0.6), label: "Average", value: StatsFormatting.mbPerSecond(avgUpload)) - DetailRow(color: uploadColor.opacity(0.45), label: "Peak", value: StatsFormatting.mbPerSecond(peakUpload)) + DetailRow(color: uploadColor.opacity(0.6), label: "Average", value: StatsFormatting.throughput(avgUpload)) + DetailRow(color: uploadColor.opacity(0.45), label: "Peak", value: StatsFormatting.throughput(peakUpload)) } .frame(maxWidth: .infinity, alignment: .leading) } @@ -213,8 +213,8 @@ private struct NetworkInterfaceRow: View { Spacer() VStack(alignment: .trailing, spacing: 4) { - metricRow(label: "Down", value: StatsFormatting.mbPerSecond(interface.currentDownload), color: downloadColor) - metricRow(label: "Up", value: StatsFormatting.mbPerSecond(interface.currentUpload), color: uploadColor) + metricRow(label: "Down", value: StatsFormatting.throughput(interface.currentDownload), color: downloadColor) + metricRow(label: "Up", value: StatsFormatting.throughput(interface.currentUpload), color: uploadColor) } } } diff --git a/DynamicIsland/components/Stats/RankedProcessPopover.swift b/DynamicIsland/components/Stats/RankedProcessPopover.swift index 1f35dfb..ce1ea24 100644 --- a/DynamicIsland/components/Stats/RankedProcessPopover.swift +++ b/DynamicIsland/components/Stats/RankedProcessPopover.swift @@ -6,6 +6,9 @@ // Adapted from boring.notch implementation import SwiftUI +#if os(macOS) +import AppKit +#endif struct RankedProcessPopover: View { let rankingType: ProcessRankingType @@ -44,8 +47,12 @@ struct RankedProcessPopover: View { } .buttonStyle(PlainButtonStyle()) } - .onHover { hovering in - onHoverChange?(hovering) + .overlay(alignment: .center) { + PopoverHoverSensor { hovering in + onHoverChange?(hovering) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(false) } } @@ -64,4 +71,51 @@ struct RankedProcessPopover: View { DiskStatsDetailView() } } -} \ No newline at end of file +} + +#if os(macOS) +private struct PopoverHoverSensor: NSViewRepresentable { + var onHoverChange: ((Bool) -> Void)? + + func makeNSView(context: Context) -> TrackingView { + let view = TrackingView() + view.onHoverChange = onHoverChange + return view + } + + func updateNSView(_ nsView: TrackingView, context: Context) { + nsView.onHoverChange = onHoverChange + } + + final class TrackingView: NSView { + var onHoverChange: ((Bool) -> Void)? + private var trackingArea: NSTrackingArea? + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let trackingArea { + removeTrackingArea(trackingArea) + } + let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .activeAlways, .inVisibleRect] + let area = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil) + addTrackingArea(area) + trackingArea = area + } + + override func mouseEntered(with event: NSEvent) { + onHoverChange?(true) + } + + override func mouseExited(with event: NSEvent) { + onHoverChange?(false) + } + + deinit { + onHoverChange?(false) + if let trackingArea { + removeTrackingArea(trackingArea) + } + } + } +} +#endif \ No newline at end of file diff --git a/DynamicIsland/components/Stats/StatsFormatting.swift b/DynamicIsland/components/Stats/StatsFormatting.swift index d11bd26..0e77fd2 100644 --- a/DynamicIsland/components/Stats/StatsFormatting.swift +++ b/DynamicIsland/components/Stats/StatsFormatting.swift @@ -26,6 +26,36 @@ enum StatsFormatting { let formatted = formatter.string(from: number) ?? String(format: "%.2f", value) return "\(formatted) MB/s" } + + static func throughput(_ valueInMegabytesPerSecond: Double) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + + if valueInMegabytesPerSecond >= 1 { + formatter.maximumFractionDigits = valueInMegabytesPerSecond >= 10 ? 1 : 2 + let formatted = formatter.string(from: NSNumber(value: valueInMegabytesPerSecond)) + ?? String(format: "%.2f", valueInMegabytesPerSecond) + return "\(formatted) MB/s" + } + + let valueInKilobytesPerSecond = valueInMegabytesPerSecond * 1024 + if valueInKilobytesPerSecond >= 1 { + formatter.maximumFractionDigits = valueInKilobytesPerSecond >= 10 ? 0 : 1 + let formatted = formatter.string(from: NSNumber(value: valueInKilobytesPerSecond)) + ?? String(format: valueInKilobytesPerSecond >= 10 ? "%.0f" : "%.1f", valueInKilobytesPerSecond) + return "\(formatted) KB/s" + } + + let valueInBytesPerSecond = valueInMegabytesPerSecond * 1_048_576 + if valueInBytesPerSecond >= 1 { + formatter.maximumFractionDigits = 0 + let formatted = formatter.string(from: NSNumber(value: valueInBytesPerSecond)) + ?? String(format: "%.0f", valueInBytesPerSecond) + return "\(formatted) B/s" + } + + return "0 B/s" + } static func percentage(_ value: Double) -> String { String(format: "%.1f%%", value)