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)
INDENT_WIDTHS = (2, 3, 4, 8)
def visual_column(text: str, width: int) -> int:
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.

def tab_text(before: str, width: int, use_tabs: bool) -> str:
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.

def outdent_length(line: str, width: int) -> int:
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).

def backspace_length(before: str, width: int) -> int:
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).

MAX_FIND_HIGHLIGHTS = 2000
MARKER_COLORS = {'error': '#e03131', 'fatal error': '#e03131', 'warning': '#f08c00', 'review': '#7048e8', 'remark': '#1c7ed6', 'lint error': '#e03131', 'lint': '#0ca678', 'fortls error': '#e03131', 'fortls': '#1098ad', 'spelling': '#ae3ec9', 'gremlin': '#e8590c'}
DARK_UNDERLINE_COLORS = {'spelling': '#e599f7'}
MARKER_PRIORITY = ['fatal error', 'error', 'lint error', 'fortls error', 'warning', 'review', 'lint', 'fortls', 'remark']
PROBLEM_SOURCES = {'lint': 'fortitude', 'lint error': 'fortitude', 'fortls': 'fortls', 'fortls error': 'fortls', 'spelling': 'spelling'}
LINT_DELAY_MS = 600
GREMLIN_WIDTH = 16
COVERAGE_WIDTH = 5
BREAKPOINT_WIDTH = 15
BREAKPOINT_COLOR = '#e5484d'
BREAKPOINT_UNPLACED = '#a0a4ab'
EXECUTION_COLOR = '#f5b700'
COVERAGE_COLORS = {'ran': '#2f9e44', 'partial': '#f08c00', 'missed': '#e03131'}
SPELLING_DELAY_MS = 250
BLAME_DELAY_MS = 1500
LSP_SYNC_MS = 300
COMPLETE_MS = 120
AUTO_COMPLETE_MIN = 2
WORD_RE = re.compile('[A-Za-z0-9_]')
MODIFIER_KEYS = {<Key.Key_Shift: 16777248>, <Key.Key_Control: 16777249>, <Key.Key_Meta: 16777250>, <Key.Key_Alt: 16777251>, <Key.Key_AltGr: 16781571>, <Key.Key_CapsLock: 16777252>, <Key.Key_Super_L: 16777299>, <Key.Key_Super_R: 16777300>}
def worst_severity(notes: list[tuple[str, str]]) -> str:
92def worst_severity(notes: list[tuple[str, str]]) -> str:
93    return min((s for s, _ in notes),
94               key=lambda s: MARKER_PRIORITY.index(s) if s in MARKER_PRIORITY else len(MARKER_PRIORITY))
CHANGE_COLORS = {'unsaved': '#f59f00', 'saved': '#40c057'}
CHANGE_DIFF_MAX_LINES = 30000
def line_changes( opened: str, saved: str, current: str) -> tuple[dict[int, str], dict[int, tuple[str, int]]]:
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.
def occurrence_color(background: PySide6.QtGui.QColor) -> PySide6.QtGui.QColor:
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).

OCCURRENCE_CONTRAST = {False: 34, True: 60}
DEPTH_ROLE = 263
def word_start(text: str, column: int) -> int:
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).

def word_bounds(text: str, column: int) -> tuple[int, int] | None:
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.