Files
cumulus/src/app/connect.rs
T
skkeyeandClaude Opus 5.5 ebe8a5988e Add connect mode, portals, backlinks and renames (stage 8d, v1)
Part 8d, the last of stage 8, and with it everything on DESIGN.md's v1
list.

Connect mode (C): the cursor goes to another node, a dashed arrow in the
visual color follows it, and Enter links the two; then the cursor goes
back to where C was typed (the user's pick). d removes the line between
them (a branch is cut, and the child comes loose), > puts the node under
the other one, and <Space> a, l, c, s and t style the line as you watch,
making it when it isn't there yet. Everything connect mode changes is one
undo group, and Esc takes it back with History::cancel, which leaves
nothing to redo. From visual mode every selected node connects, and the
line pickers go back to connect mode when they close.

Portals show their document live, from a cache of what each document
file holds (open documents from their buffers, the others from their
files, looked at every second): the root's title, its color and how many
nodes it has, or ? when the document isn't there. Enter or gf goes
through, as a jump, and the pane's breadcrumb starts with the documents
it came through. <Space>p types :portal (the user's pick): a path as :e
takes it, or the title of a new document. Backlinks are put right
whenever a document is opened or saved; the root shows how many, the
Note view lists them, gu goes up to them (the user's pick) and
:backlinks lists them.

File names follow the root's title: when it changed, the next save
renames the file (-2 when the name is taken; a folder's default document
keeps its name), and the portals pointing at it, its backlinks in the
documents it leads to, the recent documents, the capital marks and the
jump lists follow. A portal whose file was moved by hand finds its
document in that folder by uid. Portals in a register keep full paths,
so a pasted portal leads to the same document from any folder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 08:24:43 -04:00

329 lines
13 KiB
Rust

//! Connect mode (`C`): the cursor goes to another node, a line follows it
//! there, and Enter links the two (DESIGN.md, "Keys"). On the way, `d`
//! removes the line already there (a branch between a parent and its child
//! is cut, and the child comes loose), `>` puts the node under the other
//! one, and the `<Space>` keys style the line, branch or link, as you watch.
//! Everything connect mode changes is one undo step, which Esc takes back;
//! when it's done, the cursor goes back to where it started (the user's
//! pick in part 8d).
//!
//! From visual mode, every selected node connects to the one you go to.
use ratatui::style::Color;
use super::action::{self, Action, AppAction, DocAction, Message};
use super::mode::Connect;
use super::popup::{LinePick, PickKind};
use crate::config::theme_file::{ARROWS, STROKES};
use crate::input::command::Command;
use crate::input::engine::Typed;
use crate::model::{EditOp, Label, LineLook, LineRef, Link, NodeId, Spot};
use crate::ui::theme::LineStyle;
use crate::view::ViewContext;
/// Connect mode's answer to a command, or `None` for the ones that move the
/// cursor as usual.
pub fn command(typed: &Typed, connect: &Connect, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
let actions = match typed.command {
Command::Confirm => link(connect, ctx),
Command::RemoveLink => remove(connect, ctx),
Command::AttachAsChild => attach(connect, ctx),
Command::CycleArrows => cycle_arrows(connect, ctx),
Command::CycleLine => cycle_line(connect, ctx),
Command::PickColor => picker(connect, ctx, LinePick::Color),
Command::PickStyle => picker(connect, ctx, LinePick::Style),
Command::EditLabel => picker(connect, ctx, LinePick::Label),
Command::Escape => cancel(connect),
// The other end is in this document.
Command::FollowPortal | Command::JumpBack | Command::JumpForward | Command::GoUp => {
say(Message::info("connect mode stays in this document: Enter or Esc first"))
}
Command::SetMark(name) | Command::JumpToMark(name) if name.is_ascii_uppercase() => {
say(Message::info("connect mode stays in this document: Enter or Esc first"))
}
Command::Search => {
say(Message::info("n and N find the last search again here: / starts one in normal mode"))
}
_ => return None,
};
Some(actions)
}
/// The nodes being connected to `target`: the sources, but not the target
/// itself.
pub fn others(connect: &Connect, target: NodeId) -> Vec<NodeId> {
connect.sources.iter().copied().filter(|&id| id != target).collect()
}
/// What's being connected to what: `Raised beds → Watering`.
pub fn ends(connect: &Connect, ctx: &ViewContext<'_>) -> String {
let target = ctx.cursor;
match others(connect, target).as_slice() {
[] => format!("{} → ?", what(ctx, &connect.sources)),
sources => format!("{} → {}", what(ctx, sources), title(ctx, target)),
}
}
/// What the status bar says in connect mode, in place of the breadcrumb:
/// what's being connected to what, and what joins them already.
pub fn status(connect: &Connect, ctx: &ViewContext<'_>) -> String {
let target = ctx.cursor;
let sources = others(connect, target);
let mut text = ends(connect, ctx);
if sources.is_empty() {
text.push_str(" · go to the node to connect");
}
if let [source] = sources.as_slice() {
match ctx.doc.line_between(*source, target) {
Some(LineRef::Link(_)) => text.push_str(" · linked"),
Some(LineRef::Branch(child)) if child == target => text.push_str(" · its child"),
Some(LineRef::Branch(_)) => text.push_str(" · its parent"),
None => {}
}
}
text
}
/// The lines from each node being connected to the cursor's, changed by
/// `change`: a line that isn't there yet is made, as a link with that look.
pub fn restyle(connect: &Connect, ctx: &ViewContext<'_>, change: impl Fn(&mut LineLook)) -> Vec<Action> {
let target = ctx.cursor;
others(connect, target)
.into_iter()
.filter_map(|source| {
let op = match ctx.doc.line_between(source, target) {
Some(line) => {
let mut look = ctx.doc.line_look(line).cloned().unwrap_or_default();
change(&mut look);
EditOp::SetLine { line, look }
}
None => {
let mut link = Link::new(source, target)?;
change(&mut link.look);
EditOp::Link(link)
}
};
Some(Action::edit_in(op, connect.group))
})
.collect()
}
/// The labels of the links from each node being connected to the cursor's
/// (made if they aren't there yet). A branch has no label: it's left alone.
pub fn relabel(connect: &Connect, ctx: &ViewContext<'_>, label: &Option<Label>) -> Vec<Action> {
let target = ctx.cursor;
others(connect, target)
.into_iter()
.filter_map(|source| {
let op = match ctx.doc.line_between(source, target) {
Some(LineRef::Link(key)) => EditOp::SetLabel { key, label: label.clone() },
Some(LineRef::Branch(_)) => return None,
None => {
let mut link = Link::new(source, target)?;
link.label = label.clone();
EditOp::Link(link)
}
};
Some(Action::edit_in(op, connect.group))
})
.collect()
}
/// The line connect mode works on first: from the first node being
/// connected to the cursor's, if anything joins them.
pub fn first_line(connect: &Connect, ctx: &ViewContext<'_>) -> Option<LineRef> {
let target = ctx.cursor;
let source = *others(connect, target).first()?;
ctx.doc.line_between(source, target)
}
/// Enter: links each node to the cursor's, unless a line joins them
/// already, and connect mode is done.
fn link(connect: &Connect, ctx: &ViewContext<'_>) -> Vec<Action> {
let target = ctx.cursor;
let sources = others(connect, target);
if sources.is_empty() {
return aim_first();
}
let links: Vec<Link> = sources
.iter()
.filter(|&&source| ctx.doc.line_between(source, target).is_none())
.filter_map(|&source| Link::new(source, target))
.collect();
let mut actions: Vec<Action> =
links.iter().map(|link| Action::edit_in(EditOp::Link(link.clone()), connect.group)).collect();
actions.extend(done(connect));
if !links.is_empty() {
let from: Vec<NodeId> = links.iter().map(Link::start).collect();
let text = format!("linked {} → {}", what(ctx, &from), title(ctx, target));
actions.push(Action::say(Message::done(text)));
}
actions
}
/// `d`: removes each line between a node and the cursor's. A link goes; a
/// branch is cut, and the child comes loose with its own branch.
fn remove(connect: &Connect, ctx: &ViewContext<'_>) -> Vec<Action> {
let target = ctx.cursor;
let sources = others(connect, target);
if sources.is_empty() {
return aim_first();
}
let (mut ops, mut loose) = (Vec::new(), Vec::new());
for &source in &sources {
match ctx.doc.line_between(source, target) {
Some(LineRef::Link(key)) => ops.push(EditOp::Unlink(vec![key])),
Some(LineRef::Branch(child)) => {
ops.push(EditOp::MoveTo { id: child, spot: Spot::Loose });
loose.push(child);
}
None => {}
}
}
let text = match (ops.len(), loose.as_slice()) {
(0, _) => {
let text =
format!("nothing joins {} and {}: Enter links them", what(ctx, &sources), title(ctx, target));
return say(Message::info(text));
}
(1, [child]) => format!("cut the branch: {} is a loose node now", title(ctx, *child)),
(1, _) => format!("unlinked {} and {}", what(ctx, &sources), title(ctx, target)),
(count, _) => format!("removed {count} lines"),
};
let mut actions: Vec<Action> = ops.into_iter().map(|op| Action::edit_in(op, connect.group)).collect();
actions.extend(done(connect));
actions.push(Action::say(Message::done(text)));
actions
}
/// `>`: puts each node under the cursor's, as its last child, with its
/// branch. It stays where it is on the canvas, and a link between the two
/// goes: the branch takes its place.
fn attach(connect: &Connect, ctx: &ViewContext<'_>) -> Vec<Action> {
let (doc, target) = (ctx.doc, ctx.cursor);
let sources = others(connect, target);
if sources.is_empty() {
return aim_first();
}
if sources.contains(&doc.root()) {
return say(Message::problem(
"the root stays at the top: C on the other node puts that one under it",
));
}
if let Some(&holder) = sources.iter().find(|&&source| doc.is_inside(target, source)) {
let text = format!(
"{} is in {}'s branch, and a branch can't go inside itself",
title(ctx, target),
title(ctx, holder)
);
return say(Message::problem(text));
}
let moving: Vec<NodeId> =
sources.into_iter().filter(|&source| doc.parent(source) != Some(target)).collect();
if moving.is_empty() {
return say(Message::info(format!("it's under {} already", title(ctx, target))));
}
let mut actions = Vec::new();
for &source in &moving {
if let Some(LineRef::Link(key)) = doc.line_between(source, target) {
actions.push(Action::edit_in(EditOp::Unlink(vec![key]), connect.group));
}
let op = EditOp::MoveTo { id: source, spot: Spot::LastChildOf(target) };
actions.push(Action::edit_in(op, connect.group));
}
actions.extend(done(connect));
let verb = if moving.len() == 1 { "is" } else { "are" };
let text = format!("{} {verb} under {} now", what(ctx, &moving), title(ctx, target));
actions.push(Action::say(Message::done(text)));
actions
}
/// `<Space>a`: the next arrowheads (off, end, start, both), after the ones
/// the first line has now, on every line.
fn cycle_arrows(connect: &Connect, ctx: &ViewContext<'_>) -> Vec<Action> {
if others(connect, ctx.cursor).is_empty() {
return aim_first();
}
let now = drawn(ctx, first_line(connect, ctx)).arrows;
let at = ARROWS.iter().position(|&(_, arrows)| arrows == now).map_or(0, |at| (at + 1) % ARROWS.len());
let (name, next) = ARROWS[at];
let mut actions = restyle(connect, ctx, |look| look.arrows = Some(next));
actions.push(Action::say(Message::info(format!("arrowheads: {name}"))));
actions
}
/// `<Space>l`: the next kind of line (solid, dashed, dotted, thick,
/// double), after the one the first line has now, on every line.
fn cycle_line(connect: &Connect, ctx: &ViewContext<'_>) -> Vec<Action> {
if others(connect, ctx.cursor).is_empty() {
return aim_first();
}
let now = drawn(ctx, first_line(connect, ctx)).stroke;
let at = STROKES.iter().position(|&(_, stroke)| stroke == now).map_or(0, |at| (at + 1) % STROKES.len());
let (name, next) = STROKES[at];
let mut actions = restyle(connect, ctx, |look| look.line = Some(next));
actions.push(Action::say(Message::info(format!("line: {name}"))));
actions
}
/// How a line is drawn now (one that isn't there yet: as a new link would
/// be).
fn drawn(ctx: &ViewContext<'_>, line: Option<LineRef>) -> LineStyle {
let (theme, default) = (ctx.theme, LineLook::default());
let look = line.and_then(|line| ctx.doc.line_look(line)).unwrap_or(&default);
match line {
Some(LineRef::Branch(_)) => theme.branch_style(look, Color::Reset),
Some(LineRef::Link(_)) | None => theme.link_style(look, Color::Reset),
}
}
/// `<Space>c`, `<Space>s` and `<Space>t`: a picker for the line, which
/// comes back to connect mode.
fn picker(connect: &Connect, ctx: &ViewContext<'_>, what: LinePick) -> Vec<Action> {
let target = ctx.cursor;
let sources = others(connect, target);
if sources.is_empty() {
return aim_first();
}
let branches = sources
.iter()
.all(|&source| matches!(ctx.doc.line_between(source, target), Some(LineRef::Branch(_))));
if what == LinePick::Label && branches {
return say(Message::info("a branch line has no label: links do"));
}
vec![Action::App(AppAction::OpenPicker(PickKind::Line(what)))]
}
/// Esc: everything connect mode changed is taken back.
pub fn cancel(connect: &Connect) -> Vec<Action> {
let mut actions = vec![Action::Doc(DocAction::Cancel(connect.group))];
actions.extend(done(connect));
actions
}
/// Back to normal mode, with the cursor where `C` was typed.
fn done(connect: &Connect) -> Vec<Action> {
vec![Action::App(AppAction::LeaveMode), Action::cursor(connect.start)]
}
fn aim_first() -> Vec<Action> {
say(Message::info("go to the other node first (hjkl…), then Enter links them"))
}
/// A node's title, for messages.
fn title(ctx: &ViewContext<'_>, id: NodeId) -> String {
ctx.doc.node(id).map(|node| node.title().to_string()).unwrap_or_default()
}
/// Some nodes, for messages: the title of one, or how many.
fn what(ctx: &ViewContext<'_>, ids: &[NodeId]) -> String {
match ids {
[id] => title(ctx, *id),
_ => action::nodes(ids.len()),
}
}
fn say(message: Message) -> Vec<Action> {
vec![Action::say(message)]
}