Files
OSGKeyboard/OSGKeyboardMac/MacDictationOverlayView.swift
T
Rocky 2d44423f4c fix(mac): prevent Option release deadlock and HUD layout storm
The hold-to-talk release path called AsyncStream.Continuation.finish()
while holding MacAudioRecorder's NSLock; onTermination re-entered the
same lock on the main thread and froze the app. Finish snapshot sinks
outside the lock and guard the audio tap once stop() begins.

The dictation HUD no longer reassigns its hosting view and forces a
synchronous relayout on every view-model tick; it uses a fixed panel
size and SwiftUI @ObservedObject refresh instead.

Adds OSGKeyboardMacTests with a regression test that fails under the
old lock re-entry pattern.
2026-07-29 16:50:32 +08:00

218 lines
8.5 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
/// Pill body width. Wide enough for dot + live badge + a 320pt transcript
/// line + waveform + stop button at `Spacing.sm` gaps.
static let pillWidth: CGFloat = 500
/// Transparent margin around the pill, sized to contain the shadow's reach
/// (radius 14 + y 5). The panel is sized to pill + margin, and the shadow
/// would otherwise clip into hard translucent-black corners.
static let shadowMargin = EdgeInsets(top: 12, leading: 16, bottom: 20, trailing: 16)
/// Total panel size the hosting `NSPanel` should use.
static var panelSize: CGSize {
CGSize(
width: pillWidth + shadowMargin.leading + shadowMargin.trailing,
// 28pt content + 11pt vertical padding on each side.
height: 28 + 22 + shadowMargin.top + shadowMargin.bottom
)
}
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)
// Fixed width, not intrinsic: the hosting panel is sized from this
// constant once, so a growing transcript never asks AppKit to resize
// the window mid-update. Long text truncates in `primaryLine` instead.
.frame(width: Self.pillWidth)
.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)
.padding(Self.shadowMargin)
.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))
}
}