mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
keys: one table drives dispatch, help, footers, the rail and the wiki
keymap.ts lists every key with its context, action and help line. actions.ts holds what each one does, typed against the table so a key without an action, or an action without a key, does not compile. modes.ts stacks the contexts (always > sheet > drawer > workspace > writing > focused tile > special > global) and writes the two-key pending logic once. App.svelte's 480-line onkeydown is now one call. Help, the panel footers (a shared Keys component), the rail's keycaps and Commands.md all read the table; the wiki gains the card and scratchpad key tables. One fix on the way: Ctrl-S in the editor no longer saves twice, which could make the second save fail as "changed somewhere else".
This commit is contained in:
1 parent
7f8a3816d3
commit
0eabcb2333
23 files changed
+1213
-1521
No files matched your search
@@ -14,6 +14,7 @@ on:
|
|||||||
- .env.example
|
- .env.example
|
||||||
- web/src/lib/commands.ts
|
- web/src/lib/commands.ts
|
||||||
- web/src/lib/Help.svelte
|
- web/src/lib/Help.svelte
|
||||||
|
- web/src/lib/keymap.ts
|
||||||
- scripts/wiki.ts
|
- scripts/wiki.ts
|
||||||
- .github/workflows/wiki.yml
|
- .github/workflows/wiki.yml
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|||||||
+31
-1
@@ -1,4 +1,4 @@
|
|||||||
<!-- Generated by scripts/wiki.ts from web/src/lib/Help.svelte and web/src/lib/commands.ts. Edit that, then run `make wiki`. -->
|
<!-- Generated by scripts/wiki.ts from web/src/lib/keymap.ts and web/src/lib/commands.ts. Edit that, then run `make wiki`. -->
|
||||||
|
|
||||||
# Keys and commands
|
# 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) |
|
| `x · dd` | delete the card (asks y/N) |
|
||||||
| `q · Esc` | leave the special workspace |
|
| `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
|
### In the agenda
|
||||||
|
|
||||||
| key | what it does |
|
| key | what it does |
|
||||||
@@ -101,6 +119,18 @@ The first list is the inbox, the last list is done.
|
|||||||
| `T` | today's diary entry |
|
| `T` | today's diary entry |
|
||||||
| `q · Esc` | leave the special workspace — a document opened from it comes back here on q |
|
| `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 <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 |
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
A command takes a `!` for its forcing variant where one exists, the way vim's
|
A command takes a `!` for its forcing variant where one exists, the way vim's
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ real data stays out of it.
|
|||||||
| `src/vault.rs` | the vault's keys and file format |
|
| `src/vault.rs` | the vault's keys and file format |
|
||||||
| `src/sweep.rs` | the daily clean-up: trash, old versions, unreferenced uploads |
|
| `src/sweep.rs` | the daily clean-up: trash, old versions, unreferenced uploads |
|
||||||
| `migrations/` | the SQLite schema, one numbered file per change |
|
| `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/store.svelte.ts` | the client's state, and every call to the API |
|
||||||
| `web/src/lib/commands.ts` | the `:` commands |
|
| `web/src/lib/commands.ts` | the `:` commands |
|
||||||
| `web/src/lib/Help.svelte` | the `?` screen |
|
| `web/src/lib/Help.svelte` | the `?` screen |
|
||||||
@@ -78,7 +79,7 @@ Three pages are generated by `scripts/wiki.ts`:
|
|||||||
|
|
||||||
| page | read from |
|
| 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` |
|
| [Configuration](Configuration.md) | `.env.example` |
|
||||||
| [API](API.md) | the API section of `README.md` |
|
| [API](API.md) | the API section of `README.md` |
|
||||||
|
|
||||||
|
|||||||
+20
-17
@@ -4,8 +4,8 @@
|
|||||||
* docs/wiki holds the pages. Three of them are not written by hand but read
|
* 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:
|
* out of the code, so they cannot drift from it:
|
||||||
*
|
*
|
||||||
* - Commands.md, from the keys in Help.svelte and the commands in commands.ts,
|
* - Commands.md, from the keymap in keymap.ts and the commands in
|
||||||
* the same lists `:help` draws;
|
* commands.ts, the same lists `:help` draws;
|
||||||
* - Configuration.md, from the comments and values in .env.example;
|
* - Configuration.md, from the comments and values in .env.example;
|
||||||
* - API.md, from the API section of README.md.
|
* - 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 { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
import { join, resolve } from 'node:path'
|
import { join, resolve } from 'node:path'
|
||||||
|
import { rows, sections, type Section } from '../web/src/lib/keymap'
|
||||||
|
|
||||||
const root = resolve(import.meta.dir, '..')
|
const root = resolve(import.meta.dir, '..')
|
||||||
const pages = join(root, 'docs/wiki')
|
const pages = join(root, 'docs/wiki')
|
||||||
@@ -45,17 +46,11 @@ const code = (text: string) => (text.includes('`') ? `<code>${cell(text).replace
|
|||||||
|
|
||||||
// --- Commands.md ---------------------------------------------------------------
|
// --- Commands.md ---------------------------------------------------------------
|
||||||
|
|
||||||
function keyTable(source: string, name: string): string {
|
function keyTable(section: Section): string {
|
||||||
const start = source.indexOf(`const ${name}: [string, string][] = [`)
|
const entry = sections.find((candidate) => candidate.section === section)!
|
||||||
if (start < 0) throw new Error(`Help.svelte has no ${name}`)
|
const table = ['| key | what it does |', '| --- | --- |', ...rows(section).map(({ key, description }) => `| ${code(key)} | ${cell(description)} |`)].join('\n')
|
||||||
const body = source.slice(start, source.indexOf('\n ]', start))
|
// A rule is not a key but the sentence the help column starts with.
|
||||||
const pairs = [...body.matchAll(new RegExp(`\\[${STRING}, ${STRING}\\]`, 'g'))].map(([, key, description]) => [unquote(key), unquote(description)])
|
return entry.rule ? `${entry.rule[0].toUpperCase()}${entry.rule.slice(1)}.\n\n${table}` : table
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Spec {
|
interface Spec {
|
||||||
@@ -116,7 +111,7 @@ function commandsPage(): string {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
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
|
`# Keys and commands
|
||||||
|
|
||||||
Everything here is also on \`?\` (\`:help\`) inside the app, which draws the same
|
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
|
### Browsing and reading
|
||||||
|
|
||||||
${keyTable(help, 'keys')}
|
${keyTable('keys')}
|
||||||
|
|
||||||
### On a board
|
### On a board
|
||||||
|
|
||||||
${keyTable(help, 'boardKeys')}
|
${keyTable('board')}
|
||||||
|
|
||||||
|
### On the back of a card
|
||||||
|
|
||||||
|
${keyTable('card')}
|
||||||
|
|
||||||
### In the agenda
|
### In the agenda
|
||||||
|
|
||||||
${keyTable(help, 'agendaKeys')}
|
${keyTable('agenda')}
|
||||||
|
|
||||||
|
### In the scratchpad
|
||||||
|
|
||||||
|
${keyTable('scratch')}
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
|||||||
+31
-1133
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,7 @@
|
|||||||
* had today yet. The keys live in App.svelte with the others; this draws
|
* had today yet. The keys live in App.svelte with the others; this draws
|
||||||
* and lets a pointer do the same.
|
* and lets a pointer do the same.
|
||||||
*/
|
*/
|
||||||
|
import Keys from './Keys.svelte'
|
||||||
import type { AgendaCard, DiaryEntry } from './api'
|
import type { AgendaCard, DiaryEntry } from './api'
|
||||||
import { workspace } from './store.svelte'
|
import { workspace } from './store.svelte'
|
||||||
import { runCommand } from './commands'
|
import { runCommand } from './commands'
|
||||||
@@ -111,23 +112,7 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<footer class="label">
|
<Keys contexts={['agenda', 'special']} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -218,13 +203,7 @@
|
|||||||
|
|
||||||
.clear { margin: 0; font-size: 13px; color: var(--subtle); }
|
.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) {
|
@media (max-width: 767px) {
|
||||||
.sheet { padding: 14px; gap: 14px; }
|
.sheet { padding: 14px; gap: 14px; }
|
||||||
.where { display: none; }
|
.where { display: none; }
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Keys from './Keys.svelte'
|
||||||
let sheet = $state<HTMLDivElement>()
|
let sheet = $state<HTMLDivElement>()
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -45,13 +46,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<footer class="label">
|
<Keys contexts={['backlinks', 'sheet']} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -92,10 +87,4 @@
|
|||||||
.muted { font-size: 11px; }
|
.muted { font-size: 11px; }
|
||||||
.context { font-size: 12px; color: var(--subtle); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.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>
|
</style>
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
* idiom as the list. The keys live in App.svelte with every other key, so
|
* 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.
|
* this file only draws and — for a pointer — moves the two cursors.
|
||||||
*/
|
*/
|
||||||
|
import Keys from './Keys.svelte'
|
||||||
import { workspace } from './store.svelte'
|
import { workspace } from './store.svelte'
|
||||||
import { formatDay, labelHue, urgency } from './util'
|
import { formatDay, labelHue, urgency } from './util'
|
||||||
|
|
||||||
@@ -141,20 +142,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- The board's keys are not the list's, so they are spelled out where they apply. -->
|
<!-- The board's keys are not the list's, so they are spelled out where they apply. -->
|
||||||
<footer class="label keys">
|
<div class="keys">
|
||||||
<span><span class="kbd">h</span><span class="kbd">j</span><span class="kbd">k</span><span class="kbd">l</span> move</span>
|
<Keys contexts={['board', 'special']}><span class="rule-hint">first list is the inbox · last list is done</span></Keys>
|
||||||
<span><span class="kbd">H</span><span class="kbd">L</span> send</span>
|
</div>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -243,17 +233,8 @@
|
|||||||
}
|
}
|
||||||
.older:hover { color: var(--light); background: transparent; }
|
.older:hover { color: var(--light); background: transparent; }
|
||||||
|
|
||||||
.keys {
|
.keys { flex: none; padding: 8px 22px 12px; }
|
||||||
flex: none;
|
.rule-hint { margin-left: auto; color: var(--muted); text-transform: none; letter-spacing: 0.04em; }
|
||||||
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; }
|
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -314,7 +295,6 @@
|
|||||||
.card { padding: 10px; }
|
.card { padding: 10px; }
|
||||||
}
|
}
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.keys > span:not(.rule-hint) { display: none; }
|
.rule-hint { margin-left: 0; }
|
||||||
.keys .rule-hint { margin-left: 0; }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
* written in marked, the day under the cursor shown below. The keys live in
|
* 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.
|
* 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 { workspace } from './store.svelte'
|
||||||
import { formatDay } from './util'
|
import { formatDay } from './util'
|
||||||
|
|
||||||
@@ -78,21 +79,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer class="label">
|
<Keys contexts={['calendar', 'special']} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -163,11 +150,5 @@
|
|||||||
.entry .name { font-family: var(--font-sans); font-size: 14px; color: var(--fg); }
|
.entry .name { font-family: var(--font-sans); font-size: 14px; color: var(--fg); }
|
||||||
.none { color: var(--subtle); }
|
.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>
|
</style>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
* description, the checklist. The keys live in App.svelte with the others;
|
* description, the checklist. The keys live in App.svelte with the others;
|
||||||
* this draws and lets a pointer do the same.
|
* this draws and lets a pointer do the same.
|
||||||
*/
|
*/
|
||||||
|
import Keys from './Keys.svelte'
|
||||||
import { followLink } from './commands'
|
import { followLink } from './commands'
|
||||||
import { renderMarkdown } from './markdown'
|
import { renderMarkdown } from './markdown'
|
||||||
import { workspace } from './store.svelte'
|
import { workspace } from './store.svelte'
|
||||||
@@ -126,20 +127,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="label">
|
<Keys contexts={['card']} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -217,9 +205,4 @@
|
|||||||
li.at .tick { border-color: var(--ink); }
|
li.at .tick { border-color: var(--ink); }
|
||||||
li.at.ticked .tick { background: 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>
|
</style>
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
* in the margin, for when there is no margin, or no pointer to click it
|
* 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'.
|
* with. The keys live in App.svelte with the other overlays'.
|
||||||
*/
|
*/
|
||||||
|
import Keys from './Keys.svelte'
|
||||||
import { workspace } from './store.svelte'
|
import { workspace } from './store.svelte'
|
||||||
|
|
||||||
let sheet = $state<HTMLDivElement>()
|
let sheet = $state<HTMLDivElement>()
|
||||||
@@ -44,13 +45,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<footer class="label">
|
<Keys contexts={['outline', 'sheet']} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -96,13 +91,7 @@
|
|||||||
.row:active { transform: none; }
|
.row:active { transform: none; }
|
||||||
.row.at { background: var(--light); color: var(--ink); }
|
.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) {
|
@media (max-width: 767px) {
|
||||||
.sheet { padding: 14px; gap: 14px; }
|
.sheet { padding: 14px; gap: 14px; }
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-152
@@ -2,7 +2,7 @@
|
|||||||
/*
|
/*
|
||||||
* The help is a sheet the size of the porthole, laid out in newspaper
|
* 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
|
* 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.
|
* with the others; these are what they move.
|
||||||
*/
|
*/
|
||||||
let pages: HTMLElement | undefined = $state()
|
let pages: HTMLElement | undefined = $state()
|
||||||
@@ -40,121 +40,9 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { commands, groups, type CommandSpec, type Group } from './commands'
|
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 { 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> = {
|
const titles: Record<Group, string> = {
|
||||||
tree: 'spaces & folders',
|
tree: 'spaces & folders',
|
||||||
@@ -169,9 +57,6 @@
|
|||||||
|
|
||||||
type Row = { key: string; args?: string; description: string }
|
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. */
|
/** `:write(w)` — the name first, then whatever else reaches it. */
|
||||||
const signature = (spec: CommandSpec) =>
|
const signature = (spec: CommandSpec) =>
|
||||||
`:${spec.name}${spec.aliases?.length ? `(${spec.aliases.join(' ')})` : ''}`
|
`:${spec.name}${spec.aliases?.length ? `(${spec.aliases.join(' ')})` : ''}`
|
||||||
@@ -182,24 +67,10 @@
|
|||||||
...(spec.bang ? [{ key: `:${spec.name}!`, description: spec.bang.help }] : []),
|
...(spec.bang ? [{ key: `:${spec.name}!`, description: spec.bang.help }] : []),
|
||||||
])
|
])
|
||||||
|
|
||||||
const editorKeys: [string, string][] = [
|
/* The keys come out of the keymap that dispatches them, so the help
|
||||||
['vim', 'the full keymap — hjkl, w/b, dd, ciw, visual mode, macros; :w writes'],
|
cannot describe a key that does something else. */
|
||||||
['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'],
|
|
||||||
]
|
|
||||||
|
|
||||||
const sections: [string, Row[]][] = [
|
const sections: [string, Row[]][] = [
|
||||||
['keys', fromPairs(keys)],
|
...keySections.map(({ section, title, rule }) => [title, [...(rule ? [{ key: 'rule', description: rule }] : []), ...rows(section)]] as [string, Row[]]),
|
||||||
['board keys', fromPairs(boardKeys)],
|
|
||||||
['card keys', fromPairs(cardKeys)],
|
|
||||||
['scratchpad keys', fromPairs(scratchKeys)],
|
|
||||||
['agenda keys', fromPairs(agendaKeys)],
|
|
||||||
['in the editor', fromPairs(editorKeys)],
|
|
||||||
...groups.map((group: Group) => [titles[group], fromSpecs(commands.filter((spec) => spec.group === group))] as [string, Row[]]),
|
...groups.map((group: Group) => [titles[group], fromSpecs(commands.filter((spec) => spec.group === group))] as [string, Row[]]),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -272,19 +143,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !isTouchDevice}
|
<Keys contexts={['help', 'sheet']} />
|
||||||
<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}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -382,10 +241,6 @@
|
|||||||
|
|
||||||
.none { margin: 0; color: var(--subtle); font-size: 12px; }
|
.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. */
|
/* A phone stacks the columns and scrolls the sheet down. */
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.sheet { overflow-y: auto; }
|
.sheet { overflow-y: auto; }
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Keys from './Keys.svelte'
|
||||||
import { diffLines, folded } from './diff'
|
import { diffLines, folded } from './diff'
|
||||||
import { formatStamp, relative } from './util'
|
import { formatStamp, relative } from './util'
|
||||||
|
|
||||||
@@ -86,15 +87,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="label">
|
<Keys contexts={['history', 'sheet']}><span class="muted">- goes, + comes back</span></Keys>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -149,16 +142,9 @@
|
|||||||
.row.added { background: color-mix(in oklab, var(--light) 14%, transparent); }
|
.row.added { background: color-mix(in oklab, var(--light) 14%, transparent); }
|
||||||
.row.fold { color: var(--muted); }
|
.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) {
|
@media (max-width: 767px) {
|
||||||
.split { flex-direction: column; }
|
.split { flex-direction: column; }
|
||||||
ul { width: auto; max-height: 30%; }
|
ul { width: auto; max-height: 30%; }
|
||||||
footer { display: none; }
|
|
||||||
}
|
|
||||||
@media (pointer: coarse) {
|
|
||||||
footer { display: none; }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -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>
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Keys from './Keys.svelte'
|
||||||
import { formatBytes, formatStamp } from './util'
|
import { formatBytes, formatStamp } from './util'
|
||||||
|
|
||||||
let sheet = $state<HTMLDivElement>()
|
let sheet = $state<HTMLDivElement>()
|
||||||
@@ -92,19 +93,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<footer class="label">
|
<Keys contexts={['media', 'sheet']} hide={workspace.editing ? [] : ['insert']} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -140,12 +129,6 @@
|
|||||||
li:hover { background: var(--surface); }
|
li:hover { background: var(--surface); }
|
||||||
li.at { background: var(--surface-alt); box-shadow: inset 2px 0 0 var(--light); }
|
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 {
|
.thumb {
|
||||||
width: 52px;
|
width: 52px;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* keys live in App.svelte with the others; this only draws and tells them
|
* keys live in App.svelte with the others; this only draws and tells them
|
||||||
* how many cards fit in a row.
|
* how many cards fit in a row.
|
||||||
*/
|
*/
|
||||||
|
import Keys from './Keys.svelte'
|
||||||
import { api, type NodeSummary } from './api'
|
import { api, type NodeSummary } from './api'
|
||||||
import { hooks } from './commands'
|
import { hooks } from './commands'
|
||||||
import { workspace } from './store.svelte'
|
import { workspace } from './store.svelte'
|
||||||
@@ -86,13 +87,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<footer class="label">
|
<Keys contexts={['overview']} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -206,14 +201,8 @@
|
|||||||
.when { flex: none; font-size: 10px; color: var(--muted); }
|
.when { flex: none; font-size: 10px; color: var(--muted); }
|
||||||
.empty { padding: 4px 8px; font-size: 12px; 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. */
|
/* Keys mean nothing to a thumb. */
|
||||||
@media (max-width: 767px), (pointer: coarse) {
|
|
||||||
footer { display: none; }
|
|
||||||
}
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.sheet { padding: 14px; gap: 14px; }
|
.sheet { padding: 14px; gap: 14px; }
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-28
@@ -10,6 +10,7 @@
|
|||||||
import type { NodeSummary } from './api'
|
import type { NodeSummary } from './api'
|
||||||
import { hooks, runCommand } from './commands'
|
import { hooks, runCommand } from './commands'
|
||||||
import Icon, { type IconName } from './Icon.svelte'
|
import Icon, { type IconName } from './Icon.svelte'
|
||||||
|
import { keysOf } from './keymap'
|
||||||
import { workspace } from './store.svelte'
|
import { workspace } from './store.svelte'
|
||||||
|
|
||||||
const { dock = false }: { dock?: boolean } = $props()
|
const { dock = false }: { dock?: boolean } = $props()
|
||||||
@@ -27,17 +28,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const groups = $derived.by((): Door[][] => {
|
const groups = $derived.by((): Door[][] => {
|
||||||
const cmd: Door = { icon: 'prompt', label: 'Command line', keys: [':'], run: () => hooks.openCommandLine(':') }
|
const cmd: Door = { icon: 'prompt', label: 'Command line', keys: keysOf('global', 'line'), run: () => hooks.openCommandLine(':') }
|
||||||
const help: Door = { icon: 'help', label: 'Help', keys: ['?'], run: () => runCommand('help') }
|
const help: Door = { icon: 'help', label: 'Help', keys: keysOf('global', 'help'), run: () => runCommand('help') }
|
||||||
|
|
||||||
if (workspace.view === 'special') {
|
if (workspace.view === 'special') {
|
||||||
// The tiles of the special workspace are doors, so a phone — which
|
// The tiles of the special workspace are doors, so a phone — which
|
||||||
// shows one at a time — can walk between them.
|
// shows one at a time — can walk between them.
|
||||||
const tiles: Door[] = [
|
const tiles: Door[] = [
|
||||||
{ icon: 'board', label: 'Board', keys: ['b'], run: () => (workspace.specialFocus = 'board'), on: workspace.specialFocus === 'board' },
|
{ 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: ['t'], run: () => (workspace.specialFocus = 'agenda'), on: workspace.specialFocus === 'agenda' },
|
{ 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: ['C'], run: () => (workspace.specialFocus = 'calendar'), on: workspace.specialFocus === 'calendar' },
|
{ icon: 'calendar', label: 'Calendar', keys: keysOf('global', 'calendar'), run: () => (workspace.specialFocus = 'calendar'), on: workspace.specialFocus === 'calendar' },
|
||||||
{ icon: 'list', label: 'Back to the tree', keys: ['Esc', 'S'], run: () => workspace.closeSpecial() },
|
{ 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]]
|
if (workspace.specialFocus !== 'board') return [tiles, [cmd, help]]
|
||||||
// The card verbs are doors too, so a phone — which has no H, L or
|
// The card verbs are doors too, so a phone — which has no H, L or
|
||||||
@@ -46,30 +47,30 @@
|
|||||||
return [
|
return [
|
||||||
tiles,
|
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: '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: '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: ['Space'], run: () => workspace.guard(() => workspace.toggleDone()), 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: ['L'], run: () => workspace.guard(() => workspace.sendCard(1)), 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: ['D'], run: () => hooks.openCommandLine(':due '), 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: '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: '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: ['J'], 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',
|
icon: 'rename',
|
||||||
label: card ? 'Rename the card' : 'Rename the list',
|
label: card ? 'Rename the card' : 'Rename the list',
|
||||||
keys: ['R'],
|
keys: keysOf('board', 'rename'),
|
||||||
run: () => hooks.openCommandLine(card ? ':name ' : ':name! '),
|
run: () => hooks.openCommandLine(card ? ':name ' : ':name! '),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'trash',
|
icon: 'trash',
|
||||||
label: card ? 'Delete the card' : 'Delete the list',
|
label: card ? 'Delete the card' : 'Delete the list',
|
||||||
keys: card ? ['x'] : [':list!'],
|
keys: card ? keysOf('board', 'delete') : [':list!'],
|
||||||
run: () => runCommand(card ? 'delete' : 'list!'),
|
run: () => runCommand(card ? 'delete' : 'list!'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -78,39 +79,39 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const board: Door = workspace.space?.has_board
|
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 }
|
: { icon: 'board', label: 'Give this space a board', keys: [':set board'], run: () => runCommand('set board'), dim: true }
|
||||||
return [
|
return [
|
||||||
[
|
[
|
||||||
{ icon: 'doc-plus', label: 'New document', keys: ['o'], run: () => runCommand('new') },
|
{ icon: 'doc-plus', label: 'New document', keys: keysOf('tree', 'new'), run: () => runCommand('new') },
|
||||||
{ icon: 'folder-plus', label: 'New folder', keys: ['⇧ O'], run: () => hooks.openCommandLine(':mkdir ') },
|
{ icon: 'folder-plus', label: 'New folder', keys: keysOf('tree', 'mkdir'), run: () => hooks.openCommandLine(':mkdir ') },
|
||||||
...(workspace.space ? [board] : []),
|
...(workspace.space ? [board] : []),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
{ icon: 'agenda', label: 'Agenda — late, due, in hand', keys: ['t'], run: () => runCommand('agenda') },
|
{ icon: 'agenda', label: 'Agenda — late, due, in hand', keys: keysOf('global', 'agenda'), run: () => runCommand('agenda') },
|
||||||
{ icon: 'calendar', label: 'Calendar — the diary by month', keys: ['C'], run: () => runCommand('cal') },
|
{ icon: 'calendar', label: 'Calendar — the diary by month', keys: keysOf('global', 'calendar'), run: () => runCommand('cal') },
|
||||||
{ icon: 'today', label: "Today's diary entry", keys: ['T'], run: () => runCommand('today') },
|
{ icon: 'today', label: "Today's diary entry", keys: keysOf('global', 'today'), run: () => runCommand('today') },
|
||||||
{ icon: 'capture', label: 'Capture a card to the inbox', keys: ['c'], run: () => hooks.openCommandLine(':card ') },
|
{ 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: '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') },
|
{ icon: 'trash', label: 'The trash — what was deleted', keys: [':trash'], run: () => runCommand('trash') },
|
||||||
...(workspace.marked.length
|
...(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',
|
icon: workspace.fullscreen ? 'shrink' : 'expand',
|
||||||
label: workspace.fullscreen ? 'Leave fullscreen' : 'Fullscreen the focused pane',
|
label: workspace.fullscreen ? 'Leave fullscreen' : 'Fullscreen the focused pane',
|
||||||
keys: ['f'],
|
keys: keysOf('global', 'fullscreen'),
|
||||||
run: () => (workspace.fullscreen = !workspace.fullscreen),
|
run: () => (workspace.fullscreen = !workspace.fullscreen),
|
||||||
on: workspace.fullscreen,
|
on: workspace.fullscreen,
|
||||||
desk: true,
|
desk: true,
|
||||||
},
|
},
|
||||||
{ icon: 'search', label: 'Search this space', keys: ['/'], run: () => hooks.openCommandLine('/') },
|
{ icon: 'search', label: 'Search this space', keys: keysOf('tree', 'search'), run: () => hooks.openCommandLine('/') },
|
||||||
{ icon: 'search-all', label: 'Search every space', keys: ['Ctrl-P'], run: () => hooks.openGlobalSearch() },
|
{ icon: 'search-all', label: 'Search every space', keys: keysOf('global', 'find'), run: () => hooks.openGlobalSearch() },
|
||||||
],
|
],
|
||||||
[cmd, help],
|
[cmd, help],
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* The keys live in App.svelte with the others; this draws and lets a
|
* The keys live in App.svelte with the others; this draws and lets a
|
||||||
* pointer do the same.
|
* pointer do the same.
|
||||||
*/
|
*/
|
||||||
|
import Keys from './Keys.svelte'
|
||||||
import { renderMarkdown } from './markdown'
|
import { renderMarkdown } from './markdown'
|
||||||
import { workspace } from './store.svelte'
|
import { workspace } from './store.svelte'
|
||||||
import { relative } from './util'
|
import { relative } from './util'
|
||||||
@@ -89,20 +90,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="label">
|
|
||||||
{#if workspace.scratchEditing}
|
{#if workspace.scratchEditing}
|
||||||
|
<footer class="label">
|
||||||
<span>writing — it saves itself</span>
|
<span>writing — it saves itself</span>
|
||||||
<span><span class="kbd">esc</span> back to the notes</span>
|
<span><span class="kbd">esc</span> back to the notes</span>
|
||||||
<span><span class="kbd">:keep</span> make it a document</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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Keys from './Keys.svelte'
|
||||||
import { relative } from './util'
|
import { relative } from './util'
|
||||||
|
|
||||||
let sheet = $state<HTMLDivElement>()
|
let sheet = $state<HTMLDivElement>()
|
||||||
@@ -68,15 +69,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<footer class="label">
|
<Keys contexts={['trash', 'sheet']} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -113,12 +106,6 @@
|
|||||||
li:hover { background: var(--surface); }
|
li:hover { background: var(--surface); }
|
||||||
li.at { background: var(--surface-alt); box-shadow: inset 2px 0 0 var(--light); }
|
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; }
|
.kind { width: 64px; flex: none; font-size: 9px; letter-spacing: 0.14em; }
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -43,6 +43,12 @@ export const hooks = {
|
|||||||
openCommandLine: (_initial: string) => {},
|
openCommandLine: (_initial: string) => {},
|
||||||
/** Ctrl-P: a search across every space. */
|
/** Ctrl-P: a search across every space. */
|
||||||
openGlobalSearch: () => {},
|
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. */
|
/** Help groups the command list under these headings, in this order. */
|
||||||
|
|||||||
@@ -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! })),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 ''
|
||||||
|
}
|
||||||
Reference in new issue
Block a user