1116 lines
40 KiB
Rust
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);
|
|
}
|
|
}
|