diff --git a/Cargo.lock b/Cargo.lock index 55d8f86..f7d49df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -113,6 +113,7 @@ dependencies = [ name = "cumulus" version = "0.1.0" dependencies = [ + "directories", "ratatui", "ron", "serde", @@ -184,6 +185,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + [[package]] name = "document-features" version = "0.2.12" @@ -325,6 +347,15 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + [[package]] name = "line-clipping" version = "0.3.8" @@ -412,6 +443,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "palette" version = "0.7.7" @@ -574,6 +611,16 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror", +] + [[package]] name = "ron" version = "0.12.2" diff --git a/Cargo.toml b/Cargo.toml index 8485602..d9d54ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,9 @@ unicode-width = "0.2" # Where one character ends and the next begins, so the cursor steps over an # accented letter or an emoji in one go. unicode-segmentation = "1" +# Where each OS keeps settings and app data: %APPDATA% on Windows, +# ~/.config and ~/.local/share on Linux. +directories = "6" [lints.rust] unsafe_code = "forbid" @@ -37,8 +40,3 @@ all = { level = "warn", priority = -1 } lto = true codegen-units = 1 strip = true - -# The canvas example is the build's checkpoint: `cargo test` runs its tests too. -[[example]] -name = "canvas" -test = true diff --git a/DESIGN.md b/DESIGN.md index 7e1be06..7f0b67c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -122,10 +122,10 @@ lines. - A title left empty comes back as it was, with a note saying so; a new idea left empty goes away. - Registers are shared by the whole app, as in vim: text yanked in one title - pastes into another. `"+` stays inside cumulus until stage 8 connects it to + pastes into another. `"+` stays inside cumulus until part 8c connects it to the system clipboard. - `.` inside the text repeats the last change there, typing included. `.` on - nodes comes with node yank and paste, in stage 8. + nodes comes with node yank and paste, in part 8c. - As in vim: `cw` changes to the end of the word, `dw` stops at the end of the line, `j` and `k` move by lines of text rather than wrapped rows, Esc steps back onto the last letter typed. @@ -189,15 +189,32 @@ lines. keys and clicks go to them, a click outside a list closes it, and they stay above the status bar. -**Files and saving** +**Files and saving** (settled in part 8a) - Documents are RON files ending in `.cum`. Configs are TOML: `config.toml`, `keys.toml` and `themes/.toml`. -- Autosave about a second after a change (`autosave = false` for vim-style - `:w`). A file changed outside cumulus is reloaded; if you also had unsaved - changes, cumulus asks which to keep. Daily backups go to the data folder. +- Autosave a second after the latest change, so typing isn't saved letter + by letter (`autosave = false` for vim-style `:w`). Undoing back to what's + on disk counts as no change. The document bar marks unsaved changes `[+]`. + Quitting saves first; without autosave, `:q` with unsaved changes refuses + (`:q!` drops them), and `ZZ` saves, then closes, as in vim. +- Every second, cumulus looks at each file's time and size. A file changed + outside cumulus is read again as an undo step (`u` brings back what was on + screen); never under a text being edited or a node being moved, which it + waits for. If you also had unsaved changes, cumulus asks which to keep + (Esc: later; `:w` keeps yours, `:e!` takes the file's). A file with a + mistake in it (a hand edit half done) stops autosave until it's fixed or + `:w` saves over it; a deleted or moved file waits for `:w` to write it + again. +- The day's first save copies the file to the data folder's backups first + (`backups//2026-10-01.cum`, the last 30 days kept). +- A document open in another cumulus opens read-only (`[RO]` in the bar) + until that one lets go of it. The lock is the operating system's lock on + a file in the data folder, so a crash never leaves one behind. - Undo per document, while cumulus runs: one step per command, per text-editing session, and per node in rapid entry. -- Each document reopens where you left it (cursor, zoom, scroll). +- Each document reopens where you left it: the cursor, and the canvas's + zoom and scroll. The documents picker (`b`) lists the documents + opened lately after the open ones. **Screen** - Vim-style splits; panes on the same document share one cursor. @@ -419,9 +436,9 @@ document has a portal pointing here. ) ``` -**Config** (TOML) lives in `%APPDATA%\cumulus` (`~/.config/cumulus` on Linux); -`cumulus --paths` prints the folders. `:config` writes these files with a -comment on every option, and cumulus reloads them when they change. +**Config** (TOML) lives in `%APPDATA%\cumulus\config` (`~/.config/cumulus` on +Linux); `cumulus --paths` prints the folders. `:config` writes these files with +a comment on every option, and cumulus reloads them when they change. ```toml # config.toml @@ -467,15 +484,20 @@ label = "blocks" question = "question" # a #question node gets the "question" style ``` -**Data folder:** where you left off in each document (keyed by `uid`), command -history, capital marks, recent documents, daily backups, and lock files. If a -second cumulus opens a document that's already open, it opens it read-only. +**Data folder** (`%APPDATA%\cumulus\data`, `~/.local/share/cumulus` on Linux): +`memory.ron` (where you left off in each document, keyed by `uid`, the command +history, recent documents; capital marks come in part 8c), `backups/` and +`locks/`. If a second cumulus opens a document that's already open, it opens +it read-only. **Command line** - `cumulus` opens `./ideas.cum`, or `~/ideas.cum` if the current folder doesn't - have one. -- `cumulus garden.cum` opens that file; `cumulus some/folder` opens that - folder's default document. + have one. When neither exists, it starts a new `~/ideas.cum` (written once + something is added): one ideas document for everywhere (the user's pick in + part 8a). +- `cumulus garden.cum` opens that file (a new document if there's no such + file yet, as `:e` does); `cumulus some/folder` opens that folder's default + document. Several can be named; the first one shows. - `cumulus new` creates `./ideas.cum`, and `cumulus new "Garden plans"` creates `./garden-plans.cum`. If the file already exists, cumulus opens it and says so. - `cumulus --paths`, `--version` and `--help`. @@ -486,7 +508,7 @@ second cumulus opens a document that's already open, it opens it read-only. cumulus/ ├─ Cargo.toml rust-toolchain.toml build.rs rustfmt.toml .cargo/config.toml ├─ README.md DESIGN.md .gitignore .gitattributes -├─ examples/ runnable checkpoints during the build (canvas.rs, …) +├─ examples/ the playground: cumulus on copies of two sample documents (canvas.rs) └─ src/ ├─ main.rs command-line arguments, then app::run ├─ lib.rs the module list @@ -501,15 +523,23 @@ cumulus/ │ motion, object, layout), list (the popup list help, menus and pickers │ share), card (the K card), status_bar, document_bar, which_key, │ command_line, theme, glyphs - ├─ config/ paths, settings, keys_file, theme_file (TOML) - ├─ storage/ document_file (RON), atomic save, backups, lock, state, watch (change polling) - └─ app/ action, mode, buffer, commands (keys every view shares), calls (`:` commands), - text_edit (a node's text being edited, rapid entry), registers, panes (the - split tree), popup (help, menu, pickers, card); later App, jumps, portals - (cache, backlinks, renames), update, render, terminal (setup, teardown, - panic hook) + ├─ config/ paths (the config and data folders), settings (and `:set`); later keys_file, + │ theme_file (TOML) + ├─ storage/ document_file (RON), write_atomically, names (file names from titles), backup, + │ lock, stamp (noticing changes made outside), memory (memory.ron) + └─ app/ mod.rs (App), input (keys and the mouse, routed), update (carrying actions out), + render, terminal (the main loop, setup, teardown, panic hook), startup (what + `cumulus` opens), documents (the open documents and their files), files + (saving, reloading, locks, where you left off), action, mode, buffer, commands + (keys every view shares), calls (`:` commands), text_edit (a node's text being + edited, rapid entry), registers, panes (the split tree), popup (help, menu, + pickers, card, questions); later jumps, portals (cache, backlinks, renames) ``` +The end-to-end tests are in `app/tests.rs` (and `app/tests/files.rs`): they +copy the sample documents to a temporary folder, type keys and click on a test +terminal, and check the screen, the documents and the files. + **Flow:** a terminal event comes in → the key engine turns keys into a typed `Command` (with its count and register) → the focused view answers it with `Action`s, and `app::commands::shared` answers the commands the view leaves @@ -550,6 +580,9 @@ trait View { // settled in stage 5 // Added in stage 7, with defaults: fn status(&self) -> String; // its name in the status bar fn cursor_area(&self, ctx: &ViewContext) -> Option; // where the K card goes beside + // Added in part 8a, with defaults (only the canvas has a camera): + fn camera(&self) -> Option; // kept with the document + fn set_camera(&mut self, camera: Camera); // where you left off } // ViewContext: the document, the cursor, the mode, the theme and the view settings. ``` @@ -576,11 +609,16 @@ it. | 5 | The shared `View` interface, `Action` and `Mode`, pulled out of stages 2–4 | the example runs through it | | 6 | Text editor: typing, plus vim normal mode inside text | editing titles in the example | | 7 | Outline and Note views; splits (the split tree); status bar, document bar, help, `K` card, right-click menu, pickers (documents, colors, borders, styles, tags) | the example with splits and two documents | -| 8 | App core: main loop, terminal setup and panic hook, buffers and panes (from the example into the app), visual and connect modes, jumps, marks, registers, files (load, autosave, reload, backups, locks, where you left off), config/theme/keys, portals, backlinks and renames, startup rules | `cumulus`: v1 | +| 8a | App core: the main loop, terminal setup and panic hook, buffers and panes (from the example into the app), files (load, autosave, reload, backups, locks, where you left off), the startup rules and `cumulus new`, `:set` | `cumulus` | +| 8b | Settings: config.toml, keys.toml, themes (named styles, styles by tag), `:theme`, `:config` | | +| 8c | The rest of vim: visual mode, node yank and paste with registers (`"+`: the clipboard), `.` on nodes, `/` `n` `N`, the jump list, capital marks, `gd` | | +| 8d | Connect mode, portals, backlinks, file names that follow the root's title | v1 | | 9 | v1.1: Board, Search picker, `:tidy` and align, branch out, export/import | | Each stage ends with `cargo build`, `cargo lint` and `cargo test` passing, and -one commit. +one commit. Stage 8, as big as stages 5 to 7 together, comes in four parts +with a commit each, and a pause after each for the user to try it (their +choice). ## Crates and tooling diff --git a/README.md b/README.md index a5a17c7..86ba001 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,22 @@ a folder on your `PATH`. `target/x86_64-unknown-linux-musl/release/cumulus` and runs on any distribution. +## Starting it + +```sh +cumulus # ./ideas.cum, or ~/ideas.cum (made the first time) +cumulus garden.cum # that document (a new one if it isn't there yet) +cumulus notes/ # that folder's ideas.cum +cumulus new "Garden plans" # makes ./garden-plans.cum (cumulus new: ./ideas.cum) +cumulus --paths # where cumulus keeps its settings and data +``` + +Documents are plain text files (`.cum`) that live wherever you like. cumulus +saves a second after each change, reloads a file another program changed +(`u` undoes that), and keeps a copy of each document a day in its data folder. +A document open in another cumulus opens read-only. Each one reopens where you +left it. + ## Keys The plan for the everyday keys (all of them are in DESIGN.md): @@ -43,10 +59,12 @@ The plan for the everyday keys (all of them are in DESIGN.md): | `+` `-` | zoom in / out | | `K` | the body in a card beside the node (`i` edits it there) | | `C` `M` `v` | connect, move, select | -| `` | the action menu: `c` color, `t` tags, `vo` outline, `vn` note… | +| `` | the action menu: `c` color, `t` tags, `b` documents, `i` inbox, `vo` outline, `vn` note… | | `Ctrl-w v` `Ctrl-w s` | split the pane side by side / stacked (`Ctrl-w hjkl` to move between them) | | `gt` `gT` | the next / previous document | | `:` `?` | command line, help (search it with `/`, Enter runs the key or command) | +| `:e` `:new` `:bd` | open a document / make one / close it | +| `:w` `:q` `ZZ` | save / close the pane (the last one quits) / save and close | After the first key of a sequence (`g`, `z`, `d`, ``…), a popup lists what can follow. The right mouse button opens a menu of what you can do with @@ -61,10 +79,13 @@ cargo lint # clippy, treating warnings as errors cargo fmt # format the code ``` -While the app is being built, the examples try out each part: +To try things without touching your own documents: ```sh -cargo run --example canvas # everything so far: the views, splits, popups, editing -cargo test show_canvas -- --ignored --nocapture # print it at each zoom, as text -cargo test --example canvas # type key sequences into it, and check what they do +cargo run --example canvas # cumulus on copies of two sample documents +cargo test show_canvas -- --ignored --nocapture # print the sample at each zoom, as text ``` + +The end-to-end tests (`src/app/tests.rs`) type keys and click on a test +terminal, with the sample documents copied to a temporary folder, and check +the screen, the documents and the files. diff --git a/examples/canvas.rs b/examples/canvas.rs index f51e84b..1e2d5b1 100644 --- a/examples/canvas.rs +++ b/examples/canvas.rs @@ -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) | -//! | `` | 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, document_file::LoadError> { - Ok(vec![ - document_file::parse(include_str!("garden.cum"))?, - document_file::parse(include_str!("kitchen.cum"))?, - ]) -} - -/// What the views look at. -struct State { - /// The open documents. None ever closes here, so a pane's index into - /// them always holds. - buffers: Vec, - mode: Mode, - theme: Theme, - settings: ViewSettings, -} - -impl State { - /// The context of the focused pane, showing document `buffer`. - fn ctx(&self, buffer: usize) -> ViewContext<'_> { - let buffer = &self.buffers[buffer]; - ViewContext { - doc: buffer.doc(), - cursor: buffer.cursor(), - mode: &self.mode, - theme: &self.theme, - settings: &self.settings, - } - } - - /// The open documents' names. - fn names(&self) -> Vec { - self.buffers.iter().map(|buffer| buffer.doc().title().to_string()).collect() - } -} - -/// A pane: one of the documents, through a view. -struct Pane { - buffer: usize, - view: Box, -} - -impl Pane { - fn new(buffer: usize, kind: ViewKind) -> Pane { - let view: Box = match kind { - ViewKind::Outline => Box::new(OutlineView::new()), - ViewKind::Note => Box::new(NoteView::new()), - ViewKind::Canvas | ViewKind::Board => Box::new(CanvasView::new()), - }; - Pane { buffer, view } - } -} - -/// Where things were drawn last, for the mouse. -#[derive(Debug, Clone, Copy, Default)] -struct Screen { - bar: Rect, - panes: Rect, -} - -struct Playground { - panes: Panes, - state: State, - keymap: Keymap, - keys: KeyEngine, - /// Yanked and deleted text, kept from one edit to the next. - registers: Registers, - mouse: MouseReader, - /// Key hints: which-key and the status bar's hint line. - which_key: bool, - command_history: CommandHistory, - /// When the command line's popup shows (`:set completion_popup=on-tab`). - completion: PopupMode, - /// The status bar's message, until the next key. - message: Option, - /// The cursor shape last sent to the terminal. - cursor_shape: SetCursorStyle, - screen: Screen, - /// The pane a mouse drag started in: it gets the whole drag. - dragging: Option, - quit: bool, -} - -impl Playground { - fn new(documents: Vec) -> Playground { - let (keymap, problems) = Keymap::from_toml(KEYS_TOML); - Playground { - panes: Panes::new(Pane::new(0, ViewKind::Canvas)), - state: State { - buffers: documents.into_iter().map(Buffer::new).collect(), - mode: Mode::Normal, - theme: Theme::default(), - settings: ViewSettings::default(), - }, - keymap, - keys: KeyEngine::default(), - registers: Registers::default(), - mouse: MouseReader::default(), - which_key: true, - command_history: CommandHistory::default(), - completion: PopupMode::default(), - message: (!problems.is_empty()).then(|| Message::problem(problems.join(" · "))), - cursor_shape: SetCursorStyle::DefaultUserShape, - screen: Screen::default(), - dragging: None, - quit: false, - } - } - - fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { - while !self.quit { - terminal.draw(|frame| self.draw(frame))?; - self.set_cursor_shape()?; - // While a sequence is half typed, wake up now and then: its - // which-key popup may be due. - let wait = - if self.keys.is_pending() { Duration::from_millis(50) } else { Duration::from_secs(3600) }; - if !event::poll(wait)? { - continue; - } - match event::read()? { - // Windows reports key releases too: only presses and repeats count. - Event::Key(event) if event.kind != KeyEventKind::Release => { - if let Some(key) = Key::from_event(event) { - self.key(key); - } - } - Event::Mouse(event) => { - if let Some(gesture) = self.mouse.read(event, Instant::now()) { - self.gesture(gesture); - } - } - _ => {} - } - } - Ok(()) - } - - /// Gives the terminal's cursor the mode's shape, when that changed. - fn set_cursor_shape(&mut self) -> io::Result<()> { - let shape = cursor_shape(&self.state.mode); - if shape != self.cursor_shape { - execute!(io::stdout(), shape)?; - self.cursor_shape = shape; - } - Ok(()) - } - - /// The document the focused pane shows. - fn buffer(&self) -> usize { - self.panes.current().buffer - } - - /// The key tables in use now: the popup's, or the focused view's. - fn contexts(&self) -> &'static [Context] { - match &self.state.mode { - Mode::Popup(popup) => popup.contexts(), - mode => self.panes.current().view.contexts(mode), - } - } - - // ----- Input ---------------------------------------------------------- - - fn key(&mut self, key: Key) { - self.message = None; - match self.state.mode { - Mode::Command(_) => return self.command_line_key(key), - Mode::Text(_) => return self.text_key(key), - Mode::Popup(_) => return self.popup_key(key), - Mode::Normal | Mode::Move(_) => {} - } - match self.keys.press(key, &self.keymap.tables(self.contexts())) { - Outcome::Run(typed) => self.dispatch(typed), - Outcome::Unknown(keys) => self.message = Some(Message::info(format!("{keys} does nothing here"))), - Outcome::Waiting | Outcome::Cancelled => {} - } - } - - /// A command: the focused view has the first say, the shared commands - /// get the rest. - fn dispatch(&mut self, typed: Typed) { - let pane = self.panes.current_mut(); - let ctx = self.state.ctx(pane.buffer); - let actions = pane.view.command(&typed, &ctx).or_else(|| commands::shared(&typed, &ctx)); - let actions = actions.unwrap_or_else(|| { - let later = format!("\"{}\" comes in a later stage of the build", describe(typed.command)); - vec![Action::say(Message::info(later))] - }); - self.update(actions); - } - - /// A key while a text is edited in a view: typed into it, or a vim key - /// inside it. - fn text_key(&mut self, key: Key) { - let tables = self.keymap.tables(self.contexts()); - // The edit comes out of the mode while it works, so it can look at - // the document; it goes back in before its actions run. - let Mode::Text(mut edit) = std::mem::take(&mut self.state.mode) else { return }; - let ctx = self.state.ctx(self.panes.current().buffer); - let actions = if edit.editor.is_typing() { - // Typing: a key the table doesn't bind types itself. - edit.typing(key, tables.bound(&[key]), &ctx) - } else { - match self.keys.press(key, &tables) { - Outcome::Run(typed) => edit.vim(&typed, &mut self.registers, &ctx), - Outcome::Unknown(keys) => { - vec![Action::say(Message::info(format!("{keys} does nothing here")))] - } - Outcome::Waiting | Outcome::Cancelled => Vec::new(), - } - }; - self.state.mode = Mode::Text(edit); - self.update(actions); - } - - /// A key while a popup is open: typed into its search (or the card's - /// body), or one of its keys. - fn popup_key(&mut self, key: Key) { - let tables = self.keymap.tables(self.contexts()); - let Mode::Popup(mut popup) = std::mem::take(&mut self.state.mode) else { return }; - let ctx = self.state.ctx(self.panes.current().buffer); - let actions = if popup.takes_typing() { - popup.typing(key, &self.keymap, &ctx) - } else { - match self.keys.press(key, &tables) { - Outcome::Run(typed) => popup.command(&typed, &mut self.registers, &ctx), - Outcome::Unknown(keys) => { - vec![Action::say(Message::info(format!("{keys} does nothing here")))] - } - Outcome::Waiting | Outcome::Cancelled => Vec::new(), - } - }; - self.state.mode = Mode::Popup(popup); - self.update(actions); - } - - /// A key while the command line is open. - fn command_line_key(&mut self, key: Key) { - let sources = self.sources(); - let Mode::Command(line) = &mut self.state.mode else { return }; - match line.key(key, &sources, &self.command_history) { - LineOutcome::Typing => {} - LineOutcome::Close => self.state.mode = Mode::Normal, - LineOutcome::Run(text) => { - self.state.mode = Mode::Normal; - if text.trim().is_empty() { - return; - } - self.command_history.add(&text); - self.run_line(&text); - } - } - } - - /// Runs a command line (without its `:`). - fn run_line(&mut self, text: &str) { - let sources = self.sources(); - let actions = match parse(text, &sources) { - Ok(call) => calls::actions_for(call, &self.state.ctx(self.buffer())), - Err(error) => vec![Action::say(Message::problem(error.to_string()))], - }; - self.update(actions); - } - - /// What the command line can name: the open documents, the focused - /// document's nodes and tags, the palette's colors. - fn sources(&self) -> Sources { - let doc = self.state.buffers[self.buffer()].doc(); - let nodes = doc.outline().into_iter().filter_map(|id| Some((id, doc.node(id)?.title().to_string()))); - let mut tags: Vec = doc - .outline() - .into_iter() - .filter_map(|id| doc.node(id)) - .flat_map(|node| node.tags.iter().map(|tag| tag.name().to_string()).collect::>()) - .collect(); - tags.sort(); - tags.dedup(); - Sources { - documents: self.state.names(), - nodes: nodes.collect(), - colors: self.state.theme.palette.keys().map(ToString::to_string).collect(), - styles: self.state.theme.node_styles.keys().map(ToString::to_string).collect(), - themes: vec!["default".into()], - settings: vec!["completion_popup".into()], - tags, - folder: std::env::current_dir().unwrap_or_default(), - } - } - - /// A mouse gesture: a popup has the mouse while it's open; otherwise it - /// goes to the document bar or to the pane under it (a drag stays with - /// the pane it started in), which takes the focus. - fn gesture(&mut self, gesture: Gesture) { - let at = |cell: Cell| Position::new(cell.col, cell.row); - // A click drops a half-typed key sequence: a `d` typed before - // clicking somewhere else mustn't wait to delete what comes next. - if matches!(gesture, Gesture::Press { .. } | Gesture::DoublePress { .. } | Gesture::RightPress { .. }) - { - self.keys.reset(); - if let Mode::Command(_) = self.state.mode { - self.state.mode = Mode::Normal; - } - } - if let Mode::Popup(_) = self.state.mode { - let Mode::Popup(mut popup) = std::mem::take(&mut self.state.mode) else { return }; - let actions = popup.mouse(gesture, &self.state.ctx(self.buffer())); - self.state.mode = Mode::Popup(popup); - return self.update(actions); - } - if let Gesture::Press { at: cell, .. } = gesture - && cell.row == self.screen.bar.y - { - let names = self.state.names(); - let bar = DocumentBar { names: &names, current: self.buffer(), theme: &self.state.theme }; - if let Some(index) = bar.at(cell.col.saturating_sub(self.screen.bar.x)) { - self.update(vec![Action::App(AppAction::SwitchDocument(DocumentChoice::At(index)))]); - } - return; - } - let target = match gesture { - Gesture::Press { at: cell, .. } - | Gesture::DoublePress { at: cell } - | Gesture::RightPress { at: cell } - | Gesture::Scroll { at: cell, .. } => self.panes.at(self.screen.panes, at(cell)), - Gesture::Drag { .. } | Gesture::Release { .. } => self.dragging, - }; - let Some(target) = target else { return }; - if let Gesture::Press { .. } = gesture { - self.dragging = Some(target); - } - if target != self.panes.focused() { - // Another pane: a text edited in this one is done first, and - // move mode ends. - let ctx = self.state.ctx(self.buffer()); - let mut actions = self.state.mode.edit().map(|edit| edit.finish(&ctx)).unwrap_or_default(); - if let Mode::Move(_) = self.state.mode { - actions.push(Action::App(AppAction::LeaveMode)); - } - self.update(actions); - self.panes.focus(target); - } - let pane = self.panes.current_mut(); - let actions = pane.view.mouse(gesture, &self.state.ctx(pane.buffer)); - if let Gesture::Release { .. } = gesture { - self.dragging = None; - } - self.update(actions); - } - - // ----- Update --------------------------------------------------------- - - /// Carries out actions in order. A problem stops the rest, since they - /// may count on it. - fn update(&mut self, actions: Vec) { - for action in actions { - let message = match action { - Action::Doc(action) => { - let buffer = self.buffer(); - self.state.buffers[buffer].apply(action) - } - Action::App(action) => self.app(action), - }; - if let Some(message) = message { - let problem = message.tone == Tone::Problem; - self.message = Some(message); - if problem { - break; - } - } - } - } - - fn app(&mut self, action: AppAction) -> Option { - match action { - AppAction::StartEdit(start) => { - let started = TextEdit::start(&self.state.ctx(self.buffer()), start); - let in_card = matches!(start, StartEdit::Body { in_card: true, .. }); - match (started, &mut self.state.mode) { - (Err(message), _) => return Some(message), - (Ok(edit), Mode::Popup(Popup::Card(card))) if in_card => card.edit = Some(edit), - (Ok(edit), mode) => *mode = Mode::Text(edit), - } - } - AppAction::EndEdit => match &mut self.state.mode { - // Done editing in the card: the card stays. - Mode::Popup(Popup::Card(card)) => card.edit = None, - mode => *mode = Mode::Normal, - }, - AppAction::TextCursor(at) => { - if let Some(edit) = self.state.mode.edit_mut() { - edit.editor.set_cursor(at); - } - } - AppAction::EnterMoveMode => self.state.mode = Mode::Move(Grab::new()), - AppAction::ToggleWhole => { - if let Mode::Move(grab) = &mut self.state.mode { - grab.whole = !grab.whole; - } - } - AppAction::LeaveMode => self.state.mode = Mode::Normal, - AppAction::OpenCommandLine => { - let line = CommandLine::new(&self.sources(), &self.command_history, self.completion); - self.state.mode = Mode::Command(line); - } - AppAction::CommandLineWith(text) => { - let line = - CommandLine::with_text(&text, &self.sources(), &self.command_history, self.completion); - self.state.mode = Mode::Command(line); - } - AppAction::RunLine(text) => self.run_line(&text), - AppAction::Run(command) => self.dispatch(Typed { command, count: None, register: None }), - AppAction::Type(keys) => keys.into_iter().for_each(|key| self.key(key)), - AppAction::ToggleFocus => { - let focus = &mut self.state.settings.focus; - *focus = !*focus; - let text = - if *focus { "focus on: branches the cursor isn't in fold away" } else { "focus off" }; - return Some(Message::info(text)); - } - AppAction::ToggleWhichKey => { - self.which_key = !self.which_key; - return Some(Message::info(if self.which_key { "key hints on" } else { "key hints off" })); - } - AppAction::Set(arg) => return Some(self.set(arg)), - AppAction::OpenHelp(search) => { - let pane = self.panes.current(); - let contexts = pane.view.contexts(&Mode::Normal); - let help = - Popup::help(&self.keymap, contexts, pane.view.kind().name(), &search, &self.state.theme); - self.state.mode = Mode::Popup(help); - } - AppAction::OpenCard => { - self.state.mode = Mode::Popup(Popup::card(self.state.ctx(self.buffer()).cursor)) - } - AppAction::OpenPicker(kind) => { - let names = self.state.names(); - match Popup::picker(kind, &self.state.ctx(self.buffer()), &names, self.buffer()) { - Ok(picker) => self.state.mode = Mode::Popup(picker), - Err(message) => return Some(message), - } - } - AppAction::OpenMenu { what, col, row } => { - let doc = self.state.buffers[self.buffer()].doc(); - let title = match what { - MenuFor::Node(id) => { - doc.node(id).map(|node| node.title().to_string()).unwrap_or_default() - } - MenuFor::Canvas(_) => "here".to_string(), - }; - let contexts = self.panes.current().view.contexts(&Mode::Normal); - let at = Position::new(col, row); - let menu = Popup::menu(what, &title, at, &self.keymap, contexts, &self.state.theme); - self.state.mode = Mode::Popup(menu); - } - AppAction::Split { arrange, view } => return self.split(arrange, view), - AppAction::FocusPane(direction) => { - if !self.panes.focus_toward(direction, self.screen.panes) { - return Some(Message::info("no pane that way")); - } - } - AppAction::NextPane => self.panes.focus_next(), - AppAction::ClosePane => { - if self.panes.close().is_none() { - self.quit = true; - } - } - AppAction::OnlyPane => { - if self.panes.only().is_empty() { - return Some(Message::info("this is the only pane")); - } - } - AppAction::EqualizePanes => self.panes.equalize(), - AppAction::ShowView(ViewKind::Board) => return Some(Message::info("the board comes in v1.1")), - AppAction::ShowView(kind) => { - let pane = self.panes.current_mut(); - if pane.view.kind() != kind { - *pane = Pane::new(pane.buffer, kind); - } - } - AppAction::SwitchDocument(choice) => return self.switch_document(choice), - AppAction::Quit => self.quit = true, - AppAction::Say(message) => return Some(message), - } - None - } - - /// Splits the focused pane: the new pane shows `view`, or the same view. - fn split(&mut self, arrange: Arrange, view: Option) -> Option { - let focused = self.panes.focused(); - let area = self - .panes - .areas(self.screen.panes) - .into_iter() - .find(|(id, _)| *id == focused) - .map(|(_, area)| area)?; - let room = match arrange { - Arrange::SideBySide => area.width >= 2 * MIN_PANE.0, - Arrange::Stacked => area.height >= 2 * MIN_PANE.1, - }; - if !room { - return Some(Message::problem("not enough room to split this pane")); - } - let pane = self.panes.current(); - let kind = view.unwrap_or(pane.view.kind()); - if kind == ViewKind::Board { - return Some(Message::info("the board comes in v1.1")); - } - let buffer = pane.buffer; - self.panes.split(arrange, Pane::new(buffer, kind)); - None - } - - /// The focused pane shows another document, in a fresh view of the same - /// kind. - fn switch_document(&mut self, choice: DocumentChoice) -> Option { - let count = self.state.buffers.len(); - let here = self.buffer(); - let to = match choice { - DocumentChoice::Next => (here + 1) % count, - DocumentChoice::Previous => (here + count - 1) % count, - DocumentChoice::At(index) => index.min(count - 1), - DocumentChoice::Named(name) => { - let names = self.state.names(); - match names.iter().position(|open| open.eq_ignore_ascii_case(&name)) { - Some(index) => index, - None => return Some(Message::problem(format!("no open document is called \"{name}\""))), - } - } - }; - let pane = self.panes.current_mut(); - if to != pane.buffer { - *pane = Pane::new(to, pane.view.kind()); - } - None - } - - /// `:set`. The playground has one setting; the app's come in stage 8. - fn set(&mut self, arg: SetArg) -> Message { - let value = match arg { - SetArg::Value(_, value) => value, - SetArg::Reset(_) => popup_name(PopupMode::default()).to_string(), - SetArg::Show(_) => { - return Message::info(format!("completion_popup = {}", popup_name(self.completion))); - } - SetArg::On(_) | SetArg::Off(_) | SetArg::Toggle(_) => { - return Message::problem("completion_popup takes a value: :set completion_popup=on-tab"); - } - }; - match PopupMode::named(&value) { - Some(popup) => { - self.completion = popup; - Message::done(format!("completion_popup = {value}")) - } - None => { - let values: Vec<&str> = PopupMode::VALUES.iter().map(|(name, _)| *name).collect(); - Message::problem(format!("completion_popup can be {}", values.join(" or "))) - } - } - } - - // ----- Drawing -------------------------------------------------------- - - fn draw(&mut self, frame: &mut Frame) { - let status_rows = if self.which_key { 2 } else { 1 }; - let [bar, panes_area, status] = - Layout::vertical([Constraint::Length(1), Constraint::Fill(1), Constraint::Length(status_rows)]) - .areas(frame.area()); - self.screen = Screen { bar, panes: panes_area }; - let names = self.state.names(); - frame.render_widget( - DocumentBar { names: &names, current: self.buffer(), theme: &self.state.theme }, - bar, - ); - - // Each pane in its box: the view and the document in the top - // border, the focused one in the accent color. Only the focused pane - // is in the mode; the others show their documents as they are. - let focused = self.panes.focused(); - let normal = Mode::Normal; - let State { buffers, mode, theme, settings } = &self.state; - for (id, area) in self.panes.areas(panes_area) { - let Some(pane) = self.panes.get_mut(id) else { continue }; - let buffer = &buffers[pane.buffer]; - let mode = if id == focused { mode } else { &normal }; - let ctx = ViewContext { doc: buffer.doc(), cursor: buffer.cursor(), mode, theme, settings }; - let color = if id == focused { theme.accent } else { theme.muted }; - let title = format!(" {} · {} ", pane.view.kind().name(), buffer.doc().title()); - let block = Block::bordered() - .border_style(Style::new().fg(color)) - .title(Span::styled(title, Style::new().fg(color))); - let inner = block.inner(area); - frame.render_widget(block, area); - pane.view.layout(inner, &ctx); - pane.view.render(frame, &ctx); - } - - // A popup over the panes; the card sits beside the cursor's node. - let index = self.buffer(); - let anchor = self.panes.current().view.cursor_area(&self.state.ctx(index)); - let State { buffers, mode, theme, settings } = &mut self.state; - if let Mode::Popup(popup) = mode { - let buffer = &buffers[index]; - let ctx = - ViewContext { doc: buffer.doc(), cursor: buffer.cursor(), mode: &normal, theme, settings }; - popup.layout(panes_area, anchor, &ctx); - popup.render(frame, &ctx); - } - - self.draw_status(frame, status); - if let Mode::Command(line) = &self.state.mode { - // The command line takes the bottom row. - let row = Rect { y: status.bottom() - 1, height: 1, ..status }; - line.render(frame, row, panes_area, &self.state.theme); - } - if self.which_key_due() - && let Some(hints) = self.keys.hints(&self.keymap.tables(self.contexts())) - { - frame.render_widget(WhichKey { hints: &hints, theme: &self.state.theme }, panes_area); - } - } - - /// The status bar: the mode, the view, the breadcrumb (or a message), - /// the keys typed so far; then the key hints, unless they're off. - fn draw_status(&self, frame: &mut Frame, area: Rect) { - let (mode, theme) = (&self.state.mode, &self.state.theme); - let typing = mode.any_edit().is_some_and(|edit| edit.editor.is_typing()); - let pane = self.panes.current(); - let buffer = &self.state.buffers[pane.buffer]; - let focus = if self.state.settings.focus { " · focus" } else { "" }; - let view = format!("{}{focus}", pane.view.status()); - let room = usize::from(area.width).saturating_sub(mode.label().len() + view.len() + 16); - let place = match &self.message { - None => Span::raw(breadcrumb(buffer.doc(), buffer.cursor(), room)), - Some(Message { text, tone: Tone::Problem }) => text.clone().light_red(), - Some(Message { text, tone: Tone::Done }) => text.clone().green(), - Some(Message { text, tone: Tone::Info }) => text.clone().yellow(), - }; - let status = StatusBar { - mode: mode.label(), - mode_color: if typing { theme.insert } else { theme.accent }, - view, - place, - keys: self.keys.shown(), - hints: self.which_key.then(|| hints(mode, pane.view.kind())), - theme, - }; - frame.render_widget(status, area); - } - - fn which_key_due(&self) -> bool { - let delay = if self.keys.first_key() == Some(self.keymap.leader()) { - Duration::ZERO - } else { - WHICH_KEY_DELAY - }; - self.which_key && self.keys.pending_since().is_some_and(|since| since.elapsed() >= delay) - } -} - -/// The hint line: the keys for what you're doing. -fn hints(mode: &Mode, view: ViewKind) -> &'static str { - match (mode, view) { - (Mode::Normal, ViewKind::Outline) => { - "j/k rows · h/l parent/child · za fold · zb bodies · i edit · a/o add · M reorder · K body · ? help" - } - (Mode::Normal, ViewKind::Note) => { - "j/k parts · Enter edit or go · h/l siblings · a/o add · panes · ? help" - } - (Mode::Normal, _) => { - "hjkl move · i edit · a/o add · dd delete · K body · M move · panes · menu · ? help" - } - (Mode::Text(edit), _) | (Mode::Popup(Popup::Card(Card { edit: Some(edit), .. })), _) => { - text_hints(edit) - } - (Mode::Move(_), ViewKind::Outline | ViewKind::Note) => { - "j/k swap with a sibling (J/K: 5) · h out a level · l under the one above · Enter done" - } - (Mode::Move(Grab { whole: true, .. }), _) => { - "hjkl moves the branch (HJKL by 5) · M just this node · Enter done" - } - (Mode::Move(_), _) => "hjkl moves just this node (HJKL by 5) · M the whole branch · Enter done", - (Mode::Command(_), _) => "Tab completes · ↑ ↓ history · Enter runs · Esc cancels", - (Mode::Popup(Popup::Card(_)), _) => "j/k scroll · i edit the body · K or Esc close", - (Mode::Popup(_), _) => "j/k move · / search · Enter pick · Esc close", - } -} - -/// The hint line while a text is edited. -fn text_hints(edit: &TextEdit) -> &'static str { - use cumulus::app::text_edit::Field; - match (edit.editor.is_typing(), edit.field) { - (true, Field::Title) if edit.new_node => { - "Enter the next idea · Tab under the one above · Shift-Tab out a level · Esc vim keys" - } - (true, Field::Body) => "Enter a new line · Esc vim keys", - (true, _) => "Enter done · Esc vim keys", - (false, _) => { - "w b e 0 $ f t move · d c y + a motion · x r ~ p · u undo · . repeat · i a type · Esc done" - } - } -} - -/// The cursor's shape in a mode, as in vim: a bar where typed letters go in -/// between two others, a block on the letter vim keys act on, and the -/// terminal's own shape where the cursor is hidden anyway. -fn cursor_shape(mode: &Mode) -> SetCursorStyle { - match mode.any_edit() { - Some(edit) if edit.editor.is_typing() => SetCursorStyle::SteadyBar, - Some(_) => SetCursorStyle::SteadyBlock, - None if matches!(mode, Mode::Command(_)) => SetCursorStyle::SteadyBar, - None => SetCursorStyle::DefaultUserShape, - } -} - -/// What a command does, in words: its own help, or its operator's. -fn describe(command: Command) -> &'static str { - match (help_for(Bound::Command(command)), command) { - ("", Command::Operate(operator, _)) => help_for(Bound::Operator(operator)), - (help, _) => help, - } -} - -/// A completion popup setting's name, as `:set` takes it. -fn popup_name(popup: PopupMode) -> &'static str { - PopupMode::VALUES.iter().find(|(_, mode)| *mode == popup).map_or("?", |(name, _)| *name) -} - -/// The playground driven by typed keys and mouse gestures on a test -/// terminal: what the stages say works, checked end to end. -#[cfg(test)] -#[path = "canvas/tests.rs"] -mod tests; diff --git a/src/app/action.rs b/src/app/action.rs index 0f7f814..122999b 100644 --- a/src/app/action.rs +++ b/src/app/action.rs @@ -5,13 +5,14 @@ //! //! [`Buffer::apply`]: super::buffer::Buffer::apply +use super::documents::BufferId; use super::panes::Arrange; use super::popup::{MenuFor, PickKind}; use super::text_edit::StartEdit; use crate::command::parse::SetArg; use crate::input::command::Command; use crate::input::key::Key; -use crate::model::{Direction, EditOp, Group, NodeId}; +use crate::model::{Direction, EditOp, Group, NodeId, Title}; use crate::view::ViewKind; /// How a status-bar message is meant. @@ -44,6 +45,15 @@ impl Message { pub fn problem(text: impl Into) -> Message { Message { text: text.into(), tone: Tone::Problem } } + + /// Several messages in one, with ` · ` between them: a problem if one of + /// them is. `None` when there are none. + pub fn joined(messages: Vec) -> Option { + let problem = messages.iter().any(|message| message.tone == Tone::Problem); + let tone = if problem { Tone::Problem } else { messages.first()?.tone }; + let texts: Vec<&str> = messages.iter().map(|message| message.text.as_str()).collect(); + Some(Message { text: texts.join(" · "), tone }) + } } /// Where the cursor goes after an edit. @@ -135,18 +145,71 @@ pub enum AppAction { }, FocusPane(Direction), NextPane, - /// Close the focused pane (the last one quits). - ClosePane, + /// Close the focused pane (`:q`, `ZZ`, `Ctrl-w c`). The last one quits, + /// unless a document has unsaved changes; `force` (`:q!`) quits anyway. + ClosePane { + force: bool, + }, OnlyPane, EqualizePanes, /// The focused pane shows another view. ShowView(ViewKind), SwitchDocument(DocumentChoice), - Quit, + // Files. + /// Save (`:w`, `:x`, `:wa`). + Save(SaveWhich), + /// Open a document in the focused pane (`:e`), from a path as typed: + /// relative to the focused document's folder, `~` for the home folder. + /// No path reads the focused document again from its file; `force` + /// (`:e!`) drops unsaved changes to do it. + Edit { + path: Option, + force: bool, + }, + /// Make a new document next to the focused one, and show it (`:new`). + NewDocument(Option), + /// Close the focused document (`:bd`); `force` drops unsaved changes. + CloseDocument { + force: bool, + }, + /// A file changed outside cumulus while its document had unsaved + /// changes here: which version to keep. + Resolve { + buffer: BufferId, + keep: Keep, + }, + + /// Quit cumulus (`:qa`): not while a document has unsaved changes, + /// unless `force` (`:qa!`). + Quit { + force: bool, + }, Say(Message), } +/// What `:w` and its kin save. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SaveWhich { + /// The focused document, changed or not (`:w`). + This, + /// The focused document, if it has unsaved changes (`:x`, `ZZ`). + IfChanged, + /// Every document with unsaved changes (`:wa`). + All, +} + +/// Which version of a document to keep when its file changed outside +/// cumulus while it had unsaved changes here. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Keep { + /// The one on screen: it's saved over the file. + Mine, + /// The file's: it takes the place of the one on screen (`u` brings that + /// back). + Theirs, +} + /// Which document the focused pane switches to. #[derive(Debug, Clone, PartialEq, Eq)] pub enum DocumentChoice { diff --git a/src/app/buffer.rs b/src/app/buffer.rs index e57d7fa..5e2f447 100644 --- a/src/app/buffer.rs +++ b/src/app/buffer.rs @@ -91,6 +91,28 @@ impl Buffer { } } + /// Puts the document as read again from its file in place. When it's + /// the same document, that's an undo step (`u` brings back what was on + /// screen) and it returns true; another document starts a new history, + /// with the cursor on its root. The marks stay where their nodes still + /// are. + pub fn reload(&mut self, newer: Document) -> bool { + let undoable = match self.doc.replace(newer) { + Ok(change) => { + self.history.record(change); + true + } + Err(other) => { + self.doc = *other; + self.history = History::default(); + self.cursor = self.doc.root(); + false + } + }; + self.keep_cursor(); + undoable + } + fn move_cursor(&mut self, to: NodeId, jump: bool) -> Option<Message> { if !self.doc.contains(to) { return Some(Message::problem("that node is gone")); diff --git a/src/app/calls.rs b/src/app/calls.rs index 16d2d4b..774b97e 100644 --- a/src/app/calls.rs +++ b/src/app/calls.rs @@ -1,7 +1,7 @@ //! What `:` commands do: [`actions_for`] turns a parsed [`Call`] into //! actions, the way views turn keys into actions. -use super::action::{Action, AppAction, CursorAfter, DocumentChoice, Message}; +use super::action::{Action, AppAction, CursorAfter, DocumentChoice, Message, SaveWhich}; use super::commands::set_fold; use super::panes::Arrange; use super::popup::PickKind; @@ -19,8 +19,18 @@ pub fn actions_for(call: Call, ctx: &ViewContext<'_>) -> Vec<Action> { let id = ctx.cursor; match call { // :q closes the pane, as in vim: the last one quits. - Call::Quit { .. } | Call::WriteQuit | Call::ClosePane => app(AppAction::ClosePane), - Call::QuitAll { .. } => app(AppAction::Quit), + Call::Quit { force } => app(AppAction::ClosePane { force }), + Call::ClosePane => app(AppAction::ClosePane { force: false }), + Call::WriteQuit => vec![ + Action::App(AppAction::Save(SaveWhich::IfChanged)), + Action::App(AppAction::ClosePane { force: false }), + ], + Call::QuitAll { force } => app(AppAction::Quit { force }), + Call::Write => app(AppAction::Save(SaveWhich::This)), + Call::WriteAll => app(AppAction::Save(SaveWhich::All)), + Call::Edit { path, force } => app(AppAction::Edit { path, force }), + Call::New(title) => app(AppAction::NewDocument(title)), + Call::CloseDocument { force } => app(AppAction::CloseDocument { force }), Call::Help(topic) => app(AppAction::OpenHelp(topic.unwrap_or_default())), Call::Split(view) => app(AppAction::Split { arrange: Arrange::Stacked, view }), Call::VSplit(view) => app(AppAction::Split { arrange: Arrange::SideBySide, view }), @@ -55,17 +65,11 @@ pub fn actions_for(call: Call, ctx: &ViewContext<'_>) -> Vec<Action> { Call::Find(text) => find(ctx, &text), Call::Set(arg) => vec![Action::App(AppAction::Set(arg))], Call::Version => say(Message::info(crate::version())), - Call::Edit { .. } - | Call::New(_) - | Call::Write - | Call::WriteAll - | Call::CloseDocument { .. } - | Call::Portal(_) - | Call::Backlinks => later("files and portals come in stage 8"), + Call::Portal(_) | Call::Backlinks => later("portals come in part 8d of the build"), Call::Jumps | Call::Marks | Call::Registers => { - later("the lists of jumps, marks and registers come in stage 8") + later("the lists of jumps, marks and registers come in part 8c of the build") } - Call::Theme(_) | Call::Config => later("theme and config files come in stage 8"), + Call::Theme(_) | Call::Config => later("theme and config files come in part 8b of the build"), Call::Tidy(_) | Call::Align(_) | Call::BranchOut | Call::Export { .. } => { later("this one comes in v1.1") } diff --git a/src/app/commands.rs b/src/app/commands.rs index e38098d..0329586 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -7,7 +7,7 @@ use std::collections::BTreeMap; -use super::action::{Action, AppAction, CursorAfter, DocAction, DocumentChoice, Message}; +use super::action::{Action, AppAction, CursorAfter, DocAction, DocumentChoice, Message, SaveWhich}; use super::mode::Grab; use super::panes::Arrange; use super::popup::PickKind; @@ -33,7 +33,7 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> { Command::SetMark(name) => vec![Action::Doc(DocAction::SetMark(name))], Command::JumpToMark(name) => vec![Action::Doc(DocAction::JumpToMark(name))], Command::Activate if ctx.doc.node(cursor).is_some_and(Node::is_portal) => { - say("going through a portal comes in stage 8") + say("going through a portal comes in part 8d of the build") } Command::EditTitle | Command::Activate => edit(EditAt::End), Command::EditTitleAtStart => edit(EditAt::Start), @@ -54,6 +54,8 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> { Command::CloseAllFolds => fold_all(ctx, Fold::Closed), Command::MoveMode => app(AppAction::EnterMoveMode), Command::CommandLine => app(AppAction::OpenCommandLine), + // `<Space>i`: an idea to the Inbox, typed in `:in`. + Command::Capture => app(AppAction::CommandLineWith("in ".into())), Command::ToggleFocus => app(AppAction::ToggleFocus), Command::ToggleWhichKey => app(AppAction::ToggleWhichKey), // Popups. @@ -72,7 +74,7 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> { Command::FocusUp => app(AppAction::FocusPane(Direction::Up)), Command::FocusRight => app(AppAction::FocusPane(Direction::Right)), Command::NextPane => app(AppAction::NextPane), - Command::ClosePane => app(AppAction::ClosePane), + Command::ClosePane => app(AppAction::ClosePane { force: false }), Command::OnlyPane => app(AppAction::OnlyPane), Command::EqualizePanes => app(AppAction::EqualizePanes), Command::ShowCanvas => app(AppAction::ShowView(ViewKind::Canvas)), @@ -80,8 +82,12 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> { Command::ShowNote => app(AppAction::ShowView(ViewKind::Note)), Command::NextDocument => app(AppAction::SwitchDocument(DocumentChoice::Next)), Command::PreviousDocument => app(AppAction::SwitchDocument(DocumentChoice::Previous)), - // ZZ closes the pane, as in vim: the last one quits. - Command::Close => app(AppAction::ClosePane), + // ZZ saves (if there are changes) and closes the pane, as in vim: the + // last one quits. + Command::Close => vec![ + Action::App(AppAction::Save(SaveWhich::IfChanged)), + Action::App(AppAction::ClosePane { force: false }), + ], // Esc always gets back to normal mode. Command::Escape => app(AppAction::LeaveMode), _ => return None, diff --git a/src/app/documents.rs b/src/app/documents.rs new file mode 100644 index 0000000..c7ccf52 --- /dev/null +++ b/src/app/documents.rs @@ -0,0 +1,235 @@ +//! The open documents (vim's buffers), each with its file: where it is, the +//! version on disk, and whether this cumulus may write it. + +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use super::buffer::Buffer; +use crate::model::{DocUid, Document, StateId}; +use crate::storage::lock::Lock; +use crate::storage::stamp::Stamp; +use crate::view::canvas::Camera; + +/// An open document's number, like vim's buffer numbers. Numbers aren't +/// handed out twice while cumulus runs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct BufferId(u32); + +/// An open document: the buffer, and its file. +#[derive(Debug)] +pub struct Open { + pub id: BufferId, + pub buffer: Buffer, + pub file: DocFile, + /// How a canvas last showed it: the next canvas on it starts there. + pub camera: Option<Camera>, +} + +impl Open { + /// Whether it has changes its file doesn't have yet. + pub fn is_dirty(&self) -> bool { + self.buffer.history().state() != self.file.saved + } + + /// Whether another cumulus has it, so this one only shows it. + pub fn is_read_only(&self) -> bool { + matches!(self.file.access, Access::ReadOnly) + } + + /// Its name: its root's title. + pub fn name(&self) -> String { + self.buffer.doc().title().to_string() + } + + /// Its file's name, for messages: `garden-plans.cum`. + pub fn file_name(&self) -> String { + self.file + .path + .file_name() + .map_or_else(|| self.file.path.display().to_string(), |name| name.to_string_lossy().into_owned()) + } +} + +/// An open document's file. +#[derive(Debug)] +pub struct DocFile { + /// Where it is, in full. + pub path: PathBuf, + /// The file as cumulus last read or wrote it. `None` while there's no + /// file: a new document is written at its first change. + pub stamp: Option<Stamp>, + /// The document's state when it last matched the file. + pub saved: StateId, + /// Whether this cumulus may write it. + pub access: Access, + /// When the latest unsaved change was made: autosave waits a second + /// after it, so typing isn't saved letter by letter. + pub changed: Option<Instant>, + /// Something wrong with the file. Autosave holds off until it's sorted + /// out. + pub trouble: Option<Trouble>, +} + +/// Whether this cumulus may write a document. +#[derive(Debug)] +pub enum Access { + /// It holds the document's lock. + Locked(Lock), + /// The lock couldn't be taken (the data folder can't be written): it + /// may write, unprotected. + Unlocked, + /// Another cumulus has the document: this one only shows it, until the + /// other one lets go. + ReadOnly, +} + +/// Why autosave holds off. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Trouble { + /// The file changed outside cumulus, and has a mistake in it now (hand + /// edits in progress, say). Fixing the file reloads it; `:w` saves over + /// it. + Broken(String), + /// The file changed outside cumulus while this one had unsaved changes: + /// cumulus asks which to keep. `asked` once the question was shown, so + /// closing it doesn't bring it back. + Conflict { newer: Box<Document>, asked: bool }, + /// The file was deleted or moved away: `:w` writes it again. + Gone, + /// Saving failed (the reason is in it). The next change or `:w` tries + /// again. + SaveFailed(String), +} + +/// The open documents, in the order the document bar shows them. There's +/// always one at least: cumulus starts with one, and the last one can't be +/// closed (closing it is quitting). So a document can always be looked up, +/// and the `get` methods need no `Option`. +#[derive(Debug)] +pub struct Documents { + first: Open, + rest: Vec<Open>, + next: u32, +} + +impl Documents { + pub fn new(buffer: Buffer, file: DocFile) -> Documents { + Documents { first: Open { id: BufferId(1), buffer, file, camera: None }, rest: Vec::new(), next: 2 } + } + + /// Opens another document, last in the bar. + pub fn add(&mut self, buffer: Buffer, file: DocFile) -> BufferId { + let id = BufferId(self.next); + self.next += 1; + self.rest.push(Open { id, buffer, file, camera: None }); + id + } + + /// The document with this number. Panes only hold numbers of open + /// documents (closing one moves its panes first); an unknown number gets + /// the first document rather than a crash. + pub fn get(&self, id: BufferId) -> &Open { + self.rest.iter().find(|open| open.id == id).unwrap_or(&self.first) + } + + /// The document with this number (see [`Documents::get`]). + pub fn get_mut(&mut self, id: BufferId) -> &mut Open { + match self.rest.iter().position(|open| open.id == id) { + Some(index) => &mut self.rest[index], + None => &mut self.first, + } + } + + pub fn contains(&self, id: BufferId) -> bool { + self.iter().any(|open| open.id == id) + } + + pub fn iter(&self) -> impl Iterator<Item = &Open> { + std::iter::once(&self.first).chain(&self.rest) + } + + pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut Open> { + std::iter::once(&mut self.first).chain(&mut self.rest) + } + + /// The numbers, in the bar's order. + pub fn ids(&self) -> Vec<BufferId> { + self.iter().map(|open| open.id).collect() + } + + /// How many are open: one at least. + pub fn count(&self) -> usize { + 1 + self.rest.len() + } + + /// Where a document is in the bar (the first place for an unknown one). + pub fn index_of(&self, id: BufferId) -> usize { + self.iter().position(|open| open.id == id).unwrap_or(0) + } + + /// The document at a place in the bar, or the last one past the end. + pub fn at(&self, index: usize) -> BufferId { + match index { + 0 => self.first.id, + _ => self.rest.get(index - 1).or(self.rest.last()).map_or(self.first.id, |open| open.id), + } + } + + /// The document whose file is at `path`. + pub fn find_path(&self, path: &Path) -> Option<BufferId> { + self.iter().find(|open| open.file.path == path).map(|open| open.id) + } + + /// The document with this uid. + pub fn find_uid(&self, uid: DocUid) -> Option<BufferId> { + self.iter().find(|open| open.buffer.doc().uid() == uid).map(|open| open.id) + } + + /// Closes a document. The last one stays: `None`. + pub fn remove(&mut self, id: BufferId) -> Option<Open> { + if self.first.id == id { + if self.rest.is_empty() { + return None; + } + let next = self.rest.remove(0); + return Some(std::mem::replace(&mut self.first, next)); + } + let index = self.rest.iter().position(|open| open.id == id)?; + Some(self.rest.remove(index)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::test_support::garden; + + fn file(name: &str) -> DocFile { + DocFile { + path: PathBuf::from(name), + stamp: None, + saved: StateId::default(), + access: Access::ReadOnly, + changed: None, + trouble: None, + } + } + + #[test] + fn there_is_always_one_document() { + let mut docs = Documents::new(Buffer::new(garden().0), file("a.cum")); + let first = docs.ids()[0]; + let second = docs.add(Buffer::new(garden().0), file("b.cum")); + let third = docs.add(Buffer::new(garden().0), file("c.cum")); + assert_eq!((docs.at(0), docs.at(2), docs.at(9)), (first, third, third)); + assert_eq!(docs.index_of(third), 2); + assert_eq!(docs.find_path(Path::new("b.cum")), Some(second)); + assert!(docs.remove(first).is_some()); + assert_eq!(docs.ids(), [second, third], "the next one comes first"); + assert!(docs.remove(third).is_some()); + assert!(docs.remove(second).is_none(), "the last one stays"); + assert_eq!(docs.get(first).id, second, "an unknown number gets the first"); + let fourth = docs.add(Buffer::new(garden().0), file("d.cum")); + assert!(fourth > third, "numbers aren't handed out twice"); + } +} diff --git a/src/app/files.rs b/src/app/files.rs new file mode 100644 index 0000000..f5a27ac --- /dev/null +++ b/src/app/files.rs @@ -0,0 +1,657 @@ +//! The documents' files: opening, saving, daily backups, reloading what +//! changed outside cumulus, read-only documents, and where you left off +//! (DESIGN.md, "Files and saving"). +//! +//! - A change is saved about a second after it (`autosave`; off, `:w` saves, +//! as in vim). Undoing back to what's on disk counts as no change. +//! - Every second, each file's [stamp](crate::storage::stamp) is checked. A +//! file changed outside cumulus is read again, as an undo step (`u` +//! brings back what was on screen). If there were unsaved changes too, +//! cumulus asks which to keep; if the file has a mistake in it (a hand +//! edit in progress), autosave waits until it's fixed. +//! - The day's first save copies the file to the backups first. +//! - A document another cumulus has open is read-only here, until that one +//! lets go of it. + +use std::io; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use thiserror::Error; + +use super::action::{DocAction, Keep, Message, SaveWhich, Tone}; +use super::buffer::Buffer; +use super::documents::{Access, BufferId, DocFile, Documents, Open, Trouble}; +use super::mode::Mode; +use super::popup::Popup; +use super::{App, Pane}; +use crate::config::paths::Paths; +use crate::model::{CanvasPos, DocUid, Document, NodeId, Title}; +use crate::storage::backup::{self, Day}; +use crate::storage::document_file::{self, LoadError}; +use crate::storage::lock::Lock; +use crate::storage::memory::LeftOff; +use crate::storage::names::{self, file_name, title_from_file}; +use crate::storage::stamp::Stamp; +use crate::storage::write_atomically; +use crate::view::canvas::{Camera, Zoom}; + +/// How long autosave waits after the latest change. +const AUTOSAVE_AFTER: Duration = Duration::from_secs(1); +/// How often the files are checked for changes made outside cumulus. +const WATCH_EVERY: Duration = Duration::from_secs(1); + +/// A document ready to open: read from its file, or new. +#[derive(Debug)] +pub struct Loaded { + pub doc: Document, + /// Where its file is, in full. + pub path: PathBuf, + /// The file as it was read; `None` for a new document, whose file is + /// written at its first change. + pub stamp: Option<Stamp>, +} + +/// Why a document couldn't be opened or made. +#[derive(Debug, Error)] +pub enum OpenError { + #[error("{}: {error}", .path.display())] + Read { path: PathBuf, error: io::Error }, + #[error("{}: {error}", .path.display())] + Load { path: PathBuf, error: LoadError }, + #[error("{}: can't write it: {error}", .path.display())] + Write { path: PathBuf, error: io::Error }, +} + +impl OpenError { + /// The error with just the file's name, for the status bar. + fn short(&self) -> String { + let (path, error) = match self { + OpenError::Read { path, error } => (path, error.to_string()), + OpenError::Load { path, error } => (path, error.to_string()), + OpenError::Write { path, error } => (path, format!("can't write it: {error}")), + }; + format!("{}: {error}", short_name(path)) + } +} + +/// Reads the document at `path`. With no file there, it's a new document +/// named after the file (written once something is added to it). +pub fn load(path: &Path) -> Result<Loaded, OpenError> { + let path = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()); + let stamp = Stamp::of(&path).map_err(|error| OpenError::Read { path: path.clone(), error })?; + if stamp.is_none() { + return Ok(Loaded { doc: Document::new(title_from_file(&path)), path, stamp }); + } + let text = + std::fs::read_to_string(&path).map_err(|error| OpenError::Read { path: path.clone(), error })?; + let doc = document_file::parse(&text).map_err(|error| OpenError::Load { path: path.clone(), error })?; + Ok(Loaded { doc, path, stamp }) +} + +/// Makes a new document at `path` and writes it right away (`cumulus new`, +/// `:new`). +pub fn create(path: &Path, title: Title) -> Result<Loaded, OpenError> { + let path = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()); + let doc = Document::new(title); + let write = |error: io::Error| OpenError::Write { path: path.clone(), error }; + let text = document_file::write(&doc).map_err(|error| write(io::Error::other(error)))?; + write_atomically(&path, &text).map_err(write)?; + let stamp = Stamp::of(&path).map_err(write)?; + Ok(Loaded { doc, path, stamp }) +} + +/// The first document, which starts the list of open ones. +pub(super) fn open_first(loaded: Loaded, paths: &Paths) -> (Documents, Option<Message>) { + let (buffer, file, said) = opened(loaded, paths); + (Documents::new(buffer, file), said) +} + +/// A loaded document as a buffer and its file, with its lock taken if it's +/// free, and what to say about it. +fn opened(loaded: Loaded, paths: &Paths) -> (Buffer, DocFile, Option<Message>) { + let Loaded { doc, path, stamp } = loaded; + let name = short_name(&path); + let access = access(paths, doc.uid()); + let read_only = matches!(access, Access::ReadOnly); + let said = read_only.then(|| Message::info(format!("{name} is open in another cumulus: read-only here"))); + let said = said.or_else(|| { + let shown = shown(&path, &paths.home); + stamp + .is_none() + .then(|| Message::info(format!("a new document: it's saved as {shown} once you add something"))) + }); + let buffer = Buffer::new(doc); + let saved = buffer.history().state(); + (buffer, DocFile { path, stamp, saved, access, changed: None, trouble: None }, said) +} + +/// Takes a document's lock, if it's free. +fn access(paths: &Paths, uid: DocUid) -> Access { + match Lock::take(&paths.locks(), uid) { + Ok(Some(lock)) => Access::Locked(lock), + Ok(None) => Access::ReadOnly, + // Without a lock file (the data folder can't be written), the + // document can still be edited. + Err(_) => Access::Unlocked, + } +} + +/// Where a file is, for a message: from `~` when it's in the home folder. +fn shown(path: &Path, home: &Path) -> String { + match path.strip_prefix(home) { + Ok(rest) => { + let parts: Vec<String> = rest.iter().map(|part| part.to_string_lossy().into_owned()).collect(); + format!("~/{}", parts.join("/")) + } + Err(_) => path.display().to_string(), + } +} + +/// A file's name, for messages: `garden-plans.cum`. +fn short_name(path: &Path) -> String { + path.file_name().map_or_else(|| path.display().to_string(), |name| name.to_string_lossy().into_owned()) +} + +/// A zoom by the name the memory keeps. +fn zoom_named(name: &str) -> Zoom { + [Zoom::Normal, Zoom::Far, Zoom::Overview] + .into_iter() + .find(|zoom| zoom.name().eq_ignore_ascii_case(name)) + .unwrap_or_default() +} + +impl App { + /// Opens another document (or finds it open already: the same file, or + /// the same document under another path). Returns it and what to say. + pub(super) fn add_document(&mut self, loaded: Loaded) -> (BufferId, Option<Message>) { + let docs = &self.shared.docs; + if let Some(id) = docs.find_path(&loaded.path).or_else(|| docs.find_uid(loaded.doc.uid())) { + return (id, None); + } + let (buffer, file, said) = opened(loaded, &self.paths); + let id = self.shared.docs.add(buffer, file); + self.came_back_to(id); + (id, said) + } + + /// A document was opened: back to where you left off in it, and it's + /// the most recent document now. + pub(super) fn came_back_to(&mut self, id: BufferId) { + let open = self.shared.docs.get_mut(id); + let uid = open.buffer.doc().uid().to_string(); + if let Some(place) = self.memory.places.get(&uid) { + let cursor = NodeId::new(place.cursor); + if open.buffer.doc().contains(cursor) { + open.buffer.apply(DocAction::MoveCursor { to: cursor, jump: false }); + } + open.camera = + Some(Camera { origin: CanvasPos::new(place.x, place.y), zoom: zoom_named(&place.zoom) }); + } + let (path, title) = (open.file.path.clone(), open.name()); + self.memory.opened(&path, uid, title); + } + + /// Keeps each pane's camera with its document: the focused pane's last, + /// so it's the one remembered. + pub(super) fn keep_cameras(&mut self) { + let focused = self.panes.focused(); + let mut cameras: Vec<(bool, BufferId, Camera)> = self + .panes + .iter_mut() + .filter_map(|(id, pane)| Some((id == focused, pane.buffer, pane.view.camera()?))) + .collect(); + cameras.sort_by_key(|(focused, _, _)| *focused); + for (_, buffer, camera) in cameras { + self.shared.docs.get_mut(buffer).camera = Some(camera); + } + } + + /// Notes where you are in a document, for next time. + fn note_place(&mut self, id: BufferId) { + let open = self.shared.docs.get(id); + let camera = open.camera.unwrap_or_default(); + let place = LeftOff { + cursor: open.buffer.cursor().get(), + zoom: camera.zoom.name().to_lowercase(), + x: camera.origin.x, + y: camera.origin.y, + }; + self.memory.places.insert(open.buffer.doc().uid().to_string(), place); + } + + /// Writes what cumulus remembers between runs: where you left off in + /// each open document, the documents opened lately, the command history. + pub fn remember(&mut self) -> io::Result<()> { + self.keep_cameras(); + for id in self.shared.docs.ids() { + self.note_place(id); + } + self.memory.history = self.command_history.lines().to_vec(); + // A new document never written, or one deleted since: nothing to + // open there next time. + self.memory.recent.retain(|recent| recent.path.exists()); + self.memory.save(&self.paths.memory()) + } + + // ----- Time passing --------------------------------------------------- + + /// What happens as time passes: autosave, and every second a look at + /// the files for changes made outside cumulus. Returns whether there's + /// something new to draw. + pub fn tick(&mut self, now: Instant) -> bool { + let mut changed = false; + if now.saturating_duration_since(self.watched) >= WATCH_EVERY { + self.watched = now; + changed |= self.watch(); + } + changed |= self.autosave(now); + changed | self.ask_about_conflicts() + } + + /// Saves the documents whose latest change is a second old. One whose + /// file changed outside cumulus since isn't saved over: that's looked + /// at first. + fn autosave(&mut self, now: Instant) -> bool { + if !self.settings.autosave { + return false; + } + let due: Vec<BufferId> = self + .shared + .docs + .iter() + .filter(|open| { + open.is_dirty() + && !open.is_read_only() + && open.file.trouble.is_none() + && open.file.changed.is_some_and(|at| now.saturating_duration_since(at) >= AUTOSAVE_AFTER) + }) + .map(|open| open.id) + .collect(); + for &id in &due { + let said = if self.changed_outside(id) { self.look_at(id) } else { self.save(id).err() }; + if let Some(message) = said { + self.message = Some(message); + } + } + !due.is_empty() + } + + /// Whether a document's file changed since cumulus last read or wrote + /// it. + fn changed_outside(&self, id: BufferId) -> bool { + let file = &self.shared.docs.get(id).file; + Stamp::of(&file.path).is_ok_and(|stamp| stamp != file.stamp) + } + + /// Writes a document to its file: the day's first save copies the old + /// file to the backups first. + fn save(&mut self, id: BufferId) -> Result<(), Message> { + let backups = self.paths.backups(); + let open = self.shared.docs.get_mut(id); + if open.is_read_only() { + return Err(read_only(open)); + } + let path = open.file.path.clone(); + let failed = |open: &mut Open, error: String| { + let text = format!("couldn't save {}: {error}", open.file_name()); + open.file.trouble = Some(Trouble::SaveFailed(text.clone())); + Message::problem(text) + }; + let text = match document_file::write(open.buffer.doc()) { + Ok(text) => text, + Err(error) => return Err(failed(open, error.to_string())), + }; + // A backup that fails mustn't stop the save itself. + let _ = backup::daily(&backups, open.buffer.doc().uid(), &path, Day::today(), backup::KEEP); + if let Err(error) = write_atomically(&path, &text) { + return Err(failed(open, error.to_string())); + } + open.file.stamp = Stamp::of(&path).ok().flatten(); + open.file.saved = open.buffer.history().state(); + open.file.changed = None; + open.file.trouble = None; + Ok(()) + } + + /// Looks at every file for changes made outside cumulus, and lets + /// read-only documents be edited once the other cumulus lets go. + fn watch(&mut self) -> bool { + let mut said = Vec::new(); + for id in self.shared.docs.ids() { + said.extend(self.look_at(id)); + let open = self.shared.docs.get_mut(id); + if open.is_read_only() + && let Access::Locked(lock) = access(&self.paths, open.buffer.doc().uid()) + { + open.file.access = Access::Locked(lock); + said.push(Message::info(format!("{}: you can edit it now", open.file_name()))); + } + } + let changed = !said.is_empty(); + if let Some(message) = Message::joined(said) { + self.message = Some(message); + } + changed + } + + /// Looks at one document's file, and acts on what changed. Not while + /// its text is being edited or a node moved: the document stays as it + /// is under the edit, and the file is looked at again after. + fn look_at(&mut self, id: BufferId) -> Option<Message> { + let busy = matches!(self.shared.mode, Mode::Text(_) | Mode::Move(_)) + || self.shared.mode.any_edit().is_some(); + if busy && id == self.buffer() { + return None; + } + let open = self.shared.docs.get_mut(id); + let stamp = Stamp::of(&open.file.path).ok()?; + if stamp == open.file.stamp { + return None; + } + let name = open.file_name(); + let Some(stamp) = stamp else { + // Gone: deleted, or moved away. Writing it back right away + // could undo a move, so that waits for `:w`. + open.file.stamp = None; + open.file.trouble = Some(Trouble::Gone); + return Some(Message::problem(format!("{name} was deleted or moved: :w writes it here again"))); + }; + let text = std::fs::read_to_string(&open.file.path).ok()?; + open.file.stamp = Some(stamp); + let newer = match document_file::parse(&text) { + Ok(newer) => newer, + Err(error) => { + open.file.trouble = Some(Trouble::Broken(error.to_string())); + return Some(Message::problem(format!( + "{name} changed outside cumulus, with a mistake in it: {error} · autosave waits until it's fixed (:w saves over it)" + ))); + } + }; + if open.is_dirty() { + open.file.trouble = Some(Trouble::Conflict { newer: Box::new(newer), asked: false }); + return None; + } + Some(self.take_in(id, newer, "{} changed outside cumulus: here it is · u undoes that")) + } + + /// Puts a document read again from its file in place. `said` is what to + /// say when that's an undo step, about the file `{}`; a different + /// document has its own message. + fn take_in(&mut self, id: BufferId, newer: Document, said: &str) -> Message { + let open = self.shared.docs.get_mut(id); + let name = open.file_name(); + let undoable = open.buffer.reload(newer); + open.file.saved = open.buffer.history().state(); + open.file.changed = None; + open.file.trouble = None; + if undoable { + return Message::info(said.replace("{}", &name)); + } + // Another document: the lock it needs is its own. + open.file.access = access(&self.paths, open.buffer.doc().uid()); + Message::info(format!("{name} holds another document now: here it is")) + } + + /// Asks which version to keep, for a document whose file changed while + /// it had unsaved changes here: in normal mode, not to interrupt typing. + fn ask_about_conflicts(&mut self) -> bool { + if !matches!(self.shared.mode, Mode::Normal) { + return false; + } + for open in self.shared.docs.iter_mut() { + if let Some(Trouble::Conflict { asked, .. }) = &mut open.file.trouble + && !*asked + { + *asked = true; + self.shared.mode = Mode::Popup(Popup::conflict(open.id, &open.file_name())); + return true; + } + } + false + } + + /// Keeps one version of a document whose file changed outside cumulus. + pub(super) fn resolve(&mut self, id: BufferId, keep: Keep) -> Option<Message> { + if !self.shared.docs.contains(id) { + return None; + } + let open = self.shared.docs.get_mut(id); + let Some(Trouble::Conflict { newer, .. }) = open.file.trouble.take() else { return None }; + match keep { + Keep::Mine => Some(match self.save(id) { + Ok(()) => { + Message::done(format!("kept yours: saved {}", self.shared.docs.get(id).file_name())) + } + Err(problem) => problem, + }), + Keep::Theirs => Some(self.take_in(id, *newer, "took the file's {} · u brings yours back")), + } + } + + // ----- Commands ------------------------------------------------------- + + /// `:w`, `:x` and `:wa`. + pub(super) fn write(&mut self, which: SaveWhich) -> Option<Message> { + let here = self.buffer(); + let ids: Vec<BufferId> = match which { + SaveWhich::This => vec![here], + SaveWhich::IfChanged => { + let open = self.shared.docs.get(here); + if !open.is_dirty() && open.file.trouble.is_none() { + return None; + } + vec![here] + } + SaveWhich::All => { + self.shared.docs.iter().filter(|open| open.is_dirty()).map(|open| open.id).collect() + } + }; + if ids.is_empty() { + return Some(Message::info("nothing to save: every document is saved")); + } + let mut names = Vec::new(); + for id in ids { + // `:w` settles a conflict: what's on screen is what you keep. + self.shared.docs.get_mut(id).file.trouble = None; + if let Err(problem) = self.save(id) { + return Some(problem); + } + names.push(self.shared.docs.get(id).file_name()); + } + Some(Message::done(format!("saved {}", names.join(", ")))) + } + + /// `:e`: opens a document in the focused pane, or (with no path) reads + /// the focused one again from its file. + pub(super) fn edit(&mut self, path: Option<String>, force: bool) -> Option<Message> { + let Some(path) = path else { return self.read_again(force) }; + let path = self.resolve_path(&path); + let loaded = match load(&path) { + Ok(loaded) => loaded, + Err(error) => return Some(Message::problem(error.short())), + }; + let (id, said) = self.add_document(loaded); + self.show(id); + said + } + + /// `:e` and `:e!` with no file: the focused document as its file has + /// it. `:e` refuses to drop unsaved changes. + fn read_again(&mut self, force: bool) -> Option<Message> { + let id = self.buffer(); + let open = self.shared.docs.get(id); + let name = open.file_name(); + if open.is_dirty() && !force { + return Some(Message::problem(format!("{name} has unsaved changes: :e! drops them"))); + } + let loaded = match load(&open.file.path) { + Ok(loaded) if loaded.stamp.is_some() => loaded, + Ok(_) => return Some(Message::problem(format!("{name} isn't on disk yet"))), + Err(error) => return Some(Message::problem(error.short())), + }; + let open = self.shared.docs.get_mut(id); + open.file.stamp = loaded.stamp; + Some(self.take_in(id, loaded.doc, "read {} again from the file · u undoes that")) + } + + /// A path typed in `:e` or `:new`: `~` is the home folder, relative paths + /// start at the focused document's folder, a folder means its default + /// document, and `.cum` is added when there's no extension. + fn resolve_path(&self, typed: &str) -> PathBuf { + let path = self.paths.expand(typed.trim()); + let path = if path.is_relative() { self.folder().join(path) } else { path }; + if path.is_dir() { + return path.join(format!("{}.{}", self.settings.default_document, names::EXTENSION)); + } + if path.extension().is_none() { path.with_extension(names::EXTENSION) } else { path } + } + + /// The focused document's folder. + pub(super) fn folder(&self) -> PathBuf { + let path = &self.shared.docs.get(self.buffer()).file.path; + path.parent().map_or_else(|| PathBuf::from("."), Path::to_path_buf) + } + + /// `:new`: a new document next to the focused one, written right away, + /// and shown. With no title, it's the folder's default document. + pub(super) fn new_document(&mut self, title: Option<Title>) -> Option<Message> { + let name = match &title { + Some(title) => file_name(title.as_str()), + None => format!("{}.{}", self.settings.default_document, names::EXTENSION), + }; + let path = self.folder().join(&name); + if path.exists() { + return match self.edit(Some(path.display().to_string()), false) { + Some(problem) if problem.tone == Tone::Problem => Some(problem), + _ => Some(Message::info(format!("{name} is here already: here it is"))), + }; + } + let title = title.unwrap_or_else(|| title_from_file(&path)); + match create(&path, title) { + Ok(loaded) => { + let (id, _) = self.add_document(loaded); + self.show(id); + Some(Message::done(format!("made {name}"))) + } + Err(error) => Some(Message::problem(error.short())), + } + } + + /// `:bd`: closes the focused document; its panes show another one. + pub(super) fn close_document(&mut self, force: bool) -> Option<Message> { + let id = self.buffer(); + if self.shared.docs.count() == 1 { + return Some(Message::info("it's the only open document: :q quits")); + } + if let Some(problem) = self.before_closing(&[id], force, ":bd!") { + return Some(problem); + } + self.keep_cameras(); + self.note_place(id); + let index = self.shared.docs.index_of(id); + let name = self.shared.docs.get(id).file_name(); + self.shared.docs.remove(id); + let next = self.shared.docs.at(index.saturating_sub(1)); + let camera = self.shared.docs.get(next).camera; + for (_, pane) in self.panes.iter_mut().filter(|(_, pane)| pane.buffer == id) { + *pane = Pane::new(next, pane.view.kind(), camera); + } + Some(Message::done(format!("closed {name}"))) + } + + /// `:qa`, and `:q` on the last pane. + pub(super) fn quit(&mut self, force: bool) -> Option<Message> { + let ids = self.shared.docs.ids(); + if let Some(problem) = self.before_closing(&ids, force, ":q!") { + return Some(problem); + } + self.quit = true; + None + } + + /// What has to happen before documents close: with autosave, their + /// changes are saved; without, unsaved changes stop it (unless `force`, + /// which `bang` names). + fn before_closing(&mut self, ids: &[BufferId], force: bool, bang: &str) -> Option<Message> { + if force { + return None; + } + for &id in ids { + // Not over a change made outside cumulus: that's looked at first. + if self.changed_outside(id) { + self.look_at(id); + } + let open = self.shared.docs.get(id); + let name = open.file_name(); + match &open.file.trouble { + Some(Trouble::Conflict { .. }) => { + return Some(Message::problem(format!( + "{name} changed outside cumulus: :w keeps yours, :e! takes the file's, {bang} neither" + ))); + } + Some(Trouble::Broken(_)) if open.is_dirty() => { + return Some(Message::problem(format!( + "{name} has a mistake in it on disk: :w saves yours over it, {bang} doesn't" + ))); + } + Some(Trouble::Gone) if open.is_dirty() => { + return Some(Message::problem(format!( + "{name} was deleted or moved: :w writes it here again, {bang} doesn't" + ))); + } + _ => {} + } + if !open.is_dirty() { + continue; + } + if !self.settings.autosave { + return Some(Message::problem(format!( + "{name} has unsaved changes: :w saves them, {bang} drops them" + ))); + } + if let Err(problem) = self.save(id) { + return Some(problem); + } + } + None + } + + /// The focused pane shows document `id`, in the same kind of view. Its + /// canvas camera stays with the document it showed. + pub(super) fn show(&mut self, id: BufferId) { + let pane = self.panes.current_mut(); + if pane.buffer == id { + return; + } + if let Some(camera) = pane.view.camera() { + self.shared.docs.get_mut(pane.buffer).camera = Some(camera); + } + let camera = self.shared.docs.get(id).camera; + *pane = Pane::new(id, pane.view.kind(), camera); + } + + /// The message for a change refused because the document is read-only. + pub(super) fn refuse_read_only(&self) -> Option<Message> { + let open = self.shared.docs.get(self.buffer()); + open.is_read_only().then(|| read_only(open)) + } +} + +fn read_only(open: &Open) -> Message { + Message::problem(format!("{} is open in another cumulus: read-only here", open.file_name())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paths_in_messages_start_from_home() { + let home = Path::new("/home/sam"); + assert_eq!(shown(&home.join("ideas.cum"), home), "~/ideas.cum"); + assert_eq!(shown(&home.join("notes").join("a.cum"), home), "~/notes/a.cum"); + let elsewhere = Path::new("/srv/b.cum"); + assert_eq!(shown(elsewhere, home), elsewhere.display().to_string()); + } +} diff --git a/src/app/input.rs b/src/app/input.rs new file mode 100644 index 0000000..c407eb0 --- /dev/null +++ b/src/app/input.rs @@ -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, + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 3253a01..b3d675c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -5,22 +5,211 @@ //! focused view answers it with [`action`]s, and [`commands::shared`] //! answers what the view leaves. A `:` command line is answered by //! [`calls::actions_for`] instead, keys while a text is edited by its -//! [`text_edit`], and keys while a popup is open by the [`popup`]. -//! 2. Document actions go to the [`buffer`] holding the document: the one -//! place a document changes. The rest change the [`mode`] or the app. +//! [`text_edit`], and keys while a popup is open by the [`popup`] +//! (`input.rs`). +//! 2. `App::update` carries the actions out: document actions go to the +//! [`buffer`] holding the document, the one place a document changes; +//! the rest change the [`mode`] or the app (`update.rs`). +//! 3. The screen is drawn from the state (`render.rs`). //! -//! The screen is split into [`panes`], each showing a document through a -//! view. -//! -//! So far this is what stages 2 to 7 needed; stage 8 adds the main loop, -//! files and settings. +//! The screen is split into [`panes`], each showing one of the open +//! [`documents`] through a view. `files.rs` looks after the documents' +//! files: opening, autosave, backups, reloading what changed outside +//! cumulus, read-only documents, where you left off. [`startup`] decides +//! what `cumulus` opens, and [`run`] is the main loop on a real terminal. pub mod action; pub mod buffer; pub mod calls; pub mod commands; +pub mod documents; +mod files; +mod input; pub mod mode; pub mod panes; pub mod popup; pub mod registers; +mod render; +pub mod startup; +mod terminal; pub mod text_edit; +mod update; + +use std::time::Instant; + +use ratatui::layout::Rect; + +pub use files::{Loaded, OpenError, create, load}; +pub use terminal::run; + +use crate::command::history::History as CommandHistory; +use crate::config::paths::Paths; +use crate::config::settings::Settings; +use crate::input::bindings::Context; +use crate::input::engine::KeyEngine; +use crate::input::keymap::Keymap; +use crate::input::mouse::MouseReader; +use crate::storage::memory::Memory; +use crate::ui::theme::Theme; +use crate::view::canvas::{Camera, CanvasView}; +use crate::view::note::NoteView; +use crate::view::outline::OutlineView; +use crate::view::{View, ViewContext, ViewKind, ViewSettings}; +use action::Message; +use buffer::Buffer; +use documents::{BufferId, Documents}; +use mode::Mode; +use panes::{PaneId, Panes}; +use registers::Registers; +use startup::Opening; + +/// cumulus, running: the open documents, the panes showing them, the mode, +/// and everything the keys and the mouse go through. +pub struct App { + panes: Panes<Pane>, + shared: Shared, + paths: Paths, + settings: Settings, + keymap: Keymap, + keys: KeyEngine, + /// Yanked and deleted text, kept from one edit to the next. + registers: Registers, + mouse: MouseReader, + command_history: CommandHistory, + /// What cumulus remembers between runs: read at the start, written at + /// the end ([`App::remember`]). + memory: Memory, + /// The status bar's message, until the next key. + message: Option<Message>, + screen: Screen, + /// The pane a mouse drag started in: it gets the whole drag. + dragging: Option<PaneId>, + /// When the files were last checked for changes made outside cumulus. + watched: Instant, + quit: bool, +} + +/// What the panes share, and the views look at: the documents and the +/// mode. (Apart from the panes, so a pane's view can answer a key while +/// looking at these.) +struct Shared { + docs: Documents, + mode: Mode, + theme: Theme, + view: ViewSettings, +} + +impl Shared { + /// What a view showing document `buffer` looks at. + fn ctx(&self, buffer: BufferId) -> ViewContext<'_> { + let buffer = &self.docs.get(buffer).buffer; + ViewContext { + doc: buffer.doc(), + cursor: buffer.cursor(), + mode: &self.mode, + theme: &self.theme, + settings: &self.view, + } + } + + /// The open documents' names, in the bar's order. + fn names(&self) -> Vec<String> { + self.docs.iter().map(documents::Open::name).collect() + } +} + +/// A pane: one of the documents, through a view. +struct Pane { + buffer: BufferId, + view: Box<dyn View>, +} + +impl Pane { + /// A pane showing `buffer` in a new view; a canvas starts at `camera`. + fn new(buffer: BufferId, kind: ViewKind, camera: Option<Camera>) -> Pane { + let mut 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()), + }; + if let Some(camera) = camera { + view.set_camera(camera); + } + Pane { buffer, view } + } +} + +/// Where things were drawn last, for the mouse. +#[derive(Debug, Clone, Copy, Default)] +struct Screen { + bar: Rect, + panes: Rect, +} + +impl App { + /// cumulus with the documents of `opening` open, the first one shown. + pub fn new(paths: Paths, settings: Settings, keymap: Keymap, opening: Opening) -> App { + let memory = Memory::load(&paths.memory()); + let Opening { first, others, message } = opening; + let theme = Theme { glyphs: settings.glyphs, ..Theme::default() }; + let view = ViewSettings { max_node_width: settings.max_node_width, ..ViewSettings::default() }; + let (docs, first_said) = files::open_first(first, &paths); + let first = docs.at(0); + let mut app = App { + panes: Panes::new(Pane::new(first, ViewKind::Canvas, None)), + shared: Shared { docs, mode: Mode::Normal, theme, view }, + command_history: CommandHistory::from_lines(memory.history.clone()), + paths, + settings, + keymap, + keys: KeyEngine::default(), + registers: Registers::default(), + mouse: MouseReader::default(), + memory, + message: None, + screen: Screen::default(), + dragging: None, + watched: Instant::now(), + quit: false, + }; + app.came_back_to(first); + if let Some(camera) = app.shared.docs.get(first).camera { + app.panes.current_mut().view.set_camera(camera); + } + let mut said: Vec<Message> = message.into_iter().chain(first_said).collect(); + for loaded in others { + said.extend(app.add_document(loaded).1); + } + app.message = Message::joined(said); + app + } + + /// Whether cumulus is done: `:q` on the last pane, or `:qa`. + pub fn is_done(&self) -> bool { + self.quit + } + + /// The document the focused pane shows. + fn buffer(&self) -> BufferId { + self.panes.current().buffer + } + + /// The buffer the focused pane shows. + fn current(&self) -> &Buffer { + &self.shared.docs.get(self.buffer()).buffer + } + + /// The key tables in use now: the popup's, or the focused view's. + fn contexts(&self) -> &'static [Context] { + match &self.shared.mode { + Mode::Popup(popup) => popup.contexts(), + mode => self.panes.current().view.contexts(mode), + } + } +} + +/// The app driven by typed keys and mouse gestures on a test terminal, with +/// its documents in a temporary folder: what the stages say works, checked +/// end to end. +#[cfg(test)] +mod tests; diff --git a/src/app/popup.rs b/src/app/popup.rs index 99d127d..f9d4b86 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -9,14 +9,17 @@ //! the node it opened on. The card shows the cursor's body, and edits it in //! place. +use std::path::PathBuf; + use ratatui::Frame; use ratatui::layout::{Position, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::Span; use unicode_width::UnicodeWidthStr; -use super::action::{Action, AppAction, DocumentChoice, Message}; +use super::action::{Action, AppAction, DocumentChoice, Keep, Message}; use super::commands; +use super::documents::BufferId; use super::registers::Registers; use super::text_edit::{EditAt, StartEdit, TextEdit}; use crate::command::parse::border; @@ -37,6 +40,8 @@ use crate::view::ViewContext; const HELP_WIDTH: u16 = 76; /// How wide a picker is, at most. const PICKER_WIDTH: u16 = 44; +/// How wide the question about a file changed outside cumulus is, at most. +const CONFLICT_WIDTH: u16 = 64; /// The widest the keys column of the help gets. const KEYS_WIDTH: usize = 12; @@ -47,6 +52,9 @@ pub enum Popup { Help(Runs), /// The right-click menu. Menu(Runs), + /// A question, with its answers: which version to keep when a file + /// changed outside cumulus. + Ask(Runs), Pick(Picker), Card(Card), } @@ -69,6 +77,14 @@ pub enum PickKind { Document, } +/// A row of the documents picker: an open document, or one opened lately. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocumentRow { + pub name: String, + pub path: PathBuf, + pub open: bool, +} + /// A list whose rows run something: the help and the menu. #[derive(Debug, Clone)] pub struct Runs { @@ -92,6 +108,8 @@ enum Run { Ask(String), /// A new loose idea, here on the canvas. NewIdeaAt(CanvasPos), + /// An action of the app's: an answer to a question. + App(AppAction), } /// A picker: the node it sets something on, and each row's value. @@ -110,7 +128,12 @@ enum Pick { Border(Option<Border>), Style(Option<StyleName>), Tag(Tag), + /// An open document, by its place in the bar. Document(usize), + /// A document opened lately, by its file. + Recent(PathBuf), + /// A heading over the rows after it. + Heading, } /// The `K` card: the node it shows, where it is, and its body while it's @@ -260,12 +283,12 @@ impl Popup { } /// A picker for the cursor's node (or, for documents, for the pane). - /// `documents` are the open documents' names, and `current` the one the - /// pane shows. + /// `documents` are the open documents, then the ones opened lately, and + /// `current` is the one the pane shows, by its place among them. pub fn picker( kind: PickKind, ctx: &ViewContext<'_>, - documents: &[String], + documents: &[DocumentRow], current: usize, ) -> Result<Popup, Message> { let (doc, theme, glyphs) = (ctx.doc, ctx.theme, ctx.theme.glyphs); @@ -323,7 +346,7 @@ impl Popup { PickKind::Style => { if theme.node_styles.is_empty() { return Err(Message::info( - "this theme has no named styles yet: theme files come in stage 8", + "this theme has no named styles yet: theme files come in part 8b of the build", )); } let mut values = vec![Pick::Style(None)]; @@ -360,12 +383,24 @@ impl Popup { (format!("tags · {}", node.title()), all.into_iter().map(Pick::Tag).collect(), rows) } PickKind::Document => { - let rows = documents - .iter() - .enumerate() - .map(|(index, name)| row(vec![Span::raw(name.clone())], name, index == current)) - .collect(); - ("documents".to_string(), (0..documents.len()).map(Pick::Document).collect(), rows) + let (mut values, mut rows) = (Vec::new(), Vec::new()); + for (index, document) in documents.iter().enumerate() { + if !document.open && !values.iter().any(|value| matches!(value, Pick::Heading)) { + values.push(Pick::Heading); + rows.push(ListRow::heading("opened lately", muted)); + } + let mut spans = vec![Span::raw(document.name.clone())]; + let value = if document.open { + Pick::Document(index) + } else { + let folder = document.path.parent().map(|folder| folder.display().to_string()); + spans.push(Span::styled(format!(" {}", folder.unwrap_or_default()), muted)); + Pick::Recent(document.path.clone()) + }; + rows.push(row(spans, &document.name, document.open && index == current)); + values.push(value); + } + ("documents".to_string(), values, rows) } }; let footer = match kind { @@ -386,11 +421,28 @@ impl Popup { Popup::Card(Card { node, edit: None, view: CardView::default() }) } + /// Asks which version of a document to keep: its file changed outside + /// cumulus while it had unsaved changes here. + pub fn conflict(buffer: BufferId, file: &str) -> Popup { + let answers = [ + ("keep mine: save it over the file", Keep::Mine), + ("take the file's (u brings mine back)", Keep::Theirs), + ]; + let rows = answers.iter().map(|(text, _)| ListRow::new(vec![Span::raw(*text)], *text)).collect(); + let runs = + answers.iter().map(|(_, keep)| Run::App(AppAction::Resolve { buffer, keep: *keep })).collect(); + let title = format!("{file} changed outside cumulus"); + let footer = "Enter: pick · Esc: later (:w keeps mine, :e! the file's)"; + let list = ListPopup::new(title, footer, rows, Place::Center { width: CONFLICT_WIDTH }); + Popup::Ask(Runs { list, runs }) + } + /// The mode's name for the status bar. pub fn label(&self) -> &'static str { match self { Popup::Help(_) => "HELP", Popup::Menu(_) => "MENU", + Popup::Ask(_) => "ASK", Popup::Pick(_) => "PICK", Popup::Card(Card { edit: Some(edit), .. }) if edit.editor.is_typing() => "INSERT", Popup::Card(Card { edit: Some(_), .. }) => "TEXT", @@ -401,7 +453,7 @@ impl Popup { /// The key tables while it's open. pub fn contexts(&self) -> &'static [Context] { match self { - Popup::Help(_) | Popup::Menu(_) | Popup::Pick(_) => &[Context::List], + Popup::Help(_) | Popup::Menu(_) | Popup::Ask(_) | Popup::Pick(_) => &[Context::List], Popup::Card(Card { edit: Some(edit), .. }) if edit.editor.is_typing() => &[Context::Insert], Popup::Card(Card { edit: Some(_), .. }) => &[Context::Text], Popup::Card(_) => &[Context::Card], @@ -412,7 +464,7 @@ impl Popup { /// while a search or a body is typed. pub fn takes_typing(&self) -> bool { match self { - Popup::Help(runs) | Popup::Menu(runs) => runs.list.is_searching(), + Popup::Help(runs) | Popup::Menu(runs) | Popup::Ask(runs) => runs.list.is_searching(), Popup::Pick(picker) => picker.list.is_searching(), Popup::Card(card) => card.edit.as_ref().is_some_and(|edit| edit.editor.is_typing()), } @@ -436,7 +488,7 @@ impl Popup { /// A key typed straight in (see [`Popup::takes_typing`]). pub fn typing(&mut self, key: Key, keymap: &Keymap, ctx: &ViewContext<'_>) -> Vec<Action> { let outcome = match self { - Popup::Help(runs) | Popup::Menu(runs) => runs.list.search_key(key), + Popup::Help(runs) | Popup::Menu(runs) | Popup::Ask(runs) => runs.list.search_key(key), Popup::Pick(picker) => picker.list.search_key(key), Popup::Card(card) => { let bound = keymap.tables(&[Context::Insert]).bound(&[key]); @@ -454,7 +506,7 @@ impl Popup { ctx: &ViewContext<'_>, ) -> Vec<Action> { let outcome = match self { - Popup::Help(runs) | Popup::Menu(runs) => runs.list.command(typed), + Popup::Help(runs) | Popup::Menu(runs) | Popup::Ask(runs) => runs.list.command(typed), Popup::Pick(picker) => picker.list.command(typed), Popup::Card(card) => return card.command(typed, registers, ctx), }; @@ -464,7 +516,7 @@ impl Popup { /// A mouse gesture, anywhere on the screen. pub fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext<'_>) -> Vec<Action> { let list = match self { - Popup::Help(runs) | Popup::Menu(runs) => &mut runs.list, + Popup::Help(runs) | Popup::Menu(runs) | Popup::Ask(runs) => &mut runs.list, Popup::Pick(picker) => &mut picker.list, Popup::Card(card) => return card.mouse(gesture, ctx), }; @@ -487,7 +539,7 @@ impl Popup { /// node is on screen, for the card. pub fn layout(&mut self, screen: Rect, anchor: Option<Rect>, ctx: &ViewContext<'_>) { match self { - Popup::Help(runs) | Popup::Menu(runs) => runs.list.layout(screen), + Popup::Help(runs) | Popup::Menu(runs) | Popup::Ask(runs) => runs.list.layout(screen), Popup::Pick(picker) => picker.list.layout(screen), Popup::Card(card) => { if let Some(text) = card_text(card.node, card.edit.as_ref(), ctx) { @@ -499,7 +551,7 @@ impl Popup { pub fn render(&self, frame: &mut Frame, ctx: &ViewContext<'_>) { match self { - Popup::Help(runs) | Popup::Menu(runs) => runs.list.render(frame, ctx.theme), + Popup::Help(runs) | Popup::Menu(runs) | Popup::Ask(runs) => runs.list.render(frame, ctx.theme), Popup::Pick(picker) => picker.list.render(frame, ctx.theme), Popup::Card(card) => { if let Some(text) = card_text(card.node, card.edit.as_ref(), ctx) { @@ -514,7 +566,9 @@ impl Popup { match (outcome, self) { (ListOutcome::Stay, _) => Vec::new(), (ListOutcome::Close, _) => leave(), - (ListOutcome::Picked(index), Popup::Help(runs) | Popup::Menu(runs)) => runs.run(index, ctx), + (ListOutcome::Picked(index), Popup::Help(runs) | Popup::Menu(runs) | Popup::Ask(runs)) => { + runs.run(index, ctx) + } (ListOutcome::Picked(index), Popup::Pick(picker)) => picker.pick(index, ctx), (ListOutcome::NoMatch(text), Popup::Pick(picker)) => picker.add_tag(&text, ctx), (ListOutcome::Picked(_) | ListOutcome::NoMatch(_), _) => Vec::new(), @@ -611,6 +665,7 @@ fn picked(value: &Pick, node: &crate::model::Node, tags: &Tags, current: usize) Pick::Style(style) => node.look.style == *style, Pick::Tag(tag) => tags.contains(tag), Pick::Document(index) => *index == current, + Pick::Recent(_) | Pick::Heading => false, } } @@ -634,6 +689,7 @@ impl Runs { Run::Line(line) => vec![Action::App(AppAction::RunLine(line.clone()))], Run::Ask(text) => vec![Action::App(AppAction::CommandLineWith(text.clone()))], Run::NewIdeaAt(spot) => commands::add_loose(ctx, *spot), + Run::App(action) => vec![Action::App(action.clone())], }; let mut actions = leave(); actions.extend(then); @@ -656,6 +712,13 @@ impl Picker { actions.push(Action::App(AppAction::SwitchDocument(DocumentChoice::At(*at)))); return actions; } + Some(Pick::Recent(path)) => { + let mut actions = leave(); + let path = Some(path.display().to_string()); + actions.push(Action::App(AppAction::Edit { path, force: false })); + return actions; + } + Some(Pick::Heading) => return Vec::new(), Some(Pick::Tag(tag)) => { let tag = tag.clone(); let on = !self.tags.contains(&tag); diff --git a/src/app/popup/tests.rs b/src/app/popup/tests.rs index 6aad783..db20570 100644 --- a/src/app/popup/tests.rs +++ b/src/app/popup/tests.rs @@ -105,7 +105,10 @@ fn pickers_set_what_is_picked() { ); 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 stage 8"); + 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); @@ -122,14 +125,24 @@ fn pickers_set_what_is_picked() { [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. - let documents = ["Garden plans".to_string(), "Kitchen".to_string()]; + // 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(); - picker.command(&typed(Command::Move(crate::input::command::Motion::Down)), &mut registers, &ctx); + 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] diff --git a/src/app/registers.rs b/src/app/registers.rs index 4b5a0b0..b9b0fb0 100644 --- a/src/app/registers.rs +++ b/src/app/registers.rs @@ -4,7 +4,7 @@ //! //! Everything yanked or deleted goes in the unnamed register, which `p` //! uses when no register is named, and also in the one named with `"a`–`"z`. -//! `"_` keeps nothing. `"+` is kept inside cumulus for now: stage 8 +//! `"_` keeps nothing. `"+` is kept inside cumulus for now: part 8c //! connects it to the system clipboard. use std::collections::BTreeMap; diff --git a/src/app/render.rs b/src/app/render.rs new file mode 100644 index 0000000..d5e622c --- /dev/null +++ b/src/app/render.rs @@ -0,0 +1,199 @@ +//! Drawing the screen from the state: the document bar at the top, the +//! panes in their boxes, a popup over them, the status bar and the command +//! line at the bottom, and which-key. + +use std::time::Duration; + +use ratatui::Frame; +use ratatui::crossterm::cursor::SetCursorStyle; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Style, Stylize}; +use ratatui::text::Span; +use ratatui::widgets::Block; + +use super::action::{Message, Tone}; +use super::mode::{Grab, Mode}; +use super::popup::{Card, Popup}; +use super::text_edit::{Field, TextEdit}; +use super::{App, Screen, Shared}; +use crate::ui::document_bar::DocumentBar; +use crate::ui::status_bar::{StatusBar, breadcrumb}; +use crate::ui::which_key::WhichKey; +use crate::view::{ViewContext, ViewKind}; + +impl App { + /// Draws the whole screen. + pub fn draw(&mut self, frame: &mut Frame) { + let status_rows = if self.settings.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.bar_names(); + let current = self.shared.docs.index_of(self.buffer()); + frame.render_widget(DocumentBar { names: &names, current, theme: &self.shared.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 Shared { docs, mode, theme, view } = &self.shared; + for (id, area) in self.panes.areas(panes_area) { + let Some(pane) = self.panes.get_mut(id) else { continue }; + let buffer = &docs.get(pane.buffer).buffer; + let mode = if id == focused { mode } else { &normal }; + let ctx = ViewContext { doc: buffer.doc(), cursor: buffer.cursor(), mode, theme, settings: view }; + 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_set(theme.glyphs.frame(false)) + .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 buffer = self.buffer(); + let anchor = self.panes.current().view.cursor_area(&self.shared.ctx(buffer)); + let Shared { docs, mode, theme, view } = &mut self.shared; + if let Mode::Popup(popup) = mode { + let buffer = &docs.get(buffer).buffer; + let ctx = ViewContext { + doc: buffer.doc(), + cursor: buffer.cursor(), + mode: &normal, + theme, + settings: view, + }; + popup.layout(panes_area, anchor, &ctx); + popup.render(frame, &ctx); + } + + self.draw_status(frame, status); + if let Mode::Command(line) = &self.shared.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.shared.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.shared.theme }, panes_area); + } + } + + /// The open documents' names for the bar: `[+]` after one with unsaved + /// changes, `[RO]` after one that's read-only here. + pub(super) fn bar_names(&self) -> Vec<String> { + self.shared + .docs + .iter() + .map(|open| match (open.is_read_only(), open.is_dirty()) { + (true, _) => format!("{} [RO]", open.name()), + (false, true) => format!("{} [+]", open.name()), + (false, false) => open.name(), + }) + .collect() + } + + /// 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.shared.mode, &self.shared.theme); + let typing = mode.any_edit().is_some_and(|edit| edit.editor.is_typing()); + let pane = self.panes.current(); + let buffer = self.current(); + let focus = if self.shared.view.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.settings.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 { + self.settings.which_key_delay + }; + self.settings.which_key && self.keys.pending_since().is_some_and(|since| since.elapsed() >= delay) + } + + /// Whether a key sequence is half typed: which-key may soon be due. + pub(super) fn is_waiting(&self) -> bool { + self.keys.is_pending() + } + + /// The cursor's shape now, 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. + pub(super) fn cursor_shape(&self) -> SetCursorStyle { + let mode = &self.shared.mode; + 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, + } + } +} + +/// 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 { + 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" + } + } +} diff --git a/src/app/startup.rs b/src/app/startup.rs new file mode 100644 index 0000000..1cc999e --- /dev/null +++ b/src/app/startup.rs @@ -0,0 +1,155 @@ +//! What `cumulus` opens, from its command line (DESIGN.md, "Command line"): +//! +//! - `cumulus`: `./ideas.cum`, or `~/ideas.cum` when this folder has none. +//! When neither exists, a new `~/ideas.cum`, written once something is +//! added: one ideas document for everywhere. +//! - `cumulus garden.cum`: that document (a new one if there's no such +//! file yet); `cumulus some/folder`: that folder's `ideas.cum`. Several +//! can be named; the first one shows. +//! - `cumulus new`: makes `./ideas.cum`; `cumulus new "Garden plans"` makes +//! `./garden-plans.cum`. If the file is there already, it's opened, and +//! cumulus says so. +//! +//! (`ideas` is the `default_document` setting.) + +use std::path::{Path, PathBuf}; + +use super::action::Message; +use super::files::{Loaded, OpenError, create, load}; +use crate::config::paths::Paths; +use crate::config::settings::Settings; +use crate::model::Title; +use crate::storage::names::{self, file_name}; + +/// What the command line asks to open. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Startup { + /// `cumulus`. + Default, + /// `cumulus garden.cum some/folder`. + Open(Vec<PathBuf>), + /// `cumulus new ["Garden plans"]`. + New(Option<String>), +} + +/// The documents to open: the first one shows. And something to say about +/// them, in the status bar. +#[derive(Debug)] +pub struct Opening { + pub first: Loaded, + pub others: Vec<Loaded>, + pub message: Option<Message>, +} + +impl Opening { + fn one(first: Loaded, message: Option<Message>) -> Opening { + Opening { first, others: Vec::new(), message } + } +} + +/// Finds (or makes) the documents the command line asks for. `here` is the +/// folder cumulus started in. +pub fn open( + startup: &Startup, + paths: &Paths, + here: &Path, + settings: &Settings, +) -> Result<Opening, OpenError> { + let default_name = format!("{}.{}", settings.default_document, names::EXTENSION); + match startup { + Startup::Default => { + let local = here.join(&default_name); + let path = if local.exists() { local } else { paths.home.join(&default_name) }; + Ok(Opening::one(load(&path)?, None)) + } + Startup::Open(files) => { + let mut loaded = files.iter().map(|path| { + let path = here.join(paths.expand(&path.to_string_lossy())); + load(&if path.is_dir() { path.join(&default_name) } else { path }) + }); + let Some(first) = loaded.next().transpose()? else { + return open(&Startup::Default, paths, here, settings); + }; + Ok(Opening { first, others: loaded.collect::<Result<_, _>>()?, message: None }) + } + Startup::New(title) => { + let title = title.as_deref().and_then(|title| Title::parse(title).ok()); + let name = title.as_ref().map_or(default_name, |title| file_name(title.as_str())); + let path = here.join(&name); + if path.exists() { + let said = Message::info(format!("{name} is here already: here it is")); + return Ok(Opening::one(load(&path)?, Some(said))); + } + let title = title.unwrap_or_else(|| names::title_from_file(&path)); + Ok(Opening::one(create(&path, title)?, Some(Message::done(format!("made {name}"))))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::Document; + use crate::storage::document_file; + use crate::storage::test_support::TempDir; + + /// A folder to start in, and cumulus's own folders (the home folder + /// among them) beside it. + fn setup() -> (TempDir, Paths, PathBuf) { + let folder = TempDir::new(); + let paths = Paths::inside(folder.path()); + let here = folder.path().join("project"); + std::fs::create_dir_all(&here).unwrap(); + (folder, paths, here) + } + + fn write_document(path: &Path, title: &str) { + let doc = Document::new(Title::parse(title).unwrap()); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, document_file::write(&doc).unwrap()).unwrap(); + } + + #[test] + fn the_ideas_document_here_or_in_the_home_folder() { + let (_folder, paths, here) = setup(); + let settings = Settings::default(); + let opening = open(&Startup::Default, &paths, &here, &settings).unwrap(); + assert_eq!(opening.first.path, paths.home.join("ideas.cum"), "neither: a new one at home"); + assert!(opening.first.stamp.is_none(), "written once something's added"); + assert_eq!(opening.first.doc.title().as_str(), "Ideas"); + + write_document(&paths.home.join("ideas.cum"), "Everything"); + let opening = open(&Startup::Default, &paths, &here, &settings).unwrap(); + assert_eq!(opening.first.doc.title().as_str(), "Everything"); + + write_document(&here.join("ideas.cum"), "This project"); + let opening = open(&Startup::Default, &paths, &here, &settings).unwrap(); + assert_eq!(opening.first.doc.title().as_str(), "This project", "the one here comes first"); + } + + #[test] + fn files_folders_and_new_documents() { + let (_folder, paths, here) = setup(); + let settings = Settings::default(); + write_document(&here.join("garden").join("ideas.cum"), "Garden"); + let named = vec![PathBuf::from("garden"), PathBuf::from("missing.cum")]; + let opening = open(&Startup::Open(named), &paths, &here, &settings).unwrap(); + assert_eq!(opening.first.doc.title().as_str(), "Garden", "a folder: its ideas.cum"); + assert_eq!(opening.others[0].doc.title().as_str(), "Missing", "a new one, named after the file"); + + let opening = open(&Startup::New(Some("Garden plans".into())), &paths, &here, &settings).unwrap(); + assert!(here.join("garden-plans.cum").exists(), "made right away"); + assert_eq!(opening.message.map(|message| message.text), Some("made garden-plans.cum".into())); + let opening = open(&Startup::New(Some("Garden plans".into())), &paths, &here, &settings).unwrap(); + assert_eq!( + opening.message.map(|message| message.text), + Some("garden-plans.cum is here already: here it is".into()) + ); + let opening = open(&Startup::New(None), &paths, &here, &settings).unwrap(); + assert_eq!(opening.first.path, here.join("ideas.cum")); + + std::fs::write(here.join("broken.cum"), "(format: 1").unwrap(); + let error = open(&Startup::Open(vec!["broken.cum".into()]), &paths, &here, &settings).unwrap_err(); + assert!(error.to_string().contains("broken.cum: line 1"), "{error}"); + } +} diff --git a/src/app/terminal.rs b/src/app/terminal.rs new file mode 100644 index 0000000..c81a49d --- /dev/null +++ b/src/app/terminal.rs @@ -0,0 +1,84 @@ +//! The main loop, on a real terminal: set it up (raw mode, the alternate +//! screen, the mouse), read keys and the mouse, let time pass (autosave, +//! files changed outside cumulus), draw, and put the terminal back as it was +//! at the end, after a crash too. + +use std::io; +use std::time::{Duration, Instant}; + +use ratatui::DefaultTerminal; +use ratatui::crossterm::cursor::SetCursorStyle; +use ratatui::crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind}; +use ratatui::crossterm::execute; + +use super::App; +use crate::input::key::Key; + +/// How often time passes when nothing happens: autosave and the look at +/// the files need no more than this. +const TICK: Duration = Duration::from_millis(250); +/// How often to wake up while a key sequence is half typed: which-key may +/// be due. +const WAITING: Duration = Duration::from_millis(50); + +/// Runs cumulus until it quits, then writes what it remembers between runs. +pub fn run(mut app: App) -> io::Result<()> { + let mut terminal = ratatui::try_init()?; + // On a crash, give the mouse back and the cursor 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 = event_loop(&mut app, &mut terminal); + let _ = execute!(io::stdout(), DisableMouseCapture, SetCursorStyle::DefaultUserShape); + ratatui::restore(); + let remembered = app.remember(); + result.and(remembered) +} + +fn event_loop(app: &mut App, terminal: &mut DefaultTerminal) -> io::Result<()> { + let mut shape = SetCursorStyle::DefaultUserShape; + let mut mouse = false; + let mut draw = true; + while !app.is_done() { + if draw { + terminal.draw(|frame| app.draw(frame))?; + } + // The cursor takes the mode's shape, and the mouse follows the + // `mouse` setting, when they change. + if app.cursor_shape() != shape { + shape = app.cursor_shape(); + execute!(io::stdout(), shape)?; + } + if app.settings.mouse != mouse { + mouse = app.settings.mouse; + if mouse { + execute!(io::stdout(), EnableMouseCapture)?; + } else { + execute!(io::stdout(), DisableMouseCapture)?; + } + } + draw = false; + if event::poll(if app.is_waiting() { WAITING } else { TICK })? { + draw = true; + 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) { + app.key(key); + } + } + Event::Mouse(event) => { + if let Some(gesture) = app.mouse.read(event, Instant::now()) { + app.gesture(gesture); + } + } + _ => {} + } + } + draw |= app.tick(Instant::now()) || app.is_waiting(); + } + Ok(()) +} diff --git a/examples/canvas/tests.rs b/src/app/tests.rs similarity index 79% rename from examples/canvas/tests.rs rename to src/app/tests.rs index 379668a..47a46e9 100644 --- a/examples/canvas/tests.rs +++ b/src/app/tests.rs @@ -1,43 +1,92 @@ -//! The playground driven by typed keys and mouse gestures on a test -//! terminal: what the stages say works, checked end to end. +//! The app driven by typed keys and mouse gestures on a test terminal, with +//! the sample documents copied to a temporary folder: what each stage says +//! works, checked end to end. The files' own tests are in `tests/files.rs`. + +mod files; + +use std::path::PathBuf; +use std::time::{Duration, Instant}; -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::cursor::SetCursorStyle; use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind}; +use super::action::Tone; +use super::startup::Opening; +use super::*; +use crate::input::key::parse_keys; +use crate::input::keymap::DEFAULT_LEADER; +use crate::input::mouse::{Cell, Gesture}; +use crate::model::{Border, CanvasPos, Content, Document, NodeId}; +use crate::storage::test_support::TempDir; +use crate::ui::command_line::PopupMode; + +const GARDEN: &str = include_str!("../../examples/garden.cum"); +const KITCHEN: &str = include_str!("../../examples/kitchen.cum"); + struct Harness { - playground: Playground, + app: App, terminal: Terminal<TestBackend>, + /// Where the documents are, with cumulus's own folders in `cumulus`. + folder: TempDir, + /// The time the app was last told, for autosave and the look at the + /// files: it only moves forward, past the real time. + clock: Instant, } impl Harness { + /// The app on copies of the garden and the kitchen, the garden shown. fn new() -> Harness { + let folder = TempDir::new(); + for (name, text) in [("garden.cum", GARDEN), ("kitchen.cum", KITCHEN)] { + std::fs::write(folder.path().join(name), text).unwrap(); + } + Harness::open(folder, &["garden.cum", "kitchen.cum"]) + } + + /// The app on these files of `folder`, the first one shown. + fn open(folder: TempDir, names: &[&str]) -> Harness { + let mut loaded = names.iter().map(|name| load(&folder.path().join(name)).unwrap()); + let first = loaded.next().unwrap(); + let opening = Opening { first, others: loaded.collect(), message: None }; + let app = + App::new(Harness::paths(&folder), Settings::default(), Keymap::new(DEFAULT_LEADER), opening); let terminal = Terminal::new(TestBackend::new(100, 32)).unwrap(); - let mut harness = Harness { playground: Playground::new(documents().unwrap()), terminal }; + let mut harness = Harness { app, terminal, folder, clock: Instant::now() }; harness.draw(); harness } + /// cumulus's own folders, inside the test's folder. + fn paths(folder: &TempDir) -> Paths { + Paths::inside(&folder.path().join("cumulus")) + } + fn draw(&mut self) { - let Harness { playground, terminal } = self; - terminal.draw(|frame| playground.draw(frame)).unwrap(); + let Harness { app, terminal, .. } = self; + terminal.draw(|frame| app.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.app.key(key); self.draw(); } } + /// Lets time pass, as the event loop does: autosave, and a look at the + /// files. + fn wait(&mut self, seconds: u64) { + self.clock = self.clock.max(Instant::now()) + Duration::from_secs(seconds); + self.app.tick(self.clock); + self.draw(); + } + fn mouse(&mut self, gesture: Gesture) { - self.playground.gesture(gesture); + self.app.gesture(gesture); self.draw(); } @@ -64,21 +113,20 @@ impl Harness { /// 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); + if let Some(gesture) = self.app.mouse.read(event, Instant::now()) { + self.app.gesture(gesture); } self.draw(); } /// The focused pane's document. fn doc(&self) -> &Document { - self.playground.state.buffers[self.playground.buffer()].doc() + self.app.current().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()) + self.title(self.app.current().cursor()) } fn title(&self, id: NodeId) -> String { @@ -86,21 +134,21 @@ impl Harness { } fn label(&self) -> &'static str { - self.playground.state.mode.label() + self.app.shared.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()) + self.app.shared.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()) + self.app.shared.mode.any_edit().map(|edit| edit.editor.cursor()) } fn message(&self) -> &str { - self.playground.message.as_ref().map_or("", |message| &message.text) + self.app.message.as_ref().map_or("", |message| &message.text) } /// The node with this title. @@ -132,6 +180,16 @@ impl Harness { self.doc().node(self.node(title)).unwrap().tags.to_string() } + /// A file in the test's folder. + fn path(&self, name: &str) -> PathBuf { + self.folder.path().join(name) + } + + /// A file in the test's folder, as text (empty when there's none). + fn file(&self, name: &str) -> String { + std::fs::read_to_string(self.path(name)).unwrap_or_default() + } + /// The terminal's cursor, when it shows. fn text_cursor(&self) -> Option<(u16, u16)> { let backend = self.terminal.backend(); @@ -232,9 +290,9 @@ fn command_lines_run_through_the_same_actions() { 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); + assert_eq!(h.app.settings.completion_popup, PopupMode::OnTab); h.type_keys(":frobnicate<CR>"); - assert_eq!(h.playground.message.as_ref().map(|message| message.tone), Some(Tone::Problem)); + assert_eq!(h.app.message.as_ref().map(|message| message.tone), Some(Tone::Problem)); } #[test] @@ -250,8 +308,8 @@ fn adding_folding_zooming_and_quitting() { 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"); + h.type_keys("ZZ"); + assert!(h.app.is_done(), "ZZ closes the last pane: that quits"); } #[test] @@ -261,16 +319,16 @@ fn typing_a_title_in_its_box() { 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); + assert_eq!(h.app.cursor_shape(), 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); + assert_eq!(h.app.cursor_shape(), 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); + assert_eq!(h.app.cursor_shape(), SetCursorStyle::DefaultUserShape); h.type_keys("u"); assert_eq!(h.cursor(), "Soil", "the whole edit was one undo step"); } @@ -430,9 +488,9 @@ fn indenting_and_outdenting_nodes() { 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"); + assert_eq!(h.message(), "\"yank (copy)…\" comes in a later part of the build"); h.type_keys("v"); - assert_eq!(h.message(), "\"select\" comes in a later stage of the build"); + assert_eq!(h.message(), "\"select\" comes in a later part of the build"); // Kitchen is a portal: its title is the other document's name. h.type_keys("glgjgjgj"); assert_eq!(h.cursor(), "Kitchen"); @@ -440,7 +498,7 @@ fn keys_from_later_stages_say_so() { 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"); + assert_eq!(h.message(), "going through a portal comes in part 8d of the build"); } // ----- Views, panes, popups and documents (stage 7) ----------------------- @@ -449,23 +507,23 @@ fn keys_from_later_stages_say_so() { 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_eq!(h.app.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.app.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_eq!(h.app.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"); + assert_eq!(h.app.panes.count(), 1, "only this pane"); h.type_keys("<C-w>s"); - assert_eq!(h.playground.panes.count(), 2); + assert_eq!(h.app.panes.count(), 2); h.type_keys("<C-w>c"); - assert_eq!(h.playground.panes.count(), 1); + assert_eq!(h.app.panes.count(), 1); h.type_keys("<C-w>j"); assert_eq!(h.message(), "no pane that way"); } @@ -535,7 +593,7 @@ fn the_card_shows_and_edits_the_body() { 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); + assert_eq!(h.app.cursor_shape(), 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."); @@ -608,9 +666,9 @@ fn more_keys_across_views() { 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(); + let first = h.app.panes.focused(); h.type_keys("<C-w>w"); - assert_ne!(h.playground.panes.focused(), first); + assert_ne!(h.app.panes.focused(), first); h.type_keys("<C-w>=<C-w>o"); // gT: the document before, round to the last. h.type_keys("gT"); @@ -623,7 +681,7 @@ fn more_keys_across_views() { 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"); + assert_eq!(h.message(), "this theme has no named styles yet: theme files come in part 8b of the build"); // <Space>w: no key hints: which-key and the hint line go. assert!(h.rows().last().unwrap().contains("? help")); h.type_keys("<Space>w"); @@ -661,3 +719,36 @@ fn the_wheel_scrolls_sideways() { h.raw_mouse(MouseEventKind::ScrollLeft, (50, 15), KeyModifiers::NONE); assert_eq!(h.screen(), before); } + +#[test] +fn space_i_captures_an_idea_to_the_inbox() { + let mut h = Harness::new(); + h.type_keys("<Space>i"); + assert_eq!(h.label(), "COMMAND"); + h.type_keys("mulch<CR>"); + assert_eq!(h.message(), "captured \"mulch\" to the Inbox"); + assert_eq!(h.parent("mulch"), "Inbox"); +} + +#[test] +fn set_changes_settings_on_the_spot() { + let mut h = Harness::new(); + h.type_keys(":set autosave?<CR>"); + assert_eq!(h.message(), "autosave = on"); + // Box-drawing characters: U+2500 to U+257F. + let boxes = |h: &Harness| h.screen().chars().any(|c| ('\u{2500}'..='\u{257f}').contains(&c)); + assert!(boxes(&h), "box-drawing characters to start with"); + h.type_keys(":set glyphs=ascii<CR>"); + assert_eq!(h.message(), "glyphs = ascii"); + assert!(!boxes(&h), "{}", h.screen()); + // Titles wrap at the width set. + assert!(h.screen().contains("Rain barrel")); + h.type_keys(":set max_node_width=10<CR>"); + assert!(!h.screen().contains("Rain barrel") && h.screen().contains("barrel"), "{}", h.screen()); + h.type_keys(":set max_node_width&<CR>"); + assert_eq!(h.message(), "max_node_width = 30"); + h.type_keys(":set which_key_delay=2<CR>"); + assert_eq!(h.message(), "which_key_delay = 2ms"); + h.type_keys(":set glyphs=boxes<CR>"); + assert_eq!(h.message(), "glyphs is unicode or ascii"); +} diff --git a/src/app/tests/files.rs b/src/app/tests/files.rs new file mode 100644 index 0000000..5844117 --- /dev/null +++ b/src/app/tests/files.rs @@ -0,0 +1,255 @@ +//! The documents' files, end to end: autosave and `:w`, quitting, files +//! changed outside cumulus, read-only documents, where you left off, `:e`, +//! `:new` and `:bd`, backups. + +use super::*; +use crate::model::DocUid; +use crate::storage::lock::Lock; + +/// The garden's uid, in the sample file. +const GARDEN_UID: &str = "7f3c9a1e52b04d18"; + +/// The document bar's row. +fn bar(h: &Harness) -> String { + h.rows()[0].clone() +} + +#[test] +fn autosave_saves_a_second_after_a_change() { + let mut h = Harness::new(); + h.type_keys("glA!<Esc><Esc>"); + assert!(bar(&h).contains("Garden plans [+]"), "unsaved: {}", bar(&h)); + assert!(!h.file("garden.cum").contains("Soil!"), "not yet"); + h.wait(2); + assert!(h.file("garden.cum").contains("title: \"Soil!\"")); + assert!(!bar(&h).contains("[+]")); + // Undoing back to what's on disk leaves nothing to save. + h.type_keys("A?<Esc><Esc>"); + assert!(bar(&h).contains("[+]")); + h.type_keys("u"); + assert!(!bar(&h).contains("[+]"), "{}", bar(&h)); +} + +#[test] +fn the_days_first_save_makes_a_backup() { + let mut h = Harness::new(); + h.type_keys("glA!<Esc><Esc>"); + h.wait(2); + let backups = Harness::paths(&h.folder).backups().join(GARDEN_UID); + let copies: Vec<PathBuf> = + std::fs::read_dir(&backups).unwrap().map(|entry| entry.unwrap().path()).collect(); + assert_eq!(copies.len(), 1); + assert_eq!(std::fs::read_to_string(&copies[0]).unwrap(), GARDEN, "as it was before the day's changes"); +} + +#[test] +fn without_autosave_w_saves_and_q_asks_first() { + let mut h = Harness::new(); + h.type_keys(":set noautosave<CR>"); + assert_eq!(h.message(), "autosave = off"); + h.type_keys("glA!<Esc><Esc>"); + h.wait(5); + assert!(!h.file("garden.cum").contains("Soil!"), "no autosave"); + h.type_keys(":q<CR>"); + assert_eq!(h.message(), "garden.cum has unsaved changes: :w saves them, :q! drops them"); + assert!(!h.app.is_done()); + h.type_keys(":w<CR>"); + assert_eq!(h.message(), "saved garden.cum"); + assert!(h.file("garden.cum").contains("Soil!")); + // ZZ saves what changed, then closes the pane: the last one quits. + h.type_keys("A?<Esc><Esc>ZZ"); + assert!(h.file("garden.cum").contains("Soil!?")); + assert!(h.app.is_done()); +} + +#[test] +fn q_bang_quits_without_saving() { + let mut h = Harness::new(); + h.type_keys(":set noautosave<CR>glA!<Esc><Esc>:q!<CR>"); + assert!(h.app.is_done()); + assert_eq!(h.file("garden.cum"), GARDEN); +} + +#[test] +fn quitting_saves_first_with_autosave() { + let mut h = Harness::new(); + h.type_keys("glA!<Esc><Esc>:qa<CR>"); + assert!(h.app.is_done()); + assert!(h.file("garden.cum").contains("Soil!"), "saved without waiting"); +} + +#[test] +fn a_file_changed_outside_comes_back_in() { + let mut h = Harness::new(); + std::fs::write(h.path("garden.cum"), GARDEN.replace("\"Soil\"", "\"Rich soil\"")).unwrap(); + h.wait(2); + assert_eq!(h.message(), "garden.cum changed outside cumulus: here it is · u undoes that"); + assert!(h.screen().contains("Rich soil")); + h.type_keys("u"); + assert!(!h.screen().contains("Rich soil")); + h.wait(2); + assert!(h.file("garden.cum").contains("title: \"Soil\""), "and the undo is saved like any change"); +} + +#[test] +fn a_conflict_asks_which_to_keep() { + let mut h = Harness::new(); + h.type_keys("glA!<Esc><Esc>"); + std::fs::write(h.path("garden.cum"), GARDEN.replace("\"Watering\"", "\"Irrigation\"")).unwrap(); + h.wait(2); + assert_eq!(h.label(), "ASK"); + assert!(h.screen().contains("garden.cum changed outside cumulus"), "{}", h.screen()); + assert!(!h.file("garden.cum").contains("Soil!"), "autosave didn't write over it"); + // Take the file's: here it is, and u brings mine back. + h.type_keys("j<CR>"); + assert_eq!(h.label(), "NORMAL"); + assert_eq!(h.message(), "took the file's garden.cum · u brings yours back"); + assert!(h.screen().contains("Irrigation") && !h.screen().contains("Soil!")); + h.type_keys("u"); + assert!(h.screen().contains("Soil!") && h.screen().contains("Watering")); +} + +#[test] +fn keeping_mine_saves_it_over_the_file() { + let mut h = Harness::new(); + h.type_keys("glA!<Esc><Esc>"); + std::fs::write(h.path("garden.cum"), GARDEN.replace("\"Watering\"", "\"Irrigation\"")).unwrap(); + h.wait(2); + h.type_keys("<CR>"); + assert_eq!(h.message(), "kept yours: saved garden.cum"); + let file = h.file("garden.cum"); + assert!(file.contains("Soil!") && !file.contains("Irrigation")); +} + +#[test] +fn a_mistake_in_the_file_holds_autosave() { + let mut h = Harness::new(); + std::fs::write(h.path("garden.cum"), "(format: 1, oops").unwrap(); + h.wait(2); + assert!( + h.message().starts_with("garden.cum changed outside cumulus, with a mistake in it: line 1"), + "{}", + h.message() + ); + h.type_keys("glA!<Esc><Esc>"); + h.wait(2); + assert_eq!(h.file("garden.cum"), "(format: 1, oops", "not saved over"); + h.type_keys(":w<CR>"); + assert!(h.file("garden.cum").contains("Soil!"), ":w saves over it"); +} + +#[test] +fn a_deleted_file_comes_back_with_w() { + let mut h = Harness::new(); + std::fs::remove_file(h.path("garden.cum")).unwrap(); + h.wait(2); + assert_eq!(h.message(), "garden.cum was deleted or moved: :w writes it here again"); + h.type_keys("glA!<Esc><Esc>"); + h.wait(2); + assert!(!h.path("garden.cum").exists(), "autosave waits"); + h.type_keys(":w<CR>"); + assert!(h.file("garden.cum").contains("Soil!")); +} + +#[test] +fn e_bang_reads_the_file_again() { + let mut h = Harness::new(); + h.type_keys(":set noautosave<CR>glA!<Esc><Esc>:e<CR>"); + assert_eq!(h.message(), "garden.cum has unsaved changes: :e! drops them"); + h.type_keys(":e!<CR>"); + assert_eq!(h.message(), "read garden.cum again from the file · u undoes that"); + assert_eq!(h.cursor(), "Soil"); + h.type_keys("u"); + assert_eq!(h.cursor(), "Soil!", "u brings the change back"); +} + +#[test] +fn a_document_open_in_another_cumulus_is_read_only() { + let folder = TempDir::new(); + std::fs::write(folder.path().join("garden.cum"), GARDEN).unwrap(); + let uid = DocUid::parse(GARDEN_UID).unwrap(); + let other = Lock::take(&Harness::paths(&folder).locks(), uid).unwrap(); + assert!(other.is_some()); + let mut h = Harness::open(folder, &["garden.cum"]); + let read_only = "garden.cum is open in another cumulus: read-only here"; + assert_eq!(h.message(), read_only); + assert!(bar(&h).contains("Garden plans [RO]")); + h.type_keys("gli"); + assert_eq!((h.label(), h.message()), ("NORMAL", read_only)); + assert_eq!(h.cursor(), "Soil", "moving around works"); + h.type_keys("dd"); + assert_eq!((h.cursor().as_str(), h.message()), ("Soil", read_only)); + drop(other); + h.wait(2); + assert_eq!(h.message(), "garden.cum: you can edit it now"); + h.type_keys("A!<Esc><Esc>"); + assert_eq!(h.cursor(), "Soil!"); +} + +#[test] +fn documents_reopen_where_you_left_off() { + let mut h = Harness::new(); + h.type_keys("glgj-"); + assert_eq!(h.cursor(), "Raised beds"); + h.type_keys(":q<CR>"); + assert!(h.app.is_done()); + h.app.remember().unwrap(); + let Harness { app, folder, .. } = h; + drop(app); + let h = Harness::open(folder, &["garden.cum", "kitchen.cum"]); + assert_eq!(h.cursor(), "Raised beds"); + assert!(h.screen().contains("canvas · far"), "{}", h.screen()); +} + +#[test] +fn e_opens_or_starts_documents_and_bd_closes_them() { + let mut h = Harness::new(); + h.type_keys(":e notes<CR>"); + let notes = h.path("notes.cum").display().to_string(); + assert_eq!(h.message(), format!("a new document: it's saved as {notes} once you add something")); + assert!(h.screen().contains("canvas · Notes")); + h.wait(2); + assert!(!h.path("notes.cum").exists(), "nothing added yet"); + h.type_keys("aSeeds<Esc><Esc>"); + h.wait(2); + assert!(h.file("notes.cum").contains("Seeds")); + h.type_keys(":e kitchen.cum<CR>"); + assert!(h.screen().contains("canvas · Kitchen"), "the one already open"); + assert_eq!(h.app.shared.docs.count(), 3); + h.type_keys(":b notes<CR>:bd<CR>"); + assert_eq!(h.message(), "closed notes.cum"); + assert_eq!(h.app.shared.docs.count(), 2); + // The documents picker lists it as opened lately: picking it opens it. + h.type_keys("<Space>b"); + assert!(h.screen().contains("opened lately"), "{}", h.screen()); + h.type_keys("/notes<CR>"); + assert!(h.screen().contains("canvas · Notes"), "{}", h.screen()); + assert_eq!(h.cursor(), "Seeds", "where it was left"); +} + +#[test] +fn new_makes_a_document_next_to_this_one() { + let mut h = Harness::new(); + h.type_keys(":new Shopping list<CR>"); + assert_eq!(h.message(), "made shopping-list.cum"); + assert!(h.file("shopping-list.cum").contains("title: \"Shopping list\""), "written at once"); + assert!(h.screen().contains("canvas · Shopping list")); + h.type_keys("gT:new Shopping list<CR>"); + assert_eq!(h.message(), "shopping-list.cum is here already: here it is"); + assert!(h.screen().contains("canvas · Shopping list")); + h.type_keys(":bd<CR>:bd<CR>:bd<CR>"); + assert_eq!(h.message(), "it's the only open document: :q quits"); +} + +#[test] +fn a_change_outside_waits_while_a_title_is_edited() { + let mut h = Harness::new(); + h.type_keys("gli"); + std::fs::write(h.path("garden.cum"), GARDEN.replace("\"Soil\"", "\"Rich soil\"")).unwrap(); + h.wait(2); + assert_eq!((h.label(), h.draft()), ("INSERT", "Soil"), "the edit goes on"); + h.type_keys("<Esc><Esc>"); + h.wait(2); + assert_eq!(h.message(), "garden.cum changed outside cumulus: here it is · u undoes that"); + assert_eq!(h.cursor(), "Rich soil"); +} diff --git a/src/app/update.rs b/src/app/update.rs new file mode 100644 index 0000000..50d734c --- /dev/null +++ b/src/app/update.rs @@ -0,0 +1,276 @@ +//! Carrying actions out: document actions go to the focused document's +//! buffer, the rest change the mode, the panes, the settings or the files. + +use std::time::Instant; + +use ratatui::layout::Position; + +use super::action::{Action, AppAction, DocAction, DocumentChoice, Message, Tone}; +use super::documents::Trouble; +use super::mode::{Grab, Mode}; +use super::panes::Arrange; +use super::popup::{DocumentRow, MenuFor, Popup}; +use super::text_edit::{StartEdit, TextEdit}; +use super::{App, Pane}; +use crate::command::parse::SetArg; +use crate::ui::command_line::CommandLine; +use crate::view::ViewKind; + +/// The smallest a pane gets, borders included: splitting stops there. +const MIN_PANE: (u16, u16) = (16, 6); + +impl App { + /// Carries out actions in order. A problem stops the rest, since they + /// may count on it. + pub(super) fn update(&mut self, actions: Vec<Action>) { + for action in actions { + let message = match action { + Action::Doc(action) => self.doc_action(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; + } + } + } + } + + /// A document action, on the focused pane's document. A read-only + /// document takes cursor moves and marks, but no changes. + fn doc_action(&mut self, action: DocAction) -> Option<Message> { + if matches!(action, DocAction::Edit { .. } | DocAction::Undo(_) | DocAction::Redo(_)) + && let Some(refused) = self.refuse_read_only() + { + return Some(refused); + } + let open = self.shared.docs.get_mut(self.buffer()); + let before = open.buffer.history().state(); + let message = open.buffer.apply(action); + if open.buffer.history().state() != before { + open.file.changed = Some(Instant::now()); + // A save that failed is tried again after the next change. + if matches!(open.file.trouble, Some(Trouble::SaveFailed(_))) { + open.file.trouble = None; + } + } + message + } + + fn app(&mut self, action: AppAction) -> Option<Message> { + match action { + AppAction::StartEdit(start) => { + if let Some(refused) = self.refuse_read_only() { + return Some(refused); + } + let started = TextEdit::start(&self.shared.ctx(self.buffer()), start); + let in_card = matches!(start, StartEdit::Body { in_card: true, .. }); + match (started, &mut self.shared.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.shared.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.shared.mode.edit_mut() { + edit.editor.set_cursor(at); + } + } + AppAction::EnterMoveMode => { + if let Some(refused) = self.refuse_read_only() { + return Some(refused); + } + self.shared.mode = Mode::Move(Grab::new()); + } + AppAction::ToggleWhole => { + if let Mode::Move(grab) = &mut self.shared.mode { + grab.whole = !grab.whole; + } + } + AppAction::LeaveMode => self.shared.mode = Mode::Normal, + AppAction::OpenCommandLine => { + let line = + CommandLine::new(&self.sources(), &self.command_history, self.settings.completion_popup); + self.shared.mode = Mode::Command(line); + } + AppAction::CommandLineWith(text) => { + let (sources, popup) = (self.sources(), self.settings.completion_popup); + self.shared.mode = + Mode::Command(CommandLine::with_text(&text, &sources, &self.command_history, popup)); + } + AppAction::RunLine(text) => self.run_line(&text), + AppAction::Run(command) => self.run_command(command), + AppAction::Type(keys) => keys.into_iter().for_each(|key| self.key(key)), + AppAction::ToggleFocus => { + let focus = &mut self.shared.view.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.settings.which_key = !self.settings.which_key; + return Some(Message::info(if self.settings.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 theme = &self.shared.theme; + let help = Popup::help(&self.keymap, contexts, pane.view.kind().name(), &search, theme); + self.shared.mode = Mode::Popup(help); + } + AppAction::OpenCard => self.shared.mode = Mode::Popup(Popup::card(self.current().cursor())), + AppAction::OpenPicker(kind) => { + let documents = self.document_rows(); + let current = self.shared.docs.index_of(self.buffer()); + match Popup::picker(kind, &self.shared.ctx(self.buffer()), &documents, current) { + Ok(picker) => self.shared.mode = Mode::Popup(picker), + Err(message) => return Some(message), + } + } + AppAction::OpenMenu { what, col, row } => { + let doc = self.current().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.shared.theme); + self.shared.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 { force } => { + if self.panes.count() == 1 { + return self.quit(force); + } + self.keep_cameras(); + self.panes.close(); + } + AppAction::OnlyPane => { + self.keep_cameras(); + 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) => { + self.keep_cameras(); + let pane = self.panes.current_mut(); + if pane.view.kind() != kind { + let camera = self.shared.docs.get(pane.buffer).camera; + *pane = Pane::new(pane.buffer, kind, camera); + } + } + AppAction::SwitchDocument(choice) => return self.switch_document(choice), + AppAction::Save(which) => return self.write(which), + AppAction::Edit { path, force } => return self.edit(path, force), + AppAction::NewDocument(title) => return self.new_document(title), + AppAction::CloseDocument { force } => return self.close_document(force), + AppAction::Resolve { buffer, keep } => return self.resolve(buffer, keep), + AppAction::Quit { force } => return self.quit(force), + 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; + let camera = pane.view.camera().or(self.shared.docs.get(buffer).camera); + self.panes.split(arrange, Pane::new(buffer, kind, camera)); + None + } + + /// The focused pane shows another open document. + fn switch_document(&mut self, choice: DocumentChoice) -> Option<Message> { + let docs = &self.shared.docs; + let (count, here) = (docs.count(), docs.index_of(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) => { + match self.shared.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}\""))), + } + } + }; + self.show(self.shared.docs.at(to)); + None + } + + /// The documents picker's rows: the open documents, then the ones + /// opened lately. + fn document_rows(&self) -> Vec<DocumentRow> { + let open = self.shared.docs.iter().map(|open| DocumentRow { + name: open.name(), + path: open.file.path.clone(), + open: true, + }); + let recent = self + .memory + .recent + .iter() + .filter(|recent| { + !self.shared.docs.iter().any(|open| { + open.file.path == recent.path || open.buffer.doc().uid().to_string() == recent.uid + }) + }) + .map(|recent| DocumentRow { name: recent.title.clone(), path: recent.path.clone(), open: false }); + open.chain(recent).collect() + } + + /// `:set`: changes a setting, and puts it to work. + fn set(&mut self, arg: &SetArg) -> Message { + match self.settings.apply(arg) { + Ok(text) => { + self.shared.theme.glyphs = self.settings.glyphs; + self.shared.view.max_node_width = self.settings.max_node_width; + if matches!(arg, SetArg::Show(_)) { Message::info(text) } else { Message::done(text) } + } + Err(problem) => Message::problem(problem), + } + } +} diff --git a/src/command/parse.rs b/src/command/parse.rs index fe05209..e650c0a 100644 --- a/src/command/parse.rs +++ b/src/command/parse.rs @@ -72,8 +72,9 @@ pub enum SetArg { /// What a command line asks for, with its arguments checked and typed. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Call { + /// Open a document; no path reads the focused one again from its file. Edit { - path: String, + path: Option<String>, force: bool, }, New(Option<Title>), @@ -240,7 +241,7 @@ fn build(spec: &Spec, bang: bool, args: &[Option<&str>], sources: &Sources) -> R let second = args.get(1).copied().flatten(); let text = first.unwrap_or_default(); Ok(match spec.id { - Id::Edit => Call::Edit { path: text.to_string(), force: bang }, + Id::Edit => Call::Edit { path: first.map(str::to_string), force: bang }, Id::New => Call::New(first.map(title).transpose()?), Id::Write => Call::Write, Id::WriteAll => Call::WriteAll, @@ -479,7 +480,7 @@ mod tests { assert_eq!(call("x"), Ok(Call::WriteQuit)); assert_eq!( call("e notes/garden.cum"), - Ok(Call::Edit { path: "notes/garden.cum".into(), force: false }) + Ok(Call::Edit { path: Some("notes/garden.cum".into()), force: false }) ); assert_eq!(call("vs outline"), Ok(Call::VSplit(Some(ViewKind::Outline)))); assert_eq!(call("in buy a rain barrel"), Ok(Call::Capture("buy a rain barrel".into()))); @@ -517,7 +518,7 @@ mod tests { assert_eq!(message(""), "type a command, like :help"); assert_eq!(message("folx open"), "there's no command :folx; did you mean :fold?"); assert_eq!(message("w!"), ":write doesn't take a !"); - assert_eq!(message("e"), ":e[dit][!] <file>: the file is missing"); + assert_eq!(message("b"), ":b[uffer] <document>: the document is missing"); assert_eq!(message("fold up"), "\"up\" isn't a way to fold: use open, auto, closed"); assert_eq!(message("fold open now"), ":fo[ld] <open|auto|closed>: \"now\" is more than it takes"); assert_eq!(message("color corla"), "\"corla\" isn't a color: use red, orange, teal"); diff --git a/src/command/registry.rs b/src/command/registry.rs index b65bb39..204bbf6 100644 --- a/src/command/registry.rs +++ b/src/command/registry.rs @@ -242,8 +242,8 @@ pub const SPECS: &[Spec] = &[ Id::Edit, "edit", "e", - &[required("file", K::File)], - "open a document, or make it (! reloads it)", + &[optional("file", K::File)], + "open a document, or make it (alone: read this one again; ! drops unsaved changes)", )), spec(Id::New, "new", "new", &[optional("title", K::Text)], "start a new document next to this one"), spec(Id::Write, "write", "w", &[], "save now (cumulus saves by itself, too)"), @@ -363,7 +363,7 @@ mod tests { #[test] fn usage_shows_how_to_type_it() { - assert_eq!(find("e").unwrap().usage(), ":e[dit][!] <file>"); + assert_eq!(find("e").unwrap().usage(), ":e[dit][!] [file]"); assert_eq!(find("fold").unwrap().usage(), ":fo[ld] <open|auto|closed>"); assert_eq!(find("export").unwrap().usage(), ":exp[ort] <md|txt> [file]"); assert_eq!(find("in").unwrap().usage(), ":in <idea>"); diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..b9afc3d --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,6 @@ +//! Where cumulus keeps its files ([`paths`]), and every setting it has +//! ([`settings`]). Reading config.toml, keys.toml and theme files comes with +//! part 8b of the build. + +pub mod paths; +pub mod settings; diff --git a/src/config/paths.rs b/src/config/paths.rs new file mode 100644 index 0000000..4c9a0e6 --- /dev/null +++ b/src/config/paths.rs @@ -0,0 +1,85 @@ +//! Where cumulus keeps its files. Documents live wherever you put them; +//! these folders hold the rest. +//! +//! | Folder | Windows | Linux | What's in it | +//! |--------|----------------------------|--------------------------|--------------| +//! | config | `%APPDATA%\cumulus\config` | `~/.config/cumulus` | config.toml, keys.toml, themes | +//! | data | `%APPDATA%\cumulus\data` | `~/.local/share/cumulus` | where you left off, history, backups, locks | +//! +//! `cumulus --paths` prints them. + +use std::path::{Path, PathBuf}; + +use directories::{ProjectDirs, UserDirs}; +use thiserror::Error; + +/// The folders cumulus uses. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Paths { + pub config: PathBuf, + pub data: PathBuf, + /// Your home folder: `cumulus` falls back to `~/ideas.cum`. + pub home: PathBuf, +} + +/// The OS couldn't say where the home folder is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[error("can't find your home folder")] +pub struct NoHome; + +impl Paths { + /// The usual folders on this OS. + pub fn locate() -> Result<Paths, NoHome> { + let project = ProjectDirs::from("", "", "cumulus").ok_or(NoHome)?; + let user = UserDirs::new().ok_or(NoHome)?; + Ok(Paths { + config: project.config_dir().to_path_buf(), + data: project.data_dir().to_path_buf(), + home: user.home_dir().to_path_buf(), + }) + } + + /// Everything inside one folder: for tests, and for the playground, + /// which mustn't touch your real files. + pub fn inside(folder: &Path) -> Paths { + Paths { config: folder.join("config"), data: folder.join("data"), home: folder.join("home") } + } + + /// Where daily copies of the documents go. + pub fn backups(&self) -> PathBuf { + self.data.join("backups") + } + + /// Where the lock files are (see [`storage::lock`](crate::storage::lock)). + pub fn locks(&self) -> PathBuf { + self.data.join("locks") + } + + /// What cumulus remembers between runs. + pub fn memory(&self) -> PathBuf { + self.data.join("memory.ron") + } + + /// `~/name`: a path from the command line or `:e` may start with `~`. + pub fn expand(&self, path: &str) -> PathBuf { + match path.strip_prefix('~') { + Some("") => self.home.clone(), + Some(rest) if rest.starts_with(['/', '\\']) => self.home.join(&rest[1..]), + _ => PathBuf::from(path), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_tilde_is_the_home_folder() { + let paths = Paths::inside(Path::new("/x")); + assert_eq!(paths.expand("~/ideas.cum"), Path::new("/x/home").join("ideas.cum")); + assert_eq!(paths.expand("~"), Path::new("/x/home")); + assert_eq!(paths.expand("~bob/a.cum"), Path::new("~bob/a.cum"), "only ~ alone or ~/"); + assert_eq!(paths.expand("a.cum"), Path::new("a.cum")); + } +} diff --git a/src/config/settings.rs b/src/config/settings.rs new file mode 100644 index 0000000..2eacf74 --- /dev/null +++ b/src/config/settings.rs @@ -0,0 +1,307 @@ +//! Every setting, with its default. `:set` changes them while cumulus runs, +//! the way vim's does: `:set name=value`, `:set name?` shows it, `:set name&` +//! puts the default back, and on/off settings also take `:set name`, +//! `:set noname` and `:set name!`. (config.toml sets them for good: part 8b.) + +use std::time::Duration; + +use crate::command::parse::SetArg; +use crate::ui::command_line::PopupMode; +use crate::ui::glyphs::Glyphs; + +/// The settings, as they are now. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Settings { + /// Save about a second after each change; off, `:w` saves (vim style). + pub autosave: bool, + /// Take the mouse: clicks, drags and the wheel. + pub mouse: bool, + /// Key hints: which-key, and the status bar's hint line. + pub which_key: bool, + /// How long a key sequence waits before which-key shows (the leader's + /// shows at once). + pub which_key_delay: Duration, + /// The width titles wrap at, on the canvas. + pub max_node_width: u16, + pub glyphs: Glyphs, + /// When the command line's completion list shows. + pub completion_popup: PopupMode, + /// The document `cumulus` looks for at startup, without `.cum`. + pub default_document: String, +} + +impl Default for Settings { + fn default() -> Settings { + Settings { + autosave: true, + mouse: true, + which_key: true, + which_key_delay: Duration::from_millis(300), + max_node_width: crate::view::canvas::boxes::DEFAULT_MAX_WIDTH, + glyphs: Glyphs::Unicode, + completion_popup: PopupMode::AsYouType, + default_document: "ideas".to_string(), + } + } +} + +/// One of the settings, by name. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Setting { + Autosave, + Mouse, + WhichKey, + WhichKeyDelay, + MaxNodeWidth, + Glyphs, + CompletionPopup, + DefaultDocument, +} + +/// The widths `max_node_width` can be. +const WIDTHS: std::ops::RangeInclusive<u16> = 10..=200; +/// The longest `which_key_delay` can be. +const LONGEST_DELAY: Duration = Duration::from_secs(5); + +impl Setting { + pub const ALL: [Setting; 8] = [ + Setting::Autosave, + Setting::Mouse, + Setting::WhichKey, + Setting::WhichKeyDelay, + Setting::MaxNodeWidth, + Setting::Glyphs, + Setting::CompletionPopup, + Setting::DefaultDocument, + ]; + + /// Its name, as config.toml and `:set` write it. + pub fn name(self) -> &'static str { + match self { + Setting::Autosave => "autosave", + Setting::Mouse => "mouse", + Setting::WhichKey => "which_key", + Setting::WhichKeyDelay => "which_key_delay", + Setting::MaxNodeWidth => "max_node_width", + Setting::Glyphs => "glyphs", + Setting::CompletionPopup => "completion_popup", + Setting::DefaultDocument => "default_document", + } + } + + /// The setting with this name. + pub fn named(name: &str) -> Option<Setting> { + Setting::ALL.into_iter().find(|setting| setting.name() == name) + } + + /// Whether it's on or off, so `:set name`, `noname` and `name!` work. + fn is_switch(self) -> bool { + matches!(self, Setting::Autosave | Setting::Mouse | Setting::WhichKey) + } + + /// The values it takes, for a message when one doesn't fit. + fn takes(self) -> String { + match self { + Setting::Autosave | Setting::Mouse | Setting::WhichKey => "on or off".to_string(), + Setting::WhichKeyDelay => "a time up to 5s, like 300ms or 1s".to_string(), + Setting::MaxNodeWidth => format!("a width from {} to {}", WIDTHS.start(), WIDTHS.end()), + Setting::Glyphs => "unicode or ascii".to_string(), + Setting::CompletionPopup => { + let values: Vec<&str> = PopupMode::VALUES.iter().map(|(name, _)| *name).collect(); + values.join(" or ") + } + Setting::DefaultDocument => "a document's name, without .cum".to_string(), + } + } +} + +impl Settings { + /// A setting's value, as `:set name?` shows it. + pub fn get(&self, setting: Setting) -> String { + let switch = |on: bool| if on { "on" } else { "off" }.to_string(); + match setting { + Setting::Autosave => switch(self.autosave), + Setting::Mouse => switch(self.mouse), + Setting::WhichKey => switch(self.which_key), + Setting::WhichKeyDelay => format!("{}ms", self.which_key_delay.as_millis()), + Setting::MaxNodeWidth => self.max_node_width.to_string(), + Setting::Glyphs => match self.glyphs { + Glyphs::Unicode => "unicode".to_string(), + Glyphs::Ascii => "ascii".to_string(), + }, + Setting::CompletionPopup => PopupMode::VALUES + .iter() + .find(|(_, mode)| *mode == self.completion_popup) + .map_or("?", |(name, _)| *name) + .to_string(), + Setting::DefaultDocument => self.default_document.clone(), + } + } + + /// Carries out a `:set`. Returns what to say: the setting's value now, + /// or what's wrong. + pub fn apply(&mut self, arg: &SetArg) -> Result<String, String> { + let (name, change) = match arg { + SetArg::Show(name) => (name, None), + SetArg::Reset(name) => (name, Some(Change::Reset)), + SetArg::On(name) => (name, Some(Change::Switch(Some(true)))), + SetArg::Off(name) => (name, Some(Change::Switch(Some(false)))), + SetArg::Toggle(name) => (name, Some(Change::Switch(None))), + SetArg::Value(name, value) => (name, Some(Change::Value(value))), + }; + let setting = Setting::named(name).ok_or_else(|| format!("there's no setting called {name}"))?; + match change { + None => {} + Some(Change::Reset) => self.reset(setting), + Some(Change::Switch(on)) if setting.is_switch() => self.switch(setting, on), + Some(Change::Switch(_)) => { + return Err(format!("{name} takes a value ({}): :set {name}=…", setting.takes())); + } + Some(Change::Value(value)) => self.set(setting, value)?, + } + Ok(format!("{name} = {}", self.get(setting))) + } + + fn reset(&mut self, setting: Setting) { + let default = Settings::default(); + match setting { + Setting::Autosave => self.autosave = default.autosave, + Setting::Mouse => self.mouse = default.mouse, + Setting::WhichKey => self.which_key = default.which_key, + Setting::WhichKeyDelay => self.which_key_delay = default.which_key_delay, + Setting::MaxNodeWidth => self.max_node_width = default.max_node_width, + Setting::Glyphs => self.glyphs = default.glyphs, + Setting::CompletionPopup => self.completion_popup = default.completion_popup, + Setting::DefaultDocument => self.default_document = default.default_document, + } + } + + /// Turns an on/off setting on, off, or (`None`) the other way. + fn switch(&mut self, setting: Setting, on: Option<bool>) { + let value = match setting { + Setting::Autosave => &mut self.autosave, + Setting::Mouse => &mut self.mouse, + Setting::WhichKey => &mut self.which_key, + _ => return, + }; + *value = on.unwrap_or(!*value); + } + + /// `:set name=value`. + fn set(&mut self, setting: Setting, value: &str) -> Result<(), String> { + let wrong = || format!("{} is {}", setting.name(), setting.takes()); + match setting { + Setting::Autosave | Setting::Mouse | Setting::WhichKey => { + let on = match value { + "on" | "true" | "yes" => true, + "off" | "false" | "no" => false, + _ => return Err(wrong()), + }; + self.switch(setting, Some(on)); + } + Setting::WhichKeyDelay => { + self.which_key_delay = + parse_duration(value).filter(|delay| *delay <= LONGEST_DELAY).ok_or_else(wrong)?; + } + Setting::MaxNodeWidth => { + self.max_node_width = + value.parse().ok().filter(|width| WIDTHS.contains(width)).ok_or_else(wrong)?; + } + Setting::Glyphs => { + self.glyphs = match value { + "unicode" => Glyphs::Unicode, + "ascii" => Glyphs::Ascii, + _ => return Err(wrong()), + }; + } + Setting::CompletionPopup => self.completion_popup = PopupMode::named(value).ok_or_else(wrong)?, + Setting::DefaultDocument => { + let name = value.trim().trim_end_matches(".cum"); + if name.is_empty() || name.contains(['/', '\\']) { + return Err(wrong()); + } + self.default_document = name.to_string(); + } + } + Ok(()) + } +} + +/// What a `:set` does to a setting. +enum Change<'a> { + Reset, + /// On, off, or (`None`) the other way. + Switch(Option<bool>), + Value(&'a str), +} + +/// A time as settings write it: `300ms`, `1s`, or a number of milliseconds. +pub fn parse_duration(text: &str) -> Option<Duration> { + let text = text.trim(); + if let Some(millis) = text.strip_suffix("ms") { + return millis.trim().parse().ok().map(Duration::from_millis); + } + if let Some(seconds) = text.strip_suffix('s') { + return seconds + .trim() + .parse::<f64>() + .ok() + .filter(|s| s.is_finite() && *s >= 0.0) + .map(Duration::from_secs_f64); + } + text.parse().ok().map(Duration::from_millis) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn set(settings: &mut Settings, arg: SetArg) -> Result<String, String> { + settings.apply(&arg) + } + + #[test] + fn set_works_like_vims() { + let mut settings = Settings::default(); + let name = |text: &str| text.to_string(); + assert_eq!(set(&mut settings, SetArg::Off(name("autosave"))), Ok("autosave = off".into())); + assert!(!settings.autosave); + assert_eq!(set(&mut settings, SetArg::Toggle(name("autosave"))), Ok("autosave = on".into())); + assert_eq!( + set(&mut settings, SetArg::Show(name("max_node_width"))), + Ok("max_node_width = 30".into()) + ); + assert_eq!( + set(&mut settings, SetArg::Value(name("max_node_width"), "40".into())), + Ok("max_node_width = 40".into()) + ); + assert_eq!( + set(&mut settings, SetArg::Value(name("max_node_width"), "4".into())), + Err("max_node_width is a width from 10 to 200".into()) + ); + assert_eq!( + set(&mut settings, SetArg::Reset(name("max_node_width"))), + Ok("max_node_width = 30".into()) + ); + assert_eq!( + set(&mut settings, SetArg::On(name("glyphs"))), + Err("glyphs takes a value (unicode or ascii): :set glyphs=…".into()) + ); + assert_eq!( + set(&mut settings, SetArg::Value(name("glyphs"), "ascii".into())), + Ok("glyphs = ascii".into()) + ); + assert_eq!( + set(&mut settings, SetArg::Value(name("which_key_delay"), "1s".into())), + Ok("which_key_delay = 1000ms".into()) + ); + } + + #[test] + fn durations() { + assert_eq!(parse_duration("300ms"), Some(Duration::from_millis(300))); + assert_eq!(parse_duration("1.5s"), Some(Duration::from_millis(1500))); + assert_eq!(parse_duration("250"), Some(Duration::from_millis(250))); + assert_eq!(parse_duration("soon"), None); + } +} diff --git a/src/lib.rs b/src/lib.rs index 50a7f86..eee37a7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,20 +2,22 @@ //! //! cumulus is a program, not a library for other projects. It's split into a //! library and a small `main.rs` so the examples in `examples/` can run the -//! same code while the app is being built (DESIGN.md, "Build order"). +//! same code (DESIGN.md, "Build order"). //! -//! | Module | What's in it | -//! |-------------|--------------------------------------------------------------| -//! | [`model`] | the data and its rules: documents, nodes, links, undo, folds | -//! | [`input`] | keys and the mouse: vim keys, which-key, keys.toml | -//! | [`command`] | the `:` command line: commands, parsing, completion, history | -//! | [`storage`] | files: documents (RON) | -//! | [`view`] | the views, one module each: so far, the canvas | -//! | [`ui`] | what views share: glyphs, theme, which-key, the command line | -//! | [`app`] | around the views: actions, modes, buffers, shared commands | +//! | Module | What's in it | +//! |-------------|------------------------------------------------------------------| +//! | [`model`] | the data and its rules: documents, nodes, links, undo, folds | +//! | [`input`] | keys and the mouse: vim keys, which-key, keys.toml | +//! | [`command`] | the `:` command line: commands, parsing, completion, history | +//! | [`storage`] | files: documents, saving, backups, locks, what cumulus remembers | +//! | [`config`] | where cumulus keeps its files, and the settings | +//! | [`view`] | the views: the canvas, the outline, the Note view | +//! | [`ui`] | what views share: glyphs, theme, popups, the bars | +//! | [`app`] | the app: the main loop, modes, buffers, panes, files | pub mod app; pub mod command; +pub mod config; pub mod input; pub mod model; pub mod storage; diff --git a/src/main.rs b/src/main.rs index c921473..e768aac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,35 +3,68 @@ //! This file only reads the command line. Everything else lives in the //! library: `src/lib.rs` lists the modules. +use std::path::PathBuf; use std::process::ExitCode; +use cumulus::app::startup::{self, Startup}; +use cumulus::app::{App, run}; +use cumulus::config::paths::Paths; +use cumulus::config::settings::Settings; +use cumulus::input::keymap::{DEFAULT_LEADER, Keymap}; + const USAGE: &str = "\ cumulus: brainstorm in the terminal -Usage: cumulus +Usage: + cumulus open ./ideas.cum, or ~/ideas.cum + cumulus <file|folder>... open those documents (a folder: its ideas.cum) + cumulus new [title] make a new document here: ./ideas.cum, or a + file named after the title Options: - -h, --help show this help - -V, --version show the version"; + --paths show where cumulus keeps its settings and data + -h, --help show this help + -V, --version show the version + +Inside cumulus, ? shows every key."; fn main() -> ExitCode { - let args: Vec<String> = std::env::args().skip(1).collect(); - match args.first().map(String::as_str) { - None => { - println!("cumulus is being built, one stage at a time: see DESIGN.md."); - ExitCode::SUCCESS - } - Some("-h" | "--help") => { - println!("{USAGE}"); - ExitCode::SUCCESS - } - Some("-V" | "--version") => { - println!("{}", cumulus::version()); - ExitCode::SUCCESS - } - Some(other) => { - eprintln!("cumulus: unknown option {other}\n\n{USAGE}"); + match start(std::env::args().skip(1).collect()) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("cumulus: {error}"); ExitCode::FAILURE } } } + +fn start(args: Vec<String>) -> Result<(), String> { + let startup = match args.first().map(String::as_str) { + None => Startup::Default, + Some("-h" | "--help") => { + println!("{USAGE}"); + return Ok(()); + } + Some("-V" | "--version") => { + println!("{}", cumulus::version()); + return Ok(()); + } + Some("--paths") => { + let paths = Paths::locate().map_err(|error| error.to_string())?; + println!("config {} (config.toml, keys.toml, themes)", paths.config.display()); + println!("data {} (where you left off, history, backups, locks)", paths.data.display()); + return Ok(()); + } + Some(option) if option.starts_with('-') => return Err(format!("unknown option {option}\n\n{USAGE}")), + // The title's words, quoted or not: `cumulus new Garden plans`. + Some("new") => Startup::New((args.len() > 1).then(|| args[1..].join(" "))), + Some(_) => Startup::Open(args.iter().map(PathBuf::from).collect()), + }; + let paths = Paths::locate().map_err(|error| error.to_string())?; + // config.toml and keys.toml come in part 8b of the build. + let settings = Settings::default(); + let here = + std::env::current_dir().map_err(|error| format!("can't tell which folder this is: {error}"))?; + let opening = startup::open(&startup, &paths, &here, &settings).map_err(|error| error.to_string())?; + run(App::new(paths, settings, Keymap::new(DEFAULT_LEADER), opening)).map_err(|error| error.to_string()) +} diff --git a/src/model/edit.rs b/src/model/edit.rs index 5d2b6f3..be6c947 100644 --- a/src/model/edit.rs +++ b/src/model/edit.rs @@ -173,6 +173,48 @@ impl Document { self.loose = loose.clone(); } } + + /// Puts `newer` in this document's place: the same document, read again + /// from its file after something else changed it. The returned change + /// holds the old value of everything that differs, so a reload is an + /// undo step like any other: undoing it brings back exactly what was + /// there, and the older steps still find what they expect. + /// + /// `newer` comes back as the error when it's another document (another + /// uid or another root): that can't be an undo step. + pub fn replace(&mut self, newer: Document) -> Result<Change, Box<Document>> { + if newer.uid() != self.uid() || newer.root() != self.root() { + return Err(Box::new(newer)); + } + let mut change = Change::default(); + let ids: Vec<NodeId> = self.entries.keys().chain(newer.entries.keys()).copied().collect(); + for id in ids { + let (old, new) = (self.entries.get(&id), newer.entries.get(&id)); + if old != new { + change.before.entries.insert(id, old.cloned()); + change.after.entries.insert(id, new.cloned()); + } + } + let keys: Vec<LinkKey> = self.links.keys().chain(newer.links.keys()).copied().collect(); + for key in keys { + let (old, new) = (self.links.get(&key), newer.links.get(&key)); + if old != new { + change.before.links.insert(key, old.cloned()); + change.after.links.insert(key, new.cloned()); + } + } + if self.loose != newer.loose { + change.before.loose = Some(self.loose.clone()); + change.after.loose = Some(newer.loose.clone()); + } + // Ids only ever grow, so none is handed out twice, undo or not. + let next_id = self.next_id.max(newer.next_id); + let backlinks = newer.backlinks().to_vec(); + self.restore(&change.after); + self.next_id = next_id; + self.set_backlinks(backlinks); + Ok(change) + } } /// A transaction in progress (see [`Document::edit`]): the document, plus the @@ -488,8 +530,36 @@ impl Edit<'_> { #[cfg(test)] mod tests { use super::*; - use crate::model::test_support::{garden, idea}; - use crate::model::{CanvasPos, Fold}; + use crate::model::test_support::{garden, idea, title}; + use crate::model::{CanvasPos, DocUid, Fold, History}; + + #[test] + fn a_newer_version_replaces_the_document_as_an_undo_step() { + let (mut doc, g) = garden(); + let mut history = History::default(); + // Something unsaved here: a node that the file doesn't have. + let (mine, change) = doc.edit(|e| e.add(idea("mine"), Spot::LastChildOf(g.soil))).unwrap(); + history.record(change); + let before = doc.clone(); + + // The file, changed by something else: Soil renamed, Cedar gone. + let (mut newer, _) = garden(); + newer.edit(|e| e.update(g.soil, |node| node.content = idea("Loam").content)).unwrap(); + newer.edit(|e| e.remove_branch(g.cedar)).unwrap(); + let expected = newer.clone(); + + history.record(doc.replace(newer).unwrap()); + assert_eq!(doc, expected); + doc.assert_rules(); + history.undo(&mut doc); + assert_eq!(doc, before, "undo brings back what was on screen"); + history.undo(&mut doc); + assert!(!doc.contains(mine), "and the older steps still undo"); + doc.assert_rules(); + + let other = Document::with_uid(title("Other"), DocUid::parse("0000000000000009").unwrap()); + assert!(doc.replace(other).is_err(), "another document isn't a change"); + } #[test] fn adding_and_moving_keep_sibling_order() { diff --git a/src/model/text.rs b/src/model/text.rs index d517385..150371a 100644 --- a/src/model/text.rs +++ b/src/model/text.rs @@ -34,6 +34,11 @@ impl Title { not_empty(one_line(text), "a title").map(Title) } + /// "Untitled": for a new document whose file name gives no title. + pub fn untitled() -> Title { + Title("Untitled".to_string()) + } + /// The title's text. pub fn as_str(&self) -> &str { &self.0 diff --git a/src/storage/backup.rs b/src/storage/backup.rs new file mode 100644 index 0000000..ac30bce --- /dev/null +++ b/src/storage/backup.rs @@ -0,0 +1,123 @@ +//! A copy of each document once a day, in the data folder: a safety net for +//! mistakes, hand edits included. The copy is made before the day's first +//! save, so it's the document as it was before that day's changes. +//! +//! Copies go in `backups/<uid>/2026-10-01.cum`: by uid, so a document keeps +//! its copies when its file is renamed. + +use std::fmt; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::model::DocUid; + +/// How many daily copies each document keeps. +pub const KEEP: usize = 30; + +/// A day, as the calendar names it (in UTC: the day a backup belongs to +/// only needs to change once every 24 hours). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Day { + year: i64, + month: u32, + day: u32, +} + +impl Day { + /// Today. + pub fn today() -> Day { + let seconds = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |since| since.as_secs()); + Day::from_days(i64::try_from(seconds / 86_400).unwrap_or(0)) + } + + /// The day this many days after 1970-01-01 (Howard Hinnant's + /// `civil_from_days`). + fn from_days(days: i64) -> Day { + let shifted = days + 719_468; + let era = shifted.div_euclid(146_097); + let day_of_era = shifted.rem_euclid(146_097); + let year_of_era = (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_from_march = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_from_march + 2) / 5 + 1; + let month = if month_from_march < 10 { month_from_march + 3 } else { month_from_march - 9 }; + let year = year_of_era + era * 400 + i64::from(month <= 2); + // Both are small by construction: 1–31 and 1–12. + Day { year, month: u32::try_from(month).unwrap_or(1), day: u32::try_from(day).unwrap_or(1) } + } +} + +impl fmt::Display for Day { + /// ISO style, so names sort by date: `2026-10-01`. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day) + } +} + +/// Copies a document's file into its backups folder, unless today's copy is +/// there already, then deletes its oldest copies so `keep` remain. Nothing +/// happens while the file doesn't exist (a new document). +pub fn daily(backups: &Path, uid: DocUid, file: &Path, today: Day, keep: usize) -> io::Result<()> { + if !file.exists() { + return Ok(()); + } + let folder = backups.join(uid.to_string()); + let todays = folder.join(format!("{today}.cum")); + if todays.exists() { + return Ok(()); + } + fs::create_dir_all(&folder)?; + fs::copy(file, &todays)?; + // The names are dates, so sorting them puts the oldest first. + let mut copies: Vec<PathBuf> = fs::read_dir(&folder)? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.extension().is_some_and(|extension| extension == "cum")) + .collect(); + copies.sort(); + let extra = copies.len().saturating_sub(keep); + for old in &copies[..extra] { + fs::remove_file(old)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::test_support::TempDir; + + #[test] + fn days_from_the_calendar() { + assert_eq!(Day::from_days(0).to_string(), "1970-01-01"); + assert_eq!(Day::from_days(11_016).to_string(), "2000-02-29", "a leap day"); + assert_eq!(Day::from_days(20_727).to_string(), "2026-10-01"); + assert_eq!(Day::from_days(-1).to_string(), "1969-12-31"); + } + + #[test] + fn one_copy_a_day_and_the_oldest_go() { + let folder = TempDir::new(); + let file = folder.path().join("garden.cum"); + let backups = folder.path().join("backups"); + let uid = DocUid::parse("7f3c9a1e52b04d18").unwrap(); + daily(&backups, uid, &file, Day::from_days(1), 2).unwrap(); + assert!(!backups.exists(), "no file yet, no copy"); + + fs::write(&file, "monday").unwrap(); + daily(&backups, uid, &file, Day::from_days(1), 2).unwrap(); + fs::write(&file, "monday, later").unwrap(); + daily(&backups, uid, &file, Day::from_days(1), 2).unwrap(); + let copies = backups.join(uid.to_string()); + assert_eq!(fs::read_to_string(copies.join("1970-01-02.cum")).unwrap(), "monday", "the day's first"); + + for day in 2..=3 { + daily(&backups, uid, &file, Day::from_days(day), 2).unwrap(); + } + let mut names: Vec<_> = fs::read_dir(&copies).unwrap().map(|e| e.unwrap().file_name()).collect(); + names.sort(); + assert_eq!(names, ["1970-01-03.cum", "1970-01-04.cum"], "only the newest two stay"); + } +} diff --git a/src/storage/lock.rs b/src/storage/lock.rs new file mode 100644 index 0000000..94d8f0a --- /dev/null +++ b/src/storage/lock.rs @@ -0,0 +1,58 @@ +//! One cumulus at a time edits a document. Two editing the same file would +//! overwrite each other's changes, so the second one to open it gets it +//! read-only (DESIGN.md, "Data folder"). +//! +//! The lock is the operating system's lock on a small file in the data +//! folder, `locks/<uid>.lock`: the system lets go of it when cumulus exits, +//! even after a crash, so a lock is never left behind by mistake. Keyed by +//! the document's uid, it holds however the file is named or reached. + +use std::fs::{self, File, OpenOptions, TryLockError}; +use std::io; +use std::path::Path; + +use crate::model::DocUid; + +/// A document this cumulus may edit, for as long as the lock is kept. +#[derive(Debug)] +pub struct Lock { + _file: File, +} + +impl Lock { + /// Takes the lock on a document, from the locks folder. `None`: another + /// cumulus has it. (The lock files stay in the folder: removing one + /// while another cumulus is about to lock it could let two in.) + pub fn take(folder: &Path, uid: DocUid) -> io::Result<Option<Lock>> { + fs::create_dir_all(folder)?; + let file = OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(folder.join(format!("{uid}.lock")))?; + match file.try_lock() { + Ok(()) => Ok(Some(Lock { _file: file })), + Err(TryLockError::WouldBlock) => Ok(None), + Err(TryLockError::Error(error)) => Err(error), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::test_support::TempDir; + + #[test] + fn a_second_lock_waits_for_the_first() { + let folder = TempDir::new(); + let uid = DocUid::parse("7f3c9a1e52b04d18").unwrap(); + let first = Lock::take(folder.path(), uid).unwrap(); + assert!(first.is_some()); + assert!(Lock::take(folder.path(), uid).unwrap().is_none(), "already taken"); + let other = DocUid::parse("c01d5e7aa0f3b912").unwrap(); + assert!(Lock::take(folder.path(), other).unwrap().is_some(), "another document is free"); + drop(first); + assert!(Lock::take(folder.path(), uid).unwrap().is_some(), "free again"); + } +} diff --git a/src/storage/memory.rs b/src/storage/memory.rs new file mode 100644 index 0000000..2f88080 --- /dev/null +++ b/src/storage/memory.rs @@ -0,0 +1,159 @@ +//! What cumulus remembers between runs, in `memory.ron` in the data folder: +//! where you left off in each document (by uid, so it survives renames), +//! the documents opened lately, and the command history. +//! +//! cumulus writes this file itself, and it's only a convenience: one it +//! can't read is set aside as if it were empty. Two cumulus running at once +//! each merge their part into what's on disk when they write it, rather than +//! overwriting each other's. + +use std::collections::BTreeMap; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use super::write_atomically; + +/// How many recent documents are kept. +const RECENT: usize = 50; +/// How many command lines are kept. +const HISTORY: usize = 200; + +/// The data folder's memory. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Memory { + /// Where you left off in each document, by uid. + pub places: BTreeMap<String, LeftOff>, + /// Documents opened lately, the latest first. + pub recent: Vec<Recent>, + /// Command lines typed, the oldest first. + pub history: Vec<String>, +} + +/// Where you left off in a document: the cursor's node, and how the canvas +/// looked at it. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct LeftOff { + pub cursor: u32, + /// The canvas's zoom, by name (`normal`, `far`, `overview`). + pub zoom: String, + /// The canvas point in the view's top-left corner. + pub x: i32, + pub y: i32, +} + +/// A document opened lately. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Recent { + /// Where its file is, in full. + pub path: PathBuf, + pub uid: String, + /// Its name when it was last open. + pub title: String, +} + +impl Memory { + /// Reads the memory file. A missing file is an empty memory; so is one + /// that can't be read, which is renamed `memory.ron.broken` so the next + /// write doesn't lose it for good. + pub fn load(path: &Path) -> Memory { + let text = match fs::read_to_string(path) { + Ok(text) => text, + Err(_) => return Memory::default(), + }; + ron::from_str(&text).unwrap_or_else(|_| { + let _ = fs::rename(path, path.with_extension("ron.broken")); + Memory::default() + }) + } + + /// Writes this memory over what's on disk, after merging in what another + /// cumulus wrote there since: the places and recent documents it knows + /// and this one doesn't, and its command lines before this one's. + pub fn save(&self, path: &Path) -> io::Result<()> { + let disk = Memory::load(path); + let mut merged = self.clone(); + for (uid, place) in disk.places { + merged.places.entry(uid).or_insert(place); + } + for recent in disk.recent { + if !merged.recent.iter().any(|known| known.uid == recent.uid) { + merged.recent.push(recent); + } + } + merged.recent.truncate(RECENT); + let mut history: Vec<String> = + disk.history.into_iter().filter(|line| !self.history.contains(line)).collect(); + history.extend(self.history.iter().cloned()); + let extra = history.len().saturating_sub(HISTORY); + merged.history = history.split_off(extra); + let pretty = ron::ser::PrettyConfig::new().new_line("\n"); + let text = ron::ser::to_string_pretty(&merged, pretty).map_err(io::Error::other)?; + write_atomically(path, &format!("// What cumulus remembers between runs. Safe to delete.\n{text}\n")) + } + + /// Remembers that a document was opened: it goes first in the recent + /// list (a document seen under another path before keeps one entry). + pub fn opened(&mut self, path: &Path, uid: String, title: String) { + self.recent.retain(|recent| recent.uid != uid && recent.path != path); + self.recent.insert(0, Recent { path: path.to_path_buf(), uid, title }); + self.recent.truncate(RECENT); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::test_support::TempDir; + + fn recent(uid: &str) -> Recent { + Recent { path: PathBuf::from(format!("/{uid}.cum")), uid: uid.into(), title: uid.into() } + } + + #[test] + fn a_missing_or_broken_file_is_an_empty_memory() { + let folder = TempDir::new(); + let path = folder.path().join("memory.ron"); + assert_eq!(Memory::load(&path), Memory::default()); + fs::write(&path, "(places: {").unwrap(); + assert_eq!(Memory::load(&path), Memory::default()); + assert!(path.with_extension("ron.broken").exists(), "kept aside"); + } + + #[test] + fn saving_merges_with_what_another_cumulus_wrote() { + let folder = TempDir::new(); + let path = folder.path().join("memory.ron"); + let mut theirs = Memory::default(); + theirs.places.insert("a".into(), LeftOff { cursor: 4, ..LeftOff::default() }); + theirs.places.insert("b".into(), LeftOff { cursor: 7, ..LeftOff::default() }); + theirs.recent = vec![recent("a"), recent("c")]; + theirs.history = vec!["w".into(), "in milk".into()]; + theirs.save(&path).unwrap(); + + let mut ours = Memory::default(); + ours.places.insert("a".into(), LeftOff { cursor: 9, ..LeftOff::default() }); + ours.recent = vec![recent("b"), recent("a")]; + ours.history = vec!["w".into(), "q".into()]; + ours.save(&path).unwrap(); + + let merged = Memory::load(&path); + assert_eq!(merged.places["a"].cursor, 9, "ours wins where both know it"); + assert_eq!(merged.places["b"].cursor, 7, "theirs stays"); + let uids: Vec<&str> = merged.recent.iter().map(|recent| recent.uid.as_str()).collect(); + assert_eq!(uids, ["b", "a", "c"]); + assert_eq!(merged.history, ["in milk", "w", "q"]); + } + + #[test] + fn opening_moves_a_document_to_the_front() { + let mut memory = Memory { recent: vec![recent("a"), recent("b")], ..Memory::default() }; + memory.opened(Path::new("/b.cum"), "b".into(), "b".into()); + let uids: Vec<&str> = memory.recent.iter().map(|recent| recent.uid.as_str()).collect(); + assert_eq!(uids, ["b", "a"]); + } +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs index c747b38..7cf980e 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -1,4 +1,96 @@ -//! Files. For now: reading and writing documents. Saving safely, backups, -//! locks and the data folder come with the app core (DESIGN.md, stage 8). +//! Files on disk: +//! - [`document_file`]: documents (`.cum`), RON text to a document and back, +//! - [`write_atomically`]: saving so a file is never left half-written, +//! - [`names`]: file names made from titles, and titles from file names, +//! - [`backup`]: a copy of each document once a day, in the data folder, +//! - [`lock`]: one cumulus at a time edits a document; another one opens +//! it read-only, +//! - [`stamp`]: noticing that something else changed a file, +//! - [`memory`]: what cumulus remembers between runs (where you left off in +//! each document, recent documents, the command history). +pub mod backup; pub mod document_file; +pub mod lock; +pub mod memory; +pub mod names; +pub mod stamp; + +use std::fs::{self, File}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +/// Writes a file so it's never left half-written: the text goes to a +/// temporary file next to it first, which then takes the real one's place in +/// a single step. If the computer crashes midway, the previous version is +/// still intact. The folder is made if it doesn't exist yet. +pub fn write_atomically(path: &Path, contents: &str) -> io::Result<()> { + if let Some(folder) = path.parent().filter(|folder| !folder.as_os_str().is_empty()) { + fs::create_dir_all(folder)?; + } + let temporary = temporary_name(path); + let mut file = File::create(&temporary)?; + file.write_all(contents.as_bytes())?; + file.sync_all()?; + drop(file); + fs::rename(&temporary, path).inspect_err(|_| { + let _ = fs::remove_file(&temporary); + }) +} + +/// The temporary file a save goes through: hidden on Linux, and named after +/// the real one so a leftover is easy to recognize. +fn temporary_name(path: &Path) -> PathBuf { + let name = path.file_name().map_or_else(|| "document".into(), |name| name.to_string_lossy()); + path.with_file_name(format!(".{name}.tmp")) +} + +/// Temporary folders for tests, deleted when dropped. (No testing crates: +/// this is all the tests need.) +#[cfg(test)] +pub(crate) mod test_support { + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU32, Ordering}; + + /// A fresh, empty folder in the system's temporary folder. + pub struct TempDir(PathBuf); + + impl TempDir { + pub fn new() -> TempDir { + static NEXT: AtomicU32 = AtomicU32::new(0); + let number = NEXT.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("cumulus-test-{}-{number}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("a temporary folder"); + TempDir(path) + } + + pub fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } +} + +#[cfg(test)] +mod tests { + use super::test_support::TempDir; + use super::*; + + #[test] + fn saving_replaces_the_file_and_leaves_nothing_behind() { + let folder = TempDir::new(); + let path = folder.path().join("new folder").join("garden.cum"); + write_atomically(&path, "first").unwrap(); + write_atomically(&path, "second").unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "second"); + let names: Vec<_> = + fs::read_dir(path.parent().unwrap()).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(names, ["garden.cum"], "no temporary file is left"); + } +} diff --git a/src/storage/names.rs b/src/storage/names.rs new file mode 100644 index 0000000..227e41d --- /dev/null +++ b/src/storage/names.rs @@ -0,0 +1,93 @@ +//! File names follow titles: a document titled "Garden plans" is +//! `garden-plans.cum`. The other way round, a document made for a file name +//! gets its title from it: `garden-plans.cum` starts as "Garden plans". + +use std::path::Path; + +use crate::model::Title; + +/// The extension of document files. +pub const EXTENSION: &str = "cum"; + +/// How long a file name made from a title gets, before `.cum`. Titles can be +/// long; file names are better short. +const MAX_LENGTH: usize = 60; + +/// Names Windows keeps for devices: a file can't be called `con.cum`. +const RESERVED: &[&str] = &[ + "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9", + "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9", +]; + +/// The file name for a document with this title: lowercase, its words +/// joined by `-`, then `.cum`. Letters from any language stay as they are; +/// anything else (spaces, punctuation, characters Windows refuses in file +/// names) breaks words, and apostrophes just go (`Kid's room` is +/// `kids-room.cum`). +pub fn file_name(title: &str) -> String { + let mut name = String::new(); + let mut gap = false; + for c in title.chars() { + if c.is_alphanumeric() { + if gap && !name.is_empty() { + name.push('-'); + } + gap = false; + name.extend(c.to_lowercase()); + } else if !matches!(c, '\'' | '’') { + gap = true; + } + } + if name.chars().count() > MAX_LENGTH { + // Cut at the last word break that fits, or mid-word if there's none. + let cut: String = name.chars().take(MAX_LENGTH).collect(); + name = match cut.rfind('-') { + Some(at) if at > 0 => cut[..at].to_string(), + _ => cut, + }; + } + if name.is_empty() { + name.push_str("untitled"); + } + if RESERVED.contains(&name.as_str()) { + name.push_str("-doc"); + } + format!("{name}.{EXTENSION}") +} + +/// The title a new document gets from its file name: `garden-plans.cum` is +/// "Garden plans", `ideas.cum` is "Ideas". +pub fn title_from_file(path: &Path) -> Title { + let stem = + path.file_stem().map(|stem| stem.to_string_lossy().replace(['-', '_'], " ")).unwrap_or_default(); + let words = stem.split_whitespace().collect::<Vec<_>>().join(" "); + let mut chars = words.chars(); + let capitalized: String = + chars.next().map(|first| first.to_uppercase().chain(chars).collect()).unwrap_or_default(); + Title::parse(&capitalized).unwrap_or_else(|_| Title::untitled()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_names_follow_titles() { + assert_eq!(file_name("Garden plans"), "garden-plans.cum"); + assert_eq!(file_name(" Kid's room: paint? "), "kids-room-paint.cum"); + assert_eq!(file_name("Été 2026 / à faire"), "été-2026-à-faire.cum"); + assert_eq!(file_name("C:\\what*now|\"x\""), "c-what-now-x.cum"); + assert_eq!(file_name("???"), "untitled.cum"); + assert_eq!(file_name("Con"), "con-doc.cum"); + let long = file_name(&"word ".repeat(30)); + assert!(long.len() <= MAX_LENGTH + ".cum".len() && long.ends_with("word.cum"), "{long}"); + } + + #[test] + fn titles_from_file_names() { + assert_eq!(title_from_file(Path::new("garden-plans.cum")).as_str(), "Garden plans"); + assert_eq!(title_from_file(Path::new("/notes/ideas.cum")).as_str(), "Ideas"); + assert_eq!(title_from_file(Path::new("été_2026.cum")).as_str(), "Été 2026"); + assert_eq!(title_from_file(Path::new("---.cum")).as_str(), "Untitled"); + } +} diff --git a/src/storage/stamp.rs b/src/storage/stamp.rs new file mode 100644 index 0000000..25f70a0 --- /dev/null +++ b/src/storage/stamp.rs @@ -0,0 +1,47 @@ +//! Noticing that something else changed a file: cumulus remembers each +//! document file's [`Stamp`] when it reads or writes it, and looks again +//! every second. A different stamp means another program (an editor, a +//! sync tool, another cumulus) wrote the file, and cumulus reloads it. +//! Looking costs one quick question to the file system per document, so +//! there's no need for a file-watching library. + +use std::fs; +use std::io; +use std::path::Path; +use std::time::SystemTime; + +/// When a file was last written, and how long it is. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Stamp { + modified: SystemTime, + len: u64, +} + +impl Stamp { + /// The stamp of the file at `path`, or `None` when there's no file there. + pub fn of(path: &Path) -> io::Result<Option<Stamp>> { + match fs::metadata(path) { + Ok(metadata) => Ok(Some(Stamp { modified: metadata.modified()?, len: metadata.len() })), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::test_support::TempDir; + + #[test] + fn a_write_changes_the_stamp() { + let folder = TempDir::new(); + let path = folder.path().join("garden.cum"); + assert_eq!(Stamp::of(&path).unwrap(), None); + fs::write(&path, "one").unwrap(); + let first = Stamp::of(&path).unwrap(); + assert_eq!(Stamp::of(&path).unwrap(), first, "nothing changed"); + fs::write(&path, "three").unwrap(); + assert_ne!(Stamp::of(&path).unwrap(), first); + } +} diff --git a/src/ui/card.rs b/src/ui/card.rs index c50e8b5..6651cfb 100644 --- a/src/ui/card.rs +++ b/src/ui/card.rs @@ -15,7 +15,7 @@ use ratatui::Frame; use ratatui::layout::{Position, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, BorderType, Clear}; +use ratatui::widgets::{Block, Clear}; use super::text_editor::layout; use super::theme::Theme; @@ -126,7 +126,7 @@ impl CardView { }; let border = if text.cursor.is_some() && text.typing { theme.insert } else { theme.muted }; let block = Block::bordered() - .border_type(BorderType::Rounded) + .border_set(theme.glyphs.frame(true)) .border_style(Style::new().fg(border)) .title(Line::styled( format!(" {} ", text.title), diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index 2b79fa9..0666666 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -18,7 +18,7 @@ use ratatui::Frame; use ratatui::layout::{Position, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, BorderType, Clear, Paragraph}; +use ratatui::widgets::{Block, Clear, Paragraph}; use unicode_width::UnicodeWidthStr; use super::text_input::TextInput; @@ -262,7 +262,7 @@ impl CommandLine { }) .collect(); let block = - Block::bordered().border_type(BorderType::Rounded).border_style(Style::new().fg(theme.muted)); + Block::bordered().border_set(theme.glyphs.frame(true)).border_style(Style::new().fg(theme.muted)); frame.render_widget(Clear, rect); frame.render_widget(Paragraph::new(lines).block(block), rect); } diff --git a/src/ui/document_bar.rs b/src/ui/document_bar.rs index d7b8c2a..bc24286 100644 --- a/src/ui/document_bar.rs +++ b/src/ui/document_bar.rs @@ -55,7 +55,12 @@ impl Widget for DocumentBar<'_> { break; } if index > 0 { - buf.set_span(area.x + place.start - 1, area.y, &Span::styled("│", muted), 1); + buf.set_span( + area.x + place.start - 1, + area.y, + &Span::styled(self.theme.glyphs.separator(), muted), + 1, + ); } let style = if index == self.current { Style::new().fg(Color::Black).bg(self.theme.accent).add_modifier(Modifier::BOLD) diff --git a/src/ui/glyphs.rs b/src/ui/glyphs.rs index 3c8c426..390badf 100644 --- a/src/ui/glyphs.rs +++ b/src/ui/glyphs.rs @@ -4,8 +4,22 @@ use std::sync::LazyLock; +use ratatui::symbols::border; + use crate::model::{Direction, Stroke}; +/// The frame of a pane or a popup, in plain ASCII. +const ASCII_FRAME: border::Set<'static> = border::Set { + top_left: "+", + top_right: "+", + bottom_left: "+", + bottom_right: "+", + vertical_left: "|", + vertical_right: "|", + horizontal_top: "-", + horizontal_bottom: "-", +}; + /// How heavy one arm of a line is, in one cell. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Weight { @@ -144,6 +158,23 @@ impl Glyphs { } } + /// The frame around a pane (square) or a popup (`rounded`). + pub fn frame(self, rounded: bool) -> border::Set<'static> { + match (self, rounded) { + (Glyphs::Unicode, true) => border::ROUNDED, + (Glyphs::Unicode, false) => border::PLAIN, + (Glyphs::Ascii, _) => ASCII_FRAME, + } + } + + /// What stands between the parts of the bars. + pub fn separator(self) -> &'static str { + match self { + Glyphs::Unicode => "│", + Glyphs::Ascii => "|", + } + } + /// The mark at the end of text that was cut short. pub fn ellipsis(self) -> char { match self { diff --git a/src/ui/list.rs b/src/ui/list.rs index 756e7db..f2b6570 100644 --- a/src/ui/list.rs +++ b/src/ui/list.rs @@ -15,7 +15,7 @@ use ratatui::Frame; use ratatui::layout::{Position, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, BorderType, Clear}; +use ratatui::widgets::{Block, Clear}; use unicode_width::UnicodeWidthStr; use super::text_input::TextInput; @@ -362,7 +362,7 @@ impl ListPopup { frame.render_widget(Clear, area); let accent = Style::new().fg(theme.accent).add_modifier(Modifier::BOLD); let block = Block::bordered() - .border_type(BorderType::Rounded) + .border_set(theme.glyphs.frame(true)) .border_style(Style::new().fg(theme.muted)) .title(Line::styled(format!(" {} ", self.title), accent)) .title_bottom(Line::styled(format!(" {} ", self.footer), Style::new().fg(theme.muted))); diff --git a/src/ui/status_bar.rs b/src/ui/status_bar.rs index b5b844c..d02ef3a 100644 --- a/src/ui/status_bar.rs +++ b/src/ui/status_bar.rs @@ -49,7 +49,8 @@ impl Widget for StatusBar<'_> { format!(" {} ", self.mode), Style::new().fg(Color::Black).bg(self.mode_color).add_modifier(Modifier::BOLD), ); - let line = Line::from(vec![label, Span::raw(format!(" {} │ ", self.view)), self.place]); + let separator = self.theme.glyphs.separator(); + let line = Line::from(vec![label, Span::raw(format!(" {} {separator} ", self.view)), self.place]); let status = Rect { height: 1, ..area }; Paragraph::new(line).render(status, buf); let keys = Line::styled( diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 87f94c2..af10cfc 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -5,7 +5,7 @@ //! [`LineStyle`]) in layers: the theme's default, then the style a tag asks //! for, then a named style, then the overrides themselves. //! -//! Theme files (TOML) come with the config (DESIGN.md, stage 8). Until then +//! Theme files (TOML) come with the config (part 8b of the build). Until then //! [`Theme::default`] is the theme. use std::collections::BTreeMap; diff --git a/src/ui/which_key.rs b/src/ui/which_key.rs index e0a2763..03bcdb4 100644 --- a/src/ui/which_key.rs +++ b/src/ui/which_key.rs @@ -12,7 +12,7 @@ use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, BorderType, Clear, Paragraph, Widget}; +use ratatui::widgets::{Block, Clear, Paragraph, Widget}; use unicode_width::UnicodeWidthStr; use super::theme::Theme; @@ -65,7 +65,7 @@ impl Widget for WhichKey<'_> { Clear.render(rect, buf); let title = Line::styled(format!(" {} ", self.hints.title), key_style); let block = Block::bordered() - .border_type(BorderType::Rounded) + .border_set(self.theme.glyphs.frame(true)) .border_style(Style::new().fg(self.theme.muted)) .title(title); Paragraph::new(lines).block(block).render(rect, buf); diff --git a/src/view/canvas/view.rs b/src/view/canvas/view.rs index d92c21a..f99994f 100644 --- a/src/view/canvas/view.rs +++ b/src/view/canvas/view.rs @@ -362,6 +362,14 @@ impl View for CanvasView { let rect = self.scene.rect_of(ctx.cursor)?; clip(rect, self.area) } + + fn camera(&self) -> Option<Camera> { + Some(self.camera) + } + + fn set_camera(&mut self, camera: Camera) { + self.camera = camera; + } } #[cfg(test)] diff --git a/src/view/mod.rs b/src/view/mod.rs index 72dbb0f..ee0680a 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -26,6 +26,7 @@ use crate::input::engine::Typed; use crate::input::mouse::Gesture; use crate::model::{CanvasRect, CanvasSize, Document, Node, NodeId}; use crate::ui::theme::Theme; +use canvas::Camera; use canvas::boxes::NodeBox; /// The kinds of view a pane can show. @@ -141,6 +142,16 @@ pub trait View { fn cursor_area(&self, _ctx: &ViewContext<'_>) -> Option<Rect> { None } + + /// Where the canvas is looking: kept with the document, so the next + /// canvas on it (and the next run of cumulus) starts there. The other + /// views follow the cursor, and have none. + fn camera(&self) -> Option<Camera> { + None + } + + /// Starts the canvas looking there (where you left off). + fn set_camera(&mut self, _camera: Camera) {} } #[cfg(test)]