vibefpmgui.editor
A small built-in code editor: line numbers, syntax highlighting (Pygments,
chosen by file extension), find/replace, go to line, and compiler warning /
error markers in the gutter, fortitude lint findings (see vibefpmgui.lint), and
for Fortran files the language server's hover, go to definition and outline
(see vibefpmgui.lsp).
EditorTab wraps a CodeEditor (vibefpmgui.code_editor) with a find bar
(vibefpmgui.find_bar), the outline and a status line, and takes care of
loading and saving the file (keeping its encoding and line endings) and of
noticing when it changes on disk. Its language-server features are in
vibefpmgui.editor_language, its checks (fortitude, spelling, gremlins, the
compiler's and the AI's markers) in vibefpmgui.editor_checks.
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__) 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: 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: 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: 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: 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: 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: 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(): 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: 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: 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: 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
112class EditorTab(EditorLanguageMixin, EditorChecksMixin, QWidget): 113 """One open file: editor + find bar + status line.""" 114 115 lint_enabled = True # lint Fortran files with fortitude (View menu) 116 lsp_diagnostics_enabled = True # show fortls's own checks (View menu) 117 spelling_enabled = True # spell check comments and strings (View menu) 118 gremlins_enabled = True # View → Show Gremlins (non-ASCII and invisible characters) 119 minimap_enabled = True # View → Show Minimap 120 blame_enabled = False # View → Show Git Blame (see vibefpmgui.git_blame) 121 word_wrap = False # View → Word Wrap 122 trim_on_save = True # Edit → Trim Trailing Whitespace on Save 123 124 dirty_changed = Signal(bool) 125 saved = Signal(str) 126 created = Signal(str) # Save As made a file that wasn't there (a new file's, or another name's) 127 path_changed = Signal(str) 128 navigate_requested = Signal(str, int, int) # go to file, line (1-based), column (0-based) 129 references_found = Signal(str, object) # name, [(file, line, start column, end column)] 130 rename_ready = Signal(str, str, object) # old name, new name, {file: [lsp.TextEdit]} 131 problems_changed = Signal() # (see problems) 132 explain_requested = Signal(object) # a Problem to Explain with AI (from the editor's menu) 133 134 def __init__(self, path: Path, parent=None, untitled: bool = False): 135 """``untitled``: a new file, not saved yet (File → New File): ``path`` 136 is only its name for now (no such file); it has no file type, so no 137 colors or checks, until Save As gives it a name.""" 138 super().__init__(parent) 139 self.path = Path(path).resolve() 140 self.untitled = untitled 141 self.encoding = "utf-8" 142 self.newline = "\n" 143 self._disk_newline = "\n" # the line end of the file as saved (see is_dirty) 144 self.mixed_newlines = False # the file has both LF and CRLF (or old Mac CR) line ends (see line_endings) 145 self._disk_mixed = False 146 self._disk_text = "" 147 self._compiler_markers: dict[int, list[tuple[str, str]]] = {} 148 self._review_markers: dict[int, list[tuple[str, str]]] = {} 149 self._lint_markers: dict[int, list[tuple[str, str]]] = {} 150 self._lsp_markers: dict[int, list[tuple[str, str]]] = {} 151 self._fortitude_issues: list = [] # (the editor underlines these and fortls's together) 152 self._lsp_issues: list = [] 153 self._spelling_issues: list = [] 154 self._lint_process: QProcess | None = None 155 self._lint_again = False # the text changed while fortitude was running 156 self.rewriting = False # Format or Fix All is running on this text (see MainWindow._rewrite_text) 157 self.lsp: lsp_mod.LspClient | None = None # the language server, for Fortran files 158 self._outline: list[tuple[int, str, str, int, int]] = [] 159 self.editor = CodeEditor() 160 self.highlighter = PygmentsHighlighter(self.editor.document(), lexer_for_path(self.path)) 161 self.editor.comment_prefix, self.editor.fixed_form = comment_style(self.path) # (Toggle Line Comment) 162 self.highlighter.line_on_view = lambda: self.editor.firstVisibleBlock().blockNumber() # (colored first) 163 self.find_bar = FindBar(self.editor) 164 # A change to the text: the last test run's coverage no longer fits its lines. 165 self.editor.document().contentsChange.connect( 166 lambda _pos, removed, added: (removed or added) and self.editor.coverage and self.editor.set_coverage(None)) 167 # The selection's other occurrences: matched as the find bar is set. 168 self.editor.match_options = lambda: (self.find_bar.case_check.isChecked(), 169 self.find_bar.word_check.isChecked()) 170 self.find_bar.case_check.toggled.connect(self.editor.update_occurrences) 171 self.find_bar.word_check.toggled.connect(self.editor.update_occurrences) 172 self.status = StatusLabel() 173 self.status.setContentsMargins(8, 2, 8, 2) 174 175 # Outline of the file (modules, types, procedures), from the language server. 176 self.outline_box = QComboBox() 177 self.outline_box.setMinimumContentsLength(30) 178 self.outline_box.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon) 179 self.outline_box.setToolTip("Outline: jump to a module, type or procedure in this file") 180 self.outline_box.setIconSize(QSize(16, 16)) 181 self.outline_box.setItemDelegate(_OutlineDelegate(self.outline_box)) 182 self.outline_box.activated.connect(self._outline_chosen) 183 self.outline_bar = QWidget() 184 bar = QHBoxLayout(self.outline_bar) 185 bar.setContentsMargins(6, 3, 6, 3) 186 bar.addWidget(self.outline_box) 187 bar.addStretch(1) 188 self.outline_bar.hide() 189 190 layout = QVBoxLayout(self) 191 layout.setContentsMargins(0, 0, 0, 0) 192 layout.setSpacing(0) 193 layout.addWidget(self.outline_bar) 194 # Shown when the file changes on disk while it has unsaved edits: the 195 # choice waits here, in the tab (a dialog from a timer could open on 196 # top of another one, e.g. Save As). 197 self._disk_change: tuple[str, str, str] | None = None # (text, encoding, line end) on disk, waiting 198 self.disk_bar = QFrame() 199 self.disk_bar.setObjectName("DiskBar") 200 self.disk_bar.setStyleSheet("#DiskBar { background: rgba(240, 140, 0, 60); border-bottom: 1px solid " 201 "rgba(240, 140, 0, 160); }") 202 self.disk_label = QLabel() 203 self.disk_label.setWordWrap(True) 204 reload_button = QPushButton("Reload") 205 reload_button.setToolTip("Show the file as it is on disk (your unsaved edits are lost; Undo brings them back)") 206 reload_button.clicked.connect(self.reload_from_disk) 207 keep_button = QPushButton("Keep Mine") 208 keep_button.setToolTip("Keep your edits; saving them replaces the file on disk") 209 keep_button.clicked.connect(self.keep_my_edits) 210 for button in (reload_button, keep_button): 211 button.setAttribute(Qt.WA_MacSmallSize) 212 disk_row = QHBoxLayout(self.disk_bar) 213 disk_row.setContentsMargins(8, 4, 8, 4) 214 disk_row.addWidget(self.disk_label, 1) 215 disk_row.addWidget(reload_button) 216 disk_row.addWidget(keep_button) 217 self.disk_bar.hide() 218 layout.addWidget(self.disk_bar) 219 # The text, with the minimap at its right. 220 # (A child of the tab from the start: shown without a parent, even 221 # until the layout below takes it, it was a window of its own for a 222 # moment, which Windows showed as a window flashing up.) 223 self.minimap = Minimap(self.editor, self) 224 self.editor.minimap = self.minimap 225 self.minimap.setVisible(self.minimap_enabled) 226 self.editor.set_word_wrap(self.word_wrap) 227 self.highlighter.relexed.connect(self.minimap.invalidate) 228 # Git blame, left of the minimap (View → Show Git Blame): read again once typing pauses. 229 self.blame_column = BlameColumn(self.editor, self) 230 self.blame_column.setVisible(self.blame_enabled) 231 self._blame_request = 0 # (an answer to an older request is dropped) 232 self._blame_timer = QTimer(self) 233 self._blame_timer.setSingleShot(True) 234 self._blame_timer.setInterval(BLAME_DELAY_MS) 235 self._blame_timer.timeout.connect(self.refresh_blame) 236 self.editor.document().contentsChanged.connect( 237 lambda: self.blame_enabled and self._blame_timer.start()) 238 self._spelling_timer = QTimer(self) # (once the colors, so the comments and strings, are updated) 239 self._spelling_timer.setSingleShot(True) 240 self._spelling_timer.setInterval(SPELLING_DELAY_MS) 241 self._spelling_timer.timeout.connect(self.check_spelling) 242 self.highlighter.relexed.connect(self._spelling_timer.start) 243 body = QHBoxLayout() 244 body.setContentsMargins(0, 0, 0, 0) 245 body.setSpacing(0) 246 body.addWidget(self.editor, 1) 247 body.addWidget(self.blame_column) 248 body.addWidget(self.minimap) 249 layout.addLayout(body, 1) 250 layout.addWidget(self.find_bar) 251 layout.addWidget(self.status) 252 253 self.watcher = QFileSystemWatcher(self) 254 self.watcher.fileChanged.connect(self._file_changed) 255 self._check_timer = QTimer(self) # coalesce bursts of change events 256 self._check_timer.setSingleShot(True) 257 self._check_timer.setInterval(200) 258 self._check_timer.timeout.connect(self._check_disk) 259 260 self._lint_timer = QTimer(self) 261 self._lint_timer.setSingleShot(True) 262 self._lint_timer.setInterval(LINT_DELAY_MS) 263 self._lint_timer.timeout.connect(self.lint) 264 self._changes_timer = QTimer(self) # the gutter's change bars, once typing pauses 265 self._changes_timer.setSingleShot(True) 266 self._changes_timer.setInterval(300) 267 self._changes_timer.timeout.connect(self.update_change_marks) 268 self._lsp_timer = QTimer(self) 269 self._lsp_timer.setSingleShot(True) 270 self._lsp_timer.setInterval(LSP_SYNC_MS) 271 self._lsp_timer.timeout.connect(self._lsp_sync) 272 self.editor.definition_requested.connect(self.go_to_definition) 273 self.editor.implementation_requested.connect(self.go_to_implementation) 274 self.editor.references_requested.connect(self.find_references) 275 self.editor.rename_requested.connect(self.rename_symbol) 276 self.editor.lint_fix_requested.connect(self.apply_lint_fix) 277 self.editor.explain_requested.connect(self._explain_line) 278 self._complete_timer = QTimer(self) 279 self._complete_timer.setSingleShot(True) 280 self._complete_timer.setInterval(COMPLETE_MS) 281 self._complete_timer.timeout.connect(lambda: self.request_completion(manual=False)) 282 self._signature_timer = QTimer(self) 283 self._signature_timer.setSingleShot(True) 284 self._signature_timer.setInterval(COMPLETE_MS) 285 self._signature_timer.timeout.connect(self.request_signature) 286 self.editor.typed.connect(self._typed) 287 self.editor.completion_chosen.connect(self.accept_completion) 288 self.editor.completion_popup.accepted.connect(self.accept_completion) 289 self.editor.cursorPositionChanged.connect(self._cursor_moved_popups) 290 291 self.editor.document().modificationChanged.connect(self.dirty_changed) 292 self.editor.cursorPositionChanged.connect(self.update_status) 293 self.editor.cursors_changed.connect(self.update_status) 294 self.load() 295 self.refresh_style() 296 self.editor.textChanged.connect(self._lint_timer.start) 297 self.editor.textChanged.connect(self._changes_timer.start) 298 self._changes_timer.timeout.connect(self.check_gremlins) # (the same typing pause) 299 self.editor.textChanged.connect(lambda: self.lsp is not None and self._lsp_timer.start()) 300 self.editor.cursorPositionChanged.connect(self._outline_follow_cursor) 301 self.check_gremlins() 302 self.lint() 303 304 def close_down(self): 305 """Before the tab is closed: stop background work.""" 306 self.stop_lint() 307 self.detach_lsp() 308 self.highlighter.close() # (a big file may be being lexed on a thread) 309 310 def update_change_marks(self): 311 """The gutter's change bars: lines changed and not saved yet, and 312 lines changed since the file was opened (and saved).""" 313 self._changes_timer.stop() 314 marks, deletions = line_changes(getattr(self, "_opened_text", self._disk_text), self._disk_text, 315 self.editor.toPlainText()) 316 self.editor.set_change_marks(marks, deletions) 317 318 # ------------------------------------------------------------- files 319 @staticmethod 320 def is_probably_text(path: Path) -> bool: 321 try: 322 with open(path, "rb") as f: 323 return b"\0" not in f.read(8192) 324 except OSError: 325 return False 326 327 def _read(self) -> tuple[str, str, str, bool]: 328 """(text with "\\n" line ends, encoding, line end, whether the line 329 ends are mixed) of the file on disk (see line_endings).""" 330 return read_source(self.path) 331 332 def load(self): 333 if self.untitled: # (nothing on disk yet: empty, saved as UTF-8 with this platform's line ends) 334 text, self.encoding, self.newline, self.mixed_newlines = "", "utf-8", os.linesep, False 335 else: 336 text, self.encoding, self.newline, self.mixed_newlines = self._read() 337 self._disk_newline = self.newline # (a converted file is unsaved: see is_dirty) 338 self._disk_mixed = self.mixed_newlines 339 self._disk_text = text 340 self._opened_text = text # (the change bars compare with this and the saved text) 341 self.editor.setPlainText(text) 342 self.editor.document().setModified(False) 343 self._watch() 344 self.update_status() 345 if getattr(self, "blame_column", None) is not None: # (not when first loaded: see __init__) 346 self.refresh_blame() 347 348 def _watch(self): 349 if self.watcher.files(): 350 self.watcher.removePaths(self.watcher.files()) 351 if self.path.exists(): 352 self.watcher.addPath(str(self.path)) 353 354 def is_dirty(self) -> bool: 355 """Unsaved changes: to the text, or to its line endings (Convert Line Endings).""" 356 return (self.editor.document().isModified() or self.newline != self._disk_newline 357 or self._disk_mixed != self.mixed_newlines) 358 359 def convert_line_endings(self, newline: str) -> bool: 360 """Edit → Convert Line Endings: the file is saved with ``newline`` 361 ("\\n" Unix, "\\r\\n" Windows) at every line end (a file with mixed 362 ones, or old Mac ones, which Qt reads as line breaks, gets the same 363 everywhere). False if it already has them.""" 364 if newline == self.newline and not self.mixed_newlines: 365 return False 366 self.newline = newline 367 self.mixed_newlines = False # (all the same from the next save) 368 self.dirty_changed.emit(self.is_dirty()) 369 self.update_status() 370 return True 371 372 def trim_trailing_whitespace(self) -> int: 373 """Edit → Trim Trailing Whitespace: the spaces and tabs at the ends of 374 the lines removed (one step to undo); how many lines had them. The 375 lines are found in the text at once (going through a big file's 376 every line took a tenth of a second at every save).""" 377 text = self.editor.toPlainText() 378 lines, line, at = [], 0, 0 379 for match in TRAILING_SPACE_RE.finditer(text): 380 line += text.count("\n", at, match.start()) 381 at = match.start() 382 lines.append(line) 383 if not lines: 384 return 0 385 document = self.editor.document() 386 cursor = QTextCursor(document) 387 cursor.beginEditBlock() 388 for number in lines: 389 block = document.findBlockByNumber(number) 390 text = block.text() 391 # (Qt's positions count a character beyond U+FFFF as two.) 392 cursor.setPosition(block.position() + _qt_length(text.rstrip())) 393 cursor.setPosition(block.position() + _qt_length(text), QTextCursor.KeepAnchor) 394 cursor.removeSelectedText() 395 cursor.endEditBlock() 396 return len(lines) 397 398 def save(self) -> bool: 399 if self.untitled: 400 return self.save_as() # (a name first) 401 if self.trim_on_save and self.path.suffix.lower() not in TRIM_KEEPS: 402 self.trim_trailing_whitespace() 403 text = self.editor.toPlainText() 404 try: 405 data = text.replace("\n", self.newline).encode(self.encoding) 406 except UnicodeEncodeError: 407 data = text.replace("\n", self.newline).encode("utf-8") 408 self.encoding = "utf-8" 409 try: 410 self.path.write_bytes(data) 411 except OSError as exc: 412 QMessageBox.warning(self, "Save failed", f"Could not save {self.path}:\n{exc}") 413 return False 414 self._disk_text = text 415 self._disk_newline = self.newline 416 self.mixed_newlines = self._disk_mixed = False # (saved: every line ends the same way now) 417 self._disk_change = None # (the save replaced any other version on disk) 418 self.disk_bar.hide() 419 self.editor.document().setModified(False) 420 self.dirty_changed.emit(False) # (also when only the line endings had changed) 421 # Line numbers in the compiler/review notes may no longer be right. 422 self._compiler_markers, self._review_markers = {}, {} 423 self.editor.set_markers({}) 424 self._watch() 425 if self.lsp is not None: 426 self._flush_lsp() 427 self.lsp.save_document(self.path) 428 self.update_change_marks() # unsaved bars become saved ones 429 self.refresh_blame() 430 self.saved.emit(str(self.path)) 431 return True 432 433 def save_as(self) -> bool: 434 filename, _ = QFileDialog.getSaveFileName(self, "Save As", str(self.path)) 435 if not filename: 436 return False 437 client = self.lsp 438 self.detach_lsp() # (under the old name) 439 self.untitled = False 440 new = not Path(filename).exists() 441 self._set_path(filename) # (its type known now: colors, comments…) 442 saved = self.save() 443 if saved and new: 444 self.created.emit(str(self.path)) 445 self.lint() # it may (no longer) be a Fortran file 446 self.attach_lsp(client) 447 return saved 448 449 def renamed(self, path: str | Path): 450 """The file was renamed or moved (on disk): the tab follows it, 451 keeping any unsaved changes.""" 452 client = self.lsp 453 self.detach_lsp() # (under the old name) 454 self._set_path(path) 455 self._watch() 456 self.lint() # it may (no longer) be a Fortran file 457 self.attach_lsp(client) 458 self.update_status() 459 460 def _set_path(self, path: str | Path): 461 self.path = Path(path).resolve() 462 self.highlighter.set_lexer(lexer_for_path(self.path)) 463 self.editor.comment_prefix, self.editor.fixed_form = comment_style(self.path) 464 self.path_changed.emit(str(self.path)) 465 466 def maybe_save(self) -> bool: 467 """Ask about unsaved changes; True if it's fine to close.""" 468 if not self.is_dirty(): 469 return True 470 answer = QMessageBox.question( 471 self, "Unsaved changes", f"Save changes to {self.path.name}?", 472 QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel, QMessageBox.Save) 473 if answer == QMessageBox.Save: 474 return self.save() 475 return answer == QMessageBox.Discard 476 477 def display_path(self) -> str: 478 """Where the file is, as told to the user (a tab's tooltip).""" 479 return f"{self.path.name} (not saved yet)" if self.untitled else str(self.path) 480 481 def _file_changed(self, _path: str): 482 self._check_timer.start() 483 484 def _check_disk(self): 485 """The file changed on disk (another editor, git, ...).""" 486 self._watch() # some tools replace the file, which drops the watch 487 if not self.path.exists(): 488 self.update_status("deleted on disk") 489 return 490 try: 491 text, encoding, newline, mixed = self._read() 492 except OSError: 493 return 494 if text == self._disk_text: 495 return # e.g. our own save 496 if self.is_dirty(): 497 # Unsaved edits: ask in the tab (see disk_bar), not with a dialog. 498 self._disk_change = (text, encoding, newline, mixed) 499 self.disk_label.setText(f"<b>{html.escape(self.path.name)}</b> was changed by another program, " 500 "and you have unsaved edits.") 501 self.disk_bar.show() 502 return 503 self._load_from_disk(text, encoding, newline, mixed) 504 505 def reload_from_disk(self): 506 """The disk bar's Reload: the file as it is on disk, in place of the 507 unsaved edits (one Undo step brings them back).""" 508 change, self._disk_change = self._disk_change, None 509 self.disk_bar.hide() 510 if change is not None: 511 self._load_from_disk(*change, undoable=True) 512 513 def keep_my_edits(self): 514 """The disk bar's Keep Mine: the edits stay (saving replaces the file); 515 this version on disk isn't asked about again.""" 516 change, self._disk_change = self._disk_change, None 517 self.disk_bar.hide() 518 if change is not None: 519 self._disk_text, _encoding, self._disk_newline, self._disk_mixed = change 520 521 def _load_from_disk(self, text: str, encoding: str, newline: str, mixed: bool, undoable: bool = False): 522 cursor = self.editor.textCursor().position() 523 scroll = self.editor.verticalScrollBar().value() 524 self._disk_text = text 525 self.encoding, self.newline, self._disk_newline = encoding, newline, newline 526 self.mixed_newlines = self._disk_mixed = mixed 527 if undoable: # (replacing the edits: one Undo step, so they can come back) 528 whole = QTextCursor(self.editor.document()) 529 whole.select(QTextCursor.Document) 530 whole.insertText(text) 531 else: 532 self.editor.setPlainText(text) 533 self.editor.document().setModified(False) 534 self.dirty_changed.emit(False) 535 c = self.editor.textCursor() 536 c.setPosition(min(cursor, len(text))) 537 self.editor.setTextCursor(c) 538 self.editor.verticalScrollBar().setValue(scroll) 539 540 # ----------------------------------------------------------- display 541 def set_font(self, font: QFont): 542 self.editor.setFont(font) 543 self.editor.set_indentation(self.editor.indent_width, self.editor.use_tabs) # (tab width in the new font) 544 self.editor.update_gutter_width() 545 self.blame_column.refresh_font() 546 547 # ----------------------------------------------------------- git blame 548 def set_blame_enabled(self, on: bool): 549 """View → Show Git Blame, for this tab.""" 550 self.blame_column.setVisible(on) 551 if on: 552 self.refresh_blame() 553 554 def refresh_blame(self): 555 """Read the blame of the text as it is now (off the window's thread).""" 556 self._blame_timer.stop() 557 if not self.blame_enabled: 558 return 559 if self.untitled: 560 self.blame_column.set_blame(None, "Not saved yet: no history.") 561 return 562 self._blame_request += 1 563 request, path = self._blame_request, self.path 564 # (As saving would write it: its line endings and encoding. Blamed with "\n" ends, a file 565 # committed with "\r\n" ones differed on every line: all "not committed yet".) 566 text = self.editor.toPlainText().replace("\n", self.newline).encode(self.encoding, "replace") 567 568 def work(): 569 if not gs.available(): 570 return None, "Git blame needs git (https://git-scm.com), on the PATH." 571 try: 572 found = git_blame.blame(path, text) 573 except gs.GitError as error: 574 return None, f"git blame: {error}" 575 return found, "" if found is not None else "Not in git: no history yet." 576 577 def done(result): 578 if request == self._blame_request: 579 self.blame_column.set_blame(*result) 580 581 call_in_background(work, done, self) 582 583 def refresh_style(self): 584 """Apply the syntax colors (View → Syntax Colors, or automatic).""" 585 apply_style(self.editor, self.highlighter) 586 self.editor.update_extra_selections() 587 self.editor.gutter.update() 588 if getattr(self, "minimap", None) is not None: 589 self.minimap.invalidate() 590 591 def update_status(self, note: str = ""): 592 cursor = self.editor.textCursor() 593 indent = f"Tabs: {self.editor.indent_width}" if self.editor.use_tabs else f"Spaces: {self.editor.indent_width}" 594 parts = [f"Ln {cursor.blockNumber() + 1}, Col {cursor.positionInBlock() + 1}", 595 indent, self.highlighter.lexer.name, "UTF-8 with BOM" if self.encoding == "utf-8-sig" else self.encoding.upper(), 596 ("CRLF" if self.newline == "\r\n" else "LF") + (" (mixed)" if self.mixed_newlines else "")] 597 parts += self._issue_counts() 598 if self.editor.extra_cursors: 599 parts.insert(1, f"{len(self.editor.extra_cursors) + 1} cursors (Esc: back to one)") 600 if note: 601 parts.append(note) 602 self.status.setText("<span style='color:gray'>" + " · ".join(parts) + "</span>") 603 kind = "CRLF" if self.newline == "\r\n" else "LF" 604 self.status.setToolTip( 605 f"This file has more than one kind of line ending: saving makes them all {kind}, the most " 606 "common one (Edit → Convert Line Endings to choose)" if self.mixed_newlines else "") 607 608 def _issue_counts(self) -> list[str]: 609 """The status line's counts ("12 fortitude issues"…). Counted again 610 only when what's counted changed (each is replaced, not changed in 611 place, when it does): the status line follows the cursor, and a big 612 file's thousands of issues were counted at every step.""" 613 counted = (self._compiler_markers, self._review_markers, self._lint_markers, self._lsp_markers, 614 self._spelling_issues, self.editor.gremlins) 615 cached = getattr(self, "_counts", None) 616 if cached is not None and all(a is b for a, b in zip(cached[0], counted, strict=True)): 617 return cached[1] 618 parts = [] 619 if self._compiler_markers: 620 notes = [s for ns in self._compiler_markers.values() for s, _ in ns] 621 errors = sum(1 for s in notes if s in ("error", "fatal error")) 622 warnings = sum(1 for s in notes if s == "warning") 623 parts.append(f"{errors} errors, {warnings} warnings from the last build") 624 for markers, what in ((self._review_markers, "AI review note"), (self._lint_markers, "fortitude issue"), 625 (self._lsp_markers, "fortls issue")): 626 if markers: 627 count = sum(len(ns) for ns in markers.values()) 628 parts.append(f"{count} {what}{'s' * (count != 1)}") 629 if self._spelling_issues: 630 count = len(self._spelling_issues) 631 parts.append(f"{count} misspelled word{'s' * (count != 1)}") 632 if self.editor.gremlins: 633 count = sum(len(g) for g in self.editor.gremlins.values()) 634 parts.append(f"{count} gremlin{'s' * (count != 1)}") 635 self._counts = (counted, parts) 636 return parts 637 638 def replace_text(self, text: str): 639 """Replace the whole text as one undoable edit, keeping the cursor 640 on the same line and column and the view where it was.""" 641 cursor = self.editor.textCursor() 642 line, column = cursor.blockNumber(), cursor.positionInBlock() 643 scroll = self.editor.verticalScrollBar().value() 644 cursor.beginEditBlock() 645 cursor.select(QTextCursor.Document) 646 cursor.insertText(text) 647 cursor.endEditBlock() 648 block = self.editor.document().findBlockByNumber(min(line, self.editor.blockCount() - 1)) 649 cursor = QTextCursor(block) 650 cursor.setPosition(block.position() + min(column, max(0, block.length() - 1))) 651 self.editor.setTextCursor(cursor) 652 self.editor.verticalScrollBar().setValue(scroll) 653 654 def go_to_line_dialog(self): 655 line, ok = QInputDialog.getInt( 656 self, "Go to Line", f"Line (1–{self.editor.blockCount()}):", 657 self.editor.textCursor().blockNumber() + 1, 1, self.editor.blockCount()) 658 if ok: 659 self.editor.go_to_line(line)
One open file: editor + find bar + status line.
134 def __init__(self, path: Path, parent=None, untitled: bool = False): 135 """``untitled``: a new file, not saved yet (File → New File): ``path`` 136 is only its name for now (no such file); it has no file type, so no 137 colors or checks, until Save As gives it a name.""" 138 super().__init__(parent) 139 self.path = Path(path).resolve() 140 self.untitled = untitled 141 self.encoding = "utf-8" 142 self.newline = "\n" 143 self._disk_newline = "\n" # the line end of the file as saved (see is_dirty) 144 self.mixed_newlines = False # the file has both LF and CRLF (or old Mac CR) line ends (see line_endings) 145 self._disk_mixed = False 146 self._disk_text = "" 147 self._compiler_markers: dict[int, list[tuple[str, str]]] = {} 148 self._review_markers: dict[int, list[tuple[str, str]]] = {} 149 self._lint_markers: dict[int, list[tuple[str, str]]] = {} 150 self._lsp_markers: dict[int, list[tuple[str, str]]] = {} 151 self._fortitude_issues: list = [] # (the editor underlines these and fortls's together) 152 self._lsp_issues: list = [] 153 self._spelling_issues: list = [] 154 self._lint_process: QProcess | None = None 155 self._lint_again = False # the text changed while fortitude was running 156 self.rewriting = False # Format or Fix All is running on this text (see MainWindow._rewrite_text) 157 self.lsp: lsp_mod.LspClient | None = None # the language server, for Fortran files 158 self._outline: list[tuple[int, str, str, int, int]] = [] 159 self.editor = CodeEditor() 160 self.highlighter = PygmentsHighlighter(self.editor.document(), lexer_for_path(self.path)) 161 self.editor.comment_prefix, self.editor.fixed_form = comment_style(self.path) # (Toggle Line Comment) 162 self.highlighter.line_on_view = lambda: self.editor.firstVisibleBlock().blockNumber() # (colored first) 163 self.find_bar = FindBar(self.editor) 164 # A change to the text: the last test run's coverage no longer fits its lines. 165 self.editor.document().contentsChange.connect( 166 lambda _pos, removed, added: (removed or added) and self.editor.coverage and self.editor.set_coverage(None)) 167 # The selection's other occurrences: matched as the find bar is set. 168 self.editor.match_options = lambda: (self.find_bar.case_check.isChecked(), 169 self.find_bar.word_check.isChecked()) 170 self.find_bar.case_check.toggled.connect(self.editor.update_occurrences) 171 self.find_bar.word_check.toggled.connect(self.editor.update_occurrences) 172 self.status = StatusLabel() 173 self.status.setContentsMargins(8, 2, 8, 2) 174 175 # Outline of the file (modules, types, procedures), from the language server. 176 self.outline_box = QComboBox() 177 self.outline_box.setMinimumContentsLength(30) 178 self.outline_box.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon) 179 self.outline_box.setToolTip("Outline: jump to a module, type or procedure in this file") 180 self.outline_box.setIconSize(QSize(16, 16)) 181 self.outline_box.setItemDelegate(_OutlineDelegate(self.outline_box)) 182 self.outline_box.activated.connect(self._outline_chosen) 183 self.outline_bar = QWidget() 184 bar = QHBoxLayout(self.outline_bar) 185 bar.setContentsMargins(6, 3, 6, 3) 186 bar.addWidget(self.outline_box) 187 bar.addStretch(1) 188 self.outline_bar.hide() 189 190 layout = QVBoxLayout(self) 191 layout.setContentsMargins(0, 0, 0, 0) 192 layout.setSpacing(0) 193 layout.addWidget(self.outline_bar) 194 # Shown when the file changes on disk while it has unsaved edits: the 195 # choice waits here, in the tab (a dialog from a timer could open on 196 # top of another one, e.g. Save As). 197 self._disk_change: tuple[str, str, str] | None = None # (text, encoding, line end) on disk, waiting 198 self.disk_bar = QFrame() 199 self.disk_bar.setObjectName("DiskBar") 200 self.disk_bar.setStyleSheet("#DiskBar { background: rgba(240, 140, 0, 60); border-bottom: 1px solid " 201 "rgba(240, 140, 0, 160); }") 202 self.disk_label = QLabel() 203 self.disk_label.setWordWrap(True) 204 reload_button = QPushButton("Reload") 205 reload_button.setToolTip("Show the file as it is on disk (your unsaved edits are lost; Undo brings them back)") 206 reload_button.clicked.connect(self.reload_from_disk) 207 keep_button = QPushButton("Keep Mine") 208 keep_button.setToolTip("Keep your edits; saving them replaces the file on disk") 209 keep_button.clicked.connect(self.keep_my_edits) 210 for button in (reload_button, keep_button): 211 button.setAttribute(Qt.WA_MacSmallSize) 212 disk_row = QHBoxLayout(self.disk_bar) 213 disk_row.setContentsMargins(8, 4, 8, 4) 214 disk_row.addWidget(self.disk_label, 1) 215 disk_row.addWidget(reload_button) 216 disk_row.addWidget(keep_button) 217 self.disk_bar.hide() 218 layout.addWidget(self.disk_bar) 219 # The text, with the minimap at its right. 220 # (A child of the tab from the start: shown without a parent, even 221 # until the layout below takes it, it was a window of its own for a 222 # moment, which Windows showed as a window flashing up.) 223 self.minimap = Minimap(self.editor, self) 224 self.editor.minimap = self.minimap 225 self.minimap.setVisible(self.minimap_enabled) 226 self.editor.set_word_wrap(self.word_wrap) 227 self.highlighter.relexed.connect(self.minimap.invalidate) 228 # Git blame, left of the minimap (View → Show Git Blame): read again once typing pauses. 229 self.blame_column = BlameColumn(self.editor, self) 230 self.blame_column.setVisible(self.blame_enabled) 231 self._blame_request = 0 # (an answer to an older request is dropped) 232 self._blame_timer = QTimer(self) 233 self._blame_timer.setSingleShot(True) 234 self._blame_timer.setInterval(BLAME_DELAY_MS) 235 self._blame_timer.timeout.connect(self.refresh_blame) 236 self.editor.document().contentsChanged.connect( 237 lambda: self.blame_enabled and self._blame_timer.start()) 238 self._spelling_timer = QTimer(self) # (once the colors, so the comments and strings, are updated) 239 self._spelling_timer.setSingleShot(True) 240 self._spelling_timer.setInterval(SPELLING_DELAY_MS) 241 self._spelling_timer.timeout.connect(self.check_spelling) 242 self.highlighter.relexed.connect(self._spelling_timer.start) 243 body = QHBoxLayout() 244 body.setContentsMargins(0, 0, 0, 0) 245 body.setSpacing(0) 246 body.addWidget(self.editor, 1) 247 body.addWidget(self.blame_column) 248 body.addWidget(self.minimap) 249 layout.addLayout(body, 1) 250 layout.addWidget(self.find_bar) 251 layout.addWidget(self.status) 252 253 self.watcher = QFileSystemWatcher(self) 254 self.watcher.fileChanged.connect(self._file_changed) 255 self._check_timer = QTimer(self) # coalesce bursts of change events 256 self._check_timer.setSingleShot(True) 257 self._check_timer.setInterval(200) 258 self._check_timer.timeout.connect(self._check_disk) 259 260 self._lint_timer = QTimer(self) 261 self._lint_timer.setSingleShot(True) 262 self._lint_timer.setInterval(LINT_DELAY_MS) 263 self._lint_timer.timeout.connect(self.lint) 264 self._changes_timer = QTimer(self) # the gutter's change bars, once typing pauses 265 self._changes_timer.setSingleShot(True) 266 self._changes_timer.setInterval(300) 267 self._changes_timer.timeout.connect(self.update_change_marks) 268 self._lsp_timer = QTimer(self) 269 self._lsp_timer.setSingleShot(True) 270 self._lsp_timer.setInterval(LSP_SYNC_MS) 271 self._lsp_timer.timeout.connect(self._lsp_sync) 272 self.editor.definition_requested.connect(self.go_to_definition) 273 self.editor.implementation_requested.connect(self.go_to_implementation) 274 self.editor.references_requested.connect(self.find_references) 275 self.editor.rename_requested.connect(self.rename_symbol) 276 self.editor.lint_fix_requested.connect(self.apply_lint_fix) 277 self.editor.explain_requested.connect(self._explain_line) 278 self._complete_timer = QTimer(self) 279 self._complete_timer.setSingleShot(True) 280 self._complete_timer.setInterval(COMPLETE_MS) 281 self._complete_timer.timeout.connect(lambda: self.request_completion(manual=False)) 282 self._signature_timer = QTimer(self) 283 self._signature_timer.setSingleShot(True) 284 self._signature_timer.setInterval(COMPLETE_MS) 285 self._signature_timer.timeout.connect(self.request_signature) 286 self.editor.typed.connect(self._typed) 287 self.editor.completion_chosen.connect(self.accept_completion) 288 self.editor.completion_popup.accepted.connect(self.accept_completion) 289 self.editor.cursorPositionChanged.connect(self._cursor_moved_popups) 290 291 self.editor.document().modificationChanged.connect(self.dirty_changed) 292 self.editor.cursorPositionChanged.connect(self.update_status) 293 self.editor.cursors_changed.connect(self.update_status) 294 self.load() 295 self.refresh_style() 296 self.editor.textChanged.connect(self._lint_timer.start) 297 self.editor.textChanged.connect(self._changes_timer.start) 298 self._changes_timer.timeout.connect(self.check_gremlins) # (the same typing pause) 299 self.editor.textChanged.connect(lambda: self.lsp is not None and self._lsp_timer.start()) 300 self.editor.cursorPositionChanged.connect(self._outline_follow_cursor) 301 self.check_gremlins() 302 self.lint()
304 def close_down(self): 305 """Before the tab is closed: stop background work.""" 306 self.stop_lint() 307 self.detach_lsp() 308 self.highlighter.close() # (a big file may be being lexed on a thread)
Before the tab is closed: stop background work.
310 def update_change_marks(self): 311 """The gutter's change bars: lines changed and not saved yet, and 312 lines changed since the file was opened (and saved).""" 313 self._changes_timer.stop() 314 marks, deletions = line_changes(getattr(self, "_opened_text", self._disk_text), self._disk_text, 315 self.editor.toPlainText()) 316 self.editor.set_change_marks(marks, deletions)
The gutter's change bars: lines changed and not saved yet, and lines changed since the file was opened (and saved).
332 def load(self): 333 if self.untitled: # (nothing on disk yet: empty, saved as UTF-8 with this platform's line ends) 334 text, self.encoding, self.newline, self.mixed_newlines = "", "utf-8", os.linesep, False 335 else: 336 text, self.encoding, self.newline, self.mixed_newlines = self._read() 337 self._disk_newline = self.newline # (a converted file is unsaved: see is_dirty) 338 self._disk_mixed = self.mixed_newlines 339 self._disk_text = text 340 self._opened_text = text # (the change bars compare with this and the saved text) 341 self.editor.setPlainText(text) 342 self.editor.document().setModified(False) 343 self._watch() 344 self.update_status() 345 if getattr(self, "blame_column", None) is not None: # (not when first loaded: see __init__) 346 self.refresh_blame()
354 def is_dirty(self) -> bool: 355 """Unsaved changes: to the text, or to its line endings (Convert Line Endings).""" 356 return (self.editor.document().isModified() or self.newline != self._disk_newline 357 or self._disk_mixed != self.mixed_newlines)
Unsaved changes: to the text, or to its line endings (Convert Line Endings).
359 def convert_line_endings(self, newline: str) -> bool: 360 """Edit → Convert Line Endings: the file is saved with ``newline`` 361 ("\\n" Unix, "\\r\\n" Windows) at every line end (a file with mixed 362 ones, or old Mac ones, which Qt reads as line breaks, gets the same 363 everywhere). False if it already has them.""" 364 if newline == self.newline and not self.mixed_newlines: 365 return False 366 self.newline = newline 367 self.mixed_newlines = False # (all the same from the next save) 368 self.dirty_changed.emit(self.is_dirty()) 369 self.update_status() 370 return True
Edit → Convert Line Endings: the file is saved with newline
("\n" Unix, "\r\n" Windows) at every line end (a file with mixed
ones, or old Mac ones, which Qt reads as line breaks, gets the same
everywhere). False if it already has them.
372 def trim_trailing_whitespace(self) -> int: 373 """Edit → Trim Trailing Whitespace: the spaces and tabs at the ends of 374 the lines removed (one step to undo); how many lines had them. The 375 lines are found in the text at once (going through a big file's 376 every line took a tenth of a second at every save).""" 377 text = self.editor.toPlainText() 378 lines, line, at = [], 0, 0 379 for match in TRAILING_SPACE_RE.finditer(text): 380 line += text.count("\n", at, match.start()) 381 at = match.start() 382 lines.append(line) 383 if not lines: 384 return 0 385 document = self.editor.document() 386 cursor = QTextCursor(document) 387 cursor.beginEditBlock() 388 for number in lines: 389 block = document.findBlockByNumber(number) 390 text = block.text() 391 # (Qt's positions count a character beyond U+FFFF as two.) 392 cursor.setPosition(block.position() + _qt_length(text.rstrip())) 393 cursor.setPosition(block.position() + _qt_length(text), QTextCursor.KeepAnchor) 394 cursor.removeSelectedText() 395 cursor.endEditBlock() 396 return len(lines)
Edit → Trim Trailing Whitespace: the spaces and tabs at the ends of the lines removed (one step to undo); how many lines had them. The lines are found in the text at once (going through a big file's every line took a tenth of a second at every save).
398 def save(self) -> bool: 399 if self.untitled: 400 return self.save_as() # (a name first) 401 if self.trim_on_save and self.path.suffix.lower() not in TRIM_KEEPS: 402 self.trim_trailing_whitespace() 403 text = self.editor.toPlainText() 404 try: 405 data = text.replace("\n", self.newline).encode(self.encoding) 406 except UnicodeEncodeError: 407 data = text.replace("\n", self.newline).encode("utf-8") 408 self.encoding = "utf-8" 409 try: 410 self.path.write_bytes(data) 411 except OSError as exc: 412 QMessageBox.warning(self, "Save failed", f"Could not save {self.path}:\n{exc}") 413 return False 414 self._disk_text = text 415 self._disk_newline = self.newline 416 self.mixed_newlines = self._disk_mixed = False # (saved: every line ends the same way now) 417 self._disk_change = None # (the save replaced any other version on disk) 418 self.disk_bar.hide() 419 self.editor.document().setModified(False) 420 self.dirty_changed.emit(False) # (also when only the line endings had changed) 421 # Line numbers in the compiler/review notes may no longer be right. 422 self._compiler_markers, self._review_markers = {}, {} 423 self.editor.set_markers({}) 424 self._watch() 425 if self.lsp is not None: 426 self._flush_lsp() 427 self.lsp.save_document(self.path) 428 self.update_change_marks() # unsaved bars become saved ones 429 self.refresh_blame() 430 self.saved.emit(str(self.path)) 431 return True
433 def save_as(self) -> bool: 434 filename, _ = QFileDialog.getSaveFileName(self, "Save As", str(self.path)) 435 if not filename: 436 return False 437 client = self.lsp 438 self.detach_lsp() # (under the old name) 439 self.untitled = False 440 new = not Path(filename).exists() 441 self._set_path(filename) # (its type known now: colors, comments…) 442 saved = self.save() 443 if saved and new: 444 self.created.emit(str(self.path)) 445 self.lint() # it may (no longer) be a Fortran file 446 self.attach_lsp(client) 447 return saved
449 def renamed(self, path: str | Path): 450 """The file was renamed or moved (on disk): the tab follows it, 451 keeping any unsaved changes.""" 452 client = self.lsp 453 self.detach_lsp() # (under the old name) 454 self._set_path(path) 455 self._watch() 456 self.lint() # it may (no longer) be a Fortran file 457 self.attach_lsp(client) 458 self.update_status()
The file was renamed or moved (on disk): the tab follows it, keeping any unsaved changes.
466 def maybe_save(self) -> bool: 467 """Ask about unsaved changes; True if it's fine to close.""" 468 if not self.is_dirty(): 469 return True 470 answer = QMessageBox.question( 471 self, "Unsaved changes", f"Save changes to {self.path.name}?", 472 QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel, QMessageBox.Save) 473 if answer == QMessageBox.Save: 474 return self.save() 475 return answer == QMessageBox.Discard
Ask about unsaved changes; True if it's fine to close.
477 def display_path(self) -> str: 478 """Where the file is, as told to the user (a tab's tooltip).""" 479 return f"{self.path.name} (not saved yet)" if self.untitled else str(self.path)
Where the file is, as told to the user (a tab's tooltip).
505 def reload_from_disk(self): 506 """The disk bar's Reload: the file as it is on disk, in place of the 507 unsaved edits (one Undo step brings them back).""" 508 change, self._disk_change = self._disk_change, None 509 self.disk_bar.hide() 510 if change is not None: 511 self._load_from_disk(*change, undoable=True)
The disk bar's Reload: the file as it is on disk, in place of the unsaved edits (one Undo step brings them back).
513 def keep_my_edits(self): 514 """The disk bar's Keep Mine: the edits stay (saving replaces the file); 515 this version on disk isn't asked about again.""" 516 change, self._disk_change = self._disk_change, None 517 self.disk_bar.hide() 518 if change is not None: 519 self._disk_text, _encoding, self._disk_newline, self._disk_mixed = change
The disk bar's Keep Mine: the edits stay (saving replaces the file); this version on disk isn't asked about again.
548 def set_blame_enabled(self, on: bool): 549 """View → Show Git Blame, for this tab.""" 550 self.blame_column.setVisible(on) 551 if on: 552 self.refresh_blame()
View → Show Git Blame, for this tab.
554 def refresh_blame(self): 555 """Read the blame of the text as it is now (off the window's thread).""" 556 self._blame_timer.stop() 557 if not self.blame_enabled: 558 return 559 if self.untitled: 560 self.blame_column.set_blame(None, "Not saved yet: no history.") 561 return 562 self._blame_request += 1 563 request, path = self._blame_request, self.path 564 # (As saving would write it: its line endings and encoding. Blamed with "\n" ends, a file 565 # committed with "\r\n" ones differed on every line: all "not committed yet".) 566 text = self.editor.toPlainText().replace("\n", self.newline).encode(self.encoding, "replace") 567 568 def work(): 569 if not gs.available(): 570 return None, "Git blame needs git (https://git-scm.com), on the PATH." 571 try: 572 found = git_blame.blame(path, text) 573 except gs.GitError as error: 574 return None, f"git blame: {error}" 575 return found, "" if found is not None else "Not in git: no history yet." 576 577 def done(result): 578 if request == self._blame_request: 579 self.blame_column.set_blame(*result) 580 581 call_in_background(work, done, self)
Read the blame of the text as it is now (off the window's thread).
583 def refresh_style(self): 584 """Apply the syntax colors (View → Syntax Colors, or automatic).""" 585 apply_style(self.editor, self.highlighter) 586 self.editor.update_extra_selections() 587 self.editor.gutter.update() 588 if getattr(self, "minimap", None) is not None: 589 self.minimap.invalidate()
Apply the syntax colors (View → Syntax Colors, or automatic).
591 def update_status(self, note: str = ""): 592 cursor = self.editor.textCursor() 593 indent = f"Tabs: {self.editor.indent_width}" if self.editor.use_tabs else f"Spaces: {self.editor.indent_width}" 594 parts = [f"Ln {cursor.blockNumber() + 1}, Col {cursor.positionInBlock() + 1}", 595 indent, self.highlighter.lexer.name, "UTF-8 with BOM" if self.encoding == "utf-8-sig" else self.encoding.upper(), 596 ("CRLF" if self.newline == "\r\n" else "LF") + (" (mixed)" if self.mixed_newlines else "")] 597 parts += self._issue_counts() 598 if self.editor.extra_cursors: 599 parts.insert(1, f"{len(self.editor.extra_cursors) + 1} cursors (Esc: back to one)") 600 if note: 601 parts.append(note) 602 self.status.setText("<span style='color:gray'>" + " · ".join(parts) + "</span>") 603 kind = "CRLF" if self.newline == "\r\n" else "LF" 604 self.status.setToolTip( 605 f"This file has more than one kind of line ending: saving makes them all {kind}, the most " 606 "common one (Edit → Convert Line Endings to choose)" if self.mixed_newlines else "")
638 def replace_text(self, text: str): 639 """Replace the whole text as one undoable edit, keeping the cursor 640 on the same line and column and the view where it was.""" 641 cursor = self.editor.textCursor() 642 line, column = cursor.blockNumber(), cursor.positionInBlock() 643 scroll = self.editor.verticalScrollBar().value() 644 cursor.beginEditBlock() 645 cursor.select(QTextCursor.Document) 646 cursor.insertText(text) 647 cursor.endEditBlock() 648 block = self.editor.document().findBlockByNumber(min(line, self.editor.blockCount() - 1)) 649 cursor = QTextCursor(block) 650 cursor.setPosition(block.position() + min(column, max(0, block.length() - 1))) 651 self.editor.setTextCursor(cursor) 652 self.editor.verticalScrollBar().setValue(scroll)
Replace the whole text as one undoable edit, keeping the cursor on the same line and column and the view where it was.
Inherited Members
668def monospace_font(size_factor: float = 1.0, editor: bool = False) -> QFont: 669 """The fixed-width font, scaled by ``size_factor`` (View → text size); 670 with ``editor``, the one chosen in View → Editor Font.""" 671 font = QFontDatabase.systemFont(QFontDatabase.FixedFont) 672 if editor and editor_font_family: 673 font.setFamily(editor_font_family) 674 font.setStyleHint(QFont.Monospace) 675 if editor and editor_font_size > 0: 676 font.setPointSizeF(editor_font_size) 677 font.setPointSizeF(font.pointSizeF() * size_factor) 678 return font
The fixed-width font, scaled by size_factor (View → text size);
with editor, the one chosen in View → Editor Font.