1+ from collections .abc import Callable
12from pathlib import Path
23
34from rich .segment import Segment
1011from textual .widgets import Static , TextArea
1112
1213from commit_editor .git import get_signed_off_by
14+ from commit_editor .spelling import WORD_PATTERN , SpellCheckCache
1315
1416TITLE_MAX_LENGTH = 50
1517BODY_MAX_LENGTH = 72
18+ _SUGGESTION_PREFIX = "Suggestions for"
1619
1720
1821def 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 ()
0 commit comments