Viewer: Much improved search functionality. Now all matches are displayed with a few surrounding words. Also supports using regular expressions to search. Fixes #1834247 [Enhanced Search [Enhancement]](https://bugs.launchpad.net/calibre/+bug/1834247)

This commit is contained in:
Kovid Goyal 2020-01-21 21:23:07 +05:30
parent 1a69773b74
commit 5bd3a88bc3
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
6 changed files with 99 additions and 5 deletions

View File

@ -17,6 +17,7 @@ from PyQt5.Qt import (
) )
from calibre.ebooks.conversion.search_replace import REGEX_FLAGS 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.progress_indicator import ProgressIndicator
from calibre.gui2.viewer.web_view import get_data, get_manifest, vprefs from calibre.gui2.viewer.web_view import get_data, get_manifest, vprefs
from calibre.gui2.widgets2 import HistoryComboBox from calibre.gui2.widgets2 import HistoryComboBox
@ -36,14 +37,17 @@ class BusySpinner(QWidget): # {{{
self.la = la = QLabel(_('Searching...')) self.la = la = QLabel(_('Searching...'))
l.addWidget(la) l.addWidget(la)
l.addStretch(10) l.addStretch(10)
self.is_running = False
def start(self): def start(self):
self.setVisible(True) self.setVisible(True)
self.pi.start() self.pi.start()
self.is_running = True
def stop(self): def stop(self):
self.setVisible(False) self.setVisible(False)
self.pi.stop() self.pi.stop()
self.is_running = False
# }}} # }}}
@ -92,7 +96,11 @@ class SearchResult(object):
def static_text(self): def static_text(self):
if self._static_text is None: if self._static_text is None:
before_words = self.before.split() 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 ' ' before_space = '' if self.before.rstrip() == self.before else ' '
after_words = self.after.split() after_words = self.after.split()
after = ' '.join(after_words[:3])[:15] + '' after = ' '.join(after_words[:3])[:15] + ''
@ -102,6 +110,13 @@ class SearchResult(object):
st.setTextWidth(10000) st.setTextWidth(10000)
return self._static_text 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) @lru_cache(maxsize=None)
def searchable_text_for_name(name): 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')) qt.setCurrentIndex(qt.findData(vprefs.get('viewer-search-mode', 'normal') or 'normal'))
h.addWidget(qt) h.addWidget(qt)
self.case_sensitive = cs = QCheckBox(_('Case sensitive'), self) self.case_sensitive = cs = QCheckBox(_('&Case sensitive'), self)
cs.setFocusPolicy(Qt.NoFocus) cs.setFocusPolicy(Qt.NoFocus)
cs.setChecked(bool(vprefs.get('viewer-search-case-sensitive', False))) cs.setChecked(bool(vprefs.get('viewer-search-case-sensitive', False)))
h.addWidget(cs) h.addWidget(cs)
@ -304,6 +319,28 @@ class Results(QListWidget): # {{{
i %= self.count() i %= self.count()
self.setCurrentRow(i) self.setCurrentRow(i)
self.item_activated() 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) search_requested = pyqtSignal(object)
results_found = pyqtSignal(object) results_found = pyqtSignal(object)
show_search_result = pyqtSignal(object)
def __init__(self, parent=None): def __init__(self, parent=None):
QWidget.__init__(self, parent) QWidget.__init__(self, parent)
@ -323,6 +361,7 @@ class SearchPanel(QWidget): # {{{
si.do_search.connect(self.search_requested) si.do_search.connect(self.search_requested)
l.addWidget(si) l.addWidget(si)
self.results = r = Results(self) self.results = r = Results(self)
r.show_search_result.connect(self.do_show_search_result, type=Qt.QueuedConnection)
l.addWidget(r, 100) l.addWidget(r, 100)
self.spinner = s = BusySpinner(self) self.spinner = s = BusySpinner(self)
s.setVisible(False) s.setVisible(False)
@ -362,15 +401,14 @@ class SearchPanel(QWidget): # {{{
if spine_idx < 0: if spine_idx < 0:
self.results_found.emit(SearchFinished(search_query)) self.results_found.emit(SearchFinished(search_query))
continue continue
names = spine[spine_idx:] + spine[:spine_idx] for name in spine:
for name in names:
counter = Counter() counter = Counter()
spine_idx = idx_map[name] spine_idx = idx_map[name]
try: try:
for i, result in enumerate(search_in_name(name, search_query)): for i, result in enumerate(search_in_name(name, search_query)):
before, text, after = result before, text, after = result
counter[text] += 1
self.results_found.emit(SearchResult(search_query, before, text, after, name, spine_idx, counter[text])) self.results_found.emit(SearchResult(search_query, before, text, after, name, spine_idx, counter[text]))
counter[text] += 1
except Exception: except Exception:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
@ -381,6 +419,8 @@ class SearchPanel(QWidget): # {{{
return return
if isinstance(result, SearchFinished): if isinstance(result, SearchFinished):
self.spinner.stop() self.spinner.stop()
if not self.results.count():
self.show_no_results_found()
return return
if self.results.add_result(result) == 1: if self.results.add_result(result) == 1:
# first result # first result
@ -401,4 +441,17 @@ class SearchPanel(QWidget): # {{{
def find_next_requested(self, previous): def find_next_requested(self, previous):
self.results.find_next(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: <b>{}</b>').format(self.current_search.text), show=True)
# }}} # }}}

View File

@ -159,6 +159,8 @@ class EbookViewer(MainWindow):
self.web_view.toggle_toc.connect(self.toggle_toc) self.web_view.toggle_toc.connect(self.toggle_toc)
self.web_view.show_search.connect(self.show_search) self.web_view.show_search.connect(self.show_search)
self.web_view.find_next.connect(self.search_widget.find_next_requested) 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_bookmarks.connect(self.toggle_bookmarks)
self.web_view.toggle_inspector.connect(self.toggle_inspector) self.web_view.toggle_inspector.connect(self.toggle_inspector)
self.web_view.toggle_lookup.connect(self.toggle_lookup) self.web_view.toggle_lookup.connect(self.toggle_lookup)

View File

@ -249,6 +249,7 @@ class ViewerBridge(Bridge):
toggle_inspector = from_js() toggle_inspector = from_js()
toggle_lookup = from_js() toggle_lookup = from_js()
show_search = from_js() show_search = from_js()
search_result_not_found = from_js(object)
find_next = from_js(object) find_next = from_js(object)
quit = from_js() quit = from_js()
update_current_toc_nodes = from_js(object, object) update_current_toc_nodes = from_js(object, object)
@ -282,6 +283,7 @@ class ViewerBridge(Bridge):
goto_frac = to_js() goto_frac = to_js()
trigger_shortcut = to_js() trigger_shortcut = to_js()
set_system_palette = to_js() set_system_palette = to_js()
show_search_result = to_js()
def apply_font_settings(page_or_view): def apply_font_settings(page_or_view):
@ -419,6 +421,7 @@ class WebView(RestartingWebEngineView):
reload_book = pyqtSignal() reload_book = pyqtSignal()
toggle_toc = pyqtSignal() toggle_toc = pyqtSignal()
show_search = pyqtSignal() show_search = pyqtSignal()
search_result_not_found = pyqtSignal(object)
find_next = pyqtSignal(object) find_next = pyqtSignal(object)
toggle_bookmarks = pyqtSignal() toggle_bookmarks = pyqtSignal()
toggle_inspector = pyqtSignal() toggle_inspector = pyqtSignal()
@ -464,6 +467,7 @@ class WebView(RestartingWebEngineView):
self.bridge.reload_book.connect(self.reload_book) self.bridge.reload_book.connect(self.reload_book)
self.bridge.toggle_toc.connect(self.toggle_toc) self.bridge.toggle_toc.connect(self.toggle_toc)
self.bridge.show_search.connect(self.show_search) 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.find_next.connect(self.find_next)
self.bridge.toggle_bookmarks.connect(self.toggle_bookmarks) self.bridge.toggle_bookmarks.connect(self.toggle_bookmarks)
self.bridge.toggle_inspector.connect(self.toggle_inspector) self.bridge.toggle_inspector.connect(self.toggle_inspector)
@ -654,5 +658,8 @@ class WebView(RestartingWebEngineView):
def trigger_shortcut(self, which): def trigger_shortcut(self, which):
self.execute_when_ready('trigger_shortcut', 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): def palette_changed(self):
self.execute_when_ready('set_system_palette', system_colors()) self.execute_when_ready('set_system_palette', system_colors())

View File

@ -112,6 +112,7 @@ class IframeBoss:
'wheel_from_margin': self.wheel_from_margin, 'wheel_from_margin': self.wheel_from_margin,
'window_size': self.received_window_size, 'window_size': self.received_window_size,
'overlay_visibility_changed': self.on_overlay_visibility_changed, 'overlay_visibility_changed': self.on_overlay_visibility_changed,
'show_search_result': self.show_search_result,
} }
self.comm = IframeClient(handlers) self.comm = IframeClient(handlers)
self.last_window_ypos = 0 self.last_window_ypos = 0
@ -312,6 +313,8 @@ class IframeBoss:
self.scroll_to_ref(ipos.refnum) self.scroll_to_ref(ipos.refnum)
elif ipos.type is 'cfi': elif ipos.type is 'cfi':
self.jump_to_cfi(ipos.cfi) self.jump_to_cfi(ipos.cfi)
elif ipos.type is 'search_result':
self.show_search_result(ipos, True)
elif ipos.type is 'search': elif ipos.type is 'search':
self.find(ipos.search_data, True) self.find(ipos.search_data, True)
spine = self.book.manifest.spine spine = self.book.manifest.spine
@ -526,6 +529,18 @@ class IframeBoss:
else: else:
self.send_message('find_in_spine', text=data.text, backwards=data.backwards, searched_in_spine=data.searched_in_spine) 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): 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) self.send_message('reference_item_changed', refnum=ref_num_or_none, index=current_spine_item().index)

View File

@ -242,6 +242,9 @@ class View:
'update_cfi': self.on_update_cfi, 'update_cfi': self.on_update_cfi,
'update_progress_frac': self.on_update_progress_frac, 'update_progress_frac': self.on_update_progress_frac,
'update_toc_position': self.on_update_toc_position, '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' entry_point = None if runtime.is_standalone_viewer else 'read_book.iframe'
if runtime.is_standalone_viewer: if runtime.is_standalone_viewer:
@ -1072,3 +1075,9 @@ class View:
else: else:
div.style.display = 'block' div.style.display = 'block'
div.textContent = f'{index}.{refnum}' 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})

View File

@ -288,6 +288,12 @@ def trigger_shortcut(which):
view.on_handle_shortcut({'name': 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): def onerror(msg, script_url, line_number, column_number, error_object):
if not error_object: if not error_object:
# cross domain error # cross domain error
@ -393,6 +399,8 @@ if window is window.top:
to_python.customize_toolbar() to_python.customize_toolbar()
ui_operations.autoscroll_state_changed = def(active): ui_operations.autoscroll_state_changed = def(active):
to_python.autoscroll_state_changed(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')) document.body.appendChild(E.div(id='view'))
window.onerror = onerror window.onerror = onerror