QCodeSitter 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. QCodeSitter/__init__.py +38 -0
  2. QCodeSitter/behaviors/__init__.py +115 -0
  3. QCodeSitter/behaviors/auto_bracket.py +141 -0
  4. QCodeSitter/behaviors/code_folding.py +530 -0
  5. QCodeSitter/behaviors/comment_toggle.py +71 -0
  6. QCodeSitter/behaviors/highlight_matching_brackets.py +181 -0
  7. QCodeSitter/behaviors/highlight_matching_selection.py +59 -0
  8. QCodeSitter/behaviors/line_numbers.py +49 -0
  9. QCodeSitter/behaviors/multi_cursor_paint.py +131 -0
  10. QCodeSitter/behaviors/overscroll.py +104 -0
  11. QCodeSitter/behaviors/smart_indent.py +191 -0
  12. QCodeSitter/behaviors/syntax_highlighting.py +181 -0
  13. QCodeSitter/behaviors/tab_completion/__init__.py +257 -0
  14. QCodeSitter/behaviors/tab_completion/popup.py +123 -0
  15. QCodeSitter/behaviors/tab_completion/providers.py +66 -0
  16. QCodeSitter/behaviors/tab_completion/types.py +55 -0
  17. QCodeSitter/code_editor.py +540 -0
  18. QCodeSitter/cursors/__init__.py +8 -0
  19. QCodeSitter/cursors/clipboard.py +51 -0
  20. QCodeSitter/cursors/iterator.py +141 -0
  21. QCodeSitter/cursors/line_edits.py +128 -0
  22. QCodeSitter/cursors/multi.py +240 -0
  23. QCodeSitter/cursors/state.py +55 -0
  24. QCodeSitter/cursors/tracked.py +97 -0
  25. QCodeSitter/defaults.py +93 -0
  26. QCodeSitter/editing_keys.py +150 -0
  27. QCodeSitter/editor_options.py +246 -0
  28. QCodeSitter/gui_colors.py +51 -0
  29. QCodeSitter/gutters.py +141 -0
  30. QCodeSitter/integrations/__init__.py +1 -0
  31. QCodeSitter/integrations/preditor_interface.py +390 -0
  32. QCodeSitter/languages/__init__.py +99 -0
  33. QCodeSitter/languages/base.py +60 -0
  34. QCodeSitter/languages/python.py +146 -0
  35. QCodeSitter/languages/python_highlights.py +444 -0
  36. QCodeSitter/py.typed +0 -0
  37. QCodeSitter/queries/__init__.py +73 -0
  38. QCodeSitter/queries/predicates.py +172 -0
  39. QCodeSitter/syntax_specs.py +65 -0
  40. QCodeSitter/themes.py +271 -0
  41. QCodeSitter/tracked_document.py +107 -0
  42. QCodeSitter/tree_manager.py +180 -0
  43. QCodeSitter/utils.py +84 -0
  44. qcodesitter-0.1.0.dist-info/METADATA +255 -0
  45. qcodesitter-0.1.0.dist-info/RECORD +49 -0
  46. qcodesitter-0.1.0.dist-info/WHEEL +5 -0
  47. qcodesitter-0.1.0.dist-info/entry_points.txt +2 -0
  48. qcodesitter-0.1.0.dist-info/licenses/LICENSE.txt +7 -0
  49. qcodesitter-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,172 @@
