1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
//! `Action` is the single vocabulary of the editor. SPEC.md §7.2.
//!
//! Config strings map to these names. Everything that mutates the buffer is an
//! `Action`, dispatched from one place — which is what makes undo grouping,
//! dirty tracking, and (post-v1) macro recording tractable.
use crate::text::motion::Motion;
/// What an operator applies to. An object and a motion are two ways of naming
/// a range, so they reach the same `operate_*` helpers.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Target {
Motion(Motion),
/// `iw`, `a(` — the object's key and whether it takes its delimiters.
Object { key: char, around: bool },
/// The doubled form: `dd`, `yy`, `>>`.
Line,
/// Visual mode — whatever is selected.
Selection,
}
/// Where a panel starts looking. Deliberately NOT a project root: Shoin edits
/// prose, and a directory of notes is not a checkout — `.git` has no more to
/// say about where a text begins than any other dotfile.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Root {
/// The edited file's own directory — the near view.
File,
/// `$HOME` — everything you might have written.
Home,
/// The folder Shoin was launched from — the whole vault, when the note
/// being edited sits in one of its subfolders.
Cwd,
}
impl Root {
/// `[tree] root`: what `file_tree` / `find_file` open on. Only the near
/// views are offered — `$HOME` already has its own keys. `None` for a
/// spelling this does not know, so the config check can name it.
pub fn parse(s: &str) -> Option<Root> {
Some(match s.trim().to_ascii_lowercase().as_str() {
"file" => Root::File,
"cwd" => Root::Cwd,
_ => return None,
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Action {
// --- movement ---
Move(Motion),
/// `H`/`M`/`L` and friends resolve against the viewport, which only the app
/// can see; the grammar emits the motion and `app` finishes it.
SelectObject { key: char, around: bool },
SwapSelectionEnds,
// --- mode changes ---
Insert,
Append,
InsertLineStart,
AppendLineEnd,
OpenBelow,
OpenAbove,
Visual,
VisualLine,
NormalMode,
Command,
SearchForward,
SearchBackward,
// --- operators (applied to a motion, an object, a line, or a selection) ---
Operator { op: Operator, target: Target },
// --- direct edits ---
DeleteChar,
DeleteCharBack,
/// `D` / `C` — to the end of the line.
DeleteToEol,
ChangeToEol,
/// `Y` — the whole line, without waiting for a motion.
YankLine,
/// `n` / `N`.
SearchNext { reverse: bool },
/// `;` / `,`.
RepeatFind { reverse: bool },
/// `*`.
SearchWordUnderCursor,
/// `Ctrl-e` / `Ctrl-y` — scroll the page a row, leaving the cursor where
/// it is unless it would go off screen.
ScrollView { down: bool },
/// `gp` — a new paragraph below, in Insert.
AppendParagraph,
/// `<C-w>` window commands, by their second key.
Window(char),
/// `<leader>sv`/`<leader>ss` — split the focused pane, or close it if the
/// window is already split. Toggle symmetry: every panel is bound to a key
/// that both opens and closes it, which is how this editor reconciles
/// having chrome at all with the no-interface ethos.
ToggleSplit { vertical: bool },
ClosePane,
OnlyPane,
PasteAfter,
PasteBefore,
ReplaceChar(char),
JoinLines,
ToggleCase,
/// `Alt-j` / `Alt-k` — move the list item under the cursor, children and
/// all, past its next / previous sibling (past `count` of them).
ListItemMove { down: bool },
/// `move_lines_down` / `move_lines_up` — the lines of the Visual selection
/// (the cursor's line outside Visual) past the next / previous `count`
/// lines, still selected. Plain lines: no list knowledge, no renumbering.
MoveLines { down: bool },
/// Insert-mode `Tab` / `Shift-Tab` on a list item: `>al` / `<al` without
/// leaving Insert.
ListShift { outdent: bool },
Undo,
Redo,
Repeat,
// --- writer verbs (SPEC.md §7.3) ---
ToggleBold,
ToggleItalic,
ToggleHighlight,
ToggleCode,
InsertLink,
ToggleTask,
AlignTable,
SetHeading(u8),
ClearHeading,
// --- app ---
Save,
SaveStayInsert,
Quit { force: bool },
WriteQuit,
CycleFocus,
ToggleTypewriter,
ToggleZen,
/// Read-only mode on or off, as `:view` does. Not an edit, so it works
/// from inside read-only mode — that is the point of it.
ToggleView,
/// The line-number gutter, off ↔ whichever mode (`relative`/`absolute`)
/// was last chosen. `:number [mode]` reaches the modes directly.
ToggleNumbers,
/// Flip `layout.conceal` — live preview vs. every line raw.
ToggleConceal,
/// `:align` with no argument, reachable from a key: left → center → right
/// → justified → left. No single value is "off", so cycling is the only
/// way a leader binding reaches every value.
CycleAlign,
/// Toggle the file-tree pane, rooted at the edited file's own folder or at
/// `$HOME`. Inside the tree, `-` and `+` move the root from there.
FileTree { root: Root },
/// Toggle the fuzzy file finder overlay, rooted at the edited file's own
/// directory or at `$HOME`.
FindFile { root: Root },
/// The same overlay over the open buffers.
FindBuffer,
/// `<C-^>` — back to the document this pane was showing before. Vim's
/// alternate file, and the way back out of a followed link.
AlternateBuffer,
/// Open the shortcuts/keybindings reference overlay (`:help shortcuts`,
/// one key away). `:help [topic]` reaches the same overlay for any topic;
/// q / Esc closes it, same as it closes `:help`.
Help,
/// `gf` — open what the cursor is on: a `[[note]]`, an unexpanded
/// `![[embed]]`, or the path in a `[text](path)`. A note that does not
/// exist yet is CREATED — in a vault, the link is written before the note.
FollowLink,
/// `gx` — hand what the cursor is on to the operating system. Kept apart
/// from `FollowLink` on purpose: one key that sometimes opens a buffer and
/// sometimes launches a browser is a key you hesitate before pressing.
OpenExternal,
/// Recognized key, deliberately does nothing.
Nop,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Operator {
Delete,
Change,
Yank,
Indent,
Outdent,
Lowercase,
Uppercase,
}
impl Action {
/// Whether this changes the text, or starts Insert to change it. What
/// read-only mode refuses; everything else (moving, searching, yanking,
/// panels, saving) still works.
pub fn edits(&self) -> bool {
match self {
Action::Operator { op, .. } => *op != Operator::Yank,
Action::Insert
| Action::Append
| Action::InsertLineStart
| Action::AppendLineEnd
| Action::OpenBelow
| Action::OpenAbove
| Action::AppendParagraph
| Action::DeleteChar
| Action::DeleteCharBack
| Action::DeleteToEol
| Action::ChangeToEol
| Action::PasteAfter
| Action::PasteBefore
| Action::ReplaceChar(_)
| Action::JoinLines
| Action::ToggleCase
| Action::ListItemMove { .. }
| Action::MoveLines { .. }
| Action::ListShift { .. }
| Action::Undo
| Action::Redo
| Action::Repeat
| Action::ToggleBold
| Action::ToggleItalic
| Action::ToggleHighlight
| Action::ToggleCode
| Action::InsertLink
| Action::ToggleTask
| Action::AlignTable
| Action::SetHeading(_)
| Action::ClearHeading => true,
_ => false,
}
}
/// Parse the action name used in `shoin.conf` `[keys.*]` tables.
pub fn from_config_name(name: &str) -> Option<Action> {
Some(match name {
"" | "nop" | "none" => Action::Nop,
"save" | "write" => Action::Save,
"save_stay_insert" => Action::SaveStayInsert,
"save_quit" | "write_quit" | "wq" => Action::WriteQuit,
"quit" => Action::Quit { force: false },
"quit!" | "force_quit" => Action::Quit { force: true },
"insert" => Action::Insert,
"append" => Action::Append,
"insert_line_start" => Action::InsertLineStart,
"append_line_end" => Action::AppendLineEnd,
"open_below" => Action::OpenBelow,
"open_above" => Action::OpenAbove,
"normal_mode" | "escape" => Action::NormalMode,
"visual" => Action::Visual,
"visual_line" => Action::VisualLine,
"command" => Action::Command,
"search_forward" | "search" => Action::SearchForward,
"search_backward" => Action::SearchBackward,
"undo" => Action::Undo,
"redo" => Action::Redo,
"repeat" => Action::Repeat,
"paste_after" | "paste" => Action::PasteAfter,
"paste_before" => Action::PasteBefore,
"delete_char" => Action::DeleteChar,
"join_lines" => Action::JoinLines,
"scroll_view_down" | "scroll_down" => Action::ScrollView { down: true },
"scroll_view_up" | "scroll_up" => Action::ScrollView { down: false },
"list_item_down" => Action::ListItemMove { down: true },
"list_item_up" => Action::ListItemMove { down: false },
"move_lines_down" | "move_line_down" => Action::MoveLines { down: true },
"move_lines_up" | "move_line_up" => Action::MoveLines { down: false },
"list_indent" => Action::ListShift { outdent: false },
"list_outdent" => Action::ListShift { outdent: true },
"toggle_bold" | "bold" => Action::ToggleBold,
"toggle_italic" | "italic" => Action::ToggleItalic,
"toggle_highlight" | "highlight" => Action::ToggleHighlight,
"toggle_code" | "code" => Action::ToggleCode,
"insert_link" | "link" => Action::InsertLink,
"toggle_task" | "task" => Action::ToggleTask,
"align_table" | "table" => Action::AlignTable,
"clear_heading" => Action::ClearHeading,
"cycle_focus" | "focus" => Action::CycleFocus,
"toggle_typewriter" | "typewriter" => Action::ToggleTypewriter,
"toggle_conceal" | "conceal" => Action::ToggleConceal,
"toggle_zen" | "zen" => Action::ToggleZen,
"toggle_view" | "view" | "read_only" => Action::ToggleView,
"toggle_numbers" | "numbers" | "number" => Action::ToggleNumbers,
"cycle_align" | "align" => Action::CycleAlign,
"help" | "shortcuts" => Action::Help,
"file_tree" | "file_explorer" | "tree" => Action::FileTree { root: Root::File },
"file_tree_home" | "file_explorer_home" => Action::FileTree { root: Root::Home },
"find_file" | "fuzzy_find" | "files" => Action::FindFile { root: Root::File },
"find_file_home" | "fuzzy_find_home" => Action::FindFile { root: Root::Home },
"file_tree_cwd" | "file_explorer_cwd" => Action::FileTree { root: Root::Cwd },
"find_file_cwd" | "fuzzy_find_cwd" => Action::FindFile { root: Root::Cwd },
"alternate_buffer" | "alternate" | "alt_buffer" => Action::AlternateBuffer,
"follow_link" | "follow" | "goto_file" => Action::FollowLink,
"open_external" | "open_url" | "xdg_open" => Action::OpenExternal,
"find_buffer" | "buffers" | "switch_buffer" => Action::FindBuffer,
"split_vertical" | "vsplit" => Action::ToggleSplit { vertical: true },
"split_horizontal" | "split" => Action::ToggleSplit { vertical: false },
"close_pane" => Action::ClosePane,
"only_pane" | "only" => Action::OnlyPane,
_ => {
if let Some(n) = name.strip_prefix("heading_") {
return n.parse::<u8>().ok().filter(|n| (1..=6).contains(n)).map(Action::SetHeading);
}
return None;
}
})
}
}