From 1d881304428c8e0fc6698559443c08dd9feea2a2 Mon Sep 17 00:00:00 2001 From: grohith327 Date: Sun, 22 Mar 2026 11:52:03 -0700 Subject: [PATCH 1/5] Smooth bottom overlay hotkey-release transition --- Sources/Fluid/ContentView.swift | 4 + Sources/Fluid/Services/MenuBarManager.swift | 4 +- Sources/Fluid/Views/BottomOverlayView.swift | 173 +++++++++++++++++++- Sources/Fluid/Views/NotchContentViews.swift | 6 + 4 files changed, 177 insertions(+), 10 deletions(-) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index a72d675..381f0ad 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -1582,6 +1582,10 @@ struct ContentView: View { self.clearActiveRecordingMode() + if NotchOverlayManager.shared.isBottomOverlayVisible { + BottomOverlayWindowController.shared.beginReleaseTransition() + } + // Show "Transcribing..." state before calling stop() to keep overlay visible. // The asr.stop() call performs the final transcription which can take a moment // (especially for slower models like Whisper Medium/Large). diff --git a/Sources/Fluid/Services/MenuBarManager.swift b/Sources/Fluid/Services/MenuBarManager.swift index 3ed1adc..5542387 100644 --- a/Sources/Fluid/Services/MenuBarManager.swift +++ b/Sources/Fluid/Services/MenuBarManager.swift @@ -94,10 +94,10 @@ final class MenuBarManager: ObservableObject { asrService.$partialTranscription .receive(on: DispatchQueue.main) .sink { [weak self] newText in - guard self != nil else { return } + guard let self else { return } // CRITICAL FIX: Check if streaming preview is enabled before updating notch // The "Show Live Preview" toggle in Preferences should control this behavior - if SettingsStore.shared.enableStreamingPreview { + if SettingsStore.shared.enableStreamingPreview, !self.isProcessingActive { NotchOverlayManager.shared.updateTranscriptionText(newText) } } diff --git a/Sources/Fluid/Views/BottomOverlayView.swift b/Sources/Fluid/Views/BottomOverlayView.swift index 0bdd8e2..070727c 100644 --- a/Sources/Fluid/Views/BottomOverlayView.swift +++ b/Sources/Fluid/Views/BottomOverlayView.swift @@ -31,8 +31,11 @@ final class BottomOverlayWindowController { private var window: NSPanel? private var audioSubscription: AnyCancellable? private var pendingResizeWorkItem: DispatchWorkItem? + private var pendingReleaseTransitionResetWorkItem: DispatchWorkItem? private var localMouseDownMonitor: Any? private var globalMouseDownMonitor: Any? + private var releaseTransitionActiveUntil: Date? + private var deferredSizeUpdateDuringReleaseTransition = false private init() { NotificationCenter.default.addObserver(forName: NSNotification.Name("OverlayOffsetChanged"), object: nil, queue: .main) { [weak self] _ in @@ -48,6 +51,7 @@ final class BottomOverlayWindowController { } func show(audioPublisher: AnyPublisher, mode: OverlayMode) { + self.endReleaseTransition(flushDeferredUpdate: false) self.pendingResizeWorkItem?.cancel() self.pendingResizeWorkItem = nil BottomOverlayPromptMenuController.shared.hide() @@ -93,6 +97,7 @@ final class BottomOverlayWindowController { } func hide() { + self.endReleaseTransition(flushDeferredUpdate: false) // Cancel audio subscription self.audioSubscription?.cancel() self.audioSubscription = nil @@ -121,13 +126,59 @@ final class BottomOverlayWindowController { func setProcessing(_ processing: Bool) { NotchContentState.shared.setProcessing(processing) + if !processing { + self.endReleaseTransition() + } + } + + func beginReleaseTransition(duration: TimeInterval = 0.28) { + let clampedDuration = max(duration, 0.12) + let now = Date() + let requestedDeadline = now.addingTimeInterval(clampedDuration) + if let existingDeadline = self.releaseTransitionActiveUntil, existingDeadline > requestedDeadline { + self.releaseTransitionActiveUntil = existingDeadline + } else { + self.releaseTransitionActiveUntil = requestedDeadline + } + + self.pendingReleaseTransitionResetWorkItem?.cancel() + + guard let activeDeadline = self.releaseTransitionActiveUntil else { return } + let delay = max(activeDeadline.timeIntervalSince(now), 0) + let resetWorkItem = DispatchWorkItem { [weak self] in + self?.endReleaseTransition() + } + self.pendingReleaseTransitionResetWorkItem = resetWorkItem + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: resetWorkItem) + + NotchContentState.shared.setBottomOverlayReleaseTransitioning(true) + } + + func endReleaseTransition(flushDeferredUpdate: Bool = true) { + self.pendingReleaseTransitionResetWorkItem?.cancel() + self.pendingReleaseTransitionResetWorkItem = nil + + self.releaseTransitionActiveUntil = nil + NotchContentState.shared.setBottomOverlayReleaseTransitioning(false) + + let shouldFlush = flushDeferredUpdate && self.deferredSizeUpdateDuringReleaseTransition + self.deferredSizeUpdateDuringReleaseTransition = false + + if shouldFlush, self.window?.isVisible == true { + self.scheduleSizeAndPositionUpdate(after: 0) + } } func refreshSizeForContent() { self.scheduleSizeAndPositionUpdate() } - private func scheduleSizeAndPositionUpdate(after delay: TimeInterval = 0.03) { + private func scheduleSizeAndPositionUpdate(after delay: TimeInterval = 0.08) { + if self.isReleaseTransitionActive { + self.deferredSizeUpdateDuringReleaseTransition = true + return + } + self.pendingResizeWorkItem?.cancel() // Debounce rapid streaming updates to avoid resize thrash. @@ -140,6 +191,11 @@ final class BottomOverlayWindowController { /// Update window size based on current SwiftUI content and re-position private func updateSizeAndPosition() { + if self.isReleaseTransitionActive { + self.deferredSizeUpdateDuringReleaseTransition = true + return + } + guard let window = window, let hostingView = window.contentView as? NSHostingView else { return } // Re-calculate fitting size for the new layout constants @@ -179,6 +235,7 @@ final class BottomOverlayWindowController { panel.hasShadow = false // SwiftUI handles shadow panel.isMovableByWindowBackground = false panel.hidesOnDeactivate = false + panel.animationBehavior = .none let contentView = BottomOverlayView() let hostingView = NSHostingView(rootView: contentView) @@ -197,6 +254,16 @@ final class BottomOverlayWindowController { self.window = panel } + private var isReleaseTransitionActive: Bool { + guard let deadline = self.releaseTransitionActiveUntil else { return false } + if deadline > Date() { + return true + } + + self.releaseTransitionActiveUntil = nil + return false + } + private func ensureMouseDownMonitors() { if self.localMouseDownMonitor == nil { self.localMouseDownMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in @@ -1559,6 +1626,17 @@ private struct PromptSelectorAnchorReader: NSViewRepresentable { } } +private struct DynamicPreviewHeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + let next = nextValue() + if next > 0 { + value = next + } + } +} + // MARK: - Bottom Overlay SwiftUI View struct BottomOverlayView: View { @@ -1579,6 +1657,9 @@ struct BottomOverlayView: View { @State private var promptSelectorWindow: NSWindow? @State private var actionsSelectorFrameInScreen: CGRect = .zero @State private var actionsSelectorWindow: NSWindow? + @State private var dynamicPreviewMeasuredHeight: CGFloat = 0 + @State private var frozenDynamicPreviewHeight: CGFloat? + @State private var dynamicPreviewResizeBucket: Int = 0 struct LayoutConstants { let hPadding: CGFloat @@ -1786,6 +1867,50 @@ struct BottomOverlayView: View { self.layout.waveformWidth * 2.2 } + private var dynamicPreviewBaseMinHeight: CGFloat { + self.hasTranscription || self.contentState.isProcessing ? self.layout.transFontSize * 1.5 : 0 + } + + private var effectiveDynamicPreviewLockedHeight: CGFloat? { + guard self.contentState.isBottomOverlayReleaseTransitioning else { return nil } + guard let frozen = self.frozenDynamicPreviewHeight else { return nil } + return max(frozen, self.layout.transFontSize * 1.5) + } + + private var effectiveDynamicPreviewMinHeight: CGFloat { + if let lockedHeight = self.effectiveDynamicPreviewLockedHeight { + return lockedHeight + } + return self.dynamicPreviewBaseMinHeight + } + + private var estimatedPreviewLineHeight: CGFloat { + max(self.layout.transFontSize * 1.25, self.layout.transFontSize + 2) + } + + private func previewResizeBucket(for previewText: String) -> Int { + let trimmed = previewText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return self.contentState.isProcessing ? 1 : 0 } + + if self.settings.overlaySize == .small { + return 1 + } + + let newlineCount = trimmed.filter { $0 == "\n" }.count + let charCapacityPerLine = max(Int((self.previewMaxWidth / max(self.layout.transFontSize * 0.56, 1)).rounded(.down)), 12) + let estimatedWrappedLines = max(1, (trimmed.count + charCapacityPerLine - 1) / charCapacityPerLine) + let maxVisibleLines = max(Int((self.previewMaxHeight / max(self.estimatedPreviewLineHeight, 1)).rounded(.down)), 1) + return min(max(estimatedWrappedLines + newlineCount, 1), maxVisibleLines) + } + + private func refreshDynamicPreviewSizeIfNeeded(for previewText: String) { + guard !self.layout.usesFixedCanvas else { return } + let nextBucket = self.previewResizeBucket(for: previewText) + guard nextBucket != self.dynamicPreviewResizeBucket else { return } + self.dynamicPreviewResizeBucket = nextBucket + BottomOverlayWindowController.shared.refreshSizeForContent() + } + private var transcriptionVerticalPadding: CGFloat { max(4, self.layout.vPadding / 2) } @@ -1797,6 +1922,10 @@ struct BottomOverlayView: View { return "" } + private var currentPreviewSizingText: String { + self.contentState.isProcessing ? self.processingStatusText : self.transcriptionPreviewText + } + private var overlayBorderLineWidth: CGFloat { self.settings.overlaySize == .large ? 0.8 : 1 } @@ -2295,9 +2424,16 @@ struct BottomOverlayView: View { ) } } + .background( + GeometryReader { proxy in + Color.clear + .preference(key: DynamicPreviewHeightPreferenceKey.self, value: proxy.size.height) + } + ) .frame( maxWidth: self.previewMaxWidth, - minHeight: self.hasTranscription || self.contentState.isProcessing ? self.layout.transFontSize * 1.5 : 0 + minHeight: self.effectiveDynamicPreviewMinHeight, + maxHeight: self.effectiveDynamicPreviewLockedHeight ) } } @@ -2384,12 +2520,12 @@ struct BottomOverlayView: View { alignment: .top ) .onChange(of: self.settings.overlaySize) { _, _ in + self.dynamicPreviewResizeBucket = self.previewResizeBucket(for: self.currentPreviewSizingText) + self.frozenDynamicPreviewHeight = nil BottomOverlayWindowController.shared.refreshSizeForContent() } .onChange(of: self.contentState.cachedPreviewText) { _, _ in - if !self.layout.usesFixedCanvas { - BottomOverlayWindowController.shared.refreshSizeForContent() - } + self.refreshDynamicPreviewSizeIfNeeded(for: self.currentPreviewSizingText) } .onChange(of: self.contentState.mode) { _, _ in if !self.isPromptSelectableMode || self.contentState.isProcessing { @@ -2408,6 +2544,7 @@ struct BottomOverlayView: View { case .command: break } if !self.layout.usesFixedCanvas { + self.dynamicPreviewResizeBucket = self.previewResizeBucket(for: self.currentPreviewSizingText) BottomOverlayWindowController.shared.refreshSizeForContent() } } @@ -2423,9 +2560,32 @@ struct BottomOverlayView: View { self.isHoveringActionsChip = false self.isHoveringSettingsChip = false if !self.layout.usesFixedCanvas { + self.refreshDynamicPreviewSizeIfNeeded(for: self.currentPreviewSizingText) + if processing { + BottomOverlayWindowController.shared.refreshSizeForContent() + } + } + } + .onChange(of: self.contentState.isBottomOverlayReleaseTransitioning) { _, transitioning in + guard !self.layout.usesFixedCanvas else { return } + if transitioning { + let measuredHeight = self.dynamicPreviewMeasuredHeight > 0 + ? self.dynamicPreviewMeasuredHeight + : self.effectiveDynamicPreviewMinHeight + self.frozenDynamicPreviewHeight = max(measuredHeight, self.layout.transFontSize * 1.5) + } else { + self.frozenDynamicPreviewHeight = nil BottomOverlayWindowController.shared.refreshSizeForContent() } } + .onPreferenceChange(DynamicPreviewHeightPreferenceKey.self) { measuredHeight in + guard !self.layout.usesFixedCanvas else { return } + guard measuredHeight > 0 else { return } + self.dynamicPreviewMeasuredHeight = measuredHeight + } + .onAppear { + self.dynamicPreviewResizeBucket = self.previewResizeBucket(for: self.currentPreviewSizingText) + } .onDisappear { self.closePromptMenu() self.closeModeMenu() @@ -2443,9 +2603,6 @@ struct BottomOverlayView: View { // NotchOverlayManager.shared.onNotchClicked?() // } // } - .animation(.easeInOut(duration: 0.15), value: self.hasTranscription) - .animation(.easeInOut(duration: 0.2), value: self.contentState.mode) - .animation(.easeInOut(duration: 0.2), value: self.contentState.isProcessing) } } diff --git a/Sources/Fluid/Views/NotchContentViews.swift b/Sources/Fluid/Views/NotchContentViews.swift index eb174e2..b6e3eee 100644 --- a/Sources/Fluid/Views/NotchContentViews.swift +++ b/Sources/Fluid/Views/NotchContentViews.swift @@ -124,6 +124,7 @@ class NotchContentState: ObservableObject { // MARK: - Bottom Overlay Audio Level @Published var bottomOverlayAudioLevel: CGFloat = 0 // Audio level for bottom overlay waveform + @Published var isBottomOverlayReleaseTransitioning: Bool = false /// Called when the user requests a live mode switch from the prompt picker tabs. var onPromptModeSwitchRequested: ((SettingsStore.PromptMode) -> Void)? @@ -154,6 +155,11 @@ class NotchContentState: ObservableObject { self.expandedModeAudioLevel = level } + func setBottomOverlayReleaseTransitioning(_ transitioning: Bool) { + guard self.isBottomOverlayReleaseTransitioning != transitioning else { return } + self.isBottomOverlayReleaseTransitioning = transitioning + } + // MARK: - Command Output Methods /// Show expanded output view with content From fc244bf9ecb4fb684601dc2cad44557441fbc2e0 Mon Sep 17 00:00:00 2001 From: grohith327 Date: Sun, 22 Mar 2026 12:08:28 -0700 Subject: [PATCH 2/5] Fix lint error --- Sources/Fluid/Views/BottomOverlayView.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/Fluid/Views/BottomOverlayView.swift b/Sources/Fluid/Views/BottomOverlayView.swift index 070727c..33044b7 100644 --- a/Sources/Fluid/Views/BottomOverlayView.swift +++ b/Sources/Fluid/Views/BottomOverlayView.swift @@ -35,7 +35,7 @@ final class BottomOverlayWindowController { private var localMouseDownMonitor: Any? private var globalMouseDownMonitor: Any? private var releaseTransitionActiveUntil: Date? - private var deferredSizeUpdateDuringReleaseTransition = false + private var deferredResizePending = false private init() { NotificationCenter.default.addObserver(forName: NSNotification.Name("OverlayOffsetChanged"), object: nil, queue: .main) { [weak self] _ in @@ -161,8 +161,8 @@ final class BottomOverlayWindowController { self.releaseTransitionActiveUntil = nil NotchContentState.shared.setBottomOverlayReleaseTransitioning(false) - let shouldFlush = flushDeferredUpdate && self.deferredSizeUpdateDuringReleaseTransition - self.deferredSizeUpdateDuringReleaseTransition = false + let shouldFlush = flushDeferredUpdate && self.deferredResizePending + self.deferredResizePending = false if shouldFlush, self.window?.isVisible == true { self.scheduleSizeAndPositionUpdate(after: 0) @@ -175,7 +175,7 @@ final class BottomOverlayWindowController { private func scheduleSizeAndPositionUpdate(after delay: TimeInterval = 0.08) { if self.isReleaseTransitionActive { - self.deferredSizeUpdateDuringReleaseTransition = true + self.deferredResizePending = true return } @@ -192,7 +192,7 @@ final class BottomOverlayWindowController { /// Update window size based on current SwiftUI content and re-position private func updateSizeAndPosition() { if self.isReleaseTransitionActive { - self.deferredSizeUpdateDuringReleaseTransition = true + self.deferredResizePending = true return } From 91aae3fd685df294334b5ba41c9be6ae26ed498c Mon Sep 17 00:00:00 2001 From: grohith327 Date: Sun, 22 Mar 2026 11:52:03 -0700 Subject: [PATCH 3/5] Smooth bottom overlay hotkey-release transition --- Sources/Fluid/ContentView.swift | 4 + Sources/Fluid/Services/MenuBarManager.swift | 4 +- Sources/Fluid/Views/BottomOverlayView.swift | 173 +++++++++++++++++++- Sources/Fluid/Views/NotchContentViews.swift | 6 + 4 files changed, 177 insertions(+), 10 deletions(-) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index d39683d..e79ffc7 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -1612,6 +1612,10 @@ struct ContentView: View { self.clearActiveRecordingMode() + if NotchOverlayManager.shared.isBottomOverlayVisible { + BottomOverlayWindowController.shared.beginReleaseTransition() + } + // Show "Transcribing..." state before calling stop() to keep overlay visible. // The asr.stop() call performs the final transcription which can take a moment // (especially for slower models like Whisper Medium/Large). diff --git a/Sources/Fluid/Services/MenuBarManager.swift b/Sources/Fluid/Services/MenuBarManager.swift index 8c05de5..0fd3517 100644 --- a/Sources/Fluid/Services/MenuBarManager.swift +++ b/Sources/Fluid/Services/MenuBarManager.swift @@ -94,10 +94,10 @@ final class MenuBarManager: ObservableObject { asrService.$partialTranscription .receive(on: DispatchQueue.main) .sink { [weak self] newText in - guard self != nil else { return } + guard let self else { return } // CRITICAL FIX: Check if streaming preview is enabled before updating notch // The "Show Live Preview" toggle in Preferences should control this behavior - if SettingsStore.shared.enableStreamingPreview { + if SettingsStore.shared.enableStreamingPreview, !self.isProcessingActive { NotchOverlayManager.shared.updateTranscriptionText(newText) } } diff --git a/Sources/Fluid/Views/BottomOverlayView.swift b/Sources/Fluid/Views/BottomOverlayView.swift index 89b10b3..6fc73a0 100644 --- a/Sources/Fluid/Views/BottomOverlayView.swift +++ b/Sources/Fluid/Views/BottomOverlayView.swift @@ -31,8 +31,11 @@ final class BottomOverlayWindowController { private var window: NSPanel? private var audioSubscription: AnyCancellable? private var pendingResizeWorkItem: DispatchWorkItem? + private var pendingReleaseTransitionResetWorkItem: DispatchWorkItem? private var localMouseDownMonitor: Any? private var globalMouseDownMonitor: Any? + private var releaseTransitionActiveUntil: Date? + private var deferredSizeUpdateDuringReleaseTransition = false private init() { NotificationCenter.default.addObserver(forName: NSNotification.Name("OverlayOffsetChanged"), object: nil, queue: .main) { [weak self] _ in @@ -48,6 +51,7 @@ final class BottomOverlayWindowController { } func show(audioPublisher: AnyPublisher, mode: OverlayMode) { + self.endReleaseTransition(flushDeferredUpdate: false) self.pendingResizeWorkItem?.cancel() self.pendingResizeWorkItem = nil BottomOverlayPromptMenuController.shared.hide() @@ -93,6 +97,7 @@ final class BottomOverlayWindowController { } func hide() { + self.endReleaseTransition(flushDeferredUpdate: false) // Cancel audio subscription self.audioSubscription?.cancel() self.audioSubscription = nil @@ -121,13 +126,59 @@ final class BottomOverlayWindowController { func setProcessing(_ processing: Bool) { NotchContentState.shared.setProcessing(processing) + if !processing { + self.endReleaseTransition() + } + } + + func beginReleaseTransition(duration: TimeInterval = 0.28) { + let clampedDuration = max(duration, 0.12) + let now = Date() + let requestedDeadline = now.addingTimeInterval(clampedDuration) + if let existingDeadline = self.releaseTransitionActiveUntil, existingDeadline > requestedDeadline { + self.releaseTransitionActiveUntil = existingDeadline + } else { + self.releaseTransitionActiveUntil = requestedDeadline + } + + self.pendingReleaseTransitionResetWorkItem?.cancel() + + guard let activeDeadline = self.releaseTransitionActiveUntil else { return } + let delay = max(activeDeadline.timeIntervalSince(now), 0) + let resetWorkItem = DispatchWorkItem { [weak self] in + self?.endReleaseTransition() + } + self.pendingReleaseTransitionResetWorkItem = resetWorkItem + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: resetWorkItem) + + NotchContentState.shared.setBottomOverlayReleaseTransitioning(true) + } + + func endReleaseTransition(flushDeferredUpdate: Bool = true) { + self.pendingReleaseTransitionResetWorkItem?.cancel() + self.pendingReleaseTransitionResetWorkItem = nil + + self.releaseTransitionActiveUntil = nil + NotchContentState.shared.setBottomOverlayReleaseTransitioning(false) + + let shouldFlush = flushDeferredUpdate && self.deferredSizeUpdateDuringReleaseTransition + self.deferredSizeUpdateDuringReleaseTransition = false + + if shouldFlush, self.window?.isVisible == true { + self.scheduleSizeAndPositionUpdate(after: 0) + } } func refreshSizeForContent() { self.scheduleSizeAndPositionUpdate() } - private func scheduleSizeAndPositionUpdate(after delay: TimeInterval = 0.03) { + private func scheduleSizeAndPositionUpdate(after delay: TimeInterval = 0.08) { + if self.isReleaseTransitionActive { + self.deferredSizeUpdateDuringReleaseTransition = true + return + } + self.pendingResizeWorkItem?.cancel() // Debounce rapid streaming updates to avoid resize thrash. @@ -140,6 +191,11 @@ final class BottomOverlayWindowController { /// Update window size based on current SwiftUI content and re-position private func updateSizeAndPosition() { + if self.isReleaseTransitionActive { + self.deferredSizeUpdateDuringReleaseTransition = true + return + } + guard let window = window, let hostingView = window.contentView as? NSHostingView else { return } // Re-calculate fitting size for the new layout constants @@ -179,6 +235,7 @@ final class BottomOverlayWindowController { panel.hasShadow = false // SwiftUI handles shadow panel.isMovableByWindowBackground = false panel.hidesOnDeactivate = false + panel.animationBehavior = .none let contentView = BottomOverlayView() let hostingView = NSHostingView(rootView: contentView) @@ -197,6 +254,16 @@ final class BottomOverlayWindowController { self.window = panel } + private var isReleaseTransitionActive: Bool { + guard let deadline = self.releaseTransitionActiveUntil else { return false } + if deadline > Date() { + return true + } + + self.releaseTransitionActiveUntil = nil + return false + } + private func ensureMouseDownMonitors() { if self.localMouseDownMonitor == nil { self.localMouseDownMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in @@ -1562,6 +1629,17 @@ private struct PromptSelectorAnchorReader: NSViewRepresentable { } } +private struct DynamicPreviewHeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + let next = nextValue() + if next > 0 { + value = next + } + } +} + // MARK: - Bottom Overlay SwiftUI View struct BottomOverlayView: View { @@ -1582,6 +1660,9 @@ struct BottomOverlayView: View { @State private var promptSelectorWindow: NSWindow? @State private var actionsSelectorFrameInScreen: CGRect = .zero @State private var actionsSelectorWindow: NSWindow? + @State private var dynamicPreviewMeasuredHeight: CGFloat = 0 + @State private var frozenDynamicPreviewHeight: CGFloat? + @State private var dynamicPreviewResizeBucket: Int = 0 struct LayoutConstants { let hPadding: CGFloat @@ -1828,6 +1909,50 @@ struct BottomOverlayView: View { self.layout.waveformWidth * 2.2 } + private var dynamicPreviewBaseMinHeight: CGFloat { + self.hasTranscription || self.contentState.isProcessing ? self.layout.transFontSize * 1.5 : 0 + } + + private var effectiveDynamicPreviewLockedHeight: CGFloat? { + guard self.contentState.isBottomOverlayReleaseTransitioning else { return nil } + guard let frozen = self.frozenDynamicPreviewHeight else { return nil } + return max(frozen, self.layout.transFontSize * 1.5) + } + + private var effectiveDynamicPreviewMinHeight: CGFloat { + if let lockedHeight = self.effectiveDynamicPreviewLockedHeight { + return lockedHeight + } + return self.dynamicPreviewBaseMinHeight + } + + private var estimatedPreviewLineHeight: CGFloat { + max(self.layout.transFontSize * 1.25, self.layout.transFontSize + 2) + } + + private func previewResizeBucket(for previewText: String) -> Int { + let trimmed = previewText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return self.contentState.isProcessing ? 1 : 0 } + + if self.settings.overlaySize == .small { + return 1 + } + + let newlineCount = trimmed.filter { $0 == "\n" }.count + let charCapacityPerLine = max(Int((self.previewMaxWidth / max(self.layout.transFontSize * 0.56, 1)).rounded(.down)), 12) + let estimatedWrappedLines = max(1, (trimmed.count + charCapacityPerLine - 1) / charCapacityPerLine) + let maxVisibleLines = max(Int((self.previewMaxHeight / max(self.estimatedPreviewLineHeight, 1)).rounded(.down)), 1) + return min(max(estimatedWrappedLines + newlineCount, 1), maxVisibleLines) + } + + private func refreshDynamicPreviewSizeIfNeeded(for previewText: String) { + guard !self.layout.usesFixedCanvas else { return } + let nextBucket = self.previewResizeBucket(for: previewText) + guard nextBucket != self.dynamicPreviewResizeBucket else { return } + self.dynamicPreviewResizeBucket = nextBucket + BottomOverlayWindowController.shared.refreshSizeForContent() + } + private var transcriptionVerticalPadding: CGFloat { max(4, self.layout.vPadding / 2) } @@ -1839,6 +1964,10 @@ struct BottomOverlayView: View { return "" } + private var currentPreviewSizingText: String { + self.contentState.isProcessing ? self.processingStatusText : self.transcriptionPreviewText + } + private var overlayBorderLineWidth: CGFloat { self.settings.overlaySize == .large ? 0.8 : 1 } @@ -2350,9 +2479,16 @@ struct BottomOverlayView: View { ) } } + .background( + GeometryReader { proxy in + Color.clear + .preference(key: DynamicPreviewHeightPreferenceKey.self, value: proxy.size.height) + } + ) .frame( maxWidth: self.previewMaxWidth, - minHeight: self.hasTranscription || self.contentState.isProcessing ? self.layout.transFontSize * 1.5 : 0 + minHeight: self.effectiveDynamicPreviewMinHeight, + maxHeight: self.effectiveDynamicPreviewLockedHeight ) } } @@ -2439,12 +2575,12 @@ struct BottomOverlayView: View { alignment: .top ) .onChange(of: self.settings.overlaySize) { _, _ in + self.dynamicPreviewResizeBucket = self.previewResizeBucket(for: self.currentPreviewSizingText) + self.frozenDynamicPreviewHeight = nil BottomOverlayWindowController.shared.refreshSizeForContent() } .onChange(of: self.contentState.cachedPreviewText) { _, _ in - if !self.layout.usesFixedCanvas { - BottomOverlayWindowController.shared.refreshSizeForContent() - } + self.refreshDynamicPreviewSizeIfNeeded(for: self.currentPreviewSizingText) } .onChange(of: self.contentState.mode) { _, _ in if !self.isPromptSelectableMode || self.contentState.isProcessing { @@ -2463,6 +2599,7 @@ struct BottomOverlayView: View { case .command: break } if !self.layout.usesFixedCanvas { + self.dynamicPreviewResizeBucket = self.previewResizeBucket(for: self.currentPreviewSizingText) BottomOverlayWindowController.shared.refreshSizeForContent() } } @@ -2478,9 +2615,32 @@ struct BottomOverlayView: View { self.isHoveringActionsChip = false self.isHoveringSettingsChip = false if !self.layout.usesFixedCanvas { + self.refreshDynamicPreviewSizeIfNeeded(for: self.currentPreviewSizingText) + if processing { + BottomOverlayWindowController.shared.refreshSizeForContent() + } + } + } + .onChange(of: self.contentState.isBottomOverlayReleaseTransitioning) { _, transitioning in + guard !self.layout.usesFixedCanvas else { return } + if transitioning { + let measuredHeight = self.dynamicPreviewMeasuredHeight > 0 + ? self.dynamicPreviewMeasuredHeight + : self.effectiveDynamicPreviewMinHeight + self.frozenDynamicPreviewHeight = max(measuredHeight, self.layout.transFontSize * 1.5) + } else { + self.frozenDynamicPreviewHeight = nil BottomOverlayWindowController.shared.refreshSizeForContent() } } + .onPreferenceChange(DynamicPreviewHeightPreferenceKey.self) { measuredHeight in + guard !self.layout.usesFixedCanvas else { return } + guard measuredHeight > 0 else { return } + self.dynamicPreviewMeasuredHeight = measuredHeight + } + .onAppear { + self.dynamicPreviewResizeBucket = self.previewResizeBucket(for: self.currentPreviewSizingText) + } .onDisappear { self.closePromptMenu() self.closeModeMenu() @@ -2498,9 +2658,6 @@ struct BottomOverlayView: View { // NotchOverlayManager.shared.onNotchClicked?() // } // } - .animation(.easeInOut(duration: 0.15), value: self.hasTranscription) - .animation(.easeInOut(duration: 0.2), value: self.contentState.mode) - .animation(.easeInOut(duration: 0.2), value: self.contentState.isProcessing) } } diff --git a/Sources/Fluid/Views/NotchContentViews.swift b/Sources/Fluid/Views/NotchContentViews.swift index 34b04ff..0ea8584 100644 --- a/Sources/Fluid/Views/NotchContentViews.swift +++ b/Sources/Fluid/Views/NotchContentViews.swift @@ -124,6 +124,7 @@ class NotchContentState: ObservableObject { // MARK: - Bottom Overlay Audio Level @Published var bottomOverlayAudioLevel: CGFloat = 0 // Audio level for bottom overlay waveform + @Published var isBottomOverlayReleaseTransitioning: Bool = false /// Called when the user requests a live mode switch from the prompt picker tabs. var onPromptModeSwitchRequested: ((SettingsStore.PromptMode) -> Void)? @@ -154,6 +155,11 @@ class NotchContentState: ObservableObject { self.expandedModeAudioLevel = level } + func setBottomOverlayReleaseTransitioning(_ transitioning: Bool) { + guard self.isBottomOverlayReleaseTransitioning != transitioning else { return } + self.isBottomOverlayReleaseTransitioning = transitioning + } + // MARK: - Command Output Methods /// Show expanded output view with content From c718a5e669f97234efa2435d347e8c5472740949 Mon Sep 17 00:00:00 2001 From: grohith327 Date: Sun, 22 Mar 2026 12:08:28 -0700 Subject: [PATCH 4/5] Fix lint error --- Sources/Fluid/Views/BottomOverlayView.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/Fluid/Views/BottomOverlayView.swift b/Sources/Fluid/Views/BottomOverlayView.swift index 6fc73a0..e769d9b 100644 --- a/Sources/Fluid/Views/BottomOverlayView.swift +++ b/Sources/Fluid/Views/BottomOverlayView.swift @@ -35,7 +35,7 @@ final class BottomOverlayWindowController { private var localMouseDownMonitor: Any? private var globalMouseDownMonitor: Any? private var releaseTransitionActiveUntil: Date? - private var deferredSizeUpdateDuringReleaseTransition = false + private var deferredResizePending = false private init() { NotificationCenter.default.addObserver(forName: NSNotification.Name("OverlayOffsetChanged"), object: nil, queue: .main) { [weak self] _ in @@ -161,8 +161,8 @@ final class BottomOverlayWindowController { self.releaseTransitionActiveUntil = nil NotchContentState.shared.setBottomOverlayReleaseTransitioning(false) - let shouldFlush = flushDeferredUpdate && self.deferredSizeUpdateDuringReleaseTransition - self.deferredSizeUpdateDuringReleaseTransition = false + let shouldFlush = flushDeferredUpdate && self.deferredResizePending + self.deferredResizePending = false if shouldFlush, self.window?.isVisible == true { self.scheduleSizeAndPositionUpdate(after: 0) @@ -175,7 +175,7 @@ final class BottomOverlayWindowController { private func scheduleSizeAndPositionUpdate(after delay: TimeInterval = 0.08) { if self.isReleaseTransitionActive { - self.deferredSizeUpdateDuringReleaseTransition = true + self.deferredResizePending = true return } @@ -192,7 +192,7 @@ final class BottomOverlayWindowController { /// Update window size based on current SwiftUI content and re-position private func updateSizeAndPosition() { if self.isReleaseTransitionActive { - self.deferredSizeUpdateDuringReleaseTransition = true + self.deferredResizePending = true return } From 57996f4de71fcf602ef0676cf7cb158e0a40fd42 Mon Sep 17 00:00:00 2001 From: grohith327 Date: Sun, 22 Mar 2026 13:55:44 -0700 Subject: [PATCH 5/5] Fix animation on empty message --- Sources/Fluid/ContentView.swift | 45 ++++++++++----- Sources/Fluid/Services/TypingService.swift | 4 +- Sources/Fluid/Views/BottomOverlayView.swift | 61 ++++++++++++++++----- 3 files changed, 78 insertions(+), 32 deletions(-) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index e79ffc7..c0566a7 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -1612,27 +1612,32 @@ struct ContentView: View { self.clearActiveRecordingMode() - if NotchOverlayManager.shared.isBottomOverlayVisible { - BottomOverlayWindowController.shared.beginReleaseTransition() - } + let hadLivePreviewText = self.asr.partialTranscription + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty == false - // Show "Transcribing..." state before calling stop() to keep overlay visible. - // The asr.stop() call performs the final transcription which can take a moment - // (especially for slower models like Whisper Medium/Large). - DebugLogger.shared.debug("Showing transcription processing state", source: "ContentView") - self.menuBarManager.setProcessing(true) - NotchOverlayManager.shared.updateTranscriptionText("Transcribing...") + // Only show a processing transition when we already observed spoken text. + // If there was no spoken text, let the overlay disappear immediately on hotkey release. + if hadLivePreviewText { + if NotchOverlayManager.shared.isBottomOverlayVisible { + BottomOverlayWindowController.shared.beginReleaseTransition() + } - // Give SwiftUI a chance to render the processing state before we do heavier work - // (ASR finalization + optional AI post-processing). - await Task.yield() + DebugLogger.shared.debug("Showing transcription processing state", source: "ContentView") + self.menuBarManager.setProcessing(true) + NotchOverlayManager.shared.updateTranscriptionText("Transcribing...") + + // Give SwiftUI a chance to render the processing state before heavier work. + await Task.yield() + } // Stop the ASR service and wait for transcription to complete - // The processing indicator will stay visible during this phase let transcribedText = await asr.stop() - // Reset the transcription text display after transcription completes - NotchOverlayManager.shared.updateTranscriptionText("") + // Reset transient status text if we showed it. + if hadLivePreviewText { + NotchOverlayManager.shared.updateTranscriptionText("") + } guard transcribedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { DebugLogger.shared.debug("Transcription returned empty text", source: "ContentView") @@ -1648,6 +1653,11 @@ struct ContentView: View { promptTest.lastOutputText = "" promptTest.lastError = "" + let overlayStillVisible = NotchOverlayManager.shared.isBottomOverlayVisible || NotchOverlayManager.shared.isAnyNotchVisible + if !hadLivePreviewText, overlayStillVisible { + self.menuBarManager.setProcessing(true) + } + guard DictationAIPostProcessingGate.isConfigured() else { promptTest.lastError = "AI post-processing is not configured. Enable AI Enhancement and configure a provider/model (and API key for non-local endpoints)." self.menuBarManager.setProcessing(false) @@ -1706,6 +1716,11 @@ struct ContentView: View { if shouldUseAI { DebugLogger.shared.debug("Routing transcription through AI post-processing", source: "ContentView") + let overlayStillVisible = NotchOverlayManager.shared.isBottomOverlayVisible || NotchOverlayManager.shared.isAnyNotchVisible + if !hadLivePreviewText, overlayStillVisible { + self.menuBarManager.setProcessing(true) + } + // Update overlay text to show we're now refining (processing already true) NotchOverlayManager.shared.updateTranscriptionText("Refining...") diff --git a/Sources/Fluid/Services/TypingService.swift b/Sources/Fluid/Services/TypingService.swift index 2afafef..f346ed5 100644 --- a/Sources/Fluid/Services/TypingService.swift +++ b/Sources/Fluid/Services/TypingService.swift @@ -47,11 +47,11 @@ final class TypingService { /// if the layout data is unavailable. private static func virtualKeyCode(for character: Character, qwertyFallback: CGKeyCode) -> CGKeyCode { if Thread.isMainThread { - return tisLookup(for: character, qwertyFallback: qwertyFallback) + return self.tisLookup(for: character, qwertyFallback: qwertyFallback) } var result = qwertyFallback DispatchQueue.main.sync { - result = tisLookup(for: character, qwertyFallback: qwertyFallback) + result = self.tisLookup(for: character, qwertyFallback: qwertyFallback) } return result } diff --git a/Sources/Fluid/Views/BottomOverlayView.swift b/Sources/Fluid/Views/BottomOverlayView.swift index e769d9b..1fcf3b1 100644 --- a/Sources/Fluid/Views/BottomOverlayView.swift +++ b/Sources/Fluid/Views/BottomOverlayView.swift @@ -82,17 +82,31 @@ final class BottomOverlayWindowController { self.createWindow() } - // Position at bottom center of main screen - self.positionWindow() - - // Show with animation - self.window?.alphaValue = 0 - self.window?.orderFrontRegardless() - - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.25 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - self.window?.animator().alphaValue = 1 + guard let window = self.window else { return } + + // Animate the panel rising from below its final anchored position. + if let targetOrigin = self.anchoredWindowOrigin(for: window) { + let startOrigin = NSPoint(x: targetOrigin.x, y: targetOrigin.y - self.presentationLiftDistance(for: window)) + window.setFrameOrigin(startOrigin) + window.alphaValue = 0 + window.orderFrontRegardless() + + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.24 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + window.animator().alphaValue = 1 + window.animator().setFrameOrigin(targetOrigin) + } + } else { + self.positionWindow() + window.alphaValue = 0 + window.orderFrontRegardless() + + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.24 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + window.animator().alphaValue = 1 + } } } @@ -114,13 +128,19 @@ final class BottomOverlayWindowController { NotchContentState.shared.targetAppIcon = nil guard let window = window else { return } + let hideOrigin = NSPoint( + x: window.frame.origin.x, + y: window.frame.origin.y - (self.presentationLiftDistance(for: window) * 0.6) + ) NSAnimationContext.runAnimationGroup { context in - context.duration = 0.2 + context.duration = 0.18 context.timingFunction = CAMediaTimingFunction(name: .easeIn) window.animator().alphaValue = 0 + window.animator().setFrameOrigin(hideOrigin) } completionHandler: { window.orderOut(nil) + self.positionWindow() } } @@ -314,9 +334,16 @@ final class BottomOverlayWindowController { // Safe check for window and screen availability guard let window = window else { return } + guard let anchoredOrigin = self.anchoredWindowOrigin(for: window) else { return } + + // Apply position directly to avoid implicit frame animations during hover-driven resizes. + window.setFrameOrigin(anchoredOrigin) + } + + private func anchoredWindowOrigin(for window: NSWindow) -> NSPoint? { // Use the screen that contains the window, or fallback to the main screen let screen = window.screen ?? NSScreen.main - guard let screen = screen else { return } + guard let screen = screen else { return nil } let fullFrame = screen.frame let visibleFrame = screen.visibleFrame @@ -339,8 +366,12 @@ final class BottomOverlayWindowController { y = max(min(y, maxY), minY) - // Apply position directly to avoid implicit frame animations during hover-driven resizes. - window.setFrameOrigin(NSPoint(x: x, y: y)) + return NSPoint(x: x, y: y) + } + + private func presentationLiftDistance(for window: NSWindow) -> CGFloat { + let height = max(window.frame.height, 1) + return max(14, min(height * 0.2, 28)) } }