diff --git a/src/calibre/gui2/viewer/search.py b/src/calibre/gui2/viewer/search.py index 7fd5a44fa2..92de1d4c65 100644 --- a/src/calibre/gui2/viewer/search.py +++ b/src/calibre/gui2/viewer/search.py @@ -17,6 +17,7 @@ from PyQt5.Qt import ( ) from calibre.ebooks.conversion.search_replace import REGEX_FLAGS +from calibre.gui2 import warning_dialog from calibre.gui2.progress_indicator import ProgressIndicator from calibre.gui2.viewer.web_view import get_data, get_manifest, vprefs from calibre.gui2.widgets2 import HistoryComboBox @@ -36,14 +37,17 @@ class BusySpinner(QWidget): # {{{ self.la = la = QLabel(_('Searching...')) l.addWidget(la) l.addStretch(10) + self.is_running = False def start(self): self.setVisible(True) self.pi.start() + self.is_running = True def stop(self): self.setVisible(False) self.pi.stop() + self.is_running = False # }}} @@ -92,7 +96,11 @@ class SearchResult(object): def static_text(self): if self._static_text is None: before_words = self.before.split() - before = '…' + ' '.join(before_words[-3:])[:15] + before = ' '.join(before_words[-3:]) + before_extra = len(before) - 15 + if before_extra > 0: + before = before[before_extra:] + before = '…' + before before_space = '' if self.before.rstrip() == self.before else ' ' after_words = self.after.split() after = ' '.join(after_words[:3])[:15] + '…' @@ -102,6 +110,13 @@ class SearchResult(object): st.setTextWidth(10000) return self._static_text + @property + def for_js(self): + return {'file_name': self.file_name, 'spine_idx': self.spine_idx, 'index': self.index, 'text': self.text} + + def is_or_is_after(self, result_from_js): + return result_from_js['spine_idx'] == self.spine_idx and self.index >= result_from_js['index'] and result_from_js['text'] == self.text + @lru_cache(maxsize=None) def searchable_text_for_name(name): @@ -198,7 +213,7 @@ class SearchInput(QWidget): # {{{ qt.setCurrentIndex(qt.findData(vprefs.get('viewer-search-mode', 'normal') or 'normal')) h.addWidget(qt) - self.case_sensitive = cs = QCheckBox(_('Case sensitive'), self) + self.case_sensitive = cs = QCheckBox(_('&Case sensitive'), self) cs.setFocusPolicy(Qt.NoFocus) cs.setChecked(bool(vprefs.get('viewer-search-case-sensitive', False))) h.addWidget(cs) @@ -304,6 +319,28 @@ class Results(QListWidget): # {{{ i %= self.count() self.setCurrentRow(i) self.item_activated() + + def search_result_not_found(self, sr): + remove = [] + for i in range(self.count()): + item = self.item(i) + r = item.data(Qt.UserRole) + if r.is_or_is_after(sr): + remove.append(i) + if remove: + last_i = remove[-1] + if last_i < self.count() - 1: + self.setCurrentRow(last_i + 1) + self.item_activated() + elif remove[0] > 0: + self.setCurrentRow(remove[0] - 1) + self.item_activated() + for i in reversed(remove): + self.takeItem(i) + if self.count(): + warning_dialog(self, _('Hidden text'), _( + 'Some search results were for hidden text, they have been removed.'), show=True) + # }}} @@ -311,6 +348,7 @@ class SearchPanel(QWidget): # {{{ search_requested = pyqtSignal(object) results_found = pyqtSignal(object) + show_search_result = pyqtSignal(object) def __init__(self, parent=None): QWidget.__init__(self, parent) @@ -323,6 +361,7 @@ class SearchPanel(QWidget): # {{{ si.do_search.connect(self.search_requested) l.addWidget(si) self.results = r = Results(self) + r.show_search_result.connect(self.do_show_search_result, type=Qt.QueuedConnection) l.addWidget(r, 100) self.spinner = s = BusySpinner(self) s.setVisible(False) @@ -362,15 +401,14 @@ class SearchPanel(QWidget): # {{{ if spine_idx < 0: self.results_found.emit(SearchFinished(search_query)) continue - names = spine[spine_idx:] + spine[:spine_idx] - for name in names: + for name in spine: counter = Counter() spine_idx = idx_map[name] try: for i, result in enumerate(search_in_name(name, search_query)): before, text, after = result - counter[text] += 1 self.results_found.emit(SearchResult(search_query, before, text, after, name, spine_idx, counter[text])) + counter[text] += 1 except Exception: import traceback traceback.print_exc() @@ -381,6 +419,8 @@ class SearchPanel(QWidget): # {{{ return if isinstance(result, SearchFinished): self.spinner.stop() + if not self.results.count(): + self.show_no_results_found() return if self.results.add_result(result) == 1: # first result @@ -401,4 +441,17 @@ class SearchPanel(QWidget): # {{{ def find_next_requested(self, previous): self.results.find_next(previous) + + def do_show_search_result(self, sr): + self.show_search_result.emit(sr.for_js) + + def search_result_not_found(self, sr): + self.results.search_result_not_found(sr) + if not self.results.count() and not self.spinner.is_running: + self.show_no_results_found() + + def show_no_results_found(self): + if self.current_search: + warning_dialog(self, _('No matches found'), _( + 'No matches were found for: {}').format(self.current_search.text), show=True) # }}} diff --git a/src/calibre/gui2/viewer/ui.py b/src/calibre/gui2/viewer/ui.py index a3c5945e1e..b17411f493 100644 --- a/src/calibre/gui2/viewer/ui.py +++ b/src/calibre/gui2/viewer/ui.py @@ -159,6 +159,8 @@ class EbookViewer(MainWindow): self.web_view.toggle_toc.connect(self.toggle_toc) self.web_view.show_search.connect(self.show_search) self.web_view.find_next.connect(self.search_widget.find_next_requested) + self.search_widget.show_search_result.connect(self.web_view.show_search_result) + self.web_view.search_result_not_found.connect(self.search_widget.search_result_not_found) self.web_view.toggle_bookmarks.connect(self.toggle_bookmarks) self.web_view.toggle_inspector.connect(self.toggle_inspector) self.web_view.toggle_lookup.connect(self.toggle_lookup) diff --git a/src/calibre/gui2/viewer/web_view.py b/src/calibre/gui2/viewer/web_view.py index 401f3daeca..6dd4923ee8 100644 --- a/src/calibre/gui2/viewer/web_view.py +++ b/src/calibre/gui2/viewer/web_view.py @@ -249,6 +249,7 @@ class ViewerBridge(Bridge): toggle_inspector = from_js() toggle_lookup = from_js() show_search = from_js() + search_result_not_found = from_js(object) find_next = from_js(object) quit = from_js() update_current_toc_nodes = from_js(object, object) @@ -282,6 +283,7 @@ class ViewerBridge(Bridge): goto_frac = to_js() trigger_shortcut = to_js() set_system_palette = to_js() + show_search_result = to_js() def apply_font_settings(page_or_view): @@ -419,6 +421,7 @@ class WebView(RestartingWebEngineView): reload_book = pyqtSignal() toggle_toc = pyqtSignal() show_search = pyqtSignal() + search_result_not_found = pyqtSignal(object) find_next = pyqtSignal(object) toggle_bookmarks = pyqtSignal() toggle_inspector = pyqtSignal() @@ -464,6 +467,7 @@ class WebView(RestartingWebEngineView): self.bridge.reload_book.connect(self.reload_book) self.bridge.toggle_toc.connect(self.toggle_toc) self.bridge.show_search.connect(self.show_search) + self.bridge.search_result_not_found.connect(self.search_result_not_found) self.bridge.find_next.connect(self.find_next) self.bridge.toggle_bookmarks.connect(self.toggle_bookmarks) self.bridge.toggle_inspector.connect(self.toggle_inspector) @@ -654,5 +658,8 @@ class WebView(RestartingWebEngineView): def trigger_shortcut(self, which): self.execute_when_ready('trigger_shortcut', which) + def show_search_result(self, sr): + self.execute_when_ready('show_search_result', sr) + def palette_changed(self): self.execute_when_ready('set_system_palette', system_colors()) diff --git a/src/pyj/read_book/iframe.pyj b/src/pyj/read_book/iframe.pyj index bcb588eb8d..93d322dd5c 100644 --- a/src/pyj/read_book/iframe.pyj +++ b/src/pyj/read_book/iframe.pyj @@ -112,6 +112,7 @@ class IframeBoss: 'wheel_from_margin': self.wheel_from_margin, 'window_size': self.received_window_size, 'overlay_visibility_changed': self.on_overlay_visibility_changed, + 'show_search_result': self.show_search_result, } self.comm = IframeClient(handlers) self.last_window_ypos = 0 @@ -312,6 +313,8 @@ class IframeBoss: self.scroll_to_ref(ipos.refnum) elif ipos.type is 'cfi': self.jump_to_cfi(ipos.cfi) + elif ipos.type is 'search_result': + self.show_search_result(ipos, True) elif ipos.type is 'search': self.find(ipos.search_data, True) spine = self.book.manifest.spine @@ -526,6 +529,18 @@ class IframeBoss: else: self.send_message('find_in_spine', text=data.text, backwards=data.backwards, searched_in_spine=data.searched_in_spine) + def show_search_result(self, data, from_load): + sr = data.search_result + idx = -1 + window.getSelection().removeAllRanges() + while idx < sr.index: + if not window.find(sr.text, True, False, False, False, False): + self.send_message('search_result_not_found', search_result=sr) + break + idx += 1 + if idx > -1 and current_layout_mode() is not 'flow': + snap_to_selection() + def reference_item_changed(self, ref_num_or_none): self.send_message('reference_item_changed', refnum=ref_num_or_none, index=current_spine_item().index) diff --git a/src/pyj/read_book/view.pyj b/src/pyj/read_book/view.pyj index 9d31dd7659..16cd2da6c6 100644 --- a/src/pyj/read_book/view.pyj +++ b/src/pyj/read_book/view.pyj @@ -242,6 +242,9 @@ class View: 'update_cfi': self.on_update_cfi, 'update_progress_frac': self.on_update_progress_frac, 'update_toc_position': self.on_update_toc_position, + 'search_result_not_found': def (data): if ui_operations.search_result_not_found: + ui_operations.search_result_not_found(data.search_result) + , } entry_point = None if runtime.is_standalone_viewer else 'read_book.iframe' if runtime.is_standalone_viewer: @@ -1072,3 +1075,9 @@ class View: else: div.style.display = 'block' div.textContent = f'{index}.{refnum}' + + def show_search_result(self, sr): + if self.currently_showing.name is sr.file_name: + self.iframe_wrapper.send_message('show_search_result', search_result=sr) + else: + self.show_name(sr.file_name, initial_position={'type':'search_result', 'search_result':sr, 'replace_history':True}) diff --git a/src/pyj/viewer-main.pyj b/src/pyj/viewer-main.pyj index 40be984e2e..4f47f8381d 100644 --- a/src/pyj/viewer-main.pyj +++ b/src/pyj/viewer-main.pyj @@ -288,6 +288,12 @@ def trigger_shortcut(which): view.on_handle_shortcut({'name': which}) +@from_python +def show_search_result(sr): + if view: + view.show_search_result(sr) + + def onerror(msg, script_url, line_number, column_number, error_object): if not error_object: # cross domain error @@ -393,6 +399,8 @@ if window is window.top: to_python.customize_toolbar() ui_operations.autoscroll_state_changed = def(active): to_python.autoscroll_state_changed(active) + ui_operations.search_result_not_found = def(sr): + to_python.search_result_not_found(sr) document.body.appendChild(E.div(id='view')) window.onerror = onerror