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
TRIM_KEEPS = {'.patch', '.md', '.markdown', '.diff'}
TRAILING_SPACE_RE = re.compile('[^\\S\\n]+$', re.MULTILINE)
class EditorTab(vibefpmgui.editor_language.EditorLanguageMixin, vibefpmgui.editor_checks.EditorChecksMixin, PySide6.QtWidgets.QWidget):
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.

EditorTab(path: pathlib.Path, parent=None, untitled: bool = False)
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()

untitled: a new file, not saved yet (File → New File): path is only its name for now (no such file); it has no file type, so no colors or checks, until Save As gives it a name.

lint_enabled = True
lsp_diagnostics_enabled = True
spelling_enabled = True
gremlins_enabled = True
minimap_enabled = True
blame_enabled = False
word_wrap = False
trim_on_save = True
def dirty_changed(unknown):

The type of the None singleton.

def saved(unknown):

The type of the None singleton.

def created(unknown):

The type of the None singleton.

def path_changed(unknown):

The type of the None singleton.

def navigate_requested(unknown):

The type of the None singleton.

def references_found(unknown):

The type of the None singleton.

def rename_ready(unknown):

The type of the None singleton.

def problems_changed(unknown):

The type of the None singleton.

def explain_requested(unknown):

The type of the None singleton.

path
untitled
encoding
newline
mixed_newlines
rewriting
editor
highlighter
find_bar
status
outline_box
outline_bar
disk_bar
disk_label
minimap
blame_column
watcher
def close_down(self):
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.

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

@staticmethod
def is_probably_text(path: pathlib.Path) -> bool:
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
def load(self):
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()
def is_dirty(self) -> bool:
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).

def convert_line_endings(self, newline: str) -> bool:
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.

def trim_trailing_whitespace(self) -> int:
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).

def save(self) -> bool:
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
def save_as(self) -> bool:
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
def renamed(self, path: str | pathlib.Path):
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.

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

def display_path(self) -> str:
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).

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

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

def set_font(self, font: PySide6.QtGui.QFont):
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()
def set_blame_enabled(self, on: bool):
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.

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

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

def update_status(self, note: str = ''):
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 "")
def replace_text(self, text: str):
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.

def go_to_line_dialog(self):
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)
staticMetaObject = PySide6.QtCore.QMetaObject("EditorTab" inherits "QWidget": Methods: #33 type=Signal, signature=dirty_changed(bool), parameters=bool #34 type=Signal, signature=saved(QString), parameters=QString #35 type=Signal, signature=created(QString), parameters=QString #36 type=Signal, signature=path_changed(QString), parameters=QString #37 type=Signal, signature=navigate_requested(QString,int,int), parameters=QString, int, int #38 type=Signal, signature=references_found(QString,PyObject), parameters=QString, PyObject #39 type=Signal, signature=rename_ready(QString,QString,PyObject), parameters=QString, QString, PyObject #40 type=Signal, signature=problems_changed() #41 type=Signal, signature=explain_requested(PyObject), parameters=PyObject )
editor_font_family = ''
editor_font_size = 0.0
def monospace_font(size_factor: float = 1.0, editor: bool = False) -> PySide6.QtGui.QFont:
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.