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
29 changes: 20 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -945,14 +945,19 @@ the `@AppStorage(TriggerKeyStore.defaultsKey)` + `TriggerKey.fromPersisted` pair
restating that pairing per view. The unset default belongs to `fromPersisted` (an absent keycode maps
to right ⌘), so views must not re-declare `TriggerKey.rightCommand.rawValue` themselves.

**Per-key styles.** A Key Styles row in the Advanced pane's Experimental section (`KeyStylesRow`) — a status naming the
**Per-key styles.** A Key Styles row in its own section on the Styles pane (`KeyStylesSection`, `KeyStylesRow`) — a status naming the
keys set up, and an Edit… button — opens a sheet with one pop-up per `TriggerKey`: the style a press
of that key dictates with — Same as main window, Default, or a profile (disabled while enhanced
transcripts are off) — or Off for keys other than the bound one. On Advanced rather than under the
key picker because it's a power-user extra, so General stays short enough for a small display; a
row in an existing section and a sheet, not a section or a row per key, because Advanced is already
near that limit — a section of its own pushed Reset… off the bottom of CI's screen. `TriggerKeyStyleStore` (`BlurtTriggerKeyStyles`, keycode → style id) holds
it; an absent value is today's single key with the current style. Each key is still a lone modifier
transcripts are off) — or Off for keys other than the bound one. On Styles, under the profiles it
picks from, rather than under the key picker, so General stays short enough for a small display;
its own section rather than a row of Custom Styles, because that section is disabled while
enhanced transcripts are off and turning a key on or off still works then; one row and a sheet,
not a row per key, to keep the pane short. `TriggerKeyStyleStore` (`BlurtTriggerKeyStyles`, keycode → style id) holds
it; an absent value is today's single key with the current style. Rebinding swaps each moved key's
entry with a stash (`BlurtTriggerKeyStyleStash`): the key moved away from goes back to what it was
as an extra before it was bound (off, unless the user had set it up as one), and gets its bound
style back if it is bound again. Turning another key on or off doesn't reset the router, so a
recording latched while the sheet is open survives it. Each key is still a lone modifier
from the same set, watched by the same listen-only tap. The press command carries its key
(`DictationSession.Command.press(key:)`), and the session resolves that key's style once, on the
press's own turn, into a `PressStyle` value: its instructions ride the request's context, its name
Expand Down Expand Up @@ -1220,9 +1225,15 @@ refused utterance itself can't be retried, its audio having streamed as it was s
early) defers the next one to the key's expiry, or a day out once it has expired, instead of asking
every minute. Renewal keeps a rotated refresh token before anything else can fail; a refused refresh
or exchange means the connection was ended elsewhere and drops key and session, an unreachable server
changes nothing. A renewal suspends twice on the network, so a `generation` counter makes one that
wakes after a sign-out, reset or new sign-in write nothing, and `signOut()` waits out a renewal in
flight first. `signOut()` ends the connection server-side (`POST /api/v2/oauth/api-key/revoke`) and
changes nothing. "Refused" at the token endpoint means an `invalid_grant` or `invalid_client` error,
not any 400: an `invalid_request` mid-deploy or a proxy's page says nothing about the grant. A
renewed key the Keychain won't take leaves the session's dates alone, so the renewal stays due. A
renewal suspends twice on the network, so a `generation` counter makes one that
wakes after a sign-out, reset or new sign-in write nothing; `signOut()` waits out a renewal in
flight first, starts none while it talks to the server, and keeps a sign-in that lands meanwhile. A
sign-in whose key the Keychain refuses puts the earlier session back and revokes the grant it just
minted. An unreadable session item (a locked Keychain) is looked at again every five minutes, and the
loop re-reads the status each pass. `signOut()` ends the connection server-side (`POST /api/v2/oauth/api-key/revoke`) and
only then forgets it here; a revoke the server couldn't confirm (503) leaves everything in place and
returns an inline report so the user can retry (`SignInFailure.failureReport` makes the same
inline-vs-alert split as `APIKeySubmission`). `CredentialRows.resolve` decides which of the account
Expand Down
2 changes: 1 addition & 1 deletion App/Blurt/Blurt/Overlay/OverlayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ struct OverlayView: View {
var body: some View {
content
// The width is the bridge's: standard, or widened by the controller for a
// notice that writes its reason on the pill (`OverlayUIState.pillText`).
// notice that writes its reason on the pill (`OverlayUIState.pillNotice`).
.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
Expand Down
4 changes: 2 additions & 2 deletions App/Blurt/Blurt/Overlay/OverlayWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ 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`):
// Ceiling for a pill that writes its reason out (`OverlayUIState.pillNotice`):
// 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
Expand Down Expand Up @@ -317,7 +317,7 @@ final class OverlayWindowController {
/// 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 }
guard let text = state.pillNotice?.text else { return pillSize.width }
let measured = NSAttributedString(
string: text.uppercased(),
attributes: [
Expand Down
7 changes: 2 additions & 5 deletions App/Blurt/Blurt/Wizard/DeveloperSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,12 @@ struct UpdatesSection: View {
}
}

/// The Advanced pane's experimental features — per-key styles (`KeyStylesRow`),
/// read-aloud (see `SelectionSpeechStore`), asking about the selection
/// The Advanced pane's experimental features — read-aloud (see
/// `SelectionSpeechStore`), asking about the selection
/// (`SelectionAskStore`) and how a read sounds (`ReadAloudStyleStore`). The
/// router reads the same defaults these controls write at every press, so a
/// change applies to the next one.
struct ExperimentalSection: View {
var coordinator: AppCoordinator

@AppStorage(SelectionSpeechStore.defaultsKey) private var selectionSpeech = false
@AppStorage(SelectionAskStore.defaultsKey) private var selectionAsk = SelectionAskStore.defaultValue
@AppStorage(ReadAloudStyleStore.speedDefaultsKey) private var speed = ReadAloudStyleStore.defaultSpeed
Expand Down Expand Up @@ -90,7 +88,6 @@ struct ExperimentalSection: View {

var body: some View {
Section {
KeyStylesRow(coordinator: coordinator)
Toggle(isOn: $selectionSpeech) {
SettingLabel(title: "Read selected text aloud", systemImage: "speaker.wave.2")
}
Expand Down
33 changes: 24 additions & 9 deletions App/Blurt/Blurt/Wizard/KeyStylesRow.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,27 @@
import BlurtEngine
import SwiftUI

/// The Key Styles row in a section of its own, under the styles it picks from.
/// Not a row of Custom Styles: that section is disabled while enhanced
/// transcripts are off, and turning a key on or off still works then (the
/// sheet disables only the styles).
struct KeyStylesSection: View {
let coordinator: AppCoordinator

var body: some View {
Section {
KeyStylesRow(coordinator: coordinator)
} footer: {
Text("Use a different style with each key, without switching styles in the main window.")
}
}
}

/// The Key Styles row: a status and an "Edit…" button, like the API key row,
/// whose sheet gives each trigger key its own style (or Off, for keys other
/// than the bound one). The first row of the Advanced pane's Experimental
/// section rather than beside the dictation key on General — it's a
/// power-user extra, and a section of its own made Advanced too tall for a
/// small display. Settings-only, so it is not a wizard step.
/// than the bound one). On the Styles pane, under the styles it picks from,
/// rather than beside the dictation key on General, which has to stay short
/// enough for a small display. Settings-only, so it is not a wizard step.
struct KeyStylesRow: View {
var coordinator: AppCoordinator

Expand Down Expand Up @@ -40,7 +55,7 @@ struct KeyStylesRow: View {
bound: bound, styles: TriggerKeyStyleStore.styles(decoding: keyStylesRaw),
profiles: StyleProfileStore().profiles(decoding: profilesRaw))
let changed = entries.filter(\.isChanged).map(\.text)
SettingRow(title: "Key Styles", systemImage: "textformat") {
SettingRow(title: "Key Styles", systemImage: "keyboard") {
HStack(spacing: 8) {
// The keys set up beyond the default, or the main window's style.
Text(changed.isEmpty ? KeyStylesSheet.mainWindowName : changed.joined(separator: ", "))
Expand Down Expand Up @@ -83,7 +98,7 @@ private struct KeyStylesSheet: View {
/// profile — `choice` is one `TriggerKeyStyleStore.choice` has normalized.
static func name(of choice: String, profiles: [StyleProfile]) -> String {
switch choice {
case "": offName
case TriggerKeyStyleStore.offChoice: offName
case TriggerKeyStyleStore.currentStyleID: mainWindowName
default: StyleProfileStore.active(in: profiles, id: choice)?.name ?? StyleProfileStore.defaultStyleName
}
Expand All @@ -102,7 +117,7 @@ private struct KeyStylesSheet: View {
accessibilityID: UITestIdentifiers.keyStylePicker(key.keyCode),
selection: choice(for: key, styles: styles, profiles: profiles)
) {
if key != bound { Text(Self.offName).tag("") }
if key != bound { Text(Self.offName).tag(TriggerKeyStyleStore.offChoice) }
Text(Self.mainWindowName).tag(TriggerKeyStyleStore.currentStyleID)
Group {
Text(StyleProfileStore.defaultStyleName).tag(StyleProfileStore.defaultStyleID)
Expand Down Expand Up @@ -145,13 +160,13 @@ private struct KeyStylesSheet: View {
VStack(alignment: .leading, spacing: 6) {
Text("Key Styles")
.font(.headline)
Text("Each key can dictate with its own style, without changing the style selected in the main window.")
Text("Pick a style for each key. Turn another key on to dictate with it too.")
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}

/// One key's pop-up: `""` is Off, otherwise a style id (see `TriggerKeyStyleStore`).
/// One key's pop-up: `offChoice` is Off, otherwise a style id (see `TriggerKeyStyleStore`).
private func choice(
for key: TriggerKey, styles: [TriggerKey: String], profiles: [StyleProfile]
) -> Binding<String> {
Expand Down
22 changes: 13 additions & 9 deletions App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ struct SettingsWindowRoot: View {
TabView {
GeneralSettingsTab(coordinator: coordinator)
.tabItem { Label(UITestIdentifiers.generalSettingsTab, systemImage: "gearshape") }
StylesSettingsTab()
StylesSettingsTab(coordinator: coordinator)
.tabItem { Label(UITestIdentifiers.stylesSettingsTab, systemImage: "textformat") }
VocabularySettingsTab()
.tabItem {
Expand Down Expand Up @@ -66,21 +66,25 @@ private struct GeneralSettingsTab: View {
}
}

/// The enhanced-transcripts switch and the user's style profiles — their own
/// pane rather than an Advanced section. The switch leads because styles only
/// shape the enhanced rewrite: with it off they're disabled, and the switch that
/// re-enables them sits directly above rather than on another pane.
/// The enhanced-transcripts switch, the user's style profiles, and which style
/// each trigger key dictates with — their own pane rather than an Advanced
/// section. The switch leads because styles only shape the enhanced rewrite:
/// with it off they're disabled, and the switch that re-enables them sits
/// directly above rather than on another pane.
private struct StylesSettingsTab: View {
let coordinator: AppCoordinator

var body: some View {
SettingsPane {
TranscriptionSection()
StyleProfilesSection()
KeyStylesSection(coordinator: coordinator)
}
}
}

/// The occasional stuff: checking for an update, experimental features
/// (per-key styles among them), the developer-mode log toggle, and the
/// (read-aloud), the developer-mode log toggle, and the
/// start-over button. Kept out of General so
/// the common pane stays short.
private struct AdvancedSettingsTab: View {
Expand All @@ -90,7 +94,7 @@ private struct AdvancedSettingsTab: View {
var body: some View {
SettingsPane {
UpdatesSection(model: updaterModel)
ExperimentalSection(coordinator: coordinator)
ExperimentalSection()
MaintenanceSection()
ResetSection(coordinator: coordinator)
}
Expand Down Expand Up @@ -196,7 +200,7 @@ private struct StyleProfilesSection: View {
// Ellipsis for the same reason as the API-key row's "Connect…": the
// action needs more input before it completes.
Button("Add Style…") { editing = StyleProfile(name: "", instructions: "") }
.disabled(profiles.count >= StyleProfileStore.profileLimit)
.disabled(profiles.count >= StyleProfileStore.limit)
.accessibilityIdentifier(UITestIdentifiers.styleProfileAdd)
} header: {
Text("Custom Styles")
Expand All @@ -208,7 +212,7 @@ private struct StyleProfilesSection: View {
enhancedTranscripts
? "A style tells Blurt how to polish your dictations — for example, “casual, all "
+ "lowercase” for chat. Switch between styles in the main window. Create up to "
+ "\(StyleProfileStore.profileLimit) styles."
+ "\(StyleProfileStore.limit) styles."
: StylesInertNotice.settingsText)
}
.disabled(!enhancedTranscripts)
Expand Down
42 changes: 16 additions & 26 deletions App/Blurt/Blurt/Wizard/Steps/AccountRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,37 +15,35 @@ struct AccountRowView: View {
@State private var signIn = AccountSignIn()
/// A sign-in or sign-out in flight; the button is disabled so they can't overlap.
@State private var isWorking = false
/// A recoverable failure, shown in red under the row until the next attempt.
@State private var inlineError: String?
@State private var fault: Fault?
/// The last attempt's failure, until the next attempt: a recoverable one in
/// red under the row, a Keychain fault as an alert. The strings are the
/// engine's.
@State private var failure: APIKeySubmission.FailureReport?

/// The alert-shaped failure, carried as view state so the alert can present
/// it. The strings are the engine's.
private struct Fault {
let title: String
let message: String
private var alert: (title: String, message: String)? {
if case .alert(let title, let message) = failure { (title, message) } else { nil }
}

private var isPresentingFault: Binding<Bool> {
Binding(get: { fault != nil }, set: { if !$0 { fault = nil } })
private var isPresentingAlert: Binding<Bool> {
Binding(get: { alert != nil }, set: { if !$0 { failure = nil } })
}

var body: some View {
// A `Group` so the row and its inline error stay sibling rows of the
// parent `Section` while sharing the alert and the teardown.
Group {
row
if let inlineError {
if case .inline(let message) = failure {
// Red, no caution glyph: "retry this", not "something broke".
Text(inlineError)
Text(message)
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
}
.alert(fault?.title ?? "", isPresented: isPresentingFault, presenting: fault) { _ in
.alert(alert?.title ?? "", isPresented: isPresentingAlert, presenting: alert) { _ in
Button("OK", role: .cancel) {}
} message: { fault in
Text(fault.message)
} message: { alert in
Text(alert.message)
}
// The row goes away with its window or a Settings tab switch; either ends
// the browser session too, since nothing would be left to finish it.
Expand Down Expand Up @@ -85,28 +83,20 @@ struct AccountRowView: View {
Task {
let outcome = await signIn.run(account)
isWorking = false
if case .failed(let failure) = outcome { report(failure.failureReport) }
if case .failed(let reason) = outcome { failure = reason.failureReport }
}
}

private func signOut() {
begin()
Task {
let failure = await account.signOut()
failure = await account.signOut()
isWorking = false
if let failure { report(failure) }
}
}

private func begin() {
isWorking = true
inlineError = nil
}

private func report(_ report: APIKeySubmission.FailureReport) {
switch report {
case .inline(let message): inlineError = message
case .alert(let title, let message): fault = Fault(title: title, message: message)
}
failure = nil
}
}
2 changes: 1 addition & 1 deletion App/Blurt/Blurt/Wizard/Steps/HotkeyStepView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import SwiftUI
/// The dictation-key section of the setup/settings screen. Two menu pickers: which
/// lone modifier triggers dictation, and whether it activates by tap (toggle),
/// hold (push-to-talk), or both; changes are persisted and pushed to the event tap
/// immediately. Giving the other trigger keys their own styles is the Advanced
/// immediately. Giving the other trigger keys their own styles is the Styles
/// pane's `KeyStylesRow`.
struct HotkeyStepView: View {
var coordinator: AppCoordinator
Expand Down
14 changes: 7 additions & 7 deletions App/Blurt/BlurtUITests/SettingsUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ final class SettingsUITests: BlurtUITestCase {
/// right ⌘ is the bound key: it dictates with the main window's style and, since
/// the bound key always dictates, offers no Off; the other keys start Off.
func testKeyStylesSheetListsEveryKey() {
let sheet = openKeyStyles(openAdvancedSettings())
let sheet = openKeyStyles(openStylesSettings())

for keyCode in [rightCommand, rightOption, fnKey] {
XCTAssertTrue(
Expand All @@ -268,7 +268,7 @@ final class SettingsUITests: BlurtUITestCase {
/// Turning right ⌥ on persists: the button's spoken summary names it, and the
/// sheet shows the choice again when reopened.
func testKeyStyleChoicePersists() {
let settings = openAdvancedSettings()
let settings = openStylesSettings()
let sheet = openKeyStyles(settings)

sheet.popUpButtons[UITestIdentifiers.keyStylePicker(rightOption)].click()
Expand All @@ -290,7 +290,7 @@ final class SettingsUITests: BlurtUITestCase {
/// Escape closes the sheet (it has no Cancel button to carry the shortcut) and
/// leaves the Settings window open.
func testEscapeClosesKeyStylesSheet() {
let settings = openAdvancedSettings()
let settings = openStylesSettings()
let sheet = openKeyStyles(settings)

app.typeKey(.escape, modifierFlags: [])
Expand All @@ -299,12 +299,12 @@ final class SettingsUITests: BlurtUITestCase {
XCTAssertTrue(settings.exists, "Escape should close only the sheet, not Settings")
}

/// Returns the Advanced pane, where the Key Styles row lives.
private func openAdvancedSettings() -> XCUIElement {
selectSettingsTab(openSettingsWindow(), named: UITestIdentifiers.advancedSettingsTab)
/// Returns the Styles pane, where the Key Styles row lives.
private func openStylesSettings() -> XCUIElement {
selectSettingsTab(openSettingsWindow(), named: UITestIdentifiers.stylesSettingsTab)
}

/// Opens the Key Styles sheet from its row's Edit… button on the Advanced
/// Opens the Key Styles sheet from its row's Edit… button on the Styles
/// pane and returns it.
private func openKeyStyles(_ settings: XCUIElement) -> XCUIElement {
let button = settings.buttons[UITestIdentifiers.keyStylesButton]
Expand Down
Loading
Loading