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
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user