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]>
329 lines
13 KiB
Rust
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)]
|
|
}
|