1
+ """Text predicates for tree-sitter queries
2
+
3
+ Tree-sitter's built-in text predicates (#eq?, #match?, #any-of?, etc) compare the
4
+ node's source text against the strings in the query. Our trees are parsed as UTF-16,
5
+ so tree-sitter compares UTF-16 bytes against UTF-8 strings, and they never match.
6
+
7
+ To get around this, `prepare_query_source` renames the text predicates so tree-sitter
8
+ treats them as custom predicates, and `QueryPredicates` evaluates them against the
9
+ node text it is given, which TrackedDocument.node_text reads from the document.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import functools
15
+ import re
16
+ import warnings
17
+ from collections.abc import Callable
18
+ from typing import TYPE_CHECKING
19
+
20
+ if TYPE_CHECKING:
21
+ from tree_sitter import Node
22
+
23
+ from . import Captures, PredicateArgs
24
+
25
+
26
+ PREFIX = "qcs-"
27
+ _TEXT_PREDICATE_RE = re.compile(r"#((?:not-)?(?:eq|match|lua-match|any-of))\?")
28
+
29
+ # Lua pattern character classes (nvim queries use these) as regex set contents
30
+ _LUA_CLASSES = {
31
+ "a": r"A-Za-z",
32
+ "c": r"\x00-\x1f\x7f",
33
+ "d": r"0-9",
34
+ "l": r"a-z",
35
+ "p": r"!-/:-@\[-`{-~",
36
+ "s": r"\s",
37
+ "u": r"A-Z",
38
+ "w": r"A-Za-z0-9",
39
+ "x": r"A-Fa-f0-9",
40
+ }
41
+
42
+
43
+ def prepare_query_source(source: str) -> str:
44
+ """Rename the text predicates in a query so we can evaluate them ourselves"""
45
+ return _TEXT_PREDICATE_RE.sub(lambda m: f"#{PREFIX}{m.group(1)}?", source)
46
+
47
+
48
+ def _lua_class(nxt: str, in_set: bool) -> str:
49
+ """Convert the character after a % into regex"""
50
+ cls = _LUA_CLASSES.get(nxt.lower())
51
+ if cls is None:
52
+ # %% or %. etc are escaped literals
53
+ return re.escape(nxt)
54
+ if not nxt.isupper():
55
+ return cls if in_set else f"[{cls}]"
56
+ # Uppercase classes are the complement
57
+ if in_set:
58
+ raise re.error(f"%{nxt} is not supported inside a set")
59
+ return f"[^{cls}]"
60
+
61
+
62
+ def lua_to_regex(pattern: str) -> str:
63
+ """Convert the lua pattern character classes (ie %u, %l) into a python regex
64
+
65
+ Only handles the character classes, which is what the nvim queries use.
66
+ Normal regex syntax passes through unchanged. The complement classes (ie %U)
67
+ aren't supported inside of a [set]
68
+ """
69
+ out = []
70
+ in_set = False
71
+ i = 0
72
+ while i < len(pattern):
73
+ ch = pattern[i]
74
+ if ch in "%\\" and i + 1 < len(pattern):
75
+ pair = pattern[i : i + 2]
76
+ out.append(_lua_class(pair[1], in_set) if ch == "%" else pair)
77
+ i += 2
78
+ continue
79
+ if ch == "[" and not in_set:
80
+ in_set = True
81
+ elif ch == "]" and in_set:
82
+ in_set = False
83
+ out.append(ch)
84
+ i += 1
85
+ return "".join(out)
86
+
87
+
88
+ @functools.lru_cache(maxsize=256)
89
+ def _compile_pattern(pattern: str) -> re.Pattern | None:
90
+ """Compile a #match? pattern once, or warn once and give None if it's invalid"""
91
+ try:
92
+ return re.compile(lua_to_regex(pattern))
93
+ except re.error as err:
94
+ warnings.warn(
95
+ f"Invalid #match? pattern {pattern!r} in a tree-sitter query: {err}",
96
+ stacklevel=2,
97
+ )
98
+ return None
99
+
100
+
101
+ class QueryPredicates:
102
+ """A predicate callback for QueryCursor.captures that evaluates the renamed
103
+ text predicates using the text from a document
104
+
105
+ Args:
106
+ get_text: A function that returns the source text of a node
107
+ """
108
+
109
+ def __init__(self, get_text: Callable[[Node], str]) -> None:
110
+ self.get_text = get_text
111
+
112
+ def _eq_check(
113
+ self, rest: PredicateArgs, captures: Captures
114
+ ) -> Callable[[str], bool] | None:
115
+ value, kind = rest[0]
116
+ if kind != "capture":
117
+ return lambda text: text == value
118
+ others = captures.get(value, [])
119
+ if not others:
120
+ return None
121
+ other = self.get_text(others[0])
122
+ return lambda text: text == other
123
+
124
+ def _match_check(self, rest: PredicateArgs) -> Callable[[str], bool]:
125
+ regex = _compile_pattern(rest[0][0])
126
+ if regex is None:
127
+ # An invalid pattern can't match anything
128
+ return lambda _text: False
129
+ return lambda text: regex.search(text) is not None
130
+
131
+ def _make_check(
132
+ self, name: str, rest: PredicateArgs, captures: Captures
133
+ ) -> Callable[[str], bool] | None:
134
+ """Build the text test for a predicate, or None if it can't filter"""
135
+ if name == "any-of":
136
+ values = {value for value, _kind in rest}
137
+ return lambda text: text in values
138
+ if not rest:
139
+ return None
140
+ if name == "eq":
141
+ return self._eq_check(rest, captures)
142
+ # The nvim queries use lua classes in #match? too, so treat them the same
143
+ if name in ("match", "lua-match"):
144
+ return self._match_check(rest)
145
+ return None
146
+
147
+ def __call__(
148
+ self,
149
+ predicate: str,
150
+ args: PredicateArgs,
151
+ pattern_index: int,
152
+ captures: Captures,
153
+ ) -> bool:
154
+ # The names match tree-sitter's QueryPredicate protocol
155
+ name = predicate
156
+ if not name.startswith(PREFIX):
157
+ # Not one of ours. Don't filter anything out
158
+ return True
159
+ name = name[len(PREFIX) : -1]
160
+ negate = name.startswith("not-")
161
+ if negate:
162
+ name = name[4:]
163
+
164
+ if not args or args[0][1] != "capture":
165
+ return True
166
+ check = self._make_check(name, args[1:], captures)
167
+ if check is None:
168
+ return True
169
+ return all(
170
+ check(self.get_text(node)) != negate
171
+ for node in captures.get(args[0][0], [])
172
+ )
@@ -0,0 +1,65 @@
1
+ """The "syntax" option: text formats keyed by tree-sitter capture name, and how
2
+ dotted capture names fall back to their parents
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections.abc import Mapping
8
+ from typing import TypedDict, TypeVar
9
+
10
+
11
+ class SyntaxSpec(TypedDict, total=False):
12
+ color: str
13
+ italic: bool
14
+ bold: bool
15
+
16
+
17
+ # Tree-sitter capture name -> format
18
+ SyntaxSpecs = dict[str, SyntaxSpec]
19
+
20
+ T = TypeVar("T")
21
+
22
+
23
+ def resolve_capture(name: str, specs: Mapping[str, T]) -> T | None:
24
+ """Look up a treesitter capture name, falling back to its parent groups
25
+
26
+ For example "function.method.call" will check "function.method.call", then
27
+ "function.method", then "function". This mirrors how nvim resolves highlight groups
28
+
29
+ Args:
30
+ name: The dotted treesitter capture name
31
+ specs: A dictionary keyed by capture names
32
+
33
+ Returns:
34
+ The value of the most specific matching key, or None if nothing matches
35
+ """
36
+ parts = name.split(".")
37
+ while parts:
38
+ val = specs.get(".".join(parts))
39
+ if val is not None:
40
+ return val
41
+ parts.pop()
42
+ return None
43
+
44
+
45
+ def overlay_syntax(base: SyntaxSpecs, overrides: SyntaxSpecs) -> SyntaxSpecs:
46
+ """Overlay syntax specs onto a base set of syntax specs
47
+
48
+ Overrides are resolved hierarchically, so an override for "keyword" will replace
49
+ the base "keyword.return" unless there is also an override for "keyword.return".
50
+
51
+ Args:
52
+ base: A syntax spec dictionary as returned by read_theme
53
+ overrides: A partial syntax spec dictionary
54
+
55
+ Returns:
56
+ A new syntax spec dictionary
57
+ """
58
+ names = set(base) | set(overrides)
59
+ out: SyntaxSpecs = {}
60
+ for name in names:
61
+ spec = resolve_capture(name, overrides)
62
+ if spec is None:
63
+ spec = base[name]
64
+ out[name] = spec.copy()
65
+ return out
QCodeSitter/themes.py ADDED
@@ -0,0 +1,271 @@
1
+ """Built-in color themes, and helpers to turn a theme into editor options
2
+
3
+ A theme has an optional "palette" of named colors, "gui" colors for the editor
4
+ and its behaviors (see gui_colors.py), and "syntax" specs keyed by tree-sitter
5
+ capture name (see syntax_specs.py for their format). Gui and syntax colors can
6
+ be hex colors or palette names.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import warnings
12
+ from collections.abc import Mapping
13
+ from typing import TypedDict
14
+
15
+ from Qt import QtGui
16
+
17
+ from .gui_colors import GuiColors
18
+ from .syntax_specs import SyntaxSpecs
19
+
20
+
21
+ class _RequiredThemeSections(TypedDict):
22
+ gui: dict[str, str]
23
+ syntax: SyntaxSpecs
24
+
25
+
26
+ class Theme(_RequiredThemeSections, total=False):
27
+ palette: dict[str, str]
28
+
29
+
30
+ # fmt: off
31
+
32
+ # This colorscheme is pulled from the nvim Dracula theme
33
+ # https://github.com/Mofiqul/dracula.nvim
34
+ DARK_THEME: Theme = {
35
+ "palette": {
36
+ "red": "#FF5555",
37
+ "orange": "#FFB86C",
38
+ "yellow": "#F1FA8C",
39
+ "green": "#50fa7b",
40
+ "purple": "#BD93F9",
41
+ "cyan": "#8BE9FD",
42
+ "pink": "#FF79C6",
43
+ "bright_red": "#FF6E6E",
44
+ "bright_green": "#69FF94",
45
+ "bright_yellow": "#FFFFA5",
46
+ "bright_blue": "#D6ACFF",
47
+ "bright_magenta": "#FF92DF",
48
+ "bright_cyan": "#A4FFFF",
49
+ "bright_white": "#FFFFFF",
50
+ "white": "#ABB2BF",
51
+ "half_mid_gray": "#B4B4B4",
52
+ "mid_gray": "#C8C8C8",
53
+ "gray": "#787878",
54
+ "dark_gray": "#101010",
55
+ "black": "#191A21",
56
+ "cream": "#F8F8F2",
57
+ "xlight_slate": "#8E9CBC",
58
+ "light_slate": "#6272A4",
59
+ "mid_light_slate": "#4C4C5C",
60
+ "slate": "#3E4452",
61
+ "slate2": "#3C3C4C",
62
+ "mid_dark_slate": "#282A36",
63
+ "dark_slate": "#21222C",
64
+ },
65
+
66
+ "gui" : {
67
+ "bg": "mid_dark_slate",
68
+ "bg_dim": "slate2",
69
+ "fg": "cream",
70
+ "fg_dim": "mid_gray",
71
+ "gutter": "dark_gray",
72
+ "gutter_fg": "xlight_slate",
73
+ "hl_dim": "mid_light_slate",
74
+ "manual_fold": "orange",
75
+ "match_hl": "bright_yellow",
76
+ "menu": "dark_slate",
77
+ "outside_bg": "dark_slate",
78
+ "pair_hl": "mid_gray",
79
+ "primary_cursor": "bright_white",
80
+ "secondary_cursor": "half_mid_gray",
81
+ "selection": "mid_light_slate",
82
+ "secondary_selection": "half_mid_gray",
83
+ },
84
+
85
+ "syntax" : {
86
+ "attribute": {"color": "cyan"},
87
+ "attribute.builtin": {"color": "cyan"},
88
+ "boolean": {"color": "purple"},
89
+ "character.special": {"color": "green"},
90
+ "comment": {"color": "light_slate"},
91
+ "constructor": {"color": "cyan"},
92
+ "function": {"color": "green"},
93
+ "function.builtin": {"color": "cyan"},
94
+ "function.call": {"color": "green"},
95
+ "function.macro": {"color": "green"},
96
+ "function.method": {"color": "green"},
97
+ "function.method.call": {"color": "green"},
98
+ "keyword": {"color": "pink"},
99
+ "keyword.conditional": {"color": "pink"},
100
+ "keyword.coroutine": {"color": "pink"},
101
+ "keyword.directive": {"color": "pink"},
102
+ "keyword.exception": {"color": "purple"},
103
+ "keyword.function": {"color": "cyan"},
104
+ "keyword.import": {"color": "pink"},
105
+ "keyword.operator": {"color": "pink"},
106
+ "keyword.repeat": {"color": "pink"},
107
+ "keyword.return": {"color": "pink"},
108
+ "keyword.type": {"color": "pink"},
109
+ "module": {"color": "orange"},
110
+ "module.builtin": {"color": "orange"},
111
+ "none": {"color": "purple"},
112
+ "number": {"color": "purple"},
113
+ "number.float": {"color": "green"},
114
+ "operator": {"color": "pink"},
115
+ "punctuation.bracket": {"color": "cream"},
116
+ "punctuation.delimiter": {"color": "cream"},
117
+ "string": {"color": "yellow"},
118
+ "string.documentation": {"color": "cyan"},
119
+ "string.escape": {"color": "cyan"},
120
+ "string.regexp": {"color": "red"},
121
+ "type": {"color": "bright_cyan"},
122
+ "type.builtin": {"color": "cyan", "italic": True},
123
+ "type.definition": {"color": "bright_cyan"},
124
+ "variable": {"color": "cream"},
125
+ "variable.builtin": {"color": "purple"},
126
+ "variable.member": {"color": "orange"},
127
+ "variable.parameter": {"color": "orange"},
128
+ "constant": {"color": "purple"},
129
+ "constant.builtin": {"color": "purple"},
130
+ },
131
+ }
132
+
133
+ # This colorscheme is pulled from the nvim Edge theme
134
+ # https://github.com/sainnhe/edge
135
+ LIGHT_THEME: Theme = {
136
+ "palette" : {
137
+ 'true_black': '#000000',
138
+ 'true_white': '#FFFFFF',
139
+ 'black': '#DDE2E7',
140
+ 'bg_dim': '#E8EBF0',
141
+ 'bg0': '#FAFAFA',
142
+ 'bg1': '#EEF1F4',
143
+ 'bg2': '#E8EBF0',
144
+ 'bg3': '#E8EBF0',
145
+ 'bg4': '#DDE2E7',
146
+ 'bg_grey': '#BCC5CF',
147
+ 'bg_red': '#F6E4E4',
148
+ 'bg_yellow': '#F0ECE2',
149
+ 'bg_green': '#E5EEE4',
150
+ 'bg_blue': '#E3EAF6',
151
+ 'bg_purple': '#F4E9F8',
152
+ 'filled_red': '#E17373',
153
+ 'filled_green': '#76AF6F',
154
+ 'filled_blue': '#6996E0',
155
+ 'filled_purple': '#BF75D6',
156
+ 'fg': '#4B505B',
157
+ 'red': '#D05858',
158
+ 'yellow': '#BE7E05',
159
+ 'green': '#608E32',
160
+ 'cyan': '#3A8B84',
161
+ 'blue': '#5079BE',
162
+ 'purple': '#B05CCC',
163
+ 'grey': '#8790A0',
164
+ 'grey_dim': '#BAC3CB',
165
+ },
166
+
167
+ "gui" : {
168
+ "bg": "true_white",
169
+ "bg_dim": "bg_dim",
170
+ "fg": "fg",
171
+ "fg_dim": "grey",
172
+ "gutter": "bg2",
173
+ "gutter_fg": "fg",
174
+ "hl_dim": "filled_blue",
175
+ "manual_fold": "yellow",
176
+ "match_hl": "filled_green",
177
+ "menu": "bg4",
178
+ "outside_bg": "bg_grey",
179
+ "pair_hl": "bg_yellow",
180
+ "primary_cursor": "true_black",
181
+ "secondary_cursor": "true_black",
182
+ "selection": "filled_blue",
183
+ "secondary_selection": "blue",
184
+ },
185
+
186
+ "syntax" : {
187
+ "attribute": {"color": "yellow"},
188
+ "attribute.builtin": {"color": "blue"},
189
+ "boolean": {"color": "green"},
190
+ "character.special": {"color": "yellow"},
191
+ "comment": {"color": "grey"},
192
+ "constructor": {"color": "blue"},
193
+ "function": {"color": "blue"},
194
+ "function.builtin": {"color": "blue"},
195
+ "function.call": {"color": "blue"},
196
+ "function.macro": {"color": "blue"},
197
+ "function.method": {"color": "blue"},
198
+ "function.method.call": {"color": "blue"},
199
+ "keyword": {"color": "purple"},
200
+ "keyword.conditional": {"color": "purple"},
201
+ "keyword.coroutine": {"color": "purple"},
202
+ "keyword.directive": {"color": "purple"},
203
+ "keyword.exception": {"color": "purple"},
204
+ "keyword.function": {"color": "purple"},
205
+ "keyword.import": {"color": "purple"},
206
+ "keyword.operator": {"color": "purple"},
207
+ "keyword.repeat": {"color": "purple"},
208
+ "keyword.return": {"color": "purple"},
209
+ "keyword.type": {"color": "yellow"},
210
+ "module": {"color": "yellow"},
211
+ "module.builtin": {"color": "yellow"},
212
+ "none": {"color": "fg"},
213
+ "number": {"color": "green"},
214
+ "number.float": {"color": "green"},
215
+ "operator": {"color": "purple"},
216
+ "punctuation.bracket": {"color": "grey"},
217
+ "punctuation.delimiter": {"color": "grey"},
218
+ "string": {"color": "green"},
219
+ "string.documentation": {"color": "green"},
220
+ "string.escape": {"color": "yellow"},
221
+ "string.regexp": {"color": "yellow"},
222
+ "type": {"color": "yellow"},
223
+ "type.builtin": {"color": "yellow"},
224
+ "type.definition": {"color": "yellow"},
225
+ "variable": {"color": "red", "italic": True},
226
+ "variable.builtin": {"color": "cyan", "italic": True},
227
+ "variable.member": {"color": "cyan"},
228
+ "variable.parameter": {"color": "red", "italic": True},
229
+ "constant": {"color": "red", "italic": True},
230
+ "constant.builtin": {"color": "red", "italic": True},
231
+ },
232
+ }
233
+ # fmt: on
234
+
235
+
236
+ def read_theme(theme: Theme) -> tuple[GuiColors, SyntaxSpecs]:
237
+ """Resolve a theme's palette names into gui colors and syntax specs
238
+
239
+ See the module docstring for the theme format.
240
+
241
+ Returns:
242
+ The gui colors, which also include the palette's names unless a gui
243
+ color has the same name, and the syntax specs
244
+ """
245
+ pal = theme.get("palette", {})
246
+
247
+ gui = {
248
+ **pal,
249
+ **{k: _resolve_color(f"gui {k!r}", v, pal) for k, v in theme["gui"].items()},
250
+ }
251
+
252
+ newsyn: SyntaxSpecs = {}
253
+ for k, vdata_orig in theme["syntax"].items():
254
+ vdata = vdata_orig.copy()
255
+ if "color" in vdata:
256
+ vdata["color"] = _resolve_color(f"syntax {k!r}", vdata["color"], gui)
257
+ newsyn[k] = vdata
258
+
259
+ return gui, newsyn
260
+
261
+
262
+ def _resolve_color(where: str, value: str, names: Mapping[str, str]) -> str:
263
+ """Look up a color name, warning if it's neither a known name nor a color"""
264
+ if value in names:
265
+ return names[value]
266
+ if not QtGui.QColor(value).isValid():
267
+ warnings.warn(
268
+ f"Theme {where} color {value!r} is not a palette name or a color",
269
+ stacklevel=3,
270
+ )
271
+ return value
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from Qt import QtCore, QtGui, QtWidgets
6
+ from tree_sitter import Point
7
+
8
+ if TYPE_CHECKING:
9
+ from tree_sitter import Node
10
+
11
+ # Qt positions count UTF-16 code units, and tree-sitter parses the document as
12
+ # UTF-16LE bytes. Python strings count code points, so they differ for
13
+ # characters outside the BMP, which take two code units
14
+ ENC = "utf-16-le"
15
+
16
+
17
+ def len16(val: str) -> int:
18
+ """Get the number of UTF-16 code units, where surrogate pairs count as 2"""
19
+ return len(val.encode(ENC)) // 2
20
+
21
+
22
+ def utf16_to_index(text: str, col: int) -> int:
23
+ """Convert a UTF-16 column in text (ie: positionInBlock) to a str index
24
+
25
+ A column inside a surrogate pair rounds down to the start of the pair
26
+ """
27
+ return len(text.encode(ENC)[: col * 2].decode(ENC, errors="ignore"))
28
+
29
+
30
+ class TrackedDocument(QtGui.QTextDocument):
31
+ """A QTextDocument that reports its edits in the form tree-sitter expects
32
+
33
+ Qt positions are UTF-16 code units. Tree-sitter parses the document as
34
+ UTF-16LE, so its offsets and Point columns are in bytes, which is always
35
+ 2 bytes per code unit. Every edit made through a TrackedCursor emits
36
+ `byteContentsChange` with those byte offsets.
37
+ """
38
+
39
+ byteContentsChange: QtCore.Signal[tuple[int, int, int, Point, Point, Point]] = (
40
+ QtCore.Signal(int, int, int, Point, Point, Point)
41
+ )
42
+
43
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
44
+ super().__init__(*args, **kwargs)
45
+ self.setDocumentLayout(QtWidgets.QPlainTextDocumentLayout(self))
46
+
47
+ @staticmethod
48
+ def byte_to_char(byte_offset: int) -> int:
49
+ """Convert a UTF-16LE byte offset from tree-sitter to a Qt position"""
50
+ return byte_offset // 2
51
+
52
+ @staticmethod
53
+ def char_to_byte(position: int) -> int:
54
+ """Convert a Qt position to a UTF-16LE byte offset for tree-sitter"""
55
+ return position * 2
56
+
57
+ def row_col_to_char(self, row: int, col: int) -> int:
58
+ """Get the Qt position of a block row and a column in UTF-16 code units"""
59
+ return self.findBlockByNumber(row).position() + col
60
+
61
+ def node_text(self, node: Node) -> str:
62
+ """Get the current text of a tree-sitter node of this document"""
63
+ start, end = node.start_point, node.end_point
64
+ lines = []
65
+ block = self.findBlockByNumber(start.row)
66
+ for row in range(start.row, end.row + 1):
67
+ if not block.isValid():
68
+ break
69
+ data = block.text().encode(ENC)
70
+ first = start.column if row == start.row else 0
71
+ last = end.column if row == end.row else len(data)
72
+ lines.append(data[first:last].decode(ENC, errors="replace"))
73
+ block = block.next()
74
+ return "\n".join(lines)
75
+
76
+ def report_edit(self, position: int, removed: str, added: str) -> None:
77
+ """Emit `byteContentsChange` for an edit at a Qt position
78
+
79
+ The signal carries the start, old end and new end as UTF-16LE byte
80
+ offsets, then the same three as Points with byte columns.
81
+ """
82
+ start_block = self.findBlock(position)
83
+ start_line = start_block.blockNumber()
84
+ start_col = self.char_to_byte(position - start_block.position())
85
+ start_byte = self.char_to_byte(position)
86
+
87
+ def end_point(text: str) -> tuple[int, Point]:
88
+ """Get the end byte and end point of text inserted at position"""
89
+ # QTextCursor.selectedText uses the paragraph separator for newlines
90
+ text = text.replace("
", "\n")
91
+ lines = text.count("\n")
92
+ if lines == 0:
93
+ col = start_col + len(text.encode(ENC))
94
+ else:
95
+ col = len(text[text.rfind("\n") + 1 :].encode(ENC))
96
+ return start_byte + len(text.encode(ENC)), Point(start_line + lines, col)
97
+
98
+ old_end_byte, old_end_point = end_point(removed)
99
+ new_end_byte, new_end_point = end_point(added)
100
+ self.byteContentsChange.emit(
101
+ start_byte,
102
+ old_end_byte,
103
+ new_end_byte,
104
+ Point(start_line, start_col),
105
+ old_end_point,
106
+ new_end_point,
107
+ )