diff --git a/Cargo.lock b/Cargo.lock index 5a73417..50de61e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -148,7 +148,7 @@ dependencies = [ [[package]] name = "cumulus" -version = "0.1.0" +version = "1.0.0" dependencies = [ "arboard", "directories", diff --git a/Cargo.toml b/Cargo.toml index 5db9644..c36a295 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cumulus" -version = "0.1.0" +version = "1.0.0" edition = "2024" # The version cumulus is built and tested with (pinned in rust-toolchain.toml). rust-version = "1.90" diff --git a/DESIGN.md b/DESIGN.md index 56b1322..92aaf5b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -249,6 +249,33 @@ lines. document by uid (a file moved or renamed by hand), and keeps the path it finds. +**Tidying and lining up** (settled in part 9a) +- `=` 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, `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). `=` 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) - 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 @@ -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 | | **Operators** | `dd` or `dab` delete branch · `dib` delete children · `x` delete just the node · `yy` `yab` `yib` yank · `p` paste as children · `P` paste as siblings below · `>>` `<<` indent / outdent · `"a`–`"z` and `"+` registers · `.` repeat · `u` `Ctrl-r` undo / redo | | **Modes** | `v` `V` visual · `M` move · `C` connect · `:` command · Esc back to normal | -| **Leader** `` | `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** `` | `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) | | **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 | @@ -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 | | **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 · `` style menu (`s` named, `a` arrows, `l` line, `c` color, `t` label) · Esc cancel (takes it all back) | -| **Visual** | `d` `x` `y` `>` `<` `M` and `c` `s` `B` `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 `c` `s` `B` `t` act on the whole selection · `a` then `h` `l` `k` `j` `c` line it up · `=` 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 @@ -662,7 +689,7 @@ cumulus/ │ keymap (+ keys.toml), engine (counts, registers, operators), mouse ├─ command/ registry, parse, complete, history ├─ 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: │ motion, object, layout), list (the popup list help, menus and pickers │ 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`, -`app/tests/config.rs`, `app/tests/vim.rs`, `app/tests/connect.rs` and -`app/tests/portals.rs`): they copy the sample +`app/tests/config.rs`, `app/tests/vim.rs`, `app/tests/connect.rs`, +`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 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 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 -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 diff --git a/README.md b/README.md index defc216..4a37860 100644 --- a/README.md +++ b/README.md @@ -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) | | `v` `V` | select: the nodes between here and where you started (`V`: with their branches); then `d` `y` `>` `<` `M` or `c` act on all of them | | `M` | move the branch with `hjkl` (`M` again: just the node) | +| `=` | tidy the branch: `m` a mind map, `c` an org chart, `g` a grid (`:tidy`) | +| `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, `` `a` `l` `c` `s` `t` style it, Esc takes it all back) | | `/` `n` `N` | search: titles, tags and bodies, fuzzily, with the note of each match beside it / the next match / the previous one | | `Ctrl-o` `Tab` | back to where you jumped from / forward again (across documents) | diff --git a/src/app/calls.rs b/src/app/calls.rs index 5c99fe3..7ef1c8a 100644 --- a/src/app/calls.rs +++ b/src/app/calls.rs @@ -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 { +/// 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 { 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 { 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 { 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 { 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 { vec![Action::App(action)] } +/// The same change on each node, as one undo step. +fn each(nodes: &[NodeId], op: impl Fn(NodeId) -> EditOp) -> Vec { + 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 { 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); } diff --git a/src/app/commands.rs b/src/app/commands.rs index b073b47..6dc8987 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -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> { 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 }) } +/// `=` 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 { + let tops: Vec = 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 = places.iter().copied().collect(); + let inside: BTreeSet = tops.iter().flat_map(|&top| ctx.doc.branch(top)).collect(); + let after: Vec = 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))] +} + +/// `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 { + 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) -> CursorAfter { diff --git a/src/app/input.rs b/src/app/input.rs index deb9f04..b61f6bf 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -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); diff --git a/src/app/render.rs b/src/app/render.rs index f82139b..acb94f3 100644 --- a/src/app/render.rs +++ b/src/app/render.rs @@ -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 · c color · Esc stop" + "hjkl more · o other end · d delete · y yank · > < in/out · M move · color, align… · Esc stop" } (Mode::Visual(_), ViewKind::Note) => { "h/l the siblings too · o other end · d delete · y yank · > < in/out · c color · Esc stop" diff --git a/src/app/tests.rs b/src/app/tests.rs index 46c2009..0287bdb 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -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"); + h.type_keys(":branchout"); 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"); diff --git a/src/app/tests/arrange.rs b/src/app/tests/arrange.rs new file mode 100644 index 0000000..690444f --- /dev/null +++ b/src/app/tests/arrange.rs @@ -0,0 +1,87 @@ +//! Tidying and lining up (part 9a), end to end: `=` and `:tidy` lay +//! a branch out as a mind map, an org chart or a grid, and `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("="); + 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"); + 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"); + 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:in seeds:in hoe:in worms:find inbox=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("ggglvjja"); + 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"); + 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"); + 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"); + assert_eq!(h.message(), "select the nodes to line up first: v, then hjkl"); +} diff --git a/src/app/update.rs b/src/app/update.rs index 25b5e7f..979b31d 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -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); diff --git a/src/app/visual.rs b/src/app/visual.rs index 40302af..216e457 100644 --- a/src/app/visual.rs +++ b/src/app/visual.rs @@ -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> { - 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 { +pub(super) fn tops(selected: &[NodeId], ctx: &ViewContext<'_>) -> Vec { let set: BTreeSet = selected.iter().copied().collect(); selected .iter() diff --git a/src/command/parse.rs b/src/command/parse.rs index e650c0a..2b013d4 100644 --- a/src/command/parse.rs +++ b/src/command/parse.rs @@ -8,29 +8,9 @@ use thiserror::Error; use super::complete::{edit_distance, rank}; use super::registry::{self, Choice, CommandId as Id, Spec}; use crate::model::{Border, ColorName, Fold, NodeId, StyleName, Tag, Title}; +pub use crate::model::{Layout, Side}; 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. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ExportFormat { diff --git a/src/input/bindings.rs b/src/input/bindings.rs index 1af3ec0..6da10e1 100644 --- a/src/input/bindings.rs +++ b/src/input/bindings.rs @@ -8,6 +8,7 @@ use super::command::{ 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`. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -236,6 +237,9 @@ const NORMAL: &[DefaultBinding] = &[ bind("B", C::PickBorder), bind("t", C::EditTags), bind("p", C::NewPortal), + bind("=m", C::Tidy(Layout::Map)), + bind("=c", C::Tidy(Layout::Chart)), + bind("=g", C::Tidy(Layout::Grid)), bind("i", C::Capture), bind("b", C::PickDocument), bind("vc", C::ShowCanvas), @@ -329,6 +333,15 @@ const VISUAL: &[DefaultBinding] = &[ bind("s", C::PickStyle), bind("B", C::PickBorder), bind("t", C::EditTags), + bind_help("=m", C::Tidy(Layout::Map), "tidy each branch: a mind map"), + bind_help("=c", C::Tidy(Layout::Chart), "tidy each branch: an org chart"), + bind_help("=g", C::Tidy(Layout::Grid), "tidy each branch: a grid"), + bind("ah", C::Align(Side::Left)), + bind("al", C::Align(Side::Right)), + bind("ak", C::Align(Side::Top)), + bind("aj", C::Align(Side::Bottom)), + bind("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::VisualBranches, "select whole branches (again: stop)"), bind_help("", C::Escape, "stop selecting"), @@ -484,6 +497,8 @@ pub const GROUPS: &[(&str, &str)] = &[ ("z", "view & folds"), ("", "actions"), ("v", "show a view"), + ("=", "tidy"), + ("a", "align"), ("", "panes"), ("[", "previous"), ("]", "next"), diff --git a/src/input/command.rs b/src/input/command.rs index 2902ff3..a0ecfdf 100644 --- a/src/input/command.rs +++ b/src/input/command.rs @@ -7,6 +7,8 @@ //! [`COMMANDS`] names every command, for `keys.toml`, and says what it does, //! for which-key and the help. +use crate::model::{Layout, Side}; + /// Ways to move the cursor. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Motion { @@ -295,6 +297,12 @@ pub enum Command { /// `Ctrl-u`. DeleteLineBefore, + // Arranging. + /// Lay the cursor's branch out (or each selected one). + Tidy(Layout), + /// Line the selected nodes up. + Align(Side), + // Looks, tags, portals. PickColor, PickStyle, @@ -497,6 +505,15 @@ pub const COMMANDS: &[CommandInfo] = &[ 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-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. info("pick-color", C::PickColor, "color…"), info("pick-style", C::PickStyle, "style…"), diff --git a/src/model/geometry.rs b/src/model/geometry.rs index bd5b2d6..3ec75e3 100644 --- a/src/model/geometry.rs +++ b/src/model/geometry.rs @@ -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 /// `y`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/src/model/mod.rs b/src/model/mod.rs index 6c8f90c..d2a1be9 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -28,7 +28,7 @@ pub use clip::{Clip, ClipNode}; pub use document::{Document, NodeRecord, Place, StructureError}; pub use edit::{Change, Edit, EditError, Spot}; 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 ids::{DocUid, NodeId}; pub use link::{LineRef, Link, LinkKey}; diff --git a/src/model/op.rs b/src/model/op.rs index a8ecdda..625a5bd 100644 --- a/src/model/op.rs +++ b/src/model/op.rs @@ -7,7 +7,7 @@ use std::collections::BTreeSet; use super::clip::Clip; use super::document::Document; use super::edit::{Change, Edit, EditError, Spot}; -use super::geometry::CanvasOffset; +use super::geometry::{CanvasOffset, CanvasPos}; use super::ids::NodeId; use super::link::{LineRef, Link, LinkKey}; use super::look::{Border, LineLook}; @@ -97,6 +97,8 @@ pub enum EditOp { key: LinkKey, label: Option