use std::io;
use std::path::{Path, PathBuf};
use corundum_framework_platform::dirs::AppDirs;
use serde::{Deserialize, Serialize};
use crate::RailOrder;
use crate::look::Look;
pub const FILE_NAME: &str = "corundum.toml";
#[derive(Clone, Default, PartialEq, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Stored {
pub look: Option<Look>,
pub rail: Option<RailOrder>,
pub side: Option<SidePane>,
}
#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct SidePane {
pub column: bool,
}
impl Default for SidePane {
fn default() -> SidePane {
SidePane { column: true }
}
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Store {
path: PathBuf,
}
impl Store {
pub fn new(dirs: &AppDirs) -> Store {
Store::at(dirs.config().join(FILE_NAME))
}
pub fn for_app(app_name: &str) -> Option<Store> {
AppDirs::new(app_name).map(|dirs| Store::new(&dirs))
}
pub fn at(path: impl Into<PathBuf>) -> Store {
Store { path: path.into() }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn load(&self) -> Option<Stored> {
let text = match std::fs::read_to_string(&self.path) {
Ok(text) => text,
Err(err) if err.kind() == io::ErrorKind::NotFound => return None,
Err(err) => {
tracing::warn!(
"could not read corundum's file {}: {err}",
self.path.display()
);
return None;
}
};
toml::from_str(&text)
.inspect_err(|err| {
tracing::warn!(
"corundum's file {} is not readable: {err}",
self.path.display()
);
})
.ok()
}
pub fn save(&self, state: &Stored) -> io::Result<()> {
if let Some(dir) = self.path.parent() {
std::fs::create_dir_all(dir)?;
}
let text = toml::to_string_pretty(state).map_err(io::Error::other)?;
let mut tmp = self.path.clone().into_os_string();
tmp.push(".tmp");
std::fs::write(&tmp, text)?;
std::fs::rename(&tmp, &self.path)
}
}
#[derive(Clone, Default)]
struct Held {
state: Stored,
store: Option<Store>,
}
fn held_id() -> egui::Id {
egui::Id::unique("corundum_ui_store")
}
fn held(ctx: &egui::Context) -> Held {
ctx.data(|d| d.get_temp::<Held>(held_id()))
.unwrap_or_default()
}
fn set_held(ctx: &egui::Context, held: Held) {
ctx.data_mut(|d| d.insert_temp(held_id(), held));
}
pub fn attach(ctx: &egui::Context, store: Store) -> bool {
let saved = store.load();
let found = saved.is_some();
let state = saved.unwrap_or_default();
if let Some(look) = &state.look {
crate::look::install(ctx, look);
}
set_held(
ctx,
Held {
state,
store: Some(store),
},
);
found
}
pub fn state(ctx: &egui::Context) -> Stored {
held(ctx).state
}
pub fn store(ctx: &egui::Context) -> Option<Store> {
held(ctx).store
}
pub(crate) fn update(ctx: &egui::Context, change: impl FnOnce(&mut Stored)) {
let mut held = held(ctx);
let before = held.state.clone();
change(&mut held.state);
if held.state == before {
return;
}
if let Some(store) = &held.store
&& let Err(err) = store.save(&held.state)
{
tracing::warn!(
"could not save corundum's file {}: {err}",
store.path().display()
);
}
set_held(ctx, held);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::i18n::{Language, LanguageSettings};
use crate::theme::{Contrast, Swatch, ThemeSettings, Tint};
fn look() -> Look {
Look {
theme: ThemeSettings {
preference: egui::ThemePreference::Dark,
contrast: Contrast::High,
accent: Some(Tint::Swatch(Swatch::Green)),
},
language: LanguageSettings {
language: Language::Explicit("ja".into()),
},
}
}
fn rail() -> RailOrder {
RailOrder {
order: vec!["b".into(), "a".into()],
hidden: vec!["a".into()],
}
}
fn store(dir: &tempfile::TempDir) -> Store {
Store::at(dir.path().join("app").join(FILE_NAME))
}
#[test]
fn what_is_saved_loads_back() {
let dir = tempfile::tempdir().unwrap();
let store = store(&dir);
assert_eq!(store.load(), None, "nothing saved yet");
let state = Stored {
look: Some(look()),
rail: Some(rail()),
side: None,
};
store.save(&state).unwrap();
assert_eq!(store.load(), Some(state));
assert!(!dir.path().join("app/corundum.toml.tmp").exists());
}
#[test]
fn every_accent_and_language_is_written_as_toml() {
let dir = tempfile::tempdir().unwrap();
let store = store(&dir);
let accents = [
None,
Some(Tint::Swatch(Swatch::Red)),
Some(Tint::Custom {
chroma: 0.125,
hue: 270.0,
}),
];
let languages = [Language::System, Language::Explicit("en".into())];
for accent in accents {
for language in languages.clone() {
let state = Stored {
look: Some(Look {
theme: ThemeSettings {
accent,
..Default::default()
},
language: LanguageSettings { language },
}),
rail: None,
side: None,
};
store.save(&state).unwrap();
assert_eq!(store.load(), Some(state));
}
}
}
#[test]
fn a_hand_written_file_reads() {
let dir = tempfile::tempdir().unwrap();
let store = store(&dir);
std::fs::create_dir_all(store.path().parent().unwrap()).unwrap();
std::fs::write(
store.path(),
"[look.theme]\ncontrast = \"High\"\n\n[look.language]\nlanguage = { Explicit = \"ja\" }\n\n[rail]\norder = [\"b\", \"a\"]\n",
)
.unwrap();
let state = store.load().unwrap();
let look = state.look.unwrap();
assert_eq!(look.theme.contrast, Contrast::High);
assert_eq!(look.language.language, Language::Explicit("ja".into()));
assert_eq!(state.rail.unwrap().order, ["b", "a"]);
}
#[test]
fn a_broken_file_is_nothing_saved() {
let dir = tempfile::tempdir().unwrap();
let store = store(&dir);
std::fs::create_dir_all(store.path().parent().unwrap()).unwrap();
std::fs::write(store.path(), "[look\n").unwrap();
assert_eq!(store.load(), None);
}
#[test]
fn an_empty_file_is_an_empty_state() {
let dir = tempfile::tempdir().unwrap();
let store = store(&dir);
std::fs::create_dir_all(store.path().parent().unwrap()).unwrap();
std::fs::write(store.path(), "").unwrap();
assert_eq!(store.load(), Some(Stored::default()));
}
#[test]
fn attach_installs_the_saved_look() {
let dir = tempfile::tempdir().unwrap();
let state = Stored {
look: Some(look()),
rail: Some(rail()),
side: None,
};
store(&dir).save(&state).unwrap();
let ctx = egui::Context::default();
assert!(attach(&ctx, store(&dir)));
assert_eq!(super::state(&ctx), state);
assert!(crate::theme::is_installed(&ctx));
assert_eq!(crate::i18n::current(&ctx).to_string(), "ja");
}
#[test]
fn attach_without_anything_saved_installs_nothing() {
let dir = tempfile::tempdir().unwrap();
let ctx = egui::Context::default();
assert!(!attach(&ctx, store(&dir)));
assert_eq!(super::state(&ctx), Stored::default());
assert!(!crate::theme::is_installed(&ctx));
assert_eq!(super::store(&ctx), Some(store(&dir)));
}
#[test]
fn an_update_saves_what_changed_and_keeps_the_rest() {
let dir = tempfile::tempdir().unwrap();
let ctx = egui::Context::default();
attach(&ctx, store(&dir));
update(&ctx, |s| s.look = Some(look()));
update(&ctx, |s| s.rail = Some(rail()));
let saved = store(&dir).load().unwrap();
assert_eq!(saved.look, Some(look()));
assert_eq!(saved.rail, Some(rail()));
}
#[test]
fn an_update_that_changes_nothing_is_not_saved() {
let dir = tempfile::tempdir().unwrap();
let ctx = egui::Context::default();
attach(&ctx, store(&dir));
update(&ctx, |s| s.rail = Some(rail()));
std::fs::remove_file(store(&dir).path()).unwrap();
update(&ctx, |s| s.rail = Some(rail()));
assert!(!store(&dir).path().exists());
}
#[test]
fn an_unwritable_store_still_holds_the_change() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("app"), "").unwrap();
let ctx = egui::Context::default();
attach(&ctx, store(&dir));
update(&ctx, |s| s.rail = Some(rail()));
assert_eq!(super::state(&ctx).rail, Some(rail()));
}
}