pub struct Help {
pub title: String,
pub lines: Vec<String>,
pub scroll: usize,
}
impl Help {
pub fn open(topic: &str) -> Help {
let key = topic.trim().to_ascii_lowercase();
let (title, lines): (&str, Vec<&str>) = match key.as_str() {
"" | "overview" | "help" => ("help", OVERVIEW.to_vec()),
"shortcuts" | "reference" | "cheatsheet" | "cheat" => ("shortcuts", SHORTCUTS.to_vec()),
"bindings" | "keys" | "keymap" | "keymaps" | "motions" => ("bindings", BINDINGS.to_vec()),
"commands" | "command" | "ex" => ("commands", COMMANDS.to_vec()),
"writer" | "verbs" | "markdown" | "format" => ("writer verbs", WRITER.to_vec()),
"config" | "configuration" | "settings" | "theme" => ("config", CONFIG.to_vec()),
_ => ("help", unknown(topic)),
};
Help {
title: title.to_string(),
lines: lines.into_iter().map(str::to_string).collect(),
scroll: 0,
}
}
}
fn unknown(topic: &str) -> Vec<&'static str> {
let msg: &'static str = Box::leak(format!("No help topic {topic:?}.").into_boxed_str());
let mut v = vec![msg, ""];
v.extend_from_slice(&[
"Available topics:",
" :help this overview",
" :help shortcuts every keyboard interaction, by category",
" :help bindings keys, motions, operators",
" :help commands the : (ex) commands",
" :help writer bold / italic / headings / tasks",
" :help config the config file and theme",
]);
v
}
const OVERVIEW: &[&str] = &[
"Shoin — a no-clutter terminal Markdown editor.",
"",
"The line the cursor is on shows raw Markdown; every other line hides",
"its markers and shows the result (live preview). Editing is modal,",
"with Vim-style keys.",
"",
"MODES",
" Normal move around and run operators. Esc always returns here.",
" Insert type text — enter with i a I A o O (Esc to leave).",
" Visual select text — v (chars), V (lines), or drag the mouse.",
" Dragging while inserting gives (insert) visual: the caret",
" takes the accent color, and finishing hands typing back.",
" Command the : line for ex commands.",
" Search / forward, ? backward; n / N repeat.",
"",
"GETTING AROUND THE BASICS",
" h j k l / arrows move i / a insert / append",
" w b e words o / O open line below/above",
" 0 ^ $ line x delete char",
" gg / G top / bottom dd / yy / p delete / yank / paste",
" u / Ctrl-r undo / redo :w :q save / close",
"",
"FILE TREE (left pane)",
" <leader>fe / fE open in this file's folder / all of $HOME",
" j k l/Enter h move · open file or expand dir · collapse",
" - / = move the ROOT up a level / into this directory",
" H show / hide dotfiles",
" q close the tree (<leader> is Space by default)",
" Ctrl-w h / l / w move between panes: tree / editor / cycle",
"",
"SPLITS (panes)",
" <leader>sv / ss split beside / below — again to close it",
" Ctrl-w v s split Ctrl-w q close this pane",
" Ctrl-w h j k l move between panes w cycle o only",
" Ctrl-w > < + - wider / narrower / taller / shorter (3<C-w>> x3)",
" Ctrl-w = equal shares again",
" :sp :vs :close :only",
"",
"BUFFERS (several files open at once)",
" <leader>fb switch buffer (the finder, over open buffers)",
" :ls list them :b <name|n> switch to one",
" :bn :bp next / previous :bd close this one",
" :e <path> open another file",
"",
"FIND FILES (fuzzy finder)",
" <leader>ff / fF search this file's directory / all of $HOME",
" type narrow the list; Ctrl-u clears the query",
" Ctrl-n / Ctrl-p next / previous match (arrows work too)",
" Enter Esc open the selected file · close the finder",
"",
"MORE HELP",
" <leader>hl every keyboard interaction, by category",
" :help bindings the full key list",
" :help commands every : command",
" :help writer Markdown formatting verbs (g...)",
" :help config config file location and settings",
"",
"Scroll with j/k or the arrows. Press q or Esc to close.",
];
const SHORTCUTS: &[&str] = &[
"SHORTCUTS — every keyboard interaction, by kind",
"",
"<leader> KEYS (leader is Space by default — [input].leader)",
" <leader>hl this page",
" <leader>w save <leader>q quit",
" <leader>F cycle focus mode <leader>t typewriter scroll",
" <leader>z toggle zen (all chrome) <leader>c toggle conceal",
" <leader>n toggle line numbers <leader>a cycle text align",
" <leader>fe file tree, this folder <leader>fE file tree, $HOME",
" <leader>ff find file, this folder <leader>fF find file, $HOME",
" <leader>fb switch buffer (finder)",
" <leader>sv split beside / close <leader>ss split below / close",
" <leader>so keep only this pane",
" <leader>b toggle **bold** (Visual mode only)",
" <leader>i toggle *italic* (Visual mode only)",
" These are built in (the shipped shoin/keys.conf) — remap any of them, add",
" more under [keys.normal] / [keys.visual], or set one to \"none\" to remove",
" it. See :help config.",
"",
": COMMANDS (type : then the command, Enter to run — :help commands for all)",
" :w :q :wq save / close / both :Q :qa quit all",
" :e <path> open a file :help [topic] this help",
" :sp :vs :close :only split / close panes",
" :ls :b <name> :bn :bp :bd buffers",
" :diff :revert :export [fmt] merge / reload / export",
" :set <key> [value] settings, e.g. measure, autosave, line_spacing",
" :embed [mode] how much of ![[...]] to expand in place",
" :number [mode] line-number gutter: off/relative/absolute",
" :align [mode] text align: left/center/right/justified (no arg cycles)",
" :u :redo undo / redo — the : spellings of u and Ctrl-r",
"",
"NAVIGATION (motions — take a count, e.g. 3w; also see :help bindings)",
" h j k l left / down / up / right (arrows work too)",
" w b e W B E word forward / back / end (W/B/E = whitespace-delimited)",
" 0 ^ $ line start / first non-blank / end",
" { } previous / next paragraph gg G top / bottom",
" f{c} t{c} find / till char (F/T backward); ; , repeat / reverse",
" H M L top / middle / bottom of the visible screen",
" Ctrl-d/u/f/b half page / full page down / up",
" gf gx follow a link · open it externally",
"",
"KEYBINDINGS (single/doubled keys — the Vim grammar, :help bindings for all)",
" i a I A o O insert / append / line start / line end / open below/above",
" d c y dd cc yy operators — delete / change / yank, doubled = whole line",
" x X D C Y delete char / before / to-eol / change-to-eol / yank-line",
" p P paste after / before u Ctrl-r undo / redo",
" v V Visual charwise / linewise . repeat last change",
" drag Visual charwise, from where the button went down",
" drag in Insert (insert) visual — an operator or Esc resumes typing",
" / ? n N search forward / back / repeat : command line",
" g1..g6 g0 set / clear heading level gb gi gh gk bold/italic/",
" highlight/code (writer)",
" Ctrl-w h j k l v s q o window: move / split / close / cycle pane",
"",
"Scroll with j/k or the arrows. Press q or Esc to close.",
];
const BINDINGS: &[&str] = &[
"KEY BINDINGS",
"",
"MOTIONS (also take a count, e.g. 3w)",
" h j k l left / down / up / right (arrows work too)",
" w W b B next / prev word (W/B = whitespace-delimited)",
" e E end of word",
" 0 ^ $ line start / first non-blank / line end",
" { } previous / next paragraph",
" gg G first / last line (G with a count: go to that line)",
" f{c} t{c} find / till char forward; F / T backward",
" ; , repeat / reverse the last f / t",
" * search the word under the cursor",
" H M L top / middle / bottom of the visible screen",
" Ctrl-d/u half page down / up Ctrl-f/b full page",
"",
"OPERATORS (operator + motion, or doubled for the whole line)",
" d delete c change y yank",
" dd cc yy whole line d$ dw dj ... over a motion",
" > < indent / outdent lines (>> << , >ip)",
" >al <al indent / outdent a list item with its children",
" (a numbered list it leaves or joins is renumbered)",
" >aL <aL ... every item at that level",
" (refused when it would break the list: a first item,",
" a top-level outdent, a top-level list 4 columns in)",
" gu gU lowercase / uppercase (guiw, gUU)",
" D C Y to end of line",
" x X delete char under / before the cursor",
" r{c} replace the char under the cursor",
" ~ J toggle case / join with the next line",
" p P paste after / before",
" 2d3w counts multiply — this deletes six words",
"",
"TEXT OBJECTS (after an operator, or in Visual: diw, ca(, vip)",
" iw aw inner / around word iW aW whitespace-delimited",
" is as sentence ip ap paragraph (linewise)",
" i\" i\' i` inside quotes a\" with the quotes",
" i( i[ i{ i< inside brackets a( ... with the brackets",
" (b = (), B = {}; brackets may span lines)",
" il al list item without / with its children (linewise)",
" aL every item at the same level, with children",
"",
"LISTS",
" Alt-j Alt-k move the item past the next / previous one, with its",
" sub-items; a count moves it past that many. 1. 2. 3.",
" lists are renumbered. Never leaves its list (<al does).",
" >al <al indent / outdent it il al aL the list objects",
" Tab S-Tab the same, in Insert on an item's line (list_tab)",
" In tmux, a quick Esc j can arrive as Alt-j: set",
" escape-time to 10 or less. On macOS the terminal must",
" send Option as Meta (Esc+).",
"",
"REGISTERS",
" \"a{cmd} use register a: \"ayy \"ap \"add",
" \"A{cmd} uppercase name APPENDS to that register",
" \"0 the last yank — a delete never overwrites it",
" \"1 - \"9 the delete ring, newest first",
" \"+ \"* the system clipboard: \"+yy \"+p",
" (:set clipboard sends plain y d p there too)",
" Over SSH a copy reaches YOUR clipboard by OSC 52 (the",
" terminal must support it; in tmux, set-clipboard on).",
" Paste from elsewhere with the terminal's own paste,",
" Ctrl+Shift+V / Cmd+V: \"+p there has only Shoin's copy.",
"",
"VISUAL",
" v V start charwise / linewise select",
" d c y x operate on the selection o swap ends",
" > < u U indent / outdent, lowercase / uppercase",
" i{obj} a{obj} select a text object (viw, vap)",
"",
"STARTING",
" shoin a blank page, with these hints on it until you type",
" ([splash] art draws your own picture, or none)",
" shoin notes.md open (or create on first :w) a file",
" shoin -R notes.md open it read-only (see READ-ONLY below)",
" shoin -p notes.md print it as the editor shows it, and exit;",
" --pager pages a long one through $PAGER",
" cat notes.md | shoin piped text, in an unnamed buffer: :w name.md",
" saves it, :q just quits (shoin - reads stdin too)",
" cat notes.md | shoin -p print piped text",
"",
"READ-ONLY (shoin -R, or :view to turn it on and off)",
" Every line concealed, no caret. Edits are refused with a message.",
" j k scroll a line Ctrl-d / Ctrl-u half a page",
" gg G top / bottom / ? n N search",
" q quit i switch to editing",
" Yanking, panels, gf and :w still work.",
"",
"FILE TREE (<leader>fe in this file's folder, <leader>fE over $HOME)",
" j k h l move / collapse / expand — Enter or o opens a file",
" g G R q top / bottom, refresh, close",
" - = re-root one level up / into the selected directory",
" H show / hide dotfiles (off by default, then remembered)",
" a new file in this directory (end with / for a folder)",
" r rename m move (a path, relative to the root)",
" d delete — asks first, and says how much a folder takes",
" <C-w> h j k l or the arrows: back to the editor and between panes",
"",
" A prompt takes Ctrl-u to clear it, Esc to abandon it. Renaming or",
" moving a file you have open carries the buffer with it.",
"",
"INSERT MODE",
" i a I A o O insert / append / line start / line end / open below,",
" above",
" jk leave Insert (input.escape_alias; \"\" turns it off)",
" Esc Ctrl-c leave Insert",
" Ctrl-s save, and keep typing (in Normal mode too, as :w)",
" Enter continues a list marker or quote prefix; on an empty",
" item it ends the list (editor.auto_indent)",
"",
"OTHER",
" u Ctrl-r undo / redo . repeat last change",
" / ? n N search / repeat",
" : command line",
"",
"Bindings are configurable under [keys.*]; see :help config.",
];
const COMMANDS: &[&str] = &[
"COMMANDS (type : then the command, Enter to run)",
"",
" :w :write [path] save (optionally to a path)",
" :w! save over a file that changed on disk, or",
" over an existing file :w <path> would not touch",
" :set autosave [on|off] write modified NAMED buffers on a timer — off",
" by default, and it keeps the guard above",
" :set autosave_interval=3 minutes between autosaves, 1-5",
" :diff merge against the file on disk. n/p move between",
" differences, Tab cycles this one (live | file |",
" both), l/f/b set them all, w commits, q aborts.",
" Nothing is written until w; then it is one undo.",
" :revert :revert! re-read the file from disk (! discards unsaved",
" changes). The way to say \"take theirs\".",
" :set autoreload [on|off] take a file's changes back automatically when",
" something else edits it. On by default, and only",
" ever on a buffer with nothing unsaved.",
" :set clipboard [on|off] plain y, d and p use the system clipboard too,",
" as vim's clipboard=unnamedplus. Off by default;",
" \"+ and \"* reach it either way. (:set cb)",
" :q close this buffer; the LAST one quits the editor",
" :q! the same, discarding changes",
" :Q :qa quit outright — every buffer, in one step",
" :Q! :qa! quit outright, discarding changes",
" :wq :x save and quit :wq! even if it changed",
" :e! / :reload re-read the config file",
" :e <path> open a file in a new buffer",
" :u :un :und :undo take back the last change — the same as u",
" :red :redo put it back again — the same as Ctrl-r",
"",
"BUFFERS",
" :ls :buffers list open buffers (▸ marks the current one)",
" :b <name> :b <n> switch by name fragment or number",
" :bn :bp next / previous buffer (the list is a ring)",
" <C-^> <C-6> back to the previous buffer — the way back out of",
" a gf; press it twice to land where you started",
" :bd :bd! close this buffer (! discards changes)",
"",
"PANES",
" :sp :vs split horizontally / vertically",
" :close close the focused pane",
" :only close every pane but this one",
"",
"TRANSCLUSION (![[note]] embeds another file; [[note]] is just a link)",
" :embed [mode] how much of an ![[...]] to expand in place:",
" none leave the link as written (default)",
" short the target's own text, in a box",
" rec …and whatever IT embeds, recursively",
" full rec with no box — reads as one document",
" a bare :embed toggles off and back to your last mode",
" :export [md|txt| write the finished document out — opens a dialog",
" html|pdf] html is one self-contained page; pdf needs pandoc",
" [justify] with a suggested name you can change (default md)",
" pdf needs pandoc + a layout engine (typst is the",
" smallest); the error names whichever is missing",
" justify stretches html/txt to fill the measure —",
" md and pdf have no layout of their own to stretch",
"",
" ![[note]] the whole file ![[note#Heading]] one section",
" ![[note#^blockid]] one block",
"",
"FOLLOWING LINKS",
" gf open what the cursor is on — [[note]], ![[note]],",
" or the path in [text](note.md). A #Heading or",
" #^blockid lands on it rather than at the top.",
" A note that does not exist yet is CREATED empty,",
" so a link can be written before its note is.",
" A name several notes answer to opens the finder.",
" gx hand it to the desktop instead — a URL, a picture,",
" a PDF. Never creates anything.",
"",
"IMAGES (![[photo.png]] — png, jpg, gif, webp, bmp)",
" An image embed reserves a box the shape of the picture, captioned with",
" its name and pixel size. In a terminal that can draw pixels (kitty,",
" Ghostty, WezTerm, iTerm2) the picture itself is drawn there; everywhere",
" else the box and caption ARE the picture. :export html carries it along",
" inside the page, so the file stays self-contained.",
" SHOIN_IMAGE_PROTOCOL=kitty|iterm|none overrides the detection.",
"",
" An embed on the cursor's line shows its raw source, like **bold** does.",
" Embedded text belongs to another file, so it cannot be edited here.",
"",
"VIEW",
" :focus [mode] off | paragraph | sentence (no arg = cycle)",
" :number [mode] line-number gutter: relative | absolute — a bare",
" :number toggles off and back to your last mode",
" :align [mode] left | center | right | justified — how each line",
" sits WITHIN the column; no arg cycles through all",
" four. The column's own placement on screen is the",
" separate layout.align setting (center | left).",
" :typewriter :tw toggle typewriter scrolling",
" :zen toggle all chrome at once",
" :view read-only: all concealed, q quits, i edits",
" :set <k> [on|off] conceal/typewriter/mouse/focus/numbers/code_syntax",
" :set measure=72 text width in columns (:set measure to read it)",
" :set line_spacing=1 blank rows between lines, 0-4",
"",
"HELP",
" :help [topic] this help — topics: shortcuts, bindings,",
" commands, writer, config",
];
const WRITER: &[&str] = &[
"WRITER VERBS (the g prefix — act on the word, or the selection)",
"",
" gb toggle **bold** around the word or selection",
" gi toggle *italic*",
" gh toggle ==highlight==",
" gk toggle `code`",
"",
" gt toggle a task checkbox - [ ] <-> - [x]",
" (on a plain list item, adds an empty checkbox)",
"",
" gl wrap as a [link](url) cursor lands in the URL slot",
" gf follow the link under the cursor (:help commands)",
" gx open it with the desktop instead",
"",
" g1 .. g6 set the heading level of the current line",
" g0 strip the heading",
" gp start the next paragraph (jump down + Insert)",
"",
"In Normal mode these act on the word under the cursor; in Visual",
"mode they wrap the selection. Each is a single undo step.",
];
const CONFIG: &[&str] = &[
"CONFIGURATION",
"",
"Config lives in a directory whose *.conf files are merged (conf.d style),",
"so you can split it by category: theme.conf, keys.conf, layout.conf, ...",
"",
"Searched in order (first that exists wins; file or directory):",
" 1. --config <path>",
" 2. $SHOIN_CONFIG",
" 3. ./shoin.conf (single file)",
" 4. ./shoin/ (directory of *.conf)",
" 5. $XDG_CONFIG_HOME/shoin/",
" 6. ~/.config/shoin/ (the usual home)",
"",
"Any missing key falls back to its default. Edits hot-reload live; a parse",
"error keeps the running config. See it all with: shoin --print-config",
"",
"COMMON SETTINGS",
" [layout] measure (the text width), focus, numbers, typewriter, conceal,",
" hanging_indent, line_spacing, scroll_hint",
" [theme] background, text, heading_1..6, ... (default: Tokyo Night)",
" [input] leader, mouse, escape_alias",
" [editor] auto_indent, auto_pair, scroll_off, undo_coalesce_ms,",
" trim_on_save, final_newline, autosave, autosave_interval,",
" autoreload, clipboard, clipboard_background (run copies on a",
" background thread, if yanks feel slow with clipboard on),",
" list_renumber (keep 1. 2. 3. in sequence after >al <al),",
" list_tab (Tab / Shift-Tab in Insert indent a list item)",
" [tree] show_hidden — dotfiles in the file tree (H toggles it live)",
" [transclude] embed, root, max_depth, heading_offset, border",
" [status] enabled, flash_ms, show",
" [cursor] normal, insert, visual, blink",
" [markdown] tags, wiki_links, highlight, tables, code_syntax",
" [glyphs] nerd_fonts, bullet, quote_bar, indent_guide, conflict, ...",
" [splash] art — \"\" built-in, \"none\" off, or a path to your own",
" [keys.normal] / [keys.insert] / [keys.visual]",
" e.g. \"<leader>w\" = \"save\" (leader defaults to space)",
"",
"No config is needed: the shipped keys.conf is built in, and your [keys.*]",
"merge over it. A binding you write replaces that key's; the rest stay.",
" \"<leader>z\" = \"none\" removes a shipped binding",
" \"<C-j>\" = \"list_item_down\" Ctrl-j as well as Alt-j (list_item_up: Alt-k)",
"",
"Bindings name any VERB, not just a discrete action, so the Vim grammar",
"itself is remappable and keeps working:",
" \"s\" = \"operator_delete\" then ss siw 2s3w all behave",
" \"n\" = \"move_down\" motions, objects and counts included",
"",
"A malformed [keys.*] binding is skipped (with a warning), never fatal.",
"An exact match fires as soon as it is complete, so a binding that is also",
"the start of a longer one shadows it — that is warned about too.",
"[keys.insert] takes SINGLE keys only.",
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_topics_and_aliases() {
assert_eq!(Help::open("").title, "help");
assert_eq!(Help::open("shortcuts").title, "shortcuts");
assert_eq!(Help::open("cheatsheet").title, "shortcuts"); assert_eq!(Help::open("bindings").title, "bindings");
assert_eq!(Help::open("keys").title, "bindings"); assert_eq!(Help::open("commands").title, "commands");
assert_eq!(Help::open("writer").title, "writer verbs");
assert_eq!(Help::open("config").title, "config");
assert!(!Help::open("bindings").lines.is_empty());
assert!(!Help::open("shortcuts").lines.is_empty());
}
#[test]
fn shortcuts_page_covers_every_kind() {
let h = Help::open("shortcuts");
assert!(h.lines.iter().any(|l| l.contains("<leader> KEYS")));
assert!(h.lines.iter().any(|l| l.contains(": COMMANDS")));
assert!(h.lines.iter().any(|l| l.contains("NAVIGATION")));
assert!(h.lines.iter().any(|l| l.contains("KEYBINDINGS")));
assert!(h.lines.iter().any(|l| l.contains("<leader>hl")));
}
#[test]
fn unknown_topic_lists_the_real_ones() {
let h = Help::open("wat");
assert_eq!(h.title, "help");
assert!(h.lines.iter().any(|l| l.contains("No help topic")));
assert!(h.lines.iter().any(|l| l.contains(":help bindings")));
}
}