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