From cc3b6f903f0d684c8dabb7b8907c705dfc0c8e06 Mon Sep 17 00:00:00 2001 From: aria Date: Wed, 28 Oct 2026 03:37:22 +0900 Subject: [PATCH] feat(settings): add grouped configuration hub, languages and tray controls --- Cargo.lock | 84 ++ Cargo.toml | 1 + README.md | 17 + compositor/Cargo.toml | 1 + compositor/src/config.rs | 51 +- compositor/src/config/appearance.rs | 30 +- compositor/src/config/bindings.rs | 15 +- compositor/src/config/bindings/modes.rs | 2 +- compositor/src/config/input_motion.rs | 10 +- compositor/src/config/outputs.rs | 16 +- compositor/src/config/tab_indicator.rs | 2 +- compositor/src/config/xwayland.rs | 8 +- compositor/src/idle_inhibition.rs | 8 +- compositor/src/main.rs | 16 + compositor/src/session_lock.rs | 2 +- compositor/src/wallpaper.rs | 6 +- compositor/src/window_rules.rs | 2 +- crates/ferese-activities/Cargo.toml | 1 + crates/ferese-activities/src/pomodoro.rs | 8 +- crates/ferese-activities/src/store.rs | 22 +- crates/ferese-animation/Cargo.toml | 1 + crates/ferese-animation/src/settings.rs | 15 +- crates/ferese-config/Cargo.toml | 1 + crates/ferese-config/locales/zh-CN.json | 571 +++++++++ crates/ferese-config/src/activities.rs | 8 +- crates/ferese-config/src/document.rs | 148 +++ crates/ferese-config/src/lib.rs | 2 + crates/ferese-config/src/locale.rs | 152 +++ crates/ferese-config/src/night_light.rs | 2 +- crates/ferese-config/src/notifications.rs | 2 +- crates/ferese-config/src/status.rs | 49 + crates/ferese-config/src/theme.rs | 12 +- crates/ferese-panel/Cargo.toml | 1 + crates/ferese-panel/src/morph.rs | 2 +- crates/ferese-panel/src/panel/composition.rs | 51 +- crates/ferese-panel/src/panel/geometry.rs | 4 +- crates/ferese-panel/src/panel/radius.rs | 9 + crates/ferese-panel/src/shape.rs | 4 +- crates/ferese-rime/src/bin/probe.rs | 61 +- crates/ferese-theme-model/Cargo.toml | 1 + crates/ferese-theme-model/src/families.rs | 4 +- .../ferese-theme-model/src/resolved_theme.rs | 40 +- crates/ferese-theme/src/calendar.rs | 21 +- scripts/rime-preview.py | 23 +- scripts/tests/fixtures/tray-item.js | 29 + shell/ferese-lock/src/main.rs | 17 +- shell/ferese-polkit-agent/src/main.rs | 1 + shell/ferese-polkit-agent/src/prompt.rs | 43 +- shell/ferese-settings/src/all_options.rs | 1075 +++++++++++++++++ .../src/connections/bluetooth_ui.rs | 2 +- .../src/connections/wifi_ui.rs | 2 +- shell/ferese-settings/src/displays_ui.rs | 13 +- shell/ferese-settings/src/form_controls.rs | 8 +- shell/ferese-settings/src/main.rs | 69 +- shell/ferese-settings/src/pages.rs | 93 +- shell/ferese-settings/src/panel_controls.rs | 143 ++- shell/ferese-settings/src/panel_inspector.rs | 21 +- shell/ferese-settings/src/panel_preview.rs | 13 +- shell/ferese-settings/src/schema.rs | 125 +- shell/ferese-settings/src/theme_controls.rs | 18 +- shell/ferese-settings/src/tray_controls.rs | 111 ++ .../ferese-settings/src/wallpaper_controls.rs | 10 +- shell/ferese-shell/Cargo.toml | 1 + shell/ferese-shell/src/activities.rs | 18 +- shell/ferese-shell/src/activities/pomodoro.rs | 2 +- shell/ferese-shell/src/bar.rs | 19 +- shell/ferese-shell/src/config.rs | 27 +- shell/ferese-shell/src/display_mode.rs | 4 +- shell/ferese-shell/src/launcher/view.rs | 4 +- shell/ferese-shell/src/main.rs | 18 +- shell/ferese-shell/src/motion.rs | 2 +- shell/ferese-shell/src/notification_ui.rs | 10 +- shell/ferese-shell/src/panel_hub.rs | 2 +- shell/ferese-shell/src/panel_hub/activity.rs | 4 +- shell/ferese-shell/src/rendering.rs | 1 + shell/ferese-shell/src/status_ui/audio.rs | 10 +- shell/ferese-shell/src/status_ui/bar.rs | 2 + shell/ferese-shell/src/status_ui/battery.rs | 4 +- shell/ferese-shell/src/status_ui/bluetooth.rs | 2 +- shell/ferese-shell/src/status_ui/calendar.rs | 24 +- shell/ferese-shell/src/status_ui/controls.rs | 2 +- shell/ferese-shell/src/status_ui/media.rs | 6 +- shell/ferese-shell/src/status_ui/mod.rs | 30 +- shell/ferese-shell/src/status_ui/network.rs | 2 +- .../ferese-shell/src/status_ui/night_light.rs | 18 +- shell/ferese-shell/src/status_ui/system.rs | 11 +- shell/ferese-shell/src/surfaces.rs | 2 +- shell/ferese-shell/src/system_modal.rs | 18 +- shell/ferese-shell/src/tray.rs | 509 ++++++++ shell/ferese-shell/src/workspace_ui.rs | 2 +- 90 files changed, 3625 insertions(+), 408 deletions(-) create mode 100644 crates/ferese-config/locales/zh-CN.json create mode 100644 crates/ferese-config/src/locale.rs create mode 100644 crates/ferese-config/src/status.rs create mode 100644 scripts/tests/fixtures/tray-item.js create mode 100644 shell/ferese-settings/src/all_options.rs create mode 100644 shell/ferese-settings/src/tray_controls.rs create mode 100644 shell/ferese-shell/src/tray.rs diff --git a/Cargo.lock b/Cargo.lock index 35e9b8d..c8d590c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1696,6 +1696,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.18.0" @@ -1828,6 +1834,7 @@ dependencies = [ "libc", "memmap2 0.9.11", "notify 8.2.0", + "schemars", "serde", "serde_json", "smithay", @@ -1842,6 +1849,7 @@ dependencies = [ name = "ferese-activities" version = "0.1.0" dependencies = [ + "schemars", "serde", ] @@ -1850,6 +1858,7 @@ name = "ferese-animation" version = "0.1.0" dependencies = [ "ferese-layout", + "schemars", "serde", ] @@ -1863,6 +1872,7 @@ dependencies = [ "jiff", "kdl", "regex", + "schemars", "serde", "serde_json", "tempfile", @@ -1917,6 +1927,7 @@ version = "0.1.0" dependencies = [ "ferese-animation", "ferese-layout", + "schemars", "serde", "serde_json", ] @@ -2026,6 +2037,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "system-tray", "tempfile", "tokio", "tokio-tungstenite", @@ -2068,6 +2080,7 @@ dependencies = [ name = "ferese-theme-model" version = "0.1.0" dependencies = [ + "schemars", "serde", ] @@ -5436,6 +5449,26 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "regex" version = "1.13.1" @@ -5717,6 +5750,31 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.6", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -5784,6 +5842,17 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "serde_json" version = "1.0.151" @@ -6251,6 +6320,21 @@ dependencies = [ "version-compare", ] +[[package]] +name = "system-tray" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab761caca0fb6cb7c57e09df241eb26ea8cdc63df88f1badca4818494f74d55f" +dependencies = [ + "cfg-if", + "futures-lite", + "serde", + "thiserror 2.0.21", + "tokio", + "tracing", + "zbus", +] + [[package]] name = "taffy" version = "0.9.2" diff --git a/Cargo.toml b/Cargo.toml index d290144..a9dd002 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ undocumented_unsafe_blocks = "deny" wildcard_imports = "deny" [workspace.dependencies] +schemars = { version = "1.2.3", features = ["preserve_order"] } ferese-shape = { git = "https://github.com/ferese-wm/ferese-shape", tag = "v0.1.0" } bitflags = "2.9.4" calloop = { version = "0.14.5", features = ["signals"] } diff --git a/README.md b/README.md index 3e58a56..5ebc2d2 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,23 @@ inside your existing Wayland desktop. ## Configure Open **Control Center → Settings** or edit `~/.config/ferese/config.kdl`. +This fork adds a grouped **Appearance** settings hub. **General → Language** +supports the system language, 简体中文 and English and saves the choice as +`ui { language "zh-CN"; }` (`auto` and `en` are also supported). It changes +Ferese's interface independently of keyboard layouts and input methods. + +**All options** exposes the compositor's complete configuration schema, including +nested lists, maps and optional sections. Confirm text with Enter and apply the +whole draft; validation, conflict detection and backups use the normal Settings +save path. Basic settings continue to save immediately. + +**Tray icons** controls panel status icons and StatusNotifierItem application +icons. Applications can be hidden individually, including after they exit. Tray +preferences live under `status { tray { enabled #true; show-passive #false; +icon-size 16; hidden-items "application-id"; }; }`. Existing custom panels can +add an **Application tray** item in **Panel & Shell**. The compositor, Shell and +Settings must be built and installed together so the schema and editor match. + The [configuration reference](https://github.com/ferese-wm/ferese/wiki/Configuration) and [example config](packaging/config.kdl) cover appearance, layouts, displays, window rules and startup apps. diff --git a/compositor/Cargo.toml b/compositor/Cargo.toml index 92c838b..4981565 100644 --- a/compositor/Cargo.toml +++ b/compositor/Cargo.toml @@ -9,6 +9,7 @@ rust-version.workspace = true workspace = true [dependencies] +schemars.workspace = true ferese-activities = { path = "../crates/ferese-activities" } ferese-panel = { path = "../crates/ferese-panel" } ferese-shape.workspace = true diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 763bafe..f7ec3e5 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -36,8 +36,10 @@ pub(crate) use tab_indicator::TabIndicatorSettings; use crate::window_rules; use crate::window_rules::{WindowRule, WindowRuleConfig}; -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)] pub struct Config { + #[serde(default)] + ui: ferese_config::locale::Settings, #[serde(default)] activities: ferese_config::activities::Settings, #[serde(default)] @@ -79,10 +81,10 @@ pub struct Config { #[serde(default)] pub(crate) xwayland: XwaylandConfig, #[serde(default, rename = "status")] - _status: ShellStatusConfig, + _status: ferese_config::status::Settings, } -#[derive(Clone, Debug, Deserialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, PartialEq, schemars::JsonSchema, serde::Serialize)] pub(crate) struct DaemonConfig { pub command: Vec, #[serde(default = "default_true")] @@ -93,16 +95,6 @@ pub(crate) struct DaemonConfig { pub nested: bool, } -// Validate shell-only fields too, before publishing an accepted source. -#[derive(Debug, Default, Deserialize)] -#[allow(dead_code)] -#[serde(deny_unknown_fields)] -struct ShellStatusConfig { - keybinding_guide: Option, - low_battery_threshold: Option, - settings_command: Option>, -} - impl Config { #[cfg(test)] pub(crate) fn parse_source(source: &str) -> Result { @@ -129,6 +121,7 @@ impl Config { } pub(crate) fn runtime_config(&self) -> Result { + self._status.validate().map_err(ConfigError::Binding)?; if let Some(panels) = &self.panels { ferese_panel::validate(panels).map_err(ConfigError::Binding)?; } @@ -212,13 +205,13 @@ impl Config { } } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)] struct WorkspacesConfig { #[serde(default)] auto_back_and_forth: bool, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)] struct LayoutConfig { mode: Option, #[serde(default = "default_inner_gap")] @@ -240,14 +233,14 @@ impl Default for LayoutConfig { } } -#[derive(Clone, Copy, Debug, Deserialize)] +#[derive(Clone, Copy, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)] #[serde(rename_all = "lowercase")] enum LayoutModeValue { Scrolling, Tree, } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)] struct ScrollingConfig { #[serde(default)] tab_indicator: tab_indicator::TabIndicatorConfig, @@ -258,14 +251,14 @@ struct ScrollingConfig { width_presets: Vec, } -#[derive(Clone, Copy, Debug, Deserialize)] +#[derive(Clone, Copy, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)] #[serde(rename_all = "lowercase")] enum ColumnDisplayValue { Stacked, Tabbed, } -#[derive(Clone, Copy, Debug, Deserialize)] +#[derive(Clone, Copy, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)] #[serde(rename_all = "snake_case")] enum FocusStrategyValue { Minimal, @@ -273,7 +266,7 @@ enum FocusStrategyValue { Paged, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)] #[serde(untagged)] enum ColumnWidthValue { Proportion(f64), @@ -428,6 +421,24 @@ pub(crate) fn config_path() -> Option { #[cfg(test)] mod tests { + #[test] + fn configuration_schema_exposes_effective_panel_defaults_and_typed_values() { + let exported = crate::configuration_schema(); + assert_eq!(exported["version"], 1); + assert_eq!( + exported["defaults"]["panels"], + serde_json::to_value( + Config::default() + .panels + .unwrap_or_else(|| vec![ferese_panel::Panel::default()]) + ) + .unwrap() + ); + let config: Config = serde_json::from_value(exported["defaults"].clone()).unwrap(); + config.runtime_config().unwrap(); + assert!(exported["schema"]["properties"].get("ui").is_some()); + assert!(exported["schema"]["properties"].get("status").is_some()); + } #[test] fn geometry_rejects_removed_and_misspelled_keys_in_both_consumption_paths() { for key in [ diff --git a/compositor/src/config/appearance.rs b/compositor/src/config/appearance.rs index d072aaf..977197e 100644 --- a/compositor/src/config/appearance.rs +++ b/compositor/src/config/appearance.rs @@ -202,14 +202,14 @@ pub struct BorderGradient { pub angle: f64, } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)] pub(super) struct BorderPaintConfig { #[serde(default)] pub(super) style: ferese_config::theme::PaintStyle, pub(super) gradient: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)] pub(super) struct BorderGradientConfig { pub(super) from: String, pub(super) to: String, @@ -278,7 +278,9 @@ pub struct ThemeSettings { pub reduce_transparency: bool, } -#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] +#[derive( + Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, schemars::JsonSchema, serde::Serialize, +)] #[serde(rename_all = "snake_case")] pub enum MaterialStyle { Translucent, @@ -286,7 +288,7 @@ pub enum MaterialStyle { Solid, } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)] #[serde(deny_unknown_fields)] pub(super) struct AppearanceConfig { #[serde(default)] @@ -303,7 +305,7 @@ pub struct FocusEffectSettings { pub duration_ms: f64, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)] #[serde(default, deny_unknown_fields)] pub(super) struct FocusEffectConfig { pub(super) enabled: bool, @@ -327,7 +329,7 @@ impl Default for FocusEffectConfig { } } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)] pub(super) struct ThemeConfig { #[serde(default)] pub(super) border: BorderPaintConfig, @@ -349,23 +351,23 @@ pub(super) struct ThemeConfig { pub(super) surface: ThemeSurfaceConfig, } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)] pub(super) struct ThemeSurfaceConfig { #[serde(default)] pub(super) bar: ThemeBarConfig, } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)] pub(super) struct ThemeBarConfig { pub(super) background: Option, } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)] pub(super) struct OverviewTypographyConfig { pub(super) font_family: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)] pub(super) struct ThemeMaterialConfig { #[serde(default = "default_shell_opacity")] pub(super) opacity: f64, @@ -400,7 +402,7 @@ fn default_backdrop_blur() -> f64 { 12.0 } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)] pub(super) struct ThemeColorsConfig { #[serde(default = "default_text_primary_color")] pub(super) text_primary: String, @@ -429,13 +431,13 @@ impl Default for ThemeColorsConfig { } } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)] pub(super) struct ThemeShadowConfig { #[serde(default)] pub(super) soft: SoftShadowConfig, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)] pub(super) struct SoftShadowConfig { #[serde(default = "default_shadow_offset_y")] pub(super) offset_y: f64, @@ -455,7 +457,7 @@ impl Default for SoftShadowConfig { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)] #[serde(deny_unknown_fields)] pub(super) struct ThemeGeometryConfig { #[serde(default, rename = "control_gap")] diff --git a/compositor/src/config/bindings.rs b/compositor/src/config/bindings.rs index d022538..76a229a 100644 --- a/compositor/src/config/bindings.rs +++ b/compositor/src/config/bindings.rs @@ -832,7 +832,18 @@ impl Binding { } } -#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Hash, PartialEq)] +#[derive( + Clone, + Copy, + Debug, + Default, + Deserialize, + Eq, + Hash, + PartialEq, + schemars::JsonSchema, + serde::Serialize, +)] #[serde(rename_all = "lowercase")] pub(super) enum BindingMatch { #[default] @@ -843,7 +854,7 @@ pub(super) enum BindingMatch { Pointer, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)] pub(super) struct BindingConfig { pub(super) keys: String, #[serde(default, rename = "match")] diff --git a/compositor/src/config/bindings/modes.rs b/compositor/src/config/bindings/modes.rs index a5fa3f6..7afa2b4 100644 --- a/compositor/src/config/bindings/modes.rs +++ b/compositor/src/config/bindings/modes.rs @@ -6,7 +6,7 @@ use super::{ #[cfg(test)] use ferese_layout::Direction; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)] #[serde(deny_unknown_fields)] pub(crate) struct BindingModeConfig { name: String, diff --git a/compositor/src/config/input_motion.rs b/compositor/src/config/input_motion.rs index 32f4322..97f20cf 100644 --- a/compositor/src/config/input_motion.rs +++ b/compositor/src/config/input_motion.rs @@ -141,7 +141,7 @@ fn default_swipe_threshold() -> u16 { 80 } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)] pub(super) struct InputConfig { #[serde(default)] pub(super) focus_follows_mouse: bool, @@ -176,7 +176,7 @@ impl Default for InputConfig { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)] pub(super) struct TouchpadConfig { #[serde(default)] pub(super) accel_speed: f64, @@ -257,14 +257,16 @@ mod tests { } } -#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[derive( + Clone, Copy, Debug, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize, +)] #[serde(rename_all = "lowercase")] pub enum AccelerationProfile { Adaptive, Flat, } -#[derive(Clone, Copy, Debug, Deserialize, PartialEq)] +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, schemars::JsonSchema, serde::Serialize)] #[serde(default, deny_unknown_fields)] pub struct MouseSettings { pub natural_scroll: bool, diff --git a/compositor/src/config/outputs.rs b/compositor/src/config/outputs.rs index 049430c..61a3142 100644 --- a/compositor/src/config/outputs.rs +++ b/compositor/src/config/outputs.rs @@ -191,7 +191,9 @@ pub struct OutputModeRequest { pub refresh_millihertz: Option, } -#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] +#[derive( + Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, schemars::JsonSchema, serde::Serialize, +)] #[serde(rename_all = "snake_case")] pub enum OutputTransform { #[default] @@ -211,7 +213,9 @@ pub enum OutputTransform { Flipped270, } -#[derive(Clone, Copy, Debug, Default, Deserialize, serde::Serialize, PartialEq, Eq)] +#[derive( + Clone, Copy, Debug, Default, Deserialize, serde::Serialize, PartialEq, Eq, schemars::JsonSchema, +)] #[serde(rename_all = "kebab-case")] pub enum LidPolicy { #[default] @@ -223,7 +227,9 @@ const fn default_confirm_timeout() -> u32 { 15 } -#[derive(Clone, Copy, Debug, Default, Deserialize, serde::Serialize, PartialEq, Eq)] +#[derive( + Clone, Copy, Debug, Default, Deserialize, serde::Serialize, PartialEq, Eq, schemars::JsonSchema, +)] #[serde(rename_all = "kebab-case")] pub enum OutputLayout { InternalOnly, @@ -233,7 +239,7 @@ pub enum OutputLayout { Mirror, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)] pub(super) struct OutputProfileConfig { pub(super) name: String, #[serde(default)] @@ -248,7 +254,7 @@ pub(super) struct OutputProfileConfig { pub(super) outputs: Vec, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)] pub(super) struct OutputConfig { #[serde(default)] pub(super) auto_refresh: bool, diff --git a/compositor/src/config/tab_indicator.rs b/compositor/src/config/tab_indicator.rs index 13225a1..0d84239 100644 --- a/compositor/src/config/tab_indicator.rs +++ b/compositor/src/config/tab_indicator.rs @@ -1,6 +1,6 @@ use super::{Config, ConfigError, Deserialize, parse_color}; -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)] #[serde(default, deny_unknown_fields)] pub(super) struct TabIndicatorConfig { width: f64, diff --git a/compositor/src/config/xwayland.rs b/compositor/src/config/xwayland.rs index 7a81de7..e393b00 100644 --- a/compositor/src/config/xwayland.rs +++ b/compositor/src/config/xwayland.rs @@ -2,7 +2,9 @@ use std::path::PathBuf; use serde::Deserialize; -#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)] +#[derive( + Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize, +)] #[serde(rename_all = "kebab-case")] pub(crate) enum XwaylandStartup { #[default] @@ -10,7 +12,7 @@ pub(crate) enum XwaylandStartup { Eager, } -#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize)] #[serde(default, deny_unknown_fields)] pub(crate) struct XwaylandConfig { pub enabled: bool, @@ -79,7 +81,7 @@ mod tests { #[test] fn startup_mode_accepts_kebab_case() { - #[derive(Deserialize)] + #[derive(Deserialize, schemars::JsonSchema, serde::Serialize)] struct Wrapper { startup: XwaylandStartup, } diff --git a/compositor/src/idle_inhibition.rs b/compositor/src/idle_inhibition.rs index 9253d83..659e17e 100644 --- a/compositor/src/idle_inhibition.rs +++ b/compositor/src/idle_inhibition.rs @@ -11,7 +11,9 @@ use smithay::wayland::shell::xdg::XdgToplevelSurfaceData; use crate::Ferese; -#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[derive( + Clone, Copy, Debug, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize, +)] #[serde(default, deny_unknown_fields)] pub(crate) struct Settings { pub fullscreen_playback: bool, @@ -25,7 +27,9 @@ impl Default for Settings { } } -#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[derive( + Clone, Copy, Debug, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize, +)] #[serde(rename_all = "kebab-case")] pub enum Mode { None, diff --git a/compositor/src/main.rs b/compositor/src/main.rs index ac7ee22..c3769da 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -65,12 +65,28 @@ use tracing::{info, warn}; use crate::backends::LaunchConfig; +fn configuration_schema() -> serde_json::Value { + let mut schema = + serde_json::to_value(schemars::schema_for!(config::Config)).expect("serializable schema"); + let mut defaults = + serde_json::to_value(config::Config::default()).expect("serializable defaults"); + // None means the implicit default panel, not an empty desktop. Present its + // effective value so the complete editor can modify it directly. + defaults["panels"] = serde_json::json!([ferese_panel::Panel::default()]); + schema["properties"]["panels"]["default"] = defaults["panels"].clone(); + serde_json::json!({"version":1,"schema":schema,"defaults":defaults}) +} + fn main() -> Result<(), Box> { init_logging(); // Settings validates a candidate before atomically replacing the user's file. // This mode never opens a display, input device, or compositor socket. let args = std::env::args_os().skip(1).collect::>(); + if args == ["--config-schema"] || args == ["--config-schema", "--backend=schema-query"] { + println!("{}", configuration_schema()); + return Ok(()); + } if args.first().is_some_and(|arg| arg == "--check-config") { if args.len() != 2 { return Err("usage: ferese --check-config PATH".into()); diff --git a/compositor/src/session_lock.rs b/compositor/src/session_lock.rs index f69df9a..e3a8403 100644 --- a/compositor/src/session_lock.rs +++ b/compositor/src/session_lock.rs @@ -17,7 +17,7 @@ use smithay::wayland::session_lock::{ use crate::Ferese; -#[derive(Clone, Copy, Debug, serde::Deserialize)] +#[derive(Clone, Copy, Debug, serde::Deserialize, schemars::JsonSchema, serde::Serialize)] #[serde(default)] pub(crate) struct IdleSettings { pub dim_after_seconds: u64, diff --git a/compositor/src/wallpaper.rs b/compositor/src/wallpaper.rs index 63ecdc5..00e2c69 100644 --- a/compositor/src/wallpaper.rs +++ b/compositor/src/wallpaper.rs @@ -27,7 +27,7 @@ use std::sync::Arc; const UPLOAD_RETRY_DELAY: Duration = Duration::from_secs(5); -#[derive(Clone, Debug, Deserialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, PartialEq, schemars::JsonSchema, serde::Serialize)] pub(crate) struct WallpaperConfig { #[serde(default = "default_wallpaper_path")] pub path: Option, @@ -48,7 +48,9 @@ impl Default for WallpaperConfig { } } -#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)] +#[derive( + Clone, Copy, Debug, Default, Deserialize, PartialEq, schemars::JsonSchema, serde::Serialize, +)] #[serde(rename_all = "snake_case")] pub(crate) enum WallpaperMode { #[default] diff --git a/compositor/src/window_rules.rs b/compositor/src/window_rules.rs index b33d788..1c35c5c 100644 --- a/compositor/src/window_rules.rs +++ b/compositor/src/window_rules.rs @@ -1,6 +1,6 @@ use serde::Deserialize; -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)] pub(crate) struct WindowRuleConfig { pub app_id: Option, pub title: Option, diff --git a/crates/ferese-activities/Cargo.toml b/crates/ferese-activities/Cargo.toml index f49deb4..7696894 100644 --- a/crates/ferese-activities/Cargo.toml +++ b/crates/ferese-activities/Cargo.toml @@ -9,4 +9,5 @@ rust-version.workspace = true workspace = true [dependencies] +schemars.workspace = true serde.workspace = true diff --git a/crates/ferese-activities/src/pomodoro.rs b/crates/ferese-activities/src/pomodoro.rs index 3bfb60e..077a54b 100644 --- a/crates/ferese-activities/src/pomodoro.rs +++ b/crates/ferese-activities/src/pomodoro.rs @@ -3,7 +3,7 @@ use crate::Time; use serde::{Deserialize, Serialize}; -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] #[serde(default, deny_unknown_fields)] pub struct Settings { pub work_minutes: u16, @@ -46,7 +46,7 @@ impl Settings { }) * 60_000 } } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] #[serde(rename_all = "kebab-case")] pub enum Phase { Work, @@ -63,7 +63,7 @@ impl Phase { } } } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] #[serde(rename_all = "kebab-case")] pub enum Status { Running, @@ -71,7 +71,7 @@ pub enum Status { Ready, Finished, } -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] pub struct Session { pub settings: Settings, pub phase: Phase, diff --git a/crates/ferese-activities/src/store.rs b/crates/ferese-activities/src/store.rs index 0841daa..861d588 100644 --- a/crates/ferese-activities/src/store.rs +++ b/crates/ferese-activities/src/store.rs @@ -7,14 +7,18 @@ const BOOST_MS: u64 = 5_000; pub const MAX_TEXT: usize = 256; pub const MAX_LEASE_MS: u64 = 300_000; -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] +#[derive( + Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize, schemars::JsonSchema, +)] #[serde(deny_unknown_fields)] pub struct Owner { pub pid: u32, pub birth: u64, } -#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] +#[derive( + Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize, schemars::JsonSchema, +)] #[serde(deny_unknown_fields)] pub struct Id { pub owner: Owner, @@ -23,7 +27,7 @@ pub struct Id { pub session: u64, } -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)] #[serde( tag = "kind", content = "value", @@ -52,7 +56,7 @@ impl Value { } } -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)] #[serde( tag = "kind", content = "data", @@ -66,7 +70,7 @@ pub enum Template { List { rows: Vec }, } -#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, schemars::JsonSchema)] #[serde(rename_all = "kebab-case")] pub enum Source { Publisher, @@ -79,7 +83,7 @@ pub enum Source { Process, } -#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, schemars::JsonSchema)] #[serde(rename_all = "kebab-case")] pub enum State { Running, @@ -88,7 +92,7 @@ pub enum State { Cancelled, } -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub struct Data { pub source: Source, @@ -204,7 +208,7 @@ fn text(value: &str) -> Result<(), &'static str> { } } -#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[derive(Clone, Copy, Debug, Deserialize, Serialize, schemars::JsonSchema)] #[serde(default, deny_unknown_fields)] pub struct Limits { pub max_visible: usize, @@ -228,7 +232,7 @@ impl Default for Limits { } } -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)] pub struct Activity { pub id: Id, pub data: Data, diff --git a/crates/ferese-animation/Cargo.toml b/crates/ferese-animation/Cargo.toml index a276967..34209e3 100644 --- a/crates/ferese-animation/Cargo.toml +++ b/crates/ferese-animation/Cargo.toml @@ -9,5 +9,6 @@ rust-version.workspace = true workspace = true [dependencies] +schemars.workspace = true ferese-layout = { path = "../ferese-layout" } serde.workspace = true diff --git a/crates/ferese-animation/src/settings.rs b/crates/ferese-animation/src/settings.rs index d5b5b92..82def77 100644 --- a/crates/ferese-animation/src/settings.rs +++ b/crates/ferese-animation/src/settings.rs @@ -1,7 +1,9 @@ use crate::{CrossingPolicy, SpringConfig}; /// One configuration contract for compositor and out-of-process shell motion. -#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)] +#[derive( + Clone, Copy, Debug, PartialEq, serde::Deserialize, schemars::JsonSchema, serde::Serialize, +)] #[serde(default, deny_unknown_fields)] pub struct MotionSettings { pub enabled: bool, @@ -63,7 +65,16 @@ impl MotionSettings { } } -#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Deserialize)] +#[derive( + Clone, + Copy, + Debug, + Default, + PartialEq, + serde::Deserialize, + schemars::JsonSchema, + serde::Serialize, +)] #[serde(default, deny_unknown_fields)] pub struct SpringSettings { pub duration_ms: Option, diff --git a/crates/ferese-config/Cargo.toml b/crates/ferese-config/Cargo.toml index 82c73ed..10d373f 100644 --- a/crates/ferese-config/Cargo.toml +++ b/crates/ferese-config/Cargo.toml @@ -9,6 +9,7 @@ rust-version.workspace = true workspace = true [dependencies] +schemars.workspace = true ferese-activities = { path = "../ferese-activities" } regex = "1.12" ferese-panel = { path = "../ferese-panel" } diff --git a/crates/ferese-config/locales/zh-CN.json b/crates/ferese-config/locales/zh-CN.json new file mode 100644 index 0000000..3f259c3 --- /dev/null +++ b/crates/ferese-config/locales/zh-CN.json @@ -0,0 +1,571 @@ +{ + "Appearance": "外观与设置", + "Theme": "主题", + "General": "常规", + "Language": "界面语言", + "Follow system": "跟随系统", + "English": "English", + "Desktop": "桌面", + "Interaction": "交互", + "System": "系统", + "Advanced": "高级", + "All options": "全部配置", + "Tray icons": "托盘图标", + "Application tray": "应用托盘", + "Show tray icons": "显示托盘图标", + "Save": "保存", + "Cancel": "取消", + "Apply": "应用", + "Reload": "重新加载", + "Undo": "撤销", + "Search settings": "搜索设置", + "Input": "输入", + "Show passive icons": "显示被动图标", + "Tray icon size": "托盘图标大小", + "Hidden applications": "隐藏的应用", + "Application IDs to hide, separated by spaces.": "要隐藏的应用 ID,以空格分隔。", + "Include applications without active status.": "同时显示没有活动状态的应用。", + "Show application icons in panel tray items.": "在面板的应用托盘组件中显示图标。", + "Size of application tray icons.": "应用托盘图标的大小。", + "Status icons can also be hidden individually in Panel & Shell.": "也可以在“面板与 Shell”中单独隐藏状态图标。", + "Manage tray icons": "管理托盘图标", + "All Ferese settings, grouped by function.": "按功能分组管理 Ferese 的全部配置。", + "Language and desktop preferences.": "界面语言与桌面偏好设置。", + "Changes the Ferese interface language. Applies immediately.": "切换 Ferese 界面语言,立即生效。", + "Choose which status and application icons appear in the panel.": "选择面板中显示的状态图标和应用图标。", + "Every supported configuration option, including advanced settings.": "管理全部受支持的配置选项,包括高级设置。", + "Advanced changes are saved together. Press Enter to confirm text fields, then Apply.": "高级配置统一保存。文本输入后按回车确认,再点击“应用”。", + "Config changed externally. Reload before applying this draft.": "配置已被其他程序修改。请重新加载后再应用此草稿。", + "Confirm text fields with Enter before applying.": "请先按回车确认文本字段,再应用配置。", + "Cannot load configuration schema": "无法加载配置结构", + "Maximum nesting depth reached": "已达到最大嵌套层数", + "Discard draft": "放弃草稿", + "New key": "新建键", + "Text": "文本", + "Value": "数值", + "Enable": "启用", + "Disable": "停用", + "Remove": "移除", + "Add": "添加", + "Search": "搜索", + "Ferese Settings": "Ferese 设置", + "Close navigation": "关闭导航", + "Show settings navigation": "显示设置导航", + "Reload configuration": "重新加载配置", + "Undo last change": "撤销上次修改", + "A desktop that feels like yours.": "打造适合你的桌面。", + "A place for every screen.": "为每块屏幕安排位置。", + "A sample of active, occupied and empty workspaces.": "预览活动、已占用和空闲工作区。", + "A subtle outline around the active window.": "为活动窗口添加细边框。", + "Accent color": "强调色", + "Accent style": "强调样式", + "Accessibility": "辅助功能", + "Active window opacity": "活动窗口不透明度", + "Add group": "添加分组", + "Add login item": "添加登录启动项", + "Adjust pointer acceleration for connected mice.": "调整已连接鼠标的指针加速度。", + "Adjust pointer acceleration for the touchpad.": "调整触控板的指针加速度。", + "Advanced customization": "高级自定义", + "Animate window changes and shell transitions.": "为窗口变化和 Shell 过渡启用动画。", + "Animation speed": "动画速度", + "Animations and transition speed.": "动画与过渡速度。", + "Apply to": "应用到", + "Apply window opacity and inactive dimming. Turning this off keeps your settings.": "应用窗口透明度与非活动窗口变暗效果,关闭时保留设置。", + "Arrange your panel and change the shell's appearance.": "排列面板并调整 Shell 外观。", + "Arrangement": "排列", + "Auto": "自动", + "Auto uses 60 Hz below 30% battery and returns to normal on AC or at 35%. Applying a display mode can briefly blank the screen.": "自动模式在电量低于 30% 时使用 60 Hz,连接电源或电量达到 35% 时恢复。应用显示模式时屏幕可能短暂变黑。", + "Automatic appearance": "自动外观", + "Avoid accidental pointer movement.": "避免误触导致指针移动。", + "Back to Panels": "返回面板", + "Background blur": "背景模糊", + "Background opacity for the bar, popovers, menus, notifications and sharing dialog. Text and icons stay opaque.": "面板、弹出层、菜单、通知和共享对话框的背景不透明度;文字和图标保持不透明。", + "Between windows": "窗口间距", + "Blur the lock screen wallpaper. Zero keeps the original image sharp.": "模糊锁屏壁纸。设为零保留原图清晰度。", + "Brightness": "亮度", + "Characters repeated each second while a key is held.": "按住按键时每秒重复的字符数。", + "Choose a fixed appearance or let Auto follow a source.": "使用固定外观,或让自动模式跟随指定来源。", + "Choose an image above, or enter its full path.": "在上方选择图片,或输入完整路径。", + "Choose image…": "选择图片…", + "Choose the finish for the panel, launcher, menus and notifications.": "选择面板、启动器、菜单和通知的材质样式。", + "Clock format": "时钟格式", + "Composition preview": "布局预览", + "Connections are managed by the system. Passwords are not saved in your Ferese config.": "连接由系统管理,密码不会保存在 Ferese 配置中。", + "Corner radius": "圆角半径", + "Current wallpaper": "当前壁纸", + "Custom themes": "自定义主题", + "Dark at": "深色开始时间", + "Dark theme file": "深色主题文件", + "Darken inactive windows. Zero disables dimming.": "降低非活动窗口亮度。设为零关闭变暗效果。", + "Darken the wallpaper behind the clock and sign-in card.": "降低时钟和登录卡片背后的壁纸亮度。", + "Default duration; apps can request another timeout. Pauses while hovered.": "默认显示时长;应用可指定其他时长,悬停时暂停计时。", + "Default wallpapers": "默认壁纸", + "Delay before repeat": "重复延迟", + "Deselect": "取消选择", + "Dim after": "变暗等待时间", + "Dim the locked display after inactivity. Zero disables dimming.": "锁屏闲置一段时间后降低显示亮度,设为零关闭。", + "Disable touchpad with a mouse": "连接鼠标时停用触控板", + "Display mode": "显示模式", + "Display sleep after": "显示器休眠等待时间", + "Display the time above the sign-in card.": "在登录卡片上方显示时间。", + "Display the weekday and date.": "显示星期和日期。", + "Distance needed to navigate. Larger values reduce accidental swipes.": "触发导航所需的滑动距离;较大值可减少误触。", + "Do Not Disturb": "勿扰模式", + "Down": "下移", + "Drag items to reorder. Select an item or group to edit.": "拖动项目排序,选择项目或分组进行编辑。", + "Drop an item here, or add a group.": "将项目拖到这里,或添加分组。", + "Drop items here": "将项目拖到这里", + "Duration of opacity and dimming changes when the active window changes.": "活动窗口改变时,不透明度和亮度变化的过渡时长。", + "Edge margin": "边缘距离", + "Edit panel": "编辑面板", + "Emulate middle click": "模拟中键点击", + "Existing workspaces keep their current layout.": "现有工作区保留当前布局。", + "Fill crops to the screen. Fit keeps the whole image.": "填充会裁剪图片以铺满屏幕;适应会保留完整图片。", + "Focus border": "焦点边框", + "Focus follows pointer": "焦点跟随指针", + "Focus rings and selected controls. Use a hex color.": "焦点边框和选中控件的颜色,使用十六进制色值。", + "Focus transition": "焦点过渡", + "Focus visible windows without raising or scrolling. Click to bring them into view.": "聚焦可见窗口时不置顶或滚动;点击可将窗口移入视野。", + "Fraction of the available workspace.": "占可用工作区的比例。", + "Gradients retain the border color's transparency.": "渐变保留边框颜色的透明度。", + "How long to hold a key before it starts repeating.": "按住按键后开始重复前的等待时间。", + "How the viewport follows the focused column.": "视口跟随焦点列的方式。", + "Ignore touchpad while typing": "打字时忽略触控板", + "Image location": "图片位置", + "Image placement": "图片缩放方式", + "Import…": "导入…", + "Inactive border style": "非活动边框样式", + "Inactive window dimming": "非活动窗口变暗", + "Inactive window opacity": "非活动窗口不透明度", + "Increase contrast": "增强对比度", + "Inset from both edges; stops before extra controls overflow.": "从两侧向内缩进,并避免额外控件溢出。", + "Interface font": "界面字体", + "Keep notifications in history when popups are hidden.": "隐藏弹出通知时仍在历史记录中保留通知。", + "Keep the shortcut guide until you are comfortable with Ferese.": "在熟悉 Ferese 前保留快捷键指南。", + "Keeps icon and text sizes unchanged.": "保持图标和文字大小不变。", + "Key repeat": "按键重复", + "Keyboard & mouse": "键盘与鼠标", + "Keyboard layouts": "键盘布局", + "Keyboard layouts, pointer behavior and touchpad gestures.": "键盘布局、指针行为与触控板手势。", + "Keyboard shortcuts and swipe actions.": "键盘快捷键与滑动操作。", + "Keyboard, mouse and swipe bindings share the same actions. Mouse shortcuts can use MouseLeft, MouseRight, MouseMiddle, MouseBack, MouseForward or WheelUp/Down/Left/Right with modifiers. Use pointer-move or pointer-resize for dragging. Swipes use Swipe3Up, Swipe3Down, Swipe3Left or Swipe3Right (also supports 4 or 5 fingers). Actions such as toggle-overview and none need no argument.": "键盘、鼠标和滑动手势共用操作定义。鼠标可使用 MouseLeft、MouseRight、MouseMiddle、MouseBack、MouseForward 或 WheelUp/Down/Left/Right,并组合修饰键。拖动使用 pointer-move 或 pointer-resize。滑动使用 Swipe3Up、Swipe3Down、Swipe3Left 或 Swipe3Right,也支持 4 指和 5 指。toggle-overview 和 none 等操作无需参数。", + "Layout codes, separated by commas. For example: us,ru.": "布局代码,以逗号分隔,例如 us,ru。", + "Layout for new workspaces": "新工作区布局", + "Layout variant": "布局变体", + "Leave empty to use the standard variant.": "留空使用标准变体。", + "Left-handed mouse": "左手鼠标", + "Light at": "浅色开始时间", + "Light theme file": "浅色主题文件", + "Live display information unavailable": "无法获取实时显示信息", + "Local time in HH:MM format.": "本地时间,格式为 HH:MM。", + "Lock screen": "锁屏", + "Login items": "登录启动项", + "Login items update live. Disabling or removing an item stops the session-owned process; enabling one starts it.": "登录启动项实时更新。停用或移除会停止当前会话管理的进程,启用会启动进程。", + "Lower temperatures are warmer": "色温越低,颜色越暖", + "Lower values give transitions more time.": "数值越小,过渡时间越长。", + "Make it your own": "个性化设置", + "Managed automatically · no saved display profile": "自动管理 · 未保存显示配置", + "Match appearance": "跟随外观", + "Move or remove this group’s items before removing the group.": "请先移动或移除此分组中的项目,再移除分组。", + "Natural scrolling": "自然滚动", + "New window width": "新窗口宽度", + "Night light is unavailable": "夜光不可用", + "No displays or saved profiles found": "未找到显示器或已保存的配置", + "No matching settings. Try wallpaper, keyboard, or motion.": "未找到匹配的设置。可尝试搜索壁纸、键盘或动画。", + "Opacity of other application windows.": "其他应用窗口的不透明度。", + "Opacity of the active application, including fullscreen windows.": "活动应用窗口的不透明度,包括全屏窗口。", + "Open Ferese Settings": "打开 Ferese 设置", + "Optional KDL overrides. Relative paths start in your config folder.": "可选的 KDL 覆盖文件,相对路径从配置目录开始。", + "Optional overrides for Dark appearance.": "可选的深色外观覆盖文件。", + "Optional overrides for Light appearance.": "可选的浅色外观覆盖文件。", + "Panel & Shell": "面板与 Shell", + "Password": "密码", + "Pause the built-in touchpad while an external mouse is connected.": "连接外部鼠标时暂停使用内置触控板。", + "Placement": "位置", + "Popup timeout": "弹出通知时长", + "Press both mouse buttons together to middle-click.": "同时按下鼠标左右键来模拟中键点击。", + "Press the current workspace's shortcut again to return to the previous workspace on this monitor.": "再次按下当前工作区快捷键,返回本显示器上一个工作区。", + "Preview lock screen": "预览锁屏", + "Program and arguments": "程序与参数", + "Ready when you sign in.": "登录时准备就绪。", + "Reduce motion": "减少动画", + "Reduce transparency": "减少透明效果", + "Refresh displays": "刷新显示器", + "Remove custom panel": "移除自定义面板", + "Remove login item": "移除登录启动项", + "Remove override": "移除覆盖值", + "Reset to style": "恢复样式默认值", + "Reverse the direction of the mouse wheel.": "反转鼠标滚轮方向。", + "Rounded corners for application windows.": "应用窗口的圆角。", + "Rounds the bar, menus, notifications and hover backgrounds. Window corners are separate.": "调整面板、菜单、通知和悬停背景的圆角,窗口圆角单独设置。", + "Sample content": "示例内容", + "Scale wheel scrolling in applications.": "缩放应用中的滚轮滚动量。", + "Scroll behavior": "滚动行为", + "Select an item to adjust how it appears, or a group to edit its placement and spacing.": "选择项目调整外观,选择分组调整位置和间距。", + "Set the scene for your workspace.": "为工作区设置背景。", + "Shared theme file": "共享主题文件", + "Shell appearance": "Shell 外观", + "Shell corner radius": "Shell 圆角半径", + "Shell opacity": "Shell 不透明度", + "Show clock": "显示时钟", + "Show date": "显示日期", + "Show keybinding guide at login": "登录时显示快捷键指南", + "Show popup notifications": "显示弹出通知", + "Shown on all displays": "显示在所有屏幕上", + "Side margins": "两侧边距", + "Silence normal popups; critical notifications can still appear.": "隐藏普通弹出通知,紧急通知仍可显示。", + "Size & spacing": "大小与间距", + "Soften the background behind translucent windows. Adjust the amount in Appearance.": "模糊半透明窗口后的背景,可在外观中调整强度。", + "Softness behind translucent shell surfaces and windows. Zero disables blur.": "半透明 Shell 和窗口背景的模糊强度,设为零关闭模糊。", + "Solid surfaces; background blur is disabled.": "使用不透明表面,并关闭背景模糊。", + "Space around the outside of the layout.": "布局外围的间距。", + "Space between neighboring windows.": "相邻窗口之间的距离。", + "Space between the panel and application windows.": "面板与应用窗口之间的距离。", + "Space from the selected screen edge. Set to zero for a flush panel.": "面板与所选屏幕边缘的距离,设为零贴紧边缘。", + "Stay informed on your terms.": "按你的偏好接收通知。", + "Stronger text and borders.": "增强文字和边框的对比度。", + "Surface style": "表面样式", + "Swap the primary and secondary mouse buttons.": "交换鼠标主键和次键。", + "Swipe distance": "滑动距离", + "Tap the touchpad to click. Updates connected devices immediately.": "轻触触控板实现点击,立即应用到已连接设备。", + "Tap to click": "轻触点击", + "The preview is an ordinary window and does not lock your session. Automatic locking is configured separately in Login items.": "预览是普通窗口,不会锁定会话。自动锁定可在登录启动项中单独配置。", + "Theme overrides": "主题覆盖", + "Toggle back with the same workspace shortcut": "再次按工作区快捷键返回", + "Touchpad speed": "触控板速度", + "Turn off locked displays after inactivity. Input wakes them; zero disables display sleep.": "锁屏闲置后关闭显示器,输入可唤醒,设为零关闭休眠。", + "Up": "上移", + "Use a 12-hour or 24-hour clock.": "使用 12 小时制或 24 小时制。", + "Use immediate changes in place of animations.": "立即切换状态,省略动画。", + "Use shell opacity": "使用 Shell 不透明度", + "Use the name of an installed font family.": "使用已安装的字体族名称。", + "Uses your wallpaper, shell colors, font and corner radius from Appearance. Changes apply when the locker or preview opens.": "使用外观中的壁纸、Shell 颜色、字体和圆角。下次打开锁屏或预览时生效。", + "Volume": "音量", + "Wallpaper dimming": "壁纸变暗", + "Wallpaper softness": "壁纸模糊", + "Welcome to Ferese": "欢迎使用 Ferese", + "Wi-Fi networks and Bluetooth devices.": "Wi-Fi 网络与蓝牙设备。", + "Window blur": "窗口模糊", + "Window clearance": "窗口预留空间", + "Window focus borders and selected controls. Gradients use the theme colors unless overridden.": "窗口焦点边框与选中控件。渐变默认使用主题颜色。", + "Window focus effects": "窗口焦点效果", + "Window layouts, spacing and focus effects.": "窗口布局、间距与焦点效果。", + "Workspace edges": "工作区边距", + "Workspace style": "工作区样式", + "You are using the built-in shortcuts. Add custom bindings in config.kdl; they will appear here after Reload.": "当前使用内置快捷键。可在“全部配置”中添加自定义绑定,重新加载后在此显示。", + "Your desktop, safely put away.": "安全锁定你的桌面。", + "system follows your desktop timezone; or enter an IANA timezone.": "system 跟随桌面时区,也可以输入 IANA 时区名称。", + "Windows": "窗口", + "Motion": "动画", + "Wallpaper": "壁纸", + "Notifications": "通知", + "Displays": "显示器", + "Connections": "连接", + "Shortcuts": "快捷键", + "Make the desktop easier to see and use.": "让桌面更易于看清和操作。", + "Find a setting for your desktop.": "查找桌面设置。", + "Light": "浅色", + "Dark": "深色", + "Schedule": "定时", + "Manual": "手动", + "Default": "默认", + "Custom": "自定义", + "Solid": "纯色", + "Gradient": "渐变", + "None": "无", + "On": "开启", + "Off": "关闭", + "on": "开启", + "off": "关闭", + "Top": "顶部", + "Bottom": "底部", + "Left": "左侧", + "Right": "右侧", + "Center": "居中", + "Start": "起始", + "End": "末尾", + "Fill": "填充", + "Fit": "适应", + "Stretch": "拉伸", + "Tile": "平铺", + "Static": "静态", + "Scrolling": "滚动", + "Tiling": "平铺", + "Floating": "浮动", + "Always": "始终", + "Never": "从不", + "Automatic": "自动", + "Compact": "紧凑", + "Icon": "图标", + "Wide": "宽", + "Dots": "圆点", + "Numbers": "数字", + "Pills": "胶囊", + "Overview": "概览", + "Workspaces": "工作区", + "Focused window": "焦点窗口", + "Media": "媒体", + "Activities": "活动", + "Quick Settings": "快捷设置", + "Night light": "夜光", + "Network": "网络", + "Audio": "音频", + "Recording": "录制", + "Capture status": "捕获状态", + "Battery": "电池", + "Clock": "时钟", + "Overflow": "溢出项", + "Control Center": "控制中心", + "Sound": "声音", + "Calendar": "日历", + "Panel items": "面板项目", + "Screen recording": "屏幕录制", + "Settings": "设置", + "Power": "电源", + "Lock": "锁定", + "Lock session": "锁定会话", + "Log out": "注销", + "Restart": "重启", + "Shut down": "关机", + "Suspend": "挂起", + "Hibernate": "休眠", + "Cancel recording": "取消录制", + "Stop recording": "停止录制", + "Start recording": "开始录制", + "Open calendar": "打开日历", + "Authentication required": "需要身份验证", + "Unlock": "解锁", + "Unlocking…": "正在解锁…", + "Try again": "重试", + "Sign in": "登录", + "Dismiss": "关闭", + "Clear all": "全部清除", + "Connect": "连接", + "Disconnect": "断开连接", + "Connected": "已连接", + "Disconnected": "未连接", + "Connecting…": "正在连接…", + "Forget": "忘记", + "Pair": "配对", + "Remove device": "移除设备", + "Available networks": "可用网络", + "Saved networks": "已保存网络", + "Bluetooth devices": "蓝牙设备", + "Refresh": "刷新", + "Details": "详情", + "Close": "关闭", + "Date & time": "日期与时间", + "Time zone": "时区", + "Mouse": "鼠标", + "Touchpad": "触控板", + "Keyboard": "键盘", + "Pointer speed": "指针速度", + "Scrolling speed": "滚动速度", + "Opacity": "不透明度", + "Blur": "模糊", + "Spacing": "间距", + "Width": "宽度", + "Height": "高度", + "Position": "位置", + "Scale": "缩放", + "Rotation": "旋转", + "Resolution": "分辨率", + "Refresh rate": "刷新率", + "Enabled": "启用", + "Disabled": "停用", + "Priority": "优先级", + "Visible": "可见", + "Remove group": "移除分组", + "Add item": "添加项目", + "Remove item": "移除项目", + "ui": "界面", + "status": "状态与托盘", + "activities": "活动", + "panels": "面板", + "notifications": "通知", + "lock_screen": "锁屏", + "night_light": "夜光", + "idle_inhibit": "阻止空闲", + "animations": "动画", + "layout": "布局", + "workspaces": "工作区", + "input": "输入设备", + "appearance": "外观效果", + "theme": "主题", + "commands": "命令", + "bindings": "快捷键绑定", + "binding_modes": "快捷键模式", + "window_rules": "窗口规则", + "scrolling": "滚动", + "output_profiles": "显示器配置", + "xwayland": "XWayland", + "autostart": "自动启动", + "language": "界面语言", + "tray": "应用托盘", + "enabled": "启用", + "show_passive": "显示被动图标", + "icon_size": "图标大小", + "hidden_items": "隐藏的应用", + "keybinding_guide": "快捷键指南", + "low_battery_threshold": "低电量阈值", + "settings_command": "设置命令", + "focus_follows_mouse": "焦点跟随鼠标", + "xkb_layout": "键盘布局", + "xkb_variant": "布局变体", + "xkb_options": "键盘选项", + "repeat_rate": "按键重复速率", + "repeat_delay_ms": "按键重复延迟(毫秒)", + "touchpad": "触控板", + "mouse": "鼠标", + "natural_scroll": "自然滚动", + "accel_speed": "加速速度", + "accel_profile": "加速模式", + "left_handed": "左手模式", + "middle_emulation": "中键模拟", + "scroll_factor": "滚动倍率", + "drag_threshold": "拖动阈值", + "scroll_cooldown_ms": "滚动冷却时间(毫秒)", + "tap": "轻触点击", + "disable_while_mouse_connected": "连接鼠标时停用", + "disable_while_typing": "打字时停用", + "swipe_threshold": "滑动阈值", + "id": "标识", + "name": "名称", + "kind": "类型", + "style": "样式", + "start": "起始", + "center": "居中", + "end": "末尾", + "groups": "分组", + "items": "项目", + "geometry": "几何", + "morph": "变形面板", + "edge": "屏幕边缘", + "border": "边框", + "background_opacity": "背景不透明度", + "corner_radius": "圆角半径", + "visible": "可见", + "priority": "优先级", + "overflow": "溢出策略", + "representation": "显示形式", + "metadata": "附加数据", + "gap_before": "前置间距", + "percentage": "显示百分比", + "keys": "按键", + "action": "操作", + "argument": "参数", + "match": "匹配条件", + "outputs": "输出", + "position": "位置", + "scale": "缩放", + "transform": "变换", + "mode": "模式", + "file": "文件", + "light": "浅色", + "dark": "深色", + "auto": "自动", + "schedule": "定时", + "material": "材质", + "typography": "排版", + "colors": "颜色", + "font_family": "字体族", + "opacity": "不透明度", + "blur_radius": "模糊半径", + "gap": "间距", + "gaps": "间距", + "width": "宽度", + "height": "高度", + "spacing": "间距", + "size": "大小", + "rules": "规则", + "progress": "进度", + "ignore_parents": "忽略父级", + "title": "标题", + "icon": "图标", + "result": "结果", + "min_duration_ms": "最短时长(毫秒)", + "flash_on_fail": "失败时闪烁", + "dedupe_by": "去重字段", + "object": "对象", + "array": "列表", + "string": "文本", + "number": "数值", + "integer": "整数", + "boolean": "开关", + "null": "未设置", + "January": "一月", + "February": "二月", + "March": "三月", + "April": "四月", + "May": "五月", + "June": "六月", + "July": "七月", + "August": "八月", + "September": "九月", + "October": "十月", + "November": "十一月", + "December": "十二月", + "Mon": "一", + "Tue": "二", + "Wed": "三", + "Thu": "四", + "Fri": "五", + "Sat": "六", + "Sun": "日", + "Filter options": "筛选配置", + "Reset draft value": "重置草稿值", + "Accessible action": "无障碍操作", + "Applications requested that this action be prevented:": "以下应用请求阻止此操作:", + "Attach to screen edge": "吸附屏幕边缘", + "Bluetooth power": "蓝牙开关", + "Clear notifications": "清除通知", + "Collapse panel hub": "收起面板中心", + "Disable this guide in Settings → Shortcuts. It appears at each login until disabled.": "可以在“设置 → 快捷键”中关闭此指南,关闭前会在每次登录时显示。", + "Edit Workspaces": "编辑工作区", + "Esc to exit": "按 Esc 退出", + "Low battery": "电量不足", + "Mute": "静音", + "New notifications will appear here.": "新通知将显示在这里。", + "Night light could not be applied to some displays": "无法在部分显示器上应用夜光", + "No active player": "没有活动播放器", + "No active screen sharing or OBS output": "没有活动的屏幕共享或 OBS 输出", + "No active tasks": "没有进行中的任务", + "No display supports night light": "没有显示器支持夜光", + "No notifications": "没有通知", + "No open windows": "没有打开的窗口", + "Open Settings": "打开设置", + "Outline": "轮廓", + "Play or pause": "播放或暂停", + "Pomodoro": "番茄钟", + "Power Profiles service unavailable": "电源配置服务不可用", + "Preview on hover": "悬停时预览", + "Recent": "最近", + "Service unavailable": "服务不可用", + "Show activity count": "显示活动数量", + "Signal strength": "信号强度", + "Today": "今天", + "You're all caught up": "已查看全部通知", + "Now playing": "正在播放", + "Authenticate": "验证身份", + "Account": "账户", + "Hide details": "隐藏详情", + "Show details": "显示详情", + "Caps Lock is on": "大写锁定已开启", + "Press Enter to unlock": "按回车解锁", + "Preview only · your session is not locked": "仅预览 · 当前会话未锁定", + "Checking password…": "正在验证密码…", + "Password not accepted. Try again.": "密码验证失败,请重试。", + "Changes save automatically": "更改会自动保存", + "Preview config · changes do not update your desktop": "预览配置 · 更改不会应用到当前桌面", + "Saved · desktop updated": "已保存 · 桌面已更新", + "Saved to preview config · desktop unchanged": "已保存到预览配置 · 当前桌面未更改", + "Saved · could not confirm desktop reload": "已保存 · 无法确认桌面是否已重新加载", + "Not saved": "未保存", + "Reloaded from your config": "已重新加载配置", + "Updated from your config": "已从配置更新", + "Config changed externally · Reload when your edits are finished": "配置已被外部修改 · 完成编辑后请重新加载", + "Application icons": "应用图标", + "Refresh applications": "刷新应用列表", + "Panel status icons": "面板状态图标", + "Install the matching Ferese compositor beside Settings.": "请将匹配版本的 Ferese 主程序与设置程序安装在同一目录。" +} diff --git a/crates/ferese-config/src/activities.rs b/crates/ferese-config/src/activities.rs index 62c3ad8..de588b5 100644 --- a/crates/ferese-config/src/activities.rs +++ b/crates/ferese-config/src/activities.rs @@ -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, diff --git a/crates/ferese-config/src/document.rs b/crates/ferese-config/src/document.rs index e132fa5..dc37481 100644 --- a/crates/ferese-config/src/document.rs +++ b/crates/ferese-config/src/document.rs @@ -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::>(); + 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::>(); + 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::().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::>(); + 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 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 } 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 } } 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(source: &str) -> Result { 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( diff --git a/crates/ferese-config/src/lib.rs b/crates/ferese-config/src/lib.rs index db8ebd1..234cff2 100644 --- a/crates/ferese-config/src/lib.rs +++ b/crates/ferese-config/src/lib.rs @@ -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::*; diff --git a/crates/ferese-config/src/locale.rs b/crates/ferese-config/src/locale.rs new file mode 100644 index 0000000..ad74c17 --- /dev/null +++ b/crates/ferese-config/src/locale.rs @@ -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 = 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 { + static CATALOG: OnceLock> = 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::("\"unsupported\"").is_err()); + } +} diff --git a/crates/ferese-config/src/night_light.rs b/crates/ferese-config/src/night_light.rs index 0aa8345..c05bfee 100644 --- a/crates/ferese-config/src/night_light.rs +++ b/crates/ferese-config/src/night_light.rs @@ -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, diff --git a/crates/ferese-config/src/notifications.rs b/crates/ferese-config/src/notifications.rs index 94f0e2c..3ad9db8 100644 --- a/crates/ferese-config/src/notifications.rs +++ b/crates/ferese-config/src/notifications.rs @@ -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, diff --git a/crates/ferese-config/src/status.rs b/crates/ferese-config/src/status.rs new file mode 100644 index 0000000..927260d --- /dev/null +++ b/crates/ferese-config/src/status.rs @@ -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>, + 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, +} +impl Default for Tray { + fn default() -> Self { + Self { + enabled: true, + show_passive: false, + icon_size: 16., + hidden_items: vec![], + } + } +} diff --git a/crates/ferese-config/src/theme.rs b/crates/ferese-config/src/theme.rs index a078291..8a1b520 100644 --- a/crates/ferese-config/src/theme.rs +++ b/crates/ferese-config/src/theme.rs @@ -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, @@ -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, } -#[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, diff --git a/crates/ferese-panel/Cargo.toml b/crates/ferese-panel/Cargo.toml index a9aa035..f1aa21f 100644 --- a/crates/ferese-panel/Cargo.toml +++ b/crates/ferese-panel/Cargo.toml @@ -9,6 +9,7 @@ rust-version.workspace = true workspace = true [dependencies] +schemars.workspace = true ferese-animation = { path = "../ferese-animation" } ferese-layout = { path = "../ferese-layout" } serde.workspace = true diff --git a/crates/ferese-panel/src/morph.rs b/crates/ferese-panel/src/morph.rs index b4f4621..efa6833 100644 --- a/crates/ferese-panel/src/morph.rs +++ b/crates/ferese-panel/src/morph.rs @@ -8,7 +8,7 @@ use crate::motion::{Geometry, Motion}; const FADE: Duration = Duration::from_millis(120); const CONTENT_DELAY: Duration = Duration::from_millis(130); -#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)] #[serde(default, deny_unknown_fields)] pub struct Profile { pub peek_on_hover: bool, diff --git a/crates/ferese-panel/src/panel/composition.rs b/crates/ferese-panel/src/panel/composition.rs index 237b299..559413f 100644 --- a/crates/ferese-panel/src/panel/composition.rs +++ b/crates/ferese-panel/src/panel/composition.rs @@ -20,11 +20,13 @@ fn padding() -> [u16; 2] { [2, 3] } -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] #[serde(transparent)] pub struct PanelId(pub String); -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive( + Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema, +)] #[serde(rename_all = "lowercase")] pub enum Edge { #[default] @@ -42,15 +44,17 @@ impl Edge { } } -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] #[serde(transparent)] pub struct GroupId(pub String); -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[derive( + Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, schemars::JsonSchema, +)] #[serde(transparent)] pub struct ItemId(pub String); -#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] #[serde(rename_all = "kebab-case")] pub enum Representation { Wide, @@ -58,7 +62,9 @@ pub enum Representation { Icon, } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive( + Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema, +)] #[serde(rename_all = "kebab-case")] pub enum OverflowPolicy { #[default] @@ -67,7 +73,9 @@ pub enum OverflowPolicy { Always, } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive( + Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema, +)] #[serde(rename_all = "kebab-case")] pub enum WorkspaceStyle { Numbers, @@ -110,7 +118,7 @@ impl WorkspaceStyle { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] #[serde(tag = "kind", rename_all = "kebab-case")] pub enum ItemKind { Overview, @@ -122,6 +130,7 @@ pub enum ItemKind { Media, Activities, QuickSettings, + Tray, NightLight, Network, Audio, @@ -158,6 +167,7 @@ impl ItemKind { Self::Media => "Media", Self::Activities => "Activities", Self::QuickSettings => "Quick Settings", + Self::Tray => "Application tray", Self::NightLight => "Night light", Self::Network => "Network", Self::Audio => "Audio", @@ -184,7 +194,7 @@ impl ItemKind { } } -#[derive(Clone, Debug, PartialEq, Serialize)] +#[derive(Clone, Debug, PartialEq, Serialize, schemars::JsonSchema)] pub struct Item { pub id: ItemId, #[serde(flatten)] @@ -241,7 +251,7 @@ impl<'de> Deserialize<'de> for Item { "percentage is only supported by Battery items", )); } - #[derive(Deserialize)] + #[derive(Deserialize, schemars::JsonSchema, serde::Serialize)] struct Fields { id: ItemId, #[serde(flatten)] @@ -325,6 +335,7 @@ impl Item { match self.kind { ItemKind::Activities => availability.activities, ItemKind::Media => availability.media, + ItemKind::Tray => availability.tray, ItemKind::CaptureStatus => availability.capture, ItemKind::Network => availability.network, ItemKind::Audio => availability.audio, @@ -338,6 +349,7 @@ impl Item { #[derive(Clone, Copy, Debug, Default)] pub struct Availability { + pub tray: bool, pub capture: bool, pub media: bool, pub activities: bool, @@ -348,7 +360,9 @@ pub struct Availability { pub external_display: bool, } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive( + Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema, +)] #[serde(rename_all = "kebab-case")] pub enum PanelSurface { None, @@ -356,7 +370,9 @@ pub enum PanelSurface { Solid, } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive( + Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema, +)] #[serde(rename_all = "kebab-case")] pub enum GroupSurface { #[default] @@ -376,7 +392,7 @@ impl GroupSurface { } } -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub struct Group { pub id: GroupId, @@ -402,14 +418,14 @@ impl Group { } } -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)] #[serde(default, deny_unknown_fields)] pub struct Zone { pub groups: Vec, pub spacing: f32, } -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub struct Panel { pub id: PanelId, @@ -700,6 +716,7 @@ impl Panel { let controls = vec![ item("media", Media), item("quick-settings", QuickSettings), + item("tray", Tray), item("night-light", NightLight), item("network", Network), item("audio", Audio), @@ -820,6 +837,7 @@ mod tests { "focused-window", "media", "quick-settings", + "tray", "night-light", "network", "audio", @@ -866,6 +884,7 @@ mod tests { ); assert_eq!( visible(Availability { + tray: true, capture: true, activities: true, media: true, @@ -879,7 +898,7 @@ mod tests { .map(|item| item.id.0.as_str()) .collect::>() ); - assert_eq!(items(&panel).len(), 14); + assert_eq!(items(&panel).len(), 15); } #[test] diff --git a/crates/ferese-panel/src/panel/geometry.rs b/crates/ferese-panel/src/panel/geometry.rs index be0afd2..08d2577 100644 --- a/crates/ferese-panel/src/panel/geometry.rs +++ b/crates/ferese-panel/src/panel/geometry.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Clone, Copy, Debug, Default, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Deserialize, Serialize, schemars::JsonSchema)] #[serde(default, deny_unknown_fields)] pub struct GeometryIntent { #[serde(skip_serializing_if = "Option::is_none")] @@ -51,7 +51,7 @@ impl GeometryIntent { } /// Logical dimensions owned by a panel, independent of its color theme or edge. -#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)] #[serde(default, deny_unknown_fields)] pub struct PanelGeometry { pub height: f32, diff --git a/crates/ferese-panel/src/panel/radius.rs b/crates/ferese-panel/src/panel/radius.rs index 93bcd74..90aca5f 100644 --- a/crates/ferese-panel/src/panel/radius.rs +++ b/crates/ferese-panel/src/panel/radius.rs @@ -4,6 +4,15 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; #[derive(Clone, Copy, Debug, PartialEq)] pub struct CornerRadii(pub [f32; 4]); +impl schemars::JsonSchema for CornerRadii { + fn schema_name() -> std::borrow::Cow<'static, str> { + "CornerRadii".into() + } + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({"anyOf":[{"type":"number","minimum":0},{"type":"array","minItems":4,"maxItems":4,"items":{"type":"number","minimum":0}}]}) + } +} + impl CornerRadii { #[must_use] pub fn uniform(radius: f32) -> Self { diff --git a/crates/ferese-panel/src/shape.rs b/crates/ferese-panel/src/shape.rs index 3fd9dd8..41c2fda 100644 --- a/crates/ferese-panel/src/shape.rs +++ b/crates/ferese-panel/src/shape.rs @@ -2,7 +2,9 @@ use crate::{CornerRadii, Edge}; use ferese_layout::Rect; use serde::{Deserialize, Serialize}; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive( + Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema, +)] #[serde(rename_all = "kebab-case")] pub enum Style { Floating, diff --git a/crates/ferese-rime/src/bin/probe.rs b/crates/ferese-rime/src/bin/probe.rs index 037a47b..1051ac3 100644 --- a/crates/ferese-rime/src/bin/probe.rs +++ b/crates/ferese-rime/src/bin/probe.rs @@ -83,6 +83,22 @@ fn parse_numbers(text: &str) -> Result, Box> { delegate_noop!(State: ignore pointer_manager::ZwlrVirtualPointerManagerV1); delegate_noop!(State: ignore virtual_pointer::ZwlrVirtualPointerV1); +fn named_keysym(name: &str) -> Result { + let canonical = match name { + "Ctrl" | "Control" => "Control_L", + "Shift" => "Shift_L", + "Alt" => "Alt_L", + "Super" | "Meta" => "Super_L", + _ => name, + }; + let symbol = xkb::keysym_from_name(canonical, xkb::KEYSYM_NO_FLAGS).raw(); + if symbol == 0 { + Err(format!("unknown test keysym {name:?}")) + } else { + Ok(symbol) + } +} + fn main() -> Result<(), Box> { if std::env::var("FERESE_TEST_INPUT").as_deref() != Ok("1") { return Err( @@ -122,16 +138,13 @@ fn main() -> Result<(), Box> { sequences.push(vec![character as u32]); } } - "--key" => sequences.push(vec![ - xkb::keysym_from_name(&args.next().ok_or("missing key")?, xkb::KEYSYM_NO_FLAGS) - .raw(), - ]), + "--key" => sequences.push(vec![named_keysym(&args.next().ok_or("missing key")?)?]), "--chord" => sequences.push( args.next() .ok_or("missing chord")? .split('+') - .map(|name| xkb::keysym_from_name(name, xkb::KEYSYM_NO_FLAGS).raw()) - .collect(), + .map(named_keysym) + .collect::, _>>()?, ), _ => return Err(format!("unknown test input argument {arg}").into()), } @@ -191,7 +204,7 @@ fn main() -> Result<(), Box> { xkb::KEYMAP_COMPILE_NO_FLAGS, ) .ok_or("US test keymap missing")?; - let xkb = xkb::State::new(&keymap); + let mut xkb = xkb::State::new(&keymap); let text = keymap.get_as_string(xkb::KEYMAP_FORMAT_TEXT_V1) + "\0"; let mut file = tempfile::tempfile()?; file.write_all(text.as_bytes())?; @@ -211,11 +224,25 @@ fn main() -> Result<(), Box> { .collect::, _>>()?; for key in &codes { keyboard.key(started.elapsed().as_millis() as u32, *key, 1); + xkb.update_key(xkb::Keycode::new(*key + 8), xkb::KeyDirection::Down); + keyboard.modifiers( + xkb.serialize_mods(xkb::STATE_MODS_DEPRESSED), + xkb.serialize_mods(xkb::STATE_MODS_LATCHED), + xkb.serialize_mods(xkb::STATE_MODS_LOCKED), + xkb.serialize_layout(xkb::STATE_LAYOUT_EFFECTIVE), + ); queue.roundtrip(&mut state)?; std::thread::sleep(Duration::from_millis(35)); } for key in codes.iter().rev() { keyboard.key(started.elapsed().as_millis() as u32, *key, 0); + xkb.update_key(xkb::Keycode::new(*key + 8), xkb::KeyDirection::Up); + keyboard.modifiers( + xkb.serialize_mods(xkb::STATE_MODS_DEPRESSED), + xkb.serialize_mods(xkb::STATE_MODS_LATCHED), + xkb.serialize_mods(xkb::STATE_MODS_LOCKED), + xkb.serialize_layout(xkb::STATE_LAYOUT_EFFECTIVE), + ); queue.roundtrip(&mut state)?; std::thread::sleep(Duration::from_millis(35)); } @@ -302,3 +329,23 @@ fn main() -> Result<(), Box> { println!("nested test input complete"); Ok(()) } + +#[cfg(test)] +mod tests { + #[test] + fn modifier_aliases_are_valid_and_unknown_key_names_are_rejected() { + for (alias, name) in [ + ("Ctrl", "Control_L"), + ("Shift", "Shift_L"), + ("Alt", "Alt_L"), + ("Super", "Super_L"), + ] { + assert_eq!( + super::named_keysym(alias).unwrap(), + super::named_keysym(name).unwrap() + ); + assert_ne!(super::named_keysym(alias).unwrap(), 0); + } + assert!(super::named_keysym("not-a-real-key").is_err()); + } +} diff --git a/crates/ferese-theme-model/Cargo.toml b/crates/ferese-theme-model/Cargo.toml index 8eec3ea..80df4d8 100644 --- a/crates/ferese-theme-model/Cargo.toml +++ b/crates/ferese-theme-model/Cargo.toml @@ -9,4 +9,5 @@ rust-version.workspace = true workspace = true [dependencies] +schemars.workspace = true serde.workspace = true diff --git a/crates/ferese-theme-model/src/families.rs b/crates/ferese-theme-model/src/families.rs index 2bec215..98a2e12 100644 --- a/crates/ferese-theme-model/src/families.rs +++ b/crates/ferese-theme-model/src/families.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use crate::{Appearance, Tokens}; -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)] pub struct Palette { pub background: String, pub bar: String, @@ -25,7 +25,7 @@ impl From<&Tokens> for Palette { } } -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)] pub struct Family { pub id: String, pub name: String, diff --git a/crates/ferese-theme-model/src/resolved_theme.rs b/crates/ferese-theme-model/src/resolved_theme.rs index b61d9fc..f577e49 100644 --- a/crates/ferese-theme-model/src/resolved_theme.rs +++ b/crates/ferese-theme-model/src/resolved_theme.rs @@ -2,7 +2,9 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; -#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[derive( + Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq, schemars::JsonSchema, +)] #[serde(rename_all = "lowercase")] pub enum Appearance { Light, @@ -10,7 +12,9 @@ pub enum Appearance { Dark, } -#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[derive( + Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq, schemars::JsonSchema, +)] #[serde(rename_all = "lowercase")] pub enum Mode { Light, @@ -19,14 +23,14 @@ pub enum Mode { Auto, } -#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq, schemars::JsonSchema)] #[serde(default)] pub struct Accessibility { pub increase_contrast: bool, pub reduce_transparency: bool, } -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)] pub struct Colors { pub surface_base: String, pub surface_raised: String, @@ -39,7 +43,7 @@ pub struct Colors { pub shadow: String, } -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)] pub struct Material { pub style: String, pub opacity: f64, @@ -47,7 +51,7 @@ pub struct Material { pub tint_strength: f64, } -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)] pub struct Geometry { pub border_width: f64, pub focus_ring_width: f64, @@ -56,50 +60,52 @@ pub struct Geometry { pub control_gap: f64, } -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)] pub struct Typography { pub font_family: String, } -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)] pub struct Background { pub path: Option, pub lock_path: Option, pub mode: String, } -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)] pub struct BarSurface { pub background: String, pub text_primary: String, pub text_muted: String, } -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)] pub struct Surfaces { pub bar: BarSurface, } -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)] pub struct SoftShadow { pub offset_y: f64, pub blur: f64, pub opacity: f64, } -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)] pub struct Shadows { pub soft: SoftShadow, } -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)] pub struct Gradient { pub from: String, pub to: String, pub angle: f64, } -#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[derive( + Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq, schemars::JsonSchema, +)] #[serde(rename_all = "lowercase")] pub enum PaintStyle { #[default] @@ -113,7 +119,7 @@ impl PaintStyle { } } -#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)] +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, schemars::JsonSchema)] pub struct Paint { #[serde(default, skip_serializing_if = "PaintStyle::is_auto")] pub style: PaintStyle, @@ -131,7 +137,7 @@ impl Paint { } } -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)] pub struct Tokens { pub colors: Colors, pub material: Material, @@ -144,7 +150,7 @@ pub struct Tokens { pub focus_ring: Paint, } -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)] pub struct ResolvedTheme { pub appearance: Appearance, pub tokens: Tokens, diff --git a/crates/ferese-theme/src/calendar.rs b/crates/ferese-theme/src/calendar.rs index 6039c56..d5db338 100644 --- a/crates/ferese-theme/src/calendar.rs +++ b/crates/ferese-theme/src/calendar.rs @@ -7,6 +7,15 @@ use jiff::Zoned; #[must_use] pub fn format_bar_time(now: &Zoned) -> String { + if ferese_config::locale::resolved() == ferese_config::locale::Language::Chinese { + return format!( + "{}月{}日, {:02}:{:02}", + now.month(), + now.day(), + now.hour(), + now.minute() + ); + } let months = [ "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sept", "oct", "nov", "dec", ]; @@ -93,9 +102,15 @@ fn grid_with_density<'a, M: Clone + 'a>( let muted = palette.muted.scale_alpha(opacity); let [previous, today_button, next] = navigation; let header = row![ - crate::text(format!("{} {year}", months[(month - 1) as usize]), font) - .size(if compact { 13 } else { 15 }) - .width(Length::Fill), + crate::text( + format!( + "{} {year}", + ferese_config::locale::text(months[(month - 1) as usize]) + ), + font + ) + .size(if compact { 13 } else { 15 }) + .width(Length::Fill), previous, today_button, next, diff --git a/scripts/rime-preview.py b/scripts/rime-preview.py index f37783a..25adfd0 100644 --- a/scripts/rime-preview.py +++ b/scripts/rime-preview.py @@ -83,7 +83,7 @@ def environment(): "IBUS_ADDRESS": "", "FERESE_SESSION_IMPORT_ENV": "0", "FERESE_ENABLE_INPUT_METHOD": "1", "FERESE_RIME_CONTROL": str(RUNTIME / "rime-control.sock"), "FERESE_ENABLE_TEST_POINTER": "1", - "FERESE_ENABLE_SCREENCOPY": "1", "XDG_DATA_DIRS": f"{Path.home()}/.local/share:/usr/local/share:/usr/share"} + "FERESE_ENABLE_SCREENCOPY": "1", "PATH": f"{ROOT}/target/debug:" + os.environ.get("PATH", "/usr/bin"), "XDG_DATA_DIRS": f"{Path.home()}/.local/share:/usr/local/share:/usr/share"} values.update({f"XDG_{kind.upper()}_HOME": str(DATA / "profile" / kind) for kind in ("config", "data", "cache", "state")}) return values @@ -131,15 +131,16 @@ def start(): RUNTIME.mkdir(mode=0o700, exist_ok=True) if RUNTIME.stat().st_uid != os.getuid() or RUNTIME.stat().st_mode & 0o077: raise RuntimeError("Unexpected private runtime owner or permissions") - (DATA / "profile/config/ferese/config.kdl").write_text(''' -xwayland { enabled #false; } -status { keybinding-guide #false; } -window-rule app-id="dev.ferese.RimeValidation" floating=#true width=880 height=560 -window-rule app-id="io.github.lgse.Strata" floating=#true width=1050 height=720 -window-rule app-id="QQ" floating=#true width=1000 height=720 -window-rule app-id="teams-for-linux" floating=#true width=1000 height=720 -theme { typography { font-family "MiSans"; }; } -''') + if not (DATA / "profile/config/ferese/config.kdl").exists(): + (DATA / "profile/config/ferese/config.kdl").write_text(''' + xwayland { enabled #false; } + status { keybinding-guide #false; } + window-rule app-id="dev.ferese.RimeValidation" floating=#true width=880 height=560 + window-rule app-id="io.github.lgse.Strata" floating=#true width=1050 height=720 + window-rule app-id="QQ" floating=#true width=1000 height=720 + window-rule app-id="teams-for-linux" floating=#true width=1000 height=720 + theme { typography { font-family "MiSans"; }; } + ''') host_runtime = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) host_display = Path(os.environ.get("WAYLAND_DISPLAY", "wayland-0")) if not host_display.is_absolute(): @@ -148,7 +149,7 @@ theme { typography { font-family "MiSans"; }; } raise RuntimeError("Parent Wayland display is missing") env = {**environment(), "WAYLAND_DISPLAY": str(host_display), "RUST_LOG": "info"} start_unit(MAIN, ["/usr/bin/dbus-run-session", "--", str(ROOT / "target/debug/ferese"), "--backend", "nested", - "--supervise-client", "--grant-effects", "--grant-shell-control", "--", "/usr/bin/ferese-shell"], env, "3G") + "--supervise-client", "--grant-effects", "--grant-shell-control", "--", str(ROOT / "target/debug/ferese-shell")], env, "3G") c = wait_for(context, "Development compositor did not publish its private connection") (DATA / "context.json").write_text(json.dumps(c, indent=2) + "\n") start_ime() diff --git a/scripts/tests/fixtures/tray-item.js b/scripts/tests/fixtures/tray-item.js new file mode 100644 index 0000000..3668b75 --- /dev/null +++ b/scripts/tests/fixtures/tray-item.js @@ -0,0 +1,29 @@ +// Private-session StatusNotifierItem fixture. Never sends application messages. +const {Gio,GLib}=imports.gi; +const output=ARGV[0]; +const xml=` + + + + + + + + + + + + + +`; +let activated=0; +const pixels=Array.from({length:16*16*4},(_,i)=>[255,19,231,173][i%4]); +const item={Category:'ApplicationStatus',Id:'ferese-tray-fixture',Title:'Ferese tray fixture',Status:'Active',WindowId:0, +IconName:'',IconPixmap:[[16,16,pixels]],IconThemePath:'',OverlayIconName:'',OverlayIconPixmap:[],AttentionIconName:'',AttentionIconPixmap:[],AttentionMovieName:'',ToolTip:['',[],'Ferese tray fixture','Test icon'],ItemIsMenu:false,Menu:'/NO_DBUSMENU', +Activate(){GLib.file_set_contents(output,JSON.stringify({activated:++activated}));},SecondaryActivate(){},ContextMenu(){},Scroll(){}}; +const loop=new GLib.MainLoop(null,false); +let exported; +Gio.bus_own_name(Gio.BusType.SESSION,'dev.ferese.TrayFixture',Gio.BusNameOwnerFlags.NONE, +connection=>{exported=Gio.DBusExportedObject.wrapJSObject(xml,item);exported.export(connection,'/StatusNotifierItem');}, +(connection,name)=>{connection.call('org.kde.StatusNotifierWatcher','/StatusNotifierWatcher','org.kde.StatusNotifierWatcher','RegisterStatusNotifierItem',new GLib.Variant('(s)',[name]),null,Gio.DBusCallFlags.NONE,5000,null,(c,r)=>{try{c.call_finish(r);GLib.file_set_contents(output,JSON.stringify({activated,registered:true}));}catch(e){printerr(e.message);loop.quit();}});},()=>loop.quit()); +loop.run(); diff --git a/shell/ferese-lock/src/main.rs b/shell/ferese-lock/src/main.rs index 4c93ffa..c452cee 100644 --- a/shell/ferese-lock/src/main.rs +++ b/shell/ferese-lock/src/main.rs @@ -30,6 +30,8 @@ fn main() -> iced::Result { ferese_config::config_path() }; + ferese_config::locale::load(config.as_deref()); + if !args.is_empty() && !preview && args != ["--foreground"] { eprintln!("Usage: ferese-lock [--preview | --foreground]"); std::process::exit(2); @@ -430,7 +432,7 @@ impl Locker { let margin = if size.width < 640. { 24. } else { 36. }; let top = (size.height * 0.09).max(24.); let a = &self.appearance; - let mut input = widget::text_input("Password", self.password.as_str()) + let mut input = widget::text_input(ferese_config::tr!("Password"), self.password.as_str()) .password() .padding([4, 10]) .size(14) @@ -494,9 +496,9 @@ impl Locker { input, self.label( if self.caps_lock { - "Caps Lock is on" + ferese_config::tr!("Caps Lock is on") } else { - self.status + ferese_config::locale::text(self.status) }, 12 ) @@ -542,9 +544,12 @@ impl Locker { container(identity).center_x(Length::Fill), widget::Space::new().height(Length::Fill), row![ - row![ferese_symbol(), self.label("Ferese", 13)] - .spacing(5) - .align_y(iced::Alignment::Center), + row![ + ferese_symbol(), + self.label(ferese_config::tr!("Ferese"), 13) + ] + .spacing(5) + .align_y(iced::Alignment::Center), widget::Space::new().width(Length::Fill), self.label( if self.preview { diff --git a/shell/ferese-polkit-agent/src/main.rs b/shell/ferese-polkit-agent/src/main.rs index b64c0ac..4ad46aa 100644 --- a/shell/ferese-polkit-agent/src/main.rs +++ b/shell/ferese-polkit-agent/src/main.rs @@ -324,6 +324,7 @@ async fn run_agent() -> Result<(), Box> { } fn main() -> Result<(), Box> { + ferese_config::locale::load(ferese_config::config_path().as_deref()); // SAFETY: PR_SET_DUMPABLE takes an integer value and does not dereference caller memory. unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0); diff --git a/shell/ferese-polkit-agent/src/prompt.rs b/shell/ferese-polkit-agent/src/prompt.rs index 3353384..b6a0bc2 100644 --- a/shell/ferese-polkit-agent/src/prompt.rs +++ b/shell/ferese-polkit-agent/src/prompt.rs @@ -475,13 +475,15 @@ impl cosmic::Application for Prompt { Message::Submit }); } - let action = button::custom(ferese_theme::text("Authenticate", palette.font).size(13)) - .class(ferese_theme::controls::authentication_button( - palette.palette(), - )) - .height(Length::Fixed(36.)) - .padding([8, 16]) - .on_press_maybe((!self.waiting && !self.answer.is_empty()).then_some(Message::Submit)); + let action = button::custom( + ferese_theme::text(ferese_config::tr!("Authenticate"), palette.font).size(13), + ) + .class(ferese_theme::controls::authentication_button( + palette.palette(), + )) + .height(Length::Fixed(36.)) + .padding([8, 16]) + .on_press_maybe((!self.waiting && !self.answer.is_empty()).then_some(Message::Submit)); let lock_icon = if let Some(icon) = &self.action_icon { widget::icon(icon.clone()).size(20) } else { @@ -506,7 +508,8 @@ impl cosmic::Application for Prompt { 12., )), column![ - ferese_theme::text("Authentication required", palette.font).size(18), + ferese_theme::text(ferese_config::tr!("Authentication required"), palette.font) + .size(18), ferese_theme::text(format!("Confirm as {user}"), palette.font) .size(12) .class(theme::Text::Color(palette.muted)), @@ -524,9 +527,9 @@ impl cosmic::Application for Prompt { button::custom( ferese_theme::text( if self.details_expanded { - "Hide details" + ferese_config::tr!("Hide details") } else { - "Show details" + ferese_config::tr!("Show details") }, palette.font, ) @@ -563,7 +566,7 @@ impl cosmic::Application for Prompt { body = body.push( container( row![ - ferese_theme::text("Account", palette.font) + ferese_theme::text(ferese_config::tr!("Account"), palette.font) .size(12) .class(theme::Text::Color(palette.muted)), widget::dropdown( @@ -610,14 +613,16 @@ impl cosmic::Application for Prompt { } body = body.push( row![ - button::custom(ferese_theme::text("Cancel", palette.font).size(13)) - .class(ferese_theme::controls::material_button_style( - palette.palette(), - false, - material_opacity, - )) - .padding([8, 14]) - .on_press(Message::Cancel), + button::custom( + ferese_theme::text(ferese_config::tr!("Cancel"), palette.font).size(13) + ) + .class(ferese_theme::controls::material_button_style( + palette.palette(), + false, + material_opacity, + )) + .padding([8, 14]) + .on_press(Message::Cancel), action ] .spacing(8) diff --git a/shell/ferese-settings/src/all_options.rs b/shell/ferese-settings/src/all_options.rs new file mode 100644 index 0000000..b95d23d --- /dev/null +++ b/shell/ferese-settings/src/all_options.rs @@ -0,0 +1,1075 @@ +//! 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) -> Element<'_, Message> { + let mut out = column([]).spacing(18); + for (name, pages) in Page::NAVIGATION { + if name == "Appearance" { + continue; + } + out = out.push(self.panel_heading(tr(name), 15.)); + for &p in pages { + out = out.push(self.hub_card(p)); + } + } + column([]) + .spacing(12) + .push(self.hub_card(Page::General)) + .push(out) + .into() + } + fn hub_card(&self, p: Page) -> Element<'static, Message> { + let palette = visuals::Palette::from_resolved(&self.resolved.presented); + button::custom( + row([]) + .spacing(12) + .align_y(Alignment::Center) + .push(visuals::icon(p, palette.accent)) + .push( + column([]) + .spacing(3) + .push(self.label(p.title(), 15.)) + .push(self.label(p.subtitle(), 12.)), + ) + .push(self.label(ferese_config::tr!("›"), 20.)), + ) + .name(p.title()) + .padding(14) + .width(Length::Fill) + .class(visuals::button_style(palette, false)) + .on_press(Message::Page(p)) + .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(8).push(self.note(t( + "Status icons can also be hidden individually in Panel & Shell.", + ))); + 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); + } +} diff --git a/shell/ferese-settings/src/connections/bluetooth_ui.rs b/shell/ferese-settings/src/connections/bluetooth_ui.rs index 7b67e99..e07c8ca 100644 --- a/shell/ferese-settings/src/connections/bluetooth_ui.rs +++ b/shell/ferese-settings/src/connections/bluetooth_ui.rs @@ -64,7 +64,7 @@ impl App { ) .push( ferese_theme::controls::switch(adapter.powered, p) - .name("Bluetooth power") + .name(ferese_config::tr!("Bluetooth power")) .on_press_maybe(ready.then(|| { message(Input::Run(Command::BluetoothPower( adapter.path.clone(), diff --git a/shell/ferese-settings/src/connections/wifi_ui.rs b/shell/ferese-settings/src/connections/wifi_ui.rs index af95a9b..f0b6f85 100644 --- a/shell/ferese-settings/src/connections/wifi_ui.rs +++ b/shell/ferese-settings/src/connections/wifi_ui.rs @@ -49,7 +49,7 @@ impl App { ) .push( ferese_theme::controls::switch(wifi.enabled, p) - .name("Wi-Fi power") + .name(ferese_config::tr!("Wi-Fi power")) .on_press_maybe( (ready && (wifi.hardware_enabled || wifi.enabled) diff --git a/shell/ferese-settings/src/displays_ui.rs b/shell/ferese-settings/src/displays_ui.rs index 13d3c78..a7e23fe 100644 --- a/shell/ferese-settings/src/displays_ui.rs +++ b/shell/ferese-settings/src/displays_ui.rs @@ -36,14 +36,14 @@ impl App { .width(Length::Fill), ) .push(self.settings_icon_button( - "Refresh displays", + ferese_config::tr!("Refresh displays"), "M20 7V3l-3 3a8 8 0 1 0 3 6 M20 3h-5", Some(Message::RefreshDisplays), )), ); if let Some(error) = &self.display_error { panel = panel.push(cosmic::widget::tooltip( - self.note("Live display information unavailable"), + self.note(ferese_config::tr!("Live display information unavailable")), self.label(error.clone(), 11.), cosmic::widget::tooltip::Position::Bottom, )); @@ -159,13 +159,16 @@ impl App { ))); panel = panel.push(row([]).spacing(5).align_y(Alignment::Center) .push(cosmic::widget::tooltip(visuals::action_icon("M12 3a9 9 0 1 0 0 18a9 9 0 0 0 0-18 M12 11v6 M12 7h.01", palette.muted), - self.label("Auto uses 60 Hz below 30% battery and returns to normal on AC or at 35%. Applying a display mode can briefly blank the screen.", 11.), cosmic::widget::tooltip::Position::Top)) + self.label(ferese_config::tr!("Auto uses 60 Hz below 30% battery and returns to normal on AC or at 35%. Applying a display mode can briefly blank the screen."), 11.), cosmic::widget::tooltip::Position::Top)) .push(self.note(if target.active { "Changes apply live" } else { "Saved profile · applies when active" }))); } else { - panel = panel.push(self.note("Managed automatically · no saved display profile")); + panel = panel.push(self.note(ferese_config::tr!( + "Managed automatically · no saved display profile" + ))); } } else { - panel = panel.push(self.note("No displays or saved profiles found")); + panel = + panel.push(self.note(ferese_config::tr!("No displays or saved profiles found"))); } container(panel) .padding([7, 10]) diff --git a/shell/ferese-settings/src/form_controls.rs b/shell/ferese-settings/src/form_controls.rs index 770fb31..68c91a6 100644 --- a/shell/ferese-settings/src/form_controls.rs +++ b/shell/ferese-settings/src/form_controls.rs @@ -123,10 +123,10 @@ impl App { let palette = visuals::Palette::from_resolved(&self.resolved.presented); let mut labels = column([]) .spacing(2) - .push(self.label(field.label.clone(), 13.)); + .push(self.label(ferese_config::locale::translate(&field.label), 13.)); if !field.description.is_empty() { labels = labels.push( - self.label(field.description.clone(), 11.) + self.label(ferese_config::locale::translate(&field.description), 11.) .class(cosmic::theme::Text::Color(palette.muted)), ); } @@ -169,7 +169,7 @@ impl App { format!("{value:.2}{suffix}") }; if margin_limit == Some(None) { - self.label("—", 12.).into() + self.label(ferese_config::tr!("—"), 12.).into() } else if max <= min { self.label(display, 12.).into() } else { @@ -195,7 +195,7 @@ impl App { let mut options = row([]).spacing(2); for (key, label) in choices { options = options.push( - button::custom(self.label(*label, 12.)) + button::custom(self.label(ferese_config::locale::text(label), 12.)) .padding([4, 8]) .class(self.control_button_style(palette, value == *key)) .on_press(Message::Change(set(&path, *key))), diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index 48bcddf..ed4b9cb 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -1,3 +1,4 @@ +mod all_options; mod connections; mod form_controls; mod navigation; @@ -8,6 +9,7 @@ mod panel_edit; mod panel_inspector; mod panel_preview; mod theme_controls; +mod tray_controls; use theme_controls::gallery_id; @@ -62,6 +64,11 @@ fn main() -> cosmic::iced::Result { } Some("wallpaper") => Some(InitialPage::Wallpaper), Some("bar") => Some(InitialPage::Bar), + Some("appearance") => Some(InitialPage::Settings(Page::Appearance)), + Some("general") => Some(InitialPage::Settings(Page::General)), + Some("theme") => Some(InitialPage::Settings(Page::Theme)), + Some("tray") => Some(InitialPage::Settings(Page::Tray)), + Some("all-options") => Some(InitialPage::Settings(Page::Configuration)), _ => { eprintln!("--page accepts connections, wifi, bluetooth, wallpaper, or bar"); return Ok(()); @@ -79,6 +86,9 @@ fn main() -> cosmic::iced::Result { let path = path.canonicalize().unwrap_or(path); fonts::families(); let initial = Snapshot::read(&path); + if let Ok(snapshot) = &initial { + ferese_config::locale::set(ferese_config::locale::from_document(&snapshot.doc)); + } let theme = visuals::native_theme(initial.as_ref().ok()); cosmic::app::run::( @@ -94,12 +104,16 @@ fn main() -> cosmic::iced::Result { #[derive(Clone, Copy)] enum InitialPage { Connections(connections::Tab), + Settings(Page), Wallpaper, Bar, } #[derive(Clone, Debug)] enum Message { + Options(all_options::Input), + TrayApps(Vec), + RefreshTrayApps, ReadRenderScale(cosmic::iced::window::Id), RenderScale(f32), Connections(connections::Input), @@ -165,6 +179,8 @@ enum Message { } struct App { + tray_apps: Vec, + options: all_options::State, connections: connections::State, displays: Vec, display_selection: Option, @@ -265,7 +281,11 @@ impl cosmic::Application for App { let font = fonts::interface_font(&resolved.presented.tokens.typography.font_family); let native_palette = visuals::Palette::from_resolved(&resolved.presented); let family_sections = theme_controls::FamilySections::new(&resolved.families, ¤t); + ferese_config::locale::set(ferese_config::locale::from_document(¤t.doc)); + let options = all_options::State::load(¤t); let mut app = Self { + tray_apps: Vec::new(), + options, connections: connections::State { tab: match initial_page { Some(InitialPage::Connections(tab)) => tab, @@ -291,6 +311,7 @@ impl cosmic::Application for App { Some(InitialPage::Connections(_)) => Page::Connections, Some(InitialPage::Wallpaper) => Page::Wallpaper, Some(InitialPage::Bar) => Page::Bar, + Some(InitialPage::Settings(page)) => page, None => Page::Appearance, }, profile_pages: std::env::var_os("FERESE_PROFILE_SETTINGS").is_some(), @@ -328,9 +349,15 @@ impl cosmic::Application for App { let task = app .core .main_window_id() - .map(|id| app.set_window_title("Ferese Settings".into(), id)) + .map(|id| app.set_window_title(ferese_config::tr!("Ferese Settings").into(), id)) .unwrap_or_else(Task::none); + let tray_apps = if app.page == Page::Tray { + app.load_tray_apps() + } else { + Task::none() + }; + let task = Task::batch([task, tray_apps]); let connections = if app.page == Page::Connections { app.refresh_connections() } else { @@ -347,6 +374,9 @@ impl cosmic::Application for App { fn update(&mut self, message: Message) -> Task { match message { + Message::TrayApps(apps) => self.tray_apps = apps, + Message::RefreshTrayApps => return self.load_tray_apps(), + Message::Options(input) => return self.options_input(input), Message::DragWindow => return self.core.drag(None), Message::ReadRenderScale(id) => { return cosmic::iced::window::scale_factor(id) @@ -427,11 +457,16 @@ impl cosmic::Application for App { || !self.inputs.is_empty() || !self.ranges.is_empty() || !self.pending.is_empty() + || self.options.dirty() { self.status = "Config changed externally · Reload when your edits are finished" .into(); } else { + ferese_config::locale::set(ferese_config::locale::from_document( + &snapshot.doc, + )); + self.options.reset(&snapshot); self.current = snapshot.clone(); self.draft = snapshot; self.family_sections = theme_controls::FamilySections::new( @@ -493,6 +528,12 @@ impl cosmic::Application for App { self.page_transition = self.profile_pages.then(std::time::Instant::now); let leaving = self.page == Page::Connections && page != Page::Connections; + if page == Page::Configuration && !self.options.dirty() { + self.options.reset(&self.current); + } + if page == Page::Configuration { + self.options.filter = self.search.clone(); + } self.page = page; self.search.clear(); self.visible_rows = initial_visible_rows(); @@ -507,6 +548,7 @@ impl cosmic::Application for App { let load = match page { Page::Displays | Page::Bar => self.load_displays(), Page::Wallpaper => self.load_thumbnail(), + Page::Tray => self.load_tray_apps(), _ => Task::none(), }; return Task::batch([reset, cleanup, load]); @@ -517,6 +559,9 @@ impl cosmic::Application for App { if matches!(page, Page::Displays | Page::Bar) { return Task::batch([reset, self.load_displays()]); } + if page == Page::Tray { + return Task::batch([reset, self.load_tray_apps()]); + } if page == Page::Wallpaper { return Task::batch([reset, self.load_thumbnail()]); } @@ -725,6 +770,12 @@ impl cosmic::Application for App { Ok((snapshot, live)) => { self.undo = Some(std::mem::take(&mut self.saving_previous)); self.current = snapshot; + ferese_config::locale::set(ferese_config::locale::from_document( + &self.current.doc, + )); + if !self.options.dirty() || self.options.applying { + self.options.reset(&self.current); + } self.font = fonts::interface_font( &self.resolved.presented.tokens.typography.font_family, ); @@ -755,7 +806,18 @@ impl cosmic::Application for App { tokio::time::sleep(std::time::Duration::from_secs(5)).await; Message::ExpireUndo(revision) }); + let title = self + .core + .main_window_id() + .map(|id| { + self.set_window_title( + ferese_config::tr!("Ferese Settings").into(), + id, + ) + }) + .unwrap_or_else(Task::none); return Task::batch([ + title, self.update_theme(), self.flush(), self.load_thumbnail(), @@ -766,12 +828,15 @@ impl cosmic::Application for App { self.error = Some(error); self.pending.clear(); self.draft = self.current.clone(); + self.options.applying = false; self.status = "Not saved".into(); } } } Message::Reload if !self.saving => match Snapshot::read(&self.path) { Ok(snapshot) => { + ferese_config::locale::set(ferese_config::locale::from_document(&snapshot.doc)); + self.options.reset(&snapshot); self.current = snapshot.clone(); self.draft = snapshot; self.family_sections = @@ -1754,7 +1819,7 @@ mod tests { .expect("requested renderer"); eprintln!("renderer={}", Headless::name(&renderer)); - for page in [Page::Shortcuts, Page::Appearance] { + for page in [Page::Shortcuts, Page::Theme] { let mut app = shortcuts(500); app.page = page; let theme = app.native_palette.native_theme(); diff --git a/shell/ferese-settings/src/pages.rs b/shell/ferese-settings/src/pages.rs index dfc2e00..bc04a0c 100644 --- a/shell/ferese-settings/src/pages.rs +++ b/shell/ferese-settings/src/pages.rs @@ -26,7 +26,7 @@ impl App { .align_y(Alignment::Center) .push(visuals::brand_icon()) .push( - self.label("Ferese", 16.) + self.label(ferese_config::tr!("Ferese"), 16.) .class(cosmic::theme::Text::Color(palette.text)), ) .width(Length::Fill), @@ -36,7 +36,7 @@ impl App { ) .push(widget::Space::new().height(14)) .push( - text_input("Search settings", self.search.clone()) + text_input(ferese_config::tr!("Search settings"), self.search.clone()) .id(widget::Id::new("settings-search")) .on_input(Message::Search) .font(self.font) @@ -46,7 +46,7 @@ impl App { .push(widget::Space::new().height(8)); if compact { sidebar = sidebar.push(self.settings_button( - "Close navigation", + ferese_config::tr!("Close navigation"), "M6 6l12 12 M6 18L18 6", Some(Message::Sidebar(false)), false, @@ -56,7 +56,7 @@ impl App { for (title, pages) in Page::NAVIGATION { let mut section = column([]).spacing(3).push( container( - self.label(title, 11.) + self.label(ferese_config::locale::text(title), 11.) .class(cosmic::theme::Text::Color(palette.muted)), ) .padding([0, 8]), @@ -118,13 +118,13 @@ impl App { ) .width(Length::Fill), ); - if self.page == Page::Appearance && self.search.is_empty() { + if self.page == Page::Theme && self.search.is_empty() { let automatic = self.draft.string("theme.mode", "dark") == "auto"; heading = heading.push( row([]) .spacing(8) .align_y(Alignment::Center) - .push(self.label("Auto", 12.)) + .push(self.label(ferese_config::tr!("Auto"), 12.)) .push( ferese_theme::controls::switch(automatic, palette) .name(if automatic { @@ -179,11 +179,18 @@ impl App { .into_iter() .flat_map(|(_, pages)| pages.iter().copied()) }; - if !pages().any(|p| p.matches(&self.search)) { - body = body - .push(self.note("No matching settings. Try wallpaper, keyboard, or motion.")); + if !pages().any(|p| { + p.matches(&self.search) + || (p == Page::Configuration && self.options.matches(&self.search)) + }) { + body = body.push(self.note(ferese_config::tr!( + "No matching settings. Try wallpaper, keyboard, or motion." + ))); } - for page in pages().filter(|p| p.matches(&self.search)) { + for page in pages().filter(|p| { + p.matches(&self.search) + || (*p == Page::Configuration && self.options.matches(&self.search)) + }) { let destination = page.search_destination(&self.search); body = body.push( button::custom( @@ -199,13 +206,24 @@ impl App { ); } } else { + if self.page == Page::Appearance { + body = body.push(self.settings_hub()); + } + if self.page == Page::Configuration { + body = body.push(self.all_options_view()); + } + if self.page == Page::Tray { + body = body + .push(self.tray_application_controls()) + .push(self.tray_status_controls()); + } if self.page == Page::Windows { body = body.push(visuals::preview_resolved( &self.draft, &self.resolved.presented, )); } - if self.page == Page::Appearance { + if self.page == Page::Theme { let split = visuals::split(&self.draft); if split { body = body @@ -245,11 +263,11 @@ impl App { if !fields.is_empty() { let mut group = column([]).spacing(1); - if self.page == Page::Appearance && self.auto_details { + if self.page == Page::Theme && self.auto_details { group = group.push(self.auto_controls()); } for field in fields { - if self.page == Page::Appearance + if self.page == Page::Theme && (field.path == "theme.mode" || field.path.starts_with("theme.schedule.") || field.path.ends_with(".file")) @@ -258,13 +276,13 @@ impl App { } group = group.push(self.field(field)); } - if self.page == Page::Appearance { + if self.page == Page::Theme { group = group.push( button::custom( row([]) .spacing(8) .align_y(Alignment::Center) - .push(self.label("Advanced customization", 13.)) + .push(self.label(ferese_config::tr!("Advanced customization"), 13.)) .push(visuals::action_icon( if self.advanced_theme { "M6 15l6-6 6 6" @@ -298,17 +316,17 @@ impl App { match self.page { Page::Connections => body = body.push(self.connections_view()), Page::LockScreen => { - body = body.push(self.note("Uses your wallpaper, shell colors, font and corner radius from Appearance. Changes apply when the locker or preview opens.")); + body = body.push(self.note(ferese_config::tr!("Uses your wallpaper, shell colors, font and corner radius from Appearance. Changes apply when the locker or preview opens."))); body = body.push(self.settings_button( - "Preview lock screen", + ferese_config::tr!("Preview lock screen"), "M7 10V7a5 5 0 0 1 10 0v3 M5 10h14v11H5z M12 14v3", Some(Message::PreviewLock), false, )); - body = body.push(self.note("The preview is an ordinary window and does not lock your session. Automatic locking is configured separately in Login items.")); + body = body.push(self.note(ferese_config::tr!("The preview is an ordinary window and does not lock your session. Automatic locking is configured separately in Login items."))); } Page::Startup => { - body = body.push(self.note("Login items update live. Disabling or removing an item stops the session-owned process; enabling one starts it.")); + body = body.push(self.note(ferese_config::tr!("Login items update live. Disabling or removing an item stops the session-owned process; enabling one starts it."))); for index in 0..self.draft.records("autostart") { let prefix = format!("autostart.{index}"); @@ -338,7 +356,7 @@ impl App { .push( container( self.settings_icon_button( - "Remove login item", + ferese_config::tr!("Remove login item"), "M4 6h16 M9 6V3h6v3 M6 6l1 15h10l1-15 M10 10v7 M14 10v7", (!self.saving) .then_some(Message::Remove("autostart".into(), index)), @@ -356,16 +374,19 @@ impl App { row([]) .spacing(8) .push( - text_input("Program and arguments", self.new_command.clone()) - .font(self.font) - .size(12) - .padding([4, 8]) - .style(visuals::input_style(palette)) - .on_input(Message::NewCommand), + text_input( + ferese_config::tr!("Program and arguments"), + self.new_command.clone(), + ) + .font(self.font) + .size(12) + .padding([4, 8]) + .style(visuals::input_style(palette)) + .on_input(Message::NewCommand), ) .push( self.settings_button( - "Add login item", + ferese_config::tr!("Add login item"), "M12 5v14 M5 12h14", (!self.saving && !self.new_command.trim().is_empty()) .then_some(Message::AddCommand), @@ -375,7 +396,7 @@ impl App { ); } Page::Shortcuts => { - body = body.push(self.note("Keyboard, mouse and swipe bindings share the same actions. Mouse shortcuts can use MouseLeft, MouseRight, MouseMiddle, MouseBack, MouseForward or WheelUp/Down/Left/Right with modifiers. Use pointer-move or pointer-resize for dragging. Swipes use Swipe3Up, Swipe3Down, Swipe3Left or Swipe3Right (also supports 4 or 5 fingers). Actions such as toggle-overview and none need no argument.")); + body = body.push(self.note(ferese_config::tr!("Keyboard, mouse and swipe bindings share the same actions. Mouse shortcuts can use MouseLeft, MouseRight, MouseMiddle, MouseBack, MouseForward or WheelUp/Down/Left/Right with modifiers. Use pointer-move or pointer-resize for dragging. Swipes use Swipe3Up, Swipe3Down, Swipe3Left or Swipe3Right (also supports 4 or 5 fingers). Actions such as toggle-overview and none need no argument."))); let mut gestures = row([]).spacing(6); for (keys, label) in [ ("Swipe3Up", "Customize swipe up"), @@ -447,7 +468,7 @@ impl App { ); } if self.draft.records("bindings") == 0 { - body = body.push(self.note("You are using the built-in shortcuts. Add custom bindings in config.kdl; they will appear here after Reload.")); + body = body.push(self.note(ferese_config::tr!("You are using the built-in shortcuts. Add custom bindings in config.kdl; they will appear here after Reload."))); } } Page::Displays => body = body.push(self.displays_view()), @@ -459,7 +480,7 @@ impl App { if compact { page_heading = page_heading.push( container(self.settings_icon_button( - "Show settings navigation", + ferese_config::tr!("Show settings navigation"), "M3 5h18v14H3z M9 5v14", Some(Message::Sidebar(true)), )) @@ -478,7 +499,7 @@ impl App { content = content.push( container( row(self.page.subpages().iter().map(|&(page, title)| { - button::custom(self.label(title, 13.)) + button::custom(self.label(ferese_config::locale::text(title), 13.)) .name(format!("{}: {title}", self.page.navigation_page().title())) .padding([7, 12]) .class(visuals::panel_button(palette, self.page == page)) @@ -570,7 +591,7 @@ impl App { )) .padding(4) .on_press_maybe((self.undo.is_some() && !self.saving).then_some(Message::Undo)), - self.label("Undo last change", 11.), + self.label(ferese_config::tr!("Undo last change"), 11.), widget::tooltip::Position::Top, )) .push(widget::tooltip( @@ -580,11 +601,11 @@ impl App { )) .padding(4) .on_press_maybe((!self.saving).then_some(Message::Reload)), - self.label("Reload configuration", 11.), + self.label(ferese_config::tr!("Reload configuration"), 11.), widget::tooltip::Position::Top, )); let footer: Element<'_, Message> = if self.page == Page::Connections { - self.note("Connections are managed by the system. Passwords are not saved in your Ferese config.") + self.note(ferese_config::tr!("Connections are managed by the system. Passwords are not saved in your Ferese config.")) } else if self.page == Page::Bar { container( row([]) @@ -597,13 +618,13 @@ impl App { })) .push(widget::Space::new().width(Length::Fill)) .push(self.settings_button( - "Undo", + ferese_config::tr!("Undo"), "M3 10h8 M3 10V3 M3 10c3-7 17-6 17 3a7 7 0 0 1-7 7", (self.undo.is_some() && !self.saving).then_some(Message::Undo), false, )) .push(self.settings_button( - "Reload configuration", + ferese_config::tr!("Reload configuration"), "M21 3v6h-6 M3 21v-6h6 M3 9a9 9 0 0 1 15-6l3 6 M21 15a9 9 0 0 1-15 6l-3-6", (!self.saving).then_some(Message::Reload), false, diff --git a/shell/ferese-settings/src/panel_controls.rs b/shell/ferese-settings/src/panel_controls.rs index 2307fbd..29ea495 100644 --- a/shell/ferese-settings/src/panel_controls.rs +++ b/shell/ferese-settings/src/panel_controls.rs @@ -35,7 +35,7 @@ impl std::fmt::Display for StyleChoice { } } -const CATALOG: [CatalogItem; 15] = [ +const CATALOG: [CatalogItem; 16] = [ CatalogItem(ItemKind::Overview), CatalogItem(ItemKind::Workspaces { style: ferese_panel::WorkspaceStyle::Dots, @@ -44,6 +44,7 @@ const CATALOG: [CatalogItem; 15] = [ CatalogItem(ItemKind::Media), CatalogItem(ItemKind::Activities), CatalogItem(ItemKind::QuickSettings), + CatalogItem(ItemKind::Tray), CatalogItem(ItemKind::NightLight), CatalogItem(ItemKind::Network), CatalogItem(ItemKind::Audio), @@ -58,7 +59,7 @@ const CATALOG: [CatalogItem; 15] = [ impl App { pub(super) fn panel_navigation(&self) -> Element<'static, Message> { self.settings_button( - "Back to Panels", + ferese_config::tr!("Back to Panels"), "M15 18l-6-6 6-6", Some(Message::PanelPage(PanelPage::Panels)), false, @@ -71,52 +72,53 @@ impl App { let mut rows = widget::column([]).spacing(12); match self.panel_page { PanelPage::Panels => { - rows = - rows.push( - widget::container( - widget::row([]) - .spacing(12) - .align_y(cosmic::iced::Alignment::Center) - .push(crate::visuals::action_icon( - if edge == Edge::Top { - "M3 5h18v14H3z M3 9h18" - } else { - "M3 5h18v14H3z M3 15h18" - }, - palette.accent, - )) - .push( - widget::column([]) - .spacing(3) - .push(self.panel_heading( - if edge == Edge::Top { - "Top panel" - } else { - "Bottom panel" - }, - 14., - )) - .push(self.note("Shown on all displays")) - .width(cosmic::iced::Length::Fill), - ) - .push(self.settings_button( - "Edit panel", - "M4 20l4-1 12-12-3-3L5 16z", - Some(Message::PanelPage(PanelPage::Arrange)), - false, - )) - .push(self.settings_icon_button( - "Remove custom panel", + rows = rows.push( + widget::container( + widget::row([]) + .spacing(12) + .align_y(cosmic::iced::Alignment::Center) + .push(crate::visuals::action_icon( + if edge == Edge::Top { + "M3 5h18v14H3z M3 9h18" + } else { + "M3 5h18v14H3z M3 15h18" + }, + palette.accent, + )) + .push( + widget::column([]) + .spacing(3) + .push(self.panel_heading( + if edge == Edge::Top { + "Top panel" + } else { + "Bottom panel" + }, + 14., + )) + .push(self.note(ferese_config::tr!("Shown on all displays"))) + .width(cosmic::iced::Length::Fill), + ) + .push(self.settings_button( + ferese_config::tr!("Edit panel"), + "M4 20l4-1 12-12-3-3L5 16z", + Some(Message::PanelPage(PanelPage::Arrange)), + false, + )) + .push( + self.settings_icon_button( + ferese_config::tr!("Remove custom panel"), "M4 7h16 M9 7V4h6v3 M6 7l1 13h10l1-13 M10 11v5 M14 11v5", self.draft.item("panels").is_some().then(|| { Message::Change(store::Edit::Unset("panels".into())) }), - )), - ) - .padding(14) - .width(cosmic::iced::Length::Fill) - .class(crate::visuals::surface(palette.card, 14.)), - ); + ), + ), + ) + .padding(14) + .width(cosmic::iced::Length::Fill) + .class(crate::visuals::surface(palette.card, 14.)), + ); let preset = self.preview_panel().ok().and_then(|panel| panel.preset()); let position = widget::row([Edge::Top, Edge::Bottom].into_iter().map(|position| { let label = if position == Edge::Top { @@ -152,7 +154,7 @@ impl App { ); let mut appearance = widget::column([]) .spacing(16) - .push(self.panel_heading("Appearance", 15.)) + .push(self.panel_heading(ferese_config::tr!("Appearance"), 15.)) .push(self.panel_style_choices(preset.unwrap_or_default())) .push(self.panel_divider()) .push(self.panel_opacity_controls()) @@ -166,7 +168,7 @@ impl App { self.draft.boolean("panels.0.border", false), palette, ) - .name("Outline") + .name(ferese_config::tr!("Outline")) .on_press(Message::PanelEdit(Action::SetBorder( !self.draft.boolean("panels.0.border", false), ))), @@ -192,7 +194,7 @@ impl App { "Join the island to the screen edge with curved shoulders.", widget::container( ferese_theme::controls::switch(attached, palette) - .name("Attach to screen edge") + .name(ferese_config::tr!("Attach to screen edge")) .on_press(Message::PanelEdit(Action::SetMorphAttached( !attached, ))), @@ -208,7 +210,7 @@ impl App { "Expand the island when you hover over it.", widget::container( ferese_theme::controls::switch(enabled, palette) - .name("Preview on hover") + .name(ferese_config::tr!("Preview on hover")) .on_press(Message::PanelEdit(Action::SetPeekOnHover( !enabled, ))), @@ -248,7 +250,7 @@ impl App { "Show +n for additional activities.", widget::container( ferese_theme::controls::switch(show_badge, palette) - .name("Show activity count") + .name(ferese_config::tr!("Show activity count")) .on_press(Message::Change(store::set( "activities.show_badge", !show_badge, @@ -272,10 +274,10 @@ impl App { let mut settings = widget::column([]).spacing(4).push( widget::container( widget::row![ - self.panel_heading("Size & spacing", 15.) + self.panel_heading(ferese_config::tr!("Size & spacing"), 15.) .width(cosmic::iced::Length::Fill), self.settings_button( - "Reset to style", + ferese_config::tr!("Reset to style"), "M3 12a9 9 0 1 0 3-6 M3 3v6h6", pins.then_some(Message::PanelEdit(Action::ResetShape)), false @@ -355,7 +357,7 @@ impl App { ); let mut shell = widget::column([]) .spacing(4) - .push(self.panel_heading("Shell appearance", 15.)); + .push(self.panel_heading(ferese_config::tr!("Shell appearance"), 15.)); for field in crate::schema::fields(Page::Bar) .into_iter() .filter(|field| field.path.starts_with("theme.")) @@ -427,7 +429,7 @@ impl App { "Background opacity", "Reduce transparency is on. Turn it off in Accessibility to change opacity.", self.settings_button( - "Accessibility", + ferese_config::tr!("Accessibility"), "M9 18l6-6-6-6", Some(Message::Page(Page::Accessibility)), false, @@ -482,7 +484,7 @@ impl App { .width(48), ) .push(self.settings_icon_button( - "Use shell opacity", + ferese_config::tr!("Use shell opacity"), "M3 12a9 9 0 1 0 3-6 M3 3v6h6", custom.then_some(Message::PanelEdit(Action::ClearOpacity)), )); @@ -632,8 +634,10 @@ impl App { cosmic::iced::widget::responsive(move |size| { let editor = widget::column([]) .spacing(12) - .push(self.panel_heading("Arrangement", 15.)) - .push(self.note("Drag items to reorder. Select an item or group to edit.")) + .push(self.panel_heading(ferese_config::tr!("Arrangement"), 15.)) + .push(self.note(ferese_config::tr!( + "Drag items to reorder. Select an item or group to edit." + ))) .push( widget::scrollable(self.panel_editor(&panel)) .id(widget::Id::new("settings-content")) @@ -710,9 +714,9 @@ impl App { "M4 4h16v16H4z M14 4v16 M7 8h4 M7 12h4", palette.muted, )) - .push(self.panel_heading("Make it your own", 15.)) + .push(self.panel_heading(ferese_config::tr!("Make it your own"), 15.)) .push(self.note( - "Select an item to adjust how it appears, or a group to edit its placement and spacing.", + ferese_config::tr!("Select an item to adjust how it appears, or a group to edit its placement and spacing."), )), ) .padding([40, 8]) @@ -779,7 +783,7 @@ impl App { ) .push( self.settings_button( - "Add group", + ferese_config::tr!("Add group"), "M12 5v14 M5 12h14", (panel.group_count() < ferese_panel::MAX_GROUPS) .then_some(Message::PanelEdit(Action::AddGroup(placement))), @@ -881,8 +885,10 @@ impl App { } let mut contents = widget::column([]).spacing(4).push(heading); if items.is_empty() { - contents = contents - .push(widget::container(self.note("Drop items here")).padding([8, 6])); + contents = contents.push( + widget::container(self.note(ferese_config::tr!("Drop items here"))) + .padding([8, 6]), + ); } else { contents = contents.push( widget::flex_row(items) @@ -922,12 +928,14 @@ impl App { } if zone.groups.is_empty() { groups = groups.push( - widget::container(self.note("Drop an item here, or add a group.")) - .id(crate::panel_drag::empty_zone_id(placement)) - .width(Length::Fill) - .height(64) - .padding(12) - .class(crate::visuals::surface(palette.card, 10.)), + widget::container( + self.note(ferese_config::tr!("Drop an item here, or add a group.")), + ) + .id(crate::panel_drag::empty_zone_id(placement)) + .width(Length::Fill) + .height(64) + .padding(12) + .class(crate::visuals::surface(palette.card, 10.)), ); } zones = zones.push( @@ -951,6 +959,7 @@ pub(super) fn group_name(id: &str) -> String { pub(super) fn item_icon(kind: ItemKind, tint: cosmic::iced::Color) -> widget::icon::Icon { use ferese_theme::icons; let source = match kind { + ItemKind::Tray => icons::APPLICATION, ItemKind::Overview => icons::FERESE, ItemKind::Workspaces { .. } => icons::OVERVIEW, ItemKind::FocusedWindow => icons::DISPLAY, diff --git a/shell/ferese-settings/src/panel_inspector.rs b/shell/ferese-settings/src/panel_inspector.rs index 07a2d08..1428296 100644 --- a/shell/ferese-settings/src/panel_inspector.rs +++ b/shell/ferese-settings/src/panel_inspector.rs @@ -23,7 +23,7 @@ impl App { .width(Length::Fill), ) .push(self.settings_icon_button( - "Deselect", + ferese_config::tr!("Deselect"), "M6 6l12 12 M6 18L18 6", Some(Message::PanelClearSelection), )), @@ -58,7 +58,7 @@ impl App { rows = rows.push( widget::column([]) .spacing(8) - .push(self.label("Placement", 13.)) + .push(self.label(ferese_config::tr!("Placement"), 13.)) .push(crate::visuals::panel_select( cosmic::iced::widget::pick_list( crate::panel_edit::destinations(panel), @@ -166,8 +166,10 @@ impl App { }; let mut choices = widget::column([]) .spacing(6) - .push(self.label("Workspace style", 13.)) - .push(self.note("A sample of active, occupied and empty workspaces.")); + .push(self.label(ferese_config::tr!("Workspace style"), 13.)) + .push(self.note(ferese_config::tr!( + "A sample of active, occupied and empty workspaces." + ))); for style in ferese_panel::WorkspaceStyle::ALL { let sample = ferese_theme::workspaces::sample(style, palette, self.font, 22.); choices = choices.push( @@ -210,7 +212,7 @@ impl App { .push( widget::column([]) .spacing(8) - .push(self.label("Placement", 13.)) + .push(self.label(ferese_config::tr!("Placement"), 13.)) .push(crate::visuals::panel_select( cosmic::iced::widget::pick_list( Zone::ALL.to_vec(), @@ -307,8 +309,9 @@ impl App { ), ); if !group.items.is_empty() { - rows = rows - .push(self.note("Move or remove this group’s items before removing the group.")); + rows = rows.push(self.note(ferese_config::tr!( + "Move or remove this group’s items before removing the group." + ))); } rows.into() } @@ -322,8 +325,8 @@ impl App { ) -> Element<'static, Message> { widget::row([]) .spacing(8) - .push(self.settings_icon_button("Up", "M6 15l6-6 6 6", earlier)) - .push(self.settings_icon_button("Down", "M6 9l6 6 6-6", later)) + .push(self.settings_icon_button(ferese_config::tr!("Up"), "M6 15l6-6 6 6", earlier)) + .push(self.settings_icon_button(ferese_config::tr!("Down"), "M6 9l6 6 6-6", later)) .push(self.settings_icon_button( remove_label, "M4 6h16 M9 6V3h6v3 M6 6l1 15h10l1-15 M10 10v7 M14 10v7", diff --git a/shell/ferese-settings/src/panel_preview.rs b/shell/ferese-settings/src/panel_preview.rs index ff4a0b6..cceee65 100644 --- a/shell/ferese-settings/src/panel_preview.rs +++ b/shell/ferese-settings/src/panel_preview.rs @@ -7,6 +7,7 @@ use ferese_theme::panel::{Sample, Spacing, frame}; use crate::{App, Element, Message, panel_controls, visuals}; const SAMPLE_AVAILABILITY: Availability = Availability { + tray: true, capture: true, activities: true, media: true, @@ -47,8 +48,11 @@ impl App { .push( row([]) .align_y(Alignment::Center) - .push(self.label("Composition preview", 14.).width(Length::Fill)) - .push(self.note("Sample content")), + .push( + self.label(ferese_config::tr!("Composition preview"), 14.) + .width(Length::Fill), + ) + .push(self.note(ferese_config::tr!("Sample content"))), ) .push( container(self.panel_preview_frame(panel.clone())) @@ -121,7 +125,7 @@ impl App { if panel.style == ferese_panel::PanelStyle::Morph { let palette = visuals::Palette::from_resolved(&self.resolved.presented); let height = panel.resolved_geometry().height; - let hub = container(self.label("● 10:09", 13.)) + let hub = container(self.label(ferese_config::tr!("● 10:09"), 13.)) .id("morph-preview-body") .center_x(panel.morph.idle_width as f32) .center_y(height); @@ -383,7 +387,7 @@ impl App { let sample = ferese_theme::workspaces::sample(style, palette, self.font, 20.); let control = button::custom(container(sample).id(format!("preview-icon:{}", item.id.0))) - .name("Edit Workspaces") + .name(ferese_config::tr!("Edit Workspaces")) .padding([0, 2]) .height(control_height) .on_press(Message::PanelSelect(item.id.clone())) @@ -408,6 +412,7 @@ impl App { ItemKind::Network => icons::WIFI_FULL, ItemKind::Audio => icons::VOLUME_HIGH, ItemKind::Recording => icons::RECORD, + ItemKind::Tray => icons::APPLICATION, ItemKind::CaptureStatus => icons::DISPLAY, ItemKind::Notifications => icons::NOTIFICATIONS, ItemKind::Battery { .. } => icons::BATTERY_75, diff --git a/shell/ferese-settings/src/schema.rs b/shell/ferese-settings/src/schema.rs index 35f7b04..7ea9957 100644 --- a/shell/ferese-settings/src/schema.rs +++ b/shell/ferese-settings/src/schema.rs @@ -1,6 +1,10 @@ #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Page { Appearance, + General, + Theme, + Tray, + Configuration, Accessibility, Wallpaper, Bar, @@ -16,8 +20,12 @@ pub enum Page { } impl Page { - pub const NAVIGATION: [(&'static str, &'static [Self]); 3] = [ - ("Desktop", &[Self::Appearance, Self::Wallpaper, Self::Bar]), + pub const NAVIGATION: [(&'static str, &'static [Self]); 5] = [ + ("Appearance", &[Self::Appearance, Self::General]), + ( + "Desktop", + &[Self::Theme, Self::Wallpaper, Self::Bar, Self::Tray], + ), ( "Interaction", &[Self::Windows, Self::Keyboard, Self::Accessibility], @@ -32,6 +40,7 @@ impl Page { Self::Startup, ], ), + ("Advanced", &[Self::Configuration]), ]; /// Subpages share one entry in navigation and search. @@ -66,8 +75,12 @@ impl Page { } #[cfg(test)] - pub const ALL: [Self; 13] = [ + pub const ALL: [Self; 17] = [ Self::Appearance, + Self::General, + Self::Theme, + Self::Tray, + Self::Configuration, Self::Accessibility, Self::Wallpaper, Self::Bar, @@ -83,8 +96,15 @@ impl Page { ]; pub fn title(self) -> &'static str { + ferese_config::locale::text(self.title_key()) + } + fn title_key(self) -> &'static str { match self { Self::Appearance => "Appearance", + Self::General => "General", + Self::Tray => "Tray icons", + Self::Configuration => "All options", + Self::Theme => "Theme", Self::Accessibility => "Accessibility", Self::Wallpaper => "Wallpaper", Self::Bar => "Panel & Shell", @@ -101,8 +121,14 @@ impl Page { } pub fn subtitle(self) -> &'static str { - match self { - Self::Appearance => "A desktop that feels like yours.", + ferese_config::locale::text(match self { + Self::Appearance => "All Ferese settings, grouped by function.", + Self::General => "Language and desktop preferences.", + Self::Tray => "Choose which status and application icons appear in the panel.", + Self::Configuration => { + "Every supported configuration option, including advanced settings." + } + Self::Theme => "A desktop that feels like yours.", Self::Accessibility => "Make the desktop easier to see and use.", Self::Wallpaper => "Set the scene for your workspace.", Self::Bar => "Arrange your panel and change the shell's appearance.", @@ -115,7 +141,7 @@ impl Page { Self::Startup => "Ready when you sign in.", Self::Displays => "A place for every screen.", Self::Connections => "Wi-Fi networks and Bluetooth devices.", - } + }) } pub fn icon(self) -> &'static str { @@ -123,7 +149,11 @@ impl Page { Self::Accessibility => { "M12 3a2 2 0 1 0 0 4a2 2 0 1 0 0-4 M4 9h16 M12 9v6 M12 15l-5 6 M12 15l5 6" } - Self::Appearance => { + Self::Appearance | Self::General | Self::Configuration => { + "M4 5h16 M4 12h16 M4 19h16 M8 3v4 M16 10v4 M10 17v4" + } + Self::Tray => "M3 5h18v14H3z M6 9h3v3H6z M12 9h3v3h-3z M18 9h.01", + Self::Theme => { "M12 3a9 9 0 1 0 0 18h1a2 2 0 0 0 0-4h-1a1 1 0 0 1 0-2h3a6 6 0 0 0 0-12z M7 9h.01 M10 6h.01 M15 6h.01 M18 10h.01" } Self::Wallpaper => "M4 4h16v16H4z M4 16l5-5 4 4 3-3 4 4 M15 8h.01", @@ -157,11 +187,31 @@ impl Page { fn matches_own(self, query: &str) -> bool { let query = query.to_lowercase(); self.title().to_lowercase().contains(&query) + || self.title_key().to_lowercase().contains(&query) + || ferese_config::locale::in_language( + ferese_config::locale::Language::Chinese, + self.title_key(), + ) + .to_lowercase() + .contains(&query) || self.subtitle().to_lowercase().contains(&query) || fields(self).iter().any(|f| { - format!("{} {} {}", f.label, f.description, f.path) - .to_lowercase() - .contains(&query) + format!( + "{} {} {} {} {}", + f.label, + f.description, + f.path, + ferese_config::locale::in_language( + ferese_config::locale::Language::Chinese, + &f.label + ), + ferese_config::locale::in_language( + ferese_config::locale::Language::Chinese, + &f.description + ) + ) + .to_lowercase() + .contains(&query) }) } } @@ -375,7 +425,7 @@ pub fn fields(page: Page) -> Vec { false, ), ], - Page::Appearance => vec![ + Page::Theme => vec![ choice( "theme.mode", "Appearance", @@ -910,6 +960,57 @@ pub fn fields(page: Page) -> Vec { "Keep the shortcut guide until you are comfortable with Ferese.", true, )], - Page::Startup | Page::Displays | Page::Connections => vec![], + Page::General => vec![choice( + "ui.language", + "Language", + "Changes the Ferese interface language. Applies immediately.", + "auto", + &[ + ("auto", "Follow system"), + ("zh-CN", "简体中文"), + ("en", "English"), + ], + )], + Page::Tray => vec![ + toggle( + "status.tray.enabled", + "Show tray icons", + "Show application icons in panel tray items.", + true, + ), + toggle( + "status.tray.show_passive", + "Show passive icons", + "Include applications without active status.", + false, + ), + range( + "status.tray.icon_size", + "Tray icon size", + "Size of application tray icons.", + RangeSpec { + default: 16., + min: 8., + max: 48., + step: 1., + suffix: " px", + integer: true, + }, + ), + Field::new( + "status.tray.hidden_items", + "Hidden applications", + "Application IDs to hide, separated by spaces.", + Kind::Text { + default: "", + argv: true, + }, + ), + ], + Page::Appearance + | Page::Configuration + | Page::Startup + | Page::Displays + | Page::Connections => vec![], } } diff --git a/shell/ferese-settings/src/theme_controls.rs b/shell/ferese-settings/src/theme_controls.rs index 5d1b6e3..8dd4ac0 100644 --- a/shell/ferese-settings/src/theme_controls.rs +++ b/shell/ferese-settings/src/theme_controls.rs @@ -56,7 +56,7 @@ impl App { let labels = column([]) .spacing(2) .width(Length::Fill) - .push(self.label("Theme overrides", 13.)) + .push(self.label(ferese_config::tr!("Theme overrides"), 13.)) .push(widget::tooltip( self.label(filename, 11.) .class(cosmic::theme::Text::Color(palette.muted)), @@ -106,7 +106,7 @@ impl App { button::custom(visuals::action_icon("M6 6l12 12M6 18L18 6", palette.muted)) .padding(4) .on_press(Message::Change(Edit::Unset(path.into()))), - self.label("Remove override", 11.), + self.label(ferese_config::tr!("Remove override"), 11.), widget::tooltip::Position::Bottom, )); } @@ -152,7 +152,7 @@ impl App { column([]) .spacing(2) .width(Length::Fill) - .push(self.label("Automatic appearance", 13.)) + .push(self.label(ferese_config::tr!("Automatic appearance"), 13.)) .push(self.note(description)), ) .push(sources), @@ -221,18 +221,18 @@ impl App { ); if appearance != Some(Dark) && self.undo.is_some() { heading = heading.push(self.settings_icon_button( - "Undo", + ferese_config::tr!("Undo"), "M4 10h8 M4 10V3 M4 10c3-7 16-6 16 3a7 7 0 0 1-7 7", (!self.saving).then_some(Message::Undo), )); } - let mut custom_heading = row([]) - .align_y(Alignment::Center) - .spacing(6) - .push(self.label("Custom themes", 14.).width(Length::Fill)); + let mut custom_heading = row([]).align_y(Alignment::Center).spacing(6).push( + self.label(ferese_config::tr!("Custom themes"), 14.) + .width(Length::Fill), + ); if appearance != Some(Dark) { custom_heading = custom_heading.push(self.settings_button( - "Import…", + ferese_config::tr!("Import…"), "M3 7V5h6l2 2h10v13H3z M12 10v7 M9 14l3 3 3-3", Some(Message::ImportTheme), false, diff --git a/shell/ferese-settings/src/tray_controls.rs b/shell/ferese-settings/src/tray_controls.rs new file mode 100644 index 0000000..d6ffbf7 --- /dev/null +++ b/shell/ferese-settings/src/tray_controls.rs @@ -0,0 +1,111 @@ +//! Read tray applications through the existing watcher without becoming a host. +use super::{Alignment, App, Element, Length, Message, Task, column, row, set}; +use ferese_config::locale::text as t; +#[derive(Clone, Debug)] +pub struct Application { + pub id: String, + pub title: String, +} +async fn applications() -> Result, String> { + let connection = zbus::Connection::session() + .await + .map_err(|e| e.to_string())?; + let watcher = zbus::Proxy::new( + &connection, + "org.kde.StatusNotifierWatcher", + "/StatusNotifierWatcher", + "org.kde.StatusNotifierWatcher", + ) + .await + .map_err(|e| e.to_string())?; + let addresses: Vec = watcher + .get_property("RegisteredStatusNotifierItems") + .await + .map_err(|e| e.to_string())?; + let mut apps = Vec::new(); + for address in addresses.into_iter().take(128) { + let (name, path) = address + .find('/') + .map_or((address.as_str(), "/StatusNotifierItem"), |i| { + (&address[..i], &address[i..]) + }); + let Ok(proxy) = + zbus::Proxy::new(&connection, name, path, "org.kde.StatusNotifierItem").await + else { + continue; + }; + let Ok(id) = proxy.get_property::("Id").await else { + continue; + }; + let title = proxy + .get_property::("Title") + .await + .unwrap_or_else(|_| id.clone()); + if !apps.iter().any(|a: &Application| a.id == id) { + apps.push(Application { id, title }); + } + } + apps.sort_by(|a, b| a.title.cmp(&b.title)); + Ok(apps) +} +impl App { + pub(super) fn load_tray_apps(&self) -> Task { + cosmic::task::future(async { Message::TrayApps(applications().await.unwrap_or_default()) }) + } + pub(super) fn tray_application_controls(&self) -> Element<'_, Message> { + let hidden = self + .draft + .item("status.tray.hidden_items") + .and_then(serde_json::Value::as_array) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(str::to_owned)) + .collect::>() + }) + .unwrap_or_default(); + let mut apps = self.tray_apps.clone(); + for id in &hidden { + if !apps.iter().any(|a| &a.id == id) { + apps.push(Application { + id: id.clone(), + title: id.clone(), + }); + } + } + let mut out = column([]) + .spacing(8) + .push(self.panel_heading(t("Application icons"), 15.)); + for app in apps { + let visible = !hidden.contains(&app.id); + let mut next = hidden.clone(); + if visible { + next.push(app.id); + } else { + next.retain(|id| id != &app.id); + } + out = out.push( + row([]) + .spacing(8) + .align_y(Alignment::Center) + .push(self.label(app.title, 13.).width(Length::Fill)) + .push( + ferese_theme::controls::switch( + visible, + super::visuals::Palette::from_resolved(&self.resolved.presented), + ) + .on_press(Message::Change(set( + "status.tray.hidden_items", + serde_json::json!(next), + ))), + ), + ); + } + out.push(self.settings_button( + t("Refresh applications"), + "M3 10a8 8 0 1 1 1 7 M3 3v7h7", + Some(Message::RefreshTrayApps), + false, + )) + .into() + } +} diff --git a/shell/ferese-settings/src/wallpaper_controls.rs b/shell/ferese-settings/src/wallpaper_controls.rs index 8746efb..a8a3a4d 100644 --- a/shell/ferese-settings/src/wallpaper_controls.rs +++ b/shell/ferese-settings/src/wallpaper_controls.rs @@ -343,7 +343,7 @@ impl App { let preview = &self.wallpaper.active; let mut body = column([]) .spacing(8) - .push(self.label("Current wallpaper", 13.)); + .push(self.label(ferese_config::tr!("Current wallpaper"), 13.)); let current: Element<'_, Message> = if let Some(handle) = &preview.handle { widget::image(handle.clone()) .border_radius(palette.radius.min(10.)) @@ -376,7 +376,7 @@ impl App { row([]) .spacing(8) .align_y(Alignment::Center) - .push(self.label("Apply to", 12.)) + .push(self.label(ferese_config::tr!("Apply to"), 12.)) .push(ferese_theme::controls::select( cosmic::iced::widget::pick_list( Target::ALL, @@ -389,7 +389,7 @@ impl App { )) .push(widget::Space::new().width(Length::Fill)) .push(self.settings_button( - "Choose image…", + ferese_config::tr!("Choose image…"), "M3 4h18v16H3z M3 15l5-5 5 5 3-3 5 5 M15 8h.01", Some(Message::PickWallpaper), false, @@ -444,10 +444,10 @@ impl App { row([]) .spacing(8) .align_y(Alignment::Center) - .push(self.label("Default wallpapers", 13.)) + .push(self.label(ferese_config::tr!("Default wallpapers"), 13.)) .push(widget::Space::new().width(Length::Fill)) .push(self.settings_button( - "Match appearance", + ferese_config::tr!("Match appearance"), "M20 7v5h-5 M4 17v-5h5 M6 7a7 7 0 0 1 12-2l2 2 M18 17a7 7 0 0 1-12 2l-2-2", Some(Message::RestoreDefaultWallpapers), false, diff --git a/shell/ferese-shell/Cargo.toml b/shell/ferese-shell/Cargo.toml index ea6aadb..7e2220d 100644 --- a/shell/ferese-shell/Cargo.toml +++ b/shell/ferese-shell/Cargo.toml @@ -32,6 +32,7 @@ tempfile.workspace = true tokio = { version = "1", features = ["time", "rt", "sync", "net", "macros"] } wayland-client.workspace = true zbus = "5.19.0" +system-tray = { version = "0.8.9", default-features = false, features = ["data"] } pipewire = { version = "0.10.1", features = ["v0_3_65"] } image-codec = { package = "image", version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] } url = "2.5.8" diff --git a/shell/ferese-shell/src/activities.rs b/shell/ferese-shell/src/activities.rs index 77b922f..fed2923 100644 --- a/shell/ferese-shell/src/activities.rs +++ b/shell/ferese-shell/src/activities.rs @@ -583,7 +583,12 @@ impl FereseShell { ) -> (Element<'_, cosmic::Action>, bool) { use ferese_panel::Representation; let Some(presentation) = self.activities.flat.get(&id) else { - return (container(self.render.text("")).width(0).into(), false); + return ( + container(self.render.text(ferese_config::tr!(""))) + .width(0) + .into(), + false, + ); }; let Some(activity) = presentation.selection.visible.first().and_then(|id| { self.activities @@ -593,7 +598,12 @@ impl FereseShell { .chain(self.activities.native.iter()) .find(|entry| &entry.id == id) }) else { - return (container(self.render.text("")).width(0).into(), false); + return ( + container(self.render.text(ferese_config::tr!(""))) + .width(0) + .into(), + false, + ); }; let palette = self.config.theme.palette(); let result_age = activity.result_deadline.map(|deadline| { @@ -1089,7 +1099,7 @@ impl FereseShell { rows.push( container( self.render - .text("No active tasks") + .text(ferese_config::tr!("No active tasks")) .size(12) .class(theme::Text::Color(palette.muted.scale_alpha(opacity))), ) @@ -1167,7 +1177,7 @@ impl FereseShell { { body = body.push( button::custom(icons::tinted(icons::MEDIA_PLAY, 16, palette.text)) - .name("Play or pause") + .name(ferese_config::tr!("Play or pause")) .on_press(cosmic::Action::App(Message::MediaAction( self.media.action("play-pause"), ))), diff --git a/shell/ferese-shell/src/activities/pomodoro.rs b/shell/ferese-shell/src/activities/pomodoro.rs index 043422e..0b75e99 100644 --- a/shell/ferese-shell/src/activities/pomodoro.rs +++ b/shell/ferese-shell/src/activities/pomodoro.rs @@ -122,7 +122,7 @@ impl FereseShell { badge, column![ self.render - .text("Pomodoro") + .text(ferese_config::tr!("Pomodoro")) .size(13) .class(theme::Text::Color(primary)), self.render diff --git a/shell/ferese-shell/src/bar.rs b/shell/ferese-shell/src/bar.rs index 8012477..99aaeb3 100644 --- a/shell/ferese-shell/src/bar.rs +++ b/shell/ferese-shell/src/bar.rs @@ -75,7 +75,7 @@ impl FereseShell { pub(super) fn view_layer(&self, id: window::Id) -> Element<'_, cosmic::Action> { if self.bar_hidden(id) { - return container(self.render.text("")) + return container(self.render.text(ferese_config::tr!(""))) .width(Length::Fill) .height(Length::Fill) .into(); @@ -94,6 +94,7 @@ impl FereseShell { let effects = output.and_then(|output| output.effects.as_ref()); let compositor_material = effects.is_some(); let availability = Availability { + tray: self.tray.available(&self.config.status.tray), capture: self.capture_status.active(), activities: self.activity_available(id), media: self.media.snapshot.selected.is_some(), @@ -324,9 +325,12 @@ impl FereseShell { cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1), )); let badge = container( - row![title, self.render.text("Esc to exit").size(12)] - .spacing(12) - .align_y(cosmic::iced::Alignment::Center), + row![ + title, + self.render.text(ferese_config::tr!("Esc to exit")).size(12) + ] + .spacing(12) + .align_y(cosmic::iced::Alignment::Center), ) .id(badge_id.clone()) .padding([0, 12]) @@ -480,6 +484,9 @@ impl FereseShell { control_height, ); } + ItemKind::Tray => { + return (self.view_tray(id), false); + } ItemKind::Media => self.view_media_item(representation, selected, width), ItemKind::QuickSettings => { self.view_status_item(status_ui::Menu::System, false, selected) @@ -535,7 +542,7 @@ impl FereseShell { button::custom(bar_content(content, bar.group_item_height)) .padding([0, 4]) .height(bar.group_item_height) - .name("Open calendar") + .name(ferese_config::tr!("Open calendar")) .on_press_with_rectangle(move |offset, bounds| { cosmic::Action::App(Message::OpenMenu( status_ui::Menu::Calendar, @@ -558,7 +565,7 @@ impl FereseShell { bar_icon(ferese_theme::icons::DISPLAY, bar.icon_size, foreground), control_height, )) - .name("Display mode") + .name(ferese_config::tr!("Display mode")) .height(control_height) .padding([ 0.0, diff --git a/shell/ferese-shell/src/config.rs b/shell/ferese-shell/src/config.rs index 481564e..b0bf28a 100644 --- a/shell/ferese-shell/src/config.rs +++ b/shell/ferese-shell/src/config.rs @@ -8,6 +8,7 @@ const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20]; #[derive(Clone, Debug)] pub(crate) struct ShellConfig { + pub(crate) ui: ferese_config::locale::Settings, pub(crate) terminal: Vec, pub(crate) activities: ferese_config::activities::Settings, pub(crate) notifications: ferese_config::notifications::NotificationConfig, @@ -24,6 +25,7 @@ impl Default for ShellConfig { fn default() -> Self { let status = StatusConfig::default(); Self { + ui: Default::default(), terminal: vec!["foot".into()], activities: Default::default(), notifications: Default::default(), @@ -38,23 +40,7 @@ impl Default for ShellConfig { } } -#[derive(Clone, Debug, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub(crate) struct StatusConfig { - pub(crate) keybinding_guide: bool, - pub(crate) low_battery_threshold: u8, - pub(crate) settings_command: Option>, -} - -impl Default for StatusConfig { - fn default() -> Self { - Self { - keybinding_guide: true, - low_battery_threshold: 20, - settings_command: Some(vec!["ferese-settings".into()]), - } - } -} +pub(crate) use ferese_config::status::Settings as StatusConfig; #[derive(Clone, Copy, Debug)] pub(crate) struct ShellTheme { @@ -190,6 +176,8 @@ pub(crate) enum WallpaperMode { #[derive(Debug, Default, Deserialize)] struct FereseConfig { + #[serde(default)] + ui: ferese_config::locale::Settings, #[serde(default)] commands: std::collections::HashMap>, #[serde(default)] @@ -405,6 +393,10 @@ fn parse_document( .map_err(|error| ferese_config::Error::from(error.to_string())) { Ok(config) => { + config + .status + .validate() + .map_err(ferese_config::Error::from)?; if let Some(panels) = &config.panels { ferese_panel::validate(panels).map_err(ferese_config::Error::from)?; } @@ -424,6 +416,7 @@ fn parse_document( theme.bar_radius = theme.material_radius; Ok(ShellConfig { + ui: config.ui, terminal: config .commands .get("terminal") diff --git a/shell/ferese-shell/src/display_mode.rs b/shell/ferese-shell/src/display_mode.rs index e72f044..bb17fe0 100644 --- a/shell/ferese-shell/src/display_mode.rs +++ b/shell/ferese-shell/src/display_mode.rs @@ -415,7 +415,9 @@ impl FereseShell { .is_some_and(|modal| modal.focus_visible); let pending = model.pending(); let mut header = row![ - self.render.text("Display mode").size(17), + self.render + .text(ferese_config::tr!("Display mode")) + .size(17), cosmic::iced::widget::Space::new().width(Length::Fill) ] .align_y(cosmic::iced::Alignment::Center) diff --git a/shell/ferese-shell/src/launcher/view.rs b/shell/ferese-shell/src/launcher/view.rs index 6622b93..c249cb7 100644 --- a/shell/ferese-shell/src/launcher/view.rs +++ b/shell/ferese-shell/src/launcher/view.rs @@ -141,7 +141,7 @@ impl FereseShell { suggestion = suggestion.push( container( self.render - .text("Tab") + .text(ferese_config::tr!("Tab")) .size(12) .line_height(cosmic::iced::widget::text::LineHeight::Absolute(14.into())) .class(theme::Text::Color(muted)), @@ -440,7 +440,7 @@ impl FereseShell { if launcher.rows.is_empty() { cards = cards.push( self.render - .text("No open windows") + .text(ferese_config::tr!("No open windows")) .size(14) .class(theme::Text::Color(muted)), ); diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index b198171..b59a9eb 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -2,6 +2,7 @@ mod activities; mod bar; mod battery_indicator; mod surfaces; +mod tray; use bar::{bar_content, output_bar_hidden}; use surfaces::{EffectsBinding, EffectsContext, native_wayland_surface}; @@ -151,6 +152,7 @@ type WallpaperLoad = cosmic::iced::futures::channel::oneshot::Receiver>; struct FereseShell { + tray: tray::Model, core: Core, config: ShellConfig, theme_snapshot: ferese_ipc::theme::Snapshot, @@ -205,6 +207,8 @@ struct OutputSurfaces { #[derive(Clone, Debug)] enum Message { + Tray(tray::Event), + TrayInput(tray::Input), Launcher(launcher::Message), LauncherEnter(window::Id, bool), NightLightTemperature(u16), @@ -357,6 +361,7 @@ impl cosmic::Application for FereseShell { status_service: status::Service::start(config.status.settings_command.clone()), status: status::Snapshot::default(), recorder: recording::Recorder::default(), + tray: tray::Model::default(), capture_status: capture_status::Model::new(), media: media::Model::default(), activities: activities::Model::default(), @@ -453,6 +458,7 @@ impl cosmic::Application for FereseShell { activities::subscription::subscription().map(Message::ActivitiesUpdated), self.activity_tick_subscription() .map(|_| Message::ActivityTick), + tray::subscription().map(Message::Tray), self.media.subscription().map(Message::MediaUpdated), self.media_tick_subscription().map(|_| Message::MediaTick), self.media.art.subscription().map(Message::MediaArt), @@ -478,6 +484,11 @@ impl cosmic::Application for FereseShell { fn update(&mut self, message: Self::Message) -> Task { let task = match message { + Message::Tray(event) => { + self.tray.update(event); + Task::none() + } + Message::TrayInput(input) => self.tray_input(input), Message::Launcher(message) => self.update_launcher(message), Message::LauncherEnter(id, alt) => { if self.launcher.as_ref().is_some_and(|l| l.id == id) { @@ -1150,11 +1161,11 @@ impl cosmic::Application for FereseShell { } fn view(&self) -> Element<'_, Self::Message> { - self.render.text("").into() + self.render.text(ferese_config::tr!("")).into() } fn view_window(&self, _id: window::Id) -> Element<'_, Self::Message> { - self.render.text("").into() + self.render.text(ferese_config::tr!("")).into() } } @@ -1185,6 +1196,8 @@ impl FereseShell { } fn apply_config(&mut self, mut config: config::ShellConfig) -> Task { + ferese_config::locale::set(config.ui.language); + self.clock = clock::current_time(); if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some() { config.wallpaper.path = None; } @@ -1386,6 +1399,7 @@ mod tests { status_service: status::Service::start(None), status: Default::default(), recorder: Default::default(), + tray: tray::Model::default(), capture_status: Default::default(), media: Default::default(), activities: Default::default(), diff --git a/shell/ferese-shell/src/motion.rs b/shell/ferese-shell/src/motion.rs index 9b308c2..030e9cd 100644 --- a/shell/ferese-shell/src/motion.rs +++ b/shell/ferese-shell/src/motion.rs @@ -699,7 +699,7 @@ mod tests { )) .push( cosmic::widget::button::custom(cosmic::widget::text("Action")) - .name("Accessible action") + .name(ferese_config::tr!("Accessible action")) .on_press(()) .width(100) .height(40), diff --git a/shell/ferese-shell/src/notification_ui.rs b/shell/ferese-shell/src/notification_ui.rs index 7b88660..a1ff1c0 100644 --- a/shell/ferese-shell/src/notification_ui.rs +++ b/shell/ferese-shell/src/notification_ui.rs @@ -723,7 +723,7 @@ impl FereseShell { .spacing(3) .push( self.render - .text("Notifications") + .text(ferese_config::tr!("Notifications")) .size(16) .font(bold) .class(theme::Text::Color(primary)), @@ -767,7 +767,7 @@ impl FereseShell { .spacing(2) .push( self.render - .text("Do Not Disturb") + .text(ferese_config::tr!("Do Not Disturb")) .size(12) .font(bold) .class(theme::Text::Color(primary)), @@ -821,14 +821,14 @@ impl FereseShell { )) .push( self.render - .text("You're all caught up") + .text(ferese_config::tr!("You're all caught up")) .size(15) .font(bold) .class(theme::Text::Color(primary)), ) .push( self.render - .text("New notifications will appear here.") + .text(ferese_config::tr!("New notifications will appear here.")) .size(11) .class(theme::Text::Color(muted)), ); @@ -843,7 +843,7 @@ impl FereseShell { .align_y(alignment::Vertical::Center) .push( self.render - .text("Recent") + .text(ferese_config::tr!("Recent")) .size(11) .font(bold) .class(theme::Text::Color(muted)) diff --git a/shell/ferese-shell/src/panel_hub.rs b/shell/ferese-shell/src/panel_hub.rs index 351da30..15c03ac 100644 --- a/shell/ferese-shell/src/panel_hub.rs +++ b/shell/ferese-shell/src/panel_hub.rs @@ -1101,7 +1101,7 @@ impl FereseShell { button::custom( icons::tinted(icons::morph::CLOSE, 18, icon_primary).opacity(opacity), ) - .name("Collapse panel hub") + .name(ferese_config::tr!("Collapse panel hub")) .padding([5, 3]) .width(30) .height(28) diff --git a/shell/ferese-shell/src/panel_hub/activity.rs b/shell/ferese-shell/src/panel_hub/activity.rs index 30ac247..3159c8d 100644 --- a/shell/ferese-shell/src/panel_hub/activity.rs +++ b/shell/ferese-shell/src/panel_hub/activity.rs @@ -149,7 +149,7 @@ impl FereseShell { return row![ self.battery_indicator(self.config.theme, opacity, true), self.render - .text("Low battery") + .text(ferese_config::tr!("Low battery")) .size(13) .class(theme::Text::Color(foreground)) .width(width) @@ -202,7 +202,7 @@ impl FereseShell { if expanded { strip = strip.push( self.render - .text("Recording") + .text(ferese_config::tr!("Recording")) .size(13) .class(theme::Text::Color(foreground)) .width(Length::Fill), diff --git a/shell/ferese-shell/src/rendering.rs b/shell/ferese-shell/src/rendering.rs index 6b9a7ab..9b10f18 100644 --- a/shell/ferese-shell/src/rendering.rs +++ b/shell/ferese-shell/src/rendering.rs @@ -9,6 +9,7 @@ pub(crate) struct Rendering { impl Rendering { pub fn new(config: &crate::config::ShellConfig) -> Self { + ferese_config::locale::set(config.ui.language); Self { font: crate::configured_font(config.font_family.as_deref()), motion: config.animations, diff --git a/shell/ferese-shell/src/status_ui/audio.rs b/shell/ferese-shell/src/status_ui/audio.rs index fb6bbde..0425f1c 100644 --- a/shell/ferese-shell/src/status_ui/audio.rs +++ b/shell/ferese-shell/src/status_ui/audio.rs @@ -21,8 +21,14 @@ pub(super) fn view<'a>( opacity: p, } = style; if let Some(a) = &shell.status.audio { - let mut volume_heading = row![shell.render.text("Volume").size(13).width(Length::Fill)] - .align_y(Alignment::Center); + let mut volume_heading = row![ + shell + .render + .text(ferese_config::tr!("Volume")) + .size(13) + .width(Length::Fill) + ] + .align_y(Alignment::Center); if combined { volume_heading = volume_heading.push(shell_switch( shell.render, diff --git a/shell/ferese-shell/src/status_ui/bar.rs b/shell/ferese-shell/src/status_ui/bar.rs index 89a9990..12e1123 100644 --- a/shell/ferese-shell/src/status_ui/bar.rs +++ b/shell/ferese-shell/src/status_ui/bar.rs @@ -146,6 +146,7 @@ impl FereseShell { fn status_label(kind: Menu, s: &Snapshot) -> String { match kind { + Menu::Tray => "Application tray".into(), Menu::Network => match &s.network { Some(n) if n.enabled => format!( "Wi-Fi: {}", @@ -202,6 +203,7 @@ pub(super) fn audio_icon(volume: u8, muted: bool) -> &'static [u8] { pub(super) fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) { match kind { + Menu::Tray => (ferese_theme::icons::APPLICATION, true), Menu::NightLight => ( ferese_theme::icons::MOON, s.night_light.as_ref().is_some_and(|s| s.enabled), diff --git a/shell/ferese-shell/src/status_ui/battery.rs b/shell/ferese-shell/src/status_ui/battery.rs index 3cb7ddd..444af96 100644 --- a/shell/ferese-shell/src/status_ui/battery.rs +++ b/shell/ferese-shell/src/status_ui/battery.rs @@ -73,7 +73,7 @@ pub(super) fn view<'a>( rows = rows.push( shell .render - .text("Power Profiles service unavailable") + .text(ferese_config::tr!("Power Profiles service unavailable")) .size(12) .class(theme::Text::Color(muted)), ); @@ -136,7 +136,7 @@ pub(super) fn brightness<'a>( } = style; if let Some(value) = shell.status.brightness { let brightness = column![ - shell.render.text("Brightness").size(13), + shell.render.text(ferese_config::tr!("Brightness")).size(13), slider_row( shell.render, ferese_theme::icons::BRIGHTNESS, diff --git a/shell/ferese-shell/src/status_ui/bluetooth.rs b/shell/ferese-shell/src/status_ui/bluetooth.rs index 6b37bf6..2dc5870 100644 --- a/shell/ferese-shell/src/status_ui/bluetooth.rs +++ b/shell/ferese-shell/src/status_ui/bluetooth.rs @@ -50,7 +50,7 @@ pub(super) fn view<'a>( shell.render.text(device).size(14), shell .render - .text("Connected") + .text(ferese_config::tr!("Connected")) .size(12) .class(theme::Text::Color(muted)), ] diff --git a/shell/ferese-shell/src/status_ui/calendar.rs b/shell/ferese-shell/src/status_ui/calendar.rs index c60ca66..7a993ce 100644 --- a/shell/ferese-shell/src/status_ui/calendar.rs +++ b/shell/ferese-shell/src/status_ui/calendar.rs @@ -18,9 +18,27 @@ pub(super) fn view<'a>( render.font, opacity, [ - menu_button(render, "‹", Message::CalendarMonth(-1), primary, opacity), - menu_button(render, "Today", Message::CalendarToday, primary, opacity), - menu_button(render, "›", Message::CalendarMonth(1), primary, opacity), + menu_button( + render, + ferese_config::tr!("‹"), + Message::CalendarMonth(-1), + primary, + opacity, + ), + menu_button( + render, + ferese_config::tr!("Today"), + Message::CalendarToday, + primary, + opacity, + ), + menu_button( + render, + ferese_config::tr!("›"), + Message::CalendarMonth(1), + primary, + opacity, + ), ], ) } diff --git a/shell/ferese-shell/src/status_ui/controls.rs b/shell/ferese-shell/src/status_ui/controls.rs index 4102384..48ba98d 100644 --- a/shell/ferese-shell/src/status_ui/controls.rs +++ b/shell/ferese-shell/src/status_ui/controls.rs @@ -82,7 +82,7 @@ pub(super) fn level_meter( opacity: f32, ) -> Element<'static, cosmic::Action> { let segment = move |amount: u16, filled: bool| { - container(render.text("")) + container(render.text(ferese_config::tr!(""))) .width(Length::FillPortion(amount)) .height(5) .class(theme::Container::custom(move |_| container::Style { diff --git a/shell/ferese-shell/src/status_ui/media.rs b/shell/ferese-shell/src/status_ui/media.rs index 828acc6..4a7d7c9 100644 --- a/shell/ferese-shell/src/status_ui/media.rs +++ b/shell/ferese-shell/src/status_ui/media.rs @@ -79,7 +79,7 @@ pub(super) fn bar<'a>( width: Option, ) -> Element<'a, cosmic::Action> { let Some(player) = &shell.media.snapshot.selected else { - return shell.render.text("").into(); + return shell.render.text(ferese_config::tr!("")).into(); }; let foreground = color(if selected { palette.accent @@ -302,7 +302,7 @@ pub(super) fn hub<'a>( return rows.push( shell .render - .text("No active player") + .text(ferese_config::tr!("No active player")) .size(14) .class(theme::Text::Color(style.muted)), ); @@ -624,7 +624,7 @@ pub(super) fn view<'a>( rows = rows.push( shell .render - .text("No active player") + .text(ferese_config::tr!("No active player")) .size(13) .class(theme::Text::Color(style.muted)), ); diff --git a/shell/ferese-shell/src/status_ui/mod.rs b/shell/ferese-shell/src/status_ui/mod.rs index e5af0cf..5f2fa62 100644 --- a/shell/ferese-shell/src/status_ui/mod.rs +++ b/shell/ferese-shell/src/status_ui/mod.rs @@ -39,6 +39,7 @@ struct MenuStyle { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Menu { + Tray, NightLight, Activities, Workspaces, @@ -90,7 +91,8 @@ impl Menu { } fn title(self) -> &'static str { - match self { + ferese_config::locale::text(match self { + Self::Tray => "Application tray", Self::NightLight => "Night light", Self::Activities => "Activities", Self::Workspaces => "Workspaces", @@ -105,12 +107,12 @@ impl Menu { Self::CaptureStatus => "Capture status", Self::Media => "Now playing", Self::Notifications => "Notifications", - } + }) } fn available(self, status: &Snapshot) -> bool { match self { - Self::NightLight => true, + Self::Tray | Self::NightLight => true, Self::Network => status.network.is_some(), Self::Bluetooth => status.bluetooth.is_some(), Self::Audio => status.audio.is_some(), @@ -207,7 +209,7 @@ impl FereseShell { { rows = rows.push(toggle_row( self.render, - "Wi-Fi", + ferese_config::tr!("Wi-Fi"), network.enabled, Action::Wifi(!network.enabled), theme, @@ -219,7 +221,7 @@ impl FereseShell { { rows = rows.push(toggle_row( self.render, - "Mute", + ferese_config::tr!("Mute"), audio.muted, Action::Mute(!audio.muted), theme, @@ -227,6 +229,7 @@ impl FereseShell { )); } let mut rows = match kind { + Menu::Tray => rows.push(self.tray_menu()), Menu::System => system::view(self, rows, style, true), Menu::NightLight => night_light::view(self, rows, style), Menu::Network => network::view(self, rows, style), @@ -245,14 +248,14 @@ impl FereseShell { if self.notifications.entries.is_empty() { rows = rows.push( self.render - .text("No notifications") + .text(ferese_config::tr!("No notifications")) .size(13) .class(theme::Text::Color(style.muted)), ); } else { rows = rows.push(menu_button( self.render, - "Clear notifications", + ferese_config::tr!("Clear notifications"), Message::ClearNotifications, style.muted, opacity, @@ -269,7 +272,7 @@ impl FereseShell { .width(Length::Fill), menu_button( self.render, - "×", + ferese_config::tr!("×"), Message::RemoveNotification(notice.id), style.muted, opacity @@ -322,7 +325,7 @@ impl FereseShell { if !self.capture_status.active() { rows = rows.push( self.render - .text("No active screen sharing or OBS output") + .text(ferese_config::tr!("No active screen sharing or OBS output")) .size(13) .class(theme::Text::Color(style.muted)), ); @@ -349,7 +352,7 @@ impl FereseShell { if !kind.available(&self.status) { rows = rows.push( self.render - .text("Service unavailable") + .text(ferese_config::tr!("Service unavailable")) .size(13) .class(theme::Text::Color(style.muted)), ); @@ -378,7 +381,7 @@ impl FereseShell { fn view_status_menu(&self) -> Element<'_, cosmic::Action> { let Some(menu) = &self.menu else { - return self.render.text("").into(); + return self.render.text(ferese_config::tr!("")).into(); }; motion::frame_driven( menu.motion.revision(), @@ -405,7 +408,7 @@ impl FereseShell { fn view_status_menu_at(&self, now: Instant) -> Element<'_, cosmic::Action> { let Some(menu) = &self.menu else { - return self.render.text("").into(); + return self.render.text(ferese_config::tr!("")).into(); }; if menu.kind == Menu::Notifications && self.notifications.ready { return cosmic::widget::autosize::autosize( @@ -461,6 +464,7 @@ impl FereseShell { rows = rows.push(heading); } rows = match kind { + Menu::Tray => rows.push(self.tray_menu()), Menu::System => system::view(self, rows, style, false), Menu::NightLight => night_light::view(self, rows, style), Menu::Network => network::view(self, rows, style), @@ -480,7 +484,7 @@ impl FereseShell { if kind != Menu::Calendar && !kind.available(&self.status) { rows = rows.push( self.render - .text("Service unavailable") + .text(ferese_config::tr!("Service unavailable")) .size(13) .class(theme::Text::Color(muted)), ); diff --git a/shell/ferese-shell/src/status_ui/network.rs b/shell/ferese-shell/src/status_ui/network.rs index cd66a22..571eee6 100644 --- a/shell/ferese-shell/src/status_ui/network.rs +++ b/shell/ferese-shell/src/status_ui/network.rs @@ -50,7 +50,7 @@ pub(super) fn view<'a>( row![ shell .render - .text("Signal strength") + .text(ferese_config::tr!("Signal strength")) .size(12) .width(Length::Fill), shell.render.text(strength).size(12) diff --git a/shell/ferese-shell/src/status_ui/night_light.rs b/shell/ferese-shell/src/status_ui/night_light.rs index eeeb8b9..4119c14 100644 --- a/shell/ferese-shell/src/status_ui/night_light.rs +++ b/shell/ferese-shell/src/status_ui/night_light.rs @@ -8,7 +8,12 @@ pub(super) fn view<'a>( style: MenuStyle, ) -> MenuRows<'a> { let Some(status) = &shell.status.night_light else { - return rows.push(shell.render.text("Night light is unavailable").size(13)); + return rows.push( + shell + .render + .text(ferese_config::tr!("Night light is unavailable")) + .size(13), + ); }; rows = rows.push(toggle_row( shell.render, @@ -29,7 +34,10 @@ pub(super) fn view<'a>( )) .step(100u16) .on_release(cosmic::Action::App(Message::CommitNightLightTemperature)), - shell.render.text("Lower temperatures are warmer").size(12), + shell + .render + .text(ferese_config::tr!("Lower temperatures are warmer")) + .size(12), ] .spacing(8); rows = rows.push(control_card( @@ -42,14 +50,16 @@ pub(super) fn view<'a>( rows = rows.push( shell .render - .text("No display supports night light") + .text(ferese_config::tr!("No display supports night light")) .size(13), ); } else if status.enabled && status.failed_outputs > 0 { rows = rows.push( shell .render - .text("Night light could not be applied to some displays") + .text(ferese_config::tr!( + "Night light could not be applied to some displays" + )) .size(12), ); } diff --git a/shell/ferese-shell/src/status_ui/system.rs b/shell/ferese-shell/src/status_ui/system.rs index 17e2377..4eaa8fd 100644 --- a/shell/ferese-shell/src/status_ui/system.rs +++ b/shell/ferese-shell/src/status_ui/system.rs @@ -56,7 +56,7 @@ pub(super) fn view<'a>( ) .opacity(p), ) - .name("Open Ferese Settings") + .name(ferese_config::tr!("Open Ferese Settings")) .width(30) .height(28) .padding([5, 3]) @@ -67,7 +67,10 @@ pub(super) fn view<'a>( ); modes = modes.push(cosmic::widget::tooltip( settings, - shell.render.text("Open Ferese Settings").size(12), + shell + .render + .text(ferese_config::tr!("Open Ferese Settings")) + .size(12), cosmic::widget::tooltip::Position::Left, )); } @@ -129,7 +132,7 @@ pub(super) fn view<'a>( .center_y(28), shell .render - .text("Battery") + .text(ferese_config::tr!("Battery")) .size(11) .width(Length::Fill) .height(16) @@ -281,7 +284,7 @@ pub(super) fn heading<'a>( color_with_opacity(theme.accent, p), )) .padding(6) - .name("Open Settings") + .name(ferese_config::tr!("Open Settings")) .on_press(cosmic::Action::App(Message::Control(Action::Settings))), primary, false, diff --git a/shell/ferese-shell/src/surfaces.rs b/shell/ferese-shell/src/surfaces.rs index 897aa90..ee84f58 100644 --- a/shell/ferese-shell/src/surfaces.rs +++ b/shell/ferese-shell/src/surfaces.rs @@ -621,7 +621,7 @@ impl FereseShell { pub(super) fn view_wallpaper(&self) -> Element<'_, cosmic::Action> { let Some(handle) = self.wallpaper.as_ref() else { - return container(self.render.text("")) + return container(self.render.text(ferese_config::tr!(""))) .width(Length::Fill) .height(Length::Fill) .class(theme::Container::custom(wallpaper_fallback_style)) diff --git a/shell/ferese-shell/src/system_modal.rs b/shell/ferese-shell/src/system_modal.rs index f0acbbd..a8b2056 100644 --- a/shell/ferese-shell/src/system_modal.rs +++ b/shell/ferese-shell/src/system_modal.rs @@ -508,10 +508,10 @@ impl FereseShell { fn view_system_modal(&self, id: window::Id) -> Element<'_, cosmic::Action> { let Some(modal) = &self.system_modal else { - return self.render.text("").into(); + return self.render.text(ferese_config::tr!("")).into(); }; let Some(surface) = modal.surfaces.iter().find(|surface| surface.id == id) else { - return self.render.text("").into(); + return self.render.text(ferese_config::tr!("")).into(); }; motion::frame_driven( modal.motion.revision(), @@ -542,10 +542,10 @@ impl FereseShell { now: Instant, ) -> Element<'_, cosmic::Action> { let Some(modal) = &self.system_modal else { - return self.render.text("").into(); + return self.render.text(ferese_config::tr!("")).into(); }; let Some(surface) = modal.surfaces.iter().find(|surface| surface.id == id) else { - return self.render.text("").into(); + return self.render.text(ferese_config::tr!("")).into(); }; let progress = modal.motion.progress_at(now); let content: Element<'_, cosmic::Action> = if surface.primary { @@ -568,7 +568,9 @@ impl FereseShell { { rows = rows.push( self.render - .text("Applications requested that this action be prevented:") + .text(ferese_config::tr!( + "Applications requested that this action be prevented:" + )) .size(13) .class(theme::Text::Color(palette.muted)), ); @@ -664,13 +666,13 @@ impl FereseShell { .saturating_sub(360) .clamp(40, 400) as f32; column![ - self.render.text("Welcome to Ferese").size(23), + self.render.text(ferese_config::tr!("Welcome to Ferese")).size(23), ferese_theme::menus::section_label("Your active shortcuts", self.render.font, palette.muted,) .size(14), container(cosmic::widget::scrollable(bindings).height(Length::Shrink)).max_height(height), self.render .text( - "Disable this guide in Settings → Shortcuts. It appears at each login until disabled." + ferese_config::tr!("Disable this guide in Settings → Shortcuts. It appears at each login until disabled.") ) .size(12) .class(theme::Text::Color(palette.muted)), @@ -718,7 +720,7 @@ impl FereseShell { })) .into() } else { - self.render.text("").into() + self.render.text(ferese_config::tr!("")).into() }; let content = if surface.primary { motion::animated( diff --git a/shell/ferese-shell/src/tray.rs b/shell/ferese-shell/src/tray.rs new file mode 100644 index 0000000..328aef6 --- /dev/null +++ b/shell/ferese-shell/src/tray.rs @@ -0,0 +1,509 @@ +//! StatusNotifierItem host on the session bus. The watcher stays subscribed when +//! icons are hidden so applications reappear immediately when enabled again. +use crate::{FereseShell, Message, status_ui}; +use cosmic::{ + Element, + app::Task, + iced::{Alignment, Length, Subscription}, + widget::{button, column, container, icon, image, row}, +}; +use ferese_config::locale::text as t; +use std::sync::Arc; +use system_tray::{ + client::{ActivateRequest, Client}, + item::{Status, StatusNotifierItem}, + menu::MenuItem, +}; + +#[derive(Clone, Debug)] +pub enum Event { + Snapshot( + Arc, + Vec<( + String, + StatusNotifierItem, + Option, + )>, + ), + Disconnected, +} +#[derive(Clone, Debug)] +pub enum Input { + Locate(String, cosmic::iced::window::Id, cosmic::widget::Id), + Located( + String, + cosmic::iced::window::Id, + Option, + ), + Activate(String), + Context( + String, + cosmic::iced::window::Id, + cosmic::iced::Rectangle, + ), + Menu(String, i32), + Manage, + Done(Result<(), String>), +} +#[derive(Default)] +pub struct Model { + client: Option>, + items: Vec, + current: Option, + expanded: std::collections::HashSet, +} +struct Entry { + address: String, + item: StatusNotifierItem, + menu: Option, + pixmap: Option, +} +impl Model { + pub fn update(&mut self, event: Event) { + match event { + Event::Snapshot(client, items) => { + self.client = Some(client); + self.items = items + .into_iter() + .map(|(address, item, menu)| { + let pixmap = pixmap(&item); + Entry { + address, + item, + menu, + pixmap, + } + }) + .collect(); + self.items.sort_by(|a, b| a.item.id.cmp(&b.item.id)); + } + Event::Disconnected => { + self.client = None; + self.items.clear(); + self.current = None; + } + } + } + pub fn available(&self, config: &ferese_config::status::Tray) -> bool { + self.visible(config).next().is_some() + } + fn visible<'a>( + &'a self, + config: &'a ferese_config::status::Tray, + ) -> impl Iterator { + self.items.iter().filter(move |e| { + config.enabled + && (config.show_passive || e.item.status != Status::Passive) + && !config.hidden_items.contains(&e.item.id) + }) + } +} +pub fn subscription() -> Subscription { + Subscription::run(|| { + cosmic::iced::stream::channel(16, async move |mut out| { + use cosmic::iced::futures::SinkExt; + loop { + if let Ok(client) = Client::new().await { + let client = Arc::new(client); + let mut receiver = client.subscribe(); + loop { + let items = client + .items() + .lock() + .map(|m| { + m.iter() + .map(|(a, (i, m))| (a.clone(), i.clone(), m.clone())) + .collect() + }) + .unwrap_or_default(); + if out + .send(Event::Snapshot(client.clone(), items)) + .await + .is_err() + { + return; + } + match receiver.recv().await { + Ok(_) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {} + Err(_) => break, + } + } + } + if out.send(Event::Disconnected).await.is_err() { + return; + } + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } + }) + }) +} +fn pixmap(item: &StatusNotifierItem) -> Option { + let data = if item.status == Status::NeedsAttention { + item.attention_icon_pixmap + .as_ref() + .or(item.icon_pixmap.as_ref()) + } else { + item.icon_pixmap.as_ref() + }?; + let p = data + .iter() + .filter(|p| { + p.width > 0 + && p.height > 0 + && p.width <= 512 + && p.height <= 512 + && p.pixels.len() == p.width as usize * p.height as usize * 4 + }) + .min_by_key(|p| (p.width - 32).abs())?; + let mut rgba = p.pixels.clone(); + for p in rgba.chunks_exact_mut(4) { + p.rotate_left(1) + } + Some(image::Handle::from_rgba( + p.width as u32, + p.height as u32, + rgba, + )) +} +impl FereseShell { + pub(crate) fn view_tray( + &self, + id: cosmic::iced::window::Id, + ) -> Element<'_, cosmic::Action> { + let size = self.config.status.tray.icon_size; + let mut icons = row([]).spacing(4).align_y(Alignment::Center); + for e in self.tray.visible(&self.config.status.tray) { + let visual: Element<'_, cosmic::Action> = if let Some(p) = &e.pixmap { + image(p.clone()).width(size).height(size).into() + } else if let Some(name) = e.item.icon_name.as_ref().filter(|n| !n.is_empty()) { + icon(icon::from_name(name.clone()).size(size as u16).handle()) + .size(size as u16) + .into() + } else { + self.render.text(ferese_config::tr!("●")).size(size).into() + }; + let address = e.address.clone(); + let title = e.item.title.as_deref().unwrap_or(&e.item.id); + let control = button::custom(container(visual).center_y(size + 8.)) + .padding([0, 3]) + .name(title.to_owned()) + .on_press_with_rectangle(move |offset, bounds| { + cosmic::Action::App(Message::TrayInput(Input::Context( + address.clone(), + id, + cosmic::iced::Rectangle { + x: (bounds.x - offset.x) as i32, + y: (bounds.y - offset.y) as i32, + width: bounds.width as i32, + height: bounds.height as i32, + }, + ))) + }); + // Left click activates normal applications; menu-only indicators open their menu. + let control = if !e.item.item_is_menu { + control.on_press(cosmic::Action::App(Message::TrayInput(Input::Activate( + e.address.clone(), + )))) + } else { + control + }; + let address = e.address.clone(); + let target = cosmic::widget::Id::unique(); + let control = cosmic::widget::mouse_area(container(control).id(target.clone())) + .on_right_press(cosmic::Action::App(Message::TrayInput(Input::Locate( + address, id, target, + )))); + // An ordinary tooltip is clipped by a layer bar and can cover the + // icon. Keep its accessible name; details live in the popover. + icons = icons.push(control); + } + icons.into() + } + pub(crate) fn tray_input(&mut self, input: Input) -> Task { + match input { + Input::Locate(address, parent, target) => { + cosmic::iced::advanced::widget::operate(FindBounds { + target, + parent, + window: None, + found: None, + }) + .map(move |rect| { + cosmic::Action::App(Message::TrayInput(Input::Located( + address.clone(), + parent, + rect, + ))) + }) + } + Input::Located(address, parent, Some(rect)) => self.tray_input(Input::Context( + address, + parent, + cosmic::iced::Rectangle { + x: rect.x as i32, + y: rect.y as i32, + width: rect.width as i32, + height: rect.height as i32, + }, + )), + Input::Located(_, _, None) => Task::none(), + Input::Context(address, parent, mut rect) => { + self.tray.current = Some(address.clone()); + self.tray.expanded.clear(); + if rect.x == 0 && rect.y == 0 { + rect.x = 20; + rect.y = 20; + } + let about = self.tray.client.clone().zip( + self.tray + .items + .iter() + .find(|i| i.address == address) + .and_then(|i| i.item.menu.clone()), + ); + let task = if let Some((client, path)) = about { + cosmic::task::future(async move { + Message::TrayInput(Input::Done( + client + .about_to_show_menuitem(address, path, 0) + .await + .map(|_| ()) + .map_err(|e| e.to_string()), + )) + }) + } else { + Task::none() + }; + Task::batch([ + task, + self.open_menu( + status_ui::Menu::Tray, + status_ui::PopoverAnchor { + parent, + panel: self.panel_for_bar(parent).id.clone(), + item: None, + rectangle: rect, + }, + ), + ]) + } + Input::Manage => { + let command = self + .config + .status + .settings_command + .clone() + .unwrap_or_else(|| vec!["ferese-settings".into()]); + if let Some(exe) = command.first() { + let _ = std::process::Command::new(exe) + .args(&command[1..]) + .args(["--page", "tray"]) + .spawn(); + } + self.close_menu() + } + Input::Activate(address) => { + if let Some(client) = self.tray.client.clone() { + cosmic::task::future(async move { + Message::TrayInput(Input::Done( + client + .activate(ActivateRequest::Default { + address, + x: 0, + y: 0, + }) + .await + .map_err(|e| e.to_string()), + )) + }) + } else { + Task::none() + } + } + Input::Menu(address, id) => { + if self + .tray + .items + .iter() + .filter(|e| e.address == address) + .filter_map(|e| e.menu.as_ref()) + .flat_map(|m| m.submenus.iter()) + .any(|m| contains_submenu(m, id)) + { + if !self.tray.expanded.remove(&id) { + self.tray.expanded.insert(id); + } + if let Some((client, path)) = self.tray.client.clone().zip( + self.tray + .items + .iter() + .find(|e| e.address == address) + .and_then(|e| e.item.menu.clone()), + ) { + return cosmic::task::future(async move { + Message::TrayInput(Input::Done( + client + .about_to_show_menuitem(address, path, id) + .await + .map(|_| ()) + .map_err(|e| e.to_string()), + )) + }); + } + return Task::none(); + } + let menu_path = self + .tray + .items + .iter() + .find(|e| e.address == address) + .and_then(|e| e.item.menu.clone()); + if let Some((client, menu_path)) = self.tray.client.clone().zip(menu_path) { + let task = cosmic::task::future(async move { + Message::TrayInput(Input::Done( + client + .activate(ActivateRequest::MenuItem { + address, + menu_path, + submenu_id: id, + }) + .await + .map_err(|e| e.to_string()), + )) + }); + Task::batch([task, self.close_menu()]) + } else { + Task::none() + } + } + Input::Done(result) => { + if let Err(e) = result { + eprintln!("tray action: {e}"); + } + Task::none() + } + } + } + pub(crate) fn tray_menu(&self) -> Element<'_, cosmic::Action> { + let mut rows = column([]).spacing(6); + if let Some(e) = self + .tray + .current + .as_ref() + .and_then(|address| self.tray.items.iter().find(|e| &e.address == address)) + { + if let Some(menu) = &e.menu { + for item in &menu.submenus { + rows = rows.push(self.tray_menu_item(&e.address, item, 0)); + } + } + rows = rows.push( + button::custom(self.render.text(t("Manage tray icons")).size(12)) + .padding(8) + .on_press(cosmic::Action::App(Message::TrayInput(Input::Manage))), + ); + } + rows.into() + } + fn tray_menu_item<'a>( + &'a self, + address: &str, + item: &'a MenuItem, + depth: usize, + ) -> Element<'a, cosmic::Action> { + if !item.visible || depth > 8 { + return cosmic::widget::Space::new().height(0).into(); + } + let title = item + .label + .as_deref() + .unwrap_or("") + .replace("__", "\0") + .replace('_', "") + .replace('\0', "_"); + let mut rows = column([]).spacing(3); + rows = rows.push( + button::custom( + self.render + .text(format!( + "{}{}", + title, + if item.submenu.is_empty() + && item.children_display.as_deref() != Some("submenu") + { + "" + } else { + " ›" + } + )) + .size(13), + ) + .padding(8) + .width(Length::Fill) + .on_press_maybe(item.enabled.then(|| { + cosmic::Action::App(Message::TrayInput(Input::Menu(address.into(), item.id))) + })), + ); + if self.tray.expanded.contains(&item.id) { + for sub in &item.submenu { + rows = rows + .push(container(self.tray_menu_item(address, sub, depth + 1)).padding([0, 12])); + } + } + rows.into() + } +} +fn contains_submenu(item: &MenuItem, id: i32) -> bool { + (item.id == id + && (!item.submenu.is_empty() || item.children_display.as_deref() == Some("submenu"))) + || item.submenu.iter().any(|s| contains_submenu(s, id)) +} +#[cfg(test)] +mod tests { + #[test] + fn lazy_submenus_are_detected_without_children() { + let item = system_tray::menu::MenuItem { + id: 7, + children_display: Some("submenu".into()), + ..Default::default() + }; + assert!(super::contains_submenu(&item, 7)); + assert!(!super::contains_submenu(&item, 8)); + } + #[test] + fn argb_conversion_keeps_alpha() { + let mut argb = [128, 2, 3, 4]; + argb.rotate_left(1); + assert_eq!(argb, [2, 3, 4, 128]); + } +} + +struct FindBounds { + target: cosmic::widget::Id, + parent: cosmic::iced::window::Id, + window: Option, + found: Option, +} +impl cosmic::iced::advanced::widget::Operation> for FindBounds { + fn traverse( + &mut self, + children: &mut dyn FnMut( + &mut dyn cosmic::iced::advanced::widget::Operation>, + ), + ) { + children(self); + } + fn set_window_id(&mut self, id: cosmic::iced::window::Id) { + self.window = Some(id); + } + fn container(&mut self, id: Option<&cosmic::widget::Id>, bounds: cosmic::iced::Rectangle) { + if self.window == Some(self.parent) && id == Some(&self.target) { + self.found = Some(bounds); + } + } + fn finish( + &self, + ) -> cosmic::iced::advanced::widget::operation::Outcome> { + cosmic::iced::advanced::widget::operation::Outcome::Some(self.found) + } +} diff --git a/shell/ferese-shell/src/workspace_ui.rs b/shell/ferese-shell/src/workspace_ui.rs index d9cc104..a828aed 100644 --- a/shell/ferese-shell/src/workspace_ui.rs +++ b/shell/ferese-shell/src/workspace_ui.rs @@ -329,7 +329,7 @@ impl FereseShell { .iter() .find(|workspace| workspace.id == hover.workspace) }) else { - return self.render.text("").into(); + return self.render.text(ferese_config::tr!("")).into(); }; let active = self .snapshot