suggest a filename from the first line — when on,
@@ -5065,6 +5077,15 @@ Adding a Mastodon account
a default, or remove one (which deletes its
saved sign-in from this computer). Your sign-in is stored securely in
the Windows Credential Manager, never in a plain file.
+Proofread posts before sending. In Mastodon
+Accounts..., select an account and tick Spell-check
+posts before sending to turn on per-account proofreading (off
+by default). When it is on, pressing Post for that
+account first opens the Spelling Review (F7) on the post text so you can
+fix misspellings, and the post is sent only after you finish or skip the
+review. The setting is per account, so you can enable it for some
+accounts and not others; existing accounts are unaffected until you turn
+it on.
Working with Different
Document Types
Quill is strongest today with plain text, Markdown, HTML, RTF, EPUB,
diff --git a/docs/user guide/userguide.md b/docs/user guide/userguide.md
index 022b6104..bf9079e1 100644
--- a/docs/user guide/userguide.md
+++ b/docs/user guide/userguide.md
@@ -715,6 +715,14 @@ connection, nothing uploaded.
- **Spell word aloud** — after announcing the misspelling, QUILL reads it
letter by letter. The pause before spelling starts is configurable.
+#### Spell check a document before saving
+
+Turn on **Settings → Editing → Spell check a document before saving** (off by
+default) and QUILL opens the same Spelling Review (F7) automatically whenever you
+save, so you can correct misspellings before the file is written. Review or skip
+the issues as usual; the save then proceeds with your corrections. This applies
+to **Save** and **Save As** for the document you are editing.
+
**Editor shortcuts** (without opening the dialog):
| Key | Action |
@@ -2558,6 +2566,7 @@ Quill's current settings and customization surface covers the things you are mos
- system tray mode
- persistent undo
- spell check as you type
+- **spell check a document before saving** — when on, saving opens the Spelling Review (F7) first so you can correct the document before it is written. Off by default.
- line-number visibility
- whether Quill starts with no document open
- **suggest a filename from the first line** — when on, saving an untitled document pre-fills the Save dialog with a name taken from the first line (across formats; leading markup like a Markdown heading, quote, or list bullet is stripped). Off by default.
@@ -2661,6 +2670,8 @@ The first time you post, Quill offers to add an account; you can also open **Too
You can add several accounts, give each its own nickname, **set a default**, or **remove** one (which deletes its saved sign-in from this computer). Your sign-in is stored securely in the Windows Credential Manager, never in a plain file.
+**Proofread posts before sending.** In **Mastodon Accounts...**, select an account and tick **Spell-check posts before sending** to turn on per-account proofreading (off by default). When it is on, pressing **Post** for that account first opens the Spelling Review (F7) on the post text so you can fix misspellings, and the post is sent only after you finish or skip the review. The setting is per account, so you can enable it for some accounts and not others; existing accounts are unaffected until you turn it on.
+
## Working with Different Document Types
Quill is strongest today with plain text, Markdown, HTML, RTF, EPUB, and extracted text workflows. It also has intake and extraction review features for imported material such as PDF/OCR sources and structured import support for Office-style formats.
diff --git a/quill/core/mastodon/accounts.py b/quill/core/mastodon/accounts.py
index 83dba706..58a9ddaf 100644
--- a/quill/core/mastodon/accounts.py
+++ b/quill/core/mastodon/accounts.py
@@ -46,6 +46,11 @@ class MastodonAccount:
instance_url: str
handle: str # e.g. "@user@mastodon.social", for display
client_id: str
+ # When True, posting through this account first opens the F7 spelling review
+ # on the post text so the user can fix misspellings before it is sent. Off by
+ # default; accounts saved before this field existed load as False, so the
+ # feature is purely opt-in and existing accounts are unaffected (#post-spellcheck).
+ spell_check_before_post: bool = False
@property
def display_name(self) -> str:
@@ -81,6 +86,9 @@ def list_accounts() -> list[MastodonAccount]:
instance_url=str(entry.get("instance_url", "")),
handle=str(entry.get("handle", "")),
client_id=str(entry.get("client_id", "")),
+ # Missing on accounts saved before the field existed -> False
+ # (opt-in migration: existing accounts stay off).
+ spell_check_before_post=bool(entry.get("spell_check_before_post", False)),
)
)
except KeyError:
@@ -114,6 +122,7 @@ def _persist(accounts: list[MastodonAccount], default_id: str | None) -> None:
"instance_url": a.instance_url,
"handle": a.handle,
"client_id": a.client_id,
+ "spell_check_before_post": a.spell_check_before_post,
}
for a in accounts
],
@@ -130,6 +139,7 @@ def add_account(
client_id: str,
client_secret: str,
access_token: str,
+ spell_check_before_post: bool = False,
) -> MastodonAccount:
"""Store a new account's metadata + secrets; return it. Becomes default if first."""
account = MastodonAccount(
@@ -138,6 +148,7 @@ def add_account(
instance_url=instance_url,
handle=handle,
client_id=client_id,
+ spell_check_before_post=spell_check_before_post,
)
save_secret(_token_cred(account.id), access_token)
save_secret(_secret_cred(account.id), client_secret)
@@ -166,6 +177,20 @@ def set_default_account(account_id: str) -> None:
_persist(accounts, account_id)
+def set_spell_check_before_post(account_id: str, enabled: bool) -> None:
+ """Turn the pre-post spelling review on/off for one account (off by default)."""
+ from dataclasses import replace
+
+ accounts = list_accounts()
+ if not any(a.id == account_id for a in accounts):
+ return
+ updated = [
+ replace(a, spell_check_before_post=bool(enabled)) if a.id == account_id else a
+ for a in accounts
+ ]
+ _persist(updated, default_account_id())
+
+
def access_token_for(account_id: str) -> str:
"""Return the stored access token for *account_id* (``""`` when missing)."""
return load_secret(_token_cred(account_id))
@@ -181,4 +206,5 @@ def access_token_for(account_id: str) -> str:
"list_accounts",
"remove_account",
"set_default_account",
+ "set_spell_check_before_post",
]
diff --git a/quill/core/settings.py b/quill/core/settings.py
index da46ea28..615da01d 100644
--- a/quill/core/settings.py
+++ b/quill/core/settings.py
@@ -83,6 +83,9 @@ class Settings:
tray_enabled: bool = False
persistent_undo: bool = False
spellcheck_as_you_type: bool = False
+ # When True, saving a document first opens the F7 spelling review so the user
+ # can correct misspellings before the file is written. Off by default.
+ spell_check_before_save: bool = False
intellisense_as_you_type: bool = False
snippet_trigger_expansion: bool = True
preview_browser: str = "system"
@@ -515,6 +518,7 @@ def from_dict(cls, data: dict[str, Any]) -> Settings:
tray_enabled = bool(data.get("tray_enabled", False))
persistent_undo = bool(data.get("persistent_undo", False))
spellcheck_as_you_type = bool(data.get("spellcheck_as_you_type", False))
+ spell_check_before_save = bool(data.get("spell_check_before_save", False))
intellisense_as_you_type = bool(data.get("intellisense_as_you_type", False))
snippet_trigger_expansion = bool(data.get("snippet_trigger_expansion", True))
preview_browser = str(data.get("preview_browser", "system")).strip() or "system"
@@ -1035,6 +1039,7 @@ def from_dict(cls, data: dict[str, Any]) -> Settings:
tray_enabled=tray_enabled,
persistent_undo=persistent_undo,
spellcheck_as_you_type=spellcheck_as_you_type,
+ spell_check_before_save=spell_check_before_save,
intellisense_as_you_type=intellisense_as_you_type,
snippet_trigger_expansion=snippet_trigger_expansion,
preview_browser=preview_browser,
diff --git a/quill/core/settings_specs.py b/quill/core/settings_specs.py
index 6c5fe9e3..d4b01670 100644
--- a/quill/core/settings_specs.py
+++ b/quill/core/settings_specs.py
@@ -375,6 +375,16 @@ def _ai_tts_voice_choices() -> tuple[tuple[str, str], ...]:
feature_id="core.spellcheck",
keywords=("spelling", "spell check", "typos"),
),
+ SettingSpec(
+ "spell_check_before_save",
+ "Spell check a document before saving",
+ "editing",
+ "bool",
+ "Open the spelling review (F7) when you save, so you can correct "
+ "misspellings before the file is written. Off by default.",
+ feature_id="core.spellcheck",
+ keywords=("spelling", "spell check", "save", "proofread", "before saving"),
+ ),
SettingSpec(
"intellisense_as_you_type",
"Word prediction and tag IntelliSense",
diff --git a/quill/tools/module_size_budgets.json b/quill/tools/module_size_budgets.json
index 8230836d..cf4d0759 100644
--- a/quill/tools/module_size_budgets.json
+++ b/quill/tools/module_size_budgets.json
@@ -25,6 +25,7 @@
"_rebaseline_2026_06_26_warm_settings_and_kokoro": "read_aloud.py 1258->1299, settings.py 1201->1209, main_frame.py 26142->26145, main_frame_speech.py 1277->1300, and a new main_frame_dictation_hotkeys.py entry at 601 for: (1) the live voice-preview phrase now matches the recorded samples (the phrase.txt tagline) instead of the quick-brown-fox line; (2) warm_dictation_model / warm_kokoro_model settings (default on) so users can disable background model warm-up; (3) bundled-Kokoro discovery (default_kokoro_model_dir prefers a user copy, else {QUILL_APP_ROOT}/kokoro-models) so Kokoro can ship in the installer without a download; (4) warm_kokoro_onnx() + prewarm_kokoro_model() background warm gated on the setting. Budgets re-baselined.",
"_rebaseline_2026_06_26_dictation_transcribe_dialog": "main_frame_dictation_hotkeys.py 601->621 (+20) for the dictation transcription progress dialog (#700): the transcribe path now shows the same AIProgressDialog (percentage + Minimize to status bar, quiet mirroring via _set_status_quiet) as the audio/video file path, then closes on completion and hands the text to the controller (which inserts and announces the word count). Replaces the bare _run_background_task path. Budget re-baselined.",
"_rebaseline_2026_06_26_transcribe_progress_dialog": "main_frame_speech.py 1300->1337 (+37) for the audio/video transcription progress dialog (#700): transcribe_audio_offline() now shows an AIProgressDialog with a real percentage (Faster Whisper reports segment-based progress), a \"Minimize to status bar\" button, and quiet status mirroring (_set_status_quiet) so a minimized run is not chatty; on completion it closes (clearing its status line) and opens the transcript, which announces the word count once. Replaces the bare _run_background_task status-bar path. Budget re-baselined.",
+ "_rebaseline_2026_06_29_spellcheck_before_post_and_save": "settings.py 1279->1284 (+5) and settings_specs.py 1820->1830 (+10) for the proofread-before-send / spell-check-before-save feature: a new Settings.spell_check_before_save field (default off) with its load() coercion, plus the searchable SettingSpec. settings.py / settings_specs.py are the canonical homes for user settings and their specs (same accepted settings-field rebaseline pattern). All other logic lives in untracked or headroom modules: the new wx-free quill/ui/spell_review.py helper, the per-account MastodonAccount.spell_check_before_post field (quill/core/mastodon/accounts.py, under cap), and the UI hooks in main_frame.py / mastodon_dialogs.py (within their budgets). Budgets re-baselined to current size.",
"_rebaseline_2026_06_29_whisper_unbundle": "main_frame_dictation_hotkeys.py 623->635 (+12) for the whisper.cpp unbundle (PRD 10.2.4): the dictation pre-flight now OFFERS to download the offline speech engine (Tools > Speech > Download Offline Speech Engine, via release_assets) when it is missing, instead of just pointing at a menu -- so a fresh install is never stranded. whisper is no longer bundled in the installer (build + iss generator updated; iss regenerated); fresh installs fetch the ~8 MB engine on demand (verified), upgraders keep their copy. Budget re-baselined.",
"_rebaseline_2026_06_29_kokoro_unbundle": "main_frame_speech.py 1423->1427 (+4) for the Kokoro unbundle (PRD 10.2.4): download_offline_speech_engine() now passes should_cancel/label to release_assets.fetch_component (cancel handled in-module rather than via a swallowed progress-raise). The Kokoro download handler itself (main_frame.py) was rewritten to route through release_assets and net shrank. Kokoro is no longer bundled in the installer; it downloads on demand from QUILL's verified release asset, and upgraders keep their existing copy. Budget re-baselined.",
"_rebaseline_2026_06_29_on_demand_speech_engine": "main_frame_speech.py 1344->1423 (+79) and main_frame_menu.py 4075->4087 (+12) for the on-demand offline speech engine (whisper.cpp) download (PRD 10.2.4): download_offline_speech_engine() worker (mirrors the existing download_ffmpeg/download_faster_whisper handlers, which already live here) + a 'Download Offline Speech Engine...' item in Tools > Speech with its id and EVT_MENU binding. All acquisition logic is in the new wx-free quill/core/release_assets.py (pinned + SHA-256-verified fetch from QUILL's own GitHub release asset; under the 600 default cap). The engine still ships bundled in the installer; this is the recovery/optional path. Budgets re-baselined to current size.",
@@ -288,9 +289,9 @@
"quill/core/publishing_clients.py": 624,
"quill/core/quillins/validation.py": 1324,
"quill/core/read_aloud.py": 1299,
- "quill/core/settings.py": 1279,
+ "quill/core/settings.py": 1284,
"quill/core/settings_registry.py": 158,
- "quill/core/settings_specs.py": 1820,
+ "quill/core/settings_specs.py": 1830,
"quill/core/watch_actions.py": 789,
"quill/io/rtf.py": 712,
"quill/io/rtf_model.py": 831,
diff --git a/quill/ui/main_frame.py b/quill/ui/main_frame.py
index 516890a2..06ce3bfc 100644
--- a/quill/ui/main_frame.py
+++ b/quill/ui/main_frame.py
@@ -9387,6 +9387,10 @@ def save_file(self) -> None:
if self.document.path is None:
self.save_file_as()
return
+ # Optional pre-save proofread (off by default): review misspellings in the
+ # editor before the file is written. save_file_as runs its own copy.
+ if getattr(self.settings, "spell_check_before_save", False):
+ self.open_spell_check_dialog()
if self.document.modified:
backup_document(self.document)
self._write_document_to_disk(self.document)
@@ -9604,6 +9608,10 @@ def save_file_as(self) -> None:
target = self._resolve_save_target(Path(dialog.GetPath()), chosen_filter)
self._last_file_dir = str(target.parent)
+ # Optional pre-save proofread (off by default), after the user has chosen
+ # the destination but before the file is written.
+ if getattr(self.settings, "spell_check_before_save", False):
+ self.open_spell_check_dialog()
self.document.set_text(self.editor.GetValue())
if self.document.modified and self.document.path is not None:
backup_document(self.document)
@@ -13400,6 +13408,7 @@ def post_to_mastodon(self) -> None:
accounts=accounts,
default_account_id=_accounts.default_account_id(),
announce=self._announce_result,
+ spell_review=self._spell_review_textctrl,
)
if dialog.show() == self._wx.ID_OK:
url = dialog.posted_url or ""
@@ -16269,6 +16278,26 @@ def show_dictionary_status(self) -> None:
f"document={document_count}, project={project_count}"
)
+ def _spell_review_textctrl(self, text_ctrl: object) -> None:
+ """Run the F7 spelling review over a wx text control (e.g. a Mastodon post).
+
+ Mirrors open_spell_check_dialog but targets the given control instead of
+ the editor, so corrections apply to the post text before it is sent (#549
+ Mastodon proofread-before-send).
+ """
+ from quill.ui.spell_review import review_textctrl
+
+ review_textctrl(
+ self._wx,
+ self.frame,
+ text_ctrl,
+ dictionary=self._spell_dictionary(),
+ announce_fn=self._announce,
+ settings=self.settings,
+ show_modal=self._show_modal_dialog,
+ )
+ self._invalidate_spell_dictionary_cache()
+
def open_spell_check_dialog(self) -> None:
"""Open the guided F7 Spelling Review dialog."""
from quill.core.spelling.session import ReviewSession
diff --git a/quill/ui/mastodon_dialogs.py b/quill/ui/mastodon_dialogs.py
index 3ddf073f..ab7743f0 100644
--- a/quill/ui/mastodon_dialogs.py
+++ b/quill/ui/mastodon_dialogs.py
@@ -33,11 +33,15 @@ def __init__(
accounts: list[account_store.MastodonAccount],
default_account_id: str | None,
announce=None,
+ spell_review=None,
) -> None:
import wx
self._wx = wx
self._announce = announce or (lambda _m: None)
+ # Optional callable(text_ctrl) -> None that runs the F7 spelling review
+ # over the post text. Provided by MainFrame; None in tests / headless use.
+ self._spell_review = spell_review
self._accounts = accounts
self.posted_url: str | None = None
@@ -117,6 +121,15 @@ def _on_post(self, _event: object) -> None:
if account is None:
self._announce("Add an account first.")
return
+ # Proofread before sending when this account opts in (off by default).
+ # The review edits the post text in place; re-read it afterwards.
+ if account.spell_check_before_post and self._spell_review is not None:
+ self._spell_review(self._text)
+ text = self._text.GetValue()
+ if not text.strip():
+ self._announce("Cannot post: the text is empty.")
+ self._text.SetFocus()
+ return
visibility = client.VISIBILITIES[max(0, self._visibility.GetSelection())][0]
token = account_store.access_token_for(account.id)
if not token:
@@ -156,6 +169,12 @@ def __init__(self, parent: object, *, announce=None) -> None:
self._list.SetName("Mastodon accounts")
root.Add(self._list, 1, wx.EXPAND | wx.ALL, 12)
+ self._spellcheck_check = wx.CheckBox(
+ self.dialog, label="Spell-check &posts before sending (selected account)"
+ )
+ self._spellcheck_check.SetName("Spell-check posts before sending")
+ root.Add(self._spellcheck_check, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 12)
+
row = wx.BoxSizer(wx.HORIZONTAL)
add_button = wx.Button(self.dialog, label="&Add Account...")
self._remove_button = wx.Button(self.dialog, label="&Remove")
@@ -181,14 +200,36 @@ def __init__(self, parent: object, *, announce=None) -> None:
self._remove_button.Bind(wx.EVT_BUTTON, lambda _e: self._on_remove())
self._default_button.Bind(wx.EVT_BUTTON, lambda _e: self._on_set_default())
self._list.Bind(wx.EVT_LISTBOX, lambda _e: self._update_button_states())
+ self._spellcheck_check.Bind(wx.EVT_CHECKBOX, lambda _e: self._on_toggle_spellcheck())
self._refresh()
self._list.SetFocus()
def _update_button_states(self) -> None:
- """Enable Remove / Set as Default only when an account is selected."""
- has_selection = self._selected_id() is not None
+ """Enable Remove / Set as Default and sync the spell-check box to selection."""
+ account = self._selected_account_obj()
+ has_selection = account is not None
self._remove_button.Enable(has_selection)
self._default_button.Enable(has_selection)
+ # Read the flag fresh from the store so it is always correct regardless of
+ # toggles, and never resets the list selection (unlike _refresh).
+ self._spellcheck_check.Enable(has_selection)
+ self._spellcheck_check.SetValue(bool(account and account.spell_check_before_post))
+
+ def _selected_account_obj(self) -> account_store.MastodonAccount | None:
+ account_id = self._selected_id()
+ return account_store.get_account(account_id) if account_id else None
+
+ def _on_toggle_spellcheck(self) -> None:
+ account_id = self._selected_id()
+ if account_id is None:
+ return
+ enabled = bool(self._spellcheck_check.GetValue())
+ account_store.set_spell_check_before_post(account_id, enabled)
+ self._announce(
+ "Spell-check before posting turned on for this account."
+ if enabled
+ else "Spell-check before posting turned off for this account."
+ )
def _refresh(self) -> None:
accounts = account_store.list_accounts()
diff --git a/quill/ui/spell_review.py b/quill/ui/spell_review.py
new file mode 100644
index 00000000..313fbd40
--- /dev/null
+++ b/quill/ui/spell_review.py
@@ -0,0 +1,63 @@
+"""Run the F7 spelling review over an arbitrary wx text control.
+
+``MainFrame.open_spell_check_dialog`` runs the guided spelling review against the
+main editor. This helper runs the same review (``ReviewSession`` +
+``SpellingReviewDialog``) against any ``wx.TextCtrl`` — e.g. the Mastodon compose
+box — so corrections are applied back into that control before its text is used.
+Kept out of ``main_frame`` so the compose dialog need not import the frame.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+
+def review_textctrl(
+ wx: Any,
+ parent: Any,
+ text_ctrl: Any,
+ *,
+ dictionary: Any,
+ announce_fn: Any,
+ settings: Any,
+ show_modal: Any,
+ scope_label: str = "post",
+ document_path: Path | None = None,
+) -> None:
+ """Spell-check ``text_ctrl`` in place via the guided review dialog.
+
+ No-op when the control is empty or already clean. Corrections are written
+ back into the control with ``Replace`` (the same call the editor path uses),
+ so the caller reads the corrected value afterwards.
+ """
+ from quill.core.spelling.session import ReviewSession
+ from quill.ui.spelling_review_dialog import SpellingReviewDialog
+
+ text = text_ctrl.GetValue()
+ if not text.strip():
+ return
+ session = ReviewSession(
+ text=text,
+ dictionary=set(dictionary),
+ scope_start=0,
+ scope_end=len(text),
+ )
+ if session.is_complete():
+ announce_fn("No misspellings found.")
+ return
+
+ def _apply(start: int, old_end: int, replacement: str) -> None:
+ text_ctrl.Replace(start, old_end, replacement)
+
+ dlg = SpellingReviewDialog(
+ parent=parent,
+ session=session,
+ apply_fn=_apply,
+ announce_fn=announce_fn,
+ document_path=document_path,
+ project_root=Path.cwd(),
+ settings=settings,
+ scope_label=scope_label,
+ )
+ dlg.show(show_modal)
diff --git a/tests/unit/core/test_mastodon_accounts.py b/tests/unit/core/test_mastodon_accounts.py
index 90455e78..60693987 100644
--- a/tests/unit/core/test_mastodon_accounts.py
+++ b/tests/unit/core/test_mastodon_accounts.py
@@ -95,3 +95,56 @@ def test_accounts_file_carries_a_schema_version(store: dict[str, str]) -> None:
encoding="utf-8",
)
assert [a.nickname for a in accounts.list_accounts()] == ["Main"]
+
+
+def test_spell_check_before_post_defaults_off(store: dict[str, str]) -> None:
+ account = _add("Main")
+ assert account.spell_check_before_post is False
+ assert accounts.list_accounts()[0].spell_check_before_post is False
+
+
+def test_set_spell_check_before_post_toggles_and_persists(store: dict[str, str]) -> None:
+ account = _add("Main")
+ accounts.set_spell_check_before_post(account.id, True)
+ assert accounts.get_account(account.id).spell_check_before_post is True
+ accounts.set_spell_check_before_post(account.id, False)
+ assert accounts.get_account(account.id).spell_check_before_post is False
+
+
+def test_add_account_can_enable_pre_post_review(store: dict[str, str]) -> None:
+ account = accounts.add_account(
+ nickname="Pre",
+ instance_url="https://mastodon.social",
+ handle="@pre@mastodon.social",
+ client_id="cid",
+ client_secret="csecret",
+ access_token="tok",
+ spell_check_before_post=True,
+ )
+ assert accounts.get_account(account.id).spell_check_before_post is True
+
+
+def test_existing_account_without_field_migrates_to_off(store: dict[str, str]) -> None:
+ import json as _json
+ from pathlib import Path
+
+ # Simulate an account saved before the field existed (opt-in migration).
+ Path(str(accounts.accounts_path())).write_text(
+ _json.dumps({
+ "schema_version": 1,
+ "accounts": [
+ {
+ "id": "abc123",
+ "nickname": "Legacy",
+ "instance_url": "https://mastodon.social",
+ "handle": "@legacy@mastodon.social",
+ "client_id": "cid",
+ }
+ ],
+ "default_id": "abc123",
+ }),
+ encoding="utf-8",
+ )
+ account = accounts.get_account("abc123")
+ assert account is not None
+ assert account.spell_check_before_post is False