/* * What the keys in keymap.ts do, under the names the table gives them, one * record per context. The type below holds the two together: a binding * whose action is missing here, or an action no binding names, does not * compile. * * An action is handed the key as `keyName` spelled it, for the ones that * share an action and differ in direction. Returning `false` lets the key * on to the next context down, as if it had not been bound here. */ import { cycleTile, focusTile, followLink, hooks, runCommand } from './commands' import { bindings, type Context } from './keymap' import { focusHelpFilter, scrollHelp } from './Help.svelte' import { copyMedia, insertMedia, removeMedia } from './MediaBrowser.svelte' import { purgeTrashed, restoreTrashed } from './Trash.svelte' import { openBacklink } from './Backlinks.svelte' import { moveHistory, restoreShown } from './History.svelte' import { workspace, type SpecialTile } from './store.svelte' import { dwindle, formatDay, neighbour, type Direction } from './util' export type Run = (key: string, event: KeyboardEvent) => boolean | void type Bound = (typeof bindings)[number] type ActionOf = Extract['action'] const guard = (task: () => Promise) => void workspace.guard(task) const line = (text: string) => hooks.openCommandLine(text) const shifted = (key: string) => key.startsWith('Shift-') /** The space a digit key stands for: 1 is the first, 0 the tenth. */ const spaceIndex = (key: string) => { const digit = key.slice(-1) return digit === '0' ? 9 : Number(digit) - 1 } /** The document on screen is what j/k scroll, rather than the list. */ const reading = () => !!workspace.open && !workspace.editing && workspace.pane === 'document' /** A cursor down a sheet's list, kept on it. */ function step(cursor: 'outlineCursor' | 'mediaCursor' | 'trashCursor' | 'backlinksCursor', length: number, by: number) { workspace[cursor] = Math.min(Math.max(workspace[cursor] + by, 0), Math.max(length - 1, 0)) } export async function openOverview() { await workspace.guard(() => workspace.loadOverview()) workspace.overviewCursor = Math.max( workspace.overview.findIndex((entry) => entry.space.id === workspace.spaceId), 0, ) workspace.overlay = 'overview' } /* * `F`: the hints are pinned where the targets are, so a pane still sliding * in — a space switch, the board just opened — is let settle first. */ async function showHints(root: HTMLElement) { // What moves the root may be an animation on a pane around it. A blinking // cursor never finishes, so only what ends is waited for. const settling = document.getAnimations().filter((animation) => { const target = (animation.effect as KeyframeEffect | null)?.target if (!target || animation.effect?.getComputedTiming().endTime === Infinity) return false return target.contains(root) || root.contains(target) }) await Promise.all(settling.map((animation) => animation.finished.catch(() => {}))) if (root.isConnected) hooks.showHints(root) } /** `gx`: the file as the browser shows it, in a tab of its own. */ function openInTab() { const file = workspace.target?.file if (!file) return workspace.say('gx opens a file — put the cursor on one', 'error') // A tab opened after the vault's question would be stopped as a popup, so // a sealed file asks for the vault to be opened first instead. if (file.encrypted && !workspace.vault?.unlocked) return workspace.say('sealed — :unlock first, then gx', 'error') followLink(file.url, true) } /** Focus moves between the panes, out of the editor too. */ function focusPane(pane: 'list' | 'document') { if (pane === 'document' && !workspace.open) return workspace.pane = pane if (pane === 'document' && workspace.editing) hooks.focusEditor() else hooks.focusList() } /* * The special workspace is three tiles: the board on the left, the agenda * over the calendar on the right. Alt-h/j/k/l moves between them. */ const specialNeighbours: Record>> = { board: { l: 'agenda' }, agenda: { h: 'board', j: 'calendar' }, calendar: { h: 'board', k: 'agenda' }, } /* * Alt + h/j/k/l: the focus goes to the tile that way, out of the editor * too; left of the leftmost tile is the list. With Shift the focused tile * goes that way instead, swapping places with the one it lands on. */ function tileKey(direction: Direction, shift: boolean) { if (workspace.view === 'special') { const next = specialNeighbours[workspace.specialFocus][direction] if (next && !shift) workspace.specialFocus = next return } if (workspace.pane === 'list' || !workspace.open) { if (direction === 'l' && !shift) focusPane('document') return } const next = neighbour(dwindle(workspace.tiles.length), workspace.focused, direction) if (next < 0) { if (direction === 'h' && !shift) focusPane('list') return } if (!shift) return void focusTile(next) const tiles = [...workspace.tiles] ;[tiles[workspace.focused], tiles[next]] = [tiles[next], tiles[workspace.focused]] workspace.tiles = tiles workspace.focused = next } export const actions: { [C in Context]: Record, Run> } = { always: { save: () => { if (workspace.scratchEditing) guard(() => workspace.saveScratchNote()) else if (workspace.describing) guard(() => workspace.saveDescription()) else if (workspace.open) guard(() => workspace.save()) }, close: () => void runCommand('close'), focus: (key) => tileKey(key.slice(-1) as Direction, false), swap: (key) => tileKey(key.slice(-1) as Direction, true), }, sheet: { // A description or a note being written has had its Esc; the sheet // stays up under it. close: () => { if (workspace.describing || workspace.scratchEditing) return workspace.overlay = 'none' }, }, /* * The help's columns run on to the right, so h/l and j/k move a column, * Ctrl-D/U a screen, g/G to either end, and `/` narrows it down. */ help: { next: () => scrollHelp(1), previous: () => scrollHelp(-1), pageDown: () => scrollHelp('pagedown'), pageUp: () => scrollHelp('pageup'), page: (key) => scrollHelp(shifted(key) ? 'pageup' : 'pagedown'), start: () => scrollHelp('start'), end: () => scrollHelp('end'), filter: () => focusHelpFilter(), }, /** The overview: hjkl across the grid, a digit or Enter to go. */ overview: { step: (key) => { const columns = hooks.overviewColumns() const by = { h: -1, ArrowLeft: -1, l: 1, ArrowRight: 1, k: -columns, ArrowUp: -columns, j: columns, ArrowDown: columns }[key] ?? 0 workspace.overviewCursor = Math.min(Math.max(workspace.overviewCursor + by, 0), workspace.overview.length - 1) }, go: (key) => goToSpace(spaceIndex(key)), enter: () => goToSpace(workspace.overviewCursor), close: () => void (workspace.overlay = 'none'), }, /** `gO`'s list: j/k down the headings, Enter to the one picked. */ outline: { down: () => step('outlineCursor', workspace.outline.length, 1), up: () => step('outlineCursor', workspace.outline.length, -1), bottom: () => step('outlineCursor', workspace.outline.length, Infinity), go: () => { const heading = workspace.outline[workspace.outlineCursor] workspace.overlay = 'none' heading?.go() }, }, /** The media list: j/k down the files, i inserts, y copies, x deletes. */ media: { down: () => step('mediaCursor', workspace.media.length, 1), up: () => step('mediaCursor', workspace.media.length, -1), bottom: () => step('mediaCursor', workspace.media.length, Infinity), insert: () => { const file = workspace.media[workspace.mediaCursor] if (file) insertMedia(file) }, copy: () => { const file = workspace.media[workspace.mediaCursor] if (file) void copyMedia(file) }, delete: () => { const file = workspace.media[workspace.mediaCursor] if (file) void removeMedia(file) }, }, /** The trash: j/k down what was deleted, Enter or r restores, x deletes for good. */ trash: { down: () => step('trashCursor', workspace.trash.length, 1), up: () => step('trashCursor', workspace.trash.length, -1), bottom: () => step('trashCursor', workspace.trash.length, Infinity), restore: () => { const item = workspace.trash[workspace.trashCursor] if (item) void restoreTrashed(item) }, purge: () => { const item = workspace.trash[workspace.trashCursor] if (item) void purgeTrashed(item) }, }, /** The backlinks: j/k through the documents that link here, Enter opens one. */ backlinks: { down: () => step('backlinksCursor', workspace.backlinks.length, 1), up: () => step('backlinksCursor', workspace.backlinks.length, -1), bottom: () => step('backlinksCursor', workspace.backlinks.length, Infinity), open: () => { const link = workspace.backlinks[workspace.backlinksCursor] if (link) void openBacklink(link) }, }, /** The history: j/k through the versions, Enter brings the one shown back. */ history: { down: () => moveHistory(1), up: () => moveHistory(-1), bottom: () => moveHistory(workspace.history.length), restore: () => void restoreShown(), }, /* * The card sheet reads the list keys over its checklist, and the board's * card keys for the card itself — `D`, `H`/`L`, `R` act on it as they do * on the board under it. While the description is being written the keys * are the editor's. */ card: { down: () => void workspace.moveTask(1), up: () => void workspace.moveTask(-1), top: () => void workspace.moveTask(-Infinity), bottom: () => void workspace.moveTask(Infinity), tick: () => guard(() => workspace.toggleTask()), add: () => line(':task '), reword: () => { const task = workspace.card?.checklist[workspace.taskCursor] if (task) line(`:task! ${task.text}`) }, nudgeDown: () => guard(() => workspace.nudgeTask(1)), nudgeUp: () => guard(() => workspace.nudgeTask(-1)), drop: () => guard(() => workspace.deleteTask()), describe: () => workspace.describe(), label: () => line(':label '), rename: () => line(`:name ${workspace.card?.title ?? ''}`), due: () => line(':due '), sendLeft: () => guard(() => workspace.sendCard(-1)), sendRight: () => guard(() => workspace.sendCard(1)), notes: () => { workspace.overlay = 'none' guard(() => workspace.openCard()) }, line: () => line(':'), close: () => workspace.closeCardSheet(), }, /* * The scratchpad, while it is down and its note is not being written: the * list keys over its notes. It sits over whatever was on screen, so * nothing else hears a key until it is raised again. */ drawer: { down: () => void (workspace.scratchCursor = Math.min(workspace.scratchCursor + 1, Math.max(workspace.scratchNotes.length - 1, 0))), up: () => void (workspace.scratchCursor = Math.max(workspace.scratchCursor - 1, 0)), top: () => void (workspace.scratchCursor = 0), bottom: () => void (workspace.scratchCursor = Math.max(workspace.scratchNotes.length - 1, 0)), new: () => workspace.newScratchNote(), write: () => workspace.editScratchNote(), keep: () => guard(() => workspace.keepScratchNote()), drop: () => { if (!workspace.scratchNote) return void (async () => { if (await workspace.ask('drop this scratch note?')) await workspace.guard(() => workspace.dropScratchNote()) })() }, line: () => line(':'), help: () => void runCommand('help'), raise: () => void workspace.closeScratch(), nothing: () => {}, }, workspace: { /** Esc leaves one thing: the line, or fullscreen. */ escape: () => { if (workspace.describing || workspace.scratchEditing) return if (hooks.closeCommandLine()) return if (workspace.fullscreen && !workspace.editing) return void (workspace.fullscreen = false) return false }, // `Q`, a quick note: the scratchpad lowered from anywhere a key is not // being typed — a letter, since a backtick is a dead key on many layouts. scratch: () => { if (workspace.scratchOpen) return false guard(() => workspace.toggleScratch()) }, }, writing: { refocus: () => hooks.focusEditor(), }, /* * The focused pane owns j/k. In the document they scroll what is on * screen; `h` moves focus to the list with the document still open, where * they walk the rows again, and `l` goes back. */ tree: { down: () => void (reading() ? hooks.scrollReader(1) : workspace.move(1)), up: () => void (reading() ? hooks.scrollReader(-1) : workspace.move(-1)), top: () => void (reading() ? hooks.scrollReader('top') : (workspace.cursor = 0)), bottom: () => void (reading() ? hooks.scrollReader('bottom') : (workspace.cursor = Math.max(workspace.nodes.length - 1, 0))), open: (key) => { const enter = key === 'Enter' || key === 'Shift-Enter' if (enter && reading() && hooks.fileAction()) return // Shift-Enter, like `L`: beside what is open, in a tile of its own. if (key === 'Shift-Enter' && !reading()) return guard(() => workspace.openBeside()) const shown = workspace.tiles.findIndex((tile) => tile.open && tile.open.id === workspace.selected?.id) if (shown >= 0) void focusTile(shown) else guard(() => workspace.openSelected()) }, beside: () => { if (!reading()) guard(() => workspace.openBeside()) }, // Leftwards at every depth: from the document to the list, from the // list up a level. left: () => { if (workspace.open && workspace.pane === 'document') focusPane('list') else guard(() => workspace.up()) }, // `gt` / `gT` walk the tiles, as they walk vim's tab pages. nextTile: () => void cycleTile(1), previousTile: () => void cycleTile(-1), sideways: (key) => { const amount = ({ zh: -1, zl: 1, zH: 'halfleft', zL: 'halfright' } as const)[key as 'zh' | 'zl' | 'zH' | 'zL'] if (!reading() || !hooks.scrollWide(amount)) workspace.say('nothing wider than the column in view', 'error') }, // Past the last heading there is still the end, as in vim. nextHeading: () => { if (reading() && !hooks.jumpHeading(1)) hooks.scrollReader('bottom') }, previousHeading: () => { if (reading() && !hooks.jumpHeading(-1)) hooks.scrollReader('top') }, outline: () => void runCommand('outline'), history: () => void runCommand('history'), backlinks: () => void runCommand('backlinks'), inTab: () => openInTab(), // `f` already fills the screen, so the hints vimium puts on `f` live on // its other key. hints: () => { const root = reading() ? hooks.hintRoot() : null if (root) void showHints(root) }, // `[d` / `]d`: the diary entry of the day before or after, by its date. previousEntry: () => guard(() => workspace.stepEntry(-1)), nextEntry: () => guard(() => workspace.stepEntry(1)), // Pick nodes up here, walk to a folder, put them down with `p`. mark: () => workspace.mark(), markAll: () => workspace.markAll(), drop: () => guard(() => workspace.drop()), new: () => void runCommand('new'), // Capture: a thought goes on this space's inbox without leaving here. capture: () => line(':card '), // The name has to come from somewhere, and the command line is where // names are typed — this only saves typing `:mkdir `. mkdir: () => line(':mkdir '), edit: () => void runCommand('edit'), // The board is a view of the space, not a row in the list, so it needs // a key as plain as the one that opens a document. board: () => void runCommand('board'), // Renaming starts from the name it has, so a typo is a fix, not a retype. rename: () => { const target = workspace.target const current = target && target.id === workspace.open?.id ? workspace.draft.name : (target?.name ?? '') line(`:name ${current.trim()}`) }, close: () => { if (workspace.open) void runCommand('q') else workspace.unmarkAll() }, delete: () => void runCommand('d'), // A delete goes to the trash, so it can be undone the way vim undoes. undo: () => guard(() => workspace.undoDelete()), // Sharing is a key away; taking a link back from its readers asks first. share: () => void (async () => { const target = workspace.target if (target?.shared && !(await workspace.ask(`stop sharing "${target.name.trim() || target.slug}"? the link stops working`))) return void runCommand(target?.shared ? 'unshare' : 'share') })(), // A vim hand presses y to yank; here it retires the link readers have, // so it asks first. link: () => void (async () => { const target = workspace.target if (!target?.shared) return void runCommand('link') if (await workspace.ask(`new share link for "${target.name.trim() || target.slug}"? the current one stops working`)) void runCommand('link') })(), // Through the results, as vim's `n` goes through the matches; the // search itself stays until `:noh`. next: () => guard(() => workspace.nextResult(1)), previous: () => guard(() => workspace.nextResult(-1)), search: () => line('/'), }, /* * The board reads the same keys as the list, one dimension wider: h/l walk * the lists, j/k the cards, and the shifted pair takes the card along. * Nothing falls through to the tree keys, because they would act on a tree * that is not on screen. */ board: { left: () => workspace.moveList(-1), // Right of the last list is the agenda, the tile beside the board. right: () => { if (workspace.listCursor >= (workspace.board?.lists.length ?? 0) - 1) workspace.specialFocus = 'agenda' else workspace.moveList(1) }, down: () => workspace.moveCard(1), up: () => workspace.moveCard(-1), top: () => void (workspace.cardCursor = 0), bottom: () => void (workspace.cardCursor = Math.max((workspace.list?.cards.length ?? 0) - 1, 0)), nudgeDown: () => guard(() => workspace.nudgeCard(1)), nudgeUp: () => guard(() => workspace.nudgeCard(-1)), sendLeft: () => guard(() => workspace.sendCard(-1)), sendRight: () => guard(() => workspace.sendCard(1)), done: () => guard(() => workspace.toggleDone()), turn: () => workspace.openCardSheet(), // The card's notes, without turning it over first: `gf` as on its back. notes: () => guard(() => workspace.openCard()), new: () => line(':card '), due: () => line(':due '), // On an empty list there is no card to rename, so it is the list. The // name is there to be edited, not typed again from nothing. rename: () => line(workspace.card ? `:name ${workspace.card.title}` : `:name! ${workspace.list?.name ?? ''}`), old: () => void (workspace.showOldDone = !workspace.showOldDone), // A letter over every card in view: typing it puts the cursor there. hints: () => { const root = hooks.boardRoot() if (root) void showHints(root) }, reload: () => guard(() => workspace.reloadBoard()), delete: () => void runCommand('delete'), leave: () => workspace.closeSpecial(), }, /* * The agenda: j/k down the list, Enter to the card, Space finishes it. * Past the cards the cursor walks on into what was written on this day, * and past that into the calendar under it; `h` is the board to its left. */ agenda: { down: () => { const last = Math.max(workspace.agendaCards.length + workspace.agenda.on_this_day.length - 1, 0) if (workspace.agendaCursor >= last) workspace.specialFocus = 'calendar' else workspace.agendaCursor += 1 }, up: () => void (workspace.agendaCursor = Math.max(workspace.agendaCursor - 1, 0)), left: () => void (workspace.specialFocus = 'board'), open: () => { const card = workspace.agendaCards[workspace.agendaCursor] const past = workspace.agenda.on_this_day[workspace.agendaCursor - workspace.agendaCards.length] if (card) guard(() => workspace.goToCard(card)) // Opened from the special workspace, so `q` on it comes back here. else if (past) guard(() => workspace.openPath(past.path)) }, finish: () => { const card = workspace.agendaCards[workspace.agendaCursor] if (card) guard(() => workspace.finishCard(card)) }, due: () => { if (workspace.agendaCards[workspace.agendaCursor]) line(':due ') }, line: () => line(':'), sendLeft: () => sendAgendaCard(-1), sendRight: () => sendAgendaCard(1), today: () => void runCommand('today'), leave: () => workspace.closeSpecial(), }, /** The calendar: h/l a day, j/k a week, H/L a month, Enter to the day. */ calendar: { day: (key) => guard(() => workspace.moveCalendar(key === 'h' || key === 'ArrowLeft' ? -1 : 1, 0)), week: (key) => guard(() => workspace.moveCalendar(key === 'k' || key === 'ArrowUp' ? -7 : 7, 0)), month: (key) => guard(() => workspace.moveCalendar(0, key === 'H' ? -1 : 1)), today: () => { workspace.calendarDay = formatDay(Math.floor(Date.now() / 1000)) guard(() => workspace.loadCalendar()) }, open: () => void workspace.guard(() => workspace.openCalendarDay()).then(() => hooks.focusEditor()), leave: () => workspace.closeSpecial(), }, special: { // Tab walks the tiles here, as it walks the spaces everywhere else; the // digits still switch space, and the board with it. cycle: (key) => workspace.cycleSpecial(shifted(key) ? -1 : 1), board: () => void runCommand('board'), leave: () => workspace.closeSpecial(), }, global: { // Ctrl-P: find anything, in any space — the one search that is not // confined to where you are. find: () => hooks.openGlobalSearch(), // Ctrl-D / Ctrl-U page the open document, as they do in a pager. halfDown: () => (workspace.open && !workspace.editing ? void hooks.scrollReader('halfdown') : false), halfUp: () => (workspace.open && !workspace.editing ? void hooks.scrollReader('halfup') : false), /* * Spaces are workspaces: digits jump to one, shifted digits send the node * there, Tab walks along the bar. Digits are read from `event.code`, so * Shift+1 means space 1 whatever symbol the layout puts on that key. */ space: (key) => guard(() => workspace.switchSpace(spaceIndex(key))), send: (key) => guard(() => workspace.sendToSpace(spaceIndex(key))), cycle: (key) => guard(() => workspace.cycleSpace(shifted(key) ? -1 : 1)), overview: () => void openOverview(), fullscreen: () => void (workspace.fullscreen = !workspace.fullscreen), // The agenda and today's entry are a key away from anywhere, the board // included: they are the two questions asked most. agenda: () => void runCommand('agenda'), today: () => void runCommand('today'), calendar: () => void runCommand('cal'), // `S`, as the desktop's special workspace is bound: the board, the // agenda and the calendar, put up and away again. `b`, `t` and `C` only // ever go to their tile. special: () => void runCommand('special'), line: () => line(':'), help: () => void runCommand('help'), }, editor: {}, } function goToSpace(index: number) { const entry = workspace.overview[index] if (!entry) return workspace.overlay = 'none' guard(() => workspace.enterSpace(entry.space.id)) } function sendAgendaCard(by: 1 | -1) { const card = workspace.agendaCards[workspace.agendaCursor] if (card) guard(() => workspace.sendAgendaCard(card, by)) }