mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
commit
e75650b146
9 changed files with 1086 additions and 77 deletions
|
|
@ -21,6 +21,35 @@ struct ContentView: View {
|
||||||
@ObservedObject var musicManager = MusicManager.shared
|
@ObservedObject var musicManager = MusicManager.shared
|
||||||
@ObservedObject var timerManager = TimerManager.shared
|
@ObservedObject var timerManager = TimerManager.shared
|
||||||
@ObservedObject var batteryModel = BatteryStatusViewModel.shared
|
@ObservedObject var batteryModel = BatteryStatusViewModel.shared
|
||||||
|
@ObservedObject var statsManager = StatsManager.shared
|
||||||
|
|
||||||
|
@Default(.enableStatsFeature) var enableStatsFeature
|
||||||
|
@Default(.showCpuGraph) var showCpuGraph
|
||||||
|
@Default(.showMemoryGraph) var showMemoryGraph
|
||||||
|
@Default(.showGpuGraph) var showGpuGraph
|
||||||
|
@Default(.showNetworkGraph) var showNetworkGraph
|
||||||
|
@Default(.showDiskGraph) var showDiskGraph
|
||||||
|
|
||||||
|
var dynamicNotchSize: CGSize {
|
||||||
|
// Only apply dynamic sizing when on stats tab and stats are enabled
|
||||||
|
guard coordinator.currentView == .stats && enableStatsFeature else {
|
||||||
|
return openNotchSize
|
||||||
|
}
|
||||||
|
|
||||||
|
let enabledGraphsCount = [showCpuGraph, showMemoryGraph, showGpuGraph, showNetworkGraph, showDiskGraph].filter { $0 }.count
|
||||||
|
|
||||||
|
// Calculate height based on layout: 1-3 graphs = single row, 4+ graphs = two rows
|
||||||
|
var requiredHeight = openNotchSize.height
|
||||||
|
|
||||||
|
if enabledGraphsCount >= 4 {
|
||||||
|
// Two rows needed - add height for second row plus spacing
|
||||||
|
let extraHeight: CGFloat = 120 + 12 // Graph height + spacing
|
||||||
|
requiredHeight = openNotchSize.height + extraHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
// Width stays constant - no horizontal expansion
|
||||||
|
return CGSize(width: openNotchSize.width, height: requiredHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@State private var isHovering: Bool = false
|
@State private var isHovering: Bool = false
|
||||||
|
|
@ -173,7 +202,7 @@ struct ContentView: View {
|
||||||
// .keyboardShortcut("E", modifiers: .command)
|
// .keyboardShortcut("E", modifiers: .command)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: openNotchSize.width, maxHeight: openNotchSize.height, alignment: .top)
|
.frame(maxWidth: dynamicNotchSize.width, maxHeight: dynamicNotchSize.height, alignment: .top)
|
||||||
.shadow(color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow]) ? .black.opacity(0.6) : .clear, radius: Defaults[.cornerRadiusScaling] ? 10 : 5)
|
.shadow(color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow]) ? .black.opacity(0.6) : .clear, radius: Defaults[.cornerRadiusScaling] ? 10 : 5)
|
||||||
.background(dragDetector)
|
.background(dragDetector)
|
||||||
.environmentObject(vm)
|
.environmentObject(vm)
|
||||||
|
|
|
||||||
|
|
@ -85,12 +85,32 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
var closeNotchWorkItem: DispatchWorkItem?
|
var closeNotchWorkItem: DispatchWorkItem?
|
||||||
private var previousScreens: [NSScreen]?
|
private var previousScreens: [NSScreen]?
|
||||||
private var onboardingWindowController: NSWindowController?
|
private var onboardingWindowController: NSWindowController?
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
// Debouncing mechanism for window size updates
|
||||||
|
private var windowSizeUpdateWorkItem: DispatchWorkItem?
|
||||||
|
|
||||||
|
private func debouncedUpdateWindowSize() {
|
||||||
|
// Cancel any existing work item
|
||||||
|
windowSizeUpdateWorkItem?.cancel()
|
||||||
|
|
||||||
|
// Create new work item with delay
|
||||||
|
let workItem = DispatchWorkItem { [weak self] in
|
||||||
|
self?.updateWindowSizeIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store reference and schedule
|
||||||
|
windowSizeUpdateWorkItem = workItem
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: workItem)
|
||||||
|
}
|
||||||
|
|
||||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ notification: Notification) {
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
|
// Cancel any pending window size updates
|
||||||
|
windowSizeUpdateWorkItem?.cancel()
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,9 +145,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
private func createDynamicIslandWindow(for screen: NSScreen, with viewModel: DynamicIslandViewModel)
|
private func createDynamicIslandWindow(for screen: NSScreen, with viewModel: DynamicIslandViewModel)
|
||||||
-> NSWindow
|
-> NSWindow
|
||||||
{
|
{
|
||||||
|
// Use the current required size instead of always using openNotchSize
|
||||||
|
let requiredSize = calculateRequiredNotchSize()
|
||||||
|
|
||||||
let window = DynamicIslandWindow(
|
let window = DynamicIslandWindow(
|
||||||
contentRect: NSRect(
|
contentRect: NSRect(
|
||||||
x: 0, y: 0, width: openNotchSize.width, height: openNotchSize.height),
|
x: 0, y: 0, width: requiredSize.width, height: requiredSize.height),
|
||||||
styleMask: [.borderless, .nonactivatingPanel, .utilityWindow, .hudWindow],
|
styleMask: [.borderless, .nonactivatingPanel, .utilityWindow, .hudWindow],
|
||||||
backing: .buffered,
|
backing: .buffered,
|
||||||
defer: false
|
defer: false
|
||||||
|
|
@ -150,22 +173,153 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
window.alphaValue = 0
|
window.alphaValue = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.main.async { [weak window] in
|
// Use the same centering logic as updateWindowSizeIfNeeded()
|
||||||
guard let window = window else { return }
|
|
||||||
let screenFrame = screen.frame
|
let screenFrame = screen.frame
|
||||||
window.setFrameOrigin(
|
let centerX = screenFrame.origin.x + (screenFrame.width / 2)
|
||||||
NSPoint(
|
let newX = centerX - (window.frame.width / 2)
|
||||||
x: screenFrame.origin.x + (screenFrame.width / 2) - window.frame.width / 2,
|
let newY = screenFrame.origin.y + screenFrame.height - window.frame.height
|
||||||
y: screenFrame.origin.y + screenFrame.height - window.frame.height
|
|
||||||
))
|
window.setFrame(NSRect(
|
||||||
|
x: newX,
|
||||||
|
y: newY,
|
||||||
|
width: window.frame.width,
|
||||||
|
height: window.frame.height
|
||||||
|
), display: false)
|
||||||
|
|
||||||
|
if changeAlpha {
|
||||||
window.alphaValue = 1
|
window.alphaValue = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func updateWindowSizeIfNeeded() {
|
||||||
|
// Calculate required size based on current state
|
||||||
|
let requiredSize = calculateRequiredNotchSize()
|
||||||
|
|
||||||
|
if Defaults[.showOnAllDisplays] {
|
||||||
|
// Update all windows if size has changed (multi-display mode)
|
||||||
|
for (screen, window) in windows {
|
||||||
|
if window.frame.size != requiredSize {
|
||||||
|
// Calculate center position BEFORE any changes
|
||||||
|
let screenFrame = screen.frame
|
||||||
|
let centerX = screenFrame.origin.x + (screenFrame.width / 2)
|
||||||
|
let newX = centerX - (requiredSize.width / 2)
|
||||||
|
let newY = screenFrame.origin.y + screenFrame.height - requiredSize.height
|
||||||
|
|
||||||
|
// Stop any existing animations first
|
||||||
|
NSAnimationContext.runAnimationGroup { _ in
|
||||||
|
window.animator().setFrame(window.frame, display: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate window resize smoothly
|
||||||
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
context.duration = 0.25
|
||||||
|
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||||
|
context.allowsImplicitAnimation = true
|
||||||
|
window.animator().setFrame(NSRect(
|
||||||
|
x: newX,
|
||||||
|
y: newY,
|
||||||
|
width: requiredSize.width,
|
||||||
|
height: requiredSize.height
|
||||||
|
), display: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Update single window if size has changed (single display mode)
|
||||||
|
if let window = window, window.frame.size != requiredSize {
|
||||||
|
// Find the screen this window is on
|
||||||
|
let currentScreen = NSScreen.screens.first { screen in
|
||||||
|
screen.frame.intersects(window.frame)
|
||||||
|
} ?? NSScreen.main ?? NSScreen.screens.first!
|
||||||
|
|
||||||
|
// Calculate center position BEFORE any changes
|
||||||
|
let screenFrame = currentScreen.frame
|
||||||
|
let centerX = screenFrame.origin.x + (screenFrame.width / 2)
|
||||||
|
let newX = centerX - (requiredSize.width / 2)
|
||||||
|
let newY = screenFrame.origin.y + screenFrame.height - requiredSize.height
|
||||||
|
|
||||||
|
// Stop any existing animations first
|
||||||
|
NSAnimationContext.runAnimationGroup { _ in
|
||||||
|
window.animator().setFrame(window.frame, display: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate window resize smoothly
|
||||||
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
context.duration = 0.25
|
||||||
|
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||||
|
context.allowsImplicitAnimation = true
|
||||||
|
window.animator().setFrame(NSRect(
|
||||||
|
x: newX,
|
||||||
|
y: newY,
|
||||||
|
width: requiredSize.width,
|
||||||
|
height: requiredSize.height
|
||||||
|
), display: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateRequiredNotchSize() -> CGSize {
|
||||||
|
// Only apply dynamic sizing when on stats tab and stats are enabled
|
||||||
|
guard coordinator.currentView == .stats && Defaults[.enableStatsFeature] else {
|
||||||
|
return openNotchSize
|
||||||
|
}
|
||||||
|
|
||||||
|
let enabledGraphsCount = [
|
||||||
|
Defaults[.showCpuGraph],
|
||||||
|
Defaults[.showMemoryGraph],
|
||||||
|
Defaults[.showGpuGraph],
|
||||||
|
Defaults[.showNetworkGraph],
|
||||||
|
Defaults[.showDiskGraph]
|
||||||
|
].filter { $0 }.count
|
||||||
|
|
||||||
|
// Calculate height based on layout: 1-3 graphs = single row, 4+ graphs = two rows
|
||||||
|
var requiredHeight = openNotchSize.height
|
||||||
|
|
||||||
|
if enabledGraphsCount >= 4 {
|
||||||
|
// Two rows needed - add height for second row plus spacing
|
||||||
|
let extraHeight: CGFloat = 120 + 12 // Graph height + spacing
|
||||||
|
requiredHeight = openNotchSize.height + extraHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
// Width stays constant - no horizontal expansion
|
||||||
|
return CGSize(width: openNotchSize.width, height: requiredHeight)
|
||||||
|
}
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
|
||||||
coordinator.setupWorkersNotificationObservers()
|
coordinator.setupWorkersNotificationObservers()
|
||||||
|
|
||||||
|
// Observe tab changes - use debounced updates
|
||||||
|
coordinator.$currentView.sink { [weak self] newView in
|
||||||
|
self?.debouncedUpdateWindowSize()
|
||||||
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
|
// Observe stats settings changes - use debounced updates
|
||||||
|
Defaults.publisher(.enableStatsFeature, options: []).sink { [weak self] _ in
|
||||||
|
self?.debouncedUpdateWindowSize()
|
||||||
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
|
Defaults.publisher(.showCpuGraph, options: []).sink { [weak self] _ in
|
||||||
|
self?.debouncedUpdateWindowSize()
|
||||||
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
|
Defaults.publisher(.showMemoryGraph, options: []).sink { [weak self] _ in
|
||||||
|
self?.debouncedUpdateWindowSize()
|
||||||
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
|
Defaults.publisher(.showGpuGraph, options: []).sink { [weak self] _ in
|
||||||
|
self?.debouncedUpdateWindowSize()
|
||||||
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
|
Defaults.publisher(.showNetworkGraph, options: []).sink { [weak self] _ in
|
||||||
|
self?.debouncedUpdateWindowSize()
|
||||||
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
|
Defaults.publisher(.showDiskGraph, options: []).sink { [weak self] _ in
|
||||||
|
self?.debouncedUpdateWindowSize()
|
||||||
|
}.store(in: &cancellables)
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
selector: #selector(screenConfigurationDidChange),
|
selector: #selector(screenConfigurationDidChange),
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,9 @@
|
||||||
},
|
},
|
||||||
"%lld%%" : {
|
"%lld%%" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"•" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"• Bug fixes" : {
|
"• Bug fixes" : {
|
||||||
|
|
||||||
|
|
@ -367,6 +370,12 @@
|
||||||
},
|
},
|
||||||
"Disabled" : {
|
"Disabled" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Disk Read" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Disk Write" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Download" : {
|
"Download" : {
|
||||||
|
|
||||||
|
|
@ -412,6 +421,9 @@
|
||||||
},
|
},
|
||||||
"dynamic.island" : {
|
"dynamic.island" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Each graph can be individually enabled or disabled. Network activity shows download/upload speeds, and disk I/O shows read/write speeds." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Easily copy, store, and manage your most-used content. Upgrade now for advanced features like multi-item storage and quick access!" : {
|
"Easily copy, store, and manage your most-used content. Upgrade now for advanced features like multi-item storage and quick access!" : {
|
||||||
|
|
||||||
|
|
@ -432,6 +444,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Enable graph visibility in Settings → Stats to view performance data." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Enable haptics" : {
|
"Enable haptics" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
|
|
@ -530,6 +545,9 @@
|
||||||
},
|
},
|
||||||
"Gradient" : {
|
"Gradient" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Graph Visibility" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Hide DynamicIsland Options" : {
|
"Hide DynamicIsland Options" : {
|
||||||
|
|
||||||
|
|
@ -586,6 +604,9 @@
|
||||||
},
|
},
|
||||||
"Live" : {
|
"Live" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Live Performance Data" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Lottie JSON URL" : {
|
"Lottie JSON URL" : {
|
||||||
|
|
||||||
|
|
@ -706,6 +727,12 @@
|
||||||
},
|
},
|
||||||
"Need Restart" : {
|
"Need Restart" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Network Download" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Network Upload" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Never hide" : {
|
"Never hide" : {
|
||||||
|
|
||||||
|
|
@ -738,6 +765,9 @@
|
||||||
},
|
},
|
||||||
"No extension installed" : {
|
"No extension installed" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"No Graphs Enabled" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Non-notch display height" : {
|
"Non-notch display height" : {
|
||||||
|
|
||||||
|
|
@ -1032,9 +1062,6 @@
|
||||||
},
|
},
|
||||||
"System features" : {
|
"System features" : {
|
||||||
|
|
||||||
},
|
|
||||||
"System Performance" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"The clipboard icon appears in the header next to the settings gear when enabled. Use the global shortcut Cmd+Shift+V or click the icon to access history." : {
|
"The clipboard icon appears in the header next to the settings gear when enabled. Use the global shortcut Cmd+Shift+V or click the icon to access history." : {
|
||||||
|
|
||||||
|
|
@ -1119,7 +1146,10 @@
|
||||||
"What's New" : {
|
"What's New" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"When enabled, the Stats tab will display real-time CPU, Memory, and GPU usage graphs. This feature requires system permissions and may use additional battery." : {
|
"When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"With %lld graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"You can now enjoy the app. If you want to tweak things further, you can always visit the settings." : {
|
"You can now enjoy the app. If you want to tweak things further, you can always visit the settings." : {
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,253 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Defaults
|
import Defaults
|
||||||
|
|
||||||
|
// Graph data protocol for unified interface
|
||||||
|
protocol GraphData {
|
||||||
|
var title: String { get }
|
||||||
|
var color: Color { get }
|
||||||
|
var icon: String { get }
|
||||||
|
var type: GraphType { get }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum GraphType {
|
||||||
|
case single
|
||||||
|
case dual
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single value graph data
|
||||||
|
struct SingleGraphData: GraphData {
|
||||||
|
let title: String
|
||||||
|
let value: String
|
||||||
|
let data: [Double]
|
||||||
|
let color: Color
|
||||||
|
let icon: String
|
||||||
|
let type: GraphType = .single
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dual value graph data (for network/disk)
|
||||||
|
struct DualGraphData: GraphData {
|
||||||
|
let title: String
|
||||||
|
let positiveValue: String
|
||||||
|
let negativeValue: String
|
||||||
|
let positiveData: [Double]
|
||||||
|
let negativeData: [Double]
|
||||||
|
let positiveColor: Color
|
||||||
|
let negativeColor: Color
|
||||||
|
let color: Color // Primary color for the component
|
||||||
|
let icon: String
|
||||||
|
let type: GraphType = .dual
|
||||||
|
}
|
||||||
|
|
||||||
struct NotchStatsView: View {
|
struct NotchStatsView: View {
|
||||||
@ObservedObject var statsManager = StatsManager.shared
|
@ObservedObject var statsManager = StatsManager.shared
|
||||||
@Default(.enableStatsFeature) var enableStatsFeature
|
@Default(.enableStatsFeature) var enableStatsFeature
|
||||||
|
@Default(.showCpuGraph) var showCpuGraph
|
||||||
|
@Default(.showMemoryGraph) var showMemoryGraph
|
||||||
|
@Default(.showGpuGraph) var showGpuGraph
|
||||||
|
@Default(.showNetworkGraph) var showNetworkGraph
|
||||||
|
@Default(.showDiskGraph) var showDiskGraph
|
||||||
|
|
||||||
|
var availableGraphs: [GraphData] {
|
||||||
|
var graphs: [GraphData] = []
|
||||||
|
|
||||||
|
if showCpuGraph {
|
||||||
|
graphs.append(SingleGraphData(
|
||||||
|
title: "CPU",
|
||||||
|
value: statsManager.cpuUsageString,
|
||||||
|
data: statsManager.cpuHistory,
|
||||||
|
color: .blue,
|
||||||
|
icon: "cpu"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if showMemoryGraph {
|
||||||
|
graphs.append(SingleGraphData(
|
||||||
|
title: "Memory",
|
||||||
|
value: statsManager.memoryUsageString,
|
||||||
|
data: statsManager.memoryHistory,
|
||||||
|
color: .green,
|
||||||
|
icon: "memorychip"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if showGpuGraph {
|
||||||
|
graphs.append(SingleGraphData(
|
||||||
|
title: "GPU",
|
||||||
|
value: statsManager.gpuUsageString,
|
||||||
|
data: statsManager.gpuHistory,
|
||||||
|
color: .purple,
|
||||||
|
icon: "display"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if showNetworkGraph {
|
||||||
|
graphs.append(DualGraphData(
|
||||||
|
title: "Network",
|
||||||
|
positiveValue: String(format: "↓%.1f MB/s", statsManager.networkDownload),
|
||||||
|
negativeValue: String(format: "↑%.1f MB/s", statsManager.networkUpload),
|
||||||
|
positiveData: statsManager.networkDownloadHistory,
|
||||||
|
negativeData: statsManager.networkUploadHistory,
|
||||||
|
positiveColor: .orange,
|
||||||
|
negativeColor: .red,
|
||||||
|
color: .orange,
|
||||||
|
icon: "network"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if showDiskGraph {
|
||||||
|
graphs.append(DualGraphData(
|
||||||
|
title: "Disk",
|
||||||
|
positiveValue: String(format: "R %.1f MB/s", statsManager.diskRead),
|
||||||
|
negativeValue: String(format: "W %.1f MB/s", statsManager.diskWrite),
|
||||||
|
positiveData: statsManager.diskReadHistory,
|
||||||
|
negativeData: statsManager.diskWriteHistory,
|
||||||
|
positiveColor: .cyan,
|
||||||
|
negativeColor: .yellow,
|
||||||
|
color: .cyan,
|
||||||
|
icon: "internaldrive"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return graphs
|
||||||
|
}
|
||||||
|
|
||||||
|
// New vertical expansion layout system
|
||||||
|
@ViewBuilder
|
||||||
|
var statsGridLayout: some View {
|
||||||
|
let graphCount = availableGraphs.count
|
||||||
|
|
||||||
|
switch graphCount {
|
||||||
|
case 1...3:
|
||||||
|
// Single row for 1-3 graphs
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(0..<graphCount, id: \.self) { index in
|
||||||
|
graphView(for: availableGraphs[index])
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .scale.combined(with: .opacity),
|
||||||
|
removal: .scale.combined(with: .opacity)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.25), value: graphCount)
|
||||||
|
|
||||||
|
case 4:
|
||||||
|
// 2x2 quadrants for 4 graphs
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
graphView(for: availableGraphs[0])
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .scale.combined(with: .opacity),
|
||||||
|
removal: .scale.combined(with: .opacity)
|
||||||
|
))
|
||||||
|
graphView(for: availableGraphs[1])
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .scale.combined(with: .opacity),
|
||||||
|
removal: .scale.combined(with: .opacity)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
graphView(for: availableGraphs[2])
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .scale.combined(with: .opacity),
|
||||||
|
removal: .scale.combined(with: .opacity)
|
||||||
|
))
|
||||||
|
graphView(for: availableGraphs[3])
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .scale.combined(with: .opacity),
|
||||||
|
removal: .scale.combined(with: .opacity)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.25), value: graphCount)
|
||||||
|
|
||||||
|
case 5:
|
||||||
|
// 3 on top, 2 on bottom (taking half space each)
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
// Top row: 3 graphs
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
graphView(for: availableGraphs[0])
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .scale.combined(with: .opacity),
|
||||||
|
removal: .scale.combined(with: .opacity)
|
||||||
|
))
|
||||||
|
graphView(for: availableGraphs[1])
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .scale.combined(with: .opacity),
|
||||||
|
removal: .scale.combined(with: .opacity)
|
||||||
|
))
|
||||||
|
graphView(for: availableGraphs[2])
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .scale.combined(with: .opacity),
|
||||||
|
removal: .scale.combined(with: .opacity)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
// Bottom row: 2 graphs centered
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Spacer()
|
||||||
|
graphView(for: availableGraphs[3])
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .scale.combined(with: .opacity),
|
||||||
|
removal: .scale.combined(with: .opacity)
|
||||||
|
))
|
||||||
|
graphView(for: availableGraphs[4])
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .scale.combined(with: .opacity),
|
||||||
|
removal: .scale.combined(with: .opacity)
|
||||||
|
))
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.25), value: graphCount)
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Fallback for more than 5 graphs (shouldn't happen with current settings)
|
||||||
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: min(3, graphCount)), spacing: 12) {
|
||||||
|
ForEach(0..<graphCount, id: \.self) { index in
|
||||||
|
graphView(for: availableGraphs[index])
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .scale.combined(with: .opacity),
|
||||||
|
removal: .scale.combined(with: .opacity)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.25), value: graphCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to create graph views
|
||||||
|
@ViewBuilder
|
||||||
|
func graphView(for graphData: GraphData) -> some View {
|
||||||
|
if let singleData = graphData as? SingleGraphData {
|
||||||
|
StatCard(
|
||||||
|
title: singleData.title,
|
||||||
|
value: singleData.value,
|
||||||
|
data: singleData.data,
|
||||||
|
color: singleData.color,
|
||||||
|
icon: singleData.icon
|
||||||
|
)
|
||||||
|
} else if let dualData = graphData as? DualGraphData {
|
||||||
|
DualStatCard(
|
||||||
|
title: dualData.title,
|
||||||
|
positiveValue: dualData.positiveValue,
|
||||||
|
negativeValue: dualData.negativeValue,
|
||||||
|
positiveData: dualData.positiveData,
|
||||||
|
negativeData: dualData.negativeData,
|
||||||
|
positiveColor: dualData.positiveColor,
|
||||||
|
negativeColor: dualData.negativeColor,
|
||||||
|
icon: dualData.icon
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|
@ -33,42 +277,38 @@ struct NotchStatsView: View {
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.padding()
|
.padding()
|
||||||
} else {
|
} else if availableGraphs.isEmpty {
|
||||||
// Stats content - use full width and height
|
// No graphs enabled state
|
||||||
ZStack(alignment: .topTrailing) {
|
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
// Stats Grid - use more space
|
Image(systemName: "chart.xyaxis.line")
|
||||||
HStack(spacing: 12) {
|
.font(.system(size: 40))
|
||||||
// CPU Usage
|
.foregroundColor(.secondary)
|
||||||
StatCard(
|
|
||||||
title: "CPU",
|
|
||||||
value: statsManager.cpuUsageString,
|
|
||||||
data: statsManager.cpuHistory,
|
|
||||||
color: .blue,
|
|
||||||
icon: "cpu"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Memory Usage
|
Text("No Graphs Enabled")
|
||||||
StatCard(
|
.font(.headline)
|
||||||
title: "Memory",
|
.foregroundColor(.primary)
|
||||||
value: statsManager.memoryUsageString,
|
|
||||||
data: statsManager.memoryHistory,
|
|
||||||
color: .green,
|
|
||||||
icon: "memorychip"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GPU Usage
|
Text("Enable graph visibility in Settings → Stats to view performance data.")
|
||||||
StatCard(
|
.font(.caption)
|
||||||
title: "GPU",
|
.foregroundColor(.secondary)
|
||||||
value: statsManager.gpuUsageString,
|
.multilineTextAlignment(.center)
|
||||||
data: statsManager.gpuHistory,
|
.padding(.horizontal)
|
||||||
color: .purple,
|
|
||||||
icon: "display"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding()
|
||||||
|
} else {
|
||||||
|
// Stats content with vertical expansion layout
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
// New vertical expansion layout
|
||||||
|
statsGridLayout
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
|
.animation(.easeInOut(duration: 0.25), value: availableGraphs.count)
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .scale.combined(with: .opacity),
|
||||||
|
removal: .scale.combined(with: .opacity)
|
||||||
|
))
|
||||||
|
|
||||||
// Live indicator and controls in top-right corner
|
// Live indicator and controls in top-right corner
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
|
|
@ -90,7 +330,7 @@ struct NotchStatsView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("Clear") {
|
Button("Clear") {
|
||||||
clearStatsData()
|
statsManager.clearHistory()
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.disabled(statsManager.isMonitoring)
|
.disabled(statsManager.isMonitoring)
|
||||||
|
|
@ -126,12 +366,6 @@ struct NotchStatsView: View {
|
||||||
// Keep monitoring running when tab is not visible
|
// Keep monitoring running when tab is not visible
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func clearStatsData() {
|
|
||||||
statsManager.cpuHistory = Array(repeating: 0.0, count: 30)
|
|
||||||
statsManager.memoryHistory = Array(repeating: 0.0, count: 30)
|
|
||||||
statsManager.gpuHistory = Array(repeating: 0.0, count: 30)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StatCard: View {
|
struct StatCard: View {
|
||||||
|
|
@ -162,6 +396,7 @@ struct StatCard: View {
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.fontWeight(.bold)
|
.fontWeight(.bold)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
|
.frame(height: 22) // Match the height of dual values in DualStatCard
|
||||||
|
|
||||||
// Mini graph - use more height
|
// Mini graph - use more height
|
||||||
MiniGraph(data: data, color: color)
|
MiniGraph(data: data, color: color)
|
||||||
|
|
@ -174,6 +409,66 @@ struct StatCard: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct DualStatCard: View {
|
||||||
|
let title: String
|
||||||
|
let positiveValue: String
|
||||||
|
let negativeValue: String
|
||||||
|
let positiveData: [Double]
|
||||||
|
let negativeData: [Double]
|
||||||
|
let positiveColor: Color
|
||||||
|
let negativeColor: Color
|
||||||
|
let icon: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
// Header
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.foregroundColor(positiveColor)
|
||||||
|
.font(.caption2)
|
||||||
|
|
||||||
|
Text(title)
|
||||||
|
.font(.caption2)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dual values - horizontal layout to match single value height
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(positiveValue)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(positiveColor)
|
||||||
|
|
||||||
|
Text("•")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Text(negativeValue)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(negativeColor)
|
||||||
|
}
|
||||||
|
.frame(height: 22) // Match the height of .title3 text in StatCard
|
||||||
|
|
||||||
|
// Dual quadrant graph
|
||||||
|
DualQuadrantGraph(
|
||||||
|
positiveData: positiveData,
|
||||||
|
negativeData: negativeData,
|
||||||
|
positiveColor: positiveColor,
|
||||||
|
negativeColor: negativeColor
|
||||||
|
)
|
||||||
|
.frame(height: 50)
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(Color(NSColor.controlBackgroundColor).opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct MiniGraph: View {
|
struct MiniGraph: View {
|
||||||
let data: [Double]
|
let data: [Double]
|
||||||
let color: Color
|
let color: Color
|
||||||
|
|
@ -229,6 +524,123 @@ struct MiniGraph: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct DualQuadrantGraph: View {
|
||||||
|
let positiveData: [Double]
|
||||||
|
let negativeData: [Double]
|
||||||
|
let positiveColor: Color
|
||||||
|
let negativeColor: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let maxPositive = positiveData.max() ?? 1.0
|
||||||
|
let maxNegative = negativeData.max() ?? 1.0
|
||||||
|
let maxValue = max(maxPositive, maxNegative)
|
||||||
|
|
||||||
|
let normalizedPositive = maxValue > 0 ? positiveData.map { $0 / maxValue } : positiveData
|
||||||
|
let normalizedNegative = maxValue > 0 ? negativeData.map { $0 / maxValue } : negativeData
|
||||||
|
|
||||||
|
let centerY = geometry.size.height / 2
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
// Center dividing line
|
||||||
|
Path { path in
|
||||||
|
path.move(to: CGPoint(x: 0, y: centerY))
|
||||||
|
path.addLine(to: CGPoint(x: geometry.size.width, y: centerY))
|
||||||
|
}
|
||||||
|
.stroke(Color.secondary.opacity(0.3), lineWidth: 1)
|
||||||
|
|
||||||
|
// Positive quadrant (upper half)
|
||||||
|
Path { path in
|
||||||
|
guard !normalizedPositive.isEmpty else { return }
|
||||||
|
|
||||||
|
let stepX = geometry.size.width / CGFloat(normalizedPositive.count - 1)
|
||||||
|
|
||||||
|
for (index, value) in normalizedPositive.enumerated() {
|
||||||
|
let x = CGFloat(index) * stepX
|
||||||
|
let y = centerY - (centerY * CGFloat(value)) // Above center
|
||||||
|
|
||||||
|
if index == 0 {
|
||||||
|
path.move(to: CGPoint(x: x, y: y))
|
||||||
|
} else {
|
||||||
|
path.addLine(to: CGPoint(x: x, y: y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stroke(positiveColor, lineWidth: 2)
|
||||||
|
|
||||||
|
// Positive fill
|
||||||
|
Path { path in
|
||||||
|
guard !normalizedPositive.isEmpty else { return }
|
||||||
|
|
||||||
|
let stepX = geometry.size.width / CGFloat(normalizedPositive.count - 1)
|
||||||
|
|
||||||
|
path.move(to: CGPoint(x: 0, y: centerY))
|
||||||
|
|
||||||
|
for (index, value) in normalizedPositive.enumerated() {
|
||||||
|
let x = CGFloat(index) * stepX
|
||||||
|
let y = centerY - (centerY * CGFloat(value))
|
||||||
|
path.addLine(to: CGPoint(x: x, y: y))
|
||||||
|
}
|
||||||
|
|
||||||
|
path.addLine(to: CGPoint(x: geometry.size.width, y: centerY))
|
||||||
|
path.closeSubpath()
|
||||||
|
}
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
gradient: Gradient(colors: [positiveColor.opacity(0.3), positiveColor.opacity(0.1)]),
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .center
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Negative quadrant (lower half)
|
||||||
|
Path { path in
|
||||||
|
guard !normalizedNegative.isEmpty else { return }
|
||||||
|
|
||||||
|
let stepX = geometry.size.width / CGFloat(normalizedNegative.count - 1)
|
||||||
|
|
||||||
|
for (index, value) in normalizedNegative.enumerated() {
|
||||||
|
let x = CGFloat(index) * stepX
|
||||||
|
let y = centerY + (centerY * CGFloat(value)) // Below center
|
||||||
|
|
||||||
|
if index == 0 {
|
||||||
|
path.move(to: CGPoint(x: x, y: y))
|
||||||
|
} else {
|
||||||
|
path.addLine(to: CGPoint(x: x, y: y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stroke(negativeColor, lineWidth: 2)
|
||||||
|
|
||||||
|
// Negative fill
|
||||||
|
Path { path in
|
||||||
|
guard !normalizedNegative.isEmpty else { return }
|
||||||
|
|
||||||
|
let stepX = geometry.size.width / CGFloat(normalizedNegative.count - 1)
|
||||||
|
|
||||||
|
path.move(to: CGPoint(x: 0, y: centerY))
|
||||||
|
|
||||||
|
for (index, value) in normalizedNegative.enumerated() {
|
||||||
|
let x = CGFloat(index) * stepX
|
||||||
|
let y = centerY + (centerY * CGFloat(value))
|
||||||
|
path.addLine(to: CGPoint(x: x, y: y))
|
||||||
|
}
|
||||||
|
|
||||||
|
path.addLine(to: CGPoint(x: geometry.size.width, y: centerY))
|
||||||
|
path.closeSubpath()
|
||||||
|
}
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
gradient: Gradient(colors: [negativeColor.opacity(0.3), negativeColor.opacity(0.1)]),
|
||||||
|
startPoint: .bottom,
|
||||||
|
endPoint: .center
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
NotchStatsView()
|
NotchStatsView()
|
||||||
.frame(width: 400, height: 300)
|
.frame(width: 400, height: 300)
|
||||||
|
|
|
||||||
|
|
@ -1292,6 +1292,15 @@ struct Shortcuts: View {
|
||||||
struct Stats: View {
|
struct Stats: View {
|
||||||
@ObservedObject var statsManager = StatsManager.shared
|
@ObservedObject var statsManager = StatsManager.shared
|
||||||
@Default(.enableStatsFeature) var enableStatsFeature
|
@Default(.enableStatsFeature) var enableStatsFeature
|
||||||
|
@Default(.showCpuGraph) var showCpuGraph
|
||||||
|
@Default(.showMemoryGraph) var showMemoryGraph
|
||||||
|
@Default(.showGpuGraph) var showGpuGraph
|
||||||
|
@Default(.showNetworkGraph) var showNetworkGraph
|
||||||
|
@Default(.showDiskGraph) var showDiskGraph
|
||||||
|
|
||||||
|
var enabledGraphsCount: Int {
|
||||||
|
[showCpuGraph, showMemoryGraph, showGpuGraph, showNetworkGraph, showDiskGraph].filter { $0 }.count
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
|
|
@ -1307,13 +1316,35 @@ struct Stats: View {
|
||||||
} header: {
|
} header: {
|
||||||
Text("General")
|
Text("General")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("When enabled, the Stats tab will display real-time CPU, Memory, and GPU usage graphs. This feature requires system permissions and may use additional battery.")
|
Text("When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery.")
|
||||||
.multilineTextAlignment(.trailing)
|
.multilineTextAlignment(.trailing)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
|
|
||||||
if enableStatsFeature {
|
if enableStatsFeature {
|
||||||
|
Section {
|
||||||
|
Defaults.Toggle("CPU Usage", key: .showCpuGraph)
|
||||||
|
Defaults.Toggle("Memory Usage", key: .showMemoryGraph)
|
||||||
|
Defaults.Toggle("GPU Usage", key: .showGpuGraph)
|
||||||
|
Defaults.Toggle("Network Activity", key: .showNetworkGraph)
|
||||||
|
Defaults.Toggle("Disk I/O", key: .showDiskGraph)
|
||||||
|
} header: {
|
||||||
|
Text("Graph Visibility")
|
||||||
|
} footer: {
|
||||||
|
if enabledGraphsCount >= 4 {
|
||||||
|
Text("With \(enabledGraphsCount) graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row.")
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
} else {
|
||||||
|
Text("Each graph can be individually enabled or disabled. Network activity shows download/upload speeds, and disk I/O shows read/write speeds.")
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Monitoring Status")
|
Text("Monitoring Status")
|
||||||
|
|
@ -1328,26 +1359,64 @@ struct Stats: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
if statsManager.isMonitoring {
|
if statsManager.isMonitoring {
|
||||||
|
if showCpuGraph {
|
||||||
HStack {
|
HStack {
|
||||||
Text("CPU Usage")
|
Text("CPU Usage")
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(statsManager.cpuUsageString)
|
Text(statsManager.cpuUsageString)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if showMemoryGraph {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Memory Usage")
|
Text("Memory Usage")
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(statsManager.memoryUsageString)
|
Text(statsManager.memoryUsageString)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if showGpuGraph {
|
||||||
HStack {
|
HStack {
|
||||||
Text("GPU Usage")
|
Text("GPU Usage")
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(statsManager.gpuUsageString)
|
Text(statsManager.gpuUsageString)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if showNetworkGraph {
|
||||||
|
HStack {
|
||||||
|
Text("Network Download")
|
||||||
|
Spacer()
|
||||||
|
Text(String(format: "%.1f MB/s", statsManager.networkDownload))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Network Upload")
|
||||||
|
Spacer()
|
||||||
|
Text(String(format: "%.1f MB/s", statsManager.networkUpload))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if showDiskGraph {
|
||||||
|
HStack {
|
||||||
|
Text("Disk Read")
|
||||||
|
Spacer()
|
||||||
|
Text(String(format: "%.1f MB/s", statsManager.diskRead))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Disk Write")
|
||||||
|
Spacer()
|
||||||
|
Text(String(format: "%.1f MB/s", statsManager.diskWrite))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text("Last Updated")
|
Text("Last Updated")
|
||||||
|
|
@ -1357,7 +1426,7 @@ struct Stats: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("System Performance")
|
Text("Live Performance Data")
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
|
|
@ -1375,7 +1444,7 @@ struct Stats: View {
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button("Clear Data") {
|
Button("Clear Data") {
|
||||||
clearStatsData()
|
statsManager.clearHistory()
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.disabled(statsManager.isMonitoring)
|
.disabled(statsManager.isMonitoring)
|
||||||
|
|
@ -1387,12 +1456,6 @@ struct Stats: View {
|
||||||
}
|
}
|
||||||
.navigationTitle("Stats")
|
.navigationTitle("Stats")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func clearStatsData() {
|
|
||||||
statsManager.cpuHistory = Array(repeating: 0.0, count: 30)
|
|
||||||
statsManager.memoryHistory = Array(repeating: 0.0, count: 30)
|
|
||||||
statsManager.gpuHistory = Array(repeating: 0.0, count: 30)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func proFeatureBadge() -> some View {
|
func proFeatureBadge() -> some View {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import IOKit
|
import IOKit
|
||||||
import IOKit.ps
|
import IOKit.ps
|
||||||
|
import Darwin
|
||||||
|
import Network
|
||||||
|
|
||||||
class StatsManager: ObservableObject {
|
class StatsManager: ObservableObject {
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
|
|
@ -19,22 +21,50 @@ class StatsManager: ObservableObject {
|
||||||
@Published var cpuUsage: Double = 0.0
|
@Published var cpuUsage: Double = 0.0
|
||||||
@Published var memoryUsage: Double = 0.0
|
@Published var memoryUsage: Double = 0.0
|
||||||
@Published var gpuUsage: Double = 0.0
|
@Published var gpuUsage: Double = 0.0
|
||||||
|
@Published var networkDownload: Double = 0.0 // MB/s
|
||||||
|
@Published var networkUpload: Double = 0.0 // MB/s
|
||||||
|
@Published var diskRead: Double = 0.0 // MB/s
|
||||||
|
@Published var diskWrite: Double = 0.0 // MB/s
|
||||||
@Published var lastUpdated: Date = .distantPast
|
@Published var lastUpdated: Date = .distantPast
|
||||||
|
|
||||||
// Historical data for graphs (last 30 data points)
|
// Historical data for graphs (last 30 data points)
|
||||||
@Published var cpuHistory: [Double] = []
|
@Published var cpuHistory: [Double] = []
|
||||||
@Published var memoryHistory: [Double] = []
|
@Published var memoryHistory: [Double] = []
|
||||||
@Published var gpuHistory: [Double] = []
|
@Published var gpuHistory: [Double] = []
|
||||||
|
@Published var networkDownloadHistory: [Double] = []
|
||||||
|
@Published var networkUploadHistory: [Double] = []
|
||||||
|
@Published var diskReadHistory: [Double] = []
|
||||||
|
@Published var diskWriteHistory: [Double] = []
|
||||||
|
|
||||||
private var monitoringTimer: Timer?
|
private var monitoringTimer: Timer?
|
||||||
private let maxHistoryPoints = 30
|
private let maxHistoryPoints = 30
|
||||||
|
|
||||||
|
// Network monitoring state
|
||||||
|
private var previousNetworkStats: (bytesIn: UInt64, bytesOut: UInt64) = (0, 0)
|
||||||
|
private var previousTimestamp: Date = Date()
|
||||||
|
|
||||||
|
// Disk monitoring state
|
||||||
|
private var previousDiskStats: (bytesRead: UInt64, bytesWritten: UInt64) = (0, 0)
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
private init() {
|
private init() {
|
||||||
// Initialize with empty history
|
// Initialize with empty history
|
||||||
cpuHistory = Array(repeating: 0.0, count: maxHistoryPoints)
|
cpuHistory = Array(repeating: 0.0, count: maxHistoryPoints)
|
||||||
memoryHistory = Array(repeating: 0.0, count: maxHistoryPoints)
|
memoryHistory = Array(repeating: 0.0, count: maxHistoryPoints)
|
||||||
gpuHistory = Array(repeating: 0.0, count: maxHistoryPoints)
|
gpuHistory = Array(repeating: 0.0, count: maxHistoryPoints)
|
||||||
|
networkDownloadHistory = Array(repeating: 0.0, count: maxHistoryPoints)
|
||||||
|
networkUploadHistory = Array(repeating: 0.0, count: maxHistoryPoints)
|
||||||
|
diskReadHistory = Array(repeating: 0.0, count: maxHistoryPoints)
|
||||||
|
diskWriteHistory = Array(repeating: 0.0, count: maxHistoryPoints)
|
||||||
|
|
||||||
|
// Initialize baseline network stats
|
||||||
|
let initialStats = getNetworkStats()
|
||||||
|
previousNetworkStats = initialStats
|
||||||
|
previousTimestamp = Date()
|
||||||
|
|
||||||
|
// Initialize baseline disk stats
|
||||||
|
let initialDiskStats = getDiskStats()
|
||||||
|
previousDiskStats = initialDiskStats
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
|
|
@ -73,16 +103,48 @@ class StatsManager: ObservableObject {
|
||||||
let newMemoryUsage = getMemoryUsage()
|
let newMemoryUsage = getMemoryUsage()
|
||||||
let newGpuUsage = getGPUUsage()
|
let newGpuUsage = getGPUUsage()
|
||||||
|
|
||||||
|
// Calculate network speeds
|
||||||
|
let currentNetworkStats = getNetworkStats()
|
||||||
|
let currentTime = Date()
|
||||||
|
let timeInterval = currentTime.timeIntervalSince(previousTimestamp)
|
||||||
|
|
||||||
|
let bytesDownloaded = currentNetworkStats.bytesIn - previousNetworkStats.bytesIn
|
||||||
|
let bytesUploaded = currentNetworkStats.bytesOut - previousNetworkStats.bytesOut
|
||||||
|
|
||||||
|
let downloadSpeed = timeInterval > 0 ? Double(bytesDownloaded) / timeInterval / 1_048_576 : 0.0 // Convert to MB/s
|
||||||
|
let uploadSpeed = timeInterval > 0 ? Double(bytesUploaded) / timeInterval / 1_048_576 : 0.0 // Convert to MB/s
|
||||||
|
|
||||||
|
// Calculate disk speeds
|
||||||
|
let currentDiskStats = getDiskStats()
|
||||||
|
let bytesRead = currentDiskStats.bytesRead - previousDiskStats.bytesRead
|
||||||
|
let bytesWritten = currentDiskStats.bytesWritten - previousDiskStats.bytesWritten
|
||||||
|
|
||||||
|
let readSpeed = timeInterval > 0 ? Double(bytesRead) / timeInterval / 1_048_576 : 0.0 // Convert to MB/s
|
||||||
|
let writeSpeed = timeInterval > 0 ? Double(bytesWritten) / timeInterval / 1_048_576 : 0.0 // Convert to MB/s
|
||||||
|
|
||||||
// Update current values
|
// Update current values
|
||||||
cpuUsage = newCpuUsage
|
cpuUsage = newCpuUsage
|
||||||
memoryUsage = newMemoryUsage
|
memoryUsage = newMemoryUsage
|
||||||
gpuUsage = newGpuUsage
|
gpuUsage = newGpuUsage
|
||||||
|
networkDownload = max(0.0, downloadSpeed)
|
||||||
|
networkUpload = max(0.0, uploadSpeed)
|
||||||
|
diskRead = max(0.0, readSpeed)
|
||||||
|
diskWrite = max(0.0, writeSpeed)
|
||||||
lastUpdated = Date()
|
lastUpdated = Date()
|
||||||
|
|
||||||
// Update history arrays (sliding window)
|
// Update history arrays (sliding window)
|
||||||
updateHistory(value: newCpuUsage, history: &cpuHistory)
|
updateHistory(value: newCpuUsage, history: &cpuHistory)
|
||||||
updateHistory(value: newMemoryUsage, history: &memoryHistory)
|
updateHistory(value: newMemoryUsage, history: &memoryHistory)
|
||||||
updateHistory(value: newGpuUsage, history: &gpuHistory)
|
updateHistory(value: newGpuUsage, history: &gpuHistory)
|
||||||
|
updateHistory(value: networkDownload, history: &networkDownloadHistory)
|
||||||
|
updateHistory(value: networkUpload, history: &networkUploadHistory)
|
||||||
|
updateHistory(value: readSpeed, history: &diskReadHistory)
|
||||||
|
updateHistory(value: writeSpeed, history: &diskWriteHistory)
|
||||||
|
|
||||||
|
// Update previous stats for next calculation
|
||||||
|
previousNetworkStats = currentNetworkStats
|
||||||
|
previousDiskStats = currentDiskStats
|
||||||
|
previousTimestamp = currentTime
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateHistory(value: Double, history: inout [Double]) {
|
private func updateHistory(value: Double, history: inout [Double]) {
|
||||||
|
|
@ -159,6 +221,86 @@ class StatsManager: ObservableObject {
|
||||||
return min(100.0, max(0.0, simulatedUsage))
|
return min(100.0, max(0.0, simulatedUsage))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func getNetworkStats() -> (bytesIn: UInt64, bytesOut: UInt64) {
|
||||||
|
// Use BSD sockets to get network interface statistics
|
||||||
|
var totalBytesIn: UInt64 = 0
|
||||||
|
var totalBytesOut: UInt64 = 0
|
||||||
|
|
||||||
|
var ifaddrs: UnsafeMutablePointer<ifaddrs>?
|
||||||
|
guard getifaddrs(&ifaddrs) == 0 else {
|
||||||
|
return (totalBytesIn, totalBytesOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer { freeifaddrs(ifaddrs) }
|
||||||
|
|
||||||
|
var ptr = ifaddrs
|
||||||
|
while ptr != nil {
|
||||||
|
defer { ptr = ptr?.pointee.ifa_next }
|
||||||
|
|
||||||
|
guard let interface = ptr?.pointee,
|
||||||
|
interface.ifa_addr.pointee.sa_family == UInt8(AF_LINK) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = String(cString: interface.ifa_name)
|
||||||
|
// Skip loopback and non-active interfaces
|
||||||
|
guard !name.hasPrefix("lo") && !name.hasPrefix("gif") && !name.hasPrefix("stf") else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if let data = interface.ifa_data?.assumingMemoryBound(to: if_data.self) {
|
||||||
|
totalBytesIn += UInt64(data.pointee.ifi_ibytes)
|
||||||
|
totalBytesOut += UInt64(data.pointee.ifi_obytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (totalBytesIn, totalBytesOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getDiskStats() -> (bytesRead: UInt64, bytesWritten: UInt64) {
|
||||||
|
// Use IOKit to get disk I/O statistics from IOStorage service
|
||||||
|
var totalBytesRead: UInt64 = 0
|
||||||
|
var totalBytesWritten: UInt64 = 0
|
||||||
|
|
||||||
|
let matchingDict = IOServiceMatching("IOStorage")
|
||||||
|
var iterator: io_iterator_t = 0
|
||||||
|
let result = IOServiceGetMatchingServices(kIOMasterPortDefault, matchingDict, &iterator)
|
||||||
|
|
||||||
|
guard result == KERN_SUCCESS else {
|
||||||
|
return (totalBytesRead, totalBytesWritten)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer { IOObjectRelease(iterator) }
|
||||||
|
|
||||||
|
var service: io_registry_entry_t = IOIteratorNext(iterator)
|
||||||
|
while service != 0 {
|
||||||
|
defer {
|
||||||
|
IOObjectRelease(service)
|
||||||
|
service = IOIteratorNext(iterator)
|
||||||
|
}
|
||||||
|
|
||||||
|
var properties: Unmanaged<CFMutableDictionary>?
|
||||||
|
let propertiesResult = IORegistryEntryCreateCFProperties(service, &properties, kCFAllocatorDefault, 0)
|
||||||
|
|
||||||
|
guard propertiesResult == KERN_SUCCESS,
|
||||||
|
let props = properties?.takeRetainedValue() as? [String: Any],
|
||||||
|
let statistics = props["Statistics"] as? [String: Any] else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the correct property names for APFS/modern filesystems
|
||||||
|
if let bytesRead = statistics["Bytes read from block device"] as? UInt64 {
|
||||||
|
totalBytesRead += bytesRead
|
||||||
|
}
|
||||||
|
|
||||||
|
if let bytesWritten = statistics["Bytes written to block device"] as? UInt64 {
|
||||||
|
totalBytesWritten += bytesWritten
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (totalBytesRead, totalBytesWritten)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Computed Properties for UI
|
// MARK: - Computed Properties for UI
|
||||||
var cpuUsageString: String {
|
var cpuUsageString: String {
|
||||||
return String(format: "%.1f%%", cpuUsage)
|
return String(format: "%.1f%%", cpuUsage)
|
||||||
|
|
@ -172,6 +314,22 @@ class StatsManager: ObservableObject {
|
||||||
return String(format: "%.1f%%", gpuUsage)
|
return String(format: "%.1f%%", gpuUsage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var networkDownloadString: String {
|
||||||
|
return String(format: "%.1f MB/s", networkDownload)
|
||||||
|
}
|
||||||
|
|
||||||
|
var networkUploadString: String {
|
||||||
|
return String(format: "%.1f MB/s", networkUpload)
|
||||||
|
}
|
||||||
|
|
||||||
|
var diskReadString: String {
|
||||||
|
return String(format: "%.1f MB/s", diskRead)
|
||||||
|
}
|
||||||
|
|
||||||
|
var diskWriteString: String {
|
||||||
|
return String(format: "%.1f MB/s", diskWrite)
|
||||||
|
}
|
||||||
|
|
||||||
var maxCpuUsage: Double {
|
var maxCpuUsage: Double {
|
||||||
return cpuHistory.max() ?? 0.0
|
return cpuHistory.max() ?? 0.0
|
||||||
}
|
}
|
||||||
|
|
@ -201,4 +359,15 @@ class StatsManager: ObservableObject {
|
||||||
guard !nonZeroValues.isEmpty else { return 0.0 }
|
guard !nonZeroValues.isEmpty else { return 0.0 }
|
||||||
return nonZeroValues.reduce(0, +) / Double(nonZeroValues.count)
|
return nonZeroValues.reduce(0, +) / Double(nonZeroValues.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Clear History Method
|
||||||
|
func clearHistory() {
|
||||||
|
cpuHistory = Array(repeating: 0.0, count: maxHistoryPoints)
|
||||||
|
memoryHistory = Array(repeating: 0.0, count: maxHistoryPoints)
|
||||||
|
gpuHistory = Array(repeating: 0.0, count: maxHistoryPoints)
|
||||||
|
networkDownloadHistory = Array(repeating: 0.0, count: maxHistoryPoints)
|
||||||
|
networkUploadHistory = Array(repeating: 0.0, count: maxHistoryPoints)
|
||||||
|
diskReadHistory = Array(repeating: 0.0, count: maxHistoryPoints)
|
||||||
|
diskWriteHistory = Array(repeating: 0.0, count: maxHistoryPoints)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,11 @@ extension Defaults.Keys {
|
||||||
|
|
||||||
// MARK: Stats Feature
|
// MARK: Stats Feature
|
||||||
static let enableStatsFeature = Key<Bool>("enableStatsFeature", default: false)
|
static let enableStatsFeature = Key<Bool>("enableStatsFeature", default: false)
|
||||||
|
static let showCpuGraph = Key<Bool>("showCpuGraph", default: true)
|
||||||
|
static let showMemoryGraph = Key<Bool>("showMemoryGraph", default: true)
|
||||||
|
static let showGpuGraph = Key<Bool>("showGpuGraph", default: true)
|
||||||
|
static let showNetworkGraph = Key<Bool>("showNetworkGraph", default: false)
|
||||||
|
static let showDiskGraph = Key<Bool>("showDiskGraph", default: false)
|
||||||
|
|
||||||
// MARK: Timer Feature
|
// MARK: Timer Feature
|
||||||
static let enableTimerFeature = Key<Bool>("enableTimerFeature", default: true)
|
static let enableTimerFeature = Key<Bool>("enableTimerFeature", default: true)
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
||||||
|
|
||||||
func open() {
|
func open() {
|
||||||
withAnimation(.bouncy) {
|
withAnimation(.bouncy) {
|
||||||
self.notchSize = openNotchSize
|
self.notchSize = calculateDynamicNotchSize()
|
||||||
self.notchState = .open
|
self.notchState = .open
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,6 +124,29 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
||||||
MusicManager.shared.forceUpdate()
|
MusicManager.shared.forceUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func calculateDynamicNotchSize() -> CGSize {
|
||||||
|
// Only apply dynamic sizing when on stats tab and stats are enabled
|
||||||
|
guard DynamicIslandViewCoordinator.shared.currentView == .stats && Defaults[.enableStatsFeature] else {
|
||||||
|
return openNotchSize
|
||||||
|
}
|
||||||
|
|
||||||
|
let enabledGraphsCount = [
|
||||||
|
Defaults[.showCpuGraph],
|
||||||
|
Defaults[.showMemoryGraph],
|
||||||
|
Defaults[.showGpuGraph],
|
||||||
|
Defaults[.showNetworkGraph],
|
||||||
|
Defaults[.showDiskGraph]
|
||||||
|
].filter { $0 }.count
|
||||||
|
|
||||||
|
// If 4+ graphs are enabled, increase width
|
||||||
|
if enabledGraphsCount >= 4 {
|
||||||
|
let extraWidth: CGFloat = CGFloat(enabledGraphsCount - 3) * 120
|
||||||
|
return CGSize(width: openNotchSize.width + extraWidth, height: openNotchSize.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
return openNotchSize
|
||||||
|
}
|
||||||
|
|
||||||
func close() {
|
func close() {
|
||||||
withAnimation(.smooth) { [weak self] in
|
withAnimation(.smooth) { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
|
||||||
124
logic.md
Normal file
124
logic.md
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
# Dynamic Island Alignment Logic Analysis
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
The Dynamic Island window sometimes gets misaligned (moves to the left) when switching between tabs, especially when going from stats tab (with 4+ graphs that make the window wider) back to other tabs.
|
||||||
|
|
||||||
|
## Current Implementation Analysis
|
||||||
|
|
||||||
|
### When Window is Created
|
||||||
|
1. `createDynamicIslandWindow()` creates window with `openNotchSize` (fixed size)
|
||||||
|
2. `positionWindow()` centers it using current window width
|
||||||
|
3. Window is perfectly centered initially
|
||||||
|
|
||||||
|
### When User Switches to Stats Tab (4+ graphs)
|
||||||
|
1. `coordinator.$currentView` triggers `updateWindowSizeIfNeeded()`
|
||||||
|
2. `calculateRequiredNotchSize()` returns larger size (openNotchSize.width + extraWidth)
|
||||||
|
3. `updateWindowSizeIfNeeded()` resizes window and re-centers using new width
|
||||||
|
4. Window should stay centered with new larger size
|
||||||
|
|
||||||
|
### When User Switches Away from Stats Tab
|
||||||
|
1. `coordinator.$currentView` triggers `updateWindowSizeIfNeeded()` again
|
||||||
|
2. `calculateRequiredNotchSize()` returns normal `openNotchSize`
|
||||||
|
3. `updateWindowSizeIfNeeded()` resizes window back and re-centers using normal width
|
||||||
|
4. **THIS IS WHERE THE PROBLEM OCCURS**
|
||||||
|
|
||||||
|
## Root Cause Analysis
|
||||||
|
|
||||||
|
### The Issue: Timing and State Mismatch
|
||||||
|
|
||||||
|
The problem is likely that `coordinator.currentView` and the actual UI state are not perfectly synchronized. Here's what probably happens:
|
||||||
|
|
||||||
|
1. User clicks another tab
|
||||||
|
2. `coordinator.$currentView` fires immediately
|
||||||
|
3. `updateWindowSizeIfNeeded()` calculates size as if we're NOT on stats tab
|
||||||
|
4. BUT the ContentView might still be showing stats content (transition not complete)
|
||||||
|
5. Window gets resized to small size while content is still large
|
||||||
|
6. This causes misalignment because content and window size don't match
|
||||||
|
|
||||||
|
### Why It Sometimes Works
|
||||||
|
- When the UI transition is fast enough, everything stays in sync
|
||||||
|
- When there's any delay or animation, they get out of sync
|
||||||
|
|
||||||
|
## Potential Solutions
|
||||||
|
|
||||||
|
### Option 1: Delay the Window Resize
|
||||||
|
Add a small delay to ensure UI transition is complete before resizing window.
|
||||||
|
|
||||||
|
### Option 2: Remove Window Resizing, Use Content Clipping
|
||||||
|
Let the window stay large and let the content handle its own sizing internally.
|
||||||
|
|
||||||
|
### Option 3: Better State Synchronization
|
||||||
|
Make sure window resizing only happens when we're 100% sure the content has updated.
|
||||||
|
|
||||||
|
### Option 4: Use ContentView's dynamicNotchSize Instead
|
||||||
|
The ContentView already has `dynamicNotchSize` logic. Maybe the window should use that instead of having its own calculation.
|
||||||
|
|
||||||
|
## Recommended Approach
|
||||||
|
|
||||||
|
**Option 2** seems safest: Remove dynamic window resizing entirely and let the content handle sizing.
|
||||||
|
|
||||||
|
The ContentView already has:
|
||||||
|
```swift
|
||||||
|
var dynamicNotchSize: CGSize {
|
||||||
|
// calculation logic
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
And uses it in:
|
||||||
|
```swift
|
||||||
|
.frame(maxWidth: dynamicNotchSize.width, maxHeight: dynamicNotchSize.height)
|
||||||
|
```
|
||||||
|
|
||||||
|
This means the content already knows how to size itself. The window can stay at the maximum possible size, and the content will center itself within that space.
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
1. Remove all dynamic window resizing logic
|
||||||
|
2. Set window to maximum possible size (openNotchSize.width + max extra width)
|
||||||
|
3. Let ContentView handle all dynamic sizing internally
|
||||||
|
4. Test alignment stays perfect
|
||||||
|
|
||||||
|
This eliminates the timing issue completely because the window never changes size after initial creation.
|
||||||
|
|
||||||
|
## REAL ROOT CAUSE FOUND AND FIXED
|
||||||
|
|
||||||
|
### The ACTUAL Problem: Window Creation with Wrong Size
|
||||||
|
|
||||||
|
The issue was NOT positioning logic - it was that **`createDynamicIslandWindow()` always created windows with `openNotchSize`** instead of the current required size!
|
||||||
|
|
||||||
|
#### When This Caused Problems:
|
||||||
|
|
||||||
|
1. **"Show on all displays" toggle**: Creates new window with default `openNotchSize`, losing dynamic sizing
|
||||||
|
2. **Screen changes**: `adjustWindowPosition()` creates new windows with wrong size
|
||||||
|
3. **App restarts/other events**: Any time a new window is created, it ignores current dynamic sizing needs
|
||||||
|
|
||||||
|
#### The Wrong Code:
|
||||||
|
```swift
|
||||||
|
// OLD - ALWAYS used openNotchSize
|
||||||
|
let window = DynamicIslandWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: openNotchSize.width, height: openNotchSize.height),
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### The Fix:
|
||||||
|
```swift
|
||||||
|
// NEW - Uses current required size
|
||||||
|
let requiredSize = calculateRequiredNotchSize()
|
||||||
|
let window = DynamicIslandWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: requiredSize.width, height: requiredSize.height),
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why This Fixes Everything:
|
||||||
|
✅ **"Show on all displays" toggle**: New windows created with correct size
|
||||||
|
✅ **Tab switching**: No more size mismatch when repositioning
|
||||||
|
✅ **Screen changes**: New windows respect current graph settings
|
||||||
|
✅ **Consistent behavior**: All window creation uses same sizing logic
|
||||||
|
|
||||||
|
### The Real Issue Was:
|
||||||
|
- Windows created with wrong size, then positioned correctly = **misalignment**
|
||||||
|
- Content expecting larger window, but window is small = **positioning errors**
|
||||||
|
- Dynamic sizing only worked if you never triggered window recreation
|
||||||
|
|
||||||
|
### Result:
|
||||||
|
Perfect alignment in ALL scenarios - no more window size/content size mismatches!
|
||||||
Loading…
Add table
Add a link
Reference in a new issue