diff --git a/AGENTS.md b/AGENTS.md index f9019e8b..64cf503c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -902,10 +902,9 @@ three of them pure engine logic: defaulting to **right ⌘**. - **`DictationKeyGate`** — pure, clock-free state machine (`idle`/`armed`/`latched`) turning modifier-down/up and other-key-down into `start`/`stop`/`cancel`/`none`. Recording starts the - instant the modifier goes down; what a key-up means is that key's **`TriggerActivation`** - (`TriggerKeyBindings.activation(for:)`: the key's own mode in `TriggerKeyActivationStore`, else - tap-or-hold for a key that reads aloud, else `TriggerActivationStore`'s shared mode, default - tap-or-hold): under tap-or-hold a release ≥ `holdThreshold` (default 1 s) is a **hold** + instant the modifier goes down; what a key-up means is the **`TriggerActivation`** every key + shares (`TriggerKeyBindings.activation`, from `TriggerActivationStore`, default tap-or-hold — + one mode for all keys, owner-directed 2026-11-04, after per-key modes were tried): under tap-or-hold a release ≥ `holdThreshold` (default 1 s) is a **hold** (push-to-talk → stop) while a shorter release **latches** (tap-to-toggle; next tap stops); tap-only latches on every release, hold-only stops on every release. A combo (modifier + another key, e.g. ⌘C) from idle cancels the fresh capture; over a latched recording it @@ -956,20 +955,27 @@ defaults and the rules; views that must re-render live on a Settings change use through `@AppStorage` and builds the snapshot from them. The unset defaults belong to the bindings (an absent keycode maps to right ⌘), so views must not restate them. -**Keys.** Settings' Keys tab, after General (`KeysSettingsTab`), is a table with two pop-ups per -`TriggerKey`: its action (`TriggerKeyChoice`) — Off, dictate (with the main window's style, no custom style, or a profile; the -styles disabled while enhanced transcripts are off), Read Selection Aloud, Ask About Selection, Rewrite Selection, or Control Your Mac — and whether it is -tapped, held, or either. How read-aloud sounds (voice, speed, skipping jargon — +**Keys.** Settings' Keys tab, after General (`KeysSettingsTab`), has one row per action +(`TriggerKeyChoice` minus Off) — dictate (with the main window's style, no custom style, or each +profile; the styles disabled while enhanced transcripts are off), Read Selection Aloud, Ask About +Selection, Rewrite Selection, Control Your Mac — each with a pop-up choosing its key or None +(`TriggerKeyStyleStore.assign(_:to:)`: a key does one thing, so choosing one in use moves it, and +the Dictate row's key becomes the bound key), then one Activation pop-up for every key. Each key's start/stop cue voice is the Sounds tab's, after Keys +(`SoundsSettingsTab`): one row per key that isn't Off. `TriggerKeySoundStore` +(`BlurtTriggerKeySounds`, keycode → pack id) holds the ones set; a key with no entry plays its +action's setting (`SoundPackStore.fallback(for:)` — read-aloud's, Control Your Mac's, or +dictation's for every recording action), so an untouched install sounds as it did before keys had +voices. `CueSoundPlayer` keeps a pair per key and plays the one whose press started the recording +or read (`AppCoordinator`'s `onStart` tells it first). How read-aloud sounds (voice, speed, skipping jargon — `ReadAloudStyleStore`) is its own Settings tab, Read Aloud, before Styles (`ReadAloudSettingsTab`); picking a voice plays a sample of it, as picking a cue sound does. Its own tab, not rows on General, because General has to stay short enough for a small display (three per-key rows pushed the cue-sound picker off CI's screen). `TriggerKeyStyleStore` (`BlurtTriggerKeyStyles`, keycode → choice, the stored form a style id had -before read and ask) and `TriggerKeyActivationStore` (`BlurtTriggerKeyActivations`, keycode → mode, -the bound key's included) are the writes; both encode through `TriggerKeyMap`. An absent choice is -the key's default — the bound key dictating with the current style, left ⌥ reading aloud, the rest -off — so Off is stored explicitly where it isn't the default; an absent mode follows -`TriggerActivationStore`'s shared one, so an install from before per-key modes reads unchanged. The +before read and ask, encoded through `TriggerKeyMap`) and `TriggerActivationStore` +(`BlurtTriggerActivation`) are the writes; the action rows are a view over the keycode map. An +absent choice is the key's default — the bound key dictating with the current style, left ⌥ +reading aloud, the rest off — so Off is stored explicitly where it isn't the default. The bound key (`TriggerKeyStore`, the one the ready screen and menu bar name) always dictates: `TriggerKeyStyleStore.setChoice` refuses to take the only dictating key off dictation, and taking the bound key off it moves the binding to the next dictating key first. A key's entries mean the @@ -1477,20 +1483,41 @@ restating them. `App/Blurt/Blurt/App.swift` declares four scenes (five in Debug): - **Main window** (`MainWindowRoot`) — the setup wizard until the app is fully configured, then the - "ready" screen (`ReadyView`: the wordmark over a grouped `Form` — the same surface as the Settings - panes, no hand-drawn cards. A Shortcut row trails the bound key as a keycap that fills with the - accent while the key is down, beside the configured activation ("Tap or hold", "Tap" or "Hold") - swapped for "Listening…"/"Transcribing…" on the same phase stream the pill renders, with Esc - cancelling while this window is key; a Style row's - pop-up — Default plus up to 4 profiles and an "Edit Styles…" item that opens Settings on its Styles - pane, selectable by ⌘1–⌘5, locked during capture — with a footer only while enhanced transcripts - are off, saying styles don't apply; then a Recent section, one form row per dictation trailing the - style it was made with and the relative time as plain secondary text, swapped for a Copy - affordance on hover, and when empty a first-dictation prompt. A bottom bar - holds "Powered by AssemblyAI · Report a bug" and a plain Settings button; the LinkedIn share is in - the Help menu). Standard titlebar, always presented at launch. + "ready" screen (`ReadyView`), shaped like a dictation app's home: Blurt's work happens outside the + window, on the keys, so they lead — a legend under a Keys heading on a soft translucent panel (not a white card: + only Library entries are cards) with each configured key's action and the key as a keycap (`KeyCap`) by its + full name ("Right Command", the name every Settings row uses), so it reads as a key to press, not a + control — not clickable, and nothing in the window reacts while a key is down (how a dictation is going is the pill's to say); once a custom style + exists, a Style row's pop-up (Default plus up to 4 profiles, ⌘1–⌘5) closes + the card, with a footer only while enhanced transcripts are off. Below it, the Library as a feed of + what Blurt has done. The whole window scrolls as one list, as Journal's (the keys lead it and scroll + away, the scroller is the window's), on a pale wash of the brand green (`BlurtBrand.backdrop`) with no rule + under the toolbar; the entries are white cards (`LibraryCard`). The Library is a stock `List` drawn as cards (row + backgrounds and insets; the table's own highlight turned off so a selected card takes a Journal-style + accent ring): a click selects, double-click or Return opens, ⌘C copies, Delete deletes, arrows move; + day headings are ordinary rows, so they scroll rather than pin. Entries are compact cards with the time on the details line and a ⋯ menu + of the entry's actions (Open, Play, Copy, Show in Finder, Delete…), and when empty a + first-dictation prompt. The unified toolbar holds Record and + Browse leading, then the Library's Filter menu (Mail's: one kind or all, the icon filled while a + filter is on), Settings, and the search (`.searchable`, ⌘F: its own capsule at the trailing edge, + widening when clicked into, as Finder's and Mail's do) with no title beside it. Esc cancels a dictation while the window + is key. Opening an item — and a recording, or a file going up — slides the window over to it, as an + entry does in Journal (`TranscriptPage`): a back button leads the toolbar on its own, the item's + title heads the page (in the toolbar it crowded the actions into a » menu), and its actions + follow as icons — the View menu (✨: the transcript, Plain text, the stock tasks, + Custom…), Play and Stop — the entry's own audio, or speech generated on the spot (a spinner until + the first word), Copy, Show in Finder, Delete — over the transcript. Nothing there is edited, so + there's no Done or check mark: back (or Esc) slides back to the Library, abandoning a file going up; + a recording holds the page until it's stopped or discarded. A file comes in four ways, as + a Mac app's do: dropped anywhere on the window (`audioFileDropTarget` — no drop zone at rest, an + accent overlay saying "Drop to Transcribe" only while a file is held over it), dropped on the Dock + icon or opened from Finder's Open With (`CFBundleDocumentTypes`, an Alternate handler for + `public.audiovisual-content`, through `AppDelegate.application(_:open:)`), File › Open… (⌘O), or + Browse; each goes through `TranscriptsModel.openFiles`, which refuses anything but audio or video + with a reason. No footer: Report a bug and the LinkedIn share are in the Help menu. Always presented + at launch. - **Settings** (`SettingsWindowRoot`) — a `TabView` reached via ⌘, / the menu bar, - never at launch. General holds the everyday setup (key, shortcut, mic, cue); Styles leads with the + never at launch. General holds the everyday setup (account, mic, meeting auto-record); Keys holds an action-per-row table with one activation mode; Sounds holds each key's cue voice; Styles leads with the enhanced-transcripts switch (styles only shape that rewrite) above the style profiles; Vocabulary holds the key terms and the text-shortcut table (+ / −, double-click to edit); Advanced holds the update check, the developer-mode toggle, and the **reset** — one destructive button running the engine's `InstallReset` (the same sweep as diff --git a/App/Blurt/Blurt.xcodeproj/project.pbxproj b/App/Blurt/Blurt.xcodeproj/project.pbxproj index 7383ddad..fceb04c2 100644 --- a/App/Blurt/Blurt.xcodeproj/project.pbxproj +++ b/App/Blurt/Blurt.xcodeproj/project.pbxproj @@ -29,7 +29,6 @@ 0A7FB8A90BB7E7A5110A4F4D /* BlurtEngine in Frameworks */ = {isa = PBXBuildFile; productRef = 371215E93A9C2CE60C6347BB /* BlurtEngine */; }; 0BAB613C778CDF7691FAE6E6 /* rom1b-11-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 4052A873788D31A33BE80FCB /* rom1b-11-stop.m4a */; }; 0CB2E6C02E57374806139E68 /* juno-60-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 24B8138546D9CD21F6D55F54 /* juno-60-start.m4a */; }; - 0CBFABA54AEF0C1060C14623 /* TranscriptSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00C696D5D5EB4F5A4FBE5752 /* TranscriptSheet.swift */; }; 0D4AE0F936EAA5E3F0CE96AD /* rom1b-5-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = F2B1262C9AB83ED251421D03 /* rom1b-5-start.m4a */; }; 0DB94677FDF38489C7CE3E25 /* blurt-ready-logo.png in Resources */ = {isa = PBXBuildFile; fileRef = 49B8C7CF9FC84D9E3B96796B /* blurt-ready-logo.png */; }; 0F06FD2F6DB2831867C8B681 /* juno-52-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = ED1535C5BDC865063C5DA3A3 /* juno-52-stop.m4a */; }; @@ -103,6 +102,7 @@ 2F5526E7BF0E08FE4E74F2B2 /* juno-69-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = DED921D522FA87E2970C2EFF /* juno-69-stop.m4a */; }; 3015C1E46AE0ECA3A96F819C /* juno-69-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 6A29FFB86C18EA299EAFFCFD /* juno-69-start.m4a */; }; 3182C793A397A4AC6BF4F24D /* AccountSignIn.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC096B44D037E7BB119609A /* AccountSignIn.swift */; }; + 31A4E70BB32A269EAC5F782A /* LibraryCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4ACFC5071B68C432A875B39 /* LibraryCard.swift */; }; 3246BF1C37C7A027817D9458 /* juno-110-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = E552326770AD5967B8128420 /* juno-110-stop.m4a */; }; 336F75E5060815018D79ACEB /* rom1b-21-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 01B663528C4E1AFEF24675E1 /* rom1b-21-start.m4a */; }; 33C581B5DE3847C05F540701 /* juno-81-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 3FCE941C19A3FF0DC61462B7 /* juno-81-stop.m4a */; }; @@ -203,7 +203,6 @@ 691207BE65B615EF4C9B77C1 /* rom1b-14-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = DB1715E594E1F2A1A3F152E0 /* rom1b-14-stop.m4a */; }; 697C21589BFDE5A66DD168A1 /* juno-67-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 5D1E0161AF60ADB6D903535E /* juno-67-start.m4a */; }; 69C396E4080D37117F2AEB01 /* juno-87-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 9D02AECDAE6B146F11B7A721 /* juno-87-stop.m4a */; }; - 69E27229D8C64D7119918B7A /* SoundStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27E18FF6880EADB94F98C7C4 /* SoundStepView.swift */; }; 6A1402BFED3758AAD9C84ADF /* SettingRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 254D4D3CAC1C114EC51C96C8 /* SettingRow.swift */; }; 6A498D9EE4EDDA7B47837FB6 /* juno-73-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = D6CA551786B56B10B45B9BF3 /* juno-73-start.m4a */; }; 6B3FF5991DA85C563D97B83C /* juno-109-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 47B0B08D890A1A7CB4577410 /* juno-109-start.m4a */; }; @@ -241,7 +240,6 @@ 82B97735B1714774843153EF /* juno-90-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 79F8F36D7C5D074BC5135FEA /* juno-90-start.m4a */; }; 84958721E6DEB4D06E1A01B6 /* juno-42-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = F77424E89C3E4B5B787BDFA4 /* juno-42-stop.m4a */; }; 8736CF7025B1EE7C295218D7 /* juno-9-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 8887DCE124B065F8313AC59F /* juno-9-start.m4a */; }; - 875E4A36097D4366AB281C12 /* FlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC36DD628AE3887680B120AA /* FlowLayout.swift */; }; 87B7038A5385E149C978908B /* juno-15-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 0386BF281886EDBFE9EC8C61 /* juno-15-start.m4a */; }; 87F937969B18876084CF0B7D /* rom1a-13-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 605E69165B4D2A9B8530606A /* rom1a-13-start.m4a */; }; 88E50D191E0D56142A774676 /* rom1a-31-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = AFCA36ECEFBA3D7C7B1AB1B7 /* rom1a-31-start.m4a */; }; @@ -341,20 +339,20 @@ B6B9598209C21737FC80D9FC /* MenuBarUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63869ED0BF971F732668349B /* MenuBarUITests.swift */; }; B79801F65857FFF5829AE13A /* HarnessDriving.swift in Sources */ = {isa = PBXBuildFile; fileRef = C23288EE0E6273C0D7603791 /* HarnessDriving.swift */; }; B844779F8427A1225C45A9FB /* WizardController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30E57A9C9B934A2120381005 /* WizardController.swift */; }; - BA2CE80B16AB80F1AA942884 /* TranscribeRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2216A871A3EB7FEC8B9CC42 /* TranscribeRow.swift */; }; BA31A2D639A9EBEAFA22DF5D /* juno-63-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = E97DEAE3F8FD9ADD7DB41B02 /* juno-63-stop.m4a */; }; BAC7996520BFCAA7BE0F929F /* juno-125-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 72144FD6362B731D322BA316 /* juno-125-stop.m4a */; }; BB6AD6072811807783AF6526 /* juno-87-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = A72EE584A3704166E932D950 /* juno-87-start.m4a */; }; BBFE6E3BBB854163861E1450 /* juno-26-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = CE3BEBDADD5DD54629837F34 /* juno-26-stop.m4a */; }; BC3195AFF07AA2FA98486A3F /* rom1a-14-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 1DE1622660807FF440D18D34 /* rom1a-14-stop.m4a */; }; + BC4D5F7EC52E0BE8CE4E1C37 /* AudioFileDropTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = D574A31ACB311D98BBE107F6 /* AudioFileDropTarget.swift */; }; BC5D17A158C17F6D5450FFC4 /* rom1a-9-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = F17631D5583E0070A4DFCF3F /* rom1a-9-start.m4a */; }; BDC3E7A0E9E6A349E340459D /* juno-115-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 338537DEC8694C87839545CC /* juno-115-start.m4a */; }; BE6EA55FEF13362210107437 /* juno-7-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 627207A211954DBC35179466 /* juno-7-stop.m4a */; }; + BEAC8381714F51F8506775B5 /* SoundsSettingsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17E3218A976D2B1B5173112 /* SoundsSettingsTab.swift */; }; BF1A9E4424DCFC45D3EC9376 /* juno-112-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = F40AB81C66FD9183F49BF644 /* juno-112-stop.m4a */; }; BF6D4D09C97777C6A792D6A5 /* juno-24-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = E53A15B7450BFE6143B4306D /* juno-24-stop.m4a */; }; BF73DA6BFC2C267E1D0B4510 /* rom1a-20-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = D58930841AD9EB26714E8385 /* rom1a-20-start.m4a */; }; C138D17460660BF867DFBACD /* juno-38-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 22FF5E0C4681C483B4828FB2 /* juno-38-start.m4a */; }; - C13B68C111B47E70D1EF0ED2 /* SearchField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C204A91D4BD4F03B153E3DF /* SearchField.swift */; }; C203EE9CD56CF1D5BFF10137 /* TranscriptsModel+Files.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CD2747529AD4DAA2244C9C7 /* TranscriptsModel+Files.swift */; }; C289DEA536A2C64BC5818739 /* juno-1-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 54244D8B2EFFCBDC803F49E2 /* juno-1-stop.m4a */; }; C30A635488E406D5F54C85C0 /* rom1a-16-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = AA55EF35D8FFB4A5EF7A8557 /* rom1a-16-stop.m4a */; }; @@ -378,6 +376,7 @@ CB21595A20A62031DED02A6D /* juno-79-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 5F06797402F73A862C8DFA12 /* juno-79-start.m4a */; }; CB3039BB58F6A5681971FD72 /* juno-20-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 5B56D4D2910BBB44EEB4690A /* juno-20-stop.m4a */; }; CBA0D3727CA1C62D0665F51B /* LaunchPerformanceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 850AFD4ECA01A669BDD91959 /* LaunchPerformanceUITests.swift */; }; + CBBD5CF823AA917B1586E7DE /* TranscriptPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0785B687B7CBC619F0A9D8E2 /* TranscriptPage.swift */; }; CC08E344F8558B7489F5DE06 /* rom1b-17-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 2A90E48A68218F156F84F968 /* rom1b-17-start.m4a */; }; CC1722BF93360A2F8627C468 /* juno-18-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 9A9BA87A72AFB3829A388E95 /* juno-18-start.m4a */; }; CCB264F7996BE4BC0ECB2C70 /* juno-24-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 87E45061EDF49C769A6B292D /* juno-24-start.m4a */; }; @@ -402,6 +401,7 @@ D3695589CB693DCDB7DFE324 /* juno-77-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 2B0DAD072642B1E71BD6E0FC /* juno-77-stop.m4a */; }; D399F9939C8AFFDC7219C0B7 /* UITestIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0C585C5F805520ADA502A8F /* UITestIdentifiers.swift */; }; D3E4BAEFF91B635E197B39C2 /* rom1a-26-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = EB504AAB7330EE4D4C1E4948 /* rom1a-26-start.m4a */; }; + D4392247B72EA5E269CA6093 /* SettingsPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24DEF4A7379A8E14E20A7639 /* SettingsPane.swift */; }; D4782F83F43693371724011D /* rom1b-23-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = EAB0871FD8CB94A4DCF837CC /* rom1b-23-start.m4a */; }; D5927A391D26CAB212D6FBB4 /* rom1b-3-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 49CD6A6815EDDB16BBBCCCB2 /* rom1b-3-start.m4a */; }; D5A84975628548A076CEB3D7 /* juno-118-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 92387F60666853C0AA1BF0B5 /* juno-118-stop.m4a */; }; @@ -496,7 +496,6 @@ 002FB9F5FD28A66C04993007 /* juno-61-start.m4a */ = {isa = PBXFileReference; path = "juno-61-start.m4a"; sourceTree = ""; }; 00768AE8171902B439F0602F /* juno-89-start.m4a */ = {isa = PBXFileReference; path = "juno-89-start.m4a"; sourceTree = ""; }; 0088DE3FD3980CA727EB02FE /* juno-111-start.m4a */ = {isa = PBXFileReference; path = "juno-111-start.m4a"; sourceTree = ""; }; - 00C696D5D5EB4F5A4FBE5752 /* TranscriptSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptSheet.swift; sourceTree = ""; }; 0150E727307295CE46981426 /* rom1b-19-stop.m4a */ = {isa = PBXFileReference; path = "rom1b-19-stop.m4a"; sourceTree = ""; }; 01B663528C4E1AFEF24675E1 /* rom1b-21-start.m4a */ = {isa = PBXFileReference; path = "rom1b-21-start.m4a"; sourceTree = ""; }; 01F10B3261EA8F8616B8595A /* MainWindowRoot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowRoot.swift; sourceTree = ""; }; @@ -507,6 +506,7 @@ 0566F4FC45C72A6DA586126E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 0618C6EE1B8864C57F02CC1E /* rom1b-0-stop.m4a */ = {isa = PBXFileReference; path = "rom1b-0-stop.m4a"; sourceTree = ""; }; 06A8F7161C5917AADFF6AC77 /* juno-67-stop.m4a */ = {isa = PBXFileReference; path = "juno-67-stop.m4a"; sourceTree = ""; }; + 0785B687B7CBC619F0A9D8E2 /* TranscriptPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptPage.swift; sourceTree = ""; }; 08A7417835D8538AC4A7E2FB /* DictationComposition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationComposition.swift; sourceTree = ""; }; 093A44A6DB129960326C1796 /* juno-35-stop.m4a */ = {isa = PBXFileReference; path = "juno-35-stop.m4a"; sourceTree = ""; }; 09C4E97A7D9EE493D5EC54BB /* juno-59-start.m4a */ = {isa = PBXFileReference; path = "juno-59-start.m4a"; sourceTree = ""; }; @@ -555,9 +555,9 @@ 22FF5E0C4681C483B4828FB2 /* juno-38-start.m4a */ = {isa = PBXFileReference; path = "juno-38-start.m4a"; sourceTree = ""; }; 240939D503F3CE03A0697A5F /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; 24B8138546D9CD21F6D55F54 /* juno-60-start.m4a */ = {isa = PBXFileReference; path = "juno-60-start.m4a"; sourceTree = ""; }; + 24DEF4A7379A8E14E20A7639 /* SettingsPane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsPane.swift; sourceTree = ""; }; 254D4D3CAC1C114EC51C96C8 /* SettingRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingRow.swift; sourceTree = ""; }; 258681056CA44359083EDC9B /* rom1b-12-stop.m4a */ = {isa = PBXFileReference; path = "rom1b-12-stop.m4a"; sourceTree = ""; }; - 27E18FF6880EADB94F98C7C4 /* SoundStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundStepView.swift; sourceTree = ""; }; 281E6ED71E7F613E6E6689D0 /* juno-25-start.m4a */ = {isa = PBXFileReference; path = "juno-25-start.m4a"; sourceTree = ""; }; 2864D45C20A932F54C83C62D /* juno-29-stop.m4a */ = {isa = PBXFileReference; path = "juno-29-stop.m4a"; sourceTree = ""; }; 286A4475154226AC60716712 /* juno-125-start.m4a */ = {isa = PBXFileReference; path = "juno-125-start.m4a"; sourceTree = ""; }; @@ -793,7 +793,6 @@ 99B45E82D3DF744DF2031238 /* rom1b-6-stop.m4a */ = {isa = PBXFileReference; path = "rom1b-6-stop.m4a"; sourceTree = ""; }; 9A9BA87A72AFB3829A388E95 /* juno-18-start.m4a */ = {isa = PBXFileReference; path = "juno-18-start.m4a"; sourceTree = ""; }; 9AE5EE7838D59C7068270D1C /* BrandOrb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrandOrb.swift; sourceTree = ""; }; - 9C204A91D4BD4F03B153E3DF /* SearchField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchField.swift; sourceTree = ""; }; 9C3B044CD20456A76EBB99FB /* juno-61-stop.m4a */ = {isa = PBXFileReference; path = "juno-61-stop.m4a"; sourceTree = ""; }; 9CD2747529AD4DAA2244C9C7 /* TranscriptsModel+Files.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TranscriptsModel+Files.swift"; sourceTree = ""; }; 9D02AECDAE6B146F11B7A721 /* juno-87-stop.m4a */ = {isa = PBXFileReference; path = "juno-87-stop.m4a"; sourceTree = ""; }; @@ -845,14 +844,12 @@ BBA9C9322D0AF3D9C529F06F /* MenuBarScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarScene.swift; sourceTree = ""; }; BBF8A81BBD03B233A8C2B845 /* rom1a-6-stop.m4a */ = {isa = PBXFileReference; path = "rom1a-6-stop.m4a"; sourceTree = ""; }; BC17D9B01FDBC9667A7CD78A /* rom1a-22-start.m4a */ = {isa = PBXFileReference; path = "rom1a-22-start.m4a"; sourceTree = ""; }; - BC36DD628AE3887680B120AA /* FlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowLayout.swift; sourceTree = ""; }; BCE9C2C9715E2C1C96683AE0 /* juno-40-stop.m4a */ = {isa = PBXFileReference; path = "juno-40-stop.m4a"; sourceTree = ""; }; BD38CC94DC42A35A4174316A /* rom1b-1-start.m4a */ = {isa = PBXFileReference; path = "rom1b-1-start.m4a"; sourceTree = ""; }; BEEF91CD94662F4344ECFDE9 /* rom1b-27-start.m4a */ = {isa = PBXFileReference; path = "rom1b-27-start.m4a"; sourceTree = ""; }; C19DBE4661B752927D16B261 /* BlurtBrand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurtBrand.swift; sourceTree = ""; }; C1BD23E2476F46A7BD75BAB9 /* rom1b-31-start.m4a */ = {isa = PBXFileReference; path = "rom1b-31-start.m4a"; sourceTree = ""; }; C1BDC7B0B66BD2DD29DE3CFA /* TranscriptsModel+Meetings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TranscriptsModel+Meetings.swift"; sourceTree = ""; }; - C2216A871A3EB7FEC8B9CC42 /* TranscribeRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscribeRow.swift; sourceTree = ""; }; C23288EE0E6273C0D7603791 /* HarnessDriving.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HarnessDriving.swift; sourceTree = ""; }; C2CB5D2E464BFD7F8BABE760 /* rom1b-7-stop.m4a */ = {isa = PBXFileReference; path = "rom1b-7-stop.m4a"; sourceTree = ""; }; C38C73970E1D1978494F7BAE /* juno-10-start.m4a */ = {isa = PBXFileReference; path = "juno-10-start.m4a"; sourceTree = ""; }; @@ -880,6 +877,8 @@ D220A3AC36DCE278804F81A1 /* rom1b-18-start.m4a */ = {isa = PBXFileReference; path = "rom1b-18-start.m4a"; sourceTree = ""; }; D27DAF2436D272EEF031303B /* juno-47-start.m4a */ = {isa = PBXFileReference; path = "juno-47-start.m4a"; sourceTree = ""; }; D3681457B0FB13FF3FD6BAF6 /* juno-74-stop.m4a */ = {isa = PBXFileReference; path = "juno-74-stop.m4a"; sourceTree = ""; }; + D4ACFC5071B68C432A875B39 /* LibraryCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryCard.swift; sourceTree = ""; }; + D574A31ACB311D98BBE107F6 /* AudioFileDropTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFileDropTarget.swift; sourceTree = ""; }; D58930841AD9EB26714E8385 /* rom1a-20-start.m4a */ = {isa = PBXFileReference; path = "rom1a-20-start.m4a"; sourceTree = ""; }; D643CB1D491E00A2182907F8 /* BlurtCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurtCommands.swift; sourceTree = ""; }; D679085098210876A65E88BD /* rom1b-5-stop.m4a */ = {isa = PBXFileReference; path = "rom1b-5-stop.m4a"; sourceTree = ""; }; @@ -946,6 +945,7 @@ F0B1CD9D40D126CD251FDD46 /* juno-0-stop.m4a */ = {isa = PBXFileReference; path = "juno-0-stop.m4a"; sourceTree = ""; }; F1340BFA7AF0D9D50EAA4A24 /* rom1a-22-stop.m4a */ = {isa = PBXFileReference; path = "rom1a-22-stop.m4a"; sourceTree = ""; }; F17631D5583E0070A4DFCF3F /* rom1a-9-start.m4a */ = {isa = PBXFileReference; path = "rom1a-9-start.m4a"; sourceTree = ""; }; + F17E3218A976D2B1B5173112 /* SoundsSettingsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundsSettingsTab.swift; sourceTree = ""; }; F2B1262C9AB83ED251421D03 /* rom1b-5-start.m4a */ = {isa = PBXFileReference; path = "rom1b-5-start.m4a"; sourceTree = ""; }; F2D66E4734A5DC086904E421 /* rom1a-3-stop.m4a */ = {isa = PBXFileReference; path = "rom1a-3-stop.m4a"; sourceTree = ""; }; F37D9EAC78D140EE0CD438F2 /* juno-17-start.m4a */ = {isa = PBXFileReference; path = "juno-17-start.m4a"; sourceTree = ""; }; @@ -1032,7 +1032,6 @@ CE00AEC94B430DEF79A46820 /* Config */, 4B1375E36CD42DBEFF08BC7E /* Packages */, D8F69C40E4A93A6B3433BAC0 /* Shared */, - 8B2A4F2F9E78B853F1BED1C4 /* Shared */, 10E2E11380C974390308C1D2 /* Products */, ); sourceTree = ""; @@ -1462,12 +1461,14 @@ 77C72856311952343339E7BE /* Wizard */ = { isa = PBXGroup; children = ( + D574A31ACB311D98BBE107F6 /* AudioFileDropTarget.swift */, D643CB1D491E00A2182907F8 /* BlurtCommands.swift */, E0552AF62A5A7B4EC4FC6EB0 /* ClipPlayer.swift */, 0AF3379504F58AAF3A57819F /* DeleteConfirmation.swift */, C7C95B4C80443E2A6EFE05E7 /* DeveloperSection.swift */, 48C4CFC6271703797187A269 /* KeyCap.swift */, AE6928CEB0A1A92241762DB6 /* KeysSettingsTab.swift */, + D4ACFC5071B68C432A875B39 /* LibraryCard.swift */, A1E4E6475D21A5D03D21B1B2 /* LibraryParts.swift */, 64C90BFCBE7F97C6FB19F861 /* LibrarySection.swift */, 324768A585509378B2AF8A8A /* LiveRecordingView.swift */, @@ -1476,15 +1477,15 @@ EB55696AF62110C04E63B601 /* ObservedKeyBindings.swift */, 754D13A631535995AF41AA5B /* ReadAloudSettingsTab.swift */, 383E03AB60BA21AAD92655D5 /* ReadyView.swift */, - 9C204A91D4BD4F03B153E3DF /* SearchField.swift */, 254D4D3CAC1C114EC51C96C8 /* SettingRow.swift */, + 24DEF4A7379A8E14E20A7639 /* SettingsPane.swift */, 37CBB4580F251B182A8B1937 /* SettingsWindowRoot.swift */, + F17E3218A976D2B1B5173112 /* SoundsSettingsTab.swift */, CBE3B07ED4D1581018AD06E7 /* SpeakerTurnsView.swift */, 30703F589CF213720C7E3B15 /* StyleRow.swift */, 3A50C85936C100F9E51CC2AD /* TextShortcutsSection.swift */, - C2216A871A3EB7FEC8B9CC42 /* TranscribeRow.swift */, A67D0F9D8DB94457B8103A80 /* TranscriptFacet.swift */, - 00C696D5D5EB4F5A4FBE5752 /* TranscriptSheet.swift */, + 0785B687B7CBC619F0A9D8E2 /* TranscriptPage.swift */, 77874E1A3EAA028438BCD14E /* TranscriptsModel.swift */, 9CD2747529AD4DAA2244C9C7 /* TranscriptsModel+Files.swift */, C1BDC7B0B66BD2DD29DE3CFA /* TranscriptsModel+Meetings.swift */, @@ -1505,15 +1506,6 @@ path = Branding; sourceTree = ""; }; - 8B2A4F2F9E78B853F1BED1C4 /* Shared */ = { - isa = PBXGroup; - children = ( - BC36DD628AE3887680B120AA /* FlowLayout.swift */, - ); - name = Shared; - path = ../Shared; - sourceTree = ""; - }; 8C67CBF637D85C0F642E53B5 /* MenuBar */ = { isa = PBXGroup; children = ( @@ -1606,7 +1598,6 @@ C59092F9220BBE7465068325 /* KeyTermsStepView.swift */, 4BE9BA7182953D06AAD913AE /* MicrophoneStepView.swift */, E8DE60178ABA2CC7F4EB6401 /* PermissionsStepView.swift */, - 27E18FF6880EADB94F98C7C4 /* SoundStepView.swift */, ); path = Steps; sourceTree = ""; @@ -2163,6 +2154,7 @@ B391EA5F093506B3DF318F10 /* AppCoordinator+UITesting.swift in Sources */, 3407E8759D61D569A998EE78 /* AppCoordinator.swift in Sources */, 8D2BA6E9FAC605D678A73164 /* AppDelegate.swift in Sources */, + BC4D5F7EC52E0BE8CE4E1C37 /* AudioFileDropTarget.swift in Sources */, 24EC3A8465DE5D0356AB73F8 /* Binding+IsPresent.swift in Sources */, 034A91EE923BF5EA71438B7D /* BlurtBrand.swift in Sources */, 76C1876DB538F8E190EB509E /* BlurtCommands.swift in Sources */, @@ -2175,11 +2167,11 @@ 0F1317CCFA2A3245DAA96141 /* DictationComposition.swift in Sources */, 1C40B5DB92262E05808ECCEA /* DictationKeyTap.swift in Sources */, B5A7CEA6517E5A1E4D77623F /* FloatingPanel.swift in Sources */, - 875E4A36097D4366AB281C12 /* FlowLayout.swift in Sources */, 22526AFA400A8E77982C3B3A /* KeyActionName.swift in Sources */, 92041B42C67CE21AF82B6863 /* KeyCap.swift in Sources */, 9B7CB19D131F1374FCA0A986 /* KeyTermsStepView.swift in Sources */, 1C89CB94C7D10B67BD0573DF /* KeysSettingsTab.swift in Sources */, + 31A4E70BB32A269EAC5F782A /* LibraryCard.swift in Sources */, 16BAB2F3D0F8ED815A55BB65 /* LibraryParts.swift in Sources */, 58FAFE916586BFC0CBCC0EB9 /* LibrarySection.swift in Sources */, E8AE03AA4DCD807D2BB8D660 /* LiquidGlass.swift in Sources */, @@ -2195,18 +2187,17 @@ D750C1BF16502EEC92182ECD /* PermissionsStepView.swift in Sources */, 24607B2B47E779840B7C225C /* ReadAloudSettingsTab.swift in Sources */, A13335FE97FA2E60A4F5CABC /* ReadyView.swift in Sources */, - C13B68C111B47E70D1EF0ED2 /* SearchField.swift in Sources */, 6A1402BFED3758AAD9C84ADF /* SettingRow.swift in Sources */, + D4392247B72EA5E269CA6093 /* SettingsPane.swift in Sources */, 1D1100AB3D47EFC80D47B04B /* SettingsWindowRoot.swift in Sources */, 526C973DCC1F588F91DEF1E5 /* SoundPackCatalog+ReadAloud.swift in Sources */, 8F3F1CC7203A148A68DFCCFF /* SoundPackCatalog.swift in Sources */, - 69E27229D8C64D7119918B7A /* SoundStepView.swift in Sources */, + BEAC8381714F51F8506775B5 /* SoundsSettingsTab.swift in Sources */, 9C076A67F761F60E59CAA27C /* SpeakerTurnsView.swift in Sources */, 0708BB71FB0A48748BA508C0 /* StyleRow.swift in Sources */, F30578AA785CAE98E944F6B7 /* TextShortcutsSection.swift in Sources */, - BA2CE80B16AB80F1AA942884 /* TranscribeRow.swift in Sources */, 42AFAB9A814A219C84B7DF51 /* TranscriptFacet.swift in Sources */, - 0CBFABA54AEF0C1060C14623 /* TranscriptSheet.swift in Sources */, + CBBD5CF823AA917B1586E7DE /* TranscriptPage.swift in Sources */, C203EE9CD56CF1D5BFF10137 /* TranscriptsModel+Files.swift in Sources */, B0C0C659DD81D9D496ED3789 /* TranscriptsModel+Meetings.swift in Sources */, B599DD24EE5B50CDAC2F41C8 /* TranscriptsModel+Playback.swift in Sources */, @@ -2326,7 +2317,6 @@ SPARKLE_PUBLIC_ED_KEY = "3RdSHCmCH/BOqgV/FR01c4VZDi6uylEigDq4PFBaCpQ="; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_PACKAGE_NAME = Blurt; SWIFT_VERSION = 6.0; }; name = "Debug-Local"; @@ -2393,7 +2383,6 @@ SPARKLE_PUBLIC_ED_KEY = "3RdSHCmCH/BOqgV/FR01c4VZDi6uylEigDq4PFBaCpQ="; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_PACKAGE_NAME = Blurt; SWIFT_VERSION = 6.0; VALIDATE_PRODUCT = YES; }; @@ -2510,7 +2499,6 @@ SPARKLE_PUBLIC_ED_KEY = "3RdSHCmCH/BOqgV/FR01c4VZDi6uylEigDq4PFBaCpQ="; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_PACKAGE_NAME = Blurt; SWIFT_VERSION = 6.0; }; name = Debug; diff --git a/App/Blurt/Blurt/AppCoordinator+UITesting.swift b/App/Blurt/Blurt/AppCoordinator+UITesting.swift index f3308566..9edc370b 100644 --- a/App/Blurt/Blurt/AppCoordinator+UITesting.swift +++ b/App/Blurt/Blurt/AppCoordinator+UITesting.swift @@ -18,9 +18,9 @@ keyTap?.simulateReleaseForTesting(key, asTap: asTap) } - /// Sets how `key` is pressed, as the Keys tab would, and rebinds the tap. - func setKeyActivationForTesting(_ activation: TriggerActivation, for key: TriggerKey) { - TriggerKeyActivationStore().setActivation(activation, for: key) + /// Sets how every key is pressed, as the Keys tab would, and rebinds the tap. + func setActivationForTesting(_ activation: TriggerActivation) { + TriggerActivationStore().setActivation(activation) dictationBindingChanged() } diff --git a/App/Blurt/Blurt/AppCoordinator.swift b/App/Blurt/Blurt/AppCoordinator.swift index 5fb6319d..322d25fb 100644 --- a/App/Blurt/Blurt/AppCoordinator.swift +++ b/App/Blurt/Blurt/AppCoordinator.swift @@ -48,8 +48,9 @@ final class AppCoordinator { /// crowded menu bar, so nothing here is relied on for correctness. private(set) var menuBarStatus: MenuBarStatus = .idle /// Whether the mic is being brought up or capturing (`PipelinePhase - /// .isCapturing`) — the ready screen disables the style switcher on it, and - /// its `.recording` half (via `menuBarStatus`) drives the listening state. + /// .isCapturing`) — while it is, Esc in the ready screen cancels the + /// dictation. Nothing on that screen changes with it: the pill shows how a + /// dictation is going. private(set) var isCapturing = false /// The last phase rendered, so the pill can go back to it when a read ends. @ObservationIgnored private var lastPhase = PipelinePhase.idle @@ -176,7 +177,12 @@ final class AppCoordinator { keyTap = DictationKeyTap( // The key rides the press, and the session resolves its style for that one // dictation (`PressStyle`); the main window's selection is never written. - onStart: { key in router.press(key: key) }, + // The cues hear of the key first, so the recording or read it starts + // opens in that key's voice. + onStart: { [weak self] key in + self?.cues.keyPressed(key) + router.press(key: key) + }, onStop: { router.submit(.release) }, onCancel: { router.submit(.cancel) }, onLatch: { router.keyLatched() }, @@ -306,6 +312,9 @@ final class AppCoordinator { func dictationBindingChanged() { keyTap?.refreshBinding() syncGlobeKeyOverride() + // A key with no voice of its own follows its action's, so a new action + // can mean a new voice to load. + cues.refresh() } /// Turns macOS's own 🌐 action off while `fn` is a trigger, and back on @@ -325,20 +334,10 @@ final class AppCoordinator { /// The record start/stop chimes (see `CueSoundPlayer` below). private let cues = CueSoundPlayer() - /// Called when the user changes the sound pack in Settings: reload the cue - /// players and preview the new voice so the choice is audible immediately. - func soundPackChanged() { - cues.packChanged() - } - - /// Read-aloud's cue pack changed in Settings: reload and preview it. - func readAloudSoundPackChanged() { - cues.readPackChanged() - } - - /// Control Your Mac's cue pack changed in Settings: reload and preview it. - func commandSoundPackChanged() { - cues.commandPackChanged() + /// Called when the user changes a key's sound in Settings: reload that key's + /// cue players and preview the new voice so the choice is audible immediately. + func keySoundChanged(_ key: TriggerKey) { + cues.packChanged(for: key) } /// The Read Aloud tab picked a voice or a speed: play a sample in it, as a cue @@ -373,11 +372,11 @@ final class AppCoordinator { // Mirror the phase onto the menu bar indicator (mapping lives in the engine, // unit-tested alongside `overlayState`). menuBarStatus = phase.menuBarStatus - // And onto the ready screen: the listening state and the style-switch lock - // both follow the same stream the pill renders, never a second source. + // And onto the ready screen, for its Esc-to-cancel: the same stream the + // pill renders, never a second source. isCapturing = phase.isCapturing - cues.transition(for: phase, commanding: speechRouter?.processingState == .commanding) + cues.transition(for: phase) // A dictation that ended without a key event (auto-release cap, a refused or // failed press) leaves the trigger's gate latched, which would swallow the diff --git a/App/Blurt/Blurt/AppDelegate.swift b/App/Blurt/Blurt/AppDelegate.swift index ddf44706..114ac3d1 100644 --- a/App/Blurt/Blurt/AppDelegate.swift +++ b/App/Blurt/Blurt/AppDelegate.swift @@ -195,6 +195,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { Task { await self.runLeakExercise(coord) } } #endif + // A launch to open files hears of them before the models above existed. + openPendingFiles() } /// One-shot startup migration: if the signing identity changed since the last @@ -278,6 +280,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } #endif + /// Audio or video files opened in Blurt — dropped on the Dock icon, or from + /// Finder's Open With — waiting for the models, which a launch to open them + /// creates only in `applicationDidFinishLaunching`. + @ObservationIgnored private var pendingFileURLs: [URL] = [] + + func application(_ application: NSApplication, open urls: [URL]) { + pendingFileURLs += urls + openPendingFiles() + } + + /// Brings the window up over the files opened in Blurt and transcribes the + /// first audio or video one — once the models exist, and only once setup is + /// done; until then the window comes up on setup and the files are let go. + private func openPendingFiles() { + guard !pendingFileURLs.isEmpty, let coordinator, let wizardController else { return } + surfaceMainWindow() + if wizardController.isReady { coordinator.transcripts.openFiles(pendingFileURLs) } + pendingFileURLs = [] + } + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { // Blurt keeps running with the overlay pill after its window is closed; // the window reopens via ⌘, / the Dock. diff --git a/App/Blurt/Blurt/Branding/BlurtBrand.swift b/App/Blurt/Blurt/Branding/BlurtBrand.swift index a47292ba..80fa63b2 100644 --- a/App/Blurt/Blurt/Branding/BlurtBrand.swift +++ b/App/Blurt/Blurt/Branding/BlurtBrand.swift @@ -85,3 +85,14 @@ enum BlurtBrand { nonisolated static let orbRingGradient = LinearGradient( colors: [green, .white], startPoint: .topLeading, endPoint: .bottomTrailing) } + +extension BlurtBrand { + /// The main window's backdrop: a pale wash of the brand green over the + /// window colour, nearly white at the top and a touch greener down the + /// window, under its white cards. Over the window colour, so it darkens + /// with Dark Mode as the cards do. + nonisolated static var backdrop: some View { + LinearGradient(colors: [accent.opacity(0.05), accent.opacity(0.16)], startPoint: .top, endPoint: .bottom) + .background(Color(nsColor: .windowBackgroundColor)) + } +} diff --git a/App/Blurt/Blurt/CueSoundPlayer.swift b/App/Blurt/Blurt/CueSoundPlayer.swift index 6eefdfb2..7fc092d2 100644 --- a/App/Blurt/Blurt/CueSoundPlayer.swift +++ b/App/Blurt/Blurt/CueSoundPlayer.swift @@ -1,17 +1,25 @@ import AVFoundation import BlurtEngine -/// Owns the cue chimes — dictation's start/stop pair, read-aloud's and Control -/// Your Mac's, each a `CuePair` from its own setting: loading the selected pack, pre-rolling so -/// the first chime never stalls the pill, previewing on a pack change, and -/// firing on the recording edge or a read's start and end. Kept out of -/// `AppCoordinator`'s body so chime behavior can change without churning the -/// session↔UI wiring. +/// Owns the cue chimes — a start/stop `CuePair` per trigger key, each from that +/// key's voice (`TriggerKeySoundStore.soundPack(for:)`): loading the selected +/// pack, pre-rolling so the first chime never stalls the pill, previewing on a +/// pack change, and firing on the recording edge or a read's start and end, in +/// the voice of the key that started it. Kept out of `AppCoordinator`'s body so +/// chime behavior can change without churning the session↔UI wiring. final class CueSoundPlayer { - private let dictation = CuePair(store: .dictation) - private let readAloud = CuePair(store: .readAloud) - /// A command key's recording: the same edges as a dictation's, its own voice. - private let command = CuePair(store: .command) + private let pairs: [TriggerKey: CuePair] = Dictionary( + uniqueKeysWithValues: TriggerKey.allCases.map { key in + (key, CuePair { TriggerKeySoundStore().soundPack(for: key) }) + }) + /// The key whose press came last (`keyPressed(_:)`): the voice the next + /// recording or read starts in. The bound key until a press says otherwise. + private var pressedKey = TriggerKeyStore().triggerKey + /// The key a recording's start cue played for, so its stop cue matches even + /// if another key is pressed before it lands. + private var recordingKey: TriggerKey? + /// The same for a read, between `speechStarted()` and `speechEnded()`. + private var speakingKey: TriggerKey? /// Edge-detector deciding when the start/stop chimes fire. The mapping from a /// pipeline phase to a cue lives in the engine (`RecordingCueGate`), where /// `swift test` covers it; this player just plays whatever it resolves to. @@ -43,12 +51,22 @@ final class CueSoundPlayer { /// dictation*, which is always well after this launch-time call, so the AAC /// decode never sits on the main thread during startup. func prime() { + refresh() + startObservingRoute() + } + + /// Loads any key whose voice changed under it — a new action moves a key + /// with no voice of its own to that action's. Cheap when nothing did: each + /// pair skips a load of the pack it already holds. + func refresh() { Task { - await dictation.load() - await readAloud.load() - await command.load() + for key in TriggerKey.allCases { await pairs[key]?.load() } } - startObservingRoute() + } + + /// A trigger key's press started something; its voice is the next one heard. + func keyPressed(_ key: TriggerKey) { + pressedKey = key } /// The player is owned for the whole app session, so this never runs in @@ -97,30 +115,23 @@ final class CueSoundPlayer { AudioRouteMonitor() } - /// Previews a newly selected dictation pack (`CuePair.preview`). - func packChanged() { - Task { await dictation.preview() } - } - - /// Previews a newly selected read-aloud pack. - func readPackChanged() { - Task { await readAloud.preview() } + /// Previews `key`'s newly selected pack (`CuePair.preview`). + func packChanged(for key: TriggerKey) { + Task { await pairs[key]?.preview() } } - /// Previews a newly selected Control Your Mac pack. - func commandPackChanged() { - Task { await command.preview() } - } - - /// Fires the start/stop cue on the recording edge. Call once per rendered - /// phase; only the idle↔recording transitions make a sound (the edge logic is - /// the engine's `RecordingCueGate`). `commanding`: the recording is a Control - /// Your Mac press's spoken command, which plays that setting's voice. - func transition(for phase: PipelinePhase, commanding: Bool = false) { - let pair = commanding ? command : dictation + /// Fires the start/stop cue on the recording edge, in the voice of the key + /// whose press started the recording. Call once per rendered phase; only the + /// idle↔recording transitions make a sound (the edge logic is the engine's + /// `RecordingCueGate`). + func transition(for phase: PipelinePhase) { switch cueGate.cue(for: phase) { - case .start: pair.playStart() - case .stop: pair.playStop() + case .start: + recordingKey = pressedKey + pairs[pressedKey]?.playStart() + case .stop: + pairs[recordingKey ?? pressedKey]?.playStop() + recordingKey = nil case nil: break } // A pending route re-prime is acted on at exactly two points. @@ -152,29 +163,41 @@ final class CueSoundPlayer { reload(reprime.phase(phase)) } - /// Reloads the pairs `reloads` names, each against the current route. + /// Reloads the pairs `reloads` names, each against the current route. The + /// schedule's two halves are the keys that record (cued on capture edges) + /// and the keys that speak (cued around a read); an ask key does both. private func reload(_ reloads: CueReprimeSchedule.Reloads) { - // The command pair plays on the same capture edges, so it is re-primed - // with dictation's. - if reloads.dictation { - Task { - await dictation.load(force: true) - await command.load(force: true) - } + let bindings = TriggerKeyBindings() + let keys = TriggerKey.allCases.filter { key in + let action = bindings.action(for: key) + let records = action != .readAloud + let speaks = action == .readAloud || action == .ask + return (reloads.dictation && records) || (reloads.readAloud && speaks) + } + guard !keys.isEmpty else { return } + Task { + for key in keys { await pairs[key]?.load(force: true) } } - if reloads.readAloud { Task { await readAloud.load(force: true) } } } - /// Read-aloud's start and stop, from its own pack, so a read never sounds - /// like a recording starting. - func speechStarted() { readAloud.playStart() } - func speechEnded() { readAloud.playStop() } + /// A read's start and stop, in the voice of the key that asked for it. + func speechStarted() { + speakingKey = pressedKey + pairs[pressedKey]?.playStart() + } + + func speechEnded() { + pairs[speakingKey ?? pressedKey]?.playStop() + speakingKey = nil + } } -/// One setting's start/stop chimes: its store, the decoded players, and the -/// load that keeps them current. +/// One key's start/stop chimes: where its pack comes from, the decoded +/// players, and the load that keeps them current. private final class CuePair { - private let store: SoundPackStore + /// Reads the key's current pack — re-read on every load, so a changed voice + /// or action is picked up without rebuilding the pair. + private let currentPack: () -> SoundPack private var start: AVAudioPlayer? private var stop: AVAudioPlayer? /// The pack the current players were decoded from, so a repeat load can skip @@ -196,8 +219,8 @@ private final class CuePair { /// rather than blasting at the system output level. private nonisolated static let cueVolume: Float = 0.35 - init(store: SoundPackStore) { - self.store = store + init(currentPack: @escaping () -> SoundPack) { + self.currentPack = currentPack } /// Decodes and installs the players for the current selection if they aren't @@ -206,7 +229,7 @@ private final class CuePair { /// for a re-prime where the selection is unchanged and only the output route /// moved. func load(force: Bool = false) async { - let pack = store.soundPack + let pack = currentPack() guard force || pack != loaded else { return } generation += 1 let ticket = generation diff --git a/App/Blurt/Blurt/Info.plist b/App/Blurt/Blurt/Info.plist index 7d548a27..9a407380 100644 --- a/App/Blurt/Blurt/Info.plist +++ b/App/Blurt/Blurt/Info.plist @@ -16,6 +16,21 @@ $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName $(BLURT_APP_NAME) + CFBundleDocumentTypes + + + CFBundleTypeName + Audio or Video File + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + LSItemContentTypes + + public.audiovisual-content + + + CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier diff --git a/App/Blurt/Blurt/MenuBar/MenuBarScene.swift b/App/Blurt/Blurt/MenuBar/MenuBarScene.swift index ace491d5..b3673bdf 100644 --- a/App/Blurt/Blurt/MenuBar/MenuBarScene.swift +++ b/App/Blurt/Blurt/MenuBar/MenuBarScene.swift @@ -33,12 +33,12 @@ struct MenuBarContent: View { // the dictation key or its mode changes in Settings (`ObservedKeyBindings`). @ObservedKeyBindings private var bindings private var triggerKey: TriggerKey { bindings.bound } - private var activation: TriggerActivation { bindings.activation(for: bindings.bound) } + private var activation: TriggerActivation { bindings.activation } var body: some View { // Disabled informational row: the dictation trigger is an invisible lone // modifier, so spell it out here as the menu bar's discoverability anchor. - Text("\(activation.label) \(triggerKey.label) to dictate and paste") + Text("\(activation.label) \(triggerKey.spokenName) to dictate and paste") Divider() diff --git a/App/Blurt/Blurt/SoundPackCatalog+ReadAloud.swift b/App/Blurt/Blurt/SoundPackCatalog+ReadAloud.swift index a2f897cb..1a76ca1b 100644 --- a/App/Blurt/Blurt/SoundPackCatalog+ReadAloud.swift +++ b/App/Blurt/Blurt/SoundPackCatalog+ReadAloud.swift @@ -16,4 +16,30 @@ extension SoundPackStore { static var dictation: SoundPackStore { SoundPackStore(catalog: .blurt) } static var readAloud: SoundPackStore { SoundPackStore(catalog: .blurtReadAloud, cue: .readAloud) } static var command: SoundPackStore { SoundPackStore(catalog: .blurtCommand, cue: .command) } + + /// The setting a key with no voice of its own follows: its action's. A read + /// key sounds like a read and a command key like a command, and any other + /// key — dictate, ask, rewrite, each a recording — like a dictation. + static func fallback(for action: TriggerKeyAction) -> SoundPackStore { + switch action { + case .readAloud: .readAloud + case .command: .command + case .off, .dictate, .ask, .rewrite: .dictation + } + } +} + +extension TriggerKeySoundStore { + /// A key's cue voice: the one stored for it, decoded against the catalog, or + /// else its action's setting (`SoundPackStore.fallback(for:)`), so a key + /// nobody has given a voice sounds as it did before keys had their own. + static func soundPack(id: String?, action: TriggerKeyAction) -> SoundPack { + guard let id else { return SoundPackStore.fallback(for: action).soundPack } + return SoundPackCatalog.blurt.fromPersisted(id) + } + + /// `key`'s cue voice, from the stored settings. + func soundPack(for key: TriggerKey) -> SoundPack { + Self.soundPack(id: soundID(for: key), action: TriggerKeyBindings().action(for: key)) + } } diff --git a/App/Blurt/Blurt/UITestSupport.swift b/App/Blurt/Blurt/UITestSupport.swift index 018145b5..aa032631 100644 --- a/App/Blurt/Blurt/UITestSupport.swift +++ b/App/Blurt/Blurt/UITestSupport.swift @@ -290,7 +290,7 @@ .accessibilityIdentifier(UITestIdentifiers.harnessSetCommand) Button("Rewrite") { coordinator?.setKeyChoiceForTesting(.rewrite, for: state.selectedKey) } .accessibilityIdentifier(UITestIdentifiers.harnessSetRewrite) - Button("Tap Mode") { coordinator?.setKeyActivationForTesting(.tap, for: state.selectedKey) } + Button("Tap Mode") { coordinator?.setActivationForTesting(.tap) } .accessibilityIdentifier(UITestIdentifiers.harnessSetTapMode) } HStack(spacing: 8) { diff --git a/App/Blurt/Blurt/Wizard/AudioFileDropTarget.swift b/App/Blurt/Blurt/Wizard/AudioFileDropTarget.swift new file mode 100644 index 00000000..8e5bb8b3 --- /dev/null +++ b/App/Blurt/Blurt/Wizard/AudioFileDropTarget.swift @@ -0,0 +1,60 @@ +import SwiftUI + +extension View { + /// Makes the whole view a drop target for audio and video files, as + /// MacWhisper's and ImageOptim's windows are: nothing on screen asks for a + /// drop, and only while a file is held over the window does it say what + /// letting go will do. + func audioFileDropTarget(_ model: TranscriptsModel) -> some View { + modifier(AudioFileDropTarget(model: model)) + } +} + +private struct AudioFileDropTarget: ViewModifier { + var model: TranscriptsModel + @State private var isTargeted = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func body(content: Content) -> some View { + content + .dropDestination(for: URL.self) { urls, _ in + // Never over a recording: it owns the sheet until it's saved. + guard !model.isRecordingBusy else { return false } + model.openFiles(urls) + return true + } isTargeted: { + isTargeted = $0 + } + // The window's content gives way entirely while a file is held over + // it: one target, nothing behind it competing. + .opacity(isTargeted ? 0 : 1) + .overlay { + if isTargeted { dropTarget } + } + .animation(reduceMotion ? nil : .easeOut(duration: 0.15), value: isTargeted) + } + + /// What the window becomes while a file is held over it: a large dashed + /// well in the accent around one glyph and one line, as a Mac app's empty + /// drop well reads. + private var dropTarget: some View { + VStack(spacing: 12) { + Image(systemName: "waveform.badge.plus") + .font(.system(size: 44, weight: .light)) + Text("Drop to Transcribe") + .font(.title2.weight(.semibold)) + Text("Audio or video files") + .font(.callout) + .foregroundStyle(.secondary) + } + .foregroundStyle(BlurtBrand.accent) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(BlurtBrand.accent.opacity(0.7), style: StrokeStyle(lineWidth: 2, dash: [8, 6])) + .padding(16) + ) + .allowsHitTesting(false) + .transition(.opacity) + } +} diff --git a/App/Blurt/Blurt/Wizard/BlurtCommands.swift b/App/Blurt/Blurt/Wizard/BlurtCommands.swift index 866c0520..8877b251 100644 --- a/App/Blurt/Blurt/Wizard/BlurtCommands.swift +++ b/App/Blurt/Blurt/Wizard/BlurtCommands.swift @@ -5,6 +5,7 @@ import SwiftUI struct BlurtCommands: Commands { var appDelegate: AppDelegate @FocusedValue(\.focusHistorySearch) private var focusHistorySearch + @FocusedValue(\.importAudioFile) private var importAudioFile var body: some Commands { // "Check for Updates…" sits just below "About Blurt" in the app menu — the @@ -23,6 +24,14 @@ struct BlurtCommands: Commands { // reading as an ad. No ellipses: they open a page and ask nothing more, the // way Apple's own Help-menu links read. Each omits itself if its URL fails // to build. + // File › Open… (⌘O): the main window's file panel, to transcribe an audio + // or video file — the menu's way to what the toolbar's Browse does. In + // place of New, which a single-window app has nothing to offer. + CommandGroup(replacing: .newItem) { + Button("Open…") { importAudioFile?() } + .keyboardShortcut("o") + .disabled(importAudioFile == nil) + } // Edit › Find (⌘F): focuses the Recent list's search field, as any Mac // window with a search field answers to. CommandGroup(after: .textEditing) { diff --git a/App/Blurt/Blurt/Wizard/DeleteConfirmation.swift b/App/Blurt/Blurt/Wizard/DeleteConfirmation.swift index 7ee47ada..5257ddd4 100644 --- a/App/Blurt/Blurt/Wizard/DeleteConfirmation.swift +++ b/App/Blurt/Blurt/Wizard/DeleteConfirmation.swift @@ -37,10 +37,9 @@ extension View { // Explicit, so Return lands on Cancel and never on the destructive button. Button("Cancel", role: .cancel) {} } message: { entry in - Text( - "\u{201C}\(entry.title)\u{201D} " - + (entry.ownsAudio - ? "and its audio will be deleted. This can't be undone." : "will be deleted. This can't be undone.")) + // No title: the item is the one just chosen, and a long title made the + // dialog about the words rather than the choice. + Text(entry.ownsAudio ? "Its audio will be deleted too. This can't be undone." : "This can't be undone.") } } } diff --git a/App/Blurt/Blurt/Wizard/KeyCap.swift b/App/Blurt/Blurt/Wizard/KeyCap.swift index f09844be..052d2d49 100644 --- a/App/Blurt/Blurt/Wizard/KeyCap.swift +++ b/App/Blurt/Blurt/Wizard/KeyCap.swift @@ -1,35 +1,50 @@ import BlurtEngine import SwiftUI -/// A quiet keyboard legend: plain text, as a menu prints an item's shortcut, -/// with no fill or border — a key to press on the keyboard, not a control to -/// click. The real key lights it during capture. +/// A key's name drawn as a keycap — a small raised key, as shortcut hints are +/// drawn — so it reads as a key to press on the keyboard, not a control to +/// click. It stays as it is while the key is down. struct KeyCap: View { var label: String - var isLit = false var body: some View { Text(label) - .fontWeight(isLit ? .semibold : .regular) - .foregroundStyle(isLit ? AnyShapeStyle(BlurtBrand.accent) : AnyShapeStyle(.secondary)) - .animation(.easeOut(duration: 0.1), value: isLit) + .font(.callout) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(Color(nsColor: .textBackgroundColor)) + .shadow(color: .black.opacity(0.18), radius: 0, y: 1) + ) + .overlay( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .strokeBorder(Color(nsColor: .separatorColor)) + ) } } extension TriggerKey { - /// The keycap's face, e.g. "Right ⌘": `label` with its leading capital, - /// since a key's legend is a title rather than mid-sentence prose — except - /// `fn`, which Apple keyboards print lowercase. - var keycapLabel: String { - if self == .function { return label } - return label.prefix(1).uppercased() + label.dropFirst() - } - /// The key named in words, e.g. "Right Command": `fullName` without its - /// parenthesised symbol. VoiceOver hears it without the symbol read as - /// the name a second time. + /// parenthesised symbol. The one name a key goes by on screen — the main + /// window's legend, the menu bar, and every Settings row — and what VoiceOver + /// hears, without the symbol read as the name a second time. var spokenName: String { guard let symbol = fullName.range(of: " (") else { return fullName } return String(fullName[.. String { switch key { case .rightCommand, .leftCommand: "command" @@ -39,98 +38,97 @@ struct KeysSettingsTab: View { } } + /// One action row: what it does, and whether it rides the enhanced rewrite + /// (a custom style, which is disabled with it off). Its icon is its + /// action's, as on the main window. + private struct ActionRow: Identifiable { + let choice: TriggerKeyChoice + let title: String + var isStyled = false + var id: String { title } + } + + private func rows(profiles: [StyleProfile]) -> [ActionRow] { + [ + ActionRow(choice: .dictate, title: "Dictate"), + ActionRow( + choice: .dictate(style: StyleProfileStore.defaultStyleID), title: "Dictate: No custom style", + isStyled: true), + ] + + profiles.map { + ActionRow(choice: .dictate(style: $0.id.uuidString), title: "Dictate: \($0.name)", isStyled: true) + } + + [ + ActionRow(choice: .readAloud, title: KeyActionName.readAloud), + ActionRow(choice: .ask, title: KeyActionName.ask), + ActionRow(choice: .rewrite, title: KeyActionName.rewrite), + ActionRow(choice: .command, title: KeyActionName.command), + ] + } + var body: some View { let bindings = bindings let profiles = StyleProfileStore().profiles(decoding: profilesRaw) SettingsPane { Section { - ForEach(TriggerKey.allCases, id: \.self) { key in - row(for: key, bindings: bindings, profiles: profiles, isOnlyDictatingKey: bindings.isOnlyDictatingKey(key)) - } - } header: { - HStack(spacing: 8) { - Text("Key") - Spacer(minLength: 12) - Text("Action") - .frame(width: Self.actionWidth, alignment: .trailing) - Text("Activation") - .frame(width: Self.activationWidth, alignment: .trailing) + ForEach(rows(profiles: profiles)) { row in + keyRow(row, bindings: bindings, profiles: profiles) } } - } - } - - private func row( - for key: TriggerKey, bindings: TriggerKeyBindings, profiles: [StyleProfile], isOnlyDictatingKey: Bool - ) -> some View { - let action = bindings.action(for: key) - return SettingRow(title: key.spokenName, systemImage: Self.symbol(for: key)) { - HStack(spacing: 8) { - // Every item names its action on its own, since a closed pop-up shows - // only the one chosen, without the menu's dividers around it. The - // hidden titles are what VoiceOver reads for each pop-up. The only - // dictating key offers only dictation — left out rather than disabled, - // since a menu pop-up doesn't reliably disable single items. - Picker("Action for \(key.fullName)", selection: choice(for: key, bindings: bindings, profiles: profiles)) { - if !isOnlyDictatingKey { - Text("Off").tag(TriggerKeyChoice.off) - Divider() - } - Text("Dictate").tag(TriggerKeyChoice.dictate) - Group { - Text("Dictate: No custom style").tag(TriggerKeyChoice.dictate(style: StyleProfileStore.defaultStyleID)) - ForEach(profiles) { profile in - Text("Dictate: \(profile.name)").tag(TriggerKeyChoice.dictate(style: profile.id.uuidString)) - } - } - .disabled(!enhancedTranscripts) - if !isOnlyDictatingKey { - Divider() - Text(KeyActionName.readAloud).tag(TriggerKeyChoice.readAloud) - Text(KeyActionName.ask).tag(TriggerKeyChoice.ask) - Text(KeyActionName.rewrite).tag(TriggerKeyChoice.rewrite) - Text(KeyActionName.command).tag(TriggerKeyChoice.command) - } - } - .labelsHidden() - .pickerStyle(.menu) - .lineLimit(1) - .frame(width: Self.actionWidth, alignment: .trailing) - .accessibilityIdentifier(UITestIdentifiers.keyStylePicker(key.keyCode)) - Picker("Tap or hold for \(key.fullName)", selection: activation(for: key, bindings: bindings)) { + Section { + PickerSettingRow( + title: "Activation", systemImage: "hand.tap", + accessibilityID: UITestIdentifiers.activationPicker, + subtitle: bindings.activation.hint(for: .dictate), selection: activation + ) { ForEach(TriggerActivation.allCases, id: \.self) { mode in Text(mode.label).tag(mode) } } - .labelsHidden() - .pickerStyle(.menu) - .frame(width: Self.activationWidth, alignment: .trailing) - .disabled(action == .off) - .accessibilityIdentifier(UITestIdentifiers.keyActivationPicker(key.keyCode)) } } } - /// One key's action pop-up. - private func choice( - for key: TriggerKey, bindings: TriggerKeyBindings, profiles: [StyleProfile] - ) -> Binding { + private func keyRow(_ row: ActionRow, bindings: TriggerKeyBindings, profiles: [StyleProfile]) -> some View { + let holder = TriggerKey.allCases.first { bindings.choice(for: $0, profiles: profiles) == row.choice } + // The only dictating key can't be left without dictation, so its row + // offers no None — left out rather than disabled, since a menu pop-up + // doesn't reliably disable single items. + let canClear = holder.map { !bindings.isOnlyDictatingKey($0) } ?? true + return PickerSettingRow( + title: row.title, systemImage: row.choice.action.systemImage, + accessibilityID: UITestIdentifiers.actionKeyPicker(row.title), + pickerLabel: "Key for \(row.title)", selection: key(for: row.choice, holder: holder) + ) { + if canClear { + Text("None").tag(TriggerKey?.none) + Divider() + } + ForEach(TriggerKey.allCases, id: \.self) { key in + Text(key.spokenName).tag(TriggerKey?.some(key)) + } + } + .disabled(row.isStyled && !enhancedTranscripts) + } + + /// One action's key pop-up. + private func key(for choice: TriggerKeyChoice, holder: TriggerKey?) -> Binding { Binding( - get: { bindings.choice(for: key, profiles: profiles) }, + get: { holder }, set: { newValue in - // Refused only for the only dictating key, whose other choices the - // pop-up disables. - guard TriggerKeyStyleStore().setChoice(newValue, for: key) else { return } + // Refused only when it would leave no key dictating, which the + // pop-ups already keep out of reach. + guard TriggerKeyStyleStore().assign(newValue, to: choice) else { return } coordinator.dictationBindingChanged() }) } - /// One key's tap/hold pop-up. - private func activation(for key: TriggerKey, bindings: TriggerKeyBindings) -> Binding { + /// The tap/hold pop-up every key shares. + private var activation: Binding { Binding( - get: { bindings.activation(for: key) }, + get: { bindings.activation }, set: { newValue in - TriggerKeyActivationStore().setActivation(newValue, for: key) + TriggerActivationStore().setActivation(newValue) coordinator.dictationBindingChanged() }) } diff --git a/App/Blurt/Blurt/Wizard/LibraryCard.swift b/App/Blurt/Blurt/Wizard/LibraryCard.swift new file mode 100644 index 00000000..9f3e7df1 --- /dev/null +++ b/App/Blurt/Blurt/Wizard/LibraryCard.swift @@ -0,0 +1,71 @@ +import AppKit +import SwiftUI + +/// Turns off the enclosing table's own selection highlight — the one +/// SwiftUI's `List` draws on macOS and offers no modifier for — so the cards +/// draw their own. Found from a view inside the list, once it's in a window. +struct TableSelectionHighlightRemover: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { Finder() } + // Again on every update: the list builds its table after this view joins + // the window, and may rebuild it. + func updateNSView(_ nsView: NSView, context: Context) { + DispatchQueue.main.async { (nsView as? Finder)?.removeHighlight() } + } + + private final class Finder: NSView { + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + DispatchQueue.main.async { [weak self] in self?.removeHighlight() } + } + + func removeHighlight() { + // The list's table is a sibling of this background, under a shared + // ancestor: search down from each ancestor in turn. + var ancestor = superview + while let view = ancestor { + if let table = Self.table(in: view) { + table.selectionHighlightStyle = .none + return + } + ancestor = view.superview + } + } + + private static func table(in view: NSView) -> NSTableView? { + if let table = view as? NSTableView { return table } + for subview in view.subviews { + if let table = table(in: subview) { return table } + } + return nil + } + } +} + +/// A Library card, drawn as a list row's background: white, rounded and +/// lifted, inset by the window's margin and half the gap between cards; when +/// selected, ringed in the accent, as Journal marks an entry. +struct LibraryCard: View { + let isSelected: Bool + + /// The space between two cards. + static let gap: CGFloat = 4 + /// The selection ring's width, outside the card. + static let ring: CGFloat = 3 + + var body: some View { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(nsColor: .textBackgroundColor)) + .shadow(color: .black.opacity(0.12), radius: 6, y: 2) + // Selected, as Journal marks an entry: a thick accent ring just + // outside the card, its corners following the card's. + .background { + if isSelected { + RoundedRectangle(cornerRadius: 12 + Self.ring, style: .continuous) + .fill(BlurtBrand.accent) + .padding(-Self.ring) + } + } + .padding(.horizontal, 20) + .padding(.vertical, Self.gap) + } +} diff --git a/App/Blurt/Blurt/Wizard/LibraryParts.swift b/App/Blurt/Blurt/Wizard/LibraryParts.swift index e5e1de08..1d840757 100644 --- a/App/Blurt/Blurt/Wizard/LibraryParts.swift +++ b/App/Blurt/Blurt/Wizard/LibraryParts.swift @@ -191,9 +191,3 @@ struct EmptyListMessage: View { .accessibilityElement(children: .combine) } } - -/// A grouped form section's shape, for what's drawn to match one: the Library's -/// panel and the record row's drop-target outline. -enum FormPanel { - static let shape = RoundedRectangle(cornerRadius: 10, style: .continuous) -} diff --git a/App/Blurt/Blurt/Wizard/LibrarySection.swift b/App/Blurt/Blurt/Wizard/LibrarySection.swift index ea9efbac..3f841e1b 100644 --- a/App/Blurt/Blurt/Wizard/LibrarySection.swift +++ b/App/Blurt/Blurt/Wizard/LibrarySection.swift @@ -1,45 +1,27 @@ -import Accessibility import AppKit import BlurtEngine import SwiftUI /// The ready screen's Library: every saved recording, dictation, file and /// spoken answer, searchable, filtered by kind, and grouped under day headings. -/// A stock plain `List` on a grouped form section's fill: native selection supports -/// double-click/Return to open, ⌘C to copy, Delete to confirm deletion, and ⌘F -/// to search; the contextual menu holds each entry's actions. -struct LibrarySection: View { +/// A stock `List` drawn as white cards on the window's brand gradient, as +/// Journal's entries look, led by the window's key legend (`header`). Mac list +/// behaviour throughout: a click selects a card, double-click or Return opens +/// it, ⌘C copies, Delete asks to delete, arrows move; its ⋯ menu and the +/// contextual menu hold the rest, and ⌘F searches. +struct LibrarySection: View { var coordinator: AppCoordinator let triggerKey: TriggerKey let activation: TriggerActivation - /// The list's tallest: about six two-line rows and their day headings, - /// then it scrolls. - private static let maxListHeight: CGFloat = 300 - /// The list's shortest: room for the empty prompt or a no-results message. - private static let minListHeight: CGFloat = 132 - /// A two-line row and a day heading, as a plain list draws them. - private static let rowHeight: CGFloat = 44 - private static let headingHeight: CGFloat = 28 - - /// The list's height: fitted to the whole library — its rows and day - /// headings — between `minListHeight` and `maxListHeight`, so a short - /// library doesn't sit atop an empty panel. Sized from the history rather - /// than what the filter or search shows, so the panel keeps one shape while - /// they change. Only the entries that could fit are walked. - static func listHeight(for history: [SavedTranscript]) -> CGFloat { - let calendar = Calendar.current - let visible = history.prefix(Int(maxListHeight / rowHeight) + 1) - let headings = Set(visible.map { calendar.startOfDay(for: $0.date) }).count - let content = CGFloat(visible.count) * rowHeight + CGFloat(headings) * headingHeight - return min(max(content, minListHeight), maxListHeight) - } - - @State private var query = "" - @State private var filter = LibraryFilter.all + /// The toolbar's search (`ReadyView`). + let query: String + /// The toolbar's Filter menu (`ReadyView`). + let filter: LibraryFilter + /// What leads the list, above the first day: the window's key legend. + @ViewBuilder var header: Header @State private var selection: SavedTranscript.ID? @State private var pendingDelete: SavedTranscript? - @State private var searchFocusRequest = 0 /// The history, filtered and under its day headings: rebuilt only when the /// history, the filter or the search changes — not on every selection /// change or play/stop, which also redraw this view, and which would @@ -102,39 +84,71 @@ struct LibrarySection: View { uniquingKeysWith: { first, _ in first }) } - private func entry(id: SavedTranscript.ID?) -> SavedTranscript? { - id.flatMap { id in model.history.first { $0.id == id } } - } - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text("Library") + List(selection: $selection) { + Section { + header + .listRowInsets(Self.margins) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .selectionDisabled() + } + if days.isEmpty { + emptyMessage + .frame(maxWidth: .infinity, minHeight: 160) + .listRowInsets(Self.margins) + .listRowBackground(LibraryCard(isSelected: false)) + .listRowSeparator(.hidden) + .selectionDisabled() + } + ForEach(days) { day in + // The day as an ordinary row, not a section header: those stay pinned + // to the top as the list scrolls, and these headings scroll with it. + Text(day.title) .font(.headline) + .padding(.top, 8) + .listRowInsets(Self.headingInsets) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .selectionDisabled() .accessibilityAddTraits(.isHeader) - Spacer(minLength: 8) - // Nothing to filter or search yet, so the controls wait for a first entry. - if !model.history.isEmpty { - SearchField(text: $query, prompt: "Search transcripts", focusRequest: searchFocusRequest) - .frame(width: 180) + ForEach(day.rows, id: \.entry.id) { row in + let entry = row.entry + HistoryRow( + entry: entry, details: row.details, dayTitle: day.title, + actions: EntryActions(entry: entry, model: model, delete: { pendingDelete = entry }) + ) + .tag(entry.id) + // Label colours on the white card even when selected: without the + // band, the selected row's white text would vanish. + .environment(\.backgroundProminence, .standard) + .listRowInsets(Self.cardInsets) + .listRowBackground(LibraryCard(isSelected: selection == entry.id)) + .listRowSeparator(.hidden) } } - if !model.history.isEmpty { - filterPicker - } - // The panel is always drawn so the window keeps one shape, but the list - // only while it has rows: a plain list emptied of every section keeps - // drawing its old rows, under the empty message laid over them. - Group { - if days.isEmpty { Color.clear } else { list } + } + .listStyle(.plain) + .listSectionSeparator(.hidden) + .scrollContentBackground(.hidden) + // The card's outline marks the selection (`LibraryCard`), not the + // system's full-width band, which ran past the cards' rounded edges. + .background(TableSelectionHighlightRemover()) + .accessibilityIdentifier(UITestIdentifiers.recentList) + .contextMenu(forSelectionType: SavedTranscript.ID.self) { ids in + if let entry = entry(id: ids.first) { + EntryActions(entry: entry, model: model, delete: { pendingDelete = entry }) } - .overlay { emptyMessage } - .frame(height: Self.listHeight(for: model.history)) - // The grouped form's section look, so the Library matches the panels - // above it: a plain list on the form's fill, clipped to its radius. - .background(Color(nsColor: .quaternarySystemFill), in: FormPanel.shape) - .clipShape(FormPanel.shape) + } primaryAction: { ids in + // Double-click and Return. + if let entry = entry(id: ids.first) { model.open(entry) } + } + .onCopyCommand { + guard let entry = entry(id: selection) else { return [] } + AccessibilityNotification.Announcement("Copied").post() + return [NSItemProvider(object: entry.text as NSString)] } + .onDeleteCommand { pendingDelete = entry(id: selection) } .onChange(of: model.history, initial: true) { updateDetails() refresh() @@ -151,11 +165,9 @@ struct LibrarySection: View { guard (try? await Task.sleep(for: .milliseconds(150))) != nil else { return } refresh() } - // Edit › Find (⌘F), for this window. - .focusedSceneValue(\.focusHistorySearch, model.history.isEmpty ? nil : { searchFocusRequest += 1 }) .deleteConfirmation(for: $pendingDelete, model: model) { deleted in - // The next row takes the selection, as Mac lists do, so Delete - // can carry on down the list. + // The next card takes the selection, as Mac lists do, so Delete can + // carry on down the list. let rows = days.flatMap(\.rows).map(\.entry) guard selection == deleted.id, let index = rows.firstIndex(where: { $0.id == deleted.id }) else { return } let neighbors = rows.indices.contains(index + 1) ? rows[index + 1] : index > 0 ? rows[index - 1] : nil @@ -163,55 +175,29 @@ struct LibrarySection: View { } } - /// The kind filter, at its natural width — small, as at regular size its five - /// segments outgrow the window. - private var filterPicker: some View { - Picker("Show", selection: $filter) { - ForEach(LibraryFilter.allCases, id: \.self) { Text($0.title).tag($0) } - } - .pickerStyle(.segmented) - .labelsHidden() - .controlSize(.small) - .fixedSize() + /// The list's own padding at each side of a row, on top of its insets. + private static var listPadding: CGFloat { 8 } + /// The window's margin around everything in the list: 20 pt, less the + /// list's own padding. + private static var margins: EdgeInsets { + EdgeInsets(top: 8, leading: 20 - listPadding, bottom: 8, trailing: 20 - listPadding) + } + /// A day heading's inset: in line with the Keys heading, 4 pt inside the + /// margin. + private static var headingInsets: EdgeInsets { + EdgeInsets(top: 4, leading: 24 - listPadding, bottom: 2, trailing: 20 - listPadding) + } + /// A card's content inset: the window margin, the gap between cards, and the + /// card's own padding — 14 at the sides, and optically even top to bottom + /// (the title's line keeps more space above its caps than the details' keeps + /// under its baseline, so the top takes 2 pt less than the bottom). + private static var cardInsets: EdgeInsets { + EdgeInsets( + top: LibraryCard.gap + 9, leading: 34 - listPadding, bottom: LibraryCard.gap + 11, trailing: 34 - listPadding) } - /// The list of entries, under their day headings. - private var list: some View { - List(selection: $selection) { - ForEach(days) { day in - Section(day.title) { - ForEach(day.rows, id: \.entry.id) { row in - let entry = row.entry - HistoryRow( - entry: entry, details: row.details, dayTitle: day.title, - actions: EntryActions(entry: entry, model: model, delete: { pendingDelete = entry }) - ) - .tag(entry.id) - // Two-line rows separate themselves, as in Mail; a plain list's - // edge-to-edge rules wouldn't match the form's inset ones above. - .listRowSeparator(.hidden) - } - } - } - } - .listStyle(.plain) - .listSectionSeparator(.hidden) - .scrollContentBackground(.hidden) - .accessibilityIdentifier(UITestIdentifiers.recentList) - .contextMenu(forSelectionType: SavedTranscript.ID.self) { ids in - if let entry = entry(id: ids.first) { - EntryActions(entry: entry, model: model, delete: { pendingDelete = entry }) - } - } primaryAction: { ids in - // Double-click and Return. - if let entry = entry(id: ids.first) { model.open(entry) } - } - .onCopyCommand { - guard let entry = entry(id: selection) else { return [] } - AccessibilityNotification.Announcement("Copied").post() - return [NSItemProvider(object: entry.text as NSString)] - } - .onDeleteCommand { pendingDelete = entry(id: selection) } + private func entry(id: SavedTranscript.ID?) -> SavedTranscript? { + id.flatMap { id in model.history.first { $0.id == id } } } @ViewBuilder private var emptyMessage: some View { @@ -236,7 +222,7 @@ struct LibrarySection: View { .foregroundStyle(.primary) HStack(spacing: 4) { Text("Click a text field, then \(activation.startVerb)") - KeyCap(label: triggerKey.keycapLabel) + KeyCap(label: triggerKey.spokenName) Text("to dictate") } Text("Press Record, or drop an audio file, to transcribe") @@ -279,108 +265,68 @@ private struct EntryActions: View { } } -/// One saved entry as a two-line row: its kind's glyph, the title (the LLM's -/// for a recording, else the start of the text) over its details, and the time -/// of day — the day is the section heading. Opening is double-click or Return, -/// deleting is Delete, and the rest is the contextual menu, as in any Mac list. -/// Under the pointer the time gives way to a one-click Copy button. +/// One saved entry as a compact two-line row — Journal's entry card, much +/// denser: its kind's glyph, the title (the LLM's for a recording, else the +/// start of the text) over its details and time of day — the day is the +/// section heading — and a ⋯ menu of its actions, as each Journal entry has. +/// A click on the card opens it; the same actions are its contextual menu. private struct HistoryRow: View { let entry: SavedTranscript let details: String let dayTitle: String let actions: EntryActions - @State private var isHovered = false - @State private var showsCopied = false - /// Bumped per copy, so a second copy restarts the "Copied" timer. - @State private var copyCount = 0 - - /// What the trailing slot shows: the time at rest, Copy under the pointer, - /// and a brief "Copied" after a copy. One value, so only one can show. - private enum TrailingSlot { case time, copyButton, copied } - - private var trailingSlot: TrailingSlot { - if showsCopied { return .copied } - return isHovered ? .copyButton : .time - } + /// Wide enough for the widest kind glyph. + private static let iconWidth: CGFloat = 20 var body: some View { let title = entry.title let time = entry.date.formatted(date: .omitted, time: .shortened) - HStack { - Label { - VStack(alignment: .leading, spacing: 1) { - Text(title) - .lineLimit(1) - .truncationMode(entry.isTranscript ? .middle : .tail) - Text(details) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } icon: { - Image(systemName: LibraryKind(entry).systemImage) + HStack(spacing: 8) { + // Centred on both lines, in a slot of one width, so every title starts + // at the same x whatever the kind's glyph. + Image(systemName: LibraryKind(entry).systemImage) + .foregroundStyle(.secondary) + .frame(width: Self.iconWidth) + VStack(alignment: .leading, spacing: 1) { + Text(title) + .lineLimit(1) + .truncationMode(entry.isTranscript ? .middle : .tail) + Text("\(details) · \(time)") + .font(.subheadline) .foregroundStyle(.secondary) + .monospacedDigit() + .lineLimit(1) } - Spacer(minLength: 8) - trailingAccessory(time: time) - } - .padding(.vertical, 3) - .onHover { isHovered = $0 } - // Reverts "Copied" after a beat; a superseded timer is cancelled rather - // than clearing a newer copy's confirmation. - .task(id: copyCount) { - guard copyCount > 0 else { return } - guard (try? await Task.sleep(for: .seconds(1.5))) != nil else { return } - showsCopied = false + Spacer(minLength: 4) + Menu { + actions + } label: { + Label("Actions", systemImage: "ellipsis") + .labelStyle(.iconOnly) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .foregroundStyle(.secondary) + .help("Actions") + // VoiceOver reaches the same actions on the row itself. + .accessibilityHidden(true) } .accessibilityElement(children: .combine) .accessibilityLabel("\(title), \(details), \(dayTitle) at \(time)") .accessibilityHint("Opens the transcript") - // The contextual menu's actions, offered to VoiceOver on the row itself. + // The menu's actions, offered to VoiceOver on the row itself. .accessibilityActions { actions } } +} - /// The trailing slot: the time, the Copy button or "Copied", layered and - /// faded rather than swapped, so the slot sizes to the widest and nothing - /// shifts as they trade places. - private func trailingAccessory(time: String) -> some View { - ZStack(alignment: .trailing) { - Text(time) - .foregroundStyle(.secondary) - .monospacedDigit() - .opacity(trailingSlot == .time ? 1 : 0) - .allowsHitTesting(false) - Button(action: copy) { - // Tighter than `Label`'s icon–title gap, which reads as two items here. - HStack(spacing: 3) { - Image(systemName: "doc.on.doc") - Text("Copy") - } - } - // Borderless draws it in the accent, marking it clickable against the - // secondary time it replaces. - .buttonStyle(.borderless) - .help("Copy the transcript") - .opacity(trailingSlot == .copyButton ? 1 : 0) - // Opacity-0 views still hit-test: take clicks only while visible. - .allowsHitTesting(trailingSlot == .copyButton) - Label("Copied", systemImage: "checkmark") - .foregroundStyle(.secondary) - .opacity(trailingSlot == .copied ? 1 : 0) - .allowsHitTesting(false) - } - .font(.subheadline) - .fixedSize() - .animation(.easeOut(duration: 0.12), value: trailingSlot) - // VoiceOver copies through the row's Copy action and hears the - // announcement, so the visual slot stays out of the combined element. - .accessibilityHidden(true) - } - - private func copy() { - NSPasteboard.copyAnnounced(entry.text) - showsCopied = true - copyCount += 1 - } +extension FocusedValues { + /// Puts the cursor in the focused window's history search: what Edit › Find + /// (⌘F) calls. Nil — and the menu item disabled — in a window without one. + @Entry var focusHistorySearch: (() -> Void)? + /// Opens the focused window's file panel to transcribe a file: what File › + /// Open… (⌘O) calls. Nil — and the menu item disabled — in a window without + /// one. + @Entry var importAudioFile: (() -> Void)? } diff --git a/App/Blurt/Blurt/Wizard/LiveRecordingView.swift b/App/Blurt/Blurt/Wizard/LiveRecordingView.swift index c700254f..d934beea 100644 --- a/App/Blurt/Blurt/Wizard/LiveRecordingView.swift +++ b/App/Blurt/Blurt/Wizard/LiveRecordingView.swift @@ -41,7 +41,7 @@ struct LiveRecordingView: View { } .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) - .padding(8) + .padding(.vertical, 4) } // Follows the newest line as the transcript grows. .defaultScrollAnchor(.bottom) diff --git a/App/Blurt/Blurt/Wizard/MeetingAutoRecordSection.swift b/App/Blurt/Blurt/Wizard/MeetingAutoRecordSection.swift index a9c27a57..81852f0b 100644 --- a/App/Blurt/Blurt/Wizard/MeetingAutoRecordSection.swift +++ b/App/Blurt/Blurt/Wizard/MeetingAutoRecordSection.swift @@ -15,10 +15,16 @@ struct MeetingAutoRecordSection: View { Toggle(isOn: $isOn) { SettingLabel( title: "Auto-record meetings", systemImage: "video", - subtitle: "Records your mic and Mac audio while a meeting app or browser call uses the microphone.") + subtitle: "Records your mic and Mac audio.") } .accessibilityIdentifier(UITestIdentifiers.autoRecordToggle) .onChange(of: isOn) { _, isOn in model.setAutoRecordMeetings(isOn) } + } header: { + Text("Meetings") + } footer: { + // The when, below the row as the Microphone section's fallback note is, + // so the subtitle stays one line. + Text("Recording starts when a meeting app or browser call uses the microphone.") } } } diff --git a/App/Blurt/Blurt/Wizard/ObservedKeyBindings.swift b/App/Blurt/Blurt/Wizard/ObservedKeyBindings.swift index d2c794a1..aa8e2fce 100644 --- a/App/Blurt/Blurt/Wizard/ObservedKeyBindings.swift +++ b/App/Blurt/Blurt/Wizard/ObservedKeyBindings.swift @@ -5,7 +5,7 @@ import SwiftUI /// for the views that show them: the Keys tab, the ready screen, the menu bar, /// the permissions page and the Read Aloud tab. /// -/// Observes the four raw slots the bindings are built from through +/// Observes the three raw slots the bindings are built from through /// `@AppStorage`, rather than reading `TriggerKeyBindings()` once, so a view /// re-renders when the separate Settings window changes any of them. Each slot's unset default /// (0, "") is "nothing persisted": the defaults belong to `TriggerKeyBindings`, @@ -14,11 +14,9 @@ import SwiftUI struct ObservedKeyBindings: DynamicProperty { @AppStorage(TriggerKeyStore.defaultsKey) private var boundKeyCode = 0 @AppStorage(TriggerKeyStyleStore.defaultsKey) private var choicesRaw = "" - @AppStorage(TriggerKeyActivationStore.defaultsKey) private var modesRaw = "" - @AppStorage(TriggerActivationStore.defaultsKey) private var sharedModeRaw = "" + @AppStorage(TriggerActivationStore.defaultsKey) private var activationRaw = "" var wrappedValue: TriggerKeyBindings { - TriggerKeyBindings( - boundKeyCode: boundKeyCode, choicesRaw: choicesRaw, modesRaw: modesRaw, sharedModeRaw: sharedModeRaw) + TriggerKeyBindings(boundKeyCode: boundKeyCode, choicesRaw: choicesRaw, activationRaw: activationRaw) } } diff --git a/App/Blurt/Blurt/Wizard/ReadAloudSettingsTab.swift b/App/Blurt/Blurt/Wizard/ReadAloudSettingsTab.swift index fa519250..873cf4c9 100644 --- a/App/Blurt/Blurt/Wizard/ReadAloudSettingsTab.swift +++ b/App/Blurt/Blurt/Wizard/ReadAloudSettingsTab.swift @@ -22,7 +22,7 @@ struct ReadAloudSection: View { var coordinator: AppCoordinator // Observed so the section follows the Keys tab: with no key reading aloud - // or asking, these settings do nothing, and asking adds its own note. + // or asking, these settings do nothing, so they're disabled. @ObservedKeyBindings private var bindings @AppStorage(ReadAloudStyleStore.speedDefaultsKey) private var speed = ReadAloudStyleStore.defaultSpeed @@ -57,7 +57,6 @@ struct ReadAloudSection: View { var body: some View { let bindings = bindings let readsOrAsks = bindings.readsOrAsks - let asks = bindings.asks Section { Group { PickerSettingRow( @@ -90,26 +89,6 @@ struct ReadAloudSection: View { .accessibilityIdentifier(UITestIdentifiers.readAloudSkipsJargonToggle) } .disabled(!readsOrAsks) - } header: { - Text("Playback") - } footer: { - // Each note is its own paragraph, and each says where the text goes: reading - // aloud is on by default (left ⌥), so the first one always shows. - if TriggerKey.allCases.contains(where: { bindings.action(for: $0) == .readAloud }) { - Text("Select text, then use a key assigned to \(KeyActionName.readAloud).") - } else if !readsOrAsks { - Text("Assign a \(KeyActionName.readAloud) or \(KeyActionName.ask) key in Keys to enable playback.") - } - if asks { - Text("Select text, then use an \(KeyActionName.ask) key and speak your request.") - Text("Your selection and spoken request are sent to AssemblyAI’s LLM Gateway to generate an answer.") - } - if readsOrAsks { - Text("Selected text is sent to AssemblyAI to read aloud.") - if skipsJargon { - Text("Skip technical text also sends your selection to AssemblyAI’s LLM Gateway before playback.") - } - } } } } diff --git a/App/Blurt/Blurt/Wizard/ReadyView.swift b/App/Blurt/Blurt/Wizard/ReadyView.swift index bbd04eea..14edb8af 100644 --- a/App/Blurt/Blurt/Wizard/ReadyView.swift +++ b/App/Blurt/Blurt/Wizard/ReadyView.swift @@ -1,11 +1,12 @@ import BlurtEngine import SwiftUI -/// The "you're all set" screen shown in the main window once setup is complete: -/// the wordmark over a grouped form — a row to record or drop in audio, then -/// the keys you've configured and the style in effect — the Library, and a -/// bottom bar with the attribution links and a Settings button, the window's own door to the Settings scene -/// alongside the app-menu "Settings…" (⌘,) and the menu-bar item. +/// The main window once setup is complete, shaped like a dictation app's home: +/// Blurt's real work happens outside it, on the keys, so the keys lead — each +/// configured key and what it does, and the style in effect — over a feed of +/// what Blurt has done (the Library). Recording and file import, the one +/// thing done from the window itself, are toolbar buttons beside the Filter +/// menu, Settings and the search. /// /// A grouped `Form`, the same surface the Settings window's panes use, rather /// than hand-drawn cards: one inset-grouped style for every container means @@ -22,7 +23,7 @@ struct ReadyView: View { private var triggerKey: TriggerKey { bindings.bound } /// The dictation key's tap/hold mode, which the rows' tooltips and the /// Library's first-run prompt follow. - private var activation: TriggerActivation { bindings.activation(for: bindings.bound) } + private var activation: TriggerActivation { bindings.activation } /// Kept as view state because Settings is a separate scene: its defaults /// write can otherwise leave this already-open window displaying the old @@ -30,225 +31,263 @@ struct ReadyView: View { /// after each list write, and this window reloads through the store so its /// decoding and normalization rules remain the single source of truth. @State private var profiles = StyleProfileStore().profiles + /// The toolbar's Filter menu: everything, or one kind of entry. + @State private var filter = LibraryFilter.all + /// The toolbar's search, and whether the cursor is in it (⌘F). + @State private var query = "" + @FocusState private var searchFocused: Bool + /// The toolbar's Browse open panel. + @State private var isImporting = false @AppStorage(StyleProfileStore.activeDefaultsKey) private var rawActiveID = "" - @Environment(\.accessibilityReduceMotion) private var reduceMotion + + /// The one page the window pushes: whatever the model has open. + private enum Route: Hashable { case openItem } + + /// The navigation path, bound to the model: a page while it has something + /// open — an item, a recording, a file going up, which open themselves as + /// often as a click opens them — and none when it's idle. Popping (back, + /// or Esc) lets go of it, abandoning a file going up. + private var path: Binding<[Route]> { + Binding( + get: { coordinator.transcripts.state == .idle ? [] : [.openItem] }, + set: { if $0.isEmpty { coordinator.transcripts.dismiss() } }) + } var body: some View { + // The system's push and back, as Journal's entries open. + NavigationStack(path: path) { + home + .frame(minHeight: Self.minHeight, idealHeight: Self.idealHeight, maxHeight: .infinity) + .toolbar { toolbarItems } + // The system's search: its own capsule at the toolbar's trailing + // edge, widening when clicked into, as Finder's and Mail's do. + .searchable(text: $query, placement: .toolbar, prompt: "Search Blurt") + .searchFocused($searchFocused) + // Not the field a window opens in: focused, it widens and pushes + // Filter and Settings behind a » menu. Click or ⌘F puts it there. + .defaultFocus($searchFocused, false) + .task { searchFocused = false } + // A click anywhere in the window's content lets go of the search, as + // clicking off a field does. Alongside, not instead of, what the + // click does — a card still opens, a menu still drops. + .simultaneousGesture(TapGesture().onEnded { searchFocused = false }) + .navigationDestination(for: Route.self) { _ in + TranscriptPage(model: coordinator.transcripts) + .frame(minHeight: Self.minHeight, idealHeight: Self.idealHeight, maxHeight: .infinity) + // A recording holds the page until it's stopped or discarded. + .navigationBarBackButtonHidden(coordinator.transcripts.isRecordingBusy) + } + } + .frame(width: MainWindow.contentWidth) + // No rule under the toolbar: the window reads as one surface, the + // backdrop running up behind the toolbar as Journal's does. + .toolbarBackgroundVisibility(.hidden, for: .windowToolbar) + // No title beside the search: it left the field no room to grow when + // focused, and the toolbar overflowed into a » menu. An open item names + // itself in its own toolbar. The window keeps its title for the Window + // menu, Mission Control and accessibility. + .toolbar(removing: .title) + // Drop an audio or video file anywhere on the window to transcribe it. + .audioFileDropTarget(coordinator.transcripts) + // Edit › Find (⌘F), for this window: nothing to search before a first + // entry. + .focusedSceneValue( + \.focusHistorySearch, coordinator.transcripts.history.isEmpty ? nil : { searchFocused = true } + ) + // File › Open… (⌘O), for this window: Browse's panel. Not over a + // recording, which `transcribe(_:)` refuses. + .focusedSceneValue(\.importAudioFile, coordinator.transcripts.isRecordingBusy ? nil : { isImporting = true }) + .fileImporter(isPresented: $isImporting, allowedContentTypes: [.audiovisualContent]) { result in + if case .success(let url) = result { coordinator.transcripts.transcribe(url) } + } + // Esc cancels the in-flight dictation — same command the overlay's owner + // submits (`.cancel`), scoped to this window by living on a button in it. + // Present through all of capture (not just `.recording`), so Esc during + // the mic bring-up also cancels rather than beeping. + .background { + if coordinator.isCapturing { + Button("Cancel Dictation") { coordinator.session.submit(.cancel) } + .keyboardShortcut(.cancelAction) + .hidden() + } + } + .onReceive( + NotificationCenter.default.publisher(for: StyleProfileStore.profilesDidChangeNotification) + ) { _ in + profiles = StyleProfileStore().profiles + } + } + + /// The window's height: free between these, as its content scrolls; an + /// open item takes the same, so the window doesn't jump as it comes and goes. + private static let minHeight: CGFloat = 420 + private static let idealHeight: CGFloat = 640 + + /// The keys over the Library, as white cards on the brand gradient: what + /// the window shows with nothing open. + @ViewBuilder private var home: some View { // Decoded and resolved once per render: `body` re-runs on every dictation // (the Library is live), and both answers cost a JSON decode. Which // style is active is the engine's rule — the stored pointer, `nil` for the // Default sentinel, or the first profile when it names nothing — never a // second reading here. let active = StyleProfileStore.active(in: profiles, id: rawActiveID) - VStack(spacing: 0) { - MainWindowBranding() - .padding(.top, 12) - - Form { - // Recording and file import first: the one row that starts something - // from the window itself. - Section { - TranscribeRow(model: coordinator.transcripts) - } - // The keys, each with how its gesture works in its tooltip; the - // writing style the dictation keys use closes the group. - Section { - shortcutRow - // Every other key given an action in the Keys tab, in its order. + // One list for the whole window: the keys lead it and scroll away with + // the Library under them, and its scroller is the window's own. + LibrarySection( + coordinator: coordinator, triggerKey: triggerKey, activation: activation, query: query, filter: filter + ) { + // What you can press — Blurt's real work happens outside this window, + // on these keys. A legend under a Keys heading, not a white card: only the + // Library's entries are cards, and the keys are drawn as keycaps, to + // press rather than click. Once a custom style exists, the writing + // style the dictation keys use closes the group. + VStack(alignment: .leading, spacing: 0) { + Text("Keys") + .font(.headline) + .padding(.leading, 4) + .padding(.bottom, 4) + .accessibilityAddTraits(.isHeader) + // A soft, translucent panel — rounded like the cards but flat and + // see-through, so it reads as a legend rather than another card to + // click. + VStack(alignment: .leading, spacing: 0) { + shortcutRow.keyRow() + // Every other key given an action in the Keys tab, in its order, + // each under a faint hairline across the row, pairing each action + // with its key. ForEach(bindings.extraKeys, id: \.self) { key in - extraKeyRow(key, profiles: profiles) + KeyDivider() + extraKeyRow(key, profiles: profiles).keyRow() } - // Always present, even with no custom styles, so the active treatment - // remains visible from the main window. + } + .padding(.vertical, 6) + .background( + Color(nsColor: .textBackgroundColor).opacity(0.55), + in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + // Only once there is a custom style: with none, every dictation is + // Default and the row would offer no choice. Never locked during a + // dictation: each one's style is fixed as its key goes down + // (`PressStyle`), so a pick mid-utterance applies to the next. + if !profiles.isEmpty { StyleRow(profiles: profiles, activeID: active?.id) - // Locked while the mic is opening or capturing: a style picked - // mid-utterance would disagree with what the request was built - // with. `.disabled` propagates to the pop-up and the hidden - // ⌘1–⌘5 buttons, whose shortcuts don't fire while disabled. - .disabled(coordinator.isCapturing) - } footer: { + .keyRow() + // The caveat is about styles, so it goes with the row. StylesInertNotice() + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 10) } } - .formStyle(.grouped) - .controlSize(.small) - .environment(\.defaultMinListRowHeight, 28) - .scrollDisabled(true) - .fixedSize(horizontal: false, vertical: true) - - // Every saved entry, outside the form so its list scrolls; the whole - // window takes a dropped file (`audioFileDropTarget`). - LibrarySection(coordinator: coordinator, triggerKey: triggerKey, activation: activation) - .padding(.horizontal, 20) - .padding(.bottom, 16) - - bottomBar - // The grouped form insets its sections by 20, so the bar lines up - // with their edges. - .padding(.horizontal, 20) - .padding(.bottom, 16) - } - .frame(width: MainWindow.contentWidth) - .fixedSize(horizontal: false, vertical: true) - // Drop an audio or video file anywhere on the window to transcribe it. - .audioFileDropTarget(coordinator.transcripts) - .onReceive( - NotificationCenter.default.publisher(for: StyleProfileStore.profilesDidChangeNotification) - ) { _ in - profiles = StyleProfileStore().profiles } + .background(BlurtBrand.backdrop.ignoresSafeArea()) } /// The dictation shortcut as a form row: "Dictate" leading, the bound key - /// trailing, and how its gesture works in the tooltip. The keycap fills - /// with the accent the moment the key goes down (`isCapturing`, which - /// includes the mic bring-up) — the window's proof the hotkey is registered — - /// with the live phase beside the key during capture and transcription. - /// - /// The phase is gated on the same stream the overlay pill renders - /// (`coordinator.menuBarStatus`, whose `.recording` deliberately excludes - /// the mic bring-up) — during "Connecting…" nothing is captured yet, so - /// claiming "Listening" would invite unrecoverable speech. + /// trailing, and how its gesture works in the tooltip. Still while a key is + /// down: how a dictation is going is the pill's to say, not the window's. private var shortcutRow: some View { - SettingRow(title: "Dictate", systemImage: "keyboard") { - HStack(spacing: 8) { - // The live phase while dictating. - if coordinator.menuBarStatus != .idle { - phaseText - .foregroundStyle(.secondary) - } - KeyCap(label: triggerKey.keycapLabel, isLit: coordinator.isCapturing) - } + SettingRow(title: "Dictate", systemImage: TriggerKeyAction.dictate.systemImage) { + KeyCap(label: triggerKey.spokenName) } .help(activation.hint(for: .dictate)) .accessibilityElement(children: .ignore) - .accessibilityLabel("Dictate: \(triggerKey.spokenName). \(phaseDescription)") - // Esc cancels the in-flight dictation — same command the overlay's owner - // submits (`.cancel`), scoped to this window by living on a button in it. - // Present through all of capture (not just `.recording`), so Esc during - // the mic bring-up also cancels rather than beeping. - .background { - if coordinator.isCapturing { - Button("Cancel Dictation") { coordinator.session.submit(.cancel) } - .keyboardShortcut(.cancelAction) - .hidden() - } - } + .accessibilityLabel("Dictate: \(triggerKey.spokenName). \(activation.hint(for: .dictate))") } /// One extra key's row: what it does — with the style a dictating key uses /// when it isn't the main window's — leading, its keycap trailing, and how - /// its gesture works in the tooltip. No live phase: that stays on the dictation row above, - /// whichever key started the dictation. + /// its gesture works in the tooltip. private func extraKeyRow(_ key: TriggerKey, profiles: [StyleProfile]) -> some View { let choice = bindings.choice(for: key, profiles: profiles) - let (title, symbol): (String, String) = + let title: String = switch choice { - case .readAloud: (LibraryKind.readAloud.name, LibraryKind.readAloud.systemImage) - case .ask: (LibraryKind.ask.name, LibraryKind.ask.systemImage) - case .rewrite: (KeyActionName.rewrite, "pencil.line") - case .command: (KeyActionName.command, "command") - case .dictate(TriggerKeyChoice.mainWindowStyle), .off: ("Dictate", "keyboard") + case .readAloud: LibraryKind.readAloud.name + case .ask: LibraryKind.ask.name + case .rewrite: KeyActionName.rewrite + case .command: KeyActionName.command + case .dictate(TriggerKeyChoice.mainWindowStyle), .off: "Dictate" case .dictate(let style): - ( - "Dictate: \(StyleProfileStore.active(in: profiles, id: style)?.name ?? StyleProfileStore.defaultStyleName)", - "keyboard" - ) + "Dictate: \(StyleProfileStore.active(in: profiles, id: style)?.name ?? StyleProfileStore.defaultStyleName)" } - let hint = bindings.activation(for: key).hint(for: choice.action) + let symbol = choice.action.systemImage + let hint = bindings.activation.hint(for: choice.action) return SettingRow(title: title, systemImage: symbol) { - KeyCap(label: key.keycapLabel) + KeyCap(label: key.spokenName) } .help(hint) .accessibilityElement(children: .ignore) .accessibilityLabel("\(title): \(key.spokenName). \(hint)") } - /// The shortcut row's trailing status, shown only during an active dictation. - @ViewBuilder - private var phaseText: some View { - switch coordinator.menuBarStatus { - case .recording: - HStack(spacing: 4) { - Image(systemName: "waveform") - .foregroundStyle(BlurtBrand.accent) - // The pill's live-capture heartbeat (`RecordingTag`), same cadence, - // stilled under Reduce Motion the same way. - .pulsingOpacity(period: 1.2, minOpacity: 0.4, animated: !reduceMotion) - Text("Listening…") + /// The toolbar: Record and Browse leading, as a window's own actions are; + /// beside the search, the Library's Filter menu and Settings. + @ToolbarContentBuilder private var toolbarItems: some ToolbarContent { + let model = coordinator.transcripts + ToolbarItemGroup(placement: .navigation) { + Button { + model.startRecording() + } label: { + Label("Record", systemImage: "record.circle") } - case .transcribing: - Text("Transcribing…") - case .idle: - EmptyView() + // Disabled while anything else has the transcript sheet; a running + // recording's Stop lives in that sheet. + .disabled(!model.canRecord) + .help( + model.canRecord + ? "Record your microphone and your Mac's audio together, transcribed live" + : "Available when the current transcription finishes" + ) + .accessibilityIdentifier(UITestIdentifiers.transcribeRecordButton) + Button { + isImporting = true + } label: { + Label("Browse", systemImage: "folder") + } + // `transcribe(_:)` refuses while a recording is busy, so the button + // says so rather than opening a panel that does nothing. + .disabled(model.isRecordingBusy) + .help("Choose an audio or video file to transcribe") + .accessibilityIdentifier(UITestIdentifiers.transcribeBrowseButton) } - } - - /// The phase for VoiceOver, spelled out where the row abbreviates — naming - /// only the gestures the configured activation responds to. - private var phaseDescription: String { - switch coordinator.menuBarStatus { - case .recording: "Listening. \(activation.finishHint) Escape cancels." - case .transcribing: "Transcribing." - case .idle: activation.hint(for: .dictate) + ToolbarItem { + // Mail's Filter button: one kind of entry, or all of them. The icon + // fills while a filter is on, so a shortened list never looks complete. + Menu { + Picker("Show", selection: $filter) { + ForEach(LibraryFilter.allCases, id: \.self) { kind in + Label(kind.title, systemImage: kind.systemImage).tag(kind) + } + } + .pickerStyle(.inline) + } label: { + Label( + "Filter", + systemImage: filter == .all + ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill") + } + .help(filter == .all ? "Show one kind of entry" : "Showing \(filter.title.lowercased())") + .accessibilityIdentifier(UITestIdentifiers.libraryFilterMenu) } - } - - /// The window's last row, laid out the way a macOS window's bottom bar is: - /// the attribution on the leading edge, the one button on the trailing edge. - /// A standard push button — Settings is a place visited once, and ⌘, and the - /// app menu already reach it, so it takes neither the accent nor glass. - private var bottomBar: some View { - HStack { - footerLinks - Spacer() + ToolbarItem { Button { openSettings() } label: { - Label("Settings…", systemImage: "gearshape") - .labelStyle(.titleAndIcon) + Label("Settings", systemImage: "slider.horizontal.3") } - .controlSize(.small) + .help("Settings") } - } - - /// "Powered by AssemblyAI · Report a bug", caption-level. Links retain - /// the system's semantic link color instead of reusing the brand accent that - /// also colors noninteractive glyphs elsewhere in the window. - /// - /// Split so the linked words keep their affordance: "Powered by" is quiet - /// secondary prose, while each `Link` carries the platform's link color; - /// making the entire line secondary leaves the interactive words - /// indistinguishable from static attribution. - /// - /// Sharing lives in the Help menu (`BlurtCommands`) rather than here: a - /// half-linked "Share Blurt on LinkedIn" read as an ad on the window's - /// quietest line. Each link omits itself if its URL fails to build - /// (`force_unwrapping` is banned repo-wide). - private var footerLinks: some View { - HStack(spacing: 3) { - if let url = BlurtLinks.poweredBy { - Text("Powered by").foregroundStyle(.secondary) - Link("AssemblyAI", destination: url) - } - if let issuesURL = BlurtLinks.reportBug { - // The dot is decoration, so VoiceOver skips it. - Text("·").foregroundStyle(.secondary).accessibilityHidden(true) - Link("Report a bug", destination: issuesURL) - } + // The search in a group of its own, apart from Settings. + if #available(macOS 26, *) { + ToolbarSpacer(.fixed) } - .font(.caption2) - .tint(.secondary) } } -/// The app's outbound links, shared by the ready screen's footer and the Help -/// menu (`BlurtCommands`). Failable construction throughout, so a caller omits -/// its link rather than force-unwrapping. +/// The app's outbound links, in the Help menu (`BlurtCommands`). Failable +/// construction throughout, so a caller omits its link rather than +/// force-unwrapping. enum BlurtLinks { - /// Where "Powered by AssemblyAI" points. - static let poweredBy = URL(string: "https://www.assemblyai.com/blurt") - /// The repo's GitHub issues. static let reportBug = URL(string: "https://github.com/AssemblyAI/blurt/issues") @@ -261,7 +300,8 @@ enum BlurtLinks { }() } -/// The `blurt` wordmark over the form: the brand-green mark +/// The `blurt` wordmark over the setup screen's form (`WizardView`; the ready +/// screen goes without, its toolbar holding the search): the brand-green mark /// (`Design/brand/blurt-ready-logo.png`, a 720×180 rasterization of the design's /// vector wordmark, so its 72×18 pt slot is fed nearly 7× the pixels it needs and /// stays crisp at any display scale). Smoothly interpolated — it's curved @@ -271,10 +311,9 @@ enum BlurtLinks { /// omits itself if the PNG can't load rather than swapping in a fallback /// identity view. struct MainWindowBranding: View { - /// Loaded once for the process rather than per `body` evaluation: this view - /// sits in `ReadyView`, whose body re-runs on every new dictation - /// (the saved history), and a bundle lookup plus a PNG decode is not - /// something to re-do on the main thread each time. + /// Loaded once for the process rather than per `body` evaluation: a bundle + /// lookup plus a PNG decode is not something to re-do on the main thread + /// each time the setup screen re-renders. /// /// Left to the target's `SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor` default /// rather than spelled `nonisolated(unsafe)`: `body` reads it on the main actor @@ -306,3 +345,22 @@ struct MainWindowBranding: View { } } } + +extension View { + /// A key legend row's padding: room for the hairline between rows. + fileprivate func keyRow() -> some View { + padding(.horizontal, 10).padding(.vertical, 6) + } +} + +/// The hairline between key rows: the separator colour, faint on the pale +/// panel, across the whole row — icon and all — inset as the rows are. +private struct KeyDivider: View { + var body: some View { + Rectangle() + .fill(Color(nsColor: .separatorColor).opacity(0.6)) + .frame(height: 0.5) + .padding(.horizontal, 10) + .accessibilityHidden(true) + } +} diff --git a/App/Blurt/Blurt/Wizard/SearchField.swift b/App/Blurt/Blurt/Wizard/SearchField.swift deleted file mode 100644 index 6f45bb6b..00000000 --- a/App/Blurt/Blurt/Wizard/SearchField.swift +++ /dev/null @@ -1,56 +0,0 @@ -import AppKit -import SwiftUI - -/// `NSSearchField` for SwiftUI: the system's own search control — magnifying -/// glass, clear button, Esc to clear — which a styled `TextField` only -/// imitates. Filters as the user types. -struct SearchField: NSViewRepresentable { - @Binding var text: String - let prompt: String - /// Bumped to put the cursor in the field — Edit › Find (⌘F), through the - /// window's `focusHistorySearch` focused value. - var focusRequest = 0 - - func makeNSView(context: Context) -> NSSearchField { - let field = NSSearchField() - field.placeholderString = prompt - field.controlSize = .small - // A small control's own font: the default stays regular-size, so the - // placeholder clipped in a field sized for small text. - field.font = .systemFont(ofSize: NSFont.systemFontSize(for: .small)) - field.sendsSearchStringImmediately = true - field.delegate = context.coordinator - field.setAccessibilityLabel("Search Library") - field.setAccessibilityIdentifier(UITestIdentifiers.recentSearchField) - context.coordinator.focusRequest = focusRequest - return field - } - - func updateNSView(_ field: NSSearchField, context: Context) { - if field.stringValue != text { field.stringValue = text } - if focusRequest != context.coordinator.focusRequest { - context.coordinator.focusRequest = focusRequest - field.window?.makeFirstResponder(field) - } - } - - func makeCoordinator() -> Coordinator { Coordinator(text: $text) } - - final class Coordinator: NSObject, NSSearchFieldDelegate { - let text: Binding - /// The last focus request acted on. - var focusRequest = 0 - init(text: Binding) { self.text = text } - - func controlTextDidChange(_ notification: Notification) { - guard let field = notification.object as? NSSearchField else { return } - text.wrappedValue = field.stringValue - } - } -} - -extension FocusedValues { - /// Puts the cursor in the focused window's history search: what Edit › Find - /// (⌘F) calls. Nil — and the menu item disabled — in a window without one. - @Entry var focusHistorySearch: (() -> Void)? -} diff --git a/App/Blurt/Blurt/Wizard/SettingRow.swift b/App/Blurt/Blurt/Wizard/SettingRow.swift index 12f8103c..4431ac99 100644 --- a/App/Blurt/Blurt/Wizard/SettingRow.swift +++ b/App/Blurt/Blurt/Wizard/SettingRow.swift @@ -81,6 +81,9 @@ struct PickerSettingRow: View { var systemImage: String var accessibilityID: String var subtitle: String? + /// What VoiceOver calls the pop-up, when the row's title alone doesn't say + /// what it sets (a Sounds row is titled with its key). Defaults to the title. + var pickerLabel: String? @Binding var selection: Value @ViewBuilder var options: () -> Options @@ -88,7 +91,7 @@ struct PickerSettingRow: View { SettingRow(title: title, systemImage: systemImage, subtitle: subtitle) { // The picker keeps its own (hidden) title so VoiceOver reads a meaningful // name for the pop-up button rather than an empty string. - Picker(title, selection: $selection, content: options) + Picker(pickerLabel ?? title, selection: $selection, content: options) .labelsHidden() .pickerStyle(.menu) // Deliberately *not* `.fixedSize()`: the pop-up hugs its title anyway diff --git a/App/Blurt/Blurt/Wizard/SettingsPane.swift b/App/Blurt/Blurt/Wizard/SettingsPane.swift new file mode 100644 index 00000000..1a66a380 --- /dev/null +++ b/App/Blurt/Blurt/Wizard/SettingsPane.swift @@ -0,0 +1,15 @@ +import SwiftUI + +/// The chrome every settings pane shares: a grouped, non-scrolling `Form` that +/// hugs its content, so each pane sizes the window to exactly its sections and +/// the panes can't drift apart in layout. +struct SettingsPane: View { + @ViewBuilder var content: Content + + var body: some View { + Form { content } + .formStyle(.grouped) + .scrollDisabled(true) + .fixedSize(horizontal: false, vertical: true) + } +} diff --git a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift index 399f395c..6707c2bb 100644 --- a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift +++ b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift @@ -3,7 +3,7 @@ import SwiftUI /// Root view of the `Settings` scene. A `TabView` at the root of a `Settings` /// scene renders as the standard macOS preferences window — a segmented toolbar -/// of panes (General / Keys / Read Aloud / Styles / Vocabulary / Advanced), each sized to its own content. This is the +/// of panes (General / Keys / Sounds / Read Aloud / Styles / Vocabulary / Advanced), each sized to its own content. This is the /// HIG-native answer to a settings screen that outgrows one pane: keeping every /// pane short means the window never has to grow past a small display (a single /// stacked `Form` did, stranding the bottom section off-screen). Each pane @@ -22,6 +22,8 @@ struct SettingsWindowRoot: View { .tabItem { Label(UITestIdentifiers.generalSettingsTab, systemImage: "gearshape") } KeysSettingsTab(coordinator: coordinator) .tabItem { Label(UITestIdentifiers.keysSettingsTab, systemImage: "keyboard") } + SoundsSettingsTab(coordinator: coordinator) + .tabItem { Label(UITestIdentifiers.soundsSettingsTab, systemImage: "bell") } ReadAloudSettingsTab(coordinator: coordinator) .tabItem { Label(UITestIdentifiers.readAloudSettingsTab, systemImage: "speaker.wave.2") } StylesSettingsTab() @@ -40,22 +42,9 @@ struct SettingsWindowRoot: View { } } -/// The chrome every settings pane shares: a grouped, non-scrolling `Form` that -/// hugs its content, so each pane sizes the window to exactly its sections and -/// the panes can't drift apart in layout. -struct SettingsPane: View { - @ViewBuilder var content: Content - - var body: some View { - Form { content } - .formStyle(.grouped) - .scrollDisabled(true) - .fixedSize(horizontal: false, vertical: true) - } -} - /// The everyday setup a user changes: the AssemblyAI account, the microphone, -/// the cue sounds, and whether meetings record themselves. The trigger keys are their own tab (`KeysSettingsTab`). Key terms live on Vocabulary, +/// and whether meetings record themselves. The trigger keys and their cue +/// sounds are their own tabs (`KeysSettingsTab`, `SoundsSettingsTab`). Key terms live on Vocabulary, /// beside the text shortcuts — both are "words Blurt should know". private struct GeneralSettingsTab: View { let coordinator: AppCoordinator @@ -64,7 +53,6 @@ private struct GeneralSettingsTab: View { SettingsPane { APIKeyStepView(apiKey: coordinator.apiKey, account: coordinator.account) MicrophoneStepView() - SoundStepView(coordinator: coordinator) MeetingAutoRecordSection(model: coordinator.transcripts) } } diff --git a/App/Blurt/Blurt/Wizard/SoundsSettingsTab.swift b/App/Blurt/Blurt/Wizard/SoundsSettingsTab.swift new file mode 100644 index 00000000..960c409f --- /dev/null +++ b/App/Blurt/Blurt/Wizard/SoundsSettingsTab.swift @@ -0,0 +1,77 @@ +import BlurtEngine +import SwiftUI + +/// The Sounds tab: one row per trigger key that does something, each with its +/// own start/stop cue voice (or None), so two keys never have to sound alike — +/// a read never sounds like a dictation starting unless the user wants it to. +/// A pane of its own after Keys rather than a column there: the key table +/// already fills the window's width. A key set to Off isn't listed, since it +/// has nothing to cue; its voice is kept for when it's turned back on. +/// +/// A key with no voice of its own shows, and plays, its action's setting +/// (`TriggerKeySoundStore.soundPack(id:action:)`), so an untouched install +/// sounds as it did before keys had voices. Writes go through the store and +/// then the coordinator, which reloads that key's players and previews the +/// pick; `@AppStorage` only observes, as on `KeysSettingsTab`. +struct SoundsSettingsTab: View { + let coordinator: AppCoordinator + + @ObservedKeyBindings private var bindings + @AppStorage(TriggerKeySoundStore.defaultsKey) private var soundsRaw = "" + + var body: some View { + let bindings = bindings + let ids = TriggerKeySoundStore.soundIDs(decoding: soundsRaw) + SettingsPane { + Section { + ForEach(TriggerKey.allCases.filter { bindings.action(for: $0) != .off }, id: \.self) { key in + PickerSettingRow( + title: key.spokenName, systemImage: KeysSettingsTab.symbol(for: key), + accessibilityID: UITestIdentifiers.keySoundPicker(key.keyCode), + subtitle: Self.actionName(bindings.action(for: key)), + pickerLabel: "Sound for \(key.fullName)", + selection: selection(for: key, id: ids[key], action: bindings.action(for: key)) + ) { + packOptions + } + } + } + } + } + + /// What the row's key does, as its subtitle — the same names the Keys tab's + /// pop-ups use. + private static func actionName(_ action: TriggerKeyAction) -> String { + switch action { + case .off: "Off" + case .dictate: "Dictate" + case .readAloud: KeyActionName.readAloud + case .ask: KeyActionName.ask + case .rewrite: KeyActionName.rewrite + case .command: KeyActionName.command + } + } + + private func selection(for key: TriggerKey, id: String?, action: TriggerKeyAction) -> Binding { + Binding( + get: { TriggerKeySoundStore.soundPack(id: id, action: action) }, + set: { newValue in + TriggerKeySoundStore().setSoundID(newValue.id, for: key) + coordinator.keySoundChanged(key) + }) + } + + /// Every picker's options: None, then every voice by synth. One catalog's + /// voices, so the lists can't drift apart. + @ViewBuilder private var packOptions: some View { + Text(SoundPack.none.label).tag(SoundPack.none) + Divider() + ForEach(SoundPackCatalog.blurt.groups, id: \.self) { group in + Section(group) { + ForEach(SoundPackCatalog.blurt.voices(in: group)) { pack in + Text(pack.label).tag(pack) + } + } + } + } +} diff --git a/App/Blurt/Blurt/Wizard/Steps/PermissionsStepView.swift b/App/Blurt/Blurt/Wizard/Steps/PermissionsStepView.swift index 4036b7a5..38f6e99d 100644 --- a/App/Blurt/Blurt/Wizard/Steps/PermissionsStepView.swift +++ b/App/Blurt/Blurt/Wizard/Steps/PermissionsStepView.swift @@ -45,7 +45,7 @@ struct PermissionsStepView: View { permissionRow( PermissionInfo( label: "Microphone", symbol: "mic.fill", buttonLabel: "Allow Access", - detail: "Records when you use \(triggerKey.keycapLabel)."), + detail: "Records when you use \(triggerKey.spokenName)."), granted: controller.permissions.microphone, action: { Task { diff --git a/App/Blurt/Blurt/Wizard/Steps/SoundStepView.swift b/App/Blurt/Blurt/Wizard/Steps/SoundStepView.swift deleted file mode 100644 index 3f4b6190..00000000 --- a/App/Blurt/Blurt/Wizard/Steps/SoundStepView.swift +++ /dev/null @@ -1,95 +0,0 @@ -import BlurtEngine -import SwiftUI - -/// The sound-cue section of the settings screen. Three menu pickers choose the -/// start/stop voice (or None) for dictation, for read-aloud and for Control -/// Your Mac — each its own pair, so a read or a command never sounds like a -/// dictation starting. Each choice is persisted, -/// its players reloaded, and a preview played immediately. -struct SoundStepView: View { - var coordinator: AppCoordinator - - // Empty means "no pack persisted" — the unset default belongs to - // `SoundPackCatalog.fromPersisted` (below), which names no known voice for `""` - // and so resolves the catalog's default. See `KeysSettingsTab` for why the view - // must not restate it. - @AppStorage(SoundPackStore.defaultsKey) private var soundPackID = "" - @AppStorage(SoundPackStore.readAloudDefaultsKey) private var readAloudPackID = "" - @AppStorage(SoundPackStore.commandDefaultsKey) private var commandPackID = "" - - private var selection: Binding { - Binding( - get: { - SoundPackCatalog.blurt.fromPersisted(soundPackID) - }, - set: { newValue in - // Write through the store (see `KeysSettingsTab` for why): the store owns the - // encoding, `@AppStorage` observes the key to re-render. - SoundPackStore.dictation.soundPack = newValue - coordinator.soundPackChanged() - }) - } - - /// Read-aloud's pack, the same way, through its own catalog default. - private var readAloudSelection: Binding { - Binding( - get: { SoundPackCatalog.blurtReadAloud.fromPersisted(readAloudPackID) }, - set: { newValue in - SoundPackStore.readAloud.soundPack = newValue - coordinator.readAloudSoundPackChanged() - }) - } - - /// Control Your Mac's pack, the same way, through its own catalog default. - private var commandSelection: Binding { - Binding( - get: { SoundPackCatalog.blurtCommand.fromPersisted(commandPackID) }, - set: { newValue in - SoundPackStore.command.soundPack = newValue - coordinator.commandSoundPackChanged() - }) - } - - /// Every picker's options: None, then every voice by synth. One catalog's - /// voices, so the lists can't drift apart. - @ViewBuilder private var packOptions: some View { - Text(SoundPack.none.label).tag(SoundPack.none) - ForEach(SoundPackCatalog.blurt.groups, id: \.self) { group in - Section(group) { - ForEach(SoundPackCatalog.blurt.voices(in: group)) { pack in - Text(pack.label).tag(pack) - } - } - } - } - - var body: some View { - Section { - PickerSettingRow( - title: "Dictation", systemImage: "speaker.wave.2", - accessibilityID: UITestIdentifiers.soundPicker, - subtitle: "Start and stop recording.", selection: selection - ) { - packOptions - } - PickerSettingRow( - title: "Read aloud", systemImage: "speaker.wave.2.bubble", - accessibilityID: UITestIdentifiers.readAloudSoundPicker, - subtitle: "Start and stop playback.", selection: readAloudSelection - ) { - packOptions - } - PickerSettingRow( - title: "Control Your Mac", systemImage: "command", - accessibilityID: UITestIdentifiers.commandSoundPicker, - subtitle: "Start and stop a spoken command.", selection: commandSelection - ) { - packOptions - } - } header: { - Text("Sounds") - } footer: { - Text("Choose None to turn off start and stop sounds.") - } - } -} diff --git a/App/Blurt/Blurt/Wizard/StyleRow.swift b/App/Blurt/Blurt/Wizard/StyleRow.swift index f85acf45..b6857970 100644 --- a/App/Blurt/Blurt/Wizard/StyleRow.swift +++ b/App/Blurt/Blurt/Wizard/StyleRow.swift @@ -34,22 +34,17 @@ struct StyleRow: View { SettingRow( title: "Writing style", systemImage: "textformat" ) { - if profiles.isEmpty { - // With no alternative there is nothing to pick. A passive value avoids - // presenting a one-item pop-up that promises a choice it can't offer. - Text(StyleProfileStore.defaultStyleName) - .foregroundStyle(.secondary) - } else { - Picker("Writing style", selection: choice) { - Text(StyleProfileStore.defaultStyleName).tag(Choice.defaultStyle) - ForEach(profiles) { profile in - Text(profile.name).tag(Choice.profile(profile.id)) - } + // Shown only once a custom style exists (`ReadyView`), so there is always + // a choice beyond Default. + Picker("Writing style", selection: choice) { + Text(StyleProfileStore.defaultStyleName).tag(Choice.defaultStyle) + ForEach(profiles) { profile in + Text(profile.name).tag(Choice.profile(profile.id)) } - .pickerStyle(.menu) - .labelsHidden() - .accessibilityIdentifier(UITestIdentifiers.styleProfilePickerFromMain) } + .pickerStyle(.menu) + .labelsHidden() + .accessibilityIdentifier(UITestIdentifiers.styleProfilePickerFromMain) } .background(shortcuts) } diff --git a/App/Blurt/Blurt/Wizard/TranscribeRow.swift b/App/Blurt/Blurt/Wizard/TranscribeRow.swift deleted file mode 100644 index 882766e3..00000000 --- a/App/Blurt/Blurt/Wizard/TranscribeRow.swift +++ /dev/null @@ -1,75 +0,0 @@ -import SwiftUI - -/// The way in for audio, as the ready screen's first form row: "Record, or -/// drop an audio file" — the title names both ways in — and trailing **Record** (the window's prominent -/// action) and **Browse…** (the open panel). The whole window takes a dropped -/// file (`audioFileDropTarget`); while one hovers anywhere over it, the whole -/// row turns into the drop target — accent-tinted, a dashed accent outline, -/// and one line saying what letting go will do — so there's no mistaking it. -/// -/// Record is disabled while anything else has the transcript sheet; a running -/// recording's Stop lives in that sheet, which can't be dismissed without one. -struct TranscribeRow: View { - var model: TranscriptsModel - @Environment(\.isFileDropTargeted) private var isTargeted - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @State private var isImporting = false - - var body: some View { - Group { - if isTargeted { - dropTarget - } else { - row - } - } - .listRowBackground( - ZStack { - if isTargeted { - Rectangle().fill(BlurtBrand.accent.opacity(0.12)) - FormPanel.shape - .strokeBorder(BlurtBrand.accent, style: StrokeStyle(lineWidth: 2, dash: [6, 4])) - } - } - ) - .animation(reduceMotion ? nil : .easeOut(duration: 0.15), value: isTargeted) - .fileImporter(isPresented: $isImporting, allowedContentTypes: [.audiovisualContent]) { result in - if case .success(let url) = result { model.transcribe(url) } - } - } - - /// The row while a file hovers over the window: one centered line in the - /// accent, at the row's usual height. - private var dropTarget: some View { - Label("Drop to transcribe", systemImage: "arrow.down.doc") - .font(.headline) - .foregroundStyle(BlurtBrand.accent) - .frame(maxWidth: .infinity, minHeight: 36) - } - - private var row: some View { - SettingRow(title: "Record, or drop an audio file", systemImage: "waveform") { - HStack(spacing: 6) { - Button { - model.startRecording() - } label: { - Label("Record", systemImage: "record.circle") - } - .buttonStyle(.borderedProminent) - .disabled(!model.canRecord) - .accessibilityIdentifier(UITestIdentifiers.transcribeRecordButton) - .help( - model.canRecord - ? "Record your microphone and your Mac's audio together, transcribed live" - : "Available when the current transcription finishes") - Button("Browse…") { isImporting = true } - // `transcribe(_:)` refuses while a recording is busy, so the button - // says so rather than opening a panel that does nothing. - .disabled(model.isRecordingBusy) - .help("Choose an audio or video file to transcribe") - .accessibilityIdentifier(UITestIdentifiers.transcribeBrowseButton) - } - .fixedSize() - } - } -} diff --git a/App/Blurt/Blurt/Wizard/TranscriptFacet.swift b/App/Blurt/Blurt/Wizard/TranscriptFacet.swift index 75bab31c..a02a9c8e 100644 --- a/App/Blurt/Blurt/Wizard/TranscriptFacet.swift +++ b/App/Blurt/Blurt/Wizard/TranscriptFacet.swift @@ -1,7 +1,7 @@ import BlurtEngine -/// The sheet's chips: the transcript itself, the stock transforms, and the -/// user's own request. Titles and symbols are the app's; the instructions +/// The transcript sheet's views, in its "View:" pop-up: the transcript itself, +/// the stock transforms, and the user's own request. Titles and symbols are the app's; the instructions /// behind them are the engine's (`TranscriptTransform`). Raw values key the /// saved results, so they never change once shipped. enum TranscriptFacet: String, Hashable, CaseIterable { @@ -46,7 +46,7 @@ enum TranscriptFacet: String, Hashable, CaseIterable { } } - /// The engine transform behind this chip; nil for Full, and for Custom + /// The engine transform behind this view; nil for Full, and for Custom /// until there is a request. func transform(custom: String) -> TranscriptTransform? { switch self { diff --git a/App/Blurt/Blurt/Wizard/TranscriptPage.swift b/App/Blurt/Blurt/Wizard/TranscriptPage.swift new file mode 100644 index 00000000..3534beb1 --- /dev/null +++ b/App/Blurt/Blurt/Wizard/TranscriptPage.swift @@ -0,0 +1,369 @@ +import Accessibility +import AppKit +import AssemblyAI +import BlurtEngine +import SwiftUI + +/// An opened item, pushed over the main window's Library on its +/// `NavigationStack` as an entry opens in Journal: the system's back button +/// leads the toolbar, the item's actions follow as icons — the View menu, +/// Play, Copy, Show in Finder, Delete — and its title heads the transcript +/// below. Nothing here is edited, so there's no Done or check mark: back (or +/// Esc) returns to the Library. The same page shows a recording as it runs, a +/// file's progress, and why one failed. +struct TranscriptPage: View { + var model: TranscriptsModel + @State private var copied = false + @State private var pendingDelete: SavedTranscript? + + var body: some View { + Group { + if case .finished = model.state, case .ready = model.contents[model.selection] { + // A transcript scrolls as one page with its title, as a note does: + // the title goes up with the text rather than staying pinned, and the + // scroller is the window's own. + ScrollView { + VStack(alignment: .leading, spacing: 12) { + titleLine + facetContent + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + } + } else { + VStack(alignment: .leading, spacing: 12) { + titleLine + status + } + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } + + .toolbar { toolbarItems } + // Esc goes back, as the back button does, wherever focus is — a hidden + // button rather than `onExitCommand`, which needs the page focused and + // lost it to the View menu. Not over a recording (`dismiss`). + .background { + if !model.isRecordingBusy { + Button("Back") { model.dismiss() } + .keyboardShortcut(.cancelAction) + .hidden() + } + } + // The page's replacement is silent otherwise. + .onChange(of: model.state) { _, state in + switch state { + case .finished: AccessibilityNotification.Announcement("Transcript ready").post() + case .failed(_, let message): AccessibilityNotification.Announcement(message).post() + case .recording: AccessibilityNotification.Announcement("Recording").post() + case .idle, .transcribing, .finishingRecording: break + } + } + // "Copied" belongs to the text that was copied, not the next view's. + .onChange(of: model.selection) { copied = false } + // Asks first; deleting the open entry slides back to the Library. + .deleteConfirmation(for: $pendingDelete, model: model) + } + + /// The title heads the page, as Notes' date line heads a note: in the + /// toolbar it took the actions' room, pushing them into a » menu. Only where + /// it says something the text doesn't — a recording's or a file's; a + /// dictation's or a read's would just repeat the words below it. + @ViewBuilder private var titleLine: some View { + if showsTitle { + Text(title) + .font(.body.weight(.semibold)) + .lineLimit(2) + .truncationMode(.middle) + .textSelection(.enabled) + } + } + + /// Everything but a transcript in hand: a recording, progress, a failure, + /// or a task still on its way. + @ViewBuilder private var status: some View { + switch model.state { + case .idle: + EmptyView() + case .recording: + LiveRecordingView(model: model) + case .finishingRecording: + ProgressLine("Finishing…").frame(maxWidth: .infinity, maxHeight: .infinity) + case .transcribing(_, let phase): + ProgressLine(phase == .uploading ? "Uploading…" : "Transcribing…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .finished: + facetContent + case .failed(_, let message): + Label(message, systemImage: "exclamationmark.triangle") + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Spacer() + } + } + + /// Whether the page has a title: a saved recording's or file's, or a file + /// going up — never an entry whose title is only the start of its text, nor + /// a recording under way, whose live header ("● Recording 17:26") already + /// says so. + private var showsTitle: Bool { + switch model.state { + case .finished: model.openEntry?.isTranscript == true + case .recording, .finishingRecording, .idle: false + case .transcribing, .failed: true + } + } + + /// What the page is headed with: the saved entry's title (the LLM's once + /// it lands), the file going up, or the recording. + private var title: String { + switch model.state { + case .finished: model.openEntry?.title ?? "" + case .transcribing(let fileName, _), .failed(let fileName, _): fileName + case .recording, .finishingRecording: "Recording" + case .idle: "" + } + } + + /// Once there's a transcript, its view and actions — the system's back + /// button leads (`ReadyView`'s `NavigationStack`): the View + /// menu, Play or Listen, Copy, and Show in Finder where + /// there's a file, then Delete in a group of its own. + @ToolbarContentBuilder private var toolbarItems: some ToolbarContent { + if model.state == .finished { actions } + } + + @ToolbarContentBuilder private var actions: some ToolbarContent { + ToolbarItem { + TranscriptFacetMenu(model: model) + } + ToolbarItemGroup { + if model.canPlayShown { playButton } + Button { + model.selectedText.map(copy) + } label: { + Label(copied ? "Copied" : "Copy", systemImage: copied ? "checkmark" : "doc.on.doc") + } + .disabled(model.selectedText == nil) + .help("Copy \(readingName == "it" ? "the transcript" : readingName)") + if let url = model.openEntry?.audioURL { + Button { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } label: { + Label("Show in Finder", systemImage: "folder") + } + .disabled(!FileManager.default.fileExists(atPath: url.path)) + .help("Show in Finder") + } + } + // Delete in a group of its own, apart from the actions that keep the item. + if let entry = model.openEntry { + if #available(macOS 26, *) { + ToolbarSpacer(.fixed) + } + ToolbarItem { + Button { + pendingDelete = entry + } label: { + Label("Delete", systemImage: "trash") + } + .help("Delete…") + } + } + } + + /// Plays what's showing — the entry's own audio on Full, anything else read + /// aloud in the user's voice, speech made on the spot — behind one Play and + /// Stop for both, with a spinner until spoken words start. The tooltip says + /// which it is. Toolbar-sized, as its neighbours are. + @ViewBuilder private var playButton: some View { + let original = model.showsOriginalAudio + let playing = model.isPlayingShown + Button { + model.togglePlayShown() + } label: { + Label { + Text(model.isPreparingShown ? "Preparing…" : playing ? "Stop" : "Play") + } icon: { + // One slot for every state, so the toolbar never re-lays itself out + // as the button changes, and the same play and stop for the entry's + // audio and for speech, so a press reads as the button changing state. + Group { + if model.isPreparingShown { + ProgressView().controlSize(.small) + } else { + Image(systemName: playing ? "stop.circle" : "play.circle") + } + } + .frame(width: Self.playIconSize, height: Self.playIconSize) + } + } + .help(original ? "Play the audio" : "Read \(readingName) aloud") + } + + /// The play button's icon slot: wide enough for the widest of its glyphs. + private static let playIconSize: CGFloat = 22 + + /// What Listen reads, mid-sentence: "it" for the transcript, else the view. + private var readingName: String { + model.selection == .full ? "it" : model.selection.title.lowercased() + } + + /// The selected facet's text area: the text, or its progress, or why it + /// failed with a way to try again. + @ViewBuilder + private var facetContent: some View { + let content = model.contents[model.selection] + Group { + switch content { + case .ready(let text): + // Inside the page's own scroll (`body`). A transcript's Full tab, + // with speakers told apart, shows as coloured turns (parsed once, + // `fullTurns`); everything else is prose, shown as it is. + Group { + if model.selection == .full, !model.fullTurns.isEmpty { + SpeakerTurnsView(turns: model.fullTurns) + } else { + Text(text).textSelection(.enabled) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + case .failed(let message): + VStack(spacing: 8) { + Label(message, systemImage: "exclamationmark.triangle") + .foregroundStyle(.secondary) + Button("Try Again") { model.retry() } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + case .loading: + ProgressLine(model.selection.progressTitle).frame(maxWidth: .infinity, maxHeight: .infinity) + case nil: + // Custom before a request: the popover is asking for one. + Text("Describe what to do with the transcript.") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + // In the page's scroll a transcript takes its own height; anything else + // fills what's left of the window. + .frame(maxHeight: model.selectedText == nil ? .infinity : nil) + } + + private func copy(_ transcript: String) { + NSPasteboard.copyAnnounced(transcript) + copied = true + } +} + +/// The view over the transcript, as a toolbar menu under an AI icon — the +/// transcript itself (Full, and Plain text where it differs), then the stock +/// transforms, then Custom, which asks for its request in a popover. The +/// current view is checked here and named in the window's subtitle. +private struct TranscriptFacetMenu: View { + var model: TranscriptsModel + @State private var isEditingCustom = false + @State private var customDraft = "" + + /// Custom is selected only once it has a request: choosing it without one + /// asks first, and Cancel leaves the selection where it was. + private var selection: Binding { + Binding( + get: { model.selection }, + set: { facet in + if facet == .custom, model.customRequest.isEmpty { + editCustom() + } else { + model.select(facet) + } + }) + } + + var body: some View { + let facets = model.availableFacets + Menu { + Picker("View", selection: selection) { + ForEach(facets.filter { $0 == .full || $0 == .plain }, id: \.self, content: item) + Divider() + ForEach(facets.filter { ![.full, .plain, .custom].contains($0) }, id: \.self, content: item) + Divider() + item(.custom) + } + .pickerStyle(.inline) + // Re-choosing the selected item doesn't reach the binding, so a + // request already applied is changed from here. + if model.selection == .custom { + Button("Edit Custom Request…", action: editCustom) + } + } label: { + Label("View", systemImage: "sparkles") + } + .help("Show the transcript or a task on it") + .accessibilityIdentifier(UITestIdentifiers.facetPicker) + .popover(isPresented: $isEditingCustom, arrowEdge: .bottom) { customEditor } + } + + /// One view in the menu. Custom reads "Custom…" until it has a request, + /// since choosing it asks for one first. + private func item(_ facet: TranscriptFacet) -> some View { + let title = facet == .custom && model.customRequest.isEmpty ? "Custom…" : facet.title + return Label(title, systemImage: facet.systemImage).tag(facet) + } + + private func editCustom() { + customDraft = model.customRequest + isEditingCustom = true + } + + /// Custom's request, asked for under the toolbar's View menu. + private var customEditor: some View { + VStack(alignment: .leading, spacing: 8) { + Text("What should Blurt do with this transcript?") + .font(.headline) + TextField( + "Custom request", text: $customDraft, prompt: Text("e.g. Write it up as a blog post"), axis: .vertical + ) + .lineLimit(2...4) + .textFieldStyle(.roundedBorder) + .onSubmit(apply) + HStack { + Spacer() + Button("Cancel", role: .cancel) { isEditingCustom = false } + .keyboardShortcut(.cancelAction) + Button("Apply", action: apply) + .keyboardShortcut(.defaultAction) + .disabled(customDraft.trimmedNonEmpty() == nil) + } + } + .padding(14) + .frame(width: 320) + } + + private func apply() { + model.applyCustom(customDraft) + isEditingCustom = false + } +} + +/// A spinner beside a word on what it's waiting for: every progress state in +/// the page, spelled once. +struct ProgressLine: View { + let title: String + init(_ title: String) { self.title = title } + + var body: some View { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text(title).foregroundStyle(.secondary) + } + } +} + +extension View { + /// The page's read-only text area: the rest of its height, the text right + /// on the window under the title, as a Journal entry's is — no box or edge. + func textAreaSurface() -> some View { + frame(maxHeight: .infinity) + } +} diff --git a/App/Blurt/Blurt/Wizard/TranscriptSheet.swift b/App/Blurt/Blurt/Wizard/TranscriptSheet.swift deleted file mode 100644 index 6c087613..00000000 --- a/App/Blurt/Blurt/Wizard/TranscriptSheet.swift +++ /dev/null @@ -1,340 +0,0 @@ -import Accessibility -import AppKit -import AssemblyAI -import BlurtEngine -import SwiftUI - -extension View { - /// Makes the whole view a drop target for audio and video files: a drop - /// starts a transcription in `model`, and the result comes up in a sheet - /// over the window. The hover state is published through - /// the environment, so the drop well (`TranscribeRow`) lights up wherever over the window the file - /// is held — the window is the target, the well is where it says so. - func audioFileDropTarget(_ model: TranscriptsModel) -> some View { - modifier(AudioFileDropTarget(model: model)) - } -} - -extension EnvironmentValues { - /// Whether a file is being dragged over the window's drop target. - @Entry var isFileDropTargeted = false -} - -private struct AudioFileDropTarget: ViewModifier { - var model: TranscriptsModel - @State private var isTargeted = false - - func body(content: Content) -> some View { - content - .environment(\.isFileDropTargeted, isTargeted) - .dropDestination(for: URL.self) { urls, _ in - // One file at a time: the sheet shows one transcript. - // A refused file says why rather than just snapping back: the zone - // already promised a transcription. - // Never over a recording: it owns the sheet until it's saved. - guard !model.isRecordingBusy else { return false } - guard let url = urls.first(where: TranscriptsModel.accepts) else { - model.refuse(urls.first) - return true - } - model.transcribe(url) - return true - } isTargeted: { - isTargeted = $0 - } - .sheet(isPresented: isPresented) { - TranscriptSheet(model: model) - } - } - - /// The sheet is up whenever a file is in flight or has an outcome to show; - /// closing it by any route (Done, Esc) goes through `dismiss()`. - private var isPresented: Binding { - Binding( - get: { model.state != .idle }, - set: { if !$0 { model.dismiss() } }) - } -} - -/// A transcript's progress and outcome: a spinner with Cancel while a file -/// uploads and transcribes, then the transcript — selectable, with Copy — or the reason -/// it failed. -private struct TranscriptSheet: View { - var model: TranscriptsModel - @State private var copied = false - @State private var pendingDelete: SavedTranscript? - /// The last state worth drawing. Idle means the sheet is closing, and it - /// keeps showing what it had while it animates out rather than going blank. - @State private var lastShown = TranscriptsModel.State.idle - - private var shown: TranscriptsModel.State { model.state == .idle ? lastShown : model.state } - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - switch shown { - case .idle: - EmptyView() - case .recording: - LiveRecordingView(model: model) - case .finishingRecording: - header("Recording") - ProgressLine("Finishing…").frame(maxWidth: .infinity, minHeight: 80) - case .transcribing(let fileName, let phase): - header(fileName) - ProgressLine(phase == .uploading ? "Uploading…" : "Transcribing…").frame(maxWidth: .infinity, minHeight: 80) - buttons { Button("Cancel", role: .cancel) { model.dismiss() }.keyboardShortcut(.cancelAction) } - case .finished: - finished(model.openEntry) - case .failed(let fileName, let message): - header(fileName) - Label(message, systemImage: "exclamationmark.triangle") - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - buttons { Button("OK") { model.dismiss() }.keyboardShortcut(.defaultAction) } - } - } - .padding(20) - // Within the window's width — a sheet wider than its window looks - // detached; the chips wrap to a third row instead. - .frame(width: MainWindow.contentWidth - 40) - // Esc closes the sheet in every state, not only while Cancel is showing. - .onExitCommand { model.dismiss() } - .onChange(of: model.state, initial: true) { if model.state != .idle { lastShown = model.state } } - // The spinner's replacement is silent otherwise. - .onChange(of: model.state) { _, state in - switch state { - case .finished: AccessibilityNotification.Announcement("Transcript ready").post() - case .failed(_, let message): AccessibilityNotification.Announcement(message).post() - case .recording: AccessibilityNotification.Announcement("Recording").post() - case .idle, .transcribing, .finishingRecording: break - } - } - .deleteConfirmation(for: $pendingDelete, model: model, closesSheet: true) - // "Copied" belongs to the text that was copied, not the next facet's. - .onChange(of: model.selection) { copied = false } - } - - /// The open entry's transcript, its chips and actions. - @ViewBuilder - private func finished(_ entry: SavedTranscript?) -> some View { - HStack { - // The saved entry's title, which becomes the LLM's once it lands. - header(entry?.title ?? "") - Spacer() - // Plays what's showing: Full's own audio, or a task result read aloud. - if model.canPlayShown { - let playing = model.isPlayingShown - Button { - model.togglePlayShown() - } label: { - Label(playing ? "Stop" : "Play", systemImage: playing ? "stop.fill" : "play.fill") - } - .controlSize(.small) - .help( - model.selection == .full && entry?.kind != .dictation - ? "Play the audio" - : "Read \(model.selection == .full ? "it" : model.selection.title.lowercased()) aloud") - } - // Where the audio lives, a click from Finder. - if let url = entry?.audioURL { - Button { - NSWorkspace.shared.activateFileViewerSelecting([url]) - } label: { - Label("Show in Finder", systemImage: "folder") - .labelStyle(.iconOnly) - } - .buttonStyle(.borderless) - .disabled(!FileManager.default.fileExists(atPath: url.path)) - .help("Show in Finder") - } - } - TranscriptFacetPicker(model: model) - facetContent - HStack { - if let entry { - Button("Delete…", role: .destructive) { pendingDelete = entry } - } - Spacer() - Button(copied ? "Copied" : "Copy") { model.selectedText.map(copy) } - .disabled(model.selectedText == nil) - Button("Done") { model.dismiss() } - .keyboardShortcut(.defaultAction) - } - } - - /// The selected facet's text area: the text, or its progress, or why it - /// failed with a way to try again. - @ViewBuilder - private var facetContent: some View { - let content = model.contents[model.selection] - Group { - switch content { - case .ready(let text): - ScrollView { - Group { - // A transcript's Full tab, with speakers told apart, shows as - // coloured turns (parsed once, `fullTurns`); everything else is - // prose, shown as it is. - if model.selection == .full, !model.fullTurns.isEmpty { - SpeakerTurnsView(turns: model.fullTurns) - } else { - Text(text).textSelection(.enabled) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(8) - } - case .failed(let message): - VStack(spacing: 8) { - Label(message, systemImage: "exclamationmark.triangle") - .foregroundStyle(.secondary) - Button("Try Again") { model.retry() } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() - case .loading: - ProgressLine(model.selection.progressTitle).frame(maxWidth: .infinity, maxHeight: .infinity) - case nil: - // Custom before a request: the popover is asking for one. - Text("Describe what to do with the transcript.") - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - .textAreaSurface() - } - - private func header(_ fileName: String) -> some View { - Label(fileName, systemImage: "waveform") - .font(.headline) - .lineLimit(1) - .truncationMode(.middle) - } - - private func buttons(@ViewBuilder _ content: () -> some View) -> some View { - HStack { - Spacer() - content() - } - } - - private func copy(_ transcript: String) { - NSPasteboard.copyAnnounced(transcript) - copied = true - } -} - -/// The facet chips over the transcript: Full (the transcript itself, so the -/// original is always one click back), the stock transforms, and Custom, which -/// asks for its request in a popover. Capsules that wrap onto a second row; -/// the selected one takes the accent. A row of toggles in a single-choice -/// group, so each reads to VoiceOver as a button with the selected trait. -private struct TranscriptFacetPicker: View { - var model: TranscriptsModel - @State private var isEditingCustom = false - @State private var customDraft = "" - - var body: some View { - FlowLayout(spacing: 6) { - ForEach(model.availableFacets, id: \.self) { facet in - // Only Custom asks for anything, so only it carries the popover. - if facet == .custom { - chip(facet) - .popover(isPresented: $isEditingCustom, arrowEdge: .bottom) { customEditor } - } else { - chip(facet) - } - } - } - // One single-choice group to VoiceOver, announced before the chip it's on. - .accessibilityElement(children: .contain) - .accessibilityLabel("View") - } - - private func chip(_ facet: TranscriptFacet) -> some View { - let isSelected = model.selection == facet - return Button { - // Custom is selected only once it has a request: the first click (and - // a click on it while selected) asks for one; Cancel leaves the - // selection where it was. - if facet == .custom, model.customRequest.isEmpty || isSelected { - customDraft = model.customRequest - isEditingCustom = true - } else { - model.select(facet) - } - } label: { - HStack(spacing: 4) { - Image(systemName: facet.systemImage) - Text(facet.title) - if model.contents[facet] == .loading, !isSelected { - ProgressView().controlSize(.mini) - } - } - .padding(.horizontal, 10) - .padding(.vertical, 4) - .foregroundStyle(isSelected ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) - .background( - isSelected ? AnyShapeStyle(BlurtBrand.accent) : AnyShapeStyle(.quaternary), - in: Capsule() - ) - .contentShape(Capsule()) - } - .buttonStyle(.plain) - .accessibilityAddTraits(isSelected ? .isSelected : []) - .accessibilityIdentifier(UITestIdentifiers.facetChipPrefix + facet.rawValue) - } - - /// Custom's request, asked for where the chip is. - private var customEditor: some View { - VStack(alignment: .leading, spacing: 8) { - Text("What should Blurt do with this transcript?") - .font(.headline) - TextField( - "Custom request", text: $customDraft, prompt: Text("e.g. Write it up as a blog post"), axis: .vertical - ) - .lineLimit(2...4) - .textFieldStyle(.roundedBorder) - .onSubmit(apply) - HStack { - Spacer() - Button("Cancel", role: .cancel) { isEditingCustom = false } - .keyboardShortcut(.cancelAction) - Button("Apply", action: apply) - .keyboardShortcut(.defaultAction) - .disabled(customDraft.trimmedNonEmpty() == nil) - } - } - .padding(14) - .frame(width: 320) - } - - private func apply() { - model.applyCustom(customDraft) - isEditingCustom = false - } -} - -/// A spinner beside a word on what it's waiting for: every progress state in -/// the sheet, spelled once. -struct ProgressLine: View { - let title: String - init(_ title: String) { self.title = title } - - var body: some View { - HStack(spacing: 8) { - ProgressView().controlSize(.small) - Text(title).foregroundStyle(.secondary) - } - } -} - -extension View { - /// The sheet's read-only text area: a fixed height on the text background, - /// with an edge — the window colour would leave the text with none. - func textAreaSurface() -> some View { - frame(height: 260) - .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 6)) - .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(.separator)) - } -} diff --git a/App/Blurt/Blurt/Wizard/TranscriptsModel+Files.swift b/App/Blurt/Blurt/Wizard/TranscriptsModel+Files.swift index a2e97552..0062c8a9 100644 --- a/App/Blurt/Blurt/Wizard/TranscriptsModel+Files.swift +++ b/App/Blurt/Blurt/Wizard/TranscriptsModel+Files.swift @@ -2,20 +2,31 @@ import BlurtEngine import Foundation import UniformTypeIdentifiers -// The model's file naming and file-type rules, and the refusal of a drop that +// The model's file naming and file-type rules, and the refusal of a file that // breaks them, split from TranscriptsModel.swift for the file-length budget. extension TranscriptsModel { - /// Whether a dropped URL is worth trying: a file whose type is audio or - /// video. The service is the real judge — this only keeps a dropped folder - /// or document from opening the sheet just to fail. + /// Transcribes the first audio or video file among `urls` — dropped on the + /// window or the Dock icon, or opened from Finder's Open With — or says why + /// none was. Never over a recording: it owns the sheet until it's saved. + func openFiles(_ urls: [URL]) { + guard !isRecordingBusy else { return } + guard let url = urls.first(where: Self.accepts) else { + refuse(urls.first) + return + } + transcribe(url) + } + + /// Whether a URL is worth trying: a file whose type is audio or video. The + /// service is the real judge — this only keeps a folder or document from + /// opening the sheet just to fail. static func accepts(_ url: URL) -> Bool { guard url.isFileURL, let type = UTType(filenameExtension: url.pathExtension) else { return false } return type.conforms(to: .audiovisualContent) } - /// Shows why a drop that held no audio or video file wasn't transcribed. - func refuse(_ url: URL?) { - guard !isRecordingBusy else { return } + /// Shows why files that held no audio or video weren't transcribed. + private func refuse(_ url: URL?) { reset() state = .failed( fileName: url?.lastPathComponent ?? "Dropped item", diff --git a/App/Blurt/Blurt/Wizard/TranscriptsModel+Playback.swift b/App/Blurt/Blurt/Wizard/TranscriptsModel+Playback.swift index 42ef6b80..ab0c5d6e 100644 --- a/App/Blurt/Blurt/Wizard/TranscriptsModel+Playback.swift +++ b/App/Blurt/Blurt/Wizard/TranscriptsModel+Playback.swift @@ -23,7 +23,7 @@ extension TranscriptsModel { } /// Full, on an entry that has audio of its own: Play plays that, not a reading. - private var showsOriginalAudio: Bool { + var showsOriginalAudio: Bool { selection == .full && openEntry?.hasAudio == true } @@ -35,6 +35,12 @@ extension TranscriptsModel { return readingFacet == selection } + /// The showing content is being read aloud, but its speech is still being + /// generated — nothing has played yet. + var isPreparingShown: Bool { + !showsOriginalAudio && readingFacet == selection && !readingStarted + } + /// Plays (or stops) what's showing. func togglePlayShown() { if showsOriginalAudio { @@ -51,8 +57,16 @@ extension TranscriptsModel { player.stop() let facet = selection readingFacet = facet + readingStarted = false + // Heard off the main actor, when the first audio plays. + let started: @Sendable () -> Void = { [weak self] in + Task { @MainActor [weak self] in + guard let self, self.readingFacet == facet else { return } + self.readingStarted = true + } + } readingTask = Task { [weak self, reader] in - try? await reader.read(text) + try? await reader.read(text, onStart: started) guard !Task.isCancelled, let self, readingFacet == facet else { return } readingFacet = nil } @@ -64,5 +78,6 @@ extension TranscriptsModel { readingTask?.cancel() readingTask = nil readingFacet = nil + readingStarted = false } } diff --git a/App/Blurt/Blurt/Wizard/TranscriptsModel.swift b/App/Blurt/Blurt/Wizard/TranscriptsModel.swift index c0b0414d..c9ae72d4 100644 --- a/App/Blurt/Blurt/Wizard/TranscriptsModel.swift +++ b/App/Blurt/Blurt/Wizard/TranscriptsModel.swift @@ -65,6 +65,8 @@ final class TranscriptsModel { let reader = TextReader() /// The task result being read aloud now, if any. var readingFacet: TranscriptFacet? + /// Whether that reading's first word has played; before, it's preparing. + var readingStarted = false @ObservationIgnored var readingTask: Task? /// Meetings' switch, policy, probe loop and last answer (`+Meetings.swift`). @ObservationIgnored var autoRecordMeetings = false diff --git a/App/Blurt/BlurtUITests/BlurtUITestSupport.swift b/App/Blurt/BlurtUITests/BlurtUITestSupport.swift index 48bc4b79..df46dd70 100644 --- a/App/Blurt/BlurtUITests/BlurtUITestSupport.swift +++ b/App/Blurt/BlurtUITests/BlurtUITestSupport.swift @@ -52,22 +52,23 @@ class BlurtUITestCase: XCTestCase { /// General pane. Production restores the last-used pane, per the macOS HIG; /// tests explicitly select General here so each case starts deterministically. /// The command is app-global, so it works regardless of which window has - /// focus. Unlike the harness, Settings opens frontmost via ⌘,, so its + /// focus, and it's sent even when Settings is already open: that raises it + /// over the harness a test clicked since, rather than driving its toolbar + /// from behind. Unlike the harness, Settings opens frontmost via ⌘,, so its /// controls are hittable without closing the other windows. @discardableResult func openSettingsWindow(timeout: TimeInterval = 10) -> XCUIElement { let paneTitles = [ UITestIdentifiers.generalSettingsTab, UITestIdentifiers.keysSettingsTab, + UITestIdentifiers.soundsSettingsTab, UITestIdentifiers.readAloudSettingsTab, UITestIdentifiers.stylesSettingsTab, UITestIdentifiers.vocabularySettingsTab, UITestIdentifiers.advancedSettingsTab, ] let settings = app.windows.matching(NSPredicate(format: "title IN %@", paneTitles)).firstMatch - if !settings.exists { - app.typeKey(",", modifierFlags: .command) - } + app.typeKey(",", modifierFlags: .command) XCTAssertTrue( settings.waitForExistence(timeout: timeout), "Settings window did not open after ⌘,") @@ -77,21 +78,28 @@ class BlurtUITestCase: XCTestCase { /// Selects a Settings pane (a `TabView` tab in the preferences toolbar) by its /// visible name. macOS exposes a preference tab as a radio button or a plain - /// button depending on the OS build, so try the radio group first and fall - /// back to a button. Returns a fresh proxy for the settings window: selecting - /// a pane retitles the window to the pane's name, so a proxy captured before - /// the switch (e.g. `openSettingsWindow()`'s) goes stale — scope follow-up - /// queries to the returned one. + /// button depending on the OS build (a button on macOS 26), so one query + /// takes either: waiting out a radio that never comes left the button only + /// a few seconds, too few on a loaded CI runner. Returns a fresh proxy for + /// the settings window: selecting a pane retitles the window to the pane's + /// name, so a proxy captured before the switch (e.g. `openSettingsWindow()`'s) + /// goes stale — scope follow-up queries to the returned one. @discardableResult func selectSettingsTab(_ window: XCUIElement, named name: String) -> XCUIElement { - let radio = window.radioButtons[name] - if radio.waitForExistence(timeout: 5) { - radio.click() - } else { - let button = window.buttons[name] - XCTAssertTrue(button.waitForExistence(timeout: 3), "Settings tab '\(name)' not found") - button.click() + let tab = window.toolbars.firstMatch.descendants(matching: .any) + .matching( + NSPredicate( + format: "(elementType == %d OR elementType == %d) AND title == %@", + XCUIElement.ElementType.radioButton.rawValue, XCUIElement.ElementType.button.rawValue, name) + ) + .firstMatch + guard tab.waitForExistence(timeout: 10) else { + // What the window held instead, so a CI failure says why without the + // .xcresult. + XCTFail("Settings tab '\(name)' not found in:\n\(window.debugDescription)") + return app.windows[name] } + tab.click() return app.windows[name] } diff --git a/App/Blurt/BlurtUITests/KeysAndReadAloudUITests.swift b/App/Blurt/BlurtUITests/KeysAndReadAloudUITests.swift index 7996617c..610417cc 100644 --- a/App/Blurt/BlurtUITests/KeysAndReadAloudUITests.swift +++ b/App/Blurt/BlurtUITests/KeysAndReadAloudUITests.swift @@ -80,8 +80,8 @@ final class KeysAndReadAloudUITests: BlurtUITestCase { // MARK: - Styles - /// A style made in Settings shows up in the Keys tab's menu. The bound key - /// dictates with the main window's style; a key set to "No custom style" + /// A style made in Settings shows up as a Keys tab row. The bound key + /// dictates with the main window's style; the key given "No custom style" /// sends none. func testEachKeyDictatesWithItsOwnStyle() { let harness = readyHarness() @@ -90,11 +90,12 @@ final class KeysAndReadAloudUITests: BlurtUITestCase { // The window's title follows its tab, so find it afresh before switching. let keys = selectSettingsTab(openSettingsWindow(), named: UITestIdentifiers.keysSettingsTab) - let option = keys.popUpButtons[UITestIdentifiers.keyStylePicker(61)] - XCTAssertTrue(option.waitForExistence(timeout: 5), "Right ⌥'s action pop-up not found") - option.click() - XCTAssertTrue(option.menuItems["Dictate: Casual"].waitForExistence(timeout: 5), "The new style should be offered") - option.menuItems["Dictate: No custom style"].click() + XCTAssertTrue( + keys.popUpButtons[UITestIdentifiers.actionKeyPicker("Dictate: Casual")].waitForExistence(timeout: 5), + "The new style should have a row") + let plain = keys.popUpButtons[UITestIdentifiers.actionKeyPicker("Dictate: No custom style")] + plain.click() + plain.menuItems["Right Option"].click() harness.click() pick("right ⌘", in: harness) diff --git a/App/Blurt/BlurtUITests/KeysLiveUpdateUITests.swift b/App/Blurt/BlurtUITests/KeysLiveUpdateUITests.swift index 48bf63b0..b3c91a8d 100644 --- a/App/Blurt/BlurtUITests/KeysLiveUpdateUITests.swift +++ b/App/Blurt/BlurtUITests/KeysLiveUpdateUITests.swift @@ -34,8 +34,8 @@ final class KeysLiveUpdateUITests: BlurtUITestCase { let statusItem = app.menuBars.statusItems.firstMatch XCTAssertTrue(statusItem.waitForExistence(timeout: 15)) statusItem.click() - let hint = app.menuItems.matching(NSPredicate(format: "title CONTAINS %@", "right ⌥ to dictate")).firstMatch - XCTAssertTrue(hint.waitForExistence(timeout: 5), "The menu bar's reminder should name right ⌥") + let hint = app.menuItems.matching(NSPredicate(format: "title CONTAINS %@", "Right Option to dictate")).firstMatch + XCTAssertTrue(hint.waitForExistence(timeout: 5), "The menu bar's reminder should name Right Option") app.typeKey(.escape, modifierFlags: []) } } diff --git a/App/Blurt/BlurtUITests/ReadAloudSettingsUITests.swift b/App/Blurt/BlurtUITests/ReadAloudSettingsUITests.swift index b7a3fcab..a2c2fa8a 100644 --- a/App/Blurt/BlurtUITests/ReadAloudSettingsUITests.swift +++ b/App/Blurt/BlurtUITests/ReadAloudSettingsUITests.swift @@ -1,8 +1,8 @@ import XCTest /// The Read Aloud tab and read-aloud's own sound: each setting persists, the -/// controls follow whether any key reads aloud, the privacy notes follow what -/// the keys do, and the voice and speed picked are the ones a read uses. +/// controls follow whether any key reads aloud, and the voice and speed picked +/// are the ones a read uses. final class ReadAloudSettingsUITests: BlurtUITestCase { private func readAloudTab() -> XCUIElement { selectSettingsTab(openSettingsWindow(), named: UITestIdentifiers.readAloudSettingsTab) @@ -17,12 +17,13 @@ final class ReadAloudSettingsUITests: BlurtUITestCase { // MARK: - U1: the read-aloud sound - /// Read-aloud's sound starts on the Juno's Brass and is its own setting: - /// changing it leaves dictation's alone, and it sticks. + /// The read-aloud key's sound starts on the Juno's Brass and is its own + /// setting: changing it leaves the dictating key's alone, and it sticks. func testReadAloudSoundIsItsOwnSetting() { - let general = openSettingsWindow() - let read = general.popUpButtons[UITestIdentifiers.readAloudSoundPicker] - let dictation = general.popUpButtons[UITestIdentifiers.soundPicker] + let sounds = selectSettingsTab(openSettingsWindow(), named: UITestIdentifiers.soundsSettingsTab) + // Left ⌥ (58) reads aloud and right ⌘ (54) dictates on an untouched install. + let read = sounds.popUpButtons[UITestIdentifiers.keySoundPicker(58)] + let dictation = sounds.popUpButtons[UITestIdentifiers.keySoundPicker(54)] XCTAssertTrue(read.waitForExistence(timeout: 5)) XCTAssertEqual(read.value as? String, "Brass") XCTAssertEqual(dictation.value as? String, "Orchestra") @@ -35,9 +36,11 @@ final class ReadAloudSettingsUITests: BlurtUITestCase { XCTAssertEqual(read.value as? String, "Clav 1", "…nor dictation's change read-aloud's") let reopened = selectSettingsTab( - selectSettingsTab(general, named: UITestIdentifiers.readAloudSettingsTab), - named: UITestIdentifiers.generalSettingsTab) - XCTAssertEqual(reopened.popUpButtons[UITestIdentifiers.readAloudSoundPicker].value as? String, "Clav 1") + selectSettingsTab(sounds, named: UITestIdentifiers.readAloudSettingsTab), + named: UITestIdentifiers.soundsSettingsTab) + XCTAssertEqual( + reopened.popUpButtons[UITestIdentifiers.keySoundPicker(58)].value as? String, + "Clav 1") } // MARK: - U2: the Read Aloud tab @@ -56,9 +59,9 @@ final class ReadAloudSettingsUITests: BlurtUITestCase { XCTAssertEqual(reopened.popUpButtons[UITestIdentifiers.readAloudSpeedPicker].value as? String, "1.5×") } - /// With no key reading aloud or asking, the controls are disabled and the tab - /// says how to turn read-aloud on; a key that asks adds the gateway note. - func testControlsAndNotesFollowTheKeys() { + /// With no key reading aloud or asking, the controls are disabled; a key + /// that asks enables them again. + func testControlsFollowTheKeys() { let harness = readyHarness() bind("left ⌥", with: UITestIdentifiers.harnessSetOff, in: harness) @@ -66,18 +69,12 @@ final class ReadAloudSettingsUITests: BlurtUITestCase { let voice = tab.popUpButtons[UITestIdentifiers.readAloudVoicePicker] XCTAssertTrue(voice.waitForExistence(timeout: 5)) XCTAssertFalse(voice.isEnabled, "With no read or ask key, the voice can't be picked") - let hint = "to enable playback" - let assign = NSPredicate(format: "value CONTAINS %@ OR label CONTAINS %@", hint, hint) - XCTAssertTrue(tab.staticTexts.matching(assign).firstMatch.exists, "The tab should say how to turn it on") harness.click() bind("left ⌃", with: UITestIdentifiers.harnessSetAsk, in: harness) - let tabAgain = readAloudTab() - let gateway = NSPredicate(format: "value CONTAINS %@ OR label CONTAINS %@", "spoken request", "spoken request") - XCTAssertTrue( - tabAgain.staticTexts.matching(gateway).firstMatch.waitForExistence(timeout: 5), - "A key that asks should add the LLM Gateway note") - XCTAssertTrue(tabAgain.popUpButtons[UITestIdentifiers.readAloudVoicePicker].isEnabled) + let voiceAgain = readAloudTab().popUpButtons[UITestIdentifiers.readAloudVoicePicker] + XCTAssertTrue(voiceAgain.waitForExistence(timeout: 5)) + XCTAssertTrue(voiceAgain.isEnabled, "A key that asks should enable the voice") } /// The voice and speed picked are the ones the next read speaks with. diff --git a/App/Blurt/BlurtUITests/ReadyViewUITests.swift b/App/Blurt/BlurtUITests/ReadyViewUITests.swift index 5d1b9678..d479ba66 100644 --- a/App/Blurt/BlurtUITests/ReadyViewUITests.swift +++ b/App/Blurt/BlurtUITests/ReadyViewUITests.swift @@ -29,27 +29,19 @@ final class ReadyViewUITests: BlurtUITestCase { app.descendants(matching: .any).matching(readPredicate).firstMatch.exists, "Ready screen should list the read-aloud key") - // The style row is always present. With no custom styles there isn't a real - // choice, so it reads Default without drawing a one-item pop-up. + // With no custom styles there's no choice to make, so there's no style row. let main = app.windows[UITestIdentifiers.mainWindowTitle] - XCTAssertTrue(main.staticTexts["Default"].exists, "The style row should show Default") - XCTAssertFalse( - main.popUpButtons[UITestIdentifiers.styleProfilePickerFromMain].exists, - "The style row should not offer a pop-up until there is another choice") - // The Library, empty on a fresh launch, shows its header and the - // first-dictation prompt that fills the reserved list area. - XCTAssertTrue( - app.staticTexts["Library"].exists, - "Ready screen should have a Library section") + XCTAssertFalse(main.staticTexts["Writing style"].exists, "No style row without a custom style") + XCTAssertFalse(main.popUpButtons[UITestIdentifiers.styleProfilePickerFromMain].exists) + // The Library, empty on a fresh launch, shows the first-dictation prompt + // that fills the reserved list area. XCTAssertTrue( emptyRecentPrompt(in: main).exists, "An empty Library should prompt the first dictation") - // The Settings button at the window's foot — the main window's own route - // to the Settings scene, alongside ⌘, and the menu-bar item. - XCTAssertTrue( - main.buttons["Settings…"].exists, - "Ready screen should offer its Settings button") + // The toolbar's Settings button — the main window's own route to the + // Settings scene, alongside ⌘, and the menu-bar item. + XCTAssertTrue(main.buttons["Settings"].exists, "Ready screen should offer its Settings button") } func testCompletedDictationPopulatesRecentList() { @@ -89,11 +81,10 @@ final class ReadyViewUITests: BlurtUITestCase { pasteboard.clearContents() pasteboard.setString("sentinel-before-copy", forType: .string) - // Select the row, then right-click it for its "Copy" contextual menu item. - // Selecting first keeps the assertion honest whichever "Copy" the query - // lands on: the context menu copies the row it was opened on, and Edit › - // Copy (which `app.menus` can also match) copies the selected row — and - // nothing without a selection, which a right-click doesn't make. + // Select the card, then right-click it for its "Copy" contextual menu + // item. Selecting first keeps the assertion honest whichever "Copy" the + // query lands on: the context menu copies the card it was opened on, and + // Edit › Copy copies the selected one. row.click() row.rightClick() let copyItem = app.menus.menuItems["Copy"].firstMatch @@ -149,7 +140,7 @@ final class ReadyViewUITests: BlurtUITestCase { XCTAssertTrue( picker.waitForNonExistence(timeout: 5), "Deleting the final custom style should remove the ready-window pop-up immediately") - XCTAssertTrue(main.staticTexts["Default"].exists, "The style row should fall back to Default") + XCTAssertFalse(main.staticTexts["Writing style"].exists, "…and the style row with it") } // MARK: - Shared choreography @@ -190,6 +181,6 @@ final class ReadyViewUITests: BlurtUITestCase { private func recentRow(in main: XCUIElement) -> XCUIElement { let text = UITestIdentifiers.defaultCannedTranscript let rowPredicate = NSPredicate(format: "label CONTAINS %@ OR value CONTAINS %@", text, text) - return main.outlines[UITestIdentifiers.recentList].staticTexts.matching(rowPredicate).firstMatch + return main.descendants(matching: .any).matching(rowPredicate).firstMatch } } diff --git a/App/Blurt/BlurtUITests/SettingsUITests.swift b/App/Blurt/BlurtUITests/SettingsUITests.swift index 5508a3ea..cfe71bdf 100644 --- a/App/Blurt/BlurtUITests/SettingsUITests.swift +++ b/App/Blurt/BlurtUITests/SettingsUITests.swift @@ -62,20 +62,20 @@ final class SettingsUITests: BlurtUITestCase { "Show key should expose a plain (non-secure) text field") } - /// Taking the bound key off dictation moves the binding to the other dictating - /// key: turn right ⌥ on, set right ⌘ to read aloud, and both stick. + /// Dictation moves to another key, and the key it left can take another + /// action: dictate with right ⌥, read aloud with right ⌘, and both stick. func testDictationMovesToAnotherKey() { let keys = openKeys() - let option = keys.popUpButtons[UITestIdentifiers.keyStylePicker(rightOption)] - let command = keys.popUpButtons[UITestIdentifiers.keyStylePicker(rightCommand)] + let dictate = keys.popUpButtons[UITestIdentifiers.actionKeyPicker("Dictate")] + let read = keys.popUpButtons[UITestIdentifiers.actionKeyPicker(KeyActionName.readAloud)] - option.click() - app.menuItems["Dictate"].click() - command.click() - app.menuItems[KeyActionName.readAloud].click() + dictate.click() + app.menuItems["Right Option"].click() + read.click() + app.menuItems["Right Command"].click() - XCTAssertEqual(command.value as? String, KeyActionName.readAloud) - XCTAssertEqual(option.value as? String, "Dictate") + XCTAssertEqual(read.value as? String, "Right Command") + XCTAssertEqual(dictate.value as? String, "Right Option") } /// The microphone picker exists and defaults to following the system input. @@ -95,11 +95,12 @@ final class SettingsUITests: BlurtUITestCase { } /// The sound-cue picker changes the persisted selection. Selecting "None" - /// works regardless of the runner's persisted starting value. + /// works regardless of the runner's persisted starting value. The sounds + /// are their own pane, so switch to it first. func testSoundPickerChangesSelection() { - let settings = openSettingsWindow() + let settings = selectSettingsTab(openSettingsWindow(), named: UITestIdentifiers.soundsSettingsTab) - let picker = settings.popUpButtons[UITestIdentifiers.soundPicker] + let picker = settings.popUpButtons[UITestIdentifiers.keySoundPicker(54)] XCTAssertTrue(picker.waitForExistence(timeout: 10), "Sound picker not found") picker.click() app.menuItems["None"].click() @@ -238,57 +239,44 @@ final class SettingsUITests: BlurtUITestCase { // MARK: - Keys - /// The trigger keys' keycodes (`TriggerKey`'s raw values), which suffix each - /// pop-up's identifier in the Keys tab. Spelled out because this bundle - /// doesn't link the engine. - private let rightCommand = 54 - private let rightOption = 61 - private let fnKey = 63 - private let leftCommand = 55 - private let leftOption = 58 - private let leftControl = 59 - - /// The Keys tab has an action and a mode pop-up per trigger key. On a reset - /// launch right ⌘ dictates and, as the only dictating key, can't be turned - /// off; left ⌥ reads aloud on a tap or hold; the rest are off, with no mode - /// to pick. - func testKeysTabListsEveryKey() { + /// The Keys tab has a key pop-up per action, and one activation pop-up. On + /// a reset launch right ⌘ dictates and, as the only dictating key, can't be + /// cleared; left ⌥ reads aloud; nothing asks. + func testKeysTabListsEveryAction() { let keys = openKeys() - for keyCode in [rightCommand, rightOption, fnKey, leftCommand, leftOption, leftControl] { - XCTAssertTrue( - keys.popUpButtons[UITestIdentifiers.keyStylePicker(keyCode)].exists, - "The tab should list an action pop-up for keycode \(keyCode)") + let actions = [ + "Dictate", "Dictate: No custom style", KeyActionName.readAloud, KeyActionName.ask, + KeyActionName.rewrite, KeyActionName.command, + ] + for action in actions { XCTAssertTrue( - keys.popUpButtons[UITestIdentifiers.keyActivationPicker(keyCode)].exists, - "The tab should list a mode pop-up for keycode \(keyCode)") + keys.popUpButtons[UITestIdentifiers.actionKeyPicker(action)].exists, + "The tab should list a key pop-up for \(action)") } - XCTAssertEqual(keys.popUpButtons[UITestIdentifiers.keyStylePicker(rightOption)].value as? String, "Off") - XCTAssertFalse(keys.popUpButtons[UITestIdentifiers.keyActivationPicker(rightOption)].isEnabled) - XCTAssertEqual( - keys.popUpButtons[UITestIdentifiers.keyStylePicker(leftOption)].value as? String, KeyActionName.readAloud) + XCTAssertTrue(keys.popUpButtons[UITestIdentifiers.activationPicker].exists) XCTAssertEqual( - keys.popUpButtons[UITestIdentifiers.keyActivationPicker(leftOption)].value as? String, "Tap or hold") - - let bound = keys.popUpButtons[UITestIdentifiers.keyStylePicker(rightCommand)] - XCTAssertEqual(bound.value as? String, "Dictate") - bound.click() - XCTAssertTrue(bound.menuItems["Dictate"].waitForExistence(timeout: 5)) - XCTAssertFalse(bound.menuItems["Off"].exists, "The only dictating key can't be turned off") - XCTAssertFalse(bound.menuItems[KeyActionName.readAloud].exists, "…nor set to read aloud") + keys.popUpButtons[UITestIdentifiers.actionKeyPicker(KeyActionName.readAloud)].value as? String, "Left Option") + XCTAssertEqual(keys.popUpButtons[UITestIdentifiers.actionKeyPicker(KeyActionName.ask)].value as? String, "None") + + let dictate = keys.popUpButtons[UITestIdentifiers.actionKeyPicker("Dictate")] + XCTAssertEqual(dictate.value as? String, "Right Command") + dictate.click() + XCTAssertTrue(dictate.menuItems["Right Command"].waitForExistence(timeout: 5)) + XCTAssertFalse(dictate.menuItems["None"].exists, "The only dictating key can't be cleared") // Close the menu by re-choosing the current item, leaving the setting as it was. - bound.menuItems["Dictate"].click() + dictate.menuItems["Right Command"].click() } - /// A key's mode persists: the tab shows it again after another pane. - func testKeyModePersists() { - openKeys().popUpButtons[UITestIdentifiers.keyActivationPicker(leftOption)].click() + /// The activation mode persists: the tab shows it again after another pane. + func testActivationPersists() { + openKeys().popUpButtons[UITestIdentifiers.activationPicker].click() app.menuItems["Hold"].click() let settings = openSettingsWindow() let reopened = selectSettingsTab(settings, named: UITestIdentifiers.keysSettingsTab) XCTAssertEqual( - reopened.popUpButtons[UITestIdentifiers.keyActivationPicker(leftOption)].value as? String, "Hold", + reopened.popUpButtons[UITestIdentifiers.activationPicker].value as? String, "Hold", "The mode should survive leaving the tab") } diff --git a/App/Blurt/BlurtUITests/TranscriptionUITests.swift b/App/Blurt/BlurtUITests/TranscriptionUITests.swift index 82be660c..bc69d781 100644 --- a/App/Blurt/BlurtUITests/TranscriptionUITests.swift +++ b/App/Blurt/BlurtUITests/TranscriptionUITests.swift @@ -2,7 +2,7 @@ import XCTest /// The main window's transcription features: the Recent history (its filter, /// search, ⌘F, opening, deleting), the transcript sheet (speaker turns, Plain -/// text, a task on the LLM), transcribing a file, the drop well's controls, and +/// text, a task on the LLM), transcribing a file, the toolbar's Record and Browse, and /// the auto-record setting. /// /// Runs on the ready screen against a seeded, throwaway history @@ -55,14 +55,15 @@ final class TranscriptionUITests: BlurtUITestCase { XCTAssertFalse(row(titled: UITestIdentifiers.seededDictationText).exists) // Nothing matches: the system's no-results state. - let search = app.searchFields[UITestIdentifiers.recentSearchField] - XCTAssertTrue(search.exists, "The Library should have a search field") + // The window's toolbar search, the only search field in the main window. + let search = app.windows[UITestIdentifiers.mainWindowTitle].searchFields.firstMatch + XCTAssertTrue(search.exists, "The main window should have a search field") search.typeText("zzqx") XCTAssertTrue( text(containing: "No results").waitForExistence(timeout: 5), "A search with no matches should say so") } - // MARK: - Transcript sheet + // MARK: - Open item func testOpeningARecordingShowsSpeakersPlainTextAndATask() { mainWindow() @@ -71,23 +72,24 @@ final class TranscriptionUITests: BlurtUITestCase { recording.doubleClick() // Full: the turns, each under its speaker. - XCTAssertTrue(sheetText(containing: "Speaker A").waitForExistence(timeout: 5), "Full shows speaker A's turn") - XCTAssertTrue(sheetText(containing: "Speaker B").exists, "Full shows speaker B's turn") - XCTAssertTrue(sheetText(containing: UITestIdentifiers.seededRecordingSpeakerA).exists) + XCTAssertTrue(windowText(containing: "Speaker A").waitForExistence(timeout: 5), "Full shows speaker A's turn") + XCTAssertTrue(windowText(containing: "Speaker B").exists, "Full shows speaker B's turn") + XCTAssertTrue(windowText(containing: UITestIdentifiers.seededRecordingSpeakerA).exists) // Plain text: the same words, no labels. - chip("plain").click() + showView("Plain text") let plain = "\(UITestIdentifiers.seededRecordingSpeakerA) \(UITestIdentifiers.seededRecordingSpeakerB)" - XCTAssertTrue(sheetText(containing: plain).waitForExistence(timeout: 5), "Plain text shows the unlabelled words") + XCTAssertTrue(windowText(containing: plain).waitForExistence(timeout: 5), "Plain text shows the unlabelled words") // A task: the canned gateway's answer lands in the text area. - chip("meetingNotes").click() + showView("Meeting notes") XCTAssertTrue( - sheetText(containing: UITestIdentifiers.cannedTaskResult).waitForExistence(timeout: 10), + windowText(containing: UITestIdentifiers.cannedTaskResult).waitForExistence(timeout: 10), "Meeting notes shows the LLM's result") - app.windows[UITestIdentifiers.mainWindowTitle].sheets.buttons["Done"].click() - XCTAssertFalse(chip("full").waitForExistence(timeout: 2), "Done closes the sheet") + // Esc pops back to the Library, as the navigation's back button does. + app.typeKey(.escape, modifierFlags: []) + XCTAssertFalse(viewPicker.waitForExistence(timeout: 2), "Esc returns to the Library") } func testDeleteAsksThenRemovesTheEntry() { @@ -113,7 +115,7 @@ final class TranscriptionUITests: BlurtUITestCase { // MARK: - Controls and settings - func testDropWellControlsAndAutoRecordSetting() { + func testToolbarControlsAndAutoRecordSetting() { let main = mainWindow() XCTAssertTrue(main.buttons[UITestIdentifiers.transcribeRecordButton].waitForExistence(timeout: 10)) XCTAssertTrue(main.buttons[UITestIdentifiers.transcribeRecordButton].isEnabled) @@ -134,7 +136,7 @@ final class TranscriptionUITests: BlurtUITestCase { /// A Library row by its title. The row is one combined element whose text /// (", <details>") XCUITest may report as its label or its value. private func row(titled title: String) -> XCUIElement { - app.windows[UITestIdentifiers.mainWindowTitle].outlines[UITestIdentifiers.recentList].staticTexts + app.windows[UITestIdentifiers.mainWindowTitle].descendants(matching: .any) .matching(NSPredicate(format: "label BEGINSWITH %@ OR value BEGINSWITH %@", title, title)).firstMatch } @@ -146,31 +148,31 @@ final class TranscriptionUITests: BlurtUITestCase { .matching(NSPredicate(format: "label CONTAINS %@ OR value CONTAINS %@", text, text)).firstMatch } - /// Anything in the transcript sheet showing `text`. A speaker turn is one - /// combined element — a group, not static text — so any type is matched, - /// which the sheet's small tree keeps fast. - private func sheetText(containing text: String) -> XCUIElement { - app.windows[UITestIdentifiers.mainWindowTitle].sheets.firstMatch.descendants(matching: .any) + /// Anything in the app showing `text` — the open item's transcript. A speaker turn is one combined element — a group, + /// not static text — so any type is matched. + private func windowText(containing text: String) -> XCUIElement { + app.descendants(matching: .any) .matching(NSPredicate(format: "label CONTAINS %@ OR value CONTAINS %@", text, text)).firstMatch } - /// A transcript sheet chip, by its facet's raw value. - private func chip(_ facet: String) -> XCUIElement { - app.windows[UITestIdentifiers.mainWindowTitle].buttons[UITestIdentifiers.facetChipPrefix + facet].firstMatch + /// The open item's toolbar View menu. + private var viewPicker: XCUIElement { + app.menuButtons[UITestIdentifiers.facetPicker].firstMatch } - /// A segment of the Library's filter. Segmented controls surface as radio - /// buttons or plain buttons depending on the OS build. + /// Shows the view titled `title`, from the toolbar's View menu. + private func showView(_ title: String) { + XCTAssertTrue(viewPicker.waitForExistence(timeout: 5), "The open item's View menu not found") + viewPicker.click() + viewPicker.menuItems[title].click() + } + + /// A kind from the toolbar's Filter menu. private func selectFilter(_ title: String) { - let main = app.windows[UITestIdentifiers.mainWindowTitle] - let radio = main.radioButtons[title] - if radio.waitForExistence(timeout: 3) { - radio.click() - } else { - let button = main.buttons[title] - XCTAssertTrue(button.waitForExistence(timeout: 3), "Filter '\(title)' not found") - button.click() - } + let menu = app.windows[UITestIdentifiers.mainWindowTitle].menuButtons[UITestIdentifiers.libraryFilterMenu] + XCTAssertTrue(menu.waitForExistence(timeout: 3), "The Filter menu not found") + menu.click() + app.menuItems[title].click() } } @@ -184,19 +186,19 @@ final class TranscribeFileUITests: BlurtUITestCase { func testTranscribingAFileSavesItUnderItsTitle() { let main = mainWindow() - // The sheet opens on the new transcript, its turns under each speaker… + // The window slides to the new transcript, its turns under each speaker… XCTAssertTrue( - main.sheets.firstMatch.descendants(matching: .any) + app.descendants(matching: .any) .matching( NSPredicate( format: "label CONTAINS %@ OR value CONTAINS %@", UITestIdentifiers.cannedFileSpeakerA, UITestIdentifiers.cannedFileSpeakerA) ).firstMatch.waitForExistence(timeout: 15), - "The sheet opens on the new transcript") + "The window opens the new transcript") // …and the entry is saved under the LLM's title. let title = UITestIdentifiers.cannedFileTitle XCTAssertTrue( - main.outlines[UITestIdentifiers.recentList].staticTexts + main.descendants(matching: .any) .matching(NSPredicate(format: "label BEGINSWITH %@ OR value BEGINSWITH %@", title, title)).firstMatch .waitForExistence(timeout: 15), "The transcribed file is saved under the LLM's title") diff --git a/App/Blurt/Shared/UITestIdentifiers.swift b/App/Blurt/Shared/UITestIdentifiers.swift index 4f12dfd0..99d21a1b 100644 --- a/App/Blurt/Shared/UITestIdentifiers.swift +++ b/App/Blurt/Shared/UITestIdentifiers.swift @@ -22,6 +22,8 @@ enum UITestIdentifiers { // Window titles the XCUITest suite queries, sourced from the `Window(_:id:)` // declarations in `App.swift`. static let mainWindowTitle = "Blurt" + /// The main window's toolbar Filter menu, over the Library. + static let libraryFilterMenu = "main.toolbar.filter" static let harnessWindowTitle = "Blurt UI Test Harness" /// The harness `Window`'s scene id. static let harnessWindowID = "uitest.harness" @@ -32,6 +34,7 @@ enum UITestIdentifiers { // opening title (see `settingsWindowTitle` in BlurtUITestSupport). static let generalSettingsTab = "General" static let keysSettingsTab = "Keys" + static let soundsSettingsTab = "Sounds" static let readAloudSettingsTab = "Read Aloud" static let stylesSettingsTab = "Styles" static let advancedSettingsTab = "Advanced" @@ -77,13 +80,14 @@ enum UITestIdentifiers { static let apiKeyCancel = "settings.apiKey.cancel" static let apiKeyError = "settings.apiKey.error" static let keyTermsField = "settings.keyTerms.field" - /// One pair per trigger key on the Keys tab, suffixed with its keycode. - static func keyStylePicker(_ keyCode: Int) -> String { "settings.keyStyles.picker.\(keyCode)" } - static func keyActivationPicker(_ keyCode: Int) -> String { "settings.keyStyles.activation.\(keyCode)" } + /// One per action row on the Keys tab — its key pop-up — suffixed with the + /// row's title ("Dictate", "Read Selection Aloud", "Dictate: Casual"). + static func actionKeyPicker(_ action: String) -> String { "settings.actionKey.picker.\(action)" } + /// The one tap/hold mode for every key, under the Keys tab's table. + static let activationPicker = "settings.activation.picker" static let micPicker = "settings.mic.picker" - static let soundPicker = "settings.sound.picker" - static let readAloudSoundPicker = "settings.readAloudSound.picker" - static let commandSoundPicker = "settings.commandSound.picker" + /// One per trigger key that does something, on the Sounds tab, suffixed with its keycode. + static func keySoundPicker(_ keyCode: Int) -> String { "settings.keySound.picker.\(keyCode)" } static let harnessSetCommand = "harness.key.setCommand" static let harnessSetRewrite = "harness.key.setRewrite" /// What the stub planner was asked to plan, and what the stub performer did. @@ -180,10 +184,10 @@ enum UITestIdentifiers { /// What the canned gateway returns for any task. static let cannedTaskResult = "Canned task result for the transcript." static let recentList = "recent.list" - static let recentSearchField = "recent.search" static let transcribeRecordButton = "transcribe.record" static let transcribeBrowseButton = "transcribe.browse" static let autoRecordToggle = "settings.autoRecordMeetings" /// A transcript sheet chip: this prefix, then the facet's raw value. - static let facetChipPrefix = "facet." + /// The open item's toolbar View menu. + static let facetPicker = "facet.picker" } diff --git a/App/Blurt/project.yml b/App/Blurt/project.yml index 7af92a37..7200d5f4 100644 --- a/App/Blurt/project.yml +++ b/App/Blurt/project.yml @@ -23,10 +23,6 @@ settings: base: SWIFT_VERSION: "6.0" MACOSX_DEPLOYMENT_TARGET: "15.0" - # A package access identifier, as the iPhone project sets one: the shared - # `App/Shared` sources declare `package` API for the iPhone's modules, and - # `package` won't compile in a target without a package name. - SWIFT_PACKAGE_NAME: Blurt ARCHS: "$(ARCHS_STANDARD)" CODE_SIGN_STYLE: Manual CODE_SIGN_IDENTITY: "602F699488189767137DF15633B967B1371ACD86" @@ -155,8 +151,6 @@ targets: # Shared with BlurtUITests: the single source of truth for UI-test # identifiers/sentinels (see Shared/UITestIdentifiers.swift). - path: Shared - # Shared with the iPhone app's BlurtDesign; see the file. - - path: ../Shared # The brand assets both apps ship (Design/brand), so neither app's folder # is a dependency of the other. - path: ../../Design/brand/AppIcon.icon @@ -173,6 +167,16 @@ targets: CFBundleShortVersionString: "0.1.68" LSMinimumSystemVersion: "15.0" LSApplicationCategoryType: public.app-category.productivity + # Audio and video files open in Blurt to be transcribed — dropped on + # the Dock icon, or from Finder's Open With (`application(_:open:)`) — + # as an alternate handler, so Blurt never becomes the default app for + # a file type. + CFBundleDocumentTypes: + - CFBundleTypeName: Audio or Video File + CFBundleTypeRole: Viewer + LSHandlerRank: Alternate + LSItemContentTypes: + - public.audiovisual-content NSMicrophoneUsageDescription: Blurt uses your microphone to transcribe your dictation with AssemblyAI. # The main window's Record button: system audio beside the microphone. NSAudioCaptureUsageDescription: Blurt records your Mac's audio alongside your microphone so it can transcribe meetings and calls with AssemblyAI. diff --git a/App/Shared/FlowLayout.swift b/App/Shared/FlowLayout.swift index 2e59e5bf..189d2317 100644 --- a/App/Shared/FlowLayout.swift +++ b/App/Shared/FlowLayout.swift @@ -1,10 +1,9 @@ import SwiftUI /// Lays its children out left to right, wrapping onto a new line when the next -/// one won't fit — the iPhone's key-term pills and the Mac transcript sheet's -/// facet chips. Compiled into both apps (the Mac target and the iPhone's -/// `BlurtDesign`) from this one file, since no stock container does it and two -/// copies would drift; each side supplies its own spacing. +/// one won't fit — the iPhone's key-term pills. Compiled into the iPhone's +/// `BlurtDesign` from here, outside any one target, since no stock container +/// does it; it supplies its own spacing. package struct FlowLayout: Layout { var spacing: CGFloat diff --git a/Sources/BlurtEngine/Config/DefaultsKey.swift b/Sources/BlurtEngine/Config/DefaultsKey.swift index 07cd6494..f9e1afdd 100644 --- a/Sources/BlurtEngine/Config/DefaultsKey.swift +++ b/Sources/BlurtEngine/Config/DefaultsKey.swift @@ -30,8 +30,8 @@ enum DefaultsKey: String, CaseIterable { /// What each trigger key does — dictate with a style, read aloud, ask, or /// nothing (`TriggerKeyStyleStore`). The raw value predates read and ask. case triggerKeyStyles = "TriggerKeyStyles" - /// Each trigger key's tap/hold mode (`TriggerKeyActivationStore`). - case triggerKeyActivations = "TriggerKeyActivations" + /// Each trigger key's own start/stop cue voice (`TriggerKeySoundStore`). + case triggerKeySounds = "TriggerKeySounds" case soundPack = "SoundPack" /// Read-aloud's start/stop cue voice (`SoundPackStore`, `.readAloud`). case readAloudSoundPack = "ReadAloudSoundPack" diff --git a/Sources/BlurtEngine/Hotkey/SelectionSpeechRouter.swift b/Sources/BlurtEngine/Hotkey/SelectionSpeechRouter.swift index 4382cc49..24fde015 100644 --- a/Sources/BlurtEngine/Hotkey/SelectionSpeechRouter.swift +++ b/Sources/BlurtEngine/Hotkey/SelectionSpeechRouter.swift @@ -109,7 +109,7 @@ import Foundation public func press(key: TriggerKey) { let bindings = TriggerKeyBindings(defaults: defaults) let action = bindings.action(for: key) - let holds = bindings.activation(for: key) != .tap + let holds = bindings.activation != .tap perform(routing.press(key: key, action: action, holds: holds)) } diff --git a/Sources/BlurtEngine/Hotkey/TriggerActivation.swift b/Sources/BlurtEngine/Hotkey/TriggerActivation.swift index ed018e2c..8fb9f959 100644 --- a/Sources/BlurtEngine/Hotkey/TriggerActivation.swift +++ b/Sources/BlurtEngine/Hotkey/TriggerActivation.swift @@ -62,18 +62,6 @@ public enum TriggerActivation: String, CaseIterable, Sendable { } } - /// How to finish a dictation that is recording, e.g. "Tap again or release - /// to finish." — only the gestures this mode actually stops on. - public var finishHint: String { "\(stopGesture) to finish." } - - private var stopGesture: String { - switch self { - case .tapOrHold: "Tap again or release" - case .tap: "Tap again" - case .hold: "Release" - } - } - /// Whether releasing the trigger latches the recording on (tap-to-toggle) /// rather than stopping it, given how long this press was held. Only presses /// that *started* the recording ask — a release over an already-latched diff --git a/Sources/BlurtEngine/Hotkey/TriggerActivationStore.swift b/Sources/BlurtEngine/Hotkey/TriggerActivationStore.swift index 55b5265f..c375b55e 100644 --- a/Sources/BlurtEngine/Hotkey/TriggerActivationStore.swift +++ b/Sources/BlurtEngine/Hotkey/TriggerActivationStore.swift @@ -1,10 +1,21 @@ -/// The shared tap/hold mode's slot: the one setting there was before each key -/// had its own (`TriggerKeyActivationStore`), and still the mode of any key -/// without one. Read through `TriggerKeyBindings`, which decodes it with -/// `TriggerActivation.fromPersisted` (unset or unknown is tap-or-hold); no -/// screen writes it any more, but an existing install's value is honoured. +import Foundation + +/// The one tap/hold mode every trigger key is pressed under, as set on the +/// Keys tab. Read through `TriggerKeyBindings`, which decodes it with +/// `TriggerActivation.fromPersisted` (unset or unknown is tap-or-hold); this +/// is the one write. public struct TriggerActivationStore { - /// UserDefaults key holding the shared mode. Public so SwiftUI views can - /// observe it directly (e.g. `@AppStorage`) and re-render on change. + /// UserDefaults key holding the mode. Public so SwiftUI views can observe it + /// directly (e.g. `@AppStorage`) and re-render on change. public static var defaultsKey: String { DefaultsKey.triggerActivation.key } + + private let defaults: UserDefaults + + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + public func setActivation(_ activation: TriggerActivation) { + defaults.set(activation.rawValue, forKey: Self.defaultsKey) + } } diff --git a/Sources/BlurtEngine/Hotkey/TriggerKeyActivationStore.swift b/Sources/BlurtEngine/Hotkey/TriggerKeyActivationStore.swift deleted file mode 100644 index cbf56ce9..00000000 --- a/Sources/BlurtEngine/Hotkey/TriggerKeyActivationStore.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Foundation - -/// Persists whether each trigger key is tapped, held, or either — the Keys -/// tab's second pop-up — as keycode → `TriggerActivation` raw value -/// (`TriggerKeyMap`), the bound key's included. Read through -/// `TriggerKeyBindings`: a key with no entry follows `TriggerActivationStore`'s -/// shared mode (the one setting there was before keys had their own), so an -/// existing install reads unchanged, and moving the binding moves nothing. -public struct TriggerKeyActivationStore { - /// Public so SwiftUI views can observe it directly (e.g. `@AppStorage`). - public static var defaultsKey: String { DefaultsKey.triggerKeyActivations.key } - - private let defaults: UserDefaults - - public init(defaults: UserDefaults = .standard) { - self.defaults = defaults - } - - /// Sets `key`'s mode. - public func setActivation(_ activation: TriggerActivation, for key: TriggerKey) { - var entries = TriggerKeyMap.decode(defaults.string(forKey: Self.defaultsKey) ?? "") - entries[key] = activation.rawValue - TriggerKeyMap.write(entries, to: Self.defaultsKey, in: defaults) - } -} diff --git a/Sources/BlurtEngine/Hotkey/TriggerKeyBindings.swift b/Sources/BlurtEngine/Hotkey/TriggerKeyBindings.swift index ef3067b7..2477bc64 100644 --- a/Sources/BlurtEngine/Hotkey/TriggerKeyBindings.swift +++ b/Sources/BlurtEngine/Hotkey/TriggerKeyBindings.swift @@ -74,35 +74,31 @@ public enum TriggerKeyChoice: Hashable, Sendable { } /// One read of everything the trigger keys are set to: the bound key -/// (`TriggerKeyStore`), each key's choice (`TriggerKeyStyleStore`) and each -/// key's tap/hold mode (`TriggerKeyActivationStore`, with -/// `TriggerActivationStore` as the mode of a key that has none). Every reader — -/// the tap, the read-aloud router, the press's style, and each view — asks -/// this, so the rules are written once: the bound key always dictates; with no -/// entry, the bound key dictates with the main window's style, left ⌥ reads -/// aloud, and every other key is off; a key with no mode of its own reads -/// aloud on a tap or hold, or else follows the shared mode. +/// (`TriggerKeyStore`), each key's choice (`TriggerKeyStyleStore`) and the one +/// tap/hold mode they're all pressed under (`TriggerActivationStore`). Every +/// reader — the tap, the read-aloud router, the press's style, and each view — +/// asks this, so the rules are written once: the bound key always dictates; +/// with no entry, the bound key dictates with the main window's style, left ⌥ +/// reads aloud, and every other key is off. public struct TriggerKeyBindings: Sendable, Equatable { /// The key the ready screen and menu bar name, which always dictates. public let bound: TriggerKey private let choices: [TriggerKey: TriggerKeyChoice] - private let modes: [TriggerKey: TriggerActivation] - private let sharedMode: TriggerActivation + /// How every key is pressed. + public let activation: TriggerActivation public init(defaults: UserDefaults = .standard) { self.init( boundKeyCode: defaults.integer(forKey: TriggerKeyStore.defaultsKey), choicesRaw: defaults.string(forKey: TriggerKeyStyleStore.defaultsKey) ?? "", - modesRaw: defaults.string(forKey: TriggerKeyActivationStore.defaultsKey) ?? "", - sharedModeRaw: defaults.string(forKey: TriggerActivationStore.defaultsKey) ?? "") + activationRaw: defaults.string(forKey: TriggerActivationStore.defaultsKey) ?? "") } /// From the raw slots a view has already observed through `@AppStorage`. - public init(boundKeyCode: Int, choicesRaw: String, modesRaw: String, sharedModeRaw: String) { + public init(boundKeyCode: Int, choicesRaw: String, activationRaw: String) { bound = TriggerKey.fromPersisted(boundKeyCode) choices = TriggerKeyMap.decode(choicesRaw).mapValues(TriggerKeyChoice.init(raw:)) - modes = TriggerKeyMap.decode(modesRaw).compactMapValues(TriggerActivation.init(rawValue:)) - sharedMode = TriggerActivation.fromPersisted(sharedModeRaw) + activation = TriggerActivation.fromPersisted(activationRaw) } /// What `key` does with no choice of its own. Left ⌥ rather than left ⌘ @@ -132,12 +128,6 @@ public struct TriggerKeyBindings: Sendable, Equatable { /// What a press of `key` does. public func action(for key: TriggerKey) -> TriggerKeyAction { choice(for: key).action } - /// `key`'s tap/hold mode. - public func activation(for key: TriggerKey) -> TriggerActivation { - if let mode = modes[key] { return mode } - return key != bound && action(for: key) == .readAloud ? .tapOrHold : sharedMode - } - /// The keys the tap watches: every key that does something. public var enabledKeys: Set<TriggerKey> { Set(TriggerKey.allCases.filter { action(for: $0) != .off }) } @@ -154,13 +144,11 @@ public struct TriggerKeyBindings: Sendable, Equatable { TriggerKey.allCases.filter { $0 != bound && action(for: $0) != .off } } - /// Whether some key reads aloud or asks — read-aloud's settings do something - /// — and whether some key asks, which sends what's said to the LLM Gateway. + /// Whether some key reads aloud or asks — read-aloud's settings do something. public var readsOrAsks: Bool { TriggerKey.allCases.contains { [.readAloud, .ask].contains(action(for: $0)) } } - public var asks: Bool { TriggerKey.allCases.contains { action(for: $0) == .ask } } - /// Every key the tap watches, with its mode — the router's binding. + /// Every key the tap watches, with the mode — the router's binding. public var activations: [TriggerKey: TriggerActivation] { - Dictionary(uniqueKeysWithValues: enabledKeys.map { ($0, activation(for: $0)) }) + Dictionary(uniqueKeysWithValues: enabledKeys.map { ($0, activation) }) } } diff --git a/Sources/BlurtEngine/Hotkey/TriggerKeyMap.swift b/Sources/BlurtEngine/Hotkey/TriggerKeyMap.swift index 159940f2..aa85feba 100644 --- a/Sources/BlurtEngine/Hotkey/TriggerKeyMap.swift +++ b/Sources/BlurtEngine/Hotkey/TriggerKeyMap.swift @@ -2,7 +2,7 @@ import Foundation /// The on-disk form of a per-key setting — one JSON object, keycode → string — /// shared by `TriggerKeyStyleStore` (actions and styles) and -/// `TriggerKeyActivationStore` (tap/hold modes), so the encoding and its one +/// `TriggerKeySoundStore` (cue voices), so the encoding and its one /// safety rule are written once. enum TriggerKeyMap { /// The map behind a raw slot value. Blank or undecodable reads as empty, and diff --git a/Sources/BlurtEngine/Hotkey/TriggerKeySoundStore.swift b/Sources/BlurtEngine/Hotkey/TriggerKeySoundStore.swift new file mode 100644 index 00000000..ec865933 --- /dev/null +++ b/Sources/BlurtEngine/Hotkey/TriggerKeySoundStore.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Persists each trigger key's own start/stop cue voice, as set in the Sounds +/// tab: keycode → `SoundPack.id`, one JSON object (`TriggerKeyMap`). +/// +/// Ids only — the voices are the host's (`SoundPackCatalog`), so the host +/// decodes an entry against its catalog, and resolves a key with no entry from +/// its action's own cue setting (`SoundPackStore`). That fallback is why an +/// untouched install sounds exactly as it did before keys had sounds of their +/// own. +public struct TriggerKeySoundStore { + /// Public so SwiftUI views can observe it directly (e.g. `@AppStorage`). + public static var defaultsKey: String { DefaultsKey.triggerKeySounds.key } + + private let defaults: UserDefaults + + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + /// Every key's stored pack id, from a raw slot value a view has observed. + public static func soundIDs(decoding raw: String) -> [TriggerKey: String] { + TriggerKeyMap.decode(raw) + } + + /// `key`'s stored pack id, or nil when it follows its action's setting. + public func soundID(for key: TriggerKey) -> String? { + Self.soundIDs(decoding: defaults.string(forKey: Self.defaultsKey) ?? "")[key] + } + + /// Sets `key`'s pack id; nil removes the entry, so it follows its action's + /// setting again. + public func setSoundID(_ id: String?, for key: TriggerKey) { + var entries = Self.soundIDs(decoding: defaults.string(forKey: Self.defaultsKey) ?? "") + entries[key] = id + TriggerKeyMap.write(entries, to: Self.defaultsKey, in: defaults) + } +} diff --git a/Sources/BlurtEngine/Hotkey/TriggerKeyStyleStore.swift b/Sources/BlurtEngine/Hotkey/TriggerKeyStyleStore.swift index 247cdb9c..31292095 100644 --- a/Sources/BlurtEngine/Hotkey/TriggerKeyStyleStore.swift +++ b/Sources/BlurtEngine/Hotkey/TriggerKeyStyleStore.swift @@ -37,4 +37,25 @@ public struct TriggerKeyStyleStore { TriggerKeyMap.write(entries, to: Self.defaultsKey, in: defaults) return true } + + /// Gives `choice` to `key` alone, or with `key` nil to no key — the Keys + /// tab's action rows, one key each. Every other key that held `choice` is + /// turned off, and `key` gives up whatever it did before. Dictating with the + /// main window's style also makes `key` the bound key, the one the ready + /// screen names. Refused, with nothing written, when it would leave no key + /// dictating. + @discardableResult + public func assign(_ key: TriggerKey?, to choice: TriggerKeyChoice) -> Bool { + let before = TriggerKeyBindings(defaults: defaults) + let holders = TriggerKey.allCases.filter { $0 != key && before.choice(for: $0) == choice } + let dictatingAfter = before.dictatingKeys.filter { !holders.contains($0) && $0 != key } + guard (choice.action == .dictate && key != nil) || !dictatingAfter.isEmpty else { return false } + // `key` first, so a holder turned off below is never the last dictating key. + if let key { + setChoice(choice, for: key) + if choice == .dictate { TriggerKeyStore(defaults: defaults).triggerKey = key } + } + for holder in holders { setChoice(.off, for: holder) } + return true + } } diff --git a/Sources/BlurtEngine/README.md b/Sources/BlurtEngine/README.md index 46f2b58b..e493736f 100644 --- a/Sources/BlurtEngine/README.md +++ b/Sources/BlurtEngine/README.md @@ -236,6 +236,7 @@ Keys are the host identity's `defaultsPrefix` followed by the `DefaultsKey` case | -------------------------- | -------------------------------- | --------------------------------- | | `TriggerKeyStore` | `BlurtTriggerKeyCode` | right ⌘ | | `SoundPackStore` | `BlurtSoundPack` | the catalog's `defaultVoiceID` | +| `TriggerKeySoundStore` | `BlurtTriggerKeySounds` | each key plays its action's pack | | `KeyTermsStore` | `BlurtKeyTerms` | no key terms | | `DeveloperModeStore` | `BlurtDeveloperMode` | off — nothing is logged to disk | | `EnhancedTranscriptsStore` | `BlurtEnhancedTranscripts` | **on** — `llm_response` is pasted | @@ -258,7 +259,7 @@ Two string helpers carry the "usable text" rule shared by focus capture, the con The engine ships the _decision logic_ for a lone-modifier trigger; the host supplies the event source (in Blurt, a `CGEventTap` — see `App/Blurt/Blurt/Hotkey/DictationKeyTap.swift` for the reference wiring). - **`TriggerKey`** — the curated lone modifiers usable as a trigger (right ⌘, right ⌥, `fn`, left ⌘, left ⌥, left ⌃), with keycodes, display labels, and the device-modifier masks the event source needs. -- **`TriggerKeyBindings`** — one read of what every key does (`TriggerKeyChoice`: off, dictate with a style, read aloud, ask) and how it's pressed: the bound key always dictates, and the defaults are the bound key dictating, left ⌥ reading aloud, the rest off. `TriggerKeyStyleStore.setChoice` and `TriggerKeyActivationStore.setActivation` are the writes. +- **`TriggerKeyBindings`** — one read of what every key does (`TriggerKeyChoice`: off, dictate with a style, read aloud, ask) and how it's pressed: the bound key always dictates, and the defaults are the bound key dictating, left ⌥ reading aloud, the rest off. `TriggerKeyStyleStore.setChoice` (one key) and `assign(_:to:)` (one action, the Keys tab's rows) and `TriggerActivationStore.setActivation` (the one tap/hold mode for every key) are the writes. - **`GlobeKeyOverride`** — while `fn` does anything — dictates, reads aloud, or asks, as set in the Keys tab — sets macOS's "Press 🌐 key to" (`com.apple.HIToolbox` `AppleFnUsageType`) to Do Nothing so the press doesn't also fire the system's emoji/input-source/dictation action; once `fn` is off the replaced value is restored. The record sits in a suite both builds share (`sharedSuite`), and if either domain can't be opened `sync` does nothing. The write only lands at the next log-in, so the Keys tab's footer warns that `fn` may also open the emoji picker or Apple Dictation until then. `TriggerKeyStore.migrateStaleFunctionBinding()` rewrites a keycode 63 saved before `fn` was removed (#146) to right ⌘, once, so restoring `fn` doesn't rebind anyone behind their back. The listen-only tap can't swallow the press, so this is the only way to keep it from double-firing. - **`TriggerKeyStore`** — persists the chosen key in `UserDefaults` (`BlurtTriggerKeyCode`), defaulting to right ⌘. - **`DictationKeyGate`** — a pure, clock-free state machine that turns `modifierDown(at:)` / `modifierUp(at:)` / `otherKeyDown()` into `.start` / `.stop` / `.cancel` / `.none`. Recording starts the instant the modifier goes down; on key-up, a release held ≥ `holdThreshold` (default 1 s) is push-to-talk (stop), a shorter release latches tap-to-toggle (next tap stops). A modifier+key combo from idle cancels the fresh capture; over a latched recording it passes through as a normal shortcut. Callers pass monotonic timestamps, so every decision is deterministic and unit-tested (`DictationKeyGateTests`, `HotkeyRaceTests`). diff --git a/Sources/BlurtEngine/TTS/TextReader.swift b/Sources/BlurtEngine/TTS/TextReader.swift index e2d40d33..8724aa55 100644 --- a/Sources/BlurtEngine/TTS/TextReader.swift +++ b/Sources/BlurtEngine/TTS/TextReader.swift @@ -1,4 +1,6 @@ #if os(macOS) + import Synchronization + /// Reads any text aloud in the user's read-aloud voice and speed — the /// transcript sheet's Play on a task result (meeting notes, a summary), which /// has no audio of its own. The same streaming TTS as the read-aloud key, @@ -6,6 +8,8 @@ /// and without keeping a clip (the text is already saved with its entry). /// /// Runs until the speech has played; cancelling the task stops it at once. + /// The speech is generated as it plays, so there's a wait before the first + /// word: `onStart` says when it's over, for a caller showing "Preparing…". public struct TextReader: Sendable { private let speaker: any ReadAloudSpeaker @@ -17,8 +21,27 @@ self.speaker = speaker } - public func read(_ text: String) async throws { - try await speaker.speak(text, style: ReadAloudStyleStore().style.verbatim, onLevel: nil) + /// Reads `text` aloud. `onStart` runs once, when the first audio plays — + /// the speaker's first level reading — and not at all if none does. + public func read(_ text: String, onStart: (@Sendable () -> Void)? = nil) async throws { + let onLevel: (@Sendable (Float) -> Void)? = onStart.map { onStart in + let once = Once() + return { _ in if once.claim() { onStart() } } + } + try await speaker.speak(text, style: ReadAloudStyleStore().style.verbatim, onLevel: onLevel) + } + } + + /// True from `claim()` the first time only, from any thread: the speaker + /// reports levels off the main actor, many times a second. + private final class Once: Sendable { + private let claimed = Mutex(false) + + func claim() -> Bool { + claimed.withLock { claimed in + defer { claimed = true } + return !claimed + } } } #endif diff --git a/Tests/BlurtEngineTests/HistoryModelTests.swift b/Tests/BlurtEngineTests/HistoryModelTests.swift index 378c62f7..95fd5667 100644 --- a/Tests/BlurtEngineTests/HistoryModelTests.swift +++ b/Tests/BlurtEngineTests/HistoryModelTests.swift @@ -139,4 +139,36 @@ struct HistoryModelTests { #expect(calls.first?.kind == "speak: Meeting notes.") #expect(calls.first?.style.skipsJargon == false) } + + @Test("onStart runs once, at the first audio, however many levels follow") + func textReaderOnStart() async throws { + let starts = Mutex(0) + try await TextReader(speaker: LevelSpeaker(levels: 3)).read("Meeting notes.") { + starts.withLock { $0 += 1 } + } + #expect(starts.withLock { $0 } == 1) + } + + @Test("onStart doesn't run when no audio plays") + func textReaderNoAudio() async throws { + let starts = Mutex(0) + try await TextReader(speaker: LevelSpeaker(levels: 0)).read("Meeting notes.") { + starts.withLock { $0 += 1 } + } + #expect(starts.withLock { $0 } == 0) + } +} + +/// A speaker that reports `levels` meter readings, as audio playing does, then +/// finishes. +private struct LevelSpeaker: ReadAloudSpeaker { + let levels: Int + + func speak(_ text: String, style: ReadAloudStyle, onLevel: (@Sendable (Float) -> Void)?) async throws { + for _ in 0..<levels { onLevel?(0.5) } + } + + func answer( + _ request: String, about selection: String, style: ReadAloudStyle, onLevel: (@Sendable (Float) -> Void)? + ) async throws {} } diff --git a/Tests/BlurtEngineTests/PersistedSettingsTests.swift b/Tests/BlurtEngineTests/PersistedSettingsTests.swift index 8bc3f4e7..31ad5c55 100644 --- a/Tests/BlurtEngineTests/PersistedSettingsTests.swift +++ b/Tests/BlurtEngineTests/PersistedSettingsTests.swift @@ -64,7 +64,7 @@ struct PersistedSettingsTests { TriggerKeyStore.defaultsKey, TriggerActivationStore.defaultsKey, TriggerKeyStyleStore.defaultsKey, - TriggerKeyActivationStore.defaultsKey, + TriggerKeySoundStore.defaultsKey, SoundPackStore.defaultsKey, SoundPackStore.readAloudDefaultsKey, SoundPackStore.commandDefaultsKey, diff --git a/Tests/BlurtEngineTests/ReadAloudPillAndCueTests.swift b/Tests/BlurtEngineTests/ReadAloudPillAndCueTests.swift index 92eadda4..984af427 100644 --- a/Tests/BlurtEngineTests/ReadAloudPillAndCueTests.swift +++ b/Tests/BlurtEngineTests/ReadAloudPillAndCueTests.swift @@ -70,18 +70,16 @@ struct ReadAloudPillAndCueTests { @Test("the views' key decisions: extra rows, the only dictating key, whether read-aloud does anything") func bindingsViewHelpers() { - let defaults = TriggerKeyBindings(boundKeyCode: 54, choicesRaw: "", modesRaw: "", sharedModeRaw: "") + let defaults = TriggerKeyBindings(boundKeyCode: 54, choicesRaw: "", activationRaw: "") #expect(defaults.extraKeys == [.leftOption]) #expect(defaults.isOnlyDictatingKey(.rightCommand)) #expect(defaults.readsOrAsks) - #expect(!defaults.asks) let asking = TriggerKeyBindings( - boundKeyCode: 54, choicesRaw: #"{"58":"","61":"current","59":"ask"}"#, modesRaw: "", sharedModeRaw: "") + boundKeyCode: 54, choicesRaw: #"{"58":"","61":"current","59":"ask"}"#, activationRaw: "") #expect(asking.extraKeys == [.rightOption, .leftControl]) #expect(!asking.isOnlyDictatingKey(.rightCommand)) #expect(asking.readsOrAsks) - #expect(asking.asks) - let silent = TriggerKeyBindings(boundKeyCode: 54, choicesRaw: #"{"58":""}"#, modesRaw: "", sharedModeRaw: "") + let silent = TriggerKeyBindings(boundKeyCode: 54, choicesRaw: #"{"58":""}"#, activationRaw: "") #expect(!silent.readsOrAsks) } } diff --git a/Tests/BlurtEngineTests/TriggerActivationTests.swift b/Tests/BlurtEngineTests/TriggerActivationTests.swift index c7543fc8..b5cb4078 100644 --- a/Tests/BlurtEngineTests/TriggerActivationTests.swift +++ b/Tests/BlurtEngineTests/TriggerActivationTests.swift @@ -8,9 +8,9 @@ import Testing /// act, in `DictationKeyGateTests`. @Suite("TriggerActivation") struct TriggerActivationTests { - /// The bound key's mode with no entry of its own: the shared slot. + /// The one mode every key is pressed under. private func sharedMode(_ defaults: UserDefaults) -> TriggerActivation { - TriggerKeyBindings(defaults: defaults).activation(for: .rightCommand) + TriggerKeyBindings(defaults: defaults).activation } @Test("defaults to tap-or-hold when unset") @@ -21,10 +21,10 @@ struct TriggerActivationTests { #expect(TriggerActivation.fromPersisted("") == .tapOrHold) } - @Test("an existing install's shared mode is still honoured") + @Test("a mode set is the mode read") func roundTrips() { let defaults = freshDefaults() - defaults.set(TriggerActivation.hold.rawValue, forKey: TriggerActivationStore.defaultsKey) + TriggerActivationStore(defaults: defaults).setActivation(.hold) #expect(sharedMode(defaults) == .hold) } @@ -45,8 +45,6 @@ struct TriggerActivationTests { #expect(TriggerActivation.tapOrHold.hint(for: .dictate) == "Tap to start and stop. Hold to talk.") #expect(TriggerActivation.hold.hint(for: .readAloud) == "Hold to listen. Release to stop.") #expect(TriggerActivation.tapOrHold.hint(for: .ask) == "Tap to start and stop. Hold to ask.") - #expect( - Set(TriggerActivation.allCases.map(\.finishHint)).count == TriggerActivation.allCases.count) } @Test func mainWindowWordingNamesOnlyTheConfiguredGestures() { @@ -55,8 +53,5 @@ struct TriggerActivationTests { #expect(TriggerActivation.tapOrHold.startVerb == "tap") #expect(TriggerActivation.tap.startVerb == "tap") #expect(TriggerActivation.hold.startVerb == "hold") - #expect(TriggerActivation.tapOrHold.finishHint == "Tap again or release to finish.") - #expect(TriggerActivation.tap.finishHint == "Tap again to finish.") - #expect(TriggerActivation.hold.finishHint == "Release to finish.") } } diff --git a/Tests/BlurtEngineTests/TriggerKeySoundStoreTests.swift b/Tests/BlurtEngineTests/TriggerKeySoundStoreTests.swift new file mode 100644 index 00000000..2e7632f5 --- /dev/null +++ b/Tests/BlurtEngineTests/TriggerKeySoundStoreTests.swift @@ -0,0 +1,38 @@ +import Foundation +import Testing + +@testable import BlurtEngine + +/// Each trigger key's own cue voice (`TriggerKeySoundStore`): absent until set, +/// per key, and removable back to "follow the action's setting". +@Suite("TriggerKeySoundStore") +struct TriggerKeySoundStoreTests { + private let defaults = freshDefaults() + private var store: TriggerKeySoundStore { TriggerKeySoundStore(defaults: defaults) } + + @Test("an untouched install stores nothing, so every key follows its action") + func untouched() { + #expect(defaults.string(forKey: TriggerKeySoundStore.defaultsKey) == nil) + for key in TriggerKey.allCases { #expect(store.soundID(for: key) == nil) } + } + + @Test("a key's voice is its own and sticks") + func perKey() { + store.setSoundID("juno-0", for: .leftOption) + store.setSoundID("none", for: .rightCommand) + #expect(store.soundID(for: .leftOption) == "juno-0") + #expect(store.soundID(for: .rightCommand) == "none") + #expect(store.soundID(for: .leftControl) == nil) + let raw = defaults.string(forKey: TriggerKeySoundStore.defaultsKey) ?? "" + #expect(TriggerKeySoundStore.soundIDs(decoding: raw) == [.leftOption: "juno-0", .rightCommand: "none"]) + } + + @Test("clearing a key's voice removes only its entry") + func clear() { + store.setSoundID("juno-0", for: .leftOption) + store.setSoundID("rom1a-7", for: .leftControl) + store.setSoundID(nil, for: .leftOption) + #expect(store.soundID(for: .leftOption) == nil) + #expect(store.soundID(for: .leftControl) == "rom1a-7") + } +} diff --git a/Tests/BlurtEngineTests/TriggerKeyStyleTests.swift b/Tests/BlurtEngineTests/TriggerKeyStyleTests.swift index 7212987a..6dc6b675 100644 --- a/Tests/BlurtEngineTests/TriggerKeyStyleTests.swift +++ b/Tests/BlurtEngineTests/TriggerKeyStyleTests.swift @@ -4,7 +4,7 @@ import Testing @testable import BlurtEngine /// What each trigger key does and how it's pressed (`TriggerKeyBindings`, over -/// `TriggerKeyStyleStore` and `TriggerKeyActivationStore`), and the style one +/// `TriggerKeyStyleStore` and `TriggerActivationStore`), and the style one /// dictation uses without moving the main window's selection (`PressStyle` /// over `StyleProfileStore.profile(for:)`). @Suite("TriggerKeyBindings") @@ -14,7 +14,6 @@ struct TriggerKeyStyleTests { private let formal = StyleProfile(name: "Formal", instructions: "no contractions") private var choices: TriggerKeyStyleStore { TriggerKeyStyleStore(defaults: defaults) } - private var modes: TriggerKeyActivationStore { TriggerKeyActivationStore(defaults: defaults) } private var bindings: TriggerKeyBindings { TriggerKeyBindings(defaults: defaults) } /// A style store holding both profiles with Formal selected in the main window. @@ -66,8 +65,7 @@ struct TriggerKeyStyleTests { @Test("the bound key dictates whatever its entry says") func boundKeyAlwaysDictates() { let bindings = TriggerKeyBindings( - boundKeyCode: TriggerKey.rightCommand.keyCode, choicesRaw: #"{"54":"read-aloud"}"#, modesRaw: "", - sharedModeRaw: "") + boundKeyCode: TriggerKey.rightCommand.keyCode, choicesRaw: #"{"54":"read-aloud"}"#, activationRaw: "") #expect(bindings.choice(for: .rightCommand) == .dictate) } @@ -98,7 +96,7 @@ struct TriggerKeyStyleTests { @Test("a row shows Off, the main window's style, a style that still exists, read or ask") func rowChoice() { func bindings(_ raw: String) -> TriggerKeyBindings { - TriggerKeyBindings(boundKeyCode: 54, choicesRaw: raw, modesRaw: "", sharedModeRaw: "") + TriggerKeyBindings(boundKeyCode: 54, choicesRaw: raw, activationRaw: "") } let styled = bindings(#"{"61":"\#(casual.id.uuidString)","63":"\#(formal.id.uuidString)"}"#) #expect(styled.choice(for: .rightCommand, profiles: [casual]) == .dictate) @@ -112,29 +110,44 @@ struct TriggerKeyStyleTests { #expect(base.choice(for: .leftControl, profiles: []) == .ask) } - @Test("modes: a key's own, else left ⌥'s read is tap or hold, else the shared mode") + @Test("one mode for every key: the shared slot, tap or hold when unset") func activations() { - defaults.set(TriggerActivation.hold.rawValue, forKey: TriggerActivationStore.defaultsKey) choices.setChoice(.dictate(style: casual.id.uuidString), for: .rightOption) - #expect(bindings.activation(for: .rightCommand) == .hold) - #expect(bindings.activation(for: .leftOption) == .tapOrHold) - #expect(bindings.activation(for: .rightOption) == .hold) - modes.setActivation(.tap, for: .leftOption) - modes.setActivation(.tap, for: .rightCommand) - #expect(bindings.activation(for: .leftOption) == .tap) - #expect(bindings.activation(for: .rightCommand) == .tap) - // Right ⌥ has no mode of its own, so it follows the shared one. - #expect(bindings.activations == [.rightCommand: .tap, .rightOption: .hold, .leftOption: .tap]) - } - - @Test("each key keeps its own mode when the binding moves") - func modesStayWithTheirKeys() { - choices.setChoice(.dictate, for: .rightOption) - modes.setActivation(.tap, for: .rightOption) - modes.setActivation(.hold, for: .rightCommand) - choices.setChoice(.off, for: .rightCommand) + #expect(bindings.activation == .tapOrHold) + TriggerActivationStore(defaults: defaults).setActivation(.hold) + #expect(bindings.activation == .hold) + #expect(bindings.activations == [.rightCommand: .hold, .rightOption: .hold, .leftOption: .hold]) + } + + @Test("an action row's key: it alone holds the action, and the key gives up its old one") + func assignMovesTheAction() { + // Left ⌃ takes reading aloud from left ⌥, which turns off. + #expect(choices.assign(.leftControl, to: .readAloud)) + #expect(bindings.action(for: .leftControl) == .readAloud) + #expect(bindings.action(for: .leftOption) == .off) + // Left ⌃ moves on to asking; nothing reads aloud any more. + #expect(choices.assign(.leftControl, to: .ask)) + #expect(bindings.action(for: .leftControl) == .ask) + #expect(!TriggerKey.allCases.contains { bindings.action(for: $0) == .readAloud }) + // No key asks. + #expect(choices.assign(nil, to: .ask)) + #expect(bindings.action(for: .leftControl) == .off) + } + + @Test("Dictate's key becomes the bound key; the last dictating key can't be left without dictation") + func assignKeepsADictatingKey() { + #expect(!choices.assign(nil, to: .dictate), "Right ⌘ is the only dictating key") + #expect(bindings.bound == .rightCommand) + #expect(!choices.assign(.rightCommand, to: .readAloud), "…nor can it be given another action") + #expect(choices.assign(.function, to: .dictate)) + #expect(bindings.bound == .function) + #expect(bindings.action(for: .rightCommand) == .off) + // A styled dictation key keeps the binding where it is. + #expect(choices.assign(.rightOption, to: .dictate(style: casual.id.uuidString))) + #expect(bindings.bound == .function) + #expect(choices.assign(nil, to: .dictate), "Right ⌥ still dictates") + #expect(bindings.dictatingKeys == [.rightOption]) #expect(bindings.bound == .rightOption) - #expect(bindings.activation(for: .rightOption) == .tap) } @Test("each press resolves under its own key's mode")