about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--bonus/w3m.toml181
-rw-r--r--res/chawan.html4
-rw-r--r--res/config.toml555
-rw-r--r--src/config/config.nim94
-rw-r--r--src/config/toml.nim3
-rw-r--r--src/local/client.nim18
-rw-r--r--src/main.nim12
7 files changed, 554 insertions, 313 deletions
diff --git a/bonus/w3m.toml b/bonus/w3m.toml
index 3ed27cf9..a8ab54e0 100644
--- a/bonus/w3m.toml
+++ b/bonus/w3m.toml
@@ -3,73 +3,10 @@
 # ~/.config/chawan/w3m.toml, then at the beginning of
 # ~/.config/chawan/chawan.toml, include = "w3m.toml".)
 
-[page]
-# Page/cursor movement
-' ' = 'n => pager.scrollDown(pager.height * (n ?? 1))'
-C-v = 'n => pager.scrollDown(pager.height * (n ?? 1))'
-b = 'n => pager.scrollUp(pager.height * (n ?? 1))'
-M-v = 'n => pager.scrollUp(pager.height * (n ?? 1))'
-'M-[6~' = 'n => pager.scrollDown(pager.height * (n ?? 1))'
-'M-[5~' = 'n => pager.scrollUp(pager.height * (n ?? 1))'
-l = 'n => pager.cursorRight(n)'
-h = 'n => pager.cursorLeft(n)'
-j = 'n => pager.cursorDown(n)'
-k = 'n => pager.cursorUp(n)'
-C-f = 'n => pager.cursorRight(n)'
-C-b = 'n => pager.cursorLeft(n)'
-C-n = 'n => pager.cursorDown(n)'
-C-p = 'n => pager.cursorUp(n)'
-J = 'n => pager.scrollUp(n)'
-K = 'n => pager.scrollDown(n)'
-'^' = 'pager.cursorLineBegin()'
-C-a = 'pager.cursorLineBegin()'
-'$' = 'pager.cursorLineEnd()'
-C-e = 'pager.cursorLineEnd()'
-w = 'pager.cursorNextWord()'
-W = 'pager.cursorWordBegin()'
-'<' = 'n => pager.pageLeft(n)'
-'>' = 'n => pager.pageRight(n)'
-'.' = 'n => pager.scrollLeft(n)'
-',' = 'n => pager.scrollRight(n)'
-g = 'n => n ? pager.gotoLine(n) : pager.cursorFirstLine()'
-'M-<' = 'pager.cursorFirstLine()'
-G = 'n => n ? pager.gotoLine(n) : pager.cursorLastLine()'
-'M->' = 'pager.cursorLastLine()'
-M-g = 'n => pager.gotoLine(n)'
-Z = 'pager.centerColumn()'
-z = 'pager.centerLine()'
-C-i = 'n => pager.cursorNextLink(n)'
-C-u = 'n => pager.cursorPrevLink(n)'
-M-C-i = 'n => pager.cursorPrevLink(n)'
-'[' = 'n => pager.cursorNthLink(n)'
-']' = 'n => pager.cursorRevNthLink(n)'
-# Hyperlink selection
-C-j = 'pager.click()'
-C-m = 'pager.click()'
-c = 'pager.peek()'
-u = 'pager.peekCursor()'
-#TODO download, etc
-# File and URL-related actions
-U = 'pager.load()'
-V = 'pager.load()' #TODO file only
-#TODO exec shell
-# Buffer operations
-B = 'pager.discardBuffer()'
-v = 'pager.toggleSource()'
-#TODO edit
-C-l = 'pager.redraw()'
-R = 'pager.reload()'
-#TODO save, save source
-E = '''
-() => {
-	if (pager.url.protocol == "file:")
-		pager.extern(pager.getEditorCommand(pager.url.pathname))
-	else
-		pager.alert("Can't edit other than local file");
-}
-'''
-#TODO buffer selection mode
-'C-@' = '''
+[cmd.w3m.buffer]
+pageDown = 'n => pager.scrollDown(pager.height * (n ?? 1))'
+pageUp = 'n => pager.scrollUp(pager.height * (n ?? 1))'
+mark = '''
 () => {
 	/* id is always the current position; this way we can clear by
 	   setting a mark twice at the same position. */
@@ -80,7 +17,10 @@ E = '''
 		pager.clearMark(id);
 }
 '''
-M-p = '''
+gotoLine = 'n => pager.gotoLine(n)'
+centerColumn = '() => pager.centerColumn()'
+centerLine = '() => pager.centerLine()'
+prevMark = '''
 () => {
 	const next = pager.findPrevMark();
 	if (next)
@@ -89,30 +29,105 @@ M-p = '''
 		pager.alert("No mark exists before here");
 }
 '''
-M-n = '''
+nextMark = '''
 () => {
 	const next = pager.findNextMark();
 	if (next)
 		pager.gotoMark(next);
 	else
-		pager.alert("No mark exists after here");
+		pager.alert("No mark exists before here");
 }
 '''
-# Search
-'/' = 'pager.searchForward()'
-C-s = 'pager.searchForward()'
-'?' = 'pager.searchBackward()'
-C-r = 'pager.searchBackward()'
-n = 'pager.searchNext()'
-N = 'pager.searchPrev()'
-C-w = '''
-config.search.wrap = !config.search.wrap;
-pager.alert("Wrap search " + (config.search.wrap ? "on" : "off"));
+
+[cmd.w3m.pager]
+saveFile = '''
+() => {
+	if (pager.url.protocol == "file:")
+		pager.extern(pager.getEditorCommand(pager.url.pathname))
+	else
+		pager.alert("Can't edit other than local file");
+}
+'''
+askQuit = '''
+() => pager.ask("Do you want to exit Chawan?").then(x => x ? quit() : void(0))
 '''
+
+[page]
+# Page/cursor movement
+' ' = 'cmd.w3m.buffer.pageDown'
+C-v = 'cmd.w3m.buffer.pageDown'
+b = 'cmd.w3m.buffer.pageUp'
+M-v = 'cmd.w3m.buffer.pageUp'
+'M-[6~' = 'cmd.w3m.buffer.pageDown'
+'M-[5~' = 'cmd.w3m.buffer.pageUp'
+C-f = 'cmd.buffer.cursorRight'
+C-b = 'cmd.buffer.cursorLeft'
+C-n = 'cmd.buffer.cursorDown'
+C-p = 'cmd.buffer.cursorUp'
+J = 'cmd.buffer.scrollUp'
+K = 'cmd.buffer.scrollDown'
+'^' = 'cmd.buffer.cursorLineBegin'
+C-a = 'cmd.buffer.cursorLineBegin'
+'$' = 'cmd.buffer.cursorLineEnd'
+C-e = 'cmd.buffer.cursorLineEnd'
+w = 'cmd.buffer.cursorNextWord'
+W = 'cmd.buffer.cursorWordBegin'
+'<' = 'cmd.buffer.pageLeft'
+'>' = 'cmd.buffer.pageRight'
+'.' = 'cmd.buffer.scrollLeft'
+',' = 'cmd.buffer.scrollRight'
+g = 'cmd.buffer.gotoLineOrStart'
+'M-<' = 'cmd.buffer.cursorFirstLine'
+G = 'cmd.buffer.gotoLineOrEnd'
+'M->' = 'cmd.buffer.cursorLastLine'
+M-g = 'cmd.w3m.buffer.gotoLine'
+Z = 'cmd.w3m.buffer.centerColumn'
+z = 'cmd.w3m.buffer.centerLine'
+C-i = 'cmd.buffer.cursorNextLink'
+C-u = 'cmd.buffer.cursorPrevLink'
+M-C-i = 'cmd.buffer.cursorPrevLink'
+'[' = 'cmd.buffer.cursorNthLink'
+']' = 'cmd.buffer.cursorRevNthLink'
+# Hyperlink selection
+C-j = 'cmd.buffer.click'
+C-m = 'cmd.buffer.click'
+c = 'cmd.pager.peek'
+u = 'cmd.pager.peekCursor'
+a = 'cmd.pager.saveLink'
+M-C-j = 'cmd.buffer.saveLink'
+M-C-m = 'cmd.buffer.saveLink'
+I = 'cmd.buffer.viewImage'
+#TODO save image
+# File and URL-related actions
+U = 'cmd.pager.load'
+V = 'cmd.pager.load' #TODO file only
+#TODO exec shell
+# Buffer operations
+B = 'cmd.pager.discardBuffer'
+v = 'cmd.pager.toggleSource'
+#TODO edit
+C-l = 'cmd.buffer.redraw'
+R = 'cmd.pager.reload'
+E = 'cmd.pager.editFile'
+M-s = 'cmd.pager.saveSource'
+#TODO save screen, edit screen
+#TODO buffer selection mode
+'C-@' = 'cmd.w3m.buffer.mark'
+M-p = 'cmd.w3m.buffer.prevMark'
+M-n = 'cmd.w3m.buffer.nextMark'
+# Search
+'/' = 'cmd.pager.searchForward'
+C-s = 'cmd.pager.searchForward'
+'?' = 'cmd.pager.searchBackward'
+C-r = 'cmd.pager.searchBackward'
+n = 'cmd.pager.searchNext'
+N = 'cmd.pager.searchPrev'
+C-w = 'cmd.pager.toggleWrap'
 # Misc
 #TODO shell out, help file, options, cookies
-C-c = 'pager.cancel()'
-q = 'pager.ask("Do you want to exit Chawan?").then(x => x ? quit() : void(0))'
-Q = 'quit()'
+C-c = 'cmd.pager.cancel'
+q = 'cmd.w3m.pager.askQuit'
+Q = 'cmd.pager.quit'
+C-d = ''
 
 # w3m line editing is equivalent to Chawan's defaults.
diff --git a/res/chawan.html b/res/chawan.html
index 169c6800..6ce6950b 100644
--- a/res/chawan.html
+++ b/res/chawan.html
@@ -67,7 +67,7 @@ up/down by one row
 <li><kbd>C-d</kbd>, <kbd>C-u</kbd>: scroll up/down by half a page
 <li><kbd>C-f</kbd>, <kbd>C-b</kbd> (or <kbd>PgDn</kbd>, <kbd>PgUp</kbd>)</kbd>:
 scroll up/down by an entire page
-<li><kbd>{number}G<kbd> (or <kbd>{number}gg</kbd>): jump to {number}'th line
+<li><kbd>{number}G</kbd> (or <kbd>{number}gg</kbd>): jump to {number}'th line
 <li><kbd>g0<kbd>: jump to first character of the current line's visible part
 <li><kbd>gc<kbd>: jump to center of the current line's visible part
 <li><kbd>g$<kbd>: jump to last character of the current line's visible part
@@ -79,7 +79,7 @@ screen to the left/right by one cell
 <li><kbd>/</kbd>, <kbd>?</kbd>: on-page search (or search backwards)
 <li><kbd>n</kbd>, <kbd>N</kbd>: next/previous match
 <li><kbd>C-z</kbd>: suspend the browser
-<li><kbd>M-C-c</kbd>: cancel loading
+<li><kbd>C-c</kbd>: cancel loading
 <li><kbd>H</kbd>, <kbd>M</kbd>, <kbd>L</kbd>: move cursor to the
 Highest/Middle/Lowest rows
 <li><kbd>zz</kbd>, <kbd>z.</kbd>: center on current line (and move to
diff --git a/res/config.toml b/res/config.toml
index d7186c24..739a7099 100644
--- a/res/config.toml
+++ b/res/config.toml
@@ -4,6 +4,214 @@ startup-script = ""
 headless = false
 console-buffer = true
 
+[cmd.pager]
+quit = '() => quit()'
+suspend = '() => suspend()'
+copyURL = '''
+() => {
+	if (pager.externInto('xsel -bi', pager.url))
+		pager.alert("Copied URL to clipboard.");
+	else
+		pager.alert("Failed to copy URL to clipboard. (Is xsel installed?)");
+}
+'''
+copyCursorLink = '''
+() => {
+	const link = pager.hoverLink;
+	if (!link)
+		pager.alert("Please move the cursor above a link and try again.");
+	else if (pager.externInto('xsel -bi', link))
+		pager.alert("Copied URL to clipboard.");
+	else
+		pager.alert("Failed to copy URL to clipboard. (Is xsel installed?)");
+}
+'''
+yankCursorImage = '''
+() => {
+	const link = pager.hoverImage;
+	if (!link)
+		pager.alert("Please move the cursor above an image and try again.");
+	else if (pager.externInto('xsel -bi', link))
+		pager.alert("Copied URL to clipboard.");
+	else
+		pager.alert("Failed to copy URL to clipboard. (Is xsel installed?)");
+}
+'''
+gotoClipboardURL = '''
+() => {
+	const s = pager.externCapture('xsel -bo');
+	if (s === null)
+		pager.alert("Failed to read URL from clipboard. (Is xsel installed?)");
+	else
+		pager.loadSubmit(s);
+}
+'''
+peek = '() => pager.alert(pager.url)'
+peekCursor = '() => pager.peekCursor()'
+toggleWrap = '''
+() => {
+	config.search.wrap = !config.search.wrap;
+	pager.alert("Wrap search " + (config.search.wrap ? "on" : "off"));
+}
+'''
+dupeBuffer = '() => pager.dupeBuffer()'
+load = '() => pager.load()'
+webSearch = '() => pager.load("ddg:")'
+openBookmarks = '() => pager.loadSubmit("~/.w3m/bookmark.html")'
+reloadBuffer = '() => pager.reload()'
+lineInfo = '() => pager.lineInfo()'
+toggleSource = '() => pager.toggleSource()'
+discardBuffer = '() => pager.discardBuffer()'
+discardTree = '() => pager.discardTree()'
+prevBuffer = '() => pager.prevBuffer()'
+prevSiblingBuffer = '() => pager.prevSiblingBuffer()'
+nextBuffer = '() => pager.nextBuffer()'
+nextSiblingBuffer = '() => pager.nextSiblingBuffer()'
+parentBuffer = '() => pager.parentBuffer()'
+enterCommand = '() => pager.command()'
+searchForward = '() => pager.searchForward()'
+searchBackward = '() => pager.searchBackward()'
+isearchForward = '() => pager.isearchForward()'
+isearchBackward = '() => pager.isearchBackward()'
+searchNext = 'n => pager.searchNext(n)'
+searchPrev = 'n => pager.searchPrev(n)'
+toggleCommandMode = '''
+() => {
+	if ((pager.commandMode = consoleBuffer != pager.buffer))
+		console.show();
+	else
+		console.hide();
+}
+'''
+
+[cmd.buffer]
+cursorLeft = 'n => pager.cursorLeft(n)'
+cursorDown = 'n => pager.cursorDown(n)'
+cursorUp = 'n => pager.cursorUp(n)'
+cursorRight = 'n => pager.cursorRight(n)'
+cursorLineBegin = '() => pager.cursorLineBegin()'
+cursorLineTextStart = '() => pager.cursorLineTextStart()'
+cursorLineEnd = '() => pager.cursorLineEnd()'
+cursorNextWord = '() => pager.cursorNextWord()'
+cursorNextViWord = '() => pager.cursorNextViWord()'
+cursorNextBigWord = '() => pager.cursorNextBigWord()'
+cursorWordBegin = '() => pager.cursorWordBegin()'
+cursorViWordBegin = '() => pager.cursorViWordBegin()'
+cursorBigWordBegin = '() => pager.cursorBigWordBegin()'
+cursorWordEnd = '() => pager.cursorWordEnd()'
+cursorViWordEnd = '() => pager.cursorViWordEnd()'
+cursorBigWordEnd = '() => pager.cursorBigWordEnd()'
+cursorPrevLink = 'n => pager.cursorPrevLink(n)'
+cursorNextLink = 'n => pager.cursorNextLink(n)'
+cursorPrevParagraph = 'n => pager.cursorPrevParagraph(n)'
+cursorNextParagraph = 'n => pager.cursorNextParagraph(n)'
+cursorTop = 'n => pager.cursorTop(n)'
+cursorMiddle = '() => pager.cursorMiddle()'
+cursorBottom = 'n => pager.cursorBottom(n)'
+cursorLeftEdge = '() => pager.cursorLeftEdge()'
+cursorMiddleColumn = '() => pager.cursorMiddleColumn()'
+cursorRightEdge = '() => pager.cursorRightEdge()'
+halfPageDown = 'n => pager.halfPageDown(n)'
+halfPageUp = 'n => pager.halfPageUp(n)'
+pageDown = 'n => pager.pageDown(n)'
+pageUp = 'n => pager.pageUp(n)'
+pageLeft = 'n => pager.pageLeft(n)'
+pageRight = 'n => pager.pageRight(n)'
+scrollDown = 'n => pager.scrollDown(n)'
+scrollUp = 'n => pager.scrollUp(n)'
+scrollLeft = 'n => pager.scrollLeft(n)'
+scrollRight = 'n => pager.scrollRight(n)'
+click = '() => pager.click()'
+viewImage = '() => pager.gotoURL(pager.hoverImage)'
+markURL = '() => pager.markURL()'
+redraw = '() => pager.redraw()'
+reshape = '() => pager.reshape()'
+cancel = '() => pager.cancel()'
+# vi G
+gotoLineOrEnd = 'n => n ? pager.gotoLine(n) : pager.cursorLastLine()'
+# vim gg
+gotoLineOrStart = 'n => n ? pager.gotoLine(n) : pager.cursorFirstLine()'
+# vi z. z^M z-
+centerLineBegin = 'n => pager.centerLineBegin(n)'
+raisePageBegin = 'n => pager.raisePageBegin(n)'
+lowerPageBegin = 'n => pager.lowerPageBegin(n)'
+# vi z+ z^
+nextPageBegin = 'n => pager.nextPageBegin(n)'
+previousPageBegin = 'n => pager.previousPageBegin(n)'
+# vim zz zb zt
+centerLine = 'n => pager.centerLine(n)'
+raisePage = 'n => pager.raisePage(n)'
+lowerPage = 'n => pager.lowerPage(n)'
+cursorToggleSelection = 'n => pager.cursorToggleSelection(n)'
+cursorToggleSelectionLine = 'n => pager.cursorToggleSelection(n, {selectionType: "line"})'
+cursorToggleSelectionBlock = 'n => pager.cursorToggleSelection(n, {selectionType: "block"})'
+sourceEdit = '''
+() => {
+	const url = pager.url;
+	pager.extern(pager.getEditorCommand(url.protocol == "file:" ?
+		url.pathname :
+		pager.cacheFile));
+}
+'''
+saveLink = '() => pager.saveLink()'
+saveSource = '() => pager.saveSource()'
+mark = '''
+async () => {
+	const c = await pager.askChar("m");
+	if (c.charCodeAt() != 3) /* ctrl-c */
+		pager.setMark(c);
+}
+'''
+gotoMark = '''
+async () => {
+	const c = await pager.askChar('`');
+	if (c.charCodeAt() != 3) /* C-c */
+		pager.gotoMark(c);
+}
+'''
+gotoMarkY = '''
+async () => {
+	const c = await pager.askChar('`');
+	if (c.charCodeAt() != 3) /* C-c */
+		pager.gotoMarkY(c);
+}
+'''
+copySelection = '''
+async () => {
+	if (!pager.currentSelection) {
+		feedNext();
+		return;
+	}
+	const text = await pager.getSelectionText(pager.currentSelection);
+	if (pager.externInto('xsel -bi', text))
+		pager.alert("Copied selection to clipboard.");
+	else
+		pager.alert("Failed to copy selection to clipboard. (Is xsel installed?)");
+	pager.cursorToggleSelection();
+}
+'''
+cursorNthLink = 'n => pager.cursorNthLink(n)'
+cursorRevNthLink = 'n => pager.cursorRevNthLink(n)'
+
+[cmd.line]
+submit = '() => line.submit()'
+backspace = '() => line.backspace()'
+delete = '() => line.delete()'
+cancel = '() => line.cancel()'
+prevWord = '() => line.prevWord()'
+nextWord = '() => line.nextWord()'
+backward = '() => line.backward()'
+forward = '() => line.forward()'
+clear = '() => line.clear()'
+kill = '() => line.kill()'
+clearWord = '() => line.clearWord()'
+killWord = '() => line.killWord()'
+begin = '() => line.begin()'
+end = '() => line.end()'
+escape = '() => line.escape()'
+prevHist = '() => line.prevHist()'
+nextHist = '() => line.nextHist()'
+
 [search]
 wrap = true
 ignore-case = true
@@ -80,218 +288,139 @@ match = '^ddg:'
 substitute-url = '(x) => "https://lite.duckduckgo.com/lite/?kp=-1&kd=-1&q=" + encodeURIComponent(x.split(":").slice(1).join(":"))'
 
 [page]
-q = 'quit()'
-C-z = 'suspend()'
-h = 'n => pager.cursorLeft(n)'
-j = 'n => pager.cursorDown(n)'
-k = 'n => pager.cursorUp(n)'
-l = 'n => pager.cursorRight(n)'
-'M-[D' = 'n => pager.cursorLeft(n)'
-'M-[B' = 'n => pager.cursorDown(n)'
-'M-[A' = 'n => pager.cursorUp(n)'
-'M-[C' = 'n => pager.cursorRight(n)'
-'0' = 'pager.cursorLineBegin()'
-'^' = 'pager.cursorLineTextStart()'
-'$' = 'pager.cursorLineEnd()'
-b = 'pager.cursorViWordBegin()'
-e = 'pager.cursorViWordEnd()'
-w = 'pager.cursorNextViWord()'
-B = 'pager.cursorBigWordBegin()'
-E = 'pager.cursorBigWordEnd()'
-W = 'pager.cursorNextBigWord()'
-'[' = 'n => pager.cursorPrevLink(n)'
-']' = 'n => pager.cursorNextLink(n)'
-'{' = 'n => pager.cursorPrevParagraph(n)'
-'}' = 'n => pager.cursorNextParagraph(n)'
-H = 'n => pager.cursorTop(n)'
-M = '() => pager.cursorMiddle()'
-L = 'n => pager.cursorBottom(n)'
-g0 = 'pager.cursorLeftEdge()'
-gc = 'pager.cursorMiddleColumn()'
-'g$' = 'pager.cursorRightEdge()'
-C-d = 'n => pager.halfPageDown(n)'
-C-u = 'n => pager.halfPageUp(n)'
-C-f = 'n => pager.pageDown(n)'
-C-b = 'n => pager.pageUp(n)'
-'M-[6~' = 'n => pager.pageDown(n)'
-'M-[5~' = 'n => pager.pageUp(n)'
-'zH'= 'n => pager.pageLeft(n)'
-'zL' = 'n => pager.pageRight(n)'
-'<' = 'n => pager.pageLeft(n)'
-'>' = 'n => pager.pageRight(n)'
-C-e = 'n => pager.scrollDown(n)'
-C-y = 'n => pager.scrollUp(n)'
-sE = '''
-() => {
-	const url = pager.url;
-	pager.extern(pager.getEditorCommand(url.protocol == "file:" ?
-		url.pathname :
-		pager.cacheFile));
-}
-'''
-sC-m = 'pager.saveLink()'
-sC-j = 'pager.saveLink()'
-sS = 'pager.saveSource()'
-m = '''
-async () => {
-	const c = await pager.askChar("m");
-	if (c.charCodeAt() != 3) /* ctrl-c */
-		pager.setMark(c);
-}
-'''
-'`' = '''
-async () => {
-	const c = await pager.askChar('`');
-	if (c.charCodeAt() != 3) /* C-c */
-		pager.gotoMark(c);
-}
-'''
-"'" = '''
-async () => {
-	const c = await pager.askChar('`');
-	if (c.charCodeAt() != 3) /* C-c */
-		pager.gotoMarkY(c);
-}
-'''
-'zh'= 'n => pager.scrollLeft(n)'
-'zl' = 'n => pager.scrollRight(n)'
-J = 'n => pager.scrollDown(n)'
-K = 'n => pager.scrollUp(n)'
-'-'= 'n => pager.scrollLeft(n)'
-'+' = 'n => pager.scrollRight(n)'
-C-m = 'pager.click()'
-C-j = 'pager.click()'
-I = 'pager.gotoURL(pager.hoverImage)'
-M-u = 'pager.dupeBuffer()'
-C-l = 'pager.load()'
-C-k = 'pager.load("ddg:")'
-M-b = 'pager.loadSubmit("~/.w3m/bookmark.html")'
-U = 'pager.reload()'
-r = 'pager.redraw()'
-R = 'pager.reshape()'
-M-C-c = 'pager.cancel()'
-gg = 'n => n ? pager.gotoLine(n) : pager.cursorFirstLine()'
-G = 'n => n ? pager.gotoLine(n) : pager.cursorLastLine()'
-M-g = 'pager.gotoLine()'
-'z.' = 'n => pager.centerLineBegin(n)'
-'zC-m' = 'n => pager.raisePageBegin(n)'
-'zC-j' = 'n => pager.raisePageBegin(n)'
-'z-' = 'n => pager.lowerPageBegin(n)'
-zz = 'n => pager.centerLine(n)'
-'zt' = 'n => pager.raisePage(n)'
-'zb' = 'n => pager.lowerPage(n)'
-'z+' = 'n => pager.nextPageBegin(n)'
-'z^' = 'n => pager.previousPageBegin(n)'
-C-g = 'pager.lineInfo()'
-v = 'n => pager.cursorToggleSelection(n)'
-V = 'n => pager.cursorToggleSelection(n, {selectionType: "line"})'
-C-v = 'n => pager.cursorToggleSelection(n, {selectionType: "block"})'
-y = '''
-async () => {
-	if (!pager.currentSelection) {
-		feedNext();
-		return;
-	}
-	const text = await pager.getSelectionText(pager.currentSelection);
-	if (pager.externInto('xsel -bi', text))
-		pager.alert("Copied selection to clipboard.");
-	else
-		pager.alert("Failed to copy selection to clipboard. (Is xsel installed?)");
-	pager.cursorToggleSelection();
-}
-'''
-'\' = 'pager.toggleSource()'
-D = 'pager.discardBuffer()'
-M-d = 'pager.discardTree()'
-',' = 'pager.prevBuffer()'
-'M-,' = 'pager.prevSiblingBuffer()'
-'.' = 'pager.nextBuffer()'
-'M-.' = 'pager.nextSiblingBuffer()'
-'M-/' = 'pager.parentBuffer()'
-M-c = 'pager.command()'
-'/' = 'pager.isearchForward()'
-'?' = 'pager.isearchBackward()'
-n = 'n => pager.searchNext(n)'
-N = 'n => pager.searchPrev(n)'
-c = 'pager.peek()'
-u = 'pager.peekCursor()'
-C-w = '''
-config.search.wrap = !config.search.wrap;
-pager.alert("Wrap search " + (config.search.wrap ? "on" : "off"));
-'''
-M-y = '''
-() => {
-	if (pager.externInto('xsel -bi', pager.url))
-		pager.alert("Copied URL to clipboard.");
-	else
-		pager.alert("Failed to copy URL to clipboard. (Is xsel installed?)");
-}
-'''
-yc = '() => pager.alert("Please use `yu` to copy URLs instead!");'
-yu = '''
-() => {
-	const link = pager.hoverLink;
-	if (!link)
-		pager.alert("Please move the cursor above a link and try again.");
-	else if (pager.externInto('xsel -bi', link))
-		pager.alert("Copied URL to clipboard.");
-	else
-		pager.alert("Failed to copy URL to clipboard. (Is xsel installed?)");
-}
-'''
-yI = '''
-() => {
-	const link = pager.hoverImage;
-	if (!link)
-		pager.alert("Please move the cursor above an image and try again.");
-	else if (pager.externInto('xsel -bi', link))
-		pager.alert("Copied URL to clipboard.");
-	else
-		pager.alert("Failed to copy URL to clipboard. (Is xsel installed?)");
-}
-'''
-M-p = '''
-() => {
-	const s = pager.externCapture('xsel -bo');
-	if (s === null)
-		pager.alert("Failed to read URL from clipboard. (Is xsel installed?)");
-	else
-		pager.loadSubmit(s);
-}
-'''
-':' = 'pager.markURL()'
+# buffer commands
+q = 'cmd.pager.quit'
+C-z = 'cmd.pager.suspend'
+h = 'cmd.buffer.cursorLeft'
+j = 'cmd.buffer.cursorDown'
+k = 'cmd.buffer.cursorUp'
+l = 'cmd.buffer.cursorRight'
+'M-[D' = 'cmd.buffer.cursorLeft'
+'M-[B' = 'cmd.buffer.cursorDown'
+'M-[A' = 'cmd.buffer.cursorUp'
+'M-[C' = 'cmd.buffer.cursorRight'
+'0' = 'cmd.buffer.cursorLineBegin'
+'^' = 'cmd.buffer.cursorLineTextStart'
+'$' = 'cmd.buffer.cursorLineEnd'
+b = 'cmd.buffer.cursorViWordBegin'
+e = 'cmd.buffer.cursorViWordEnd'
+w = 'cmd.buffer.cursorNextViWord'
+B = 'cmd.buffer.cursorBigWordBegin'
+E = 'cmd.buffer.cursorBigWordEnd'
+W = 'cmd.buffer.cursorNextBigWord'
+'[' = 'cmd.buffer.cursorPrevLink'
+']' = 'cmd.buffer.cursorNextLink'
+'{' = 'cmd.buffer.cursorPrevParagraph'
+'}' = 'cmd.buffer.cursorNextParagraph'
+H = 'cmd.buffer.cursorTop'
+M = 'cmd.buffer.cursorMiddle'
+L = 'cmd.buffer.cursorBottom'
+g0 = 'cmd.buffer.cursorLeftEdge'
+gc = 'cmd.buffer.cursorMiddleColumn'
+'g$' = 'cmd.buffer.cursorRightEdge'
+C-d = 'cmd.buffer.halfPageDown'
+C-u = 'cmd.buffer.halfPageUp'
+C-f = 'cmd.buffer.pageDown'
+C-b = 'cmd.buffer.pageUp'
+'M-[6~' = 'cmd.buffer.pageDown'
+'M-[5~' = 'cmd.buffer.pageUp'
+'zH'= 'cmd.buffer.pageLeft'
+'zL' = 'cmd.buffer.pageRight'
+'<' = 'cmd.buffer.pageLeft'
+'>' = 'cmd.buffer.pageRight'
+C-e = 'cmd.buffer.scrollDown'
+C-y = 'cmd.buffer.scrollUp'
+J = 'cmd.buffer.scrollDown'
+K = 'cmd.buffer.scrollUp'
+sE = 'cmd.buffer.sourceEdit'
+sC-m = 'cmd.buffer.saveLink'
+sC-j = 'cmd.buffer.saveLink'
+m = 'cmd.buffer.mark'
+'`' = 'cmd.buffer.gotoMark'
+"'" = 'cmd.buffer.gotoMarkY'
+'zh'= 'cmd.buffer.scrollLeft'
+'zl' = 'cmd.buffer.scrollRight'
+'-'= 'cmd.buffer.scrollLeft'
+'+' = 'cmd.buffer.scrollRight'
+C-m = 'cmd.buffer.click'
+C-j = 'cmd.buffer.click'
+I = 'cmd.buffer.viewImage'
+':' = 'cmd.buffer.markURL'
+r = 'cmd.buffer.redraw'
+R = 'cmd.buffer.reshape'
+C-c = 'cmd.buffer.cancel'
+gg = 'cmd.buffer.gotoLineOrStart'
+G = 'cmd.buffer.gotoLineOrEnd'
+'z.' = 'cmd.buffer.centerLineBegin'
+'zC-m' = 'cmd.buffer.raisePageBegin'
+'zC-j' = 'cmd.buffer.raisePageBegin'
+'z-' = 'cmd.buffer.lowerPageBegin'
+zz = 'cmd.buffer.centerLine'
+'zt' = 'cmd.buffer.raisePage'
+'zb' = 'cmd.buffer.lowerPage'
+'z+' = 'cmd.buffer.nextPageBegin'
+'z^' = 'cmd.buffer.previousPageBegin'
+y = 'cmd.buffer.copySelection'
+v = 'cmd.buffer.cursorToggleSelection'
+V = 'cmd.buffer.cursorToggleSelectionLine'
+C-v = 'cmd.buffer.cursorToggleSelectionBlock'
+
+# pager commands
+sS = 'cmd.pager.saveSource'
+M-u = 'cmd.pager.dupeBuffer'
+C-l = 'cmd.pager.load'
+C-k = 'cmd.pager.webSearch'
+M-b = 'cmd.pager.openBookmarks'
+U = 'cmd.pager.reloadBuffer'
+C-g = 'cmd.pager.lineInfo'
+'\' = 'cmd.pager.toggleSource'
+D = 'cmd.pager.discardBuffer'
+M-d = 'cmd.pager.discardTree'
+',' = 'cmd.pager.prevBuffer'
+'M-,' = 'cmd.pager.prevSiblingBuffer'
+'.' = 'cmd.pager.nextBuffer'
+'M-.' = 'cmd.pager.nextSiblingBuffer'
+'M-/' = 'cmd.pager.parentBuffer'
+M-c = 'cmd.pager.enterCommand'
+'/' = 'cmd.pager.isearchForward'
+'?' = 'cmd.pager.isearchBackward'
+n = 'cmd.pager.searchNext'
+N = 'cmd.pager.searchPrev'
+c = 'cmd.pager.peek'
+u = 'cmd.pager.peekCursor'
+C-w = 'cmd.pager.toggleWrap'
+M-y = 'cmd.pager.copyURL'
+yc = 'pager.alert("Please use `yu` to copy URLs")'
+yu = 'cmd.pager.copyCursorLink'
+yI = 'cmd.pager.copyCursorImage'
+M-p = 'cmd.pager.gotoClipboardURL'
 
 [line]
-C-m = 'line.submit()'
-C-j = 'line.submit()'
-C-h = 'line.backspace()'
-'C-?' = 'line.backspace()'
-C-d = 'line.delete()'
-C-c = 'line.cancel()'
-M-b = 'line.prevWord()'
-M-f = 'line.nextWord()'
-C-b = 'line.backward()'
-C-f = 'line.forward()'
-C-u = 'line.clear()'
-C-_ = 'line.clear()'
-M-k = 'line.clear()'
-C-k = 'line.kill()'
-C-w = 'line.clearWord()'
-M-C-h = 'line.clearWord()'
-'M-C-?' = 'line.clearWord()'
-M-d = 'line.killWord()'
-C-a = 'line.begin()'
-C-e = 'line.end()'
-C-v = 'line.escape()'
-C-p = 'line.prevHist()'
-C-n = 'line.nextHist()'
-M-c = '''
-if ((pager.commandMode = consoleBuffer != pager.buffer))
-	console.show();
-else
-	console.hide();
-'''
-'M-[D' = 'line.backward()'
-'M-[B' = 'line.nextHist()'
-'M-[A' = 'line.prevHist()'
-'M-[C' = 'line.forward()'
+C-m = 'cmd.line.submit'
+C-j = 'cmd.line.submit'
+C-h = 'cmd.line.backspace'
+'C-?' = 'cmd.line.backspace'
+C-d = 'cmd.line.delete'
+C-c = 'cmd.line.cancel'
+M-b = 'cmd.line.prevWord'
+M-f = 'cmd.line.nextWord'
+C-b = 'cmd.line.backward'
+C-f = 'cmd.line.forward'
+C-u = 'cmd.line.clear'
+C-_ = 'cmd.line.clear'
+M-k = 'cmd.line.clear'
+C-k = 'cmd.line.kill'
+C-w = 'cmd.line.clearWord'
+M-C-h = 'cmd.line.clearWord'
+'M-C-?' = 'cmd.line.clearWord'
+M-d = 'cmd.line.killWord'
+C-a = 'cmd.line.begin'
+C-e = 'cmd.line.end'
+C-v = 'cmd.line.escape'
+C-p = 'cmd.line.prevHist'
+C-n = 'cmd.line.nextHist'
+M-c = 'cmd.pager.toggleCommandMode'
+'M-[D' = 'cmd.line.backward'
+'M-[B' = 'cmd.line.nextHist'
+'M-[A' = 'cmd.line.prevHist'
+'M-[C' = 'cmd.line.forward'
diff --git a/src/config/config.nim b/src/config/config.nim
index ebf36d49..287c2e54 100644
--- a/src/config/config.nim
+++ b/src/config/config.nim
@@ -1,8 +1,10 @@
 import std/options
 import std/os
 import std/streams
+import std/strutils
 import std/tables
 
+import bindings/quickjs
 import config/chapath
 import config/mailcap
 import config/mimetypes
@@ -12,6 +14,7 @@ import js/fromjs
 import js/javascript
 import js/propertyenumlist
 import js/regex
+import js/tojs
 import loader/headers
 import types/cell
 import types/color
@@ -70,6 +73,11 @@ type
     display_charset* {.jsgetset.}: Option[Charset]
     document_charset* {.jsgetset.}: seq[Charset]
 
+  CommandConfig = object
+    jsObj*: JSValue
+    init*: seq[tuple[k, cmd: string]] # initial k/v map
+    map*: Table[string, JSValue] # qualified name -> function
+
   ExternalConfig = object
     tmpdir* {.jsgetset.}: ChaPathResolved
     editor* {.jsgetset.}: string
@@ -130,6 +138,7 @@ type
     #TODO getset
     siteconf*: seq[SiteConfig]
     omnirule*: seq[OmniRule]
+    cmd*: CommandConfig
     page* {.jsget.}: ActionMap
     line* {.jsget.}: ActionMap
 
@@ -331,6 +340,8 @@ proc parseConfigValue(ctx: var ConfigParser; x: var Mailcap; v: TomlValue;
   k: string)
 proc parseConfigValue(ctx: var ConfigParser; x: var URIMethodMap; v: TomlValue;
   k: string)
+proc parseConfigValue(ctx: var ConfigParser; x: var CommandConfig; v: TomlValue;
+  k: string)
 
 proc typeCheck(v: TomlValue; t: TomlValueType; k: string) =
   if v.t != t:
@@ -626,6 +637,28 @@ proc parseConfigValue(ctx: var ConfigParser; x: var URIMethodMap; v: TomlValue;
       x.parseURIMethodMap(f.readAll())
   x.append(DefaultURIMethodMap)
 
+func isCompatibleIdent(s: string): bool =
+  if s.len == 0 or s[0] notin AsciiAlpha + {'_', '$'}:
+    return false
+  for i in 1 ..< s.len:
+    if s[i] notin AsciiAlphaNumeric + {'_', '$'}:
+      return false
+  return true
+
+proc parseConfigValue(ctx: var ConfigParser; x: var CommandConfig; v: TomlValue;
+    k: string) =
+  typeCheck(v, tvtTable, k)
+  for kk, vv in v:
+    let kkk = k & "." & kk
+    typeCheck(vv, {tvtTable, tvtString}, kkk)
+    if not kk.isCompatibleIdent():
+      raise newException(ValueError, "invalid command name: " & kkk)
+    if vv.t == tvtTable:
+      ctx.parseConfigValue(x, vv, kkk)
+    else: # tvtString
+      # skip initial "cmd.", we don't need it
+      x.init.add((kkk.substr("cmd.".len), vv.s))
+
 type ParseConfigResult* = object
   success*: bool
   warnings*: seq[string]
@@ -700,15 +733,64 @@ proc getNormalAction*(config: Config; s: string): string =
 proc getLinedAction*(config: Config; s: string): string =
   return config.line.getOrDefault(s)
 
-proc readConfig*(pathOverride: Option[string]; jsctx: JSContext): Config =
-  result = Config(jsctx: jsctx)
-  discard result.parseConfig("res", newStringStream(defaultConfig)) #TODO TODO TODO
+type ReadConfigResult = tuple
+  config: Config
+  res: ParseConfigResult
+
+proc readConfig*(pathOverride: Option[string]; jsctx: JSContext):
+    ReadConfigResult =
+  let config = Config(jsctx: jsctx)
+  var res = config.parseConfig("res", newStringStream(defaultConfig))
+  if not res.success:
+    return (nil, res)
   if pathOverride.isNone:
     when defined(debug):
-      discard result.readConfig(getCurrentDir() / "res", "config.toml")
-    discard result.readConfig(getConfigDir() / "chawan", "config.toml")
+      res = config.readConfig(getCurrentDir() / "res", "config.toml")
+      if not res.success:
+        return (nil, res)
+    res = config.readConfig(getConfigDir() / "chawan", "config.toml")
   else:
-    discard result.readConfig(getCurrentDir(), pathOverride.get)
+    res = config.readConfig(getCurrentDir(), pathOverride.get)
+  if not res.success:
+    return (nil, res)
+  return (config, res)
+
+# called after parseConfig returns
+proc initCommands*(config: Config): Err[string] =
+  let ctx = config.jsctx
+  let obj = JS_NewObject(ctx)
+  defer: JS_FreeValue(ctx, obj)
+  if JS_IsException(obj):
+    return err(ctx.getExceptionStr())
+  for i in countdown(config.cmd.init.high, 0):
+    let (k, cmd) = config.cmd.init[i]
+    if k in config.cmd.map:
+      # already in map; skip
+      continue
+    var objIt = obj
+    let name = k.afterLast('.')
+    if name.len < k.len:
+      for ss in k.substr(0, k.high - name.len - 1).split('.'):
+        var prop = JS_GetPropertyStr(ctx, objIt, cstring(ss))
+        if JS_IsUndefined(prop):
+          prop = JS_NewObject(ctx)
+          ctx.definePropertyE(objIt, ss, prop)
+        if JS_IsException(prop):
+          return err(ctx.getExceptionStr())
+        objIt = prop
+    if cmd == "":
+      config.cmd.map[k] = JS_UNDEFINED
+      continue
+    let fun = ctx.eval(cmd, "<" & k & ">", JS_EVAL_TYPE_GLOBAL)
+    if JS_IsException(fun):
+      return err(ctx.getExceptionStr())
+    if not JS_IsFunction(ctx, fun):
+      return err(k & " is not a function")
+    ctx.definePropertyE(objIt, name, JS_DupValue(ctx, fun))
+    config.cmd.map[k] = fun
+  config.cmd.jsObj = JS_DupValue(ctx, obj)
+  config.cmd.init = @[]
+  ok()
 
 proc addConfigModule*(ctx: JSContext) =
   ctx.registerType(ActionMap)
diff --git a/src/config/toml.nim b/src/config/toml.nim
index 5f471b27..760f335d 100644
--- a/src/config/toml.nim
+++ b/src/config/toml.nim
@@ -183,6 +183,7 @@ proc consumeString(state: var TomlParser, first: char):
     multiline = true
     state.seek(2)
   if multiline and state.peek(0) == '\n':
+    inc state.line
     discard state.consume()
   var escape = false
   var ml_trim = false
@@ -223,6 +224,8 @@ proc consumeString(state: var TomlParser, first: char):
       if c notin {'\n', ' ', '\t'}:
         res &= c
         ml_trim = false
+      if c == '\n':
+        inc state.line
     else:
       if c == '\n':
         inc state.line
diff --git a/src/local/client.nim b/src/local/client.nim
index 72c85744..c63a18db 100644
--- a/src/local/client.nim
+++ b/src/local/client.nim
@@ -181,8 +181,13 @@ proc handlePagerEvents(client: Client) =
   if container != nil:
     client.pager.handleEvents(container)
 
-proc evalAction(client: Client, action: string, arg0: int32): EmptyPromise =
-  var ret = client.evalJS(action, "<command>")
+proc evalActionJS(client: Client; action: string): JSValue =
+  client.config.cmd.map.withValue(action, p):
+    return JS_DupValue(client.jsctx, p[])
+  return client.evalJS(action, "<command>")
+
+proc evalAction(client: Client; action: string; arg0: int32): EmptyPromise =
+  var ret = client.evalActionJS(action)
   let ctx = client.jsctx
   var p = EmptyPromise()
   p.resolve()
@@ -814,16 +819,13 @@ proc newClient*(config: Config; forkserver: ForkServer; jsctx: JSContext;
     tmpdir: config.external.tmpdir
   ))
   pager.setLoader(loader)
-  let client = Client(
-    config: config,
-    jsrt: jsrt,
-    jsctx: jsctx,
-    pager: pager
-  )
+  let client = Client(config: config, jsrt: jsrt, jsctx: jsctx, pager: pager)
   jsrt.setInterruptHandler(interruptHandler, cast[pointer](client))
   var global = JS_GetGlobalObject(jsctx)
   jsctx.registerType(Client, asglobal = true)
   setGlobal(jsctx, global, client)
+  jsctx.definePropertyE(global, "cmd", config.cmd.jsObj)
+  config.cmd.jsObj = JS_NULL
   JS_FreeValue(jsctx, global)
   client.addJSModules(jsctx)
   return client
diff --git a/src/main.nim b/src/main.nim
index 193a3575..20d9c564 100644
--- a/src/main.nim
+++ b/src/main.nim
@@ -175,14 +175,24 @@ proc main() =
     inc ctx.i
   let jsrt = newJSRuntime()
   let jsctx = jsrt.newJSContext()
-  let config = readConfig(ctx.configPath, jsctx)
   var warnings = newSeq[string]()
+  let (config, res) = readConfig(ctx.configPath, jsctx)
+  if not res.success:
+    stderr.write(res.errorMsg)
+    quit(1)
+  warnings.add(res.warnings)
   for opt in ctx.opts:
     let res = config.parseConfig(getCurrentDir(), opt, laxnames = true)
     if not res.success:
       stderr.write(res.errorMsg)
       quit(1)
+    warnings.add(res.warnings)
   config.css.stylesheet &= ctx.stylesheet
+  block commands:
+    let res = config.initCommands()
+    if res.isErr:
+      stderr.write("Error parsing commands: " & res.error)
+      quit(1)
   set_cjk_ambiguous(config.display.double_width_ambiguous)
   if pages.len == 0 and stdin.isatty():
     if ctx.visual: