side-vim 0.1.0__tar.gz

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-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sprytnyk.dev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,212 @@
1
+ Metadata-Version: 2.4
2
+ Name: side-vim
3
+ Version: 0.1.0
4
+ Summary: Vim mode for PySide6's QPlainTextEdit
5
+ Author: sprytnyk.dev
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Dist: pyside6-essentials>=6.8
9
+ Requires-Python: >=3.12
10
+ Project-URL: Repository, https://github.com/sprytnyk/side-vim
11
+ Description-Content-Type: text/markdown
12
+
13
+ # side-vim
14
+
15
+ A Vim mode for PySide6's `QPlainTextEdit`. Attach it to an existing editor to add
16
+ Normal, Insert, Visual and Visual Line modes.
17
+
18
+ ![The demo in Visual mode, with text selected and the mode in the status bar](https://raw.githubusercontent.com/sprytnyk/side-vim/main/docs/demo.png)
19
+
20
+ ## Install
21
+
22
+ Requires Python 3.12 or newer and PySide6 6.8 or newer. Installing side-vim also
23
+ installs PySide6-Essentials.
24
+
25
+ ```sh
26
+ uv add side-vim
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```python
32
+ import sys
33
+
34
+ from PySide6.QtWidgets import QApplication, QLabel, QPlainTextEdit, QVBoxLayout, QWidget
35
+
36
+ from side_vim import VimMode
37
+
38
+ app = QApplication(sys.argv)
39
+ window = QWidget()
40
+ editor = QPlainTextEdit("Try w, i, Esc, v and y.")
41
+ status = QLabel()
42
+ layout = QVBoxLayout(window)
43
+ layout.addWidget(editor)
44
+ layout.addWidget(status)
45
+
46
+ vim = VimMode(editor) # Starts in Normal mode. Qt deletes it with the editor.
47
+ status.setText(vim.mode.value)
48
+ vim.mode_changed.connect(lambda mode: status.setText(mode.value))
49
+
50
+ window.resize(800, 500)
51
+ window.show()
52
+ editor.setFocus()
53
+ sys.exit(app.exec())
54
+ ```
55
+
56
+ Attach one `VimMode` per editor. Import `Mode` to compare `vim.mode` with
57
+ `Mode.NORMAL`, `Mode.INSERT`, `Mode.VISUAL` or `Mode.VISUAL_LINE`.
58
+
59
+ | API | Behavior |
60
+ | --- | --- |
61
+ | `vim.mode_changed.connect(callback)` | Calls `callback(mode)` when the mode changes. |
62
+ | `vim.pending_changed.connect(callback)` | Calls `callback(text)` with pending keys such as `2d`, or `""` when done. |
63
+ | `vim.escape_hook = callback` | Calls `callback()` before handling Esc. Return `True` if the app used it, for example to close a popup. |
64
+ | `vim.search_hook = callback` | `/` calls `callback(True)`; `?` calls `callback(False)`. The app opens its search UI. |
65
+ | `vim.set_search("rent", forward=True)` | Saves the search text and jumps to the next match. `n` repeats in that direction; `N` reverses it. |
66
+ | `vim.normal()` | Goes to Normal mode as Esc does, from Insert or Visual mode. |
67
+ | `vim.clipboard = True` | Uses the system clipboard for yank, delete, change and paste. Defaults to `False`. |
68
+ | `vim.layout_map = UKRAINIAN` | Maps Ukrainian letters to Vim keys. This is the default; import `UKRAINIAN` from `side_vim`. Set `None` to disable it, or pass your own dictionary. |
69
+ | `vim.set_enabled(False)` | Restores the plain editor and its cursor. `True` enables Vim in Normal mode. Read the setting through `vim.enabled`. |
70
+ | `vim.detach()` | Removes Vim from the editor. To turn it off for a while, use `set_enabled(False)`. |
71
+
72
+ When the app accepts a search, call `set_search` with its text and direction,
73
+ after `normal()` if the search was opened from Insert mode.
74
+ Search is case-sensitive, wraps around the document, and uses plain text rather
75
+ than regular expressions. Each editor keeps its own search. An empty string
76
+ clears it. In Insert mode or while disabled, `set_search` saves the search without
77
+ moving the cursor. The [demo](https://github.com/sprytnyk/side-vim/blob/main/side_vim/__main__.py)
78
+ shows a find bar and an Esc hook together.
79
+
80
+ The unnamed register is shared between editors in the same process. With
81
+ `clipboard=False`, yank in one editor and paste in another. With `clipboard=True`,
82
+ text copied in another app pastes as whole lines if it ends in a newline.
83
+
84
+ ## Supported keys
85
+
86
+ Counts work with motions and edits: `3w`, `5G`, `2dd`, `y3j`. A counted insert
87
+ repeats the typed text: `3ix<Esc>` inserts `xxx`.
88
+
89
+ | Action | Keys |
90
+ | --- | --- |
91
+ | Move | `h j k l`, `w b e`, `W B E`, `0 ^ $`, `gg G` |
92
+ | Insert | `i a I A o O` |
93
+ | Delete, change, yank | `d c y` followed by a motion; `dd cc yy`, `x D C` |
94
+ | Paste, join | `p P`, `J` |
95
+ | Undo, redo | `u`, `Ctrl+R` |
96
+ | Select | `v` for characters, `V` for lines; motions extend the selection |
97
+ | Edit a selection | `d c y x p`; `o` switches its ends |
98
+ | Search | `/ ?` through the app hook, then `n N` |
99
+ | Scroll a line | `Ctrl+E`, `Ctrl+Y` |
100
+ | Scroll half a screen | `Ctrl+D`, `Ctrl+U` |
101
+ | Scroll a screen | `Ctrl+F`, `Ctrl+B` |
102
+ | Return to Normal | `Esc`, `Ctrl+[` |
103
+
104
+ In Normal and Visual modes, the Ctrl keys above take priority over app shortcuts.
105
+ `j` and `k` keep the screen column across tabs and wide characters, and a wrapped
106
+ line counts as one line, as in Vim.
107
+
108
+ This release has only the keys above. It has no repeat (`.`), text objects,
109
+ `f t r ~`, colon commands, macros, marks, named registers or block selection.
110
+
111
+ ## Differences from Vim
112
+
113
+ side-vim follows Vim 9.1 with its default settings, except for these:
114
+
115
+ - Joined emoji and flags, such as 👨‍👩‍👧 and 🇺🇦, are one character. Vim splits
116
+ them into their parts. Accents, CJK and single emoji work as in Vim.
117
+ - Search uses plain text, not regular expressions. There is no command line.
118
+ `/` and `?` call `search_hook`, then the app shows its own search box and
119
+ passes the text to `set_search`.
120
+ - `J` puts one space after a period, like Vim with `nojoinspaces`. Vim's default
121
+ puts two.
122
+ - Qt stops scrolling when the last line reaches the bottom of the editor. Vim
123
+ scrolls on until the last line is at the top, so `Ctrl+F` and `Ctrl+E` stop
124
+ sooner near the end. The cursor still goes to the line Vim picks.
125
+ - In Insert mode the editor handles every key except Esc and `Ctrl+[`. Arrows,
126
+ Backspace and app shortcuts work as in any Qt editor. Vim's Insert mode keys,
127
+ such as `Ctrl+W`, `Ctrl+U` and `Ctrl+O`, are not there.
128
+ - In Normal and Visual modes, Ctrl keys that side-vim doesn't use go to the
129
+ editor or the app, and so do PageUp, PageDown and the function keys. `Ctrl+A`
130
+ selects all, for example. Qt's keys that change the text do nothing in these
131
+ modes: paste, cut, `Ctrl+Backspace` and `Ctrl+Delete`. In Vim, `Ctrl+V` starts
132
+ a block selection, which side-vim doesn't have.
133
+ - Other keys without a command do nothing in Normal mode. They never type text.
134
+ - Esc in Normal mode with nothing pending goes to the app, for example to close a
135
+ dialog. Vim beeps.
136
+ - A read-only editor allows movement, scrolling, selection, yank and search, and
137
+ ignores edits. Vim only warns when you change a read-only file.
138
+ - Dragging with the mouse in Normal mode starts Visual mode.
139
+ - Outside Insert mode, Ukrainian letters work as the Latin keys in the same
140
+ place, so `ц` is `w`. Vim needs `langmap` for that. Punctuation stays as typed,
141
+ `ґ` isn't mapped because its key moves between layout variants, and `/` needs
142
+ the English layout.
143
+ - On macOS, Ctrl means the physical Control key. Command shortcuts stay with the
144
+ app.
145
+ - The tab width comes from the editor's tab stop, not from a `tabstop` setting.
146
+ - `u` and the app's undo share the editor's undo stack, so they undo the same
147
+ steps.
148
+
149
+ ## Develop
150
+
151
+ ```sh
152
+ uv sync --locked
153
+ make run # Demo window
154
+ make check # Ruff and pytest
155
+ make format # Format Python
156
+ make record # Record expected results using local Vim
157
+ make unicode # Regenerate Vim character classes and widths
158
+ make mac-keys # Key checks on a Mac
159
+ ```
160
+
161
+ Tests run Qt offscreen. CI runs for release tags and can also be started from
162
+ **Actions → CI → Run workflow**. It tests Linux, macOS and Windows on Python 3.12
163
+ and 3.14, and the lowest allowed PySide6 on Python 3.12.
164
+ The tests replay saved [Vim cases](https://github.com/sprytnyk/side-vim/tree/main/tests/cases),
165
+ so CI doesn't need Vim. Recording them needs Vim, and the scrolling cases need a
166
+ Unix terminal.
167
+
168
+ On a Mac, `make mac-keys` opens the demo and presses keys through macOS. It checks
169
+ Ctrl and Cmd keys, search, read-only mode and the Ukrainian layout, and prints PASS
170
+ or FAIL for each. Start it on the English (ABC) layout with Ukrainian-PC installed,
171
+ and don't type until it finishes.
172
+
173
+ ## Release
174
+
175
+ Set these up by hand before the first tag:
176
+
177
+ 1. In the GitHub repository, open **Settings**, then **Environments**, and create
178
+ `pypi`. Add yourself as a required reviewer. Leave **Prevent self-review** off,
179
+ so you can approve runs you start. Limit deployment tags to `v*`.
180
+ 2. In your PyPI account's **Publishing** page, add a pending trusted publisher:
181
+
182
+ | Field | Value |
183
+ | --- | --- |
184
+ | PyPI project | `side-vim` |
185
+ | GitHub owner | `sprytnyk` |
186
+ | Repository | `side-vim` |
187
+ | Workflow | `release.yml` |
188
+ | Environment | `pypi` |
189
+
190
+ A pending publisher creates the project on its first upload. No PyPI token
191
+ goes into GitHub. See the [PyPI setup guide](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/)
192
+ and [GitHub approval guide](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/review-deployments).
193
+
194
+ For each release:
195
+
196
+ 1. On `main`, check the README and run `make check`. If key handling changed,
197
+ run `make mac-keys` on a Mac.
198
+ 2. Run `uv version X.Y.Z`. Write the dated `CHANGELOG.md` section from the commits
199
+ since the previous release, using only sections with entries: Breaking,
200
+ Highlights, Fixes, Changes and Removed. Review it before committing.
201
+ 3. Build and test the wheel in a temporary environment. Make the signed
202
+ `chore(release): vX.Y.Z` commit and signed `vX.Y.Z` tag with key
203
+ `0xC00C0F0D647BD3C6`. Push `main` and that tag to `origin`.
204
+ 4. The release workflow runs CI, checks that the tag matches the package version,
205
+ and builds the wheel and source distribution. Review the run and approve the
206
+ `pypi` job. It publishes those files using
207
+ [uv trusted publishing](https://docs.astral.sh/uv/guides/integration/github/#publishing-to-pypi).
208
+ 5. Confirm the version is on PyPI, then create a GitHub release using just the
209
+ changelog section as its notes. Attach no files.
210
+
211
+ The project [release skill](https://github.com/sprytnyk/side-vim/blob/main/.claude/skills/release/SKILL.md)
212
+ has the command sequence and approval points.
@@ -0,0 +1,200 @@
1
+ # side-vim
2
+
3
+ A Vim mode for PySide6's `QPlainTextEdit`. Attach it to an existing editor to add
4
+ Normal, Insert, Visual and Visual Line modes.
5
+
6
+ ![The demo in Visual mode, with text selected and the mode in the status bar](https://raw.githubusercontent.com/sprytnyk/side-vim/main/docs/demo.png)
7
+
8
+ ## Install
9
+
10
+ Requires Python 3.12 or newer and PySide6 6.8 or newer. Installing side-vim also
11
+ installs PySide6-Essentials.
12
+
13
+ ```sh
14
+ uv add side-vim
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```python
20
+ import sys
21
+
22
+ from PySide6.QtWidgets import QApplication, QLabel, QPlainTextEdit, QVBoxLayout, QWidget
23
+
24
+ from side_vim import VimMode
25
+
26
+ app = QApplication(sys.argv)
27
+ window = QWidget()
28
+ editor = QPlainTextEdit("Try w, i, Esc, v and y.")
29
+ status = QLabel()
30
+ layout = QVBoxLayout(window)
31
+ layout.addWidget(editor)
32
+ layout.addWidget(status)
33
+
34
+ vim = VimMode(editor) # Starts in Normal mode. Qt deletes it with the editor.
35
+ status.setText(vim.mode.value)
36
+ vim.mode_changed.connect(lambda mode: status.setText(mode.value))
37
+
38
+ window.resize(800, 500)
39
+ window.show()
40
+ editor.setFocus()
41
+ sys.exit(app.exec())
42
+ ```
43
+
44
+ Attach one `VimMode` per editor. Import `Mode` to compare `vim.mode` with
45
+ `Mode.NORMAL`, `Mode.INSERT`, `Mode.VISUAL` or `Mode.VISUAL_LINE`.
46
+
47
+ | API | Behavior |
48
+ | --- | --- |
49
+ | `vim.mode_changed.connect(callback)` | Calls `callback(mode)` when the mode changes. |
50
+ | `vim.pending_changed.connect(callback)` | Calls `callback(text)` with pending keys such as `2d`, or `""` when done. |
51
+ | `vim.escape_hook = callback` | Calls `callback()` before handling Esc. Return `True` if the app used it, for example to close a popup. |
52
+ | `vim.search_hook = callback` | `/` calls `callback(True)`; `?` calls `callback(False)`. The app opens its search UI. |
53
+ | `vim.set_search("rent", forward=True)` | Saves the search text and jumps to the next match. `n` repeats in that direction; `N` reverses it. |
54
+ | `vim.normal()` | Goes to Normal mode as Esc does, from Insert or Visual mode. |
55
+ | `vim.clipboard = True` | Uses the system clipboard for yank, delete, change and paste. Defaults to `False`. |
56
+ | `vim.layout_map = UKRAINIAN` | Maps Ukrainian letters to Vim keys. This is the default; import `UKRAINIAN` from `side_vim`. Set `None` to disable it, or pass your own dictionary. |
57
+ | `vim.set_enabled(False)` | Restores the plain editor and its cursor. `True` enables Vim in Normal mode. Read the setting through `vim.enabled`. |
58
+ | `vim.detach()` | Removes Vim from the editor. To turn it off for a while, use `set_enabled(False)`. |
59
+
60
+ When the app accepts a search, call `set_search` with its text and direction,
61
+ after `normal()` if the search was opened from Insert mode.
62
+ Search is case-sensitive, wraps around the document, and uses plain text rather
63
+ than regular expressions. Each editor keeps its own search. An empty string
64
+ clears it. In Insert mode or while disabled, `set_search` saves the search without
65
+ moving the cursor. The [demo](https://github.com/sprytnyk/side-vim/blob/main/side_vim/__main__.py)
66
+ shows a find bar and an Esc hook together.
67
+
68
+ The unnamed register is shared between editors in the same process. With
69
+ `clipboard=False`, yank in one editor and paste in another. With `clipboard=True`,
70
+ text copied in another app pastes as whole lines if it ends in a newline.
71
+
72
+ ## Supported keys
73
+
74
+ Counts work with motions and edits: `3w`, `5G`, `2dd`, `y3j`. A counted insert
75
+ repeats the typed text: `3ix<Esc>` inserts `xxx`.
76
+
77
+ | Action | Keys |
78
+ | --- | --- |
79
+ | Move | `h j k l`, `w b e`, `W B E`, `0 ^ $`, `gg G` |
80
+ | Insert | `i a I A o O` |
81
+ | Delete, change, yank | `d c y` followed by a motion; `dd cc yy`, `x D C` |
82
+ | Paste, join | `p P`, `J` |
83
+ | Undo, redo | `u`, `Ctrl+R` |
84
+ | Select | `v` for characters, `V` for lines; motions extend the selection |
85
+ | Edit a selection | `d c y x p`; `o` switches its ends |
86
+ | Search | `/ ?` through the app hook, then `n N` |
87
+ | Scroll a line | `Ctrl+E`, `Ctrl+Y` |
88
+ | Scroll half a screen | `Ctrl+D`, `Ctrl+U` |
89
+ | Scroll a screen | `Ctrl+F`, `Ctrl+B` |
90
+ | Return to Normal | `Esc`, `Ctrl+[` |
91
+
92
+ In Normal and Visual modes, the Ctrl keys above take priority over app shortcuts.
93
+ `j` and `k` keep the screen column across tabs and wide characters, and a wrapped
94
+ line counts as one line, as in Vim.
95
+
96
+ This release has only the keys above. It has no repeat (`.`), text objects,
97
+ `f t r ~`, colon commands, macros, marks, named registers or block selection.
98
+
99
+ ## Differences from Vim
100
+
101
+ side-vim follows Vim 9.1 with its default settings, except for these:
102
+
103
+ - Joined emoji and flags, such as 👨‍👩‍👧 and 🇺🇦, are one character. Vim splits
104
+ them into their parts. Accents, CJK and single emoji work as in Vim.
105
+ - Search uses plain text, not regular expressions. There is no command line.
106
+ `/` and `?` call `search_hook`, then the app shows its own search box and
107
+ passes the text to `set_search`.
108
+ - `J` puts one space after a period, like Vim with `nojoinspaces`. Vim's default
109
+ puts two.
110
+ - Qt stops scrolling when the last line reaches the bottom of the editor. Vim
111
+ scrolls on until the last line is at the top, so `Ctrl+F` and `Ctrl+E` stop
112
+ sooner near the end. The cursor still goes to the line Vim picks.
113
+ - In Insert mode the editor handles every key except Esc and `Ctrl+[`. Arrows,
114
+ Backspace and app shortcuts work as in any Qt editor. Vim's Insert mode keys,
115
+ such as `Ctrl+W`, `Ctrl+U` and `Ctrl+O`, are not there.
116
+ - In Normal and Visual modes, Ctrl keys that side-vim doesn't use go to the
117
+ editor or the app, and so do PageUp, PageDown and the function keys. `Ctrl+A`
118
+ selects all, for example. Qt's keys that change the text do nothing in these
119
+ modes: paste, cut, `Ctrl+Backspace` and `Ctrl+Delete`. In Vim, `Ctrl+V` starts
120
+ a block selection, which side-vim doesn't have.
121
+ - Other keys without a command do nothing in Normal mode. They never type text.
122
+ - Esc in Normal mode with nothing pending goes to the app, for example to close a
123
+ dialog. Vim beeps.
124
+ - A read-only editor allows movement, scrolling, selection, yank and search, and
125
+ ignores edits. Vim only warns when you change a read-only file.
126
+ - Dragging with the mouse in Normal mode starts Visual mode.
127
+ - Outside Insert mode, Ukrainian letters work as the Latin keys in the same
128
+ place, so `ц` is `w`. Vim needs `langmap` for that. Punctuation stays as typed,
129
+ `ґ` isn't mapped because its key moves between layout variants, and `/` needs
130
+ the English layout.
131
+ - On macOS, Ctrl means the physical Control key. Command shortcuts stay with the
132
+ app.
133
+ - The tab width comes from the editor's tab stop, not from a `tabstop` setting.
134
+ - `u` and the app's undo share the editor's undo stack, so they undo the same
135
+ steps.
136
+
137
+ ## Develop
138
+
139
+ ```sh
140
+ uv sync --locked
141
+ make run # Demo window
142
+ make check # Ruff and pytest
143
+ make format # Format Python
144
+ make record # Record expected results using local Vim
145
+ make unicode # Regenerate Vim character classes and widths
146
+ make mac-keys # Key checks on a Mac
147
+ ```
148
+
149
+ Tests run Qt offscreen. CI runs for release tags and can also be started from
150
+ **Actions → CI → Run workflow**. It tests Linux, macOS and Windows on Python 3.12
151
+ and 3.14, and the lowest allowed PySide6 on Python 3.12.
152
+ The tests replay saved [Vim cases](https://github.com/sprytnyk/side-vim/tree/main/tests/cases),
153
+ so CI doesn't need Vim. Recording them needs Vim, and the scrolling cases need a
154
+ Unix terminal.
155
+
156
+ On a Mac, `make mac-keys` opens the demo and presses keys through macOS. It checks
157
+ Ctrl and Cmd keys, search, read-only mode and the Ukrainian layout, and prints PASS
158
+ or FAIL for each. Start it on the English (ABC) layout with Ukrainian-PC installed,
159
+ and don't type until it finishes.
160
+
161
+ ## Release
162
+
163
+ Set these up by hand before the first tag:
164
+
165
+ 1. In the GitHub repository, open **Settings**, then **Environments**, and create
166
+ `pypi`. Add yourself as a required reviewer. Leave **Prevent self-review** off,
167
+ so you can approve runs you start. Limit deployment tags to `v*`.
168
+ 2. In your PyPI account's **Publishing** page, add a pending trusted publisher:
169
+
170
+ | Field | Value |
171
+ | --- | --- |
172
+ | PyPI project | `side-vim` |
173
+ | GitHub owner | `sprytnyk` |
174
+ | Repository | `side-vim` |
175
+ | Workflow | `release.yml` |
176
+ | Environment | `pypi` |
177
+
178
+ A pending publisher creates the project on its first upload. No PyPI token
179
+ goes into GitHub. See the [PyPI setup guide](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/)
180
+ and [GitHub approval guide](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/review-deployments).
181
+
182
+ For each release:
183
+
184
+ 1. On `main`, check the README and run `make check`. If key handling changed,
185
+ run `make mac-keys` on a Mac.
186
+ 2. Run `uv version X.Y.Z`. Write the dated `CHANGELOG.md` section from the commits
187
+ since the previous release, using only sections with entries: Breaking,
188
+ Highlights, Fixes, Changes and Removed. Review it before committing.
189
+ 3. Build and test the wheel in a temporary environment. Make the signed
190
+ `chore(release): vX.Y.Z` commit and signed `vX.Y.Z` tag with key
191
+ `0xC00C0F0D647BD3C6`. Push `main` and that tag to `origin`.
192
+ 4. The release workflow runs CI, checks that the tag matches the package version,
193
+ and builds the wheel and source distribution. Review the run and approve the
194
+ `pypi` job. It publishes those files using
195
+ [uv trusted publishing](https://docs.astral.sh/uv/guides/integration/github/#publishing-to-pypi).
196
+ 5. Confirm the version is on PyPI, then create a GitHub release using just the
197
+ changelog section as its notes. Attach no files.
198
+
199
+ The project [release skill](https://github.com/sprytnyk/side-vim/blob/main/.claude/skills/release/SKILL.md)
200
+ has the command sequence and approval points.
@@ -0,0 +1,47 @@
1
+ [project]
2
+ name = "side-vim"
3
+ version = "0.1.0"
4
+ description = "Vim mode for PySide6's QPlainTextEdit"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.12"
9
+ dependencies = ["PySide6-Essentials>=6.8"]
10
+
11
+ [[project.authors]]
12
+ name = "sprytnyk.dev"
13
+
14
+ [project.urls]
15
+ Repository = "https://github.com/sprytnyk/side-vim"
16
+
17
+ [dependency-groups]
18
+ dev = [
19
+ "pytest>=8",
20
+ "ruff>=0.8",
21
+ "pip-audit>=2.7",
22
+ ]
23
+
24
+ [build-system]
25
+ requires = ["uv_build>=0.12,<0.13"]
26
+ build-backend = "uv_build"
27
+
28
+ [tool.uv.build-backend]
29
+ module-root = ""
30
+
31
+ [tool.ruff]
32
+ line-length = 100
33
+ target-version = "py312"
34
+
35
+ [tool.ruff.lint]
36
+ select = [
37
+ "E",
38
+ "F",
39
+ "W",
40
+ "I",
41
+ "B",
42
+ "UP",
43
+ "SIM",
44
+ ]
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
@@ -0,0 +1,38 @@
1
+ [project]
2
+ name = "side-vim"
3
+ version = "0.1.0"
4
+ description = "Vim mode for PySide6's QPlainTextEdit"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "sprytnyk.dev" }]
9
+ requires-python = ">=3.12"
10
+ dependencies = ["PySide6-Essentials>=6.8"]
11
+
12
+ [project.urls]
13
+ Repository = "https://github.com/sprytnyk/side-vim"
14
+
15
+ [dependency-groups]
16
+ dev = [
17
+ "pytest>=8",
18
+ "ruff>=0.8",
19
+ "pip-audit>=2.7",
20
+ ]
21
+
22
+ [build-system]
23
+ requires = ["uv_build>=0.12,<0.13"]
24
+ build-backend = "uv_build"
25
+
26
+ [tool.uv.build-backend]
27
+ # the package sits at the repo root, not under src/
28
+ module-root = ""
29
+
30
+ [tool.ruff]
31
+ line-length = 100
32
+ target-version = "py312"
33
+
34
+ [tool.ruff.lint]
35
+ select = ["E", "F", "W", "I", "B", "UP", "SIM"]
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = ["tests"]
@@ -0,0 +1,7 @@
1
+ """A vim mode for PySide6's QPlainTextEdit."""
2
+
3
+ from side_vim.layouts import UKRAINIAN
4
+ from side_vim.mode import Mode
5
+ from side_vim.vim import VimMode
6
+
7
+ __all__ = ["UKRAINIAN", "Mode", "VimMode"]
@@ -0,0 +1,146 @@
1
+ """A demo: an editor with vim keys and a status bar. Run it with `make run`."""
2
+
3
+ import sys
4
+
5
+ from PySide6.QtCore import Qt
6
+ from PySide6.QtGui import QFontDatabase, QKeySequence, QShortcut
7
+ from PySide6.QtWidgets import (
8
+ QApplication,
9
+ QCheckBox,
10
+ QLabel,
11
+ QLineEdit,
12
+ QMainWindow,
13
+ QPlainTextEdit,
14
+ )
15
+
16
+ from side_vim import Mode, VimMode
17
+
18
+ SAMPLE = """\
19
+ side-vim demo. Esc goes back to Normal mode; i, a, I, A, o and O start typing.
20
+ Try w b e, W B E, 0 ^ $, gg G, h j k l, with counts: 3w, 2j, 5G.
21
+ Edit with x, dw, d2j, cw, cc, C, D, dd, yy, yw, p, P and J; u undoes, Ctrl+R redoes.
22
+ Tick "clipboard" to yank to the system clipboard and paste from it.
23
+ v and V select, or drag with the mouse; then move, o, and d c y x p on the selection.
24
+ / and ? open the find bar: type, Enter, then n and N. Esc closes the find bar first.
25
+ Scroll with Ctrl+E and Ctrl+Y (a line), Ctrl+D and Ctrl+U (half a screen), Ctrl+F and
26
+ Ctrl+B (a screen). Tick "read-only" to try that; on the Ukrainian layout, ц works as w.
27
+
28
+ \tA tab first, then words: foo.bar(baz) and foo-bar_baz.
29
+ Emoji count as one character: 😀 👨‍👩‍👧 🇺🇦 1️⃣ and é.
30
+ CJK is two cells wide, so j and k keep the column: 日本語のテキスト here.
31
+ Українською теж: слово, ще одне слово.
32
+
33
+ A long line that wraps when the window is narrow enough, to show that j and k move by \
34
+ whole lines and not by the rows on the screen, the same as in vim with its defaults.
35
+
36
+ The lines below are there to scroll through and to search in (try /rent or /😀).
37
+ """
38
+
39
+ FILLER = [
40
+ "rent 1200, food 350, transport 90",
41
+ " indented: savings = rent * 0.2",
42
+ "a line with an emoji 😀 and a flag 🇺🇦",
43
+ "",
44
+ "\tafter a tab: total, rent, food",
45
+ "short",
46
+ "Українська: оренда, їжа, транспорт",
47
+ "日本語のテキスト and rent again",
48
+ ]
49
+
50
+
51
+ def sample() -> str:
52
+ lines = [f"{i:3} {FILLER[i % len(FILLER)]}" for i in range(1, 301)]
53
+ return SAMPLE + "\n" + "\n".join(lines) + "\n"
54
+
55
+
56
+ class Demo(QMainWindow):
57
+ """The demo window: an editor with vim keys, a find bar and a status bar."""
58
+
59
+ def __init__(self) -> None:
60
+ super().__init__()
61
+ self.setWindowTitle("side-vim demo")
62
+
63
+ self.editor = editor = QPlainTextEdit()
64
+ font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
65
+ font.setPointSize(16)
66
+ editor.setFont(font)
67
+ editor.setTabStopDistance(editor.fontMetrics().horizontalAdvance(" ") * 8)
68
+ editor.setPlainText(sample())
69
+ self.setCentralWidget(editor)
70
+
71
+ self.vim = vim = VimMode(editor)
72
+
73
+ # a find bar: / and ? open it; Enter hands the text to vim, which jumps to it
74
+ self.find = QLineEdit()
75
+ self.find.setPlaceholderText("find")
76
+ self.find.hide()
77
+ self._forward = True
78
+ self.find.returnPressed.connect(self._run_find)
79
+ QShortcut(
80
+ QKeySequence("Esc"),
81
+ self.find,
82
+ self.close_find,
83
+ context=Qt.ShortcutContext.WidgetShortcut,
84
+ )
85
+ vim.search_hook = self.open_find
86
+ vim.escape_hook = self.close_find
87
+
88
+ self.mode_label = QLabel()
89
+ keys_label = QLabel()
90
+ toggle = QCheckBox("vim")
91
+ toggle.setChecked(True)
92
+ toggle.toggled.connect(vim.set_enabled)
93
+ toggle.toggled.connect(self.mode_label.setVisible)
94
+ clipboard = QCheckBox("clipboard")
95
+ clipboard.toggled.connect(lambda on: setattr(vim, "clipboard", on))
96
+ read_only = QCheckBox("read-only")
97
+ read_only.toggled.connect(editor.setReadOnly)
98
+ for box in (toggle, clipboard, read_only):
99
+ box.toggled.connect(lambda _: editor.setFocus())
100
+
101
+ self._show_mode(vim.mode)
102
+ vim.mode_changed.connect(self._show_mode)
103
+ vim.pending_changed.connect(keys_label.setText)
104
+
105
+ status = self.statusBar()
106
+ status.addWidget(self.mode_label)
107
+ status.addWidget(self.find, 1)
108
+ status.addPermanentWidget(keys_label)
109
+ status.addPermanentWidget(read_only)
110
+ status.addPermanentWidget(clipboard)
111
+ status.addPermanentWidget(toggle)
112
+ self.resize(1200, 640)
113
+
114
+ def open_find(self, forward: bool) -> None:
115
+ self._forward = forward
116
+ self.find.setPlaceholderText("find down" if forward else "find up")
117
+ self.find.show()
118
+ self.find.selectAll()
119
+ self.find.setFocus()
120
+
121
+ def close_find(self) -> bool:
122
+ """The Esc hook: closes the find bar first, if it's open."""
123
+ if not self.find.isVisible():
124
+ return False
125
+ self.find.hide()
126
+ self.editor.setFocus()
127
+ return True
128
+
129
+ def _run_find(self) -> None:
130
+ self.close_find()
131
+ self.vim.set_search(self.find.text(), self._forward)
132
+
133
+ def _show_mode(self, mode: Mode) -> None:
134
+ self.mode_label.setText(f"-- {mode.value.upper()} --")
135
+
136
+
137
+ def main() -> int:
138
+ app = QApplication(sys.argv)
139
+ window = Demo()
140
+ window.show()
141
+ window.editor.setFocus()
142
+ return app.exec()
143
+
144
+
145
+ if __name__ == "__main__":
146
+ sys.exit(main())