From 6d55e773985a775fcad1758a66ab92feb6db7ff2 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Wed, 6 Aug 2025 23:15:06 +0545 Subject: [PATCH] Improve mini recorder animations and timing --- MiniRecorder_Morphing_Requirements.md | 140 ++++++++++++++++++ .../Views/Recorder/MiniRecorderPanel.swift | 31 +++- .../Views/Recorder/MiniRecorderView.swift | 36 ++++- .../Views/Recorder/MiniWindowManager.swift | 21 +++ 4 files changed, 221 insertions(+), 7 deletions(-) create mode 100644 MiniRecorder_Morphing_Requirements.md diff --git a/MiniRecorder_Morphing_Requirements.md b/MiniRecorder_Morphing_Requirements.md new file mode 100644 index 0000000..2fd2928 --- /dev/null +++ b/MiniRecorder_Morphing_Requirements.md @@ -0,0 +1,140 @@ +# MiniRecorder Morphing Window Requirements + +## 🎯 Core Requirements + +### **Visual Behavior** +1. **Visualizer Always Centered**: The audio visualizer must remain in the exact same position throughout all animations +2. **Fixed Window Position**: The MiniRecorderView window position should never change during morphing +3. **Horizontal-Only Expansion**: Only width should change, height remains constant at 34px +4. **Hover-Triggered**: Expansion should occur on hover, collapse on hover exit + +### **Layout States** + +#### **Compact State (Default)** +- **Width**: ~70px (just enough for visualizer + minimal padding) +- **Content**: Audio visualizer/status display only +- **Buttons**: Hidden/not rendered +- **Centering**: Visualizer perfectly centered in compact window + +#### **Expanded State (On Hover)** +- **Width**: ~160px (current full width) +- **Content**: RecorderPromptButton + Visualizer + RecorderPowerModeButton +- **Buttons**: Fully visible and functional +- **Centering**: Visualizer remains in same absolute screen position + +## 🔧 Technical Constraints + +### **Window Positioning** +- Window's center point must remain constant +- When expanding from 70px → 160px, window should grow equally left and right (45px each side) +- `NSRect` calculations must account for center-anchored growth + +### **Animation Requirements** +- Smooth spring animation (~0.3-0.4s duration) +- Buttons should appear/disappear gracefully (fade in/out or slide from edges) +- No jarring movements or position jumps +- Reversible animation (expand ↔ collapse) + +### **SwiftUI Layout Considerations** +- HStack with conditional button rendering +- Visualizer maintains `frame(maxWidth: .infinity)` behavior in both states +- Proper spacing and padding calculations for both states + +## 💡 Recommended Implementation Strategy + +### **Approach: Center-Anchored Window Growth with Sliding Buttons** + +#### **Window Management (MiniRecorderPanel)** +``` +Compact Window Rect: +- Width: 70px +- Height: 34px +- X: screenCenter - 35px +- Y: current Y position + +Expanded Window Rect: +- Width: 160px +- Height: 34px +- X: screenCenter - 80px // Grows left by 45px +- Y: same Y position // Grows right by 45px +``` + +#### **SwiftUI Layout (MiniRecorderView)** +``` +HStack(spacing: 0) { + // Left button - slides in from left edge + if isExpanded { + RecorderPromptButton() + .transition(.move(edge: .leading).combined(with: .opacity)) + } + + // Visualizer - always centered, never moves + statusView + .frame(width: visualizerWidth) // Fixed width + + // Right button - slides in from right edge + if isExpanded { + RecorderPowerModeButton() + .transition(.move(edge: .trailing).combined(with: .opacity)) + } +} +``` + +#### **State Management** +- `@State private var isExpanded = false` +- `@State private var isHovering = false` +- Hover detection with debouncing for smooth UX +- Window resize triggered by state changes + +## 🎨 Animation Sequence + +### **Expansion (Compact → Expanded)** +1. **Trigger**: Mouse enters window bounds +2. **Window**: Animate width 70px → 160px (center-anchored) +3. **Buttons**: Slide in from edges with fade-in +4. **Duration**: ~0.3s with spring easing +5. **Result**: Visualizer appears unmoved, buttons visible + +### **Collapse (Expanded → Compact)** +1. **Trigger**: Mouse leaves window bounds (with delay) +2. **Buttons**: Slide out to edges with fade-out +3. **Window**: Animate width 160px → 70px (center-anchored) +4. **Duration**: ~0.3s with spring easing +5. **Result**: Back to visualizer-only, same position + +## 🚫 Critical Don'ts + +- **Never move the visualizer's absolute screen position** +- **Never change the window's center point** +- **Never animate height or vertical position** +- **Never show jarring button pop-ins (use smooth transitions)** +- **Never let buttons overlap the visualizer during animation** + +## 📐 Calculations + +### **Visualizer Dimensions** +- AudioVisualizer: 12 bars × 3px + 11 × 2px spacing = 58px width +- With padding: ~70px total compact width + +### **Button Dimensions** +- Each button: ~24px width + padding +- Total button space: ~90px (45px per side) +- Total expanded width: 70px + 90px = 160px + +### **Center-Anchored Growth** +``` +Compact X position: screenCenterX - 35px +Expanded X position: screenCenterX - 80px +Growth: 45px left + 45px right = 90px total +``` + +## 🎯 Success Criteria + +✅ **Visualizer never visually moves during any animation** +✅ **Window position anchor point remains constant** +✅ **Smooth hover-based expansion/collapse** +✅ **Buttons appear/disappear gracefully** +✅ **No layout jumps or glitches** +✅ **Maintains current functionality in expanded state** + +This approach ensures the visualizer appears completely stationary while the window "grows around it" to reveal the buttons, creating a seamless morphing effect. \ No newline at end of file diff --git a/VoiceInk/Views/Recorder/MiniRecorderPanel.swift b/VoiceInk/Views/Recorder/MiniRecorderPanel.swift index 55f56f8..b5e3891 100644 --- a/VoiceInk/Views/Recorder/MiniRecorderPanel.swift +++ b/VoiceInk/Views/Recorder/MiniRecorderPanel.swift @@ -30,17 +30,20 @@ class MiniRecorderPanel: NSPanel { standardWindowButton(.closeButton)?.isHidden = true } - static func calculateWindowMetrics() -> NSRect { + static func calculateWindowMetrics(expanded: Bool = false) -> NSRect { guard let screen = NSScreen.main else { - return NSRect(x: 0, y: 0, width: 160, height: 34) + return NSRect(x: 0, y: 0, width: expanded ? 160 : 70, height: 34) } - let width: CGFloat = 160 + let compactWidth: CGFloat = 100 + let expandedWidth: CGFloat = 160 + let width = expanded ? expandedWidth : compactWidth let height: CGFloat = 34 let padding: CGFloat = 24 let visibleFrame = screen.visibleFrame - let xPosition = visibleFrame.midX - (width / 2) - 5 + let centerX = visibleFrame.midX - 5 + let xPosition = centerX - (width / 2) let yPosition = visibleFrame.minY + padding return NSRect( @@ -52,11 +55,29 @@ class MiniRecorderPanel: NSPanel { } func show() { - let metrics = MiniRecorderPanel.calculateWindowMetrics() + let metrics = MiniRecorderPanel.calculateWindowMetrics(expanded: false) setFrame(metrics, display: true) orderFrontRegardless() } + func expandWindow(completion: (() -> Void)? = nil) { + let expandedMetrics = MiniRecorderPanel.calculateWindowMetrics(expanded: true) + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.25 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + animator().setFrame(expandedMetrics, display: true) + }, completionHandler: completion) + } + + func collapseWindow(completion: (() -> Void)? = nil) { + let compactMetrics = MiniRecorderPanel.calculateWindowMetrics(expanded: false) + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.25 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + animator().setFrame(compactMetrics, display: true) + }, completionHandler: completion) + } + func hide(completion: @escaping () -> Void) { completion() } diff --git a/VoiceInk/Views/Recorder/MiniRecorderView.swift b/VoiceInk/Views/Recorder/MiniRecorderView.swift index 831a183..737f9d0 100644 --- a/VoiceInk/Views/Recorder/MiniRecorderView.swift +++ b/VoiceInk/Views/Recorder/MiniRecorderView.swift @@ -8,6 +8,7 @@ struct MiniRecorderView: View { @State private var showPowerModePopover = false @State private var showEnhancementPromptPopover = false + @State private var isHovering = false private var backgroundView: some View { ZStack { @@ -45,16 +46,47 @@ struct MiniRecorderView: View { } .overlay { HStack(spacing: 0) { - RecorderPromptButton(showPopover: $showEnhancementPromptPopover) + // Left button zone + Group { + if windowManager.isExpanded { + RecorderPromptButton(showPopover: $showEnhancementPromptPopover) + .transition(.scale(scale: 0.5).combined(with: .opacity)) + } + } + .frame(width: windowManager.isExpanded ? nil : 0) + .clipped() + .animation(.easeInOut(duration: 0.25), value: windowManager.isExpanded) + // Fixed visualizer zone statusView .frame(maxWidth: .infinity) .padding(.horizontal, 6) - RecorderPowerModeButton(showPopover: $showPowerModePopover) + // Right button zone + Group { + if windowManager.isExpanded { + RecorderPowerModeButton(showPopover: $showPowerModePopover) + .transition(.scale(scale: 0.5).combined(with: .opacity)) + } + } + .frame(width: windowManager.isExpanded ? nil : 0) + .clipped() + .animation(.easeInOut(duration: 0.25), value: windowManager.isExpanded) } .padding(.vertical, 8) } + .onHover { hovering in + isHovering = hovering + if hovering { + windowManager.expand() + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + if !isHovering { + windowManager.collapse() + } + } + } + } } } } diff --git a/VoiceInk/Views/Recorder/MiniWindowManager.swift b/VoiceInk/Views/Recorder/MiniWindowManager.swift index f083e88..3736a21 100644 --- a/VoiceInk/Views/Recorder/MiniWindowManager.swift +++ b/VoiceInk/Views/Recorder/MiniWindowManager.swift @@ -3,6 +3,7 @@ import AppKit class MiniWindowManager: ObservableObject { @Published var isVisible = false + @Published var isExpanded = false private var windowController: NSWindowController? private var miniPanel: MiniRecorderPanel? private let whisperState: WhisperState @@ -40,6 +41,26 @@ class MiniWindowManager: ObservableObject { miniPanel?.show() } + func expand() { + guard isVisible, !isExpanded else { return } + + withAnimation(.easeInOut(duration: 0.25)) { + isExpanded = true + } + + miniPanel?.expandWindow() + } + + func collapse() { + guard isVisible, isExpanded else { return } + + withAnimation(.easeInOut(duration: 0.25)) { + isExpanded = false + } + + miniPanel?.collapseWindow() + } + func hide() { guard isVisible else { return }