Skip to content

Commit 95b7ad8

Browse files
feat: add customisable spellcheck (#6)
* feat(landing): update landing page to include new option * chore: version bump + changelog * docs: add VALID_LANGUAGES * docs: add SPELL CHECK section in REFERENCE * feat(spellcheck): implement customizable spell check setup and language selection * test(spellcheck): add tests for spell check functionality and language setup
1 parent 9233d9b commit 95b7ad8

15 files changed

Lines changed: 558 additions & 53 deletions

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.5.0] - 2026-04-27
9+
10+
### Added
11+
12+
- Spell check now supports 80+ languages powered by hunspell, including Hungarian, Arabic, Polish, French, and many more. Language is set per profile during setup and can be changed any time in manage profiles.
13+
- Spell check language selection is part of the setup wizard and the new profile flow.
14+
- Existing users are prompted to set a language on first launch after upgrading, with the option to configure each profile individually.
15+
- `--languages` flag lists all supported language codes with names.
16+
- Spellcheck settings persist per profile.
17+
- Spell check on/off toggle added to edit and new profile modals (`ctrl+k`).
18+
- SPELL CHECK section added to `--reference`.
19+
820
## [1.4.5] - 2026-04-27
921

1022
### Added

landing/documentation.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ <h2 class="docs-heading"># Options</h2>
4646
<span class="docs-desc">Run setup wizard again</span>
4747
</div>
4848
<div class="docs-row">
49-
<span class="docs-option">--reference</span>
50-
<span class="docs-desc">Show reference</span>
49+
<span class="docs-option">--reference / --languages </span>
50+
<span class="docs-desc">Show reference or list valid languages</span>
5151
</div>
5252
<div class="docs-row">
5353
<span class="docs-option">--license</span>

prosaic/REFERENCE

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,19 @@ Each profile has its own archive, git remote, and theme.
8383
Manage from dashboard (m key) or edit settings.json.
8484

8585

86+
SPELL CHECK
87+
-----------
88+
89+
Spell check is powered by hunspell and supports 80+ languages.
90+
Each profile has its own language setting.
91+
92+
F7 Toggle spell check on/off
93+
prosaic --languages List all supported language codes
94+
95+
Language is set per profile during setup or via manage profiles (m).
96+
Misspelled words are underlined in the editor.
97+
98+
8699
PANE DEFAULTS
87100
-------------
88101

prosaic/VALID_LANGUAGES

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
af_ZA Afrikaans
2+
an_ES Aragonese
3+
ar Arabic
4+
be_BY Belarusian
5+
bg_BG Bulgarian
6+
br_FR Breton
7+
ca_ES Catalan
8+
cs_CZ Czech
9+
da_DK Danish
10+
de_AT German (Austria)
11+
de_CH German (Switzerland)
12+
de_DE German (Germany)
13+
el_GR Greek
14+
en_AU English (Australian)
15+
en_CA English (Canada)
16+
en_GB English (Great Britain)
17+
en_US English (US)
18+
en_ZA English (South African)
19+
es Spanish (all variants)
20+
es_AR Spanish (Argentina)
21+
es_BO Spanish (Bolivia)
22+
es_CL Spanish (Chile)
23+
es_CO Spanish (Colombia)
24+
es_CR Spanish (Costa Rica)
25+
es_CU Spanish (Cuba)
26+
es_DO Spanish (Dominican Republic)
27+
es_EC Spanish (Ecuador)
28+
es_ES Spanish (Spain)
29+
es_GQ Spanish (Equatorial Guinea)
30+
es_GT Spanish (Guatemala)
31+
es_HN Spanish (Honduras)
32+
es_MX Spanish (Mexico)
33+
es_NI Spanish (Nicaragua)
34+
es_PA Spanish (Panama)
35+
es_PE Spanish (Peru)
36+
es_PH Spanish (Philippines)
37+
es_PR Spanish (Puerto Rico)
38+
es_PY Spanish (Paraguay)
39+
es_SV Spanish (El Salvador)
40+
es_US Spanish (US)
41+
es_UY Spanish (Uruguay)
42+
es_VE Spanish (Venezuela)
43+
et_EE Estonian
44+
fr_FR French
45+
gd_GB Scottish Gaelic
46+
gu_IN Gujarati
47+
gug_PY Guarani
48+
he_IL Hebrew
49+
hi_IN Hindi
50+
hr_HR Croatian
51+
hu_HU Hungarian
52+
id_ID Indonesian
53+
is Icelandic
54+
it_IT Italian
55+
ku_TR Kurdish (Turkey)
56+
lt_LT Lithuanian
57+
lv_LV Latvian
58+
md Mapudungun
59+
nb_NO Norwegian (Bokmal)
60+
nl_NL Dutch (Netherlands)
61+
nn_NO Norwegian (Nynorsk)
62+
oc_FR Occitan
63+
pl_PL Polish
64+
pt_BR Portuguese (Brazilian)
65+
pt_PT Portuguese
66+
ro_RO Romanian
67+
si_LK Sinhala
68+
sk_SK Slovak
69+
sl_SI Slovenian
70+
sr Serbian (Cyrillic)
71+
sr-Latn Serbian (Latin)
72+
sv_SE Swedish
73+
sw_TZ Swahili
74+
ta Tamil
75+
th_TH Thai
76+
tr_TR Turkish
77+
uk_UA Ukrainian
78+
vi_VN Vietnamese

