Files
feresaria/shell/ferese-settings/src/all_options.rs
T

1116 lines
40 KiB
Rust

//! 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<String>,
text: HashMap<String, String>,
keys: HashMap<String, String>,
error: Option<String>,
}
impl State {
pub fn load(snapshot: &Snapshot) -> Self {
static CATALOG: std::sync::OnceLock<Result<Value, String>> = 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::<Vec<_>>();
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::<usize>() {
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::<usize>()
.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<String, String> {
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<Value, String> {
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<Message> {
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::<usize>() {
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::<Vec<_>>()
};
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<ferese_panel::Panel> = 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);
}
}