pub mod keymap;
use self::keymap::Shortcut;
pub mod theme;
use these_macros_should_be_illegal::discriminated_str;
#[cfg(not(test))]
use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use self::theme::Appearance;
use crate::input::InputSettings;
use crate::ui::config::UI;
use crate::ui::cycle::cyclic;
use crate::ui::key::KeyEvent;
use crate::ui::render::maths::{FractionStyle, MathDisplay};
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(Default, Serialize, Deserialize)]
#[serde(default)]
pub struct SavedSettings {
pub editor: Settings,
pub input: InputSettings,
pub rebinds: Vec<(Shortcut, KeyEvent)>,
}
impl Persisted for SavedSettings {
const FILE: &'static str = "settings.json";
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Preferences {
pub appearance: Appearance,
pub input: InputSettings,
pub editor: Settings,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
pub language_server: bool,
pub semantic_highlighting: bool,
pub statusline: StatusLinePosition,
pub idle_statusline: IdleStatusLine,
pub maths: MathDisplay,
pub fractions: FractionStyle,
pub math_fonts: bool,
pub matrix_row_spacing_percent: u16,
pub output_rows: usize,
pub example_seconds: u16,
}
impl Default for Settings {
fn default() -> Self {
Self {
language_server: true,
semantic_highlighting: true,
statusline: StatusLinePosition::default(),
idle_statusline: IdleStatusLine::default(),
maths: MathDisplay::default(),
fractions: FractionStyle::default(),
math_fonts: false,
matrix_row_spacing_percent: UI.matrix_row_spacing_percent,
output_rows: UI.max_output_rows,
example_seconds: 0,
}
}
}
impl Settings {
pub const fn semantic_colors(&self) -> bool {
self.language_server && self.semantic_highlighting
}
pub fn adjust_output_rows(&mut self, delta: i8) {
let step = usize::from(delta.unsigned_abs());
self.output_rows = match delta > 0 {
true => self.output_rows.saturating_add(step),
false => self.output_rows.saturating_sub(step),
}
.max(UI.min_output_rows);
}
pub fn adjust_matrix_row_spacing(&mut self, delta: i8) {
let step = u16::from(delta.unsigned_abs()) * UI.matrix_row_spacing_step;
self.matrix_row_spacing_percent = match delta > 0 {
true => self.matrix_row_spacing_percent.saturating_add(step),
false => self.matrix_row_spacing_percent.saturating_sub(step),
}
.clamp(
UI.min_matrix_row_spacing_percent,
UI.max_matrix_row_spacing_percent,
);
}
pub fn adjust_example_seconds(&mut self, delta: i8) {
let step = u16::from(delta.unsigned_abs());
self.example_seconds = match delta > 0 {
true => self.example_seconds.saturating_add(step),
false => self.example_seconds.saturating_sub(step),
};
}
pub fn example_timeout(&self) -> Duration {
Duration::from_secs(self.example_seconds.into())
}
pub fn reformats(&self, other: &Self) -> bool {
(
self.maths,
self.fractions,
self.math_fonts,
self.matrix_row_spacing_percent,
self.output_rows,
) != (
other.maths,
other.fractions,
other.math_fonts,
other.matrix_row_spacing_percent,
other.output_rows,
)
}
}
cyclic! {
#[discriminated_str(label)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StatusLinePosition {
#[default]
BelowInput = "below input",
AboveInput = "above input",
Off = "off",
}
}
cyclic! {
#[discriminated_str(label)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SegmentPlacement {
Left = "left",
Center = "centre",
Right = "right",
Hidden = "off",
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct IdleStatusLine {
pub scheme: SegmentPlacement,
pub directory: SegmentPlacement,
pub hints: SegmentPlacement,
}
impl Default for IdleStatusLine {
fn default() -> Self {
Self {
scheme: SegmentPlacement::Left,
directory: SegmentPlacement::Left,
hints: SegmentPlacement::Right,
}
}
}
#[cfg(test)]
mod tests {
use std::env;
use std::time::{SystemTime, UNIX_EPOCH};
use crossterm::event::KeyCode::Char;
use super::keymap::Keymaps;
use super::*;
use crate::ui::action::Action;
use crate::ui::key::KeyMod::Alt;
#[test]
fn the_examples_timeout_steps_by_seconds_and_stops_at_off() {
let mut settings = Settings::default();
settings.adjust_example_seconds(1);
let on = settings.example_timeout();
assert!(!on.is_zero());
settings.adjust_example_seconds(1);
assert!(settings.example_timeout() > on);
settings.adjust_example_seconds(-2);
settings.adjust_example_seconds(-1);
assert!(
settings.example_timeout().is_zero(),
"off is as low as it goes"
);
}
#[test]
fn the_matrix_row_spacing_stays_within_its_range() {
let mut settings = Settings::default();
settings.adjust_matrix_row_spacing(i8::MIN);
settings.adjust_matrix_row_spacing(i8::MIN);
settings.adjust_matrix_row_spacing(-1);
assert_eq!(
settings.matrix_row_spacing_percent,
UI.min_matrix_row_spacing_percent
);
settings.matrix_row_spacing_percent = UI.max_matrix_row_spacing_percent;
settings.adjust_matrix_row_spacing(1);
assert_eq!(
settings.matrix_row_spacing_percent,
UI.max_matrix_row_spacing_percent
);
}
#[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.editor.fractions = FractionStyle::Inline;
saved.editor.statusline = StatusLinePosition::Off;
saved.editor.output_rows = 7;
saved.input.scrolloff = 5;
write(&path, &saved).expect("write settings");
let loaded: SavedSettings = read(&path).expect("read settings");
let _ = fs::remove_file(&path);
assert_eq!(loaded.editor.fractions, FractionStyle::Inline);
assert_eq!(loaded.editor.statusline, StatusLinePosition::Off);
assert_eq!(loaded.editor.output_rows, 7);
assert_eq!(loaded.input.scrolloff, 5);
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#"{"editor":{"fractions":"underlined"}}"#).expect("parses");
assert_eq!(saved.editor.fractions, FractionStyle::Underlined);
assert_eq!(saved.editor.output_rows, Settings::default().output_rows);
assert!(saved.rebinds.is_empty());
}
#[test]
fn semantic_highlighting_defaults_on_for_old_settings_and_saves_explicit_off() {
let old: SavedSettings = serde_json::from_str(r#"{"editor":{"math_fonts":true}}"#).unwrap();
assert!(old.editor.semantic_highlighting);
assert!(old.editor.math_fonts);
let mut disabled = old;
disabled.editor.semantic_highlighting = false;
let roundtrip: SavedSettings =
serde_json::from_str(&serde_json::to_string(&disabled).unwrap()).unwrap();
assert!(!roundtrip.editor.semantic_highlighting);
assert!(roundtrip.editor.math_fonts);
}
#[test]
fn semantic_colors_need_the_language_server_but_keep_their_choice() {
let old: SavedSettings = serde_json::from_str(r#"{"editor":{}}"#).unwrap();
assert!(old.editor.language_server);
let mut settings = old.editor;
settings.semantic_highlighting = true;
settings.language_server = false;
assert!(!settings.semantic_colors());
settings.language_server = true;
assert!(settings.semantic_colors());
}
}