c0c9dad149
Replace the Sherpa offline pipeline with native MLX streaming, resilient model downloads, live transcript previews, and supporting tests and documentation.
204 lines
7.9 KiB
Swift
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))
|
|
}
|
|
}
|