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

+19
View File
@@ -7,6 +7,7 @@
use super::documents::BufferId;
use super::jumps::Place;
use super::mode::Connect;
use super::panes::Arrange;
use super::popup::{MenuFor, PickKind};
use super::repeat::Look;
@@ -96,6 +97,9 @@ pub enum DocAction {
SetMark(char),
/// Go to a mark; `'` and `` ` `` go back to before the last jump.
JumpToMark(char),
/// Take back the group's undo step, if it's the newest, leaving nothing
/// to redo: Esc in connect mode.
Cancel(Group),
}
/// What happens around the documents.
@@ -124,6 +128,21 @@ pub enum AppAction {
EnterMoveMode(Vec<NodeId>),
/// In move mode: the whole branch, or just the node.
ToggleWhole,
/// Connect mode (`C`): from the cursor's node, or from these (a
/// selection's).
EnterConnectMode(Vec<NodeId>),
/// Back to connect mode, from a picker it opened.
ResumeConnect(Connect),
/// Enter or `gf` on a portal: through it, to its document's root.
FollowPortal(NodeId),
/// `:portal`: a portal under the cursor's node, to the document at the
/// path typed (a new document when nothing's there).
NewPortal(String),
/// `gu`: up to the portals that lead to this document; `list`
/// (`:backlinks`) lists them even when there's just one.
GoUp {
list: bool,
},
/// Back to normal mode.
LeaveMode,
OpenCommandLine,
+18 -1
View File
@@ -4,7 +4,7 @@
use std::collections::BTreeMap;
use super::action::{self, CursorAfter, DocAction, Message};
use crate::model::{Applied, Document, History, NodeId};
use crate::model::{Applied, Backlink, Document, History, NodeId, Portal};
/// An open document, with what goes with it while it's open.
#[derive(Debug)]
@@ -83,6 +83,12 @@ impl Buffer {
Applied::Removed(_) | Applied::Added(_) | Applied::Changed => None,
}
}
DocAction::Cancel(group) => {
if self.history.cancel(group, &mut self.doc) {
self.keep_cursor();
}
None
}
DocAction::Undo(times) => self.undo_or_redo(times, true),
DocAction::Redo(times) => self.undo_or_redo(times, false),
DocAction::SetMark(name) => {
@@ -103,6 +109,17 @@ impl Buffer {
}
}
/// Puts a portal node's portal in step with its document: bookkeeping,
/// outside the undo history (see [`Document::set_portal`]).
pub fn set_portal(&mut self, id: NodeId, portal: Portal) -> bool {
self.doc.set_portal(id, portal)
}
/// Replaces the backlinks: bookkeeping, outside the undo history.
pub fn set_backlinks(&mut self, backlinks: Vec<Backlink>) {
self.doc.set_backlinks(backlinks);
}
/// Puts the document as read again from its file in place. When it's
/// the same document, that's an undo step (`u` brings back what was on
/// screen) and it returns true; another document starts a new history,
+2 -1
View File
@@ -65,7 +65,8 @@ pub fn actions_for(call: Call, ctx: &ViewContext<'_>) -> Vec<Action> {
Call::Find(text) => app(AppAction::Find(text)),
Call::Set(arg) => vec![Action::App(AppAction::Set(arg))],
Call::Version => say(Message::info(crate::version())),
Call::Portal(_) | Call::Backlinks => later("portals come in part 8d of the build"),
Call::Portal(path) => app(AppAction::NewPortal(path)),
Call::Backlinks => app(AppAction::GoUp { list: true }),
Call::Jumps => app(AppAction::OpenJumps),
Call::Marks => app(AppAction::OpenMarks),
Call::Registers => app(AppAction::OpenRegisters),
+6 -1
View File
@@ -42,8 +42,12 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
Command::JumpToMark(name) => vec![Action::Doc(DocAction::JumpToMark(name))],
Command::FollowLink => follow_link(ctx),
Command::Activate if ctx.doc.node(cursor).is_some_and(Node::is_portal) => {
say("going through a portal comes in part 8d of the build")
app(AppAction::FollowPortal(cursor))
}
Command::FollowPortal => app(AppAction::FollowPortal(cursor)),
Command::GoUp => app(AppAction::GoUp { list: false }),
// `<Space>p`: a portal, to a file typed in `:portal`.
Command::NewPortal => app(AppAction::CommandLineWith("portal ".into())),
Command::EditTitle | Command::Activate => edit(EditAt::End),
Command::EditTitleAtStart => edit(EditAt::Start),
Command::RetypeTitle => edit(EditAt::Retype),
@@ -79,6 +83,7 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
Command::OpenAllFolds => fold_all(ctx, Fold::Open),
Command::CloseAllFolds => fold_all(ctx, Fold::Closed),
Command::MoveMode => app(AppAction::EnterMoveMode(Vec::new())),
Command::ConnectMode => app(AppAction::EnterConnectMode(Vec::new())),
Command::Visual => app(AppAction::Visual { branches: false }),
Command::VisualBranches => app(AppAction::Visual { branches: true }),
Command::CommandLine => app(AppAction::OpenCommandLine),
+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)]
}
+12 -3
View File
@@ -1,11 +1,12 @@
//! The open documents (vim's buffers), each with its file: where it is, the
//! version on disk, and whether this cumulus may write it.
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::time::Instant;
use super::buffer::Buffer;
use crate::model::{DocUid, Document, StateId};
use crate::model::{DocUid, Document, StateId, Title};
use crate::storage::lock::Lock;
use crate::storage::stamp::Stamp;
use crate::view::canvas::Camera;
@@ -23,6 +24,9 @@ pub struct Open {
pub file: DocFile,
/// How a canvas last showed it: the next canvas on it starts there.
pub camera: Option<Camera>,
/// The files its portals led to when its backlinks were last put right
/// in them: one it doesn't lead to anymore has one to take back.
pub led_to: BTreeSet<PathBuf>,
}
impl Open {
@@ -68,6 +72,9 @@ pub struct DocFile {
/// Something wrong with the file. Autosave holds off until it's sorted
/// out.
pub trouble: Option<Trouble>,
/// The root's title when the file was last read or written: when the
/// title changes, the next save gives the file its new name.
pub named: Title,
}
/// Whether this cumulus may write a document.
@@ -114,14 +121,15 @@ pub struct Documents {
impl Documents {
pub fn new(buffer: Buffer, file: DocFile) -> Documents {
Documents { first: Open { id: BufferId(1), buffer, file, camera: None }, rest: Vec::new(), next: 2 }
let first = Open { id: BufferId(1), buffer, file, camera: None, led_to: BTreeSet::new() };
Documents { first, rest: Vec::new(), next: 2 }
}
/// Opens another document, last in the bar.
pub fn add(&mut self, buffer: Buffer, file: DocFile) -> BufferId {
let id = BufferId(self.next);
self.next += 1;
self.rest.push(Open { id, buffer, file, camera: None });
self.rest.push(Open { id, buffer, file, camera: None, led_to: BTreeSet::new() });
id
}
@@ -212,6 +220,7 @@ mod tests {
access: Access::ReadOnly,
changed: None,
trouble: None,
named: Title::untitled(),
}
}
+85 -10
View File
@@ -13,6 +13,7 @@
//! - A document another cumulus has open is read-only here, until that one
//! lets go of it.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
@@ -65,7 +66,7 @@ pub enum OpenError {
impl OpenError {
/// The error with just the file's name, for the status bar.
fn short(&self) -> String {
pub(super) fn short(&self) -> String {
let (path, error) = match self {
OpenError::Read { path, error } => (path, error.to_string()),
OpenError::Load { path, error } => (path, error.to_string()),
@@ -112,6 +113,7 @@ pub(super) fn open_first(loaded: Loaded, paths: &Paths) -> (Documents, Option<Me
fn opened(loaded: Loaded, paths: &Paths) -> (Buffer, DocFile, Option<Message>) {
let Loaded { doc, path, stamp } = loaded;
let name = short_name(&path);
let named = doc.title().clone();
let access = access(paths, doc.uid());
let read_only = matches!(access, Access::ReadOnly);
let said = read_only.then(|| Message::info(format!("{name} is open in another cumulus: read-only here")));
@@ -123,7 +125,7 @@ fn opened(loaded: Loaded, paths: &Paths) -> (Buffer, DocFile, Option<Message>) {
});
let buffer = Buffer::new(doc);
let saved = buffer.history().state();
(buffer, DocFile { path, stamp, saved, access, changed: None, trouble: None }, said)
(buffer, DocFile { path, stamp, saved, access, changed: None, trouble: None, named }, said)
}
/// Takes a document's lock, if it's free.
@@ -172,6 +174,8 @@ impl App {
let (buffer, file, said) = opened(loaded, &self.paths);
let id = self.shared.docs.add(buffer, file);
self.came_back_to(id);
self.sync_backlinks(id);
self.refresh_portals(true);
(id, said)
}
@@ -286,9 +290,58 @@ impl App {
Stamp::of(&file.path).is_ok_and(|stamp| stamp != file.stamp)
}
/// Writes a document to its file: the day's first save copies the old
/// file to the backups first.
/// Saves a document: when its root's title changed since the file was
/// last read or written, the file takes the new title's name first, and
/// after it's written, the documents its portals lead to get their
/// backlinks put right.
fn save(&mut self, id: BufferId) -> Result<(), Message> {
let renamed = self.follow_title(id);
self.write_file(id)?;
if let Some((old, new)) = renamed {
self.renamed(id, &old, &new);
}
self.sync_backlinks(id);
Ok(())
}
/// When the root's title changed since the file was last read or
/// written, the file takes the new title's name, in the same folder
/// (with `-2`, `-3`… when that name is taken): "file names follow the
/// root's title". A folder's default document keeps its name. Returns
/// where the file was and where it is now.
fn follow_title(&mut self, id: BufferId) -> Option<(PathBuf, PathBuf)> {
let default = format!("{}.{}", self.settings.default_document, names::EXTENSION);
let open = self.shared.docs.get_mut(id);
let title = open.buffer.doc().title().clone();
if title == open.file.named || open.is_read_only() {
return None;
}
open.file.named = title.clone();
let old = open.file.path.clone();
let name = short_name(&old);
let wanted = file_name(title.as_str());
if name.eq_ignore_ascii_case(&default) || name.eq_ignore_ascii_case(&wanted) {
return None;
}
let new = free_path(old.parent()?, &wanted)?;
let moved = if open.file.stamp.is_some() { fs::rename(&old, &new) } else { Ok(()) };
match moved {
Ok(()) => {
open.file.path = new.clone();
Some((old, new))
}
// It's saved where it is.
Err(error) => {
let text = format!("couldn't rename {name} to {}: {error}", short_name(&new));
self.message = Some(Message::problem(text));
None
}
}
}
/// Writes a document to its file as it is: the day's first save copies
/// the old file to the backups first.
pub(super) fn write_file(&mut self, id: BufferId) -> Result<(), Message> {
let backups = self.paths.backups();
let open = self.shared.docs.get_mut(id);
if open.is_read_only() {
@@ -331,6 +384,7 @@ impl App {
said.push(Message::info(format!("{}: you can edit it now", open.file_name())));
}
}
self.refresh_portals(true);
let changed = !said.is_empty();
if let Some(message) = Message::joined(said) {
self.message = Some(message);
@@ -342,7 +396,7 @@ impl App {
/// its text is being edited or a node moved: the document stays as it
/// is under the edit, and the file is looked at again after.
fn look_at(&mut self, id: BufferId) -> Option<Message> {
let busy = matches!(self.shared.mode, Mode::Text(_) | Mode::Move(_))
let busy = matches!(self.shared.mode, Mode::Text(_) | Mode::Move(_) | Mode::Connect(_))
|| self.shared.mode.any_edit().is_some();
if busy && id == self.buffer() {
return None;
@@ -384,6 +438,9 @@ impl App {
fn take_in(&mut self, id: BufferId, newer: Document, said: &str) -> Message {
let open = self.shared.docs.get_mut(id);
let name = open.file_name();
// The file has the name it has: a title changed in it isn't a
// reason to rename it.
open.file.named = newer.title().clone();
let undoable = open.buffer.reload(newer);
open.file.saved = open.buffer.history().state();
open.file.changed = None;
@@ -462,7 +519,12 @@ impl App {
}
names.push(self.shared.docs.get(id).file_name());
}
Some(Message::done(format!("saved {}", names.join(", "))))
let saved = format!("saved {}", names.join(", "));
// A file renamed as it was saved says so too.
Some(match self.message.take() {
Some(renamed) => Message::done(format!("{saved} · {}", renamed.text)),
None => Message::done(saved),
})
}
/// `:e`: opens a document in the focused pane, or (with no path) reads
@@ -498,10 +560,10 @@ impl App {
Some(self.take_in(id, loaded.doc, "read {} again from the file · u undoes that"))
}
/// A path typed in `:e` or `:new`: `~` is the home folder, relative paths
/// start at the focused document's folder, a folder means its default
/// document, and `.cum` is added when there's no extension.
fn resolve_path(&self, typed: &str) -> PathBuf {
/// A path typed in `:e`, `:new` or `:portal`: `~` is the home folder,
/// relative paths start at the focused document's folder, a folder means
/// its default document, and `.cum` is added when there's no extension.
pub(super) fn resolve_path(&self, typed: &str) -> PathBuf {
let path = self.paths.expand(typed.trim());
let path = if path.is_relative() { self.folder().join(path) } else { path };
if path.is_dir() {
@@ -557,8 +619,10 @@ impl App {
self.shared.docs.remove(id);
let next = self.shared.docs.at(index.saturating_sub(1));
let camera = self.shared.docs.get(next).camera;
let uid = self.shared.docs.get(next).buffer.doc().uid();
for (_, pane) in self.panes.iter_mut().filter(|(_, pane)| pane.buffer == id) {
pane.show(next, pane.view.kind(), camera);
pane.arrive(uid);
}
Some(Message::done(format!("closed {name}")))
}
@@ -643,6 +707,7 @@ impl App {
}
let camera = self.shared.docs.get(id).camera;
pane.show(id, pane.view.kind(), camera);
pane.arrive(self.shared.docs.get(id).buffer.doc().uid());
if let Mode::Visual(_) = self.shared.mode {
self.shared.mode = Mode::Normal;
}
@@ -655,6 +720,16 @@ 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<PathBuf> {
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) })
.map(|name| folder.join(name))
.find(|path| !path.exists())
}
fn read_only(open: &Open) -> Message {
Message::problem(format!("{} is open in another cumulus: read-only here", open.file_name()))
}
+27 -8
View File
@@ -6,7 +6,7 @@ use ratatui::layout::Position;
use super::action::{Action, AppAction, DocumentChoice, Message};
use super::mode::Mode;
use super::{App, calls, commands, visual};
use super::{App, calls, commands, connect, visual};
use crate::command::parse::{Sources, parse};
use crate::config::settings::Setting;
use crate::config::theme_file;
@@ -26,7 +26,7 @@ impl App {
Mode::Command(_) => self.command_line_key(key),
Mode::Text(_) => self.text_key(key),
Mode::Popup(_) => self.popup_key(key),
Mode::Normal | Mode::Visual(_) | Mode::Move(_) => {
Mode::Normal | Mode::Visual(_) | Mode::Move(_) | Mode::Connect(_) => {
match self.keys.press(key, &self.keymap.tables(self.contexts())) {
Outcome::Run(typed) => self.run_typed(typed),
Outcome::Unknown(keys) => {
@@ -37,10 +37,12 @@ impl App {
}
}
self.end_recording();
// Portals show the documents they lead to as they are now.
self.refresh_portals(false);
}
/// A command: the focused view has the first say, then visual mode's
/// commands (in visual mode), then the shared commands.
/// A command: the focused view has the first say, then visual or
/// connect mode's commands (in those modes), then the shared commands.
pub(super) fn dispatch(&mut self, typed: Typed) {
let selected = self.selection().unwrap_or_default();
let pane = self.panes.current_mut();
@@ -51,6 +53,7 @@ impl App {
.command(&typed, &ctx)
.or_else(|| match &self.shared.mode {
Mode::Visual(visual) => visual::command(&typed, visual, &selected, kind, &ctx),
Mode::Connect(connect) => connect::command(&typed, connect, &ctx),
_ => None,
})
.or_else(|| commands::shared(&typed, &ctx));
@@ -169,7 +172,21 @@ impl App {
/// goes to the document bar or to the pane under it (a drag stays with
/// the pane it started in), which takes the focus.
pub fn gesture(&mut self, gesture: Gesture) {
self.route_gesture(gesture);
self.refresh_portals(false);
}
fn route_gesture(&mut self, gesture: Gesture) {
let at = |cell: Cell| Position::new(cell.col, cell.row);
// In connect mode a click only aims: no editing, no menu, no
// selecting.
let gesture = match (&self.shared.mode, gesture) {
(Mode::Connect(_), Gesture::RightPress { .. }) => return,
(Mode::Connect(_), Gesture::Press { at, .. } | Gesture::DoublePress { at }) => {
Gesture::Press { at, ctrl: false }
}
_ => gesture,
};
// A click drops a half-typed key sequence: a `d` typed before
// clicking somewhere else mustn't wait to delete what comes next.
if matches!(gesture, Gesture::Press { .. } | Gesture::DoublePress { .. } | Gesture::RightPress { .. })
@@ -214,12 +231,14 @@ impl App {
self.dragging = Some(target);
}
if target != self.panes.focused() {
// Another pane: a text edited in this one is done first, and
// move or visual mode ends.
// Another pane: a text edited in this one is done first, move or
// visual mode ends, and connect mode is cancelled.
let ctx = self.shared.ctx(self.buffer());
let mut actions = self.shared.mode.edit().map(|edit| edit.finish(&ctx)).unwrap_or_default();
if let Mode::Move(_) | Mode::Visual(_) = self.shared.mode {
actions.push(Action::App(AppAction::LeaveMode));
match &self.shared.mode {
Mode::Move(_) | Mode::Visual(_) => actions.push(Action::App(AppAction::LeaveMode)),
Mode::Connect(connecting) => actions.extend(connect::cancel(connecting)),
_ => {}
}
self.update(actions);
self.panes.focus(target);
+9 -1
View File
@@ -4,7 +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::path::PathBuf;
use std::path::{Path, PathBuf};
use super::action::{DocAction, Message};
use super::{App, files};
@@ -65,6 +65,14 @@ impl Jumps {
self.places.get(to).cloned()
}
/// A document's file moved (its name followed its title): its places
/// find it at `path`.
pub fn moved(&mut self, uid: DocUid, path: &Path) {
for place in self.places.iter_mut().filter(|place| place.uid == uid) {
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<usize>) {
+52 -12
View File
@@ -24,14 +24,16 @@ pub mod calls;
mod clipboard;
pub mod commands;
mod configure;
pub mod connect;
pub mod documents;
mod files;
mod input;
mod jumps;
pub mod jumps;
mod marks;
pub mod mode;
pub mod panes;
pub mod popup;
pub mod portals;
pub mod registers;
mod render;
mod repeat;
@@ -43,7 +45,7 @@ mod update;
pub mod visual;
mod yank;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::time::Instant;
use ratatui::layout::Rect;
@@ -59,6 +61,7 @@ use crate::input::bindings::Context;
use crate::input::engine::KeyEngine;
use crate::input::keymap::Keymap;
use crate::input::mouse::MouseReader;
use crate::model::DocUid;
use crate::storage::memory::Memory;
use crate::ui::theme::Theme;
use crate::view::canvas::{Camera, CanvasView};
@@ -71,6 +74,7 @@ use documents::{BufferId, Documents};
use jumps::Jumps;
use mode::Mode;
use panes::{PaneId, Panes};
use portals::Portals;
use registers::Registers;
use startup::Opening;
@@ -121,18 +125,28 @@ struct Shared {
mode: Mode,
theme: Theme,
view: ViewSettings,
/// What the documents portals lead to look like.
portals: Portals,
}
impl Shared {
/// What a view showing document `buffer` looks at.
fn ctx(&self, buffer: BufferId) -> ViewContext<'_> {
let buffer = &self.docs.get(buffer).buffer;
self.ctx_in(buffer, &self.mode)
}
/// What a view showing document `buffer` looks at, in `mode` (the panes
/// without the focus are drawn as in normal mode).
fn ctx_in<'a>(&'a self, buffer: BufferId, mode: &'a Mode) -> ViewContext<'a> {
let open = self.docs.get(buffer);
ViewContext {
doc: buffer.doc(),
cursor: buffer.cursor(),
mode: &self.mode,
doc: open.buffer.doc(),
cursor: open.buffer.cursor(),
mode,
theme: &self.theme,
settings: &self.view,
portals: &self.portals,
folder: open.file.path.parent().unwrap_or(Path::new(".")),
}
}
@@ -142,11 +156,23 @@ impl Shared {
}
}
/// A pane: one of the documents, through a view, and where it jumped from.
/// A pane: one of the documents, through a view, where it jumped from, and
/// the portals it came through.
struct Pane {
buffer: BufferId,
view: Box<dyn View>,
jumps: Jumps,
/// The documents the pane came to this one through, by portals, the
/// first one first: the breadcrumb starts with them.
trail: Vec<Crumb>,
}
/// A document a pane came through, by a portal.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Crumb {
pub uid: DocUid,
/// Its name then.
pub title: String,
}
impl Pane {
@@ -160,14 +186,24 @@ impl Pane {
if let Some(camera) = camera {
view.set_camera(camera);
}
Pane { buffer, view, jumps: Jumps::default() }
Pane { buffer, view, jumps: Jumps::default(), trail: Vec::new() }
}
/// The pane shows `buffer` in a new view (a canvas starting at `camera`);
/// its jump list stays.
/// its jump list and its trail stay.
fn show(&mut self, buffer: BufferId, kind: ViewKind, camera: Option<Camera>) {
let jumps = std::mem::take(&mut self.jumps);
*self = Pane { jumps, ..Pane::new(buffer, kind, camera) };
let (jumps, trail) = (std::mem::take(&mut self.jumps), std::mem::take(&mut self.trail));
*self = Pane { jumps, trail, ..Pane::new(buffer, kind, camera) };
}
/// The pane got to document `uid` another way than a portal: if it came
/// through that one, the trail goes back to it; otherwise it starts
/// again.
fn arrive(&mut self, uid: DocUid) {
match self.trail.iter().position(|crumb| crumb.uid == uid) {
Some(at) => self.trail.truncate(at),
None => self.trail.clear(),
}
}
}
@@ -193,7 +229,7 @@ impl App {
let first = docs.at(0);
let mut app = App {
panes: Panes::new(Pane::new(first, ViewKind::Canvas, None)),
shared: Shared { docs, mode: Mode::Normal, theme, view },
shared: Shared { docs, mode: Mode::Normal, theme, view, portals: Portals::default() },
command_history: CommandHistory::from_lines(memory.history.clone()),
paths,
settings,
@@ -223,6 +259,10 @@ impl App {
for loaded in others {
said.extend(app.add_document(loaded).1);
}
// Once every document named is open, so one that's about to open
// isn't written underneath it.
app.sync_backlinks(first);
app.refresh_portals(true);
app.message = Message::joined(said);
app
}
+24
View File
@@ -22,6 +22,9 @@ pub enum Mode {
Visual(Visual),
/// Moving the cursor's node (or its branch) with hjkl.
Move(Grab),
/// Linking nodes: the cursor goes to another node, the line to it shows,
/// and Enter makes it.
Connect(Connect),
/// Typing a `:` command.
Command(CommandLine),
/// A popup has the keys: the help, the menu, a picker or the `K` card
@@ -53,6 +56,26 @@ impl Visual {
}
}
/// Connect mode's state (see [`connect`](super::connect)).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Connect {
/// The nodes being connected: the cursor's when `C` was typed, or a
/// selection's. The cursor is the other end.
pub sources: Vec<NodeId>,
/// Where the cursor goes back to when it's done.
pub start: NodeId,
/// Everything connect mode changes is one undo step, which Esc takes
/// back.
pub group: Group,
}
impl Connect {
/// Connecting `sources`, starting from the cursor at `start`.
pub fn new(sources: Vec<NodeId>, start: NodeId) -> Connect {
Connect { sources, start, group: Group::new() }
}
}
/// Move mode's state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Grab {
@@ -122,6 +145,7 @@ impl Mode {
Mode::Visual(Visual { branches: false, .. }) => "VISUAL",
Mode::Visual(_) => "VISUAL BRANCH",
Mode::Move(_) => "MOVE",
Mode::Connect(_) => "CONNECT",
Mode::Command(_) => "COMMAND",
Mode::Popup(popup) => popup.label(),
}
+186 -6
View File
@@ -19,8 +19,10 @@ use unicode_width::UnicodeWidthStr;
use super::action::{Action, AppAction, CursorAfter, DocAction, DocumentChoice, Keep, Message};
use super::commands;
use super::connect;
use super::documents::BufferId;
use super::jumps::Place as JumpPlace;
use super::mode::Connect;
use super::registers::Registers;
use super::repeat::Look;
use super::search::Search;
@@ -35,7 +37,8 @@ use crate::input::key::{Key, notation};
use crate::input::keymap::{Keymap, Tables};
use crate::input::mouse::{Cell, Gesture};
use crate::model::{
Border, CanvasPos, ColorName, Content, Direction, EditOp, Group, NodeId, StyleName, Tag, Tags,
Border, CanvasPos, ColorName, Content, Direction, EditOp, Group, Label, LineColor, LineRef, NodeId,
StyleName, Tag, Tags,
};
use crate::ui::card::{CardText, CardView};
use crate::ui::list::{ListOutcome, ListPopup, ListRow, Place};
@@ -84,6 +87,16 @@ pub enum PickKind {
Tags,
/// The document the pane shows.
Document,
/// For the lines connect mode is making.
Line(LinePick),
}
/// What a picker for connect mode's lines picks.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinePick {
Color,
Style,
Label,
}
/// A row of the documents picker: an open document, or one opened lately.
@@ -134,6 +147,9 @@ pub struct Picker {
tags: Tags,
/// Makes the changes to several nodes one undo step.
group: Group,
/// A picker connect mode opened: the mode to go back to, which knows
/// the lines the picker styles.
back: Option<Connect>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -146,6 +162,9 @@ enum Pick {
Document(usize),
/// A document opened lately, by its file.
Recent(PathBuf),
LineColor(Option<LineColor>),
LineStyle(Option<StyleName>),
Label(Option<Label>),
/// A heading over the rows after it.
Heading,
}
@@ -422,6 +441,8 @@ impl Popup {
}
("documents".to_string(), values, rows)
}
// Those pick for a line, and connect mode opens them.
PickKind::Line(_) => return Err(Message::info("C first: line pickers work in connect mode")),
};
let footer = match kind {
PickKind::Tags => "Enter: on or off · / then a new one: add it",
@@ -433,7 +454,116 @@ impl Popup {
if let Some(index) = values.iter().position(|value| picked(value, node, &tags, current)) {
list.select(index);
}
Ok(Popup::Pick(Picker { list, nodes: nodes.to_vec(), values, tags, group: Group::new() }))
Ok(Popup::Pick(Picker { list, nodes: nodes.to_vec(), values, tags, group: Group::new(), back: None }))
}
/// A picker for the lines connect mode is making or styling: from each
/// node being connected to the cursor's. It starts on what the first
/// line has; picking, or Esc, goes back to connect mode. The label
/// picker takes typing at once: a label it doesn't list is a new one.
pub fn line_picker(what: LinePick, ctx: &ViewContext<'_>, connect: &Connect) -> Popup {
let (doc, theme, glyphs) = (ctx.doc, ctx.theme, ctx.theme.glyphs);
let muted = Style::new().fg(theme.muted);
let line = connect::first_line(connect, ctx);
let look = line.and_then(|line| doc.line_look(line)).cloned().unwrap_or_default();
let label = match line {
Some(LineRef::Link(key)) => doc.link(key).and_then(|link| link.label.clone()),
_ => None,
};
let (name, values) = match what {
LinePick::Color => {
let mut values = vec![Pick::LineColor(None), Pick::LineColor(Some(LineColor::FollowNode))];
let named =
theme.palette.keys().cloned().map(|name| Pick::LineColor(Some(LineColor::Named(name))));
values.extend(named);
("line color", values)
}
LinePick::Style => {
let mut values = vec![Pick::LineStyle(None)];
values.extend(theme.line_styles.keys().cloned().map(|name| Pick::LineStyle(Some(name))));
("line style", values)
}
LinePick::Label => {
// The labels links have here, and the line styles' own.
let mut labels: Vec<Label> = doc.links().filter_map(|link| link.label.clone()).collect();
labels.extend(theme.line_styles.values().filter_map(|named| named.label.clone()));
labels.sort();
labels.dedup();
let mut values = vec![Pick::Label(None)];
values.extend(labels.into_iter().map(|label| Pick::Label(Some(label))));
("label", values)
}
};
let rows = values
.iter()
.map(|value| {
let (spans, search, on) = match value {
Pick::LineColor(None) => (
vec![Span::styled("theme default", muted)],
"theme default".to_string(),
look.color.is_none(),
),
Pick::LineColor(Some(LineColor::FollowNode)) => (
vec![Span::raw("the node's own color")],
"node own".to_string(),
look.color == Some(LineColor::FollowNode),
),
Pick::LineColor(Some(LineColor::Named(name))) => {
let swatch = Style::new().fg(theme.color(name));
let spans = vec![
Span::styled(format!("{} ", glyphs.bullet()), swatch),
Span::raw(name.to_string()),
];
(spans, name.to_string(), look.color == Some(LineColor::Named(name.clone())))
}
Pick::LineStyle(None) => (
vec![Span::styled("no style", muted)],
"no style none".to_string(),
look.style.is_none(),
),
Pick::LineStyle(Some(name)) => (
vec![Span::raw(name.to_string())],
name.to_string(),
look.style.as_ref() == Some(name),
),
Pick::Label(None) => {
(vec![Span::styled("no label", muted)], "no label none".to_string(), label.is_none())
}
Pick::Label(Some(text)) => {
(vec![Span::raw(text.to_string())], text.to_string(), label.as_ref() == Some(text))
}
_ => (Vec::new(), String::new(), false),
};
checked(ListRow::new(spans, search), on, theme)
})
.collect::<Vec<_>>();
let start = values.iter().position(|value| match value {
Pick::LineColor(color) => look.color == *color,
Pick::LineStyle(style) => look.style == *style,
Pick::Label(text) => label == *text,
_ => false,
});
let title = format!("{name} · {}", connect::ends(connect, ctx));
let footer = match what {
LinePick::Label => "type a new one, or pick · Enter: use it · Esc: back",
LinePick::Color | LinePick::Style => "Enter: pick · Esc: back to connecting",
};
let mut list = ListPopup::new(title, footer, rows, Place::Center { width: PICKER_WIDTH });
if let Some(index) = start {
list.select(index);
}
if what == LinePick::Label {
list = list.typing_first();
}
let back = Some(connect.clone());
Popup::Pick(Picker {
list,
nodes: Vec::new(),
values,
tags: Tags::default(),
group: connect.group,
back,
})
}
/// `:marks`: this document's marks, then the capital ones. Enter goes
@@ -540,6 +670,24 @@ impl Popup {
Popup::Menu(Runs { list, runs })
}
/// `gu` with several portals leading here, and `:backlinks`: the
/// documents they're in (title, then path). Enter goes there, on the
/// portal.
pub fn backlinks(ups: &[(String, String, JumpPlace)], theme: &Theme) -> Popup {
let muted = Style::new().fg(theme.muted);
let mark = theme.glyphs.backlink();
let (mut rows, mut runs) = (Vec::new(), Vec::new());
for (title, path, place) in ups {
let spans = vec![Span::raw(format!("{mark} {title}")), Span::styled(format!(" {path}"), muted)];
rows.push(ListRow::new(spans, title.as_str()));
runs.push(Run::App(AppAction::GoToPlace(place.clone())));
}
let footer = "Enter: go there, on its portal · Esc: close";
let list =
ListPopup::new("portals that lead here", footer, rows, Place::Center { width: CONFLICT_WIDTH });
Popup::Menu(Runs { list, runs })
}
/// The `K` card for a node.
pub fn card(node: NodeId) -> Popup {
Popup::Card(Card { node, edit: None, view: CardView::default() })
@@ -715,12 +863,16 @@ impl Popup {
fn outcome(&mut self, outcome: ListOutcome, ctx: &ViewContext<'_>) -> Vec<Action> {
match (outcome, self) {
(ListOutcome::Stay, _) => Vec::new(),
// A picker connect mode opened goes back to it.
(ListOutcome::Close, Popup::Pick(Picker { back: Some(connect), .. })) => {
vec![Action::App(AppAction::ResumeConnect(connect.clone()))]
}
(ListOutcome::Close, _) => leave(),
(ListOutcome::Picked(index), Popup::Help(runs) | Popup::Menu(runs) | Popup::Ask(runs)) => {
runs.run(index, ctx)
}
(ListOutcome::Picked(index), Popup::Pick(picker)) => picker.pick(index, ctx),
(ListOutcome::NoMatch(text), Popup::Pick(picker)) => picker.add_tag(&text, ctx),
(ListOutcome::NoMatch(text), Popup::Pick(picker)) => picker.add_new(&text, ctx),
(ListOutcome::Picked(_) | ListOutcome::NoMatch(_), _) => Vec::new(),
}
}
@@ -825,7 +977,7 @@ fn picked(value: &Pick, node: &crate::model::Node, tags: &Tags, current: usize)
Pick::Style(style) => node.look.style == *style,
Pick::Tag(tag) => tags.contains(tag),
Pick::Document(index) => *index == current,
Pick::Recent(_) | Pick::Heading => false,
Pick::Recent(_) | Pick::Heading | Pick::LineColor(_) | Pick::LineStyle(_) | Pick::Label(_) => false,
}
}
@@ -888,6 +1040,9 @@ impl Picker {
self.list.set_row(index, tag_row(&tag, on, ctx.theme));
return self.set(Look::Tag { tag, on });
}
Some(value @ (Pick::LineColor(_) | Pick::LineStyle(_) | Pick::Label(_))) => {
return self.set_line(value, ctx);
}
Some(Pick::Color(color)) => Look::Color(color.clone()),
Some(Pick::Border(border)) => Look::Border(*border),
Some(Pick::Style(style)) => Look::Style(style.clone()),
@@ -911,8 +1066,28 @@ impl Picker {
Action::Doc(DocAction::Edit { op: Box::new(op), cursor: CursorAfter::Stay, group })
}
/// Enter on a search that matches no tag: a new tag, put on the node.
fn add_tag(&mut self, text: &str, ctx: &ViewContext<'_>) -> Vec<Action> {
/// A value for the lines connect mode is making, then back to it.
fn set_line(&self, value: &Pick, ctx: &ViewContext<'_>) -> Vec<Action> {
let Some(connect) = &self.back else { return Vec::new() };
let mut actions = match value {
Pick::LineColor(color) => connect::restyle(connect, ctx, |look| look.color = color.clone()),
Pick::LineStyle(style) => connect::restyle(connect, ctx, |look| look.style = style.clone()),
Pick::Label(label) => connect::relabel(connect, ctx, label),
_ => Vec::new(),
};
actions.push(Action::App(AppAction::ResumeConnect(connect.clone())));
actions
}
/// Enter on a search that matches nothing: in the tags picker, a new
/// tag, put on the node; in the label picker, a new label.
fn add_new(&mut self, text: &str, ctx: &ViewContext<'_>) -> Vec<Action> {
if self.values.iter().any(|value| matches!(value, Pick::Label(_))) {
return match Label::parse(text) {
Ok(label) => self.set_line(&Pick::Label(Some(label)), ctx),
Err(error) => vec![Action::say(Message::problem(error.to_string()))],
};
}
if !self.values.iter().all(|value| matches!(value, Pick::Tag(_))) {
return Vec::new();
}
@@ -929,6 +1104,11 @@ impl Picker {
}
}
/// A row with a check mark when it's what's there now.
fn checked(row: ListRow, on: bool, theme: &Theme) -> ListRow {
if on { row.with_note(Span::styled("✓", Style::new().fg(theme.accent))) } else { row }
}
/// A row of the tags picker, checked when the node has the tag.
fn tag_row(tag: &Tag, on: bool, theme: &Theme) -> ListRow {
let row = ListRow::new(vec![Span::raw(tag.to_string())], tag.name());
+546
View File
@@ -0,0 +1,546 @@
//! Portals between documents (DESIGN.md, "Portals"): a portal node leads to
//! another document's root, and shows its title, color and how many nodes
//! it has, live.
//!
//! - [`Portals`] remembers what each document file holds, for the portals
//! and backlinks leading to it: the open documents as they are now, the
//! others as read from disk, looked at again every second.
//! - Enter or `gf` goes through a portal, as a jump (`Ctrl-o` comes back),
//! to the other document's root; the pane's breadcrumb then starts with
//! the documents it came through.
//! - Each document keeps the portals that lead to it, its backlinks. When a
//! document is opened or saved, the documents its portals lead to get
//! theirs put right. `gu` goes up to them.
//! - When a root's title changes, the next save renames its file
//! (`files.rs`), and the portals and backlinks pointing at it follow.
//! - A portal whose file isn't where it says looks for its document in that
//! folder, by uid, and keeps the path it finds.
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use super::action::{CursorAfter, DocAction, Message};
use super::documents::BufferId;
use super::files::load;
use super::jumps::Place;
use super::mode::Mode;
use super::popup::Popup;
use super::{App, Crumb};
use crate::model::{Backlink, Content, DocUid, Document, EditOp, Node, NodeId, Portal, Spot, Title};
use crate::storage::backup::{self, Day};
use crate::storage::document_file;
use crate::storage::link_paths::{relative, resolve};
use crate::storage::lock::Lock;
use crate::storage::names::{self, file_name, title_from_file};
use crate::storage::stamp::Stamp;
use crate::storage::write_atomically;
use crate::view::canvas::placement::child_spot;
/// A document as its portals show it: its uid, its root (the title and the
/// look), and how many nodes it has.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Seen {
pub uid: DocUid,
pub root: Node,
pub count: usize,
}
impl Seen {
fn of(doc: &Document) -> Option<Seen> {
Some(Seen { uid: doc.uid(), root: doc.node(doc.root())?.clone(), count: doc.node_count() })
}
/// The document's name: its root's title.
pub fn title(&self) -> &Title {
self.root.title()
}
}
/// What a portal shows of its document.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Shown<'a> {
/// The document, as it is now.
Live(&'a Seen),
/// No document at its path: the file is gone, or isn't a document.
Missing,
/// Another document is at its path now.
Other(&'a Seen),
/// Not looked at yet.
Unknown,
}
/// What each document file holds, for the portals and backlinks that lead
/// to it.
#[derive(Debug, Default)]
pub struct Portals {
files: BTreeMap<PathBuf, Looked>,
}
/// A file as it was last looked at: its stamp then (`None` for an open
/// document, which is looked at in its buffer instead), and the document
/// in it, if there's one.
#[derive(Debug, Clone)]
struct Looked {
stamp: Option<Stamp>,
seen: Option<Seen>,
}
impl Portals {
/// What a portal in a document in `folder` shows.
pub fn shown(&self, folder: &Path, portal: &Portal) -> Shown<'_> {
match self.files.get(&resolve(folder, &portal.path)) {
None => Shown::Unknown,
Some(Looked { seen: Some(seen), .. }) if seen.uid == portal.uid => Shown::Live(seen),
Some(Looked { seen: Some(seen), .. }) => Shown::Other(seen),
Some(Looked { seen: None, .. }) => Shown::Missing,
}
}
/// What the file at `path` holds, if it's been looked at.
pub fn seen(&self, path: &Path) -> Option<&Seen> {
self.files.get(path)?.seen.as_ref()
}
/// An open document, as it is now.
fn put(&mut self, path: &Path, doc: &Document) {
self.files.insert(path.to_path_buf(), Looked { stamp: None, seen: Seen::of(doc) });
}
/// The file at `path`, read again if it changed since it was read.
fn look(&mut self, path: &Path) {
let Ok(stamp) = Stamp::of(path) else { return };
if self.files.get(path).is_some_and(|looked| looked.stamp.is_some() && looked.stamp == stamp) {
return;
}
let seen = stamp.and_then(|_| read(path).ok()).and_then(|doc| Seen::of(&doc));
self.files.insert(path.to_path_buf(), Looked { stamp, seen });
}
/// A file that isn't there anymore (renamed).
fn forget(&mut self, path: &Path) {
self.files.remove(path);
}
}
/// The folder a document file is in.
pub fn folder_of(path: &Path) -> PathBuf {
path.parent().map_or_else(|| PathBuf::from("."), Path::to_path_buf)
}
/// The document in a file.
fn read(path: &Path) -> Result<Document, String> {
let text = fs::read_to_string(path).map_err(|error| error.to_string())?;
document_file::parse(&text).map_err(|error| error.to_string())
}
/// The document with this uid among a folder's `.cum` files: one moved or
/// renamed where its portals couldn't follow (by hand, say).
fn find_uid(folder: &Path, uid: DocUid) -> Option<PathBuf> {
fs::read_dir(folder)
.ok()?
.flatten()
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|extension| extension == names::EXTENSION))
.find(|path| read(path).is_ok_and(|doc| doc.uid() == uid))
}
/// The file's name, as its folder spells it: on Windows `Kitchen.cum` finds
/// `kitchen.cum`, and the portal should keep the name that works on Linux
/// too.
fn spelled(path: &Path) -> PathBuf {
let (Some(folder), Some(name)) = (path.parent(), path.file_name()) else { return path.to_path_buf() };
let name = name.to_string_lossy().to_lowercase();
fs::read_dir(folder)
.ok()
.and_then(|entries| {
entries.flatten().map(|entry| entry.path()).find(|found| {
found.file_name().is_some_and(|found| found.to_string_lossy().to_lowercase() == name)
})
})
.unwrap_or_else(|| path.to_path_buf())
}
/// Whether two paths name the same file: Windows ignores case in file
/// names, Linux doesn't.
fn same_file(a: &Path, b: &Path) -> bool {
if cfg!(windows) {
a.to_string_lossy().to_lowercase() == b.to_string_lossy().to_lowercase()
} else {
a == b
}
}
/// A file's name, for messages.
fn name_of(path: &Path) -> String {
path.file_name().map_or_else(|| path.display().to_string(), |name| name.to_string_lossy().into_owned())
}
impl App {
/// Brings the portals up to date: what their documents hold now (the
/// open ones from their buffers; with `read`, the others from their
/// files), and the title each portal remembers in case its file goes.
pub(super) fn refresh_portals(&mut self, read: bool) {
let shared = &mut self.shared;
for open in shared.docs.iter() {
shared.portals.put(&open.file.path, open.buffer.doc());
}
if read {
let mut wanted = BTreeSet::new();
for open in shared.docs.iter() {
let (folder, doc) = (folder_of(&open.file.path), open.buffer.doc());
wanted.extend(doc.portals().map(|(_, portal)| resolve(&folder, &portal.path)));
wanted.extend(doc.backlinks().iter().map(|backlink| resolve(&folder, &backlink.from)));
}
for path in wanted {
if shared.docs.find_path(&path).is_none() {
shared.portals.look(&path);
}
}
}
for open in shared.docs.iter_mut() {
let folder = folder_of(&open.file.path);
let titles: Vec<(NodeId, Portal)> = open
.buffer
.doc()
.portals()
.filter_map(|(id, portal)| match shared.portals.shown(&folder, portal) {
Shown::Live(seen) if *seen.title() != portal.title => {
Some((id, Portal { title: seen.title().clone(), ..portal.clone() }))
}
_ => None,
})
.collect();
for (id, portal) in titles {
open.buffer.set_portal(id, portal);
}
}
}
/// Enter or `gf` on a portal: its document shows in the pane, on its
/// root. That's a jump (`Ctrl-o` comes back), and the pane's breadcrumb
/// starts with the documents it came through.
pub(super) fn follow_portal(&mut self, id: NodeId) -> Option<Message> {
let here = self.buffer();
let open = self.shared.docs.get(here);
let Some(Content::Portal(portal)) = open.buffer.doc().node(id).map(|node| &node.content) else {
return Some(Message::info("this node isn't a portal: :portal makes one"));
};
let portal = portal.clone();
let from = Crumb { uid: open.buffer.doc().uid(), title: open.name() };
let (target, said) = match self.open_portal(here, &portal) {
Ok(found) => found,
Err(problem) => return Some(problem),
};
// A portal to its own document (made by hand) just goes to the root.
if target == here {
let root = self.current().doc().root();
return self.doc_action(DocAction::MoveCursor { to: root, jump: true });
}
let mut trail = self.panes.current().trail.clone();
self.show(target);
// The pane came through `from`; going back up one it came through
// takes the trail back there.
let uid = self.current().doc().uid();
match trail.iter().position(|crumb| crumb.uid == uid) {
Some(at) => trail.truncate(at),
None => trail.push(from),
}
self.panes.current_mut().trail = trail;
let root = self.current().doc().root();
self.doc_action(DocAction::MoveCursor { to: root, jump: false });
said
}
/// The open document a portal leads to, opened if it isn't yet: by its
/// uid among the open ones, then at its path, then (moved or renamed
/// where the portal couldn't follow) in that folder by its uid, a path
/// the portal keeps. Also what to say about opening it.
fn open_portal(
&mut self,
here: BufferId,
portal: &Portal,
) -> Result<(BufferId, Option<Message>), Message> {
if let Some(open) = self.shared.docs.find_uid(portal.uid) {
return Ok((open, None));
}
let path = resolve(&folder_of(&self.shared.docs.get(here).file.path), &portal.path);
let found = match read(&path) {
Ok(doc) if doc.uid() == portal.uid => path,
other => {
let lost = match other {
Ok(doc) => format!("{} holds another document now (\"{}\")", name_of(&path), doc.title()),
Err(_) if !path.exists() => format!("{} isn't there anymore", name_of(&path)),
Err(error) => format!("{} can't be read: {error}", name_of(&path)),
};
let moved = path.parent().and_then(|folder| find_uid(folder, portal.uid));
let Some(moved) = moved else { return Err(Message::problem(lost)) };
self.repath(here, portal.uid, &moved);
moved
}
};
let loaded = load(&found).map_err(|error| Message::problem(error.short()))?;
Ok(self.add_document(loaded))
}
/// `:portal`, which `<Space>p` types: a portal under the cursor's node,
/// where `a` adds a child, to the document at the path typed (relative
/// to this document's folder, as `:e` takes it). When nothing's there,
/// what was typed is a new document's title: it's made next to this one,
/// as `:new` makes one (with a `/` in it, it's made at that path).
pub(super) fn new_portal(&mut self, typed: &str) -> Option<Message> {
if let Some(refused) = self.refuse_read_only() {
return Some(refused);
}
let typed = typed.trim();
let folder = self.folder();
let path = self.resolve_path(typed);
let by_title = folder.join(file_name(typed));
let slashed = typed.contains(['/', '\\']);
// The document's file, and the title of a new one to make there.
let (path, new) = if path.exists() {
(spelled(&path), None)
} else if !slashed && by_title.exists() {
(spelled(&by_title), None)
} else if slashed {
let title = title_from_file(&path);
(path, Some(title))
} else {
match Title::parse(typed) {
Ok(title) => (by_title, Some(title)),
Err(error) => return Some(Message::problem(error.to_string())),
}
};
if same_file(&path, &self.shared.docs.get(self.buffer()).file.path) {
return Some(Message::problem("a portal can't lead to its own document"));
}
let made = new.is_some();
if let Some(title) = new
&& let Err(error) = super::files::create(&path, title)
{
return Some(Message::problem(error.short()));
}
let target = match self.shared.docs.find_path(&path) {
Some(open) => Seen::of(self.shared.docs.get(open).buffer.doc()),
None => match read(&path) {
Ok(doc) => Seen::of(&doc),
Err(error) => return Some(Message::problem(format!("{}: {error}", name_of(&path)))),
},
};
let (Some(target), Some(link)) = (target, relative(&folder, &path)) else { return None };
let portal = Portal { path: link, uid: target.uid, title: target.title().clone() };
let ctx = self.shared.ctx(self.buffer());
let node = Node::portal(portal, crate::model::CanvasPos::default());
let pos = child_spot(ctx.doc, ctx.cursor, ctx.box_size(&node), &ctx.box_rects());
let spot = Spot::LastChildOf(ctx.cursor);
let add = EditOp::Add { node: Node { pos, ..node }, spot };
let said =
self.doc_action(DocAction::Edit { op: Box::new(add), cursor: CursorAfter::Added, group: None });
if said.is_some() {
return said;
}
self.refresh_portals(true);
let name = name_of(&path);
Some(Message::done(if made {
format!("made {name}, with a portal to it: Enter goes there")
} else {
format!("a portal to {name}: Enter goes there")
}))
}
/// `gu`, and `:backlinks` (`list`): up to the portals that lead to this
/// document. One goes straight there (a jump); several, or
/// `:backlinks`, pop up a list, the document the pane came from first.
pub(super) fn go_up(&mut self, list: bool) -> Option<Message> {
let open = self.shared.docs.get(self.buffer());
let folder = folder_of(&open.file.path);
let mut ups: Vec<(String, String, Place)> = open
.buffer
.doc()
.backlinks()
.iter()
.map(|backlink| {
let path = resolve(&folder, &backlink.from);
let title = self
.shared
.portals
.seen(&path)
.map_or_else(|| title_from_file(&path).to_string(), |seen| seen.title().to_string());
let place = Place { uid: backlink.uid, path, node: backlink.node };
(title, backlink.from.as_str().to_string(), place)
})
.collect();
if let Some(came) = self.panes.current().trail.last() {
ups.sort_by_key(|(_, _, place)| place.uid != came.uid);
}
match ups.as_slice() {
[] => Some(Message::info("no portal leads here yet: :portal in another document makes one")),
[(_, _, place)] if !list => {
let place = place.clone();
self.jump_to_place(&place)
}
_ => {
self.shared.mode = Mode::Popup(Popup::backlinks(&ups, &self.shared.theme));
None
}
}
}
/// Gives the documents this one's portals lead to the backlinks they
/// should have from it, and takes away the ones from portals that went
/// (DESIGN.md: "Backlinks are stored in the target document and synced
/// on save"). Done when a document is opened and when it's saved; a
/// document another cumulus has open is left alone.
pub(super) fn sync_backlinks(&mut self, id: BufferId) {
let open = self.shared.docs.get(id);
// Not on disk yet: no portal can lead back to it.
if open.file.stamp.is_none() {
return;
}
let source = (open.buffer.doc().uid(), open.file.path.clone());
let folder = folder_of(&source.1);
let mut leads: BTreeMap<PathBuf, (DocUid, Vec<NodeId>)> = BTreeMap::new();
for (node, portal) in open.buffer.doc().portals() {
let lead =
leads.entry(resolve(&folder, &portal.path)).or_insert_with(|| (portal.uid, Vec::new()));
lead.1.push(node);
}
let before = std::mem::take(&mut self.shared.docs.get_mut(id).led_to);
let paths: BTreeSet<PathBuf> = before.into_iter().chain(leads.keys().cloned()).collect();
for path in paths {
let (uid, nodes) = leads.get(&path).map_or((None, &[][..]), |(uid, nodes)| (Some(*uid), nodes));
self.give_backlinks(&path, uid, &source, nodes);
}
self.shared.docs.get_mut(id).led_to = leads.into_keys().collect();
}
/// Puts the backlinks from one document (`source`: its uid and its file)
/// in the document at `path` (which has the uid `uid`, when that's
/// known): exactly one for each portal in `nodes`.
fn give_backlinks(
&mut self,
path: &Path,
uid: Option<DocUid>,
source: &(DocUid, PathBuf),
nodes: &[NodeId],
) {
let wanted = |doc: &Document, folder: &Path| -> Option<Vec<Backlink>> {
let from = relative(folder, &source.1)?;
let mut backlinks: Vec<Backlink> =
doc.backlinks().iter().filter(|backlink| backlink.uid != source.0).cloned().collect();
backlinks.extend(nodes.iter().map(|&node| Backlink { from: from.clone(), uid: source.0, node }));
backlinks.sort();
(backlinks != doc.backlinks()).then_some(backlinks)
};
let docs = &self.shared.docs;
if let Some(target) = docs.find_path(path).or_else(|| uid.and_then(|uid| docs.find_uid(uid))) {
let open = self.shared.docs.get_mut(target);
let Some(backlinks) = wanted(open.buffer.doc(), &folder_of(&open.file.path)) else { return };
open.buffer.set_backlinks(backlinks);
// A document with changes of its own takes them along with those.
if !open.is_dirty() && open.file.stamp.is_some() && open.file.trouble.is_none() {
let _ = self.write_file(target);
}
return;
}
let Ok(mut doc) = read(path) else { return };
if uid.is_some_and(|uid| uid != doc.uid()) {
return;
}
let Some(backlinks) = wanted(&doc, &folder_of(path)) else { return };
doc.set_backlinks(backlinks);
self.write_closed(path, &doc);
}
/// Writes a document that isn't open here, unless another cumulus has
/// it: the day's first write copies it to the backups first.
fn write_closed(&self, path: &Path, doc: &Document) {
let lock = match Lock::take(&self.paths.locks(), doc.uid()) {
Ok(Some(lock)) => Some(lock),
Ok(None) => return,
// No lock files (the data folder can't be written): unprotected.
Err(_) => None,
};
let Ok(text) = document_file::write(doc) else { return };
let _ = backup::daily(&self.paths.backups(), doc.uid(), path, Day::today(), backup::KEEP);
let _ = write_atomically(path, &text);
drop(lock);
}
/// After a document's file was renamed from `old` to `new`: the portals
/// that lead to it (in the documents its backlinks list, and in any open
/// one) take the new path, and so does everything that remembers where
/// it was.
pub(super) fn renamed(&mut self, id: BufferId, old: &Path, new: &Path) {
let doc = self.shared.docs.get(id).buffer.doc();
let uid = doc.uid();
let folder = folder_of(new);
let sources: BTreeSet<PathBuf> =
doc.backlinks().iter().map(|backlink| resolve(&folder, &backlink.from)).collect();
for other in self.shared.docs.ids() {
if other != id {
self.repath(other, uid, new);
}
}
for path in sources {
if self.shared.docs.find_path(&path).is_some() {
continue;
}
let Ok(mut doc) = read(&path) else { continue };
if repath_doc(&mut doc, &folder_of(&path), uid, new) {
self.write_closed(&path, &doc);
}
}
self.memory.moved(&uid.to_string(), new);
for (_, pane) in self.panes.iter_mut() {
pane.jumps.moved(uid, new);
}
self.shared.portals.forget(old);
self.message = Some(Message::done(format!(
"{} is {} now: a file's name follows its title",
name_of(old),
name_of(new)
)));
}
/// The portals to document `uid` in an open document take the path
/// `new`: written straight away, unless the document has changes of its
/// own to save with it.
fn repath(&mut self, id: BufferId, uid: DocUid, new: &Path) {
let open = self.shared.docs.get_mut(id);
let folder = folder_of(&open.file.path);
let Some(link) = relative(&folder, new) else { return };
let moved: Vec<(NodeId, Portal)> = open
.buffer
.doc()
.portals()
.filter(|(_, portal)| portal.uid == uid && portal.path != link)
.map(|(node, portal)| (node, Portal { path: link.clone(), ..portal.clone() }))
.collect();
if moved.is_empty() {
return;
}
for (node, portal) in moved {
open.buffer.set_portal(node, portal);
}
if !open.is_dirty() && open.file.stamp.is_some() && open.file.trouble.is_none() {
let _ = self.write_file(id);
}
}
}
/// The portals to document `uid` in a document (whose file is in `folder`)
/// take the path `new`. Returns whether one changed.
fn repath_doc(doc: &mut Document, folder: &Path, uid: DocUid, new: &Path) -> bool {
let Some(link) = relative(folder, new) else { return false };
let moved: Vec<(NodeId, Portal)> = doc
.portals()
.filter(|(_, portal)| portal.uid == uid && portal.path != link)
.map(|(node, portal)| (node, Portal { path: link.clone(), ..portal.clone() }))
.collect();
let changed = !moved.is_empty();
for (node, portal) in moved {
doc.set_portal(node, portal);
}
changed
}
+28 -13
View File
@@ -2,6 +2,7 @@
//! panes in their boxes, a popup over them, the status bar and the command
//! line at the bottom, and which-key.
use std::path::Path;
use std::time::Duration;
use ratatui::Frame;
@@ -15,7 +16,7 @@ use super::action::{self, Message, Tone};
use super::mode::{Grab, Mode};
use super::popup::{Card, Popup};
use super::text_edit::{Field, TextEdit};
use super::{App, Screen, Shared};
use super::{App, Screen, Shared, connect};
use crate::ui::document_bar::DocumentBar;
use crate::ui::status_bar::{StatusBar, breadcrumb};
use crate::ui::which_key::WhichKey;
@@ -38,12 +39,12 @@ impl App {
// is in the mode; the others show their documents as they are.
let focused = self.panes.focused();
let normal = Mode::Normal;
let Shared { docs, mode, theme, view } = &self.shared;
let theme = &self.shared.theme;
for (id, area) in self.panes.areas(panes_area) {
let Some(pane) = self.panes.get_mut(id) else { continue };
let buffer = &docs.get(pane.buffer).buffer;
let mode = if id == focused { mode } else { &normal };
let ctx = ViewContext { doc: buffer.doc(), cursor: buffer.cursor(), mode, theme, settings: view };
let buffer = &self.shared.docs.get(pane.buffer).buffer;
let mode = if id == focused { &self.shared.mode } else { &normal };
let ctx = self.shared.ctx_in(pane.buffer, mode);
let color = if id == focused { theme.accent } else { theme.muted };
let title = format!(" {} · {} ", pane.view.kind().name(), buffer.doc().title());
let block = Block::bordered()
@@ -59,15 +60,17 @@ impl App {
// A popup over the panes; the card sits beside the cursor's node.
let buffer = self.buffer();
let anchor = self.panes.current().view.cursor_area(&self.shared.ctx(buffer));
let Shared { docs, mode, theme, view } = &mut self.shared;
let Shared { docs, mode, theme, view, portals } = &mut self.shared;
if let Mode::Popup(popup) = mode {
let buffer = &docs.get(buffer).buffer;
let open = docs.get(buffer);
let ctx = ViewContext {
doc: buffer.doc(),
cursor: buffer.cursor(),
doc: open.buffer.doc(),
cursor: open.buffer.cursor(),
mode: &normal,
theme,
settings: view,
portals,
folder: open.file.path.parent().unwrap_or(Path::new(".")),
};
popup.layout(panes_area, anchor, &ctx);
popup.render(frame, &ctx);
@@ -111,11 +114,17 @@ impl App {
let view = format!("{}{focus}", pane.view.status());
let room = usize::from(area.width).saturating_sub(mode.label().len() + view.len() + 16);
// In visual mode, how many nodes are selected takes the breadcrumb's
// place.
let selected = self.selection().map(|selected| format!("{} selected", action::nodes(selected.len())));
// place; in connect mode, what's being connected to what.
let selected = match mode {
Mode::Connect(connecting) => Some(connect::status(connecting, &self.shared.ctx(self.buffer()))),
_ => self.selection().map(|selected| format!("{} selected", action::nodes(selected.len()))),
};
let place = match (&self.message, selected) {
(None, Some(selected)) => Span::raw(selected),
(None, None) => Span::raw(breadcrumb(buffer.doc(), buffer.cursor(), room)),
(None, None) => {
let trail: Vec<String> = pane.trail.iter().map(|crumb| crumb.title.clone()).collect();
Span::raw(breadcrumb(&trail, buffer.doc(), buffer.cursor(), room))
}
(Some(Message { text, tone: Tone::Problem }), _) => text.clone().light_red(),
(Some(Message { text, tone: Tone::Done }), _) => text.clone().green(),
(Some(Message { text, tone: Tone::Info }), _) => text.clone().yellow(),
@@ -124,7 +133,7 @@ impl App {
mode: mode.label(),
mode_color: match mode {
_ if typing => theme.insert,
Mode::Visual(_) => theme.visual,
Mode::Visual(_) | Mode::Connect(_) => theme.visual,
_ => theme.accent,
},
view,
@@ -201,6 +210,12 @@ fn hints(mode: &Mode, view: ViewKind) -> &'static str {
"hjkl moves the branch (HJKL by 5) · M just this node · Enter done"
}
(Mode::Move(_), _) => "hjkl moves just this node (HJKL by 5) · M the whole branch · Enter done",
(Mode::Connect(_), ViewKind::Note) => {
"h/l gh gj gk gl go to the node · Enter link · d unlink · > under it · <Space> style · Esc cancel"
}
(Mode::Connect(_), _) => {
"go to the node · Enter link · d unlink or cut · > under it · <Space> style · Esc cancel"
}
(Mode::Command(_), _) => "Tab completes · ↑ ↓ history · Enter runs · Esc cancels",
(Mode::Popup(Popup::Card(_)), _) => "j/k scroll · i edit the body · K or Esc close",
(Mode::Popup(Popup::Search(_)), _) => "type to search · ↑ ↓ or Tab move · Enter go there · Esc close",
+5 -7
View File
@@ -4,7 +4,9 @@
//! `tests/files.rs`, and the config folder in `tests/config.rs`.
mod config;
mod connect;
mod files;
mod portals;
mod vim;
use std::path::PathBuf;
@@ -501,20 +503,16 @@ fn indenting_and_outdenting_nodes() {
}
#[test]
fn keys_from_later_stages_say_so() {
fn v1_1_commands_say_so_and_portal_titles_stay() {
let mut h = Harness::new();
h.type_keys("C");
assert_eq!(h.message(), "\"connect mode\" comes in a later part of the build");
h.type_keys("<Space>p");
assert_eq!(h.message(), "\"new portal…\" comes in a later part of the build");
h.type_keys(":tidy<CR>");
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");
assert_eq!(h.cursor(), "Kitchen");
h.type_keys("i");
assert_eq!(h.message(), "a portal shows its document's name: rename that document instead");
assert_eq!(h.label(), "NORMAL");
h.type_keys("<CR>");
assert_eq!(h.message(), "going through a portal comes in part 8d of the build");
}
// ----- Views, panes, popups and documents (stage 7) -----------------------
+140
View File
@@ -0,0 +1,140 @@
//! Connect mode (part 8d), end to end: linking, styling the line as it's
//! made, cutting and attaching branches, Esc taking it all back, and
//! connecting a selection.
use ratatui::style::Color;
use super::*;
use crate::model::{Arrows, ColorName, LineColor, LineRef, LinkKey, Stroke};
impl Harness {
/// The status bar's first line.
fn status(&self) -> String {
let rows = self.rows();
rows[rows.len() - 2].clone()
}
/// The link between two nodes, by title.
fn link_between(&self, a: &str, b: &str) -> Option<crate::model::Link> {
self.doc().link(LinkKey::new(self.node(a), self.node(b))?).cloned()
}
}
#[test]
fn connect_mode_links_two_nodes() {
let mut h = Harness::new();
h.type_keys("glC");
assert_eq!(h.label(), "CONNECT");
assert!(h.status().contains("Soil → ? · go to the node to connect"), "{}", h.status());
h.type_keys("<CR>");
assert_eq!(h.message(), "go to the other node first (hjkl…), then Enter links them");
// The cursor goes to the other end; a dashed arrow follows it there.
h.type_keys("gj");
assert_eq!(h.cursor(), "Raised beds");
assert!(h.status().contains("Soil → Raised beds"), "{}", h.status());
let (col, row) = h.find("Soil");
assert_eq!(h.color_at((col - 2, row)), Color::Magenta, "Soil is what's being connected");
assert!(h.screen().contains('╌'), "the dashed line on its way:\n{}", h.screen());
h.type_keys("<CR>");
assert_eq!((h.label(), h.cursor().as_str()), ("NORMAL", "Soil"), "back where C was typed");
assert_eq!(h.message(), "linked Soil → Raised beds");
let link = h.link_between("Soil", "Raised beds").unwrap();
assert_eq!((link.start(), link.end()), (h.node("Soil"), h.node("Raised beds")));
h.type_keys("u");
assert!(h.link_between("Soil", "Raised beds").is_none(), "one undo step");
}
#[test]
fn the_line_is_styled_as_it_is_made_and_esc_takes_it_back() {
let mut h = Harness::new();
h.type_keys("glCgj<Space>a");
assert_eq!(h.message(), "arrowheads: start", "after the end arrow a new link would have");
assert_eq!(h.label(), "CONNECT");
h.type_keys("<Space>l");
assert_eq!(h.message(), "line: dotted");
h.type_keys("<Space>c");
assert_eq!(h.label(), "PICK");
h.type_keys("/red<CR>");
assert_eq!(h.label(), "CONNECT", "the picker goes back to connect mode");
h.type_keys("<Space>t");
assert!(h.screen().contains("label · Soil → Raised beds"), "{}", h.screen());
h.type_keys("feeds<CR>");
let link = h.link_between("Soil", "Raised beds").unwrap();
let red = Some(LineColor::Named(ColorName::parse("red").unwrap()));
assert_eq!(
(link.look.arrows, link.look.line, &link.look.color),
(Some(Arrows::Start), Some(Stroke::Dotted), &red)
);
assert_eq!(link.label.as_ref().map(|label| label.as_str()), Some("feeds"));
h.type_keys("<Esc>");
assert_eq!((h.label(), h.cursor().as_str()), ("NORMAL", "Soil"));
assert!(h.link_between("Soil", "Raised beds").is_none(), "Esc took it all back");
h.type_keys("u");
assert_eq!(h.message(), "nothing to undo", "and left nothing behind");
}
#[test]
fn d_cuts_a_branch_and_gt_attaches_one() {
let mut h = Harness::new();
// d, aimed at the parent: the branch is cut, and the node comes loose.
h.type_keys("glgjglCgh");
assert!(h.status().contains("Cedar, untreated → Raised beds · its parent"), "{}", h.status());
h.type_keys("d");
assert_eq!(h.message(), "cut the branch: Cedar, untreated is a loose node now");
assert!(h.doc().loose().contains(&h.node("Cedar, untreated")));
assert_eq!(h.cursor(), "Cedar, untreated");
// >: under the node aimed at, staying where it is on the canvas.
let pos = h.pos("Cedar, untreated");
h.type_keys("Cgggl>");
assert_eq!(h.message(), "Cedar, untreated is under Soil now");
assert_eq!(h.parent("Cedar, untreated"), "Soil");
assert_eq!(h.pos("Cedar, untreated"), pos);
// The root stays at the top, and a branch can't go inside itself.
h.type_keys("ggCgl>");
assert_eq!(h.message(), "the root stays at the top: C on the other node puts that one under it");
assert_eq!(h.label(), "CONNECT", "still connecting");
h.type_keys("<Esc>glCgl>");
assert_eq!(h.message(), "Cedar, untreated is in Soil's branch, and a branch can't go inside itself");
// d on a link removes it.
h.type_keys("<Esc>:find seeds<CR>CG");
assert!(h.status().contains("Seeds to order → ask about compost · linked"), "{}", h.status());
h.type_keys("d");
assert_eq!(h.message(), "unlinked Seeds to order and ask about compost");
assert!(h.doc().links().next().is_none());
}
#[test]
fn a_selection_connects_every_node_to_the_target() {
let mut h = Harness::new();
// v on Soil and Raised beds, then C: both connect to where the cursor goes.
h.type_keys("glvjC");
assert_eq!(h.label(), "CONNECT");
h.type_keys("G");
assert!(h.status().contains("2 nodes → ask about compost"), "{}", h.status());
h.type_keys("<CR>");
assert_eq!(h.message(), "linked 2 nodes → ask about compost");
assert!(h.link_between("Soil", "ask about compost").is_some());
assert!(h.link_between("Raised beds", "ask about compost").is_some());
assert_eq!(h.cursor(), "Raised beds", "back where C was typed");
}
#[test]
fn connect_mode_aims_with_the_mouse_and_works_in_the_outline() {
let mut h = Harness::new();
// A click aims, without dragging anything.
h.type_keys("glC");
let watering = h.find("Watering");
let pos = h.pos("Watering");
h.click(watering);
assert_eq!((h.label(), h.cursor().as_str()), ("CONNECT", "Watering"));
assert_eq!(h.pos("Watering"), pos);
h.type_keys("<Esc>");
// In the outline: k goes up to the parent, and <Space>l styles the branch.
h.type_keys("<Space>vo");
h.type_keys("Ck");
assert!(h.status().contains("Soil → Garden plans · its parent"), "{}", h.status());
h.type_keys("<Space>l<CR>");
let look = h.doc().line_look(LineRef::Branch(h.node("Soil"))).cloned().unwrap();
assert_eq!(look.line, Some(Stroke::Dashed), "a branch starts solid: dashed is next");
assert_eq!((h.label(), h.cursor().as_str()), ("NORMAL", "Soil"));
}
+192
View File
@@ -0,0 +1,192 @@
//! Portals (part 8d), end to end: what a portal shows, going through one
//! and back, `gu` up to the portals that lead here, `:portal`, backlinks
//! kept in step, and file names that follow titles.
use super::*;
impl Harness {
/// The status bar's first line.
fn status_line(&self) -> String {
let rows = self.rows();
rows[rows.len() - 2].clone()
}
/// The row of the screen where `text` is.
fn row_of(&self, text: &str) -> String {
let (_, row) = self.find(text);
self.rows()[usize::from(row)].clone()
}
}
/// The garden on its own, with the kitchen's file renamed to `pantry.cum`
/// behind its portal's back.
fn garden_with_the_kitchen_moved() -> Harness {
let folder = TempDir::new();
std::fs::write(folder.path().join("garden.cum"), GARDEN).unwrap();
std::fs::write(folder.path().join("pantry.cum"), KITCHEN).unwrap();
Harness::open(folder, &["garden.cum"])
}
#[test]
fn a_portal_shows_its_document_live() {
let mut h = Harness::new();
let (col, row) = h.find("» Kitchen");
// The kitchen's color (its root's), and how many nodes it has.
assert_eq!(h.color_at((col - 2, row)), ratatui::style::Color::Indexed(178), "yellow, as Kitchen is");
assert!(h.rows()[usize::from(row) + 1].contains(" 5 "), "{}", h.screen());
// A node added in the kitchen counts at once.
h.type_keys("gtaPans<CR><Esc><Esc>gT");
let (_, row) = h.find("» Kitchen");
assert!(h.rows()[usize::from(row) + 1].contains(" 6 "), "{}", h.screen());
// So does a new title.
h.type_keys("gt:rename Cooking<CR>gT");
assert!(h.screen().contains("» Cooking"), "{}", h.screen());
}
#[test]
fn enter_goes_through_a_portal_and_ctrl_o_comes_back() {
let mut h = Harness::new();
h.type_keys("glgjgjgj");
assert_eq!(h.cursor(), "Kitchen");
h.type_keys("<CR>");
assert_eq!(h.doc().title().as_str(), "Kitchen");
assert_eq!(h.cursor(), "Kitchen", "on its root");
assert!(h.status_line().contains("Garden plans » Kitchen"), "the way here: {}", h.status_line());
h.type_keys("gl");
assert!(h.status_line().contains("Garden plans » Kitchen › Herbs on the sill"), "{}", h.status_line());
// Ctrl-o: back to the portal, and the breadcrumb is the garden's again.
h.type_keys("<C-o>");
assert_eq!((h.doc().title().as_str(), h.cursor().as_str()), ("Garden plans", "Kitchen"));
assert!(h.status_line().contains(" Garden plans › Kitchen"), "{}", h.status_line());
h.type_keys("gf");
assert_eq!(h.doc().title().as_str(), "Kitchen", "gf goes through too");
// The Note view says where a portal leads.
h.type_keys("<C-o><Space>vn");
assert!(h.screen().contains("a portal to kitchen.cum · 5 nodes · Enter goes there"), "{}", h.screen());
}
#[test]
fn gu_goes_up_to_the_portals_that_lead_here() {
let mut h = Harness::new();
h.type_keys("gt");
assert_eq!(h.doc().title().as_str(), "Kitchen");
// The root counts the portals that lead to its document.
assert!(h.row_of("Kitchen").contains("Kitchen") && h.screen().contains("« 1"), "{}", h.screen());
h.type_keys("glgu");
assert_eq!((h.doc().title().as_str(), h.cursor().as_str()), ("Garden plans", "Kitchen"), "on the portal");
// :backlinks lists them, even just one.
h.type_keys("<C-o>:backlinks<CR>");
assert!(h.screen().contains("portals that lead here"), "{}", h.screen());
assert!(h.screen().contains("« Garden plans garden.cum"), "{}", h.screen());
h.type_keys("<CR>");
assert_eq!(h.cursor(), "Kitchen");
// In the Note view, the root lists them as parts (after the title, the
// tags, the body and two children): Enter goes there.
h.type_keys("gtgg<Space>vn");
assert!(h.screen().contains("Portals here"), "{}", h.screen());
h.type_keys("jjjjj<CR>");
assert_eq!((h.doc().title().as_str(), h.cursor().as_str()), ("Garden plans", "Kitchen"));
// A document no portal leads to says so.
h.type_keys(":new Shopping list<CR>gu");
assert_eq!(h.message(), "no portal leads here yet: :portal in another document makes one");
}
#[test]
fn portal_makes_a_new_document_or_finds_one() {
let mut h = Harness::new();
h.type_keys("<Space>p");
assert_eq!(h.label(), "COMMAND");
assert!(h.rows().last().unwrap().starts_with(":portal "), "{}", h.screen());
h.type_keys("Compost bins<CR>");
assert_eq!(h.message(), "made compost-bins.cum, with a portal to it: Enter goes there");
assert_eq!(h.cursor(), "Compost bins");
assert_eq!(h.parent("Compost bins"), "Garden plans", "under the cursor's node, where a adds one");
assert!(h.file("compost-bins.cum").contains("title: \"Compost bins\""));
// Saved, the portal puts a backlink in its document.
h.wait(1);
let made = h.file("compost-bins.cum");
assert!(made.contains("from: \"garden.cum\""), "{made}");
// One that's there already, by its file's name or its title.
h.type_keys("gg:portal kitchen<CR>");
assert_eq!(h.message(), "a portal to kitchen.cum: Enter goes there");
h.type_keys("gg:portal Compost bins<CR>");
assert_eq!(h.message(), "a portal to compost-bins.cum: Enter goes there");
h.type_keys("gg:portal garden<CR>");
assert_eq!(h.message(), "a portal can't lead to its own document");
}
#[test]
fn backlinks_follow_the_portals_as_they_come_and_go() {
let mut h = Harness::new();
// The kitchen portal goes: once the garden is saved, the kitchen
// forgets it.
h.type_keys("glgjgjgjdd");
h.wait(1);
assert!(!h.file("kitchen.cum").contains("backlinks"), "{}", h.file("kitchen.cum"));
h.type_keys("gt");
assert!(!h.screen().contains("« 1"), "{}", h.screen());
// Back again: so is the backlink, with the portal's node.
h.type_keys("gTu");
h.wait(1);
assert!(h.file("kitchen.cum").contains("node: 10"), "{}", h.file("kitchen.cum"));
}
#[test]
fn a_new_title_renames_the_file_and_its_portals_follow() {
let mut h = Harness::new();
h.type_keys("gt:rename Cooking<CR>");
h.wait(1);
assert_eq!(h.message(), "kitchen.cum is cooking.cum now: a file's name follows its title");
assert!(!h.path("kitchen.cum").exists());
assert!(h.file("cooking.cum").contains("title: \"Cooking\""));
let garden = h.file("garden.cum");
assert!(garden.contains("path: \"cooking.cum\"") && garden.contains("title: \"Cooking\""), "{garden}");
// The portal still goes there.
h.type_keys("gTglgjgjgj<CR>");
assert_eq!(h.doc().title().as_str(), "Cooking");
// A name that's taken gets a number.
h.type_keys("gT:rename Cooking<CR>");
h.wait(1);
assert_eq!(h.message(), "garden.cum is cooking-2.cum now: a file's name follows its title");
assert!(h.file("cooking.cum").contains("from: \"cooking-2.cum\""), "the backlink follows it too");
// A folder's default document keeps its name.
h.type_keys(":new<CR>:rename Everything<CR>");
h.wait(1);
assert!(h.path("ideas.cum").exists() && !h.path("everything.cum").exists());
}
#[test]
fn a_portal_finds_its_document_moved_by_hand() {
let mut h = garden_with_the_kitchen_moved();
let (_, row) = h.find("» Kitchen");
assert!(h.rows()[usize::from(row) + 1].contains(" ? "), "not where it says: {}", h.screen());
h.type_keys("glgjgjgj<CR>");
assert_eq!(h.doc().title().as_str(), "Kitchen", "found in its folder by its uid");
assert!(h.file("garden.cum").contains("path: \"pantry.cum\""), "and the portal keeps that path");
// Gone for good: Enter says so.
let mut h = garden_with_the_kitchen_moved();
std::fs::remove_file(h.path("pantry.cum")).unwrap();
h.type_keys("glgjgjgj<CR>");
assert_eq!(h.message(), "kitchen.cum isn't there anymore");
assert_eq!(h.doc().title().as_str(), "Garden plans");
}
#[test]
fn a_portal_pasted_into_another_folder_still_leads_there() {
let mut h = Harness::new();
h.type_keys("glgjgjgjyy:e rooms/attic.cum<CR>p");
let path = h.doc().portals().next().map(|(_, portal)| portal.path.as_str().to_string());
assert_eq!(path.as_deref(), Some("../kitchen.cum"), "from the attic's own folder");
h.type_keys("<CR>");
assert_eq!(h.doc().title().as_str(), "Kitchen");
}
#[test]
fn w_says_when_the_file_takes_a_new_name() {
let mut h = Harness::new();
h.type_keys("gt:rename Pantry<CR>:w<CR>");
assert_eq!(
h.message(),
"saved pantry.cum · kitchen.cum is pantry.cum now: a file's name follows its title"
);
}
+9
View File
@@ -1,9 +1,12 @@
//! Editing a node's text: rapid entry, empty titles, `o` inside a title,
//! bodies and tags, and the document following the text.
use std::path::Path;
use super::*;
use crate::app::buffer::Buffer;
use crate::app::mode::Mode;
use crate::app::portals::Portals;
use crate::input::bindings::Context;
use crate::input::engine::{KeyEngine, Outcome};
use crate::input::key::parse_keys;
@@ -46,12 +49,15 @@ impl App {
fn press(&mut self, keys: &str) {
for key in parse_keys(keys, DEFAULT_LEADER).unwrap() {
let normal = Mode::Normal;
let portals = Portals::default();
let ctx = ViewContext {
doc: self.buffer.doc(),
cursor: self.buffer.cursor(),
mode: &normal,
theme: &self.theme,
settings: &self.settings,
portals: &portals,
folder: Path::new("."),
};
let actions = match &mut self.mode {
Mode::Text(edit) if edit.editor.is_typing() => {
@@ -79,12 +85,15 @@ impl App {
}
Action::App(AppAction::StartEdit(start)) => {
let normal = Mode::Normal;
let portals = Portals::default();
let ctx = ViewContext {
doc: self.buffer.doc(),
cursor: self.buffer.cursor(),
mode: &normal,
theme: &self.theme,
settings: &self.settings,
portals: &portals,
folder: Path::new("."),
};
match TextEdit::start(&ctx, start) {
Ok(edit) => self.mode = Mode::Text(edit),
+24 -5
View File
@@ -7,9 +7,9 @@ use ratatui::layout::Position;
use super::action::{Action, AppAction, DocAction, DocumentChoice, Message, Tone};
use super::documents::Trouble;
use super::mode::{Grab, Mode, Visual};
use super::mode::{Connect, Grab, Mode, Visual};
use super::panes::Arrange;
use super::popup::{DocumentRow, MenuFor, Popup};
use super::popup::{DocumentRow, MenuFor, PickKind, Popup};
use super::search::Search;
use super::text_edit::{StartEdit, TextEdit};
use super::{App, Pane};
@@ -113,6 +113,18 @@ impl App {
}
AppAction::ToggleSelected(id) => self.toggle_selected(id),
AppAction::SelectAlso(ids) => return self.select_also(ids),
AppAction::EnterConnectMode(nodes) => {
if let Some(refused) = self.refuse_read_only() {
return Some(refused);
}
let cursor = self.current().cursor();
let sources = if nodes.is_empty() { vec![cursor] } else { nodes };
self.shared.mode = Mode::Connect(Connect::new(sources, cursor));
}
AppAction::ResumeConnect(connect) => self.shared.mode = Mode::Connect(connect),
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::ToggleWhole => {
if let Mode::Move(grab) = &mut self.shared.mode {
grab.whole = !grab.whole;
@@ -173,6 +185,12 @@ impl App {
AppAction::OpenRegisters => self.open_registers(),
AppAction::OpenLinks => self.open_links(),
AppAction::Find(query) => return self.find_now(query),
// Connect mode's pickers style the lines it's making.
AppAction::OpenPicker(PickKind::Line(what)) => {
let Mode::Connect(connect) = &self.shared.mode else { return None };
let picker = Popup::line_picker(what, &self.shared.ctx(self.buffer()), connect);
self.shared.mode = Mode::Popup(picker);
}
AppAction::OpenPicker(kind) => {
let documents = self.document_rows();
let current = self.shared.docs.index_of(self.buffer());
@@ -263,9 +281,10 @@ impl App {
}
let buffer = pane.buffer;
let camera = pane.view.camera().or(self.shared.docs.get(buffer).camera);
// The new pane starts with a copy of this one's jump list, as in vim.
let jumps = pane.jumps.clone();
self.panes.split(arrange, Pane { jumps, ..Pane::new(buffer, kind, camera) });
// The new pane starts with a copy of this one's jump list, as in vim
// (and of the portals it came through).
let (jumps, trail) = (pane.jumps.clone(), pane.trail.clone());
self.panes.split(arrange, Pane { jumps, trail, ..Pane::new(buffer, kind, camera) });
None
}
+6 -1
View File
@@ -172,7 +172,12 @@ pub fn command(
Command::MoveMode => {
say("several nodes move on the canvas; here, > and < move them through the tree")
}
Command::ConnectMode => say("connect mode comes in part 8d of the build"),
// Every node connects to the one the cursor goes to; with `V`, each
// branch from its top.
Command::ConnectMode => {
let sources = if visual.branches { tops(selected, ctx) } else { selected.to_vec() };
app(AppAction::EnterConnectMode(sources))
}
_ => return None,
};
Some(actions)
+10 -2
View File
@@ -9,7 +9,8 @@ use super::action::{self, CursorAfter, DocAction, Message, PasteAt};
use super::commands;
use super::registers::Kept;
use crate::input::command::Register;
use crate::model::{Clip, NodeId};
use crate::model::{Clip, LinkPath, NodeId};
use crate::storage::link_paths::{relative, resolve};
impl App {
/// Keeps nodes of the focused document in a register: exactly `nodes`,
@@ -21,11 +22,15 @@ impl App {
branches: bool,
) -> Option<Message> {
let doc = self.current().doc();
let clip = if branches {
let mut clip = if branches {
Clip::branches(doc, nodes)
} else {
Clip::take(doc, &nodes.iter().copied().collect::<BTreeSet<NodeId>>())
};
// Its portals keep full paths while it's in a register: it can go
// into a document in another folder.
let folder = self.folder();
clip.repath(|path| LinkPath::parse(&resolve(&folder, path).to_string_lossy()).ok());
let count = action::nodes(clip.count());
if let Err(problem) = self.registers.store(register, Kept::Nodes(clip)) {
return Some(Message::problem(problem));
@@ -51,6 +56,9 @@ impl App {
let Some(mut clip) = kept.clip() else {
return Some(Message::info("the register's text is empty: no nodes to make"));
};
// Its portals' paths, from this document's folder.
let folder = self.folder();
clip.repath(|path| relative(&folder, &resolve(&folder, path)));
let ctx = self.shared.ctx(self.buffer());
if !clip.placed {
clip.lay_out(|node| ctx.box_size(node));