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/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()