// 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: OSGColor.macOverlayShadow, 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)) } }