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:
1 parent
f6dc4564aa
commit
abe3dc1d84
26 files changed
+1683
-129
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user