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
LIGHT_STYLE = 'default'
DARK_STYLE = 'monokai'
AUTO = 'auto'
chosen_style = 'auto'
def document_text(document) -> str:
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.

def style_is_dark(name: str) -> bool:
54def style_is_dark(name: str) -> bool:
55    try:
56        return QColor(get_style_by_name(name).background_color).lightness() < 128
57    except ClassNotFound:
58        return False
def available_styles() -> tuple[list[str], list[str]]:
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.

def style_name(palette: PySide6.QtGui.QPalette) -> str:
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.

def style_text_color(style) -> PySide6.QtGui.QColor:
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).

def apply_style(widget, highlighter):
 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.

class JsonWithCommentsLexer(pygments.lexers.data.JsonLexer):
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.

name = 'JSON with Comments'
aliases: ClassVar[list[str]] = ['jsonc']
filenames: ClassVar[list[str]] = ['*.jsonc', '*.code-workspace']
class FortranSourceLexer(pygments.lexers.fortran.FortranLexer):
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.

tokens: ClassVar[dict] = {'strings': [('"(?:""|&[ \\t]*\\n[ \\t]*&?|[^"\\n])*"', Token.Literal.String.Double), ("'(?:''|&[ \\t]*\\n[ \\t]*&?|[^'\\n])*'", Token.Literal.String.Single), ('"[^"\\n]*', Token.Literal.String.Double), ("'[^'\\n]*", Token.Literal.String.Single)]}
class FortranFixedSourceLexer(pygments.lexers.fortran.FortranFixedLexer):
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.

tokens: ClassVar[dict] = {'root': [('[C*].*\\n', Token.Comment), ('#.*\\n', Token.Comment.Preproc), (' {0,4}!.*\\n', Token.Comment), ('(.{5})', Token.Name.Label, 'cont-char'), ('.*\\n', <function using.<locals>.callback>)], 'code': [('(.{66})(.*)(\\n)', <function bygroups.<locals>.callback>, 'root'), ('(.*)(\\n)', <function bygroups.<locals>.callback>, 'root'), <pygments.lexer.default object>]}
class FyppLexer(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.

name = 'Fortran (fypp)'
aliases: ClassVar[list[str]] = ['fypp']
filenames: ClassVar[list[str]] = ['*.fypp']
tokens: ClassVar[dict] = {'root': [('(#[:!]|[@$]:)[^\\n]*', Token.Comment.Preproc), ('\\$\\{.*?\\}\\$|@\\{.*?\\}@', Token.Comment.Preproc), ('[a-z]\\w*(?=\\$\\{|@\\{)|(?<=\\}\\$)\\w+|(?<=\\}@)\\w+', Token.Name), inherit]}
def lexer_for_path(path) -> object:
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).

def keep_positions(lexer):
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.

PROSE_LEXERS = {'Markdown', 'reStructuredText', 'Text only'}
NOT_PROSE = (Token.Comment.Preproc, Token.Comment.PreprocFile, Token.Literal.String.Escape, Token.Literal.String.Interpol, Token.Literal.String.Regex, Token.Literal.String.Symbol, Token.Literal.String.Affix, Token.Literal.String.Backtick)
def token_role(ttype, all_prose: bool) -> str | None:
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.

def token_format(style, ttype) -> PySide6.QtGui.QTextCharFormat:
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.

def is_dark(palette: PySide6.QtGui.QPalette) -> bool:
268def is_dark(palette: QPalette) -> bool:
269    return palette.color(QPalette.Base).lightness() < 128
LEX_IN_THREAD = 100000
QUICK_LINES = 50
VIEW_LINES = 150
CHUNK_LINES = 2000
class PygmentsHighlighter(PySide6.QtGui.QSyntaxHighlighter):
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

PygmentsHighlighter(document, lexer=None, style: str | None = 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.

def relexed(unknown):

The type of the None singleton.

lexer
prose: list[tuple[int, int]]
names: set[str]
line_on_view
style
def close(self):
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.

def set_lexer(self, lexer):
376    def set_lexer(self, lexer):
377        self.lexer = keep_positions(lexer)
378        self._full = True
379        self._generation += 1
380        self._relex()
@contextmanager
def replacing_text(self, lexer=None):
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.

def set_style(self, name: str):
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)
def background(self) -> PySide6.QtGui.QColor:
412    def background(self) -> QColor:
413        return QColor(self.style.background_color)
text: str
415    @property
416    def text(self) -> str:
417        """The text the colors (and `prose`, `names`) are for."""
418        return self._text

The text the colors (and prose, names) are for.

def is_current(self) -> bool:
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).

def highlightBlock(self, text: str):
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

staticMetaObject = PySide6.QtCore.QMetaObject("PygmentsHighlighter" inherits "QSyntaxHighlighter": Methods: #8 type=Signal, signature=relexed() #9 type=Signal, signature=_lexed(PyObject), parameters=PyObject )