feat(settings): add grouped configuration hub, languages and tray controls
This commit is contained in:
1 parent
6f34fd080f
commit
cc3b6f903f
90 files changed
+3625
-408
No files matched your search
@@ -8,7 +8,7 @@ use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::Error;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, schemars::JsonSchema)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Settings {
|
||||
pub max_visible: usize,
|
||||
@@ -36,14 +36,14 @@ impl Default for Settings {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Sources {
|
||||
pub shell_hook: bool,
|
||||
pub process_watch: bool,
|
||||
pub notification_bridge: bool,
|
||||
}
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, schemars::JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Rule {
|
||||
pub name: String,
|
||||
@@ -59,7 +59,7 @@ pub struct Rule {
|
||||
pub flash_on_fail: bool,
|
||||
pub result: ResultText,
|
||||
}
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, schemars::JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ResultText {
|
||||
pub ok: String,
|
||||
|
||||
@@ -72,6 +72,7 @@ fn field(name: &str, parent: &str) -> String {
|
||||
}
|
||||
|
||||
match name {
|
||||
"rule" if parent == "activities" => "rules".into(),
|
||||
"binding" => "bindings".into(),
|
||||
"binding-mode" => "binding_modes".into(),
|
||||
"window-rule" => "window_rules".into(),
|
||||
@@ -99,6 +100,7 @@ fn is_records(key: &str, parent: &str) -> bool {
|
||||
| ("binding_modes", "bindings")
|
||||
| ("start" | "center" | "end", "groups")
|
||||
| ("groups", "items")
|
||||
| ("activities", "rules")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -112,6 +114,8 @@ fn is_array(key: &str, parent: &str) -> bool {
|
||||
| "width_presets"
|
||||
| "xkb_options"
|
||||
| "settings_command"
|
||||
| "hidden_items"
|
||||
| "ignore_parents"
|
||||
| "outputs"
|
||||
)
|
||||
}
|
||||
@@ -330,6 +334,20 @@ impl Document {
|
||||
Some(value)
|
||||
}
|
||||
|
||||
/// Apply a structured editor draft as one transaction. Unchanged subtrees
|
||||
/// retain their KDL nodes, formatting and comments; no partial draft is saved.
|
||||
pub fn merge_value(&mut self, desired: &Value) -> Result<(), Error> {
|
||||
if !desired.is_object() {
|
||||
return Err(Error("Configuration must be an object".into()));
|
||||
}
|
||||
let mut candidate = self.clone();
|
||||
reconcile(&mut candidate.doc, &[], &self.value, desired)?;
|
||||
format_document(&mut candidate.doc);
|
||||
candidate.refresh()?;
|
||||
*self = candidate;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn refresh(&mut self) -> Result<(), Error> {
|
||||
self.value = Value::Object(object(&self.doc, "")?);
|
||||
Ok(())
|
||||
@@ -535,6 +553,93 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
fn reconcile(
|
||||
doc: &mut KdlDocument,
|
||||
path: &[String],
|
||||
old: &Value,
|
||||
new: &Value,
|
||||
) -> Result<(), Error> {
|
||||
if old == new {
|
||||
return Ok(());
|
||||
}
|
||||
if let (Some(before), Some(after)) = (old.as_object(), new.as_object()) {
|
||||
for key in before.keys().filter(|key| !after.contains_key(*key)) {
|
||||
let mut child = path.to_vec();
|
||||
child.push(key.clone());
|
||||
remove_value(doc, &child)?;
|
||||
}
|
||||
for (key, value) in after {
|
||||
let mut child = path.to_vec();
|
||||
child.push(key.clone());
|
||||
reconcile(doc, &child, before.get(key).unwrap_or(&Value::Null), value)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let refs = path.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
if new.is_null() {
|
||||
return remove_value(doc, path);
|
||||
}
|
||||
let records = path.last().is_some_and(|key| {
|
||||
is_records(
|
||||
key,
|
||||
path.get(path.len().wrapping_sub(2))
|
||||
.map_or("", String::as_str),
|
||||
)
|
||||
});
|
||||
// Activity parsing resolves built-in rules even when not authored. Rewrite
|
||||
// that rule collection together so its intermediate shape is never invalid.
|
||||
let activity_rules = path == ["activities", "rules"];
|
||||
if records
|
||||
&& !activity_rules
|
||||
&& let (Some(before), Some(after)) = (old.as_array(), new.as_array())
|
||||
&& before.len() == after.len()
|
||||
&& before.iter().all(Value::is_object)
|
||||
&& after.iter().all(Value::is_object)
|
||||
{
|
||||
for (index, (before, after)) in before.iter().zip(after).enumerate() {
|
||||
let mut child = path.to_vec();
|
||||
child.push(index.to_string());
|
||||
reconcile(doc, &child, before, after)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
set_in(doc, &refs, "", new.clone())
|
||||
}
|
||||
|
||||
fn remove_value(doc: &mut KdlDocument, path: &[String]) -> Result<(), Error> {
|
||||
let refs = path.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
if unset_property(doc, &refs, "")? {
|
||||
return Ok(());
|
||||
}
|
||||
let (name, parents) = refs
|
||||
.split_last()
|
||||
.ok_or_else(|| Error("Cannot remove the configuration root".into()))?;
|
||||
let parent = parents
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|p| p.parse::<usize>().is_err())
|
||||
.copied()
|
||||
.unwrap_or("");
|
||||
let section = section_mut(doc, parents, "")?;
|
||||
let indices = section
|
||||
.nodes()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, node)| (field(node.name().value(), parent) == *name).then_some(index))
|
||||
.collect::<Vec<_>>();
|
||||
let mut comments = String::new();
|
||||
for index in indices.into_iter().rev() {
|
||||
let removed = section.nodes_mut().remove(index);
|
||||
collect_comments(&removed, &mut comments);
|
||||
}
|
||||
if !comments.is_empty() {
|
||||
let mut format = section.format().cloned().unwrap_or_default();
|
||||
format.trailing.push_str(&comments);
|
||||
section.set_format(format);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_comments(node: &KdlNode, output: &mut String) {
|
||||
let mut keep = |text: &str| {
|
||||
if text.contains("//") || text.contains("/*") {
|
||||
@@ -917,6 +1022,7 @@ fn node_name(key: &str, parent: &str) -> String {
|
||||
"panels" => "panel".into(),
|
||||
"groups" => "group".into(),
|
||||
"items" => "item".into(),
|
||||
"rules" if parent == "activities" => "rule".into(),
|
||||
_ => key.replace('_', "-"),
|
||||
}
|
||||
}
|
||||
@@ -927,6 +1033,7 @@ fn value_node(key: &str, value: &Value, parent: &str) -> Result<KdlNode, Error>
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let positions: &[&str] = match key {
|
||||
"rules" if parent == "activities" => &["name"],
|
||||
"bindings" => &["keys", "action", "argument"],
|
||||
"binding_modes" => &["name"],
|
||||
"output_profiles" => &["name"],
|
||||
@@ -947,6 +1054,11 @@ fn value_node(key: &str, value: &Value, parent: &str) -> Result<KdlNode, Error>
|
||||
}
|
||||
|
||||
for (name, value) in map {
|
||||
if value.is_null()
|
||||
|| (name == "ignore_parents" && value.as_array().is_some_and(Vec::is_empty))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if consumed.contains(&name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
@@ -959,6 +1071,7 @@ fn value_node(key: &str, value: &Value, parent: &str) -> Result<KdlNode, Error>
|
||||
}
|
||||
} else if is_records(key, parent)
|
||||
&& map.len() <= 4
|
||||
&& !(key == "rules" && parent == "activities")
|
||||
&& !value.is_array()
|
||||
&& !value.is_object()
|
||||
{
|
||||
@@ -1007,6 +1120,41 @@ pub fn from_str<T: DeserializeOwned>(source: &str) -> Result<T, Error> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn structured_activity_rules_use_native_kdl_syntax() {
|
||||
let mut doc = Document::parse("activities { max-visible 2; }\n").unwrap();
|
||||
let mut desired = doc.value().clone();
|
||||
desired["activities"]["rules"][0]["title"] = "Custom build".into();
|
||||
doc.merge_value(&desired).unwrap();
|
||||
let reloaded = Document::parse(&doc.to_string()).unwrap();
|
||||
assert_eq!(
|
||||
reloaded.value()["activities"]["rules"][0]["title"],
|
||||
"Custom build"
|
||||
);
|
||||
assert!(doc.to_string().contains("rule"));
|
||||
}
|
||||
#[test]
|
||||
fn structured_draft_preserves_comments_and_removes_whole_collections() {
|
||||
let mut doc=Document::parse("// header\ninput { repeat-rate 25; }\n// first\nbinding \"Super+A\" \"none\"\n// second\nbinding \"Super+B\" \"none\"\n").unwrap();
|
||||
let mut desired = doc.value().clone();
|
||||
desired["input"]["repeat_rate"] = 30.into();
|
||||
desired.as_object_mut().unwrap().remove("bindings");
|
||||
doc.merge_value(&desired).unwrap();
|
||||
let result = doc.to_string();
|
||||
assert!(result.contains("// header"));
|
||||
assert!(result.contains("// first"));
|
||||
assert!(result.contains("// second"));
|
||||
let reloaded = Document::parse(&result).unwrap();
|
||||
assert_eq!(reloaded.get("input.repeat_rate"), Some(&30.into()));
|
||||
assert!(reloaded.get("bindings").is_none());
|
||||
}
|
||||
#[test]
|
||||
fn structured_draft_rejects_invalid_root_without_mutating_source() {
|
||||
let mut doc = Document::parse("input { repeat-rate 25; }").unwrap();
|
||||
let original = doc.to_string();
|
||||
assert!(doc.merge_value(&serde_json::json!([])).is_err());
|
||||
assert_eq!(doc.to_string(), original);
|
||||
}
|
||||
#[test]
|
||||
fn named_binding_modes_round_trip_and_keep_nested_bindings_editable() {
|
||||
let mut document = super::Document::parse(
|
||||
|
||||
@@ -3,9 +3,11 @@ pub mod activities;
|
||||
mod document;
|
||||
pub mod families;
|
||||
pub mod file;
|
||||
pub mod locale;
|
||||
pub mod night_light;
|
||||
pub mod notifications;
|
||||
pub mod presets;
|
||||
pub mod status;
|
||||
pub mod theme;
|
||||
|
||||
pub use document::*;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Ferese interface language; independent of keyboard layout and system locale.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{
|
||||
OnceLock,
|
||||
atomic::{AtomicU8, Ordering},
|
||||
};
|
||||
|
||||
#[derive(
|
||||
Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
|
||||
)]
|
||||
pub enum Language {
|
||||
#[default]
|
||||
#[serde(rename = "auto")]
|
||||
Auto,
|
||||
#[serde(rename = "en", alias = "en-US", alias = "en_US")]
|
||||
English,
|
||||
#[serde(rename = "zh-CN", alias = "zh_CN", alias = "zh-Hans", alias = "zh")]
|
||||
Chinese,
|
||||
}
|
||||
impl Language {
|
||||
#[must_use]
|
||||
pub const fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Auto => "auto",
|
||||
Self::English => "en",
|
||||
Self::Chinese => "zh-CN",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Settings {
|
||||
pub language: Language,
|
||||
}
|
||||
|
||||
static LANGUAGE: AtomicU8 = AtomicU8::new(0);
|
||||
pub fn set(language: Language) {
|
||||
LANGUAGE.store(
|
||||
match language {
|
||||
Language::Auto => 0,
|
||||
Language::English => 1,
|
||||
Language::Chinese => 2,
|
||||
},
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
#[must_use]
|
||||
pub fn configured() -> Language {
|
||||
match LANGUAGE.load(Ordering::Relaxed) {
|
||||
1 => Language::English,
|
||||
2 => Language::Chinese,
|
||||
_ => Language::Auto,
|
||||
}
|
||||
}
|
||||
#[must_use]
|
||||
pub fn language_for_locale(locale: &str) -> Language {
|
||||
if locale.to_ascii_lowercase().starts_with("zh") {
|
||||
Language::Chinese
|
||||
} else {
|
||||
Language::English
|
||||
}
|
||||
}
|
||||
#[must_use]
|
||||
pub fn resolved() -> Language {
|
||||
match configured() {
|
||||
Language::Auto => {
|
||||
static SYSTEM: OnceLock<Language> = OnceLock::new();
|
||||
*SYSTEM.get_or_init(|| {
|
||||
let locale = ["LC_ALL", "LC_MESSAGES", "LANGUAGE", "LANG"]
|
||||
.into_iter()
|
||||
.find_map(|key| std::env::var(key).ok().filter(|value| !value.is_empty()))
|
||||
.unwrap_or_else(|| "en".into());
|
||||
language_for_locale(&locale)
|
||||
})
|
||||
}
|
||||
language => language,
|
||||
}
|
||||
}
|
||||
pub fn from_document(document: &crate::Document) -> Language {
|
||||
document
|
||||
.get("ui.language")
|
||||
.cloned()
|
||||
.and_then(|value| serde_json::from_value(value).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub fn load(path: Option<&std::path::Path>) {
|
||||
let language = path
|
||||
.and_then(|path| std::fs::read_to_string(path).ok())
|
||||
.and_then(|source| crate::Document::parse(&source).ok())
|
||||
.map(|doc| from_document(&doc))
|
||||
.unwrap_or_default();
|
||||
set(language);
|
||||
}
|
||||
|
||||
fn chinese() -> &'static BTreeMap<String, String> {
|
||||
static CATALOG: OnceLock<BTreeMap<String, String>> = OnceLock::new();
|
||||
CATALOG.get_or_init(|| {
|
||||
serde_json::from_str(include_str!("../locales/zh-CN.json"))
|
||||
.expect("valid built-in Chinese catalog")
|
||||
})
|
||||
}
|
||||
#[must_use]
|
||||
pub fn in_language<'a>(language: Language, key: &'a str) -> &'a str {
|
||||
if language == Language::Chinese {
|
||||
chinese().get(key).map_or(key, String::as_str)
|
||||
} else {
|
||||
key
|
||||
}
|
||||
}
|
||||
#[must_use]
|
||||
pub fn translate(key: &str) -> String {
|
||||
in_language(resolved(), key).to_owned()
|
||||
}
|
||||
#[must_use]
|
||||
pub fn text(key: &'static str) -> &'static str {
|
||||
in_language(resolved(), key)
|
||||
}
|
||||
#[must_use]
|
||||
pub fn format(key: &str, arguments: &[(&str, String)]) -> String {
|
||||
let mut result = translate(key);
|
||||
for (name, value) in arguments {
|
||||
result = result.replace(&format!("{{{name}}}"), value);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! tr {
|
||||
($key:literal) => {
|
||||
$crate::locale::text($key)
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn locale_choice_does_not_rewrite_identifiers_or_unknown_values() {
|
||||
assert_eq!(language_for_locale("zh_CN.UTF-8"), Language::Chinese);
|
||||
assert_eq!(language_for_locale("en_US.UTF-8"), Language::English);
|
||||
assert_eq!(language_for_locale("C.UTF-8"), Language::English);
|
||||
assert_eq!(in_language(Language::Chinese, "Appearance"), "外观与设置");
|
||||
assert_eq!(in_language(Language::English, "Appearance"), "Appearance");
|
||||
assert_eq!(
|
||||
in_language(Language::Chinese, "org.example.CustomApp"),
|
||||
"org.example.CustomApp"
|
||||
);
|
||||
assert!(serde_json::from_str::<Language>("\"unsupported\"").is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Settings {
|
||||
pub enabled: bool,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct NotificationConfig {
|
||||
pub show_popups: bool,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Shared shell status/tray configuration, validated before a reload is accepted.
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Settings {
|
||||
pub keybinding_guide: bool,
|
||||
pub low_battery_threshold: u8,
|
||||
pub settings_command: Option<Vec<String>>,
|
||||
pub tray: Tray,
|
||||
}
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
keybinding_guide: true,
|
||||
low_battery_threshold: 20,
|
||||
settings_command: Some(vec!["ferese-settings".into()]),
|
||||
tray: Tray::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Settings {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.low_battery_threshold > 100 {
|
||||
return Err("low-battery-threshold must be between 0 and 100".into());
|
||||
}
|
||||
if !self.tray.icon_size.is_finite() || !(8.0..=48.0).contains(&self.tray.icon_size) {
|
||||
return Err("tray icon-size must be between 8 and 48 pixels".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Tray {
|
||||
pub enabled: bool,
|
||||
pub show_passive: bool,
|
||||
pub icon_size: f32,
|
||||
pub hidden_items: Vec<String>,
|
||||
}
|
||||
impl Default for Tray {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
show_passive: false,
|
||||
icon_size: 16.,
|
||||
hidden_items: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ pub use ferese_theme_model::*;
|
||||
|
||||
pub const TRANSITION_MS: u64 = 250;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
|
||||
#[serde(default)]
|
||||
pub struct Selection {
|
||||
pub family: Option<String>,
|
||||
@@ -33,7 +33,9 @@ impl Default for Selection {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[derive(
|
||||
Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq, schemars::JsonSchema,
|
||||
)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum AutoSource {
|
||||
System,
|
||||
@@ -47,7 +49,7 @@ pub struct AutoContext {
|
||||
pub system: Option<Appearance>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
|
||||
#[serde(default)]
|
||||
pub struct Schedule {
|
||||
pub source: AutoSource,
|
||||
@@ -155,12 +157,12 @@ impl Schedule {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
|
||||
pub struct CustomTheme {
|
||||
pub file: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
|
||||
#[serde(default)]
|
||||
pub struct Policy {
|
||||
pub family: Option<String>,
|
||||
|
||||
Reference in new issue
Block a user