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,141 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator
4
+ from contextlib import contextmanager
5
+ from typing import TYPE_CHECKING
6
+
7
+ from Qt import QtGui
8
+
9
+ from .state import CursorState, primary_first
10
+
11
+ if TYPE_CHECKING:
12
+ from ..code_editor import CodeEditor
13
+ from .tracked import TrackedCursor
14
+
15
+
16
+ class CursorIterator:
17
+ """Runs an edit at every cursor, in document order, as one undo step
18
+
19
+ Each pass of `iterate_cursors` moves a single cursor to each cursor state.
20
+ Every state follows the edits made at the other cursors, the way a
21
+ QTextCursor does.
22
+
23
+ A pass can mark a cursor as completed, and later passes in the same
24
+ `session` skip it. That lets behaviors handle some cursors and leave the
25
+ rest to the next handler. Replacing the editor's cursors during a session
26
+ starts over with the new cursors, none of them completed, and ends the
27
+ pass that replaced them.
28
+ """
29
+
30
+ def __init__(self, editor: CodeEditor) -> None:
31
+ self.editor = editor
32
+ # Whether the cursor being yielded was marked completed
33
+ self._current_completed = False
34
+ # The cursors of the session, and whether each one is completed
35
+ self._states: list[CursorState] = []
36
+ self._completed: list[bool] = []
37
+ self._primary_index: int = 0
38
+ # Set while a pass writes its cursors back, which isn't a replacement
39
+ self._writing_back = False
40
+ self.active = False
41
+ editor.cursors.changed.connect(self._on_cursors_changed)
42
+
43
+ def snapshot_cursors(self) -> None:
44
+ """Take the editor's current cursors, and clear their completed flags"""
45
+ self._states, self._primary_index = self.editor.cursors.in_document_order()
46
+ self._completed = [False] * len(self._states)
47
+
48
+ def _on_cursors_changed(self) -> None:
49
+ if self.active and not self._writing_back:
50
+ self.snapshot_cursors()
51
+
52
+ @contextmanager
53
+ def session(self) -> Iterator[None]:
54
+ """Run several iterate_cursors passes as a single operation
55
+
56
+ The cursors are snapshot once at the start, and any cursor completed
57
+ by one pass is skipped by the passes after it
58
+ """
59
+ self.snapshot_cursors()
60
+ self.active = True
61
+ try:
62
+ yield
63
+ finally:
64
+ self.active = False
65
+
66
+ def mark_cursor_completed(self) -> None:
67
+ """Mark the current cursor as completed. Only useful inside iterate_cursors"""
68
+ self._current_completed = True
69
+
70
+ def all_completed(self) -> bool:
71
+ """Whether every cursor in the current session has been completed"""
72
+ return all(self._completed)
73
+
74
+ def read_cursors(self) -> Iterator[QtGui.QTextCursor]:
75
+ """Yield a cursor at each non-completed cursor state, to read the document
76
+
77
+ These are in the same order as `iterate_cursors`, but moving them doesn't
78
+ move the editor's cursors, and editing with them isn't tracked
79
+ """
80
+ if not self.active:
81
+ self.snapshot_cursors()
82
+ qt_cursor = QtGui.QTextCursor(self.editor.document())
83
+ for state, completed in zip(self._states, self._completed):
84
+ if not completed:
85
+ yield state.apply(qt_cursor)
86
+
87
+ def iterate_cursors(
88
+ self, join_edit: bool = False, complete_all: bool = False
89
+ ) -> Iterator[TrackedCursor]:
90
+ """Yield a cursor at each non-completed cursor state, in one edit block
91
+
92
+ Args:
93
+ join_edit: Join the edits onto the previous undo step
94
+ complete_all: Mark every yielded cursor completed, for a pass that
95
+ handles every cursor
96
+ """
97
+ if not self.active:
98
+ # A one-off edit outside of a session works on the current cursors
99
+ self.snapshot_cursors()
100
+ states, completed = self._states, self._completed
101
+
102
+ document = self.editor.document()
103
+ qt_cursor = self.editor.textCursor()
104
+ # Qt moves these with every edit, even one that deletes past them, so
105
+ # they always hold where each cursor is now
106
+ trackers = [state.build_cursor(document) for state in states]
107
+ if join_edit:
108
+ qt_cursor.joinPreviousEditBlock()
109
+ else:
110
+ qt_cursor.beginEditBlock()
111
+
112
+ yielded_any = False
113
+ try:
114
+ for i, tracker in enumerate(trackers):
115
+ if completed[i]:
116
+ continue
117
+
118
+ CursorState.from_cursor(tracker).apply(qt_cursor)
119
+ self._current_completed = False
120
+ yield qt_cursor
121
+ if self._states is not states:
122
+ # The loop body replaced the cursors, so the old ones are stale
123
+ return
124
+ CursorState.from_cursor(qt_cursor).apply(tracker)
125
+ completed[i] = complete_all or self._current_completed
126
+ yielded_any = True
127
+ finally:
128
+ qt_cursor.endEditBlock()
129
+ # Later edits can move the cursors before them too
130
+ if self._states is states:
131
+ for state, tracker in zip(states, trackers):
132
+ state.copy_from(tracker)
133
+
134
+ if yielded_any:
135
+ self._writing_back = True
136
+ try:
137
+ self.editor.cursors.replace_all(
138
+ primary_first(self._states, self._primary_index)
139
+ )
140
+ finally:
141
+ self._writing_back = False
@@ -0,0 +1,128 @@
1
+ """Multi-cursor edits that join the cursors sharing a line or a span"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import TYPE_CHECKING
7
+
8
+ from Qt import QtGui
9
+
10
+ from ..tracked_document import len16
11
+ from ..utils import MM, MO
12
+
13
+ if TYPE_CHECKING:
14
+ from .iterator import CursorIterator
15
+
16
+
17
+ def expand_to_lines(cursor: QtGui.QTextCursor) -> None:
18
+ """Expand a cursor selection to whole lines
19
+ If there is no selection, expand to the current line
20
+ """
21
+ start = cursor.selectionStart()
22
+ end = cursor.selectionEnd()
23
+ cursor.setPosition(start)
24
+ cursor.movePosition(MO.StartOfLine)
25
+ cursor.setPosition(end, MM.KeepAnchor)
26
+ cursor.movePosition(MO.EndOfLine, MM.KeepAnchor)
27
+
28
+
29
+ def replace_lines(
30
+ cursor: QtGui.QTextCursor, transform: Callable[[list[str]], list[str]]
31
+ ) -> None:
32
+ """Replace the whole lines under the cursor, and select the new lines
33
+
34
+ Args:
35
+ cursor: The cursor whose selection (or current line) to replace
36
+ transform: Takes the lines, without their newlines, and returns new ones
37
+ """
38
+ expand_to_lines(cursor)
39
+ start = cursor.selectionStart()
40
+ lines = cursor.selection().toPlainText().split("\n")
41
+ new_text = "\n".join(transform(lines))
42
+ cursor.insertText(new_text)
43
+ cursor.setPosition(start)
44
+ cursor.setPosition(start + len16(new_text), MM.KeepAnchor)
45
+
46
+
47
+ def edit_joined_spans(
48
+ citer: CursorIterator,
49
+ span_of: Callable[[QtGui.QTextCursor], tuple[int, int]],
50
+ edit_join: Callable[[QtGui.QTextCursor, tuple[int, int], int], None],
51
+ ) -> None:
52
+ """Edit the spans of every cursor, once for each group of spans that overlap or touch
53
+
54
+ The first cursor of each join makes the edit, and the other cursors in the
55
+ join take its selection afterwards, so the cursors merge
56
+
57
+ Args:
58
+ citer: The editor's cursor iterator
59
+ span_of: Gives the (start, end) span to edit for a cursor. It may move
60
+ the cursor
61
+ edit_join: Edits a joined span at the first cursor of the join. The int
62
+ is how far the edits at the joins before it have moved this cursor
63
+ """
64
+ starts = []
65
+ spans = []
66
+ for cursor in citer.read_cursors():
67
+ starts.append(cursor.selectionStart())
68
+ spans.append(span_of(cursor))
69
+
70
+ # A span can start before the span of the cursor before it, but then it
71
+ # overlaps it, so the joins are still in the order of the cursors
72
+ joined: list[tuple[int, int]] = []
73
+ joins = [0] * len(spans)
74
+ for i in sorted(range(len(spans)), key=lambda i: spans[i][0]):
75
+ start, end = spans[i]
76
+ if joined and start <= joined[-1][1]:
77
+ joined[-1] = (joined[-1][0], max(joined[-1][1], end))
78
+ else:
79
+ joined.append((start, end))
80
+ joins[i] = len(joined) - 1
81
+
82
+ # The iterator goes first, so it runs to its end and writes the cursors back
83
+ edited_join = -1
84
+ edited = (0, 0)
85
+ for (i, cursor), join in zip(
86
+ enumerate(citer.iterate_cursors(complete_all=True)), joins
87
+ ):
88
+ if join != edited_join:
89
+ edit_join(cursor, joined[join], cursor.selectionStart() - starts[i])
90
+ edited_join = join
91
+ edited = (cursor.anchor(), cursor.position())
92
+ else:
93
+ cursor.setPosition(edited[0])
94
+ cursor.setPosition(edited[1], MM.KeepAnchor)
95
+
96
+
97
+ def replace_lines_at_cursors(
98
+ citer: CursorIterator, transform: Callable[[list[str]], list[str]]
99
+ ) -> None:
100
+ """Replace the whole lines under every cursor, and select the new lines
101
+
102
+ Cursors that share a line are joined, so each line is replaced once. Each
103
+ cursor in a join selects all of its new lines, so the cursors merge
104
+
105
+ Args:
106
+ citer: The editor's cursor iterator
107
+ transform: Takes the lines, without their newlines, and returns new ones
108
+ """
109
+
110
+ def line_span(cursor: QtGui.QTextCursor) -> tuple[int, int]:
111
+ document = cursor.document()
112
+ first = document.findBlock(cursor.selectionStart()).blockNumber()
113
+ last = document.findBlock(cursor.selectionEnd()).blockNumber()
114
+ return first, last
115
+
116
+ def replace_join(cursor: QtGui.QTextCursor, span: tuple[int, int], _: int) -> None:
117
+ # The first cursor of a join is on its first line, wherever the
118
+ # edits at the joins before it have moved that line to
119
+ first, last = span
120
+ start_block = cursor.document().findBlock(cursor.selectionStart())
121
+ end_block = cursor.document().findBlockByNumber(
122
+ start_block.blockNumber() + last - first
123
+ )
124
+ cursor.setPosition(start_block.position())
125
+ cursor.setPosition(end_block.position(), MM.KeepAnchor)
126
+ replace_lines(cursor, transform)
127
+
128
+ edit_joined_spans(citer, line_span, replace_join)
@@ -0,0 +1,240 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import TYPE_CHECKING
5
+
6
+ from Qt import QtCore, QtGui
7
+
8
+ from ..tracked_document import len16, utf16_to_index
9
+ from ..utils import MM, MO, leading_whitespace
10
+ from .state import CursorState, primary_first
11
+
12
+ if TYPE_CHECKING:
13
+ from ..code_editor import CodeEditor
14
+
15
+
16
+ def _merge_overlapping(cursors: list[CursorState]) -> list[CursorState]:
17
+ """Merge cursors that overlap, keeping the first cursor first
18
+
19
+ Cursors that only touch merge when at least one of them has no selection,
20
+ so two selections side by side stay separate
21
+
22
+ A merged selection covers both selections, and is backward if either was
23
+ """
24
+ if len(cursors) <= 1:
25
+ return cursors
26
+
27
+ first = cursors[0]
28
+ merged: list[CursorState] = []
29
+ primary_index = 0
30
+ for cursor in sorted(cursors, key=lambda c: c.selectionStart()):
31
+ last = merged[-1] if merged else None
32
+ if last is not None and (
33
+ cursor.selectionStart() < last.selectionEnd()
34
+ or (
35
+ cursor.selectionStart() == last.selectionEnd()
36
+ and not (cursor.hasSelection() and last.hasSelection())
37
+ )
38
+ ):
39
+ start = last.selectionStart()
40
+ end = max(last.selectionEnd(), cursor.selectionEnd())
41
+ if last.is_backward() or cursor.is_backward():
42
+ merged[-1] = CursorState(end, start)
43
+ else:
44
+ merged[-1] = CursorState(start, end)
45
+ else:
46
+ merged.append(cursor)
47
+ if cursor is first:
48
+ primary_index = len(merged) - 1
49
+ return primary_first(merged, primary_index)
50
+
51
+
52
+ def _smart_home(qt_cursor: QtGui.QTextCursor, mode: MM) -> None:
53
+ """Move to the first non-whitespace character, or column 0 if already there"""
54
+ block = qt_cursor.block()
55
+ line_start = block.position()
56
+ text = block.text()
57
+ # Whitespace is never a surrogate pair, so its length is its UTF-16 length
58
+ indent_end = line_start + len(leading_whitespace(text))
59
+
60
+ target = line_start if qt_cursor.position() == indent_end else indent_end
61
+ qt_cursor.setPosition(target, mode)
62
+
63
+
64
+ class MultiCursor(QtCore.QObject):
65
+ """The editor's cursors, and the commands that add and move them
66
+
67
+ Qt only knows about the primary cursor. The others are kept here as
68
+ CursorStates, and drawn by the MultiCursorPaint behavior. The commands that
69
+ add cursors return whether they added one.
70
+ """
71
+
72
+ changed = QtCore.Signal()
73
+
74
+ def __init__(self, editor: CodeEditor) -> None:
75
+ super().__init__(editor)
76
+ self.editor = editor
77
+ self.secondary: list[CursorState] = []
78
+
79
+ def primary(self) -> CursorState:
80
+ """Snapshot Qt's primary cursor as a CursorState"""
81
+ return CursorState.from_cursor(self.editor.textCursor())
82
+
83
+ def set_primary(self, state: CursorState) -> None:
84
+ self.editor.setTextCursor(state.apply(self.editor.textCursor()))
85
+
86
+ def in_document_order(self) -> tuple[list[CursorState], int]:
87
+ """Get every cursor sorted by position, and the index of the primary one"""
88
+ primary = self.primary()
89
+ ret = sorted([primary, *self.secondary], key=lambda c: c.selectionStart())
90
+ return ret, ret.index(primary)
91
+
92
+ def _primary_first(self) -> list[CursorState]:
93
+ return primary_first(*self.in_document_order())
94
+
95
+ def replace_all(self, cursors: list[CursorState]) -> None:
96
+ """Replace every cursor, merging any that overlap
97
+
98
+ The first cursor becomes the primary, the rest become secondaries.
99
+ """
100
+ if not cursors:
101
+ self.clear_secondary()
102
+ return
103
+ merged = _merge_overlapping(cursors)
104
+ self.set_primary(merged[0])
105
+ self.secondary = merged[1:]
106
+ self.changed.emit()
107
+
108
+ def clear_secondary(self) -> None:
109
+ """Leave multi-cursor mode, keeping only the primary cursor"""
110
+ self.secondary.clear()
111
+ self.changed.emit()
112
+
113
+ def add_next_occurrence(self) -> bool:
114
+ """Add a cursor selecting the next occurrence of the primary selection
115
+
116
+ With no selection, select the word under the cursor first.
117
+ Returns whether a cursor was added
118
+ """
119
+ cursor = self.editor.textCursor()
120
+ if not cursor.hasSelection():
121
+ cursor.select(QtGui.QTextCursor.SelectionType.WordUnderCursor)
122
+ if not cursor.hasSelection():
123
+ return False
124
+ self.editor.setTextCursor(cursor)
125
+
126
+ search_text = cursor.selectedText()
127
+ all_cursors = self._primary_first()
128
+ next_pos = self._find(search_text, max(c.selectionEnd() for c in all_cursors))
129
+ if next_pos < 0:
130
+ # Wrap around to the start, but only up to the first cursor
131
+ next_pos = self._find(search_text, 0)
132
+ if next_pos < 0 or next_pos >= min(c.selectionStart() for c in all_cursors):
133
+ return False
134
+ end = next_pos + len16(search_text)
135
+ return self._add([*all_cursors, CursorState(next_pos, end)])
136
+
137
+ def _find(self, search_text: str, start_pos: int) -> int:
138
+ """Find the position of search_text after start_pos, or -1 if there is none
139
+
140
+ QTextDocument.find only matches within one block, so search the raw
141
+ text instead. Like selectedText, it separates blocks with U+2029, so
142
+ multi-line selections match too
143
+ """
144
+ text = self.editor.document().toRawText()
145
+ found = text.find(search_text, utf16_to_index(text, start_pos))
146
+ return -1 if found < 0 else len16(text[:found])
147
+
148
+ def move(self, operation: MO, select: bool = False) -> None:
149
+ """Move every cursor, like QTextCursor.movePosition
150
+
151
+ Args:
152
+ operation: Where to move each cursor to
153
+ select: Extend the selections, like holding Shift
154
+ """
155
+ mode = MM.KeepAnchor if select else MM.MoveAnchor
156
+ self._move_each(lambda qt_cursor: qt_cursor.movePosition(operation, mode))
157
+
158
+ def smart_home(self, select: bool = False) -> None:
159
+ """Move every cursor to its line's indent, or to column 0 if it's there"""
160
+ mode = MM.KeepAnchor if select else MM.MoveAnchor
161
+ self._move_each(lambda qt_cursor: _smart_home(qt_cursor, mode))
162
+
163
+ def _move_each(self, move: Callable[[QtGui.QTextCursor], object]) -> None:
164
+ qt_cursor = QtGui.QTextCursor(self.editor.document())
165
+ moved = []
166
+ for state in self._primary_first():
167
+ state.apply(qt_cursor)
168
+ move(qt_cursor)
169
+ moved.append(CursorState.from_cursor(qt_cursor))
170
+ self.replace_all(moved)
171
+
172
+ def selected_texts(self) -> list[str]:
173
+ """Get the selected text of every cursor, in document order"""
174
+ all_cursors, _primary_index = self.in_document_order()
175
+ qt_cursor = QtGui.QTextCursor(self.editor.document())
176
+ return [
177
+ state.apply(qt_cursor).selectedText().replace("
", "\n")
178
+ for state in all_cursors
179
+ ]
180
+
181
+ def _add_adjacent(self, above: bool) -> bool:
182
+ """Move the primary cursor up or down a line, leaving a cursor behind"""
183
+ current_position = self.primary().position
184
+ block = self.editor.document().findBlock(current_position)
185
+ adj_block = block.previous() if above else block.next()
186
+ if not block.isValid() or not adj_block.isValid():
187
+ return False
188
+
189
+ col = min(current_position - block.position(), len16(adj_block.text()))
190
+ new_primary_pos = adj_block.position() + col
191
+ return self._add(
192
+ [
193
+ CursorState(new_primary_pos, new_primary_pos),
194
+ *self.secondary,
195
+ CursorState(current_position, current_position),
196
+ ]
197
+ )
198
+
199
+ def add_above(self) -> bool:
200
+ """Move the primary cursor up a line, leaving a cursor behind"""
201
+ return self._add_adjacent(True)
202
+
203
+ def add_below(self) -> bool:
204
+ """Move the primary cursor down a line, leaving a cursor behind"""
205
+ return self._add_adjacent(False)
206
+
207
+ def add_to_line_ends(self) -> bool:
208
+ """Replace the selection with a cursor at the end of each of its lines
209
+
210
+ Returns whether there are more cursors than before
211
+ """
212
+ cursor = self.editor.textCursor()
213
+ if not cursor.hasSelection():
214
+ return False
215
+
216
+ doc = self.editor.document()
217
+ block = doc.findBlock(cursor.selectionStart())
218
+ end_block = doc.findBlock(cursor.selectionEnd())
219
+ cursors = []
220
+ while block.isValid():
221
+ line_end = block.position() + len16(block.text())
222
+ cursors.append(CursorState(line_end, line_end))
223
+ if block == end_block:
224
+ break
225
+ block = block.next()
226
+ return self._add(cursors)
227
+
228
+ def add_at(self, position: int) -> bool:
229
+ """Add a cursor at a position, like Alt+Click"""
230
+ return self._add([*self._primary_first(), CursorState(position, position)])
231
+
232
+ def _add(self, cursors: list[CursorState]) -> bool:
233
+ """Replace every cursor, and return whether there are more than before
234
+
235
+ A new cursor that overlaps another one merges into it, as does one
236
+ that touches another when either of them has no selection
237
+ """
238
+ count = len(self.secondary)
239
+ self.replace_all(cursors)
240
+ return len(self.secondary) > count
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from Qt import QtGui
6
+
7
+ from ..utils import MM
8
+
9
+
10
+ @dataclass
11
+ class CursorState:
12
+ """A cursor's anchor and position in UTF-16 code units
13
+
14
+ The selection methods mirror QTextCursor's names, so code can take either
15
+ """
16
+
17
+ anchor: int
18
+ position: int
19
+
20
+ @classmethod
21
+ def from_cursor(cls, cursor: QtGui.QTextCursor) -> CursorState:
22
+ return cls(cursor.anchor(), cursor.position())
23
+
24
+ def hasSelection(self) -> bool:
25
+ return self.anchor != self.position
26
+
27
+ def is_backward(self) -> bool:
28
+ """Whether the selection was made from right to left"""
29
+ return self.anchor > self.position
30
+
31
+ def selectionStart(self) -> int:
32
+ return min(self.anchor, self.position)
33
+
34
+ def selectionEnd(self) -> int:
35
+ return max(self.anchor, self.position)
36
+
37
+ def apply(self, cursor: QtGui.QTextCursor) -> QtGui.QTextCursor:
38
+ """Move a QTextCursor to this state"""
39
+ cursor.setPosition(self.anchor)
40
+ cursor.setPosition(self.position, MM.KeepAnchor)
41
+ return cursor
42
+
43
+ def copy_from(self, cursor: QtGui.QTextCursor) -> None:
44
+ self.anchor = cursor.anchor()
45
+ self.position = cursor.position()
46
+
47
+ def build_cursor(self, document: QtGui.QTextDocument) -> QtGui.QTextCursor:
48
+ return self.apply(QtGui.QTextCursor(document))
49
+
50
+
51
+ def primary_first(states: list[CursorState], primary_index: int) -> list[CursorState]:
52
+ """Copy the states, moving the primary cursor's state to the front"""
53
+ states = states[:]
54
+ states.insert(0, states.pop(primary_index))
55
+ return states
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, NoReturn
4
+
5
+ from Qt import QtGui
6
+
7
+ if TYPE_CHECKING:
8
+ from ..tracked_document import TrackedDocument
9
+
10
+
11
+ class TrackedCursor(QtGui.QTextCursor):
12
+ """A QTextCursor that reports its text edits to its TrackedDocument
13
+
14
+ insertText, insertBlock, removeSelectedText, deleteChar and
15
+ deletePreviousChar are reported, so the tree can follow them. The rich text
16
+ inserts, like insertHtml, raise instead of editing behind the tree's back
17
+ """
18
+
19
+ def __init__(self, document: TrackedDocument, cursor: QtGui.QTextCursor) -> None:
20
+ self._doc = document
21
+ super().__init__(cursor)
22
+
23
+ def deleteChar(self) -> None:
24
+ if self.hasSelection():
25
+ self.removeSelectedText()
26
+ return
27
+ ttc = QtGui.QTextCursor(self)
28
+ ttc.movePosition(ttc.MoveOperation.NextCharacter, ttc.MoveMode.KeepAnchor)
29
+ removed = ttc.selectedText()
30
+ position = self.position()
31
+ super().deleteChar()
32
+ self._doc.report_edit(position, removed, "")
33
+
34
+ def deletePreviousChar(self) -> None:
35
+ if self.hasSelection():
36
+ self.removeSelectedText()
37
+ return
38
+ ttc = QtGui.QTextCursor(self)
39
+ ttc.movePosition(ttc.MoveOperation.PreviousCharacter, ttc.MoveMode.KeepAnchor)
40
+ removed = ttc.selectedText()
41
+ position = ttc.position()
42
+ super().deletePreviousChar()
43
+ self._doc.report_edit(position, removed, "")
44
+
45
+ def removeSelectedText(self) -> None:
46
+ removed = self.selectedText()
47
+ position = self.selectionStart()
48
+ super().removeSelectedText()
49
+ self._doc.report_edit(position, removed, "")
50
+
51
+ def insertBlock(self, *args: Any, **kwargs: Any) -> None:
52
+ position, removed = self._replaced_span()
53
+ super().insertBlock(*args, **kwargs)
54
+ self._doc.report_edit(position, removed, "\n")
55
+
56
+ def insertText(
57
+ self, text: str, format: QtGui.QTextCharFormat | None = None
58
+ ) -> None:
59
+ position, removed = self._replaced_span()
60
+ if format is not None:
61
+ super().insertText(text, format)
62
+ else:
63
+ super().insertText(text)
64
+ self._doc.report_edit(position, removed, text)
65
+
66
+ def _untracked(self, name: str) -> NoReturn:
67
+ raise NotImplementedError(
68
+ f"TrackedCursor.{name} would edit the document without updating "
69
+ "the tree, use insertText"
70
+ )
71
+
72
+ def insertFragment(self, *args: object, **kwargs: object) -> NoReturn:
73
+ self._untracked("insertFragment")
74
+
75
+ def insertFrame(self, *args: object, **kwargs: object) -> NoReturn:
76
+ self._untracked("insertFrame")
77
+
78
+ def insertHtml(self, *args: object, **kwargs: object) -> NoReturn:
79
+ self._untracked("insertHtml")
80
+
81
+ def insertImage(self, *args: object, **kwargs: object) -> NoReturn:
82
+ self._untracked("insertImage")
83
+
84
+ def insertList(self, *args: object, **kwargs: object) -> NoReturn:
85
+ self._untracked("insertList")
86
+
87
+ def insertMarkdown(self, *args: object, **kwargs: object) -> NoReturn:
88
+ self._untracked("insertMarkdown")
89
+
90
+ def insertTable(self, *args: object, **kwargs: object) -> NoReturn:
91
+ self._untracked("insertTable")
92
+
93
+ def _replaced_span(self) -> tuple[int, str]:
94
+ """Where an insert starts, and the selected text it replaces"""
95
+ if self.hasSelection():
96
+ return self.selectionStart(), self.selectedText()
97
+ return self.position(), ""