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

+3
View File
@@ -19,6 +19,9 @@ pub enum LineOwner {
Branch(NodeId),
/// A cross-link.
Link(LinkKey),
/// The line connect mode shows from a node to the cursor's, before
/// Enter makes it.
Preview,
}
/// The color and owner given to the cells a line or border passes through.
+53 -8
View File
@@ -13,10 +13,11 @@ use super::boxes::{NodeBox, draft_box, truncate};
use super::camera::{Camera, ScreenPos, ScreenRect, Zoom};
use super::lines::{LineLayer, LineOwner, Paint};
use super::route::{Bend, route};
use crate::app::connect;
use crate::app::mode::Mode;
use crate::app::text_edit::TextEdit;
use crate::app::visual::selection;
use crate::model::{Arrows, Border, Content, Document, Node, NodeId, Place, Visible};
use crate::model::{Arrows, Border, Content, Document, LineRef, Node, NodeId, Place, Stroke, Visible};
use crate::ui::glyphs::Glyphs;
use crate::ui::text_editor::layout;
use crate::ui::theme::{LineStyle, Theme};
@@ -108,8 +109,10 @@ impl Scene {
let visible = Visible::new(doc, cursor, settings.focus);
let picked: BTreeSet<NodeId> = match ctx.mode {
Mode::Visual(visual) => selection(visual, ctx, ViewKind::Canvas).into_iter().collect(),
Mode::Connect(connecting) => connect::others(connecting, cursor).into_iter().collect(),
_ => BTreeSet::new(),
};
let aim = Aim::new(ctx);
let mut scene = Scene {
size,
zoom: camera.zoom,
@@ -124,8 +127,11 @@ impl Scene {
};
for &id in &visible.order {
let Some(node) = doc.node(id) else { continue };
let style = theme.node_style(node, id == doc.root());
let badge = visible.hidden.get(&id).map(|count| format!("+{count}")).unwrap_or_default();
let style = ctx.node_style(id, node);
let mut badge = ctx.badge(id, node).unwrap_or_default();
if let Some(count) = visible.hidden.get(&id) {
badge = if badge.is_empty() { format!("+{count}") } else { format!("{badge} +{count}") };
}
let max_width = settings.max_node_width;
let (rect, shape) = match edit.filter(|edit| edit.node == id && camera.zoom == Zoom::Normal) {
Some(edit) => scene.draft(node, edit, badge, style.border, camera, max_width),
@@ -143,7 +149,7 @@ impl Scene {
scene.index = scene.nodes.iter().enumerate().map(|(index, node)| (node.id, index)).collect();
scene.cover();
scene.add_borders();
scene.add_lines(doc, theme);
scene.add_lines(doc, theme, &aim);
scene
}
@@ -248,13 +254,18 @@ impl Scene {
}
/// Adds every branch (from a shown parent to a shown child) and every
/// cross-link between shown nodes.
fn add_lines(&mut self, doc: &Document, theme: &Theme) {
/// cross-link between shown nodes. In connect mode, the lines aimed at
/// stand out in the visual color, and a dashed arrow runs to the cursor
/// from each node with no line to it yet.
fn add_lines(&mut self, doc: &Document, theme: &Theme, aim: &Aim) {
for child_index in 0..self.nodes.len() {
let child = &self.nodes[child_index];
let Some(Place::Child { parent, branch }) = doc.place(child.id) else { continue };
let Some(&parent_index) = self.index.get(parent) else { continue };
let style = theme.branch_style(branch, self.nodes[parent_index].color);
let mut style = theme.branch_style(branch, self.nodes[parent_index].color);
if aim.lines.contains(&LineRef::Branch(child.id)) {
style.color = self.visual;
}
let owner = LineOwner::Branch(child.id);
self.connect(parent_index, child_index, Bend::NearStart, style, owner, None);
}
@@ -263,10 +274,20 @@ impl Scene {
else {
continue;
};
let style = theme.link_style(&link.look, self.nodes[start].color);
let mut style = theme.link_style(&link.look, self.nodes[start].color);
if aim.lines.contains(&LineRef::Link(link.key())) {
style.color = self.visual;
}
let label = theme.link_label(link);
self.connect(start, end, Bend::Halfway, style, LineOwner::Link(link.key()), label);
}
for &(source, target) in &aim.previews {
let (Some(&start), Some(&end)) = (self.index.get(&source), self.index.get(&target)) else {
continue;
};
let style = LineStyle { stroke: Stroke::Dashed, arrows: Arrows::End, color: self.visual };
self.connect(start, end, Bend::Halfway, style, LineOwner::Preview, None);
}
}
/// Draws one line from node `from` to node `to` (indexes in `nodes`),
@@ -336,6 +357,30 @@ impl Scene {
}
}
/// What connect mode aims at: the lines already there between the nodes
/// being connected and the cursor's, and the pairs with no line yet.
#[derive(Debug, Default)]
struct Aim {
lines: BTreeSet<LineRef>,
previews: Vec<(NodeId, NodeId)>,
}
impl Aim {
fn new(ctx: &ViewContext<'_>) -> Aim {
let mut aim = Aim::default();
let Mode::Connect(connecting) = ctx.mode else { return aim };
for source in connect::others(connecting, ctx.cursor) {
match ctx.doc.line_between(source, ctx.cursor) {
Some(line) => {
aim.lines.insert(line);
}
None => aim.previews.push((source, ctx.cursor)),
}
}
aim
}
}
/// The index of the topmost node on a cell, in a `covered` grid.
fn covered_at(covered: &[Option<usize>], size: Size, at: ScreenPos) -> Option<usize> {
let (width, height) = (i32::from(size.width), i32::from(size.height));
+8 -6
View File
@@ -75,10 +75,11 @@ impl CanvasView {
Scene::build(ctx, self.camera, self.area.as_size())
}
/// A left press: on a node, selects it and starts dragging its branch;
/// on empty space, starts panning.
fn press(&mut self, at: ScreenPos) -> Vec<Action> {
/// A left press: on a node, selects it and starts dragging its branch
/// (in connect mode, just aims at it); on empty space, starts panning.
fn press(&mut self, at: ScreenPos, ctx: &ViewContext<'_>) -> Vec<Action> {
match self.scene.node_at(at) {
Some(id) if matches!(ctx.mode, Mode::Connect(_)) => vec![Action::cursor(id)],
Some(id) => {
let moved = CanvasOffset::default();
self.drag = Some(Drag::Node { id, from: at, moved, group: Group::new() });
@@ -264,6 +265,7 @@ impl View for CanvasView {
mode,
&[Context::Canvas, Context::Normal, Context::Motions],
&[Context::Visual, Context::Canvas, Context::Motions],
&[Context::Connect, Context::Canvas, Context::Motions],
)
}
@@ -311,7 +313,7 @@ impl View for CanvasView {
Mode::Move(grab) => return self.move_command(typed, grab, ctx),
// Keys inside a title are the edit's, not the view's.
Mode::Text(_) | Mode::Popup(_) => return None,
Mode::Normal | Mode::Visual(_) | Mode::Command(_) => {}
Mode::Normal | Mode::Visual(_) | Mode::Connect(_) | Mode::Command(_) => {}
}
let steps = cells(typed);
let half_page = i32::from(self.area.height / 2);
@@ -348,7 +350,7 @@ impl View for CanvasView {
}
Gesture::Press { at, .. } => {
let at = self.screen(at);
let Mode::Text(edit) = ctx.mode else { return self.press(at) };
let Mode::Text(edit) = ctx.mode else { return self.press(at, ctx) };
// While a title is edited, a click in it moves the text
// cursor; a click anywhere else keeps the edit, finishes it,
// and does what a click does.
@@ -356,7 +358,7 @@ impl View for CanvasView {
return vec![Action::App(AppAction::TextCursor(offset))];
}
let mut actions = edit.finish(ctx);
actions.extend(self.press(at));
actions.extend(self.press(at, ctx));
actions
}
Gesture::DoublePress { at } => {
+75 -6
View File
@@ -15,17 +15,22 @@ pub mod note;
pub mod outline;
use std::collections::BTreeMap;
use std::path::Path;
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::Color;
use crate::app::action::Action;
use crate::app::mode::Mode;
use crate::app::portals::{Portals, Shown};
use crate::input::bindings::Context;
use crate::input::engine::Typed;
use crate::input::mouse::Gesture;
use crate::model::{CanvasRect, CanvasSize, Document, Node, NodeId};
use crate::ui::theme::Theme;
use crate::model::{CanvasRect, CanvasSize, Content, Document, LinkPath, Node, NodeId, Portal};
use crate::storage::link_paths::resolve;
use crate::storage::names::title_from_file;
use crate::ui::theme::{NodeStyle, Theme};
use canvas::Camera;
use canvas::boxes::NodeBox;
@@ -80,15 +85,68 @@ pub struct ViewContext<'a> {
pub mode: &'a Mode,
pub theme: &'a Theme,
pub settings: &'a ViewSettings,
/// What the documents portals lead to look like.
pub portals: &'a Portals,
/// The folder of the document's file: portal paths start there.
pub folder: &'a Path,
}
impl ViewContext<'_> {
impl<'a> ViewContext<'a> {
/// Every node's box on the canvas, at Normal zoom. Every view needs
/// these to place new nodes, since every node has a spot on the canvas.
pub fn box_rects(&self) -> BTreeMap<NodeId, CanvasRect> {
canvas::boxes::box_rects(self.doc, self.settings.max_node_width, self.theme.glyphs)
}
/// How a node is drawn, worked out by the theme. A portal without a
/// color of its own takes its document's, and fades when its document
/// isn't there.
pub fn node_style(&self, id: NodeId, node: &Node) -> NodeStyle {
let mut style = self.theme.node_style(node, id == self.doc.root());
if let Content::Portal(portal) = &node.content {
match self.portal(portal) {
Shown::Live(seen) if style.color == Color::Reset => {
style.color = self.theme.node_style(&seen.root, true).color;
}
Shown::Missing | Shown::Other(_) => style.faded = true,
Shown::Live(_) | Shown::Unknown => {}
}
}
style
}
/// What a node's box says in its bottom right corner, besides what a
/// fold hides: on the root, how many portals lead to the document (`«
/// 2`); on a portal, how many nodes its document has (`?` when it isn't
/// there).
pub fn badge(&self, id: NodeId, node: &Node) -> Option<String> {
if let Content::Portal(portal) = &node.content {
return match self.portal(portal) {
Shown::Live(seen) => Some(seen.count.to_string()),
Shown::Missing | Shown::Other(_) => Some("?".to_string()),
Shown::Unknown => None,
};
}
let backlinks = self.doc.backlinks().len();
(id == self.doc.root() && backlinks > 0)
.then(|| format!("{} {backlinks}", self.theme.glyphs.backlink()))
}
/// What a portal of this document shows of its own document.
pub fn portal(&self, portal: &Portal) -> Shown<'a> {
self.portals.shown(self.folder, portal)
}
/// The name of the document at a path a backlink keeps (relative to
/// this document's folder): its title when it's been seen, or one made
/// from its file name.
pub fn document_named(&self, path: &LinkPath) -> String {
let path = resolve(self.folder, path);
self.portals
.seen(&path)
.map_or_else(|| title_from_file(&path).to_string(), |seen| seen.title().to_string())
}
/// The size a node's box has on the canvas.
pub fn box_size(&self, node: &Node) -> CanvasSize {
NodeBox::new(node, String::new(), self.settings.max_node_width, self.theme.glyphs).rect.size
@@ -96,16 +154,18 @@ impl ViewContext<'_> {
}
/// The key tables a view reads in a mode: `normal` in normal mode (its own
/// table first), `visual` in visual mode, and what every view reads in the
/// other modes.
/// table first), `visual` and `connect` in those modes, and what every view
/// reads in the other modes.
pub fn contexts_for(
mode: &Mode,
normal: &'static [Context],
visual: &'static [Context],
connect: &'static [Context],
) -> &'static [Context] {
match mode {
Mode::Normal => normal,
Mode::Visual(_) => visual,
Mode::Connect(_) => connect,
Mode::Text(edit) if edit.editor.is_typing() => &[Context::Insert],
Mode::Text(_) => &[Context::Text],
Mode::Move(_) => &[Context::Move],
@@ -170,11 +230,20 @@ pub(crate) mod test_support {
pub mode: Mode,
pub theme: Theme,
pub settings: ViewSettings,
pub portals: Portals,
}
impl Setup {
pub fn ctx<'a>(&'a self, doc: &'a Document, cursor: NodeId) -> ViewContext<'a> {
ViewContext { doc, cursor, mode: &self.mode, theme: &self.theme, settings: &self.settings }
ViewContext {
doc,
cursor,
mode: &self.mode,
theme: &self.theme,
settings: &self.settings,
portals: &self.portals,
folder: Path::new("."),
}
}
}
}
+49 -8
View File
@@ -14,7 +14,8 @@
//! ```
//!
//! It shows the cursor's node, and follows it. A note is made of parts: the
//! title, the tags, the body, each link and each child. `j` and `k` go from
//! title, the tags, the body, each link, each child, and on the root, each
//! portal that leads to the document. `j` and `k` go from
//! part to part (the bar on the left marks the one you're on), and Enter or
//! `i` edits it, or goes to that link or child. The body is edited like a
//! title, with Enter starting a new line. `h` and `l` turn the page: the
@@ -27,16 +28,19 @@ use ratatui::layout::{Position, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use crate::app::action::{Action, AppAction, Message};
use crate::app::action::{self, Action, AppAction, Message};
use crate::app::commands;
use crate::app::jumps::Place;
use crate::app::mode::Mode;
use crate::app::popup::MenuFor;
use crate::app::portals::Shown;
use crate::app::text_edit::{EditAt, Field, StartEdit, TextEdit};
use crate::input::bindings::Context;
use crate::input::command::{Command, Motion};
use crate::input::engine::Typed;
use crate::input::mouse::{Cell, Gesture};
use crate::model::{Content, Direction, NodeId};
use crate::storage::link_paths::resolve;
use crate::ui::text_editor::layout;
use crate::view::{View, ViewContext, ViewKind, contexts_for};
@@ -56,6 +60,8 @@ enum Part {
Link(usize),
/// A child, in order.
Child(usize),
/// On the root: a portal in another document that leads here.
Backlink(usize),
}
/// The Note view.
@@ -78,6 +84,8 @@ pub struct NoteView {
links: Vec<NodeId>,
/// The node's children, in order.
children: Vec<NodeId>,
/// On the root: where the portals that lead here are.
backlinks: Vec<Place>,
/// The part being edited, as drawn.
editing: Option<Editing>,
/// A preview (the search's): no bar marks a part.
@@ -131,7 +139,7 @@ impl NoteView {
let muted = Style::new().fg(theme.muted);
// The title, in the node's color.
let color = theme.node_style(node, id == doc.root()).color;
let color = ctx.node_style(id, node).color;
let title_style = Style::new().fg(color).add_modifier(Modifier::BOLD);
match (draft(Field::Title), &node.content) {
(Some(edit), _) => self.add_draft(Part::Title, edit, title_style),
@@ -139,8 +147,16 @@ impl NoteView {
(None, Content::Portal(portal)) => {
let title = format!("{} {}", glyphs.portal(), portal.title);
self.add_text(Part::Title, &title, title_style);
let path = format!("a portal to {}", portal.path.as_str());
self.add_row(Some(Part::Title), vec![Span::styled(path, muted)]);
let path = portal.path.as_str();
let about = match ctx.portal(portal) {
Shown::Live(seen) => {
format!("a portal to {path} · {} · Enter goes there", action::nodes(seen.count))
}
Shown::Missing => format!("a portal to {path}, which isn't there"),
Shown::Other(seen) => format!("a portal to {path}, which holds \"{}\" now", seen.title()),
Shown::Unknown => format!("a portal to {path}"),
};
self.add_row(Some(Part::Title), vec![Span::styled(about, muted)]);
}
}
@@ -215,6 +231,23 @@ impl NoteView {
vec![Span::raw(format!("{} {title}", glyphs.child_mark()))],
);
}
// On the root: the portals that lead to the document.
self.backlinks.clear();
if id == doc.root() && !doc.backlinks().is_empty() {
self.add_row(None, Vec::new());
self.add_row(None, vec![Span::styled("Portals here", muted)]);
}
for (index, backlink) in doc.backlinks().iter().enumerate().filter(|_| id == doc.root()) {
let title = ctx.document_named(&backlink.from);
let spans = vec![
Span::raw(format!("{} {title}", glyphs.backlink())),
Span::styled(format!(" {}", backlink.from.as_str()), muted),
];
self.add_row(Some(Part::Backlink(index)), spans);
let path = resolve(ctx.folder, &backlink.from);
self.backlinks.push(Place { uid: backlink.uid, path, node: backlink.node });
}
}
fn add_row(&mut self, part: Option<Part>, spans: Vec<Span<'static>>) {
@@ -299,7 +332,7 @@ impl NoteView {
Part::Title => return None,
Part::Tags => StartEdit::Tags(at),
Part::Body => StartEdit::Body { at, in_card: false },
Part::Link(_) | Part::Child(_) if at != EditAt::End => {
Part::Link(_) | Part::Child(_) | Part::Backlink(_) if at != EditAt::End => {
let text = "Enter goes there: the title, the tags and the body are the parts you type in";
return Some(vec![Action::say(Message::info(text))]);
}
@@ -310,6 +343,13 @@ impl NoteView {
Part::Child(index) => {
return Some(self.children.get(index).map(|&to| Action::cursor(to)).into_iter().collect());
}
// Up to the portal, in its document: a jump.
Part::Backlink(index) => {
let place = self.backlinks.get(index).cloned();
return Some(
place.map(|place| Action::App(AppAction::GoToPlace(place))).into_iter().collect(),
);
}
};
Some(vec![Action::App(AppAction::StartEdit(start))])
}
@@ -364,6 +404,7 @@ impl View for NoteView {
mode,
&[Context::Note, Context::Normal, Context::Motions],
&[Context::Visual, Context::Motions],
&[Context::Connect, Context::Motions],
)
}
@@ -419,7 +460,7 @@ impl View for NoteView {
fn command(&mut self, typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
match ctx.mode {
Mode::Move(grab) => return commands::reorder(typed, grab, ctx),
Mode::Normal | Mode::Visual(_) => {}
Mode::Normal | Mode::Visual(_) | Mode::Connect(_) => {}
Mode::Text(_) | Mode::Command(_) | Mode::Popup(_) => return None,
}
let times = isize::try_from(typed.times()).unwrap_or(1);
@@ -487,7 +528,7 @@ impl View for NoteView {
let start = StartEdit::Body { at: spot, in_card: false };
Some(vec![Action::App(AppAction::StartEdit(start))])
}
Part::Link(_) | Part::Child(_) => self.open(EditAt::End),
Part::Link(_) | Part::Child(_) | Part::Backlink(_) => self.open(EditAt::End),
};
actions.extend(open.unwrap_or_default());
actions
+8 -2
View File
@@ -35,6 +35,7 @@ use unicode_width::UnicodeWidthStr;
use crate::app::action::{Action, AppAction, Message};
use crate::app::commands;
use crate::app::connect;
use crate::app::mode::Mode;
use crate::app::popup::MenuFor;
use crate::app::text_edit::{EditAt, StartEdit, TextEdit};
@@ -124,6 +125,7 @@ impl OutlineView {
let edit = ctx.mode.title_edit();
self.picked = match ctx.mode {
Mode::Visual(visual) => selection(visual, ctx, ViewKind::Outline).into_iter().collect(),
Mode::Connect(connecting) => connect::others(connecting, ctx.cursor).into_iter().collect(),
_ => BTreeSet::new(),
};
// The color of the lines from a node to its children, as on the
@@ -173,7 +175,7 @@ impl OutlineView {
let (doc, theme, glyphs) = (ctx.doc, ctx.theme, ctx.theme.glyphs);
let Some(node) = doc.node(id) else { return };
let top_level = lines.spans.is_empty();
let style = theme.node_style(node, id == doc.root());
let style = ctx.node_style(id, node);
let color = style.color;
let has_children = !doc.children(id).is_empty();
let folded = visible.hidden.contains_key(&id);
@@ -228,6 +230,9 @@ impl OutlineView {
if has_body && !self.bodies {
lines.push(format!(" {}", glyphs.body_mark()), muted);
}
if let Some(badge) = ctx.badge(id, node) {
lines.push(format!(" {badge}"), muted);
}
if let Some(count) = visible.hidden.get(&id) {
lines.push(format!(" +{count}"), muted);
}
@@ -433,6 +438,7 @@ impl View for OutlineView {
mode,
&[Context::Outline, Context::Normal, Context::Motions],
&[Context::Visual, Context::Outline, Context::Motions],
&[Context::Connect, Context::Outline, Context::Motions],
)
}
@@ -475,7 +481,7 @@ impl View for OutlineView {
fn command(&mut self, typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
match ctx.mode {
Mode::Move(grab) => return commands::reorder(typed, grab, ctx),
Mode::Normal | Mode::Visual(_) => {}
Mode::Normal | Mode::Visual(_) | Mode::Connect(_) => {}
Mode::Text(_) | Mode::Command(_) | Mode::Popup(_) => return None,
}
let rows = isize::try_from(typed.times()).unwrap_or(1);