Coverage for vibefpmgui/editor.py: 91%

496 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

1"""A small built-in code editor: line numbers, syntax highlighting (Pygments, 

2chosen by file extension), find/replace, go to line, and compiler warning / 

3error markers in the gutter, fortitude lint findings (see `vibefpmgui.lint`), and 

4for Fortran files the language server's hover, go to definition and outline 

5(see `vibefpmgui.lsp`). 

6 

7`EditorTab` wraps a `CodeEditor` (`vibefpmgui.code_editor`) with a find bar 

8(`vibefpmgui.find_bar`), the outline and a status line, and takes care of 

9loading and saving the file (keeping its encoding and line endings) and of 

10noticing when it changes on disk. Its language-server features are in 

11`vibefpmgui.editor_language`, its checks (fortitude, spelling, gremlins, the 

12compiler's and the AI's markers) in `vibefpmgui.editor_checks`. 

13""" 

14 

15from __future__ import annotations 

16 

17import html 

18import os 

19import re 

20from pathlib import Path 

21 

22from qtpy.QtCore import QFileSystemWatcher, QProcess, QRect, QSize, Qt, QTimer, Signal 

23from qtpy.QtGui import QFont, QFontDatabase, QPalette, QTextCursor 

24from qtpy.QtWidgets import ( 

25 QApplication, 

26 QComboBox, 

27 QFileDialog, 

28 QFrame, 

29 QHBoxLayout, 

30 QInputDialog, 

31 QLabel, 

32 QMessageBox, 

33 QPushButton, 

34 QStyle, 

35 QStyledItemDelegate, 

36 QStyleOptionViewItem, 

37 QVBoxLayout, 

38 QWidget, 

39) 

40 

41from . import git_blame 

42from . import git_status as gs 

43from . import lsp as lsp_mod 

44from .background import call_in_background 

45from .code_editor import CodeEditor 

46from .editor_checks import EditorChecksMixin 

47from .editor_language import EditorLanguageMixin 

48from .editor_shared import ( 

49 BLAME_DELAY_MS, 

50 COMPLETE_MS, 

51 DEPTH_ROLE, 

52 LINT_DELAY_MS, 

53 LSP_SYNC_MS, 

54 SPELLING_DELAY_MS, 

55 line_changes, 

56) 

57from .find_bar import FindBar 

58from .git_blame import BlameColumn 

59from .highlighter import PygmentsHighlighter, apply_style, lexer_for_path 

60from .icons import SYMBOL_INDENT 

61from .line_edits import comment_style 

62from .minimap import Minimap 

63from .source_text import read_source 

64from .widgets import StatusLabel 

65 

66# Files whose trailing whitespace means something (Markdown: a line break; 

67# a patch: a context line's), not trimmed on save (Edit → Trim Trailing 

68# Whitespace still trims them). 

69TRIM_KEEPS = {".md", ".markdown", ".diff", ".patch"} 

70def _qt_length(text: str) -> int: 

71 """``text``'s length as Qt counts it (UTF-16).""" 

72 return len(text) if text.isascii() else len(text.encode("utf-16-le")) // 2 

73 

74 

75TRAILING_SPACE_RE = re.compile(r"[^\S\n]+$", re.MULTILINE) # (whitespace at a line's end, as str.rstrip strips) 

76 

77 

78class _OutlineDelegate(QStyledItemDelegate): 

79 """Draws the outline's entries with the badge and name together, 

80 indented one step per nesting level (row background across the full 

81 width).""" 

82 

83 def paint(self, painter, option, index): 

84 opt = QStyleOptionViewItem(option) 

85 self.initStyleOption(opt, index) 

86 style = opt.widget.style() if opt.widget else QApplication.style() 

87 selected = bool(opt.state & QStyle.State_Selected) or bool(opt.state & QStyle.State_MouseOver) 

88 painter.save() 

89 if selected: 

90 painter.fillRect(opt.rect, opt.palette.color(QPalette.Active, QPalette.Highlight)) 

91 else: 

92 style.drawPrimitive(QStyle.PE_PanelItemViewItem, opt, painter, opt.widget) 

93 x = opt.rect.left() + 8 + SYMBOL_INDENT * int(index.data(DEPTH_ROLE) or 0) 

94 size = opt.decorationSize 

95 top = opt.rect.top() + (opt.rect.height() - size.height()) // 2 

96 opt.icon.paint(painter, QRect(x, top, size.width(), size.height())) 

