Skip to content

Grab Text: add a configurable global shortcut for instant OCR - #24

Open
havokentity wants to merge 2 commits into
mainfrom
feat/grabtext-global-hotkey
Open

Grab Text: add a configurable global shortcut for instant OCR#24
havokentity wants to merge 2 commits into
mainfrom
feat/grabtext-global-hotkey

Conversation

@havokentity

Copy link
Copy Markdown
Owner

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

havokentity and others added 2 commits July 21, 2026 13:03
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant