|
| 1 | +#+TITLE: Edit-with-Emacs — Feature Specification |
| 2 | +#+STARTUP: showall |
| 3 | + |
| 4 | +* Overview |
| 5 | + |
| 6 | +Edit-with-Emacs (EWE) lets you edit any text field in any macOS app using |
| 7 | +Emacs. It is a *two-process system*: |
| 8 | + |
| 9 | +- *Hammerspoon side* (~emacs.fnl~, Fennel/Lua): captures text, manages |
| 10 | + sessions, writes text back to the target app. |
| 11 | +- *Emacs side* (~spacehammer.el~, Elisp): provides the editing buffer, minor |
| 12 | + mode, keybindings, and IPC calls back to Hammerspoon. |
| 13 | + |
| 14 | +Communication flows in both directions: |
| 15 | +- Hammerspoon → Emacs: via ~emacsclient -e~ (evaluates elisp) |
| 16 | +- Emacs → Hammerspoon: via ~hs -c~ CLI (evaluates Lua/Fennel) |
| 17 | + |
| 18 | +* Architecture |
| 19 | + |
| 20 | +** Write-back modes |
| 21 | + |
| 22 | +Every session operates in one of two modes, chosen at session creation: |
| 23 | + |
| 24 | +| Mode | Condition | Read text via | Write text via | |
| 25 | +|------------+---------------------------------------+---------------+-------------------------| |
| 26 | +| =:ax= | Focused element is a recognized text | Clipboard | AXUIElement setAXValue | |
| 27 | +| | role AND AXValue is settable | (Cmd+C) | (direct, no app switch) | |
| 28 | +|------------+---------------------------------------+---------------+-------------------------| |
| 29 | +| =:clipboard= | AX element unavailable or unsupported | Clipboard | Clipboard + Cmd+V | |
| 30 | +| | | (Cmd+C) | (requires app switch) | |
| 31 | + |
| 32 | +AX element detection checks for these roles: =AXTextArea=, =AXTextField=, |
| 33 | +=AXComboBox=, =AXTextView=. The element must also report ~isAttributeSettable |
| 34 | +:AXValue~ as true. |
| 35 | + |
| 36 | +** Session state |
| 37 | + |
| 38 | +Each invocation creates a session (stored in the ~sessions~ table, keyed by |
| 39 | +session-id). A session contains: |
| 40 | + |
| 41 | +| Field | Description | |
| 42 | +|---------------+---------------------------------------------------------| |
| 43 | +| =pid= | Process ID of the originating app | |
| 44 | +| =title= | Window title of the originating app | |
| 45 | +| =screen= | Display ID where the invocation happened | |
| 46 | +| =ax-element= | Stored AXUIElement reference (nil in clipboard mode) | |
| 47 | +| =mode= | =:ax= or =:clipboard= | |
| 48 | +| =has-selection= | Boolean — true if user had text selected at invocation | |
| 49 | +| =prefix= | Text before the selection (nil if no selection / no AX) | |
| 50 | +| =suffix= | Text after the selection (nil if no selection / no AX) | |
| 51 | +| =original= | The original text that was copied (from clipboard) | |
| 52 | + |
| 53 | +** Selection-aware editing |
| 54 | + |
| 55 | +When the user selects a portion of text before invoking EWE: |
| 56 | + |
| 57 | +1. *Read*: Cmd+C copies just the selection. The Emacs buffer contains only the |
| 58 | + selected text. |
| 59 | +2. *Anchoring*: ~build-session~ uses ~string.find~ to locate the selected text |
| 60 | + within the full AXValue, splitting it into prefix and suffix. |
| 61 | +3. *Write-back*: ~session-write-ax~ reconstructs: ~prefix .. edited_text .. |
| 62 | + suffix~, then writes the full string via AXValue. |
| 63 | +4. *Re-selection*: After a 100ms delay (apps reset AXSelectedTextRange when |
| 64 | + AXValue changes), sets AXSelectedTextRange to |
| 65 | + ~{:location (length prefix) :length (length edited_text)}~. |
| 66 | + |
| 67 | +This means subsequent syncs continue to replace only the selected portion. |
| 68 | + |
| 69 | +*Known limitation*: prefix/suffix anchoring uses ~string.find~ with plain match. |
| 70 | +If the selected text appears multiple times in the field, it will anchor to the |
| 71 | +*first* occurrence. Using AXSelectedTextRange at capture time would be more |
| 72 | +reliable but is not yet implemented. |
| 73 | + |
| 74 | +* Lifecycle |
| 75 | + |
| 76 | +** 1. Invocation (~edit-with-emacs~) |
| 77 | + |
| 78 | +Triggered by a global hotkey (default: Cmd+Ctrl+O). |
| 79 | + |
| 80 | +#+begin_example |
| 81 | +User presses hotkey |
| 82 | + │ |
| 83 | + ├─ Capture: current window, app PID, title, screen ID |
| 84 | + ├─ Probe: get-focused-text-element (AX check) |
| 85 | + ├─ Generate: unique session-id |
| 86 | + ├─ Send: Cmd+C (copy) |
| 87 | + │ |
| 88 | + ├─ wait-for-clipboard-change (poll up to 500ms) |
| 89 | + │ │ |
| 90 | + │ ├─ Clipboard changed? → User had a selection |
| 91 | + │ │ └─ build-session(... had-selection=true) |
| 92 | + │ │ |
| 93 | + │ └─ No change? → Nothing was selected |
| 94 | + │ ├─ Send: Cmd+A, Cmd+C (select all, copy) |
| 95 | + │ ├─ wait-for-clipboard-change again |
| 96 | + │ │ ├─ Changed? → build-session(... had-selection=false) |
| 97 | + │ │ └─ Still nothing? → Last resort: read AXValue directly |
| 98 | + │ │ └─ Set clipboard from AXValue, build-session(... had-selection=false) |
| 99 | +#+end_example |
| 100 | + |
| 101 | +** 2. Buffer creation (~spacehammer-edit-with-emacs~, Emacs side) |
| 102 | + |
| 103 | +Called by Hammerspoon via emacsclient. |
| 104 | + |
| 105 | +1. Create/reuse buffer named =* spacehammer-edit TITLE [SESSION-ID] *= |
| 106 | +2. Set buffer-local variables: pid, title, session-id, selection-only |
| 107 | +3. ~clipboard-yank~ — paste text from system clipboard into buffer |
| 108 | +4. Enable ~spacehammer-edit-with-emacs-mode~ (minor mode) |
| 109 | +5. ~pop-to-buffer~ — display the buffer |
| 110 | +6. Run ~spacehammer-edit-with-emacs-hook~ (user customization point) |
| 111 | +7. Set header-line (after hooks, so major-mode changes don't clobber it) |
| 112 | + |
| 113 | +** 3a. Sync (~C-c C-s~ → ~spacehammer-sync-edit-with-emacs~) |
| 114 | + |
| 115 | +Pushes buffer text to the originating app *without* closing the buffer. |
| 116 | + |
| 117 | +#+begin_example |
| 118 | +Emacs Hammerspoon |
| 119 | + │ │ |
| 120 | + ├─ Get buffer text │ |
| 121 | + ├─ Escape for Lua string │ |
| 122 | + ├─ hs -c syncText(session-id, text) ──────►│ |
| 123 | + │ ├─ AX mode? |
| 124 | + │ │ ├─ session-write-ax (write prefix+text+suffix) |
| 125 | + │ │ ├─ After 100ms: set AXSelectedTextRange |
| 126 | + │ │ └─ Set clipboard (safety net) |
| 127 | + │ │ |
| 128 | + │ └─ Clipboard mode? |
| 129 | + │ ├─ Selection: paste-via-clipboard, return to Emacs |
| 130 | + │ └─ No selection: Cmd+A, Cmd+V, return to Emacs |
| 131 | + │ │ |
| 132 | + ├─ Show "Synced" message │ |
| 133 | + └─ Run spacehammer-after-sync-hook │ |
| 134 | +#+end_example |
| 135 | + |
| 136 | +** 3b. Finish (~C-c C-c~ → ~spacehammer-finish-edit-with-emacs~) |
| 137 | + |
| 138 | +Pushes buffer text and *ends* the session. |
| 139 | + |
| 140 | +1. Run ~spacehammer-before-finish-edit-with-emacs-hook~ |
| 141 | +2. Kill buffer (or buffer+window if not sole window) |
| 142 | +3. IPC: ~finishSession(session-id, text)~ |
| 143 | + - AX mode: write via AX, set clipboard (safety), re-select, activate app |
| 144 | + - Clipboard mode: ~paste-via-clipboard~ |
| 145 | +4. Session removed from sessions table |
| 146 | + |
| 147 | +Legacy fallback (no session-id): uses ~clipboard-kill-ring-save~ + |
| 148 | +~switchToAppAndPasteFromClipboard~. |
| 149 | + |
| 150 | +** 3c. Cancel (~C-c C-k~ → ~spacehammer-cancel-edit-with-emacs~) |
| 151 | + |
| 152 | +Abandons edits, returns to originating app. |
| 153 | + |
| 154 | +1. Run ~spacehammer-before-cancel-edit-with-emacs-hook~ |
| 155 | +2. Kill buffer (or buffer+window) |
| 156 | +3. IPC: ~cancelSession(session-id)~ — activates app, removes session |
| 157 | + |
| 158 | +No text is written back. |
| 159 | + |
| 160 | +* Invariants |
| 161 | + |
| 162 | +These must always hold. Violating any of them is a regression. |
| 163 | + |
| 164 | +** Clipboard safety net |
| 165 | +Every text transition must leave a trace in the system clipboard: |
| 166 | +- *On read*: Text is always copied to the clipboard via Cmd+C (or set |
| 167 | + explicitly via ~hs.pasteboard.setContents~ as a last resort). |
| 168 | +- *On write (sync/finish)*: ~hs.pasteboard.setContents text~ is called even |
| 169 | + in AX mode. This ensures clipboard history managers always have a copy. |
| 170 | + |
| 171 | +** Buffer-local variables survive major-mode changes |
| 172 | +~spacehammer--caller-pid~, ~spacehammer--session-id~, |
| 173 | +~spacehammer--selection-only~, ~spacehammer--caller-title~ all have |
| 174 | +~permanent-local~ property set. Hooks that change major mode (e.g., activating |
| 175 | +~markdown-mode~) must not lose session state. |
| 176 | + |
| 177 | +** Selection-only edits preserve surrounding text |
| 178 | +When ~has-selection~ is true and prefix/suffix are set, write-back must |
| 179 | +reconstruct ~prefix + edited_text + suffix~. The buffer only ever contains the |
| 180 | +selected portion — never the full field. |
| 181 | + |
| 182 | +** Selection re-adjustment after write-back |
| 183 | +After setting AXValue in selection mode, AXSelectedTextRange must be set (with |
| 184 | +a 100ms delay) to ~{:location (length prefix) :length (length edited_text)}~. |
| 185 | +This keeps the replaced portion visually selected in the target app, enabling |
| 186 | +repeated sync cycles. |
| 187 | + |
| 188 | +** Session cleanup |
| 189 | +~finish-session~ and ~cancel-session~ must always remove the session from the |
| 190 | +sessions table, even if the write-back fails. |
| 191 | + |
| 192 | +** AX element re-acquisition |
| 193 | +~session-write-ax~ must attempt to re-acquire the AX element via |
| 194 | +~ax-get-app-element~ if the stored reference is nil (element went stale). The |
| 195 | +re-acquired reference must be stored back in the session. |
| 196 | + |
| 197 | +** Header-line set after hooks |
| 198 | +~spacehammer--set-header-line~ is called after |
| 199 | +~spacehammer-edit-with-emacs-hook~ runs, because hook functions commonly change |
| 200 | +the major mode (which resets ~header-line-format~). |
| 201 | + |
| 202 | +** Emacs window cleanup |
| 203 | +Buffer kill uses ~kill-buffer-and-window~ when the edit buffer is not the sole |
| 204 | +window, ~kill-buffer~ otherwise. This avoids leaving empty windows. |
| 205 | + |
| 206 | +* Emacs-side extension points |
| 207 | + |
| 208 | +| Hook | When | Args | |
| 209 | +|------------------------------------------------+------------------------+-------------------------| |
| 210 | +| ~spacehammer-edit-with-emacs-hook~ | Buffer created & shown | buffer-name, pid, title | |
| 211 | +| ~spacehammer-before-finish-edit-with-emacs-hook~ | Before buffer killed | buffer-name, pid | |
| 212 | +| ~spacehammer-before-cancel-edit-with-emacs-hook~ | Before buffer killed | buffer-name, pid | |
| 213 | +| ~spacehammer-after-sync-hook~ | After sync IPC sent | buffer-name, session-id | |
| 214 | + |
| 215 | +Typical hook usage: switch major mode based on app title, set visited-file-name |
| 216 | +for LSP, enter insert state (Evil/Vim). |
| 217 | + |
| 218 | +* Keybindings (spacehammer-edit-with-emacs-mode) |
| 219 | + |
| 220 | +| Key | Command | Action | |
| 221 | +|---------+------------------------------------+-----------------------------| |
| 222 | +| ~C-c C-c~ | ~spacehammer-finish-edit-with-emacs~ | Submit text, close buffer | |
| 223 | +| ~C-c C-s~ | ~spacehammer-sync-edit-with-emacs~ | Push text, keep buffer open | |
| 224 | +| ~C-c C-k~ | ~spacehammer-cancel-edit-with-emacs~ | Abandon, close buffer | |
| 225 | + |
| 226 | +* IPC interface |
| 227 | + |
| 228 | +** Hammerspoon → Emacs (via emacsclient -e) |
| 229 | + |
| 230 | +| Elisp function | Args | |
| 231 | +|-----------------------------+------------------------------------------------| |
| 232 | +| ~spacehammer-edit-with-emacs~ | pid, title, screen, session-id, selection-only | |
| 233 | + |
| 234 | +** Emacs → Hammerspoon (via hs -c) |
| 235 | + |
| 236 | +| Lua expression (via require("emacs")) | Purpose | |
| 237 | +|----------------------------------------+----------------------------------| |
| 238 | +| ~.syncText(session-id, text)~ | Push text without ending session | |
| 239 | +| ~.finishSession(session-id, text)~ | Push text and end session | |
| 240 | +| ~.cancelSession(session-id)~ | End session without writing | |
| 241 | +| ~.getSessionInfo(session-id)~ | Query session metadata | |
| 242 | +| ~.switchToApp(pid)~ | Legacy: activate app by PID | |
| 243 | +| ~.switchToAppAndPasteFromClipboard(pid)~ | Legacy: activate + Edit>Paste | |
| 244 | + |
| 245 | +* String escaping chain |
| 246 | + |
| 247 | +Text passes through multiple escaping layers: |
| 248 | + |
| 249 | +1. *Lua side* (~escape-elisp-string~): escapes ~\~, ~"~, ~\n~, ~\r~ for |
| 250 | + embedding in elisp string literals sent via emacsclient. |
| 251 | +2. *Elisp side* (~spacehammer--escape-lua-string~): escapes ~\~, ~"~, ~\n~, |
| 252 | + ~\r~ for embedding in Lua string literals sent via ~hs -c~. |
| 253 | + |
| 254 | +This is a fragile area. Any text containing unusual characters (backslashes, |
| 255 | +quotes, control characters, unicode) must survive the round-trip without |
| 256 | +corruption. |
| 257 | + |
| 258 | +* Known limitations |
| 259 | + |
| 260 | +1. *Prefix/suffix anchoring uses string.find*: If the selected text appears |
| 261 | + multiple times in the field, it anchors to the first occurrence. Could be |
| 262 | + fixed by using AXSelectedTextRange at capture time instead. |
| 263 | + |
| 264 | +2. *Clipboard mode sync is disruptive*: In clipboard fallback mode, sync |
| 265 | + requires switching to the target app, pasting, and switching back. The user |
| 266 | + sees a brief app flash. |
| 267 | + |
| 268 | +3. *AX element staleness*: Stored AXUIElement references can go stale if the |
| 269 | + target app's UI changes (e.g., navigating to a different view). Re-acquisition |
| 270 | + via ~ax-get-app-element~ helps but depends on the same field being focused. |
| 271 | + |
| 272 | +4. *100ms delay for selection re-adjustment*: Apps reset AXSelectedTextRange |
| 273 | + when AXValue changes. The 100ms timer is empirically chosen; some slow apps |
| 274 | + might need more. |
| 275 | + |
| 276 | +5. *Cmd+C race*: ~wait-for-clipboard-change~ polls at 50ms intervals for up to |
| 277 | + 500ms. Extremely slow apps may not respond in time. |
| 278 | + |
| 279 | +6. *No multi-cursor/multi-selection support*: Only a single contiguous |
| 280 | + selection is supported. |
| 281 | + |
| 282 | +* Customization example: direction-aware buffer placement |
| 283 | + |
| 284 | +The edit buffer can be displayed on the side of the Emacs frame closest to the |
| 285 | +caller app. This is a personal customization (not part of spacehammer.el) since |
| 286 | +it only applies to GUI Emacs. |
| 287 | + |
| 288 | +The approach: a custom ~display-buffer~ action function reads |
| 289 | +~spacehammer--caller-pid~ from the buffer (already set before ~pop-to-buffer~), |
| 290 | +queries Hammerspoon for the caller app's window center via ~hs -c~, compares |
| 291 | +with ~(frame-position)~ + ~(frame-pixel-width)~, and passes the direction to |
| 292 | +~display-buffer-in-quadrant~. |
| 293 | + |
| 294 | +~do-applescript~ (Emacs built-in) is used rather than ~hs -c~ or ~osascript~ |
| 295 | +because it runs in-process with zero subprocess overhead. Requires Emacs to |
| 296 | +have Accessibility permission in macOS Privacy & Security settings. If the |
| 297 | +permission is stale (e.g., after an Emacs upgrade), remove and re-add Emacs, |
| 298 | +then restart it — macOS caches permission denials per-process. |
| 299 | + |
| 300 | +* Manual test matrix |
| 301 | + |
| 302 | +Use this after changes to verify nothing is broken. |
| 303 | + |
| 304 | +** Scenario 1: Full-field edit, AX mode app (e.g., Slack, Notes) |
| 305 | +1. Focus a text field with some text, no selection |
| 306 | +2. Press Cmd+Ctrl+O |
| 307 | +3. *Expect*: Emacs buffer opens with full text, header shows app name, no "[selection]" |
| 308 | +4. Edit text, press C-c C-c |
| 309 | +5. *Expect*: Buffer closes, target app activates, text is replaced |
| 310 | +6. *Expect*: Clipboard contains the edited text |
| 311 | + |
| 312 | +** Scenario 2: Selection-only edit, AX mode app |
| 313 | +1. Select a portion of text in a text field |
| 314 | +2. Press Cmd+Ctrl+O |
| 315 | +3. *Expect*: Emacs buffer contains only the selected text, header shows "[selection]" |
| 316 | +4. Edit text, press C-c C-s (sync) |
| 317 | +5. *Expect*: Target app text updates — only the selected portion is replaced, surrounding text intact |
| 318 | +6. *Expect*: The replaced portion is visually selected in the target app |
| 319 | +7. Edit again, press C-c C-s again |
| 320 | +8. *Expect*: Same behavior — only the (now changed) portion is replaced again |
| 321 | +9. Press C-c C-c (finish) |
| 322 | +10. *Expect*: Buffer closes, target app activates, final text in place |
| 323 | + |
| 324 | +** Scenario 3: Cancel |
| 325 | +1. Start an edit session (either full or selection) |
| 326 | +2. Make some changes in the buffer |
| 327 | +3. Press C-c C-k |
| 328 | +4. *Expect*: Buffer closes, target app activates, original text unchanged |
| 329 | + |
| 330 | +** Scenario 4: Clipboard fallback (e.g., app where AX is not available) |
| 331 | +1. Focus a text field in an app that doesn't expose AX text elements |
| 332 | +2. Press Cmd+Ctrl+O |
| 333 | +3. *Expect*: Emacs buffer opens with text |
| 334 | +4. Edit and C-c C-c |
| 335 | +5. *Expect*: App activates, text is pasted via clipboard |
| 336 | + |
| 337 | +** Scenario 5: Clipboard safety |
| 338 | +1. Perform any edit session (AX or clipboard mode) |
| 339 | +2. After finish or sync, check clipboard history manager |
| 340 | +3. *Expect*: The edited text appears in clipboard history |
| 341 | + |
| 342 | +** Scenario 6: Hook-driven major mode change |
| 343 | +1. Configure ~spacehammer-edit-with-emacs-hook~ to activate a major mode |
| 344 | + (e.g., ~markdown-mode~) |
| 345 | +2. Start an edit session |
| 346 | +3. *Expect*: Major mode is active, header-line is visible, C-c C-c/C-s/C-k all work |
| 347 | +4. *Expect*: Buffer-local session variables (pid, session-id) survived the mode change |
| 348 | + |
| 349 | +** Scenario 7: Multiple simultaneous sessions |
| 350 | +1. Start an edit session from App A — don't finish it |
| 351 | +2. Switch to App B, start another edit session |
| 352 | +3. *Expect*: Both buffers exist with distinct names and session IDs |
| 353 | +4. Finish each independently |
| 354 | +5. *Expect*: Each writes back to its own originating app |
0 commit comments