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:
1 parent
bd819409f4
commit
ebe8a5988e
45 files changed
+2329
-148
No files matched your search
@@ -197,6 +197,58 @@ lines.
|
||||
beside the node. `:marks`, `:jumps` and `:registers` list them; Enter goes
|
||||
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)
|
||||
- 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
|
||||
@@ -406,7 +458,8 @@ enum Mode {
|
||||
// two can't disagree. Rapid entry lives here too.
|
||||
Visual(Visual), // anchor + Rectangle or Branches
|
||||
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
|
||||
Popup(Popup), // the help, the menu, a picker or the K card (which
|
||||
// holds its own TextEdit while the body is edited)
|
||||
@@ -420,12 +473,12 @@ text).
|
||||
|
||||
| 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 |
|
||||
| **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 |
|
||||
| **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) |
|
||||
| **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 |
|
||||
@@ -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 |
|
||||
| **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 |
|
||||
| **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 |
|
||||
|
||||
## Files
|
||||
@@ -616,9 +669,9 @@ cumulus/
|
||||
│ command_line, theme, glyphs
|
||||
├─ 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)
|
||||
├─ storage/ document_file (RON), write_atomically, names (file names from titles), backup,
|
||||
│ lock, stamp (noticing changes made outside), memory (memory.ron), markdown
|
||||
│ (nodes as Markdown, for the clipboard)
|
||||
├─ storage/ document_file (RON), write_atomically, names (file names from titles), link_paths
|
||||
│ (portal paths: relative, with /), backup, lock, stamp (noticing changes made
|
||||
│ 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),
|
||||
render, terminal (the main loop, setup, teardown, panic hook), startup (what
|
||||
`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
|
||||
"+), yank (nodes into registers and back), visual (selections), repeat (`.`),
|
||||
search (`/`), jumps (each pane's jump list), marks (capital marks, `:marks`),
|
||||
panes (the split tree), popup (help, menu, pickers, card, questions); later
|
||||
portals (cache, backlinks, renames)
|
||||
connect (connect mode), portals (what portals show, going through them,
|
||||
`: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`,
|
||||
`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
|
||||
check the screen, the documents and the files.
|
||||
|
||||
|
||||
@@ -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
|
||||
mouse works too. Runs on Windows and Linux.
|
||||
|
||||
> **Status:** being built, one stage at a time. See [DESIGN.md](DESIGN.md) for
|
||||
> the plan; this README grows with the app.
|
||||
> **Status:** v1 is built: everything on [DESIGN.md](DESIGN.md)'s v1 list.
|
||||
> v1.1 (the board, a search across documents, tidying, export) comes next.
|
||||
|
||||
## 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
|
||||
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
|
||||
|
||||
`: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) |
|
||||
| `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) |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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… |
|
||||
| `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 |
|
||||
|
||||
@@ -40,4 +40,11 @@
|
||||
Empty it on Sundays.",
|
||||
),
|
||||
],
|
||||
backlinks: [
|
||||
(
|
||||
from: "garden.cum",
|
||||
uid: "7f3c9a1e52b04d18",
|
||||
node: 10,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
use super::documents::BufferId;
|
||||
use super::jumps::Place;
|
||||
use super::mode::Connect;
|
||||
use super::panes::Arrange;
|
||||
use super::popup::{MenuFor, PickKind};
|
||||
use super::repeat::Look;
|
||||
@@ -96,6 +97,9 @@ pub enum DocAction {
|
||||
SetMark(char),
|
||||
/// Go to a mark; `'` and `` ` `` go back to before the last jump.
|
||||
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.
|
||||
@@ -124,6 +128,21 @@ pub enum AppAction {
|
||||
EnterMoveMode(Vec<NodeId>),
|
||||
/// In move mode: the whole branch, or just the node.
|
||||
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.
|
||||
LeaveMode,
|
||||
OpenCommandLine,
|
||||
|
||||
+18
-1
@@ -4,7 +4,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
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.
|
||||
#[derive(Debug)]
|
||||
@@ -83,6 +83,12 @@ impl Buffer {
|
||||
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::Redo(times) => self.undo_or_redo(times, false),
|
||||
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
|
||||
/// 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,
|
||||
|
||||
+2
-1
@@ -65,7 +65,8 @@ pub fn actions_for(call: Call, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
Call::Find(text) => app(AppAction::Find(text)),
|
||||
Call::Set(arg) => vec![Action::App(AppAction::Set(arg))],
|
||||
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::Marks => app(AppAction::OpenMarks),
|
||||
Call::Registers => app(AppAction::OpenRegisters),
|
||||
|
||||
+6
-1
@@ -42,8 +42,12 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
|
||||
Command::JumpToMark(name) => vec![Action::Doc(DocAction::JumpToMark(name))],
|
||||
Command::FollowLink => follow_link(ctx),
|
||||
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::EditTitleAtStart => edit(EditAt::Start),
|
||||
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::CloseAllFolds => fold_all(ctx, Fold::Closed),
|
||||
Command::MoveMode => app(AppAction::EnterMoveMode(Vec::new())),
|
||||
Command::ConnectMode => app(AppAction::EnterConnectMode(Vec::new())),
|
||||
Command::Visual => app(AppAction::Visual { branches: false }),
|
||||
Command::VisualBranches => app(AppAction::Visual { branches: true }),
|
||||
Command::CommandLine => app(AppAction::OpenCommandLine),
|
||||
|
||||
@@ -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
@@ -1,11 +1,12 @@
|
||||
//! The open documents (vim's buffers), each with its file: where it is, the
|
||||
//! version on disk, and whether this cumulus may write it.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
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::stamp::Stamp;
|
||||
use crate::view::canvas::Camera;
|
||||
@@ -23,6 +24,9 @@ pub struct Open {
|
||||
pub file: DocFile,
|
||||
/// How a canvas last showed it: the next canvas on it starts there.
|
||||
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 {
|
||||
@@ -68,6 +72,9 @@ pub struct DocFile {
|
||||
/// Something wrong with the file. Autosave holds off until it's sorted
|
||||
/// out.
|
||||
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.
|
||||
@@ -114,14 +121,15 @@ pub struct Documents {
|
||||
|
||||
impl 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.
|
||||
pub fn add(&mut self, buffer: Buffer, file: DocFile) -> BufferId {
|
||||
let id = BufferId(self.next);
|
||||
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
|
||||
}
|
||||
|
||||
@@ -212,6 +220,7 @@ mod tests {
|
||||
access: Access::ReadOnly,
|
||||
changed: None,
|
||||
trouble: None,
|
||||
named: Title::untitled(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+85
-10
@@ -13,6 +13,7 @@
|
||||
//! - A document another cumulus has open is read-only here, until that one
|
||||
//! lets go of it.
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -65,7 +66,7 @@ pub enum OpenError {
|
||||
|
||||
impl OpenError {
|
||||
/// 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 {
|
||||
OpenError::Read { 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>) {
|
||||
let Loaded { doc, path, stamp } = loaded;
|
||||
let name = short_name(&path);
|
||||
let named = doc.title().clone();
|
||||
let access = access(paths, doc.uid());
|
||||
let read_only = matches!(access, Access::ReadOnly);
|
||||
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 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.
|
||||
@@ -172,6 +174,8 @@ impl App {
|
||||
let (buffer, file, said) = opened(loaded, &self.paths);
|
||||
let id = self.shared.docs.add(buffer, file);
|
||||
self.came_back_to(id);
|
||||
self.sync_backlinks(id);
|
||||
self.refresh_portals(true);
|
||||
(id, said)
|
||||
}
|
||||
|
||||
@@ -286,9 +290,58 @@ impl App {
|
||||
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
|
||||
/// file to the backups first.
|
||||
/// Saves a document: when its root's title changed since the file was
|
||||
/// 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> {
|
||||
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 open = self.shared.docs.get_mut(id);
|
||||
if open.is_read_only() {
|
||||
@@ -331,6 +384,7 @@ impl App {
|
||||
said.push(Message::info(format!("{}: you can edit it now", open.file_name())));
|
||||
}
|
||||
}
|
||||
self.refresh_portals(true);
|
||||
let changed = !said.is_empty();
|
||||
if let Some(message) = Message::joined(said) {
|
||||
self.message = Some(message);
|
||||
@@ -342,7 +396,7 @@ impl App {
|
||||
/// 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.
|
||||
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();
|
||||
if busy && id == self.buffer() {
|
||||
return None;
|
||||
@@ -384,6 +438,9 @@ impl App {
|
||||
fn take_in(&mut self, id: BufferId, newer: Document, said: &str) -> Message {
|
||||
let open = self.shared.docs.get_mut(id);
|
||||
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);
|
||||
open.file.saved = open.buffer.history().state();
|
||||
open.file.changed = None;
|
||||
@@ -462,7 +519,12 @@ impl App {
|
||||
}
|
||||
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
|
||||
@@ -498,10 +560,10 @@ impl App {
|
||||
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
|
||||
/// start at the focused document's folder, a folder means its default
|
||||
/// document, and `.cum` is added when there's no extension.
|
||||
fn resolve_path(&self, typed: &str) -> PathBuf {
|
||||
/// A path typed in `:e`, `:new` or `:portal`: `~` is the home folder,
|
||||
/// relative paths start at the focused document's folder, a folder means
|
||||
/// its default document, and `.cum` is added when there's no extension.
|
||||
pub(super) fn resolve_path(&self, typed: &str) -> PathBuf {
|
||||
let path = self.paths.expand(typed.trim());
|
||||
let path = if path.is_relative() { self.folder().join(path) } else { path };
|
||||
if path.is_dir() {
|
||||
@@ -557,8 +619,10 @@ impl App {
|
||||
self.shared.docs.remove(id);
|
||||
let next = self.shared.docs.at(index.saturating_sub(1));
|
||||
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) {
|
||||
pane.show(next, pane.view.kind(), camera);
|
||||
pane.arrive(uid);
|
||||
}
|
||||
Some(Message::done(format!("closed {name}")))
|
||||
}
|
||||
@@ -643,6 +707,7 @@ impl App {
|
||||
}
|
||||
let camera = self.shared.docs.get(id).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 {
|
||||
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 {
|
||||
Message::problem(format!("{} is open in another cumulus: read-only here", open.file_name()))
|
||||
}
|
||||
|
||||
+27
-8
@@ -6,7 +6,7 @@ use ratatui::layout::Position;
|
||||
|
||||
use super::action::{Action, AppAction, DocumentChoice, Message};
|
||||
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::config::settings::Setting;
|
||||
use crate::config::theme_file;
|
||||
@@ -26,7 +26,7 @@ impl App {
|
||||
Mode::Command(_) => self.command_line_key(key),
|
||||
Mode::Text(_) => self.text_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())) {
|
||||
Outcome::Run(typed) => self.run_typed(typed),
|
||||
Outcome::Unknown(keys) => {
|
||||
@@ -37,10 +37,12 @@ impl App {
|
||||
}
|
||||
}
|
||||
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
|
||||
/// commands (in visual mode), then the shared commands.
|
||||
/// A command: the focused view has the first say, then visual or
|
||||
/// connect mode's commands (in those modes), then the shared commands.
|
||||
pub(super) fn dispatch(&mut self, typed: Typed) {
|
||||
let selected = self.selection().unwrap_or_default();
|
||||
let pane = self.panes.current_mut();
|
||||
@@ -51,6 +53,7 @@ impl App {
|
||||
.command(&typed, &ctx)
|
||||
.or_else(|| match &self.shared.mode {
|
||||
Mode::Visual(visual) => visual::command(&typed, visual, &selected, kind, &ctx),
|
||||
Mode::Connect(connect) => connect::command(&typed, connect, &ctx),
|
||||
_ => None,
|
||||
})
|
||||
.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
|
||||
/// the pane it started in), which takes the focus.
|
||||
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);
|
||||
// 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
|
||||
// clicking somewhere else mustn't wait to delete what comes next.
|
||||
if matches!(gesture, Gesture::Press { .. } | Gesture::DoublePress { .. } | Gesture::RightPress { .. })
|
||||
@@ -214,12 +231,14 @@ impl App {
|
||||
self.dragging = Some(target);
|
||||
}
|
||||
if target != self.panes.focused() {
|
||||
// Another pane: a text edited in this one is done first, and
|
||||
// move or visual mode ends.
|
||||
// Another pane: a text edited in this one is done first, move or
|
||||
// visual mode ends, and connect mode is cancelled.
|
||||
let ctx = self.shared.ctx(self.buffer());
|
||||
let mut actions = self.shared.mode.edit().map(|edit| edit.finish(&ctx)).unwrap_or_default();
|
||||
if let Mode::Move(_) | Mode::Visual(_) = self.shared.mode {
|
||||
actions.push(Action::App(AppAction::LeaveMode));
|
||||
match &self.shared.mode {
|
||||
Mode::Move(_) | Mode::Visual(_) => actions.push(Action::App(AppAction::LeaveMode)),
|
||||
Mode::Connect(connecting) => actions.extend(connect::cancel(connecting)),
|
||||
_ => {}
|
||||
}
|
||||
self.update(actions);
|
||||
self.panes.focus(target);
|
||||
|
||||
+9
-1
@@ -4,7 +4,7 @@
|
||||
//! 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.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::action::{DocAction, Message};
|
||||
use super::{App, files};
|
||||
@@ -65,6 +65,14 @@ impl Jumps {
|
||||
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
|
||||
/// (none until they're used), for `:jumps`.
|
||||
pub fn list(&self) -> (&[Place], Option<usize>) {
|
||||
|
||||
+52
-12
@@ -24,14 +24,16 @@ pub mod calls;
|
||||
mod clipboard;
|
||||
pub mod commands;
|
||||
mod configure;
|
||||
pub mod connect;
|
||||
pub mod documents;
|
||||
mod files;
|
||||
mod input;
|
||||
mod jumps;
|
||||
pub mod jumps;
|
||||
mod marks;
|
||||
pub mod mode;
|
||||
pub mod panes;
|
||||
pub mod popup;
|
||||
pub mod portals;
|
||||
pub mod registers;
|
||||
mod render;
|
||||
mod repeat;
|
||||
@@ -43,7 +45,7 @@ mod update;
|
||||
pub mod visual;
|
||||
mod yank;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
use ratatui::layout::Rect;
|
||||
@@ -59,6 +61,7 @@ use crate::input::bindings::Context;
|
||||
use crate::input::engine::KeyEngine;
|
||||
use crate::input::keymap::Keymap;
|
||||
use crate::input::mouse::MouseReader;
|
||||
use crate::model::DocUid;
|
||||
use crate::storage::memory::Memory;
|
||||
use crate::ui::theme::Theme;
|
||||
use crate::view::canvas::{Camera, CanvasView};
|
||||
@@ -71,6 +74,7 @@ use documents::{BufferId, Documents};
|
||||
use jumps::Jumps;
|
||||
use mode::Mode;
|
||||
use panes::{PaneId, Panes};
|
||||
use portals::Portals;
|
||||
use registers::Registers;
|
||||
use startup::Opening;
|
||||
|
||||
@@ -121,18 +125,28 @@ struct Shared {
|
||||
mode: Mode,
|
||||
theme: Theme,
|
||||
view: ViewSettings,
|
||||
/// What the documents portals lead to look like.
|
||||
portals: Portals,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
/// What a view showing document `buffer` looks at.
|
||||
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 {
|
||||
doc: buffer.doc(),
|
||||
cursor: buffer.cursor(),
|
||||
mode: &self.mode,
|
||||
doc: open.buffer.doc(),
|
||||
cursor: open.buffer.cursor(),
|
||||
mode,
|
||||
theme: &self.theme,
|
||||
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 {
|
||||
buffer: BufferId,
|
||||
view: Box<dyn View>,
|
||||
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 {
|
||||
@@ -160,14 +186,24 @@ impl Pane {
|
||||
if let Some(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`);
|
||||
/// its jump list stays.
|
||||
/// its jump list and its trail stay.
|
||||
fn show(&mut self, buffer: BufferId, kind: ViewKind, camera: Option<Camera>) {
|
||||
let jumps = std::mem::take(&mut self.jumps);
|
||||
*self = Pane { jumps, ..Pane::new(buffer, kind, camera) };
|
||||
let (jumps, trail) = (std::mem::take(&mut self.jumps), std::mem::take(&mut self.trail));
|
||||
*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 mut app = App {
|
||||
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()),
|
||||
paths,
|
||||
settings,
|
||||
@@ -223,6 +259,10 @@ impl App {
|
||||
for loaded in others {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ pub enum Mode {
|
||||
Visual(Visual),
|
||||
/// Moving the cursor's node (or its branch) with hjkl.
|
||||
Move(Grab),
|
||||
/// Linking nodes: the cursor goes to another node, the line to it shows,
|
||||
/// and Enter makes it.
|
||||
Connect(Connect),
|
||||
/// Typing a `:` command.
|
||||
Command(CommandLine),
|
||||
/// 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.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Grab {
|
||||
@@ -122,6 +145,7 @@ impl Mode {
|
||||
Mode::Visual(Visual { branches: false, .. }) => "VISUAL",
|
||||
Mode::Visual(_) => "VISUAL BRANCH",
|
||||
Mode::Move(_) => "MOVE",
|
||||
Mode::Connect(_) => "CONNECT",
|
||||
Mode::Command(_) => "COMMAND",
|
||||
Mode::Popup(popup) => popup.label(),
|
||||
}
|
||||
|
||||
+186
-6
@@ -19,8 +19,10 @@ use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use super::action::{Action, AppAction, CursorAfter, DocAction, DocumentChoice, Keep, Message};
|
||||
use super::commands;
|
||||
use super::connect;
|
||||
use super::documents::BufferId;
|
||||
use super::jumps::Place as JumpPlace;
|
||||
use super::mode::Connect;
|
||||
use super::registers::Registers;
|
||||
use super::repeat::Look;
|
||||
use super::search::Search;
|
||||
@@ -35,7 +37,8 @@ use crate::input::key::{Key, notation};
|
||||
use crate::input::keymap::{Keymap, Tables};
|
||||
use crate::input::mouse::{Cell, Gesture};
|
||||
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::list::{ListOutcome, ListPopup, ListRow, Place};
|
||||
@@ -84,6 +87,16 @@ pub enum PickKind {
|
||||
Tags,
|
||||
/// The document the pane shows.
|
||||
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.
|
||||
@@ -134,6 +147,9 @@ pub struct Picker {
|
||||
tags: Tags,
|
||||
/// Makes the changes to several nodes one undo step.
|
||||
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)]
|
||||
@@ -146,6 +162,9 @@ enum Pick {
|
||||
Document(usize),
|
||||
/// A document opened lately, by its file.
|
||||
Recent(PathBuf),
|
||||
LineColor(Option<LineColor>),
|
||||
LineStyle(Option<StyleName>),
|
||||
Label(Option<Label>),
|
||||
/// A heading over the rows after it.
|
||||
Heading,
|
||||
}
|
||||
@@ -422,6 +441,8 @@ impl Popup {
|
||||
}
|
||||
("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 {
|
||||
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)) {
|
||||
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
|
||||
@@ -540,6 +670,24 @@ impl Popup {
|
||||
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.
|
||||
pub fn card(node: NodeId) -> Popup {
|
||||
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> {
|
||||
match (outcome, self) {
|
||||
(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::Picked(index), Popup::Help(runs) | Popup::Menu(runs) | Popup::Ask(runs)) => {
|
||||
runs.run(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(),
|
||||
}
|
||||
}
|
||||
@@ -825,7 +977,7 @@ fn picked(value: &Pick, node: &crate::model::Node, tags: &Tags, current: usize)
|
||||
Pick::Style(style) => node.look.style == *style,
|
||||
Pick::Tag(tag) => tags.contains(tag),
|
||||
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));
|
||||
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::Border(border)) => Look::Border(*border),
|
||||
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 })
|
||||
}
|
||||
|
||||
/// Enter on a search that matches no tag: a new tag, put on the node.
|
||||
fn add_tag(&mut self, text: &str, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
/// A value for the lines connect mode is making, then back to it.
|
||||
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(_))) {
|
||||
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.
|
||||
fn tag_row(tag: &Tag, on: bool, theme: &Theme) -> ListRow {
|
||||
let row = ListRow::new(vec![Span::raw(tag.to_string())], tag.name());
|
||||
|
||||
@@ -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
@@ -2,6 +2,7 @@
|
||||
//! panes in their boxes, a popup over them, the status bar and the command
|
||||
//! line at the bottom, and which-key.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use ratatui::Frame;
|
||||
@@ -15,7 +16,7 @@ use super::action::{self, Message, Tone};
|
||||
use super::mode::{Grab, Mode};
|
||||
use super::popup::{Card, Popup};
|
||||
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::status_bar::{StatusBar, breadcrumb};
|
||||
use crate::ui::which_key::WhichKey;
|
||||
@@ -38,12 +39,12 @@ impl App {
|
||||
// is in the mode; the others show their documents as they are.
|
||||
let focused = self.panes.focused();
|
||||
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) {
|
||||
let Some(pane) = self.panes.get_mut(id) else { continue };
|
||||
let buffer = &docs.get(pane.buffer).buffer;
|
||||
let mode = if id == focused { mode } else { &normal };
|
||||
let ctx = ViewContext { doc: buffer.doc(), cursor: buffer.cursor(), mode, theme, settings: view };
|
||||
let buffer = &self.shared.docs.get(pane.buffer).buffer;
|
||||
let mode = if id == focused { &self.shared.mode } else { &normal };
|
||||
let ctx = self.shared.ctx_in(pane.buffer, mode);
|
||||
let color = if id == focused { theme.accent } else { theme.muted };
|
||||
let title = format!(" {} · {} ", pane.view.kind().name(), buffer.doc().title());
|
||||
let block = Block::bordered()
|
||||
@@ -59,15 +60,17 @@ impl App {
|
||||
// A popup over the panes; the card sits beside the cursor's node.
|
||||
let buffer = self.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 {
|
||||
let buffer = &docs.get(buffer).buffer;
|
||||
let open = docs.get(buffer);
|
||||
let ctx = ViewContext {
|
||||
doc: buffer.doc(),
|
||||
cursor: buffer.cursor(),
|
||||
doc: open.buffer.doc(),
|
||||
cursor: open.buffer.cursor(),
|
||||
mode: &normal,
|
||||
theme,
|
||||
settings: view,
|
||||
portals,
|
||||
folder: open.file.path.parent().unwrap_or(Path::new(".")),
|
||||
};
|
||||
popup.layout(panes_area, anchor, &ctx);
|
||||
popup.render(frame, &ctx);
|
||||
@@ -111,11 +114,17 @@ impl App {
|
||||
let view = format!("{}{focus}", pane.view.status());
|
||||
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
|
||||
// place.
|
||||
let selected = self.selection().map(|selected| format!("{} selected", action::nodes(selected.len())));
|
||||
// place; in connect mode, what's being connected to what.
|
||||
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) {
|
||||
(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::Done }), _) => text.clone().green(),
|
||||
(Some(Message { text, tone: Tone::Info }), _) => text.clone().yellow(),
|
||||
@@ -124,7 +133,7 @@ impl App {
|
||||
mode: mode.label(),
|
||||
mode_color: match mode {
|
||||
_ if typing => theme.insert,
|
||||
Mode::Visual(_) => theme.visual,
|
||||
Mode::Visual(_) | Mode::Connect(_) => theme.visual,
|
||||
_ => theme.accent,
|
||||
},
|
||||
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"
|
||||
}
|
||||
(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::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",
|
||||
|
||||
+5
-7
@@ -4,7 +4,9 @@
|
||||
//! `tests/files.rs`, and the config folder in `tests/config.rs`.
|
||||
|
||||
mod config;
|
||||
mod connect;
|
||||
mod files;
|
||||
mod portals;
|
||||
mod vim;
|
||||
|
||||
use std::path::PathBuf;
|
||||
@@ -501,20 +503,16 @@ fn indenting_and_outdenting_nodes() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_from_later_stages_say_so() {
|
||||
fn v1_1_commands_say_so_and_portal_titles_stay() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("C");
|
||||
assert_eq!(h.message(), "\"connect mode\" comes in a later part of the build");
|
||||
h.type_keys("<Space>p");
|
||||
assert_eq!(h.message(), "\"new portal…\" comes in a later part of the build");
|
||||
h.type_keys(":tidy<CR>");
|
||||
assert_eq!(h.message(), "this one comes in v1.1");
|
||||
// Kitchen is a portal: its title is the other document's name.
|
||||
h.type_keys("glgjgjgj");
|
||||
assert_eq!(h.cursor(), "Kitchen");
|
||||
h.type_keys("i");
|
||||
assert_eq!(h.message(), "a portal shows its document's name: rename that document instead");
|
||||
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) -----------------------
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
//! Editing a node's text: rapid entry, empty titles, `o` inside a title,
|
||||
//! bodies and tags, and the document following the text.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::*;
|
||||
use crate::app::buffer::Buffer;
|
||||
use crate::app::mode::Mode;
|
||||
use crate::app::portals::Portals;
|
||||
use crate::input::bindings::Context;
|
||||
use crate::input::engine::{KeyEngine, Outcome};
|
||||
use crate::input::key::parse_keys;
|
||||
@@ -46,12 +49,15 @@ impl App {
|
||||
fn press(&mut self, keys: &str) {
|
||||
for key in parse_keys(keys, DEFAULT_LEADER).unwrap() {
|
||||
let normal = Mode::Normal;
|
||||
let portals = Portals::default();
|
||||
let ctx = ViewContext {
|
||||
doc: self.buffer.doc(),
|
||||
cursor: self.buffer.cursor(),
|
||||
mode: &normal,
|
||||
theme: &self.theme,
|
||||
settings: &self.settings,
|
||||
portals: &portals,
|
||||
folder: Path::new("."),
|
||||
};
|
||||
let actions = match &mut self.mode {
|
||||
Mode::Text(edit) if edit.editor.is_typing() => {
|
||||
@@ -79,12 +85,15 @@ impl App {
|
||||
}
|
||||
Action::App(AppAction::StartEdit(start)) => {
|
||||
let normal = Mode::Normal;
|
||||
let portals = Portals::default();
|
||||
let ctx = ViewContext {
|
||||
doc: self.buffer.doc(),
|
||||
cursor: self.buffer.cursor(),
|
||||
mode: &normal,
|
||||
theme: &self.theme,
|
||||
settings: &self.settings,
|
||||
portals: &portals,
|
||||
folder: Path::new("."),
|
||||
};
|
||||
match TextEdit::start(&ctx, start) {
|
||||
Ok(edit) => self.mode = Mode::Text(edit),
|
||||
|
||||
+24
-5
@@ -7,9 +7,9 @@ use ratatui::layout::Position;
|
||||
|
||||
use super::action::{Action, AppAction, DocAction, DocumentChoice, Message, Tone};
|
||||
use super::documents::Trouble;
|
||||
use super::mode::{Grab, Mode, Visual};
|
||||
use super::mode::{Connect, Grab, Mode, Visual};
|
||||
use super::panes::Arrange;
|
||||
use super::popup::{DocumentRow, MenuFor, Popup};
|
||||
use super::popup::{DocumentRow, MenuFor, PickKind, Popup};
|
||||
use super::search::Search;
|
||||
use super::text_edit::{StartEdit, TextEdit};
|
||||
use super::{App, Pane};
|
||||
@@ -113,6 +113,18 @@ impl App {
|
||||
}
|
||||
AppAction::ToggleSelected(id) => self.toggle_selected(id),
|
||||
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 => {
|
||||
if let Mode::Move(grab) = &mut self.shared.mode {
|
||||
grab.whole = !grab.whole;
|
||||
@@ -173,6 +185,12 @@ impl App {
|
||||
AppAction::OpenRegisters => self.open_registers(),
|
||||
AppAction::OpenLinks => self.open_links(),
|
||||
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) => {
|
||||
let documents = self.document_rows();
|
||||
let current = self.shared.docs.index_of(self.buffer());
|
||||
@@ -263,9 +281,10 @@ impl App {
|
||||
}
|
||||
let buffer = pane.buffer;
|
||||
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.
|
||||
let jumps = pane.jumps.clone();
|
||||
self.panes.split(arrange, Pane { jumps, ..Pane::new(buffer, kind, camera) });
|
||||
// The new pane starts with a copy of this one's jump list, as in vim
|
||||
// (and of the portals it came through).
|
||||
let (jumps, trail) = (pane.jumps.clone(), pane.trail.clone());
|
||||
self.panes.split(arrange, Pane { jumps, trail, ..Pane::new(buffer, kind, camera) });
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -172,7 +172,12 @@ pub fn command(
|
||||
Command::MoveMode => {
|
||||
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,
|
||||
};
|
||||
Some(actions)
|
||||
|
||||
+10
-2
@@ -9,7 +9,8 @@ use super::action::{self, CursorAfter, DocAction, Message, PasteAt};
|
||||
use super::commands;
|
||||
use super::registers::Kept;
|
||||
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 {
|
||||
/// Keeps nodes of the focused document in a register: exactly `nodes`,
|
||||
@@ -21,11 +22,15 @@ impl App {
|
||||
branches: bool,
|
||||
) -> Option<Message> {
|
||||
let doc = self.current().doc();
|
||||
let clip = if branches {
|
||||
let mut clip = if branches {
|
||||
Clip::branches(doc, nodes)
|
||||
} else {
|
||||
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());
|
||||
if let Err(problem) = self.registers.store(register, Kept::Nodes(clip)) {
|
||||
return Some(Message::problem(problem));
|
||||
@@ -51,6 +56,9 @@ impl App {
|
||||
let Some(mut clip) = kept.clip() else {
|
||||
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());
|
||||
if !clip.placed {
|
||||
clip.lay_out(|node| ctx.box_size(node));
|
||||
|
||||
@@ -41,8 +41,9 @@ use crate::ui::theme::Theme;
|
||||
/// The theme built into cumulus.
|
||||
pub const BUILT_IN: &str = "default";
|
||||
|
||||
/// The words for line kinds, in files.
|
||||
const STROKES: [(&str, Stroke); 5] = [
|
||||
/// The words for line kinds, in files (and the order connect mode's
|
||||
/// `<Space>l` goes through them).
|
||||
pub const STROKES: [(&str, Stroke); 5] = [
|
||||
("solid", Stroke::Solid),
|
||||
("dashed", Stroke::Dashed),
|
||||
("dotted", Stroke::Dotted),
|
||||
@@ -50,8 +51,9 @@ const STROKES: [(&str, Stroke); 5] = [
|
||||
("double", Stroke::Double),
|
||||
];
|
||||
|
||||
/// The words for arrowheads, in files.
|
||||
const ARROWS: [(&str, Arrows); 4] =
|
||||
/// The words for arrowheads, in files (and the order connect mode's
|
||||
/// `<Space>a` goes through them).
|
||||
pub const ARROWS: [(&str, Arrows); 4] =
|
||||
[("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".
|
||||
|
||||
+14
-11
@@ -117,7 +117,9 @@ pub const SITUATIONS: &[(&str, &[Context])] = &[
|
||||
("normal mode in the Note view", &[Context::Note, Context::Normal, Context::Motions]),
|
||||
("visual mode", &[Context::Visual, Context::Canvas, Context::Motions]),
|
||||
("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]),
|
||||
("the K card", &[Context::Card]),
|
||||
("vim keys inside text", &[Context::Text]),
|
||||
@@ -195,6 +197,7 @@ const MOTIONS: &[DefaultBinding] = &[
|
||||
/// Normal mode, in every view.
|
||||
const NORMAL: &[DefaultBinding] = &[
|
||||
bind_help("<CR>", C::Activate, "edit the title (a portal: go through it)"),
|
||||
bind("gu", C::GoUp),
|
||||
bind("i", C::EditTitle),
|
||||
bind("A", C::EditTitle),
|
||||
bind("I", C::EditTitleAtStart),
|
||||
@@ -354,16 +357,16 @@ const MOVE: &[DefaultBinding] = &[
|
||||
|
||||
/// Connect mode: walk to another node, then link to it.
|
||||
const CONNECT: &[DefaultBinding] = &[
|
||||
bind_help("<CR>", C::Confirm, "connect to this node (or save the link)"),
|
||||
bind("d", C::RemoveLink),
|
||||
bind(">", C::AttachAsChild),
|
||||
bind_help("<leader>s", C::PickStyle, "the link's style…"),
|
||||
bind("<leader>a", C::CycleArrows),
|
||||
bind("<leader>l", C::CycleLine),
|
||||
bind_help("<leader>c", C::PickColor, "the link's color…"),
|
||||
bind("<leader>t", C::EditLabel),
|
||||
bind_help("<Esc>", C::Escape, "cancel"),
|
||||
bind_help("<C-c>", C::Escape, "cancel"),
|
||||
bind_help("<CR>", C::Confirm, "link to this node, and done"),
|
||||
bind_help("d", C::RemoveLink, "remove the line to this node (a branch: cut it)"),
|
||||
bind_help(">", C::AttachAsChild, "put it under this node"),
|
||||
bind_help("<leader>s", C::PickStyle, "the line's style…"),
|
||||
bind_help("<leader>a", C::CycleArrows, "arrowheads: off, end, start, both"),
|
||||
bind_help("<leader>l", C::CycleLine, "line: solid, dashed, dotted, thick, double"),
|
||||
bind_help("<leader>c", C::PickColor, "the line's color…"),
|
||||
bind_help("<leader>t", C::EditLabel, "the link's label…"),
|
||||
bind_help("<Esc>", C::Escape, "cancel: take it all back"),
|
||||
bind_help("<C-c>", C::Escape, "cancel: take it all back"),
|
||||
];
|
||||
|
||||
/// Lists in popups.
|
||||
|
||||
@@ -217,6 +217,9 @@ pub enum Command {
|
||||
JumpForward,
|
||||
FollowPortal,
|
||||
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.
|
||||
Activate,
|
||||
|
||||
@@ -428,6 +431,7 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
info("jump-forward", C::JumpForward, "jump forward"),
|
||||
info("follow-portal", C::FollowPortal, "through the portal"),
|
||||
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"),
|
||||
// The view.
|
||||
info("center", C::Center, "center the view"),
|
||||
|
||||
+23
-1
@@ -11,7 +11,8 @@ use super::geometry::{CanvasOffset, CanvasPos, CanvasRect, CanvasSize};
|
||||
use super::ids::{DocUid, NodeId};
|
||||
use super::link::Link;
|
||||
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
|
||||
/// new children are placed).
|
||||
@@ -60,6 +61,18 @@ impl ClipNode {
|
||||
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 {
|
||||
@@ -98,6 +111,15 @@ impl Clip {
|
||||
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.
|
||||
pub fn count(&self) -> usize {
|
||||
self.nodes().len()
|
||||
|
||||
+38
-1
@@ -7,7 +7,7 @@ use thiserror::Error;
|
||||
|
||||
use super::geometry::CanvasPos;
|
||||
use super::ids::{DocUid, NodeId};
|
||||
use super::link::{Link, LinkKey};
|
||||
use super::link::{LineRef, Link, LinkKey};
|
||||
use super::look::LineLook;
|
||||
use super::node::{Content, Node};
|
||||
use super::portal::{Backlink, Portal};
|
||||
@@ -358,6 +358,30 @@ impl Document {
|
||||
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.
|
||||
pub fn backlinks(&self) -> &[Backlink] {
|
||||
&self.backlinks
|
||||
@@ -369,6 +393,19 @@ impl Document {
|
||||
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.
|
||||
pub fn portals(&self) -> impl Iterator<Item = (NodeId, &Portal)> + '_ {
|
||||
self.entries.iter().filter_map(|(id, entry)| match &entry.node.content {
|
||||
|
||||
@@ -100,6 +100,19 @@ impl History {
|
||||
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.
|
||||
pub fn state(&self) -> StateId {
|
||||
self.done.last().map_or(StateId::default(), |step| step.state)
|
||||
@@ -222,6 +235,27 @@ mod tests {
|
||||
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]
|
||||
fn a_group_that_undoes_itself_leaves_no_step() {
|
||||
let (mut doc, g) = crate::model::test_support::garden();
|
||||
|
||||
@@ -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
|
||||
/// only matters for where the arrowheads go.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ pub use fold::Visible;
|
||||
pub use geometry::{CanvasOffset, CanvasPos, CanvasRect, CanvasSize, Direction};
|
||||
pub use history::{Group, History, StateId};
|
||||
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 node::{Content, Fold, Idea, Node};
|
||||
pub use op::{Applied, EditOp, Inbox};
|
||||
|
||||
+47
-3
@@ -9,10 +9,10 @@ use super::document::Document;
|
||||
use super::edit::{Change, Edit, EditError, Spot};
|
||||
use super::geometry::CanvasOffset;
|
||||
use super::ids::NodeId;
|
||||
use super::link::{Link, LinkKey};
|
||||
use super::look::Border;
|
||||
use super::link::{LineRef, Link, LinkKey};
|
||||
use super::look::{Border, LineLook};
|
||||
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.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -84,8 +84,19 @@ pub enum EditOp {
|
||||
id: NodeId,
|
||||
tags: Tags,
|
||||
},
|
||||
/// A new cross-link, or one that replaces the link between its nodes.
|
||||
Link(Link),
|
||||
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
|
||||
/// offset, the next ones right after it.
|
||||
Paste {
|
||||
@@ -213,6 +224,15 @@ fn apply(edit: &mut Edit<'_>, op: &EditOp) -> Result<Applied, EditError> {
|
||||
}
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -259,6 +279,30 @@ mod tests {
|
||||
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]
|
||||
fn bodies_tags_and_styles() {
|
||||
let (mut doc, g) = garden();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,17 @@ impl Memory {
|
||||
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
|
||||
/// list (a document seen under another path before keeps one entry).
|
||||
pub fn opened(&mut self, path: &Path, uid: String, title: String) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! - [`document_file`]: documents (`.cum`), RON text to a document and back,
|
||||
//! - [`write_atomically`]: saving so a file is never left half-written,
|
||||
//! - [`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,
|
||||
//! - [`lock`]: one cumulus at a time edits a document; another one opens
|
||||
//! it read-only,
|
||||
@@ -12,6 +13,7 @@
|
||||
|
||||
pub mod backup;
|
||||
pub mod document_file;
|
||||
pub mod link_paths;
|
||||
pub mod lock;
|
||||
pub mod markdown;
|
||||
pub mod memory;
|
||||
|
||||
@@ -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`).
|
||||
pub fn frame(self, rounded: bool) -> border::Set<'static> {
|
||||
match (self, rounded) {
|
||||
|
||||
@@ -141,6 +141,14 @@ impl ListPopup {
|
||||
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.
|
||||
pub fn is_searching(&self) -> bool {
|
||||
self.searching
|
||||
|
||||
+34
-14
@@ -68,21 +68,37 @@ impl Widget for StatusBar<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the cursor is in the tree, from the top: `Garden plans › Watering
|
||||
/// › Drip line`. When it's wider than `width`, the top levels give way to
|
||||
/// `…`.
|
||||
pub fn breadcrumb(doc: &Document, cursor: NodeId, width: usize) -> String {
|
||||
/// Where the cursor is: the documents the pane came through by portals
|
||||
/// (`trail`, their names), then the tree from the top: `Garden plans »
|
||||
/// Kitchen › Herbs › Basil`. When it's wider than `width`, the first parts
|
||||
/// give way to `…`.
|
||||
pub fn breadcrumb(trail: &[String], doc: &Document, cursor: NodeId, width: usize) -> String {
|
||||
let mut path: Vec<String> =
|
||||
doc.ancestors(cursor).filter_map(|id| Some(doc.node(id)?.title().to_string())).collect();
|
||||
path.reverse();
|
||||
if let Some(node) = doc.node(cursor) {
|
||||
path.push(node.title().to_string());
|
||||
}
|
||||
let mut crumbs = path.join(" › ");
|
||||
let mut dropped = 0;
|
||||
while crumbs.width() > width && dropped + 1 < path.len() {
|
||||
dropped += 1;
|
||||
crumbs = format!("… › {}", path[dropped..].join(" › "));
|
||||
// Each part with what comes after it: » after a document came through,
|
||||
// › between the levels of this one.
|
||||
let last = path.len().saturating_sub(1);
|
||||
let parts: Vec<(String, &str)> = trail
|
||||
.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
|
||||
}
|
||||
@@ -95,10 +111,14 @@ mod tests {
|
||||
#[test]
|
||||
fn the_breadcrumb_gives_way_at_the_top() {
|
||||
let (doc, g) = garden();
|
||||
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, 22), "… › Cedar");
|
||||
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.cedar, 80), "Garden plans › 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, 5), "… › Cedar", "the node itself always shows");
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,9 @@ pub enum LineOwner {
|
||||
Branch(NodeId),
|
||||
/// A cross-link.
|
||||
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.
|
||||
|
||||
@@ -13,10 +13,11 @@ use super::boxes::{NodeBox, draft_box, truncate};
|
||||
use super::camera::{Camera, ScreenPos, ScreenRect, Zoom};
|
||||
use super::lines::{LineLayer, LineOwner, Paint};
|
||||
use super::route::{Bend, route};
|
||||
use crate::app::connect;
|
||||
use crate::app::mode::Mode;
|
||||
use crate::app::text_edit::TextEdit;
|
||||
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::text_editor::layout;
|
||||
use crate::ui::theme::{LineStyle, Theme};
|
||||
@@ -108,8 +109,10 @@ impl Scene {
|
||||
let visible = Visible::new(doc, cursor, settings.focus);
|
||||
let picked: BTreeSet<NodeId> = match ctx.mode {
|
||||
Mode::Visual(visual) => selection(visual, ctx, ViewKind::Canvas).into_iter().collect(),
|
||||
Mode::Connect(connecting) => connect::others(connecting, cursor).into_iter().collect(),
|
||||
_ => BTreeSet::new(),
|
||||
};
|
||||
let aim = Aim::new(ctx);
|
||||
let mut scene = Scene {
|
||||
size,
|
||||
zoom: camera.zoom,
|
||||
@@ -124,8 +127,11 @@ impl Scene {
|
||||
};
|
||||
for &id in &visible.order {
|
||||
let Some(node) = doc.node(id) else { continue };
|
||||
let style = theme.node_style(node, id == doc.root());
|
||||
let badge = visible.hidden.get(&id).map(|count| format!("+{count}")).unwrap_or_default();
|
||||
let style = ctx.node_style(id, node);
|
||||
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 (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),
|
||||
@@ -143,7 +149,7 @@ impl Scene {
|
||||
scene.index = scene.nodes.iter().enumerate().map(|(index, node)| (node.id, index)).collect();
|
||||
scene.cover();
|
||||
scene.add_borders();
|
||||
scene.add_lines(doc, theme);
|
||||
scene.add_lines(doc, theme, &aim);
|
||||
scene
|
||||
}
|
||||
|
||||
@@ -248,13 +254,18 @@ impl Scene {
|
||||
}
|
||||
|
||||
/// Adds every branch (from a shown parent to a shown child) and every
|
||||
/// cross-link between shown nodes.
|
||||
fn add_lines(&mut self, doc: &Document, theme: &Theme) {
|
||||
/// cross-link between shown nodes. In connect mode, the lines aimed at
|
||||
/// 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() {
|
||||
let child = &self.nodes[child_index];
|
||||
let Some(Place::Child { parent, branch }) = doc.place(child.id) 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);
|
||||
self.connect(parent_index, child_index, Bend::NearStart, style, owner, None);
|
||||
}
|
||||
@@ -263,10 +274,20 @@ impl Scene {
|
||||
else {
|
||||
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);
|
||||
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`),
|
||||
@@ -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.
|
||||
fn covered_at(covered: &[Option<usize>], size: Size, at: ScreenPos) -> Option<usize> {
|
||||
let (width, height) = (i32::from(size.width), i32::from(size.height));
|
||||
|
||||
@@ -75,10 +75,11 @@ impl CanvasView {
|
||||
Scene::build(ctx, self.camera, self.area.as_size())
|
||||
}
|
||||
|
||||
/// A left press: on a node, selects it and starts dragging its branch;
|
||||
/// on empty space, starts panning.
|
||||
fn press(&mut self, at: ScreenPos) -> Vec<Action> {
|
||||
/// A left press: on a node, selects it and starts dragging its branch
|
||||
/// (in connect mode, just aims at it); on empty space, starts panning.
|
||||
fn press(&mut self, at: ScreenPos, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
match self.scene.node_at(at) {
|
||||
Some(id) if matches!(ctx.mode, Mode::Connect(_)) => vec![Action::cursor(id)],
|
||||
Some(id) => {
|
||||
let moved = CanvasOffset::default();
|
||||
self.drag = Some(Drag::Node { id, from: at, moved, group: Group::new() });
|
||||
@@ -264,6 +265,7 @@ impl View for CanvasView {
|
||||
mode,
|
||||
&[Context::Canvas, Context::Normal, 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),
|
||||
// Keys inside a title are the edit's, not the view's.
|
||||
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 half_page = i32::from(self.area.height / 2);
|
||||
@@ -348,7 +350,7 @@ impl View for CanvasView {
|
||||
}
|
||||
Gesture::Press { 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
|
||||
// cursor; a click anywhere else keeps the edit, finishes it,
|
||||
// and does what a click does.
|
||||
@@ -356,7 +358,7 @@ impl View for CanvasView {
|
||||
return vec![Action::App(AppAction::TextCursor(offset))];
|
||||
}
|
||||
let mut actions = edit.finish(ctx);
|
||||
actions.extend(self.press(at));
|
||||
actions.extend(self.press(at, ctx));
|
||||
actions
|
||||
}
|
||||
Gesture::DoublePress { at } => {
|
||||
|
||||
+75
-6
@@ -15,17 +15,22 @@ pub mod note;
|
||||
pub mod outline;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
|
||||
use crate::app::action::Action;
|
||||
use crate::app::mode::Mode;
|
||||
use crate::app::portals::{Portals, Shown};
|
||||
use crate::input::bindings::Context;
|
||||
use crate::input::engine::Typed;
|
||||
use crate::input::mouse::Gesture;
|
||||
use crate::model::{CanvasRect, CanvasSize, Document, Node, NodeId};
|
||||
use crate::ui::theme::Theme;
|
||||
use crate::model::{CanvasRect, CanvasSize, Content, Document, LinkPath, Node, NodeId, Portal};
|
||||
use crate::storage::link_paths::resolve;
|
||||
use crate::storage::names::title_from_file;
|
||||
use crate::ui::theme::{NodeStyle, Theme};
|
||||
use canvas::Camera;
|
||||
use canvas::boxes::NodeBox;
|
||||
|
||||
@@ -80,15 +85,68 @@ pub struct ViewContext<'a> {
|
||||
pub mode: &'a Mode,
|
||||
pub theme: &'a Theme,
|
||||
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
|
||||
/// these to place new nodes, since every node has a spot on the canvas.
|
||||
pub fn box_rects(&self) -> BTreeMap<NodeId, CanvasRect> {
|
||||
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.
|
||||
pub fn box_size(&self, node: &Node) -> CanvasSize {
|
||||
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
|
||||
/// table first), `visual` in visual mode, and what every view reads in the
|
||||
/// other modes.
|
||||
/// table first), `visual` and `connect` in those modes, and what every view
|
||||
/// reads in the other modes.
|
||||
pub fn contexts_for(
|
||||
mode: &Mode,
|
||||
normal: &'static [Context],
|
||||
visual: &'static [Context],
|
||||
connect: &'static [Context],
|
||||
) -> &'static [Context] {
|
||||
match mode {
|
||||
Mode::Normal => normal,
|
||||
Mode::Visual(_) => visual,
|
||||
Mode::Connect(_) => connect,
|
||||
Mode::Text(edit) if edit.editor.is_typing() => &[Context::Insert],
|
||||
Mode::Text(_) => &[Context::Text],
|
||||
Mode::Move(_) => &[Context::Move],
|
||||
@@ -170,11 +230,20 @@ pub(crate) mod test_support {
|
||||
pub mode: Mode,
|
||||
pub theme: Theme,
|
||||
pub settings: ViewSettings,
|
||||
pub portals: Portals,
|
||||
}
|
||||
|
||||
impl Setup {
|
||||
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
@@ -14,7 +14,8 @@
|
||||
//! ```
|
||||
//!
|
||||
//! 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
|
||||
//! `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
|
||||
@@ -27,16 +28,19 @@ use ratatui::layout::{Position, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
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::jumps::Place;
|
||||
use crate::app::mode::Mode;
|
||||
use crate::app::popup::MenuFor;
|
||||
use crate::app::portals::Shown;
|
||||
use crate::app::text_edit::{EditAt, Field, StartEdit, TextEdit};
|
||||
use crate::input::bindings::Context;
|
||||
use crate::input::command::{Command, Motion};
|
||||
use crate::input::engine::Typed;
|
||||
use crate::input::mouse::{Cell, Gesture};
|
||||
use crate::model::{Content, Direction, NodeId};
|
||||
use crate::storage::link_paths::resolve;
|
||||
use crate::ui::text_editor::layout;
|
||||
use crate::view::{View, ViewContext, ViewKind, contexts_for};
|
||||
|
||||
@@ -56,6 +60,8 @@ enum Part {
|
||||
Link(usize),
|
||||
/// A child, in order.
|
||||
Child(usize),
|
||||
/// On the root: a portal in another document that leads here.
|
||||
Backlink(usize),
|
||||
}
|
||||
|
||||
/// The Note view.
|
||||
@@ -78,6 +84,8 @@ pub struct NoteView {
|
||||
links: Vec<NodeId>,
|
||||
/// The node's children, in order.
|
||||
children: Vec<NodeId>,
|
||||
/// On the root: where the portals that lead here are.
|
||||
backlinks: Vec<Place>,
|
||||
/// The part being edited, as drawn.
|
||||
editing: Option<Editing>,
|
||||
/// A preview (the search's): no bar marks a part.
|
||||
@@ -131,7 +139,7 @@ impl NoteView {
|
||||
let muted = Style::new().fg(theme.muted);
|
||||
|
||||
// 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);
|
||||
match (draft(Field::Title), &node.content) {
|
||||
(Some(edit), _) => self.add_draft(Part::Title, edit, title_style),
|
||||
@@ -139,8 +147,16 @@ impl NoteView {
|
||||
(None, Content::Portal(portal)) => {
|
||||
let title = format!("{} {}", glyphs.portal(), portal.title);
|
||||
self.add_text(Part::Title, &title, title_style);
|
||||
let path = format!("a portal to {}", portal.path.as_str());
|
||||
self.add_row(Some(Part::Title), vec![Span::styled(path, muted)]);
|
||||
let path = portal.path.as_str();
|
||||
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()))],
|
||||
);
|
||||
}
|
||||
|
||||
// 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>>) {
|
||||
@@ -299,7 +332,7 @@ impl NoteView {
|
||||
Part::Title => return None,
|
||||
Part::Tags => StartEdit::Tags(at),
|
||||
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";
|
||||
return Some(vec![Action::say(Message::info(text))]);
|
||||
}
|
||||
@@ -310,6 +343,13 @@ impl NoteView {
|
||||
Part::Child(index) => {
|
||||
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))])
|
||||
}
|
||||
@@ -364,6 +404,7 @@ impl View for NoteView {
|
||||
mode,
|
||||
&[Context::Note, Context::Normal, 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>> {
|
||||
match ctx.mode {
|
||||
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,
|
||||
}
|
||||
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 };
|
||||
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
|
||||
|
||||
+8
-2
@@ -35,6 +35,7 @@ use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::app::action::{Action, AppAction, Message};
|
||||
use crate::app::commands;
|
||||
use crate::app::connect;
|
||||
use crate::app::mode::Mode;
|
||||
use crate::app::popup::MenuFor;
|
||||
use crate::app::text_edit::{EditAt, StartEdit, TextEdit};
|
||||
@@ -124,6 +125,7 @@ impl OutlineView {
|
||||
let edit = ctx.mode.title_edit();
|
||||
self.picked = match ctx.mode {
|
||||
Mode::Visual(visual) => selection(visual, ctx, ViewKind::Outline).into_iter().collect(),
|
||||
Mode::Connect(connecting) => connect::others(connecting, ctx.cursor).into_iter().collect(),
|
||||
_ => BTreeSet::new(),
|
||||
};
|
||||
// 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 Some(node) = doc.node(id) else { return };
|
||||
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 has_children = !doc.children(id).is_empty();
|
||||
let folded = visible.hidden.contains_key(&id);
|
||||
@@ -228,6 +230,9 @@ impl OutlineView {
|
||||
if has_body && !self.bodies {
|
||||
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) {
|
||||
lines.push(format!(" +{count}"), muted);
|
||||
}
|
||||
@@ -433,6 +438,7 @@ impl View for OutlineView {
|
||||
mode,
|
||||
&[Context::Outline, Context::Normal, 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>> {
|
||||
match ctx.mode {
|
||||
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,
|
||||
}
|
||||
let rows = isize::try_from(typed.times()).unwrap_or(1);
|
||||
|
||||
Reference in new issue
Block a user