Coverage for vibefpmgui/editor_shared.py: 98%
104 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""What the editor's modules share: colors, delays, severities, and text helpers
2(no widgets, so any module can import it: the minimap, the panels…)."""
4from __future__ import annotations
6import difflib
7import re
9from qtpy.QtCore import Qt
10from qtpy.QtGui import QColor
12from . import gremlins as gremlins_mod
14INDENT_WIDTHS = (2, 3, 4, 8) # View → Indentation
16def visual_column(text: str, width: int) -> int:
17 """How far across a line's ``text`` reaches, with tabs to the next
18 multiple of ``width``."""
19 column = 0
20 for ch in text:
21 column = (column // width + 1) * width if ch == "\t" else column + 1
22 return column
24def tab_text(before: str, width: int, use_tabs: bool) -> str:
25 """What Tab types after ``before`` (the line up to the cursor): a tab,
26 or spaces to the next tab stop."""
27 if use_tabs:
28 return "\t"
29 return " " * (width - visual_column(before, width) % width)
31def outdent_length(line: str, width: int) -> int:
32 """How many characters at the start of a line ⇧Tab removes: one tab, or
33 spaces up to one indent level (or both, up to that width)."""
34 spaces = min(len(line) - len(line.lstrip(" ")), width)
35 if spaces < width and line[spaces:spaces + 1] == "\t":
36 return spaces + 1 # (a tab, after any spaces short of a whole level)
37 return spaces
39def backspace_length(before: str, width: int) -> int:
40 """In leading spaces, Backspace removes back to the previous tab stop:
41 how many spaces (0: an ordinary Backspace)."""
42 if not before or before.strip(" "):
43 return 0 # (not just spaces before the cursor)
44 return (len(before) - 1) % width + 1
46MAX_FIND_HIGHLIGHTS = 2000
48MARKER_COLORS = {"error": "#e03131", "fatal error": "#e03131", "warning": "#f08c00",
49 "review": "#7048e8", "remark": "#1c7ed6",
50 "lint error": "#e03131", "lint": "#0ca678",
51 "fortls error": "#e03131", "fortls": "#1098ad", "spelling": "#ae3ec9",
52 "gremlin": gremlins_mod.COLOR}
54# Underline colors on a dark background, where these would be too dim to see.
55DARK_UNDERLINE_COLORS = {"spelling": "#e599f7"}
57# Which marker color wins when a line has several kinds of note.
58MARKER_PRIORITY = ["fatal error", "error", "lint error", "fortls error", "warning", "review", "lint",
59 "fortls", "remark"]
61# Which tool an underlined issue is from (by its severity), for the Problems tab.
62PROBLEM_SOURCES = {"lint": "fortitude", "lint error": "fortitude", "fortls": "fortls",
63 "fortls error": "fortls", "spelling": "spelling"}
65LINT_DELAY_MS = 600 # lint once typing pauses this long
67GREMLIN_WIDTH = 16 # the gutter's gremlin column (only while the file has gremlins)
69COVERAGE_WIDTH = 5 # the gutter's coverage bars, at its left edge (only while the file has coverage)
70BREAKPOINT_WIDTH = 15 # the gutter's breakpoint column, at its left edge (source files the debugger can stop in)
71BREAKPOINT_COLOR = "#e5484d" # a breakpoint's dot (hollow: disabled)
72BREAKPOINT_UNPLACED = "#a0a4ab" # one the debugger couldn't place (no code on that line…)
73EXECUTION_COLOR = "#f5b700" # the debugger's current line: the gutter's arrow, and the line's tint
75COVERAGE_COLORS = {"ran": "#2f9e44", "partial": "#f08c00", "missed": "#e03131"} # (gutter bars, minimap)
77SPELLING_DELAY_MS = 250 # spell check this long after the colors are updated
78BLAME_DELAY_MS = 1500 # git blame read again this long after typing pauses (see vibefpmgui.git_blame)
80LSP_SYNC_MS = 300 # send edits to the language server once typing pauses this long
82COMPLETE_MS = 120 # ask for completions once typing pauses this long
84AUTO_COMPLETE_MIN = 2 # characters of a name typed before completions appear by themselves
86WORD_RE = re.compile(r"[A-Za-z0-9_]")
88MODIFIER_KEYS = {Qt.Key_Shift, Qt.Key_Control, Qt.Key_Meta, Qt.Key_Alt, Qt.Key_AltGr,
89 Qt.Key_CapsLock, Qt.Key_Super_L, Qt.Key_Super_R}
91def worst_severity(notes: list[tuple[str, str]]) -> str:
92 return min((s for s, _ in notes),
93 key=lambda s: MARKER_PRIORITY.index(s) if s in MARKER_PRIORITY else len(MARKER_PRIORITY))
95# Change bars in the gutter: lines changed and not saved yet, and lines
96# changed since the file was opened but saved.
97CHANGE_COLORS = {"unsaved": "#f59f00", "saved": "#40c057"}
99CHANGE_DIFF_MAX_LINES = 30_000 # (bigger files: no change bars, the diff would be slow)
101def line_changes(opened: str, saved: str, current: str) -> tuple[dict[int, str], dict[int, tuple[str, int]]]:
102 """Which lines of ``current`` differ from the saved text ("unsaved") or,
103 if not, from the text as opened ("saved"); and where lines were deleted:
104 {line: (kind, count)}, meaning removed just above that line (line count
105 + 1: at the end). Lines are 1-based."""
106 lines = current.split("\n")
107 if len(lines) > CHANGE_DIFF_MAX_LINES: 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true
108 return {}, {}
110 def diff(base: str):
111 changed: set[int] = set()
112 deleted: dict[int, int] = {}
113 old = base.split("\n")
114 # Only the middle differs (edits are usually in one place): skip the
115 # lines that are the same at the start and the end before diffing.
116 top, limit = 0, min(len(old), len(lines))
117 while top < limit and old[top] == lines[top]:
118 top += 1
119 bottom = 0
120 while bottom < limit - top and old[-1 - bottom] == lines[-1 - bottom]:
121 bottom += 1
122 matcher = difflib.SequenceMatcher(None, old[top:len(old) - bottom], lines[top:len(lines) - bottom],
123 autojunk=False)
124 for tag, i1, i2, j1, j2 in matcher.get_opcodes():
125 if tag in ("replace", "insert"):
126 changed.update(range(top + j1 + 1, top + j2 + 1))
127 elif tag == "delete":
128 deleted[top + j1 + 1] = i2 - i1
129 return changed, deleted
131 unsaved, unsaved_deleted = diff(saved)
132 since_open, open_deleted = (unsaved, unsaved_deleted) if opened == saved else diff(opened)
133 marks = {line: "saved" for line in since_open - unsaved}
134 marks.update({line: "unsaved" for line in unsaved})
135 deletions = {line: ("saved", n) for line, n in open_deleted.items() if line not in unsaved_deleted}
136 deletions.update({line: ("unsaved", n) for line, n in unsaved_deleted.items()})
137 return marks, deletions
140def occurrence_color(background: QColor) -> QColor:
141 """The highlight of the selection's other occurrences on a syntax
142 style's background: a blue mixed into it until it's plainly lighter (on
143 a dark background) or darker (on a light one). The app's selection color
144 was all but invisible on some styles (github-dark's near-black)."""
145 tint = QColor(90, 150, 255)
146 dark = background.lightness() < 128
147 wanted = OCCURRENCE_CONTRAST[dark]
148 color = QColor(background)
149 for amount in (0.25, 0.35, 0.45, 0.55, 0.65, 0.75): 149 ↛ 155line 149 didn't jump to line 155 because the loop on line 149 didn't complete
150 color = QColor(round(background.red() + (tint.red() - background.red()) * amount),
151 round(background.green() + (tint.green() - background.green()) * amount),
152 round(background.blue() + (tint.blue() - background.blue()) * amount))
153 if abs(color.lightness() - background.lightness()) >= wanted:
154 break
155 return color
157# How far the highlight is from the background (lightness, 0–255): a dark
158# background needs more to show (dark colors look closer than they measure).
159OCCURRENCE_CONTRAST = {False: 34, True: 60}
162DEPTH_ROLE = Qt.UserRole + 7 # outline entries: how deeply nested
165def word_start(text: str, column: int) -> int:
166 """Where the name ending at ``column`` of a line starts (``column`` if
167 there's none just before it)."""
168 start = column
169 while start > 0 and WORD_RE.match(text[start - 1]):
170 start -= 1
171 return start
174def word_bounds(text: str, column: int) -> tuple[int, int] | None:
175 """(start, end) of the name at ``column`` of a line, or just before it
176 (the cursor right after a name); None if there's none there, or it's a
177 number."""
178 i = column
179 if not (i < len(text) and WORD_RE.match(text[i])):
180 if i > 0 and WORD_RE.match(text[i - 1]):
181 i -= 1
182 else:
183 return None
184 start, end = word_start(text, i), i
185 while end < len(text) and WORD_RE.match(text[end]):
186 end += 1
187 return None if text[start].isdigit() else (start, end)