1#!/usr/local/bin/python3.8 2# vim:fileencoding=utf-8 3# License: GPLv3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net> 4 5 6from qt.core import ( 7 QWidget, QHBoxLayout, QVBoxLayout, QLabel, QComboBox, QPushButton, QIcon, 8 pyqtSignal, QFont, QCheckBox, QSizePolicy 9) 10from lxml.etree import tostring 11 12from calibre import prepare_string_for_xml 13from calibre.gui2 import error_dialog 14from calibre.gui2.tweak_book import tprefs, editors, current_container 15from calibre.gui2.tweak_book.search import get_search_regex, InvalidRegex, initialize_search_request 16from calibre.gui2.tweak_book.widgets import BusyCursor 17from calibre.gui2.widgets2 import HistoryComboBox 18from polyglot.builtins import iteritems, error_message 19 20# UI {{{ 21 22 23class ModeBox(QComboBox): 24 25 def __init__(self, parent): 26 QComboBox.__init__(self, parent) 27 self.addItems([_('Normal'), _('Regex')]) 28 self.setToolTip('<style>dd {margin-bottom: 1.5ex}</style>' + _( 29 '''Select how the search expression is interpreted 30 <dl> 31 <dt><b>Normal</b></dt> 32 <dd>The search expression is treated as normal text, calibre will look for the exact text.</dd> 33 <dt><b>Regex</b></dt> 34 <dd>The search expression is interpreted as a regular expression. See the User Manual for more help on using regular expressions.</dd> 35 </dl>''')) 36 37 @property 38 def mode(self): 39 return ('normal', 'regex')[self.currentIndex()] 40 41 @mode.setter 42 def mode(self, val): 43 self.setCurrentIndex({'regex':1}.get(val, 0)) 44 45 46class WhereBox(QComboBox): 47 48 def __init__(self, parent, emphasize=False): 49 QComboBox.__init__(self) 50 self.addItems([_('Current file'), _('All text files'), _('Selected files'), _('Open files')]) 51 self.setToolTip('<style>dd {margin-bottom: 1.5ex}</style>' + _( 52 ''' 53 Where to search/replace: 54 <dl> 55 <dt><b>Current file</b></dt> 56 <dd>Search only inside the currently opened file</dd> 57 <dt><b>All text files</b></dt> 58 <dd>Search in all text (HTML) files</dd> 59 <dt><b>Selected files</b></dt> 60 <dd>Search in the files currently selected in the File browser</dd> 61 <dt><b>Open files</b></dt> 62 <dd>Search in the files currently open in the editor</dd> 63 </dl>''')) 64 self.emphasize = emphasize 65 self.ofont = QFont(self.font()) 66 if emphasize: 67 f = self.emph_font = QFont(self.ofont) 68 f.setBold(True), f.setItalic(True) 69 self.setFont(f) 70 71 @property 72 def where(self): 73 wm = {0:'current', 1:'text', 2:'selected', 3:'open'} 74 return wm[self.currentIndex()] 75 76 @where.setter 77 def where(self, val): 78 wm = {0:'current', 1:'text', 2:'selected', 3:'open'} 79 self.setCurrentIndex({v:k for k, v in iteritems(wm)}[val]) 80 81 def showPopup(self): 82 # We do it like this so that the popup uses a normal font 83 if self.emphasize: 84 self.setFont(self.ofont) 85 QComboBox.showPopup(self) 86 87 def hidePopup(self): 88 if self.emphasize: 89 self.setFont(self.emph_font) 90 QComboBox.hidePopup(self) 91 92 93class TextSearch(QWidget): 94 95 find_text = pyqtSignal(object) 96 97 def __init__(self, ui): 98 QWidget.__init__(self, ui) 99 self.l = l = QVBoxLayout(self) 100 self.la = la = QLabel(_('&Find:')) 101 self.find = ft = HistoryComboBox(self) 102 ft.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) 103 ft.initialize('tweak_book_text_search_history') 104 la.setBuddy(ft) 105 self.h = h = QHBoxLayout() 106 h.addWidget(la), h.addWidget(ft), l.addLayout(h) 107 108 self.h2 = h = QHBoxLayout() 109 l.addLayout(h) 110 111 self.mode = m = ModeBox(self) 112 h.addWidget(m) 113 self.where_box = wb = WhereBox(self) 114 h.addWidget(wb) 115 self.cs = cs = QCheckBox(_('&Case sensitive')) 116 h.addWidget(cs) 117 self.da = da = QCheckBox(_('&Dot all')) 118 da.setToolTip('<p>'+_("Make the '.' special character match any character at all, including a newline")) 119 h.addWidget(da) 120 121 self.h3 = h = QHBoxLayout() 122 l.addLayout(h) 123 h.addStretch(10) 124 self.next_button = b = QPushButton(QIcon(I('arrow-down.png')), _('&Next'), self) 125 b.setToolTip(_('Find next match')) 126 h.addWidget(b) 127 connect_lambda(b.clicked, self, lambda self: self.do_search('down')) 128 self.prev_button = b = QPushButton(QIcon(I('arrow-up.png')), _('&Previous'), self) 129 b.setToolTip(_('Find previous match')) 130 h.addWidget(b) 131 connect_lambda(b.clicked, self, lambda self: self.do_search('up')) 132 133 state = tprefs.get('text_search_widget_state') 134 self.state = state or {} 135 136 @property 137 def state(self): 138 return {'mode': self.mode.mode, 'where':self.where_box.where, 'case_sensitive':self.cs.isChecked(), 'dot_all':self.da.isChecked()} 139 140 @state.setter 141 def state(self, val): 142 self.mode.mode = val.get('mode', 'normal') 143 self.where_box.where = val.get('where', 'current') 144 self.cs.setChecked(bool(val.get('case_sensitive'))) 145 self.da.setChecked(bool(val.get('dot_all', True))) 146 147 def save_state(self): 148 tprefs['text_search_widget_state'] = self.state 149 150 def do_search(self, direction='down'): 151 state = self.state 152 state['find'] = self.find.text() 153 state['direction'] = direction 154 self.find_text.emit(state) 155# }}} 156 157 158def file_matches_pattern(fname, pat): 159 root = current_container().parsed(fname) 160 if hasattr(root, 'xpath'): 161 raw = tostring(root, method='text', encoding='unicode', with_tail=True) 162 else: 163 raw = current_container().raw_data(fname) 164 return pat.search(raw) is not None 165 166 167def run_text_search(search, current_editor, current_editor_name, searchable_names, gui_parent, show_editor, edit_file): 168 try: 169 pat = get_search_regex(search) 170 except InvalidRegex as e: 171 return error_dialog(gui_parent, _('Invalid regex'), '<p>' + _( 172 'The regular expression you entered is invalid: <pre>{0}</pre>With error: {1}').format( 173 prepare_string_for_xml(e.regex), error_message(e)), show=True) 174 editor, where, files, do_all, marked = initialize_search_request(search, 'count', current_editor, current_editor_name, searchable_names) 175 with BusyCursor(): 176 if editor is not None: 177 if editor.find_text(pat): 178 return True 179 if not files and editor.find_text(pat, wrap=True): 180 return True 181 for fname, syntax in iteritems(files): 182 ed = editors.get(fname, None) 183 if ed is not None: 184 if ed.find_text(pat, complete=True): 185 show_editor(fname) 186 return True 187 else: 188 if file_matches_pattern(fname, pat): 189 edit_file(fname, syntax) 190 if editors[fname].find_text(pat, complete=True): 191 return True 192 193 msg = '<p>' + _('No matches were found for %s') % ('<pre style="font-style:italic">' + prepare_string_for_xml(search['find']) + '</pre>') 194 return error_dialog(gui_parent, _('Not found'), msg, show=True) 195 196 197def find_text_in_chunks(pat, chunks): 198 text = ''.join(x[0] for x in chunks) 199 m = pat.search(text) 200 if m is None: 201 return -1, -1 202 start, after = m.span() 203 204 def contains(clen, pt): 205 return offset <= pt < offset + clen 206 207 offset = 0 208 start_pos = end_pos = None 209 210 for chunk, chunk_start in chunks: 211 clen = len(chunk) 212 if offset + clen < start: 213 offset += clen 214 continue # this chunk ends before start 215 if start_pos is None: 216 if contains(clen, start): 217 start_pos = chunk_start + (start - offset) 218 if start_pos is not None: 219 if contains(clen, after-1): 220 end_pos = chunk_start + (after - offset) 221 return start_pos, end_pos 222 offset += clen 223 if offset > after: 224 break # the next chunk starts after end 225 return -1, -1 226