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.
This commit is contained in:
@@ -24,7 +24,34 @@ final class MacDictationOverlayController {
|
||||
private let bottomMargin: CGFloat = 36
|
||||
private let fallbackSize = NSSize(width: 400, height: 52)
|
||||
|
||||
private init() {}
|
||||
// MARK: - User-draggable position (persisted across launches)
|
||||
|
||||
/// True once the user has dragged the HUD; suppresses the default
|
||||
/// bottom-center snap so the panel stays where the user placed it.
|
||||
private var hasCustomPosition = false
|
||||
/// Stored as center-X + bottom-left Y so the anchor stays stable while the
|
||||
/// pill grows / shrinks with the live transcript (symmetric resize).
|
||||
private var customCenterX: CGFloat = 0
|
||||
private var customOriginY: CGFloat = 0
|
||||
/// The origin we last set programmatically (kept for clamping / bookkeeping).
|
||||
private var lastProgrammaticOrigin: NSPoint?
|
||||
/// Cursor + window origin captured at the start of a manual drag, so we can
|
||||
/// follow the absolute cursor and stay immune to the window moving under it.
|
||||
private var dragCursorStart: NSPoint?
|
||||
private var dragWindowStart: NSPoint?
|
||||
|
||||
private static let hasCustomPositionKey = "mac.overlay.hasCustomPosition"
|
||||
private static let centerXKey = "mac.overlay.centerX"
|
||||
private static let originYKey = "mac.overlay.originY"
|
||||
|
||||
private init() {
|
||||
let defaults = UserDefaults.standard
|
||||
if defaults.bool(forKey: Self.hasCustomPositionKey) {
|
||||
hasCustomPosition = true
|
||||
customCenterX = CGFloat(defaults.double(forKey: Self.centerXKey))
|
||||
customOriginY = CGFloat(defaults.double(forKey: Self.originYKey))
|
||||
}
|
||||
}
|
||||
|
||||
func start(observing viewModel: MacDictationViewModel) {
|
||||
guard cancellables.isEmpty else { return }
|
||||
@@ -134,6 +161,8 @@ final class MacDictationOverlayController {
|
||||
// full-screen apps, without going as high as the screen saver.
|
||||
panel.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.floatingWindow)) + 1)
|
||||
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
||||
// Background dragging can't move a non-activating panel; we drive the
|
||||
// drag ourselves from a SwiftUI DragGesture (see `dragMoved`).
|
||||
panel.isMovableByWindowBackground = false
|
||||
panel.hidesOnDeactivate = false
|
||||
panel.ignoresMouseEvents = false
|
||||
@@ -147,10 +176,15 @@ final class MacDictationOverlayController {
|
||||
|
||||
private func makeRoot(viewModel: MacDictationViewModel) -> AnyView {
|
||||
AnyView(
|
||||
MacDictationOverlayView(viewModel: viewModel)
|
||||
.macSystemPalette()
|
||||
.environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
|
||||
.preferredColorScheme(MacAppearancePreference.current.colorScheme)
|
||||
MacDictationOverlayView(
|
||||
viewModel: viewModel,
|
||||
onDragChanged: { [weak self] in self?.dragMoved() },
|
||||
onDragEnded: { [weak self] in self?.dragEnded() },
|
||||
onResetPosition: { [weak self] in self?.resetPositionToDefault() }
|
||||
)
|
||||
.macSystemPalette()
|
||||
.environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
|
||||
.preferredColorScheme(MacAppearancePreference.current.colorScheme)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -158,18 +192,27 @@ final class MacDictationOverlayController {
|
||||
guard let panel, let hosting else { return }
|
||||
hosting.layoutSubtreeIfNeeded()
|
||||
let fitting = hosting.fittingSize
|
||||
// Bounds include the 32pt horizontal transparent margin around the pill
|
||||
// (16 per side) that gives the shadow room, so the pill body itself
|
||||
// still spans ~300–520.
|
||||
let width = fitting.width.isFinite && fitting.width > 1
|
||||
? min(max(fitting.width, 300), 520)
|
||||
? min(max(fitting.width, 332), 552)
|
||||
: fallbackSize.width
|
||||
let height = fitting.height.isFinite && fitting.height > 1
|
||||
? max(fitting.height, fallbackSize.height)
|
||||
: fallbackSize.height
|
||||
var frame = panel.frame
|
||||
let midX = frame.midX
|
||||
// Grow / shrink around the anchor center so the pill stays put: the
|
||||
// dragged center when custom, otherwise its current center.
|
||||
let targetMidX = hasCustomPosition ? customCenterX : frame.midX
|
||||
frame.size = NSSize(width: width, height: height)
|
||||
if midX.isFinite {
|
||||
frame.origin.x = midX - width / 2
|
||||
if targetMidX.isFinite {
|
||||
frame.origin.x = targetMidX - width / 2
|
||||
}
|
||||
if let visible = (NSScreen.main ?? NSScreen.screens.first)?.visibleFrame {
|
||||
frame.origin = clampedOrigin(frame.origin, size: frame.size, in: visible)
|
||||
}
|
||||
lastProgrammaticOrigin = frame.origin
|
||||
panel.setFrame(frame, display: true)
|
||||
hosting.frame = NSRect(origin: .zero, size: frame.size)
|
||||
}
|
||||
@@ -179,13 +222,89 @@ final class MacDictationOverlayController {
|
||||
let screen = NSScreen.main ?? NSScreen.screens.first
|
||||
guard let visible = screen?.visibleFrame else { return }
|
||||
let size = panel.frame.size
|
||||
let origin = NSPoint(
|
||||
x: visible.midX - size.width / 2,
|
||||
y: visible.minY + bottomMargin
|
||||
)
|
||||
// Respect the user's dragged spot; otherwise snap to bottom-center.
|
||||
let desired: NSPoint
|
||||
if hasCustomPosition {
|
||||
desired = NSPoint(x: customCenterX - size.width / 2, y: customOriginY)
|
||||
} else {
|
||||
desired = NSPoint(
|
||||
x: visible.midX - size.width / 2,
|
||||
y: visible.minY + bottomMargin
|
||||
)
|
||||
}
|
||||
let origin = clampedOrigin(desired, size: size, in: visible)
|
||||
lastProgrammaticOrigin = origin
|
||||
panel.setFrameOrigin(origin)
|
||||
}
|
||||
|
||||
/// Keep the panel fully inside the screen's visible frame so a dragged /
|
||||
/// restored position can never strand it off-screen (e.g. after a display
|
||||
/// or resolution change).
|
||||
private func clampedOrigin(_ origin: NSPoint, size: NSSize, in visible: NSRect) -> NSPoint {
|
||||
guard visible.width >= size.width, visible.height >= size.height else {
|
||||
return origin
|
||||
}
|
||||
let x = min(max(origin.x, visible.minX), visible.maxX - size.width)
|
||||
let y = min(max(origin.y, visible.minY), visible.maxY - size.height)
|
||||
return NSPoint(x: x, y: y)
|
||||
}
|
||||
|
||||
/// Follows the absolute cursor while dragging. Reading `NSEvent.mouseLocation`
|
||||
/// (screen coordinates) instead of the gesture's local translation avoids the
|
||||
/// feedback loop you'd get from moving the window the gesture lives in.
|
||||
private func dragMoved() {
|
||||
guard let panel else { return }
|
||||
let cursor = NSEvent.mouseLocation
|
||||
if dragCursorStart == nil {
|
||||
dragCursorStart = cursor
|
||||
dragWindowStart = panel.frame.origin
|
||||
}
|
||||
guard let cursorStart = dragCursorStart, let windowStart = dragWindowStart else { return }
|
||||
let target = NSPoint(
|
||||
x: windowStart.x + (cursor.x - cursorStart.x),
|
||||
y: windowStart.y + (cursor.y - cursorStart.y)
|
||||
)
|
||||
let size = panel.frame.size
|
||||
let visible = (NSScreen.main ?? NSScreen.screens.first)?.visibleFrame
|
||||
let origin = visible.map { clampedOrigin(target, size: size, in: $0) } ?? target
|
||||
lastProgrammaticOrigin = origin
|
||||
panel.setFrameOrigin(origin)
|
||||
}
|
||||
|
||||
/// Persist the dragged spot as center-X + bottom-left Y.
|
||||
private func dragEnded() {
|
||||
dragCursorStart = nil
|
||||
dragWindowStart = nil
|
||||
guard let panel else { return }
|
||||
customCenterX = panel.frame.midX
|
||||
customOriginY = panel.frame.origin.y
|
||||
hasCustomPosition = true
|
||||
persistPosition()
|
||||
}
|
||||
|
||||
/// Double-clicking the pill clears the custom spot and returns it to the
|
||||
/// default bottom-center.
|
||||
private func resetPositionToDefault() {
|
||||
hasCustomPosition = false
|
||||
clearPersistedPosition()
|
||||
resizeToFit()
|
||||
reposition()
|
||||
}
|
||||
|
||||
private func persistPosition() {
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.set(hasCustomPosition, forKey: Self.hasCustomPositionKey)
|
||||
defaults.set(Double(customCenterX), forKey: Self.centerXKey)
|
||||
defaults.set(Double(customOriginY), forKey: Self.originYKey)
|
||||
}
|
||||
|
||||
private func clearPersistedPosition() {
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.removeObject(forKey: Self.hasCustomPositionKey)
|
||||
defaults.removeObject(forKey: Self.centerXKey)
|
||||
defaults.removeObject(forKey: Self.originYKey)
|
||||
}
|
||||
|
||||
private func scheduleHide() {
|
||||
hideWorkItem?.cancel()
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
|
||||
Reference in New Issue
Block a user