vibefpmgui.editor_shared
What the editor's modules share: colors, delays, severities, and text helpers (no widgets, so any module can import it: the minimap, the panels…).
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: 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): 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)
17def visual_column(text: str, width: int) -> int: 18 """How far across a line's ``text`` reaches, with tabs to the next 19 multiple of ``width``.""" 20 column = 0 21 for ch in text: 22 column = (column // width + 1) * width if ch == "\t" else column + 1 23 return column
How far across a line's text reaches, with tabs to the next
multiple of width.
25def tab_text(before: str, width: int, use_tabs: bool) -> str: 26 """What Tab types after ``before`` (the line up to the cursor): a tab, 27 or spaces to the next tab stop.""" 28 if use_tabs: 29 return "\t" 30 return " " * (width - visual_column(before, width) % width)
What Tab types after before (the line up to the cursor): a tab,
or spaces to the next tab stop.
32def outdent_length(line: str, width: int) -> int: 33 """How many characters at the start of a line ⇧Tab removes: one tab, or 34 spaces up to one indent level (or both, up to that width).""" 35 spaces = min(len(line) - len(line.lstrip(" ")), width) 36 if spaces < width and line[spaces:spaces + 1] == "\t": 37 return spaces + 1 # (a tab, after any spaces short of a whole level) 38 return spaces
How many characters at the start of a line ⇧Tab removes: one tab, or spaces up to one indent level (or both, up to that width).
40def backspace_length(before: str, width: int) -> int: 41 """In leading spaces, Backspace removes back to the previous tab stop: 42 how many spaces (0: an ordinary Backspace).""" 43 if not before or before.strip(" "): 44 return 0 # (not just spaces before the cursor) 45 return (len(before) - 1) % width + 1
In leading spaces, Backspace removes back to the previous tab stop: how many spaces (0: an ordinary Backspace).
102def line_changes(opened: str, saved: str, current: str) -> tuple[dict[int, str], dict[int, tuple[str, int]]]: 103 """Which lines of ``current`` differ from the saved text ("unsaved") or, 104 if not, from the text as opened ("saved"); and where lines were deleted: 105 {line: (kind, count)}, meaning removed just above that line (line count 106 + 1: at the end). Lines are 1-based.""" 107 lines = current.split("\n") 108 if len(lines) > CHANGE_DIFF_MAX_LINES: 109 return {}, {} 110 111 def diff(base: str): 112 changed: set[int] = set() 113 deleted: dict[int, int] = {} 114 old = base.split("\n") 115 # Only the middle differs (edits are usually in one place): skip the 116 # lines that are the same at the start and the end before diffing. 117 top, limit = 0, min(len(old), len(lines)) 118 while top < limit and old[top] == lines[top]: 119 top += 1 120 bottom = 0 121 while bottom < limit - top and old[-1 - bottom] == lines[-1 - bottom]: 122 bottom += 1 123 matcher = difflib.SequenceMatcher(None, old[top:len(old) - bottom], lines[top:len(lines) - bottom], 124 autojunk=False) 125 for tag, i1, i2, j1, j2 in matcher.get_opcodes(): 126 if tag in ("replace", "insert"): 127 changed.update(range(top + j1 + 1, top + j2 + 1)) 128 elif tag == "delete": 129 deleted[top + j1 + 1] = i2 - i1 130 return changed, deleted 131 132 unsaved, unsaved_deleted = diff(saved) 133 since_open, open_deleted = (unsaved, unsaved_deleted) if opened == saved else diff(opened) 134 marks = {line: "saved" for line in since_open - unsaved} 135 marks.update({line: "unsaved" for line in unsaved}) 136 deletions = {line: ("saved", n) for line, n in open_deleted.items() if line not in unsaved_deleted} 137 deletions.update({line: ("unsaved", n) for line, n in unsaved_deleted.items()}) 138 return marks, deletions
Which lines of current differ from the saved text ("unsaved") or,
if not, from the text as opened ("saved"); and where lines were deleted:
{line: (kind, count)}, meaning removed just above that line (line count
- 1: at the end). Lines are 1-based.
141def occurrence_color(background: QColor) -> QColor: 142 """The highlight of the selection's other occurrences on a syntax 143 style's background: a blue mixed into it until it's plainly lighter (on 144 a dark background) or darker (on a light one). The app's selection color 145 was all but invisible on some styles (github-dark's near-black).""" 146 tint = QColor(90, 150, 255) 147 dark = background.lightness() < 128 148 wanted = OCCURRENCE_CONTRAST[dark] 149 color = QColor(background) 150 for amount in (0.25, 0.35, 0.45, 0.55, 0.65, 0.75): 151 color = QColor(round(background.red() + (tint.red() - background.red()) * amount), 152 round(background.green() + (tint.green() - background.green()) * amount), 153 round(background.blue() + (tint.blue() - background.blue()) * amount)) 154 if abs(color.lightness() - background.lightness()) >= wanted: 155 break 156 return color
The highlight of the selection's other occurrences on a syntax style's background: a blue mixed into it until it's plainly lighter (on a dark background) or darker (on a light one). The app's selection color was all but invisible on some styles (github-dark's near-black).
166def word_start(text: str, column: int) -> int: 167 """Where the name ending at ``column`` of a line starts (``column`` if 168 there's none just before it).""" 169 start = column 170 while start > 0 and WORD_RE.match(text[start - 1]): 171 start -= 1 172 return start
Where the name ending at column of a line starts (column if
there's none just before it).
175def word_bounds(text: str, column: int) -> tuple[int, int] | None: 176 """(start, end) of the name at ``column`` of a line, or just before it 177 (the cursor right after a name); None if there's none there, or it's a 178 number.""" 179 i = column 180 if not (i < len(text) and WORD_RE.match(text[i])): 181 if i > 0 and WORD_RE.match(text[i - 1]): 182 i -= 1 183 else: 184 return None 185 start, end = word_start(text, i), i 186 while end < len(text) and WORD_RE.match(text[end]): 187 end += 1 188 return None if text[start].isdigit() else (start, end)
(start, end) of the name at column of a line, or just before it
(the cursor right after a name); None if there's none there, or it's a
number.