mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-06-23 15:30:45 -04:00
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:
parent
1a69773b74
commit
5bd3a88bc3
@ -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)
|
||||||
# }}}
|
# }}}
|
||||||
|
@ -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)
|
||||||
|
@ -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())
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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})
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user