//! The recommended PopupNotifications look: a banner across the top of the screen (docs/runicnpc/ //! PLAN.md stage 9e, D327). //! //! PopupNotifications is optional and the operator's to install (D141); its config is the //! operator's too. The installer writes the banner into it only where it is **missing** or **still //! exactly the plugin's own defaults** — every setting equal to 0.2.1's, the `Version` aside (D328). //! A config with any one setting changed is somebody's choice and is kept whole, never merged into. //! A config that does not parse is kept too: it is not ours to repair. //! //! The file carries a `Version`: PopupNotifications resets a config whose version is missing or //! below 0.2.0 to its defaults on load, which would undo the banner at the first boot. use std::path::Path; use serde_json::Value; /// The config's file name, in the framework's config directory beside the bridge's. pub const FILE: &str = "PopupNotifications.json"; /// What is there now, and so what happens to it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum State { /// No config: the banner is written, and PopupNotifications reads it when it is installed. Missing, /// The plugin's own defaults, untouched: the banner replaces them. Holds the file's version. Defaults(Version), /// Changed by someone, or unreadable: kept. Theirs, } /// PopupNotifications' `VersionNumber`, as its config writes it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Version { pub major: u64, pub minor: u64, pub patch: u64, } /// The version written into a config made from nothing: the release the defaults are read from. const WRITTEN_VERSION: Version = Version { major: 0, minor: 2, patch: 1, }; impl State { pub fn writes(self) -> bool { !matches!(self, Self::Theirs) } pub fn label(self) -> &'static str { match self { Self::Missing => "banner written (no config yet)", Self::Defaults(_) => "banner written (was the plugin's defaults)", Self::Theirs => "kept (not the plugin's defaults)", } } } /// Reads the config at `path` and says what it is. pub fn inspect(path: &Path) -> State { match std::fs::read(path) { Err(e) if e.kind() == std::io::ErrorKind::NotFound => State::Missing, Err(_) => State::Theirs, Ok(bytes) => classify(&bytes), } } fn classify(bytes: &[u8]) -> State { let Ok(Value::Object(mut found)) = serde_json::from_slice::(bytes) else { return State::Theirs; }; let version = found.remove("Version"); let Ok(Value::Object(mut defaults)) = serde_json::from_str::(&render(DEFAULTS, None)) else { unreachable!("the defaults are valid JSON") }; defaults.remove("Version"); if !same(&Value::Object(found), &Value::Object(defaults)) { return State::Theirs; } State::Defaults( version .as_ref() .and_then(read_version) .unwrap_or(WRITTEN_VERSION), ) } fn read_version(v: &Value) -> Option { Some(Version { major: v.get("Major")?.as_u64()?, minor: v.get("Minor")?.as_u64()?, patch: v.get("Patch")?.as_u64()?, }) } /// JSON equality with numbers compared as numbers: the plugin writes its floats through /// Newtonsoft, and a value written `1.0` by one framework and `1` by another is the same setting. fn same(a: &Value, b: &Value) -> bool { match (a, b) { (Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) { (Some(x), Some(y)) => (x - y).abs() < 1e-6, _ => false, }, (Value::Object(x), Value::Object(y)) => { x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| same(v, w))) } (Value::Array(x), Value::Array(y)) => { x.len() == y.len() && x.iter().zip(y).all(|(v, w)| same(v, w)) } _ => a == b, } } /// The banner, as the file to write for `state`. `None` for a config that is kept. pub fn banner_for(state: State) -> Option { match state { State::Missing => Some(render(BANNER, Some(WRITTEN_VERSION))), State::Defaults(version) => Some(render(BANNER, Some(version))), State::Theirs => None, } } /// One look: the eleven settings PopupNotifications has. struct Look { left: &'static str, bottom: &'static str, width: &'static str, height: &'static str, spacing: &'static str, close: bool, alpha: &'static str, font: &'static str, font_size: u32, } /// PopupNotifications 0.2.1's `GetBaseConfig()`: a small grey box at the right of the screen. const DEFAULTS: Look = Look { left: "0.8", bottom: "0.78", width: "0.19", height: "0.1", spacing: "0.01", close: true, alpha: "0.5", font: "droidsansmono.ttf", font_size: 12, }; /// The recommended look (9d player session, 2026-10-09): a banner across the top of the screen. const BANNER: Look = Look { left: "0.15", bottom: "0.87", width: "0.7", height: "0.07", spacing: "0.005", close: false, alpha: "0.8", font: "robotocondensed-bold.ttf", font_size: 18, }; /// The config file, laid out as the plugin writes it. Duration, the most shown, both colours and /// the close button's transparency are the plugin's own in both looks. fn render(look: Look, version: Option) -> String { let version = version.unwrap_or(WRITTEN_VERSION); format!( r##"{{ "Notification duration (in seconds)": 8, "Maximum notifications shown at any time": 6, "UI Positioning": {{ "Position of the left side of notification (0.0 - 1.0)": {left}, "Position of the bottom of noticiation (0.0 - 1.0)": {bottom}, "Width (0.0 - 1.0)": {width}, "Height (0.0 - 1.0)": {height}, "Space between notification (0.0 - 1.0)": {spacing} }}, "UI Options": {{ "Show close button": {close}, "Panel color (hex)": "#2b2b2b", "Panel transparency (0.0 - 1.0)": {alpha}, "Close button color (hex)": "#d85540", "Close button transparency (0.0 - 1.0)": 0.5, "Font": "{font}", "Font size": {font_size} }}, "Version": {{ "Major": {major}, "Minor": {minor}, "Patch": {patch} }} }}"##, left = look.left, bottom = look.bottom, width = look.width, height = look.height, spacing = look.spacing, close = look.close, alpha = look.alpha, font = look.font, font_size = look.font_size, major = version.major, minor = version.minor, patch = version.patch, ) } #[cfg(test)] mod tests { use super::*; use crate::util::TempDir; /// The file PopupNotifications 0.2.1 wrote on both rigs at its first load (2026-10-09). const RIG_DEFAULTS: &str = r##"{ "Notification duration (in seconds)": 8, "Maximum notifications shown at any time": 6, "UI Positioning": { "Position of the left side of notification (0.0 - 1.0)": 0.8, "Position of the bottom of noticiation (0.0 - 1.0)": 0.78, "Width (0.0 - 1.0)": 0.19, "Height (0.0 - 1.0)": 0.1, "Space between notification (0.0 - 1.0)": 0.01 }, "UI Options": { "Show close button": true, "Panel color (hex)": "#2b2b2b", "Panel transparency (0.0 - 1.0)": 0.5, "Close button color (hex)": "#d85540", "Close button transparency (0.0 - 1.0)": 0.5, "Font": "droidsansmono.ttf", "Font size": 12 }, "Version": { "Major": 0, "Minor": 2, "Patch": 1 } }"##; #[test] fn a_missing_config_gets_the_banner_with_a_version() { let dir = TempDir::new("rg-popup-missing").unwrap(); let state = inspect(&dir.path().join(FILE)); assert_eq!(state, State::Missing); let written: Value = serde_json::from_str(&banner_for(state).unwrap()).unwrap(); assert_eq!(written["UI Positioning"]["Width (0.0 - 1.0)"], 0.7); assert_eq!(written["UI Options"]["Show close button"], false); assert_eq!(written["UI Options"]["Font"], "robotocondensed-bold.ttf"); assert_eq!(written["UI Options"]["Font size"], 18); // Without a version the plugin resets the file to its defaults at load. assert_eq!(written["Version"]["Minor"], 2); } #[test] fn the_plugins_own_defaults_are_replaced_and_their_version_kept() { assert_eq!( classify(RIG_DEFAULTS.as_bytes()), State::Defaults(WRITTEN_VERSION) ); let later = RIG_DEFAULTS.replace("\"Patch\": 1", "\"Patch\": 4"); let state = classify(later.as_bytes()); assert_eq!( state, State::Defaults(Version { major: 0, minor: 2, patch: 4 }) ); assert!(banner_for(state).unwrap().contains("\"Patch\": 4")); // A whole number written as a float is still the default. let floats = RIG_DEFAULTS.replace("\"Font size\": 12", "\"Font size\": 12.0"); assert!(matches!(classify(floats.as_bytes()), State::Defaults(_))); } #[test] fn the_rendered_defaults_are_the_file_the_plugin_writes() { let ours: Value = serde_json::from_str(&render(DEFAULTS, None)).unwrap(); let rig: Value = serde_json::from_str(RIG_DEFAULTS).unwrap(); assert_eq!(ours, rig); } #[test] fn any_change_keeps_the_whole_config() { for changed in [ RIG_DEFAULTS.replace("\"Font size\": 12", "\"Font size\": 14"), RIG_DEFAULTS.replace( "\"Notification duration (in seconds)\": 8", "\"Notification duration (in seconds)\": 9", ), RIG_DEFAULTS.replace("#2b2b2b", "#000000"), render(BANNER, None), "{ not json".to_string(), "[]".to_string(), ] { let state = classify(changed.as_bytes()); assert_eq!(state, State::Theirs, "{changed}"); assert_eq!(banner_for(state), None); } } #[test] fn a_config_with_a_setting_added_or_missing_is_theirs() { let mut v: Value = serde_json::from_str(RIG_DEFAULTS).unwrap(); v["Extra"] = Value::Bool(true); assert_eq!(classify(v.to_string().as_bytes()), State::Theirs); let mut v: Value = serde_json::from_str(RIG_DEFAULTS).unwrap(); v.as_object_mut().unwrap().remove("UI Options"); assert_eq!(classify(v.to_string().as_bytes()), State::Theirs); } }