turbovi 0.3.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.
turbovi-0.3.0/PKG-INFO ADDED
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: turbovi
3
+ Version: 0.3.0
4
+ Summary: A vi clone for the terminal: enough vim to edit with, in three thousand lines
5
+ Keywords: terminal,tui,vi,vim,editor,modal
6
+ License-Expression: Apache-2.0
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Environment :: Console
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: POSIX
11
+ Classifier: Operating System :: POSIX :: Linux
12
+ Classifier: Operating System :: MacOS :: MacOS X
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Text Editors
17
+ Classifier: Topic :: Terminals
18
+ Classifier: Typing :: Typed
19
+ Requires-Dist: turbodesk[syntax]>=0.2
20
+ Requires-Python: >=3.12
21
+ Description-Content-Type: text/markdown
22
+
23
+ # TurboVI
24
+
25
+ A vi clone for the terminal: modes, counts, operators, registers, marks, macros, split windows and `:s`, in about three thousand lines of Python.
26
+
27
+ ```sh
28
+ uv sync
29
+ uv run turbovi somefile.py
30
+ uv run turbovi one.py two.py # two buffers, :bn between them
31
+ uv run turbovi # a scratch buffer
32
+ ```
33
+
34
+ `:q` to leave, `:help` for what is here.
35
+
36
+ ## What a vim user gets
37
+
38
+ **The grammar**, `["x] [count] operator [count] motion`, over `hjkl w b e W B E 0 ^ $ gg G { } f F t T %`. Operators `d c y > <` with doubling (`dd`, `yy`, `cc`), text objects (`iw aw i( a" i{`), and `x X D C s S r ~ J p P o O`.
39
+
40
+ **Modes**: normal, insert, visual, visual-line and command.
41
+
42
+ **Paging** with `<C-f>` `<C-b>` `<C-d>` `<C-u>`, by the window rather than by a fixed number, and `<C-e>` / `<C-y>` to move the view without moving the cursor.
43
+
44
+ **Search** with `/ ? n N` and match highlighting. **Undo and redo** with `u` and `<C-r>`. **`.`** repeats the last change, recorded as keystrokes the way vi does it, so a change that ends in insert mode replays whole.
45
+
46
+ **`:s`** with ranges (`%`, `.`, `$`, `1,5`, `.+1`), the `g`, `i` and `n` flags, any non-alphanumeric delimiter, and `&` in the replacement. One `u` undoes the whole substitution.
47
+
48
+ **Marks**: `ma` puts one down, `` `a `` comes back to the character and `'a` to the first non-blank of that line. Every jump leaves a mark, so `` `` `` returns from a `G` or a search. `:marks` lists them. They are not shifted when the text moves, which is the one place this is smaller than vi.
49
+
50
+ **Macros**: `qa` records into register `a`, `q` stops, `@a` plays, `3@a` plays three times, `@@` repeats the last. They share storage with the yank registers the way vi's do, so `"ap` pastes a macro as text and `@a` runs a yanked line. A macro that calls itself stops with a message rather than a stack that runs out.
51
+
52
+ **Several files at once** with `:e :ls :bn :bp :b`, split windows with `:sp` and `:vsp`, and `<C-w>` then `w s v c o` or `h j k l` to move between them. `<C-p>` and `:find` are a fuzzy picker over the project: type a few letters of the path and it opens in the current window.
53
+
54
+ **The system clipboard** on `"+y` and `"+p`, over OSC 52 and a local helper, so it crosses ssh.
55
+
56
+ **Syntax colouring** from the file's name. `:set syntax=rust` colours a file with no suffix, `:set nosyntax` stops. A search hit and a selection paint over the colouring, because finding something inside a comment has to be visible.
57
+
58
+ **A file opens and saves as itself.** The encoding, the line endings and whether the last line had a newline are read once and written back unchanged. A latin-1 file stays latin-1, a CRLF file stays CRLF, and a file with no trailing newline does not gain one.
59
+
60
+ ## Settings and recovery
61
+
62
+ Settings live in `$XDG_CONFIG_HOME/turbovi/config`, a file of ex commands the way a `vimrc` is:
63
+
64
+ ```
65
+ set nu
66
+ set rnu
67
+ sp
68
+ ```
69
+
70
+ A modified buffer is copied to `$XDG_DATA_HOME/turbovi/recover/` every few seconds, keyed by a hash of its absolute path, and `:recover` puts it back after a crash without writing anything to disk. The recovery copy is dropped when the buffer is saved.
71
+
72
+ ## What it is not
73
+
74
+ Not a vim: no plugins, no scripting language, no `:g`, no folds, no tabs, no persistent undo across sessions yet.
75
+
76
+ ## Built on turbodesk
77
+
78
+ [turbodesk](https://turbodesk.lab.abilian.com) is the immediate-mode terminal UI library underneath. This editor is the application on it that uses **no widgets at all** — views, events and hooks, nothing else — which is what proved the core carries a real application on its own. It lived in that repository as `examples/vi/` until 2026-08-26; a smaller showcase version stays there.
@@ -0,0 +1,56 @@
1
+ # TurboVI
2
+
3
+ A vi clone for the terminal: modes, counts, operators, registers, marks, macros, split windows and `:s`, in about three thousand lines of Python.
4
+
5
+ ```sh
6
+ uv sync
7
+ uv run turbovi somefile.py
8
+ uv run turbovi one.py two.py # two buffers, :bn between them
9
+ uv run turbovi # a scratch buffer
10
+ ```
11
+
12
+ `:q` to leave, `:help` for what is here.
13
+
14
+ ## What a vim user gets
15
+
16
+ **The grammar**, `["x] [count] operator [count] motion`, over `hjkl w b e W B E 0 ^ $ gg G { } f F t T %`. Operators `d c y > <` with doubling (`dd`, `yy`, `cc`), text objects (`iw aw i( a" i{`), and `x X D C s S r ~ J p P o O`.
17
+
18
+ **Modes**: normal, insert, visual, visual-line and command.
19
+
20
+ **Paging** with `<C-f>` `<C-b>` `<C-d>` `<C-u>`, by the window rather than by a fixed number, and `<C-e>` / `<C-y>` to move the view without moving the cursor.
21
+
22
+ **Search** with `/ ? n N` and match highlighting. **Undo and redo** with `u` and `<C-r>`. **`.`** repeats the last change, recorded as keystrokes the way vi does it, so a change that ends in insert mode replays whole.
23
+
24
+ **`:s`** with ranges (`%`, `.`, `$`, `1,5`, `.+1`), the `g`, `i` and `n` flags, any non-alphanumeric delimiter, and `&` in the replacement. One `u` undoes the whole substitution.
25
+
26
+ **Marks**: `ma` puts one down, `` `a `` comes back to the character and `'a` to the first non-blank of that line. Every jump leaves a mark, so `` `` `` returns from a `G` or a search. `:marks` lists them. They are not shifted when the text moves, which is the one place this is smaller than vi.
27
+
28
+ **Macros**: `qa` records into register `a`, `q` stops, `@a` plays, `3@a` plays three times, `@@` repeats the last. They share storage with the yank registers the way vi's do, so `"ap` pastes a macro as text and `@a` runs a yanked line. A macro that calls itself stops with a message rather than a stack that runs out.
29
+
30
+ **Several files at once** with `:e :ls :bn :bp :b`, split windows with `:sp` and `:vsp`, and `<C-w>` then `w s v c o` or `h j k l` to move between them. `<C-p>` and `:find` are a fuzzy picker over the project: type a few letters of the path and it opens in the current window.
31
+
32
+ **The system clipboard** on `"+y` and `"+p`, over OSC 52 and a local helper, so it crosses ssh.
33
+
34
+ **Syntax colouring** from the file's name. `:set syntax=rust` colours a file with no suffix, `:set nosyntax` stops. A search hit and a selection paint over the colouring, because finding something inside a comment has to be visible.
35
+
36
+ **A file opens and saves as itself.** The encoding, the line endings and whether the last line had a newline are read once and written back unchanged. A latin-1 file stays latin-1, a CRLF file stays CRLF, and a file with no trailing newline does not gain one.
37
+
38
+ ## Settings and recovery
39
+
40
+ Settings live in `$XDG_CONFIG_HOME/turbovi/config`, a file of ex commands the way a `vimrc` is:
41
+
42
+ ```
43
+ set nu
44
+ set rnu
45
+ sp
46
+ ```
47
+
48
+ A modified buffer is copied to `$XDG_DATA_HOME/turbovi/recover/` every few seconds, keyed by a hash of its absolute path, and `:recover` puts it back after a crash without writing anything to disk. The recovery copy is dropped when the buffer is saved.
49
+
50
+ ## What it is not
51
+
52
+ Not a vim: no plugins, no scripting language, no `:g`, no folds, no tabs, no persistent undo across sessions yet.
53
+
54
+ ## Built on turbodesk
55
+
56
+ [turbodesk](https://turbodesk.lab.abilian.com) is the immediate-mode terminal UI library underneath. This editor is the application on it that uses **no widgets at all** — views, events and hooks, nothing else — which is what proved the core carries a real application on its own. It lived in that repository as `examples/vi/` until 2026-08-26; a smaller showcase version stays there.
@@ -0,0 +1,80 @@
1
+ [project]
2
+ name = "turbovi"
3
+ version = "0.3.0"
4
+ description = "A vi clone for the terminal: enough vim to edit with, in three thousand lines"
5
+ requires-python = ">=3.12"
6
+ readme = "README.md"
7
+ license = "Apache-2.0"
8
+ keywords = ["terminal", "tui", "vi", "vim", "editor", "modal"]
9
+ classifiers = [
10
+ "Development Status :: 4 - Beta",
11
+ "Environment :: Console",
12
+ "Intended Audience :: Developers",
13
+ "Operating System :: POSIX",
14
+ "Operating System :: POSIX :: Linux",
15
+ "Operating System :: MacOS :: MacOS X",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Programming Language :: Python :: 3.14",
19
+ "Topic :: Text Editors",
20
+ "Topic :: Terminals",
21
+ "Typing :: Typed",
22
+ ]
23
+ # No `License ::` classifier: `license` above is an SPDX expression (PEP 639), and
24
+ # PyPI rejects a distribution that carries both.
25
+
26
+ # The syntax extra rather than plain turbodesk: `:set syntax` colours the buffer, and
27
+ # `turbodesk.syntax` is where Pygments lives.
28
+ dependencies = ["turbodesk[syntax]>=0.2"]
29
+
30
+ [project.scripts]
31
+ turbovi = "turbovi.__main__:main"
32
+
33
+ # [project.urls] once the repository is public. A project URL that 404s is worse than
34
+ # an absent one.
35
+
36
+ [build-system]
37
+ requires = ["uv_build>=0.11.33,<0.12.0"]
38
+ build-backend = "uv_build"
39
+
40
+ [tool.uv.sources]
41
+ # turbodesk from the checkout beside this one, so a library edit is picked up live.
42
+ # Drop this and the version floor above takes over the day both are on PyPI.
43
+ turbodesk = { path = "../turbodesk", editable = true }
44
+
45
+ # Linting lives in ruff.toml.
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
49
+ pythonpath = ["src"]
50
+ addopts = "--strict-markers --import-mode=importlib"
51
+ markers = [
52
+ "unit: isolated and fast — no I/O, no event loop",
53
+ "integration: components wired together, may touch the filesystem",
54
+ "slow: needs an external program or wall-clock time",
55
+ ]
56
+
57
+ [tool.mypy]
58
+ # Read by zuban too. Same search path as pytest and the other checkers.
59
+ mypy_path = "src"
60
+
61
+ [tool.ty.environment]
62
+ extra-paths = ["src"]
63
+
64
+ [tool.pyrefly]
65
+ project-includes = ["**/*.py*"]
66
+ project-excludes = ["**/.venv/**"]
67
+ search-path = ["src", "tests"]
68
+
69
+ [dependency-groups]
70
+ dev = [
71
+ "mypy>=2.3.0",
72
+ "pre-commit>=4.6.1",
73
+ "pyrefly>=1.2.0",
74
+ "pytest>=9.1.1",
75
+ "pytest-cov>=7.1.0",
76
+ "ruff>=0.16.1",
77
+ "ty>=0.0.66",
78
+ "types-pygments>=2.20.0.20260728",
79
+ "zuban>=0.9.1",
80
+ ]
@@ -0,0 +1,16 @@
1
+ """TurboVI: a vi clone for the terminal, in about three thousand lines.
2
+
3
+ Modes, motions, operators, counts, registers, marks, macros, `.` repeat, `:s` with
4
+ ranges and flags, multiple buffers, split windows, syntax colouring, crash recovery,
5
+ and a config file that is a list of ex commands the way a `vimrc` is.
6
+
7
+ `state.py` is the value every keystroke transforms; `keymap.py` is the grammar over it,
8
+ pure functions with no `UI` import. `session.py` owns the buffers and windows, and
9
+ `screen.py` draws. Built on [turbodesk](https://turbodesk.lab.abilian.com), and the one
10
+ application on it that uses no widgets at all: the buffer is drawn from the view
11
+ combinators directly, which is what proved the core carries an application on its own.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ __version__ = "0.3.0"
@@ -0,0 +1,198 @@
1
+ """turbovi — a vi clone, enough vim to edit with, in under two thousand lines.
2
+
3
+ turbovi # scratch buffer
4
+ turbovi somefile.py
5
+ turbovi one.py two.py # two buffers, :bn between them
6
+
7
+ Modes, counts, operators, motions, text objects, registers, undo, search, `.` to repeat
8
+ the last change, `:s` to substitute, and a handful of other ex commands. Several files
9
+ at once with `:e` and `:bn`, split windows with `:sp` and `:vsp`, `<C-w>` to move
10
+ between them. Source is coloured by the file's name, and `:set syntax=rust` says so by
11
+ hand. `:q` to leave, `:help` for what is here.
12
+
13
+ Settings live in `$XDG_CONFIG_HOME/turbovi/config`, a file of ex commands the way a
14
+ `vimrc` is. A modified buffer is copied to `$XDG_DATA_HOME/turbovi/recover/` every few
15
+ seconds, and `:recover` puts it back after a crash.
16
+
17
+ Not a bonsai_term example. This one exists to find out whether the turbodesk core is
18
+ enough to build a real application on — no widgets, just views, events and hooks.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import sys
24
+ from collections.abc import Callable
25
+ from dataclasses import replace
26
+
27
+ from turbodesk import UI, View, run
28
+ from turbodesk.events import Event, Key, KeyPress, Mod, Paste
29
+ from turbovi import persist, screen, session
30
+ from turbovi.session import Session
31
+ from turbovi.state import INSERT
32
+
33
+ HELP = (
34
+ "motions hjkl w b e W B E 0 ^ $ gg G { } f F t T % · "
35
+ "pages <C-f> <C-b> <C-d> <C-u> · scroll <C-e> <C-y> · counts 3j d2w · "
36
+ 'operators d c y > < with dd yy cc · text objects iw aw i( a" · '
37
+ "x X D C s S r ~ J p P o O · v V visual · u <C-r> undo · "
38
+ "/ ? n N search · . repeat · qa..q @a @@ macros · ma `a 'a marks · "
39
+ '"+y "+p system clipboard · '
40
+ ":w :q :wq :s :set nu/syntax :noh :recover · ZZ · "
41
+ ":e :ls :bn :bp buffers · :sp :vsp :only splits · <C-w>wsvcjkhl windows"
42
+ )
43
+
44
+ #: How often a modified buffer is copied somewhere it can be recovered from. Long
45
+ #: enough to cost nothing, short enough that a crash loses a sentence rather than a
46
+ #: paragraph.
47
+ RECOVERY_SECONDS = 5.0
48
+
49
+ SPECIAL_KEYS = {
50
+ Key.ESCAPE: "<Esc>",
51
+ Key.ENTER: "<CR>",
52
+ Key.BACKSPACE: "<BS>",
53
+ Key.TAB: "<Tab>",
54
+ Key.UP: "<Up>",
55
+ Key.DOWN: "<Down>",
56
+ Key.LEFT: "<Left>",
57
+ Key.RIGHT: "<Right>",
58
+ Key.DELETE: "<Del>",
59
+ Key.HOME: "<Home>",
60
+ Key.END: "<End>",
61
+ Key.PAGE_UP: "<C-b>",
62
+ Key.PAGE_DOWN: "<C-f>",
63
+ }
64
+
65
+
66
+ def encode(event: Event) -> str | None:
67
+ """Input event to vim key notation, which is what the keymap parses."""
68
+ if isinstance(event, Paste):
69
+ return None
70
+ if not isinstance(event, KeyPress):
71
+ return None
72
+ if isinstance(event.key, str):
73
+ if Mod.CTRL in event.mods:
74
+ return f"<C-{event.key}>"
75
+ return event.key if Mod.META not in event.mods else None
76
+ return SPECIAL_KEYS.get(event.key)
77
+
78
+
79
+ def configured(live: Session) -> Session:
80
+ """Apply the config file, which is a list of ex commands.
81
+
82
+ The loaded file's own message survives unless a config line complained, because the
83
+ thing worth reading on the first frame is usually "there is a recovery file" and a
84
+ config that ran cleanly has nothing to say.
85
+ """
86
+ loaded = session.current(live).message
87
+ for line in persist.read_config():
88
+ live = session.command(
89
+ session.settled(live, session.current(live).noted("")), line
90
+ )
91
+ said = session.current(live).message
92
+ return live if said else session.settled(live, session.current(live).noted(loaded))
93
+
94
+
95
+ #: Requests the app carries out rather than the session: both have to leave the render
96
+ #: loop, one for a subprocess and one for a modal.
97
+ SPAWNED = ("clip", "find")
98
+
99
+
100
+ def app(ui: UI, initial: Session) -> View:
101
+ stored, set_live = ui.state(initial)
102
+
103
+ # **Cleared before anything else reads it.** A spawned task does not run until this
104
+ # render returns, and a picker then waits on somebody typing at a modal, so a
105
+ # request left in place is re-read by every frame in between and spawned again each
106
+ # time. The first attempt cleared it *and then* let the scroll below write the old
107
+ # session back over the top — seventy-nine stacked modals, each revealed by closing
108
+ # the one on top of it. Everything downstream reads `live`, the cleared one.
109
+ pending = session.current(stored).request
110
+ spawning = bool(pending) and pending[0] in SPAWNED
111
+ live = (
112
+ session.settled(stored, replace(session.current(stored), request=()))
113
+ if spawning
114
+ else stored
115
+ )
116
+ editor = session.current(live)
117
+ if spawning:
118
+ if pending[0] == "clip":
119
+ ui.spawn(_clipboard(live, pending, set_live))
120
+ else:
121
+ ui.spawn(_find_file(live, ui, set_live))
122
+
123
+ # `live` here is one frame behind the scroll and the size settled below, and that
124
+ # is not a problem: setting an unequal value marks the frame dirty, so a session
125
+ # that has just measured a new terminal is re-rendered before any key is delivered
126
+ # and the handler that sees the key closes over the settled value.
127
+ @ui.on_event
128
+ def on_key(event: Event) -> bool:
129
+ key = encode(event)
130
+ if key is None:
131
+ return False
132
+ if key == "<C-c>": # vi answers Ctrl-C with a nudge, it does not quit
133
+ set_live(session.settled(live, editor.noted("type :q to exit")))
134
+ return True
135
+ set_live(session.apply(live, key))
136
+ return True
137
+
138
+ # Keep a copy of every buffer that differs from its file, and drop it the moment it
139
+ # does not. `:w` clears `modified`, so writing takes the copy away by itself.
140
+ ui.every(RECOVERY_SECONDS, lambda: _keep(live))
141
+
142
+ if editor.quit:
143
+ for buffer in live.buffers:
144
+ if buffer.path:
145
+ persist.drop_recovery(buffer.path)
146
+ ui.exit()
147
+
148
+ # Setting an equal value does not schedule a frame, so storing the scroll this frame
149
+ # settled on is free on every frame that did not scroll.
150
+ scrolled = session.followed(live, ui.size)
151
+ set_live(scrolled)
152
+
153
+ ui.set_cursor(
154
+ screen.CURSOR, kind="bar-blinking" if editor.mode == INSERT else "block"
155
+ )
156
+ return session.render(scrolled, ui.size, ui.theme)
157
+
158
+
159
+ async def _find_file(
160
+ live: Session, ui: UI, set_live: Callable[[Session], None]
161
+ ) -> None:
162
+ set_live(await session.found(live, ui))
163
+
164
+
165
+ async def _clipboard(
166
+ live: Session, request: tuple[str, ...], set_live: Callable[[Session], None]
167
+ ) -> None:
168
+ set_live(await session.clipped(live, request))
169
+
170
+
171
+ def _keep(live: Session) -> None:
172
+ """The recovery copy covers every open buffer, not only the one on screen."""
173
+ for buffer in live.buffers:
174
+ if not buffer.path:
175
+ continue
176
+ if buffer.modified:
177
+ persist.save_recovery(buffer.path, buffer.lines)
178
+ else:
179
+ persist.drop_recovery(buffer.path)
180
+
181
+
182
+ def opened(paths: list[str]) -> Session:
183
+ """The session the command line asks for: a buffer per file, the first on screen."""
184
+ buffers = tuple(session.load(path) for path in paths) or (session.load(None),)
185
+ return configured(Session(buffers=buffers))
186
+
187
+
188
+ def main() -> None:
189
+ arguments = [a for a in sys.argv[1:] if not a.startswith("-")]
190
+ if "--help" in sys.argv:
191
+ print(f"usage: turbovi [FILE...]\n\n{HELP}")
192
+ return
193
+ initial = opened(arguments)
194
+ run(lambda ui: app(ui, initial), title="turbovi")
195
+
196
+
197
+ if __name__ == "__main__":
198
+ main()