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

+32 -14
View File
@@ -2,21 +2,26 @@
//! actions, the way views turn keys into actions.
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::popup::PickKind;
use super::visual;
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::{ViewContext, ViewKind};
/// The title of the loose node `:in` collects ideas under.
const INBOX: &str = "Inbox";
/// The actions a command line asks for. Commands from later stages of the
/// build say so.
pub fn actions_for(call: Call, ctx: &ViewContext<'_>) -> Vec<Action> {
/// The actions a command line asks for. A line typed in visual mode acts
/// on the nodes that were selected (`on`), as the pickers do: `:color`,
/// `: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 nodes = if on.is_empty() { vec![id] } else { on.to_vec() };
match call {
// :q closes the pane, as in vim: the last one quits.
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::Buffers => app(AppAction::OpenPicker(PickKind::Document)),
Call::Buffer(name) => app(AppAction::SwitchDocument(DocumentChoice::Named(name))),
Call::Style(style) => vec![Action::edit(EditOp::SetStyle { id, style: Some(style) })],
Call::Fold(fold) => set_fold(ctx, fold),
Call::Style(style) => each(&nodes, |id| EditOp::SetStyle { id, style: Some(style.clone()) }),
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::Color(color) => vec![Action::edit(EditOp::SetColor { id, color })],
Call::Border(border) => vec![Action::edit(EditOp::SetBorder { id, border: Some(border) })],
Call::Color(color) => each(&nodes, |id| EditOp::SetColor { id, color: color.clone() }),
Call::Border(border) => each(&nodes, |id| EditOp::SetBorder { id, border: Some(border) }),
Call::Tags(changes) => {
let (mut add, mut remove) = (Vec::new(), Vec::new());
for change in changes {
@@ -52,7 +61,7 @@ pub fn actions_for(call: Call, ctx: &ViewContext<'_>) -> Vec<Action> {
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.
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::Theme(name) => vec![Action::App(AppAction::Set(SetArg::Value("theme".into(), name)))],
Call::Config => app(AppAction::OpenConfig),
Call::Tidy(_) | Call::Align(_) | Call::BranchOut | Call::Export { .. } => {
later("this one comes in v1.1")
}
Call::Tidy(layout) => commands::tidy(ctx, &visual::tops(&nodes, ctx), layout),
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)]
}
/// 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> {
vec![Action::say(message)]
}
@@ -145,7 +163,7 @@ mod tests {
/// Runs a command line's actions on a buffer, as the app does.
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 {
buffer.apply(action);
}
+74 -4
View File
@@ -5,9 +5,11 @@
//! own meaning (in the Note view, `dd` will delete a line of text).
//! [`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::panes::Arrange;
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::engine::Typed;
use crate::model::{
CanvasPos, CanvasRect, CanvasSize, Clip, Direction, Document, EditOp, Fold, Group, Node, NodeId, Spot,
Title, Visible,
CanvasPos, CanvasRect, CanvasSize, Clip, Direction, Document, EditOp, Fold, Group, Layout, Node, NodeId,
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::{ViewContext, ViewKind};
@@ -82,6 +85,8 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
Command::AutoFold => set_fold(ctx, Fold::Auto),
Command::OpenAllFolds => fold_all(ctx, Fold::Open),
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::ConnectMode => app(AppAction::EnterConnectMode(Vec::new())),
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 })
}
/// `<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
/// nowhere else.
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::key::Key;
use crate::input::mouse::{Cell, Gesture};
use crate::model::NodeId;
use crate::ui::command_line::LineOutcome;
use crate::ui::document_bar::DocumentBar;
@@ -122,21 +123,28 @@ impl App {
LineOutcome::Typing => {}
LineOutcome::Close => self.shared.mode = Mode::Normal,
LineOutcome::Run(text) => {
let on = std::mem::take(&mut line.selection);
self.shared.mode = Mode::Normal;
if text.trim().is_empty() {
return;
}
self.command_history.add(&text);
self.run_line(&text);
self.run_line_on(&text, &on);
}
}
}
/// Runs a command line (without its `:`).
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 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()))],
};
self.update(actions);
+1 -1
View File
@@ -189,7 +189,7 @@ fn hints(mode: &Mode, view: ViewKind) -> &'static str {
text_hints(edit)
}
(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) => {
"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
//! `tests/files.rs`, and the config folder in `tests/config.rs`.
mod arrange;
mod config;
mod connect;
mod files;
@@ -505,7 +506,7 @@ fn indenting_and_outdenting_nodes() {
#[test]
fn v1_1_commands_say_so_and_portal_titles_stay() {
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");
// Kitchen is a portal: its title is the other document's name.
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::OpenCommandLine => {
// From visual mode, the line acts on the selection.
let selection = self.selection().unwrap_or_default();
let line =
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) => {
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 {
/// The selected nodes, in visual mode, as the focused view takes them.
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();
Some(selection(visual, &self.shared.ctx(pane.buffer), pane.view.kind()))
}
@@ -172,6 +177,19 @@ pub fn command(
Command::MoveMode => {
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
// branch from its top.
Command::ConnectMode => {
@@ -215,7 +233,7 @@ fn delete(
}
/// 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();
selected
.iter()