Skip to content

Commit 8ebb6fa

Browse files
authored
improves edit-with-emacs feature (#213)
- multiple edit buffers - app can have its own edit buffer now - sync - updating the text without closing the buffer - selection-aware editing Pre-selected text portion after "sync" gets replaced without affecting the rest of the text
1 parent 760b6d3 commit 8ebb6fa

File tree

4 files changed

+901
-98
lines changed

4 files changed

+901
-98
lines changed

CHANGELOG.ORG

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
1+
* [2026-03-03 Mon]
2+
** 2.0.0 - Edit-with-Emacs v2
3+
*** Session-based architecture
4+
Each edit invocation creates a unique session, enabling multiple simultaneous edit buffers across different apps.
5+
*** Direct text injection via macOS Accessibility API
6+
Text fields with settable AXValue are written to directly - no clipboard clobbering, no app switching (for supported apps). Falls back to clipboard-based Cmd+V for apps without AX support.
7+
*** Selection-aware editing
8+
Selecting a portion of text before invoking edit-with-emacs opens a buffer with only the selected text. On sync/finish, only the selected portion is replaced (prefix/suffix anchoring), and the replaced range is re-selected in the target app via AXSelectedTextRange.
9+
*** Live sync (C-c C-s)
10+
Push text changes to the target app without closing the edit buffer. Enables iterative editing with visual feedback.
11+
*** Clipboard safety net
12+
Text always passes through the system clipboard (even in AX mode), so clipboard history managers can always recover it.
13+
*** Informative header-line
14+
Edit buffers display the caller app name, [selection] indicator, and keybindings for submit/sync/cancel.
15+
*** Comprehensive feature spec
16+
Added =docs/edit-with-emacs-spec.org= documenting architecture, invariants, IPC interface, and manual test matrix.
17+
118
* [2026-01-07 Wed]
219
** 1.6.1 - fix emacsclient location detection
320

docs/edit-with-emacs-spec.org

Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
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

Comments
 (0)