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:
skkeyeandClaude Opus 5.5 committed 2026-10-01 23:18:21 -04:00
1 parent e6f3c06ecc
commit f6dc4564aa
47 files changed
+3985 -1024

No files matched your search

+221
View File
@@ -0,0 +1,221 @@
//! Keys and the mouse, routed: to the command line while it's open, to the
//! text being edited, to an open popup, or through the key engine to the
//! focused view and then the shared commands.
use ratatui::layout::Position;
use super::action::{Action, AppAction, DocumentChoice, Message};
use super::mode::Mode;
use super::{App, calls, commands};
use crate::command::parse::{Sources, parse};
use crate::config::settings::Setting;
use crate::input::command::{Bound, Command, help_for};
use crate::input::engine::{Outcome, Typed};
use crate::input::key::Key;
use crate::input::mouse::{Cell, Gesture};
use crate::ui::command_line::LineOutcome;
use crate::ui::document_bar::DocumentBar;
impl App {
/// A key, as typed.
pub fn key(&mut self, key: Key) {
self.message = None;
match self.shared.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.shared.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 part of the build", describe(typed.command));
vec![Action::say(Message::info(later))]
});
self.update(actions);
}
/// Runs a command as if its keys were typed (the help and the menu).
pub(super) fn run_command(&mut self, command: Command) {
self.dispatch(Typed { command, count: None, register: None });
}
/// 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.shared.mode) else { return };
let ctx = self.shared.ctx(self.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.shared.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.shared.mode) else { return };
let ctx = self.shared.ctx(self.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.shared.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.shared.mode else { return };
match line.key(key, &sources, &self.command_history) {
LineOutcome::Typing => {}
LineOutcome::Close => self.shared.mode = Mode::Normal,
LineOutcome::Run(text) => {
self.shared.mode = Mode::Normal;
if text.trim().is_empty() {
return;
}
self.command_history.add(&text);
self.run_line(&text);
}
}
}
/// Runs a command line (without its `:`).
pub(super) fn run_line(&mut self, text: &str) {
let sources = self.sources();
let actions = match parse(text, &sources) {
Ok(call) => calls::actions_for(call, &self.shared.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, the settings.
pub(super) fn sources(&self) -> Sources {
let doc = self.current().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();
let theme = &self.shared.theme;
Sources {
documents: self.shared.names(),
nodes: nodes.collect(),
colors: theme.palette.keys().map(ToString::to_string).collect(),
styles: theme.node_styles.keys().map(ToString::to_string).collect(),
themes: vec!["default".into()],
settings: Setting::ALL.iter().map(|setting| setting.name().to_string()).collect(),
tags,
folder: self.folder(),
}
}
/// 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.
pub 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.shared.mode {
self.shared.mode = Mode::Normal;
}
}
if let Mode::Popup(_) = self.shared.mode {
let Mode::Popup(mut popup) = std::mem::take(&mut self.shared.mode) else { return };
let actions = popup.mouse(gesture, &self.shared.ctx(self.buffer()));
self.shared.mode = Mode::Popup(popup);
return self.update(actions);
}
if let Gesture::Press { at: cell, .. } = gesture
&& cell.row == self.screen.bar.y
{
let names = self.bar_names();
let current = self.shared.docs.index_of(self.buffer());
let bar = DocumentBar { names: &names, current, theme: &self.shared.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.shared.ctx(self.buffer());
let mut actions = self.shared.mode.edit().map(|edit| edit.finish(&ctx)).unwrap_or_default();
if let Mode::Move(_) = self.shared.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.shared.ctx(pane.buffer));
if let Gesture::Release { .. } = gesture {
self.dragging = None;
}
self.update(actions);
}
}
/// 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,
}
}