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

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…).""" 

3 

4from __future__ import annotations 

5 

6import difflib 

7import re 

8 

9from qtpy.QtCore import Qt 

10from qtpy.QtGui import QColor 

11 

12from . import gremlins as gremlins_mod 

13 

14INDENT_WIDTHS = (2, 3, 4, 8) # View → Indentation 

15 

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 

23 

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) 

30 

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 

38 

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 

45 

46MAX_FIND_HIGHLIGHTS = 2000 

47 

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} 

53 

54# Underline colors on a dark background, where these would be too dim to see. 

55DARK_UNDERLINE_COLORS = {"spelling": "#e599f7"} 

56 

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"] 

60 

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"} 

64 

65LINT_DELAY_MS = 600 # lint once typing pauses this long 

66 

67GREMLIN_WIDTH = 16 # the gutter's gremlin column (only while the file has gremlins) 

68 

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 

74 

75COVERAGE_COLORS = {"ran": "#2f9e44", "partial": "#f08c00", "missed": "#e03131"} # (gutter bars, minimap) 

76 

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) 

79 

80LSP_SYNC_MS = 300 # send edits to the language server once typing pauses this long 

81 

82COMPLETE_MS = 120 # ask for completions once typing pauses this long 

83 

84AUTO_COMPLETE_MIN = 2 # characters of a name typed before completions appear by themselves 

85 

86WORD_RE = re.compile(r"[A-Za-z0-9_]") 

87 

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} 

90 

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

94 

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"} 

98 

99CHANGE_DIFF_MAX_LINES = 30_000 # (bigger files: no change bars, the diff would be slow) 

100 

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 {}, {} 

109 

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 

130 

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 

138 

139 

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 

156 

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} 

160 

161 

162DEPTH_ROLE = Qt.UserRole + 7 # outline entries: how deeply nested 

163 

164 

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 

172 

173 

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)