Coverage for vibefpmgui/highlighter.py: 96%

431 statements  

« 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. 

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: 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 

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: 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) 

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: 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() 

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): 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 

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(): 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 

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