Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions App/Blurt/Blurt/Overlay/OverlayPillContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,37 @@ struct StatusLineText: View {
/// The text color — brand green for every state but `.error`, which the
/// design gives its own orange rather than a red pill body.
var color: Color = BlurtBrand.greenOnDark
/// Where an over-long line is cut. The tail, except for a device name, which
/// keeps its distinguishing end ("…Scarlett Solo USB").
var truncation: Text.TruncationMode = .tail
/// Whether an over-long line shrinks before it truncates. Fine for a status
/// word; a sentence shrunk to 70% of 9 pt is unreadable, so a written-out
/// notice truncates at full size instead.
var shrinksToFit = true

init(_ text: String, color: Color = BlurtBrand.greenOnDark) {
init(
_ text: String, color: Color = BlurtBrand.greenOnDark,
truncation: Text.TruncationMode = .tail, shrinksToFit: Bool = true
) {
self.text = text
self.color = color
self.truncation = truncation
self.shrinksToFit = shrinksToFit
}

/// The status-line type, named so `OverlayWindowController.pillWidth(for:)`
/// measures a written-out notice with exactly what draws it.
static let fontSize: CGFloat = 9
static let tracking: CGFloat = 0.9

var body: some View {
Text(text)
.font(.system(size: 9, weight: .semibold))
.font(.system(size: Self.fontSize, weight: .semibold))
.textCase(.uppercase)
.tracking(0.9)
.tracking(Self.tracking)
.lineLimit(1)
.minimumScaleFactor(0.7)
.truncationMode(truncation)
.minimumScaleFactor(shrinksToFit ? 0.7 : 1)
.foregroundStyle(color)
}
}
Expand Down
35 changes: 33 additions & 2 deletions App/Blurt/Blurt/Overlay/OverlayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ struct OverlayView: View {

var body: some View {
content
.frame(width: pillSize.width, height: pillSize.height)
// The width is the bridge's: standard, or widened by the controller for a
// notice that writes its reason on the pill (`OverlayUIState.pillText`).
.frame(width: bridge.pillWidth, height: pillSize.height)
// Solid, opaque capsule rather than Liquid Glass: a fixed fill never adapts
// to the backdrop, so the pill fades in the same dark gray everywhere with
// no first-frame flash (see `fillColor`). `.animation` below cross-fades the
Expand All @@ -57,7 +59,11 @@ struct OverlayView: View {
// edge rather than clipping into a line.
.compositingGroup()
.shadow(color: .black.opacity(0.25), radius: 10, y: 3)
.animation(reduceMotion ? nil : .easeInOut(duration: 0.15), value: state)
// No cross-fade for a change that resizes the pill — the controller fades
// the whole panel over that swap instead (`OverlayBridge.animatesStateChange`).
.animation(
reduceMotion || !bridge.animatesStateChange ? nil : .easeInOut(duration: 0.15), value: state
)
.contentShape(Rectangle())
// Transparent margin so the shadow has room to render without being
// clipped by the panel's contentRect (most visible at the rounded ends).
Expand Down Expand Up @@ -130,6 +136,27 @@ struct OverlayView: View {
StatusLineText("Pasted")
.transition(.opacity)
.help(state.accessibilityLabel)
case .inputSilent:
// The silent-mic notice: the device name is the diagnosis, so it goes on
// the pill itself — the controller has widened the capsule to fit — in the
// same orange as the "Error" word.
//
// Two runs, so a device name too long for the capped pill is cut in its
// middle while "isn't sending any audio" — the part that says what's wrong —
// always shows in full, and neither shrinks.
if let notice = state.pillNotice {
HStack(spacing: 0) {
StatusLineText(
notice.subject + " ", color: BlurtBrand.errorOrange,
truncation: .middle, shrinksToFit: false)
StatusLineText(notice.predicate, color: BlurtBrand.errorOrange, shrinksToFit: false)
.fixedSize()
.layoutPriority(1)
}
.padding(.horizontal, Self.errorTextInset)
.transition(.opacity)
.help(state.accessibilityLabel)
}
case .noTarget:
// Quiet, informational notice: there was no text field to type into, so
// the transcript went to the clipboard. Styled exactly like "Pasted"
Expand All @@ -142,6 +169,10 @@ struct OverlayView: View {
}
}

/// Horizontal inset of a notice written out on the pill. Internal so
/// `OverlayWindowController.pillWidth(for:)` sizes the pill with the same value.
static let errorTextInset: CGFloat = 14

/// The pill's inner inset and the gap between the orb and what follows it.
private static let contentInset: CGFloat = 12
private static let contentSpacing: CGFloat = 8
Expand Down
91 changes: 88 additions & 3 deletions App/Blurt/Blurt/Overlay/OverlayWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ final class OverlayBridge {
/// The latest mic loudness, 0...1 (MicCapture.linearLevel). The overlay's
/// voice bars track this current value — there is no scrolling history.
var level: Float = 0
/// The capsule's current width: `OverlayWindowController.pillSize.width`,
/// except while a notice that writes its reason on the pill is up (see
/// `OverlayWindowController.pillWidth(for:)`).
var pillWidth: CGFloat = OverlayWindowController.pillSize.width
/// Whether the view cross-fades its latest state change. False for a change
/// that also resizes the pill: the panel and the capsule can't move in step,
/// so that swap happens in one step under a fade of the whole panel instead.
/// A flag the view reads rather than a `withTransaction`, because this
/// `@Observable` reaches the view in a later update, after any transaction
/// set here has ended.
var animatesStateChange = true

func pushLevel(_ value: Float) {
// `value` arrives on the fixed 0...1 scale `MicCaptureProtocol.levels`
Expand Down Expand Up @@ -52,6 +63,10 @@ final class OverlayWindowController {
// `shadowMargin` is checked on both sides rather than only one.
private static let panelSize = OverlayPlacement.panelSize(
pillSize: pillSize, shadowMargin: shadowMargin)
// Ceiling for a pill that writes its reason out (`OverlayUIState.pillText`):
// room for the sentence with an ordinary device name; a longer one truncates
// rather than stretching the pill across the screen.
private static let maxPillWidth: CGFloat = 340

private let panel: NSPanel
private let hosting: NSHostingView<OverlayView>
Expand All @@ -77,6 +92,11 @@ final class OverlayWindowController {
self.hosting = NSHostingView(rootView: OverlayView(bridge: bridge))
self.hosting.wantsLayer = true
self.hosting.layer?.backgroundColor = .clear
// This controller sizes the panel (`resizePanel(toPillWidth:)`), keeping the
// pill centred as it widens. Left at its default, the hosting view would also
// resize the window to fit the content whenever the width changed, anchored at
// a corner.
self.hosting.sizingOptions = []
self.panel = FloatingPanel.make(
size: Self.panelSize,
collectionBehavior: [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary],
Expand Down Expand Up @@ -143,7 +163,20 @@ final class OverlayWindowController {
// a value that hadn't moved. The notice handling below stays outside the guard:
// a repeated notice still has to announce and re-arm its revert.
if bridge.state != state {
bridge.state = state
let width = Self.pillWidth(for: state)
let resizes = width != bridge.pillWidth
bridge.animatesStateChange = !resizes
if resizes {
// The window resizes a frame before SwiftUI redraws at the new size, so
// swap under a transparent panel; `setVisible(true)` below fades it back
// in with the pill's usual appear ramp.
if panel.isVisible { panel.alphaValue = 0 }
bridge.pillWidth = width
bridge.state = state
resizePanel(toPillWidth: width)
} else {
bridge.state = state
}
}
// The red error flash and the neutral "copied" notice are both transient: the
// pill is otherwise only up during active dictation, so they linger briefly to
Expand Down Expand Up @@ -204,10 +237,12 @@ final class OverlayWindowController {
/// the screen: an empty capsule still animating after the dismiss. The pill's
/// only fade is the alpha ramp in `setVisible`, so this settle is instantaneous.
private func settleContent() {
defer { resizePanel(toPillWidth: Self.pillSize.width) }
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
bridge.state = .idle
bridge.pillWidth = Self.pillSize.width
// Clear the level too, or the pill's next appearance renders its bars at the
// PREVIOUS dictation's loudness until the first new meter tick (~50 ms)
// replaces it — a one-frame "already talking" flash at the start of every
Expand Down Expand Up @@ -268,6 +303,55 @@ final class OverlayWindowController {
bridge.pushLevel(value)
}

// MARK: - Pill width

/// The pill's width for `state`: the compact standard width, or — for a notice
/// that writes its reason out — wide enough for that text in the status-line
/// type, capped at `maxPillWidth`. Measured with the same font and tracking
/// `StatusLineText` draws with, so the text fits without scaling down.
static func pillWidth(for state: OverlayUIState) -> CGFloat {
guard let text = state.pillText else { return pillSize.width }
let measured = NSAttributedString(
string: text.uppercased(),
attributes: [
.font: NSFont.systemFont(ofSize: StatusLineText.fontSize, weight: .semibold),
.kern: StatusLineText.tracking,
]
).size().width
// The same few points of headroom the standard width keeps for its longest
// word (see `pillSize`), so Bold Text or a font-metric change truncates
// nothing at an ordinary device name.
let width = (measured + OverlayView.errorTextInset * 2 + 8).rounded(.up)
return min(max(width, pillSize.width), maxPillWidth)
}

/// How much wider than standard the panel is right now. The persisted drag
/// origin is always the *standard* panel's, so the two places that cross that
/// boundary — placing the panel and saving a drag — shift by half of this.
private var extraPanelWidth: CGFloat { panel.frame.width - Self.panelSize.width }

/// Resizes the panel to hold a `width`-wide pill. Widening grows it evenly on
/// both sides, so the pill stays centred where it was, then clamps it onto the
/// screen — a pill dragged near an edge would otherwise push its notice off
/// it. Returning to the standard width goes back to the pill's own placement
/// (`reposition`), not the centre of a notice that may have been clamped. A
/// programmatic move either way, so it isn't persisted as a drag.
private func resizePanel(toPillWidth width: CGFloat) {
let delta = width + Self.shadowMargin * 2 - panel.frame.width
guard delta != 0 else { return }
var frame = panel.frame
frame.origin.x -= delta / 2
frame.size.width += delta
if width > Self.pillSize.width, let screen = panel.screen ?? NSScreen.main {
frame.origin = OverlayPlacement.clamped(
origin: frame.origin, size: frame.size, into: screen.visibleFrame)
}
suppressOriginPersist = true
panel.setFrame(frame, display: false)
suppressOriginPersist = false
if width == Self.pillSize.width { reposition() }
}

private func reposition() {
guard let screen = NSScreen.main else { return }
// All the placement policy — default bottom-center, the clearance, the
Expand All @@ -277,7 +361,7 @@ final class OverlayWindowController {
let origin = OverlayPlacement.panelOrigin(
panelSize: panel.frame.size,
visibleFrame: screen.visibleFrame,
customOrigin: Self.originStore.origin,
customOrigin: Self.originStore.origin.map { CGPoint(x: $0.x - extraPanelWidth / 2, y: $0.y) },
shadowMargin: Self.shadowMargin)
suppressOriginPersist = true
panel.setFrameOrigin(origin)
Expand All @@ -286,7 +370,8 @@ final class OverlayWindowController {

private func handleDidMove() {
guard !suppressOriginPersist else { return }
Self.originStore.origin = panel.frame.origin
let origin = panel.frame.origin
Self.originStore.origin = CGPoint(x: origin.x + extraPanelWidth / 2, y: origin.y)
}

/// Persistence for the dragged origin lives in the engine next to the clamping
Expand Down
11 changes: 8 additions & 3 deletions App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -281,12 +281,17 @@ final class DictationCoordinator {
case .processing: .processing
case .pasted: .pasted
case .noTarget: .copied
case .error: .error
case .error, .inputSilent: .error
}
}

private static func message(_ state: OverlayUIState) -> String? {
guard case .error(let message) = state else { return nil }
return message
switch state {
case .error(let message): message
// No wide pill on the iPhone: the silent mic reads as an error, its sentence
// (with the device name) as the message.
case .inputSilent: state.accessibilityLabel
case .idle, .connecting, .recording, .processing, .pasted, .noTarget: nil
}
}
}
1 change: 1 addition & 0 deletions App/BlurtiOS/BlurtiOSCore/HomeStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ package nonisolated struct HomeStatus: Equatable {
case .pasted: "Pasted"
case .noTarget: "Copied"
case .error(let message): message
case .inputSilent: overlay.accessibilityLabel
case .idle: windowOpen ? "Ready to dictate" : "Not listening"
}
}
Expand Down
2 changes: 2 additions & 0 deletions App/BlurtiOS/BlurtiOSCore/VoiceState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ package nonisolated struct VoiceState: Equatable {
case .pasted: self.init(phase: .pasted, isReady: windowOpen)
case .noTarget: self.init(phase: .copied, isReady: windowOpen)
case .error(let message): self.init(phase: .error, isReady: windowOpen, message: message)
case .inputSilent:
self.init(phase: .error, isReady: windowOpen, message: overlay.accessibilityLabel)
}
}

Expand Down
8 changes: 8 additions & 0 deletions Sources/BlurtEngine/Audio/AudioInputDevices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ public enum AudioInputDevices {
systemDefaultDevice?.localizedName
}

/// The name of the input a press records from — the pinned device, or the
/// system default when there is no pin (`MicCapture.ResolvedInput` has already
/// folded a missing pin into nil) — or nil when that device can't be found.
static func inputName(pinnedUID: String?) -> String? {
guard let pinnedUID else { return systemDefaultInputName() }
return device(forUID: pinnedUID)?.localizedName
}

/// The transport type of the device carrying this UID, or nil when no
/// connected device does.
///
Expand Down
6 changes: 5 additions & 1 deletion Sources/BlurtEngine/Audio/MicCapture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,11 @@ public actor MicCapture: MicCaptureProtocol {
// so no stopGeneration bump — a concurrent stop() correctly sees no active
// capture.
guard gap != nil else {
throw BlurtError.audioCaptureFailed(underlying: MicCaptureError.inputNeverDelivered)
// Named only here, on the failure path, so a press that succeeds pays no
// extra device lookup.
throw BlurtError.audioCaptureFailed(
underlying: MicCaptureError.inputNeverDelivered(
deviceName: AudioInputDevices.inputName(pinnedUID: resolved.pinnedUID)))
}

activeRecorder = recorder
Expand Down
12 changes: 10 additions & 2 deletions Sources/BlurtEngine/Audio/MicCaptureError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,20 @@ enum MicCaptureError: LocalizedError {
/// `MicLiveness`'s cap — the device delivered nothing (or only digital
/// silence) for the whole wait, so the press fails closed rather than
/// recording an utterance the mic isn't receiving.
case inputNeverDelivered
///
/// Carries the silent device's name, because the device is usually the whole
/// story: an aggregate whose real interface is unplugged, or a loopback like
/// BlackHole set as the default input, records nothing while every other mic
/// works. "The microphone didn't start" sent users hunting for a Blurt bug;
/// naming the device points them at their audio settings. Nil when the name
/// can't be read (the device vanished mid-press).
case inputNeverDelivered(deviceName: String?)

var errorDescription: String? {
switch self {
case .noInputDevice: "No microphone is available."
case .inputNeverDelivered: "The microphone didn't start."
case .inputNeverDelivered(let deviceName?): "\(deviceName) isn't sending any audio."
case .inputNeverDelivered(nil): "The microphone isn't sending any audio."
}
}
}
4 changes: 4 additions & 0 deletions Sources/BlurtEngine/BlurtError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ extension BlurtError: LocalizedError {
case .apiKeyMissing: "Add your AssemblyAI API key in Settings to start dictating."
case .sttFailed(let underlying): "Transcription failed: \(underlying.localizedDescription)"
case .targetAppLost: "Target app lost focus or quit."
// `MicCaptureError`'s messages are already written for the user and name the
// problem, so the prefix would only bury the device name; anything else a
// capture throws keeps it as context.
case .audioCaptureFailed(let underlying as MicCaptureError): underlying.localizedDescription
case .audioCaptureFailed(let underlying): "Audio capture failed: \(underlying.localizedDescription)"
case .noEditableTarget: "No text field was focused — copied to the clipboard instead."
}
Expand Down
Loading
Loading