Run cumulus for real: the app core and its files (stage 8a)
The playground becomes app::App, and `cumulus` runs it: the main loop with terminal setup and a panic hook, and the startup rules (./ideas.cum, else ~/ideas.cum, made the first time; files and folders by name; `cumulus new`; --paths). The documents' files: - autosave a second after the latest change (`:set noautosave` for vim-style :w), [+] in the document bar for unsaved changes; quitting saves first; without autosave :q refuses, :q! drops, ZZ saves first; - a file changed outside cumulus is read again as an undo step; with unsaved changes here too, cumulus asks which to keep; a file with a mistake in it holds autosave until it's fixed or :w saves over it; a deleted one waits for :w; nothing is reloaded under an edit; - the day's first save backs the file up to the data folder (30 days); - a document open in another cumulus is read-only ([RO]) until that one lets go: OS file locks in the data folder, so a crash leaves none; - where you left off (cursor, zoom, scroll), the recent documents (now in the documents picker) and the command history, in memory.ron; - :e (alone: read it again; ! drops changes), :new, :bd, :w, :wa, :x, :qa, <Space>i for :in, and :set for every setting. glyphs=ascii now reaches the frames and bars too, not just the canvas and outline. Stage 8 comes in four parts with a commit each (the user's choice); 8b is the settings files. The example is now a playground running the app on copies of the sample documents in a temporary folder, and the end-to-end tests moved to src/app/tests.rs, with tests for the files. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
e6f3c06ecc
commit
f6dc4564aa
47 files changed
+3985
-1024
No files matched your search
+30
-843
@@ -1,850 +1,37 @@
|
||||
//! 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.
|
||||
//! The playground: cumulus itself, on copies of two sample documents (a
|
||||
//! garden, and the kitchen its portal leads to), in a fresh temporary
|
||||
//! folder. Trying things never changes the files in the repository, and
|
||||
//! cumulus's own folders (where you left off, backups, locks) are in there
|
||||
//! too, apart from your real ones.
|
||||
//!
|
||||
//! 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.
|
||||
//! Run it with `cargo run --example canvas`. Everything works as in
|
||||
//! `cumulus`, files included: the copies are saved as you go, and the folder
|
||||
//! is printed when you quit, to look at them (or to change one while the
|
||||
//! playground runs, and watch it reload).
|
||||
|
||||
use std::fs;
|
||||
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);
|
||||
use cumulus::app::action::Message;
|
||||
use cumulus::app::startup::Opening;
|
||||
use cumulus::app::{App, load, run};
|
||||
use cumulus::config::paths::Paths;
|
||||
use cumulus::config::settings::Settings;
|
||||
use cumulus::input::keymap::{DEFAULT_LEADER, Keymap};
|
||||
|
||||
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
|
||||
let folder = std::env::temp_dir().join(format!("cumulus-playground-{}", std::process::id()));
|
||||
fs::create_dir_all(&folder)?;
|
||||
let (garden, kitchen) = (folder.join("garden.cum"), folder.join("kitchen.cum"));
|
||||
fs::write(&garden, include_str!("garden.cum"))?;
|
||||
fs::write(&kitchen, include_str!("kitchen.cum"))?;
|
||||
let opening = Opening {
|
||||
first: load(&garden).map_err(io::Error::other)?,
|
||||
others: vec![load(&kitchen).map_err(io::Error::other)?],
|
||||
message: Some(Message::info("the playground: copies of the samples, in a temporary folder")),
|
||||
};
|
||||
let paths = Paths::inside(&folder.join("cumulus"));
|
||||
run(App::new(paths, Settings::default(), Keymap::new(DEFAULT_LEADER), opening))?;
|
||||
println!("The playground's files are in {}", folder.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -1,663 +0,0 @@
|
||||
//! The playground driven by typed keys and mouse gestures on a test
|
||||
//! terminal: what the stages say works, checked end to end.
|
||||
|
||||
use super::*;
|
||||
use cumulus::input::key::parse_keys;
|
||||
use cumulus::input::keymap::DEFAULT_LEADER;
|
||||
use cumulus::model::{Border, CanvasPos, Content, NodeId};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
|
||||
|
||||
struct Harness {
|
||||
playground: Playground,
|
||||
terminal: Terminal<TestBackend>,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
fn new() -> Harness {
|
||||
let terminal = Terminal::new(TestBackend::new(100, 32)).unwrap();
|
||||
let mut harness = Harness { playground: Playground::new(documents().unwrap()), 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();
|
||||
}
|
||||
|
||||
fn click(&mut self, (col, row): (u16, u16)) {
|
||||
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, row): (u16, u16)) {
|
||||
let at = Cell { col, row };
|
||||
self.click((col, row));
|
||||
self.mouse(Gesture::DoublePress { at });
|
||||
self.mouse(Gesture::Release { at, dragged: false });
|
||||
}
|
||||
|
||||
fn right_click(&mut self, (col, row): (u16, u16)) {
|
||||
self.mouse(Gesture::RightPress { at: Cell { col, row } });
|
||||
}
|
||||
|
||||
/// A mouse event as the terminal sends it, read the way the event loop
|
||||
/// reads it.
|
||||
fn raw_mouse(&mut self, kind: MouseEventKind, (column, row): (u16, u16), modifiers: KeyModifiers) {
|
||||
let event = MouseEvent { kind, column, row, modifiers };
|
||||
if let Some(gesture) = self.playground.mouse.read(event, Instant::now()) {
|
||||
self.playground.gesture(gesture);
|
||||
}
|
||||
self.draw();
|
||||
}
|
||||
|
||||
/// The focused pane's document.
|
||||
fn doc(&self) -> &Document {
|
||||
self.playground.state.buffers[self.playground.buffer()].doc()
|
||||
}
|
||||
|
||||
/// The title of the cursor's node.
|
||||
fn cursor(&self) -> String {
|
||||
let buffer = &self.playground.state.buffers[self.playground.buffer()];
|
||||
self.title(buffer.cursor())
|
||||
}
|
||||
|
||||
fn title(&self, id: NodeId) -> String {
|
||||
self.doc().node(id).unwrap().title().to_string()
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
self.playground.state.mode.label()
|
||||
}
|
||||
|
||||
/// The text being edited, as typed so far.
|
||||
fn draft(&self) -> &str {
|
||||
self.playground.state.mode.any_edit().map_or("", |edit| edit.editor.text())
|
||||
}
|
||||
|
||||
/// Where the text cursor is in the text being edited: a byte offset.
|
||||
fn draft_cursor(&self) -> Option<usize> {
|
||||
self.playground.state.mode.any_edit().map(|edit| edit.editor.cursor())
|
||||
}
|
||||
|
||||
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.doc();
|
||||
doc.outline().into_iter().find(|&id| doc.node(id).unwrap().title().as_str() == title).unwrap()
|
||||
}
|
||||
|
||||
fn children(&self, title: &str) -> Vec<String> {
|
||||
self.doc().children(self.node(title)).iter().map(|&id| self.title(id)).collect()
|
||||
}
|
||||
|
||||
fn parent(&self, title: &str) -> String {
|
||||
self.title(self.doc().parent(self.node(title)).unwrap())
|
||||
}
|
||||
|
||||
fn pos(&self, title: &str) -> CanvasPos {
|
||||
self.doc().node(self.node(title)).unwrap().pos
|
||||
}
|
||||
|
||||
fn body(&self, title: &str) -> String {
|
||||
match &self.doc().node(self.node(title)).unwrap().content {
|
||||
Content::Idea(idea) => idea.body.as_str().to_string(),
|
||||
Content::Portal(_) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tags(&self, title: &str) -> String {
|
||||
self.doc().node(self.node(title)).unwrap().tags.to_string()
|
||||
}
|
||||
|
||||
/// The terminal's cursor, when it shows.
|
||||
fn text_cursor(&self) -> Option<(u16, u16)> {
|
||||
let backend = self.terminal.backend();
|
||||
let at = backend.cursor_position();
|
||||
backend.cursor_visible().then_some((at.x, at.y))
|
||||
}
|
||||
|
||||
/// What's on screen, row by row.
|
||||
fn screen(&self) -> String {
|
||||
self.rows().join("\n")
|
||||
}
|
||||
|
||||
fn rows(&self) -> Vec<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()).collect()
|
||||
}
|
||||
|
||||
/// The cell where `text` first shows in the panes (between the document
|
||||
/// bar and the status bar).
|
||||
fn find(&self, text: &str) -> (u16, u16) {
|
||||
let rows = self.rows();
|
||||
let last = rows.len() - 3;
|
||||
self.find_in(text, 1..=last).unwrap_or_else(|| panic!("{text:?} isn't on screen:\n{}", self.screen()))
|
||||
}
|
||||
|
||||
fn find_in(&self, text: &str, rows: std::ops::RangeInclusive<usize>) -> Option<(u16, u16)> {
|
||||
let screen = self.rows();
|
||||
rows.into_iter().find_map(|row| {
|
||||
let at = screen[row].find(text)?;
|
||||
let col = screen[row][..at].chars().count();
|
||||
Some((u16::try_from(col).ok()?, u16::try_from(row).ok()?))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ----- The canvas and editing (stages 2 to 6) -----------------------------
|
||||
|
||||
#[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<CR>");
|
||||
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 (col, row) = h.find("Soil");
|
||||
let (start, end) = (Cell { col, row }, Cell { col: col + 4, row: row + 2 });
|
||||
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<CR>");
|
||||
let root = h.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<CR>");
|
||||
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<CR>");
|
||||
assert_eq!(h.cursor(), "Drip line");
|
||||
assert_eq!(h.message(), "\"drip\": match 1 of 1");
|
||||
h.type_keys(":set completion_popup=on-tab<CR>");
|
||||
assert_eq!(h.playground.completion, PopupMode::OnTab);
|
||||
h.type_keys(":frobnicate<CR>");
|
||||
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<Esc><Esc>");
|
||||
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().contains("canvas · far"), "the status bar shows the zoom");
|
||||
h.type_keys("q");
|
||||
assert!(h.playground.quit, "q closes the last pane: that quits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_a_title_in_its_box() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("gli mix");
|
||||
assert_eq!(h.label(), "INSERT");
|
||||
let (col, row) = h.find("Soil mix");
|
||||
assert_eq!(h.text_cursor(), Some((col + 8, row)), "after the last letter");
|
||||
assert_eq!(cursor_shape(&h.playground.state.mode), SetCursorStyle::SteadyBar);
|
||||
h.type_keys("<Esc>");
|
||||
assert_eq!(h.label(), "TEXT");
|
||||
let (col, row) = h.find("Soil mix");
|
||||
assert_eq!(h.text_cursor(), Some((col + 7, row)), "on the last letter, as in vim");
|
||||
assert_eq!(cursor_shape(&h.playground.state.mode), SetCursorStyle::SteadyBlock);
|
||||
h.type_keys("<Esc>");
|
||||
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("glgjgjaBucket<CR>Hose<Tab>");
|
||||
assert_eq!(h.children("Watering"), ["Rain barrel", "Drip line", "Bucket"]);
|
||||
assert_eq!(h.children("Bucket"), ["Hose"], "Tab: under the idea above");
|
||||
h.type_keys("<CR>Can<S-Tab>");
|
||||
assert_eq!(
|
||||
h.children("Watering"),
|
||||
["Rain barrel", "Drip line", "Bucket", "Can"],
|
||||
"Shift-Tab: out a level"
|
||||
);
|
||||
h.type_keys("<CR><CR>");
|
||||
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(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retyping_and_repeating() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("glccCompost<Esc><Esc>");
|
||||
assert_eq!(h.cursor(), "Compost", "cc types the title anew");
|
||||
h.type_keys("A!<Esc>..");
|
||||
assert_eq!(h.draft(), "Compost!!!", ". repeats the typing too");
|
||||
h.type_keys("u<Esc>");
|
||||
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("glgjgli<Esc>bcwtreated<Esc>0f,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("<Esc>");
|
||||
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<Esc>");
|
||||
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<Esc>", "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<Esc>", "plant beans", 10),
|
||||
("0sP<Esc>", "Plant beans", 0),
|
||||
("$r!", "Plant bean!", 10),
|
||||
("u", "Plant beans", 10),
|
||||
("<C-r>", "Plant bean!", 10),
|
||||
("0wD", "Plant ", 5),
|
||||
("0CSeed<Esc>", "Seed", 3),
|
||||
("0yiwP", "SeedSeed", 3),
|
||||
("I><Esc>", ">SeedSeed", 0),
|
||||
("a-<Esc>", ">-SeedSeed", 1),
|
||||
("SGo<Esc>", "Go", 1),
|
||||
("0iE<Esc>", "EGo", 0),
|
||||
("3~", "egO", 2),
|
||||
("Sa.b.c.d.e.f.g<Esc>0d3f.", "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("<Esc>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<Esc>\"ayiw<Esc>");
|
||||
assert_eq!(h.label(), "NORMAL");
|
||||
h.type_keys("gjA <Esc>\"ap<Esc>");
|
||||
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("gli<Esc>oCompost<Esc><Esc>");
|
||||
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 (col, row) = h.find("Watering");
|
||||
h.double_click((col + 3, row));
|
||||
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.find("Wat-ering");
|
||||
h.click(at);
|
||||
h.type_keys("~");
|
||||
assert_eq!(h.draft(), "~Wat-ering");
|
||||
// A click anywhere else keeps the edit, finishes it, and selects.
|
||||
let soil = h.find("Soil");
|
||||
h.click(soil);
|
||||
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("v");
|
||||
assert_eq!(h.message(), "\"select\" 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("<CR>");
|
||||
assert_eq!(h.message(), "going through a portal comes in stage 8");
|
||||
}
|
||||
|
||||
// ----- Views, panes, popups and documents (stage 7) -----------------------
|
||||
|
||||
#[test]
|
||||
fn panes_split_and_share_the_cursor() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("<C-w>v<Space>vo");
|
||||
assert_eq!(h.playground.panes.count(), 2);
|
||||
assert!(h.screen().contains("outline · Garden plans"), "the new pane shows the outline:\n{}", h.screen());
|
||||
assert!(h.screen().contains("▾ Garden plans"));
|
||||
h.type_keys("j");
|
||||
assert_eq!(h.cursor(), "Soil", "j in the outline: the next row");
|
||||
h.type_keys("<C-w>l");
|
||||
assert_eq!(h.playground.panes.current().view.kind(), ViewKind::Canvas);
|
||||
assert_eq!(h.cursor(), "Soil", "panes on one document share its cursor");
|
||||
h.type_keys(":vsplit note<CR>");
|
||||
assert_eq!(h.playground.panes.count(), 3);
|
||||
assert!(h.screen().contains("note · Garden plans"));
|
||||
h.type_keys("<C-w>o");
|
||||
assert_eq!(h.playground.panes.count(), 1, "only this pane");
|
||||
h.type_keys("<C-w>s");
|
||||
assert_eq!(h.playground.panes.count(), 2);
|
||||
h.type_keys("<C-w>c");
|
||||
assert_eq!(h.playground.panes.count(), 1);
|
||||
h.type_keys("<C-w>j");
|
||||
assert_eq!(h.message(), "no pane that way");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_outline_edits_reorders_and_reads() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("<Space>vo");
|
||||
assert!(h.screen().contains("▾ Garden plans"));
|
||||
h.type_keys("jA!<Esc><Esc>");
|
||||
assert_eq!(h.cursor(), "Soil!");
|
||||
assert!(h.screen().contains("├ Soil!"));
|
||||
// Move mode in the outline: j swaps the node with the sibling below.
|
||||
h.type_keys("Mj<CR>");
|
||||
assert_eq!(h.children("Garden plans")[..2], ["Raised beds", "Soil!"]);
|
||||
h.type_keys("u");
|
||||
assert_eq!(h.children("Garden plans")[0], "Soil!");
|
||||
// zb: every body under its title.
|
||||
assert!(!h.screen().contains("The neighbor has a bin."));
|
||||
h.type_keys("zb");
|
||||
assert!(h.screen().contains("The neighbor has a bin."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_note_view_edits_its_parts() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("<Space>vngl");
|
||||
assert!(h.screen().contains("note · Garden plans"));
|
||||
assert!(h.screen().contains("#todo"), "Soil's tags");
|
||||
// j j: the body; Enter: write it.
|
||||
h.type_keys("jj<CR>Loamy, with compost.<CR>Test the pH.<Esc><Esc>");
|
||||
assert_eq!(h.body("Soil"), "Loamy, with compost.\nTest the pH.");
|
||||
assert!(h.screen().contains("Test the pH."));
|
||||
// k: the tags; Enter: add one.
|
||||
h.type_keys("k<CR> #spring<CR>");
|
||||
assert_eq!(h.tags("Soil"), "#spring #todo");
|
||||
h.type_keys("u");
|
||||
assert_eq!(h.tags("Soil"), "#todo");
|
||||
// On Raised beds, the children are parts too: Enter goes there.
|
||||
h.type_keys("gjjjj<CR>");
|
||||
assert_eq!(h.cursor(), "Cedar, untreated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_finds_and_runs_keys_and_commands() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("?");
|
||||
assert_eq!(h.label(), "HELP");
|
||||
assert!(h.screen().contains("help · canvas"));
|
||||
h.type_keys("/zoom out<CR>");
|
||||
assert_eq!(h.label(), "NORMAL");
|
||||
assert!(h.screen().contains("canvas · far"), "the help ran it");
|
||||
h.type_keys("?/:color<CR>teal<CR>");
|
||||
let root = h.doc().node(h.node("Garden plans")).unwrap();
|
||||
assert_eq!(
|
||||
root.look.color.as_ref().map(|color| color.as_str()),
|
||||
Some("teal"),
|
||||
":color waited for the color"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_card_shows_and_edits_the_body() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys(":find compost<CR>K");
|
||||
assert_eq!(h.label(), "CARD");
|
||||
assert!(h.screen().contains("The neighbor has a bin."));
|
||||
h.type_keys("i");
|
||||
assert_eq!(h.label(), "INSERT");
|
||||
assert_eq!(cursor_shape(&h.playground.state.mode), SetCursorStyle::SteadyBar);
|
||||
h.type_keys("<CR>Gloves too.<Esc><Esc>");
|
||||
assert_eq!(h.label(), "CARD", "done editing: the card stays");
|
||||
assert_eq!(h.body("ask about compost"), "The neighbor has a bin.\nBring a bucket.\nGloves too.");
|
||||
assert!(h.screen().contains("Gloves too."));
|
||||
h.type_keys("K");
|
||||
assert_eq!(h.label(), "NORMAL");
|
||||
h.type_keys("u");
|
||||
assert_eq!(h.body("ask about compost"), "The neighbor has a bin.\nBring a bucket.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_menu_on_a_node_and_on_the_empty_canvas() {
|
||||
let mut h = Harness::new();
|
||||
let soil = h.find("Soil");
|
||||
h.right_click(soil);
|
||||
assert_eq!((h.label(), h.cursor().as_str()), ("MENU", "Soil"));
|
||||
assert!(h.screen().contains("add a child"), "{}", h.screen());
|
||||
h.type_keys("/color<CR>");
|
||||
assert_eq!(h.label(), "PICK", "color… opens the color picker");
|
||||
h.type_keys("/teal<CR>");
|
||||
let node = h.doc().node(h.node("Soil")).unwrap();
|
||||
assert_eq!(node.look.color.as_ref().map(|color| color.as_str()), Some("teal"));
|
||||
// On the empty canvas: a new idea right there.
|
||||
h.right_click((95, 10));
|
||||
h.type_keys("<CR>Mulch<Esc><Esc>");
|
||||
let mulch = h.node("Mulch");
|
||||
assert!(h.doc().loose().contains(&mulch), "a loose idea");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pickers_and_documents() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("gl<Space>B/dashed<CR>");
|
||||
assert_eq!(h.doc().node(h.node("Soil")).unwrap().look.border, Some(Border::Dashed));
|
||||
// #todo is the only tag: Enter takes it off; the picker stays open.
|
||||
h.type_keys("<Space>t<CR>");
|
||||
assert_eq!((h.label(), h.tags("Soil").as_str()), ("PICK", ""));
|
||||
h.type_keys("<Esc>");
|
||||
// The documents: gt, the picker, :b, and a click on the bar.
|
||||
h.type_keys("gt");
|
||||
assert!(h.screen().contains("canvas · Kitchen"));
|
||||
assert_eq!(h.cursor(), "Kitchen");
|
||||
h.type_keys("<Space>bk<CR>");
|
||||
assert_eq!(h.cursor(), "Soil", "back in the garden, where the cursor was");
|
||||
h.type_keys(":b kitchen<CR>");
|
||||
assert_eq!(h.cursor(), "Kitchen");
|
||||
let garden = h.find_in("Garden plans", 0..=0).unwrap();
|
||||
h.click(garden);
|
||||
assert_eq!(h.cursor(), "Soil");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_keys_across_views() {
|
||||
let mut h = Harness::new();
|
||||
// Rapid entry in the outline: o adds a row, Enter the next one.
|
||||
h.type_keys("<Space>vojoMulch<CR>Straw<Esc><Esc>");
|
||||
assert_eq!(h.children("Garden plans")[..3], ["Soil", "Mulch", "Straw"]);
|
||||
assert!(h.screen().contains("├ Straw"));
|
||||
// za folds in the outline as anywhere.
|
||||
h.type_keys("ggza");
|
||||
assert!(h.screen().contains("▸ Garden plans"), "{}", h.screen());
|
||||
h.type_keys("za");
|
||||
// The Note view: h and l turn the page, K opens the card.
|
||||
h.type_keys("<Space>vngll");
|
||||
assert_eq!(h.cursor(), "Mulch", "l: the sibling after Soil");
|
||||
h.type_keys("h");
|
||||
assert_eq!(h.cursor(), "Soil");
|
||||
h.type_keys("K");
|
||||
assert_eq!(h.label(), "CARD");
|
||||
h.type_keys("<Esc>");
|
||||
// Ctrl-w w goes round the panes; Ctrl-w = evens them out.
|
||||
h.type_keys("<C-w>v");
|
||||
let first = h.playground.panes.focused();
|
||||
h.type_keys("<C-w>w");
|
||||
assert_ne!(h.playground.panes.focused(), first);
|
||||
h.type_keys("<C-w>=<C-w>o");
|
||||
// gT: the document before, round to the last.
|
||||
h.type_keys("gT");
|
||||
assert_eq!(h.cursor(), "Kitchen");
|
||||
h.type_keys("gT");
|
||||
// :help with a topic opens it searched.
|
||||
h.type_keys(":help fold<CR>");
|
||||
assert_eq!(h.label(), "HELP");
|
||||
assert!(h.screen().contains("/fold"));
|
||||
h.type_keys("<Esc><Esc>");
|
||||
// <Space>s: this theme has no named styles.
|
||||
h.type_keys("<Space>s");
|
||||
assert_eq!(h.message(), "this theme has no named styles yet: theme files come in stage 8");
|
||||
// <Space>w: no key hints: which-key and the hint line go.
|
||||
assert!(h.rows().last().unwrap().contains("? help"));
|
||||
h.type_keys("<Space>w");
|
||||
assert_eq!(h.message(), "key hints off");
|
||||
assert!(!h.screen().contains("? help"), "{}", h.screen());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wheel_scrolls_sideways() {
|
||||
let mut h = Harness::new();
|
||||
let (col, row) = h.find("Watering");
|
||||
// A wheel that tilts, or a touchpad: the canvas pans sideways.
|
||||
h.raw_mouse(MouseEventKind::ScrollRight, (col, row), KeyModifiers::NONE);
|
||||
assert_eq!(h.find("Watering"), (col - 6, row));
|
||||
// Shift or Alt with the wheel: the same, for mice that don't tilt.
|
||||
h.raw_mouse(MouseEventKind::ScrollUp, (col, row), KeyModifiers::SHIFT);
|
||||
assert_eq!(h.find("Watering"), (col, row));
|
||||
h.raw_mouse(MouseEventKind::ScrollDown, (col, row), KeyModifiers::ALT);
|
||||
assert_eq!(h.find("Watering"), (col - 6, row));
|
||||
// The wheel on its own still scrolls up and down.
|
||||
h.raw_mouse(MouseEventKind::ScrollDown, (col, row), KeyModifiers::NONE);
|
||||
assert_eq!(h.find("Watering"), (col - 6, row - 3));
|
||||
|
||||
// The outline, in a narrow pane: rows cut at its edge scroll sideways.
|
||||
h.type_keys("<C-w>v<C-w>v<Space>vogt");
|
||||
assert!(h.screen().contains("outline · Kitchen"));
|
||||
let (col, row) = h.find("Mint, in");
|
||||
h.raw_mouse(MouseEventKind::ScrollRight, (col, row), KeyModifiers::NONE);
|
||||
assert_eq!(h.find("Mint, in"), (col - 6, row), "the wheel sideways");
|
||||
h.type_keys("zh");
|
||||
assert_eq!(h.find("Mint, in"), (col, row), "zh: back");
|
||||
// In a popup, the wheel sideways does nothing (it used to move it up).
|
||||
h.type_keys("?j");
|
||||
let before = h.screen();
|
||||
h.raw_mouse(MouseEventKind::ScrollLeft, (50, 15), KeyModifiers::NONE);
|
||||
assert_eq!(h.screen(), before);
|
||||
}
|
||||
Reference in new issue
Block a user