keys: Ctrl moves between tiles, Ctrl-Q jots from anywhere, the plan gets a screen of its own

Alt never reached the app, so the tile keys are Ctrl-h/j/k/l and
Ctrl-Shift-h/j/k/l, taken before vim sees them, and h/l walk the tiles
while reading. Ctrl-Q lowers the scratchpad from the editor straight
into the last note, and raises it back to the document, still writing.
The drawer sits in the porthole's opening with a full rim. The board,
the agenda, the calendar and a card's back live on a plan screen that
S goes to and comes back from, so documents and views never share the
screen. The calendar fits its tile instead of scrolling.
This commit is contained in:
nvrl committed 2026-10-22 16:51:50 +02:00
1 parent b1107013ee
commit 650fc40fa7
26 files changed
+340 -221

No files matched your search

+7 -2
View File
@@ -246,8 +246,8 @@
[[...keysOf('tree', 'down'), ...keysOf('tree', 'up')], 'browse, l opens, L opens beside'],
[keysOf('global', 'space'), 'switch space, Tab cycles'],
[keysOf('global', 'overview'), 'overview of every space'],
[keysOf('global', 'plan'), 'board, agenda and calendar'],
[[...keysOf('global', 'board'), ...keysOf('global', 'agenda'), ...keysOf('global', 'calendar')], 'one of them, in a tile'],
[keysOf('global', 'plan'), 'the plan: board, agenda, calendar'],
[[...keysOf('global', 'board'), ...keysOf('global', 'agenda'), ...keysOf('global', 'calendar')], 'one of them, on the plan'],
[keysOf('tree', 'capture'), 'capture a card to the inbox'],
[keysOf('global', 'find'), 'find in every space'],
[keysOf('tree', 'search'), 'search this space'],
@@ -293,6 +293,7 @@
class="panes switch"
class:reading={workspace.tiles.all.some((tile) => !tile.empty)}
class:full={workspace.fullscreen}
class:plan={workspace.tiles.planning}
class:focus-doc={workspace.focus !== 'list'}
style="--dir: {workspace.switchDirection}"
>
@@ -433,6 +434,10 @@
height: calc(var(--h) * 100% - var(--gap));
}
/* The plan screen: the views alone, with no list beside them. */
.panes.plan { grid-template-columns: 1fr; }
.panes.plan .window.list { display: none; }
/* `f`: the focused pane fills the porthole — the list, or one tile. */
.panes.full { grid-template-columns: 1fr; }
.panes.full.focus-doc .window.list,
+17 -7
View File
@@ -83,14 +83,18 @@
</div>
<style>
/* A tile's window is its frame, so it only fills it. */
/*
* A tile's window is its frame, so it only fills it — and the month is
* fitted into what the tile leaves it, never scrolled: the weeks share the
* height, up to a row a pointer still hits comfortably.
*/
.sheet {
display: flex;
flex-direction: column;
gap: 16px;
gap: 12px;
height: 100%;
overflow-y: auto;
padding: 20px 24px;
overflow: hidden;
padding: 16px 20px;
}
header { display: flex; align-items: center; gap: 12px; }
@@ -99,21 +103,26 @@
header .title { color: var(--light); }
.grid {
flex: 0 1 auto;
min-height: 0;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
grid-template-rows: auto;
grid-auto-rows: minmax(0, 40px);
gap: 3px;
list-style: none;
margin: 0;
padding: 0;
}
.weekday { text-align: center; font-size: 10px; color: var(--muted); }
.grid > li { min-height: 0; }
.day {
position: relative;
display: grid;
place-items: center;
width: 100%;
aspect-ratio: 1.4;
height: 100%;
padding: 0;
border: 1px solid transparent;
border-radius: var(--radius-s);
@@ -126,12 +135,13 @@
.day.written { color: var(--fg); }
.day.today { border-color: var(--rim); }
.day.at { background: var(--light); color: var(--ink); }
.dot, .many { position: absolute; bottom: 4px; }
.dot, .many { position: absolute; bottom: 3px; }
.dot { width: 5px; height: 5px; border-radius: 999px; background: var(--light); }
.many { font-size: 9px; color: var(--light); }
.day.at :is(.dot) { background: var(--ink); }
.day.at .many { color: var(--ink); }
.chosen { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
.chosen h2 {
display: flex;
align-items: center;
+9 -2
View File
@@ -155,6 +155,7 @@
import { onDestroy, onMount } from 'svelte'
import { api } from './api'
import { hooks } from './commands'
import { claim } from './modes'
import { editorTheme, highlighting, languages } from './editorTheme'
import { embedSnippet } from './markdown'
import { workspace, type Tile } from './store.svelte'
@@ -180,9 +181,11 @@
/*
* Esc, before vim sees it: in normal mode, or with no vim, it leaves, as
* `:q` does — unless it is closing the completions.
* `:q` does — unless it is closing the completions. The Ctrl keys heard
* everywhere — between the tiles, the scratchpad — go before vim too.
*/
function onkeydown(event: KeyboardEvent) {
if (claim(event)) return
if (event.key !== 'Escape' || (workspace.vimEnabled && mode !== 'normal')) return
if (view && completionStatus(view.state) !== null) return
event.preventDefault()
@@ -317,6 +320,7 @@
parent: host!,
})
if (writing?.append) view.dispatch({ selection: { anchor: text.length } })
if (writing || workspace.tile === tile) view.focus()
const cm = workspace.vimEnabled ? getCM(view) : null
@@ -333,7 +337,10 @@
// A fresh entry starts in insert mode, and so does an empty short text,
// which is there to be written; anything else starts in normal mode,
// the way opening a file in vim does.
if (tile?.enterInsert || (writing && !text.trim())) {
if (writing?.append && text.trim()) {
Vim.handleKey(cm, 'G', 'workspace')
Vim.handleKey(cm, 'A', 'workspace')
} else if (tile?.enterInsert || (writing && !text.trim())) {
if (tile) tile.enterInsert = false
Vim.handleKey(cm, 'i', 'workspace')
}
+18 -13
View File
@@ -83,6 +83,7 @@
<footer class="label">
<span>writing — it saves itself</span>
<span><span class="kbd">esc</span> back to the notes</span>
<span><span class="kbd">⌃ Q</span> back to where you were</span>
<span><span class="kbd">:keep</span> make it a document</span>
</footer>
{:else}
@@ -92,31 +93,35 @@
<style>
/*
* Hung from under the bar across the frame's opening, as the desktop's
* scratchpad slides down from under its bar: hull, a rim, the lower corners
* rounded, and the light along its lower edge to say it has the keys.
* The upper part of the porthole, come down over what is in it: the same
* opening under the bar, the same rounded corners, and the focused
* window's lit rim, since it has the keys while it is down. It is
* uncovered from the top, as the desktop's scratchpad comes from under its
* bar.
*/
.drawer {
--pane-bg: color-mix(in oklab, var(--surface) 40%, var(--hull));
position: absolute;
top: calc(var(--bar-h) + var(--frame));
left: calc(var(--frame) + 24px);
right: calc(var(--rail-w) + 24px);
top: var(--bar-h);
left: var(--frame);
right: var(--rail-w);
z-index: 25;
height: min(62vh, 640px);
height: min(62vh, 640px, calc(100% - var(--bar-h) - var(--status-h)));
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 20px 12px;
background: var(--hull);
border: 1px solid var(--surface-alt);
border-top: 0;
border-bottom-color: var(--light-dim);
border-radius: 0 0 var(--frame-radius) var(--frame-radius);
border: 1px solid transparent;
border-radius: var(--frame-radius);
background:
linear-gradient(var(--pane-bg), var(--pane-bg)) padding-box,
linear-gradient(135deg, var(--light), var(--light-dim)) border-box;
box-shadow: 0 18px 40px color-mix(in oklab, var(--ink) 70%, transparent);
animation: lower var(--t-slow) var(--ease-snap) both;
}
@keyframes lower {
from { transform: translateY(-104%); }
from { clip-path: inset(0 0 100% 0 round var(--frame-radius)); }
to { clip-path: inset(0 0 0 0 round var(--frame-radius)); }
}
header { display: flex; align-items: center; gap: 10px; }
+1 -1
View File
@@ -135,7 +135,7 @@
actions(index)
}}
class:active={index === workspace.tree.cursor}
class:open={workspace.tiles.all.some((tile) => tile.open?.id === node.id)}
class:open={workspace.tiles.every.some((tile) => tile.open?.id === node.id)}
class:marked={workspace.tree.isMarked(node.id)}
class:nested
data-index={index}
+7 -1
View File
@@ -7,7 +7,13 @@
const { pending = '' }: { pending?: string } = $props()
const mode = $derived(
workspace.editing ? (workspace.vimEnabled ? workspace.vimMode.toUpperCase() : 'EDIT') : 'BROWSE',
workspace.editing
? workspace.vimEnabled
? workspace.vimMode.toUpperCase()
: 'EDIT'
: workspace.tiles.planning
? 'PLAN'
: 'BROWSE',
)
// The path is the file name here: work/einarbeiten/01-was-ist-eebus.
+25 -13
View File
@@ -82,7 +82,7 @@ function focusPane(pane: 'list' | 'document') {
}
/*
* Alt + h/j/k/l: the focus goes to the tile that way, out of the editor
* Ctrl + 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.
*/
@@ -93,7 +93,8 @@ function tileKey(direction: Direction, shift: boolean) {
}
const next = neighbour(dwindle(workspace.tiles.all.length), workspace.tiles.focused, direction)
if (next < 0) {
if (direction === 'h' && !shift) focusPane('list')
// The plan screen has no list beside it.
if (direction === 'h' && !shift && !workspace.tiles.planning) focusPane('list')
return
}
if (!shift) return void focusTile(next)
@@ -103,15 +104,24 @@ function tileKey(direction: Direction, shift: boolean) {
workspace.tiles.focused = next
}
/** The drawer up, and the keys back where they were: in the editor, when a document was being written. */
async function raise() {
await workspace.scratch.close()
if (!workspace.scratch.open && workspace.editing && workspace.pane === 'document') hooks.focusEditor()
}
export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
always: {
save: () => {
if (workspace.drafts.writing) guard(() => workspace.drafts.saveWriting())
else if (workspace.open) guard(() => workspace.drafts.save())
},
close: () => void runCommand('close'),
focus: (key) => tileKey(key.slice(-1) as Direction, false),
swap: (key) => tileKey(key.slice(-1) as Direction, true),
// Under the drawer the tiles are out of sight, so the keys are the note's.
focus: (key) => (workspace.scratch.open ? false : tileKey(key.slice(-1) as Direction, false)),
swap: (key) => (workspace.scratch.open ? false : tileKey(key.slice(-1).toLowerCase() as Direction, true)),
// Ctrl-Q: down with the last note being written, and up again back to
// what was being done — the document still in the mode it was left in.
scratch: () => guard(() => (workspace.scratch.open ? raise() : workspace.scratch.jot())),
},
sheet: {
@@ -318,7 +328,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
},
line: () => line(':'),
help: () => void runCommand('help'),
raise: () => void workspace.scratch.close(),
raise: () => guard(raise),
nothing: () => {},
},
@@ -355,6 +365,8 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
open: (key) => {
const enter = key === 'Enter' || key === 'Shift-Enter'
if (enter && reading() && hooks.fileAction()) return
// Reading, `l` is the tile to the right, as `h` is the one to the left.
if (!enter && workspace.pane === 'document' && workspace.open) return tileKey('l', false)
// Shift-Enter, like `L`: beside what is open, in a tile of its own.
if (key === 'Shift-Enter' && !reading()) return guard(() => workspace.tiles.openBeside())
const shown = workspace.tiles.all.findIndex((tile) => tile.open && tile.open.id === workspace.tree.selected?.id)
@@ -364,10 +376,10 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
beside: () => {
if (!reading()) guard(() => workspace.tiles.openBeside())
},
// Leftwards at every depth: from the document to the list, from the
// list up a level.
// Leftwards at every depth: from a document to the tile left of it, from
// the leftmost to the list, from the list up a level.
left: () => {
if (workspace.open && workspace.pane === 'document') focusPane('list')
if (workspace.open && workspace.pane === 'document') tileKey('h', false)
else guard(() => workspace.tree.up())
},
sideways: (key) => {
@@ -516,7 +528,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
open: () => void workspace.guard(() => workspace.diary.openCalendarDay()).then(() => hooks.focusEditor()),
},
/** What every view shares: `q` closes its tile, back to the one before. */
/** What every view shares: `q` closes a card's back, and from the others goes back to the documents. */
views: {
close: () => void runCommand('close'),
},
@@ -549,9 +561,9 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
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.
// `S`, as the desktop's special workspace is bound: the plan screen —
// the board, the agenda and the calendar — and back to the documents.
// `b`, `t` and `C` go there too, to their own view.
plan: () => void runCommand('plan'),
line: () => line(':'),
help: () => void runCommand('help'),
+3 -3
View File
@@ -591,14 +591,14 @@ export const commands: CommandSpec[] = [
{
name: 'board',
group: 'board',
help: "this space's board, in a tile — the last one shown when this space has none",
help: "this space's board, on the plan screen — the last one shown when this space has none",
run: () => workspace.guard(() => workspace.tiles.show('board')),
},
{
name: 'plan',
aliases: ['special'],
group: 'board',
help: 'the board, the agenda and the calendar, tiled ahead of what is open — S puts them up, and every view away again; 1 … 9 on the board picks the board',
help: 'the plan screen: the board, the agenda and the calendar, in place of the documents — S goes there and back again; 1 … 9 on the board picks the board',
run: () => workspace.guard(() => workspace.tiles.togglePlan()),
},
{
@@ -874,7 +874,7 @@ export const commands: CommandSpec[] = [
name: 'close',
aliases: ['clo'],
group: 'tiles',
help: 'close the focused tile — Alt-q; asks when its draft could not be written',
help: 'close the focused tile — q while reading; on the plan screen, back to the documents; asks when its draft could not be written',
run: () =>
workspace.guard(async () => {
await workspace.drafts.flushTile()
+11 -11
View File
@@ -46,7 +46,7 @@ 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 `*`
* `Shift-Tab`, `Ctrl-d`, `Ctrl-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.
*/
@@ -78,12 +78,11 @@ export const bindings = [
{ 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', essential: true },
{ 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: 'always', keys: ['Ctrl-h', 'Ctrl-j', 'Ctrl-k', 'Ctrl-l'], action: 'focus', typing: true, label: 'Ctrl-h/j/k/l', help: 'focus the tile that way, from the editor too — left of the leftmost is the list; h / l do the same while reading', essential: true },
{ context: 'always', keys: ['Ctrl-H', 'Ctrl-J', 'Ctrl-K', 'Ctrl-L'], action: 'swap', typing: true, label: 'Ctrl-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: 'global', keys: ['gt'], action: 'nextTile', label: 'gt / gT', help: 'next / previous tile — a view too' },
{ context: 'global', 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', essential: true },
{ 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' },
@@ -109,14 +108,14 @@ export const bindings = [
{ context: 'tree', keys: ['za'], action: 'files', help: "a document's own files, folded open under it in the list — again folds them away; p drops marked files in there" },
{ 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: 'plan', help: 'the board, the agenda and the calendar, tiled ahead of what is open with the board focused — S again puts every view away', essential: true },
{ context: 'global', keys: ['t'], action: 'agenda', help: 'agenda — late, due this week, in hand, on every board — in a tile of its own', essential: true },
{ context: 'global', keys: ['S'], action: 'plan', help: 'the plan screen: the board, the agenda and the calendar, in place of the documents and the list — S again goes back to the documents as they were', essential: true },
{ context: 'global', keys: ['t'], action: 'agenda', help: 'agenda — late, due this week, in hand, on every board — on the plan screen', essential: true },
{ context: 'global', keys: ['T'], action: 'today', help: "today's diary entry, started if there is none — from templates/day when the diary has one", essential: true },
{ context: 'global', keys: ['C'], action: 'calendar', help: 'the diary by month, in a tile: h/l a day, j/k a week, H/L a month, . today, Enter opens or starts the day beside it' },
{ context: 'global', keys: ['C'], action: 'calendar', help: 'the diary by month, on the plan screen: h/l a day, j/k a week, H/L a month, . today, Enter opens or starts the day among the documents' },
{ 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', essential: true },
{ context: 'global', keys: ['b'], action: 'board', help: "the board, in a tile: this space's, or the last one shown — 1 … 9 there shows another's" },
{ context: 'global', keys: ['b'], action: 'board', help: "the board, on the plan screen: this space's, or the last one shown — 1 … 9 there shows another's" },
{ context: 'tree', keys: ['c'], action: 'capture', help: "capture a card into this space's inbox, without leaving here — reading a document, a card about it; Tab on the line makes it a note, or a line in today's entry", essential: true },
{ context: 'tree', keys: ['q'], action: 'close', label: 'Esc · q', help: 'close the document, and its tile when there are several — Esc first puts down what m picked up', essential: true },
{ context: 'tree', keys: ['Escape'], action: 'escape' },
@@ -136,12 +135,13 @@ export const bindings = [
// --- 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', essential: true },
{ context: 'always', keys: ['Ctrl-q'], action: 'scratch', typing: true, section: 'scratch', label: 'Ctrl-Q', help: 'from anywhere, the editor too: the scratchpad down with the last note being written, and again back to where you were, still writing — Firefox quits on it instead', essential: true },
// 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' },
// --- what the views share: the board, the agenda, the calendar, a card's back
{ context: 'views', keys: ['q', 'Escape'], action: 'close', label: 'q · Esc', caps: ['q'], hint: 'close', help: "in a view — the board, the agenda, the calendar, a card's back — close its tile; the focus goes back to the tile before" },
{ context: 'views', keys: ['q', 'Escape'], action: 'close', label: 'q · Esc', caps: ['q'], hint: 'close', help: "on the plan screen: from a card's back to the board, from the board, the agenda or the calendar back to the documents" },
// --- 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, to the tile beside' },
@@ -156,7 +156,7 @@ export const bindings = [
{ 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, in a tile beside: 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: ['gf'], action: 'notes', help: "the card's notes — a document, made on first use, opened among the documents; S 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' },
@@ -183,7 +183,7 @@ export const bindings = [
{ 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: ['gf'], action: 'notes', hint: 'notes', help: "the card's notes — a document, made on first use, opened among the documents; S comes back" },
{ context: 'card', keys: [':'], action: 'line' },
// --- the scratchpad, while it is down --------------------------------------------
+18 -2
View File
@@ -3,7 +3,7 @@
* 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
* always Ctrl-S, Ctrl-Q, Ctrl-h/j/k/l — heard in the editor too
* a sheet help, info, trash …; nothing under one hears a key
* drawer the scratchpad, while it is down
* workspace Esc out of fullscreen, Q
@@ -52,7 +52,7 @@ const lone = new Set(['Shift', 'Control', 'Alt', 'AltGraph', 'Meta', 'CapsLock',
/**
* 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
* `Ctrl-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.
@@ -134,3 +134,19 @@ export function dispatch(event: KeyboardEvent, pending: string, line: boolean, t
}
return ''
}
/**
* A Ctrl key heard in every context, taken before the editor sees it: vim
* has uses of its own for Ctrl-h and Ctrl-k, and what it handles never
* reaches `dispatch`. Ctrl-S is left to the editor, which saves its own
* text. True when it was taken.
*/
export function claim(event: KeyboardEvent): boolean {
const name = keyName(event)
if (!name?.startsWith('Ctrl-')) return false
const binding = find('always', name)
if (!binding?.typing || binding.action === 'save' || !run(binding, name, event)) return false
event.preventDefault()
event.stopPropagation()
return true
}
+1 -1
View File
@@ -334,7 +334,7 @@ export class Cards {
/** The cards about each document open in a tile, read again. */
async #reloadCardsAbout() {
await Promise.all(
this.ws.tiles.all.map(async (tile) => {
this.ws.tiles.every.map(async (tile) => {
const id = tile.open?.kind === 'document' ? tile.open.id : null
if (id === null) return
const cards = await api.nodeCards(id).catch(() => null)
+4 -4
View File
@@ -89,7 +89,7 @@ export class Drafts {
/** Back online: what is waiting goes now rather than at the next retry. */
watchConnection() {
window.addEventListener('online', () => {
for (const tile of this.ws.tiles.all) {
for (const tile of this.ws.tiles.every) {
if (!tile.dirty) continue
tile.stopAutosave()
void this.#writeBack(tile)
@@ -107,7 +107,7 @@ export class Drafts {
* unsaved work without a word.
*/
async flush() {
for (const tile of this.ws.tiles.all) await this.flushTile(tile)
for (const tile of this.ws.tiles.every) await this.flushTile(tile)
}
/** The same for one tile only, as it is about to show something else. */
@@ -173,7 +173,7 @@ export class Drafts {
* then refused, and `:e!` takes the rewritten text.
*/
async reread() {
for (const tile of this.ws.tiles.all) {
for (const tile of this.ws.tiles.every) {
if (!tile.open || tile.editing || tile.dirty) continue
const node = await api.getNode(tile.open.id).catch(() => null)
if (!node) continue
@@ -236,7 +236,7 @@ export class Drafts {
/** Every tile showing `id` reads it again, after it was written somewhere else. */
async rereadNode(id: number) {
for (const tile of this.ws.tiles.all) {
for (const tile of this.ws.tiles.every) {
if (tile.open?.id !== id || tile.dirty) continue
const node = await api.getNode(id)
tile.open = node
+12
View File
@@ -43,6 +43,18 @@ export class Scratchpad {
this.open = false
}
/**
* Ctrl-Q, from anywhere: the drawer down with the note written last being
* written on, at its end — or, when it is down, up again.
*/
async jot() {
if (this.open) return this.close()
await this.toggle()
this.cursor = 0
this.editNote()
if (this.ws.drafts.writing) this.ws.drafts.writing.append = true
}
/** `o`: a new note at the top, written straight away. */
newNote() {
this.#writeNote(null, '')
+70 -40
View File
@@ -1,6 +1,6 @@
import { isContainer, type WorkspaceNode } from '../api'
import { formatDay } from '../util'
import type { Workspace } from '../store.svelte'
import type { Pane, Workspace } from '../store.svelte'
/**
* What a tile shows: a node of the tree — a document or a file — or one of
@@ -68,9 +68,10 @@ export class Tile {
}
/**
* The tiles on screen and which has the focus: opened, closed, walked, and
* the views — the board, the agenda, the calendar, a card's back — put up
* among them.
* The tiles on screen and which has the focus: opened, closed, walked. There
* are two screens of them, and one is shown at a time: the documents, beside
* the list, and the plan — the board, the agenda, the calendar, a card's
* back — which `S` goes to and comes back from.
*/
export class Tiles {
readonly ws: Workspace
@@ -91,9 +92,19 @@ export class Tiles {
return this.all[this.focused] ?? this.all[0]
}
/** Every tile showing `id`. */
/** The plan screen is on show, and the documents are put away behind it. */
planning = $state(false)
/** The screen not on show, as it was left. */
#away = $state<{ all: Tile[]; focused: number; trail: Tile[]; pane: Pane }>({ all: [], focused: 0, trail: [], pane: 'list' })
/** The tiles of both screens: a draft behind the plan is still written. */
get every(): Tile[] {
return [...this.all, ...this.#away.all]
}
/** Every tile showing `id`, on either screen. */
showing(id: number): Tile[] {
return this.all.filter((tile) => tile.open?.id === id)
return this.every.filter((tile) => tile.open?.id === id)
}
/*
@@ -158,8 +169,13 @@ export class Tiles {
if (this.current.empty) this.ws.pane = 'list'
}
/** `:close`: the focused tile goes, or with only one, what it shows. */
/**
* `:close`: the focused tile goes, or with only one, what it shows. On the
* plan only a card's back goes; closing the board, the agenda or the
* calendar goes back to the documents.
*/
close() {
if (this.planning && this.current.kind !== 'card') return this.leavePlan()
this.ws.drafts.leaveCard()
this.current.clear()
this.prune()
@@ -201,46 +217,60 @@ export class Tiles {
/*
* The views — the board, the agenda, the calendar, a card's back — are
* tiles, opened beside what is on screen. Each is read again whenever it is
* gone to: it is looked at in passing, and what it says should be what is
* true now.
* the tiles of the plan screen. Each is read again whenever it is gone to:
* it is looked at in passing, and what it says should be what is true now.
*/
/** `b`, `t`, `C`, Enter on a card: to that view's tile, opening it first when it is not up. */
/** The views the plan screen holds, in the order they are tiled. */
static readonly PLAN: View[] = ['board', 'agenda', 'calendar']
/** The other screen on show, and this one put away as it is. */
swap() {
const away = this.#away
this.#away = { all: this.all, focused: this.focused, trail: this.trail, pane: this.ws.pane }
this.all = away.all.length > 0 ? away.all : [new Tile()]
this.focused = Math.min(away.focused, this.all.length - 1)
this.trail = away.trail
this.ws.pane = away.pane
this.planning = !this.planning
}
/** To the plan screen, with every view on it, from wherever the documents were. */
enterPlan() {
if (this.planning) return
this.ws.drafts.leaveCard()
this.swap()
const cards = this.all.filter((tile) => tile.kind === 'card')
this.all = [...Tiles.PLAN.map((kind) => this.view(kind) ?? new Tile(kind)), ...cards]
this.focused = Math.min(this.focused, this.all.length - 1)
this.ws.pane = 'document'
}
/** Back to the documents, as they were left. */
leavePlan() {
if (!this.planning) return
this.ws.drafts.leaveCard()
this.swap()
// What was closed behind the plan — deleted, moved away — is gone from here too.
this.prune()
if (!this.current.empty) this.focusOn(this.focused)
}
/** `b`, `t`, `C`, Enter on a card: to the plan screen, and that view on it focused. */
async show(kind: View) {
await this.loadView(kind)
if (!this.view(kind)) {
// The one empty tile there can be takes it; otherwise it goes beside.
if (this.current.empty) this.current.kind = kind
else this.all.push(new Tile(kind))
}
// Coming to the plan, every view on it is read again, not only this one.
const kinds = this.planning ? [kind] : Tiles.PLAN
await Promise.all(kinds.map((each) => this.loadView(each)))
this.enterPlan()
if (!this.view(kind)) this.all.push(new Tile(kind))
this.focusOn(this.all.indexOf(this.view(kind)!))
if (kind === 'board' && this.ws.cards.boardSpaceId === null) this.ws.say('no space has a board — :set board turns one on', 'error')
}
/** The views `S` puts up together, in the order they are tiled. */
static readonly PLAN: View[] = ['board', 'agenda', 'calendar']
/**
* `S`: the board, the agenda and the calendar, tiled ahead of the
* documents with the board focused — or, when all three are up, every view
* put away again.
*/
/** `S`: the plan screen with the board focused, or back to the documents. */
async togglePlan() {
if (Tiles.PLAN.every((kind) => this.view(kind))) {
for (const tile of this.all) if (tile.kind !== 'node') tile.clear()
this.prune()
if (!this.current.empty) this.focusOn(this.focused)
return
}
await Promise.all(Tiles.PLAN.map((kind) => this.loadView(kind)))
const current = this.current
const missing = Tiles.PLAN.filter((kind) => !this.view(kind)).map((kind) => new Tile(kind))
this.all = [...missing, ...this.all]
this.focused = this.all.indexOf(current)
this.prune()
this.focusOn(this.all.indexOf(this.view('board')!))
if (this.ws.cards.boardSpaceId === null) this.ws.say('no space has a board — :set board turns one on', 'error')
if (this.planning) return this.leavePlan()
await this.show('board')
}
/** Read again what a view shows. */
@@ -253,7 +283,7 @@ export class Tiles {
}
}
/** Every view on screen read again, after a space switch brought them. */
/** Every view on the plan read again, after a space switch. */
async loadViews() {
if (this.view('board') || this.view('card')) await this.ws.cards.loadBoard()
if (this.view('calendar')) await this.loadView('calendar')
+42 -42
View File
@@ -5,9 +5,10 @@ import type { Workspace, Pane } from '../store.svelte'
/**
* Where a space was left: the folder, the tiles, the row, the pane. A tile
* is the id of the node it shows, or the name of the view. `openId` is the
* focused tile's, all a spot held before there were tiles, and `open` the
* documents alone, all it held before there were views.
* is the id of the node it shows; the name of a view is what a spot held
* while views were tiled among the documents, and is passed over now.
* `openId` is the focused tile's, all a spot held before there were tiles,
* and `open` the documents alone, all it held before there were views.
*/
type Spot = {
parentId: number
@@ -144,47 +145,49 @@ export class Tree {
if (start) await this.enterSpace(start.id)
}
/** `views` false: the views are leaving with the focus, and are not left behind. */
#rememberSpot(views = true) {
/** Where this space is left, its documents on screen. */
#rememberSpot() {
if (this.spaceId === null || this.parentId === null) return
// A card's back is the card under the board's cursor, which is not kept.
const kept = this.ws.tiles.all.filter((tile) => !tile.empty && (tile.kind === 'node' || (views && tile.kind !== 'card')))
const kept = this.ws.tiles.all.filter((tile) => tile.open)
this.#spots[this.spaceId] = {
parentId: this.parentId,
openId: this.ws.open?.id ?? null,
cursor: this.cursor,
pane: this.ws.pane,
tiles: kept.map((tile) => (tile.kind === 'node' ? tile.open!.id : tile.kind)),
tiles: kept.map((tile) => tile.open!.id),
focused: Math.max(kept.indexOf(this.ws.tile), 0),
}
localStorage.setItem('diary:spots', JSON.stringify(this.#spots))
}
async enterSpace(id: number) {
const leaving = this.spaceId !== null && this.spaceId !== id
/*
* The views look at every board and the whole diary, so with one of them
* focused they come along — moved, not copied — as the special workspace
* once did: a digit on the board shows that space's board, and the
* agenda stays beside it.
* The views look at every board and the whole diary, so the plan screen
* stays as it is — a digit on the board shows that space's board — and
* it is the documents behind it that are this space's.
*/
let carried: Tile[] = []
let carriedFocus: Tile | undefined
const planning = this.ws.tiles.planning
if (planning) this.ws.tiles.swap()
try {
await this.#enterSpace(id)
} finally {
if (planning) this.ws.tiles.swap()
}
await this.ws.tiles.loadViews()
}
async #enterSpace(id: number) {
const leaving = this.spaceId !== null && this.spaceId !== id
if (leaving) {
await this.ws.drafts.flush()
const carry = this.ws.focus !== 'list' && this.ws.focus !== 'node'
this.#rememberSpot(!carry)
this.#rememberSpot()
const from = this.spaces.findIndex((s) => s.id === this.spaceId)
const to = this.spaces.findIndex((s) => s.id === id)
this.ws.switchDirection = to >= from ? 1 : -1
if (carry) {
carried = this.ws.tiles.all.filter((tile) => tile.kind !== 'node' && tile.kind !== 'card')
carriedFocus = this.ws.tile.kind === 'card' ? (this.ws.tiles.view('board') ?? carried[0]) : this.ws.tile
}
// Each space has tiles of its own; these come back with this one.
for (const tile of this.ws.tiles.all) tile.stopAutosave()
this.ws.tiles.replace(carried, carriedFocus)
this.ws.pane = carried.length > 0 ? 'document' : 'list'
this.ws.tiles.replace([])
this.ws.pane = 'list'
}
this.spaceId = id
localStorage.setItem('diary:space', String(id))
@@ -204,12 +207,7 @@ export class Tree {
spot.tiles ?? spot.open ?? (spot.openId !== null && this.nodes.some((n) => n.id === spot.openId) ? [spot.openId] : [])
const restored: Tile[] = []
for (const entry of entries) {
if (typeof entry === 'string') {
let tile = this.ws.tiles.view(entry)
if (!tile) this.ws.tiles.all.push((tile = new Tile(entry)))
restored.push(tile)
continue
}
if (typeof entry === 'string') continue
const empty = this.ws.tiles.all.findIndex((tile) => tile.empty)
if (empty >= 0) this.ws.tiles.focused = empty
else {
@@ -224,25 +222,23 @@ export class Tree {
}
}
this.ws.tiles.prune()
const back = carriedFocus ?? restored[Math.min(spot.focused ?? 0, restored.length - 1)]
const back = restored[Math.min(spot.focused ?? 0, restored.length - 1)]
if (back && this.ws.tiles.all.includes(back)) {
this.ws.tiles.focusOn(this.ws.tiles.all.indexOf(back))
if (!carriedFocus) this.ws.pane = spot.pane
this.ws.pane = spot.pane
} else {
this.cursor = Math.min(spot.cursor, Math.max(this.nodes.length - 1, 0))
}
}
this.ws.tiles.trail = []
await this.ws.tiles.loadViews()
}
/** Show the children of `id`, which must be a space or a folder. */
async goTo(id: number, keep?: number) {
await this.ws.drafts.flush()
const folder = await api.getNode(id)
// Walking into another space by a link leaves this one where it was; the
// views stay on screen, so they go along rather than stay behind too.
if (this.spaceId !== null && folder.path[0]?.id !== this.spaceId) this.#rememberSpot(false)
// Walking into another space by a link leaves this one where it was.
if (this.spaceId !== null && folder.path[0]?.id !== this.spaceId) this.#rememberSpot()
this.parentId = id
this.parentSealed = folder.sealed
this.query = ''
@@ -347,7 +343,14 @@ export class Tree {
}
async openNode(id: number, edit = false) {
if (this.ws.open?.id !== id) await this.ws.drafts.flushTile()
/*
* The plan screen holds the views alone: what is opened from it — a
* card's notes, a day of the calendar, a search — opens on the documents'
* screen, beside what is there, and `S` goes back.
*/
const fromPlan = this.ws.tiles.planning
if (fromPlan) this.ws.tiles.leavePlan()
else if (this.ws.open?.id !== id) await this.ws.drafts.flushTile()
const node = await api.getNode(id)
if (isContainer(node)) {
@@ -362,10 +365,7 @@ export class Tree {
if (edit && node.kind === 'document') this.ws.editing = true
return
}
// A view is never put away by what is opened from it — a card's notes,
// a day of the calendar, a search from the board: that opens beside it,
// and closing it goes back there.
if (this.ws.tile.kind !== 'node') this.ws.tiles.add()
if (fromPlan && !this.ws.tile.empty) this.ws.tiles.add()
this.ws.open = node
this.ws.draft = { name: node.name, body: node.body, created_at: node.created_at }
@@ -549,7 +549,7 @@ export class Tree {
async deleteSpace(id: number) {
const { removed } = await api.deleteNode(id)
this.lastDeleted = id
for (const tile of this.ws.tiles.all) tile.clear()
for (const tile of this.ws.tiles.every) if (tile.kind === 'node') tile.clear()
this.ws.tiles.prune()
this.ws.cards.rawBoard = null
this.ws.cards.boardSpaceId = null
@@ -585,7 +585,7 @@ export class Tree {
*/
async moveNodes(nodes: NodeSummary[], parent: number, where = 'here') {
if (nodes.length === 0) return this.ws.say('nothing marked — press m on a row first', 'error')
const leaving = this.ws.tiles.all.filter((tile) => nodes.some((n) => n.id === tile.open?.id))
const leaving = this.ws.tiles.every.filter((tile) => nodes.some((n) => n.id === tile.open?.id))
for (const tile of leaving) {
await this.ws.drafts.flushTile(tile)
tile.clear()
+1 -1
View File
@@ -51,6 +51,6 @@ export class Vault {
failed > 0 || shared > 0 ? 'error' : undefined,
)
await this.ws.tree.refresh()
for (const tile of this.ws.tiles.all) if (tile.open?.kind === 'file') tile.open = await api.getNode(tile.open.id)
for (const tile of this.ws.tiles.every) if (tile.open?.kind === 'file') tile.open = await api.getNode(tile.open.id)
}
}
+3 -2
View File
@@ -160,7 +160,7 @@ export class Workspace {
// the draft included, so pick up where it stopped rather than start over.
if (this.tree.spaces.length > 0) {
await this.tree.refresh()
for (const tile of this.tiles.all) {
for (const tile of this.tiles.every) {
if (tile.open && tile.dirty) await this.guard(() => this.drafts.save({ quiet: true, tile }))
}
if (this.drafts.writing?.dirty) await this.guard(() => this.drafts.saveWriting())
@@ -178,7 +178,8 @@ export class Workspace {
this.vault.status = null
this.tree.spaces = []
this.tree.nodes = []
for (const tile of this.tiles.all) tile.clear()
if (this.tiles.planning) this.tiles.leavePlan()
for (const tile of this.tiles.every) tile.clear()
this.tiles.prune()
}
+2
View File
@@ -24,6 +24,8 @@ export class Writing {
dirty = $state(false)
/** The text was found in this browser, newer than what the server has. */
readonly recovered: boolean
/** Opened to be typed into straight away: the editor starts in insert mode, at the end. */
append = false
#write: (text: string) => Promise<void>
#fail: (error: unknown, retry: number) => void