diff --git a/README.md b/README.md index 637d30c..ef14666 100644 --- a/README.md +++ b/README.md @@ -72,8 +72,12 @@ it; in the document pane it is that document. In the browse pane: | `Shift-1` … `9` | send the marked nodes, or the selected one, to that space | | `Tab`, `Shift-Tab` | next / previous space | | `w` | overview of every space; `hjkl` then `Enter` | -| `f` | fullscreen the focused pane | -| `Alt-h` / `Alt-l` | focus the list / the document, from the editor too | +| `f` | fullscreen the focused pane, or the focused tile | +| `Alt-h/j/k/l` | focus the tile in that direction, from the editor too; `Alt-h` past the leftmost tile focuses the list | +| `Alt-Shift-h/j/k/l` | move the focused tile that way | +| `L`, `Shift-Enter` | open the highlighted row in a new tile instead of the focused one | +| `gt` / `gT` | next / previous tile, in the editor too | +| `Alt-q` | close the focused tile (`:close`); asks first if its draft could not be written | | `j` / `k` | move down / up, or scroll the document when it has focus | | `gg` / `G` | first / last entry, or top / bottom of the document; a first `g`, `d`, `]` or `[` shows in the status line for a second while it waits for the second key | | `Ctrl-D` / `Ctrl-U` | half a page down / up the open document | @@ -92,7 +96,7 @@ it; in the document pane it is that document. In the browse pane: | `t` | the agenda: what is late, due this week, or in hand, on every board | | `T` | today's diary entry, started if there is none | | `c` | capture a card into this space's inbox without leaving where you are | -| `Esc`, `q` | close the document | +| `Esc`, `q` | close the document, and its tile when there are several | | `h` | up one level in the tree | | `/` | full-text search in this space (`:noh` clears it); `Tab` or `Ctrl-N`/`Ctrl-P` walk the results without leaving the line, `Enter` opens the one lit | | `Ctrl-P` | full-text search across every space; `Enter` goes to the match | @@ -112,7 +116,7 @@ there goes back down into the text. In the text, `]]`, `[[` and `gO` walk the headings as they do in the reader. `gf` follows the link under the cursor, saving first, and `gx` opens it in a new tab. The rest: `:today` opens today's entry, `:edit [id]` (or `:12`) opens a node by id, `:next`, `:prev`, `:first`, `:last` and `:random` walk the -list, `:delete [id]` deletes, `:copy` puts the whole document on the clipboard, +list (`:bn` and `:bp` walk the tiles), `:delete [id]` deletes, `:copy` puts the whole document on the clipboard, `:clear` drops the search, `:reload` re-reads the folder, `:stats` counts words and lines, `:search!` searches every space, `:set clipboard` / `:set noclipboard` mirrors vim yanks to the system clipboard, `:logout` ends the @@ -136,6 +140,24 @@ has today's entry, an upload into the folder, a search across every space, and on the board the card moves, renames and deletes; the agenda has a done button per card. +## Tiles + +Several documents can be open at once. `L` (or `Shift-Enter`) on a row opens +it in a tile of its own; `Enter` and `l` still open it in the focused tile. +Right of the list the tiles share the space the way hyprland's dwindle layout +does: two side by side, a third splits the newer one, and so on. Each keeps +its own scroll position and its own editor, and the keys always act on the +focused one. Opening a document that is already in a tile focuses that tile +rather than opening it twice. + +`Alt-h/j/k/l` moves the focus between tiles and `Alt-Shift-h/j/k/l` moves the +tile itself; `gt` / `gT` and `:bn` / `:bp` cycle through them, `:ls` lists +them, `:b ` focuses one, `:close` (`Alt-q`) closes one and `:only` +every other. `:next` and `:prev` still walk the list. Each space remembers its +tiles, so switching away and back finds them as they were. A card's notes +open in a tile beside what was being read, and `q` there goes back to the +board. Under 768px wide only the focused tile is shown. + ## The SFTP mount The workspace can be mounted as a filesystem over SFTP, so an editor or a file diff --git a/web/src/App.svelte b/web/src/App.svelte index 7e7ab84..bc7d2ff 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1,5 +1,5 @@ diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index 6d709e1..88bde39 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -7,8 +7,12 @@ ['Shift 1 … 9', 'send the marked nodes, or the selected one, to that space'], ['Tab · Shift-Tab', 'next / previous space'], ['w', 'overview of every space — hjkl, then Enter'], - ['f', 'fullscreen the focused pane; again or Esc to leave'], - ['Alt-h / Alt-l', 'focus the list / the document, from the editor too'], + ['f', 'fullscreen the focused pane or tile; again or Esc to leave'], + ['Alt-h/j/k/l', 'focus the tile that way, from the editor too — left of the leftmost is the list'], + ['Alt-Shift-h/j/k/l', 'move the focused tile that way'], + ['L · Shift-Enter', 'open the row in a new tile beside the others'], + ['gt / gT', 'next / previous tile — in the editor too'], + ['Alt-q', 'close the focused tile — :close; :only closes the others'], ['j / k · ↓ ↑', 'move down / up the list — scroll the document when it has focus'], ['gg / G', 'first / last row, or top / bottom of the document — a first g, d, ] or [ waits a second in the status line'], ['Ctrl-D / Ctrl-U', 'half a page down / up the open document'], @@ -31,7 +35,7 @@ ['Ctrl-P', 'find anything, in every space — Enter goes to the result lit'], ['b', "open this space's board"], ['c', "capture a card into this space's inbox, without leaving here"], - ['Esc · q', 'close the document'], + ['Esc · q', 'close the document, and its tile when there are several'], ['/', 'full-text search, inside the current space — Tab or Ctrl-N/P walk the results, Enter opens one'], ['n / N', 'next / previous search result, opened beside the results — :noh clears the search'], ['s', 'toggle sharing for the document — asks before taking it back'], @@ -74,6 +78,7 @@ tree: 'spaces & folders', board: 'boards', documents: 'documents', + tiles: 'tiles', editing: 'writing', sharing: 'sharing & export', vault: 'vault', diff --git a/web/src/lib/Sidebar.svelte b/web/src/lib/Sidebar.svelte index 0a3bf34..45db900 100644 --- a/web/src/lib/Sidebar.svelte +++ b/web/src/lib/Sidebar.svelte @@ -121,7 +121,7 @@ actions(index) }} class:active={index === workspace.cursor} - class:open={workspace.open?.id === node.id} + class:open={workspace.tiles.some((tile) => tile.open?.id === node.id)} class:marked={workspace.isMarked(node.id)} data-index={index} onclick={(event) => click(event, index, node)} diff --git a/web/src/lib/StatusBar.svelte b/web/src/lib/StatusBar.svelte index 4b10c47..935f297 100644 --- a/web/src/lib/StatusBar.svelte +++ b/web/src/lib/StatusBar.svelte @@ -32,6 +32,7 @@ {workspace.flash?.text ?? ''} {#if pending}{pending}{/if} + {#if workspace.tiles.length > 1}tile {workspace.focused + 1}/{workspace.tiles.length}{/if} {#if workspace.fullscreen}full{/if} {#if workspace.vault?.unlocked}vault open{/if} {#if lines !== null}{lines}L{/if} diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index 03fb0aa..4c65203 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -1,3 +1,4 @@ +import { tick } from 'svelte' import { api, ApiError, isContainer, type BackupLogin, type BackupStatus } from './api' import { workspace } from './store.svelte' import { formatDay, parseDay, parseDue, relative, sealedNote, type Heading, type ScrollAmount } from './util' @@ -41,7 +42,7 @@ export const hooks = { } /** Help groups the command list under these headings, in this order. */ -export const groups = ['tree', 'board', 'documents', 'editing', 'sharing', 'vault', 'view'] as const +export const groups = ['tree', 'board', 'documents', 'tiles', 'editing', 'sharing', 'vault', 'view'] as const export type Group = (typeof groups)[number] export interface CommandSpec { @@ -57,6 +58,12 @@ export interface CommandSpec { bang?: { help: string; run: (arg: string) => void | Promise } } +/** Close the focused tile; the index of the one that has the focus after. */ +const closeFocused = () => { + workspace.closeTile() + return workspace.focused +} + const requireOpen = () => { if (!workspace.open) { workspace.say('no document open', 'error') @@ -79,6 +86,33 @@ export function followLink(href: string, tab = false) { window.open(new URL(href, location.href).href, '_blank', 'noopener,noreferrer') } +/** + * Focus a tile and hand it the keys: its editor while it is being written, + * otherwise the shell, whose keys then act on that tile. + */ +export async function focusTile(index: number) { + if (!workspace.tiles[index]) return + workspace.focusTile(index) + // The tile's editor fills the hooks once it has seen it has the focus. + await tick() + if (workspace.editing && workspace.pane === 'document') hooks.focusEditor() + else hooks.focusList() +} + +/** `gt` / `gT`, `:bn` / `:bp`: the next or previous tile, round and round. */ +export function cycleTile(delta: 1 | -1) { + const count = workspace.tiles.length + if (count < 2) return workspace.say('only one tile — L on a row opens another', 'error') + return focusTile((workspace.focused + delta + count) % count) +} + +/** `:ls`'s line for one tile: number, `%` for the focused one, `[+]` unsaved. */ +const describeTile = (index: number) => { + const tile = workspace.tiles[index] + const name = tile.open ? tile.open.name.trim() || tile.open.slug : '(empty)' + return `${index + 1}${index === workspace.focused ? '%' : ''} ${name}${tile.dirty ? ' [+]' : ''}` +} + /** Move the cursor and follow it, so `:next` reads as well as `j` then `Enter`. */ const jumpTo = async (index: number) => { if (workspace.nodes.length === 0) return workspace.say('nothing here', 'error') @@ -272,10 +306,8 @@ export const commands: CommandSpec[] = [ } if (workspace.editing) workspace.editing = false else if (workspace.returnTo === 'board') return void workspace.guard(() => workspace.returnToBoard()) - else { - workspace.open = null - workspace.pane = 'list' - } + else if (workspace.tiles.length > 1) return focusTile(closeFocused()) + else workspace.closeTile() hooks.focusList() }, bang: { @@ -361,7 +393,6 @@ export const commands: CommandSpec[] = [ }, { name: 'board', - aliases: ['b'], group: 'board', help: "show this space's board, or go back to the tree", run: () => workspace.guard(() => workspace.toggleBoardView()), @@ -563,18 +594,78 @@ export const commands: CommandSpec[] = [ }, { name: 'next', - aliases: ['bn'], group: 'documents', help: 'open the next node down the list', run: () => jumpTo(workspace.cursor + 1), }, { name: 'prev', - aliases: ['bp'], group: 'documents', help: 'open the previous node', run: () => jumpTo(workspace.cursor - 1), }, + { + name: 'close', + aliases: ['clo'], + group: 'tiles', + help: 'close the focused tile — Alt-q; asks when its draft could not be written', + run: () => + workspace.guard(async () => { + await workspace.flushTile() + if (workspace.dirty) { + const name = workspace.open?.slug ?? 'the draft' + if (!(await workspace.ask(`${name} could not be written — close anyway? the draft stays in this browser`))) return + } + await focusTile(closeFocused()) + }), + }, + { + name: 'only', + aliases: ['on'], + group: 'tiles', + help: 'close every tile but the focused one', + run: () => workspace.guard(() => workspace.onlyTile()), + }, + { + name: 'ls', + aliases: ['buffers', 'files'], + group: 'tiles', + help: 'list the open tiles, % on the focused one', + run: () => workspace.say(workspace.tiles.map((_, index) => describeTile(index)).join(' · ')), + }, + { + name: 'buffer', + aliases: ['b'], + args: '', + group: 'tiles', + help: 'focus a tile by its number in :ls, or by the start of its name', + complete: () => workspace.tiles.flatMap((tile) => (tile.open ? [tile.open.slug] : [])), + run: (arg) => { + const wanted = arg.trim().toLowerCase() + if (!wanted) return workspace.say(describeTile(workspace.focused)) + const index = /^\d+$/.test(wanted) + ? Number(wanted) - 1 + : workspace.tiles.findIndex( + (tile) => tile.open && (tile.open.slug.startsWith(wanted) || tile.open.name.toLowerCase().startsWith(wanted)), + ) + if (!workspace.tiles[index]) return workspace.say(`E86: no tile ${arg.trim()} — :ls lists them`, 'error') + return focusTile(index) + }, + }, + { + name: 'bnext', + aliases: ['bn'], + group: 'tiles', + help: 'the next tile — gt', + run: () => cycleTile(1), + }, + { + name: 'bprevious', + aliases: ['bp', 'bN'], + group: 'tiles', + help: 'the previous tile — gT', + run: () => cycleTile(-1), + }, { name: 'first', group: 'documents', diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index 3848e06..796641f 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -21,8 +21,18 @@ export type Pane = 'list' | 'document' export type View = 'tree' | 'board' export type Flash = { text: string; kind: 'info' | 'error' } | null -/** Where a space was left: the folder, the open document, the row, the pane. */ -type Spot = { parentId: number; openId: number | null; cursor: number; pane: Pane } +/** + * Where a space was left: the folder, the open documents, the row, the pane. + * `openId` is the focused tile's, all a spot held before there were tiles. + */ +type Spot = { + parentId: number + openId: number | null + cursor: number + pane: Pane + open?: number[] + focused?: number +} export type Draft = { name: string; body: string; created_at: number } @@ -157,6 +167,78 @@ class Workspace { return this.tiles.filter((tile) => tile.open?.id === id) } + /** + * Focus a tile, and follow it in the list and the address bar, as opening + * its document did. + */ + focusTile(index: number) { + const tile = this.tiles[index] + if (!tile) return + this.focused = index + if (!tile.open) return + this.pane = 'document' + const row = this.nodes.findIndex((n) => n.id === tile.open?.id) + if (row >= 0) this.cursor = row + this.#syncUrl(tile.open.path) + } + + /** + * 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. + */ + prune() { + const current = this.tile + if (this.tiles.length > 1) { + const kept = this.tiles.filter((tile) => tile.open) + this.tiles = kept.length > 0 ? kept : [current] + const at = this.tiles.indexOf(current) + this.focused = at >= 0 ? at : Math.min(this.focused, this.tiles.length - 1) + } + if (!this.open) this.pane = 'list' + } + + /** `:close`: the focused tile goes, or with only one, its document. */ + closeTile() { + this.tile.clear() + this.prune() + if (this.open) this.focusTile(this.focused) + } + + /** + * `L` / Shift-Enter: the row under the cursor in a tile of its own, next to + * what is open, rather than in its place. + */ + async openBeside() { + const selected = this.selected + if (!selected) return + if (isContainer(selected)) return this.say(`${selected.slug} is a ${selected.kind} — l goes into it`, 'error') + const shown = this.tiles.findIndex((tile) => tile.open?.id === selected.id) + if (shown >= 0) return this.focusTile(shown) + if (this.open) { + this.tiles.push(new Tile()) + this.focused = this.tiles.length - 1 + } + try { + await this.openSelected() + } finally { + this.prune() + } + } + + /** `:only`: every other tile goes, after their drafts are written. */ + async onlyTile() { + const others = this.tiles.filter((tile) => tile !== this.tile) + for (const tile of others) await this.flushTile(tile) + const unsaved = others.filter((tile) => tile.dirty) + if (unsaved.length > 0) { + const names = unsaved.map((tile) => tile.open?.slug).join(', ') + if (!(await this.ask(`${names} could not be written — close anyway? the drafts stay in this browser`))) return + } + for (const tile of others) tile.stopAutosave() + this.tiles = [this.tile] + this.focused = 0 + } + /** The board of the current space, loaded only while it is being looked at. */ view = $state('tree') /** The board as the server sent it. */ @@ -517,7 +599,7 @@ class Workspace { if (tile.draft.name.trim() || tile.draft.body.trim()) return false tile.clear() this.dropStash(open.id) - if (tile === this.tile) this.pane = 'list' + this.prune() try { await api.deleteNode(open.id) } catch { @@ -580,6 +662,7 @@ class Workspace { this.spaces = [] this.nodes = [] for (const tile of this.tiles) tile.clear() + this.prune() } /* @@ -633,11 +716,14 @@ class Workspace { #rememberSpot() { if (this.spaceId === null || this.parentId === null) return + const open = this.tiles.flatMap((tile) => (tile.open ? [tile.open.id] : [])) this.#spots[this.spaceId] = { parentId: this.parentId, openId: this.open?.id ?? null, cursor: this.cursor, pane: this.pane, + open, + focused: Math.max(open.indexOf(this.open?.id ?? -1), 0), } localStorage.setItem('diary:spots', JSON.stringify(this.#spots)) } @@ -650,8 +736,10 @@ class Workspace { const from = this.spaces.findIndex((s) => s.id === this.spaceId) const to = this.spaces.findIndex((s) => s.id === id) this.switchDirection = to >= from ? 1 : -1 - this.tile.clear() - this.returnTo = null + // Each space has tiles of its own; these come back with this one. + for (const tile of this.tiles) tile.stopAutosave() + this.tiles = [new Tile()] + this.focused = 0 this.pane = 'list' } this.spaceId = id @@ -668,9 +756,24 @@ class Workspace { await this.goTo(id) } if (spot && this.parentId === spot.parentId) { - if (spot.openId !== null && this.nodes.some((n) => n.id === spot.openId)) { - // Reopened under the board, if that is what is on screen. - await this.openNode(spot.openId, false, true) + const ids = spot.open ?? (spot.openId !== null && this.nodes.some((n) => n.id === spot.openId) ? [spot.openId] : []) + let opened = 0 + for (const open of ids) { + if (opened > 0) { + this.tiles.push(new Tile()) + this.focused = this.tiles.length - 1 + } + try { + // Reopened under the board, if that is what is on screen. + await this.openNode(open, false, true) + opened++ + } catch { + /* deleted or moved away since: one tile fewer */ + } + } + this.prune() + if (opened > 0) { + this.focusTile(Math.min(spot.focused ?? 0, this.tiles.length - 1)) this.pane = spot.pane } else { this.cursor = Math.min(spot.cursor, Math.max(this.nodes.length - 1, 0)) @@ -772,6 +875,14 @@ class Workspace { await this.goTo(node.id) return } + // A document already open in another tile is gone to, not opened twice: + // two drafts of one document would only overwrite each other. + const shown = this.tiles.findIndex((tile) => tile !== this.tile && tile.open?.id === id) + if (shown >= 0) { + this.focusTile(shown) + if (edit && node.kind === 'document') this.editing = true + return + } this.open = node this.draft = { name: node.name, body: node.body, created_at: node.created_at } @@ -889,7 +1000,7 @@ class Workspace { const node = showing[0]?.open ?? this.nodes.find((n) => n.id === id) const { removed } = await api.deleteNode(id) for (const tile of showing) tile.clear() - if (showing.includes(this.tile)) this.pane = 'list' + this.prune() this.marked = this.marked.filter((n) => n.id !== id) await this.refresh() const name = node ? `"${node.name.trim() || node.slug}"` : `#${id}` @@ -928,6 +1039,7 @@ class Workspace { async deleteSpace(id: number) { const { removed } = await api.deleteNode(id) for (const tile of this.tiles) tile.clear() + this.prune() this.rawBoard = null this.view = 'tree' this.spaces = await api.listSpaces() @@ -966,7 +1078,7 @@ class Workspace { await this.flushTile(tile) tile.clear() } - if (leaving.includes(this.tile)) this.pane = 'list' + this.prune() const { moved, unchanged } = await api.moveNodes( nodes.map((n) => n.id), parent, @@ -1310,15 +1422,22 @@ class Workspace { this.view = 'tree' if (this.parentId !== space) await this.goTo(space) else await this.refresh() + // The notes open beside whatever was being read, and `q` takes their + // tile away again on the way back to the board. + if (this.open && this.open.id !== nodeId) { + this.tiles.push(new Tile()) + this.focused = this.tiles.length - 1 + } await this.openNode(nodeId, card.node_id === null) + this.prune() if (card.node_id === null) this.enterInsert = true this.returnTo = 'board' } /** Back to the board a card's document was opened from. */ async returnToBoard() { - this.tile.clear() this.returnTo = null + this.closeTile() this.pane = 'list' await this.reloadBoard() this.view = 'board' diff --git a/web/src/lib/util.ts b/web/src/lib/util.ts index a7208d3..83364fe 100644 --- a/web/src/lib/util.ts +++ b/web/src/lib/util.ts @@ -233,3 +233,62 @@ export function linkAt(line: string, column: number): string | null { /** What a share link's reader will miss: a share never serves a sealed file. */ export const sealedNote = (sealed: number) => sealed > 0 ? ` — ${sealed} sealed file${sealed === 1 ? '' : 's'} in it stay hidden from readers` : '' + +/** Where a tile sits, as fractions of the space the tiles share. */ +export type Rect = { x: number; y: number; w: number; h: number } +export type Direction = 'h' | 'j' | 'k' | 'l' + +/** + * Hyprland's dwindle: the first tile takes half, and each one after it takes + * half of what is left, side by side and then one over the other in turn. + */ +export function dwindle(count: number): Rect[] { + const rects: Rect[] = [] + let rest: Rect = { x: 0, y: 0, w: 1, h: 1 } + for (let i = 0; i < count; i++) { + if (i === count - 1) { + rects.push(rest) + } else if (i % 2 === 0) { + rects.push({ ...rest, w: rest.w / 2 }) + rest = { ...rest, x: rest.x + rest.w / 2, w: rest.w / 2 } + } else { + rects.push({ ...rest, h: rest.h / 2 }) + rest = { ...rest, y: rest.y + rest.h / 2, h: rest.h / 2 } + } + } + return rects +} + +/** + * The tile next to `from` in a direction: one that touches its side and + * shares some of its length, the nearest and then the most overlapping. + * -1 when it is at the edge. + */ +export function neighbour(rects: Rect[], from: number, direction: Direction): number { + const a = rects[from] + if (!a) return -1 + const near = 1e-6 + let best = -1 + let bestGap = Infinity + let bestOverlap = 0 + rects.forEach((b, index) => { + if (index === from) return + const across = direction === 'h' || direction === 'l' + const overlap = across + ? Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) + : Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) + if (overlap <= near) return + const gap = + direction === 'l' ? b.x - (a.x + a.w) + : direction === 'h' ? a.x - (b.x + b.w) + : direction === 'j' ? b.y - (a.y + a.h) + : a.y - (b.y + b.h) + if (gap < -near) return + if (gap < bestGap - near || (Math.abs(gap - bestGap) <= near && overlap > bestOverlap)) { + best = index + bestGap = gap + bestOverlap = overlap + } + }) + return best +}