vibefpmgui.highlighter
Syntax highlighting for QPlainTextEdit/QTextEdit using a Pygments lexer.
The whole document is lexed at once (so multi-line strings and the like are handled correctly) and each text block then just applies the token formats that overlap it. After an edit (once typing pauses) the document is lexed again, on a thread for a big one, and only the lines whose colors changed are recolored. Meanwhile the line being typed on is lexed on its own, at once, so what's typed is colored straight away (the whole-document lexing then corrects anything that depends on other lines). In the same way, a big file just opened has the lines on view lexed on their own at once, while the whole of it is lexed on the thread (colors in a moment, not seconds later).
1"""Syntax highlighting for QPlainTextEdit/QTextEdit using a Pygments lexer. 2 3The whole document is lexed at once (so multi-line strings and the like are 4handled correctly) and each text block then just applies the token formats 5that overlap it. After an edit (once typing pauses) the document is lexed 6again, on a thread for a big one, and only the lines whose colors changed 7are recolored. Meanwhile the line being typed on is lexed on its own, at 8once, so what's typed is colored straight away (the whole-document lexing 9then corrects anything that depends on other lines). In the same way, a big 10file just opened has the lines on view lexed on their own at once, while the 11whole of it is lexed on the thread (colors in a moment, not seconds later). 12""" 13 14from __future__ import annotations 15 16import sys 17import threading 18from bisect import bisect_right 19from contextlib import contextmanager 20from pathlib import Path 21from typing import ClassVar 22 23from pygments import lex 24from pygments.lexer import bygroups, default, inherit, using 25from pygments.lexers import ( 26 FortranFixedLexer, 27 FortranLexer, 28 JsonLexer, 29 TextLexer, 30 TOMLLexer, 31 get_lexer_for_filename, 32) 33from pygments.styles import get_all_styles, get_style_by_name 34from pygments.token import Comment, Generic, Keyword, Name, String, Text, Token 35from pygments.util import ClassNotFound 36from qtpy.QtCore import QTimer, Signal 37from qtpy.QtGui import QColor, QFont, QPalette, QSyntaxHighlighter, QTextCharFormat 38from qtpy.QtWidgets import QApplication 39 40LIGHT_STYLE = "default" 41DARK_STYLE = "monokai" 42AUTO = "auto" # LIGHT_STYLE or DARK_STYLE, following the system's light/dark mode 43chosen_style = AUTO # View → Syntax Colors (set by the main window) 44 45 46def document_text(document) -> str: 47 """A QTextDocument's text exactly as it is ("\\n" between lines). Qt's 48 toPlainText() turns non-breaking spaces into spaces (and U+2028 into a 49 line break): saving that would change the file.""" 50 return document.toRawText().replace("\u2029", "\n") 51 52 53def style_is_dark(name: str) -> bool: 54 try: 55 return QColor(get_style_by_name(name).background_color).lightness() < 128 56 except ClassNotFound: 57 return False 58 59 60def available_styles() -> tuple[list[str], list[str]]: 61 """(light styles, dark styles): every Pygments style installed, sorted.""" 62 light, dark = [], [] 63 for name in sorted(get_all_styles()): 64 (dark if style_is_dark(name) else light).append(name) 65 return light, dark 66 67 68def style_name(palette: QPalette) -> str: 69 """The style to use: the chosen one, or automatic: the one that goes with 70 the app's theme (View → Theme), else by light/dark mode.""" 71 from . import themes 72 73 for name in ((chosen_style,) if chosen_style != AUTO else ()) + (themes.syntax_style(),): 74 if name: 75 try: 76 get_style_by_name(name) 77 return name 78 except ClassNotFound: 79 pass # uninstalled since: fall back to automatic 80 return DARK_STYLE if is_dark(palette) else LIGHT_STYLE 81 82 83def style_text_color(style) -> QColor: 84 """The color a style uses for plain text (styles that don't say get 85 near-white on a dark background, near-black on a light one).""" 86 for token in (Text, Token): 87 color = style.style_for_token(token).get("color") 88 if color: 89 return QColor("#" + color) 90 dark = QColor(style.background_color).lightness() < 128 91 return QColor("#f8f8f2") if dark else QColor("#1b1b1b") 92 93 94def apply_style(widget, highlighter): 95 """Highlight with the current style and give the text widget its 96 background and text colors.""" 97 highlighter.set_style(style_name(QApplication.palette())) 98 palette = widget.palette() 99 palette.setColor(QPalette.Base, highlighter.background()) 100 palette.setColor(QPalette.Text, style_text_color(highlighter.style)) 101 widget.setPalette(palette) 102 103 104# Fortran extensions Pygments doesn't know (or maps to another language). 105_FIXED_FORM = {".f", ".for", ".ftn", ".f77"} 106_FREE_FORM = {".f90", ".f95", ".f03", ".f08", ".f18", ".f23", ".fpp", ".inc"} 107_FYPP = {".fypp"} # Fortran with fypp's preprocessing (see FyppLexer) 108# "JSON with Comments" (as VS Code calls it): workspace files, .jsonc, and the 109# .json files in a .vscode folder (settings, launch, tasks…). 110_JSONC = {".code-workspace", ".jsonc"} 111 112 113class JsonWithCommentsLexer(JsonLexer): 114 """Pygments' JSON lexer already accepts // and /* */ comments (and 115 trailing commas); this just gives it VS Code's name for the format.""" 116 117 name = "JSON with Comments" 118 aliases: ClassVar[list[str]] = ["jsonc"] 119 filenames: ClassVar[list[str]] = ["*.jsonc", "*.code-workspace"] 120 121 122def _fortran_string(quote: str) -> str: 123 """A Fortran string: to its closing quote (one doubled is a quote in it), 124 over lines only with a continuation's ``&``.""" 125 return rf"{quote}(?:{quote}{quote}|&[ \t]*\n[ \t]*&?|[^{quote}\n])*{quote}" 126 127 128class FortranSourceLexer(FortranLexer): 129 """Pygments' Fortran lexer with Fortran's strings. Its own are C's: a 130 backslash escapes what follows, and they go on over lines. So ``'\\'`` (a 131 backslash) didn't end, nor did a string left open while it's typed: all 132 that followed, to the next quote, was colored as a string. One left open 133 here is a string to the end of its line.""" 134 135 tokens: ClassVar[dict] = { 136 "strings": [ 137 (_fortran_string('"'), String.Double), 138 (_fortran_string("'"), String.Single), 139 (r'"[^"\n]*', String.Double), 140 (r"'[^'\n]*", String.Single), 141 ], 142 } 143 144 145class FortranFixedSourceLexer(FortranFixedLexer): 146 """Pygments' fixed-form lexer, its lines read by `FortranSourceLexer`.""" 147 148 def _lex_line(self, match, ctx=None): 149 text = match.group(0) + "\n" 150 for index, token, value in FortranSourceLexer().get_tokens_unprocessed(text): 151 value = value.replace("\n", "") 152 if value: 153 yield index, token, value 154 155 tokens: ClassVar[dict] = { 156 "root": [ 157 (r"[C*].*\n", Comment), 158 (r"#.*\n", Comment.Preproc), 159 (r" {0,4}!.*\n", Comment), 160 (r"(.{5})", Name.Label, "cont-char"), 161 (r".*\n", using(FortranSourceLexer)), 162 ], 163 "code": [ 164 (r"(.{66})(.*)(\n)", bygroups(_lex_line, Comment, Text.Whitespace), "root"), 165 (r"(.*)(\n)", bygroups(_lex_line, Text.Whitespace), "root"), 166 default("root"), 167 ], 168 } 169 170 171class FyppLexer(FortranSourceLexer): 172 """Fortran with fypp (https://fypp.readthedocs.io) in it: its directive 173 lines (``#:if``, ``#:for``, ``$:``, ``@:``), comments (``#!``) and inline 174 expressions (``${...}$``, ``@{...}@``) as preprocessing, the rest as 175 Fortran.""" 176 177 name = "Fortran (fypp)" 178 aliases: ClassVar[list[str]] = ["fypp"] 179 filenames: ClassVar[list[str]] = ["*.fypp"] 180 tokens: ClassVar[dict] = { 181 "root": [ 182 (r"(#[:!]|[@$]:)[^\n]*", Comment.Preproc), # (a line's directive, indented or not) 183 (r"\$\{.*?\}\$|@\{.*?\}@", Comment.Preproc), # (an expression inside a line) 184 (r"[a-z]\w*(?=\$\{|@\{)|(?<=\}\$)\w+|(?<=\}@)\w+", Name), # (a name made with one: x_${k}$_y) 185 inherit, 186 ], 187 } 188 189 190def lexer_for_path(path) -> object: 191 """A Pygments lexer chosen from the file name (plain text if unknown), 192 which keeps the text's positions (see keep_positions).""" 193 name = str(path) 194 if not name: # (no file, e.g. an empty diff pane: Pygments would go through every lexer to find none) 195 return keep_positions(TextLexer()) 196 suffix = ("." + name.rsplit(".", 1)[-1].lower()) if "." in name else "" 197 if suffix in _FIXED_FORM: 198 return keep_positions(FortranFixedSourceLexer()) 199 if suffix in _FREE_FORM: 200 return keep_positions(FortranSourceLexer()) 201 if suffix in _FYPP: 202 return keep_positions(FyppLexer()) 203 if suffix in _JSONC or (suffix == ".json" and Path(name).parent.name == ".vscode"): 204 return keep_positions(JsonWithCommentsLexer()) 205 try: 206 return keep_positions(get_lexer_for_filename(name)) 207 except ClassNotFound: 208 return keep_positions(TextLexer()) 209 210 211def keep_positions(lexer): 212 """A lexer that doesn't drop the text's leading and trailing line breaks 213 (Pygments does by default): the tokens' positions are then the text's. 214 Otherwise a file starting with blank lines is colored a character off 215 for each of them.""" 216 lexer.stripnl = False 217 lexer.stripall = False 218 return lexer 219 220 221# Lexers for files that are all prose (their text is spell checked, not only 222# comments and strings). 223PROSE_LEXERS = {"Text only", "Markdown", "reStructuredText"} 224NOT_PROSE = (Comment.Preproc, Comment.PreprocFile, String.Escape, String.Interpol, String.Regex, 225 String.Symbol, String.Affix, String.Backtick) # (Markdown's `code`) 226 227 228def token_role(ttype, all_prose: bool) -> str | None: 229 """"prose" (a comment or string: spell checked), "name" (a name or 230 keyword: not a misspelling elsewhere) or None.""" 231 if any(ttype in t for t in NOT_PROSE): 232 return None 233 if ttype in Comment or ttype in String or (all_prose and (ttype in Text or ttype in Generic)): 234 return "prose" 235 if ttype in Name or ttype in Keyword: 236 return "name" 237 return None 238 239 240def token_format(style, ttype) -> QTextCharFormat: 241 """How a Pygments style shows a token type.""" 242 # Some lexers make their own token types (e.g. YAML's 243 # Literal.Scalar.Plain) that styles don't list: use the nearest 244 # parent type the style knows, as Pygments' own formatters do. 245 styled = ttype 246 while not style.styles_token(styled) and styled.parent is not None: 247 styled = styled.parent 248 spec = style.style_for_token(styled) 249 if ttype is Name and not spec["color"]: 250 # The TOML lexer marks keys as plain Name, which most styles 251 # leave uncolored; borrow the attribute-name color instead. 252 spec = style.style_for_token(Name.Attribute) 253 fmt = QTextCharFormat() 254 if spec["color"]: 255 fmt.setForeground(QColor("#" + spec["color"])) 256 if spec["bgcolor"]: 257 fmt.setBackground(QColor("#" + spec["bgcolor"])) 258 if spec["bold"]: 259 fmt.setFontWeight(QFont.Bold) 260 if spec["italic"]: 261 fmt.setFontItalic(True) 262 if spec["underline"]: 263 fmt.setFontUnderline(True) 264 return fmt 265 266 267def is_dark(palette: QPalette) -> bool: 268 return palette.color(QPalette.Base).lightness() < 128 269 270 271LEX_IN_THREAD = 100_000 # characters: bigger documents are lexed on a thread (typing stays smooth) 272 273 274def _common_prefix(a: str, b: str) -> int: 275 """How many characters two texts start with in common (compared a 276 chunk at a time, so big texts are quick).""" 277 n, i, step = min(len(a), len(b)), 0, 4096 278 while i < n and a[i:i + step] == b[i:i + step]: 279 i += step 280 i = min(i, n) 281 while i < n and a[i] == b[i]: 282 i += 1 283 return i 284 285 286def _common_suffix(a: str, b: str, limit: int) -> int: 287 """How many characters two texts end with in common (at most ``limit``).""" 288 k, step = 0, 4096 289 while k < limit and a[len(a) - min(k + step, limit):len(a) - k] == b[len(b) - min(k + step, limit):len(b) - k]: 290 k = min(k + step, limit) 291 while k < limit and a[len(a) - k - 1] == b[len(b) - k - 1]: 292 k += 1 293 return k 294 295 296_switching_lock = threading.Lock() 297_switching_users = 0 298 299 300@contextmanager 301def _quick_thread_switching(interval: float = 0.0005): 302 """Python switches between threads more often while inside (for every 303 lexing thread at once; the normal interval comes back after the last).""" 304 global _switching_users 305 with _switching_lock: 306 if _switching_users == 0: 307 _quick_thread_switching.saved = sys.getswitchinterval() 308 sys.setswitchinterval(interval) 309 _switching_users += 1 310 try: 311 yield 312 finally: 313 with _switching_lock: 314 _switching_users -= 1 315 if _switching_users == 0: 316 sys.setswitchinterval(_quick_thread_switching.saved) 317 318 319QUICK_LINES = 50 # lines colored on their own per edit (more, e.g. the whole text replaced: wait for the lexing) 320VIEW_LINES = 150 # a big file's lines on view (and a little more) colored on their own until it's lexed 321CHUNK_LINES = 2000 # recolored at a time (more: the lines on view first, the rest a chunk at a time) 322 323 324class PygmentsHighlighter(QSyntaxHighlighter): 325 relexed = Signal() # the whole document has new colors (e.g. for the minimap) 326 _lexed = Signal(object) # (text, tokens) from the lexing thread 327 328 def __init__(self, document, lexer=None, style: str | None = None): 329 super().__init__(document) 330 self.lexer = keep_positions(lexer or TOMLLexer()) 331 self._spans: list[tuple[int, int, QTextCharFormat]] = [] 332 self._starts: list[int] = [] 333 self._formats: dict = {} 334 self._text = "" # the text the spans are for 335 self.prose: list[tuple[int, int]] = [] # (position, length) of each comment and string in it 336 self.names: set[str] = set() # the names and keywords in it (lowercase) 337 self._full = True # recolor every line next time (the style or lexer changed) 338 self._lexing = False # a thread is lexing 339 self._generation = 0 # changes with the style or lexer (a thread's answer from before is stale) 340 self._busy = False 341 self._closed = False # its tab is closed (see close): a lexing thread then drops its answer 342 self._quick_budget = QUICK_LINES # (see _quick_line) 343 self._provisional = False # coloring the lines on view on their own (see _color_view_first) 344 self._pending: list[tuple[int, int]] = [] # lines (0-based, first and last) still to recolor 345 self._job = 0 # (a recoloring a chunk at a time: changes when it's dropped) 346 self.line_on_view = None # () -> the first line on view (0-based), set by the editor: colored first 347 self.style = None 348 self._style_name = None 349 # While typing, wait for a short pause before lexing again. 350 self._timer = QTimer(self) 351 self._timer.setSingleShot(True) 352 self._timer.setInterval(120) 353 self._timer.timeout.connect(self._relex) 354 self._lexed.connect(self._apply) 355 self.set_style(style or LIGHT_STYLE) 356 document.contentsChange.connect(self._schedule) 357 358 def _schedule(self, *_): 359 if not self._busy: 360 if self._pending: # (edited while recoloring a chunk at a time: everything again, once lexed) 361 self._pending = [] 362 self._job += 1 363 self._full = True 364 self._timer.start() 365 366 def close(self): 367 """Its tab is closing: no more lexing, and a thread still lexing 368 drops its answer instead of sending it to a highlighter that's gone.""" 369 self._closed = True 370 self._timer.stop() 371 self._generation += 1 372 self._job += 1 373 self._pending = [] 374 375 def set_lexer(self, lexer): 376 self.lexer = keep_positions(lexer) 377 self._full = True 378 self._generation += 1 379 self._relex() 380 381 @contextmanager 382 def replacing_text(self, lexer=None): 383 """The document's whole text set (within it) at once, not typed (a 384 diff shown): colored straight away, not after the pause that typing 385 waits for, nor meanwhile with the old text's colors (in the wrong 386 places). ``lexer``: another one for it.""" 387 if lexer is not None: 388 self.lexer = keep_positions(lexer) 389 self._spans, self._starts = [], [] 390 self._full = True 391 self._generation += 1 392 self._job += 1 393 self._pending = [] 394 self._busy = True # (the text's change isn't an edit to wait for) 395 try: 396 yield 397 finally: 398 self._busy = False 399 self._relex() 400 401 def set_style(self, name: str): 402 if name == self._style_name: 403 return 404 self._style_name = name 405 self.style = get_style_by_name(name) 406 self._formats.clear() 407 self._full = True 408 self._generation += 1 409 QTimer.singleShot(0, self, self._relex) # (once, even when the text is set just after; not if deleted) 410 411 def background(self) -> QColor: 412 return QColor(self.style.background_color) 413 414 @property 415 def text(self) -> str: 416 """The text the colors (and `prose`, `names`) are for.""" 417 return self._text 418 419 def is_current(self) -> bool: 420 """Whether the colors are for the document as it is now: no lexing 421 waiting or running (and once that's done, `relexed` is emitted).""" 422 return not (self._timer.isActive() or self._lexing or self._full or self._pending) 423 424 def _format(self, ttype) -> QTextCharFormat: 425 fmt = self._formats.get(ttype) 426 if fmt is None: 427 fmt = self._formats[ttype] = token_format(self.style, ttype) 428 return fmt 429 430 @staticmethod 431 def _work(text: str, lexer, style, formats: dict, old_text: str, old_spans: list, full: bool): 432 """Lex ``text`` and work out what to recolor (on a thread for a big 433 document: no Qt objects but formats, which are plain values). 434 Returns (text, spans, starts, new formats, (start, end) or None for 435 everything, prose, names); spans [] if lexing failed (plain text). 436 prose and names: for spell checking (see `token_role`).""" 437 formats = dict(formats) # (the window's own copy is not touched here) 438 added = {} 439 spans, pos = [], 0 440 prose: list[tuple[int, int]] = [] 441 names: set[str] = set() 442 all_prose = getattr(lexer, "name", "") in PROSE_LEXERS 443 roles: dict = {} 444 try: 445 for ttype, value in lex(text, lexer): 446 # Pygments may append a trailing newline that isn't in the document. 447 length = min(len(value), len(text) - pos) 448 if length > 0: 449 fmt = formats.get(ttype) 450 if fmt is None: 451 fmt = formats[ttype] = added[ttype] = token_format(style, ttype) 452 spans.append((pos, length, fmt)) 453 role = roles.get(ttype, 0) 454 if role == 0: 455 role = roles[ttype] = token_role(ttype, all_prose) 456 if role == "prose": 457 if prose and prose[-1][0] + prose[-1][1] == pos: # (one comment: several tokens) 458 prose[-1] = (prose[-1][0], prose[-1][1] + length) 459 else: 460 prose.append((pos, length)) 461 elif role == "name": 462 names.add(value.strip().lower()) 463 pos += len(value) 464 except Exception: # noqa: BLE001 - never let highlighting break the editor 465 spans, full, prose, names = [], True, [], set() 466 starts = [s[0] for s in spans] 467 region = None if full else PygmentsHighlighter._changed(old_text, old_spans, text, spans) 468 return text, spans, starts, added, region, prose, names 469 470 @staticmethod 471 def _changed(old_text: str, old: list, text: str, new: list) -> tuple[int, int]: 472 """The part of the new text whose colors may differ: the edited text, 473 and the tokens from the first difference to the last (e.g. an opened 474 string changes the rest of the file).""" 475 prefix = _common_prefix(old_text, text) 476 suffix = _common_suffix(old_text, text, min(len(old_text), len(text)) - prefix) 477 start, end = prefix, len(text) - suffix 478 shift = len(text) - len(old_text) 479 n = min(len(old), len(new)) 480 i = 0 481 while i < n and old[i][0] == new[i][0] and old[i][1] == new[i][1] and old[i][2] is new[i][2]: 482 i += 1 483 j = 0 484 while j < n - i: 485 a, b = old[-1 - j], new[-1 - j] 486 if a[0] + shift != b[0] or a[1] != b[1] or a[2] is not b[2]: 487 break 488 j += 1 489 if i < len(new): 490 start = min(start, new[i][0]) 491 last = len(new) - 1 - j 492 if last >= i: 493 end = max(end, new[last][0] + new[last][1]) 494 return start, end 495 496 def _relex(self, *_): 497 """Lex the text again (on a thread for a big document) and recolor 498 what changed.""" 499 if self._busy or self._closed: 500 return 501 self._timer.stop() 502 text = document_text(self.document()) 503 if text == self._text and not self._full: 504 return 505 if self._lexing: # (the thread's answer is checked against the text then) 506 return 507 args = (text, self.lexer, self.style, self._formats, self._text, self._spans, self._full) 508 generation = self._generation 509 if len(text) > LEX_IN_THREAD: 510 self._lexing = True 511 threading.Thread(target=self._lex_in_thread, args=(generation, args), 512 daemon=True, name="Syntax highlighting").start() 513 if not self._spans: # (nothing colored yet: a file just opened) 514 self._color_view_first() 515 else: 516 self._apply((generation, self._work(*args))) 517 518 def _color_view_first(self): 519 """The lines on view colored now, each lexed on its own, while the 520 whole document is lexed on the thread (that then colors every line, 521 correcting what depends on other lines: a string continued on the 522 next, say).""" 523 first = self.line_on_view() if self.line_on_view is not None else 0 524 self._provisional = self._busy = True # (recoloring isn't an edit) 525 try: 526 block = self.document().findBlockByNumber(max(first, 0)) 527 for _ in range(VIEW_LINES): 528 if not block.isValid(): 529 break 530 self.rehighlightBlock(block) 531 block = block.next() 532 finally: 533 self._provisional = self._busy = False 534 535 def _lex_in_thread(self, generation, args): 536 # The lexer is Python too: while it runs, the window's code waits its 537 # turn each time it runs (Python's GIL), up to the switch interval 538 # each time. A shorter interval while lexing keeps the window responsive. 539 with _quick_thread_switching(): 540 result = self._work(*args) 541 if self._closed: 542 return # (its tab was closed meanwhile: nobody wants the colors) 543 try: 544 self._lexed.emit((generation, result)) 545 except RuntimeError: 546 pass # (deleted meanwhile without close(): the same) 547 548 def _apply(self, result): 549 generation, (text, spans, starts, added, region, prose, names) = result 550 self._lexing = False 551 if text != document_text(self.document()) or generation != self._generation: 552 self._relex() # (the text, style or lexer changed while lexing: again) 553 return 554 self._formats.update(added) 555 self._spans, self._starts, self._text = spans, starts, text 556 self.prose, self.names = prose, names 557 document = self.document() 558 last = document.blockCount() - 1 559 if self._full or region is None: 560 first = 0 561 else: 562 first = document.findBlock(region[0]).blockNumber() 563 end = document.findBlock(region[1]) 564 last = end.blockNumber() if end.isValid() else last 565 self._full = False 566 self._recolor(max(first, 0), last) 567 568 def _recolor(self, first: int, last: int): 569 """Recolor lines ``first`` to ``last`` (0-based). Many (a big file 570 colored after lexing, a string opened at the top of one): those on 571 view first, then the rest a chunk at a time, the window answering in 572 between (all at once took a second for 70 000 lines).""" 573 self._job += 1 574 if last - first < CHUNK_LINES: 575 self._pending = [] 576 self._recolor_lines(first, last) 577 self.relexed.emit() 578 return 579 view = self.line_on_view() if self.line_on_view is not None else first 580 low = min(max(first, view - CHUNK_LINES // 4), max(first, last - CHUNK_LINES + 1)) 581 high = min(last, low + CHUNK_LINES - 1) 582 rest = [*range(first, low, CHUNK_LINES), *range(high + 1, last + 1, CHUNK_LINES)] 583 self._pending = [(low, high)] + [(a, min(a + CHUNK_LINES, low if a < low else last + 1) - 1) for a in rest] 584 self._recolor_next(self._job) 585 586 def _recolor_next(self, job: int): 587 if job != self._job or not self._pending: 588 return # (dropped: edited, closed, or recolored again since) 589 first, last = self._pending.pop(0) 590 self._recolor_lines(first, last) 591 if self._pending: 592 QTimer.singleShot(0, self, lambda: self._recolor_next(job)) 593 else: 594 self.relexed.emit() 595 596 def _recolor_lines(self, first: int, last: int): 597 self._busy = True # (recoloring changes the document's formats: not an edit) 598 try: 599 block = self.document().findBlockByNumber(first) 600 while block.isValid() and block.blockNumber() <= last: 601 self.rehighlightBlock(block) 602 block = block.next() 603 finally: 604 self._busy = False 605 606 def highlightBlock(self, text: str): 607 if self._provisional: 608 self._lex_line(text) 609 return 610 start = self.currentBlock().position() 611 end = start + len(text) 612 lexed = self._text 613 if not self._busy and lexed and (lexed[start:end] != text or (end < len(lexed) and lexed[end] != "\n")): 614 if self._quick_line(text): 615 return # (an edited line, colored on its own until the next lexing) 616 # Past the lines colored on their own (the whole text replaced, e.g. 617 # by Fix All): the last lexing's colors are for other characters 618 # here. Plain until the next lexing, which then recolors every line 619 # (lines that only moved aren't in the part it would recolor). 620 self._full = True 621 return 622 spans, count = self._spans, len(self._spans) 623 i = max(bisect_right(self._starts, start) - 1, 0) 624 while i < count: # (by index: slicing the list would copy the rest of it for every line) 625 pos, length, fmt = spans[i] 626 i += 1 627 if pos >= end: 628 break 629 if pos + length <= start: 630 continue 631 a, b = max(pos, start), min(pos + length, end) 632 self.setFormat(a - start, b - a, fmt) 633 634 def _quick_line(self, text: str) -> bool: 635 """Color a line that changed since the last lexing by lexing just it, 636 so typing is colored at once (False: over QUICK_LINES this edit).""" 637 if self._quick_budget <= 0: 638 return False 639 if self._quick_budget == QUICK_LINES: 640 QTimer.singleShot(0, self, self._reset_quick_budget) # (the budget is per edit) 641 self._quick_budget -= 1 642 return self._lex_line(text) 643 644 def _lex_line(self, text: str) -> bool: 645 """Color the current line by lexing it on its own (False: it failed).""" 646 pos = 0 647 try: 648 for ttype, value in lex(text, self.lexer): 649 length = min(len(value), len(text) - pos) 650 if length > 0: 651 self.setFormat(pos, length, self._format(ttype)) 652 pos += len(value) 653 except Exception: # noqa: BLE001 - never let highlighting break the editor 654 return False 655 return True 656 657 def _reset_quick_budget(self): 658 self._quick_budget = QUICK_LINES
47def document_text(document) -> str: 48 """A QTextDocument's text exactly as it is ("\\n" between lines). Qt's 49 toPlainText() turns non-breaking spaces into spaces (and U+2028 into a 50 line break): saving that would change the file.""" 51 return document.toRawText().replace("\u2029", "\n")
A QTextDocument's text exactly as it is ("\n" between lines). Qt's toPlainText() turns non-breaking spaces into spaces (and U+2028 into a line break): saving that would change the file.
61def available_styles() -> tuple[list[str], list[str]]: 62 """(light styles, dark styles): every Pygments style installed, sorted.""" 63 light, dark = [], [] 64 for name in sorted(get_all_styles()): 65 (dark if style_is_dark(name) else light).append(name) 66 return light, dark
(light styles, dark styles): every Pygments style installed, sorted.
69def style_name(palette: QPalette) -> str: 70 """The style to use: the chosen one, or automatic: the one that goes with 71 the app's theme (View → Theme), else by light/dark mode.""" 72 from . import themes 73 74 for name in ((chosen_style,) if chosen_style != AUTO else ()) + (themes.syntax_style(),): 75 if name: 76 try: 77 get_style_by_name(name) 78 return name 79 except ClassNotFound: 80 pass # uninstalled since: fall back to automatic 81 return DARK_STYLE if is_dark(palette) else LIGHT_STYLE
The style to use: the chosen one, or automatic: the one that goes with the app's theme (View → Theme), else by light/dark mode.
84def style_text_color(style) -> QColor: 85 """The color a style uses for plain text (styles that don't say get 86 near-white on a dark background, near-black on a light one).""" 87 for token in (Text, Token): 88 color = style.style_for_token(token).get("color") 89 if color: 90 return QColor("#" + color) 91 dark = QColor(style.background_color).lightness() < 128 92 return QColor("#f8f8f2") if dark else QColor("#1b1b1b")
The color a style uses for plain text (styles that don't say get near-white on a dark background, near-black on a light one).
95def apply_style(widget, highlighter): 96 """Highlight with the current style and give the text widget its 97 background and text colors.""" 98 highlighter.set_style(style_name(QApplication.palette())) 99 palette = widget.palette() 100 palette.setColor(QPalette.Base, highlighter.background()) 101 palette.setColor(QPalette.Text, style_text_color(highlighter.style)) 102 widget.setPalette(palette)
Highlight with the current style and give the text widget its background and text colors.
114class JsonWithCommentsLexer(JsonLexer): 115 """Pygments' JSON lexer already accepts // and /* */ comments (and 116 trailing commas); this just gives it VS Code's name for the format.""" 117 118 name = "JSON with Comments" 119 aliases: ClassVar[list[str]] = ["jsonc"] 120 filenames: ClassVar[list[str]] = ["*.jsonc", "*.code-workspace"]
Pygments' JSON lexer already accepts // and /* */ comments (and trailing commas); this just gives it VS Code's name for the format.
129class FortranSourceLexer(FortranLexer): 130 """Pygments' Fortran lexer with Fortran's strings. Its own are C's: a 131 backslash escapes what follows, and they go on over lines. So ``'\\'`` (a 132 backslash) didn't end, nor did a string left open while it's typed: all 133 that followed, to the next quote, was colored as a string. One left open 134 here is a string to the end of its line.""" 135 136 tokens: ClassVar[dict] = { 137 "strings": [ 138 (_fortran_string('"'), String.Double), 139 (_fortran_string("'"), String.Single), 140 (r'"[^"\n]*', String.Double), 141 (r"'[^'\n]*", String.Single), 142 ], 143 }
Pygments' Fortran lexer with Fortran's strings. Its own are C's: a
backslash escapes what follows, and they go on over lines. So '\' (a
backslash) didn't end, nor did a string left open while it's typed: all
that followed, to the next quote, was colored as a string. One left open
here is a string to the end of its line.
146class FortranFixedSourceLexer(FortranFixedLexer): 147 """Pygments' fixed-form lexer, its lines read by `FortranSourceLexer`.""" 148 149 def _lex_line(self, match, ctx=None): 150 text = match.group(0) + "\n" 151 for index, token, value in FortranSourceLexer().get_tokens_unprocessed(text): 152 value = value.replace("\n", "") 153 if value: 154 yield index, token, value 155 156 tokens: ClassVar[dict] = { 157 "root": [ 158 (r"[C*].*\n", Comment), 159 (r"#.*\n", Comment.Preproc), 160 (r" {0,4}!.*\n", Comment), 161 (r"(.{5})", Name.Label, "cont-char"), 162 (r".*\n", using(FortranSourceLexer)), 163 ], 164 "code": [ 165 (r"(.{66})(.*)(\n)", bygroups(_lex_line, Comment, Text.Whitespace), "root"), 166 (r"(.*)(\n)", bygroups(_lex_line, Text.Whitespace), "root"), 167 default("root"), 168 ], 169 }
Pygments' fixed-form lexer, its lines read by FortranSourceLexer.
172class FyppLexer(FortranSourceLexer): 173 """Fortran with fypp (https://fypp.readthedocs.io) in it: its directive 174 lines (``#:if``, ``#:for``, ``$:``, ``@:``), comments (``#!``) and inline 175 expressions (``${...}$``, ``@{...}@``) as preprocessing, the rest as 176 Fortran.""" 177 178 name = "Fortran (fypp)" 179 aliases: ClassVar[list[str]] = ["fypp"] 180 filenames: ClassVar[list[str]] = ["*.fypp"] 181 tokens: ClassVar[dict] = { 182 "root": [ 183 (r"(#[:!]|[@$]:)[^\n]*", Comment.Preproc), # (a line's directive, indented or not) 184 (r"\$\{.*?\}\$|@\{.*?\}@", Comment.Preproc), # (an expression inside a line) 185 (r"[a-z]\w*(?=\$\{|@\{)|(?<=\}\$)\w+|(?<=\}@)\w+", Name), # (a name made with one: x_${k}$_y) 186 inherit, 187 ], 188 }
Fortran with fypp (https://fypp.readthedocs.io) in it: its directive
lines (#:if, #:for, $:, @:), comments (#!) and inline
expressions (${...}$, @{...}@) as preprocessing, the rest as
Fortran.
191def lexer_for_path(path) -> object: 192 """A Pygments lexer chosen from the file name (plain text if unknown), 193 which keeps the text's positions (see keep_positions).""" 194 name = str(path) 195 if not name: # (no file, e.g. an empty diff pane: Pygments would go through every lexer to find none) 196 return keep_positions(TextLexer()) 197 suffix = ("." + name.rsplit(".", 1)[-1].lower()) if "." in name else "" 198 if suffix in _FIXED_FORM: 199 return keep_positions(FortranFixedSourceLexer()) 200 if suffix in _FREE_FORM: 201 return keep_positions(FortranSourceLexer()) 202 if suffix in _FYPP: 203 return keep_positions(FyppLexer()) 204 if suffix in _JSONC or (suffix == ".json" and Path(name).parent.name == ".vscode"): 205 return keep_positions(JsonWithCommentsLexer()) 206 try: 207 return keep_positions(get_lexer_for_filename(name)) 208 except ClassNotFound: 209 return keep_positions(TextLexer())
A Pygments lexer chosen from the file name (plain text if unknown), which keeps the text's positions (see keep_positions).
212def keep_positions(lexer): 213 """A lexer that doesn't drop the text's leading and trailing line breaks 214 (Pygments does by default): the tokens' positions are then the text's. 215 Otherwise a file starting with blank lines is colored a character off 216 for each of them.""" 217 lexer.stripnl = False 218 lexer.stripall = False 219 return lexer
A lexer that doesn't drop the text's leading and trailing line breaks (Pygments does by default): the tokens' positions are then the text's. Otherwise a file starting with blank lines is colored a character off for each of them.
229def token_role(ttype, all_prose: bool) -> str | None: 230 """"prose" (a comment or string: spell checked), "name" (a name or 231 keyword: not a misspelling elsewhere) or None.""" 232 if any(ttype in t for t in NOT_PROSE): 233 return None 234 if ttype in Comment or ttype in String or (all_prose and (ttype in Text or ttype in Generic)): 235 return "prose" 236 if ttype in Name or ttype in Keyword: 237 return "name" 238 return None
"prose" (a comment or string: spell checked), "name" (a name or keyword: not a misspelling elsewhere) or None.
241def token_format(style, ttype) -> QTextCharFormat: 242 """How a Pygments style shows a token type.""" 243 # Some lexers make their own token types (e.g. YAML's 244 # Literal.Scalar.Plain) that styles don't list: use the nearest 245 # parent type the style knows, as Pygments' own formatters do. 246 styled = ttype 247 while not style.styles_token(styled) and styled.parent is not None: 248 styled = styled.parent 249 spec = style.style_for_token(styled) 250 if ttype is Name and not spec["color"]: 251 # The TOML lexer marks keys as plain Name, which most styles 252 # leave uncolored; borrow the attribute-name color instead. 253 spec = style.style_for_token(Name.Attribute) 254 fmt = QTextCharFormat() 255 if spec["color"]: 256 fmt.setForeground(QColor("#" + spec["color"])) 257 if spec["bgcolor"]: 258 fmt.setBackground(QColor("#" + spec["bgcolor"])) 259 if spec["bold"]: 260 fmt.setFontWeight(QFont.Bold) 261 if spec["italic"]: 262 fmt.setFontItalic(True) 263 if spec["underline"]: 264 fmt.setFontUnderline(True) 265 return fmt
How a Pygments style shows a token type.
325class PygmentsHighlighter(QSyntaxHighlighter): 326 relexed = Signal() # the whole document has new colors (e.g. for the minimap) 327 _lexed = Signal(object) # (text, tokens) from the lexing thread 328 329 def __init__(self, document, lexer=None, style: str | None = None): 330 super().__init__(document) 331 self.lexer = keep_positions(lexer or TOMLLexer()) 332 self._spans: list[tuple[int, int, QTextCharFormat]] = [] 333 self._starts: list[int] = [] 334 self._formats: dict = {} 335 self._text = "" # the text the spans are for 336 self.prose: list[tuple[int, int]] = [] # (position, length) of each comment and string in it 337 self.names: set[str] = set() # the names and keywords in it (lowercase) 338 self._full = True # recolor every line next time (the style or lexer changed) 339 self._lexing = False # a thread is lexing 340 self._generation = 0 # changes with the style or lexer (a thread's answer from before is stale) 341 self._busy = False 342 self._closed = False # its tab is closed (see close): a lexing thread then drops its answer 343 self._quick_budget = QUICK_LINES # (see _quick_line) 344 self._provisional = False # coloring the lines on view on their own (see _color_view_first) 345 self._pending: list[tuple[int, int]] = [] # lines (0-based, first and last) still to recolor 346 self._job = 0 # (a recoloring a chunk at a time: changes when it's dropped) 347 self.line_on_view = None # () -> the first line on view (0-based), set by the editor: colored first 348 self.style = None 349 self._style_name = None 350 # While typing, wait for a short pause before lexing again. 351 self._timer = QTimer(self) 352 self._timer.setSingleShot(True) 353 self._timer.setInterval(120) 354 self._timer.timeout.connect(self._relex) 355 self._lexed.connect(self._apply) 356 self.set_style(style or LIGHT_STYLE) 357 document.contentsChange.connect(self._schedule) 358 359 def _schedule(self, *_): 360 if not self._busy: 361 if self._pending: # (edited while recoloring a chunk at a time: everything again, once lexed) 362 self._pending = [] 363 self._job += 1 364 self._full = True 365 self._timer.start() 366 367 def close(self): 368 """Its tab is closing: no more lexing, and a thread still lexing 369 drops its answer instead of sending it to a highlighter that's gone.""" 370 self._closed = True 371 self._timer.stop() 372 self._generation += 1 373 self._job += 1 374 self._pending = [] 375 376 def set_lexer(self, lexer): 377 self.lexer = keep_positions(lexer) 378 self._full = True 379 self._generation += 1 380 self._relex() 381 382 @contextmanager 383 def replacing_text(self, lexer=None): 384 """The document's whole text set (within it) at once, not typed (a 385 diff shown): colored straight away, not after the pause that typing 386 waits for, nor meanwhile with the old text's colors (in the wrong 387 places). ``lexer``: another one for it.""" 388 if lexer is not None: 389 self.lexer = keep_positions(lexer) 390 self._spans, self._starts = [], [] 391 self._full = True 392 self._generation += 1 393 self._job += 1 394 self._pending = [] 395 self._busy = True # (the text's change isn't an edit to wait for) 396 try: 397 yield 398 finally: 399 self._busy = False 400 self._relex() 401 402 def set_style(self, name: str): 403 if name == self._style_name: 404 return 405 self._style_name = name 406 self.style = get_style_by_name(name) 407 self._formats.clear() 408 self._full = True 409 self._generation += 1 410 QTimer.singleShot(0, self, self._relex) # (once, even when the text is set just after; not if deleted) 411 412 def background(self) -> QColor: 413 return QColor(self.style.background_color) 414 415 @property 416 def text(self) -> str: 417 """The text the colors (and `prose`, `names`) are for.""" 418 return self._text 419 420 def is_current(self) -> bool: 421 """Whether the colors are for the document as it is now: no lexing 422 waiting or running (and once that's done, `relexed` is emitted).""" 423 return not (self._timer.isActive() or self._lexing or self._full or self._pending) 424 425 def _format(self, ttype) -> QTextCharFormat: 426 fmt = self._formats.get(ttype) 427 if fmt is None: 428 fmt = self._formats[ttype] = token_format(self.style, ttype) 429 return fmt 430 431 @staticmethod 432 def _work(text: str, lexer, style, formats: dict, old_text: str, old_spans: list, full: bool): 433 """Lex ``text`` and work out what to recolor (on a thread for a big 434 document: no Qt objects but formats, which are plain values). 435 Returns (text, spans, starts, new formats, (start, end) or None for 436 everything, prose, names); spans [] if lexing failed (plain text). 437 prose and names: for spell checking (see `token_role`).""" 438 formats = dict(formats) # (the window's own copy is not touched here) 439 added = {} 440 spans, pos = [], 0 441 prose: list[tuple[int, int]] = [] 442 names: set[str] = set() 443 all_prose = getattr(lexer, "name", "") in PROSE_LEXERS 444 roles: dict = {} 445 try: 446 for ttype, value in lex(text, lexer): 447 # Pygments may append a trailing newline that isn't in the document. 448 length = min(len(value), len(text) - pos) 449 if length > 0: 450 fmt = formats.get(ttype) 451 if fmt is None: 452 fmt = formats[ttype] = added[ttype] = token_format(style, ttype) 453 spans.append((pos, length, fmt)) 454 role = roles.get(ttype, 0) 455 if role == 0: 456 role = roles[ttype] = token_role(ttype, all_prose) 457 if role == "prose": 458 if prose and prose[-1][0] + prose[-1][1] == pos: # (one comment: several tokens) 459 prose[-1] = (prose[-1][0], prose[-1][1] + length) 460 else: 461 prose.append((pos, length)) 462 elif role == "name": 463 names.add(value.strip().lower()) 464 pos += len(value) 465 except Exception: # noqa: BLE001 - never let highlighting break the editor 466 spans, full, prose, names = [], True, [], set() 467 starts = [s[0] for s in spans] 468 region = None if full else PygmentsHighlighter._changed(old_text, old_spans, text, spans) 469 return text, spans, starts, added, region, prose, names 470 471 @staticmethod 472 def _changed(old_text: str, old: list, text: str, new: list) -> tuple[int, int]: 473 """The part of the new text whose colors may differ: the edited text, 474 and the tokens from the first difference to the last (e.g. an opened 475 string changes the rest of the file).""" 476 prefix = _common_prefix(old_text, text) 477 suffix = _common_suffix(old_text, text, min(len(old_text), len(text)) - prefix) 478 start, end = prefix, len(text) - suffix 479 shift = len(text) - len(old_text) 480 n = min(len(old), len(new)) 481 i = 0 482 while i < n and old[i][0] == new[i][0] and old[i][1] == new[i][1] and old[i][2] is new[i][2]: 483 i += 1 484 j = 0 485 while j < n - i: 486 a, b = old[-1 - j], new[-1 - j] 487 if a[0] + shift != b[0] or a[1] != b[1] or a[2] is not b[2]: 488 break 489 j += 1 490 if i < len(new): 491 start = min(start, new[i][0]) 492 last = len(new) - 1 - j 493 if last >= i: 494 end = max(end, new[last][0] + new[last][1]) 495 return start, end 496 497 def _relex(self, *_): 498 """Lex the text again (on a thread for a big document) and recolor 499 what changed.""" 500 if self._busy or self._closed: 501 return 502 self._timer.stop() 503 text = document_text(self.document()) 504 if text == self._text and not self._full: 505 return 506 if self._lexing: # (the thread's answer is checked against the text then) 507 return 508 args = (text, self.lexer, self.style, self._formats, self._text, self._spans, self._full) 509 generation = self._generation 510 if len(text) > LEX_IN_THREAD: 511 self._lexing = True 512 threading.Thread(target=self._lex_in_thread, args=(generation, args), 513 daemon=True, name="Syntax highlighting").start() 514 if not self._spans: # (nothing colored yet: a file just opened) 515 self._color_view_first() 516 else: 517 self._apply((generation, self._work(*args))) 518 519 def _color_view_first(self): 520 """The lines on view colored now, each lexed on its own, while the 521 whole document is lexed on the thread (that then colors every line, 522 correcting what depends on other lines: a string continued on the 523 next, say).""" 524 first = self.line_on_view() if self.line_on_view is not None else 0 525 self._provisional = self._busy = True # (recoloring isn't an edit) 526 try: 527 block = self.document().findBlockByNumber(max(first, 0)) 528 for _ in range(VIEW_LINES): 529 if not block.isValid(): 530 break 531 self.rehighlightBlock(block) 532 block = block.next() 533 finally: 534 self._provisional = self._busy = False 535 536 def _lex_in_thread(self, generation, args): 537 # The lexer is Python too: while it runs, the window's code waits its 538 # turn each time it runs (Python's GIL), up to the switch interval 539 # each time. A shorter interval while lexing keeps the window responsive. 540 with _quick_thread_switching(): 541 result = self._work(*args) 542 if self._closed: 543 return # (its tab was closed meanwhile: nobody wants the colors) 544 try: 545 self._lexed.emit((generation, result)) 546 except RuntimeError: 547 pass # (deleted meanwhile without close(): the same) 548 549 def _apply(self, result): 550 generation, (text, spans, starts, added, region, prose, names) = result 551 self._lexing = False 552 if text != document_text(self.document()) or generation != self._generation: 553 self._relex() # (the text, style or lexer changed while lexing: again) 554 return 555 self._formats.update(added) 556 self._spans, self._starts, self._text = spans, starts, text 557 self.prose, self.names = prose, names 558 document = self.document() 559 last = document.blockCount() - 1 560 if self._full or region is None: 561 first = 0 562 else: 563 first = document.findBlock(region[0]).blockNumber() 564 end = document.findBlock(region[1]) 565 last = end.blockNumber() if end.isValid() else last 566 self._full = False 567 self._recolor(max(first, 0), last) 568 569 def _recolor(self, first: int, last: int): 570 """Recolor lines ``first`` to ``last`` (0-based). Many (a big file 571 colored after lexing, a string opened at the top of one): those on 572 view first, then the rest a chunk at a time, the window answering in 573 between (all at once took a second for 70 000 lines).""" 574 self._job += 1 575 if last - first < CHUNK_LINES: 576 self._pending = [] 577 self._recolor_lines(first, last) 578 self.relexed.emit() 579 return 580 view = self.line_on_view() if self.line_on_view is not None else first 581 low = min(max(first, view - CHUNK_LINES // 4), max(first, last - CHUNK_LINES + 1)) 582 high = min(last, low + CHUNK_LINES - 1) 583 rest = [*range(first, low, CHUNK_LINES), *range(high + 1, last + 1, CHUNK_LINES)] 584 self._pending = [(low, high)] + [(a, min(a + CHUNK_LINES, low if a < low else last + 1) - 1) for a in rest] 585 self._recolor_next(self._job) 586 587 def _recolor_next(self, job: int): 588 if job != self._job or not self._pending: 589 return # (dropped: edited, closed, or recolored again since) 590 first, last = self._pending.pop(0) 591 self._recolor_lines(first, last) 592 if self._pending: 593 QTimer.singleShot(0, self, lambda: self._recolor_next(job)) 594 else: 595 self.relexed.emit() 596 597 def _recolor_lines(self, first: int, last: int): 598 self._busy = True # (recoloring changes the document's formats: not an edit) 599 try: 600 block = self.document().findBlockByNumber(first) 601 while block.isValid() and block.blockNumber() <= last: 602 self.rehighlightBlock(block) 603 block = block.next() 604 finally: 605 self._busy = False 606 607 def highlightBlock(self, text: str): 608 if self._provisional: 609 self._lex_line(text) 610 return 611 start = self.currentBlock().position() 612 end = start + len(text) 613 lexed = self._text 614 if not self._busy and lexed and (lexed[start:end] != text or (end < len(lexed) and lexed[end] != "\n")): 615 if self._quick_line(text): 616 return # (an edited line, colored on its own until the next lexing) 617 # Past the lines colored on their own (the whole text replaced, e.g. 618 # by Fix All): the last lexing's colors are for other characters 619 # here. Plain until the next lexing, which then recolors every line 620 # (lines that only moved aren't in the part it would recolor). 621 self._full = True 622 return 623 spans, count = self._spans, len(self._spans) 624 i = max(bisect_right(self._starts, start) - 1, 0) 625 while i < count: # (by index: slicing the list would copy the rest of it for every line) 626 pos, length, fmt = spans[i] 627 i += 1 628 if pos >= end: 629 break 630 if pos + length <= start: 631 continue 632 a, b = max(pos, start), min(pos + length, end) 633 self.setFormat(a - start, b - a, fmt) 634 635 def _quick_line(self, text: str) -> bool: 636 """Color a line that changed since the last lexing by lexing just it, 637 so typing is colored at once (False: over QUICK_LINES this edit).""" 638 if self._quick_budget <= 0: 639 return False 640 if self._quick_budget == QUICK_LINES: 641 QTimer.singleShot(0, self, self._reset_quick_budget) # (the budget is per edit) 642 self._quick_budget -= 1 643 return self._lex_line(text) 644 645 def _lex_line(self, text: str) -> bool: 646 """Color the current line by lexing it on its own (False: it failed).""" 647 pos = 0 648 try: 649 for ttype, value in lex(text, self.lexer): 650 length = min(len(value), len(text) - pos) 651 if length > 0: 652 self.setFormat(pos, length, self._format(ttype)) 653 pos += len(value) 654 except Exception: # noqa: BLE001 - never let highlighting break the editor 655 return False 656 return True 657 658 def _reset_quick_budget(self): 659 self._quick_budget = QUICK_LINES
QSyntaxHighlighter(self, parent: PySide6.QtGui.QTextDocument, /) -> None QSyntaxHighlighter(self, parent: PySide6.QtCore.QObject, /) -> None
329 def __init__(self, document, lexer=None, style: str | None = None): 330 super().__init__(document) 331 self.lexer = keep_positions(lexer or TOMLLexer()) 332 self._spans: list[tuple[int, int, QTextCharFormat]] = [] 333 self._starts: list[int] = [] 334 self._formats: dict = {} 335 self._text = "" # the text the spans are for 336 self.prose: list[tuple[int, int]] = [] # (position, length) of each comment and string in it 337 self.names: set[str] = set() # the names and keywords in it (lowercase) 338 self._full = True # recolor every line next time (the style or lexer changed) 339 self._lexing = False # a thread is lexing 340 self._generation = 0 # changes with the style or lexer (a thread's answer from before is stale) 341 self._busy = False 342 self._closed = False # its tab is closed (see close): a lexing thread then drops its answer 343 self._quick_budget = QUICK_LINES # (see _quick_line) 344 self._provisional = False # coloring the lines on view on their own (see _color_view_first) 345 self._pending: list[tuple[int, int]] = [] # lines (0-based, first and last) still to recolor 346 self._job = 0 # (a recoloring a chunk at a time: changes when it's dropped) 347 self.line_on_view = None # () -> the first line on view (0-based), set by the editor: colored first 348 self.style = None 349 self._style_name = None 350 # While typing, wait for a short pause before lexing again. 351 self._timer = QTimer(self) 352 self._timer.setSingleShot(True) 353 self._timer.setInterval(120) 354 self._timer.timeout.connect(self._relex) 355 self._lexed.connect(self._apply) 356 self.set_style(style or LIGHT_STYLE) 357 document.contentsChange.connect(self._schedule)
__init__(self, parent: PySide6.QtGui.QTextDocument, /) -> None __init__(self, parent: PySide6.QtCore.QObject, /) -> None
Initialize self. See help(type(self)) for accurate signature.
367 def close(self): 368 """Its tab is closing: no more lexing, and a thread still lexing 369 drops its answer instead of sending it to a highlighter that's gone.""" 370 self._closed = True 371 self._timer.stop() 372 self._generation += 1 373 self._job += 1 374 self._pending = []
Its tab is closing: no more lexing, and a thread still lexing drops its answer instead of sending it to a highlighter that's gone.
382 @contextmanager 383 def replacing_text(self, lexer=None): 384 """The document's whole text set (within it) at once, not typed (a 385 diff shown): colored straight away, not after the pause that typing 386 waits for, nor meanwhile with the old text's colors (in the wrong 387 places). ``lexer``: another one for it.""" 388 if lexer is not None: 389 self.lexer = keep_positions(lexer) 390 self._spans, self._starts = [], [] 391 self._full = True 392 self._generation += 1 393 self._job += 1 394 self._pending = [] 395 self._busy = True # (the text's change isn't an edit to wait for) 396 try: 397 yield 398 finally: 399 self._busy = False 400 self._relex()
The document's whole text set (within it) at once, not typed (a
diff shown): colored straight away, not after the pause that typing
waits for, nor meanwhile with the old text's colors (in the wrong
places). lexer: another one for it.
402 def set_style(self, name: str): 403 if name == self._style_name: 404 return 405 self._style_name = name 406 self.style = get_style_by_name(name) 407 self._formats.clear() 408 self._full = True 409 self._generation += 1 410 QTimer.singleShot(0, self, self._relex) # (once, even when the text is set just after; not if deleted)
420 def is_current(self) -> bool: 421 """Whether the colors are for the document as it is now: no lexing 422 waiting or running (and once that's done, `relexed` is emitted).""" 423 return not (self._timer.isActive() or self._lexing or self._full or self._pending)
Whether the colors are for the document as it is now: no lexing
waiting or running (and once that's done, relexed is emitted).
607 def highlightBlock(self, text: str): 608 if self._provisional: 609 self._lex_line(text) 610 return 611 start = self.currentBlock().position() 612 end = start + len(text) 613 lexed = self._text 614 if not self._busy and lexed and (lexed[start:end] != text or (end < len(lexed) and lexed[end] != "\n")): 615 if self._quick_line(text): 616 return # (an edited line, colored on its own until the next lexing) 617 # Past the lines colored on their own (the whole text replaced, e.g. 618 # by Fix All): the last lexing's colors are for other characters 619 # here. Plain until the next lexing, which then recolors every line 620 # (lines that only moved aren't in the part it would recolor). 621 self._full = True 622 return 623 spans, count = self._spans, len(self._spans) 624 i = max(bisect_right(self._starts, start) - 1, 0) 625 while i < count: # (by index: slicing the list would copy the rest of it for every line) 626 pos, length, fmt = spans[i] 627 i += 1 628 if pos >= end: 629 break 630 if pos + length <= start: 631 continue 632 a, b = max(pos, start), min(pos + length, end) 633 self.setFormat(a - start, b - a, fmt)
highlightBlock(self, text: str, /) -> None