Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/helpers/ipcHandlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -2652,10 +2652,10 @@ class IPCHandlers {
}

// Smart spacing (#856): append a trailing space so the next paste's leading
// space self-corrects the gap. macOS prepend-mode (getPrecedingChar) is
// intentionally skipped here — its Accessibility read costs hundreds of ms,
// too slow for the paste hot path.
const textToPaste = applySmartSpacing({ text, mode: "append" });
// space self-corrects the gap. Reading the char before the cursor to space
// the front instead would take a macOS Accessibility read costing hundreds
// of ms — too slow for the paste hot path.
const textToPaste = applySmartSpacing(text);

// Windows: restore the foreground window captured at record start so the
// paste lands in the field the user was dictating into, not wherever focus
Expand Down
27 changes: 4 additions & 23 deletions src/helpers/smartSpacing.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,5 @@
// Pure spacing rules applied between previously-typed text and a paste.
// "prepend" mode needs the char before the cursor (read via Accessibility on
// macOS); "append" mode is the platform-agnostic fallback.

const OPENING_CHARS = new Set([" ", "\t", "\n", "\r", "(", "[", "{", "<", '"', "'", "`", "“", "‘"]);
const LEADING_PUNCTUATION = new Set([",", ".", "!", "?", ";", ":", ")", "]", "}", "%", "”", "’"]);

function applySmartSpacing({ text, mode, precedingChar }) {
if (typeof text !== "string" || text.length === 0) return text;
if (mode === "prepend") return applyPrepend(text, precedingChar);
if (mode === "append") return applyAppend(text);
return text;
}

function applyPrepend(text, precedingChar) {
if (precedingChar == null || precedingChar === "") return text;
if (/^\s/.test(text)) return text;
if (OPENING_CHARS.has(precedingChar)) return text;
// Don't separate prior text from closing punctuation: "Hello" + ", world".
if (LEADING_PUNCTUATION.has(text[0])) return text;
return " " + text;
}
// Paste-time spacing: append a trailing space so the next dictation's paste
// doesn't run into this one. Kept pure so the rules stay unit-testable.

// Unspaced scripts (Han, kana) and CJK punctuation (Symbols and Punctuation,
// Fullwidth/Halfwidth Forms, Vertical Forms, Compatibility Forms): a trailing
Expand All @@ -29,7 +9,8 @@ function applyPrepend(text, precedingChar) {
const ENDS_WITH_CJK =
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\u3000-\u303f\uff00-\uff65\ufe10-\ufe1f\ufe30-\ufe4f]$/u;