97 painter.setPen(opt.palette.color(QPalette.Active, QPalette.HighlightedText) if selected 

98 else opt.palette.color(QPalette.Text)) 

99 painter.setFont(opt.font) 

100 text_rect = QRect(x + size.width() + 6, opt.rect.top(), opt.rect.right() - x - size.width() - 6, 

101 opt.rect.height()) 

102 painter.drawText(text_rect, Qt.AlignVCenter | Qt.AlignLeft, opt.text) 

103 painter.restore() 

104 

105 def sizeHint(self, option, index): 

106 hint = super().sizeHint(option, index) 

107 return QSize(hint.width() + 16 + SYMBOL_INDENT * int(index.data(DEPTH_ROLE) or 0), 

108 max(hint.height(), 22)) 

109 

110 

111class EditorTab(EditorLanguageMixin, EditorChecksMixin, QWidget): 

112 """One open file: editor + find bar + status line.""" 

113 

114 lint_enabled = True # lint Fortran files with fortitude (View menu) 

115 lsp_diagnostics_enabled = True # show fortls's own checks (View menu) 

116 spelling_enabled = True # spell check comments and strings (View menu) 

117 gremlins_enabled = True # View → Show Gremlins (non-ASCII and invisible characters) 

118 minimap_enabled = True # View → Show Minimap 

119 blame_enabled = False # View → Show Git Blame (see vibefpmgui.git_blame) 

120 word_wrap = False # View → Word Wrap 

121 trim_on_save = True # Edit → Trim Trailing Whitespace on Save 

122 

123 dirty_changed = Signal(bool) 

124 saved = Signal(str) 

125 created = Signal(str) # Save As made a file that wasn't there (a new file's, or another name's) 

126 path_changed = Signal(str) 

127 navigate_requested = Signal(str, int, int) # go to file, line (1-based), column (0-based) 

128 references_found = Signal(str, object) # name, [(file, line, start column, end column)] 

129 rename_ready = Signal(str, str, object) # old name, new name, {file: [lsp.TextEdit]} 

130 problems_changed = Signal() # (see problems) 

131 explain_requested = Signal(object) # a Problem to Explain with AI (from the editor's menu) 

132 

133 def __init__(self, path: Path, parent=None, untitled: bool = False): 

134 """``untitled``: a new file, not saved yet (File → New File): ``path`` 

135 is only its name for now (no such file); it has no file type, so no 

136 colors or checks, until Save As gives it a name.""" 

137 super().__init__(parent) 

138 self.path = Path(path).resolve() 

139 self.untitled = untitled 

140 self.encoding = "utf-8" 

141 self.newline = "\n" 

142 self._disk_newline = "\n" # the line end of the file as saved (see is_dirty) 

143 self.mixed_newlines = False # the file has both LF and CRLF (or old Mac CR) line ends (see line_endings) 

144 self._disk_mixed = False 

145 self._disk_text = "" 

146 self._compiler_markers: dict[int, list[tuple[str, str]]] = {} 

147 self._review_markers: dict[int, list[tuple[str, str]]] = {} 

148 self._lint_markers: dict[int, list[tuple[str, str]]] = {} 

149 self._lsp_markers: dict[int, list[tuple[str, str]]] = {} 

150 self._fortitude_issues: list = [] # (the editor underlines these and fortls's together) 

151 self._lsp_issues: list = [] 

152 self._spelling_issues: list = [] 

153 self._lint_process: QProcess | None = None 

154 self._lint_again = False # the text changed while fortitude was running 

155 self.rewriting = False # Format or Fix All is running on this text (see MainWindow._rewrite_text) 

156 self.lsp: lsp_mod.LspClient | None = None # the language server, for Fortran files 

157 self._outline: list[tuple[int, str, str, int, int]] = [] 

158 self.editor = CodeEditor() 

159 self.highlighter = PygmentsHighlighter(self.editor.document(), lexer_for_path(self.path)) 

160 self.editor.comment_prefix, self.editor.fixed_form = comment_style(self.path) # (Toggle Line Comment) 

161 self.highlighter.line_on_view = lambda: self.editor.firstVisibleBlock().blockNumber() # (colored first) 

162 self.find_bar = FindBar(self.editor) 

163 # A change to the text: the last test run's coverage no longer fits its lines. 

164 self.editor.document().contentsChange.connect( 

165 lambda _pos, removed, added: (removed or added) and self.editor.coverage and self.editor.set_coverage(None)) 

166 # The selection's other occurrences: matched as the find bar is set. 

