Grab Text: add a configurable global shortcut for instant OCR - #24
Open
havokentity wants to merge 2 commits into
Open
Grab Text: add a configurable global shortcut for instant OCR#24havokentity wants to merge 2 commits into
havokentity wants to merge 2 commits into
Conversation
Grab Text could only be started by opening its window and clicking a button, which is backwards for a tool whose value is measured in seconds saved. ⌃⌥G now starts the region capture from anywhere, and the recognised text goes straight to the clipboard under the existing "copy automatically" preference. The window stays closed on the happy path — interrupting the user is the thing the shortcut exists to avoid — and only comes forward when the result needs them: a failure, a missing permission, or text that was not copied and would otherwise be lost with nowhere to read it. The state moved out of the SwiftUI view into GrabTextController because there are now two entry points into one capture. With `isGrabbing` living in view state, a hotkey press while the window was closed had nothing to check against and would start a second `screencapture -i`, leaving two processes fighting over the crosshair and a stray temp file behind. The controller owns the single guard, and the Carbon handler runs the capture synchronously (via MainActor.assumeIsolated — Carbon dispatches on the main run loop) rather than hopping through a Task, so no second press can slip past the guard while a hop is queued. Reuses the existing hotkey machinery rather than growing a second one: GlobalHotKey's failure-surfacing `register`, WindowShortcut's storage codec and glyph rendering, and the shortcut recorder — which moved out of WindowManagerView.swift into its own file and is now keyed by an AnyHashable slot token, because Window Manager has one shortcut per WindowAction and Grab Text has exactly one. Sharing it keeps the parts that are easy to get wrong (Esc handling, the auto-cancel when the panel resigns key, releasing the live Carbon keys so the recorder can read a combination they would otherwise swallow) in a single place instead of copied per tool. Two failures that would otherwise be silent are now spoken: - A combination another app owns exclusively is reported with the OSStatus macOS gave, on the shortcut row and on the main window's hint line, as is a Secure Input session — which suppresses delivery while registration still succeeds, so without saying so the shortcut looks fine and does nothing. - Screen capture is gated on Screen Recording, and without the grant `screencapture` returns a redacted frame rather than an error, which reads as "no text found" and sends the user hunting through their selection for a problem that is a permission. `GrabTextKit.classifyCapture` is the pure decision that tells those apart, and the settings sheet explains and requests the grant. The shortcut persists under DefaultsKey.grabTextCaptureShortcut and is deliberately left out of registerDefaults(): an absent value has to keep meaning "never customized, use today's default", which is what lets the default change later without stranding everyone who never opened the setting. An explicit "off" is stored as a sentinel so it stays distinguishable from that. The controller takes an injectable hotkey registrar and capture closure, which is what makes any of this testable: RegisterEventHotKey can neither be made to fail on demand nor safely left registered by a test process, and `screencapture -i` wants a GUI session and a human with a mouse. 20 new tests cover the storage codec and its fallbacks, the capture classification, the in-flight guard, the registration-failure surface, and each branch of what the user is told (suite: 772 → 792, 0 failures). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The hint under the Grab Text button reports two things macOS changes without announcing: Secure Event Input, which silently swallows every hotkey press while it is held, and the Screen Recording grant. Both were read once and then never again. `refreshEnvironment()` was reachable only from `registerHotKey()` and two `onAppear`s, and `HelperWindowHost` builds its hosting controller exactly once and never tears it down — so the window's `onAppear` fires at launch and never again, however many times the window is shown. The result was a hint that could be permanently wrong in both directions: a user whose ⌃⌥G had stopped working opened the window to find out why and read "Press ⌃⌥G anywhere to grab text", while a helper that launched during a stuck `loginwindow` warned about Secure Input forever with a shortcut that worked fine. The host now reports when its window starts and stops being visible — `show()` reveals it, occlusion and closing end it — and Grab Text re-reads the environment on that edge and on a 1s poll for as long as the window is on screen, which is the same shape Window Manager uses for the same flag. A poll rather than a notification because macOS posts none for either state, and visibility-scoped rather than always-on because nobody can read a hint on a window they cannot see, and this is an idle helper the rest of the time. The Secure Input read moved behind an injected closure so the staleness is testable at all: the system flag can neither be turned on from a test process nor read back at a moment of our choosing. Two smaller things the shortcut got wrong: A grab cancelled with Esc while the Screen Recording grant reads missing used to activate the app and yank the window forward on *every* press. While the grant is missing an Esc-cancel is indistinguishable from a capture TCC blocked (`classifyCapture` documents why it resolves the ambiguity that way), and the preflight is read in this process while `screencapture` runs in another, so the two can disagree after a grant given without relaunching. Saying it once is enough to send the user to Settings; saying it on every cancelled press is precisely the interruption the shortcut exists to avoid. The announcement re-arms when the grant comes back, so a later revocation is still reported. "Grabbed N characters. Press Copy to put them on the clipboard." was written to `statusMessage` in the one branch where the result box renders the recognised text instead of the status line — unreachable UI, and the only sentence telling a user with "Copy automatically" off that nothing had been copied. The controller now flags an uncopied result and the window shows the line above the text, where it is read. Suite: 792 → 799, 0 failures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Wave 2 Phase A.
Implementation: Grab Text: add a configurable global shortcut for instant OCR
Review fixes: Grab Text: keep the shortcut's hint true after launch
Built by an isolated agent, then adversarially reviewed by an independent agent that checked the branch out and ran
swift build+ the full suite itself. Every review finding was fixed, with a regression test added for each high and medium.🤖 Generated with Claude Code