diff --git a/DESIGN.md b/DESIGN.md index 7f0b67c..618d134 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -437,12 +437,31 @@ document has a portal pointing here. ``` **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. +Linux); `cumulus --paths` prints the folders. Each file only states what it +changes: a missing file changes nothing, and a mistake only drops its own line, +with a message in the status bar saying what's wrong. cumulus applies a file +again when it's saved. (Settled in part 8b.) + +- `:config` lists config.toml, keys.toml and the theme's file, with the folder + they're in; Enter opens one in your editor (the `editor` setting, `$VISUAL`, + `$EDITOR`, else notepad on Windows and vi elsewhere), with the terminal + handed over until it's done. A file that isn't there yet is written first, + every option at its default behind a `#`, with what it does: keys.toml lists + every command by name. +- `:set` and `:theme` change things until cumulus quits, as in vim; the files + change them for good. +- A theme file is laid over the built-in theme (`default`): a theme that says + just `accent = "magenta"` is the built-in one in magenta, and + `themes/default.toml` changes the built-in theme itself. Screen colors are + terminal colors; the colors in looks and styles are palette names. +- The built-in theme comes with named styles, each put on by the tag of the + same name: `question` (yellow, dashed), `important` (red, thick) and `done` + (gray, its title faded); and two for links, `blocks` (red, dashed, "blocks") + and `supports` (green, "supports"). The user's pick in part 8b. ```toml # config.toml -theme = "default" # a file in the themes folder +theme = "default" # or a file in the themes folder default_document = "ideas" # what `cumulus` looks for at startup autosave = true mouse = true @@ -451,11 +470,11 @@ which_key_delay = "300ms" max_node_width = 30 glyphs = "unicode" # "ascii" for fonts without box-drawing characters completion_popup = "as-you-type" # "on-tab": only after Tab, like vim's wildmenu +editor = "" # what :config opens files in ("nvim", "code --wait") # keys.toml: sections motions, normal, canvas, outline, note, visual, move, # connect, list, card, text (vim keys inside text), insert (typing: one key each, -# the others type themselves); commands by name (src/input/command.rs); -# "nothing" unbinds +# the others type themselves); commands by name; "nothing" unbinds leader = "" [normal] "x" = "delete-branch" @@ -463,23 +482,37 @@ leader = "" [canvas] "H" = "previous-document" -# themes/default.toml -accent = "cyan" -[palette] # terminal names, 0-255, or "#rrggbb" -red = 167 -orange = 208 -green = 108 # … yellow, teal, blue, purple, pink, brown, gray +# themes/night.toml +accent = "cyan" # screen colors: terminal names, 0-255, or "#rrggbb" +muted = "darkgray" [modes] insert = "green" visual = "magenta" -[styles.node.question] -color = "yellow" +[palette] # the colors nodes and lines are given, by name +red = 167 +orange = 208 # … yellow, green, teal, blue, purple, pink, brown, gray +[node] # every node, unless something says otherwise +border = "square" +[root] +border = "rounded" +[branch] # lines from a parent to its children +line = "solid" +arrows = "off" +color = "node" # the parent's own color +[link] # cross-links +line = "dashed" +arrows = "end" +[styles.node.question] # named node styles: s, and [tags] +color = "yellow" # a palette color border = "dashed" -[styles.link.blocks] +[styles.node.done] +color = "gray" +faded = true # the title in the muted color +[styles.link.blocks] # named line styles, for links color = "red" line = "dashed" arrows = "end" -label = "blocks" +label = "blocks" # shown on a link without a label of its own [tags] question = "question" # a #question node gets the "question" style ``` @@ -523,22 +556,24 @@ 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 (the config and data folders), settings (and `:set`); later keys_file, - │ theme_file (TOML) + ├─ config/ paths (the config and data folders), settings (config.toml, `:set`), keys_file + │ (keys.toml's template), theme_file (themes laid over the built-in one) ├─ 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 + (saving, reloading, locks, where you left off), configure (settings, keys and + themes applied; `:config`), 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. +The end-to-end tests are in `app/tests.rs` (with `app/tests/files.rs` and +`app/tests/config.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 diff --git a/README.md b/README.md index 86ba001..97dad44 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,23 @@ saves a second after each change, reloads a file another program changed A document open in another cumulus opens read-only. Each one reopens where you left it. +## Settings, keys and themes + +`:config` lists the three config files: `config.toml` (the settings), +`keys.toml` (your keys) and the theme's file. Enter opens one in your editor +(the `editor` setting, or `$VISUAL` / `$EDITOR`, or notepad / vi); the first +time, it's written with every option at its default behind a `#`, explained. +Take a `#` away, save, and cumulus applies it at once. Each file only needs +what it changes, and a mistake only skips its own line (the status bar says +what's wrong). + +- `:set name=value` changes a setting until cumulus quits (`:set name?` shows + it, Tab lists them). +- `:theme name` switches to `themes/name.toml`, laid over the built-in theme: + a theme can say just `accent = "magenta"`. +- The built-in theme has named styles (`s`), also put on by tags: + `#question` (yellow, dashed), `#important` (red, thick), `#done` (faded). + ## Keys The plan for the everyday keys (all of them are in DESIGN.md): diff --git a/examples/canvas.rs b/examples/canvas.rs index 1e2d5b1..fa5ad2f 100644 --- a/examples/canvas.rs +++ b/examples/canvas.rs @@ -15,9 +15,8 @@ use std::io; use cumulus::app::action::Message; use cumulus::app::startup::Opening; use cumulus::app::{App, load, run}; +use cumulus::config::Config; use cumulus::config::paths::Paths; -use cumulus::config::settings::Settings; -use cumulus::input::keymap::{DEFAULT_LEADER, Keymap}; fn main() -> io::Result<()> { let folder = std::env::temp_dir().join(format!("cumulus-playground-{}", std::process::id())); @@ -31,7 +30,8 @@ fn main() -> io::Result<()> { 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))?; + let config = Config::load(&paths); + run(App::new(paths, config, opening))?; println!("The playground's files are in {}", folder.display()); Ok(()) } diff --git a/src/app/action.rs b/src/app/action.rs index 122999b..6927b87 100644 --- a/src/app/action.rs +++ b/src/app/action.rs @@ -10,6 +10,7 @@ use super::panes::Arrange; use super::popup::{MenuFor, PickKind}; use super::text_edit::StartEdit; use crate::command::parse::SetArg; +use crate::config::ConfigFile; use crate::input::command::Command; use crate::input::key::Key; use crate::model::{Direction, EditOp, Group, NodeId, Title}; @@ -157,6 +158,10 @@ pub enum AppAction { SwitchDocument(DocumentChoice), // Files. + /// `:config`: the config files, to open one in the editor. + OpenConfig, + /// Open a config file in the editor (made first if it isn't there). + EditConfig(ConfigFile), /// Save (`:w`, `:x`, `:wa`). Save(SaveWhich), /// Open a document in the focused pane (`:e`), from a path as typed: diff --git a/src/app/calls.rs b/src/app/calls.rs index 774b97e..1178efa 100644 --- a/src/app/calls.rs +++ b/src/app/calls.rs @@ -5,7 +5,7 @@ use super::action::{Action, AppAction, CursorAfter, DocumentChoice, Message, Sav use super::commands::set_fold; use super::panes::Arrange; use super::popup::PickKind; -use crate::command::parse::{Call, TagChange}; +use crate::command::parse::{Call, SetArg, TagChange}; use crate::model::{CanvasPos, CanvasRect, EditOp, Inbox, Link, LinkKey, Node, NodeId, Title}; use crate::view::canvas::placement::{child_spot, first_child_spot, free_spot}; use crate::view::{ViewContext, ViewKind}; @@ -69,7 +69,8 @@ pub fn actions_for(call: Call, ctx: &ViewContext<'_>) -> Vec { Call::Jumps | Call::Marks | Call::Registers => { 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 part 8b of the build"), + Call::Theme(name) => vec![Action::App(AppAction::Set(SetArg::Value("theme".into(), name)))], + Call::Config => app(AppAction::OpenConfig), Call::Tidy(_) | Call::Align(_) | Call::BranchOut | Call::Export { .. } => { later("this one comes in v1.1") } diff --git a/src/app/configure.rs b/src/app/configure.rs new file mode 100644 index 0000000..7531671 --- /dev/null +++ b/src/app/configure.rs @@ -0,0 +1,190 @@ +//! Settings, keys and themes, put to work: `:set` and `:theme` while cumulus +//! runs, `:config` to open the config files in your editor, and each file +//! applied again when it's saved (they're looked at every second, with the +//! documents). + +use std::fs; +use std::io; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use super::App; +use super::action::Message; +use super::mode::Mode; +use super::popup::Popup; +use crate::command::parse::SetArg; +use crate::config::paths::Paths; +use crate::config::settings::Settings; +use crate::config::{ConfigFile, keys, theme_file}; +use crate::storage::stamp::Stamp; +use crate::storage::write_atomically; + +/// The config files as cumulus last read them. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(super) struct ConfigStamps { + settings: Option, + keys: Option, + theme: Option, +} + +impl ConfigStamps { + /// The files' stamps now, with the theme named `theme`. + pub(super) fn read(paths: &Paths, theme: &str) -> ConfigStamps { + let stamp = |file: ConfigFile| Stamp::of(&file.path(paths, theme)).ok().flatten(); + ConfigStamps { + settings: stamp(ConfigFile::Settings), + keys: stamp(ConfigFile::Keys), + theme: stamp(ConfigFile::Theme), + } + } +} + +impl App { + /// `:set` (and `:theme`): changes a setting, and puts it to work. + pub(super) fn set(&mut self, arg: &SetArg) -> Message { + let before = self.settings.clone(); + let text = match self.settings.apply(arg) { + Ok(text) => text, + Err(problem) => return Message::problem(problem), + }; + let name = self.settings.theme.clone(); + if name != before.theme + && name != theme_file::BUILT_IN + && !theme_file::path(&self.paths, &name).exists() + { + let problem = format!("there's no theme {name}: its file would be themes/{name}.toml"); + self.settings = before; + return Message::problem(problem); + } + let problems = self.settings_changed(&before); + if let Some(problems) = Message::joined(problems.into_iter().map(Message::problem).collect()) { + return problems; + } + if matches!(arg, SetArg::Show(_)) { Message::info(text) } else { Message::done(text) } + } + + /// Puts changed settings to work: the theme, the glyphs, the width + /// titles wrap at. Returns what was wrong with the theme's file. + fn settings_changed(&mut self, before: &Settings) -> Vec { + self.shared.view.max_node_width = self.settings.max_node_width; + if self.settings.theme == before.theme && self.settings.glyphs == before.glyphs { + return Vec::new(); + } + self.load_theme() + } + + /// The theme the settings name, read again from its file. + fn load_theme(&mut self) -> Vec { + let (theme, problems) = theme_file::load(&self.paths, &self.settings.theme, self.settings.glyphs); + self.shared.theme = theme; + problems + } + + /// Applies the config files saved since they were last read, and says + /// how that went. + pub(super) fn reread_config(&mut self) -> Vec { + let now = ConfigStamps::read(&self.paths, &self.settings.theme); + if now == self.config_stamps { + return Vec::new(); + } + let mut said = Vec::new(); + let mut theme_read = false; + if now.settings != self.config_stamps.settings { + let text = fs::read_to_string(ConfigFile::Settings.path(&self.paths, "")).unwrap_or_default(); + let (settings, mut problems) = Settings::from_toml(&text); + let before = std::mem::replace(&mut self.settings, settings); + theme_read = self.settings.theme != before.theme || self.settings.glyphs != before.glyphs; + problems.extend(self.settings_changed(&before)); + said.push(applied("config.toml", problems)); + } + if now.keys != self.config_stamps.keys { + let (keymap, problems) = keys(&self.paths); + self.keymap = keymap; + self.keys.reset(); + said.push(applied("keys.toml", problems)); + } + if now.theme != self.config_stamps.theme && !theme_read { + let problems = self.load_theme(); + said.push(applied(&format!("themes/{}.toml", self.settings.theme), problems)); + } + // A new theme has its own file to look at. + self.config_stamps = ConfigStamps::read(&self.paths, &self.settings.theme); + said + } + + /// `:config`: the config files, to pick one to open. + pub(super) fn open_config(&mut self) { + let theme = &self.settings.theme; + let files: Vec<(ConfigFile, String)> = ConfigFile::ALL + .into_iter() + .map(|file| { + let path = file.path(&self.paths, theme); + let name = path.strip_prefix(&self.paths.config).unwrap_or(&path); + let parts: Vec = + name.iter().map(|part| part.to_string_lossy().into_owned()).collect(); + (file, parts.join("/")) + }) + .collect(); + let popup = + Popup::config(&self.paths.config, &files, &editor_name(&self.editor()), &self.shared.theme); + self.shared.mode = Mode::Popup(popup); + } + + /// Opens a config file in the editor (the main loop hands the terminal + /// over), after writing it, every option commented out, if it isn't + /// there yet. + pub(super) fn edit_config(&mut self, file: ConfigFile) -> Option { + let path = file.path(&self.paths, &self.settings.theme); + if !path.exists() + && let Err(error) = write_atomically(&path, &file.template()) + { + return Some(Message::problem(format!("couldn't make {}: {error}", path.display()))); + } + self.edit_request = Some(path); + None + } + + /// The file the main loop should open in the editor, if any. + pub fn take_edit_request(&mut self) -> Option { + self.edit_request.take() + } + + /// The editor's command: the `editor` setting, `$VISUAL`, `$EDITOR`, or + /// the system's own (notepad on Windows, vi elsewhere). + pub fn editor(&self) -> String { + let set = |text: String| (!text.trim().is_empty()).then_some(text); + set(self.settings.editor.clone()) + .or_else(|| std::env::var("VISUAL").ok().and_then(set)) + .or_else(|| std::env::var("EDITOR").ok().and_then(set)) + .unwrap_or_else(|| if cfg!(windows) { "notepad" } else { "vi" }.to_string()) + } + + /// The editor is done, or couldn't start: the files are looked at again + /// right away, so what was saved applies now. + pub fn edited(&mut self, result: io::Result<()>) { + self.watched = Instant::now().checked_sub(Duration::from_secs(60)).unwrap_or(self.watched); + if let Err(error) = result { + let editor = self.editor(); + self.message = Some(Message::problem(format!( + "couldn't start {editor}: {error} · the editor setting picks another" + ))); + } + } +} + +/// How a config file went: applied, or what's wrong in it. +fn applied(file: &str, problems: Vec) -> Message { + Message::joined(problems.into_iter().map(Message::problem).collect()) + .unwrap_or_else(|| Message::done(format!("applied {file}"))) +} + +/// An editor's name, for the popup: `nvim` for `C:\Neovim\bin\nvim.exe -p`. +fn editor_name(command: &str) -> String { + let program = if std::path::Path::new(command).is_file() { + command + } else { + command.split_whitespace().next().unwrap_or(command) + }; + let path = std::path::Path::new(program); + path.file_stem().map_or_else(|| program.to_string(), |stem| stem.to_string_lossy().into_owned()) +} diff --git a/src/app/files.rs b/src/app/files.rs index f5a27ac..d6f6ab6 100644 --- a/src/app/files.rs +++ b/src/app/files.rs @@ -315,9 +315,10 @@ impl App { } /// Looks at every file for changes made outside cumulus, and lets - /// read-only documents be edited once the other cumulus lets go. + /// read-only documents be edited once the other cumulus lets go. The + /// config files saved since they were read apply again. fn watch(&mut self) -> bool { - let mut said = Vec::new(); + let mut said = self.reread_config(); for id in self.shared.docs.ids() { said.extend(self.look_at(id)); let open = self.shared.docs.get_mut(id); diff --git a/src/app/input.rs b/src/app/input.rs index c407eb0..3785fd8 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -9,6 +9,7 @@ use super::mode::Mode; use super::{App, calls, commands}; use crate::command::parse::{Sources, parse}; use crate::config::settings::Setting; +use crate::config::theme_file; use crate::input::command::{Bound, Command, help_for}; use crate::input::engine::{Outcome, Typed}; use crate::input::key::Key; @@ -143,7 +144,7 @@ impl App { 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()], + themes: theme_file::names(&self.paths), settings: Setting::ALL.iter().map(|setting| setting.name().to_string()).collect(), tags, folder: self.folder(), diff --git a/src/app/mod.rs b/src/app/mod.rs index b3d675c..eb08696 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -22,6 +22,7 @@ pub mod action; pub mod buffer; pub mod calls; pub mod commands; +mod configure; pub mod documents; mod files; mod input; @@ -35,6 +36,7 @@ mod terminal; pub mod text_edit; mod update; +use std::path::PathBuf; use std::time::Instant; use ratatui::layout::Rect; @@ -43,6 +45,7 @@ pub use files::{Loaded, OpenError, create, load}; pub use terminal::run; use crate::command::history::History as CommandHistory; +use crate::config::Config; use crate::config::paths::Paths; use crate::config::settings::Settings; use crate::input::bindings::Context; @@ -86,6 +89,11 @@ pub struct App { dragging: Option, /// When the files were last checked for changes made outside cumulus. watched: Instant, + /// The config files as they were last read, to apply them again when + /// they change. + config_stamps: configure::ConfigStamps, + /// A file `:config` wants opened in the editor: the main loop does it. + edit_request: Option, quit: bool, } @@ -147,11 +155,13 @@ struct Screen { } 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 { + /// cumulus with its config, and the documents of `opening` open, the + /// first one shown. + pub fn new(paths: Paths, config: Config, opening: Opening) -> App { let memory = Memory::load(&paths.memory()); let Opening { first, others, message } = opening; - let theme = Theme { glyphs: settings.glyphs, ..Theme::default() }; + let Config { settings, keymap, theme, problems } = config; + let config_stamps = configure::ConfigStamps::read(&paths, &settings.theme); 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); @@ -170,13 +180,16 @@ impl App { screen: Screen::default(), dragging: None, watched: Instant::now(), + config_stamps, + edit_request: None, 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.into_iter().chain(first_said).collect(); + let config_said = problems.into_iter().map(Message::problem); + let mut said: Vec = config_said.chain(message).chain(first_said).collect(); for loaded in others { said.extend(app.add_document(loaded).1); } diff --git a/src/app/popup.rs b/src/app/popup.rs index f9d4b86..1eecae9 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -9,7 +9,7 @@ //! the node it opened on. The card shows the cursor's body, and edits it in //! place. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use ratatui::Frame; use ratatui::layout::{Position, Rect}; @@ -24,6 +24,7 @@ use super::registers::Registers; use super::text_edit::{EditAt, StartEdit, TextEdit}; use crate::command::parse::border; use crate::command::registry::{BORDERS, SPECS}; +use crate::config::ConfigFile; use crate::input::bindings::Context; use crate::input::command::{Bound, Command, Operator, Target, TargetBound, help_for}; use crate::input::engine::Typed; @@ -40,7 +41,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. +/// How wide the question about a file changed outside cumulus, and the +/// config files, are at most. const CONFLICT_WIDTH: u16 = 64; /// The widest the keys column of the help gets. const KEYS_WIDTH: usize = 12; @@ -344,11 +346,6 @@ impl Popup { (format!("border · {}", node.title()), values, rows) } PickKind::Style => { - if theme.node_styles.is_empty() { - return Err(Message::info( - "this theme has no named styles yet: theme files come in part 8b of the build", - )); - } let mut values = vec![Pick::Style(None)]; values.extend(theme.node_styles.keys().cloned().map(|name| Pick::Style(Some(name)))); let rows = values @@ -421,6 +418,23 @@ impl Popup { Popup::Card(Card { node, edit: None, view: CardView::default() }) } + /// The config files (`:config`), under the folder they're in: Enter + /// opens one in `editor`. + pub fn config(folder: &Path, files: &[(ConfigFile, String)], editor: &str, theme: &Theme) -> Popup { + let muted = Style::new().fg(theme.muted); + let width = files.iter().map(|(_, name)| name.width()).max().unwrap_or(0); + let mut rows = vec![ListRow::heading(&folder.display().to_string(), muted)]; + let mut runs = vec![Run::Nothing]; + for (file, name) in files { + let spans = vec![Span::raw(format!("{name:width$} ")), Span::styled(file.about(), muted)]; + rows.push(ListRow::new(spans, name.as_str())); + runs.push(Run::App(AppAction::EditConfig(*file))); + } + let footer = format!("Enter: open it in {editor} · Esc: close"); + let list = ListPopup::new("config", footer, rows, Place::Center { width: CONFLICT_WIDTH }); + Popup::Menu(Runs { list, runs }) + } + /// 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 { diff --git a/src/app/popup/tests.rs b/src/app/popup/tests.rs index db20570..1dfe447 100644 --- a/src/app/popup/tests.rs +++ b/src/app/popup/tests.rs @@ -104,14 +104,20 @@ fn pickers_set_what_is_picked() { [app(AppAction::LeaveMode), Action::edit(EditOp::SetBorder { id: g.soil, border: dashed })] ); - let no_styles = Popup::picker(PickKind::Style, &ctx, &[], 0).unwrap_err(); + // The theme's own named styles. + let mut styles = pick(PickKind::Style); + search(&mut styles, "imp", &ctx); + let important = StyleName::parse("important").ok(); assert_eq!( - no_styles.text, - "this theme has no named styles yet: theme files come in part 8b of the build" + enter(&mut styles, &ctx), + [app(AppAction::LeaveMode), Action::edit(EditOp::SetStyle { id: g.soil, style: important })] ); // Tags go on and off, and the picker stays open; a new one is added. + // The document's tags come with the theme's (done, important, + // question), in order: wood is last. let mut tags = pick(PickKind::Tags); + tags.command(&typed(Command::Move(crate::input::command::Motion::Last)), &mut Registers::default(), &ctx); let wood = Tag::parse("wood").unwrap(); let on = Action::edit(EditOp::Tag { id: g.soil, add: vec![wood.clone()], remove: Vec::new() }); let off = Action::edit(EditOp::Tag { id: g.soil, add: Vec::new(), remove: vec![wood] }); diff --git a/src/app/terminal.rs b/src/app/terminal.rs index c81a49d..d3292fb 100644 --- a/src/app/terminal.rs +++ b/src/app/terminal.rs @@ -1,15 +1,21 @@ //! 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. +//! at the end, after a crash too. For `:config`, it hands the terminal over +//! to your editor until it's done. use std::io; +use std::path::Path; +use std::process::Command; 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 ratatui::crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; use super::App; use crate::input::key::Key; @@ -78,7 +84,51 @@ fn event_loop(app: &mut App, terminal: &mut DefaultTerminal) -> io::Result<()> { _ => {} } } + if let Some(path) = app.take_edit_request() { + let result = edit(terminal, &app.editor(), &path, mouse); + app.edited(result); + draw = true; + } draw |= app.tick(Instant::now()) || app.is_waiting(); } Ok(()) } + +/// Opens a file in the editor, with the terminal handed over to it until +/// it's done. Returns whether it could start. +fn edit(terminal: &mut DefaultTerminal, editor: &str, path: &Path, mouse: bool) -> io::Result<()> { + execute!(io::stdout(), DisableMouseCapture, SetCursorStyle::DefaultUserShape, LeaveAlternateScreen)?; + disable_raw_mode()?; + let ran = run_editor(editor, path); + enable_raw_mode()?; + execute!(io::stdout(), EnterAlternateScreen)?; + if mouse { + execute!(io::stdout(), EnableMouseCapture)?; + } + // Everything on screen is the editor's now: draw it all again. + terminal.clear()?; + ran +} + +/// Runs the editor on a file and waits for it. The command is a program +/// with what it takes before the file (`code --wait`), or a program's whole +/// path, spaces and all. +fn run_editor(editor: &str, path: &Path) -> io::Result<()> { + let (program, args): (&str, Vec<&str>) = if Path::new(editor).is_file() { + (editor, Vec::new()) + } else { + let mut words = editor.split_whitespace(); + (words.next().unwrap_or(editor), words.collect()) + }; + let status = Command::new(program).args(&args).arg(path).status(); + match status { + // Windows only starts .exe files by name; an editor that's a script + // (code is code.cmd) needs the command interpreter. + Err(error) if cfg!(windows) && error.kind() == io::ErrorKind::NotFound => { + let status = Command::new("cmd").arg("/C").arg(program).args(&args).arg(path).status()?; + // 9009: the interpreter didn't find it either. + if status.code() == Some(9009) { Err(error) } else { Ok(()) } + } + status => status.map(|_| ()), + } +} diff --git a/src/app/tests.rs b/src/app/tests.rs index 47a46e9..2c9b4b8 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -1,7 +1,9 @@ //! 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`. +//! works, checked end to end. The files have their own tests in +//! `tests/files.rs`, and the config folder in `tests/config.rs`. +mod config; mod files; use std::path::PathBuf; @@ -50,8 +52,9 @@ impl 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); + // The config folder, as the test left it. + let paths = Harness::paths(&folder); + let app = App::new(paths.clone(), Config::load(&paths), opening); let terminal = Terminal::new(TestBackend::new(100, 32)).unwrap(); let mut harness = Harness { app, terminal, folder, clock: Instant::now() }; harness.draw(); @@ -190,6 +193,18 @@ impl Harness { std::fs::read_to_string(self.path(name)).unwrap_or_default() } + /// The text color of the cell at a place on screen. + fn color_at(&self, (col, row): (u16, u16)) -> ratatui::style::Color { + self.terminal.backend().buffer()[(col, row)].fg + } + + /// Writes a file in the config folder (making the folder first). + fn write_config(&self, name: &str, text: &str) { + let path = Harness::paths(&self.folder).config.join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + } + /// The terminal's cursor, when it shows. fn text_cursor(&self) -> Option<(u16, u16)> { let backend = self.terminal.backend(); @@ -679,9 +694,10 @@ fn more_keys_across_views() { assert_eq!(h.label(), "HELP"); assert!(h.screen().contains("/fold")); h.type_keys(""); - // s: this theme has no named styles. - h.type_keys("s"); - assert_eq!(h.message(), "this theme has no named styles yet: theme files come in part 8b of the build"); + // s: the theme's named styles. + h.type_keys("s/question"); + let node = h.doc().node(h.app.current().cursor()).unwrap(); + assert_eq!(node.look.style.as_ref().map(|style| style.as_str()), Some("question")); // w: no key hints: which-key and the hint line go. assert!(h.rows().last().unwrap().contains("? help")); h.type_keys("w"); diff --git a/src/app/tests/config.rs b/src/app/tests/config.rs new file mode 100644 index 0000000..50dfac1 --- /dev/null +++ b/src/app/tests/config.rs @@ -0,0 +1,109 @@ +//! The config folder, end to end: the built-in styles and their tags, +//! config.toml, keys.toml and themes applied when they're saved, `:theme`, +//! and `:config`. + +use ratatui::style::Color; + +use super::*; +use crate::config::ConfigFile; + +#[test] +fn the_built_in_styles_come_with_their_tags() { + let mut h = Harness::new(); + h.type_keys("gl:tag +questiongg"); + let (col, row) = h.find("Soil"); + let border = h.rows()[usize::from(row) - 1].chars().nth(usize::from(col)).unwrap(); + assert_eq!(border, '╌', "#question: a dashed border\n{}", h.screen()); + assert_eq!(h.color_at((col - 2, row)), Color::Indexed(178), "in yellow"); + // #done fades the title back (once the cursor is elsewhere). + h.type_keys("glgjgj:tag +donegg"); + assert_eq!(h.color_at(h.find("Watering")), Color::DarkGray); +} + +#[test] +fn config_toml_applies_when_saved() { + let mut h = Harness::new(); + h.write_config("config.toml", "max_node_width = 10\nglyphs = \"ascii\"\n"); + h.wait(2); + assert_eq!(h.message(), "applied config.toml"); + let boxes = |h: &Harness| h.screen().chars().any(|c| ('\u{2500}'..='\u{257f}').contains(&c)); + assert!(!boxes(&h), "{}", h.screen()); + assert!(!h.screen().contains("Rain barrel"), "titles wrap at 10"); + // A mistake keeps that setting at its default, and says so; what the + // file no longer says is back to its default too. + h.write_config("config.toml", "mouse = \"maybe\"\n"); + h.wait(2); + assert_eq!(h.message(), "config.toml: mouse is on or off"); + assert!(boxes(&h) && h.screen().contains("Rain barrel")); +} + +#[test] +fn config_problems_show_at_startup() { + let folder = TempDir::new(); + std::fs::write(folder.path().join("garden.cum"), GARDEN).unwrap(); + let config = Harness::paths(&folder).config; + std::fs::create_dir_all(&config).unwrap(); + std::fs::write(config.join("config.toml"), "colour = 1\n").unwrap(); + let h = Harness::open(folder, &["garden.cum"]); + assert!(h.message().starts_with("config.toml: there's no setting called colour"), "{}", h.message()); +} + +#[test] +fn keys_toml_applies_when_saved() { + let mut h = Harness::new(); + h.write_config("keys.toml", "[normal]\n\"Q\" = \"frobnicate\"\n"); + h.wait(2); + assert_eq!(h.message(), "keys.toml: [normal] \"Q\": there's no command \"frobnicate\""); + h.write_config("keys.toml", "[normal]\n\"Q\" = \"close\"\n"); + h.wait(2); + assert_eq!(h.message(), "applied keys.toml"); + h.type_keys("Q"); + assert!(h.app.is_done(), "Q closes the last pane: that quits"); +} + +#[test] +fn themes_switch_and_apply_when_saved() { + let mut h = Harness::new(); + h.write_config("themes/night.toml", "accent = \"magenta\"\n[styles.node.later]\nborder = \"double\"\n"); + h.type_keys(":theme night"); + assert_eq!(h.message(), "theme = night"); + assert_eq!(h.app.shared.theme.accent, Color::Magenta); + h.type_keys("s"); + assert!(h.screen().contains("later"), "its style is in the picker:\n{}", h.screen()); + h.type_keys(""); + h.write_config("themes/night.toml", "accent = \"blue\"\n"); + h.wait(2); + assert_eq!(h.message(), "applied themes/night.toml"); + assert_eq!(h.app.shared.theme.accent, Color::Blue); + h.type_keys(":set theme=nope"); + assert_eq!(h.message(), "there's no theme nope: its file would be themes/nope.toml"); + assert_eq!(h.app.shared.theme.accent, Color::Blue, "still night"); + h.type_keys(":theme default"); + assert_eq!(h.app.shared.theme.accent, Color::Cyan); +} + +#[test] +fn config_opens_its_files_in_the_editor() { + let mut h = Harness::new(); + h.type_keys(":set editor=myeditor:config"); + assert_eq!(h.label(), "MENU"); + let screen = h.screen(); + for text in ["config.toml", "keys.toml", "themes/default.toml", "Enter: open it in myeditor"] { + assert!(screen.contains(text), "{text}:\n{screen}"); + } + let paths = Harness::paths(&h.folder); + // Enter: config.toml, made first, every setting commented out. + h.type_keys(""); + assert_eq!(h.label(), "NORMAL"); + let settings = ConfigFile::Settings.path(&paths, "default"); + assert_eq!(h.app.take_edit_request(), Some(settings.clone())); + assert!(std::fs::read_to_string(&settings).unwrap().contains("# autosave = true")); + // The theme's file, the same way. + h.type_keys(":configjj"); + let theme = ConfigFile::Theme.path(&paths, "default"); + assert_eq!(h.app.take_edit_request(), Some(theme.clone())); + assert!(std::fs::read_to_string(&theme).unwrap().contains("# accent = \"cyan\"")); + // An editor that doesn't start says so. + h.app.edited(Err(std::io::Error::other("not found"))); + assert_eq!(h.message(), "couldn't start myeditor: not found · the editor setting picks another"); +} diff --git a/src/app/update.rs b/src/app/update.rs index 50d734c..52f3c2e 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -12,7 +12,6 @@ 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; @@ -184,6 +183,8 @@ impl App { } } AppAction::SwitchDocument(choice) => return self.switch_document(choice), + AppAction::OpenConfig => self.open_config(), + AppAction::EditConfig(file) => return self.edit_config(file), AppAction::Save(which) => return self.write(which), AppAction::Edit { path, force } => return self.edit(path, force), AppAction::NewDocument(title) => return self.new_document(title), @@ -261,16 +262,4 @@ impl App { .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/config/keys_file.rs b/src/config/keys_file.rs new file mode 100644 index 0000000..ea04fe8 --- /dev/null +++ b/src/config/keys_file.rs @@ -0,0 +1,63 @@ +//! keys.toml: your keys, on top of cumulus's own. Reading it is the +//! keymap's job ([`Keymap::from_toml`](crate::input::keymap::Keymap::from_toml)); +//! this is the file as `:config` first writes it, with examples and every +//! command a key can run, by name. + +use super::comment; +use crate::input::bindings::Context; +use crate::input::command::COMMANDS; + +/// keys.toml as `:config` first writes it: how it works, examples to take +/// the `#` from, and every command's name. +pub fn template() -> String { + let sections: Vec<&str> = Context::ALL.iter().map(|context| context.name()).collect(); + let mut text = String::from( + "# Your keys, on top of cumulus's own. Each section is where its keys work,\n\ + # and each line binds keys, in vim's notation, to a command by its name\n\ + # (they're all at the end). \"nothing\" takes a key away. Take the # away\n\ + # from a line to use it. cumulus applies this file when you save it, and\n\ + # ? in cumulus shows every key.\n\ + #\n", + ); + text.push_str(&comment(&format!( + "Sections: {}. motions: moving, in normal, visual and connect modes; normal: every view; \ + list: the popups' lists; card: the K card; text: vim keys inside a text; insert: typing \ + (one key each: the others type themselves).", + sections.join(", ") + ))); + text.push_str( + "\n# The key stands for.\n\ + # leader = \"\"\n\ + \n\ + # [normal]\n\ + # \"x\" = \"delete-branch\"\n\ + # \"x\" = \"nothing\"\n\ + \n\ + # [canvas]\n\ + # \"H\" = \"previous-document\"\n\ + # \"L\" = \"next-document\"\n\ + \n\ + # Every command, by name:\n", + ); + let width = COMMANDS.iter().map(|info| info.name.len()).max().unwrap_or(0); + for info in COMMANDS { + text.push_str(&format!("# {:width$} {}\n", info.name, info.help)); + } + text +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::settings::uncomment; + use crate::input::keymap::Keymap; + + #[test] + fn the_examples_in_the_template_work() { + let template = template(); + assert_eq!(Keymap::from_toml(&template).1, Vec::::new(), "all commented out"); + let (_, problems) = Keymap::from_toml(&uncomment(&template)); + assert_eq!(problems, Vec::::new(), "{template}"); + assert!(template.contains("# delete-branch") && template.contains("# show-note")); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index b9afc3d..ea9c4cc 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,6 +1,178 @@ -//! 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. +//! Where cumulus keeps its files ([`paths`]), and the files in its config +//! folder: config.toml ([`settings`]), keys.toml ([`keys_file`]) and themes +//! ([`theme_file`]). Each one only states what it changes; a missing file +//! changes nothing, and a mistake only drops the line it's on, with a +//! message saying what's wrong. cumulus applies a file again when it's +//! saved. +pub mod keys_file; pub mod paths; pub mod settings; +pub mod theme_file; + +use std::fs; +use std::path::PathBuf; + +use paths::Paths; +use settings::Settings; + +use crate::input::keymap::{DEFAULT_LEADER, Keymap}; +use crate::ui::theme::Theme; + +/// One of the config folder's files. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfigFile { + /// config.toml: the settings. + Settings, + /// keys.toml: your keys. + Keys, + /// The theme's file, `themes/.toml`. + Theme, +} + +impl ConfigFile { + pub const ALL: [ConfigFile; 3] = [ConfigFile::Settings, ConfigFile::Keys, ConfigFile::Theme]; + + /// Where it is, for the theme named `theme`. + pub fn path(self, paths: &Paths, theme: &str) -> PathBuf { + match self { + ConfigFile::Settings => paths.config.join("config.toml"), + ConfigFile::Keys => paths.config.join("keys.toml"), + ConfigFile::Theme => theme_file::path(paths, theme), + } + } + + /// What's in it, in a few words. + pub fn about(self) -> &'static str { + match self { + ConfigFile::Settings => "the settings", + ConfigFile::Keys => "your keys", + ConfigFile::Theme => "the theme", + } + } + + /// The file as `:config` first writes it: everything it can hold, each + /// at its default, commented out. + pub fn template(self) -> String { + match self { + ConfigFile::Settings => Settings::template(), + ConfigFile::Keys => keys_file::template(), + ConfigFile::Theme => theme_file::template(&Theme::default()), + } + } +} + +/// Everything the config folder sets. +#[derive(Debug, Clone)] +pub struct Config { + pub settings: Settings, + pub keymap: Keymap, + pub theme: Theme, + /// What was wrong in the files, in words. + pub problems: Vec, +} + +impl Default for Config { + /// No files: everything as cumulus has it. + fn default() -> Config { + Config { + settings: Settings::default(), + keymap: Keymap::new(DEFAULT_LEADER), + theme: Theme::default(), + problems: Vec::new(), + } + } +} + +impl Config { + /// Reads the config folder: config.toml, keys.toml, and the theme + /// config.toml names. + pub fn load(paths: &Paths) -> Config { + let (settings, mut problems) = read(&ConfigFile::Settings.path(paths, "")) + .map_or_else(|| (Settings::default(), Vec::new()), |text| Settings::from_toml(&text)); + let (keymap, keys_problems) = keys(paths); + problems.extend(keys_problems); + let (theme, theme_problems) = theme_file::load(paths, &settings.theme, settings.glyphs); + problems.extend(theme_problems); + Config { settings, keymap, theme, problems } + } +} + +/// The keys: keys.toml on top of the defaults. +pub fn keys(paths: &Paths) -> (Keymap, Vec) { + match read(&ConfigFile::Keys.path(paths, "")) { + Some(text) => { + let (keymap, problems) = Keymap::from_toml(&text); + (keymap, problems.into_iter().map(|problem| format!("keys.toml: {problem}")).collect()) + } + None => (Keymap::new(DEFAULT_LEADER), Vec::new()), + } +} + +/// A config file's text, when there's a file. +fn read(path: &std::path::Path) -> Option { + fs::read_to_string(path).ok() +} + +/// Text as comment lines for a config file: `# ` before each, and at most +/// 76 columns. +pub(crate) fn comment(text: &str) -> String { + let mut lines: Vec = Vec::new(); + for word in text.split_whitespace() { + match lines.last_mut() { + Some(line) if line.len() + 1 + word.len() <= 76 => { + line.push(' '); + line.push_str(word); + } + _ => lines.push(format!("# {word}")), + } + } + lines.iter().map(|line| format!("{line}\n")).collect() +} + +/// Text that doesn't read as TOML, in one line: where it went wrong, and +/// what's wrong there. +pub fn toml_problem(text: &str, error: &toml::de::Error) -> String { + match error.span() { + Some(span) => { + let before = text.char_indices().take_while(|&(at, _)| at < span.start); + let line = before.filter(|&(_, c)| c == '\n').count() + 1; + format!("line {line}: {}", error.message().trim()) + } + None => error.message().trim().to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::test_support::TempDir; + use crate::ui::glyphs::Glyphs; + + #[test] + fn the_folder_sets_settings_keys_and_theme() { + let folder = TempDir::new(); + let paths = Paths::inside(folder.path()); + let config = Config::load(&paths); + assert!(config.problems.is_empty()); + assert_eq!(config.settings, Settings::default()); + + fs::create_dir_all(paths.config.join("themes")).unwrap(); + fs::write(paths.config.join("config.toml"), "theme = \"night\"\nglyphs = \"ascii\"\n").unwrap(); + fs::write(paths.config.join("keys.toml"), "[normal]\n\"Q\" = \"close\"\n\"x\" = \"frobnicate\"\n") + .unwrap(); + fs::write(paths.config.join("themes").join("night.toml"), "accent = \"magenta\"\n").unwrap(); + let config = Config::load(&paths); + assert_eq!(config.theme.accent, ratatui::style::Color::Magenta); + assert_eq!(config.theme.glyphs, Glyphs::Ascii, "the theme draws with the glyphs setting"); + assert_eq!(config.problems.len(), 1, "{:?}", config.problems); + assert!(config.problems[0].contains("frobnicate"), "{:?}", config.problems); + } + + #[test] + fn comments_wrap_at_76_columns() { + let text = comment(&"word ".repeat(30)); + assert!(text.lines().all(|line| line.starts_with("# ") && line.len() <= 76), "{text}"); + assert_eq!(text.split_whitespace().filter(|word| *word == "word").count(), 30); + } +} diff --git a/src/config/settings.rs b/src/config/settings.rs index 2eacf74..a6cf30b 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -1,7 +1,8 @@ -//! 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.) +//! Every setting, with its default, and config.toml, where you change them +//! for good. `: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!`. use std::time::Duration; @@ -12,6 +13,10 @@ use crate::ui::glyphs::Glyphs; /// The settings, as they are now. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Settings { + /// The theme: `default` (built in), or a file in the themes folder. + pub theme: String, + /// The document `cumulus` looks for at startup, without `.cum`. + pub default_document: String, /// Save about a second after each change; off, `:w` saves (vim style). pub autosave: bool, /// Take the mouse: clicks, drags and the wheel. @@ -26,13 +31,16 @@ pub struct Settings { 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, + /// The program `:config` opens files in; empty: `$VISUAL`, `$EDITOR`, + /// or the system's own. + pub editor: String, } impl Default for Settings { fn default() -> Settings { Settings { + theme: "default".to_string(), + default_document: "ideas".to_string(), autosave: true, mouse: true, which_key: true, @@ -40,7 +48,7 @@ impl Default for Settings { max_node_width: crate::view::canvas::boxes::DEFAULT_MAX_WIDTH, glyphs: Glyphs::Unicode, completion_popup: PopupMode::AsYouType, - default_document: "ideas".to_string(), + editor: String::new(), } } } @@ -48,6 +56,8 @@ impl Default for Settings { /// One of the settings, by name. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Setting { + Theme, + DefaultDocument, Autosave, Mouse, WhichKey, @@ -55,7 +65,7 @@ pub enum Setting { MaxNodeWidth, Glyphs, CompletionPopup, - DefaultDocument, + Editor, } /// The widths `max_node_width` can be. @@ -64,7 +74,10 @@ const WIDTHS: std::ops::RangeInclusive = 10..=200; const LONGEST_DELAY: Duration = Duration::from_secs(5); impl Setting { - pub const ALL: [Setting; 8] = [ + /// Every setting, in the order config.toml lists them. + pub const ALL: [Setting; 10] = [ + Setting::Theme, + Setting::DefaultDocument, Setting::Autosave, Setting::Mouse, Setting::WhichKey, @@ -72,12 +85,14 @@ impl Setting { Setting::MaxNodeWidth, Setting::Glyphs, Setting::CompletionPopup, - Setting::DefaultDocument, + Setting::Editor, ]; /// Its name, as config.toml and `:set` write it. pub fn name(self) -> &'static str { match self { + Setting::Theme => "theme", + Setting::DefaultDocument => "default_document", Setting::Autosave => "autosave", Setting::Mouse => "mouse", Setting::WhichKey => "which_key", @@ -85,7 +100,7 @@ impl Setting { Setting::MaxNodeWidth => "max_node_width", Setting::Glyphs => "glyphs", Setting::CompletionPopup => "completion_popup", - Setting::DefaultDocument => "default_document", + Setting::Editor => "editor", } } @@ -94,6 +109,33 @@ impl Setting { Setting::ALL.into_iter().find(|setting| setting.name() == name) } + /// What it does, for config.toml: a line or two. + pub fn help(self) -> &'static str { + match self { + Setting::Theme => "The theme: \"default\" (built in), or a file in the themes folder.", + Setting::DefaultDocument => { + "The document `cumulus` opens when you name none: this name with .cum,\n\ + in the folder you start in, or else in your home folder." + } + Setting::Autosave => "Save a second after each change. Off: :w saves, as in vim.", + Setting::Mouse => "Use the mouse. Off, the terminal keeps it (to select text, say).", + Setting::WhichKey => "Key hints: what can follow a key, and the hint line at the bottom.", + Setting::WhichKeyDelay => { + "How long a key waits before its hints show (the leader's show at once)." + } + Setting::MaxNodeWidth => "How wide titles get on the canvas before they wrap: 10 to 200.", + Setting::Glyphs => "\"unicode\" draws with box-drawing characters; \"ascii\" with + - |.", + Setting::CompletionPopup => { + "When the command line lists what can finish a word: \"as-you-type\",\n\ + or \"on-tab\" (after Tab, like vim's wildmenu)." + } + Setting::Editor => { + "The program :config opens files in, like \"nvim\" or \"code --wait\".\n\ + Empty: $VISUAL, then $EDITOR, then notepad on Windows and vi elsewhere." + } + } + } + /// 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) @@ -102,6 +144,8 @@ impl Setting { /// The values it takes, for a message when one doesn't fit. fn takes(self) -> String { match self { + Setting::Theme => "a theme's name: letters, digits, - and _".to_string(), + Setting::DefaultDocument => "a document's name, without .cum".to_string(), 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()), @@ -110,7 +154,7 @@ impl Setting { let values: Vec<&str> = PopupMode::VALUES.iter().map(|(name, _)| *name).collect(); values.join(" or ") } - Setting::DefaultDocument => "a document's name, without .cum".to_string(), + Setting::Editor => "a program, with what it takes before the file".to_string(), } } } @@ -120,6 +164,8 @@ impl Settings { pub fn get(&self, setting: Setting) -> String { let switch = |on: bool| if on { "on" } else { "off" }.to_string(); match setting { + Setting::Theme => self.theme.clone(), + Setting::DefaultDocument => self.default_document.clone(), Setting::Autosave => switch(self.autosave), Setting::Mouse => switch(self.mouse), Setting::WhichKey => switch(self.which_key), @@ -134,7 +180,17 @@ impl Settings { .find(|(_, mode)| *mode == self.completion_popup) .map_or("?", |(name, _)| *name) .to_string(), - Setting::DefaultDocument => self.default_document.clone(), + Setting::Editor => self.editor.clone(), + } + } + + /// A setting's value as config.toml writes it: `true`, `30`, `"ascii"`. + fn toml_value(&self, setting: Setting) -> String { + match setting { + Setting::Autosave | Setting::Mouse | Setting::WhichKey => (self.get(setting) == "on").to_string(), + Setting::MaxNodeWidth => self.get(setting), + // Debug quotes it, escaping as TOML does. + _ => format!("{:?}", self.get(setting)), } } @@ -165,6 +221,8 @@ impl Settings { fn reset(&mut self, setting: Setting) { let default = Settings::default(); match setting { + Setting::Theme => self.theme = default.theme, + Setting::DefaultDocument => self.default_document = default.default_document, Setting::Autosave => self.autosave = default.autosave, Setting::Mouse => self.mouse = default.mouse, Setting::WhichKey => self.which_key = default.which_key, @@ -172,7 +230,7 @@ impl Settings { 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, + Setting::Editor => self.editor = default.editor, } } @@ -190,7 +248,22 @@ impl Settings { /// `:set name=value`. fn set(&mut self, setting: Setting, value: &str) -> Result<(), String> { let wrong = || format!("{} is {}", setting.name(), setting.takes()); + let value = value.trim(); match setting { + Setting::Theme => { + let fits = |c: char| c.is_alphanumeric() || c == '-' || c == '_'; + if value.is_empty() || !value.chars().all(fits) { + return Err(wrong()); + } + self.theme = value.to_string(); + } + Setting::DefaultDocument => { + let name = value.trim_end_matches(".cum"); + if name.is_empty() || name.contains(['/', '\\']) { + return Err(wrong()); + } + self.default_document = name.to_string(); + } Setting::Autosave | Setting::Mouse | Setting::WhichKey => { let on = match value { "on" | "true" | "yes" => true, @@ -215,16 +288,67 @@ impl Settings { }; } 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(); - } + Setting::Editor => self.editor = value.to_string(), } Ok(()) } + + /// The settings config.toml asks for: each one it names, on top of the + /// defaults. A mistake only drops its own line, and is reported in + /// words. + pub fn from_toml(text: &str) -> (Settings, Vec) { + let mut settings = Settings::default(); + let table: toml::Table = match toml::from_str(text) { + Ok(table) => table, + Err(error) => { + return (settings, vec![format!("config.toml: {}", super::toml_problem(text, &error))]); + } + }; + let mut problems = Vec::new(); + for (name, value) in &table { + if Setting::named(name).is_none() { + let names: Vec<&str> = Setting::ALL.iter().map(|setting| setting.name()).collect(); + problems.push(format!( + "config.toml: there's no setting called {name} (there's {})", + names.join(", ") + )); + continue; + } + let value = match value { + toml::Value::String(text) => text.clone(), + toml::Value::Integer(number) => number.to_string(), + toml::Value::Float(number) => number.to_string(), + toml::Value::Boolean(on) => on.to_string(), + other => { + problems.push(format!("config.toml: {name} = {other} isn't a value it takes")); + continue; + } + }; + if let Err(problem) = settings.apply(&SetArg::Value(name.clone(), value)) { + problems.push(format!("config.toml: {problem}")); + } + } + (settings, problems) + } + + /// config.toml as `:config` first writes it: every setting with what it + /// does, at its default, commented out. + pub fn template() -> String { + let defaults = Settings::default(); + let mut text = String::from( + "# cumulus settings. Each one is here at its default, behind a #: take\n\ + # the # away to change it. cumulus applies this file when you save it.\n\ + # (`:set name=value` changes a setting until cumulus quits.)\n", + ); + for setting in Setting::ALL { + text.push('\n'); + for line in setting.help().lines() { + text.push_str(&format!("# {line}\n")); + } + text.push_str(&format!("# {} = {}\n", setting.name(), defaults.toml_value(setting))); + } + text + } } /// What a `:set` does to a setting. @@ -252,6 +376,20 @@ pub fn parse_duration(text: &str) -> Option { text.parse().ok().map(Duration::from_millis) } +/// Takes the `#` away from the lines that hold a setting (or a key, or a +/// theme value): a template with every one of them on. +#[cfg(test)] +pub(crate) fn uncomment(template: &str) -> String { + template + .lines() + .map(|line| match line.strip_prefix("# ") { + Some(rest) if rest.contains(" = ") || rest.starts_with('[') => rest, + _ => line, + }) + .collect::>() + .join("\n") +} + #[cfg(test)] mod tests { use super::*; @@ -295,6 +433,10 @@ mod tests { set(&mut settings, SetArg::Value(name("which_key_delay"), "1s".into())), Ok("which_key_delay = 1000ms".into()) ); + assert_eq!( + set(&mut settings, SetArg::Value(name("theme"), "../x".into())), + Err("theme is a theme's name: letters, digits, - and _".into()) + ); } #[test] @@ -304,4 +446,40 @@ mod tests { assert_eq!(parse_duration("250"), Some(Duration::from_millis(250))); assert_eq!(parse_duration("soon"), None); } + + #[test] + fn config_toml_sets_what_it_names_and_reports_mistakes() { + let (settings, problems) = Settings::from_toml( + "autosave = false\nmax_node_width = 40\nglyphs = \"ascii\"\nwhich_key_delay = 500\n\ + mouse = \"maybe\"\ncolour = \"red\"\n", + ); + assert!(!settings.autosave); + assert_eq!((settings.max_node_width, settings.glyphs), (40, Glyphs::Ascii)); + assert_eq!(settings.which_key_delay, Duration::from_millis(500), "a number is milliseconds"); + assert!(settings.mouse, "a mistake keeps the default"); + assert_eq!(problems.len(), 2, "{problems:?}"); + assert_eq!( + problems[0], + "config.toml: there's no setting called colour (there's theme, default_document, autosave, mouse, which_key, which_key_delay, max_node_width, glyphs, completion_popup, editor)" + ); + assert_eq!(problems[1], "config.toml: mouse is on or off"); + + let (_, problems) = Settings::from_toml("autosave = \n"); + assert!(problems[0].starts_with("config.toml: line 1: "), "{problems:?}"); + } + + #[test] + fn the_template_holds_every_setting_at_its_default() { + let template = Settings::template(); + assert_eq!(Settings::from_toml(&template), (Settings::default(), Vec::new()), "all commented out"); + let (settings, problems) = Settings::from_toml(&uncomment(&template)); + assert_eq!( + (settings, problems), + (Settings::default(), Vec::new()), + "every line taken in:\n{template}" + ); + for setting in Setting::ALL { + assert!(template.contains(&format!("# {} = ", setting.name())), "{}", setting.name()); + } + } } diff --git a/src/config/theme_file.rs b/src/config/theme_file.rs new file mode 100644 index 0000000..6302a57 --- /dev/null +++ b/src/config/theme_file.rs @@ -0,0 +1,608 @@ +//! Theme files: `themes/.toml` in the config folder. A theme only +//! states what it changes, on top of cumulus's own (the `default` theme, +//! built in), the way a node's look is worked out in layers: a theme that +//! says just `accent = "magenta"` is the built-in one in magenta. A file +//! called `default.toml` changes the built-in theme itself. +//! +//! ```toml +//! accent = "cyan" # screen colors: a terminal name, 0-255, "#rrggbb" +//! [modes] +//! insert = "green" +//! [palette] # the colors nodes and lines are given, by name +//! red = 167 +//! [node] # every node, unless something says otherwise +//! border = "square" +//! [branch] # lines from a parent to its children +//! color = "node" # the parent's own color +//! [styles.node.question] # named node styles: s, and [tags] +//! color = "yellow" # a palette color +//! border = "dashed" +//! [styles.link.blocks] # named line styles, for links +//! line = "dashed" +//! label = "blocks" # on a link with no label of its own +//! [tags] # a node with this tag gets this style +//! question = "question" +//! ``` + +use std::fs; +use std::path::PathBuf; +use std::str::FromStr; + +use ratatui::style::Color; + +use super::comment; +use super::paths::Paths; +use crate::command::parse::border; +use crate::command::registry::BORDERS; +use crate::model::{Arrows, Border, ColorName, Label, LineColor, LineLook, NodeLook, Stroke, StyleName, Tag}; +use crate::ui::glyphs::Glyphs; +use crate::ui::theme::Theme; + +/// The theme built into cumulus. +pub const BUILT_IN: &str = "default"; + +/// The words for line kinds, in files. +const STROKES: [(&str, Stroke); 5] = [ + ("solid", Stroke::Solid), + ("dashed", Stroke::Dashed), + ("dotted", Stroke::Dotted), + ("thick", Stroke::Thick), + ("double", Stroke::Double), +]; + +/// The words for arrowheads, in files. +const ARROWS: [(&str, Arrows); 4] = + [("off", Arrows::Off), ("end", Arrows::End), ("start", Arrows::Start), ("both", Arrows::Both)]; + +/// In a line's `color`, this word means "the node's own color". +const FOLLOW_NODE: &str = "node"; + +/// Where the theme called `name` is. +pub fn path(paths: &Paths, name: &str) -> PathBuf { + paths.config.join("themes").join(format!("{name}.toml")) +} + +/// The theme called `name`: the built-in one, with its file on top. Any +/// other name than `default` needs its file. `glyphs` (a setting) goes in +/// too. +pub fn load(paths: &Paths, name: &str, glyphs: Glyphs) -> (Theme, Vec) { + let base = Theme { glyphs, ..Theme::default() }; + match fs::read_to_string(path(paths, name)) { + Ok(text) => read(&text, base, &format!("themes/{name}.toml")), + Err(_) if name == BUILT_IN => (base, Vec::new()), + Err(_) => (base, vec![format!("there's no theme {name}: its file would be themes/{name}.toml")]), + } +} + +/// The themes there are: the built-in one, and each file in the themes +/// folder. +pub fn names(paths: &Paths) -> Vec { + let folder = paths.config.join("themes"); + let files = fs::read_dir(folder).into_iter().flatten().filter_map(|entry| { + let path = entry.ok()?.path(); + let toml = path.extension().is_some_and(|extension| extension == "toml"); + let name = toml.then(|| Some(path.file_stem()?.to_string_lossy().into_owned())).flatten()?; + (name != BUILT_IN).then_some(name) + }); + let mut names: Vec = std::iter::once(BUILT_IN.to_string()).chain(files).collect(); + names[1..].sort(); + names.dedup(); + names +} + +/// A theme file's text, on top of `base`. `file` names it in what's +/// reported. A mistake only drops its own line. +pub fn read(text: &str, base: Theme, file: &str) -> (Theme, Vec) { + let table: toml::Table = match toml::from_str(text) { + Ok(table) => table, + Err(error) => return (base, vec![format!("{file}: {}", super::toml_problem(text, &error))]), + }; + let mut reader = Reader { theme: base, problems: Vec::new(), file }; + for (key, value) in &table { + reader.top(key, value); + } + reader.check_names(); + (reader.theme, reader.problems) +} + +/// A theme file being read: the theme so far, and what was wrong. +struct Reader<'a> { + theme: Theme, + problems: Vec, + file: &'a str, +} + +impl Reader<'_> { + fn problem(&mut self, place: &str, text: String) { + self.problems.push(format!("{}: {place}{text}", self.file)); + } + + /// The table under `key`, or a problem saying it should be one. + fn table<'v>(&mut self, key: &str, value: &'v toml::Value) -> Option<&'v toml::Table> { + let table = value.as_table(); + if table.is_none() { + self.problem("", format!("{key} should be a section: [{key}]")); + } + table + } + + fn top(&mut self, key: &str, value: &toml::Value) { + match key { + "accent" | "muted" => { + let Some(color) = self.terminal_color(key, value) else { return }; + if key == "accent" { + self.theme.accent = color; + } else { + self.theme.muted = color; + } + } + "modes" => { + let Some(modes) = self.table(key, value) else { return }; + for (mode, value) in modes { + let place = format!("[modes] {mode}"); + let slot = match mode.as_str() { + "insert" => &mut self.theme.insert, + "visual" => &mut self.theme.visual, + _ => { + self.problem(&place, ": the modes with a color are insert and visual".into()); + continue; + } + }; + match terminal_color(value) { + Ok(color) => *slot = color, + Err(problem) => self.problem(&place, problem), + } + } + } + "palette" => { + let Some(palette) = self.table(key, value) else { return }; + for (name, value) in palette { + let place = format!("[palette] {name}"); + match (ColorName::parse(name), terminal_color(value)) { + (Ok(name), Ok(color)) => { + self.theme.palette.insert(name, color); + } + (Err(error), _) => self.problem(&place, format!(": {error}")), + (_, Err(problem)) => self.problem(&place, problem), + } + } + } + "node" | "root" => { + let Some(look) = self.table(key, value) else { return }; + let mut node = if key == "node" { self.theme.node.clone() } else { self.theme.root.clone() }; + let mut faded = false; + self.node_look(&format!("[{key}]"), look, &mut node, &mut faded, false); + if key == "node" { + self.theme.node = node; + } else { + self.theme.root = node; + } + } + "branch" | "link" => { + let Some(look) = self.table(key, value) else { return }; + let mut line = if key == "branch" { self.theme.branch.clone() } else { self.theme.link.clone() }; + let mut label = None; + self.line_look(&format!("[{key}]"), look, &mut line, &mut label, false); + if key == "branch" { + self.theme.branch = line; + } else { + self.theme.link = line; + } + } + "styles" => { + let Some(styles) = self.table(key, value) else { return }; + for (kind, named) in styles { + let Some(named) = self.table(&format!("styles.{kind}"), named) else { continue }; + match kind.as_str() { + "node" => self.node_styles(named), + "link" => self.line_styles(named), + _ => self.problem(&format!("[styles.{kind}]"), ": styles are styles.node or styles.link".into()), + } + } + } + "tags" => { + let Some(tags) = self.table(key, value) else { return }; + for (tag, style) in tags { + let place = format!("[tags] {tag}"); + let style = style.as_str().ok_or(": the style should be a name in quotes".to_string()); + match (Tag::parse(tag), style.and_then(|style| StyleName::parse(style).map_err(|e| format!(": {e}")))) { + (Ok(tag), Ok(style)) => match self.theme.tag_styles.iter_mut().find(|(known, _)| *known == tag) { + Some(entry) => entry.1 = style, + None => self.theme.tag_styles.push((tag, style)), + }, + (Err(error), _) => self.problem(&place, format!(": {error}")), + (_, Err(problem)) => self.problem(&place, problem), + } + } + } + _ => self.problem( + "", + format!( + "there's no {key} in a theme (there's accent, muted, modes, palette, node, root, branch, link, styles, tags)" + ), + ), + } + } + + fn terminal_color(&mut self, key: &str, value: &toml::Value) -> Option { + terminal_color(value).map_err(|problem| self.problem(key, problem)).ok() + } + + fn node_styles(&mut self, styles: &toml::Table) { + for (name, value) in styles { + let place = format!("[styles.node.{name}]"); + let Ok(style) = StyleName::parse(name) else { + self.problem(&place, ": a style's name is letters, digits, - and _".into()); + continue; + }; + let Some(look) = self.table(&format!("styles.node.{name}"), value) else { continue }; + let mut named = self.theme.node_styles.get(&style).cloned().unwrap_or_default(); + self.node_look(&place, look, &mut named.look, &mut named.faded, true); + self.theme.node_styles.insert(style, named); + } + } + + fn line_styles(&mut self, styles: &toml::Table) { + for (name, value) in styles { + let place = format!("[styles.link.{name}]"); + let Ok(style) = StyleName::parse(name) else { + self.problem(&place, ": a style's name is letters, digits, - and _".into()); + continue; + }; + let Some(look) = self.table(&format!("styles.link.{name}"), value) else { continue }; + let mut named = self.theme.line_styles.get(&style).cloned().unwrap_or_default(); + self.line_look(&place, look, &mut named.look, &mut named.label, true); + self.theme.line_styles.insert(style, named); + } + } + + /// A node look's fields: `color` and `border` (and `faded`, in a named + /// style). + fn node_look( + &mut self, + place: &str, + table: &toml::Table, + look: &mut NodeLook, + faded: &mut bool, + named: bool, + ) { + for (field, value) in table { + let place = format!("{place} {field}"); + let text = value.as_str(); + match (field.as_str(), text) { + ("color", Some(text)) => match ColorName::parse(text) { + Ok(color) => look.color = Some(color), + Err(error) => self.problem(&place, format!(": {error}")), + }, + ("border", Some(text)) => match border(text) { + Ok(kind) => look.border = Some(kind), + Err(_) => { + self.problem(&place, format!(": use {}", words(BORDERS.iter().map(|b| b.word)))) + } + }, + ("faded", _) if named => match value.as_bool() { + Some(on) => *faded = on, + None => self.problem(&place, ": faded is true or false".into()), + }, + ("color" | "border", None) => self.problem(&place, ": it should be a word in quotes".into()), + _ => { + let fields = if named { "color, border, faded" } else { "color, border" }; + self.problem(&place, format!(": a node's look has {fields}")); + } + } + } + } + + /// A line look's fields: `line`, `arrows` and `color` (and `label`, in a + /// named style). + fn line_look( + &mut self, + place: &str, + table: &toml::Table, + look: &mut LineLook, + label: &mut Option