167 self.editor.match_options = lambda: (self.find_bar.case_check.isChecked(), 

168 self.find_bar.word_check.isChecked()) 

169 self.find_bar.case_check.toggled.connect(self.editor.update_occurrences) 

170 self.find_bar.word_check.toggled.connect(self.editor.update_occurrences) 

171 self.status = StatusLabel() 

172 self.status.setContentsMargins(8, 2, 8, 2) 

173 

174 # Outline of the file (modules, types, procedures), from the language server. 

175 self.outline_box = QComboBox() 

176 self.outline_box.setMinimumContentsLength(30) 

177 self.outline_box.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon) 

178 self.outline_box.setToolTip("Outline: jump to a module, type or procedure in this file") 

179 self.outline_box.setIconSize(QSize(16, 16)) 

180 self.outline_box.setItemDelegate(_OutlineDelegate(self.outline_box)) 

181 self.outline_box.activated.connect(self._outline_chosen) 

182 self.outline_bar = QWidget() 

183 bar = QHBoxLayout(self.outline_bar) 

184 bar.setContentsMargins(6, 3, 6, 3) 

185 bar.addWidget(self.outline_box) 

186 bar.addStretch(1) 

187 self.outline_bar.hide() 

188 

189 layout = QVBoxLayout(self) 

190 layout.setContentsMargins(0, 0, 0, 0) 

191 layout.setSpacing(0) 

192 layout.addWidget(self.outline_bar) 

193 # Shown when the file changes on disk while it has unsaved edits: the 

194 # choice waits here, in the tab (a dialog from a timer could open on 

195 # top of another one, e.g. Save As). 

196 self._disk_change: tuple[str, str, str] | None = None # (text, encoding, line end) on disk, waiting 

197 self.disk_bar = QFrame() 

198 self.disk_bar.setObjectName("DiskBar") 

199 self.disk_bar.setStyleSheet("#DiskBar { background: rgba(240, 140, 0, 60); border-bottom: 1px solid " 

200 "rgba(240, 140, 0, 160); }") 

201 self.disk_label = QLabel() 

202 self.disk_label.setWordWrap(True) 

203 reload_button = QPushButton("Reload") 

204 reload_button.setToolTip("Show the file as it is on disk (your unsaved edits are lost; Undo brings them back)") 

205 reload_button.clicked.connect(self.reload_from_disk) 

206 keep_button = QPushButton("Keep Mine") 

207 keep_button.setToolTip("Keep your edits; saving them replaces the file on disk") 

208 keep_button.clicked.connect(self.keep_my_edits) 

209 for button in (reload_button, keep_button): 

210 button.setAttribute(Qt.WA_MacSmallSize) 

211 disk_row = QHBoxLayout(self.disk_bar) 

212 disk_row.setContentsMargins(8, 4, 8, 4) 

213 disk_row.addWidget(self.disk_label, 1) 

214 disk_row.addWidget(reload_button) 

215 disk_row.addWidget(keep_button) 

216 self.disk_bar.hide() 

217 layout.addWidget(self.disk_bar) 

218 # The text, with the minimap at its right. 

219 # (A child of the tab from the start: shown without a parent, even 

220 # until the layout below takes it, it was a window of its own for a 

221 # moment, which Windows showed as a window flashing up.) 

222 self.minimap = Minimap(self.editor, self) 

223 self.editor.minimap = self.minimap 

224 self.minimap.setVisible(self.minimap_enabled) 

225 self.editor.set_word_wrap(self.word_wrap) 

226 self.highlighter.relexed.connect(self.minimap.invalidate) 

227 # Git blame, left of the minimap (View → Show Git Blame): read again once typing pauses. 

228 self.blame_column = BlameColumn(self.editor, self) 

229 self.blame_column.setVisible(self.blame_enabled) 

230 self._blame_request = 0 # (an answer to an older request is dropped) 

231 self._blame_timer = QTimer(self) 

232 self._blame_timer.setSingleShot(True) 

233 self._blame_timer.setInterval(BLAME_DELAY_MS) 

234 self._blame_timer.timeout.connect(self.refresh_blame) 

235 self.editor.document().contentsChanged.connect( 

236 lambda: self.blame_enabled and self._blame_timer.start()) 

237 self._spelling_timer = QTimer(self) # (once the colors, so the comments and strings, are updated) 

238 self._spelling_timer.setSingleShot(True) 

239 self._spelling_timer.setInterval(SPELLING_DELAY_MS) 

