diff --git a/README.md b/README.md index 94657af..4caf3aa 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/docs/wiki/Boards-and-Agenda.md b/docs/wiki/Boards-and-Agenda.md index 2ab9b35..e45b354 100644 --- a/docs/wiki/Boards-and-Agenda.md +++ b/docs/wiki/Boards-and-Agenda.md @@ -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 `) | | `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 diff --git a/docs/wiki/Commands.md b/docs/wiki/Commands.md index 9f3cb9e..5569265 100644 --- a/docs/wiki/Commands.md +++ b/docs/wiki/Commands.md @@ -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! `, `: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 ` | 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 | diff --git a/docs/wiki/Diary.md b/docs/wiki/Diary.md index 9c2fcc3..c17766a 100644 --- a/docs/wiki/Diary.md +++ b/docs/wiki/Diary.md @@ -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. diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index 9598a7c..f7ce69c 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -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. diff --git a/docs/wiki/Guide.md b/docs/wiki/Guide.md index 7a5ce37..ee4e695 100644 --- a/docs/wiki/Guide.md +++ b/docs/wiki/Guide.md @@ -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 diff --git a/web/src/App.svelte b/web/src/App.svelte index c423882..0b76bce 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -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> diff --git a/web/src/lib/Agenda.svelte b/web/src/lib/Agenda.svelte index 022f07a..ace874b 100644 --- a/web/src/lib/Agenda.svelte +++ b/web/src/lib/Agenda.svelte @@ -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; } diff --git a/web/src/lib/Bar.svelte b/web/src/lib/Bar.svelte index bd24a4a..6f80a03 100644 --- a/web/src/lib/Bar.svelte +++ b/web/src/lib/Bar.svelte @@ -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 diff --git a/web/src/lib/Board.svelte b/web/src/lib/Board.svelte index 25b8681..9c8ab55 100644 --- a/web/src/lib/Board.svelte +++ b/web/src/lib/Board.svelte @@ -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> diff --git a/web/src/lib/Calendar.svelte b/web/src/lib/Calendar.svelte index 1e4964e..dacf386 100644 --- a/web/src/lib/Calendar.svelte +++ b/web/src/lib/Calendar.svelte @@ -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; } diff --git a/web/src/lib/CardSheet.svelte b/web/src/lib/CardTile.svelte similarity index 77% rename from web/src/lib/CardSheet.svelte rename to web/src/lib/CardTile.svelte index f6a9936..dd00ac0 100644 --- a/web/src/lib/CardSheet.svelte +++ b/web/src/lib/CardTile.svelte @@ -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> diff --git a/web/src/lib/Rail.svelte b/web/src/lib/Rail.svelte index e5fe6ed..acf23cd 100644 --- a/web/src/lib/Rail.svelte +++ b/web/src/lib/Rail.svelte @@ -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) }, diff --git a/web/src/lib/actions.ts b/web/src/lib/actions.ts index c024d2e..7cdee35 100644 --- a/web/src/lib/actions.ts +++ b/web/src/lib/actions.ts @@ -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'), }, diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index 974ce9c..ba3cf18 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -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())) } diff --git a/web/src/lib/keymap.ts b/web/src/lib/keymap.ts index 94e0a2a..aef13f8 100644 --- a/web/src/lib/keymap.ts +++ b/web/src/lib/keymap.ts @@ -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'] }, diff --git a/web/src/lib/modes.ts b/web/src/lib/modes.ts index cd8d3bf..9f60237 100644 --- a/web/src/lib/modes.ts +++ b/web/src/lib/modes.ts @@ -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 diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index fcd8a69..fd2c69b 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -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`) }