//! A playground for the canvas (stages 2 to 5 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. //! //! It runs the way the app will: the key engine turns keys into commands, //! the canvas view (or the commands every view shares) turns those into //! actions, and [`Playground::update`] carries them out. //! //! 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 | //! | `ma` `'a` | mark a node / jump back to it (`''`: where you were) | //! | `` | the action menu (`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::app::action::{Action, AppAction, Message, Tone}; use cumulus::app::buffer::Buffer; use cumulus::app::mode::{Grab, Mode}; use cumulus::app::{calls, commands}; use cumulus::command::history::History as CommandHistory; use cumulus::command::parse::{SetArg, Sources, parse}; use cumulus::input::command::{Bound, Command, help_for}; use cumulus::input::engine::{KeyEngine, Outcome}; use cumulus::input::key::Key; use cumulus::input::keymap::Keymap; use cumulus::input::mouse::{Gesture, MouseReader}; use cumulus::model::Document; use cumulus::storage::document_file; use cumulus::ui::command_line::{CommandLine, LineOutcome, PopupMode}; use cumulus::ui::theme::Theme; use cumulus::ui::which_key::WhichKey; use cumulus::view::canvas::CanvasView; use cumulus::view::{View, ViewContext, ViewSettings}; 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); 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 } /// What the view looks at. struct State { buffer: Buffer, mode: Mode, theme: Theme, settings: ViewSettings, } impl State { fn ctx(&self) -> ViewContext<'_> { ViewContext { doc: self.buffer.doc(), cursor: self.buffer.cursor(), mode: &self.mode, theme: &self.theme, settings: &self.settings, } } } struct Playground { /// The one view (splits come in stage 7). view: CanvasView, state: State, keymap: Keymap, keys: KeyEngine, mouse: MouseReader, which_key: bool, command_history: CommandHistory, /// When the command line's popup shows (`:set completion_popup=on-tab`). popup: PopupMode, /// The status bar's message, until the next key. message: Option, quit: bool, } impl Playground { fn new(doc: Document) -> Playground { let (keymap, problems) = Keymap::from_toml(KEYS_TOML); Playground { view: CanvasView::new(), state: State { buffer: Buffer::new(doc), mode: Mode::Normal, theme: Theme::default(), settings: ViewSettings::default(), }, keymap, keys: KeyEngine::default(), mouse: MouseReader::default(), which_key: true, command_history: CommandHistory::default(), popup: PopupMode::default(), message: (!problems.is_empty()).then(|| Message::problem(problems.join(" · "))), quit: false, } } fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { while !self.quit { 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); } } Event::Mouse(event) => { if let Some(gesture) = self.mouse.read(event, Instant::now()) { self.gesture(gesture); } } _ => {} } } Ok(()) } // ----- Input ---------------------------------------------------------- fn gesture(&mut self, gesture: Gesture) { let actions = self.view.mouse(gesture, &self.state.ctx()); self.update(actions); } fn key(&mut self, key: Key) { self.message = None; if let Mode::Command(_) = self.state.mode { return self.command_line_key(key); } let tables = self.keymap.tables(self.view.contexts(&self.state.mode)); match self.keys.press(key, &tables) { Outcome::Run(typed) => { // The view has the first say; the shared commands get the rest. let ctx = self.state.ctx(); let actions = self.view.command(&typed, &ctx).or_else(|| commands::shared(&typed, &ctx)); let actions = actions.unwrap_or_else(|| { let later = format!("\"{}\" comes in a later stage of the build", describe(typed.command)); vec![Action::say(Message::info(later))] }); self.update(actions); } Outcome::Unknown(keys) => self.message = Some(Message::info(format!("{keys} does nothing here"))), Outcome::Waiting | Outcome::Cancelled => {} } } /// A key while the command line is open. fn command_line_key(&mut self, key: Key) { let sources = self.sources(); let Mode::Command(line) = &mut self.state.mode else { return }; match line.key(key, &sources, &self.command_history) { LineOutcome::Typing => {} LineOutcome::Close => self.state.mode = Mode::Normal, LineOutcome::Run(text) => { self.state.mode = Mode::Normal; if text.trim().is_empty() { return; } self.command_history.add(&text); let actions = match parse(&text, &sources) { Ok(call) => calls::actions_for(call, &self.state.ctx()), Err(error) => vec![Action::say(Message::problem(error.to_string()))], }; self.update(actions); } } } /// What the command line can name: this document, its nodes, the /// palette's colors, its tags. fn sources(&self) -> Sources { let doc = self.state.buffer.doc(); let nodes = doc.outline().into_iter().filter_map(|id| Some((id, doc.node(id)?.title().to_string()))); let mut tags: Vec = doc .outline() .into_iter() .filter_map(|id| doc.node(id)) .flat_map(|node| node.tags.iter().map(|tag| tag.name().to_string()).collect::>()) .collect(); tags.sort(); tags.dedup(); Sources { documents: vec![doc.title().to_string()], nodes: nodes.collect(), colors: self.state.theme.palette.keys().map(ToString::to_string).collect(), styles: self.state.theme.node_styles.keys().map(ToString::to_string).collect(), themes: vec!["default".into()], settings: vec!["completion_popup".into()], tags, folder: std::env::current_dir().unwrap_or_default(), } } // ----- Update --------------------------------------------------------- /// Carries out actions in order. A problem stops the rest, since they /// may count on it. fn update(&mut self, actions: Vec) { for action in actions { let message = match action { Action::Doc(action) => self.state.buffer.apply(action), Action::App(action) => self.app(action), }; if let Some(message) = message { let problem = message.tone == Tone::Problem; self.message = Some(message); if problem { break; } } } } fn app(&mut self, action: AppAction) -> Option { match action { AppAction::EnterMoveMode => self.state.mode = Mode::Move(Grab::new()), AppAction::ToggleWhole => { if let Mode::Move(grab) = &mut self.state.mode { grab.whole = !grab.whole; } } AppAction::LeaveMode => self.state.mode = Mode::Normal, AppAction::OpenCommandLine => { let line = CommandLine::new(&self.sources(), &self.command_history, self.popup); self.state.mode = Mode::Command(line); } AppAction::ToggleFocus => { let focus = &mut self.state.settings.focus; *focus = !*focus; let text = if *focus { "focus on: branches the cursor isn't in fold away" } else { "focus off" }; return Some(Message::info(text)); } AppAction::ToggleWhichKey => { self.which_key = !self.which_key; return Some(Message::info(if self.which_key { "key hints on" } else { "key hints off" })); } AppAction::Set(arg) => return Some(self.set(arg)), AppAction::Quit => self.quit = true, AppAction::Say(message) => return Some(message), } None } /// `:set`. The playground has one setting; the app's come in stage 8. fn set(&mut self, arg: SetArg) -> Message { let value = match arg { SetArg::Value(_, value) => value, SetArg::Reset(_) => popup_name(PopupMode::default()).to_string(), SetArg::Show(_) => { return Message::info(format!("completion_popup = {}", popup_name(self.popup))); } SetArg::On(_) | SetArg::Off(_) | SetArg::Toggle(_) => { return Message::problem("completion_popup takes a value: :set completion_popup=on-tab"); } }; match PopupMode::named(&value) { Some(popup) => { self.popup = popup; Message::done(format!("completion_popup = {value}")) } None => { let values: Vec<&str> = PopupMode::VALUES.iter().map(|(name, _)| *name).collect(); Message::problem(format!("completion_popup can be {}", values.join(" or "))) } } } // ----- Drawing -------------------------------------------------------- fn draw(&mut self, frame: &mut Frame) { let [canvas, status] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(frame.area()); let ctx = self.state.ctx(); self.view.layout(canvas, &ctx); self.view.render(frame, &ctx); // While the command line is open, it takes the status line's place. if let Mode::Command(line) = &self.state.mode { line.render(frame, status, canvas, &self.state.theme); return; } if self.which_key_due() && let Some(hints) = self.keys.hints(&self.keymap.tables(self.view.contexts(&self.state.mode))) { frame.render_widget(WhichKey { hints: &hints, theme: &self.state.theme }, canvas); } self.draw_status(frame, status); } /// 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 hints = match self.state.mode { Mode::Move(Grab { whole: true, .. }) => { "hjkl moves the branch (HJKL by 5) · M just this node · Enter done" } Mode::Move(_) => "hjkl moves just this node (HJKL by 5) · M the whole branch · Enter done", Mode::Normal | Mode::Command(_) => { "hjkl move · g go to · z folds · +/- zoom · a/o add · x/dd delete · M move · u undo · : command · menu · q quit" } }; let doc = self.state.buffer.doc(); let title = doc.node(self.state.buffer.cursor()).map(|node| node.title().to_string()).unwrap_or_default(); let focus = if self.state.settings.focus { " · focus" } else { "" }; let middle = match &self.message { None => hints.dark_gray(), Some(Message { text, tone: Tone::Problem }) => text.clone().light_red(), Some(Message { text, tone: Tone::Done }) => text.clone().green(), Some(Message { text, tone: Tone::Info }) => text.clone().yellow(), }; let line = Line::from(vec![ format!(" {} ", self.state.mode.label()).black().on_cyan().bold(), format!(" {}{focus} │ {title} │ ", self.view.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) } } /// What a command does, in words: its own help, or its operator's. fn describe(command: Command) -> &'static str { match (help_for(Bound::Command(command)), command) { ("", Command::Operate(operator, _)) => help_for(Bound::Operator(operator)), (help, _) => help, } } /// A completion popup setting's name, as `:set` takes it. fn popup_name(popup: PopupMode) -> &'static str { PopupMode::VALUES.iter().find(|(_, mode)| *mode == popup).map_or("?", |(name, _)| *name) } /// The playground driven by typed keys and mouse gestures on a test /// terminal: what this stage says works, checked end to end. #[cfg(test)] mod tests { use super::*; use cumulus::input::key::parse_keys; use cumulus::input::keymap::DEFAULT_LEADER; use cumulus::input::mouse::Cell; use cumulus::model::{CanvasPos, NodeId}; use ratatui::Terminal; use ratatui::backend::TestBackend; struct Harness { playground: Playground, terminal: Terminal, } impl Harness { fn new() -> Harness { let doc = document_file::parse(include_str!("garden.cum")).unwrap(); let terminal = Terminal::new(TestBackend::new(100, 30)).unwrap(); let mut harness = Harness { playground: Playground::new(doc), terminal }; harness.draw(); harness } fn draw(&mut self) { let Harness { playground, terminal } = self; terminal.draw(|frame| playground.draw(frame)).unwrap(); } /// Types keys written in vim notation, drawing after each one as the /// event loop does. fn type_keys(&mut self, keys: &str) { for key in parse_keys(keys, DEFAULT_LEADER).unwrap() { self.playground.key(key); self.draw(); } } fn mouse(&mut self, gesture: Gesture) { self.playground.gesture(gesture); self.draw(); } /// The title of the cursor's node. fn cursor(&self) -> String { let buffer = &self.playground.state.buffer; buffer.doc().node(buffer.cursor()).unwrap().title().to_string() } fn message(&self) -> &str { self.playground.message.as_ref().map_or("", |message| &message.text) } /// The node with this title. fn node(&self, title: &str) -> NodeId { let doc = self.playground.state.buffer.doc(); doc.outline().into_iter().find(|&id| doc.node(id).unwrap().title().as_str() == title).unwrap() } fn pos(&self, title: &str) -> CanvasPos { self.playground.state.buffer.doc().node(self.node(title)).unwrap().pos } /// What's on screen, row by row. fn screen(&self) -> String { let buffer = self.terminal.backend().buffer(); let rows = buffer.content().chunks(usize::from(buffer.area.width)); rows.map(|row| row.iter().map(|cell| cell.symbol()).collect::() + "\n").collect() } } #[test] fn marks_counts_and_undo() { let mut h = Harness::new(); h.type_keys("glma"); assert_eq!(h.cursor(), "Soil"); assert_eq!(h.message(), "mark a is on \"Soil\": 'a comes back here"); h.type_keys("gg'a"); assert_eq!(h.cursor(), "Soil"); h.type_keys("''"); assert_eq!(h.cursor(), "Garden plans", "'' goes back to before the last jump"); h.type_keys("'a2dd"); assert_eq!(h.message(), "deleted 4 nodes · u to undo", "Soil, and Raised beds with its two"); assert_eq!(h.cursor(), "Watering"); assert!(!h.screen().contains("Raised beds")); h.type_keys("u"); assert_eq!(h.cursor(), "Soil"); assert!(h.screen().contains("Raised beds")); } #[test] fn move_mode_moves_the_branch_as_one_undo_step() { let mut h = Harness::new(); h.type_keys("glgjM"); assert_eq!(h.cursor(), "Raised beds"); assert_eq!(h.playground.state.mode.label(), "MOVE"); h.type_keys("3lJ"); assert_eq!(h.playground.state.mode.label(), "NORMAL"); assert_eq!(h.pos("Raised beds"), CanvasPos::new(25, 12), "3 right, 5 down"); assert_eq!(h.pos("Cedar, untreated"), CanvasPos::new(46, 10), "its branch came along"); h.type_keys("u"); assert_eq!(h.pos("Raised beds"), CanvasPos::new(22, 7)); assert_eq!(h.pos("Cedar, untreated"), CanvasPos::new(43, 5)); } #[test] fn dragging_a_node_moves_its_branch() { let mut h = Harness::new(); let soil = h.playground.view.camera().to_screen(h.pos("Soil")); let at = |col: i32, row: i32| Cell { col: u16::try_from(col).unwrap(), row: u16::try_from(row).unwrap() }; let (start, end) = (at(soil.col + 1, soil.row + 1), at(soil.col + 5, soil.row + 3)); h.mouse(Gesture::Press { at: start, ctrl: false }); assert_eq!(h.cursor(), "Soil", "the press selects"); h.mouse(Gesture::Drag { from: start, at: end, ctrl: false }); h.mouse(Gesture::Release { at: end, dragged: true }); assert_eq!(h.pos("Soil"), CanvasPos::new(26, 4)); h.type_keys("u"); assert_eq!(h.pos("Soil"), CanvasPos::new(22, 2)); } #[test] fn command_lines_run_through_the_same_actions() { let mut h = Harness::new(); h.type_keys(":color teal"); let root = h.playground.state.buffer.doc().node(h.node("Garden plans")).unwrap(); assert_eq!(root.look.color.as_ref().map(|color| color.as_str()), Some("teal")); h.type_keys(":in mulch"); assert_eq!(h.message(), "captured \"mulch\" to the Inbox"); assert_eq!(h.playground.state.buffer.doc().parent(h.node("mulch")), Some(h.node("Inbox"))); assert_eq!(h.cursor(), "Garden plans", "capturing leaves the cursor where it was"); h.type_keys(":find drip"); assert_eq!(h.cursor(), "Drip line"); assert_eq!(h.message(), "\"drip\": match 1 of 1"); h.type_keys(":set completion_popup=on-tab"); assert_eq!(h.playground.popup, PopupMode::OnTab); h.type_keys(":frobnicate"); assert_eq!(h.playground.message.as_ref().map(|message| message.tone), Some(Tone::Problem)); } #[test] fn adding_folding_zooming_and_quitting() { let mut h = Harness::new(); h.type_keys("a"); assert_eq!(h.cursor(), "new idea"); h.type_keys("u"); assert_eq!(h.cursor(), "Garden plans", "undo goes back to where the change was"); h.type_keys("glgjza"); assert_eq!(h.cursor(), "Raised beds"); assert!(h.screen().contains("+2"), "a folded branch shows how many nodes it hides"); assert!(!h.screen().contains("40 cm high")); h.type_keys("-"); assert!(h.screen().lines().last().unwrap().contains("Far"), "the status bar shows the zoom"); h.type_keys("q"); assert!(h.playground.quit); } #[test] fn keys_from_later_stages_say_so() { let mut h = Harness::new(); h.type_keys("yy"); assert_eq!(h.message(), "\"yank (copy)…\" comes in a later stage of the build"); h.type_keys("i"); assert_eq!(h.message(), "\"edit the title\" comes in a later stage of the build"); } }