Pull the view interface, actions and modes out of the canvas example
The canvas example did everything itself; now it runs the way the app will. The key engine turns keys into commands, the focused view answers with actions, and the commands every view shares (undo, marks, adding, deleting, folds, tree moves, modes) answer the rest. A view can still give a key its own meaning, since it gets the first say. `:` lines become actions the same way (app::calls). - View trait: kind, contexts (its key tables), layout, render, command and mouse. layout comes before render because a view keeps the frame it drew, to match clicks against, and follows the cursor with its camera; drawing changes nothing. CanvasView owns the camera, the last scene and the mouse drag; it reads the rest from a ViewContext. - Edits travel as data (EditOp), so views never touch the document. Buffer::apply is the one place a document changes: the edit, its undo step, where the cursor goes, marks and `''`. - Mode holds each mode's own state: move mode carries its Grab (whole branch or not), the command line lives in Mode::Command. - Undo groups: a whole move-mode session or mouse drag is one undo step, built up as it goes. After an undo the cursor lands on a node the change added or removed, before the others it touched. - Actions run in order, and a problem stops the rest. Small improvements on the way: zz centers the middle of the node, :find says which match it's on, :unlink with no links says so, folding a node without children says why nothing happened, and a captured idea is placed next to a new Inbox even on the first :in. The example has end-to-end tests now: they type key sequences and mouse gestures into it on a test terminal, and `cargo test` runs them. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
57ba2c275d
commit
6eedbe0ce2
21 files changed
+2085
-763
No files matched your search
@@ -0,0 +1,133 @@
|
||||
//! Actions: what views, keys and the command line ask for. Views never
|
||||
//! change the document, the cursor or the mode themselves. They answer with
|
||||
//! actions, and the app carries them out: [`Buffer::apply`] for a document
|
||||
//! and its cursor, the app for the rest.
|
||||
//!
|
||||
//! [`Buffer::apply`]: super::buffer::Buffer::apply
|
||||
|
||||
use crate::command::parse::SetArg;
|
||||
use crate::model::{EditOp, Group, NodeId};
|
||||
|
||||
/// How a status-bar message is meant.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Tone {
|
||||
/// Something to know.
|
||||
Info,
|
||||
/// Something got done.
|
||||
Done,
|
||||
/// Something didn't work.
|
||||
Problem,
|
||||
}
|
||||
|
||||
/// A message for the status bar.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Message {
|
||||
pub text: String,
|
||||
pub tone: Tone,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
pub fn info(text: impl Into<String>) -> Message {
|
||||
Message { text: text.into(), tone: Tone::Info }
|
||||
}
|
||||
|
||||
pub fn done(text: impl Into<String>) -> Message {
|
||||
Message { text: text.into(), tone: Tone::Done }
|
||||
}
|
||||
|
||||
pub fn problem(text: impl Into<String>) -> Message {
|
||||
Message { text: text.into(), tone: Tone::Problem }
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the cursor goes after an edit.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CursorAfter {
|
||||
/// Where it is (or to the root, if its node is gone).
|
||||
Stay,
|
||||
/// To the node the edit added.
|
||||
Added,
|
||||
/// To this node.
|
||||
To(NodeId),
|
||||
}
|
||||
|
||||
/// What happens to a document and its cursor.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DocAction {
|
||||
/// Move the cursor. A `jump` is one that `''` can come back from.
|
||||
MoveCursor {
|
||||
to: NodeId,
|
||||
jump: bool,
|
||||
},
|
||||
/// Change the document, as one undo step (or as part of its group's).
|
||||
/// The op is boxed because it can hold whole nodes, which would make
|
||||
/// every action that size.
|
||||
Edit {
|
||||
op: Box<EditOp>,
|
||||
cursor: CursorAfter,
|
||||
group: Option<Group>,
|
||||
},
|
||||
Undo(u32),
|
||||
Redo(u32),
|
||||
SetMark(char),
|
||||
/// Go to a mark; `'` and `` ` `` go back to before the last jump.
|
||||
JumpToMark(char),
|
||||
}
|
||||
|
||||
/// What happens around the documents.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AppAction {
|
||||
/// Move mode: hjkl moves the cursor's branch.
|
||||
EnterMoveMode,
|
||||
/// In move mode: the whole branch, or just the node.
|
||||
ToggleWhole,
|
||||
/// Back to normal mode.
|
||||
LeaveMode,
|
||||
OpenCommandLine,
|
||||
/// Focus mode: Open branches act like Auto ones.
|
||||
ToggleFocus,
|
||||
ToggleWhichKey,
|
||||
/// Change a setting.
|
||||
Set(SetArg),
|
||||
Quit,
|
||||
Say(Message),
|
||||
}
|
||||
|
||||
/// Anything views, keys and the command line ask for.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
Doc(DocAction),
|
||||
App(AppAction),
|
||||
}
|
||||
|
||||
impl Action {
|
||||
/// Move the cursor to a node.
|
||||
pub fn cursor(to: NodeId) -> Action {
|
||||
Action::Doc(DocAction::MoveCursor { to, jump: false })
|
||||
}
|
||||
|
||||
/// Jump to a node: `''` comes back.
|
||||
pub fn jump(to: NodeId) -> Action {
|
||||
Action::Doc(DocAction::MoveCursor { to, jump: true })
|
||||
}
|
||||
|
||||
/// Change the document, as one undo step; the cursor stays.
|
||||
pub fn edit(op: EditOp) -> Action {
|
||||
Action::Doc(DocAction::Edit { op: Box::new(op), cursor: CursorAfter::Stay, group: None })
|
||||
}
|
||||
|
||||
/// Change the document, as one undo step, then move the cursor.
|
||||
pub fn edit_then(op: EditOp, cursor: CursorAfter) -> Action {
|
||||
Action::Doc(DocAction::Edit { op: Box::new(op), cursor, group: None })
|
||||
}
|
||||
|
||||
/// Change the document as part of a group's undo step.
|
||||
pub fn edit_in(op: EditOp, group: Group) -> Action {
|
||||
Action::Doc(DocAction::Edit { op: Box::new(op), cursor: CursorAfter::Stay, group: Some(group) })
|
||||
}
|
||||
|
||||
/// Show a message.
|
||||
pub fn say(message: Message) -> Action {
|
||||
Action::App(AppAction::Say(message))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! An open document: the document, its undo history, its cursor and its
|
||||
//! marks. [`Buffer::apply`] is the one place where a document changes.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::action::{CursorAfter, DocAction, Message};
|
||||
use crate::model::{Applied, Document, History, NodeId};
|
||||
|
||||
/// An open document, with what goes with it while it's open.
|
||||
#[derive(Debug)]
|
||||
pub struct Buffer {
|
||||
doc: Document,
|
||||
history: History,
|
||||
cursor: NodeId,
|
||||
marks: BTreeMap<char, NodeId>,
|
||||
/// Where the cursor was before the last jump, for `''`.
|
||||
before_jump: Option<NodeId>,
|
||||
}
|
||||
|
||||
impl Buffer {
|
||||
/// A buffer for a document, with the cursor on its root.
|
||||
pub fn new(doc: Document) -> Buffer {
|
||||
Buffer {
|
||||
cursor: doc.root(),
|
||||
doc,
|
||||
history: History::default(),
|
||||
marks: BTreeMap::new(),
|
||||
before_jump: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn doc(&self) -> &Document {
|
||||
&self.doc
|
||||
}
|
||||
|
||||
/// The node the cursor is on (always one that exists).
|
||||
pub fn cursor(&self) -> NodeId {
|
||||
self.cursor
|
||||
}
|
||||
|
||||
pub fn history(&self) -> &History {
|
||||
&self.history
|
||||
}
|
||||
|
||||
/// Carries out an action on the document or its cursor, and says how it
|
||||
/// went when there's something to say.
|
||||
pub fn apply(&mut self, action: DocAction) -> Option<Message> {
|
||||
match action {
|
||||
DocAction::MoveCursor { to, jump } => self.move_cursor(to, jump),
|
||||
DocAction::Edit { op, cursor, group } => {
|
||||
let (applied, change) = match self.doc.apply(&op) {
|
||||
Ok(done) => done,
|
||||
Err(error) => return Some(Message::problem(error.to_string())),
|
||||
};
|
||||
match group {
|
||||
Some(group) => self.history.record_in(change, group),
|
||||
None => self.history.record(change),
|
||||
}
|
||||
self.cursor = match (cursor, applied) {
|
||||
(CursorAfter::Added, Applied::Added(id)) | (CursorAfter::To(id), _) => id,
|
||||
_ => self.cursor,
|
||||
};
|
||||
self.keep_cursor();
|
||||
match applied {
|
||||
Applied::Removed(count) => {
|
||||
Some(Message::done(format!("deleted {} · u to undo", nodes(count))))
|
||||
}
|
||||
Applied::Added(_) | Applied::Changed => None,
|
||||
}
|
||||
}
|
||||
DocAction::Undo(times) => self.undo_or_redo(times, true),
|
||||
DocAction::Redo(times) => self.undo_or_redo(times, false),
|
||||
DocAction::SetMark(name) => {
|
||||
self.marks.insert(name, self.cursor);
|
||||
let title =
|
||||
self.doc.node(self.cursor).map(|node| node.title().to_string()).unwrap_or_default();
|
||||
Some(Message::done(format!("mark {name} is on \"{title}\": '{name} comes back here")))
|
||||
}
|
||||
DocAction::JumpToMark('\'' | '`') => match self.before_jump {
|
||||
Some(id) if self.doc.contains(id) => self.move_cursor(id, true),
|
||||
_ => Some(Message::info("no jump to go back from yet")),
|
||||
},
|
||||
DocAction::JumpToMark(name) => match self.marks.get(&name) {
|
||||
Some(&id) if self.doc.contains(id) => self.move_cursor(id, true),
|
||||
Some(_) => Some(Message::problem(format!("the node marked {name} is gone"))),
|
||||
None => Some(Message::info(format!("no mark {name} yet: m{name} sets it"))),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn move_cursor(&mut self, to: NodeId, jump: bool) -> Option<Message> {
|
||||
if !self.doc.contains(to) {
|
||||
return Some(Message::problem("that node is gone"));
|
||||
}
|
||||
if jump && to != self.cursor {
|
||||
self.before_jump = Some(self.cursor);
|
||||
}
|
||||
self.cursor = to;
|
||||
None
|
||||
}
|
||||
|
||||
/// Undoes (or redoes) up to `times` changes; the cursor goes where the
|
||||
/// last one happened.
|
||||
fn undo_or_redo(&mut self, times: u32, undo: bool) -> Option<Message> {
|
||||
let mut landed = None;
|
||||
for _ in 0..times {
|
||||
let change =
|
||||
if undo { self.history.undo(&mut self.doc) } else { self.history.redo(&mut self.doc) };
|
||||
let Some(change) = change else { break };
|
||||
landed = Some(change.nodes().find(|id| self.doc.contains(*id)));
|
||||
}
|
||||
match landed {
|
||||
None => Some(Message::info(if undo { "nothing to undo" } else { "nothing to redo" })),
|
||||
Some(target) => {
|
||||
self.cursor = target.unwrap_or(self.cursor);
|
||||
self.keep_cursor();
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The cursor's node may be gone after an edit or an undo: then it goes
|
||||
/// to the root.
|
||||
fn keep_cursor(&mut self) {
|
||||
if !self.doc.contains(self.cursor) {
|
||||
self.cursor = self.doc.root();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "1 node", "3 nodes".
|
||||
fn nodes(count: usize) -> String {
|
||||
if count == 1 { "1 node".to_string() } else { format!("{count} nodes") }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::test_support::{garden, idea};
|
||||
use crate::model::{EditOp, Spot};
|
||||
|
||||
fn edit(op: EditOp, cursor: CursorAfter) -> DocAction {
|
||||
DocAction::Edit { op: Box::new(op), cursor, group: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_move_the_cursor_and_undo_brings_it_back() {
|
||||
let (doc, g) = garden();
|
||||
let mut buffer = Buffer::new(doc);
|
||||
buffer.apply(edit(
|
||||
EditOp::Add { node: idea("new"), spot: Spot::LastChildOf(g.soil) },
|
||||
CursorAfter::Added,
|
||||
));
|
||||
let added = buffer.cursor();
|
||||
assert_eq!(buffer.doc().parent(added), Some(g.soil));
|
||||
let message = buffer.apply(edit(EditOp::RemoveBranches(vec![g.soil]), CursorAfter::To(g.beds)));
|
||||
assert_eq!(message, Some(Message::done("deleted 2 nodes · u to undo")));
|
||||
assert_eq!(buffer.cursor(), g.beds);
|
||||
buffer.apply(DocAction::Undo(1));
|
||||
assert_eq!(buffer.cursor(), g.soil, "back where the change was");
|
||||
assert_eq!(buffer.apply(DocAction::Undo(5)), None);
|
||||
assert_eq!(buffer.apply(DocAction::Undo(1)), Some(Message::info("nothing to undo")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_and_jumping_back() {
|
||||
let (doc, g) = garden();
|
||||
let mut buffer = Buffer::new(doc);
|
||||
buffer.apply(DocAction::MoveCursor { to: g.cedar, jump: false });
|
||||
buffer.apply(DocAction::SetMark('a'));
|
||||
buffer.apply(DocAction::MoveCursor { to: g.compost, jump: true });
|
||||
buffer.apply(DocAction::JumpToMark('a'));
|
||||
assert_eq!(buffer.cursor(), g.cedar);
|
||||
buffer.apply(DocAction::JumpToMark('\''));
|
||||
assert_eq!(buffer.cursor(), g.compost, "'' goes back to before the jump");
|
||||
assert_eq!(
|
||||
buffer.apply(DocAction::JumpToMark('z')),
|
||||
Some(Message::info("no mark z yet: mz sets it"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cursor_never_rests_on_a_deleted_node() {
|
||||
let (doc, g) = garden();
|
||||
let mut buffer = Buffer::new(doc);
|
||||
buffer.apply(DocAction::MoveCursor { to: g.cedar, jump: false });
|
||||
buffer.apply(edit(EditOp::RemoveBranches(vec![g.beds]), CursorAfter::Stay));
|
||||
assert_eq!(buffer.cursor(), buffer.doc().root());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//! What `:` commands do: [`actions_for`] turns a parsed [`Call`] into
|
||||
//! actions, the way views turn keys into actions.
|
||||
|
||||
use super::action::{Action, AppAction, CursorAfter, Message};
|
||||
use super::commands::set_fold;
|
||||
use crate::command::parse::{Call, TagChange};
|
||||
use crate::model::{CanvasPos, CanvasRect, EditOp, Inbox, Link, LinkKey, Node, NodeId, Title};
|
||||
use crate::view::ViewContext;
|
||||
use crate::view::canvas::placement::{child_spot, first_child_spot, free_spot};
|
||||
|
||||
/// The title of the loose node `:in` collects ideas under.
|
||||
const INBOX: &str = "Inbox";
|
||||
|
||||
/// The actions a command line asks for. Commands from later stages of the
|
||||
/// build say so.
|
||||
pub fn actions_for(call: Call, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
let id = ctx.cursor;
|
||||
match call {
|
||||
Call::Quit { .. } | Call::QuitAll { .. } | Call::WriteQuit | Call::ClosePane => {
|
||||
vec![Action::App(AppAction::Quit)]
|
||||
}
|
||||
Call::Fold(fold) => set_fold(ctx, fold),
|
||||
Call::Focus => vec![Action::App(AppAction::ToggleFocus)],
|
||||
Call::Color(color) => vec![Action::edit(EditOp::SetColor { id, color })],
|
||||
Call::Border(border) => vec![Action::edit(EditOp::SetBorder { id, border: Some(border) })],
|
||||
Call::Tags(changes) => {
|
||||
let (mut add, mut remove) = (Vec::new(), Vec::new());
|
||||
for change in changes {
|
||||
match change {
|
||||
TagChange::Add(tag) => add.push(tag),
|
||||
TagChange::Remove(tag) => remove.push(tag),
|
||||
}
|
||||
}
|
||||
vec![Action::edit(EditOp::Tag { id, add, remove })]
|
||||
}
|
||||
// A document's name is its root's title.
|
||||
Call::Rename(title) => vec![Action::edit(EditOp::SetTitle { id: ctx.doc.root(), title })],
|
||||
Call::Capture(text) => capture(ctx, &text),
|
||||
Call::Link(target) => match Link::new(id, target) {
|
||||
Some(link) => vec![Action::edit(EditOp::Link(link))],
|
||||
None => say(Message::problem("a node can't link to itself")),
|
||||
},
|
||||
Call::Unlink(target) => unlink(ctx, target),
|
||||
Call::Find(text) => find(ctx, &text),
|
||||
Call::Set(arg) => vec![Action::App(AppAction::Set(arg))],
|
||||
Call::Version => say(Message::info(crate::version())),
|
||||
Call::Help(_) => later("the help screen comes in stage 7; Tab completes any command"),
|
||||
Call::Split(_) | Call::VSplit(_) | Call::Only | Call::Show(_) => {
|
||||
later("splits and the other views come in stage 7")
|
||||
}
|
||||
Call::Edit { .. }
|
||||
| Call::New(_)
|
||||
| Call::Write
|
||||
| Call::WriteAll
|
||||
| Call::Buffers
|
||||
| Call::Buffer(_)
|
||||
| Call::CloseDocument { .. }
|
||||
| Call::Portal(_)
|
||||
| Call::Backlinks => later("documents, files and portals come in stage 8"),
|
||||
Call::Jumps | Call::Marks | Call::Registers => {
|
||||
later("the lists of jumps, marks and registers come in stage 8")
|
||||
}
|
||||
Call::Style(_) | Call::Theme(_) | Call::Config => later("theme and config files come in stage 8"),
|
||||
Call::Board(_) | Call::Tidy(_) | Call::Align(_) | Call::BranchOut | Call::Export { .. } => {
|
||||
later("this one comes in v1.1")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn say(message: Message) -> Vec<Action> {
|
||||
vec![Action::say(message)]
|
||||
}
|
||||
|
||||
fn later(text: &str) -> Vec<Action> {
|
||||
say(Message::info(text))
|
||||
}
|
||||
|
||||
/// `:in`: adds an idea under the Inbox, a loose node made the first time.
|
||||
/// The cursor stays where it is: capturing shouldn't break your flow.
|
||||
fn capture(ctx: &ViewContext<'_>, text: &str) -> Vec<Action> {
|
||||
let doc = ctx.doc;
|
||||
let title = match Title::parse(text) {
|
||||
Ok(title) => title,
|
||||
Err(error) => return say(Message::problem(error.to_string())),
|
||||
};
|
||||
let idea = Node::idea(title, CanvasPos::default());
|
||||
let (size, rects) = (ctx.box_size(&idea), ctx.box_rects());
|
||||
let existing = doc
|
||||
.loose()
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&id| doc.node(id).is_some_and(|node| node.title().as_str() == INBOX));
|
||||
let (inbox, pos) = match existing {
|
||||
Some(inbox) => (Inbox::Existing(inbox), child_spot(doc, inbox, size, &rects)),
|
||||
None => {
|
||||
let Ok(title) = Title::parse(INBOX) else { return Vec::new() };
|
||||
let inbox = Node::idea(title, CanvasPos::default());
|
||||
let inbox_size = ctx.box_size(&inbox);
|
||||
// Below everything else, at the left.
|
||||
let below = rects.values().map(|rect| rect.bottom()).max().unwrap_or(0) + 2;
|
||||
let inbox_pos = free_spot(CanvasPos::new(0, below), inbox_size, &rects);
|
||||
let pos = first_child_spot(CanvasRect::new(inbox_pos, inbox_size), size, &rects);
|
||||
(Inbox::New(Node { pos: inbox_pos, ..inbox }), pos)
|
||||
}
|
||||
};
|
||||
vec![
|
||||
Action::edit_then(EditOp::Capture { idea: Node { pos, ..idea }, inbox }, CursorAfter::Stay),
|
||||
Action::say(Message::done(format!("captured \"{text}\" to the Inbox"))),
|
||||
]
|
||||
}
|
||||
|
||||
/// `:unlink`: the link to a node, or every link of the cursor's node.
|
||||
fn unlink(ctx: &ViewContext<'_>, target: Option<NodeId>) -> Vec<Action> {
|
||||
let keys: Vec<LinkKey> = match target {
|
||||
Some(target) => LinkKey::new(ctx.cursor, target).into_iter().collect(),
|
||||
None => ctx.doc.links_of(ctx.cursor).map(Link::key).collect(),
|
||||
};
|
||||
if keys.is_empty() {
|
||||
return say(Message::info("this node has no links to remove"));
|
||||
}
|
||||
vec![Action::edit(EditOp::Unlink(keys))]
|
||||
}
|
||||
|
||||
/// `:find`: jumps to the next node whose title has the text in it, in
|
||||
/// reading order, starting over at the top after the last.
|
||||
fn find(ctx: &ViewContext<'_>, text: &str) -> Vec<Action> {
|
||||
let doc = ctx.doc;
|
||||
let wanted = text.to_lowercase();
|
||||
let order = doc.outline();
|
||||
let matches: Vec<usize> = (0..order.len())
|
||||
.filter(|&index| {
|
||||
doc.node(order[index]).is_some_and(|node| node.title().as_str().to_lowercase().contains(&wanted))
|
||||
})
|
||||
.collect();
|
||||
let here = order.iter().position(|&id| id == ctx.cursor).unwrap_or(0);
|
||||
let Some(nth) = matches.iter().position(|&index| index > here).or((!matches.is_empty()).then_some(0))
|
||||
else {
|
||||
return say(Message::problem(format!("nothing matches \"{text}\"")));
|
||||
};
|
||||
vec![
|
||||
Action::jump(order[matches[nth]]),
|
||||
Action::say(Message::info(format!("\"{text}\": match {} of {}", nth + 1, matches.len()))),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::buffer::Buffer;
|
||||
use crate::model::test_support::garden;
|
||||
use crate::view::test_support::Setup;
|
||||
|
||||
/// Runs a command line's actions on a buffer, as the app does.
|
||||
fn run(buffer: &mut Buffer, setup: &Setup, call: Call) {
|
||||
for action in actions_for(call, &setup.ctx(buffer.doc(), buffer.cursor())) {
|
||||
if let Action::Doc(action) = action {
|
||||
buffer.apply(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_fills_one_inbox_without_overlaps() {
|
||||
let ((doc, g), setup) = (garden(), Setup::default());
|
||||
let mut buffer = Buffer::new(doc);
|
||||
run(&mut buffer, &setup, Call::Capture("mulch".into()));
|
||||
run(&mut buffer, &setup, Call::Capture("worms".into()));
|
||||
let doc = buffer.doc();
|
||||
let inboxes: Vec<NodeId> = doc
|
||||
.loose()
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&id| doc.node(id).unwrap().title().as_str() == INBOX)
|
||||
.collect();
|
||||
assert_eq!(inboxes.len(), 1);
|
||||
assert_eq!(doc.children(inboxes[0]).len(), 2);
|
||||
assert_eq!(buffer.cursor(), g.root, "the cursor stays");
|
||||
let rects = setup.ctx(doc, g.root).box_rects();
|
||||
for (a, rect) in &rects {
|
||||
assert!(rects.iter().all(|(b, other)| a == b || !rect.overlaps(*other)), "{a:?} overlaps");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_goes_round_the_matches() {
|
||||
let ((doc, g), setup) = (garden(), Setup::default());
|
||||
let mut buffer = Buffer::new(doc);
|
||||
// "Raised beds" and "Cedar" both have "ed" in them.
|
||||
run(&mut buffer, &setup, Call::Find("ED".into()));
|
||||
assert_eq!(buffer.cursor(), g.beds);
|
||||
run(&mut buffer, &setup, Call::Find("ed".into()));
|
||||
assert_eq!(buffer.cursor(), g.cedar);
|
||||
run(&mut buffer, &setup, Call::Find("ed".into()));
|
||||
assert_eq!(buffer.cursor(), g.beds, "back to the first");
|
||||
let nothing = actions_for(Call::Find("kale".into()), &setup.ctx(buffer.doc(), buffer.cursor()));
|
||||
assert_eq!(nothing, say(Message::problem("nothing matches \"kale\"")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Key commands that mean the same in every view: undo, marks, modes,
|
||||
//! adding and deleting nodes, folds, moving through the tree…
|
||||
//!
|
||||
//! The focused view gets each command first, so a view can give a key its
|
||||
//! own meaning (in the Note view, `dd` will delete a line of text).
|
||||
//! [`shared`] answers the ones it leaves.
|
||||
|
||||
use super::action::{Action, AppAction, CursorAfter, DocAction, Message};
|
||||
use crate::input::command::{Command, Motion, Operator, Target};
|
||||
use crate::input::engine::Typed;
|
||||
use crate::model::{CanvasPos, EditOp, Fold, Node, NodeId, Spot, Title, Visible};
|
||||
use crate::view::ViewContext;
|
||||
use crate::view::canvas::placement::{below_spot, child_spot};
|
||||
|
||||
/// The actions for a command every view shares, or `None` when it isn't
|
||||
/// one.
|
||||
pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
|
||||
let times = typed.times();
|
||||
let cursor = ctx.cursor;
|
||||
let actions = match typed.command {
|
||||
Command::Move(motion) => return tree_motion(motion, times, ctx),
|
||||
Command::Undo => vec![Action::Doc(DocAction::Undo(times))],
|
||||
Command::Redo => vec![Action::Doc(DocAction::Redo(times))],
|
||||
Command::SetMark(name) => vec![Action::Doc(DocAction::SetMark(name))],
|
||||
Command::JumpToMark(name) => vec![Action::Doc(DocAction::JumpToMark(name))],
|
||||
Command::AddChild => add(ctx, Spot::LastChildOf(cursor)),
|
||||
Command::AddBelow => add(ctx, Spot::After(cursor)),
|
||||
Command::AddAbove => add(ctx, Spot::Before(cursor)),
|
||||
Command::DeleteNode => delete_node(ctx),
|
||||
Command::Operate(Operator::Delete, Target::Current | Target::Branch) => delete_branches(ctx, times),
|
||||
Command::Operate(Operator::Delete, Target::Children) => delete_children(ctx),
|
||||
Command::ToggleFold => toggle_fold(ctx),
|
||||
Command::OpenFold => set_fold(ctx, Fold::Open),
|
||||
Command::CloseFold => set_fold(ctx, Fold::Closed),
|
||||
Command::AutoFold => set_fold(ctx, Fold::Auto),
|
||||
Command::OpenAllFolds => fold_all(ctx, Fold::Open),
|
||||
Command::CloseAllFolds => fold_all(ctx, Fold::Closed),
|
||||
Command::MoveMode => app(AppAction::EnterMoveMode),
|
||||
Command::CommandLine => app(AppAction::OpenCommandLine),
|
||||
Command::ToggleFocus => app(AppAction::ToggleFocus),
|
||||
Command::ToggleWhichKey => app(AppAction::ToggleWhichKey),
|
||||
Command::Close => app(AppAction::Quit),
|
||||
// Esc always gets back to normal mode.
|
||||
Command::Escape => app(AppAction::LeaveMode),
|
||||
_ => return None,
|
||||
};
|
||||
Some(actions)
|
||||
}
|
||||
|
||||
/// Sets how the cursor's node folds (`zo`, `zc`, `zf`, `:fold`).
|
||||
pub fn set_fold(ctx: &ViewContext<'_>, fold: Fold) -> Vec<Action> {
|
||||
if ctx.doc.children(ctx.cursor).is_empty() {
|
||||
return say("nothing to fold: this node has no children");
|
||||
}
|
||||
let mut actions = vec![Action::edit(EditOp::SetFold { ids: vec![ctx.cursor], fold })];
|
||||
// Folding Auto changes nothing on screen until the cursor leaves.
|
||||
if fold == Fold::Auto {
|
||||
actions.extend(say("this branch folds away when the cursor leaves it"));
|
||||
}
|
||||
actions
|
||||
}
|
||||
|
||||
fn app(action: AppAction) -> Vec<Action> {
|
||||
vec![Action::App(action)]
|
||||
}
|
||||
|
||||
fn say(text: &str) -> Vec<Action> {
|
||||
vec![Action::say(Message::info(text))]
|
||||
}
|
||||
|
||||
/// Moves through the tree: `gh` `gl` `gj` `gk`, `w` `b`, `gg` `G`. A count
|
||||
/// repeats the move, each step starting where the last one landed (folds
|
||||
/// included: an Auto fold opens once the cursor is inside).
|
||||
fn tree_motion(motion: Motion, times: u32, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
|
||||
use Motion as M;
|
||||
let nowhere = match motion {
|
||||
M::Parent => "this node is at the top",
|
||||
M::FirstChild => "this node has no children",
|
||||
M::NextSibling => "no sibling after this one",
|
||||
M::PreviousSibling => "no sibling before this one",
|
||||
M::Next => "this is the last node",
|
||||
M::Previous => "this is the first node",
|
||||
// gg and G land in the same place however often they're repeated.
|
||||
M::First | M::Last => "",
|
||||
// hjkl are the view's, and the rest come in later stages.
|
||||
_ => return None,
|
||||
};
|
||||
let jump = matches!(motion, M::First | M::Last);
|
||||
let times = if jump { 1 } else { times };
|
||||
let mut at = ctx.cursor;
|
||||
for _ in 0..times {
|
||||
match tree_step(ctx, at, motion) {
|
||||
Some(next) => at = next,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let actions = if at == ctx.cursor {
|
||||
if jump { Vec::new() } else { say(nowhere) }
|
||||
} else if jump {
|
||||
// gg and G are jumps, as in vim: '' comes back from them.
|
||||
vec![Action::jump(at)]
|
||||
} else {
|
||||
vec![Action::cursor(at)]
|
||||
};
|
||||
Some(actions)
|
||||
}
|
||||
|
||||
/// Where one step of a tree motion goes from `from`.
|
||||
fn tree_step(ctx: &ViewContext<'_>, from: NodeId, motion: Motion) -> Option<NodeId> {
|
||||
let doc = ctx.doc;
|
||||
let shown = || Visible::new(doc, from, ctx.settings.focus).order;
|
||||
match motion {
|
||||
Motion::Parent => doc.parent(from),
|
||||
Motion::FirstChild => doc.children(from).first().copied(),
|
||||
Motion::NextSibling => doc.next_sibling(from),
|
||||
Motion::PreviousSibling => doc.previous_sibling(from),
|
||||
Motion::First => Some(doc.root()),
|
||||
Motion::Last => shown().last().copied(),
|
||||
Motion::Next | Motion::Previous => {
|
||||
let order = shown();
|
||||
let here = order.iter().position(|&id| id == from)?;
|
||||
let there = if motion == Motion::Next { here.checked_add(1) } else { here.checked_sub(1) };
|
||||
there.and_then(|index| order.get(index).copied())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a "new idea" node at `spot`, placed next to its parent or sibling,
|
||||
/// and moves the cursor to it.
|
||||
fn add(ctx: &ViewContext<'_>, spot: Spot) -> Vec<Action> {
|
||||
let Ok(title) = Title::parse("new idea") else { return Vec::new() };
|
||||
let node = Node::idea(title, CanvasPos::default());
|
||||
let (size, rects) = (ctx.box_size(&node), ctx.box_rects());
|
||||
let pos = match spot {
|
||||
Spot::LastChildOf(parent) => child_spot(ctx.doc, parent, size, &rects),
|
||||
_ => below_spot(ctx.cursor, size, &rects),
|
||||
};
|
||||
vec![Action::edit_then(EditOp::Add { node: Node { pos, ..node }, spot }, CursorAfter::Added)]
|
||||
}
|
||||
|
||||
/// `x`: deletes just the cursor's node; its children take its place.
|
||||
fn delete_node(ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
let doc = ctx.doc;
|
||||
let next = doc.children(ctx.cursor).first().copied().or_else(|| doc.parent(ctx.cursor));
|
||||
vec![Action::edit_then(EditOp::RemoveKeepingChildren(ctx.cursor), after(next))]
|
||||
}
|
||||
|
||||
/// `dd` or `dab`: the cursor's node and its branch. With a count, the next
|
||||
/// siblings and their branches go too, as `3dd` deletes three lines in vim.
|
||||
fn delete_branches(ctx: &ViewContext<'_>, count: u32) -> Vec<Action> {
|
||||
let doc = ctx.doc;
|
||||
let branches = doc.siblings_from(ctx.cursor, usize::try_from(count).unwrap_or(1));
|
||||
let Some(&last) = branches.last() else { return Vec::new() };
|
||||
// The cursor goes to the sibling after them, or before them, or up.
|
||||
let next = doc
|
||||
.next_sibling(last)
|
||||
.or_else(|| doc.previous_sibling(ctx.cursor))
|
||||
.or_else(|| doc.parent(ctx.cursor));
|
||||
vec![Action::edit_then(EditOp::RemoveBranches(branches), after(next))]
|
||||
}
|
||||
|
||||
/// `dib`: everything below the cursor's node.
|
||||
fn delete_children(ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
let children = ctx.doc.children(ctx.cursor).to_vec();
|
||||
if children.is_empty() {
|
||||
return say("this node has no children to delete");
|
||||
}
|
||||
vec![Action::edit(EditOp::RemoveBranches(children))]
|
||||
}
|
||||
|
||||
/// Where the cursor goes after a delete: to `next`, or the root when there's
|
||||
/// nowhere else.
|
||||
fn after(next: Option<NodeId>) -> CursorAfter {
|
||||
next.map_or(CursorAfter::Stay, CursorAfter::To)
|
||||
}
|
||||
|
||||
/// `za`: a closed branch opens; any other closes.
|
||||
fn toggle_fold(ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
let Some(node) = ctx.doc.node(ctx.cursor) else { return Vec::new() };
|
||||
set_fold(ctx, if node.fold == Fold::Closed { Fold::Open } else { Fold::Closed })
|
||||
}
|
||||
|
||||
/// `zR` and `zM`: every branch in the document.
|
||||
fn fold_all(ctx: &ViewContext<'_>, fold: Fold) -> Vec<Action> {
|
||||
let doc = ctx.doc;
|
||||
let ids = doc.outline().into_iter().filter(|&id| !doc.children(id).is_empty()).collect();
|
||||
vec![Action::edit(EditOp::SetFold { ids, fold })]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::test_support::garden;
|
||||
use crate::view::test_support::Setup;
|
||||
|
||||
fn typed(command: Command, count: Option<u32>) -> Typed {
|
||||
Typed { command, count, register: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_count_deletes_the_next_siblings_too() {
|
||||
let ((doc, g), setup) = (garden(), Setup::default());
|
||||
let dd = Command::Operate(Operator::Delete, Target::Current);
|
||||
assert_eq!(
|
||||
shared(&typed(dd, Some(2)), &setup.ctx(&doc, g.soil)),
|
||||
Some(vec![Action::edit_then(
|
||||
EditOp::RemoveBranches(vec![g.soil, g.beds]),
|
||||
CursorAfter::To(g.watering)
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_motions_repeat_and_say_when_they_cant() {
|
||||
let ((doc, g), setup) = (garden(), Setup::default());
|
||||
let ctx = setup.ctx(&doc, g.soil);
|
||||
let next_sibling = Command::Move(Motion::NextSibling);
|
||||
assert_eq!(shared(&typed(next_sibling, Some(5)), &ctx), Some(vec![Action::cursor(g.watering)]));
|
||||
assert_eq!(
|
||||
shared(&typed(Command::Move(Motion::FirstChild), None), &ctx),
|
||||
Some(say("this node has no children"))
|
||||
);
|
||||
assert_eq!(
|
||||
shared(&typed(Command::Move(Motion::First), None), &ctx),
|
||||
Some(vec![Action::jump(g.root)])
|
||||
);
|
||||
assert_eq!(shared(&typed(Command::Move(Motion::Left), None), &ctx), None, "hjkl are the view's");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! The app: everything around the views.
|
||||
//!
|
||||
//! How input becomes a change:
|
||||
//! 1. A key goes through the key engine and comes out as a command; the
|
||||
//! focused view answers it with [`action`]s, and [`commands::shared`]
|
||||
//! answers what the view leaves. A `:` command line is answered by
|
||||
//! [`calls::actions_for`] instead.
|
||||
//! 2. Document actions go to the [`buffer`] holding the document: the one
|
||||
//! place a document changes. The rest change the [`mode`] or the app.
|
||||
//!
|
||||
//! So far this is what stages 2 to 4 needed; stage 8 adds the main loop,
|
||||
//! panes, files and settings.
|
||||
|
||||
pub mod action;
|
||||
pub mod buffer;
|
||||
pub mod calls;
|
||||
pub mod commands;
|
||||
pub mod mode;
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Modes, as in vim: what keys mean depends on the mode. Each mode holds
|
||||
//! what it needs, so a mode's state can't outlive it (there's no "move mode
|
||||
//! flag" to forget to clear).
|
||||
|
||||
use crate::model::Group;
|
||||
use crate::ui::command_line::CommandLine;
|
||||
|
||||
/// The app's mode.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub enum Mode {
|
||||
/// Moving around and acting on nodes.
|
||||
#[default]
|
||||
Normal,
|
||||
/// Moving the cursor's node (or its branch) with hjkl.
|
||||
Move(Grab),
|
||||
/// Typing a `:` command.
|
||||
Command(CommandLine),
|
||||
}
|
||||
|
||||
/// Move mode's state.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Grab {
|
||||
/// The whole branch moves, or just the node.
|
||||
pub whole: bool,
|
||||
/// Makes every move one undo step.
|
||||
pub group: Group,
|
||||
}
|
||||
|
||||
impl Grab {
|
||||
/// Moving a whole branch, as move mode starts.
|
||||
pub fn new() -> Grab {
|
||||
Grab { whole: true, group: Group::new() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Grab {
|
||||
fn default() -> Grab {
|
||||
Grab::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
/// The mode's name for the status bar.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Mode::Normal => "NORMAL",
|
||||
Mode::Move(_) => "MOVE",
|
||||
Mode::Command(_) => "COMMAND",
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user