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

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

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

No files matched your search

+9 -9
View File
@@ -141,9 +141,9 @@ it; in the document pane it is that document. In the browse pane:
| `R` | rename what is highlighted — a document, a folder or a file — starting from its name (`:name!` renames the space) | | `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 | | `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 | | `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 | | `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 the special workspace; a digit there shows another space's | | `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 the special workspace | | `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 | | `T` | today's diary entry, started if there is none |
| `C` | the diary by month (`:cal`) — see [The diary](#the-diary) | | `C` | the diary by month (`:cal`) — see [The diary](#the-diary) |
| `[d` / `]d` | the diary entry before / after the one open, by date | | `[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 ## Boards
`b`, the board door on the rail, or `:board` swaps the tree for the special `b`, the board door on the rail, or `:board` shows the board of the space
workspace — the board of the space being browsed, with the agenda and the being browsed in a tile beside what is open, and `q` or `Esc` closes it; `S`
calendar beside it — and `Esc` swaps it back. Every space has one except the 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 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 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 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` | down / up the cards of that list |
| `J` / `K` | move the card down / up its list | | `J` / `K` | move the card down / up its list |
| `H` / `L` | move the card to the list on the left / right | | `H` / `L` | move the card to the list on the left / right |
| `Enter` | turn the card over: labels, a markdown description, a checklist | | `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; `q` comes back | | `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 | | `Space` | into the done column, or back out to the inbox |
| `o`, `c` | a new card on this list | | `o`, `c` | a new card on this list |
| `D` | due date — `fri`, `tm`, `+3d`, `+2w`, `10-31`, `2026-10-31` | | `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 | | `z` | show done cards older than a week |
| `F` | a letter over every card in view; typing it puts the cursor on that card | | `F` | a letter over every card in view; typing it puts the cursor on that card |
| `x`, `dd` | delete the 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` 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` adds one and `dd` drops it; `e` writes the description in the vim editor, `:w`
+42 -34
View File
@@ -1,38 +1,44 @@
# Boards and the agenda # Boards and the agenda
## The special workspace ## Views
The board, the agenda and the [calendar](Diary.md#the-calendar) share one The board, the agenda, the [calendar](Diary.md#the-calendar) and the back of
workspace of their own, as the desktop's special workspace holds what is a card are views: tiles like the documents' tiles, opened beside what is on
looked at in passing. `S` (or `:special`, or the `special` pill in the bar) screen and walked the same way. `S` (or `:plan`) puts the board, the agenda
puts it up and away again. It is three tiles: the board on the left, the and the calendar up together, ahead of the documents with the board focused —
agenda over the calendar on the right. 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 | | key | what it does |
| --- | --- | | --- | --- |
| `S` | put the special workspace up, or away | | `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, from anywhere | | `b`, `t`, `C` | to the board, the agenda or the calendar, opening its tile when it is not up |
| `Tab` / `Shift-Tab` | the next / previous tile | | `gt` / `gT` | the next / previous tile |
| `Alt-h` / `Alt-j` / `Alt-k` / `Alt-l` | the tile that way | | `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 | | `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 | | `f` | the focused tile fills the screen; again or `Esc` to leave |
| `1` … `9` | switch space, and the board with it | | `1` … `9` | switch space; with a view focused, the views come along |
| `q`, `Esc` | leave, back to where you were | | `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 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` lit in its head; the other views keep their cursors, dimmed. A view is never
only ever go to their tile, and `Esc` only ever leaves — or, with a tile replaced by what is opened from it — a card's notes, a day of the calendar, an
filling the screen, leaves that first. A document opened from the workspace — entry of the agenda, a row of the list: that opens in a tile beside it, and
a card's notes, a day of the calendar, an entry of the agenda, `T` — comes `q` on it comes back to the view. Each view is read again whenever it is gone
back to the tile it was opened from on `q`. Everything in it is read again to.
each time it comes up.
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 ## The board
Every space except the diary has a board. `b`, the board button on the rail, or 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 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 the diary, the last board shown. The head of the board names every space that
has one, with the digit that goes there. 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 | | `z` | show done cards older than a week |
| `F` | a letter over every card; type it to jump there | | `F` | a letter over every card; type it to jump there |
| `x`, `dd` | delete the card | | `x`, `dd` | delete the card |
| `q`, `Esc` | leave the special workspace | | `q`, `Esc` | close the board's tile |
### The back of a card ### The back of a card
`Enter` on a card turns it over: a sheet floats over the board with its `Enter` on a card turns it over: a tile beside the board with its labels, its
labels, its description and its checklist. The card stays the one under the description and its checklist. It is the card under the board's cursor, so
board's cursor, so `D`, `R`, `H` and `L` act on it as they do on the board. 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 | | 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 | | `e`, `i`, `Enter` | write the description |
| `a` | put a label on, or take it off (`:label <name>`) | | `a` | put a label on, or take it off (`:label <name>`) |
| `gf` | the card's notes, below | | `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 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 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. coloured the way nvim colours it, written and read.
Labels are lower case, and each name keeps its colour on every card and every 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 ### 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 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). you stay where you were. From outside the browser, see [Capture](Capture.md).
### Notes ### Notes
`gf` on a card, or on its back, opens its notes: a document named after the card, made in the `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 root of the space the first time, in a tile beside the board. Closing it goes
same card. `:card!` while reading a document makes a card that points at it. 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 The link is loose on purpose: deleting the document leaves the card, because the
work is still open when its notes are gone. 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 ## The agenda
`t` (or `:agenda`) gathers what needs you from every board at once, in the `t` (or `:agenda`) gathers what needs you from every board at once, in a tile
special workspace beside the board. of its own.
![The agenda](../images/agenda.png) ![The agenda](../images/agenda.png)
@@ -163,15 +171,15 @@ special workspace beside the board.
| key | what it does | | key | what it does |
| --- | --- | | --- | --- |
| `j` / `k` | down / up, on into the diary entries, then into the calendar | | `j` / `k` | down / up, on into the diary entries, then into the tile below |
| `h` | to the board | | `h` | to the tile on the left |
| `Enter` | to the card on its own board, in the tile beside, or open the entry | | `Enter` | to the card on its own board, in the tile beside, or open the entry |
| `Space` | finish the card where it stands | | `Space` | finish the card where it stands |
| `D` | due date | | `D` | due date |
| `H` / `L` | to the list left / right on its board | | `H` / `L` | to the list left / right on its board |
| `:` | the command line with the agenda kept up, so `:repeat` acts on the card | | `:` | the command line with the agenda kept up, so `:repeat` acts on the card |
| `T` | today's diary entry | | `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" 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 is the day in `WORKSPACE_TIMEZONE`, the same as for the mails, so a browser in
+13 -16
View File
@@ -22,7 +22,7 @@ arguments and `↑` / `↓` recall earlier lines.
| `Alt-h/j/k/l` | focus the tile that way, from the editor too — left of the leftmost is the list | | `Alt-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 | | `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 | | `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 | | `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 | | `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 | | `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 / 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 | | `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 | | `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 | | `S` | the board, the agenda and the calendar, tiled ahead of what is open with the board focused — S again puts every view away |
| `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 a tile of its own |
| `t` | agenda — late, due this week, in hand, on every board — in the special workspace |
| `T` | today's diary entry, started if there is none — from templates/day when the diary has one | | `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 | | `[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 | | `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 | | `c` | capture a card into this space's inbox, without leaving here |
| `Esc · q` | close the document, and its tile when there are several | | `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 | | `/` | 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 | | `:` | command line — Tab / Shift-Tab cycle the matches, then the argument; ↑ ↓ recall what ran before |
| `Ctrl-S` | save, from anywhere | | `Ctrl-S` | save, from anywhere |
| `?` | this screen, from the editor too — h/l a column, / filters it down | | `?` | 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 ### On a board
@@ -71,13 +71,13 @@ The first list is the inbox, the last list is done.
| key | what it does | | 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 | | `j / k` | down / up the cards |
| `gg / G` | first / last card of the list | | `gg / G` | first / last card of the list |
| `J / K` | move the card down / up its list | | `J / K` | move the card down / up its list |
| `H / L` | move the card to the list left / right | | `H / L` | move the card to the list left / right |
| `Space` | into the done list, or back out to the inbox | | `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 | | `gf` | the card's notes — a document, made on first use; q comes back |
| `o · c` | new card on this list | | `o · c` | new card on this list |
| `D` | due date: fri, tm, +3d, 10-31 … | | `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 | | `F` | a letter over every card in view — type it to put the cursor there |
| `r` | read the board again, for changes made elsewhere | | `r` | read the board again, for changes made elsewhere |
| `x · dd` | delete the card (asks y/N) | | `x · dd` | delete the card (asks y/N) |
| `q · Esc` | leave the special workspace |
### On the back of a card ### 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 | | `R · D` | rename the card, its due date |
| `H / L` | send the card to the list left / right | | `H / L` | send the card to the list left / right |
| `gf` | the card's notes — a document, made on first use; q comes back | | `gf` | the card's notes — a document, made on first use; q comes back |
| `q · Esc` | back to the board |
### In the agenda ### In the agenda
| key | what it does | | key | what it does |
| --- | --- | | --- | --- |
| `j / k` | down / up the cards, then what was written on this day in earlier years, then into the calendar | | `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 board | | `h` | to the tile on the left |
| `Enter` | to the card, on its own board beside — or to the entry | | `Enter` | to the card, on its own board beside — or to the entry |
| `Space` | done, where it stands | | `Space` | done, where it stands |
| `D` | due date: fri, tm, +3d … — :due on the line | | `D` | due date: fri, tm, +3d … — :due on the line |
| `:` | the line, with the agenda kept up — :repeat weekly acts on the card | | `:` | the line, with the agenda kept up — :repeat weekly acts on the card |
| `H / L` | to the list left / right on its board | | `H / L` | to the list left / right on its board |
| `T` | today's diary entry | | `T` | today's diary entry |
| `q · Esc` | leave the special workspace — a document opened from it comes back here on q |
### In the scratchpad ### In the scratchpad
@@ -156,11 +153,11 @@ do: `:w!`, `:space! <name>`, `:import!`.
| command | what it does | | command | what it does |
| --- | --- | | --- | --- |
| `:board` | this space's board, in the special workspace | | `:board` | this space's board, in a tile — the last one shown when this space has none |
| `: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 | | `:plan, :special` | the board, the agenda and the calendar, tiled ahead of what is open — S puts them up, and every view away again; 1 … 9 on the board picks the board |
| `:card, :c <title>` | add a card — on the board to the list under the cursor, elsewhere to this space's inbox | | `:card, :c <title>` | add a card — on the board to the list under the cursor, elsewhere to this space's inbox |
| `:card!` | add a card linked to the open document — gf on it opens the document | | `: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 | | `: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 | | `: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 | | `:task, :step <text>` | add a step to the open card's checklist, below the cursor |
+5 -5
View File
@@ -13,9 +13,9 @@ an entry written after midnight on a trip still lands on the right day.
## The calendar ## The calendar
`C` (or `:cal`) shows the diary a month at a time, in the `C` (or `:cal`) shows the diary a month at a time, in a
[special workspace](Boards-and-Agenda.md#the-special-workspace) under the [tile of its own](Boards-and-Agenda.md#views) — under the agenda when `S` puts
agenda. them up together.
![The diary by month](../images/calendar.png) ![The diary by month](../images/calendar.png)
@@ -25,8 +25,8 @@ agenda.
| `j` / `k` | a week forward / back | | `j` / `k` | a week forward / back |
| `H` / `L` | a month back / forward | | `H` / `L` | a month back / forward |
| `.` | today | | `.` | today |
| `Enter` | open the day's entry, or start one dated that day | | `Enter` | open the day's entry beside the calendar, or start one dated that day |
| `q`, `Esc` | leave the special workspace; an entry opened here comes back on `q` | | `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 A dot marks a day with an entry. A number says how many when there are more
than one. than one.
+3 -3
View File
@@ -71,9 +71,9 @@ The rail of buttons on the right does the same as the keys, for a mouse.
add a name and press `Enter`. `l` or `Enter` on the folder goes into it, and add a name and press `Enter`. `l` or `Enter` on the folder goes into it, and
`h` comes back up. `h` comes back up.
6. Press `c`, type `try the board`, and press `Enter`. That is a card in the 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 inbox of this space's board. Press `b` to see the board in a tile, and
back. It shares a special workspace with the agenda and the calendar: `S` `Esc` to close it. `S` puts it up with the agenda and the calendar, and puts
puts it up and away, and `Tab` walks between them. them away again; `Alt-h`/`j`/`k`/`l` walk between the tiles.
7. Press `T`. That is today's diary entry, started for you. 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. 8. Press `?` for the list of every key and command. `h` / `l` walk its columns, `/` narrows it down, and `Esc` closes it.
+10 -9
View File
@@ -11,9 +11,9 @@ The keys are the desktop's. On a phone, every one of these has a button; see
## Morning: what needs you ## Morning: what needs you
The first time you open the workspace each day, the **agenda** opens by itself 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 when anything is late or due. Any other time, press `t`. It opens in a tile
**special workspace**, beside the board and the calendar; `S` puts that up and beside what is open; `S` puts it up with the board and the calendar, and puts
away from anywhere, and `Tab` walks its tiles. them away again, and `Alt-h`/`j`/`k`/`l` walk the tiles.
![The agenda](../images/agenda.png) ![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; - `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. - `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). counts while either is above zero. More in [Boards and the agenda](Boards-and-Agenda.md).
## At work: spaces, documents and tiles ## 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 `Renew the gateway certificate`, and `Enter`. The card is in this space's
inbox, and you have not left the document you were reading. 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. shows the board of another space.
![The board](../images/board.png) ![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 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 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 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: card over to its labels, description and checklist, in a tile beside, and
a document made for the card the first time you ask. `:repeat weekly` `gf` opens its notes: a document made for the card the first time you ask,
makes a card come back in the inbox each time it is finished. `q` goes back to also beside, and `q` on it comes back to the board. `:repeat weekly` makes a
the tree. card come back in the inbox each time it is finished. `q` on the board closes
its tile.
### Finding things ### Finding things
+32 -75
View File
@@ -20,7 +20,7 @@
import Overview from './lib/Overview.svelte' import Overview from './lib/Overview.svelte'
import MediaBrowser from './lib/MediaBrowser.svelte' import MediaBrowser from './lib/MediaBrowser.svelte'
import Trash from './lib/Trash.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 Scratch from './lib/Scratch.svelte'
import Actions from './lib/Actions.svelte' import Actions from './lib/Actions.svelte'
import Prompt from './lib/Prompt.svelte' import Prompt from './lib/Prompt.svelte'
@@ -62,7 +62,6 @@
verify: Verify, verify: Verify,
info: Info, info: Info,
trash: Trash, trash: Trash,
card: CardSheet,
media: MediaBrowser, media: MediaBrowser,
} }
let boardEl = $state<HTMLDivElement>() let boardEl = $state<HTMLDivElement>()
@@ -244,8 +243,8 @@
[[...keysOf('tree', 'down'), ...keysOf('tree', 'up')], 'browse, l opens, L opens beside'], [[...keysOf('tree', 'down'), ...keysOf('tree', 'up')], 'browse, l opens, L opens beside'],
[keysOf('global', 'space'), 'switch space, Tab cycles'], [keysOf('global', 'space'), 'switch space, Tab cycles'],
[keysOf('global', 'overview'), 'overview of every space'], [keysOf('global', 'overview'), 'overview of every space'],
[keysOf('global', 'special'), 'board, agenda and calendar'], [keysOf('global', 'plan'), 'board, agenda and calendar'],
[[...keysOf('tree', 'board'), ...keysOf('global', 'agenda'), ...keysOf('global', 'calendar')], 'straight to one of them'], [[...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('tree', 'capture'), 'capture a card to the inbox'],
[keysOf('global', 'find'), 'find in every space'], [keysOf('global', 'find'), 'find in every space'],
[keysOf('tree', 'search'), 'search this 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 <!-- 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. --> does between workspaces: from below when moving right, above when left. -->
{#key workspace.spaceId} {#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 <div
class="panes switch" class="panes switch"
class:reading={workspace.tiles.some((tile) => tile.open)} class:reading={workspace.tiles.some((tile) => !tile.empty)}
class:full={workspace.fullscreen} class:full={workspace.fullscreen}
class:focus-doc={!!workspace.open && workspace.pane === 'document'} class:focus-doc={workspace.focus !== 'list'}
style="--dir: {workspace.switchDirection}" style="--dir: {workspace.switchDirection}"
> >
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="window list" class="window list"
class:focused={!workspace.open || workspace.pane === 'list'} class:focused={workspace.focus === 'list'}
onpointerdown={() => (workspace.pane = 'list')} onpointerdown={() => (workspace.pane = 'list')}
><Sidebar /></div> ><Sidebar /></div>
<div class="tiles" class:only={workspace.tiles.length === 1}> <div class="tiles" class:only={workspace.tiles.length === 1}>
@@ -329,13 +306,23 @@
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="window entry" class="window entry"
class:view={tile.kind !== 'node'}
data-kind={tile.kind}
class:current={index === workspace.focused} 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}" style="--x: {rect.x}; --y: {rect.y}; --w: {rect.w}; --h: {rect.h}"
onpointerdown={() => tile.open && workspace.focusTile(index)} onpointerdown={() => !tile.empty && workspace.focusTile(index)}
onfocusin={() => tile.open && index !== workspace.focused && 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} /> <FilePane {tile} />
{:else if tile.open} {:else if tile.open}
<DocumentPane {tile} /> <DocumentPane {tile} />
@@ -358,7 +345,6 @@
{/each} {/each}
</div> </div>
</div> </div>
{/if}
{/key} {/key}
</main> </main>
@@ -402,48 +388,23 @@
<style> <style>
.boot { display: grid; place-items: center; height: 100%; } .boot { display: grid; place-items: center; height: 100%; }
/* .fill { height: 100%; }
* The special workspace: the board takes what the right column leaves, /* The focused view's key is lit, as its border is. */
* the agenda takes what the calendar leaves under it. .tiles > .window.focused :global(.tile-key) { background: var(--light); border-color: var(--light); color: var(--ink); }
*/
.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); }
/* One cursor is lit at a time: the other tiles keep theirs, dimmed. */ /* One cursor is lit at a time: the views without the focus keep theirs, dimmed. */
.special > .window:not(.focused) :global(:is(.card.active, .row.at, .day.at)) { .view:not(.focused) :global(:is(.card.active, .row.at, .day.at, li.at)) {
background: var(--surface-alt); background: var(--surface-alt);
color: var(--light); color: var(--light);
} }
.special > .window:not(.focused) :global(.row.at :is(.where, .due)) { color: var(--muted); } .view:not(.focused) :global(.row.at :is(.where, .due)) { color: var(--muted); }
.special > .window:not(.focused) :global(.card.active :is(.meta, .doc)) { color: var(--muted); } .view: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); } .view: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); } .view: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); } .view:not(.focused) :global(:is(.card.active, li.at) .tick) { border-color: var(--outline); }
.special > .window:not(.focused) :global(.day.at :is(.dot)) { background: var(--light); } .view:not(.focused) :global(.day.at :is(.dot)) { background: var(--light); }
.special > .window:not(.focused) :global(.day.at .many) { color: var(--light); } .view: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; }
/* 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)); } .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. */ /* 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:not(.current) { display: none; }
.tiles > .window.current { inset: calc(var(--gap) / 2); width: auto; height: auto; } .tiles > .window.current { inset: calc(var(--gap) / 2); width: auto; height: auto; }
.overlay { padding: 12px; } .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> </style>
+3 -6
View File
@@ -112,23 +112,20 @@
</section> </section>
{/if} {/if}
<Keys contexts={['agenda', 'special']} /> <Keys contexts={['agenda', 'views']} />
</div> </div>
<style> <style>
li:has(.finish) { display: flex; align-items: center; } 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); } .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 { .sheet {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
width: min(100%, 84ch); height: 100%;
max-height: 100%;
overflow-y: auto; 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; padding: 20px 24px;
} }
+16 -31
View File
@@ -35,43 +35,30 @@
<button class="pill add" title="new space (:space! <name>)" onclick={() => hooks.openCommandLine(':space! ')}> <button class="pill add" title="new space (:space! <name>)" onclick={() => hooks.openCommandLine(':space! ')}>
+ +
</button> </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> </nav>
<span class="divider path"></span> <span class="divider path"></span>
<nav class="crumbs" aria-label="path"> <nav class="crumbs" aria-label="path">
{#if workspace.view === 'special'} <button
<span class="here">special:{workspace.specialFocus}</span> class="crumb"
{:else} 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 <button
class="crumb" class="crumb"
class:here={crumbs.length === 0} class:here={index === crumbs.length - 1}
onclick={() => workspace.spaceId !== null && workspace.guard(() => workspace.goTo(workspace.spaceId!))} onclick={() => workspace.guard(() => workspace.goTo(crumb.id))}
{@attach into(workspace.spaceId, 'into ~')} {@attach into(crumb.id, `into ${crumb.slug}`)}
> >
~ {crumb.slug}
</button> </button>
{#each crumbs as crumb, index (crumb.id)} {/each}
<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}
</nav> </nav>
<span class="spacer"></span> <span class="spacer"></span>
@@ -123,7 +110,6 @@
.pill:hover .n { color: var(--light-dim); } .pill:hover .n { color: var(--light-dim); }
.pill[aria-current='true'] .n { color: var(--ink); opacity: 0.55; } .pill[aria-current='true'] .n { color: var(--ink); opacity: 0.55; }
.pill.add { color: var(--muted); } .pill.add { color: var(--muted); }
.pill.special { margin-left: 6px; }
.pill.add:hover { color: var(--light); } .pill.add:hover { color: var(--light); }
/* A crumb is a link along the path; a hairline grows under it on hover. */ /* 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 { background: transparent; color: var(--light); }
.crumb:hover::after { transform: scaleX(1); } .crumb:hover::after { transform: scaleX(1); }
.crumb.here, .here { color: var(--light); } .crumb.here { color: var(--light); }
.sep { color: var(--overlay); } .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 * On a phone the other spaces are only their number, like the desktop's
+2 -2
View File
@@ -82,7 +82,7 @@
class:done={card.done_at !== null} class:done={card.done_at !== null}
data-at="{listIndex}:{cardIndex}" data-at="{listIndex}:{cardIndex}"
onclick={() => pick(listIndex, cardIndex)} onclick={() => pick(listIndex, cardIndex)}
ondblclick={() => workspace.openCardSheet()} ondblclick={() => workspace.guard(() => workspace.turnCard())}
title="Enter turns the card over" title="Enter turns the card over"
> >
{#if card.labels.length} {#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. --> <!-- The board's keys are not the list's, so they are spelled out where they apply. -->
<div class="keys"> <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>
</div> </div>
+3 -6
View File
@@ -79,20 +79,17 @@
{/each} {/each}
</section> </section>
<Keys contexts={['calendar', 'special']} /> <Keys contexts={['calendar', 'views']} />
</div> </div>
<style> <style>
/* A tile's window is its frame, so it only fills it. */
.sheet { .sheet {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
width: min(100%, 60ch); height: 100%;
max-height: 100%;
overflow-y: auto; 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; padding: 20px 24px;
} }
@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
/* /*
* Enter on a card turns it over: a window floated over the board, as the * Enter on a card turns it over: a tile beside the board with what the
* desktop floats one, with what the card holds on its back — labels, the * card holds on its back — labels, the description, the checklist. It is
* description, the checklist. The keys live in actions.ts with the others; * the card under the board's cursor, so walking the board leafs through
* this draws and lets a pointer do the same. * 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 { followLink } from './commands'
import { renderMarkdown } from './markdown' import { renderMarkdown } from './markdown'
import { workspace } from './store.svelte' import { workspace } from './store.svelte'
@@ -18,11 +19,21 @@
const ticked = $derived(card?.checklist.filter((task) => task.done).length ?? 0) const ticked = $derived(card?.checklist.filter((task) => task.done).length ?? 0)
const html = $derived(card?.body.trim() ? renderMarkdown(card.body, { wikilinks: card.wikilinks }) : '') 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(() => { $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. */ /** A link in the description leaves the card for what it leads to, as one in a document does. */
function follow(event: MouseEvent) { function follow(event: MouseEvent) {
@@ -30,7 +41,6 @@
const href = (event.target as HTMLElement | null)?.closest('a')?.getAttribute('href') ?? '' const href = (event.target as HTMLElement | null)?.closest('a')?.getAttribute('href') ?? ''
if (!href.startsWith('/n/')) return if (!href.startsWith('/n/')) return
event.preventDefault() event.preventDefault()
workspace.closeCardSheet()
followLink(href) followLink(href)
} }
@@ -44,9 +54,16 @@
} }
</script> </script>
{#if card} <div class="back" bind:this={root}>
<Sheet title="card" detail="{workspace.boardSpace?.slug ?? ''} / {list?.name ?? ''}{role ? ` · ${role}` : ''}" width={76} cursor={workspace.taskCursor} keys={['card']}> <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"> <div class="scroll">
<h2 class:done={card.done_at !== null}>{card.title}</h2> <h2 class:done={card.done_at !== null}>{card.title}</h2>
@@ -113,12 +130,28 @@
</ul> </ul>
</section> </section>
</div> </div>
</Sheet> {:else}
{/if} <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> <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 { 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); } h2.done { text-decoration: line-through; color: var(--muted); }
@@ -170,4 +203,7 @@
li.at .tick { border-color: var(--ink); } li.at .tick { border-color: var(--ink); }
li.at.ticked .tick { background: var(--ink); } li.at.ticked .tick { background: var(--ink); }
@media (max-width: 767px) {
.back { padding: 14px; gap: 14px; }
}
</style> </style>
+15 -16
View File
@@ -31,21 +31,22 @@
const cmd: Door = { icon: 'prompt', label: 'Command line', keys: keysOf('global', 'line'), run: () => hooks.openCommandLine(':') } const 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') } const help: Door = { icon: 'help', label: 'Help', keys: keysOf('global', 'help'), run: () => runCommand('help') }
if (workspace.view === 'special') { const focus = workspace.focus
// The tiles of the special workspace are doors, so a phone — which const board: Door = workspace.space?.has_board || focus === 'board'
// shows one at a time — can walk between them. ? { icon: 'board', label: 'Board', keys: keysOf('global', 'board'), run: () => runCommand('board'), on: focus === 'board' }
const tiles: Door[] = [ : { icon: 'board', label: 'Give this space a board', keys: [':set board'], run: () => runCommand('set board'), dim: true }
{ icon: 'board', label: 'Board', keys: keysOf('special', 'board'), run: () => (workspace.specialFocus = 'board'), on: workspace.specialFocus === 'board' }, const agenda: Door = { icon: 'agenda', label: 'Agenda — late, due, in hand', keys: keysOf('global', 'agenda'), run: () => runCommand('agenda'), on: focus === 'agenda' }
{ icon: 'agenda', label: 'Agenda — late, due, in hand', keys: keysOf('global', 'agenda'), run: () => (workspace.specialFocus = 'agenda'), on: workspace.specialFocus === 'agenda' }, const calendar: Door = { icon: 'calendar', label: 'Calendar — the diary by month', keys: keysOf('global', 'calendar'), run: () => runCommand('cal'), on: focus === 'calendar' }
{ icon: 'calendar', label: 'Calendar', keys: keysOf('global', 'calendar'), run: () => (workspace.specialFocus = 'calendar'), on: workspace.specialFocus === 'calendar' }, // A phone shows the focused tile alone, so a view needs a door out of it.
{ icon: 'list', label: 'Back to the tree', keys: [...keysOf('special', 'leave'), ...keysOf('global', 'special')], run: () => workspace.closeSpecial() }, const close: Door[] =
] focus !== 'list' && focus !== 'node' ? [{ icon: 'list', label: 'Close this view', keys: keysOf('views', 'close'), run: () => runCommand('close') }] : []
if (workspace.specialFocus !== 'board') return [tiles, [cmd, help]]
if (focus === 'board' || focus === 'card') {
// The card verbs are doors too, so a phone — which has no H, L or // The card verbs are doors too, so a phone — which has no H, L or
// Space — can still move work along. // Space — can still move work along.
const card = !!workspace.card const card = !!workspace.card
return [ return [
tiles, [board, agenda, calendar, ...close],
[ [
{ icon: 'doc-plus', label: 'New card on this list', keys: keysOf('board', 'new'), run: () => hooks.openCommandLine(':card ') }, { icon: 'doc-plus', label: 'New card on this list', keys: keysOf('board', 'new'), run: () => hooks.openCommandLine(':card ') },
{ icon: 'column-plus', label: 'New list', keys: [':list'], run: () => hooks.openCommandLine(':list ') }, { icon: 'column-plus', label: 'New list', keys: [':list'], run: () => hooks.openCommandLine(':list ') },
@@ -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 [ return [
[ [
{ icon: 'doc-plus', label: 'New document', keys: keysOf('tree', 'new'), run: () => runCommand('new') }, { icon: 'doc-plus', label: 'New document', keys: keysOf('tree', 'new'), run: () => runCommand('new') },
@@ -88,10 +86,11 @@
...(workspace.space ? [board] : []), ...(workspace.space ? [board] : []),
], ],
[ [
{ icon: 'agenda', label: 'Agenda — late, due, in hand', keys: keysOf('global', 'agenda'), run: () => runCommand('agenda') }, agenda,
{ icon: 'calendar', label: 'Calendar — the diary by month', keys: keysOf('global', 'calendar'), run: () => runCommand('cal') }, calendar,
{ icon: 'today', label: "Today's diary entry", keys: keysOf('global', 'today'), run: () => runCommand('today') }, { icon: 'today', label: "Today's diary entry", keys: keysOf('global', 'today'), run: () => runCommand('today') },
{ icon: 'capture', label: 'Capture a card to the inbox', keys: keysOf('tree', 'capture'), run: () => hooks.openCommandLine(':card ') }, { 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) }, { icon: 'upload', label: 'Upload files into this folder', keys: [':upload'], run: () => hooks.pickTreeFiles(false) },
+26 -52
View File
@@ -14,7 +14,7 @@ import { focusHelpFilter, scrollHelp } from './Help.svelte'
import { copyMedia, insertMedia, removeMedia } from './MediaBrowser.svelte' import { copyMedia, insertMedia, removeMedia } from './MediaBrowser.svelte'
import { purgeTrashed, restoreTrashed } from './Trash.svelte' import { purgeTrashed, restoreTrashed } from './Trash.svelte'
import { goHeading, moveHistory, openBacklink, renewLink, restoreShown, toggleSharing, turnInfo } from './Info.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' import { dwindle, formatDay, neighbour, type Direction } from './util'
export type Run = (key: string, event: KeyboardEvent) => boolean | void 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. */ /** Focus moves between the panes, out of the editor too. */
function focusPane(pane: 'list' | 'document') { function focusPane(pane: 'list' | 'document') {
if (pane === 'document' && !workspace.open) return if (pane === 'document' && workspace.tile.empty) return
workspace.pane = pane workspace.pane = pane
if (pane === 'document' && workspace.editing) hooks.focusEditor() if (pane === 'document' && workspace.editing) hooks.focusEditor()
else hooks.focusList() 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 * 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 * 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. * goes that way instead, swapping places with the one it lands on.
*/ */
function tileKey(direction: Direction, shift: boolean) { function tileKey(direction: Direction, shift: boolean) {
if (workspace.view === 'special') { if (workspace.focus === 'list') {
const next = specialNeighbours[workspace.specialFocus][direction]
if (next && !shift) workspace.specialFocus = next
return
}
if (workspace.pane === 'list' || !workspace.open) {
if (direction === 'l' && !shift) focusPane('document') if (direction === 'l' && !shift) focusPane('document')
return return
} }
@@ -131,10 +116,9 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
}, },
sheet: { sheet: {
// A description or a note being written has had its Esc; the sheet // A note being written has had its Esc; the sheet stays up under it.
// stays up under it.
close: () => { close: () => {
if (workspace.describing || workspace.scratchEditing) return if (workspace.scratchEditing) return
workspace.closeSheet() workspace.closeSheet()
}, },
}, },
@@ -233,16 +217,16 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
restore: () => void restoreShown(), 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: { share: {
toggle: () => void toggleSharing(workspace.open), toggle: () => void toggleSharing(workspace.open),
link: () => void renewLink(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 * 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. * are the editor's.
*/ */
card: { card: {
@@ -265,12 +249,8 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
due: () => line(':due '), due: () => line(':due '),
sendLeft: () => guard(() => workspace.sendCard(-1)), sendLeft: () => guard(() => workspace.sendCard(-1)),
sendRight: () => guard(() => workspace.sendCard(1)), sendRight: () => guard(() => workspace.sendCard(1)),
notes: () => { notes: () => guard(() => workspace.openCard()),
workspace.closeSheet()
guard(() => workspace.openCard())
},
line: () => line(':'), 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') if (workspace.open && workspace.pane === 'document') focusPane('list')
else guard(() => workspace.up()) 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) => { sideways: (key) => {
const amount = ({ zh: -1, zl: 1, zH: 'halfleft', zL: 'halfright' } as const)[key as 'zh' | 'zl' | 'zH' | 'zL'] 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') 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 `. // names are typed — this only saves typing `:mkdir `.
mkdir: () => line(':mkdir '), mkdir: () => line(':mkdir '),
edit: () => void runCommand('edit'), 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. // Renaming starts from the name it has, so a typo is a fix, not a retype.
rename: () => { rename: () => {
const target = workspace.target const target = workspace.target
@@ -419,9 +393,9 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
*/ */
board: { board: {
left: () => workspace.moveList(-1), 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: () => { 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) else workspace.moveList(1)
}, },
down: () => workspace.moveCard(1), down: () => workspace.moveCard(1),
@@ -433,7 +407,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
sendLeft: () => guard(() => workspace.sendCard(-1)), sendLeft: () => guard(() => workspace.sendCard(-1)),
sendRight: () => guard(() => workspace.sendCard(1)), sendRight: () => guard(() => workspace.sendCard(1)),
done: () => guard(() => workspace.toggleDone()), 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. // The card's notes, without turning it over first: `gf` as on its back.
notes: () => guard(() => workspace.openCard()), notes: () => guard(() => workspace.openCard()),
new: () => line(':card '), new: () => line(':card '),
@@ -449,27 +423,26 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
}, },
reload: () => guard(() => workspace.reloadBoard()), reload: () => guard(() => workspace.reloadBoard()),
delete: () => void runCommand('delete'), delete: () => void runCommand('delete'),
leave: () => workspace.closeSpecial(),
}, },
/* /*
* The agenda: j/k down the list, Enter to the card, Space finishes it. * 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, * 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: { agenda: {
down: () => { down: () => {
const last = Math.max(workspace.agendaCards.length + workspace.agenda.on_this_day.length - 1, 0) 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 else workspace.agendaCursor += 1
}, },
up: () => void (workspace.agendaCursor = Math.max(workspace.agendaCursor - 1, 0)), up: () => void (workspace.agendaCursor = Math.max(workspace.agendaCursor - 1, 0)),
left: () => void (workspace.specialFocus = 'board'), left: () => tileKey('h', false),
open: () => { open: () => {
const card = workspace.agendaCards[workspace.agendaCursor] const card = workspace.agendaCards[workspace.agendaCursor]
const past = workspace.agenda.on_this_day[workspace.agendaCursor - workspace.agendaCards.length] const past = workspace.agenda.on_this_day[workspace.agendaCursor - workspace.agendaCards.length]
if (card) guard(() => workspace.goToCard(card)) 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)) else if (past) guard(() => workspace.openPath(past.path))
}, },
finish: () => { finish: () => {
@@ -483,7 +456,6 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
sendLeft: () => sendAgendaCard(-1), sendLeft: () => sendAgendaCard(-1),
sendRight: () => sendAgendaCard(1), sendRight: () => sendAgendaCard(1),
today: () => void runCommand('today'), 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. */ /** 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()) guard(() => workspace.loadCalendar())
}, },
open: () => void workspace.guard(() => workspace.openCalendarDay()).then(() => hooks.focusEditor()), open: () => void workspace.guard(() => workspace.openCalendarDay()).then(() => hooks.focusEditor()),
leave: () => workspace.closeSpecial(),
}, },
special: { /** What every view shares: `q` closes its tile, back to the one before. */
// Tab walks the tiles here, as it walks the spaces everywhere else; the views: {
// digits still switch space, and the board with it. close: () => void runCommand('close'),
cycle: (key) => workspace.cycleSpecial(shifted(key) ? -1 : 1),
board: () => void runCommand('board'),
leave: () => workspace.closeSpecial(),
}, },
global: { global: {
@@ -524,6 +492,12 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
cycle: (key) => guard(() => workspace.cycleSpace(shifted(key) ? -1 : 1)), cycle: (key) => guard(() => workspace.cycleSpace(shifted(key) ? -1 : 1)),
overview: () => void openOverview(), overview: () => void openOverview(),
fullscreen: () => void (workspace.fullscreen = !workspace.fullscreen), 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 // The agenda and today's entry are a key away from anywhere, the board
// included: they are the two questions asked most. // included: they are the two questions asked most.
agenda: () => void runCommand('agenda'), 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 // `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 // agenda and the calendar, put up and away again. `b`, `t` and `C` only
// ever go to their tile. // ever go to their tile.
special: () => void runCommand('special'), plan: () => void runCommand('plan'),
line: () => line(':'), line: () => line(':'),
help: () => void runCommand('help'), help: () => void runCommand('help'),
}, },
+40 -34
View File
@@ -95,6 +95,11 @@ export async function openInfo(section?: InfoSection) {
}) })
} }
/** The board's cursor, or the card turned over beside it, has the keys. */
const onBoard = () => workspace.focus === 'board' || workspace.focus === 'card'
/** A card has the keys: on the board, its back, or highlighted in the agenda. */
const cardFocused = () => onBoard() || workspace.focus === 'agenda'
const requireOpen = () => { const requireOpen = () => {
if (!workspace.open) { if (!workspace.open) {
workspace.say('no document open', 'error') 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. */ /** `:ls`'s line for one tile: number, `%` for the focused one, `[+]` unsaved. */
const describeTile = (index: number) => { const describeTile = (index: number) => {
const tile = workspace.tiles[index] 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 ? ' [+]' : ''}` 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.scratchEditing) return workspace.leaveScratchNote()
if (workspace.describing) return void workspace.stopDescribing() if (workspace.describing) return void workspace.stopDescribing()
// A document `o` made and nothing was written in is not kept. // A document `o` made and nothing was written in is not kept.
if (await workspace.abandonIfBlank()) { if (await workspace.abandonIfBlank()) return hooks.focusList()
if (workspace.returnTo === 'special') await workspace.guard(() => workspace.returnToSpecial())
return hooks.focusList()
}
if (workspace.dirty) { if (workspace.dirty) {
workspace.say('unsaved changes — :w to write, :q! to discard', 'error') workspace.say('unsaved changes — :w to write, :q! to discard', 'error')
return return
} }
if (workspace.editing) workspace.editing = false 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 if (workspace.tiles.length > 1) return focusTile(closeFocused())
else workspace.closeTile() else workspace.closeTile()
hooks.focusList() hooks.focusList()
@@ -472,14 +473,15 @@ export const commands: CommandSpec[] = [
{ {
name: 'board', name: 'board',
group: 'board', group: 'board',
help: "this space's board, in the special workspace", help: "this space's board, in a tile — the last one shown when this space has none",
run: () => workspace.guard(() => workspace.showSpecial('board')), run: () => workspace.guard(() => workspace.showView('board')),
}, },
{ {
name: 'special', name: 'plan',
aliases: ['special'],
group: 'board', 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', 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.toggleSpecial()), run: () => workspace.guard(() => workspace.togglePlan()),
}, },
{ {
name: 'card', name: 'card',
@@ -494,9 +496,7 @@ export const commands: CommandSpec[] = [
bang: { bang: {
help: 'add a card linked to the open document — gf on it opens the document', help: 'add a card linked to the open document — gf on it opens the document',
run: async (arg) => { run: async (arg) => {
// On the board the tree is not on screen, so only the open document const node = workspace.target
// can be meant; elsewhere it is whatever has focus.
const node = workspace.view === 'special' ? workspace.open : workspace.target
if (!node || node.kind !== 'document') { if (!node || node.kind !== 'document') {
return workspace.say('no document to link — open one first', 'error') return workspace.say('no document to link — open one first', 'error')
} }
@@ -509,11 +509,11 @@ export const commands: CommandSpec[] = [
name: 'due', name: 'due',
args: '<when|->', args: '<when|->',
group: 'board', 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'], complete: () => ['today', 'tm', 'fri', '+3d'],
run: async (arg) => { run: async (arg) => {
const agenda = workspace.view === 'special' && workspace.specialFocus === 'agenda' const agenda = workspace.focus === 'agenda'
if (workspace.view !== 'special') return workspace.say('no board open — :board', 'error') if (!cardFocused()) return workspace.say('no card in focus — :board', 'error')
const set = (due_at: number | null) => const set = (due_at: number | null) =>
agenda ? workspace.patchAgendaCard({ due_at }) : workspace.patchCard({ due_at }) agenda ? workspace.patchAgendaCard({ due_at }) : workspace.patchCard({ due_at })
const target = arg.trim() 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', 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', '-'], complete: () => ['daily', 'weekly', 'monthly', 'yearly', '+2w', '-'],
run: async (arg) => { run: async (arg) => {
const agenda = workspace.view === 'special' && workspace.specialFocus === 'agenda' const agenda = workspace.focus === 'agenda'
if (workspace.view !== 'special') return workspace.say('no board open — :board', 'error') if (!cardFocused()) return workspace.say('no card in focus — :board', 'error')
const rule = arg.trim() const rule = arg.trim()
const repeat = rule === '-' || rule === '' ? null : rule const repeat = rule === '-' || rule === '' ? null : rule
await workspace.guard(async () => { await workspace.guard(async () => {
@@ -547,7 +547,11 @@ export const commands: CommandSpec[] = [
group: 'board', group: 'board',
help: 'move the selected card into the done list, or back out to the inbox', help: 'move the selected card into the done list, or back out to the inbox',
run: () => { 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()) return workspace.guard(() => workspace.toggleDone())
}, },
}, },
@@ -579,14 +583,14 @@ export const commands: CommandSpec[] = [
group: 'board', group: 'board',
help: "add a step to the open card's checklist, below the cursor", help: "add a step to the open card's checklist, below the cursor",
run: async (arg) => { 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') if (!arg.trim()) return workspace.say('usage: :task <text>', 'error')
await workspace.guard(() => workspace.addTask(arg.trim())) await workspace.guard(() => workspace.addTask(arg.trim()))
}, },
bang: { bang: {
help: 'reword the step under the cursor', help: 'reword the step under the cursor',
run: async (arg) => { 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') if (!arg.trim()) return workspace.say('usage: :task! <text>', 'error')
await workspace.guard(() => workspace.renameTask(arg.trim())) 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', help: 'put a label on the card, or take it off when it has it — each name keeps its colour everywhere',
complete: () => workspace.boardLabels, complete: () => workspace.boardLabels,
run: async (arg) => { 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') if (!arg.trim()) return workspace.say(workspace.card?.labels.join(' · ') || 'no labels — :label <name> adds one')
await workspace.guard(() => workspace.toggleLabel(arg)) await workspace.guard(() => workspace.toggleLabel(arg))
}, },
@@ -610,14 +614,14 @@ export const commands: CommandSpec[] = [
group: 'board', group: 'board',
help: 'add a column to the board', help: 'add a column to the board',
run: async (arg) => { 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') if (!arg.trim()) return workspace.say('usage: :list <name>', 'error')
await workspace.guard(() => workspace.createList(arg.trim())) await workspace.guard(() => workspace.createList(arg.trim()))
}, },
bang: { bang: {
help: 'delete the column under the cursor and every card on it', help: 'delete the column under the cursor and every card on it',
run: async () => { 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 const list = workspace.list
if (!list) return workspace.say('no list selected', 'error') if (!list) return workspace.say('no list selected', 'error')
const cards = list.cards.length 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', 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: () => run: () =>
workspace.guard(async () => { workspace.guard(async () => {
if (workspace.view === 'special') return void (workspace.specialFocus = 'calendar') if (workspace.view('calendar')) return workspace.showView('calendar')
await workspace.openCalendar() await workspace.openCalendar()
}), }),
}, },
@@ -706,8 +710,8 @@ export const commands: CommandSpec[] = [
help: 'what is late, due this week, or in hand — across every board', help: 'what is late, due this week, or in hand — across every board',
run: () => run: () =>
workspace.guard(async () => { workspace.guard(async () => {
if (workspace.view !== 'special') workspace.agendaCursor = 0 if (!workspace.view('agenda')) workspace.agendaCursor = 0
await workspace.showSpecial('agenda') await workspace.showView('agenda')
}), }),
}, },
{ {
@@ -797,14 +801,16 @@ export const commands: CommandSpec[] = [
args: '<n|name>', args: '<n|name>',
group: 'tiles', group: 'tiles',
help: 'focus a tile by its number in :ls, or by the start of its name', 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) => { run: (arg) => {
const wanted = arg.trim().toLowerCase() const wanted = arg.trim().toLowerCase()
if (!wanted) return workspace.say(describeTile(workspace.focused)) if (!wanted) return workspace.say(describeTile(workspace.focused))
const index = /^\d+$/.test(wanted) const index = /^\d+$/.test(wanted)
? Number(wanted) - 1 ? Number(wanted) - 1
: workspace.tiles.findIndex( : workspace.tiles.findIndex((tile) =>
(tile) => tile.open && (tile.open.slug.startsWith(wanted) || tile.open.name.toLowerCase().startsWith(wanted)), 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') if (!workspace.tiles[index]) return workspace.say(`E86: no tile ${arg.trim()} — :ls lists them`, 'error')
return focusTile(index) 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', help: 'delete a node, and everything under it, into the trash (asks first) — u brings it back',
run: async (arg) => { run: async (arg) => {
// On the board the thing under the cursor is a card, not a node. // 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 const card = workspace.card
if (!card) return workspace.say('no card selected', 'error') if (!card) return workspace.say('no card selected', 'error')
if (!(await workspace.ask(`delete the card "${card.title}"?`))) return 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', help: 'rename what is under the cursor — document, folder, or card on a board; bare, while writing, into the title',
run: async (arg) => { run: async (arg) => {
if (!arg.trim() && workspace.editing && workspace.pane === 'document') return hooks.focusTitle() 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') if (!arg.trim()) return workspace.say('usage: :name <text>', 'error')
return workspace.guard(() => workspace.patchCard({ title: arg.trim() })) return workspace.guard(() => workspace.patchCard({ title: arg.trim() }))
} }
@@ -899,7 +905,7 @@ export const commands: CommandSpec[] = [
bang: { bang: {
help: 'rename the space you are in — on the board, the list under the cursor', help: 'rename the space you are in — on the board, the list under the cursor',
run: async (arg) => { run: async (arg) => {
if (workspace.view === 'special') { if (onBoard()) {
if (!arg.trim()) return workspace.say('usage: :name! <list name>', 'error') if (!arg.trim()) return workspace.say('usage: :name! <list name>', 'error')
return workspace.guard(() => workspace.renameList(arg.trim())) return workspace.guard(() => workspace.renameList(arg.trim()))
} }
+16 -22
View File
@@ -10,8 +10,8 @@
/** /**
* Where a key is heard. modes.ts stacks them, the topmost first: * Where a key is heard. modes.ts stacks them, the topmost first:
* always > a sheet > the drawer > workspace > writing > the focused tile's * always > a sheet > the drawer > workspace > writing > what has the focus
* kind (tree, board, agenda, calendar) > special > global. * (the tree, or a view: board, agenda, calendar, card) > views > global.
*/ */
export type Context = export type Context =
| 'always' | 'always'
@@ -33,7 +33,7 @@ export type Context =
| 'board' | 'board'
| 'agenda' | 'agenda'
| 'calendar' | 'calendar'
| 'special' | 'views'
| 'global' | 'global'
| 'editor' | '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-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: '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: ['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: 'global', keys: ['gt'], action: 'nextTile', label: 'gt / gT', help: 'next / previous tile — a view too' },
{ context: 'tree', keys: ['gT'], action: 'previousTile' }, { 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: '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: ['j', 'ArrowDown'], action: 'down', label: 'j / k · ↓ ↑', help: 'move down / up the list — scroll the document when it has focus' },
{ context: 'tree', keys: ['k', 'ArrowUp'], action: 'up' }, { context: 'tree', keys: ['k', 'ArrowUp'], action: 'up' },
@@ -104,16 +104,14 @@ export const bindings = [
{ context: 'tree', keys: ['p'], action: 'drop' }, { 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', 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: '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: '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: '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 a tile of its own' },
{ context: 'global', keys: ['t'], action: 'agenda', help: 'agenda — late, due this week, in hand, on every board — in the special workspace' },
{ context: 'global', keys: ['T'], action: 'today', help: "today's diary entry, started if there is none — from templates/day when the diary has one" }, { context: 'global', keys: ['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: 'previousEntry', label: '[d / ]d', help: 'the diary entry before / after the one open, by date' },
{ context: 'tree', keys: [']d'], action: 'nextEntry' }, { 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: '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: '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: 'special', keys: ['b'], action: 'board' },
{ context: 'tree', keys: ['c'], action: 'capture', help: "capture a card into this space's inbox, without leaving here" }, { context: 'tree', keys: ['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: ['q', 'Escape'], action: 'close', label: 'Esc · q', help: 'close the document, and its tile when there are several' },
{ context: 'tree', keys: ['/'], action: 'search', help: 'full-text search, inside the current space and its text files — Tab or Ctrl-N/P walk the results, Enter opens one' }, { context: 'tree', keys: ['/'], 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. // hands the focus back to it instead of walking the list unseen.
{ context: 'writing', keys: ['*'], action: 'refocus' }, { context: 'writing', keys: ['*'], action: 'refocus' },
// --- the special workspace ------------------------------------------------- // --- what the views share: the board, the agenda, the calendar, a card's back
{ context: 'special', keys: ['Escape'], action: 'leave', caps: ['esc'], hint: 'leave' }, { 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 --------------------------------------------------------------- // --- 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: ['l', 'ArrowRight'], action: 'right' },
{ context: 'board', keys: ['j', 'ArrowDown'], action: 'down', label: 'j / k', help: 'down / up the cards' }, { context: 'board', keys: ['j', 'ArrowDown'], action: 'down', label: 'j / k', help: 'down / up the cards' },
{ context: 'board', keys: ['k', 'ArrowUp'], action: 'up' }, { context: 'board', keys: ['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: ['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: ['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: ['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: ['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: ['o', 'c'], action: 'new', label: 'o · c', caps: ['o'], hint: 'new', help: 'new card on this list' },
{ context: 'board', keys: ['D'], action: 'due', hint: 'due', help: 'due date: fri, tm, +3d, 10-31 …' }, { context: 'board', keys: ['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: ['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', 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', caps: ['1…9'], hint: 'board' },
{ context: 'board', keys: ['q'], action: 'leave', label: 'q · Esc', help: 'leave the special workspace' },
// --- the back of a card --------------------------------------------------- // --- the back of a card ---------------------------------------------------
{ context: 'card', keys: ['j', 'ArrowDown'], action: 'down', label: 'j / k', caps: ['j', 'k'], hint: 'step', help: 'down / up the checklist' }, { context: 'card', keys: ['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: ['L'], action: 'sendRight' },
{ context: 'card', keys: ['gf'], action: 'notes', hint: 'notes', help: "the card's notes — a document, made on first use; q comes back" }, { context: 'card', keys: ['gf'], action: 'notes', hint: 'notes', help: "the card's notes — a document, made on first use; q comes back" },
{ context: 'card', keys: [':'], action: 'line' }, { 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 -------------------------------------------- // --- the scratchpad, while it is down --------------------------------------------
{ context: 'drawer', keys: ['j', 'ArrowDown'], action: 'down', label: 'j / k', caps: ['j', 'k'], hint: 'note', help: 'down / up the notes, the one touched last on top' }, { context: 'drawer', keys: ['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' }, { context: 'drawer', keys: ['*'], action: 'nothing' },
// --- the agenda -------------------------------------------------------------- // --- 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: ['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: ['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: ['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' }, { 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: ['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: ['L'], action: 'sendRight' },
{ context: 'agenda', keys: ['T'], action: 'today', hint: 'diary', help: "today's diary entry" }, { 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 ------------------------------------------------------------ // --- the calendar ------------------------------------------------------------
{ context: 'calendar', keys: ['h', 'ArrowLeft', 'l', 'ArrowRight'], action: 'day', caps: ['h', 'l'], hint: 'day' }, { 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: ['H', 'L'], action: 'month', hint: 'month' },
{ context: 'calendar', keys: ['.'], action: 'today', hint: 'today' }, { context: 'calendar', keys: ['.'], action: 'today', hint: 'today' },
{ context: 'calendar', keys: ['Enter'], action: 'open', hint: 'open' }, { context: 'calendar', keys: ['Enter'], action: 'open', hint: 'open' },
{ context: 'calendar', keys: ['q'], action: 'leave' },
// --- the sheets over everything -------------------------------------------------- // --- the sheets over everything --------------------------------------------------
{ context: 'sheet', keys: ['Escape'], action: 'close', typing: true, caps: ['q', 'esc'], hint: 'close' }, { 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. */ /** 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 }[] = [ 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: '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: 'card', title: 'card keys', contexts: ['card'] },
{ section: 'scratch', title: 'scratchpad keys', contexts: ['drawer'] }, { section: 'scratch', title: 'scratchpad keys', contexts: ['drawer'] },
+13 -9
View File
@@ -4,12 +4,13 @@
* a context takes it. * a context takes it.
* *
* always Ctrl-S, Alt-h/j/k/l — heard in the editor too * 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 * drawer the scratchpad, while it is down
* workspace Esc out of fullscreen, Q * workspace Esc out of fullscreen, Q
* writing a document being written takes every plain key back * writing a document being written takes every plain key back
* tile the focused kind: tree, board, agenda or calendar * tile what has the focus: the tree, or a view — board, agenda,
* special what the three tiles of the special workspace share * calendar, a card's back
* views what the views share: q, gt, b
* global spaces, t, T, C, S, :, ? * global spaces, t, T, C, S, :, ?
* *
* The `[y/N]` prompt sits over all of it, in App.svelte, and the command * 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' import { workspace } from './store.svelte'
/** The sheets with keys of their own, under the same name as the sheet. */ /** 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. */ /** The stack, the topmost first. */
export function stack(): Context[] { export function stack(): Context[] {
@@ -29,16 +30,19 @@ export function stack(): Context[] {
if (overlay !== 'none') { if (overlay !== 'none') {
// The info sheet hears the keys of the part on show, then its own Tab. // The info sheet hears the keys of the part on show, then its own Tab.
if (overlay === 'info') modes.push(workspace.infoSection, 'info') 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)) modes.push(overlay as Context)
else if (sheets.has(overlay) && !(overlay === 'card' && workspace.describing)) modes.push(overlay as Context)
return [...modes, 'sheet'] return [...modes, 'sheet']
} }
if (workspace.scratchOpen && !workspace.scratchEditing) modes.push('drawer') if (workspace.scratchOpen && !workspace.scratchEditing) modes.push('drawer')
modes.push('workspace') modes.push('workspace')
if (workspace.view === 'special') modes.push(workspace.specialFocus, 'special') const focus = workspace.focus
else { if (focus === 'list' || focus === 'node') {
if (workspace.editing && workspace.pane === 'document') modes.push('writing') if (workspace.editing && focus === 'node') modes.push('writing')
modes.push('tree') 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') modes.push('global')
return modes return modes
+225 -170
View File
@@ -26,22 +26,23 @@ import { formatBytes, formatDay, formatStamp, formatWeekday, isTouchDevice, pars
export type Pane = 'list' | 'document' export type Pane = 'list' | 'document'
/** /**
* The tree of documents, or the special workspace: the board of the space * What a tile shows: a node of the tree — a document or a file — or one of
* being browsed, the agenda and the calendar, tiled side by side and a key * the views over the boards and the diary, which are tiled beside the
* away from anywhere, like the desktop's scratchpad. * documents like any other window.
*/ */
export type View = 'tree' | 'special' export type TileKind = 'node' | 'board' | 'agenda' | 'calendar' | 'card'
/** The tile of the special workspace that has focus. */ export type View = Exclude<TileKind, 'node'>
export type SpecialTile = 'board' | 'agenda' | 'calendar'
/** What floats over the tiles; one at a time. */ /** 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. */ /** The info sheet's parts: what the open document holds, and what is around it. */
export type InfoSection = 'outline' | 'history' | 'backlinks' | 'share' export type InfoSection = 'outline' | 'history' | 'backlinks' | 'share'
export type Flash = { text: string; kind: 'info' | 'error' } | null export type Flash = { text: string; kind: 'info' | 'error' } | null
/** /**
* Where a space was left: the folder, the open documents, the row, the pane. * Where a space was left: the folder, the tiles, the row, the pane. A tile
* `openId` is the focused tile's, all a spot held before there were tiles. * 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 = { type Spot = {
parentId: number parentId: number
@@ -49,6 +50,7 @@ type Spot = {
cursor: number cursor: number
pane: Pane pane: Pane
open?: number[] open?: number[]
tiles?: (number | View)[]
focused?: number focused?: number
} }
@@ -63,12 +65,15 @@ function doneSaid(moved: Moved): string {
let tiles = 0 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 * 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 { export class Tile {
readonly id = ++tiles readonly id = ++tiles
kind = $state<TileKind>('node')
open = $state<WorkspaceNode | null>(null) open = $state<WorkspaceNode | null>(null)
draft = $state<Draft>({ name: '', body: '', created_at: 0 }) draft = $state<Draft>({ name: '', body: '', created_at: 0 })
editing = $state(false) editing = $state(false)
@@ -77,12 +82,6 @@ export class Tile {
dirty = $state(false) dirty = $state(false)
/** The editor's vim mode, for the status line while this tile has focus. */ /** The editor's vim mode, for the status line while this tile has focus. */
vimMode = $state('normal') 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 * 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. * 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 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() { clear() {
this.stopAutosave() this.stopAutosave()
this.kind = 'node'
this.open = null this.open = null
this.editing = false this.editing = false
this.dirty = false this.dirty = false
@@ -173,12 +182,6 @@ class Workspace {
set dirty(dirty: boolean) { set dirty(dirty: boolean) {
this.tile.dirty = dirty this.tile.dirty = dirty
} }
get returnTo() {
return this.tile.returnTo
}
set returnTo(to: 'special' | null) {
this.tile.returnTo = to
}
get vimMode() { get vimMode() {
return this.tile.vimMode return this.tile.vimMode
} }
@@ -191,6 +194,23 @@ class Workspace {
return this.tiles.filter((tile) => tile.open?.id === id) 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 * Focus a tile, and follow it in the list and the address bar, as opening
* its document did. * its document did.
@@ -198,34 +218,48 @@ class Workspace {
focusTile(index: number) { focusTile(index: number) {
const tile = this.tiles[index] const tile = this.tiles[index]
if (!tile) return 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 this.focused = index
if (!tile.open) return if (tile.empty) return
this.pane = 'document' this.pane = 'document'
if (!tile.open) return
const row = this.nodes.findIndex((n) => n.id === tile.open?.id) const row = this.nodes.findIndex((n) => n.id === tile.open?.id)
if (row >= 0) this.cursor = row if (row >= 0) this.cursor = row
this.#syncUrl(tile.open.path) 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 * 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() { prune() {
const current = this.tile const current = this.tile
if (this.tiles.length > 1) { 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] this.tiles = kept.length > 0 ? kept : [current]
const at = this.tiles.indexOf(current) const back = this.tiles.includes(current) ? current : [...this.#trail].reverse().find((tile) => this.tiles.includes(tile))
this.focused = at >= 0 ? at : Math.min(this.focused, this.tiles.length - 1) 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() { closeTile() {
if (this.tile.kind === 'card' && this.describing && !this.stopDescribing()) return
this.tile.clear() this.tile.clear()
this.prune() 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') 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) const shown = this.tiles.findIndex((tile) => tile.open?.id === selected.id)
if (shown >= 0) return this.focusTile(shown) if (shown >= 0) return this.focusTile(shown)
if (this.open) { if (!this.tile.empty) this.#addTile()
this.tiles.push(new Tile())
this.focused = this.tiles.length - 1
}
try { try {
await this.openSelected() await this.openSelected()
} finally { } finally {
@@ -261,11 +292,10 @@ class Workspace {
for (const tile of others) tile.stopAutosave() for (const tile of others) tile.stopAutosave()
this.tiles = [this.tile] this.tiles = [this.tile]
this.focused = 0 this.focused = 0
this.#trail = []
} }
/** The board of the current space, loaded only while it is being looked at. */ /** The board as the server sent it, loaded only while it is being looked at. */
view = $state<View>('tree')
/** The board as the server sent it. */
rawBoard = $state<Board | null>(null) rawBoard = $state<Board | null>(null)
/* /*
* The board as it is walked and drawn: the done list keeps only what was * 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') overlay = $state<'none' | Sheet>('none')
/** The part of the info sheet on show; it stays for the next time it opens. */ /** The part of the info sheet on show; it stays for the next time it opens. */
infoSection = $state<InfoSection>('outline') 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 * 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. * diary does not leave the board's tile empty.
*/ */
@@ -794,33 +822,49 @@ class Workspace {
if (start) await this.enterSpace(start.id) 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 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] = { this.#spots[this.spaceId] = {
parentId: this.parentId, parentId: this.parentId,
openId: this.open?.id ?? null, openId: this.open?.id ?? null,
cursor: this.cursor, cursor: this.cursor,
pane: this.pane, pane: this.pane,
open, tiles: kept.map((tile) => (tile.kind === 'node' ? tile.open!.id : tile.kind)),
focused: Math.max(open.indexOf(this.open?.id ?? -1), 0), focused: Math.max(kept.indexOf(this.tile), 0),
} }
localStorage.setItem('diary:spots', JSON.stringify(this.#spots)) localStorage.setItem('diary:spots', JSON.stringify(this.#spots))
} }
async enterSpace(id: number) { async enterSpace(id: number) {
const leaving = this.spaceId !== null && this.spaceId !== id 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) { if (leaving) {
await this.flush() 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 from = this.spaces.findIndex((s) => s.id === this.spaceId)
const to = this.spaces.findIndex((s) => s.id === id) const to = this.spaces.findIndex((s) => s.id === id)
this.switchDirection = to >= from ? 1 : -1 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. // Each space has tiles of its own; these come back with this one.
for (const tile of this.tiles) tile.stopAutosave() for (const tile of this.tiles) tile.stopAutosave()
this.tiles = [new Tile()] this.tiles = carried.length > 0 ? carried : [new Tile()]
this.focused = 0 this.focused = carriedFocus ? this.tiles.indexOf(carriedFocus) : 0
this.pane = 'list' this.#trail = []
this.pane = carried.length > 0 ? 'document' : 'list'
} }
this.spaceId = id this.spaceId = id
localStorage.setItem('diary:space', String(id)) localStorage.setItem('diary:space', String(id))
@@ -836,44 +880,49 @@ class Workspace {
await this.goTo(id) await this.goTo(id)
} }
if (spot && this.parentId === spot.parentId) { if (spot && this.parentId === spot.parentId) {
const ids = spot.open ?? (spot.openId !== null && this.nodes.some((n) => n.id === spot.openId) ? [spot.openId] : []) const entries =
let opened = 0 spot.tiles ?? spot.open ?? (spot.openId !== null && this.nodes.some((n) => n.id === spot.openId) ? [spot.openId] : [])
for (const open of ids) { const restored: Tile[] = []
if (opened > 0) { 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.tiles.push(new Tile())
this.focused = this.tiles.length - 1 this.focused = this.tiles.length - 1
} }
try { try {
// Reopened under the board, if that is what is on screen. await this.openNode(entry)
await this.openNode(open, false, true) restored.push(this.tile)
opened++
} catch { } catch {
/* deleted or moved away since: one tile fewer */ /* deleted or moved away since: one tile fewer */
} }
} }
this.prune() this.prune()
if (opened > 0) { const back = carriedFocus ?? restored[Math.min(spot.focused ?? 0, restored.length - 1)]
this.focusTile(Math.min(spot.focused ?? 0, this.tiles.length - 1)) if (back && this.tiles.includes(back)) {
this.pane = spot.pane this.focusTile(this.tiles.indexOf(back))
if (!carriedFocus) this.pane = spot.pane
} else { } else {
this.cursor = Math.min(spot.cursor, Math.max(this.nodes.length - 1, 0)) 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 this.#trail = []
// board; a space without one leaves the last board where it is. await this.#loadViews()
if (this.view === 'special' && this.space?.has_board) {
this.listCursor = 0
this.cardCursor = 0
await this.reloadBoard(id, undefined)
}
} }
/** Show the children of `id`, which must be a space or a folder. */ /** Show the children of `id`, which must be a space or a folder. */
async goTo(id: number, keep?: number) { async goTo(id: number, keep?: number) {
await this.flush() await this.flush()
const folder = await api.getNode(id) const folder = await api.getNode(id)
// Walking into another space by a link leaves this one where it was. // Walking into another space by a link leaves this one where it was; the
if (this.spaceId !== null && folder.path[0]?.id !== this.spaceId) this.#rememberSpot() // 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.parentId = id
this.parentSealed = folder.sealed this.parentSealed = folder.sealed
this.query = '' this.query = ''
@@ -905,8 +954,6 @@ class Workspace {
} }
async search(query: string, global = this.globalSearch) { 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.query = query
this.globalSearch = global && query !== '' this.globalSearch = global && query !== ''
this.cursor = 0 this.cursor = 0
@@ -936,19 +983,10 @@ class Workspace {
this.cursor = Math.min(Math.max(this.cursor + delta, 0), this.nodes.length - 1) 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() if (this.open?.id !== id) await this.flushTile()
const node = await api.getNode(id) 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)) { if (isContainer(node)) {
await this.goTo(node.id) await this.goTo(node.id)
return return
@@ -961,6 +999,10 @@ class Workspace {
if (edit && node.kind === 'document') this.editing = true if (edit && node.kind === 'document') this.editing = true
return 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.open = node
this.draft = { name: node.name, body: node.body, created_at: node.created_at } 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}`) this.say(`restored the space ${name}`)
} else { } else {
const parent = node.path.at(-2) 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) if (parent.id === this.parentId && !this.query) await this.refresh(node.id)
else if (this.overlay !== 'trash') await this.goTo(parent.id, node.id) else if (this.overlay !== 'trash') await this.goTo(parent.id, node.id)
} }
@@ -1256,7 +1298,6 @@ class Workspace {
this.prune() this.prune()
this.rawBoard = null this.rawBoard = null
this.boardSpaceId = null this.boardSpaceId = null
this.view = 'tree'
this.spaces = await api.listSpaces() this.spaces = await api.listSpaces()
const next = this.spaces.find((space) => space.role === 'diary') ?? this.spaces[0] const next = this.spaces.find((space) => space.role === 'diary') ?? this.spaces[0]
if (next) await this.enterSpace(next.id) if (next) await this.enterSpace(next.id)
@@ -1356,7 +1397,7 @@ class Workspace {
if (late + due === 0) return if (late + due === 0) return
localStorage.setItem('diary:agenda-seen', today) localStorage.setItem('diary:agenda-seen', today)
this.agendaCursor = 0 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. */ /** `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 open = this.open
const reading = open && this.spaces.find((space) => space.id === open.path[0]?.id)?.role === 'diary' 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)) this.calendarDay = formatDay(reading ? open.created_at : Math.floor(Date.now() / 1000))
await this.openSpecial('calendar') await this.showView('calendar')
} }
async loadCalendar() { async loadCalendar() {
@@ -1385,7 +1426,7 @@ class Workspace {
async openCalendarDay() { async openCalendarDay() {
const day = this.calendarDay const day = this.calendarDay
const entry = this.calendarEntries.find((e) => e.day === day) 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) if (entry) return this.openPath(entry.path)
await this.openDay(day) await this.openDay(day)
} }
@@ -1423,10 +1464,10 @@ class Workspace {
/** Enter in the agenda: to the card, on its own board, beside the agenda. */ /** Enter in the agenda: to the card, on its own board, beside the agenda. */
async goToCard(card: AgendaCard) { 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) if (this.spaceId !== card.space_id) await this.enterSpace(card.space_id)
await this.reloadBoard(card.space_id, card.id) await this.reloadBoard(card.space_id, card.id)
this.view = 'special' await this.showView('board')
this.specialFocus = 'board'
} }
/** Space in the agenda: into that board's done list, from wherever it is. */ /** Space in the agenda: into that board's done list, from wherever it is. */
@@ -1435,7 +1476,7 @@ class Workspace {
if (!done) return if (!done) return
const moved = await api.moveCard(card.id, done.id, done.cards.length) const moved = await api.moveCard(card.id, done.id, done.cards.length)
await this.loadAgenda() 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)) this.say(doneSaid(moved))
} }
@@ -1448,7 +1489,7 @@ class Workspace {
// It may have changed group; the cursor goes with it. // It may have changed group; the cursor goes with it.
const at = this.agendaCards.findIndex((c) => c.id === card.id) const at = this.agendaCards.findIndex((c) => c.id === card.id)
if (at >= 0) this.agendaCursor = at 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 return updated
} }
@@ -1465,7 +1506,7 @@ class Workspace {
await this.loadAgenda() await this.loadAgenda()
const at = this.agendaCards.findIndex((c) => c.id === card.id) const at = this.agendaCards.findIndex((c) => c.id === card.id)
if (at >= 0) this.agendaCursor = at 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}`) 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. * where a card is would be worse than a round trip.
*/ */
/** /*
* The special workspace, with `focus` the tile the keys go to. Everything * The views — the board, the agenda, the calendar, a card's back — are
* in it is read again on the way in: it is looked at in passing, and what * tiles, opened beside what is on screen. Each is read again whenever it is
* it says should be what is true now. * 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 space = this.space
const shown = this.boardSpace?.has_board ? this.boardSpace : this.spaces.find((s) => s.has_board) const shown = this.boardSpace?.has_board ? this.boardSpace : this.spaces.find((s) => s.has_board)
const board = space?.has_board ? space : shown const board = space?.has_board ? space : shown
if (board) await this.reloadBoard(board.id, board.id === this.boardSpaceId ? this.card?.id : undefined) if (!board) {
else {
this.rawBoard = null this.rawBoard = null
this.boardSpaceId = null this.boardSpaceId = null
return
} }
if (!this.calendarDay) this.calendarDay = formatDay(Math.floor(Date.now() / 1000)) // Another space's board starts at its top left, as a space switch did.
await Promise.all([this.loadAgenda(), this.loadCalendar()]) if (board.id !== this.boardSpaceId) {
this.view = 'special' this.listCursor = 0
this.specialFocus = focus this.cardCursor = 0
if (focus === 'board' && !board) this.say('no space has a board — :set board turns one on', 'error') }
} await this.reloadBoard(board.id, board.id === this.boardSpaceId ? this.card?.id : undefined)
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')
} }
get boardSpace(): NodeSummary | undefined { get boardSpace(): NodeSummary | undefined {
@@ -1677,7 +1747,7 @@ class Workspace {
* it goes on the list under the cursor instead. * it goes on the list under the cursor instead.
*/ */
async createCard(title: string, nodeId: number | null = null) { async createCard(title: string, nodeId: number | null = null) {
if (this.view === 'special') { if (this.focus === 'board' || this.focus === 'card') {
const list = this.list const list = this.list
if (!list) return this.say('no list to put it on', 'error') if (!list) return this.say('no list to put it on', 'error')
const card = await api.createCard(list.id, { title, node_id: nodeId }) 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. * 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') if (!this.card) return this.say('no card selected', 'error')
this.taskCursor = 0 this.taskCursor = 0
this.describing = false await this.showView('card')
this.openSheet('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 { get scratchNote(): ScratchNote | undefined {
@@ -1864,25 +1946,17 @@ class Workspace {
await this.dropScratchNote(false) await this.dropScratchNote(false)
this.scratchEditing = false this.scratchEditing = false
this.scratchOpen = false this.scratchOpen = false
if (this.view === 'special') this.closeSpecial()
await this.refresh(node.id) await this.refresh(node.id)
await this.openNode(node.id) await this.openNode(node.id)
this.say(`kept as ${node.path.map((crumb) => crumb.slug).join('/')}`) 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) { openSheet(sheet: Sheet) {
this.overlay = 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() { closeSheet() {
if (this.overlay === 'card') return this.closeCardSheet()
this.overlay = 'none' this.overlay = 'none'
} }
@@ -2065,31 +2139,12 @@ class Workspace {
await api.updateCard(card.id, { node_id: node.id }) await api.updateCard(card.id, { node_id: node.id })
nodeId = node.id nodeId = node.id
} }
this.view = 'tree'
if (this.parentId !== space) await this.goTo(space) if (this.parentId !== space) await this.goTo(space)
else await this.refresh() else await this.refresh()
// The notes open beside whatever was being read, and `q` takes their // The notes open beside the board, and `q` takes their tile away again
// tile away again on the way back to the board. // on the way back to it.
if (this.open && this.open.id !== nodeId) {
this.tiles.push(new Tile())
this.focused = this.tiles.length - 1
}
await this.openNode(nodeId, card.node_id === null) await this.openNode(nodeId, card.node_id === null)
this.prune()
if (card.node_id === null) this.enterInsert = true 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. */ /** Turn a space's board on or off. The diary ships with it off. */
@@ -2098,11 +2153,11 @@ class Workspace {
if (!space) return if (!space) return
await api.updateNode(space.id, { has_board: on }) await api.updateNode(space.id, { has_board: on })
this.spaces = await api.listSpaces() this.spaces = await api.listSpaces()
if (on) await this.openBoard() if (on) await this.showView('board')
else if (this.boardSpaceId === space.id) { else if (this.boardSpaceId === space.id) {
this.rawBoard = null this.rawBoard = null
this.boardSpaceId = 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`) this.say(on ? `${space.slug} has a board` : `${space.slug} has no board any more`)
} }