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.
- QCodeSitter/__init__.py +38 -0
- QCodeSitter/behaviors/__init__.py +115 -0
- QCodeSitter/behaviors/auto_bracket.py +141 -0
- QCodeSitter/behaviors/code_folding.py +530 -0
- QCodeSitter/behaviors/comment_toggle.py +71 -0
- QCodeSitter/behaviors/highlight_matching_brackets.py +181 -0
- QCodeSitter/behaviors/highlight_matching_selection.py +59 -0
- QCodeSitter/behaviors/line_numbers.py +49 -0
- QCodeSitter/behaviors/multi_cursor_paint.py +131 -0
- QCodeSitter/behaviors/overscroll.py +104 -0
- QCodeSitter/behaviors/smart_indent.py +191 -0
- QCodeSitter/behaviors/syntax_highlighting.py +181 -0
- QCodeSitter/behaviors/tab_completion/__init__.py +257 -0
- QCodeSitter/behaviors/tab_completion/popup.py +123 -0
- QCodeSitter/behaviors/tab_completion/providers.py +66 -0
- QCodeSitter/behaviors/tab_completion/types.py +55 -0
- QCodeSitter/code_editor.py +540 -0
- QCodeSitter/cursors/__init__.py +8 -0
- QCodeSitter/cursors/clipboard.py +51 -0
- QCodeSitter/cursors/iterator.py +141 -0
- QCodeSitter/cursors/line_edits.py +128 -0
- QCodeSitter/cursors/multi.py +240 -0
- QCodeSitter/cursors/state.py +55 -0
- QCodeSitter/cursors/tracked.py +97 -0
- QCodeSitter/defaults.py +93 -0
- QCodeSitter/editing_keys.py +150 -0
- QCodeSitter/editor_options.py +246 -0
- QCodeSitter/gui_colors.py +51 -0
- QCodeSitter/gutters.py +141 -0
- QCodeSitter/integrations/__init__.py +1 -0
- QCodeSitter/integrations/preditor_interface.py +390 -0
- QCodeSitter/languages/__init__.py +99 -0
- QCodeSitter/languages/base.py +60 -0
- QCodeSitter/languages/python.py +146 -0
- QCodeSitter/languages/python_highlights.py +444 -0
- QCodeSitter/py.typed +0 -0
- QCodeSitter/queries/__init__.py +73 -0
- QCodeSitter/queries/predicates.py +172 -0
- QCodeSitter/syntax_specs.py +65 -0
- QCodeSitter/themes.py +271 -0
- QCodeSitter/tracked_document.py +107 -0
- QCodeSitter/tree_manager.py +180 -0
- QCodeSitter/utils.py +84 -0
- qcodesitter-0.1.0.dist-info/METADATA +255 -0
- qcodesitter-0.1.0.dist-info/RECORD +49 -0
- qcodesitter-0.1.0.dist-info/WHEEL +5 -0
- qcodesitter-0.1.0.dist-info/entry_points.txt +2 -0
- qcodesitter-0.1.0.dist-info/licenses/LICENSE.txt +7 -0
- 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(), ""
|