//! A playground for the canvas engine (stage 2 of the build): a sample //! document you can move around, zoom, fold and rearrange. The real keys, //! modes and command line come in later stages; this has just enough to try //! the canvas. //! //! Run it with `cargo run --example canvas`. //! //! | Keys | What they do | //! |----------------|-----------------------------------------------------| //! | `hjkl`, arrows | move to the nearest node that way | //! | `gh gl gj gk` | parent, first child, next / previous sibling | //! | `gg` | the root | //! | `HJKL` | pan the view | //! | `+` `-` | zoom in / out | //! | `za` `zf` `zi` | fold or unfold, auto-fold, focus | //! | `zz` | center the view on the cursor | //! | `a` `o` | add a child / a sibling below ("new idea") | //! | `x` `dd` | delete the node (its children move up) / the branch | //! | `M` | move mode: hjkl moves the branch, Enter or Esc ends | //! | `u` `Ctrl-r` | undo / redo | //! | `q` | quit | //! //! Mouse: click selects, dragging a node moves its branch, dragging empty //! space pans, the wheel scrolls. use std::io; use cumulus::model::{ CanvasOffset, Change, Document, Edit, EditError, Fold, History, Node, NodeId, Spot, Title, Visible, }; use cumulus::storage::document_file; use cumulus::ui::glyphs::Direction; use cumulus::ui::theme::Theme; use cumulus::view::canvas::boxes::{NodeBox, box_rects}; use cumulus::view::canvas::navigate::nearest; use cumulus::view::canvas::placement::{below_spot, child_spot}; use cumulus::view::canvas::{Camera, Scene, SceneOptions, ScreenPos}; use ratatui::crossterm::event::{ self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; use ratatui::crossterm::execute; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::{DefaultTerminal, Frame}; 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 } /// A mouse drag in progress. enum Drag { /// Moving a node's branch: where the drag started, how far the branch has /// moved so far, and the undo step for the whole drag. Node { id: NodeId, from: ScreenPos, moved: CanvasOffset, change: Option }, /// 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, /// The first key of a two-key command (`g`, `z`, `d`), waiting for the /// second. pending: Option, /// In move mode: the undo step for the whole move so far. moving: Option>, drag: Option, /// 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 { Playground { cursor: doc.root(), doc, history: History::default(), camera: Camera::default(), options: SceneOptions::default(), theme: Theme::default(), pending: None, moving: None, drag: None, scene: Scene::default(), canvas_area: Rect::default(), follow: true, message: String::new(), } } fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { loop { terminal.draw(|frame| self.draw(frame))?; match event::read()? { // Windows reports key releases too: only presses and repeats count. Event::Key(key) if key.kind != KeyEventKind::Release => { if !self.key(key) { return Ok(()); } } Event::Mouse(mouse) => self.mouse(mouse), _ => {} } } } 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); frame.render_widget(self.status_line(), status); } fn build_scene(&self, area: Rect) -> Scene { Scene::build(&self.doc, self.cursor, self.camera, area.as_size(), &self.theme, self.options) } fn status_line(&self) -> Line<'static> { let mode = if self.moving.is_some() { " MOVE " } else { " CANVAS " }; 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 hints = if self.message.is_empty() { "hjkl move · gh/gl tree · +/- zoom · za/zf/zi fold · a/o add · x/dd delete · M move · u undo · q quit" .to_string() } else { self.message.clone() }; let pending = self.pending.map(|key| format!(" {key}")).unwrap_or_default(); Line::from(vec![ mode.black().on_cyan().bold(), format!(" {}{focus} │ {title} │ ", self.camera.zoom.name()).into(), hints.dark_gray(), pending.cyan().bold(), ]) } /// Handles a key. Returns false to quit. fn key(&mut self, key: KeyEvent) -> bool { self.message.clear(); if self.moving.is_some() { self.move_key(key); return true; } if let Some(first) = self.pending.take() { if let KeyCode::Char(second) = key.code { self.sequence(first, second); } return true; } let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); match key.code { KeyCode::Char('q') => return false, KeyCode::Char('c') if ctrl => return false, KeyCode::Char('r') if ctrl => self.redo(), KeyCode::Char('h') | KeyCode::Left => self.go(Direction::Left), KeyCode::Char('j') | KeyCode::Down => self.go(Direction::Down), KeyCode::Char('k') | KeyCode::Up => self.go(Direction::Up), KeyCode::Char('l') | KeyCode::Right => self.go(Direction::Right), KeyCode::Char('H') => self.camera.pan(-8, 0), KeyCode::Char('J') => self.camera.pan(0, 4), KeyCode::Char('K') => self.camera.pan(0, -4), KeyCode::Char('L') => self.camera.pan(8, 0), KeyCode::Char('+' | '=') => self.zoom(true), KeyCode::Char('-') => self.zoom(false), KeyCode::Char('a') => self.add_child(), KeyCode::Char('o') => self.add_below(), KeyCode::Char('x') => self.delete_node(), KeyCode::Char('u') => self.undo(), KeyCode::Char('M') => { self.moving = Some(None); self.message = "hjkl moves the branch (HJKL by 5) · Enter or Esc ends".into(); } KeyCode::Char(first @ ('g' | 'z' | 'd')) => self.pending = Some(first), _ => {} } true } /// The second key of `g…`, `z…` and `d…`. fn sequence(&mut self, first: char, second: char) { match (first, second) { ('g', 'g') => self.jump(Some(self.doc.root())), ('g', 'h') => self.jump(self.doc.parent(self.cursor)), ('g', 'l') => self.jump(self.doc.children(self.cursor).first().copied()), ('g', 'j') => self.jump(self.doc.next_sibling(self.cursor)), ('g', 'k') => self.jump(self.doc.previous_sibling(self.cursor)), ('z', 'a') => self.toggle_fold(), ('z', 'f') => self.set_fold(Fold::Auto), ('z', 'i') => { self.options.focus = !self.options.focus; self.message = format!("focus {}", if self.options.focus { "on" } else { "off" }); } ('z', 'z') => self.center(), ('d', 'd') => self.delete_branch(), _ => self.message = format!("{first}{second} does nothing here"), } } fn move_key(&mut self, key: KeyEvent) { let (cols, rows) = match key.code { KeyCode::Char('h') | KeyCode::Left => (-1, 0), KeyCode::Char('l') | KeyCode::Right => (1, 0), KeyCode::Char('j') | KeyCode::Down => (0, 1), KeyCode::Char('k') | KeyCode::Up => (0, -1), KeyCode::Char('H') => (-5, 0), KeyCode::Char('L') => (5, 0), KeyCode::Char('J') => (0, 5), KeyCode::Char('K') => (0, -5), KeyCode::Enter | KeyCode::Esc | KeyCode::Char('M') => { if let Some(Some(change)) = self.moving.take() { self.history.record(change); } return; } _ => return, }; let (id, offset) = (self.cursor, self.camera.cells(cols, rows)); if let Ok(((), next)) = self.doc.edit(|edit| edit.shift_branch(id, offset)) { let so_far = self.moving.take().flatten(); self.moving = Some(Some(match so_far { Some(change) => change.then(next), None => next, })); self.follow = true; } } fn mouse(&mut self, mouse: MouseEvent) { let area = self.canvas_area; let at = ScreenPos::new( i32::from(mouse.column) - i32::from(area.x), i32::from(mouse.row) - i32::from(area.y), ); match mouse.kind { MouseEventKind::Down(MouseButton::Left) => { 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 }, }); } MouseEventKind::Drag(MouseButton::Left) => 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 => {} }, MouseEventKind::Up(MouseButton::Left) => { if let Some(Drag::Node { change: Some(change), .. }) = self.drag.take() { self.history.record(change); } } MouseEventKind::ScrollDown => self.camera.pan(0, 3), MouseEventKind::ScrollUp => self.camera.pan(0, -3), MouseEventKind::ScrollRight => self.camera.pan(6, 0), MouseEventKind::ScrollLeft => self.camera.pan(-6, 0), _ => {} } } /// 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) { 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 add_child(&mut self) { let Some(node) = self.new_idea() else { return }; 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 parent = self.cursor; let node = Node { pos: child_spot(&self.doc, parent, size, &rects), ..node }; let added = self.edit(|edit| edit.add(node, Spot::LastChildOf(parent))); self.jump(added); } fn add_below(&mut self) { let Some(node) = self.new_idea() else { return }; 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 sibling = self.cursor; let node = Node { pos: below_spot(sibling, size, &rects), ..node }; let added = self.edit(|edit| edit.add(node, Spot::After(sibling))); self.jump(added); } fn new_idea(&self) -> Option { Title::parse("new idea").ok().map(|title| Node::idea(title, Default::default())) } 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 undo(&mut self) { let touched: Vec = 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 = 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(&mut self, steps: impl FnOnce(&mut Edit<'_>) -> Result) -> Option { match self.doc.edit(steps) { Ok((value, change)) => { self.history.record(change); Some(value) } Err(error) => { self.message = error.to_string(); None } } } }