easymd-cli 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.
- easymd/__init__.py +3 -0
- easymd/__main__.py +3 -0
- easymd/app.py +214 -0
- easymd/cli.py +29 -0
- easymd/editor.py +459 -0
- easymd_cli-0.1.0.dist-info/METADATA +92 -0
- easymd_cli-0.1.0.dist-info/RECORD +10 -0
- easymd_cli-0.1.0.dist-info/WHEEL +4 -0
- easymd_cli-0.1.0.dist-info/entry_points.txt +2 -0
- easymd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
easymd/__init__.py
ADDED
easymd/__main__.py
ADDED
easymd/app.py
ADDED
|
@@ -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()
|
easymd/cli.py
ADDED
|
@@ -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()
|
easymd/editor.py
ADDED
|
@@ -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,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,10 @@
|
|
|
1
|
+
easymd/__init__.py,sha256=M-lIcUS3_gvP8pFeTFJqDyCtxqD2czgxNbGGlLR5S2c,94
|
|
2
|
+
easymd/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
|
|
3
|
+
easymd/app.py,sha256=LqAe095WsCJ-4TIqyhAquKnEM_JAKh3SvKJteHap0W4,6879
|
|
4
|
+
easymd/cli.py,sha256=_XcLSVtI3_95KnrxCQrsBq5lwHxnpU5Zf-VbFNnLo3k,684
|
|
5
|
+
easymd/editor.py,sha256=bmd7W1_dVybrf9AU8GOVOUpBF6vI6d8gamhOpKlQiYs,16748
|
|
6
|
+
easymd_cli-0.1.0.dist-info/METADATA,sha256=ywMMXfFnmv5tWvCRlDmIDFM5fP0xnsOJBguOjcBrn58,2711
|
|
7
|
+
easymd_cli-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
8
|
+
easymd_cli-0.1.0.dist-info/entry_points.txt,sha256=HVFUFbOQ70fuvlp906xp8EXlm-2r8mcWE4gfKJfvflY,43
|
|
9
|
+
easymd_cli-0.1.0.dist-info/licenses/LICENSE,sha256=nbubAbxjU-xUFD9lUEmCoP4v5y0MBPsuLYmMaubm120,1077
|
|
10
|
+
easymd_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|