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/caret.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""vim's block cursor in Visual mode.
|
|
2
|
+
|
|
3
|
+
The selection is the editor's own, so the app's copy (Ctrl+C) copies what `y` yanks.
|
|
4
|
+
But Qt draws its cursor at one end of its selection, and in vim the character under
|
|
5
|
+
the cursor is part of the selection, so the two don't line up. In Visual mode the
|
|
6
|
+
editor's cursor is hidden and this widget draws vim's one on top of the text. It
|
|
7
|
+
blinks like the editor's cursor, and only while the editor has focus.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from math import ceil
|
|
11
|
+
|
|
12
|
+
from PySide6.QtCore import QPointF, QRect, Qt, QTimer
|
|
13
|
+
from PySide6.QtGui import QGuiApplication, QPainter, QPaintEvent, QPalette, QTextCursor
|
|
14
|
+
from PySide6.QtWidgets import QPlainTextEdit, QWidget
|
|
15
|
+
|
|
16
|
+
from side_vim.text import split_clusters, utf16_len
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Caret(QWidget):
|
|
20
|
+
def __init__(self, editor: QPlainTextEdit) -> None:
|
|
21
|
+
super().__init__(editor.viewport())
|
|
22
|
+
self._editor = editor
|
|
23
|
+
self._position: int | None = None
|
|
24
|
+
self._text = ""
|
|
25
|
+
self._ascent = 0.0
|
|
26
|
+
self._on = True # the half of the blink it's drawn in
|
|
27
|
+
self._blink = QTimer(self)
|
|
28
|
+
self._blink.timeout.connect(self._toggle)
|
|
29
|
+
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
|
30
|
+
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
|
31
|
+
self.hide()
|
|
32
|
+
editor.updateRequest.connect(self._on_update_request)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def position(self) -> int | None:
|
|
36
|
+
"""The Qt position of the character it's on, or None when hidden."""
|
|
37
|
+
return self._position
|
|
38
|
+
|
|
39
|
+
def show_at(self, position: int | None) -> None:
|
|
40
|
+
"""Draw the cursor on the character at this Qt position; None hides it."""
|
|
41
|
+
if position != self._position:
|
|
42
|
+
self._position = position
|
|
43
|
+
self.restart_blink()
|
|
44
|
+
self.place()
|
|
45
|
+
|
|
46
|
+
def restart_blink(self) -> None:
|
|
47
|
+
"""Drawn from now on for half a blink, like the editor's cursor after a move.
|
|
48
|
+
Call it when the editor gets or loses focus."""
|
|
49
|
+
self._on = True
|
|
50
|
+
flash = QGuiApplication.styleHints().cursorFlashTime()
|
|
51
|
+
if self._position is not None and flash > 0 and self._editor.hasFocus():
|
|
52
|
+
self._blink.start(flash // 2)
|
|
53
|
+
else:
|
|
54
|
+
self._blink.stop()
|
|
55
|
+
self.update()
|
|
56
|
+
|
|
57
|
+
def _toggle(self) -> None:
|
|
58
|
+
self._on = not self._on
|
|
59
|
+
self.update()
|
|
60
|
+
|
|
61
|
+
def place(self) -> None:
|
|
62
|
+
"""Put the widget over its character again, after a scroll or a change."""
|
|
63
|
+
position = self._position
|
|
64
|
+
document = self._editor.document()
|
|
65
|
+
if position is None or position >= document.characterCount():
|
|
66
|
+
self.hide()
|
|
67
|
+
return
|
|
68
|
+
cursor = QTextCursor(document)
|
|
69
|
+
cursor.setPosition(position)
|
|
70
|
+
block = cursor.block()
|
|
71
|
+
offset = position - block.position()
|
|
72
|
+
line = block.layout().lineForTextPosition(offset)
|
|
73
|
+
if not line.isValid():
|
|
74
|
+
self.hide()
|
|
75
|
+
return
|
|
76
|
+
self._text = char_at(block.text(), offset)
|
|
77
|
+
self._ascent = line.ascent()
|
|
78
|
+
rect = self._editor.cursorRect(cursor)
|
|
79
|
+
width = char_width(self._editor, position)
|
|
80
|
+
geometry = QRect(rect.left(), rect.top(), width, ceil(line.height()))
|
|
81
|
+
if self.geometry() != geometry:
|
|
82
|
+
self.setGeometry(geometry)
|
|
83
|
+
self.update()
|
|
84
|
+
self.show()
|
|
85
|
+
self.raise_()
|
|
86
|
+
|
|
87
|
+
def _on_update_request(self, _rect: QRect, _dy: int) -> None:
|
|
88
|
+
if self._position is not None:
|
|
89
|
+
self.place()
|
|
90
|
+
|
|
91
|
+
def paintEvent(self, event: QPaintEvent) -> None:
|
|
92
|
+
if not (self._on and self._editor.hasFocus()):
|
|
93
|
+
return
|
|
94
|
+
palette = self._editor.palette()
|
|
95
|
+
painter = QPainter(self)
|
|
96
|
+
painter.fillRect(self.rect(), palette.color(QPalette.ColorRole.Text))
|
|
97
|
+
if self._text.strip():
|
|
98
|
+
painter.setPen(palette.color(QPalette.ColorRole.Base))
|
|
99
|
+
painter.setFont(self._editor.font())
|
|
100
|
+
painter.drawText(QPointF(0, self._ascent), self._text)
|
|
101
|
+
painter.end()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def char_at(text: str, offset: int) -> str:
|
|
105
|
+
"""The character starting at a UTF-16 offset in a block's text, or "" at its end."""
|
|
106
|
+
end = 0
|
|
107
|
+
for cluster in split_clusters(text):
|
|
108
|
+
end += utf16_len(cluster)
|
|
109
|
+
if end > offset:
|
|
110
|
+
return cluster
|
|
111
|
+
return ""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def char_width(editor: QPlainTextEdit, position: int) -> int:
|
|
115
|
+
"""The width of the character at a Qt position, as the editor draws it. At the end
|
|
116
|
+
of a line, the width of an "x"."""
|
|
117
|
+
cursor = QTextCursor(editor.document())
|
|
118
|
+
cursor.setPosition(position)
|
|
119
|
+
block = cursor.block()
|
|
120
|
+
offset = position - block.position()
|
|
121
|
+
metrics = editor.fontMetrics()
|
|
122
|
+
cluster = char_at(block.text(), offset)
|
|
123
|
+
if not cluster:
|
|
124
|
+
return max(metrics.horizontalAdvance("x"), 1)
|
|
125
|
+
line = block.layout().lineForTextPosition(offset)
|
|
126
|
+
end = offset + utf16_len(cluster)
|
|
127
|
+
if line.isValid() and end <= line.textStart() + line.textLength():
|
|
128
|
+
width = line.cursorToX(end)[0] - line.cursorToX(offset)[0]
|
|
129
|
+
if width >= 1:
|
|
130
|
+
return ceil(width)
|
|
131
|
+
return max(metrics.horizontalAdvance(cluster), metrics.horizontalAdvance("x"), 1)
|
side_vim/edits.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Edits vim makes that are worked out on plain text: joining lines, and where the
|
|
2
|
+
cursor goes after undo, redo and Visual mode operators. No Qt here.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from side_vim.motions import BLANKS, Line, Pos, first_non_blank
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Register:
|
|
13
|
+
"""What yank, delete and change saved. linewise: whole lines (`yy`, `dd`), and the
|
|
14
|
+
text ends with a newline. Otherwise part of a line (`yw`, `x`)."""
|
|
15
|
+
|
|
16
|
+
text: str = ""
|
|
17
|
+
linewise: bool = False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def join_parts(lines: Sequence[str]) -> list[tuple[int, str]]:
|
|
21
|
+
"""How vim's J (with 'nojoinspaces') joins lines: for each line after the first,
|
|
22
|
+
how many leading blanks it loses, and what goes before it ("" or one space).
|
|
23
|
+
|
|
24
|
+
No space goes in when the line so far is empty or ends in a blank, or when the
|
|
25
|
+
next line is empty (or all blanks) or starts with ")".
|
|
26
|
+
"""
|
|
27
|
+
parts: list[tuple[int, str]] = []
|
|
28
|
+
empty = not lines[0]
|
|
29
|
+
previous = lines[0]
|
|
30
|
+
for line in lines[1:]:
|
|
31
|
+
rest = line.lstrip("".join(BLANKS))
|
|
32
|
+
space = bool(rest) and not rest.startswith(")") and not empty
|
|
33
|
+
space = space and not previous.endswith(BLANKS)
|
|
34
|
+
parts.append((len(line) - len(rest), " " if space else ""))
|
|
35
|
+
empty = empty and not rest
|
|
36
|
+
previous = rest
|
|
37
|
+
return parts
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def join(lines: Sequence[str]) -> tuple[str, int]:
|
|
41
|
+
"""The line vim's J makes of these lines, and the index in it (in code points)
|
|
42
|
+
where the cursor goes: where the last join happened."""
|
|
43
|
+
out = lines[0]
|
|
44
|
+
cursor = 0
|
|
45
|
+
for line, (blanks, space) in zip(lines[1:], join_parts(lines), strict=True):
|
|
46
|
+
cursor = len(out)
|
|
47
|
+
out += space + line[blanks:]
|
|
48
|
+
return out, cursor
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def undo_cursor(lines: Sequence[Line], saved: Pos | None, first: int, last: int) -> Pos:
|
|
52
|
+
"""Where vim puts the cursor after an undo or redo (u_undoredo in vim's undo.c).
|
|
53
|
+
|
|
54
|
+
saved: the cursor when the change was made, or None if unknown. first and last:
|
|
55
|
+
the lines the undo or redo changed, as they are now. If the saved cursor is in or
|
|
56
|
+
next to those lines, it goes back there. Otherwise it goes to the first changed
|
|
57
|
+
line, on its first non-blank.
|
|
58
|
+
"""
|
|
59
|
+
last_line = len(lines) - 1
|
|
60
|
+
if saved is not None and first - 1 <= saved.line <= last + 1:
|
|
61
|
+
line = saved.line
|
|
62
|
+
else:
|
|
63
|
+
line = first
|
|
64
|
+
if saved is not None and saved.line + 1 == line and line > 0:
|
|
65
|
+
# off by one line: back where the change was made (undoing an `o`)
|
|
66
|
+
line = saved.line
|
|
67
|
+
line = min(max(line, 0), last_line)
|
|
68
|
+
text = lines[line]
|
|
69
|
+
col = saved.col if saved is not None and saved.line == line else first_non_blank(text)
|
|
70
|
+
return Pos(line, min(col, max(len(text) - 1, 0)))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def visual_start(anchor: Pos, cursor: Pos, linewise: bool) -> Pos:
|
|
74
|
+
"""Where an operator on a Visual selection starts: the cursor goes there after `y`,
|
|
75
|
+
and `u` puts it back there.
|
|
76
|
+
|
|
77
|
+
Part of a line: the start of the selection. Whole lines: the cursor if it's on a
|
|
78
|
+
line above the other end, otherwise column 0 of the first line, like vim.
|
|
79
|
+
"""
|
|
80
|
+
if not linewise:
|
|
81
|
+
return min(anchor, cursor)
|
|
82
|
+
if cursor.line < anchor.line:
|
|
83
|
+
return cursor
|
|
84
|
+
return Pos(anchor.line, 0)
|
side_vim/keys.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Qt key events as vim key tokens: "w", "G", "<Esc>", "<C-r>", "<Left>".
|
|
2
|
+
|
|
3
|
+
None means the key isn't for vim: a modifier on its own, a key with Cmd (macOS) or
|
|
4
|
+
Meta, or a key with no text that vim doesn't know (PageUp, F1). Those go to the editor
|
|
5
|
+
or the app as usual.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import unicodedata
|
|
10
|
+
|
|
11
|
+
from PySide6.QtCore import QCoreApplication, Qt
|
|
12
|
+
from PySide6.QtGui import QKeyEvent
|
|
13
|
+
|
|
14
|
+
ESC = "<Esc>"
|
|
15
|
+
IS_MAC = sys.platform == "darwin"
|
|
16
|
+
|
|
17
|
+
# Invisible characters used in emoji and Indic scripts.
|
|
18
|
+
JOINERS = frozenset({"\u200c", "\u200d"})
|
|
19
|
+
|
|
20
|
+
# Keys without text, by their vim name. Shift and Alt add "S-" and "A-".
|
|
21
|
+
NAMED = {
|
|
22
|
+
Qt.Key.Key_Escape: "Esc",
|
|
23
|
+
Qt.Key.Key_Left: "Left",
|
|
24
|
+
Qt.Key.Key_Right: "Right",
|
|
25
|
+
Qt.Key.Key_Up: "Up",
|
|
26
|
+
Qt.Key.Key_Down: "Down",
|
|
27
|
+
Qt.Key.Key_Home: "Home",
|
|
28
|
+
Qt.Key.Key_End: "End",
|
|
29
|
+
Qt.Key.Key_Return: "CR",
|
|
30
|
+
Qt.Key.Key_Enter: "CR",
|
|
31
|
+
Qt.Key.Key_Backspace: "BS",
|
|
32
|
+
Qt.Key.Key_Delete: "Del",
|
|
33
|
+
Qt.Key.Key_Tab: "Tab",
|
|
34
|
+
Qt.Key.Key_Backtab: "Tab",
|
|
35
|
+
Qt.Key.Key_Insert: "Insert",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
MODIFIER_KEYS = frozenset(
|
|
39
|
+
{
|
|
40
|
+
Qt.Key.Key_Shift,
|
|
41
|
+
Qt.Key.Key_Control,
|
|
42
|
+
Qt.Key.Key_Meta,
|
|
43
|
+
Qt.Key.Key_Alt,
|
|
44
|
+
Qt.Key.Key_AltGr,
|
|
45
|
+
Qt.Key.Key_CapsLock,
|
|
46
|
+
Qt.Key.Key_NumLock,
|
|
47
|
+
Qt.Key.Key_ScrollLock,
|
|
48
|
+
Qt.Key.Key_Super_L,
|
|
49
|
+
Qt.Key.Key_Super_R,
|
|
50
|
+
Qt.Key.Key_Hyper_L,
|
|
51
|
+
Qt.Key.Key_Hyper_R,
|
|
52
|
+
Qt.Key.Key_Mode_switch,
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def map_layout(token: str, layout: dict[str, str] | None) -> str:
|
|
58
|
+
"""A key token with its letter mapped through a layout map: "к" -> "r",
|
|
59
|
+
"<C-к>" -> "<C-r>", and "<C-х>" -> "<Esc>" like Ctrl+[. Tokens it doesn't know
|
|
60
|
+
stay as they are."""
|
|
61
|
+
if not layout:
|
|
62
|
+
return token
|
|
63
|
+
if token in layout:
|
|
64
|
+
return layout[token]
|
|
65
|
+
if token.startswith("<C-") and token.endswith(">"):
|
|
66
|
+
head, _, name = token[1:-1].rpartition("-")
|
|
67
|
+
mapped = layout.get(name)
|
|
68
|
+
if mapped is not None and len(mapped) == 1:
|
|
69
|
+
if head == "C" and mapped == "[":
|
|
70
|
+
return ESC # Ctrl+х is the key of Ctrl+[
|
|
71
|
+
# Ctrl tokens use the lower case name, like key_token does
|
|
72
|
+
return f"<{head}-{mapped.lower() if mapped.isalpha() else mapped}>"
|
|
73
|
+
return token
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def control_modifier(mac: bool = IS_MAC) -> Qt.KeyboardModifier:
|
|
77
|
+
"""The modifier the physical Control key arrives as. On macOS Qt swaps Ctrl and Cmd,
|
|
78
|
+
so Control arrives as Meta, unless the app turned the swap off."""
|
|
79
|
+
swapped = not QCoreApplication.testAttribute(Qt.ApplicationAttribute.AA_MacDontSwapCtrlAndMeta)
|
|
80
|
+
if mac and swapped:
|
|
81
|
+
return Qt.KeyboardModifier.MetaModifier
|
|
82
|
+
return Qt.KeyboardModifier.ControlModifier
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _name(key: int) -> str | None:
|
|
86
|
+
"""A key's name for a Ctrl token: the lower case character, or a NAMED name."""
|
|
87
|
+
if key in NAMED:
|
|
88
|
+
return NAMED[key]
|
|
89
|
+
if key < 0x110000 and chr(key).isprintable():
|
|
90
|
+
return chr(key).lower()
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _is_text(text: str) -> bool:
|
|
95
|
+
"""True if a key typed text. Not str.isprintable: that refuses the joiner in a
|
|
96
|
+
family emoji, and the key would then type in Normal mode."""
|
|
97
|
+
return bool(text) and all(unicodedata.category(ch)[0] != "C" or ch in JOINERS for ch in text)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def key_token(event: QKeyEvent, mac: bool = IS_MAC) -> str | None:
|
|
101
|
+
"""The vim token for a key press, or None if the key isn't for vim."""
|
|
102
|
+
key = event.key()
|
|
103
|
+
if key in MODIFIER_KEYS:
|
|
104
|
+
return None
|
|
105
|
+
mods = event.modifiers()
|
|
106
|
+
ctrl = control_modifier(mac)
|
|
107
|
+
# Cmd on macOS, the Meta (Super) key elsewhere: always the app's
|
|
108
|
+
app_modifier = (
|
|
109
|
+
Qt.KeyboardModifier.MetaModifier
|
|
110
|
+
if ctrl == Qt.KeyboardModifier.ControlModifier
|
|
111
|
+
else Qt.KeyboardModifier.ControlModifier
|
|
112
|
+
)
|
|
113
|
+
if mods & app_modifier:
|
|
114
|
+
return None
|
|
115
|
+
shift = "S-" if mods & Qt.KeyboardModifier.ShiftModifier else ""
|
|
116
|
+
alt = "A-" if mods & Qt.KeyboardModifier.AltModifier else ""
|
|
117
|
+
text = event.text()
|
|
118
|
+
|
|
119
|
+
if mods & ctrl:
|
|
120
|
+
if (key == Qt.Key.Key_BracketLeft or text == "\x1b") and not (shift or alt):
|
|
121
|
+
return ESC
|
|
122
|
+
name = _name(key)
|
|
123
|
+
return f"<C-{alt}{shift}{name}>" if name else None
|
|
124
|
+
|
|
125
|
+
if key in NAMED:
|
|
126
|
+
if key == Qt.Key.Key_Escape:
|
|
127
|
+
return ESC
|
|
128
|
+
return f"<{alt}{shift}{NAMED[key]}>"
|
|
129
|
+
if text == "\x1b":
|
|
130
|
+
return ESC
|
|
131
|
+
if _is_text(text):
|
|
132
|
+
# on macOS, Option is how people type characters, so its text is just text
|
|
133
|
+
return f"<A-{text}>" if alt and not mac else text
|
|
134
|
+
return None
|
side_vim/layouts.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Keyboard layout maps for VimMode.layout_map."""
|
|
2
|
+
|
|
3
|
+
# The Ukrainian layout: each Cyrillic letter maps to the Latin key in the same place,
|
|
4
|
+
# so Normal mode keys work without switching layouts. Only letters: punctuation stays
|
|
5
|
+
# as it is, so the English layout keeps working. ґ is left out, since its key differs
|
|
6
|
+
# between layout variants.
|
|
7
|
+
UKRAINIAN_LOWER = "йцукенгшщзхїфівапролджєячсмитьбю"
|
|
8
|
+
LATIN_LOWER = "qwertyuiop[]asdfghjkl;'zxcvbnm,."
|
|
9
|
+
LATIN_UPPER = 'QWERTYUIOP{}ASDFGHJKL:"ZXCVBNM<>'
|
|
10
|
+
UKRAINIAN: dict[str, str] = {
|
|
11
|
+
**dict(zip(UKRAINIAN_LOWER, LATIN_LOWER, strict=True)),
|
|
12
|
+
**dict(zip(UKRAINIAN_LOWER.upper(), LATIN_UPPER, strict=True)),
|
|
13
|
+
}
|