`:` opens a vim-style command line. Every command is described once, in a typed table (name, the short form that still works, aliases, a `!`, its arguments and what it does), so parsing, completion and the help can never disagree. Short forms work as in vim (`:e`, `:vs`, `:q!`, `:x`), and a test checks that no typed name can mean two commands. Parsing turns a line into a typed Call, checking every argument against what the app offers (open documents, nodes, colors, styles, settings), or says what's wrong in words: the usage when an argument is missing, the choices when a word isn't one, "did you mean :fold?" for a typo. Documents and nodes match fuzzily (`:b gpl` finds Garden plans). While typing, a popup above the word lists what can finish it (command names by prefix, choices, files from the folders on disk, documents and nodes fuzzily), with Tab and Shift-Tab putting them in the line. Ghost text after the cursor comes from the history first, like fish's, then from the best candidate; → or Ctrl-e takes it. Up and Down browse the lines that start with what was typed. The line itself is a small input that steps over whole characters, accents and emoji included. The canvas example opens it with `:` and runs a handful of commands: :fold, :color, :border, :tag, :focus, :rename, :in (to an Inbox), :link, :unlink, :find, :version and :q. Co-Authored-By: Claude Opus 5.5 <[email protected]>
775 lines
31 KiB
Rust
775 lines
31 KiB
Rust
//! A playground for the canvas (stages 2 to 4 of the build): a sample
|
|
//! document to move around, zoom, fold and rearrange, with the real vim keys,
|
|
//! the which-key popup and the `:` command line. Text editing and the other
|
|
//! views come in later stages.
|
|
//!
|
|
//! Run it with `cargo run --example canvas`. A few keys to start with:
|
|
//!
|
|
//! | Keys | What they do |
|
|
//! |----------------|------------------------------------------------------|
|
|
//! | `hjkl`, arrows | move to the nearest node that way (`5j`: five times) |
|
|
//! | `g` | go to: `gh gl gj gk` the tree, `gg` the root |
|
|
//! | `z` | view & folds: `za` `zf` `zi` `zz` `zR` `zM` |
|
|
//! | `+` `-` | zoom in / out |
|
|
//! | `a` `o` `O` | add a child / a sibling below / above |
|
|
//! | `x` `dd` `dib` | delete the node / the branch / the children |
|
|
//! | `M` | move mode: hjkl moves the branch, Enter ends |
|
|
//! | `u` `Ctrl-r` | undo / redo |
|
|
//! | `<Space>` | the action menu (`<Space>w`: key hints on or off) |
|
|
//! | `:` | the command line: try `:fold auto`, `:color teal`, `:in an idea`, `:link rain`, `:find soil` (Tab completes) |
|
|
//! | `q`, `ZZ` | quit |
|
|
//!
|
|
//! Mouse: click selects, dragging a node moves its branch, dragging empty
|
|
//! space pans, the wheel scrolls.
|
|
|
|
use std::io;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use cumulus::command::history::History as CommandHistory;
|
|
use cumulus::command::parse::{Call, Sources, TagChange, parse};
|
|
use cumulus::input::bindings::Context;
|
|
use cumulus::input::command::{Bound, Command, Motion, Operator, Target, help_for};
|
|
use cumulus::input::engine::{KeyEngine, Outcome, Typed};
|
|
use cumulus::input::key::Key;
|
|
use cumulus::input::keymap::Keymap;
|
|
use cumulus::input::mouse::{Cell, Gesture, MouseReader};
|
|
use cumulus::model::{
|
|
CanvasOffset, CanvasPos, CanvasSize, Change, Content, Direction, Document, Edit, EditError, Fold,
|
|
History, Link, LinkKey, Node, NodeId, Spot, Title, Visible,
|
|
};
|
|
use cumulus::storage::document_file;
|
|
use cumulus::ui::command_line::{CommandLine, LineOutcome};
|
|
use cumulus::ui::theme::Theme;
|
|
use cumulus::ui::which_key::WhichKey;
|
|
use cumulus::view::canvas::boxes::{NodeBox, box_rects};
|
|
use cumulus::view::canvas::navigate::nearest;
|
|
use cumulus::view::canvas::placement::{below_spot, child_spot, free_spot};
|
|
use cumulus::view::canvas::{Camera, Scene, SceneOptions, ScreenPos};
|
|
use ratatui::crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind};
|
|
use ratatui::crossterm::execute;
|
|
use ratatui::layout::{Constraint, Layout, Rect};
|
|
use ratatui::style::Stylize;
|
|
use ratatui::text::Line;
|
|
use ratatui::widgets::Paragraph;
|
|
use ratatui::{DefaultTerminal, Frame};
|
|
|
|
/// The playground's own keys.toml: `q` quits. (cumulus itself keeps `q`
|
|
/// free for macros; this shows how keys.toml changes a key.)
|
|
const KEYS_TOML: &str = r#"
|
|
[normal]
|
|
"q" = "close"
|
|
"#;
|
|
|
|
/// How long a sequence waits before its which-key popup shows (the leader's
|
|
/// shows right away).
|
|
const WHICH_KEY_DELAY: Duration = Duration::from_millis(300);
|
|
|
|
const NORMAL: &[Context] = &[Context::Canvas, Context::Normal, Context::Motions];
|
|
const MOVING: &[Context] = &[Context::Move];
|
|
|
|
fn main() -> io::Result<()> {
|
|
let doc = document_file::parse(include_str!("garden.cum")).map_err(io::Error::other)?;
|
|
let mut terminal = ratatui::try_init()?;
|
|
execute!(io::stdout(), EnableMouseCapture)?;
|
|
// On a crash, turn the mouse off too; ratatui's own hook restores the rest.
|
|
let restore_the_rest = std::panic::take_hook();
|
|
std::panic::set_hook(Box::new(move |info| {
|
|
let _ = execute!(io::stdout(), DisableMouseCapture);
|
|
restore_the_rest(info);
|
|
}));
|
|
let result = Playground::new(doc).run(&mut terminal);
|
|
let _ = execute!(io::stdout(), DisableMouseCapture);
|
|
ratatui::restore();
|
|
result
|
|
}
|
|
|
|
enum Mode {
|
|
Normal,
|
|
/// Moving the cursor's node: its whole branch, or just the node. The
|
|
/// undo step for the whole move builds up as it goes.
|
|
Move {
|
|
whole: bool,
|
|
change: Option<Change>,
|
|
},
|
|
}
|
|
|
|
/// A mouse drag in progress.
|
|
enum Drag {
|
|
/// Moving a node's branch: how far it has moved so far, and the undo
|
|
/// step for the whole drag.
|
|
Node { id: NodeId, from: ScreenPos, moved: CanvasOffset, change: Option<Change> },
|
|
/// Panning the view: the cell the mouse was last on.
|
|
Pan { last: ScreenPos },
|
|
}
|
|
|
|
struct Playground {
|
|
doc: Document,
|
|
history: History,
|
|
cursor: NodeId,
|
|
camera: Camera,
|
|
options: SceneOptions,
|
|
theme: Theme,
|
|
keymap: Keymap,
|
|
keys: KeyEngine,
|
|
which_key: bool,
|
|
mode: Mode,
|
|
mouse: MouseReader,
|
|
drag: Option<Drag>,
|
|
/// The command line, while it's open.
|
|
command_line: Option<CommandLine>,
|
|
command_history: CommandHistory,
|
|
/// The last frame drawn, to match clicks against.
|
|
scene: Scene,
|
|
canvas_area: Rect,
|
|
/// Set when the cursor moved, so the next frame brings it into view.
|
|
follow: bool,
|
|
message: String,
|
|
}
|
|
|
|
impl Playground {
|
|
fn new(doc: Document) -> Playground {
|
|
let (keymap, problems) = Keymap::from_toml(KEYS_TOML);
|
|
Playground {
|
|
cursor: doc.root(),
|
|
doc,
|
|
history: History::default(),
|
|
camera: Camera::default(),
|
|
options: SceneOptions::default(),
|
|
theme: Theme::default(),
|
|
keymap,
|
|
keys: KeyEngine::default(),
|
|
which_key: true,
|
|
mode: Mode::Normal,
|
|
mouse: MouseReader::default(),
|
|
drag: None,
|
|
command_line: None,
|
|
command_history: CommandHistory::default(),
|
|
scene: Scene::default(),
|
|
canvas_area: Rect::default(),
|
|
follow: true,
|
|
message: problems.join(" · "),
|
|
}
|
|
}
|
|
|
|
fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
|
|
loop {
|
|
terminal.draw(|frame| self.draw(frame))?;
|
|
// While a sequence is half typed, wake up now and then: its
|
|
// which-key popup may be due.
|
|
let wait =
|
|
if self.keys.is_pending() { Duration::from_millis(50) } else { Duration::from_secs(3600) };
|
|
if !event::poll(wait)? {
|
|
continue;
|
|
}
|
|
match event::read()? {
|
|
// Windows reports key releases too: only presses and repeats count.
|
|
Event::Key(event) if event.kind != KeyEventKind::Release => {
|
|
if let Some(key) = Key::from_event(event)
|
|
&& !self.key(key)
|
|
{
|
|
return Ok(());
|
|
}
|
|
}
|
|
Event::Mouse(event) => {
|
|
if let Some(gesture) = self.mouse.read(event, Instant::now()) {
|
|
self.gesture(gesture);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ----- Drawing --------------------------------------------------------
|
|
|
|
fn draw(&mut self, frame: &mut Frame) {
|
|
let [canvas, status] =
|
|
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(frame.area());
|
|
self.canvas_area = canvas;
|
|
self.scene = self.build_scene(canvas);
|
|
if self.follow {
|
|
self.follow = false;
|
|
if let Some(rect) = self.scene.rect_of(self.cursor)
|
|
&& self.camera.reveal(rect, canvas.as_size(), 3)
|
|
{
|
|
self.scene = self.build_scene(canvas);
|
|
}
|
|
}
|
|
frame.render_widget(&self.scene, canvas);
|
|
// While the command line is open, it takes the status line's place.
|
|
if let Some(line) = &self.command_line {
|
|
line.render(frame, status, canvas, &self.theme);
|
|
return;
|
|
}
|
|
if self.which_key_due()
|
|
&& let Some(hints) = self.keys.hints(&self.keymap.tables(self.contexts()))
|
|
{
|
|
frame.render_widget(WhichKey { hints: &hints, theme: &self.theme }, canvas);
|
|
}
|
|
self.draw_status(frame, status);
|
|
}
|
|
|
|
fn build_scene(&self, area: Rect) -> Scene {
|
|
Scene::build(&self.doc, self.cursor, self.camera, area.as_size(), &self.theme, self.options)
|
|
}
|
|
|
|
/// The bottom line: the mode, the zoom and the cursor's title, then a
|
|
/// message or key hints, and the keys typed so far on the right.
|
|
fn draw_status(&self, frame: &mut Frame, area: Rect) {
|
|
let (mode, hints) = match self.mode {
|
|
Mode::Normal => (
|
|
" NORMAL ",
|
|
"hjkl move · g go to · z folds · +/- zoom · a/o add · x/dd delete · M move · u undo · : command · <Space> menu · q quit",
|
|
),
|
|
Mode::Move { whole: true, .. } => {
|
|
(" MOVE ", "hjkl moves the branch (HJKL by 5) · M just this node · Enter done")
|
|
}
|
|
Mode::Move { whole: false, .. } => {
|
|
(" MOVE ", "hjkl moves just this node (HJKL by 5) · M the whole branch · Enter done")
|
|
}
|
|
};
|
|
let title = self.doc.node(self.cursor).map(|node| node.title().to_string()).unwrap_or_default();
|
|
let focus = if self.options.focus { " · focus" } else { "" };
|
|
let middle = if self.message.is_empty() { hints.dark_gray() } else { self.message.clone().yellow() };
|
|
let line = Line::from(vec![
|
|
mode.black().on_cyan().bold(),
|
|
format!(" {}{focus} │ {title} │ ", self.camera.zoom.name()).into(),
|
|
middle,
|
|
]);
|
|
frame.render_widget(Paragraph::new(line), area);
|
|
let typed = Line::from(format!("{} ", self.keys.shown()).cyan().bold());
|
|
frame.render_widget(Paragraph::new(typed).right_aligned(), area);
|
|
}
|
|
|
|
fn which_key_due(&self) -> bool {
|
|
let delay = if self.keys.first_key() == Some(self.keymap.leader()) {
|
|
Duration::ZERO
|
|
} else {
|
|
WHICH_KEY_DELAY
|
|
};
|
|
self.which_key && self.keys.pending_since().is_some_and(|since| since.elapsed() >= delay)
|
|
}
|
|
|
|
// ----- Keys -----------------------------------------------------------
|
|
|
|
fn contexts(&self) -> &'static [Context] {
|
|
match self.mode {
|
|
Mode::Normal => NORMAL,
|
|
Mode::Move { .. } => MOVING,
|
|
}
|
|
}
|
|
|
|
/// Handles a key press. Returns false to quit.
|
|
fn key(&mut self, key: Key) -> bool {
|
|
self.message.clear();
|
|
if self.command_line.is_some() {
|
|
return self.command_line_key(key);
|
|
}
|
|
let outcome = self.keys.press(key, &self.keymap.tables(self.contexts()));
|
|
match outcome {
|
|
Outcome::Run(typed) => return self.run_command(typed),
|
|
Outcome::Unknown(keys) => self.message = format!("{keys} does nothing here"),
|
|
Outcome::Waiting | Outcome::Cancelled => {}
|
|
}
|
|
true
|
|
}
|
|
|
|
/// Runs a command. Returns false to quit.
|
|
fn run_command(&mut self, typed: Typed) -> bool {
|
|
if let Mode::Move { .. } = self.mode {
|
|
self.move_command(typed);
|
|
return true;
|
|
}
|
|
let times = typed.times();
|
|
let steps = i32::try_from(times).unwrap_or(1);
|
|
match typed.command {
|
|
Command::Move(motion) => (0..times).for_each(|_| self.motion(motion)),
|
|
Command::Operate(Operator::Delete, Target::Current | Target::Branch) => self.delete_branch(),
|
|
Command::Operate(Operator::Delete, Target::Children) => self.delete_children(),
|
|
Command::DeleteNode => self.delete_node(),
|
|
Command::AddChild => self.add(Spot::LastChildOf(self.cursor)),
|
|
Command::AddBelow => self.add(Spot::After(self.cursor)),
|
|
Command::AddAbove => self.add(Spot::Before(self.cursor)),
|
|
Command::Undo => (0..times).for_each(|_| self.undo()),
|
|
Command::Redo => (0..times).for_each(|_| self.redo()),
|
|
Command::ZoomIn => self.zoom(true),
|
|
Command::ZoomOut => self.zoom(false),
|
|
Command::ToggleFold => self.toggle_fold(),
|
|
Command::OpenFold => self.set_fold(Fold::Open),
|
|
Command::CloseFold => self.set_fold(Fold::Closed),
|
|
Command::AutoFold => self.set_fold(Fold::Auto),
|
|
Command::OpenAllFolds => self.fold_all(Fold::Open),
|
|
Command::CloseAllFolds => self.fold_all(Fold::Closed),
|
|
Command::ToggleFocus => {
|
|
self.options.focus = !self.options.focus;
|
|
self.message = format!("focus {}", if self.options.focus { "on" } else { "off" });
|
|
}
|
|
Command::Center => self.center(),
|
|
Command::ScrollDown => self.camera.pan(0, 3 * steps),
|
|
Command::ScrollUp => self.camera.pan(0, -3 * steps),
|
|
Command::ScrollLeft => self.camera.pan(-6 * steps, 0),
|
|
Command::ScrollRight => self.camera.pan(6 * steps, 0),
|
|
Command::HalfPageDown => self.camera.pan(0, i32::from(self.canvas_area.height / 2)),
|
|
Command::HalfPageUp => self.camera.pan(0, -i32::from(self.canvas_area.height / 2)),
|
|
Command::MoveMode => self.mode = Mode::Move { whole: true, change: None },
|
|
Command::CommandLine => {
|
|
self.command_line = Some(CommandLine::new(&self.sources(), &self.command_history));
|
|
}
|
|
Command::ToggleWhichKey => {
|
|
self.which_key = !self.which_key;
|
|
self.message = format!("key hints {}", if self.which_key { "on" } else { "off" });
|
|
}
|
|
Command::Escape => {}
|
|
Command::Close => return false,
|
|
other => {
|
|
let help = help_for(Bound::Command(other));
|
|
self.message = format!("\"{help}\" comes in a later stage of the build");
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
fn motion(&mut self, motion: Motion) {
|
|
let doc = &self.doc;
|
|
let target = match motion {
|
|
Motion::Left => return self.go(Direction::Left),
|
|
Motion::Down => return self.go(Direction::Down),
|
|
Motion::Up => return self.go(Direction::Up),
|
|
Motion::Right => return self.go(Direction::Right),
|
|
Motion::Parent => doc.parent(self.cursor),
|
|
Motion::FirstChild => doc.children(self.cursor).first().copied(),
|
|
Motion::NextSibling => doc.next_sibling(self.cursor),
|
|
Motion::PreviousSibling => doc.previous_sibling(self.cursor),
|
|
Motion::First => Some(doc.root()),
|
|
Motion::Next | Motion::Previous | Motion::Last => {
|
|
let order = Visible::new(doc, self.cursor, self.options.focus).order;
|
|
let here = order.iter().position(|&id| id == self.cursor).unwrap_or(0);
|
|
match motion {
|
|
Motion::Next => order.get(here + 1).copied(),
|
|
Motion::Previous => here.checked_sub(1).and_then(|index| order.get(index).copied()),
|
|
_ => order.last().copied(),
|
|
}
|
|
}
|
|
_ => {
|
|
self.message = "search comes in a later stage of the build".into();
|
|
return;
|
|
}
|
|
};
|
|
self.jump(target);
|
|
}
|
|
|
|
/// Commands in move mode: hjkl moves the node or its branch.
|
|
fn move_command(&mut self, typed: Typed) {
|
|
let step = i32::try_from(typed.times()).unwrap_or(1);
|
|
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 => (-5 * step, 0),
|
|
Command::FarRight => (5 * step, 0),
|
|
Command::FarDown => (0, 5 * step),
|
|
Command::FarUp => (0, -5 * step),
|
|
Command::ToggleWhole => {
|
|
if let Mode::Move { whole, .. } = &mut self.mode {
|
|
*whole = !*whole;
|
|
}
|
|
return;
|
|
}
|
|
_ => {
|
|
if let Mode::Move { change: Some(change), .. } =
|
|
std::mem::replace(&mut self.mode, Mode::Normal)
|
|
{
|
|
self.history.record(change);
|
|
}
|
|
return;
|
|
}
|
|
};
|
|
let (id, offset) = (self.cursor, self.camera.cells(cols, rows));
|
|
let whole = matches!(self.mode, Mode::Move { whole: true, .. });
|
|
let result = self.doc.edit(|edit| {
|
|
if whole { edit.shift_branch(id, offset) } else { edit.update(id, |node| node.pos += offset) }
|
|
});
|
|
if let (Ok(((), next)), Mode::Move { change, .. }) = (result, &mut self.mode) {
|
|
*change = Some(match change.take() {
|
|
Some(so_far) => so_far.then(next),
|
|
None => next,
|
|
});
|
|
self.follow = true;
|
|
}
|
|
}
|
|
|
|
// ----- The command line -----------------------------------------------
|
|
|
|
/// A key while the command line is open. Returns false to quit.
|
|
fn command_line_key(&mut self, key: Key) -> bool {
|
|
let sources = self.sources();
|
|
let Some(line) = &mut self.command_line else { return true };
|
|
match line.key(key, &sources, &self.command_history) {
|
|
LineOutcome::Typing => true,
|
|
LineOutcome::Close => {
|
|
self.command_line = None;
|
|
true
|
|
}
|
|
LineOutcome::Run(text) => {
|
|
self.command_line = None;
|
|
if text.trim().is_empty() {
|
|
return true;
|
|
}
|
|
self.command_history.add(&text);
|
|
match parse(&text, &sources) {
|
|
Ok(call) => self.run_call(call),
|
|
Err(error) => {
|
|
self.message = error.to_string();
|
|
true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What the command line can name: this document, its nodes, the
|
|
/// palette's colors, its tags.
|
|
fn sources(&self) -> Sources {
|
|
let doc = &self.doc;
|
|
let nodes = doc.outline().into_iter().filter_map(|id| Some((id, doc.node(id)?.title().to_string())));
|
|
let mut tags: Vec<String> = doc
|
|
.outline()
|
|
.into_iter()
|
|
.filter_map(|id| doc.node(id))
|
|
.flat_map(|node| node.tags.iter().map(|tag| tag.name().to_string()).collect::<Vec<_>>())
|
|
.collect();
|
|
tags.sort();
|
|
tags.dedup();
|
|
Sources {
|
|
documents: vec![doc.title().to_string()],
|
|
nodes: nodes.collect(),
|
|
colors: self.theme.palette.keys().map(ToString::to_string).collect(),
|
|
styles: self.theme.node_styles.keys().map(ToString::to_string).collect(),
|
|
themes: vec!["default".into()],
|
|
settings: Vec::new(),
|
|
tags,
|
|
folder: std::env::current_dir().unwrap_or_default(),
|
|
}
|
|
}
|
|
|
|
/// Runs a command line. Returns false to quit.
|
|
fn run_call(&mut self, call: Call) -> bool {
|
|
let id = self.cursor;
|
|
match call {
|
|
Call::Quit { .. } | Call::QuitAll { .. } | Call::WriteQuit | Call::ClosePane => return false,
|
|
Call::Fold(fold) => self.set_fold(fold),
|
|
Call::Focus => {
|
|
self.options.focus = !self.options.focus;
|
|
self.message = format!("focus {}", if self.options.focus { "on" } else { "off" });
|
|
}
|
|
Call::Color(color) => {
|
|
self.edit(|edit| edit.update(id, |node| node.look.color = color));
|
|
}
|
|
Call::Border(border) => {
|
|
self.edit(|edit| edit.update(id, |node| node.look.border = Some(border)));
|
|
}
|
|
Call::Tags(changes) => {
|
|
self.edit(|edit| {
|
|
edit.update(id, |node| {
|
|
for change in changes {
|
|
match change {
|
|
TagChange::Add(tag) => node.tags.insert(tag),
|
|
TagChange::Remove(tag) => node.tags.remove(&tag),
|
|
};
|
|
}
|
|
})
|
|
});
|
|
}
|
|
Call::Rename(title) => {
|
|
let root = self.doc.root();
|
|
self.edit(|edit| {
|
|
edit.update(root, |node| {
|
|
if let Content::Idea(idea) = &mut node.content {
|
|
idea.title = title;
|
|
}
|
|
})
|
|
});
|
|
}
|
|
Call::Capture(text) => self.capture(&text),
|
|
Call::Link(target) => match Link::new(id, target) {
|
|
Some(link) => {
|
|
self.edit(|edit| edit.link(link));
|
|
}
|
|
None => self.message = "a node can't link to itself".into(),
|
|
},
|
|
Call::Unlink(target) => {
|
|
let keys: Vec<LinkKey> = match target.and_then(|target| LinkKey::new(id, target)) {
|
|
Some(key) => vec![key],
|
|
None => self.doc.links_of(id).map(Link::key).collect(),
|
|
};
|
|
self.edit(|edit| keys.iter().try_for_each(|&key| edit.unlink(key).map(drop)));
|
|
}
|
|
Call::Find(text) => self.find(&text),
|
|
Call::Version => self.message = cumulus::version(),
|
|
Call::Help(_) => {
|
|
self.message = "the help screen comes in stage 7; Tab completes any command".into()
|
|
}
|
|
other => self.message = format!("{other:?} comes in a later stage of the build"),
|
|
}
|
|
true
|
|
}
|
|
|
|
/// `:in`: adds an idea under the Inbox, a loose node made the first time.
|
|
fn capture(&mut self, text: &str) {
|
|
let Ok(title) = Title::parse(text) else { return };
|
|
let inbox = self
|
|
.doc
|
|
.loose()
|
|
.iter()
|
|
.copied()
|
|
.find(|&id| self.doc.node(id).is_some_and(|node| node.title().as_str() == "Inbox"));
|
|
let rects = box_rects(&self.doc, self.options.max_width, self.theme.glyphs);
|
|
let below_everything = rects.values().map(|rect| rect.bottom()).max().unwrap_or(0) + 2;
|
|
let size = CanvasSize::new(30, 3);
|
|
let added = self.edit(|edit| {
|
|
let inbox = match inbox {
|
|
Some(inbox) => inbox,
|
|
None => {
|
|
let pos = free_spot(CanvasPos::new(0, below_everything), size, &rects);
|
|
let title = Title::parse("Inbox").map_err(|_| EditError::Full)?;
|
|
edit.add(Node::idea(title, pos), Spot::Loose)?
|
|
}
|
|
};
|
|
let pos = child_spot(edit.doc(), inbox, size, &rects);
|
|
edit.add(Node::idea(title, pos), Spot::LastChildOf(inbox))
|
|
});
|
|
if added.is_some() {
|
|
self.message = format!("captured \"{text}\" to the Inbox");
|
|
}
|
|
}
|
|
|
|
/// `:find`: goes to the next node whose title has the text in it.
|
|
fn find(&mut self, text: &str) {
|
|
let wanted = text.to_lowercase();
|
|
let order = self.doc.outline();
|
|
let here = order.iter().position(|&id| id == self.cursor).unwrap_or(0);
|
|
let matches: Vec<NodeId> = order
|
|
.iter()
|
|
.copied()
|
|
.filter(|&id| {
|
|
self.doc.node(id).is_some_and(|node| node.title().as_str().to_lowercase().contains(&wanted))
|
|
})
|
|
.collect();
|
|
let next = matches.iter().copied().find(|&id| order.iter().position(|&o| o == id) > Some(here));
|
|
match next.or_else(|| matches.first().copied()) {
|
|
Some(id) => {
|
|
self.jump(Some(id));
|
|
self.message = format!("{} match(es) for \"{text}\"", matches.len());
|
|
}
|
|
None => self.message = format!("nothing matches \"{text}\""),
|
|
}
|
|
}
|
|
|
|
// ----- The mouse ------------------------------------------------------
|
|
|
|
fn gesture(&mut self, gesture: Gesture) {
|
|
let area = self.canvas_area;
|
|
let screen = |cell: Cell| {
|
|
ScreenPos::new(i32::from(cell.col) - i32::from(area.x), i32::from(cell.row) - i32::from(area.y))
|
|
};
|
|
match gesture {
|
|
Gesture::Press { at, .. } => {
|
|
let at = screen(at);
|
|
self.drag = Some(match self.scene.node_at(at) {
|
|
Some(id) => {
|
|
self.cursor = id;
|
|
Drag::Node { id, from: at, moved: CanvasOffset::default(), change: None }
|
|
}
|
|
None => Drag::Pan { last: at },
|
|
});
|
|
}
|
|
Gesture::DoublePress { .. } => self.message = "editing titles comes in stage 6".into(),
|
|
Gesture::Drag { at, .. } => self.drag_to(screen(at)),
|
|
Gesture::Release { .. } => {
|
|
if let Some(Drag::Node { change: Some(change), .. }) = self.drag.take() {
|
|
self.history.record(change);
|
|
}
|
|
}
|
|
Gesture::Scroll { direction, .. } => match direction {
|
|
Direction::Up => self.camera.pan(0, -3),
|
|
Direction::Down => self.camera.pan(0, 3),
|
|
Direction::Left => self.camera.pan(-6, 0),
|
|
Direction::Right => self.camera.pan(6, 0),
|
|
},
|
|
Gesture::RightPress { .. } => self.message = "the right-click menu comes in stage 7".into(),
|
|
}
|
|
}
|
|
|
|
fn drag_to(&mut self, at: ScreenPos) {
|
|
match &mut self.drag {
|
|
Some(Drag::Node { id, from, moved, change }) => {
|
|
let total = self.camera.cells(at.col - from.col, at.row - from.row);
|
|
let step = CanvasOffset::new(total.dx - moved.dx, total.dy - moved.dy);
|
|
let id = *id;
|
|
if step != CanvasOffset::default()
|
|
&& let Ok(((), next)) = self.doc.edit(|edit| edit.shift_branch(id, step))
|
|
{
|
|
*moved = total;
|
|
*change = Some(match change.take() {
|
|
Some(so_far) => so_far.then(next),
|
|
None => next,
|
|
});
|
|
}
|
|
}
|
|
Some(Drag::Pan { last }) => {
|
|
self.camera.pan(last.col - at.col, last.row - at.row);
|
|
*last = at;
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
|
|
// ----- What the commands do -------------------------------------------
|
|
|
|
/// Moves the cursor to the nearest node on show in a direction.
|
|
fn go(&mut self, direction: Direction) {
|
|
let visible = Visible::new(&self.doc, self.cursor, self.options.focus);
|
|
let rects = box_rects(&self.doc, self.options.max_width, self.theme.glyphs);
|
|
let Some(&from) = rects.get(&self.cursor) else { return };
|
|
let candidates = visible.order.iter().filter_map(|id| Some((*id, *rects.get(id)?)));
|
|
match nearest(from, direction, candidates) {
|
|
Some(id) => self.jump(Some(id)),
|
|
None => self.message = "nothing that way".into(),
|
|
}
|
|
}
|
|
|
|
fn jump(&mut self, target: Option<NodeId>) {
|
|
match target {
|
|
Some(id) => {
|
|
self.cursor = id;
|
|
self.follow = true;
|
|
}
|
|
None => self.message = "nothing there".into(),
|
|
}
|
|
}
|
|
|
|
fn zoom(&mut self, closer: bool) {
|
|
let zoom = if closer { self.camera.zoom.closer() } else { self.camera.zoom.further() };
|
|
if let Some(node) = self.doc.node(self.cursor) {
|
|
self.camera.set_zoom(zoom, node.pos);
|
|
}
|
|
self.follow = true;
|
|
}
|
|
|
|
fn center(&mut self) {
|
|
if let Some(node) = self.doc.node(self.cursor) {
|
|
self.camera.center_on(node.pos, self.canvas_area.as_size());
|
|
}
|
|
}
|
|
|
|
fn toggle_fold(&mut self) {
|
|
let Some(node) = self.doc.node(self.cursor) else { return };
|
|
let fold = match node.fold {
|
|
Fold::Closed => Fold::Open,
|
|
Fold::Open | Fold::Auto => Fold::Closed,
|
|
};
|
|
self.set_fold(fold);
|
|
}
|
|
|
|
fn set_fold(&mut self, fold: Fold) {
|
|
let id = self.cursor;
|
|
self.edit(|edit| edit.update(id, |node| node.fold = fold));
|
|
self.message = format!("fold: {fold:?}");
|
|
}
|
|
|
|
fn fold_all(&mut self, fold: Fold) {
|
|
let parents: Vec<NodeId> =
|
|
self.doc.outline().into_iter().filter(|&id| !self.doc.children(id).is_empty()).collect();
|
|
self.edit(|edit| parents.iter().try_for_each(|&id| edit.update(id, |node| node.fold = fold)));
|
|
}
|
|
|
|
/// Adds a "new idea" node at `spot`, placed next to its parent or
|
|
/// sibling, and moves the cursor to it.
|
|
fn add(&mut self, spot: Spot) {
|
|
let Ok(title) = Title::parse("new idea") else { return };
|
|
let node = Node::idea(title, Default::default());
|
|
let rects = box_rects(&self.doc, self.options.max_width, self.theme.glyphs);
|
|
let size = NodeBox::new(&node, String::new(), self.options.max_width, self.theme.glyphs).rect.size;
|
|
let pos = match spot {
|
|
Spot::LastChildOf(parent) => child_spot(&self.doc, parent, size, &rects),
|
|
_ => below_spot(self.cursor, size, &rects),
|
|
};
|
|
let added = self.edit(|edit| edit.add(Node { pos, ..node }, spot));
|
|
self.jump(added);
|
|
}
|
|
|
|
fn delete_node(&mut self) {
|
|
let id = self.cursor;
|
|
let next = self.doc.children(id).first().copied().or_else(|| self.doc.parent(id));
|
|
if self.edit(|edit| edit.remove_keeping_children(id)).is_some() {
|
|
self.cursor = next.unwrap_or(self.doc.root());
|
|
}
|
|
}
|
|
|
|
fn delete_branch(&mut self) {
|
|
let id = self.cursor;
|
|
let next = self.doc.parent(id).or_else(|| self.doc.previous_sibling(id));
|
|
if let Some(removed) = self.edit(|edit| edit.remove_branch(id)) {
|
|
self.cursor = next.unwrap_or(self.doc.root());
|
|
self.message = format!("deleted {} node(s) · u to undo", removed.len());
|
|
}
|
|
}
|
|
|
|
fn delete_children(&mut self) {
|
|
let children = self.doc.children(self.cursor).to_vec();
|
|
let removed = self.edit(|edit| {
|
|
let mut count = 0;
|
|
for &child in &children {
|
|
count += edit.remove_branch(child)?.len();
|
|
}
|
|
Ok(count)
|
|
});
|
|
if let Some(count) = removed {
|
|
self.message = format!("deleted {count} node(s) · u to undo");
|
|
}
|
|
}
|
|
|
|
fn undo(&mut self) {
|
|
let touched: Vec<NodeId> = match self.history.undo(&mut self.doc) {
|
|
Some(change) => change.nodes().collect(),
|
|
None => {
|
|
self.message = "nothing to undo".into();
|
|
return;
|
|
}
|
|
};
|
|
self.land_on(&touched);
|
|
}
|
|
|
|
fn redo(&mut self) {
|
|
let touched: Vec<NodeId> = match self.history.redo(&mut self.doc) {
|
|
Some(change) => change.nodes().collect(),
|
|
None => {
|
|
self.message = "nothing to redo".into();
|
|
return;
|
|
}
|
|
};
|
|
self.land_on(&touched);
|
|
}
|
|
|
|
/// After undo or redo: the cursor goes where the change happened.
|
|
fn land_on(&mut self, touched: &[NodeId]) {
|
|
let target = touched.iter().copied().find(|id| self.doc.contains(*id)).unwrap_or(self.doc.root());
|
|
self.jump(Some(target));
|
|
}
|
|
|
|
/// Runs an edit and records it for undo; shows the error if it's refused.
|
|
fn edit<T>(&mut self, steps: impl FnOnce(&mut Edit<'_>) -> Result<T, EditError>) -> Option<T> {
|
|
match self.doc.edit(steps) {
|
|
Ok((value, change)) => {
|
|
self.history.record(change);
|
|
Some(value)
|
|
}
|
|
Err(error) => {
|
|
self.message = error.to_string();
|
|
None
|
|
}
|
|
}
|
|
}
|
|
}
|