From e4719cb9f4fb61e7535a0e9883626d56361208cf Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Fri, 13 Mar 2026 00:14:00 +1100 Subject: [PATCH] Improve animations and resizing again. Add option for animation speed. --- .../UserInterfaceState.xcuserstate | Bin 40682 -> 40682 bytes Downterm/CommandNotch/ContentView.swift | 2 +- .../CommandNotch/Managers/ScreenManager.swift | 112 ++++++++++++++++-- .../CommandNotch/Models/NotchSettings.swift | 3 + .../CommandNotch/Models/NotchViewModel.swift | 13 +- .../CommandNotch/Views/SettingsView.swift | 14 +++ 6 files changed, 135 insertions(+), 9 deletions(-) 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 6c75193a6eef659879dbe46d92db9db7395384ce..f499c486068fe99bdb6bbc51a119f7dbbcb7dcaa 100644 GIT binary patch delta 1444 zcmZXUeN0t#7{||fxOmPzSGc?wf^6#T{ODzq3)v=bF2Jr4Eo~-TmCM`ZauFun>kT;P zGR@ARA}dhgyCxQLPR0xs;L@HY zmNkKK<|Re}H&ofTZZ9m%xAlOVd3hYF@k|Y#X(g7217c3)yA1uEXCjzL{89&dnYbSC zfL9q-MtZ=<4N#Q=lu8itaq&aq(S7mdR5v?XY z*W^&yJe7U=uFeX0!&=5{uwdED42uvLHxQIbDBT$rHlV+j@c|o&1Ur^A>}E|WyMxx$ zwq-rC0%^NcwusWk4KmvNXlu&~QTCzSkMdL&rCiElE!YR-U>{S(IoJn0r9$34eQ<4z znLC7*lO>eEwWI7nIaxvpw@coJa4&Ze0q>OOm>t|@ZeAHw!KFeajyWpBqD4Ag>s-ZK zw_6(nEFjCb&@d>aM5sKNotI)dhQG~3)E)>|l#qV&2ctKEGV=(IXQ(^~2$ zzdDglty6`rd#1);MeA>9{i8ZW!fZUvwx))Xa%jG4FspUqz|9+*Gg}Jk_MBhd*?b%@5nAV^(8Jy`mZH8=;p-*TG=87C9~IMo zc5!v?miQ0nyH<}4U+AZ(IXrq9QOQ2b+Q6OFHU^CgxZ1{F07HL+12kU6MT4`rXe;z) zUpb4L1`{ewhN=l07_*Ruh#N1^#6l*@ER>ddOZhk#+Jc9Uk<<6r z+e5S~Dg>8k2hsjI+UFWVv{saDD5D$iq8$-5qzUb`zh9LR{h7vimR;1M((@C9apA&J zrSE8^zk1knxGiINGkkO_?t#;IX^D1yn#m`y2oddyGFru8TvNBO7! z{|KfHrVMP;l!g()1dL#9`g?hJQ(xlkel4{n5j~mAW6b)sDU4IMx9EoaO=6i?E7psR zqAWIvir6f^BDRPJ#gpO;F^~wdiX@X1l1{RTjcg%Bq>gxrpS()mAg$yO(a2HKNxDfN z`BZ=YO+6~DSvnzoA`M8Vr8Cl~^ttqf^riH@G$RG21?ie}U0RfGN&mjsaYb9#P9FEsMsmwUdS=leYO zdv1OZ<_DqoEFDtIqRQltzhD@$tJ9?|M?ijAVL+*#-F*NUEojiP6JgDYxT z!Z;O9MNlTF00$M<1C?NhD&+&^lrq=@P7tA58B#t}hLx3?#lA{ApIOL44VKq}M++Cs z%L8N7vy=k$;4Rv`)n>EicSAk3cMN=3(||Rb1Tz=lEzVac@~8C)$zj1*`joIIW{FLu z^OQc#otbB>R_Owjeq>Rn6569>XQ6#8RG>_u$w(8k%ydA1F6#|C0}2(vNYAmC`F#Z=AAGIPqX3Jw*VxUa}8vld}@ ztoD>{-kRd~yPZ|L8bsgYk&($m-nT+h3#z+Z<*rO^E?PnIFG&6s$w%yj9I!t`vo4`R4Tv*)4^&JI0qDY`}#X4rA#Ngx1y(=>ED2nxH;c;q(IPB7Q!7 zCn~yk^hQ$NrugG?ok=5sFMA2<5|+*)>OQY|Md0syFw7gS;yD?v01o{HF3@ld9~z9} zLyOh-cq?gqX)vL}WUw2TPMNq2L|orVCg!qH=AbnD%<=yaxwTwg@IX0yaVnhGu6oEC z8NJC@6r^2NVSa(O5ADyR{Zd1a){3$XWt4vr?Et4CjcX_QepLGONB!|MeN)@gXiekB zxUcY*nHgr9xu`;w3U(DLd$@Dl*MO=2P~pZxpGgFNhhRKplKKM5DU^7#=6_SqaI>V( z52Vj6+yfNTXYf~LM7v;**X(jEp7V$@K$PAg3uKTGMh#(u4U8$2$(V}7kw((M_yEcW zQU2ce-yp_xV-_|rW?+L54hKou_2<$s`R#K_g<4umqVawzn_5GyS&>TB>qRYks5O_b z;A?m<@8kXa(|i-(%qx5=e~3TIALmc<@AB{Q{d|BQ=0D~?;Xmbn7VZ|-3mb)Ep+@is zvd}E-6JakWNVf=_Bbg>9jN|ot4I=N$Ik5OS&yHa;$uh Zyh=`&bL6$MMcyD6$X59=J@@bl_zT?q0O$Y! 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