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