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

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

No files matched your search

Generated
+84
View File
@@ -1696,6 +1696,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]] [[package]]
name = "either" name = "either"
version = "1.18.0" version = "1.18.0"
@@ -1828,6 +1834,7 @@ dependencies = [
"libc", "libc",
"memmap2 0.9.11", "memmap2 0.9.11",
"notify 8.2.0", "notify 8.2.0",
"schemars",
"serde", "serde",
"serde_json", "serde_json",
"smithay", "smithay",
@@ -1842,6 +1849,7 @@ dependencies = [
name = "ferese-activities" name = "ferese-activities"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"schemars",
"serde", "serde",
] ]
@@ -1850,6 +1858,7 @@ name = "ferese-animation"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ferese-layout", "ferese-layout",
"schemars",
"serde", "serde",
] ]
@@ -1863,6 +1872,7 @@ dependencies = [
"jiff", "jiff",
"kdl", "kdl",
"regex", "regex",
"schemars",
"serde", "serde",
"serde_json", "serde_json",
"tempfile", "tempfile",
@@ -1917,6 +1927,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"ferese-animation", "ferese-animation",
"ferese-layout", "ferese-layout",
"schemars",
"serde", "serde",
"serde_json", "serde_json",
] ]
@@ -2026,6 +2037,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"system-tray",
"tempfile", "tempfile",
"tokio", "tokio",
"tokio-tungstenite", "tokio-tungstenite",
@@ -2068,6 +2080,7 @@ dependencies = [
name = "ferese-theme-model" name = "ferese-theme-model"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"schemars",
"serde", "serde",
] ]
@@ -5436,6 +5449,26 @@ dependencies = [
"thiserror 2.0.21", "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]] [[package]]
name = "regex" name = "regex"
version = "1.13.1" version = "1.13.1"
@@ -5717,6 +5750,31 @@ dependencies = [
"winapi-util", "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]] [[package]]
name = "scoped-tls" name = "scoped-tls"
version = "1.0.1" version = "1.0.1"
@@ -5784,6 +5842,17 @@ dependencies = [
"syn 3.0.6", "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]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.151" version = "1.0.151"
@@ -6251,6 +6320,21 @@ dependencies = [
"version-compare", "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]] [[package]]
name = "taffy" name = "taffy"
version = "0.9.2" version = "0.9.2"
+1
View File
@@ -42,6 +42,7 @@ undocumented_unsafe_blocks = "deny"
wildcard_imports = "deny" wildcard_imports = "deny"
[workspace.dependencies] [workspace.dependencies]
schemars = { version = "1.2.3", features = ["preserve_order"] }
ferese-shape = { git = "https://github.com/ferese-wm/ferese-shape", tag = "v0.1.0" } ferese-shape = { git = "https://github.com/ferese-wm/ferese-shape", tag = "v0.1.0" }
bitflags = "2.9.4" bitflags = "2.9.4"
calloop = { version = "0.14.5", features = ["signals"] } calloop = { version = "0.14.5", features = ["signals"] }
+17
View File
@@ -71,6 +71,23 @@ inside your existing Wayland desktop.
## Configure ## Configure
Open **Control Center → Settings** or edit `~/.config/ferese/config.kdl`. 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) The [configuration reference](https://github.com/ferese-wm/ferese/wiki/Configuration)
and [example config](packaging/config.kdl) cover appearance, layouts, displays, and [example config](packaging/config.kdl) cover appearance, layouts, displays,
window rules and startup apps. window rules and startup apps.
+1
View File
@@ -9,6 +9,7 @@ rust-version.workspace = true
workspace = true workspace = true
[dependencies] [dependencies]
schemars.workspace = true
ferese-activities = { path = "../crates/ferese-activities" } ferese-activities = { path = "../crates/ferese-activities" }
ferese-panel = { path = "../crates/ferese-panel" } ferese-panel = { path = "../crates/ferese-panel" }
ferese-shape.workspace = true ferese-shape.workspace = true
+31 -20
View File
@@ -36,8 +36,10 @@ pub(crate) use tab_indicator::TabIndicatorSettings;
use crate::window_rules; use crate::window_rules;
use crate::window_rules::{WindowRule, WindowRuleConfig}; use crate::window_rules::{WindowRule, WindowRuleConfig};
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub struct Config { pub struct Config {
#[serde(default)]
ui: ferese_config::locale::Settings,
#[serde(default)] #[serde(default)]
activities: ferese_config::activities::Settings, activities: ferese_config::activities::Settings,
#[serde(default)] #[serde(default)]
@@ -79,10 +81,10 @@ pub struct Config {
#[serde(default)] #[serde(default)]
pub(crate) xwayland: XwaylandConfig, pub(crate) xwayland: XwaylandConfig,
#[serde(default, rename = "status")] #[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(crate) struct DaemonConfig {
pub command: Vec<String>, pub command: Vec<String>,
#[serde(default = "default_true")] #[serde(default = "default_true")]
@@ -93,16 +95,6 @@ pub(crate) struct DaemonConfig {
pub nested: bool, 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<bool>,
low_battery_threshold: Option<u8>,
settings_command: Option<Vec<String>>,
}
impl Config { impl Config {
#[cfg(test)] #[cfg(test)]
pub(crate) fn parse_source(source: &str) -> Result<Self, ConfigError> { pub(crate) fn parse_source(source: &str) -> Result<Self, ConfigError> {
@@ -129,6 +121,7 @@ impl Config {
} }
pub(crate) fn runtime_config(&self) -> Result<crate::RuntimeConfig, ConfigError> { pub(crate) fn runtime_config(&self) -> Result<crate::RuntimeConfig, ConfigError> {
self._status.validate().map_err(ConfigError::Binding)?;
if let Some(panels) = &self.panels { if let Some(panels) = &self.panels {
ferese_panel::validate(panels).map_err(ConfigError::Binding)?; 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 { struct WorkspacesConfig {
#[serde(default)] #[serde(default)]
auto_back_and_forth: bool, auto_back_and_forth: bool,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
struct LayoutConfig { struct LayoutConfig {
mode: Option<LayoutModeValue>, mode: Option<LayoutModeValue>,
#[serde(default = "default_inner_gap")] #[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")] #[serde(rename_all = "lowercase")]
enum LayoutModeValue { enum LayoutModeValue {
Scrolling, Scrolling,
Tree, Tree,
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
struct ScrollingConfig { struct ScrollingConfig {
#[serde(default)] #[serde(default)]
tab_indicator: tab_indicator::TabIndicatorConfig, tab_indicator: tab_indicator::TabIndicatorConfig,
@@ -258,14 +251,14 @@ struct ScrollingConfig {
width_presets: Vec<ColumnWidthValue>, width_presets: Vec<ColumnWidthValue>,
} }
#[derive(Clone, Copy, Debug, Deserialize)] #[derive(Clone, Copy, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
enum ColumnDisplayValue { enum ColumnDisplayValue {
Stacked, Stacked,
Tabbed, Tabbed,
} }
#[derive(Clone, Copy, Debug, Deserialize)] #[derive(Clone, Copy, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
enum FocusStrategyValue { enum FocusStrategyValue {
Minimal, Minimal,
@@ -273,7 +266,7 @@ enum FocusStrategyValue {
Paged, Paged,
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(untagged)] #[serde(untagged)]
enum ColumnWidthValue { enum ColumnWidthValue {
Proportion(f64), Proportion(f64),
@@ -428,6 +421,24 @@ pub(crate) fn config_path() -> Option<PathBuf> {
#[cfg(test)] #[cfg(test)]
mod tests { 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] #[test]
fn geometry_rejects_removed_and_misspelled_keys_in_both_consumption_paths() { fn geometry_rejects_removed_and_misspelled_keys_in_both_consumption_paths() {
for key in [ for key in [
+16 -14
View File
@@ -202,14 +202,14 @@ pub struct BorderGradient {
pub angle: f64, pub angle: f64,
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct BorderPaintConfig { pub(super) struct BorderPaintConfig {
#[serde(default)] #[serde(default)]
pub(super) style: ferese_config::theme::PaintStyle, pub(super) style: ferese_config::theme::PaintStyle,
pub(super) gradient: Option<BorderGradientConfig>, pub(super) gradient: Option<BorderGradientConfig>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct BorderGradientConfig { pub(super) struct BorderGradientConfig {
pub(super) from: String, pub(super) from: String,
pub(super) to: String, pub(super) to: String,
@@ -278,7 +278,9 @@ pub struct ThemeSettings {
pub reduce_transparency: bool, 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")] #[serde(rename_all = "snake_case")]
pub enum MaterialStyle { pub enum MaterialStyle {
Translucent, Translucent,
@@ -286,7 +288,7 @@ pub enum MaterialStyle {
Solid, Solid,
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub(super) struct AppearanceConfig { pub(super) struct AppearanceConfig {
#[serde(default)] #[serde(default)]
@@ -303,7 +305,7 @@ pub struct FocusEffectSettings {
pub duration_ms: f64, pub duration_ms: f64,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub(super) struct FocusEffectConfig { pub(super) struct FocusEffectConfig {
pub(super) enabled: bool, 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 { pub(super) struct ThemeConfig {
#[serde(default)] #[serde(default)]
pub(super) border: BorderPaintConfig, pub(super) border: BorderPaintConfig,
@@ -349,23 +351,23 @@ pub(super) struct ThemeConfig {
pub(super) surface: ThemeSurfaceConfig, pub(super) surface: ThemeSurfaceConfig,
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct ThemeSurfaceConfig { pub(super) struct ThemeSurfaceConfig {
#[serde(default)] #[serde(default)]
pub(super) bar: ThemeBarConfig, pub(super) bar: ThemeBarConfig,
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct ThemeBarConfig { pub(super) struct ThemeBarConfig {
pub(super) background: Option<String>, pub(super) background: Option<String>,
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct OverviewTypographyConfig { pub(super) struct OverviewTypographyConfig {
pub(super) font_family: Option<String>, pub(super) font_family: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct ThemeMaterialConfig { pub(super) struct ThemeMaterialConfig {
#[serde(default = "default_shell_opacity")] #[serde(default = "default_shell_opacity")]
pub(super) opacity: f64, pub(super) opacity: f64,
@@ -400,7 +402,7 @@ fn default_backdrop_blur() -> f64 {
12.0 12.0
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct ThemeColorsConfig { pub(super) struct ThemeColorsConfig {
#[serde(default = "default_text_primary_color")] #[serde(default = "default_text_primary_color")]
pub(super) text_primary: String, 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 { pub(super) struct ThemeShadowConfig {
#[serde(default)] #[serde(default)]
pub(super) soft: SoftShadowConfig, pub(super) soft: SoftShadowConfig,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct SoftShadowConfig { pub(super) struct SoftShadowConfig {
#[serde(default = "default_shadow_offset_y")] #[serde(default = "default_shadow_offset_y")]
pub(super) offset_y: f64, 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)] #[serde(deny_unknown_fields)]
pub(super) struct ThemeGeometryConfig { pub(super) struct ThemeGeometryConfig {
#[serde(default, rename = "control_gap")] #[serde(default, rename = "control_gap")]
+13 -2
View File
@@ -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")] #[serde(rename_all = "lowercase")]
pub(super) enum BindingMatch { pub(super) enum BindingMatch {
#[default] #[default]
@@ -843,7 +854,7 @@ pub(super) enum BindingMatch {
Pointer, Pointer,
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct BindingConfig { pub(super) struct BindingConfig {
pub(super) keys: String, pub(super) keys: String,
#[serde(default, rename = "match")] #[serde(default, rename = "match")]
+1 -1
View File
@@ -6,7 +6,7 @@ use super::{
#[cfg(test)] #[cfg(test)]
use ferese_layout::Direction; use ferese_layout::Direction;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub(crate) struct BindingModeConfig { pub(crate) struct BindingModeConfig {
name: String, name: String,
+6 -4
View File
@@ -141,7 +141,7 @@ fn default_swipe_threshold() -> u16 {
80 80
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct InputConfig { pub(super) struct InputConfig {
#[serde(default)] #[serde(default)]
pub(super) focus_follows_mouse: bool, 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 { pub(super) struct TouchpadConfig {
#[serde(default)] #[serde(default)]
pub(super) accel_speed: f64, 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")] #[serde(rename_all = "lowercase")]
pub enum AccelerationProfile { pub enum AccelerationProfile {
Adaptive, Adaptive,
Flat, Flat,
} }
#[derive(Clone, Copy, Debug, Deserialize, PartialEq)] #[derive(Clone, Copy, Debug, Deserialize, PartialEq, schemars::JsonSchema, serde::Serialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct MouseSettings { pub struct MouseSettings {
pub natural_scroll: bool, pub natural_scroll: bool,
+11 -5
View File
@@ -191,7 +191,9 @@ pub struct OutputModeRequest {
pub refresh_millihertz: Option<u32>, pub refresh_millihertz: Option<u32>,
} }
#[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")] #[serde(rename_all = "snake_case")]
pub enum OutputTransform { pub enum OutputTransform {
#[default] #[default]
@@ -211,7 +213,9 @@ pub enum OutputTransform {
Flipped270, 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")] #[serde(rename_all = "kebab-case")]
pub enum LidPolicy { pub enum LidPolicy {
#[default] #[default]
@@ -223,7 +227,9 @@ const fn default_confirm_timeout() -> u32 {
15 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")] #[serde(rename_all = "kebab-case")]
pub enum OutputLayout { pub enum OutputLayout {
InternalOnly, InternalOnly,
@@ -233,7 +239,7 @@ pub enum OutputLayout {
Mirror, Mirror,
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct OutputProfileConfig { pub(super) struct OutputProfileConfig {
pub(super) name: String, pub(super) name: String,
#[serde(default)] #[serde(default)]
@@ -248,7 +254,7 @@ pub(super) struct OutputProfileConfig {
pub(super) outputs: Vec<OutputConfig>, pub(super) outputs: Vec<OutputConfig>,
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct OutputConfig { pub(super) struct OutputConfig {
#[serde(default)] #[serde(default)]
pub(super) auto_refresh: bool, pub(super) auto_refresh: bool,
+1 -1
View File
@@ -1,6 +1,6 @@
use super::{Config, ConfigError, Deserialize, parse_color}; use super::{Config, ConfigError, Deserialize, parse_color};
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub(super) struct TabIndicatorConfig { pub(super) struct TabIndicatorConfig {
width: f64, width: f64,
+5 -3
View File
@@ -2,7 +2,9 @@ use std::path::PathBuf;
use serde::Deserialize; 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")] #[serde(rename_all = "kebab-case")]
pub(crate) enum XwaylandStartup { pub(crate) enum XwaylandStartup {
#[default] #[default]
@@ -10,7 +12,7 @@ pub(crate) enum XwaylandStartup {
Eager, Eager,
} }
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub(crate) struct XwaylandConfig { pub(crate) struct XwaylandConfig {
pub enabled: bool, pub enabled: bool,
@@ -79,7 +81,7 @@ mod tests {
#[test] #[test]
fn startup_mode_accepts_kebab_case() { fn startup_mode_accepts_kebab_case() {
#[derive(Deserialize)] #[derive(Deserialize, schemars::JsonSchema, serde::Serialize)]
struct Wrapper { struct Wrapper {
startup: XwaylandStartup, startup: XwaylandStartup,
} }
+6 -2
View File
@@ -11,7 +11,9 @@ use smithay::wayland::shell::xdg::XdgToplevelSurfaceData;
use crate::Ferese; 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)] #[serde(default, deny_unknown_fields)]
pub(crate) struct Settings { pub(crate) struct Settings {
pub fullscreen_playback: bool, 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")] #[serde(rename_all = "kebab-case")]
pub enum Mode { pub enum Mode {
None, None,
+16
View File
@@ -65,12 +65,28 @@ use tracing::{info, warn};
use crate::backends::LaunchConfig; 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<dyn Error>> { fn main() -> Result<(), Box<dyn Error>> {
init_logging(); init_logging();
// Settings validates a candidate before atomically replacing the user's file. // Settings validates a candidate before atomically replacing the user's file.
// This mode never opens a display, input device, or compositor socket. // This mode never opens a display, input device, or compositor socket.
let args = std::env::args_os().skip(1).collect::<Vec<_>>(); let args = std::env::args_os().skip(1).collect::<Vec<_>>();
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.first().is_some_and(|arg| arg == "--check-config") {
if args.len() != 2 { if args.len() != 2 {
return Err("usage: ferese --check-config PATH".into()); return Err("usage: ferese --check-config PATH".into());
+1 -1
View File
@@ -17,7 +17,7 @@ use smithay::wayland::session_lock::{
use crate::Ferese; use crate::Ferese;
#[derive(Clone, Copy, Debug, serde::Deserialize)] #[derive(Clone, Copy, Debug, serde::Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(default)] #[serde(default)]
pub(crate) struct IdleSettings { pub(crate) struct IdleSettings {
pub dim_after_seconds: u64, pub dim_after_seconds: u64,
+4 -2
View File
@@ -27,7 +27,7 @@ use std::sync::Arc;
const UPLOAD_RETRY_DELAY: Duration = Duration::from_secs(5); 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 { pub(crate) struct WallpaperConfig {
#[serde(default = "default_wallpaper_path")] #[serde(default = "default_wallpaper_path")]
pub path: Option<PathBuf>, pub path: Option<PathBuf>,
@@ -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")] #[serde(rename_all = "snake_case")]
pub(crate) enum WallpaperMode { pub(crate) enum WallpaperMode {
#[default] #[default]
+1 -1
View File
@@ -1,6 +1,6 @@
use serde::Deserialize; use serde::Deserialize;
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(crate) struct WindowRuleConfig { pub(crate) struct WindowRuleConfig {
pub app_id: Option<String>, pub app_id: Option<String>,
pub title: Option<String>, pub title: Option<String>,
+1
View File
@@ -9,4 +9,5 @@ rust-version.workspace = true
workspace = true workspace = true
[dependencies] [dependencies]
schemars.workspace = true
serde.workspace = true serde.workspace = true
+4 -4
View File
@@ -3,7 +3,7 @@
use crate::Time; use crate::Time;
use serde::{Deserialize, Serialize}; 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)] #[serde(default, deny_unknown_fields)]
pub struct Settings { pub struct Settings {
pub work_minutes: u16, pub work_minutes: u16,
@@ -46,7 +46,7 @@ impl Settings {
}) * 60_000 }) * 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")] #[serde(rename_all = "kebab-case")]
pub enum Phase { pub enum Phase {
Work, 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")] #[serde(rename_all = "kebab-case")]
pub enum Status { pub enum Status {
Running, Running,
@@ -71,7 +71,7 @@ pub enum Status {
Ready, Ready,
Finished, Finished,
} }
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
pub struct Session { pub struct Session {
pub settings: Settings, pub settings: Settings,
pub phase: Phase, pub phase: Phase,
+13 -9
View File
@@ -7,14 +7,18 @@ const BOOST_MS: u64 = 5_000;
pub const MAX_TEXT: usize = 256; pub const MAX_TEXT: usize = 256;
pub const MAX_LEASE_MS: u64 = 300_000; 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)] #[serde(deny_unknown_fields)]
pub struct Owner { pub struct Owner {
pub pid: u32, pub pid: u32,
pub birth: u64, 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)] #[serde(deny_unknown_fields)]
pub struct Id { pub struct Id {
pub owner: Owner, pub owner: Owner,
@@ -23,7 +27,7 @@ pub struct Id {
pub session: u64, pub session: u64,
} }
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde( #[serde(
tag = "kind", tag = "kind",
content = "value", content = "value",
@@ -52,7 +56,7 @@ impl Value {
} }
} }
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde( #[serde(
tag = "kind", tag = "kind",
content = "data", content = "data",
@@ -66,7 +70,7 @@ pub enum Template {
List { rows: Vec<String> }, List { rows: Vec<String> },
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] #[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub enum Source { pub enum Source {
Publisher, Publisher,
@@ -79,7 +83,7 @@ pub enum Source {
Process, Process,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] #[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub enum State { pub enum State {
Running, Running,
@@ -88,7 +92,7 @@ pub enum State {
Cancelled, Cancelled,
} }
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Data { pub struct Data {
pub source: Source, 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)] #[serde(default, deny_unknown_fields)]
pub struct Limits { pub struct Limits {
pub max_visible: usize, 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 struct Activity {
pub id: Id, pub id: Id,
pub data: Data, pub data: Data,
+1
View File
@@ -9,5 +9,6 @@ rust-version.workspace = true
workspace = true workspace = true
[dependencies] [dependencies]
schemars.workspace = true
ferese-layout = { path = "../ferese-layout" } ferese-layout = { path = "../ferese-layout" }
serde.workspace = true serde.workspace = true
+13 -2
View File
@@ -1,7 +1,9 @@
use crate::{CrossingPolicy, SpringConfig}; use crate::{CrossingPolicy, SpringConfig};
/// One configuration contract for compositor and out-of-process shell motion. /// 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)] #[serde(default, deny_unknown_fields)]
pub struct MotionSettings { pub struct MotionSettings {
pub enabled: bool, 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)] #[serde(default, deny_unknown_fields)]
pub struct SpringSettings { pub struct SpringSettings {
pub duration_ms: Option<f64>, pub duration_ms: Option<f64>,
+1
View File
@@ -9,6 +9,7 @@ rust-version.workspace = true
workspace = true workspace = true
[dependencies] [dependencies]
schemars.workspace = true
ferese-activities = { path = "../ferese-activities" } ferese-activities = { path = "../ferese-activities" }
regex = "1.12" regex = "1.12"
ferese-panel = { path = "../ferese-panel" } ferese-panel = { path = "../ferese-panel" }
+571
View File
@@ -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 主程序与设置程序安装在同一目录。"
}
+4 -4
View File
@@ -8,7 +8,7 @@ use serde_json::{Map, Value, json};
use crate::Error; use crate::Error;
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct Settings { pub struct Settings {
pub max_visible: usize, 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)] #[serde(default, deny_unknown_fields)]
pub struct Sources { pub struct Sources {
pub shell_hook: bool, pub shell_hook: bool,
pub process_watch: bool, pub process_watch: bool,
pub notification_bridge: bool, pub notification_bridge: bool,
} }
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Rule { pub struct Rule {
pub name: String, pub name: String,
@@ -59,7 +59,7 @@ pub struct Rule {
pub flash_on_fail: bool, pub flash_on_fail: bool,
pub result: ResultText, pub result: ResultText,
} }
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct ResultText { pub struct ResultText {
pub ok: String, pub ok: String,
+148
View File
@@ -72,6 +72,7 @@ fn field(name: &str, parent: &str) -> String {
} }
match name { match name {
"rule" if parent == "activities" => "rules".into(),
"binding" => "bindings".into(), "binding" => "bindings".into(),
"binding-mode" => "binding_modes".into(), "binding-mode" => "binding_modes".into(),
"window-rule" => "window_rules".into(), "window-rule" => "window_rules".into(),
@@ -99,6 +100,7 @@ fn is_records(key: &str, parent: &str) -> bool {
| ("binding_modes", "bindings") | ("binding_modes", "bindings")
| ("start" | "center" | "end", "groups") | ("start" | "center" | "end", "groups")
| ("groups", "items") | ("groups", "items")
| ("activities", "rules")
) )
} }
@@ -112,6 +114,8 @@ fn is_array(key: &str, parent: &str) -> bool {
| "width_presets" | "width_presets"
| "xkb_options" | "xkb_options"
| "settings_command" | "settings_command"
| "hidden_items"
| "ignore_parents"
| "outputs" | "outputs"
) )
} }
@@ -330,6 +334,20 @@ impl Document {
Some(value) 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> { fn refresh(&mut self) -> Result<(), Error> {
self.value = Value::Object(object(&self.doc, "")?); self.value = Value::Object(object(&self.doc, "")?);
Ok(()) Ok(())
@@ -535,6 +553,93 @@ impl Document {
} }
} }
fn reconcile(
doc: &mut KdlDocument,
path: &[String],
old: &Value,
new: &Value,
) -> Result<(), Error> {
if old == new {
return Ok(());
}
if let (Some(before), Some(after)) = (old.as_object(), new.as_object()) {
for key in before.keys().filter(|key| !after.contains_key(*key)) {
let mut child = path.to_vec();
child.push(key.clone());
remove_value(doc, &child)?;
}
for (key, value) in after {
let mut child = path.to_vec();
child.push(key.clone());
reconcile(doc, &child, before.get(key).unwrap_or(&Value::Null), value)?;
}
return Ok(());
}
let refs = path.iter().map(String::as_str).collect::<Vec<_>>();
if new.is_null() {
return remove_value(doc, path);
}
let records = path.last().is_some_and(|key| {
is_records(
key,
path.get(path.len().wrapping_sub(2))
.map_or("", String::as_str),
)
});
// Activity parsing resolves built-in rules even when not authored. Rewrite
// that rule collection together so its intermediate shape is never invalid.
let activity_rules = path == ["activities", "rules"];
if records
&& !activity_rules
&& let (Some(before), Some(after)) = (old.as_array(), new.as_array())
&& before.len() == after.len()
&& before.iter().all(Value::is_object)
&& after.iter().all(Value::is_object)
{
for (index, (before, after)) in before.iter().zip(after).enumerate() {
let mut child = path.to_vec();
child.push(index.to_string());
reconcile(doc, &child, before, after)?;
}
return Ok(());
}
set_in(doc, &refs, "", new.clone())
}
fn remove_value(doc: &mut KdlDocument, path: &[String]) -> Result<(), Error> {
let refs = path.iter().map(String::as_str).collect::<Vec<_>>();
if unset_property(doc, &refs, "")? {
return Ok(());
}
let (name, parents) = refs
.split_last()
.ok_or_else(|| Error("Cannot remove the configuration root".into()))?;
let parent = parents
.iter()
.rev()
.find(|p| p.parse::<usize>().is_err())
.copied()
.unwrap_or("");
let section = section_mut(doc, parents, "")?;
let indices = section
.nodes()
.iter()
.enumerate()
.filter_map(|(index, node)| (field(node.name().value(), parent) == *name).then_some(index))
.collect::<Vec<_>>();
let mut comments = String::new();
for index in indices.into_iter().rev() {
let removed = section.nodes_mut().remove(index);
collect_comments(&removed, &mut comments);
}
if !comments.is_empty() {
let mut format = section.format().cloned().unwrap_or_default();
format.trailing.push_str(&comments);
section.set_format(format);
}
Ok(())
}
fn collect_comments(node: &KdlNode, output: &mut String) { fn collect_comments(node: &KdlNode, output: &mut String) {
let mut keep = |text: &str| { let mut keep = |text: &str| {
if text.contains("//") || text.contains("/*") { if text.contains("//") || text.contains("/*") {
@@ -917,6 +1022,7 @@ fn node_name(key: &str, parent: &str) -> String {
"panels" => "panel".into(), "panels" => "panel".into(),
"groups" => "group".into(), "groups" => "group".into(),
"items" => "item".into(), "items" => "item".into(),
"rules" if parent == "activities" => "rule".into(),
_ => key.replace('_', "-"), _ => key.replace('_', "-"),
} }
} }
@@ -927,6 +1033,7 @@ fn value_node(key: &str, value: &Value, parent: &str) -> Result<KdlNode, Error>
match value { match value {
Value::Object(map) => { Value::Object(map) => {
let positions: &[&str] = match key { let positions: &[&str] = match key {
"rules" if parent == "activities" => &["name"],
"bindings" => &["keys", "action", "argument"], "bindings" => &["keys", "action", "argument"],
"binding_modes" => &["name"], "binding_modes" => &["name"],
"output_profiles" => &["name"], "output_profiles" => &["name"],
@@ -947,6 +1054,11 @@ fn value_node(key: &str, value: &Value, parent: &str) -> Result<KdlNode, Error>
} }
for (name, value) in map { 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()) { if consumed.contains(&name.as_str()) {
continue; continue;
} }
@@ -959,6 +1071,7 @@ fn value_node(key: &str, value: &Value, parent: &str) -> Result<KdlNode, Error>
} }
} else if is_records(key, parent) } else if is_records(key, parent)
&& map.len() <= 4 && map.len() <= 4
&& !(key == "rules" && parent == "activities")
&& !value.is_array() && !value.is_array()
&& !value.is_object() && !value.is_object()
{ {
@@ -1007,6 +1120,41 @@ pub fn from_str<T: DeserializeOwned>(source: &str) -> Result<T, Error> {
mod tests { mod tests {
use super::*; 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] #[test]
fn named_binding_modes_round_trip_and_keep_nested_bindings_editable() { fn named_binding_modes_round_trip_and_keep_nested_bindings_editable() {
let mut document = super::Document::parse( let mut document = super::Document::parse(
+2
View File
@@ -3,9 +3,11 @@ pub mod activities;
mod document; mod document;
pub mod families; pub mod families;
pub mod file; pub mod file;
pub mod locale;
pub mod night_light; pub mod night_light;
pub mod notifications; pub mod notifications;
pub mod presets; pub mod presets;
pub mod status;
pub mod theme; pub mod theme;
pub use document::*; pub use document::*;
+152
View File
@@ -0,0 +1,152 @@
//! Ferese interface language; independent of keyboard layout and system locale.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::{
OnceLock,
atomic::{AtomicU8, Ordering},
};
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
)]
pub enum Language {
#[default]
#[serde(rename = "auto")]
Auto,
#[serde(rename = "en", alias = "en-US", alias = "en_US")]
English,
#[serde(rename = "zh-CN", alias = "zh_CN", alias = "zh-Hans", alias = "zh")]
Chinese,
}
impl Language {
#[must_use]
pub const fn key(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::English => "en",
Self::Chinese => "zh-CN",
}
}
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub language: Language,
}
static LANGUAGE: AtomicU8 = AtomicU8::new(0);
pub fn set(language: Language) {
LANGUAGE.store(
match language {
Language::Auto => 0,
Language::English => 1,
Language::Chinese => 2,
},
Ordering::Relaxed,
);
}
#[must_use]
pub fn configured() -> Language {
match LANGUAGE.load(Ordering::Relaxed) {
1 => Language::English,
2 => Language::Chinese,
_ => Language::Auto,
}
}
#[must_use]
pub fn language_for_locale(locale: &str) -> Language {
if locale.to_ascii_lowercase().starts_with("zh") {
Language::Chinese
} else {
Language::English
}
}
#[must_use]
pub fn resolved() -> Language {
match configured() {
Language::Auto => {
static SYSTEM: OnceLock<Language> = OnceLock::new();
*SYSTEM.get_or_init(|| {
let locale = ["LC_ALL", "LC_MESSAGES", "LANGUAGE", "LANG"]
.into_iter()
.find_map(|key| std::env::var(key).ok().filter(|value| !value.is_empty()))
.unwrap_or_else(|| "en".into());
language_for_locale(&locale)
})
}
language => language,
}
}
pub fn from_document(document: &crate::Document) -> Language {
document
.get("ui.language")
.cloned()
.and_then(|value| serde_json::from_value(value).ok())
.unwrap_or_default()
}
pub fn load(path: Option<&std::path::Path>) {
let language = path
.and_then(|path| std::fs::read_to_string(path).ok())
.and_then(|source| crate::Document::parse(&source).ok())
.map(|doc| from_document(&doc))
.unwrap_or_default();
set(language);
}
fn chinese() -> &'static BTreeMap<String, String> {
static CATALOG: OnceLock<BTreeMap<String, String>> = OnceLock::new();
CATALOG.get_or_init(|| {
serde_json::from_str(include_str!("../locales/zh-CN.json"))
.expect("valid built-in Chinese catalog")
})
}
#[must_use]
pub fn in_language<'a>(language: Language, key: &'a str) -> &'a str {
if language == Language::Chinese {
chinese().get(key).map_or(key, String::as_str)
} else {
key
}
}
#[must_use]
pub fn translate(key: &str) -> String {
in_language(resolved(), key).to_owned()
}
#[must_use]
pub fn text(key: &'static str) -> &'static str {
in_language(resolved(), key)
}
#[must_use]
pub fn format(key: &str, arguments: &[(&str, String)]) -> String {
let mut result = translate(key);
for (name, value) in arguments {
result = result.replace(&format!("{{{name}}}"), value);
}
result
}
#[macro_export]
macro_rules! tr {
($key:literal) => {
$crate::locale::text($key)
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn locale_choice_does_not_rewrite_identifiers_or_unknown_values() {
assert_eq!(language_for_locale("zh_CN.UTF-8"), Language::Chinese);
assert_eq!(language_for_locale("en_US.UTF-8"), Language::English);
assert_eq!(language_for_locale("C.UTF-8"), Language::English);
assert_eq!(in_language(Language::Chinese, "Appearance"), "外观与设置");
assert_eq!(in_language(Language::English, "Appearance"), "Appearance");
assert_eq!(
in_language(Language::Chinese, "org.example.CustomApp"),
"org.example.CustomApp"
);
assert!(serde_json::from_str::<Language>("\"unsupported\"").is_err());
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize}; 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)] #[serde(default, deny_unknown_fields)]
pub struct Settings { pub struct Settings {
pub enabled: bool, pub enabled: bool,
+1 -1
View File
@@ -1,6 +1,6 @@
use serde::Deserialize; use serde::Deserialize;
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct NotificationConfig { pub struct NotificationConfig {
pub show_popups: bool, pub show_popups: bool,
+49
View File
@@ -0,0 +1,49 @@
//! Shared shell status/tray configuration, validated before a reload is accepted.
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub keybinding_guide: bool,
pub low_battery_threshold: u8,
pub settings_command: Option<Vec<String>>,
pub tray: Tray,
}
impl Default for Settings {
fn default() -> Self {
Self {
keybinding_guide: true,
low_battery_threshold: 20,
settings_command: Some(vec!["ferese-settings".into()]),
tray: Tray::default(),
}
}
}
impl Settings {
pub fn validate(&self) -> Result<(), String> {
if self.low_battery_threshold > 100 {
return Err("low-battery-threshold must be between 0 and 100".into());
}
if !self.tray.icon_size.is_finite() || !(8.0..=48.0).contains(&self.tray.icon_size) {
return Err("tray icon-size must be between 8 and 48 pixels".into());
}
Ok(())
}
}
#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct Tray {
pub enabled: bool,
pub show_passive: bool,
pub icon_size: f32,
pub hidden_items: Vec<String>,
}
impl Default for Tray {
fn default() -> Self {
Self {
enabled: true,
show_passive: false,
icon_size: 16.,
hidden_items: vec![],
}
}
}
+7 -5
View File
@@ -12,7 +12,7 @@ pub use ferese_theme_model::*;
pub const TRANSITION_MS: u64 = 250; pub const TRANSITION_MS: u64 = 250;
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
#[serde(default)] #[serde(default)]
pub struct Selection { pub struct Selection {
pub family: Option<String>, pub family: Option<String>,
@@ -33,7 +33,9 @@ impl Default for Selection {
} }
} }
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] #[derive(
Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq, schemars::JsonSchema,
)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub enum AutoSource { pub enum AutoSource {
System, System,
@@ -47,7 +49,7 @@ pub struct AutoContext {
pub system: Option<Appearance>, pub system: Option<Appearance>,
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
#[serde(default)] #[serde(default)]
pub struct Schedule { pub struct Schedule {
pub source: AutoSource, 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 struct CustomTheme {
pub file: PathBuf, pub file: PathBuf,
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
#[serde(default)] #[serde(default)]
pub struct Policy { pub struct Policy {
pub family: Option<String>, pub family: Option<String>,
+1
View File
@@ -9,6 +9,7 @@ rust-version.workspace = true
workspace = true workspace = true
[dependencies] [dependencies]
schemars.workspace = true
ferese-animation = { path = "../ferese-animation" } ferese-animation = { path = "../ferese-animation" }
ferese-layout = { path = "../ferese-layout" } ferese-layout = { path = "../ferese-layout" }
serde.workspace = true serde.workspace = true
+1 -1
View File
@@ -8,7 +8,7 @@ use crate::motion::{Geometry, Motion};
const FADE: Duration = Duration::from_millis(120); const FADE: Duration = Duration::from_millis(120);
const CONTENT_DELAY: Duration = Duration::from_millis(130); 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)] #[serde(default, deny_unknown_fields)]
pub struct Profile { pub struct Profile {
pub peek_on_hover: bool, pub peek_on_hover: bool,
+35 -16
View File
@@ -20,11 +20,13 @@ fn padding() -> [u16; 2] {
[2, 3] [2, 3]
} }
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(transparent)] #[serde(transparent)]
pub struct PanelId(pub String); 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")] #[serde(rename_all = "lowercase")]
pub enum Edge { pub enum Edge {
#[default] #[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)] #[serde(transparent)]
pub struct GroupId(pub String); 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)] #[serde(transparent)]
pub struct ItemId(pub String); 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")] #[serde(rename_all = "kebab-case")]
pub enum Representation { pub enum Representation {
Wide, Wide,
@@ -58,7 +62,9 @@ pub enum Representation {
Icon, 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")] #[serde(rename_all = "kebab-case")]
pub enum OverflowPolicy { pub enum OverflowPolicy {
#[default] #[default]
@@ -67,7 +73,9 @@ pub enum OverflowPolicy {
Always, 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")] #[serde(rename_all = "kebab-case")]
pub enum WorkspaceStyle { pub enum WorkspaceStyle {
Numbers, 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")] #[serde(tag = "kind", rename_all = "kebab-case")]
pub enum ItemKind { pub enum ItemKind {
Overview, Overview,
@@ -122,6 +130,7 @@ pub enum ItemKind {
Media, Media,
Activities, Activities,
QuickSettings, QuickSettings,
Tray,
NightLight, NightLight,
Network, Network,
Audio, Audio,
@@ -158,6 +167,7 @@ impl ItemKind {
Self::Media => "Media", Self::Media => "Media",
Self::Activities => "Activities", Self::Activities => "Activities",
Self::QuickSettings => "Quick Settings", Self::QuickSettings => "Quick Settings",
Self::Tray => "Application tray",
Self::NightLight => "Night light", Self::NightLight => "Night light",
Self::Network => "Network", Self::Network => "Network",
Self::Audio => "Audio", 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 struct Item {
pub id: ItemId, pub id: ItemId,
#[serde(flatten)] #[serde(flatten)]
@@ -241,7 +251,7 @@ impl<'de> Deserialize<'de> for Item {
"percentage is only supported by Battery items", "percentage is only supported by Battery items",
)); ));
} }
#[derive(Deserialize)] #[derive(Deserialize, schemars::JsonSchema, serde::Serialize)]
struct Fields { struct Fields {
id: ItemId, id: ItemId,
#[serde(flatten)] #[serde(flatten)]
@@ -325,6 +335,7 @@ impl Item {
match self.kind { match self.kind {
ItemKind::Activities => availability.activities, ItemKind::Activities => availability.activities,
ItemKind::Media => availability.media, ItemKind::Media => availability.media,
ItemKind::Tray => availability.tray,
ItemKind::CaptureStatus => availability.capture, ItemKind::CaptureStatus => availability.capture,
ItemKind::Network => availability.network, ItemKind::Network => availability.network,
ItemKind::Audio => availability.audio, ItemKind::Audio => availability.audio,
@@ -338,6 +349,7 @@ impl Item {
#[derive(Clone, Copy, Debug, Default)] #[derive(Clone, Copy, Debug, Default)]
pub struct Availability { pub struct Availability {
pub tray: bool,
pub capture: bool, pub capture: bool,
pub media: bool, pub media: bool,
pub activities: bool, pub activities: bool,
@@ -348,7 +360,9 @@ pub struct Availability {
pub external_display: bool, 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")] #[serde(rename_all = "kebab-case")]
pub enum PanelSurface { pub enum PanelSurface {
None, None,
@@ -356,7 +370,9 @@ pub enum PanelSurface {
Solid, 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")] #[serde(rename_all = "kebab-case")]
pub enum GroupSurface { pub enum GroupSurface {
#[default] #[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)] #[serde(deny_unknown_fields)]
pub struct Group { pub struct Group {
pub id: GroupId, 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)] #[serde(default, deny_unknown_fields)]
pub struct Zone { pub struct Zone {
pub groups: Vec<Group>, pub groups: Vec<Group>,
pub spacing: f32, pub spacing: f32,
} }
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Panel { pub struct Panel {
pub id: PanelId, pub id: PanelId,
@@ -700,6 +716,7 @@ impl Panel {
let controls = vec![ let controls = vec![
item("media", Media), item("media", Media),
item("quick-settings", QuickSettings), item("quick-settings", QuickSettings),
item("tray", Tray),
item("night-light", NightLight), item("night-light", NightLight),
item("network", Network), item("network", Network),
item("audio", Audio), item("audio", Audio),
@@ -820,6 +837,7 @@ mod tests {
"focused-window", "focused-window",
"media", "media",
"quick-settings", "quick-settings",
"tray",
"night-light", "night-light",
"network", "network",
"audio", "audio",
@@ -866,6 +884,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
visible(Availability { visible(Availability {
tray: true,
capture: true, capture: true,
activities: true, activities: true,
media: true, media: true,
@@ -879,7 +898,7 @@ mod tests {
.map(|item| item.id.0.as_str()) .map(|item| item.id.0.as_str())
.collect::<Vec<_>>() .collect::<Vec<_>>()
); );
assert_eq!(items(&panel).len(), 14); assert_eq!(items(&panel).len(), 15);
} }
#[test] #[test]
+2 -2
View File
@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize}; 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)] #[serde(default, deny_unknown_fields)]
pub struct GeometryIntent { pub struct GeometryIntent {
#[serde(skip_serializing_if = "Option::is_none")] #[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. /// 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)] #[serde(default, deny_unknown_fields)]
pub struct PanelGeometry { pub struct PanelGeometry {
pub height: f32, pub height: f32,
+9
View File
@@ -4,6 +4,15 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct CornerRadii(pub [f32; 4]); 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 { impl CornerRadii {
#[must_use] #[must_use]
pub fn uniform(radius: f32) -> Self { pub fn uniform(radius: f32) -> Self {
+3 -1
View File
@@ -2,7 +2,9 @@ use crate::{CornerRadii, Edge};
use ferese_layout::Rect; use ferese_layout::Rect;
use serde::{Deserialize, Serialize}; 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")] #[serde(rename_all = "kebab-case")]
pub enum Style { pub enum Style {
Floating, Floating,
+54 -7
View File
@@ -83,6 +83,22 @@ fn parse_numbers(text: &str) -> Result<Vec<u32>, Box<dyn Error>> {
delegate_noop!(State: ignore pointer_manager::ZwlrVirtualPointerManagerV1); delegate_noop!(State: ignore pointer_manager::ZwlrVirtualPointerManagerV1);
delegate_noop!(State: ignore virtual_pointer::ZwlrVirtualPointerV1); delegate_noop!(State: ignore virtual_pointer::ZwlrVirtualPointerV1);
fn named_keysym(name: &str) -> Result<u32, String> {
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<dyn Error>> { fn main() -> Result<(), Box<dyn Error>> {
if std::env::var("FERESE_TEST_INPUT").as_deref() != Ok("1") { if std::env::var("FERESE_TEST_INPUT").as_deref() != Ok("1") {
return Err( return Err(
@@ -122,16 +138,13 @@ fn main() -> Result<(), Box<dyn Error>> {
sequences.push(vec![character as u32]); sequences.push(vec![character as u32]);
} }
} }
"--key" => sequences.push(vec![ "--key" => sequences.push(vec![named_keysym(&args.next().ok_or("missing key")?)?]),
xkb::keysym_from_name(&args.next().ok_or("missing key")?, xkb::KEYSYM_NO_FLAGS)
.raw(),
]),
"--chord" => sequences.push( "--chord" => sequences.push(
args.next() args.next()
.ok_or("missing chord")? .ok_or("missing chord")?
.split('+') .split('+')
.map(|name| xkb::keysym_from_name(name, xkb::KEYSYM_NO_FLAGS).raw()) .map(named_keysym)
.collect(), .collect::<Result<Vec<_>, _>>()?,
), ),
_ => return Err(format!("unknown test input argument {arg}").into()), _ => return Err(format!("unknown test input argument {arg}").into()),
} }
@@ -191,7 +204,7 @@ fn main() -> Result<(), Box<dyn Error>> {
xkb::KEYMAP_COMPILE_NO_FLAGS, xkb::KEYMAP_COMPILE_NO_FLAGS,
) )
.ok_or("US test keymap missing")?; .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 text = keymap.get_as_string(xkb::KEYMAP_FORMAT_TEXT_V1) + "\0";
let mut file = tempfile::tempfile()?; let mut file = tempfile::tempfile()?;
file.write_all(text.as_bytes())?; file.write_all(text.as_bytes())?;
@@ -211,11 +224,25 @@ fn main() -> Result<(), Box<dyn Error>> {
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
for key in &codes { for key in &codes {
keyboard.key(started.elapsed().as_millis() as u32, *key, 1); 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)?; queue.roundtrip(&mut state)?;
std::thread::sleep(Duration::from_millis(35)); std::thread::sleep(Duration::from_millis(35));
} }
for key in codes.iter().rev() { for key in codes.iter().rev() {
keyboard.key(started.elapsed().as_millis() as u32, *key, 0); 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)?; queue.roundtrip(&mut state)?;
std::thread::sleep(Duration::from_millis(35)); std::thread::sleep(Duration::from_millis(35));
} }
@@ -302,3 +329,23 @@ fn main() -> Result<(), Box<dyn Error>> {
println!("nested test input complete"); println!("nested test input complete");
Ok(()) 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());
}
}
+1
View File
@@ -9,4 +9,5 @@ rust-version.workspace = true
workspace = true workspace = true
[dependencies] [dependencies]
schemars.workspace = true
serde.workspace = true serde.workspace = true
+2 -2
View File
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use crate::{Appearance, Tokens}; use crate::{Appearance, Tokens};
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Palette { pub struct Palette {
pub background: String, pub background: String,
pub bar: 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 struct Family {
pub id: String, pub id: String,
pub name: String, pub name: String,
+23 -17
View File
@@ -2,7 +2,9 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize}; 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")] #[serde(rename_all = "lowercase")]
pub enum Appearance { pub enum Appearance {
Light, Light,
@@ -10,7 +12,9 @@ pub enum Appearance {
Dark, 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")] #[serde(rename_all = "lowercase")]
pub enum Mode { pub enum Mode {
Light, Light,
@@ -19,14 +23,14 @@ pub enum Mode {
Auto, Auto,
} }
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] #[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq, schemars::JsonSchema)]
#[serde(default)] #[serde(default)]
pub struct Accessibility { pub struct Accessibility {
pub increase_contrast: bool, pub increase_contrast: bool,
pub reduce_transparency: bool, pub reduce_transparency: bool,
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Colors { pub struct Colors {
pub surface_base: String, pub surface_base: String,
pub surface_raised: String, pub surface_raised: String,
@@ -39,7 +43,7 @@ pub struct Colors {
pub shadow: String, pub shadow: String,
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Material { pub struct Material {
pub style: String, pub style: String,
pub opacity: f64, pub opacity: f64,
@@ -47,7 +51,7 @@ pub struct Material {
pub tint_strength: f64, pub tint_strength: f64,
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Geometry { pub struct Geometry {
pub border_width: f64, pub border_width: f64,
pub focus_ring_width: f64, pub focus_ring_width: f64,
@@ -56,50 +60,52 @@ pub struct Geometry {
pub control_gap: f64, pub control_gap: f64,
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Typography { pub struct Typography {
pub font_family: String, pub font_family: String,
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Background { pub struct Background {
pub path: Option<PathBuf>, pub path: Option<PathBuf>,
pub lock_path: Option<PathBuf>, pub lock_path: Option<PathBuf>,
pub mode: String, pub mode: String,
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct BarSurface { pub struct BarSurface {
pub background: String, pub background: String,
pub text_primary: String, pub text_primary: String,
pub text_muted: String, pub text_muted: String,
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Surfaces { pub struct Surfaces {
pub bar: BarSurface, pub bar: BarSurface,
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct SoftShadow { pub struct SoftShadow {
pub offset_y: f64, pub offset_y: f64,
pub blur: f64, pub blur: f64,
pub opacity: f64, pub opacity: f64,
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Shadows { pub struct Shadows {
pub soft: SoftShadow, pub soft: SoftShadow,
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Gradient { pub struct Gradient {
pub from: String, pub from: String,
pub to: String, pub to: String,
pub angle: f64, 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")] #[serde(rename_all = "lowercase")]
pub enum PaintStyle { pub enum PaintStyle {
#[default] #[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 { pub struct Paint {
#[serde(default, skip_serializing_if = "PaintStyle::is_auto")] #[serde(default, skip_serializing_if = "PaintStyle::is_auto")]
pub style: PaintStyle, 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 struct Tokens {
pub colors: Colors, pub colors: Colors,
pub material: Material, pub material: Material,
@@ -144,7 +150,7 @@ pub struct Tokens {
pub focus_ring: Paint, pub focus_ring: Paint,
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct ResolvedTheme { pub struct ResolvedTheme {
pub appearance: Appearance, pub appearance: Appearance,
pub tokens: Tokens, pub tokens: Tokens,
+18 -3
View File
@@ -7,6 +7,15 @@ use jiff::Zoned;
#[must_use] #[must_use]
pub fn format_bar_time(now: &Zoned) -> String { 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 = [ let months = [
"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sept", "oct", "nov", "dec", "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 muted = palette.muted.scale_alpha(opacity);
let [previous, today_button, next] = navigation; let [previous, today_button, next] = navigation;
let header = row![ let header = row![
crate::text(format!("{} {year}", months[(month - 1) as usize]), font) crate::text(
.size(if compact { 13 } else { 15 }) format!(
.width(Length::Fill), "{} {year}",
ferese_config::locale::text(months[(month - 1) as usize])
),
font
)
.size(if compact { 13 } else { 15 })
.width(Length::Fill),
previous, previous,
today_button, today_button,
next, next,
+12 -11
View File
@@ -83,7 +83,7 @@ def environment():
"IBUS_ADDRESS": "", "FERESE_SESSION_IMPORT_ENV": "0", "FERESE_ENABLE_INPUT_METHOD": "1", "IBUS_ADDRESS": "", "FERESE_SESSION_IMPORT_ENV": "0", "FERESE_ENABLE_INPUT_METHOD": "1",
"FERESE_RIME_CONTROL": str(RUNTIME / "rime-control.sock"), "FERESE_RIME_CONTROL": str(RUNTIME / "rime-control.sock"),
"FERESE_ENABLE_TEST_POINTER": "1", "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")}) values.update({f"XDG_{kind.upper()}_HOME": str(DATA / "profile" / kind) for kind in ("config", "data", "cache", "state")})
return values return values
@@ -131,15 +131,16 @@ def start():
RUNTIME.mkdir(mode=0o700, exist_ok=True) RUNTIME.mkdir(mode=0o700, exist_ok=True)
if RUNTIME.stat().st_uid != os.getuid() or RUNTIME.stat().st_mode & 0o077: if RUNTIME.stat().st_uid != os.getuid() or RUNTIME.stat().st_mode & 0o077:
raise RuntimeError("Unexpected private runtime owner or permissions") raise RuntimeError("Unexpected private runtime owner or permissions")
(DATA / "profile/config/ferese/config.kdl").write_text(''' if not (DATA / "profile/config/ferese/config.kdl").exists():
xwayland { enabled #false; } (DATA / "profile/config/ferese/config.kdl").write_text('''
status { keybinding-guide #false; } xwayland { enabled #false; }
window-rule app-id="dev.ferese.RimeValidation" floating=#true width=880 height=560 status { keybinding-guide #false; }
window-rule app-id="io.github.lgse.Strata" floating=#true width=1050 height=720 window-rule app-id="dev.ferese.RimeValidation" floating=#true width=880 height=560
window-rule app-id="QQ" floating=#true width=1000 height=720 window-rule app-id="io.github.lgse.Strata" floating=#true width=1050 height=720
window-rule app-id="teams-for-linux" floating=#true width=1000 height=720 window-rule app-id="QQ" floating=#true width=1000 height=720
theme { typography { font-family "MiSans"; }; } 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_runtime = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}"))
host_display = Path(os.environ.get("WAYLAND_DISPLAY", "wayland-0")) host_display = Path(os.environ.get("WAYLAND_DISPLAY", "wayland-0"))
if not host_display.is_absolute(): if not host_display.is_absolute():
@@ -148,7 +149,7 @@ theme { typography { font-family "MiSans"; }; }
raise RuntimeError("Parent Wayland display is missing") raise RuntimeError("Parent Wayland display is missing")
env = {**environment(), "WAYLAND_DISPLAY": str(host_display), "RUST_LOG": "info"} 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", 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") c = wait_for(context, "Development compositor did not publish its private connection")
(DATA / "context.json").write_text(json.dumps(c, indent=2) + "\n") (DATA / "context.json").write_text(json.dumps(c, indent=2) + "\n")
start_ime() start_ime()
+29
View File
@@ -0,0 +1,29 @@
// Private-session StatusNotifierItem fixture. Never sends application messages.
const {Gio,GLib}=imports.gi;
const output=ARGV[0];
const xml=`<node><interface name="org.kde.StatusNotifierItem">
<property name="Category" type="s" access="read"/><property name="Id" type="s" access="read"/>
<property name="Title" type="s" access="read"/><property name="Status" type="s" access="read"/>
<property name="WindowId" type="i" access="read"/><property name="IconName" type="s" access="read"/>
<property name="IconPixmap" type="a(iiay)" access="read"/><property name="IconThemePath" type="s" access="read"/>
<property name="OverlayIconName" type="s" access="read"/><property name="OverlayIconPixmap" type="a(iiay)" access="read"/>
<property name="AttentionIconName" type="s" access="read"/><property name="AttentionIconPixmap" type="a(iiay)" access="read"/>
<property name="AttentionMovieName" type="s" access="read"/><property name="ToolTip" type="(sa(iiay)ss)" access="read"/>
<property name="ItemIsMenu" type="b" access="read"/><property name="Menu" type="o" access="read"/>
<method name="Activate"><arg type="i" direction="in"/><arg type="i" direction="in"/></method>
<method name="SecondaryActivate"><arg type="i" direction="in"/><arg type="i" direction="in"/></method>
<method name="ContextMenu"><arg type="i" direction="in"/><arg type="i" direction="in"/></method>
<method name="Scroll"><arg type="i" direction="in"/><arg type="s" direction="in"/></method>
<signal name="NewIcon"/><signal name="NewStatus"><arg type="s"/></signal>
</interface></node>`;
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();
+11 -6
View File
@@ -30,6 +30,8 @@ fn main() -> iced::Result {
ferese_config::config_path() ferese_config::config_path()
}; };
ferese_config::locale::load(config.as_deref());
if !args.is_empty() && !preview && args != ["--foreground"] { if !args.is_empty() && !preview && args != ["--foreground"] {
eprintln!("Usage: ferese-lock [--preview | --foreground]"); eprintln!("Usage: ferese-lock [--preview | --foreground]");
std::process::exit(2); std::process::exit(2);
@@ -430,7 +432,7 @@ impl Locker {
let margin = if size.width < 640. { 24. } else { 36. }; let margin = if size.width < 640. { 24. } else { 36. };
let top = (size.height * 0.09).max(24.); let top = (size.height * 0.09).max(24.);
let a = &self.appearance; 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() .password()
.padding([4, 10]) .padding([4, 10])
.size(14) .size(14)
@@ -494,9 +496,9 @@ impl Locker {
input, input,
self.label( self.label(
if self.caps_lock { if self.caps_lock {
"Caps Lock is on" ferese_config::tr!("Caps Lock is on")
} else { } else {
self.status ferese_config::locale::text(self.status)
}, },
12 12
) )
@@ -542,9 +544,12 @@ impl Locker {
container(identity).center_x(Length::Fill), container(identity).center_x(Length::Fill),
widget::Space::new().height(Length::Fill), widget::Space::new().height(Length::Fill),
row![ row![
row![ferese_symbol(), self.label("Ferese", 13)] row![
.spacing(5) ferese_symbol(),
.align_y(iced::Alignment::Center), self.label(ferese_config::tr!("Ferese"), 13)
]
.spacing(5)
.align_y(iced::Alignment::Center),
widget::Space::new().width(Length::Fill), widget::Space::new().width(Length::Fill),
self.label( self.label(
if self.preview { if self.preview {
+1
View File
@@ -324,6 +324,7 @@ async fn run_agent() -> Result<(), Box<dyn std::error::Error>> {
} }
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
ferese_config::locale::load(ferese_config::config_path().as_deref());
// SAFETY: PR_SET_DUMPABLE takes an integer value and does not dereference caller memory. // SAFETY: PR_SET_DUMPABLE takes an integer value and does not dereference caller memory.
unsafe { unsafe {
libc::prctl(libc::PR_SET_DUMPABLE, 0); libc::prctl(libc::PR_SET_DUMPABLE, 0);
+24 -19
View File
@@ -475,13 +475,15 @@ impl cosmic::Application for Prompt {
Message::Submit Message::Submit
}); });
} }
let action = button::custom(ferese_theme::text("Authenticate", palette.font).size(13)) let action = button::custom(
.class(ferese_theme::controls::authentication_button( ferese_theme::text(ferese_config::tr!("Authenticate"), palette.font).size(13),
palette.palette(), )
)) .class(ferese_theme::controls::authentication_button(
.height(Length::Fixed(36.)) palette.palette(),
.padding([8, 16]) ))
.on_press_maybe((!self.waiting && !self.answer.is_empty()).then_some(Message::Submit)); .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 { let lock_icon = if let Some(icon) = &self.action_icon {
widget::icon(icon.clone()).size(20) widget::icon(icon.clone()).size(20)
} else { } else {
@@ -506,7 +508,8 @@ impl cosmic::Application for Prompt {
12., 12.,
)), )),
column![ 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) ferese_theme::text(format!("Confirm as {user}"), palette.font)
.size(12) .size(12)
.class(theme::Text::Color(palette.muted)), .class(theme::Text::Color(palette.muted)),
@@ -524,9 +527,9 @@ impl cosmic::Application for Prompt {
button::custom( button::custom(
ferese_theme::text( ferese_theme::text(
if self.details_expanded { if self.details_expanded {
"Hide details" ferese_config::tr!("Hide details")
} else { } else {
"Show details" ferese_config::tr!("Show details")
}, },
palette.font, palette.font,
) )
@@ -563,7 +566,7 @@ impl cosmic::Application for Prompt {
body = body.push( body = body.push(
container( container(
row![ row![
ferese_theme::text("Account", palette.font) ferese_theme::text(ferese_config::tr!("Account"), palette.font)
.size(12) .size(12)
.class(theme::Text::Color(palette.muted)), .class(theme::Text::Color(palette.muted)),
widget::dropdown( widget::dropdown(
@@ -610,14 +613,16 @@ impl cosmic::Application for Prompt {
} }
body = body.push( body = body.push(
row![ row![
button::custom(ferese_theme::text("Cancel", palette.font).size(13)) button::custom(
.class(ferese_theme::controls::material_button_style( ferese_theme::text(ferese_config::tr!("Cancel"), palette.font).size(13)
palette.palette(), )
false, .class(ferese_theme::controls::material_button_style(
material_opacity, palette.palette(),
)) false,
.padding([8, 14]) material_opacity,
.on_press(Message::Cancel), ))
.padding([8, 14])
.on_press(Message::Cancel),
action action
] ]
.spacing(8) .spacing(8)
File diff suppressed because it is too large. Load diff
@@ -64,7 +64,7 @@ impl App {
) )
.push( .push(
ferese_theme::controls::switch(adapter.powered, p) ferese_theme::controls::switch(adapter.powered, p)
.name("Bluetooth power") .name(ferese_config::tr!("Bluetooth power"))
.on_press_maybe(ready.then(|| { .on_press_maybe(ready.then(|| {
message(Input::Run(Command::BluetoothPower( message(Input::Run(Command::BluetoothPower(
adapter.path.clone(), adapter.path.clone(),
@@ -49,7 +49,7 @@ impl App {
) )
.push( .push(
ferese_theme::controls::switch(wifi.enabled, p) ferese_theme::controls::switch(wifi.enabled, p)
.name("Wi-Fi power") .name(ferese_config::tr!("Wi-Fi power"))
.on_press_maybe( .on_press_maybe(
(ready (ready
&& (wifi.hardware_enabled || wifi.enabled) && (wifi.hardware_enabled || wifi.enabled)
+8 -5
View File
@@ -36,14 +36,14 @@ impl App {
.width(Length::Fill), .width(Length::Fill),
) )
.push(self.settings_icon_button( .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", "M20 7V3l-3 3a8 8 0 1 0 3 6 M20 3h-5",
Some(Message::RefreshDisplays), Some(Message::RefreshDisplays),
)), )),
); );
if let Some(error) = &self.display_error { if let Some(error) = &self.display_error {
panel = panel.push(cosmic::widget::tooltip( 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.), self.label(error.clone(), 11.),
cosmic::widget::tooltip::Position::Bottom, cosmic::widget::tooltip::Position::Bottom,
)); ));
@@ -159,13 +159,16 @@ impl App {
))); )));
panel = panel.push(row([]).spacing(5).align_y(Alignment::Center) 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), .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" }))); .push(self.note(if target.active { "Changes apply live" } else { "Saved profile · applies when active" })));
} else { } 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 { } 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) container(panel)
.padding([7, 10]) .padding([7, 10])
+4 -4
View File
@@ -123,10 +123,10 @@ impl App {
let palette = visuals::Palette::from_resolved(&self.resolved.presented); let palette = visuals::Palette::from_resolved(&self.resolved.presented);
let mut labels = column([]) let mut labels = column([])
.spacing(2) .spacing(2)
.push(self.label(field.label.clone(), 13.)); .push(self.label(ferese_config::locale::translate(&field.label), 13.));
if !field.description.is_empty() { if !field.description.is_empty() {
labels = labels.push( 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)), .class(cosmic::theme::Text::Color(palette.muted)),
); );
} }
@@ -169,7 +169,7 @@ impl App {
format!("{value:.2}{suffix}") format!("{value:.2}{suffix}")
}; };
if margin_limit == Some(None) { if margin_limit == Some(None) {
self.label("—", 12.).into() self.label(ferese_config::tr!("—"), 12.).into()
} else if max <= min { } else if max <= min {
self.label(display, 12.).into() self.label(display, 12.).into()
} else { } else {
@@ -195,7 +195,7 @@ impl App {
let mut options = row([]).spacing(2); let mut options = row([]).spacing(2);
for (key, label) in choices { for (key, label) in choices {
options = options.push( options = options.push(
button::custom(self.label(*label, 12.)) button::custom(self.label(ferese_config::locale::text(label), 12.))
.padding([4, 8]) .padding([4, 8])
.class(self.control_button_style(palette, value == *key)) .class(self.control_button_style(palette, value == *key))
.on_press(Message::Change(set(&path, *key))), .on_press(Message::Change(set(&path, *key))),
+67 -2
View File
@@ -1,3 +1,4 @@
mod all_options;
mod connections; mod connections;
mod form_controls; mod form_controls;
mod navigation; mod navigation;
@@ -8,6 +9,7 @@ mod panel_edit;
mod panel_inspector; mod panel_inspector;
mod panel_preview; mod panel_preview;
mod theme_controls; mod theme_controls;
mod tray_controls;
use theme_controls::gallery_id; use theme_controls::gallery_id;
@@ -62,6 +64,11 @@ fn main() -> cosmic::iced::Result {
} }
Some("wallpaper") => Some(InitialPage::Wallpaper), Some("wallpaper") => Some(InitialPage::Wallpaper),
Some("bar") => Some(InitialPage::Bar), 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"); eprintln!("--page accepts connections, wifi, bluetooth, wallpaper, or bar");
return Ok(()); return Ok(());
@@ -79,6 +86,9 @@ fn main() -> cosmic::iced::Result {
let path = path.canonicalize().unwrap_or(path); let path = path.canonicalize().unwrap_or(path);
fonts::families(); fonts::families();
let initial = Snapshot::read(&path); 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()); let theme = visuals::native_theme(initial.as_ref().ok());
cosmic::app::run::<App>( cosmic::app::run::<App>(
@@ -94,12 +104,16 @@ fn main() -> cosmic::iced::Result {
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
enum InitialPage { enum InitialPage {
Connections(connections::Tab), Connections(connections::Tab),
Settings(Page),
Wallpaper, Wallpaper,
Bar, Bar,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
enum Message { enum Message {
Options(all_options::Input),
TrayApps(Vec<tray_controls::Application>),
RefreshTrayApps,
ReadRenderScale(cosmic::iced::window::Id), ReadRenderScale(cosmic::iced::window::Id),
RenderScale(f32), RenderScale(f32),
Connections(connections::Input), Connections(connections::Input),
@@ -165,6 +179,8 @@ enum Message {
} }
struct App { struct App {
tray_apps: Vec<tray_controls::Application>,
options: all_options::State,
connections: connections::State, connections: connections::State,
displays: Vec<displays::Display>, displays: Vec<displays::Display>,
display_selection: Option<String>, display_selection: Option<String>,
@@ -265,7 +281,11 @@ impl cosmic::Application for App {
let font = fonts::interface_font(&resolved.presented.tokens.typography.font_family); let font = fonts::interface_font(&resolved.presented.tokens.typography.font_family);
let native_palette = visuals::Palette::from_resolved(&resolved.presented); let native_palette = visuals::Palette::from_resolved(&resolved.presented);
let family_sections = theme_controls::FamilySections::new(&resolved.families, &current); let family_sections = theme_controls::FamilySections::new(&resolved.families, &current);
ferese_config::locale::set(ferese_config::locale::from_document(&current.doc));
let options = all_options::State::load(&current);
let mut app = Self { let mut app = Self {
tray_apps: Vec::new(),
options,
connections: connections::State { connections: connections::State {
tab: match initial_page { tab: match initial_page {
Some(InitialPage::Connections(tab)) => tab, Some(InitialPage::Connections(tab)) => tab,
@@ -291,6 +311,7 @@ impl cosmic::Application for App {
Some(InitialPage::Connections(_)) => Page::Connections, Some(InitialPage::Connections(_)) => Page::Connections,
Some(InitialPage::Wallpaper) => Page::Wallpaper, Some(InitialPage::Wallpaper) => Page::Wallpaper,
Some(InitialPage::Bar) => Page::Bar, Some(InitialPage::Bar) => Page::Bar,
Some(InitialPage::Settings(page)) => page,
None => Page::Appearance, None => Page::Appearance,
}, },
profile_pages: std::env::var_os("FERESE_PROFILE_SETTINGS").is_some(), profile_pages: std::env::var_os("FERESE_PROFILE_SETTINGS").is_some(),
@@ -328,9 +349,15 @@ impl cosmic::Application for App {
let task = app let task = app
.core .core
.main_window_id() .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); .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 { let connections = if app.page == Page::Connections {
app.refresh_connections() app.refresh_connections()
} else { } else {
@@ -347,6 +374,9 @@ impl cosmic::Application for App {
fn update(&mut self, message: Message) -> Task<Message> { fn update(&mut self, message: Message) -> Task<Message> {
match message { 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::DragWindow => return self.core.drag(None),
Message::ReadRenderScale(id) => { Message::ReadRenderScale(id) => {
return cosmic::iced::window::scale_factor(id) return cosmic::iced::window::scale_factor(id)
@@ -427,11 +457,16 @@ impl cosmic::Application for App {
|| !self.inputs.is_empty() || !self.inputs.is_empty()
|| !self.ranges.is_empty() || !self.ranges.is_empty()
|| !self.pending.is_empty() || !self.pending.is_empty()
|| self.options.dirty()
{ {
self.status = self.status =
"Config changed externally · Reload when your edits are finished" "Config changed externally · Reload when your edits are finished"
.into(); .into();
} else { } else {
ferese_config::locale::set(ferese_config::locale::from_document(
&snapshot.doc,
));
self.options.reset(&snapshot);
self.current = snapshot.clone(); self.current = snapshot.clone();
self.draft = snapshot; self.draft = snapshot;
self.family_sections = theme_controls::FamilySections::new( 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); self.page_transition = self.profile_pages.then(std::time::Instant::now);
let leaving = self.page == Page::Connections && page != Page::Connections; 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.page = page;
self.search.clear(); self.search.clear();
self.visible_rows = initial_visible_rows(); self.visible_rows = initial_visible_rows();
@@ -507,6 +548,7 @@ impl cosmic::Application for App {
let load = match page { let load = match page {
Page::Displays | Page::Bar => self.load_displays(), Page::Displays | Page::Bar => self.load_displays(),
Page::Wallpaper => self.load_thumbnail(), Page::Wallpaper => self.load_thumbnail(),
Page::Tray => self.load_tray_apps(),
_ => Task::none(), _ => Task::none(),
}; };
return Task::batch([reset, cleanup, load]); return Task::batch([reset, cleanup, load]);
@@ -517,6 +559,9 @@ impl cosmic::Application for App {
if matches!(page, Page::Displays | Page::Bar) { if matches!(page, Page::Displays | Page::Bar) {
return Task::batch([reset, self.load_displays()]); return Task::batch([reset, self.load_displays()]);
} }
if page == Page::Tray {
return Task::batch([reset, self.load_tray_apps()]);
}
if page == Page::Wallpaper { if page == Page::Wallpaper {
return Task::batch([reset, self.load_thumbnail()]); return Task::batch([reset, self.load_thumbnail()]);
} }
@@ -725,6 +770,12 @@ impl cosmic::Application for App {
Ok((snapshot, live)) => { Ok((snapshot, live)) => {
self.undo = Some(std::mem::take(&mut self.saving_previous)); self.undo = Some(std::mem::take(&mut self.saving_previous));
self.current = snapshot; 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.font = fonts::interface_font(
&self.resolved.presented.tokens.typography.font_family, &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; tokio::time::sleep(std::time::Duration::from_secs(5)).await;
Message::ExpireUndo(revision) 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([ return Task::batch([
title,
self.update_theme(), self.update_theme(),
self.flush(), self.flush(),
self.load_thumbnail(), self.load_thumbnail(),
@@ -766,12 +828,15 @@ impl cosmic::Application for App {
self.error = Some(error); self.error = Some(error);
self.pending.clear(); self.pending.clear();
self.draft = self.current.clone(); self.draft = self.current.clone();
self.options.applying = false;
self.status = "Not saved".into(); self.status = "Not saved".into();
} }
} }
} }
Message::Reload if !self.saving => match Snapshot::read(&self.path) { Message::Reload if !self.saving => match Snapshot::read(&self.path) {
Ok(snapshot) => { Ok(snapshot) => {
ferese_config::locale::set(ferese_config::locale::from_document(&snapshot.doc));
self.options.reset(&snapshot);
self.current = snapshot.clone(); self.current = snapshot.clone();
self.draft = snapshot; self.draft = snapshot;
self.family_sections = self.family_sections =
@@ -1754,7 +1819,7 @@ mod tests {
.expect("requested renderer"); .expect("requested renderer");
eprintln!("renderer={}", Headless::name(&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); let mut app = shortcuts(500);
app.page = page; app.page = page;
let theme = app.native_palette.native_theme(); let theme = app.native_palette.native_theme();
+57 -36
View File
@@ -26,7 +26,7 @@ impl App {
.align_y(Alignment::Center) .align_y(Alignment::Center)
.push(visuals::brand_icon()) .push(visuals::brand_icon())
.push( .push(
self.label("Ferese", 16.) self.label(ferese_config::tr!("Ferese"), 16.)
.class(cosmic::theme::Text::Color(palette.text)), .class(cosmic::theme::Text::Color(palette.text)),
) )
.width(Length::Fill), .width(Length::Fill),
@@ -36,7 +36,7 @@ impl App {
) )
.push(widget::Space::new().height(14)) .push(widget::Space::new().height(14))
.push( .push(
text_input("Search settings", self.search.clone()) text_input(ferese_config::tr!("Search settings"), self.search.clone())
.id(widget::Id::new("settings-search")) .id(widget::Id::new("settings-search"))
.on_input(Message::Search) .on_input(Message::Search)
.font(self.font) .font(self.font)
@@ -46,7 +46,7 @@ impl App {
.push(widget::Space::new().height(8)); .push(widget::Space::new().height(8));
if compact { if compact {
sidebar = sidebar.push(self.settings_button( sidebar = sidebar.push(self.settings_button(
"Close navigation", ferese_config::tr!("Close navigation"),
"M6 6l12 12 M6 18L18 6", "M6 6l12 12 M6 18L18 6",
Some(Message::Sidebar(false)), Some(Message::Sidebar(false)),
false, false,
@@ -56,7 +56,7 @@ impl App {
for (title, pages) in Page::NAVIGATION { for (title, pages) in Page::NAVIGATION {
let mut section = column([]).spacing(3).push( let mut section = column([]).spacing(3).push(
container( container(
self.label(title, 11.) self.label(ferese_config::locale::text(title), 11.)
.class(cosmic::theme::Text::Color(palette.muted)), .class(cosmic::theme::Text::Color(palette.muted)),
) )
.padding([0, 8]), .padding([0, 8]),
@@ -118,13 +118,13 @@ impl App {
) )
.width(Length::Fill), .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"; let automatic = self.draft.string("theme.mode", "dark") == "auto";
heading = heading.push( heading = heading.push(
row([]) row([])
.spacing(8) .spacing(8)
.align_y(Alignment::Center) .align_y(Alignment::Center)
.push(self.label("Auto", 12.)) .push(self.label(ferese_config::tr!("Auto"), 12.))
.push( .push(
ferese_theme::controls::switch(automatic, palette) ferese_theme::controls::switch(automatic, palette)
.name(if automatic { .name(if automatic {
@@ -179,11 +179,18 @@ impl App {
.into_iter() .into_iter()
.flat_map(|(_, pages)| pages.iter().copied()) .flat_map(|(_, pages)| pages.iter().copied())
}; };
if !pages().any(|p| p.matches(&self.search)) { if !pages().any(|p| {
body = body p.matches(&self.search)
.push(self.note("No matching settings. Try wallpaper, keyboard, or motion.")); || (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); let destination = page.search_destination(&self.search);
body = body.push( body = body.push(
button::custom( button::custom(
@@ -199,13 +206,24 @@ impl App {
); );
} }
} else { } 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 { if self.page == Page::Windows {
body = body.push(visuals::preview_resolved( body = body.push(visuals::preview_resolved(
&self.draft, &self.draft,
&self.resolved.presented, &self.resolved.presented,
)); ));
} }
if self.page == Page::Appearance { if self.page == Page::Theme {
let split = visuals::split(&self.draft); let split = visuals::split(&self.draft);
if split { if split {
body = body body = body
@@ -245,11 +263,11 @@ impl App {
if !fields.is_empty() { if !fields.is_empty() {
let mut group = column([]).spacing(1); 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()); group = group.push(self.auto_controls());
} }
for field in fields { for field in fields {
if self.page == Page::Appearance if self.page == Page::Theme
&& (field.path == "theme.mode" && (field.path == "theme.mode"
|| field.path.starts_with("theme.schedule.") || field.path.starts_with("theme.schedule.")
|| field.path.ends_with(".file")) || field.path.ends_with(".file"))
@@ -258,13 +276,13 @@ impl App {
} }
group = group.push(self.field(field)); group = group.push(self.field(field));
} }
if self.page == Page::Appearance { if self.page == Page::Theme {
group = group.push( group = group.push(
button::custom( button::custom(
row([]) row([])
.spacing(8) .spacing(8)
.align_y(Alignment::Center) .align_y(Alignment::Center)
.push(self.label("Advanced customization", 13.)) .push(self.label(ferese_config::tr!("Advanced customization"), 13.))
.push(visuals::action_icon( .push(visuals::action_icon(
if self.advanced_theme { if self.advanced_theme {
"M6 15l6-6 6 6" "M6 15l6-6 6 6"
@@ -298,17 +316,17 @@ impl App {
match self.page { match self.page {
Page::Connections => body = body.push(self.connections_view()), Page::Connections => body = body.push(self.connections_view()),
Page::LockScreen => { 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( 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", "M7 10V7a5 5 0 0 1 10 0v3 M5 10h14v11H5z M12 14v3",
Some(Message::PreviewLock), Some(Message::PreviewLock),
false, 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 => { 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") { for index in 0..self.draft.records("autostart") {
let prefix = format!("autostart.{index}"); let prefix = format!("autostart.{index}");
@@ -338,7 +356,7 @@ impl App {
.push( .push(
container( container(
self.settings_icon_button( 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", "M4 6h16 M9 6V3h6v3 M6 6l1 15h10l1-15 M10 10v7 M14 10v7",
(!self.saving) (!self.saving)
.then_some(Message::Remove("autostart".into(), index)), .then_some(Message::Remove("autostart".into(), index)),
@@ -356,16 +374,19 @@ impl App {
row([]) row([])
.spacing(8) .spacing(8)
.push( .push(
text_input("Program and arguments", self.new_command.clone()) text_input(
.font(self.font) ferese_config::tr!("Program and arguments"),
.size(12) self.new_command.clone(),
.padding([4, 8]) )
.style(visuals::input_style(palette)) .font(self.font)
.on_input(Message::NewCommand), .size(12)
.padding([4, 8])
.style(visuals::input_style(palette))
.on_input(Message::NewCommand),
) )
.push( .push(
self.settings_button( self.settings_button(
"Add login item", ferese_config::tr!("Add login item"),
"M12 5v14 M5 12h14", "M12 5v14 M5 12h14",
(!self.saving && !self.new_command.trim().is_empty()) (!self.saving && !self.new_command.trim().is_empty())
.then_some(Message::AddCommand), .then_some(Message::AddCommand),
@@ -375,7 +396,7 @@ impl App {
); );
} }
Page::Shortcuts => { 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); let mut gestures = row([]).spacing(6);
for (keys, label) in [ for (keys, label) in [
("Swipe3Up", "Customize swipe up"), ("Swipe3Up", "Customize swipe up"),
@@ -447,7 +468,7 @@ impl App {
); );
} }
if self.draft.records("bindings") == 0 { 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()), Page::Displays => body = body.push(self.displays_view()),
@@ -459,7 +480,7 @@ impl App {
if compact { if compact {
page_heading = page_heading.push( page_heading = page_heading.push(
container(self.settings_icon_button( container(self.settings_icon_button(
"Show settings navigation", ferese_config::tr!("Show settings navigation"),
"M3 5h18v14H3z M9 5v14", "M3 5h18v14H3z M9 5v14",
Some(Message::Sidebar(true)), Some(Message::Sidebar(true)),
)) ))
@@ -478,7 +499,7 @@ impl App {
content = content.push( content = content.push(
container( container(
row(self.page.subpages().iter().map(|&(page, title)| { 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())) .name(format!("{}: {title}", self.page.navigation_page().title()))
.padding([7, 12]) .padding([7, 12])
.class(visuals::panel_button(palette, self.page == page)) .class(visuals::panel_button(palette, self.page == page))
@@ -570,7 +591,7 @@ impl App {
)) ))
.padding(4) .padding(4)
.on_press_maybe((self.undo.is_some() && !self.saving).then_some(Message::Undo)), .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, widget::tooltip::Position::Top,
)) ))
.push(widget::tooltip( .push(widget::tooltip(
@@ -580,11 +601,11 @@ impl App {
)) ))
.padding(4) .padding(4)
.on_press_maybe((!self.saving).then_some(Message::Reload)), .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, widget::tooltip::Position::Top,
)); ));
let footer: Element<'_, Message> = if self.page == Page::Connections { 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 { } else if self.page == Page::Bar {
container( container(
row([]) row([])
@@ -597,13 +618,13 @@ impl App {
})) }))
.push(widget::Space::new().width(Length::Fill)) .push(widget::Space::new().width(Length::Fill))
.push(self.settings_button( .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", "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), (self.undo.is_some() && !self.saving).then_some(Message::Undo),
false, false,
)) ))
.push(self.settings_button( .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", "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), (!self.saving).then_some(Message::Reload),
false, false,
+76 -67
View File
@@ -35,7 +35,7 @@ impl std::fmt::Display for StyleChoice {
} }
} }
const CATALOG: [CatalogItem; 15] = [ const CATALOG: [CatalogItem; 16] = [
CatalogItem(ItemKind::Overview), CatalogItem(ItemKind::Overview),
CatalogItem(ItemKind::Workspaces { CatalogItem(ItemKind::Workspaces {
style: ferese_panel::WorkspaceStyle::Dots, style: ferese_panel::WorkspaceStyle::Dots,
@@ -44,6 +44,7 @@ const CATALOG: [CatalogItem; 15] = [
CatalogItem(ItemKind::Media), CatalogItem(ItemKind::Media),
CatalogItem(ItemKind::Activities), CatalogItem(ItemKind::Activities),
CatalogItem(ItemKind::QuickSettings), CatalogItem(ItemKind::QuickSettings),
CatalogItem(ItemKind::Tray),
CatalogItem(ItemKind::NightLight), CatalogItem(ItemKind::NightLight),
CatalogItem(ItemKind::Network), CatalogItem(ItemKind::Network),
CatalogItem(ItemKind::Audio), CatalogItem(ItemKind::Audio),
@@ -58,7 +59,7 @@ const CATALOG: [CatalogItem; 15] = [
impl App { impl App {
pub(super) fn panel_navigation(&self) -> Element<'static, Message> { pub(super) fn panel_navigation(&self) -> Element<'static, Message> {
self.settings_button( self.settings_button(
"Back to Panels", ferese_config::tr!("Back to Panels"),
"M15 18l-6-6 6-6", "M15 18l-6-6 6-6",
Some(Message::PanelPage(PanelPage::Panels)), Some(Message::PanelPage(PanelPage::Panels)),
false, false,
@@ -71,52 +72,53 @@ impl App {
let mut rows = widget::column([]).spacing(12); let mut rows = widget::column([]).spacing(12);
match self.panel_page { match self.panel_page {
PanelPage::Panels => { PanelPage::Panels => {
rows = rows = rows.push(
rows.push( widget::container(
widget::container( widget::row([])
widget::row([]) .spacing(12)
.spacing(12) .align_y(cosmic::iced::Alignment::Center)
.align_y(cosmic::iced::Alignment::Center) .push(crate::visuals::action_icon(
.push(crate::visuals::action_icon( if edge == Edge::Top {
if edge == Edge::Top { "M3 5h18v14H3z M3 9h18"
"M3 5h18v14H3z M3 9h18" } else {
} else { "M3 5h18v14H3z M3 15h18"
"M3 5h18v14H3z M3 15h18" },
}, palette.accent,
palette.accent, ))
)) .push(
.push( widget::column([])
widget::column([]) .spacing(3)
.spacing(3) .push(self.panel_heading(
.push(self.panel_heading( if edge == Edge::Top {
if edge == Edge::Top { "Top panel"
"Top panel" } else {
} else { "Bottom panel"
"Bottom panel" },
}, 14.,
14., ))
)) .push(self.note(ferese_config::tr!("Shown on all displays")))
.push(self.note("Shown on all displays")) .width(cosmic::iced::Length::Fill),
.width(cosmic::iced::Length::Fill), )
) .push(self.settings_button(
.push(self.settings_button( ferese_config::tr!("Edit panel"),
"Edit panel", "M4 20l4-1 12-12-3-3L5 16z",
"M4 20l4-1 12-12-3-3L5 16z", Some(Message::PanelPage(PanelPage::Arrange)),
Some(Message::PanelPage(PanelPage::Arrange)), false,
false, ))
)) .push(
.push(self.settings_icon_button( self.settings_icon_button(
"Remove custom panel", ferese_config::tr!("Remove custom panel"),
"M4 7h16 M9 7V4h6v3 M6 7l1 13h10l1-13 M10 11v5 M14 11v5", "M4 7h16 M9 7V4h6v3 M6 7l1 13h10l1-13 M10 11v5 M14 11v5",
self.draft.item("panels").is_some().then(|| { self.draft.item("panels").is_some().then(|| {
Message::Change(store::Edit::Unset("panels".into())) Message::Change(store::Edit::Unset("panels".into()))
}), }),
)), ),
) ),
.padding(14) )
.width(cosmic::iced::Length::Fill) .padding(14)
.class(crate::visuals::surface(palette.card, 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 preset = self.preview_panel().ok().and_then(|panel| panel.preset());
let position = widget::row([Edge::Top, Edge::Bottom].into_iter().map(|position| { let position = widget::row([Edge::Top, Edge::Bottom].into_iter().map(|position| {
let label = if position == Edge::Top { let label = if position == Edge::Top {
@@ -152,7 +154,7 @@ impl App {
); );
let mut appearance = widget::column([]) let mut appearance = widget::column([])
.spacing(16) .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_style_choices(preset.unwrap_or_default()))
.push(self.panel_divider()) .push(self.panel_divider())
.push(self.panel_opacity_controls()) .push(self.panel_opacity_controls())
@@ -166,7 +168,7 @@ impl App {
self.draft.boolean("panels.0.border", false), self.draft.boolean("panels.0.border", false),
palette, palette,
) )
.name("Outline") .name(ferese_config::tr!("Outline"))
.on_press(Message::PanelEdit(Action::SetBorder( .on_press(Message::PanelEdit(Action::SetBorder(
!self.draft.boolean("panels.0.border", false), !self.draft.boolean("panels.0.border", false),
))), ))),
@@ -192,7 +194,7 @@ impl App {
"Join the island to the screen edge with curved shoulders.", "Join the island to the screen edge with curved shoulders.",
widget::container( widget::container(
ferese_theme::controls::switch(attached, palette) 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( .on_press(Message::PanelEdit(Action::SetMorphAttached(
!attached, !attached,
))), ))),
@@ -208,7 +210,7 @@ impl App {
"Expand the island when you hover over it.", "Expand the island when you hover over it.",
widget::container( widget::container(
ferese_theme::controls::switch(enabled, palette) ferese_theme::controls::switch(enabled, palette)
.name("Preview on hover") .name(ferese_config::tr!("Preview on hover"))
.on_press(Message::PanelEdit(Action::SetPeekOnHover( .on_press(Message::PanelEdit(Action::SetPeekOnHover(
!enabled, !enabled,
))), ))),
@@ -248,7 +250,7 @@ impl App {
"Show +n for additional activities.", "Show +n for additional activities.",
widget::container( widget::container(
ferese_theme::controls::switch(show_badge, palette) ferese_theme::controls::switch(show_badge, palette)
.name("Show activity count") .name(ferese_config::tr!("Show activity count"))
.on_press(Message::Change(store::set( .on_press(Message::Change(store::set(
"activities.show_badge", "activities.show_badge",
!show_badge, !show_badge,
@@ -272,10 +274,10 @@ impl App {
let mut settings = widget::column([]).spacing(4).push( let mut settings = widget::column([]).spacing(4).push(
widget::container( widget::container(
widget::row![ widget::row![
self.panel_heading("Size & spacing", 15.) self.panel_heading(ferese_config::tr!("Size & spacing"), 15.)
.width(cosmic::iced::Length::Fill), .width(cosmic::iced::Length::Fill),
self.settings_button( self.settings_button(
"Reset to style", ferese_config::tr!("Reset to style"),
"M3 12a9 9 0 1 0 3-6 M3 3v6h6", "M3 12a9 9 0 1 0 3-6 M3 3v6h6",
pins.then_some(Message::PanelEdit(Action::ResetShape)), pins.then_some(Message::PanelEdit(Action::ResetShape)),
false false
@@ -355,7 +357,7 @@ impl App {
); );
let mut shell = widget::column([]) let mut shell = widget::column([])
.spacing(4) .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) for field in crate::schema::fields(Page::Bar)
.into_iter() .into_iter()
.filter(|field| field.path.starts_with("theme.")) .filter(|field| field.path.starts_with("theme."))
@@ -427,7 +429,7 @@ impl App {
"Background opacity", "Background opacity",
"Reduce transparency is on. Turn it off in Accessibility to change opacity.", "Reduce transparency is on. Turn it off in Accessibility to change opacity.",
self.settings_button( self.settings_button(
"Accessibility", ferese_config::tr!("Accessibility"),
"M9 18l6-6-6-6", "M9 18l6-6-6-6",
Some(Message::Page(Page::Accessibility)), Some(Message::Page(Page::Accessibility)),
false, false,
@@ -482,7 +484,7 @@ impl App {
.width(48), .width(48),
) )
.push(self.settings_icon_button( .push(self.settings_icon_button(
"Use shell opacity", ferese_config::tr!("Use shell opacity"),
"M3 12a9 9 0 1 0 3-6 M3 3v6h6", "M3 12a9 9 0 1 0 3-6 M3 3v6h6",
custom.then_some(Message::PanelEdit(Action::ClearOpacity)), custom.then_some(Message::PanelEdit(Action::ClearOpacity)),
)); ));
@@ -632,8 +634,10 @@ impl App {
cosmic::iced::widget::responsive(move |size| { cosmic::iced::widget::responsive(move |size| {
let editor = widget::column([]) let editor = widget::column([])
.spacing(12) .spacing(12)
.push(self.panel_heading("Arrangement", 15.)) .push(self.panel_heading(ferese_config::tr!("Arrangement"), 15.))
.push(self.note("Drag items to reorder. Select an item or group to edit.")) .push(self.note(ferese_config::tr!(
"Drag items to reorder. Select an item or group to edit."
)))
.push( .push(
widget::scrollable(self.panel_editor(&panel)) widget::scrollable(self.panel_editor(&panel))
.id(widget::Id::new("settings-content")) .id(widget::Id::new("settings-content"))
@@ -710,9 +714,9 @@ impl App {
"M4 4h16v16H4z M14 4v16 M7 8h4 M7 12h4", "M4 4h16v16H4z M14 4v16 M7 8h4 M7 12h4",
palette.muted, 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( .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]) .padding([40, 8])
@@ -779,7 +783,7 @@ impl App {
) )
.push( .push(
self.settings_button( self.settings_button(
"Add group", ferese_config::tr!("Add group"),
"M12 5v14 M5 12h14", "M12 5v14 M5 12h14",
(panel.group_count() < ferese_panel::MAX_GROUPS) (panel.group_count() < ferese_panel::MAX_GROUPS)
.then_some(Message::PanelEdit(Action::AddGroup(placement))), .then_some(Message::PanelEdit(Action::AddGroup(placement))),
@@ -881,8 +885,10 @@ impl App {
} }
let mut contents = widget::column([]).spacing(4).push(heading); let mut contents = widget::column([]).spacing(4).push(heading);
if items.is_empty() { if items.is_empty() {
contents = contents contents = contents.push(
.push(widget::container(self.note("Drop items here")).padding([8, 6])); widget::container(self.note(ferese_config::tr!("Drop items here")))
.padding([8, 6]),
);
} else { } else {
contents = contents.push( contents = contents.push(
widget::flex_row(items) widget::flex_row(items)
@@ -922,12 +928,14 @@ impl App {
} }
if zone.groups.is_empty() { if zone.groups.is_empty() {
groups = groups.push( groups = groups.push(
widget::container(self.note("Drop an item here, or add a group.")) widget::container(
.id(crate::panel_drag::empty_zone_id(placement)) self.note(ferese_config::tr!("Drop an item here, or add a group.")),
.width(Length::Fill) )
.height(64) .id(crate::panel_drag::empty_zone_id(placement))
.padding(12) .width(Length::Fill)
.class(crate::visuals::surface(palette.card, 10.)), .height(64)
.padding(12)
.class(crate::visuals::surface(palette.card, 10.)),
); );
} }
zones = zones.push( 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 { pub(super) fn item_icon(kind: ItemKind, tint: cosmic::iced::Color) -> widget::icon::Icon {
use ferese_theme::icons; use ferese_theme::icons;
let source = match kind { let source = match kind {
ItemKind::Tray => icons::APPLICATION,
ItemKind::Overview => icons::FERESE, ItemKind::Overview => icons::FERESE,
ItemKind::Workspaces { .. } => icons::OVERVIEW, ItemKind::Workspaces { .. } => icons::OVERVIEW,
ItemKind::FocusedWindow => icons::DISPLAY, ItemKind::FocusedWindow => icons::DISPLAY,
+12 -9
View File
@@ -23,7 +23,7 @@ impl App {
.width(Length::Fill), .width(Length::Fill),
) )
.push(self.settings_icon_button( .push(self.settings_icon_button(
"Deselect", ferese_config::tr!("Deselect"),
"M6 6l12 12 M6 18L18 6", "M6 6l12 12 M6 18L18 6",
Some(Message::PanelClearSelection), Some(Message::PanelClearSelection),
)), )),
@@ -58,7 +58,7 @@ impl App {
rows = rows.push( rows = rows.push(
widget::column([]) widget::column([])
.spacing(8) .spacing(8)
.push(self.label("Placement", 13.)) .push(self.label(ferese_config::tr!("Placement"), 13.))
.push(crate::visuals::panel_select( .push(crate::visuals::panel_select(
cosmic::iced::widget::pick_list( cosmic::iced::widget::pick_list(
crate::panel_edit::destinations(panel), crate::panel_edit::destinations(panel),
@@ -166,8 +166,10 @@ impl App {
}; };
let mut choices = widget::column([]) let mut choices = widget::column([])
.spacing(6) .spacing(6)
.push(self.label("Workspace style", 13.)) .push(self.label(ferese_config::tr!("Workspace style"), 13.))
.push(self.note("A sample of active, occupied and empty workspaces.")); .push(self.note(ferese_config::tr!(
"A sample of active, occupied and empty workspaces."
)));
for style in ferese_panel::WorkspaceStyle::ALL { for style in ferese_panel::WorkspaceStyle::ALL {
let sample = ferese_theme::workspaces::sample(style, palette, self.font, 22.); let sample = ferese_theme::workspaces::sample(style, palette, self.font, 22.);
choices = choices.push( choices = choices.push(
@@ -210,7 +212,7 @@ impl App {
.push( .push(
widget::column([]) widget::column([])
.spacing(8) .spacing(8)
.push(self.label("Placement", 13.)) .push(self.label(ferese_config::tr!("Placement"), 13.))
.push(crate::visuals::panel_select( .push(crate::visuals::panel_select(
cosmic::iced::widget::pick_list( cosmic::iced::widget::pick_list(
Zone::ALL.to_vec(), Zone::ALL.to_vec(),
@@ -307,8 +309,9 @@ impl App {
), ),
); );
if !group.items.is_empty() { if !group.items.is_empty() {
rows = rows rows = rows.push(self.note(ferese_config::tr!(
.push(self.note("Move or remove this group’s items before removing the group.")); "Move or remove this group’s items before removing the group."
)));
} }
rows.into() rows.into()
} }
@@ -322,8 +325,8 @@ impl App {
) -> Element<'static, Message> { ) -> Element<'static, Message> {
widget::row([]) widget::row([])
.spacing(8) .spacing(8)
.push(self.settings_icon_button("Up", "M6 15l6-6 6 6", earlier)) .push(self.settings_icon_button(ferese_config::tr!("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!("Down"), "M6 9l6 6 6-6", later))
.push(self.settings_icon_button( .push(self.settings_icon_button(
remove_label, remove_label,
"M4 6h16 M9 6V3h6v3 M6 6l1 15h10l1-15 M10 10v7 M14 10v7", "M4 6h16 M9 6V3h6v3 M6 6l1 15h10l1-15 M10 10v7 M14 10v7",
+9 -4
View File
@@ -7,6 +7,7 @@ use ferese_theme::panel::{Sample, Spacing, frame};
use crate::{App, Element, Message, panel_controls, visuals}; use crate::{App, Element, Message, panel_controls, visuals};
const SAMPLE_AVAILABILITY: Availability = Availability { const SAMPLE_AVAILABILITY: Availability = Availability {
tray: true,
capture: true, capture: true,
activities: true, activities: true,
media: true, media: true,
@@ -47,8 +48,11 @@ impl App {
.push( .push(
row([]) row([])
.align_y(Alignment::Center) .align_y(Alignment::Center)
.push(self.label("Composition preview", 14.).width(Length::Fill)) .push(
.push(self.note("Sample content")), self.label(ferese_config::tr!("Composition preview"), 14.)
.width(Length::Fill),
)
.push(self.note(ferese_config::tr!("Sample content"))),
) )
.push( .push(
container(self.panel_preview_frame(panel.clone())) container(self.panel_preview_frame(panel.clone()))
@@ -121,7 +125,7 @@ impl App {
if panel.style == ferese_panel::PanelStyle::Morph { if panel.style == ferese_panel::PanelStyle::Morph {
let palette = visuals::Palette::from_resolved(&self.resolved.presented); let palette = visuals::Palette::from_resolved(&self.resolved.presented);
let height = panel.resolved_geometry().height; 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") .id("morph-preview-body")
.center_x(panel.morph.idle_width as f32) .center_x(panel.morph.idle_width as f32)
.center_y(height); .center_y(height);
@@ -383,7 +387,7 @@ impl App {
let sample = ferese_theme::workspaces::sample(style, palette, self.font, 20.); let sample = ferese_theme::workspaces::sample(style, palette, self.font, 20.);
let control = let control =
button::custom(container(sample).id(format!("preview-icon:{}", item.id.0))) button::custom(container(sample).id(format!("preview-icon:{}", item.id.0)))
.name("Edit Workspaces") .name(ferese_config::tr!("Edit Workspaces"))
.padding([0, 2]) .padding([0, 2])
.height(control_height) .height(control_height)
.on_press(Message::PanelSelect(item.id.clone())) .on_press(Message::PanelSelect(item.id.clone()))
@@ -408,6 +412,7 @@ impl App {
ItemKind::Network => icons::WIFI_FULL, ItemKind::Network => icons::WIFI_FULL,
ItemKind::Audio => icons::VOLUME_HIGH, ItemKind::Audio => icons::VOLUME_HIGH,
ItemKind::Recording => icons::RECORD, ItemKind::Recording => icons::RECORD,
ItemKind::Tray => icons::APPLICATION,
ItemKind::CaptureStatus => icons::DISPLAY, ItemKind::CaptureStatus => icons::DISPLAY,
ItemKind::Notifications => icons::NOTIFICATIONS, ItemKind::Notifications => icons::NOTIFICATIONS,
ItemKind::Battery { .. } => icons::BATTERY_75, ItemKind::Battery { .. } => icons::BATTERY_75,
+113 -12
View File
@@ -1,6 +1,10 @@
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Page { pub enum Page {
Appearance, Appearance,
General,
Theme,
Tray,
Configuration,
Accessibility, Accessibility,
Wallpaper, Wallpaper,
Bar, Bar,
@@ -16,8 +20,12 @@ pub enum Page {
} }
impl Page { impl Page {
pub const NAVIGATION: [(&'static str, &'static [Self]); 3] = [ pub const NAVIGATION: [(&'static str, &'static [Self]); 5] = [
("Desktop", &[Self::Appearance, Self::Wallpaper, Self::Bar]), ("Appearance", &[Self::Appearance, Self::General]),
(
"Desktop",
&[Self::Theme, Self::Wallpaper, Self::Bar, Self::Tray],
),
( (
"Interaction", "Interaction",
&[Self::Windows, Self::Keyboard, Self::Accessibility], &[Self::Windows, Self::Keyboard, Self::Accessibility],
@@ -32,6 +40,7 @@ impl Page {
Self::Startup, Self::Startup,
], ],
), ),
("Advanced", &[Self::Configuration]),
]; ];
/// Subpages share one entry in navigation and search. /// Subpages share one entry in navigation and search.
@@ -66,8 +75,12 @@ impl Page {
} }
#[cfg(test)] #[cfg(test)]
pub const ALL: [Self; 13] = [ pub const ALL: [Self; 17] = [
Self::Appearance, Self::Appearance,
Self::General,
Self::Theme,
Self::Tray,
Self::Configuration,
Self::Accessibility, Self::Accessibility,
Self::Wallpaper, Self::Wallpaper,
Self::Bar, Self::Bar,
@@ -83,8 +96,15 @@ impl Page {
]; ];
pub fn title(self) -> &'static str { pub fn title(self) -> &'static str {
ferese_config::locale::text(self.title_key())
}
fn title_key(self) -> &'static str {
match self { match self {
Self::Appearance => "Appearance", Self::Appearance => "Appearance",
Self::General => "General",
Self::Tray => "Tray icons",
Self::Configuration => "All options",
Self::Theme => "Theme",
Self::Accessibility => "Accessibility", Self::Accessibility => "Accessibility",
Self::Wallpaper => "Wallpaper", Self::Wallpaper => "Wallpaper",
Self::Bar => "Panel & Shell", Self::Bar => "Panel & Shell",
@@ -101,8 +121,14 @@ impl Page {
} }
pub fn subtitle(self) -> &'static str { pub fn subtitle(self) -> &'static str {
match self { ferese_config::locale::text(match self {
Self::Appearance => "A desktop that feels like yours.", 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::Accessibility => "Make the desktop easier to see and use.",
Self::Wallpaper => "Set the scene for your workspace.", Self::Wallpaper => "Set the scene for your workspace.",
Self::Bar => "Arrange your panel and change the shell's appearance.", 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::Startup => "Ready when you sign in.",
Self::Displays => "A place for every screen.", Self::Displays => "A place for every screen.",
Self::Connections => "Wi-Fi networks and Bluetooth devices.", Self::Connections => "Wi-Fi networks and Bluetooth devices.",
} })
} }
pub fn icon(self) -> &'static str { pub fn icon(self) -> &'static str {
@@ -123,7 +149,11 @@ impl Page {
Self::Accessibility => { 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" "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" "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", 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 { fn matches_own(self, query: &str) -> bool {
let query = query.to_lowercase(); let query = query.to_lowercase();
self.title().to_lowercase().contains(&query) 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) || self.subtitle().to_lowercase().contains(&query)
|| fields(self).iter().any(|f| { || fields(self).iter().any(|f| {
format!("{} {} {}", f.label, f.description, f.path) format!(
.to_lowercase() "{} {} {} {} {}",
.contains(&query) 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<Field> {
false, false,
), ),
], ],
Page::Appearance => vec![ Page::Theme => vec![
choice( choice(
"theme.mode", "theme.mode",
"Appearance", "Appearance",
@@ -910,6 +960,57 @@ pub fn fields(page: Page) -> Vec<Field> {
"Keep the shortcut guide until you are comfortable with Ferese.", "Keep the shortcut guide until you are comfortable with Ferese.",
true, 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![],
} }
} }
+9 -9
View File
@@ -56,7 +56,7 @@ impl App {
let labels = column([]) let labels = column([])
.spacing(2) .spacing(2)
.width(Length::Fill) .width(Length::Fill)
.push(self.label("Theme overrides", 13.)) .push(self.label(ferese_config::tr!("Theme overrides"), 13.))
.push(widget::tooltip( .push(widget::tooltip(
self.label(filename, 11.) self.label(filename, 11.)
.class(cosmic::theme::Text::Color(palette.muted)), .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)) button::custom(visuals::action_icon("M6 6l12 12M6 18L18 6", palette.muted))
.padding(4) .padding(4)
.on_press(Message::Change(Edit::Unset(path.into()))), .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, widget::tooltip::Position::Bottom,
)); ));
} }
@@ -152,7 +152,7 @@ impl App {
column([]) column([])
.spacing(2) .spacing(2)
.width(Length::Fill) .width(Length::Fill)
.push(self.label("Automatic appearance", 13.)) .push(self.label(ferese_config::tr!("Automatic appearance"), 13.))
.push(self.note(description)), .push(self.note(description)),
) )
.push(sources), .push(sources),
@@ -221,18 +221,18 @@ impl App {
); );
if appearance != Some(Dark) && self.undo.is_some() { if appearance != Some(Dark) && self.undo.is_some() {
heading = heading.push(self.settings_icon_button( 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", "M4 10h8 M4 10V3 M4 10c3-7 16-6 16 3a7 7 0 0 1-7 7",
(!self.saving).then_some(Message::Undo), (!self.saving).then_some(Message::Undo),
)); ));
} }
let mut custom_heading = row([]) let mut custom_heading = row([]).align_y(Alignment::Center).spacing(6).push(
.align_y(Alignment::Center) self.label(ferese_config::tr!("Custom themes"), 14.)
.spacing(6) .width(Length::Fill),
.push(self.label("Custom themes", 14.).width(Length::Fill)); );
if appearance != Some(Dark) { if appearance != Some(Dark) {
custom_heading = custom_heading.push(self.settings_button( custom_heading = custom_heading.push(self.settings_button(
"Import…", ferese_config::tr!("Import…"),
"M3 7V5h6l2 2h10v13H3z M12 10v7 M9 14l3 3 3-3", "M3 7V5h6l2 2h10v13H3z M12 10v7 M9 14l3 3 3-3",
Some(Message::ImportTheme), Some(Message::ImportTheme),
false, false,
+111
View File
@@ -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<Vec<Application>, 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<String> = 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::<String>("Id").await else {
continue;
};
let title = proxy
.get_property::<String>("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<Message> {
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::<Vec<_>>()
})
.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()
}
}
@@ -343,7 +343,7 @@ impl App {
let preview = &self.wallpaper.active; let preview = &self.wallpaper.active;
let mut body = column([]) let mut body = column([])
.spacing(8) .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 { let current: Element<'_, Message> = if let Some(handle) = &preview.handle {
widget::image(handle.clone()) widget::image(handle.clone())
.border_radius(palette.radius.min(10.)) .border_radius(palette.radius.min(10.))
@@ -376,7 +376,7 @@ impl App {
row([]) row([])
.spacing(8) .spacing(8)
.align_y(Alignment::Center) .align_y(Alignment::Center)
.push(self.label("Apply to", 12.)) .push(self.label(ferese_config::tr!("Apply to"), 12.))
.push(ferese_theme::controls::select( .push(ferese_theme::controls::select(
cosmic::iced::widget::pick_list( cosmic::iced::widget::pick_list(
Target::ALL, Target::ALL,
@@ -389,7 +389,7 @@ impl App {
)) ))
.push(widget::Space::new().width(Length::Fill)) .push(widget::Space::new().width(Length::Fill))
.push(self.settings_button( .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", "M3 4h18v16H3z M3 15l5-5 5 5 3-3 5 5 M15 8h.01",
Some(Message::PickWallpaper), Some(Message::PickWallpaper),
false, false,
@@ -444,10 +444,10 @@ impl App {
row([]) row([])
.spacing(8) .spacing(8)
.align_y(Alignment::Center) .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(widget::Space::new().width(Length::Fill))
.push(self.settings_button( .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", "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), Some(Message::RestoreDefaultWallpapers),
false, false,
+1
View File
@@ -32,6 +32,7 @@ tempfile.workspace = true
tokio = { version = "1", features = ["time", "rt", "sync", "net", "macros"] } tokio = { version = "1", features = ["time", "rt", "sync", "net", "macros"] }
wayland-client.workspace = true wayland-client.workspace = true
zbus = "5.19.0" zbus = "5.19.0"
system-tray = { version = "0.8.9", default-features = false, features = ["data"] }
pipewire = { version = "0.10.1", features = ["v0_3_65"] } pipewire = { version = "0.10.1", features = ["v0_3_65"] }
image-codec = { package = "image", version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] } image-codec = { package = "image", version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
url = "2.5.8" url = "2.5.8"
+14 -4
View File
@@ -583,7 +583,12 @@ impl FereseShell {
) -> (Element<'_, cosmic::Action<Message>>, bool) { ) -> (Element<'_, cosmic::Action<Message>>, bool) {
use ferese_panel::Representation; use ferese_panel::Representation;
let Some(presentation) = self.activities.flat.get(&id) else { 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| { let Some(activity) = presentation.selection.visible.first().and_then(|id| {
self.activities self.activities
@@ -593,7 +598,12 @@ impl FereseShell {
.chain(self.activities.native.iter()) .chain(self.activities.native.iter())
.find(|entry| &entry.id == id) .find(|entry| &entry.id == id)
}) else { }) 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 palette = self.config.theme.palette();
let result_age = activity.result_deadline.map(|deadline| { let result_age = activity.result_deadline.map(|deadline| {
@@ -1089,7 +1099,7 @@ impl FereseShell {
rows.push( rows.push(
container( container(
self.render self.render
.text("No active tasks") .text(ferese_config::tr!("No active tasks"))
.size(12) .size(12)
.class(theme::Text::Color(palette.muted.scale_alpha(opacity))), .class(theme::Text::Color(palette.muted.scale_alpha(opacity))),
) )
@@ -1167,7 +1177,7 @@ impl FereseShell {
{ {
body = body.push( body = body.push(
button::custom(icons::tinted(icons::MEDIA_PLAY, 16, palette.text)) 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( .on_press(cosmic::Action::App(Message::MediaAction(
self.media.action("play-pause"), self.media.action("play-pause"),
))), ))),
@@ -122,7 +122,7 @@ impl FereseShell {
badge, badge,
column![ column![
self.render self.render
.text("Pomodoro") .text(ferese_config::tr!("Pomodoro"))
.size(13) .size(13)
.class(theme::Text::Color(primary)), .class(theme::Text::Color(primary)),
self.render self.render
+13 -6
View File
@@ -75,7 +75,7 @@ impl FereseShell {
pub(super) fn view_layer(&self, id: window::Id) -> Element<'_, cosmic::Action<Message>> { pub(super) fn view_layer(&self, id: window::Id) -> Element<'_, cosmic::Action<Message>> {
if self.bar_hidden(id) { if self.bar_hidden(id) {
return container(self.render.text("")) return container(self.render.text(ferese_config::tr!("")))
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.into(); .into();
@@ -94,6 +94,7 @@ impl FereseShell {
let effects = output.and_then(|output| output.effects.as_ref()); let effects = output.and_then(|output| output.effects.as_ref());
let compositor_material = effects.is_some(); let compositor_material = effects.is_some();
let availability = Availability { let availability = Availability {
tray: self.tray.available(&self.config.status.tray),
capture: self.capture_status.active(), capture: self.capture_status.active(),
activities: self.activity_available(id), activities: self.activity_available(id),
media: self.media.snapshot.selected.is_some(), media: self.media.snapshot.selected.is_some(),
@@ -324,9 +325,12 @@ impl FereseShell {
cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1), cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1),
)); ));
let badge = container( let badge = container(
row![title, self.render.text("Esc to exit").size(12)] row![
.spacing(12) title,
.align_y(cosmic::iced::Alignment::Center), self.render.text(ferese_config::tr!("Esc to exit")).size(12)
]
.spacing(12)
.align_y(cosmic::iced::Alignment::Center),
) )
.id(badge_id.clone()) .id(badge_id.clone())
.padding([0, 12]) .padding([0, 12])
@@ -480,6 +484,9 @@ impl FereseShell {
control_height, control_height,
); );
} }
ItemKind::Tray => {
return (self.view_tray(id), false);
}
ItemKind::Media => self.view_media_item(representation, selected, width), ItemKind::Media => self.view_media_item(representation, selected, width),
ItemKind::QuickSettings => { ItemKind::QuickSettings => {
self.view_status_item(status_ui::Menu::System, false, selected) 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)) button::custom(bar_content(content, bar.group_item_height))
.padding([0, 4]) .padding([0, 4])
.height(bar.group_item_height) .height(bar.group_item_height)
.name("Open calendar") .name(ferese_config::tr!("Open calendar"))
.on_press_with_rectangle(move |offset, bounds| { .on_press_with_rectangle(move |offset, bounds| {
cosmic::Action::App(Message::OpenMenu( cosmic::Action::App(Message::OpenMenu(
status_ui::Menu::Calendar, status_ui::Menu::Calendar,
@@ -558,7 +565,7 @@ impl FereseShell {
bar_icon(ferese_theme::icons::DISPLAY, bar.icon_size, foreground), bar_icon(ferese_theme::icons::DISPLAY, bar.icon_size, foreground),
control_height, control_height,
)) ))
.name("Display mode") .name(ferese_config::tr!("Display mode"))
.height(control_height) .height(control_height)
.padding([ .padding([
0.0, 0.0,
+10 -17
View File
@@ -8,6 +8,7 @@ const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20];
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct ShellConfig { pub(crate) struct ShellConfig {
pub(crate) ui: ferese_config::locale::Settings,
pub(crate) terminal: Vec<String>, pub(crate) terminal: Vec<String>,
pub(crate) activities: ferese_config::activities::Settings, pub(crate) activities: ferese_config::activities::Settings,
pub(crate) notifications: ferese_config::notifications::NotificationConfig, pub(crate) notifications: ferese_config::notifications::NotificationConfig,
@@ -24,6 +25,7 @@ impl Default for ShellConfig {
fn default() -> Self { fn default() -> Self {
let status = StatusConfig::default(); let status = StatusConfig::default();
Self { Self {
ui: Default::default(),
terminal: vec!["foot".into()], terminal: vec!["foot".into()],
activities: Default::default(), activities: Default::default(),
notifications: Default::default(), notifications: Default::default(),
@@ -38,23 +40,7 @@ impl Default for ShellConfig {
} }
} }
#[derive(Clone, Debug, Deserialize)] pub(crate) use ferese_config::status::Settings as StatusConfig;
#[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<Vec<String>>,
}
impl Default for StatusConfig {
fn default() -> Self {
Self {
keybinding_guide: true,
low_battery_threshold: 20,
settings_command: Some(vec!["ferese-settings".into()]),
}
}
}
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub(crate) struct ShellTheme { pub(crate) struct ShellTheme {
@@ -190,6 +176,8 @@ pub(crate) enum WallpaperMode {
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
struct FereseConfig { struct FereseConfig {
#[serde(default)]
ui: ferese_config::locale::Settings,
#[serde(default)] #[serde(default)]
commands: std::collections::HashMap<String, Vec<String>>, commands: std::collections::HashMap<String, Vec<String>>,
#[serde(default)] #[serde(default)]
@@ -405,6 +393,10 @@ fn parse_document(
.map_err(|error| ferese_config::Error::from(error.to_string())) .map_err(|error| ferese_config::Error::from(error.to_string()))
{ {
Ok(config) => { Ok(config) => {
config
.status
.validate()
.map_err(ferese_config::Error::from)?;
if let Some(panels) = &config.panels { if let Some(panels) = &config.panels {
ferese_panel::validate(panels).map_err(ferese_config::Error::from)?; ferese_panel::validate(panels).map_err(ferese_config::Error::from)?;
} }
@@ -424,6 +416,7 @@ fn parse_document(
theme.bar_radius = theme.material_radius; theme.bar_radius = theme.material_radius;
Ok(ShellConfig { Ok(ShellConfig {
ui: config.ui,
terminal: config terminal: config
.commands .commands
.get("terminal") .get("terminal")
+3 -1
View File
@@ -415,7 +415,9 @@ impl FereseShell {
.is_some_and(|modal| modal.focus_visible); .is_some_and(|modal| modal.focus_visible);
let pending = model.pending(); let pending = model.pending();
let mut header = row![ 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) cosmic::iced::widget::Space::new().width(Length::Fill)
] ]
.align_y(cosmic::iced::Alignment::Center) .align_y(cosmic::iced::Alignment::Center)
+2 -2
View File
@@ -141,7 +141,7 @@ impl FereseShell {
suggestion = suggestion.push( suggestion = suggestion.push(
container( container(
self.render self.render
.text("Tab") .text(ferese_config::tr!("Tab"))
.size(12) .size(12)
.line_height(cosmic::iced::widget::text::LineHeight::Absolute(14.into())) .line_height(cosmic::iced::widget::text::LineHeight::Absolute(14.into()))
.class(theme::Text::Color(muted)), .class(theme::Text::Color(muted)),
@@ -440,7 +440,7 @@ impl FereseShell {
if launcher.rows.is_empty() { if launcher.rows.is_empty() {
cards = cards.push( cards = cards.push(
self.render self.render
.text("No open windows") .text(ferese_config::tr!("No open windows"))
.size(14) .size(14)
.class(theme::Text::Color(muted)), .class(theme::Text::Color(muted)),
); );
+16 -2
View File
@@ -2,6 +2,7 @@ mod activities;
mod bar; mod bar;
mod battery_indicator; mod battery_indicator;
mod surfaces; mod surfaces;
mod tray;
use bar::{bar_content, output_bar_hidden}; use bar::{bar_content, output_bar_hidden};
use surfaces::{EffectsBinding, EffectsContext, native_wayland_surface}; use surfaces::{EffectsBinding, EffectsContext, native_wayland_surface};
@@ -151,6 +152,7 @@ type WallpaperLoad =
cosmic::iced::futures::channel::oneshot::Receiver<Result<image::Handle, String>>; cosmic::iced::futures::channel::oneshot::Receiver<Result<image::Handle, String>>;
struct FereseShell { struct FereseShell {
tray: tray::Model,
core: Core, core: Core,
config: ShellConfig, config: ShellConfig,
theme_snapshot: ferese_ipc::theme::Snapshot, theme_snapshot: ferese_ipc::theme::Snapshot,
@@ -205,6 +207,8 @@ struct OutputSurfaces {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
enum Message { enum Message {
Tray(tray::Event),
TrayInput(tray::Input),
Launcher(launcher::Message), Launcher(launcher::Message),
LauncherEnter(window::Id, bool), LauncherEnter(window::Id, bool),
NightLightTemperature(u16), NightLightTemperature(u16),
@@ -357,6 +361,7 @@ impl cosmic::Application for FereseShell {
status_service: status::Service::start(config.status.settings_command.clone()), status_service: status::Service::start(config.status.settings_command.clone()),
status: status::Snapshot::default(), status: status::Snapshot::default(),
recorder: recording::Recorder::default(), recorder: recording::Recorder::default(),
tray: tray::Model::default(),
capture_status: capture_status::Model::new(), capture_status: capture_status::Model::new(),
media: media::Model::default(), media: media::Model::default(),
activities: activities::Model::default(), activities: activities::Model::default(),
@@ -453,6 +458,7 @@ impl cosmic::Application for FereseShell {
activities::subscription::subscription().map(Message::ActivitiesUpdated), activities::subscription::subscription().map(Message::ActivitiesUpdated),
self.activity_tick_subscription() self.activity_tick_subscription()
.map(|_| Message::ActivityTick), .map(|_| Message::ActivityTick),
tray::subscription().map(Message::Tray),
self.media.subscription().map(Message::MediaUpdated), self.media.subscription().map(Message::MediaUpdated),
self.media_tick_subscription().map(|_| Message::MediaTick), self.media_tick_subscription().map(|_| Message::MediaTick),
self.media.art.subscription().map(Message::MediaArt), self.media.art.subscription().map(Message::MediaArt),
@@ -478,6 +484,11 @@ impl cosmic::Application for FereseShell {
fn update(&mut self, message: Self::Message) -> Task<Self::Message> { fn update(&mut self, message: Self::Message) -> Task<Self::Message> {
let task = match message { 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::Launcher(message) => self.update_launcher(message),
Message::LauncherEnter(id, alt) => { Message::LauncherEnter(id, alt) => {
if self.launcher.as_ref().is_some_and(|l| l.id == id) { 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> { 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> { 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<Message> { fn apply_config(&mut self, mut config: config::ShellConfig) -> Task<Message> {
ferese_config::locale::set(config.ui.language);
self.clock = clock::current_time();
if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some() { if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some() {
config.wallpaper.path = None; config.wallpaper.path = None;
} }
@@ -1386,6 +1399,7 @@ mod tests {
status_service: status::Service::start(None), status_service: status::Service::start(None),
status: Default::default(), status: Default::default(),
recorder: Default::default(), recorder: Default::default(),
tray: tray::Model::default(),
capture_status: Default::default(), capture_status: Default::default(),
media: Default::default(), media: Default::default(),
activities: Default::default(), activities: Default::default(),
+1 -1
View File
@@ -699,7 +699,7 @@ mod tests {
)) ))
.push( .push(
cosmic::widget::button::custom(cosmic::widget::text("Action")) cosmic::widget::button::custom(cosmic::widget::text("Action"))
.name("Accessible action") .name(ferese_config::tr!("Accessible action"))
.on_press(()) .on_press(())
.width(100) .width(100)
.height(40), .height(40),
+5 -5
View File
@@ -723,7 +723,7 @@ impl FereseShell {
.spacing(3) .spacing(3)
.push( .push(
self.render self.render
.text("Notifications") .text(ferese_config::tr!("Notifications"))
.size(16) .size(16)
.font(bold) .font(bold)
.class(theme::Text::Color(primary)), .class(theme::Text::Color(primary)),
@@ -767,7 +767,7 @@ impl FereseShell {
.spacing(2) .spacing(2)
.push( .push(
self.render self.render
.text("Do Not Disturb") .text(ferese_config::tr!("Do Not Disturb"))
.size(12) .size(12)
.font(bold) .font(bold)
.class(theme::Text::Color(primary)), .class(theme::Text::Color(primary)),
@@ -821,14 +821,14 @@ impl FereseShell {
)) ))
.push( .push(
self.render self.render
.text("You're all caught up") .text(ferese_config::tr!("You're all caught up"))
.size(15) .size(15)
.font(bold) .font(bold)
.class(theme::Text::Color(primary)), .class(theme::Text::Color(primary)),
) )
.push( .push(
self.render self.render
.text("New notifications will appear here.") .text(ferese_config::tr!("New notifications will appear here."))
.size(11) .size(11)
.class(theme::Text::Color(muted)), .class(theme::Text::Color(muted)),
); );
@@ -843,7 +843,7 @@ impl FereseShell {
.align_y(alignment::Vertical::Center) .align_y(alignment::Vertical::Center)
.push( .push(
self.render self.render
.text("Recent") .text(ferese_config::tr!("Recent"))
.size(11) .size(11)
.font(bold) .font(bold)
.class(theme::Text::Color(muted)) .class(theme::Text::Color(muted))
+1 -1
View File
@@ -1101,7 +1101,7 @@ impl FereseShell {
button::custom( button::custom(
icons::tinted(icons::morph::CLOSE, 18, icon_primary).opacity(opacity), icons::tinted(icons::morph::CLOSE, 18, icon_primary).opacity(opacity),
) )
.name("Collapse panel hub") .name(ferese_config::tr!("Collapse panel hub"))
.padding([5, 3]) .padding([5, 3])
.width(30) .width(30)
.height(28) .height(28)
+2 -2
View File
@@ -149,7 +149,7 @@ impl FereseShell {
return row![ return row![
self.battery_indicator(self.config.theme, opacity, true), self.battery_indicator(self.config.theme, opacity, true),
self.render self.render
.text("Low battery") .text(ferese_config::tr!("Low battery"))
.size(13) .size(13)
.class(theme::Text::Color(foreground)) .class(theme::Text::Color(foreground))
.width(width) .width(width)
@@ -202,7 +202,7 @@ impl FereseShell {
if expanded { if expanded {
strip = strip.push( strip = strip.push(
self.render self.render
.text("Recording") .text(ferese_config::tr!("Recording"))
.size(13) .size(13)
.class(theme::Text::Color(foreground)) .class(theme::Text::Color(foreground))
.width(Length::Fill), .width(Length::Fill),
+1
View File
@@ -9,6 +9,7 @@ pub(crate) struct Rendering {
impl Rendering { impl Rendering {
pub fn new(config: &crate::config::ShellConfig) -> Self { pub fn new(config: &crate::config::ShellConfig) -> Self {
ferese_config::locale::set(config.ui.language);
Self { Self {
font: crate::configured_font(config.font_family.as_deref()), font: crate::configured_font(config.font_family.as_deref()),
motion: config.animations, motion: config.animations,
+8 -2
View File
@@ -21,8 +21,14 @@ pub(super) fn view<'a>(
opacity: p, opacity: p,
} = style; } = style;
if let Some(a) = &shell.status.audio { if let Some(a) = &shell.status.audio {
let mut volume_heading = row![shell.render.text("Volume").size(13).width(Length::Fill)] let mut volume_heading = row![
.align_y(Alignment::Center); shell
.render
.text(ferese_config::tr!("Volume"))
.size(13)
.width(Length::Fill)
]
.align_y(Alignment::Center);
if combined { if combined {
volume_heading = volume_heading.push(shell_switch( volume_heading = volume_heading.push(shell_switch(
shell.render, shell.render,
+2
View File
@@ -146,6 +146,7 @@ impl FereseShell {
fn status_label(kind: Menu, s: &Snapshot) -> String { fn status_label(kind: Menu, s: &Snapshot) -> String {
match kind { match kind {
Menu::Tray => "Application tray".into(),
Menu::Network => match &s.network { Menu::Network => match &s.network {
Some(n) if n.enabled => format!( Some(n) if n.enabled => format!(
"Wi-Fi: {}", "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) { pub(super) fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
match kind { match kind {
Menu::Tray => (ferese_theme::icons::APPLICATION, true),
Menu::NightLight => ( Menu::NightLight => (
ferese_theme::icons::MOON, ferese_theme::icons::MOON,
s.night_light.as_ref().is_some_and(|s| s.enabled), s.night_light.as_ref().is_some_and(|s| s.enabled),
+2 -2
View File
@@ -73,7 +73,7 @@ pub(super) fn view<'a>(
rows = rows.push( rows = rows.push(
shell shell
.render .render
.text("Power Profiles service unavailable") .text(ferese_config::tr!("Power Profiles service unavailable"))
.size(12) .size(12)
.class(theme::Text::Color(muted)), .class(theme::Text::Color(muted)),
); );
@@ -136,7 +136,7 @@ pub(super) fn brightness<'a>(
} = style; } = style;
if let Some(value) = shell.status.brightness { if let Some(value) = shell.status.brightness {
let brightness = column![ let brightness = column![
shell.render.text("Brightness").size(13), shell.render.text(ferese_config::tr!("Brightness")).size(13),
slider_row( slider_row(
shell.render, shell.render,
ferese_theme::icons::BRIGHTNESS, ferese_theme::icons::BRIGHTNESS,
@@ -50,7 +50,7 @@ pub(super) fn view<'a>(
shell.render.text(device).size(14), shell.render.text(device).size(14),
shell shell
.render .render
.text("Connected") .text(ferese_config::tr!("Connected"))
.size(12) .size(12)
.class(theme::Text::Color(muted)), .class(theme::Text::Color(muted)),
] ]
+21 -3
View File
@@ -18,9 +18,27 @@ pub(super) fn view<'a>(
render.font, render.font,
opacity, opacity,
[ [
menu_button(render, "‹", Message::CalendarMonth(-1), primary, opacity), menu_button(
menu_button(render, "Today", Message::CalendarToday, primary, opacity), render,
menu_button(render, "›", Message::CalendarMonth(1), primary, opacity), 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,
),
], ],
) )
} }
+1 -1
View File
@@ -82,7 +82,7 @@ pub(super) fn level_meter(
opacity: f32, opacity: f32,
) -> Element<'static, cosmic::Action<Message>> { ) -> Element<'static, cosmic::Action<Message>> {
let segment = move |amount: u16, filled: bool| { let segment = move |amount: u16, filled: bool| {
container(render.text("")) container(render.text(ferese_config::tr!("")))
.width(Length::FillPortion(amount)) .width(Length::FillPortion(amount))
.height(5) .height(5)
.class(theme::Container::custom(move |_| container::Style { .class(theme::Container::custom(move |_| container::Style {
+3 -3
View File
@@ -79,7 +79,7 @@ pub(super) fn bar<'a>(
width: Option<f32>, width: Option<f32>,
) -> Element<'a, cosmic::Action<Message>> { ) -> Element<'a, cosmic::Action<Message>> {
let Some(player) = &shell.media.snapshot.selected else { 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 { let foreground = color(if selected {
palette.accent palette.accent
@@ -302,7 +302,7 @@ pub(super) fn hub<'a>(
return rows.push( return rows.push(
shell shell
.render .render
.text("No active player") .text(ferese_config::tr!("No active player"))
.size(14) .size(14)
.class(theme::Text::Color(style.muted)), .class(theme::Text::Color(style.muted)),
); );
@@ -624,7 +624,7 @@ pub(super) fn view<'a>(
rows = rows.push( rows = rows.push(
shell shell
.render .render
.text("No active player") .text(ferese_config::tr!("No active player"))
.size(13) .size(13)
.class(theme::Text::Color(style.muted)), .class(theme::Text::Color(style.muted)),
); );
+17 -13
View File
@@ -39,6 +39,7 @@ struct MenuStyle {
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Menu { pub enum Menu {
Tray,
NightLight, NightLight,
Activities, Activities,
Workspaces, Workspaces,
@@ -90,7 +91,8 @@ impl Menu {
} }
fn title(self) -> &'static str { fn title(self) -> &'static str {
match self { ferese_config::locale::text(match self {
Self::Tray => "Application tray",
Self::NightLight => "Night light", Self::NightLight => "Night light",
Self::Activities => "Activities", Self::Activities => "Activities",
Self::Workspaces => "Workspaces", Self::Workspaces => "Workspaces",
@@ -105,12 +107,12 @@ impl Menu {
Self::CaptureStatus => "Capture status", Self::CaptureStatus => "Capture status",
Self::Media => "Now playing", Self::Media => "Now playing",
Self::Notifications => "Notifications", Self::Notifications => "Notifications",
} })
} }
fn available(self, status: &Snapshot) -> bool { fn available(self, status: &Snapshot) -> bool {
match self { match self {
Self::NightLight => true, Self::Tray | Self::NightLight => true,
Self::Network => status.network.is_some(), Self::Network => status.network.is_some(),
Self::Bluetooth => status.bluetooth.is_some(), Self::Bluetooth => status.bluetooth.is_some(),
Self::Audio => status.audio.is_some(), Self::Audio => status.audio.is_some(),
@@ -207,7 +209,7 @@ impl FereseShell {
{ {
rows = rows.push(toggle_row( rows = rows.push(toggle_row(
self.render, self.render,
"Wi-Fi", ferese_config::tr!("Wi-Fi"),
network.enabled, network.enabled,
Action::Wifi(!network.enabled), Action::Wifi(!network.enabled),
theme, theme,
@@ -219,7 +221,7 @@ impl FereseShell {
{ {
rows = rows.push(toggle_row( rows = rows.push(toggle_row(
self.render, self.render,
"Mute", ferese_config::tr!("Mute"),
audio.muted, audio.muted,
Action::Mute(!audio.muted), Action::Mute(!audio.muted),
theme, theme,
@@ -227,6 +229,7 @@ impl FereseShell {
)); ));
} }
let mut rows = match kind { let mut rows = match kind {
Menu::Tray => rows.push(self.tray_menu()),
Menu::System => system::view(self, rows, style, true), Menu::System => system::view(self, rows, style, true),
Menu::NightLight => night_light::view(self, rows, style), Menu::NightLight => night_light::view(self, rows, style),
Menu::Network => network::view(self, rows, style), Menu::Network => network::view(self, rows, style),
@@ -245,14 +248,14 @@ impl FereseShell {
if self.notifications.entries.is_empty() { if self.notifications.entries.is_empty() {
rows = rows.push( rows = rows.push(
self.render self.render
.text("No notifications") .text(ferese_config::tr!("No notifications"))
.size(13) .size(13)
.class(theme::Text::Color(style.muted)), .class(theme::Text::Color(style.muted)),
); );
} else { } else {
rows = rows.push(menu_button( rows = rows.push(menu_button(
self.render, self.render,
"Clear notifications", ferese_config::tr!("Clear notifications"),
Message::ClearNotifications, Message::ClearNotifications,
style.muted, style.muted,
opacity, opacity,
@@ -269,7 +272,7 @@ impl FereseShell {
.width(Length::Fill), .width(Length::Fill),
menu_button( menu_button(
self.render, self.render,
"×", ferese_config::tr!("×"),
Message::RemoveNotification(notice.id), Message::RemoveNotification(notice.id),
style.muted, style.muted,
opacity opacity
@@ -322,7 +325,7 @@ impl FereseShell {
if !self.capture_status.active() { if !self.capture_status.active() {
rows = rows.push( rows = rows.push(
self.render self.render
.text("No active screen sharing or OBS output") .text(ferese_config::tr!("No active screen sharing or OBS output"))
.size(13) .size(13)
.class(theme::Text::Color(style.muted)), .class(theme::Text::Color(style.muted)),
); );
@@ -349,7 +352,7 @@ impl FereseShell {
if !kind.available(&self.status) { if !kind.available(&self.status) {
rows = rows.push( rows = rows.push(
self.render self.render
.text("Service unavailable") .text(ferese_config::tr!("Service unavailable"))
.size(13) .size(13)
.class(theme::Text::Color(style.muted)), .class(theme::Text::Color(style.muted)),
); );
@@ -378,7 +381,7 @@ impl FereseShell {
fn view_status_menu(&self) -> Element<'_, cosmic::Action<Message>> { fn view_status_menu(&self) -> Element<'_, cosmic::Action<Message>> {
let Some(menu) = &self.menu else { let Some(menu) = &self.menu else {
return self.render.text("").into(); return self.render.text(ferese_config::tr!("")).into();
}; };
motion::frame_driven( motion::frame_driven(
menu.motion.revision(), menu.motion.revision(),
@@ -405,7 +408,7 @@ impl FereseShell {
fn view_status_menu_at(&self, now: Instant) -> Element<'_, cosmic::Action<Message>> { fn view_status_menu_at(&self, now: Instant) -> Element<'_, cosmic::Action<Message>> {
let Some(menu) = &self.menu else { 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 { if menu.kind == Menu::Notifications && self.notifications.ready {
return cosmic::widget::autosize::autosize( return cosmic::widget::autosize::autosize(
@@ -461,6 +464,7 @@ impl FereseShell {
rows = rows.push(heading); rows = rows.push(heading);
} }
rows = match kind { rows = match kind {
Menu::Tray => rows.push(self.tray_menu()),
Menu::System => system::view(self, rows, style, false), Menu::System => system::view(self, rows, style, false),
Menu::NightLight => night_light::view(self, rows, style), Menu::NightLight => night_light::view(self, rows, style),
Menu::Network => network::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) { if kind != Menu::Calendar && !kind.available(&self.status) {
rows = rows.push( rows = rows.push(
self.render self.render
.text("Service unavailable") .text(ferese_config::tr!("Service unavailable"))
.size(13) .size(13)
.class(theme::Text::Color(muted)), .class(theme::Text::Color(muted)),
); );
+1 -1
View File
@@ -50,7 +50,7 @@ pub(super) fn view<'a>(
row![ row![
shell shell
.render .render
.text("Signal strength") .text(ferese_config::tr!("Signal strength"))
.size(12) .size(12)
.width(Length::Fill), .width(Length::Fill),
shell.render.text(strength).size(12) shell.render.text(strength).size(12)
@@ -8,7 +8,12 @@ pub(super) fn view<'a>(
style: MenuStyle, style: MenuStyle,
) -> MenuRows<'a> { ) -> MenuRows<'a> {
let Some(status) = &shell.status.night_light else { 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( rows = rows.push(toggle_row(
shell.render, shell.render,
@@ -29,7 +34,10 @@ pub(super) fn view<'a>(
)) ))
.step(100u16) .step(100u16)
.on_release(cosmic::Action::App(Message::CommitNightLightTemperature)), .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); .spacing(8);
rows = rows.push(control_card( rows = rows.push(control_card(
@@ -42,14 +50,16 @@ pub(super) fn view<'a>(
rows = rows.push( rows = rows.push(
shell shell
.render .render
.text("No display supports night light") .text(ferese_config::tr!("No display supports night light"))
.size(13), .size(13),
); );
} else if status.enabled && status.failed_outputs > 0 { } else if status.enabled && status.failed_outputs > 0 {
rows = rows.push( rows = rows.push(
shell shell
.render .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), .size(12),
); );
} }
+7 -4
View File
@@ -56,7 +56,7 @@ pub(super) fn view<'a>(
) )
.opacity(p), .opacity(p),
) )
.name("Open Ferese Settings") .name(ferese_config::tr!("Open Ferese Settings"))
.width(30) .width(30)
.height(28) .height(28)
.padding([5, 3]) .padding([5, 3])
@@ -67,7 +67,10 @@ pub(super) fn view<'a>(
); );
modes = modes.push(cosmic::widget::tooltip( modes = modes.push(cosmic::widget::tooltip(
settings, 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, cosmic::widget::tooltip::Position::Left,
)); ));
} }
@@ -129,7 +132,7 @@ pub(super) fn view<'a>(
.center_y(28), .center_y(28),
shell shell
.render .render
.text("Battery") .text(ferese_config::tr!("Battery"))
.size(11) .size(11)
.width(Length::Fill) .width(Length::Fill)
.height(16) .height(16)
@@ -281,7 +284,7 @@ pub(super) fn heading<'a>(
color_with_opacity(theme.accent, p), color_with_opacity(theme.accent, p),
)) ))
.padding(6) .padding(6)
.name("Open Settings") .name(ferese_config::tr!("Open Settings"))
.on_press(cosmic::Action::App(Message::Control(Action::Settings))), .on_press(cosmic::Action::App(Message::Control(Action::Settings))),
primary, primary,
false, false,
+1 -1
View File
@@ -621,7 +621,7 @@ impl FereseShell {
pub(super) fn view_wallpaper(&self) -> Element<'_, cosmic::Action<Message>> { pub(super) fn view_wallpaper(&self) -> Element<'_, cosmic::Action<Message>> {
let Some(handle) = self.wallpaper.as_ref() else { let Some(handle) = self.wallpaper.as_ref() else {
return container(self.render.text("")) return container(self.render.text(ferese_config::tr!("")))
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.class(theme::Container::custom(wallpaper_fallback_style)) .class(theme::Container::custom(wallpaper_fallback_style))
+10 -8
View File
@@ -508,10 +508,10 @@ impl FereseShell {
fn view_system_modal(&self, id: window::Id) -> Element<'_, cosmic::Action<Message>> { fn view_system_modal(&self, id: window::Id) -> Element<'_, cosmic::Action<Message>> {
let Some(modal) = &self.system_modal else { 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 { 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( motion::frame_driven(
modal.motion.revision(), modal.motion.revision(),
@@ -542,10 +542,10 @@ impl FereseShell {
now: Instant, now: Instant,
) -> Element<'_, cosmic::Action<Message>> { ) -> Element<'_, cosmic::Action<Message>> {
let Some(modal) = &self.system_modal else { 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 { 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 progress = modal.motion.progress_at(now);
let content: Element<'_, cosmic::Action<Message>> = if surface.primary { let content: Element<'_, cosmic::Action<Message>> = if surface.primary {
@@ -568,7 +568,9 @@ impl FereseShell {
{ {
rows = rows.push( rows = rows.push(
self.render self.render
.text("Applications requested that this action be prevented:") .text(ferese_config::tr!(
"Applications requested that this action be prevented:"
))
.size(13) .size(13)
.class(theme::Text::Color(palette.muted)), .class(theme::Text::Color(palette.muted)),
); );
@@ -664,13 +666,13 @@ impl FereseShell {
.saturating_sub(360) .saturating_sub(360)
.clamp(40, 400) as f32; .clamp(40, 400) as f32;
column![ 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,) ferese_theme::menus::section_label("Your active shortcuts", self.render.font, palette.muted,)
.size(14), .size(14),
container(cosmic::widget::scrollable(bindings).height(Length::Shrink)).max_height(height), container(cosmic::widget::scrollable(bindings).height(Length::Shrink)).max_height(height),
self.render self.render
.text( .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) .size(12)
.class(theme::Text::Color(palette.muted)), .class(theme::Text::Color(palette.muted)),
@@ -718,7 +720,7 @@ impl FereseShell {
})) }))
.into() .into()
} else { } else {
self.render.text("").into() self.render.text(ferese_config::tr!("")).into()
}; };
let content = if surface.primary { let content = if surface.primary {
motion::animated( motion::animated(
+509
View File
@@ -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<Client>,
Vec<(
String,
StatusNotifierItem,
Option<system_tray::menu::TrayMenu>,
)>,
),
Disconnected,
}
#[derive(Clone, Debug)]
pub enum Input {
Locate(String, cosmic::iced::window::Id, cosmic::widget::Id),
Located(
String,
cosmic::iced::window::Id,
Option<cosmic::iced::Rectangle>,
),
Activate(String),
Context(
String,
cosmic::iced::window::Id,
cosmic::iced::Rectangle<i32>,
),
Menu(String, i32),
Manage,
Done(Result<(), String>),
}
#[derive(Default)]
pub struct Model {
client: Option<Arc<Client>>,
items: Vec<Entry>,
current: Option<String>,
expanded: std::collections::HashSet<i32>,
}
struct Entry {
address: String,
item: StatusNotifierItem,
menu: Option<system_tray::menu::TrayMenu>,
pixmap: Option<image::Handle>,
}
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<Item = &'a Entry> {
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<Event> {
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<image::Handle> {
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<Message>> {
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<Message>> = 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<Message> {
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<Message>> {
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<Message>> {
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<cosmic::iced::window::Id>,
found: Option<cosmic::iced::Rectangle>,
}
impl cosmic::iced::advanced::widget::Operation<Option<cosmic::iced::Rectangle>> for FindBounds {
fn traverse(
&mut self,
children: &mut dyn FnMut(
&mut dyn cosmic::iced::advanced::widget::Operation<Option<cosmic::iced::Rectangle>>,
),
) {
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<Option<cosmic::iced::Rectangle>> {
cosmic::iced::advanced::widget::operation::Outcome::Some(self.found)
}
}
+1 -1
View File
@@ -329,7 +329,7 @@ impl FereseShell {
.iter() .iter()
.find(|workspace| workspace.id == hover.workspace) .find(|workspace| workspace.id == hover.workspace)
}) else { }) else {
return self.render.text("").into(); return self.render.text(ferese_config::tr!("")).into();
}; };
let active = self let active = self
.snapshot .snapshot