Add connect mode, portals, backlinks and renames (stage 8d, v1)

Part 8d, the last of stage 8, and with it everything on DESIGN.md's v1
list.

Connect mode (C): the cursor goes to another node, a dashed arrow in the
visual color follows it, and Enter links the two; then the cursor goes
back to where C was typed (the user's pick). d removes the line between
them (a branch is cut, and the child comes loose), > puts the node under
the other one, and <Space> a, l, c, s and t style the line as you watch,
making it when it isn't there yet. Everything connect mode changes is one
undo group, and Esc takes it back with History::cancel, which leaves
nothing to redo. From visual mode every selected node connects, and the
line pickers go back to connect mode when they close.

Portals show their document live, from a cache of what each document
file holds (open documents from their buffers, the others from their
files, looked at every second): the root's title, its color and how many
nodes it has, or ? when the document isn't there. Enter or gf goes
through, as a jump, and the pane's breadcrumb starts with the documents
it came through. <Space>p types :portal (the user's pick): a path as :e
takes it, or the title of a new document. Backlinks are put right
whenever a document is opened or saved; the root shows how many, the
Note view lists them, gu goes up to them (the user's pick) and
:backlinks lists them.

File names follow the root's title: when it changed, the next save
renames the file (-2 when the name is taken; a folder's default document
keeps its name), and the portals pointing at it, its backlinks in the
documents it leads to, the recent documents, the capital marks and the
jump lists follow. A portal whose file was moved by hand finds its
document in that folder by uid. Portals in a register keep full paths,
so a pasted portal leads to the same document from any folder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-10-02 08:24:43 -04:00
1 parent bd819409f4
commit ebe8a5988e
45 files changed
+2329 -148

No files matched your search

+65 -10
View File
@@ -197,6 +197,58 @@ lines.
beside the node. `:marks`, `:jumps` and `:registers` list them; Enter goes beside the node. `:marks`, `:jumps` and `:registers` list them; Enter goes
to the mark or the place, or pastes the register. to the mark or the place, or pastes the register.
**Connect mode and portals** (settled in part 8d)
- `C` starts connect mode on the cursor's node: the cursor goes to another
node as usual (`hjkl`, `gh`…, `n` after a search, a click), the node
being connected is drawn thick in the visual color, and a dashed arrow in
that color follows the cursor. Enter links them (a line already there
stays) and connect mode is done, with the cursor back on the node `C`
started from (the user's pick). `d` removes the line between them: a link
goes; a branch (aimed at the node's parent, or at a child) is cut, and the
child comes loose with its own branch. `>` puts the node under the one
aimed at, as its last child, where it is on the canvas (a link between the
two goes: the branch takes its place).
- The `<Space>` keys style the line as you watch: `a` and `l` go through the
arrowheads (off, end, start, both) and the kinds of line (solid, dashed,
dotted, thick, double); `c` and `s` pick a color (or the node's own) and a
named line style; `t` a link's label, where typing a label that isn't
listed makes it. A line that isn't there yet is made, as a link. Everything
connect mode changes is one undo step, and Esc takes it all back.
- From visual mode, `C` connects every selected node (with `V`, each
branch's top) to the one the cursor goes to. Connect mode works in every
view, and stays in its document.
- A portal shows its document live: the root's title, its color (when the
portal has none of its own) and how many nodes it has, in the box's corner
(`?` and faded when the document isn't there). The portal remembers the
title, in case the file goes. Enter or `gf` goes through it, to the root:
a jump, so `Ctrl-o` comes back. The breadcrumb then starts with the
documents the pane came through (`Garden plans » Kitchen › Herbs`);
going back up one of them takes the trail back there.
- `<Space>p` opens the command line with `:portal ` typed (the user's
pick): a path as `:e` takes it, relative to the document's folder, with
the same completion. An existing document gets a portal under the
cursor's node, where `a` adds a child; when nothing's there, what was
typed is a new document's title, made next to this one as `:new` makes
it. A portal to its own document, or to a file that isn't a document, is
refused.
- Backlinks are put right whenever a document is opened or saved: each
document its portals lead to gets exactly one backlink per portal (an
open one in its buffer, written at once unless it has changes of its own
to save with them; a closed one in its file, unless another cumulus has
it). The root shows how many (`« 2`), the Note view lists them on the
root, and `gu` goes up to them (the user's pick): one straight there, on
the portal, several in a list, the document the pane came from first.
`:backlinks` always lists them.
- File names follow the root's title: when the title changed since the
file was read or written, the next save renames it (`-2`, `-3`… when the
name is taken; a folder's default document keeps its name). The portals
pointing at it take the new path (in the documents its backlinks name,
and any open one), as do the backlinks in the documents it leads to, the
recent documents, the capital marks and the jump lists.
- A portal whose file isn't where it says looks in that folder for its
document by uid (a file moved or renamed by hand), and keeps the path it
finds.
**Views, panes and popups** (settled in stage 7) **Views, panes and popups** (settled in stage 7)
- The Note view is made of parts: the title, the tags, the body, each link - The Note view is made of parts: the title, the tags, the body, each link
(out, then in) and each child. `j` and `k` step through them, with a bar (out, then in) and each child. `j` and `k` step through them, with a bar
@@ -406,7 +458,8 @@ enum Mode {
// two can't disagree. Rapid entry lives here too. // two can't disagree. Rapid entry lives here too.
Visual(Visual), // anchor + Rectangle or Branches Visual(Visual), // anchor + Rectangle or Branches
Move(Grab), // a node or a branch being moved Move(Grab), // a node or a branch being moved
Connect(Connect), // the source node and the link being styled Connect(Connect), // the nodes being connected, where the cursor goes
// back, and the undo group Esc takes back
Command(CommandLine), // `:` with completion Command(CommandLine), // `:` with completion
Popup(Popup), // the help, the menu, a picker or the K card (which Popup(Popup), // the help, the menu, a picker or the K card (which
// holds its own TextEdit while the body is edited) // holds its own TextEdit while the body is edited)
@@ -420,12 +473,12 @@ text).
| Group | Keys | | Group | Keys |
|---|---| |---|---|
| **Move** (every view) | `hjkl` or arrows · `gh gj gk gl` tree moves · `w` `b` next / previous in reading order · `gg` root · `G` last · `/` `n` `N` search · `Ctrl-o` `Ctrl-i` (Tab) jump back / forward · `m{a-z}` `'{a-z}` marks (`A–Z` across documents) · Enter or `gf` through a portal · `gd` follow a cross-link | | **Move** (every view) | `hjkl` or arrows · `gh gj gk gl` tree moves · `w` `b` next / previous in reading order · `gg` root · `G` last · `/` `n` `N` search · `Ctrl-o` `Ctrl-i` (Tab) jump back / forward · `m{a-z}` `'{a-z}` marks (`A–Z` across documents) · Enter or `gf` through a portal · `gu` up to the portals that lead here · `gd` follow a cross-link |
| **View** | `zz` center · `Ctrl-e` `Ctrl-y` `zh` `zl` pan · `Ctrl-d` `Ctrl-u` half a screen · `+` `-` zoom · `za zo zc zR zM` folds · `zf` Auto · `zi` focus · `K` body card | | **View** | `zz` center · `Ctrl-e` `Ctrl-y` `zh` `zl` pan · `Ctrl-d` `Ctrl-u` half a screen · `+` `-` zoom · `za zo zc zR zM` folds · `zf` Auto · `zi` focus · `K` body card |
| **Create and edit** | `o` `O` sibling below / above · `a` child · `i` `A` Enter edit the title · `I` edit from its start · `cc` `S` retype | | **Create and edit** | `o` `O` sibling below / above · `a` child · `i` `A` Enter edit the title · `I` edit from its start · `cc` `S` retype |
| **Operators** | `dd` or `dab` delete branch · `dib` delete children · `x` delete just the node · `yy` `yab` `yib` yank · `p` paste as children · `P` paste as siblings below · `>>` `<<` indent / outdent · `"a`–`"z` and `"+` registers · `.` repeat · `u` `Ctrl-r` undo / redo | | **Operators** | `dd` or `dab` delete branch · `dib` delete children · `x` delete just the node · `yy` `yab` `yib` yank · `p` paste as children · `P` paste as siblings below · `>>` `<<` indent / outdent · `"a`–`"z` and `"+` registers · `.` repeat · `u` `Ctrl-r` undo / redo |
| **Modes** | `v` `V` visual · `M` move · `C` connect · `:` command · Esc back to normal | | **Modes** | `v` `V` visual · `M` move · `C` connect · `:` command · Esc back to normal |
| **Leader** `<Space>` | `c` color · `s` style · `B` border · `t` tags · `p` new portal · `i` inbox · `b` documents · `v` then `c`/`o`/`n` for canvas / outline / note · `w` which-key on/off · `?` help | | **Leader** `<Space>` | `c` color · `s` style · `B` border · `t` tags · `p` new portal (`:portal `) · `i` inbox · `b` documents · `v` then `c`/`o`/`n` for canvas / outline / note · `w` which-key on/off · `?` help |
| **Panes, documents** | `Ctrl-w v` `Ctrl-w s` split · `Ctrl-w hjkl` focus · `Ctrl-w w` the next pane · `Ctrl-w c` close · `Ctrl-w o` only · `Ctrl-w =` even sizes · `gt` `gT` `]b` `[b` next / previous document · `ZZ` close pane (the last one quits) | | **Panes, documents** | `Ctrl-w v` `Ctrl-w s` split · `Ctrl-w hjkl` focus · `Ctrl-w w` the next pane · `Ctrl-w c` close · `Ctrl-w o` only · `Ctrl-w =` even sizes · `gt` `gT` `]b` `[b` next / previous document · `ZZ` close pane (the last one quits) |
| **Outline** | `j` `k` rows · `h` `l` parent / first child · `zb` every body · `M` then `j` `k` (`J` `K`: 5) swap with siblings, `h` out, `l` under the one above | | **Outline** | `j` `k` rows · `h` `l` parent / first child · `zb` every body · `M` then `j` `k` (`J` `K`: 5) swap with siblings, `h` out, `l` under the one above |
| **Note view** | `j` `k` parts · Enter `i` `A` edit the part (`I` from its start, `cc` `S` anew), or go to that link or child · `h` `l` the sibling before / after | | **Note view** | `j` `k` parts · Enter `i` `A` edit the part (`I` from its start, `cc` `S` anew), or go to that link or child · `h` `l` the sibling before / after |
@@ -434,7 +487,7 @@ text).
| **Typing** | `Backspace` `Delete` · `Ctrl-w` delete word · `Ctrl-u` delete line · arrows, Home, End · Enter (title: done, or after `o O a` the next idea; body: new line) · Tab Shift-Tab (a new idea: under the one above / out a level) · Esc to vim keys | | **Typing** | `Backspace` `Delete` · `Ctrl-w` delete word · `Ctrl-u` delete line · arrows, Home, End · Enter (title: done, or after `o O a` the next idea; body: new line) · Tab Shift-Tab (a new idea: under the one above / out a level) · Esc to vim keys |
| **Inside text** | `h j k l w b e 0 ^ $ gg G` (and arrows, Home, End) · `f t F T ; ,` · `d` `c` `y` with any motion and `iw aw i" a" i( a(` · `x X s S D C Y` · `r ~ J p P` · `u Ctrl-r .` · `i a I A` back to typing · `o O` (title: a new sibling; body: a new line) · Esc done | | **Inside text** | `h j k l w b e 0 ^ $ gg G` (and arrows, Home, End) · `f t F T ; ,` · `d` `c` `y` with any motion and `iw aw i" a" i( a(` · `x X s S D C Y` · `r ~ J p P` · `u Ctrl-r .` · `i a I A` back to typing · `o O` (title: a new sibling; body: a new line) · Esc done |
| **Move mode** | `hjkl` 1 cell · `HJKL` 5 cells · `M` branch ↔ node · Enter or Esc done | | **Move mode** | `hjkl` 1 cell · `HJKL` 5 cells · `M` branch ↔ node · Enter or Esc done |
| **Connect mode** | move to the target as usual · Enter link · `d` remove · `>` attach as child · `<Space>` style menu (`s` named, `a` arrows, `l` line, `c` color, `t` label) · Esc cancel | | **Connect mode** | move to the target as usual (or click it) · Enter link · `d` remove (a branch: cut) · `>` attach as child · `<Space>` style menu (`s` named, `a` arrows, `l` line, `c` color, `t` label) · Esc cancel (takes it all back) |
| **Visual** | `d` `x` `y` `>` `<` `M` and `<Space>c` `<Space>s` `<Space>B` `<Space>t` act on the whole selection · `o` swaps ends · `v` `V` switch, or stop · Ctrl+click, Ctrl+drag pick with the mouse | | **Visual** | `d` `x` `y` `>` `<` `M` and `<Space>c` `<Space>s` `<Space>B` `<Space>t` act on the whole selection · `o` swaps ends · `v` `V` switch, or stop · Ctrl+click, Ctrl+drag pick with the mouse |
## Files ## Files
@@ -616,9 +669,9 @@ cumulus/
│ command_line, theme, glyphs │ command_line, theme, glyphs
├─ config/ paths (the config and data folders), settings (config.toml, `:set`), keys_file ├─ config/ paths (the config and data folders), settings (config.toml, `:set`), keys_file
│ (keys.toml's template), theme_file (themes laid over the built-in one) │ (keys.toml's template), theme_file (themes laid over the built-in one)
├─ storage/ document_file (RON), write_atomically, names (file names from titles), backup, ├─ storage/ document_file (RON), write_atomically, names (file names from titles), link_paths
│ lock, stamp (noticing changes made outside), memory (memory.ron), markdown │ (portal paths: relative, with /), backup, lock, stamp (noticing changes made
│ (nodes as Markdown, for the clipboard) │ outside), memory (memory.ron), markdown (nodes as Markdown, for the clipboard)
└─ app/ mod.rs (App), input (keys and the mouse, routed), update (carrying actions out), └─ app/ mod.rs (App), input (keys and the mouse, routed), update (carrying actions out),
render, terminal (the main loop, setup, teardown, panic hook), startup (what render, terminal (the main loop, setup, teardown, panic hook), startup (what
`cumulus` opens), documents (the open documents and their files), files `cumulus` opens), documents (the open documents and their files), files
@@ -628,12 +681,14 @@ cumulus/
edited, rapid entry), registers (text or nodes), clipboard (the system's, for edited, rapid entry), registers (text or nodes), clipboard (the system's, for
"+), yank (nodes into registers and back), visual (selections), repeat (`.`), "+), yank (nodes into registers and back), visual (selections), repeat (`.`),
search (`/`), jumps (each pane's jump list), marks (capital marks, `:marks`), search (`/`), jumps (each pane's jump list), marks (capital marks, `:marks`),
panes (the split tree), popup (help, menu, pickers, card, questions); later connect (connect mode), portals (what portals show, going through them,
portals (cache, backlinks, renames) `:portal`, backlinks, renames), panes (the split tree), popup (help, menu,
pickers, card, questions)
``` ```
The end-to-end tests are in `app/tests.rs` (with `app/tests/files.rs`, The end-to-end tests are in `app/tests.rs` (with `app/tests/files.rs`,
`app/tests/config.rs` and `app/tests/vim.rs`): they copy the sample `app/tests/config.rs`, `app/tests/vim.rs`, `app/tests/connect.rs` and
`app/tests/portals.rs`): they copy the sample
documents to a temporary folder, type keys and click on a test terminal, and documents to a temporary folder, type keys and click on a test terminal, and
check the screen, the documents and the files. check the screen, the documents and the files.
+11 -2
View File
@@ -4,8 +4,8 @@ Brainstorm in the terminal: ideas as nodes on a 2D canvas, connected, folded
and color-coded, across as many documents as you like. Vim keys first; the and color-coded, across as many documents as you like. Vim keys first; the
mouse works too. Runs on Windows and Linux. mouse works too. Runs on Windows and Linux.
> **Status:** being built, one stage at a time. See [DESIGN.md](DESIGN.md) for > **Status:** v1 is built: everything on [DESIGN.md](DESIGN.md)'s v1 list.
> the plan; this README grows with the app. > v1.1 (the board, a search across documents, tidying, export) comes next.
## Build and run ## Build and run
@@ -42,6 +42,12 @@ saves a second after each change, reloads a file another program changed
A document open in another cumulus opens read-only. Each one reopens where you A document open in another cumulus opens read-only. Each one reopens where you
left it. left it.
A file's name follows its root's title: give the root a new title and the
file is renamed (`ideas.cum` keeps its name), and the portals leading to it
follow. Portals link documents: a portal shows its document's title, color
and size, Enter goes through it, and each document knows the portals that
lead to it (`« 2` on its root, `gu` goes up to them).
## Settings, keys and themes ## Settings, keys and themes
`:config` lists the three config files: `config.toml` (the settings), `:config` lists the three config files: `config.toml` (the settings),
@@ -82,10 +88,13 @@ The plan for the everyday keys (all of them are in DESIGN.md):
| `K` | the body in a card beside the node (`i` edits it there) | | `K` | the body in a card beside the node (`i` edits it there) |
| `v` `V` | select: the nodes between here and where you started (`V`: with their branches); then `d` `y` `>` `<` `M` or `<Space>c` act on all of them | | `v` `V` | select: the nodes between here and where you started (`V`: with their branches); then `d` `y` `>` `<` `M` or `<Space>c` act on all of them |
| `M` | move the branch with `hjkl` (`M` again: just the node) | | `M` | move the branch with `hjkl` (`M` again: just the node) |
| `C` | connect: go to another node, Enter links them (`d` removes the line, `>` puts it under that node, `<Space>` `a` `l` `c` `s` `t` style it, Esc takes it all back) |
| `/` `n` `N` | search: titles, tags and bodies, fuzzily, with the note of each match beside it / the next match / the previous one | | `/` `n` `N` | search: titles, tags and bodies, fuzzily, with the note of each match beside it / the next match / the previous one |
| `Ctrl-o` `Tab` | back to where you jumped from / forward again (across documents) | | `Ctrl-o` `Tab` | back to where you jumped from / forward again (across documents) |
| `ma` `'a` `mA` | mark a node / go back to it / a capital mark: from any document | | `ma` `'a` `mA` | mark a node / go back to it / a capital mark: from any document |
| `gd` | follow a cross-link | | `gd` | follow a cross-link |
| `<Space>p` | a portal to another document (`:portal file`, or a new document's title) |
| Enter or `gf` `gu` | through a portal / up to the portals that lead here |
| `<Space>` | the action menu: `c` color, `t` tags, `b` documents, `i` inbox, `vo` outline, `vn` note… | | `<Space>` | the action menu: `c` color, `t` tags, `b` documents, `i` inbox, `vo` outline, `vn` note… |
| `Ctrl-w v` `Ctrl-w s` | split the pane side by side / stacked (`Ctrl-w hjkl` to move between them) | | `Ctrl-w v` `Ctrl-w s` | split the pane side by side / stacked (`Ctrl-w hjkl` to move between them) |
| `gt` `gT` | the next / previous document | | `gt` `gT` | the next / previous document |
+7
View File
@@ -40,4 +40,11 @@
Empty it on Sundays.", Empty it on Sundays.",
), ),
], ],
backlinks: [
(
from: "garden.cum",
uid: "7f3c9a1e52b04d18",
node: 10,
),
],
) )
+19
View File
@@ -7,6 +7,7 @@
use super::documents::BufferId; use super::documents::BufferId;
use super::jumps::Place; use super::jumps::Place;
use super::mode::Connect;
use super::panes::Arrange; use super::panes::Arrange;
use super::popup::{MenuFor, PickKind}; use super::popup::{MenuFor, PickKind};
use super::repeat::Look; use super::repeat::Look;
@@ -96,6 +97,9 @@ pub enum DocAction {
SetMark(char), SetMark(char),
/// Go to a mark; `'` and `` ` `` go back to before the last jump. /// Go to a mark; `'` and `` ` `` go back to before the last jump.
JumpToMark(char), JumpToMark(char),
/// Take back the group's undo step, if it's the newest, leaving nothing
/// to redo: Esc in connect mode.
Cancel(Group),
} }
/// What happens around the documents. /// What happens around the documents.
@@ -124,6 +128,21 @@ pub enum AppAction {
EnterMoveMode(Vec<NodeId>), EnterMoveMode(Vec<NodeId>),
/// In move mode: the whole branch, or just the node. /// In move mode: the whole branch, or just the node.
ToggleWhole, ToggleWhole,
/// Connect mode (`C`): from the cursor's node, or from these (a
/// selection's).
EnterConnectMode(Vec<NodeId>),
/// Back to connect mode, from a picker it opened.
ResumeConnect(Connect),
/// Enter or `gf` on a portal: through it, to its document's root.
FollowPortal(NodeId),
/// `:portal`: a portal under the cursor's node, to the document at the
/// path typed (a new document when nothing's there).
NewPortal(String),
/// `gu`: up to the portals that lead to this document; `list`
/// (`:backlinks`) lists them even when there's just one.
GoUp {
list: bool,
},
/// Back to normal mode. /// Back to normal mode.
LeaveMode, LeaveMode,
OpenCommandLine, OpenCommandLine,
+18 -1
View File
@@ -4,7 +4,7 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use super::action::{self, CursorAfter, DocAction, Message}; use super::action::{self, CursorAfter, DocAction, Message};
use crate::model::{Applied, Document, History, NodeId}; use crate::model::{Applied, Backlink, Document, History, NodeId, Portal};
/// An open document, with what goes with it while it's open. /// An open document, with what goes with it while it's open.
#[derive(Debug)] #[derive(Debug)]
@@ -83,6 +83,12 @@ impl Buffer {
Applied::Removed(_) | Applied::Added(_) | Applied::Changed => None, Applied::Removed(_) | Applied::Added(_) | Applied::Changed => None,
} }
} }
DocAction::Cancel(group) => {
if self.history.cancel(group, &mut self.doc) {
self.keep_cursor();
}
None
}
DocAction::Undo(times) => self.undo_or_redo(times, true), DocAction::Undo(times) => self.undo_or_redo(times, true),
DocAction::Redo(times) => self.undo_or_redo(times, false), DocAction::Redo(times) => self.undo_or_redo(times, false),
DocAction::SetMark(name) => { DocAction::SetMark(name) => {
@@ -103,6 +109,17 @@ impl Buffer {
} }
} }
/// Puts a portal node's portal in step with its document: bookkeeping,
/// outside the undo history (see [`Document::set_portal`]).
pub fn set_portal(&mut self, id: NodeId, portal: Portal) -> bool {
self.doc.set_portal(id, portal)
}
/// Replaces the backlinks: bookkeeping, outside the undo history.
pub fn set_backlinks(&mut self, backlinks: Vec<Backlink>) {
self.doc.set_backlinks(backlinks);
}
/// Puts the document as read again from its file in place. When it's /// Puts the document as read again from its file in place. When it's
/// the same document, that's an undo step (`u` brings back what was on /// the same document, that's an undo step (`u` brings back what was on
/// screen) and it returns true; another document starts a new history, /// screen) and it returns true; another document starts a new history,
+2 -1
View File
@@ -65,7 +65,8 @@ pub fn actions_for(call: Call, ctx: &ViewContext<'_>) -> Vec<Action> {
Call::Find(text) => app(AppAction::Find(text)), Call::Find(text) => app(AppAction::Find(text)),
Call::Set(arg) => vec![Action::App(AppAction::Set(arg))], Call::Set(arg) => vec![Action::App(AppAction::Set(arg))],
Call::Version => say(Message::info(crate::version())), Call::Version => say(Message::info(crate::version())),
Call::Portal(_) | Call::Backlinks => later("portals come in part 8d of the build"), Call::Portal(path) => app(AppAction::NewPortal(path)),
Call::Backlinks => app(AppAction::GoUp { list: true }),
Call::Jumps => app(AppAction::OpenJumps), Call::Jumps => app(AppAction::OpenJumps),
Call::Marks => app(AppAction::OpenMarks), Call::Marks => app(AppAction::OpenMarks),
Call::Registers => app(AppAction::OpenRegisters), Call::Registers => app(AppAction::OpenRegisters),
+6 -1
View File
@@ -42,8 +42,12 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
Command::JumpToMark(name) => vec![Action::Doc(DocAction::JumpToMark(name))], Command::JumpToMark(name) => vec![Action::Doc(DocAction::JumpToMark(name))],
Command::FollowLink => follow_link(ctx), Command::FollowLink => follow_link(ctx),
Command::Activate if ctx.doc.node(cursor).is_some_and(Node::is_portal) => { Command::Activate if ctx.doc.node(cursor).is_some_and(Node::is_portal) => {
say("going through a portal comes in part 8d of the build") app(AppAction::FollowPortal(cursor))
} }
Command::FollowPortal => app(AppAction::FollowPortal(cursor)),
Command::GoUp => app(AppAction::GoUp { list: false }),
// `<Space>p`: a portal, to a file typed in `:portal`.
Command::NewPortal => app(AppAction::CommandLineWith("portal ".into())),
Command::EditTitle | Command::Activate => edit(EditAt::End), Command::EditTitle | Command::Activate => edit(EditAt::End),
Command::EditTitleAtStart => edit(EditAt::Start), Command::EditTitleAtStart => edit(EditAt::Start),
Command::RetypeTitle => edit(EditAt::Retype), Command::RetypeTitle => edit(EditAt::Retype),
@@ -79,6 +83,7 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
Command::OpenAllFolds => fold_all(ctx, Fold::Open), Command::OpenAllFolds => fold_all(ctx, Fold::Open),
Command::CloseAllFolds => fold_all(ctx, Fold::Closed), Command::CloseAllFolds => fold_all(ctx, Fold::Closed),
Command::MoveMode => app(AppAction::EnterMoveMode(Vec::new())), Command::MoveMode => app(AppAction::EnterMoveMode(Vec::new())),
Command::ConnectMode => app(AppAction::EnterConnectMode(Vec::new())),
Command::Visual => app(AppAction::Visual { branches: false }), Command::Visual => app(AppAction::Visual { branches: false }),
Command::VisualBranches => app(AppAction::Visual { branches: true }), Command::VisualBranches => app(AppAction::Visual { branches: true }),
Command::CommandLine => app(AppAction::OpenCommandLine), Command::CommandLine => app(AppAction::OpenCommandLine),
+328
View File
@@ -0,0 +1,328 @@
//! Connect mode (`C`): the cursor goes to another node, a line follows it
//! there, and Enter links the two (DESIGN.md, "Keys"). On the way, `d`
//! removes the line already there (a branch between a parent and its child
//! is cut, and the child comes loose), `>` puts the node under the other
//! one, and the `<Space>` keys style the line, branch or link, as you watch.
//! Everything connect mode changes is one undo step, which Esc takes back;
//! when it's done, the cursor goes back to where it started (the user's
//! pick in part 8d).
//!
//! From visual mode, every selected node connects to the one you go to.
use ratatui::style::Color;
use super::action::{self, Action, AppAction, DocAction, Message};
use super::mode::Connect;
use super::popup::{LinePick, PickKind};
use crate::config::theme_file::{ARROWS, STROKES};
use crate::input::command::Command;
use crate::input::engine::Typed;
use crate::model::{EditOp, Label, LineLook, LineRef, Link, NodeId, Spot};
use crate::ui::theme::LineStyle;
use crate::view::ViewContext;
/// Connect mode's answer to a command, or `None` for the ones that move the
/// cursor as usual.
pub fn command(typed: &Typed, connect: &Connect, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
let actions = match typed.command {
Command::Confirm => link(connect, ctx),
Command::RemoveLink => remove(connect, ctx),
Command::AttachAsChild => attach(connect, ctx),
Command::CycleArrows => cycle_arrows(connect, ctx),
Command::CycleLine => cycle_line(connect, ctx),
Command::PickColor => picker(connect, ctx, LinePick::Color),
Command::PickStyle => picker(connect, ctx, LinePick::Style),
Command::EditLabel => picker(connect, ctx, LinePick::Label),
Command::Escape => cancel(connect),
// The other end is in this document.
Command::FollowPortal | Command::JumpBack | Command::JumpForward | Command::GoUp => {
say(Message::info("connect mode stays in this document: Enter or Esc first"))
}
Command::SetMark(name) | Command::JumpToMark(name) if name.is_ascii_uppercase() => {
say(Message::info("connect mode stays in this document: Enter or Esc first"))
}
Command::Search => {
say(Message::info("n and N find the last search again here: / starts one in normal mode"))
}
_ => return None,
};
Some(actions)
}
/// The nodes being connected to `target`: the sources, but not the target
/// itself.
pub fn others(connect: &Connect, target: NodeId) -> Vec<NodeId> {
connect.sources.iter().copied().filter(|&id| id != target).collect()
}
/// What's being connected to what: `Raised beds → Watering`.
pub fn ends(connect: &Connect, ctx: &ViewContext<'_>) -> String {
let target = ctx.cursor;
match others(connect, target).as_slice() {
[] => format!("{} → ?", what(ctx, &connect.sources)),
sources => format!("{} → {}", what(ctx, sources), title(ctx, target)),
}
}
/// What the status bar says in connect mode, in place of the breadcrumb:
/// what's being connected to what, and what joins them already.
pub fn status(connect: &Connect, ctx: &ViewContext<'_>) -> String {
let target = ctx.cursor;
let sources = others(connect, target);
let mut text = ends(connect, ctx);
if sources.is_empty() {
text.push_str(" · go to the node to connect");
}
if let [source] = sources.as_slice() {
match ctx.doc.line_between(*source, target) {
Some(LineRef::Link(_)) => text.push_str(" · linked"),
Some(LineRef::Branch(child)) if child == target => text.push_str(" · its child"),
Some(LineRef::Branch(_)) => text.push_str(" · its parent"),
None => {}
}
}
text
}
/// The lines from each node being connected to the cursor's, changed by
/// `change`: a line that isn't there yet is made, as a link with that look.
pub fn restyle(connect: &Connect, ctx: &ViewContext<'_>, change: impl Fn(&mut LineLook)) -> Vec<Action> {
let target = ctx.cursor;
others(connect, target)
.into_iter()
.filter_map(|source| {
let op = match ctx.doc.line_between(source, target) {
Some(line) => {
let mut look = ctx.doc.line_look(line).cloned().unwrap_or_default();
change(&mut look);
EditOp::SetLine { line, look }
}
None => {
let mut link = Link::new(source, target)?;
change(&mut link.look);
EditOp::Link(link)
}
};
Some(Action::edit_in(op, connect.group))
})
.collect()
}
/// The labels of the links from each node being connected to the cursor's
/// (made if they aren't there yet). A branch has no label: it's left alone.
pub fn relabel(connect: &Connect, ctx: &ViewContext<'_>, label: &Option<Label>) -> Vec<Action> {
let target = ctx.cursor;
others(connect, target)
.into_iter()
.filter_map(|source| {
let op = match ctx.doc.line_between(source, target) {
Some(LineRef::Link(key)) => EditOp::SetLabel { key, label: label.clone() },
Some(LineRef::Branch(_)) => return None,
None => {
let mut link = Link::new(source, target)?;
link.label = label.clone();
EditOp::Link(link)
}
};
Some(Action::edit_in(op, connect.group))
})
.collect()
}
/// The line connect mode works on first: from the first node being
/// connected to the cursor's, if anything joins them.
pub fn first_line(connect: &Connect, ctx: &ViewContext<'_>) -> Option<LineRef> {
let target = ctx.cursor;
let source = *others(connect, target).first()?;
ctx.doc.line_between(source, target)
}
/// Enter: links each node to the cursor's, unless a line joins them
/// already, and connect mode is done.
fn link(connect: &Connect, ctx: &ViewContext<'_>) -> Vec<Action> {
let target = ctx.cursor;
let sources = others(connect, target);
if sources.is_empty() {
return aim_first();
}
let links: Vec<Link> = sources
.iter()
.filter(|&&source| ctx.doc.line_between(source, target).is_none())
.filter_map(|&source| Link::new(source, target))
.collect();
let mut actions: Vec<Action> =
links.iter().map(|link| Action::edit_in(EditOp::Link(link.clone()), connect.group)).collect();
actions.extend(done(connect));
if !links.is_empty() {
let from: Vec<NodeId> = links.iter().map(Link::start).collect();
let text = format!("linked {} → {}", what(ctx, &from), title(ctx, target));
actions.push(Action::say(Message::done(text)));
}
actions
}
/// `d`: removes each line between a node and the cursor's. A link goes; a
/// branch is cut, and the child comes loose with its own branch.
fn remove(connect: &Connect, ctx: &ViewContext<'_>) -> Vec<Action> {
let target = ctx.cursor;
let sources = others(connect, target);
if sources.is_empty() {
return aim_first();
}
let (mut ops, mut loose) = (Vec::new(), Vec::new());
for &source in &sources {
match ctx.doc.line_between(source, target) {
Some(LineRef::Link(key)) => ops.push(EditOp::Unlink(vec![key])),
Some(LineRef::Branch(child)) => {
ops.push(EditOp::MoveTo { id: child, spot: Spot::Loose });
loose.push(child);
}
None => {}
}
}
let text = match (ops.len(), loose.as_slice()) {
(0, _) => {
let text =
format!("nothing joins {} and {}: Enter links them", what(ctx, &sources), title(ctx, target));
return say(Message::info(text));
}
(1, [child]) => format!("cut the branch: {} is a loose node now", title(ctx, *child)),
(1, _) => format!("unlinked {} and {}", what(ctx, &sources), title(ctx, target)),
(count, _) => format!("removed {count} lines"),
};
let mut actions: Vec<Action> = ops.into_iter().map(|op| Action::edit_in(op, connect.group)).collect();
actions.extend(done(connect));
actions.push(Action::say(Message::done(text)));
actions
}
/// `>`: puts each node under the cursor's, as its last child, with its
/// branch. It stays where it is on the canvas, and a link between the two
/// goes: the branch takes its place.
fn attach(connect: &Connect, ctx: &ViewContext<'_>) -> Vec<Action> {
let (doc, target) = (ctx.doc, ctx.cursor);
let sources = others(connect, target);
if sources.is_empty() {
return aim_first();
}
if sources.contains(&doc.root()) {
return say(Message::problem(
"the root stays at the top: C on the other node puts that one under it",
));
}
if let Some(&holder) = sources.iter().find(|&&source| doc.is_inside(target, source)) {
let text = format!(
"{} is in {}'s branch, and a branch can't go inside itself",
title(ctx, target),
title(ctx, holder)
);
return say(Message::problem(text));
}
let moving: Vec<NodeId> =
sources.into_iter().filter(|&source| doc.parent(source) != Some(target)).collect();
if moving.is_empty() {
return say(Message::info(format!("it's under {} already", title(ctx, target))));
}
let mut actions = Vec::new();
for &source in &moving {
if let Some(LineRef::Link(key)) = doc.line_between(source, target) {
actions.push(Action::edit_in(EditOp::Unlink(vec![key]), connect.group));
}
let op = EditOp::MoveTo { id: source, spot: Spot::LastChildOf(target) };
actions.push(Action::edit_in(op, connect.group));
}
actions.extend(done(connect));
let verb = if moving.len() == 1 { "is" } else { "are" };
let text = format!("{} {verb} under {} now", what(ctx, &moving), title(ctx, target));
actions.push(Action::say(Message::done(text)));
actions
}
/// `<Space>a`: the next arrowheads (off, end, start, both), after the ones
/// the first line has now, on every line.
fn cycle_arrows(connect: &Connect, ctx: &ViewContext<'_>) -> Vec<Action> {
if others(connect, ctx.cursor).is_empty() {
return aim_first();
}
let now = drawn(ctx, first_line(connect, ctx)).arrows;
let at = ARROWS.iter().position(|&(_, arrows)| arrows == now).map_or(0, |at| (at + 1) % ARROWS.len());
let (name, next) = ARROWS[at];
let mut actions = restyle(connect, ctx, |look| look.arrows = Some(next));
actions.push(Action::say(Message::info(format!("arrowheads: {name}"))));
actions
}
/// `<Space>l`: the next kind of line (solid, dashed, dotted, thick,
/// double), after the one the first line has now, on every line.
fn cycle_line(connect: &Connect, ctx: &ViewContext<'_>) -> Vec<Action> {
if others(connect, ctx.cursor).is_empty() {
return aim_first();
}
let now = drawn(ctx, first_line(connect, ctx)).stroke;
let at = STROKES.iter().position(|&(_, stroke)| stroke == now).map_or(0, |at| (at + 1) % STROKES.len());
let (name, next) = STROKES[at];
let mut actions = restyle(connect, ctx, |look| look.line = Some(next));
actions.push(Action::say(Message::info(format!("line: {name}"))));
actions
}
/// How a line is drawn now (one that isn't there yet: as a new link would
/// be).
fn drawn(ctx: &ViewContext<'_>, line: Option<LineRef>) -> LineStyle {
let (theme, default) = (ctx.theme, LineLook::default());
let look = line.and_then(|line| ctx.doc.line_look(line)).unwrap_or(&default);
match line {
Some(LineRef::Branch(_)) => theme.branch_style(look, Color::Reset),
Some(LineRef::Link(_)) | None => theme.link_style(look, Color::Reset),
}
}
/// `<Space>c`, `<Space>s` and `<Space>t`: a picker for the line, which
/// comes back to connect mode.
fn picker(connect: &Connect, ctx: &ViewContext<'_>, what: LinePick) -> Vec<Action> {
let target = ctx.cursor;
let sources = others(connect, target);
if sources.is_empty() {
return aim_first();
}
let branches = sources
.iter()
.all(|&source| matches!(ctx.doc.line_between(source, target), Some(LineRef::Branch(_))));
if what == LinePick::Label && branches {
return say(Message::info("a branch line has no label: links do"));
}
vec![Action::App(AppAction::OpenPicker(PickKind::Line(what)))]
}
/// Esc: everything connect mode changed is taken back.
pub fn cancel(connect: &Connect) -> Vec<Action> {
let mut actions = vec![Action::Doc(DocAction::Cancel(connect.group))];
actions.extend(done(connect));
actions
}
/// Back to normal mode, with the cursor where `C` was typed.
fn done(connect: &Connect) -> Vec<Action> {
vec![Action::App(AppAction::LeaveMode), Action::cursor(connect.start)]
}
fn aim_first() -> Vec<Action> {
say(Message::info("go to the other node first (hjkl…), then Enter links them"))
}
/// A node's title, for messages.
fn title(ctx: &ViewContext<'_>, id: NodeId) -> String {
ctx.doc.node(id).map(|node| node.title().to_string()).unwrap_or_default()
}
/// Some nodes, for messages: the title of one, or how many.
fn what(ctx: &ViewContext<'_>, ids: &[NodeId]) -> String {
match ids {
[id] => title(ctx, *id),
_ => action::nodes(ids.len()),
}
}
fn say(message: Message) -> Vec<Action> {
vec![Action::say(message)]
}
+12 -3
View File
@@ -1,11 +1,12 @@
//! The open documents (vim's buffers), each with its file: where it is, the //! The open documents (vim's buffers), each with its file: where it is, the
//! version on disk, and whether this cumulus may write it. //! version on disk, and whether this cumulus may write it.
use std::collections::BTreeSet;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Instant; use std::time::Instant;
use super::buffer::Buffer; use super::buffer::Buffer;
use crate::model::{DocUid, Document, StateId}; use crate::model::{DocUid, Document, StateId, Title};
use crate::storage::lock::Lock; use crate::storage::lock::Lock;
use crate::storage::stamp::Stamp; use crate::storage::stamp::Stamp;
use crate::view::canvas::Camera; use crate::view::canvas::Camera;
@@ -23,6 +24,9 @@ pub struct Open {
pub file: DocFile, pub file: DocFile,
/// How a canvas last showed it: the next canvas on it starts there. /// How a canvas last showed it: the next canvas on it starts there.
pub camera: Option<Camera>, pub camera: Option<Camera>,
/// The files its portals led to when its backlinks were last put right
/// in them: one it doesn't lead to anymore has one to take back.
pub led_to: BTreeSet<PathBuf>,
} }
impl Open { impl Open {
@@ -68,6 +72,9 @@ pub struct DocFile {
/// Something wrong with the file. Autosave holds off until it's sorted /// Something wrong with the file. Autosave holds off until it's sorted
/// out. /// out.
pub trouble: Option<Trouble>, pub trouble: Option<Trouble>,
/// The root's title when the file was last read or written: when the
/// title changes, the next save gives the file its new name.
pub named: Title,
} }
/// Whether this cumulus may write a document. /// Whether this cumulus may write a document.
@@ -114,14 +121,15 @@ pub struct Documents {
impl Documents { impl Documents {
pub fn new(buffer: Buffer, file: DocFile) -> Documents { pub fn new(buffer: Buffer, file: DocFile) -> Documents {
Documents { first: Open { id: BufferId(1), buffer, file, camera: None }, rest: Vec::new(), next: 2 } let first = Open { id: BufferId(1), buffer, file, camera: None, led_to: BTreeSet::new() };
Documents { first, rest: Vec::new(), next: 2 }
} }
/// Opens another document, last in the bar. /// Opens another document, last in the bar.
pub fn add(&mut self, buffer: Buffer, file: DocFile) -> BufferId { pub fn add(&mut self, buffer: Buffer, file: DocFile) -> BufferId {
let id = BufferId(self.next); let id = BufferId(self.next);
self.next += 1; self.next += 1;
self.rest.push(Open { id, buffer, file, camera: None }); self.rest.push(Open { id, buffer, file, camera: None, led_to: BTreeSet::new() });
id id
} }
@@ -212,6 +220,7 @@ mod tests {
access: Access::ReadOnly, access: Access::ReadOnly,
changed: None, changed: None,
trouble: None, trouble: None,
named: Title::untitled(),
} }
} }
+85 -10
View File
@@ -13,6 +13,7 @@
//! - A document another cumulus has open is read-only here, until that one //! - A document another cumulus has open is read-only here, until that one
//! lets go of it. //! lets go of it.
use std::fs;
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -65,7 +66,7 @@ pub enum OpenError {
impl OpenError { impl OpenError {
/// The error with just the file's name, for the status bar. /// The error with just the file's name, for the status bar.
fn short(&self) -> String { pub(super) fn short(&self) -> String {
let (path, error) = match self { let (path, error) = match self {
OpenError::Read { path, error } => (path, error.to_string()), OpenError::Read { path, error } => (path, error.to_string()),
OpenError::Load { path, error } => (path, error.to_string()), OpenError::Load { path, error } => (path, error.to_string()),
@@ -112,6 +113,7 @@ pub(super) fn open_first(loaded: Loaded, paths: &Paths) -> (Documents, Option<Me
fn opened(loaded: Loaded, paths: &Paths) -> (Buffer, DocFile, Option<Message>) { fn opened(loaded: Loaded, paths: &Paths) -> (Buffer, DocFile, Option<Message>) {
let Loaded { doc, path, stamp } = loaded; let Loaded { doc, path, stamp } = loaded;
let name = short_name(&path); let name = short_name(&path);
let named = doc.title().clone();
let access = access(paths, doc.uid()); let access = access(paths, doc.uid());
let read_only = matches!(access, Access::ReadOnly); let read_only = matches!(access, Access::ReadOnly);
let said = read_only.then(|| Message::info(format!("{name} is open in another cumulus: read-only here"))); let said = read_only.then(|| Message::info(format!("{name} is open in another cumulus: read-only here")));
@@ -123,7 +125,7 @@ fn opened(loaded: Loaded, paths: &Paths) -> (Buffer, DocFile, Option<Message>) {
}); });
let buffer = Buffer::new(doc); let buffer = Buffer::new(doc);
let saved = buffer.history().state(); let saved = buffer.history().state();
(buffer, DocFile { path, stamp, saved, access, changed: None, trouble: None }, said) (buffer, DocFile { path, stamp, saved, access, changed: None, trouble: None, named }, said)
} }
/// Takes a document's lock, if it's free. /// Takes a document's lock, if it's free.
@@ -172,6 +174,8 @@ impl App {
let (buffer, file, said) = opened(loaded, &self.paths); let (buffer, file, said) = opened(loaded, &self.paths);
let id = self.shared.docs.add(buffer, file); let id = self.shared.docs.add(buffer, file);
self.came_back_to(id); self.came_back_to(id);
self.sync_backlinks(id);
self.refresh_portals(true);
(id, said) (id, said)
} }
@@ -286,9 +290,58 @@ impl App {
Stamp::of(&file.path).is_ok_and(|stamp| stamp != file.stamp) Stamp::of(&file.path).is_ok_and(|stamp| stamp != file.stamp)
} }
/// Writes a document to its file: the day's first save copies the old /// Saves a document: when its root's title changed since the file was
/// file to the backups first. /// last read or written, the file takes the new title's name first, and
/// after it's written, the documents its portals lead to get their
/// backlinks put right.
fn save(&mut self, id: BufferId) -> Result<(), Message> { fn save(&mut self, id: BufferId) -> Result<(), Message> {
let renamed = self.follow_title(id);
self.write_file(id)?;
if let Some((old, new)) = renamed {
self.renamed(id, &old, &new);
}
self.sync_backlinks(id);
Ok(())
}
/// When the root's title changed since the file was last read or
/// written, the file takes the new title's name, in the same folder
/// (with `-2`, `-3`… when that name is taken): "file names follow the
/// root's title". A folder's default document keeps its name. Returns
/// where the file was and where it is now.
fn follow_title(&mut self, id: BufferId) -> Option<(PathBuf, PathBuf)> {
let default = format!("{}.{}", self.settings.default_document, names::EXTENSION);
let open = self.shared.docs.get_mut(id);
let title = open.buffer.doc().title().clone();
if title == open.file.named || open.is_read_only() {
return None;
}
open.file.named = title.clone();
let old = open.file.path.clone();
let name = short_name(&old);
let wanted = file_name(title.as_str());
if name.eq_ignore_ascii_case(&default) || name.eq_ignore_ascii_case(&wanted) {
return None;
}
let new = free_path(old.parent()?, &wanted)?;
let moved = if open.file.stamp.is_some() { fs::rename(&old, &new) } else { Ok(()) };
match moved {
Ok(()) => {
open.file.path = new.clone();
Some((old, new))
}
// It's saved where it is.
Err(error) => {
let text = format!("couldn't rename {name} to {}: {error}", short_name(&new));
self.message = Some(Message::problem(text));
None
}
}
}
/// Writes a document to its file as it is: the day's first save copies
/// the old file to the backups first.
pub(super) fn write_file(&mut self, id: BufferId) -> Result<(), Message> {
let backups = self.paths.backups(); let backups = self.paths.backups();
let open = self.shared.docs.get_mut(id); let open = self.shared.docs.get_mut(id);
if open.is_read_only() { if open.is_read_only() {
@@ -331,6 +384,7 @@ impl App {
said.push(Message::info(format!("{}: you can edit it now", open.file_name()))); said.push(Message::info(format!("{}: you can edit it now", open.file_name())));
} }
} }
self.refresh_portals(true);
let changed = !said.is_empty(); let changed = !said.is_empty();
if let Some(message) = Message::joined(said) { if let Some(message) = Message::joined(said) {
self.message = Some(message); self.message = Some(message);
@@ -342,7 +396,7 @@ impl App {
/// its text is being edited or a node moved: the document stays as it /// its text is being edited or a node moved: the document stays as it
/// is under the edit, and the file is looked at again after. /// is under the edit, and the file is looked at again after.
fn look_at(&mut self, id: BufferId) -> Option<Message> { fn look_at(&mut self, id: BufferId) -> Option<Message> {
let busy = matches!(self.shared.mode, Mode::Text(_) | Mode::Move(_)) let busy = matches!(self.shared.mode, Mode::Text(_) | Mode::Move(_) | Mode::Connect(_))
|| self.shared.mode.any_edit().is_some(); || self.shared.mode.any_edit().is_some();
if busy && id == self.buffer() { if busy && id == self.buffer() {
return None; return None;
@@ -384,6 +438,9 @@ impl App {
fn take_in(&mut self, id: BufferId, newer: Document, said: &str) -> Message { fn take_in(&mut self, id: BufferId, newer: Document, said: &str) -> Message {
let open = self.shared.docs.get_mut(id); let open = self.shared.docs.get_mut(id);
let name = open.file_name(); let name = open.file_name();
// The file has the name it has: a title changed in it isn't a
// reason to rename it.
open.file.named = newer.title().clone();
let undoable = open.buffer.reload(newer); let undoable = open.buffer.reload(newer);
open.file.saved = open.buffer.history().state(); open.file.saved = open.buffer.history().state();
open.file.changed = None; open.file.changed = None;
@@ -462,7 +519,12 @@ impl App {
} }
names.push(self.shared.docs.get(id).file_name()); names.push(self.shared.docs.get(id).file_name());
} }
Some(Message::done(format!("saved {}", names.join(", ")))) let saved = format!("saved {}", names.join(", "));
// A file renamed as it was saved says so too.
Some(match self.message.take() {
Some(renamed) => Message::done(format!("{saved} · {}", renamed.text)),
None => Message::done(saved),
})
} }
/// `:e`: opens a document in the focused pane, or (with no path) reads /// `:e`: opens a document in the focused pane, or (with no path) reads
@@ -498,10 +560,10 @@ impl App {
Some(self.take_in(id, loaded.doc, "read {} again from the file · u undoes that")) Some(self.take_in(id, loaded.doc, "read {} again from the file · u undoes that"))
} }
/// A path typed in `:e` or `:new`: `~` is the home folder, relative paths /// A path typed in `:e`, `:new` or `:portal`: `~` is the home folder,
/// start at the focused document's folder, a folder means its default /// relative paths start at the focused document's folder, a folder means
/// document, and `.cum` is added when there's no extension. /// its default document, and `.cum` is added when there's no extension.
fn resolve_path(&self, typed: &str) -> PathBuf { pub(super) fn resolve_path(&self, typed: &str) -> PathBuf {
let path = self.paths.expand(typed.trim()); let path = self.paths.expand(typed.trim());
let path = if path.is_relative() { self.folder().join(path) } else { path }; let path = if path.is_relative() { self.folder().join(path) } else { path };
if path.is_dir() { if path.is_dir() {
@@ -557,8 +619,10 @@ impl App {
self.shared.docs.remove(id); self.shared.docs.remove(id);
let next = self.shared.docs.at(index.saturating_sub(1)); let next = self.shared.docs.at(index.saturating_sub(1));
let camera = self.shared.docs.get(next).camera; let camera = self.shared.docs.get(next).camera;
let uid = self.shared.docs.get(next).buffer.doc().uid();
for (_, pane) in self.panes.iter_mut().filter(|(_, pane)| pane.buffer == id) { for (_, pane) in self.panes.iter_mut().filter(|(_, pane)| pane.buffer == id) {
pane.show(next, pane.view.kind(), camera); pane.show(next, pane.view.kind(), camera);
pane.arrive(uid);
} }
Some(Message::done(format!("closed {name}"))) Some(Message::done(format!("closed {name}")))
} }
@@ -643,6 +707,7 @@ impl App {
} }
let camera = self.shared.docs.get(id).camera; let camera = self.shared.docs.get(id).camera;
pane.show(id, pane.view.kind(), camera); pane.show(id, pane.view.kind(), camera);
pane.arrive(self.shared.docs.get(id).buffer.doc().uid());
if let Mode::Visual(_) = self.shared.mode { if let Mode::Visual(_) = self.shared.mode {
self.shared.mode = Mode::Normal; self.shared.mode = Mode::Normal;
} }
@@ -655,6 +720,16 @@ impl App {
} }
} }
/// A free file name in `folder` for `wanted` (`garden-plans.cum`): itself,
/// or with `-2`, `-3`… before `.cum`.
fn free_path(folder: &Path, wanted: &str) -> Option<PathBuf> {
let stem = wanted.strip_suffix(&format!(".{}", names::EXTENSION)).unwrap_or(wanted);
(1..1000)
.map(|n| if n == 1 { wanted.to_string() } else { format!("{stem}-{n}.{}", names::EXTENSION) })
.map(|name| folder.join(name))
.find(|path| !path.exists())
}
fn read_only(open: &Open) -> Message { fn read_only(open: &Open) -> Message {
Message::problem(format!("{} is open in another cumulus: read-only here", open.file_name())) Message::problem(format!("{} is open in another cumulus: read-only here", open.file_name()))
} }
+27 -8
View File
@@ -6,7 +6,7 @@ use ratatui::layout::Position;
use super::action::{Action, AppAction, DocumentChoice, Message}; use super::action::{Action, AppAction, DocumentChoice, Message};
use super::mode::Mode; use super::mode::Mode;
use super::{App, calls, commands, visual}; use super::{App, calls, commands, connect, visual};
use crate::command::parse::{Sources, parse}; use crate::command::parse::{Sources, parse};
use crate::config::settings::Setting; use crate::config::settings::Setting;
use crate::config::theme_file; use crate::config::theme_file;
@@ -26,7 +26,7 @@ impl App {
Mode::Command(_) => self.command_line_key(key), Mode::Command(_) => self.command_line_key(key),
Mode::Text(_) => self.text_key(key), Mode::Text(_) => self.text_key(key),
Mode::Popup(_) => self.popup_key(key), Mode::Popup(_) => self.popup_key(key),
Mode::Normal | Mode::Visual(_) | Mode::Move(_) => { Mode::Normal | Mode::Visual(_) | Mode::Move(_) | Mode::Connect(_) => {
match self.keys.press(key, &self.keymap.tables(self.contexts())) { match self.keys.press(key, &self.keymap.tables(self.contexts())) {
Outcome::Run(typed) => self.run_typed(typed), Outcome::Run(typed) => self.run_typed(typed),
Outcome::Unknown(keys) => { Outcome::Unknown(keys) => {
@@ -37,10 +37,12 @@ impl App {
} }
} }
self.end_recording(); self.end_recording();
// Portals show the documents they lead to as they are now.
self.refresh_portals(false);
} }
/// A command: the focused view has the first say, then visual mode's /// A command: the focused view has the first say, then visual or
/// commands (in visual mode), then the shared commands. /// connect mode's commands (in those modes), then the shared commands.
pub(super) fn dispatch(&mut self, typed: Typed) { pub(super) fn dispatch(&mut self, typed: Typed) {
let selected = self.selection().unwrap_or_default(); let selected = self.selection().unwrap_or_default();
let pane = self.panes.current_mut(); let pane = self.panes.current_mut();
@@ -51,6 +53,7 @@ impl App {
.command(&typed, &ctx) .command(&typed, &ctx)
.or_else(|| match &self.shared.mode { .or_else(|| match &self.shared.mode {
Mode::Visual(visual) => visual::command(&typed, visual, &selected, kind, &ctx), Mode::Visual(visual) => visual::command(&typed, visual, &selected, kind, &ctx),
Mode::Connect(connect) => connect::command(&typed, connect, &ctx),
_ => None, _ => None,
}) })
.or_else(|| commands::shared(&typed, &ctx)); .or_else(|| commands::shared(&typed, &ctx));
@@ -169,7 +172,21 @@ impl App {
/// goes to the document bar or to the pane under it (a drag stays with /// goes to the document bar or to the pane under it (a drag stays with
/// the pane it started in), which takes the focus. /// the pane it started in), which takes the focus.
pub fn gesture(&mut self, gesture: Gesture) { pub fn gesture(&mut self, gesture: Gesture) {
self.route_gesture(gesture);
self.refresh_portals(false);
}
fn route_gesture(&mut self, gesture: Gesture) {
let at = |cell: Cell| Position::new(cell.col, cell.row); let at = |cell: Cell| Position::new(cell.col, cell.row);
// In connect mode a click only aims: no editing, no menu, no
// selecting.
let gesture = match (&self.shared.mode, gesture) {
(Mode::Connect(_), Gesture::RightPress { .. }) => return,
(Mode::Connect(_), Gesture::Press { at, .. } | Gesture::DoublePress { at }) => {
Gesture::Press { at, ctrl: false }
}
_ => gesture,
};
// A click drops a half-typed key sequence: a `d` typed before // A click drops a half-typed key sequence: a `d` typed before
// clicking somewhere else mustn't wait to delete what comes next. // clicking somewhere else mustn't wait to delete what comes next.
if matches!(gesture, Gesture::Press { .. } | Gesture::DoublePress { .. } | Gesture::RightPress { .. }) if matches!(gesture, Gesture::Press { .. } | Gesture::DoublePress { .. } | Gesture::RightPress { .. })
@@ -214,12 +231,14 @@ impl App {
self.dragging = Some(target); self.dragging = Some(target);
} }
if target != self.panes.focused() { if target != self.panes.focused() {
// Another pane: a text edited in this one is done first, and // Another pane: a text edited in this one is done first, move or
// move or visual mode ends. // visual mode ends, and connect mode is cancelled.
let ctx = self.shared.ctx(self.buffer()); let ctx = self.shared.ctx(self.buffer());
let mut actions = self.shared.mode.edit().map(|edit| edit.finish(&ctx)).unwrap_or_default(); let mut actions = self.shared.mode.edit().map(|edit| edit.finish(&ctx)).unwrap_or_default();
if let Mode::Move(_) | Mode::Visual(_) = self.shared.mode { match &self.shared.mode {
actions.push(Action::App(AppAction::LeaveMode)); Mode::Move(_) | Mode::Visual(_) => actions.push(Action::App(AppAction::LeaveMode)),
Mode::Connect(connecting) => actions.extend(connect::cancel(connecting)),
_ => {}
} }
self.update(actions); self.update(actions);
self.panes.focus(target); self.panes.focus(target);
+9 -1
View File
@@ -4,7 +4,7 @@
//! Each pane has its own; a new pane starts with a copy of its first //! Each pane has its own; a new pane starts with a copy of its first
//! pane's. A place in another document opens it, even after it was closed. //! pane's. A place in another document opens it, even after it was closed.
use std::path::PathBuf; use std::path::{Path, PathBuf};
use super::action::{DocAction, Message}; use super::action::{DocAction, Message};
use super::{App, files}; use super::{App, files};
@@ -65,6 +65,14 @@ impl Jumps {
self.places.get(to).cloned() self.places.get(to).cloned()
} }
/// A document's file moved (its name followed its title): its places
/// find it at `path`.
pub fn moved(&mut self, uid: DocUid, path: &Path) {
for place in self.places.iter_mut().filter(|place| place.uid == uid) {
place.path = path.to_path_buf();
}
}
/// The places, oldest first, and which one `Ctrl-o` and `Ctrl-i` are on /// The places, oldest first, and which one `Ctrl-o` and `Ctrl-i` are on
/// (none until they're used), for `:jumps`. /// (none until they're used), for `:jumps`.
pub fn list(&self) -> (&[Place], Option<usize>) { pub fn list(&self) -> (&[Place], Option<usize>) {
+52 -12
View File
@@ -24,14 +24,16 @@ pub mod calls;
mod clipboard; mod clipboard;
pub mod commands; pub mod commands;
mod configure; mod configure;
pub mod connect;
pub mod documents; pub mod documents;
mod files; mod files;
mod input; mod input;
mod jumps; pub mod jumps;
mod marks; mod marks;
pub mod mode; pub mod mode;
pub mod panes; pub mod panes;
pub mod popup; pub mod popup;
pub mod portals;
pub mod registers; pub mod registers;
mod render; mod render;
mod repeat; mod repeat;
@@ -43,7 +45,7 @@ mod update;
pub mod visual; pub mod visual;
mod yank; mod yank;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::time::Instant; use std::time::Instant;
use ratatui::layout::Rect; use ratatui::layout::Rect;
@@ -59,6 +61,7 @@ use crate::input::bindings::Context;
use crate::input::engine::KeyEngine; use crate::input::engine::KeyEngine;
use crate::input::keymap::Keymap; use crate::input::keymap::Keymap;
use crate::input::mouse::MouseReader; use crate::input::mouse::MouseReader;
use crate::model::DocUid;
use crate::storage::memory::Memory; use crate::storage::memory::Memory;
use crate::ui::theme::Theme; use crate::ui::theme::Theme;
use crate::view::canvas::{Camera, CanvasView}; use crate::view::canvas::{Camera, CanvasView};
@@ -71,6 +74,7 @@ use documents::{BufferId, Documents};
use jumps::Jumps; use jumps::Jumps;
use mode::Mode; use mode::Mode;
use panes::{PaneId, Panes}; use panes::{PaneId, Panes};
use portals::Portals;
use registers::Registers; use registers::Registers;
use startup::Opening; use startup::Opening;
@@ -121,18 +125,28 @@ struct Shared {
mode: Mode, mode: Mode,
theme: Theme, theme: Theme,
view: ViewSettings, view: ViewSettings,
/// What the documents portals lead to look like.
portals: Portals,
} }
impl Shared { impl Shared {
/// What a view showing document `buffer` looks at. /// What a view showing document `buffer` looks at.
fn ctx(&self, buffer: BufferId) -> ViewContext<'_> { fn ctx(&self, buffer: BufferId) -> ViewContext<'_> {
let buffer = &self.docs.get(buffer).buffer; self.ctx_in(buffer, &self.mode)
}
/// What a view showing document `buffer` looks at, in `mode` (the panes
/// without the focus are drawn as in normal mode).
fn ctx_in<'a>(&'a self, buffer: BufferId, mode: &'a Mode) -> ViewContext<'a> {
let open = self.docs.get(buffer);
ViewContext { ViewContext {
doc: buffer.doc(), doc: open.buffer.doc(),
cursor: buffer.cursor(), cursor: open.buffer.cursor(),
mode: &self.mode, mode,
theme: &self.theme, theme: &self.theme,
settings: &self.view, settings: &self.view,
portals: &self.portals,
folder: open.file.path.parent().unwrap_or(Path::new(".")),
} }
} }
@@ -142,11 +156,23 @@ impl Shared {
} }
} }
/// A pane: one of the documents, through a view, and where it jumped from. /// A pane: one of the documents, through a view, where it jumped from, and
/// the portals it came through.
struct Pane { struct Pane {
buffer: BufferId, buffer: BufferId,
view: Box<dyn View>, view: Box<dyn View>,
jumps: Jumps, jumps: Jumps,
/// The documents the pane came to this one through, by portals, the
/// first one first: the breadcrumb starts with them.
trail: Vec<Crumb>,
}
/// A document a pane came through, by a portal.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Crumb {
pub uid: DocUid,
/// Its name then.
pub title: String,
} }
impl Pane { impl Pane {
@@ -160,14 +186,24 @@ impl Pane {
if let Some(camera) = camera { if let Some(camera) = camera {
view.set_camera(camera); view.set_camera(camera);
} }
Pane { buffer, view, jumps: Jumps::default() } Pane { buffer, view, jumps: Jumps::default(), trail: Vec::new() }
} }
/// The pane shows `buffer` in a new view (a canvas starting at `camera`); /// The pane shows `buffer` in a new view (a canvas starting at `camera`);
/// its jump list stays. /// its jump list and its trail stay.
fn show(&mut self, buffer: BufferId, kind: ViewKind, camera: Option<Camera>) { fn show(&mut self, buffer: BufferId, kind: ViewKind, camera: Option<Camera>) {
let jumps = std::mem::take(&mut self.jumps); let (jumps, trail) = (std::mem::take(&mut self.jumps), std::mem::take(&mut self.trail));
*self = Pane { jumps, ..Pane::new(buffer, kind, camera) }; *self = Pane { jumps, trail, ..Pane::new(buffer, kind, camera) };
}
/// The pane got to document `uid` another way than a portal: if it came
/// through that one, the trail goes back to it; otherwise it starts
/// again.
fn arrive(&mut self, uid: DocUid) {
match self.trail.iter().position(|crumb| crumb.uid == uid) {
Some(at) => self.trail.truncate(at),
None => self.trail.clear(),
}
} }
} }
@@ -193,7 +229,7 @@ impl App {
let first = docs.at(0); let first = docs.at(0);
let mut app = App { let mut app = App {
panes: Panes::new(Pane::new(first, ViewKind::Canvas, None)), panes: Panes::new(Pane::new(first, ViewKind::Canvas, None)),
shared: Shared { docs, mode: Mode::Normal, theme, view }, shared: Shared { docs, mode: Mode::Normal, theme, view, portals: Portals::default() },
command_history: CommandHistory::from_lines(memory.history.clone()), command_history: CommandHistory::from_lines(memory.history.clone()),
paths, paths,
settings, settings,
@@ -223,6 +259,10 @@ impl App {
for loaded in others { for loaded in others {
said.extend(app.add_document(loaded).1); said.extend(app.add_document(loaded).1);
} }
// Once every document named is open, so one that's about to open
// isn't written underneath it.
app.sync_backlinks(first);
app.refresh_portals(true);
app.message = Message::joined(said); app.message = Message::joined(said);
app app
} }
+24
View File
@@ -22,6 +22,9 @@ pub enum Mode {
Visual(Visual), Visual(Visual),
/// Moving the cursor's node (or its branch) with hjkl. /// Moving the cursor's node (or its branch) with hjkl.
Move(Grab), Move(Grab),
/// Linking nodes: the cursor goes to another node, the line to it shows,
/// and Enter makes it.
Connect(Connect),
/// Typing a `:` command. /// Typing a `:` command.
Command(CommandLine), Command(CommandLine),
/// A popup has the keys: the help, the menu, a picker or the `K` card /// A popup has the keys: the help, the menu, a picker or the `K` card
@@ -53,6 +56,26 @@ impl Visual {
} }
} }
/// Connect mode's state (see [`connect`](super::connect)).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Connect {
/// The nodes being connected: the cursor's when `C` was typed, or a
/// selection's. The cursor is the other end.
pub sources: Vec<NodeId>,
/// Where the cursor goes back to when it's done.
pub start: NodeId,
/// Everything connect mode changes is one undo step, which Esc takes
/// back.
pub group: Group,
}
impl Connect {
/// Connecting `sources`, starting from the cursor at `start`.
pub fn new(sources: Vec<NodeId>, start: NodeId) -> Connect {
Connect { sources, start, group: Group::new() }
}
}
/// Move mode's state. /// Move mode's state.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Grab { pub struct Grab {
@@ -122,6 +145,7 @@ impl Mode {
Mode::Visual(Visual { branches: false, .. }) => "VISUAL", Mode::Visual(Visual { branches: false, .. }) => "VISUAL",
Mode::Visual(_) => "VISUAL BRANCH", Mode::Visual(_) => "VISUAL BRANCH",
Mode::Move(_) => "MOVE", Mode::Move(_) => "MOVE",
Mode::Connect(_) => "CONNECT",
Mode::Command(_) => "COMMAND", Mode::Command(_) => "COMMAND",
Mode::Popup(popup) => popup.label(), Mode::Popup(popup) => popup.label(),
} }
+186 -6
View File
@@ -19,8 +19,10 @@ use unicode_width::UnicodeWidthStr;
use super::action::{Action, AppAction, CursorAfter, DocAction, DocumentChoice, Keep, Message}; use super::action::{Action, AppAction, CursorAfter, DocAction, DocumentChoice, Keep, Message};
use super::commands; use super::commands;
use super::connect;
use super::documents::BufferId; use super::documents::BufferId;
use super::jumps::Place as JumpPlace; use super::jumps::Place as JumpPlace;
use super::mode::Connect;
use super::registers::Registers; use super::registers::Registers;
use super::repeat::Look; use super::repeat::Look;
use super::search::Search; use super::search::Search;
@@ -35,7 +37,8 @@ use crate::input::key::{Key, notation};
use crate::input::keymap::{Keymap, Tables}; use crate::input::keymap::{Keymap, Tables};
use crate::input::mouse::{Cell, Gesture}; use crate::input::mouse::{Cell, Gesture};
use crate::model::{ use crate::model::{
Border, CanvasPos, ColorName, Content, Direction, EditOp, Group, NodeId, StyleName, Tag, Tags, Border, CanvasPos, ColorName, Content, Direction, EditOp, Group, Label, LineColor, LineRef, NodeId,
StyleName, Tag, Tags,
}; };
use crate::ui::card::{CardText, CardView}; use crate::ui::card::{CardText, CardView};
use crate::ui::list::{ListOutcome, ListPopup, ListRow, Place}; use crate::ui::list::{ListOutcome, ListPopup, ListRow, Place};
@@ -84,6 +87,16 @@ pub enum PickKind {
Tags, Tags,
/// The document the pane shows. /// The document the pane shows.
Document, Document,
/// For the lines connect mode is making.
Line(LinePick),
}
/// What a picker for connect mode's lines picks.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinePick {
Color,
Style,
Label,
} }
/// A row of the documents picker: an open document, or one opened lately. /// A row of the documents picker: an open document, or one opened lately.
@@ -134,6 +147,9 @@ pub struct Picker {
tags: Tags, tags: Tags,
/// Makes the changes to several nodes one undo step. /// Makes the changes to several nodes one undo step.
group: Group, group: Group,
/// A picker connect mode opened: the mode to go back to, which knows
/// the lines the picker styles.
back: Option<Connect>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -146,6 +162,9 @@ enum Pick {
Document(usize), Document(usize),
/// A document opened lately, by its file. /// A document opened lately, by its file.
Recent(PathBuf), Recent(PathBuf),
LineColor(Option<LineColor>),
LineStyle(Option<StyleName>),
Label(Option<Label>),
/// A heading over the rows after it. /// A heading over the rows after it.
Heading, Heading,
} }
@@ -422,6 +441,8 @@ impl Popup {
} }
("documents".to_string(), values, rows) ("documents".to_string(), values, rows)
} }
// Those pick for a line, and connect mode opens them.
PickKind::Line(_) => return Err(Message::info("C first: line pickers work in connect mode")),
}; };
let footer = match kind { let footer = match kind {
PickKind::Tags => "Enter: on or off · / then a new one: add it", PickKind::Tags => "Enter: on or off · / then a new one: add it",
@@ -433,7 +454,116 @@ impl Popup {
if let Some(index) = values.iter().position(|value| picked(value, node, &tags, current)) { if let Some(index) = values.iter().position(|value| picked(value, node, &tags, current)) {
list.select(index); list.select(index);
} }
Ok(Popup::Pick(Picker { list, nodes: nodes.to_vec(), values, tags, group: Group::new() })) Ok(Popup::Pick(Picker { list, nodes: nodes.to_vec(), values, tags, group: Group::new(), back: None }))
}
/// A picker for the lines connect mode is making or styling: from each
/// node being connected to the cursor's. It starts on what the first
/// line has; picking, or Esc, goes back to connect mode. The label
/// picker takes typing at once: a label it doesn't list is a new one.
pub fn line_picker(what: LinePick, ctx: &ViewContext<'_>, connect: &Connect) -> Popup {
let (doc, theme, glyphs) = (ctx.doc, ctx.theme, ctx.theme.glyphs);
let muted = Style::new().fg(theme.muted);
let line = connect::first_line(connect, ctx);
let look = line.and_then(|line| doc.line_look(line)).cloned().unwrap_or_default();
let label = match line {
Some(LineRef::Link(key)) => doc.link(key).and_then(|link| link.label.clone()),
_ => None,
};
let (name, values) = match what {
LinePick::Color => {
let mut values = vec![Pick::LineColor(None), Pick::LineColor(Some(LineColor::FollowNode))];
let named =
theme.palette.keys().cloned().map(|name| Pick::LineColor(Some(LineColor::Named(name))));
values.extend(named);
("line color", values)
}
LinePick::Style => {
let mut values = vec![Pick::LineStyle(None)];
values.extend(theme.line_styles.keys().cloned().map(|name| Pick::LineStyle(Some(name))));
("line style", values)
}
LinePick::Label => {
// The labels links have here, and the line styles' own.
let mut labels: Vec<Label> = doc.links().filter_map(|link| link.label.clone()).collect();
labels.extend(theme.line_styles.values().filter_map(|named| named.label.clone()));
labels.sort();
labels.dedup();
let mut values = vec![Pick::Label(None)];
values.extend(labels.into_iter().map(|label| Pick::Label(Some(label))));
("label", values)
}
};
let rows = values
.iter()
.map(|value| {
let (spans, search, on) = match value {
Pick::LineColor(None) => (
vec![Span::styled("theme default", muted)],
"theme default".to_string(),
look.color.is_none(),
),
Pick::LineColor(Some(LineColor::FollowNode)) => (
vec![Span::raw("the node's own color")],
"node own".to_string(),
look.color == Some(LineColor::FollowNode),
),
Pick::LineColor(Some(LineColor::Named(name))) => {
let swatch = Style::new().fg(theme.color(name));
let spans = vec![
Span::styled(format!("{} ", glyphs.bullet()), swatch),
Span::raw(name.to_string()),
];
(spans, name.to_string(), look.color == Some(LineColor::Named(name.clone())))
}
Pick::LineStyle(None) => (
vec![Span::styled("no style", muted)],
"no style none".to_string(),
look.style.is_none(),
),
Pick::LineStyle(Some(name)) => (
vec![Span::raw(name.to_string())],
name.to_string(),
look.style.as_ref() == Some(name),
),
Pick::Label(None) => {
(vec![Span::styled("no label", muted)], "no label none".to_string(), label.is_none())
}
Pick::Label(Some(text)) => {
(vec![Span::raw(text.to_string())], text.to_string(), label.as_ref() == Some(text))
}
_ => (Vec::new(), String::new(), false),
};
checked(ListRow::new(spans, search), on, theme)
})
.collect::<Vec<_>>();
let start = values.iter().position(|value| match value {
Pick::LineColor(color) => look.color == *color,
Pick::LineStyle(style) => look.style == *style,
Pick::Label(text) => label == *text,
_ => false,
});
let title = format!("{name} · {}", connect::ends(connect, ctx));
let footer = match what {
LinePick::Label => "type a new one, or pick · Enter: use it · Esc: back",
LinePick::Color | LinePick::Style => "Enter: pick · Esc: back to connecting",
};
let mut list = ListPopup::new(title, footer, rows, Place::Center { width: PICKER_WIDTH });
if let Some(index) = start {
list.select(index);
}
if what == LinePick::Label {
list = list.typing_first();
}
let back = Some(connect.clone());
Popup::Pick(Picker {
list,
nodes: Vec::new(),
values,
tags: Tags::default(),
group: connect.group,
back,
})
} }
/// `:marks`: this document's marks, then the capital ones. Enter goes /// `:marks`: this document's marks, then the capital ones. Enter goes
@@ -540,6 +670,24 @@ impl Popup {
Popup::Menu(Runs { list, runs }) Popup::Menu(Runs { list, runs })
} }
/// `gu` with several portals leading here, and `:backlinks`: the
/// documents they're in (title, then path). Enter goes there, on the
/// portal.
pub fn backlinks(ups: &[(String, String, JumpPlace)], theme: &Theme) -> Popup {
let muted = Style::new().fg(theme.muted);
let mark = theme.glyphs.backlink();
let (mut rows, mut runs) = (Vec::new(), Vec::new());
for (title, path, place) in ups {
let spans = vec![Span::raw(format!("{mark} {title}")), Span::styled(format!(" {path}"), muted)];
rows.push(ListRow::new(spans, title.as_str()));
runs.push(Run::App(AppAction::GoToPlace(place.clone())));
}
let footer = "Enter: go there, on its portal · Esc: close";
let list =
ListPopup::new("portals that lead here", footer, rows, Place::Center { width: CONFLICT_WIDTH });
Popup::Menu(Runs { list, runs })
}
/// The `K` card for a node. /// The `K` card for a node.
pub fn card(node: NodeId) -> Popup { pub fn card(node: NodeId) -> Popup {
Popup::Card(Card { node, edit: None, view: CardView::default() }) Popup::Card(Card { node, edit: None, view: CardView::default() })
@@ -715,12 +863,16 @@ impl Popup {
fn outcome(&mut self, outcome: ListOutcome, ctx: &ViewContext<'_>) -> Vec<Action> { fn outcome(&mut self, outcome: ListOutcome, ctx: &ViewContext<'_>) -> Vec<Action> {
match (outcome, self) { match (outcome, self) {
(ListOutcome::Stay, _) => Vec::new(), (ListOutcome::Stay, _) => Vec::new(),
// A picker connect mode opened goes back to it.
(ListOutcome::Close, Popup::Pick(Picker { back: Some(connect), .. })) => {
vec![Action::App(AppAction::ResumeConnect(connect.clone()))]
}
(ListOutcome::Close, _) => leave(), (ListOutcome::Close, _) => leave(),
(ListOutcome::Picked(index), Popup::Help(runs) | Popup::Menu(runs) | Popup::Ask(runs)) => { (ListOutcome::Picked(index), Popup::Help(runs) | Popup::Menu(runs) | Popup::Ask(runs)) => {
runs.run(index, ctx) runs.run(index, ctx)
} }
(ListOutcome::Picked(index), Popup::Pick(picker)) => picker.pick(index, ctx), (ListOutcome::Picked(index), Popup::Pick(picker)) => picker.pick(index, ctx),
(ListOutcome::NoMatch(text), Popup::Pick(picker)) => picker.add_tag(&text, ctx), (ListOutcome::NoMatch(text), Popup::Pick(picker)) => picker.add_new(&text, ctx),
(ListOutcome::Picked(_) | ListOutcome::NoMatch(_), _) => Vec::new(), (ListOutcome::Picked(_) | ListOutcome::NoMatch(_), _) => Vec::new(),
} }
} }
@@ -825,7 +977,7 @@ fn picked(value: &Pick, node: &crate::model::Node, tags: &Tags, current: usize)
Pick::Style(style) => node.look.style == *style, Pick::Style(style) => node.look.style == *style,
Pick::Tag(tag) => tags.contains(tag), Pick::Tag(tag) => tags.contains(tag),
Pick::Document(index) => *index == current, Pick::Document(index) => *index == current,
Pick::Recent(_) | Pick::Heading => false, Pick::Recent(_) | Pick::Heading | Pick::LineColor(_) | Pick::LineStyle(_) | Pick::Label(_) => false,
} }
} }
@@ -888,6 +1040,9 @@ impl Picker {
self.list.set_row(index, tag_row(&tag, on, ctx.theme)); self.list.set_row(index, tag_row(&tag, on, ctx.theme));
return self.set(Look::Tag { tag, on }); return self.set(Look::Tag { tag, on });
} }
Some(value @ (Pick::LineColor(_) | Pick::LineStyle(_) | Pick::Label(_))) => {
return self.set_line(value, ctx);
}
Some(Pick::Color(color)) => Look::Color(color.clone()), Some(Pick::Color(color)) => Look::Color(color.clone()),
Some(Pick::Border(border)) => Look::Border(*border), Some(Pick::Border(border)) => Look::Border(*border),
Some(Pick::Style(style)) => Look::Style(style.clone()), Some(Pick::Style(style)) => Look::Style(style.clone()),
@@ -911,8 +1066,28 @@ impl Picker {
Action::Doc(DocAction::Edit { op: Box::new(op), cursor: CursorAfter::Stay, group }) Action::Doc(DocAction::Edit { op: Box::new(op), cursor: CursorAfter::Stay, group })
} }
/// Enter on a search that matches no tag: a new tag, put on the node. /// A value for the lines connect mode is making, then back to it.
fn add_tag(&mut self, text: &str, ctx: &ViewContext<'_>) -> Vec<Action> { fn set_line(&self, value: &Pick, ctx: &ViewContext<'_>) -> Vec<Action> {
let Some(connect) = &self.back else { return Vec::new() };
let mut actions = match value {
Pick::LineColor(color) => connect::restyle(connect, ctx, |look| look.color = color.clone()),
Pick::LineStyle(style) => connect::restyle(connect, ctx, |look| look.style = style.clone()),
Pick::Label(label) => connect::relabel(connect, ctx, label),
_ => Vec::new(),
};
actions.push(Action::App(AppAction::ResumeConnect(connect.clone())));
actions
}
/// Enter on a search that matches nothing: in the tags picker, a new
/// tag, put on the node; in the label picker, a new label.
fn add_new(&mut self, text: &str, ctx: &ViewContext<'_>) -> Vec<Action> {
if self.values.iter().any(|value| matches!(value, Pick::Label(_))) {
return match Label::parse(text) {
Ok(label) => self.set_line(&Pick::Label(Some(label)), ctx),
Err(error) => vec![Action::say(Message::problem(error.to_string()))],
};
}
if !self.values.iter().all(|value| matches!(value, Pick::Tag(_))) { if !self.values.iter().all(|value| matches!(value, Pick::Tag(_))) {
return Vec::new(); return Vec::new();
} }
@@ -929,6 +1104,11 @@ impl Picker {
} }
} }
/// A row with a check mark when it's what's there now.
fn checked(row: ListRow, on: bool, theme: &Theme) -> ListRow {
if on { row.with_note(Span::styled("✓", Style::new().fg(theme.accent))) } else { row }
}
/// A row of the tags picker, checked when the node has the tag. /// A row of the tags picker, checked when the node has the tag.
fn tag_row(tag: &Tag, on: bool, theme: &Theme) -> ListRow { fn tag_row(tag: &Tag, on: bool, theme: &Theme) -> ListRow {
let row = ListRow::new(vec![Span::raw(tag.to_string())], tag.name()); let row = ListRow::new(vec![Span::raw(tag.to_string())], tag.name());
+546
View File
@@ -0,0 +1,546 @@
//! Portals between documents (DESIGN.md, "Portals"): a portal node leads to
//! another document's root, and shows its title, color and how many nodes
//! it has, live.
//!
//! - [`Portals`] remembers what each document file holds, for the portals
//! and backlinks leading to it: the open documents as they are now, the
//! others as read from disk, looked at again every second.
//! - Enter or `gf` goes through a portal, as a jump (`Ctrl-o` comes back),
//! to the other document's root; the pane's breadcrumb then starts with
//! the documents it came through.
//! - Each document keeps the portals that lead to it, its backlinks. When a
//! document is opened or saved, the documents its portals lead to get
//! theirs put right. `gu` goes up to them.
//! - When a root's title changes, the next save renames its file
//! (`files.rs`), and the portals and backlinks pointing at it follow.
//! - A portal whose file isn't where it says looks for its document in that
//! folder, by uid, and keeps the path it finds.
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use super::action::{CursorAfter, DocAction, Message};
use super::documents::BufferId;
use super::files::load;
use super::jumps::Place;
use super::mode::Mode;
use super::popup::Popup;
use super::{App, Crumb};
use crate::model::{Backlink, Content, DocUid, Document, EditOp, Node, NodeId, Portal, Spot, Title};
use crate::storage::backup::{self, Day};
use crate::storage::document_file;
use crate::storage::link_paths::{relative, resolve};
use crate::storage::lock::Lock;
use crate::storage::names::{self, file_name, title_from_file};
use crate::storage::stamp::Stamp;
use crate::storage::write_atomically;
use crate::view::canvas::placement::child_spot;
/// A document as its portals show it: its uid, its root (the title and the
/// look), and how many nodes it has.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Seen {
pub uid: DocUid,
pub root: Node,
pub count: usize,
}
impl Seen {
fn of(doc: &Document) -> Option<Seen> {
Some(Seen { uid: doc.uid(), root: doc.node(doc.root())?.clone(), count: doc.node_count() })
}
/// The document's name: its root's title.
pub fn title(&self) -> &Title {
self.root.title()
}
}
/// What a portal shows of its document.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Shown<'a> {
/// The document, as it is now.
Live(&'a Seen),
/// No document at its path: the file is gone, or isn't a document.
Missing,
/// Another document is at its path now.
Other(&'a Seen),
/// Not looked at yet.
Unknown,
}
/// What each document file holds, for the portals and backlinks that lead
/// to it.
#[derive(Debug, Default)]
pub struct Portals {
files: BTreeMap<PathBuf, Looked>,
}
/// A file as it was last looked at: its stamp then (`None` for an open
/// document, which is looked at in its buffer instead), and the document
/// in it, if there's one.
#[derive(Debug, Clone)]
struct Looked {
stamp: Option<Stamp>,
seen: Option<Seen>,
}
impl Portals {
/// What a portal in a document in `folder` shows.
pub fn shown(&self, folder: &Path, portal: &Portal) -> Shown<'_> {
match self.files.get(&resolve(folder, &portal.path)) {
None => Shown::Unknown,
Some(Looked { seen: Some(seen), .. }) if seen.uid == portal.uid => Shown::Live(seen),
Some(Looked { seen: Some(seen), .. }) => Shown::Other(seen),
Some(Looked { seen: None, .. }) => Shown::Missing,
}
}
/// What the file at `path` holds, if it's been looked at.
pub fn seen(&self, path: &Path) -> Option<&Seen> {
self.files.get(path)?.seen.as_ref()
}
/// An open document, as it is now.
fn put(&mut self, path: &Path, doc: &Document) {
self.files.insert(path.to_path_buf(), Looked { stamp: None, seen: Seen::of(doc) });
}
/// The file at `path`, read again if it changed since it was read.
fn look(&mut self, path: &Path) {
let Ok(stamp) = Stamp::of(path) else { return };
if self.files.get(path).is_some_and(|looked| looked.stamp.is_some() && looked.stamp == stamp) {
return;
}
let seen = stamp.and_then(|_| read(path).ok()).and_then(|doc| Seen::of(&doc));
self.files.insert(path.to_path_buf(), Looked { stamp, seen });
}
/// A file that isn't there anymore (renamed).
fn forget(&mut self, path: &Path) {
self.files.remove(path);
}
}
/// The folder a document file is in.
pub fn folder_of(path: &Path) -> PathBuf {
path.parent().map_or_else(|| PathBuf::from("."), Path::to_path_buf)
}
/// The document in a file.
fn read(path: &Path) -> Result<Document, String> {
let text = fs::read_to_string(path).map_err(|error| error.to_string())?;
document_file::parse(&text).map_err(|error| error.to_string())
}
/// The document with this uid among a folder's `.cum` files: one moved or
/// renamed where its portals couldn't follow (by hand, say).
fn find_uid(folder: &Path, uid: DocUid) -> Option<PathBuf> {
fs::read_dir(folder)
.ok()?
.flatten()
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|extension| extension == names::EXTENSION))
.find(|path| read(path).is_ok_and(|doc| doc.uid() == uid))
}
/// The file's name, as its folder spells it: on Windows `Kitchen.cum` finds
/// `kitchen.cum`, and the portal should keep the name that works on Linux
/// too.
fn spelled(path: &Path) -> PathBuf {
let (Some(folder), Some(name)) = (path.parent(), path.file_name()) else { return path.to_path_buf() };
let name = name.to_string_lossy().to_lowercase();
fs::read_dir(folder)
.ok()
.and_then(|entries| {
entries.flatten().map(|entry| entry.path()).find(|found| {
found.file_name().is_some_and(|found| found.to_string_lossy().to_lowercase() == name)
})
})
.unwrap_or_else(|| path.to_path_buf())
}
/// Whether two paths name the same file: Windows ignores case in file
/// names, Linux doesn't.
fn same_file(a: &Path, b: &Path) -> bool {
if cfg!(windows) {
a.to_string_lossy().to_lowercase() == b.to_string_lossy().to_lowercase()
} else {
a == b
}
}
/// A file's name, for messages.
fn name_of(path: &Path) -> String {
path.file_name().map_or_else(|| path.display().to_string(), |name| name.to_string_lossy().into_owned())
}
impl App {
/// Brings the portals up to date: what their documents hold now (the
/// open ones from their buffers; with `read`, the others from their
/// files), and the title each portal remembers in case its file goes.
pub(super) fn refresh_portals(&mut self, read: bool) {
let shared = &mut self.shared;
for open in shared.docs.iter() {
shared.portals.put(&open.file.path, open.buffer.doc());
}
if read {
let mut wanted = BTreeSet::new();
for open in shared.docs.iter() {
let (folder, doc) = (folder_of(&open.file.path), open.buffer.doc());
wanted.extend(doc.portals().map(|(_, portal)| resolve(&folder, &portal.path)));
wanted.extend(doc.backlinks().iter().map(|backlink| resolve(&folder, &backlink.from)));
}
for path in wanted {
if shared.docs.find_path(&path).is_none() {
shared.portals.look(&path);
}
}
}
for open in shared.docs.iter_mut() {
let folder = folder_of(&open.file.path);
let titles: Vec<(NodeId, Portal)> = open
.buffer
.doc()
.portals()
.filter_map(|(id, portal)| match shared.portals.shown(&folder, portal) {
Shown::Live(seen) if *seen.title() != portal.title => {
Some((id, Portal { title: seen.title().clone(), ..portal.clone() }))
}
_ => None,
})
.collect();
for (id, portal) in titles {
open.buffer.set_portal(id, portal);
}
}
}
/// Enter or `gf` on a portal: its document shows in the pane, on its
/// root. That's a jump (`Ctrl-o` comes back), and the pane's breadcrumb
/// starts with the documents it came through.
pub(super) fn follow_portal(&mut self, id: NodeId) -> Option<Message> {
let here = self.buffer();
let open = self.shared.docs.get(here);
let Some(Content::Portal(portal)) = open.buffer.doc().node(id).map(|node| &node.content) else {
return Some(Message::info("this node isn't a portal: :portal makes one"));
};
let portal = portal.clone();
let from = Crumb { uid: open.buffer.doc().uid(), title: open.name() };
let (target, said) = match self.open_portal(here, &portal) {
Ok(found) => found,
Err(problem) => return Some(problem),
};
// A portal to its own document (made by hand) just goes to the root.
if target == here {
let root = self.current().doc().root();
return self.doc_action(DocAction::MoveCursor { to: root, jump: true });
}
let mut trail = self.panes.current().trail.clone();
self.show(target);
// The pane came through `from`; going back up one it came through
// takes the trail back there.
let uid = self.current().doc().uid();
match trail.iter().position(|crumb| crumb.uid == uid) {
Some(at) => trail.truncate(at),
None => trail.push(from),
}
self.panes.current_mut().trail = trail;
let root = self.current().doc().root();
self.doc_action(DocAction::MoveCursor { to: root, jump: false });
said
}
/// The open document a portal leads to, opened if it isn't yet: by its
/// uid among the open ones, then at its path, then (moved or renamed
/// where the portal couldn't follow) in that folder by its uid, a path
/// the portal keeps. Also what to say about opening it.
fn open_portal(
&mut self,
here: BufferId,
portal: &Portal,
) -> Result<(BufferId, Option<Message>), Message> {
if let Some(open) = self.shared.docs.find_uid(portal.uid) {
return Ok((open, None));
}
let path = resolve(&folder_of(&self.shared.docs.get(here).file.path), &portal.path);
let found = match read(&path) {
Ok(doc) if doc.uid() == portal.uid => path,
other => {
let lost = match other {
Ok(doc) => format!("{} holds another document now (\"{}\")", name_of(&path), doc.title()),
Err(_) if !path.exists() => format!("{} isn't there anymore", name_of(&path)),
Err(error) => format!("{} can't be read: {error}", name_of(&path)),
};
let moved = path.parent().and_then(|folder| find_uid(folder, portal.uid));
let Some(moved) = moved else { return Err(Message::problem(lost)) };
self.repath(here, portal.uid, &moved);
moved
}
};
let loaded = load(&found).map_err(|error| Message::problem(error.short()))?;
Ok(self.add_document(loaded))
}
/// `:portal`, which `<Space>p` types: a portal under the cursor's node,
/// where `a` adds a child, to the document at the path typed (relative
/// to this document's folder, as `:e` takes it). When nothing's there,
/// what was typed is a new document's title: it's made next to this one,
/// as `:new` makes one (with a `/` in it, it's made at that path).
pub(super) fn new_portal(&mut self, typed: &str) -> Option<Message> {
if let Some(refused) = self.refuse_read_only() {
return Some(refused);
}
let typed = typed.trim();
let folder = self.folder();
let path = self.resolve_path(typed);
let by_title = folder.join(file_name(typed));
let slashed = typed.contains(['/', '\\']);
// The document's file, and the title of a new one to make there.
let (path, new) = if path.exists() {
(spelled(&path), None)
} else if !slashed && by_title.exists() {
(spelled(&by_title), None)
} else if slashed {
let title = title_from_file(&path);
(path, Some(title))
} else {
match Title::parse(typed) {
Ok(title) => (by_title, Some(title)),
Err(error) => return Some(Message::problem(error.to_string())),
}
};
if same_file(&path, &self.shared.docs.get(self.buffer()).file.path) {
return Some(Message::problem("a portal can't lead to its own document"));
}
let made = new.is_some();
if let Some(title) = new
&& let Err(error) = super::files::create(&path, title)
{
return Some(Message::problem(error.short()));
}
let target = match self.shared.docs.find_path(&path) {
Some(open) => Seen::of(self.shared.docs.get(open).buffer.doc()),
None => match read(&path) {
Ok(doc) => Seen::of(&doc),
Err(error) => return Some(Message::problem(format!("{}: {error}", name_of(&path)))),
},
};
let (Some(target), Some(link)) = (target, relative(&folder, &path)) else { return None };
let portal = Portal { path: link, uid: target.uid, title: target.title().clone() };
let ctx = self.shared.ctx(self.buffer());
let node = Node::portal(portal, crate::model::CanvasPos::default());
let pos = child_spot(ctx.doc, ctx.cursor, ctx.box_size(&node), &ctx.box_rects());
let spot = Spot::LastChildOf(ctx.cursor);
let add = EditOp::Add { node: Node { pos, ..node }, spot };
let said =
self.doc_action(DocAction::Edit { op: Box::new(add), cursor: CursorAfter::Added, group: None });
if said.is_some() {
return said;
}
self.refresh_portals(true);
let name = name_of(&path);
Some(Message::done(if made {
format!("made {name}, with a portal to it: Enter goes there")
} else {
format!("a portal to {name}: Enter goes there")
}))
}
/// `gu`, and `:backlinks` (`list`): up to the portals that lead to this
/// document. One goes straight there (a jump); several, or
/// `:backlinks`, pop up a list, the document the pane came from first.
pub(super) fn go_up(&mut self, list: bool) -> Option<Message> {
let open = self.shared.docs.get(self.buffer());
let folder = folder_of(&open.file.path);
let mut ups: Vec<(String, String, Place)> = open
.buffer
.doc()
.backlinks()
.iter()
.map(|backlink| {
let path = resolve(&folder, &backlink.from);
let title = self
.shared
.portals
.seen(&path)
.map_or_else(|| title_from_file(&path).to_string(), |seen| seen.title().to_string());
let place = Place { uid: backlink.uid, path, node: backlink.node };
(title, backlink.from.as_str().to_string(), place)
})
.collect();
if let Some(came) = self.panes.current().trail.last() {
ups.sort_by_key(|(_, _, place)| place.uid != came.uid);
}
match ups.as_slice() {
[] => Some(Message::info("no portal leads here yet: :portal in another document makes one")),
[(_, _, place)] if !list => {
let place = place.clone();
self.jump_to_place(&place)
}
_ => {
self.shared.mode = Mode::Popup(Popup::backlinks(&ups, &self.shared.theme));
None
}
}
}
/// Gives the documents this one's portals lead to the backlinks they
/// should have from it, and takes away the ones from portals that went
/// (DESIGN.md: "Backlinks are stored in the target document and synced
/// on save"). Done when a document is opened and when it's saved; a
/// document another cumulus has open is left alone.
pub(super) fn sync_backlinks(&mut self, id: BufferId) {
let open = self.shared.docs.get(id);
// Not on disk yet: no portal can lead back to it.
if open.file.stamp.is_none() {
return;
}
let source = (open.buffer.doc().uid(), open.file.path.clone());
let folder = folder_of(&source.1);
let mut leads: BTreeMap<PathBuf, (DocUid, Vec<NodeId>)> = BTreeMap::new();
for (node, portal) in open.buffer.doc().portals() {
let lead =
leads.entry(resolve(&folder, &portal.path)).or_insert_with(|| (portal.uid, Vec::new()));
lead.1.push(node);
}
let before = std::mem::take(&mut self.shared.docs.get_mut(id).led_to);
let paths: BTreeSet<PathBuf> = before.into_iter().chain(leads.keys().cloned()).collect();
for path in paths {
let (uid, nodes) = leads.get(&path).map_or((None, &[][..]), |(uid, nodes)| (Some(*uid), nodes));
self.give_backlinks(&path, uid, &source, nodes);
}
self.shared.docs.get_mut(id).led_to = leads.into_keys().collect();
}
/// Puts the backlinks from one document (`source`: its uid and its file)
/// in the document at `path` (which has the uid `uid`, when that's
/// known): exactly one for each portal in `nodes`.
fn give_backlinks(
&mut self,
path: &Path,
uid: Option<DocUid>,
source: &(DocUid, PathBuf),
nodes: &[NodeId],
) {
let wanted = |doc: &Document, folder: &Path| -> Option<Vec<Backlink>> {
let from = relative(folder, &source.1)?;
let mut backlinks: Vec<Backlink> =
doc.backlinks().iter().filter(|backlink| backlink.uid != source.0).cloned().collect();
backlinks.extend(nodes.iter().map(|&node| Backlink { from: from.clone(), uid: source.0, node }));
backlinks.sort();
(backlinks != doc.backlinks()).then_some(backlinks)
};
let docs = &self.shared.docs;
if let Some(target) = docs.find_path(path).or_else(|| uid.and_then(|uid| docs.find_uid(uid))) {
let open = self.shared.docs.get_mut(target);
let Some(backlinks) = wanted(open.buffer.doc(), &folder_of(&open.file.path)) else { return };
open.buffer.set_backlinks(backlinks);
// A document with changes of its own takes them along with those.
if !open.is_dirty() && open.file.stamp.is_some() && open.file.trouble.is_none() {
let _ = self.write_file(target);
}
return;
}
let Ok(mut doc) = read(path) else { return };
if uid.is_some_and(|uid| uid != doc.uid()) {
return;
}
let Some(backlinks) = wanted(&doc, &folder_of(path)) else { return };
doc.set_backlinks(backlinks);
self.write_closed(path, &doc);
}
/// Writes a document that isn't open here, unless another cumulus has
/// it: the day's first write copies it to the backups first.
fn write_closed(&self, path: &Path, doc: &Document) {
let lock = match Lock::take(&self.paths.locks(), doc.uid()) {
Ok(Some(lock)) => Some(lock),
Ok(None) => return,
// No lock files (the data folder can't be written): unprotected.
Err(_) => None,
};
let Ok(text) = document_file::write(doc) else { return };
let _ = backup::daily(&self.paths.backups(), doc.uid(), path, Day::today(), backup::KEEP);
let _ = write_atomically(path, &text);
drop(lock);
}
/// After a document's file was renamed from `old` to `new`: the portals
/// that lead to it (in the documents its backlinks list, and in any open
/// one) take the new path, and so does everything that remembers where
/// it was.
pub(super) fn renamed(&mut self, id: BufferId, old: &Path, new: &Path) {
let doc = self.shared.docs.get(id).buffer.doc();
let uid = doc.uid();
let folder = folder_of(new);
let sources: BTreeSet<PathBuf> =
doc.backlinks().iter().map(|backlink| resolve(&folder, &backlink.from)).collect();
for other in self.shared.docs.ids() {
if other != id {
self.repath(other, uid, new);
}
}
for path in sources {
if self.shared.docs.find_path(&path).is_some() {
continue;
}
let Ok(mut doc) = read(&path) else { continue };
if repath_doc(&mut doc, &folder_of(&path), uid, new) {
self.write_closed(&path, &doc);
}
}
self.memory.moved(&uid.to_string(), new);
for (_, pane) in self.panes.iter_mut() {
pane.jumps.moved(uid, new);
}
self.shared.portals.forget(old);
self.message = Some(Message::done(format!(
"{} is {} now: a file's name follows its title",
name_of(old),
name_of(new)
)));
}
/// The portals to document `uid` in an open document take the path
/// `new`: written straight away, unless the document has changes of its
/// own to save with it.
fn repath(&mut self, id: BufferId, uid: DocUid, new: &Path) {
let open = self.shared.docs.get_mut(id);
let folder = folder_of(&open.file.path);
let Some(link) = relative(&folder, new) else { return };
let moved: Vec<(NodeId, Portal)> = open
.buffer
.doc()
.portals()
.filter(|(_, portal)| portal.uid == uid && portal.path != link)
.map(|(node, portal)| (node, Portal { path: link.clone(), ..portal.clone() }))
.collect();
if moved.is_empty() {
return;
}
for (node, portal) in moved {
open.buffer.set_portal(node, portal);
}
if !open.is_dirty() && open.file.stamp.is_some() && open.file.trouble.is_none() {
let _ = self.write_file(id);
}
}
}
/// The portals to document `uid` in a document (whose file is in `folder`)
/// take the path `new`. Returns whether one changed.
fn repath_doc(doc: &mut Document, folder: &Path, uid: DocUid, new: &Path) -> bool {
let Some(link) = relative(folder, new) else { return false };
let moved: Vec<(NodeId, Portal)> = doc
.portals()
.filter(|(_, portal)| portal.uid == uid && portal.path != link)
.map(|(node, portal)| (node, Portal { path: link.clone(), ..portal.clone() }))
.collect();
let changed = !moved.is_empty();
for (node, portal) in moved {
doc.set_portal(node, portal);
}
changed
}
+28 -13
View File
@@ -2,6 +2,7 @@
//! panes in their boxes, a popup over them, the status bar and the command //! panes in their boxes, a popup over them, the status bar and the command
//! line at the bottom, and which-key. //! line at the bottom, and which-key.
use std::path::Path;
use std::time::Duration; use std::time::Duration;
use ratatui::Frame; use ratatui::Frame;
@@ -15,7 +16,7 @@ use super::action::{self, Message, Tone};
use super::mode::{Grab, Mode}; use super::mode::{Grab, Mode};
use super::popup::{Card, Popup}; use super::popup::{Card, Popup};
use super::text_edit::{Field, TextEdit}; use super::text_edit::{Field, TextEdit};
use super::{App, Screen, Shared}; use super::{App, Screen, Shared, connect};
use crate::ui::document_bar::DocumentBar; use crate::ui::document_bar::DocumentBar;
use crate::ui::status_bar::{StatusBar, breadcrumb}; use crate::ui::status_bar::{StatusBar, breadcrumb};
use crate::ui::which_key::WhichKey; use crate::ui::which_key::WhichKey;
@@ -38,12 +39,12 @@ impl App {
// is in the mode; the others show their documents as they are. // is in the mode; the others show their documents as they are.
let focused = self.panes.focused(); let focused = self.panes.focused();
let normal = Mode::Normal; let normal = Mode::Normal;
let Shared { docs, mode, theme, view } = &self.shared; let theme = &self.shared.theme;
for (id, area) in self.panes.areas(panes_area) { for (id, area) in self.panes.areas(panes_area) {
let Some(pane) = self.panes.get_mut(id) else { continue }; let Some(pane) = self.panes.get_mut(id) else { continue };
let buffer = &docs.get(pane.buffer).buffer; let buffer = &self.shared.docs.get(pane.buffer).buffer;
let mode = if id == focused { mode } else { &normal }; let mode = if id == focused { &self.shared.mode } else { &normal };
let ctx = ViewContext { doc: buffer.doc(), cursor: buffer.cursor(), mode, theme, settings: view }; let ctx = self.shared.ctx_in(pane.buffer, mode);
let color = if id == focused { theme.accent } else { theme.muted }; let color = if id == focused { theme.accent } else { theme.muted };
let title = format!(" {} · {} ", pane.view.kind().name(), buffer.doc().title()); let title = format!(" {} · {} ", pane.view.kind().name(), buffer.doc().title());
let block = Block::bordered() let block = Block::bordered()
@@ -59,15 +60,17 @@ impl App {
// A popup over the panes; the card sits beside the cursor's node. // A popup over the panes; the card sits beside the cursor's node.
let buffer = self.buffer(); let buffer = self.buffer();
let anchor = self.panes.current().view.cursor_area(&self.shared.ctx(buffer)); let anchor = self.panes.current().view.cursor_area(&self.shared.ctx(buffer));
let Shared { docs, mode, theme, view } = &mut self.shared; let Shared { docs, mode, theme, view, portals } = &mut self.shared;
if let Mode::Popup(popup) = mode { if let Mode::Popup(popup) = mode {
let buffer = &docs.get(buffer).buffer; let open = docs.get(buffer);
let ctx = ViewContext { let ctx = ViewContext {
doc: buffer.doc(), doc: open.buffer.doc(),
cursor: buffer.cursor(), cursor: open.buffer.cursor(),
mode: &normal, mode: &normal,
theme, theme,
settings: view, settings: view,
portals,
folder: open.file.path.parent().unwrap_or(Path::new(".")),
}; };
popup.layout(panes_area, anchor, &ctx); popup.layout(panes_area, anchor, &ctx);
popup.render(frame, &ctx); popup.render(frame, &ctx);
@@ -111,11 +114,17 @@ impl App {
let view = format!("{}{focus}", pane.view.status()); let view = format!("{}{focus}", pane.view.status());
let room = usize::from(area.width).saturating_sub(mode.label().len() + view.len() + 16); let room = usize::from(area.width).saturating_sub(mode.label().len() + view.len() + 16);
// In visual mode, how many nodes are selected takes the breadcrumb's // In visual mode, how many nodes are selected takes the breadcrumb's
// place. // place; in connect mode, what's being connected to what.
let selected = self.selection().map(|selected| format!("{} selected", action::nodes(selected.len()))); let selected = match mode {
Mode::Connect(connecting) => Some(connect::status(connecting, &self.shared.ctx(self.buffer()))),
_ => self.selection().map(|selected| format!("{} selected", action::nodes(selected.len()))),
};
let place = match (&self.message, selected) { let place = match (&self.message, selected) {
(None, Some(selected)) => Span::raw(selected), (None, Some(selected)) => Span::raw(selected),
(None, None) => Span::raw(breadcrumb(buffer.doc(), buffer.cursor(), room)), (None, None) => {
let trail: Vec<String> = pane.trail.iter().map(|crumb| crumb.title.clone()).collect();
Span::raw(breadcrumb(&trail, buffer.doc(), buffer.cursor(), room))
}
(Some(Message { text, tone: Tone::Problem }), _) => text.clone().light_red(), (Some(Message { text, tone: Tone::Problem }), _) => text.clone().light_red(),
(Some(Message { text, tone: Tone::Done }), _) => text.clone().green(), (Some(Message { text, tone: Tone::Done }), _) => text.clone().green(),
(Some(Message { text, tone: Tone::Info }), _) => text.clone().yellow(), (Some(Message { text, tone: Tone::Info }), _) => text.clone().yellow(),
@@ -124,7 +133,7 @@ impl App {
mode: mode.label(), mode: mode.label(),
mode_color: match mode { mode_color: match mode {
_ if typing => theme.insert, _ if typing => theme.insert,
Mode::Visual(_) => theme.visual, Mode::Visual(_) | Mode::Connect(_) => theme.visual,
_ => theme.accent, _ => theme.accent,
}, },
view, view,
@@ -201,6 +210,12 @@ fn hints(mode: &Mode, view: ViewKind) -> &'static str {
"hjkl moves the branch (HJKL by 5) · M just this node · Enter done" "hjkl moves the branch (HJKL by 5) · M just this node · Enter done"
} }
(Mode::Move(_), _) => "hjkl moves just this node (HJKL by 5) · M the whole branch · Enter done", (Mode::Move(_), _) => "hjkl moves just this node (HJKL by 5) · M the whole branch · Enter done",
(Mode::Connect(_), ViewKind::Note) => {
"h/l gh gj gk gl go to the node · Enter link · d unlink · > under it · <Space> style · Esc cancel"
}
(Mode::Connect(_), _) => {
"go to the node · Enter link · d unlink or cut · > under it · <Space> style · Esc cancel"
}
(Mode::Command(_), _) => "Tab completes · ↑ ↓ history · Enter runs · Esc cancels", (Mode::Command(_), _) => "Tab completes · ↑ ↓ history · Enter runs · Esc cancels",
(Mode::Popup(Popup::Card(_)), _) => "j/k scroll · i edit the body · K or Esc close", (Mode::Popup(Popup::Card(_)), _) => "j/k scroll · i edit the body · K or Esc close",
(Mode::Popup(Popup::Search(_)), _) => "type to search · ↑ ↓ or Tab move · Enter go there · Esc close", (Mode::Popup(Popup::Search(_)), _) => "type to search · ↑ ↓ or Tab move · Enter go there · Esc close",
+5 -7
View File
@@ -4,7 +4,9 @@
//! `tests/files.rs`, and the config folder in `tests/config.rs`. //! `tests/files.rs`, and the config folder in `tests/config.rs`.
mod config; mod config;
mod connect;
mod files; mod files;
mod portals;
mod vim; mod vim;
use std::path::PathBuf; use std::path::PathBuf;
@@ -501,20 +503,16 @@ fn indenting_and_outdenting_nodes() {
} }
#[test] #[test]
fn keys_from_later_stages_say_so() { fn v1_1_commands_say_so_and_portal_titles_stay() {
let mut h = Harness::new(); let mut h = Harness::new();
h.type_keys("C"); h.type_keys(":tidy<CR>");
assert_eq!(h.message(), "\"connect mode\" comes in a later part of the build"); assert_eq!(h.message(), "this one comes in v1.1");
h.type_keys("<Space>p");
assert_eq!(h.message(), "\"new portal…\" comes in a later part of the build");
// Kitchen is a portal: its title is the other document's name. // Kitchen is a portal: its title is the other document's name.
h.type_keys("glgjgjgj"); h.type_keys("glgjgjgj");
assert_eq!(h.cursor(), "Kitchen"); assert_eq!(h.cursor(), "Kitchen");
h.type_keys("i"); h.type_keys("i");
assert_eq!(h.message(), "a portal shows its document's name: rename that document instead"); assert_eq!(h.message(), "a portal shows its document's name: rename that document instead");
assert_eq!(h.label(), "NORMAL"); assert_eq!(h.label(), "NORMAL");
h.type_keys("<CR>");
assert_eq!(h.message(), "going through a portal comes in part 8d of the build");
} }
// ----- Views, panes, popups and documents (stage 7) ----------------------- // ----- Views, panes, popups and documents (stage 7) -----------------------
+140
View File
@@ -0,0 +1,140 @@
//! Connect mode (part 8d), end to end: linking, styling the line as it's
//! made, cutting and attaching branches, Esc taking it all back, and
//! connecting a selection.
use ratatui::style::Color;
use super::*;
use crate::model::{Arrows, ColorName, LineColor, LineRef, LinkKey, Stroke};
impl Harness {
/// The status bar's first line.
fn status(&self) -> String {
let rows = self.rows();
rows[rows.len() - 2].clone()
}
/// The link between two nodes, by title.
fn link_between(&self, a: &str, b: &str) -> Option<crate::model::Link> {
self.doc().link(LinkKey::new(self.node(a), self.node(b))?).cloned()
}
}
#[test]
fn connect_mode_links_two_nodes() {
let mut h = Harness::new();
h.type_keys("glC");
assert_eq!(h.label(), "CONNECT");
assert!(h.status().contains("Soil → ? · go to the node to connect"), "{}", h.status());
h.type_keys("<CR>");
assert_eq!(h.message(), "go to the other node first (hjkl…), then Enter links them");
// The cursor goes to the other end; a dashed arrow follows it there.
h.type_keys("gj");
assert_eq!(h.cursor(), "Raised beds");
assert!(h.status().contains("Soil → Raised beds"), "{}", h.status());
let (col, row) = h.find("Soil");
assert_eq!(h.color_at((col - 2, row)), Color::Magenta, "Soil is what's being connected");
assert!(h.screen().contains('╌'), "the dashed line on its way:\n{}", h.screen());
h.type_keys("<CR>");
assert_eq!((h.label(), h.cursor().as_str()), ("NORMAL", "Soil"), "back where C was typed");
assert_eq!(h.message(), "linked Soil → Raised beds");
let link = h.link_between("Soil", "Raised beds").unwrap();
assert_eq!((link.start(), link.end()), (h.node("Soil"), h.node("Raised beds")));
h.type_keys("u");
assert!(h.link_between("Soil", "Raised beds").is_none(), "one undo step");
}
#[test]
fn the_line_is_styled_as_it_is_made_and_esc_takes_it_back() {
let mut h = Harness::new();
h.type_keys("glCgj<Space>a");
assert_eq!(h.message(), "arrowheads: start", "after the end arrow a new link would have");
assert_eq!(h.label(), "CONNECT");
h.type_keys("<Space>l");
assert_eq!(h.message(), "line: dotted");
h.type_keys("<Space>c");
assert_eq!(h.label(), "PICK");
h.type_keys("/red<CR>");
assert_eq!(h.label(), "CONNECT", "the picker goes back to connect mode");
h.type_keys("<Space>t");
assert!(h.screen().contains("label · Soil → Raised beds"), "{}", h.screen());
h.type_keys("feeds<CR>");
let link = h.link_between("Soil", "Raised beds").unwrap();
let red = Some(LineColor::Named(ColorName::parse("red").unwrap()));
assert_eq!(
(link.look.arrows, link.look.line, &link.look.color),
(Some(Arrows::Start), Some(Stroke::Dotted), &red)
);
assert_eq!(link.label.as_ref().map(|label| label.as_str()), Some("feeds"));
h.type_keys("<Esc>");
assert_eq!((h.label(), h.cursor().as_str()), ("NORMAL", "Soil"));
assert!(h.link_between("Soil", "Raised beds").is_none(), "Esc took it all back");
h.type_keys("u");
assert_eq!(h.message(), "nothing to undo", "and left nothing behind");
}
#[test]
fn d_cuts_a_branch_and_gt_attaches_one() {
let mut h = Harness::new();
// d, aimed at the parent: the branch is cut, and the node comes loose.
h.type_keys("glgjglCgh");
assert!(h.status().contains("Cedar, untreated → Raised beds · its parent"), "{}", h.status());
h.type_keys("d");
assert_eq!(h.message(), "cut the branch: Cedar, untreated is a loose node now");
assert!(h.doc().loose().contains(&h.node("Cedar, untreated")));
assert_eq!(h.cursor(), "Cedar, untreated");
// >: under the node aimed at, staying where it is on the canvas.
let pos = h.pos("Cedar, untreated");
h.type_keys("Cgggl>");
assert_eq!(h.message(), "Cedar, untreated is under Soil now");
assert_eq!(h.parent("Cedar, untreated"), "Soil");
assert_eq!(h.pos("Cedar, untreated"), pos);
// The root stays at the top, and a branch can't go inside itself.
h.type_keys("ggCgl>");
assert_eq!(h.message(), "the root stays at the top: C on the other node puts that one under it");
assert_eq!(h.label(), "CONNECT", "still connecting");
h.type_keys("<Esc>glCgl>");
assert_eq!(h.message(), "Cedar, untreated is in Soil's branch, and a branch can't go inside itself");
// d on a link removes it.
h.type_keys("<Esc>:find seeds<CR>CG");
assert!(h.status().contains("Seeds to order → ask about compost · linked"), "{}", h.status());
h.type_keys("d");
assert_eq!(h.message(), "unlinked Seeds to order and ask about compost");
assert!(h.doc().links().next().is_none());
}
#[test]
fn a_selection_connects_every_node_to_the_target() {
let mut h = Harness::new();
// v on Soil and Raised beds, then C: both connect to where the cursor goes.
h.type_keys("glvjC");
assert_eq!(h.label(), "CONNECT");
h.type_keys("G");
assert!(h.status().contains("2 nodes → ask about compost"), "{}", h.status());
h.type_keys("<CR>");
assert_eq!(h.message(), "linked 2 nodes → ask about compost");
assert!(h.link_between("Soil", "ask about compost").is_some());
assert!(h.link_between("Raised beds", "ask about compost").is_some());
assert_eq!(h.cursor(), "Raised beds", "back where C was typed");
}
#[test]
fn connect_mode_aims_with_the_mouse_and_works_in_the_outline() {
let mut h = Harness::new();
// A click aims, without dragging anything.
h.type_keys("glC");
let watering = h.find("Watering");
let pos = h.pos("Watering");
h.click(watering);
assert_eq!((h.label(), h.cursor().as_str()), ("CONNECT", "Watering"));
assert_eq!(h.pos("Watering"), pos);
h.type_keys("<Esc>");
// In the outline: k goes up to the parent, and <Space>l styles the branch.
h.type_keys("<Space>vo");
h.type_keys("Ck");
assert!(h.status().contains("Soil → Garden plans · its parent"), "{}", h.status());
h.type_keys("<Space>l<CR>");
let look = h.doc().line_look(LineRef::Branch(h.node("Soil"))).cloned().unwrap();
assert_eq!(look.line, Some(Stroke::Dashed), "a branch starts solid: dashed is next");
assert_eq!((h.label(), h.cursor().as_str()), ("NORMAL", "Soil"));
}
+192
View File
@@ -0,0 +1,192 @@
//! Portals (part 8d), end to end: what a portal shows, going through one
//! and back, `gu` up to the portals that lead here, `:portal`, backlinks
//! kept in step, and file names that follow titles.
use super::*;
impl Harness {
/// The status bar's first line.
fn status_line(&self) -> String {
let rows = self.rows();
rows[rows.len() - 2].clone()
}
/// The row of the screen where `text` is.
fn row_of(&self, text: &str) -> String {
let (_, row) = self.find(text);
self.rows()[usize::from(row)].clone()
}
}
/// The garden on its own, with the kitchen's file renamed to `pantry.cum`
/// behind its portal's back.
fn garden_with_the_kitchen_moved() -> Harness {
let folder = TempDir::new();
std::fs::write(folder.path().join("garden.cum"), GARDEN).unwrap();
std::fs::write(folder.path().join("pantry.cum"), KITCHEN).unwrap();
Harness::open(folder, &["garden.cum"])
}
#[test]
fn a_portal_shows_its_document_live() {
let mut h = Harness::new();
let (col, row) = h.find("» Kitchen");
// The kitchen's color (its root's), and how many nodes it has.
assert_eq!(h.color_at((col - 2, row)), ratatui::style::Color::Indexed(178), "yellow, as Kitchen is");
assert!(h.rows()[usize::from(row) + 1].contains(" 5 "), "{}", h.screen());
// A node added in the kitchen counts at once.
h.type_keys("gtaPans<CR><Esc><Esc>gT");
let (_, row) = h.find("» Kitchen");
assert!(h.rows()[usize::from(row) + 1].contains(" 6 "), "{}", h.screen());
// So does a new title.
h.type_keys("gt:rename Cooking<CR>gT");
assert!(h.screen().contains("» Cooking"), "{}", h.screen());
}
#[test]
fn enter_goes_through_a_portal_and_ctrl_o_comes_back() {
let mut h = Harness::new();
h.type_keys("glgjgjgj");
assert_eq!(h.cursor(), "Kitchen");
h.type_keys("<CR>");
assert_eq!(h.doc().title().as_str(), "Kitchen");
assert_eq!(h.cursor(), "Kitchen", "on its root");
assert!(h.status_line().contains("Garden plans » Kitchen"), "the way here: {}", h.status_line());
h.type_keys("gl");
assert!(h.status_line().contains("Garden plans » Kitchen › Herbs on the sill"), "{}", h.status_line());
// Ctrl-o: back to the portal, and the breadcrumb is the garden's again.
h.type_keys("<C-o>");
assert_eq!((h.doc().title().as_str(), h.cursor().as_str()), ("Garden plans", "Kitchen"));
assert!(h.status_line().contains(" Garden plans › Kitchen"), "{}", h.status_line());
h.type_keys("gf");
assert_eq!(h.doc().title().as_str(), "Kitchen", "gf goes through too");
// The Note view says where a portal leads.
h.type_keys("<C-o><Space>vn");
assert!(h.screen().contains("a portal to kitchen.cum · 5 nodes · Enter goes there"), "{}", h.screen());
}
#[test]
fn gu_goes_up_to_the_portals_that_lead_here() {
let mut h = Harness::new();
h.type_keys("gt");
assert_eq!(h.doc().title().as_str(), "Kitchen");
// The root counts the portals that lead to its document.
assert!(h.row_of("Kitchen").contains("Kitchen") && h.screen().contains("« 1"), "{}", h.screen());
h.type_keys("glgu");
assert_eq!((h.doc().title().as_str(), h.cursor().as_str()), ("Garden plans", "Kitchen"), "on the portal");
// :backlinks lists them, even just one.
h.type_keys("<C-o>:backlinks<CR>");
assert!(h.screen().contains("portals that lead here"), "{}", h.screen());
assert!(h.screen().contains("« Garden plans garden.cum"), "{}", h.screen());
h.type_keys("<CR>");
assert_eq!(h.cursor(), "Kitchen");
// In the Note view, the root lists them as parts (after the title, the
// tags, the body and two children): Enter goes there.
h.type_keys("gtgg<Space>vn");
assert!(h.screen().contains("Portals here"), "{}", h.screen());
h.type_keys("jjjjj<CR>");
assert_eq!((h.doc().title().as_str(), h.cursor().as_str()), ("Garden plans", "Kitchen"));
// A document no portal leads to says so.
h.type_keys(":new Shopping list<CR>gu");
assert_eq!(h.message(), "no portal leads here yet: :portal in another document makes one");
}
#[test]
fn portal_makes_a_new_document_or_finds_one() {
let mut h = Harness::new();
h.type_keys("<Space>p");
assert_eq!(h.label(), "COMMAND");
assert!(h.rows().last().unwrap().starts_with(":portal "), "{}", h.screen());
h.type_keys("Compost bins<CR>");
assert_eq!(h.message(), "made compost-bins.cum, with a portal to it: Enter goes there");
assert_eq!(h.cursor(), "Compost bins");
assert_eq!(h.parent("Compost bins"), "Garden plans", "under the cursor's node, where a adds one");
assert!(h.file("compost-bins.cum").contains("title: \"Compost bins\""));
// Saved, the portal puts a backlink in its document.
h.wait(1);
let made = h.file("compost-bins.cum");
assert!(made.contains("from: \"garden.cum\""), "{made}");
// One that's there already, by its file's name or its title.
h.type_keys("gg:portal kitchen<CR>");
assert_eq!(h.message(), "a portal to kitchen.cum: Enter goes there");
h.type_keys("gg:portal Compost bins<CR>");
assert_eq!(h.message(), "a portal to compost-bins.cum: Enter goes there");
h.type_keys("gg:portal garden<CR>");
assert_eq!(h.message(), "a portal can't lead to its own document");
}
#[test]
fn backlinks_follow_the_portals_as_they_come_and_go() {
let mut h = Harness::new();
// The kitchen portal goes: once the garden is saved, the kitchen
// forgets it.
h.type_keys("glgjgjgjdd");
h.wait(1);
assert!(!h.file("kitchen.cum").contains("backlinks"), "{}", h.file("kitchen.cum"));
h.type_keys("gt");
assert!(!h.screen().contains("« 1"), "{}", h.screen());
// Back again: so is the backlink, with the portal's node.
h.type_keys("gTu");
h.wait(1);
assert!(h.file("kitchen.cum").contains("node: 10"), "{}", h.file("kitchen.cum"));
}
#[test]
fn a_new_title_renames_the_file_and_its_portals_follow() {
let mut h = Harness::new();
h.type_keys("gt:rename Cooking<CR>");
h.wait(1);
assert_eq!(h.message(), "kitchen.cum is cooking.cum now: a file's name follows its title");
assert!(!h.path("kitchen.cum").exists());
assert!(h.file("cooking.cum").contains("title: \"Cooking\""));
let garden = h.file("garden.cum");
assert!(garden.contains("path: \"cooking.cum\"") && garden.contains("title: \"Cooking\""), "{garden}");
// The portal still goes there.
h.type_keys("gTglgjgjgj<CR>");
assert_eq!(h.doc().title().as_str(), "Cooking");
// A name that's taken gets a number.
h.type_keys("gT:rename Cooking<CR>");
h.wait(1);
assert_eq!(h.message(), "garden.cum is cooking-2.cum now: a file's name follows its title");
assert!(h.file("cooking.cum").contains("from: \"cooking-2.cum\""), "the backlink follows it too");
// A folder's default document keeps its name.
h.type_keys(":new<CR>:rename Everything<CR>");
h.wait(1);
assert!(h.path("ideas.cum").exists() && !h.path("everything.cum").exists());
}
#[test]
fn a_portal_finds_its_document_moved_by_hand() {
let mut h = garden_with_the_kitchen_moved();
let (_, row) = h.find("» Kitchen");
assert!(h.rows()[usize::from(row) + 1].contains(" ? "), "not where it says: {}", h.screen());
h.type_keys("glgjgjgj<CR>");
assert_eq!(h.doc().title().as_str(), "Kitchen", "found in its folder by its uid");
assert!(h.file("garden.cum").contains("path: \"pantry.cum\""), "and the portal keeps that path");
// Gone for good: Enter says so.
let mut h = garden_with_the_kitchen_moved();
std::fs::remove_file(h.path("pantry.cum")).unwrap();
h.type_keys("glgjgjgj<CR>");
assert_eq!(h.message(), "kitchen.cum isn't there anymore");
assert_eq!(h.doc().title().as_str(), "Garden plans");
}
#[test]
fn a_portal_pasted_into_another_folder_still_leads_there() {
let mut h = Harness::new();
h.type_keys("glgjgjgjyy:e rooms/attic.cum<CR>p");
let path = h.doc().portals().next().map(|(_, portal)| portal.path.as_str().to_string());
assert_eq!(path.as_deref(), Some("../kitchen.cum"), "from the attic's own folder");
h.type_keys("<CR>");
assert_eq!(h.doc().title().as_str(), "Kitchen");
}
#[test]
fn w_says_when_the_file_takes_a_new_name() {
let mut h = Harness::new();
h.type_keys("gt:rename Pantry<CR>:w<CR>");
assert_eq!(
h.message(),
"saved pantry.cum · kitchen.cum is pantry.cum now: a file's name follows its title"
);
}
+9
View File
@@ -1,9 +1,12 @@
//! Editing a node's text: rapid entry, empty titles, `o` inside a title, //! Editing a node's text: rapid entry, empty titles, `o` inside a title,
//! bodies and tags, and the document following the text. //! bodies and tags, and the document following the text.
use std::path::Path;
use super::*; use super::*;
use crate::app::buffer::Buffer; use crate::app::buffer::Buffer;
use crate::app::mode::Mode; use crate::app::mode::Mode;
use crate::app::portals::Portals;
use crate::input::bindings::Context; use crate::input::bindings::Context;
use crate::input::engine::{KeyEngine, Outcome}; use crate::input::engine::{KeyEngine, Outcome};
use crate::input::key::parse_keys; use crate::input::key::parse_keys;
@@ -46,12 +49,15 @@ impl App {
fn press(&mut self, keys: &str) { fn press(&mut self, keys: &str) {
for key in parse_keys(keys, DEFAULT_LEADER).unwrap() { for key in parse_keys(keys, DEFAULT_LEADER).unwrap() {
let normal = Mode::Normal; let normal = Mode::Normal;
let portals = Portals::default();
let ctx = ViewContext { let ctx = ViewContext {
doc: self.buffer.doc(), doc: self.buffer.doc(),
cursor: self.buffer.cursor(), cursor: self.buffer.cursor(),
mode: &normal, mode: &normal,
theme: &self.theme, theme: &self.theme,
settings: &self.settings, settings: &self.settings,
portals: &portals,
folder: Path::new("."),
}; };
let actions = match &mut self.mode { let actions = match &mut self.mode {
Mode::Text(edit) if edit.editor.is_typing() => { Mode::Text(edit) if edit.editor.is_typing() => {
@@ -79,12 +85,15 @@ impl App {
} }
Action::App(AppAction::StartEdit(start)) => { Action::App(AppAction::StartEdit(start)) => {
let normal = Mode::Normal; let normal = Mode::Normal;
let portals = Portals::default();
let ctx = ViewContext { let ctx = ViewContext {
doc: self.buffer.doc(), doc: self.buffer.doc(),
cursor: self.buffer.cursor(), cursor: self.buffer.cursor(),
mode: &normal, mode: &normal,
theme: &self.theme, theme: &self.theme,
settings: &self.settings, settings: &self.settings,
portals: &portals,
folder: Path::new("."),
}; };
match TextEdit::start(&ctx, start) { match TextEdit::start(&ctx, start) {
Ok(edit) => self.mode = Mode::Text(edit), Ok(edit) => self.mode = Mode::Text(edit),
+24 -5
View File
@@ -7,9 +7,9 @@ use ratatui::layout::Position;
use super::action::{Action, AppAction, DocAction, DocumentChoice, Message, Tone}; use super::action::{Action, AppAction, DocAction, DocumentChoice, Message, Tone};
use super::documents::Trouble; use super::documents::Trouble;
use super::mode::{Grab, Mode, Visual}; use super::mode::{Connect, Grab, Mode, Visual};
use super::panes::Arrange; use super::panes::Arrange;
use super::popup::{DocumentRow, MenuFor, Popup}; use super::popup::{DocumentRow, MenuFor, PickKind, Popup};
use super::search::Search; use super::search::Search;
use super::text_edit::{StartEdit, TextEdit}; use super::text_edit::{StartEdit, TextEdit};
use super::{App, Pane}; use super::{App, Pane};
@@ -113,6 +113,18 @@ impl App {
} }
AppAction::ToggleSelected(id) => self.toggle_selected(id), AppAction::ToggleSelected(id) => self.toggle_selected(id),
AppAction::SelectAlso(ids) => return self.select_also(ids), AppAction::SelectAlso(ids) => return self.select_also(ids),
AppAction::EnterConnectMode(nodes) => {
if let Some(refused) = self.refuse_read_only() {
return Some(refused);
}
let cursor = self.current().cursor();
let sources = if nodes.is_empty() { vec![cursor] } else { nodes };
self.shared.mode = Mode::Connect(Connect::new(sources, cursor));
}
AppAction::ResumeConnect(connect) => self.shared.mode = Mode::Connect(connect),
AppAction::FollowPortal(id) => return self.follow_portal(id),
AppAction::NewPortal(path) => return self.new_portal(&path),
AppAction::GoUp { list } => return self.go_up(list),
AppAction::ToggleWhole => { AppAction::ToggleWhole => {
if let Mode::Move(grab) = &mut self.shared.mode { if let Mode::Move(grab) = &mut self.shared.mode {
grab.whole = !grab.whole; grab.whole = !grab.whole;
@@ -173,6 +185,12 @@ impl App {
AppAction::OpenRegisters => self.open_registers(), AppAction::OpenRegisters => self.open_registers(),
AppAction::OpenLinks => self.open_links(), AppAction::OpenLinks => self.open_links(),
AppAction::Find(query) => return self.find_now(query), AppAction::Find(query) => return self.find_now(query),
// Connect mode's pickers style the lines it's making.
AppAction::OpenPicker(PickKind::Line(what)) => {
let Mode::Connect(connect) = &self.shared.mode else { return None };
let picker = Popup::line_picker(what, &self.shared.ctx(self.buffer()), connect);
self.shared.mode = Mode::Popup(picker);
}
AppAction::OpenPicker(kind) => { AppAction::OpenPicker(kind) => {
let documents = self.document_rows(); let documents = self.document_rows();
let current = self.shared.docs.index_of(self.buffer()); let current = self.shared.docs.index_of(self.buffer());
@@ -263,9 +281,10 @@ impl App {
} }
let buffer = pane.buffer; let buffer = pane.buffer;
let camera = pane.view.camera().or(self.shared.docs.get(buffer).camera); let camera = pane.view.camera().or(self.shared.docs.get(buffer).camera);
// The new pane starts with a copy of this one's jump list, as in vim. // The new pane starts with a copy of this one's jump list, as in vim
let jumps = pane.jumps.clone(); // (and of the portals it came through).
self.panes.split(arrange, Pane { jumps, ..Pane::new(buffer, kind, camera) }); let (jumps, trail) = (pane.jumps.clone(), pane.trail.clone());
self.panes.split(arrange, Pane { jumps, trail, ..Pane::new(buffer, kind, camera) });
None None
} }
+6 -1
View File
@@ -172,7 +172,12 @@ pub fn command(
Command::MoveMode => { Command::MoveMode => {
say("several nodes move on the canvas; here, > and < move them through the tree") say("several nodes move on the canvas; here, > and < move them through the tree")
} }
Command::ConnectMode => say("connect mode comes in part 8d of the build"), // Every node connects to the one the cursor goes to; with `V`, each
// branch from its top.
Command::ConnectMode => {
let sources = if visual.branches { tops(selected, ctx) } else { selected.to_vec() };
app(AppAction::EnterConnectMode(sources))
}
_ => return None, _ => return None,
}; };
Some(actions) Some(actions)
+10 -2
View File
@@ -9,7 +9,8 @@ use super::action::{self, CursorAfter, DocAction, Message, PasteAt};
use super::commands; use super::commands;
use super::registers::Kept; use super::registers::Kept;
use crate::input::command::Register; use crate::input::command::Register;
use crate::model::{Clip, NodeId}; use crate::model::{Clip, LinkPath, NodeId};
use crate::storage::link_paths::{relative, resolve};
impl App { impl App {
/// Keeps nodes of the focused document in a register: exactly `nodes`, /// Keeps nodes of the focused document in a register: exactly `nodes`,
@@ -21,11 +22,15 @@ impl App {
branches: bool, branches: bool,
) -> Option<Message> { ) -> Option<Message> {
let doc = self.current().doc(); let doc = self.current().doc();
let clip = if branches { let mut clip = if branches {
Clip::branches(doc, nodes) Clip::branches(doc, nodes)
} else { } else {
Clip::take(doc, &nodes.iter().copied().collect::<BTreeSet<NodeId>>()) Clip::take(doc, &nodes.iter().copied().collect::<BTreeSet<NodeId>>())
}; };
// Its portals keep full paths while it's in a register: it can go
// into a document in another folder.
let folder = self.folder();
clip.repath(|path| LinkPath::parse(&resolve(&folder, path).to_string_lossy()).ok());
let count = action::nodes(clip.count()); let count = action::nodes(clip.count());
if let Err(problem) = self.registers.store(register, Kept::Nodes(clip)) { if let Err(problem) = self.registers.store(register, Kept::Nodes(clip)) {
return Some(Message::problem(problem)); return Some(Message::problem(problem));
@@ -51,6 +56,9 @@ impl App {
let Some(mut clip) = kept.clip() else { let Some(mut clip) = kept.clip() else {
return Some(Message::info("the register's text is empty: no nodes to make")); return Some(Message::info("the register's text is empty: no nodes to make"));
}; };
// Its portals' paths, from this document's folder.
let folder = self.folder();
clip.repath(|path| relative(&folder, &resolve(&folder, path)));
let ctx = self.shared.ctx(self.buffer()); let ctx = self.shared.ctx(self.buffer());
if !clip.placed { if !clip.placed {
clip.lay_out(|node| ctx.box_size(node)); clip.lay_out(|node| ctx.box_size(node));
+6 -4
View File
@@ -41,8 +41,9 @@ use crate::ui::theme::Theme;
/// The theme built into cumulus. /// The theme built into cumulus.
pub const BUILT_IN: &str = "default"; pub const BUILT_IN: &str = "default";
/// The words for line kinds, in files. /// The words for line kinds, in files (and the order connect mode's
const STROKES: [(&str, Stroke); 5] = [ /// `<Space>l` goes through them).
pub const STROKES: [(&str, Stroke); 5] = [
("solid", Stroke::Solid), ("solid", Stroke::Solid),
("dashed", Stroke::Dashed), ("dashed", Stroke::Dashed),
("dotted", Stroke::Dotted), ("dotted", Stroke::Dotted),
@@ -50,8 +51,9 @@ const STROKES: [(&str, Stroke); 5] = [
("double", Stroke::Double), ("double", Stroke::Double),
]; ];
/// The words for arrowheads, in files. /// The words for arrowheads, in files (and the order connect mode's
const ARROWS: [(&str, Arrows); 4] = /// `<Space>a` goes through them).
pub const ARROWS: [(&str, Arrows); 4] =
[("off", Arrows::Off), ("end", Arrows::End), ("start", Arrows::Start), ("both", Arrows::Both)]; [("off", Arrows::Off), ("end", Arrows::End), ("start", Arrows::Start), ("both", Arrows::Both)];
/// In a line's `color`, this word means "the node's own color". /// In a line's `color`, this word means "the node's own color".
+14 -11
View File
@@ -117,7 +117,9 @@ pub const SITUATIONS: &[(&str, &[Context])] = &[
("normal mode in the Note view", &[Context::Note, Context::Normal, Context::Motions]), ("normal mode in the Note view", &[Context::Note, Context::Normal, Context::Motions]),
("visual mode", &[Context::Visual, Context::Canvas, Context::Motions]), ("visual mode", &[Context::Visual, Context::Canvas, Context::Motions]),
("move mode", &[Context::Move]), ("move mode", &[Context::Move]),
("connect mode", &[Context::Connect, Context::Canvas, Context::Motions]), ("connect mode on the canvas", &[Context::Connect, Context::Canvas, Context::Motions]),
("connect mode in the outline", &[Context::Connect, Context::Outline, Context::Motions]),
("connect mode in the Note view", &[Context::Connect, Context::Motions]),
("lists", &[Context::List]), ("lists", &[Context::List]),
("the K card", &[Context::Card]), ("the K card", &[Context::Card]),
("vim keys inside text", &[Context::Text]), ("vim keys inside text", &[Context::Text]),
@@ -195,6 +197,7 @@ const MOTIONS: &[DefaultBinding] = &[
/// Normal mode, in every view. /// Normal mode, in every view.
const NORMAL: &[DefaultBinding] = &[ const NORMAL: &[DefaultBinding] = &[
bind_help("<CR>", C::Activate, "edit the title (a portal: go through it)"), bind_help("<CR>", C::Activate, "edit the title (a portal: go through it)"),
bind("gu", C::GoUp),
bind("i", C::EditTitle), bind("i", C::EditTitle),
bind("A", C::EditTitle), bind("A", C::EditTitle),
bind("I", C::EditTitleAtStart), bind("I", C::EditTitleAtStart),
@@ -354,16 +357,16 @@ const MOVE: &[DefaultBinding] = &[
/// Connect mode: walk to another node, then link to it. /// Connect mode: walk to another node, then link to it.
const CONNECT: &[DefaultBinding] = &[ const CONNECT: &[DefaultBinding] = &[
bind_help("<CR>", C::Confirm, "connect to this node (or save the link)"), bind_help("<CR>", C::Confirm, "link to this node, and done"),
bind("d", C::RemoveLink), bind_help("d", C::RemoveLink, "remove the line to this node (a branch: cut it)"),
bind(">", C::AttachAsChild), bind_help(">", C::AttachAsChild, "put it under this node"),
bind_help("<leader>s", C::PickStyle, "the link's style…"), bind_help("<leader>s", C::PickStyle, "the line's style…"),
bind("<leader>a", C::CycleArrows), bind_help("<leader>a", C::CycleArrows, "arrowheads: off, end, start, both"),
bind("<leader>l", C::CycleLine), bind_help("<leader>l", C::CycleLine, "line: solid, dashed, dotted, thick, double"),
bind_help("<leader>c", C::PickColor, "the link's color…"), bind_help("<leader>c", C::PickColor, "the line's color…"),
bind("<leader>t", C::EditLabel), bind_help("<leader>t", C::EditLabel, "the link's label…"),
bind_help("<Esc>", C::Escape, "cancel"), bind_help("<Esc>", C::Escape, "cancel: take it all back"),
bind_help("<C-c>", C::Escape, "cancel"), bind_help("<C-c>", C::Escape, "cancel: take it all back"),
]; ];
/// Lists in popups. /// Lists in popups.
+4
View File
@@ -217,6 +217,9 @@ pub enum Command {
JumpForward, JumpForward,
FollowPortal, FollowPortal,
FollowLink, FollowLink,
/// `gu`: up to where the document is reached from (the portals that
/// lead to it).
GoUp,
/// Enter: edit the title, or go through a portal. /// Enter: edit the title, or go through a portal.
Activate, Activate,
@@ -428,6 +431,7 @@ pub const COMMANDS: &[CommandInfo] = &[
info("jump-forward", C::JumpForward, "jump forward"), info("jump-forward", C::JumpForward, "jump forward"),
info("follow-portal", C::FollowPortal, "through the portal"), info("follow-portal", C::FollowPortal, "through the portal"),
info("follow-link", C::FollowLink, "follow a cross-link"), info("follow-link", C::FollowLink, "follow a cross-link"),
info("go-up", C::GoUp, "up to the portals that lead here"),
info("activate", C::Activate, "edit the title, or go through a portal"), info("activate", C::Activate, "edit the title, or go through a portal"),
// The view. // The view.
info("center", C::Center, "center the view"), info("center", C::Center, "center the view"),
+23 -1
View File
@@ -11,7 +11,8 @@ use super::geometry::{CanvasOffset, CanvasPos, CanvasRect, CanvasSize};
use super::ids::{DocUid, NodeId}; use super::ids::{DocUid, NodeId};
use super::link::Link; use super::link::Link;
use super::look::LineLook; use super::look::LineLook;
use super::node::Node; use super::node::{Content, Node};
use super::portal::LinkPath;
/// Columns between a parent and its children when a clip is laid out (as /// Columns between a parent and its children when a clip is laid out (as
/// new children are placed). /// new children are placed).
@@ -60,6 +61,18 @@ impl ClipNode {
child.walk(found); child.walk(found);
} }
} }
/// Changes the path of this portal, and of the portals under it.
fn repath(&mut self, change: &impl Fn(&LinkPath) -> Option<LinkPath>) {
if let Content::Portal(portal) = &mut self.node.content
&& let Some(path) = change(&portal.path)
{
portal.path = path;
}
for child in &mut self.children {
child.repath(change);
}
}
} }
impl Clip { impl Clip {
@@ -98,6 +111,15 @@ impl Clip {
found found
} }
/// Changes the paths of the portals in it. A portal's path is relative
/// to its document's folder, so it changes on the way out of one
/// document and into another (which can be in another folder).
pub fn repath(&mut self, change: impl Fn(&LinkPath) -> Option<LinkPath>) {
for root in &mut self.roots {
root.repath(&change);
}
}
/// How many nodes it holds. /// How many nodes it holds.
pub fn count(&self) -> usize { pub fn count(&self) -> usize {
self.nodes().len() self.nodes().len()
+38 -1
View File
@@ -7,7 +7,7 @@ use thiserror::Error;
use super::geometry::CanvasPos; use super::geometry::CanvasPos;
use super::ids::{DocUid, NodeId}; use super::ids::{DocUid, NodeId};
use super::link::{Link, LinkKey}; use super::link::{LineRef, Link, LinkKey};
use super::look::LineLook; use super::look::LineLook;
use super::node::{Content, Node}; use super::node::{Content, Node};
use super::portal::{Backlink, Portal}; use super::portal::{Backlink, Portal};
@@ -358,6 +358,30 @@ impl Document {
self.links.values().filter(move |link| link.key().touches(id)) self.links.values().filter(move |link| link.key().touches(id))
} }
/// The line joining two nodes, if one does: the branch between a
/// parent and its child, or the cross-link between them.
pub fn line_between(&self, a: NodeId, b: NodeId) -> Option<LineRef> {
if self.parent(a) == Some(b) {
return Some(LineRef::Branch(a));
}
if self.parent(b) == Some(a) {
return Some(LineRef::Branch(b));
}
let key = LinkKey::new(a, b)?;
self.links.contains_key(&key).then_some(LineRef::Link(key))
}
/// How a line is drawn, on top of what the theme says.
pub fn line_look(&self, line: LineRef) -> Option<&LineLook> {
match line {
LineRef::Branch(child) => match self.place(child)? {
Place::Child { branch, .. } => Some(branch),
Place::Root | Place::Loose => None,
},
LineRef::Link(key) => self.link(key).map(|link| &link.look),
}
}
/// The portals in other documents that lead here. /// The portals in other documents that lead here.
pub fn backlinks(&self) -> &[Backlink] { pub fn backlinks(&self) -> &[Backlink] {
&self.backlinks &self.backlinks
@@ -369,6 +393,19 @@ impl Document {
self.backlinks = backlinks; self.backlinks = backlinks;
} }
/// Puts a portal node's portal in step with its document (a new path
/// after a rename, the title it has now). Bookkeeping, like the
/// backlinks, so not an undoable edit. Returns whether it changed.
pub fn set_portal(&mut self, id: NodeId, portal: Portal) -> bool {
match self.entries.get_mut(&id).map(|entry| &mut entry.node.content) {
Some(Content::Portal(old)) if *old != portal => {
*old = portal;
true
}
_ => false,
}
}
/// The portals in this document, with the nodes they sit on. /// The portals in this document, with the nodes they sit on.
pub fn portals(&self) -> impl Iterator<Item = (NodeId, &Portal)> + '_ { pub fn portals(&self) -> impl Iterator<Item = (NodeId, &Portal)> + '_ {
self.entries.iter().filter_map(|(id, entry)| match &entry.node.content { self.entries.iter().filter_map(|(id, entry)| match &entry.node.content {
+34
View File
@@ -100,6 +100,19 @@ impl History {
self.done.last().map(|step| &step.change) self.done.last().map(|step| &step.change)
} }
/// Takes back the newest step when it belongs to `group`, as if it
/// never happened: there's nothing to redo afterwards (Esc in connect
/// mode). Returns whether there was one.
pub fn cancel(&mut self, group: Group, doc: &mut Document) -> bool {
if self.done.last().is_none_or(|step| step.group != Some(group)) {
return false;
}
if let Some(step) = self.done.pop() {
doc.restore(step.change.before());
}
true
}
/// The state the document is in now. /// The state the document is in now.
pub fn state(&self) -> StateId { pub fn state(&self) -> StateId {
self.done.last().map_or(StateId::default(), |step| step.state) self.done.last().map_or(StateId::default(), |step| step.state)
@@ -222,6 +235,27 @@ mod tests {
assert!(!history.can_undo()); assert!(!history.can_undo());
} }
#[test]
fn a_cancelled_group_leaves_nothing_to_redo() {
let (mut doc, g) = crate::model::test_support::garden();
let start = doc.clone();
let mut history = History::default();
let (_, change) =
doc.apply(&crate::model::EditOp::SetFold { ids: vec![g.beds], fold: Fold::Closed }).unwrap();
history.record(change);
let folded = (doc.clone(), history.state());
let connect = Group::new();
let link = Link::new(g.soil, g.cedar).unwrap();
let (_, change) = doc.apply(&crate::model::EditOp::Link(link)).unwrap();
history.record_in(change, connect);
assert!(history.cancel(connect, &mut doc));
assert_eq!((doc.clone(), history.state()), folded, "back to before the group");
assert!(!history.can_redo());
assert!(!history.cancel(connect, &mut doc), "only the group's own step goes");
history.undo(&mut doc);
assert_eq!(doc, start);
}
#[test] #[test]
fn a_group_that_undoes_itself_leaves_no_step() { fn a_group_that_undoes_itself_leaves_no_step() {
let (mut doc, g) = crate::model::test_support::garden(); let (mut doc, g) = crate::model::test_support::garden();
+9
View File
@@ -35,6 +35,15 @@ impl LinkKey {
} }
} }
/// A line between two nodes: the branch from a parent to its child, or a
/// cross-link. Connect mode styles either kind the same way.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum LineRef {
/// The branch from a node's parent to it (the node is the child).
Branch(NodeId),
Link(LinkKey),
}
/// A cross-link between two nodes. It points from its start to its end, which /// A cross-link between two nodes. It points from its start to its end, which
/// only matters for where the arrowheads go. /// only matters for where the arrowheads go.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
+1 -1
View File
@@ -31,7 +31,7 @@ pub use fold::Visible;
pub use geometry::{CanvasOffset, CanvasPos, CanvasRect, CanvasSize, Direction}; pub use geometry::{CanvasOffset, CanvasPos, CanvasRect, CanvasSize, Direction};
pub use history::{Group, History, StateId}; pub use history::{Group, History, StateId};
pub use ids::{DocUid, NodeId}; pub use ids::{DocUid, NodeId};
pub use link::{Link, LinkKey}; pub use link::{LineRef, Link, LinkKey};
pub use look::{Arrows, Border, LineColor, LineLook, NodeLook, Stroke}; pub use look::{Arrows, Border, LineColor, LineLook, NodeLook, Stroke};
pub use node::{Content, Fold, Idea, Node}; pub use node::{Content, Fold, Idea, Node};
pub use op::{Applied, EditOp, Inbox}; pub use op::{Applied, EditOp, Inbox};
+47 -3
View File
@@ -9,10 +9,10 @@ use super::document::Document;
use super::edit::{Change, Edit, EditError, Spot}; use super::edit::{Change, Edit, EditError, Spot};
use super::geometry::CanvasOffset; use super::geometry::CanvasOffset;
use super::ids::NodeId; use super::ids::NodeId;
use super::link::{Link, LinkKey}; use super::link::{LineRef, Link, LinkKey};
use super::look::Border; use super::look::{Border, LineLook};
use super::node::{Content, Fold, Node}; use super::node::{Content, Fold, Node};
use super::text::{Body, ColorName, StyleName, Tag, Tags, Title}; use super::text::{Body, ColorName, Label, StyleName, Tag, Tags, Title};
/// One change to a document. /// One change to a document.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -84,8 +84,19 @@ pub enum EditOp {
id: NodeId, id: NodeId,
tags: Tags, tags: Tags,
}, },
/// A new cross-link, or one that replaces the link between its nodes.
Link(Link), Link(Link),
Unlink(Vec<LinkKey>), Unlink(Vec<LinkKey>),
/// How a line is drawn: a branch's or a cross-link's (connect mode).
SetLine {
line: LineRef,
look: LineLook,
},
/// A cross-link's own label (`None`: none, or its named style's).
SetLabel {
key: LinkKey,
label: Option<Label>,
},
/// Copies of a clip (`p`): the first at `spot`, each moved by its /// Copies of a clip (`p`): the first at `spot`, each moved by its
/// offset, the next ones right after it. /// offset, the next ones right after it.
Paste { Paste {
@@ -213,6 +224,15 @@ fn apply(edit: &mut Edit<'_>, op: &EditOp) -> Result<Applied, EditError> {
} }
Ok(Applied::Changed) Ok(Applied::Changed)
} }
EditOp::SetLine { line: LineRef::Branch(child), look } => {
edit.set_branch_look(*child, look.clone()).map(|()| Applied::Changed)
}
EditOp::SetLine { line: LineRef::Link(key), look } => {
edit.set_link_look(*key, look.clone()).map(|()| Applied::Changed)
}
EditOp::SetLabel { key, label } => {
edit.set_link_label(*key, label.clone()).map(|()| Applied::Changed)
}
EditOp::Paste { clip, spot, offsets } => edit.paste(clip, *spot, offsets).map(Applied::Added), EditOp::Paste { clip, spot, offsets } => edit.paste(clip, *spot, offsets).map(Applied::Added),
} }
} }
@@ -259,6 +279,30 @@ mod tests {
assert_eq!(doc, start); assert_eq!(doc, start);
} }
#[test]
fn branches_and_links_are_lines_alike() {
let (mut doc, g) = garden();
assert_eq!(doc.line_between(g.beds, g.cedar), Some(LineRef::Branch(g.cedar)), "parent to child");
assert_eq!(doc.line_between(g.cedar, g.beds), Some(LineRef::Branch(g.cedar)), "either way");
assert_eq!(doc.line_between(g.soil, g.cedar), None);
let dotted = LineLook { line: Some(crate::model::Stroke::Dotted), ..LineLook::default() };
doc.apply(&EditOp::SetLine { line: LineRef::Branch(g.cedar), look: dotted.clone() }).unwrap();
assert_eq!(doc.line_look(LineRef::Branch(g.cedar)), Some(&dotted));
doc.apply(&EditOp::Link(Link::new(g.soil, g.cedar).unwrap())).unwrap();
let key = LinkKey::new(g.soil, g.cedar).unwrap();
assert_eq!(doc.line_between(g.cedar, g.soil), Some(LineRef::Link(key)));
doc.apply(&EditOp::SetLine { line: LineRef::Link(key), look: dotted.clone() }).unwrap();
let label = Label::parse("feeds").ok();
doc.apply(&EditOp::SetLabel { key, label: label.clone() }).unwrap();
let link = doc.link(key).unwrap();
assert_eq!((&link.look, &link.label), (&dotted, &label));
assert_eq!(
doc.apply(&EditOp::SetLine { line: LineRef::Branch(g.root), look: dotted }),
Err(EditError::NotAChild(g.root)),
"the root has no branch line"
);
}
#[test] #[test]
fn bodies_tags_and_styles() { fn bodies_tags_and_styles() {
let (mut doc, g) = garden(); let (mut doc, g) = garden();
+82
View File
@@ -0,0 +1,82 @@
//! Paths between documents. A portal (and a backlink) keeps the other
//! document's path relative to its own folder, with `/` on every OS, so a
//! folder of documents can move, or sync between Windows and Linux, and its
//! portals still lead where they did. [`relative`] writes such a path and
//! [`resolve`] reads it back in full.
use std::path::{Component, Path, PathBuf};
use crate::model::LinkPath;
/// The full path a link path leads to, from the folder of the document that
/// holds it. `.` and `..` are worked out from the text alone, so this works
/// for files that aren't there (yet, or anymore).
pub fn resolve(folder: &Path, path: &LinkPath) -> PathBuf {
normalize(&folder.join(path.as_str()))
}
/// How a document in `folder` writes the path of the file at `target`:
/// relative to the folder (`../kitchen.cum`), or in full when the two have
/// nothing in common (files on two drives). `None` only for an empty
/// `target`.
pub fn relative(folder: &Path, target: &Path) -> Option<LinkPath> {
let (folder, target) = (normalize(folder), normalize(target));
let from: Vec<Component<'_>> = folder.components().collect();
let to: Vec<Component<'_>> = target.components().collect();
let common = from.iter().zip(&to).take_while(|(a, b)| same(a, b)).count();
let text = if common == 0 {
target.to_string_lossy().into_owned()
} else {
let ups = std::iter::repeat_n("..".to_string(), from.len() - common);
let downs = to[common..].iter().map(|part| part.as_os_str().to_string_lossy().into_owned());
ups.chain(downs).collect::<Vec<_>>().join("/")
};
LinkPath::parse(&text).ok()
}
/// A path with its `.` and `..` worked out (a `..` past the root stays).
pub fn normalize(path: &Path) -> PathBuf {
let mut parts: Vec<Component<'_>> = Vec::new();
for part in path.components() {
match part {
Component::CurDir => {}
Component::ParentDir if matches!(parts.last(), Some(Component::Normal(_))) => {
parts.pop();
}
part => parts.push(part),
}
}
parts.iter().collect()
}
/// Whether two parts of paths name the same thing: Windows ignores case in
/// file names, Linux doesn't.
fn same(a: &Component<'_>, b: &Component<'_>) -> bool {
if cfg!(windows) {
a.as_os_str().to_string_lossy().to_lowercase() == b.as_os_str().to_string_lossy().to_lowercase()
} else {
a == b
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn link_paths_go_there_and_back() {
let base = std::env::temp_dir().join("documents");
let garden = base.join("garden.cum");
let kitchen = base.join("rooms").join("kitchen.cum");
let down = relative(&base, &kitchen).unwrap();
assert_eq!(down.as_str(), "rooms/kitchen.cum", "with / on every OS");
assert_eq!(resolve(&base, &down), kitchen);
let up = relative(&base.join("rooms"), &garden).unwrap();
assert_eq!(up.as_str(), "../garden.cum");
assert_eq!(resolve(&base.join("rooms"), &up), garden);
let roundabout = LinkPath::parse("./rooms/../garden.cum").unwrap();
assert_eq!(resolve(&base, &roundabout), garden);
let windows = LinkPath::parse("rooms\\kitchen.cum").unwrap();
assert_eq!(resolve(&base, &windows), kitchen, "a path typed with \\ reads the same");
}
}
+11
View File
@@ -113,6 +113,17 @@ impl Memory {
write_atomically(path, &format!("// What cumulus remembers between runs. Safe to delete.\n{text}\n")) write_atomically(path, &format!("// What cumulus remembers between runs. Safe to delete.\n{text}\n"))
} }
/// A document's file moved (its name followed its title): the recent
/// documents and the capital marks find it at `path`.
pub fn moved(&mut self, uid: &str, path: &Path) {
for recent in self.recent.iter_mut().filter(|recent| recent.uid == uid) {
recent.path = path.to_path_buf();
}
for mark in self.marks.values_mut().filter(|mark| mark.uid == uid) {
mark.path = path.to_path_buf();
}
}
/// Remembers that a document was opened: it goes first in the recent /// Remembers that a document was opened: it goes first in the recent
/// list (a document seen under another path before keeps one entry). /// list (a document seen under another path before keeps one entry).
pub fn opened(&mut self, path: &Path, uid: String, title: String) { pub fn opened(&mut self, path: &Path, uid: String, title: String) {
+2
View File
@@ -2,6 +2,7 @@
//! - [`document_file`]: documents (`.cum`), RON text to a document and back, //! - [`document_file`]: documents (`.cum`), RON text to a document and back,
//! - [`write_atomically`]: saving so a file is never left half-written, //! - [`write_atomically`]: saving so a file is never left half-written,
//! - [`names`]: file names made from titles, and titles from file names, //! - [`names`]: file names made from titles, and titles from file names,
//! - [`link_paths`]: the paths portals keep, relative and with `/`,
//! - [`backup`]: a copy of each document once a day, in the data folder, //! - [`backup`]: a copy of each document once a day, in the data folder,
//! - [`lock`]: one cumulus at a time edits a document; another one opens //! - [`lock`]: one cumulus at a time edits a document; another one opens
//! it read-only, //! it read-only,
@@ -12,6 +13,7 @@
pub mod backup; pub mod backup;
pub mod document_file; pub mod document_file;
pub mod link_paths;
pub mod lock; pub mod lock;
pub mod markdown; pub mod markdown;
pub mod memory; pub mod memory;
+9
View File
@@ -158,6 +158,15 @@ impl Glyphs {
} }
} }
/// The mark before how many portals lead to a document (`« 2` on its
/// root), and before each one in a list.
pub fn backlink(self) -> char {
match self {
Glyphs::Unicode => '«',
Glyphs::Ascii => '<',
}
}
/// The frame around a pane (square) or a popup (`rounded`). /// The frame around a pane (square) or a popup (`rounded`).
pub fn frame(self, rounded: bool) -> border::Set<'static> { pub fn frame(self, rounded: bool) -> border::Set<'static> {
match (self, rounded) { match (self, rounded) {
+8
View File
@@ -141,6 +141,14 @@ impl ListPopup {
self self
} }
/// Starts with typing going into the search: for a picker whose search
/// can make a new value (a link's label).
pub fn typing_first(mut self) -> ListPopup {
self.search = Some(TextInput::new(""));
self.searching = true;
self
}
/// Whether typing goes into the search. /// Whether typing goes into the search.
pub fn is_searching(&self) -> bool { pub fn is_searching(&self) -> bool {
self.searching self.searching
+34 -14
View File
@@ -68,21 +68,37 @@ impl Widget for StatusBar<'_> {
} }
} }
/// Where the cursor is in the tree, from the top: `Garden plans › Watering /// Where the cursor is: the documents the pane came through by portals
/// › Drip line`. When it's wider than `width`, the top levels give way to /// (`trail`, their names), then the tree from the top: `Garden plans »
/// `…`. /// Kitchen › Herbs › Basil`. When it's wider than `width`, the first parts
pub fn breadcrumb(doc: &Document, cursor: NodeId, width: usize) -> String { /// give way to `…`.
pub fn breadcrumb(trail: &[String], doc: &Document, cursor: NodeId, width: usize) -> String {
let mut path: Vec<String> = let mut path: Vec<String> =
doc.ancestors(cursor).filter_map(|id| Some(doc.node(id)?.title().to_string())).collect(); doc.ancestors(cursor).filter_map(|id| Some(doc.node(id)?.title().to_string())).collect();
path.reverse(); path.reverse();
if let Some(node) = doc.node(cursor) { if let Some(node) = doc.node(cursor) {
path.push(node.title().to_string()); path.push(node.title().to_string());
} }
let mut crumbs = path.join(" › "); // Each part with what comes after it: » after a document came through,
let mut dropped = 0; // › between the levels of this one.
while crumbs.width() > width && dropped + 1 < path.len() { let last = path.len().saturating_sub(1);
dropped += 1; let parts: Vec<(String, &str)> = trail
crumbs = format!("… › {}", path[dropped..].join(" › ")); .iter()
.map(|title| (title.clone(), " » "))
.chain(path.into_iter().enumerate().map(|(at, title)| (title, if at == last { "" } else { " › " })))
.collect();
let from = |first: usize| -> String {
let rest: String = parts[first..].iter().map(|(title, after)| format!("{title}{after}")).collect();
match first.checked_sub(1) {
Some(before) => format!("…{}{rest}", parts[before].1),
None => rest,
}
};
let mut first = 0;
let mut crumbs = from(0);
while crumbs.width() > width && first + 1 < parts.len() {
first += 1;
crumbs = from(first);
} }
crumbs crumbs
} }
@@ -95,10 +111,14 @@ mod tests {
#[test] #[test]
fn the_breadcrumb_gives_way_at_the_top() { fn the_breadcrumb_gives_way_at_the_top() {
let (doc, g) = garden(); let (doc, g) = garden();
assert_eq!(breadcrumb(&doc, g.cedar, 80), "Garden plans › Raised beds › Cedar"); assert_eq!(breadcrumb(&[], &doc, g.cedar, 80), "Garden plans › Raised beds › Cedar");
assert_eq!(breadcrumb(&doc, g.cedar, 23), "… › Raised beds › Cedar"); assert_eq!(breadcrumb(&[], &doc, g.cedar, 23), "… › Raised beds › Cedar");
assert_eq!(breadcrumb(&doc, g.cedar, 22), "… › Cedar"); assert_eq!(breadcrumb(&[], &doc, g.cedar, 22), "… › Cedar");
assert_eq!(breadcrumb(&doc, g.cedar, 5), "… › Cedar", "the node itself always shows"); assert_eq!(breadcrumb(&[], &doc, g.cedar, 5), "… › Cedar", "the node itself always shows");
assert_eq!(breadcrumb(&doc, g.compost, 80), "ask about compost"); assert_eq!(breadcrumb(&[], &doc, g.compost, 80), "ask about compost");
let trail = ["Projects".to_string(), "Home".to_string()];
assert_eq!(breadcrumb(&trail, &doc, g.beds, 80), "Projects » Home » Garden plans › Raised beds");
assert_eq!(breadcrumb(&trail, &doc, g.beds, 40), "… » Home » Garden plans › Raised beds");
assert_eq!(breadcrumb(&trail, &doc, g.beds, 30), "… » Garden plans › Raised beds");
} }
} }
+3
View File
@@ -19,6 +19,9 @@ pub enum LineOwner {
Branch(NodeId), Branch(NodeId),
/// A cross-link. /// A cross-link.
Link(LinkKey), Link(LinkKey),
/// The line connect mode shows from a node to the cursor's, before
/// Enter makes it.
Preview,
} }
/// The color and owner given to the cells a line or border passes through. /// The color and owner given to the cells a line or border passes through.
+53 -8
View File
@@ -13,10 +13,11 @@ use super::boxes::{NodeBox, draft_box, truncate};
use super::camera::{Camera, ScreenPos, ScreenRect, Zoom}; use super::camera::{Camera, ScreenPos, ScreenRect, Zoom};
use super::lines::{LineLayer, LineOwner, Paint}; use super::lines::{LineLayer, LineOwner, Paint};
use super::route::{Bend, route}; use super::route::{Bend, route};
use crate::app::connect;
use crate::app::mode::Mode; use crate::app::mode::Mode;
use crate::app::text_edit::TextEdit; use crate::app::text_edit::TextEdit;
use crate::app::visual::selection; use crate::app::visual::selection;
use crate::model::{Arrows, Border, Content, Document, Node, NodeId, Place, Visible}; use crate::model::{Arrows, Border, Content, Document, LineRef, Node, NodeId, Place, Stroke, Visible};
use crate::ui::glyphs::Glyphs; use crate::ui::glyphs::Glyphs;
use crate::ui::text_editor::layout; use crate::ui::text_editor::layout;
use crate::ui::theme::{LineStyle, Theme}; use crate::ui::theme::{LineStyle, Theme};
@@ -108,8 +109,10 @@ impl Scene {
let visible = Visible::new(doc, cursor, settings.focus); let visible = Visible::new(doc, cursor, settings.focus);
let picked: BTreeSet<NodeId> = match ctx.mode { let picked: BTreeSet<NodeId> = match ctx.mode {
Mode::Visual(visual) => selection(visual, ctx, ViewKind::Canvas).into_iter().collect(), Mode::Visual(visual) => selection(visual, ctx, ViewKind::Canvas).into_iter().collect(),
Mode::Connect(connecting) => connect::others(connecting, cursor).into_iter().collect(),
_ => BTreeSet::new(), _ => BTreeSet::new(),
}; };
let aim = Aim::new(ctx);
let mut scene = Scene { let mut scene = Scene {
size, size,
zoom: camera.zoom, zoom: camera.zoom,
@@ -124,8 +127,11 @@ impl Scene {
}; };
for &id in &visible.order { for &id in &visible.order {
let Some(node) = doc.node(id) else { continue }; let Some(node) = doc.node(id) else { continue };
let style = theme.node_style(node, id == doc.root()); let style = ctx.node_style(id, node);
let badge = visible.hidden.get(&id).map(|count| format!("+{count}")).unwrap_or_default(); let mut badge = ctx.badge(id, node).unwrap_or_default();
if let Some(count) = visible.hidden.get(&id) {
badge = if badge.is_empty() { format!("+{count}") } else { format!("{badge} +{count}") };
}
let max_width = settings.max_node_width; let max_width = settings.max_node_width;
let (rect, shape) = match edit.filter(|edit| edit.node == id && camera.zoom == Zoom::Normal) { let (rect, shape) = match edit.filter(|edit| edit.node == id && camera.zoom == Zoom::Normal) {
Some(edit) => scene.draft(node, edit, badge, style.border, camera, max_width), Some(edit) => scene.draft(node, edit, badge, style.border, camera, max_width),
@@ -143,7 +149,7 @@ impl Scene {
scene.index = scene.nodes.iter().enumerate().map(|(index, node)| (node.id, index)).collect(); scene.index = scene.nodes.iter().enumerate().map(|(index, node)| (node.id, index)).collect();
scene.cover(); scene.cover();
scene.add_borders(); scene.add_borders();
scene.add_lines(doc, theme); scene.add_lines(doc, theme, &aim);
scene scene
} }
@@ -248,13 +254,18 @@ impl Scene {
} }
/// Adds every branch (from a shown parent to a shown child) and every /// Adds every branch (from a shown parent to a shown child) and every
/// cross-link between shown nodes. /// cross-link between shown nodes. In connect mode, the lines aimed at
fn add_lines(&mut self, doc: &Document, theme: &Theme) { /// stand out in the visual color, and a dashed arrow runs to the cursor
/// from each node with no line to it yet.
fn add_lines(&mut self, doc: &Document, theme: &Theme, aim: &Aim) {
for child_index in 0..self.nodes.len() { for child_index in 0..self.nodes.len() {
let child = &self.nodes[child_index]; let child = &self.nodes[child_index];
let Some(Place::Child { parent, branch }) = doc.place(child.id) else { continue }; let Some(Place::Child { parent, branch }) = doc.place(child.id) else { continue };
let Some(&parent_index) = self.index.get(parent) else { continue }; let Some(&parent_index) = self.index.get(parent) else { continue };
let style = theme.branch_style(branch, self.nodes[parent_index].color); let mut style = theme.branch_style(branch, self.nodes[parent_index].color);
if aim.lines.contains(&LineRef::Branch(child.id)) {
style.color = self.visual;
}
let owner = LineOwner::Branch(child.id); let owner = LineOwner::Branch(child.id);
self.connect(parent_index, child_index, Bend::NearStart, style, owner, None); self.connect(parent_index, child_index, Bend::NearStart, style, owner, None);
} }
@@ -263,10 +274,20 @@ impl Scene {
else { else {
continue; continue;
}; };
let style = theme.link_style(&link.look, self.nodes[start].color); let mut style = theme.link_style(&link.look, self.nodes[start].color);
if aim.lines.contains(&LineRef::Link(link.key())) {
style.color = self.visual;
}
let label = theme.link_label(link); let label = theme.link_label(link);
self.connect(start, end, Bend::Halfway, style, LineOwner::Link(link.key()), label); self.connect(start, end, Bend::Halfway, style, LineOwner::Link(link.key()), label);
} }
for &(source, target) in &aim.previews {
let (Some(&start), Some(&end)) = (self.index.get(&source), self.index.get(&target)) else {
continue;
};
let style = LineStyle { stroke: Stroke::Dashed, arrows: Arrows::End, color: self.visual };
self.connect(start, end, Bend::Halfway, style, LineOwner::Preview, None);
}
} }
/// Draws one line from node `from` to node `to` (indexes in `nodes`), /// Draws one line from node `from` to node `to` (indexes in `nodes`),
@@ -336,6 +357,30 @@ impl Scene {
} }
} }
/// What connect mode aims at: the lines already there between the nodes
/// being connected and the cursor's, and the pairs with no line yet.
#[derive(Debug, Default)]
struct Aim {
lines: BTreeSet<LineRef>,
previews: Vec<(NodeId, NodeId)>,
}
impl Aim {
fn new(ctx: &ViewContext<'_>) -> Aim {
let mut aim = Aim::default();
let Mode::Connect(connecting) = ctx.mode else { return aim };
for source in connect::others(connecting, ctx.cursor) {
match ctx.doc.line_between(source, ctx.cursor) {
Some(line) => {
aim.lines.insert(line);
}
None => aim.previews.push((source, ctx.cursor)),
}
}
aim
}
}
/// The index of the topmost node on a cell, in a `covered` grid. /// The index of the topmost node on a cell, in a `covered` grid.
fn covered_at(covered: &[Option<usize>], size: Size, at: ScreenPos) -> Option<usize> { fn covered_at(covered: &[Option<usize>], size: Size, at: ScreenPos) -> Option<usize> {
let (width, height) = (i32::from(size.width), i32::from(size.height)); let (width, height) = (i32::from(size.width), i32::from(size.height));
+8 -6
View File
@@ -75,10 +75,11 @@ impl CanvasView {
Scene::build(ctx, self.camera, self.area.as_size()) Scene::build(ctx, self.camera, self.area.as_size())
} }
/// A left press: on a node, selects it and starts dragging its branch; /// A left press: on a node, selects it and starts dragging its branch
/// on empty space, starts panning. /// (in connect mode, just aims at it); on empty space, starts panning.
fn press(&mut self, at: ScreenPos) -> Vec<Action> { fn press(&mut self, at: ScreenPos, ctx: &ViewContext<'_>) -> Vec<Action> {
match self.scene.node_at(at) { match self.scene.node_at(at) {
Some(id) if matches!(ctx.mode, Mode::Connect(_)) => vec![Action::cursor(id)],
Some(id) => { Some(id) => {
let moved = CanvasOffset::default(); let moved = CanvasOffset::default();
self.drag = Some(Drag::Node { id, from: at, moved, group: Group::new() }); self.drag = Some(Drag::Node { id, from: at, moved, group: Group::new() });
@@ -264,6 +265,7 @@ impl View for CanvasView {
mode, mode,
&[Context::Canvas, Context::Normal, Context::Motions], &[Context::Canvas, Context::Normal, Context::Motions],
&[Context::Visual, Context::Canvas, Context::Motions], &[Context::Visual, Context::Canvas, Context::Motions],
&[Context::Connect, Context::Canvas, Context::Motions],
) )
} }
@@ -311,7 +313,7 @@ impl View for CanvasView {
Mode::Move(grab) => return self.move_command(typed, grab, ctx), Mode::Move(grab) => return self.move_command(typed, grab, ctx),
// Keys inside a title are the edit's, not the view's. // Keys inside a title are the edit's, not the view's.
Mode::Text(_) | Mode::Popup(_) => return None, Mode::Text(_) | Mode::Popup(_) => return None,
Mode::Normal | Mode::Visual(_) | Mode::Command(_) => {} Mode::Normal | Mode::Visual(_) | Mode::Connect(_) | Mode::Command(_) => {}
} }
let steps = cells(typed); let steps = cells(typed);
let half_page = i32::from(self.area.height / 2); let half_page = i32::from(self.area.height / 2);
@@ -348,7 +350,7 @@ impl View for CanvasView {
} }
Gesture::Press { at, .. } => { Gesture::Press { at, .. } => {
let at = self.screen(at); let at = self.screen(at);
let Mode::Text(edit) = ctx.mode else { return self.press(at) }; let Mode::Text(edit) = ctx.mode else { return self.press(at, ctx) };
// While a title is edited, a click in it moves the text // While a title is edited, a click in it moves the text
// cursor; a click anywhere else keeps the edit, finishes it, // cursor; a click anywhere else keeps the edit, finishes it,
// and does what a click does. // and does what a click does.
@@ -356,7 +358,7 @@ impl View for CanvasView {
return vec![Action::App(AppAction::TextCursor(offset))]; return vec![Action::App(AppAction::TextCursor(offset))];
} }
let mut actions = edit.finish(ctx); let mut actions = edit.finish(ctx);
actions.extend(self.press(at)); actions.extend(self.press(at, ctx));
actions actions
} }
Gesture::DoublePress { at } => { Gesture::DoublePress { at } => {
+75 -6
View File
@@ -15,17 +15,22 @@ pub mod note;
pub mod outline; pub mod outline;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::Path;
use ratatui::Frame; use ratatui::Frame;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::Color;
use crate::app::action::Action; use crate::app::action::Action;
use crate::app::mode::Mode; use crate::app::mode::Mode;
use crate::app::portals::{Portals, Shown};
use crate::input::bindings::Context; use crate::input::bindings::Context;
use crate::input::engine::Typed; use crate::input::engine::Typed;
use crate::input::mouse::Gesture; use crate::input::mouse::Gesture;
use crate::model::{CanvasRect, CanvasSize, Document, Node, NodeId}; use crate::model::{CanvasRect, CanvasSize, Content, Document, LinkPath, Node, NodeId, Portal};
use crate::ui::theme::Theme; use crate::storage::link_paths::resolve;
use crate::storage::names::title_from_file;
use crate::ui::theme::{NodeStyle, Theme};
use canvas::Camera; use canvas::Camera;
use canvas::boxes::NodeBox; use canvas::boxes::NodeBox;
@@ -80,15 +85,68 @@ pub struct ViewContext<'a> {
pub mode: &'a Mode, pub mode: &'a Mode,
pub theme: &'a Theme, pub theme: &'a Theme,
pub settings: &'a ViewSettings, pub settings: &'a ViewSettings,
/// What the documents portals lead to look like.
pub portals: &'a Portals,
/// The folder of the document's file: portal paths start there.
pub folder: &'a Path,
} }
impl ViewContext<'_> { impl<'a> ViewContext<'a> {
/// Every node's box on the canvas, at Normal zoom. Every view needs /// Every node's box on the canvas, at Normal zoom. Every view needs
/// these to place new nodes, since every node has a spot on the canvas. /// these to place new nodes, since every node has a spot on the canvas.
pub fn box_rects(&self) -> BTreeMap<NodeId, CanvasRect> { pub fn box_rects(&self) -> BTreeMap<NodeId, CanvasRect> {
canvas::boxes::box_rects(self.doc, self.settings.max_node_width, self.theme.glyphs) canvas::boxes::box_rects(self.doc, self.settings.max_node_width, self.theme.glyphs)
} }
/// How a node is drawn, worked out by the theme. A portal without a
/// color of its own takes its document's, and fades when its document
/// isn't there.
pub fn node_style(&self, id: NodeId, node: &Node) -> NodeStyle {
let mut style = self.theme.node_style(node, id == self.doc.root());
if let Content::Portal(portal) = &node.content {
match self.portal(portal) {
Shown::Live(seen) if style.color == Color::Reset => {
style.color = self.theme.node_style(&seen.root, true).color;
}
Shown::Missing | Shown::Other(_) => style.faded = true,
Shown::Live(_) | Shown::Unknown => {}
}
}
style
}
/// What a node's box says in its bottom right corner, besides what a
/// fold hides: on the root, how many portals lead to the document (`«
/// 2`); on a portal, how many nodes its document has (`?` when it isn't
/// there).
pub fn badge(&self, id: NodeId, node: &Node) -> Option<String> {
if let Content::Portal(portal) = &node.content {
return match self.portal(portal) {
Shown::Live(seen) => Some(seen.count.to_string()),
Shown::Missing | Shown::Other(_) => Some("?".to_string()),
Shown::Unknown => None,
};
}
let backlinks = self.doc.backlinks().len();
(id == self.doc.root() && backlinks > 0)
.then(|| format!("{} {backlinks}", self.theme.glyphs.backlink()))
}
/// What a portal of this document shows of its own document.
pub fn portal(&self, portal: &Portal) -> Shown<'a> {
self.portals.shown(self.folder, portal)
}
/// The name of the document at a path a backlink keeps (relative to
/// this document's folder): its title when it's been seen, or one made
/// from its file name.
pub fn document_named(&self, path: &LinkPath) -> String {
let path = resolve(self.folder, path);
self.portals
.seen(&path)
.map_or_else(|| title_from_file(&path).to_string(), |seen| seen.title().to_string())
}
/// The size a node's box has on the canvas. /// The size a node's box has on the canvas.
pub fn box_size(&self, node: &Node) -> CanvasSize { pub fn box_size(&self, node: &Node) -> CanvasSize {
NodeBox::new(node, String::new(), self.settings.max_node_width, self.theme.glyphs).rect.size NodeBox::new(node, String::new(), self.settings.max_node_width, self.theme.glyphs).rect.size
@@ -96,16 +154,18 @@ impl ViewContext<'_> {
} }
/// The key tables a view reads in a mode: `normal` in normal mode (its own /// The key tables a view reads in a mode: `normal` in normal mode (its own
/// table first), `visual` in visual mode, and what every view reads in the /// table first), `visual` and `connect` in those modes, and what every view
/// other modes. /// reads in the other modes.
pub fn contexts_for( pub fn contexts_for(
mode: &Mode, mode: &Mode,
normal: &'static [Context], normal: &'static [Context],
visual: &'static [Context], visual: &'static [Context],
connect: &'static [Context],
) -> &'static [Context] { ) -> &'static [Context] {
match mode { match mode {
Mode::Normal => normal, Mode::Normal => normal,
Mode::Visual(_) => visual, Mode::Visual(_) => visual,
Mode::Connect(_) => connect,
Mode::Text(edit) if edit.editor.is_typing() => &[Context::Insert], Mode::Text(edit) if edit.editor.is_typing() => &[Context::Insert],
Mode::Text(_) => &[Context::Text], Mode::Text(_) => &[Context::Text],
Mode::Move(_) => &[Context::Move], Mode::Move(_) => &[Context::Move],
@@ -170,11 +230,20 @@ pub(crate) mod test_support {
pub mode: Mode, pub mode: Mode,
pub theme: Theme, pub theme: Theme,
pub settings: ViewSettings, pub settings: ViewSettings,
pub portals: Portals,
} }
impl Setup { impl Setup {
pub fn ctx<'a>(&'a self, doc: &'a Document, cursor: NodeId) -> ViewContext<'a> { pub fn ctx<'a>(&'a self, doc: &'a Document, cursor: NodeId) -> ViewContext<'a> {
ViewContext { doc, cursor, mode: &self.mode, theme: &self.theme, settings: &self.settings } ViewContext {
doc,
cursor,
mode: &self.mode,
theme: &self.theme,
settings: &self.settings,
portals: &self.portals,
folder: Path::new("."),
}
} }
} }
} }
+49 -8
View File
@@ -14,7 +14,8 @@
//! ``` //! ```
//! //!
//! It shows the cursor's node, and follows it. A note is made of parts: the //! It shows the cursor's node, and follows it. A note is made of parts: the
//! title, the tags, the body, each link and each child. `j` and `k` go from //! title, the tags, the body, each link, each child, and on the root, each
//! portal that leads to the document. `j` and `k` go from
//! part to part (the bar on the left marks the one you're on), and Enter or //! part to part (the bar on the left marks the one you're on), and Enter or
//! `i` edits it, or goes to that link or child. The body is edited like a //! `i` edits it, or goes to that link or child. The body is edited like a
//! title, with Enter starting a new line. `h` and `l` turn the page: the //! title, with Enter starting a new line. `h` and `l` turn the page: the
@@ -27,16 +28,19 @@ use ratatui::layout::{Position, Rect};
use ratatui::style::{Modifier, Style}; use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use crate::app::action::{Action, AppAction, Message}; use crate::app::action::{self, Action, AppAction, Message};
use crate::app::commands; use crate::app::commands;
use crate::app::jumps::Place;
use crate::app::mode::Mode; use crate::app::mode::Mode;
use crate::app::popup::MenuFor; use crate::app::popup::MenuFor;
use crate::app::portals::Shown;
use crate::app::text_edit::{EditAt, Field, StartEdit, TextEdit}; use crate::app::text_edit::{EditAt, Field, StartEdit, TextEdit};
use crate::input::bindings::Context; use crate::input::bindings::Context;
use crate::input::command::{Command, Motion}; use crate::input::command::{Command, Motion};
use crate::input::engine::Typed; use crate::input::engine::Typed;
use crate::input::mouse::{Cell, Gesture}; use crate::input::mouse::{Cell, Gesture};
use crate::model::{Content, Direction, NodeId}; use crate::model::{Content, Direction, NodeId};
use crate::storage::link_paths::resolve;
use crate::ui::text_editor::layout; use crate::ui::text_editor::layout;
use crate::view::{View, ViewContext, ViewKind, contexts_for}; use crate::view::{View, ViewContext, ViewKind, contexts_for};
@@ -56,6 +60,8 @@ enum Part {
Link(usize), Link(usize),
/// A child, in order. /// A child, in order.
Child(usize), Child(usize),
/// On the root: a portal in another document that leads here.
Backlink(usize),
} }
/// The Note view. /// The Note view.
@@ -78,6 +84,8 @@ pub struct NoteView {
links: Vec<NodeId>, links: Vec<NodeId>,
/// The node's children, in order. /// The node's children, in order.
children: Vec<NodeId>, children: Vec<NodeId>,
/// On the root: where the portals that lead here are.
backlinks: Vec<Place>,
/// The part being edited, as drawn. /// The part being edited, as drawn.
editing: Option<Editing>, editing: Option<Editing>,
/// A preview (the search's): no bar marks a part. /// A preview (the search's): no bar marks a part.
@@ -131,7 +139,7 @@ impl NoteView {
let muted = Style::new().fg(theme.muted); let muted = Style::new().fg(theme.muted);
// The title, in the node's color. // The title, in the node's color.
let color = theme.node_style(node, id == doc.root()).color; let color = ctx.node_style(id, node).color;
let title_style = Style::new().fg(color).add_modifier(Modifier::BOLD); let title_style = Style::new().fg(color).add_modifier(Modifier::BOLD);
match (draft(Field::Title), &node.content) { match (draft(Field::Title), &node.content) {
(Some(edit), _) => self.add_draft(Part::Title, edit, title_style), (Some(edit), _) => self.add_draft(Part::Title, edit, title_style),
@@ -139,8 +147,16 @@ impl NoteView {
(None, Content::Portal(portal)) => { (None, Content::Portal(portal)) => {
let title = format!("{} {}", glyphs.portal(), portal.title); let title = format!("{} {}", glyphs.portal(), portal.title);
self.add_text(Part::Title, &title, title_style); self.add_text(Part::Title, &title, title_style);
let path = format!("a portal to {}", portal.path.as_str()); let path = portal.path.as_str();
self.add_row(Some(Part::Title), vec![Span::styled(path, muted)]); let about = match ctx.portal(portal) {
Shown::Live(seen) => {
format!("a portal to {path} · {} · Enter goes there", action::nodes(seen.count))
}
Shown::Missing => format!("a portal to {path}, which isn't there"),
Shown::Other(seen) => format!("a portal to {path}, which holds \"{}\" now", seen.title()),
Shown::Unknown => format!("a portal to {path}"),
};
self.add_row(Some(Part::Title), vec![Span::styled(about, muted)]);
} }
} }
@@ -215,6 +231,23 @@ impl NoteView {
vec![Span::raw(format!("{} {title}", glyphs.child_mark()))], vec![Span::raw(format!("{} {title}", glyphs.child_mark()))],
); );
} }
// On the root: the portals that lead to the document.
self.backlinks.clear();
if id == doc.root() && !doc.backlinks().is_empty() {
self.add_row(None, Vec::new());
self.add_row(None, vec![Span::styled("Portals here", muted)]);
}
for (index, backlink) in doc.backlinks().iter().enumerate().filter(|_| id == doc.root()) {
let title = ctx.document_named(&backlink.from);
let spans = vec![
Span::raw(format!("{} {title}", glyphs.backlink())),
Span::styled(format!(" {}", backlink.from.as_str()), muted),
];
self.add_row(Some(Part::Backlink(index)), spans);
let path = resolve(ctx.folder, &backlink.from);
self.backlinks.push(Place { uid: backlink.uid, path, node: backlink.node });
}
} }
fn add_row(&mut self, part: Option<Part>, spans: Vec<Span<'static>>) { fn add_row(&mut self, part: Option<Part>, spans: Vec<Span<'static>>) {
@@ -299,7 +332,7 @@ impl NoteView {
Part::Title => return None, Part::Title => return None,
Part::Tags => StartEdit::Tags(at), Part::Tags => StartEdit::Tags(at),
Part::Body => StartEdit::Body { at, in_card: false }, Part::Body => StartEdit::Body { at, in_card: false },
Part::Link(_) | Part::Child(_) if at != EditAt::End => { Part::Link(_) | Part::Child(_) | Part::Backlink(_) if at != EditAt::End => {
let text = "Enter goes there: the title, the tags and the body are the parts you type in"; let text = "Enter goes there: the title, the tags and the body are the parts you type in";
return Some(vec![Action::say(Message::info(text))]); return Some(vec![Action::say(Message::info(text))]);
} }
@@ -310,6 +343,13 @@ impl NoteView {
Part::Child(index) => { Part::Child(index) => {
return Some(self.children.get(index).map(|&to| Action::cursor(to)).into_iter().collect()); return Some(self.children.get(index).map(|&to| Action::cursor(to)).into_iter().collect());
} }
// Up to the portal, in its document: a jump.
Part::Backlink(index) => {
let place = self.backlinks.get(index).cloned();
return Some(
place.map(|place| Action::App(AppAction::GoToPlace(place))).into_iter().collect(),
);
}
}; };
Some(vec![Action::App(AppAction::StartEdit(start))]) Some(vec![Action::App(AppAction::StartEdit(start))])
} }
@@ -364,6 +404,7 @@ impl View for NoteView {
mode, mode,
&[Context::Note, Context::Normal, Context::Motions], &[Context::Note, Context::Normal, Context::Motions],
&[Context::Visual, Context::Motions], &[Context::Visual, Context::Motions],
&[Context::Connect, Context::Motions],
) )
} }
@@ -419,7 +460,7 @@ impl View for NoteView {
fn command(&mut self, typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> { fn command(&mut self, typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
match ctx.mode { match ctx.mode {
Mode::Move(grab) => return commands::reorder(typed, grab, ctx), Mode::Move(grab) => return commands::reorder(typed, grab, ctx),
Mode::Normal | Mode::Visual(_) => {} Mode::Normal | Mode::Visual(_) | Mode::Connect(_) => {}
Mode::Text(_) | Mode::Command(_) | Mode::Popup(_) => return None, Mode::Text(_) | Mode::Command(_) | Mode::Popup(_) => return None,
} }
let times = isize::try_from(typed.times()).unwrap_or(1); let times = isize::try_from(typed.times()).unwrap_or(1);
@@ -487,7 +528,7 @@ impl View for NoteView {
let start = StartEdit::Body { at: spot, in_card: false }; let start = StartEdit::Body { at: spot, in_card: false };
Some(vec![Action::App(AppAction::StartEdit(start))]) Some(vec![Action::App(AppAction::StartEdit(start))])
} }
Part::Link(_) | Part::Child(_) => self.open(EditAt::End), Part::Link(_) | Part::Child(_) | Part::Backlink(_) => self.open(EditAt::End),
}; };
actions.extend(open.unwrap_or_default()); actions.extend(open.unwrap_or_default());
actions actions
+8 -2
View File
@@ -35,6 +35,7 @@ use unicode_width::UnicodeWidthStr;
use crate::app::action::{Action, AppAction, Message}; use crate::app::action::{Action, AppAction, Message};
use crate::app::commands; use crate::app::commands;
use crate::app::connect;
use crate::app::mode::Mode; use crate::app::mode::Mode;
use crate::app::popup::MenuFor; use crate::app::popup::MenuFor;
use crate::app::text_edit::{EditAt, StartEdit, TextEdit}; use crate::app::text_edit::{EditAt, StartEdit, TextEdit};
@@ -124,6 +125,7 @@ impl OutlineView {
let edit = ctx.mode.title_edit(); let edit = ctx.mode.title_edit();
self.picked = match ctx.mode { self.picked = match ctx.mode {
Mode::Visual(visual) => selection(visual, ctx, ViewKind::Outline).into_iter().collect(), Mode::Visual(visual) => selection(visual, ctx, ViewKind::Outline).into_iter().collect(),
Mode::Connect(connecting) => connect::others(connecting, ctx.cursor).into_iter().collect(),
_ => BTreeSet::new(), _ => BTreeSet::new(),
}; };
// The color of the lines from a node to its children, as on the // The color of the lines from a node to its children, as on the
@@ -173,7 +175,7 @@ impl OutlineView {
let (doc, theme, glyphs) = (ctx.doc, ctx.theme, ctx.theme.glyphs); let (doc, theme, glyphs) = (ctx.doc, ctx.theme, ctx.theme.glyphs);
let Some(node) = doc.node(id) else { return }; let Some(node) = doc.node(id) else { return };
let top_level = lines.spans.is_empty(); let top_level = lines.spans.is_empty();
let style = theme.node_style(node, id == doc.root()); let style = ctx.node_style(id, node);
let color = style.color; let color = style.color;
let has_children = !doc.children(id).is_empty(); let has_children = !doc.children(id).is_empty();
let folded = visible.hidden.contains_key(&id); let folded = visible.hidden.contains_key(&id);
@@ -228,6 +230,9 @@ impl OutlineView {
if has_body && !self.bodies { if has_body && !self.bodies {
lines.push(format!(" {}", glyphs.body_mark()), muted); lines.push(format!(" {}", glyphs.body_mark()), muted);
} }
if let Some(badge) = ctx.badge(id, node) {
lines.push(format!(" {badge}"), muted);
}
if let Some(count) = visible.hidden.get(&id) { if let Some(count) = visible.hidden.get(&id) {
lines.push(format!(" +{count}"), muted); lines.push(format!(" +{count}"), muted);
} }
@@ -433,6 +438,7 @@ impl View for OutlineView {
mode, mode,
&[Context::Outline, Context::Normal, Context::Motions], &[Context::Outline, Context::Normal, Context::Motions],
&[Context::Visual, Context::Outline, Context::Motions], &[Context::Visual, Context::Outline, Context::Motions],
&[Context::Connect, Context::Outline, Context::Motions],
) )
} }
@@ -475,7 +481,7 @@ impl View for OutlineView {
fn command(&mut self, typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> { fn command(&mut self, typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
match ctx.mode { match ctx.mode {
Mode::Move(grab) => return commands::reorder(typed, grab, ctx), Mode::Move(grab) => return commands::reorder(typed, grab, ctx),
Mode::Normal | Mode::Visual(_) => {} Mode::Normal | Mode::Visual(_) | Mode::Connect(_) => {}
Mode::Text(_) | Mode::Command(_) | Mode::Popup(_) => return None, Mode::Text(_) | Mode::Command(_) | Mode::Popup(_) => return None,
} }
let rows = isize::try_from(typed.times()).unwrap_or(1); let rows = isize::try_from(typed.times()).unwrap_or(1);