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
38 changes: 35 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,41 @@ already fixed in code nobody could run yet.
menu refresh move behind a restarting 120 ms timer -- so they run in the gap
*after* the character has reached the screen reader, and are skipped entirely
while you type faster than that; and the periodic autosave disk write moved off
the UI thread, removing a recurring mid-sentence hitch on large files. A build check now asserts the one-read-per-
keystroke budget, so this cannot quietly creep back.
(`quill/ui/main_frame_typing.py`, `quill/ui/main_frame_write_safety.py`)
the UI thread, removing a recurring mid-sentence hitch on large files. A build
check now asserts the one-read-per-keystroke budget, so this cannot quietly
creep back. (`quill/ui/main_frame_typing.py`, `quill/ui/main_frame_write_safety.py`)

**A second pass found the larger half.** Re-examining the same report turned
up costs the first round's budget could not see, and together they dwarfed
what it fixed:
- **The status bar refreshed twice per keystroke** -- once dragged in
synchronously by the title refresh, once from the key-up handler -- and its
cells each re-copied the document to compute themselves: the word count ran
a full text pass, line/column scanned to the caret, the progress and page
cells copied the buffer again. Nine full-document copies and two full scans
per character, on top of everything round one had removed. The title bar now
refreshes alone (and skips the native call when the title has not changed --
retitling a window fires a screen-reader name-change event, and nobody needs
to hear a rename to the same name); the status bar catches up in the typing
pause, and caret movement coalesces its refresh, so holding an arrow key
costs one refresh when the caret stops instead of one per repeat. Status-bar
cells and the entering/leaving-table check now read the document's own text
-- the same string, already in memory -- instead of copying the buffer out
of the control again.
- **The abbreviation expander opened the Windows clipboard on every
keystroke.** The clipboard is a shared, single-owner resource: opening it
contends with clipboard managers and with a screen reader's own clipboard
polling, and the deliberate retry that papers over that contention can hold
the typing thread for up to a fifth of a second -- per character. It is now
read only when an abbreviation has actually matched *and* its expansion uses
`${clipboard}`, which is to say: almost never.

