diff --git a/Cargo.lock b/Cargo.lock index 1b76b78..d0f8026 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1657,6 +1657,7 @@ dependencies = [ "ferese-protocols", "fontdb", "image", + "jiff", "libc", "notify", "serde", @@ -1665,6 +1666,7 @@ dependencies = [ "tracing", "tracing-subscriber", "xcursor", + "zbus", ] [[package]] @@ -1678,9 +1680,11 @@ dependencies = [ name = "ferese-config" version = "0.1.0" dependencies = [ + "jiff", "kdl", "serde", "serde_json", + "tempfile", ] [[package]] @@ -1696,6 +1700,7 @@ dependencies = [ name = "ferese-ipc" version = "0.1.0" dependencies = [ + "ferese-config", "serde", "serde_json", ] @@ -1709,12 +1714,14 @@ name = "ferese-lock" version = "0.1.0" dependencies = [ "ferese-config", + "ferese-ipc", "ferese-theme", "image", "jiff", "libc", "libcosmic", "libloading", + "serde_json", "tempfile", "tokio", "wayland-client", @@ -1791,9 +1798,13 @@ name = "ferese-theme" version = "0.1.0" dependencies = [ "ferese-config", + "ferese-ipc", "ferese-protocols", + "iced_accessibility", "jiff", "libcosmic", + "serde_json", + "tokio", "wayland-client", ] diff --git a/README.md b/README.md index e08412d..b8366e9 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ lock screen, and screen sharing. Change themes and settings without restarting. - Scrolling and tree layouts, floating windows, and a workspace overview. - Configurable keyboard shortcuts and touchpad gestures. - Multiple monitors with fractional scaling. -- Six theme presets, wallpapers, blur, and adjustable corners. +- Six theme families, Light/Dark/Auto modes, custom KDL themes, wallpapers, blur, and adjustable corners. - Clock and sticky-note widgets. - Screenshots with Satty editing, screen sharing, and top-bar recording. diff --git a/assets/themes/licenses/catppuccin.txt b/assets/themes/licenses/catppuccin.txt new file mode 100644 index 0000000..006383b --- /dev/null +++ b/assets/themes/licenses/catppuccin.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Catppuccin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/assets/themes/licenses/everforest.txt b/assets/themes/licenses/everforest.txt new file mode 100644 index 0000000..546ed63 --- /dev/null +++ b/assets/themes/licenses/everforest.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 sainnhe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/assets/themes/licenses/gruvbox.txt b/assets/themes/licenses/gruvbox.txt new file mode 100644 index 0000000..c8a63f7 --- /dev/null +++ b/assets/themes/licenses/gruvbox.txt @@ -0,0 +1,24 @@ +Gruvbox palette by morhetz. The upstream README declares MIT/X11. +https://github.com/morhetz/gruvbox#license + +MIT License + +Copyright (c) morhetz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/assets/themes/licenses/rose-pine.txt b/assets/themes/licenses/rose-pine.txt new file mode 100644 index 0000000..590df78 --- /dev/null +++ b/assets/themes/licenses/rose-pine.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Rosé Pine + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/assets/themes/licenses/tokyo-night.txt b/assets/themes/licenses/tokyo-night.txt new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/assets/themes/licenses/tokyo-night.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/compositor/Cargo.toml b/compositor/Cargo.toml index b2246fc..026a488 100644 --- a/compositor/Cargo.toml +++ b/compositor/Cargo.toml @@ -16,7 +16,9 @@ ferese-ipc = { path = "../crates/ferese-ipc" } ferese-layout = { path = "../crates/ferese-layout" } ferese-protocols = { path = "../crates/ferese-protocols", features = ["server"] } libc.workspace = true +jiff.workspace = true notify = "8.2.0" +zbus = "5.19.0" image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] } smithay.workspace = true serde.workspace = true diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 0b9538d..f04e1df 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -1,7 +1,7 @@ use std::collections::{HashMap, HashSet}; use std::error::Error; +use std::fmt; use std::path::PathBuf; -use std::{fmt, fs, io}; use ferese_animation::SpringConfig; use ferese_core::LayoutMode; @@ -187,6 +187,7 @@ pub struct ThemeSettings { pub focus_ring_gradient: Option, pub shadow_color: RgbaColor, pub surface_base_color: RgbaColor, + pub bar_background_color: RgbaColor, pub text_primary_color: RgbaColor, pub shell_opacity: f64, pub inactive_dim: InactiveDimSettings, @@ -196,6 +197,7 @@ pub struct ThemeSettings { pub shadow_opacity: f64, pub material_style: MaterialStyle, pub backdrop_blur: f64, + pub material_tint_strength: f64, pub material_radius: f64, pub panel_radius: f64, } @@ -258,9 +260,23 @@ struct ThemeConfig { shadow: ThemeShadowConfig, #[serde(default)] material: ThemeMaterialConfig, + #[serde(default)] + surface: ThemeSurfaceConfig, +} + +#[derive(Debug, Default, Deserialize)] +struct ThemeSurfaceConfig { + #[serde(default)] + bar: ThemeBarConfig, +} + +#[derive(Debug, Default, Deserialize)] +struct ThemeBarConfig { + background: Option, } impl Config { + #[cfg(test)] pub(crate) fn parse_source(source: &str) -> Result { ferese_config::from_str(source).map_err(|source| ConfigError::Parse { path: config_path().unwrap_or_default(), @@ -268,6 +284,18 @@ impl Config { }) } + pub(crate) fn resolved_theme_settings( + theme: &ferese_config::theme::ResolvedTheme, + inactive_dim: InactiveDimSettings, + ) -> Result { + let mut config = Self::default(); + config.theme = serde_json::from_value(serde_json::to_value(&theme.tokens).unwrap()) + .expect("resolved theme matches runtime schema"); + let mut settings = config.theme_settings()?; + settings.inactive_dim = inactive_dim; + Ok(settings) + } + pub(crate) fn runtime_config(&self) -> Result { self.notifications.validate().map_err(ConfigError::InvalidBinding)?; self.desktop_widgets.validate().map_err(ConfigError::InvalidBinding)?; @@ -328,6 +356,8 @@ struct ThemeMaterialConfig { style: MaterialStyle, #[serde(default = "default_backdrop_blur")] blur_radius: f64, + #[serde(default = "default_material_tint_strength")] + tint_strength: f64, } impl Default for ThemeMaterialConfig { @@ -336,6 +366,7 @@ impl Default for ThemeMaterialConfig { style: MaterialStyle::Solid, opacity: default_shell_opacity(), blur_radius: default_backdrop_blur(), + tint_strength: default_material_tint_strength(), } } } @@ -344,6 +375,10 @@ fn default_shell_opacity() -> f64 { ferese_config::DEFAULT_MATERIAL_OPACITY } +fn default_material_tint_strength() -> f64 { + 0.5 +} + fn default_backdrop_blur() -> f64 { 12.0 } @@ -834,10 +869,7 @@ enum ColumnWidthValue { #[derive(Debug)] pub enum ConfigError { - Read { - path: PathBuf, - source: io::Error, - }, + #[cfg(test)] Parse { path: PathBuf, source: ferese_config::Error, @@ -870,9 +902,7 @@ pub enum ConfigError { impl fmt::Display for ConfigError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Read { path, source } => { - write!(formatter, "failed to read {}: {source}", path.display()) - } + #[cfg(test)] Self::Parse { path, source } => { write!(formatter, "failed to parse {}: {source}", path.display()) } @@ -904,7 +934,7 @@ impl fmt::Display for ConfigError { impl Error for ConfigError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { - Self::Read { source, .. } => Some(source), + #[cfg(test)] Self::Parse { source, .. } => Some(source), Self::InvalidColumnWidth { .. } | Self::InvalidAnimationValue { .. } @@ -919,19 +949,6 @@ impl Error for ConfigError { } impl Config { - pub fn load() -> Result { - let Some(path) = config_path() else { - return Ok(Self::default()); - }; - let source = match fs::read_to_string(&path) { - Ok(source) => source, - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Self::default()), - Err(source) => return Err(ConfigError::Read { path, source }), - }; - - ferese_config::from_str(&source).map_err(|source| ConfigError::Parse { path, source }) - } - pub fn layout_mode(&self) -> LayoutMode { match self.layout.mode.unwrap_or(LayoutModeValue::Scrolling) { LayoutModeValue::Scrolling => LayoutMode::Scrolling, @@ -1173,6 +1190,15 @@ impl Config { focus_ring_gradient: self.theme.focus_ring.settings("focus_ring")?, shadow_color: parse_color(&self.theme.colors.shadow, "colors.shadow")?, surface_base_color: parse_color(&self.theme.colors.surface_base, "colors.surface_base")?, + bar_background_color: parse_color( + self.theme + .surface + .bar + .background + .as_deref() + .unwrap_or(&self.theme.colors.surface_base), + "surface.bar.background", + )?, shell_opacity: unit_theme_value(self.theme.material.opacity, "material.opacity")?, inactive_dim: InactiveDimSettings { enabled: self.appearance.inactive_dim.enabled, @@ -1188,6 +1214,7 @@ impl Config { shadow_opacity, material_style: self.theme.material.style, backdrop_blur: nonnegative_theme_value(self.theme.material.blur_radius, "material.blur_radius")?.min(32.0), + material_tint_strength: unit_theme_value(self.theme.material.tint_strength, "material.tint_strength")?, material_radius, panel_radius: material_radius, }) @@ -2020,6 +2047,7 @@ mod tests { focus_ring_gradient: None, shadow_color: RgbaColor([1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 128.0 / 255.0]), surface_base_color: RgbaColor([17.0 / 255.0, 24.0 / 255.0, 33.0 / 255.0, 1.0,]), + bar_background_color: RgbaColor([17.0 / 255.0, 24.0 / 255.0, 33.0 / 255.0, 1.0,]), text_primary_color: RgbaColor([244.0 / 255.0, 247.0 / 255.0, 251.0 / 255.0, 1.0]), shell_opacity: 0.78, inactive_dim: InactiveDimSettings { @@ -2033,6 +2061,7 @@ mod tests { shadow_opacity: 0.4, material_style: MaterialStyle::Translucent, backdrop_blur: 12.0, + material_tint_strength: 0.5, material_radius: 14.0, panel_radius: 14.0, } @@ -2103,6 +2132,7 @@ mod tests { .theme_settings() .unwrap(); assert_eq!(theme.surface_base_color, RgbaColor([0.0, 0.0, 0.0, 1.0])); + assert_eq!(theme.bar_background_color, RgbaColor([1.0, 1.0, 1.0, 1.0])); } #[test] diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 83e967b..f777f7b 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -117,6 +117,7 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result) -> Result Response::error(request.id, error.code, error.message), + Ok(()) => match crate::theme::preview(&request.args) { + Ok(value) => Response::success(request.id, value), + Err(error) => Response::error(request.id, "invalid_config", error), + }, + }; + if write_frame(&mut stream, &response).is_err() { + return; + } + continue; + } let (response, receiver) = sync_channel(1); let exit_requested = request.command == "exit"; let screenshot_requested = matches!(request.command.as_str(), "screenshot" | "screenshot-window"); @@ -404,7 +434,9 @@ impl Ferese { if self.session_lock.active && !matches!( request.command.as_str(), - "portal-shortcuts-poll" + "theme-get" + | "theme-status" + | "portal-shortcuts-poll" | "input-capture-disable" | "input-capture-release" | "get-session-state" @@ -429,6 +461,14 @@ impl Ferese { fn dispatch_ipc_command(&mut self, owner: u64, command: &str, args: &Value) -> Result { match command { + "theme-get" | "theme-status" => return Ok(self.theme_engine.value()), + "theme-set-mode" => { + let mode = serde_json::from_value(args["mode"].clone()) + .map_err(|error| CommandError::new("invalid_argument", error.to_string()))?; + return self + .set_theme_mode(mode) + .map_err(|error| CommandError::new("invalid_config", error)); + } "portal-inhibit" => { let inhibition = serde_json::from_value(args.clone()) .map_err(|error| CommandError::new("invalid_argument", error.to_string()))?; diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 7d95efe..b1c8dde 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -24,6 +24,7 @@ mod session_lock; mod shell_control; mod stacking; mod state; +mod theme; mod wallpaper; mod window_rules; mod winit; @@ -40,7 +41,6 @@ pub use state::{Ferese, RuntimeConfig}; use tracing::{info, warn}; use crate::backends::LaunchConfig; -use crate::config::Config; fn main() -> Result<(), Box> { init_logging(); @@ -53,18 +53,27 @@ fn main() -> Result<(), Box> { return Err("usage: ferese --check-config PATH".into()); } let source = std::fs::read_to_string(&args[1])?; - Config::parse_source(&source)?.runtime_config()?; + theme::prepare( + &source, + std::path::Path::new(&args[1]) + .parent() + .unwrap_or(std::path::Path::new(".")), + )?; println!("Configuration is valid"); return Ok(()); } let launch = LaunchConfig::from_environment().map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; - let initial_source = config::config_path().and_then(|path| std::fs::read_to_string(path).ok()); - let config = match initial_source.as_deref() { - Some(source) => Config::parse_source(source)?, - None => Config::load()?, - }; + let initial_source = config::config_path() + .filter(|path| path.exists()) + .map(|path| theme::read_source(&path)) + .transpose()?; + let source = initial_source.as_deref().unwrap_or(""); + let directory = config::config_path() + .and_then(|path| path.parent().map(ToOwned::to_owned)) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let (config, runtime, candidate) = theme::prepare(source, &directory)?; let mut event_loop = EventLoop::try_new()?; let signals = Signals::new(&[Signal::SIGINT, Signal::SIGTERM])?; event_loop @@ -74,20 +83,11 @@ fn main() -> Result<(), Box> { state.loop_signal.stop(); })?; let display = Display::new()?; - let runtime = config.runtime_config()?; let mut state = Ferese::new(&mut event_loop, display, runtime)?; state.config_source = initial_source.filter(|source| source.len() <= 60 * 1024); + theme::init(&mut event_loop, &mut state, candidate)?; overview::init_font_loader(&mut event_loop, &mut state)?; backends::init(launch.backend, &mut event_loop, &mut state)?; - let mut monitor = config::config_path().and_then(|path| { - match reload::ConfigMonitor::with_wakeup(path, Some(event_loop.get_signal())) { - Ok(monitor) => Some(monitor), - Err(error) => { - warn!(%error, "automatic config watching unavailable; use feresectl reload-config"); - None - } - } - }); use calloop::timer::{TimeoutAction, Timer}; info!(socket = ?state.socket_name, backend = ?launch.backend, "Ferese is accepting Wayland clients"); let mut child = spawn_client(&mut state, launch.client, launch.client_capabilities); @@ -104,30 +104,9 @@ fn main() -> Result<(), Box> { TimeoutAction::ToDuration(Duration::from_secs(1)) }, )?; - let reload_timer_pending = std::rc::Rc::new(std::cell::Cell::new(false)); let reload_handle = event_loop.handle(); let result = event_loop.run(None, &mut state, |state| { - if let Some(monitor) = &mut monitor { - if let Some(result) = monitor.poll(std::time::Instant::now()) - && let Err(error) = result.and_then(|source| state.reload_config_source(source)) - { - warn!(%error, "config reload rejected; retaining last working config"); - } - // At most one timer exists. Edits arriving during debounce move the - // deadline; the old timer wakes once and is replaced if necessary. - if !reload_timer_pending.get() - && let Some(deadline) = monitor.next_deadline() - { - let pending = reload_timer_pending.clone(); - match reload_handle.insert_source(Timer::from_deadline(deadline), move |_, _, _| { - pending.set(false); - TimeoutAction::Drop - }) { - Ok(_) => reload_timer_pending.set(true), - Err(error) => warn!(%error, "could not schedule config debounce"), - } - } - } + state.poll_theme(); // The decoder wakes the loop after publishing its result, including // when no output has a pending frame. let wallpaper_changed = state.wallpaper.poll(); diff --git a/compositor/src/reload.rs b/compositor/src/reload.rs index d8c82f7..98e369a 100644 --- a/compositor/src/reload.rs +++ b/compositor/src/reload.rs @@ -1,11 +1,16 @@ //! Filesystem events wake the loop; only pending edits need a debounce timer. +#[cfg(test)] use std::io::Read; use std::path::PathBuf; +#[cfg(test)] use std::sync::mpsc; +#[cfg(test)] use std::time::{Duration, Instant}; +#[cfg(test)] use notify::{RecommendedWatcher, RecursiveMode, Watcher}; +#[cfg(test)] fn read_source(path: &std::path::Path) -> Result { let file = std::fs::File::open(path).map_err(|e| e.to_string())?; let mut source = String::new(); @@ -18,6 +23,7 @@ fn read_source(path: &std::path::Path) -> Result { Ok(source) } +#[cfg(test)] pub(crate) struct ConfigMonitor { _watcher: RecommendedWatcher, events: mpsc::Receiver<()>, @@ -26,6 +32,7 @@ pub(crate) struct ConfigMonitor { observed: Option, } +#[cfg(test)] impl ConfigMonitor { #[cfg(test)] pub(crate) fn new(path: PathBuf) -> notify::Result { @@ -115,13 +122,33 @@ impl crate::Ferese { if source.len() > 60 * 1024 { return Err("configuration exceeds the 60 KiB live-reload limit".into()); } - if self.config_source.as_ref() == Some(&source) { - return Ok(()); - } // Validate every runtime field before mutating any scene/input state. - let config = crate::config::Config::parse_source(&source).map_err(|e| e.to_string())?; - let runtime = config.runtime_config().map_err(|e| e.to_string())?; - self.apply_runtime_config(runtime)?; + let directory = crate::config::config_path() + .and_then(|path| path.parent().map(ToOwned::to_owned)) + .unwrap_or_else(|| PathBuf::from(".")); + let (_, mut runtime, candidate) = match crate::theme::prepare(&source, &directory) { + Ok(prepared) => prepared, + Err(error) => { + self.theme_engine.reject(error.clone()); + return Err(error); + } + }; + let wallpaper = runtime.wallpaper.clone(); + runtime.wallpaper = self.wallpaper.configuration().clone(); + let previous_theme = self.theme_settings; + let non_theme = |source: &str| -> Option { + let document = ferese_config::Document::parse(source).ok()?; + let mut value = document.value().clone(); + value.as_object_mut()?.remove("theme"); + Some(value) + }; + if self.config_source.as_deref().and_then(non_theme) != non_theme(&source) { + self.apply_runtime_config(runtime)?; + } + self.overview + .set_font_family(candidate.theme.tokens.typography.font_family.clone()); + self.theme_settings = previous_theme; + self.accept_theme(candidate, wallpaper); self.config_source = Some(source.clone()); self.shell_resources.retain(|resource| { if let Ok(shell) = resource.upgrade() { @@ -139,7 +166,7 @@ impl crate::Ferese { pub(crate) fn reload_config(&mut self) -> Result<(), String> { let path = crate::config::config_path().ok_or("config directory is unavailable")?; - let source = read_source(&path)?; + let source = crate::theme::read_source(&path)?; self.reload_config_source(source) } } diff --git a/compositor/src/state.rs b/compositor/src/state.rs index a0a5c4e..b489474 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -225,6 +225,7 @@ pub struct Ferese { pub(crate) session_lock: Lock, pub(crate) lock_idle: IdleSettings, pub(crate) config_source: Option, + pub(crate) theme_engine: crate::theme::Engine, pub start_time: Instant, pub socket_name: OsString, pub display_handle: DisplayHandle, @@ -439,6 +440,7 @@ impl Ferese { session_lock: crate::session_lock::Lock::default(), lock_idle: config.lock_idle, config_source: None, + theme_engine: Default::default(), start_time, socket_name, display_handle, diff --git a/compositor/src/theme.rs b/compositor/src/theme.rs new file mode 100644 index 0000000..db0288e --- /dev/null +++ b/compositor/src/theme.rs @@ -0,0 +1,619 @@ +use std::collections::{HashMap, HashSet}; +use std::io::{self, Read}; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, mpsc}; +use std::time::{Duration, Instant}; + +use calloop::generic::Generic; +use calloop::timer::{TimeoutAction, Timer}; +use calloop::{Interest, Mode as PollMode, PostAction}; +use ferese_config::Document; +use ferese_config::theme::{Candidate, Mode, Snapshot, TRANSITION_MS, resolve_with_context}; +use ferese_ipc::Response; +use jiff::Timestamp; +use notify::{RecommendedWatcher, RecursiveMode, Watcher}; +use serde_json::{Value, json}; +use smithay::backend::renderer::gles::GlesRenderer; +use smithay::output::Output; + +use crate::config::Config; +use crate::wallpaper::{WallpaperConfig, WallpaperState}; +use crate::{Ferese, RuntimeConfig}; + +const DEBOUNCE: Duration = Duration::from_millis(150); +const WALLPAPER_TIMEOUT: Duration = Duration::from_secs(2); + +pub(crate) fn read_source(path: &Path) -> Result { + let mut source = String::new(); + std::fs::File::open(path) + .map_err(|e| format!("{}: {e}", path.display()))? + .take(60 * 1024 + 1) + .read_to_string(&mut source) + .map_err(|e| e.to_string())?; + if source.len() > 60 * 1024 { + return Err(format!("{} exceeds 60 KiB", path.display())); + } + Ok(source) +} + +pub(crate) fn prepare(source: &str, directory: &Path) -> Result<(Config, RuntimeConfig, Candidate), String> { + let document = Document::parse(source).map_err(|e| e.to_string())?; + let candidate = resolve_with_context(&document, directory, Timestamp::now(), &auto_context(), read_source)?; + let config: Config = + serde_json::from_value(document.with_theme(&candidate.theme).value().clone()).map_err(|e| e.to_string())?; + let runtime = config.runtime_config().map_err(|e| e.to_string())?; + Ok((config, runtime, candidate)) +} + +pub(crate) fn preview(args: &Value) -> Result { + let source = args["source"] + .as_str() + .filter(|source| source.len() <= 60 * 1024) + .ok_or("Missing candidate configuration")?; + let directory = args["directory"] + .as_str() + .map(PathBuf::from) + .or_else(|| crate::config::config_path().and_then(|path| path.parent().map(ToOwned::to_owned))) + .unwrap_or_else(|| PathBuf::from(".")); + let document = Document::parse(source).map_err(|e| e.to_string())?; + let candidate = resolve_with_context(&document, &directory, Timestamp::now(), &auto_context(), read_source)?; + Ok( + json!({"theme": candidate.theme, "warnings": candidate.warnings, "families": candidate.families, "fallback_note": candidate.fallback_note}), + ) +} + +fn system_appearance_value(value: &str) -> Option { + use ferese_config::theme::Appearance; + if value.contains("prefer-dark") { + Some(Appearance::Dark) + } else if value.contains("prefer-light") || value.contains("default") { + Some(Appearance::Light) + } else { + None + } +} + +static SYSTEM_APPEARANCE: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +fn auto_context() -> ferese_config::theme::AutoContext { + let appearance = SYSTEM_APPEARANCE.get_or_init(|| { + let value = std::process::Command::new("gsettings") + .args(["get", "org.gnome.desktop.interface", "color-scheme"]) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| system_appearance_value(&String::from_utf8_lossy(&output.stdout))); + Mutex::new(value) + }); + ferese_config::theme::AutoContext { + system: *appearance.lock().unwrap(), + } +} + +struct Pending { + candidate: Candidate, + wallpaper: Option, + ready: HashSet, + deadline: Instant, +} + +struct Transition { + from: ferese_config::theme::ResolvedTheme, + started: Instant, + previous_wallpaper: Option, +} + +#[derive(Default)] +pub(crate) struct Engine { + pub(crate) snapshot: Snapshot, + waiters: HashMap)>, + watcher: Option, + watched_directories: HashSet, + active_files: Vec, + paths: Arc>>, + events: Option>, + dirty: Option, + timer_pending: bool, + clock: Option, + pending: Option, + transition: Option, + last_frame: Option, +} + +impl Engine { + pub(crate) fn watch(&mut self, owner: u64, id: u64, since: u64, response: mpsc::SyncSender) { + if since != self.snapshot.revision { + let _ = response.try_send(Response::success(id, self.value())); + } else { + self.waiters.insert(owner, (id, response)); + } + } + + pub(crate) fn remove(&mut self, owner: u64) { + self.waiters.remove(&owner); + } + + pub(crate) fn value(&self) -> Value { + serde_json::to_value(&self.snapshot).expect("serializable theme snapshot") + } + + fn publish(&mut self) { + self.snapshot.revision = self.snapshot.revision.wrapping_add(1); + let value = self.value(); + for (_, (id, response)) in self.waiters.drain() { + let _ = response.try_send(Response::success(id, value.clone())); + } + } + + pub(crate) fn reject(&mut self, error: String) { + if self.snapshot.error.as_ref() != Some(&error) { + tracing::warn!(%error, "theme reload rejected; retaining active theme"); + self.snapshot.error = Some(error); + self.publish(); + } + } + + fn watch_files(&mut self, files: &[PathBuf]) { + let mut targets = HashSet::from([PathBuf::from("/etc/localtime")]); + if let Some(path) = crate::config::config_path() { + targets.insert(path); + } + for path in self.active_files.iter().chain(files) { + let path = if let (Some(parent), Some(name)) = (path.parent(), path.file_name()) { + parent.canonicalize().unwrap_or_else(|_| parent.to_owned()).join(name) + } else { + path.clone() + }; + if let Ok(target) = path.canonicalize() { + targets.insert(target); + } + targets.insert(path); + } + let mut directories: HashSet<_> = targets + .iter() + .map(|path| { + let mut directory = path.parent().unwrap_or(Path::new(".")); + while !directory.is_dir() { + directory = directory.parent().unwrap_or(Path::new("/")); + } + directory.to_owned() + }) + .collect(); + *self.paths.lock().unwrap() = targets; + if let Some(watcher) = &mut self.watcher { + for directory in self.watched_directories.difference(&directories) { + let _ = watcher.unwatch(directory); + } + let mut failed = Vec::new(); + for directory in directories.difference(&self.watched_directories) { + let mode = if directory == Path::new("/etc") { + RecursiveMode::NonRecursive + } else { + RecursiveMode::Recursive + }; + if let Err(error) = watcher.watch(directory, mode) { + tracing::warn!(%error, path = %directory.display(), "cannot watch theme directory"); + failed.push(directory.clone()); + } + } + for directory in failed { + directories.remove(&directory); + } + } + self.watched_directories = directories; + } + + fn arm_clock(&self, next: Option) -> io::Result<()> { + let Some(fd) = &self.clock else { return Ok(()) }; + let mut timer: libc::itimerspec = unsafe { std::mem::zeroed() }; + if let Some(next) = next { + timer.it_value.tv_sec = next.as_second(); + timer.it_value.tv_nsec = i64::from(next.subsec_nanosecond()); + } + let result = unsafe { + libc::timerfd_settime( + fd.as_raw_fd(), + libc::TFD_TIMER_ABSTIME | libc::TFD_TIMER_CANCEL_ON_SET, + &timer, + std::ptr::null_mut(), + ) + }; + if result < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + } +} + +pub(crate) fn init( + event_loop: &mut calloop::EventLoop<'static, Ferese>, + state: &mut Ferese, + candidate: Candidate, +) -> Result<(), Box> { + let clock = unsafe { libc::timerfd_create(libc::CLOCK_REALTIME, libc::TFD_CLOEXEC | libc::TFD_NONBLOCK) }; + if clock < 0 { + return Err(io::Error::last_os_error().into()); + } + let clock = unsafe { OwnedFd::from_raw_fd(clock) }; + let source = clock.try_clone()?; + state.theme_engine.clock = Some(clock); + event_loop + .handle() + .insert_source(Generic::new(source, Interest::READ, PollMode::Level), |_, _, state| { + if let Some(clock) = &state.theme_engine.clock { + let mut expirations = 0u64; + unsafe { libc::read(clock.as_raw_fd(), (&mut expirations as *mut u64).cast(), 8) }; + } + state.refresh_theme(); + Ok(PostAction::Continue) + })?; + let (sender, events) = mpsc::sync_channel(1); + let paths = state.theme_engine.paths.clone(); + if let Some(config) = crate::config::config_path() { + paths.lock().unwrap().insert(config); + } + paths.lock().unwrap().insert(PathBuf::from("/etc/localtime")); + let wakeup = state.loop_signal.clone(); + let resume_sender = sender.clone(); + let resume_wakeup = wakeup.clone(); + std::thread::Builder::new() + .name("ferese-theme-resume".into()) + .spawn(move || { + let monitor = || -> zbus::Result<()> { + let connection = zbus::blocking::Connection::system()?; + let proxy = zbus::blocking::Proxy::new( + &connection, + "org.freedesktop.login1", + "/org/freedesktop/login1", + "org.freedesktop.login1.Manager", + )?; + for message in proxy.receive_signal("PrepareForSleep")? { + let (sleeping,): (bool,) = message.body().deserialize()?; + if !sleeping { + let _ = resume_sender.try_send(()); + resume_wakeup.wakeup(); + } + } + Ok(()) + }; + if let Err(error) = monitor() { + tracing::warn!(%error, "theme resume monitor unavailable"); + } + })?; + let system_sender = sender.clone(); + let system_wakeup = wakeup.clone(); + std::thread::Builder::new() + .name("ferese-system-appearance".into()) + .spawn(move || { + use std::io::BufRead; + use std::os::unix::process::CommandExt; + let mut command = std::process::Command::new("gsettings"); + unsafe { + command.pre_exec(|| { + if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + let monitor = command + .args(["monitor", "org.gnome.desktop.interface", "color-scheme"]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn(); + if let Ok(mut monitor) = monitor { + if let Ok(output) = std::process::Command::new("gsettings") + .args(["get", "org.gnome.desktop.interface", "color-scheme"]) + .output() + { + if let Some(state) = SYSTEM_APPEARANCE.get() { + *state.lock().unwrap() = system_appearance_value(&String::from_utf8_lossy(&output.stdout)); + } + let _ = system_sender.try_send(()); + system_wakeup.wakeup(); + } + if let Some(output) = monitor.stdout.take() { + for line in std::io::BufReader::new(output).lines().map_while(Result::ok) { + let value = system_appearance_value(&line); + if let Some(state) = SYSTEM_APPEARANCE.get() { + *state.lock().unwrap() = value; + } + let _ = system_sender.try_send(()); + system_wakeup.wakeup(); + } + } + let _ = monitor.wait(); + } + })?; + let watcher = notify::recommended_watcher(move |event: notify::Result| { + if let Ok(event) = event + && !event.kind.is_access() + && event.paths.iter().any(|path| { + paths + .lock() + .unwrap() + .iter() + .any(|target| target == path || target.starts_with(path)) + }) + { + let _ = sender.try_send(()); + wakeup.wakeup(); + } + })?; + state.theme_engine.watcher = Some(watcher); + state.theme_engine.events = Some(events); + state.theme_engine.active_files = candidate.files.clone(); + state.theme_engine.watch_files(&candidate.files); + state.theme_engine.arm_clock(candidate.next_transition)?; + state.theme_engine.snapshot.families = candidate.families; + state.theme_engine.snapshot.fallback_note = candidate.fallback_note; + state.theme_engine.snapshot.mode = candidate.policy.mode; + state.theme_engine.snapshot.theme = candidate.theme.clone(); + state.theme_engine.snapshot.presented = candidate.theme; + state.theme_engine.snapshot.warnings = candidate.warnings; + state.theme_engine.publish(); + Ok(()) +} + +impl Ferese { + pub(crate) fn refresh_theme(&mut self) { + let result = crate::config::config_path() + .map_or_else( + || { + self.config_source + .clone() + .ok_or_else(|| "No configuration available".into()) + }, + |path| { + if path.exists() { + read_source(&path) + } else { + Ok(String::new()) + } + }, + ) + .and_then(|source| { + if let Ok(document) = Document::parse(&source) + && let Some(value) = document.get("theme") + && let Ok(policy) = serde_json::from_value::(value.clone()) + { + let directory = crate::config::config_path() + .and_then(|path| path.parent().map(ToOwned::to_owned)) + .unwrap_or_else(|| PathBuf::from(".")); + let files: Vec<_> = [&policy.file, &policy.light.file, &policy.dark.file] + .into_iter() + .flatten() + .filter(|path| !path.as_os_str().is_empty()) + .map(|path| ferese_config::theme::theme_path(&directory, path)) + .collect(); + let mut files = files; + files.extend( + policy + .custom_themes + .values() + .map(|theme| ferese_config::theme::theme_path(&directory, &theme.file)), + ); + self.theme_engine.watch_files(&files); + } + self.reload_config_source(source) + }); + if let Err(error) = result { + self.theme_engine.reject(error); + } + } + + pub(crate) fn accept_theme(&mut self, candidate: Candidate, wallpaper: WallpaperConfig) { + self.theme_engine.active_files = candidate.files.clone(); + self.theme_engine.watch_files(&candidate.files); + if let Err(error) = self.theme_engine.arm_clock(candidate.next_transition) { + tracing::warn!(%error, "cannot arm appearance schedule"); + } + if candidate.theme == self.theme_engine.snapshot.theme && self.theme_engine.pending.is_none() { + let changed = self.theme_engine.snapshot.mode != candidate.policy.mode + || self.theme_engine.snapshot.warnings != candidate.warnings + || self.theme_engine.snapshot.error.is_some() + || self.theme_engine.snapshot.families != candidate.families + || self.theme_engine.snapshot.fallback_note != candidate.fallback_note; + self.theme_engine.snapshot.families = candidate.families; + self.theme_engine.snapshot.fallback_note = candidate.fallback_note; + self.theme_engine.snapshot.mode = candidate.policy.mode; + self.theme_engine.snapshot.warnings = candidate.warnings; + self.theme_engine.snapshot.error = None; + if changed { + self.theme_engine.publish(); + } + return; + } + if let Some(pending) = &mut self.theme_engine.pending + && pending + .wallpaper + .as_ref() + .is_some_and(|state| state.configuration() == &wallpaper) + { + pending.candidate = candidate; + self.poll_theme(); + return; + } + let wallpaper = (self.wallpaper.configuration() != &wallpaper) + .then(|| WallpaperState::with_wakeup(wallpaper, Some(self.loop_signal.clone()))); + self.theme_engine.pending = Some(Pending { + candidate, + wallpaper, + ready: HashSet::new(), + deadline: Instant::now() + WALLPAPER_TIMEOUT, + }); + self.poll_theme(); + } + + pub(crate) fn prepare_theme_wallpaper(&mut self, renderer: &mut GlesRenderer, output: &Output) { + if let Some(pending) = &mut self.theme_engine.pending + && let Some(wallpaper) = &mut pending.wallpaper + { + wallpaper.poll(); + if wallpaper.element(renderer, output).is_some() || wallpaper.configuration().path.is_none() { + pending.ready.insert(output.clone()); + } + } + } + + pub(crate) fn previous_theme_wallpaper( + &mut self, + renderer: &mut GlesRenderer, + output: &Output, + ) -> Option { + let transition = self.theme_engine.transition.as_mut()?; + transition.previous_wallpaper.as_mut()?.element(renderer, output) + } + + pub(crate) fn theme_progress(&self) -> f32 { + self.theme_engine + .transition + .as_ref() + .filter(|transition| transition.previous_wallpaper.is_some()) + .map_or(1., |transition| { + (transition.started.elapsed().as_secs_f32() / (TRANSITION_MS as f32 / 1000.)).clamp(0., 1.) + }) + } + + pub(crate) fn poll_theme(&mut self) { + let now = Instant::now(); + if self + .theme_engine + .events + .as_ref() + .is_some_and(|events| events.try_iter().next().is_some()) + { + self.theme_engine.dirty = Some(now + DEBOUNCE); + } + if self.theme_engine.dirty.is_some_and(|deadline| now >= deadline) { + self.theme_engine.dirty = None; + self.refresh_theme(); + } + let mut start = false; + if let Some(pending) = &mut self.theme_engine.pending { + if let Some(wallpaper) = &mut pending.wallpaper { + wallpaper.poll(); + } + let ready = + pending.wallpaper.is_none() || self.space.outputs().all(|output| pending.ready.contains(output)); + let failed = pending.wallpaper.as_ref().is_some_and(WallpaperState::failed); + start = ready || failed || now >= pending.deadline; + if start && !ready { + pending.wallpaper = None; + pending + .candidate + .warnings + .push("Wallpaper unavailable; retained previous image".into()); + tracing::warn!("theme wallpaper was not ready; retaining previous image"); + } + } + if start { + let pending = self.theme_engine.pending.take().unwrap(); + let from = self.theme_engine.snapshot.presented.clone(); + let previous_wallpaper = pending + .wallpaper + .map(|next| std::mem::replace(&mut self.wallpaper, next)); + self.theme_engine.snapshot.families = pending.candidate.families; + self.theme_engine.snapshot.fallback_note = pending.candidate.fallback_note; + self.theme_engine.snapshot.mode = pending.candidate.policy.mode; + self.theme_engine.snapshot.theme = pending.candidate.theme; + self.theme_engine.snapshot.warnings = pending.candidate.warnings; + self.theme_engine.snapshot.error = None; + self.theme_engine.transition = Some(Transition { + from, + started: now, + previous_wallpaper, + }); + self.theme_engine.last_frame = None; + // Publish the target appearance before drawing transitional tokens. + self.theme_engine.publish(); + } + let frame_due = self + .theme_engine + .last_frame + .is_none_or(|last| now.saturating_duration_since(last) >= Duration::from_millis(16)); + if frame_due && let Some(transition) = &self.theme_engine.transition { + self.theme_engine.last_frame = Some(now); + let progress = + now.saturating_duration_since(transition.started).as_secs_f64() / (TRANSITION_MS as f64 / 1000.); + let target = &self.theme_engine.snapshot.theme; + let complete = progress >= 1. || target.reduced_motion; + let presented = if complete { + target.clone() + } else { + transition.from.transition(target, progress) + }; + self.theme_settings = Config::resolved_theme_settings(&presented, self.theme_settings.inactive_dim) + .expect("validated resolved theme"); + self.theme_engine.snapshot.presented = presented; + self.theme_engine.publish(); + self.backdrop_generation = self.backdrop_generation.wrapping_add(1); + if complete { + self.theme_engine.transition = None; + } + crate::backends::direct::render_all(self); + } else if frame_due && self.theme_engine.pending.is_some() { + self.theme_engine.last_frame = Some(now); + crate::backends::direct::render_all(self); + } + if !self.theme_engine.timer_pending { + let deadline = if self.theme_engine.transition.is_some() || self.theme_engine.pending.is_some() { + Some(now + Duration::from_millis(16)) + } else { + self.theme_engine.dirty + }; + if let Some(deadline) = deadline { + self.theme_engine.timer_pending = true; + if let Err(error) = self + .loop_handle + .insert_source(Timer::from_deadline(deadline), |_, _, state| { + state.theme_engine.timer_pending = false; + state.poll_theme(); + TimeoutAction::Drop + }) + { + self.theme_engine.timer_pending = false; + tracing::warn!(%error, "cannot schedule theme update"); + } + } + } + } + + pub(crate) fn set_theme_mode(&mut self, mode: Mode) -> Result { + let path = crate::config::config_path().ok_or("No configuration directory")?; + let original = read_source(&path)?; + let mut document = Document::parse(&original).map_err(|e| e.to_string())?; + document.set("theme.mode", json!(mode)).map_err(|e| e.to_string())?; + let source = document.to_string(); + prepare(&source, path.parent().unwrap_or(Path::new(".")))?; + let temporary = path.with_extension(format!("kdl.{}.tmp", std::process::id())); + let result = (|| -> Result<(), String> { + use std::io::Write; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + .map_err(|e| e.to_string())?; + if let Ok(metadata) = std::fs::metadata(&path) { + file.set_permissions(metadata.permissions()) + .map_err(|e| e.to_string())?; + } + file.write_all(source.as_bytes()).map_err(|e| e.to_string())?; + file.sync_all().map_err(|e| e.to_string())?; + if read_source(&path)? != original { + return Err("Configuration changed while updating the mode; please try again".into()); + } + std::fs::rename(&temporary, &path).map_err(|e| e.to_string())?; + Ok(()) + })(); + if result.is_err() { + let _ = std::fs::remove_file(temporary); + } + result?; + self.reload_config_source(source)?; + Ok(self.theme_engine.value()) + } +} diff --git a/compositor/src/wallpaper.rs b/compositor/src/wallpaper.rs index 5e5984e..df56f0e 100644 --- a/compositor/src/wallpaper.rs +++ b/compositor/src/wallpaper.rs @@ -113,6 +113,14 @@ impl WallpaperState { } } + pub(crate) fn configuration(&self) -> &WallpaperConfig { + &self.config + } + + pub(crate) fn failed(&self) -> bool { + self.config.path.is_some() && self.receiver.is_none() && self.pixels.is_none() + } + #[cfg(test)] pub fn owns_background(&self) -> bool { self.owned diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index 300a0e5..1646a69 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -442,8 +442,6 @@ void main() { } "#; -const BLURRED_MATERIAL_TINT: f32 = 0.5; - #[derive(Clone, Debug)] pub(crate) struct BlurProgram(GlesTexProgram); @@ -1378,9 +1376,20 @@ fn output_elements( output, &[Layer::Bottom, Layer::Background], )); - if let Some(wallpaper) = state.wallpaper.element(renderer, output) { + state.prepare_theme_wallpaper(renderer, output); + let progress = state.theme_progress(); + let wallpaper = state.wallpaper.element(renderer, output); + let has_wallpaper = wallpaper.is_some(); + if let Some(mut wallpaper) = wallpaper { + wallpaper.alpha = progress; elements.push(wallpaper.into()); } + if let Some(mut previous) = state.previous_theme_wallpaper(renderer, output) { + if !has_wallpaper { + previous.alpha = 1. - progress; + } + elements.push(previous.into()); + } elements } @@ -2397,14 +2406,19 @@ fn material_element( let mode = output.current_mode()?; let scale = output.current_scale().fractional_scale(); let transform = output.current_transform().invert(); - let [red, green, blue, _] = state.theme_settings.surface_base_color.0; + let background = if role == crate::effects::SemanticRole::Panel { + state.theme_settings.bar_background_color + } else { + state.theme_settings.surface_base_color + }; + let [red, green, blue, _] = background.0; let radius = radius.min(geometry.size.w.min(geometry.size.h) as f32 * 0.5); let [offset_y, shadow_blur, shadow_opacity] = material.shadow; let edge_bar = role == crate::effects::SemanticRole::Panel && radius == 0.0 && geometry.loc.y == 0; let shadow_opacity = if edge_bar { 0.0 } else { - shadow_opacity * f64::from(presentation_alpha) + shadow_opacity * (state.theme_settings.shadow_opacity / 0.2) * f64::from(presentation_alpha) }; let shadow_geometry = Rectangle::new( (geometry.loc.x, geometry.loc.y + offset_y.round() as i32).into(), @@ -2479,7 +2493,7 @@ fn material_element( None }; if capture.is_some() { - parameters.tint[3] = BLURRED_MATERIAL_TINT; + parameters.tint[3] = state.theme_settings.material_tint_strength as f32; } let make_element = || { if let Some((capture, program)) = &capture { @@ -2771,7 +2785,12 @@ mod tests { let mut target_texture: GlesTexture = renderer.create_buffer(Fourcc::Abgr8888, size).unwrap(); let damage = Rectangle::::from_size((32, 32).into()); - for opacity in [0.2f32, 0.71, 1.0] { + for (opacity, strength, tint) in [ + (0.2f32, 0.5f32, 0.1f32), + (0.71, 0.5, 0.1), + (1.0, 0.5, 0.1), + (0.94, 1.0, 245.0 / 255.0), + ] { let uniforms = vec![ Uniform::new("visible_rect", [0.0f32, 0.0, 32.0, 32.0]), Uniform::new("material_radius", 0.0f32), @@ -2780,7 +2799,7 @@ mod tests { Uniform::new("blur_radius", 12.0f32), Uniform::new("presentation_alpha", 1.0f32), Uniform::new("background_opacity", opacity), - Uniform::new("tint", [0.1f32, 0.1, 0.1, super::BLURRED_MATERIAL_TINT]), + Uniform::new("tint", [tint, tint, tint, strength]), ]; let mut target = renderer.bind(&mut target_texture).unwrap(); { @@ -2820,9 +2839,7 @@ mod tests { left.abs_diff(right) <= 2, "opacity {opacity} leaked the original backdrop: {left} versus {right}" ); - let expected = (128.0 * (1.0 - super::BLURRED_MATERIAL_TINT * opacity) - + 25.5 * super::BLURRED_MATERIAL_TINT * opacity) - .round() as u8; + let expected = (128.0 * (1.0 - strength * opacity) + tint * 255.0 * strength * opacity).round() as u8; assert!( left.abs_diff(expected) <= 2, "opacity {opacity} failed to control tint: {left} versus {expected}" diff --git a/crates/ferese-config/Cargo.toml b/crates/ferese-config/Cargo.toml index 1933dda..5a8eb77 100644 --- a/crates/ferese-config/Cargo.toml +++ b/crates/ferese-config/Cargo.toml @@ -6,6 +6,10 @@ license.workspace = true rust-version = "1.95" [dependencies] +jiff.workspace = true kdl = "6.7.1" serde.workspace = true serde_json.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/ferese-config/src/families.rs b/crates/ferese-config/src/families.rs new file mode 100644 index 0000000..0ef5058 --- /dev/null +++ b/crates/ferese-config/src/families.rs @@ -0,0 +1,92 @@ +use serde::{Deserialize, Serialize}; + +use crate::theme::{Appearance, Tokens}; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub struct Palette { + pub background: String, + pub bar: String, + pub surface: String, + pub text: String, + pub muted: String, + pub accent: String, +} + +impl From<&Tokens> for Palette { + fn from(tokens: &Tokens) -> Self { + Self { + background: tokens.colors.surface_base.clone(), + bar: tokens.surface.bar.background.clone(), + surface: tokens.colors.surface_raised.clone(), + text: tokens.colors.text_primary.clone(), + muted: tokens.colors.text_muted.clone(), + accent: tokens.colors.accent.clone(), + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub struct Family { + pub id: String, + pub name: String, + pub light: Option, + pub dark: Option, +} + +impl Family { + pub fn palette(&self, appearance: Appearance) -> Option<&Palette> { + match appearance { + Appearance::Light => self.light.as_ref(), + Appearance::Dark => self.dark.as_ref(), + } + } + + pub fn availability(&self) -> &'static str { + match (self.light.is_some(), self.dark.is_some()) { + (true, true) => "light and dark", + (true, false) => "light only", + _ => "dark only", + } + } +} + +pub const BUILTINS: [(&str, &str, &str, &str); 6] = [ + ("ferese-blue", "Ferese Blue", "ferese-blue-light", "ferese-blue"), + ("catppuccin", "Catppuccin", "catppuccin-latte", "catppuccin-mocha"), + ("gruvbox", "Gruvbox", "gruvbox-light", "gruvbox"), + ("rose-pine", "Rosé Pine", "rose-pine-dawn", "rose-pine-moon"), + ("tokyo-night", "Tokyo Night", "tokyo-night-day", "tokyo-night"), + ("everforest", "Everforest", "everforest-light", "everforest-dark"), +]; + +pub fn family_id(preset: &str) -> &str { + BUILTINS + .iter() + .find(|row| row.2 == preset || row.3 == preset) + .map_or(preset, |row| row.0) +} + +pub fn variant(id: &str, appearance: Appearance) -> Option<&'static str> { + if let Some(row) = BUILTINS.iter().find(|row| row.0 == id) { + return Some(if appearance == Appearance::Light { row.2 } else { row.3 }); + } + match (id, appearance) { + ("dracula", Appearance::Dark) => Some("dracula"), + ("monochrome", Appearance::Dark) => Some("monochrome"), + ("monokai", Appearance::Dark) => Some("monokai"), + ("ayu-light", Appearance::Light) => Some("ayu-light"), + _ => None, + } +} + +pub fn builtins() -> Vec { + BUILTINS + .iter() + .map(|(id, name, light, dark)| Family { + id: (*id).into(), + name: (*name).into(), + light: Some(Palette::from(&crate::theme::preset(light, Appearance::Light).unwrap())), + dark: Some(Palette::from(&crate::theme::preset(dark, Appearance::Dark).unwrap())), + }) + .collect() +} diff --git a/crates/ferese-config/src/lib.rs b/crates/ferese-config/src/lib.rs index dfcf406..d025917 100644 --- a/crates/ferese-config/src/lib.rs +++ b/crates/ferese-config/src/lib.rs @@ -1,3 +1,7 @@ +pub mod families; +pub mod presets; +pub mod theme; + use std::fmt; use std::path::PathBuf; @@ -45,7 +49,7 @@ pub fn default_wallpaper() -> &'static str { } fn field(name: &str, parent: &str) -> String { - if parent == "commands" { + if matches!(parent, "commands" | "custom_themes") { return name.to_owned(); } @@ -210,6 +214,14 @@ impl Document { &self.value } + /// A runtime view; the original KDL remains available for comment-preserving edits. + pub fn with_theme(&self, theme: &theme::ResolvedTheme) -> Self { + let mut document = self.clone(); + document.value["theme"] = serde_json::to_value(&theme.tokens).expect("serializable theme tokens"); + document.value["theme"]["appearance"] = serde_json::to_value(theme.appearance).unwrap(); + document + } + pub fn get(&self, path: &str) -> Option<&Value> { let mut value = &self.value; @@ -229,7 +241,26 @@ impl Document { } pub fn set(&mut self, path: &str, value: Value) -> Result<(), Error> { + if value.as_str() == Some("") + && matches!( + path, + "theme.file" | "theme.light.file" | "theme.dark.file" | "theme.accent" + ) + { + return self.unset(path); + } let mut candidate = self.clone(); + if matches!( + path, + "theme.mode" + | "theme.family" + | "theme.light.family" + | "theme.dark.family" + | "theme.light.preset" + | "theme.dark.preset" + ) { + candidate.migrate_theme_selection()?; + } let parts = path.split('.').collect::>(); set_in(&mut candidate.doc, &parts, "", value)?; format_document(&mut candidate.doc); @@ -262,6 +293,67 @@ impl Document { Ok(()) } + fn migrate_theme_selection(&mut self) -> Result<(), Error> { + if self.get("theme.mode").is_some() { + return Ok(()); + } + if self.get("theme.light").is_some() || self.get("theme.dark").is_some() { + set_in(&mut self.doc, &["theme", "mode"], "", Value::String("dark".into()))?; + return self.refresh(); + } + let Some(index) = self.doc.nodes().iter().position(|node| node.name().value() == "theme") else { + return Ok(()); + }; + if let Some(children) = self.doc.nodes_mut()[index].children_mut().as_mut() { + let mut moved = Vec::new(); + for name in ["colors", "surface", "border", "focus-ring"] { + if let Some(index) = children.nodes().iter().position(|node| node.name().value() == name) { + moved.push(children.nodes_mut().remove(index)); + } + } + if !moved.is_empty() { + let dark = section_mut(children, &["dark"], "theme")?; + dark.nodes_mut().extend(moved); + } + } + set_in(&mut self.doc, &["theme", "mode"], "", Value::String("dark".into()))?; + self.refresh() + } + + pub fn unset(&mut self, path: &str) -> Result<(), Error> { + if self.get(path).is_none() { + return Ok(()); + } + let mut candidate = self.clone(); + let (name, parents) = path.rsplit_once('.').map_or((path, ""), |(p, n)| (n, p)); + let doc = section_mut( + &mut candidate.doc, + &parents.split('.').filter(|s| !s.is_empty()).collect::>(), + "", + )?; + if let Some(index) = doc + .nodes() + .iter() + .position(|node| field(node.name().value(), parents.rsplit('.').next().unwrap_or("")) == name) + { + let node = doc.nodes_mut().remove(index); + let mut comments = String::new(); + collect_comments(&node, &mut comments); + if let Some(format) = doc.format_mut() { + format.trailing.push_str(&comments); + } else { + doc.set_format(kdl::KdlDocumentFormat { + leading: String::new(), + trailing: comments, + }); + } + } + format_document(&mut candidate.doc); + candidate.refresh()?; + *self = candidate; + Ok(()) + } + pub fn remove(&mut self, path: &str, index: usize) -> Result<(), Error> { let mut candidate = self.clone(); let (last, parents) = path.rsplit_once('.').map_or((path, ""), |(p, l)| (l, p)); @@ -288,6 +380,37 @@ impl Document { } } +fn collect_comments(node: &KdlNode, output: &mut String) { + let mut keep = |text: &str| { + if text.contains("//") || text.contains("/*") { + output.push_str(text); + output.push('\n'); + } + }; + if let Some(format) = node.format() { + keep(&format.leading); + keep(&format.before_children); + keep(&format.before_terminator); + keep(&format.terminator); + keep(&format.trailing); + } + for entry in node.entries() { + if let Some(format) = entry.format() { + keep(&format.leading); + keep(&format.trailing); + } + } + if let Some(children) = node.children() { + if let Some(format) = children.format() { + keep(&format.leading); + keep(&format.trailing); + } + for child in children.nodes() { + collect_comments(child, output); + } + } +} + impl fmt::Display for Document { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.doc.fmt(f) @@ -506,7 +629,7 @@ fn kdl_value(value: &Value) -> Result { } fn node_name(key: &str, parent: &str) -> String { - if parent == "commands" { + if matches!(parent, "commands" | "custom_themes") { return key.into(); } @@ -599,6 +722,33 @@ pub fn from_str(source: &str) -> Result { #[cfg(test)] mod tests { use super::*; + #[test] + fn adopting_appearance_modes_preserves_legacy_colors_and_comments() { + let mut document = Document::parse( + "// my desktop\ntheme {\n colors {\n accent \"#123456\" // my accent\n }\n}\n", + ) + .unwrap(); + document.set("theme.mode", serde_json::json!("light")).unwrap(); + assert_eq!( + document.get("theme.dark.colors.accent"), + Some(&serde_json::json!("#123456")) + ); + assert!(document.get("theme.colors").is_none()); + assert!(document.to_string().contains("// my accent")); + document.unset("theme.dark.colors").unwrap(); + assert!(document.to_string().contains("// my accent")); + assert!(document.to_string().contains("// my desktop")); + Document::parse(&document.to_string()).unwrap(); + } + + #[test] + fn clearing_optional_theme_references_removes_them_without_empty_paths() { + let mut document = Document::parse("theme { file \"custom_theme.kdl\"; } ").unwrap(); + document.set("theme.file", serde_json::json!("")).unwrap(); + assert!(document.get("theme.file").is_none()); + document.set("theme.file", serde_json::json!("")).unwrap(); + } + #[test] fn widget_output_arrays_and_command_names_survive_edits() { let source = r#"commands { diff --git a/crates/ferese-config/src/presets.rs b/crates/ferese-config/src/presets.rs new file mode 100644 index 0000000..5993b32 --- /dev/null +++ b/crates/ferese-config/src/presets.rs @@ -0,0 +1,239 @@ +use crate::theme::Appearance; + +pub struct Preset { + pub id: &'static str, + pub appearance: Appearance, + pub name: &'static str, + pub description: &'static str, + pub base: &'static str, + pub raised: &'static str, + pub application_background: &'static str, + pub text: &'static str, + pub muted: &'static str, + pub accent: &'static str, + pub border: &'static str, + pub gradient_end: &'static str, + pub opacity: f64, + pub blur_radius: f64, + pub tint_strength: f64, + pub shadow_opacity: f64, +} + +pub const PRESETS: [Preset; 12] = [ + Preset { + appearance: Appearance::Dark, + id: "ferese-blue", + name: "Ferese Blue", + description: "Default", + base: "#111821", + raised: "#1E2530", + application_background: "#171E27", + text: "#F4F7FB", + muted: "#8793A2", + accent: "#3D7BE6", + border: "#FFFFFF18", + gradient_end: "#3D7BE6", + opacity: 0.78, + blur_radius: 12.0, + tint_strength: 0.5, + shadow_opacity: 0.2, + }, + Preset { + appearance: Appearance::Dark, + id: "catppuccin-mocha", + name: "Catppuccin", + description: "Mocha", + base: "#1E1E2E", + raised: "#313244", + application_background: "#1E1E2E", + text: "#CDD6F4", + muted: "#A6ADC8", + accent: "#CBA6F7", + border: "#45475A88", + gradient_end: "#B4BEFE", + opacity: 0.84, + blur_radius: 18.0, + tint_strength: 0.62, + shadow_opacity: 0.2, + }, + Preset { + appearance: Appearance::Dark, + id: "gruvbox", + name: "Gruvbox", + description: "Dark", + base: "#282828", + raised: "#3C3836", + application_background: "#282828", + text: "#EBDBB2", + muted: "#BDAE93", + accent: "#FE8019", + border: "#50494588", + gradient_end: "#FABD2F", + opacity: 0.86, + blur_radius: 14.0, + tint_strength: 0.66, + shadow_opacity: 0.22, + }, + Preset { + appearance: Appearance::Dark, + id: "rose-pine-moon", + name: "Rosé Pine", + description: "Moon", + base: "#232136", + raised: "#2A273F", + application_background: "#232136", + text: "#E0DEF4", + muted: "#918DAA", + accent: "#EA9A97", + border: "#56526E88", + gradient_end: "#C4A7E7", + opacity: 0.88, + blur_radius: 18.0, + tint_strength: 0.7, + shadow_opacity: 0.18, + }, + Preset { + appearance: Appearance::Dark, + id: "tokyo-night", + name: "Tokyo Night", + description: "Night", + base: "#1A1B26", + raised: "#292E42", + application_background: "#1A1B26", + text: "#C0CAF5", + muted: "#A9B1D6", + accent: "#7AA2F7", + border: "#41486888", + gradient_end: "#BB9AF7", + opacity: 0.85, + blur_radius: 20.0, + tint_strength: 0.66, + shadow_opacity: 0.22, + }, + Preset { + appearance: Appearance::Dark, + id: "everforest-dark", + name: "Everforest", + description: "Dark · hard", + base: "#272E33", + raised: "#2E383C", + application_background: "#272E33", + text: "#D3C6AA", + muted: "#9DA9A0", + accent: "#A7C080", + border: "#49535B88", + gradient_end: "#83C092", + opacity: 0.9, + blur_radius: 14.0, + tint_strength: 0.7, + shadow_opacity: 0.18, + }, + Preset { + appearance: Appearance::Light, + id: "ferese-blue-light", + name: "Ferese Blue", + description: "Default", + base: "#F5F7FB", + raised: "#E7E9ED", + application_background: "#EFF1F5", + text: "#182234", + muted: "#586579", + accent: "#3D7BE6", + border: "#18223430", + gradient_end: "#3D7BE6", + opacity: 0.94, + blur_radius: 10.0, + tint_strength: 1.0, + shadow_opacity: 0.1, + }, + Preset { + appearance: Appearance::Light, + id: "catppuccin-latte", + name: "Catppuccin", + description: "Latte", + base: "#EFF1F5", + raised: "#E6E9EF", + application_background: "#EFF1F5", + text: "#4C4F69", + muted: "#5C5F77", + accent: "#8839EF", + border: "#9CA0B066", + gradient_end: "#7287FD", + opacity: 0.95, + blur_radius: 12.0, + tint_strength: 1.0, + shadow_opacity: 0.08, + }, + Preset { + appearance: Appearance::Light, + id: "gruvbox-light", + name: "Gruvbox", + description: "Light", + base: "#FBF1C7", + raised: "#F2E5BC", + application_background: "#FBF1C7", + text: "#3C3836", + muted: "#665C54", + accent: "#AF3A03", + border: "#92837466", + gradient_end: "#B57614", + opacity: 0.96, + blur_radius: 9.0, + tint_strength: 1.0, + shadow_opacity: 0.11, + }, + Preset { + appearance: Appearance::Light, + id: "rose-pine-dawn", + name: "Rosé Pine", + description: "Dawn", + base: "#FAF4ED", + raised: "#FFFAF3", + application_background: "#FAF4ED", + text: "#464261", + muted: "#716D89", + accent: "#B4637A", + border: "#CECACD66", + gradient_end: "#907AA9", + opacity: 0.96, + blur_radius: 12.0, + tint_strength: 1.0, + shadow_opacity: 0.07, + }, + Preset { + appearance: Appearance::Light, + id: "tokyo-night-day", + name: "Tokyo Night", + description: "Day", + base: "#E1E2E7", + raised: "#D0D5E3", + application_background: "#E1E2E7", + text: "#3358B0", + muted: "#4D5A8C", + accent: "#2C76DD", + border: "#8990B366", + gradient_end: "#9854F1", + opacity: 0.96, + blur_radius: 10.0, + tint_strength: 1.0, + shadow_opacity: 0.1, + }, + Preset { + appearance: Appearance::Light, + id: "everforest-light", + name: "Everforest", + description: "Light · hard", + base: "#FFFBEF", + raised: "#F8F5E4", + application_background: "#FFFBEF", + text: "#5C6A72", + muted: "#677367", + accent: "#849601", + border: "#939F9166", + gradient_end: "#35A77C", + opacity: 0.96, + blur_radius: 8.0, + tint_strength: 1.0, + shadow_opacity: 0.08, + }, +]; diff --git a/crates/ferese-config/src/theme.rs b/crates/ferese-config/src/theme.rs new file mode 100644 index 0000000..4375122 --- /dev/null +++ b/crates/ferese-config/src/theme.rs @@ -0,0 +1,1608 @@ +//! Pure theme resolution, appearance scheduling, and readable transition samples. +use std::path::{Path, PathBuf}; + +use jiff::civil::{DateTime, Time}; +use jiff::tz::{Disambiguation, TimeZone}; +use jiff::{Timestamp, ToSpan}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::Document; + +pub const SCHEMA_VERSION: u32 = 2; +pub const TRANSITION_MS: u64 = 250; + +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Appearance { + Light, + #[default] + Dark, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Mode { + Light, + #[default] + Dark, + Auto, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(default)] +pub struct Accessibility { + pub increase_contrast: bool, + pub reduce_transparency: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct Selection { + pub family: Option, + pub preset: String, + pub file: Option, + #[serde(flatten)] + pub overrides: serde_json::Map, +} + +impl Default for Selection { + fn default() -> Self { + Self { + preset: "ferese-blue".into(), + family: None, + file: None, + overrides: Default::default(), + } + } +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum AutoSource { + System, + SunriseSunset, + #[default] + Schedule, +} + +#[derive(Clone, Debug, Default)] +pub struct AutoContext { + pub system: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(default)] +pub struct Schedule { + pub source: AutoSource, + pub timezone: String, + pub light_at: String, + pub dark_at: String, +} + +impl Default for Schedule { + fn default() -> Self { + Self { + source: AutoSource::Schedule, + timezone: "system".into(), + light_at: "07:00".into(), + dark_at: "19:00".into(), + } + } +} + +impl Schedule { + pub fn resolve( + &self, + now: Timestamp, + zone: &TimeZone, + context: &AutoContext, + ) -> Result<(Appearance, Option, Option), String> { + match self.source { + AutoSource::System => Ok(( + context.system.unwrap_or(Appearance::Dark), + None, + context + .system + .is_none() + .then(|| "System appearance is unavailable. Using Dark until it becomes available.".into()), + )), + AutoSource::Schedule => self + .boundaries(now, zone) + .map(|(appearance, next)| (appearance, Some(next), None)), + AutoSource::SunriseSunset => { + Err("Sunrise/sunset is not available yet; choose Follow system or Custom schedule.".into()) + } + } + } + + pub fn zone(&self) -> Result { + if self.timezone == "system" { + if std::env::var_os("TZ").is_some() { + TimeZone::try_system().map_err(|e| e.to_string()) + } else { + let bytes = std::fs::read("/etc/localtime").map_err(|e| e.to_string())?; + TimeZone::tzif("system", &bytes).map_err(|e| e.to_string()) + } + } else { + TimeZone::get(&self.timezone).map_err(|e| e.to_string()) + } + } + + pub fn boundaries(&self, now: Timestamp, zone: &TimeZone) -> Result<(Appearance, Timestamp), String> { + let parse = |s: &str| -> Result { + if s.len() != 5 || s.as_bytes()[2] != b':' { + return Err("theme schedule times must use HH:MM".into()); + } + format!("{s}:00").parse::