240 self._spelling_timer.timeout.connect(self.check_spelling) 

241 self.highlighter.relexed.connect(self._spelling_timer.start) 

242 body = QHBoxLayout() 

243 body.setContentsMargins(0, 0, 0, 0) 

244 body.setSpacing(0) 

245 body.addWidget(self.editor, 1) 

246 body.addWidget(self.blame_column) 

247 body.addWidget(self.minimap) 

248 layout.addLayout(body, 1) 

249 layout.addWidget(self.find_bar) 

250 layout.addWidget(self.status) 

251 

252 self.watcher = QFileSystemWatcher(self) 

253 self.watcher.fileChanged.connect(self._file_changed) 

254 self._check_timer = QTimer(self) # coalesce bursts of change events 

255 self._check_timer.setSingleShot(True) 

256 self._check_timer.setInterval(200) 

257 self._check_timer.timeout.connect(self._check_disk) 

258 

259 self._lint_timer = QTimer(self) 

260 self._lint_timer.setSingleShot(True) 

261 self._lint_timer.setInterval(LINT_DELAY_MS) 

262 self._lint_timer.timeout.connect(self.lint) 

263 self._changes_timer = QTimer(self) # the gutter's change bars, once typing pauses 

264 self._changes_timer.setSingleShot(True) 

265 self._changes_timer.setInterval(300) 

266 self._changes_timer.timeout.connect(self.update_change_marks) 

267 self._lsp_timer = QTimer(self) 

268 self._lsp_timer.setSingleShot(True) 

269 self._lsp_timer.setInterval(LSP_SYNC_MS) 

270 self._lsp_timer.timeout.connect(self._lsp_sync) 

271 self.editor.definition_requested.connect(self.go_to_definition) 

272 self.editor.implementation_requested.connect(self.go_to_implementation) 

273 self.editor.references_requested.connect(self.find_references) 

274 self.editor.rename_requested.connect(self.rename_symbol) 

275 self.editor.lint_fix_requested.connect(self.apply_lint_fix) 

276 self.editor.explain_requested.connect(self._explain_line) 

277 self._complete_timer = QTimer(self) 

278 self._complete_timer.setSingleShot(True) 

279 self._complete_timer.setInterval(COMPLETE_MS) 

280 self._complete_timer.timeout.connect(lambda: self.request_completion(manual=False)) 

281 self._signature_timer = QTimer(self) 

282 self._signature_timer.setSingleShot(True) 

283 self._signature_timer.setInterval(COMPLETE_MS) 

284 self._signature_timer.timeout.connect(self.request_signature) 

285 self.editor.typed.connect(self._typed) 

286 self.editor.completion_chosen.connect(self.accept_completion) 

287 self.editor.completion_popup.accepted.connect(self.accept_completion) 

288 self.editor.cursorPositionChanged.connect(self._cursor_moved_popups) 

289 

290 self.editor.document().modificationChanged.connect(self.dirty_changed) 

291 self.editor.cursorPositionChanged.connect(self.update_status) 

292 self.editor.cursors_changed.connect(self.update_status) 

293 self.load() 

294 self.refresh_style() 

295 self.editor.textChanged.connect(self._lint_timer.start) 

296 self.editor.textChanged.connect(self._changes_timer.start) 

297 self._changes_timer.timeout.connect(self.check_gremlins) # (the same typing pause) 

298 self.editor.textChanged.connect(lambda: self.lsp is not None and self._lsp_timer.start()) 

299 self.editor.cursorPositionChanged.connect(self._outline_follow_cursor) 

300 self.check_gremlins() 

301 self.lint() 

302 

303 def close_down(self): 

304 """Before the tab is closed: stop background work.""" 

305 self.stop_lint() 

306 self.detach_lsp() 

307 self.highlighter.close() # (a big file may be being lexed on a thread) 

308 

309 def update_change_marks(self): 

310 """The gutter's change bars: lines changed and not saved yet, and 

311 lines changed since the file was opened (and saved).""" 

312 self._changes_timer.stop() 

313 marks, deletions = line_changes(getattr(self, "_opened_text", self._disk_text), self._disk_text, 

314 self.editor.toPlainText()) 

315 self.editor.set_change_marks(marks, deletions) 

316 

317 # ------------------------------------------------------------- files 

318 @staticmethod 

319 def is_probably_text(path: Path) -> bool: 

320 try: 

321 with open(path, "rb") as f: 

322 return b"\0" not in f.read(8192) 

