diff --git a/.github/workflows/wiki.yml b/.github/workflows/wiki.yml index e8131c6..665b5ba 100644 --- a/.github/workflows/wiki.yml +++ b/.github/workflows/wiki.yml @@ -14,6 +14,7 @@ on: - .env.example - web/src/lib/commands.ts - web/src/lib/Help.svelte + - web/src/lib/keymap.ts - scripts/wiki.ts - .github/workflows/wiki.yml workflow_dispatch: diff --git a/docs/wiki/Commands.md b/docs/wiki/Commands.md index 2c4e1b2..c9b0658 100644 --- a/docs/wiki/Commands.md +++ b/docs/wiki/Commands.md @@ -1,4 +1,4 @@ - + # Keys and commands @@ -87,6 +87,24 @@ The first list is the inbox, the last list is done. | `x · dd` | delete the card (asks y/N) | | `q · Esc` | leave the special workspace | +### On the back of a card + +| key | what it does | +| --- | --- | +| `j / k` | down / up the checklist | +| `gg / G` | first / last step | +| `Space · x` | tick the step, or untick it | +| `o` | a new step below the cursor — :task on the line | +| `r` | reword the step | +| `J / K` | move the step down / up | +| `dd` | drop the step | +| `e · i · Enter` | write the description, in the editor — :w writes it, :q or Esc in normal mode leaves | +| `a` | put on a label, or take it off — :label on the line, Tab offers the board's | +| `R · D` | rename the card, its due date | +| `H / L` | send the card to the list left / right | +| `gf` | the card's notes — a document, made on first use; q comes back | +| `q · Esc` | back to the board | + ### In the agenda | key | what it does | @@ -101,6 +119,18 @@ The first list is the inbox, the last list is done. | `T` | today's diary entry | | `q · Esc` | leave the special workspace — a document opened from it comes back here on q | +### In the scratchpad + +| key | what it does | +| --- | --- | +| `Q` | lower the scratchpad over whatever is on screen, or raise it again — :scratch takes a note without it | +| `j / k` | down / up the notes, the one touched last on top | +| `o` | a new note, written straight away — it saves itself | +| `Enter · e · i` | write the note — Esc in normal mode, or :q, comes back to the notes | +| `K` | keep the note: a document in the folder being browsed, titled by its first line — :keep names it | +| `dd` | drop the note (asks y/N) | +| `q · Esc · Q` | raise the scratchpad | + ## Commands A command takes a `!` for its forcing variant where one exists, the way vim's diff --git a/docs/wiki/Development.md b/docs/wiki/Development.md index e91aeab..aa260d0 100644 --- a/docs/wiki/Development.md +++ b/docs/wiki/Development.md @@ -28,7 +28,8 @@ real data stays out of it. | `src/vault.rs` | the vault's keys and file format | | `src/sweep.rs` | the daily clean-up: trash, old versions, unreferenced uploads | | `migrations/` | the SQLite schema, one numbered file per change | -| `web/src/App.svelte` | the keys | +| `web/src/lib/keymap.ts` | every key: where it is heard, what it is called, its help line | +| `web/src/lib/actions.ts`, `modes.ts` | what each key does, and which contexts hear it first | | `web/src/lib/store.svelte.ts` | the client's state, and every call to the API | | `web/src/lib/commands.ts` | the `:` commands | | `web/src/lib/Help.svelte` | the `?` screen | @@ -78,7 +79,7 @@ Three pages are generated by `scripts/wiki.ts`: | page | read from | | --- | --- | -| [Keys and commands](Commands.md) | `web/src/lib/Help.svelte` and `web/src/lib/commands.ts` | +| [Keys and commands](Commands.md) | `web/src/lib/keymap.ts` and `web/src/lib/commands.ts` | | [Configuration](Configuration.md) | `.env.example` | | [API](API.md) | the API section of `README.md` | diff --git a/scripts/wiki.ts b/scripts/wiki.ts index f5713b1..148dfd7 100644 --- a/scripts/wiki.ts +++ b/scripts/wiki.ts @@ -4,8 +4,8 @@ * docs/wiki holds the pages. Three of them are not written by hand but read * out of the code, so they cannot drift from it: * - * - Commands.md, from the keys in Help.svelte and the commands in commands.ts, - * the same lists `:help` draws; + * - Commands.md, from the keymap in keymap.ts and the commands in + * commands.ts, the same lists `:help` draws; * - Configuration.md, from the comments and values in .env.example; * - API.md, from the API section of README.md. * @@ -17,6 +17,7 @@ */ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' +import { rows, sections, type Section } from '../web/src/lib/keymap' const root = resolve(import.meta.dir, '..') const pages = join(root, 'docs/wiki') @@ -45,17 +46,11 @@ const code = (text: string) => (text.includes('`') ? `<code>${cell(text).replace // --- Commands.md --------------------------------------------------------------- -function keyTable(source: string, name: string): string { - const start = source.indexOf(`const ${name}: [string, string][] = [`) - if (start < 0) throw new Error(`Help.svelte has no ${name}`) - const body = source.slice(start, source.indexOf('\n ]', start)) - const pairs = [...body.matchAll(new RegExp(`\\[${STRING}, ${STRING}\\]`, 'g'))].map(([, key, description]) => [unquote(key), unquote(description)]) - if (pairs.length === 0) throw new Error(`Help.svelte: ${name} is empty`) - // `rule` is not a key but the sentence the help column starts with. - const rule = pairs.find(([key]) => key === 'rule') - const rows = pairs.filter(([key]) => key !== 'rule').map(([key, description]) => `| ${code(key)} | ${cell(description)} |`) - const table = ['| key | what it does |', '| --- | --- |', ...rows].join('\n') - return rule ? `${rule[1][0].toUpperCase()}${rule[1].slice(1)}.\n\n${table}` : table +function keyTable(section: Section): string { + const entry = sections.find((candidate) => candidate.section === section)! + const table = ['| key | what it does |', '| --- | --- |', ...rows(section).map(({ key, description }) => `| ${code(key)} | ${cell(description)} |`)].join('\n') + // A rule is not a key but the sentence the help column starts with. + return entry.rule ? `${entry.rule[0].toUpperCase()}${entry.rule.slice(1)}.\n\n${table}` : table } interface Spec { @@ -116,7 +111,7 @@ function commandsPage(): string { }) return ( - generated('web/src/lib/Help.svelte and web/src/lib/commands.ts') + + generated('web/src/lib/keymap.ts and web/src/lib/commands.ts') + `# Keys and commands Everything here is also on \`?\` (\`:help\`) inside the app, which draws the same @@ -129,15 +124,23 @@ arguments and \`↑\` / \`↓\` recall earlier lines. ### Browsing and reading -${keyTable(help, 'keys')} +${keyTable('keys')} ### On a board -${keyTable(help, 'boardKeys')} +${keyTable('board')} + +### On the back of a card + +${keyTable('card')} ### In the agenda -${keyTable(help, 'agendaKeys')} +${keyTable('agenda')} + +### In the scratchpad + +${keyTable('scratch')} ## Commands diff --git a/web/src/App.svelte b/web/src/App.svelte index fdf5783..1940baf 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1,5 +1,8 @@ <script lang="ts"> - import { cycleTile, focusTile, followLink, hooks, runCommand } from './lib/commands' + import { openOverview } from './lib/actions' + import { hooks } from './lib/commands' + import { keysOf } from './lib/keymap' + import { dispatch } from './lib/modes' import { embedSnippet } from './lib/markdown' import Agenda from './lib/Agenda.svelte' import Calendar from './lib/Calendar.svelte' @@ -9,17 +12,17 @@ import DocumentPane from './lib/DocumentPane.svelte' import FilePane from './lib/FilePane.svelte' import Headings from './lib/Headings.svelte' - import Help, { focusHelpFilter, scrollHelp } from './lib/Help.svelte' + import Help from './lib/Help.svelte' import Hints from './lib/Hints.svelte' import Verify from './lib/Verify.svelte' import Login from './lib/Login.svelte' import Overview from './lib/Overview.svelte' - import MediaBrowser, { copyMedia, insertMedia, removeMedia } from './lib/MediaBrowser.svelte' - import Trash, { purgeTrashed, restoreTrashed } from './lib/Trash.svelte' - import Backlinks, { openBacklink } from './lib/Backlinks.svelte' + import MediaBrowser from './lib/MediaBrowser.svelte' + import Trash from './lib/Trash.svelte' + import Backlinks from './lib/Backlinks.svelte' import CardSheet from './lib/CardSheet.svelte' import Scratch from './lib/Scratch.svelte' - import History, { moveHistory, restoreShown } from './lib/History.svelte' + import History from './lib/History.svelte' import Actions from './lib/Actions.svelte' import Prompt from './lib/Prompt.svelte' import SecretPrompt from './lib/SecretPrompt.svelte' @@ -27,8 +30,8 @@ import Shared from './lib/Shared.svelte' import Sidebar from './lib/Sidebar.svelte' import StatusBar from './lib/StatusBar.svelte' - import { workspace, type SpecialTile } from './lib/store.svelte' - import { dwindle, formatDay, neighbour, type Direction } from './lib/util' + import { workspace } from './lib/store.svelte' + import { dwindle } from './lib/util' /* * A share link is /s/<token>#<key>. The fragment never leaves the browser, so @@ -93,6 +96,14 @@ cmdGlobal = true cmdline = '/' } + hooks.closeCommandLine = () => { + if (cmdline === null) return false + cmdline = null + shell?.focus() + return true + } + hooks.showHints = (root) => (hintRoot = root) + hooks.boardRoot = () => boardEl ?? null }) /* @@ -217,651 +228,19 @@ // What the empty pane offers, in the key/value shape of a status readout. // Keys are keycaps, so `o` and `O` cannot be read as the same key. const offers: [string[], string][] = [ - [['o'], 'write a new document'], - [['⇧ O'], 'make a folder'], - [['j', 'k'], 'browse, l opens, L opens beside'], - [['1…9'], 'switch space, Tab cycles'], - [['w'], 'overview of every space'], - [['S'], 'board, agenda and calendar'], - [['b', 't', 'C'], 'straight to one of them'], - [['c'], 'capture a card to the inbox'], - [['⌃ P'], 'find in every space'], - [['/'], 'search this space'], - [['?'], 'help'], + [keysOf('tree', 'new'), 'write a new document'], + [keysOf('tree', 'mkdir'), 'make a folder'], + [[...keysOf('tree', 'down'), ...keysOf('tree', 'up')], 'browse, l opens, L opens beside'], + [keysOf('global', 'space'), 'switch space, Tab cycles'], + [keysOf('global', 'overview'), 'overview of every space'], + [keysOf('global', 'special'), 'board, agenda and calendar'], + [[...keysOf('tree', 'board'), ...keysOf('global', 'agenda'), ...keysOf('global', 'calendar')], 'straight to one of them'], + [keysOf('tree', 'capture'), 'capture a card to the inbox'], + [keysOf('global', 'find'), 'find in every space'], + [keysOf('tree', 'search'), 'search this space'], + [keysOf('global', 'help'), 'help'], ] - /* - * Spaces are workspaces: digits jump to one, shifted digits send the node - * there, Tab walks along the bar. `event.code` rather than `event.key`, so - * Shift+1 means space 1 whatever symbol the layout puts on that key. - */ - function spaceKey(event: KeyboardEvent): boolean { - const digit = /^Digit(\d)$/.exec(event.code)?.[1] - if (digit !== undefined) { - const index = digit === '0' ? 9 : Number(digit) - 1 - void workspace.guard(() => - event.shiftKey ? workspace.sendToSpace(index) : workspace.switchSpace(index), - ) - return true - } - if (event.key === 'Tab') { - void workspace.guard(() => workspace.cycleSpace(event.shiftKey ? -1 : 1)) - return true - } - if (event.key === 'w') { - void openOverview() - return true - } - return false - } - - 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' - } - - /** The overview's own keys: hjkl across the grid, a digit or Enter to go. */ - function overviewKey(event: KeyboardEvent): boolean { - const count = workspace.overview.length - const columns = hooks.overviewColumns() - const go = (index: number) => { - const entry = workspace.overview[index] - if (!entry) return - workspace.overlay = 'none' - void workspace.guard(() => workspace.enterSpace(entry.space.id)) - } - const digit = /^Digit(\d)$/.exec(event.code)?.[1] - if (digit !== undefined) { - go(digit === '0' ? 9 : Number(digit) - 1) - return true - } - const step: Record<string, number> = { h: -1, ArrowLeft: -1, l: 1, ArrowRight: 1, k: -columns, ArrowUp: -columns, j: columns, ArrowDown: columns } - if (event.key in step) { - workspace.overviewCursor = Math.min(Math.max(workspace.overviewCursor + step[event.key], 0), count - 1) - return true - } - if (event.key === 'Enter') { - go(workspace.overviewCursor) - return true - } - if (event.key === 'w' || event.key === 'q') { - workspace.overlay = 'none' - return true - } - return false - } - - /** - * The agenda's keys: 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. - */ - function agendaKey(event: KeyboardEvent): boolean { - const cards = workspace.agendaCards - const card = cards[workspace.agendaCursor] - const past = workspace.agenda.on_this_day[workspace.agendaCursor - cards.length] - switch (event.key) { - case 'j': - case 'ArrowDown': { - const last = Math.max(cards.length + workspace.agenda.on_this_day.length - 1, 0) - if (workspace.agendaCursor >= last) workspace.specialFocus = 'calendar' - else workspace.agendaCursor += 1 - return true - } - case 'k': - case 'ArrowUp': - workspace.agendaCursor = Math.max(workspace.agendaCursor - 1, 0) - return true - case 'Enter': - if (card) void workspace.guard(() => workspace.goToCard(card)) - // Opened from the special workspace, so `q` on it comes back here. - else if (past) void workspace.guard(() => workspace.openPath(past.path)) - return true - case ' ': - if (card) void workspace.guard(() => workspace.finishCard(card)) - return true - case 'D': - // The agenda stays up behind the line, so `:due` knows which card. - if (card) { - cmdGlobal = false - cmdline = ':due ' - } - return true - case ':': - // And so does `:repeat`, and any other command that asks for it. - cmdGlobal = false - cmdline = ':' - return true - case 'H': - case 'L': - if (card) void workspace.guard(() => workspace.sendAgendaCard(card, event.key === 'H' ? -1 : 1)) - return true - case 'T': - void runCommand('today') - return true - case 'h': - case 'ArrowLeft': - workspace.specialFocus = 'board' - return true - case 'q': - workspace.closeSpecial() - return true - default: - return false - } - } - - /** The calendar's keys: h/l a day, j/k a week, H/L a month, Enter to the day. */ - function calendarKey(event: KeyboardEvent): boolean { - const step: Record<string, [number, number]> = { - h: [-1, 0], ArrowLeft: [-1, 0], l: [1, 0], ArrowRight: [1, 0], - k: [-7, 0], ArrowUp: [-7, 0], j: [7, 0], ArrowDown: [7, 0], - H: [0, -1], L: [0, 1], - } - if (event.key in step) { - const [days, months] = step[event.key] - void workspace.guard(() => workspace.moveCalendar(days, months)) - return true - } - switch (event.key) { - case '.': - workspace.calendarDay = formatDay(Math.floor(Date.now() / 1000)) - void workspace.guard(() => workspace.loadCalendar()) - return true - case 'Enter': - void workspace.guard(() => workspace.openCalendarDay()).then(() => hooks.focusEditor()) - return true - case 'q': - workspace.closeSpecial() - return true - default: - return false - } - } - - /** - * The help's keys: its 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. - */ - function helpKey(event: KeyboardEvent): boolean { - if (event.ctrlKey) { - if (event.key !== 'd' && event.key !== 'u') return false - scrollHelp(event.key === 'd' ? 'pagedown' : 'pageup') - return true - } - switch (event.key) { - case 'l': - case 'j': - case 'ArrowRight': - case 'ArrowDown': - scrollHelp(1) - return true - case 'h': - case 'k': - case 'ArrowLeft': - case 'ArrowUp': - scrollHelp(-1) - return true - case ' ': - scrollHelp(event.shiftKey ? 'pageup' : 'pagedown') - return true - case 'g': - scrollHelp('start') - return true - case 'G': - scrollHelp('end') - return true - case '/': - focusHelpFilter() - return true - default: - return false - } - } - - /** `gO`'s list: j/k down the headings, Enter to the one picked. */ - function outlineKey(event: KeyboardEvent): boolean { - const last = Math.max(workspace.outline.length - 1, 0) - switch (event.key) { - case 'j': - case 'ArrowDown': - workspace.outlineCursor = Math.min(workspace.outlineCursor + 1, last) - return true - case 'k': - case 'ArrowUp': - workspace.outlineCursor = Math.max(workspace.outlineCursor - 1, 0) - return true - case 'G': - workspace.outlineCursor = last - return true - case 'Enter': - case 'l': { - const heading = workspace.outline[workspace.outlineCursor] - workspace.overlay = 'none' - heading?.go() - return true - } - default: - return false - } - } - - /** The media list's keys: j/k down the files, i inserts, y copies, x deletes. */ - function mediaKey(event: KeyboardEvent): boolean { - const files = workspace.media - const last = Math.max(files.length - 1, 0) - const file = files[workspace.mediaCursor] - switch (event.key) { - case 'j': - case 'ArrowDown': - workspace.mediaCursor = Math.min(workspace.mediaCursor + 1, last) - return true - case 'k': - case 'ArrowUp': - workspace.mediaCursor = Math.max(workspace.mediaCursor - 1, 0) - return true - case 'G': - workspace.mediaCursor = last - return true - case 'Enter': - case 'i': - if (file) insertMedia(file) - return true - case 'y': - if (file) void copyMedia(file) - return true - case 'x': - if (file) void removeMedia(file) - return true - default: - return false - } - } - - /** The trash's keys: j/k down what was deleted, Enter or r restores, x deletes for good. */ - function trashKey(event: KeyboardEvent): boolean { - const items = workspace.trash - const last = Math.max(items.length - 1, 0) - const item = items[workspace.trashCursor] - switch (event.key) { - case 'j': - case 'ArrowDown': - workspace.trashCursor = Math.min(workspace.trashCursor + 1, last) - return true - case 'k': - case 'ArrowUp': - workspace.trashCursor = Math.max(workspace.trashCursor - 1, 0) - return true - case 'G': - workspace.trashCursor = last - return true - case 'Enter': - case 'r': - if (item) void restoreTrashed(item) - return true - case 'x': - if (item) void purgeTrashed(item) - return true - default: - return false - } - } - - /** The backlinks' keys: j/k through the documents that link here, Enter opens one. */ - function backlinksKey(event: KeyboardEvent): boolean { - const last = Math.max(workspace.backlinks.length - 1, 0) - switch (event.key) { - case 'j': - case 'ArrowDown': - workspace.backlinksCursor = Math.min(workspace.backlinksCursor + 1, last) - return true - case 'k': - case 'ArrowUp': - workspace.backlinksCursor = Math.max(workspace.backlinksCursor - 1, 0) - return true - case 'G': - workspace.backlinksCursor = last - return true - case 'Enter': { - const link = workspace.backlinks[workspace.backlinksCursor] - if (link) void openBacklink(link) - return true - } - default: - return false - } - } - - /** The history's keys: j/k through the versions, Enter brings the one shown back. */ - function historyKey(event: KeyboardEvent): boolean { - switch (event.key) { - case 'j': - case 'ArrowDown': - moveHistory(1) - return true - case 'k': - case 'ArrowUp': - moveHistory(-1) - return true - case 'G': - moveHistory(workspace.history.length) - return true - case 'Enter': - void restoreShown() - return true - default: - return false - } - } - - /* - * `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) hintRoot = 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) - } - - /* - * 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. - */ - /* - * 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<SpecialTile, Partial<Record<Direction, SpecialTile>>> = { - board: { l: 'agenda' }, - agenda: { h: 'board', j: 'calendar' }, - calendar: { h: 'board', k: 'agenda' }, - } - - 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(layout, 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 - } - - /** Alt+h / Alt+l: 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 shell?.focus() - } - - /* - * 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. - */ - function boardKey(key: string): boolean { - switch (key) { - case 'h': - case 'ArrowLeft': - workspace.moveList(-1) - return true - case 'l': - case 'ArrowRight': - // Right of the last list is the agenda, the tile beside the board. - if (workspace.listCursor >= (workspace.board?.lists.length ?? 0) - 1) workspace.specialFocus = 'agenda' - else workspace.moveList(1) - return true - case 'j': - case 'ArrowDown': - workspace.moveCard(1) - return true - case 'k': - case 'ArrowUp': - workspace.moveCard(-1) - return true - case 'J': - void workspace.guard(() => workspace.nudgeCard(1)) - return true - case 'K': - void workspace.guard(() => workspace.nudgeCard(-1)) - return true - case 'H': - void workspace.guard(() => workspace.sendCard(-1)) - return true - case 'L': - void workspace.guard(() => workspace.sendCard(1)) - return true - case 'G': - workspace.cardCursor = Math.max((workspace.list?.cards.length ?? 0) - 1, 0) - return true - case 'Enter': - workspace.openCardSheet() - return true - case 'o': - case 'c': - cmdline = ':card ' - return true - case 'R': - // 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. - cmdline = workspace.card ? `:name ${workspace.card.title}` : `:name! ${workspace.list?.name ?? ''}` - return true - case 'D': - cmdline = ':due ' - return true - case 'z': - workspace.showOldDone = !workspace.showOldDone - return true - case 'F': - // A letter over every card in view: typing it puts the cursor there. - if (boardEl) void showHints(boardEl) - return true - case ' ': - void workspace.guard(() => workspace.toggleDone()) - return true - case 'x': - void runCommand('delete') - return true - case 'r': - void workspace.guard(() => workspace.reloadBoard()) - return true - case 'q': - case 'Escape': - workspace.closeSpecial() - return true - default: - return false - } - } - - /* - * 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. - */ - function cardKey(event: KeyboardEvent): boolean { - const key = event.key - const previous = pending - pending = '' - if (previous === 'g' && key === 'g') return void workspace.moveTask(-Infinity), true - if (previous === 'g' && key === 'f') { - workspace.overlay = 'none' - void workspace.guard(() => workspace.openCard()) - return true - } - if (previous === 'd' && key === 'd') return void workspace.guard(() => workspace.deleteTask()), true - switch (key) { - case 'g': - case 'd': - pending = key - return true - case 'j': - case 'ArrowDown': - void workspace.moveTask(1) - return true - case 'k': - case 'ArrowUp': - void workspace.moveTask(-1) - return true - case 'G': - void workspace.moveTask(Infinity) - return true - case 'J': - void workspace.guard(() => workspace.nudgeTask(1)) - return true - case 'K': - void workspace.guard(() => workspace.nudgeTask(-1)) - return true - case ' ': - case 'x': - void workspace.guard(() => workspace.toggleTask()) - return true - case 'o': - cmdline = ':task ' - return true - case 'r': { - const task = workspace.card?.checklist[workspace.taskCursor] - if (task) cmdline = `:task! ${task.text}` - return true - } - case 'e': - case 'i': - case 'Enter': - workspace.describe() - return true - case 'a': - cmdline = ':label ' - return true - case 'R': - cmdline = `:name ${workspace.card?.title ?? ''}` - return true - case 'D': - cmdline = ':due ' - return true - case 'H': - void workspace.guard(() => workspace.sendCard(-1)) - return true - case 'L': - void workspace.guard(() => workspace.sendCard(1)) - return true - case ':': - cmdGlobal = false - cmdline = ':' - return true - case 'q': - case 'Escape': - workspace.closeCardSheet() - return true - default: - return false - } - } - - /* - * The scratchpad's keys, 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. - */ - function scratchKey(event: KeyboardEvent): boolean { - const key = event.key - const previous = pending - pending = '' - const last = Math.max(workspace.scratchNotes.length - 1, 0) - if (previous === 'g' && key === 'g') return (workspace.scratchCursor = 0), true - if (previous === 'd' && key === 'd') { - const note = workspace.scratchNote - if (note) { - void (async () => { - if (await workspace.ask('drop this scratch note?')) await workspace.guard(() => workspace.dropScratchNote()) - })() - } - return true - } - switch (key) { - case 'g': - case 'd': - pending = key - return true - case 'j': - case 'ArrowDown': - workspace.scratchCursor = Math.min(workspace.scratchCursor + 1, last) - return true - case 'k': - case 'ArrowUp': - workspace.scratchCursor = Math.max(workspace.scratchCursor - 1, 0) - return true - case 'G': - workspace.scratchCursor = last - return true - case 'o': - case 'O': - case 'c': - workspace.newScratchNote() - return true - case 'e': - case 'i': - case 'a': - case 'Enter': - workspace.editScratchNote() - return true - case 'K': - void workspace.guard(() => workspace.keepScratchNote()) - return true - case ':': - cmdGlobal = false - cmdline = ':' - return true - case '?': - void runCommand('help') - return true - case 'Q': - case 'q': - case 'Escape': - void workspace.closeScratch() - return true - default: - // Nothing under the drawer hears a key while it is down. - return true - } - } - function isTypingTarget(target: EventTarget | null) { const node = target as HTMLElement | null return ( @@ -874,488 +253,7 @@ } function onkeydown(event: KeyboardEvent) { - if (event.key === 's' && (event.metaKey || event.ctrlKey)) { - event.preventDefault() - if (workspace.scratchEditing) void workspace.guard(() => workspace.saveScratchNote()) - else if (workspace.describing) void workspace.guard(() => workspace.saveDescription()) - else if (workspace.open) void workspace.guard(() => workspace.save()) - return - } - - // `event.code`, so Alt-Shift-h is the same key on every layout, and on a - // Mac, where Alt turns h into ˙. - const alt = event.altKey && !event.ctrlKey && !event.metaKey ? /^Key([HJKLQ])$/.exec(event.code)?.[1].toLowerCase() : undefined - if (alt === 'q' && !event.shiftKey) { - event.preventDefault() - void runCommand('close') - return - } - if (alt && alt !== 'q') { - event.preventDefault() - tileKey(alt as Direction, event.shiftKey) - return - } - - // The command line over an overlay — `D` in the agenda — keeps its keys. - const listening = cmdline === null && !isTypingTarget(event.target) - // `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. - if (event.key === 'Q' && listening && !workspace.scratchOpen && workspace.overlay === 'none' && !event.ctrlKey && !event.metaKey && !event.altKey) { - event.preventDefault() - void workspace.guard(() => workspace.toggleScratch()) - return - } - if (workspace.scratchOpen && !workspace.scratchEditing && workspace.overlay === 'none' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) { - if (scratchKey(event)) { - event.preventDefault() - return - } - } - if (workspace.overlay === 'card' && !workspace.describing && listening && !event.ctrlKey && !event.metaKey && !event.altKey) { - if (cardKey(event)) { - event.preventDefault() - return - } - } - if (workspace.overlay === 'help' && listening && !event.metaKey && !event.altKey) { - if (helpKey(event)) { - event.preventDefault() - return - } - } - if (workspace.overlay === 'overview' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) { - if (overviewKey(event)) { - event.preventDefault() - return - } - } - // The special workspace's agenda and calendar read their own keys while - // they have focus; what they leave falls through to the rest below. - const special = workspace.overlay === 'none' && workspace.view === 'special' && listening && !event.ctrlKey && !event.metaKey && !event.altKey - if (special && workspace.specialFocus === 'agenda') { - if (agendaKey(event)) { - event.preventDefault() - return - } - } - if (workspace.overlay === 'media' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) { - if (mediaKey(event)) { - event.preventDefault() - return - } - } - if (workspace.overlay === 'history' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) { - if (historyKey(event)) { - event.preventDefault() - return - } - } - if (workspace.overlay === 'backlinks' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) { - if (backlinksKey(event)) { - event.preventDefault() - return - } - } - if (workspace.overlay === 'trash' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) { - if (trashKey(event)) { - event.preventDefault() - return - } - } - if (special && workspace.specialFocus === 'calendar') { - if (calendarKey(event)) { - event.preventDefault() - return - } - } - if (workspace.overlay === 'outline' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) { - if (outlineKey(event)) { - event.preventDefault() - return - } - } - - if (event.key === 'Escape') { - // The description's editor has had its Esc; the sheet stays up under it. - if (workspace.describing || workspace.scratchEditing) return - if (workspace.overlay !== 'none') { - workspace.overlay = 'none' - return - } - if (cmdline !== null) { - cmdline = null - shell?.focus() - return - } - if (workspace.fullscreen && !workspace.editing) { - workspace.fullscreen = false - return - } - } - - if (workspace.overlay !== 'none' && listening) { - if (event.key === 'q' || event.key === '?') { - event.preventDefault() - workspace.overlay = 'none' - } - return - } - - if (cmdline !== null || isTypingTarget(event.target)) return - - // While writing, the status line says the editor is listening. A key that - // lands anywhere else — the focus left behind by a prompt, a click on the - // header — hands the focus back to it instead of walking the list unseen. - if (workspace.editing && workspace.pane === 'document' && !event.ctrlKey && !event.metaKey && !event.altKey) { - event.preventDefault() - hooks.focusEditor() - return - } - - // Ctrl-P: find anything, in any space — the one search that is not - // confined to where you are. - if (event.ctrlKey && !event.metaKey && !event.altKey && event.key === 'p') { - event.preventDefault() - cmdGlobal = true - cmdline = '/' - return - } - - // Ctrl-D / Ctrl-U page the open document, as they do in a pager. - if (event.ctrlKey && !event.metaKey && !event.altKey && (event.key === 'd' || event.key === 'u')) { - if (workspace.open && !workspace.editing) { - event.preventDefault() - hooks.scrollReader(event.key === 'd' ? 'halfdown' : 'halfup') - return - } - } - - if (event.ctrlKey || event.metaKey || event.altKey) return - - const key = event.key - const previous = pending - pending = '' - - // In the special workspace Tab walks its tiles, as it walks the spaces - // everywhere else; the digits still switch space, and the board with it. - if (key === 'Tab' && workspace.view === 'special') { - event.preventDefault() - workspace.cycleSpecial(event.shiftKey ? -1 : 1) - return - } - if (spaceKey(event)) { - event.preventDefault() - return - } - // `gt` / `gT` walk the tiles, as they walk vim's tab pages — before `t` - // and `T` on their own are taken for the agenda and today's entry. - if (previous === 'g' && (key === 't' || key === 'T') && workspace.view !== 'special') { - event.preventDefault() - void cycleTile(key === 't' ? 1 : -1) - return - } - if (key === 'f') { - event.preventDefault() - workspace.fullscreen = !workspace.fullscreen - return - } - // The agenda and today's entry are a key away from anywhere, the board - // included: they are the two questions asked most. - if (key === 't') { - event.preventDefault() - void runCommand('agenda') - return - } - if (key === 'T') { - event.preventDefault() - void runCommand('today') - return - } - if (key === 'C') { - event.preventDefault() - void runCommand('cal') - return - } - // `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. - if (key === 'S') { - event.preventDefault() - void runCommand('special') - return - } - - if (workspace.view === 'special') { - if (workspace.specialFocus !== 'board') { - // The agenda and the calendar have had their keys; nothing reaches - // the board's from them, since it is not the tile with focus. - if (key === 'b') void runCommand('board') - else if (key === 'Escape') workspace.closeSpecial() - else if (key === ':') cmdline = ':' - else if (key === '?') void runCommand('help') - else return - event.preventDefault() - return - } - if (key === 'b') { - event.preventDefault() - return - } - if (previous === 'g' && key === 'g') { - event.preventDefault() - workspace.cardCursor = 0 - return - } - // The card's notes, without turning it over first: `gf` as on its back. - if (previous === 'g' && key === 'f') { - event.preventDefault() - void workspace.guard(() => workspace.openCard()) - return - } - if (previous === 'd' && key === 'd') { - event.preventDefault() - void runCommand('delete') - return - } - if (key === 'g' || key === 'd') { - pending = key - return - } - if (boardKey(key)) { - event.preventDefault() - return - } - if (key === ':' || key === '?') { - event.preventDefault() - if (key === ':') { - cmdGlobal = false - cmdline = ':' - } - else void runCommand('help') - } - return - } - - /* - * 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. - */ - const reading = !!workspace.open && !workspace.editing && workspace.pane === 'document' - - // Two-key sequences: gg and dd; gO, ]] and [[ for the headings of what - // is being read, as in vim. - if (previous === 'g' && key === 'g') { - event.preventDefault() - if (reading) hooks.scrollReader('top') - else workspace.cursor = 0 - return - } - if (previous === 'g' && key === 'O') { - event.preventDefault() - void runCommand('outline') - return - } - if (previous === 'g' && key === 'h') { - event.preventDefault() - void runCommand('history') - return - } - if (previous === 'g' && key === 'b') { - event.preventDefault() - void runCommand('backlinks') - return - } - if (previous === 'g' && key === 'x') { - event.preventDefault() - openInTab() - return - } - - if (previous === 'd' && key === 'd') { - event.preventDefault() - void runCommand('d') - return - } - if (previous === 'z' && ['h', 'l', 'H', 'L'].includes(key)) { - event.preventDefault() - const amount = ({ h: -1, l: 1, H: 'halfleft', L: 'halfright' } as const)[key as 'h' | 'l' | 'H' | 'L'] - if (!reading || !hooks.scrollWide(amount)) workspace.say('nothing wider than the column in view', 'error') - return - } - // `[d` / `]d`: the diary entry of the day before or after, by its date. - if ((previous === '[' || previous === ']') && key === 'd') { - event.preventDefault() - void workspace.guard(() => workspace.stepEntry(previous === '[' ? -1 : 1)) - return - } - if ((previous === ']' && key === ']') || (previous === '[' && key === '[')) { - event.preventDefault() - // Past the last heading there is still the end, as in vim. - if (reading && !hooks.jumpHeading(key === ']' ? 1 : -1)) hooks.scrollReader(key === ']' ? 'bottom' : 'top') - return - } - if (key === 'g' || key === 'd' || key === ']' || key === '[' || key === 'z') { - pending = key - return - } - - switch (key) { - case 'j': - case 'ArrowDown': - event.preventDefault() - if (reading) hooks.scrollReader(1) - else workspace.move(1) - break - case 'k': - case 'ArrowUp': - event.preventDefault() - if (reading) hooks.scrollReader(-1) - else workspace.move(-1) - break - case 'G': - event.preventDefault() - if (reading) hooks.scrollReader('bottom') - else workspace.cursor = Math.max(workspace.nodes.length - 1, 0) - break - case 'Enter': - case 'l': - case 'ArrowRight': { - event.preventDefault() - if (key === 'Enter' && reading && hooks.fileAction()) break - // Shift-Enter, like `L`: beside what is open, in a tile of its own. - if (key === 'Enter' && event.shiftKey && !reading) { - void workspace.guard(() => workspace.openBeside()) - break - } - const shown = workspace.tiles.findIndex((tile) => tile.open && tile.open.id === workspace.selected?.id) - if (shown >= 0) void focusTile(shown) - else void workspace.guard(() => workspace.openSelected()) - break - } - case 'L': - event.preventDefault() - if (!reading) void workspace.guard(() => workspace.openBeside()) - break - case 'F': { - // `f` already fills the screen, so the hints vimium puts on `f` live - // on its other key. - event.preventDefault() - const root = reading ? hooks.hintRoot() : null - if (root) void showHints(root) - break - } - case 'm': - // Pick nodes up here, walk to a folder, put them down with `p`. - event.preventDefault() - workspace.mark() - break - case 'M': - event.preventDefault() - workspace.markAll() - break - case 'p': - event.preventDefault() - void workspace.guard(() => workspace.drop()) - break - case 'o': - event.preventDefault() - void runCommand('new') - break - case 'c': - // Capture: a thought goes on this space's inbox without leaving here. - event.preventDefault() - cmdline = ':card ' - break - case 'O': - // The name has to come from somewhere, and the command line is where - // names are typed — this only saves typing `:mkdir `. - event.preventDefault() - cmdline = ':mkdir ' - break - case 'i': - case 'a': - event.preventDefault() - void runCommand('edit') - break - case 'b': - // 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. - event.preventDefault() - void runCommand('board') - break - case 'R': { - // Renaming needs a name typed, and names are typed on the command - // line — starting from the one it has, so a typo is a fix, not a retype. - event.preventDefault() - const target = workspace.target - const current = target && target.id === workspace.open?.id ? workspace.draft.name : (target?.name ?? '') - cmdline = `:name ${current.trim()}` - break - } - case 'h': - case 'ArrowLeft': - // Leftwards at every depth: from the document to the list, from the - // list up a level. - event.preventDefault() - if (workspace.open && workspace.pane === 'document') focusPane('list') - else void workspace.guard(() => workspace.up()) - break - case 'q': - case 'Escape': - event.preventDefault() - if (workspace.open) void runCommand('q') - else workspace.unmarkAll() - break - case 'x': - event.preventDefault() - void runCommand('d') - break - case 'u': - // A delete goes to the trash, so it can be undone the way vim undoes. - event.preventDefault() - void workspace.guard(() => workspace.undoDelete()) - break - case 's': - // Sharing is a key away; taking a link back from its readers asks first. - event.preventDefault() - 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') - })() - break - case 'y': - // A vim hand presses y to yank; here it retires the link readers have, - // so it asks first. - event.preventDefault() - 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') - })() - break - case 'n': - case 'N': - // Through the results, as vim's `n` goes through the matches; the - // search itself stays until `:noh`. - event.preventDefault() - void workspace.guard(() => workspace.nextResult(key === 'n' ? 1 : -1)) - break - case '/': - event.preventDefault() - cmdGlobal = false - cmdline = '/' - break - case ':': - event.preventDefault() - cmdline = ':' - break - case '?': - event.preventDefault() - void runCommand('help') - break - } + pending = dispatch(event, pending, cmdline !== null, isTypingTarget(event.target)) } </script> diff --git a/web/src/lib/Agenda.svelte b/web/src/lib/Agenda.svelte index e0183a9..022f07a 100644 --- a/web/src/lib/Agenda.svelte +++ b/web/src/lib/Agenda.svelte @@ -5,6 +5,7 @@ * had today yet. The keys live in App.svelte with the others; this draws * and lets a pointer do the same. */ + import Keys from './Keys.svelte' import type { AgendaCard, DiaryEntry } from './api' import { workspace } from './store.svelte' import { runCommand } from './commands' @@ -111,23 +112,7 @@ </section> {/if} - <footer class="label"> - <span class="kbd">j</span><span class="kbd">k</span> move - <span class="gap"></span> - <span class="kbd">↵</span> open - <span class="gap"></span> - <span class="kbd">␣</span> done - <span class="gap"></span> - <span class="kbd">D</span> due - <span class="gap"></span> - <span class="kbd">H</span><span class="kbd">L</span> list - <span class="gap"></span> - <span class="kbd">T</span> diary - <span class="gap"></span> - <span class="kbd">tab</span> tile - <span class="gap"></span> - <span class="kbd">esc</span> leave - </footer> + <Keys contexts={['agenda', 'special']} /> </div> <style> @@ -218,13 +203,7 @@ .clear { margin: 0; font-size: 13px; color: var(--subtle); } - footer { display: flex; align-items: center; gap: 4px; font-size: 10px; } - footer .kbd { margin-right: 2px; } - footer .gap { width: 14px; } - @media (max-width: 767px), (pointer: coarse) { - footer { display: none; } - } @media (max-width: 767px) { .sheet { padding: 14px; gap: 14px; } .where { display: none; } diff --git a/web/src/lib/Backlinks.svelte b/web/src/lib/Backlinks.svelte index f32b372..3ba8076 100644 --- a/web/src/lib/Backlinks.svelte +++ b/web/src/lib/Backlinks.svelte @@ -10,6 +10,7 @@ </script> <script lang="ts"> + import Keys from './Keys.svelte' let sheet = $state<HTMLDivElement>() $effect(() => { @@ -45,13 +46,7 @@ {/each} </ul> - <footer class="label"> - <span class="kbd">j</span><span class="kbd">k</span> move - <span class="gap"></span> - <span class="kbd">enter</span> open - <span class="gap"></span> - <span class="kbd">q</span><span class="kbd">esc</span> close - </footer> + <Keys contexts={['backlinks', 'sheet']} /> </div> <style> @@ -92,10 +87,4 @@ .muted { font-size: 11px; } .context { font-size: 12px; color: var(--subtle); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - footer { display: flex; align-items: center; gap: 4px; font-size: 10px; } - footer .kbd { margin-right: 2px; } - footer .gap { width: 14px; } - @media (max-width: 767px), (pointer: coarse) { - footer { display: none; } - } </style> diff --git a/web/src/lib/Board.svelte b/web/src/lib/Board.svelte index 9d4bb62..25b8681 100644 --- a/web/src/lib/Board.svelte +++ b/web/src/lib/Board.svelte @@ -4,6 +4,7 @@ * idiom as the list. The keys live in App.svelte with every other key, so * this file only draws and — for a pointer — moves the two cursors. */ + import Keys from './Keys.svelte' import { workspace } from './store.svelte' import { formatDay, labelHue, urgency } from './util' @@ -141,20 +142,9 @@ </div> <!-- The board's keys are not the list's, so they are spelled out where they apply. --> - <footer class="label keys"> - <span><span class="kbd">h</span><span class="kbd">j</span><span class="kbd">k</span><span class="kbd">l</span> move</span> - <span><span class="kbd">H</span><span class="kbd">L</span> send</span> - <span><span class="kbd">␣</span> done</span> - <span><span class="kbd">↵</span> open</span> - <span><span class="kbd">o</span> new</span> - <span><span class="kbd">D</span> due</span> - <span><span class="kbd">R</span> rename</span> - <span><span class="kbd">dd</span> delete</span> - <span><span class="kbd">1</span>…<span class="kbd">9</span> board</span> - <span><span class="kbd">tab</span> tile</span> - <span><span class="kbd">esc</span> leave</span> - <span class="rule-hint">first list is the inbox · last list is done</span> - </footer> + <div class="keys"> + <Keys contexts={['board', 'special']}><span class="rule-hint">first list is the inbox · last list is done</span></Keys> + </div> </div> <style> @@ -243,17 +233,8 @@ } .older:hover { color: var(--light); background: transparent; } - .keys { - flex: none; - display: flex; - flex-wrap: wrap; - gap: 6px 16px; - align-items: center; - padding: 8px 22px 12px; - font-size: 11px; - } - .keys > span { display: flex; gap: 4px; align-items: center; } - .keys .rule-hint { margin-left: auto; color: var(--muted); text-transform: none; letter-spacing: 0.04em; } + .keys { flex: none; padding: 8px 22px 12px; } + .rule-hint { margin-left: auto; color: var(--muted); text-transform: none; letter-spacing: 0.04em; } ul { list-style: none; @@ -314,7 +295,6 @@ .card { padding: 10px; } } @media (max-width: 767px) { - .keys > span:not(.rule-hint) { display: none; } - .keys .rule-hint { margin-left: 0; } + .rule-hint { margin-left: 0; } } </style> diff --git a/web/src/lib/Calendar.svelte b/web/src/lib/Calendar.svelte index 8db74b2..1e4964e 100644 --- a/web/src/lib/Calendar.svelte +++ b/web/src/lib/Calendar.svelte @@ -4,6 +4,7 @@ * written in marked, the day under the cursor shown below. The keys live in * App.svelte with the others; this draws and lets a pointer do the same. */ + import Keys from './Keys.svelte' import { workspace } from './store.svelte' import { formatDay } from './util' @@ -78,21 +79,7 @@ {/each} </section> - <footer class="label"> - <span class="kbd">h</span><span class="kbd">l</span> day - <span class="gap"></span> - <span class="kbd">j</span><span class="kbd">k</span> week - <span class="gap"></span> - <span class="kbd">H</span><span class="kbd">L</span> month - <span class="gap"></span> - <span class="kbd">.</span> today - <span class="gap"></span> - <span class="kbd">↵</span> open - <span class="gap"></span> - <span class="kbd">tab</span> tile - <span class="gap"></span> - <span class="kbd">esc</span> leave - </footer> + <Keys contexts={['calendar', 'special']} /> </div> <style> @@ -163,11 +150,5 @@ .entry .name { font-family: var(--font-sans); font-size: 14px; color: var(--fg); } .none { color: var(--subtle); } - footer { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: 10px; } - footer .kbd { margin-right: 2px; } - footer .gap { width: 14px; } - @media (max-width: 767px), (pointer: coarse) { - footer { display: none; } - } </style> diff --git a/web/src/lib/CardSheet.svelte b/web/src/lib/CardSheet.svelte index d656019..2b50bd3 100644 --- a/web/src/lib/CardSheet.svelte +++ b/web/src/lib/CardSheet.svelte @@ -5,6 +5,7 @@ * description, the checklist. The keys live in App.svelte with the others; * this draws and lets a pointer do the same. */ + import Keys from './Keys.svelte' import { followLink } from './commands' import { renderMarkdown } from './markdown' import { workspace } from './store.svelte' @@ -126,20 +127,7 @@ </section> </div> - <footer class="label"> - <span><span class="kbd">j</span><span class="kbd">k</span> step</span> - <span><span class="kbd">␣</span> tick</span> - <span><span class="kbd">o</span> add</span> - <span><span class="kbd">r</span> reword</span> - <span><span class="kbd">J</span><span class="kbd">K</span> order</span> - <span><span class="kbd">dd</span> drop</span> - <span><span class="kbd">e</span> describe</span> - <span><span class="kbd">a</span> label</span> - <span><span class="kbd">D</span> due</span> - <span><span class="kbd">H</span><span class="kbd">L</span> send</span> - <span><span class="kbd">gf</span> notes</span> - <span><span class="kbd">q</span> close</span> - </footer> + <Keys contexts={['card']} /> </div> {/if} @@ -217,9 +205,4 @@ li.at .tick { border-color: var(--ink); } li.at.ticked .tick { background: var(--ink); } - footer { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 10px; } - footer > span { display: flex; gap: 3px; align-items: center; } - @media (max-width: 767px), (pointer: coarse) { - footer { display: none; } - } </style> diff --git a/web/src/lib/Headings.svelte b/web/src/lib/Headings.svelte index dbf3e46..634f10d 100644 --- a/web/src/lib/Headings.svelte +++ b/web/src/lib/Headings.svelte @@ -4,6 +4,7 @@ * in the margin, for when there is no margin, or no pointer to click it * with. The keys live in App.svelte with the other overlays'. */ + import Keys from './Keys.svelte' import { workspace } from './store.svelte' let sheet = $state<HTMLDivElement>() @@ -44,13 +45,7 @@ {/each} </ol> - <footer class="label"> - <span class="kbd">j</span><span class="kbd">k</span> move - <span class="gap"></span> - <span class="kbd">↵</span> jump there - <span class="gap"></span> - <span class="kbd">q</span><span class="kbd">esc</span> close - </footer> + <Keys contexts={['outline', 'sheet']} /> </div> <style> @@ -96,13 +91,7 @@ .row:active { transform: none; } .row.at { background: var(--light); color: var(--ink); } - footer { display: flex; align-items: center; gap: 4px; font-size: 10px; } - footer .kbd { margin-right: 2px; } - footer .gap { width: 14px; } - @media (max-width: 767px), (pointer: coarse) { - footer { display: none; } - } @media (max-width: 767px) { .sheet { padding: 14px; gap: 14px; } } diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index e201119..cb1fac0 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -2,7 +2,7 @@ /* * The help is a sheet the size of the porthole, laid out in newspaper * columns that run on to the right, so all of it is in view at once on a - * wide screen and a key away on a narrow one. Its keys live in App.svelte + * wide screen and a key away on a narrow one. Its keys live in keymap.ts * with the others; these are what they move. */ let pages: HTMLElement | undefined = $state() @@ -40,121 +40,9 @@ <script lang="ts"> import { commands, groups, type CommandSpec, type Group } from './commands' + import Keys from './Keys.svelte' + import { rows, sections as keySections } from './keymap' import { workspace } from './store.svelte' - import { isTouchDevice } from './util' - - const keys: [string, string][] = [ - ['1 … 9 · 0', 'switch to that space, like a workspace'], - ['Shift 1 … 9', 'send the marked nodes, or the selected one, to that space'], - ['Tab · Shift-Tab', 'next / previous space'], - ['w', 'overview of every space — hjkl, then Enter'], - ['f', 'fullscreen the focused pane or tile; again or Esc to leave'], - ['Alt-h/j/k/l', 'focus the tile that way, from the editor too — left of the leftmost is the list'], - ['Alt-Shift-h/j/k/l', 'move the focused tile that way'], - ['L · Shift-Enter', 'open the row in a new tile beside the others'], - ['gt / gT', 'next / previous tile — in the editor too'], - ['Alt-q', 'close the focused tile — :close; :only closes the others'], - ['j / k · ↓ ↑', 'move down / up the list — scroll the document when it has focus'], - ['gg / G', 'first / last row, or top / bottom of the document — a first g, d, ] or [ waits a second in the status line'], - ['Ctrl-D / Ctrl-U', 'half a page down / up the open document'], - ['zl / zh · zL / zH', 'the wide table or code block at the top of the view, sideways — a little, or half its width'], - [']] / [[', 'next / previous heading of the document — in the editor too'], - ['gO', 'the headings as a list: j/k, Enter jumps there'], - ['F', 'hints over the links and buttons in view — type one to follow it'], - ['gf / gx', 'in the editor: follow the link under the cursor / open it in a tab'], - ['gx', 'open the file under the cursor in a tab of its own'], - ['Enter · l', 'open a document, or enter a folder — on an open keystore or sealed file, Enter asks for its password'], - ['h', 'from the document to the list, from the list up a level'], - ['o', 'new document here, straight into insert mode'], - ['O', 'new folder here'], - ['i · a', 'edit the open document'], - ['R', 'rename what is under the cursor — document, folder or card — starting from its name'], - ['m / p', 'pick nodes up, then drop them all into the folder you walk to'], - ['M', 'pick up the whole folder — again puts it all back'], - ['Ctrl / Shift-click', 'pick a row up · every row up to it — drag them onto a folder'], - ['S', 'the special workspace: the board, the agenda and the calendar side by side — S again, q or Esc leave it'], - ['Tab · Alt-h/j/k/l', 'in the special workspace: the next tile, or the one that way — l past the last list and j past the agenda go on too'], - ['t', 'agenda — late, due this week, in hand, on every board — in the special workspace'], - ['T', "today's diary entry, started if there is none — from templates/day when the diary has one"], - ['C', 'the diary by month, in the special workspace: h/l a day, j/k a week, H/L a month, . today, Enter opens or starts the day'], - ['[d / ]d', 'the diary entry before / after the one open, by date'], - ['Ctrl-P', 'find anything, in every space — Enter opens the result lit beside them, n goes on'], - ['b', "the board, in the special workspace: this space's, or the last one shown — 1 … 9 there shows another's"], - ['c', "capture a card into this space's inbox, without leaving here"], - ['Esc · q', 'close the document, and its tile when there are several'], - ['/', 'full-text search, inside the current space and its text files — Tab or Ctrl-N/P walk the results, Enter opens one'], - ['n / N', 'next / previous search result, opened beside the results — :noh clears the search'], - ['s', 'toggle sharing for the document — asks before taking it back'], - ['y', 'mint a new share link and copy it — asks, the old one stops working'], - ['x · dd', 'delete what has focus — the row in the list, the document in its pane — into the trash (asks y/N)'], - ['u', 'undo the last delete: it comes back out of the trash — :trash has the rest'], - ['gh', "the open document's earlier versions, as diffs — Enter brings one back"], - - ['gb', 'the documents that link to the open one — [[title]] links by title, [[ completes one while writing'], - [':', 'command line — Tab / Shift-Tab cycle the matches, then the argument; ↑ ↓ recall what ran before'], - ['Ctrl-S', 'save, from anywhere'], - ['?', 'this screen, from the editor too — h/l a column, / filters it down'], - ] - - /* The board rebinds most keys, so it gets its own column rather than - footnotes on the list keys. */ - const boardKeys: [string, string][] = [ - ['rule', 'the first list is the inbox, the last list is done'], - ['h / l', 'to the list left / right — right of the last is the agenda'], - ['j / k', 'down / up the cards'], - ['gg / G', 'first / last card of the list'], - ['J / K', 'move the card down / up its list'], - ['H / L', 'move the card to the list left / right'], - ['Space', 'into the done list, or back out to the inbox'], - ['Enter', 'turn the card over: its labels, description and checklist'], - ['gf', "the card's notes — a document, made on first use; q comes back"], - ['o · c', 'new card on this list'], - ['D', 'due date: fri, tm, +3d, 10-31 …'], - ['R', 'rename the card, or the list when it is empty'], - ['z', 'show done cards older than a week'], - ['F', 'a letter over every card in view — type it to put the cursor there'], - ['r', 'read the board again, for changes made elsewhere'], - ['x · dd', 'delete the card (asks y/N)'], - ['q · Esc', 'leave the special workspace'], - ] - - const scratchKeys: [string, string][] = [ - ['Q', 'lower the scratchpad over whatever is on screen, or raise it again — :scratch <text> takes a note without it'], - ['j / k', 'down / up the notes, the one touched last on top'], - ['o', 'a new note, written straight away — it saves itself'], - ['Enter · e · i', 'write the note — Esc in normal mode, or :q, comes back to the notes'], - ['K', 'keep the note: a document in the folder being browsed, titled by its first line — :keep <title> names it'], - ['dd', 'drop the note (asks y/N)'], - ['q · Esc · Q', 'raise the scratchpad'], - ] - - const cardKeys: [string, string][] = [ - ['j / k', 'down / up the checklist'], - ['gg / G', 'first / last step'], - ['Space · x', 'tick the step, or untick it'], - ['o', 'a new step below the cursor — :task on the line'], - ['r', 'reword the step'], - ['J / K', 'move the step down / up'], - ['dd', 'drop the step'], - ['e · i · Enter', 'write the description, in the editor — :w writes it, :q or Esc in normal mode leaves'], - ['a', 'put on a label, or take it off — :label on the line, Tab offers the board\'s'], - ['R · D', 'rename the card, its due date'], - ['H / L', 'send the card to the list left / right'], - ['gf', "the card's notes — a document, made on first use; q comes back"], - ['q · Esc', 'back to the board'], - ] - - const agendaKeys: [string, string][] = [ - ['j / k', 'down / up the cards, then what was written on this day in earlier years, then into the calendar'], - ['h', 'to the board'], - ['Enter', 'to the card, on its own board beside — or to the entry'], - ['Space', 'done, where it stands'], - ['D', 'due date: fri, tm, +3d … — :due on the line'], - [':', 'the line, with the agenda kept up — :repeat weekly acts on the card'], - ['H / L', 'to the list left / right on its board'], - ['T', "today's diary entry"], - ['q · Esc', 'leave the special workspace — a document opened from it comes back here on q'], - ] const titles: Record<Group, string> = { tree: 'spaces & folders', @@ -169,9 +57,6 @@ type Row = { key: string; args?: string; description: string } - const fromPairs = (pairs: [string, string][]): Row[] => - pairs.map(([key, description]) => ({ key, description })) - /** `:write(w)` — the name first, then whatever else reaches it. */ const signature = (spec: CommandSpec) => `:${spec.name}${spec.aliases?.length ? `(${spec.aliases.join(' ')})` : ''}` @@ -182,24 +67,10 @@ ...(spec.bang ? [{ key: `:${spec.name}!`, description: spec.bang.help }] : []), ]) - const editorKeys: [string, string][] = [ - ['vim', 'the full keymap — hjkl, w/b, dd, ciw, visual mode, macros; :w writes'], - ['drop · paste', 'an image, video or audio file attaches it'], - ['$x^2$ · $$…$$', 'set as math'], - ['```rust', 'a fenced block in a language is coloured as nvim colours it, written and read'], - ['```typst', 'a block typst sets, shown as an image — math, tables, figures; no packages or files'], - [':12', 'jumps straight to entry 12'], - ['y · d', 'copied to the system clipboard — :set noclipboard keeps them in the registers'], - ['files', 'open in place — pdf, text and code, markdown, csv, certificates and keystores'], - ] - + /* The keys come out of the keymap that dispatches them, so the help + cannot describe a key that does something else. */ const sections: [string, Row[]][] = [ - ['keys', fromPairs(keys)], - ['board keys', fromPairs(boardKeys)], - ['card keys', fromPairs(cardKeys)], - ['scratchpad keys', fromPairs(scratchKeys)], - ['agenda keys', fromPairs(agendaKeys)], - ['in the editor', fromPairs(editorKeys)], + ...keySections.map(({ section, title, rule }) => [title, [...(rule ? [{ key: 'rule', description: rule }] : []), ...rows(section)]] as [string, Row[]]), ...groups.map((group: Group) => [titles[group], fromSpecs(commands.filter((spec) => spec.group === group))] as [string, Row[]]), ] @@ -272,19 +143,7 @@ {/each} </div> - {#if !isTouchDevice} - <footer class="label"> - <span class="kbd">h</span><span class="kbd">l</span> column - <span class="gap"></span> - <span class="kbd">⌃d</span><span class="kbd">⌃u</span> page - <span class="gap"></span> - <span class="kbd">g</span><span class="kbd">G</span> start · end - <span class="gap"></span> - <span class="kbd">/</span> filter, and read the lines whole - <span class="gap"></span> - <span class="kbd">?</span><span class="kbd">esc</span> close - </footer> - {/if} + <Keys contexts={['help', 'sheet']} /> </div> <style> @@ -382,10 +241,6 @@ .none { margin: 0; color: var(--subtle); font-size: 12px; } - footer { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: 10px; } - footer .kbd { margin-right: 2px; } - footer .gap { width: 14px; } - /* A phone stacks the columns and scrolls the sheet down. */ @media (max-width: 767px) { .sheet { overflow-y: auto; } diff --git a/web/src/lib/History.svelte b/web/src/lib/History.svelte index 3f9309e..476c235 100644 --- a/web/src/lib/History.svelte +++ b/web/src/lib/History.svelte @@ -26,6 +26,7 @@ </script> <script lang="ts"> + import Keys from './Keys.svelte' import { diffLines, folded } from './diff' import { formatStamp, relative } from './util' @@ -86,15 +87,7 @@ </div> </div> - <footer class="label"> - <span class="kbd">j</span><span class="kbd">k</span> earlier / later - <span class="gap"></span> - <span class="kbd">enter</span> restore - <span class="gap"></span> - <span class="muted">- goes, + comes back</span> - <span class="gap"></span> - <span class="kbd">q</span><span class="kbd">esc</span> close - </footer> + <Keys contexts={['history', 'sheet']}><span class="muted">- goes, + comes back</span></Keys> </div> <style> @@ -149,16 +142,9 @@ .row.added { background: color-mix(in oklab, var(--light) 14%, transparent); } .row.fold { color: var(--muted); } - footer { display: flex; align-items: center; gap: 4px; font-size: 10px; } - footer .kbd { margin-right: 2px; } - footer .gap { width: 14px; } @media (max-width: 767px) { .split { flex-direction: column; } ul { width: auto; max-height: 30%; } - footer { display: none; } - } - @media (pointer: coarse) { - footer { display: none; } } </style> diff --git a/web/src/lib/Keys.svelte b/web/src/lib/Keys.svelte new file mode 100644 index 0000000..31790a2 --- /dev/null +++ b/web/src/lib/Keys.svelte @@ -0,0 +1,43 @@ +<script lang="ts"> + /* + * The footer of a panel: the keys its contexts read, straight from the + * keymap — each one with a hint, in the table's order — and whatever the + * panel adds after them. A phone has no keys to press, so there it shows + * only what the panel adds. + */ + import type { Snippet } from 'svelte' + import { hints, type Context } from './keymap' + import { isTouchDevice } from './util' + + const { + contexts, + hide = [], + children, + }: { + contexts: Context[] + /** Actions whose hint is left out while they would do nothing. */ + hide?: string[] + children?: Snippet + } = $props() + + const shown = $derived(isTouchDevice ? [] : hints(contexts).filter((entry) => !entry.action || !hide.includes(entry.action))) +</script> + +{#if shown.length || children} + <footer class="label"> + {#each shown as entry, index (index)} + <span class="hint">{#each entry.caps as cap (cap)}<span class="kbd">{cap}</span>{/each} {entry.hint}</span> + {/each} + {@render children?.()} + </footer> +{/if} + +<style> + footer { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; font-size: 10px; } + .hint { display: flex; gap: 3px; align-items: center; } + + @media (max-width: 767px) { + .hint { display: none; } + footer:not(:has(> :not(.hint))) { display: none; } + } +</style> diff --git a/web/src/lib/MediaBrowser.svelte b/web/src/lib/MediaBrowser.svelte index 90a7767..86ddc8f 100644 --- a/web/src/lib/MediaBrowser.svelte +++ b/web/src/lib/MediaBrowser.svelte @@ -33,6 +33,7 @@ </script> <script lang="ts"> + import Keys from './Keys.svelte' import { formatBytes, formatStamp } from './util' let sheet = $state<HTMLDivElement>() @@ -92,19 +93,7 @@ {/each} </ul> - <footer class="label"> - <span class="kbd">j</span><span class="kbd">k</span> move - <span class="gap"></span> - {#if workspace.editing} - <span class="kbd">i</span> insert - <span class="gap"></span> - {/if} - <span class="kbd">y</span> copy url - <span class="gap"></span> - <span class="kbd">x</span> delete - <span class="gap"></span> - <span class="kbd">q</span><span class="kbd">esc</span> close - </footer> + <Keys contexts={['media', 'sheet']} hide={workspace.editing ? [] : ['insert']} /> </div> <style> @@ -140,12 +129,6 @@ li:hover { background: var(--surface); } li.at { background: var(--surface-alt); box-shadow: inset 2px 0 0 var(--light); } - footer { display: flex; align-items: center; gap: 4px; font-size: 10px; } - footer .kbd { margin-right: 2px; } - footer .gap { width: 14px; } - @media (max-width: 767px), (pointer: coarse) { - footer { display: none; } - } .thumb { width: 52px; diff --git a/web/src/lib/Overview.svelte b/web/src/lib/Overview.svelte index 75fde50..d1dddee 100644 --- a/web/src/lib/Overview.svelte +++ b/web/src/lib/Overview.svelte @@ -5,6 +5,7 @@ * keys live in App.svelte with the others; this only draws and tells them * how many cards fit in a row. */ + import Keys from './Keys.svelte' import { api, type NodeSummary } from './api' import { hooks } from './commands' import { workspace } from './store.svelte' @@ -86,13 +87,7 @@ {/each} </ol> - <footer class="label"> - <span class="kbd">h</span><span class="kbd">j</span><span class="kbd">k</span><span class="kbd">l</span> move - <span class="gap"></span> - <span class="kbd">1…9</span><span class="kbd">↵</span> go - <span class="gap"></span> - <span class="kbd">w</span><span class="kbd">esc</span> close - </footer> + <Keys contexts={['overview']} /> </div> <style> @@ -206,14 +201,8 @@ .when { flex: none; font-size: 10px; color: var(--muted); } .empty { padding: 4px 8px; font-size: 12px; color: var(--muted); } - footer { display: flex; align-items: center; gap: 4px; font-size: 10px; } - footer .kbd { margin-right: 2px; } - footer .gap { width: 14px; } /* Keys mean nothing to a thumb. */ - @media (max-width: 767px), (pointer: coarse) { - footer { display: none; } - } @media (max-width: 767px) { .sheet { padding: 14px; gap: 14px; } } diff --git a/web/src/lib/Rail.svelte b/web/src/lib/Rail.svelte index f5093db..e5fe6ed 100644 --- a/web/src/lib/Rail.svelte +++ b/web/src/lib/Rail.svelte @@ -10,6 +10,7 @@ import type { NodeSummary } from './api' import { hooks, runCommand } from './commands' import Icon, { type IconName } from './Icon.svelte' + import { keysOf } from './keymap' import { workspace } from './store.svelte' const { dock = false }: { dock?: boolean } = $props() @@ -27,17 +28,17 @@ } const groups = $derived.by((): Door[][] => { - const cmd: Door = { icon: 'prompt', label: 'Command line', keys: [':'], run: () => hooks.openCommandLine(':') } - const help: Door = { icon: 'help', label: 'Help', keys: ['?'], run: () => runCommand('help') } + const cmd: Door = { icon: 'prompt', label: 'Command line', keys: keysOf('global', 'line'), run: () => hooks.openCommandLine(':') } + const help: Door = { icon: 'help', label: 'Help', keys: keysOf('global', 'help'), run: () => runCommand('help') } if (workspace.view === 'special') { // The tiles of the special workspace are doors, so a phone — which // shows one at a time — can walk between them. const tiles: Door[] = [ - { icon: 'board', label: 'Board', keys: ['b'], run: () => (workspace.specialFocus = 'board'), on: workspace.specialFocus === 'board' }, - { icon: 'agenda', label: 'Agenda — late, due, in hand', keys: ['t'], run: () => (workspace.specialFocus = 'agenda'), on: workspace.specialFocus === 'agenda' }, - { icon: 'calendar', label: 'Calendar', keys: ['C'], run: () => (workspace.specialFocus = 'calendar'), on: workspace.specialFocus === 'calendar' }, - { icon: 'list', label: 'Back to the tree', keys: ['Esc', 'S'], run: () => workspace.closeSpecial() }, + { icon: 'board', label: 'Board', keys: keysOf('special', 'board'), run: () => (workspace.specialFocus = 'board'), on: workspace.specialFocus === 'board' }, + { icon: 'agenda', label: 'Agenda — late, due, in hand', keys: keysOf('global', 'agenda'), run: () => (workspace.specialFocus = 'agenda'), on: workspace.specialFocus === 'agenda' }, + { icon: 'calendar', label: 'Calendar', keys: keysOf('global', 'calendar'), run: () => (workspace.specialFocus = 'calendar'), on: workspace.specialFocus === 'calendar' }, + { icon: 'list', label: 'Back to the tree', keys: [...keysOf('special', 'leave'), ...keysOf('global', 'special')], run: () => workspace.closeSpecial() }, ] if (workspace.specialFocus !== 'board') return [tiles, [cmd, help]] // The card verbs are doors too, so a phone — which has no H, L or @@ -46,30 +47,30 @@ return [ tiles, [ - { icon: 'doc-plus', label: 'New card on this list', keys: ['o'], run: () => hooks.openCommandLine(':card ') }, + { icon: 'doc-plus', label: 'New card on this list', keys: keysOf('board', 'new'), run: () => hooks.openCommandLine(':card ') }, { icon: 'column-plus', label: 'New list', keys: [':list'], run: () => hooks.openCommandLine(':list ') }, ], [ - { icon: 'left', label: 'Send the card left', keys: ['H'], run: () => workspace.guard(() => workspace.sendCard(-1)), dim: !card }, - { icon: 'check', label: 'Done, or back to the inbox', keys: ['Space'], run: () => workspace.guard(() => workspace.toggleDone()), dim: !card }, - { icon: 'right', label: 'Send the card right', keys: ['L'], run: () => workspace.guard(() => workspace.sendCard(1)), dim: !card }, - { icon: 'calendar', label: 'Due date', keys: ['D'], run: () => hooks.openCommandLine(':due '), dim: !card }, + { icon: 'left', label: 'Send the card left', keys: keysOf('board', 'sendLeft'), run: () => workspace.guard(() => workspace.sendCard(-1)), dim: !card }, + { icon: 'check', label: 'Done, or back to the inbox', keys: keysOf('board', 'done'), run: () => workspace.guard(() => workspace.toggleDone()), dim: !card }, + { icon: 'right', label: 'Send the card right', keys: keysOf('board', 'sendRight'), run: () => workspace.guard(() => workspace.sendCard(1)), dim: !card }, + { icon: 'calendar', label: 'Due date', keys: keysOf('board', 'due'), run: () => hooks.openCommandLine(':due '), dim: !card }, { icon: 'repeat', label: 'Repeat once done', keys: [':repeat'], run: () => hooks.openCommandLine(':repeat '), dim: !card }, - { icon: 'doc', label: "The card's notes", keys: ['gf'], run: () => workspace.guard(() => workspace.openCard()), dim: !card }, + { icon: 'doc', label: "The card's notes", keys: keysOf('board', 'notes'), run: () => workspace.guard(() => workspace.openCard()), dim: !card }, ], [ - { icon: 'up', label: 'Card up its list', keys: ['K'], run: () => workspace.guard(() => workspace.nudgeCard(-1)), dim: !card }, - { icon: 'down', label: 'Card down its list', keys: ['J'], run: () => workspace.guard(() => workspace.nudgeCard(1)), dim: !card }, + { icon: 'up', label: 'Card up its list', keys: keysOf('board', 'nudgeUp'), run: () => workspace.guard(() => workspace.nudgeCard(-1)), dim: !card }, + { icon: 'down', label: 'Card down its list', keys: keysOf('board', 'nudgeDown'), run: () => workspace.guard(() => workspace.nudgeCard(1)), dim: !card }, { icon: 'rename', label: card ? 'Rename the card' : 'Rename the list', - keys: ['R'], + keys: keysOf('board', 'rename'), run: () => hooks.openCommandLine(card ? ':name ' : ':name! '), }, { icon: 'trash', label: card ? 'Delete the card' : 'Delete the list', - keys: card ? ['x'] : [':list!'], + keys: card ? keysOf('board', 'delete') : [':list!'], run: () => runCommand(card ? 'delete' : 'list!'), }, ], @@ -78,39 +79,39 @@ } const board: Door = workspace.space?.has_board - ? { icon: 'board', label: 'Board', keys: ['b'], run: () => runCommand('board') } + ? { icon: 'board', label: 'Board', keys: keysOf('tree', 'board'), run: () => runCommand('board') } : { icon: 'board', label: 'Give this space a board', keys: [':set board'], run: () => runCommand('set board'), dim: true } return [ [ - { icon: 'doc-plus', label: 'New document', keys: ['o'], run: () => runCommand('new') }, - { icon: 'folder-plus', label: 'New folder', keys: ['⇧ O'], run: () => hooks.openCommandLine(':mkdir ') }, + { icon: 'doc-plus', label: 'New document', keys: keysOf('tree', 'new'), run: () => runCommand('new') }, + { icon: 'folder-plus', label: 'New folder', keys: keysOf('tree', 'mkdir'), run: () => hooks.openCommandLine(':mkdir ') }, ...(workspace.space ? [board] : []), ], [ - { icon: 'agenda', label: 'Agenda — late, due, in hand', keys: ['t'], run: () => runCommand('agenda') }, - { icon: 'calendar', label: 'Calendar — the diary by month', keys: ['C'], run: () => runCommand('cal') }, - { icon: 'today', label: "Today's diary entry", keys: ['T'], run: () => runCommand('today') }, - { icon: 'capture', label: 'Capture a card to the inbox', keys: ['c'], run: () => hooks.openCommandLine(':card ') }, + { icon: 'agenda', label: 'Agenda — late, due, in hand', keys: keysOf('global', 'agenda'), run: () => runCommand('agenda') }, + { icon: 'calendar', label: 'Calendar — the diary by month', keys: keysOf('global', 'calendar'), run: () => runCommand('cal') }, + { icon: 'today', label: "Today's diary entry", keys: keysOf('global', 'today'), run: () => runCommand('today') }, + { icon: 'capture', label: 'Capture a card to the inbox', keys: keysOf('tree', 'capture'), run: () => hooks.openCommandLine(':card ') }, ], [ { icon: 'upload', label: 'Upload files into this folder', keys: [':upload'], run: () => hooks.pickTreeFiles(false) }, { icon: 'trash', label: 'The trash — what was deleted', keys: [':trash'], run: () => runCommand('trash') }, ...(workspace.marked.length - ? [{ icon: 'drop' as const, label: `Drop ${dropping(workspace.marked)} here`, keys: ['p'], run: () => workspace.guard(() => workspace.drop()), on: true }] + ? [{ icon: 'drop' as const, label: `Drop ${dropping(workspace.marked)} here`, keys: keysOf('tree', 'drop'), run: () => workspace.guard(() => workspace.drop()), on: true }] : []), ], [ - { icon: 'grid', label: 'Every space', keys: ['w'], run: () => hooks.openOverview() }, + { icon: 'grid', label: 'Every space', keys: keysOf('global', 'overview'), run: () => hooks.openOverview() }, { icon: workspace.fullscreen ? 'shrink' : 'expand', label: workspace.fullscreen ? 'Leave fullscreen' : 'Fullscreen the focused pane', - keys: ['f'], + keys: keysOf('global', 'fullscreen'), run: () => (workspace.fullscreen = !workspace.fullscreen), on: workspace.fullscreen, desk: true, }, - { icon: 'search', label: 'Search this space', keys: ['/'], run: () => hooks.openCommandLine('/') }, - { icon: 'search-all', label: 'Search every space', keys: ['Ctrl-P'], run: () => hooks.openGlobalSearch() }, + { icon: 'search', label: 'Search this space', keys: keysOf('tree', 'search'), run: () => hooks.openCommandLine('/') }, + { icon: 'search-all', label: 'Search every space', keys: keysOf('global', 'find'), run: () => hooks.openGlobalSearch() }, ], [cmd, help], ] diff --git a/web/src/lib/Scratch.svelte b/web/src/lib/Scratch.svelte index 02597ae..71afd35 100644 --- a/web/src/lib/Scratch.svelte +++ b/web/src/lib/Scratch.svelte @@ -6,6 +6,7 @@ * The keys live in App.svelte with the others; this draws and lets a * pointer do the same. */ + import Keys from './Keys.svelte' import { renderMarkdown } from './markdown' import { workspace } from './store.svelte' import { relative } from './util' @@ -89,20 +90,15 @@ </div> </div> - <footer class="label"> - {#if workspace.scratchEditing} + {#if workspace.scratchEditing} + <footer class="label"> <span>writing — it saves itself</span> <span><span class="kbd">esc</span> back to the notes</span> <span><span class="kbd">:keep</span> make it a document</span> - {:else} - <span><span class="kbd">j</span><span class="kbd">k</span> note</span> - <span><span class="kbd">o</span> new</span> - <span><span class="kbd">↵</span> write</span> - <span><span class="kbd">K</span> keep as document</span> - <span><span class="kbd">dd</span> drop</span> - <span><span class="kbd">Q</span><span class="kbd">q</span> raise</span> - {/if} - </footer> + </footer> + {:else} + <Keys contexts={['drawer']} /> + {/if} </div> <style> diff --git a/web/src/lib/Trash.svelte b/web/src/lib/Trash.svelte index 978f14a..1cffe2c 100644 --- a/web/src/lib/Trash.svelte +++ b/web/src/lib/Trash.svelte @@ -23,6 +23,7 @@ </script> <script lang="ts"> + import Keys from './Keys.svelte' import { relative } from './util' let sheet = $state<HTMLDivElement>() @@ -68,15 +69,7 @@ {/each} </ul> - <footer class="label"> - <span class="kbd">j</span><span class="kbd">k</span> move - <span class="gap"></span> - <span class="kbd">enter</span><span class="kbd">r</span> restore - <span class="gap"></span> - <span class="kbd">x</span> delete for good - <span class="gap"></span> - <span class="kbd">q</span><span class="kbd">esc</span> close - </footer> + <Keys contexts={['trash', 'sheet']} /> </div> <style> @@ -113,12 +106,6 @@ li:hover { background: var(--surface); } li.at { background: var(--surface-alt); box-shadow: inset 2px 0 0 var(--light); } - footer { display: flex; align-items: center; gap: 4px; font-size: 10px; } - footer .kbd { margin-right: 2px; } - footer .gap { width: 14px; } - @media (max-width: 767px), (pointer: coarse) { - footer { display: none; } - } .kind { width: 64px; flex: none; font-size: 9px; letter-spacing: 0.14em; } diff --git a/web/src/lib/actions.ts b/web/src/lib/actions.ts new file mode 100644 index 0000000..4588140 --- /dev/null +++ b/web/src/lib/actions.ts @@ -0,0 +1,557 @@ +/* + * 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<C extends Context> = Extract<Bound, { context: C; action: string }>['action'] + +const guard = (task: () => Promise<unknown>) => 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<SpecialTile, Partial<Record<Direction, SpecialTile>>> = { + 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<ActionOf<C>, 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)) +} diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index 099ef79..2f5cde3 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -43,6 +43,12 @@ export const hooks = { openCommandLine: (_initial: string) => {}, /** Ctrl-P: a search across every space. */ openGlobalSearch: () => {}, + /** Esc with the line open but the keyboard elsewhere: the line away; false when it is not open. */ + closeCommandLine: (): boolean => false, + /** `F`: letters over the links and buttons inside `root`. */ + showHints: (_root: HTMLElement) => {}, + /** The board on screen, for `F` over its cards. */ + boardRoot: (): HTMLElement | null => null, } /** Help groups the command list under these headings, in this order. */ diff --git a/web/src/lib/keymap.ts b/web/src/lib/keymap.ts new file mode 100644 index 0000000..c710e91 --- /dev/null +++ b/web/src/lib/keymap.ts @@ -0,0 +1,325 @@ +/* + * Every key the app reads, in one table: what is pressed, where, what it + * does, and the words the help, the footers, the rail and the wiki show for + * it. The keyboard is dispatched from here (modes.ts), so a key the help + * describes is a key that exists. + * + * This file is data only — no imports — so scripts/wiki.ts can read it as it + * is. What each action does lives in actions.ts, under the same names. + */ + +/** + * Where a key is heard. modes.ts stacks them, the topmost first: + * always > a sheet > the drawer > workspace > writing > the focused tile's + * kind (tree, board, agenda, calendar) > special > global. + */ +export type Context = + | 'always' + | 'sheet' + | 'help' + | 'overview' + | 'outline' + | 'media' + | 'trash' + | 'backlinks' + | 'history' + | 'card' + | 'drawer' + | 'workspace' + | 'writing' + | 'tree' + | 'board' + | 'agenda' + | 'calendar' + | 'special' + | 'global' + | 'editor' + +/** The help's columns of keys, and the wiki's. */ +export type Section = 'keys' | 'board' | 'card' | 'scratch' | 'agenda' | 'editor' + +export interface Binding { + context: Context + /** + * What is pressed, as `keyName` spells it: `j`, `G`, `Enter`, `Space`, + * `Shift-Tab`, `Ctrl-d`, `Alt-h`, `1`, `Shift-1`, a two-key `gg`, or `*` + * for any key without Ctrl, Alt or Meta. A binding without keys only + * describes — a mouse gesture, the editor's own keymap. + */ + keys?: readonly string[] + /** The name of what it does, in actions.ts under the same context. */ + action?: string + /** Heard while a field or the editor has the keyboard. */ + typing?: boolean + /** The keys as the help writes them; `keys` joined when not given. */ + label?: string + /** A line in the help; a binding without one is not listed there. */ + help?: string + /** The help column it is listed in, when not its context's own. */ + section?: Section + /** A word or two for the footer of its context. */ + hint?: string + /** The keycaps the footer and the rail show; from `keys` when not given. */ + caps?: readonly string[] +} + +const digits = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'] as const + +export const bindings = [ + // --- browsing and reading; `always` is heard in the editor too --------------- + { context: 'global', keys: digits, action: 'space', caps: ['1…9'], label: '1 … 9 · 0', help: 'switch to that space, like a workspace' }, + { context: 'global', keys: digits.map((digit) => `Shift-${digit}`), action: 'send', label: 'Shift 1 … 9', help: 'send the marked nodes, or the selected one, to that space' }, + { context: 'global', keys: ['Tab', 'Shift-Tab'], action: 'cycle', label: 'Tab · Shift-Tab', help: 'next / previous space' }, + { context: 'global', keys: ['w'], action: 'overview', help: 'overview of every space — hjkl, then Enter' }, + { context: 'global', keys: ['f'], action: 'fullscreen', help: 'fullscreen the focused pane or tile; again or Esc to leave' }, + { context: 'always', keys: ['Alt-h', 'Alt-j', 'Alt-k', 'Alt-l'], action: 'focus', typing: true, label: 'Alt-h/j/k/l', help: 'focus the tile that way, from the editor too — left of the leftmost is the list' }, + { context: 'always', keys: ['Alt-Shift-h', 'Alt-Shift-j', 'Alt-Shift-k', 'Alt-Shift-l'], action: 'swap', typing: true, label: 'Alt-Shift-h/j/k/l', help: 'move the focused tile that way' }, + { context: 'tree', keys: ['L'], action: 'beside', label: 'L · Shift-Enter', help: 'open the row in a new tile beside the others' }, + { context: 'tree', keys: ['gt'], action: 'nextTile', label: 'gt / gT', help: 'next / previous tile — in the editor too' }, + { context: 'tree', keys: ['gT'], action: 'previousTile' }, + { context: 'always', keys: ['Alt-q'], action: 'close', typing: true, help: 'close the focused tile — :close; :only closes the others' }, + { context: 'tree', keys: ['j', 'ArrowDown'], action: 'down', label: 'j / k · ↓ ↑', help: 'move down / up the list — scroll the document when it has focus' }, + { context: 'tree', keys: ['k', 'ArrowUp'], action: 'up' }, + { context: 'tree', keys: ['gg'], action: 'top', label: 'gg / G', help: 'first / last row, or top / bottom of the document — a first g, d, ] or [ waits a second in the status line' }, + { context: 'tree', keys: ['G'], action: 'bottom' }, + { context: 'global', keys: ['Ctrl-d'], action: 'halfDown', label: 'Ctrl-D / Ctrl-U', help: 'half a page down / up the open document' }, + { context: 'global', keys: ['Ctrl-u'], action: 'halfUp' }, + { context: 'tree', keys: ['zh', 'zl', 'zH', 'zL'], action: 'sideways', label: 'zl / zh · zL / zH', help: 'the wide table or code block at the top of the view, sideways — a little, or half its width' }, + { context: 'tree', keys: [']]'], action: 'nextHeading', label: ']] / [[', help: 'next / previous heading of the document — in the editor too' }, + { context: 'tree', keys: ['[['], action: 'previousHeading' }, + { context: 'tree', keys: ['gO'], action: 'outline', help: 'the headings as a list: j/k, Enter jumps there' }, + { context: 'tree', keys: ['F'], action: 'hints', help: 'hints over the links and buttons in view — type one to follow it' }, + { context: 'tree', label: 'gf / gx', help: 'in the editor: follow the link under the cursor / open it in a tab' }, + { context: 'tree', keys: ['gx'], action: 'inTab', help: 'open the file under the cursor in a tab of its own' }, + { context: 'tree', keys: ['Enter', 'Shift-Enter', 'l', 'ArrowRight'], action: 'open', label: 'Enter · l', help: 'open a document, or enter a folder — on an open keystore or sealed file, Enter asks for its password' }, + { context: 'tree', keys: ['h', 'ArrowLeft'], action: 'left', label: 'h', help: 'from the document to the list, from the list up a level' }, + { context: 'tree', keys: ['o'], action: 'new', help: 'new document here, straight into insert mode' }, + { context: 'tree', keys: ['O'], action: 'mkdir', caps: ['⇧ O'], help: 'new folder here' }, + { context: 'tree', keys: ['i', 'a'], action: 'edit', help: 'edit the open document' }, + { context: 'tree', keys: ['R'], action: 'rename', help: 'rename what is under the cursor — document, folder or card — starting from its name' }, + { context: 'tree', keys: ['m'], action: 'mark', label: 'm / p', help: 'pick nodes up, then drop them all into the folder you walk to' }, + { context: 'tree', keys: ['p'], action: 'drop' }, + { context: 'tree', keys: ['M'], action: 'markAll', help: 'pick up the whole folder — again puts it all back' }, + { context: 'tree', label: 'Ctrl / Shift-click', help: 'pick a row up · every row up to it — drag them onto a folder' }, + { context: 'global', keys: ['S'], action: 'special', help: 'the special workspace: the board, the agenda and the calendar side by side — S again, q or Esc leave it' }, + { context: 'special', keys: ['Tab', 'Shift-Tab'], action: 'cycle', label: 'Tab · Alt-h/j/k/l', hint: 'tile', help: 'in the special workspace: the next tile, or the one that way — l past the last list and j past the agenda go on too' }, + { context: 'global', keys: ['t'], action: 'agenda', help: 'agenda — late, due this week, in hand, on every board — in the special workspace' }, + { context: 'global', keys: ['T'], action: 'today', help: "today's diary entry, started if there is none — from templates/day when the diary has one" }, + { context: 'global', keys: ['C'], action: 'calendar', help: 'the diary by month, in the special workspace: h/l a day, j/k a week, H/L a month, . today, Enter opens or starts the day' }, + { context: 'tree', keys: ['[d'], action: 'previousEntry', label: '[d / ]d', help: 'the diary entry before / after the one open, by date' }, + { context: 'tree', keys: [']d'], action: 'nextEntry' }, + { context: 'global', keys: ['Ctrl-p'], action: 'find', label: 'Ctrl-P', help: 'find anything, in every space — Enter opens the result lit beside them, n goes on' }, + { context: 'tree', keys: ['b'], action: 'board', help: "the board, in the special workspace: this space's, or the last one shown — 1 … 9 there shows another's" }, + { context: 'special', keys: ['b'], action: 'board' }, + { context: 'tree', keys: ['c'], action: 'capture', help: "capture a card into this space's inbox, without leaving here" }, + { context: 'tree', keys: ['q', 'Escape'], action: 'close', label: 'Esc · q', help: 'close the document, and its tile when there are several' }, + { context: 'tree', keys: ['/'], action: 'search', help: 'full-text search, inside the current space and its text files — Tab or Ctrl-N/P walk the results, Enter opens one' }, + { context: 'tree', keys: ['n'], action: 'next', label: 'n / N', help: 'next / previous search result, opened beside the results — :noh clears the search' }, + { context: 'tree', keys: ['N'], action: 'previous' }, + { context: 'tree', keys: ['s'], action: 'share', help: 'toggle sharing for the document — asks before taking it back' }, + { context: 'tree', keys: ['y'], action: 'link', help: 'mint a new share link and copy it — asks, the old one stops working' }, + { context: 'tree', keys: ['x', 'dd'], action: 'delete', help: 'delete what has focus — the row in the list, the document in its pane — into the trash (asks y/N)' }, + { context: 'tree', keys: ['u'], action: 'undo', help: 'undo the last delete: it comes back out of the trash — :trash has the rest' }, + { context: 'tree', keys: ['gh'], action: 'history', help: "the open document's earlier versions, as diffs — Enter brings one back" }, + { context: 'tree', keys: ['gb'], action: 'backlinks', help: 'the documents that link to the open one — [[title]] links by title, [[ completes one while writing' }, + { context: 'global', keys: [':'], action: 'line', help: 'command line — Tab / Shift-Tab cycle the matches, then the argument; ↑ ↓ recall what ran before' }, + { context: 'always', keys: ['Ctrl-s', 'Meta-s'], action: 'save', typing: true, label: 'Ctrl-S', help: 'save, from anywhere' }, + { context: 'global', keys: ['?'], action: 'help', help: 'this screen, from the editor too — h/l a column, / filters it down' }, + + // --- the workspace around the panes ------------------------------------------ + { context: 'workspace', keys: ['Escape'], action: 'escape', typing: true }, + { context: 'workspace', keys: ['Q'], action: 'scratch', section: 'scratch', help: 'lower the scratchpad over whatever is on screen, or raise it again — :scratch <text> takes a note without it' }, + // While a document is being written, a key that lands outside the editor + // hands the focus back to it instead of walking the list unseen. + { context: 'writing', keys: ['*'], action: 'refocus' }, + + // --- the special workspace ------------------------------------------------- + { context: 'special', keys: ['Escape'], action: 'leave', caps: ['esc'], hint: 'leave' }, + + // --- on a board --------------------------------------------------------------- + { context: 'board', keys: ['h', 'ArrowLeft'], action: 'left', label: 'h / l', caps: ['h', 'j', 'k', 'l'], hint: 'move', help: 'to the list left / right — right of the last is the agenda' }, + { context: 'board', keys: ['l', 'ArrowRight'], action: 'right' }, + { context: 'board', keys: ['j', 'ArrowDown'], action: 'down', label: 'j / k', help: 'down / up the cards' }, + { context: 'board', keys: ['k', 'ArrowUp'], action: 'up' }, + { context: 'board', keys: ['gg'], action: 'top', label: 'gg / G', help: 'first / last card of the list' }, + { context: 'board', keys: ['G'], action: 'bottom' }, + { context: 'board', keys: ['J'], action: 'nudgeDown', label: 'J / K', help: 'move the card down / up its list' }, + { context: 'board', keys: ['K'], action: 'nudgeUp' }, + { context: 'board', keys: ['H'], action: 'sendLeft', label: 'H / L', caps: ['H', 'L'], hint: 'send', help: 'move the card to the list left / right' }, + { context: 'board', keys: ['L'], action: 'sendRight' }, + { context: 'board', keys: ['Space'], action: 'done', hint: 'done', help: 'into the done list, or back out to the inbox' }, + { context: 'board', keys: ['Enter'], action: 'turn', hint: 'turn over', help: 'turn the card over: its labels, description and checklist' }, + { context: 'board', keys: ['gf'], action: 'notes', help: "the card's notes — a document, made on first use; q comes back" }, + { context: 'board', keys: ['o', 'c'], action: 'new', label: 'o · c', caps: ['o'], hint: 'new', help: 'new card on this list' }, + { context: 'board', keys: ['D'], action: 'due', hint: 'due', help: 'due date: fri, tm, +3d, 10-31 …' }, + { context: 'board', keys: ['R'], action: 'rename', hint: 'rename', help: 'rename the card, or the list when it is empty' }, + { context: 'board', keys: ['z'], action: 'old', help: 'show done cards older than a week' }, + { context: 'board', keys: ['F'], action: 'hints', help: 'a letter over every card in view — type it to put the cursor there' }, + { context: 'board', keys: ['r'], action: 'reload', help: 'read the board again, for changes made elsewhere' }, + { context: 'board', keys: ['x', 'dd'], action: 'delete', caps: ['dd'], hint: 'delete', help: 'delete the card (asks y/N)' }, + { context: 'board', caps: ['1…9'], hint: 'board' }, + { context: 'board', keys: ['q'], action: 'leave', label: 'q · Esc', help: 'leave the special workspace' }, + + // --- the back of a card --------------------------------------------------- + { context: 'card', keys: ['j', 'ArrowDown'], action: 'down', label: 'j / k', caps: ['j', 'k'], hint: 'step', help: 'down / up the checklist' }, + { context: 'card', keys: ['k', 'ArrowUp'], action: 'up' }, + { context: 'card', keys: ['gg'], action: 'top', label: 'gg / G', help: 'first / last step' }, + { context: 'card', keys: ['G'], action: 'bottom' }, + { context: 'card', keys: ['Space', 'x'], action: 'tick', label: 'Space · x', caps: ['␣'], hint: 'tick', help: 'tick the step, or untick it' }, + { context: 'card', keys: ['o'], action: 'add', hint: 'add', help: 'a new step below the cursor — :task on the line' }, + { context: 'card', keys: ['r'], action: 'reword', hint: 'reword', help: 'reword the step' }, + { context: 'card', keys: ['J'], action: 'nudgeDown', label: 'J / K', caps: ['J', 'K'], hint: 'order', help: 'move the step down / up' }, + { context: 'card', keys: ['K'], action: 'nudgeUp' }, + { context: 'card', keys: ['dd'], action: 'drop', hint: 'drop', help: 'drop the step' }, + { context: 'card', keys: ['e', 'i', 'Enter'], action: 'describe', caps: ['e'], hint: 'describe', help: 'write the description, in the editor — :w writes it, :q or Esc in normal mode leaves' }, + { context: 'card', keys: ['a'], action: 'label', hint: 'label', help: "put on a label, or take it off — :label on the line, Tab offers the board's" }, + { context: 'card', keys: ['R'], action: 'rename', label: 'R · D', help: 'rename the card, its due date' }, + { context: 'card', keys: ['D'], action: 'due', hint: 'due' }, + { context: 'card', keys: ['H'], action: 'sendLeft', label: 'H / L', caps: ['H', 'L'], hint: 'send', help: 'send the card to the list left / right' }, + { context: 'card', keys: ['L'], action: 'sendRight' }, + { context: 'card', keys: ['gf'], action: 'notes', hint: 'notes', help: "the card's notes — a document, made on first use; q comes back" }, + { context: 'card', keys: [':'], action: 'line' }, + { context: 'card', keys: ['q', 'Escape'], action: 'close', label: 'q · Esc', caps: ['q'], hint: 'close', help: 'back to the board' }, + + // --- the scratchpad, while it is down -------------------------------------------- + { context: 'drawer', keys: ['j', 'ArrowDown'], action: 'down', label: 'j / k', caps: ['j', 'k'], hint: 'note', help: 'down / up the notes, the one touched last on top' }, + { context: 'drawer', keys: ['k', 'ArrowUp'], action: 'up' }, + { context: 'drawer', keys: ['gg'], action: 'top' }, + { context: 'drawer', keys: ['G'], action: 'bottom' }, + { context: 'drawer', keys: ['o', 'O', 'c'], action: 'new', label: 'o', caps: ['o'], hint: 'new', help: 'a new note, written straight away — it saves itself' }, + { context: 'drawer', keys: ['Enter', 'e', 'i', 'a'], action: 'write', label: 'Enter · e · i', caps: ['↵'], hint: 'write', help: 'write the note — Esc in normal mode, or :q, comes back to the notes' }, + { context: 'drawer', keys: ['K'], action: 'keep', hint: 'keep as document', help: 'keep the note: a document in the folder being browsed, titled by its first line — :keep <title> names it' }, + { context: 'drawer', keys: ['dd'], action: 'drop', hint: 'drop', help: 'drop the note (asks y/N)' }, + { context: 'drawer', keys: [':'], action: 'line' }, + { context: 'drawer', keys: ['?'], action: 'help' }, + { context: 'drawer', keys: ['q', 'Escape', 'Q'], action: 'raise', label: 'q · Esc · Q', caps: ['Q', 'q'], hint: 'raise', help: 'raise the scratchpad' }, + // Nothing under the drawer hears a key while it is down. + { context: 'drawer', keys: ['*'], action: 'nothing' }, + + // --- the agenda -------------------------------------------------------------- + { context: 'agenda', keys: ['j', 'ArrowDown'], action: 'down', label: 'j / k', caps: ['j', 'k'], hint: 'move', help: 'down / up the cards, then what was written on this day in earlier years, then into the calendar' }, + { context: 'agenda', keys: ['k', 'ArrowUp'], action: 'up' }, + { context: 'agenda', keys: ['h', 'ArrowLeft'], action: 'left', label: 'h', help: 'to the board' }, + { context: 'agenda', keys: ['Enter'], action: 'open', hint: 'open', help: 'to the card, on its own board beside — or to the entry' }, + { context: 'agenda', keys: ['Space'], action: 'finish', hint: 'done', help: 'done, where it stands' }, + { context: 'agenda', keys: ['D'], action: 'due', hint: 'due', help: 'due date: fri, tm, +3d … — :due on the line' }, + // The agenda stays up behind the line, so `:due` and `:repeat` know which card. + { context: 'agenda', keys: [':'], action: 'line', help: 'the line, with the agenda kept up — :repeat weekly acts on the card' }, + { context: 'agenda', keys: ['H'], action: 'sendLeft', label: 'H / L', caps: ['H', 'L'], hint: 'list', help: 'to the list left / right on its board' }, + { context: 'agenda', keys: ['L'], action: 'sendRight' }, + { context: 'agenda', keys: ['T'], action: 'today', hint: 'diary', help: "today's diary entry" }, + { context: 'agenda', keys: ['q'], action: 'leave', label: 'q · Esc', help: 'leave the special workspace — a document opened from it comes back here on q' }, + + // --- the calendar ------------------------------------------------------------ + { context: 'calendar', keys: ['h', 'ArrowLeft', 'l', 'ArrowRight'], action: 'day', caps: ['h', 'l'], hint: 'day' }, + { context: 'calendar', keys: ['k', 'ArrowUp', 'j', 'ArrowDown'], action: 'week', caps: ['j', 'k'], hint: 'week' }, + { context: 'calendar', keys: ['H', 'L'], action: 'month', hint: 'month' }, + { context: 'calendar', keys: ['.'], action: 'today', hint: 'today' }, + { context: 'calendar', keys: ['Enter'], action: 'open', hint: 'open' }, + { context: 'calendar', keys: ['q'], action: 'leave' }, + + // --- the sheets over everything -------------------------------------------------- + { context: 'sheet', keys: ['Escape'], action: 'close', typing: true, caps: ['q', 'esc'], hint: 'close' }, + { context: 'sheet', keys: ['q', '?'], action: 'close' }, + + { context: 'help', keys: ['l', 'j', 'ArrowRight', 'ArrowDown'], action: 'next', caps: ['h', 'l'], hint: 'column' }, + { context: 'help', keys: ['h', 'k', 'ArrowLeft', 'ArrowUp'], action: 'previous' }, + { context: 'help', keys: ['Ctrl-d'], action: 'pageDown', caps: ['⌃d', '⌃u'], hint: 'page' }, + { context: 'help', keys: ['Ctrl-u'], action: 'pageUp' }, + { context: 'help', keys: ['Space'], action: 'page' }, + { context: 'help', keys: ['g'], action: 'start', caps: ['g', 'G'], hint: 'start · end' }, + { context: 'help', keys: ['G'], action: 'end' }, + { context: 'help', keys: ['/'], action: 'filter', hint: 'filter, and read the lines whole' }, + + { context: 'overview', keys: ['h', 'ArrowLeft', 'l', 'ArrowRight', 'k', 'ArrowUp', 'j', 'ArrowDown'], action: 'step', caps: ['h', 'j', 'k', 'l'], hint: 'move' }, + { context: 'overview', keys: digits, action: 'go', caps: ['1…9', '↵'], hint: 'go' }, + { context: 'overview', keys: ['Enter'], action: 'enter' }, + { context: 'overview', keys: ['w', 'q'], action: 'close', caps: ['w', 'esc'], hint: 'close' }, + + { context: 'outline', keys: ['j', 'ArrowDown'], action: 'down', caps: ['j', 'k'], hint: 'move' }, + { context: 'outline', keys: ['k', 'ArrowUp'], action: 'up' }, + { context: 'outline', keys: ['G'], action: 'bottom' }, + { context: 'outline', keys: ['Enter', 'l'], action: 'go', caps: ['↵'], hint: 'jump there' }, + + { context: 'media', keys: ['j', 'ArrowDown'], action: 'down', caps: ['j', 'k'], hint: 'move' }, + { context: 'media', keys: ['k', 'ArrowUp'], action: 'up' }, + { context: 'media', keys: ['G'], action: 'bottom' }, + { context: 'media', keys: ['Enter', 'i'], action: 'insert', caps: ['i'], hint: 'insert' }, + { context: 'media', keys: ['y'], action: 'copy', hint: 'copy url' }, + { context: 'media', keys: ['x'], action: 'delete', hint: 'delete' }, + + { context: 'trash', keys: ['j', 'ArrowDown'], action: 'down', caps: ['j', 'k'], hint: 'move' }, + { context: 'trash', keys: ['k', 'ArrowUp'], action: 'up' }, + { context: 'trash', keys: ['G'], action: 'bottom' }, + { context: 'trash', keys: ['Enter', 'r'], action: 'restore', caps: ['↵', 'r'], hint: 'restore' }, + { context: 'trash', keys: ['x'], action: 'purge', hint: 'delete for good' }, + + { context: 'backlinks', keys: ['j', 'ArrowDown'], action: 'down', caps: ['j', 'k'], hint: 'move' }, + { context: 'backlinks', keys: ['k', 'ArrowUp'], action: 'up' }, + { context: 'backlinks', keys: ['G'], action: 'bottom' }, + { context: 'backlinks', keys: ['Enter'], action: 'open', hint: 'open' }, + + { context: 'history', keys: ['j', 'ArrowDown'], action: 'down', caps: ['j', 'k'], hint: 'earlier / later' }, + { context: 'history', keys: ['k', 'ArrowUp'], action: 'up' }, + { context: 'history', keys: ['G'], action: 'bottom' }, + { context: 'history', keys: ['Enter'], action: 'restore', hint: 'restore' }, + + // --- the editor's own keymap, described only --------------------------------------- + { context: 'editor', label: 'vim', help: 'the full keymap — hjkl, w/b, dd, ciw, visual mode, macros; :w writes' }, + { context: 'editor', label: 'drop · paste', help: 'an image, video or audio file attaches it' }, + { context: 'editor', label: '$x^2$ · $$…$$', help: 'set as math' }, + { context: 'editor', label: '```rust', help: 'a fenced block in a language is coloured as nvim colours it, written and read' }, + { context: 'editor', label: '```typst', help: 'a block typst sets, shown as an image — math, tables, figures; no packages or files' }, + { context: 'editor', label: ':12', help: 'jumps straight to entry 12' }, + { context: 'editor', label: 'y · d', help: 'copied to the system clipboard — :set noclipboard keeps them in the registers' }, + { context: 'editor', label: 'files', help: 'open in place — pdf, text and code, markdown, csv, certificates and keystores' }, +] as const satisfies readonly Binding[] + +/** The help's key columns, in order, and the context each draws from. */ +export const sections: readonly { section: Section; title: string; contexts: readonly Context[]; rule?: string }[] = [ + { section: 'keys', title: 'keys', contexts: ['always', 'workspace', 'global', 'tree', 'special'] }, + { section: 'board', title: 'board keys', contexts: ['board'], rule: 'the first list is the inbox, the last list is done' }, + { section: 'card', title: 'card keys', contexts: ['card'] }, + { section: 'scratch', title: 'scratchpad keys', contexts: ['drawer'] }, + { section: 'agenda', title: 'agenda keys', contexts: ['agenda'] }, + { section: 'editor', title: 'in the editor', contexts: ['editor'] }, +] + +const named: Record<string, string> = { Escape: 'Esc' } + +/** The keys as the help writes them. */ +export const label = (binding: Binding) => binding.label ?? (binding.keys ?? []).map((key) => named[key] ?? key).join(' · ') + +/** The rows of one help column, in the table's order. */ +export function rows(section: Section): { key: string; description: string }[] { + const contexts = sections.find((entry) => entry.section === section)?.contexts ?? [] + return (bindings as readonly Binding[]) + .filter((binding) => binding.help && (binding.section ?? (contexts.includes(binding.context) ? section : undefined)) === section) + .map((binding) => ({ key: label(binding), description: binding.help! })) +} + +const caps: Record<string, string> = { Enter: '↵', Escape: 'esc', Space: '␣', Tab: 'tab' } + +/** The keycaps of a binding: what the footers and the rail show. */ +export function keycaps(binding: Binding): string[] { + if (binding.caps) return [...binding.caps] + return (binding.keys ?? []) + .filter((key) => !key.startsWith('Arrow') && !key.startsWith('Shift-')) + .map((key) => caps[key] ?? key.replace(/^Ctrl-(.)$/, (_, letter: string) => `⌃ ${letter.toUpperCase()}`)) +} + +/** The keycaps of what `action` is bound to in `context`, for a door on the rail. */ +export function keysOf(context: Context, action: string): string[] { + const binding = (bindings as readonly Binding[]).find((entry) => entry.context === context && entry.action === action) + if (!binding) throw new Error(`no key for ${context}.${action}`) + return keycaps(binding) +} + +/** The footer of a context: each binding with a hint, in the table's order. */ +export function hints(contexts: readonly Context[]): { action?: string; caps: string[]; hint: string }[] { + return contexts.flatMap((context) => + (bindings as readonly Binding[]) + .filter((binding) => binding.context === context && binding.hint) + .map((binding) => ({ action: binding.action, caps: keycaps(binding), hint: binding.hint! })), + ) +} diff --git a/web/src/lib/modes.ts b/web/src/lib/modes.ts new file mode 100644 index 0000000..09fb191 --- /dev/null +++ b/web/src/lib/modes.ts @@ -0,0 +1,130 @@ +/* + * Which keys are heard right now, and in what order: a stack of the + * contexts in keymap.ts, the topmost first. A key goes down the stack until + * a context takes it. + * + * always Ctrl-S, Alt-h/j/k/l — heard in the editor too + * a sheet help, trash, the card's back …; nothing under one hears a key + * drawer the scratchpad, while it is down + * workspace Esc out of fullscreen, Q + * writing a document being written takes every plain key back + * tile the focused kind: tree, board, agenda or calendar + * special what the three tiles of the special workspace share + * global spaces, t, T, C, S, :, ? + * + * The `[y/N]` prompt sits over all of it, in App.svelte, and the command + * line keeps its own keys while it has them. + */ +import { actions, type Run } from './actions' +import { bindings, type Binding, type Context } from './keymap' +import { workspace } from './store.svelte' + +/** The contexts that are sheets: one is up when the overlay of that name is. */ +const sheets = new Set<string>(['help', 'overview', 'outline', 'media', 'trash', 'backlinks', 'history', 'card']) + +/** The stack, the topmost first. */ +export function stack(): Context[] { + const modes: Context[] = ['always'] + const overlay = workspace.overlay + if (overlay !== 'none') { + // While the description is written, the card's back has no keys of its own. + if (sheets.has(overlay) && !(overlay === 'card' && workspace.describing)) modes.push(overlay as Context) + return [...modes, 'sheet'] + } + if (workspace.scratchOpen && !workspace.scratchEditing) modes.push('drawer') + modes.push('workspace') + if (workspace.view === 'special') modes.push(workspace.specialFocus, 'special') + else { + if (workspace.editing && workspace.pane === 'document') modes.push('writing') + modes.push('tree') + } + modes.push('global') + return modes +} + +const lone = new Set(['Shift', 'Control', 'Alt', 'AltGraph', 'Meta', 'CapsLock', 'OS']) + +/** + * A key as the table spells it: `j`, `G`, `Enter`, `Shift-Tab`, `Ctrl-d`, + * `Alt-Shift-h`, `1`, `Shift-1`. Digits, and letters under Alt, are read + * from `event.code`, so they are the same key on every layout — and on a + * Mac, where Alt turns h into ˙. Shift is only spelled out where the key + * itself does not already say it. A modifier on its own is no key at all. + */ +export function keyName(event: KeyboardEvent): string | null { + if (lone.has(event.key)) return null + const digit = /^Digit(\d)$/.exec(event.code)?.[1] + const letter = event.altKey && !event.ctrlKey && !event.metaKey ? /^Key([A-Z])$/.exec(event.code)?.[1].toLowerCase() : undefined + const key = digit ?? letter ?? (event.key === ' ' ? 'Space' : event.key) + const shift = event.shiftKey && (key.length > 1 || digit !== undefined || letter !== undefined) + const mods = [event.ctrlKey && 'Ctrl', event.metaKey && 'Meta', event.altKey && 'Alt', shift && 'Shift'] + return [...mods.filter(Boolean), key].join('-') +} + +const plain = (name: string) => !/^(Ctrl|Meta|Alt)-/.test(name) + +const byContext = new Map<Context, Binding[]>() +for (const binding of bindings as readonly Binding[]) { + if (!binding.keys) continue + byContext.set(binding.context, [...(byContext.get(binding.context) ?? []), binding]) +} + +function find(context: Context, name: string): Binding | undefined { + const own = byContext.get(context) ?? [] + // A shifted key nothing asked for is the key itself: Shift-Space is Space. + return own.find((binding) => binding.keys!.includes(name)) ?? (name.startsWith('Shift-') ? find(context, name.slice(6)) : undefined) +} + +function run(binding: Binding, name: string, event: KeyboardEvent): boolean { + const action = (actions[binding.context] as Record<string, Run>)[binding.action!] + return action(name, event) !== false +} + +/** + * Hand a key to the stack. `pending` is the first half of a two-key `gg`, + * `dd`, `]]` … still waiting; what comes back is the half waiting now. + */ +export function dispatch(event: KeyboardEvent, pending: string, line: boolean, typing: boolean): string { + const name = keyName(event) + if (name === null) return pending + // What the editor took for itself — its own Ctrl-S among them — is done; + // running it again here would send a second save racing the first. + if (typing && event.defaultPrevented) return pending + const listening = !line && !typing + const heard = (binding: Binding | undefined) => !!binding && (listening || !!binding.typing) + + // A sheet hears what it hears and lets nothing past it; the drawer and a + // document being written take every plain key that reaches them. + const reach: Context[] = [] + for (const context of stack()) { + reach.push(context) + if (context === 'sheet') break + if (plain(name) && listening && byContext.get(context)?.some((binding) => binding.keys!.includes('*'))) break + } + + const take = (binding: Binding, key: string) => { + if (!run(binding, key, event)) return false + event.preventDefault() + return true + } + + // The second half of a pair goes first, wherever in the stack the pair is. + if (pending && listening) { + for (const context of reach) { + const binding = find(context, pending + name) + if (heard(binding) && take(binding!, pending + name)) return '' + } + } + for (const context of reach) { + const binding = find(context, name) + if (heard(binding) && take(binding!, name)) return '' + // The first half of a pair this context knows waits for the second. + if (listening && !binding && byContext.get(context)?.some((entry) => entry.keys!.some((key) => key.length === 2 && key[0] === name))) { + event.preventDefault() + return name + } + const rest = find(context, '*') + if (listening && plain(name) && heard(rest) && take(rest!, name)) return '' + } + return '' +}