1# -*- coding: utf-8 -*-
2
3# Copyright (C) 2007 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"""Assistant to guide through multiple text correction tasks."""
19
20import aeidon
21import gaupol
22import os
23
24from aeidon.i18n   import _, n_
25from gi.repository import Gdk
26from gi.repository import GLib
27from gi.repository import GObject
28from gi.repository import Gtk
29from gi.repository import Pango
30
31__all__ = ("TextAssistant", "TextAssistantPage")
32
33
34class TextAssistantPage(Gtk.Box):
35
36    """
37    Baseclass for pages of :class:`TextAssistant`.
38
39   :ivar description: One-line description used in the introduction page
40   :ivar handle: Unique unlocalized name for internal references
41   :ivar page_title: Short string used as configuration page title
42   :ivar page_type: A :class:`Gtk.AssistantPageType` constant
43   :ivar title: Short title used in the introduction page
44
45    Of these attributes, :attr:`description`, :attr:`handle` and :attr:`title`
46    are only required for pages of type :attr:`Gtk.AssistantPageType.CONTENT`.
47    """
48
49    def __init__(self, assistant):
50        """Initialize a :class:`TextAssistantPage` instance."""
51        GObject.GObject.__init__(self, orientation=Gtk.Orientation.VERTICAL)
52        self.assistant = assistant
53        self.description = None
54        self.handle = None
55        self.page_title = None
56        self.page_type = None
57        self.title = None
58
59
60class BuilderPage(TextAssistantPage):
61
62    """Baseclass for pages of :class:`TextAssistant` built with GtkBuilder."""
63
64    _widgets = ()
65
66    def __init__(self, assistant, basename):
67        """Initialize a :class:`BuilderPage` instance."""
68        TextAssistantPage.__init__(self, assistant)
69        self._builder = Gtk.Builder()
70        self._builder.set_translation_domain("gaupol")
71        self._builder.add_from_file(os.path.join(
72            aeidon.DATA_DIR, "ui", "text-assistant", basename))
73        self._builder.connect_signals(self)
74        self._set_attributes(self._widgets)
75        container = self._builder.get_object("main_container")
76        window = container.get_parent()
77        window.remove(container)
78        self.add(container)
79        gaupol.util.idle_add(window.destroy)
80
81    def _set_attributes(self, widgets):
82        """Assign all names in `widgets` as attributes of `self`."""
83        for name in widgets:
84            widget = self._builder.get_object(name)
85            setattr(self, "_{}".format(name), widget)
86
87
88class IntroductionPage(BuilderPage):
89
90    """Page for listing all text correction tasks."""
91
92    _widgets = ("columns_combo", "subtitles_combo", "tree_view")
93
94    def __init__(self, assistant):
95        """Initialize a :class:`IntroductionPage` instance."""
96        BuilderPage.__init__(self, assistant, "introduction-page.ui")
97        # TRANSLATORS: Keep these page titles short, since they
98        # affect the width of the text correction assistant sidebar.
99        self.page_title = _("Tasks and Target")
100        self.page_type = Gtk.AssistantPageType.INTRO
101        self._init_columns_combo()
102        self._init_subtitles_combo()
103        self._init_tree_view()
104        self._init_values()
105
106    def get_field(self):
107        """Return the selected field."""
108        index = self._columns_combo.get_active()
109        return [gaupol.fields.MAIN_TEXT,
110                gaupol.fields.TRAN_TEXT][index]
111
112    def get_selected_pages(self):
113        """Return selected content pages."""
114        store = self._tree_view.get_model()
115        return [x[0] for x in store if x[1]]
116
117    def get_target(self):
118        """Return the selected target."""
119        index = self._subtitles_combo.get_active()
120        return [gaupol.targets.SELECTED,
121                gaupol.targets.CURRENT,
122                gaupol.targets.ALL][index]
123
124    def _init_columns_combo(self):
125        """Initalize the columns target combo box."""
126        store = Gtk.ListStore(str)
127        self._columns_combo.set_model(store)
128        store.append((_("Correct texts in the text column"),))
129        store.append((_("Correct texts in the translation column"),))
130        renderer = Gtk.CellRendererText()
131        self._columns_combo.pack_start(renderer, expand=True)
132        self._columns_combo.add_attribute(renderer, "text", 0)
133
134    def _init_subtitles_combo(self):
135        """Initalize the subtitles target combo box."""
136        store = Gtk.ListStore(str)
137        self._subtitles_combo.set_model(store)
138        store.append((_("Correct texts in selected subtitles"),))
139        store.append((_("Correct texts in current project"),))
140        store.append((_("Correct texts in all open projects"),))
141        renderer = Gtk.CellRendererText()
142        self._subtitles_combo.pack_start(renderer, expand=True)
143        self._subtitles_combo.add_attribute(renderer, "text", 0)
144
145    def _init_tree_view(self):
146        """Initialize the tree view of tasks."""
147        store = Gtk.ListStore(object, bool, str)
148        self._tree_view.set_model(store)
149        selection = self._tree_view.get_selection()
150        selection.set_mode(Gtk.SelectionMode.SINGLE)
151        renderer = Gtk.CellRendererToggle()
152        renderer.props.activatable = True
153        renderer.props.xpad = 6
154        renderer.connect("toggled", self._on_tree_view_cell_toggled)
155        column = Gtk.TreeViewColumn("", renderer, active=1)
156        self._tree_view.append_column(column)
157        renderer = Gtk.CellRendererText()
158        renderer.props.ellipsize = Pango.EllipsizeMode.END
159        column = Gtk.TreeViewColumn("", renderer, markup=2)
160        self._tree_view.append_column(column)
161
162    def _init_values(self):
163        """Initialize default values for widgets."""
164        self._columns_combo.set_active({
165            gaupol.fields.MAIN_TEXT: 0,
166            gaupol.fields.TRAN_TEXT: 1,
167        }[gaupol.conf.text_assistant.field])
168        self._subtitles_combo.set_active({
169            gaupol.targets.SELECTED: 0,
170            gaupol.targets.CURRENT: 1,
171            gaupol.targets.ALL: 2,
172        }[gaupol.conf.text_assistant.target])
173
174    def _on_columns_combo_changed(self, *args):
175        """Save the selected field."""
176        gaupol.conf.text_assistant.field = self.get_field()
177
178    def _on_subtitles_combo_changed(self, *args):
179        """Save the selected target."""
180        gaupol.conf.text_assistant.target = self.get_target()
181
182    def _on_tree_view_cell_toggled(self, renderer, path):
183        """Toggle and save task check button value."""
184        store = self._tree_view.get_model()
185        store[path][1] = not store[path][1]
186        store[path][0].set_visible(store[path][1])
187        pages = [x.handle for x in self.get_selected_pages()]
188        gaupol.conf.text_assistant.pages = pages
189
190    def populate_tree_view(self, content_pages):
191        """Populate the tree view with tasks from `content_pages`."""
192        self._tree_view.get_model().clear()
193        store = self._tree_view.get_model()
194        pages = gaupol.conf.text_assistant.pages
195        for page in content_pages:
196            title = GLib.markup_escape_text(page.title)
197            description = GLib.markup_escape_text(page.description)
198            markup = "<b>{}</b>\n{}".format(title, description)
199            page.set_visible(page.handle in pages)
200            store.append((page, page.handle in pages, markup))
201        self._tree_view.get_selection().unselect_all()
202
203
204class LocalePage(BuilderPage):
205
206    """Page with script, language and coutry based pattern selection."""
207
208    _ui_file_basename = NotImplementedError
209
210    _widgets = (
211        "country_combo",
212        "country_label",
213        "language_combo",
214        "language_label",
215        "script_combo",
216        "script_label",
217        "tree_view",
218    )
219
220    def __init__(self, assistant):
221        """Initialize a :class:`LocalePage` instance."""
222        BuilderPage.__init__(self, assistant, self._ui_file_basename)
223        self.conf = None
224        self._init_attributes()
225        self._init_tree_view()
226        self._init_combo_boxes()
227        self._init_values()
228
229    def correct_texts(self, project, indices, doc):
230        """Correct texts in `project`."""
231        raise NotImplementedError
232
233    def _filter_patterns(self, patterns):
234        """Return a subset of `patterns` to show."""
235        return patterns
236
237    def _get_country(self):
238        """Return the selected country or ``None``."""
239        if not self._country_combo.get_sensitive(): return None
240        index = self._country_combo.get_active()
241        if index < 0: return None
242        store = self._country_combo.get_model()
243        value = store[index][0]
244        return (None if value == "other" else value)
245
246    def _get_language(self):
247        """Return the selected language or ``None``."""
248        if not self._language_combo.get_sensitive(): return None
249        index = self._language_combo.get_active()
250        if index < 0: return None
251        store = self._language_combo.get_model()
252        value = store[index][0]
253        return (None if value == "other" else value)
254
255    def _get_script(self):
256        """Return the selected script or ``None``."""
257        if not self._script_combo.get_sensitive(): return None
258        index = self._script_combo.get_active()
259        if index < 0: return None
260        store = self._script_combo.get_model()
261        value = store[index][0]
262        return (None if value == "other" else value)
263
264    def _init_attributes(self):
265        """Initialize values of page attributes."""
266        raise NotImplementedError
267
268    def _init_combo(self, combo_box):
269        """Initialize `combo_box` and populate with `items`."""
270        store = Gtk.ListStore(str, str)
271        combo_box.set_model(store)
272        renderer = Gtk.CellRendererText()
273        combo_box.pack_start(renderer, expand=True)
274        combo_box.add_attribute(renderer, "text", 1)
275        func = gaupol.util.separate_combo
276        combo_box.set_row_separator_func(func, None)
277
278    def _init_combo_boxes(self):
279        """Initialize and populate combo boxes."""
280        self._init_combo(self._script_combo)
281        self._init_combo(self._language_combo)
282        self._init_combo(self._country_combo)
283        self._populate_script_combo()
284        self._populate_language_combo()
285        self._populate_country_combo()
286
287    def _init_tree_view(self):
288        """Initialize the tree view of individual corrections."""
289        store = Gtk.ListStore(object, bool, bool, str)
290        store_filter = store.filter_new()
291        store_filter.set_visible_column(1)
292        self._tree_view.set_model(store_filter)
293        selection = self._tree_view.get_selection()
294        selection.set_mode(Gtk.SelectionMode.SINGLE)
295        renderer = Gtk.CellRendererToggle()
296        renderer.props.activatable = True
297        renderer.props.xpad = 6
298        renderer.connect("toggled", self._on_tree_view_cell_toggled)
299        column = Gtk.TreeViewColumn("", renderer, active=2)
300        self._tree_view.append_column(column)
301        renderer = Gtk.CellRendererText()
302        renderer.props.ellipsize = Pango.EllipsizeMode.END
303        column = Gtk.TreeViewColumn("", renderer, markup=3)
304        self._tree_view.append_column(column)
305
306    def _init_values(self):
307        """Initialize default values for widgets."""
308        pass
309
310    def _on_country_combo_changed(self, combo_box):
311        """Populate the tree view with a subset patterns."""
312        self.conf.country = self._get_country() or ""
313        self._populate_tree_view()
314
315    def _on_language_combo_changed(self, combo_box):
316        """Populate the tree view with a subset patterns."""
317        language = self._get_language()
318        sensitive = language is not None
319        self._populate_country_combo()
320        self._country_combo.set_sensitive(sensitive)
321        self._country_label.set_sensitive(sensitive)
322        self.conf.language = language or ""
323        self._populate_tree_view()
324
325    def _on_script_combo_changed(self, combo_box):
326        """Populate the tree view with a subset patterns."""
327        script = self._get_script()
328        sensitive = script is not None
329        self._populate_language_combo()
330        self._language_combo.set_sensitive(sensitive)
331        self._language_label.set_sensitive(sensitive)
332        language = self._get_language()
333        sensitive = sensitive and language is not None
334        self._populate_country_combo()
335        self._country_combo.set_sensitive(sensitive)
336        self._country_label.set_sensitive(sensitive)
337        self.conf.script = script or ""
338        self._populate_tree_view()
339
340    def _on_tree_view_cell_toggled(self, renderer, path):
341        """Toggle the check button value."""
342        store_filter = self._tree_view.get_model()
343        store = store_filter.get_model()
344        path = Gtk.TreePath.new_from_string(path)
345        path = store_filter.convert_path_to_child_path(path)
346        name = store[path][0].get_name(False)
347        enabled = not store[path][2]
348        for i in range(len(store)):
349            # Toggle all patterns with the same name.
350            if store[i][0].get_name(False) == name:
351                store[i][0].enabled = enabled
352                store[i][2] = enabled
353
354    def _populate_combo(self, combo_box, items, active):
355        """Populate `combo_box` with `items`."""
356        store = combo_box.get_model()
357        combo_box.set_model(None)
358        store.clear()
359        for code, name in items:
360            store.append((code, name))
361        if len(store) > 0:
362            store.append((gaupol.COMBO_SEPARATOR, ""))
363        store.append(("other", _("Other")))
364        combo_box.set_active(len(store) - 1)
365        for i in range(len(store)):
366            if store[i][0] == active and active:
367                combo_box.set_active(i)
368        combo_box.set_model(store)
369
370    def _populate_country_combo(self):
371        """Populate the country combo box."""
372        script = self._get_script()
373        language = self._get_language()
374        codes = self._manager.get_countries(script, language)
375        names = list(map(aeidon.countries.code_to_name, codes))
376        items = [(codes[i], names[i]) for i in range(len(codes))]
377        items.sort(key=lambda x: x[1])
378        self._populate_combo(self._country_combo, items, self.conf.country)
379
380    def _populate_language_combo(self):
381        """Populate the language combo box."""
382        script = self._get_script()
383        codes = self._manager.get_languages(script)
384        names = list(map(aeidon.languages.code_to_name, codes))
385        items = [(codes[i], names[i]) for i in range(len(codes))]
386        items.sort(key=lambda x: x[1])
387        self._populate_combo(self._language_combo, items, self.conf.language)
388
389    def _populate_script_combo(self):
390        """Populate the script combo box."""
391        codes = self._manager.get_scripts()
392        names = list(map(aeidon.scripts.code_to_name, codes))
393        items = [(codes[i], names[i]) for i in range(len(codes))]
394        items.sort(key=lambda x: x[1])
395        self._populate_combo(self._script_combo, items, self.conf.script)
396
397    def _populate_tree_view(self):
398        """Populate the tree view with a subset patterns."""
399        store_filter = self._tree_view.get_model()
400        store = store_filter.get_model()
401        store.clear()
402        script = self._get_script()
403        language = self._get_language()
404        country = self._get_country()
405        patterns = self._manager.get_patterns(script, language, country)
406        patterns = self._filter_patterns(patterns)
407        names_entered = set(())
408        for pattern in patterns:
409            name = pattern.get_name()
410            visible = not name in names_entered
411            names_entered.add(name)
412            name = GLib.markup_escape_text(name)
413            description = pattern.get_description()
414            description = GLib.markup_escape_text(description)
415            markup = "<b>{}</b>\n{}".format(name, description)
416            store.append((pattern, visible, pattern.enabled, markup))
417        self._tree_view.get_selection().unselect_all()
418
419
420class CapitalizationPage(LocalePage):
421
422    """Page for capitalizing texts in subtitles."""
423
424    _ui_file_basename = "capitalization-page.ui"
425
426    def correct_texts(self, project, indices, doc):
427        """Correct texts in `project`."""
428        script = self._get_script()
429        language = self._get_language()
430        country = self._get_country()
431        self._manager.save_config(script, language, country)
432        patterns = self._manager.get_patterns(script, language, country)
433        project.capitalize(indices, doc, patterns)
434
435    def _init_attributes(self):
436        """Initialize values of page attributes."""
437        self._manager = aeidon.PatternManager("capitalization")
438        self.conf = gaupol.conf.capitalization
439        self.description = _("Capitalize texts written in lower case")
440        self.handle = "capitalization"
441        # TRANSLATORS: Keep these page titles short, since they
442        # affect the width of the text correction assistant sidebar.
443        self.page_title = _("Capitalization Patterns")
444        self.page_type = Gtk.AssistantPageType.CONTENT
445        self.title = _("Capitalize texts")
446
447
448class CommonErrorPage(LocalePage):
449
450    """Page for correcting common human and OCR errors."""
451
452    _ui_file_basename = "common-error-page.ui"
453    _widgets = ("human_check", "ocr_check") + LocalePage._widgets
454
455    def correct_texts(self, project, indices, doc):
456        """Correct texts in `project`."""
457        script = self._get_script()
458        language = self._get_language()
459        country = self._get_country()
460        self._manager.save_config(script, language, country)
461        patterns = self._manager.get_patterns(script, language, country)
462        project.correct_common_errors(indices, doc, patterns)
463
464    def _init_attributes(self):
465        """Initialize values of page attributes."""
466        self._manager = aeidon.PatternManager("common-error")
467        self.conf = gaupol.conf.common_error
468        self.description = _("Correct common errors made by humans or image recognition software")
469        self.handle = "common-error"
470        # TRANSLATORS: Keep these page titles short, since they
471        # affect the width of the text correction assistant sidebar.
472        self.page_title = _("Common Error Patterns")
473        self.page_type = Gtk.AssistantPageType.CONTENT
474        self.title = _("Correct common errors")
475
476    def _filter_patterns(self, patterns):
477        """Return a subset of `patterns` to show."""
478        def use_pattern(pattern):
479            classes = set(pattern.get_field_list("Classes"))
480            return(bool(classes & set(self.conf.classes)))
481        return list(filter(use_pattern, patterns))
482
483    def _init_values(self):
484        """Initialize default values for widgets."""
485        self._human_check.set_active("Human" in self.conf.classes)
486        self._ocr_check.set_active("OCR" in self.conf.classes)
487
488    def _on_human_check_toggled(self, check_button):
489        """Populate the tree view with a subset patterns."""
490        if check_button.get_active():
491            self.conf.classes.append("Human")
492            self.conf.classes = sorted(set(self.conf.classes))
493        elif "Human" in self.conf.classes:
494            self.conf.classes.remove("Human")
495        self._populate_tree_view()
496
497    def _on_ocr_check_toggled(self, check_button):
498        """Populate the tree view with a subset patterns."""
499        if check_button.get_active():
500            self.conf.classes.append("OCR")
501            self.conf.classes = sorted(set(self.conf.classes))
502        elif "OCR" in self.conf.classes:
503            self.conf.classes.remove("OCR")
504        self._populate_tree_view()
505
506
507class HearingImpairedPage(LocalePage):
508
509    """Page for removing hearing impaired parts from subtitles."""
510
511    _ui_file_basename = "hearing-impaired-page.ui"
512
513    def correct_texts(self, project, indices, doc):
514        """Correct texts in `project`."""
515        script = self._get_script()
516        language = self._get_language()
517        country = self._get_country()
518        self._manager.save_config(script, language, country)
519        patterns = self._manager.get_patterns(script, language, country)
520        project.remove_hearing_impaired(indices, doc, patterns)
521
522    def _init_attributes(self):
523        """Initialize values of page attributes."""
524        self._manager = aeidon.PatternManager("hearing-impaired")
525        self.conf = gaupol.conf.hearing_impaired
526        self.description = _("Remove explanatory texts meant for the hearing impaired")
527        self.handle = "hearing-impaired"
528        # TRANSLATORS: Keep these page titles short, since they
529        # affect the width of the text correction assistant sidebar.
530        self.page_title = _("Hearing Impaired Patterns")
531        self.page_type = Gtk.AssistantPageType.CONTENT
532        self.title = _("Remove hearing impaired texts")
533
534
535class JoinSplitWordsPage(BuilderPage):
536
537    """Page for joining or splitting words based on spell-check suggestions."""
538
539    _widgets = ("language_button", "join_check", "split_check")
540
541    def __init__(self, assistant):
542        """Initialize a :class:`JoinSplitWordsPage` instance."""
543        BuilderPage.__init__(self, assistant, "join-split-page.ui")
544        self.description = _("Use spell-check suggestions to fix whitespace detection errors of image recognition software")
545        self.handle = "join-split-words"
546        # TRANSLATORS: Keep these page titles short, since they
547        # affect the width of the text correction assistant sidebar.
548        self.page_title = _("Joining and Splitting Words")
549        self.page_type = Gtk.AssistantPageType.CONTENT
550        self.title = _("Join or Split Words")
551        self._init_values()
552
553    def correct_texts(self, project, indices, doc):
554        """Correct texts in `project`."""
555        import enchant
556        language = gaupol.conf.spell_check.language
557        if gaupol.conf.join_split_words.join:
558            try:
559                project.spell_check_join_words(indices, doc, language)
560            except enchant.Error as error:
561                return self._show_error_dialog(str(error))
562        if gaupol.conf.join_split_words.split:
563            try:
564                project.spell_check_split_words(indices, doc, language)
565            except enchant.Error as error:
566                return self._show_error_dialog(str(error))
567
568    def _init_values(self):
569        """Initialize default values for widgets."""
570        language = gaupol.conf.spell_check.language
571        language = aeidon.locales.code_to_name(language)
572        self._language_button.set_label(language)
573        self._join_check.set_active(gaupol.conf.join_split_words.join)
574        self._split_check.set_active(gaupol.conf.join_split_words.split)
575
576    def _on_join_check_toggled(self, check_button, *args):
577        """Save value of join option."""
578        gaupol.conf.join_split_words.join = check_button.get_active()
579
580    def _on_language_button_clicked(self, button, *args):
581        """Show a language dialog and update `button` label."""
582        gaupol.util.set_cursor_busy(self.assistant)
583        dialog = gaupol.LanguageDialog(self.assistant, False)
584        gaupol.util.set_cursor_normal(self.assistant)
585        gaupol.util.flash_dialog(dialog)
586        language = gaupol.conf.spell_check.language
587        language = aeidon.locales.code_to_name(language)
588        self._language_button.set_label(language)
589
590    def _on_split_check_toggled(self, check_button, *args):
591        """Save value of split option."""
592        gaupol.conf.join_split_words.split = check_button.get_active()
593
594    def _show_error_dialog(self, message):
595        """Show an error dialog after failing to load dictionary."""
596        name = gaupol.conf.spell_check.language
597        name = aeidon.locales.code_to_name(name)
598        title = _('Failed to load dictionary for language "{}"').format(name)
599        dialog = gaupol.ErrorDialog(self.get_parent(), title, message)
600        dialog.add_button(_("_OK"), Gtk.ResponseType.OK)
601        dialog.set_default_response(Gtk.ResponseType.OK)
602        gaupol.util.flash_dialog(dialog)
603
604
605class LineBreakPage(LocalePage):
606
607    """Page for breaking text into lines."""
608
609    _ui_file_basename = "line-break-page.ui"
610
611    def correct_texts(self, project, indices, doc):
612        """Correct texts in `project`."""
613        script = self._get_script()
614        language = self._get_language()
615        country = self._get_country()
616        self._manager.save_config(script, language, country)
617        patterns = self._manager.get_patterns(script, language, country)
618        length_func = gaupol.ruler.get_length_function(self.conf.length_unit)
619        skip = self.conf.use_skip_max_length or self.conf.use_skip_max_lines
620        project.break_lines(indices=indices,
621                            doc=doc,
622                            patterns=patterns,
623                            length_func=length_func,
624                            max_length=self.conf.max_length,
625                            max_lines=self.conf.max_lines,
626                            skip=skip,
627                            max_skip_length=self._max_skip_length,
628                            max_skip_lines=self._max_skip_lines)
629
630    def _init_attributes(self):
631        """Initialize values of page attributes."""
632        self._manager = aeidon.PatternManager("line-break")
633        self.conf = gaupol.conf.line_break
634        self.description = _("Break text into lines of defined length")
635        self.handle = "line-break"
636        # TRANSLATORS: Keep these page titles short, since they
637        # affect the width of the text correction assistant sidebar.
638        self.page_title = _("Line-Break Patterns")
639        self.page_type = Gtk.AssistantPageType.CONTENT
640        self.title = _("Break lines")
641
642    @property
643    def _max_skip_length(self):
644        """Return the maximum line length to skip."""
645        if self.conf.use_skip_max_length:
646            return self.conf.skip_max_length
647        return 32768
648
649    @property
650    def _max_skip_lines(self):
651        """Return the maximum amount of lines to skip."""
652        if self.conf.use_skip_max_lines:
653            return self.conf.skip_max_lines
654        return 32768
655
656
657class LineBreakOptionsPage(BuilderPage):
658
659    """Page for editing line-break options."""
660
661    _widgets = (
662        "max_length_spin",
663        "max_lines_spin",
664        "max_skip_length_spin",
665        "max_skip_lines_spin",
666        "skip_length_check",
667        "skip_lines_check",
668        "skip_unit_combo",
669        "unit_combo",
670    )
671
672    def __init__(self, assistant):
673        """Initialize a :class:`LineBreakOptionsPage` instance."""
674        BuilderPage.__init__(self, assistant, "line-break-options-page.ui")
675        self.conf = gaupol.conf.line_break
676        # TRANSLATORS: Keep these page titles short, since they
677        # affect the width of the text correction assistant sidebar.
678        self.page_title = _("Line-Break Options")
679        self.page_type = Gtk.AssistantPageType.CONTENT
680        self._init_unit_combo(self._unit_combo)
681        self._init_unit_combo(self._skip_unit_combo)
682        self._init_values()
683
684    def _init_unit_combo(self, combo_box):
685        """Initialize line length unit `combo_box`."""
686        store = Gtk.ListStore(str)
687        combo_box.set_model(store)
688        for label in (x.label for x in gaupol.length_units):
689            store.append((label,))
690        renderer = Gtk.CellRendererText()
691        combo_box.pack_start(renderer, expand=True)
692        combo_box.add_attribute(renderer, "text", 0)
693
694    def _init_values(self):
695        """Initialize default values for widgets."""
696        self._max_length_spin.set_value(self.conf.max_length)
697        self._max_lines_spin.set_value(self.conf.max_lines)
698        self._max_skip_length_spin.set_value(self.conf.skip_max_length)
699        self._max_skip_lines_spin.set_value(self.conf.skip_max_lines)
700        self._skip_length_check.set_active(self.conf.use_skip_max_length)
701        self._skip_lines_check.set_active(self.conf.use_skip_max_lines)
702        self._skip_unit_combo.set_active(self.conf.length_unit)
703        self._unit_combo.set_active(self.conf.length_unit)
704
705    def _on_max_length_spin_value_changed(self, spin_button):
706        """Save maximum line length value."""
707        self.conf.max_length = spin_button.get_value_as_int()
708
709    def _on_max_lines_spin_value_changed(self, spin_button):
710        """Save maximum line amount value."""
711        self.conf.max_lines = spin_button.get_value_as_int()
712
713    def _on_max_skip_length_spin_value_changed(self, spin_button):
714        """Save maximum line length to skip value."""
715        self.conf.skip_max_length = spin_button.get_value_as_int()
716
717    def _on_max_skip_lines_spin_value_changed(self, spin_button):
718        """Save maximum line amount to skip value."""
719        self.conf.skip_max_lines = spin_button.get_value_as_int()
720
721    def _on_skip_length_check_toggled(self, check_button):
722        """Save skip by line length value."""
723        use_skip = check_button.get_active()
724        self.conf.use_skip_max_length = use_skip
725        self._max_skip_length_spin.set_sensitive(use_skip)
726        self._skip_unit_combo.set_sensitive(use_skip)
727
728    def _on_skip_lines_check_toggled(self, check_button):
729        """Save skip by line amount value."""
730        use_skip = check_button.get_active()
731        self.conf.use_skip_max_lines = use_skip
732        self._max_skip_lines_spin.set_sensitive(use_skip)
733
734    def _on_skip_unit_combo_changed(self, combo_box):
735        """Save and sync length unit value of `combo_box."""
736        index = combo_box.get_active()
737        length_unit = gaupol.length_units[index]
738        self.conf.length_unit = length_unit
739        self._unit_combo.set_active(index)
740
741    def _on_unit_combo_changed(self, combo_box):
742        """Save and sync length unit value of `combo_box."""
743        index = combo_box.get_active()
744        length_unit = gaupol.length_units[index]
745        self.conf.length_unit = length_unit
746        self._skip_unit_combo.set_active(index)
747
748
749class ProgressPage(BuilderPage):
750
751    """Page for showing progress of text corrections."""
752
753    _widgets = ("message_label", "progress_bar", "status_label", "task_label")
754
755    def __init__(self, assistant):
756        """Initialize a :class:`ProgressPage` instance."""
757        BuilderPage.__init__(self, assistant, "progress-page.ui")
758        self._current_task = None
759        self._total_tasks = None
760        # TRANSLATORS: Keep these page titles short, since they
761        # affect the width of the text correction assistant sidebar.
762        self.page_title = _("Correcting Texts")
763        self.page_type = Gtk.AssistantPageType.PROGRESS
764        self._init_values()
765
766    def _init_values(self):
767        """Initalize default values for widgets."""
768        message = _("Each task is now being run on each project.")
769        self._message_label.set_text(message)
770        self.reset(100)
771
772    def bump_progress(self, n=1):
773        """Bump the current progress by `n`."""
774        self.set_progress(self._current_task + n)
775
776    def reset(self, total, clear_text=False):
777        """Set `total` as the amount of tasks to be run."""
778        self._current_task = 0
779        self._total_tasks = total
780        self.set_progress(0, total)
781        self.set_project_name("")
782        self.set_task_name("")
783        if clear_text:
784            self._progress_bar.set_text("")
785        gaupol.util.iterate_main()
786
787    def set_progress(self, current, total=None):
788        """Set current as the task progress status."""
789        total = total or self._total_tasks
790        fraction = (current/total if total > 0 else 0)
791        self._progress_bar.set_fraction(fraction)
792        text = _("{current:d} of {total:d} tasks complete")
793        self._progress_bar.set_text(text.format(**locals()))
794        self._current_task = current
795        self._total_tasks = total
796        gaupol.util.iterate_main()
797
798    def set_project_name(self, name):
799        """Set `name` as the currently checked project."""
800        text = _("Project: {}").format(name)
801        self._status_label.set_text(text)
802        gaupol.util.iterate_main()
803
804    def set_task_name(self, name):
805        """Set `name` as the currently performed task."""
806        text = _("Task: {}").format(name)
807        self._task_label.set_text(text)
808        gaupol.util.iterate_main()
809
810
811class ConfirmationPage(BuilderPage):
812
813    """Page to confirm changes made after performing all tasks."""
814
815    _widgets = (
816        "mark_all_button",
817        "preview_button",
818        "remove_check",
819        "tree_view",
820        "unmark_all_button",
821    )
822
823    def __init__(self, assistant):
824        """Initialize a :class:`ConfirmationPage` instance."""
825        BuilderPage.__init__(self, assistant, "confirmation-page.ui")
826        self.application = None
827        self.conf = gaupol.conf.text_assistant
828        self.doc = None
829        # TRANSLATORS: Keep these page titles short, since they
830        # affect the width of the text correction assistant sidebar.
831        self.page_title = _("Confirm Changes")
832        self.page_type = Gtk.AssistantPageType.CONFIRM
833        self._init_tree_view()
834        self._init_values()
835
836    def _add_text_column(self, index, title):
837        """Add a multiline text column to the tree view."""
838        renderer = gaupol.MultilineCellRenderer()
839        renderer.set_show_lengths(True)
840        renderer.props.editable = (index == 4)
841        renderer.props.ellipsize = Pango.EllipsizeMode.END
842        renderer.props.font = gaupol.util.get_font()
843        renderer.props.yalign = 0
844        renderer.props.xpad = 4
845        renderer.props.ypad = 4
846        column = Gtk.TreeViewColumn(title, renderer, text=index)
847        column.set_resizable(True)
848        column.set_expand(True)
849        self._tree_view.append_column(column)
850
851    def _can_preview(self):
852        """Return ``True`` if preview is possible."""
853        row = self._get_selected_row()
854        if row is None: return False
855        store = self._tree_view.get_model()
856        page = store[row][0]
857        if page is None: return False
858        return bool(page.project.video_path and page.project.main_file)
859
860    def get_confirmed_changes(self):
861        """Return a sequence of changes marked as accepted."""
862        changes = []
863        store = self._tree_view.get_model()
864        for row in (x for x in store if x[2]):
865            page, index, accept, orig, new = row
866            changes.append((page, index, orig, new))
867        return tuple(changes)
868
869    def _get_selected_row(self):
870        """Return the selected row in the tree view or ``None``."""
871        selection = self._tree_view.get_selection()
872        store, itr = selection.get_selected()
873        if itr is None: return None
874        path = store.get_path(itr)
875        return gaupol.util.tree_path_to_row(path)
876
877    def _init_tree_view(self):
878        """Initialize the tree view of corrections."""
879        # page, index, accept, original text, new text
880        store = Gtk.ListStore(object, int, bool, str, str)
881        self._tree_view.set_model(store)
882        selection = self._tree_view.get_selection()
883        selection.set_mode(Gtk.SelectionMode.SINGLE)
884        selection.connect("changed", self._on_tree_view_selection_changed)
885        renderer = Gtk.CellRendererToggle()
886        renderer.props.activatable = True
887        renderer.props.xpad = 6
888        renderer.connect("toggled", self._on_tree_view_cell_toggled)
889        column = Gtk.TreeViewColumn(_("Accept"), renderer, active=2)
890        column.set_resizable(True)
891        self._tree_view.append_column(column)
892        if gaupol.conf.editor.use_zebra_stripes:
893            callback = self._on_renderer_set_background
894            column.set_cell_data_func(renderer, callback, None)
895        self._add_text_column(3, _("Original Text"))
896        self._add_text_column(4, _("Corrected Text"))
897        column = self._tree_view.get_column(2)
898        renderer = column.get_cells()[0]
899        renderer.connect("edited", self._on_tree_view_cell_edited)
900
901    def _init_values(self):
902        """Initialize default values for widgets."""
903        self._remove_check.set_active(self.conf.remove_blank)
904        self._preview_button.set_sensitive(False)
905
906    def _on_mark_all_button_clicked(self, *args):
907        """Set all accept column values to ``True``."""
908        store = self._tree_view.get_model()
909        for i in range(len(store)):
910            store[i][2] = True
911
912    def _on_preview_button_clicked(self, *args):
913        """Preview original text in a video player."""
914        row = self._get_selected_row()
915        store = self._tree_view.get_model()
916        page = store[row][0]
917        index = store[row][1]
918        position = page.project.subtitles[index].start
919        self.application.preview(page, position, self.doc)
920
921    def _on_remove_check_toggled(self, check_button):
922        """Save remove blank subtitles value."""
923        self.conf.remove_blank = check_button.get_active()
924
925    def _on_renderer_set_background(self, column, renderer, store, itr, data):
926        """Set zerba-striped backgrounds for all columns."""
927        path = self._tree_view.get_model().get_path(itr)
928        row = gaupol.util.tree_path_to_row(path)
929        color = (gaupol.util.get_zebra_color(self._tree_view)
930                 if row % 2 == 0 else None)
931
932        for column in self._tree_view.get_columns():
933            for renderer in column.get_cells():
934                renderer.props.cell_background_rgba = color
935
936    def _on_tree_view_cell_edited(self, renderer, path, text):
937        """Edit text in the corrected text column."""
938        store = self._tree_view.get_model()
939        store[path][4] = text
940
941    def _on_tree_view_cell_toggled(self, renderer, path):
942        """Toggle accept column value."""
943        store = self._tree_view.get_model()
944        store[path][2] = not store[path][2]
945
946    def _on_tree_view_selection_changed(self, *args):
947        """Update preview button sensitivity."""
948        self._preview_button.set_sensitive(self._can_preview())
949
950    def _on_unmark_all_button_clicked(self, *args):
951        """Set all accept column values to ``False``."""
952        store = self._tree_view.get_model()
953        for i in range(len(store)):
954            store[i][2] = False
955
956    def populate_tree_view(self, changes):
957        """Populate the tree view of changes to texts."""
958        self._tree_view.get_model().clear()
959        store = self._tree_view.get_model()
960        for page, index, orig, new in changes:
961            store.append((page, index, True, orig, new))
962        self._tree_view.get_selection().unselect_all()
963
964
965class TextAssistant(Gtk.Assistant):
966
967    """Assistant to guide through multiple text correction tasks."""
968
969    def __init__(self, parent, application):
970        """Initialize a :class:`TextAssistant` instance."""
971        GObject.GObject.__init__(self)
972        self._confirmation_page = ConfirmationPage(self)
973        self._introduction_page = IntroductionPage(self)
974        self._previous_page = None
975        self._progress_page = ProgressPage(self)
976        self.application = application
977        self._init_properties()
978        self._init_signal_handlers()
979        self.resize(*gaupol.conf.text_assistant.size)
980        if gaupol.conf.text_assistant.maximized:
981            self.maximize()
982        self.set_modal(True)
983        self.set_position(Gtk.WindowPosition.CENTER_ON_PARENT)
984        self.set_transient_for(parent)
985
986    def add_page(self, page):
987        """Add `page` and configure its properties."""
988        page.show_all()
989        self.append_page(page)
990        self.set_page_type(page, page.page_type)
991        self.set_page_title(page, page.page_title)
992        if page.page_type != Gtk.AssistantPageType.PROGRESS:
993            self.set_page_complete(page, True)
994
995    def add_pages(self, pages):
996        """Add associated `pages` and configure their properties."""
997        for page in pages:
998            self.add_page(page)
999        def on_notify_visible(page, prop, pages):
1000            for page in pages[1:]:
1001                page.set_visible(pages[0].get_visible())
1002        pages[0].connect("notify::visible", on_notify_visible, pages)
1003
1004    def _copy_project(self, project):
1005        """Return a copy of `project` with some same properties."""
1006        copy = aeidon.Project(project.framerate)
1007        copy.main_file = project.main_file
1008        copy.tran_file = project.tran_file
1009        copy.subtitles = [x.copy() for x in project.subtitles]
1010        return copy
1011
1012    def _correct_texts(self, assistant_pages):
1013        """Correct texts by all pages and present changes."""
1014        changes = []
1015        target = self._introduction_page.get_target()
1016        field = self._introduction_page.get_field()
1017        doc = gaupol.util.text_field_to_document(field)
1018        rows = self.application.get_target_rows(target)
1019        application_pages = self.application.get_target_pages(target)
1020        total = len(application_pages) * len(assistant_pages)
1021        self._progress_page.reset(total)
1022        for application_page in application_pages:
1023            name = application_page.get_main_basename()
1024            self._progress_page.set_project_name(name)
1025            project = application_page.project
1026            # Initialize a dummy project to apply corrections in
1027            # to be able to present those corrections for approval and
1028            # to finally be able to apply only approved corrections.
1029            dummy = self._copy_project(project)
1030            static_subtitles = dummy.subtitles[:]
1031            for page in assistant_pages:
1032                self._progress_page.set_task_name(page.title)
1033                page.correct_texts(dummy, rows, doc)
1034                self._progress_page.bump_progress()
1035            for i in range(len(static_subtitles)):
1036                orig = project.subtitles[i].get_text(doc)
1037                new = static_subtitles[i].get_text(doc)
1038                if orig == new: continue
1039                changes.append((application_page, i, orig, new))
1040        self._prepare_confirmation_page(doc, changes)
1041        self.set_current_page(self.get_current_page() + 1)
1042
1043    def _init_properties(self):
1044        """Initialize assistant properties."""
1045        self.set_title(_("Correct Texts"))
1046        self.add_page(self._introduction_page)
1047        self.add_page(HearingImpairedPage(self))
1048        if aeidon.util.enchant_and_dicts_available():
1049            self.add_page(JoinSplitWordsPage(self))
1050        self.add_page(CommonErrorPage(self))
1051        self.add_page(CapitalizationPage(self))
1052        self.add_pages((LineBreakPage(self), LineBreakOptionsPage(self)))
1053        self.add_page(self._progress_page)
1054        self.add_page(self._confirmation_page)
1055
1056    def _init_signal_handlers(self):
1057        """Initialize signal handlers."""
1058        aeidon.util.connect(self, self, "apply")
1059        aeidon.util.connect(self, self, "cancel")
1060        aeidon.util.connect(self, self, "close")
1061        aeidon.util.connect(self, self, "prepare")
1062        aeidon.util.connect(self, self, "window-state-event")
1063
1064    def _on_apply(self, *args):
1065        """Apply accepted changes to projects."""
1066        gaupol.util.set_cursor_busy(self)
1067        edits = removals = 0
1068        changes = self._confirmation_page.get_confirmed_changes()
1069        changed_pages = aeidon.util.get_unique([x[0] for x in changes])
1070        field = self._introduction_page.get_field()
1071        doc = gaupol.util.text_field_to_document(field)
1072        description = _("Correcting texts")
1073        register = aeidon.registers.DO
1074        for page in changed_pages:
1075            indices = [x[1] for x in changes if x[0] is page]
1076            texts = [x[3] for x in changes if x[0] is page]
1077            if indices and texts:
1078                page.project.replace_texts(indices, doc, texts)
1079                page.project.set_action_description(register, description)
1080                edits += len(indices)
1081            indices = [x for i, x in enumerate(indices) if not texts[i]]
1082            if indices and gaupol.conf.text_assistant.remove_blank:
1083                page.project.remove_subtitles(indices)
1084                page.project.group_actions(register, 2, description)
1085                removals += len(indices)
1086            page.view.columns_autosize()
1087        edits = edits - removals
1088        message = _("Edited {edits:d} and removed {removals:d} subtitles")
1089        self.application.flash_message(message.format(**locals()))
1090        gaupol.util.set_cursor_normal(self)
1091
1092    def _on_cancel(self, *args):
1093        """Destroy assistant."""
1094        self._save_window_geometry()
1095        self.destroy()
1096
1097    def _on_close(self, *args):
1098        """Destroy assistant."""
1099        self._save_window_geometry()
1100        self.destroy()
1101
1102    def _on_prepare(self, assistant, page):
1103        """Prepare `page` to be shown next."""
1104        previous_page = self._previous_page
1105        self._previous_page = page
1106        if page is self._introduction_page:
1107            return self._prepare_introduction_page()
1108        pages = self._introduction_page.get_selected_pages()
1109        if page is self._progress_page:
1110            if previous_page is not self._confirmation_page:
1111                return self._prepare_progress_page(pages)
1112
1113    def _on_window_state_event(self, window, event):
1114        """Save window maximization."""
1115        state = event.new_window_state
1116        maximized = bool(state & Gdk.WindowState.MAXIMIZED)
1117        gaupol.conf.text_assistant.maximized = maximized
1118
1119    def _prepare_confirmation_page(self, doc, changes):
1120        """Present `changes` and activate confirmation page."""
1121        count = len(changes)
1122        title = n_("Confirm {:d} Change",
1123                   "Confirm {:d} Changes",
1124                   count).format(count)
1125
1126        self.set_page_title(self._confirmation_page, title)
1127        self._confirmation_page.application = self.application
1128        self._confirmation_page.doc = doc
1129        self._confirmation_page.populate_tree_view(changes)
1130        self.set_page_complete(self._progress_page, True)
1131
1132    def _prepare_introduction_page(self):
1133        """Prepare introduction page content."""
1134        n = self.get_n_pages()
1135        pages = list(map(self.get_nth_page, range(n)))
1136        pages.remove(self._introduction_page)
1137        pages.remove(self._progress_page)
1138        pages.remove(self._confirmation_page)
1139        pages = [x for x in pages if hasattr(x, "correct_texts")]
1140        self._introduction_page.populate_tree_view(pages)
1141
1142    def _prepare_progress_page(self, pages):
1143        """Prepare progress page for `pages`."""
1144        self._progress_page.reset(0, True)
1145        self.set_page_complete(self._progress_page, False)
1146        gaupol.util.delay_add(10, self._correct_texts, pages)
1147
1148    def _save_window_geometry(self):
1149        """Save the geometry of the assistant window."""
1150        if not gaupol.conf.text_assistant.maximized:
1151            gaupol.conf.text_assistant.size = list(self.get_size())
1152