mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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".
558 lines
23 KiB
TypeScript
558 lines
23 KiB
TypeScript
/*
|
|
* 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))
|
|
}
|