side-vim 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.
- side_vim/__init__.py +7 -0
- side_vim/__main__.py +146 -0
- side_vim/_unicode_data.py +1469 -0
- side_vim/caret.py +131 -0
- side_vim/edits.py +84 -0
- side_vim/keys.py +134 -0
- side_vim/layouts.py +13 -0
- side_vim/mode.py +10 -0
- side_vim/motions.py +428 -0
- side_vim/notation.py +8 -0
- side_vim/parser.py +185 -0
- side_vim/scroll.py +98 -0
- side_vim/search.py +118 -0
- side_vim/text.py +146 -0
- side_vim/unicode.py +31 -0
- side_vim/vim.py +1398 -0
- side_vim-0.1.0.dist-info/METADATA +212 -0
- side_vim-0.1.0.dist-info/RECORD +20 -0
- side_vim-0.1.0.dist-info/WHEEL +4 -0
- side_vim-0.1.0.dist-info/licenses/LICENSE +21 -0
side_vim/vim.py
ADDED
|
@@ -0,0 +1,1398 @@
|
|
|
1
|
+
"""VimMode: vim keys for a QPlainTextEdit. The only module that talks to the editor.
|
|
2
|
+
|
|
3
|
+
It sits on the editor as an event filter. In Normal mode it takes the keys, turns them
|
|
4
|
+
into commands with the parser, and moves the cursor or edits the text. In Insert mode
|
|
5
|
+
the editor types as usual, and only Esc (or Ctrl+[) is taken.
|
|
6
|
+
|
|
7
|
+
In Visual mode the selection is the editor's own, set to exactly what vim selects, and
|
|
8
|
+
caret.Caret draws vim's cursor over it. A mouse drag in Normal mode starts Visual mode.
|
|
9
|
+
The Caret also draws the cursor in a read-only editor, where Qt draws none.
|
|
10
|
+
|
|
11
|
+
Outside Insert mode, keys typed on the Ukrainian layout work as the Latin keys in the
|
|
12
|
+
same place (layout_map), so `к` is `r` and Ctrl+к is Ctrl+R.
|
|
13
|
+
|
|
14
|
+
Every position goes through text.Lines, which works in whole characters (a family
|
|
15
|
+
emoji is one) and converts to Qt's UTF-16 positions.
|
|
16
|
+
|
|
17
|
+
Undo uses the document's own undo stack, so the app's undo (Ctrl+Z in Insert mode, an
|
|
18
|
+
Edit menu) and vim's `u` see the same steps. Each Normal mode command is one edit
|
|
19
|
+
block, so one step. An insert session (from `i`, `o` or `cw` to Esc) is one step too:
|
|
20
|
+
Qt splits typing into several steps, or adds it to the step before, so at Esc the
|
|
21
|
+
session's steps are undone and its whole change is made again as one block. An app
|
|
22
|
+
that watches textChanged sees the text change a few times then, ending as it was.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
26
|
+
from contextlib import contextmanager, suppress
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import overload
|
|
29
|
+
|
|
30
|
+
from PySide6.QtCore import QEvent, QObject, Qt, QTimer, Signal
|
|
31
|
+
from PySide6.QtGui import (
|
|
32
|
+
QGuiApplication,
|
|
33
|
+
QInputMethodEvent,
|
|
34
|
+
QKeyEvent,
|
|
35
|
+
QKeySequence,
|
|
36
|
+
QTextCursor,
|
|
37
|
+
QTextDocument,
|
|
38
|
+
)
|
|
39
|
+
from PySide6.QtWidgets import QPlainTextEdit
|
|
40
|
+
|
|
41
|
+
from side_vim.caret import Caret, char_width
|
|
42
|
+
from side_vim.edits import Register, join, join_parts, undo_cursor, visual_start
|
|
43
|
+
from side_vim.keys import ESC, key_token, map_layout
|
|
44
|
+
from side_vim.layouts import UKRAINIAN
|
|
45
|
+
from side_vim.mode import Mode
|
|
46
|
+
from side_vim.motions import (
|
|
47
|
+
BLANKS,
|
|
48
|
+
Pos,
|
|
49
|
+
Range,
|
|
50
|
+
column_for,
|
|
51
|
+
first_non_blank,
|
|
52
|
+
line_range,
|
|
53
|
+
move,
|
|
54
|
+
operator_range,
|
|
55
|
+
range_text,
|
|
56
|
+
screen_col,
|
|
57
|
+
visual_range,
|
|
58
|
+
)
|
|
59
|
+
from side_vim.parser import (
|
|
60
|
+
CTRL_KEYS,
|
|
61
|
+
INSERT_COMMANDS,
|
|
62
|
+
LINE,
|
|
63
|
+
OPERATORS,
|
|
64
|
+
Command,
|
|
65
|
+
Parser,
|
|
66
|
+
changes_text,
|
|
67
|
+
)
|
|
68
|
+
from side_vim.scroll import SCROLL_KEYS, scroll
|
|
69
|
+
from side_vim.search import search
|
|
70
|
+
from side_vim.text import PARAGRAPH_SEPARATOR, Lines, split_clusters, utf16_len
|
|
71
|
+
|
|
72
|
+
VISUAL_MODES = frozenset({Mode.VISUAL, Mode.VISUAL_LINE})
|
|
73
|
+
|
|
74
|
+
KEEP_ANCHOR = QTextCursor.MoveMode.KeepAnchor
|
|
75
|
+
|
|
76
|
+
# Qt's own keys that change the text, like Ctrl+V. Outside Insert mode they do nothing,
|
|
77
|
+
# so only vim's commands edit there.
|
|
78
|
+
QT_EDIT_KEYS = (
|
|
79
|
+
QKeySequence.StandardKey.Paste,
|
|
80
|
+
QKeySequence.StandardKey.Cut,
|
|
81
|
+
QKeySequence.StandardKey.DeleteStartOfWord,
|
|
82
|
+
QKeySequence.StandardKey.DeleteEndOfWord,
|
|
83
|
+
QKeySequence.StandardKey.DeleteEndOfLine,
|
|
84
|
+
QKeySequence.StandardKey.DeleteCompleteLine,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# The editor draws its cursor only if one of these is on.
|
|
88
|
+
DRAWS_CURSOR = Qt.TextInteractionFlag.TextEditable | Qt.TextInteractionFlag.TextSelectableByKeyboard
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# The unnamed register is shared by all editors, so yanks paste between them.
|
|
92
|
+
_register = Register()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def takes_nothing(r: Range, no_lines: bool) -> bool:
|
|
96
|
+
"""True if a delete or change over r has nothing to take: an empty part of a line,
|
|
97
|
+
or a text with no lines at all (see VimMode._no_lines). A line that x emptied is
|
|
98
|
+
still a line, and dd takes its line break."""
|
|
99
|
+
return no_lines or (not r.linewise and r.start == r.end)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
type Version = tuple[QTextDocument, int] # a document and its revision
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class UndoStep:
|
|
107
|
+
"""An undo step vim knows. cursor: where `u` puts the cursor (None: unknown).
|
|
108
|
+
no_lines: whether the text on the other side of the step has no lines. vim keeps
|
|
109
|
+
that with each step too, so `u` and Ctrl+R bring it back. in_qt: False for a step
|
|
110
|
+
Qt doesn't have, since the text stayed the same: dd on the only line, once x
|
|
111
|
+
emptied it, goes from one empty line to no lines."""
|
|
112
|
+
|
|
113
|
+
cursor: Pos | None
|
|
114
|
+
no_lines: bool
|
|
115
|
+
in_qt: bool = True
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class _Insert:
|
|
120
|
+
"""An insert session, from `i` (or `o`, `cw`, ...) to Esc."""
|
|
121
|
+
|
|
122
|
+
count: int # how many times the typed text goes in, like `3ix<Esc>`
|
|
123
|
+
open_line: bool # o and O open a new line for every repeat
|
|
124
|
+
document: QTextDocument
|
|
125
|
+
origin: str # the document's text before the session changed anything
|
|
126
|
+
saved: Pos | None # where `u` puts the cursor back; None: where typing starts
|
|
127
|
+
start: int = 0 # the Qt position where typing started
|
|
128
|
+
before: bytes = b"" # the document as UTF-16 then
|
|
129
|
+
levels: int = 0 # undo steps Qt added during the session
|
|
130
|
+
seen: int = 0 # levels at the last change
|
|
131
|
+
merged_outside: bool = False # Qt added the first change to the step before
|
|
132
|
+
messy: bool = False # the app undid or redid something during the session
|
|
133
|
+
no_lines: bool = False # VimMode._no_lines when the session started
|
|
134
|
+
edited: bool = False # the text changed during the session, even if back as it was
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class VimMode(QObject):
|
|
138
|
+
"""Vim keys for one QPlainTextEdit. Starts in Normal mode.
|
|
139
|
+
|
|
140
|
+
clipboard: False keeps yanked and deleted text in vim's own register. True puts
|
|
141
|
+
it on the system clipboard, and p pastes from there.
|
|
142
|
+
escape_hook: called first on every Esc that reaches the editor. If it returns True
|
|
143
|
+
(it closed a popup, say), vim does nothing else with that Esc.
|
|
144
|
+
search_hook: `/` calls it with True, `?` with False. The app shows its search, and
|
|
145
|
+
passes the text to set_search.
|
|
146
|
+
layout_map: keys outside Insert mode go through it, so a Cyrillic letter works as
|
|
147
|
+
the Latin key in the same place. UKRAINIAN by default; None turns it off.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
mode_changed = Signal(Mode)
|
|
151
|
+
pending_changed = Signal(str)
|
|
152
|
+
|
|
153
|
+
# When a window closes, Python may clear this object while Qt still sends its
|
|
154
|
+
# editor's last events. These defaults make the filter let them all through.
|
|
155
|
+
_enabled = False
|
|
156
|
+
_busy = False
|
|
157
|
+
_insert: _Insert | None = None
|
|
158
|
+
|
|
159
|
+
def __init__(self, editor: QPlainTextEdit) -> None:
|
|
160
|
+
super().__init__(editor)
|
|
161
|
+
self.clipboard = False
|
|
162
|
+
self.escape_hook: Callable[[], bool] | None = None
|
|
163
|
+
self.search_hook: Callable[[bool], None] | None = None
|
|
164
|
+
self.layout_map: dict[str, str] | None = UKRAINIAN
|
|
165
|
+
self._search: tuple[str, bool] | None = None
|
|
166
|
+
# what Ctrl+D and Ctrl+U scroll, once a count set it (vim's 'scroll')
|
|
167
|
+
self._scroll_amount: int | None = None
|
|
168
|
+
self._editor = editor
|
|
169
|
+
self._parser = Parser()
|
|
170
|
+
self._pending = ""
|
|
171
|
+
self._mode = Mode.NORMAL
|
|
172
|
+
self._enabled = True
|
|
173
|
+
self._attached = True
|
|
174
|
+
self._insert = None
|
|
175
|
+
# the screen column j and k aim for, valid while the cursor stays where we put it
|
|
176
|
+
self._want: int | None = None
|
|
177
|
+
self._want_at: tuple[int, Version] | None = None
|
|
178
|
+
self._block_on = False
|
|
179
|
+
self._plain_width = editor.cursorWidth()
|
|
180
|
+
# The undo steps vim knows, newest last: Qt's steps from the top of the
|
|
181
|
+
# document's stack, and the few only vim has. Valid while _version() is still
|
|
182
|
+
# _synced.
|
|
183
|
+
self._done: list[UndoStep] = []
|
|
184
|
+
self._undone: list[UndoStep] = []
|
|
185
|
+
self._synced = self._version()
|
|
186
|
+
# vim's empty buffer: no lines at all, as in a new text or after dd deleted every
|
|
187
|
+
# line. Any edit gives it a line again, even one that leaves it empty.
|
|
188
|
+
self._no_lines = not editor.document().toRawText()
|
|
189
|
+
# set while vim edits, so the signals that fires don't count as the user's
|
|
190
|
+
self._busy = False
|
|
191
|
+
# Visual mode: the two ends of the selection, as positions Qt moves along when
|
|
192
|
+
# the text changes. None outside Visual mode.
|
|
193
|
+
self._anchor: QTextCursor | None = None
|
|
194
|
+
self._end: QTextCursor | None = None
|
|
195
|
+
self._mouse_down = False
|
|
196
|
+
self._caret = Caret(editor)
|
|
197
|
+
# the editor's interaction flags while Visual mode has turned DRAWS_CURSOR off
|
|
198
|
+
self._saved_flags: Qt.TextInteractionFlag | None = None
|
|
199
|
+
editor.installEventFilter(self)
|
|
200
|
+
editor.viewport().installEventFilter(self)
|
|
201
|
+
editor.cursorPositionChanged.connect(self._on_cursor_moved)
|
|
202
|
+
# the app can move the anchor alone, which doesn't move the cursor
|
|
203
|
+
editor.selectionChanged.connect(self._on_cursor_moved)
|
|
204
|
+
self._on_cursor_moved()
|
|
205
|
+
self._normal_handlers, self._visual_handlers = self._handlers()
|
|
206
|
+
|
|
207
|
+
def _handlers(
|
|
208
|
+
self,
|
|
209
|
+
) -> tuple[dict[str, Callable[[Command], None]], dict[str, Callable[[Command], None]]]:
|
|
210
|
+
"""What runs each command, in Normal mode and in Visual mode. They cover every
|
|
211
|
+
name in the parser's tables, and the tests check that."""
|
|
212
|
+
both: dict[str, Callable[[Command], None]] = {
|
|
213
|
+
"move": self._move,
|
|
214
|
+
"n": self._search_next,
|
|
215
|
+
"N": self._search_next,
|
|
216
|
+
"/": self._open_search,
|
|
217
|
+
"?": self._open_search,
|
|
218
|
+
}
|
|
219
|
+
both |= dict.fromkeys(SCROLL_KEYS, self._scroll)
|
|
220
|
+
normal = both | dict.fromkeys(OPERATORS, self._operate)
|
|
221
|
+
normal |= dict.fromkeys(INSERT_COMMANDS, self._start_insert)
|
|
222
|
+
normal |= {
|
|
223
|
+
"p": self._paste,
|
|
224
|
+
"P": self._paste,
|
|
225
|
+
"J": self._join,
|
|
226
|
+
"u": self._undo_command,
|
|
227
|
+
"<C-r>": self._undo_command,
|
|
228
|
+
"v": self._start_visual_command,
|
|
229
|
+
"V": self._start_visual_command,
|
|
230
|
+
}
|
|
231
|
+
visual = both | dict.fromkeys(OPERATORS, self._visual_operate)
|
|
232
|
+
visual |= {
|
|
233
|
+
"p": self._visual_paste,
|
|
234
|
+
"o": self._swap_ends,
|
|
235
|
+
"v": self._switch_visual,
|
|
236
|
+
"V": self._switch_visual,
|
|
237
|
+
}
|
|
238
|
+
return normal, visual
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def mode(self) -> Mode:
|
|
242
|
+
return self._mode
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def enabled(self) -> bool:
|
|
246
|
+
return self._enabled
|
|
247
|
+
|
|
248
|
+
def set_search(self, text: str, forward: bool = True) -> None:
|
|
249
|
+
"""Search for plain text, like typing it after `/` (forward) or `?` in vim and
|
|
250
|
+
pressing Enter: the cursor goes to the next match, if there is one. After that n
|
|
251
|
+
looks for the text the same way, and N the other way."""
|
|
252
|
+
text = text.replace("\r\n", "\n").replace(PARAGRAPH_SEPARATOR, "\n")
|
|
253
|
+
self._search = (text, forward) if text else None
|
|
254
|
+
if self._enabled and self._mode is not Mode.INSERT:
|
|
255
|
+
self._search_next(Command("n"))
|
|
256
|
+
|
|
257
|
+
def normal(self) -> None:
|
|
258
|
+
"""Go to Normal mode as Esc does: what was typed since `i` is one undo step,
|
|
259
|
+
and Visual mode drops its selection. For an app that takes the user back to
|
|
260
|
+
the text, from its search bar say."""
|
|
261
|
+
if self._enabled:
|
|
262
|
+
self._escape()
|
|
263
|
+
|
|
264
|
+
def set_enabled(self, enabled: bool) -> None:
|
|
265
|
+
"""False: a plain editor again, with its own cursor. True: back in Normal mode."""
|
|
266
|
+
if not self._attached:
|
|
267
|
+
return
|
|
268
|
+
self._drop_insert()
|
|
269
|
+
self._drop_visual()
|
|
270
|
+
self._parser.reset()
|
|
271
|
+
self._emit_pending()
|
|
272
|
+
self._enabled = enabled
|
|
273
|
+
if enabled:
|
|
274
|
+
self._set_mode(Mode.NORMAL)
|
|
275
|
+
self._on_cursor_moved()
|
|
276
|
+
else:
|
|
277
|
+
self._update_cursor_width()
|
|
278
|
+
|
|
279
|
+
def detach(self) -> None:
|
|
280
|
+
"""Take vim off the editor for good."""
|
|
281
|
+
if not self._attached:
|
|
282
|
+
return
|
|
283
|
+
self.set_enabled(False)
|
|
284
|
+
self._attached = False
|
|
285
|
+
self._editor.removeEventFilter(self)
|
|
286
|
+
self._editor.viewport().removeEventFilter(self)
|
|
287
|
+
self._editor.cursorPositionChanged.disconnect(self._on_cursor_moved)
|
|
288
|
+
self._editor.selectionChanged.disconnect(self._on_cursor_moved)
|
|
289
|
+
|
|
290
|
+
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
|
291
|
+
if not self._enabled:
|
|
292
|
+
return False
|
|
293
|
+
kind = event.type()
|
|
294
|
+
if watched is not self._editor:
|
|
295
|
+
if kind in (QEvent.Type.MouseButtonPress, QEvent.Type.MouseButtonDblClick):
|
|
296
|
+
# a click forgets the column j and k aim for, even on the same spot
|
|
297
|
+
self._want_at = None
|
|
298
|
+
self._mouse_down = True
|
|
299
|
+
elif kind == QEvent.Type.MouseButtonRelease:
|
|
300
|
+
self._mouse_down = False
|
|
301
|
+
elif kind == QEvent.Type.Resize:
|
|
302
|
+
# Qt skips the call if the caret is deleted before the timer fires
|
|
303
|
+
QTimer.singleShot(0, self._caret, self._caret.place)
|
|
304
|
+
return False
|
|
305
|
+
if kind == QEvent.Type.ShortcutOverride:
|
|
306
|
+
assert isinstance(event, QKeyEvent)
|
|
307
|
+
if self._takes(self._token(event)) or self._blocks(event):
|
|
308
|
+
event.accept()
|
|
309
|
+
return True
|
|
310
|
+
return False
|
|
311
|
+
if kind == QEvent.Type.KeyPress:
|
|
312
|
+
assert isinstance(event, QKeyEvent)
|
|
313
|
+
return self._key_press(event)
|
|
314
|
+
if kind == QEvent.Type.InputMethod:
|
|
315
|
+
if self._mode is Mode.INSERT:
|
|
316
|
+
return False
|
|
317
|
+
# Outside Insert mode an input method's text never goes in: it's keys. On
|
|
318
|
+
# Linux, letters typed on a layout like Ukrainian often come this way.
|
|
319
|
+
assert isinstance(event, QInputMethodEvent)
|
|
320
|
+
self._input_keys(event.commitString())
|
|
321
|
+
return True
|
|
322
|
+
if kind == QEvent.Type.ReadOnlyChange:
|
|
323
|
+
if self._saved_flags is not None:
|
|
324
|
+
# the app's setReadOnly: its flags are the ones to put back after Visual
|
|
325
|
+
self._saved_flags = None
|
|
326
|
+
if self._mode is Mode.INSERT and self._read_only():
|
|
327
|
+
self._leave_insert()
|
|
328
|
+
self._update_cursor_width()
|
|
329
|
+
if kind in (QEvent.Type.FocusIn, QEvent.Type.FocusOut):
|
|
330
|
+
self._caret.restart_blink()
|
|
331
|
+
if kind == QEvent.Type.FontChange:
|
|
332
|
+
# the editor lays out the text again first
|
|
333
|
+
QTimer.singleShot(0, self, self._update_cursor_width)
|
|
334
|
+
return False
|
|
335
|
+
|
|
336
|
+
def _token(self, event: QKeyEvent) -> str | None:
|
|
337
|
+
"""The key as vim reads it now: through layout_map, outside Insert mode. In
|
|
338
|
+
Insert mode only Ctrl+х maps, to Esc, since it's the key of Ctrl+[."""
|
|
339
|
+
token = key_token(event)
|
|
340
|
+
if token is None:
|
|
341
|
+
return None
|
|
342
|
+
mapped = map_layout(token, self.layout_map)
|
|
343
|
+
if self._mode is Mode.INSERT:
|
|
344
|
+
return ESC if mapped == ESC else token
|
|
345
|
+
return mapped
|
|
346
|
+
|
|
347
|
+
def _read_only(self) -> bool:
|
|
348
|
+
"""The app made the editor read-only (Visual mode's own change doesn't count)."""
|
|
349
|
+
flags = self._saved_flags
|
|
350
|
+
if flags is None:
|
|
351
|
+
flags = self._editor.textInteractionFlags()
|
|
352
|
+
return not flags & Qt.TextInteractionFlag.TextEditable
|
|
353
|
+
|
|
354
|
+
def _takes(self, token: str | None) -> bool:
|
|
355
|
+
"""True if vim handles this key now, instead of the editor or the app."""
|
|
356
|
+
if token is None:
|
|
357
|
+
return False
|
|
358
|
+
if self._mode is Mode.INSERT:
|
|
359
|
+
return token == ESC
|
|
360
|
+
if token == ESC:
|
|
361
|
+
# Esc with nothing pending goes on to the app, to close a dialog say
|
|
362
|
+
return bool(self._parser.pending) or self._mode in VISUAL_MODES
|
|
363
|
+
if token.startswith("<C-"):
|
|
364
|
+
return token in CTRL_KEYS
|
|
365
|
+
# every other key is vim's, so Normal mode never types text
|
|
366
|
+
return True
|
|
367
|
+
|
|
368
|
+
def _blocks(self, event: QKeyEvent) -> bool:
|
|
369
|
+
"""True for a key of Qt's that would change the text outside Insert mode. Vim's
|
|
370
|
+
keys come first: on Linux, Ctrl+U deletes a line in Qt but scrolls in vim."""
|
|
371
|
+
return self._mode is not Mode.INSERT and any(event.matches(key) for key in QT_EDIT_KEYS)
|
|
372
|
+
|
|
373
|
+
def _key_press(self, event: QKeyEvent) -> bool:
|
|
374
|
+
token = self._token(event)
|
|
375
|
+
if token == ESC and self.escape_hook is not None and self.escape_hook():
|
|
376
|
+
return True
|
|
377
|
+
if not self._takes(token):
|
|
378
|
+
return self._blocks(event)
|
|
379
|
+
assert token is not None
|
|
380
|
+
if token == ESC:
|
|
381
|
+
self._escape()
|
|
382
|
+
return True
|
|
383
|
+
self._feed(token)
|
|
384
|
+
return True
|
|
385
|
+
|
|
386
|
+
def _feed(self, token: str) -> None:
|
|
387
|
+
command = self._parser.feed(token, visual=self._mode in VISUAL_MODES)
|
|
388
|
+
self._emit_pending()
|
|
389
|
+
if command is not None:
|
|
390
|
+
self._run(command)
|
|
391
|
+
|
|
392
|
+
def _input_keys(self, text: str) -> None:
|
|
393
|
+
"""Text from an input method outside Insert mode, as keys one by one. If one of
|
|
394
|
+
them starts Insert mode (like `i`), the rest is typed."""
|
|
395
|
+
chars = split_clusters(text)
|
|
396
|
+
for i, ch in enumerate(chars):
|
|
397
|
+
if self._mode is Mode.INSERT:
|
|
398
|
+
cursor = self._editor.textCursor()
|
|
399
|
+
cursor.insertText("".join(chars[i:]))
|
|
400
|
+
self._editor.setTextCursor(cursor)
|
|
401
|
+
return
|
|
402
|
+
self._feed(map_layout(ch, self.layout_map))
|
|
403
|
+
|
|
404
|
+
def _escape(self) -> None:
|
|
405
|
+
if self._mode is Mode.INSERT:
|
|
406
|
+
self._leave_insert()
|
|
407
|
+
return
|
|
408
|
+
self._parser.reset()
|
|
409
|
+
self._emit_pending()
|
|
410
|
+
if self._mode in VISUAL_MODES:
|
|
411
|
+
self._leave_visual()
|
|
412
|
+
|
|
413
|
+
def _run(self, command: Command) -> None:
|
|
414
|
+
visual = self._mode in VISUAL_MODES
|
|
415
|
+
if changes_text(command, visual) and self._read_only():
|
|
416
|
+
return
|
|
417
|
+
handlers = self._visual_handlers if visual else self._normal_handlers
|
|
418
|
+
handlers[command.name](command)
|
|
419
|
+
|
|
420
|
+
def _open_search(self, command: Command) -> None:
|
|
421
|
+
if self.search_hook is not None:
|
|
422
|
+
self.search_hook(command.name == "/")
|
|
423
|
+
|
|
424
|
+
def _lines(self) -> Lines:
|
|
425
|
+
"""The text as it is now. Make a new one after changing the text."""
|
|
426
|
+
return Lines(_Blocks(self._editor.document()))
|
|
427
|
+
|
|
428
|
+
def _to_qt(self, lines: Lines, pos: Pos) -> int:
|
|
429
|
+
"""Like Lines.to_qt, but the document knows where each line starts."""
|
|
430
|
+
start = self._editor.document().findBlockByNumber(pos.line).position()
|
|
431
|
+
return start + lines.offset(pos)
|
|
432
|
+
|
|
433
|
+
def _from_qt(self, lines: Lines, position: int) -> Pos:
|
|
434
|
+
"""A Qt position in the editor's document as vim's line and column."""
|
|
435
|
+
return _from_qt(self._editor.document(), lines, position)
|
|
436
|
+
|
|
437
|
+
def _position(self) -> int:
|
|
438
|
+
"""vim's cursor as a Qt position. In Visual mode it's one end of the selection,
|
|
439
|
+
not always where the editor's cursor is."""
|
|
440
|
+
if self._end is not None:
|
|
441
|
+
return self._end.position()
|
|
442
|
+
return self._editor.textCursor().position()
|
|
443
|
+
|
|
444
|
+
def _pos(self, lines: Lines) -> Pos:
|
|
445
|
+
return self._from_qt(lines, self._position())
|
|
446
|
+
|
|
447
|
+
def _go(self, lines: Lines, pos: Pos) -> None:
|
|
448
|
+
if self._end is not None:
|
|
449
|
+
self._end.setPosition(self._to_qt(lines, pos))
|
|
450
|
+
self._show_visual()
|
|
451
|
+
return
|
|
452
|
+
cursor = self._editor.textCursor()
|
|
453
|
+
cursor.setPosition(self._to_qt(lines, pos))
|
|
454
|
+
self._editor.setTextCursor(cursor)
|
|
455
|
+
self._update_cursor_width()
|
|
456
|
+
|
|
457
|
+
def _put(self, lines: Lines, pos: Pos) -> None:
|
|
458
|
+
"""Go to pos, kept on a character of a line that exists, as Normal mode wants."""
|
|
459
|
+
line = min(max(pos.line, 0), len(lines) - 1)
|
|
460
|
+
self._go(lines, Pos(line, min(max(pos.col, 0), max(len(lines[line]) - 1, 0))))
|
|
461
|
+
|
|
462
|
+
def _version(self) -> Version:
|
|
463
|
+
"""Which text the editor shows now. The revision alone can't tell: an app with
|
|
464
|
+
tabs swaps in another document, and its revision can be the same."""
|
|
465
|
+
document = self._editor.document()
|
|
466
|
+
return document, document.revision()
|
|
467
|
+
|
|
468
|
+
def _where(self) -> tuple[int, Version]:
|
|
469
|
+
return self._position(), self._version()
|
|
470
|
+
|
|
471
|
+
def _tabstop(self) -> int:
|
|
472
|
+
"""The editor's tab width in spaces."""
|
|
473
|
+
space = self._editor.fontMetrics().horizontalAdvance(" ")
|
|
474
|
+
if space <= 0:
|
|
475
|
+
return 8
|
|
476
|
+
return max(1, round(self._editor.tabStopDistance() / space))
|
|
477
|
+
|
|
478
|
+
def _wanted(self) -> int | None:
|
|
479
|
+
"""The screen column j and k aim for, if the cursor is still where vim put it."""
|
|
480
|
+
return self._want if self._want_at == self._where() else None
|
|
481
|
+
|
|
482
|
+
def _move(self, command: Command) -> None:
|
|
483
|
+
assert command.motion is not None
|
|
484
|
+
lines = self._lines()
|
|
485
|
+
moved = move(
|
|
486
|
+
lines,
|
|
487
|
+
self._pos(lines),
|
|
488
|
+
command.motion,
|
|
489
|
+
command.count,
|
|
490
|
+
want=self._wanted(),
|
|
491
|
+
visual=self._mode in VISUAL_MODES,
|
|
492
|
+
tabstop=self._tabstop(),
|
|
493
|
+
)
|
|
494
|
+
if moved is None:
|
|
495
|
+
return
|
|
496
|
+
self._go(lines, moved.pos)
|
|
497
|
+
self._want = moved.want
|
|
498
|
+
self._want_at = self._where()
|
|
499
|
+
|
|
500
|
+
def _search_next(self, command: Command) -> None:
|
|
501
|
+
if self._search is None:
|
|
502
|
+
return
|
|
503
|
+
text, forward = self._search
|
|
504
|
+
lines = self._lines()
|
|
505
|
+
pattern = [split_clusters(part) for part in text.split("\n")]
|
|
506
|
+
found = search(
|
|
507
|
+
lines, self._pos(lines), pattern, forward == (command.name == "n"), command.count
|
|
508
|
+
)
|
|
509
|
+
if found is None:
|
|
510
|
+
return
|
|
511
|
+
self._go(lines, found)
|
|
512
|
+
self._want_at = None
|
|
513
|
+
|
|
514
|
+
def _view(self) -> tuple[int, int, int]:
|
|
515
|
+
"""The first line on the screen, how many lines fit on it, and the highest
|
|
516
|
+
first line the editor scrolls to (the last line at the bottom)."""
|
|
517
|
+
first, _, height = self._visible()
|
|
518
|
+
rows = self._editor.verticalScrollBar().maximum()
|
|
519
|
+
block = self._editor.document().firstBlock()
|
|
520
|
+
while block.isValid() and rows >= block.lineCount() and block.next().isValid():
|
|
521
|
+
rows -= block.lineCount()
|
|
522
|
+
block = block.next()
|
|
523
|
+
return first, height, block.blockNumber()
|
|
524
|
+
|
|
525
|
+
def _visible(self) -> tuple[int, int, int]:
|
|
526
|
+
"""The first and last lines fully on the screen, and how many lines fit on it
|
|
527
|
+
(vim's window height). Lines can differ in height: emoji often make a line
|
|
528
|
+
taller. Past the end of the text, lines as high as the last one would fit."""
|
|
529
|
+
editor = self._editor
|
|
530
|
+
offset = editor.contentOffset()
|
|
531
|
+
bottom = editor.viewport().height()
|
|
532
|
+
block = editor.firstVisibleBlock()
|
|
533
|
+
first = last = block.blockNumber()
|
|
534
|
+
count = 0
|
|
535
|
+
while block.isValid():
|
|
536
|
+
geometry = editor.blockBoundingGeometry(block).translated(offset)
|
|
537
|
+
if geometry.bottom() > bottom + 0.5 and count:
|
|
538
|
+
break
|
|
539
|
+
last = block.blockNumber()
|
|
540
|
+
count += 1
|
|
541
|
+
if not block.next().isValid():
|
|
542
|
+
row = geometry.height() / max(block.lineCount(), 1)
|
|
543
|
+
if row > 0:
|
|
544
|
+
count += max(int((bottom - geometry.bottom()) // row), 0)
|
|
545
|
+
block = block.next()
|
|
546
|
+
return first, last, max(count, 1)
|
|
547
|
+
|
|
548
|
+
def _scroll_to(self, line: int) -> None:
|
|
549
|
+
"""Scroll so that a line is the first on the screen, as far as the editor goes."""
|
|
550
|
+
rows = 0
|
|
551
|
+
block = self._editor.document().firstBlock()
|
|
552
|
+
while block.isValid() and block.blockNumber() < line:
|
|
553
|
+
rows += block.lineCount()
|
|
554
|
+
block = block.next()
|
|
555
|
+
self._editor.verticalScrollBar().setValue(rows)
|
|
556
|
+
|
|
557
|
+
def _scroll(self, command: Command) -> None:
|
|
558
|
+
lines = self._lines()
|
|
559
|
+
pos = self._pos(lines)
|
|
560
|
+
top, height, max_top = self._view()
|
|
561
|
+
scrolled = scroll(
|
|
562
|
+
command.name,
|
|
563
|
+
command.count,
|
|
564
|
+
top=top,
|
|
565
|
+
line=pos.line,
|
|
566
|
+
height=height,
|
|
567
|
+
lines=len(lines),
|
|
568
|
+
max_top=max_top,
|
|
569
|
+
amount=self._scroll_amount,
|
|
570
|
+
)
|
|
571
|
+
if scrolled is None:
|
|
572
|
+
return
|
|
573
|
+
self._scroll_amount = scrolled.amount
|
|
574
|
+
want = self._wanted()
|
|
575
|
+
self._scroll_to(scrolled.top)
|
|
576
|
+
# keep the cursor on a line that's fully on the screen, like vim
|
|
577
|
+
first, last, _ = self._visible()
|
|
578
|
+
line = min(max(scrolled.line, first), last)
|
|
579
|
+
visual = self._mode in VISUAL_MODES
|
|
580
|
+
text = lines[line]
|
|
581
|
+
if not scrolled.keep_column:
|
|
582
|
+
self._go(lines, Pos(line, first_non_blank(text)))
|
|
583
|
+
self._want_at = None
|
|
584
|
+
elif line != pos.line:
|
|
585
|
+
if want is None:
|
|
586
|
+
want = screen_col(lines[pos.line], pos.col, self._tabstop())
|
|
587
|
+
col = column_for(text, want, visual, self._tabstop())
|
|
588
|
+
self._go(lines, Pos(line, col))
|
|
589
|
+
self._want = want
|
|
590
|
+
self._want_at = self._where()
|
|
591
|
+
|
|
592
|
+
def _on_cursor_moved(self) -> None:
|
|
593
|
+
"""Outside Insert mode, keep the cursor on a character, never after the last
|
|
594
|
+
one, and never inside one (an app or Qt's flag bug can put it there)."""
|
|
595
|
+
if not self._enabled or self._busy or self._mode is Mode.INSERT:
|
|
596
|
+
return
|
|
597
|
+
cursor = self._editor.textCursor()
|
|
598
|
+
if self._mode in VISUAL_MODES:
|
|
599
|
+
self._visual_cursor_moved(cursor)
|
|
600
|
+
return
|
|
601
|
+
if cursor.hasSelection():
|
|
602
|
+
if self._mouse_down:
|
|
603
|
+
self._adopt_selection(cursor)
|
|
604
|
+
else:
|
|
605
|
+
self._update_cursor_width()
|
|
606
|
+
return
|
|
607
|
+
lines = self._lines()
|
|
608
|
+
pos = self._from_qt(lines, cursor.position())
|
|
609
|
+
fixed = Pos(pos.line, min(pos.col, max(len(lines[pos.line]) - 1, 0)))
|
|
610
|
+
if self._to_qt(lines, fixed) != cursor.position():
|
|
611
|
+
self._go(lines, fixed)
|
|
612
|
+
else:
|
|
613
|
+
self._update_cursor_width()
|
|
614
|
+
|
|
615
|
+
def _update_cursor_width(self) -> None:
|
|
616
|
+
"""A block cursor in Normal mode, the editor's own cursor in Insert mode. In
|
|
617
|
+
Visual mode the editor's cursor is hidden and the Caret draws vim's."""
|
|
618
|
+
visual = self._enabled and self._mode in VISUAL_MODES and self._end is not None
|
|
619
|
+
if self._enabled and self._mode is not Mode.INSERT:
|
|
620
|
+
if not self._block_on:
|
|
621
|
+
self._plain_width = self._editor.cursorWidth()
|
|
622
|
+
self._block_on = True
|
|
623
|
+
self._set_cursor_width(0 if visual else char_width(self._editor, self._position()))
|
|
624
|
+
elif self._block_on:
|
|
625
|
+
self._block_on = False
|
|
626
|
+
self._set_cursor_width(self._plain_width)
|
|
627
|
+
self._hide_editor_cursor(visual)
|
|
628
|
+
# a read-only editor draws no cursor of its own
|
|
629
|
+
no_cursor = not self._editor.textInteractionFlags() & DRAWS_CURSOR
|
|
630
|
+
normal = self._enabled and self._mode is Mode.NORMAL and no_cursor
|
|
631
|
+
self._caret.show_at(self._position() if visual or normal else None)
|
|
632
|
+
|
|
633
|
+
def _set_cursor_width(self, width: int) -> None:
|
|
634
|
+
"""Qt repaints only the cursor's new area when its width changes, so the block
|
|
635
|
+
turning into Insert mode's line left the rest of the block on screen."""
|
|
636
|
+
if self._editor.cursorWidth() != width:
|
|
637
|
+
self._editor.viewport().update(self._editor.cursorRect())
|
|
638
|
+
self._editor.setCursorWidth(width)
|
|
639
|
+
|
|
640
|
+
def _hide_editor_cursor(self, hide: bool) -> None:
|
|
641
|
+
"""Width 0 doesn't hide the editor's cursor everywhere: on a screen scaled by a
|
|
642
|
+
fraction (125%, 150%) Qt still draws a thin blinking line. So in Visual mode the
|
|
643
|
+
editor stops taking keys to edit (vim takes them anyway), and then Qt draws no
|
|
644
|
+
cursor. Meanwhile isReadOnly() is True. If the app changes the flags meanwhile
|
|
645
|
+
(setReadOnly, or other flags), its change stays."""
|
|
646
|
+
flags = self._editor.textInteractionFlags()
|
|
647
|
+
if hide:
|
|
648
|
+
if self._saved_flags is None and flags & DRAWS_CURSOR:
|
|
649
|
+
self._saved_flags = flags
|
|
650
|
+
self._editor.setTextInteractionFlags(flags & ~DRAWS_CURSOR)
|
|
651
|
+
elif self._saved_flags is not None:
|
|
652
|
+
if flags == self._saved_flags & ~DRAWS_CURSOR:
|
|
653
|
+
self._editor.setTextInteractionFlags(self._saved_flags)
|
|
654
|
+
self._saved_flags = None
|
|
655
|
+
|
|
656
|
+
def _operate(self, command: Command) -> None:
|
|
657
|
+
assert command.motion is not None
|
|
658
|
+
name = command.name
|
|
659
|
+
lines = self._lines()
|
|
660
|
+
pos = self._pos(lines)
|
|
661
|
+
if command.motion == LINE:
|
|
662
|
+
r = line_range(lines, pos, command.count)
|
|
663
|
+
if r is None:
|
|
664
|
+
return
|
|
665
|
+
# dd and cc go to the first non-blank of the last line first; vim's undo
|
|
666
|
+
# remembers whichever of the two comes first
|
|
667
|
+
end = Pos(r.end.line, first_non_blank(lines[r.end.line]))
|
|
668
|
+
start = pos if name == "y" else min(pos, end)
|
|
669
|
+
else:
|
|
670
|
+
moved = move(
|
|
671
|
+
lines,
|
|
672
|
+
pos,
|
|
673
|
+
command.motion,
|
|
674
|
+
command.count,
|
|
675
|
+
want=self._wanted(),
|
|
676
|
+
op=name,
|
|
677
|
+
tabstop=self._tabstop(),
|
|
678
|
+
)
|
|
679
|
+
if moved is None:
|
|
680
|
+
return
|
|
681
|
+
r = operator_range(lines, pos, moved, name)
|
|
682
|
+
start = min(pos, moved.pos)
|
|
683
|
+
empty = takes_nothing(r, self._has_no_lines(lines))
|
|
684
|
+
# vim's empty yank empties the register, an empty delete leaves it alone
|
|
685
|
+
if not empty or name == "y":
|
|
686
|
+
self._store(range_text(lines, r), r.linewise)
|
|
687
|
+
if name == "y":
|
|
688
|
+
self._put(lines, start)
|
|
689
|
+
self._want_at = None # j and k aim for the cursor's column again
|
|
690
|
+
elif name == "d":
|
|
691
|
+
if not empty:
|
|
692
|
+
self._delete(lines, r, start)
|
|
693
|
+
else:
|
|
694
|
+
self._change_text(lines, r, start, empty)
|
|
695
|
+
|
|
696
|
+
def _span(self, lines: Lines, r: Range) -> tuple[int, int]:
|
|
697
|
+
"""The Qt positions a range covers. Whole lines take one line break with them,
|
|
698
|
+
the one after them, or before them at the end of the document."""
|
|
699
|
+
if not r.linewise:
|
|
700
|
+
return self._to_qt(lines, r.start), self._to_qt(lines, r.end)
|
|
701
|
+
document = self._editor.document()
|
|
702
|
+
first = document.findBlockByNumber(r.start.line)
|
|
703
|
+
last = document.findBlockByNumber(r.end.line)
|
|
704
|
+
after = last.next()
|
|
705
|
+
if after.isValid():
|
|
706
|
+
return first.position(), after.position()
|
|
707
|
+
end = last.position() + last.length() - 1
|
|
708
|
+
return max(first.position() - 1, 0), end
|
|
709
|
+
|
|
710
|
+
def _delete(self, lines: Lines, r: Range, start: Pos) -> None:
|
|
711
|
+
begin, end = self._span(lines, r)
|
|
712
|
+
every_line = r.linewise and r.start.line == 0 and r.end.line == len(lines) - 1
|
|
713
|
+
document = self._editor.document()
|
|
714
|
+
steps = document.availableUndoSteps()
|
|
715
|
+
with self._change(start) as edit:
|
|
716
|
+
edit.setPosition(begin)
|
|
717
|
+
edit.setPosition(end, KEEP_ANCHOR)
|
|
718
|
+
edit.removeSelectedText()
|
|
719
|
+
if every_line:
|
|
720
|
+
if document.availableUndoSteps() == steps:
|
|
721
|
+
self._done.append(UndoStep(start, self._no_lines, in_qt=False))
|
|
722
|
+
self._undone.clear()
|
|
723
|
+
self._no_lines = True
|
|
724
|
+
lines = self._lines()
|
|
725
|
+
if r.linewise:
|
|
726
|
+
line = min(r.start.line, len(lines) - 1)
|
|
727
|
+
self._put(lines, Pos(line, first_non_blank(lines[line])))
|
|
728
|
+
else:
|
|
729
|
+
self._put(lines, r.start)
|
|
730
|
+
|
|
731
|
+
def _change_text(self, lines: Lines, r: Range, start: Pos, empty: bool) -> None:
|
|
732
|
+
"""c: delete the range and type in its place. Whole lines leave one empty line
|
|
733
|
+
(no autoindent). The delete and the typing are one undo step."""
|
|
734
|
+
document = self._editor.document()
|
|
735
|
+
if r.linewise and r.end.line > r.start.line:
|
|
736
|
+
# vim deletes the lines below the first one with the cursor a line down, so
|
|
737
|
+
# that's where its undo puts it back
|
|
738
|
+
start = Pos(start.line + 1, start.col)
|
|
739
|
+
self._begin_insert(start, count=1, open_line=False)
|
|
740
|
+
if r.linewise:
|
|
741
|
+
begin = document.findBlockByNumber(r.start.line).position()
|
|
742
|
+
last = document.findBlockByNumber(r.end.line)
|
|
743
|
+
end = last.position() + last.length() - 1
|
|
744
|
+
else:
|
|
745
|
+
begin, end = self._span(lines, r)
|
|
746
|
+
if not empty and end > begin:
|
|
747
|
+
edit = QTextCursor(document)
|
|
748
|
+
edit.beginEditBlock()
|
|
749
|
+
edit.setPosition(begin)
|
|
750
|
+
edit.setPosition(end, KEEP_ANCHOR)
|
|
751
|
+
edit.removeSelectedText()
|
|
752
|
+
edit.endEditBlock()
|
|
753
|
+
cursor = self._editor.textCursor()
|
|
754
|
+
cursor.setPosition(begin)
|
|
755
|
+
self._typing_starts(cursor)
|
|
756
|
+
|
|
757
|
+
def _start_visual_command(self, command: Command) -> None:
|
|
758
|
+
self._start_visual(Mode.VISUAL if command.name == "v" else Mode.VISUAL_LINE)
|
|
759
|
+
|
|
760
|
+
def _start_visual(self, mode: Mode) -> None:
|
|
761
|
+
document = self._editor.document()
|
|
762
|
+
position = self._editor.textCursor().position()
|
|
763
|
+
self._anchor = QTextCursor(document)
|
|
764
|
+
self._anchor.setPosition(position)
|
|
765
|
+
self._end = QTextCursor(document)
|
|
766
|
+
self._end.setPosition(position)
|
|
767
|
+
self._set_mode(mode)
|
|
768
|
+
self._show_visual()
|
|
769
|
+
|
|
770
|
+
def _switch_visual(self, command: Command) -> None:
|
|
771
|
+
"""v or V in Visual mode: the other kind of Visual mode, or back to Normal."""
|
|
772
|
+
mode = Mode.VISUAL if command.name == "v" else Mode.VISUAL_LINE
|
|
773
|
+
if mode is self._mode:
|
|
774
|
+
self._leave_visual()
|
|
775
|
+
else:
|
|
776
|
+
self._set_mode(mode)
|
|
777
|
+
self._show_visual()
|
|
778
|
+
|
|
779
|
+
def _swap_ends(self, command: Command) -> None:
|
|
780
|
+
"""o in Visual mode: the cursor goes to the other end of the selection."""
|
|
781
|
+
self._anchor, self._end = self._end, self._anchor
|
|
782
|
+
self._want_at = None
|
|
783
|
+
self._show_visual()
|
|
784
|
+
|
|
785
|
+
def _selection(self, lines: Lines) -> tuple[Pos, Pos]:
|
|
786
|
+
"""The two ends of the Visual selection: where it started, and the cursor."""
|
|
787
|
+
assert self._anchor is not None and self._end is not None
|
|
788
|
+
return (
|
|
789
|
+
self._from_qt(lines, self._anchor.position()),
|
|
790
|
+
self._from_qt(lines, self._end.position()),
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
def _qt_selection(self, lines: Lines) -> tuple[int, int]:
|
|
794
|
+
"""The editor's anchor and position that show the Visual selection."""
|
|
795
|
+
anchor, end = self._selection(lines)
|
|
796
|
+
r = visual_range(lines, anchor, end, self._mode is Mode.VISUAL_LINE)
|
|
797
|
+
if r.linewise:
|
|
798
|
+
document = self._editor.document()
|
|
799
|
+
low = document.findBlockByNumber(r.start.line).position()
|
|
800
|
+
last = document.findBlockByNumber(r.end.line)
|
|
801
|
+
high = last.position() + last.length() - 1
|
|
802
|
+
else:
|
|
803
|
+
low, high = self._to_qt(lines, r.start), self._to_qt(lines, r.end)
|
|
804
|
+
return (low, high) if anchor <= end else (high, low)
|
|
805
|
+
|
|
806
|
+
def _show_visual(self) -> None:
|
|
807
|
+
"""Set the editor's selection to the Visual one, and draw the cursor."""
|
|
808
|
+
anchor, position = self._qt_selection(self._lines())
|
|
809
|
+
cursor = self._editor.textCursor()
|
|
810
|
+
if (cursor.anchor(), cursor.position()) != (anchor, position):
|
|
811
|
+
cursor.setPosition(anchor)
|
|
812
|
+
cursor.setPosition(position, KEEP_ANCHOR)
|
|
813
|
+
self._busy = True
|
|
814
|
+
try:
|
|
815
|
+
self._editor.setTextCursor(cursor)
|
|
816
|
+
finally:
|
|
817
|
+
self._busy = False
|
|
818
|
+
self._update_cursor_width()
|
|
819
|
+
|
|
820
|
+
def _adopt_selection(self, cursor: QTextCursor) -> None:
|
|
821
|
+
"""Visual mode for a selection the mouse or the app made: the same characters,
|
|
822
|
+
with vim's cursor on the last one at the editor's cursor end."""
|
|
823
|
+
lines = self._lines()
|
|
824
|
+
low = min(cursor.anchor(), cursor.position())
|
|
825
|
+
high = max(cursor.anchor(), cursor.position())
|
|
826
|
+
first = self._from_qt(lines, low)
|
|
827
|
+
last = self._from_qt(lines, high)
|
|
828
|
+
if last.col > 0:
|
|
829
|
+
last = Pos(last.line, last.col - 1)
|
|
830
|
+
elif last.line > first.line:
|
|
831
|
+
last = Pos(last.line - 1, len(lines[last.line - 1]))
|
|
832
|
+
anchor, end = (first, last) if cursor.position() == high else (last, first)
|
|
833
|
+
document = self._editor.document()
|
|
834
|
+
self._anchor = QTextCursor(document)
|
|
835
|
+
self._anchor.setPosition(self._to_qt(lines, anchor))
|
|
836
|
+
self._end = QTextCursor(document)
|
|
837
|
+
self._end.setPosition(self._to_qt(lines, end))
|
|
838
|
+
self._want_at = None
|
|
839
|
+
self._set_mode(Mode.VISUAL)
|
|
840
|
+
|
|
841
|
+
def _visual_cursor_moved(self, cursor: QTextCursor) -> None:
|
|
842
|
+
"""The editor's cursor moved in Visual mode, and not by vim: a click, a drag, or
|
|
843
|
+
the app. Text changes move both ends along, which shows nothing new."""
|
|
844
|
+
valid = (
|
|
845
|
+
self._anchor is not None
|
|
846
|
+
and self._anchor.document() is self._editor.document()
|
|
847
|
+
and not self._mouse_down
|
|
848
|
+
)
|
|
849
|
+
if valid and self._qt_selection(self._lines()) == (cursor.anchor(), cursor.position()):
|
|
850
|
+
self._update_cursor_width()
|
|
851
|
+
elif cursor.hasSelection():
|
|
852
|
+
self._adopt_selection(cursor)
|
|
853
|
+
else:
|
|
854
|
+
# a click, or the app put the cursor somewhere: back to Normal mode there
|
|
855
|
+
self._drop_visual()
|
|
856
|
+
self._set_mode(Mode.NORMAL)
|
|
857
|
+
self._on_cursor_moved()
|
|
858
|
+
|
|
859
|
+
def _drop_visual(self) -> None:
|
|
860
|
+
"""Forget the Visual selection; the editor's selection stays as it is."""
|
|
861
|
+
self._anchor = self._end = None
|
|
862
|
+
self._caret.show_at(None)
|
|
863
|
+
|
|
864
|
+
def _leave_visual(self) -> Lines:
|
|
865
|
+
"""Back to Normal mode with the cursor at vim's cursor, kept on a character."""
|
|
866
|
+
lines = self._lines()
|
|
867
|
+
_, end = self._selection(lines)
|
|
868
|
+
self._drop_visual()
|
|
869
|
+
self._set_mode(Mode.NORMAL)
|
|
870
|
+
self._busy = True
|
|
871
|
+
try:
|
|
872
|
+
self._put(lines, end)
|
|
873
|
+
finally:
|
|
874
|
+
self._busy = False
|
|
875
|
+
return lines
|
|
876
|
+
|
|
877
|
+
def _visual_range(self) -> tuple[Lines, Range, Pos]:
|
|
878
|
+
"""End Visual mode for an operator: the text, the range it acts on, and where
|
|
879
|
+
it starts (see visual_start)."""
|
|
880
|
+
lines = self._lines()
|
|
881
|
+
anchor, end = self._selection(lines)
|
|
882
|
+
linewise = self._mode is Mode.VISUAL_LINE
|
|
883
|
+
r = visual_range(lines, anchor, end, linewise)
|
|
884
|
+
start = visual_start(anchor, end, linewise)
|
|
885
|
+
self._leave_visual()
|
|
886
|
+
return lines, r, start
|
|
887
|
+
|
|
888
|
+
def _visual_operate(self, command: Command) -> None:
|
|
889
|
+
name = command.name
|
|
890
|
+
lines, r, start = self._visual_range()
|
|
891
|
+
empty = takes_nothing(r, self._has_no_lines(lines))
|
|
892
|
+
if not empty or name == "y":
|
|
893
|
+
self._store(range_text(lines, r), r.linewise)
|
|
894
|
+
if name == "y":
|
|
895
|
+
self._put(lines, start)
|
|
896
|
+
self._want_at = None
|
|
897
|
+
elif name == "d":
|
|
898
|
+
if empty:
|
|
899
|
+
self._put(lines, start)
|
|
900
|
+
else:
|
|
901
|
+
self._delete(lines, r, start)
|
|
902
|
+
else:
|
|
903
|
+
self._change_text(lines, r, start, empty)
|
|
904
|
+
|
|
905
|
+
def _visual_paste(self, command: Command) -> None:
|
|
906
|
+
"""p in Visual mode: the register's text takes the selection's place, and the
|
|
907
|
+
selection goes into the register. Whole lines put in part of a line go on lines
|
|
908
|
+
of their own; part of a line put over whole lines becomes a line."""
|
|
909
|
+
count = command.count or 1
|
|
910
|
+
register = self._read_register()
|
|
911
|
+
lines, r, start = self._visual_range()
|
|
912
|
+
no_lines = self._has_no_lines(lines)
|
|
913
|
+
empty = takes_nothing(r, no_lines)
|
|
914
|
+
deleted = range_text(lines, r)
|
|
915
|
+
if not register.text:
|
|
916
|
+
if not empty:
|
|
917
|
+
self._store(deleted, r.linewise)
|
|
918
|
+
self._delete(lines, r, start)
|
|
919
|
+
return
|
|
920
|
+
body = register.text.removesuffix("\n") if register.linewise else register.text
|
|
921
|
+
document = self._editor.document()
|
|
922
|
+
with self._change(start) as edit:
|
|
923
|
+
if r.linewise:
|
|
924
|
+
first = document.findBlockByNumber(r.start.line)
|
|
925
|
+
last = document.findBlockByNumber(r.end.line)
|
|
926
|
+
begin = first.position()
|
|
927
|
+
edit.setPosition(begin)
|
|
928
|
+
edit.setPosition(last.position() + last.length() - 1, KEEP_ANCHOR)
|
|
929
|
+
chunk = "\n".join([body] * count)
|
|
930
|
+
else:
|
|
931
|
+
begin, end = self._span(lines, r)
|
|
932
|
+
edit.setPosition(begin)
|
|
933
|
+
edit.setPosition(end, KEEP_ANCHOR)
|
|
934
|
+
if register.linewise:
|
|
935
|
+
# with no lines there's no line to split: they go below one, like p
|
|
936
|
+
chunk = "\n" + "\n".join([body] * count) + ("" if no_lines else "\n")
|
|
937
|
+
else:
|
|
938
|
+
chunk = body * count
|
|
939
|
+
edit.insertText(chunk)
|
|
940
|
+
if not empty:
|
|
941
|
+
self._store(deleted, r.linewise)
|
|
942
|
+
lines = self._lines()
|
|
943
|
+
if r.linewise or register.linewise:
|
|
944
|
+
line = r.start.line + (not r.linewise)
|
|
945
|
+
self._put(lines, Pos(line, first_non_blank(lines[line])))
|
|
946
|
+
elif "\n" in chunk:
|
|
947
|
+
self._put(lines, self._from_qt(lines, begin))
|
|
948
|
+
else:
|
|
949
|
+
self._put(lines, self._from_qt(lines, begin + utf16_len(chunk) - 1))
|
|
950
|
+
|
|
951
|
+
def _paste(self, command: Command) -> None:
|
|
952
|
+
register = self._read_register()
|
|
953
|
+
if not register.text:
|
|
954
|
+
return
|
|
955
|
+
count = command.count or 1
|
|
956
|
+
lines = self._lines()
|
|
957
|
+
pos = self._pos(lines)
|
|
958
|
+
after = command.name == "p"
|
|
959
|
+
document = self._editor.document()
|
|
960
|
+
if register.linewise:
|
|
961
|
+
body = register.text.removesuffix("\n")
|
|
962
|
+
block = document.findBlockByNumber(pos.line)
|
|
963
|
+
if after:
|
|
964
|
+
at, chunk = block.position() + block.length() - 1, ("\n" + body) * count
|
|
965
|
+
else:
|
|
966
|
+
at, chunk = block.position(), (body + "\n") * count
|
|
967
|
+
with self._change(pos) as edit:
|
|
968
|
+
edit.setPosition(at)
|
|
969
|
+
edit.insertText(chunk)
|
|
970
|
+
lines = self._lines()
|
|
971
|
+
line = pos.line + 1 if after else pos.line
|
|
972
|
+
self._put(lines, Pos(line, first_non_blank(lines[line])))
|
|
973
|
+
return
|
|
974
|
+
text = lines[pos.line]
|
|
975
|
+
at = self._to_qt(lines, Pos(pos.line, min(pos.col + 1, len(text)) if after else pos.col))
|
|
976
|
+
chunk = register.text * count
|
|
977
|
+
with self._change(pos) as edit:
|
|
978
|
+
edit.setPosition(at)
|
|
979
|
+
edit.insertText(chunk)
|
|
980
|
+
lines = self._lines()
|
|
981
|
+
# on the last character pasted, or on the first if it was more than one line
|
|
982
|
+
if "\n" in chunk:
|
|
983
|
+
self._put(lines, self._from_qt(lines, at))
|
|
984
|
+
else:
|
|
985
|
+
self._put(lines, self._from_qt(lines, at + utf16_len(chunk) - 1))
|
|
986
|
+
|
|
987
|
+
def _join(self, command: Command) -> None:
|
|
988
|
+
lines = self._lines()
|
|
989
|
+
pos = self._pos(lines)
|
|
990
|
+
count = max(command.count or 2, 2)
|
|
991
|
+
if pos.line + count > len(lines):
|
|
992
|
+
if count == 2:
|
|
993
|
+
return # on the last line: nothing to join
|
|
994
|
+
count = len(lines) - pos.line
|
|
995
|
+
if count < 2:
|
|
996
|
+
# vim "joins" the one line, which only moves the cursor
|
|
997
|
+
self._put(lines, Pos(pos.line, 0))
|
|
998
|
+
return
|
|
999
|
+
raws = [lines.raw(i) for i in range(pos.line, pos.line + count)]
|
|
1000
|
+
joined, at = join(raws)
|
|
1001
|
+
document = self._editor.document()
|
|
1002
|
+
start = document.findBlockByNumber(pos.line).position()
|
|
1003
|
+
with self._change(pos) as edit:
|
|
1004
|
+
# from the last line up, so the lines above keep their positions
|
|
1005
|
+
for i, (blanks, space) in reversed(list(enumerate(join_parts(raws), 1))):
|
|
1006
|
+
block = document.findBlockByNumber(pos.line + i)
|
|
1007
|
+
edit.setPosition(block.position() - 1)
|
|
1008
|
+
edit.setPosition(block.position() + blanks, KEEP_ANCHOR)
|
|
1009
|
+
edit.removeSelectedText()
|
|
1010
|
+
if space:
|
|
1011
|
+
edit.insertText(space)
|
|
1012
|
+
lines = self._lines()
|
|
1013
|
+
self._put(lines, self._from_qt(lines, start + utf16_len(joined[:at])))
|
|
1014
|
+
|
|
1015
|
+
def _store(self, text: str, linewise: bool) -> None:
|
|
1016
|
+
global _register
|
|
1017
|
+
_register = Register(text, linewise)
|
|
1018
|
+
if self.clipboard:
|
|
1019
|
+
QGuiApplication.clipboard().setText(text)
|
|
1020
|
+
|
|
1021
|
+
def _read_register(self) -> Register:
|
|
1022
|
+
"""What p pastes. From the clipboard: if it still holds our last yank, that
|
|
1023
|
+
yank's kind; otherwise whole lines if the text ends in a newline."""
|
|
1024
|
+
if not self.clipboard:
|
|
1025
|
+
return _register
|
|
1026
|
+
text = QGuiApplication.clipboard().text().replace("\r\n", "\n")
|
|
1027
|
+
if text == _register.text:
|
|
1028
|
+
return _register
|
|
1029
|
+
return Register(text, linewise=text.endswith("\n"))
|
|
1030
|
+
|
|
1031
|
+
def _check_synced(self) -> None:
|
|
1032
|
+
"""Forget where `u` goes if the document changed without vim, since its undo
|
|
1033
|
+
steps no longer match the ones vim knows."""
|
|
1034
|
+
version = self._version()
|
|
1035
|
+
if version != self._synced:
|
|
1036
|
+
self._done.clear()
|
|
1037
|
+
self._undone.clear()
|
|
1038
|
+
self._synced = version
|
|
1039
|
+
# like vim reading a file: an empty one has no lines
|
|
1040
|
+
self._no_lines = not self._editor.document().toRawText()
|
|
1041
|
+
|
|
1042
|
+
def _has_no_lines(self, lines: Lines) -> bool:
|
|
1043
|
+
self._check_synced()
|
|
1044
|
+
return self._no_lines and len(lines) == 1 and not lines[0]
|
|
1045
|
+
|
|
1046
|
+
@contextmanager
|
|
1047
|
+
def _change(self, saved: Pos) -> Iterator[QTextCursor]:
|
|
1048
|
+
"""One Normal mode command's edits, as one undo step. saved: where `u` puts
|
|
1049
|
+
the cursor back."""
|
|
1050
|
+
document = self._editor.document()
|
|
1051
|
+
self._check_synced()
|
|
1052
|
+
no_lines = self._no_lines
|
|
1053
|
+
steps: list[None] = []
|
|
1054
|
+
|
|
1055
|
+
def on_step() -> None:
|
|
1056
|
+
steps.append(None)
|
|
1057
|
+
|
|
1058
|
+
# the revision isn't enough: an edit that changes nothing (`dd` on an empty
|
|
1059
|
+
# text) counts as a revision but adds no undo step
|
|
1060
|
+
document.undoCommandAdded.connect(on_step)
|
|
1061
|
+
edit = QTextCursor(document)
|
|
1062
|
+
self._busy = True
|
|
1063
|
+
edit.beginEditBlock()
|
|
1064
|
+
try:
|
|
1065
|
+
yield edit
|
|
1066
|
+
finally:
|
|
1067
|
+
edit.endEditBlock()
|
|
1068
|
+
self._busy = False
|
|
1069
|
+
document.undoCommandAdded.disconnect(on_step)
|
|
1070
|
+
if steps:
|
|
1071
|
+
self._done.append(UndoStep(saved, no_lines))
|
|
1072
|
+
self._undone.clear()
|
|
1073
|
+
self._no_lines = False
|
|
1074
|
+
self._synced = self._version()
|
|
1075
|
+
|
|
1076
|
+
def _undo_command(self, command: Command) -> None:
|
|
1077
|
+
self._undo(command.count or 1, redo=command.name == "<C-r>")
|
|
1078
|
+
|
|
1079
|
+
def _undo(self, count: int, redo: bool) -> None:
|
|
1080
|
+
document = self._editor.document()
|
|
1081
|
+
self._check_synced()
|
|
1082
|
+
source, target = (self._undone, self._done) if redo else (self._done, self._undone)
|
|
1083
|
+
changed: list[tuple[int, int]] = []
|
|
1084
|
+
saved: Pos | None = None
|
|
1085
|
+
|
|
1086
|
+
def on_change(position: int, removed: int, added: int) -> None:
|
|
1087
|
+
changed.append((position, position + added))
|
|
1088
|
+
|
|
1089
|
+
for _ in range(count):
|
|
1090
|
+
if source and not source[-1].in_qt:
|
|
1091
|
+
step = source.pop()
|
|
1092
|
+
saved = step.cursor
|
|
1093
|
+
target.append(UndoStep(saved, self._no_lines, in_qt=False))
|
|
1094
|
+
self._no_lines = step.no_lines
|
|
1095
|
+
continue
|
|
1096
|
+
if not (document.isRedoAvailable() if redo else document.isUndoAvailable()):
|
|
1097
|
+
break
|
|
1098
|
+
step = source.pop() if source else None
|
|
1099
|
+
saved = step.cursor if step else None
|
|
1100
|
+
target.append(UndoStep(saved, self._no_lines))
|
|
1101
|
+
changed.clear()
|
|
1102
|
+
document.contentsChange.connect(on_change)
|
|
1103
|
+
self._busy = True
|
|
1104
|
+
try:
|
|
1105
|
+
if redo:
|
|
1106
|
+
document.redo()
|
|
1107
|
+
else:
|
|
1108
|
+
document.undo()
|
|
1109
|
+
finally:
|
|
1110
|
+
self._busy = False
|
|
1111
|
+
document.contentsChange.disconnect(on_change)
|
|
1112
|
+
self._no_lines = step.no_lines if step else not document.toRawText()
|
|
1113
|
+
self._synced = self._version()
|
|
1114
|
+
lines = self._lines()
|
|
1115
|
+
if changed:
|
|
1116
|
+
first = self._from_qt(lines, min(start for start, _ in changed)).line
|
|
1117
|
+
last = self._from_qt(lines, max(end for _, end in changed)).line
|
|
1118
|
+
self._put(lines, undo_cursor(lines, saved, first, last))
|
|
1119
|
+
else:
|
|
1120
|
+
self._put(lines, self._pos(lines))
|
|
1121
|
+
|
|
1122
|
+
def _start_insert(self, command: Command) -> None:
|
|
1123
|
+
lines = self._lines()
|
|
1124
|
+
pos = self._pos(lines)
|
|
1125
|
+
text = lines[pos.line]
|
|
1126
|
+
name = command.name
|
|
1127
|
+
open_line = name in ("o", "O")
|
|
1128
|
+
self._begin_insert(pos if open_line else None, command.count or 1, open_line)
|
|
1129
|
+
cursor = self._editor.textCursor()
|
|
1130
|
+
cursor.clearSelection()
|
|
1131
|
+
if open_line:
|
|
1132
|
+
at = self._to_qt(lines, Pos(pos.line, len(text) if name == "o" else 0))
|
|
1133
|
+
# in an edit block, so Qt can't add it to the undo step before
|
|
1134
|
+
edit = QTextCursor(self._editor.document())
|
|
1135
|
+
edit.beginEditBlock()
|
|
1136
|
+
edit.setPosition(at)
|
|
1137
|
+
edit.insertText("\n")
|
|
1138
|
+
edit.endEditBlock()
|
|
1139
|
+
cursor.setPosition(at + 1 if name == "o" else at)
|
|
1140
|
+
else:
|
|
1141
|
+
if name == "i":
|
|
1142
|
+
col = pos.col
|
|
1143
|
+
elif name == "a":
|
|
1144
|
+
col = min(pos.col + 1, len(text))
|
|
1145
|
+
elif name == "I":
|
|
1146
|
+
# the first non-blank, or the line end if it's all blanks
|
|
1147
|
+
col = next((i for i, ch in enumerate(text) if ch not in BLANKS), len(text))
|
|
1148
|
+
else:
|
|
1149
|
+
col = len(text)
|
|
1150
|
+
cursor.setPosition(self._to_qt(lines, Pos(pos.line, col)))
|
|
1151
|
+
self._typing_starts(cursor)
|
|
1152
|
+
|
|
1153
|
+
def _begin_insert(self, saved: Pos | None, count: int, open_line: bool) -> None:
|
|
1154
|
+
"""Start an insert session, before anything in it changes the text."""
|
|
1155
|
+
self._check_synced()
|
|
1156
|
+
document = self._editor.document()
|
|
1157
|
+
self._set_mode(Mode.INSERT)
|
|
1158
|
+
self._insert = _Insert(
|
|
1159
|
+
count=count,
|
|
1160
|
+
open_line=open_line,
|
|
1161
|
+
document=document,
|
|
1162
|
+
origin=document.toRawText(),
|
|
1163
|
+
saved=saved,
|
|
1164
|
+
no_lines=self._no_lines,
|
|
1165
|
+
)
|
|
1166
|
+
document.undoCommandAdded.connect(self._on_undo_step)
|
|
1167
|
+
document.contentsChange.connect(self._on_insert_change)
|
|
1168
|
+
document.redoAvailable.connect(self._on_redo_available)
|
|
1169
|
+
|
|
1170
|
+
def _typing_starts(self, cursor: QTextCursor) -> None:
|
|
1171
|
+
self._editor.setTextCursor(cursor)
|
|
1172
|
+
assert self._insert is not None
|
|
1173
|
+
self._insert.start = cursor.position()
|
|
1174
|
+
self._insert.before = self._utf16()
|
|
1175
|
+
|
|
1176
|
+
def _on_undo_step(self) -> None:
|
|
1177
|
+
if self._insert is not None and not self._busy:
|
|
1178
|
+
self._insert.levels += 1
|
|
1179
|
+
|
|
1180
|
+
def _on_insert_change(self, position: int, removed: int, added: int) -> None:
|
|
1181
|
+
insert = self._insert
|
|
1182
|
+
if insert is None or self._busy:
|
|
1183
|
+
return
|
|
1184
|
+
if insert.levels == insert.seen and insert.levels == 0:
|
|
1185
|
+
# no new undo step for this change: Qt added it to the one before
|
|
1186
|
+
insert.merged_outside = True
|
|
1187
|
+
insert.seen = insert.levels
|
|
1188
|
+
insert.edited = True
|
|
1189
|
+
if insert.saved is None:
|
|
1190
|
+
# the session's own document: the editor may show another one by now, or be
|
|
1191
|
+
# half gone, as when the app quits in Insert mode
|
|
1192
|
+
document = insert.document
|
|
1193
|
+
insert.saved = _from_qt(document, Lines(_Blocks(document)), position)
|
|
1194
|
+
|
|
1195
|
+
def _on_redo_available(self, available: bool) -> None:
|
|
1196
|
+
if available and self._insert is not None and not self._busy:
|
|
1197
|
+
self._insert.messy = True
|
|
1198
|
+
|
|
1199
|
+
def _leave_insert(self) -> None:
|
|
1200
|
+
insert = self._insert
|
|
1201
|
+
if insert is not None and insert.document is self._editor.document():
|
|
1202
|
+
if insert.count > 1:
|
|
1203
|
+
self._repeat(insert)
|
|
1204
|
+
self._end_insert(insert)
|
|
1205
|
+
else:
|
|
1206
|
+
# A tab switch may have deleted the old document. Drop its session
|
|
1207
|
+
# before reading it again or repeating its text in the new one.
|
|
1208
|
+
self._drop_insert()
|
|
1209
|
+
self._set_mode(Mode.NORMAL)
|
|
1210
|
+
# like vim, the cursor steps back onto the last character typed
|
|
1211
|
+
lines = self._lines()
|
|
1212
|
+
pos = self._pos(lines)
|
|
1213
|
+
self._go(lines, Pos(pos.line, max(pos.col - 1, 0)))
|
|
1214
|
+
|
|
1215
|
+
def _disconnect(self, insert: _Insert) -> None:
|
|
1216
|
+
self._insert = None
|
|
1217
|
+
document = insert.document
|
|
1218
|
+
with suppress(RuntimeError, TypeError):
|
|
1219
|
+
document.undoCommandAdded.disconnect(self._on_undo_step)
|
|
1220
|
+
document.contentsChange.disconnect(self._on_insert_change)
|
|
1221
|
+
document.redoAvailable.disconnect(self._on_redo_available)
|
|
1222
|
+
|
|
1223
|
+
def _drop_insert(self) -> None:
|
|
1224
|
+
"""End a session without making it one undo step."""
|
|
1225
|
+
insert = self._insert
|
|
1226
|
+
if insert is not None:
|
|
1227
|
+
self._disconnect(insert)
|
|
1228
|
+
self._done.clear()
|
|
1229
|
+
self._undone.clear()
|
|
1230
|
+
self._synced = self._version()
|
|
1231
|
+
if insert.document is not self._editor.document():
|
|
1232
|
+
self._no_lines = not self._editor.document().toRawText()
|
|
1233
|
+
elif insert.edited:
|
|
1234
|
+
self._no_lines = False
|
|
1235
|
+
|
|
1236
|
+
def _end_insert(self, insert: _Insert) -> None:
|
|
1237
|
+
self._disconnect(insert)
|
|
1238
|
+
document = insert.document
|
|
1239
|
+
final = document.toRawText()
|
|
1240
|
+
changed = final != insert.origin
|
|
1241
|
+
if insert.messy:
|
|
1242
|
+
# the app's own undo ran: leave its steps as they are
|
|
1243
|
+
self._done.clear()
|
|
1244
|
+
self._undone.clear()
|
|
1245
|
+
elif insert.levels >= 2 or insert.merged_outside or (insert.levels and not changed):
|
|
1246
|
+
if not self._regroup(insert, final):
|
|
1247
|
+
self._done.clear()
|
|
1248
|
+
self._undone.clear()
|
|
1249
|
+
elif changed:
|
|
1250
|
+
self._done.append(UndoStep(insert.saved, insert.no_lines))
|
|
1251
|
+
self._undone.clear()
|
|
1252
|
+
elif changed:
|
|
1253
|
+
self._done.append(UndoStep(insert.saved, insert.no_lines))
|
|
1254
|
+
self._undone.clear()
|
|
1255
|
+
if insert.edited and insert.no_lines and not changed:
|
|
1256
|
+
# typed and deleted again: Qt has no step, but vim's goes back to no lines
|
|
1257
|
+
self._done.append(UndoStep(insert.saved, True, in_qt=False))
|
|
1258
|
+
self._undone.clear()
|
|
1259
|
+
if insert.edited:
|
|
1260
|
+
self._no_lines = False
|
|
1261
|
+
self._synced = self._version()
|
|
1262
|
+
|
|
1263
|
+
def _regroup(self, insert: _Insert, final: str) -> bool:
|
|
1264
|
+
"""Make the session one undo step: undo its steps, then make its whole change
|
|
1265
|
+
again in one edit block. False if the steps didn't lead back to where the
|
|
1266
|
+
session started; then everything is put back as it was."""
|
|
1267
|
+
document = insert.document
|
|
1268
|
+
modified = document.isModified()
|
|
1269
|
+
position = self._editor.textCursor().position()
|
|
1270
|
+
steps = insert.levels + insert.merged_outside
|
|
1271
|
+
self._busy = True
|
|
1272
|
+
try:
|
|
1273
|
+
undone = 0
|
|
1274
|
+
while undone < steps and document.isUndoAvailable():
|
|
1275
|
+
document.undo()
|
|
1276
|
+
undone += 1
|
|
1277
|
+
now = document.toRawText()
|
|
1278
|
+
if now != insert.origin and not insert.merged_outside:
|
|
1279
|
+
for _ in range(undone):
|
|
1280
|
+
document.redo()
|
|
1281
|
+
return False
|
|
1282
|
+
if now != insert.origin:
|
|
1283
|
+
# the step before took the session's first change: make it again alone
|
|
1284
|
+
_replace(document, now, insert.origin)
|
|
1285
|
+
if final != insert.origin:
|
|
1286
|
+
_replace(document, insert.origin, final)
|
|
1287
|
+
else:
|
|
1288
|
+
document.clearUndoRedoStacks(QTextDocument.Stacks.RedoStack)
|
|
1289
|
+
finally:
|
|
1290
|
+
self._busy = False
|
|
1291
|
+
# The app may have saved during Insert mode. Regrouping undo steps
|
|
1292
|
+
# leaves the text as it was, so it must keep that saved state too.
|
|
1293
|
+
document.setModified(modified)
|
|
1294
|
+
cursor = self._editor.textCursor()
|
|
1295
|
+
cursor.setPosition(min(position, document.characterCount() - 1))
|
|
1296
|
+
self._editor.setTextCursor(cursor)
|
|
1297
|
+
return True
|
|
1298
|
+
|
|
1299
|
+
def _repeat(self, insert: _Insert) -> None:
|
|
1300
|
+
"""Type the text again, count - 1 more times, like vim's `3ix<Esc>`."""
|
|
1301
|
+
cursor = self._editor.textCursor()
|
|
1302
|
+
typed = self._typed(insert, cursor.position())
|
|
1303
|
+
if typed is None or not (typed or insert.open_line):
|
|
1304
|
+
return
|
|
1305
|
+
chunk = "\n" + typed if insert.open_line else typed
|
|
1306
|
+
cursor.clearSelection()
|
|
1307
|
+
cursor.insertText(chunk * (insert.count - 1))
|
|
1308
|
+
self._editor.setTextCursor(cursor)
|
|
1309
|
+
|
|
1310
|
+
def _typed(self, insert: _Insert, position: int) -> str | None:
|
|
1311
|
+
"""The text typed in this session, or None if more happened than typing at one
|
|
1312
|
+
spot: the cursor moved away, or text before the start changed. Vim drops the
|
|
1313
|
+
repeat then too."""
|
|
1314
|
+
after = self._utf16()
|
|
1315
|
+
start = insert.start * 2
|
|
1316
|
+
grown = len(after) - len(insert.before)
|
|
1317
|
+
if grown < 0 or position * 2 != start + grown:
|
|
1318
|
+
return None
|
|
1319
|
+
if (
|
|
1320
|
+
after[:start] != insert.before[:start]
|
|
1321
|
+
or after[start + grown :] != insert.before[start:]
|
|
1322
|
+
):
|
|
1323
|
+
return None
|
|
1324
|
+
return after[start : start + grown].decode("utf-16-le").replace(PARAGRAPH_SEPARATOR, "\n")
|
|
1325
|
+
|
|
1326
|
+
def _utf16(self) -> bytes:
|
|
1327
|
+
return self._editor.document().toRawText().encode("utf-16-le", "surrogatepass")
|
|
1328
|
+
|
|
1329
|
+
def _set_mode(self, mode: Mode) -> None:
|
|
1330
|
+
changed = mode is not self._mode
|
|
1331
|
+
self._mode = mode
|
|
1332
|
+
self._update_cursor_width()
|
|
1333
|
+
if changed:
|
|
1334
|
+
self.mode_changed.emit(mode)
|
|
1335
|
+
|
|
1336
|
+
def _emit_pending(self) -> None:
|
|
1337
|
+
pending = self._parser.pending
|
|
1338
|
+
if pending != self._pending:
|
|
1339
|
+
self._pending = pending
|
|
1340
|
+
self.pending_changed.emit(pending)
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def _from_qt(document: QTextDocument, lines: Lines, position: int) -> Pos:
|
|
1344
|
+
"""Like Lines.from_qt, but the document finds the line."""
|
|
1345
|
+
block = document.findBlock(position)
|
|
1346
|
+
if not block.isValid():
|
|
1347
|
+
block = document.lastBlock()
|
|
1348
|
+
line = block.blockNumber()
|
|
1349
|
+
return Pos(line, lines.col_at(line, position - block.position()))
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def _replace(document: QTextDocument, old: str, new: str) -> None:
|
|
1353
|
+
"""Turn the document's text from old into new with one edit block, changing only
|
|
1354
|
+
the part between what they share at the start and at the end."""
|
|
1355
|
+
head = _shared_head(old, new)
|
|
1356
|
+
tail = _shared_head(old[head:][::-1], new[head:][::-1])
|
|
1357
|
+
edit = QTextCursor(document)
|
|
1358
|
+
edit.beginEditBlock()
|
|
1359
|
+
edit.setPosition(utf16_len(old[:head]))
|
|
1360
|
+
edit.setPosition(utf16_len(old[: len(old) - tail]), KEEP_ANCHOR)
|
|
1361
|
+
edit.removeSelectedText()
|
|
1362
|
+
text = new[head : len(new) - tail]
|
|
1363
|
+
if text:
|
|
1364
|
+
edit.insertText(text.replace(PARAGRAPH_SEPARATOR, "\n"))
|
|
1365
|
+
edit.endEditBlock()
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def _shared_head(a: str, b: str) -> int:
|
|
1369
|
+
"""How many code points a and b share at the start."""
|
|
1370
|
+
low, high = 0, min(len(a), len(b))
|
|
1371
|
+
while low < high:
|
|
1372
|
+
mid = (low + high + 1) // 2
|
|
1373
|
+
if a[:mid] == b[:mid]:
|
|
1374
|
+
low = mid
|
|
1375
|
+
else:
|
|
1376
|
+
high = mid - 1
|
|
1377
|
+
return low
|
|
1378
|
+
|
|
1379
|
+
|
|
1380
|
+
class _Blocks(Sequence[str]):
|
|
1381
|
+
"""The texts of a document's blocks (vim's lines), read when asked."""
|
|
1382
|
+
|
|
1383
|
+
def __init__(self, document: QTextDocument) -> None:
|
|
1384
|
+
self._document = document
|
|
1385
|
+
|
|
1386
|
+
def __len__(self) -> int:
|
|
1387
|
+
return self._document.blockCount()
|
|
1388
|
+
|
|
1389
|
+
@overload
|
|
1390
|
+
def __getitem__(self, index: int) -> str: ...
|
|
1391
|
+
@overload
|
|
1392
|
+
def __getitem__(self, index: slice) -> list[str]: ...
|
|
1393
|
+
def __getitem__(self, index: int | slice) -> str | list[str]:
|
|
1394
|
+
if isinstance(index, slice):
|
|
1395
|
+
return [self[i] for i in range(*index.indices(len(self)))]
|
|
1396
|
+
if not 0 <= index < len(self):
|
|
1397
|
+
raise IndexError(index)
|
|
1398
|
+
return self._document.findBlockByNumber(index).text()
|