Files
feresaria/crates/ferese-rime/src/profiles.rs
T

186 lines
5.9 KiB
Rust

//! Window-local mode/schema preferences, with application defaults for reopening.
//! Persist preferences only: never composition, surrounding text or window titles.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
pub struct Profile {
pub schema: String,
pub ascii: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Context {
pub session: String,
pub window: u64,
pub app_id: String,
}
impl Context {
fn key(&self) -> String {
format!("{}:{}", self.session, self.window)
}
}
#[derive(Default, Deserialize, Serialize)]
struct Stored {
#[serde(default)]
applications: BTreeMap<String, Profile>,
#[serde(default)]
windows: BTreeMap<String, Profile>,
}
pub struct Profiles {
path: PathBuf,
default: Profile,
stored: Stored,
dirty: bool,
pub context: Option<Context>,
pub current: Profile,
}
impl Profiles {
pub fn open(path: &Path, schema: String) -> Result<Self, String> {
let stored = match std::fs::read(path) {
Ok(bytes) => serde_json::from_slice(&bytes)
.map_err(|e| format!("invalid IME preferences: {e}"))?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Stored::default(),
Err(e) => return Err(e.to_string()),
};
let default = Profile {
schema,
ascii: false,
};
Ok(Self {
path: path.to_owned(),
stored,
dirty: false,
context: None,
current: default.clone(),
default,
})
}
pub fn focus(&mut self, context: Option<Context>) -> Profile {
if let Some(context) = &context {
let prefix = format!("{}:", context.session);
self.stored
.windows
.retain(|key, _| key.starts_with(&prefix));
}
let profile = context
.as_ref()
.and_then(|c| {
self.stored
.windows
.get(&c.key())
.or_else(|| self.stored.applications.get(&c.app_id))
})
.unwrap_or(&self.default)
.clone();
self.context = context;
self.current = profile.clone();
// Pin even the initial default. A second window of the same application
// can change its mode without changing this already-open window.
if let Some(context) = &self.context {
self.dirty |= !self.stored.windows.contains_key(&context.key());
self.stored
.windows
.entry(context.key())
.or_insert_with(|| profile.clone());
}
profile
}
pub fn remember(&mut self, profile: Profile) -> Result<(), String> {
if self.current == profile {
return self.flush();
}
self.current = profile.clone();
let Some(context) = &self.context else {
return Ok(());
};
self.stored.windows.insert(context.key(), profile.clone());
if !context.app_id.is_empty() {
self.stored
.applications
.insert(context.app_id.clone(), profile);
}
self.dirty = true;
self.flush()
}
pub fn flush(&mut self) -> Result<(), String> {
if self.dirty {
self.save()?;
self.dirty = false;
}
Ok(())
}
fn save(&self) -> Result<(), String> {
let parent = self.path.parent().ok_or("preferences path has no parent")?;
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?;
temp.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o600))
.map_err(|e| e.to_string())?;
serde_json::to_writer_pretty(&mut temp, &self.stored).map_err(|e| e.to_string())?;
temp.write_all(b"\n")
.and_then(|()| temp.as_file().sync_all())
.map_err(|e| e.to_string())?;
temp.persist(&self.path).map_err(|e| e.to_string())?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn context(window: u64, app: &str) -> Option<Context> {
Some(Context {
session: "one".into(),
window,
app_id: app.into(),
})
}
#[test]
fn isolates_windows_restores_closed_apps_and_survives_service_restart() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("preferences.json");
let rime = Profile {
schema: "luna_pinyin_simp".into(),
ascii: false,
};
let english = Profile {
ascii: true,
..rime.clone()
};
let other = Profile {
schema: "luna_pinyin".into(),
ascii: false,
};
let mut p = Profiles::open(&path, rime.schema.clone()).unwrap();
assert_eq!(p.focus(context(1, "QQ")), rime);
p.remember(english.clone()).unwrap();
assert_eq!(p.focus(context(2, "QQ")), english);
p.remember(other.clone()).unwrap();
assert_eq!(p.focus(context(1, "QQ")), english);
assert_eq!(p.focus(context(3, "teams")), rime);
drop(p);
let mut p = Profiles::open(&path, rime.schema.clone()).unwrap();
assert_eq!(p.focus(context(1, "QQ")), english);
assert_eq!(p.focus(context(2, "QQ")), other);
assert_eq!(p.focus(context(4, "QQ")), other);
let mut fresh = context(1, "QQ").unwrap();
fresh.session = "two".into();
assert_eq!(p.focus(Some(fresh)), other);
assert_eq!(p.focus(None), rime);
assert_eq!(
std::fs::metadata(path).unwrap().permissions().mode() & 0o777,
0o600
);
}
}