prosaic/__main__.py

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,25 @@
88
from textual.screen import ModalScreen
99

1010
from prosaic.config import (
11+
LANGUAGE_NAMES,
12+
VALID_LANGUAGE_CODES,
1113
ensure_workspace,
1214
get_active_profile,
1315
get_app_version,
1416
get_notes_path,
1517
get_profile_config,
1618
get_workspace_dir,
17-
list_profiles,
1819
load_config,
20+
needs_language_setup,
21+
profiles_needing_language_setup,
1922
save_config,
23+
save_profile_config,
2024
set_active_profile,
2125
set_last_file,
26+
set_spell_check_enabled,
2227
was_just_migrated,
2328
)
29+
2430
from prosaic.core.metrics import MetricsTracker
2531
from prosaic.screens import DashboardScreen, EditorScreen
2632
from prosaic.themes import PROSAIC_DARK_CSS, PROSAIC_LIGHT_CSS
@@ -47,6 +53,66 @@ def _get_license_text() -> str:
4753
return _wrap_output(content)
4854

4955

56+
def _run_spell_setup(profile_name: str) -> None:
57+
"""Run the one-time spell check setup for existing users upgrading to 1.5.0."""
58+
current_version = get_app_version()
59+
click.echo()
60+
click.secho(f"welcome to prosaic {current_version}!", fg="yellow", bold=True)
61+
click.echo(
62+
"this version brings a more powerful spell check with support for"
63+
" 80+ languages powered by hunspell."
64+
)
65+
click.echo()
66+
67+
enable = click.confirm("enable spell check?", default=True)
68+
set_spell_check_enabled(enable, profile_name)
69+
70+
if not enable:
71+
return
72+
73+
click.echo()
74+
for code in sorted(VALID_LANGUAGE_CODES):
75+
name = LANGUAGE_NAMES.get(code, "")
76+
click.echo(f" {code:<10} {name}")
77+
click.echo()
78+
click.echo("you can also view this later with: prosaic --languages")
79+
click.echo()
80+
lang = click.prompt(
81+
f"language for '{profile_name}'",
82+
default="en_US",
83+
show_default=True,
84+
).strip()
85+
if lang not in VALID_LANGUAGE_CODES:
86+
click.secho(f"unknown language code '{lang}' — defaulting to en_US", fg="yellow")
87+
lang = "en_US"
88+
else:
89+
click.secho(f" {LANGUAGE_NAMES.get(lang, lang)}", fg="green")
90+
91+
data = get_profile_config(profile_name)
92+
data["spell_language"] = lang
93+
save_profile_config(data, profile_name)
94+
95+
others = [p for p in profiles_needing_language_setup() if p != profile_name]
96+
if others:
97+
click.echo()
98+
if click.confirm(f"configure other profiles now? ({', '.join(others)})", default=False):
99+
for other in others:
100+
click.echo()
101+
other_lang = click.prompt(
102+
f"language for '{other}'",
103+
default="en_US",
104+
show_default=True,
105+
).strip()
106+
if other_lang not in VALID_LANGUAGE_CODES:
107+
click.secho(f" unknown code '{other_lang}' — defaulting to en_US", fg="yellow")
108+
other_lang = "en_US"
109+
else:
110+
click.secho(f" {LANGUAGE_NAMES.get(other_lang, other_lang)}", fg="green")
111+
other_data = get_profile_config(other)
112+
other_data["spell_language"] = other_lang
113+
save_profile_config(other_data, other)
114+
115+
50116
class ProsaicApp(App):
51117
"""Main Prosaic application."""
52118