323 except OSError: 

324 return False 

325 

326 def _read(self) -> tuple[str, str, str, bool]: 

327 """(text with "\\n" line ends, encoding, line end, whether the line 

328 ends are mixed) of the file on disk (see line_endings).""" 

329 return read_source(self.path) 

330 

331 def load(self): 

332 if self.untitled: # (nothing on disk yet: empty, saved as UTF-8 with this platform's line ends) 

333 text, self.encoding, self.newline, self.mixed_newlines = "", "utf-8", os.linesep, False 

334 else: 

335 text, self.encoding, self.newline, self.mixed_newlines = self._read() 

336 self._disk_newline = self.newline # (a converted file is unsaved: see is_dirty) 

337 self._disk_mixed = self.mixed_newlines 

338 self._disk_text = text 

339 self._opened_text = text # (the change bars compare with this and the saved text) 

340 self.editor.setPlainText(text) 

341 self.editor.document().setModified(False) 

342 self._watch() 

343 self.update_status() 

344 if getattr(self, "blame_column", None) is not None: # (not when first loaded: see __init__) 344 ↛ exitline 344 didn't return from function 'load' because the condition on line 344 was always true

345 self.refresh_blame() 

346 

347 def _watch(self): 

348 if self.watcher.files(): 

349 self.watcher.removePaths(self.watcher.files()) 

350 if self.path.exists(): 

351 self.watcher.addPath(str(self.path)) 

352 

353 def is_dirty(self) -> bool: 

354 """Unsaved changes: to the text, or to its line endings (Convert Line Endings).""" 

355 return (self.editor.document().isModified() or self.newline != self._disk_newline 

356 or self._disk_mixed != self.mixed_newlines) 

357 

358 def convert_line_endings(self, newline: str) -> bool: 

359 """Edit → Convert Line Endings: the file is saved with ``newline`` 

360 ("\\n" Unix, "\\r\\n" Windows) at every line end (a file with mixed 

361 ones, or old Mac ones, which Qt reads as line breaks, gets the same 

362 everywhere). False if it already has them.""" 

363 if newline == self.newline and not self.mixed_newlines: 

364 return False 

365 self.newline = newline 

366 self.mixed_newlines = False # (all the same from the next save) 

367 self.dirty_changed.emit(self.is_dirty()) 

368 self.update_status() 

369 return True 

370 

371 def trim_trailing_whitespace(self) -> int: 

372 """Edit → Trim Trailing Whitespace: the spaces and tabs at the ends of 

373 the lines removed (one step to undo); how many lines had them. The 

374 lines are found in the text at once (going through a big file's 

375 every line took a tenth of a second at every save).""" 

376 text = self.editor.toPlainText() 

377 lines, line, at = [], 0, 0 

378 for match in TRAILING_SPACE_RE.finditer(text): 

379 line += text.count("\n", at, match.start()) 

380 at = match.start() 

381 lines.append(line) 

382 if not lines: 

383 return 0 

384 document = self.editor.document() 

385 cursor = QTextCursor(document) 

386 cursor.beginEditBlock() 

387 for number in lines: 

388 block = document.findBlockByNumber(number) 

389 text = block.text() 

390 # (Qt's positions count a character beyond U+FFFF as two.) 

391 cursor.setPosition(block.position() + _qt_length(text.rstrip())) 

392 cursor.setPosition(block.position() + _qt_length(text), QTextCursor.KeepAnchor) 

393 cursor.removeSelectedText() 

394 cursor.endEditBlock() 

395 return len(lines) 

396 

397 def save(self) -> bool: 

398 if self.untitled: 

399 return self.save_as() # (a name first) 

400 if self.trim_on_save and self.path.suffix.lower() not in TRIM_KEEPS: 

401 self.trim_trailing_whitespace() 

402 text = self.editor.toPlainText() 

403 try: 

404 data = text.replace("\n", self.newline).encode(self.encoding) 

405 except UnicodeEncodeError: 

406 data = text.replace("\n", self.newline).encode("utf-8") 

407 self.encoding = "utf-8" 

408 try: 

409 self.path.write_bytes(data) 

410 except OSError as exc: 

411 QMessageBox.warning(self, "Save failed", f"Could not save {self.path}:\n{exc}") 

412 return False 

413 self._disk_text = text 

414 self._disk_newline = self.newline 

415 self.mixed_newlines = self._disk_mixed = False # (saved: every line ends the same way now) 

416 self._disk_change = None # (the save replaced any other version on disk) 

