BLKSerene/Wordless

View on GitHub
wordless/wl_results/wl_results_search.py

Summary

Maintainability
A
45 mins
Test Coverage
# ----------------------------------------------------------------------
# Wordless: Results - Search in results
# Copyright (C) 2018-2024  Ye Lei (叶磊)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
# ----------------------------------------------------------------------

import copy

from PyQt5.QtCore import QCoreApplication, Qt
from PyQt5.QtGui import QBrush, QColor
from PyQt5.QtWidgets import QPushButton

from wordless.wl_checks import wl_checks_work_area
from wordless.wl_dialogs import wl_dialogs, wl_dialogs_misc, wl_msg_boxes
from wordless.wl_nlp import wl_matching, wl_nlp_utils, wl_texts
from wordless.wl_utils import wl_misc, wl_threading
from wordless.wl_widgets import wl_buttons, wl_layouts, wl_widgets

_tr = QCoreApplication.translate

class Wl_Dialog_Results_Search(wl_dialogs.Wl_Dialog):
    def __init__(self, main, table):
        # pylint: disable=unnecessary-lambda

        super().__init__(main, _tr('Wl_Dialog_Results_Search', 'Search in Results'))

        self.tab = table.tab
        self.tables = [table]
        self.settings = self.main.settings_custom[self.tab]['search_results']
        self.last_search_settings = []
        self.items_found = []

        self.main.wl_work_area.currentChanged.connect(self.reject)

        (
            self.label_search_term,
            self.checkbox_multi_search_mode,

            self.stacked_widget_search_term,
            self.line_edit_search_term,
            self.list_search_terms,
            self.label_delimiter,

            self.checkbox_match_case,
            self.checkbox_match_whole_words,
            self.checkbox_match_inflected_forms,
            self.checkbox_use_regex,
            self.checkbox_match_without_tags,
            self.checkbox_match_tags
        ) = wl_widgets.wl_widgets_search_settings(self, self.tab)

        self.button_find_next = QPushButton(self.tr('Find next'), self)
        self.button_find_prev = QPushButton(self.tr('Find previous'), self)
        self.button_find_all = QPushButton(self.tr('Find all'), self)
        self.button_clr_hightlights = QPushButton(self.tr('Clear highlights'), self)

        self.button_find_next.setMinimumWidth(130)
        self.button_find_prev.setMinimumWidth(130)
        self.button_find_all.setMinimumWidth(130)
        self.button_clr_hightlights.setMinimumWidth(130)

        self.button_restore_defaults = wl_buttons.Wl_Button_Restore_Defaults(self, load_settings = self.load_settings)
        self.button_close = QPushButton(self.tr('Close'), self)

        self.checkbox_multi_search_mode.stateChanged.connect(self.multi_search_mode_changed)
        self.line_edit_search_term.textChanged.connect(self.search_settings_changed)
        self.line_edit_search_term.returnPressed.connect(self.button_find_next.click)
        self.list_search_terms.model().dataChanged.connect(self.search_settings_changed)

        self.checkbox_match_case.stateChanged.connect(self.search_settings_changed)
        self.checkbox_match_whole_words.stateChanged.connect(self.search_settings_changed)
        self.checkbox_match_inflected_forms.stateChanged.connect(self.search_settings_changed)
        self.checkbox_use_regex.stateChanged.connect(self.search_settings_changed)
        self.checkbox_match_without_tags.stateChanged.connect(self.search_settings_changed)
        self.checkbox_match_tags.stateChanged.connect(self.search_settings_changed)

        self.button_find_next.clicked.connect(lambda: self.find_next())
        self.button_find_prev.clicked.connect(lambda: self.find_prev())
        self.button_find_all.clicked.connect(lambda: self.find_all())
        self.button_clr_hightlights.clicked.connect(lambda: self.clr_highlights())

        self.button_close.clicked.connect(self.reject)

        layout_buttons_right = wl_layouts.Wl_Layout()
        layout_buttons_right.addWidget(self.button_find_next, 0, 0)
        layout_buttons_right.addWidget(self.button_find_prev, 1, 0)
        layout_buttons_right.addWidget(self.button_find_all, 2, 0)
        layout_buttons_right.addWidget(self.button_clr_hightlights, 3, 0)

        layout_buttons_right.setRowStretch(4, 1)

        layout_buttons_bottom = wl_layouts.Wl_Layout()
        layout_buttons_bottom.addWidget(self.button_restore_defaults, 0, 0)
        layout_buttons_bottom.addWidget(self.button_close, 0, 2)

        layout_buttons_bottom.setColumnStretch(1, 1)

        self.setLayout(wl_layouts.Wl_Layout())
        self.layout().addWidget(self.label_search_term, 0, 0)
        self.layout().addWidget(self.checkbox_multi_search_mode, 0, 1, Qt.AlignRight)
        self.layout().addWidget(self.stacked_widget_search_term, 1, 0, 1, 2)
        self.layout().addWidget(self.label_delimiter, 2, 0, 1, 2)

        self.layout().addWidget(self.checkbox_match_case, 3, 0, 1, 2)
        self.layout().addWidget(self.checkbox_match_whole_words, 4, 0, 1, 2)
        self.layout().addWidget(self.checkbox_match_inflected_forms, 5, 0, 1, 2)
        self.layout().addWidget(self.checkbox_use_regex, 6, 0, 1, 2)
        self.layout().addWidget(self.checkbox_match_without_tags, 7, 0, 1, 2)
        self.layout().addWidget(self.checkbox_match_tags, 8, 0, 1, 2)

        self.layout().addWidget(wl_layouts.Wl_Separator(self, orientation = 'vert'), 0, 2, 9, 1)
        self.layout().addLayout(layout_buttons_right, 0, 3, 9, 1)

        self.layout().addWidget(wl_layouts.Wl_Separator(self), 9, 0, 1, 4)
        self.layout().addLayout(layout_buttons_bottom, 10, 0, 1, 4)

        self.layout().setColumnStretch(0, 1)

        for table_to_search in self.tables:
            table_to_search.model().itemChanged.connect(self.table_item_changed)

        self.load_settings()

    def load_settings(self, defaults = False):
        if defaults:
            settings = copy.deepcopy(self.main.settings_default[self.tab]['search_results'])
        else:
            settings = copy.deepcopy(self.settings)

        self.checkbox_multi_search_mode.setChecked(settings['multi_search_mode'])

        if not defaults:
            self.line_edit_search_term.setText(settings['search_term'])
            self.list_search_terms.load_items(settings['search_terms'])

        self.checkbox_match_case.setChecked(settings['match_case'])
        self.checkbox_match_whole_words.setChecked(settings['match_whole_words'])
        self.checkbox_match_inflected_forms.setChecked(settings['match_inflected_forms'])
        self.checkbox_use_regex.setChecked(settings['use_regex'])
        self.checkbox_match_without_tags.setChecked(settings['match_without_tags'])
        self.checkbox_match_tags.setChecked(settings['match_tags'])

        self.search_settings_changed()

    def search_settings_changed(self):
        self.settings['multi_search_mode'] = self.checkbox_multi_search_mode.isChecked()
        self.settings['search_term'] = self.line_edit_search_term.text()
        self.settings['search_terms'] = self.list_search_terms.model().stringList()

        self.settings['match_case'] = self.checkbox_match_case.isChecked()
        self.settings['match_whole_words'] = self.checkbox_match_whole_words.isChecked()
        self.settings['match_inflected_forms'] = self.checkbox_match_inflected_forms.isChecked()
        self.settings['use_regex'] = self.checkbox_use_regex.isChecked()
        self.settings['match_without_tags'] = self.checkbox_match_without_tags.isChecked()
        self.settings['match_tags'] = self.checkbox_match_tags.isChecked()

        if wl_checks_work_area.check_search_terms(
            self.main,
            search_settings = self.settings,
            show_warning = False
        ):
            self.button_find_next.setEnabled(True)
            self.button_find_prev.setEnabled(True)
            self.button_find_all.setEnabled(True)
        else:
            self.button_find_next.setEnabled(False)
            self.button_find_prev.setEnabled(False)
            self.button_find_all.setEnabled(False)

    def multi_search_mode_changed(self):
        self.adjust_size()

        self.search_settings_changed()

    def table_item_changed(self):
        self.checkbox_match_tags.token_settings_changed(
            token_settings = self.tables[0].settings[self.tab]['token_settings']
        )

    @wl_misc.log_time
    def find_next(self):
        self.find_all()

        if self.items_found:
            selected_rows = []

            for table in self.tables:
                table.disable_updates()

            for table in self.tables:
                if table.get_selected_rows():
                    selected_rows = [id(table), table.get_selected_rows()]

                    break

            # Scroll to the next found item
            if selected_rows:
                for table in self.tables:
                    table.clearSelection()

                for table, row, _ in self.items_found:
                    # Tables are sorted by their string representations
                    if (
                        id(table) > selected_rows[0]
                        or id(table) == selected_rows[0] and row > selected_rows[1][-1]
                    ):
                        table.selectRow(row)
                        table.setFocus()

                        table.scrollTo(table.model().index(row, 0))

                        break

                # Scroll to top if this is the last item
                if not any((table.selectedIndexes() for table in self.tables)):
                    self.tables[0].scrollTo(table.model().index(self.items_found[0][1], 0))
                    self.tables[0].selectRow(self.items_found[0][1])
            else:
                self.tables[0].scrollTo(table.model().index(self.items_found[0][1], 0))
                self.tables[0].selectRow(self.items_found[0][1])

            for table in self.tables:
                table.enable_updates()

    @wl_misc.log_time
    def find_prev(self):
        self.find_all()

        if self.items_found:
            selected_rows = []

            for table in self.tables:
                table.disable_updates()

            for table in self.tables:
                if table.get_selected_rows():
                    selected_rows = [id(table), table.get_selected_rows()]

                    break

            # Scroll to the previous found item
            if selected_rows:
                for table in self.tables:
                    table.clearSelection()

                for table, row, _ in reversed(self.items_found):
                    # Tables are sorted by their string representations
                    if (
                        id(table) < selected_rows[0]
                        or id(table) == selected_rows[0] and row < selected_rows[1][-1]
                    ):
                        table.selectRow(row)
                        table.setFocus()

                        table.scrollTo(table.model().index(row, 0))

                        break

                # Scroll to bottom if this is the first item
                if not any((table.selectedIndexes() for table in self.tables)):
                    self.tables[-1].scrollTo(table.model().index(self.items_found[-1][1], 0))
                    self.tables[-1].selectRow(self.items_found[-1][1])
            else:
                self.tables[-1].scrollTo(table.model().index(self.items_found[-1][1], 0))
                self.tables[-1].selectRow(self.items_found[-1][1])

            for table in self.tables:
                table.enable_updates()

    @wl_misc.log_time
    def find_all(self):
        # Search only when there are no search history or search settings have been changed
        if not self.items_found or self.last_search_settings != copy.deepcopy(self.settings):
            self.clr_highlights()

            dialog_progress = wl_dialogs_misc.Wl_Dialog_Progress(self.main, text = self.tr('Searching in results...'))

            worker_results_search = Wl_Worker_Results_Search(
                self.main,
                dialog_progress = dialog_progress,
                update_gui = self.update_gui,
                dialog = self
            )

            wl_threading.Wl_Thread(worker_results_search).start_worker()

    def update_gui(self):
        if self.items_found:
            for table in self.tables:
                table.disable_updates()

            for table, row, col in self.items_found:
                if table.indexWidget(table.model().index(row, col)):
                    table.indexWidget(table.model().index(row, col)).setStyleSheet('border: 1px solid #E53E3A;')
                else:
                    table.model().item(row, col).setForeground(QBrush(QColor('#FFF')))
                    table.model().item(row, col).setBackground(QBrush(QColor('#E53E3A')))

            for table in self.tables:
                table.enable_updates()

            self.button_clr_hightlights.setEnabled(True)
        else:
            wl_msg_boxes.Wl_Msg_Box_Warning(
                self.main,
                title = self.tr('No Search Results'),
                text = self.tr('''
                    <div>Searching has completed successfully, but there are no results found.</div>
                    <div>You can change your settings and try again.</div>
                ''')
            ).open()

            self.button_clr_hightlights.setEnabled(False)

        # Save search settings
        self.last_search_settings = copy.deepcopy(self.settings)

        len_items_found = len(self.items_found)
        msg_item = self.tr('item') if len_items_found == 1 else self.tr('items')

        self.main.statusBar().showMessage(self.tr('Found {} {}.').format(len_items_found, msg_item))

    @wl_misc.log_time
    def clr_highlights(self):
        if self.items_found:
            for table in self.tables:
                table.disable_updates()

            for table, row, col in self.items_found:
                if table.indexWidget(table.model().index(row, col)):
                    table.indexWidget(table.model().index(row, col)).setStyleSheet('border: 0')
                # Skip if the found item no longer exist (eg. the table has been re-generated)
                elif table.model().item(row, col):
                    table.model().item(row, col).setForeground(QBrush(QColor(table.default_foreground)))
                    table.model().item(row, col).setBackground(QBrush(QColor(table.default_background)))

            for table in self.tables:
                table.enable_updates()

            self.clr_history()

            self.main.statusBar().showMessage(self.tr('Highlights cleared.'))

        self.button_clr_hightlights.setEnabled(False)

    def clr_history(self):
        self.last_search_settings.clear()
        self.items_found.clear()