@@ -178,6 +244,7 @@ def action_hide_help_panel(self) -> None:
178244
@click.option("--setup", is_flag=True, help="Run setup wizard again")
179245
@click.option("--profile", default=None, help="Use a named profile (see --profiles)")
180246
@click.option("--profiles", "show_profiles", is_flag=True, help="List available profiles")
247+
@click.option("--languages", "show_languages", is_flag=True, help="List valid spell check language codes")
181248
@click.option("--reference", is_flag=True, help="Show reference")
182249
@click.option("--license", "show_license", is_flag=True, help="Show MIT license")
183250
@click.argument("file", required=False, type=click.Path())
@@ -186,6 +253,7 @@ def main(
186253
setup: bool,
187254
profile: str | None,
188255
show_profiles: bool,
256+
show_languages: bool,
189257
reference: bool,
190258
show_license: bool,
191259
file: str | None,
@@ -206,6 +274,16 @@ def main(
206274
click.echo("no profiles configured. run: prosaic --setup")
207275
return
208276

277+
if show_languages:
278+
click.echo("valid spell check language codes:")
279+
click.echo()
280+
for code in sorted(VALID_LANGUAGE_CODES):
281+
name = LANGUAGE_NAMES.get(code, "")
282+
click.echo(f" {code:<10} {name}")
283+
click.echo()
284+
click.echo("set language in manage profiles (m) or via prosaic --profile <name>")
285+
return
286+
209287
if reference:
210288
click.echo(_get_reference_text())
211289
return
@@ -223,16 +301,16 @@ def main(
223301

224302
if is_legacy_upgrade and not setup:
225303
click.echo()
226-
click.secho(f"new in prosaic {current_version}!", fg="yellow", bold=True)
304+
click.secho(f"hello from prosaic {current_version}!", fg="yellow", bold=True)
227305
click.echo()
228-
click.echo("this version introduces profiles - separate workspaces for")
229-
click.echo("different writing projects (personal, work, fiction, etc.)")
306+
click.echo("looks like you just upgraded. we've automatically migrated")
307+
click.echo("your config to the new format and taken a backup just in case.")
230308
click.echo()
309+
click.echo("this version introduces profiles — separate workspaces for")
310+
click.echo("different writing projects (personal, work, fiction, etc.)")
231311
click.echo("your existing setup has been preserved as the 'default' profile.")
232312
click.echo()
233-
234-
click.echo("would you like to learn about profiles and set up more now?")
235-
click.echo("you can always do it later with prosaic --setup")
313+
click.echo("you can always run prosaic --setup to configure more.")
236314
click.echo()
237315
run_profiles_setup = click.confirm("set up profiles now?", default=False)
238316

@@ -272,11 +350,15 @@ def main(
272350
setup_workspace(profile_data)
273351

274352
set_active_profile(result["active_profile"])
353+
profile_name = result["active_profile"]
275354

276355
if config.get("app_version") != current_version:
277356
config["app_version"] = current_version
278357
save_config(config)
279358

359+
if needs_language_setup(profile_name):
360+
_run_spell_setup(profile_name)
361+
280362
if light is None:
281363
profile_config = get_profile_config(get_active_profile())
282364
light_mode = profile_config.get("theme", "light") == "light"

prosaic/config.py

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,34 @@
1515

1616
_active_profile: str = "default"
1717
_just_migrated: bool = False
18+
_runtime_config: dict | None = None
19+
20+
def _load_valid_languages() -> tuple[frozenset[str], dict[str, str]]:
21+
path = Path(__file__).parent / "VALID_LANGUAGES"
22+
try:
23+
lines = path.read_text(encoding="utf-8").splitlines()
24+
codes = {}
25+
for line in lines:
26+
parts = line.split("\t", 1)
27+
if len(parts) == 2:
28+
codes[parts[0].strip()] = parts[1].strip()
29+
return frozenset(codes.keys()), codes
30+
except OSError:
31+
return frozenset({"en_US", "en_GB"}), {"en_US": "English (US)", "en_GB": "English (Great Britain)"}
32+
33+
VALID_LANGUAGE_CODES, LANGUAGE_NAMES = _load_valid_languages()
34+
35+
36+
def get_spell_check_enabled(profile_name: str | None = None) -> bool:
37+
"""Return whether spell check is enabled for a profile."""
38+
return get_profile_config(profile_name).get("spell_check_enabled", True)
39+
40+
41+
def set_spell_check_enabled(enabled: bool, profile_name: str | None = None) -> None:
42+
"""Persist spell check enabled state for a profile."""
43+
profile = get_profile_config(profile_name)
44+
profile["spell_check_enabled"] = enabled
45+
save_profile_config(profile, profile_name)
1846

1947

2048
def get_active_profile() -> str:
@@ -123,8 +151,16 @@ def migrate_config(config: dict) -> dict:
123151
def load_config() -> dict:
124152
"""Load configuration from settings.json.
125153
126-
Automatically migrates legacy configs to v2 format.
154+
Migrates legacy configs to v2 format in memory. The migrated config is
155+
cached and only written to disk when save_config() is called explicitly
156+
(after setup completes), so the user's original file is never overwritten
157+
until they finish setup.
127158
"""
159+
global _runtime_config, _just_migrated
160+
161+
if _runtime_config is not None:
162+
return _runtime_config
163+
128164
config_path = get_config_path()
129165
if not config_path.exists():
130166
return {}
@@ -135,20 +171,21 @@ def load_config() -> dict:
135171
return {}
136172

137173
if "app_version" not in config and config.get("setup_complete"):
138-
global _just_migrated
139174
_just_migrated = True
140175
backup_config()
141176
config = migrate_config(config)
142-
save_config(config)
177+
_runtime_config = config # cache in memory; main() saves after setup
143178

144179
return config
145180

146181

147182
def save_config(config: dict) -> None:
148183
"""Save configuration to settings.json."""
184+
global _runtime_config
149185
config_path = get_config_path()
150186
config_path.parent.mkdir(parents=True, exist_ok=True)
151187
write_text(config_path, json.dumps(config, indent=2))
188+
_runtime_config = None # clear cache; disk is now authoritative
152189

153190

154191
def get_profile_config(profile_name: str | None = None) -> dict:
@@ -306,6 +343,16 @@ def set_last_file(path: Path) -> None:
306343
save_profile_config(profile)
307344

308345

346+
def needs_language_setup(profile_name: str | None = None) -> bool:
347+
"""Return True if this profile has no spell_language set yet."""
348+
return "spell_language" not in get_profile_config(profile_name)
349+
350+
351+
def profiles_needing_language_setup() -> list[str]:
352+
"""Return all profile names that have no spell_language set."""
353+
return [p for p in list_profiles() if needs_language_setup(p)]
354+
355+
309356
def ensure_workspace() -> None:
310357
"""Ensure the workspace structure exists."""
311358
dirs = [

0 commit comments

Comments
 (0)