335 lines
10 KiB
Rust
335 lines
10 KiB
Rust
//! What the toolbar remembers between runs, kept out of the config so a
|
|
//! read-only (home-manager) config still works.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::config::Config;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum Mode {
|
|
Screen,
|
|
Window,
|
|
Region,
|
|
Zoom,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
pub struct ToolbarState {
|
|
pub mode: Mode,
|
|
/// Seconds before the capture: 0, 5 or 10.
|
|
pub timer: u32,
|
|
pub cursor: bool,
|
|
pub preview: bool,
|
|
pub sound: bool,
|
|
}
|
|
|
|
/// The file, every key optional.
|
|
#[derive(Deserialize)]
|
|
struct File {
|
|
mode: Option<Mode>,
|
|
timer: Option<u32>,
|
|
cursor: Option<bool>,
|
|
preview: Option<bool>,
|
|
sound: Option<bool>,
|
|
}
|
|
|
|
pub const TIMERS: [u32; 3] = [0, 5, 10];
|
|
|
|
/// `$XDG_STATE_HOME/valw/toolbar.toml`, falling back to `~/.local/state`.
|
|
pub fn default_path() -> PathBuf {
|
|
std::env::var_os("XDG_STATE_HOME")
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| crate::config::home().join(".local/state"))
|
|
.join("valw/toolbar.toml")
|
|
}
|
|
|
|
pub fn defaults(config: &Config) -> ToolbarState {
|
|
ToolbarState {
|
|
mode: Mode::Region,
|
|
timer: 0,
|
|
cursor: config.capture.show_cursor,
|
|
preview: config.preview.enabled,
|
|
sound: config.sound.enabled,
|
|
}
|
|
}
|
|
|
|
/// The remembered state, or the defaults if there is none or it is broken.
|
|
pub fn load(path: &Path, config: &Config) -> ToolbarState {
|
|
let fallback = defaults(config);
|
|
let text = match std::fs::read_to_string(path) {
|
|
Ok(text) => text,
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return fallback,
|
|
Err(e) => {
|
|
tracing::warn!("could not read {}: {e}", path.display());
|
|
return fallback;
|
|
}
|
|
};
|
|
match parse(&text, fallback) {
|
|
Ok(state) => state,
|
|
Err(e) => {
|
|
tracing::warn!("ignoring {}: {e:#}", path.display());
|
|
fallback
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse(text: &str, fallback: ToolbarState) -> Result<ToolbarState> {
|
|
let file: File = toml::from_str(text)?;
|
|
let timer = file.timer.unwrap_or(fallback.timer);
|
|
if !TIMERS.contains(&timer) {
|
|
bail!("timer must be 0, 5 or 10, not {timer}");
|
|
}
|
|
Ok(ToolbarState {
|
|
mode: file.mode.unwrap_or(fallback.mode),
|
|
timer,
|
|
cursor: file.cursor.unwrap_or(fallback.cursor),
|
|
preview: file.preview.unwrap_or(fallback.preview),
|
|
sound: file.sound.unwrap_or(fallback.sound),
|
|
})
|
|
}
|
|
|
|
pub fn save(path: &Path, state: &ToolbarState) -> Result<()> {
|
|
if let Some(dir) = path.parent() {
|
|
std::fs::create_dir_all(dir)
|
|
.with_context(|| format!("could not create {}", dir.display()))?;
|
|
}
|
|
let text = toml::to_string(state).context("could not encode the toolbar state")?;
|
|
crate::output::write_atomic(path, text.as_bytes())
|
|
}
|
|
|
|
/// One line of JSON for the Noctalia plugin: the state and the timer choices.
|
|
pub fn to_json(state: &ToolbarState) -> String {
|
|
serde_json::json!({
|
|
"mode": state.mode,
|
|
"timer": state.timer,
|
|
"cursor": state.cursor,
|
|
"preview": state.preview,
|
|
"sound": state.sound,
|
|
"timers": TIMERS,
|
|
})
|
|
.to_string()
|
|
}
|
|
|
|
/// `state` with every `KEY=VALUE` applied in order; the first bad pair is
|
|
/// an error naming it.
|
|
pub fn apply_sets(mut state: ToolbarState, sets: &[String]) -> Result<ToolbarState> {
|
|
for pair in sets {
|
|
let Some((key, value)) = pair.split_once('=') else {
|
|
bail!("expected KEY=VALUE, not {pair:?}");
|
|
};
|
|
let flag = || match value {
|
|
"true" => Ok(true),
|
|
"false" => Ok(false),
|
|
_ => Err(anyhow::anyhow!(
|
|
"{key} must be true or false, not {value:?}"
|
|
)),
|
|
};
|
|
match key {
|
|
"mode" => {
|
|
state.mode = <Mode as clap::ValueEnum>::from_str(value, false).map_err(|_| {
|
|
anyhow::anyhow!("mode must be screen, window, region or zoom, not {value:?}")
|
|
})?;
|
|
}
|
|
"timer" => {
|
|
state.timer = value
|
|
.parse()
|
|
.ok()
|
|
.filter(|t| TIMERS.contains(t))
|
|
.with_context(|| format!("timer must be 0, 5 or 10, not {value:?}"))?;
|
|
}
|
|
"cursor" => state.cursor = flag()?,
|
|
"preview" => state.preview = flag()?,
|
|
"sound" => state.sound = flag()?,
|
|
_ => bail!("unknown toolbar key {key:?} (mode, timer, cursor, preview or sound)"),
|
|
}
|
|
}
|
|
Ok(state)
|
|
}
|
|
|
|
/// `valw toolbar --set`: `sets` applied to the remembered state and saved;
|
|
/// nothing changes if any pair is bad.
|
|
pub fn set(path: &Path, config: &Config, sets: &[String]) -> Result<()> {
|
|
let state = apply_sets(load(path, config), sets)?;
|
|
save(path, &state)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn defaults_come_from_the_config() {
|
|
let mut config = Config::default();
|
|
config.capture.show_cursor = true;
|
|
config.preview.enabled = false;
|
|
config.sound.enabled = false;
|
|
assert_eq!(
|
|
defaults(&config),
|
|
ToolbarState {
|
|
mode: Mode::Region,
|
|
timer: 0,
|
|
cursor: true,
|
|
preview: false,
|
|
sound: false,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn save_and_load_round_trip() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("deep/toolbar.toml");
|
|
let state = ToolbarState {
|
|
mode: Mode::Zoom,
|
|
timer: 10,
|
|
cursor: true,
|
|
preview: true,
|
|
sound: false,
|
|
};
|
|
save(&path, &state).unwrap();
|
|
assert_eq!(load(&path, &Config::default()), state);
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_file_gives_the_defaults() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let config = Config::default();
|
|
assert_eq!(
|
|
load(&dir.path().join("none.toml"), &config),
|
|
defaults(&config)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_broken_file_falls_back() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("toolbar.toml");
|
|
let config = Config::default();
|
|
for text in [
|
|
"mode = \"video\"\n",
|
|
"timer = 7\n",
|
|
"not toml at all [",
|
|
"cursor = \"yes\"\n",
|
|
] {
|
|
std::fs::write(&path, text).unwrap();
|
|
assert_eq!(load(&path, &config), defaults(&config), "{text:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn missing_and_unknown_keys_are_fine() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("toolbar.toml");
|
|
std::fs::write(&path, "mode = \"window\"\ncolour = \"red\"\n").unwrap();
|
|
let config = Config::default();
|
|
let state = load(&path, &config);
|
|
assert_eq!(state.mode, Mode::Window);
|
|
assert_eq!(state.timer, 0);
|
|
assert_eq!(state.preview, config.preview.enabled);
|
|
}
|
|
|
|
#[test]
|
|
fn json_for_the_plugin() {
|
|
let state = ToolbarState {
|
|
mode: Mode::Zoom,
|
|
timer: 5,
|
|
cursor: true,
|
|
preview: false,
|
|
sound: true,
|
|
};
|
|
let text = to_json(&state);
|
|
assert!(!text.contains('\n'), "one line");
|
|
let value: serde_json::Value = serde_json::from_str(&text).unwrap();
|
|
assert_eq!(
|
|
value,
|
|
serde_json::json!({
|
|
"mode": "zoom",
|
|
"timer": 5,
|
|
"cursor": true,
|
|
"preview": false,
|
|
"sound": true,
|
|
"timers": [0, 5, 10],
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sets_apply_every_key_in_order() {
|
|
let start = defaults(&Config::default());
|
|
let sets: Vec<String> = [
|
|
"mode=window",
|
|
"timer=10",
|
|
"cursor=true",
|
|
"preview=false",
|
|
"sound=false",
|
|
]
|
|
.map(String::from)
|
|
.into();
|
|
assert_eq!(
|
|
apply_sets(start, &sets).unwrap(),
|
|
ToolbarState {
|
|
mode: Mode::Window,
|
|
timer: 10,
|
|
cursor: true,
|
|
preview: false,
|
|
sound: false,
|
|
}
|
|
);
|
|
let twice = ["timer=5".to_string(), "timer=0".to_string()];
|
|
assert_eq!(
|
|
apply_sets(start, &twice).unwrap().timer,
|
|
0,
|
|
"the last one wins"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bad_sets_are_rejected_by_name() {
|
|
let start = defaults(&Config::default());
|
|
for (pair, message) in [
|
|
("colour=red", "unknown toolbar key \"colour\""),
|
|
("mode=video", "mode must be screen, window, region or zoom"),
|
|
("timer=7", "timer must be 0, 5 or 10"),
|
|
("timer=soon", "timer must be 0, 5 or 10"),
|
|
("cursor=maybe", "cursor must be true or false"),
|
|
("cursor", "expected KEY=VALUE"),
|
|
] {
|
|
let err = format!("{:#}", apply_sets(start, &[pair.to_string()]).unwrap_err());
|
|
assert!(err.contains(message), "{pair}: {err}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn set_writes_all_or_nothing() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("valw/toolbar.toml");
|
|
let config = Config::default();
|
|
let bad = ["timer=5".to_string(), "mode=video".to_string()];
|
|
assert!(set(&path, &config, &bad).is_err());
|
|
assert!(!path.exists(), "nothing written");
|
|
|
|
let good = ["timer=5".to_string(), "sound=false".to_string()];
|
|
set(&path, &config, &good).unwrap();
|
|
let state = load(&path, &config);
|
|
assert_eq!(
|
|
(state.timer, state.sound, state.mode),
|
|
(5, false, Mode::Region)
|
|
);
|
|
|
|
std::fs::write(&path, "not toml [").unwrap();
|
|
set(&path, &config, &["cursor=true".to_string()]).unwrap();
|
|
assert_eq!(
|
|
load(&path, &config),
|
|
ToolbarState {
|
|
cursor: true,
|
|
..defaults(&config)
|
|
},
|
|
"a broken file is replaced, starting from the defaults"
|
|
);
|
|
}
|
|
}
|