diff --git a/.gitignore b/.gitignore index 3cd7f95..2f9b0d4 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ build/ *.swo *~ TransFlow/TransFlow.xcodeproj/project.xcworkspace/xcuserdata +.cursor/plans/ diff --git a/TransFlow/TransFlow/ContentView.swift b/TransFlow/TransFlow/ContentView.swift index 18eccda..875d5cd 100644 --- a/TransFlow/TransFlow/ContentView.swift +++ b/TransFlow/TransFlow/ContentView.swift @@ -8,6 +8,8 @@ import Translation /// at app launch — not every time the user navigates to this tab. struct ContentView: View { @Bindable var viewModel: TransFlowViewModel + @Bindable var floatingPreviewManager: FloatingPreviewPanelManager + @Bindable var settings: AppSettings var body: some View { VStack(spacing: 0) { @@ -26,7 +28,11 @@ struct ContentView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) // ── Bottom: Unified live preview + controls ── - BottomPanelView(viewModel: viewModel) + BottomPanelView( + viewModel: viewModel, + floatingPreviewManager: floatingPreviewManager, + settings: settings + ) } .frame(minWidth: 640, minHeight: 460) // Translation session provider @@ -88,6 +94,8 @@ struct ContentView: View { /// Unified bottom panel containing the live transcription preview and controls. struct BottomPanelView: View { @Bindable var viewModel: TransFlowViewModel + @Bindable var floatingPreviewManager: FloatingPreviewPanelManager + @Bindable var settings: AppSettings var body: some View { VStack(spacing: 0) { @@ -101,7 +109,11 @@ struct BottomPanelView: View { livePreviewSection // ── Controls row ── - ControlBarView(viewModel: viewModel) + ControlBarView( + viewModel: viewModel, + floatingPreviewManager: floatingPreviewManager, + settings: settings + ) } .animation(.easeInOut(duration: 0.25), value: shouldShowPreview) .padding(.horizontal, 20) @@ -120,72 +132,30 @@ struct BottomPanelView: View { @ViewBuilder private var livePreviewSection: some View { if shouldShowPreview { - VStack(alignment: .leading, spacing: 3) { - if !viewModel.currentPartialText.isEmpty { - Text(viewModel.currentPartialText) - .font(.system(size: 13, weight: .regular)) - .foregroundStyle(.secondary) - .italic() - .lineLimit(3) - .frame(maxWidth: .infinity, alignment: .leading) - - if viewModel.translationService.isEnabled, - !viewModel.translationService.currentPartialTranslation.isEmpty { - Text(viewModel.translationService.currentPartialTranslation) - .font(.system(size: 12, weight: .regular)) - .foregroundStyle(.tertiary) - .italic() - .lineLimit(2) - .frame(maxWidth: .infinity, alignment: .leading) - } - } else { - // Listening indicator when active but no partial text yet - HStack(spacing: 6) { - TypingIndicatorView() - Text("control.listening") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.tertiary) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .frame(maxWidth: .infinity, minHeight: 40, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(.quaternary.opacity(0.3)) + LivePreviewContentView( + partialText: viewModel.currentPartialText, + partialTranslation: partialTranslationText, + isListening: isListening ) .transition(.opacity.combined(with: .move(edge: .bottom))) } } -} - -// MARK: - Typing Indicator -/// An animated three-dot indicator for the "Listening..." state. -struct TypingIndicatorView: View { - @State private var animating = false + private var isListening: Bool { + viewModel.listeningState == .active || viewModel.listeningState == .starting + } - var body: some View { - HStack(spacing: 3) { - ForEach(0..<3) { i in - Circle() - .fill(.tertiary) - .frame(width: 4, height: 4) - .offset(y: animating ? -2 : 2) - .animation( - .easeInOut(duration: 0.5) - .repeatForever(autoreverses: true) - .delay(Double(i) * 0.15), - value: animating - ) - } - } - .onAppear { animating = true } + private var partialTranslationText: String? { + guard viewModel.translationService.isEnabled else { return nil } + let partial = viewModel.translationService.currentPartialTranslation + return partial.isEmpty ? nil : partial } } #Preview { - ContentView(viewModel: TransFlowViewModel()) + ContentView( + viewModel: TransFlowViewModel(), + floatingPreviewManager: FloatingPreviewPanelManager(), + settings: AppSettings.shared + ) } diff --git a/TransFlow/TransFlow/Localizable.xcstrings b/TransFlow/TransFlow/Localizable.xcstrings index 9e2a72b..9ac9edb 100644 --- a/TransFlow/TransFlow/Localizable.xcstrings +++ b/TransFlow/TransFlow/Localizable.xcstrings @@ -194,6 +194,23 @@ } } }, + "control.pop_up_preview" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pop Up Preview" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "弹出预览" + } + } + } + }, "control.refresh_apps" : { "extractionState" : "manual", "localizations" : { @@ -381,6 +398,74 @@ } } }, + "floating_preview.close" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close preview" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关闭预览" + } + } + } + }, + "floating_preview.pin" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin on top" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "置顶" + } + } + } + }, + "floating_preview.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Live preview" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "实时预览" + } + } + } + }, + "floating_preview.unpin" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unpin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消置顶" + } + } + } + }, "history.delete" : { "extractionState" : "manual", "localizations" : { diff --git a/TransFlow/TransFlow/Services/FloatingPreviewPanelManager.swift b/TransFlow/TransFlow/Services/FloatingPreviewPanelManager.swift new file mode 100644 index 0000000..2462adf --- /dev/null +++ b/TransFlow/TransFlow/Services/FloatingPreviewPanelManager.swift @@ -0,0 +1,125 @@ +import SwiftUI +import AppKit + +/// Manages the lifecycle of the detachable floating live preview window. +@MainActor +@Observable +final class FloatingPreviewPanelManager: NSObject, NSWindowDelegate { + /// Whether the panel should stay above other app windows. + var isPinned: Bool = false + + private var panel: NSPanel? + private var hostingController: NSHostingController? + + /// Opens the panel if needed, updates its content, and brings it to front. + func show( + viewModel: TransFlowViewModel, + locale: Locale, + colorScheme: ColorScheme? + ) { + if panel == nil { + createPanel() + } + + updateRootView( + viewModel: viewModel, + locale: locale, + colorScheme: colorScheme + ) + applyPinState() + + panel?.orderFront(nil) + } + + /// Closes the panel (same behavior as clicking the red close button). + func close() { + panel?.close() + } + + /// Toggles the pin state and reapplies z-order behavior. + func togglePin() { + isPinned.toggle() + applyPinState() + + if isPinned { + panel?.orderFrontRegardless() + } + } + + func windowWillClose(_ notification: Notification) { + // Keep runtime-only pin behavior simple: closing the panel unpins it. + if panel == nil || notification.object as? NSPanel === panel { + isPinned = false + } + } + + private func createPanel() { + let styleMask: NSWindow.StyleMask = [ + .resizable, + .nonactivatingPanel, + ] + + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 200), + styleMask: styleMask, + backing: .buffered, + defer: true + ) + + panel.isMovableByWindowBackground = true + panel.hidesOnDeactivate = false + panel.isReleasedWhenClosed = false + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.animationBehavior = .utilityWindow + panel.minSize = NSSize(width: 360, height: 160) + panel.delegate = self + panel.setFrameAutosaveName("TransFlow.FloatingPreviewPanel") + + self.panel = panel + } + + private func updateRootView( + viewModel: TransFlowViewModel, + locale: Locale, + colorScheme: ColorScheme? + ) { + let rootView = AnyView( + FloatingPreviewView( + viewModel: viewModel, + panelManager: self + ) + .environment(\.locale, locale) + .preferredColorScheme(colorScheme) + ) + + if let hostingController { + hostingController.rootView = rootView + panel?.contentViewController = hostingController + } else { + let hostingController = NSHostingController(rootView: rootView) + self.hostingController = hostingController + panel?.contentViewController = hostingController + } + } + + private func applyPinState() { + guard let panel else { return } + + panel.level = isPinned ? .floating : .normal + panel.isFloatingPanel = isPinned + + if isPinned { + panel.collectionBehavior = [ + .canJoinAllSpaces, + .fullScreenAuxiliary, + ] + } else { + panel.collectionBehavior = [ + .moveToActiveSpace, + .fullScreenAuxiliary, + ] + } + } +} diff --git a/TransFlow/TransFlow/TransFlowApp.swift b/TransFlow/TransFlow/TransFlowApp.swift index 039f0a4..4a982e0 100644 --- a/TransFlow/TransFlow/TransFlowApp.swift +++ b/TransFlow/TransFlow/TransFlowApp.swift @@ -4,13 +4,19 @@ import SwiftUI struct TransFlowApp: App { @State private var settings = AppSettings.shared @State private var updateChecker = UpdateChecker.shared + @State private var viewModel = TransFlowViewModel() + @State private var floatingPreviewManager = FloatingPreviewPanelManager() /// Reference to the shared logger so its log file is created at launch. private let errorLogger = ErrorLogger.shared var body: some Scene { WindowGroup { - MainView() + MainView( + viewModel: viewModel, + floatingPreviewManager: floatingPreviewManager, + settings: settings + ) .environment(\.locale, settings.locale) .preferredColorScheme(settings.appAppearance.colorScheme) .onAppear { diff --git a/TransFlow/TransFlow/Views/ControlBarView.swift b/TransFlow/TransFlow/Views/ControlBarView.swift index e59918b..7e8ea0d 100644 --- a/TransFlow/TransFlow/Views/ControlBarView.swift +++ b/TransFlow/TransFlow/Views/ControlBarView.swift @@ -5,6 +5,8 @@ import AppKit /// Apple-inspired clean layout with a prominent circular record button. struct ControlBarView: View { @Bindable var viewModel: TransFlowViewModel + @Bindable var floatingPreviewManager: FloatingPreviewPanelManager + @Bindable var settings: AppSettings var body: some View { HStack(spacing: 0) { @@ -152,6 +154,9 @@ struct ControlBarView: View { // Translation controls translationControls + // Floating preview button + popUpPreviewButton + // Export button exportButton } @@ -364,6 +369,28 @@ struct ControlBarView: View { // MARK: - Export Button + private var popUpPreviewButton: some View { + Button { + floatingPreviewManager.show( + viewModel: viewModel, + locale: settings.locale, + colorScheme: settings.appAppearance.colorScheme + ) + } label: { + Image(systemName: "arrow.up.left.and.arrow.down.right") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.primary) + .frame(width: 26, height: 26) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(.quaternary.opacity(0.5)) + ) + } + .buttonStyle(.plain) + .help(Text("control.pop_up_preview")) + .accessibilityLabel(Text("control.pop_up_preview")) + } + private var exportButton: some View { Button { Task { diff --git a/TransFlow/TransFlow/Views/FloatingPreviewView.swift b/TransFlow/TransFlow/Views/FloatingPreviewView.swift new file mode 100644 index 0000000..fdbe984 --- /dev/null +++ b/TransFlow/TransFlow/Views/FloatingPreviewView.swift @@ -0,0 +1,234 @@ +import SwiftUI + +/// Content rendered inside the detachable floating preview panel. +struct FloatingPreviewView: View { + @Bindable var viewModel: TransFlowViewModel + @Bindable var panelManager: FloatingPreviewPanelManager + @State private var isHovering = false + + private let captionBottomAnchor = "floating-caption-bottom" + private let maxFinalizedSentenceCount = 4 + + var body: some View { + ZStack(alignment: .topTrailing) { + captionCard + controlOverlay + } + .padding(12) + .frame(minWidth: 340, idealWidth: 390, minHeight: 96, alignment: .topLeading) + .glassEffect(.regular, in: .rect(cornerRadius: 14)) + .contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.16)) { + isHovering = hovering + } + } + } + + private var captionCard: some View { + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 3) { + ForEach(captionLines) { line in + captionLineView(line) + } + + Color.clear + .frame(height: 8) + .id(captionBottomAnchor) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, minHeight: 80, maxHeight: .infinity) + .padding(.trailing, 56) + .onAppear { + scrollToBottom(with: proxy, animated: false) + } + .onChange(of: captionLines) { _, _ in + scrollToBottom(with: proxy) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + + private var controlOverlay: some View { + HStack(spacing: 8) { + pinButton + closeButton + } + // Keep controls out of the titlebar drag zone for reliable clicks. + .padding(.top, 18) + .padding(.trailing, 10) + .opacity(shouldShowControls ? 1 : 0) + .allowsHitTesting(shouldShowControls) + .contentShape(Rectangle()) + .zIndex(5) + } + + private var pinButton: some View { + Button { + panelManager.togglePin() + } label: { + Image(systemName: panelManager.isPinned ? "pin.fill" : "pin") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(panelManager.isPinned ? Color.accentColor : Color.secondary) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .glassEffect(.regular, in: .circle) + .help(Text(panelManager.isPinned ? "floating_preview.unpin" : "floating_preview.pin")) + .accessibilityLabel(Text(panelManager.isPinned ? "floating_preview.unpin" : "floating_preview.pin")) + } + + private var closeButton: some View { + Button { + panelManager.close() + } label: { + Image(systemName: "xmark") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.secondary) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .glassEffect(.regular, in: .circle) + .help(Text("floating_preview.close")) + .accessibilityLabel(Text("floating_preview.close")) + } + + @ViewBuilder + private func captionLineView(_ line: CaptionLine) -> some View { + if line.isPartial { + Text(line.text) + .font(line.kind == .source ? .system(size: 15, weight: .regular) : .system(size: 12, weight: .regular)) + .foregroundStyle(lineForegroundStyle(for: line.kind)) + .italic() + .frame(maxWidth: .infinity, alignment: .leading) + } else { + Text(line.text) + .font(line.kind == .source ? .system(size: 15, weight: .regular) : .system(size: 12, weight: .regular)) + .foregroundStyle(lineForegroundStyle(for: line.kind)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private func lineForegroundStyle(for kind: CaptionLine.Kind) -> AnyShapeStyle { + switch kind { + case .source: + AnyShapeStyle(.primary) + case .translation: + AnyShapeStyle(.secondary) + case .placeholder: + AnyShapeStyle(.tertiary) + } + } + + private var captionLines: [CaptionLine] { + let showTranslation = viewModel.translationService.isEnabled + var lines: [CaptionLine] = [] + + for sentence in viewModel.sentences.suffix(maxFinalizedSentenceCount) { + let sourceText = sentence.text.trimmingCharacters(in: .whitespacesAndNewlines) + if !sourceText.isEmpty { + lines.append( + CaptionLine( + id: "sentence-source-\(sentence.id.uuidString)", + text: sourceText, + kind: .source + ) + ) + } + + if showTranslation, + let translation = sentence.translation?.trimmingCharacters(in: .whitespacesAndNewlines), + !translation.isEmpty { + lines.append( + CaptionLine( + id: "sentence-translation-\(sentence.id.uuidString)", + text: translation, + kind: .translation + ) + ) + } + } + + let partialSource = viewModel.currentPartialText.trimmingCharacters(in: .whitespacesAndNewlines) + if !partialSource.isEmpty { + lines.append( + CaptionLine( + id: "partial-source", + text: partialSource, + kind: .source, + isPartial: true + ) + ) + + if showTranslation, + let partialTranslationText, + !partialTranslationText.isEmpty { + lines.append( + CaptionLine( + id: "partial-translation", + text: partialTranslationText, + kind: .translation, + isPartial: true + ) + ) + } + } + + if lines.isEmpty { + let placeholderText = isListening + ? String(localized: "control.listening") + : String(localized: "control.start_transcription") + lines.append( + CaptionLine( + id: "placeholder", + text: placeholderText, + kind: .placeholder + ) + ) + } + + return lines + } + + private func scrollToBottom(with proxy: ScrollViewProxy, animated: Bool = true) { + let action = { proxy.scrollTo(captionBottomAnchor, anchor: .bottom) } + if animated { + withAnimation(.easeOut(duration: 0.12)) { + action() + } + } else { + action() + } + } + + private var isListening: Bool { + viewModel.listeningState == .active || viewModel.listeningState == .starting + } + + private var shouldShowControls: Bool { + isHovering || panelManager.isPinned + } + + private var partialTranslationText: String? { + guard viewModel.translationService.isEnabled else { return nil } + let partial = viewModel.translationService.currentPartialTranslation + .trimmingCharacters(in: .whitespacesAndNewlines) + return partial.isEmpty ? nil : partial + } +} + +private struct CaptionLine: Identifiable, Equatable { + enum Kind: Equatable { + case source + case translation + case placeholder + } + + let id: String + let text: String + let kind: Kind + var isPartial: Bool = false +} diff --git a/TransFlow/TransFlow/Views/LivePreviewContentView.swift b/TransFlow/TransFlow/Views/LivePreviewContentView.swift new file mode 100644 index 0000000..35eab21 --- /dev/null +++ b/TransFlow/TransFlow/Views/LivePreviewContentView.swift @@ -0,0 +1,74 @@ +import SwiftUI + +/// Reusable live preview card used by the bottom panel. +struct LivePreviewContentView: View { + let partialText: String + let partialTranslation: String? + let isListening: Bool + var idleTextKey: LocalizedStringKey? = nil + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + if !partialText.isEmpty { + Text(partialText) + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(.secondary) + .italic() + .lineLimit(3) + .frame(maxWidth: .infinity, alignment: .leading) + + if let partialTranslation, !partialTranslation.isEmpty { + Text(partialTranslation) + .font(.system(size: 12, weight: .regular)) + .foregroundStyle(.tertiary) + .italic() + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + } + } else if isListening { + HStack(spacing: 6) { + TypingIndicatorView() + Text("control.listening") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } else if let idleTextKey { + Text(idleTextKey) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .frame(maxWidth: .infinity, minHeight: 40, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.quaternary.opacity(0.3)) + ) + } +} + +/// An animated three-dot indicator for the "Listening..." state. +struct TypingIndicatorView: View { + @State private var animating = false + + var body: some View { + HStack(spacing: 3) { + ForEach(0..<3) { i in + Circle() + .fill(.tertiary) + .frame(width: 4, height: 4) + .offset(y: animating ? -2 : 2) + .animation( + .easeInOut(duration: 0.5) + .repeatForever(autoreverses: true) + .delay(Double(i) * 0.15), + value: animating + ) + } + } + .onAppear { animating = true } + } +} diff --git a/TransFlow/TransFlow/Views/MainView.swift b/TransFlow/TransFlow/Views/MainView.swift index 13d497a..f19cf81 100644 --- a/TransFlow/TransFlow/Views/MainView.swift +++ b/TransFlow/TransFlow/Views/MainView.swift @@ -3,14 +3,16 @@ import SwiftUI /// Root view with NavigationSplitView providing a collapsible sidebar. /// The sidebar starts collapsed (detail only) for a clean initial appearance. /// -/// The ViewModel is owned here so it survives sidebar navigation -/// (switching between Transcription / History / Settings). -/// This prevents a new session file from being created every time the user -/// navigates back to the transcription page. +/// The shared ViewModel is injected from app root so it survives sidebar navigation +/// (switching between Transcription / History / Settings) and can be reused by +/// other UI surfaces like the floating preview window. struct MainView: View { + @Bindable var viewModel: TransFlowViewModel + @Bindable var floatingPreviewManager: FloatingPreviewPanelManager + @Bindable var settings: AppSettings + @State private var selectedDestination: SidebarDestination = .transcription @State private var columnVisibility: NavigationSplitViewVisibility = .detailOnly - @State private var viewModel = TransFlowViewModel() var body: some View { NavigationSplitView(columnVisibility: $columnVisibility) { @@ -28,7 +30,11 @@ struct MainView: View { private var detailView: some View { switch selectedDestination { case .transcription: - ContentView(viewModel: viewModel) + ContentView( + viewModel: viewModel, + floatingPreviewManager: floatingPreviewManager, + settings: settings + ) case .history: HistoryView() case .settings: diff --git a/specs/015-floating-live-preview/raw.md b/specs/015-floating-live-preview/raw.md new file mode 100644 index 0000000..391b5b8 --- /dev/null +++ b/specs/015-floating-live-preview/raw.md @@ -0,0 +1,3 @@ +### UI polish: + +a transparent floating window (macOS 26 Liquid Glass style) that shows the live transcription and the last transcribed text. diff --git a/specs/015-floating-live-preview/spec.md b/specs/015-floating-live-preview/spec.md new file mode 100644 index 0000000..c7bf58b --- /dev/null +++ b/specs/015-floating-live-preview/spec.md @@ -0,0 +1,135 @@ +# 015 - Floating Live Preview (Agent Spec) + +## Background + +Users need a detachable live preview window for transcription so they can keep live text visible while using other apps. + +Original request (`raw.md`): open a floating window that shows live transcription content. + +## Goal + +Add a `Pop Up` action from the main transcription UI that opens a draggable floating window. +The floating window must support: + +- easy close +- pin/unpin (always-on-top toggle) +- live sync with the same transcription state shown in the main window + +## Scope + +### In scope (implemented) + +- Keep existing bottom preview in place. +- Add reusable live preview component used by both main window and floating window. +- Add AppKit-backed floating panel manager (`NSPanel`) with show/close/pin lifecycle. +- Add floating preview window UI with toolbar controls (`Pin`, `Close`). +- Add `Pop Up` button in control bar. +- Add i18n keys in `Localizable.xcstrings` for `en` and `zh-Hans`. +- Ensure build passes. + +### Out of scope (for later UI iteration) + +- Liquid Glass / transparent visual style polish. +- Advanced panel chrome customization and refined visual hierarchy. +- Persisting panel pin/open state across app relaunch. + +## User Experience Requirements + +### Main entry point + +- In the transcription screen control bar, user can click `Pop Up Preview`. + +### Floating window behavior + +- If no floating panel exists: create and show one. +- If panel exists: bring it to front and refresh content/theme/locale. +- Panel can be dragged anywhere on screen. +- User can close using: + - standard macOS close button + - in-panel close icon + +### Pin behavior + +- `Pin on top`: panel level becomes floating above other app windows. +- `Unpin`: panel returns to normal level. +- Pin state is runtime-only (reset when window closes). + +## Architecture / Data Flow + +- Single shared `TransFlowViewModel` instance is owned at app root and passed down. +- Both bottom preview and floating preview read from the same view model fields: + - `currentPartialText` + - `translationService.currentPartialTranslation` + - `listeningState` + +```mermaid +flowchart LR +controlBar[ControlBarPopUpButton] --> panelManager[FloatingPreviewPanelManager] +panelManager --> panel[NSPanel] +panel --> floatingView[FloatingPreviewView] +floatingView --> previewComponent[LivePreviewContentView] +previewComponent --> viewModel[SharedTransFlowViewModel] +bottomPanel[BottomPanelView] --> previewComponent +``` + +## File Map (Implemented) + +- `TransFlow/TransFlow/Views/LivePreviewContentView.swift` + Reusable preview card + `TypingIndicatorView`. + +- `TransFlow/TransFlow/Views/FloatingPreviewView.swift` + Floating window SwiftUI content; pin/close controls; embeds `LivePreviewContentView`. + +- `TransFlow/TransFlow/Services/FloatingPreviewPanelManager.swift` + Owns `NSPanel`, show/close/pin logic, panel configuration. + +- `TransFlow/TransFlow/TransFlowApp.swift` + Owns app-lifetime shared instances: + - `TransFlowViewModel` + - `FloatingPreviewPanelManager` + - existing `AppSettings` + +- `TransFlow/TransFlow/Views/MainView.swift` + Receives injected shared state/manager from app root. + +- `TransFlow/TransFlow/ContentView.swift` + Uses injected manager/settings and reuses extracted preview view. + +- `TransFlow/TransFlow/Views/ControlBarView.swift` + Adds `Pop Up` button to open/focus floating preview. + +- `TransFlow/TransFlow/Localizable.xcstrings` + Added keys: + - `control.pop_up_preview` + - `floating_preview.title` + - `floating_preview.pin` + - `floating_preview.unpin` + - `floating_preview.close` + +## Panel Technical Contract + +- Panel type: `NSPanel` +- Drag support: `isMovableByWindowBackground = true` +- Pinning: + - pinned -> `level = .floating` + - unpinned -> `level = .normal` +- Space/fullscreen behavior: + - base: `.moveToActiveSpace`, `.fullScreenAuxiliary` + - pinned additionally: `.canJoinAllSpaces` + +## Acceptance Criteria + +- `Pop Up` button is visible in transcription control bar. +- Clicking `Pop Up` opens a floating preview window. +- Floating preview content updates live while transcribing. +- Window can be dragged to arbitrary screen positions. +- `Pin` keeps window above other app windows. +- `Unpin` returns normal z-order behavior. +- Window can be closed from both standard close control and in-window close control. +- App builds successfully via `xcodebuild`. + +## Follow-up Suggestions (UI polish pass) + +- Implement transparent/Liquid-Glass style surface and toolbar treatment. +- Improve iconography and spacing for pin/close controls. +- Consider a compact mode and remembered window frame/state.