easymd-cli 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.
@@ -0,0 +1,4 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .uv/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yiqi Yang (decajoin)
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,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: easymd-cli
3
+ Version: 0.1.0
4
+ Summary: Terminal Markdown editor with vim-style keys and live side-by-side preview
5
+ Project-URL: Repository, https://github.com/decajoin/easymd
6
+ Project-URL: Issues, https://github.com/decajoin/easymd/issues
7
+ Author-email: Yiqi Yang <yangyiqi5261@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: editor,markdown,terminal,textual,tui,vim
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Text Editors
20
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: textual[syntax]>=8.0
23
+ Description-Content-Type: text/markdown
24
+
25
+ # easymd
26
+
27
+ 终端里的 Markdown 编辑器:左侧 vim 式编辑,右侧实时预览。基于 [Textual](https://textual.textualize.io/)。
28
+
29
+ ## 安装
30
+
31
+ 需要 Python 3.11+。
32
+
33
+ 使用 [uv](https://docs.astral.sh/uv/)(推荐):
34
+
35
+ ```bash
36
+ uv sync
37
+ uv run easymd demo.md
38
+ ```
39
+
40
+ 或使用 pip:
41
+
42
+ ```bash
43
+ pip install -r requirements.txt
44
+ pip install -e .
45
+ easymd demo.md # 文件不存在时会在首次 :w 时创建
46
+ ```
47
+
48
+ ## 按键参考
49
+
50
+ ### 模式
51
+
52
+ | 按键 | 作用 |
53
+ | --- | --- |
54
+ | `i` `a` `A` `I` `o` `O` | 进入插入模式(位置同 vim) |
55
+ | `Esc` | 回到普通模式 |
56
+ | `v` | 可视模式(`y`/`d`/`c` 作用于选区) |
57
+ | `V` | 可视行模式(按整行选择,`y`/`d`/`c` 作用于整行;`v`/`V` 互切) |
58
+
59
+ ### 移动(普通/可视模式,支持数字前缀如 `3j`)
60
+
61
+ `h j k l`、`w b e`、`0 ^ $`、`gg G`(`3G` 跳第 3 行)、`Ctrl+d/u` 半页、`Ctrl+f/b` 整页
62
+
63
+ ### 编辑
64
+
65
+ | 按键 | 作用 |
66
+ | --- | --- |
67
+ | `x` | 删除光标处字符 |
68
+ | `dd` / `yy` / `cc` | 删除 / 复制 / 改写整行(支持 `3dd`) |
69
+ | `dw` `de` `d$` 等 | 操作符 + 移动(`y`、`c` 同理) |
70
+ | `p` / `P` | 在后 / 前粘贴 |
71
+ | `u` / `Ctrl+r` | 撤销 / 重做 |
72
+
73
+ ### 命令与搜索
74
+
75
+ | 命令 | 作用 |
76
+ | --- | --- |
77
+ | `:w` `:w 文件名` | 保存 |
78
+ | `:q` `:q!` `:wq` `:x` | 退出(有未保存修改时 `:q` 会拒绝) |
79
+ | `/文本` 然后 `n` / `N` | 搜索 / 下一个 / 上一个 |
80
+
81
+ ## 项目结构
82
+
83
+ ```
84
+ src/easymd/
85
+ cli.py # 命令行入口
86
+ app.py # 分屏布局、状态栏、命令行、预览同步
87
+ editor.py # vim 模态层(TextArea 子类)
88
+ tests/
89
+ smoke_test.py # 无头冒烟测试(Textual Pilot 驱动按键)
90
+ ```
91
+
92
+ 运行测试:`uv run python tests/smoke_test.py`
@@ -0,0 +1,68 @@
1
+ # easymd
2
+
3
+ 终端里的 Markdown 编辑器:左侧 vim 式编辑,右侧实时预览。基于 [Textual](https://textual.textualize.io/)。
4
+
5
+ ## 安装
6
+
7
+ 需要 Python 3.11+。
8
+
9
+ 使用 [uv](https://docs.astral.sh/uv/)(推荐):
10
+
11
+ ```bash
12
+ uv sync
13
+ uv run easymd demo.md
14
+ ```
15
+
16
+ 或使用 pip:
17
+
18
+ ```bash
19
+ pip install -r requirements.txt
20
+ pip install -e .
21
+ easymd demo.md # 文件不存在时会在首次 :w 时创建
22
+ ```
23
+
24
+ ## 按键参考
25
+
26
+ ### 模式
27
+
28
+ | 按键 | 作用 |
29
+ | --- | --- |
30
+ | `i` `a` `A` `I` `o` `O` | 进入插入模式(位置同 vim) |
31
+ | `Esc` | 回到普通模式 |
32
+ | `v` | 可视模式(`y`/`d`/`c` 作用于选区) |
33
+ | `V` | 可视行模式(按整行选择,`y`/`d`/`c` 作用于整行;`v`/`V` 互切) |
34
+
35
+ ### 移动(普通/可视模式,支持数字前缀如 `3j`)
36
+
37
+ `h j k l`、`w b e`、`0 ^ $`、`gg G`(`3G` 跳第 3 行)、`Ctrl+d/u` 半页、`Ctrl+f/b` 整页
38
+
39
+ ### 编辑
40
+
41
+ | 按键 | 作用 |
42
+ | --- | --- |
43
+ | `x` | 删除光标处字符 |
44
+ | `dd` / `yy` / `cc` | 删除 / 复制 / 改写整行(支持 `3dd`) |
45
+ | `dw` `de` `d$` 等 | 操作符 + 移动(`y`、`c` 同理) |
46
+ | `p` / `P` | 在后 / 前粘贴 |
47
+ | `u` / `Ctrl+r` | 撤销 / 重做 |
48
+
49
+ ### 命令与搜索
50
+
51
+ | 命令 | 作用 |
52
+ | --- | --- |
53
+ | `:w` `:w 文件名` | 保存 |
54
+ | `:q` `:q!` `:wq` `:x` | 退出(有未保存修改时 `:q` 会拒绝) |
55
+ | `/文本` 然后 `n` / `N` | 搜索 / 下一个 / 上一个 |
56
+
57
+ ## 项目结构
58
+
59
+ ```
60
+ src/easymd/
61
+ cli.py # 命令行入口
62
+ app.py # 分屏布局、状态栏、命令行、预览同步
63
+ editor.py # vim 模态层(TextArea 子类)
64
+ tests/
65
+ smoke_test.py # 无头冒烟测试(Textual Pilot 驱动按键)
66
+ ```
67
+
68
+ 运行测试:`uv run python tests/smoke_test.py`
@@ -0,0 +1,28 @@
1
+ # easymd 演示文档
2
+
3
+ 这是一个 **演示文件**,用来试用 easymd 的编辑和预览。
4
+
5
+ ## 功能列表
6
+
7
+ - 左侧 vim 式编辑
8
+ - 右侧实时 Markdown 预览
9
+ - 光标移动时预览联动滚动
10
+
11
+ ## 代码块
12
+
13
+ ```python
14
+ def hello(name: str) -> str:
15
+ return f"Hello, {name}!"
16
+ ```
17
+
18
+ ## 表格
19
+
20
+ | 按键 | 作用 |
21
+ | ---- | ---- |
22
+ | `i` | 进入插入模式 |
23
+ | `dd` | 删除当前行 |
24
+ | `:w` | 保存 |
25
+
26
+ > 引用块也可以正常渲染。
27
+
28
+ 试着按 `i` 开始编辑,右边的预览会自动刷新。
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "easymd-cli"
3
+ version = "0.1.0"
4
+ description = "Terminal Markdown editor with vim-style keys and live side-by-side preview"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.11"
9
+ authors = [{ name = "Yiqi Yang", email = "yangyiqi5261@gmail.com" }]
10
+ keywords = ["markdown", "vim", "tui", "editor", "terminal", "textual"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Environment :: Console",
14
+ "Intended Audience :: Developers",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Topic :: Text Editors",
21
+ "Topic :: Text Processing :: Markup :: Markdown",
22
+ ]
23
+ dependencies = ["textual[syntax]>=8.0"]
24
+
25
+ [project.scripts]
26
+ easymd = "easymd.cli:main"
27
+
28
+ [project.urls]
29
+ Repository = "https://github.com/decajoin/easymd"
30
+ Issues = "https://github.com/decajoin/easymd/issues"
31
+
32
+ [build-system]
33
+ requires = ["hatchling>=1.27"]
34
+ build-backend = "hatchling.build"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/easymd"]
38
+
39
+ [tool.hatch.build.targets.sdist]
40
+ include = ["src", "tests", "demo.md", "README.md", "LICENSE"]
@@ -0,0 +1,3 @@
1
+ """easymd: terminal Markdown editor with vim keys and live preview."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -0,0 +1,214 @@
1
+ """The easymd application: editor pane, live Markdown preview, vim command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from rich.markup import escape
8
+ from textual import events
9
+ from textual.app import App, ComposeResult
10
+ from textual.containers import Horizontal, VerticalScroll
11
+ from textual.message import Message
12
+ from textual.widgets import Input, Markdown, Static, TextArea
13
+
14
+ from .editor import VimTextArea
15
+
16
+ MODE_STYLES = {
17
+ "normal": ("NORMAL", "blue"),
18
+ "insert": ("INSERT", "green"),
19
+ "visual": ("VISUAL", "magenta"),
20
+ "visual_line": ("V-LINE", "magenta"),
21
+ }
22
+
23
+
24
+ class CommandLine(Input):
25
+ """The `:` / `/` command line; escape cancels back to the editor."""
26
+
27
+ class Cancelled(Message):
28
+ pass
29
+
30
+ def __init__(self, **kwargs) -> None:
31
+ # Focus must not select the prefix, or the first keystroke replaces it.
32
+ kwargs.setdefault("select_on_focus", False)
33
+ super().__init__(**kwargs)
34
+
35
+ async def _on_key(self, event: events.Key) -> None:
36
+ if event.key == "escape":
37
+ event.stop()
38
+ event.prevent_default()
39
+ self.post_message(self.Cancelled())
40
+ return
41
+ await super()._on_key(event)
42
+
43
+
44
+ class EasyMDApp(App):
45
+ TITLE = "easymd"
46
+
47
+ CSS = """
48
+ #main { height: 1fr; }
49
+ #editor { width: 1fr; border: none; }
50
+ #preview-scroll {
51
+ width: 1fr;
52
+ border-left: heavy $accent;
53
+ padding: 0 1;
54
+ scrollbar-size-vertical: 1;
55
+ }
56
+ #status { dock: bottom; height: 1; background: $panel; padding: 0 1; }
57
+ #cmdline {
58
+ dock: bottom;
59
+ height: 1;
60
+ border: none;
61
+ padding: 0 1;
62
+ background: $surface;
63
+ display: none;
64
+ }
65
+ """
66
+
67
+ def __init__(self, path: Path) -> None:
68
+ super().__init__()
69
+ self.path = path
70
+ self._saved_text = (
71
+ path.read_text(encoding="utf-8") if path.exists() else ""
72
+ )
73
+ self._preview_timer = None
74
+ self._notice = ""
75
+
76
+ # ------------------------------------------------------------------
77
+ # Layout
78
+
79
+ def compose(self) -> ComposeResult:
80
+ editor = VimTextArea(self._saved_text, id="editor", show_line_numbers=True)
81
+ try:
82
+ editor.language = "markdown"
83
+ except Exception:
84
+ pass # syntax highlighting is optional (needs textual[syntax])
85
+ with Horizontal(id="main"):
86
+ yield editor
87
+ with VerticalScroll(id="preview-scroll"):
88
+ yield Markdown(self._saved_text, id="preview")
89
+ yield Static(id="status")
90
+ yield CommandLine(id="cmdline")
91
+
92
+ def on_mount(self) -> None:
93
+ self.editor.focus()
94
+ self._update_status()
95
+
96
+ @property
97
+ def editor(self) -> VimTextArea:
98
+ return self.query_one("#editor", VimTextArea)
99
+
100
+ @property
101
+ def modified(self) -> bool:
102
+ return self.editor.text != self._saved_text
103
+
104
+ # ------------------------------------------------------------------
105
+ # Status bar
106
+
107
+ def _update_status(self) -> None:
108
+ try:
109
+ status = self.query_one("#status", Static)
110
+ except Exception:
111
+ return
112
+ ed = self.editor
113
+ label, color = MODE_STYLES[ed.mode]
114
+ row, col = ed.cursor_location
115
+ flag = " ●" if self.modified else ""
116
+ notice = f" {escape(self._notice)}" if self._notice else ""
117
+ status.update(
118
+ f"[bold white on {color}] {label} [/] "
119
+ f"{escape(self.path.name)}{flag}{notice}"
120
+ f"[dim] {row + 1}:{col + 1}[/]"
121
+ )
122
+
123
+ # ------------------------------------------------------------------
124
+ # Editor events
125
+
126
+ def on_text_area_changed(self, event: TextArea.Changed) -> None:
127
+ if self._preview_timer is not None:
128
+ self._preview_timer.stop()
129
+ self._preview_timer = self.set_timer(0.25, self._refresh_preview)
130
+ self._update_status()
131
+
132
+ def on_text_area_selection_changed(
133
+ self, event: TextArea.SelectionChanged
134
+ ) -> None:
135
+ self._update_status()
136
+ self._sync_scroll()
137
+
138
+ def on_vim_text_area_mode_changed(
139
+ self, event: VimTextArea.ModeChanged
140
+ ) -> None:
141
+ self._notice = ""
142
+ self._update_status()
143
+
144
+ async def _refresh_preview(self) -> None:
145
+ await self.query_one("#preview", Markdown).update(self.editor.text)
146
+ self._sync_scroll()
147
+
148
+ def _sync_scroll(self) -> None:
149
+ try:
150
+ scroller = self.query_one("#preview-scroll", VerticalScroll)
151
+ except Exception:
152
+ return
153
+ ed = self.editor
154
+ total = max(1, ed.document.line_count - 1)
155
+ fraction = ed.cursor_location[0] / total
156
+ scroller.scroll_to(y=fraction * scroller.max_scroll_y, animate=False)
157
+
158
+ # ------------------------------------------------------------------
159
+ # Command line (: and /)
160
+
161
+ def on_vim_text_area_command_requested(
162
+ self, event: VimTextArea.CommandRequested
163
+ ) -> None:
164
+ cmdline = self.query_one("#cmdline", CommandLine)
165
+ cmdline.display = True
166
+ cmdline.value = event.prefix
167
+ cmdline.cursor_position = len(cmdline.value)
168
+ cmdline.focus()
169
+
170
+ def _hide_cmdline(self) -> None:
171
+ cmdline = self.query_one("#cmdline", CommandLine)
172
+ cmdline.value = ""
173
+ cmdline.display = False
174
+ self.editor.focus()
175
+
176
+ def on_command_line_cancelled(self, event: CommandLine.Cancelled) -> None:
177
+ self._hide_cmdline()
178
+
179
+ def on_input_submitted(self, event: Input.Submitted) -> None:
180
+ value = event.value
181
+ self._hide_cmdline()
182
+ if value.startswith("/"):
183
+ pattern = value[1:]
184
+ if pattern:
185
+ self.editor.set_search(pattern)
186
+ elif value.startswith(":"):
187
+ self._run_command(value[1:].strip())
188
+
189
+ def _run_command(self, command: str) -> None:
190
+ parts = command.split()
191
+ if not parts:
192
+ return
193
+ name, args = parts[0], parts[1:]
194
+ if name in ("w", "w!", "wq", "x"):
195
+ target = Path(args[0]) if args else self.path
196
+ try:
197
+ target.write_text(self.editor.text, encoding="utf-8")
198
+ except OSError as error:
199
+ self._notice = f"E212: Can't open file for writing: {error}"
200
+ self._update_status()
201
+ return
202
+ if target == self.path:
203
+ self._saved_text = self.editor.text
204
+ self._notice = f'"{target}" written'
205
+ if name in ("wq", "x"):
206
+ self.exit()
207
+ elif name in ("q", "q!", "qa", "qa!"):
208
+ if name.endswith("!") or not self.modified:
209
+ self.exit()
210
+ else:
211
+ self._notice = "E37: No write since last change (add ! to override)"
212
+ else:
213
+ self._notice = f"E492: Not an editor command: {name}"
214
+ self._update_status()
@@ -0,0 +1,29 @@
1
+ """Command-line entry point for easymd."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ from . import __version__
9
+ from .app import EasyMDApp
10
+
11
+
12
+ def main() -> None:
13
+ parser = argparse.ArgumentParser(
14
+ prog="easymd",
15
+ description="Terminal Markdown editor with vim keys and live preview.",
16
+ )
17
+ parser.add_argument(
18
+ "file",
19
+ help="Markdown file to edit (created on first :w if it does not exist)",
20
+ )
21
+ parser.add_argument(
22
+ "--version", action="version", version=f"%(prog)s {__version__}"
23
+ )
24
+ args = parser.parse_args()
25
+ EasyMDApp(Path(args.file)).run()
26
+
27
+
28
+ if __name__ == "__main__":
29
+ main()
@@ -0,0 +1,459 @@
1
+ """Vim-style modal editing layer on top of Textual's TextArea."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from textual import events
8
+ from textual.message import Message
9
+ from textual.widgets import TextArea
10
+ from textual.widgets.text_area import Selection
11
+
12
+ # A vim "word": a run of word chars, or a run of punctuation.
13
+ WORD_RE = re.compile(r"\w+|[^\w\s]+")
14
+
15
+ NORMAL = "normal"
16
+ INSERT = "insert"
17
+ VISUAL = "visual"
18
+ VISUAL_LINE = "visual_line"
19
+
20
+
21
+ class VimTextArea(TextArea):
22
+ """TextArea with a vim-like modal key layer (normal / insert / visual)."""
23
+
24
+ class ModeChanged(Message):
25
+ def __init__(self, mode: str) -> None:
26
+ super().__init__()
27
+ self.mode = mode
28
+
29
+ class CommandRequested(Message):
30
+ """User pressed `:` or `/` in normal mode; the app owns the command line."""
31
+
32
+ def __init__(self, prefix: str) -> None:
33
+ super().__init__()
34
+ self.prefix = prefix
35
+
36
+ def __init__(self, text: str = "", **kwargs) -> None:
37
+ kwargs.setdefault("tab_behavior", "indent")
38
+ super().__init__(text, **kwargs)
39
+ self.mode = NORMAL
40
+ self._count = ""
41
+ self._pending = "" # pending operator/prefix: d, y, c or g
42
+ self._register: tuple[str, bool] = ("", False) # (text, linewise)
43
+ self._search = ""
44
+ self._line_anchor = 0 # anchor row for visual line mode
45
+
46
+ # ------------------------------------------------------------------
47
+ # Modes
48
+
49
+ def _set_mode(self, mode: str) -> None:
50
+ if mode == self.mode:
51
+ return
52
+ self.mode = mode
53
+ self._count = ""
54
+ self._pending = ""
55
+ self.post_message(self.ModeChanged(mode))
56
+
57
+ # ------------------------------------------------------------------
58
+ # Key dispatch
59
+
60
+ async def _on_key(self, event: events.Key) -> None:
61
+ if self.mode == INSERT:
62
+ if event.key == "escape":
63
+ event.stop()
64
+ event.prevent_default()
65
+ row, col = self.cursor_location
66
+ if col > 0:
67
+ self.move_cursor((row, col - 1))
68
+ self._set_mode(NORMAL)
69
+ return
70
+ await super()._on_key(event)
71
+ return
72
+ # Normal / visual mode: nothing reaches the underlying TextArea.
73
+ event.stop()
74
+ event.prevent_default()
75
+ self._handle_modal_key(event)
76
+
77
+ def _handle_modal_key(self, event: events.Key) -> None:
78
+ key = event.key
79
+ char = event.character if event.is_printable else None
80
+
81
+ if key == "escape":
82
+ if self.mode in (VISUAL, VISUAL_LINE):
83
+ self.selection = Selection.cursor(self.cursor_location)
84
+ self._set_mode(NORMAL)
85
+ self._count = ""
86
+ self._pending = ""
87
+ return
88
+
89
+ # Count prefix; a lone 0 is the line-start motion, not a count.
90
+ if char and char.isdigit() and not (char == "0" and not self._count):
91
+ self._count += char
92
+ return
93
+
94
+ n = int(self._count) if self._count else 1
95
+ explicit_count = bool(self._count)
96
+ self._count = ""
97
+ pending, self._pending = self._pending, ""
98
+
99
+ if pending == "g":
100
+ if char == "g":
101
+ last = self.document.line_count - 1
102
+ row = min(n - 1, last) if explicit_count else 0
103
+ self._move((row, 0))
104
+ return
105
+
106
+ if pending in ("d", "y", "c"):
107
+ self._handle_operator(pending, char, key, n, explicit_count)
108
+ return
109
+
110
+ # Visual-mode operators act on the selection.
111
+ if char in ("d", "x", "y", "c"):
112
+ if self.mode == VISUAL:
113
+ self._visual_operate(char)
114
+ return
115
+ if self.mode == VISUAL_LINE:
116
+ self._visual_line_operate(char)
117
+ return
118
+
119
+ # Plain motions (extend the selection in visual mode).
120
+ target = self._motion_target(char or key, n, explicit_count)
121
+ if target is not None:
122
+ self._move(target)
123
+ return
124
+
125
+ self._handle_command_key(char, key, n, explicit_count)
126
+
127
+ def _move(self, target: tuple[int, int]) -> None:
128
+ if self.mode == VISUAL_LINE:
129
+ self.move_cursor(target)
130
+ self._update_linewise_selection()
131
+ else:
132
+ self.move_cursor(target, select=self.mode == VISUAL)
133
+
134
+ def _update_linewise_selection(self) -> None:
135
+ """Expand the selection to whole lines between the anchor and the cursor."""
136
+ row = self.cursor_location[0]
137
+ anchor = self._line_anchor
138
+ doc = self.document
139
+ if row >= anchor:
140
+ self.selection = Selection(
141
+ (anchor, 0), (row, len(doc.get_line(row)))
142
+ )
143
+ else:
144
+ self.selection = Selection(
145
+ (anchor, len(doc.get_line(anchor))), (row, 0)
146
+ )
147
+
148
+ # ------------------------------------------------------------------
149
+ # Normal-mode commands
150
+
151
+ def _handle_command_key(
152
+ self, char: str | None, key: str, n: int, explicit_count: bool
153
+ ) -> None:
154
+ doc = self.document
155
+ row, col = self.cursor_location
156
+
157
+ if char == "i":
158
+ self._set_mode(INSERT)
159
+ elif char == "a":
160
+ line = doc.get_line(row)
161
+ if col < len(line):
162
+ self.move_cursor((row, col + 1))
163
+ self._set_mode(INSERT)
164
+ elif char == "A":
165
+ self.move_cursor((row, len(doc.get_line(row))))
166
+ self._set_mode(INSERT)
167
+ elif char == "I":
168
+ line = doc.get_line(row)
169
+ self.move_cursor((row, len(line) - len(line.lstrip())))
170
+ self._set_mode(INSERT)
171
+ elif char == "o":
172
+ self.move_cursor((row, len(doc.get_line(row))))
173
+ self.insert("\n")
174
+ self._set_mode(INSERT)
175
+ elif char == "O":
176
+ self.move_cursor((row, 0))
177
+ self.insert("\n")
178
+ self.move_cursor((row, 0))
179
+ self._set_mode(INSERT)
180
+ elif char == "x":
181
+ line = doc.get_line(row)
182
+ end = min(len(line), col + n)
183
+ if end > col:
184
+ self._register = (self.get_text_range((row, col), (row, end)), False)
185
+ self.delete((row, col), (row, end))
186
+ elif char in ("d", "y", "c"):
187
+ self._pending = char
188
+ if explicit_count:
189
+ self._count = str(n)
190
+ elif char == "g":
191
+ self._pending = "g"
192
+ if explicit_count:
193
+ self._count = str(n)
194
+ elif char == "v":
195
+ if self.mode == VISUAL:
196
+ self.selection = Selection.cursor(self.cursor_location)
197
+ self._set_mode(NORMAL)
198
+ else:
199
+ # From normal or visual line; keep the selection when switching.
200
+ self._set_mode(VISUAL)
201
+ elif char == "V":
202
+ if self.mode == VISUAL_LINE:
203
+ self.selection = Selection.cursor(self.cursor_location)
204
+ self._set_mode(NORMAL)
205
+ else:
206
+ # Anchor on the selection start when coming from charwise visual.
207
+ if self.mode == VISUAL:
208
+ self._line_anchor = min(
209
+ self.selection.start[0], self.selection.end[0]
210
+ )
211
+ else:
212
+ self._line_anchor = row
213
+ self._set_mode(VISUAL_LINE)
214
+ self._update_linewise_selection()
215
+ elif char == "p":
216
+ self._paste(after=True, n=n)
217
+ elif char == "P":
218
+ self._paste(after=False, n=n)
219
+ elif char == "u":
220
+ self.undo()
221
+ elif key == "ctrl+r":
222
+ self.redo()
223
+ elif char in (":", "/"):
224
+ self.post_message(self.CommandRequested(char))
225
+ elif char == "n":
226
+ self.search_next()
227
+ elif char == "N":
228
+ self.search_next(reverse=True)
229
+ elif key in ("ctrl+d", "ctrl+u"):
230
+ half = max(1, self.size.height // 2)
231
+ delta = half if key == "ctrl+d" else -half
232
+ new_row = max(0, min(doc.line_count - 1, row + delta))
233
+ self._move((new_row, min(col, len(doc.get_line(new_row)))))
234
+ elif key in ("ctrl+f", "pagedown"):
235
+ self.action_cursor_page_down()
236
+ elif key in ("ctrl+b", "pageup"):
237
+ self.action_cursor_page_up()
238
+
239
+ # ------------------------------------------------------------------
240
+ # Motions
241
+
242
+ def _motion_target(
243
+ self, sym: str, n: int, explicit_count: bool
244
+ ) -> tuple[int, int] | None:
245
+ doc = self.document
246
+ row, col = self.cursor_location
247
+ last = doc.line_count - 1
248
+
249
+ def clamp(r: int, c: int) -> tuple[int, int]:
250
+ return (r, min(c, len(doc.get_line(r))))
251
+
252
+ if sym in ("h", "left"):
253
+ return (row, max(0, col - n))
254
+ if sym in ("l", "right"):
255
+ return clamp(row, col + n)
256
+ if sym in ("j", "down"):
257
+ return clamp(min(last, row + n), col)
258
+ if sym in ("k", "up"):
259
+ return clamp(max(0, row - n), col)
260
+ if sym in ("0", "home"):
261
+ return (row, 0)
262
+ if sym == "^":
263
+ line = doc.get_line(row)
264
+ return (row, len(line) - len(line.lstrip()))
265
+ if sym in ("$", "end"):
266
+ return (row, len(doc.get_line(row)))
267
+ if sym == "G":
268
+ return (min(n - 1, last) if explicit_count else last, 0)
269
+ if sym == "w":
270
+ return self._next_word(n, end=False)
271
+ if sym == "e":
272
+ return self._next_word(n, end=True)
273
+ if sym == "b":
274
+ return self._prev_word(n)
275
+ return None
276
+
277
+ def _next_word(self, n: int, end: bool) -> tuple[int, int]:
278
+ row, col = self.cursor_location
279
+ for _ in range(n):
280
+ row, col = self._scan_forward(row, col, end)
281
+ return (row, col)
282
+
283
+ def _scan_forward(self, row: int, col: int, end: bool) -> tuple[int, int]:
284
+ doc = self.document
285
+ for r in range(row, doc.line_count):
286
+ for m in WORD_RE.finditer(doc.get_line(r)):
287
+ pos = m.start() if not end else m.end() - 1
288
+ if r > row or pos > col:
289
+ return (r, pos)
290
+ last = doc.line_count - 1
291
+ return (last, max(0, len(doc.get_line(last)) - (1 if end else 0)))
292
+
293
+ def _prev_word(self, n: int) -> tuple[int, int]:
294
+ row, col = self.cursor_location
295
+ for _ in range(n):
296
+ row, col = self._scan_back(row, col)
297
+ return (row, col)
298
+
299
+ def _scan_back(self, row: int, col: int) -> tuple[int, int]:
300
+ doc = self.document
301
+ for r in range(row, -1, -1):
302
+ best = None
303
+ for m in WORD_RE.finditer(doc.get_line(r)):
304
+ if r < row or m.start() < col:
305
+ best = m.start()
306
+ else:
307
+ break
308
+ if best is not None:
309
+ return (r, best)
310
+ return (0, 0)
311
+
312
+ # ------------------------------------------------------------------
313
+ # Operators (d / y / c)
314
+
315
+ def _line_range_text(self, row: int, end_row: int) -> str:
316
+ end_col = len(self.document.get_line(end_row))
317
+ return self.get_text_range((row, 0), (end_row, end_col))
318
+
319
+ def _delete_lines(self, n: int) -> None:
320
+ doc = self.document
321
+ row, _ = self.cursor_location
322
+ last = doc.line_count - 1
323
+ end_row = min(row + n - 1, last)
324
+ self._register = (self._line_range_text(row, end_row) + "\n", True)
325
+ if end_row < last:
326
+ self.delete((row, 0), (end_row + 1, 0))
327
+ elif row > 0:
328
+ # Deleting through the last line: eat the preceding newline instead.
329
+ self.delete(
330
+ (row - 1, len(doc.get_line(row - 1))),
331
+ (end_row, len(doc.get_line(end_row))),
332
+ )
333
+ else:
334
+ self.delete((0, 0), (end_row, len(doc.get_line(end_row))))
335
+ new_last = self.document.line_count - 1
336
+ self.move_cursor((min(row, new_last), 0))
337
+
338
+ def _handle_operator(
339
+ self, op: str, char: str | None, key: str, n: int, explicit_count: bool
340
+ ) -> None:
341
+ # Doubled operator (dd / yy / cc) works on whole lines.
342
+ if char == op:
343
+ row, _ = self.cursor_location
344
+ end_row = min(row + n - 1, self.document.line_count - 1)
345
+ if op == "y":
346
+ self._register = (self._line_range_text(row, end_row) + "\n", True)
347
+ elif op == "d":
348
+ self._delete_lines(n)
349
+ else: # cc: clear the lines' content and start inserting
350
+ self._register = (self._line_range_text(row, end_row) + "\n", True)
351
+ end_col = len(self.document.get_line(end_row))
352
+ self.delete((row, 0), (end_row, end_col))
353
+ self.move_cursor((row, 0))
354
+ self._set_mode(INSERT)
355
+ return
356
+
357
+ target = self._motion_target(char or key, n, explicit_count)
358
+ if target is None:
359
+ return
360
+ start = self.cursor_location
361
+ if target < start:
362
+ start, target = target, start
363
+ elif char == "e":
364
+ # 'e' is an inclusive motion: take the character under the target too.
365
+ target = (target[0], target[1] + 1)
366
+ text = self.get_text_range(start, target)
367
+ if not text:
368
+ return
369
+ self._register = (text, False)
370
+ if op == "y":
371
+ self.move_cursor(start)
372
+ return
373
+ self.delete(start, target)
374
+ if op == "c":
375
+ self._set_mode(INSERT)
376
+
377
+ def _visual_line_operate(self, char: str) -> None:
378
+ sel = self.selection
379
+ row, end_row = sorted([sel.start[0], sel.end[0]])
380
+ self._register = (self._line_range_text(row, end_row) + "\n", True)
381
+ if char == "y":
382
+ self.move_cursor((row, 0))
383
+ elif char == "c":
384
+ end_col = len(self.document.get_line(end_row))
385
+ self.delete((row, 0), (end_row, end_col))
386
+ self.move_cursor((row, 0))
387
+ else: # d / x: remove the lines entirely, dd-style
388
+ self.move_cursor((row, 0))
389
+ self._delete_lines(end_row - row + 1)
390
+ self._set_mode(INSERT if char == "c" else NORMAL)
391
+
392
+ def _visual_operate(self, char: str) -> None:
393
+ sel = self.selection
394
+ start, end = sorted([sel.start, sel.end])
395
+ # Vim's visual selection includes the character under the cursor.
396
+ line_len = len(self.document.get_line(end[0]))
397
+ end = (end[0], min(line_len, end[1] + 1))
398
+ text = self.get_text_range(start, end)
399
+ self._register = (text, False)
400
+ if char == "y":
401
+ self.move_cursor(start)
402
+ else: # d / x / c
403
+ self.delete(start, end)
404
+ self._set_mode(INSERT if char == "c" else NORMAL)
405
+
406
+ # ------------------------------------------------------------------
407
+ # Paste
408
+
409
+ def _paste(self, after: bool, n: int = 1) -> None:
410
+ text, linewise = self._register
411
+ if not text:
412
+ return
413
+ text = text * n
414
+ doc = self.document
415
+ row, col = self.cursor_location
416
+ if linewise:
417
+ if after:
418
+ if row == doc.line_count - 1:
419
+ eol = (row, len(doc.get_line(row)))
420
+ self.insert("\n" + text.rstrip("\n"), eol)
421
+ else:
422
+ self.insert(text, (row + 1, 0))
423
+ self.move_cursor((row + 1, 0))
424
+ else:
425
+ self.insert(text, (row, 0))
426
+ self.move_cursor((row, 0))
427
+ else:
428
+ line = doc.get_line(row)
429
+ loc = (row, min(col + 1, len(line))) if after and line else (row, col)
430
+ self.insert(text, loc, maintain_selection_offset=False)
431
+
432
+ # ------------------------------------------------------------------
433
+ # Search
434
+
435
+ def set_search(self, pattern: str) -> None:
436
+ self._search = pattern
437
+ self.search_next()
438
+
439
+ def search_next(self, reverse: bool = False) -> None:
440
+ if not self._search:
441
+ return
442
+ doc = self.document
443
+ matches: list[tuple[int, int]] = []
444
+ for r in range(doc.line_count):
445
+ line = doc.get_line(r)
446
+ i = line.find(self._search)
447
+ while i >= 0:
448
+ matches.append((r, i))
449
+ i = line.find(self._search, i + 1)
450
+ if not matches:
451
+ return
452
+ here = self.cursor_location
453
+ if reverse:
454
+ before = [m for m in matches if m < here]
455
+ target = before[-1] if before else matches[-1]
456
+ else:
457
+ after = [m for m in matches if m > here]
458
+ target = after[0] if after else matches[0]
459
+ self.move_cursor(target)
@@ -0,0 +1,133 @@
1
+ """Headless smoke test: drives the app with Textual's Pilot and asserts vim behavior."""
2
+
3
+ import asyncio
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ from easymd.app import EasyMDApp
8
+
9
+ SAMPLE = "# Title\n\nhello world\nsecond line\n"
10
+
11
+
12
+ async def run() -> None:
13
+ tmp = Path(tempfile.mkdtemp()) / "t.md"
14
+ tmp.write_text(SAMPLE, encoding="utf-8")
15
+ app = EasyMDApp(tmp)
16
+ async with app.run_test(size=(120, 40)) as pilot:
17
+ ed = app.editor
18
+ assert ed.mode == "normal", ed.mode
19
+
20
+ # Motions: j, gg, G, counts
21
+ await pilot.press("j")
22
+ assert ed.cursor_location[0] == 1
23
+ await pilot.press("G")
24
+ assert ed.cursor_location[0] == ed.document.line_count - 1
25
+ await pilot.press("g", "g")
26
+ assert ed.cursor_location == (0, 0)
27
+ await pilot.press("3", "G")
28
+ assert ed.cursor_location[0] == 2
29
+
30
+ # Word motions on "hello world"
31
+ await pilot.press("w")
32
+ assert ed.cursor_location == (2, 6), ed.cursor_location
33
+ await pilot.press("b")
34
+ assert ed.cursor_location == (2, 0)
35
+ await pilot.press("e")
36
+ assert ed.cursor_location == (2, 4)
37
+ await pilot.press("dollar_sign")
38
+ assert ed.cursor_location == (2, len("hello world"))
39
+
40
+ # Insert mode
41
+ await pilot.press("g", "g", "i")
42
+ assert ed.mode == "insert"
43
+ await pilot.press("X", "Y")
44
+ await pilot.press("escape")
45
+ assert ed.mode == "normal"
46
+ assert ed.text.startswith("XY# Title"), ed.text[:20]
47
+ assert app.modified
48
+
49
+ # dd deletes the first line, p pastes it back below
50
+ await pilot.press("g", "g", "d", "d")
51
+ assert ed.text.startswith("\nhello"), ed.text[:12]
52
+ await pilot.press("p")
53
+ assert ed.document.get_line(1) == "XY# Title", ed.document.get_line(1)
54
+
55
+ # yy / p duplicates a line
56
+ before = ed.document.line_count
57
+ await pilot.press("y", "y", "p")
58
+ assert ed.document.line_count == before + 1
59
+
60
+ # x deletes a character
61
+ await pilot.press("g", "g", "j", "0", "x")
62
+ assert ed.document.get_line(1) == "Y# Title", ed.document.get_line(1)
63
+
64
+ # Search via /
65
+ await pilot.press("slash")
66
+ for ch in "world":
67
+ await pilot.press(ch)
68
+ await pilot.press("enter")
69
+ row, col = ed.cursor_location
70
+ assert ed.document.get_line(row)[col : col + 5] == "world"
71
+
72
+ # Visual line mode: V selects the whole line, j extends, y yanks linewise
73
+ await pilot.press("g", "g", "j") # on "Y# Title"
74
+ await pilot.press("V")
75
+ assert ed.mode == "visual_line"
76
+ start, end = sorted([ed.selection.start, ed.selection.end])
77
+ assert start == (1, 0) and end == (1, len("Y# Title")), ed.selection
78
+ await pilot.press("j")
79
+ start, end = sorted([ed.selection.start, ed.selection.end])
80
+ assert start[0] == 1 and end[0] == 2, ed.selection
81
+ await pilot.press("y")
82
+ assert ed.mode == "normal"
83
+ assert ed._register == ("Y# Title\nXY# Title\n", True), ed._register
84
+
85
+ # V + p: paste the two yanked lines below
86
+ lines_before = ed.document.line_count
87
+ await pilot.press("p")
88
+ assert ed.document.line_count == lines_before + 2
89
+
90
+ # V + d removes exactly the selected lines
91
+ await pilot.press("g", "g", "V", "j", "d")
92
+ assert ed.mode == "normal"
93
+ assert ed.document.line_count == lines_before
94
+ assert ed._register[1] is True # linewise register
95
+
96
+ # V then k extends upward; escape collapses back to normal
97
+ await pilot.press("j", "j", "V", "k")
98
+ start, end = sorted([ed.selection.start, ed.selection.end])
99
+ assert end[0] - start[0] == 1, ed.selection
100
+ await pilot.press("escape")
101
+ assert ed.mode == "normal"
102
+ assert ed.selection.start == ed.selection.end
103
+
104
+ # v <-> V switching keeps a sensible selection
105
+ await pilot.press("V", "v")
106
+ assert ed.mode == "visual"
107
+ await pilot.press("V")
108
+ assert ed.mode == "visual_line"
109
+ await pilot.press("escape")
110
+
111
+ # :w writes the file and clears the modified flag
112
+ await pilot.press("colon", "w", "enter")
113
+ await pilot.pause()
114
+ assert tmp.read_text(encoding="utf-8") == ed.text
115
+ assert not app.modified
116
+
117
+ # :q with changes refuses; :q! quits
118
+ await pilot.press("i", "z", "escape")
119
+ await pilot.press("colon", "q", "enter")
120
+ await pilot.pause()
121
+ assert "E37" in app._notice, app._notice
122
+
123
+ # Preview eventually picks up the edit
124
+ await pilot.pause(0.5)
125
+
126
+ await pilot.press("colon", "q", "exclamation_mark", "enter")
127
+ await pilot.pause()
128
+
129
+ print("smoke test passed")
130
+
131
+
132
+ if __name__ == "__main__":
133
+ asyncio.run(run())