tiles: the board, the agenda, the calendar and a card's back are tiles

The special workspace was a second layout beside the tiles, with its own
focus, its own Tab, Esc and q, and a returnTo flag to find the way back.
The views are now tile kinds instead: opened beside what is on screen,
walked with Alt-h/j/k/l and gt/gT, closed with q.

- Tile gets a kind: node, board, agenda, calendar or card. view,
  specialFocus, returnTo and the special context are gone.
- A trail of focused tiles: closing one goes back to the one before, so
  q on a card's notes or a calendar day returns to the view.
- A view is never replaced by what is opened from it; that opens beside.
- S tiles the board, agenda and calendar ahead of the documents, or puts
  every view away. :plan, with :special kept as an alias.
- The card sheet is a card tile beside the board, following its cursor;
  leaving it while writing the description saves what is written.
- Spaces remember their views; with a view focused, a space switch takes
  the views along instead of leaving copies behind.
- Card commands check what has the focus rather than the special view.
This commit is contained in:
nvrl committed 2026-10-22 10:18:50 +02:00
1 parent 26b3c686d2
commit 5684614d0a
18 files changed
+522 -512

No files matched your search

+9 -9
View File
@@ -141,9 +141,9 @@ it; in the document pane it is that document. In the browse pane:
| `R` | rename what is highlighted — a document, a folder or a file — starting from its name (`:name!` renames the space) |
| `m` / `p` | pick nodes up — `m` again puts one back, `M` takes the whole folder — then drop them all into the folder you walk to |
| `Ctrl`/`Shift`-click | pick a row up / every row from the cursor to it; drag picked-up rows onto a folder, a crumb or a space |
| `S` | the special workspace: the board, the agenda and the calendar side by side; `Tab` or `Alt-h`/`j`/`k`/`l` move between them, `f` gives one the screen, `Esc` leaves |
| `b` | the board of this space, in the special workspace; a digit there shows another space's |
| `t` | the agenda: what is late, due this week, or in hand, on every board, in the special workspace |
| `S` | the board, the agenda and the calendar tiled ahead of what is open, or every view away again; `Alt-h`/`j`/`k`/`l` move between the tiles, `f` gives one the screen |
| `b` | the board of this space, in a tile; a digit there shows another space's |
| `t` | the agenda: what is late, due this week, or in hand, on every board, in a tile |
| `T` | today's diary entry, started if there is none |
| `C` | the diary by month (`:cal`) — see [The diary](#the-diary) |
| `[d` / `]d` | the diary entry before / after the one open, by date |
@@ -247,9 +247,9 @@ start as `sftp_host_key` in the data directory.
## Boards
`b`, the board door on the rail, or `:board` swaps the tree for the special
workspace — the board of the space being browsed, with the agenda and the
calendar beside it — and `Esc` swaps it back. Every space has one except the
`b`, the board door on the rail, or `:board` shows the board of the space
being browsed in a tile beside what is open, and `q` or `Esc` closes it; `S`
puts it up with the agenda and the calendar. Every space has one except the
diary — a journal has no backlog — and `:set board` / `:set noboard` changes
that for the space you are in (the dimmed board door on the rail does the
same). A new board starts with `backlog`, `doing` and `done`, and the columns mean
@@ -271,8 +271,8 @@ you were. On the board, `o` or `c` puts a card on the column under the cursor.
| `j` / `k` | down / up the cards of that list |
| `J` / `K` | move the card down / up its list |
| `H` / `L` | move the card to the list on the left / right |
| `Enter` | turn the card over: labels, a markdown description, a checklist |
| `gf` | the card's notes, a document made on first use; `q` comes back |
| `Enter` | turn the card over, in a tile beside: labels, a markdown description, a checklist |
| `gf` | the card's notes, a document made on first use, beside; `q` comes back |
| `Space` | into the done column, or back out to the inbox |
| `o`, `c` | a new card on this list |
| `D` | due date — `fri`, `tm`, `+3d`, `+2w`, `10-31`, `2026-10-31` |
@@ -280,7 +280,7 @@ you were. On the board, `o` or `c` puts a card on the column under the cursor.
| `z` | show done cards older than a week |
| `F` | a letter over every card in view; typing it puts the cursor on that card |
| `x`, `dd` | delete the card |
| `q`, `Esc` | back to the tree |
| `q`, `Esc` | close the board's tile |
On the back of a card, `j`/`k` walk the checklist, `Space` ticks a step, `o`
adds one and `dd` drops it; `e` writes the description in the vim editor, `:w`
+42 -34
View File
@@ -1,38 +1,44 @@
# Boards and the agenda
## The special workspace
## Views
The board, the agenda and the [calendar](Diary.md#the-calendar) share one
workspace of their own, as the desktop's special workspace holds what is
looked at in passing. `S` (or `:special`, or the `special` pill in the bar)
puts it up and away again. It is three tiles: the board on the left, the
agenda over the calendar on the right.
The board, the agenda, the [calendar](Diary.md#the-calendar) and the back of
a card are views: tiles like the documents' tiles, opened beside what is on
screen and walked the same way. `S` (or `:plan`) puts the board, the agenda
and the calendar up together, ahead of the documents with the board focused —
the board on the left, the agenda over the calendar on the right when nothing
else is open — and puts every view away again.
![The special workspace](../images/board.png)
![The board, the agenda and the calendar](../images/board.png)
| key | what it does |
| --- | --- |
| `S` | put the special workspace up, or away |
| `b`, `t`, `C` | to the board, the agenda or the calendar, from anywhere |
| `Tab` / `Shift-Tab` | the next / previous tile |
| `Alt-h` / `Alt-j` / `Alt-k` / `Alt-l` | the tile that way |
| `S` | the board, the agenda and the calendar up together, or every view away |
| `b`, `t`, `C` | to the board, the agenda or the calendar, opening its tile when it is not up |
| `gt` / `gT` | the next / previous tile |
| `Alt-h` / `Alt-j` / `Alt-k` / `Alt-l` | the tile that way; left of the leftmost is the list |
| `l` past the last list, `h` in the agenda, `j` past its end | on into the tile beside |
| `f` | the focused tile fills the screen; again or `Esc` to leave |
| `1` … `9` | switch space, and the board with it |
| `q`, `Esc` | leave, back to where you were |
| `1` … `9` | switch space; with a view focused, the views come along |
| `q`, `Esc` | close the view's tile, back to the tile focused before it |
The keys go to the tile with focus, which wears the light border and its key
lit in its head; the other tiles keep their cursors, dimmed. `b`, `t` and `C`
only ever go to their tile, and `Esc` only ever leaves — or, with a tile
filling the screen, leaves that first. A document opened from the workspace —
a card's notes, a day of the calendar, an entry of the agenda, `T` — comes
back to the tile it was opened from on `q`. Everything in it is read again
each time it comes up.
lit in its head; the other views keep their cursors, dimmed. A view is never
replaced by what is opened from it — a card's notes, a day of the calendar, an
entry of the agenda, a row of the list: that opens in a tile beside it, and
`q` on it comes back to the view. Each view is read again whenever it is gone
to.
Each space keeps its own tiles, views among them. Switching space with a view
focused takes the board, the agenda and the calendar along, the way the
special workspace used to stay up: a digit on the board shows that space's
board, with the agenda still beside it. Switching with a document or the list
focused leaves them in the space, and they are there again on coming back.
## The board
Every space except the diary has a board. `b`, the board button on the rail, or
`:board` shows it in the special workspace, and `Esc` goes back to the tree.
`:board` shows it in a tile beside what is open, and `q` or `Esc` closes it.
It is the board of the space being browsed, or, in a space without one such as
the diary, the last board shown. The head of the board names every space that
has one, with the digit that goes there.
@@ -64,13 +70,14 @@ a week fold away until `z` shows them.
| `z` | show done cards older than a week |
| `F` | a letter over every card; type it to jump there |
| `x`, `dd` | delete the card |
| `q`, `Esc` | leave the special workspace |
| `q`, `Esc` | close the board's tile |
### The back of a card
`Enter` on a card turns it over: a sheet floats over the board with its
labels, its description and its checklist. The card stays the one under the
board's cursor, so `D`, `R`, `H` and `L` act on it as they do on the board.
`Enter` on a card turns it over: a tile beside the board with its labels, its
description and its checklist. It is the card under the board's cursor, so
walking the board leafs through the backs, and `D`, `R`, `H` and `L` act on it
as they do on the board.
| key | what it does |
| --- | --- |
@@ -83,11 +90,12 @@ board's cursor, so `D`, `R`, `H` and `L` act on it as they do on the board.
| `e`, `i`, `Enter` | write the description |
| `a` | put a label on, or take it off (`:label <name>`) |
| `gf` | the card's notes, below |
| `q`, `Esc` | back to the board |
| `q`, `Esc` | close the back, back to the board |
The description is markdown, written in the same editor as a document, vim
and all: `:w` writes it to the card, `:q` or `Esc` in normal mode leaves, and
`:q!` throws away what was not written. A fenced block in a language is
`:q!` throws away what was not written. Leaving the tile while writing keeps
what is written. A fenced block in a language is
coloured the way nvim colours it, written and read.
Labels are lower case, and each name keeps its colour on every card and every
@@ -101,15 +109,15 @@ step open again.
### Capturing a card
`c` anywhere outside the special workspace opens `:card ` on the command line. The card goes
`c` in the tree opens `:card ` on the command line. The card goes
into the inbox of the space you are in, or of the first space with a board, and
you stay where you were. From outside the browser, see [Capture](Capture.md).
### Notes
`gf` on a card, or on its back, opens its notes: a document named after the card, made in the
root of the space the first time. Closing it goes back to the board, on the
same card. `:card!` while reading a document makes a card that points at it.
root of the space the first time, in a tile beside the board. Closing it goes
back to the board, on the same card. `:card!` while reading a document makes a card that points at it.
The link is loose on purpose: deleting the document leaves the card, because the
work is still open when its notes are gone.
@@ -150,8 +158,8 @@ does not make a second copy. The board and the agenda show the rule as
## The agenda
`t` (or `:agenda`) gathers what needs you from every board at once, in the
special workspace beside the board.
`t` (or `:agenda`) gathers what needs you from every board at once, in a tile
of its own.
![The agenda](../images/agenda.png)
@@ -163,15 +171,15 @@ special workspace beside the board.
| key | what it does |
| --- | --- |
| `j` / `k` | down / up, on into the diary entries, then into the calendar |
| `h` | to the board |
| `j` / `k` | down / up, on into the diary entries, then into the tile below |
| `h` | to the tile on the left |
| `Enter` | to the card on its own board, in the tile beside, or open the entry |
| `Space` | finish the card where it stands |
| `D` | due date |
| `H` / `L` | to the list left / right on its board |
| `:` | the command line with the agenda kept up, so `:repeat` acts on the card |
| `T` | today's diary entry |
| `q`, `Esc` | leave the special workspace |
| `q`, `Esc` | close the agenda's tile |
The agenda opens by itself once a day when something is late or due. "Today"
is the day in `WORKSPACE_TIMEZONE`, the same as for the mails, so a browser in
+13 -16
View File
@@ -22,7 +22,7 @@ arguments and `↑` / `↓` recall earlier lines.
| `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 |
| `gt / gT` | next / previous tile — a view 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 |
@@ -43,14 +43,13 @@ arguments and `↑` / `↓` recall earlier lines.
| `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 |
| `S` | the board, the agenda and the calendar, tiled ahead of what is open with the board focused — S again puts every view away |
| `t` | agenda — late, due this week, in hand, on every board — in a tile of its own |
| `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 |
| `C` | 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 |
| `[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 |
| `b` | the board, in a tile: 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 |
@@ -64,6 +63,7 @@ arguments and `↑` / `↓` recall earlier lines.
| `:` | 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 |
| `q · Esc` | in a view — the board, the agenda, the calendar, a card's back — close its tile; the focus goes back to the tile before |
### On a board
@@ -71,13 +71,13 @@ The first list is the inbox, the last list is done.
| key | what it does |
| --- | --- |
| `h / l` | to the list left / right — right of the last is the agenda |
| `h / l` | to the list left / right — right of the last, to the tile beside |
| `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 |
| `Enter` | turn the card over, in a tile beside: 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 … |
@@ -86,7 +86,6 @@ The first list is the inbox, the last list is done.
| `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 |
### On the back of a card
@@ -104,21 +103,19 @@ The first list is the inbox, the last list is done.
| `R · D` | rename the card, its due date |
| `H / L` | send the card to the list left / right |
| `gf` | the card's notes — a document, made on first use; q comes back |
| `q · Esc` | back to the board |
### In the agenda
| key | what it does |
| --- | --- |
| `j / k` | down / up the cards, then what was written on this day in earlier years, then into the calendar |
| `h` | to the board |
| `j / k` | down / up the cards, then what was written on this day in earlier years, then on to the tile below |
| `h` | to the tile on the left |
| `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 |
### In the scratchpad
@@ -156,11 +153,11 @@ do: `:w!`, `:space! <name>`, `:import!`.
| command | what it does |
| --- | --- |
| `:board` | this space's board, in the special workspace |
| `:special` | the special workspace: the board, the agenda and the calendar side by side — S puts it up and away, Tab walks its tiles, 1 … 9 picks the board |
| `:board` | this space's board, in a tile — the last one shown when this space has none |
| `:plan, :special` | 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 |
| `:card, :c <title>` | add a card — on the board to the list under the cursor, elsewhere to this space's inbox |
| `:card!` | add a card linked to the open document — gf on it opens the document |
| `:due <when\|->` | due date of the card — on the board, or highlighted in the agenda: today, tm, fri, +3d, +2w, 10-31, 2026-10-31 — - clears |
| `:due <when\|->` | due date of the card — on the board, its back, or highlighted in the agenda: today, tm, fri, +3d, +2w, 10-31, 2026-10-31 — - clears |
| `:repeat <rule\|->` | once the card is done, a copy comes back in the inbox: daily, weekly, monthly, yearly, +3d, +2w, +6m — - stops it |
| `:done` | move the selected card into the done list, or back out to the inbox |
| `:task, :step <text>` | add a step to the open card's checklist, below the cursor |
+5 -5
View File
@@ -13,9 +13,9 @@ an entry written after midnight on a trip still lands on the right day.
## The calendar
`C` (or `:cal`) shows the diary a month at a time, in the
[special workspace](Boards-and-Agenda.md#the-special-workspace) under the
agenda.
`C` (or `:cal`) shows the diary a month at a time, in a
[tile of its own](Boards-and-Agenda.md#views) — under the agenda when `S` puts
them up together.
![The diary by month](../images/calendar.png)
@@ -25,8 +25,8 @@ agenda.
| `j` / `k` | a week forward / back |
| `H` / `L` | a month back / forward |
| `.` | today |
| `Enter` | open the day's entry, or start one dated that day |
| `q`, `Esc` | leave the special workspace; an entry opened here comes back on `q` |
| `Enter` | open the day's entry beside the calendar, or start one dated that day |
| `q`, `Esc` | close the calendar's tile; `q` on an entry opened from it comes back here |
A dot marks a day with an entry. A number says how many when there are more
than one.
+3 -3
View File
@@ -71,9 +71,9 @@ The rail of buttons on the right does the same as the keys, for a mouse.
add a name and press `Enter`. `l` or `Enter` on the folder goes into it, and
`h` comes back up.
6. Press `c`, type `try the board`, and press `Enter`. That is a card in the
inbox of this space's board. Press `b` to see the board, and `Esc` to go
back. It shares a special workspace with the agenda and the calendar: `S`
puts it up and away, and `Tab` walks between them.
inbox of this space's board. Press `b` to see the board in a tile, and
`Esc` to close it. `S` puts it up with the agenda and the calendar, and puts
them away again; `Alt-h`/`j`/`k`/`l` walk between the tiles.
7. Press `T`. That is today's diary entry, started for you.
8. Press `?` for the list of every key and command. `h` / `l` walk its columns, `/` narrows it down, and `Esc` closes it.
+10 -9
View File
@@ -11,9 +11,9 @@ The keys are the desktop's. On a phone, every one of these has a button; see
## Morning: what needs you
The first time you open the workspace each day, the **agenda** opens by itself
when anything is late or due. Any other time, press `t`. It opens in the
**special workspace**, beside the board and the calendar; `S` puts that up and
away from anywhere, and `Tab` walks its tiles.
when anything is late or due. Any other time, press `t`. It opens in a tile
beside what is open; `S` puts it up with the board and the calendar, and puts
them away again, and `Alt-h`/`j`/`k`/`l` walk the tiles.
![The agenda](../images/agenda.png)
@@ -35,7 +35,7 @@ Walk it with `j` and `k`. On a card:
- `H` and `L` move it to the list left or right on its board;
- `Enter` goes to the card on its own board, in the tile beside.
`Esc` leaves the special workspace. The bar keeps showing the `late` and `today`
`Esc` closes the agenda's tile. The bar keeps showing the `late` and `today`
counts while either is above zero. More in [Boards and the agenda](Boards-and-Agenda.md).
## At work: spaces, documents and tiles
@@ -86,7 +86,7 @@ In a meeting someone asks you to renew a certificate. Press `c`, type
`Renew the gateway certificate`, and `Enter`. The card is in this space's
inbox, and you have not left the document you were reading.
Press `b` to see the **board**, in the special workspace. A digit there
Press `b` to see the **board**, in a tile beside the document. A digit there
shows the board of another space.
![The board](../images/board.png)
@@ -94,10 +94,11 @@ shows the board of another space.
The first list is the inbox and the last is done; the ones between are work in
hand. `h`/`l` move between lists and `j`/`k` between cards. `H`/`L` move a
card across, `Space` finishes it, and `D` sets its due date. `Enter` turns a
card over to its labels, description and checklist, and `gf` opens its notes:
a document made for the card the first time you ask. `:repeat weekly`
makes a card come back in the inbox each time it is finished. `q` goes back to
the tree.
card over to its labels, description and checklist, in a tile beside, and
`gf` opens its notes: a document made for the card the first time you ask,
also beside, and `q` on it comes back to the board. `:repeat weekly` makes a
card come back in the inbox each time it is finished. `q` on the board closes
its tile.
### Finding things
+32 -75
View File
@@ -20,7 +20,7 @@
import Overview from './lib/Overview.svelte'
import MediaBrowser from './lib/MediaBrowser.svelte'
import Trash from './lib/Trash.svelte'
import CardSheet from './lib/CardSheet.svelte'
import CardTile from './lib/CardTile.svelte'
import Scratch from './lib/Scratch.svelte'
import Actions from './lib/Actions.svelte'
import Prompt from './lib/Prompt.svelte'
@@ -62,7 +62,6 @@
verify: Verify,
info: Info,
trash: Trash,
card: CardSheet,
media: MediaBrowser,
}
let boardEl = $state<HTMLDivElement>()
@@ -244,8 +243,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', 'special'), 'board, agenda and calendar'],
[[...keysOf('tree', 'board'), ...keysOf('global', 'agenda'), ...keysOf('global', 'calendar')], 'straight to one of them'],
[keysOf('global', 'plan'), 'board, agenda and calendar'],
[[...keysOf('global', 'board'), ...keysOf('global', 'agenda'), ...keysOf('global', 'calendar')], 'one of them, in a tile'],
[keysOf('tree', 'capture'), 'capture a card to the inbox'],
[keysOf('global', 'find'), 'find in every space'],
[keysOf('tree', 'search'), 'search this space'],
@@ -287,39 +286,17 @@
<!-- Keyed on the space, so a switch slides the new one in, as the desktop
does between workspaces: from below when moving right, above when left. -->
{#key workspace.spaceId}
{#if workspace.view === 'special'}
<!-- The special workspace: the board, and the agenda over the
calendar beside it. `f` gives the focused one the whole porthole. -->
<div class="panes special switch" class:full={workspace.fullscreen} data-focus={workspace.specialFocus} style="--dir: {workspace.switchDirection}">
{#each ['board', 'agenda', 'calendar'] as const as tile (tile)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="window {tile}"
class:focused={workspace.specialFocus === tile}
onpointerdown={() => (workspace.specialFocus = tile)}
>
{#if tile === 'board'}
<div class="fill" bind:this={boardEl}><Board /></div>
{:else if tile === 'agenda'}
<Agenda />
{:else}
<Calendar />
{/if}
</div>
{/each}
</div>
{:else}
<div
class="panes switch"
class:reading={workspace.tiles.some((tile) => tile.open)}
class:reading={workspace.tiles.some((tile) => !tile.empty)}
class:full={workspace.fullscreen}
class:focus-doc={!!workspace.open && workspace.pane === 'document'}
class:focus-doc={workspace.focus !== 'list'}
style="--dir: {workspace.switchDirection}"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="window list"
class:focused={!workspace.open || workspace.pane === 'list'}
class:focused={workspace.focus === 'list'}
onpointerdown={() => (workspace.pane = 'list')}
><Sidebar /></div>
<div class="tiles" class:only={workspace.tiles.length === 1}>
@@ -329,13 +306,23 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="window entry"
class:view={tile.kind !== 'node'}
data-kind={tile.kind}
class:current={index === workspace.focused}
class:focused={!!tile.open && index === workspace.focused && workspace.pane === 'document'}
class:focused={!tile.empty && index === workspace.focused && workspace.pane === 'document'}
style="--x: {rect.x}; --y: {rect.y}; --w: {rect.w}; --h: {rect.h}"
onpointerdown={() => tile.open && workspace.focusTile(index)}
onfocusin={() => tile.open && index !== workspace.focused && workspace.focusTile(index)}
onpointerdown={() => !tile.empty && workspace.focusTile(index)}
onfocusin={() => !tile.empty && index !== workspace.focused && workspace.focusTile(index)}
>
{#if tile.open?.kind === 'file'}
{#if tile.kind === 'board'}
<div class="fill" bind:this={boardEl}><Board /></div>
{:else if tile.kind === 'agenda'}
<Agenda />
{:else if tile.kind === 'calendar'}
<Calendar />
{:else if tile.kind === 'card'}
<CardTile />
{:else if tile.open?.kind === 'file'}
<FilePane {tile} />
{:else if tile.open}
<DocumentPane {tile} />
@@ -358,7 +345,6 @@
{/each}
</div>
</div>
{/if}
{/key}
</main>
@@ -402,48 +388,23 @@
<style>
.boot { display: grid; place-items: center; height: 100%; }
/*
* The special workspace: the board takes what the right column leaves,
* the agenda takes what the calendar leaves under it.
*/
.panes.special {
grid-template-columns: minmax(0, 1fr) minmax(44ch, 30vw);
grid-template-rows: minmax(0, 1fr) auto;
grid-template-areas: 'board agenda' 'board calendar';
}
.special > .board { grid-area: board; }
.special > .agenda { grid-area: agenda; }
.special > .calendar { grid-area: calendar; }
.special .fill { height: 100%; }
/* The focused tile's key is lit, as its border is. */
.special > .window.focused :global(.tile-key) { background: var(--light); border-color: var(--light); color: var(--ink); }
.fill { height: 100%; }
/* The focused view's key is lit, as its border is. */
.tiles > .window.focused :global(.tile-key) { background: var(--light); border-color: var(--light); color: var(--ink); }
/* One cursor is lit at a time: the other tiles keep theirs, dimmed. */
.special > .window:not(.focused) :global(:is(.card.active, .row.at, .day.at)) {
/* One cursor is lit at a time: the views without the focus keep theirs, dimmed. */
.view:not(.focused) :global(:is(.card.active, .row.at, .day.at, li.at)) {
background: var(--surface-alt);
color: var(--light);
}
.special > .window:not(.focused) :global(.row.at :is(.where, .due)) { color: var(--muted); }
.special > .window:not(.focused) :global(.card.active :is(.meta, .doc)) { color: var(--muted); }
.special > .window:not(.focused) :global(:is(.card.active .today, .today .row.at .due)) { color: var(--light); }
.special > .window:not(.focused) :global(:is(.card.active .overdue, .overdue .row.at .due)) { color: var(--alarm); }
.special > .window:not(.focused) :global(.card.active .tick) { border-color: var(--outline); }
.special > .window:not(.focused) :global(.day.at :is(.dot)) { background: var(--light); }
.special > .window:not(.focused) :global(.day.at .many) { color: var(--light); }
.panes.special.full { grid-template-columns: 1fr; grid-template-rows: 1fr; grid-template-areas: none; }
.panes.special.full > .window:not(.focused) { display: none; }
.panes.special.full > .window { grid-area: auto; }
.view:not(.focused) :global(.row.at :is(.where, .due)) { color: var(--muted); }
.view:not(.focused) :global(.card.active :is(.meta, .doc)) { color: var(--muted); }
.view:not(.focused) :global(:is(.card.active .today, .today .row.at .due)) { color: var(--light); }
.view:not(.focused) :global(:is(.card.active .overdue, .overdue .row.at .due)) { color: var(--alarm); }
.view:not(.focused) :global(:is(.card.active, li.at) .tick) { border-color: var(--outline); }
.view:not(.focused) :global(.day.at :is(.dot)) { background: var(--light); }
.view:not(.focused) :global(.day.at .many) { color: var(--light); }
/* The agenda and the calendar are sheets of their own as overlays; as tiles
the window is their sheet. */
.special > .window :global(.sheet) {
width: 100%;
height: 100%;
max-height: none;
border: 0;
border-radius: 0;
background: none;
}
.window.list { --pane-bg: color-mix(in oklab, var(--surface) 60%, var(--base)); }
/* Only one pane on screen: nothing to tell apart, so no active border. */
@@ -516,9 +477,5 @@
.tiles > .window:not(.current) { display: none; }
.tiles > .window.current { inset: calc(var(--gap) / 2); width: auto; height: auto; }
.overlay { padding: 12px; }
/* A phone shows the special workspace's focused tile alone. */
.panes.special { grid-template-columns: 1fr; grid-template-rows: 1fr; grid-template-areas: none; }
.panes.special > .window { grid-area: auto; }
.panes.special > .window:not(.focused) { display: none; }
}
</style>
+3 -6
View File
@@ -112,23 +112,20 @@
</section>
{/if}
<Keys contexts={['agenda', 'special']} />
<Keys contexts={['agenda', 'views']} />
</div>
<style>
li:has(.finish) { display: flex; align-items: center; }
.finish { flex: none; display: grid; place-items: center; width: 44px; height: 40px; padding: 0; border: 0; background: none; color: var(--muted); }
/* A tile's window is its frame, so it only fills it. */
.sheet {
display: flex;
flex-direction: column;
gap: 16px;
width: min(100%, 84ch);
max-height: 100%;
height: 100%;
overflow-y: auto;
background: color-mix(in oklab, var(--hull) 97%, transparent);
border: 1px solid var(--surface-alt);
border-radius: var(--frame-radius);
padding: 20px 24px;
}
+16 -31
View File
@@ -35,43 +35,30 @@
<button class="pill add" title="new space (:space! <name>)" onclick={() => hooks.openCommandLine(':space! ')}>
+
</button>
<!-- The special workspace, beside the others as the desktop bar shows its own. -->
<button
class="pill special"
aria-current={workspace.view === 'special'}
title="the special workspace: board, agenda, calendar (S)"
onclick={() => workspace.guard(() => workspace.toggleSpecial())}
>
<span class="slug">special</span>
</button>
</nav>
<span class="divider path"></span>
<nav class="crumbs" aria-label="path">
{#if workspace.view === 'special'}
<span class="here">special:{workspace.specialFocus}</span>
{:else}
<button
class="crumb"
class:here={crumbs.length === 0}
onclick={() => workspace.spaceId !== null && workspace.guard(() => workspace.goTo(workspace.spaceId!))}
{@attach into(workspace.spaceId, 'into ~')}
>
~
</button>
{#each crumbs as crumb, index (crumb.id)}
<span class="sep">/</span>
<button
class="crumb"
class:here={crumbs.length === 0}
onclick={() => workspace.spaceId !== null && workspace.guard(() => workspace.goTo(workspace.spaceId!))}
{@attach into(workspace.spaceId, 'into ~')}
class:here={index === crumbs.length - 1}
onclick={() => workspace.guard(() => workspace.goTo(crumb.id))}
{@attach into(crumb.id, `into ${crumb.slug}`)}
>
~
{crumb.slug}
</button>
{#each crumbs as crumb, index (crumb.id)}
<span class="sep">/</span>
<button
class="crumb"
class:here={index === crumbs.length - 1}
onclick={() => workspace.guard(() => workspace.goTo(crumb.id))}
{@attach into(crumb.id, `into ${crumb.slug}`)}
>
{crumb.slug}
</button>
{/each}
{/if}
{/each}
</nav>
<span class="spacer"></span>
@@ -123,7 +110,6 @@
.pill:hover .n { color: var(--light-dim); }
.pill[aria-current='true'] .n { color: var(--ink); opacity: 0.55; }
.pill.add { color: var(--muted); }
.pill.special { margin-left: 6px; }
.pill.add:hover { color: var(--light); }
/* A crumb is a link along the path; a hairline grows under it on hover. */
@@ -152,9 +138,8 @@
}
.crumb:hover { background: transparent; color: var(--light); }
.crumb:hover::after { transform: scaleX(1); }
.crumb.here, .here { color: var(--light); }
.crumb.here { color: var(--light); }
.sep { color: var(--overlay); }
span.here { letter-spacing: 0.06em; text-transform: none; }
/*
* On a phone the other spaces are only their number, like the desktop's
+2 -2
View File
@@ -82,7 +82,7 @@
class:done={card.done_at !== null}
data-at="{listIndex}:{cardIndex}"
onclick={() => pick(listIndex, cardIndex)}
ondblclick={() => workspace.openCardSheet()}
ondblclick={() => workspace.guard(() => workspace.turnCard())}
title="Enter turns the card over"
>
{#if card.labels.length}
@@ -143,7 +143,7 @@
<!-- The board's keys are not the list's, so they are spelled out where they apply. -->
<div class="keys">
<Keys contexts={['board', 'special']}><span class="rule-hint">first list is the inbox · last list is done</span></Keys>
<Keys contexts={['board', 'views']}><span class="rule-hint">first list is the inbox · last list is done</span></Keys>
</div>
</div>
+3 -6
View File
@@ -79,20 +79,17 @@
{/each}
</section>
<Keys contexts={['calendar', 'special']} />
<Keys contexts={['calendar', 'views']} />
</div>
<style>
/* A tile's window is its frame, so it only fills it. */
.sheet {
display: flex;
flex-direction: column;
gap: 16px;
width: min(100%, 60ch);
max-height: 100%;
height: 100%;
overflow-y: auto;
background: color-mix(in oklab, var(--hull) 97%, transparent);
border: 1px solid var(--surface-alt);
border-radius: var(--frame-radius);
padding: 20px 24px;
}
@@ -1,11 +1,12 @@
<script lang="ts">
/*
* Enter on a card turns it over: a window floated over the board, as the
* desktop floats one, with what the card holds on its back — labels, the
* description, the checklist. The keys live in actions.ts with the others;
* this draws and lets a pointer do the same.
* Enter on a card turns it over: a tile beside the board with what the
* card holds on its back — labels, the description, the checklist. It is
* the card under the board's cursor, so walking the board leafs through
* the backs. The keys live in actions.ts with the others; this draws and
* lets a pointer do the same.
*/
import Sheet from './Sheet.svelte'
import Keys from './Keys.svelte'
import { followLink } from './commands'
import { renderMarkdown } from './markdown'
import { workspace } from './store.svelte'
@@ -18,11 +19,21 @@
const ticked = $derived(card?.checklist.filter((task) => task.done).length ?? 0)
const html = $derived(card?.body.trim() ? renderMarkdown(card.body, { wikilinks: card.wikilinks }) : '')
// A card deleted or moved out of sight under the sheet takes the sheet with it.
let root = $state<HTMLDivElement>()
// Another card's back starts at its first step.
let shown: number | undefined
$effect(() => {
if (!card) workspace.closeSheet()
if (card?.id === shown) return
shown = card?.id
workspace.taskCursor = 0
})
// Keep the step under the cursor on screen.
$effect(() => {
const index = workspace.taskCursor
root?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
})
/** A link in the description leaves the card for what it leads to, as one in a document does. */
function follow(event: MouseEvent) {
@@ -30,7 +41,6 @@
const href = (event.target as HTMLElement | null)?.closest('a')?.getAttribute('href') ?? ''
if (!href.startsWith('/n/')) return
event.preventDefault()
workspace.closeCardSheet()
followLink(href)
}
@@ -44,9 +54,16 @@
}
</script>
{#if card}
<Sheet title="card" detail="{workspace.boardSpace?.slug ?? ''} / {list?.name ?? ''}{role ? ` · ${role}` : ''}" width={76} cursor={workspace.taskCursor} keys={['card']}>
<div class="back" bind:this={root}>
<header>
<span class="label rule">
<span class="marker"></span><span class="title">card</span>
{#if card}{workspace.boardSpace?.slug ?? ''} / {list?.name ?? ''}{role ? ` · ${role}` : ''}{/if}
</span>
<span class="kbd tile-key" title="the key that turns a card over, on the board">↵</span>
</header>
{#if card}
<div class="scroll">
<h2 class:done={card.done_at !== null}>{card.title}</h2>
@@ -113,12 +130,28 @@
</ul>
</section>
</div>
</Sheet>
{/if}
{:else}
<p class="none muted">no card under the board's cursor — o on the board adds one</p>
{/if}
<Keys contexts={['card', 'views']} />
</div>
<style>
.back {
display: flex;
flex-direction: column;
gap: 18px;
height: 100%;
min-height: 0;
padding: 20px 24px;
}
.scroll { display: flex; flex-direction: column; gap: 18px; min-height: 0; overflow-y: auto; }
header { display: flex; align-items: center; gap: 12px; min-width: 0; }
header .label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
header .marker { background: var(--light); }
header .title { color: var(--light); }
.scroll { flex: 1; display: flex; flex-direction: column; gap: 18px; min-height: 0; overflow-y: auto; }
h2 { margin: 0; font-family: var(--font-sans); font-size: 20px; font-weight: 600; color: var(--light); }
h2.done { text-decoration: line-through; color: var(--muted); }
@@ -170,4 +203,7 @@
li.at .tick { border-color: var(--ink); }
li.at.ticked .tick { background: var(--ink); }
@media (max-width: 767px) {
.back { padding: 14px; gap: 14px; }
}
</style>
+15 -16
View File
@@ -31,21 +31,22 @@
const cmd: Door = { icon: 'prompt', label: 'Command line', keys: keysOf('global', 'line'), run: () => hooks.openCommandLine(':') }
const help: Door = { icon: 'help', label: 'Help', keys: keysOf('global', 'help'), run: () => runCommand('help') }
if (workspace.view === 'special') {
// The tiles of the special workspace are doors, so a phone — which
// shows one at a time — can walk between them.
const tiles: Door[] = [
{ icon: 'board', label: 'Board', keys: keysOf('special', 'board'), run: () => (workspace.specialFocus = 'board'), on: workspace.specialFocus === 'board' },
{ icon: 'agenda', label: 'Agenda — late, due, in hand', keys: keysOf('global', 'agenda'), run: () => (workspace.specialFocus = 'agenda'), on: workspace.specialFocus === 'agenda' },
{ icon: 'calendar', label: 'Calendar', keys: keysOf('global', 'calendar'), run: () => (workspace.specialFocus = 'calendar'), on: workspace.specialFocus === 'calendar' },
{ icon: 'list', label: 'Back to the tree', keys: [...keysOf('special', 'leave'), ...keysOf('global', 'special')], run: () => workspace.closeSpecial() },
]
if (workspace.specialFocus !== 'board') return [tiles, [cmd, help]]
const focus = workspace.focus
const board: Door = workspace.space?.has_board || focus === 'board'
? { icon: 'board', label: 'Board', keys: keysOf('global', 'board'), run: () => runCommand('board'), on: focus === 'board' }
: { icon: 'board', label: 'Give this space a board', keys: [':set board'], run: () => runCommand('set board'), dim: true }
const agenda: Door = { icon: 'agenda', label: 'Agenda — late, due, in hand', keys: keysOf('global', 'agenda'), run: () => runCommand('agenda'), on: focus === 'agenda' }
const calendar: Door = { icon: 'calendar', label: 'Calendar — the diary by month', keys: keysOf('global', 'calendar'), run: () => runCommand('cal'), on: focus === 'calendar' }
// A phone shows the focused tile alone, so a view needs a door out of it.
const close: Door[] =
focus !== 'list' && focus !== 'node' ? [{ icon: 'list', label: 'Close this view', keys: keysOf('views', 'close'), run: () => runCommand('close') }] : []
if (focus === 'board' || focus === 'card') {
// The card verbs are doors too, so a phone — which has no H, L or
// Space — can still move work along.
const card = !!workspace.card
return [
tiles,
[board, agenda, calendar, ...close],
[
{ 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 ') },
@@ -78,9 +79,6 @@
]
}
const board: Door = workspace.space?.has_board
? { icon: 'board', label: 'Board', keys: keysOf('tree', 'board'), run: () => runCommand('board') }
: { icon: 'board', label: 'Give this space a board', keys: [':set board'], run: () => runCommand('set board'), dim: true }
return [
[
{ icon: 'doc-plus', label: 'New document', keys: keysOf('tree', 'new'), run: () => runCommand('new') },
@@ -88,10 +86,11 @@
...(workspace.space ? [board] : []),
],
[
{ icon: 'agenda', label: 'Agenda — late, due, in hand', keys: keysOf('global', 'agenda'), run: () => runCommand('agenda') },
{ icon: 'calendar', label: 'Calendar — the diary by month', keys: keysOf('global', 'calendar'), run: () => runCommand('cal') },
agenda,
calendar,
{ icon: 'today', label: "Today's diary entry", keys: keysOf('global', 'today'), run: () => runCommand('today') },
{ icon: 'capture', label: 'Capture a card to the inbox', keys: keysOf('tree', 'capture'), run: () => hooks.openCommandLine(':card ') },
...close,
],
[
{ icon: 'upload', label: 'Upload files into this folder', keys: [':upload'], run: () => hooks.pickTreeFiles(false) },
+26 -52
View File
@@ -14,7 +14,7 @@ import { focusHelpFilter, scrollHelp } from './Help.svelte'
import { copyMedia, insertMedia, removeMedia } from './MediaBrowser.svelte'
import { purgeTrashed, restoreTrashed } from './Trash.svelte'
import { goHeading, moveHistory, openBacklink, renewLink, restoreShown, toggleSharing, turnInfo } from './Info.svelte'
import { workspace, type SpecialTile } from './store.svelte'
import { workspace } from './store.svelte'
import { dwindle, formatDay, neighbour, type Direction } from './util'
export type Run = (key: string, event: KeyboardEvent) => boolean | void
@@ -75,34 +75,19 @@ function openInTab() {
/** Focus moves between the panes, out of the editor too. */
function focusPane(pane: 'list' | 'document') {
if (pane === 'document' && !workspace.open) return
if (pane === 'document' && workspace.tile.empty) 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 (workspace.focus === 'list') {
if (direction === 'l' && !shift) focusPane('document')
return
}
@@ -131,10 +116,9 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
},
sheet: {
// A description or a note being written has had its Esc; the sheet
// stays up under it.
// A note being written has had its Esc; the sheet stays up under it.
close: () => {
if (workspace.describing || workspace.scratchEditing) return
if (workspace.scratchEditing) return
workspace.closeSheet()
},
},
@@ -233,16 +217,16 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
restore: () => void restoreShown(),
},
/** Its sharing: the same s and y as on the document itself. */
/** Its sharing: s shares or stops, y makes a new link. */
share: {
toggle: () => void toggleSharing(workspace.open),
link: () => void renewLink(workspace.open),
},
/*
* The card sheet reads the list keys over its checklist, and the board's
* A card's back 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
* on the board beside it. While the description is being written the keys
* are the editor's.
*/
card: {
@@ -265,12 +249,8 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
due: () => line(':due '),
sendLeft: () => guard(() => workspace.sendCard(-1)),
sendRight: () => guard(() => workspace.sendCard(1)),
notes: () => {
workspace.closeSheet()
guard(() => workspace.openCard())
},
notes: () => guard(() => workspace.openCard()),
line: () => line(':'),
close: () => workspace.closeCardSheet(),
},
/*
@@ -346,9 +326,6 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
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')
@@ -385,9 +362,6 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
// 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
@@ -419,9 +393,9 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
*/
board: {
left: () => workspace.moveList(-1),
// Right of the last list is the agenda, the tile beside the board.
// Right of the last list is the tile beside the board.
right: () => {
if (workspace.listCursor >= (workspace.board?.lists.length ?? 0) - 1) workspace.specialFocus = 'agenda'
if (workspace.listCursor >= (workspace.board?.lists.length ?? 0) - 1) tileKey('l', false)
else workspace.moveList(1)
},
down: () => workspace.moveCard(1),
@@ -433,7 +407,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
sendLeft: () => guard(() => workspace.sendCard(-1)),
sendRight: () => guard(() => workspace.sendCard(1)),
done: () => guard(() => workspace.toggleDone()),
turn: () => workspace.openCardSheet(),
turn: () => guard(() => workspace.turnCard()),
// The card's notes, without turning it over first: `gf` as on its back.
notes: () => guard(() => workspace.openCard()),
new: () => line(':card '),
@@ -449,27 +423,26 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
},
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.
* and past that into the tile under it; `h` is the tile 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'
if (workspace.agendaCursor >= last) tileKey('j', false)
else workspace.agendaCursor += 1
},
up: () => void (workspace.agendaCursor = Math.max(workspace.agendaCursor - 1, 0)),
left: () => void (workspace.specialFocus = 'board'),
left: () => tileKey('h', false),
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.
// Beside the agenda, so `q` on it comes back here.
else if (past) guard(() => workspace.openPath(past.path))
},
finish: () => {
@@ -483,7 +456,6 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
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. */
@@ -496,15 +468,11 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
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(),
/** What every view shares: `q` closes its tile, back to the one before. */
views: {
close: () => void runCommand('close'),
},
global: {
@@ -524,6 +492,12 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
cycle: (key) => guard(() => workspace.cycleSpace(shifted(key) ? -1 : 1)),
overview: () => void openOverview(),
fullscreen: () => void (workspace.fullscreen = !workspace.fullscreen),
// `gt` / `gT` walk the tiles, as they walk vim's tab pages.
nextTile: () => void cycleTile(1),
previousTile: () => void cycleTile(-1),
// 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'),
// 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'),
@@ -532,7 +506,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
// `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'),
plan: () => void runCommand('plan'),
line: () => line(':'),
help: () => void runCommand('help'),
},
+40 -34
View File
@@ -95,6 +95,11 @@ export async function openInfo(section?: InfoSection) {
})
}
/** The board's cursor, or the card turned over beside it, has the keys. */
const onBoard = () => workspace.focus === 'board' || workspace.focus === 'card'
/** A card has the keys: on the board, its back, or highlighted in the agenda. */
const cardFocused = () => onBoard() || workspace.focus === 'agenda'
const requireOpen = () => {
if (!workspace.open) {
workspace.say('no document open', 'error')
@@ -151,7 +156,7 @@ export function cycleTile(delta: 1 | -1) {
/** `:ls`'s line for one tile: number, `%` for the focused one, `[+]` unsaved. */
const describeTile = (index: number) => {
const tile = workspace.tiles[index]
const name = tile.open ? tile.open.name.trim() || tile.open.slug : '(empty)'
const name = tile.kind !== 'node' ? tile.kind : tile.open ? tile.open.name.trim() || tile.open.slug : '(empty)'
return `${index + 1}${index === workspace.focused ? '%' : ''} ${name}${tile.dirty ? ' [+]' : ''}`
}
@@ -373,16 +378,12 @@ export const commands: CommandSpec[] = [
if (workspace.scratchEditing) return workspace.leaveScratchNote()
if (workspace.describing) return void workspace.stopDescribing()
// A document `o` made and nothing was written in is not kept.
if (await workspace.abandonIfBlank()) {
if (workspace.returnTo === 'special') await workspace.guard(() => workspace.returnToSpecial())
return hooks.focusList()
}
if (await workspace.abandonIfBlank()) return hooks.focusList()
if (workspace.dirty) {
workspace.say('unsaved changes — :w to write, :q! to discard', 'error')
return
}
if (workspace.editing) workspace.editing = false
else if (workspace.returnTo === 'special') return void workspace.guard(() => workspace.returnToSpecial())
else if (workspace.tiles.length > 1) return focusTile(closeFocused())
else workspace.closeTile()
hooks.focusList()
@@ -472,14 +473,15 @@ export const commands: CommandSpec[] = [
{
name: 'board',
group: 'board',
help: "this space's board, in the special workspace",
run: () => workspace.guard(() => workspace.showSpecial('board')),
help: "this space's board, in a tile — the last one shown when this space has none",
run: () => workspace.guard(() => workspace.showView('board')),
},
{
name: 'special',
name: 'plan',
aliases: ['special'],
group: 'board',
help: 'the special workspace: the board, the agenda and the calendar side by side — S puts it up and away, Tab walks its tiles, 1 … 9 picks the board',
run: () => workspace.guard(() => workspace.toggleSpecial()),
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',
run: () => workspace.guard(() => workspace.togglePlan()),
},
{
name: 'card',
@@ -494,9 +496,7 @@ export const commands: CommandSpec[] = [
bang: {
help: 'add a card linked to the open document — gf on it opens the document',
run: async (arg) => {
// On the board the tree is not on screen, so only the open document
// can be meant; elsewhere it is whatever has focus.
const node = workspace.view === 'special' ? workspace.open : workspace.target
const node = workspace.target
if (!node || node.kind !== 'document') {
return workspace.say('no document to link — open one first', 'error')
}
@@ -509,11 +509,11 @@ export const commands: CommandSpec[] = [
name: 'due',
args: '<when|->',
group: 'board',
help: 'due date of the card — on the board, or highlighted in the agenda: today, tm, fri, +3d, +2w, 10-31, 2026-10-31 — - clears',
help: 'due date of the card — on the board, its back, or highlighted in the agenda: today, tm, fri, +3d, +2w, 10-31, 2026-10-31 — - clears',
complete: () => ['today', 'tm', 'fri', '+3d'],
run: async (arg) => {
const agenda = workspace.view === 'special' && workspace.specialFocus === 'agenda'
if (workspace.view !== 'special') return workspace.say('no board open — :board', 'error')
const agenda = workspace.focus === 'agenda'
if (!cardFocused()) return workspace.say('no card in focus — :board', 'error')
const set = (due_at: number | null) =>
agenda ? workspace.patchAgendaCard({ due_at }) : workspace.patchCard({ due_at })
const target = arg.trim()
@@ -531,8 +531,8 @@ export const commands: CommandSpec[] = [
help: 'once the card is done, a copy comes back in the inbox: daily, weekly, monthly, yearly, +3d, +2w, +6m — - stops it',
complete: () => ['daily', 'weekly', 'monthly', 'yearly', '+2w', '-'],
run: async (arg) => {
const agenda = workspace.view === 'special' && workspace.specialFocus === 'agenda'
if (workspace.view !== 'special') return workspace.say('no board open — :board', 'error')
const agenda = workspace.focus === 'agenda'
if (!cardFocused()) return workspace.say('no card in focus — :board', 'error')
const rule = arg.trim()
const repeat = rule === '-' || rule === '' ? null : rule
await workspace.guard(async () => {
@@ -547,7 +547,11 @@ export const commands: CommandSpec[] = [
group: 'board',
help: 'move the selected card into the done list, or back out to the inbox',
run: () => {
if (workspace.view !== 'special') return workspace.say('no board open — :board', 'error')
if (workspace.focus === 'agenda') {
const card = workspace.agendaCards[workspace.agendaCursor]
return card ? workspace.guard(() => workspace.finishCard(card)) : workspace.say('no card highlighted', 'error')
}
if (!onBoard()) return workspace.say('no card in focus — :board', 'error')
return workspace.guard(() => workspace.toggleDone())
},
},
@@ -579,14 +583,14 @@ export const commands: CommandSpec[] = [
group: 'board',
help: "add a step to the open card's checklist, below the cursor",
run: async (arg) => {
if (workspace.overlay !== 'card') return workspace.say('no card open — Enter on the board opens one', 'error')
if (workspace.focus !== 'card') return workspace.say('no card turned over — Enter on the board turns one', 'error')
if (!arg.trim()) return workspace.say('usage: :task <text>', 'error')
await workspace.guard(() => workspace.addTask(arg.trim()))
},
bang: {
help: 'reword the step under the cursor',
run: async (arg) => {
if (workspace.overlay !== 'card') return workspace.say('no card open — Enter on the board opens one', 'error')
if (workspace.focus !== 'card') return workspace.say('no card turned over — Enter on the board turns one', 'error')
if (!arg.trim()) return workspace.say('usage: :task! <text>', 'error')
await workspace.guard(() => workspace.renameTask(arg.trim()))
},
@@ -599,7 +603,7 @@ export const commands: CommandSpec[] = [
help: 'put a label on the card, or take it off when it has it — each name keeps its colour everywhere',
complete: () => workspace.boardLabels,
run: async (arg) => {
if (workspace.view !== 'special') return workspace.say('no board open — :board', 'error')
if (!onBoard()) return workspace.say('no card in focus — :board', 'error')
if (!arg.trim()) return workspace.say(workspace.card?.labels.join(' · ') || 'no labels — :label <name> adds one')
await workspace.guard(() => workspace.toggleLabel(arg))
},
@@ -610,14 +614,14 @@ export const commands: CommandSpec[] = [
group: 'board',
help: 'add a column to the board',
run: async (arg) => {
if (workspace.view !== 'special') return workspace.say('no board open — :board', 'error')
if (!workspace.view('board')) return workspace.say('no board open — :board', 'error')
if (!arg.trim()) return workspace.say('usage: :list <name>', 'error')
await workspace.guard(() => workspace.createList(arg.trim()))
},
bang: {
help: 'delete the column under the cursor and every card on it',
run: async () => {
if (workspace.view !== 'special') return workspace.say('no board open — :board', 'error')
if (!workspace.view('board')) return workspace.say('no board open — :board', 'error')
const list = workspace.list
if (!list) return workspace.say('no list selected', 'error')
const cards = list.cards.length
@@ -695,7 +699,7 @@ export const commands: CommandSpec[] = [
help: 'the diary by month — h/l a day, j/k a week, H/L a month, Enter opens the day or starts it',
run: () =>
workspace.guard(async () => {
if (workspace.view === 'special') return void (workspace.specialFocus = 'calendar')
if (workspace.view('calendar')) return workspace.showView('calendar')
await workspace.openCalendar()
}),
},
@@ -706,8 +710,8 @@ export const commands: CommandSpec[] = [
help: 'what is late, due this week, or in hand — across every board',
run: () =>
workspace.guard(async () => {
if (workspace.view !== 'special') workspace.agendaCursor = 0
await workspace.showSpecial('agenda')
if (!workspace.view('agenda')) workspace.agendaCursor = 0
await workspace.showView('agenda')
}),
},
{
@@ -797,14 +801,16 @@ export const commands: CommandSpec[] = [
args: '<n|name>',
group: 'tiles',
help: 'focus a tile by its number in :ls, or by the start of its name',
complete: () => workspace.tiles.flatMap((tile) => (tile.open ? [tile.open.slug] : [])),
complete: () => workspace.tiles.flatMap((tile) => (tile.kind !== 'node' ? [tile.kind] : tile.open ? [tile.open.slug] : [])),
run: (arg) => {
const wanted = arg.trim().toLowerCase()
if (!wanted) return workspace.say(describeTile(workspace.focused))
const index = /^\d+$/.test(wanted)
? Number(wanted) - 1
: workspace.tiles.findIndex(
(tile) => tile.open && (tile.open.slug.startsWith(wanted) || tile.open.name.toLowerCase().startsWith(wanted)),
: workspace.tiles.findIndex((tile) =>
tile.kind !== 'node'
? tile.kind.startsWith(wanted)
: tile.open && (tile.open.slug.startsWith(wanted) || tile.open.name.toLowerCase().startsWith(wanted)),
)
if (!workspace.tiles[index]) return workspace.say(`E86: no tile ${arg.trim()} — :ls lists them`, 'error')
return focusTile(index)
@@ -850,7 +856,7 @@ export const commands: CommandSpec[] = [
help: 'delete a node, and everything under it, into the trash (asks first) — u brings it back',
run: async (arg) => {
// On the board the thing under the cursor is a card, not a node.
if (workspace.view === 'special') {
if (onBoard() && !arg) {
const card = workspace.card
if (!card) return workspace.say('no card selected', 'error')
if (!(await workspace.ask(`delete the card "${card.title}"?`))) return
@@ -878,7 +884,7 @@ export const commands: CommandSpec[] = [
help: 'rename what is under the cursor — document, folder, or card on a board; bare, while writing, into the title',
run: async (arg) => {
if (!arg.trim() && workspace.editing && workspace.pane === 'document') return hooks.focusTitle()
if (workspace.view === 'special') {
if (onBoard()) {
if (!arg.trim()) return workspace.say('usage: :name <text>', 'error')
return workspace.guard(() => workspace.patchCard({ title: arg.trim() }))
}
@@ -899,7 +905,7 @@ export const commands: CommandSpec[] = [
bang: {
help: 'rename the space you are in — on the board, the list under the cursor',
run: async (arg) => {
if (workspace.view === 'special') {
if (onBoard()) {
if (!arg.trim()) return workspace.say('usage: :name! <list name>', 'error')
return workspace.guard(() => workspace.renameList(arg.trim()))
}
+16 -22
View File
@@ -10,8 +10,8 @@
/**
* 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.
* always > a sheet > the drawer > workspace > writing > what has the focus
* (the tree, or a view: board, agenda, calendar, card) > views > global.
*/
export type Context =
| 'always'
@@ -33,7 +33,7 @@ export type Context =
| 'board'
| 'agenda'
| 'calendar'
| 'special'
| 'views'
| 'global'
| 'editor'
@@ -77,8 +77,8 @@ export const bindings = [
{ 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: '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' },
{ context: 'tree', keys: ['k', 'ArrowUp'], action: 'up' },
@@ -104,16 +104,14 @@ export const bindings = [
{ 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: ['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' },
{ context: 'global', keys: ['t'], action: 'agenda', help: 'agenda — late, due this week, in hand, on every board — in a tile of its own' },
{ 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: '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: '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: '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: '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' },
@@ -136,11 +134,11 @@ export const bindings = [
// 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' },
// --- 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" },
// --- 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: ['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' },
{ 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' },
@@ -151,7 +149,7 @@ export const bindings = [
{ 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: ['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: ['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 …' },
@@ -161,7 +159,6 @@ export const bindings = [
{ 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' },
@@ -182,7 +179,6 @@ export const bindings = [
{ 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' },
@@ -200,9 +196,9 @@ export const bindings = [
{ 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: ['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 on to the tile below' },
{ context: 'agenda', keys: ['k', 'ArrowUp'], action: 'up' },
{ context: 'agenda', keys: ['h', 'ArrowLeft'], action: 'left', label: 'h', help: 'to the board' },
{ context: 'agenda', keys: ['h', 'ArrowLeft'], action: 'left', label: 'h', help: 'to the tile on the left' },
{ 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' },
@@ -211,7 +207,6 @@ export const bindings = [
{ 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' },
@@ -219,7 +214,6 @@ export const bindings = [
{ 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' },
@@ -286,7 +280,7 @@ export const bindings = [
/** 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: 'keys', title: 'keys', contexts: ['always', 'workspace', 'global', 'tree', 'views'] },
{ 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'] },
+13 -9
View File
@@ -4,12 +4,13 @@
* a context takes it.
*
* always Ctrl-S, Alt-h/j/k/l — heard in the editor too
* a sheet help, info, trash, the card's back …; nothing under one hears a key
* a sheet help, info, trash …; 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
* tile what has the focus: the tree, or a view — board, agenda,
* calendar, a card's back
* views what the views share: q, gt, b
* global spaces, t, T, C, S, :, ?
*
* The `[y/N]` prompt sits over all of it, in App.svelte, and the command
@@ -20,7 +21,7 @@ import { bindings, type Binding, type Context } from './keymap'
import { workspace } from './store.svelte'
/** The sheets with keys of their own, under the same name as the sheet. */
const sheets = new Set<string>(['help', 'overview', 'media', 'trash', 'card'])
const sheets = new Set<string>(['help', 'overview', 'media', 'trash'])
/** The stack, the topmost first. */
export function stack(): Context[] {
@@ -29,16 +30,19 @@ export function stack(): Context[] {
if (overlay !== 'none') {
// The info sheet hears the keys of the part on show, then its own Tab.
if (overlay === 'info') modes.push(workspace.infoSection, 'info')
// While the description is written, the card's back has no keys of its own.
else if (sheets.has(overlay) && !(overlay === 'card' && workspace.describing)) modes.push(overlay as Context)
else if (sheets.has(overlay)) 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')
const focus = workspace.focus
if (focus === 'list' || focus === 'node') {
if (workspace.editing && focus === 'node') modes.push('writing')
modes.push('tree')
} else {
// While the description is written, the card's back has no keys of its own.
if (!(focus === 'card' && workspace.describing)) modes.push(focus)
modes.push('views')
}
modes.push('global')
return modes
+225 -170
View File
@@ -26,22 +26,23 @@ import { formatBytes, formatDay, formatStamp, formatWeekday, isTouchDevice, pars
export type Pane = 'list' | 'document'
/**
* The tree of documents, or the special workspace: the board of the space
* being browsed, the agenda and the calendar, tiled side by side and a key
* away from anywhere, like the desktop's scratchpad.
* What a tile shows: a node of the tree — a document or a file — or one of
* the views over the boards and the diary, which are tiled beside the
* documents like any other window.
*/
export type View = 'tree' | 'special'
/** The tile of the special workspace that has focus. */
export type SpecialTile = 'board' | 'agenda' | 'calendar'
export type TileKind = 'node' | 'board' | 'agenda' | 'calendar' | 'card'
export type View = Exclude<TileKind, 'node'>
/** What floats over the tiles; one at a time. */
export type Sheet = 'help' | 'media' | 'overview' | 'actions' | 'verify' | 'info' | 'trash' | 'card'
export type Sheet = 'help' | 'media' | 'overview' | 'actions' | 'verify' | 'info' | 'trash'
/** The info sheet's parts: what the open document holds, and what is around it. */
export type InfoSection = 'outline' | 'history' | 'backlinks' | 'share'
export type Flash = { text: string; kind: 'info' | 'error' } | null
/**
* Where a space was left: the folder, the open documents, the row, the pane.
* `openId` is the focused tile's, all a spot held before there were tiles.
* 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.
*/
type Spot = {
parentId: number
@@ -49,6 +50,7 @@ type Spot = {
cursor: number
pane: Pane
open?: number[]
tiles?: (number | View)[]
focused?: number
}
@@ -63,12 +65,15 @@ function doneSaid(moved: Moved): string {
let tiles = 0
/**
* One document on screen, and everything that belongs to it rather than to
* One window on screen, and everything that belongs to it rather than to
* the workspace: what it shows, the draft being written, whether that is
* saved, and the timer that will save it.
* saved, and the timer that will save it. A view keeps its state in the
* workspace — there is one board, one agenda, one calendar — so its tile
* holds only its kind.
*/
export class Tile {
readonly id = ++tiles
kind = $state<TileKind>('node')
open = $state<WorkspaceNode | null>(null)
draft = $state<Draft>({ name: '', body: '', created_at: 0 })
editing = $state(false)
@@ -77,12 +82,6 @@ export class Tile {
dirty = $state(false)
/** The editor's vim mode, for the status line while this tile has focus. */
vimMode = $state('normal')
/*
* Set when a card's document was opened from the board, so closing the
* document goes back to the board — on the same card — rather than leaving
* you in a tree you never asked for.
*/
returnTo = $state<'special' | null>(null)
/*
* The document `o` just made. If it is left with neither a name nor a word
* in it, it is removed on the way out rather than left as an empty row.
@@ -98,9 +97,19 @@ export class Tile {
this.autosave = null
}
/** Put the tile back to showing nothing. Where it goes back to stays. */
constructor(kind: TileKind = 'node') {
this.kind = kind
}
/** Showing nothing: a node tile with no node in it. */
get empty(): boolean {
return this.kind === 'node' && !this.open
}
/** Put the tile back to showing nothing. */
clear() {
this.stopAutosave()
this.kind = 'node'
this.open = null
this.editing = false
this.dirty = false
@@ -173,12 +182,6 @@ class Workspace {
set dirty(dirty: boolean) {
this.tile.dirty = dirty
}
get returnTo() {
return this.tile.returnTo
}
set returnTo(to: 'special' | null) {
this.tile.returnTo = to
}
get vimMode() {
return this.tile.vimMode
}
@@ -191,6 +194,23 @@ class Workspace {
return this.tiles.filter((tile) => tile.open?.id === id)
}
/*
* The tiles focused before the one focused now, the last the latest:
* closing a tile goes back to the one that had the focus before it — to
* the board a card's notes were opened from, to the calendar a day was.
*/
#trail: Tile[] = []
/** What has the keys: the list, or the kind of the focused tile. */
get focus(): 'list' | TileKind {
return this.pane === 'list' || this.tile.empty ? 'list' : this.tile.kind
}
/** The tile showing `kind`, if one is. */
view(kind: View): Tile | undefined {
return this.tiles.find((tile) => tile.kind === kind)
}
/**
* Focus a tile, and follow it in the list and the address bar, as opening
* its document did.
@@ -198,34 +218,48 @@ class Workspace {
focusTile(index: number) {
const tile = this.tiles[index]
if (!tile) return
if (tile !== this.tile) {
this.#leaveCard()
this.#trail = [...this.#trail.filter((t) => t !== tile && t !== this.tile), this.tile].slice(-20)
}
this.focused = index
if (!tile.open) return
if (tile.empty) return
this.pane = 'document'
if (!tile.open) return
const row = this.nodes.findIndex((n) => n.id === tile.open?.id)
if (row >= 0) this.cursor = row
this.#syncUrl(tile.open.path)
}
/** A tile of its own for what opens next, beside what is on screen. */
#addTile(tile = new Tile()) {
this.tiles.push(tile)
this.focusTile(this.tiles.length - 1)
}
/**
* Tiles with nothing in them go, as long as one is left: a closed document
* closes its tile, as `:q` closes a window in vim.
* closes its tile, as `:q` closes a window in vim. When the focused one
* went, the focus goes back along the trail.
*/
prune() {
const current = this.tile
if (this.tiles.length > 1) {
const kept = this.tiles.filter((tile) => tile.open)
const kept = this.tiles.filter((tile) => !tile.empty)
this.tiles = kept.length > 0 ? kept : [current]
const at = this.tiles.indexOf(current)
this.focused = at >= 0 ? at : Math.min(this.focused, this.tiles.length - 1)
const back = this.tiles.includes(current) ? current : [...this.#trail].reverse().find((tile) => this.tiles.includes(tile))
this.focused = back ? this.tiles.indexOf(back) : Math.min(this.focused, this.tiles.length - 1)
}
if (!this.open) this.pane = 'list'
this.#trail = this.#trail.filter((tile) => this.tiles.includes(tile) && tile !== this.tile)
if (this.tile.empty) this.pane = 'list'
}
/** `:close`: the focused tile goes, or with only one, its document. */
/** `:close`: the focused tile goes, or with only one, what it shows. */
closeTile() {
if (this.tile.kind === 'card' && this.describing && !this.stopDescribing()) return
this.tile.clear()
this.prune()
if (this.open) this.focusTile(this.focused)
if (!this.tile.empty) this.focusTile(this.focused)
}
/**
@@ -238,10 +272,7 @@ class Workspace {
if (isContainer(selected)) return this.say(`${selected.slug} is a ${selected.kind} — l goes into it`, 'error')
const shown = this.tiles.findIndex((tile) => tile.open?.id === selected.id)
if (shown >= 0) return this.focusTile(shown)
if (this.open) {
this.tiles.push(new Tile())
this.focused = this.tiles.length - 1
}
if (!this.tile.empty) this.#addTile()
try {
await this.openSelected()
} finally {
@@ -261,11 +292,10 @@ class Workspace {
for (const tile of others) tile.stopAutosave()
this.tiles = [this.tile]
this.focused = 0
this.#trail = []
}
/** The board of the current space, loaded only while it is being looked at. */
view = $state<View>('tree')
/** The board as the server sent it. */
/** The board as the server sent it, loaded only while it is being looked at. */
rawBoard = $state<Board | null>(null)
/*
* The board as it is walked and drawn: the done list keeps only what was
@@ -329,10 +359,8 @@ class Workspace {
overlay = $state<'none' | Sheet>('none')
/** The part of the info sheet on show; it stays for the next time it opens. */
infoSection = $state<InfoSection>('outline')
/** Which of the special workspace's tiles the keys go to. */
specialFocus = $state<SpecialTile>('board')
/**
* The space whose board the special workspace shows: the one being browsed
* The space whose board the board's tile shows: the one being browsed
* when it has a board, or else the last one shown, so that coming from the
* diary does not leave the board's tile empty.
*/
@@ -794,33 +822,49 @@ class Workspace {
if (start) await this.enterSpace(start.id)
}
#rememberSpot() {
/** `views` false: the views are leaving with the focus, and are not left behind. */
#rememberSpot(views = true) {
if (this.spaceId === null || this.parentId === null) return
const open = this.tiles.flatMap((tile) => (tile.open ? [tile.open.id] : []))
// A card's back is the card under the board's cursor, which is not kept.
const kept = this.tiles.filter((tile) => !tile.empty && (tile.kind === 'node' || (views && tile.kind !== 'card')))
this.#spots[this.spaceId] = {
parentId: this.parentId,
openId: this.open?.id ?? null,
cursor: this.cursor,
pane: this.pane,
open,
focused: Math.max(open.indexOf(this.open?.id ?? -1), 0),
tiles: kept.map((tile) => (tile.kind === 'node' ? tile.open!.id : tile.kind)),
focused: Math.max(kept.indexOf(this.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.
*/
let carried: Tile[] = []
let carriedFocus: Tile | undefined
if (leaving) {
await this.flush()
this.#rememberSpot()
const carry = this.focus !== 'list' && this.focus !== 'node'
this.#rememberSpot(!carry)
const from = this.spaces.findIndex((s) => s.id === this.spaceId)
const to = this.spaces.findIndex((s) => s.id === id)
this.switchDirection = to >= from ? 1 : -1
if (carry) {
carried = this.tiles.filter((tile) => tile.kind !== 'node' && tile.kind !== 'card')
carriedFocus = this.tile.kind === 'card' ? (this.view('board') ?? carried[0]) : this.tile
}
// Each space has tiles of its own; these come back with this one.
for (const tile of this.tiles) tile.stopAutosave()
this.tiles = [new Tile()]
this.focused = 0
this.pane = 'list'
this.tiles = carried.length > 0 ? carried : [new Tile()]
this.focused = carriedFocus ? this.tiles.indexOf(carriedFocus) : 0
this.#trail = []
this.pane = carried.length > 0 ? 'document' : 'list'
}
this.spaceId = id
localStorage.setItem('diary:space', String(id))
@@ -836,44 +880,49 @@ class Workspace {
await this.goTo(id)
}
if (spot && this.parentId === spot.parentId) {
const ids = spot.open ?? (spot.openId !== null && this.nodes.some((n) => n.id === spot.openId) ? [spot.openId] : [])
let opened = 0
for (const open of ids) {
if (opened > 0) {
const entries =
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.view(entry)
if (!tile) this.tiles.push((tile = new Tile(entry)))
restored.push(tile)
continue
}
const empty = this.tiles.findIndex((tile) => tile.empty)
if (empty >= 0) this.focused = empty
else {
this.tiles.push(new Tile())
this.focused = this.tiles.length - 1
}
try {
// Reopened under the board, if that is what is on screen.
await this.openNode(open, false, true)
opened++
await this.openNode(entry)
restored.push(this.tile)
} catch {
/* deleted or moved away since: one tile fewer */
}
}
this.prune()
if (opened > 0) {
this.focusTile(Math.min(spot.focused ?? 0, this.tiles.length - 1))
this.pane = spot.pane
const back = carriedFocus ?? restored[Math.min(spot.focused ?? 0, restored.length - 1)]
if (back && this.tiles.includes(back)) {
this.focusTile(this.tiles.indexOf(back))
if (!carriedFocus) this.pane = spot.pane
} else {
this.cursor = Math.min(spot.cursor, Math.max(this.nodes.length - 1, 0))
}
}
// Switching space with the special workspace up shows the new space's
// board; a space without one leaves the last board where it is.
if (this.view === 'special' && this.space?.has_board) {
this.listCursor = 0
this.cardCursor = 0
await this.reloadBoard(id, undefined)
}
this.#trail = []
await this.#loadViews()
}
/** Show the children of `id`, which must be a space or a folder. */
async goTo(id: number, keep?: number) {
await this.flush()
const folder = await api.getNode(id)
// 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()
// 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)
this.parentId = id
this.parentSealed = folder.sealed
this.query = ''
@@ -905,8 +954,6 @@ class Workspace {
}
async search(query: string, global = this.globalSearch) {
// The results are rows in the list, which the board would hide.
if (query && this.view === 'special') this.closeSpecial()
this.query = query
this.globalSearch = global && query !== ''
this.cursor = 0
@@ -936,19 +983,10 @@ class Workspace {
this.cursor = Math.min(Math.max(this.cursor + delta, 0), this.nodes.length - 1)
}
async openNode(id: number, edit = false, underBoard = false) {
async openNode(id: number, edit = false) {
if (this.open?.id !== id) await this.flushTile()
const node = await api.getNode(id)
// What is asked for from the board — `:12`, `T`, a search — is shown, not
// opened behind it; closing a document goes back to the board.
if (this.view === 'special' && !underBoard) {
if (isContainer(node)) this.closeSpecial()
else {
this.view = 'tree'
this.returnTo = 'special'
}
}
if (isContainer(node)) {
await this.goTo(node.id)
return
@@ -961,6 +999,10 @@ class Workspace {
if (edit && node.kind === 'document') this.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.tile.kind !== 'node') this.#addTile()
this.open = node
this.draft = { name: node.name, body: node.body, created_at: node.created_at }
@@ -1112,7 +1154,7 @@ class Workspace {
this.say(`restored the space ${name}`)
} else {
const parent = node.path.at(-2)
if (parent && node.path[0]?.id === this.spaceId && this.view === 'tree') {
if (parent && node.path[0]?.id === this.spaceId) {
if (parent.id === this.parentId && !this.query) await this.refresh(node.id)
else if (this.overlay !== 'trash') await this.goTo(parent.id, node.id)
}
@@ -1256,7 +1298,6 @@ class Workspace {
this.prune()
this.rawBoard = null
this.boardSpaceId = null
this.view = 'tree'
this.spaces = await api.listSpaces()
const next = this.spaces.find((space) => space.role === 'diary') ?? this.spaces[0]
if (next) await this.enterSpace(next.id)
@@ -1356,7 +1397,7 @@ class Workspace {
if (late + due === 0) return
localStorage.setItem('diary:agenda-seen', today)
this.agendaCursor = 0
await this.openSpecial('agenda')
await this.showView('agenda')
}
/** `C`: the calendar, on the day of the entry being read or else today. */
@@ -1364,7 +1405,7 @@ class Workspace {
const open = this.open
const reading = open && this.spaces.find((space) => space.id === open.path[0]?.id)?.role === 'diary'
this.calendarDay = formatDay(reading ? open.created_at : Math.floor(Date.now() / 1000))
await this.openSpecial('calendar')
await this.showView('calendar')
}
async loadCalendar() {
@@ -1385,7 +1426,7 @@ class Workspace {
async openCalendarDay() {
const day = this.calendarDay
const entry = this.calendarEntries.find((e) => e.day === day)
// Opened from the special workspace, so `q` on the entry comes back here.
// Beside the calendar, so `q` on the entry comes back to it.
if (entry) return this.openPath(entry.path)
await this.openDay(day)
}
@@ -1423,10 +1464,10 @@ class Workspace {
/** Enter in the agenda: to the card, on its own board, beside the agenda. */
async goToCard(card: AgendaCard) {
// From the agenda's tile, which comes along into the card's space.
if (this.spaceId !== card.space_id) await this.enterSpace(card.space_id)
await this.reloadBoard(card.space_id, card.id)
this.view = 'special'
this.specialFocus = 'board'
await this.showView('board')
}
/** Space in the agenda: into that board's done list, from wherever it is. */
@@ -1435,7 +1476,7 @@ class Workspace {
if (!done) return
const moved = await api.moveCard(card.id, done.id, done.cards.length)
await this.loadAgenda()
if (this.view === 'special' && this.boardSpaceId === card.space_id) await this.reloadBoard()
if (this.boardSpaceId === card.space_id) await this.reloadBoard()
this.say(doneSaid(moved))
}
@@ -1448,7 +1489,7 @@ class Workspace {
// It may have changed group; the cursor goes with it.
const at = this.agendaCards.findIndex((c) => c.id === card.id)
if (at >= 0) this.agendaCursor = at
if (this.view === 'special' && this.boardSpaceId === card.space_id) await this.reloadBoard()
if (this.boardSpaceId === card.space_id) await this.reloadBoard()
return updated
}
@@ -1465,7 +1506,7 @@ class Workspace {
await this.loadAgenda()
const at = this.agendaCards.findIndex((c) => c.id === card.id)
if (at >= 0) this.agendaCursor = at
if (this.view === 'special' && this.boardSpaceId === card.space_id) await this.reloadBoard()
if (this.boardSpaceId === card.space_id) await this.reloadBoard()
this.say(from + delta === lists.length - 1 ? doneSaid(moved) : `"${card.title}" → ${target.name}`)
}
@@ -1533,56 +1574,85 @@ class Workspace {
* where a card is would be worse than a round trip.
*/
/**
* The special workspace, with `focus` the tile the keys go to. Everything
* in it is read again on the way in: it is looked at in passing, and what
* it says should be what is true now.
/*
* 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.
*/
async openSpecial(focus: SpecialTile = this.specialFocus) {
/** `b`, `t`, `C`, Enter on a card: to that view's tile, opening it first when it is not up. */
async showView(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.tile.empty) this.tile.kind = kind
else this.tiles.push(new Tile(kind))
}
this.focusTile(this.tiles.indexOf(this.view(kind)!))
if (kind === 'board' && this.boardSpaceId === null) this.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.
*/
async togglePlan() {
if (Workspace.PLAN.every((kind) => this.view(kind))) {
for (const tile of this.tiles) if (tile.kind !== 'node') tile.clear()
this.prune()
if (!this.tile.empty) this.focusTile(this.focused)
return
}
await Promise.all(Workspace.PLAN.map((kind) => this.#loadView(kind)))
const current = this.tile
const missing = Workspace.PLAN.filter((kind) => !this.view(kind)).map((kind) => new Tile(kind))
this.tiles = [...missing, ...this.tiles]
this.focused = this.tiles.indexOf(current)
this.prune()
this.focusTile(this.tiles.indexOf(this.view('board')!))
if (this.boardSpaceId === null) this.say('no space has a board — :set board turns one on', 'error')
}
/** Read again what a view shows. */
async #loadView(kind: View) {
if (kind === 'board' || kind === 'card') await this.#loadBoard()
else if (kind === 'agenda') await this.loadAgenda()
else {
if (!this.calendarDay) this.calendarDay = formatDay(Math.floor(Date.now() / 1000))
await this.loadCalendar()
}
}
/** Every view on screen read again, after a space switch brought them. */
async #loadViews() {
if (this.view('board') || this.view('card')) await this.#loadBoard()
if (this.view('calendar')) await this.#loadView('calendar')
}
/**
* The board of the space being browsed, or else the one shown last, or
* else the first space's that has one; null when no space has a board.
*/
async #loadBoard() {
const space = this.space
const shown = this.boardSpace?.has_board ? this.boardSpace : this.spaces.find((s) => s.has_board)
const board = space?.has_board ? space : shown
if (board) await this.reloadBoard(board.id, board.id === this.boardSpaceId ? this.card?.id : undefined)
else {
if (!board) {
this.rawBoard = null
this.boardSpaceId = null
return
}
if (!this.calendarDay) this.calendarDay = formatDay(Math.floor(Date.now() / 1000))
await Promise.all([this.loadAgenda(), this.loadCalendar()])
this.view = 'special'
this.specialFocus = focus
if (focus === 'board' && !board) this.say('no space has a board — :set board turns one on', 'error')
}
closeSpecial() {
this.view = 'tree'
this.returnTo = null
}
/** `S`: the special workspace up, on the tile it was left on, or away again. */
async toggleSpecial() {
if (this.view === 'special') this.closeSpecial()
else await this.openSpecial()
}
/**
* `b`, `t` and `C`: to that tile of the special workspace, putting it up
* first when it is not. They only ever go somewhere; leaving is `S`,
* `q` or `Esc`, from whichever tile.
*/
async showSpecial(focus: SpecialTile) {
if (this.view === 'special') this.specialFocus = focus
else await this.openSpecial(focus)
}
/** `Tab` / `Shift-Tab` in the special workspace: the next tile round, or the one before. */
cycleSpecial(by: 1 | -1) {
const order: SpecialTile[] = ['board', 'agenda', 'calendar']
this.specialFocus = order[(order.indexOf(this.specialFocus) + by + order.length) % order.length]
}
async openBoard() {
await this.openSpecial('board')
// Another space's board starts at its top left, as a space switch did.
if (board.id !== this.boardSpaceId) {
this.listCursor = 0
this.cardCursor = 0
}
await this.reloadBoard(board.id, board.id === this.boardSpaceId ? this.card?.id : undefined)
}
get boardSpace(): NodeSummary | undefined {
@@ -1677,7 +1747,7 @@ class Workspace {
* it goes on the list under the cursor instead.
*/
async createCard(title: string, nodeId: number | null = null) {
if (this.view === 'special') {
if (this.focus === 'board' || this.focus === 'card') {
const list = this.list
if (!list) return this.say('no list to put it on', 'error')
const card = await api.createCard(list.id, { title, node_id: nodeId })
@@ -1733,11 +1803,23 @@ class Workspace {
* too, and the cursor follows it wherever a move puts it.
*/
openCardSheet() {
/** Enter on a card: its back, in a tile beside the board. */
async turnCard() {
if (!this.card) return this.say('no card selected', 'error')
this.taskCursor = 0
this.describing = false
this.openSheet('card')
await this.showView('card')
}
/*
* Leaving a card's back while its description is being written: what is
* written is kept, as leaving a document's tile keeps its draft.
*/
#leaveCard() {
if (this.tile.kind !== 'card' || !this.describing) return
void this.guard(async () => {
if (this.descriptionDirty) await this.saveDescription()
this.stopDescribing(true)
})
}
get scratchNote(): ScratchNote | undefined {
@@ -1864,25 +1946,17 @@ class Workspace {
await this.dropScratchNote(false)
this.scratchEditing = false
this.scratchOpen = false
if (this.view === 'special') this.closeSpecial()
await this.refresh(node.id)
await this.openNode(node.id)
this.say(`kept as ${node.path.map((crumb) => crumb.slug).join('/')}`)
}
/** `q`: the sheet away, unless that would lose a description being written. */
closeCardSheet() {
if (this.describing && !this.stopDescribing()) return
this.overlay = 'none'
}
openSheet(sheet: Sheet) {
this.overlay = sheet
}
/** Esc, q, a click beside it: the sheet away — the card's asks first. */
/** Esc, q, a click beside it: the sheet away. */
closeSheet() {
if (this.overlay === 'card') return this.closeCardSheet()
this.overlay = 'none'
}
@@ -2065,31 +2139,12 @@ class Workspace {
await api.updateCard(card.id, { node_id: node.id })
nodeId = node.id
}
this.view = 'tree'
if (this.parentId !== space) await this.goTo(space)
else await this.refresh()
// The notes open beside whatever was being read, and `q` takes their
// tile away again on the way back to the board.
if (this.open && this.open.id !== nodeId) {
this.tiles.push(new Tile())
this.focused = this.tiles.length - 1
}
// The notes open beside the board, and `q` takes their tile away again
// on the way back to it.
await this.openNode(nodeId, card.node_id === null)
this.prune()
if (card.node_id === null) this.enterInsert = true
this.returnTo = 'special'
}
/**
* Back to the special workspace a document was opened from — a card's
* notes, a day of the calendar, an entry of the agenda — on the tile it
* was opened from.
*/
async returnToSpecial() {
this.returnTo = null
this.closeTile()
this.pane = 'list'
await this.openSpecial(this.specialFocus)
}
/** Turn a space's board on or off. The diary ships with it off. */
@@ -2098,11 +2153,11 @@ class Workspace {
if (!space) return
await api.updateNode(space.id, { has_board: on })
this.spaces = await api.listSpaces()
if (on) await this.openBoard()
if (on) await this.showView('board')
else if (this.boardSpaceId === space.id) {
this.rawBoard = null
this.boardSpaceId = null
if (this.view === 'special') await this.openSpecial()
if (this.view('board') || this.view('card')) await this.#loadBoard()
}
this.say(on ? `${space.slug} has a board` : `${space.slug} has no board any more`)
}