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:
1 parent
bd819409f4
commit
ebe8a5988e
45 files changed
+2329
-148
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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
@@ -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
@@ -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) -----------------------
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user