mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-29 08:04:32 +00:00
Working stuffgit add .
This commit is contained in:
parent
acf669ca9c
commit
630431a008
15 changed files with 2577 additions and 308 deletions
|
|
@ -40,11 +40,8 @@
|
||||||
debugDocumentVersioning = "YES"
|
debugDocumentVersioning = "YES"
|
||||||
debugServiceExtension = "internal"
|
debugServiceExtension = "internal"
|
||||||
allowLocationSimulation = "YES">
|
allowLocationSimulation = "YES">
|
||||||
<PathRunnable
|
<BuildableProductRunnable
|
||||||
runnableDebuggingMode = "0"
|
runnableDebuggingMode = "0">
|
||||||
FilePath = "/Applications/DynamicIsland.app">
|
|
||||||
</PathRunnable>
|
|
||||||
<MacroExpansion>
|
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "F54406E62CD4987A00C7AFD9"
|
BlueprintIdentifier = "F54406E62CD4987A00C7AFD9"
|
||||||
|
|
@ -52,7 +49,7 @@
|
||||||
BlueprintName = "DynamicIsland"
|
BlueprintName = "DynamicIsland"
|
||||||
ReferencedContainer = "container:DynamicIsland.xcodeproj">
|
ReferencedContainer = "container:DynamicIsland.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</MacroExpansion>
|
</BuildableProductRunnable>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
|
|
|
||||||
|
|
@ -817,10 +817,6 @@
|
||||||
},
|
},
|
||||||
"%lld colors" : {
|
"%lld colors" : {
|
||||||
|
|
||||||
},
|
|
||||||
"%lld processes" : {
|
|
||||||
"comment" : "A label at the bottom of the ranked processes popover showing the total number of processes being monitored.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
},
|
||||||
"%lld%%" : {
|
"%lld%%" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -3053,6 +3049,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Averages" : {
|
||||||
|
"comment" : "A label displayed above the average usage and last update information for a GPU.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Battery" : {
|
"Battery" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -3572,6 +3572,10 @@
|
||||||
},
|
},
|
||||||
"Bottom Pad:" : {
|
"Bottom Pad:" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Breakdown" : {
|
||||||
|
"comment" : "A label displayed above the breakdown of memory usage.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Brightness HUD" : {
|
"Brightness HUD" : {
|
||||||
|
|
||||||
|
|
@ -4828,6 +4832,10 @@
|
||||||
},
|
},
|
||||||
"Copy something to start" : {
|
"Copy something to start" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Core %lld" : {
|
||||||
|
"comment" : "A label showing the ID of a CPU core. The argument is the ID of the CPU core.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"CPU Usage" : {
|
"CPU Usage" : {
|
||||||
|
|
||||||
|
|
@ -7312,6 +7320,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"External" : {
|
||||||
|
"comment" : "A label indicating an external storage device.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Fill Notch" : {
|
"Fill Notch" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
@ -7417,6 +7429,10 @@
|
||||||
},
|
},
|
||||||
"Fit to Notch" : {
|
"Fit to Notch" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Free · %@" : {
|
||||||
|
"comment" : "A label displaying the amount of free storage on a disk device. The argument is the number of free bytes on the disk.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Gemini API Key" : {
|
"Gemini API Key" : {
|
||||||
|
|
||||||
|
|
@ -8749,6 +8765,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Idle" : {
|
||||||
|
"comment" : "A label displayed alongside the status of a GPU device.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Idle Animation Style" : {
|
"Idle Animation Style" : {
|
||||||
"comment" : "The header label for the section that lets users choose the style of idle animation.",
|
"comment" : "The header label for the section that lets users choose the style of idle animation.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
|
@ -9078,6 +9098,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"IPv4 · %@" : {
|
||||||
|
"comment" : "A label displaying an IP address for a network interface. The text inside the parentheses is the IP address of the interface.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"IPv6 · %@" : {
|
||||||
|
"comment" : "A label displaying an IPv6 address associated with a network interface. The text inside the parentheses is replaced with the actual IPv6 address.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Keyboard Backlight HUD" : {
|
"Keyboard Backlight HUD" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
@ -9201,6 +9229,10 @@
|
||||||
},
|
},
|
||||||
"Live Performance Data" : {
|
"Live Performance Data" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Load Average" : {
|
||||||
|
"comment" : "A label displayed above the load average values.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Lock Screen" : {
|
"Lock Screen" : {
|
||||||
|
|
||||||
|
|
@ -11765,6 +11797,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"No active interfaces detected yet." : {
|
||||||
|
"comment" : "A message displayed when there are no active network interfaces to display.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"No clipboard history" : {
|
"No clipboard history" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
@ -12198,11 +12234,19 @@
|
||||||
"comment" : "A label displayed when no graphs are enabled in the Stats tab.",
|
"comment" : "A label displayed when no graphs are enabled in the Stats tab.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"No process data yet. Keep stats open for a moment." : {
|
||||||
|
"comment" : "A message displayed when there is no process data available to display in the CPU stats view. It encourages the user to keep the stats open for a moment to gather more data.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"No results" : {
|
"No results" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"No results found" : {
|
"No results found" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"No storage devices detected yet." : {
|
||||||
|
"comment" : "A message indicating that no storage devices have been detected yet.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Non-notch display height" : {
|
"Non-notch display height" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -12997,10 +13041,6 @@
|
||||||
},
|
},
|
||||||
"Picking Status" : {
|
"Picking Status" : {
|
||||||
|
|
||||||
},
|
|
||||||
"PID: %d" : {
|
|
||||||
"comment" : "A small label displaying the process ID of a particular app. The argument is the process ID of the app.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
},
|
||||||
"Pin items from history to save them here" : {
|
"Pin items from history to save them here" : {
|
||||||
|
|
||||||
|
|
@ -13128,6 +13168,10 @@
|
||||||
},
|
},
|
||||||
"Position" : {
|
"Position" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Pressure" : {
|
||||||
|
"comment" : "A label describing the memory pressure status.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Preview" : {
|
"Preview" : {
|
||||||
|
|
||||||
|
|
@ -13470,6 +13514,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Read" : {
|
||||||
|
"comment" : "A heading displayed for the user's disk read speed.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Ready" : {
|
"Ready" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
@ -13488,10 +13536,6 @@
|
||||||
},
|
},
|
||||||
"Recording Status" : {
|
"Recording Status" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Refresh" : {
|
|
||||||
"comment" : "A button to refresh the displayed data.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
},
|
||||||
"Release name" : {
|
"Release name" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -14298,6 +14342,18 @@
|
||||||
},
|
},
|
||||||
"Sensitivity" : {
|
"Sensitivity" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Session · ↓ %@ · ↑ %@" : {
|
||||||
|
"comment" : "A row displaying the total downloaded and uploaded bytes for the network session.",
|
||||||
|
"isCommentAutoGenerated" : true,
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "Session · ↓ %1$@ · ↑ %2$@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Set" : {
|
"Set" : {
|
||||||
|
|
||||||
|
|
@ -16018,6 +16074,18 @@
|
||||||
},
|
},
|
||||||
"Supports reasoning mode" : {
|
"Supports reasoning mode" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Swap" : {
|
||||||
|
"comment" : "A label for the section of the memory dashboard related to swap usage.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Swap is disabled on this system." : {
|
||||||
|
"comment" : "A message indicating that swap memory is disabled on the user's system.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"System" : {
|
||||||
|
"comment" : "A label displayed above the system, load average, and uptime information.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"System features" : {
|
"System features" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -16452,8 +16520,8 @@
|
||||||
"Toggle the Dynamic Island open or closed from anywhere." : {
|
"Toggle the Dynamic Island open or closed from anywhere." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Top Processes by %@" : {
|
"Total · %@" : {
|
||||||
"comment" : "A heading that announces the top processes by a specific metric. The text inside the parentheses is replaced with the actual metric name.",
|
"comment" : "A label displaying the total storage capacity of a disk device. The argument is the total storage capacity of the disk device.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Transform" : {
|
"Transform" : {
|
||||||
|
|
@ -16976,6 +17044,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Upload" : {
|
||||||
|
"comment" : "A label displayed above the user's upload statistics.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Usage" : {
|
||||||
|
"comment" : "A label displayed above the CPU usage statistics.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Usage Breakdown" : {
|
||||||
|
"comment" : "A heading for the breakdown of CPU usage.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Use music visualizer spectrogram" : {
|
"Use music visualizer spectrogram" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -17076,8 +17156,20 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Used" : {
|
||||||
|
"comment" : "A label describing the percentage of memory that is currently in use.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Used · %@" : {
|
||||||
|
"comment" : "A label showing the used space on a storage device, followed by the size of that space. The argument is the number of bytes used on the device.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Uses event-driven private API for real-time screen recording detection" : {
|
"Uses event-driven private API for real-time screen recording detection" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Utilization" : {
|
||||||
|
"comment" : "A label displayed alongside the utilization percentage in the GPU detail view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Version" : {
|
"Version" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -17599,6 +17691,10 @@
|
||||||
},
|
},
|
||||||
"With %lld graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row." : {
|
"With %lld graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Write" : {
|
||||||
|
"comment" : "A label for the write section of the disk overview.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"You" : {
|
"You" : {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,13 @@ struct NotchStatsView: View {
|
||||||
@State private var showingCPUPopover = false
|
@State private var showingCPUPopover = false
|
||||||
@State private var showingMemoryPopover = false
|
@State private var showingMemoryPopover = false
|
||||||
@State private var showingGPUPopover = false
|
@State private var showingGPUPopover = false
|
||||||
|
@State private var showingNetworkPopover = false
|
||||||
|
@State private var showingDiskPopover = false
|
||||||
@State private var isHoveringCPUPopover = false
|
@State private var isHoveringCPUPopover = false
|
||||||
@State private var isHoveringMemoryPopover = false
|
@State private var isHoveringMemoryPopover = false
|
||||||
@State private var isHoveringGPUPopover = false
|
@State private var isHoveringGPUPopover = false
|
||||||
|
@State private var isHoveringNetworkPopover = false
|
||||||
|
@State private var isHoveringDiskPopover = false
|
||||||
@State private var isResizingForStats = false
|
@State private var isResizingForStats = false
|
||||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||||
|
|
||||||
|
|
@ -181,8 +185,7 @@ struct NotchStatsView: View {
|
||||||
private func graphViewForIndex(_ index: Int) -> some View {
|
private func graphViewForIndex(_ index: Int) -> some View {
|
||||||
let graphData = availableGraphs[index]
|
let graphData = availableGraphs[index]
|
||||||
|
|
||||||
// Only make CPU, Memory, GPU clickable for process popovers
|
if let rankingType = rankingTypeForGraph(graphData) {
|
||||||
if graphData.title == "CPU" || graphData.title == "Memory" || graphData.title == "GPU" {
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
handleGraphClick(for: graphData)
|
handleGraphClick(for: graphData)
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -191,7 +194,7 @@ struct NotchStatsView: View {
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
.popover(isPresented: bindingForGraph(graphData)) {
|
.popover(isPresented: bindingForGraph(graphData)) {
|
||||||
RankedProcessPopover(
|
RankedProcessPopover(
|
||||||
rankingType: rankingTypeForGraph(graphData),
|
rankingType: rankingType,
|
||||||
onHoverChange: { hovering in
|
onHoverChange: { hovering in
|
||||||
switch graphData.title {
|
switch graphData.title {
|
||||||
case "CPU":
|
case "CPU":
|
||||||
|
|
@ -200,13 +203,16 @@ struct NotchStatsView: View {
|
||||||
isHoveringMemoryPopover = hovering
|
isHoveringMemoryPopover = hovering
|
||||||
case "GPU":
|
case "GPU":
|
||||||
isHoveringGPUPopover = hovering
|
isHoveringGPUPopover = hovering
|
||||||
|
case "Network":
|
||||||
|
isHoveringNetworkPopover = hovering
|
||||||
|
case "Disk":
|
||||||
|
isHoveringDiskPopover = hovering
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
// Reset hover states when popover disappears
|
|
||||||
switch graphData.title {
|
switch graphData.title {
|
||||||
case "CPU":
|
case "CPU":
|
||||||
isHoveringCPUPopover = false
|
isHoveringCPUPopover = false
|
||||||
|
|
@ -214,10 +220,13 @@ struct NotchStatsView: View {
|
||||||
isHoveringMemoryPopover = false
|
isHoveringMemoryPopover = false
|
||||||
case "GPU":
|
case "GPU":
|
||||||
isHoveringGPUPopover = false
|
isHoveringGPUPopover = false
|
||||||
|
case "Network":
|
||||||
|
isHoveringNetworkPopover = false
|
||||||
|
case "Disk":
|
||||||
|
isHoveringDiskPopover = false
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// Ensure popover state is updated when popover disappears
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
updateStatsPopoverState()
|
updateStatsPopoverState()
|
||||||
}
|
}
|
||||||
|
|
@ -228,7 +237,6 @@ struct NotchStatsView: View {
|
||||||
removal: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4))
|
removal: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4))
|
||||||
))
|
))
|
||||||
} else {
|
} else {
|
||||||
// Network and Disk graphs are not clickable
|
|
||||||
UnifiedStatsCard(graphData: graphData)
|
UnifiedStatsCard(graphData: graphData)
|
||||||
.transition(.asymmetric(
|
.transition(.asymmetric(
|
||||||
insertion: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4)),
|
insertion: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4)),
|
||||||
|
|
@ -245,6 +253,10 @@ struct NotchStatsView: View {
|
||||||
showingMemoryPopover = true
|
showingMemoryPopover = true
|
||||||
case "GPU":
|
case "GPU":
|
||||||
showingGPUPopover = true
|
showingGPUPopover = true
|
||||||
|
case "Network":
|
||||||
|
showingNetworkPopover = true
|
||||||
|
case "Disk":
|
||||||
|
showingDiskPopover = true
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -258,12 +270,16 @@ struct NotchStatsView: View {
|
||||||
return $showingMemoryPopover
|
return $showingMemoryPopover
|
||||||
case "GPU":
|
case "GPU":
|
||||||
return $showingGPUPopover
|
return $showingGPUPopover
|
||||||
|
case "Network":
|
||||||
|
return $showingNetworkPopover
|
||||||
|
case "Disk":
|
||||||
|
return $showingDiskPopover
|
||||||
default:
|
default:
|
||||||
return .constant(false)
|
return .constant(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func rankingTypeForGraph(_ graphData: GraphData) -> ProcessRankingType {
|
private func rankingTypeForGraph(_ graphData: GraphData) -> ProcessRankingType? {
|
||||||
switch graphData.title {
|
switch graphData.title {
|
||||||
case "CPU":
|
case "CPU":
|
||||||
return .cpu
|
return .cpu
|
||||||
|
|
@ -271,8 +287,12 @@ struct NotchStatsView: View {
|
||||||
return .memory
|
return .memory
|
||||||
case "GPU":
|
case "GPU":
|
||||||
return .gpu
|
return .gpu
|
||||||
|
case "Network":
|
||||||
|
return .network
|
||||||
|
case "Disk":
|
||||||
|
return .disk
|
||||||
default:
|
default:
|
||||||
return .cpu
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -323,7 +343,6 @@ struct NotchStatsView: View {
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.padding()
|
.padding()
|
||||||
} else {
|
} else {
|
||||||
// Stats content with smart grid layout
|
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
statsGridLayout
|
statsGridLayout
|
||||||
}
|
}
|
||||||
|
|
@ -349,7 +368,7 @@ struct NotchStatsView: View {
|
||||||
}
|
}
|
||||||
.animation(.easeInOut(duration: 0.4), value: enableStatsFeature)
|
.animation(.easeInOut(duration: 0.4), value: enableStatsFeature)
|
||||||
.animation(.easeInOut(duration: 0.4), value: availableGraphs.count)
|
.animation(.easeInOut(duration: 0.4), value: availableGraphs.count)
|
||||||
.onChange(of: availableGraphs.count) { _, newCount in
|
.onChange(of: availableGraphs.count) { _, _ in
|
||||||
// Protect against hover interference during dynamic sizing
|
// Protect against hover interference during dynamic sizing
|
||||||
isResizingForStats = true
|
isResizingForStats = true
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
||||||
|
|
@ -365,6 +384,12 @@ struct NotchStatsView: View {
|
||||||
.onChange(of: showingGPUPopover) { _, _ in
|
.onChange(of: showingGPUPopover) { _, _ in
|
||||||
updateStatsPopoverState()
|
updateStatsPopoverState()
|
||||||
}
|
}
|
||||||
|
.onChange(of: showingNetworkPopover) { _, _ in
|
||||||
|
updateStatsPopoverState()
|
||||||
|
}
|
||||||
|
.onChange(of: showingDiskPopover) { _, _ in
|
||||||
|
updateStatsPopoverState()
|
||||||
|
}
|
||||||
.onChange(of: isHoveringCPUPopover) { _, _ in
|
.onChange(of: isHoveringCPUPopover) { _, _ in
|
||||||
updateStatsPopoverState()
|
updateStatsPopoverState()
|
||||||
}
|
}
|
||||||
|
|
@ -374,6 +399,12 @@ struct NotchStatsView: View {
|
||||||
.onChange(of: isHoveringGPUPopover) { _, _ in
|
.onChange(of: isHoveringGPUPopover) { _, _ in
|
||||||
updateStatsPopoverState()
|
updateStatsPopoverState()
|
||||||
}
|
}
|
||||||
|
.onChange(of: isHoveringNetworkPopover) { _, _ in
|
||||||
|
updateStatsPopoverState()
|
||||||
|
}
|
||||||
|
.onChange(of: isHoveringDiskPopover) { _, _ in
|
||||||
|
updateStatsPopoverState()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateStatsPopoverState() {
|
private func updateStatsPopoverState() {
|
||||||
|
|
@ -382,6 +413,8 @@ struct NotchStatsView: View {
|
||||||
let newState = (showingCPUPopover && isHoveringCPUPopover) ||
|
let newState = (showingCPUPopover && isHoveringCPUPopover) ||
|
||||||
(showingMemoryPopover && isHoveringMemoryPopover) ||
|
(showingMemoryPopover && isHoveringMemoryPopover) ||
|
||||||
(showingGPUPopover && isHoveringGPUPopover) ||
|
(showingGPUPopover && isHoveringGPUPopover) ||
|
||||||
|
(showingNetworkPopover && isHoveringNetworkPopover) ||
|
||||||
|
(showingDiskPopover && isHoveringDiskPopover) ||
|
||||||
isResizingForStats
|
isResizingForStats
|
||||||
if vm.isStatsPopoverActive != newState {
|
if vm.isStatsPopoverActive != newState {
|
||||||
vm.isStatsPopoverActive = newState
|
vm.isStatsPopoverActive = newState
|
||||||
|
|
@ -390,6 +423,8 @@ struct NotchStatsView: View {
|
||||||
print(" CPU: shown=\(showingCPUPopover), hovering=\(isHoveringCPUPopover)")
|
print(" CPU: shown=\(showingCPUPopover), hovering=\(isHoveringCPUPopover)")
|
||||||
print(" Memory: shown=\(showingMemoryPopover), hovering=\(isHoveringMemoryPopover)")
|
print(" Memory: shown=\(showingMemoryPopover), hovering=\(isHoveringMemoryPopover)")
|
||||||
print(" GPU: shown=\(showingGPUPopover), hovering=\(isHoveringGPUPopover)")
|
print(" GPU: shown=\(showingGPUPopover), hovering=\(isHoveringGPUPopover)")
|
||||||
|
print(" Network: shown=\(showingNetworkPopover), hovering=\(isHoveringNetworkPopover)")
|
||||||
|
print(" Disk: shown=\(showingDiskPopover), hovering=\(isHoveringDiskPopover)")
|
||||||
print(" Resizing: \(isResizingForStats)")
|
print(" Resizing: \(isResizingForStats)")
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
@ -460,8 +495,8 @@ struct UnifiedStatsCard: View {
|
||||||
}
|
}
|
||||||
.frame(height: 36) // Match boring.notch exactly - reduced from 50
|
.frame(height: 36) // Match boring.notch exactly - reduced from 50
|
||||||
|
|
||||||
// Click hint - only for CPU, Memory, GPU
|
// Click hint - shown for graphs that open popovers
|
||||||
if graphData.title == "CPU" || graphData.title == "Memory" || graphData.title == "GPU" {
|
if ["CPU", "Memory", "GPU", "Network", "Disk"].contains(graphData.title) {
|
||||||
Text("Click for details")
|
Text("Click for details")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
|
||||||
66
DynamicIsland/components/Stats/CPUStatsDetailView.swift
Normal file
66
DynamicIsland/components/Stats/CPUStatsDetailView.swift
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
//
|
||||||
|
// CPUStatsDetailView.swift
|
||||||
|
// DynamicIsland
|
||||||
|
//
|
||||||
|
// SwiftUI port of the stats app CPU popup layout.
|
||||||
|
// Mirrors dashboard, history, detail rows, and top processes.
|
||||||
|
//
|
||||||
|
// Created by GitHub Copilot on 18/10/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CPUStatsDetailView: View {
|
||||||
|
@ObservedObject private var statsManager = StatsManager.shared
|
||||||
|
@State private var topProcesses: [ProcessStats] = []
|
||||||
|
|
||||||
|
private let systemColor = Color(red: 0.94, green: 0.32, blue: 0.28)
|
||||||
|
private let userColor = Color(red: 0.27, green: 0.52, blue: 0.97)
|
||||||
|
private let idleColor = Color.gray.opacity(0.28)
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
StatsCard(title: "CPU Overview") {
|
||||||
|
CPUUsageDashboard(
|
||||||
|
breakdown: statsManager.cpuBreakdown,
|
||||||
|
loadAverage: statsManager.cpuLoadAverage,
|
||||||
|
uptime: statsManager.cpuUptime,
|
||||||
|
coreCount: statsManager.cpuCoreUsage.count,
|
||||||
|
systemColor: systemColor,
|
||||||
|
userColor: userColor,
|
||||||
|
idleColor: idleColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !statsManager.cpuCoreUsage.isEmpty {
|
||||||
|
StatsCard(title: "Per-Core Usage") {
|
||||||
|
CPUCoreUsageGrid(cores: statsManager.cpuCoreUsage, accentColor: userColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StatsCard(title: "Details") {
|
||||||
|
CPUDetailsGrid(
|
||||||
|
breakdown: statsManager.cpuBreakdown,
|
||||||
|
loadAverage: statsManager.cpuLoadAverage,
|
||||||
|
uptime: statsManager.cpuUptime,
|
||||||
|
coreCount: statsManager.cpuCoreUsage.count,
|
||||||
|
systemColor: systemColor,
|
||||||
|
userColor: userColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
StatsCard(title: "Top Processes") {
|
||||||
|
CPUProcessList(processes: topProcesses, accentColor: userColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
.onReceive(statsManager.$topCPUProcesses) { processes in
|
||||||
|
topProcesses = Array(processes.prefix(8))
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
topProcesses = Array(statsManager.topCPUProcesses.prefix(8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
410
DynamicIsland/components/Stats/CPUStatsSharedViews.swift
Normal file
410
DynamicIsland/components/Stats/CPUStatsSharedViews.swift
Normal file
|
|
@ -0,0 +1,410 @@
|
||||||
|
//
|
||||||
|
// CPUStatsSharedViews.swift
|
||||||
|
// DynamicIsland
|
||||||
|
//
|
||||||
|
// Shared CPU stats UI components reused across the notch stats tab
|
||||||
|
// and the legacy stats panel.
|
||||||
|
//
|
||||||
|
// Created by GitHub Copilot on 19/10/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct StatsCard<Content: View>: View {
|
||||||
|
let title: String
|
||||||
|
let padding: CGFloat
|
||||||
|
let background: Color
|
||||||
|
let cornerRadius: CGFloat
|
||||||
|
let content: Content
|
||||||
|
|
||||||
|
init(
|
||||||
|
title: String,
|
||||||
|
padding: CGFloat = 16,
|
||||||
|
background: Color = Color(nsColor: .windowBackgroundColor).opacity(0.65),
|
||||||
|
cornerRadius: CGFloat = 12,
|
||||||
|
@ViewBuilder content: () -> Content
|
||||||
|
) {
|
||||||
|
self.title = title
|
||||||
|
self.padding = padding
|
||||||
|
self.background = background
|
||||||
|
self.cornerRadius = cornerRadius
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text(title)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
|
||||||
|
content
|
||||||
|
}
|
||||||
|
.padding(padding)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||||
|
.fill(background)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||||
|
.stroke(Color.white.opacity(0.05), lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CPUUsageDashboard: View {
|
||||||
|
let breakdown: CPULoadBreakdown
|
||||||
|
let loadAverage: LoadAverage
|
||||||
|
let uptime: TimeInterval
|
||||||
|
let coreCount: Int
|
||||||
|
let systemColor: Color
|
||||||
|
let userColor: Color
|
||||||
|
let idleColor: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .center, spacing: 28) {
|
||||||
|
CPUSegmentDonut(
|
||||||
|
breakdown: breakdown,
|
||||||
|
systemColor: systemColor,
|
||||||
|
userColor: userColor,
|
||||||
|
idleColor: idleColor
|
||||||
|
)
|
||||||
|
.frame(width: 132, height: 132)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Usage")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
DetailRow(color: systemColor.opacity(0.95), label: "System", value: StatsFormatting.percentage(breakdown.system))
|
||||||
|
DetailRow(color: userColor.opacity(0.95), label: "User", value: StatsFormatting.percentage(breakdown.user))
|
||||||
|
DetailRow(color: idleColor.opacity(0.7), label: "Idle", value: StatsFormatting.percentage(breakdown.idle))
|
||||||
|
DetailRow(color: nil, label: "Active", value: StatsFormatting.percentage(breakdown.activeUsage))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("System")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
DetailRow(color: nil, label: "Logical cores", value: "\(max(coreCount, 1))")
|
||||||
|
DetailRow(color: nil, label: "Uptime", value: StatsFormatting.abbreviatedDuration(uptime))
|
||||||
|
Divider().padding(.vertical, 4)
|
||||||
|
DetailRow(color: nil, label: "Load 1m", value: String(format: "%.2f", loadAverage.oneMinute))
|
||||||
|
DetailRow(color: nil, label: "Load 5m", value: String(format: "%.2f", loadAverage.fiveMinutes))
|
||||||
|
DetailRow(color: nil, label: "Load 15m", value: String(format: "%.2f", loadAverage.fifteenMinutes))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CPUSegmentDonut: View {
|
||||||
|
let breakdown: CPULoadBreakdown
|
||||||
|
let systemColor: Color
|
||||||
|
let userColor: Color
|
||||||
|
let idleColor: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(idleColor.opacity(0.3), lineWidth: 14)
|
||||||
|
CPUUsageRing(
|
||||||
|
breakdown: breakdown,
|
||||||
|
systemColor: systemColor,
|
||||||
|
userColor: userColor,
|
||||||
|
lineWidth: 14
|
||||||
|
)
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text(StatsFormatting.percentage(breakdown.activeUsage))
|
||||||
|
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||||
|
Text("Active")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CPUUsageRing: View {
|
||||||
|
let breakdown: CPULoadBreakdown
|
||||||
|
let systemColor: Color
|
||||||
|
let userColor: Color
|
||||||
|
var lineWidth: CGFloat = 14
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let segments = breakdown.normalizedSegments
|
||||||
|
ZStack {
|
||||||
|
if segments.system > 0 {
|
||||||
|
RingArc(start: 0, end: segments.system, color: systemColor, lineWidth: lineWidth)
|
||||||
|
}
|
||||||
|
if segments.user > 0 {
|
||||||
|
RingArc(start: segments.system, end: segments.system + segments.user, color: userColor, lineWidth: lineWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RingArc: View {
|
||||||
|
let start: Double
|
||||||
|
let end: Double
|
||||||
|
let color: Color
|
||||||
|
var lineWidth: CGFloat = 14
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Circle()
|
||||||
|
.trim(from: CGFloat(start), to: CGFloat(end))
|
||||||
|
.stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LegendRow: View {
|
||||||
|
let color: Color
|
||||||
|
let title: String
|
||||||
|
let value: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 10, height: 10)
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(value)
|
||||||
|
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoadAverageRow: View {
|
||||||
|
let loadAverage: LoadAverage
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Load Average")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
LoadAverageValue(label: "1m", value: loadAverage.oneMinute)
|
||||||
|
LoadAverageValue(label: "5m", value: loadAverage.fiveMinutes)
|
||||||
|
LoadAverageValue(label: "15m", value: loadAverage.fifteenMinutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoadAverageValue: View {
|
||||||
|
let label: String
|
||||||
|
let value: Double
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(String(format: "%.2f", value))
|
||||||
|
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DetailRow: View {
|
||||||
|
let color: Color?
|
||||||
|
let label: String
|
||||||
|
let value: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
if let color {
|
||||||
|
Circle()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
}
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(value)
|
||||||
|
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CPUCoreUsageGrid: View {
|
||||||
|
let cores: [CPUCoreUsage]
|
||||||
|
let accentColor: Color
|
||||||
|
|
||||||
|
private var columns: [GridItem] {
|
||||||
|
[GridItem(.adaptive(minimum: 90), spacing: 14, alignment: .top)]
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
LazyVGrid(columns: columns, spacing: 14) {
|
||||||
|
ForEach(cores) { core in
|
||||||
|
CPUCoreUsageCell(core: core, accentColor: accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CPUCoreUsageCell: View {
|
||||||
|
let core: CPUCoreUsage
|
||||||
|
let accentColor: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Core \(core.id + 1)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(StatsFormatting.percentage(core.usage))
|
||||||
|
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundColor(accentColor)
|
||||||
|
}
|
||||||
|
GeometryReader { proxy in
|
||||||
|
let width = proxy.size.width
|
||||||
|
let progress = max(0, min(core.usage / 100, 1))
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Capsule()
|
||||||
|
.fill(Color.primary.opacity(0.08))
|
||||||
|
Capsule()
|
||||||
|
.fill(accentColor)
|
||||||
|
.frame(width: width * progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 8)
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
|
.fill(Color(nsColor: .controlBackgroundColor).opacity(0.6))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CPUDetailsGrid: View {
|
||||||
|
let breakdown: CPULoadBreakdown
|
||||||
|
let loadAverage: LoadAverage
|
||||||
|
let uptime: TimeInterval
|
||||||
|
let coreCount: Int
|
||||||
|
let systemColor: Color
|
||||||
|
let userColor: Color
|
||||||
|
var showLoadAverage: Bool = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Usage Breakdown")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
DetailRow(color: nil, label: "Total", value: StatsFormatting.percentage(breakdown.activeUsage))
|
||||||
|
DetailRow(color: systemColor, label: "System", value: StatsFormatting.percentage(breakdown.system))
|
||||||
|
DetailRow(color: userColor, label: "User", value: StatsFormatting.percentage(breakdown.user))
|
||||||
|
DetailRow(color: Color.gray.opacity(0.4), label: "Idle", value: StatsFormatting.percentage(breakdown.idle))
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider().padding(.vertical, 4)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("System")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
DetailRow(color: nil, label: "Logical cores", value: "\(max(coreCount, 1))")
|
||||||
|
DetailRow(color: nil, label: "Uptime", value: StatsFormatting.abbreviatedDuration(uptime))
|
||||||
|
}
|
||||||
|
|
||||||
|
if showLoadAverage {
|
||||||
|
Divider().padding(.vertical, 4)
|
||||||
|
LoadAverageRow(loadAverage: loadAverage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
struct CPUProcessList: View {
|
||||||
|
let processes: [ProcessStats]
|
||||||
|
let accentColor: Color
|
||||||
|
var displayLimit: Int? = nil
|
||||||
|
|
||||||
|
private var visibleProcesses: [ProcessStats] {
|
||||||
|
if let limit = displayLimit {
|
||||||
|
return Array(processes.prefix(limit))
|
||||||
|
}
|
||||||
|
return processes
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
if visibleProcesses.isEmpty {
|
||||||
|
Text("No process data yet. Keep stats open for a moment.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
} else {
|
||||||
|
ForEach(visibleProcesses) { process in
|
||||||
|
CPUProcessRow(process: process, accentColor: accentColor)
|
||||||
|
if process.id != visibleProcesses.last?.id {
|
||||||
|
Divider().padding(.leading, 32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CPUProcessRow: View {
|
||||||
|
let process: ProcessStats
|
||||||
|
let accentColor: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
iconView
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||||
|
.stroke(Color.white.opacity(0.08), lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(process.name)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(process.memoryUsageString)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(process.cpuUsageString)
|
||||||
|
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundColor(accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconView: some View {
|
||||||
|
Group {
|
||||||
|
if let icon = process.icon {
|
||||||
|
Image(nsImage: icon)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
} else {
|
||||||
|
ZStack {
|
||||||
|
accentColor.opacity(0.15)
|
||||||
|
Image(systemName: "app")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
220
DynamicIsland/components/Stats/DiskStatsDetailView.swift
Normal file
220
DynamicIsland/components/Stats/DiskStatsDetailView.swift
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
//
|
||||||
|
// DiskStatsDetailView.swift
|
||||||
|
// DynamicIsland
|
||||||
|
//
|
||||||
|
// Disk detail dashboard mirroring Stats app cards.
|
||||||
|
//
|
||||||
|
// Created by GitHub Copilot on 19/10/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct DiskStatsDetailView: View {
|
||||||
|
@ObservedObject private var statsManager = StatsManager.shared
|
||||||
|
|
||||||
|
private let readColor = Color.cyan
|
||||||
|
private let writeColor = Color.yellow
|
||||||
|
private let cardBackground = Color(nsColor: .windowBackgroundColor).opacity(0.65)
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
StatsCard(title: "Disk Overview", padding: 16, background: cardBackground, cornerRadius: 12) {
|
||||||
|
DiskOverview(
|
||||||
|
readSpeed: statsManager.diskRead,
|
||||||
|
writeSpeed: statsManager.diskWrite,
|
||||||
|
peakRead: statsManager.diskReadHistory.max() ?? 0,
|
||||||
|
peakWrite: statsManager.diskWriteHistory.max() ?? 0,
|
||||||
|
readColor: readColor,
|
||||||
|
writeColor: writeColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
StatsCard(title: "Storage Devices", padding: 16, background: cardBackground, cornerRadius: 12) {
|
||||||
|
DiskDevicesCard(devices: statsManager.diskDevices, accentColor: readColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
StatsCard(title: "Session Totals", padding: 16, background: cardBackground, cornerRadius: 12) {
|
||||||
|
DiskTotalsView(totals: statsManager.diskTotals, readColor: readColor, writeColor: writeColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 360, minHeight: 340)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DiskOverview: View {
|
||||||
|
let readSpeed: Double
|
||||||
|
let writeSpeed: Double
|
||||||
|
let peakRead: Double
|
||||||
|
let peakWrite: Double
|
||||||
|
let readColor: Color
|
||||||
|
let writeColor: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .center, spacing: 24) {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Read")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(readColor)
|
||||||
|
Text(StatsFormatting.mbPerSecond(readSpeed))
|
||||||
|
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
DetailRow(color: readColor.opacity(0.6), label: "Peak", value: StatsFormatting.mbPerSecond(peakRead))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.frame(height: 110)
|
||||||
|
.overlay(Color.white.opacity(0.06))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Write")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(writeColor)
|
||||||
|
Text(StatsFormatting.mbPerSecond(writeSpeed))
|
||||||
|
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
DetailRow(color: writeColor.opacity(0.6), label: "Peak", value: StatsFormatting.mbPerSecond(peakWrite))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DiskTotalsView: View {
|
||||||
|
let totals: DiskTotals
|
||||||
|
let readColor: Color
|
||||||
|
let writeColor: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
DetailRow(color: readColor.opacity(0.75), label: "Read", value: formattedBytes(totals.readMB))
|
||||||
|
DetailRow(color: writeColor.opacity(0.75), label: "Written", value: formattedBytes(totals.writtenMB))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formattedBytes(_ megabytes: Double) -> String {
|
||||||
|
let bytes = UInt64(megabytes * 1_048_576)
|
||||||
|
return StatsFormatting.bytes(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DiskDevicesCard: View {
|
||||||
|
let devices: [DiskDeviceMetrics]
|
||||||
|
let accentColor: Color
|
||||||
|
|
||||||
|
private var sortedDevices: [DiskDeviceMetrics] {
|
||||||
|
devices.sorted { lhs, rhs in
|
||||||
|
if lhs.isRoot != rhs.isRoot {
|
||||||
|
return lhs.isRoot && !rhs.isRoot
|
||||||
|
}
|
||||||
|
if lhs.isRemovable != rhs.isRemovable {
|
||||||
|
return !lhs.isRemovable && rhs.isRemovable
|
||||||
|
}
|
||||||
|
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if sortedDevices.isEmpty {
|
||||||
|
Text("No storage devices detected yet.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ForEach(sortedDevices) { device in
|
||||||
|
DiskDeviceRow(device: device, accentColor: accentColor)
|
||||||
|
if device.id != sortedDevices.last?.id {
|
||||||
|
Divider().padding(.leading, 36)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DiskDeviceRow: View {
|
||||||
|
let device: DiskDeviceMetrics
|
||||||
|
let accentColor: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
|
.fill(accentColor.opacity(0.16))
|
||||||
|
.overlay(
|
||||||
|
Image(systemName: device.isRemovable ? "externaldrive" : "internaldrive")
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundColor(accentColor)
|
||||||
|
)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(device.name)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
Text(device.path.path)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if device.isRoot {
|
||||||
|
Text("System")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(accentColor)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(accentColor.opacity(0.16))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
} else if device.isRemovable {
|
||||||
|
Text("External")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(accentColor)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(accentColor.opacity(0.16))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DiskUsageBar(usedBytes: device.usedBytes, totalBytes: device.totalBytes, tint: accentColor)
|
||||||
|
.frame(height: 8)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Used · \(StatsFormatting.bytes(device.usedBytes))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text("Free · \(StatsFormatting.bytes(device.freeBytes))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Total · \(StatsFormatting.bytes(device.totalBytes))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DiskUsageBar: View {
|
||||||
|
let usedBytes: UInt64
|
||||||
|
let totalBytes: UInt64
|
||||||
|
let tint: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
let width = proxy.size.width
|
||||||
|
let progress = totalBytes > 0 ? min(max(Double(usedBytes) / Double(totalBytes), 0), 1) : 0
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Capsule()
|
||||||
|
.fill(Color.primary.opacity(0.08))
|
||||||
|
Capsule()
|
||||||
|
.fill(tint.opacity(0.8))
|
||||||
|
.frame(width: width * progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
190
DynamicIsland/components/Stats/GPUStatsDetailView.swift
Normal file
190
DynamicIsland/components/Stats/GPUStatsDetailView.swift
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
//
|
||||||
|
// GPUStatsDetailView.swift
|
||||||
|
// DynamicIsland
|
||||||
|
//
|
||||||
|
// GPU detail dashboard inspired by the Stats app layout.
|
||||||
|
//
|
||||||
|
// Created by GitHub Copilot on 19/10/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct GPUStatsDetailView: View {
|
||||||
|
@ObservedObject private var statsManager = StatsManager.shared
|
||||||
|
@State private var topProcesses: [ProcessStats] = []
|
||||||
|
|
||||||
|
private let accentColor = Color.purple
|
||||||
|
private let cardBackground = Color(nsColor: .windowBackgroundColor).opacity(0.65)
|
||||||
|
private let processDisplayLimit = 8
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
StatsCard(title: "GPU Overview", padding: 16, background: cardBackground, cornerRadius: 12) {
|
||||||
|
GPUUsageDashboard(
|
||||||
|
usage: statsManager.gpuUsage,
|
||||||
|
breakdown: statsManager.gpuBreakdown,
|
||||||
|
averageUsage: statsManager.avgGpuUsage,
|
||||||
|
lastUpdated: statsManager.lastUpdated,
|
||||||
|
accentColor: accentColor,
|
||||||
|
primaryDevice: statsManager.gpuDevices.first
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !statsManager.gpuDevices.isEmpty {
|
||||||
|
StatsCard(title: "Devices", padding: 16, background: cardBackground, cornerRadius: 12) {
|
||||||
|
GPUDeviceList(devices: statsManager.gpuDevices, accentColor: accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StatsCard(title: "Top Processes", padding: 16, background: cardBackground, cornerRadius: 12) {
|
||||||
|
CPUProcessList(processes: topProcesses, accentColor: accentColor, displayLimit: processDisplayLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 360, minHeight: 380)
|
||||||
|
.onAppear(perform: refreshProcesses)
|
||||||
|
.onReceive(statsManager.$lastUpdated) { _ in
|
||||||
|
refreshProcesses()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshProcesses() {
|
||||||
|
let processes = statsManager.getProcessesRankedByGPU()
|
||||||
|
topProcesses = Array(processes.prefix(processDisplayLimit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct GPUUsageDashboard: View {
|
||||||
|
let usage: Double
|
||||||
|
let breakdown: GPUBreakdown
|
||||||
|
let averageUsage: Double
|
||||||
|
let lastUpdated: Date
|
||||||
|
let accentColor: Color
|
||||||
|
let primaryDevice: GPUDeviceMetrics?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .center, spacing: 24) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(accentColor.opacity(0.25), lineWidth: 14)
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
|
|
||||||
|
RingArc(start: 0, end: CGFloat(min(max(usage / 100, 0), 1)), color: accentColor, lineWidth: 14)
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
|
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text(StatsFormatting.percentage(usage))
|
||||||
|
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||||
|
Text("Active")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
DetailRow(color: accentColor.opacity(0.9), label: "Render", value: StatsFormatting.percentage(breakdown.render))
|
||||||
|
DetailRow(color: accentColor.opacity(0.7), label: "Compute", value: StatsFormatting.percentage(breakdown.compute))
|
||||||
|
DetailRow(color: accentColor.opacity(0.55), label: "Video", value: StatsFormatting.percentage(breakdown.video))
|
||||||
|
DetailRow(color: accentColor.opacity(0.45), label: "Other", value: StatsFormatting.percentage(breakdown.other))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Averages")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
DetailRow(color: nil, label: "Session Avg", value: StatsFormatting.percentage(averageUsage))
|
||||||
|
DetailRow(color: nil, label: "Last Update", value: formattedTimestamp(lastUpdated))
|
||||||
|
if let device = primaryDevice {
|
||||||
|
Divider().padding(.vertical, 4)
|
||||||
|
DetailRow(color: nil, label: "Active GPU", value: device.formattedVendorModel)
|
||||||
|
DetailRow(color: nil, label: "Status", value: device.isActive ? "Active" : "Idle")
|
||||||
|
DetailRow(color: nil, label: "Temperature", value: device.temperatureText)
|
||||||
|
if let cores = device.cores {
|
||||||
|
DetailRow(color: nil, label: "Cores", value: "\(cores)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formattedTimestamp(_ date: Date) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .none
|
||||||
|
formatter.timeStyle = .medium
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct GPUDeviceList: View {
|
||||||
|
let devices: [GPUDeviceMetrics]
|
||||||
|
let accentColor: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ForEach(Array(devices.enumerated()), id: \.element.id) { index, device in
|
||||||
|
GPUDeviceRow(device: device, accentColor: accentColor)
|
||||||
|
if index < devices.count - 1 {
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct GPUDeviceRow: View {
|
||||||
|
let device: GPUDeviceMetrics
|
||||||
|
let accentColor: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Circle()
|
||||||
|
.fill(device.isActive ? Color.green.opacity(0.85) : Color.red.opacity(0.7))
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(device.formattedVendorModel)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
Text(device.isActive ? "Active" : "Idle")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(device.isActive ? .green : .secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text(device.utilizationText)
|
||||||
|
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundColor(accentColor)
|
||||||
|
Text("Utilization")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
if let render = device.renderUtilization {
|
||||||
|
DetailRow(color: accentColor.opacity(0.9), label: "Renderer", value: StatsFormatting.percentage(render))
|
||||||
|
}
|
||||||
|
if let tiler = device.tilerUtilization {
|
||||||
|
DetailRow(color: accentColor.opacity(0.6), label: "Tiler", value: StatsFormatting.percentage(tiler))
|
||||||
|
}
|
||||||
|
if let temp = device.temperature {
|
||||||
|
DetailRow(color: nil, label: "Temperature", value: String(format: "%.0f°C", temp))
|
||||||
|
}
|
||||||
|
if let fan = device.fanSpeed {
|
||||||
|
DetailRow(color: nil, label: "Fan Speed", value: "\(fan)%")
|
||||||
|
}
|
||||||
|
if let coreClock = device.coreClock {
|
||||||
|
DetailRow(color: nil, label: "Core Clock", value: "\(coreClock) MHz")
|
||||||
|
}
|
||||||
|
if let memoryClock = device.memoryClock {
|
||||||
|
DetailRow(color: nil, label: "Memory Clock", value: "\(memoryClock) MHz")
|
||||||
|
}
|
||||||
|
if let cores = device.cores {
|
||||||
|
DetailRow(color: nil, label: "Cores", value: "\(cores)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
255
DynamicIsland/components/Stats/MemoryStatsDetailView.swift
Normal file
255
DynamicIsland/components/Stats/MemoryStatsDetailView.swift
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
//
|
||||||
|
// MemoryStatsDetailView.swift
|
||||||
|
// DynamicIsland
|
||||||
|
//
|
||||||
|
// Detailed memory dashboard mirroring the Stats app layout.
|
||||||
|
//
|
||||||
|
// Created by GitHub Copilot on 19/10/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MemoryStatsDetailView: View {
|
||||||
|
@ObservedObject private var statsManager = StatsManager.shared
|
||||||
|
@State private var topProcesses: [ProcessStats] = []
|
||||||
|
|
||||||
|
private let accentColor = Color.green
|
||||||
|
private let cardBackground = Color(nsColor: .windowBackgroundColor).opacity(0.65)
|
||||||
|
private let processDisplayLimit = 8
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
StatsCard(title: "Memory Overview", padding: 16, background: cardBackground, cornerRadius: 12) {
|
||||||
|
MemoryUsageDashboard(breakdown: statsManager.memoryBreakdown, accentColor: accentColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
StatsCard(title: "Pressure & Swap", padding: 16, background: cardBackground, cornerRadius: 12) {
|
||||||
|
MemoryHealthView(breakdown: statsManager.memoryBreakdown, accentColor: accentColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
StatsCard(title: "Details", padding: 16, background: cardBackground, cornerRadius: 12) {
|
||||||
|
MemoryDetailsGrid(breakdown: statsManager.memoryBreakdown, accentColor: accentColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
StatsCard(title: "Top Processes", padding: 16, background: cardBackground, cornerRadius: 12) {
|
||||||
|
CPUProcessList(processes: topProcesses, accentColor: accentColor, displayLimit: processDisplayLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 380, minHeight: 420)
|
||||||
|
.onAppear(perform: refreshProcesses)
|
||||||
|
.onReceive(statsManager.$lastUpdated) { _ in
|
||||||
|
refreshProcesses()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshProcesses() {
|
||||||
|
let processes = statsManager.getProcessesRankedByMemory()
|
||||||
|
topProcesses = Array(processes.prefix(processDisplayLimit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MemoryUsageDashboard: View {
|
||||||
|
let breakdown: MemoryBreakdown
|
||||||
|
let accentColor: Color
|
||||||
|
|
||||||
|
private let freeColor = Color.gray.opacity(0.28)
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .center, spacing: 28) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(freeColor.opacity(0.35), lineWidth: 14)
|
||||||
|
.frame(width: 128, height: 128)
|
||||||
|
|
||||||
|
MemoryUsageRing(breakdown: breakdown, usedColor: accentColor, freeColor: freeColor)
|
||||||
|
.frame(width: 128, height: 128)
|
||||||
|
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text(StatsFormatting.percentage(breakdown.usedPercentage))
|
||||||
|
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||||
|
Text("Used")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
DetailRow(color: accentColor.opacity(0.8), label: "Used", value: formattedBytes(breakdown.usedBytes))
|
||||||
|
DetailRow(color: freeColor.opacity(0.9), label: "Free", value: formattedBytes(breakdown.freeBytes))
|
||||||
|
DetailRow(color: nil, label: "Total", value: formattedBytes(breakdown.totalBytes))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Breakdown")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
MemoryBreakdownRow(label: "App", value: breakdown.appBytes, total: breakdown.totalBytes, color: accentColor.opacity(0.85))
|
||||||
|
MemoryBreakdownRow(label: "Cached", value: breakdown.cacheBytes, total: breakdown.totalBytes, color: accentColor.opacity(0.65))
|
||||||
|
MemoryBreakdownRow(label: "Wired", value: breakdown.wiredBytes, total: breakdown.totalBytes, color: accentColor.opacity(0.45))
|
||||||
|
MemoryBreakdownRow(label: "Compressed", value: breakdown.compressedBytes, total: breakdown.totalBytes, color: accentColor.opacity(0.35))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formattedBytes(_ value: UInt64) -> String {
|
||||||
|
StatsFormatting.bytes(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MemoryUsageRing: View {
|
||||||
|
let breakdown: MemoryBreakdown
|
||||||
|
let usedColor: Color
|
||||||
|
let freeColor: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
let usedFraction = CGFloat(min(max(breakdown.usedPercentage / 100, 0), 1))
|
||||||
|
if usedFraction > 0 {
|
||||||
|
RingArc(start: 0, end: usedFraction, color: usedColor, lineWidth: 14)
|
||||||
|
}
|
||||||
|
if usedFraction < 1 {
|
||||||
|
RingArc(start: usedFraction, end: 1, color: freeColor.opacity(0.65), lineWidth: 14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MemoryBreakdownRow: View {
|
||||||
|
let label: String
|
||||||
|
let value: UInt64
|
||||||
|
let total: UInt64
|
||||||
|
let color: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let percent = total > 0 ? Double(value) / Double(total) * 100 : 0
|
||||||
|
return DetailRow(color: color, label: label, value: "\(StatsFormatting.bytes(value)) · \(StatsFormatting.percentage(percent))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MemoryDetailsGrid: View {
|
||||||
|
let breakdown: MemoryBreakdown
|
||||||
|
let accentColor: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
DetailRow(color: accentColor.opacity(0.85), label: "App Memory", value: StatsFormatting.bytes(breakdown.appBytes))
|
||||||
|
DetailRow(color: accentColor.opacity(0.7), label: "Cached Files", value: StatsFormatting.bytes(breakdown.cacheBytes))
|
||||||
|
DetailRow(color: accentColor.opacity(0.6), label: "Active", value: StatsFormatting.bytes(breakdown.activeBytes))
|
||||||
|
DetailRow(color: accentColor.opacity(0.45), label: "Inactive", value: StatsFormatting.bytes(breakdown.inactiveBytes))
|
||||||
|
DetailRow(color: accentColor.opacity(0.35), label: "Wired", value: StatsFormatting.bytes(breakdown.wiredBytes))
|
||||||
|
DetailRow(color: accentColor.opacity(0.25), label: "Compressed", value: StatsFormatting.bytes(breakdown.compressedBytes))
|
||||||
|
DetailRow(color: accentColor.opacity(0.18), label: "Free", value: StatsFormatting.bytes(breakdown.freeBytes))
|
||||||
|
Divider().padding(.vertical, 4)
|
||||||
|
DetailRow(color: nil, label: "Total Memory", value: StatsFormatting.bytes(breakdown.totalBytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MemoryHealthView: View {
|
||||||
|
let breakdown: MemoryBreakdown
|
||||||
|
let accentColor: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
MemoryPressureIndicator(pressure: breakdown.pressure)
|
||||||
|
Divider().padding(.vertical, 4)
|
||||||
|
SwapUsageView(swap: breakdown.swap, accentColor: accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MemoryPressureIndicator: View {
|
||||||
|
let pressure: MemoryPressure
|
||||||
|
|
||||||
|
private var status: (title: String, color: Color, description: String) {
|
||||||
|
switch pressure.level {
|
||||||
|
case .normal:
|
||||||
|
return ("Normal", Color.green, "System memory pressure is nominal.")
|
||||||
|
case .warning:
|
||||||
|
return ("Warning", Color.orange, "Memory pressure is elevated. Closing apps may help.")
|
||||||
|
case .critical:
|
||||||
|
return ("Critical", Color.red, "Memory pressure is critical; the system may purge aggressively.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Pressure")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Capsule()
|
||||||
|
.fill(status.color.opacity(0.16))
|
||||||
|
.overlay(
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(status.color)
|
||||||
|
.frame(width: 10, height: 10)
|
||||||
|
Text(status.title)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundColor(status.color)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
)
|
||||||
|
.frame(height: 28)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(status.description)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SwapUsageView: View {
|
||||||
|
let swap: MemorySwap
|
||||||
|
let accentColor: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Swap")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
if swap.totalBytes > 0 {
|
||||||
|
DetailRow(color: accentColor.opacity(0.85), label: "Used", value: StatsFormatting.bytes(swap.usedBytes))
|
||||||
|
DetailRow(color: accentColor.opacity(0.55), label: "Free", value: StatsFormatting.bytes(swap.freeBytes))
|
||||||
|
DetailRow(color: nil, label: "Total", value: StatsFormatting.bytes(swap.totalBytes))
|
||||||
|
SwapUsageBar(usedBytes: swap.usedBytes, totalBytes: swap.totalBytes, tint: accentColor)
|
||||||
|
.frame(height: 8)
|
||||||
|
} else {
|
||||||
|
Text("Swap is disabled on this system.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SwapUsageBar: View {
|
||||||
|
let usedBytes: UInt64
|
||||||
|
let totalBytes: UInt64
|
||||||
|
let tint: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
let width = proxy.size.width
|
||||||
|
let progress = totalBytes > 0 ? min(max(Double(usedBytes) / Double(totalBytes), 0), 1) : 0
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Capsule()
|
||||||
|
.fill(Color.primary.opacity(0.08))
|
||||||
|
Capsule()
|
||||||
|
.fill(tint.opacity(0.8))
|
||||||
|
.frame(width: width * progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
264
DynamicIsland/components/Stats/NetworkStatsDetailView.swift
Normal file
264
DynamicIsland/components/Stats/NetworkStatsDetailView.swift
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
//
|
||||||
|
// NetworkStatsDetailView.swift
|
||||||
|
// DynamicIsland
|
||||||
|
//
|
||||||
|
// Network detail dashboard with throughput history and totals.
|
||||||
|
//
|
||||||
|
// Created by GitHub Copilot on 19/10/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct NetworkStatsDetailView: View {
|
||||||
|
@ObservedObject private var statsManager = StatsManager.shared
|
||||||
|
|
||||||
|
private let downloadColor = Color.orange
|
||||||
|
private let uploadColor = Color.red
|
||||||
|
private let cardBackground = Color(nsColor: .windowBackgroundColor).opacity(0.65)
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
StatsCard(title: "Network Overview", padding: 16, background: cardBackground, cornerRadius: 12) {
|
||||||
|
NetworkOverview(
|
||||||
|
download: statsManager.networkDownload,
|
||||||
|
upload: statsManager.networkUpload,
|
||||||
|
avgDownload: average(statsManager.networkDownloadHistory),
|
||||||
|
avgUpload: average(statsManager.networkUploadHistory),
|
||||||
|
peakDownload: statsManager.networkDownloadHistory.max() ?? 0,
|
||||||
|
peakUpload: statsManager.networkUploadHistory.max() ?? 0,
|
||||||
|
downloadColor: downloadColor,
|
||||||
|
uploadColor: uploadColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
StatsCard(title: "Interfaces", padding: 16, background: cardBackground, cornerRadius: 12) {
|
||||||
|
NetworkInterfacesCard(
|
||||||
|
interfaces: statsManager.networkInterfaces,
|
||||||
|
downloadColor: downloadColor,
|
||||||
|
uploadColor: uploadColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
StatsCard(title: "Session Totals", padding: 16, background: cardBackground, cornerRadius: 12) {
|
||||||
|
NetworkTotalsView(totals: statsManager.networkTotals, downloadColor: downloadColor, uploadColor: uploadColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 360, minHeight: 340)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func average(_ values: [Double]) -> Double {
|
||||||
|
guard !values.isEmpty else { return 0 }
|
||||||
|
let filtered = values.filter { $0 > 0 }
|
||||||
|
guard !filtered.isEmpty else { return 0 }
|
||||||
|
return filtered.reduce(0, +) / Double(filtered.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NetworkOverview: View {
|
||||||
|
let download: Double
|
||||||
|
let upload: Double
|
||||||
|
let avgDownload: Double
|
||||||
|
let avgUpload: Double
|
||||||
|
let peakDownload: Double
|
||||||
|
let peakUpload: Double
|
||||||
|
let downloadColor: Color
|
||||||
|
let uploadColor: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .center, spacing: 24) {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Download")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(downloadColor)
|
||||||
|
Text(StatsFormatting.mbPerSecond(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))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.frame(height: 110)
|
||||||
|
.overlay(Color.white.opacity(0.06))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Upload")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(uploadColor)
|
||||||
|
Text(StatsFormatting.mbPerSecond(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))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NetworkTotalsView: View {
|
||||||
|
let totals: NetworkTotals
|
||||||
|
let downloadColor: Color
|
||||||
|
let uploadColor: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
DetailRow(color: downloadColor.opacity(0.75), label: "Downloaded", value: formattedBytes(totals.downloadedMB))
|
||||||
|
DetailRow(color: uploadColor.opacity(0.75), label: "Uploaded", value: formattedBytes(totals.uploadedMB))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formattedBytes(_ megabytes: Double) -> String {
|
||||||
|
let bytes = UInt64(megabytes * 1_048_576)
|
||||||
|
return StatsFormatting.bytes(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NetworkInterfacesCard: View {
|
||||||
|
let interfaces: [NetworkInterfaceMetrics]
|
||||||
|
let downloadColor: Color
|
||||||
|
let uploadColor: Color
|
||||||
|
|
||||||
|
private var sortedInterfaces: [NetworkInterfaceMetrics] {
|
||||||
|
interfaces.sorted { lhs, rhs in
|
||||||
|
if lhs.isActive != rhs.isActive {
|
||||||
|
return lhs.isActive && !rhs.isActive
|
||||||
|
}
|
||||||
|
return interfacePriority(lhs.type) < interfacePriority(rhs.type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func interfacePriority(_ type: NetworkInterfaceType) -> Int {
|
||||||
|
switch type {
|
||||||
|
case .wifi:
|
||||||
|
return 0
|
||||||
|
case .ethernet:
|
||||||
|
return 1
|
||||||
|
case .cellular:
|
||||||
|
return 2
|
||||||
|
case .other:
|
||||||
|
return 3
|
||||||
|
case .loopback:
|
||||||
|
return 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if sortedInterfaces.isEmpty {
|
||||||
|
Text("No active interfaces detected yet.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ForEach(sortedInterfaces) { interface in
|
||||||
|
NetworkInterfaceRow(
|
||||||
|
interface: interface,
|
||||||
|
downloadColor: downloadColor,
|
||||||
|
uploadColor: uploadColor
|
||||||
|
)
|
||||||
|
if interface.id != sortedInterfaces.last?.id {
|
||||||
|
Divider().padding(.leading, 44)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NetworkInterfaceRow: View {
|
||||||
|
let interface: NetworkInterfaceMetrics
|
||||||
|
let downloadColor: Color
|
||||||
|
let uploadColor: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
icon
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(interface.displayName)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
if interface.isActive {
|
||||||
|
Text("Active")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(downloadColor)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(downloadColor.opacity(0.16))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let ipv4 = interface.ipv4, !ipv4.isEmpty {
|
||||||
|
Text("IPv4 · \(ipv4)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
if let ipv6 = interface.ipv6, !ipv6.isEmpty {
|
||||||
|
Text("IPv6 · \(ipv6)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Session · ↓ \(formattedBytes(interface.totalDownloaded)) · ↑ \(formattedBytes(interface.totalUploaded))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var icon: some View {
|
||||||
|
let isActive = interface.isActive
|
||||||
|
return RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
|
.fill(isActive ? downloadColor.opacity(0.18) : Color.primary.opacity(0.05))
|
||||||
|
.overlay(
|
||||||
|
Image(systemName: iconName)
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundColor(isActive ? downloadColor : .secondary)
|
||||||
|
)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconName: String {
|
||||||
|
switch interface.type {
|
||||||
|
case .wifi:
|
||||||
|
return "wifi"
|
||||||
|
case .ethernet:
|
||||||
|
return "cable.connector"
|
||||||
|
case .cellular:
|
||||||
|
return "antenna.radiowaves.left.and.right"
|
||||||
|
case .loopback:
|
||||||
|
return "arrow.triangle.2.circlepath"
|
||||||
|
case .other:
|
||||||
|
return "network"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func metricRow(label: String, value: String, color: Color) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(value)
|
||||||
|
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundColor(color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formattedBytes(_ megabytes: Double) -> String {
|
||||||
|
let bytes = UInt64(max(megabytes, 0) * 1_048_576)
|
||||||
|
return StatsFormatting.bytes(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -34,12 +34,16 @@ enum ProcessRankingType {
|
||||||
case cpu
|
case cpu
|
||||||
case memory
|
case memory
|
||||||
case gpu
|
case gpu
|
||||||
|
case network
|
||||||
|
case disk
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .cpu: return "CPU Usage"
|
case .cpu: return "CPU Usage"
|
||||||
case .memory: return "Memory Usage"
|
case .memory: return "Memory Usage"
|
||||||
case .gpu: return "GPU Usage"
|
case .gpu: return "GPU Usage"
|
||||||
|
case .network: return "Network Activity"
|
||||||
|
case .disk: return "Disk Activity"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,6 +52,8 @@ enum ProcessRankingType {
|
||||||
case .cpu: return "cpu"
|
case .cpu: return "cpu"
|
||||||
case .memory: return "memorychip"
|
case .memory: return "memorychip"
|
||||||
case .gpu: return "display"
|
case .gpu: return "display"
|
||||||
|
case .network: return "network"
|
||||||
|
case .disk: return "internaldrive"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,6 +62,8 @@ enum ProcessRankingType {
|
||||||
case .cpu: return .blue
|
case .cpu: return .blue
|
||||||
case .memory: return .green
|
case .memory: return .green
|
||||||
case .gpu: return .purple
|
case .gpu: return .purple
|
||||||
|
case .network: return .orange
|
||||||
|
case .disk: return .cyan
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -9,188 +9,59 @@ import SwiftUI
|
||||||
|
|
||||||
struct RankedProcessPopover: View {
|
struct RankedProcessPopover: View {
|
||||||
let rankingType: ProcessRankingType
|
let rankingType: ProcessRankingType
|
||||||
@ObservedObject var statsManager = StatsManager.shared
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var isHovering = false
|
|
||||||
|
|
||||||
// Callback to notify parent about hover state
|
// Callback to notify parent about hover state
|
||||||
var onHoverChange: ((Bool) -> Void)?
|
var onHoverChange: ((Bool) -> Void)?
|
||||||
|
|
||||||
private var rankedProcesses: [ProcessStats] {
|
private var configuration: (width: CGFloat, minHeight: CGFloat, padding: CGFloat) {
|
||||||
switch rankingType {
|
switch rankingType {
|
||||||
case .cpu:
|
case .cpu:
|
||||||
return statsManager.getProcessesRankedByCPU()
|
return (420, 420, 0)
|
||||||
case .memory:
|
case .memory:
|
||||||
return statsManager.getProcessesRankedByMemory()
|
return (400, 380, 0)
|
||||||
case .gpu:
|
case .gpu:
|
||||||
return statsManager.getProcessesRankedByGPU()
|
return (380, 360, 0)
|
||||||
|
case .network, .disk:
|
||||||
|
return (360, 320, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
popoverContent
|
||||||
// Header
|
.padding(configuration.padding)
|
||||||
HStack {
|
.frame(width: configuration.width)
|
||||||
Image(systemName: rankingType.icon)
|
.frame(minHeight: configuration.minHeight)
|
||||||
.font(.title3)
|
.background(Color(NSColor.controlBackgroundColor))
|
||||||
.foregroundColor(rankingType.color)
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
.shadow(color: .black.opacity(0.28), radius: 12, x: 0, y: 8)
|
||||||
Text("Top Processes by \(rankingType.title)")
|
.overlay(alignment: .topTrailing) {
|
||||||
.font(.headline)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: { dismiss() }) {
|
Button(action: { dismiss() }) {
|
||||||
Image(systemName: "xmark.circle.fill")
|
Image(systemName: "xmark.circle.fill")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
.padding(10)
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
}
|
}
|
||||||
.padding(.bottom, 4)
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
// Process list
|
|
||||||
ScrollView {
|
|
||||||
LazyVStack(spacing: 6) {
|
|
||||||
ForEach(Array(rankedProcesses.prefix(15).enumerated()), id: \.element.id) { index, process in
|
|
||||||
RankedProcessRow(
|
|
||||||
process: process,
|
|
||||||
rank: index + 1,
|
|
||||||
rankingType: rankingType
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
|
||||||
.frame(maxHeight: 300)
|
|
||||||
|
|
||||||
// Footer with refresh button
|
|
||||||
HStack {
|
|
||||||
Text("\(rankedProcesses.count) processes")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button("Refresh") {
|
|
||||||
// Trigger stats update
|
|
||||||
if statsManager.isMonitoring {
|
|
||||||
// Stats will auto-refresh
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(rankingType.color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.frame(width: 320)
|
|
||||||
.frame(minHeight: 200)
|
|
||||||
.background(Color(NSColor.controlBackgroundColor))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
.shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 4)
|
|
||||||
.onHover { hovering in
|
.onHover { hovering in
|
||||||
isHovering = hovering
|
|
||||||
onHoverChange?(hovering)
|
onHoverChange?(hovering)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
struct RankedProcessRow: View {
|
@ViewBuilder
|
||||||
let process: ProcessStats
|
private var popoverContent: some View {
|
||||||
let rank: Int
|
|
||||||
let rankingType: ProcessRankingType
|
|
||||||
@State private var isHovered = false
|
|
||||||
|
|
||||||
private var primaryValue: String {
|
|
||||||
switch rankingType {
|
switch rankingType {
|
||||||
case .cpu:
|
case .cpu:
|
||||||
return process.cpuUsageString
|
CPUStatsDetailView()
|
||||||
case .memory:
|
case .memory:
|
||||||
return process.memoryUsageString
|
MemoryStatsDetailView()
|
||||||
case .gpu:
|
case .gpu:
|
||||||
return process.cpuUsageString // Using CPU as proxy for GPU
|
GPUStatsDetailView()
|
||||||
}
|
case .network:
|
||||||
}
|
NetworkStatsDetailView()
|
||||||
|
case .disk:
|
||||||
private var secondaryValue: String {
|
DiskStatsDetailView()
|
||||||
switch rankingType {
|
|
||||||
case .cpu:
|
|
||||||
return process.memoryUsageString
|
|
||||||
case .memory:
|
|
||||||
return process.cpuUsageString
|
|
||||||
case .gpu:
|
|
||||||
return process.memoryUsageString
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
// Rank
|
|
||||||
Text("\(rank)")
|
|
||||||
.font(.caption)
|
|
||||||
.fontWeight(.bold)
|
|
||||||
.foregroundColor(rankingType.color)
|
|
||||||
.frame(width: 24, alignment: .center)
|
|
||||||
|
|
||||||
// App icon
|
|
||||||
Group {
|
|
||||||
if let icon = process.icon {
|
|
||||||
Image(nsImage: icon)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fit)
|
|
||||||
} else {
|
|
||||||
Image(systemName: "app")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: 20, height: 20)
|
|
||||||
|
|
||||||
// Process info
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(process.name)
|
|
||||||
.font(.system(size: 12, weight: .medium))
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
.lineLimit(1)
|
|
||||||
.truncationMode(.tail)
|
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Text(primaryValue)
|
|
||||||
.font(.caption)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.foregroundColor(rankingType.color)
|
|
||||||
|
|
||||||
Text(secondaryValue)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// PID
|
|
||||||
Text("PID: \(process.pid)")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.opacity(0.7)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 6)
|
|
||||||
.fill(isHovered ? Color(NSColor.selectedControlColor).opacity(0.2) : Color.clear)
|
|
||||||
)
|
|
||||||
.onHover { hovering in
|
|
||||||
withAnimation(.easeInOut(duration: 0.15)) {
|
|
||||||
isHovered = hovering
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
RankedProcessPopover(rankingType: .cpu)
|
|
||||||
.background(Color.black)
|
|
||||||
}
|
|
||||||
48
DynamicIsland/components/Stats/StatsFormatting.swift
Normal file
48
DynamicIsland/components/Stats/StatsFormatting.swift
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
//
|
||||||
|
// StatsFormatting.swift
|
||||||
|
// DynamicIsland
|
||||||
|
//
|
||||||
|
// Lightweight formatters shared across stats detail popovers.
|
||||||
|
//
|
||||||
|
// Created by GitHub Copilot on 19/10/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum StatsFormatting {
|
||||||
|
static func bytes(_ value: UInt64) -> String {
|
||||||
|
let formatter = ByteCountFormatter()
|
||||||
|
formatter.allowedUnits = [.useMB, .useGB]
|
||||||
|
formatter.countStyle = .memory
|
||||||
|
formatter.includesUnit = true
|
||||||
|
return formatter.string(fromByteCount: Int64(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func mbPerSecond(_ value: Double) -> String {
|
||||||
|
let formatter = NumberFormatter()
|
||||||
|
formatter.numberStyle = .decimal
|
||||||
|
formatter.maximumFractionDigits = value >= 10 ? 1 : 2
|
||||||
|
let number = NSNumber(value: value)
|
||||||
|
let formatted = formatter.string(from: number) ?? String(format: "%.2f", value)
|
||||||
|
return "\(formatted) MB/s"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func percentage(_ value: Double) -> String {
|
||||||
|
String(format: "%.1f%%", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func gigabytes(_ value: Double) -> String {
|
||||||
|
let formatter = NumberFormatter()
|
||||||
|
formatter.numberStyle = .decimal
|
||||||
|
formatter.maximumFractionDigits = value >= 10 ? 1 : 2
|
||||||
|
return (formatter.string(from: NSNumber(value: value)) ?? String(format: "%.2f", value)) + " GB"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func abbreviatedDuration(_ value: TimeInterval) -> String {
|
||||||
|
let formatter = DateComponentsFormatter()
|
||||||
|
formatter.allowedUnits = [.day, .hour, .minute]
|
||||||
|
formatter.maximumUnitCount = 2
|
||||||
|
formatter.unitsStyle = .abbreviated
|
||||||
|
return formatter.string(from: value) ?? "—"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -67,65 +67,10 @@ struct StatsPanelView: View {
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
} else {
|
} else {
|
||||||
// Stats content - Compact two-row layout
|
// Stats content - Compact two-row layout
|
||||||
VStack(spacing: 16) {
|
CPUStatsDetailView()
|
||||||
// Top row: System overview cards
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
CompactOverviewCard(
|
|
||||||
title: "CPU",
|
|
||||||
value: statsManager.cpuUsageString,
|
|
||||||
color: .blue,
|
|
||||||
icon: "cpu",
|
|
||||||
data: statsManager.cpuHistory
|
|
||||||
)
|
|
||||||
|
|
||||||
CompactOverviewCard(
|
|
||||||
title: "Memory",
|
|
||||||
value: statsManager.memoryUsageString,
|
|
||||||
color: .green,
|
|
||||||
icon: "memorychip",
|
|
||||||
data: statsManager.memoryHistory
|
|
||||||
)
|
|
||||||
|
|
||||||
CompactOverviewCard(
|
|
||||||
title: "GPU",
|
|
||||||
value: statsManager.gpuUsageString,
|
|
||||||
color: .purple,
|
|
||||||
icon: "display",
|
|
||||||
data: statsManager.gpuHistory
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bottom row: Network and Disk graphs
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
CompactDualGraph(
|
|
||||||
title: "Network",
|
|
||||||
primaryLabel: "Down",
|
|
||||||
primaryValue: "\(String(format: "%.1f", statsManager.networkDownload / 1024 / 1024)) MB/s",
|
|
||||||
primaryData: statsManager.networkDownloadHistory,
|
|
||||||
primaryColor: .green,
|
|
||||||
secondaryLabel: "Up",
|
|
||||||
secondaryValue: "\(String(format: "%.1f", statsManager.networkUpload / 1024 / 1024)) MB/s",
|
|
||||||
secondaryData: statsManager.networkUploadHistory,
|
|
||||||
secondaryColor: .orange
|
|
||||||
)
|
|
||||||
|
|
||||||
CompactDualGraph(
|
|
||||||
title: "Disk",
|
|
||||||
primaryLabel: "Read",
|
|
||||||
primaryValue: "\(String(format: "%.1f", statsManager.diskRead / 1024 / 1024)) MB/s",
|
|
||||||
primaryData: statsManager.diskReadHistory,
|
|
||||||
primaryColor: .cyan,
|
|
||||||
secondaryLabel: "Write",
|
|
||||||
secondaryValue: "\(String(format: "%.1f", statsManager.diskWrite / 1024 / 1024)) MB/s",
|
|
||||||
secondaryData: statsManager.diskWriteHistory,
|
|
||||||
secondaryColor: .pink
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.frame(width: 520, height: 620)
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: 650, height: 300)
|
|
||||||
.background(VisualEffectView(material: .hudWindow, blendingMode: .behindWindow))
|
.background(VisualEffectView(material: .hudWindow, blendingMode: .behindWindow))
|
||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
.shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 10)
|
.shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 10)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
39
DynamicIsland/models/CPUMetrics.swift
Normal file
39
DynamicIsland/models/CPUMetrics.swift
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
//
|
||||||
|
// CPUMetrics.swift
|
||||||
|
// DynamicIsland
|
||||||
|
//
|
||||||
|
// Lightweight CPU metric models derived from StatsManager sampling.
|
||||||
|
//
|
||||||
|
// Created by GitHub Copilot on 18/10/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct CPULoadBreakdown: Equatable {
|
||||||
|
var user: Double
|
||||||
|
var system: Double
|
||||||
|
var idle: Double
|
||||||
|
|
||||||
|
static let zero = CPULoadBreakdown(user: 0, system: 0, idle: 100)
|
||||||
|
|
||||||
|
var activeUsage: Double {
|
||||||
|
let active = user + system
|
||||||
|
return min(max(active, 0), 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedSegments: (user: Double, system: Double, idle: Double) {
|
||||||
|
let total = max(user + system + idle, 0.001)
|
||||||
|
let userFraction = min(max(user / total, 0), 1)
|
||||||
|
let systemFraction = min(max(system / total, 0), 1)
|
||||||
|
let idleFraction = min(max(idle / total, 0), 1)
|
||||||
|
return (userFraction, systemFraction, idleFraction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoadAverage: Equatable {
|
||||||
|
var oneMinute: Double
|
||||||
|
var fiveMinutes: Double
|
||||||
|
var fifteenMinutes: Double
|
||||||
|
|
||||||
|
static let zero = LoadAverage(oneMinute: 0, fiveMinutes: 0, fifteenMinutes: 0)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue