Skip to content
Open
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,4 @@ build/
*.swo
*~
TransFlow/TransFlow.xcodeproj/project.xcworkspace/xcuserdata
.cursor/plans/
90 changes: 30 additions & 60 deletions TransFlow/TransFlow/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand All @@ -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
)
}
85 changes: 85 additions & 0 deletions TransFlow/TransFlow/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down Expand Up @@ -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" : {
Expand Down
125 changes: 125 additions & 0 deletions TransFlow/TransFlow/Services/FloatingPreviewPanelManager.swift
Original file line number Diff line number Diff line change
@@ -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<AnyView>?

/// 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,
]
}
}
}
8 changes: 7 additions & 1 deletion TransFlow/TransFlow/TransFlowApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading