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/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
+ }
side_vim/mode.py ADDED
@@ -0,0 +1,10 @@
1
+ """The vim modes."""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class Mode(Enum):
7
+ NORMAL = "normal"
8
+ INSERT = "insert"
9
+ VISUAL = "visual"
10
+ VISUAL_LINE = "visual line"