Debouncing but working

This commit is contained in:
Hariharan Mudaliar 2025-07-30 20:04:03 +05:30
commit 2e19ff1ed9
4 changed files with 384 additions and 90 deletions

View file

@ -38,13 +38,17 @@ struct ContentView: View {
let enabledGraphsCount = [showCpuGraph, showMemoryGraph, showGpuGraph, showNetworkGraph, showDiskGraph].filter { $0 }.count
// If 4+ graphs are enabled, increase width
// Calculate height based on layout: 1-3 graphs = single row, 4+ graphs = two rows
var requiredHeight = openNotchSize.height
if enabledGraphsCount >= 4 {
let extraWidth: CGFloat = CGFloat(enabledGraphsCount - 3) * 120
return CGSize(width: openNotchSize.width + extraWidth, height: openNotchSize.height)
// Two rows needed - add height for second row plus spacing
let extraHeight: CGFloat = 120 + 12 // Graph height + spacing
requiredHeight = openNotchSize.height + extraHeight
}
return openNotchSize
// Width stays constant - no horizontal expansion
return CGSize(width: openNotchSize.width, height: requiredHeight)
}

View file

@ -87,11 +87,30 @@ class AppDelegate: NSObject, NSApplicationDelegate {
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 {
return false
}
func applicationWillTerminate(_ notification: Notification) {
// Cancel any pending window size updates
windowSizeUpdateWorkItem?.cancel()
NotificationCenter.default.removeObserver(self)
}
@ -126,9 +145,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
private func createDynamicIslandWindow(for screen: NSScreen, with viewModel: DynamicIslandViewModel)
-> NSWindow
{
// Use the current required size instead of always using openNotchSize
let requiredSize = calculateRequiredNotchSize()
let window = DynamicIslandWindow(
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],
backing: .buffered,
defer: false
@ -151,14 +173,20 @@ class AppDelegate: NSObject, NSApplicationDelegate {
window.alphaValue = 0
}
DispatchQueue.main.async { [weak window] in
guard let window = window else { return }
// Use the same centering logic as updateWindowSizeIfNeeded()
let screenFrame = screen.frame
window.setFrameOrigin(
NSPoint(
x: screenFrame.origin.x + (screenFrame.width / 2) - window.frame.width / 2,
y: screenFrame.origin.y + screenFrame.height - window.frame.height
))
let centerX = screenFrame.origin.x + (screenFrame.width / 2)
let newX = centerX - (window.frame.width / 2)
let newY = 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
}
}
@ -167,22 +195,65 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// Calculate required size based on current state
let requiredSize = calculateRequiredNotchSize()
// Update all windows if size has changed
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 newFrame = NSRect(
x: screenFrame.origin.x + (screenFrame.width / 2) - requiredSize.width / 2,
y: screenFrame.origin.y + screenFrame.height - requiredSize.height,
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!
// Animate the frame change for smooth transition
// 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.3
context.duration = 0.25
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
context.allowsImplicitAnimation = true
window.setFrame(newFrame, display: true, animate: true)
window.animator().setFrame(NSRect(
x: newX,
y: newY,
width: requiredSize.width,
height: requiredSize.height
), display: true)
}
}
}
@ -202,61 +273,51 @@ class AppDelegate: NSObject, NSApplicationDelegate {
Defaults[.showDiskGraph]
].filter { $0 }.count
// If 4+ graphs are enabled, increase width
// Calculate height based on layout: 1-3 graphs = single row, 4+ graphs = two rows
var requiredHeight = openNotchSize.height
if enabledGraphsCount >= 4 {
let extraWidth: CGFloat = CGFloat(enabledGraphsCount - 3) * 120
return CGSize(width: openNotchSize.width + extraWidth, height: openNotchSize.height)
// Two rows needed - add height for second row plus spacing
let extraHeight: CGFloat = 120 + 12 // Graph height + spacing
requiredHeight = openNotchSize.height + extraHeight
}
return openNotchSize
// Width stays constant - no horizontal expansion
return CGSize(width: openNotchSize.width, height: requiredHeight)
}
func applicationDidFinishLaunching(_ notification: Notification) {
coordinator.setupWorkersNotificationObservers()
// Observe tab changes to update window size dynamically
coordinator.objectWillChange.sink { [weak self] in
DispatchQueue.main.async {
self?.updateWindowSizeIfNeeded()
}
// Observe tab changes - use debounced updates
coordinator.$currentView.sink { [weak self] newView in
self?.debouncedUpdateWindowSize()
}.store(in: &cancellables)
// Observe stats settings changes
// Observe stats settings changes - use debounced updates
Defaults.publisher(.enableStatsFeature, options: []).sink { [weak self] _ in
DispatchQueue.main.async {
self?.updateWindowSizeIfNeeded()
}
self?.debouncedUpdateWindowSize()
}.store(in: &cancellables)
Defaults.publisher(.showCpuGraph, options: []).sink { [weak self] _ in
DispatchQueue.main.async {
self?.updateWindowSizeIfNeeded()
}
self?.debouncedUpdateWindowSize()
}.store(in: &cancellables)
Defaults.publisher(.showMemoryGraph, options: []).sink { [weak self] _ in
DispatchQueue.main.async {
self?.updateWindowSizeIfNeeded()
}
self?.debouncedUpdateWindowSize()
}.store(in: &cancellables)
Defaults.publisher(.showGpuGraph, options: []).sink { [weak self] _ in
DispatchQueue.main.async {
self?.updateWindowSizeIfNeeded()
}
self?.debouncedUpdateWindowSize()
}.store(in: &cancellables)
Defaults.publisher(.showNetworkGraph, options: []).sink { [weak self] _ in
DispatchQueue.main.async {
self?.updateWindowSizeIfNeeded()
}
self?.debouncedUpdateWindowSize()
}.store(in: &cancellables)
Defaults.publisher(.showDiskGraph, options: []).sink { [weak self] _ in
DispatchQueue.main.async {
self?.updateWindowSizeIfNeeded()
}
self?.debouncedUpdateWindowSize()
}.store(in: &cancellables)
NotificationCenter.default.addObserver(

View file

@ -118,15 +118,141 @@ struct NotchStatsView: View {
return graphs
}
var gridColumns: [GridItem] {
// New vertical expansion layout system
@ViewBuilder
var statsGridLayout: some View {
let graphCount = availableGraphs.count
if graphCount <= 3 {
// Horizontal layout for 1-3 graphs
return Array(repeating: GridItem(.flexible(), spacing: 12), count: graphCount)
} else {
// Compressed grid layout for 4+ graphs
return Array(repeating: GridItem(.flexible(), spacing: 8), count: graphCount)
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
)
}
}
@ -171,39 +297,18 @@ struct NotchStatsView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
} else {
// Stats content with dynamic layout
// Stats content with vertical expansion layout
ZStack(alignment: .topTrailing) {
VStack(spacing: 12) {
// Dynamic Stats Grid
LazyVGrid(columns: gridColumns, spacing: 12) {
ForEach(0..<availableGraphs.count, id: \.self) { index in
let graphData = availableGraphs[index]
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
)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
// New vertical expansion layout
statsGridLayout
}
.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
HStack(spacing: 8) {

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!