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/scroll.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Ctrl+E, Ctrl+Y, Ctrl+D, Ctrl+U, Ctrl+F and Ctrl+B, worked out on line numbers, like
|
|
2
|
+
vim 9.1 with its defaults ('scrolloff' 0, 'startofline' on). No Qt here.
|
|
3
|
+
|
|
4
|
+
The screen is the lines from `top`, `height` of them. vim can scroll until only the
|
|
5
|
+
last line is left on the screen. An editor can stop earlier, with the last line at its
|
|
6
|
+
bottom: `max_top` is the highest top it allows. The cursor still goes to vim's line.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
SCROLL_KEYS = frozenset({"<C-e>", "<C-y>", "<C-d>", "<C-u>", "<C-f>", "<C-b>"})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Scrolled:
|
|
16
|
+
top: int # the new first line on the screen
|
|
17
|
+
line: int # the cursor's new line
|
|
18
|
+
keep_column: bool # True: the column j and k aim for; False: the first non-blank
|
|
19
|
+
amount: int | None # what Ctrl+D and Ctrl+U scroll from now on (vim's 'scroll')
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def scroll(
|
|
23
|
+
key: str,
|
|
24
|
+
count: int | None,
|
|
25
|
+
*,
|
|
26
|
+
top: int,
|
|
27
|
+
line: int,
|
|
28
|
+
height: int,
|
|
29
|
+
lines: int,
|
|
30
|
+
max_top: int,
|
|
31
|
+
amount: int | None = None,
|
|
32
|
+
) -> Scrolled | None:
|
|
33
|
+
"""One scrolling command. None where vim beeps and does nothing.
|
|
34
|
+
|
|
35
|
+
top, line: the first line on the screen and the cursor's line. height: how many
|
|
36
|
+
lines fit on the screen. lines: how many lines the text has. amount: what Ctrl+D and
|
|
37
|
+
Ctrl+U scroll, if a count set it before; half the screen otherwise.
|
|
38
|
+
"""
|
|
39
|
+
last = lines - 1
|
|
40
|
+
height = max(height, 1)
|
|
41
|
+
n = count or 1
|
|
42
|
+
if key in ("<C-d>", "<C-u>"):
|
|
43
|
+
if count:
|
|
44
|
+
amount = min(count, height)
|
|
45
|
+
step = amount or max(height // 2, 1)
|
|
46
|
+
if key == "<C-d>":
|
|
47
|
+
if line >= last:
|
|
48
|
+
return None
|
|
49
|
+
# unlike Ctrl+E and Ctrl+F, it stops with the last line at the bottom
|
|
50
|
+
new_top = max(min(top + step, max_top, lines - height), top)
|
|
51
|
+
return Scrolled(new_top, min(line + step, last), False, amount)
|
|
52
|
+
if line <= 0:
|
|
53
|
+
return None
|
|
54
|
+
return Scrolled(max(top - step, 0), max(line - step, 0), False, amount)
|
|
55
|
+
|
|
56
|
+
if key == "<C-e>":
|
|
57
|
+
if top >= max_top:
|
|
58
|
+
return None
|
|
59
|
+
new_top = min(top + n, max_top)
|
|
60
|
+
return Scrolled(new_top, max(line, new_top), True, amount)
|
|
61
|
+
if key == "<C-y>":
|
|
62
|
+
if top <= 0:
|
|
63
|
+
return None
|
|
64
|
+
new_top = max(top - n, 0)
|
|
65
|
+
return Scrolled(new_top, min(line, new_top + height - 1), True, amount)
|
|
66
|
+
|
|
67
|
+
# When a count asks for more pages than there are, vim beeps on the last one and
|
|
68
|
+
# leaves the column alone.
|
|
69
|
+
page = max(height - 2, 1) # two lines stay on the screen
|
|
70
|
+
beeped = False
|
|
71
|
+
if key == "<C-f>":
|
|
72
|
+
new_top = top
|
|
73
|
+
for _ in range(n):
|
|
74
|
+
if new_top + height - 1 >= last: # the end is on the screen
|
|
75
|
+
if new_top >= last:
|
|
76
|
+
beeped = True
|
|
77
|
+
break
|
|
78
|
+
new_top = last
|
|
79
|
+
else:
|
|
80
|
+
new_top += page
|
|
81
|
+
if new_top == top and top >= last:
|
|
82
|
+
return None
|
|
83
|
+
return Scrolled(min(new_top, max_top), min(new_top, last), beeped, amount)
|
|
84
|
+
if key == "<C-b>":
|
|
85
|
+
if top <= 0:
|
|
86
|
+
return None
|
|
87
|
+
new_top = before = top
|
|
88
|
+
for _ in range(n):
|
|
89
|
+
if new_top <= 0:
|
|
90
|
+
beeped = True
|
|
91
|
+
break
|
|
92
|
+
before = new_top
|
|
93
|
+
new_top -= page
|
|
94
|
+
if new_top < 2: # vim goes all the way up from one line short of the start
|
|
95
|
+
new_top = 0
|
|
96
|
+
line = min(before + 1, new_top + height - 1, last)
|
|
97
|
+
return Scrolled(new_top, line, beeped, amount)
|
|
98
|
+
raise ValueError(f"not a scrolling key: {key!r}")
|
side_vim/search.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""n and N: find the next match of a plain text, like vim. No Qt here.
|
|
2
|
+
|
|
3
|
+
The text and the pattern are lines of characters (grapheme clusters), as in motions.py,
|
|
4
|
+
so "e" doesn't match the "e" in "e" plus an accent, just as in vim. A newline in the
|
|
5
|
+
pattern matches a line break. vim treats every line as ending in one, the last line
|
|
6
|
+
too, and so does this.
|
|
7
|
+
|
|
8
|
+
vim's defaults decide which match comes next: 'wrapscan', so the search goes on from
|
|
9
|
+
the other end, and 'cpoptions' with "c", so the matches in a line are found one after
|
|
10
|
+
the other from its start, each one after the end of the one before. In "aaaa", `n` for
|
|
11
|
+
"aa" goes from column 0 to column 2, not 1.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from bisect import bisect_right
|
|
15
|
+
from collections.abc import Iterator, Sequence
|
|
16
|
+
|
|
17
|
+
from side_vim.motions import Line, Pos
|
|
18
|
+
|
|
19
|
+
NEWLINE = "\n"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _Text:
|
|
23
|
+
"""The lines as one list of characters, with a NEWLINE after each line."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, lines: Sequence[Line]) -> None:
|
|
26
|
+
self.lines: Sequence[Line] = lines
|
|
27
|
+
self.chars: list[str] = []
|
|
28
|
+
self.starts: list[int] = []
|
|
29
|
+
for line in lines:
|
|
30
|
+
self.starts.append(len(self.chars))
|
|
31
|
+
self.chars.extend(line)
|
|
32
|
+
self.chars.append(NEWLINE)
|
|
33
|
+
|
|
34
|
+
def index(self, pos: Pos) -> int:
|
|
35
|
+
return self.starts[pos.line] + pos.col
|
|
36
|
+
|
|
37
|
+
def line_of(self, index: int) -> int:
|
|
38
|
+
return bisect_right(self.starts, index) - 1
|
|
39
|
+
|
|
40
|
+
def matches(self, line: int, pattern: list[str]) -> Iterator[tuple[int, bool]]:
|
|
41
|
+
"""The matches that start in a line, one after the other from its start: the
|
|
42
|
+
index of each, and whether it goes on past the line's end."""
|
|
43
|
+
end = self.starts[line] + len(self.lines[line]) # the line's NEWLINE
|
|
44
|
+
i = self.starts[line]
|
|
45
|
+
size = len(pattern)
|
|
46
|
+
while i <= end:
|
|
47
|
+
if self.chars[i : i + size] == pattern:
|
|
48
|
+
more = i + size > end
|
|
49
|
+
yield i, more
|
|
50
|
+
if more:
|
|
51
|
+
return
|
|
52
|
+
i += size
|
|
53
|
+
else:
|
|
54
|
+
i += 1
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def search(
|
|
58
|
+
lines: Sequence[Line],
|
|
59
|
+
pos: Pos,
|
|
60
|
+
pattern: Sequence[Line],
|
|
61
|
+
forward: bool = True,
|
|
62
|
+
count: int | None = None,
|
|
63
|
+
) -> Pos | None:
|
|
64
|
+
"""Where `n` (forward) or `N` goes from pos for a pattern given as lines of
|
|
65
|
+
characters. None if the pattern is empty or not in the text."""
|
|
66
|
+
chars = [ch for i, part in enumerate(pattern) for ch in ([NEWLINE] if i else []) + list(part)]
|
|
67
|
+
if not chars:
|
|
68
|
+
return None
|
|
69
|
+
text = _Text(lines)
|
|
70
|
+
at = text.index(pos)
|
|
71
|
+
for _ in range(count or 1):
|
|
72
|
+
found = _forward(text, at, chars) if forward else _backward(text, at, chars)
|
|
73
|
+
if found is None:
|
|
74
|
+
return None
|
|
75
|
+
at = found
|
|
76
|
+
line = text.line_of(at)
|
|
77
|
+
col = at - text.starts[line]
|
|
78
|
+
# a match that starts on the line break puts the cursor on the last character
|
|
79
|
+
if col == len(lines[line]) and col > 0:
|
|
80
|
+
col -= 1
|
|
81
|
+
return Pos(line, col)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _forward(text: _Text, at: int, pattern: list[str]) -> int | None:
|
|
85
|
+
line = text.line_of(at)
|
|
86
|
+
end = text.starts[line] + len(text.lines[line])
|
|
87
|
+
for i, more in text.matches(line, pattern):
|
|
88
|
+
# on the line break, a match counts as on the character before it
|
|
89
|
+
if i - (i == end) > at:
|
|
90
|
+
return i
|
|
91
|
+
if more:
|
|
92
|
+
break
|
|
93
|
+
count = len(text.lines)
|
|
94
|
+
for step in range(1, count + 1):
|
|
95
|
+
first = next(text.matches((line + step) % count, pattern), None)
|
|
96
|
+
if first is not None:
|
|
97
|
+
return first[0]
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _backward(text: _Text, at: int, pattern: list[str]) -> int | None:
|
|
102
|
+
line = text.line_of(at)
|
|
103
|
+
found = None
|
|
104
|
+
if at > text.starts[line]:
|
|
105
|
+
for i, _ in text.matches(line, pattern):
|
|
106
|
+
if i >= at:
|
|
107
|
+
break
|
|
108
|
+
found = i
|
|
109
|
+
if found is not None:
|
|
110
|
+
return found
|
|
111
|
+
count = len(text.lines)
|
|
112
|
+
for step in range(1, count + 1):
|
|
113
|
+
last = None
|
|
114
|
+
for i, _ in text.matches((line - step) % count, pattern):
|
|
115
|
+
last = i
|
|
116
|
+
if last is not None:
|
|
117
|
+
return last
|
|
118
|
+
return None
|
side_vim/text.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""The editor's text as vim lines, and positions in Qt's terms.
|
|
2
|
+
|
|
3
|
+
Uses QtCore only, never the editor. Two things make this more than str.split("\\n"):
|
|
4
|
+
|
|
5
|
+
- Qt counts positions in UTF-16 code units, so an emoji is 2 positions and a family
|
|
6
|
+
emoji is 8, while Python counts code points. Every position goes through here.
|
|
7
|
+
- One character on screen (a grapheme cluster: a family emoji, a flag, a letter plus
|
|
8
|
+
an accent) is one character for vim, so commands never cut one in half. Qt decides
|
|
9
|
+
where clusters end, the same way its own cursor does.
|
|
10
|
+
|
|
11
|
+
Read the editor's text with document().toRawText(), not toPlainText(): toPlainText()
|
|
12
|
+
turns a line separator (U+2028, a soft line break inside a block) into "\\n", so its
|
|
13
|
+
lines no longer match Qt's blocks.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from collections.abc import Iterator, Sequence
|
|
17
|
+
from typing import overload
|
|
18
|
+
|
|
19
|
+
from PySide6.QtCore import QTextBoundaryFinder
|
|
20
|
+
|
|
21
|
+
from side_vim.motions import Pos
|
|
22
|
+
|
|
23
|
+
# What separates blocks (vim's lines) in QTextDocument.toRawText().
|
|
24
|
+
PARAGRAPH_SEPARATOR = "β©"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def utf16_len(s: str) -> int:
|
|
28
|
+
"""Length in UTF-16 code units, the way Qt counts."""
|
|
29
|
+
return len(s.encode("utf-16-le", "surrogatepass")) // 2
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def split_clusters(line: str) -> tuple[str, ...]:
|
|
33
|
+
"""Split a line into the characters the user sees, as Qt finds them."""
|
|
34
|
+
if not line:
|
|
35
|
+
return ()
|
|
36
|
+
finder = QTextBoundaryFinder(QTextBoundaryFinder.BoundaryType.Grapheme, line)
|
|
37
|
+
# the finder speaks UTF-16; map its boundaries back to code point indexes
|
|
38
|
+
ends: set[int] = set()
|
|
39
|
+
while (boundary := finder.toNextBoundary()) != -1:
|
|
40
|
+
ends.add(boundary)
|
|
41
|
+
clusters: list[str] = []
|
|
42
|
+
start = offset = 0
|
|
43
|
+
for i, ch in enumerate(line):
|
|
44
|
+
offset += 2 if ord(ch) > 0xFFFF else 1
|
|
45
|
+
if offset in ends:
|
|
46
|
+
clusters.append(line[start : i + 1])
|
|
47
|
+
start = i + 1
|
|
48
|
+
if start < len(line):
|
|
49
|
+
clusters.append(line[start:])
|
|
50
|
+
return tuple(_pair_flags(clusters))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_flag_half(cluster: str) -> bool:
|
|
54
|
+
return all(0x1F1E6 <= ord(ch) <= 0x1F1FF for ch in cluster)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _pair_flags(clusters: list[str]) -> list[str]:
|
|
58
|
+
"""Pair regional indicators (the two halves of a flag) from the start of each run.
|
|
59
|
+
|
|
60
|
+
Qt 6.12 gets this wrong after an emoji: it splits "ππΊπ¦" into π, πΊ, π¦, and its
|
|
61
|
+
own cursor does the same, so a plain QPlainTextEdit can break a flag. Unicode
|
|
62
|
+
pairs them from the start of the run, so vim commands always take a flag whole.
|
|
63
|
+
"""
|
|
64
|
+
out: list[str] = []
|
|
65
|
+
run: list[str] = []
|
|
66
|
+
for cluster in [*clusters, ""]:
|
|
67
|
+
if cluster and _is_flag_half(cluster):
|
|
68
|
+
run.extend(cluster)
|
|
69
|
+
continue
|
|
70
|
+
out.extend("".join(run[i : i + 2]) for i in range(0, len(run), 2))
|
|
71
|
+
run = []
|
|
72
|
+
if cluster:
|
|
73
|
+
out.append(cluster)
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Lines(Sequence[tuple[str, ...]]):
|
|
78
|
+
"""The document's lines, each split into clusters when first used.
|
|
79
|
+
|
|
80
|
+
raw: QTextDocument.toRawText(), with blocks separated by U+2029. Or the lines'
|
|
81
|
+
texts as a sequence that reads them when asked, so that a long document isn't
|
|
82
|
+
copied for every key. Such a sequence must not change while this is in use.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, raw: str | Sequence[str]) -> None:
|
|
86
|
+
self._raw: Sequence[str] = raw.split(PARAGRAPH_SEPARATOR) if isinstance(raw, str) else raw
|
|
87
|
+
self._split: dict[int, tuple[str, ...]] = {}
|
|
88
|
+
|
|
89
|
+
def __len__(self) -> int:
|
|
90
|
+
return len(self._raw)
|
|
91
|
+
|
|
92
|
+
@overload
|
|
93
|
+
def __getitem__(self, index: int) -> tuple[str, ...]: ...
|
|
94
|
+
@overload
|
|
95
|
+
def __getitem__(self, index: slice) -> list[tuple[str, ...]]: ...
|
|
96
|
+
def __getitem__(self, index: int | slice) -> tuple[str, ...] | list[tuple[str, ...]]:
|
|
97
|
+
if isinstance(index, slice):
|
|
98
|
+
return [self[i] for i in range(*index.indices(len(self)))]
|
|
99
|
+
if index < 0:
|
|
100
|
+
index += len(self)
|
|
101
|
+
if not 0 <= index < len(self._raw):
|
|
102
|
+
raise IndexError(index)
|
|
103
|
+
line = self._split.get(index)
|
|
104
|
+
if line is None:
|
|
105
|
+
line = self._split[index] = split_clusters(self._raw[index])
|
|
106
|
+
return line
|
|
107
|
+
|
|
108
|
+
def __iter__(self) -> Iterator[tuple[str, ...]]:
|
|
109
|
+
return (self[i] for i in range(len(self)))
|
|
110
|
+
|
|
111
|
+
def raw(self, index: int) -> str:
|
|
112
|
+
"""A line as plain text."""
|
|
113
|
+
return self._raw[index]
|
|
114
|
+
|
|
115
|
+
def offset(self, pos: Pos) -> int:
|
|
116
|
+
"""A column as a UTF-16 offset in its line. Past the line end means the end."""
|
|
117
|
+
return utf16_len("".join(self[pos.line][: pos.col]))
|
|
118
|
+
|
|
119
|
+
def col_at(self, line: int, offset: int) -> int:
|
|
120
|
+
"""The column at a UTF-16 offset in a line. Inside a cluster (which Qt's own
|
|
121
|
+
cursor never does, but an app could) means the start of that cluster."""
|
|
122
|
+
end = col = 0
|
|
123
|
+
for cluster in self[line]:
|
|
124
|
+
end += utf16_len(cluster)
|
|
125
|
+
if end > offset:
|
|
126
|
+
break
|
|
127
|
+
col += 1
|
|
128
|
+
return col
|
|
129
|
+
|
|
130
|
+
def to_qt(self, pos: Pos) -> int:
|
|
131
|
+
"""A position as a Qt document position. A column past the line end means the
|
|
132
|
+
line end. The editor asks the document where lines start instead, which is
|
|
133
|
+
faster in long texts."""
|
|
134
|
+
start = sum(utf16_len(line) + 1 for line in self._raw[: pos.line])
|
|
135
|
+
return start + self.offset(pos)
|
|
136
|
+
|
|
137
|
+
def from_qt(self, position: int) -> Pos:
|
|
138
|
+
"""A Qt document position as a position. Past the end of the document means its
|
|
139
|
+
end."""
|
|
140
|
+
for index, raw in enumerate(self._raw):
|
|
141
|
+
length = utf16_len(raw)
|
|
142
|
+
if position <= length:
|
|
143
|
+
return Pos(index, self.col_at(index, position))
|
|
144
|
+
position -= length + 1
|
|
145
|
+
last = len(self._raw) - 1
|
|
146
|
+
return Pos(last, len(self[last]))
|
side_vim/unicode.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Character classes and cell widths, as vim sees them. No Qt here.
|
|
2
|
+
|
|
3
|
+
A "character" here is one character as the user sees it: a grapheme cluster, which may
|
|
4
|
+
be several code points (a family emoji, a flag, a letter plus an accent). Its first
|
|
5
|
+
code point decides its class and width, as in vim, where accents belong to the
|
|
6
|
+
letter before them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from bisect import bisect_right
|
|
10
|
+
|
|
11
|
+
from side_vim._unicode_data import CLASS_STARTS, CLASS_VALUES, WIDTH_STARTS, WIDTH_VALUES
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def char_class(ch: str, bigword: bool = False) -> int:
|
|
15
|
+
"""Vim's character class: 0 blank, 1 punctuation, 2 word character, 3 emoji, and
|
|
16
|
+
more for some scripts (Hiragana, CJK ideographs, ...). "" stands for the end of a
|
|
17
|
+
line, which vim counts as blank. With bigword (W, B, E) every non-blank is 1."""
|
|
18
|
+
if not ch:
|
|
19
|
+
return 0
|
|
20
|
+
cls = CLASS_VALUES[bisect_right(CLASS_STARTS, ord(ch[0])) - 1]
|
|
21
|
+
if bigword and cls:
|
|
22
|
+
return 1
|
|
23
|
+
return cls
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cell_width(ch: str) -> int:
|
|
27
|
+
"""How many screen cells vim gives a character: 2 for CJK and emoji, 1 for most.
|
|
28
|
+
A tab's width depends on where it sits, so the caller handles tabs."""
|
|
29
|
+
if not ch:
|
|
30
|
+
return 0
|
|
31
|
+
return WIDTH_VALUES[bisect_right(WIDTH_STARTS, ord(ch[0])) - 1]
|