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:
skkeyeandClaude Opus 5.5 committed 2026-10-02 10:42:00 -04:00
1 parent 95c6c3ca37
commit 13a603837e
25 files changed
+709 -65

No files matched your search

Generated
+1 -1
View File
@@ -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
View File
@@ -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"
+35 -6
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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");
+87
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+15
View File
@@ -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"),
+17
View File
@@ -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…"),
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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)?;
+9
View File
@@ -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()
+296
View File
@@ -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());
}
}
+3 -1
View File
@@ -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;
+12 -2
View File
@@ -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),
} }
+8 -4
View File
@@ -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 }
} }
+45 -2
View File
@@ -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(
+1
View File
@@ -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