pub mod appearance;
pub mod keymap;
pub mod layout;
pub mod rendering;
pub mod selection;
pub mod statusline;
pub mod tabs;
pub mod theme;
#[cfg(not(test))]
use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use self::keymap::Shortcut;
use self::layout::LayoutSettings;
use self::rendering::RenderingSettings;
use self::selection::SelectionSettings;
use self::statusline::StatusLineSettings;
use self::theme::Appearance;
use crate::ui::key::KeyEvent;
pub trait Persisted: Serialize + DeserializeOwned + Default {
const FILE: &'static str;
fn load() -> Self {
config_file(Self::FILE)
.as_deref()
.and_then(read)
.unwrap_or_default()
}
fn save(&self) {
if let Some(path) = config_file(Self::FILE) {
let _ = write(&path, self);
}
}
}
pub fn read<T: DeserializeOwned>(path: &Path) -> Option<T> {
serde_json::from_str(&fs::read_to_string(path).ok()?).ok()
}
pub fn write<T: Serialize>(path: &Path, record: &T) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, serde_json::to_vec_pretty(record)?)
}
#[cfg(not(test))]
pub(super) fn config_file(name: &str) -> Option<PathBuf> {
let config = env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?;
Some(config.join(env!("CARGO_PKG_NAME")).join(name))
}
#[cfg(test)]
pub(super) fn config_file(_: &str) -> Option<PathBuf> {
None
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Settings {
pub appearance: Appearance,
pub layout: LayoutSettings,
pub statusline: StatusLineSettings,
pub rendering: RenderingSettings,
pub selection: SelectionSettings,
}
impl Settings {
pub fn reformats(&self, other: &Self) -> bool {
self.layout.output_rows != other.layout.output_rows
|| self.rendering.reformats(&other.rendering)
}
}
#[derive(Default, Serialize, Deserialize)]
#[serde(from = "StoredSettings")]
pub struct SavedSettings {
pub layout: LayoutSettings,
pub statusline: StatusLineSettings,
pub rendering: RenderingSettings,
pub selection: SelectionSettings,
pub rebinds: Vec<(Shortcut, KeyEvent)>,
}
impl Persisted for SavedSettings {
const FILE: &'static str = "settings.json";
}
#[derive(Default, Deserialize)]
#[serde(default)]
struct StoredSettings {
layout: LayoutSettings,
statusline: StatusLineSettings,
rendering: RenderingSettings,
selection: SelectionSettings,
rebinds: Vec<(Shortcut, KeyEvent)>,
editor: Map<String, Value>,
input: Map<String, Value>,
}
impl From<StoredSettings> for SavedSettings {
fn from(stored: StoredSettings) -> Self {
let rebinds = stored.rebinds;
if stored.editor.is_empty() && stored.input.is_empty() {
return Self {
layout: stored.layout,
statusline: stored.statusline,
rendering: stored.rendering,
selection: stored.selection,
rebinds,
};
}
let older: Map<_, _> = stored.editor.into_iter().chain(stored.input).collect();
Self {
layout: section_of(&older),
statusline: section_of(&older),
rendering: section_of(&older),
selection: stored.selection,
rebinds,
}
}
}
fn section_of<Section: DeserializeOwned + Default>(older: &Map<String, Value>) -> Section {
serde_json::from_value(Value::Object(older.clone())).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use std::env;
use std::time::{SystemTime, UNIX_EPOCH};
use crossterm::event::KeyCode::Char;
use super::keymap::Keymaps;
use super::statusline::{SegmentPlacement, StatusLinePosition};
use super::*;
use crate::ui::action::Action;
use crate::ui::key::KeyMod::Alt;
use crate::ui::render::maths::FractionStyle;
#[test]
fn saved_settings_survive_a_restart() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after the Unix epoch")
.as_nanos();
let path = env::temp_dir().join(format!(
"spectral-m2-settings-{}-{nonce}.json",
std::process::id()
));
let mut keymaps = Keymaps::default();
let taken = KeyEvent::from(Char('o')) + Alt;
assert!(keymaps.rebind(Action::ClearFeed, taken));
let mut saved = SavedSettings {
rebinds: keymaps.rebinds().to_vec(),
..SavedSettings::default()
};
saved.rendering.fractions = FractionStyle::Inline;
saved.statusline.position = StatusLinePosition::Off;
saved.layout.output_rows = 7;
saved.layout.scrolloff = 5;
saved.selection.copy_on_select = false;
saved.selection.raw = true;
write(&path, &saved).expect("write settings");
let loaded: SavedSettings = read(&path).expect("read settings");
let _ = fs::remove_file(&path);
assert_eq!(loaded.rendering.fractions, FractionStyle::Inline);
assert_eq!(loaded.statusline.position, StatusLinePosition::Off);
assert_eq!(loaded.layout.output_rows, 7);
assert_eq!(loaded.layout.scrolloff, 5);
assert_eq!(loaded.selection, saved.selection);
let replayed = Keymaps::replaying(&loaded.rebinds);
for action in [Action::ClearFeed, Action::OpenOutput] {
assert_eq!(replayed.key_for(action), keymaps.key_for(action));
}
}
#[test]
fn a_partial_record_fills_in_defaults() {
let saved: SavedSettings =
serde_json::from_str(r#"{"rendering":{"fractions":"underlined"}}"#).expect("parses");
assert_eq!(saved.rendering.fractions, FractionStyle::Underlined);
assert_eq!(saved.layout, LayoutSettings::default());
assert!(saved.rebinds.is_empty());
assert_eq!(saved.selection, SelectionSettings::default());
}
#[test]
fn a_record_keeping_values_under_editor_and_input_reads_into_the_sections() {
let saved: SavedSettings = serde_json::from_str(
r#"{
"editor": {
"language_server": false,
"statusline": "above_input",
"idle_statusline": {"scheme": "right"},
"fractions": "inline",
"output_rows": 50,
"example_seconds": 2
},
"input": {"max_height_percent": 60, "complete_after": 3, "hintCompletionBelow": 4},
"rebinds": [["CopySelection", {"code": {"Char": "y"}, "modifiers": 2}]]
}"#,
)
.expect("parses");
assert!(!saved.rendering.language_server);
assert_eq!(saved.rendering.fractions, FractionStyle::Inline);
assert_eq!(saved.rendering.example_seconds, 2);
assert_eq!(saved.statusline.position, StatusLinePosition::AboveInput);
assert_eq!(saved.statusline.idle.scheme, SegmentPlacement::Right);
assert_eq!(saved.layout.output_rows, 50);
assert_eq!(saved.layout.max_input_height_percent, 60);
assert_eq!(saved.layout.complete_after, 3);
assert_eq!(saved.layout.hint_completion_below, 4);
assert_eq!(saved.rebinds.len(), 1);
}
#[test]
fn a_saved_record_reads_back_as_written() {
let mut saved = SavedSettings::default();
saved.rendering.semantic_highlighting = false;
saved.statusline.idle.hints = SegmentPlacement::Hidden;
let roundtrip: SavedSettings =
serde_json::from_str(&serde_json::to_string(&saved).unwrap()).unwrap();
assert!(!roundtrip.rendering.semantic_highlighting);
assert_eq!(roundtrip.statusline.idle.hints, SegmentPlacement::Hidden);
}
}