From bedc76bf6ee81df8cc3e72ce3d663187724f3973 Mon Sep 17 00:00:00 2001 From: Chun Hu Date: Fri, 13 Feb 2026 17:29:35 +0800 Subject: [PATCH 1/9] feat: add detachable floating live preview panel Introduce a pop-up preview window with pin and close controls so users can keep live transcription visible above other apps while keeping the existing bottom preview workflow. Co-authored-by: Cursor --- TransFlow/TransFlow/ContentView.swift | 90 +++++-------- TransFlow/TransFlow/Localizable.xcstrings | 85 ++++++++++++ .../FloatingPreviewPanelManager.swift | 127 ++++++++++++++++++ TransFlow/TransFlow/TransFlowApp.swift | 8 +- .../TransFlow/Views/ControlBarView.swift | 27 ++++ .../TransFlow/Views/FloatingPreviewView.swift | 71 ++++++++++ .../Views/LivePreviewContentView.swift | 74 ++++++++++ TransFlow/TransFlow/Views/MainView.swift | 18 ++- 8 files changed, 433 insertions(+), 67 deletions(-) create mode 100644 TransFlow/TransFlow/Services/FloatingPreviewPanelManager.swift create mode 100644 TransFlow/TransFlow/Views/FloatingPreviewView.swift create mode 100644 TransFlow/TransFlow/Views/LivePreviewContentView.swift 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..a94ff08 --- /dev/null +++ b/TransFlow/TransFlow/Services/FloatingPreviewPanelManager.swift @@ -0,0 +1,127 @@ +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?.makeKeyAndOrderFront(nil) + panel?.orderFrontRegardless() + } + + /// 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 = [ + .titled, + .closable, + .resizable, + .fullSizeContentView, + ] + + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 210), + styleMask: styleMask, + backing: .buffered, + defer: true + ) + + panel.title = String(localized: "floating_preview.title") + panel.titleVisibility = .hidden + panel.titlebarAppearsTransparent = true + panel.isMovableByWindowBackground = true + panel.hidesOnDeactivate = false + panel.isReleasedWhenClosed = false + panel.minSize = NSSize(width: 320, height: 150) + panel.delegate = self + panel.setFrameAutosaveName("TransFlow.FloatingPreviewPanel") + + panel.standardWindowButton(.miniaturizeButton)?.isHidden = true + panel.standardWindowButton(.zoomButton)?.isHidden = true + + 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 + + var behavior: NSWindow.CollectionBehavior = [ + .moveToActiveSpace, + .fullScreenAuxiliary, + ] + if isPinned { + behavior.insert(.canJoinAllSpaces) + } + panel.collectionBehavior = behavior + } +} 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..a7f428b --- /dev/null +++ b/TransFlow/TransFlow/Views/FloatingPreviewView.swift @@ -0,0 +1,71 @@ +import SwiftUI + +/// Content rendered inside the detachable floating preview panel. +struct FloatingPreviewView: View { + @Bindable var viewModel: TransFlowViewModel + @Bindable var panelManager: FloatingPreviewPanelManager + + var body: some View { + VStack(spacing: 10) { + toolbar + + LivePreviewContentView( + partialText: viewModel.currentPartialText, + partialTranslation: partialTranslationText, + isListening: isListening, + idleTextKey: "control.start_transcription" + ) + } + .padding(12) + .frame(minWidth: 320, idealWidth: 380, minHeight: 140, alignment: .topLeading) + .background(.background) + } + + private var toolbar: some View { + HStack(spacing: 8) { + Text("floating_preview.title") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + + Spacer(minLength: 8) + + 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: 22, height: 22) + } + .buttonStyle(.plain) + .help(Text(panelManager.isPinned ? "floating_preview.unpin" : "floating_preview.pin")) + .accessibilityLabel(Text(panelManager.isPinned ? "floating_preview.unpin" : "floating_preview.pin")) + + Button { + panelManager.close() + } label: { + Image(systemName: "xmark") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.secondary) + .frame(width: 22, height: 22) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(.quaternary.opacity(0.45)) + ) + } + .buttonStyle(.plain) + .help(Text("floating_preview.close")) + .accessibilityLabel(Text("floating_preview.close")) + } + } + + private var isListening: Bool { + viewModel.listeningState == .active || viewModel.listeningState == .starting + } + + private var partialTranslationText: String? { + guard viewModel.translationService.isEnabled else { return nil } + let partial = viewModel.translationService.currentPartialTranslation + return partial.isEmpty ? nil : partial + } +} diff --git a/TransFlow/TransFlow/Views/LivePreviewContentView.swift b/TransFlow/TransFlow/Views/LivePreviewContentView.swift new file mode 100644 index 0000000..117bec6 --- /dev/null +++ b/TransFlow/TransFlow/Views/LivePreviewContentView.swift @@ -0,0 +1,74 @@ +import SwiftUI + +/// Reusable live preview card used by both the bottom panel and floating window. +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: From 58e872c7e9704ec8b6658319861d54ca8c30dd4d Mon Sep 17 00:00:00 2001 From: Chun Hu Date: Sat, 14 Feb 2026 10:50:54 +0800 Subject: [PATCH 2/9] docs: add floating live preview agent spec Document the implemented floating preview architecture, behavior, file map, and acceptance criteria so future agents can iterate safely from a shared baseline. Co-authored-by: Cursor --- specs/015-floating-live-preview/spec.md | 135 ++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 specs/015-floating-live-preview/spec.md 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. From c0040d5bd2bf4ceb35c4ca3ff9fcac2a78f33a92 Mon Sep 17 00:00:00 2001 From: Chun Hu Date: Sat, 14 Feb 2026 10:56:24 +0800 Subject: [PATCH 3/9] UI polish doc --- specs/015-floating-live-preview/raw.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 specs/015-floating-live-preview/raw.md 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. From 923b81b865d1a8dc5497501f9ef60d86e012e2af Mon Sep 17 00:00:00 2001 From: Chun Hu Date: Sat, 14 Feb 2026 13:51:27 +0800 Subject: [PATCH 4/9] fix: improve floating preview readability and controls Hide native titlebar traffic lights to avoid duplicate close affordances and make long live/last transcripts readable with capped scrollable cards. Co-authored-by: Cursor --- TransFlow/TransFlow/Localizable.xcstrings | 51 ++++++++ .../FloatingPreviewPanelManager.swift | 6 + .../TransFlow/Views/FloatingPreviewView.swift | 115 ++++++++++++++++-- .../Views/LivePreviewContentView.swift | 50 ++++++-- 4 files changed, 202 insertions(+), 20 deletions(-) diff --git a/TransFlow/TransFlow/Localizable.xcstrings b/TransFlow/TransFlow/Localizable.xcstrings index 9ac9edb..a6678f1 100644 --- a/TransFlow/TransFlow/Localizable.xcstrings +++ b/TransFlow/TransFlow/Localizable.xcstrings @@ -415,6 +415,57 @@ } } }, + "floating_preview.live_section" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Live" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "实时" + } + } + } + }, + "floating_preview.last_section" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Last" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上一句" + } + } + } + }, + "floating_preview.no_last_sentence" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No completed sentence yet" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂时还没有完成的句子" + } + } + } + }, "floating_preview.pin" : { "extractionState" : "manual", "localizations" : { diff --git a/TransFlow/TransFlow/Services/FloatingPreviewPanelManager.swift b/TransFlow/TransFlow/Services/FloatingPreviewPanelManager.swift index a94ff08..9cf12f0 100644 --- a/TransFlow/TransFlow/Services/FloatingPreviewPanelManager.swift +++ b/TransFlow/TransFlow/Services/FloatingPreviewPanelManager.swift @@ -72,13 +72,19 @@ final class FloatingPreviewPanelManager: NSObject, NSWindowDelegate { panel.title = String(localized: "floating_preview.title") panel.titleVisibility = .hidden panel.titlebarAppearsTransparent = true + panel.titlebarSeparatorStyle = .none 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: 320, height: 150) panel.delegate = self panel.setFrameAutosaveName("TransFlow.FloatingPreviewPanel") + panel.standardWindowButton(.closeButton)?.isHidden = true panel.standardWindowButton(.miniaturizeButton)?.isHidden = true panel.standardWindowButton(.zoomButton)?.isHidden = true diff --git a/TransFlow/TransFlow/Views/FloatingPreviewView.swift b/TransFlow/TransFlow/Views/FloatingPreviewView.swift index a7f428b..5d65347 100644 --- a/TransFlow/TransFlow/Views/FloatingPreviewView.swift +++ b/TransFlow/TransFlow/Views/FloatingPreviewView.swift @@ -9,16 +9,35 @@ struct FloatingPreviewView: View { VStack(spacing: 10) { toolbar - LivePreviewContentView( - partialText: viewModel.currentPartialText, - partialTranslation: partialTranslationText, - isListening: isListening, - idleTextKey: "control.start_transcription" - ) + VStack(alignment: .leading, spacing: 12) { + sectionHeader("floating_preview.last_section") + lastSentenceCard + + sectionHeader("floating_preview.live_section") + LivePreviewContentView( + partialText: viewModel.currentPartialText, + partialTranslation: partialTranslationText, + isListening: isListening, + idleTextKey: "control.start_transcription", + usesGlassStyle: true, + maxContentHeight: 110, + allowsScrolling: true, + prioritizeNewestText: true + ) + } } .padding(12) - .frame(minWidth: 320, idealWidth: 380, minHeight: 140, alignment: .topLeading) - .background(.background) + .frame(minWidth: 340, idealWidth: 390, minHeight: 210, alignment: .topLeading) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(.ultraThinMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color.white.opacity(0.22), lineWidth: 0.8) + ) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .shadow(color: .black.opacity(0.2), radius: 16, x: 0, y: 10) } private var toolbar: some View { @@ -35,7 +54,15 @@ struct FloatingPreviewView: View { Image(systemName: panelManager.isPinned ? "pin.fill" : "pin") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(panelManager.isPinned ? Color.accentColor : Color.secondary) - .frame(width: 22, height: 22) + .frame(width: 24, height: 24) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(.quaternary.opacity(panelManager.isPinned ? 0.38 : 0.25)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color.white.opacity(0.16), lineWidth: 0.6) + ) } .buttonStyle(.plain) .help(Text(panelManager.isPinned ? "floating_preview.unpin" : "floating_preview.pin")) @@ -47,10 +74,14 @@ struct FloatingPreviewView: View { Image(systemName: "xmark") .font(.system(size: 11, weight: .bold)) .foregroundStyle(.secondary) - .frame(width: 22, height: 22) + .frame(width: 24, height: 24) .background( RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(.quaternary.opacity(0.45)) + .fill(.quaternary.opacity(0.3)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color.white.opacity(0.16), lineWidth: 0.6) ) } .buttonStyle(.plain) @@ -59,10 +90,72 @@ struct FloatingPreviewView: View { } } + private func sectionHeader(_ key: LocalizedStringKey) -> some View { + Text(key) + .font(.system(size: 11, weight: .semibold)) + .textCase(.uppercase) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private var lastSentenceCard: some View { + if let lastSentence = viewModel.sentences.last { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 4) { + Text(lastSentence.text) + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + if let translation = lastSentenceTranslation, !translation.isEmpty { + Text(translation) + .font(.system(size: 12, weight: .regular)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .frame(maxWidth: .infinity, minHeight: 40, maxHeight: 110, alignment: .topLeading) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.regularMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color.white.opacity(0.18), lineWidth: 0.6) + ) + } else { + Text("floating_preview.no_last_sentence") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, minHeight: 40, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.regularMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color.white.opacity(0.14), lineWidth: 0.6) + ) + } + } + private var isListening: Bool { viewModel.listeningState == .active || viewModel.listeningState == .starting } + private var lastSentenceTranslation: String? { + guard viewModel.translationService.isEnabled else { return nil } + return viewModel.sentences.last?.translation + } + private var partialTranslationText: String? { guard viewModel.translationService.isEnabled else { return nil } let partial = viewModel.translationService.currentPartialTranslation diff --git a/TransFlow/TransFlow/Views/LivePreviewContentView.swift b/TransFlow/TransFlow/Views/LivePreviewContentView.swift index 117bec6..bd0f7a7 100644 --- a/TransFlow/TransFlow/Views/LivePreviewContentView.swift +++ b/TransFlow/TransFlow/Views/LivePreviewContentView.swift @@ -6,15 +6,53 @@ struct LivePreviewContentView: View { let partialTranslation: String? let isListening: Bool var idleTextKey: LocalizedStringKey? = nil + var usesGlassStyle: Bool = false + var maxContentHeight: CGFloat? = nil + var allowsScrolling: Bool = false + var prioritizeNewestText: Bool = false var body: some View { + Group { + if allowsScrolling { + ScrollView(.vertical, showsIndicators: false) { + content + .frame(maxWidth: .infinity, alignment: .leading) + } + } else { + content + } + } + .frame(maxWidth: .infinity, minHeight: 40, maxHeight: maxContentHeight, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(cardFillStyle) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(usesGlassStyle ? Color.white.opacity(0.18) : Color.clear, lineWidth: 0.6) + ) + } + + private var cardFillStyle: AnyShapeStyle { + if usesGlassStyle { + return AnyShapeStyle(.ultraThinMaterial) + } + return AnyShapeStyle(.quaternary.opacity(0.3)) + } + + @ViewBuilder + private var content: some View { VStack(alignment: .leading, spacing: 3) { if !partialText.isEmpty { Text(partialText) .font(.system(size: 13, weight: .regular)) .foregroundStyle(.secondary) .italic() - .lineLimit(3) + .lineLimit(allowsScrolling ? nil : 3) + .truncationMode(prioritizeNewestText ? .head : .tail) + .fixedSize(horizontal: false, vertical: allowsScrolling) .frame(maxWidth: .infinity, alignment: .leading) if let partialTranslation, !partialTranslation.isEmpty { @@ -22,7 +60,8 @@ struct LivePreviewContentView: View { .font(.system(size: 12, weight: .regular)) .foregroundStyle(.tertiary) .italic() - .lineLimit(2) + .lineLimit(allowsScrolling ? nil : 2) + .fixedSize(horizontal: false, vertical: allowsScrolling) .frame(maxWidth: .infinity, alignment: .leading) } } else if isListening { @@ -40,13 +79,6 @@ struct LivePreviewContentView: View { .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)) - ) } } From 65a03f164cd1c4775c0d6e69ddb4c8d8961ee62a Mon Sep 17 00:00:00 2001 From: Chun Hu Date: Sat, 14 Feb 2026 15:39:01 +0800 Subject: [PATCH 5/9] feat: simplify floating preview into live caption mode Replace the multi-section floating preview with a compact auto-scrolling bilingual caption card and hover-reveal controls, and fix pinned window behavior by applying mutually exclusive collection behaviors. Co-authored-by: Cursor --- TransFlow/TransFlow/Localizable.xcstrings | 51 --- .../FloatingPreviewPanelManager.swift | 19 +- .../TransFlow/Views/FloatingPreviewView.swift | 330 ++++++++++++------ .../Views/LivePreviewContentView.swift | 52 +-- 4 files changed, 239 insertions(+), 213 deletions(-) diff --git a/TransFlow/TransFlow/Localizable.xcstrings b/TransFlow/TransFlow/Localizable.xcstrings index a6678f1..9ac9edb 100644 --- a/TransFlow/TransFlow/Localizable.xcstrings +++ b/TransFlow/TransFlow/Localizable.xcstrings @@ -415,57 +415,6 @@ } } }, - "floating_preview.live_section" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Live" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "实时" - } - } - } - }, - "floating_preview.last_section" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Last" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "上一句" - } - } - } - }, - "floating_preview.no_last_sentence" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No completed sentence yet" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "暂时还没有完成的句子" - } - } - } - }, "floating_preview.pin" : { "extractionState" : "manual", "localizations" : { diff --git a/TransFlow/TransFlow/Services/FloatingPreviewPanelManager.swift b/TransFlow/TransFlow/Services/FloatingPreviewPanelManager.swift index 9cf12f0..4e6f650 100644 --- a/TransFlow/TransFlow/Services/FloatingPreviewPanelManager.swift +++ b/TransFlow/TransFlow/Services/FloatingPreviewPanelManager.swift @@ -63,7 +63,7 @@ final class FloatingPreviewPanelManager: NSObject, NSWindowDelegate { ] let panel = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 210), + contentRect: NSRect(x: 0, y: 0, width: 520, height: 120), styleMask: styleMask, backing: .buffered, defer: true @@ -80,7 +80,7 @@ final class FloatingPreviewPanelManager: NSObject, NSWindowDelegate { panel.backgroundColor = .clear panel.hasShadow = true panel.animationBehavior = .utilityWindow - panel.minSize = NSSize(width: 320, height: 150) + panel.minSize = NSSize(width: 360, height: 100) panel.delegate = self panel.setFrameAutosaveName("TransFlow.FloatingPreviewPanel") @@ -121,13 +121,16 @@ final class FloatingPreviewPanelManager: NSObject, NSWindowDelegate { panel.level = isPinned ? .floating : .normal panel.isFloatingPanel = isPinned - var behavior: NSWindow.CollectionBehavior = [ - .moveToActiveSpace, - .fullScreenAuxiliary, - ] if isPinned { - behavior.insert(.canJoinAllSpaces) + panel.collectionBehavior = [ + .canJoinAllSpaces, + .fullScreenAuxiliary, + ] + } else { + panel.collectionBehavior = [ + .moveToActiveSpace, + .fullScreenAuxiliary, + ] } - panel.collectionBehavior = behavior } } diff --git a/TransFlow/TransFlow/Views/FloatingPreviewView.swift b/TransFlow/TransFlow/Views/FloatingPreviewView.swift index 5d65347..f1b5735 100644 --- a/TransFlow/TransFlow/Views/FloatingPreviewView.swift +++ b/TransFlow/TransFlow/Views/FloatingPreviewView.swift @@ -4,30 +4,19 @@ import SwiftUI 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 = 10 + private let captionViewportHeight: CGFloat = 46 var body: some View { - VStack(spacing: 10) { - toolbar - - VStack(alignment: .leading, spacing: 12) { - sectionHeader("floating_preview.last_section") - lastSentenceCard - - sectionHeader("floating_preview.live_section") - LivePreviewContentView( - partialText: viewModel.currentPartialText, - partialTranslation: partialTranslationText, - isListening: isListening, - idleTextKey: "control.start_transcription", - usesGlassStyle: true, - maxContentHeight: 110, - allowsScrolling: true, - prioritizeNewestText: true - ) - } + ZStack(alignment: .topTrailing) { + captionCard + controlOverlay } .padding(12) - .frame(minWidth: 340, idealWidth: 390, minHeight: 210, alignment: .topLeading) + .frame(minWidth: 340, idealWidth: 390, minHeight: 96, alignment: .topLeading) .background( RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(.ultraThinMaterial) @@ -38,112 +27,216 @@ struct FloatingPreviewView: View { ) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .shadow(color: .black.opacity(0.2), radius: 16, x: 0, y: 10) + .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) { + LazyVStack(alignment: .leading, spacing: 3) { + ForEach(captionLines) { line in + captionLineView(line) + } + + Color.clear + .frame(height: 1) + .id(captionBottomAnchor) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, minHeight: captionViewportHeight, maxHeight: captionViewportHeight) + .padding(.trailing, 56) + .onAppear { + scrollToBottom(with: proxy, animated: false) + } + .onChange(of: captionLines) { _, _ in + scrollToBottom(with: proxy) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.regularMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color.white.opacity(0.18), lineWidth: 0.6) + ) } - private var toolbar: some View { + private var controlOverlay: some View { HStack(spacing: 8) { - Text("floating_preview.title") + 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) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(.quaternary.opacity(panelManager.isPinned ? 0.38 : 0.25)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color.white.opacity(0.16), lineWidth: 0.6) + ) + } + .buttonStyle(.plain) + .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) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(.quaternary.opacity(0.3)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color.white.opacity(0.16), lineWidth: 0.6) + ) + } + .buttonStyle(.plain) + .help(Text("floating_preview.close")) + .accessibilityLabel(Text("floating_preview.close")) + } - Spacer(minLength: 8) - - 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) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(.quaternary.opacity(panelManager.isPinned ? 0.38 : 0.25)) - ) - .overlay( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .stroke(Color.white.opacity(0.16), lineWidth: 0.6) + @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: 14, weight: .regular)) + .foregroundStyle(lineForegroundStyle(for: line.kind)) + .lineLimit(1) + .truncationMode(.head) + .italic() + .frame(maxWidth: .infinity, alignment: .leading) + } else { + Text(line.text) + .font(line.kind == .source ? .system(size: 15, weight: .regular) : .system(size: 14, weight: .regular)) + .foregroundStyle(lineForegroundStyle(for: line.kind)) + .lineLimit(1) + .truncationMode(.head) + .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 ) + ) } - .buttonStyle(.plain) - .help(Text(panelManager.isPinned ? "floating_preview.unpin" : "floating_preview.pin")) - .accessibilityLabel(Text(panelManager.isPinned ? "floating_preview.unpin" : "floating_preview.pin")) - - Button { - panelManager.close() - } label: { - Image(systemName: "xmark") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(.secondary) - .frame(width: 24, height: 24) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(.quaternary.opacity(0.3)) + + if showTranslation, + let translation = sentence.translation?.trimmingCharacters(in: .whitespacesAndNewlines), + !translation.isEmpty { + lines.append( + CaptionLine( + id: "sentence-translation-\(sentence.id.uuidString)", + text: translation, + kind: .translation ) - .overlay( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .stroke(Color.white.opacity(0.16), lineWidth: 0.6) + ) + } + } + + 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 ) + ) } - .buttonStyle(.plain) - .help(Text("floating_preview.close")) - .accessibilityLabel(Text("floating_preview.close")) } - } - private func sectionHeader(_ key: LocalizedStringKey) -> some View { - Text(key) - .font(.system(size: 11, weight: .semibold)) - .textCase(.uppercase) - .foregroundStyle(.tertiary) - .frame(maxWidth: .infinity, alignment: .leading) + 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 } - @ViewBuilder - private var lastSentenceCard: some View { - if let lastSentence = viewModel.sentences.last { - ScrollView(.vertical, showsIndicators: false) { - VStack(alignment: .leading, spacing: 4) { - Text(lastSentence.text) - .font(.system(size: 13, weight: .regular)) - .foregroundStyle(.primary) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - - if let translation = lastSentenceTranslation, !translation.isEmpty { - Text(translation) - .font(.system(size: 12, weight: .regular)) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - } - } + private func scrollToBottom(with proxy: ScrollViewProxy, animated: Bool = true) { + let action = { proxy.scrollTo(captionBottomAnchor, anchor: .bottom) } + if animated { + withAnimation(.easeOut(duration: 0.12)) { + action() } - .frame(maxWidth: .infinity, minHeight: 40, maxHeight: 110, alignment: .topLeading) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(.regularMaterial) - ) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(Color.white.opacity(0.18), lineWidth: 0.6) - ) } else { - Text("floating_preview.no_last_sentence") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.tertiary) - .frame(maxWidth: .infinity, minHeight: 40, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(.regularMaterial) - ) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(Color.white.opacity(0.14), lineWidth: 0.6) - ) + action() } } @@ -151,14 +244,27 @@ struct FloatingPreviewView: View { viewModel.listeningState == .active || viewModel.listeningState == .starting } - private var lastSentenceTranslation: String? { - guard viewModel.translationService.isEnabled else { return nil } - return viewModel.sentences.last?.translation + 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 index bd0f7a7..35eab21 100644 --- a/TransFlow/TransFlow/Views/LivePreviewContentView.swift +++ b/TransFlow/TransFlow/Views/LivePreviewContentView.swift @@ -1,58 +1,20 @@ import SwiftUI -/// Reusable live preview card used by both the bottom panel and floating window. +/// 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 usesGlassStyle: Bool = false - var maxContentHeight: CGFloat? = nil - var allowsScrolling: Bool = false - var prioritizeNewestText: Bool = false var body: some View { - Group { - if allowsScrolling { - ScrollView(.vertical, showsIndicators: false) { - content - .frame(maxWidth: .infinity, alignment: .leading) - } - } else { - content - } - } - .frame(maxWidth: .infinity, minHeight: 40, maxHeight: maxContentHeight, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(cardFillStyle) - ) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(usesGlassStyle ? Color.white.opacity(0.18) : Color.clear, lineWidth: 0.6) - ) - } - - private var cardFillStyle: AnyShapeStyle { - if usesGlassStyle { - return AnyShapeStyle(.ultraThinMaterial) - } - return AnyShapeStyle(.quaternary.opacity(0.3)) - } - - @ViewBuilder - private var content: some View { VStack(alignment: .leading, spacing: 3) { if !partialText.isEmpty { Text(partialText) .font(.system(size: 13, weight: .regular)) .foregroundStyle(.secondary) .italic() - .lineLimit(allowsScrolling ? nil : 3) - .truncationMode(prioritizeNewestText ? .head : .tail) - .fixedSize(horizontal: false, vertical: allowsScrolling) + .lineLimit(3) .frame(maxWidth: .infinity, alignment: .leading) if let partialTranslation, !partialTranslation.isEmpty { @@ -60,8 +22,7 @@ struct LivePreviewContentView: View { .font(.system(size: 12, weight: .regular)) .foregroundStyle(.tertiary) .italic() - .lineLimit(allowsScrolling ? nil : 2) - .fixedSize(horizontal: false, vertical: allowsScrolling) + .lineLimit(2) .frame(maxWidth: .infinity, alignment: .leading) } } else if isListening { @@ -79,6 +40,13 @@ struct LivePreviewContentView: View { .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)) + ) } } From 66823b441df3348ebfd09127b08ec1fa56fb1142 Mon Sep 17 00:00:00 2001 From: Chun Hu Date: Sat, 14 Feb 2026 15:40:13 +0800 Subject: [PATCH 6/9] Add cursor plans to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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/ From acfdaa036814d4b66ed79f573657fd644e64aa25 Mon Sep 17 00:00:00 2001 From: Chun Hu Date: Thu, 26 Feb 2026 11:05:41 +0800 Subject: [PATCH 7/9] refactor: adjust floating preview panel dimensions and improve layout Updated the FloatingPreviewPanelManager to increase the height and minimum size of the panel for better usability. Modified FloatingPreviewView to reduce the maximum finalized sentence count and adjusted the layout for improved readability and aesthetics, including changes to padding and frame settings. --- .../FloatingPreviewPanelManager.swift | 4 +- .../TransFlow/Views/FloatingPreviewView.swift | 52 ++++--------------- 2 files changed, 11 insertions(+), 45 deletions(-) diff --git a/TransFlow/TransFlow/Services/FloatingPreviewPanelManager.swift b/TransFlow/TransFlow/Services/FloatingPreviewPanelManager.swift index 4e6f650..f178103 100644 --- a/TransFlow/TransFlow/Services/FloatingPreviewPanelManager.swift +++ b/TransFlow/TransFlow/Services/FloatingPreviewPanelManager.swift @@ -63,7 +63,7 @@ final class FloatingPreviewPanelManager: NSObject, NSWindowDelegate { ] let panel = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 120), + contentRect: NSRect(x: 0, y: 0, width: 520, height: 200), styleMask: styleMask, backing: .buffered, defer: true @@ -80,7 +80,7 @@ final class FloatingPreviewPanelManager: NSObject, NSWindowDelegate { panel.backgroundColor = .clear panel.hasShadow = true panel.animationBehavior = .utilityWindow - panel.minSize = NSSize(width: 360, height: 100) + panel.minSize = NSSize(width: 360, height: 160) panel.delegate = self panel.setFrameAutosaveName("TransFlow.FloatingPreviewPanel") diff --git a/TransFlow/TransFlow/Views/FloatingPreviewView.swift b/TransFlow/TransFlow/Views/FloatingPreviewView.swift index f1b5735..c991374 100644 --- a/TransFlow/TransFlow/Views/FloatingPreviewView.swift +++ b/TransFlow/TransFlow/Views/FloatingPreviewView.swift @@ -7,8 +7,7 @@ struct FloatingPreviewView: View { @State private var isHovering = false private let captionBottomAnchor = "floating-caption-bottom" - private let maxFinalizedSentenceCount = 10 - private let captionViewportHeight: CGFloat = 46 + private let maxFinalizedSentenceCount = 4 var body: some View { ZStack(alignment: .topTrailing) { @@ -17,16 +16,7 @@ struct FloatingPreviewView: View { } .padding(12) .frame(minWidth: 340, idealWidth: 390, minHeight: 96, alignment: .topLeading) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(.ultraThinMaterial) - ) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(Color.white.opacity(0.22), lineWidth: 0.8) - ) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - .shadow(color: .black.opacity(0.2), radius: 16, x: 0, y: 10) + .glassEffect(.regular, in: .rect(cornerRadius: 14)) .contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .onHover { hovering in withAnimation(.easeInOut(duration: 0.16)) { @@ -38,18 +28,18 @@ struct FloatingPreviewView: View { private var captionCard: some View { ScrollViewReader { proxy in ScrollView(.vertical, showsIndicators: false) { - LazyVStack(alignment: .leading, spacing: 3) { + VStack(alignment: .leading, spacing: 3) { ForEach(captionLines) { line in captionLineView(line) } Color.clear - .frame(height: 1) + .frame(height: 8) .id(captionBottomAnchor) } .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxWidth: .infinity, minHeight: captionViewportHeight, maxHeight: captionViewportHeight) + .frame(maxWidth: .infinity, minHeight: 80, maxHeight: .infinity) .padding(.trailing, 56) .onAppear { scrollToBottom(with: proxy, animated: false) @@ -60,14 +50,6 @@ struct FloatingPreviewView: View { } .padding(.horizontal, 12) .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(.regularMaterial) - ) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(Color.white.opacity(0.18), lineWidth: 0.6) - ) } private var controlOverlay: some View { @@ -92,16 +74,9 @@ struct FloatingPreviewView: View { .font(.system(size: 12, weight: .semibold)) .foregroundStyle(panelManager.isPinned ? Color.accentColor : Color.secondary) .frame(width: 24, height: 24) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(.quaternary.opacity(panelManager.isPinned ? 0.38 : 0.25)) - ) - .overlay( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .stroke(Color.white.opacity(0.16), lineWidth: 0.6) - ) } .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")) } @@ -114,16 +89,9 @@ struct FloatingPreviewView: View { .font(.system(size: 11, weight: .bold)) .foregroundStyle(.secondary) .frame(width: 24, height: 24) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(.quaternary.opacity(0.3)) - ) - .overlay( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .stroke(Color.white.opacity(0.16), lineWidth: 0.6) - ) } .buttonStyle(.plain) + .glassEffect(.regular, in: .circle) .help(Text("floating_preview.close")) .accessibilityLabel(Text("floating_preview.close")) } @@ -134,17 +102,15 @@ struct FloatingPreviewView: View { Text(line.text) .font(line.kind == .source ? .system(size: 15, weight: .regular) : .system(size: 14, weight: .regular)) .foregroundStyle(lineForegroundStyle(for: line.kind)) - .lineLimit(1) - .truncationMode(.head) .italic() .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, line.kind == .translation ? 6 : 0) } else { Text(line.text) .font(line.kind == .source ? .system(size: 15, weight: .regular) : .system(size: 14, weight: .regular)) .foregroundStyle(lineForegroundStyle(for: line.kind)) - .lineLimit(1) - .truncationMode(.head) .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, line.kind == .translation ? 6 : 0) } } From 3d6ef342b57a674d75864759e2365b65ea952535 Mon Sep 17 00:00:00 2001 From: Chun Hu Date: Thu, 26 Feb 2026 11:19:43 +0800 Subject: [PATCH 8/9] refactor: adjust font sizes in FloatingPreviewView for improved readability Modified the font size for caption lines in the FloatingPreviewView to enhance clarity, particularly for non-source text. Removed unnecessary padding adjustments to streamline the layout. --- TransFlow/TransFlow/Views/FloatingPreviewView.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/TransFlow/TransFlow/Views/FloatingPreviewView.swift b/TransFlow/TransFlow/Views/FloatingPreviewView.swift index c991374..fdbe984 100644 --- a/TransFlow/TransFlow/Views/FloatingPreviewView.swift +++ b/TransFlow/TransFlow/Views/FloatingPreviewView.swift @@ -100,17 +100,15 @@ struct FloatingPreviewView: View { private func captionLineView(_ line: CaptionLine) -> some View { if line.isPartial { Text(line.text) - .font(line.kind == .source ? .system(size: 15, weight: .regular) : .system(size: 14, weight: .regular)) + .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) - .padding(.leading, line.kind == .translation ? 6 : 0) } else { Text(line.text) - .font(line.kind == .source ? .system(size: 15, weight: .regular) : .system(size: 14, weight: .regular)) + .font(line.kind == .source ? .system(size: 15, weight: .regular) : .system(size: 12, weight: .regular)) .foregroundStyle(lineForegroundStyle(for: line.kind)) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, line.kind == .translation ? 6 : 0) } } From 5e9763d4aa821ea22bc51a85aed0566c8242f506 Mon Sep 17 00:00:00 2001 From: Chun Hu Date: Thu, 26 Feb 2026 11:40:01 +0800 Subject: [PATCH 9/9] fix: remove titled style mask to eliminate grey titlebar strip on floating panel The NSPanel's .titled style mask rendered a built-in titlebar material that appeared as a grey strip on light backgrounds. Switched to a borderless panel (.resizable + .nonactivatingPanel) since the titlebar was fully hidden anyway. Also changed to orderFront to avoid stealing focus from the active app. Made-with: Cursor --- .../Services/FloatingPreviewPanelManager.swift | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/TransFlow/TransFlow/Services/FloatingPreviewPanelManager.swift b/TransFlow/TransFlow/Services/FloatingPreviewPanelManager.swift index f178103..2462adf 100644 --- a/TransFlow/TransFlow/Services/FloatingPreviewPanelManager.swift +++ b/TransFlow/TransFlow/Services/FloatingPreviewPanelManager.swift @@ -28,8 +28,7 @@ final class FloatingPreviewPanelManager: NSObject, NSWindowDelegate { ) applyPinState() - panel?.makeKeyAndOrderFront(nil) - panel?.orderFrontRegardless() + panel?.orderFront(nil) } /// Closes the panel (same behavior as clicking the red close button). @@ -56,10 +55,8 @@ final class FloatingPreviewPanelManager: NSObject, NSWindowDelegate { private func createPanel() { let styleMask: NSWindow.StyleMask = [ - .titled, - .closable, .resizable, - .fullSizeContentView, + .nonactivatingPanel, ] let panel = NSPanel( @@ -69,10 +66,6 @@ final class FloatingPreviewPanelManager: NSObject, NSWindowDelegate { defer: true ) - panel.title = String(localized: "floating_preview.title") - panel.titleVisibility = .hidden - panel.titlebarAppearsTransparent = true - panel.titlebarSeparatorStyle = .none panel.isMovableByWindowBackground = true panel.hidesOnDeactivate = false panel.isReleasedWhenClosed = false @@ -84,10 +77,6 @@ final class FloatingPreviewPanelManager: NSObject, NSWindowDelegate { panel.delegate = self panel.setFrameAutosaveName("TransFlow.FloatingPreviewPanel") - panel.standardWindowButton(.closeButton)?.isHidden = true - panel.standardWindowButton(.miniaturizeButton)?.isHidden = true - panel.standardWindowButton(.zoomButton)?.isHidden = true - self.panel = panel }