diff --git a/DESIGN.md b/DESIGN.md index 92aaf5b..3be74c8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -276,6 +276,26 @@ lines. `:border`, `:style`, `:tag` and `:fold`, setting every selected node as one undo step, the way the pickers do. +**Branching out** (settled in part 9b) +- `o` (the user's pick), `:branchout [file]` or the right-click + menu moves the cursor's branch into a document of its own: next to this + one, named after the branch's title (`-2` when that's taken), or at the + path given. Its top node becomes the root, in the middle of the canvas, + and the nodes keep their ids, so the links among them, the marks on them + (small and capital) and the jump lists still find them. +- A portal to the new document takes the branch's place (the same spot in + the tree and on the canvas, the same branch line), as one undo step: `u` + brings the branch back, and the new file stays on disk, as after `:new`. + The pane then goes to the new document, on its root, as through the + portal (the user's pick): `Ctrl-o` comes back, and the breadcrumb starts + with the document it came from. +- Portals inside the branch move along (the user's pick), still leading + where they did from the new file's folder, and their documents' + backlinks follow. Links between the branch and the rest of the document + can't follow (links stay inside a document): the message counts them. + The root can't branch out (it's the whole document), and neither can a + portal (its document is one of its own already). + **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 @@ -505,7 +525,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 · `=` 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 | +| **Leader** `` | `c` color · `s` style · `B` border · `t` tags · `=` tidy (`m` map, `c` chart, `g` grid) · `p` new portal (`:portal `) · `o` this branch out, to a document of its own · `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 | @@ -709,13 +729,14 @@ cumulus/ "+), yank (nodes into registers and back), visual (selections), repeat (`.`), search (`/`), jumps (each pane's jump list), marks (capital marks, `:marks`), connect (connect mode), portals (what portals show, going through them, - `:portal`, backlinks, renames), panes (the split tree), popup (help, menu, - pickers, card, questions) + `:portal`, backlinks, renames), branch_out (`:branchout`), panes (the split + tree), popup (help, menu, pickers, card, questions) ``` The end-to-end tests are in `app/tests.rs` (with `app/tests/files.rs`, `app/tests/config.rs`, `app/tests/vim.rs`, `app/tests/connect.rs`, -`app/tests/portals.rs` and `app/tests/arrange.rs`): they copy the sample +`app/tests/portals.rs`, `app/tests/arrange.rs` and `app/tests/branch_out.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. diff --git a/README.md b/README.md index 4a37860..27d8794 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ The plan for the everyday keys (all of them are in DESIGN.md): | `ma` `'a` `mA` | mark a node / go back to it / a capital mark: from any document | | `gd` | follow a cross-link | | `p` | a portal to another document (`:portal file`, or a new document's title) | +| `o` | this branch out, into a document of its own, with a portal in its place (`:branchout`) | | Enter or `gf` `gu` | through a portal / up to the portals that lead here | | `` | the action menu: `c` color, `t` tags, `b` documents, `i` inbox, `vo` outline, `vn` note… | | `Ctrl-w v` `Ctrl-w s` | split the pane side by side / stacked (`Ctrl-w hjkl` to move between them) | diff --git a/src/app/action.rs b/src/app/action.rs index 1e26250..80f0ced 100644 --- a/src/app/action.rs +++ b/src/app/action.rs @@ -138,6 +138,10 @@ pub enum AppAction { /// `:portal`: a portal under the cursor's node, to the document at the /// path typed (a new document when nothing's there). NewPortal(String), + /// `:branchout` and `o`: the cursor's branch moves into a + /// document of its own (at the path typed, or next to this one), with a + /// portal to it in its place. + BranchOut(Option), /// `gu`: up to the portals that lead to this document; `list` /// (`:backlinks`) lists them even when there's just one. GoUp { diff --git a/src/app/branch_out.rs b/src/app/branch_out.rs new file mode 100644 index 0000000..8e11d76 --- /dev/null +++ b/src/app/branch_out.rs @@ -0,0 +1,158 @@ +//! Branching out (`:branchout`, `o`): the cursor's branch moves into +//! a document of its own, its top node the new root, and a portal to it +//! takes the branch's place. Then the pane goes there, as through the portal +//! (the user's pick in part 9b), so `Ctrl-o` comes back to it. +//! +//! The nodes keep their ids in the new document, so the links among them, +//! and the marks and jumps on them, still find them there. Portals inside +//! the branch move along (the user's pick), still leading where they did. +//! Links between the branch and the rest of the document can't follow: +//! links stay inside a document. `u` brings the branch back; the new file +//! stays on disk, as after `:new`. + +use std::collections::BTreeSet; +use std::path::Path; + +use super::App; +use super::action::{self, CursorAfter, DocAction, Message}; +use super::files::write_new; +use super::portals::folder_of; +use crate::model::{Content, DocUid, Document, EditOp, Link, Node, NodeId, NodeRecord, Place, Portal}; +use crate::storage::link_paths::{relative, resolve}; +use crate::storage::names::file_name; + +impl App { + /// Moves the cursor's branch into a new document: next to this one, + /// named after the branch's title, or at the path `typed`. + pub(super) fn branch_out(&mut self, typed: Option) -> Option { + if let Some(refused) = self.refuse_read_only() { + return Some(refused); + } + let here = self.buffer(); + let open = self.shared.docs.get(here); + let (doc, top) = (open.buffer.doc(), open.buffer.cursor()); + let node = doc.node(top)?; + if top == doc.root() { + return Some(Message::info("the root is the whole document: :branchout takes a branch under it")); + } + if node.is_portal() { + return Some(Message::info("this is a portal: its document is one of its own already")); + } + let folder = folder_of(&open.file.path); + let path = match typed { + Some(typed) => self.resolve_path(&typed), + None => super::files::free_path(&folder, &file_name(node.title().as_str()))?, + }; + if path.exists() { + let name = path.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default(); + return Some(Message::problem(format!( + "{name} is there already: :branchout makes a new document" + ))); + } + let (branch_doc, cut) = carve(doc, top, &folder, &path)?; + let (old_uid, title, pos) = (doc.uid(), node.title().clone(), node.pos); + let loaded = match write_new(&path, branch_doc) { + Ok(loaded) => loaded, + Err(error) => return Some(Message::problem(error.short())), + }; + let (new_uid, path, count) = (loaded.doc.uid(), loaded.path.clone(), loaded.doc.node_count()); + let moved: BTreeSet = loaded.doc.outline().into_iter().collect(); + + // Here, a portal to it takes the branch's place, as one undo step. + let portal = Portal { path: relative(&folder, &path)?, uid: new_uid, title }; + let op = EditOp::BranchOut { id: top, portal: Node::portal(portal, pos) }; + let edit = DocAction::Edit { op: Box::new(op), cursor: CursorAfter::Added, group: None }; + if let Some(problem) = self.doc_action(edit) { + return Some(problem); + } + let portal = self.current().cursor(); + + // The new document opens, with the marks that were on its nodes; + // the capital marks and the jumps on them follow too. + let marks: Vec<(char, NodeId)> = self + .current() + .marks() + .iter() + .filter(|(_, id)| moved.contains(id)) + .map(|(&name, &id)| (name, id)) + .collect(); + let (opened, said) = self.add_document(loaded); + self.shared.docs.get_mut(opened).buffer.keep_marks(marks); + let ids: BTreeSet = moved.iter().map(|id| id.get()).collect(); + self.memory.branched_out(&old_uid.to_string(), &new_uid.to_string(), &path, &ids); + for (_, pane) in self.panes.iter_mut() { + pane.jumps.branched_out(old_uid, new_uid, &path, &moved); + } + // It learns about the portal here, then the pane goes through it. + self.sync_backlinks(here); + let went = self.follow_portal(portal); + + let name = path.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default(); + let mut text = format!("moved {} to {name} · Ctrl-o: back", action::nodes(count)); + if cut > 0 { + let links = if cut == 1 { "1 link".to_string() } else { format!("{cut} links") }; + text.push_str(&format!(" · {links} to the rest of the document went")); + } + Message::joined([said, went].into_iter().flatten().chain([Message::done(text)]).collect()) + } +} + +/// The document a branch makes: its top node the root, at the canvas's +/// middle, every node keeping its id and its place under the others; the +/// links among them; and its portals leading where they did, from the new +/// file's folder. Also how many links joined the branch to the rest. +fn carve(doc: &Document, top: NodeId, from: &Path, to: &Path) -> Option<(Document, usize)> { + let ids = doc.branch(top); + let inside: BTreeSet = ids.iter().copied().collect(); + let origin = doc.node(top)?.pos; + let to_folder = folder_of(to); + let records: Vec = ids + .iter() + .filter_map(|&id| { + let mut node = doc.node(id)?.clone(); + node.pos = crate::model::CanvasPos::new(node.pos.x - origin.x, node.pos.y - origin.y); + if let Content::Portal(portal) = &mut node.content + && let Some(path) = relative(&to_folder, &resolve(from, &portal.path)) + { + portal.path = path; + } + let (parent, branch) = match doc.place(id) { + Some(Place::Child { parent, branch }) if id != top => (Some(*parent), branch.clone()), + _ => (None, Default::default()), + }; + Some(NodeRecord { id, parent, branch, node }) + }) + .collect(); + let touching = |link: &&Link| inside.contains(&link.start()) || inside.contains(&link.end()); + let within = |link: &&Link| inside.contains(&link.start()) && inside.contains(&link.end()); + let links: Vec = doc.links().filter(within).cloned().collect(); + let cut = doc.links().filter(touching).count() - links.len(); + let branch = Document::from_records(DocUid::random(), top, records, links, Vec::new()).ok()?; + Some((branch, cut)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::test_support::{garden, portal}; + + #[test] + fn a_branch_makes_a_document_of_its_own() { + let (mut doc, g) = garden(); + portal(&mut doc, g.beds); + doc.apply(&EditOp::Link(Link::new(g.cedar, g.soil).unwrap())).unwrap(); + let folder = std::env::temp_dir().join("documents"); + let (branch, cut) = carve(&doc, g.beds, &folder, &folder.join("rooms").join("beds.cum")).unwrap(); + branch.assert_rules(); + assert_eq!((branch.root(), branch.title().as_str()), (g.beds, "Raised beds")); + assert_eq!( + branch.node(g.beds).unwrap().pos, + crate::model::CanvasPos::new(0, 0), + "the root in the middle" + ); + assert_eq!(branch.children(g.beds).len(), 2, "Cedar and the portal"); + assert_eq!(cut, 1, "the link from Cedar to Soil can't follow"); + let (_, moved) = branch.portals().next().unwrap(); + assert_eq!(moved.path.as_str(), "../kitchen.cum", "the portal still leads to the kitchen"); + } +} diff --git a/src/app/calls.rs b/src/app/calls.rs index 7ef1c8a..f8505ff 100644 --- a/src/app/calls.rs +++ b/src/app/calls.rs @@ -83,7 +83,8 @@ pub fn actions_for(call: Call, ctx: &ViewContext<'_>, on: &[NodeId]) -> Vec app(AppAction::OpenConfig), 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"), + Call::BranchOut(path) => app(AppAction::BranchOut(path)), + Call::Export { .. } => later("this one comes in v1.1"), } } diff --git a/src/app/commands.rs b/src/app/commands.rs index 6dc8987..8bfebd1 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -51,6 +51,7 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option> { Command::GoUp => app(AppAction::GoUp { list: false }), // `p`: a portal, to a file typed in `:portal`. Command::NewPortal => app(AppAction::CommandLineWith("portal ".into())), + Command::BranchOut => app(AppAction::BranchOut(None)), Command::EditTitle | Command::Activate => edit(EditAt::End), Command::EditTitleAtStart => edit(EditAt::Start), Command::RetypeTitle => edit(EditAt::Retype), diff --git a/src/app/files.rs b/src/app/files.rs index 994ddff..5f3f04d 100644 --- a/src/app/files.rs +++ b/src/app/files.rs @@ -93,8 +93,13 @@ pub fn load(path: &Path) -> Result { /// Makes a new document at `path` and writes it right away (`cumulus new`, /// `:new`). pub fn create(path: &Path, title: Title) -> Result { + write_new(path, Document::new(title)) +} + +/// Writes a document that's new to the file system at `path` (a new one, +/// or a branch moved out of another, `:branchout`). +pub fn write_new(path: &Path, doc: Document) -> Result { let path = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()); - let doc = Document::new(title); let write = |error: io::Error| OpenError::Write { path: path.clone(), error }; let text = document_file::write(&doc).map_err(|error| write(io::Error::other(error)))?; write_atomically(&path, &text).map_err(write)?; @@ -722,7 +727,7 @@ impl App { /// A free file name in `folder` for `wanted` (`garden-plans.cum`): itself, /// or with `-2`, `-3`… before `.cum`. -fn free_path(folder: &Path, wanted: &str) -> Option { +pub(super) fn free_path(folder: &Path, wanted: &str) -> Option { let stem = wanted.strip_suffix(&format!(".{}", names::EXTENSION)).unwrap_or(wanted); (1..1000) .map(|n| if n == 1 { wanted.to_string() } else { format!("{stem}-{n}.{}", names::EXTENSION) }) diff --git a/src/app/jumps.rs b/src/app/jumps.rs index 459b114..d9888e4 100644 --- a/src/app/jumps.rs +++ b/src/app/jumps.rs @@ -4,6 +4,7 @@ //! Each pane has its own; a new pane starts with a copy of its first //! pane's. A place in another document opens it, even after it was closed. +use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use super::action::{DocAction, Message}; @@ -73,6 +74,15 @@ impl Jumps { } } + /// Nodes moved to another document (`:branchout`), keeping their ids: + /// the places on them follow. + pub fn branched_out(&mut self, from: DocUid, to: DocUid, path: &Path, nodes: &BTreeSet) { + for place in self.places.iter_mut().filter(|place| place.uid == from && nodes.contains(&place.node)) { + place.uid = to; + place.path = path.to_path_buf(); + } + } + /// The places, oldest first, and which one `Ctrl-o` and `Ctrl-i` are on /// (none until they're used), for `:jumps`. pub fn list(&self) -> (&[Place], Option) { diff --git a/src/app/mod.rs b/src/app/mod.rs index d38044e..d4071e5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -19,6 +19,7 @@ //! what `cumulus` opens, and [`run`] is the main loop on a real terminal. pub mod action; +mod branch_out; pub mod buffer; pub mod calls; mod clipboard; diff --git a/src/app/popup.rs b/src/app/popup.rs index e426221..d8b6ddc 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -890,6 +890,7 @@ const NODE_MENU: &[(&str, Command)] = &[ ("style…", Command::PickStyle), ("tags…", Command::EditTags), ("move it", Command::MoveMode), + ("move it to a document of its own", Command::BranchOut), ("open it in the Note view", Command::ShowNote), ("delete just this node", Command::DeleteNode), ("delete it with its branch", Command::Operate(Operator::Delete, Target::Current)), diff --git a/src/app/tests.rs b/src/app/tests.rs index 0287bdb..30ea9b4 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -4,6 +4,7 @@ //! `tests/files.rs`, and the config folder in `tests/config.rs`. mod arrange; +mod branch_out; mod config; mod connect; mod files; @@ -506,7 +507,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(":branchout"); + h.type_keys(":export md"); 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/branch_out.rs b/src/app/tests/branch_out.rs new file mode 100644 index 0000000..cbd7a62 --- /dev/null +++ b/src/app/tests/branch_out.rs @@ -0,0 +1,85 @@ +//! Branching out (part 9b), end to end: a branch moves into a document of +//! its own, a portal takes its place, and the pane goes there. + +use super::*; + +#[test] +fn space_o_moves_a_branch_into_a_document_of_its_own() { + let mut h = Harness::new(); + // Marks on Cedar, which is about to move. + h.type_keys("glgjglmAmaghi"); + assert_eq!(h.cursor(), "Raised beds"); + h.type_keys("o"); + assert_eq!(h.message(), "moved 3 nodes to raised-beds.cum · Ctrl-o: back"); + assert_eq!((h.doc().title().as_str(), h.cursor().as_str()), ("Raised beds", "Raised beds")); + assert_eq!(h.children("Raised beds"), ["Cedar, untreated", "40 cm high"]); + let color = h.doc().node(h.node("Raised beds")).unwrap().look.color.clone(); + assert_eq!( + color.map(|color| color.as_str().to_string()).as_deref(), + Some("orange"), + "its look came along" + ); + h.type_keys("gl"); + let rows = h.rows(); + let crumbs = "Garden plans » Raised beds › Cedar, untreated"; + assert!(rows[rows.len() - 2].contains(crumbs), "as through the portal:\n{}", h.screen()); + assert!(h.file("raised-beds.cum").contains("from: \"garden.cum\""), "it knows the portal"); + // The marks on Cedar followed it. + h.type_keys("gg'a"); + assert_eq!(h.cursor(), "Cedar, untreated"); + h.type_keys("gg'A"); + assert_eq!(h.cursor(), "Cedar, untreated"); + // Ctrl-o: back to the portal that took the branch's place. + h.type_keys("gg"); + assert_eq!(h.doc().title().as_str(), "Garden plans"); + assert_eq!(h.children("Garden plans"), ["Soil", "Raised beds", "Watering", "Kitchen"]); + assert!(h.doc().node(h.node("Raised beds")).unwrap().is_portal()); + assert!(h.screen().contains("» Raised beds"), "{}", h.screen()); + // u brings the branch back; the new file stays. + h.type_keys("u"); + assert_eq!(h.children("Raised beds"), ["Cedar, untreated", "40 cm high"]); + assert!(h.path("raised-beds.cum").exists()); +} + +#[test] +fn links_across_go_and_portals_inside_move_along() { + let mut h = Harness::new(); + // Seeds to order is linked to "ask about compost", outside its branch. + h.type_keys(":find seedso"); + assert_eq!( + h.message(), + "moved 3 nodes to seeds-to-order.cum · Ctrl-o: back · 1 link to the rest of the document went" + ); + // The kitchen portal under Watering, then Watering into another folder. + h.type_keys(""); + assert_eq!(h.doc().title().as_str(), "Garden plans"); + h.type_keys("ggglgjgjgj"); + assert_eq!(h.cursor(), "Kitchen"); + h.type_keys("Cgk>"); + assert_eq!(h.parent("Kitchen"), "Watering"); + h.type_keys("gh:branchout rooms/water.cum"); + assert_eq!(h.doc().title().as_str(), "Watering"); + let path = h.doc().portals().next().map(|(_, portal)| portal.path.as_str().to_string()); + assert_eq!(path.as_deref(), Some("../kitchen.cum"), "it still leads to the kitchen"); + h.wait(1); + let kitchen = h.file("kitchen.cum"); + assert!(kitchen.contains("from: \"rooms/water.cum\"") && !kitchen.contains("garden.cum"), "{kitchen}"); + // Through it, to the kitchen. + h.type_keys(":find kitchen"); + assert_eq!(h.doc().title().as_str(), "Kitchen"); +} + +#[test] +fn what_branch_out_says_no_to() { + let mut h = Harness::new(); + h.type_keys("o"); + assert_eq!(h.message(), "the root is the whole document: :branchout takes a branch under it"); + h.type_keys("glgjgjgjo"); + assert_eq!(h.message(), "this is a portal: its document is one of its own already"); + h.type_keys("gk:branchout kitchen"); + assert_eq!(h.message(), "kitchen.cum is there already: :branchout makes a new document"); + // The right-click menu has it too. + let watering = h.find("Watering"); + h.right_click(watering); + assert!(h.screen().contains("move it to a document of its own"), "{}", h.screen()); +} diff --git a/src/app/update.rs b/src/app/update.rs index 979b31d..41deb1e 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -125,6 +125,7 @@ impl App { AppAction::FollowPortal(id) => return self.follow_portal(id), AppAction::NewPortal(path) => return self.new_portal(&path), AppAction::GoUp { list } => return self.go_up(list), + AppAction::BranchOut(path) => return self.branch_out(path), AppAction::ToggleWhole => { if let Mode::Move(grab) = &mut self.shared.mode { grab.whole = !grab.whole; diff --git a/src/command/parse.rs b/src/command/parse.rs index 2b013d4..3137e1e 100644 --- a/src/command/parse.rs +++ b/src/command/parse.rs @@ -84,7 +84,9 @@ pub enum Call { Link(NodeId), Unlink(Option), Portal(String), - BranchOut, + /// A branch into a document of its own, at this path (or next to this + /// one, named after it). + BranchOut(Option), Tags(Vec), /// A palette color, or `None` to clear it. Color(Option), @@ -244,7 +246,7 @@ fn build(spec: &Spec, bang: bool, args: &[Option<&str>], sources: &Sources) -> R Id::Link => Call::Link(node(text, &sources.nodes)?), Id::Unlink => Call::Unlink(first.map(|query| node(query, &sources.nodes)).transpose()?), Id::Portal => Call::Portal(text.to_string()), - Id::BranchOut => Call::BranchOut, + Id::BranchOut => Call::BranchOut(first.map(str::to_string)), Id::Tags => Call::Tags(tag_changes(text)?), Id::Color => Call::Color(color(text, &sources.colors)?), Id::Style => Call::Style(style(text, &sources.styles)?), diff --git a/src/command/registry.rs b/src/command/registry.rs index 204bbf6..1eb3b3b 100644 --- a/src/command/registry.rs +++ b/src/command/registry.rs @@ -284,7 +284,13 @@ pub const SPECS: &[Spec] = &[ spec(Id::Link, "link", "li", &[required("node", K::Node)], "link this node to another one"), spec(Id::Unlink, "unlink", "unl", &[optional("node", K::Node)], "remove a link (or all of this node's)"), spec(Id::Portal, "portal", "por", &[required("file", K::File)], "add a portal to another document"), - spec(Id::BranchOut, "branchout", "bran", &[], "move this branch into a document of its own"), + spec( + Id::BranchOut, + "branchout", + "bran", + &[optional("file", K::File)], + "move this branch into a document of its own", + ), with_aliases( &["tags"], spec(Id::Tags, "tag", "ta", &[required("tags", K::Tags)], "add and remove tags: +idea -todo"), diff --git a/src/input/bindings.rs b/src/input/bindings.rs index 6da10e1..8906382 100644 --- a/src/input/bindings.rs +++ b/src/input/bindings.rs @@ -237,6 +237,7 @@ const NORMAL: &[DefaultBinding] = &[ bind("B", C::PickBorder), bind("t", C::EditTags), bind("p", C::NewPortal), + bind("o", C::BranchOut), bind("=m", C::Tidy(Layout::Map)), bind("=c", C::Tidy(Layout::Chart)), bind("=g", C::Tidy(Layout::Grid)), diff --git a/src/input/command.rs b/src/input/command.rs index a0ecfdf..8633067 100644 --- a/src/input/command.rs +++ b/src/input/command.rs @@ -309,6 +309,8 @@ pub enum Command { PickBorder, EditTags, NewPortal, + /// `o`: the cursor's branch into a document of its own. + BranchOut, Capture, // Modes. @@ -520,6 +522,7 @@ pub const COMMANDS: &[CommandInfo] = &[ info("pick-border", C::PickBorder, "border…"), info("edit-tags", C::EditTags, "tags…"), info("new-portal", C::NewPortal, "new portal…"), + info("branch-out", C::BranchOut, "this branch out, to a document of its own"), info("capture", C::Capture, "capture an idea to the inbox…"), // Modes. info("visual", C::Visual, "select"), diff --git a/src/model/op.rs b/src/model/op.rs index 625a5bd..ca31dc2 100644 --- a/src/model/op.rs +++ b/src/model/op.rs @@ -5,7 +5,7 @@ use std::collections::BTreeSet; use super::clip::Clip; -use super::document::Document; +use super::document::{Document, Place}; use super::edit::{Change, Edit, EditError, Spot}; use super::geometry::{CanvasOffset, CanvasPos}; use super::ids::NodeId; @@ -97,6 +97,13 @@ pub enum EditOp { key: LinkKey, label: Option