class Wl_Worker_Results_Search(wl_threading.Wl_Worker):
    def run(self):
        for table in self.dialog.tables:
            results = {}
            search_terms = set()

            # Only search in visible rows and columns
            rows_to_search = [
                row
                for row in range(table.model().rowCount())
                if not table.isRowHidden(row)
            ]
            cols_to_search = [
                col
                for col in range(table.model().columnCount())
                if not table.isColumnHidden(col)
            ]

            for col in cols_to_search:
                # Concordancer - Left, Node, Right / Parallel Concordancer - Parallel Unit / Dependency Parser - Sentence
                if table.indexWidget(table.model().index(0, col)):
                    for row in rows_to_search:
                        results[(row, col)] = table.indexWidget(table.model().index(row, col)).tokens_search
                else:
                    for row in rows_to_search:
                        # Dependency Parser - Sentence / N-gram Generator - N-gram
                        try:
                            results[(row, col)] = table.model().item(row, col).tokens_search
                        except AttributeError:
                            results[(row, col)] = wl_texts.display_texts_to_tokens(
                                self.main,
                                [table.model().item(row, col).text()]
                            )

            items = [token for text in results.values() for token in text]

            for file in table.settings['file_area']['files_open']:
                if file['selected']:
                    search_terms_file = wl_matching.match_search_terms_ngrams(
                        self.main, items,
                        lang = file['lang'],
                        token_settings = table.settings[self.dialog.tab]['token_settings'],
                        search_settings = self.dialog.settings
                    )

                    search_terms |= set(search_terms_file)

            for search_term in search_terms:
                len_search_term = len(search_term)

                for (row, col), text in results.items():
                    for ngram in wl_nlp_utils.ngrams(text, len_search_term):
                        if ngram == tuple(search_term):
                            self.dialog.items_found.append([table, row, col])

        self.dialog.items_found = sorted(
            self.dialog.items_found,
            key = lambda item: (id(item[0]), item[1], item[2])
        )

        self.progress_updated.emit(self.tr('Highlighting found items...'))
        self.worker_done.emit()