Coverage for vibefpmgui/highlighter.py: 96%
431 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""Syntax highlighting for QPlainTextEdit/QTextEdit using a Pygments lexer.
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"""
14from __future__ import annotations
16import sys
17import threading
18from bisect import bisect_right
19from contextlib import contextmanager
20from pathlib import Path
21from typing import ClassVar
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
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)
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")
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
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
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
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
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")
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)
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"}
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."""
117 name = "JSON with Comments"
118 aliases: ClassVar[list[str]] = ["jsonc"]
119 filenames: ClassVar[list[str]] = ["*.jsonc", "*.code-workspace"]
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}"
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."""
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 }
145class FortranFixedSourceLexer(FortranFixedLexer):
146 """Pygments' fixed-form lexer, its lines read by `FortranSourceLexer`."""
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
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 }
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."""
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 }
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())
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
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`)
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
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: 246 ↛ 247line 246 didn't jump to line 247 because the condition on line 246 was never true
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"]: 256 ↛ 257line 256 didn't jump to line 257 because the condition on line 256 was never true
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"]: 262 ↛ 263line 262 didn't jump to line 263 because the condition on line 262 was never true
263 fmt.setFontUnderline(True)
264 return fmt
267def is_dark(palette: QPalette) -> bool:
268 return palette.color(QPalette.Base).lightness() < 128
271LEX_IN_THREAD = 100_000 # characters: bigger documents are lexed on a thread (typing stays smooth)
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
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
296_switching_lock = threading.Lock()
297_switching_users = 0
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: 306 ↛ 309line 306 didn't jump to line 309 because the condition on line 306 was always true
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: 315 ↛ exitline 315 didn't jump to the function exit
316 sys.setswitchinterval(_quick_thread_switching.saved)
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)
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
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)
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()
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 = []
375 def set_lexer(self, lexer):
376 self.lexer = keep_positions(lexer)
377 self._full = True
378 self._generation += 1
379 self._relex()
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: 387 ↛ 389line 387 didn't jump to line 389 because the condition on line 387 was always true
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()
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)
411 def background(self) -> QColor:
412 return QColor(self.style.background_color)
414 @property
415 def text(self) -> str:
416 """The text the colors (and `prose`, `names`) are for."""
417 return self._text
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)
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
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
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): 489 ↛ 491line 489 didn't jump to line 491 because the condition on line 489 was always true
490 start = min(start, new[i][0])
491 last = len(new) - 1 - j
492 if last >= i: 492 ↛ 494line 492 didn't jump to line 494 because the condition on line 492 was always true
493 end = max(end, new[last][0] + new[last][1])
494 return start, end
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)))
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(): 528 ↛ 529line 528 didn't jump to line 529 because the condition on line 528 was never true
529 break
530 self.rehighlightBlock(block)
531 block = block.next()
532 finally:
533 self._provisional = self._busy = False
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)
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)
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)
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()
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
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)
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)
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
657 def _reset_quick_budget(self):
658 self._quick_budget = QUICK_LINES