//! A playground for cumulus as built so far (stages 2 to 7): two sample //! documents to move around, rearrange and write in, on the canvas, in the //! outline and in the Note view, split side by side or stacked, with the //! real vim keys, which-key, the help, the right-click menu, the pickers, //! the `K` card and the `:` command line. Files, settings and portals come //! in stage 8. //! //! It runs the way the app will: the key engine turns keys into commands, //! the focused pane's view (or the commands every view shares) turns those //! into actions, and [`Playground::update`] carries them out. While a text //! is edited or a popup is open, keys go there instead. //! //! Run it with `cargo run --example canvas`. A few keys to start with: //! //! | Keys | What they do | //! |-----------------|-----------------------------------------------------------| //! | `hjkl`, arrows | the canvas: the nearest node that way; the outline: rows, parent, child; the Note view: parts, siblings | //! | `g` | go to: `gh gl gj gk` the tree, `gg` the root | //! | `z` | view & folds: `za` `zf` `zi` `zz` `zR` `zM` (`zb`: bodies, in the outline) | //! | `+` `-` | zoom in / out | //! | `i` `A` `Enter` | edit the title (in the Note view: the part you're on) | //! | `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: on the canvas hjkl move the branch; in the outline j/k swap it with its siblings | //! | `K` | the body in a card beside the node (`i` there edits it) | //! | `u` `Ctrl-r` | undo / redo | //! | `ma` `'a` | mark a node / jump back to it (`''`: where you were) | //! | `` | the action menu: `c` color, `B` border, `t` tags, `b` documents, `vc` `vo` `vn` the view, `w` hints on or off | //! | `Ctrl-w` | panes: `v` `s` split, `hjkl` `w` focus, `c` close, `o` only, `=` even | //! | `gt` `gT` | the next / previous document | //! | `?` | help: every key and command; `/` searches, Enter runs | //! | `:` | the command line: try `:vsplit outline`, `:color teal`, `:in an idea`, `:find soil`, `:b kitchen` (Tab completes) | //! | `q`, `ZZ` | close the pane (the last one quits) | //! //! 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 text (TEXT, a block cursor). Esc //! again: done. A body is edited the same way, with Enter starting a line. //! //! Mouse: click selects (and focuses a pane), dragging a node moves its //! branch, dragging empty space pans, the wheel scrolls (sideways: a wheel //! that tilts, a touchpad, or Shift or Alt with the wheel). A double-click on //! a title edits it from that letter. Right-click: the menu. A click on a //! name in the document bar shows that document. use std::io; use std::time::{Duration, Instant}; use cumulus::app::action::{Action, AppAction, DocumentChoice, Message, Tone}; use cumulus::app::buffer::Buffer; use cumulus::app::mode::{Grab, Mode}; use cumulus::app::panes::{Arrange, PaneId, Panes}; use cumulus::app::popup::{Card, MenuFor, Popup}; use cumulus::app::registers::Registers; use cumulus::app::text_edit::{StartEdit, TextEdit}; use cumulus::app::{calls, commands}; use cumulus::command::history::History as CommandHistory; use cumulus::command::parse::{SetArg, Sources, parse}; use cumulus::input::bindings::Context; use cumulus::input::command::{Bound, Command, 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::Document; use cumulus::storage::document_file; use cumulus::ui::command_line::{CommandLine, LineOutcome, PopupMode}; use cumulus::ui::document_bar::DocumentBar; use cumulus::ui::status_bar::{StatusBar, breadcrumb}; use cumulus::ui::theme::Theme; use cumulus::ui::which_key::WhichKey; use cumulus::view::canvas::CanvasView; use cumulus::view::note::NoteView; use cumulus::view::outline::OutlineView; use cumulus::view::{View, ViewContext, ViewKind, ViewSettings}; use ratatui::crossterm::cursor::SetCursorStyle; use ratatui::crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind}; use ratatui::crossterm::execute; use ratatui::layout::{Constraint, Layout, Position, Rect}; use ratatui::style::{Style, Stylize}; use ratatui::text::Span; use ratatui::widgets::Block; use ratatui::{DefaultTerminal, Frame}; /// The playground's own keys.toml: `q` closes the pane. (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); /// The smallest a pane gets, borders included: splitting stops there. const MIN_PANE: (u16, u16) = (16, 6); fn main() -> io::Result<()> { let documents = documents().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(documents).run(&mut terminal); let _ = execute!(io::stdout(), DisableMouseCapture, SetCursorStyle::DefaultUserShape); ratatui::restore(); result } /// The sample documents: the garden, and the kitchen its portal leads to. fn documents() -> Result, document_file::LoadError> { Ok(vec![ document_file::parse(include_str!("garden.cum"))?, document_file::parse(include_str!("kitchen.cum"))?, ]) } /// What the views look at. struct State { /// The open documents. None ever closes here, so a pane's index into /// them always holds. buffers: Vec, mode: Mode, theme: Theme, settings: ViewSettings, } impl State { /// The context of the focused pane, showing document `buffer`. fn ctx(&self, buffer: usize) -> ViewContext<'_> { let buffer = &self.buffers[buffer]; ViewContext { doc: buffer.doc(), cursor: buffer.cursor(), mode: &self.mode, theme: &self.theme, settings: &self.settings, } } /// The open documents' names. fn names(&self) -> Vec { self.buffers.iter().map(|buffer| buffer.doc().title().to_string()).collect() } } /// A pane: one of the documents, through a view. struct Pane { buffer: usize, view: Box, } impl Pane { fn new(buffer: usize, kind: ViewKind) -> Pane { let view: Box = match kind { ViewKind::Outline => Box::new(OutlineView::new()), ViewKind::Note => Box::new(NoteView::new()), ViewKind::Canvas | ViewKind::Board => Box::new(CanvasView::new()), }; Pane { buffer, view } } } /// Where things were drawn last, for the mouse. #[derive(Debug, Clone, Copy, Default)] struct Screen { bar: Rect, panes: Rect, } struct Playground { panes: Panes, state: State, keymap: Keymap, keys: KeyEngine, /// Yanked and deleted text, kept from one edit to the next. registers: Registers, mouse: MouseReader, /// Key hints: which-key and the status bar's hint line. which_key: bool, command_history: CommandHistory, /// When the command line's popup shows (`:set completion_popup=on-tab`). completion: PopupMode, /// The status bar's message, until the next key. message: Option, /// The cursor shape last sent to the terminal. cursor_shape: SetCursorStyle, screen: Screen, /// The pane a mouse drag started in: it gets the whole drag. dragging: Option, quit: bool, } impl Playground { fn new(documents: Vec) -> Playground { let (keymap, problems) = Keymap::from_toml(KEYS_TOML); Playground { panes: Panes::new(Pane::new(0, ViewKind::Canvas)), state: State { buffers: documents.into_iter().map(Buffer::new).collect(), 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(), completion: PopupMode::default(), message: (!problems.is_empty()).then(|| Message::problem(problems.join(" · "))), cursor_shape: SetCursorStyle::DefaultUserShape, screen: Screen::default(), dragging: None, 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(()) } /// The document the focused pane shows. fn buffer(&self) -> usize { self.panes.current().buffer } /// The key tables in use now: the popup's, or the focused view's. fn contexts(&self) -> &'static [Context] { match &self.state.mode { Mode::Popup(popup) => popup.contexts(), mode => self.panes.current().view.contexts(mode), } } // ----- Input ---------------------------------------------------------- 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::Popup(_) => return self.popup_key(key), Mode::Normal | Mode::Move(_) => {} } match self.keys.press(key, &self.keymap.tables(self.contexts())) { Outcome::Run(typed) => self.dispatch(typed), Outcome::Unknown(keys) => self.message = Some(Message::info(format!("{keys} does nothing here"))), Outcome::Waiting | Outcome::Cancelled => {} } } /// A command: the focused view has the first say, the shared commands /// get the rest. fn dispatch(&mut self, typed: Typed) { let pane = self.panes.current_mut(); let ctx = self.state.ctx(pane.buffer); let actions = pane.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); } /// A key while a text is edited in a view: typed into it, or a vim key /// inside it. fn text_key(&mut self, key: Key) { let tables = self.keymap.tables(self.contexts()); // 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(self.panes.current().buffer); 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 a popup is open: typed into its search (or the card's /// body), or one of its keys. fn popup_key(&mut self, key: Key) { let tables = self.keymap.tables(self.contexts()); let Mode::Popup(mut popup) = std::mem::take(&mut self.state.mode) else { return }; let ctx = self.state.ctx(self.panes.current().buffer); let actions = if popup.takes_typing() { popup.typing(key, &self.keymap, &ctx) } else { match self.keys.press(key, &tables) { Outcome::Run(typed) => popup.command(&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::Popup(popup); 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); self.run_line(&text); } } } /// Runs a command line (without its `:`). fn run_line(&mut self, text: &str) { let sources = self.sources(); let actions = match parse(text, &sources) { Ok(call) => calls::actions_for(call, &self.state.ctx(self.buffer())), Err(error) => vec![Action::say(Message::problem(error.to_string()))], }; self.update(actions); } /// What the command line can name: the open documents, the focused /// document's nodes and tags, the palette's colors. fn sources(&self) -> Sources { let doc = self.state.buffers[self.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: self.state.names(), 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(), } } /// A mouse gesture: a popup has the mouse while it's open; otherwise it /// goes to the document bar or to the pane under it (a drag stays with /// the pane it started in), which takes the focus. fn gesture(&mut self, gesture: Gesture) { let at = |cell: Cell| Position::new(cell.col, cell.row); // 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(); if let Mode::Command(_) = self.state.mode { self.state.mode = Mode::Normal; } } if let Mode::Popup(_) = self.state.mode { let Mode::Popup(mut popup) = std::mem::take(&mut self.state.mode) else { return }; let actions = popup.mouse(gesture, &self.state.ctx(self.buffer())); self.state.mode = Mode::Popup(popup); return self.update(actions); } if let Gesture::Press { at: cell, .. } = gesture && cell.row == self.screen.bar.y { let names = self.state.names(); let bar = DocumentBar { names: &names, current: self.buffer(), theme: &self.state.theme }; if let Some(index) = bar.at(cell.col.saturating_sub(self.screen.bar.x)) { self.update(vec![Action::App(AppAction::SwitchDocument(DocumentChoice::At(index)))]); } return; } let target = match gesture { Gesture::Press { at: cell, .. } | Gesture::DoublePress { at: cell } | Gesture::RightPress { at: cell } | Gesture::Scroll { at: cell, .. } => self.panes.at(self.screen.panes, at(cell)), Gesture::Drag { .. } | Gesture::Release { .. } => self.dragging, }; let Some(target) = target else { return }; if let Gesture::Press { .. } = gesture { self.dragging = Some(target); } if target != self.panes.focused() { // Another pane: a text edited in this one is done first, and // move mode ends. let ctx = self.state.ctx(self.buffer()); let mut actions = self.state.mode.edit().map(|edit| edit.finish(&ctx)).unwrap_or_default(); if let Mode::Move(_) = self.state.mode { actions.push(Action::App(AppAction::LeaveMode)); } self.update(actions); self.panes.focus(target); } let pane = self.panes.current_mut(); let actions = pane.view.mouse(gesture, &self.state.ctx(pane.buffer)); if let Gesture::Release { .. } = gesture { self.dragging = None; } self.update(actions); } // ----- 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) => { let buffer = self.buffer(); self.state.buffers[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(self.buffer()), start); let in_card = matches!(start, StartEdit::Body { in_card: true, .. }); match (started, &mut self.state.mode) { (Err(message), _) => return Some(message), (Ok(edit), Mode::Popup(Popup::Card(card))) if in_card => card.edit = Some(edit), (Ok(edit), mode) => *mode = Mode::Text(edit), } } AppAction::EndEdit => match &mut self.state.mode { // Done editing in the card: the card stays. Mode::Popup(Popup::Card(card)) => card.edit = None, mode => *mode = Mode::Normal, }, AppAction::TextCursor(at) => { if let Some(edit) = self.state.mode.edit_mut() { 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.completion); self.state.mode = Mode::Command(line); } AppAction::CommandLineWith(text) => { let line = CommandLine::with_text(&text, &self.sources(), &self.command_history, self.completion); self.state.mode = Mode::Command(line); } AppAction::RunLine(text) => self.run_line(&text), AppAction::Run(command) => self.dispatch(Typed { command, count: None, register: None }), AppAction::Type(keys) => keys.into_iter().for_each(|key| self.key(key)), 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::OpenHelp(search) => { let pane = self.panes.current(); let contexts = pane.view.contexts(&Mode::Normal); let help = Popup::help(&self.keymap, contexts, pane.view.kind().name(), &search, &self.state.theme); self.state.mode = Mode::Popup(help); } AppAction::OpenCard => { self.state.mode = Mode::Popup(Popup::card(self.state.ctx(self.buffer()).cursor)) } AppAction::OpenPicker(kind) => { let names = self.state.names(); match Popup::picker(kind, &self.state.ctx(self.buffer()), &names, self.buffer()) { Ok(picker) => self.state.mode = Mode::Popup(picker), Err(message) => return Some(message), } } AppAction::OpenMenu { what, col, row } => { let doc = self.state.buffers[self.buffer()].doc(); let title = match what { MenuFor::Node(id) => { doc.node(id).map(|node| node.title().to_string()).unwrap_or_default() } MenuFor::Canvas(_) => "here".to_string(), }; let contexts = self.panes.current().view.contexts(&Mode::Normal); let at = Position::new(col, row); let menu = Popup::menu(what, &title, at, &self.keymap, contexts, &self.state.theme); self.state.mode = Mode::Popup(menu); } AppAction::Split { arrange, view } => return self.split(arrange, view), AppAction::FocusPane(direction) => { if !self.panes.focus_toward(direction, self.screen.panes) { return Some(Message::info("no pane that way")); } } AppAction::NextPane => self.panes.focus_next(), AppAction::ClosePane => { if self.panes.close().is_none() { self.quit = true; } } AppAction::OnlyPane => { if self.panes.only().is_empty() { return Some(Message::info("this is the only pane")); } } AppAction::EqualizePanes => self.panes.equalize(), AppAction::ShowView(ViewKind::Board) => return Some(Message::info("the board comes in v1.1")), AppAction::ShowView(kind) => { let pane = self.panes.current_mut(); if pane.view.kind() != kind { *pane = Pane::new(pane.buffer, kind); } } AppAction::SwitchDocument(choice) => return self.switch_document(choice), AppAction::Quit => self.quit = true, AppAction::Say(message) => return Some(message), } None } /// Splits the focused pane: the new pane shows `view`, or the same view. fn split(&mut self, arrange: Arrange, view: Option) -> Option { let focused = self.panes.focused(); let area = self .panes .areas(self.screen.panes) .into_iter() .find(|(id, _)| *id == focused) .map(|(_, area)| area)?; let room = match arrange { Arrange::SideBySide => area.width >= 2 * MIN_PANE.0, Arrange::Stacked => area.height >= 2 * MIN_PANE.1, }; if !room { return Some(Message::problem("not enough room to split this pane")); } let pane = self.panes.current(); let kind = view.unwrap_or(pane.view.kind()); if kind == ViewKind::Board { return Some(Message::info("the board comes in v1.1")); } let buffer = pane.buffer; self.panes.split(arrange, Pane::new(buffer, kind)); None } /// The focused pane shows another document, in a fresh view of the same /// kind. fn switch_document(&mut self, choice: DocumentChoice) -> Option { let count = self.state.buffers.len(); let here = self.buffer(); let to = match choice { DocumentChoice::Next => (here + 1) % count, DocumentChoice::Previous => (here + count - 1) % count, DocumentChoice::At(index) => index.min(count - 1), DocumentChoice::Named(name) => { let names = self.state.names(); match names.iter().position(|open| open.eq_ignore_ascii_case(&name)) { Some(index) => index, None => return Some(Message::problem(format!("no open document is called \"{name}\""))), } } }; let pane = self.panes.current_mut(); if to != pane.buffer { *pane = Pane::new(to, pane.view.kind()); } 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.completion))); } 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.completion = 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 status_rows = if self.which_key { 2 } else { 1 }; let [bar, panes_area, status] = Layout::vertical([Constraint::Length(1), Constraint::Fill(1), Constraint::Length(status_rows)]) .areas(frame.area()); self.screen = Screen { bar, panes: panes_area }; let names = self.state.names(); frame.render_widget( DocumentBar { names: &names, current: self.buffer(), theme: &self.state.theme }, bar, ); // Each pane in its box: the view and the document in the top // border, the focused one in the accent color. Only the focused pane // is in the mode; the others show their documents as they are. let focused = self.panes.focused(); let normal = Mode::Normal; let State { buffers, mode, theme, settings } = &self.state; for (id, area) in self.panes.areas(panes_area) { let Some(pane) = self.panes.get_mut(id) else { continue }; let buffer = &buffers[pane.buffer]; let mode = if id == focused { mode } else { &normal }; let ctx = ViewContext { doc: buffer.doc(), cursor: buffer.cursor(), mode, theme, settings }; let color = if id == focused { theme.accent } else { theme.muted }; let title = format!(" {} · {} ", pane.view.kind().name(), buffer.doc().title()); let block = Block::bordered() .border_style(Style::new().fg(color)) .title(Span::styled(title, Style::new().fg(color))); let inner = block.inner(area); frame.render_widget(block, area); pane.view.layout(inner, &ctx); pane.view.render(frame, &ctx); } // A popup over the panes; the card sits beside the cursor's node. let index = self.buffer(); let anchor = self.panes.current().view.cursor_area(&self.state.ctx(index)); let State { buffers, mode, theme, settings } = &mut self.state; if let Mode::Popup(popup) = mode { let buffer = &buffers[index]; let ctx = ViewContext { doc: buffer.doc(), cursor: buffer.cursor(), mode: &normal, theme, settings }; popup.layout(panes_area, anchor, &ctx); popup.render(frame, &ctx); } self.draw_status(frame, status); if let Mode::Command(line) = &self.state.mode { // The command line takes the bottom row. let row = Rect { y: status.bottom() - 1, height: 1, ..status }; line.render(frame, row, panes_area, &self.state.theme); } if self.which_key_due() && let Some(hints) = self.keys.hints(&self.keymap.tables(self.contexts())) { frame.render_widget(WhichKey { hints: &hints, theme: &self.state.theme }, panes_area); } } /// The status bar: the mode, the view, the breadcrumb (or a message), /// the keys typed so far; then the key hints, unless they're off. fn draw_status(&self, frame: &mut Frame, area: Rect) { let (mode, theme) = (&self.state.mode, &self.state.theme); let typing = mode.any_edit().is_some_and(|edit| edit.editor.is_typing()); let pane = self.panes.current(); let buffer = &self.state.buffers[pane.buffer]; let focus = if self.state.settings.focus { " · focus" } else { "" }; let view = format!("{}{focus}", pane.view.status()); let room = usize::from(area.width).saturating_sub(mode.label().len() + view.len() + 16); let place = match &self.message { None => Span::raw(breadcrumb(buffer.doc(), buffer.cursor(), room)), 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 status = StatusBar { mode: mode.label(), mode_color: if typing { theme.insert } else { theme.accent }, view, place, keys: self.keys.shown(), hints: self.which_key.then(|| hints(mode, pane.view.kind())), theme, }; frame.render_widget(status, 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 hint line: the keys for what you're doing. fn hints(mode: &Mode, view: ViewKind) -> &'static str { match (mode, view) { (Mode::Normal, ViewKind::Outline) => { "j/k rows · h/l parent/child · za fold · zb bodies · i edit · a/o add · M reorder · K body · ? help" } (Mode::Normal, ViewKind::Note) => { "j/k parts · Enter edit or go · h/l siblings · a/o add · panes · ? help" } (Mode::Normal, _) => { "hjkl move · i edit · a/o add · dd delete · K body · M move · panes · menu · ? help" } (Mode::Text(edit), _) | (Mode::Popup(Popup::Card(Card { edit: Some(edit), .. })), _) => { text_hints(edit) } (Mode::Move(_), ViewKind::Outline | ViewKind::Note) => { "j/k swap with a sibling (J/K: 5) · h out a level · l under the one above · Enter done" } (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::Command(_), _) => "Tab completes · ↑ ↓ history · Enter runs · Esc cancels", (Mode::Popup(Popup::Card(_)), _) => "j/k scroll · i edit the body · K or Esc close", (Mode::Popup(_), _) => "j/k move · / search · Enter pick · Esc close", } } /// The hint line while a text is edited. fn text_hints(edit: &TextEdit) -> &'static str { use cumulus::app::text_edit::Field; match (edit.editor.is_typing(), edit.field) { (true, Field::Title) if edit.new_node => { "Enter the next idea · Tab under the one above · Shift-Tab out a level · Esc vim keys" } (true, Field::Body) => "Enter a new line · Esc vim keys", (true, _) => "Enter done · Esc vim keys", (false, _) => { "w b e 0 $ f t move · d c y + a motion · x r ~ p · u undo · . repeat · i a type · Esc done" } } } /// 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.any_edit() { Some(edit) if edit.editor.is_typing() => SetCursorStyle::SteadyBar, Some(_) => SetCursorStyle::SteadyBlock, None if matches!(mode, Mode::Command(_)) => SetCursorStyle::SteadyBar, None => 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 the stages say works, checked end to end. #[cfg(test)] #[path = "canvas/tests.rs"] mod tests;