1# -*- coding: utf-8 -*- 2 3# Copyright (C) 2005 Osmo Salomaa 4# 5# This program is free software: you can redistribute it and/or modify 6# it under the terms of the GNU General Public License as published by 7# the Free Software Foundation, either version 3 of the License, or 8# (at your option) any later version. 9# 10# This program is distributed in the hope that it will be useful, 11# but WITHOUT ANY WARRANTY; without even the implied warranty of 12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13# GNU General Public License for more details. 14# 15# You should have received a copy of the GNU General Public License 16# along with this program. If not, see <http://www.gnu.org/licenses/>. 17 18"""Dialog for checking and correcting spelling.""" 19 20import aeidon 21import gaupol 22import os 23 24from aeidon.i18n import _ 25from gi.repository import Gtk 26from gi.repository import Pango 27 28__all__ = ("SpellCheckDialog",) 29 30 31class SpellCheckDialog(gaupol.BuilderDialog): 32 33 """ 34 Dialog for checking and correcting spelling. 35 36 :cvar _max_replacements: Maximum amount of replacements to save to file 37 :cvar _personal_dir: Directory for user replacement files 38 :ivar _checker: :class:`enchant.Checker` instance used 39 :ivar _doc: :attr:`aeidon.documents` item currectly being checked 40 :ivar _entry_handler: Handler for replacement entry's "changed" signal 41 :ivar _language: Language code for :attr:`_checker` 42 :ivar _new_rows: List of rows in the current page with changed texts 43 :ivar _new_texts: List of changed texts in the current page 44 :ivar _page: :class:`gaupol.Page` instance currectly being checked 45 :ivar _pager: Iterator to iterate over all target pages 46 :ivar _replacements: List of misspelled words and their replacements 47 :ivar _row: Row currently being checked 48 """ 49 _max_replacements = 10000 50 _personal_dir = os.path.join(aeidon.CONFIG_HOME_DIR, "spell-check") 51 52 _widgets = ( 53 "add_button", 54 "edit_button", 55 "entry", 56 "ignore_all_button", 57 "ignore_button", 58 "join_back_button", 59 "join_forward_button", 60 "replace_all_button", 61 "replace_button", 62 "grid", 63 "text_view", 64 "tree_view", 65 ) 66 67 def __init__(self, parent, application): 68 """ 69 Initialize a :class:`SpellCheckDialog` instance. 70 71 Raise :exc:`ValueError` if dictionary initialization fails. 72 """ 73 gaupol.BuilderDialog.__init__(self, "spell-check-dialog.ui") 74 self.application = application 75 self._checker = None 76 self._doc = None 77 self._entry_handler = None 78 self._language = gaupol.conf.spell_check.language 79 self._language_name = aeidon.locales.code_to_name(self._language) 80 self._new_rows = [] 81 self._new_texts = [] 82 self._page = None 83 self._pager = None 84 self._replacements = [] 85 self._row = None 86 self._init_dialog(parent) 87 self._init_spell_check() 88 self._init_widgets() 89 self._init_sensitivities() 90 self.resize(*gaupol.conf.spell_check.size) 91 self._start() 92 93 def _advance(self): 94 """Advance to the next spelling error.""" 95 while True: 96 try: 97 # Advance to the next spelling error in the current text. 98 return self._advance_current() 99 except StopIteration: 100 # Save the current text if changes were made. 101 text = self._checker.get_text() 102 subtitle = self._page.project.subtitles[self._row] 103 if text != subtitle.get_text(self._doc): 104 self._new_rows.append(self._row) 105 self._new_texts.append(text) 106 # Move to the next row in the current page, move to the next page 107 # in the sequence of target pages or end when all pages checked. 108 try: 109 self._advance_row() 110 except StopIteration: 111 try: 112 next(self._pager) 113 except StopIteration: 114 break 115 self._set_done() 116 117 def _advance_current(self): 118 """ 119 Advance to the next spelling error in the current text. 120 121 Raise :exc:`StopIteration` when no more errors in the current text. 122 """ 123 next(self._checker) 124 col = self._page.document_to_text_column(self._doc) 125 self._page.view.set_focus(self._row, col) 126 self._page.view.scroll_to_row(self._row) 127 text = self._checker.get_text() 128 text_buffer = self._text_view.get_buffer() 129 text_buffer.set_text(text) 130 a = self._checker.wordpos 131 z = a + len(self._checker.word) 132 start = text_buffer.get_iter_at_offset(a) 133 end = text_buffer.get_iter_at_offset(z) 134 text_buffer.apply_tag_by_name("misspelled", start, end) 135 mark = text_buffer.create_mark(None, end, True) 136 self._text_view.scroll_to_mark(mark=mark, 137 within_margin=0, 138 use_align=False, 139 xalign=0.5, 140 yalign=0.5) 141 142 leading = self._checker.leading_context(1) 143 trailing = self._checker.trailing_context(1) 144 self._join_back_button.set_sensitive(leading.isspace()) 145 self._join_forward_button.set_sensitive(trailing.isspace()) 146 self._set_entry_text("") 147 self._populate_tree_view(self._checker.suggest()) 148 self._tree_view.grab_focus() 149 150 def _advance_row(self): 151 """ 152 Advance to the next subtitle and feed its text to the spell-checker. 153 154 Raise :exc:`StopIteration` when no more subtitles in the current page. 155 """ 156 self._row += 1 157 if self._row >= len(self._page.project.subtitles): 158 raise StopIteration 159 subtitle = self._page.project.subtitles[self._row] 160 text = subtitle.get_text(self._doc) 161 self._checker.set_text(text) 162 163 def _get_next_page(self): 164 """Return the next page to check spelling in.""" 165 field = gaupol.conf.spell_check.field 166 doc = gaupol.util.text_field_to_document(field) 167 target = gaupol.conf.spell_check.target 168 for page in self.application.get_target_pages(target): 169 self.application.set_current_page(page) 170 self._page = page 171 self._doc = doc 172 self._row = -1 173 self._advance_row() 174 yield page 175 self._register_changes() 176 177 def _init_checker(self): 178 """ 179 Initialize spell-checker and its dictionary. 180 181 Raise :exc:`ValueError` if dictionary initialization fails. 182 """ 183 try: 184 import enchant.checker 185 dictionary = enchant.Dict(self._language) 186 # Sometimes enchant will initialize a dictionary that will not 187 # actually work when trying to use it, hence check something. 188 dictionary.check("gaupol") 189 self._checker = enchant.checker.SpellChecker(dictionary, "") 190 except enchant.Error as error: 191 self._show_error_dialog(str(error)) 192 raise ValueError("Dictionary initialization failed for language {}" 193 .format(repr(self._language))) 194 195 def _init_dialog(self, parent): 196 """Initialize the dialog.""" 197 self.set_default_response(Gtk.ResponseType.CLOSE) 198 self.set_transient_for(parent) 199 self.set_modal(True) 200 201 def _init_replacements(self): 202 """Read misspelled words and their replacements from file.""" 203 basename = "{}.repl".format(gaupol.conf.spell_check.language) 204 path = os.path.join(self._personal_dir, basename) 205 if not os.path.isfile(path): return 206 with aeidon.util.silent(IOError, OSError, tb=True): 207 lines = aeidon.util.readlines(path) 208 for line in aeidon.util.get_unique(lines): 209 misspelled, correct = line.strip().split("|", 1) 210 self._replacements.append((misspelled, correct)) 211 212 def _init_sensitivities(self): 213 """Initialize widget sensitivities.""" 214 self._join_back_button.set_sensitive(False) 215 self._join_forward_button.set_sensitive(False) 216 self._replace_all_button.set_sensitive(False) 217 self._replace_button.set_sensitive(False) 218 219 def _init_spell_check(self): 220 """ 221 Initialize spell-check components and related widgets. 222 223 Raise :exc:`ValueError` if dictionary initialization fails. 224 """ 225 aeidon.util.makedirs(self._personal_dir) 226 self._init_checker() 227 self._init_replacements() 228 229 def _init_tree_view(self): 230 """Initialize the suggestion tree view.""" 231 selection = self._tree_view.get_selection() 232 selection.set_mode(Gtk.SelectionMode.SINGLE) 233 selection.connect("changed", self._on_tree_view_selection_changed) 234 store = Gtk.ListStore(str) 235 self._tree_view.set_model(store) 236 column = Gtk.TreeViewColumn("", Gtk.CellRendererText(), text=0) 237 self._tree_view.append_column(column) 238 239 def _init_widgets(self): 240 """Initialize widget properties.""" 241 self._init_tree_view() 242 gaupol.style.use_font(self._entry, "custom") 243 gaupol.style.use_font(self._text_view, "custom") 244 gaupol.style.use_font(self._tree_view, "custom") 245 with aeidon.util.silent(AttributeError): 246 # Top and bottom margins available since GTK+ 3.18. 247 self._text_view.set_top_margin(6) 248 self._text_view.set_bottom_margin(6) 249 text_buffer = self._text_view.get_buffer() 250 text_buffer.create_tag("misspelled", weight=Pango.Weight.BOLD) 251 scale = gaupol.util.scale_to_size 252 scale(self._text_view, nchar=55, nlines=4, font="custom") 253 scale(self._tree_view, nchar=20, nlines=6, font="custom") 254 self._entry_handler = self._entry.connect( 255 "changed", self._on_entry_changed) 256 257 def _on_add_button_clicked(self, *args): 258 """Add the current word to the user dictionary.""" 259 self._checker.dict.add(self._checker.word) 260 self._advance() 261 262 def _on_edit_button_clicked(self, *args): 263 """Edit the current text in a separate dialog.""" 264 text = self._checker.get_text() 265 dialog = gaupol.TextEditDialog(self._dialog, text) 266 response = gaupol.util.run_dialog(dialog) 267 text = dialog.get_text() 268 dialog.destroy() 269 if response != Gtk.ResponseType.OK: return 270 self._checker.set_text(text) 271 self._advance() 272 273 def _on_entry_changed(self, entry): 274 """Populate suggestions based on text in `entry`.""" 275 word = entry.get_text() 276 suggestions = (self._checker.suggest(word) if word else []) 277 self._populate_tree_view(suggestions, select=False) 278 self._replace_button.set_sensitive(bool(word)) 279 self._replace_all_button.set_sensitive(bool(word)) 280 281 def _on_ignore_all_button_clicked(self, *args): 282 """Ignore all instances of the current word.""" 283 self._checker.ignore_always() 284 self._advance() 285 286 def _on_ignore_button_clicked(self, *args): 287 """Ignore the current word.""" 288 self._advance() 289 290 def _on_join_back_button_clicked(self, *args): 291 """Join the current word with the preceding word.""" 292 text = self._checker.get_text() 293 a = self._checker.wordpos 294 text = text[:a-1] + text[a:] 295 self._checker.set_text(text) 296 self._advance() 297 298 def _on_join_forward_button_clicked(self, *args): 299 """Join the current word with the following word.""" 300 text = self._checker.get_text() 301 z = self._checker.wordpos + len(self._checker.word) 302 text = text[:z] + text [z+1:] 303 self._checker.set_text(text) 304 self._advance() 305 306 def _on_replace_all_button_clicked(self, *args): 307 """Replace all instances of the current word.""" 308 misspelled = self._checker.word 309 correct = self._entry.get_text() 310 self._replacements.append((misspelled, correct)) 311 self._checker.replace_always(correct) 312 self._advance() 313 314 def _on_replace_button_clicked(self, *args): 315 """Replace the current word.""" 316 misspelled = self._checker.word 317 correct = self._entry.get_text() 318 self._replacements.append((misspelled, correct)) 319 self._checker.replace(correct) 320 self._advance() 321 322 def _on_response(self, dialog, response): 323 """Apply changes to the current page.""" 324 self._register_changes() 325 self._save_geometry() 326 self._set_done() 327 328 def _on_tree_view_selection_changed(self, *args): 329 """Copy the selected suggestion into the entry.""" 330 selection = self._tree_view.get_selection() 331 store, itr = selection.get_selected() 332 if itr is None: return 333 path = store.get_path(itr) 334 row = gaupol.util.tree_path_to_row(path) 335 self._set_entry_text(store[row][0]) 336 337 def _populate_tree_view(self, suggestions, select=True): 338 """Populate the tree view with `suggestions`.""" 339 word = self._checker.word 340 repl = reversed(self._replacements) 341 replacements = [x[1] for x in repl if x[0] == word] 342 suggestions = list(replacements) + list(suggestions) 343 suggestions = aeidon.util.get_unique(suggestions) 344 store = self._tree_view.get_model() 345 self._tree_view.set_model(None) 346 store.clear() 347 for suggestion in suggestions: 348 store.append((suggestion,)) 349 self._tree_view.set_model(store) 350 if select and len(store) > 0: 351 self._tree_view.set_cursor(0) 352 self._tree_view.scroll_to_cell(0) 353 354 def _register_changes(self): 355 """Register changes to the current page.""" 356 if not self._new_rows: return 357 self._page.project.replace_texts( 358 self._new_rows, self._doc, self._new_texts) 359 self._page.project.set_action_description( 360 aeidon.registers.DO, _("Spell-checking")) 361 self._new_rows = [] 362 self._new_texts = [] 363 364 def _save_geometry(self): 365 """Save dialog size.""" 366 if self.is_maximized(): return 367 gaupol.conf.spell_check.size = list(self.get_size()) 368 369 def _set_done(self): 370 """Set state of widgets for finished spell-check.""" 371 self._text_view.get_buffer().set_text("") 372 self._set_entry_text("") 373 self._populate_tree_view(()) 374 self._grid.set_sensitive(False) 375 self._write_replacements() 376 377 def _set_entry_text(self, word): 378 """Set `word` to the entry with its "changed" handler blocked.""" 379 self._entry.handler_block(self._entry_handler) 380 self._entry.set_text(word) 381 self._entry.handler_unblock(self._entry_handler) 382 self._replace_button.set_sensitive(bool(word)) 383 self._replace_all_button.set_sensitive(bool(word)) 384 385 def _show_error_dialog(self, message): 386 """Show an error dialog after failing to load dictionary.""" 387 title = _('Failed to load dictionary for language "{}"') 388 title = title.format(self._language_name) 389 dialog = gaupol.ErrorDialog(self._dialog, title, message) 390 dialog.add_button(_("_OK"), Gtk.ResponseType.OK) 391 dialog.set_default_response(Gtk.ResponseType.OK) 392 gaupol.util.flash_dialog(dialog) 393 394 def _start(self): 395 """Start checking the spelling.""" 396 self._pager = self._get_next_page() 397 next(self._pager) 398 self._advance() 399 400 def _write_replacements(self): 401 """Write misspelled words and their replacements to file.""" 402 if not self._replacements: return 403 self._replacements = aeidon.util.get_unique( 404 self._replacements, keep_last=True) 405 basename = "{}.repl".format(self._language) 406 path = os.path.join(self._personal_dir, basename) 407 if len(self._replacements) > self._max_replacements: 408 # Discard the oldest of replacements. 409 self._replacements[-self._max_replacements:] 410 text = "\n".join("|".join(x) for x in self._replacements) + "\n" 411 with aeidon.util.silent(IOError, OSError, tb=True): 412 aeidon.util.write(path, text) 413