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