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
@@ -37,3 +37,8 @@ all = { level = "warn", priority = -1 }
|
|||||||
lto = true
|
lto = true
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
strip = true
|
strip = true
|
||||||
|
|
||||||
|
# The canvas example is the build's checkpoint: `cargo test` runs its tests too.
|
||||||
|
[[example]]
|
||||||
|
name = "canvas"
|
||||||
|
test = true
|
||||||
@@ -392,36 +392,53 @@ cumulus/
|
|||||||
└─ src/
|
└─ src/
|
||||||
├─ main.rs command-line arguments, then app::run
|
├─ main.rs command-line arguments, then app::run
|
||||||
├─ lib.rs the module list
|
├─ lib.rs the module list
|
||||||
├─ model/ ids, text, geometry, look, node, portal, link, document, edit, history, fold
|
├─ model/ ids, text, geometry, look, node, portal, link, document, edit, op (edits as
|
||||||
|
│ data), history, fold
|
||||||
├─ input/ key (vim notation), command (Command enum + names), bindings (default tables),
|
├─ input/ key (vim notation), command (Command enum + names), bindings (default tables),
|
||||||
│ keymap (+ keys.toml), engine (counts, registers, operators), which_key, mouse
|
│ keymap (+ keys.toml), engine (counts, registers, operators), mouse
|
||||||
├─ command/ registry, parse, complete, history
|
├─ command/ registry, parse, complete, history
|
||||||
├─ view/ mod.rs (the View trait), canvas/ (camera, boxes, lines, route, render, hit,
|
├─ view/ mod.rs (the View trait), canvas/ (camera, boxes, lines, route, scene, render,
|
||||||
│ navigate, placement), outline, note, board
|
│ navigate, placement, view), outline, note, board
|
||||||
├─ ui/ text_editor (+ vim normal), which_key, command_line, status_bar, document_bar,
|
├─ ui/ text_editor (+ vim normal), which_key, command_line, line_input, status_bar,
|
||||||
│ card, menu, picker, help, theme, glyphs
|
│ document_bar, card, menu, picker, help, theme, glyphs
|
||||||
├─ config/ paths, settings, keys_file, theme_file (TOML)
|
├─ config/ paths, settings, keys_file, theme_file (TOML)
|
||||||
├─ storage/ document_file (RON), atomic save, backups, lock, state, watch (change polling)
|
├─ storage/ document_file (RON), atomic save, backups, lock, state, watch (change polling)
|
||||||
└─ app/ App, mode, action, buffers, panes (split tree), jumps, marks, registers,
|
└─ app/ action, mode, buffer, commands (keys every view shares), calls (`:` commands);
|
||||||
portals (cache, backlinks, renames), update, render,
|
later App, panes (split tree), jumps, registers, portals (cache, backlinks,
|
||||||
terminal (setup, teardown, panic hook)
|
renames), update, render, terminal (setup, teardown, panic hook)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Flow:** a terminal event comes in → `input` turns the key or mouse event into
|
**Flow:** a terminal event comes in → the key engine turns keys into a typed
|
||||||
a `Command` → the focused view turns that command into `Action`s →
|
`Command` (with its count and register) → the focused view answers it with
|
||||||
`App::update` applies them (the only place data changes) → the screen is drawn
|
`Action`s, and `app::commands::shared` answers the commands the view leaves
|
||||||
from the state.
|
(undo, marks, adding, deleting, folds, modes…), so a view can give a key its own
|
||||||
|
meaning → a `:` line is parsed into a `Call` and `app::calls` answers it →
|
||||||
|
`update` carries out the actions in order, stopping at the first problem:
|
||||||
|
document actions go to the document's `Buffer` (the only place a document
|
||||||
|
changes: the edit, its undo step, the cursor and the marks), the rest change
|
||||||
|
the mode or the app → the screen is drawn from the state.
|
||||||
|
|
||||||
|
Edits travel as data (`EditOp`), so views never touch the document. A `Group`
|
||||||
|
makes several edits one undo step: a whole move-mode session, a mouse drag.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
trait View { // expected shape; stage 5 settles it from what stages 2–4 needed
|
trait View { // settled in stage 5
|
||||||
fn kind(&self) -> ViewKind;
|
fn kind(&self) -> ViewKind;
|
||||||
fn keys(&self, mode: &Mode) -> &[KeyTable]; // its tables, most specific first
|
fn contexts(&self, mode: &Mode) -> &'static [Context]; // its key tables, most specific first
|
||||||
fn command(&mut self, command: Command, ctx: &Ctx) -> Vec<Action>;
|
fn layout(&mut self, area: Rect, ctx: &ViewContext); // fit to the area, follow the cursor
|
||||||
fn mouse(&mut self, gesture: Gesture, ctx: &Ctx) -> Vec<Action>;
|
fn render(&self, frame: &mut Frame, ctx: &ViewContext); // draws the layout, changes nothing
|
||||||
fn render(&self, frame: &mut Frame, area: Rect, ctx: &Ctx); // reads state, draws, changes nothing
|
fn command(&mut self, typed: &Typed, ctx: &ViewContext) -> Option<Vec<Action>>; // None: not mine
|
||||||
|
fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext) -> Vec<Action>;
|
||||||
}
|
}
|
||||||
|
// ViewContext: the document, the cursor, the mode, the theme and the view settings.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
What changed from the first sketch: `layout` comes before `render`, because a
|
||||||
|
view keeps the frame it drew (the canvas matches clicks against it) and moves
|
||||||
|
its camera to follow the cursor, and drawing should change nothing; `command`
|
||||||
|
takes the whole `Typed` (counts matter) and returns `None` for commands it
|
||||||
|
leaves to the shared ones.
|
||||||
|
|
||||||
## Build order
|
## Build order
|
||||||
|
|
||||||
Hardest parts first, so they shape a simple interface that every view shares
|
Hardest parts first, so they shape a simple interface that every view shares
|
||||||
|
|||||||
@@ -60,4 +60,5 @@ While the app is being built, the examples try out each part:
|
|||||||
```sh
|
```sh
|
||||||
cargo run --example canvas # the canvas, vim keys, which-key, :commands
|
cargo run --example canvas # the canvas, vim keys, which-key, :commands
|
||||||
cargo test show_canvas -- --ignored --nocapture # print it at each zoom, as text
|
cargo test show_canvas -- --ignored --nocapture # print it at each zoom, as text
|
||||||
|
cargo test --example canvas # type key sequences into it, and check what they do
|
||||||
```
|
```
|
||||||
+408
-696
File diff suppressed because it is too large.
Load diff
@@ -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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,9 @@
|
|||||||
//! | [`storage`] | files: documents (RON) |
|
//! | [`storage`] | files: documents (RON) |
|
||||||
//! | [`view`] | the views, one module each: so far, the canvas |
|
//! | [`view`] | the views, one module each: so far, the canvas |
|
||||||
//! | [`ui`] | what views share: glyphs, theme, which-key, the command line |
|
//! | [`ui`] | what views share: glyphs, theme, which-key, the command line |
|
||||||
|
//! | [`app`] | around the views: actions, modes, buffers, shared commands |
|
||||||
|
|
||||||
|
pub mod app;
|
||||||
pub mod command;
|
pub mod command;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
|
|||||||
+9
-2
@@ -79,9 +79,16 @@ impl Change {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The nodes this change touched, so undo and redo can show where it
|
/// The nodes this change touched, so undo and redo can show where it
|
||||||
/// happened.
|
/// happened: the nodes it added or removed first, then the others (like
|
||||||
|
/// a parent whose list of children changed).
|
||||||
pub fn nodes(&self) -> impl Iterator<Item = NodeId> + '_ {
|
pub fn nodes(&self) -> impl Iterator<Item = NodeId> + '_ {
|
||||||
self.before.entries.keys().copied()
|
let came_or_went = |id: &NodeId| {
|
||||||
|
let existed = |side: &Snapshot| side.entries.get(id).is_some_and(Option::is_some);
|
||||||
|
existed(&self.before) != existed(&self.after)
|
||||||
|
};
|
||||||
|
let (first, rest): (Vec<NodeId>, Vec<NodeId>) =
|
||||||
|
self.before.entries.keys().copied().partition(came_or_went);
|
||||||
|
first.into_iter().chain(rest)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This change followed by `next`, as one undo step: for anything both
|
/// This change followed by `next`, as one undo step: for anything both
|
||||||
|
|||||||
+83
-12
@@ -1,5 +1,7 @@
|
|||||||
//! Undo and redo, for one document.
|
//! Undo and redo, for one document.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
use super::document::Document;
|
use super::document::Document;
|
||||||
use super::edit::Change;
|
use super::edit::Change;
|
||||||
|
|
||||||
@@ -9,12 +11,40 @@ use super::edit::Change;
|
|||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
pub struct StateId(u64);
|
pub struct StateId(u64);
|
||||||
|
|
||||||
|
/// Changes made under one group make one undo step: a mouse drag is many
|
||||||
|
/// small moves, and undoes in one go.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct Group(u64);
|
||||||
|
|
||||||
|
impl Group {
|
||||||
|
/// A new group, different from every other one.
|
||||||
|
pub fn new() -> Group {
|
||||||
|
static NEXT: AtomicU64 = AtomicU64::new(1);
|
||||||
|
Group(NEXT.fetch_add(1, Ordering::Relaxed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Group {
|
||||||
|
fn default() -> Group {
|
||||||
|
Group::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One undo step: a change, the state it leads to, and its group if it has
|
||||||
|
/// one.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Step {
|
||||||
|
state: StateId,
|
||||||
|
change: Change,
|
||||||
|
group: Option<Group>,
|
||||||
|
}
|
||||||
|
|
||||||
/// The undo history of one document: the changes that can be undone (newest
|
/// The undo history of one document: the changes that can be undone (newest
|
||||||
/// last), and the ones undone that can be redone until the next new change.
|
/// last), and the ones undone that can be redone until the next new change.
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct History {
|
pub struct History {
|
||||||
done: Vec<(StateId, Change)>,
|
done: Vec<Step>,
|
||||||
undone: Vec<(StateId, Change)>,
|
undone: Vec<Step>,
|
||||||
last_state: u64,
|
last_state: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,34 +53,51 @@ impl History {
|
|||||||
/// are skipped, and a new change means what was undone can't be redone
|
/// are skipped, and a new change means what was undone can't be redone
|
||||||
/// anymore.
|
/// anymore.
|
||||||
pub fn record(&mut self, change: Change) {
|
pub fn record(&mut self, change: Change) {
|
||||||
|
self.record_step(change, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remembers a change made as part of a group: while the newest undo step
|
||||||
|
/// belongs to the same group, the change joins it.
|
||||||
|
pub fn record_in(&mut self, change: Change, group: Group) {
|
||||||
|
self.record_step(change, Some(group));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_step(&mut self, change: Change, group: Option<Group>) {
|
||||||
if change.is_empty() {
|
if change.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.last_state += 1;
|
self.last_state += 1;
|
||||||
self.done.push((StateId(self.last_state), change));
|
let state = StateId(self.last_state);
|
||||||
self.undone.clear();
|
self.undone.clear();
|
||||||
|
match self.done.last_mut() {
|
||||||
|
Some(last) if group.is_some() && last.group == group => {
|
||||||
|
last.change = std::mem::take(&mut last.change).then(change);
|
||||||
|
last.state = state;
|
||||||
|
}
|
||||||
|
_ => self.done.push(Step { state, change, group }),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Undoes the newest change, and returns it so the caller can show where
|
/// Undoes the newest change, and returns it so the caller can show where
|
||||||
/// it happened.
|
/// it happened.
|
||||||
pub fn undo(&mut self, doc: &mut Document) -> Option<&Change> {
|
pub fn undo(&mut self, doc: &mut Document) -> Option<&Change> {
|
||||||
let (state, change) = self.done.pop()?;
|
let step = self.done.pop()?;
|
||||||
doc.restore(change.before());
|
doc.restore(step.change.before());
|
||||||
self.undone.push((state, change));
|
self.undone.push(step);
|
||||||
self.undone.last().map(|(_, change)| change)
|
self.undone.last().map(|step| &step.change)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Redoes the newest undone change, and returns it.
|
/// Redoes the newest undone change, and returns it.
|
||||||
pub fn redo(&mut self, doc: &mut Document) -> Option<&Change> {
|
pub fn redo(&mut self, doc: &mut Document) -> Option<&Change> {
|
||||||
let (state, change) = self.undone.pop()?;
|
let step = self.undone.pop()?;
|
||||||
doc.restore(change.after());
|
doc.restore(step.change.after());
|
||||||
self.done.push((state, change));
|
self.done.push(step);
|
||||||
self.done.last().map(|(_, change)| change)
|
self.done.last().map(|step| &step.change)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The state the document is in now.
|
/// The state the document is in now.
|
||||||
pub fn state(&self) -> StateId {
|
pub fn state(&self) -> StateId {
|
||||||
self.done.last().map_or(StateId::default(), |(state, _)| *state)
|
self.done.last().map_or(StateId::default(), |step| step.state)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether there's a change to undo.
|
/// Whether there's a change to undo.
|
||||||
@@ -145,4 +192,28 @@ mod tests {
|
|||||||
fn doc_root() -> NodeId {
|
fn doc_root() -> NodeId {
|
||||||
NodeId::new(1)
|
NodeId::new(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_group_undoes_in_one_step() {
|
||||||
|
let (mut doc, g) = crate::model::test_support::garden();
|
||||||
|
let start = doc.clone();
|
||||||
|
let mut history = History::default();
|
||||||
|
let drag = Group::new();
|
||||||
|
for _ in 0..3 {
|
||||||
|
let op = crate::model::EditOp::Shift {
|
||||||
|
id: g.beds,
|
||||||
|
offset: crate::model::CanvasOffset::new(1, 0),
|
||||||
|
branch: true,
|
||||||
|
};
|
||||||
|
let (_, change) = doc.apply(&op).unwrap();
|
||||||
|
history.record_in(change, drag);
|
||||||
|
}
|
||||||
|
let (_, other) =
|
||||||
|
doc.apply(&crate::model::EditOp::SetFold { ids: vec![g.beds], fold: Fold::Closed }).unwrap();
|
||||||
|
history.record(other);
|
||||||
|
history.undo(&mut doc);
|
||||||
|
history.undo(&mut doc);
|
||||||
|
assert_eq!(doc, start, "three moves, one step");
|
||||||
|
assert!(!history.can_undo());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+3
-1
@@ -19,6 +19,7 @@ mod ids;
|
|||||||
mod link;
|
mod link;
|
||||||
mod look;
|
mod look;
|
||||||
mod node;
|
mod node;
|
||||||
|
mod op;
|
||||||
mod portal;
|
mod portal;
|
||||||
mod text;
|
mod text;
|
||||||
|
|
||||||
@@ -26,11 +27,12 @@ pub use document::{Document, NodeRecord, Place, StructureError};
|
|||||||
pub use edit::{Change, Edit, EditError, Spot};
|
pub use edit::{Change, Edit, EditError, Spot};
|
||||||
pub use fold::Visible;
|
pub use fold::Visible;
|
||||||
pub use geometry::{CanvasOffset, CanvasPos, CanvasRect, CanvasSize, Direction};
|
pub use geometry::{CanvasOffset, CanvasPos, CanvasRect, CanvasSize, Direction};
|
||||||
pub use history::{History, StateId};
|
pub use history::{Group, History, StateId};
|
||||||
pub use ids::{DocUid, NodeId};
|
pub use ids::{DocUid, NodeId};
|
||||||
pub use link::{Link, LinkKey};
|
pub use link::{Link, LinkKey};
|
||||||
pub use look::{Arrows, Border, LineColor, LineLook, NodeLook, Stroke};
|
pub use look::{Arrows, Border, LineColor, LineLook, NodeLook, Stroke};
|
||||||
pub use node::{Content, Fold, Idea, Node};
|
pub use node::{Content, Fold, Idea, Node};
|
||||||
|
pub use op::{Applied, EditOp, Inbox};
|
||||||
pub use portal::{Backlink, EmptyPath, LinkPath, Portal};
|
pub use portal::{Backlink, EmptyPath, LinkPath, Portal};
|
||||||
pub use text::{Body, ColorName, Label, StyleName, Tag, Tags, TextError, Title};
|
pub use text::{Body, ColorName, Label, StyleName, Tag, Tags, TextError, Title};
|
||||||
|
|
||||||
|
|||||||
+205
@@ -0,0 +1,205 @@
|
|||||||
|
//! Changes to a document, as data. A view describes the change it wants as
|
||||||
|
//! an [`EditOp`], and the app applies it with [`Document::apply`]: views
|
||||||
|
//! never touch the document themselves.
|
||||||
|
|
||||||
|
use super::document::Document;
|
||||||
|
use super::edit::{Change, Edit, EditError, Spot};
|
||||||
|
use super::geometry::CanvasOffset;
|
||||||
|
use super::ids::NodeId;
|
||||||
|
use super::link::{Link, LinkKey};
|
||||||
|
use super::look::Border;
|
||||||
|
use super::node::{Content, Fold, Node};
|
||||||
|
use super::text::{ColorName, Tag, Title};
|
||||||
|
|
||||||
|
/// One change to a document.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum EditOp {
|
||||||
|
/// A new node at a spot in the tree.
|
||||||
|
Add {
|
||||||
|
node: Node,
|
||||||
|
spot: Spot,
|
||||||
|
},
|
||||||
|
/// An idea for the Inbox, a loose node that's made the first time.
|
||||||
|
Capture {
|
||||||
|
idea: Node,
|
||||||
|
inbox: Inbox,
|
||||||
|
},
|
||||||
|
/// Nodes with their branches (`dd`, `3dd`, `dib`).
|
||||||
|
RemoveBranches(Vec<NodeId>),
|
||||||
|
/// Just this node; its children take its place (`x`).
|
||||||
|
RemoveKeepingChildren(NodeId),
|
||||||
|
/// A node, with its branch, to another spot in the tree.
|
||||||
|
MoveTo {
|
||||||
|
id: NodeId,
|
||||||
|
spot: Spot,
|
||||||
|
},
|
||||||
|
/// A node moves on the canvas, with its branch when `branch` is set.
|
||||||
|
Shift {
|
||||||
|
id: NodeId,
|
||||||
|
offset: CanvasOffset,
|
||||||
|
branch: bool,
|
||||||
|
},
|
||||||
|
/// How some branches fold.
|
||||||
|
SetFold {
|
||||||
|
ids: Vec<NodeId>,
|
||||||
|
fold: Fold,
|
||||||
|
},
|
||||||
|
/// A node's color (`None`: whatever the theme says).
|
||||||
|
SetColor {
|
||||||
|
id: NodeId,
|
||||||
|
color: Option<ColorName>,
|
||||||
|
},
|
||||||
|
SetBorder {
|
||||||
|
id: NodeId,
|
||||||
|
border: Option<Border>,
|
||||||
|
},
|
||||||
|
/// An idea's title (a portal's title comes from its document).
|
||||||
|
SetTitle {
|
||||||
|
id: NodeId,
|
||||||
|
title: Title,
|
||||||
|
},
|
||||||
|
/// Tags added to and removed from a node.
|
||||||
|
Tag {
|
||||||
|
id: NodeId,
|
||||||
|
add: Vec<Tag>,
|
||||||
|
remove: Vec<Tag>,
|
||||||
|
},
|
||||||
|
Link(Link),
|
||||||
|
Unlink(Vec<LinkKey>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Inbox an idea goes into: the one there is, or a new one.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Inbox {
|
||||||
|
Existing(NodeId),
|
||||||
|
New(Node),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What an applied op did, for the cursor and the status bar.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Applied {
|
||||||
|
/// A node was added.
|
||||||
|
Added(NodeId),
|
||||||
|
/// This many nodes were removed, branches included.
|
||||||
|
Removed(usize),
|
||||||
|
/// Something else changed.
|
||||||
|
Changed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Document {
|
||||||
|
/// Applies an op as one transaction (see [`Document::edit`]): on an
|
||||||
|
/// error, nothing changes.
|
||||||
|
pub fn apply(&mut self, op: &EditOp) -> Result<(Applied, Change), EditError> {
|
||||||
|
self.edit(|edit| apply(edit, op))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply(edit: &mut Edit<'_>, op: &EditOp) -> Result<Applied, EditError> {
|
||||||
|
match op {
|
||||||
|
EditOp::Add { node, spot } => edit.add(node.clone(), *spot).map(Applied::Added),
|
||||||
|
EditOp::Capture { idea, inbox } => {
|
||||||
|
let inbox = match inbox {
|
||||||
|
Inbox::Existing(id) => *id,
|
||||||
|
Inbox::New(node) => edit.add(node.clone(), Spot::Loose)?,
|
||||||
|
};
|
||||||
|
edit.add(idea.clone(), Spot::LastChildOf(inbox)).map(Applied::Added)
|
||||||
|
}
|
||||||
|
EditOp::RemoveBranches(ids) => {
|
||||||
|
let mut removed = 0;
|
||||||
|
for &id in ids {
|
||||||
|
// A branch inside one removed earlier is already gone.
|
||||||
|
if edit.doc().contains(id) {
|
||||||
|
removed += edit.remove_branch(id)?.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Applied::Removed(removed))
|
||||||
|
}
|
||||||
|
EditOp::RemoveKeepingChildren(id) => edit.remove_keeping_children(*id).map(|()| Applied::Removed(1)),
|
||||||
|
EditOp::MoveTo { id, spot } => edit.move_to(*id, *spot).map(|()| Applied::Changed),
|
||||||
|
EditOp::Shift { id, offset, branch: true } => {
|
||||||
|
edit.shift_branch(*id, *offset).map(|()| Applied::Changed)
|
||||||
|
}
|
||||||
|
EditOp::Shift { id, offset, branch: false } => {
|
||||||
|
edit.update(*id, |node| node.pos += *offset).map(|()| Applied::Changed)
|
||||||
|
}
|
||||||
|
EditOp::SetFold { ids, fold } => {
|
||||||
|
for &id in ids {
|
||||||
|
edit.update(id, |node| node.fold = *fold)?;
|
||||||
|
}
|
||||||
|
Ok(Applied::Changed)
|
||||||
|
}
|
||||||
|
EditOp::SetColor { id, color } => {
|
||||||
|
edit.update(*id, |node| node.look.color = color.clone()).map(|()| Applied::Changed)
|
||||||
|
}
|
||||||
|
EditOp::SetBorder { id, border } => {
|
||||||
|
edit.update(*id, |node| node.look.border = *border).map(|()| Applied::Changed)
|
||||||
|
}
|
||||||
|
EditOp::SetTitle { id, title } => edit
|
||||||
|
.update(*id, |node| {
|
||||||
|
if let Content::Idea(idea) = &mut node.content {
|
||||||
|
idea.title = title.clone();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(|()| Applied::Changed),
|
||||||
|
EditOp::Tag { id, add, remove } => edit
|
||||||
|
.update(*id, |node| {
|
||||||
|
for tag in add {
|
||||||
|
node.tags.insert(tag.clone());
|
||||||
|
}
|
||||||
|
for tag in remove {
|
||||||
|
node.tags.remove(tag);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(|()| Applied::Changed),
|
||||||
|
EditOp::Link(link) => edit.link(link.clone()).map(|_| Applied::Changed),
|
||||||
|
EditOp::Unlink(keys) => {
|
||||||
|
for &key in keys {
|
||||||
|
edit.unlink(key)?;
|
||||||
|
}
|
||||||
|
Ok(Applied::Changed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::model::CanvasPos;
|
||||||
|
use crate::model::test_support::{garden, idea, title};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ops_apply_as_one_undo_step_each() {
|
||||||
|
let (mut doc, g) = garden();
|
||||||
|
let start = doc.clone();
|
||||||
|
let (applied, change) = doc.apply(&EditOp::RemoveBranches(vec![g.soil, g.beds, g.cedar])).unwrap();
|
||||||
|
assert_eq!(applied, Applied::Removed(3), "Cedar went with its parent");
|
||||||
|
let mut history = crate::model::History::default();
|
||||||
|
history.record(change);
|
||||||
|
history.undo(&mut doc);
|
||||||
|
assert_eq!(doc, start);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capture_makes_the_inbox_once() {
|
||||||
|
let (mut doc, _) = garden();
|
||||||
|
let inbox = Node::idea(title("Inbox"), CanvasPos::new(0, 20));
|
||||||
|
let (Applied::Added(first), _) =
|
||||||
|
doc.apply(&EditOp::Capture { idea: idea("compost bin"), inbox: Inbox::New(inbox) }).unwrap()
|
||||||
|
else {
|
||||||
|
panic!("an idea was added");
|
||||||
|
};
|
||||||
|
let inbox_id = doc.parent(first).unwrap();
|
||||||
|
assert_eq!(doc.node(inbox_id).unwrap().title().as_str(), "Inbox");
|
||||||
|
doc.apply(&EditOp::Capture { idea: idea("worms"), inbox: Inbox::Existing(inbox_id) }).unwrap();
|
||||||
|
assert_eq!(doc.children(inbox_id).len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failing_op_changes_nothing() {
|
||||||
|
let (mut doc, g) = garden();
|
||||||
|
let start = doc.clone();
|
||||||
|
let op = EditOp::SetFold { ids: vec![g.beds, NodeId::new(99)], fold: Fold::Closed };
|
||||||
|
assert_eq!(doc.apply(&op), Err(EditError::NoSuchNode(NodeId::new(99))));
|
||||||
|
assert_eq!(doc, start);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@
|
|||||||
//! 2. Rendering the scene (in `render.rs`) writes it into the terminal.
|
//! 2. Rendering the scene (in `render.rs`) writes it into the terminal.
|
||||||
//! 3. Clicks are looked up in the same scene ([`Scene::node_at`]); hjkl uses
|
//! 3. Clicks are looked up in the same scene ([`Scene::node_at`]); hjkl uses
|
||||||
//! [`navigate`], and [`placement`] decides where new nodes go.
|
//! [`navigate`], and [`placement`] decides where new nodes go.
|
||||||
|
//!
|
||||||
|
//! [`CanvasView`] puts it together behind the [`View`](super::View) trait: it
|
||||||
|
//! keeps the camera and the last scene, and answers keys and the mouse.
|
||||||
|
|
||||||
pub mod boxes;
|
pub mod boxes;
|
||||||
pub mod camera;
|
pub mod camera;
|
||||||
@@ -18,7 +21,9 @@ pub mod placement;
|
|||||||
mod render;
|
mod render;
|
||||||
pub mod route;
|
pub mod route;
|
||||||
pub mod scene;
|
pub mod scene;
|
||||||
|
mod view;
|
||||||
|
|
||||||
pub use camera::{Camera, ScreenPos, ScreenRect, Zoom};
|
pub use camera::{Camera, ScreenPos, ScreenRect, Zoom};
|
||||||
pub use lines::LineOwner;
|
pub use lines::LineOwner;
|
||||||
pub use scene::{Scene, SceneNode, SceneOptions, Shape};
|
pub use scene::{Scene, SceneNode, Shape};
|
||||||
|
pub use view::CanvasView;
|
||||||
@@ -19,12 +19,22 @@ pub fn child_spot(
|
|||||||
size: CanvasSize,
|
size: CanvasSize,
|
||||||
rects: &BTreeMap<NodeId, CanvasRect>,
|
rects: &BTreeMap<NodeId, CanvasRect>,
|
||||||
) -> CanvasPos {
|
) -> CanvasPos {
|
||||||
let Some(parent_rect) = rects.get(&parent) else { return CanvasPos::default() };
|
let Some(&parent_rect) = rects.get(&parent) else { return CanvasPos::default() };
|
||||||
let start = match doc.children(parent).last().and_then(|child| rects.get(child)) {
|
match doc.children(parent).last().and_then(|child| rects.get(child)) {
|
||||||
Some(last) => CanvasPos::new(last.left(), last.bottom() + ROW_GAP),
|
Some(last) => free_spot(CanvasPos::new(last.left(), last.bottom() + ROW_GAP), size, rects),
|
||||||
None => CanvasPos::new(parent_rect.right() + BRANCH_GAP, parent_rect.top()),
|
None => first_child_spot(parent_rect, size, rects),
|
||||||
};
|
}
|
||||||
free_spot(start, size, rects)
|
}
|
||||||
|
|
||||||
|
/// Where the first child of a box goes: to its right, level with it, then
|
||||||
|
/// further down until it's clear. The box doesn't need to be in `rects` yet,
|
||||||
|
/// so this works for a parent that's being added in the same edit.
|
||||||
|
pub fn first_child_spot(
|
||||||
|
parent: CanvasRect,
|
||||||
|
size: CanvasSize,
|
||||||
|
rects: &BTreeMap<NodeId, CanvasRect>,
|
||||||
|
) -> CanvasPos {
|
||||||
|
free_spot(CanvasPos::new(parent.right() + BRANCH_GAP, parent.top()), size, rects)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where a new node just after `sibling` goes: right below it, then further
|
/// Where a new node just after `sibling` goes: right below it, then further
|
||||||
|
|||||||
@@ -171,7 +171,8 @@ mod tests {
|
|||||||
use crate::model::test_support::garden;
|
use crate::model::test_support::garden;
|
||||||
use crate::model::{CanvasPos, Fold, Label, Link};
|
use crate::model::{CanvasPos, Fold, Label, Link};
|
||||||
use crate::ui::theme::Theme;
|
use crate::ui::theme::Theme;
|
||||||
use crate::view::canvas::{Camera, SceneOptions, Zoom};
|
use crate::view::ViewSettings;
|
||||||
|
use crate::view::canvas::{Camera, Zoom};
|
||||||
|
|
||||||
/// Prints the example document (`examples/garden.cum`) at each zoom, to
|
/// Prints the example document (`examples/garden.cum`) at each zoom, to
|
||||||
/// look at the canvas without a terminal:
|
/// look at the canvas without a terminal:
|
||||||
@@ -188,7 +189,7 @@ mod tests {
|
|||||||
camera,
|
camera,
|
||||||
Size::new(84, 36),
|
Size::new(84, 36),
|
||||||
&Theme::default(),
|
&Theme::default(),
|
||||||
SceneOptions::default(),
|
&ViewSettings::default(),
|
||||||
);
|
);
|
||||||
println!("── {} {}", zoom.name(), "─".repeat(70));
|
println!("── {} {}", zoom.name(), "─".repeat(70));
|
||||||
for row in snapshot(&scene) {
|
for row in snapshot(&scene) {
|
||||||
@@ -217,7 +218,7 @@ mod tests {
|
|||||||
Camera::default(),
|
Camera::default(),
|
||||||
Size::new(52, 17),
|
Size::new(52, 17),
|
||||||
&Theme::default(),
|
&Theme::default(),
|
||||||
SceneOptions::default(),
|
&ViewSettings::default(),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
snapshot(&scene),
|
snapshot(&scene),
|
||||||
@@ -251,7 +252,7 @@ mod tests {
|
|||||||
doc.edit(|e| e.update(g.beds, |node| node.look.color = Some(orange.clone()))).unwrap();
|
doc.edit(|e| e.update(g.beds, |node| node.look.color = Some(orange.clone()))).unwrap();
|
||||||
let theme = Theme::default();
|
let theme = Theme::default();
|
||||||
let scene =
|
let scene =
|
||||||
Scene::build(&doc, g.soil, Camera::default(), Size::new(52, 8), &theme, SceneOptions::default());
|
Scene::build(&doc, g.soil, Camera::default(), Size::new(52, 8), &theme, &ViewSettings::default());
|
||||||
let area = Rect::new(0, 0, 52, 8);
|
let area = Rect::new(0, 0, 52, 8);
|
||||||
let mut buf = Buffer::empty(area);
|
let mut buf = Buffer::empty(area);
|
||||||
scene.render(area, &mut buf);
|
scene.render(area, &mut buf);
|
||||||
@@ -277,7 +278,7 @@ mod tests {
|
|||||||
Camera::default(),
|
Camera::default(),
|
||||||
Size::new(52, 19),
|
Size::new(52, 19),
|
||||||
&Theme::default(),
|
&Theme::default(),
|
||||||
SceneOptions::default(),
|
&ViewSettings::default(),
|
||||||
);
|
);
|
||||||
let text = snapshot(&scene);
|
let text = snapshot(&scene);
|
||||||
assert!(text[6].ends_with("── +1 ─┘"), "the hidden Cedar shows as +1: {text:#?}");
|
assert!(text[6].ends_with("── +1 ─┘"), "the hidden Cedar shows as +1: {text:#?}");
|
||||||
@@ -288,7 +289,7 @@ mod tests {
|
|||||||
// the compost note up into Watering, labeled where it runs level.
|
// the compost note up into Watering, labeled where it runs level.
|
||||||
let far = Camera { origin: CanvasPos::new(0, 0), zoom: Zoom::Far };
|
let far = Camera { origin: CanvasPos::new(0, 0), zoom: Zoom::Far };
|
||||||
let scene =
|
let scene =
|
||||||
Scene::build(&doc, g.root, far, Size::new(40, 9), &Theme::default(), SceneOptions::default());
|
Scene::build(&doc, g.root, far, Size::new(40, 9), &Theme::default(), &ViewSettings::default());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
snapshot(&scene),
|
snapshot(&scene),
|
||||||
vec![
|
vec![
|
||||||
|
|||||||
@@ -8,32 +8,18 @@ use ratatui::layout::Size;
|
|||||||
use ratatui::style::Color;
|
use ratatui::style::Color;
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
use super::boxes::{DEFAULT_MAX_WIDTH, NodeBox, truncate};
|
use super::boxes::{NodeBox, truncate};
|
||||||
use super::camera::{Camera, ScreenPos, ScreenRect, Zoom};
|
use super::camera::{Camera, ScreenPos, ScreenRect, Zoom};
|
||||||
use super::lines::{LineLayer, LineOwner, Paint};
|
use super::lines::{LineLayer, LineOwner, Paint};
|
||||||
use super::route::{Bend, route};
|
use super::route::{Bend, route};
|
||||||
use crate::model::{Arrows, Border, Content, Document, Node, NodeId, Place, Visible};
|
use crate::model::{Arrows, Border, Content, Document, Node, NodeId, Place, Visible};
|
||||||
use crate::ui::glyphs::Glyphs;
|
use crate::ui::glyphs::Glyphs;
|
||||||
use crate::ui::theme::{LineStyle, Theme};
|
use crate::ui::theme::{LineStyle, Theme};
|
||||||
|
use crate::view::ViewSettings;
|
||||||
|
|
||||||
/// How many columns of a title show at Far zoom.
|
/// How many columns of a title show at Far zoom.
|
||||||
const FAR_TITLE_WIDTH: usize = 14;
|
const FAR_TITLE_WIDTH: usize = 14;
|
||||||
|
|
||||||
/// What a scene needs besides the document, the cursor and the camera.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct SceneOptions {
|
|
||||||
/// The width titles wrap at.
|
|
||||||
pub max_width: u16,
|
|
||||||
/// Focus mode: Open branches act like Auto ones.
|
|
||||||
pub focus: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for SceneOptions {
|
|
||||||
fn default() -> SceneOptions {
|
|
||||||
SceneOptions { max_width: DEFAULT_MAX_WIDTH, focus: false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How a node is drawn at the camera's zoom.
|
/// How a node is drawn at the camera's zoom.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum Shape {
|
pub enum Shape {
|
||||||
@@ -95,9 +81,9 @@ impl Scene {
|
|||||||
camera: Camera,
|
camera: Camera,
|
||||||
size: Size,
|
size: Size,
|
||||||
theme: &Theme,
|
theme: &Theme,
|
||||||
options: SceneOptions,
|
settings: &ViewSettings,
|
||||||
) -> Scene {
|
) -> Scene {
|
||||||
let visible = Visible::new(doc, cursor, options.focus);
|
let visible = Visible::new(doc, cursor, settings.focus);
|
||||||
let mut scene = Scene {
|
let mut scene = Scene {
|
||||||
size,
|
size,
|
||||||
zoom: camera.zoom,
|
zoom: camera.zoom,
|
||||||
@@ -112,7 +98,8 @@ impl Scene {
|
|||||||
let Some(node) = doc.node(id) else { continue };
|
let Some(node) = doc.node(id) else { continue };
|
||||||
let style = theme.node_style(node, id == doc.root());
|
let style = theme.node_style(node, id == doc.root());
|
||||||
let badge = visible.hidden.get(&id).map(|count| format!("+{count}")).unwrap_or_default();
|
let badge = visible.hidden.get(&id).map(|count| format!("+{count}")).unwrap_or_default();
|
||||||
let (rect, shape) = shape(node, badge, style.border, camera, theme.glyphs, options.max_width);
|
let (rect, shape) =
|
||||||
|
shape(node, badge, style.border, camera, theme.glyphs, settings.max_node_width);
|
||||||
scene.nodes.push(SceneNode { id, rect, shape, color: style.color, selected: id == cursor });
|
scene.nodes.push(SceneNode { id, rect, shape, color: style.color, selected: id == cursor });
|
||||||
}
|
}
|
||||||
// The cursor's node goes last, on top of any it overlaps.
|
// The cursor's node goes last, on top of any it overlaps.
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
//! The canvas as a [`View`]. It owns the camera and the last scene it laid
|
||||||
|
//! out; everything else it reads from the [`ViewContext`], and every change
|
||||||
|
//! it wants to the document goes out as an action.
|
||||||
|
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
|
||||||
|
use super::camera::{Camera, ScreenPos, Zoom};
|
||||||
|
use super::navigate::nearest;
|
||||||
|
use super::scene::Scene;
|
||||||
|
use crate::app::action::{Action, AppAction, Message};
|
||||||
|
use crate::app::mode::{Grab, Mode};
|
||||||
|
use crate::input::bindings::Context;
|
||||||
|
use crate::input::command::{Command, Motion};
|
||||||
|
use crate::input::engine::Typed;
|
||||||
|
use crate::input::mouse::{Cell, Gesture};
|
||||||
|
use crate::model::{CanvasOffset, Direction, EditOp, Group, NodeId, Visible};
|
||||||
|
use crate::view::{View, ViewContext, ViewKind};
|
||||||
|
|
||||||
|
/// How close (in cells) the cursor's node may come to an edge before the
|
||||||
|
/// view follows it, like vim's `scrolloff`.
|
||||||
|
const SCROLL_OFF: i32 = 3;
|
||||||
|
/// How far `Ctrl-e`, `Ctrl-y` and the mouse wheel scroll.
|
||||||
|
const SCROLL_ROWS: i32 = 3;
|
||||||
|
/// How far `zh`, `zl` and the wheel sideways scroll: cells are about twice
|
||||||
|
/// as tall as they're wide, so this is about as far as `SCROLL_ROWS`.
|
||||||
|
const SCROLL_COLS: i32 = 6;
|
||||||
|
/// How many cells `HJKL` move a node in move mode (`hjkl` move it one).
|
||||||
|
const FAR: i32 = 5;
|
||||||
|
|
||||||
|
/// A mouse drag in progress.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Drag {
|
||||||
|
/// Moving a node with its branch: where the drag started, and how far
|
||||||
|
/// the branch has moved so far. The whole drag is one undo step.
|
||||||
|
Node { id: NodeId, from: ScreenPos, moved: CanvasOffset, group: Group },
|
||||||
|
/// Panning the view: the cell the mouse was last on.
|
||||||
|
Pan { last: ScreenPos },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The canvas view.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct CanvasView {
|
||||||
|
camera: Camera,
|
||||||
|
/// The frame laid out last: what's drawn, and what a click lands on.
|
||||||
|
scene: Scene,
|
||||||
|
/// Where on screen the view is.
|
||||||
|
area: Rect,
|
||||||
|
/// The cursor at the last layout. When it moves, the view follows.
|
||||||
|
last_cursor: Option<NodeId>,
|
||||||
|
/// Bring the cursor's node into view at the next layout, though the
|
||||||
|
/// cursor didn't move: after a zoom, or when move mode moves the node.
|
||||||
|
reveal: bool,
|
||||||
|
drag: Option<Drag>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CanvasView {
|
||||||
|
pub fn new() -> CanvasView {
|
||||||
|
CanvasView::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The camera (the status bar shows its zoom).
|
||||||
|
pub fn camera(&self) -> Camera {
|
||||||
|
self.camera
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(&self, ctx: &ViewContext<'_>) -> Scene {
|
||||||
|
Scene::build(ctx.doc, ctx.cursor, self.camera, self.area.as_size(), ctx.theme, ctx.settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// hjkl: the nearest node on show that way, `times` times over. Folds are
|
||||||
|
/// worked out again at each step, since an Auto fold opens when the
|
||||||
|
/// cursor goes inside it.
|
||||||
|
fn go(&self, direction: Direction, times: u32, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||||
|
let rects = ctx.box_rects();
|
||||||
|
let mut at = ctx.cursor;
|
||||||
|
for _ in 0..times {
|
||||||
|
let Some(&from) = rects.get(&at) else { break };
|
||||||
|
let shown = Visible::new(ctx.doc, at, ctx.settings.focus).order;
|
||||||
|
match nearest(from, direction, shown.iter().filter_map(|id| Some((*id, *rects.get(id)?)))) {
|
||||||
|
Some(next) => at = next,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if at == ctx.cursor {
|
||||||
|
vec![Action::say(Message::info("nothing that way"))]
|
||||||
|
} else {
|
||||||
|
vec![Action::cursor(at)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zooms, keeping the cursor's node where it is on screen.
|
||||||
|
fn zoom(&mut self, zoom: Zoom, ctx: &ViewContext<'_>) {
|
||||||
|
if let Some(node) = ctx.doc.node(ctx.cursor) {
|
||||||
|
self.camera.set_zoom(zoom, node.pos);
|
||||||
|
}
|
||||||
|
self.reveal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `zz`: the middle of the cursor's node to the middle of the view.
|
||||||
|
fn center(&mut self, ctx: &ViewContext<'_>) {
|
||||||
|
if let Some(rect) = self.scene.rect_of(ctx.cursor) {
|
||||||
|
let (width, height) = (i32::from(self.area.width), i32::from(self.area.height));
|
||||||
|
self.camera.pan(rect.mid_col() - width / 2, rect.mid_row() - height / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move mode: hjkl moves the cursor's node, or its whole branch, and
|
||||||
|
/// every move until the mode ends is one undo step.
|
||||||
|
fn move_command(&mut self, typed: &Typed, grab: Grab, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
|
||||||
|
let step = cells(typed);
|
||||||
|
let (cols, rows) = match typed.command {
|
||||||
|
Command::Move(Motion::Left) => (-step, 0),
|
||||||
|
Command::Move(Motion::Right) => (step, 0),
|
||||||
|
Command::Move(Motion::Down) => (0, step),
|
||||||
|
Command::Move(Motion::Up) => (0, -step),
|
||||||
|
Command::FarLeft => (-FAR * step, 0),
|
||||||
|
Command::FarRight => (FAR * step, 0),
|
||||||
|
Command::FarDown => (0, FAR * step),
|
||||||
|
Command::FarUp => (0, -FAR * step),
|
||||||
|
Command::ToggleWhole => return Some(vec![Action::App(AppAction::ToggleWhole)]),
|
||||||
|
Command::Confirm => return Some(vec![Action::App(AppAction::LeaveMode)]),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
self.reveal = true;
|
||||||
|
let op = EditOp::Shift { id: ctx.cursor, offset: self.camera.cells(cols, rows), branch: grab.whole };
|
||||||
|
Some(vec![Action::edit_in(op, grab.group)])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The view's cell under a terminal cell.
|
||||||
|
fn screen(&self, cell: Cell) -> ScreenPos {
|
||||||
|
ScreenPos::new(
|
||||||
|
i32::from(cell.col) - i32::from(self.area.x),
|
||||||
|
i32::from(cell.row) - i32::from(self.area.y),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drag_to(&mut self, at: ScreenPos) -> Vec<Action> {
|
||||||
|
match &mut self.drag {
|
||||||
|
Some(Drag::Node { id, from, moved, group }) => {
|
||||||
|
let total = self.camera.cells(at.col - from.col, at.row - from.row);
|
||||||
|
// Each step moves the branch from where the last one left it.
|
||||||
|
let step = CanvasOffset::new(total.dx - moved.dx, total.dy - moved.dy);
|
||||||
|
if step == CanvasOffset::default() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
*moved = total;
|
||||||
|
vec![Action::edit_in(EditOp::Shift { id: *id, offset: step, branch: true }, *group)]
|
||||||
|
}
|
||||||
|
Some(Drag::Pan { last }) => {
|
||||||
|
self.camera.pan(last.col - at.col, last.row - at.row);
|
||||||
|
*last = at;
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
None => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A command's count, as a number of cells to move or scroll by.
|
||||||
|
fn cells(typed: &Typed) -> i32 {
|
||||||
|
// The key engine keeps counts under 10 000, so this always fits.
|
||||||
|
i32::try_from(typed.times()).unwrap_or(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The direction of hjkl, or `None` for the other motions.
|
||||||
|
fn direction(motion: Motion) -> Option<Direction> {
|
||||||
|
match motion {
|
||||||
|
Motion::Left => Some(Direction::Left),
|
||||||
|
Motion::Down => Some(Direction::Down),
|
||||||
|
Motion::Up => Some(Direction::Up),
|
||||||
|
Motion::Right => Some(Direction::Right),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl View for CanvasView {
|
||||||
|
fn kind(&self) -> ViewKind {
|
||||||
|
ViewKind::Canvas
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contexts(&self, mode: &Mode) -> &'static [Context] {
|
||||||
|
match mode {
|
||||||
|
Mode::Normal => &[Context::Canvas, Context::Normal, Context::Motions],
|
||||||
|
Mode::Move(_) => &[Context::Move],
|
||||||
|
// The command line reads its own keys.
|
||||||
|
Mode::Command(_) => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layout(&mut self, area: Rect, ctx: &ViewContext<'_>) {
|
||||||
|
self.area = area;
|
||||||
|
let moved = self.last_cursor.replace(ctx.cursor) != Some(ctx.cursor);
|
||||||
|
// While a node is dragged, the view stays put under the mouse.
|
||||||
|
let follow = std::mem::take(&mut self.reveal) || (moved && self.drag.is_none());
|
||||||
|
self.scene = self.build(ctx);
|
||||||
|
if follow
|
||||||
|
&& let Some(rect) = self.scene.rect_of(ctx.cursor)
|
||||||
|
&& self.camera.reveal(rect, area.as_size(), SCROLL_OFF)
|
||||||
|
{
|
||||||
|
self.scene = self.build(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self, frame: &mut Frame, _ctx: &ViewContext<'_>) {
|
||||||
|
frame.render_widget(&self.scene, self.area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command(&mut self, typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
|
||||||
|
if let Mode::Move(grab) = ctx.mode {
|
||||||
|
return self.move_command(typed, *grab, ctx);
|
||||||
|
}
|
||||||
|
let steps = cells(typed);
|
||||||
|
let half_page = i32::from(self.area.height / 2);
|
||||||
|
match typed.command {
|
||||||
|
Command::Move(motion) => return direction(motion).map(|way| self.go(way, typed.times(), ctx)),
|
||||||
|
Command::ZoomIn => self.zoom(self.camera.zoom.closer(), ctx),
|
||||||
|
Command::ZoomOut => self.zoom(self.camera.zoom.further(), ctx),
|
||||||
|
Command::Center => self.center(ctx),
|
||||||
|
Command::ScrollDown => self.camera.pan(0, SCROLL_ROWS * steps),
|
||||||
|
Command::ScrollUp => self.camera.pan(0, -SCROLL_ROWS * steps),
|
||||||
|
Command::ScrollLeft => self.camera.pan(-SCROLL_COLS * steps, 0),
|
||||||
|
Command::ScrollRight => self.camera.pan(SCROLL_COLS * steps, 0),
|
||||||
|
Command::HalfPageDown => self.camera.pan(0, half_page),
|
||||||
|
Command::HalfPageUp => self.camera.pan(0, -half_page),
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
// The rest only move the camera, which is the view's own.
|
||||||
|
Some(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mouse(&mut self, gesture: Gesture, _ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||||
|
match gesture {
|
||||||
|
Gesture::Press { at, .. } => {
|
||||||
|
let at = self.screen(at);
|
||||||
|
match self.scene.node_at(at) {
|
||||||
|
Some(id) => {
|
||||||
|
let moved = CanvasOffset::default();
|
||||||
|
self.drag = Some(Drag::Node { id, from: at, moved, group: Group::new() });
|
||||||
|
vec![Action::cursor(id)]
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.drag = Some(Drag::Pan { last: at });
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Gesture::Drag { at, .. } => self.drag_to(self.screen(at)),
|
||||||
|
Gesture::Release { .. } => {
|
||||||
|
self.drag = None;
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
Gesture::Scroll { direction, .. } => {
|
||||||
|
let (cols, rows) = match direction {
|
||||||
|
Direction::Up => (0, -SCROLL_ROWS),
|
||||||
|
Direction::Down => (0, SCROLL_ROWS),
|
||||||
|
Direction::Left => (-SCROLL_COLS, 0),
|
||||||
|
Direction::Right => (SCROLL_COLS, 0),
|
||||||
|
};
|
||||||
|
self.camera.pan(cols, rows);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
Gesture::DoublePress { .. } => {
|
||||||
|
vec![Action::say(Message::info("editing titles comes in stage 6"))]
|
||||||
|
}
|
||||||
|
Gesture::RightPress { .. } => {
|
||||||
|
vec![Action::say(Message::info("the right-click menu comes in stage 7"))]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::app::action::DocAction;
|
||||||
|
use crate::app::buffer::Buffer;
|
||||||
|
use crate::model::CanvasPos;
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The terminal cell over a canvas point, with the view at the top-left
|
||||||
|
/// of the screen and the camera where it starts.
|
||||||
|
fn cell(pos: CanvasPos) -> Cell {
|
||||||
|
Cell { col: pos.x as u16, row: pos.y as u16 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pos(buffer: &Buffer, id: NodeId) -> CanvasPos {
|
||||||
|
buffer.doc().node(id).unwrap().pos
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hjkl_with_a_count_goes_step_by_step() {
|
||||||
|
let ((doc, g), setup) = (garden(), Setup::default());
|
||||||
|
let mut view = CanvasView::new();
|
||||||
|
// Garden plans → Raised beds → Cedar, all on one row.
|
||||||
|
let right = typed(Command::Move(Motion::Right), Some(2));
|
||||||
|
assert_eq!(view.command(&right, &setup.ctx(&doc, g.root)), Some(vec![Action::cursor(g.cedar)]));
|
||||||
|
let left = typed(Command::Move(Motion::Left), None);
|
||||||
|
assert_eq!(
|
||||||
|
view.command(&left, &setup.ctx(&doc, g.root)),
|
||||||
|
Some(vec![Action::say(Message::info("nothing that way"))])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_mode_moves_the_branch_as_one_undo_step() {
|
||||||
|
let (doc, g) = garden();
|
||||||
|
let grab = Grab::new();
|
||||||
|
let setup = Setup { mode: Mode::Move(grab), ..Setup::default() };
|
||||||
|
let mut view = CanvasView::new();
|
||||||
|
let mut buffer = Buffer::new(doc);
|
||||||
|
buffer.apply(DocAction::MoveCursor { to: g.beds, jump: false });
|
||||||
|
for command in [Command::Move(Motion::Right), Command::FarDown] {
|
||||||
|
for action in view.command(&typed(command, Some(2)), &setup.ctx(buffer.doc(), g.beds)).unwrap() {
|
||||||
|
let Action::Doc(action) = action else { panic!("only edits") };
|
||||||
|
buffer.apply(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(pos(&buffer, g.beds), CanvasPos::new(24, 14), "2 right, 10 down");
|
||||||
|
assert_eq!(pos(&buffer, g.cedar), CanvasPos::new(42, 14), "its branch came along");
|
||||||
|
buffer.apply(DocAction::Undo(1));
|
||||||
|
assert_eq!(pos(&buffer, g.beds), CanvasPos::new(22, 4), "one undo puts it all back");
|
||||||
|
assert!(!buffer.history().can_undo());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dragging_a_node_moves_its_branch() {
|
||||||
|
let ((doc, g), setup) = (garden(), Setup::default());
|
||||||
|
let mut view = CanvasView::new();
|
||||||
|
let mut buffer = Buffer::new(doc);
|
||||||
|
view.layout(Rect::new(0, 0, 80, 20), &setup.ctx(buffer.doc(), buffer.cursor()));
|
||||||
|
let start = cell(CanvasPos::new(25, 5));
|
||||||
|
let gestures = [
|
||||||
|
Gesture::Press { at: start, ctrl: false },
|
||||||
|
Gesture::Drag { from: start, at: Cell { col: 27, row: 5 }, ctrl: false },
|
||||||
|
Gesture::Drag { from: start, at: Cell { col: 30, row: 6 }, ctrl: false },
|
||||||
|
Gesture::Release { at: Cell { col: 30, row: 6 }, dragged: true },
|
||||||
|
];
|
||||||
|
for gesture in gestures {
|
||||||
|
for action in view.mouse(gesture, &setup.ctx(buffer.doc(), buffer.cursor())) {
|
||||||
|
let Action::Doc(action) = action else { panic!("only document actions") };
|
||||||
|
buffer.apply(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(buffer.cursor(), g.beds, "the press selected it");
|
||||||
|
assert_eq!(pos(&buffer, g.beds), CanvasPos::new(27, 5));
|
||||||
|
assert_eq!(pos(&buffer, g.cedar), CanvasPos::new(45, 5));
|
||||||
|
buffer.apply(DocAction::Undo(1));
|
||||||
|
assert_eq!(pos(&buffer, g.beds), CanvasPos::new(22, 4), "one undo step");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_view_follows_the_cursor_but_not_a_scroll() {
|
||||||
|
let ((doc, g), setup) = (garden(), Setup::default());
|
||||||
|
let mut view = CanvasView::new();
|
||||||
|
let area = Rect::new(0, 0, 30, 12);
|
||||||
|
view.layout(area, &setup.ctx(&doc, g.root));
|
||||||
|
// Cedar's box starts 40 columns in: off the right of a 30-column view.
|
||||||
|
view.layout(area, &setup.ctx(&doc, g.cedar));
|
||||||
|
let rect = view.scene.rect_of(g.cedar).unwrap();
|
||||||
|
assert!(rect.left() >= SCROLL_OFF && rect.right() <= 30 - SCROLL_OFF, "{rect:?}");
|
||||||
|
// Scrolling takes the view away from the cursor, and it stays away.
|
||||||
|
let before = view.camera().origin;
|
||||||
|
view.command(&typed(Command::ScrollRight, None), &setup.ctx(&doc, g.cedar));
|
||||||
|
view.layout(area, &setup.ctx(&doc, g.cedar));
|
||||||
|
assert_eq!(view.camera().origin, CanvasPos::new(before.x + SCROLL_COLS, before.y));
|
||||||
|
}
|
||||||
|
}
|
||||||
+106
-2
@@ -1,8 +1,31 @@
|
|||||||
//! The views, one module each. Stage 5 of the build adds the interface they
|
//! The views, one module each, all behind the [`View`] trait.
|
||||||
//! all share; for now there's the canvas.
|
//!
|
||||||
|
//! A view looks at the document through a [`ViewContext`] and answers keys
|
||||||
|
//! and the mouse with [`Action`]s; it never changes the document, the cursor
|
||||||
|
//! or the mode itself. What it owns is its own way of looking: the canvas
|
||||||
|
//! keeps its camera, the outline its scroll position. So every view works the
|
||||||
|
//! same way from the outside:
|
||||||
|
//!
|
||||||
|
//! 1. [`View::layout`] fits it to its area (and brings the cursor into view),
|
||||||
|
//! 2. [`View::render`] draws it,
|
||||||
|
//! 3. [`View::command`] and [`View::mouse`] turn input into actions.
|
||||||
|
|
||||||
pub mod canvas;
|
pub mod canvas;
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
|
||||||
|
use crate::app::action::Action;
|
||||||
|
use crate::app::mode::Mode;
|
||||||
|
use crate::input::bindings::Context;
|
||||||
|
use crate::input::engine::Typed;
|
||||||
|
use crate::input::mouse::Gesture;
|
||||||
|
use crate::model::{CanvasRect, CanvasSize, Document, Node, NodeId};
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
use canvas::boxes::NodeBox;
|
||||||
|
|
||||||
/// The kinds of view a pane can show.
|
/// The kinds of view a pane can show.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum ViewKind {
|
pub enum ViewKind {
|
||||||
@@ -29,3 +52,84 @@ impl ViewKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Settings every view follows.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct ViewSettings {
|
||||||
|
/// The width titles wrap at (`max_node_width` in config.toml).
|
||||||
|
pub max_node_width: u16,
|
||||||
|
/// Focus mode: Open branches act like Auto ones.
|
||||||
|
pub focus: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ViewSettings {
|
||||||
|
fn default() -> ViewSettings {
|
||||||
|
ViewSettings { max_node_width: canvas::boxes::DEFAULT_MAX_WIDTH, focus: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a view can look at while it lays out, draws or handles input.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct ViewContext<'a> {
|
||||||
|
pub doc: &'a Document,
|
||||||
|
/// The document's cursor, shared by every pane showing it.
|
||||||
|
pub cursor: NodeId,
|
||||||
|
pub mode: &'a Mode,
|
||||||
|
pub theme: &'a Theme,
|
||||||
|
pub settings: &'a ViewSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ViewContext<'_> {
|
||||||
|
/// Every node's box on the canvas, at Normal zoom. Every view needs
|
||||||
|
/// these to place new nodes, since every node has a spot on the canvas.
|
||||||
|
pub fn box_rects(&self) -> BTreeMap<NodeId, CanvasRect> {
|
||||||
|
canvas::boxes::box_rects(self.doc, self.settings.max_node_width, self.theme.glyphs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The size a node's box has on the canvas.
|
||||||
|
pub fn box_size(&self, node: &Node) -> CanvasSize {
|
||||||
|
NodeBox::new(node, String::new(), self.settings.max_node_width, self.theme.glyphs).rect.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What every view does.
|
||||||
|
pub trait View {
|
||||||
|
/// Which view this is.
|
||||||
|
fn kind(&self) -> ViewKind;
|
||||||
|
|
||||||
|
/// The key tables in use in a mode, most specific first.
|
||||||
|
fn contexts(&self, mode: &Mode) -> &'static [Context];
|
||||||
|
|
||||||
|
/// Fits the view to its area before it's drawn, bringing the cursor into
|
||||||
|
/// view when it moved.
|
||||||
|
fn layout(&mut self, area: Rect, ctx: &ViewContext<'_>);
|
||||||
|
|
||||||
|
/// Draws the view as laid out. Drawing changes nothing.
|
||||||
|
fn render(&self, frame: &mut Frame, ctx: &ViewContext<'_>);
|
||||||
|
|
||||||
|
/// A command typed while this view has the focus, or `None` when it isn't
|
||||||
|
/// one this view knows.
|
||||||
|
fn command(&mut self, typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>>;
|
||||||
|
|
||||||
|
/// A mouse gesture over the view.
|
||||||
|
fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext<'_>) -> Vec<Action>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod test_support {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// What a [`ViewContext`] borrows besides the document, for tests.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Setup {
|
||||||
|
pub mode: Mode,
|
||||||
|
pub theme: Theme,
|
||||||
|
pub settings: ViewSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Setup {
|
||||||
|
pub fn ctx<'a>(&'a self, doc: &'a Document, cursor: NodeId) -> ViewContext<'a> {
|
||||||
|
ViewContext { doc, cursor, mode: &self.mode, theme: &self.theme, settings: &self.settings }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user