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]>
162 lines
7.0 KiB
Rust
162 lines
7.0 KiB
Rust
//! The popups driven by their key tables' commands, as the app drives them.
|
|
|
|
use super::*;
|
|
use crate::input::key::KeyName;
|
|
use crate::input::keymap::DEFAULT_LEADER;
|
|
use crate::model::test_support::garden;
|
|
use crate::model::{Spot, Tags};
|
|
use crate::view::test_support::Setup;
|
|
|
|
const CANVAS: &[Context] = &[Context::Canvas, Context::Normal, Context::Motions];
|
|
|
|
fn typed(command: Command) -> Typed {
|
|
Typed { command, count: None, register: None }
|
|
}
|
|
|
|
/// Types a search into a list popup, the way `/` and the keys after it do.
|
|
fn search(popup: &mut Popup, text: &str, ctx: &ViewContext<'_>) {
|
|
let keymap = Keymap::new(DEFAULT_LEADER);
|
|
popup.command(&typed(Command::Search), &mut Registers::default(), ctx);
|
|
for c in text.chars() {
|
|
popup.typing(Key::char(c), &keymap, ctx);
|
|
}
|
|
}
|
|
|
|
/// Enter, while a search is typed.
|
|
fn enter(popup: &mut Popup, ctx: &ViewContext<'_>) -> Vec<Action> {
|
|
popup.typing(Key::plain(KeyName::Enter), &Keymap::new(DEFAULT_LEADER), ctx)
|
|
}
|
|
|
|
fn app(action: AppAction) -> Action {
|
|
Action::App(action)
|
|
}
|
|
|
|
#[test]
|
|
fn help_finds_keys_and_commands_and_runs_them() {
|
|
let ((doc, g), setup) = (garden(), Setup::default());
|
|
let ctx = setup.ctx(&doc, g.root);
|
|
let keymap = Keymap::new(DEFAULT_LEADER);
|
|
let help = || Popup::help(&keymap, CANVAS, "canvas", "", &setup.theme);
|
|
|
|
let mut popup = help();
|
|
search(&mut popup, "dd", &ctx);
|
|
let dd = Command::Operate(Operator::Delete, Target::Current);
|
|
assert_eq!(enter(&mut popup, &ctx), [app(AppAction::LeaveMode), app(AppAction::Run(dd))]);
|
|
|
|
let mut popup = help();
|
|
search(&mut popup, "zoom in", &ctx);
|
|
assert_eq!(enter(&mut popup, &ctx), [app(AppAction::LeaveMode), app(AppAction::Run(Command::ZoomIn))]);
|
|
|
|
// A key that waits for more is typed, so the rest can follow.
|
|
let mut popup = help();
|
|
search(&mut popup, "mark this", &ctx);
|
|
let m = vec![Key::char('m')];
|
|
assert_eq!(enter(&mut popup, &ctx), [app(AppAction::LeaveMode), app(AppAction::Type(m))]);
|
|
|
|
// A command that needs more opens the command line with it typed.
|
|
let mut popup = Popup::help(&keymap, CANVAS, "canvas", ":color", &setup.theme);
|
|
let confirm = popup.command(&typed(Command::Confirm), &mut Registers::default(), &ctx);
|
|
assert_eq!(confirm, [app(AppAction::LeaveMode), app(AppAction::CommandLineWith("color ".into()))]);
|
|
assert_eq!(popup.label(), "HELP");
|
|
}
|
|
|
|
#[test]
|
|
fn the_menu_shows_the_keys_that_do_the_same() {
|
|
let keymap = Keymap::new(DEFAULT_LEADER);
|
|
let tables = keymap.tables(CANVAS);
|
|
assert_eq!(keys_for(&tables, Command::AddChild).as_deref(), Some("a"));
|
|
assert_eq!(keys_for(&tables, Command::PickColor).as_deref(), Some("<Space>c"));
|
|
assert_eq!(keys_for(&tables, Command::Operate(Operator::Delete, Target::Current)).as_deref(), Some("dd"));
|
|
|
|
// On the empty canvas, the first thing is a new idea right there.
|
|
let ((doc, g), setup) = (garden(), Setup::default());
|
|
let ctx = setup.ctx(&doc, g.root);
|
|
let spot = CanvasPos::new(60, 30);
|
|
let mut menu =
|
|
Popup::menu(MenuFor::Canvas(spot), "canvas", Position::new(5, 5), &keymap, CANVAS, &setup.theme);
|
|
let actions = menu.command(&typed(Command::Confirm), &mut Registers::default(), &ctx);
|
|
assert_eq!(actions[0], app(AppAction::LeaveMode));
|
|
let Action::Doc(crate::app::action::DocAction::Edit { op, .. }) = &actions[1] else { panic!("an add") };
|
|
let EditOp::Add { node, spot: Spot::Loose } = op.as_ref() else { panic!("a loose node") };
|
|
assert_eq!(node.pos, spot, "where the click was");
|
|
}
|
|
|
|
#[test]
|
|
fn pickers_set_what_is_picked() {
|
|
let ((mut doc, g), setup) = (garden(), Setup::default());
|
|
doc.apply(&EditOp::SetTags { id: g.beds, tags: Tags::parse("#wood").unwrap() }).unwrap();
|
|
let ctx = setup.ctx(&doc, g.soil);
|
|
let pick = |kind| Popup::picker(kind, &ctx, &[], 0).unwrap();
|
|
|
|
let mut colors = pick(PickKind::Color);
|
|
search(&mut colors, "teal", &ctx);
|
|
let teal = ColorName::parse("teal").ok();
|
|
assert_eq!(
|
|
enter(&mut colors, &ctx),
|
|
[app(AppAction::LeaveMode), Action::edit(EditOp::SetColor { id: g.soil, color: teal })]
|
|
);
|
|
|
|
let mut borders = pick(PickKind::Border);
|
|
search(&mut borders, "dashed", &ctx);
|
|
let dashed = Some(Border::Dashed);
|
|
assert_eq!(
|
|
enter(&mut borders, &ctx),
|
|
[app(AppAction::LeaveMode), Action::edit(EditOp::SetBorder { id: g.soil, border: dashed })]
|
|
);
|
|
|
|
let no_styles = Popup::picker(PickKind::Style, &ctx, &[], 0).unwrap_err();
|
|
assert_eq!(
|
|
no_styles.text,
|
|
"this theme has no named styles yet: theme files come in part 8b of the build"
|
|
);
|
|
|
|
// Tags go on and off, and the picker stays open; a new one is added.
|
|
let mut tags = pick(PickKind::Tags);
|
|
let wood = Tag::parse("wood").unwrap();
|
|
let on = Action::edit(EditOp::Tag { id: g.soil, add: vec![wood.clone()], remove: Vec::new() });
|
|
let off = Action::edit(EditOp::Tag { id: g.soil, add: Vec::new(), remove: vec![wood] });
|
|
let mut registers = Registers::default();
|
|
assert_eq!(tags.command(&typed(Command::Confirm), &mut registers, &ctx), [on]);
|
|
assert_eq!(tags.command(&typed(Command::Confirm), &mut registers, &ctx), [off]);
|
|
search(&mut tags, "spring", &ctx);
|
|
let spring = Tag::parse("spring").unwrap();
|
|
assert_eq!(
|
|
enter(&mut tags, &ctx),
|
|
[Action::edit(EditOp::Tag { id: g.soil, add: vec![spring], remove: Vec::new() })]
|
|
);
|
|
|
|
// Documents: it starts on the one shown; the next one is a j away, and
|
|
// one opened lately two more (past its heading).
|
|
let row =
|
|
|name: &str, open| DocumentRow { name: name.into(), path: format!("/notes/{name}.cum").into(), open };
|
|
let documents = [row("Garden plans", true), row("Kitchen", true), row("Reading", false)];
|
|
let mut picker = Popup::picker(PickKind::Document, &ctx, &documents, 0).unwrap();
|
|
let down = typed(Command::Move(crate::input::command::Motion::Down));
|
|
picker.command(&down, &mut registers, &ctx);
|
|
assert_eq!(
|
|
picker.command(&typed(Command::Confirm), &mut registers, &ctx),
|
|
[app(AppAction::LeaveMode), app(AppAction::SwitchDocument(DocumentChoice::At(1)))]
|
|
);
|
|
picker.command(&down, &mut registers, &ctx);
|
|
let path = Some(std::path::Path::new("/notes/Reading.cum").display().to_string());
|
|
assert_eq!(
|
|
picker.command(&typed(Command::Confirm), &mut registers, &ctx),
|
|
[app(AppAction::LeaveMode), app(AppAction::Edit { path, force: false })]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_card_edits_the_body_and_closes() {
|
|
let ((doc, g), setup) = (garden(), Setup::default());
|
|
let ctx = setup.ctx(&doc, g.beds);
|
|
let mut card = Popup::card(g.beds);
|
|
assert_eq!((card.label(), card.contexts()), ("CARD", &[Context::Card][..]));
|
|
let mut registers = Registers::default();
|
|
let start = StartEdit::Body { at: EditAt::End, in_card: true };
|
|
assert_eq!(
|
|
card.command(&typed(Command::EditBody), &mut registers, &ctx),
|
|
[app(AppAction::StartEdit(start))]
|
|
);
|
|
assert_eq!(card.command(&typed(Command::Escape), &mut registers, &ctx), [app(AppAction::LeaveMode)]);
|
|
}
|