feat(settings): add grouped configuration hub, languages and tray controls

This commit is contained in:
aria committed 2026-10-28 03:37:22 +09:00
1 parent 6f34fd080f
commit cc3b6f903f
90 files changed
+3625 -408

No files matched your search

+4 -4
View File
@@ -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,
+148
View File
@@ -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(
+2
View File
@@ -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::*;
+152
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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,
+49
View File
@@ -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![],
}
}
}
+7 -5
View File
@@ -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>,