417 self.disk_bar.hide() 

418 self.editor.document().setModified(False) 

419 self.dirty_changed.emit(False) # (also when only the line endings had changed) 

420 # Line numbers in the compiler/review notes may no longer be right. 

421 self._compiler_markers, self._review_markers = {}, {} 

422 self.editor.set_markers({}) 

423 self._watch() 

424 if self.lsp is not None: 424 ↛ 425line 424 didn't jump to line 425 because the condition on line 424 was never true

425 self._flush_lsp() 

426 self.lsp.save_document(self.path) 

427 self.update_change_marks() # unsaved bars become saved ones 

428 self.refresh_blame() 

429 self.saved.emit(str(self.path)) 

430 return True 

431 

432 def save_as(self) -> bool: 

433 filename, _ = QFileDialog.getSaveFileName(self, "Save As", str(self.path)) 

434 if not filename: 

435 return False 

436 client = self.lsp 

437 self.detach_lsp() # (under the old name) 

438 self.untitled = False 

439 new = not Path(filename).exists() 

440 self._set_path(filename) # (its type known now: colors, comments…) 

441 saved = self.save() 

442 if saved and new: 442 ↛ 444line 442 didn't jump to line 444 because the condition on line 442 was always true

443 self.created.emit(str(self.path)) 

444 self.lint() # it may (no longer) be a Fortran file 

445 self.attach_lsp(client) 

446 return saved 

447 

448 def renamed(self, path: str | Path): 

449 """The file was renamed or moved (on disk): the tab follows it, 

450 keeping any unsaved changes.""" 

451 client = self.lsp 

452 self.detach_lsp() # (under the old name) 

453 self._set_path(path) 

454 self._watch() 

455 self.lint() # it may (no longer) be a Fortran file 

456 self.attach_lsp(client) 

457 self.update_status() 

458 

459 def _set_path(self, path: str | Path): 

460 self.path = Path(path).resolve() 

461 self.highlighter.set_lexer(lexer_for_path(self.path)) 

462 self.editor.comment_prefix, self.editor.fixed_form = comment_style(self.path) 

463 self.path_changed.emit(str(self.path)) 

464 

465 def maybe_save(self) -> bool: 

466 """Ask about unsaved changes; True if it's fine to close.""" 

467 if not self.is_dirty(): 

468 return True 

469 answer = QMessageBox.question( 

470 self, "Unsaved changes", f"Save changes to {self.path.name}?", 

471 QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel, QMessageBox.Save) 

472 if answer == QMessageBox.Save: 472 ↛ 473line 472 didn't jump to line 473 because the condition on line 472 was never true

473 return self.save() 

474 return answer == QMessageBox.Discard 

475 

476 def display_path(self) -> str: 

477 """Where the file is, as told to the user (a tab's tooltip).""" 

478 return f"{self.path.name} (not saved yet)" if self.untitled else str(self.path) 

479 

480 def _file_changed(self, _path: str): 

481 self._check_timer.start() 

482 

483 def _check_disk(self): 

484 """The file changed on disk (another editor, git, ...).""" 

485 self._watch() # some tools replace the file, which drops the watch 

486 if not self.path.exists(): 

487 self.update_status("deleted on disk") 

488 return 

489 try: 

490 text, encoding, newline, mixed = self._read() 

491 except OSError: 

492 return 

493 if text == self._disk_text: 

494 return # e.g. our own save 

495 if self.is_dirty(): 

496 # Unsaved edits: ask in the tab (see disk_bar), not with a dialog. 

497 self._disk_change = (text, encoding, newline, mixed) 

498 self.disk_label.setText(f"<b>{html.escape(self.path.name)}</b> was changed by another program, " 

499 "and you have unsaved edits.") 

500 self.disk_bar.show() 

501 return 

502 self._load_from_disk(text, encoding, newline, mixed) 

503 

504 def reload_from_disk(self): 

505 """The disk bar's Reload: the file as it is on disk, in place of the 

506 unsaved edits (one Undo step brings them back).""" 

507 change, self._disk_change = self._disk_change, None 

508 self.disk_bar.hide() 

509 if change is not None: 509 ↛ exitline 509 didn't return from function 'reload_from_disk' because the condition on line 509 was always true

510 self._load_from_disk(*change, undoable=True) 

511 

512 def keep_my_edits(self): 

513 """The disk bar's Keep Mine: the edits stay (saving replaces the file); 

514 this version on disk isn't asked about again.""" 

