1#!/usr/local/bin/python3.8
2# vim:fileencoding=utf-8
3# License: GPLv3 Copyright: 2013, Kovid Goyal <kovid at kovidgoyal.net>
4
5
6import importlib
7import os
8import re
9import regex
10import textwrap
11import unicodedata
12from qt.core import (
13    QColor, QColorDialog, QFont, QFontDatabase, QKeySequence, QPainter, QPalette,
14    QPlainTextEdit, QRect, QSize, Qt, QTextCursor, QTextEdit, QTextFormat, QTimer,
15    QToolTip, QWidget, pyqtSignal
16)
17
18from calibre import prepare_string_for_xml
19from calibre.ebooks.oeb.base import OEB_DOCS, OEB_STYLES, css_text
20from calibre.ebooks.oeb.polish.replace import get_recommended_folders
21from calibre.ebooks.oeb.polish.utils import guess_type
22from calibre.gui2.tweak_book import (
23    CONTAINER_DND_MIMETYPE, TOP, current_container, tprefs
24)
25from calibre.gui2.tweak_book.completion.popup import CompletionPopup
26from calibre.gui2.tweak_book.editor import (
27    CLASS_ATTRIBUTE_PROPERTY, LINK_PROPERTY, SPELL_LOCALE_PROPERTY, SPELL_PROPERTY,
28    SYNTAX_PROPERTY, store_locale
29)
30from calibre.gui2.tweak_book.editor.smarts import NullSmarts
31from calibre.gui2.tweak_book.editor.snippets import SnippetManager
32from calibre.gui2.tweak_book.editor.syntax.base import SyntaxHighlighter
33from calibre.gui2.tweak_book.editor.themes import (
34    get_theme, theme_color, theme_format
35)
36from calibre.gui2.tweak_book.widgets import PARAGRAPH_SEPARATOR, PlainTextEdit
37from calibre.spell.break_iterator import index_of
38from calibre.utils.icu import (
39    capitalize, lower, safe_chr, string_length, swapcase, upper
40)
41from calibre.utils.img import image_to_data
42from calibre.utils.titlecase import titlecase
43from polyglot.builtins import as_unicode
44
45
46def get_highlighter(syntax):
47    if syntax:
48        try:
49            return importlib.import_module('calibre.gui2.tweak_book.editor.syntax.' + syntax).Highlighter
50        except (ImportError, AttributeError):
51            pass
52    return SyntaxHighlighter
53
54
55def get_smarts(syntax):
56    if syntax:
57        smartsname = {'xml':'html'}.get(syntax, syntax)
58        try:
59            return importlib.import_module('calibre.gui2.tweak_book.editor.smarts.' + smartsname).Smarts
60        except (ImportError, AttributeError):
61            pass
62
63
64_dff = None
65
66
67def default_font_family():
68    global _dff
69    if _dff is None:
70        families = set(map(str, QFontDatabase().families()))
71        for x in ('Ubuntu Mono', 'Consolas', 'Liberation Mono'):
72            if x in families:
73                _dff = x
74                break
75        if _dff is None:
76            _dff = 'Courier New'
77    return _dff
78
79
80class LineNumbers(QWidget):  # {{{
81
82    def __init__(self, parent):
83        QWidget.__init__(self, parent)
84
85    def sizeHint(self):
86        return QSize(self.parent().line_number_area_width(), 0)
87
88    def paintEvent(self, ev):
89        self.parent().paint_line_numbers(ev)
90# }}}
91
92
93class TextEdit(PlainTextEdit):
94
95    link_clicked = pyqtSignal(object)
96    class_clicked = pyqtSignal(object)
97    smart_highlighting_updated = pyqtSignal()
98
99    def __init__(self, parent=None, expected_geometry=(100, 50)):
100        PlainTextEdit.__init__(self, parent)
101        self.snippet_manager = SnippetManager(self)
102        self.completion_popup = CompletionPopup(self)
103        self.request_completion = self.completion_doc_name = None
104        self.clear_completion_cache_timer = t = QTimer(self)
105        t.setInterval(5000), t.timeout.connect(self.clear_completion_cache), t.setSingleShot(True)
106        self.textChanged.connect(t.start)
107        self.last_completion_request = -1
108        self.gutter_width = 0
109        self.tw = 2
110        self.expected_geometry = expected_geometry
111        self.saved_matches = {}
112        self.syntax = None
113        self.smarts = NullSmarts(self)
114        self.current_cursor_line = None
115        self.current_search_mark = None
116        self.smarts_highlight_timer = t = QTimer()
117        t.setInterval(750), t.setSingleShot(True), t.timeout.connect(self.update_extra_selections)
118        self.highlighter = SyntaxHighlighter()
119        self.line_number_area = LineNumbers(self)
120        self.apply_settings()
121        self.setMouseTracking(True)
122        self.cursorPositionChanged.connect(self.highlight_cursor_line)
123        self.blockCountChanged[int].connect(self.update_line_number_area_width)
124        self.updateRequest.connect(self.update_line_number_area)
125
126    def get_droppable_files(self, md):
127
128        def is_mt_ok(mt):
129            return self.syntax == 'html' and (
130                mt in OEB_DOCS or mt in OEB_STYLES or mt.startswith('image/')
131            )
132
133        if md.hasFormat(CONTAINER_DND_MIMETYPE):
134            for line in as_unicode(bytes(md.data(CONTAINER_DND_MIMETYPE))).splitlines():
135                mt = current_container().mime_map.get(line, 'application/octet-stream')
136                if is_mt_ok(mt):
137                    yield line, mt, True
138            return
139        for qurl in md.urls():
140            if qurl.isLocalFile() and os.access(qurl.toLocalFile(), os.R_OK):
141                path = qurl.toLocalFile()
142                mt = guess_type(path)
143                if is_mt_ok(mt):
144                    yield path, mt, False
145
146    def canInsertFromMimeData(self, md):
147        if md.hasText() or (md.hasHtml() and self.syntax == 'html') or md.hasImage():
148            return True
149        elif tuple(self.get_droppable_files(md)):
150            return True
151        return False
152
153    def insertFromMimeData(self, md):
154        files = tuple(self.get_droppable_files(md))
155        base = self.highlighter.doc_name or None
156
157        def get_name(name):
158            folder = get_recommended_folders(current_container(), (name,))[name] or ''
159            if folder:
160                folder += '/'
161            return folder + name
162
163        def get_href(name):
164            return current_container().name_to_href(name, base)
165
166        def insert_text(text):
167            c = self.textCursor()
168            c.insertText(text)
169            self.setTextCursor(c)
170            self.ensureCursorVisible()
171
172        def add_file(name, data, mt=None):
173            from calibre.gui2.tweak_book.boss import get_boss
174            name = current_container().add_file(name, data, media_type=mt, modify_name_if_needed=True)
175            get_boss().refresh_file_list()
176            return name
177
178        if files:
179            for path, mt, is_name in files:
180                if is_name:
181                    name = path
182                else:
183                    name = get_name(os.path.basename(path))
184                    with lopen(path, 'rb') as f:
185                        name = add_file(name, f.read(), mt)
186                href = get_href(name)
187                if mt.startswith('image/'):
188                    self.insert_image(href)
189                elif mt in OEB_STYLES:
190                    insert_text('<link href="{}" rel="stylesheet" type="text/css"/>'.format(href))
191                elif mt in OEB_DOCS:
192                    self.insert_hyperlink(href, name)
193            self.ensureCursorVisible()
194            return
195        if md.hasImage():
196            img = md.imageData()
197            if img is not None and not img.isNull():
198                data = image_to_data(img, fmt='PNG')
199                name = add_file(get_name('dropped_image.png'), data)
200                self.insert_image(get_href(name))
201                self.ensureCursorVisible()
202                return
203        if md.hasText():
204            return insert_text(md.text())
205        if md.hasHtml():
206            insert_text(md.html())
207            return
208
209    @property
210    def is_modified(self):
211        ''' True if the document has been modified since it was loaded or since
212        the last time is_modified was set to False. '''
213        return self.document().isModified()
214
215    @is_modified.setter
216    def is_modified(self, val):
217        self.document().setModified(bool(val))
218
219    def sizeHint(self):
220        return self.size_hint
221
222    def apply_settings(self, prefs=None, dictionaries_changed=False):  # {{{
223        prefs = prefs or tprefs
224        self.setAcceptDrops(prefs.get('editor_accepts_drops', True))
225        self.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth if prefs['editor_line_wrap'] else QPlainTextEdit.LineWrapMode.NoWrap)
226        theme = get_theme(prefs['editor_theme'])
227        self.apply_theme(theme)
228        w = self.fontMetrics()
229        self.space_width = w.width(' ')
230        self.tw = self.smarts.override_tab_stop_width if self.smarts.override_tab_stop_width is not None else prefs['editor_tab_stop_width']
231        self.setTabStopWidth(self.tw * self.space_width)
232        if dictionaries_changed:
233            self.highlighter.rehighlight()
234
235    def apply_theme(self, theme):
236        self.theme = theme
237        pal = self.palette()
238        pal.setColor(QPalette.ColorRole.Base, theme_color(theme, 'Normal', 'bg'))
239        pal.setColor(QPalette.ColorRole.AlternateBase, theme_color(theme, 'CursorLine', 'bg'))
240        pal.setColor(QPalette.ColorRole.Text, theme_color(theme, 'Normal', 'fg'))
241        pal.setColor(QPalette.ColorRole.Highlight, theme_color(theme, 'Visual', 'bg'))
242        pal.setColor(QPalette.ColorRole.HighlightedText, theme_color(theme, 'Visual', 'fg'))
243        self.setPalette(pal)
244        self.tooltip_palette = pal = QPalette()
245        pal.setColor(QPalette.ColorRole.ToolTipBase, theme_color(theme, 'Tooltip', 'bg'))
246        pal.setColor(QPalette.ColorRole.ToolTipText, theme_color(theme, 'Tooltip', 'fg'))
247        self.line_number_palette = pal = QPalette()
248        pal.setColor(QPalette.ColorRole.Base, theme_color(theme, 'LineNr', 'bg'))
249        pal.setColor(QPalette.ColorRole.Text, theme_color(theme, 'LineNr', 'fg'))
250        pal.setColor(QPalette.ColorRole.BrightText, theme_color(theme, 'LineNrC', 'fg'))
251        self.match_paren_format = theme_format(theme, 'MatchParen')
252        font = self.font()
253        ff = tprefs['editor_font_family']
254        if ff is None:
255            ff = default_font_family()
256        font.setFamily(ff)
257        font.setPointSize(tprefs['editor_font_size'])
258        self.tooltip_font = QFont(font)
259        self.tooltip_font.setPointSize(font.pointSize() - 1)
260        self.setFont(font)
261        self.highlighter.apply_theme(theme)
262        w = self.fontMetrics()
263        self.number_width = max(map(lambda x:w.width(str(x)), range(10)))
264        self.size_hint = QSize(self.expected_geometry[0] * w.averageCharWidth(), self.expected_geometry[1] * w.height())
265        self.highlight_color = theme_color(theme, 'HighlightRegion', 'bg')
266        self.highlight_cursor_line()
267        self.completion_popup.clear_caches(), self.completion_popup.update()
268    # }}}
269
270    def load_text(self, text, syntax='html', process_template=False, doc_name=None):
271        self.syntax = syntax
272        self.highlighter = get_highlighter(syntax)()
273        self.highlighter.apply_theme(self.theme)
274        self.highlighter.set_document(self.document(), doc_name=doc_name)
275        sclass = get_smarts(syntax)
276        if sclass is not None:
277            self.smarts = sclass(self)
278            if self.smarts.override_tab_stop_width is not None:
279                self.tw = self.smarts.override_tab_stop_width
280                self.setTabStopWidth(self.tw * self.space_width)
281        if isinstance(text, bytes):
282            text = text.decode('utf-8', 'replace')
283        self.setPlainText(unicodedata.normalize('NFC', str(text)))
284        if process_template and QPlainTextEdit.find(self, '%CURSOR%'):
285            c = self.textCursor()
286            c.insertText('')
287
288    def change_document_name(self, newname):
289        self.highlighter.doc_name = newname
290        self.highlighter.rehighlight()  # Ensure links are checked w.r.t. the new name correctly
291
292    def replace_text(self, text):
293        c = self.textCursor()
294        pos = c.position()
295        c.beginEditBlock()
296        c.clearSelection()
297        c.select(QTextCursor.SelectionType.Document)
298        c.insertText(unicodedata.normalize('NFC', text))
299        c.endEditBlock()
300        c.setPosition(min(pos, len(text)))
301        self.setTextCursor(c)
302        self.ensureCursorVisible()
303
304    def simple_replace(self, text, cursor=None):
305        c = cursor or self.textCursor()
306        c.insertText(unicodedata.normalize('NFC', text))
307        self.setTextCursor(c)
308
309    def go_to_line(self, lnum, col=None):
310        lnum = max(1, min(self.blockCount(), lnum))
311        c = self.textCursor()
312        c.clearSelection()
313        c.movePosition(QTextCursor.MoveOperation.Start)
314        c.movePosition(QTextCursor.MoveOperation.NextBlock, n=lnum - 1)
315        c.movePosition(QTextCursor.MoveOperation.StartOfLine)
316        c.movePosition(QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor)
317        text = str(c.selectedText()).rstrip('\0')
318        if col is None:
319            c.movePosition(QTextCursor.MoveOperation.StartOfLine)
320            lt = text.lstrip()
321            if text and lt and lt != text:
322                c.movePosition(QTextCursor.MoveOperation.NextWord)
323        else:
324            c.setPosition(c.block().position() + col)
325            if c.blockNumber() + 1 > lnum:
326                # We have moved past the end of the line
327                c.setPosition(c.block().position())
328                c.movePosition(QTextCursor.MoveOperation.EndOfBlock)
329        self.setTextCursor(c)
330        self.ensureCursorVisible()
331
332    def update_extra_selections(self, instant=True):
333        sel = []
334        if self.current_cursor_line is not None:
335            sel.append(self.current_cursor_line)
336        if self.current_search_mark is not None:
337            sel.append(self.current_search_mark)
338        if instant and not self.highlighter.has_requests and self.smarts is not None:
339            sel.extend(self.smarts.get_extra_selections(self))
340            self.smart_highlighting_updated.emit()
341        else:
342            self.smarts_highlight_timer.start()
343        self.setExtraSelections(sel)
344
345    # Search and replace {{{
346    def mark_selected_text(self):
347        sel = QTextEdit.ExtraSelection()
348        sel.format.setBackground(self.highlight_color)
349        sel.cursor = self.textCursor()
350        if sel.cursor.hasSelection():
351            self.current_search_mark = sel
352            c = self.textCursor()
353            c.clearSelection()
354            self.setTextCursor(c)
355        else:
356            self.current_search_mark = None
357        self.update_extra_selections()
358
359    def find_in_marked(self, pat, wrap=False, save_match=None):
360        if self.current_search_mark is None:
361            return False
362        csm = self.current_search_mark.cursor
363        reverse = pat.flags & regex.REVERSE
364        c = self.textCursor()
365        c.clearSelection()
366        m_start = min(csm.position(), csm.anchor())
367        m_end = max(csm.position(), csm.anchor())
368        if c.position() < m_start:
369            c.setPosition(m_start)
370        if c.position() > m_end:
371            c.setPosition(m_end)
372        pos = m_start if reverse else m_end
373        if wrap:
374            pos = m_end if reverse else m_start
375        c.setPosition(pos, QTextCursor.MoveMode.KeepAnchor)
376        raw = str(c.selectedText()).replace(PARAGRAPH_SEPARATOR, '\n').rstrip('\0')
377        m = pat.search(raw)
378        if m is None:
379            return False
380        start, end = m.span()
381        if start == end:
382            return False
383        if wrap:
384            if reverse:
385                textpos = c.anchor()
386                start, end = textpos + end, textpos + start
387            else:
388                start, end = m_start + start, m_start + end
389        else:
390            if reverse:
391                start, end = m_start + end, m_start + start
392            else:
393                start, end = c.anchor() + start, c.anchor() + end
394
395        c.clearSelection()
396        c.setPosition(start)
397        c.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
398        self.setTextCursor(c)
399        # Center search result on screen
400        self.centerCursor()
401        if save_match is not None:
402            self.saved_matches[save_match] = (pat, m)
403        return True
404
405    def all_in_marked(self, pat, template=None):
406        if self.current_search_mark is None:
407            return 0
408        c = self.current_search_mark.cursor
409        raw = str(c.selectedText()).replace(PARAGRAPH_SEPARATOR, '\n').rstrip('\0')
410        if template is None:
411            count = len(pat.findall(raw))
412        else:
413            from calibre.gui2.tweak_book.function_replace import Function
414            repl_is_func = isinstance(template, Function)
415            if repl_is_func:
416                template.init_env()
417            raw, count = pat.subn(template, raw)
418            if repl_is_func:
419                from calibre.gui2.tweak_book.search import show_function_debug_output
420                if getattr(template.func, 'append_final_output_to_marked', False):
421                    retval = template.end()
422                    if retval:
423                        raw += str(retval)
424                else:
425                    template.end()
426                show_function_debug_output(template)
427            if count > 0:
428                start_pos = min(c.anchor(), c.position())
429                c.insertText(raw)
430                end_pos = max(c.anchor(), c.position())
431                c.setPosition(start_pos), c.setPosition(end_pos, QTextCursor.MoveMode.KeepAnchor)
432                self.update_extra_selections()
433        return count
434
435    def smart_comment(self):
436        from calibre.gui2.tweak_book.editor.comments import smart_comment
437        smart_comment(self, self.syntax)
438
439    def sort_css(self):
440        from calibre.gui2.dialogs.confirm_delete import confirm
441        if confirm(_('Sorting CSS rules can in rare cases change the effective styles applied to the book.'
442                     ' Are you sure you want to proceed?'), 'edit-book-confirm-sort-css', parent=self, config_set=tprefs):
443            c = self.textCursor()
444            c.beginEditBlock()
445            c.movePosition(QTextCursor.MoveOperation.Start), c.movePosition(QTextCursor.MoveOperation.End, QTextCursor.MoveMode.KeepAnchor)
446            text = str(c.selectedText()).replace(PARAGRAPH_SEPARATOR, '\n').rstrip('\0')
447            from calibre.ebooks.oeb.polish.css import sort_sheet
448            text = css_text(sort_sheet(current_container(), text))
449            c.insertText(text)
450            c.movePosition(QTextCursor.MoveOperation.Start)
451            c.endEditBlock()
452            self.setTextCursor(c)
453
454    def find(self, pat, wrap=False, marked=False, complete=False, save_match=None):
455        if marked:
456            return self.find_in_marked(pat, wrap=wrap, save_match=save_match)
457        reverse = pat.flags & regex.REVERSE
458        c = self.textCursor()
459        c.clearSelection()
460        if complete:
461            # Search the entire text
462            c.movePosition(QTextCursor.MoveOperation.End if reverse else QTextCursor.MoveOperation.Start)
463        pos = QTextCursor.MoveOperation.Start if reverse else QTextCursor.MoveOperation.End
464        if wrap and not complete:
465            pos = QTextCursor.MoveOperation.End if reverse else QTextCursor.MoveOperation.Start
466        c.movePosition(pos, QTextCursor.MoveMode.KeepAnchor)
467        raw = str(c.selectedText()).replace(PARAGRAPH_SEPARATOR, '\n').rstrip('\0')
468        m = pat.search(raw)
469        if m is None:
470            return False
471        start, end = m.span()
472        if start == end:
473            return False
474        if wrap and not complete:
475            if reverse:
476                textpos = c.anchor()
477                start, end = textpos + end, textpos + start
478        else:
479            if reverse:
480                # Put the cursor at the start of the match
481                start, end = end, start
482            else:
483                textpos = c.anchor()
484                start, end = textpos + start, textpos + end
485        c.clearSelection()
486        c.setPosition(start)
487        c.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
488        self.setTextCursor(c)
489        # Center search result on screen
490        self.centerCursor()
491        if save_match is not None:
492            self.saved_matches[save_match] = (pat, m)
493        return True
494
495    def find_text(self, pat, wrap=False, complete=False):
496        reverse = pat.flags & regex.REVERSE
497        c = self.textCursor()
498        c.clearSelection()
499        if complete:
500            # Search the entire text
501            c.movePosition(QTextCursor.MoveOperation.End if reverse else QTextCursor.MoveOperation.Start)
502        pos = QTextCursor.MoveOperation.Start if reverse else QTextCursor.MoveOperation.End
503        if wrap and not complete:
504            pos = QTextCursor.MoveOperation.End if reverse else QTextCursor.MoveOperation.Start
505        c.movePosition(pos, QTextCursor.MoveMode.KeepAnchor)
506        if hasattr(self.smarts, 'find_text'):
507            self.highlighter.join()
508            found, start, end = self.smarts.find_text(pat, c, reverse)
509            if not found:
510                return False
511        else:
512            raw = str(c.selectedText()).replace(PARAGRAPH_SEPARATOR, '\n').rstrip('\0')
513            m = pat.search(raw)
514            if m is None:
515                return False
516            start, end = m.span()
517            if start == end:
518                return False
519        if reverse:
520            start, end = end, start
521        c.clearSelection()
522        c.setPosition(start)
523        c.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
524        self.setTextCursor(c)
525        # Center search result on screen
526        self.centerCursor()
527        return True
528
529    def find_spell_word(self, original_words, lang, from_cursor=True, center_on_cursor=True):
530        c = self.textCursor()
531        c.setPosition(c.position())
532        if not from_cursor:
533            c.movePosition(QTextCursor.MoveOperation.Start)
534        c.movePosition(QTextCursor.MoveOperation.End, QTextCursor.MoveMode.KeepAnchor)
535
536        def find_first_word(haystack):
537            match_pos, match_word = -1, None
538            for w in original_words:
539                idx = index_of(w, haystack, lang=lang)
540                if idx > -1 and (match_pos == -1 or match_pos > idx):
541                    match_pos, match_word = idx, w
542            return match_pos, match_word
543
544        while True:
545            text = str(c.selectedText()).rstrip('\0')
546            idx, word = find_first_word(text)
547            if idx == -1:
548                return False
549            c.setPosition(c.anchor() + idx)
550            c.setPosition(c.position() + string_length(word), QTextCursor.MoveMode.KeepAnchor)
551            if self.smarts.verify_for_spellcheck(c, self.highlighter):
552                self.highlighter.join()  # Ensure highlighting is finished
553                locale = self.spellcheck_locale_for_cursor(c)
554                if not lang or not locale or (locale and lang == locale.langcode):
555                    self.setTextCursor(c)
556                    if center_on_cursor:
557                        self.centerCursor()
558                    return True
559            c.setPosition(c.position())
560            c.movePosition(QTextCursor.MoveOperation.End, QTextCursor.MoveMode.KeepAnchor)
561
562        return False
563
564    def find_next_spell_error(self, from_cursor=True):
565        c = self.textCursor()
566        if not from_cursor:
567            c.movePosition(QTextCursor.MoveOperation.Start)
568        block = c.block()
569        while block.isValid():
570            for r in block.layout().additionalFormats():
571                if r.format.property(SPELL_PROPERTY):
572                    if not from_cursor or block.position() + r.start + r.length > c.position():
573                        c.setPosition(block.position() + r.start)
574                        c.setPosition(c.position() + r.length, QTextCursor.MoveMode.KeepAnchor)
575                        self.setTextCursor(c)
576                        return True
577            block = block.next()
578        return False
579
580    def replace(self, pat, template, saved_match='gui'):
581        c = self.textCursor()
582        raw = str(c.selectedText()).replace(PARAGRAPH_SEPARATOR, '\n').rstrip('\0')
583        m = pat.fullmatch(raw)
584        if m is None:
585            # This can happen if either the user changed the selected text or
586            # the search expression uses lookahead/lookbehind operators. See if
587            # the saved match matches the currently selected text and
588            # use it, if so.
589            if saved_match is not None and saved_match in self.saved_matches:
590                saved_pat, saved = self.saved_matches.pop(saved_match)
591                if saved_pat == pat and saved.group() == raw:
592                    m = saved
593        if m is None:
594            return False
595        if callable(template):
596            text = template(m)
597        else:
598            text = m.expand(template)
599        c.insertText(text)
600        return True
601
602    def go_to_anchor(self, anchor):
603        if anchor is TOP:
604            c = self.textCursor()
605            c.movePosition(QTextCursor.MoveOperation.Start)
606            self.setTextCursor(c)
607            return True
608        base = r'''%%s\s*=\s*['"]{0,1}%s''' % regex.escape(anchor)
609        raw = str(self.toPlainText())
610        m = regex.search(base % 'id', raw)
611        if m is None:
612            m = regex.search(base % 'name', raw)
613        if m is not None:
614            c = self.textCursor()
615            c.setPosition(m.start())
616            self.setTextCursor(c)
617            return True
618        return False
619
620    # }}}
621
622    # Line numbers and cursor line {{{
623    def highlight_cursor_line(self):
624        sel = QTextEdit.ExtraSelection()
625        sel.format.setBackground(self.palette().alternateBase())
626        sel.format.setProperty(QTextFormat.Property.FullWidthSelection, True)
627        sel.cursor = self.textCursor()
628        sel.cursor.clearSelection()
629        self.current_cursor_line = sel
630        self.update_extra_selections(instant=False)
631        # Update the cursor line's line number in the line number area
632        try:
633            self.line_number_area.update(0, self.last_current_lnum[0], self.line_number_area.width(), self.last_current_lnum[1])
634        except AttributeError:
635            pass
636        block = self.textCursor().block()
637        top = int(self.blockBoundingGeometry(block).translated(self.contentOffset()).top())
638        height = int(self.blockBoundingRect(block).height())
639        self.line_number_area.update(0, top, self.line_number_area.width(), height)
640
641    def update_line_number_area_width(self, block_count=0):
642        self.gutter_width = self.line_number_area_width()
643        self.setViewportMargins(self.gutter_width, 0, 0, 0)
644
645    def line_number_area_width(self):
646        digits = 1
647        limit = max(1, self.blockCount())
648        while limit >= 10:
649            limit /= 10
650            digits += 1
651
652        return 8 + self.number_width * digits
653
654    def update_line_number_area(self, rect, dy):
655        if dy:
656            self.line_number_area.scroll(0, dy)
657        else:
658            self.line_number_area.update(0, rect.y(), self.line_number_area.width(), rect.height())
659        if rect.contains(self.viewport().rect()):
660            self.update_line_number_area_width()
661
662    def resizeEvent(self, ev):
663        QPlainTextEdit.resizeEvent(self, ev)
664        cr = self.contentsRect()
665        self.line_number_area.setGeometry(QRect(cr.left(), cr.top(), self.line_number_area_width(), cr.height()))
666
667    def paint_line_numbers(self, ev):
668        painter = QPainter(self.line_number_area)
669        painter.fillRect(ev.rect(), self.line_number_palette.color(QPalette.ColorRole.Base))
670
671        block = self.firstVisibleBlock()
672        num = block.blockNumber()
673        top = int(self.blockBoundingGeometry(block).translated(self.contentOffset()).top())
674        bottom = top + int(self.blockBoundingRect(block).height())
675        current = self.textCursor().block().blockNumber()
676        painter.setPen(self.line_number_palette.color(QPalette.ColorRole.Text))
677
678        while block.isValid() and top <= ev.rect().bottom():
679            if block.isVisible() and bottom >= ev.rect().top():
680                if current == num:
681                    painter.save()
682                    painter.setPen(self.line_number_palette.color(QPalette.ColorRole.BrightText))
683                    f = QFont(self.font())
684                    f.setBold(True)
685                    painter.setFont(f)
686                    self.last_current_lnum = (top, bottom - top)
687                painter.drawText(0, top, self.line_number_area.width() - 5, self.fontMetrics().height(),
688                              Qt.AlignmentFlag.AlignRight, str(num + 1))
689                if current == num:
690                    painter.restore()
691            block = block.next()
692            top = bottom
693            bottom = top + int(self.blockBoundingRect(block).height())
694            num += 1
695    # }}}
696
697    def override_shortcut(self, ev):
698        # Let the global cut/copy/paste/undo/redo shortcuts work, this avoids the nbsp
699        # problem as well, since they use the overridden createMimeDataFromSelection() method
700        # instead of the one from Qt (which makes copy() work), and allows proper customization
701        # of the shortcuts
702        if ev in (
703            QKeySequence.StandardKey.Copy, QKeySequence.StandardKey.Cut, QKeySequence.StandardKey.Paste,
704            QKeySequence.StandardKey.Undo, QKeySequence.StandardKey.Redo
705        ):
706            ev.ignore()
707            return True
708        # This is used to convert typed hex codes into unicode
709        # characters
710        if ev.key() == Qt.Key.Key_X and ev.modifiers() == Qt.KeyboardModifier.AltModifier:
711            ev.accept()
712            return True
713        return PlainTextEdit.override_shortcut(self, ev)
714
715    def text_for_range(self, block, r):
716        c = self.textCursor()
717        c.setPosition(block.position() + r.start)
718        c.setPosition(c.position() + r.length, QTextCursor.MoveMode.KeepAnchor)
719        return self.selected_text_from_cursor(c)
720
721    def spellcheck_locale_for_cursor(self, c):
722        with store_locale:
723            formats = self.highlighter.parse_single_block(c.block())[0]
724        pos = c.positionInBlock()
725        for r in formats:
726            if r.start <= pos <= r.start + r.length and r.format.property(SPELL_PROPERTY):
727                return r.format.property(SPELL_LOCALE_PROPERTY)
728
729    def recheck_word(self, word, locale):
730        c = self.textCursor()
731        c.movePosition(QTextCursor.MoveOperation.Start)
732        block = c.block()
733        while block.isValid():
734            for r in block.layout().additionalFormats():
735                if r.format.property(SPELL_PROPERTY) and self.text_for_range(block, r) == word:
736                    self.highlighter.reformat_block(block)
737                    break
738            block = block.next()
739
740    # Tooltips {{{
741    def syntax_range_for_cursor(self, cursor):
742        if cursor.isNull():
743            return
744        pos = cursor.positionInBlock()
745        for r in cursor.block().layout().additionalFormats():
746            if r.start <= pos <= r.start + r.length and r.format.property(SYNTAX_PROPERTY):
747                return r
748
749    def show_tooltip(self, ev):
750        c = self.cursorForPosition(ev.pos())
751        fmt_range = self.syntax_range_for_cursor(c)
752        fmt = getattr(fmt_range, 'format', None)
753        if fmt is not None:
754            tt = str(fmt.toolTip())
755            if tt:
756                QToolTip.setFont(self.tooltip_font)
757                QToolTip.setPalette(self.tooltip_palette)
758                QToolTip.showText(ev.globalPos(), textwrap.fill(tt))
759                return
760        QToolTip.hideText()
761        ev.ignore()
762    # }}}
763
764    def link_for_position(self, pos):
765        c = self.cursorForPosition(pos)
766        r = self.syntax_range_for_cursor(c)
767        if r is not None and r.format.property(LINK_PROPERTY):
768            return self.text_for_range(c.block(), r)
769
770    def select_class_name_at_cursor(self, cursor):
771        valid = re.compile(r'[\w_0-9\-]+', flags=re.UNICODE)
772
773        def keep_going():
774            q = cursor.selectedText()
775            m = valid.match(q)
776            return m is not None and m.group() == q
777
778        def run_loop(forward=True):
779            cursor.setPosition(pos)
780            n, p = QTextCursor.MoveOperation.NextCharacter, QTextCursor.MoveOperation.PreviousCharacter
781            if not forward:
782                n, p = p, n
783            while True:
784                if not cursor.movePosition(n, QTextCursor.MoveMode.KeepAnchor):
785                    break
786                if not keep_going():
787                    cursor.movePosition(p, QTextCursor.MoveMode.KeepAnchor)
788                    break
789            ans = cursor.position()
790            cursor.setPosition(pos)
791            return ans
792
793        pos = cursor.position()
794        forwards_limit = run_loop()
795        backwards_limit = run_loop(forward=False)
796        cursor.setPosition(backwards_limit)
797        cursor.setPosition(forwards_limit, QTextCursor.MoveMode.KeepAnchor)
798        return self.selected_text_from_cursor(cursor)
799
800    def class_for_position(self, pos):
801        c = self.cursorForPosition(pos)
802        r = self.syntax_range_for_cursor(c)
803        if r is not None and r.format.property(CLASS_ATTRIBUTE_PROPERTY):
804            class_name = self.select_class_name_at_cursor(c)
805            if class_name:
806                tags = self.current_tag(for_position_sync=False, cursor=c)
807                return {'class': class_name, 'sourceline_address': tags}
808
809    def mousePressEvent(self, ev):
810        if self.completion_popup.isVisible() and not self.completion_popup.rect().contains(ev.pos()):
811            # For some reason using eventFilter for this does not work, so we
812            # implement it here
813            self.completion_popup.abort()
814        if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
815            url = self.link_for_position(ev.pos())
816            if url is not None:
817                ev.accept()
818                self.link_clicked.emit(url)
819                return
820            class_data = self.class_for_position(ev.pos())
821            if class_data is not None:
822                ev.accept()
823                self.class_clicked.emit(class_data)
824                return
825        return PlainTextEdit.mousePressEvent(self, ev)
826
827    def get_range_inside_tag(self):
828        c = self.textCursor()
829        left = min(c.anchor(), c.position())
830        right = max(c.anchor(), c.position())
831        # For speed we use QPlainTextEdit's toPlainText as we dont care about
832        # spaces in this context
833        raw = str(QPlainTextEdit.toPlainText(self))
834        # Make sure the left edge is not within a <>
835        gtpos = raw.find('>', left)
836        ltpos = raw.find('<', left)
837        if gtpos < ltpos:
838            left = gtpos + 1 if gtpos > -1 else left
839        right = max(left, right)
840        if right != left:
841            gtpos = raw.find('>', right)
842            ltpos = raw.find('<', right)
843            if ltpos > gtpos:
844                ltpos = raw.rfind('<', left, right+1)
845                right = max(ltpos, left)
846        return left, right
847
848    def format_text(self, formatting):
849        if self.syntax != 'html':
850            return
851        if formatting.startswith('justify_'):
852            return self.smarts.set_text_alignment(self, formatting.partition('_')[-1])
853        color = 'currentColor'
854        if formatting in {'color', 'background-color'}:
855            color = QColorDialog.getColor(
856                QColor(Qt.GlobalColor.black if formatting == 'color' else Qt.GlobalColor.white),
857                self, _('Choose color'), QColorDialog.ColorDialogOption.ShowAlphaChannel)
858            if not color.isValid():
859                return
860            r, g, b, a = color.getRgb()
861            if a == 255:
862                color = 'rgb(%d, %d, %d)' % (r, g, b)
863            else:
864                color = 'rgba(%d, %d, %d, %.2g)' % (r, g, b, a/255)
865        prefix, suffix = {
866            'bold': ('<b>', '</b>'),
867            'italic': ('<i>', '</i>'),
868            'underline': ('<u>', '</u>'),
869            'strikethrough': ('<strike>', '</strike>'),
870            'superscript': ('<sup>', '</sup>'),
871            'subscript': ('<sub>', '</sub>'),
872            'color': ('<span style="color: %s">' % color, '</span>'),
873            'background-color': ('<span style="background-color: %s">' % color, '</span>'),
874        }[formatting]
875        left, right = self.get_range_inside_tag()
876        c = self.textCursor()
877        c.setPosition(left)
878        c.setPosition(right, QTextCursor.MoveMode.KeepAnchor)
879        prev_text = str(c.selectedText()).rstrip('\0')
880        c.insertText(prefix + prev_text + suffix)
881        if prev_text:
882            right = c.position()
883            c.setPosition(left)
884            c.setPosition(right, QTextCursor.MoveMode.KeepAnchor)
885        else:
886            c.setPosition(c.position() - len(suffix))
887        self.setTextCursor(c)
888
889    def insert_image(self, href, fullpage=False, preserve_aspect_ratio=False, width=-1, height=-1):
890        if width <= 0:
891            width = 1200
892        if height <= 0:
893            height = 1600
894        c = self.textCursor()
895        template, alt = 'url(%s)', ''
896        left = min(c.position(), c.anchor())
897        if self.syntax == 'html':
898            left, right = self.get_range_inside_tag()
899            c.setPosition(left)
900            c.setPosition(right, QTextCursor.MoveMode.KeepAnchor)
901            href = prepare_string_for_xml(href, True)
902            if fullpage:
903                template =  '''\
904<div style="page-break-before:always; page-break-after:always; page-break-inside:avoid">\
905<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" \
906version="1.1" width="100%%" height="100%%" viewBox="0 0 {w} {h}" preserveAspectRatio="{a}">\
907<image width="{w}" height="{h}" xlink:href="%s"/>\
908</svg></div>'''.format(w=width, h=height, a='xMidYMid meet' if preserve_aspect_ratio else 'none')
909            else:
910                alt = _('Image')
911                template = '<img alt="{}" src="%s" />'.format(alt)
912        text = template % href
913        c.insertText(text)
914        if self.syntax == 'html' and not fullpage:
915            c.setPosition(left + 10)
916            c.setPosition(c.position() + len(alt), QTextCursor.MoveMode.KeepAnchor)
917        else:
918            c.setPosition(left)
919            c.setPosition(left + len(text), QTextCursor.MoveMode.KeepAnchor)
920        self.setTextCursor(c)
921
922    def insert_hyperlink(self, target, text, template=None):
923        if hasattr(self.smarts, 'insert_hyperlink'):
924            self.smarts.insert_hyperlink(self, target, text, template=template)
925
926    def insert_tag(self, tag):
927        if hasattr(self.smarts, 'insert_tag'):
928            self.smarts.insert_tag(self, tag)
929
930    def remove_tag(self):
931        if hasattr(self.smarts, 'remove_tag'):
932            self.smarts.remove_tag(self)
933
934    def split_tag(self):
935        if hasattr(self.smarts, 'split_tag'):
936            self.smarts.split_tag(self)
937
938    def keyPressEvent(self, ev):
939        if ev.key() == Qt.Key.Key_X and ev.modifiers() == Qt.KeyboardModifier.AltModifier:
940            if self.replace_possible_unicode_sequence():
941                ev.accept()
942                return
943        if ev.key() == Qt.Key.Key_Insert:
944            self.setOverwriteMode(self.overwriteMode() ^ True)
945            ev.accept()
946            return
947        if self.snippet_manager.handle_key_press(ev):
948            self.completion_popup.hide()
949            return
950        if self.smarts.handle_key_press(ev, self):
951            self.handle_keypress_completion(ev)
952            return
953        QPlainTextEdit.keyPressEvent(self, ev)
954        self.handle_keypress_completion(ev)
955
956    def handle_keypress_completion(self, ev):
957        if self.request_completion is None:
958            return
959        code = ev.key()
960        if code in (
961            0, Qt.Key.Key_unknown, Qt.Key.Key_Shift, Qt.Key.Key_Control, Qt.Key.Key_Alt,
962            Qt.Key.Key_Meta, Qt.Key.Key_AltGr, Qt.Key.Key_CapsLock, Qt.Key.Key_NumLock,
963            Qt.Key.Key_ScrollLock, Qt.Key.Key_Up, Qt.Key.Key_Down):
964            # We ignore up/down arrow so as to not break scrolling through the
965            # text with the arrow keys
966            return
967        result = self.smarts.get_completion_data(self, ev)
968        if result is None:
969            self.last_completion_request += 1
970        else:
971            self.last_completion_request = self.request_completion(*result)
972        self.completion_popup.mark_completion(self, None if result is None else result[-1])
973
974    def handle_completion_result(self, result):
975        if result.request_id[0] >= self.last_completion_request:
976            self.completion_popup.handle_result(result)
977
978    def clear_completion_cache(self):
979        if self.request_completion is not None and self.completion_doc_name:
980            self.request_completion(None, 'file:' + self.completion_doc_name)
981
982    def replace_possible_unicode_sequence(self):
983        c = self.textCursor()
984        has_selection = c.hasSelection()
985        if has_selection:
986            text = str(c.selectedText()).rstrip('\0')
987        else:
988            c.setPosition(c.position() - min(c.positionInBlock(), 6), QTextCursor.MoveMode.KeepAnchor)
989            text = str(c.selectedText()).rstrip('\0')
990        m = re.search(r'[a-fA-F0-9]{2,6}$', text)
991        if m is None:
992            return False
993        text = m.group()
994        try:
995            num = int(text, 16)
996        except ValueError:
997            return False
998        if num > 0x10ffff or num < 1:
999            return False
1000        end_pos = max(c.anchor(), c.position())
1001        c.setPosition(end_pos - len(text)), c.setPosition(end_pos, QTextCursor.MoveMode.KeepAnchor)
1002        c.insertText(safe_chr(num))
1003        return True
1004
1005    def select_all(self):
1006        c = self.textCursor()
1007        c.clearSelection()
1008        c.setPosition(0)
1009        c.movePosition(QTextCursor.MoveOperation.End, QTextCursor.MoveMode.KeepAnchor)
1010        self.setTextCursor(c)
1011
1012    def rename_block_tag(self, new_name):
1013        if hasattr(self.smarts, 'rename_block_tag'):
1014            self.smarts.rename_block_tag(self, new_name)
1015
1016    def current_tag(self, for_position_sync=True, cursor=None):
1017        use_matched_tag = False
1018        if cursor is None:
1019            use_matched_tag = True
1020            cursor = self.textCursor()
1021        return self.smarts.cursor_position_with_sourceline(cursor, for_position_sync=for_position_sync, use_matched_tag=use_matched_tag)
1022
1023    def goto_sourceline(self, sourceline, tags, attribute=None):
1024        return self.smarts.goto_sourceline(self, sourceline, tags, attribute=attribute)
1025
1026    def get_tag_contents(self):
1027        c = self.smarts.get_inner_HTML(self)
1028        if c is not None:
1029            return self.selected_text_from_cursor(c)
1030
1031    def goto_css_rule(self, rule_address, sourceline_address=None):
1032        from calibre.gui2.tweak_book.editor.smarts.css import find_rule
1033        block = None
1034        if self.syntax == 'css':
1035            raw = str(self.toPlainText())
1036            line, col = find_rule(raw, rule_address)
1037            if line is not None:
1038                block = self.document().findBlockByNumber(line - 1)
1039        elif sourceline_address is not None:
1040            sourceline, tags = sourceline_address
1041            if self.goto_sourceline(sourceline, tags):
1042                c = self.textCursor()
1043                c.setPosition(c.position() + 1)
1044                self.setTextCursor(c)
1045                raw = self.get_tag_contents()
1046                line, col = find_rule(raw, rule_address)
1047                if line is not None:
1048                    block = self.document().findBlockByNumber(c.blockNumber() + line - 1)
1049
1050        if block is not None and block.isValid():
1051            c = self.textCursor()
1052            c.setPosition(block.position() + col)
1053            self.setTextCursor(c)
1054
1055    def change_case(self, action, cursor=None):
1056        cursor = cursor or self.textCursor()
1057        text = self.selected_text_from_cursor(cursor)
1058        text = {'lower':lower, 'upper':upper, 'capitalize':capitalize, 'title':titlecase, 'swap':swapcase}[action](text)
1059        cursor.insertText(text)
1060        self.setTextCursor(cursor)
1061