function applyAppend(text) {
function applySmartSpacing(text) {
if (typeof text !== "string" || text.length === 0) return text;
if (/\s$/.test(text)) return text;
if (ENDS_WITH_CJK.test(text)) return text;
return text + " ";
Expand Down
63 changes: 0 additions & 63 deletions src/helpers/textEditMonitor.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,37 +41,6 @@ function isPersistentAxNoValueFailure(stderr) {
// keyboard focus. Modern Chromium builds the tree on demand when our
// reads arrive; where it doesn't, we skip auto-learn for that paste instead.

// Returns the character before the cursor for smart-spacing. Output protocol:
// "OK:X" — preceding char is X
// "START:" — cursor at field start, no preceding char
// "" — unknown / read failed (caller falls back to append-mode spacing)
// AppleScript `character N` is 1-indexed; AXSelectedTextRange.location is
// 0-indexed, so the char at offset (loc-1) is `character loc`.
const MACOS_AX_PRECEDING_CHAR_SCRIPT = (pid) =>
`tell application "System Events"\n` +
`\tset targetProc to first application process whose unix id is ${pid}\n` +
`\tset focAttr to value of attribute "AXFocusedUIElement" of targetProc\n` +
`\tif focAttr is missing value then return ""\n` +
`\tset theVal to ""\n` +
`\ttry\n` +
`\t\tset theVal to value of attribute "AXValue" of focAttr\n` +
`\t\tif theVal is missing value then set theVal to ""\n` +
`\tend try\n` +
`\tset loc to -1\n` +
`\ttry\n` +
`\t\tset sel to value of attribute "AXSelectedTextRange" of focAttr\n` +
`\t\ttry\n` +
`\t\t\tset loc to item 1 of sel\n` +
`\t\tend try\n` +
`\tend try\n` +
`\tif loc is -1 then return ""\n` +
`\tif loc < 1 then return "START:"\n` +
`\tif (length of theVal) is 0 then return "START:"\n` +
`\tif loc > (length of theVal) then set loc to length of theVal\n` +
`\tif loc < 1 then return "START:"\n` +
`\treturn "OK:" & (character loc of theVal)\n` +
`end tell`;

// Read the exact current selection without touching the clipboard. Prefixes
// distinguish an empty selection from an inaccessible target so selection
// editing can fail closed when Accessibility cannot inspect the focused field.
Expand Down Expand Up @@ -365,38 +334,6 @@ class TextEditMonitor extends EventEmitter {
return bounds;
}

/**
* macOS: read the char before the cursor in the focused text field, used by
* paste-time smart spacing. Resolves to { state: "ok", char } | { state:
* "start" } | { state: "unknown" }. Tight timeout so paste latency is
* unaffected; on "unknown" the caller falls back to append-mode spacing.
*/
getPrecedingChar(pid, timeoutMs = 400) {
return new Promise((resolve) => {
if (process.platform !== "darwin" || !pid) {
resolve({ state: "unknown" });
return;
}
const script = MACOS_AX_PRECEDING_CHAR_SCRIPT(pid);
execFile("osascript", ["-e", script], { timeout: timeoutMs }, (err, stdout) => {
if (err) {
resolve({ state: "unknown" });
return;
}
const out = stdout.replace(/\n$/, "");
if (out === "START:") {
resolve({ state: "start" });
return;
}
if (out.startsWith("OK:")) {
resolve({ state: "ok", char: out.slice(3) });
return;
}
resolve({ state: "unknown" });
});
});
}

/**
* Start monitoring the focused text field for edits after a paste.
* Kills any existing monitor before starting a new one.
Expand Down
165 changes: 35 additions & 130 deletions test/helpers/smartSpacing.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,154 +3,59 @@ const assert = require("node:assert/strict");

const { applySmartSpacing } = require("../../src/helpers/smartSpacing");

const prepend = (text, precedingChar) =>
applySmartSpacing({ text, mode: "prepend", precedingChar });

const append = (text) => applySmartSpacing({ text, mode: "append" });

test("prepend: adds space after a regular letter", () => {
assert.equal(prepend("hello", "d"), " hello");
});

test("prepend: adds space after a digit", () => {
assert.equal(prepend("dollars", "5"), " dollars");
});

test("prepend: no space at field start (empty precedingChar)", () => {
assert.equal(prepend("hello", ""), "hello");
});

test("prepend: no space when precedingChar is null/undefined", () => {
assert.equal(prepend("hello", null), "hello");
assert.equal(prepend("hello", undefined), "hello");
});

test("prepend: no space after existing whitespace", () => {
assert.equal(prepend("hello", " "), "hello");
assert.equal(prepend("hello", "\t"), "hello");
assert.equal(prepend("hello", "\n"), "hello");
});

test("prepend: no space after opening brackets", () => {
assert.equal(prepend("hello", "("), "hello");
assert.equal(prepend("hello", "["), "hello");
assert.equal(prepend("hello", "{"), "hello");
assert.equal(prepend("hello", "<"), "hello");
});

test("prepend: no space after opening quotes", () => {
assert.equal(prepend("hello", '"'), "hello");
assert.equal(prepend("hello", "'"), "hello");
assert.equal(prepend("hello", "`"), "hello");
assert.equal(prepend("hello", "“"), "hello");
});

test("prepend: no space when transcript already starts with whitespace", () => {
assert.equal(prepend(" hello", "d"), " hello");
assert.equal(prepend("\nhello", "d"), "\nhello");
});

test("prepend: no space when transcript starts with closing punctuation", () => {
// "Hello" + ", world" → "Hello, world" (not "Hello , world")
assert.equal(prepend(", world", "o"), ", world");
assert.equal(prepend(". Period.", "o"), ". Period.");
assert.equal(prepend("! exclamation", "o"), "! exclamation");
assert.equal(prepend("? question", "o"), "? question");
assert.equal(prepend("; semicolon", "o"), "; semicolon");
assert.equal(prepend(": colon", "o"), ": colon");
assert.equal(prepend(") close paren", "o"), ") close paren");
test("adds trailing space to normal text", () => {
assert.equal(applySmartSpacing("hello"), "hello ");
});

test("prepend: adds space when preceding char is sentence punctuation (no space yet)", () => {
assert.equal(prepend("World", "."), " World");
assert.equal(prepend("World", "!"), " World");
assert.equal(prepend("World", "?"), " World");
test("adds trailing space after punctuation", () => {
assert.equal(applySmartSpacing("hello."), "hello. ");
assert.equal(applySmartSpacing("hello!"), "hello! ");
});

test("prepend: no space when preceding is whitespace, even after period+space sequence", () => {
assert.equal(prepend("World", " "), "World");
test("does not double-up when text already ends with whitespace", () => {
assert.equal(applySmartSpacing("hello "), "hello ");
assert.equal(applySmartSpacing("hello\n"), "hello\n");
assert.equal(applySmartSpacing("hello\t"), "hello\t");
});

test("prepend: handles unicode preceding chars", () => {
assert.equal(prepend("hello", "д"), " hello");
test("handles empty transcript", () => {
assert.equal(applySmartSpacing(""), "");
});

test("prepend: handles empty transcript", () => {
assert.equal(prepend("", "a"), "");
test("does not append a trailing space after CJK ideographs", () => {
assert.equal(applySmartSpacing("你好"), "你好");
assert.equal(applySmartSpacing("日本語"), "日本語");
});

test("append: adds trailing space to normal text", () => {
assert.equal(append("hello"), "hello ");
test("does not append a trailing space after kana", () => {
assert.equal(applySmartSpacing("こんにちは"), "こんにちは");
assert.equal(applySmartSpacing("カタカナ"), "カタカナ");
});

test("append: adds trailing space after punctuation", () => {
assert.equal(append("hello."), "hello. ");
assert.equal(append("hello!"), "hello! ");
test("does not append a trailing space after full-width punctuation", () => {
assert.equal(applySmartSpacing("你好。"), "你好。");
assert.equal(applySmartSpacing("すごい!"), "すごい!");
assert.equal(applySmartSpacing("何?"), "何?");
assert.equal(applySmartSpacing("はい、"), "はい、");
assert.equal(applySmartSpacing("「引用」"), "「引用」");
assert.equal(applySmartSpacing("(括弧)"), "(括弧)");
});

test("append: does not double-up when text already ends with whitespace", () => {
assert.equal(append("hello "), "hello ");
assert.equal(append("hello\n"), "hello\n");
assert.equal(append("hello\t"), "hello\t");
test("does not append a trailing space after halfwidth CJK punctuation and vertical forms", () => {
assert.equal(applySmartSpacing("テスト。"), "テスト。");
assert.equal(applySmartSpacing("你好︒"), "你好︒");
});

test("append: handles empty transcript", () => {
assert.equal(append(""), "");
test("uses the last character for mixed-script text", () => {
assert.equal(applySmartSpacing("hello 你好"), "hello 你好");
assert.equal(applySmartSpacing("你好 hello"), "你好 hello ");
});

test("append: no trailing space after CJK ideographs", () => {
assert.equal(append("你好"), "你好");
assert.equal(append("日本語"), "日本語");
test("keeps trailing spaces after Hangul because Korean uses word spacing", () => {
assert.equal(applySmartSpacing("안녕하세요"), "안녕하세요 ");
});

test("append: no trailing space after kana", () => {
assert.equal(append("こんにちは"), "こんにちは");
assert.equal(append("カタカナ"), "カタカナ");
});

test("append: no trailing space after full-width punctuation", () => {
assert.equal(append("你好。"), "你好。");
assert.equal(append("すごい!"), "すごい!");
assert.equal(append("何?"), "何?");
assert.equal(append("はい、"), "はい、");
assert.equal(append("「引用」"), "「引用」");
assert.equal(append("(括弧)"), "(括弧)");
});

test("append: no trailing space after halfwidth CJK punctuation and vertical forms", () => {
assert.equal(append("テスト。"), "テスト。");
assert.equal(append("你好︒"), "你好︒");
});

test("append: last character decides for mixed-script text", () => {
assert.equal(append("hello 你好"), "hello 你好");
assert.equal(append("你好 hello"), "你好 hello ");
});

test("append: keeps trailing space after Hangul (Korean uses word spacing)", () => {
assert.equal(append("안녕하세요"), "안녕하세요 ");
});

test("returns text unchanged for unknown mode", () => {
assert.equal(applySmartSpacing({ text: "hello", mode: "noop" }), "hello");
});

test("returns text unchanged for non-string input", () => {
assert.equal(applySmartSpacing({ text: null, mode: "append" }), null);
assert.equal(applySmartSpacing({ text: undefined, mode: "append" }), undefined);
});

test("integration: typical dictation flow", () => {
// Field starts empty: "" → "Hello there"
assert.equal(prepend("Hello there.", ""), "Hello there.");

// After append fallback, next paste's preceding char is " "
// Field: "Hello there. " → user dictates again
assert.equal(prepend("How are you?", " "), "How are you?");

// No fallback was used; preceding char is "."
assert.equal(prepend("How are you?", "."), " How are you?");

// User dictates a closing tag: "(" → "first part)"
assert.equal(prepend("first part)", "("), "first part)");
test("returns non-string input unchanged", () => {
assert.equal(applySmartSpacing(null), null);
assert.equal(applySmartSpacing(undefined), undefined);
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,6 @@ const assert = require("node:assert/strict");

const TextEditMonitor = require("../../src/helpers/textEditMonitor");

test("getPrecedingChar resolves to unknown for missing pid", async () => {
const m = new TextEditMonitor();
for (const pid of [null, undefined, 0]) {
assert.deepEqual(await m.getPrecedingChar(pid), { state: "unknown" });
}
});

test("getPrecedingChar returns unknown when the AX read fails or hangs", async () => {
const m = new TextEditMonitor();
// Non-darwin short-circuits without shelling out; darwin errors out on an
// unmapped PID. Both paths must resolve quickly with state "unknown".
const start = Date.now();
const result = await m.getPrecedingChar(99999999, 1500);
assert.equal(result.state, "unknown");
assert.ok(Date.now() - start < 3000);
});

test("activateTargetPid resolves false when no target PID was captured", async () => {
const m = new TextEditMonitor();
m.lastTargetPid = null;
Expand Down
Loading