//! A playground for the canvas (stages 2 to 6 of the build): a sample //! document to move around, zoom, fold, rearrange and write in, with the real //! vim keys, the which-key popup and the `:` command line. The other views //! come in stage 7. //! //! 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. While a title is //! being edited, keys go to the edit instead. //! //! 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 | //! | `i` `A` `Enter` | edit the title, typing at its end (`I`: at its start) | //! | `cc` `S` | type the title anew | //! | `a` `o` `O` | add a child / a sibling below / above, and type its title | //! | `x` `dd` `dib` | delete the node / the branch / the children | //! | `>>` `<<` | the node goes under the one above it / out a level | //! | `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 | //! //! Typing a title (INSERT, a bar cursor): Enter is done. After `a`, `o` or //! `O`, Enter starts the next idea instead, Tab puts the new idea under the //! one above, Shift-Tab takes it out a level, and an idea left empty goes //! away. Esc gives vim keys inside the title (TEXT, a block cursor): //! `h l w b e 0 ^ $`, `f t F T ; ,`, `d c y` with any of those or with //! `iw aw i" a" i( a(`, `x X s S D C Y r ~ p P`, `u Ctrl-r .`, counts //! (`3x`, `d2f.`), `i a I A` to type again, `o O` for a new sibling. Esc //! again: done. //! //! Mouse: click selects, dragging a node moves its branch, dragging empty //! space pans, the wheel scrolls. A double-click on a title edits it from //! that letter; while editing, a click in the title moves the text cursor, //! and a click anywhere else finishes the edit. 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::registers::Registers; use cumulus::app::text_edit::TextEdit; 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::cursor::SetCursorStyle; 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 and give the cursor back its own shape // 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, SetCursorStyle::DefaultUserShape); restore_the_rest(info); })); let result = Playground::new(doc).run(&mut terminal); let _ = execute!(io::stdout(), DisableMouseCapture, SetCursorStyle::DefaultUserShape); 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, /// Yanked and deleted text, kept from one edit to the next. registers: Registers, 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, /// The cursor shape last sent to the terminal. cursor_shape: SetCursorStyle, 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(), registers: Registers::default(), mouse: MouseReader::default(), which_key: true, command_history: CommandHistory::default(), popup: PopupMode::default(), message: (!problems.is_empty()).then(|| Message::problem(problems.join(" · "))), cursor_shape: SetCursorStyle::DefaultUserShape, quit: false, } } fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { while !self.quit { terminal.draw(|frame| self.draw(frame))?; self.set_cursor_shape()?; // 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(()) } /// Gives the terminal's cursor the mode's shape, when that changed. fn set_cursor_shape(&mut self) -> io::Result<()> { let shape = cursor_shape(&self.state.mode); if shape != self.cursor_shape { execute!(io::stdout(), shape)?; self.cursor_shape = shape; } Ok(()) } // ----- Input ---------------------------------------------------------- fn gesture(&mut self, gesture: Gesture) { // A click drops a half-typed key sequence: a `d` typed before // clicking somewhere else mustn't wait to delete what comes next. if matches!(gesture, Gesture::Press { .. } | Gesture::DoublePress { .. } | Gesture::RightPress { .. }) { self.keys.reset(); } let actions = self.view.mouse(gesture, &self.state.ctx()); self.update(actions); } fn key(&mut self, key: Key) { self.message = None; match self.state.mode { Mode::Command(_) => return self.command_line_key(key), Mode::Text(_) => return self.text_key(key), Mode::Normal | Mode::Move(_) => {} } 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 a title is edited: typed into it, or a vim key inside it. fn text_key(&mut self, key: Key) { let tables = self.keymap.tables(self.view.contexts(&self.state.mode)); // The edit comes out of the mode while it works, so it can look at // the document; it goes back in before its actions run. let Mode::Text(mut edit) = std::mem::take(&mut self.state.mode) else { return }; let ctx = self.state.ctx(); let actions = if edit.editor.is_typing() { // Typing: a key the table doesn't bind types itself. edit.typing(key, tables.bound(&[key]), &ctx) } else { match self.keys.press(key, &tables) { Outcome::Run(typed) => edit.vim(&typed, &mut self.registers, &ctx), Outcome::Unknown(keys) => { vec![Action::say(Message::info(format!("{keys} does nothing here")))] } Outcome::Waiting | Outcome::Cancelled => Vec::new(), } }; self.state.mode = Mode::Text(edit); self.update(actions); } /// 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::StartEdit(start) => { let started = TextEdit::start(&self.state.ctx(), start); match started { Ok(edit) => self.state.mode = Mode::Text(edit), Err(message) => return Some(message), } } AppAction::TextCursor(at) => { if let Mode::Text(edit) = &mut self.state.mode { edit.editor.set_cursor(at); } } 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 (mode, theme) = (&self.state.mode, &self.state.theme); let typing = matches!(mode, Mode::Text(edit) if edit.editor.is_typing()); let hints = match 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::Text(edit) if typing && edit.new_node => { "Enter the next idea · Tab under the one above · Shift-Tab out a level · Esc vim keys" } Mode::Text(_) if typing => "Enter done · Esc vim keys", Mode::Text(_) => { "w b e 0 $ f t move · d c y + a motion · x r ~ p · u undo · . repeat · i a type · o/O new idea · Esc done" } Mode::Normal | Mode::Command(_) => { "hjkl move · g go to · z folds · +/- zoom · i edit · a/o add · x/dd delete · >>/<< in/out · M move · u undo · : command · menu · q quit" } }; // While a title is edited, the status bar shows it as typed. let title = match mode { Mode::Text(edit) => edit.editor.text().to_string(), _ => { let buffer = &self.state.buffer; buffer.doc().node(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 label_color = if typing { theme.insert } else { theme.accent }; let line = Line::from(vec![ format!(" {} ", mode.label()).black().bg(label_color).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) } } /// The cursor's shape in a mode, as in vim: a bar where typed letters go in /// between two others, a block on the letter vim keys act on, and the /// terminal's own shape where the cursor is hidden anyway. fn cursor_shape(mode: &Mode) -> SetCursorStyle { match mode { Mode::Text(edit) if !edit.editor.is_typing() => SetCursorStyle::SteadyBlock, Mode::Text(_) | Mode::Command(_) => SetCursorStyle::SteadyBar, Mode::Normal | Mode::Move(_) => SetCursorStyle::DefaultUserShape, } } /// 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 cumulus::view::canvas::ScreenPos; 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(); } /// A click on a cell of the screen. fn click(&mut self, col: i32, row: i32) { let at = cell(col, row); self.mouse(Gesture::Press { at, ctrl: false }); self.mouse(Gesture::Release { at, dragged: false }); } /// A double-click, as the mouse reader reports it: a press, then a /// second one on the same cell. fn double_click(&mut self, col: i32, row: i32) { let at = cell(col, row); self.click(col, row); self.mouse(Gesture::DoublePress { at }); self.mouse(Gesture::Release { at, dragged: false }); } /// The title of the cursor's node. fn cursor(&self) -> String { let buffer = &self.playground.state.buffer; self.title(buffer.cursor()) } fn title(&self, id: NodeId) -> String { self.playground.state.buffer.doc().node(id).unwrap().title().to_string() } fn label(&self) -> &'static str { self.playground.state.mode.label() } /// The title being edited, as typed so far. fn draft(&self) -> &str { match &self.playground.state.mode { Mode::Text(edit) => edit.editor.text(), _ => "", } } /// Where the text cursor is in the title being edited: a byte offset. fn draft_cursor(&self) -> Option { match &self.playground.state.mode { Mode::Text(edit) => Some(edit.editor.cursor()), _ => None, } } 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 children(&self, title: &str) -> Vec { let doc = self.playground.state.buffer.doc(); doc.children(self.node(title)).iter().map(|&id| self.title(id)).collect() } fn parent(&self, title: &str) -> String { self.title(self.playground.state.buffer.doc().parent(self.node(title)).unwrap()) } fn pos(&self, title: &str) -> CanvasPos { self.playground.state.buffer.doc().node(self.node(title)).unwrap().pos } /// Where a node's box starts on screen: its top-left corner. Its /// title starts 2 cells right of that (the border, a space) and a /// row down. fn on_screen(&self, title: &str) -> ScreenPos { self.playground.view.camera().to_screen(self.pos(title)) } /// The terminal's cursor, when it shows. fn text_cursor(&self) -> Option<(i32, i32)> { let backend = self.terminal.backend(); let at = backend.cursor_position(); backend.cursor_visible().then(|| (i32::from(at.x), i32::from(at.y))) } /// 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() } } fn cell(col: i32, row: i32) -> Cell { Cell { col: u16::try_from(col).unwrap(), row: u16::try_from(row).unwrap() } } #[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.label(), "MOVE"); h.type_keys("3lJ"); assert_eq!(h.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.on_screen("Soil"); let (start, end) = (cell(soil.col + 1, soil.row + 1), cell(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.parent("mulch"), "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("aMulch"); assert_eq!(h.cursor(), "Mulch"); 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 typing_a_title_in_its_box() { let mut h = Harness::new(); h.type_keys("gli mix"); assert_eq!(h.label(), "INSERT"); assert!(h.screen().contains("Soil mix"), "typed into the box:\n{}", h.screen()); let soil = h.on_screen("Soil mix"); assert_eq!(h.text_cursor(), Some((soil.col + 2 + 8, soil.row + 1)), "after the last letter"); assert_eq!(cursor_shape(&h.playground.state.mode), SetCursorStyle::SteadyBar); h.type_keys(""); assert_eq!(h.label(), "TEXT"); let soil = h.on_screen("Soil mix"); assert_eq!(h.text_cursor(), Some((soil.col + 2 + 7, soil.row + 1)), "on the last letter, as in vim"); assert_eq!(cursor_shape(&h.playground.state.mode), SetCursorStyle::SteadyBlock); h.type_keys(""); assert_eq!(h.label(), "NORMAL"); assert_eq!(h.text_cursor(), None, "no text cursor outside a title"); assert_eq!(cursor_shape(&h.playground.state.mode), SetCursorStyle::DefaultUserShape); h.type_keys("u"); assert_eq!(h.cursor(), "Soil", "the whole edit was one undo step"); } #[test] fn rapid_entry_with_enter_tab_and_shift_tab() { let mut h = Harness::new(); h.type_keys("glgjgjaBucketHose"); assert_eq!(h.children("Watering"), ["Rain barrel", "Drip line", "Bucket"]); assert_eq!(h.children("Bucket"), ["Hose"], "Tab: under the idea above"); h.type_keys("Can"); assert_eq!( h.children("Watering"), ["Rain barrel", "Drip line", "Bucket", "Can"], "Shift-Tab: out a level" ); h.type_keys(""); assert_eq!(h.label(), "NORMAL", "Enter on an empty idea stops"); assert_eq!( h.children("Watering"), ["Rain barrel", "Drip line", "Bucket", "Can"], "the empty one went" ); assert_eq!(h.children("Bucket"), ["Hose"]); assert_eq!(h.cursor(), "Can"); assert_eq!(h.message(), ""); assert!(h.screen().contains("Can")); } #[test] fn retyping_and_repeating() { let mut h = Harness::new(); h.type_keys("glccCompost"); assert_eq!(h.cursor(), "Compost", "cc types the title anew"); h.type_keys("A!.."); assert_eq!(h.draft(), "Compost!!!", ". repeats the typing too"); h.type_keys("u"); assert_eq!(h.cursor(), "Compost!!", "u inside the title undoes its last change"); h.type_keys("u"); assert_eq!(h.cursor(), "Compost", "u outside undoes the whole edit"); h.type_keys("u"); assert_eq!(h.cursor(), "Soil"); } #[test] fn vim_keys_inside_a_title() { let mut h = Harness::new(); // "Cedar, untreated": change the second word, then find the comma. h.type_keys("glgjglibcwtreated0f,x"); assert_eq!(h.draft(), "Cedar treated"); h.type_keys("w~"); assert_eq!(h.draft(), "Cedar Treated", "~ switches the case and steps right"); h.type_keys("$diw"); assert_eq!(h.draft(), "Cedar "); h.type_keys(""); assert_eq!(h.cursor(), "Cedar", "the title is trimmed"); } /// Every other vim key inside a title, one step at a time, each checked /// against what vim does: the text after it, and where the cursor is. #[test] fn the_rest_of_the_vim_keys_inside_a_title() { let mut h = Harness::new(); h.type_keys("glccplant (two) \"red\" beans"); let steps: &[(&str, &str, usize)] = &[ ("0e", "plant (two) \"red\" beans", 4), ("^", "plant (two) \"red\" beans", 0), ("$F(", "plant (two) \"red\" beans", 6), ("$T\"", "plant (two) \"red\" beans", 17), // ; repeats the T, skipping the quote it's already next to. (";", "plant (two) \"red\" beans", 13), // , repeats it the other way: t". (",", "plant (two) \"red\" beans", 15), ("0f(ci(six", "plant (six) \"red\" beans", 9), ("f\"di\"", "plant (six) \"\" beans", 13), ("da\"", "plant (six) beans", 12), ("F(da(", "plant beans", 6), ("X", "plant beans", 5), ("wdaw", "plant", 4), ("A beans", "plant beans", 10), ("0sP", "Plant beans", 0), ("$r!", "Plant bean!", 10), ("u", "Plant beans", 10), ("", "Plant bean!", 10), ("0wD", "Plant ", 5), ("0CSeed", "Seed", 3), ("0yiwP", "SeedSeed", 3), ("I>", ">SeedSeed", 0), ("a-", ">-SeedSeed", 1), ("SGo", "Go", 1), ("0iE", "EGo", 0), ("3~", "egO", 2), ("Sa.b.c.d.e.f.g0d3f.", "d.e.f.g", 0), ("2x", "e.f.g", 0), ("d2f.", "g", 0), // A title has one line, so Y's line goes in like any text. ("YP", "gg", 0), ]; for &(keys, text, cursor) in steps { h.type_keys(keys); assert_eq!((h.draft(), h.draft_cursor()), (text, Some(cursor)), "after {keys}"); } h.type_keys("u"); assert_eq!(h.cursor(), "Soil", "all of it was one undo step"); } #[test] fn registers_carry_text_from_title_to_title() { let mut h = Harness::new(); h.type_keys("gli\"ayiw"); assert_eq!(h.label(), "NORMAL"); h.type_keys("gjA \"ap"); assert_eq!(h.cursor(), "Raised beds Soil", "\"a kept Soil"); } #[test] fn o_inside_a_title_starts_a_sibling() { let mut h = Harness::new(); h.type_keys("glioCompost"); assert_eq!(h.children("Garden plans"), ["Soil", "Compost", "Raised beds", "Watering", "Kitchen"]); } #[test] fn the_mouse_while_editing() { let mut h = Harness::new(); let watering = h.node("Watering"); // A double-click on the first e: typing goes in right before it. let at = h.on_screen("Watering"); h.double_click(at.col + 2 + 3, at.row + 1); assert_eq!(h.label(), "INSERT"); h.type_keys("-"); assert_eq!(h.draft(), "Wat-ering"); // A click in the title moves the text cursor. let at = h.on_screen("Wat-ering"); h.click(at.col + 2, at.row + 1); h.type_keys("~"); assert_eq!(h.draft(), "~Wat-ering"); // A click anywhere else keeps the edit, finishes it, and selects. let soil = h.on_screen("Soil"); h.click(soil.col + 2, soil.row + 1); assert_eq!(h.label(), "NORMAL"); assert_eq!(h.cursor(), "Soil"); assert_eq!(h.title(watering), "~Wat-ering"); } #[test] fn indenting_and_outdenting_nodes() { let mut h = Harness::new(); h.type_keys("glgj>>"); assert_eq!(h.cursor(), "Raised beds"); assert_eq!(h.parent("Raised beds"), "Soil", ">>: under the node above"); h.type_keys("<<"); assert_eq!( h.children("Garden plans"), ["Soil", "Raised beds", "Watering", "Kitchen"], "<<: back out" ); h.type_keys("u"); assert_eq!(h.parent("Raised beds"), "Soil"); h.type_keys("u"); assert_eq!(h.parent("Raised beds"), "Garden plans"); assert_eq!(h.pos("Raised beds"), CanvasPos::new(22, 7), "back where it was"); } #[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("K"); assert_eq!(h.message(), "\"show the body\" comes in a later stage of the build"); // Kitchen is a portal: its title is the other document's name. h.type_keys("glgjgjgj"); assert_eq!(h.cursor(), "Kitchen"); h.type_keys("i"); assert_eq!(h.message(), "a portal shows its document's name: rename that document instead"); assert_eq!(h.label(), "NORMAL"); h.type_keys(""); assert_eq!(h.message(), "going through a portal comes in stage 8"); } }