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/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]