Skip to content

Commit 653a2fa

Browse files
mprpicclaude
andcommitted
Add word-level spellchecking
Integrate pyspellchecker to underline misspelled words in the editor and show the 5 closest suggestions in the MessageBar when the cursor is on a misspelled word. Toggle spellcheck on/off with Ctrl+L. Comment lines (starting with #) are excluded from checking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Martin Prpič <martin.prpic@gmail.com>
1 parent b25ea3a commit 653a2fa

5 files changed

Lines changed: 415 additions & 30 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ classifiers = [
2727
"Topic :: Text Editors",
2828
]
2929
dependencies = [
30+
"pyspellchecker>=0.8.0",
3031
"textual>=0.50.0",
3132
]
3233

src/commit_editor/app.py

Lines changed: 146 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from collections.abc import Callable
12
from pathlib import Path
23

34
from rich.segment import Segment
@@ -10,9 +11,11 @@
1011
from textual.widgets import Static, TextArea
1112

1213
from commit_editor.git import get_signed_off_by
14+
from commit_editor.spelling import WORD_PATTERN, SpellCheckCache
1315

1416
TITLE_MAX_LENGTH = 50
1517
BODY_MAX_LENGTH = 72
18+
_SUGGESTION_PREFIX = "Suggestions for"
1619

1720

1821
def wrap_line(line: str, width: int = 72) -> list[str]:
@@ -73,62 +76,104 @@ class CommitTextArea(TextArea):
7376
def __init__(self, *args, **kwargs):
7477
super().__init__(*args, **kwargs)
7578
self._last_body_text = ""
79+
self._spell_cache = SpellCheckCache()
80+
self.spellcheck_enabled = True
7681

7782
def render_line(self, y: int) -> Strip:
78-
"""Render a line with custom highlighting for title overflow."""
83+
"""Render a line with custom highlighting for title overflow and misspelled words."""
7984
strip = super().render_line(y)
85+
lines = self.text.split("\n")
8086

81-
# Only highlight overflow on the first line (title)
82-
if y == 0:
83-
lines = self.text.split("\n")
84-
if lines:
85-
title = lines[0]
86-
if len(title) > TITLE_MAX_LENGTH:
87-
strip = self._highlight_title_overflow(strip, title)
87+
# Highlight overflow on the first line (title)
88+
if y == 0 and lines:
89+
title = lines[0]
90+
if len(title) > TITLE_MAX_LENGTH:
91+
strip = self._apply_char_styles(
92+
strip, title, Style(color="red", bold=True), lambda pos: pos >= TITLE_MAX_LENGTH
93+
)
94+
95+
# Underline misspelled words
96+
if self.spellcheck_enabled and y < len(lines):
97+
line_text = lines[y]
98+
spans = self._spell_cache.get_misspelled_spans(y, line_text)
99+
if spans:
100+
styled_positions = set()
101+
for start, end in spans:
102+
for i in range(start, end):
103+
styled_positions.add(i)
104+
strip = self._apply_char_styles(
105+
strip, line_text, Style(underline=True), lambda pos: pos in styled_positions
106+
)
88107

89108
return strip
90109

91110
@staticmethod
92-
def _highlight_title_overflow(strip: Strip, title: str) -> Strip:
93-
"""Apply warning highlighting to title characters beyond 50."""
94-
warning_style = Style(color="red", bold=True)
95-
111+
def _apply_char_styles(
112+
strip: Strip,
113+
line_text: str,
114+
extra_style: Style,
115+
should_style: Callable[[int], bool],
116+
) -> Strip:
117+
"""Apply extra_style to characters where should_style(char_position) is True."""
96118
segments = list(strip)
97119
new_segments = []
98-
99120
char_count = 0
100-
title_started = False
121+
content_started = False
101122

102123
for segment in segments:
103124
text = segment.text
104125
style = segment.style
105126

106-
if not title_started:
107-
if text and title and text.strip() and title.startswith(text.strip()[:5]):
108-
title_started = True
127+
if not content_started:
128+
if text and line_text and text.strip() and line_text.startswith(text.strip()[:5]):
129+
content_started = True
109130

110-
if title_started and text:
111-
new_text_normal = ""
112-
new_text_warning = ""
131+
if content_started and text:
132+
normal = ""
133+
styled = ""
113134

114135
for char in text:
115-
if char_count < TITLE_MAX_LENGTH:
116-
new_text_normal += char
136+
if should_style(char_count):
137+
if normal:
138+
new_segments.append(Segment(normal, style))
139+
normal = ""
140+
styled += char
117141
else:
118-
new_text_warning += char
142+
if styled:
143+
combined = style + extra_style if style else extra_style
144+
new_segments.append(Segment(styled, combined))
145+
styled = ""
146+
normal += char
119147
char_count += 1
120148

121-
if new_text_normal:
122-
new_segments.append(Segment(new_text_normal, style))
123-
if new_text_warning:
124-
# Combine existing style with warning style
125-
combined_style = style + warning_style if style else warning_style
126-
new_segments.append(Segment(new_text_warning, combined_style))
149+
if normal:
150+
new_segments.append(Segment(normal, style))
151+
if styled:
152+
combined = style + extra_style if style else extra_style
153+
new_segments.append(Segment(styled, combined))
127154
else:
128155
new_segments.append(segment)
129156

130157
return Strip(new_segments, strip.cell_length)
131158

159+
def get_word_at_cursor(self) -> str | None:
160+
"""Get the word at the current cursor position, or None."""
161+
if not self.spellcheck_enabled:
162+
return None
163+
164+
row, col = self.cursor_location
165+
lines = self.text.split("\n")
166+
if row >= len(lines):
167+
return None
168+
169+
line = lines[row]
170+
for match in WORD_PATTERN.finditer(line):
171+
if match.start() <= col <= match.end():
172+
word = match.group().strip("'")
173+
return word if word else None
174+
175+
return None
176+
132177
def wrap_current_body_line(self) -> None:
133178
"""Wrap the current line if it's a body line (line 3+) and exceeds 72 chars."""
134179
cursor_row, cursor_col = self.cursor_location
@@ -165,8 +210,21 @@ def wrap_current_body_line(self) -> None:
165210
new_col = cursor_col
166211

167212
self.load_text(new_text)
213+
self.invalidate_spell_cache()
168214
self.cursor_location = (new_row, new_col)
169215

216+
def invalidate_spell_cache(self) -> None:
217+
"""Clear the spellcheck line cache."""
218+
self._spell_cache.invalidate_all()
219+
220+
def get_misspelled_spans(self, row: int, line_text: str) -> list[tuple[int, int]]:
221+
"""Return misspelled word spans for a line."""
222+
return self._spell_cache.get_misspelled_spans(row, line_text)
223+
224+
def get_spell_suggestions(self, word: str) -> list[str]:
225+
"""Return spelling suggestions for a word."""
226+
return self._spell_cache.get_suggestions(word)
227+
170228
def get_title_length(self) -> int:
171229
"""Get the length of the title (first line)."""
172230
lines = self.text.split("\n")
@@ -204,7 +262,7 @@ def update_status(self, line: int, col: int, title_length: int, dirty: bool) ->
204262
title_display = f"Title: {title_length}"
205263

206264
left = f"Ln {line}, Col {col} | {title_display}{dirty_marker}"
207-
hints = "^S Save ^Q Quit ^O Sign-off"
265+
hints = "^S Save ^Q Quit ^O Sign-off ^L Spellcheck"
208266
left_width = len(Text.from_markup(left).plain)
209267
# Account for padding on both sides
210268
gap = (self.size.width - 2) - left_width - len(hints)
@@ -265,6 +323,7 @@ class CommitEditorApp(App):
265323
Binding("ctrl+s", "save", "Save", show=True),
266324
Binding("ctrl+q", "quit_app", "Quit", show=True),
267325
Binding("ctrl+o", "toggle_signoff", "Sign-off", show=True),
326+
Binding("ctrl+l", "toggle_spellcheck", "Spellcheck", show=True),
268327
]
269328

