Merge pull request #6 from Ebullioscopic/feature/stats

Feature/stats
This commit is contained in:
Hariharan Mudaliar 2025-07-30 20:04:51 +05:30 • committed by GitHub
commit e75650b146
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1086 additions and 77 deletions

View file

@ -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)

View file

@ -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,21 +173,152 @@ 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 let centerX = screenFrame.origin.x + (screenFrame.width / 2)
window.setFrameOrigin( let newX = centerX - (window.frame.width / 2)
NSPoint( let newY = screenFrame.origin.y + screenFrame.height - window.frame.height
x: screenFrame.origin.x + (screenFrame.width / 2) - window.frame.width / 2,
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,

View file

@ -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." : {

View file

@ -8,10 +8,254 @@
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) {
if !enableStatsFeature { if !enableStatsFeature {
@ -33,42 +277,38 @@ struct NotchStatsView: View {
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.padding() .padding()
} else if availableGraphs.isEmpty {
// No graphs enabled state
VStack(spacing: 12) {
Image(systemName: "chart.xyaxis.line")
.font(.system(size: 40))
.foregroundColor(.secondary)
Text("No Graphs Enabled")
.font(.headline)
.foregroundColor(.primary)
Text("Enable graph visibility in Settings → Stats to view performance data.")
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
} else { } else {
// Stats content - use full width and height // Stats content with vertical expansion layout
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
VStack(spacing: 12) { VStack(spacing: 12) {
// Stats Grid - use more space // New vertical expansion layout
HStack(spacing: 12) { statsGridLayout
// CPU Usage
StatCard(
title: "CPU",
value: statsManager.cpuUsageString,
data: statsManager.cpuHistory,
color: .blue,
icon: "cpu"
)
// Memory Usage
StatCard(
title: "Memory",
value: statsManager.memoryUsageString,
data: statsManager.memoryHistory,
color: .green,
icon: "memorychip"
)
// GPU Usage
StatCard(
title: "GPU",
value: statsManager.gpuUsageString,
data: statsManager.gpuHistory,
color: .purple,
icon: "display"
)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} }
.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)

View file

@ -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,25 +1359,63 @@ struct Stats: View {
} }
if statsManager.isMonitoring { if statsManager.isMonitoring {
HStack { if showCpuGraph {
Text("CPU Usage") HStack {
Spacer() Text("CPU Usage")
Text(statsManager.cpuUsageString) Spacer()
.foregroundStyle(.secondary) Text(statsManager.cpuUsageString)
.foregroundStyle(.secondary)
}
} }
HStack { if showMemoryGraph {
Text("Memory Usage") HStack {
Spacer() Text("Memory Usage")
Text(statsManager.memoryUsageString) Spacer()
.foregroundStyle(.secondary) Text(statsManager.memoryUsageString)
.foregroundStyle(.secondary)
}
} }
HStack { if showGpuGraph {
Text("GPU Usage") HStack {
Spacer() Text("GPU Usage")
Text(statsManager.gpuUsageString) Spacer()
.foregroundStyle(.secondary) Text(statsManager.gpuUsageString)
.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 {
@ -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 {

View file

@ -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)
}
} }

View file

@ -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)

View file

@ -116,13 +116,36 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
func open() { func open() {
withAnimation(.bouncy) { withAnimation(.bouncy) {
self.notchSize = openNotchSize self.notchSize = calculateDynamicNotchSize()
self.notchState = .open self.notchState = .open
} }
// Force music information update when notch is opened // Force music information update when notch is opened
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

124
logic.md Normal file
View 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!