diff --git a/Downterm/CommandNotch.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate b/Downterm/CommandNotch.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate index 6c75193..f499c48 100644 Binary files a/Downterm/CommandNotch.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate and b/Downterm/CommandNotch.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Downterm/CommandNotch/ContentView.swift b/Downterm/CommandNotch/ContentView.swift index 528c7f5..9ab597e 100644 --- a/Downterm/CommandNotch/ContentView.swift +++ b/Downterm/CommandNotch/ContentView.swift @@ -151,7 +151,7 @@ struct ContentView: View { } private var sizeAnimation: Animation? { - guard !vm.isUserResizing else { return nil } + guard !vm.isUserResizing, !vm.isPresetResizing else { return nil } return vm.notchState == .open ? vm.openAnimation : vm.closeAnimation } diff --git a/Downterm/CommandNotch/Managers/ScreenManager.swift b/Downterm/CommandNotch/Managers/ScreenManager.swift index 79c2297..4abb6f6 100644 --- a/Downterm/CommandNotch/Managers/ScreenManager.swift +++ b/Downterm/CommandNotch/Managers/ScreenManager.swift @@ -11,9 +11,11 @@ class ScreenManager: ObservableObject { static let shared = ScreenManager() private let focusRetryDelay: TimeInterval = 0.01 + private let presetResizeFrameInterval: TimeInterval = 1.0 / 60.0 private(set) var windows: [String: NotchWindow] = [:] private(set) var viewModels: [String: NotchViewModel] = [:] + private var presetResizeTimers: [String: Timer] = [:] @AppStorage(NotchSettings.Keys.showOnAllDisplays) private var showOnAllDisplays = NotchSettings.Defaults.showOnAllDisplays @@ -140,10 +142,9 @@ class ScreenManager: ObservableObject { return } - withAnimation(vm.openAnimation) { - vm.applySizePreset(preset, notifyWindowResize: false) - } - updateWindowFrame(for: screenUUID, centerHorizontally: true) + let startSize = vm.notchSize + let targetSize = vm.setStoredOpenSize(preset.size) + animatePresetResize(for: screenUUID, from: startSize, to: targetSize, duration: vm.openAnimationDuration) } // MARK: - Window creation @@ -236,10 +237,28 @@ class ScreenManager: ObservableObject { window: NotchWindow, centerHorizontally: Bool = false ) { - guard let vm = viewModels[screenUUID] else { return } + let frame = targetWindowFrame( + for: screenUUID, + on: screen, + window: window, + centerHorizontally: centerHorizontally, + contentSize: nil + ) + guard !window.frame.equalTo(frame) else { return } + window.setFrame(frame, display: false) + } + + private func targetWindowFrame( + for screenUUID: String, + on screen: NSScreen, + window: NotchWindow, + centerHorizontally: Bool, + contentSize: CGSize? + ) -> NSRect { + guard let vm = viewModels[screenUUID] else { return window.frame } let shadowPadding: CGFloat = 20 - let openSize = vm.openNotchSize + let openSize = contentSize ?? vm.openNotchSize let windowWidth = openSize.width + 40 let windowHeight = openSize.height + shadowPadding let centeredX = screen.frame.origin.x + (screen.frame.width - windowWidth) / 2 @@ -248,12 +267,87 @@ class ScreenManager: ObservableObject { ? centeredX : min(max(window.frame.minX, screen.frame.minX), screen.frame.maxX - windowWidth) - let frame = NSRect( + return NSRect( x: x, y: screen.frame.origin.y + screen.frame.height - windowHeight, width: windowWidth, height: windowHeight ) + } + + private func animatePresetResize( + for screenUUID: String, + from startSize: CGSize, + to targetSize: CGSize, + duration: TimeInterval + ) { + cancelPresetResize(for: screenUUID) + + guard let vm = viewModels[screenUUID] else { return } + guard startSize != targetSize else { + vm.notchSize = targetSize + updateWindowFrame(for: screenUUID, centerHorizontally: true) + return + } + + vm.isPresetResizing = true + let startTime = CACurrentMediaTime() + let duration = max(duration, presetResizeFrameInterval) + + let timer = Timer(timeInterval: presetResizeFrameInterval, repeats: true) { [weak self] timer in + MainActor.assumeIsolated { + guard let self, let vm = self.viewModels[screenUUID] else { + timer.invalidate() + return + } + + let elapsed = CACurrentMediaTime() - startTime + let progress = min(1, elapsed / duration) + let easedProgress = 0.5 - (cos(.pi * progress) / 2) + let size = CGSize( + width: startSize.width + ((targetSize.width - startSize.width) * easedProgress), + height: startSize.height + ((targetSize.height - startSize.height) * easedProgress) + ) + + vm.notchSize = size + self.updateWindowFrame(for: screenUUID, contentSize: size, centerHorizontally: true) + + if progress >= 1 { + vm.notchSize = targetSize + vm.isPresetResizing = false + self.updateWindowFrame(for: screenUUID, contentSize: targetSize, centerHorizontally: true) + self.presetResizeTimers[screenUUID] = nil + timer.invalidate() + } + } + } + + presetResizeTimers[screenUUID] = timer + RunLoop.main.add(timer, forMode: .common) + timer.fire() + } + + private func cancelPresetResize(for screenUUID: String) { + presetResizeTimers[screenUUID]?.invalidate() + presetResizeTimers[screenUUID] = nil + viewModels[screenUUID]?.isPresetResizing = false + } + + private func updateWindowFrame( + for screenUUID: String, + contentSize: CGSize, + centerHorizontally: Bool = false + ) { + guard let screen = NSScreen.screens.first(where: { $0.displayUUID == screenUUID }), + let window = windows[screenUUID] else { return } + + let frame = targetWindowFrame( + for: screenUUID, + on: screen, + window: window, + centerHorizontally: centerHorizontally, + contentSize: contentSize + ) guard !window.frame.equalTo(frame) else { return } window.setFrame(frame, display: false) } @@ -261,6 +355,10 @@ class ScreenManager: ObservableObject { // MARK: - Cleanup private func cleanupAllWindows() { + for (_, timer) in presetResizeTimers { + timer.invalidate() + } + presetResizeTimers.removeAll() for (_, window) in windows { window.orderOut(nil) window.close() diff --git a/Downterm/CommandNotch/Models/NotchSettings.swift b/Downterm/CommandNotch/Models/NotchSettings.swift index 0c2d4f8..796cb8a 100644 --- a/Downterm/CommandNotch/Models/NotchSettings.swift +++ b/Downterm/CommandNotch/Models/NotchSettings.swift @@ -37,6 +37,7 @@ enum NotchSettings { static let closeSpringDamping = "closeSpringDamping" static let hoverSpringResponse = "hoverSpringResponse" static let hoverSpringDamping = "hoverSpringDamping" + static let resizeAnimationDuration = "resizeAnimationDuration" // Behavior static let enableGestures = "enableGestures" @@ -85,6 +86,7 @@ enum NotchSettings { static let closeSpringDamping: Double = 1.0 static let hoverSpringResponse: Double = 0.38 static let hoverSpringDamping: Double = 0.8 + static let resizeAnimationDuration: Double = 0.42 static let enableGestures: Bool = true static let gestureSensitivity: Double = 0.5 @@ -132,6 +134,7 @@ enum NotchSettings { Keys.closeSpringDamping: Defaults.closeSpringDamping, Keys.hoverSpringResponse: Defaults.hoverSpringResponse, Keys.hoverSpringDamping: Defaults.hoverSpringDamping, + Keys.resizeAnimationDuration: Defaults.resizeAnimationDuration, Keys.enableGestures: Defaults.enableGestures, Keys.gestureSensitivity: Defaults.gestureSensitivity, diff --git a/Downterm/CommandNotch/Models/NotchViewModel.swift b/Downterm/CommandNotch/Models/NotchViewModel.swift index 667d6ef..e3a3632 100644 --- a/Downterm/CommandNotch/Models/NotchViewModel.swift +++ b/Downterm/CommandNotch/Models/NotchViewModel.swift @@ -18,6 +18,7 @@ class NotchViewModel: ObservableObject { @Published var isCloseTransitionActive: Bool = false @Published var suppressHoverOpenUntilHoverExit: Bool = false @Published var isUserResizing: Bool = false + @Published var isPresetResizing: Bool = false let terminalManager = TerminalManager.shared @@ -36,6 +37,7 @@ class NotchViewModel: ObservableObject { @AppStorage(NotchSettings.Keys.openSpringDamping) private var openSpringDamping = NotchSettings.Defaults.openSpringDamping @AppStorage(NotchSettings.Keys.closeSpringResponse) private var closeSpringResponse = NotchSettings.Defaults.closeSpringResponse @AppStorage(NotchSettings.Keys.closeSpringDamping) private var closeSpringDamping = NotchSettings.Defaults.closeSpringDamping + @AppStorage(NotchSettings.Keys.resizeAnimationDuration) private var resizeAnimationDurationSetting = NotchSettings.Defaults.resizeAnimationDuration private var closeTransitionTask: Task? @@ -45,6 +47,9 @@ class NotchViewModel: ObservableObject { var closeAnimation: Animation { .spring(response: closeSpringResponse, dampingFraction: closeSpringDamping) } + var openAnimationDuration: TimeInterval { + max(0.05, resizeAnimationDurationSetting) + } init(screenUUID: String) { self.screenUUID = screenUUID @@ -94,10 +99,16 @@ class NotchViewModel: ObservableObject { } @discardableResult - func setOpenSize(_ proposedSize: CGSize, notifyWindowResize: Bool) -> CGSize { + func setStoredOpenSize(_ proposedSize: CGSize) -> CGSize { let clampedSize = clampedOpenSize(proposedSize) openWidth = clampedSize.width openHeight = clampedSize.height + return clampedSize + } + + @discardableResult + func setOpenSize(_ proposedSize: CGSize, notifyWindowResize: Bool) -> CGSize { + let clampedSize = setStoredOpenSize(proposedSize) if notchState == .open { notchSize = clampedSize } diff --git a/Downterm/CommandNotch/Views/SettingsView.swift b/Downterm/CommandNotch/Views/SettingsView.swift index 3fc55b1..e55e655 100644 --- a/Downterm/CommandNotch/Views/SettingsView.swift +++ b/Downterm/CommandNotch/Views/SettingsView.swift @@ -227,6 +227,7 @@ struct AnimationSettingsView: View { @AppStorage(NotchSettings.Keys.closeSpringDamping) private var closeDamping = NotchSettings.Defaults.closeSpringDamping @AppStorage(NotchSettings.Keys.hoverSpringResponse) private var hoverResponse = NotchSettings.Defaults.hoverSpringResponse @AppStorage(NotchSettings.Keys.hoverSpringDamping) private var hoverDamping = NotchSettings.Defaults.hoverSpringDamping + @AppStorage(NotchSettings.Keys.resizeAnimationDuration) private var resizeDuration = NotchSettings.Defaults.resizeAnimationDuration var body: some View { Form { @@ -239,6 +240,9 @@ struct AnimationSettingsView: View { Section("Hover Animation") { springControls(response: $hoverResponse, damping: $hoverDamping) } + Section("Resize Animation") { + durationControl(duration: $resizeDuration) + } Section { Button("Reset to Defaults") { openResponse = NotchSettings.Defaults.openSpringResponse @@ -247,6 +251,7 @@ struct AnimationSettingsView: View { closeDamping = NotchSettings.Defaults.closeSpringDamping hoverResponse = NotchSettings.Defaults.hoverSpringResponse hoverDamping = NotchSettings.Defaults.hoverSpringDamping + resizeDuration = NotchSettings.Defaults.resizeAnimationDuration } } } @@ -266,6 +271,15 @@ struct AnimationSettingsView: View { Text(String(format: "%.2f", damping.wrappedValue)).monospacedDigit().frame(width: 50) } } + + @ViewBuilder + private func durationControl(duration: Binding) -> some View { + HStack { + Text("Duration") + Slider(value: duration, in: 0.05...1.5, step: 0.01) + Text(String(format: "%.2fs", duration.wrappedValue)).monospacedDigit().frame(width: 56) + } + } } // MARK: - Terminal