515 change, self._disk_change = self._disk_change, None 

516 self.disk_bar.hide() 

517 if change is not None: 517 ↛ exitline 517 didn't return from function 'keep_my_edits' because the condition on line 517 was always true

518 self._disk_text, _encoding, self._disk_newline, self._disk_mixed = change 

519 

520 def _load_from_disk(self, text: str, encoding: str, newline: str, mixed: bool, undoable: bool = False): 

521 cursor = self.editor.textCursor().position() 

522 scroll = self.editor.verticalScrollBar().value() 

523 self._disk_text = text 

524 self.encoding, self.newline, self._disk_newline = encoding, newline, newline 

525 self.mixed_newlines = self._disk_mixed = mixed 

526 if undoable: # (replacing the edits: one Undo step, so they can come back) 

527 whole = QTextCursor(self.editor.document()) 

528 whole.select(QTextCursor.Document) 

529 whole.insertText(text) 

530 else: 

531 self.editor.setPlainText(text) 

532 self.editor.document().setModified(False) 

533 self.dirty_changed.emit(False) 

534 c = self.editor.textCursor() 

535 c.setPosition(min(cursor, len(text))) 

536 self.editor.setTextCursor(c) 

537 self.editor.verticalScrollBar().setValue(scroll) 

538 

539 # ----------------------------------------------------------- display 

540 def set_font(self, font: QFont): 

541 self.editor.setFont(font) 

542 self.editor.set_indentation(self.editor.indent_width, self.editor.use_tabs) # (tab width in the new font) 

543 self.editor.update_gutter_width() 

544 self.blame_column.refresh_font() 

545 

546 # ----------------------------------------------------------- git blame 

547 def set_blame_enabled(self, on: bool): 

548 """View → Show Git Blame, for this tab.""" 

549 self.blame_column.setVisible(on) 

550 if on: 

551 self.refresh_blame() 

552 

553 def refresh_blame(self): 

554 """Read the blame of the text as it is now (off the window's thread).""" 

555 self._blame_timer.stop() 

556 if not self.blame_enabled: 

557 return 

558 if self.untitled: 558 ↛ 559line 558 didn't jump to line 559 because the condition on line 558 was never true

559 self.blame_column.set_blame(None, "Not saved yet: no history.") 

560 return 

561 self._blame_request += 1 

562 request, path = self._blame_request, self.path 

563 # (As saving would write it: its line endings and encoding. Blamed with "\n" ends, a file 

564 # committed with "\r\n" ones differed on every line: all "not committed yet".) 

565 text = self.editor.toPlainText().replace("\n", self.newline).encode(self.encoding, "replace") 

566 

567 def work(): 

568 if not gs.available(): 568 ↛ 569line 568 didn't jump to line 569 because the condition on line 568 was never true

569 return None, "Git blame needs git (https://git-scm.com), on the PATH." 

570 try: 

571 found = git_blame.blame(path, text) 

572 except gs.GitError as error: 

573 return None, f"git blame: {error}" 

574 return found, "" if found is not None else "Not in git: no history yet." 

575 

576 def done(result): 

577 if request == self._blame_request: 577 ↛ exitline 577 didn't return from function 'done' because the condition on line 577 was always true

578 self.blame_column.set_blame(*result) 

579 

580 call_in_background(work, done, self) 

581 

582 def refresh_style(self): 

583 """Apply the syntax colors (View → Syntax Colors, or automatic).""" 

584 apply_style(self.editor, self.highlighter) 

585 self.editor.update_extra_selections() 

586 self.editor.gutter.update() 

587 if getattr(self, "minimap", None) is not None: 587 ↛ exitline 587 didn't return from function 'refresh_style' because the condition on line 587 was always true

588 self.minimap.invalidate() 

589 

590 def update_status(self, note: str = ""): 

591 cursor = self.editor.textCursor() 

592 indent = f"Tabs: {self.editor.indent_width}" if self.editor.use_tabs else f"Spaces: {self.editor.indent_width}" 

593 parts = [f"Ln {cursor.blockNumber() + 1}, Col {cursor.positionInBlock() + 1}", 

594 indent, self.highlighter.lexer.name, "UTF-8 with BOM" if self.encoding == "utf-8-sig" else self.encoding.upper(), 

595 ("CRLF" if self.newline == "\r\n" else "LF") + (" (mixed)" if self.mixed_newlines else "")] 

596 parts += self._issue_counts() 

597 if self.editor.extra_cursors: 

