Read config.toml, keys.toml and themes, and open them with :config (stage 8b)

The config folder now sets everything: config.toml the settings (now
also `theme` and `editor`), keys.toml the keys, and themes/<name>.toml
the theme, laid over the built-in one, so a theme only needs what it
changes. Each file is applied again a second after it's saved; a
mistake only drops its own line, and the status bar says what's wrong,
naming the file.

:config lists the three files with their folder; Enter hands the
terminal to your editor (the editor setting, $VISUAL, $EDITOR, else
notepad or vi), writing the file first if it's missing, with every
option at its default behind a #, explained (keys.toml lists every
command by name). :theme switches themes until cumulus quits, like
:set.

The built-in theme gets the named styles the user picked, each also put
on by its tag: question (yellow, dashed), important (red, thick) and done
(gray, its title faded); and link styles blocks and supports, whose
label shows on a link without its own. Themes can also set [modes],
[node], [root], [branch] and [link].

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-10-01 23:55:11 -04:00
1 parent f6dc4564aa
commit abe3dc1d84
26 files changed
+1683 -129

No files matched your search

+56 -21
View File
@@ -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 = "<Space>"
[normal]
"<Space>x" = "delete-branch"
@@ -463,23 +482,37 @@ leader = "<Space>"
[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: <Space>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
+17
View File
@@ -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 (`<Space>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):
+3 -3
View File
@@ -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(())
}
+5
View File
@@ -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:
+3 -2
View File
@@ -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<Action> {
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")
}
+190
View File
@@ -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<Stamp>,
keys: Option<Stamp>,
theme: Option<Stamp>,
}
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<String> {
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<String> {
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<Message> {
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<String> =
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<Message> {
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<PathBuf> {
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<String>) -> 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())
}
+3 -2
View File
@@ -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);
+2 -1
View File
@@ -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(),
+17 -4
View File
@@ -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<PaneId>,
/// 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<PathBuf>,
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> = message.into_iter().chain(first_said).collect();
let config_said = problems.into_iter().map(Message::problem);
let mut said: Vec<Message> = config_said.chain(message).chain(first_said).collect();
for loaded in others {
said.extend(app.add_document(loaded).1);
}
+21 -7
View File
@@ -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 {
+9 -3
View File
@@ -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] });
+51 -1
View File
@@ -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(|_| ()),
}
}
+22 -6
View File
@@ -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("<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 part 8b of the build");
// <Space>s: the theme's named styles.
h.type_keys("<Space>s/question<CR>");
let node = h.doc().node(h.app.current().cursor()).unwrap();
assert_eq!(node.look.style.as_ref().map(|style| style.as_str()), Some("question"));
// <Space>w: no key hints: which-key and the hint line go.
assert!(h.rows().last().unwrap().contains("? help"));
h.type_keys("<Space>w");
+109
View File
@@ -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 +question<CR>gg");
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 +done<CR>gg");
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<CR>");
assert_eq!(h.message(), "theme = night");
assert_eq!(h.app.shared.theme.accent, Color::Magenta);
h.type_keys("<Space>s");
assert!(h.screen().contains("later"), "its style is in the picker:\n{}", h.screen());
h.type_keys("<Esc>");
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<CR>");
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<CR>");
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<CR>:config<CR>");
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("<CR>");
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(":config<CR>jj<CR>");
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");
}
+2 -13
View File
@@ -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),
}
}
}
+63
View File
@@ -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 <leader> stands for.\n\
# leader = \"<Space>\"\n\
\n\
# [normal]\n\
# \"<leader>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::<String>::new(), "all commented out");
let (_, problems) = Keymap::from_toml(&uncomment(&template));
assert_eq!(problems, Vec::<String>::new(), "{template}");
assert!(template.contains("# delete-branch") && template.contains("# show-note"));
}
}
+175 -3
View File
@@ -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/<name>.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<String>,
}
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<String>) {
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<String> {
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<String> = 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);
}
}
+199 -21
View File
@@ -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<u16> = 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<String>) {
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<Duration> {
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::<Vec<_>>()
.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());
}
}
}
+608
View File
@@ -0,0 +1,608 @@
//! Theme files: `themes/<name>.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: <Space>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<String>) {
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<String> {
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<String> = 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<String>) {
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<String>,
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<Color> {
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<Label>,
named: bool,
) {
for (field, value) in table {
let place = format!("{place} {field}");
let Some(text) = value.as_str() else {
self.problem(&place, ": it should be a word in quotes".into());
continue;
};
match field.as_str() {
"line" => match STROKES.iter().find(|(word, _)| *word == text) {
Some((_, stroke)) => look.line = Some(*stroke),
None => self.problem(&place, format!(": use {}", words(STROKES.iter().map(|(w, _)| *w)))),
},
"arrows" => match ARROWS.iter().find(|(word, _)| *word == text) {
Some((_, arrows)) => look.arrows = Some(*arrows),
None => self.problem(&place, format!(": use {}", words(ARROWS.iter().map(|(w, _)| *w)))),
},
"color" if text == FOLLOW_NODE => look.color = Some(LineColor::FollowNode),
"color" => match ColorName::parse(text) {
Ok(color) => look.color = Some(LineColor::Named(color)),
Err(error) => self.problem(&place, format!(": {error}")),
},
"label" if named => match Label::parse(text) {
Ok(text) => *label = Some(text),
Err(error) => self.problem(&place, format!(": {error}")),
},
_ => {
let fields = if named { "line, arrows, color, label" } else { "line, arrows, color" };
self.problem(&place, format!(": a line's look has {fields}"));
}
}
}
}
/// Names that point at something that isn't there: a palette color, or
/// a tag's style.
fn check_names(&mut self) {
let theme = &self.theme;
let mut colors: Vec<(String, &ColorName)> = Vec::new();
for (place, look) in [("[node]", &theme.node), ("[root]", &theme.root)] {
colors.extend(look.color.iter().map(|color| (place.to_string(), color)));
}
for (name, named) in &theme.node_styles {
colors.extend(named.look.color.iter().map(|color| (format!("[styles.node.{name}]"), color)));
}
let lines =
[("[branch]".to_string(), &theme.branch), ("[link]".to_string(), &theme.link)].into_iter();
let named =
theme.line_styles.iter().map(|(name, named)| (format!("[styles.link.{name}]"), &named.look));
for (place, look) in lines.chain(named) {
if let Some(LineColor::Named(color)) = &look.color {
colors.push((place, color));
}
}
let mut problems: Vec<(String, String)> = colors
.into_iter()
.filter(|(_, color)| !theme.palette.contains_key(*color))
.map(|(place, color)| (place, format!(" color: there's no {color} in the palette")))
.collect();
for (tag, style) in &theme.tag_styles {
if !theme.node_styles.contains_key(style) {
problems.push((format!("[tags] {}", tag.name()), format!(": there's no node style {style}")));
}
}
for (place, problem) in problems {
self.problem(&place, problem);
}
}
}
/// A screen color: a terminal name (`cyan`, `darkgray`), 0 to 255, or
/// `#rrggbb`.
fn terminal_color(value: &toml::Value) -> Result<Color, String> {
let wrong = || ": use a color name (like cyan or darkgray), 0 to 255, or \"#rrggbb\"".to_string();
match value {
toml::Value::Integer(index) => u8::try_from(*index).map(Color::Indexed).map_err(|_| wrong()),
toml::Value::String(text) => Color::from_str(text).map_err(|_| wrong()),
_ => Err(wrong()),
}
}
/// Words, as a list in a message: `solid, dashed, dotted`.
fn words<'a>(words: impl Iterator<Item = &'a str>) -> String {
words.collect::<Vec<_>>().join(", ")
}
// ----- The template ---------------------------------------------------------
/// A theme file with everything `theme` has, each line commented out: what
/// `:config` first writes for the theme.
pub fn template(theme: &Theme) -> String {
let mut text = String::from(
"# The theme. Each value is here as cumulus has it, behind a #: take the\n\
# # away to change one (and the section it's in, [modes] say). A theme\n\
# only needs what it changes. cumulus applies this file when you save it.\n\
#\n\
# Screen colors (accent, muted, the modes, the palette) are terminal\n\
# colors: a name (red, lightblue, darkgray...), 0 to 255, or \"#rrggbb\".\n\
# Colors in looks and styles are names from the palette.\n\n",
);
let line = |text: &mut String, key: &str, value: String| text.push_str(&format!("# {key} = {value}\n"));
text.push_str("# The cursor, the focused pane, which-key, the command line.\n");
line(&mut text, "accent", color_text(theme.accent));
text.push_str("# Quiet things: lines, hints, counts, faded titles.\n");
line(&mut text, "muted", color_text(theme.muted));
text.push_str("\n# Typing, and selecting (visual mode).\n# [modes]\n");
line(&mut text, "insert", color_text(theme.insert));
line(&mut text, "visual", color_text(theme.visual));
text.push_str("\n# The colors nodes and lines can have, by name.\n# [palette]\n");
for (name, color) in &theme.palette {
line(&mut text, name.as_str(), color_text(*color));
}
let borders = words(BORDERS.iter().map(|b| b.word));
text.push('\n');
text.push_str(&comment(&format!(
"How every node looks, unless something says otherwise. Borders: {borders}."
)));
node_lines(&mut text, "[node]", &theme.node, false);
text.push_str("\n# The root, on top of [node].\n");
node_lines(&mut text, "[root]", &theme.root, false);
text.push('\n');
text.push_str(&comment(&format!(
"Lines from a parent to its children. Lines: {}; arrows: {}; color: a palette color, or \"node\" for the parent's own.",
words(STROKES.iter().map(|(w, _)| *w)),
words(ARROWS.iter().map(|(w, _)| *w))
)));
line_lines(&mut text, "[branch]", &theme.branch, None);
text.push_str("\n# Cross-links.\n");
line_lines(&mut text, "[link]", &theme.link, None);
text.push('\n');
text.push_str(&comment(
"Named node styles, for <Space>s and [tags]. A faded node shows its title in the muted color.",
));
for (index, (name, named)) in theme.node_styles.iter().enumerate() {
if index > 0 {
text.push_str("#\n");
}
node_lines(&mut text, &format!("[styles.node.{name}]"), &named.look, named.faded);
}
text.push('\n');
text.push_str(&comment("Named line styles, for links. The label shows on a link without its own."));
for (index, (name, named)) in theme.line_styles.iter().enumerate() {
if index > 0 {
text.push_str("#\n");
}
line_lines(&mut text, &format!("[styles.link.{name}]"), &named.look, Some(&named.label));
}
text.push_str("\n# A node with one of these tags gets that style.\n# [tags]\n");
for (tag, style) in &theme.tag_styles {
line(&mut text, tag.name(), format!("{:?}", style.as_str()));
}
text
}
fn node_lines(text: &mut String, section: &str, look: &NodeLook, faded: bool) {
text.push_str(&format!("# {section}\n"));
if let Some(color) = &look.color {
text.push_str(&format!("# color = {:?}\n", color.as_str()));
}
if let Some(kind) = look.border {
text.push_str(&format!("# border = {:?}\n", border_word(kind)));
}
if faded {
text.push_str("# faded = true\n");
}
}
fn line_lines(text: &mut String, section: &str, look: &LineLook, label: Option<&Option<Label>>) {
text.push_str(&format!("# {section}\n"));
if let Some(stroke) = look.line
&& let Some((word, _)) = STROKES.iter().find(|(_, known)| *known == stroke)
{
text.push_str(&format!("# line = {word:?}\n"));
}
if let Some(arrows) = look.arrows
&& let Some((word, _)) = ARROWS.iter().find(|(_, known)| *known == arrows)
{
text.push_str(&format!("# arrows = {word:?}\n"));
}
match &look.color {
Some(LineColor::FollowNode) => text.push_str(&format!("# color = {FOLLOW_NODE:?}\n")),
Some(LineColor::Named(color)) => text.push_str(&format!("# color = {:?}\n", color.as_str())),
None => {}
}
if let Some(Some(label)) = label {
text.push_str(&format!("# label = {:?}\n", label.as_str()));
}
}
/// A border's word in files.
fn border_word(kind: Border) -> &'static str {
BORDERS
.iter()
.find(|choice| border(choice.word).ok() == Some(kind))
.map_or("square", |choice| choice.word)
}
/// A screen color as a theme file writes it: a number, `"#rrggbb"` or a name.
fn color_text(color: Color) -> String {
let name = match color {
Color::Indexed(index) => return index.to_string(),
Color::Rgb(r, g, b) => return format!("\"#{r:02x}{g:02x}{b:02x}\""),
Color::Reset => "reset",
Color::Black => "black",
Color::Red => "red",
Color::Green => "green",
Color::Yellow => "yellow",
Color::Blue => "blue",
Color::Magenta => "magenta",
Color::Cyan => "cyan",
Color::Gray => "gray",
Color::DarkGray => "darkgray",
Color::LightRed => "lightred",
Color::LightGreen => "lightgreen",
Color::LightYellow => "lightyellow",
Color::LightBlue => "lightblue",
Color::LightMagenta => "lightmagenta",
Color::LightCyan => "lightcyan",
Color::White => "white",
};
format!("{name:?}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::settings::uncomment;
use crate::storage::test_support::TempDir;
#[test]
fn a_theme_changes_only_what_it_says() {
let text = "accent = \"magenta\"\n\
[palette]\nred = 160\nmoss = \"#5f8700\"\n\
[styles.node.question]\nborder = \"square\"\n\
[styles.node.idea]\ncolor = \"moss\"\n\
[tags]\nidea = \"idea\"\n";
let (theme, problems) = read(text, Theme::default(), "themes/mine.toml");
assert_eq!(problems, Vec::<String>::new());
let built_in = Theme::default();
assert_eq!(theme.accent, Color::Magenta);
assert_eq!(theme.muted, built_in.muted, "what it doesn't say stays");
let red = ColorName::parse("red").unwrap();
assert_eq!(theme.palette[&red], Color::Indexed(160));
assert_eq!(theme.palette.len(), built_in.palette.len() + 1, "moss joins the palette");
let question = &theme.node_styles[&StyleName::parse("question").unwrap()];
assert_eq!(question.look.border, Some(Border::Square));
assert_eq!(question.look.color, ColorName::parse("yellow").ok(), "the style's color stays");
assert_eq!(theme.tag_styles.len(), built_in.tag_styles.len() + 1);
}
#[test]
fn mistakes_are_named_and_skipped() {
let text = "acent = 1\naccent = 300\n[modes]\nnormal = \"red\"\n\
[node]\nborder = \"wavy\"\n[link]\nline = \"zigzag\"\ncolor = \"plum\"\n\
[tags]\nlater = \"someday\"\n";
let (theme, problems) = read(text, Theme::default(), "themes/mine.toml");
assert_eq!(theme.accent, Theme::default().accent);
let expected = [
"themes/mine.toml: accent: use a color name (like cyan or darkgray), 0 to 255, or \"#rrggbb\"",
"themes/mine.toml: there's no acent in a theme (there's accent, muted, modes, palette, node, root, branch, link, styles, tags)",
"themes/mine.toml: [link] line: use solid, dashed, dotted, thick, double",
"themes/mine.toml: [modes] normal: the modes with a color are insert and visual",
"themes/mine.toml: [node] border: use rounded, square, double, thick, dashed, hidden",
"themes/mine.toml: [link] color: there's no plum in the palette",
"themes/mine.toml: [tags] later: there's no node style someday",
];
assert_eq!(problems, expected);
}
#[test]
fn the_template_is_the_built_in_theme() {
let built_in = Theme::default();
let template = template(&built_in);
assert_eq!(
read(&template, built_in.clone(), "t"),
(built_in.clone(), Vec::new()),
"all commented out"
);
let blank = Theme {
palette: Default::default(),
node_styles: Default::default(),
line_styles: Default::default(),
tag_styles: Vec::new(),
..Theme::default()
};
let (theme, problems) = read(&uncomment(&template), blank, "t");
assert_eq!(problems, Vec::<String>::new(), "{template}");
assert_eq!(theme, built_in, "every line taken in gives the built-in theme back:\n{template}");
}
#[test]
fn themes_by_name() {
let folder = TempDir::new();
let paths = Paths::inside(folder.path());
assert_eq!(names(&paths), ["default"]);
let (theme, problems) = load(&paths, "night", Glyphs::Ascii);
assert_eq!(problems, ["there's no theme night: its file would be themes/night.toml"]);
assert_eq!(theme.glyphs, Glyphs::Ascii);
fs::create_dir_all(paths.config.join("themes")).unwrap();
fs::write(path(&paths, "night"), "accent = \"blue\"").unwrap();
fs::write(path(&paths, "day"), "accent = \"yellow\"").unwrap();
assert_eq!(names(&paths), ["default", "day", "night"]);
assert_eq!(load(&paths, "night", Glyphs::Unicode).0.accent, Color::Blue);
}
}
+1 -1
View File
@@ -106,7 +106,7 @@ impl Keymap {
let file: toml::Table = match toml::from_str(text) {
Ok(file) => file,
Err(error) => {
return (Keymap::new(DEFAULT_LEADER), vec![format!("keys.toml isn't valid TOML: {error}")]);
return (Keymap::new(DEFAULT_LEADER), vec![crate::config::toml_problem(text, &error)]);
}
};
// The leader comes first: every `<leader>` key depends on it.
+6 -6
View File
@@ -8,9 +8,8 @@ use std::process::ExitCode;
use cumulus::app::startup::{self, Startup};
use cumulus::app::{App, run};
use cumulus::config::Config;
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
@@ -61,10 +60,11 @@ fn start(args: Vec<String>) -> Result<(), String> {
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();
// The config folder first: it names the document `cumulus` looks for.
let config = Config::load(&paths);
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())
let opening =
startup::open(&startup, &paths, &here, &config.settings).map_err(|error| error.to_string())?;
run(App::new(paths, config, opening)).map_err(|error| error.to_string())
}
+104 -28
View File
@@ -5,15 +5,19 @@
//! [`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 (part 8b of the build). Until then
//! [`Theme::default`] is the theme.
//! [`Theme::default`] is cumulus's own theme, built in. A theme file
//! (`themes/<name>.toml`, read by [`config::theme_file`]) changes it.
//!
//! [`config::theme_file`]: crate::config::theme_file
use std::collections::BTreeMap;
use ratatui::style::Color;
use super::glyphs::Glyphs;
use crate::model::{Arrows, Border, ColorName, LineColor, LineLook, Node, NodeLook, Stroke, StyleName, Tag};
use crate::model::{
Arrows, Border, ColorName, Label, LineColor, LineLook, Link, Node, NodeLook, Stroke, StyleName, Tag,
};
/// How to draw a node, all worked out.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -22,6 +26,8 @@ pub struct NodeStyle {
/// terminal's normal text color).
pub color: Color,
pub border: Border,
/// Its title in the quiet color: it steps back (the `done` style).
pub faded: bool,
}
/// How to draw a line, all worked out.
@@ -32,16 +38,33 @@ pub struct LineStyle {
pub color: Color,
}
/// A named node style: a look, and whether its nodes fade back.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NamedNode {
pub look: NodeLook,
pub faded: bool,
}
/// A named line style: a look, and the label its links show when they have
/// none of their own.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NamedLine {
pub look: LineLook,
pub label: Option<Label>,
}
/// Every color and look the views draw with.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Theme {
pub glyphs: Glyphs,
/// The accent: the cursor, the focused pane, which-key, the command line.
pub accent: Color,
/// Quiet things: lines, hints, counts.
/// Quiet things: lines, hints, counts, faded titles.
pub muted: Color,
/// Typing: the status bar's INSERT, and the box being typed in.
pub insert: Color,
/// Visual mode: what's selected.
pub visual: Color,
/// Node colors, by name.
pub palette: BTreeMap<ColorName, Color>,
/// How nodes look, unless something says otherwise.
@@ -54,9 +77,9 @@ pub struct Theme {
pub link: LineLook,
/// Named node styles. (A style's own `style` field is ignored: styles
/// don't point at other styles.)
pub node_styles: BTreeMap<StyleName, NodeLook>,
pub node_styles: BTreeMap<StyleName, NamedNode>,
/// Named line styles.
pub line_styles: BTreeMap<StyleName, LineLook>,
pub line_styles: BTreeMap<StyleName, NamedLine>,
/// Tags that give their nodes a style, applied in this order.
pub tag_styles: Vec<(Tag, StyleName)>,
}
@@ -65,7 +88,9 @@ impl Default for Theme {
/// cumulus's own theme. The palette uses mid-tone colors from the
/// standard 256, which read well on dark and light terminals alike; the
/// accent and the muted color are terminal colors, so they follow your
/// terminal's scheme.
/// terminal's scheme. A few named styles come with it, put on by tags of
/// the same names: `question`, `important` and `done`; and two for links,
/// `blocks` and `supports`.
fn default() -> Theme {
let palette = [
("red", 167),
@@ -82,11 +107,39 @@ impl Default for Theme {
.into_iter()
.filter_map(|(name, index)| Some((ColorName::parse(name).ok()?, Color::Indexed(index))))
.collect();
let color = |name: &str| ColorName::parse(name).ok();
let node_style = |name: &str, color: Option<ColorName>, border: Option<Border>, faded: bool| {
Some((
StyleName::parse(name).ok()?,
NamedNode { look: NodeLook { color, border, style: None }, faded },
))
};
let node_styles = [
node_style("question", color("yellow"), Some(Border::Dashed), false),
node_style("important", color("red"), Some(Border::Thick), false),
node_style("done", color("gray"), None, true),
];
let line_style = |name: &str, line_color: &str, stroke: Stroke| {
let look = LineLook {
line: Some(stroke),
arrows: Some(Arrows::End),
color: color(line_color).map(LineColor::Named),
style: None,
};
Some((StyleName::parse(name).ok()?, NamedLine { look, label: Label::parse(name).ok() }))
};
let line_styles =
[line_style("blocks", "red", Stroke::Dashed), line_style("supports", "green", Stroke::Solid)];
let tag_styles = ["done", "important", "question"]
.into_iter()
.filter_map(|name| Some((Tag::parse(name).ok()?, StyleName::parse(name).ok()?)))
.collect();
Theme {
glyphs: Glyphs::Unicode,
accent: Color::Cyan,
muted: Color::DarkGray,
insert: Color::Green,
visual: Color::Magenta,
palette,
node: NodeLook { border: Some(Border::Square), ..NodeLook::default() },
root: NodeLook { border: Some(Border::Rounded), ..NodeLook::default() },
@@ -99,9 +152,9 @@ impl Default for Theme {
style: None,
},
link: LineLook { line: Some(Stroke::Dashed), arrows: Some(Arrows::End), ..LineLook::default() },
node_styles: BTreeMap::new(),
line_styles: BTreeMap::new(),
tag_styles: Vec::new(),
node_styles: node_styles.into_iter().flatten().collect(),
line_styles: line_styles.into_iter().flatten().collect(),
tag_styles,
}
}
}
@@ -111,6 +164,7 @@ impl Theme {
pub fn node_style(&self, node: &Node, is_root: bool) -> NodeStyle {
let mut color: Option<ColorName> = None;
let mut border = Border::Square;
let mut faded = false;
let mut apply = |look: &NodeLook| {
if let Some(name) = &look.color {
color = Some(name.clone());
@@ -125,16 +179,18 @@ impl Theme {
}
for (tag, style) in &self.tag_styles {
if node.tags.contains(tag)
&& let Some(look) = self.node_styles.get(style)
&& let Some(named) = self.node_styles.get(style)
{
apply(look);
apply(&named.look);
faded |= named.faded;
}
}
if let Some(look) = node.look.style.as_ref().and_then(|style| self.node_styles.get(style)) {
apply(look);
if let Some(named) = node.look.style.as_ref().and_then(|style| self.node_styles.get(style)) {
apply(&named.look);
faded |= named.faded;
}
apply(&node.look);
NodeStyle { color: color.map_or(Color::Reset, |name| self.color(&name)), border }
NodeStyle { color: color.map_or(Color::Reset, |name| self.color(&name)), border, faded }
}
/// How to draw a branch line, given the look it has and the color of
@@ -149,6 +205,12 @@ impl Theme {
self.line_style(&self.link, look, start_color)
}
/// The label a link shows: its own, or its named style's.
pub fn link_label<'a>(&'a self, link: &'a Link) -> Option<&'a str> {
let named = || link.look.style.as_ref().and_then(|style| self.line_styles.get(style)?.label.as_ref());
link.label.as_ref().or_else(named).map(Label::as_str)
}
/// A palette color by name. An unknown name (from a document made with
/// another theme) shows as the normal text color.
pub fn color(&self, name: &ColorName) -> Color {
@@ -171,7 +233,7 @@ impl Theme {
};
apply(default);
if let Some(named) = look.style.as_ref().and_then(|name| self.line_styles.get(name)) {
apply(named);
apply(&named.look);
}
apply(look);
style.color = match color {
@@ -188,6 +250,7 @@ impl Theme {
#[cfg(test)]
mod tests {
use super::*;
use crate::model::NodeId;
use crate::model::test_support::idea;
fn name(text: &str) -> StyleName {
@@ -196,28 +259,25 @@ mod tests {
#[test]
fn looks_are_worked_out_in_layers() {
let mut theme = Theme::default();
let theme = Theme::default();
let yellow = ColorName::parse("yellow").unwrap();
let red = ColorName::parse("red").unwrap();
theme.node_styles.insert(
name("question"),
NodeLook { color: Some(yellow.clone()), border: Some(Border::Dashed), style: None },
);
theme.tag_styles.push((Tag::parse("question").unwrap(), name("question")));
let mut node = idea("why?");
assert_eq!(theme.node_style(&node, false), NodeStyle { color: Color::Reset, border: Border::Square });
let plain = NodeStyle { color: Color::Reset, border: Border::Square, faded: false };
assert_eq!(theme.node_style(&node, false), plain);
assert_eq!(theme.node_style(&node, true).border, Border::Rounded, "the root has its own border");
node.tags.insert(Tag::parse("question").unwrap());
assert_eq!(
theme.node_style(&node, false),
NodeStyle { color: theme.color(&yellow), border: Border::Dashed }
);
let question = NodeStyle { color: theme.color(&yellow), border: Border::Dashed, faded: false };
assert_eq!(theme.node_style(&node, false), question, "#question puts the question style on");
node.look.color = Some(red.clone());
let style = theme.node_style(&node, false);
assert_eq!((style.color, style.border), (theme.color(&red), Border::Dashed), "own color wins");
node.look.style = Some(name("done"));
assert!(theme.node_style(&node, false).faded, "done fades the node back");
}
#[test]
@@ -238,4 +298,20 @@ mod tests {
let link = theme.link_style(&LineLook::default(), teal);
assert_eq!((link.stroke, link.arrows, link.color), (Stroke::Dashed, Arrows::End, theme.muted));
}
#[test]
fn a_named_link_style_brings_its_label() {
let theme = Theme::default();
let mut link = Link::new(NodeId::new(1), NodeId::new(2)).unwrap();
assert_eq!(theme.link_label(&link), None);
link.look.style = Some(name("blocks"));
assert_eq!(theme.link_label(&link), Some("blocks"));
let style = theme.link_style(&link.look, Color::Reset);
assert_eq!(
(style.stroke, style.color),
(Stroke::Dashed, theme.color(&ColorName::parse("red").unwrap()))
);
link.label = Some(Label::parse("needs").unwrap());
assert_eq!(theme.link_label(&link), Some("needs"), "its own label wins");
}
}
+7 -3
View File
@@ -59,8 +59,11 @@ impl Scene {
}
}
}
let title_style =
if node.selected { Style::new().add_modifier(Modifier::BOLD) } else { Style::new() };
let title_style = match (node.selected, node.faded) {
(true, _) => Style::new().add_modifier(Modifier::BOLD),
(false, true) => Style::new().fg(self.muted),
(false, false) => Style::new(),
};
for (row, line) in (rect.top() + 1..).zip(lines) {
painter.text(ScreenPos::new(rect.left() + 2, row), line, title_style, &mine);
}
@@ -93,7 +96,8 @@ impl Scene {
if let Some(bullet) = chars.next() {
painter.put(node.rect.pos, bullet, Style::new().fg(node.color));
let rest = ScreenPos::new(node.rect.left() + 1, node.rect.top());
painter.text(rest, chars.as_str(), Style::new(), |_| true);
let style = if node.faded { Style::new().fg(self.muted) } else { Style::new() };
painter.text(rest, chars.as_str(), style, |_| true);
}
}
+5 -2
View File
@@ -44,6 +44,8 @@ pub struct SceneNode {
pub shape: Shape,
/// Its color: the border, bullet or dot.
pub color: Color,
/// Its title in the quiet color (the `done` style).
pub faded: bool,
/// Whether the cursor is on it.
pub selected: bool,
}
@@ -118,7 +120,8 @@ impl Scene {
Some(edit) => scene.draft(node, edit, badge, style.border, camera, max_width),
None => shape(node, badge, style.border, camera, theme.glyphs, max_width),
};
scene.nodes.push(SceneNode { id, rect, shape, color: style.color, selected: id == cursor });
let (color, faded, selected) = (style.color, style.faded, id == cursor);
scene.nodes.push(SceneNode { id, rect, shape, color, faded, selected });
}
// The cursor's node goes last, on top of any it overlaps.
if let Some(index) = scene.nodes.iter().position(|node| node.selected) {
@@ -245,7 +248,7 @@ impl Scene {
continue;
};
let style = theme.link_style(&link.look, self.nodes[start].color);
let label = link.label.as_ref().map(|label| label.as_str());
let label = theme.link_label(link);
self.connect(start, end, Bend::Halfway, style, LineOwner::Link(link.key()), label);
}
}
+1 -1
View File
@@ -174,7 +174,7 @@ impl NoteView {
.map(|link| {
let out = link.start() == id;
let other = if out { link.end() } else { link.start() };
(out, other, link.label.as_ref().map(ToString::to_string).unwrap_or_default())
(out, other, theme.link_label(link).unwrap_or_default().to_string())
})
.collect();
links.sort_by_key(|&(out, _, _)| !out);
+4 -1
View File
@@ -164,7 +164,8 @@ impl OutlineView {
let (doc, theme, glyphs) = (ctx.doc, ctx.theme, ctx.theme.glyphs);
let Some(node) = doc.node(id) else { return };
let top_level = lines.spans.is_empty();
let color = theme.node_style(node, id == doc.root()).color;
let style = theme.node_style(node, id == doc.root());
let color = style.color;
let has_children = !doc.children(id).is_empty();
let folded = visible.hidden.contains_key(&id);
let marker = if node.is_portal() {
@@ -184,6 +185,8 @@ impl OutlineView {
let muted = Style::new().fg(theme.muted);
let title_style = if id == ctx.cursor {
Style::new().fg(Color::Black).bg(theme.accent).add_modifier(Modifier::BOLD)
} else if style.faded {
muted
} else {
Style::new()
};