270329
DEFAULT_CSS = """
@@ -283,6 +342,7 @@ def __init__(self, filename: Path):
283342
self.dirty = False
284343
self._original_content = ""
285344
self._prompt_mode: str | None = None # Track active prompt type
345+
self._spell_timer = None
286346

287347
def compose(self) -> ComposeResult:
288348
yield CommitTextArea(id="editor", show_line_numbers=True, highlight_cursor_line=True)
@@ -296,6 +356,7 @@ def on_mount(self) -> None:
296356
content = self.filename.read_text()
297357
self._original_content = content
298358
editor.load_text(content)
359+
editor.invalidate_spell_cache()
299360
editor.focus()
300361

301362
self._update_status_bar()
@@ -368,6 +429,7 @@ def on_editor_changed(self, event: CommitTextArea.Changed) -> None:
368429
def on_selection_changed(self, event: CommitTextArea.SelectionChanged) -> None:
369430
"""Update status bar on cursor movement."""
370431
self._update_status_bar()
432+
self._schedule_spell_suggestions()
371433

372434
def _update_status_bar(self) -> None:
373435
"""Update the status bar with current state."""
@@ -475,6 +537,7 @@ def action_toggle_signoff(self) -> None:
475537
cursor_pos = editor.cursor_location
476538

477539
editor.load_text(new_text)
540+
editor.invalidate_spell_cache()
478541

479542
# Restore cursor position if possible
480543
new_lines = new_text.split("\n")
@@ -485,3 +548,56 @@ def action_toggle_signoff(self) -> None:
485548
editor.cursor_location = (new_row, new_col)
486549

487550
self._update_status_bar()
551+
552+
def action_toggle_spellcheck(self) -> None:
553+
"""Toggle spellcheck on/off."""
554+
editor = self.query_one("#editor", CommitTextArea)
555+
editor.spellcheck_enabled = not editor.spellcheck_enabled
556+
message_bar = self.query_one("#message", MessageBar)
557+
558+
if editor.spellcheck_enabled:
559+
message_bar.show_message("Spellcheck enabled")
560+
else:
561+
message_bar.show_message("Spellcheck disabled")
562+
563+
# Force re-render to update underlines
564+
editor.refresh()
565+
566+
def _schedule_spell_suggestions(self) -> None:
567+
"""Debounce spell suggestion updates to avoid blocking during rapid cursor movement."""
568+
if self._spell_timer is not None:
569+
self._spell_timer.stop()
570+
self._spell_timer = self.set_timer(0.15, self._update_spell_suggestions)
571+
572+
def _update_spell_suggestions(self) -> None:
573+
"""Show spelling suggestions in MessageBar when cursor is on a misspelled word."""
574+
if self._prompt_mode is not None:
575+
return
576+
577+
editor = self.query_one("#editor", CommitTextArea)
578+
message_bar = self.query_one("#message", MessageBar)
579+
580+
if not editor.spellcheck_enabled:
581+
if message_bar.message.startswith(_SUGGESTION_PREFIX):
582+
message_bar.clear()
583+
return
584+
585+
word = editor.get_word_at_cursor()
586+
if word:
587+
row, col = editor.cursor_location
588+
lines = editor.text.split("\n")
589+
if row < len(lines):
590+
spans = editor.get_misspelled_spans(row, lines[row])
591+
on_misspelled = any(start <= col < end for start, end in spans)
592+
if on_misspelled:
593+
suggestions = editor.get_spell_suggestions(word)
594+
if suggestions:
595+
suggestion_text = ", ".join(suggestions)
596+
message_bar.show_message(
597+
f"{_SUGGESTION_PREFIX} '{word}': {suggestion_text}"
598+
)
599+
return
600+
601+
# Clear only if currently showing a spell suggestion
602+
if message_bar.message.startswith(_SUGGESTION_PREFIX):
603+
message_bar.clear()

src/commit_editor/spelling.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import re
2+
import threading
3+
4+
from spellchecker import SpellChecker
5+
6+
WORD_PATTERN = re.compile(r"[a-zA-Z']+")
7+
8+
9+
class SpellCheckCache:
10+
"""Spellcheck cache with lazy background dictionary loading."""
11+
12+
def __init__(self):
13+
self._spell: SpellChecker | None = None
14+
self._line_cache: dict[tuple[int, str], list[tuple[int, int]]] = {}
15+
self._suggestion_cache: dict[str, list[str]] = {}
16+
self._load_thread = threading.Thread(target=self._load_dictionary, daemon=True)
17+
self._load_thread.start()
18+
19+
def _load_dictionary(self) -> None:
20+
self._spell = SpellChecker()
21+
22+
def get_misspelled_spans(self, line_num: int, line_text: str) -> list[tuple[int, int]]:
23+
"""Return (start_col, end_col) spans of misspelled words on a line."""
24+
if self._spell is None:
25+
return []
26+
27+
key = (line_num, line_text)
28+
if key in self._line_cache:
29+
return self._line_cache[key]
30+
31+
# Skip comment lines
32+
if line_text.lstrip().startswith("#"):
33+
self._line_cache[key] = []
34+
return []
35+
36+
spans = []
37+
words_with_positions = []
38+
39+
for match in WORD_PATTERN.finditer(line_text):
40+
raw_word = match.group()
41+
# Strip leading/trailing apostrophes
42+
stripped = raw_word.strip("'")
43+
if not stripped or len(stripped) == 1:
44+
continue
45+
46+
# Calculate offset from stripping leading apostrophes
47+
leading = len(raw_word) - len(raw_word.lstrip("'"))
48+
start = match.start() + leading
49+
end = start + len(stripped)
50+
words_with_positions.append((stripped, start, end))
51+
52+
if words_with_positions:
53+
just_words = [w for w, _, _ in words_with_positions]
54+
misspelled = self._spell.unknown(just_words)
55+
56+
for word, start, end in words_with_positions:
57+
if word.lower() in misspelled:
58+
spans.append((start, end))
59+
60+
self._line_cache[key] = spans
61+
return spans
62+
63+
def get_suggestions(self, word: str, max_count: int = 5) -> list[str]:
64+
"""Return top spelling suggestions for a word."""
65+
if self._spell is None:
66+
return []
67+
68+
cache_key = word.lower()
69+
if cache_key in self._suggestion_cache:
70+
return self._suggestion_cache[cache_key][:max_count]
71+
72+
candidates = self._spell.candidates(word)
73+
if not candidates:
74+
self._suggestion_cache[cache_key] = []
75+
return []
76+
77+
# Sort by word frequency, then alphabetically
78+
scored = []
79+
for candidate in candidates:
80+
frequency = self._spell.word_usage_frequency(candidate)
81+
scored.append((candidate, frequency))
82+
scored.sort(key=lambda x: (-x[1], x[0]))
83+
84+
result = [c for c, _ in scored]
85+
self._suggestion_cache[cache_key] = result
86+
return result[:max_count]
87+
88+
def invalidate_all(self) -> None:
89+
"""Clear the entire line cache."""
90+
self._line_cache.clear()

0 commit comments

Comments
 (0)