Files
OSGKeyboard/OSGKeyboardMac/MacDictationOverlayView.swift
T
Rocky c0c9dad149 feat(mac): add Qwen3 MLX streaming dictation
Replace the Sherpa offline pipeline with native MLX streaming, resilient model downloads, live transcript previews, and supporting tests and documentation.
2026-07-23 14:34:56 +08:00

204 lines
7.9 KiB
Swift

// MacDictationOverlayView.swift
// OSGKeyboard · Mac
//
// Compact bottom-of-screen HUD shown while dictating. Lives inside a
// non-activating NSPanel so it never steals focus from the front app.
// One-line layout: status / live transcript preview + waveform + stop.
import SwiftUI
struct MacDictationOverlayView: View {
@ObservedObject var viewModel: MacDictationViewModel
/// Called continuously while the user drags the pill (reads the live cursor
/// position on the controller side). Double-click resets to the default.
var onDragChanged: (() -> Void)?
var onDragEnded: (() -> Void)?
/// Double-click anywhere on the pill to snap it back to the default spot.
var onResetPosition: (() -> Void)?
@Environment(\.themePalette) private var palette
private var lang: AppUILanguage { viewModel.config.uiLanguage }
private var isBusy: Bool {
viewModel.isRecording || viewModel.isPreparingToRecord || viewModel.isProcessing
}
/// Trimmed live / final transcript for the single-line preview.
private var previewText: String {
viewModel.transcript.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var hasPreview: Bool { !previewText.isEmpty }
private var showsLiveBadge: Bool {
viewModel.isRecording && viewModel.isStreamingPartial
}
var body: some View {
HStack(spacing: Spacing.sm) {
statusDot
primaryLine
Spacer(minLength: Spacing.xs)
trailingControl
}
// Fixed content height so the pill never changes height between
// 识别中 (waveform, 28pt) and 润色中 (small spinner) states.
.frame(height: 28)
.padding(.horizontal, Spacing.md)
.padding(.vertical, 11)
.frame(minWidth: 300, idealWidth: 400, maxWidth: 520)
.fixedSize(horizontal: true, vertical: true)
.background(palette.surface, in: Capsule(style: .continuous))
.overlay(
Capsule(style: .continuous)
.stroke(palette.dividerStrong, lineWidth: 0.5)
)
.shadow(color: Color.black.opacity(0.22), radius: 14, y: 5)
// Transparent margin large enough to contain the shadow's reach
// (radius 14 + y 5). The panel is sized to `fittingSize`, which ignores
// shadow, so without this room the borderless window clips the shadow
// into hard translucent-black corners.
.padding(EdgeInsets(top: 12, leading: 16, bottom: 20, trailing: 16))
.contentShape(Capsule(style: .continuous))
// Manual drag: `isMovableByWindowBackground` doesn't work on a
// non-activating panel, so we move the panel ourselves. The controller
// reads the live cursor position, so the translation value is unused.
.gesture(
DragGesture(minimumDistance: 3)
.onChanged { _ in onDragChanged?() }
.onEnded { _ in onDragEnded?() }
)
.onTapGesture(count: 2) { onResetPosition?() }
.help(MacL10n.string("mac.overlay.dragHint", language: lang))
.animation(Motion.soft, value: hasPreview)
.animation(Motion.quick, value: viewModel.isRecording)
.animation(Motion.quick, value: viewModel.isStreamingPartial)
}
// MARK: - Primary line (status or one-line transcript)
@ViewBuilder
private var primaryLine: some View {
if hasPreview {
HStack(spacing: 6) {
if showsLiveBadge {
liveBadge
}
Text(previewText)
.font(TypeStyle.footnote)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.truncationMode(.head)
.frame(maxWidth: showsLiveBadge ? 280 : 320, alignment: .trailing)
.accessibilityLabel(previewText)
}
} else {
HStack(spacing: 6) {
Text(statusText)
.font(TypeStyle.caption)
.foregroundStyle(palette.textPrimary)
.contentTransition(.opacity)
if let appName = viewModel.foregroundAppName, isBusy {
Text("·")
.foregroundStyle(palette.textTertiary.opacity(0.45))
Text(appName)
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.lineLimit(1)
}
}
.animation(Motion.quick, value: statusText)
}
}
private var liveBadge: some View {
Text(MacL10n.string("mac.overlay.live", language: lang))
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(palette.recordRed)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(palette.recordRed.opacity(0.12), in: Capsule(style: .continuous))
.accessibilityHidden(true)
}
private var statusDot: some View {
Circle()
.fill(dotColor)
.frame(width: 8, height: 8)
.animation(Motion.quick, value: viewModel.isRecording)
.animation(Motion.quick, value: viewModel.isProcessing)
.animation(Motion.quick, value: viewModel.isPreparingToRecord)
.animation(Motion.quick, value: viewModel.isStreamingPartial)
}
private var dotColor: Color {
if viewModel.isRecording {
return viewModel.isStreamingPartial ? palette.accent : palette.recordRed
}
if viewModel.isPreparingToRecord || viewModel.isProcessing { return palette.warning }
return palette.accent
}
private var statusText: String {
if viewModel.isRecording {
return MacL10n.string("mac.overlay.listening", language: lang)
}
if viewModel.isPreparingToRecord {
return MacL10n.string("mac.overlay.preparing", language: lang)
}
if viewModel.isProcessing {
if viewModel.isStreamingPartial {
return MacL10n.string("mac.overlay.polishing", language: lang)
}
return MacL10n.string("mac.overlay.transcribing", language: lang)
}
return MacL10n.string("mac.overlay.done", language: lang)
}
// Fixed-size trailing slot so the pill width / height stays steady as the
// control swaps between waveform, spinner and checkmark.
private var trailingControl: some View {
HStack(spacing: Spacing.sm) {
trailingContent
}
.frame(height: 28, alignment: .trailing)
}
@ViewBuilder
private var trailingContent: some View {
if viewModel.isRecording {
MiniWaveform(
level: viewModel.audioLevel,
barCount: 7,
tint: (viewModel.isStreamingPartial ? palette.accent : palette.recordRed)
.opacity(0.9),
maxBarHeight: 28,
barWidth: 3.5,
barSpacing: 2.5
)
stopButton
} else if viewModel.isPreparingToRecord || viewModel.isProcessing {
ProgressView()
.controlSize(.small)
.frame(width: 28, height: 28)
} else {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(palette.accent)
.symbolRenderingMode(.hierarchical)
.frame(width: 28, height: 28)
}
}
private var stopButton: some View {
Button(action: viewModel.toggleRecording) {
Image(systemName: "stop.fill")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(palette.textOnAccent)
.frame(width: 28, height: 28)
.background(palette.recordRed, in: Circle())
}
.buttonStyle(.plain)
.accessibilityLabel(MacL10n.string("mac.record.stop", language: lang))
}
}