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

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

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

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

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

No files matched your search

+5
View File
@@ -10,6 +10,7 @@ use super::panes::Arrange;
use super::popup::{MenuFor, PickKind};
use super::text_edit::StartEdit;
use crate::command::parse::SetArg;
use crate::config::ConfigFile;
use crate::input::command::Command;
use crate::input::key::Key;
use crate::model::{Direction, EditOp, Group, NodeId, Title};
@@ -157,6 +158,10 @@ pub enum AppAction {
SwitchDocument(DocumentChoice),
// Files.
/// `:config`: the config files, to open one in the editor.
OpenConfig,
/// Open a config file in the editor (made first if it isn't there).
EditConfig(ConfigFile),
/// Save (`:w`, `:x`, `:wa`).
Save(SaveWhich),
/// Open a document in the focused pane (`:e`), from a path as typed:
+3 -2
View File
@@ -5,7 +5,7 @@ use super::action::{Action, AppAction, CursorAfter, DocumentChoice, Message, Sav
use super::commands::set_fold;
use super::panes::Arrange;
use super::popup::PickKind;
use crate::command::parse::{Call, TagChange};
use crate::command::parse::{Call, SetArg, TagChange};
use crate::model::{CanvasPos, CanvasRect, EditOp, Inbox, Link, LinkKey, Node, NodeId, Title};
use crate::view::canvas::placement::{child_spot, first_child_spot, free_spot};
use crate::view::{ViewContext, ViewKind};
@@ -69,7 +69,8 @@ pub fn actions_for(call: Call, ctx: &ViewContext<'_>) -> Vec<Action> {
Call::Jumps | Call::Marks | Call::Registers => {
later("the lists of jumps, marks and registers come in part 8c of the build")
}
Call::Theme(_) | Call::Config => later("theme and config files come in part 8b of the build"),
Call::Theme(name) => vec![Action::App(AppAction::Set(SetArg::Value("theme".into(), name)))],
Call::Config => app(AppAction::OpenConfig),
Call::Tidy(_) | Call::Align(_) | Call::BranchOut | Call::Export { .. } => {
later("this one comes in v1.1")
}
+190
View File
@@ -0,0 +1,190 @@
//! Settings, keys and themes, put to work: `:set` and `:theme` while cumulus
//! runs, `:config` to open the config files in your editor, and each file
//! applied again when it's saved (they're looked at every second, with the
//! documents).
use std::fs;
use std::io;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use super::App;
use super::action::Message;
use super::mode::Mode;
use super::popup::Popup;
use crate::command::parse::SetArg;
use crate::config::paths::Paths;
use crate::config::settings::Settings;
use crate::config::{ConfigFile, keys, theme_file};
use crate::storage::stamp::Stamp;
use crate::storage::write_atomically;
/// The config files as cumulus last read them.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(super) struct ConfigStamps {
settings: Option<Stamp>,
keys: Option<Stamp>,
theme: Option<Stamp>,
}
impl ConfigStamps {
/// The files' stamps now, with the theme named `theme`.
pub(super) fn read(paths: &Paths, theme: &str) -> ConfigStamps {
let stamp = |file: ConfigFile| Stamp::of(&file.path(paths, theme)).ok().flatten();
ConfigStamps {
settings: stamp(ConfigFile::Settings),
keys: stamp(ConfigFile::Keys),
theme: stamp(ConfigFile::Theme),
}
}
}
impl App {
/// `:set` (and `:theme`): changes a setting, and puts it to work.
pub(super) fn set(&mut self, arg: &SetArg) -> Message {
let before = self.settings.clone();
let text = match self.settings.apply(arg) {
Ok(text) => text,
Err(problem) => return Message::problem(problem),
};
let name = self.settings.theme.clone();
if name != before.theme
&& name != theme_file::BUILT_IN
&& !theme_file::path(&self.paths, &name).exists()
{
let problem = format!("there's no theme {name}: its file would be themes/{name}.toml");
self.settings = before;
return Message::problem(problem);
}
let problems = self.settings_changed(&before);
if let Some(problems) = Message::joined(problems.into_iter().map(Message::problem).collect()) {
return problems;
}
if matches!(arg, SetArg::Show(_)) { Message::info(text) } else { Message::done(text) }
}
/// Puts changed settings to work: the theme, the glyphs, the width
/// titles wrap at. Returns what was wrong with the theme's file.
fn settings_changed(&mut self, before: &Settings) -> Vec<String> {
self.shared.view.max_node_width = self.settings.max_node_width;
if self.settings.theme == before.theme && self.settings.glyphs == before.glyphs {
return Vec::new();
}
self.load_theme()
}
/// The theme the settings name, read again from its file.
fn load_theme(&mut self) -> Vec<String> {
let (theme, problems) = theme_file::load(&self.paths, &self.settings.theme, self.settings.glyphs);
self.shared.theme = theme;
problems
}
/// Applies the config files saved since they were last read, and says
/// how that went.
pub(super) fn reread_config(&mut self) -> Vec<Message> {
let now = ConfigStamps::read(&self.paths, &self.settings.theme);
if now == self.config_stamps {
return Vec::new();
}
let mut said = Vec::new();
let mut theme_read = false;
if now.settings != self.config_stamps.settings {
let text = fs::read_to_string(ConfigFile::Settings.path(&self.paths, "")).unwrap_or_default();
let (settings, mut problems) = Settings::from_toml(&text);
let before = std::mem::replace(&mut self.settings, settings);
theme_read = self.settings.theme != before.theme || self.settings.glyphs != before.glyphs;
problems.extend(self.settings_changed(&before));
said.push(applied("config.toml", problems));
}
if now.keys != self.config_stamps.keys {
let (keymap, problems) = keys(&self.paths);
self.keymap = keymap;
self.keys.reset();
said.push(applied("keys.toml", problems));
}
if now.theme != self.config_stamps.theme && !theme_read {
let problems = self.load_theme();
said.push(applied(&format!("themes/{}.toml", self.settings.theme), problems));
}
// A new theme has its own file to look at.
self.config_stamps = ConfigStamps::read(&self.paths, &self.settings.theme);
said
}
/// `:config`: the config files, to pick one to open.
pub(super) fn open_config(&mut self) {
let theme = &self.settings.theme;
let files: Vec<(ConfigFile, String)> = ConfigFile::ALL
.into_iter()
.map(|file| {
let path = file.path(&self.paths, theme);
let name = path.strip_prefix(&self.paths.config).unwrap_or(&path);
let parts: Vec<String> =
name.iter().map(|part| part.to_string_lossy().into_owned()).collect();
(file, parts.join("/"))
})
.collect();
let popup =
Popup::config(&self.paths.config, &files, &editor_name(&self.editor()), &self.shared.theme);
self.shared.mode = Mode::Popup(popup);
}
/// Opens a config file in the editor (the main loop hands the terminal
/// over), after writing it, every option commented out, if it isn't
/// there yet.
pub(super) fn edit_config(&mut self, file: ConfigFile) -> Option<Message> {
let path = file.path(&self.paths, &self.settings.theme);
if !path.exists()
&& let Err(error) = write_atomically(&path, &file.template())
{
return Some(Message::problem(format!("couldn't make {}: {error}", path.display())));
}
self.edit_request = Some(path);
None
}
/// The file the main loop should open in the editor, if any.
pub fn take_edit_request(&mut self) -> Option<PathBuf> {
self.edit_request.take()
}
/// The editor's command: the `editor` setting, `$VISUAL`, `$EDITOR`, or
/// the system's own (notepad on Windows, vi elsewhere).
pub fn editor(&self) -> String {
let set = |text: String| (!text.trim().is_empty()).then_some(text);
set(self.settings.editor.clone())
.or_else(|| std::env::var("VISUAL").ok().and_then(set))
.or_else(|| std::env::var("EDITOR").ok().and_then(set))
.unwrap_or_else(|| if cfg!(windows) { "notepad" } else { "vi" }.to_string())
}
/// The editor is done, or couldn't start: the files are looked at again
/// right away, so what was saved applies now.
pub fn edited(&mut self, result: io::Result<()>) {
self.watched = Instant::now().checked_sub(Duration::from_secs(60)).unwrap_or(self.watched);
if let Err(error) = result {
let editor = self.editor();
self.message = Some(Message::problem(format!(
"couldn't start {editor}: {error} · the editor setting picks another"
)));
}
}
}
/// How a config file went: applied, or what's wrong in it.
fn applied(file: &str, problems: Vec<String>) -> Message {
Message::joined(problems.into_iter().map(Message::problem).collect())
.unwrap_or_else(|| Message::done(format!("applied {file}")))
}
/// An editor's name, for the popup: `nvim` for `C:\Neovim\bin\nvim.exe -p`.
fn editor_name(command: &str) -> String {
let program = if std::path::Path::new(command).is_file() {
command
} else {
command.split_whitespace().next().unwrap_or(command)
};
let path = std::path::Path::new(program);
path.file_stem().map_or_else(|| program.to_string(), |stem| stem.to_string_lossy().into_owned())
}
+3 -2
View File
@@ -315,9 +315,10 @@ impl App {
}
/// Looks at every file for changes made outside cumulus, and lets
/// read-only documents be edited once the other cumulus lets go.
/// read-only documents be edited once the other cumulus lets go. The
/// config files saved since they were read apply again.
fn watch(&mut self) -> bool {
let mut said = Vec::new();
let mut said = self.reread_config();
for id in self.shared.docs.ids() {
said.extend(self.look_at(id));
let open = self.shared.docs.get_mut(id);
+2 -1
View File
@@ -9,6 +9,7 @@ use super::mode::Mode;
use super::{App, calls, commands};
use crate::command::parse::{Sources, parse};
use crate::config::settings::Setting;
use crate::config::theme_file;
use crate::input::command::{Bound, Command, help_for};
use crate::input::engine::{Outcome, Typed};
use crate::input::key::Key;
@@ -143,7 +144,7 @@ impl App {
nodes: nodes.collect(),
colors: theme.palette.keys().map(ToString::to_string).collect(),
styles: theme.node_styles.keys().map(ToString::to_string).collect(),
themes: vec!["default".into()],
themes: theme_file::names(&self.paths),
settings: Setting::ALL.iter().map(|setting| setting.name().to_string()).collect(),
tags,
folder: self.folder(),
+17 -4
View File
@@ -22,6 +22,7 @@ pub mod action;
pub mod buffer;
pub mod calls;
pub mod commands;
mod configure;
pub mod documents;
mod files;
mod input;
@@ -35,6 +36,7 @@ mod terminal;
pub mod text_edit;
mod update;
use std::path::PathBuf;
use std::time::Instant;
use ratatui::layout::Rect;
@@ -43,6 +45,7 @@ pub use files::{Loaded, OpenError, create, load};
pub use terminal::run;
use crate::command::history::History as CommandHistory;
use crate::config::Config;
use crate::config::paths::Paths;
use crate::config::settings::Settings;
use crate::input::bindings::Context;
@@ -86,6 +89,11 @@ pub struct App {
dragging: Option<PaneId>,
/// When the files were last checked for changes made outside cumulus.
watched: Instant,
/// The config files as they were last read, to apply them again when
/// they change.
config_stamps: configure::ConfigStamps,
/// A file `:config` wants opened in the editor: the main loop does it.
edit_request: Option<PathBuf>,
quit: bool,
}
@@ -147,11 +155,13 @@ struct Screen {
}
impl App {
/// cumulus with the documents of `opening` open, the first one shown.
pub fn new(paths: Paths, settings: Settings, keymap: Keymap, opening: Opening) -> App {
/// cumulus with its config, and the documents of `opening` open, the
/// first one shown.
pub fn new(paths: Paths, config: Config, opening: Opening) -> App {
let memory = Memory::load(&paths.memory());
let Opening { first, others, message } = opening;
let theme = Theme { glyphs: settings.glyphs, ..Theme::default() };
let Config { settings, keymap, theme, problems } = config;
let config_stamps = configure::ConfigStamps::read(&paths, &settings.theme);
let view = ViewSettings { max_node_width: settings.max_node_width, ..ViewSettings::default() };
let (docs, first_said) = files::open_first(first, &paths);
let first = docs.at(0);
@@ -170,13 +180,16 @@ impl App {
screen: Screen::default(),
dragging: None,
watched: Instant::now(),
config_stamps,
edit_request: None,
quit: false,
};
app.came_back_to(first);
if let Some(camera) = app.shared.docs.get(first).camera {
app.panes.current_mut().view.set_camera(camera);
}
let mut said: Vec<Message> = message.into_iter().chain(first_said).collect();
let config_said = problems.into_iter().map(Message::problem);
let mut said: Vec<Message> = config_said.chain(message).chain(first_said).collect();
for loaded in others {
said.extend(app.add_document(loaded).1);
}
+21 -7
View File
@@ -9,7 +9,7 @@
//! the node it opened on. The card shows the cursor's body, and edits it in
//! place.
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use ratatui::Frame;
use ratatui::layout::{Position, Rect};
@@ -24,6 +24,7 @@ use super::registers::Registers;
use super::text_edit::{EditAt, StartEdit, TextEdit};
use crate::command::parse::border;
use crate::command::registry::{BORDERS, SPECS};
use crate::config::ConfigFile;
use crate::input::bindings::Context;
use crate::input::command::{Bound, Command, Operator, Target, TargetBound, help_for};
use crate::input::engine::Typed;
@@ -40,7 +41,8 @@ use crate::view::ViewContext;
const HELP_WIDTH: u16 = 76;
/// How wide a picker is, at most.
const PICKER_WIDTH: u16 = 44;
/// How wide the question about a file changed outside cumulus is, at most.
/// How wide the question about a file changed outside cumulus, and the
/// config files, are at most.
const CONFLICT_WIDTH: u16 = 64;
/// The widest the keys column of the help gets.
const KEYS_WIDTH: usize = 12;
@@ -344,11 +346,6 @@ impl Popup {
(format!("border · {}", node.title()), values, rows)
}
PickKind::Style => {
if theme.node_styles.is_empty() {
return Err(Message::info(
"this theme has no named styles yet: theme files come in part 8b of the build",
));
}
let mut values = vec![Pick::Style(None)];
values.extend(theme.node_styles.keys().cloned().map(|name| Pick::Style(Some(name))));
let rows = values
@@ -421,6 +418,23 @@ impl Popup {
Popup::Card(Card { node, edit: None, view: CardView::default() })
}
/// The config files (`:config`), under the folder they're in: Enter
/// opens one in `editor`.
pub fn config(folder: &Path, files: &[(ConfigFile, String)], editor: &str, theme: &Theme) -> Popup {
let muted = Style::new().fg(theme.muted);
let width = files.iter().map(|(_, name)| name.width()).max().unwrap_or(0);
let mut rows = vec![ListRow::heading(&folder.display().to_string(), muted)];
let mut runs = vec![Run::Nothing];
for (file, name) in files {
let spans = vec![Span::raw(format!("{name:width$} ")), Span::styled(file.about(), muted)];
rows.push(ListRow::new(spans, name.as_str()));
runs.push(Run::App(AppAction::EditConfig(*file)));
}
let footer = format!("Enter: open it in {editor} · Esc: close");
let list = ListPopup::new("config", footer, rows, Place::Center { width: CONFLICT_WIDTH });
Popup::Menu(Runs { list, runs })
}
/// Asks which version of a document to keep: its file changed outside
/// cumulus while it had unsaved changes here.
pub fn conflict(buffer: BufferId, file: &str) -> Popup {
+9 -3
View File
@@ -104,14 +104,20 @@ fn pickers_set_what_is_picked() {
[app(AppAction::LeaveMode), Action::edit(EditOp::SetBorder { id: g.soil, border: dashed })]
);
let no_styles = Popup::picker(PickKind::Style, &ctx, &[], 0).unwrap_err();
// The theme's own named styles.
let mut styles = pick(PickKind::Style);
search(&mut styles, "imp", &ctx);
let important = StyleName::parse("important").ok();
assert_eq!(
no_styles.text,
"this theme has no named styles yet: theme files come in part 8b of the build"
enter(&mut styles, &ctx),
[app(AppAction::LeaveMode), Action::edit(EditOp::SetStyle { id: g.soil, style: important })]
);
// Tags go on and off, and the picker stays open; a new one is added.
// The document's tags come with the theme's (done, important,
// question), in order: wood is last.
let mut tags = pick(PickKind::Tags);
tags.command(&typed(Command::Move(crate::input::command::Motion::Last)), &mut Registers::default(), &ctx);
let wood = Tag::parse("wood").unwrap();
let on = Action::edit(EditOp::Tag { id: g.soil, add: vec![wood.clone()], remove: Vec::new() });
let off = Action::edit(EditOp::Tag { id: g.soil, add: Vec::new(), remove: vec![wood] });
+51 -1
View File
@@ -1,15 +1,21 @@
//! The main loop, on a real terminal: set it up (raw mode, the alternate
//! screen, the mouse), read keys and the mouse, let time pass (autosave,
//! files changed outside cumulus), draw, and put the terminal back as it was
//! at the end, after a crash too.
//! at the end, after a crash too. For `:config`, it hands the terminal over
//! to your editor until it's done.
use std::io;
use std::path::Path;
use std::process::Command;
use std::time::{Duration, Instant};
use ratatui::DefaultTerminal;
use ratatui::crossterm::cursor::SetCursorStyle;
use ratatui::crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind};
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use super::App;
use crate::input::key::Key;
@@ -78,7 +84,51 @@ fn event_loop(app: &mut App, terminal: &mut DefaultTerminal) -> io::Result<()> {
_ => {}
}
}
if let Some(path) = app.take_edit_request() {
let result = edit(terminal, &app.editor(), &path, mouse);
app.edited(result);
draw = true;
}
draw |= app.tick(Instant::now()) || app.is_waiting();
}
Ok(())
}
/// Opens a file in the editor, with the terminal handed over to it until
/// it's done. Returns whether it could start.
fn edit(terminal: &mut DefaultTerminal, editor: &str, path: &Path, mouse: bool) -> io::Result<()> {
execute!(io::stdout(), DisableMouseCapture, SetCursorStyle::DefaultUserShape, LeaveAlternateScreen)?;
disable_raw_mode()?;
let ran = run_editor(editor, path);
enable_raw_mode()?;
execute!(io::stdout(), EnterAlternateScreen)?;
if mouse {
execute!(io::stdout(), EnableMouseCapture)?;
}
// Everything on screen is the editor's now: draw it all again.
terminal.clear()?;
ran
}
/// Runs the editor on a file and waits for it. The command is a program
/// with what it takes before the file (`code --wait`), or a program's whole
/// path, spaces and all.
fn run_editor(editor: &str, path: &Path) -> io::Result<()> {
let (program, args): (&str, Vec<&str>) = if Path::new(editor).is_file() {
(editor, Vec::new())
} else {
let mut words = editor.split_whitespace();
(words.next().unwrap_or(editor), words.collect())
};
let status = Command::new(program).args(&args).arg(path).status();
match status {
// Windows only starts .exe files by name; an editor that's a script
// (code is code.cmd) needs the command interpreter.
Err(error) if cfg!(windows) && error.kind() == io::ErrorKind::NotFound => {
let status = Command::new("cmd").arg("/C").arg(program).args(&args).arg(path).status()?;
// 9009: the interpreter didn't find it either.
if status.code() == Some(9009) { Err(error) } else { Ok(()) }
}
status => status.map(|_| ()),
}
}
+22 -6
View File
@@ -1,7 +1,9 @@
//! The app driven by typed keys and mouse gestures on a test terminal, with
//! the sample documents copied to a temporary folder: what each stage says
//! works, checked end to end. The files' own tests are in `tests/files.rs`.
//! works, checked end to end. The files have their own tests in
//! `tests/files.rs`, and the config folder in `tests/config.rs`.
mod config;
mod files;
use std::path::PathBuf;
@@ -50,8 +52,9 @@ impl Harness {
let mut loaded = names.iter().map(|name| load(&folder.path().join(name)).unwrap());
let first = loaded.next().unwrap();
let opening = Opening { first, others: loaded.collect(), message: None };
let app =
App::new(Harness::paths(&folder), Settings::default(), Keymap::new(DEFAULT_LEADER), opening);
// The config folder, as the test left it.
let paths = Harness::paths(&folder);
let app = App::new(paths.clone(), Config::load(&paths), opening);
let terminal = Terminal::new(TestBackend::new(100, 32)).unwrap();
let mut harness = Harness { app, terminal, folder, clock: Instant::now() };
harness.draw();
@@ -190,6 +193,18 @@ impl Harness {
std::fs::read_to_string(self.path(name)).unwrap_or_default()
}
/// The text color of the cell at a place on screen.
fn color_at(&self, (col, row): (u16, u16)) -> ratatui::style::Color {
self.terminal.backend().buffer()[(col, row)].fg
}
/// Writes a file in the config folder (making the folder first).
fn write_config(&self, name: &str, text: &str) {
let path = Harness::paths(&self.folder).config.join(name);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
/// The terminal's cursor, when it shows.
fn text_cursor(&self) -> Option<(u16, u16)> {
let backend = self.terminal.backend();
@@ -679,9 +694,10 @@ fn more_keys_across_views() {
assert_eq!(h.label(), "HELP");
assert!(h.screen().contains("/fold"));
h.type_keys("<Esc><Esc>");
// <Space>s: this theme has no named styles.
h.type_keys("<Space>s");
assert_eq!(h.message(), "this theme has no named styles yet: theme files come in part 8b of the build");
// <Space>s: the theme's named styles.
h.type_keys("<Space>s/question<CR>");
let node = h.doc().node(h.app.current().cursor()).unwrap();
assert_eq!(node.look.style.as_ref().map(|style| style.as_str()), Some("question"));
// <Space>w: no key hints: which-key and the hint line go.
assert!(h.rows().last().unwrap().contains("? help"));
h.type_keys("<Space>w");
+109
View File
@@ -0,0 +1,109 @@
//! The config folder, end to end: the built-in styles and their tags,
//! config.toml, keys.toml and themes applied when they're saved, `:theme`,
//! and `:config`.
use ratatui::style::Color;
use super::*;
use crate::config::ConfigFile;
#[test]
fn the_built_in_styles_come_with_their_tags() {
let mut h = Harness::new();
h.type_keys("gl:tag +question<CR>gg");
let (col, row) = h.find("Soil");
let border = h.rows()[usize::from(row) - 1].chars().nth(usize::from(col)).unwrap();
assert_eq!(border, '╌', "#question: a dashed border\n{}", h.screen());
assert_eq!(h.color_at((col - 2, row)), Color::Indexed(178), "in yellow");
// #done fades the title back (once the cursor is elsewhere).
h.type_keys("glgjgj:tag +done<CR>gg");
assert_eq!(h.color_at(h.find("Watering")), Color::DarkGray);
}
#[test]
fn config_toml_applies_when_saved() {
let mut h = Harness::new();
h.write_config("config.toml", "max_node_width = 10\nglyphs = \"ascii\"\n");
h.wait(2);
assert_eq!(h.message(), "applied config.toml");
let boxes = |h: &Harness| h.screen().chars().any(|c| ('\u{2500}'..='\u{257f}').contains(&c));
assert!(!boxes(&h), "{}", h.screen());
assert!(!h.screen().contains("Rain barrel"), "titles wrap at 10");
// A mistake keeps that setting at its default, and says so; what the
// file no longer says is back to its default too.
h.write_config("config.toml", "mouse = \"maybe\"\n");
h.wait(2);
assert_eq!(h.message(), "config.toml: mouse is on or off");
assert!(boxes(&h) && h.screen().contains("Rain barrel"));
}
#[test]
fn config_problems_show_at_startup() {
let folder = TempDir::new();
std::fs::write(folder.path().join("garden.cum"), GARDEN).unwrap();
let config = Harness::paths(&folder).config;
std::fs::create_dir_all(&config).unwrap();
std::fs::write(config.join("config.toml"), "colour = 1\n").unwrap();
let h = Harness::open(folder, &["garden.cum"]);
assert!(h.message().starts_with("config.toml: there's no setting called colour"), "{}", h.message());
}
#[test]
fn keys_toml_applies_when_saved() {
let mut h = Harness::new();
h.write_config("keys.toml", "[normal]\n\"Q\" = \"frobnicate\"\n");
h.wait(2);
assert_eq!(h.message(), "keys.toml: [normal] \"Q\": there's no command \"frobnicate\"");
h.write_config("keys.toml", "[normal]\n\"Q\" = \"close\"\n");
h.wait(2);
assert_eq!(h.message(), "applied keys.toml");
h.type_keys("Q");
assert!(h.app.is_done(), "Q closes the last pane: that quits");
}
#[test]
fn themes_switch_and_apply_when_saved() {
let mut h = Harness::new();
h.write_config("themes/night.toml", "accent = \"magenta\"\n[styles.node.later]\nborder = \"double\"\n");
h.type_keys(":theme night<CR>");
assert_eq!(h.message(), "theme = night");
assert_eq!(h.app.shared.theme.accent, Color::Magenta);
h.type_keys("<Space>s");
assert!(h.screen().contains("later"), "its style is in the picker:\n{}", h.screen());
h.type_keys("<Esc>");
h.write_config("themes/night.toml", "accent = \"blue\"\n");
h.wait(2);
assert_eq!(h.message(), "applied themes/night.toml");
assert_eq!(h.app.shared.theme.accent, Color::Blue);
h.type_keys(":set theme=nope<CR>");
assert_eq!(h.message(), "there's no theme nope: its file would be themes/nope.toml");
assert_eq!(h.app.shared.theme.accent, Color::Blue, "still night");
h.type_keys(":theme default<CR>");
assert_eq!(h.app.shared.theme.accent, Color::Cyan);
}
#[test]
fn config_opens_its_files_in_the_editor() {
let mut h = Harness::new();
h.type_keys(":set editor=myeditor<CR>:config<CR>");
assert_eq!(h.label(), "MENU");
let screen = h.screen();
for text in ["config.toml", "keys.toml", "themes/default.toml", "Enter: open it in myeditor"] {
assert!(screen.contains(text), "{text}:\n{screen}");
}
let paths = Harness::paths(&h.folder);
// Enter: config.toml, made first, every setting commented out.
h.type_keys("<CR>");
assert_eq!(h.label(), "NORMAL");
let settings = ConfigFile::Settings.path(&paths, "default");
assert_eq!(h.app.take_edit_request(), Some(settings.clone()));
assert!(std::fs::read_to_string(&settings).unwrap().contains("# autosave = true"));
// The theme's file, the same way.
h.type_keys(":config<CR>jj<CR>");
let theme = ConfigFile::Theme.path(&paths, "default");
assert_eq!(h.app.take_edit_request(), Some(theme.clone()));
assert!(std::fs::read_to_string(&theme).unwrap().contains("# accent = \"cyan\""));
// An editor that doesn't start says so.
h.app.edited(Err(std::io::Error::other("not found")));
assert_eq!(h.message(), "couldn't start myeditor: not found · the editor setting picks another");
}
+2 -13
View File
@@ -12,7 +12,6 @@ use super::panes::Arrange;
use super::popup::{DocumentRow, MenuFor, Popup};
use super::text_edit::{StartEdit, TextEdit};
use super::{App, Pane};
use crate::command::parse::SetArg;
use crate::ui::command_line::CommandLine;
use crate::view::ViewKind;
@@ -184,6 +183,8 @@ impl App {
}
}
AppAction::SwitchDocument(choice) => return self.switch_document(choice),
AppAction::OpenConfig => self.open_config(),
AppAction::EditConfig(file) => return self.edit_config(file),
AppAction::Save(which) => return self.write(which),
AppAction::Edit { path, force } => return self.edit(path, force),
AppAction::NewDocument(title) => return self.new_document(title),
@@ -261,16 +262,4 @@ impl App {
.map(|recent| DocumentRow { name: recent.title.clone(), path: recent.path.clone(), open: false });
open.chain(recent).collect()
}
/// `:set`: changes a setting, and puts it to work.
fn set(&mut self, arg: &SetArg) -> Message {
match self.settings.apply(arg) {
Ok(text) => {
self.shared.theme.glyphs = self.settings.glyphs;
self.shared.view.max_node_width = self.settings.max_node_width;
if matches!(arg, SetArg::Show(_)) { Message::info(text) } else { Message::done(text) }
}
Err(problem) => Message::problem(problem),
}
}
}