Modelled on the same basis as round one, the synchronous per-keystroke cost
drops a further ~85-155x (200 KB document: ~22 ms to ~0.3 ms per keystroke;
1 MB: ~118 ms to ~1.3 ms) -- before counting the removed clipboard
contention, which no model captures. The budget test now covers all of it:
clipboard laziness, the title/status-bar split, caret coalescing, and the
zero-copy display reads.
- **A background result can no longer crash a dialog you have closed (#1353).**
Closing the AI Hub while its Ollama probe was still running raised "wrapped
C/C++ object of type StaticText has been deleted". The AI Hub was fixed in
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -14238,6 +14238,23 @@ <h2 id="11-performance-targets">11. Performance targets</h2>
<code>tests/unit/ui/test_keystroke_work_budget.py</code>, which fails
the build both on a second buffer read and on any of the deferred
consumers reappearing in <code>_sync_editor_change</code>.</p>
<p>Round 2 of the same budget (2026-08-13) extends the rule beyond
<code>GetValue</code>: the synchronous keystroke path may not refresh
the status bar (its cells cost several full-buffer reads and a
document-stats pass; they catch up in the deferred pass, and caret
movement coalesces its refresh via
<code>_schedule_statusbar_refresh</code>), may not open the
<strong>Windows clipboard</strong> (the abbreviation expander now
fetches it lazily, only for a matched expansion that uses
<code>${clipboard}</code> – the clipboard is a cross-process lock whose
retry can hold the UI thread ~200 ms), and display-only reads
(status-bar cells, the table-transition check) use
<code>_document_text_for_display()</code> – the document’s own string,
zero marshals – never a fresh <code>GetValue</code>. The title bar skips
its native <code>SetTitle</code>/tab-label calls when the strings are
unchanged, since retitling fires MSAA/UIA name-change events. All
enforced by
<code>tests/unit/ui/test_keystroke_work_budget.py</code>.</p>
<p>This is an <strong>accessibility</strong> budget, not merely a
performance one. The text-change notification NVDA and JAWS wait on is
delivered by the same thread, so work between keystrokes is heard as a
Expand Down
14 changes: 14 additions & 0 deletions docs/Product Requirement Documents and Specifications/QUILL-PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -7444,6 +7444,20 @@ The engineering choices above add up to the user-visible magic the product promi
- Autosave write: under 50 ms, non-blocking.
- **Per-keystroke work budget (#1346).** Exactly **one** `GetValue()` per `EVT_TEXT`, whatever the settings. On a multiline `wx.TextCtrl` that call marshals the whole buffer across the wx/native boundary, so each additional caller costs a complete copy of the document per character. Only three operations may run synchronously on the change path — `document.set_text`, the dirty-title refresh, and the quiet status line; everything else coalesces behind a single restarting `wx.CallLater` (`_DEFERRED_EDIT_DELAY_MS`, 120 ms). Enforced by `tests/unit/ui/test_keystroke_work_budget.py`, which fails the build both on a second buffer read and on any of the deferred consumers reappearing in `_sync_editor_change`.

Round 2 of the same budget (2026-08-13) extends the rule beyond `GetValue`:
the synchronous keystroke path may not refresh the status bar (its cells cost
several full-buffer reads and a document-stats pass; they catch up in the
deferred pass, and caret movement coalesces its refresh via
`_schedule_statusbar_refresh`), may not open the **Windows clipboard** (the
abbreviation expander now fetches it lazily, only for a matched expansion that
uses `${clipboard}` -- the clipboard is a cross-process lock whose retry can
hold the UI thread ~200 ms), and display-only reads (status-bar cells, the
table-transition check) use `_document_text_for_display()` -- the document's
own string, zero marshals -- never a fresh `GetValue`. The title bar skips its
native `SetTitle`/tab-label calls when the strings are unchanged, since
retitling fires MSAA/UIA name-change events. All enforced by
`tests/unit/ui/test_keystroke_work_budget.py`.

This is an **accessibility** budget, not merely a performance one. The text-change notification NVDA and JAWS wait on is delivered by the same thread, so work between keystrokes is heard as a delay before the spoken character, and a long enough queue coalesces keystrokes — the reported symptom was "long pauses between text entry and reporting from either NVDA and JAWS. Sometimes certain keys are not intercepted, such as the space, so that words run together." Before the fix a default configuration performed three to four full buffer marshals plus a full-text comparison and a menu-state refresh per character: roughly a megabyte of copying per keystroke on a 200 KB document.

---
Expand Down
Binary file modified docs/user guide/userguide.epub
Binary file not shown.
11 changes: 11 additions & 0 deletions docs/user guide/userguide.html
Original file line number Diff line number Diff line change
Expand Up @@ -28137,6 +28137,17 @@ <h3 id="typing-stays-ahead-of-you">Typing stays ahead of you</h3>
second of quiet, and if you are typing faster than that it is skipped
entirely until you pause — so the faster you type, the less QUILL does
between your keystrokes.</p>
<p>Two costs that used to hide on that path are gone as well. The status
bar no longer recomputes itself between keystrokes — its word count,
line and column, and progress cells used to re-copy the document several
times per character, twice over; they now update in the pauses, reading
the text QUILL already holds rather than copying it out of the editor
again. And the abbreviation expander no longer opens the Windows
clipboard on every keystroke — the clipboard is shared with every other
program (and with your screen reader’s own clipboard features), so
touching it per character meant waiting on that contention while you
typed. It is now read only at the moment an abbreviation that actually
uses <code>${clipboard}</code> expands.</p>
<p>This matters most with a screen reader. The notification NVDA and
JAWS wait on to speak the character you just typed is delivered by the
same thread that does that work, so work done between keystrokes is
Expand Down
2 changes: 2 additions & 0 deletions docs/user guide/userguide.md
Original file line number Diff line number Diff line change
Expand Up @@ -6612,6 +6612,8 @@ None of these have default key bindings. Assign them in Preferences > Keyboard,

Everything QUILL does while you type is arranged around one requirement: the next keystroke must never wait. Only three things happen between one character and the next — your text is updated, the modified marker is refreshed, and the status line is set quietly. Everything else QUILL could usefully do — the side preview, the spell-check hint, word prediction, browse-mode preparation, language detection, the enabling and disabling of menu items, the autosave — is pushed into the gap *after* the character has been handed to your screen reader, rather than done before it. It waits for a fifth of a second of quiet, and if you are typing faster than that it is skipped entirely until you pause — so the faster you type, the less QUILL does between your keystrokes.

Two costs that used to hide on that path are gone as well. The status bar no longer recomputes itself between keystrokes — its word count, line and column, and progress cells used to re-copy the document several times per character, twice over; they now update in the pauses, reading the text QUILL already holds rather than copying it out of the editor again. And the abbreviation expander no longer opens the Windows clipboard on every keystroke — the clipboard is shared with every other program (and with your screen reader's own clipboard features), so touching it per character meant waiting on that contention while you typed. It is now read only at the moment an abbreviation that actually uses `${clipboard}` expands.

This matters most with a screen reader. The notification NVDA and JAWS wait on to speak the character you just typed is delivered by the same thread that does that work, so work done between keystrokes is heard as a delay before the character — and, when the queue gets long enough, as keystrokes running together (a missing space between words is the usual first symptom). If you ever see that in a very large document, it is worth reporting rather than adjusting your screen reader: it means something has crept back onto the typing path.

## Search, Replace, and Deep Navigation
Expand Down
17 changes: 16 additions & 1 deletion quill/core/abbreviations.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,11 +230,23 @@ def try_expand(
caret: int,
library: AbbreviationLibrary,
clipboard_text: str = "",
*,
clipboard_provider: Callable[[], str] | None = None,
) -> AbbreviationMatch | None:
"""Check for an abbreviation ending just before the character at caret-1.

caret-1 must be a trigger character (space, punctuation, etc.).
Returns an AbbreviationMatch or None.

*clipboard_provider*, when given, is called instead of *clipboard_text* --
and only once an abbreviation has actually matched and its expansion
contains ``${clipboard}`` (#1346 follow-up). The Windows clipboard is a
shared, single-owner OS resource: opening it is a cross-process
synchronization point that can block behind a clipboard manager or a screen
reader's own clipboard polling. The old contract made the caller fetch it
up front, which in practice meant one clipboard open *per keystroke*;
matches are rare and ``${clipboard}`` expansions rarer, so the provider
turns that into approximately never.
"""
if caret < 2 or caret > len(text):
return None
Expand All @@ -261,7 +273,10 @@ def try_expand(
else:
match = token.lower() == abbr.abbreviation.lower()
if match:
resolved, cursor_offset, has_cursor = resolve_expansion(abbr.expansion, clipboard_text)
clip = clipboard_text
if clipboard_provider is not None and "${clipboard}" in abbr.expansion:
clip = clipboard_provider()
resolved, cursor_offset, has_cursor = resolve_expansion(abbr.expansion, clip)
return AbbreviationMatch(
token_start=token_start,
token_end=token_end,
Expand Down
145 changes: 75 additions & 70 deletions quill/core/document.py
Original file line number Diff line number Diff line change
@@ -1,70 +1,75 @@
from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path


@dataclass(slots=True)
class Document:
text: str = ""
path: Path | None = None
modified: bool = False
encoding: str = "utf-8"
line_ending: str = "\n"
source_metadata: dict[str, object] = field(default_factory=dict)
_revision: int = field(default=0, repr=False)

@property
def name(self) -> str:
if self.path is not None:
return self.path.name
display_name = str(self.source_metadata.get("display_name", "")).strip()
if display_name:
return " ".join(display_name.split())
return "Untitled"

@property
def revision(self) -> int:
"""Monotonic edit counter incremented on each textual change (#341).

Contract:

* ``revision`` is incremented exactly once per successful call to
:meth:`set_text` that actually changes the text (no-op writes do
not bump the counter).
* There is no public setter; the counter advances through
:meth:`set_text` only. Tests that need to force a known revision
value can construct a :class:`Document` directly with the private
``_revision`` field (it is a dataclass field with ``repr=False``)
or assign ``doc._revision = N`` because Python does not enforce
the leading-underscore privacy convention.
* Other state mutations (encoding change, line-ending change,
path change via :meth:`mark_saved`, source-metadata edit) do not
bump the counter; only the user-visible text changes.
* ``revision`` resets to ``0`` on a freshly constructed Document.
"""
return self._revision

def set_text(self, value: str) -> None:
if value == self.text:
return
self.text = value
self.modified = True
self._revision += 1

def mark_content_changed(self) -> None:
"""Record a content change that is not visible in the plain text.

Rich mode (One Editor, Every Format): applying bold via the TOM
changes the *document* without changing ``text``, so ``set_text``
would no-op — yet autosave keys on ``revision`` and the title bar on
``modified``. This is the sanctioned bump for those formatting-only
edits.
"""
self.modified = True
self._revision += 1

def mark_saved(self, path: Path | None = None) -> None:
if path is not None:
self.path = path
self.modified = False
from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path


@dataclass(slots=True)
class Document:
text: str = ""
path: Path | None = None
modified: bool = False
encoding: str = "utf-8"
line_ending: str = "\n"
source_metadata: dict[str, object] = field(default_factory=dict)
_revision: int = field(default=0, repr=False)

@property
def name(self) -> str:
if self.path is not None:
return self.path.name
display_name = str(self.source_metadata.get("display_name", "")).strip()
if display_name:
return " ".join(display_name.split())
return "Untitled"

@property
def revision(self) -> int:
"""Monotonic edit counter incremented on each textual change (#341).

Contract:

* ``revision`` is incremented exactly once per successful call to
:meth:`set_text` that actually changes the text (no-op writes do
not bump the counter).
* There is no public setter; the counter advances through
:meth:`set_text` only. Tests that need to force a known revision
value can construct a :class:`Document` directly with the private
``_revision`` field (it is a dataclass field with ``repr=False``)
or assign ``doc._revision = N`` because Python does not enforce
the leading-underscore privacy convention.
* Other state mutations (encoding change, line-ending change,
path change via :meth:`mark_saved`, source-metadata edit) do not
bump the counter; only the user-visible text changes.
* ``revision`` resets to ``0`` on a freshly constructed Document.
"""
return self._revision

def set_text(self, value: str) -> None:
# Length first: this runs on every keystroke (#1346), and an insertion
# or deletion -- the overwhelmingly common case -- changes the length,
# making the no-op check O(1) instead of a full-string compare that
# scans to the caret. Same-length edits (overwrite mode, a replaced
# selection of equal size) still fall through to the real compare.
if len(value) == len(self.text) and value == self.text:
return
self.text = value
self.modified = True
self._revision += 1

def mark_content_changed(self) -> None:
"""Record a content change that is not visible in the plain text.

Rich mode (One Editor, Every Format): applying bold via the TOM
changes the *document* without changing ``text``, so ``set_text``
would no-op — yet autosave keys on ``revision`` and the title bar on
``modified``. This is the sanctioned bump for those formatting-only
edits.
"""
self.modified = True
self._revision += 1

def mark_saved(self, path: Path | None = None) -> None:
if path is not None:
self.path = path
self.modified = False
Loading