Files
cumulus/examples/canvas.rs
T
skkeyeandClaude Opus 5.5 e6f3c06ecc Scroll sideways with Shift or Alt and the wheel, and in the outline
A wheel that tilts (or a touchpad) already panned the canvas sideways,
but only when the terminal passes those events on, and many don't:
Windows Terminal only does since 1.24, and Windows' own console layer
can drop them. The wheel with Shift held now scrolls sideways too, as in
most apps, and so does Alt, since Alacritty and Windows Terminal keep
Shift with the mouse for selecting text.

The outline scrolls sideways as well (the wheel, zh and zl), for rows
wider than its pane: a deep tree or a long title. Clicks land where the
rows are, however far they're scrolled, and when the cursor moves its
title comes back into view.

Fixed on the way: the wheel turned sideways over the help, the menu, a
picker or the K card moved it up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 19:48:52 -04:00

851 lines
37 KiB
Rust

//! 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) |
//! | `<Space>` | 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<Vec<Document>, 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<Buffer>,
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<String> {
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<dyn View>,
}
impl Pane {
fn new(buffer: usize, kind: ViewKind) -> Pane {
let view: Box<dyn View> = 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<Pane>,
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<Message>,
/// 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<PaneId>,
quit: bool,
}
impl Playground {
fn new(documents: Vec<Document>) -> 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<String> = doc
.outline()
.into_iter()
.filter_map(|id| doc.node(id))
.flat_map(|node| node.tags.iter().map(|tag| tag.name().to_string()).collect::<Vec<_>>())
.collect();
tags.sort();
tags.dedup();
Sources {
documents: 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<Action>) {
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<Message> {
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<ViewKind>) -> Option<Message> {
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<Message> {
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 · <C-w> panes · ? help"
}
(Mode::Normal, _) => {
"hjkl move · i edit · a/o add · dd delete · K body · M move · <C-w> panes · <Space> 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;