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