//! The complete editor consumes the compositor's schema. Paths and application //! data remain canonical; only interface labels are translated. use super::{ Alignment, App, Element, Length, Message, Page, Snapshot, Task, button, column, container, row, text_input, visuals, }; use ferese_config::locale::{text as t, translate as tr}; use serde_json::{Value, json}; use std::collections::{HashMap, HashSet}; pub const GROUPS: &[(&str, &[&str])] = &[ ("General", &["ui", "status"]), ( "Desktop", &[ "theme", "appearance", "panels", "notifications", "night_light", ], ), ( "Windows", &[ "layout", "scrolling", "workspaces", "window_rules", "animations", ], ), ("Input", &["input", "bindings", "binding_modes"]), ( "System", &[ "lock_screen", "idle_inhibit", "xwayland", "commands", "autostart", "output_profiles", "activities", ], ), ]; #[derive(Clone, Debug)] pub enum Input { Filter(String), Restore(String), Expand(String), Set(String, Value), Text(String, String), Commit(String, Value), Add(String, Value), Remove(String, String), MapKey(String, String), MapAdd(String, Value), Apply, Reset, } pub struct State { pub filter: String, pub applying: bool, schema: Value, baseline: Value, value: Value, base_source: String, expanded: HashSet, text: HashMap, keys: HashMap, error: Option, } impl State { pub fn load(snapshot: &Snapshot) -> Self { static CATALOG: std::sync::OnceLock> = std::sync::OnceLock::new(); let result = CATALOG.get_or_init(load_schema).clone(); let (schema, error) = match result { Ok(v) => (v, None), Err(e) => ( Value::Null, Some(format!("{}: {e}", t("Cannot load configuration schema"))), ), }; let mut state = Self { filter: String::new(), applying: false, schema, baseline: Value::Null, value: Value::Null, base_source: String::new(), expanded: HashSet::new(), text: HashMap::new(), keys: HashMap::new(), error, }; state.reset(snapshot); state } pub fn reset(&mut self, snapshot: &Snapshot) { self.applying = false; self.baseline = self.schema["defaults"].clone(); overlay(&mut self.baseline, snapshot.doc.value()); self.value = self.baseline.clone(); self.base_source = snapshot.source.clone(); self.text.clear(); self.keys.clear(); } pub fn dirty(&self) -> bool { self.value != self.baseline || !self.text.is_empty() } pub fn matches(&self, query: &str) -> bool { self.matches_schema("", &self.schema["schema"], query, 0) } fn matches_schema(&self, key: &str, schema: &Value, query: &str, depth: usize) -> bool { if depth > 20 { return false; } let q = query.to_lowercase(); let s = self.resolve(schema); if format!( "{key} {} {}", label(key), s.get("description").and_then(Value::as_str).unwrap_or("") ) .to_lowercase() .contains(&q) { return true; } if s["properties"].as_object().is_some_and(|m| { m.iter() .any(|(k, s)| self.matches_schema(k, s, query, depth + 1)) }) { return true; } if s.get("items") .is_some_and(|s| self.matches_schema("", s, query, depth + 1)) { return true; } s.get("oneOf") .or(s.get("anyOf")) .and_then(Value::as_array) .is_some_and(|a| { a.iter() .any(|s| self.matches_schema("", s, query, depth + 1)) }) } fn resolve(&self, schema: &Value) -> Value { if let Some(reference) = schema["$ref"].as_str() { let mut out = self.schema["schema"] .pointer(reference.trim_start_matches('#')) .cloned() .unwrap_or(json!({})); let mut extra = schema.clone(); extra.as_object_mut().unwrap().remove("$ref"); overlay(&mut out, &extra); out } else { schema.clone() } } fn template(&self, schema: &Value, depth: usize) -> Value { if depth > 20 { return Value::Null; } let s = self.resolve(schema); if let Some(v) = s.get("default").or(s.get("const")) { if !v.is_null() { return v.clone(); } } if let Some(v) = s["enum"].as_array().and_then(|a| a.first()) { return v.clone(); } if let Some(v) = s .get("oneOf") .or(s.get("anyOf")) .and_then(Value::as_array) .and_then(|a| a.iter().find(|v| v["type"] != "null")) { let mut base = s.clone(); base.as_object_mut().unwrap().remove("oneOf"); base.as_object_mut().unwrap().remove("anyOf"); let mut out = self.template(&base, depth + 1); overlay(&mut out, &self.template(v, depth + 1)); return out; } match kind(&s) { "object" => Value::Object( s["properties"] .as_object() .map(|m| { m.iter() .filter_map(|(k, v)| { let v = self.template(v, depth + 1); (!v.is_null()).then(|| (k.clone(), v)) }) .collect() }) .unwrap_or_default(), ), "array" => { let n = s["minItems"].as_u64().unwrap_or(0).min(32); Value::Array( (0..n) .map(|_| self.template(&s["items"], depth + 1)) .collect(), ) } "boolean" => false.into(), "integer" | "number" => s.get("minimum").cloned().unwrap_or(0.into()), "string" => "".into(), _ => Value::Null, } } fn put(&mut self, path: &str, v: Value) { let mut cursor = &mut self.value; let segments = path .trim_start_matches('/') .split('/') .map(unescape) .collect::>(); if path.is_empty() { *cursor = v; return; } for (i, key) in segments.iter().enumerate() { if i == segments.len() - 1 { if let Some(a) = cursor.as_array_mut() { if let Ok(i) = key.parse::() { if let Some(target) = a.get_mut(i) { *target = v; } } } else { if !cursor.is_object() { *cursor = json!({}); } cursor.as_object_mut().unwrap().insert(key.clone(), v); } return; } if cursor.is_array() { let Some(next) = key .parse::() .ok() .and_then(|i| cursor.as_array_mut().unwrap().get_mut(i)) else { return; }; cursor = next; } else { if !cursor.is_object() { *cursor = json!({}); } cursor = cursor .as_object_mut() .unwrap() .entry(key) .or_insert(json!({})); } } } fn desired(&self, snapshot: &Snapshot) -> Result { if self.base_source != snapshot.source { return Err(t("Config changed externally. Reload before applying this draft.").into()); } if !self.text.is_empty() { return Err(t("Confirm text fields with Enter before applying.").into()); } let mut desired = snapshot.doc.value().clone(); project(&mut desired, &self.baseline, &self.value); let mut doc = snapshot.doc.clone(); doc.merge_value(&desired).map_err(|e| e.to_string())?; Ok(doc.to_string()) } } fn load_schema() -> Result { use std::{ io::Read, process::{Command, Stdio}, time::{Duration, Instant}, }; let exe = std::env::current_exe().map_err(|e| e.to_string())?; let directory = exe.parent().ok_or("Missing executable directory")?; let directory = if directory.file_name().is_some_and(|n| n == "deps") { directory.parent().ok_or("Missing build directory")? } else { directory }; let backend = directory.join("ferese"); if !backend.is_file() { return Err(t("Install the matching Ferese compositor beside Settings.").into()); } let output = tempfile::NamedTempFile::new().map_err(|e| e.to_string())?; let errors = tempfile::NamedTempFile::new().map_err(|e| e.to_string())?; // The sentinel backend prevents an older binary from starting a desktop if // it does not implement this query. No PATH fallback is allowed here. let mut child = Command::new(backend) .args(["--config-schema", "--backend=schema-query"]) .stdin(Stdio::null()) .stdout(output.reopen().map_err(|e| e.to_string())?) .stderr(errors.reopen().map_err(|e| e.to_string())?) .spawn() .map_err(|e| e.to_string())?; let deadline = Instant::now() + Duration::from_secs(3); loop { if let Some(status) = child.try_wait().map_err(|e| e.to_string())? { if !status.success() { return Err(std::fs::read_to_string(errors.path()) .unwrap_or_else(|_| "Schema query failed".into())); } let mut bytes = Vec::new(); std::fs::File::open(output.path()) .map_err(|e| e.to_string())? .take(2 * 1024 * 1024) .read_to_end(&mut bytes) .map_err(|e| e.to_string())?; let catalog: Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; if catalog["version"] != 1 || !catalog["schema"]["properties"].is_object() || !catalog["defaults"].is_object() { return Err("Unsupported configuration schema".into()); } return Ok(catalog); } if Instant::now() >= deadline { let _ = child.kill(); let _ = child.wait(); return Err("Configuration schema query timed out".into()); } std::thread::sleep(Duration::from_millis(10)); } } fn overlay(base: &mut Value, addition: &Value) { if let (Some(a), Some(b)) = (base.as_object_mut(), addition.as_object()) { for (k, v) in b { overlay(a.entry(k).or_insert(Value::Null), v) } } else { *base = addition.clone() } } fn project(target: &mut Value, base: &Value, new: &Value) { if base == new { return; } if let (Some(a), Some(b)) = (base.as_object(), new.as_object()) { if !target.is_object() { *target = json!({}) } let map = target.as_object_mut().unwrap(); for k in a.keys() { if !b.contains_key(k) { map.remove(k); } } for (k, v) in b { if a.get(k) != Some(v) { if v.is_null() { map.remove(k); } else { project( map.entry(k).or_insert(Value::Null), a.get(k).unwrap_or(&Value::Null), v, ) } } } } else { *target = new.clone() } } fn kind(s: &Value) -> &str { s["type"] .as_str() .or_else(|| { s["type"] .as_array() .and_then(|a| a.iter().filter_map(Value::as_str).find(|v| *v != "null")) }) .unwrap_or(if s.get("properties").is_some() { "object" } else { "json" }) } fn escape(s: &str) -> String { s.replace('~', "~0").replace('/', "~1") } fn unescape(s: &str) -> String { s.replace("~1", "/").replace("~0", "~") } fn child(p: &str, k: &str) -> String { format!("{p}/{}", escape(k)) } fn label(k: &str) -> String { let translated = tr(k); if translated == k { k.replace('_', " ") } else { translated } } impl App { pub fn options_input(&mut self, input: Input) -> Task { let state = &mut self.options; match input { Input::Filter(query) => { state.filter = query; } Input::Restore(p) => { let v = state.baseline.pointer(&p).cloned().unwrap_or(Value::Null); state.put(&p, v); state .text .retain(|k, _| k != &p && !k.starts_with(&format!("{p}/"))); } Input::Expand(p) => { if !state.expanded.remove(&p) { state.expanded.insert(p); } } Input::Set(p, v) => { state.put(&p, v); state.text.remove(&p); } Input::Text(p, v) => { state.text.insert(p, v); } Input::Commit(p, s) => { if let Some(raw) = state.text.get(&p) { let result = match kind(&state.resolve(&s)) { "string" => Ok(Value::String(raw.clone())), _ => serde_json::from_str(raw).map_err(|e| e.to_string()), }; match result { Ok(v) => { state.put(&p, v); state.text.remove(&p); state.error = None; } Err(e) => state.error = Some(e), } } } Input::Add(p, v) => { if let Some(a) = state.value.pointer_mut(&p).and_then(Value::as_array_mut) { let i = a.len(); a.push(v); state.expanded.insert(child(&p, &i.to_string())); } } Input::Remove(p, k) => { if let Some(v) = state.value.pointer_mut(&p) { if let Some(a) = v.as_array_mut() { if let Ok(i) = k.parse::() { if i < a.len() { a.remove(i); } } } else if let Some(m) = v.as_object_mut() { m.remove(&k); } } state.text.clear(); } Input::MapKey(p, k) => { state.keys.insert(p, k); } Input::MapAdd(p, v) => { if let Some(k) = state.keys.remove(&p).filter(|s| !s.trim().is_empty()) { if let Some(m) = state.value.pointer_mut(&p).and_then(Value::as_object_mut) { m.entry(k.clone()).or_insert(v); state.expanded.insert(child(&p, &k)); } } } Input::Reset => { state.reset(&self.current); } Input::Apply => { if !self.saving { match state.desired(&self.current) { Ok(source) => { self.options.applying = true; self.error = None; return self.start_save(source); } Err(e) => self.error = Some(e), } } } } Task::none() } pub fn settings_hub(&self, width: f32) -> Element<'_, Message> { self.hub_layout(if width >= 680. { 2 } else { 1 }) } fn hub_layout(&self, columns: usize) -> Element<'_, Message> { let palette = visuals::Palette::from_resolved(&self.resolved.presented); let mut out = column([]).spacing(20); for (name, pages) in Page::NAVIGATION { if name == "Advanced" { continue; } let pages = if name == "Appearance" { vec![Page::General, Page::Configuration] } else { pages .iter() .copied() .filter(|p| *p != Page::Appearance) .collect::>() }; let mut section = column([]).spacing(8).push( self.panel_heading( tr(if name == "Appearance" { "Quick access" } else { name }), visuals::SECTION_TITLE_SIZE, ) .class(cosmic::theme::Text::Color(palette.muted)), ); for pair in pages.chunks(columns) { let mut cards = row([]).spacing(16); for &page in pair { cards = cards.push(self.hub_card(page)); } if pair.len() < columns { cards = cards.push(cosmic::widget::Space::new().width(Length::Fill)); } section = section.push(cards); } out = out.push(section); } out.into() } fn hub_card(&self, p: Page) -> Element<'static, Message> { let palette = visuals::Palette::from_resolved(&self.resolved.presented); let content = row([]) .spacing(12) .align_y(Alignment::Center) .push(visuals::icon(p, palette.muted)) .push( column([]) .spacing(4) .push(self.label(p.title(), 14.).font(cosmic::iced::Font { weight: cosmic::iced::font::Weight::Normal, ..self.font })) .push( self.label(p.subtitle(), 12.) .class(cosmic::theme::Text::Color(palette.muted)), ) .width(Length::Fill), ) .push(visuals::action_icon("M9 5l7 7-7 7", palette.muted)); container( button::custom(content) .name(p.title()) .padding(16) .width(Length::Fill) .class(visuals::button_style(palette, false)) .on_press(Message::Page(p)), ) .id(cosmic::widget::Id::new(format!( "settings-hub-{}", p as usize ))) .width(Length::Fill) .into() } pub fn all_options_view(&self) -> Element<'_, Message> { let mut out = column([]).spacing(10).push(self.note(t( "Advanced changes are saved together. Press Enter to confirm text fields, then Apply.", ))); if let Some(e) = &self.options.error { out = out.push(self.note(e)); } out = out.push( row([]) .spacing(8) .push( self.settings_button( t("Apply"), "M4 12l5 5L20 6", (self.options.dirty() && !self.saving) .then_some(Message::Options(Input::Apply)), false, ), ) .push(self.settings_button( t("Discard draft"), "M6 6l12 12 M6 18L18 6", (!self.saving).then_some(Message::Options(Input::Reset)), false, )), ); out = out.push( text_input(t("Filter options"), self.options.filter.clone()) .font(self.font) .on_input(|q| Message::Options(Input::Filter(q))), ); let props = self.options.schema["schema"]["properties"].as_object(); let mut seen = HashSet::new(); for (group, keys) in GROUPS { out = out.push(self.panel_heading(tr(group), 15.)); for &key in *keys { if let Some(s) = props.and_then(|p| p.get(key)) { seen.insert(key); out = out.push(self.option_node( &format!("/{key}"), key, s, self.options.value.get(key).unwrap_or(&Value::Null), 0, )); } } } // A newly supported root option appears even before a group is assigned. if let Some(props) = props { for (k, s) in props { if !seen.contains(k.as_str()) { out = out.push(self.option_node( &format!("/{k}"), k, s, self.options.value.get(k).unwrap_or(&Value::Null), 0, )); } } } out.into() } fn option_node( &self, path: &str, key: &str, schema: &Value, value: &Value, depth: usize, ) -> Element<'static, Message> { if depth == 0 && !self.options.filter.is_empty() && !self .options .matches_schema(key, schema, &self.options.filter, 0) { return cosmic::widget::Space::new().height(0).into(); } if depth > 20 { return self.note(t("Maximum nesting depth reached")); } let state = &self.options; let mut s = state.resolve(schema); let palette = visuals::Palette::from_resolved(&self.resolved.presented); let mut out = column([]).spacing(6); let variants = s .get("oneOf") .or(s.get("anyOf")) .and_then(Value::as_array) .cloned(); if let Some(variants) = &variants { let selected = variants .iter() .position(|variant| compatible(&state.resolve(variant), value)) .unwrap_or(0); let mut choices = row([]).spacing(4); for (i, v) in variants.iter().enumerate() { let r = state.resolve(v); let name = variant_label(&r); let p = path.to_string(); let mut next = state.template(&s, 0); overlay(&mut next, &state.template(v, 0)); choices = choices.push( button::custom(self.label(label(&name), 11.)) .padding(5) .class(visuals::button_style(palette, i == selected)) .on_press(Message::Options(Input::Set(p, next))), ); } out = out.push(choices); let v = state.resolve(&variants[selected]); s.as_object_mut().unwrap().remove("oneOf"); s.as_object_mut().unwrap().remove("anyOf"); merge_schema(&mut s, &v); } let object = kind(&s) == "object"; let array = kind(&s) == "array"; let complex = object || array; let title = format!( "{}{}", label(key), if array { format!(" ({})", value.as_array().map_or(0, Vec::len)) } else { String::new() } ); let mut header = row([]).spacing(8).align_y(Alignment::Center); if complex { let expanded = state.expanded.contains(path) || (depth == 0 && !state.filter.is_empty()); header = header.push( button::custom( self.label(format!("{} {title}", if expanded { "▾" } else { "▸" }), 13.), ) .padding([5, 8]) .width(Length::Fill) .name(path.to_owned()) .class(visuals::button_style(palette, false)) .on_press(Message::Options(Input::Expand(path.into()))), ); if value.is_null() { header = header.push(self.settings_button( t("Enable"), "M12 4v16 M4 12h16", Some(Message::Options(Input::Set( path.into(), state.template(&s, 0), ))), false, )); } out = column([]).spacing(6).push(header).push(out); if expanded && !value.is_null() { let mut inner = column([]).spacing(7); if object { let props = s["properties"].as_object(); if let Some(props) = props { for (k, sub) in props { if sub.get("const").is_some() { continue; } let resolved = state.resolve(sub); let v = value .get(k) .cloned() .or_else(|| resolved.get("default").cloned()) .unwrap_or(Value::Null); inner = inner.push(self.option_node( &child(path, k), k, sub, &v, depth + 1, )); } } if let Some(m) = value.as_object() { for (k, v) in m { if props.is_some_and(|p| p.contains_key(k)) { continue; } let sub = if s["additionalProperties"].is_object() { s["additionalProperties"].clone() } else { json!({}) }; inner = inner.push( row([]) .spacing(5) .push(self.option_node(&child(path, k), k, &sub, v, depth + 1)) .push(self.settings_button( t("Remove"), "M6 6l12 12 M6 18L18 6", Some(Message::Options(Input::Remove( path.into(), k.clone(), ))), false, )), ); } } if s.get("additionalProperties").is_some_and(|p| p != false) { let p = path.to_owned(); inner = inner.push( row([]) .spacing(5) .push( text_input( t("New key"), state.keys.get(path).cloned().unwrap_or_default(), ) .font(self.font) .on_input(move |k| { Message::Options(Input::MapKey(p.clone(), k)) }), ) .push(self.settings_button( t("Add"), "M12 4v16 M4 12h16", Some(Message::Options(Input::MapAdd( path.into(), state.template(&s["additionalProperties"], 0), ))), false, )), ); } } else if let Some(a) = value.as_array() { for (i, v) in a.iter().enumerate() { let item_schema = s["prefixItems"].get(i).unwrap_or(&s["items"]); inner = inner.push( row([]) .spacing(5) .push(self.option_node( &child(path, &i.to_string()), &format!("{}", i + 1), item_schema, v, depth + 1, )) .push(self.settings_button( t("Remove"), "M6 6l12 12 M6 18L18 6", Some(Message::Options(Input::Remove( path.into(), i.to_string(), ))), false, )), ); } if s.get("maxItems") .and_then(Value::as_u64) .is_none_or(|n| a.len() < n as usize) { inner = inner.push(self.settings_button( t("Add"), "M12 4v16 M4 12h16", Some(Message::Options(Input::Add( path.into(), state.template(&s["items"], 0), ))), false, )); } } out = out.push(container(inner).padding([2, 14])); } } else { header = header.push( column([]) .push(self.label(title, 13.)) .push(self.note(&path.trim_start_matches('/').replace('/', "."))) .width(Length::Fill), ); let control: Element<'static, Message> = if let Some(choices) = s["enum"].as_array() { row(choices.iter().map(|v| { let name = v .as_str() .map(str::to_owned) .unwrap_or_else(|| v.to_string()); button::custom(self.label(label(&name), 12.)) .padding(6) .class(visuals::button_style(palette, value == v)) .on_press(Message::Options(Input::Set(path.into(), v.clone()))) .into() })) .spacing(4) .into() } else if kind(&s) == "boolean" { ferese_theme::controls::switch(value.as_bool().unwrap_or(false), palette) .name(path.to_owned()) .on_press(Message::Options(Input::Set( path.into(), (!value.as_bool().unwrap_or(false)).into(), ))) .into() } else { let text = state.text.get(path).cloned().unwrap_or_else(|| { value.as_str().map(str::to_owned).unwrap_or_else(|| { if value.is_null() { String::new() } else { value.to_string() } }) }); let p = path.to_owned(); text_input( if kind(&s) == "string" { t("Text") } else { t("Value") }, text, ) .id(cosmic::widget::Id::new(format!("option-{path}"))) .font(self.font) .size(12) .style(visuals::input_style(palette)) .width(220) .on_input(move |v| Message::Options(Input::Text(p.clone(), v))) .on_submit({ let path = path.to_owned(); let schema = s.clone(); move |_| Message::Options(Input::Commit(path.clone(), schema.clone())) }) .into() }; out = out.push(header.push(control)); } if state.value.pointer(path) != state.baseline.pointer(path) || state.text.contains_key(path) { out = out.push(self.settings_button( t("Reset draft value"), "M3 10a8 8 0 1 1 1 7 M3 3v7h7", Some(Message::Options(Input::Restore(path.into()))), false, )); } container(out).width(Length::Fill).padding([5, 2]).into() } pub fn tray_status_controls(&self) -> Element<'_, Message> { let mut out = column([]) .spacing(12) .push(self.panel_heading(t("Panel status icons"), 14.)); let panels: Vec = self .draft .item("panels") .and_then(|v| serde_json::from_value(v.clone()).ok()) .unwrap_or_else(|| vec![ferese_panel::Panel::default()]); for panel in panels { for group in panel .start .groups .into_iter() .chain(panel.center.groups) .chain(panel.end.groups) { for item in group.items { if !matches!( item.kind, ferese_panel::ItemKind::Network | ferese_panel::ItemKind::Audio | ferese_panel::ItemKind::Battery { .. } | ferese_panel::ItemKind::Notifications | ferese_panel::ItemKind::NightLight | ferese_panel::ItemKind::Recording | ferese_panel::ItemKind::CaptureStatus ) { continue; } out = out.push( row([]) .spacing(8) .align_y(Alignment::Center) .push(self.label(tr(item.kind.label()), 13.).width(Length::Fill)) .push( ferese_theme::controls::switch( item.visible, visuals::Palette::from_resolved(&self.resolved.presented), ) .on_press(Message::PanelEdit( super::panel_edit::Action::Set( item.id, "visible".into(), (!item.visible).into(), ), )), ), ); } } } out.into() } } fn merge_schema(base: &mut Value, extra: &Value) { if let (Some(a), Some(b)) = (base.as_object_mut(), extra.as_object()) { for (k, v) in b { if k == "properties" { overlay(a.entry(k).or_insert(json!({})), v) } else { a.insert(k.clone(), v.clone()); } } } } fn compatible(s: &Value, v: &Value) -> bool { if let Some(c) = s.get("const") { return c == v; } if let Some(e) = s["enum"].as_array() { return e.contains(v); } if let Some(p) = s["properties"].as_object() { for (k, s) in p { if let Some(c) = s.get("const") { if v.get(k) != Some(c) { return false; } } } } match kind(s) { "object" => v.is_object(), "array" => v.is_array(), "null" => v.is_null(), "string" => v.is_string(), "number" | "integer" => v.is_number(), "boolean" => v.is_boolean(), _ => true, } } fn variant_label(s: &Value) -> String { s.get("title") .and_then(Value::as_str) .map(str::to_owned) .or_else(|| { s["properties"].as_object().and_then(|p| { p.values() .find_map(|v| v["const"].as_str().map(str::to_owned)) }) }) .unwrap_or_else(|| kind(s).into()) } #[cfg(test)] mod tests { use super::*; fn state(snapshot: &Snapshot) -> State { let mut state = State { filter: String::new(), applying: false, schema: json!({"defaults":{"ui":{"language":"auto"},"input":{"repeat_rate":25,"xkb_layout":"us"},"commands":{}}}), baseline: Value::Null, value: Value::Null, base_source: String::new(), expanded: HashSet::new(), text: HashMap::new(), keys: HashMap::new(), error: None, }; state.reset(snapshot); state } #[test] fn editor_changes_roundtrip_without_authoring_unrelated_defaults() { let snapshot = Snapshot::parse("// preserve\ninput { repeat-rate 25; }\n".into()).unwrap(); let mut state = state(&snapshot); state.put("/ui/language", "zh-CN".into()); state.put( "/commands/org.example.launch", json!(["foot", "-e", "printf", "test"]), ); let desired = state.desired(&snapshot).unwrap(); assert!(desired.contains("// preserve")); let reloaded = Snapshot::parse(desired).unwrap(); assert_eq!(reloaded.doc.value()["ui"]["language"], "zh-CN"); assert_eq!( reloaded.doc.value()["commands"]["org.example.launch"], json!(["foot", "-e", "printf", "test"]) ); assert!(reloaded.item("input.xkb_layout").is_none()); } #[test] fn stale_and_unconfirmed_drafts_cannot_be_applied() { let snapshot = Snapshot::parse(String::new()).unwrap(); let mut state = state(&snapshot); state.put("/input/repeat_rate", 30.into()); assert!( state .desired(&Snapshot::parse("// externally changed\n".into()).unwrap()) .is_err() ); state .text .insert("/input/repeat_rate".into(), "invalid".into()); assert!(state.desired(&snapshot).is_err()); } #[test] fn escaped_map_paths_keep_identifiers() { let snapshot = Snapshot::parse(String::new()).unwrap(); let mut state = state(&snapshot); state.put("/commands/org.example~1launch~0test", json!(["foot"])); assert_eq!( state.value["commands"]["org.example/launch~test"], json!(["foot"]) ); } #[test] fn project_keeps_unauthored_defaults_out_of_config() { let mut authored = json!({"input":{"repeat_rate":25}}); let base = json!({"input":{"repeat_rate":25,"xkb_layout":"us"},"theme":{"mode":"dark"}}); let mut edited = base.clone(); edited["input"]["xkb_layout"] = "cn".into(); project(&mut authored, &base, &edited); assert_eq!( authored, json!({"input":{"repeat_rate":25,"xkb_layout":"cn"}}) ); } #[test] fn keys_escape_losslessly() { let k = "org.example/app~1"; assert_eq!(unescape(&escape(k)), k); } }