598 parts.insert(1, f"{len(self.editor.extra_cursors) + 1} cursors (Esc: back to one)") 

599 if note: 

600 parts.append(note) 

601 self.status.setText("<span style='color:gray'>" + " · ".join(parts) + "</span>") 

602 kind = "CRLF" if self.newline == "\r\n" else "LF" 

603 self.status.setToolTip( 

604 f"This file has more than one kind of line ending: saving makes them all {kind}, the most " 

605 "common one (Edit → Convert Line Endings to choose)" if self.mixed_newlines else "") 

606 

607 def _issue_counts(self) -> list[str]: 

608 """The status line's counts ("12 fortitude issues"…). Counted again 

609 only when what's counted changed (each is replaced, not changed in 

610 place, when it does): the status line follows the cursor, and a big 

611 file's thousands of issues were counted at every step.""" 

612 counted = (self._compiler_markers, self._review_markers, self._lint_markers, self._lsp_markers, 

613 self._spelling_issues, self.editor.gremlins) 

614 cached = getattr(self, "_counts", None) 

615 if cached is not None and all(a is b for a, b in zip(cached[0], counted, strict=True)): 

616 return cached[1] 

617 parts = [] 

618 if self._compiler_markers: 

619 notes = [s for ns in self._compiler_markers.values() for s, _ in ns] 

620 errors = sum(1 for s in notes if s in ("error", "fatal error")) 

621 warnings = sum(1 for s in notes if s == "warning") 

622 parts.append(f"{errors} errors, {warnings} warnings from the last build") 

623 for markers, what in ((self._review_markers, "AI review note"), (self._lint_markers, "fortitude issue"), 

624 (self._lsp_markers, "fortls issue")): 

625 if markers: 

626 count = sum(len(ns) for ns in markers.values()) 

627 parts.append(f"{count} {what}{'s' * (count != 1)}") 

628 if self._spelling_issues: 

629 count = len(self._spelling_issues) 

630 parts.append(f"{count} misspelled word{'s' * (count != 1)}") 

631 if self.editor.gremlins: 

632 count = sum(len(g) for g in self.editor.gremlins.values()) 

633 parts.append(f"{count} gremlin{'s' * (count != 1)}") 

634 self._counts = (counted, parts) 

635 return parts 

636 

637 def replace_text(self, text: str): 

638 """Replace the whole text as one undoable edit, keeping the cursor 

639 on the same line and column and the view where it was.""" 

640 cursor = self.editor.textCursor() 

641 line, column = cursor.blockNumber(), cursor.positionInBlock() 

642 scroll = self.editor.verticalScrollBar().value() 

643 cursor.beginEditBlock() 

644 cursor.select(QTextCursor.Document) 

645 cursor.insertText(text) 

646 cursor.endEditBlock() 

647 block = self.editor.document().findBlockByNumber(min(line, self.editor.blockCount() - 1)) 

648 cursor = QTextCursor(block) 

649 cursor.setPosition(block.position() + min(column, max(0, block.length() - 1))) 

650 self.editor.setTextCursor(cursor) 

651 self.editor.verticalScrollBar().setValue(scroll) 

652 

653 def go_to_line_dialog(self): 

654 line, ok = QInputDialog.getInt( 

655 self, "Go to Line", f"Line (1–{self.editor.blockCount()}):", 

656 self.editor.textCursor().blockNumber() + 1, 1, self.editor.blockCount()) 

657 if ok: 657 ↛ exitline 657 didn't return from function 'go_to_line_dialog' because the condition on line 657 was always true

658 self.editor.go_to_line(line) 

659 

660 

661# View → Editor Font (set by the main window): a family and a size in points 

662# at 100% text size; empty / 0 use the system's fixed-width font. 

663editor_font_family = "" 

664editor_font_size = 0.0 

665 

666 

667def monospace_font(size_factor: float = 1.0, editor: bool = False) -> QFont: 

668 """The fixed-width font, scaled by ``size_factor`` (View → text size); 

669 with ``editor``, the one chosen in View → Editor Font.""" 

670 font = QFontDatabase.systemFont(QFontDatabase.FixedFont) 

671 if editor and editor_font_family: 

672 font.setFamily(editor_font_family) 

673 font.setStyleHint(QFont.Monospace) 

674 if editor and editor_font_size > 0: 

675 font.setPointSizeF(editor_font_size) 

676 font.setPointSizeF(font.pointSizeF() * size_factor) 

677 return font 

678