1#!/usr/local/bin/python3.8
2# vim:fileencoding=utf-8
3# License: GPL v3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
4
5import json
6import regex
7from collections import Counter, OrderedDict
8from html import escape
9from qt.core import (
10    QCheckBox, QComboBox, QFont, QHBoxLayout, QIcon, QLabel, Qt, QToolButton,
11    QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget, pyqtSignal
12)
13from threading import Thread
14
15from calibre.ebooks.conversion.search_replace import REGEX_FLAGS
16from calibre.gui2 import warning_dialog
17from calibre.gui2.progress_indicator import ProgressIndicator
18from calibre.gui2.viewer.config import vprefs
19from calibre.gui2.viewer.web_view import get_data, get_manifest
20from calibre.gui2.viewer.widgets import ResultsDelegate, SearchBox
21from polyglot.builtins import iteritems
22from polyglot.functools import lru_cache
23from polyglot.queue import Queue
24
25
26class BusySpinner(QWidget):  # {{{
27
28    def __init__(self, parent=None):
29        QWidget.__init__(self, parent)
30        self.l = l = QHBoxLayout(self)
31        l.setContentsMargins(0, 0, 0, 0)
32        self.pi = ProgressIndicator(self, 24)
33        l.addWidget(self.pi)
34        self.la = la = QLabel(_('Searching...'))
35        l.addWidget(la)
36        l.addStretch(10)
37        self.is_running = False
38
39    def start(self):
40        self.setVisible(True)
41        self.pi.start()
42        self.is_running = True
43
44    def stop(self):
45        self.setVisible(False)
46        self.pi.stop()
47        self.is_running = False
48# }}}
49
50
51quote_map= {'"':'"“”', "'": "'‘’"}
52qpat = regex.compile(r'''(['"])''')
53spat = regex.compile(r'(\s+)')
54invisible_chars = '(?:[\u00ad\u200c\u200d]{0,1})'
55SEARCH_RESULT_ROLE = Qt.ItemDataRole.UserRole
56RESULT_NUMBER_ROLE = SEARCH_RESULT_ROLE + 1
57SPINE_IDX_ROLE = RESULT_NUMBER_ROLE + 1
58
59
60def text_to_regex(text):
61    has_leading = text.lstrip() != text
62    has_trailing = text.rstrip() != text
63    if text and not text.strip():
64        return r'\s+'
65    ans = []
66    for wpart in spat.split(text.strip()):
67        if not wpart.strip():
68            ans.append(r'\s+')
69        else:
70            for part in qpat.split(wpart):
71                r = quote_map.get(part)
72                if r is not None:
73                    ans.append('[' + r + ']')
74                else:
75                    part = invisible_chars.join(map(regex.escape, part))
76                    ans.append(part)
77    if has_leading:
78        ans.insert(0, r'\s+')
79    if has_trailing:
80        ans.append(r'\s+')
81    return ''.join(ans)
82
83
84class Search:
85
86    def __init__(self, text, mode, case_sensitive, backwards):
87        self.text, self.mode = text, mode
88        self.case_sensitive = case_sensitive
89        self.backwards = backwards
90        self._regex = None
91
92    def __eq__(self, other):
93        if not isinstance(other, Search):
94            return False
95        return self.text == other.text and self.mode == other.mode and self.case_sensitive == other.case_sensitive
96
97    @property
98    def regex(self):
99        if self._regex is None:
100            expr = self.text
101            flags = REGEX_FLAGS
102            if not self.case_sensitive:
103                flags = regex.IGNORECASE
104            if self.mode != 'regex':
105                if self.mode == 'word':
106                    words = []
107                    for part in expr.split():
108                        words.append(r'\b{}\b'.format(text_to_regex(part)))
109                    expr = r'\s+'.join(words)
110                else:
111                    expr = text_to_regex(expr)
112            self._regex = regex.compile(expr, flags)
113        return self._regex
114
115    def __str__(self):
116        from collections import namedtuple
117        s = ('text', 'mode', 'case_sensitive', 'backwards')
118        return str(namedtuple('Search', s)(*tuple(getattr(self, x) for x in s)))
119
120
121class SearchFinished:
122
123    def __init__(self, search_query):
124        self.search_query = search_query
125
126
127class SearchResult:
128
129    __slots__ = (
130        'search_query', 'before', 'text', 'after', 'q', 'spine_idx',
131        'index', 'file_name', 'is_hidden', 'offset', 'toc_nodes',
132        'result_num'
133    )
134
135    def __init__(self, search_query, before, text, after, q, name, spine_idx, index, offset, result_num):
136        self.search_query = search_query
137        self.q = q
138        self.result_num = result_num
139        self.before, self.text, self.after = before, text, after
140        self.spine_idx, self.index = spine_idx, index
141        self.file_name = name
142        self.is_hidden = False
143        self.offset = offset
144        try:
145            self.toc_nodes = toc_nodes_for_search_result(self)
146        except Exception:
147            import traceback
148            traceback.print_exc()
149            self.toc_nodes = ()
150
151    @property
152    def for_js(self):
153        return {
154            'file_name': self.file_name, 'spine_idx': self.spine_idx, 'index': self.index, 'text': self.text,
155            'before': self.before, 'after': self.after, 'mode': self.search_query.mode, 'q': self.q,
156            'result_num': self.result_num
157        }
158
159    def is_result(self, result_from_js):
160        return result_from_js['spine_idx'] == self.spine_idx and self.index == result_from_js['index'] and result_from_js['q'] == self.q
161
162    def __str__(self):
163        from collections import namedtuple
164        s = self.__slots__[:-1]
165        return str(namedtuple('SearchResult', s)(*tuple(getattr(self, x) for x in s)))
166
167
168@lru_cache(maxsize=None)
169def searchable_text_for_name(name):
170    ans = []
171    serialized_data = json.loads(get_data(name)[0])
172    stack = []
173    for child in serialized_data['tree']['c']:
174        if child.get('n') == 'body':
175            stack.append(child)
176    ignore_text = {'script', 'style', 'title'}
177    text_pos = 0
178    anchor_offset_map = OrderedDict()
179    while stack:
180        node = stack.pop()
181        if isinstance(node, str):
182            ans.append(node)
183            text_pos += len(node)
184            continue
185        g = node.get
186        name = g('n')
187        text = g('x')
188        tail = g('l')
189        children = g('c')
190        attributes = g('a')
191        if attributes:
192            for x in attributes:
193                if x[0] == 'id':
194                    aid = x[1]
195                    if aid not in anchor_offset_map:
196                        anchor_offset_map[aid] = text_pos
197        if name and text and name not in ignore_text:
198            ans.append(text)
199            text_pos += len(text)
200        if tail:
201            stack.append(tail)
202        if children:
203            stack.extend(reversed(children))
204    return ''.join(ans), anchor_offset_map
205
206
207@lru_cache(maxsize=2)
208def get_toc_data():
209    manifest = get_manifest() or {}
210    spine = manifest.get('spine') or []
211    spine_toc_map = {name: [] for name in spine}
212    parent_map = {}
213
214    def process_node(node):
215        items = spine_toc_map.get(node['dest'])
216        if items is not None:
217            items.append(node)
218        children = node.get('children')
219        if children:
220            for child in children:
221                parent_map[id(child)] = node
222                process_node(child)
223
224    toc = manifest.get('toc')
225    if toc:
226        process_node(toc)
227    return {
228        'spine': tuple(spine), 'spine_toc_map': spine_toc_map,
229        'spine_idx_map': {name: idx for idx, name in enumerate(spine)},
230        'parent_map': parent_map
231    }
232
233
234class ToCOffsetMap:
235
236    def __init__(self, toc_nodes=(), offset_map=None, previous_toc_node=None, parent_map=None):
237        self.toc_nodes = toc_nodes
238        self.offset_map = offset_map or {}
239        self.previous_toc_node = previous_toc_node
240        self.parent_map = parent_map or {}
241
242    def toc_nodes_for_offset(self, offset):
243        matches = []
244        for node in self.toc_nodes:
245            q = self.offset_map.get(node.get('id'))
246            if q is not None:
247                if q > offset:
248                    break
249                matches.append(node)
250        if not matches and self.previous_toc_node is not None:
251            matches.append(self.previous_toc_node)
252        if matches:
253            ancestors = []
254            node = matches[-1]
255            parent = self.parent_map.get(id(node))
256            while parent is not None:
257                ancestors.append(parent)
258                parent = self.parent_map.get(id(parent))
259            if len(ancestors) > 1:
260                ancestors.pop()  # root node
261                yield from reversed(ancestors)
262            yield node
263
264
265@lru_cache(maxsize=None)
266def toc_offset_map_for_name(name):
267    anchor_map = searchable_text_for_name(name)[1]
268    toc_data = get_toc_data()
269    try:
270        idx = toc_data['spine_idx_map'][name]
271        toc_nodes = toc_data['spine_toc_map'][name]
272    except Exception:
273        idx = -1
274    if idx < 0:
275        return ToCOffsetMap()
276    offset_map = {}
277    for node in toc_nodes:
278        node_id = node.get('id')
279        if node_id is not None:
280            aid = node.get('frag')
281            offset = anchor_map.get(aid, 0)
282            offset_map[node_id] = offset
283    prev_toc_node = None
284    for spine_name in reversed(toc_data['spine'][:idx]):
285        try:
286            ptn = toc_data['spine_toc_map'][spine_name]
287        except Exception:
288            continue
289        if ptn:
290            prev_toc_node = ptn[-1]
291            break
292    return ToCOffsetMap(toc_nodes, offset_map, prev_toc_node, toc_data['parent_map'])
293
294
295def toc_nodes_for_search_result(sr):
296    sidx = sr.spine_idx
297    toc_data = get_toc_data()
298    try:
299        name = toc_data['spine'][sidx]
300    except Exception:
301        return ()
302    tmap = toc_offset_map_for_name(name)
303    return tuple(tmap.toc_nodes_for_offset(sr.offset))
304
305
306def search_in_name(name, search_query, ctx_size=75):
307    raw = searchable_text_for_name(name)[0]
308    for match in search_query.regex.finditer(raw):
309        start, end = match.span()
310        before = raw[max(0, start-ctx_size):start]
311        after = raw[end:end+ctx_size]
312        yield before, match.group(), after, start
313
314
315class SearchInput(QWidget):  # {{{
316
317    do_search = pyqtSignal(object)
318    cleared = pyqtSignal()
319    go_back = pyqtSignal()
320
321    def __init__(self, parent=None, panel_name='search'):
322        QWidget.__init__(self, parent)
323        self.ignore_search_type_changes = False
324        self.l = l = QVBoxLayout(self)
325        l.setContentsMargins(0, 0, 0, 0)
326        h = QHBoxLayout()
327        h.setContentsMargins(0, 0, 0, 0)
328        l.addLayout(h)
329
330        self.search_box = sb = SearchBox(self)
331        self.panel_name = panel_name
332        sb.initialize('viewer-{}-panel-expression'.format(panel_name))
333        sb.item_selected.connect(self.saved_search_selected)
334        sb.history_saved.connect(self.history_saved)
335        sb.history_cleared.connect(self.history_cleared)
336        sb.cleared.connect(self.cleared)
337        sb.lineEdit().returnPressed.connect(self.find_next)
338        h.addWidget(sb)
339
340        self.next_button = nb = QToolButton(self)
341        h.addWidget(nb)
342        nb.setFocusPolicy(Qt.FocusPolicy.NoFocus)
343        nb.setIcon(QIcon(I('arrow-down.png')))
344        nb.clicked.connect(self.find_next)
345        nb.setToolTip(_('Find next match'))
346
347        self.prev_button = nb = QToolButton(self)
348        h.addWidget(nb)
349        nb.setFocusPolicy(Qt.FocusPolicy.NoFocus)
350        nb.setIcon(QIcon(I('arrow-up.png')))
351        nb.clicked.connect(self.find_previous)
352        nb.setToolTip(_('Find previous match'))
353
354        h = QHBoxLayout()
355        h.setContentsMargins(0, 0, 0, 0)
356        l.addLayout(h)
357        self.query_type = qt = QComboBox(self)
358        qt.setFocusPolicy(Qt.FocusPolicy.NoFocus)
359        qt.addItem(_('Contains'), 'normal')
360        qt.addItem(_('Whole words'), 'word')
361        qt.addItem(_('Regex'), 'regex')
362        qt.setToolTip('<p>' + _(
363            'Choose the type of search: <ul>'
364            '<li><b>Contains</b> will search for the entered text anywhere.'
365            '<li><b>Whole words</b> will search for whole words that equal the entered text.'
366            '<li><b>Regex</b> will interpret the text as a regular expression.'
367        ))
368        qt.setCurrentIndex(qt.findData(vprefs.get('viewer-{}-mode'.format(self.panel_name), 'normal') or 'normal'))
369        qt.currentIndexChanged.connect(self.save_search_type)
370        h.addWidget(qt)
371
372        self.case_sensitive = cs = QCheckBox(_('&Case sensitive'), self)
373        cs.setFocusPolicy(Qt.FocusPolicy.NoFocus)
374        cs.setChecked(bool(vprefs.get('viewer-{}-case-sensitive'.format(self.panel_name), False)))
375        cs.stateChanged.connect(self.save_search_type)
376        h.addWidget(cs)
377
378        self.return_button = rb = QToolButton(self)
379        rb.setIcon(QIcon(I('back.png')))
380        rb.setToolTip(_('Go back to where you were before searching'))
381        rb.clicked.connect(self.go_back)
382        h.addWidget(rb)
383
384    def history_saved(self, new_text, history):
385        if new_text:
386            sss = vprefs.get('saved-{}-settings'.format(self.panel_name)) or {}
387            sss[new_text] = {'case_sensitive': self.case_sensitive.isChecked(), 'mode': self.query_type.currentData()}
388            history = frozenset(history)
389            sss = {k: v for k, v in iteritems(sss) if k in history}
390            vprefs['saved-{}-settings'.format(self.panel_name)] = sss
391
392    def history_cleared(self):
393        vprefs['saved-{}-settings'.format(self.panel_name)] = {}
394
395    def save_search_type(self):
396        text = self.search_box.currentText()
397        if text and not self.ignore_search_type_changes:
398            sss = vprefs.get('saved-{}-settings'.format(self.panel_name)) or {}
399            sss[text] = {'case_sensitive': self.case_sensitive.isChecked(), 'mode': self.query_type.currentData()}
400            vprefs['saved-{}-settings'.format(self.panel_name)] = sss
401
402    def saved_search_selected(self):
403        text = self.search_box.currentText()
404        if text:
405            s = (vprefs.get('saved-{}-settings'.format(self.panel_name)) or {}).get(text)
406            if s:
407                self.ignore_search_type_changes = True
408                if 'case_sensitive' in s:
409                    self.case_sensitive.setChecked(s['case_sensitive'])
410                if 'mode' in s:
411                    idx = self.query_type.findData(s['mode'])
412                    if idx > -1:
413                        self.query_type.setCurrentIndex(idx)
414                self.ignore_search_type_changes = False
415            self.find_next()
416
417    def search_query(self, backwards=False):
418        text = self.search_box.currentText()
419        if text:
420            return Search(
421                text, self.query_type.currentData() or 'normal',
422                self.case_sensitive.isChecked(), backwards
423            )
424
425    def emit_search(self, backwards=False):
426        vprefs['viewer-{}-case-sensitive'.format(self.panel_name)] = self.case_sensitive.isChecked()
427        vprefs['viewer-{}-mode'.format(self.panel_name)] = self.query_type.currentData()
428        sq = self.search_query(backwards)
429        if sq is not None:
430            self.do_search.emit(sq)
431
432    def find_next(self):
433        self.emit_search()
434
435    def find_previous(self):
436        self.emit_search(backwards=True)
437
438    def focus_input(self, text=None):
439        if text and hasattr(text, 'rstrip'):
440            self.search_box.setText(text)
441        self.search_box.setFocus(Qt.FocusReason.OtherFocusReason)
442        le = self.search_box.lineEdit()
443        le.end(False)
444        le.selectAll()
445# }}}
446
447
448class Results(QTreeWidget):  # {{{
449
450    show_search_result = pyqtSignal(object)
451    current_result_changed = pyqtSignal(object)
452    count_changed = pyqtSignal(object)
453
454    def __init__(self, parent=None):
455        QTreeWidget.__init__(self, parent)
456        self.setHeaderHidden(True)
457        self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
458        self.delegate = ResultsDelegate(self)
459        self.setItemDelegate(self.delegate)
460        self.itemClicked.connect(self.item_activated)
461        self.blank_icon = QIcon(I('blank.png'))
462        self.not_found_icon = QIcon(I('dialog_warning.png'))
463        self.currentItemChanged.connect(self.current_item_changed)
464        self.section_font = QFont(self.font())
465        self.section_font.setItalic(True)
466        self.section_map = {}
467        self.search_results = []
468        self.item_map = {}
469
470    def current_item_changed(self, current, previous):
471        if current is not None:
472            r = current.data(0, SEARCH_RESULT_ROLE)
473            if isinstance(r, SearchResult):
474                self.current_result_changed.emit(r)
475        else:
476            self.current_result_changed.emit(None)
477
478    def add_result(self, result):
479        section_title = _('Unknown')
480        section_id = -1
481        toc_nodes = getattr(result, 'toc_nodes', ()) or ()
482        if toc_nodes:
483            section_title = toc_nodes[-1].get('title') or _('Unknown')
484            section_id = toc_nodes[-1].get('id')
485            if section_id is None:
486                section_id = -1
487        section_key = section_id
488        section = self.section_map.get(section_key)
489        spine_idx = getattr(result, 'spine_idx', -1)
490        if section is None:
491            section = QTreeWidgetItem([section_title], 1)
492            section.setFlags(Qt.ItemFlag.ItemIsEnabled)
493            section.setFont(0, self.section_font)
494            section.setData(0, SPINE_IDX_ROLE, spine_idx)
495            lines = []
496            for i, node in enumerate(toc_nodes):
497                lines.append('\xa0\xa0' * i + '➤ ' + (node.get('title') or _('Unknown')))
498            if lines:
499                tt = ngettext('Table of Contents section:', 'Table of Contents sections:', len(lines))
500                tt += '\n' + '\n'.join(lines)
501                section.setToolTip(0, tt)
502            self.section_map[section_key] = section
503            for s in range(self.topLevelItemCount()):
504                ti = self.topLevelItem(s)
505                if ti.data(0, SPINE_IDX_ROLE) > spine_idx:
506                    self.insertTopLevelItem(s, section)
507                    break
508            else:
509                self.addTopLevelItem(section)
510            section.setExpanded(True)
511        item = QTreeWidgetItem(section, [' '], 2)
512        item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemNeverHasChildren)
513        item.setData(0, SEARCH_RESULT_ROLE, result)
514        item.setData(0, RESULT_NUMBER_ROLE, len(self.search_results))
515        item.setData(0, SPINE_IDX_ROLE, spine_idx)
516        if isinstance(result, SearchResult):
517            tt = '<p>…' + escape(result.before, False) + '<b>' + escape(
518                result.text, False) + '</b>' + escape(result.after, False) + '…'
519            item.setData(0, Qt.ItemDataRole.ToolTipRole, tt)
520        item.setIcon(0, self.blank_icon)
521        self.item_map[len(self.search_results)] = item
522        self.search_results.append(result)
523        n = self.number_of_results
524        self.count_changed.emit(n)
525
526    def item_activated(self):
527        i = self.currentItem()
528        if i:
529            sr = i.data(0, SEARCH_RESULT_ROLE)
530            if isinstance(sr, SearchResult):
531                if not sr.is_hidden:
532                    self.show_search_result.emit(sr)
533
534    def find_next(self, previous):
535        if self.number_of_results < 1:
536            return
537        item = self.currentItem()
538        if item is None:
539            return
540        i = int(item.data(0, RESULT_NUMBER_ROLE))
541        i += -1 if previous else 1
542        i %= self.number_of_results
543        self.setCurrentItem(self.item_map[i])
544        self.item_activated()
545
546    def search_result_not_found(self, sr):
547        for i in range(self.number_of_results):
548            item = self.item_map[i]
549            r = item.data(0, SEARCH_RESULT_ROLE)
550            if r.is_result(sr):
551                r.is_hidden = True
552                item.setIcon(0, self.not_found_icon)
553                break
554
555    def search_result_discovered(self, sr):
556        q = sr['result_num']
557        for i in range(self.number_of_results):
558            item = self.item_map[i]
559            r = item.data(0, SEARCH_RESULT_ROLE)
560            if r.result_num == q:
561                self.setCurrentItem(item)
562
563    @property
564    def current_result_is_hidden(self):
565        item = self.currentItem()
566        if item is not None:
567            sr = item.data(0, SEARCH_RESULT_ROLE)
568            if isinstance(sr, SearchResult) and sr.is_hidden:
569                return True
570        return False
571
572    @property
573    def number_of_results(self):
574        return len(self.search_results)
575
576    def clear_all_results(self):
577        self.section_map = {}
578        self.item_map = {}
579        self.search_results = []
580        self.clear()
581        self.count_changed.emit(-1)
582
583    def select_first_result(self):
584        if self.number_of_results:
585            item = self.item_map[0]
586            self.setCurrentItem(item)
587
588    def ensure_current_result_visible(self):
589        item = self.currentItem()
590        if item is not None:
591            self.scrollToItem(item)
592# }}}
593
594
595class SearchPanel(QWidget):  # {{{
596
597    search_requested = pyqtSignal(object)
598    results_found = pyqtSignal(object)
599    show_search_result = pyqtSignal(object)
600    count_changed = pyqtSignal(object)
601    hide_search_panel = pyqtSignal()
602    goto_cfi = pyqtSignal(object)
603
604    def __init__(self, parent=None):
605        QWidget.__init__(self, parent)
606        self.discovery_counter = 0
607        self.last_hidden_text_warning = None
608        self.current_search = None
609        self.anchor_cfi = None
610        self.l = l = QVBoxLayout(self)
611        l.setContentsMargins(0, 0, 0, 0)
612        self.search_input = si = SearchInput(self)
613        self.searcher = None
614        self.search_tasks = Queue()
615        self.results_found.connect(self.on_result_found, type=Qt.ConnectionType.QueuedConnection)
616        si.do_search.connect(self.search_requested)
617        si.cleared.connect(self.search_cleared)
618        si.go_back.connect(self.go_back)
619        l.addWidget(si)
620        self.results = r = Results(self)
621        r.count_changed.connect(self.count_changed)
622        r.show_search_result.connect(self.do_show_search_result, type=Qt.ConnectionType.QueuedConnection)
623        r.current_result_changed.connect(self.update_hidden_message)
624        l.addWidget(r, 100)
625        self.spinner = s = BusySpinner(self)
626        s.setVisible(False)
627        l.addWidget(s)
628        self.hidden_message = la = QLabel(_('This text is hidden in the book and cannot be displayed'))
629        la.setStyleSheet('QLabel { margin-left: 1ex }')
630        la.setWordWrap(True)
631        la.setVisible(False)
632        l.addWidget(la)
633
634    def go_back(self):
635        if self.anchor_cfi:
636            self.goto_cfi.emit(self.anchor_cfi)
637
638    def update_hidden_message(self):
639        self.hidden_message.setVisible(self.results.current_result_is_hidden)
640
641    def focus_input(self, text=None):
642        self.search_input.focus_input(text)
643
644    def search_cleared(self):
645        self.results.clear_all_results()
646        self.current_search = None
647
648    def start_search(self, search_query, current_name):
649        if self.current_search is not None and search_query == self.current_search:
650            self.find_next_requested(search_query.backwards)
651            return
652        if self.searcher is None:
653            self.searcher = Thread(name='Searcher', target=self.run_searches)
654            self.searcher.daemon = True
655            self.searcher.start()
656        self.results.clear_all_results()
657        self.hidden_message.setVisible(False)
658        self.spinner.start()
659        self.current_search = search_query
660        self.last_hidden_text_warning = None
661        self.search_tasks.put((search_query, current_name))
662        self.discovery_counter += 1
663
664    def set_anchor_cfi(self, pos_data):
665        self.anchor_cfi = pos_data['cfi']
666
667    def run_searches(self):
668        while True:
669            x = self.search_tasks.get()
670            if x is None:
671                break
672            search_query, current_name = x
673            try:
674                manifest = get_manifest() or {}
675                spine = manifest.get('spine', ())
676                idx_map = {name: i for i, name in enumerate(spine)}
677                spine_idx = idx_map.get(current_name, -1)
678            except Exception:
679                import traceback
680                traceback.print_exc()
681                spine_idx = -1
682            if spine_idx < 0:
683                self.results_found.emit(SearchFinished(search_query))
684                continue
685            num_in_spine = len(spine)
686            result_num = 0
687            for n in range(num_in_spine):
688                idx = (spine_idx + n) % num_in_spine
689                name = spine[idx]
690                counter = Counter()
691                try:
692                    for i, result in enumerate(search_in_name(name, search_query)):
693                        before, text, after, offset = result
694                        q = (before or '')[-15:] + text + (after or '')[:15]
695                        result_num += 1
696                        self.results_found.emit(SearchResult(search_query, before, text, after, q, name, idx, counter[q], offset, result_num))
697                        counter[q] += 1
698                except Exception:
699                    import traceback
700                    traceback.print_exc()
701            self.results_found.emit(SearchFinished(search_query))
702
703    def on_result_found(self, result):
704        if self.current_search is None or result.search_query != self.current_search:
705            return
706        if isinstance(result, SearchFinished):
707            self.spinner.stop()
708            if self.results.number_of_results:
709                self.results.ensure_current_result_visible()
710            else:
711                self.show_no_results_found()
712            return
713        self.results.add_result(result)
714        obj = result.for_js
715        obj['on_discovery'] = self.discovery_counter
716        self.show_search_result.emit(obj)
717        self.update_hidden_message()
718
719    def visibility_changed(self, visible):
720        if visible:
721            self.focus_input()
722
723    def clear_searches(self):
724        self.current_search = None
725        self.last_hidden_text_warning = None
726        searchable_text_for_name.cache_clear()
727        toc_offset_map_for_name.cache_clear()
728        get_toc_data.cache_clear()
729        self.spinner.stop()
730        self.results.clear_all_results()
731
732    def shutdown(self):
733        self.search_tasks.put(None)
734        self.spinner.stop()
735        self.current_search = None
736        self.last_hidden_text_warning = None
737        self.searcher = None
738
739    def find_next_requested(self, previous):
740        self.results.find_next(previous)
741
742    def trigger(self):
743        self.search_input.find_next()
744
745    def do_show_search_result(self, sr):
746        self.show_search_result.emit(sr.for_js)
747
748    def search_result_not_found(self, sr):
749        self.results.search_result_not_found(sr)
750        self.update_hidden_message()
751
752    def search_result_discovered(self, sr):
753        self.results.search_result_discovered(sr)
754
755    def show_no_results_found(self):
756        msg = _('No matches were found for:')
757        warning_dialog(self, _('No matches found'), msg + '  <b>{}</b>'.format(self.current_search.text), show=True)
758
759    def keyPressEvent(self, ev):
760        if ev.key() == Qt.Key.Key_Escape:
761            self.hide_search_panel.emit()
762            ev.accept()
763            return
764        return QWidget.keyPressEvent(self, ev)
765# }}}
766