Tidy a branch and line nodes up (stage 9a)
The first small part of stage 9 (v1.1). <Space>= then m, c or g (the user's pick: leader menus), or :tidy map|chart|grid, lays out the cursor's branch while its top node stays put: a mind map puts each node in the middle of its children (the user's pick), an org chart puts them side by side below, and a grid makes a block of cards beside the node (the user's pick), a card's own branch going along as a mind map. Every node of the branch gets a place, folded away or not (the user's pick), as one undo step, and the message says when the branch now covers nodes outside it. The lines follow the layouts: a parent whose children are all below it sends its branches down to a shared bend, and a new child of it goes beside the last one; a grid card behind another isn't joined by a line, which would look like it joins the two cards. In visual mode, <Space>a then h, l, k, j or c lines the selection up on the box around it, each node bringing its branch along, and : opens the command line on the selection: :align and :tidy take it, and so do :color, :border, :style, :tag and :fold, as one undo step. The layouts are pure functions in view::canvas::arrange, applied as one EditOp::Reposition. Layout and Side moved from the parser to the model so keys can name them. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
95c6c3ca37
commit
13a603837e
25 files changed
+709
-65
No files matched your search
Generated
+1
-1
@@ -148,7 +148,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cumulus"
|
name = "cumulus"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arboard",
|
"arboard",
|
||||||
"directories",
|
"directories",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cumulus"
|
name = "cumulus"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
# The version cumulus is built and tested with (pinned in rust-toolchain.toml).
|
# The version cumulus is built and tested with (pinned in rust-toolchain.toml).
|
||||||
rust-version = "1.90"
|
rust-version = "1.90"
|
||||||
|
|||||||
@@ -249,6 +249,33 @@ lines.
|
|||||||
document by uid (a file moved or renamed by hand), and keeps the path it
|
document by uid (a file moved or renamed by hand), and keeps the path it
|
||||||
finds.
|
finds.
|
||||||
|
|
||||||
|
**Tidying and lining up** (settled in part 9a)
|
||||||
|
- `<Space>=` then `m`, `c` or `g` (the user's pick: leader menus), or
|
||||||
|
`:tidy [map|chart|grid]`, lays out the cursor's branch: its top node
|
||||||
|
stays where it is, and every node under it gets a place, folded away or
|
||||||
|
not (the user's pick: unfolding later shows it tidy too). One undo step.
|
||||||
|
What's outside the branch stays put, even under it, and the message says
|
||||||
|
how many nodes the branch covers now.
|
||||||
|
- A mind map puts each node in the middle of its children (the user's
|
||||||
|
pick), with their branches stacked to the right. An org chart puts the
|
||||||
|
children's branches side by side below, each node in the middle above
|
||||||
|
its own. A grid makes a block of cards beside the node (the user's pick),
|
||||||
|
filled left to right, about as many columns as rows; a card's own branch
|
||||||
|
goes along with it, as a mind map.
|
||||||
|
- The lines follow: a parent whose children are all below it sends its
|
||||||
|
branches down to a shared bend, and a new child of it goes beside the
|
||||||
|
last one. In a grid, a card behind another in its row isn't joined by a
|
||||||
|
line: the bit showing between the two cards would look like it joins
|
||||||
|
them.
|
||||||
|
- In visual mode, `<Space>a` then `h`, `l`, `k`, `j` or `c` lines the
|
||||||
|
selection up: left, right, top or bottom edges, or middles in a column,
|
||||||
|
on the box around them all. Each brings its branch along (with `V`, each
|
||||||
|
branch goes by its top). `<Space>=` tidies each selected branch.
|
||||||
|
- `:` in visual mode opens the command line on the selection (the status
|
||||||
|
bar keeps counting it): `:align` and `:tidy` take it, and so do `:color`,
|
||||||
|
`:border`, `:style`, `:tag` and `:fold`, setting every selected node as
|
||||||
|
one undo step, the way the pickers do.
|
||||||
|
|
||||||
**Views, panes and popups** (settled in stage 7)
|
**Views, panes and popups** (settled in stage 7)
|
||||||
- The Note view is made of parts: the title, the tags, the body, each link
|
- The Note view is made of parts: the title, the tags, the body, each link
|
||||||
(out, then in) and each child. `j` and `k` step through them, with a bar
|
(out, then in) and each child. `j` and `k` step through them, with a bar
|
||||||
@@ -478,7 +505,7 @@ text).
|
|||||||
| **Create and edit** | `o` `O` sibling below / above · `a` child · `i` `A` Enter edit the title · `I` edit from its start · `cc` `S` retype |
|
| **Create and edit** | `o` `O` sibling below / above · `a` child · `i` `A` Enter edit the title · `I` edit from its start · `cc` `S` retype |
|
||||||
| **Operators** | `dd` or `dab` delete branch · `dib` delete children · `x` delete just the node · `yy` `yab` `yib` yank · `p` paste as children · `P` paste as siblings below · `>>` `<<` indent / outdent · `"a`–`"z` and `"+` registers · `.` repeat · `u` `Ctrl-r` undo / redo |
|
| **Operators** | `dd` or `dab` delete branch · `dib` delete children · `x` delete just the node · `yy` `yab` `yib` yank · `p` paste as children · `P` paste as siblings below · `>>` `<<` indent / outdent · `"a`–`"z` and `"+` registers · `.` repeat · `u` `Ctrl-r` undo / redo |
|
||||||
| **Modes** | `v` `V` visual · `M` move · `C` connect · `:` command · Esc back to normal |
|
| **Modes** | `v` `V` visual · `M` move · `C` connect · `:` command · Esc back to normal |
|
||||||
| **Leader** `<Space>` | `c` color · `s` style · `B` border · `t` tags · `p` new portal (`: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 · `=` tidy (`m` map, `c` chart, `g` grid) · `p` new portal (`:portal `) · `i` inbox · `b` documents · `v` then `c`/`o`/`n` for canvas / outline / note · `w` which-key on/off · `?` help |
|
||||||
| **Panes, documents** | `Ctrl-w v` `Ctrl-w s` split · `Ctrl-w hjkl` focus · `Ctrl-w w` the next pane · `Ctrl-w c` close · `Ctrl-w o` only · `Ctrl-w =` even sizes · `gt` `gT` `]b` `[b` next / previous document · `ZZ` close pane (the last one quits) |
|
| **Panes, documents** | `Ctrl-w v` `Ctrl-w s` split · `Ctrl-w hjkl` focus · `Ctrl-w w` the next pane · `Ctrl-w c` close · `Ctrl-w o` only · `Ctrl-w =` even sizes · `gt` `gT` `]b` `[b` next / previous document · `ZZ` close pane (the last one quits) |
|
||||||
| **Outline** | `j` `k` rows · `h` `l` parent / first child · `zb` every body · `M` then `j` `k` (`J` `K`: 5) swap with siblings, `h` out, `l` under the one above |
|
| **Outline** | `j` `k` rows · `h` `l` parent / first child · `zb` every body · `M` then `j` `k` (`J` `K`: 5) swap with siblings, `h` out, `l` under the one above |
|
||||||
| **Note view** | `j` `k` parts · Enter `i` `A` edit the part (`I` from its start, `cc` `S` anew), or go to that link or child · `h` `l` the sibling before / after |
|
| **Note view** | `j` `k` parts · Enter `i` `A` edit the part (`I` from its start, `cc` `S` anew), or go to that link or child · `h` `l` the sibling before / after |
|
||||||
@@ -488,7 +515,7 @@ text).
|
|||||||
| **Inside text** | `h j k l w b e 0 ^ $ gg G` (and arrows, Home, End) · `f t F T ; ,` · `d` `c` `y` with any motion and `iw aw i" a" i( a(` · `x X s S D C Y` · `r ~ J p P` · `u Ctrl-r .` · `i a I A` back to typing · `o O` (title: a new sibling; body: a new line) · Esc done |
|
| **Inside text** | `h j k l w b e 0 ^ $ gg G` (and arrows, Home, End) · `f t F T ; ,` · `d` `c` `y` with any motion and `iw aw i" a" i( a(` · `x X s S D C Y` · `r ~ J p P` · `u Ctrl-r .` · `i a I A` back to typing · `o O` (title: a new sibling; body: a new line) · Esc done |
|
||||||
| **Move mode** | `hjkl` 1 cell · `HJKL` 5 cells · `M` branch ↔ node · Enter or Esc done |
|
| **Move mode** | `hjkl` 1 cell · `HJKL` 5 cells · `M` branch ↔ node · Enter or Esc done |
|
||||||
| **Connect mode** | move to the target as usual (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) |
|
| **Connect mode** | move to the target as usual (or click it) · Enter link · `d` remove (a branch: cut) · `>` attach as child · `<Space>` style menu (`s` named, `a` arrows, `l` line, `c` color, `t` label) · Esc cancel (takes it all back) |
|
||||||
| **Visual** | `d` `x` `y` `>` `<` `M` and `<Space>c` `<Space>s` `<Space>B` `<Space>t` act on the whole selection · `o` swaps ends · `v` `V` switch, or stop · Ctrl+click, Ctrl+drag pick with the mouse |
|
| **Visual** | `d` `x` `y` `>` `<` `M` and `<Space>c` `<Space>s` `<Space>B` `<Space>t` act on the whole selection · `<Space>a` then `h` `l` `k` `j` `c` line it up · `<Space>=` tidies each branch · `:` a command on the selection · `o` swaps ends · `v` `V` switch, or stop · Ctrl+click, Ctrl+drag pick with the mouse |
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
@@ -662,7 +689,7 @@ cumulus/
|
|||||||
│ keymap (+ keys.toml), engine (counts, registers, operators), mouse
|
│ keymap (+ keys.toml), engine (counts, registers, operators), mouse
|
||||||
├─ command/ registry, parse, complete, history
|
├─ command/ registry, parse, complete, history
|
||||||
├─ view/ mod.rs (the View trait), canvas/ (camera, boxes, lines, route, scene, render,
|
├─ view/ mod.rs (the View trait), canvas/ (camera, boxes, lines, route, scene, render,
|
||||||
│ navigate, placement, view), outline, note; later board
|
│ navigate, placement, arrange (:tidy, :align), view), outline, note; later board
|
||||||
├─ ui/ text_input (a line of text and its cursor), text_editor/ (vim inside text:
|
├─ ui/ text_input (a line of text and its cursor), text_editor/ (vim inside text:
|
||||||
│ motion, object, layout), list (the popup list help, menus and pickers
|
│ motion, object, layout), list (the popup list help, menus and pickers
|
||||||
│ share), card (the K card), status_bar, document_bar, which_key,
|
│ share), card (the K card), status_bar, document_bar, which_key,
|
||||||
@@ -687,8 +714,8 @@ cumulus/
|
|||||||
```
|
```
|
||||||
|
|
||||||
The end-to-end tests are in `app/tests.rs` (with `app/tests/files.rs`,
|
The end-to-end tests are in `app/tests.rs` (with `app/tests/files.rs`,
|
||||||
`app/tests/config.rs`, `app/tests/vim.rs`, `app/tests/connect.rs` and
|
`app/tests/config.rs`, `app/tests/vim.rs`, `app/tests/connect.rs`,
|
||||||
`app/tests/portals.rs`): they copy the sample
|
`app/tests/portals.rs` and `app/tests/arrange.rs`): they copy the sample
|
||||||
documents to a temporary folder, type keys and click on a test terminal, and
|
documents to a temporary folder, type keys and click on a test terminal, and
|
||||||
check the screen, the documents and the files.
|
check the screen, the documents and the files.
|
||||||
|
|
||||||
@@ -770,7 +797,9 @@ it.
|
|||||||
Each stage ends with `cargo build`, `cargo lint` and `cargo test` passing, and
|
Each stage ends with `cargo build`, `cargo lint` and `cargo test` passing, and
|
||||||
one commit. Stage 8, as big as stages 5 to 7 together, comes in four parts
|
one commit. Stage 8, as big as stages 5 to 7 together, comes in four parts
|
||||||
with a commit each, and a pause after each for the user to try it (their
|
with a commit each, and a pause after each for the user to try it (their
|
||||||
choice).
|
choice). Stage 9 comes in small parts the same way (their choice too): 9a
|
||||||
|
`:tidy` and align, 9b branch out, 9c export and import, 9d the search
|
||||||
|
across documents, 9e the Board.
|
||||||
|
|
||||||
## Crates and tooling
|
## Crates and tooling
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ The plan for the everyday keys (all of them are in DESIGN.md):
|
|||||||
| `K` | the body in a card beside the node (`i` edits it there) |
|
| `K` | the body in a card beside the node (`i` edits it there) |
|
||||||
| `v` `V` | select: the nodes between here and where you started (`V`: with their branches); then `d` `y` `>` `<` `M` or `<Space>c` act on all of them |
|
| `v` `V` | select: the nodes between here and where you started (`V`: with their branches); then `d` `y` `>` `<` `M` or `<Space>c` act on all of them |
|
||||||
| `M` | move the branch with `hjkl` (`M` again: just the node) |
|
| `M` | move the branch with `hjkl` (`M` again: just the node) |
|
||||||
|
| `<Space>=` | tidy the branch: `m` a mind map, `c` an org chart, `g` a grid (`:tidy`) |
|
||||||
|
| `<Space>a` | in visual mode: line the selection up, `h` `l` `k` `j` left, right, top, bottom, `c` middles (`:align`; `:` in visual mode runs a command on the selection) |
|
||||||
| `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) |
|
| `C` | connect: go to another node, Enter links them (`d` removes the line, `>` puts it under that node, `<Space>` `a` `l` `c` `s` `t` style it, Esc takes it all back) |
|
||||||
| `/` `n` `N` | search: titles, tags and bodies, fuzzily, with the note of each match beside it / the next match / the previous one |
|
| `/` `n` `N` | search: titles, tags and bodies, fuzzily, with the note of each match beside it / the next match / the previous one |
|
||||||
| `Ctrl-o` `Tab` | back to where you jumped from / forward again (across documents) |
|
| `Ctrl-o` `Tab` | back to where you jumped from / forward again (across documents) |
|
||||||
|
|||||||
+32
-14
@@ -2,21 +2,26 @@
|
|||||||
//! actions, the way views turn keys into actions.
|
//! actions, the way views turn keys into actions.
|
||||||
|
|
||||||
use super::action::{Action, AppAction, CursorAfter, DocumentChoice, Message, SaveWhich};
|
use super::action::{Action, AppAction, CursorAfter, DocumentChoice, Message, SaveWhich};
|
||||||
use super::commands::set_fold;
|
use super::commands::{self, set_fold};
|
||||||
use super::panes::Arrange;
|
use super::panes::Arrange;
|
||||||
use super::popup::PickKind;
|
use super::popup::PickKind;
|
||||||
|
use super::visual;
|
||||||
use crate::command::parse::{Call, SetArg, TagChange};
|
use crate::command::parse::{Call, SetArg, TagChange};
|
||||||
use crate::model::{CanvasPos, CanvasRect, EditOp, Inbox, Link, LinkKey, Node, NodeId, Title};
|
use crate::model::{CanvasPos, CanvasRect, EditOp, Group, Inbox, Link, LinkKey, Node, NodeId, Title};
|
||||||
use crate::view::canvas::placement::{child_spot, first_child_spot, free_spot};
|
use crate::view::canvas::placement::{child_spot, first_child_spot, free_spot};
|
||||||
use crate::view::{ViewContext, ViewKind};
|
use crate::view::{ViewContext, ViewKind};
|
||||||
|
|
||||||
/// The title of the loose node `:in` collects ideas under.
|
/// The title of the loose node `:in` collects ideas under.
|
||||||
const INBOX: &str = "Inbox";
|
const INBOX: &str = "Inbox";
|
||||||
|
|
||||||
/// The actions a command line asks for. Commands from later stages of the
|
/// The actions a command line asks for. A line typed in visual mode acts
|
||||||
/// build say so.
|
/// on the nodes that were selected (`on`), as the pickers do: `:color`,
|
||||||
pub fn actions_for(call: Call, ctx: &ViewContext<'_>) -> Vec<Action> {
|
/// `:border`, `:style`, `:tag` and `:fold` set each of them (one undo step),
|
||||||
|
/// `:tidy` lays out each branch and `:align` lines them up. Otherwise they
|
||||||
|
/// act on the cursor's node. Commands from later stages of the build say so.
|
||||||
|
pub fn actions_for(call: Call, ctx: &ViewContext<'_>, on: &[NodeId]) -> Vec<Action> {
|
||||||
let id = ctx.cursor;
|
let id = ctx.cursor;
|
||||||
|
let nodes = if on.is_empty() { vec![id] } else { on.to_vec() };
|
||||||
match call {
|
match call {
|
||||||
// :q closes the pane, as in vim: the last one quits.
|
// :q closes the pane, as in vim: the last one quits.
|
||||||
Call::Quit { force } => app(AppAction::ClosePane { force }),
|
Call::Quit { force } => app(AppAction::ClosePane { force }),
|
||||||
@@ -39,11 +44,15 @@ pub fn actions_for(call: Call, ctx: &ViewContext<'_>) -> Vec<Action> {
|
|||||||
Call::Show(view) => app(AppAction::ShowView(view)),
|
Call::Show(view) => app(AppAction::ShowView(view)),
|
||||||
Call::Buffers => app(AppAction::OpenPicker(PickKind::Document)),
|
Call::Buffers => app(AppAction::OpenPicker(PickKind::Document)),
|
||||||
Call::Buffer(name) => app(AppAction::SwitchDocument(DocumentChoice::Named(name))),
|
Call::Buffer(name) => app(AppAction::SwitchDocument(DocumentChoice::Named(name))),
|
||||||
Call::Style(style) => vec![Action::edit(EditOp::SetStyle { id, style: Some(style) })],
|
Call::Style(style) => each(&nodes, |id| EditOp::SetStyle { id, style: Some(style.clone()) }),
|
||||||
Call::Fold(fold) => set_fold(ctx, fold),
|
Call::Fold(fold) if on.is_empty() => set_fold(ctx, fold),
|
||||||
|
Call::Fold(fold) => {
|
||||||
|
let ids = on.iter().copied().filter(|&id| !ctx.doc.children(id).is_empty()).collect();
|
||||||
|
vec![Action::edit(EditOp::SetFold { ids, fold })]
|
||||||
|
}
|
||||||
Call::Focus => vec![Action::App(AppAction::ToggleFocus)],
|
Call::Focus => vec![Action::App(AppAction::ToggleFocus)],
|
||||||
Call::Color(color) => vec![Action::edit(EditOp::SetColor { id, color })],
|
Call::Color(color) => each(&nodes, |id| EditOp::SetColor { id, color: color.clone() }),
|
||||||
Call::Border(border) => vec![Action::edit(EditOp::SetBorder { id, border: Some(border) })],
|
Call::Border(border) => each(&nodes, |id| EditOp::SetBorder { id, border: Some(border) }),
|
||||||
Call::Tags(changes) => {
|
Call::Tags(changes) => {
|
||||||
let (mut add, mut remove) = (Vec::new(), Vec::new());
|
let (mut add, mut remove) = (Vec::new(), Vec::new());
|
||||||
for change in changes {
|
for change in changes {
|
||||||
@@ -52,7 +61,7 @@ pub fn actions_for(call: Call, ctx: &ViewContext<'_>) -> Vec<Action> {
|
|||||||
TagChange::Remove(tag) => remove.push(tag),
|
TagChange::Remove(tag) => remove.push(tag),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
vec![Action::edit(EditOp::Tag { id, add, remove })]
|
each(&nodes, |id| EditOp::Tag { id, add: add.clone(), remove: remove.clone() })
|
||||||
}
|
}
|
||||||
// A document's name is its root's title.
|
// A document's name is its root's title.
|
||||||
Call::Rename(title) => vec![Action::edit(EditOp::SetTitle { id: ctx.doc.root(), title })],
|
Call::Rename(title) => vec![Action::edit(EditOp::SetTitle { id: ctx.doc.root(), title })],
|
||||||
@@ -72,9 +81,9 @@ pub fn actions_for(call: Call, ctx: &ViewContext<'_>) -> Vec<Action> {
|
|||||||
Call::Registers => app(AppAction::OpenRegisters),
|
Call::Registers => app(AppAction::OpenRegisters),
|
||||||
Call::Theme(name) => vec![Action::App(AppAction::Set(SetArg::Value("theme".into(), name)))],
|
Call::Theme(name) => vec![Action::App(AppAction::Set(SetArg::Value("theme".into(), name)))],
|
||||||
Call::Config => app(AppAction::OpenConfig),
|
Call::Config => app(AppAction::OpenConfig),
|
||||||
Call::Tidy(_) | Call::Align(_) | Call::BranchOut | Call::Export { .. } => {
|
Call::Tidy(layout) => commands::tidy(ctx, &visual::tops(&nodes, ctx), layout),
|
||||||
later("this one comes in v1.1")
|
Call::Align(side) => commands::align(ctx, on, side),
|
||||||
}
|
Call::BranchOut | Call::Export { .. } => later("this one comes in v1.1"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +91,15 @@ fn app(action: AppAction) -> Vec<Action> {
|
|||||||
vec![Action::App(action)]
|
vec![Action::App(action)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same change on each node, as one undo step.
|
||||||
|
fn each(nodes: &[NodeId], op: impl Fn(NodeId) -> EditOp) -> Vec<Action> {
|
||||||
|
let group = Group::new();
|
||||||
|
match nodes {
|
||||||
|
[id] => vec![Action::edit(op(*id))],
|
||||||
|
nodes => nodes.iter().map(|&id| Action::edit_in(op(id), group)).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn say(message: Message) -> Vec<Action> {
|
fn say(message: Message) -> Vec<Action> {
|
||||||
vec![Action::say(message)]
|
vec![Action::say(message)]
|
||||||
}
|
}
|
||||||
@@ -145,7 +163,7 @@ mod tests {
|
|||||||
|
|
||||||
/// Runs a command line's actions on a buffer, as the app does.
|
/// Runs a command line's actions on a buffer, as the app does.
|
||||||
fn run(buffer: &mut Buffer, setup: &Setup, call: Call) {
|
fn run(buffer: &mut Buffer, setup: &Setup, call: Call) {
|
||||||
for action in actions_for(call, &setup.ctx(buffer.doc(), buffer.cursor())) {
|
for action in actions_for(call, &setup.ctx(buffer.doc(), buffer.cursor()), &[]) {
|
||||||
if let Action::Doc(action) = action {
|
if let Action::Doc(action) = action {
|
||||||
buffer.apply(action);
|
buffer.apply(action);
|
||||||
}
|
}
|
||||||
|
|||||||
+74
-4
@@ -5,9 +5,11 @@
|
|||||||
//! own meaning (in the Note view, `dd` will delete a line of text).
|
//! own meaning (in the Note view, `dd` will delete a line of text).
|
||||||
//! [`shared`] answers the ones it leaves.
|
//! [`shared`] answers the ones it leaves.
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
use super::action::{Action, AppAction, CursorAfter, DocAction, DocumentChoice, Message, PasteAt, SaveWhich};
|
use super::action::{
|
||||||
|
self, Action, AppAction, CursorAfter, DocAction, DocumentChoice, Message, PasteAt, SaveWhich,
|
||||||
|
};
|
||||||
use super::mode::Grab;
|
use super::mode::Grab;
|
||||||
use super::panes::Arrange;
|
use super::panes::Arrange;
|
||||||
use super::popup::PickKind;
|
use super::popup::PickKind;
|
||||||
@@ -15,9 +17,10 @@ use super::text_edit::{EditAt, StartEdit};
|
|||||||
use crate::input::command::{Command, Motion, Operator, Register, Target};
|
use crate::input::command::{Command, Motion, Operator, Register, Target};
|
||||||
use crate::input::engine::Typed;
|
use crate::input::engine::Typed;
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
CanvasPos, CanvasRect, CanvasSize, Clip, Direction, Document, EditOp, Fold, Group, Node, NodeId, Spot,
|
CanvasPos, CanvasRect, CanvasSize, Clip, Direction, Document, EditOp, Fold, Group, Layout, Node, NodeId,
|
||||||
Title, Visible,
|
Side, Spot, Title, Visible,
|
||||||
};
|
};
|
||||||
|
use crate::view::canvas::arrange;
|
||||||
use crate::view::canvas::placement::{below_spot, child_spot, first_child_spot, free_spot, spot_below};
|
use crate::view::canvas::placement::{below_spot, child_spot, first_child_spot, free_spot, spot_below};
|
||||||
use crate::view::{ViewContext, ViewKind};
|
use crate::view::{ViewContext, ViewKind};
|
||||||
|
|
||||||
@@ -82,6 +85,8 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
|
|||||||
Command::AutoFold => set_fold(ctx, Fold::Auto),
|
Command::AutoFold => set_fold(ctx, Fold::Auto),
|
||||||
Command::OpenAllFolds => fold_all(ctx, Fold::Open),
|
Command::OpenAllFolds => fold_all(ctx, Fold::Open),
|
||||||
Command::CloseAllFolds => fold_all(ctx, Fold::Closed),
|
Command::CloseAllFolds => fold_all(ctx, Fold::Closed),
|
||||||
|
Command::Tidy(layout) => tidy(ctx, &[cursor], layout),
|
||||||
|
Command::Align(_) => say("select the nodes to line up first: v, then hjkl"),
|
||||||
Command::MoveMode => app(AppAction::EnterMoveMode(Vec::new())),
|
Command::MoveMode => app(AppAction::EnterMoveMode(Vec::new())),
|
||||||
Command::ConnectMode => app(AppAction::EnterConnectMode(Vec::new())),
|
Command::ConnectMode => app(AppAction::EnterConnectMode(Vec::new())),
|
||||||
Command::Visual => app(AppAction::Visual { branches: false }),
|
Command::Visual => app(AppAction::Visual { branches: false }),
|
||||||
@@ -440,6 +445,71 @@ pub fn paste(ctx: &ViewContext<'_>, clip: Clip, at: PasteAt, times: u32) -> Opti
|
|||||||
Some(EditOp::Paste { clip, spot, offsets })
|
Some(EditOp::Paste { clip, spot, offsets })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `<Space>=` and `:tidy`: the branches under `tops` laid out as `layout`
|
||||||
|
/// (see [`arrange::tidy`]), as one undo step.
|
||||||
|
pub fn tidy(ctx: &ViewContext<'_>, tops: &[NodeId], layout: Layout) -> Vec<Action> {
|
||||||
|
let tops: Vec<NodeId> = tops.iter().copied().filter(|&top| !ctx.doc.children(top).is_empty()).collect();
|
||||||
|
let what = match tops.as_slice() {
|
||||||
|
[] => return say("nothing to tidy: no children under it"),
|
||||||
|
[top] => ctx.doc.node(*top).map(|node| node.title().to_string()).unwrap_or_default(),
|
||||||
|
tops => format!("{} branches", tops.len()),
|
||||||
|
};
|
||||||
|
let rects = ctx.box_rects();
|
||||||
|
let places: Vec<(NodeId, CanvasPos)> =
|
||||||
|
tops.iter().flat_map(|&top| arrange::tidy(ctx.doc, top, layout, &rects)).collect();
|
||||||
|
let how = match layout {
|
||||||
|
Layout::Map => "a mind map",
|
||||||
|
Layout::Chart => "an org chart",
|
||||||
|
Layout::Grid => "a grid",
|
||||||
|
};
|
||||||
|
if places.is_empty() {
|
||||||
|
return say(&format!("{what} is tidy already, as {how}"));
|
||||||
|
}
|
||||||
|
// What's outside the branches stays where it is, even under them: say so.
|
||||||
|
let moved: BTreeMap<NodeId, CanvasPos> = places.iter().copied().collect();
|
||||||
|
let inside: BTreeSet<NodeId> = tops.iter().flat_map(|&top| ctx.doc.branch(top)).collect();
|
||||||
|
let after: Vec<CanvasRect> = inside
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| {
|
||||||
|
rects.get(id).map(|rect| CanvasRect::new(moved.get(id).copied().unwrap_or(rect.pos), rect.size))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let covered = Visible::new(ctx.doc, ctx.cursor, ctx.settings.focus)
|
||||||
|
.order
|
||||||
|
.into_iter()
|
||||||
|
.filter(|id| !inside.contains(id))
|
||||||
|
.filter(|id| rects.get(id).is_some_and(|other| after.iter().any(|rect| rect.overlaps(*other))))
|
||||||
|
.count();
|
||||||
|
let text = match covered {
|
||||||
|
0 => format!("tidied {what} as {how} · u undoes it"),
|
||||||
|
count => format!("tidied {what} as {how} · it covers {} now · u undoes it", action::nodes(count)),
|
||||||
|
};
|
||||||
|
vec![Action::edit(EditOp::Reposition(places)), Action::say(Message::done(text))]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<Space>a` and `:align`: the selected nodes lined up on one side (see
|
||||||
|
/// [`arrange::align`]), as one undo step.
|
||||||
|
pub fn align(ctx: &ViewContext<'_>, nodes: &[NodeId], side: Side) -> Vec<Action> {
|
||||||
|
if nodes.len() < 2 {
|
||||||
|
return say("select the nodes to line up first: v, then hjkl");
|
||||||
|
}
|
||||||
|
let places = arrange::align(ctx.doc, nodes, side, &ctx.box_rects());
|
||||||
|
let how = match side {
|
||||||
|
Side::Left => "on the left",
|
||||||
|
Side::Right => "on the right",
|
||||||
|
Side::Top => "along the top",
|
||||||
|
Side::Bottom => "along the bottom",
|
||||||
|
Side::Center => "on a middle line",
|
||||||
|
};
|
||||||
|
if places.is_empty() {
|
||||||
|
return say(&format!("they're lined up {how} already"));
|
||||||
|
}
|
||||||
|
vec![
|
||||||
|
Action::edit(EditOp::Reposition(places)),
|
||||||
|
Action::say(Message::done(format!("lined up {} {how}", action::nodes(nodes.len())))),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
/// Where the cursor goes after a delete: to `next`, or the root when there's
|
/// Where the cursor goes after a delete: to `next`, or the root when there's
|
||||||
/// nowhere else.
|
/// nowhere else.
|
||||||
fn after(next: Option<NodeId>) -> CursorAfter {
|
fn after(next: Option<NodeId>) -> CursorAfter {
|
||||||
|
|||||||
+10
-2
@@ -14,6 +14,7 @@ use crate::input::command::{Bound, Command, help_for};
|
|||||||
use crate::input::engine::{Outcome, Typed};
|
use crate::input::engine::{Outcome, Typed};
|
||||||
use crate::input::key::Key;
|
use crate::input::key::Key;
|
||||||
use crate::input::mouse::{Cell, Gesture};
|
use crate::input::mouse::{Cell, Gesture};
|
||||||
|
use crate::model::NodeId;
|
||||||
use crate::ui::command_line::LineOutcome;
|
use crate::ui::command_line::LineOutcome;
|
||||||
use crate::ui::document_bar::DocumentBar;
|
use crate::ui::document_bar::DocumentBar;
|
||||||
|
|
||||||
@@ -122,21 +123,28 @@ impl App {
|
|||||||
LineOutcome::Typing => {}
|
LineOutcome::Typing => {}
|
||||||
LineOutcome::Close => self.shared.mode = Mode::Normal,
|
LineOutcome::Close => self.shared.mode = Mode::Normal,
|
||||||
LineOutcome::Run(text) => {
|
LineOutcome::Run(text) => {
|
||||||
|
let on = std::mem::take(&mut line.selection);
|
||||||
self.shared.mode = Mode::Normal;
|
self.shared.mode = Mode::Normal;
|
||||||
if text.trim().is_empty() {
|
if text.trim().is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.command_history.add(&text);
|
self.command_history.add(&text);
|
||||||
self.run_line(&text);
|
self.run_line_on(&text, &on);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs a command line (without its `:`).
|
/// Runs a command line (without its `:`).
|
||||||
pub(super) fn run_line(&mut self, text: &str) {
|
pub(super) fn run_line(&mut self, text: &str) {
|
||||||
|
self.run_line_on(text, &[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs a command line typed in visual mode, on the nodes that were
|
||||||
|
/// selected (none: the cursor's).
|
||||||
|
fn run_line_on(&mut self, text: &str, on: &[NodeId]) {
|
||||||
let sources = self.sources();
|
let sources = self.sources();
|
||||||
let actions = match parse(text, &sources) {
|
let actions = match parse(text, &sources) {
|
||||||
Ok(call) => calls::actions_for(call, &self.shared.ctx(self.buffer())),
|
Ok(call) => calls::actions_for(call, &self.shared.ctx(self.buffer()), on),
|
||||||
Err(error) => vec![Action::say(Message::problem(error.to_string()))],
|
Err(error) => vec![Action::say(Message::problem(error.to_string()))],
|
||||||
};
|
};
|
||||||
self.update(actions);
|
self.update(actions);
|
||||||
|
|||||||
+1
-1
@@ -189,7 +189,7 @@ fn hints(mode: &Mode, view: ViewKind) -> &'static str {
|
|||||||
text_hints(edit)
|
text_hints(edit)
|
||||||
}
|
}
|
||||||
(Mode::Visual(_), ViewKind::Canvas | ViewKind::Board) => {
|
(Mode::Visual(_), ViewKind::Canvas | ViewKind::Board) => {
|
||||||
"hjkl more · o other end · d delete · y yank · > < in/out · M move · <Space>c color · Esc stop"
|
"hjkl more · o other end · d delete · y yank · > < in/out · M move · <Space> color, align… · Esc stop"
|
||||||
}
|
}
|
||||||
(Mode::Visual(_), ViewKind::Note) => {
|
(Mode::Visual(_), ViewKind::Note) => {
|
||||||
"h/l the siblings too · o other end · d delete · y yank · > < in/out · <Space>c color · Esc stop"
|
"h/l the siblings too · o other end · d delete · y yank · > < in/out · <Space>c color · Esc stop"
|
||||||
|
|||||||
+2
-1
@@ -3,6 +3,7 @@
|
|||||||
//! works, checked end to end. The files have their own tests in
|
//! works, checked end to end. The files have their own tests in
|
||||||
//! `tests/files.rs`, and the config folder in `tests/config.rs`.
|
//! `tests/files.rs`, and the config folder in `tests/config.rs`.
|
||||||
|
|
||||||
|
mod arrange;
|
||||||
mod config;
|
mod config;
|
||||||
mod connect;
|
mod connect;
|
||||||
mod files;
|
mod files;
|
||||||
@@ -505,7 +506,7 @@ fn indenting_and_outdenting_nodes() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn v1_1_commands_say_so_and_portal_titles_stay() {
|
fn v1_1_commands_say_so_and_portal_titles_stay() {
|
||||||
let mut h = Harness::new();
|
let mut h = Harness::new();
|
||||||
h.type_keys(":tidy<CR>");
|
h.type_keys(":branchout<CR>");
|
||||||
assert_eq!(h.message(), "this one comes in v1.1");
|
assert_eq!(h.message(), "this one comes in v1.1");
|
||||||
// Kitchen is a portal: its title is the other document's name.
|
// Kitchen is a portal: its title is the other document's name.
|
||||||
h.type_keys("glgjgjgj");
|
h.type_keys("glgjgjgj");
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
//! Tidying and lining up (part 9a), end to end: `<Space>=` and `:tidy` lay
|
||||||
|
//! a branch out as a mind map, an org chart or a grid, and `<Space>a` and
|
||||||
|
//! `:align` line up a selection.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::model::CanvasRect;
|
||||||
|
|
||||||
|
/// Where a node's box is now, on the canvas.
|
||||||
|
fn rect(h: &Harness, title: &str) -> CanvasRect {
|
||||||
|
h.app.shared.ctx(h.app.buffer()).box_rects()[&h.node(title)]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tidy_lays_a_branch_out_as_a_mind_map() {
|
||||||
|
let mut h = Harness::new();
|
||||||
|
let root = h.pos("Garden plans");
|
||||||
|
h.type_keys("<Space>=");
|
||||||
|
assert!(h.screen().contains("tidy: a mind map"), "which-key lists them:\n{}", h.screen());
|
||||||
|
h.type_keys("m");
|
||||||
|
assert_eq!(h.message(), "tidied Garden plans as a mind map · it covers 1 node now · u undoes it");
|
||||||
|
assert_eq!(h.pos("Garden plans"), root, "the top stays where it is");
|
||||||
|
let (soil, kitchen) = (rect(&h, "Soil"), rect(&h, "Kitchen"));
|
||||||
|
assert_eq!(soil.left(), kitchen.left(), "the children in a column");
|
||||||
|
let middle = (soil.center().y + kitchen.center().y) / 2;
|
||||||
|
assert_eq!(rect(&h, "Garden plans").center().y, middle, "the root in the middle of them");
|
||||||
|
// Folded away or not, every node gets its place.
|
||||||
|
assert_eq!(rect(&h, "Cedar, untreated").left(), rect(&h, "Raised beds").right() + 6);
|
||||||
|
h.type_keys("u");
|
||||||
|
assert_eq!(h.pos("Soil"), CanvasPos::new(22, 2), "one undo step");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_chart_sends_its_lines_down_and_a_grid_makes_a_block() {
|
||||||
|
let mut h = Harness::new();
|
||||||
|
h.type_keys(":tidy chart<CR>");
|
||||||
|
let (root, soil) = (rect(&h, "Garden plans"), rect(&h, "Soil"));
|
||||||
|
assert_eq!(soil.top(), root.bottom() + 2, "the children below");
|
||||||
|
let (_, row) = h.find("┃ Garden plans ┃");
|
||||||
|
let below = &h.rows()[usize::from(row) + 1];
|
||||||
|
assert!(below.contains('┯') || below.contains('┬'), "the lines leave downward:\n{}", h.screen());
|
||||||
|
// A new child goes beside the last one.
|
||||||
|
h.type_keys("aCompost<Esc><Esc>");
|
||||||
|
let (compost, kitchen) = (rect(&h, "Compost"), rect(&h, "Kitchen"));
|
||||||
|
assert_eq!((compost.top(), compost.left() > kitchen.right()), (kitchen.top(), true));
|
||||||
|
// The Inbox's ideas, as a block of cards beside it.
|
||||||
|
h.type_keys(":in mulch<CR>:in seeds<CR>:in hoe<CR>:in worms<CR>:find inbox<CR><Space>=g");
|
||||||
|
assert_eq!(h.message(), "tidied Inbox as a grid · u undoes it");
|
||||||
|
let (mulch, seeds, hoe) = (rect(&h, "mulch"), rect(&h, "seeds"), rect(&h, "hoe"));
|
||||||
|
assert_eq!((mulch.top(), mulch.left()), (rect(&h, "Inbox").top(), hoe.left()), "two by two");
|
||||||
|
assert_eq!(mulch.top(), seeds.top());
|
||||||
|
// Only the first column is joined: a line into seeds would show between
|
||||||
|
// the two cards.
|
||||||
|
let (col, row) = h.find("seeds");
|
||||||
|
assert_eq!(h.rows()[usize::from(row)].chars().nth(usize::from(col) - 2), Some('│'), "{}", h.screen());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn align_lines_up_a_selection() {
|
||||||
|
let mut h = Harness::new();
|
||||||
|
let barrel = h.pos("Rain barrel");
|
||||||
|
// Soil, Raised beds and Watering, their right edges in a line.
|
||||||
|
h.type_keys("ggglvjj<Space>a");
|
||||||
|
assert!(h.screen().contains("middles in a column"), "which-key lists the sides:\n{}", h.screen());
|
||||||
|
h.type_keys("l");
|
||||||
|
assert_eq!((h.label(), h.message()), ("NORMAL", "lined up 3 nodes on the right"));
|
||||||
|
let right = rect(&h, "Raised beds").right();
|
||||||
|
assert_eq!((rect(&h, "Soil").right(), rect(&h, "Watering").right()), (right, right));
|
||||||
|
let moved = h.pos("Watering").x - 22;
|
||||||
|
assert_eq!(h.pos("Rain barrel"), CanvasPos::new(barrel.x + moved, barrel.y), "its branch came along");
|
||||||
|
// : in visual mode runs a command on the selection.
|
||||||
|
h.type_keys("uggglvjj:");
|
||||||
|
assert!(h.rows()[h.rows().len() - 2].contains("3 nodes selected"), "what it runs on:\n{}", h.screen());
|
||||||
|
h.type_keys("align center<CR>");
|
||||||
|
assert_eq!(h.message(), "lined up 3 nodes on a middle line");
|
||||||
|
let middle = rect(&h, "Soil").center().x;
|
||||||
|
assert_eq!((rect(&h, "Raised beds").center().x, rect(&h, "Watering").center().x), (middle, middle));
|
||||||
|
h.type_keys("ggglvjj:color red<CR>");
|
||||||
|
let red = |h: &Harness, title: &str| {
|
||||||
|
h.doc().node(h.node(title)).unwrap().look.color.as_ref().is_some_and(|color| color.as_str() == "red")
|
||||||
|
};
|
||||||
|
assert!(["Soil", "Raised beds", "Watering"].iter().all(|title| red(&h, title)));
|
||||||
|
h.type_keys("u");
|
||||||
|
assert!(!red(&h, "Soil") && !red(&h, "Watering"), "one undo step");
|
||||||
|
// Without a selection, :align says what it needs.
|
||||||
|
h.type_keys(":align left<CR>");
|
||||||
|
assert_eq!(h.message(), "select the nodes to line up first: v, then hjkl");
|
||||||
|
}
|
||||||
+3
-1
@@ -132,9 +132,11 @@ impl App {
|
|||||||
}
|
}
|
||||||
AppAction::LeaveMode => self.shared.mode = Mode::Normal,
|
AppAction::LeaveMode => self.shared.mode = Mode::Normal,
|
||||||
AppAction::OpenCommandLine => {
|
AppAction::OpenCommandLine => {
|
||||||
|
// From visual mode, the line acts on the selection.
|
||||||
|
let selection = self.selection().unwrap_or_default();
|
||||||
let line =
|
let line =
|
||||||
CommandLine::new(&self.sources(), &self.command_history, self.settings.completion_popup);
|
CommandLine::new(&self.sources(), &self.command_history, self.settings.completion_popup);
|
||||||
self.shared.mode = Mode::Command(line);
|
self.shared.mode = Mode::Command(line.on(selection));
|
||||||
}
|
}
|
||||||
AppAction::CommandLineWith(text) => {
|
AppAction::CommandLineWith(text) => {
|
||||||
let (sources, popup) = (self.sources(), self.settings.completion_popup);
|
let (sources, popup) = (self.sources(), self.settings.completion_popup);
|
||||||
|
|||||||
+20
-2
@@ -69,7 +69,12 @@ pub fn selection(visual: &Visual, ctx: &ViewContext<'_>, kind: ViewKind) -> Vec<
|
|||||||
impl App {
|
impl App {
|
||||||
/// The selected nodes, in visual mode, as the focused view takes them.
|
/// The selected nodes, in visual mode, as the focused view takes them.
|
||||||
pub(super) fn selection(&self) -> Option<Vec<NodeId>> {
|
pub(super) fn selection(&self) -> Option<Vec<NodeId>> {
|
||||||
let Mode::Visual(visual) = &self.shared.mode else { return None };
|
let visual = match &self.shared.mode {
|
||||||
|
Mode::Visual(visual) => visual,
|
||||||
|
// A command line typed in visual mode keeps what was selected.
|
||||||
|
Mode::Command(line) if !line.selection.is_empty() => return Some(line.selection.clone()),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
let pane = self.panes.current();
|
let pane = self.panes.current();
|
||||||
Some(selection(visual, &self.shared.ctx(pane.buffer), pane.view.kind()))
|
Some(selection(visual, &self.shared.ctx(pane.buffer), pane.view.kind()))
|
||||||
}
|
}
|
||||||
@@ -172,6 +177,19 @@ pub fn command(
|
|||||||
Command::MoveMode => {
|
Command::MoveMode => {
|
||||||
say("several nodes move on the canvas; here, > and < move them through the tree")
|
say("several nodes move on the canvas; here, > and < move them through the tree")
|
||||||
}
|
}
|
||||||
|
// Each selected branch, from its top, laid out; or the nodes lined up
|
||||||
|
// (with `V`, each branch by its top, the rest coming along).
|
||||||
|
Command::Tidy(layout) => {
|
||||||
|
let mut actions = vec![leave];
|
||||||
|
actions.extend(commands::tidy(ctx, &tops(selected, ctx), layout));
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
Command::Align(side) => {
|
||||||
|
let nodes = if visual.branches { tops(selected, ctx) } else { selected.to_vec() };
|
||||||
|
let mut actions = vec![leave];
|
||||||
|
actions.extend(commands::align(ctx, &nodes, side));
|
||||||
|
actions
|
||||||
|
}
|
||||||
// Every node connects to the one the cursor goes to; with `V`, each
|
// Every node connects to the one the cursor goes to; with `V`, each
|
||||||
// branch from its top.
|
// branch from its top.
|
||||||
Command::ConnectMode => {
|
Command::ConnectMode => {
|
||||||
@@ -215,7 +233,7 @@ fn delete(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The selected nodes that aren't under another selected node.
|
/// The selected nodes that aren't under another selected node.
|
||||||
fn tops(selected: &[NodeId], ctx: &ViewContext<'_>) -> Vec<NodeId> {
|
pub(super) fn tops(selected: &[NodeId], ctx: &ViewContext<'_>) -> Vec<NodeId> {
|
||||||
let set: BTreeSet<NodeId> = selected.iter().copied().collect();
|
let set: BTreeSet<NodeId> = selected.iter().copied().collect();
|
||||||
selected
|
selected
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
+1
-21
@@ -8,29 +8,9 @@ use thiserror::Error;
|
|||||||
use super::complete::{edit_distance, rank};
|
use super::complete::{edit_distance, rank};
|
||||||
use super::registry::{self, Choice, CommandId as Id, Spec};
|
use super::registry::{self, Choice, CommandId as Id, Spec};
|
||||||
use crate::model::{Border, ColorName, Fold, NodeId, StyleName, Tag, Title};
|
use crate::model::{Border, ColorName, Fold, NodeId, StyleName, Tag, Title};
|
||||||
|
pub use crate::model::{Layout, Side};
|
||||||
use crate::view::ViewKind;
|
use crate::view::ViewKind;
|
||||||
|
|
||||||
/// How `:tidy` arranges a branch.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum Layout {
|
|
||||||
/// A mind map, growing to the right.
|
|
||||||
Map,
|
|
||||||
/// An org chart, growing down.
|
|
||||||
Chart,
|
|
||||||
/// A grid, for idea dumps.
|
|
||||||
Grid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Which edges `:align` lines up.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum Side {
|
|
||||||
Left,
|
|
||||||
Right,
|
|
||||||
Top,
|
|
||||||
Bottom,
|
|
||||||
Center,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What `:export` writes.
|
/// What `:export` writes.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum ExportFormat {
|
pub enum ExportFormat {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
use super::command::{
|
use super::command::{
|
||||||
Bound, CharKey, Command as C, Motion as M, Operator as Op, Target, TargetBound, TextObject,
|
Bound, CharKey, Command as C, Motion as M, Operator as Op, Target, TargetBound, TextObject,
|
||||||
};
|
};
|
||||||
|
use crate::model::{Layout, Side};
|
||||||
|
|
||||||
/// Where a table's keys work. Each is a section in `keys.toml`.
|
/// Where a table's keys work. Each is a section in `keys.toml`.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
@@ -236,6 +237,9 @@ const NORMAL: &[DefaultBinding] = &[
|
|||||||
bind("<leader>B", C::PickBorder),
|
bind("<leader>B", C::PickBorder),
|
||||||
bind("<leader>t", C::EditTags),
|
bind("<leader>t", C::EditTags),
|
||||||
bind("<leader>p", C::NewPortal),
|
bind("<leader>p", C::NewPortal),
|
||||||
|
bind("<leader>=m", C::Tidy(Layout::Map)),
|
||||||
|
bind("<leader>=c", C::Tidy(Layout::Chart)),
|
||||||
|
bind("<leader>=g", C::Tidy(Layout::Grid)),
|
||||||
bind("<leader>i", C::Capture),
|
bind("<leader>i", C::Capture),
|
||||||
bind("<leader>b", C::PickDocument),
|
bind("<leader>b", C::PickDocument),
|
||||||
bind("<leader>vc", C::ShowCanvas),
|
bind("<leader>vc", C::ShowCanvas),
|
||||||
@@ -329,6 +333,15 @@ const VISUAL: &[DefaultBinding] = &[
|
|||||||
bind("<leader>s", C::PickStyle),
|
bind("<leader>s", C::PickStyle),
|
||||||
bind("<leader>B", C::PickBorder),
|
bind("<leader>B", C::PickBorder),
|
||||||
bind("<leader>t", C::EditTags),
|
bind("<leader>t", C::EditTags),
|
||||||
|
bind_help("<leader>=m", C::Tidy(Layout::Map), "tidy each branch: a mind map"),
|
||||||
|
bind_help("<leader>=c", C::Tidy(Layout::Chart), "tidy each branch: an org chart"),
|
||||||
|
bind_help("<leader>=g", C::Tidy(Layout::Grid), "tidy each branch: a grid"),
|
||||||
|
bind("<leader>ah", C::Align(Side::Left)),
|
||||||
|
bind("<leader>al", C::Align(Side::Right)),
|
||||||
|
bind("<leader>ak", C::Align(Side::Top)),
|
||||||
|
bind("<leader>aj", C::Align(Side::Bottom)),
|
||||||
|
bind("<leader>ac", C::Align(Side::Center)),
|
||||||
|
bind_help(":", C::CommandLine, "command line, on the selection"),
|
||||||
bind_help("v", C::Visual, "select a rectangle (again: stop)"),
|
bind_help("v", C::Visual, "select a rectangle (again: stop)"),
|
||||||
bind_help("V", C::VisualBranches, "select whole branches (again: stop)"),
|
bind_help("V", C::VisualBranches, "select whole branches (again: stop)"),
|
||||||
bind_help("<Esc>", C::Escape, "stop selecting"),
|
bind_help("<Esc>", C::Escape, "stop selecting"),
|
||||||
@@ -484,6 +497,8 @@ pub const GROUPS: &[(&str, &str)] = &[
|
|||||||
("z", "view & folds"),
|
("z", "view & folds"),
|
||||||
("<leader>", "actions"),
|
("<leader>", "actions"),
|
||||||
("<leader>v", "show a view"),
|
("<leader>v", "show a view"),
|
||||||
|
("<leader>=", "tidy"),
|
||||||
|
("<leader>a", "align"),
|
||||||
("<C-w>", "panes"),
|
("<C-w>", "panes"),
|
||||||
("[", "previous"),
|
("[", "previous"),
|
||||||
("]", "next"),
|
("]", "next"),
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
//! [`COMMANDS`] names every command, for `keys.toml`, and says what it does,
|
//! [`COMMANDS`] names every command, for `keys.toml`, and says what it does,
|
||||||
//! for which-key and the help.
|
//! for which-key and the help.
|
||||||
|
|
||||||
|
use crate::model::{Layout, Side};
|
||||||
|
|
||||||
/// Ways to move the cursor.
|
/// Ways to move the cursor.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum Motion {
|
pub enum Motion {
|
||||||
@@ -295,6 +297,12 @@ pub enum Command {
|
|||||||
/// `Ctrl-u`.
|
/// `Ctrl-u`.
|
||||||
DeleteLineBefore,
|
DeleteLineBefore,
|
||||||
|
|
||||||
|
// Arranging.
|
||||||
|
/// Lay the cursor's branch out (or each selected one).
|
||||||
|
Tidy(Layout),
|
||||||
|
/// Line the selected nodes up.
|
||||||
|
Align(Side),
|
||||||
|
|
||||||
// Looks, tags, portals.
|
// Looks, tags, portals.
|
||||||
PickColor,
|
PickColor,
|
||||||
PickStyle,
|
PickStyle,
|
||||||
@@ -497,6 +505,15 @@ pub const COMMANDS: &[CommandInfo] = &[
|
|||||||
info("delete-after", C::DeleteAfter, "delete the character after the cursor"),
|
info("delete-after", C::DeleteAfter, "delete the character after the cursor"),
|
||||||
info("delete-word-before", C::DeleteWordBefore, "delete the word before the cursor"),
|
info("delete-word-before", C::DeleteWordBefore, "delete the word before the cursor"),
|
||||||
info("delete-line-before", C::DeleteLineBefore, "delete back to the start of the line"),
|
info("delete-line-before", C::DeleteLineBefore, "delete back to the start of the line"),
|
||||||
|
// Arranging.
|
||||||
|
info("tidy-map", C::Tidy(Layout::Map), "tidy: a mind map"),
|
||||||
|
info("tidy-chart", C::Tidy(Layout::Chart), "tidy: an org chart"),
|
||||||
|
info("tidy-grid", C::Tidy(Layout::Grid), "tidy: a grid beside it"),
|
||||||
|
info("align-left", C::Align(Side::Left), "left edges in a line"),
|
||||||
|
info("align-right", C::Align(Side::Right), "right edges in a line"),
|
||||||
|
info("align-top", C::Align(Side::Top), "top edges in a line"),
|
||||||
|
info("align-bottom", C::Align(Side::Bottom), "bottom edges in a line"),
|
||||||
|
info("align-center", C::Align(Side::Center), "middles in a column"),
|
||||||
// Looks, tags, portals.
|
// Looks, tags, portals.
|
||||||
info("pick-color", C::PickColor, "color…"),
|
info("pick-color", C::PickColor, "color…"),
|
||||||
info("pick-style", C::PickStyle, "style…"),
|
info("pick-style", C::PickStyle, "style…"),
|
||||||
|
|||||||
@@ -129,6 +129,30 @@ impl CanvasRect {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How `:tidy` arranges a branch.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Layout {
|
||||||
|
/// A mind map: the children to the right, each node in the middle of
|
||||||
|
/// its own.
|
||||||
|
Map,
|
||||||
|
/// An org chart: the children below, each node in the middle above its
|
||||||
|
/// own.
|
||||||
|
Chart,
|
||||||
|
/// A block of cards beside the node, for idea dumps.
|
||||||
|
Grid,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which edges `:align` lines up.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Side {
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
Top,
|
||||||
|
Bottom,
|
||||||
|
/// The middles, in a column.
|
||||||
|
Center,
|
||||||
|
}
|
||||||
|
|
||||||
/// The four directions, on screen and on the canvas. Up is toward smaller
|
/// The four directions, on screen and on the canvas. Up is toward smaller
|
||||||
/// `y`.
|
/// `y`.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ pub use clip::{Clip, ClipNode};
|
|||||||
pub use document::{Document, NodeRecord, Place, StructureError};
|
pub use document::{Document, NodeRecord, Place, StructureError};
|
||||||
pub use edit::{Change, Edit, EditError, Spot};
|
pub use edit::{Change, Edit, EditError, Spot};
|
||||||
pub use fold::Visible;
|
pub use fold::Visible;
|
||||||
pub use geometry::{CanvasOffset, CanvasPos, CanvasRect, CanvasSize, Direction};
|
pub use geometry::{CanvasOffset, CanvasPos, CanvasRect, CanvasSize, Direction, Layout, Side};
|
||||||
pub use history::{Group, History, StateId};
|
pub use history::{Group, History, StateId};
|
||||||
pub use ids::{DocUid, NodeId};
|
pub use ids::{DocUid, NodeId};
|
||||||
pub use link::{LineRef, Link, LinkKey};
|
pub use link::{LineRef, Link, LinkKey};
|
||||||
|
|||||||
+9
-1
@@ -7,7 +7,7 @@ use std::collections::BTreeSet;
|
|||||||
use super::clip::Clip;
|
use super::clip::Clip;
|
||||||
use super::document::Document;
|
use super::document::Document;
|
||||||
use super::edit::{Change, Edit, EditError, Spot};
|
use super::edit::{Change, Edit, EditError, Spot};
|
||||||
use super::geometry::CanvasOffset;
|
use super::geometry::{CanvasOffset, CanvasPos};
|
||||||
use super::ids::NodeId;
|
use super::ids::NodeId;
|
||||||
use super::link::{LineRef, Link, LinkKey};
|
use super::link::{LineRef, Link, LinkKey};
|
||||||
use super::look::{Border, LineLook};
|
use super::look::{Border, LineLook};
|
||||||
@@ -97,6 +97,8 @@ pub enum EditOp {
|
|||||||
key: LinkKey,
|
key: LinkKey,
|
||||||
label: Option<Label>,
|
label: Option<Label>,
|
||||||
},
|
},
|
||||||
|
/// Nodes to new places on the canvas (`:tidy`, `:align`).
|
||||||
|
Reposition(Vec<(NodeId, CanvasPos)>),
|
||||||
/// Copies of a clip (`p`): the first at `spot`, each moved by its
|
/// Copies of a clip (`p`): the first at `spot`, each moved by its
|
||||||
/// offset, the next ones right after it.
|
/// offset, the next ones right after it.
|
||||||
Paste {
|
Paste {
|
||||||
@@ -175,6 +177,12 @@ fn apply(edit: &mut Edit<'_>, op: &EditOp) -> Result<Applied, EditError> {
|
|||||||
}
|
}
|
||||||
Ok(Applied::Changed)
|
Ok(Applied::Changed)
|
||||||
}
|
}
|
||||||
|
EditOp::Reposition(places) => {
|
||||||
|
for &(id, pos) in places {
|
||||||
|
edit.update(id, |node| node.pos = pos)?;
|
||||||
|
}
|
||||||
|
Ok(Applied::Changed)
|
||||||
|
}
|
||||||
EditOp::SetFold { ids, fold } => {
|
EditOp::SetFold { ids, fold } => {
|
||||||
for &id in ids {
|
for &id in ids {
|
||||||
edit.update(id, |node| node.fold = *fold)?;
|
edit.update(id, |node| node.fold = *fold)?;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ use crate::command::complete::{Completion, complete};
|
|||||||
use crate::command::history::History;
|
use crate::command::history::History;
|
||||||
use crate::command::parse::Sources;
|
use crate::command::parse::Sources;
|
||||||
use crate::input::key::{Key, KeyName};
|
use crate::input::key::{Key, KeyName};
|
||||||
|
use crate::model::NodeId;
|
||||||
|
|
||||||
/// How many candidates the popup shows at once.
|
/// How many candidates the popup shows at once.
|
||||||
const POPUP_ROWS: usize = 8;
|
const POPUP_ROWS: usize = 8;
|
||||||
@@ -65,6 +66,9 @@ pub struct CommandLine {
|
|||||||
browsing: Option<(String, usize)>,
|
browsing: Option<(String, usize)>,
|
||||||
/// What the ghost text would add after the cursor.
|
/// What the ghost text would add after the cursor.
|
||||||
ghost: Option<String>,
|
ghost: Option<String>,
|
||||||
|
/// The nodes a line typed in visual mode acts on (`:align left`): the
|
||||||
|
/// selection when `:` was typed. Empty otherwise.
|
||||||
|
pub selection: Vec<NodeId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a key in the command line leads to.
|
/// What a key in the command line leads to.
|
||||||
@@ -95,6 +99,11 @@ impl CommandLine {
|
|||||||
line
|
line
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same command line, acting on these selected nodes.
|
||||||
|
pub fn on(self, selection: Vec<NodeId>) -> CommandLine {
|
||||||
|
CommandLine { selection, ..self }
|
||||||
|
}
|
||||||
|
|
||||||
/// What's typed so far.
|
/// What's typed so far.
|
||||||
pub fn text(&self) -> &str {
|
pub fn text(&self) -> &str {
|
||||||
self.input.text()
|
self.input.text()
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
//! Arranging nodes that are already on the canvas: `:tidy` lays a branch out
|
||||||
|
//! as a mind map, an org chart or a grid (the top node stays where it is),
|
||||||
|
//! and `:align` lines up a selection. Both only work out new places; the
|
||||||
|
//! caller makes them one edit, so they undo in one step.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! map chart grid
|
||||||
|
//! ┌──────┐ ╭────────╮
|
||||||
|
//! ┌──┤ Soil │ │ Garden │ ╭───────╮ ┌───┐ ┌───┐
|
||||||
|
//! ╭────────╮ │ └──────┘ ╰───┬────╯ │ Inbox ├──┤ a │ │ b │
|
||||||
|
//! │ Garden ├─┼──┤ Beds │ ┌─────┴─┬──────┐ ╰───────╯ └───┘ └───┘
|
||||||
|
//! ╰────────╯ │ ┌──────┐ ┌─┴──┐ ┌──┴─┐ ┌──┴─┐ ┌───┐
|
||||||
|
//! └──┤ Pond │ │Soil│ │Beds│ │Pond│ │ c │
|
||||||
|
//! └──────┘ └────┘ └────┘ └────┘ └───┘
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Every node of the branch gets a place, folded away or not (the user's
|
||||||
|
//! pick in part 9a), so unfolding later shows it tidy too.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
use crate::model::{CanvasPos, CanvasRect, Document, Layout, NodeId, Side};
|
||||||
|
|
||||||
|
/// Columns between a parent and its children in a mind map (and a grid):
|
||||||
|
/// room for the branch lines.
|
||||||
|
const BRANCH_GAP: i32 = 6;
|
||||||
|
/// Rows between branches stacked one above another.
|
||||||
|
const ROW_GAP: i32 = 1;
|
||||||
|
/// Rows between a parent and its children in an org chart: the lines bend
|
||||||
|
/// there.
|
||||||
|
const LEVEL_GAP: i32 = 2;
|
||||||
|
/// Columns between branches side by side in an org chart, and between the
|
||||||
|
/// cards of a grid.
|
||||||
|
const COL_GAP: i32 = 2;
|
||||||
|
|
||||||
|
/// New places for the nodes of `top`'s branch, laid out as `layout`; `top`
|
||||||
|
/// stays where it is. `rects` has every node's box (at Normal zoom). Only
|
||||||
|
/// the nodes that move are listed.
|
||||||
|
pub fn tidy(
|
||||||
|
doc: &Document,
|
||||||
|
top: NodeId,
|
||||||
|
layout: Layout,
|
||||||
|
rects: &BTreeMap<NodeId, CanvasRect>,
|
||||||
|
) -> Vec<(NodeId, CanvasPos)> {
|
||||||
|
let size = |id: NodeId| {
|
||||||
|
rects.get(&id).map_or((4, 3), |rect| (rect.right() - rect.left(), rect.bottom() - rect.top()))
|
||||||
|
};
|
||||||
|
let block = match layout {
|
||||||
|
Layout::Map => map(doc, top, &size),
|
||||||
|
Layout::Chart => chart(doc, top, &size),
|
||||||
|
Layout::Grid => grid(doc, top, &size),
|
||||||
|
};
|
||||||
|
let Some(&(_, laid)) = block.places.iter().find(|(id, _)| *id == top) else { return Vec::new() };
|
||||||
|
let Some(here) = doc.node(top).map(|node| node.pos) else { return Vec::new() };
|
||||||
|
let (dx, dy) = (here.x - laid.x, here.y - laid.y);
|
||||||
|
block
|
||||||
|
.places
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, pos)| (id, CanvasPos::new(pos.x + dx, pos.y + dy)))
|
||||||
|
.filter(|(id, pos)| doc.node(*id).is_some_and(|node| node.pos != *pos))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// New places for `ids`, lined up on one `side` of the box around them all
|
||||||
|
/// (`Center`: their middles on its middle column). Each brings along what's
|
||||||
|
/// under it that isn't in `ids` itself, as a moved node brings its branch.
|
||||||
|
/// Only the nodes that move are listed.
|
||||||
|
pub fn align(
|
||||||
|
doc: &Document,
|
||||||
|
ids: &[NodeId],
|
||||||
|
side: Side,
|
||||||
|
rects: &BTreeMap<NodeId, CanvasRect>,
|
||||||
|
) -> Vec<(NodeId, CanvasPos)> {
|
||||||
|
let chosen: BTreeSet<NodeId> = ids.iter().copied().filter(|id| rects.contains_key(id)).collect();
|
||||||
|
let boxes: Vec<CanvasRect> = chosen.iter().filter_map(|id| rects.get(id).copied()).collect();
|
||||||
|
let (Some(left), Some(top), Some(right), Some(bottom)) = (
|
||||||
|
boxes.iter().map(|rect| rect.left()).min(),
|
||||||
|
boxes.iter().map(|rect| rect.top()).min(),
|
||||||
|
boxes.iter().map(|rect| rect.right()).max(),
|
||||||
|
boxes.iter().map(|rect| rect.bottom()).max(),
|
||||||
|
) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let middle = (left + right) / 2;
|
||||||
|
// How far each chosen node moves.
|
||||||
|
let moves: BTreeMap<NodeId, (i32, i32)> = chosen
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&id| {
|
||||||
|
let rect = rects.get(&id)?;
|
||||||
|
let width = rect.right() - rect.left();
|
||||||
|
let height = rect.bottom() - rect.top();
|
||||||
|
let to = match side {
|
||||||
|
Side::Left => CanvasPos::new(left, rect.top()),
|
||||||
|
Side::Right => CanvasPos::new(right - width, rect.top()),
|
||||||
|
Side::Top => CanvasPos::new(rect.left(), top),
|
||||||
|
Side::Bottom => CanvasPos::new(rect.left(), bottom - height),
|
||||||
|
Side::Center => CanvasPos::new(middle - width / 2, rect.top()),
|
||||||
|
};
|
||||||
|
Some((id, (to.x - rect.left(), to.y - rect.top())))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// Every node moves as far as the nearest chosen node it's under (or is).
|
||||||
|
doc.outline()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|id| {
|
||||||
|
let mover =
|
||||||
|
std::iter::once(id).chain(doc.ancestors(id)).find(|ancestor| chosen.contains(ancestor))?;
|
||||||
|
let &(dx, dy) = moves.get(&mover)?;
|
||||||
|
let pos = doc.node(id)?.pos;
|
||||||
|
((dx, dy) != (0, 0)).then(|| (id, CanvasPos::new(pos.x + dx, pos.y + dy)))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A branch laid out: where each of its nodes goes, from the top-left corner
|
||||||
|
/// of the block they make; how big that block is; and where its own node
|
||||||
|
/// is in it (its middle row in a map, its middle column in a chart), for
|
||||||
|
/// its parent to line up with.
|
||||||
|
struct Block {
|
||||||
|
places: Vec<(NodeId, CanvasPos)>,
|
||||||
|
width: i32,
|
||||||
|
height: i32,
|
||||||
|
anchor: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Block {
|
||||||
|
/// A node on its own.
|
||||||
|
fn leaf(id: NodeId, (width, height): (i32, i32), anchor: i32) -> Block {
|
||||||
|
Block { places: vec![(id, CanvasPos::new(0, 0))], width, height, anchor }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shift(&mut self, dx: i32, dy: i32) {
|
||||||
|
for (_, pos) in &mut self.places {
|
||||||
|
*pos = CanvasPos::new(pos.x + dx, pos.y + dy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A mind map: the children's branches stacked to the right, and the node
|
||||||
|
/// level with the middle of the first and last children.
|
||||||
|
fn map(doc: &Document, id: NodeId, size: &impl Fn(NodeId) -> (i32, i32)) -> Block {
|
||||||
|
let (width, height) = size(id);
|
||||||
|
let children = doc.children(id);
|
||||||
|
if children.is_empty() {
|
||||||
|
return Block::leaf(id, (width, height), height / 2);
|
||||||
|
}
|
||||||
|
let x = width + BRANCH_GAP;
|
||||||
|
let (mut places, mut y, mut right) = (Vec::new(), 0, 0);
|
||||||
|
let mut anchors = Vec::new();
|
||||||
|
for &child in children {
|
||||||
|
let mut block = map(doc, child, size);
|
||||||
|
block.shift(x, y);
|
||||||
|
anchors.push(y + block.anchor);
|
||||||
|
right = right.max(x + block.width);
|
||||||
|
y += block.height + ROW_GAP;
|
||||||
|
places.extend(block.places);
|
||||||
|
}
|
||||||
|
let below = y - ROW_GAP;
|
||||||
|
let middle = (anchors[0] + anchors[anchors.len() - 1]) / 2;
|
||||||
|
// A node taller than reaches above its children: they go down instead.
|
||||||
|
let top = middle - height / 2;
|
||||||
|
let down = (-top).max(0);
|
||||||
|
let mut block = Block { places, width: right, height: below + down, anchor: top + down + height / 2 };
|
||||||
|
block.shift(0, down);
|
||||||
|
block.places.push((id, CanvasPos::new(0, top + down)));
|
||||||
|
block.height = block.height.max(top + down + height);
|
||||||
|
block
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An org chart: the children's branches side by side below, and the node
|
||||||
|
/// above the middle of the first and last children.
|
||||||
|
fn chart(doc: &Document, id: NodeId, size: &impl Fn(NodeId) -> (i32, i32)) -> Block {
|
||||||
|
let (width, height) = size(id);
|
||||||
|
let children = doc.children(id);
|
||||||
|
if children.is_empty() {
|
||||||
|
return Block::leaf(id, (width, height), width / 2);
|
||||||
|
}
|
||||||
|
let y = height + LEVEL_GAP;
|
||||||
|
let (mut places, mut x, mut bottom) = (Vec::new(), 0, 0);
|
||||||
|
let mut anchors = Vec::new();
|
||||||
|
for &child in children {
|
||||||
|
let mut block = chart(doc, child, size);
|
||||||
|
block.shift(x, y);
|
||||||
|
anchors.push(x + block.anchor);
|
||||||
|
bottom = bottom.max(y + block.height);
|
||||||
|
x += block.width + COL_GAP;
|
||||||
|
places.extend(block.places);
|
||||||
|
}
|
||||||
|
let across = x - COL_GAP;
|
||||||
|
let middle = (anchors[0] + anchors[anchors.len() - 1]) / 2;
|
||||||
|
let left = middle - width / 2;
|
||||||
|
let over = (-left).max(0);
|
||||||
|
let mut block = Block { places, width: across + over, height: bottom, anchor: left + over + width / 2 };
|
||||||
|
block.shift(over, 0);
|
||||||
|
block.places.push((id, CanvasPos::new(left + over, 0)));
|
||||||
|
block.width = block.width.max(left + over + width);
|
||||||
|
block
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A grid: the children as a block of cards beside the node, filled left
|
||||||
|
/// to right, about as many columns as rows. A card's own branch goes with it
|
||||||
|
/// as a mind map, and its cell is as big as that.
|
||||||
|
fn grid(doc: &Document, id: NodeId, size: &impl Fn(NodeId) -> (i32, i32)) -> Block {
|
||||||
|
let (width, height) = size(id);
|
||||||
|
let children = doc.children(id);
|
||||||
|
if children.is_empty() {
|
||||||
|
return Block::leaf(id, (width, height), height / 2);
|
||||||
|
}
|
||||||
|
let cards: Vec<Block> = children.iter().map(|&child| map(doc, child, size)).collect();
|
||||||
|
let columns = (1..).find(|columns| columns * columns >= cards.len()).unwrap_or(1);
|
||||||
|
let mut widths = vec![0; columns];
|
||||||
|
let mut heights = vec![0; cards.len().div_ceil(columns)];
|
||||||
|
for (index, card) in cards.iter().enumerate() {
|
||||||
|
widths[index % columns] = widths[index % columns].max(card.width);
|
||||||
|
heights[index / columns] = heights[index / columns].max(card.height);
|
||||||
|
}
|
||||||
|
let x0 = width + BRANCH_GAP;
|
||||||
|
let mut block = Block::leaf(id, (width, height), height / 2);
|
||||||
|
for (index, mut card) in cards.into_iter().enumerate() {
|
||||||
|
let (column, row) = (index % columns, index / columns);
|
||||||
|
let x = x0 + widths[..column].iter().sum::<i32>() + COL_GAP * i32::try_from(column).unwrap_or(0);
|
||||||
|
let y = heights[..row].iter().sum::<i32>() + ROW_GAP * i32::try_from(row).unwrap_or(0);
|
||||||
|
card.shift(x, y);
|
||||||
|
block.width = block.width.max(x + card.width);
|
||||||
|
block.height = block.height.max(y + card.height);
|
||||||
|
block.places.extend(card.places);
|
||||||
|
}
|
||||||
|
block
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::model::test_support::garden;
|
||||||
|
use crate::ui::glyphs::Glyphs;
|
||||||
|
use crate::view::canvas::boxes::box_rects;
|
||||||
|
|
||||||
|
/// Where each node is once `places` are applied.
|
||||||
|
fn moved(doc: &Document, places: &[(NodeId, CanvasPos)]) -> BTreeMap<NodeId, CanvasRect> {
|
||||||
|
let mut rects = box_rects(doc, 30, Glyphs::Unicode);
|
||||||
|
for &(id, pos) in places {
|
||||||
|
if let Some(rect) = rects.get_mut(&id) {
|
||||||
|
rect.pos = pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rects
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_mind_map_centers_each_node_on_its_children() {
|
||||||
|
let (doc, g) = garden();
|
||||||
|
let rects = box_rects(&doc, 30, Glyphs::Unicode);
|
||||||
|
let places = tidy(&doc, g.root, Layout::Map, &rects);
|
||||||
|
let after = moved(&doc, &places);
|
||||||
|
assert!(!places.iter().any(|(id, _)| *id == g.root), "the top stays");
|
||||||
|
let root = after[&g.root];
|
||||||
|
let (soil, watering) = (after[&g.soil], after[&g.watering]);
|
||||||
|
assert_eq!(soil.left(), root.right() + BRANCH_GAP, "children to the right");
|
||||||
|
assert_eq!(soil.left(), watering.left(), "in a column");
|
||||||
|
let middle = (soil.center().y + after[&g.watering].center().y) / 2;
|
||||||
|
assert_eq!(root.center().y, middle, "the root halfway between its first and last");
|
||||||
|
let boxes: Vec<CanvasRect> = after.values().copied().collect();
|
||||||
|
for (index, a) in boxes.iter().enumerate() {
|
||||||
|
assert!(boxes[index + 1..].iter().all(|b| !a.overlaps(*b)), "no box on another");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_chart_grows_down_and_a_grid_beside() {
|
||||||
|
let (doc, g) = garden();
|
||||||
|
let rects = box_rects(&doc, 30, Glyphs::Unicode);
|
||||||
|
let after = moved(&doc, &tidy(&doc, g.root, Layout::Chart, &rects));
|
||||||
|
let root = after[&g.root];
|
||||||
|
assert_eq!(after[&g.soil].top(), root.bottom() + LEVEL_GAP, "children below");
|
||||||
|
assert!(after[&g.soil].right() < after[&g.beds].left(), "side by side");
|
||||||
|
let after = moved(&doc, &tidy(&doc, g.root, Layout::Grid, &rects));
|
||||||
|
// Three children: two columns, two rows.
|
||||||
|
assert_eq!(after[&g.soil].top(), after[&g.root].top(), "level with the node");
|
||||||
|
assert_eq!(after[&g.soil].top(), after[&g.beds].top(), "Soil and Raised beds in a row");
|
||||||
|
assert!(after[&g.watering].top() > after[&g.soil].bottom(), "Watering on the next row");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn align_lines_up_and_brings_branches_along() {
|
||||||
|
let (doc, g) = garden();
|
||||||
|
let rects = box_rects(&doc, 30, Glyphs::Unicode);
|
||||||
|
let places = align(&doc, &[g.root, g.beds], Side::Left, &rects);
|
||||||
|
let after = moved(&doc, &places);
|
||||||
|
assert_eq!(after[&g.beds].left(), rects[&g.root].left(), "to the leftmost edge");
|
||||||
|
let shifted = after[&g.beds].left() - rects[&g.beds].left();
|
||||||
|
assert_eq!(after[&g.cedar].left(), rects[&g.cedar].left() + shifted, "its branch came along");
|
||||||
|
assert!(!places.iter().any(|(id, _)| *id == g.root), "already there");
|
||||||
|
let after = moved(&doc, &align(&doc, &[g.soil, g.cedar], Side::Bottom, &rects));
|
||||||
|
assert_eq!(after[&g.soil].bottom(), after[&g.cedar].bottom());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,13 @@
|
|||||||
//! cell.
|
//! cell.
|
||||||
//! 2. Rendering the scene (in `render.rs`) writes it into the terminal.
|
//! 2. Rendering the scene (in `render.rs`) writes it into the terminal.
|
||||||
//! 3. Clicks are looked up in the same scene ([`Scene::node_at`]); hjkl uses
|
//! 3. Clicks are looked up in the same scene ([`Scene::node_at`]); hjkl uses
|
||||||
//! [`navigate`], and [`placement`] decides where new nodes go.
|
//! [`navigate`], [`placement`] decides where new nodes go, and
|
||||||
|
//! [`arrange`] tidies a branch or lines nodes up.
|
||||||
//!
|
//!
|
||||||
//! [`CanvasView`] puts it together behind the [`View`](super::View) trait: it
|
//! [`CanvasView`] puts it together behind the [`View`](super::View) trait: it
|
||||||
//! keeps the camera and the last scene, and answers keys and the mouse.
|
//! keeps the camera and the last scene, and answers keys and the mouse.
|
||||||
|
|
||||||
|
pub mod arrange;
|
||||||
pub mod boxes;
|
pub mod boxes;
|
||||||
pub mod camera;
|
pub mod camera;
|
||||||
pub mod lines;
|
pub mod lines;
|
||||||
|
|||||||
@@ -9,10 +9,14 @@ use crate::model::{CanvasPos, CanvasRect, CanvasSize, Document, NodeId};
|
|||||||
const BRANCH_GAP: i32 = 6;
|
const BRANCH_GAP: i32 = 6;
|
||||||
/// Rows kept free between boxes stacked one above another.
|
/// Rows kept free between boxes stacked one above another.
|
||||||
const ROW_GAP: i32 = 1;
|
const ROW_GAP: i32 = 1;
|
||||||
|
/// Columns between children side by side, in an org chart.
|
||||||
|
const CHART_GAP: i32 = 2;
|
||||||
|
|
||||||
/// Where a new last child of `parent` goes: to the right of the parent, below
|
/// Where a new last child of `parent` goes: to the right of the parent, below
|
||||||
/// its last child (level with the parent for a first child), then further
|
/// its last child (level with the parent for a first child), then further
|
||||||
/// down until it's clear of every other box.
|
/// down until it's clear of every other box. Under a parent whose children
|
||||||
|
/// are all below it (an org chart, `:tidy chart`), it goes to the right of
|
||||||
|
/// the last child instead.
|
||||||
pub fn child_spot(
|
pub fn child_spot(
|
||||||
doc: &Document,
|
doc: &Document,
|
||||||
parent: NodeId,
|
parent: NodeId,
|
||||||
@@ -20,7 +24,13 @@ pub fn child_spot(
|
|||||||
rects: &BTreeMap<NodeId, CanvasRect>,
|
rects: &BTreeMap<NodeId, CanvasRect>,
|
||||||
) -> CanvasPos {
|
) -> CanvasPos {
|
||||||
let Some(&parent_rect) = rects.get(&parent) else { return CanvasPos::default() };
|
let Some(&parent_rect) = rects.get(&parent) else { return CanvasPos::default() };
|
||||||
match doc.children(parent).last().and_then(|child| rects.get(child)) {
|
let children = doc.children(parent);
|
||||||
|
let below = !children.is_empty()
|
||||||
|
&& children
|
||||||
|
.iter()
|
||||||
|
.all(|child| rects.get(child).is_some_and(|rect| rect.top() > parent_rect.bottom()));
|
||||||
|
match children.last().and_then(|child| rects.get(child)) {
|
||||||
|
Some(last) if below => free_spot(CanvasPos::new(last.right() + CHART_GAP, last.top()), size, rects),
|
||||||
Some(last) => free_spot(CanvasPos::new(last.left(), last.bottom() + ROW_GAP), size, rects),
|
Some(last) => free_spot(CanvasPos::new(last.left(), last.bottom() + ROW_GAP), size, rects),
|
||||||
None => first_child_spot(parent_rect, size, rects),
|
None => first_child_spot(parent_rect, size, rects),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ pub enum Bend {
|
|||||||
/// Close to the start: a parent's branches leave together and share one
|
/// Close to the start: a parent's branches leave together and share one
|
||||||
/// bend, like a bus.
|
/// bend, like a bus.
|
||||||
NearStart,
|
NearStart,
|
||||||
|
/// The same, for a parent whose children are all below it (an org
|
||||||
|
/// chart): its branches leave downward.
|
||||||
|
NearStartBelow,
|
||||||
/// Halfway between the boxes: for cross-links.
|
/// Halfway between the boxes: for cross-links.
|
||||||
Halfway,
|
Halfway,
|
||||||
}
|
}
|
||||||
@@ -49,6 +52,7 @@ pub fn route(from: ScreenRect, to: ScreenRect, bend: Bend) -> Option<Route> {
|
|||||||
let go_sideways = cols_between > 0
|
let go_sideways = cols_between > 0
|
||||||
&& match bend {
|
&& match bend {
|
||||||
Bend::NearStart => true,
|
Bend::NearStart => true,
|
||||||
|
Bend::NearStartBelow => rows_between <= 0,
|
||||||
Bend::Halfway => rows_between <= 0 || cols_between >= rows_between * 2,
|
Bend::Halfway => rows_between <= 0 || cols_between >= rows_between * 2,
|
||||||
};
|
};
|
||||||
if go_sideways {
|
if go_sideways {
|
||||||
@@ -79,8 +83,8 @@ fn sideways(start: ScreenPos, end: ScreenPos, bend: Bend) -> Route {
|
|||||||
let way = (end.col - start.col).signum();
|
let way = (end.col - start.col).signum();
|
||||||
let near = start.col + way * NEAR_START_COLS;
|
let near = start.col + way * NEAR_START_COLS;
|
||||||
let col = match bend {
|
let col = match bend {
|
||||||
Bend::NearStart if (end.col - near) * way > 0 => near,
|
Bend::NearStart | Bend::NearStartBelow if (end.col - near) * way > 0 => near,
|
||||||
Bend::NearStart | Bend::Halfway => (start.col + end.col) / 2,
|
Bend::NearStart | Bend::NearStartBelow | Bend::Halfway => (start.col + end.col) / 2,
|
||||||
};
|
};
|
||||||
Route { start, bends: vec![ScreenPos::new(col, start.row), ScreenPos::new(col, end.row)], end }
|
Route { start, bends: vec![ScreenPos::new(col, start.row), ScreenPos::new(col, end.row)], end }
|
||||||
}
|
}
|
||||||
@@ -93,8 +97,8 @@ fn upright(start: ScreenPos, end: ScreenPos, bend: Bend) -> Route {
|
|||||||
let way = (end.row - start.row).signum();
|
let way = (end.row - start.row).signum();
|
||||||
let near = start.row + way * NEAR_START_ROWS;
|
let near = start.row + way * NEAR_START_ROWS;
|
||||||
let row = match bend {
|
let row = match bend {
|
||||||
Bend::NearStart if (end.row - near) * way > 0 => near,
|
Bend::NearStart | Bend::NearStartBelow if (end.row - near) * way > 0 => near,
|
||||||
Bend::NearStart | Bend::Halfway => (start.row + end.row) / 2,
|
Bend::NearStart | Bend::NearStartBelow | Bend::Halfway => (start.row + end.row) / 2,
|
||||||
};
|
};
|
||||||
Route { start, bends: vec![ScreenPos::new(start.col, row), ScreenPos::new(end.col, row)], end }
|
Route { start, bends: vec![ScreenPos::new(start.col, row), ScreenPos::new(end.col, row)], end }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use unicode_width::UnicodeWidthStr;
|
|||||||
|
|
||||||
use super::boxes::{NodeBox, draft_box, truncate};
|
use super::boxes::{NodeBox, draft_box, truncate};
|
||||||
use super::camera::{Camera, ScreenPos, ScreenRect, Zoom};
|
use super::camera::{Camera, ScreenPos, ScreenRect, Zoom};
|
||||||
use super::lines::{LineLayer, LineOwner, Paint};
|
use super::lines::{LineLayer, LineOwner, Paint, direction_between, step};
|
||||||
use super::route::{Bend, route};
|
use super::route::{Bend, route};
|
||||||
use crate::app::connect;
|
use crate::app::connect;
|
||||||
use crate::app::mode::Mode;
|
use crate::app::mode::Mode;
|
||||||
@@ -110,6 +110,8 @@ impl Scene {
|
|||||||
let picked: BTreeSet<NodeId> = match ctx.mode {
|
let picked: BTreeSet<NodeId> = match ctx.mode {
|
||||||
Mode::Visual(visual) => selection(visual, ctx, ViewKind::Canvas).into_iter().collect(),
|
Mode::Visual(visual) => selection(visual, ctx, ViewKind::Canvas).into_iter().collect(),
|
||||||
Mode::Connect(connecting) => connect::others(connecting, cursor).into_iter().collect(),
|
Mode::Connect(connecting) => connect::others(connecting, cursor).into_iter().collect(),
|
||||||
|
// A command line typed in visual mode still shows what it acts on.
|
||||||
|
Mode::Command(line) => line.selection.iter().copied().collect(),
|
||||||
_ => BTreeSet::new(),
|
_ => BTreeSet::new(),
|
||||||
};
|
};
|
||||||
let aim = Aim::new(ctx);
|
let aim = Aim::new(ctx);
|
||||||
@@ -258,16 +260,30 @@ impl Scene {
|
|||||||
/// stand out in the visual color, and a dashed arrow runs to the cursor
|
/// stand out in the visual color, and a dashed arrow runs to the cursor
|
||||||
/// from each node with no line to it yet.
|
/// from each node with no line to it yet.
|
||||||
fn add_lines(&mut self, doc: &Document, theme: &Theme, aim: &Aim) {
|
fn add_lines(&mut self, doc: &Document, theme: &Theme, aim: &Aim) {
|
||||||
|
// A parent whose children on show are all below it (an org chart)
|
||||||
|
// sends its branches down.
|
||||||
|
let mut sideways: BTreeSet<NodeId> = BTreeSet::new();
|
||||||
|
for child in &self.nodes {
|
||||||
|
let Some(parent) = doc.parent(child.id) else { continue };
|
||||||
|
let Some(&parent_index) = self.index.get(&parent) else { continue };
|
||||||
|
if child.rect.top() <= self.nodes[parent_index].rect.bottom() {
|
||||||
|
sideways.insert(parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
for child_index in 0..self.nodes.len() {
|
for child_index in 0..self.nodes.len() {
|
||||||
let child = &self.nodes[child_index];
|
let child = &self.nodes[child_index];
|
||||||
let Some(Place::Child { parent, branch }) = doc.place(child.id) else { continue };
|
let Some(Place::Child { parent, branch }) = doc.place(child.id) else { continue };
|
||||||
let Some(&parent_index) = self.index.get(parent) else { continue };
|
let Some(&parent_index) = self.index.get(parent) else { continue };
|
||||||
|
let bend = if sideways.contains(parent) { Bend::NearStart } else { Bend::NearStartBelow };
|
||||||
|
if self.behind_a_sibling(parent_index, child_index, bend, doc.children(*parent)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let mut 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)) {
|
if aim.lines.contains(&LineRef::Branch(child.id)) {
|
||||||
style.color = self.visual;
|
style.color = self.visual;
|
||||||
}
|
}
|
||||||
let owner = LineOwner::Branch(child.id);
|
let owner = LineOwner::Branch(child.id);
|
||||||
self.connect(parent_index, child_index, Bend::NearStart, style, owner, None);
|
self.connect(parent_index, child_index, bend, style, owner, None);
|
||||||
}
|
}
|
||||||
for link in doc.links() {
|
for link in doc.links() {
|
||||||
let (Some(&start), Some(&end)) = (self.index.get(&link.start()), self.index.get(&link.end()))
|
let (Some(&start), Some(&end)) = (self.index.get(&link.start()), self.index.get(&link.end()))
|
||||||
@@ -290,6 +306,33 @@ impl Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the branch to node `to` would come in under one of its
|
||||||
|
/// `siblings`: in a grid (`:tidy grid`), a card behind another in its
|
||||||
|
/// row. That line isn't drawn, since the bit of it showing between the
|
||||||
|
/// two cards would look like it joins them; the cards in front show the
|
||||||
|
/// way.
|
||||||
|
fn behind_a_sibling(&self, from: usize, to: usize, bend: Bend, siblings: &[NodeId]) -> bool {
|
||||||
|
let pad = if self.zoom == Zoom::Normal { 0 } else { 1 };
|
||||||
|
let Some(route) = route(self.nodes[from].rect.widened(pad), self.nodes[to].rect.widened(pad), bend)
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let points = route.points();
|
||||||
|
let (Some(&end), Some(&before)) = (points.last(), points.iter().rev().nth(1)) else { return false };
|
||||||
|
let Some(way) = direction_between(before, end) else { return false };
|
||||||
|
let mut at = before;
|
||||||
|
while at != end {
|
||||||
|
if let Some(index) = covered_at(&self.covered, self.size, at)
|
||||||
|
&& index != to
|
||||||
|
&& siblings.contains(&self.nodes[index].id)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
at = step(at, way);
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/// Draws one line from node `from` to node `to` (indexes in `nodes`),
|
/// Draws one line from node `from` to node `to` (indexes in `nodes`),
|
||||||
/// with its arrowheads and label.
|
/// with its arrowheads and label.
|
||||||
fn connect(
|
fn connect(
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ impl OutlineView {
|
|||||||
self.picked = match ctx.mode {
|
self.picked = match ctx.mode {
|
||||||
Mode::Visual(visual) => selection(visual, ctx, ViewKind::Outline).into_iter().collect(),
|
Mode::Visual(visual) => selection(visual, ctx, ViewKind::Outline).into_iter().collect(),
|
||||||
Mode::Connect(connecting) => connect::others(connecting, ctx.cursor).into_iter().collect(),
|
Mode::Connect(connecting) => connect::others(connecting, ctx.cursor).into_iter().collect(),
|
||||||
|
Mode::Command(line) => line.selection.iter().copied().collect(),
|
||||||
_ => BTreeSet::new(),
|
_ => BTreeSet::new(),
|
||||||
};
|
};
|
||||||
// The color of the lines from a node to its children, as on the
|
// The color of the lines from a node to its children, as on the
|
||||||
|
|||||||
Reference in new issue
Block a user