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]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-10-02 08:24:43 -04:00
1 parent bd819409f4
commit ebe8a5988e
45 files changed
+2329 -148

No files matched your search

+328
View File
@@ -0,0 +1,328 @@
//! 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)]
}