Add resolved appearance engine and paired theme settings
Resolve light, dark and automatic appearance centrally and publish live theme snapshots to native surfaces and the appearance portal. Add paired preset families, custom theme imports, accessibility transforms and contrast-safe transitions. Refine the Settings gallery and Auto controls, move overrides under advanced customization, and share palette-aware switches and circular slider thumbs. Document configuration and include theme licenses and integration coverage.
This commit is contained in:
1 parent
2bb4c086f5
commit
3d950a0411
58 files changed
+5530
-618
No files matched your search
Generated
+11
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
+52
-22
@@ -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<BorderGradient>,
|
||||
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<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn parse_source(source: &str) -> Result<Self, ConfigError> {
|
||||
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<ThemeSettings, ConfigError> {
|
||||
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<crate::RuntimeConfig, ConfigError> {
|
||||
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<Self, ConfigError> {
|
||||
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]
|
||||
|
||||
+41
-1
@@ -117,6 +117,7 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result<Screen
|
||||
}
|
||||
state.portal_shortcuts.remove(owner);
|
||||
state.portal_session.remove(owner);
|
||||
state.theme_engine.remove(owner);
|
||||
state.refresh_idle_inhibition();
|
||||
return;
|
||||
}
|
||||
@@ -143,6 +144,22 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result<Screen
|
||||
"Missing capture session",
|
||||
));
|
||||
}
|
||||
} else if call.request.command == "theme-watch" {
|
||||
if let Err(error) = validate_request(&call.request) {
|
||||
let _ = call
|
||||
.response
|
||||
.try_send(Response::error(call.request.id, error.code, error.message));
|
||||
} else if let Some(since) = call.request.args["since"].as_u64() {
|
||||
state
|
||||
.theme_engine
|
||||
.watch(call.owner, call.request.id, since, call.response);
|
||||
} else {
|
||||
let _ = call.response.try_send(Response::error(
|
||||
call.request.id,
|
||||
"invalid_argument",
|
||||
"Missing theme revision",
|
||||
));
|
||||
}
|
||||
} else if call.request.command == "session-watch" {
|
||||
if let Err(error) = validate_request(&call.request) {
|
||||
let _ = call
|
||||
@@ -323,6 +340,19 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcEvent
|
||||
return;
|
||||
}
|
||||
};
|
||||
if request.command == "theme-preview" {
|
||||
let response = match validate_request(&request) {
|
||||
Err(error) => 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<Value, CommandError> {
|
||||
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()))?;
|
||||
|
||||
+18
-39
@@ -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<dyn Error>> {
|
||||
init_logging();
|
||||
@@ -53,18 +53,27 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
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<dyn Error>> {
|
||||
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<dyn Error>> {
|
||||
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();
|
||||
|
||||
@@ -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<String, String> {
|
||||
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<String, String> {
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct ConfigMonitor {
|
||||
_watcher: RecommendedWatcher,
|
||||
events: mpsc::Receiver<()>,
|
||||
@@ -26,6 +32,7 @@ pub(crate) struct ConfigMonitor {
|
||||
observed: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl ConfigMonitor {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn new(path: PathBuf) -> notify::Result<Self> {
|
||||
@@ -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())?;
|
||||
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<serde_json::Value> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +225,7 @@ pub struct Ferese {
|
||||
pub(crate) session_lock: Lock,
|
||||
pub(crate) lock_idle: IdleSettings,
|
||||
pub(crate) config_source: Option<String>,
|
||||
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,
|
||||
|
||||
@@ -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<String, String> {
|
||||
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<Value, String> {
|
||||
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<ferese_config::theme::Appearance> {
|
||||
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<Mutex<Option<ferese_config::theme::Appearance>>> =
|
||||
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<WallpaperState>,
|
||||
ready: HashSet<Output>,
|
||||
deadline: Instant,
|
||||
}
|
||||
|
||||
struct Transition {
|
||||
from: ferese_config::theme::ResolvedTheme,
|
||||
started: Instant,
|
||||
previous_wallpaper: Option<WallpaperState>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Engine {
|
||||
pub(crate) snapshot: Snapshot,
|
||||
waiters: HashMap<u64, (u64, mpsc::SyncSender<Response>)>,
|
||||
watcher: Option<RecommendedWatcher>,
|
||||
watched_directories: HashSet<PathBuf>,
|
||||
active_files: Vec<PathBuf>,
|
||||
paths: Arc<Mutex<HashSet<PathBuf>>>,
|
||||
events: Option<mpsc::Receiver<()>>,
|
||||
dirty: Option<Instant>,
|
||||
timer_pending: bool,
|
||||
clock: Option<OwnedFd>,
|
||||
pending: Option<Pending>,
|
||||
transition: Option<Transition>,
|
||||
last_frame: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
pub(crate) fn watch(&mut self, owner: u64, id: u64, since: u64, response: mpsc::SyncSender<Response>) {
|
||||
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<Timestamp>) -> 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<dyn std::error::Error>> {
|
||||
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<notify::Event>| {
|
||||
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::<ferese_config::theme::Policy>(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<crate::presentation::NativeTextureElement> {
|
||||
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<Value, String> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+28
-11
@@ -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::<i32, Physical>::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}"
|
||||
|
||||
@@ -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
|
||||
@@ -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<Palette>,
|
||||
pub dark: Option<Palette>,
|
||||
}
|
||||
|
||||
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<Family> {
|
||||
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()
|
||||
}
|
||||
@@ -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::<Vec<_>>();
|
||||
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::<Vec<_>>(),
|
||||
"",
|
||||
)?;
|
||||
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<KdlValue, Error> {
|
||||
}
|
||||
|
||||
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<T: DeserializeOwned>(source: &str) -> Result<T, Error> {
|
||||
#[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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
File diff suppressed because it is too large.
Load diff
@@ -8,3 +8,4 @@ rust-version.workspace = true
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
ferese-config = { path = "../ferese-config" }
|
||||
@@ -5,6 +5,8 @@ use std::{fmt, io};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
pub mod theme;
|
||||
|
||||
pub const VERSION: u32 = 1;
|
||||
pub const MAX_PAYLOAD_SIZE: usize = 1024 * 1024;
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
use std::io;
|
||||
use std::net::Shutdown;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use ferese_config::theme::Snapshot;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Request, Response, VERSION, read_frame, write_frame};
|
||||
|
||||
pub fn socket_path() -> io::Result<PathBuf> {
|
||||
std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|directory| PathBuf::from(directory).join("ferese/control.sock"))
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "XDG_RUNTIME_DIR is not set"))
|
||||
}
|
||||
|
||||
pub struct Connection {
|
||||
stream: UnixStream,
|
||||
id: u64,
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
pub fn connect() -> io::Result<Self> {
|
||||
let stream = UnixStream::connect(socket_path()?)?;
|
||||
stream.set_write_timeout(Some(Duration::from_secs(2)))?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
|
||||
Ok(Self { stream, id: 0 })
|
||||
}
|
||||
|
||||
pub fn cancellation(&self) -> io::Result<Cancellation> {
|
||||
self.stream.try_clone().map(Cancellation)
|
||||
}
|
||||
|
||||
pub fn call(&mut self, command: &str, args: Value) -> Result<Value, String> {
|
||||
self.id = self.id.wrapping_add(1);
|
||||
let request = Request {
|
||||
version: VERSION,
|
||||
id: self.id,
|
||||
kind: "command".into(),
|
||||
command: command.into(),
|
||||
args,
|
||||
};
|
||||
write_frame(&mut self.stream, &request).map_err(|e| e.to_string())?;
|
||||
let response: Response = read_frame(&mut self.stream).map_err(|e| e.to_string())?;
|
||||
if response.id != self.id || response.version != VERSION {
|
||||
return Err("Unexpected theme IPC response".into());
|
||||
}
|
||||
if let Some(error) = response.error {
|
||||
return Err(error.message);
|
||||
}
|
||||
response.result.ok_or_else(|| "Missing theme snapshot".into())
|
||||
}
|
||||
|
||||
pub fn get(&mut self) -> Result<Snapshot, String> {
|
||||
let snapshot: Snapshot =
|
||||
serde_json::from_value(self.call("theme-get", json!({}))?).map_err(|e| e.to_string())?;
|
||||
if snapshot.version != ferese_config::theme::SCHEMA_VERSION {
|
||||
return Err("Unsupported theme snapshot version".into());
|
||||
}
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
pub fn watch(&mut self, revision: u64) -> Result<Snapshot, String> {
|
||||
self.stream.set_read_timeout(None).map_err(|e| e.to_string())?;
|
||||
let snapshot: Snapshot =
|
||||
serde_json::from_value(self.call("theme-watch", json!({"since": revision}))?).map_err(|e| e.to_string())?;
|
||||
if snapshot.version != ferese_config::theme::SCHEMA_VERSION {
|
||||
return Err("Unsupported theme snapshot version".into());
|
||||
}
|
||||
Ok(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Cancellation(UnixStream);
|
||||
|
||||
impl Drop for Cancellation {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.shutdown(Shutdown::Both);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current() -> Snapshot {
|
||||
Connection::connect()
|
||||
.ok()
|
||||
.and_then(|mut connection| connection.get().ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -8,6 +8,11 @@ rust-version = "1.95"
|
||||
[dependencies]
|
||||
jiff.workspace = true
|
||||
ferese-config = { path = "../ferese-config" }
|
||||
libcosmic.workspace = true
|
||||
libcosmic = { workspace = true, features = ["a11y"] }
|
||||
ferese-protocols = { path = "../ferese-protocols", default-features = false, features = ["client"] }
|
||||
wayland-client.workspace = true
|
||||
ferese-ipc = { path = "../ferese-ipc" }
|
||||
serde_json.workspace = true
|
||||
tokio = { version = "1", features = ["rt", "sync", "time"] }
|
||||
|
||||
iced_accessibility = { git = "https://github.com/pop-os/libcosmic", rev = "03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" }
|
||||
@@ -4,8 +4,9 @@ The shared native UI theme for the shell, settings, lock screen, authentication
|
||||
prompt and screen-sharing/recording chooser.
|
||||
|
||||
- `Palette` resolves KDL colors and corners and builds the native toolkit theme.
|
||||
- `PRESETS` defines the six built-in color schemes.
|
||||
- `PRESETS` defines twelve variants in six families.
|
||||
- `font` and `text` provide shared font selection and text.
|
||||
- `gallery` supplies split theme tiles, accessible radio groups and keyboard navigation.
|
||||
- `controls` supplies surfaces, navigation, font-aware text buttons and input styles.
|
||||
- `menus` supplies headings, rows, sections, separators, switches and slider styles.
|
||||
- `calendar` supplies date/time labels, stacked clock digits and calendar grids.
|
||||
|
||||
@@ -34,6 +34,36 @@ pub fn text_button<'a, M: Clone + 'a>(
|
||||
.class(button_style(palette, selected))
|
||||
}
|
||||
|
||||
pub fn switch<'a, M: Clone + 'a>(enabled: bool, palette: Palette) -> button::Button<'a, M> {
|
||||
let paint = move |focused| button::Style {
|
||||
background: None,
|
||||
text_color: Some(palette.text),
|
||||
icon_color: Some(palette.text),
|
||||
border_radius: 12.into(),
|
||||
border_width: if focused { 1. } else { 0. },
|
||||
border_color: palette.accent,
|
||||
..Default::default()
|
||||
};
|
||||
button::custom(crate::menus::switch(enabled, palette, 1.))
|
||||
.padding(2)
|
||||
.class(theme::Button::Custom {
|
||||
active: Box::new(move |focused, _| paint(focused)),
|
||||
hovered: Box::new(move |focused, _| paint(focused)),
|
||||
pressed: Box::new(move |focused, _| paint(focused)),
|
||||
disabled: Box::new(move |_| paint(false)),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn switch_colors(palette: Palette, enabled: bool, hovered: bool) -> (Color, Color) {
|
||||
let track = if enabled {
|
||||
palette.accent
|
||||
} else {
|
||||
mix(palette.card, palette.text, if hovered { 0.18 } else { 0.12 })
|
||||
};
|
||||
let thumb = crate::foreground(track, palette.text);
|
||||
(track, thumb)
|
||||
}
|
||||
|
||||
pub fn navigation_style(p: Palette, selected: bool) -> theme::Button {
|
||||
styled_button(p, selected, true)
|
||||
}
|
||||
@@ -48,7 +78,7 @@ pub fn settings_input(p: Palette) -> theme::TextInput {
|
||||
icon_color: Some(p.muted),
|
||||
text_color: Some(p.text),
|
||||
placeholder_color: p.muted,
|
||||
selected_text_color: p.sidebar,
|
||||
selected_text_color: p.on_accent,
|
||||
selected_fill: p.accent,
|
||||
label_color: p.muted,
|
||||
};
|
||||
@@ -233,3 +263,30 @@ pub fn filled_button(fill: Color, foreground: Color, radius: f32, opacity: f32)
|
||||
disabled: Box::new(move |_| paint(0.)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn switch_thumb_remains_readable_in_every_preset_and_state() {
|
||||
for preset in crate::PRESETS {
|
||||
let resolved = ferese_config::theme::ResolvedTheme {
|
||||
appearance: preset.appearance,
|
||||
tokens: ferese_config::theme::preset(preset.id, preset.appearance).unwrap(),
|
||||
..Default::default()
|
||||
};
|
||||
let palette = Palette::from_resolved(&resolved);
|
||||
for enabled in [false, true] {
|
||||
for hovered in [false, true] {
|
||||
let (track, thumb) = switch_colors(palette, enabled, hovered);
|
||||
assert!(
|
||||
crate::contrast(track, thumb) >= 4.5,
|
||||
"{}: enabled={enabled}, hovered={hovered}",
|
||||
preset.name,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
use cosmic::iced::advanced::widget::{Id, Operation, Tree, tree};
|
||||
use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer};
|
||||
use cosmic::iced::{Event, Length, Rectangle, Size, keyboard};
|
||||
use cosmic::{Element, Renderer, Theme};
|
||||
use ferese_config::families::{Family, Palette};
|
||||
use ferese_config::theme::Appearance;
|
||||
use iced_accessibility::{A11yTree, accesskit};
|
||||
|
||||
pub fn tile<M: Clone + 'static>(
|
||||
family: &Family,
|
||||
variant: Option<Appearance>,
|
||||
active: Option<Appearance>,
|
||||
selected: bool,
|
||||
palette: crate::Palette,
|
||||
font: cosmic::font::Font,
|
||||
id: Id,
|
||||
on_select: M,
|
||||
navigate: impl Fn(&keyboard::key::Named) -> Option<M> + 'static,
|
||||
) -> Element<'static, M> {
|
||||
use cosmic::widget::{button, column, row};
|
||||
let mut caption = row([])
|
||||
.spacing(6)
|
||||
.align_y(cosmic::iced::Alignment::Center)
|
||||
.push(crate::text(family.name.clone(), font).size(12));
|
||||
if family.light.is_none() || family.dark.is_none() {
|
||||
caption = caption.push(
|
||||
crate::text(
|
||||
if family.light.is_none() {
|
||||
"Dark only"
|
||||
} else {
|
||||
"Light only"
|
||||
},
|
||||
font,
|
||||
)
|
||||
.size(10)
|
||||
.class(cosmic::theme::Text::Color(palette.muted)),
|
||||
);
|
||||
}
|
||||
let image = preview(family, variant, active, selected);
|
||||
let image =
|
||||
cosmic::widget::responsive(move |size| image.clone().height(Length::Fixed(size.width * 126. / 200.)).into())
|
||||
.height(Length::Shrink);
|
||||
let content = column([]).spacing(2).push(image).push(caption);
|
||||
let tile = button::custom(content)
|
||||
.id(id.clone())
|
||||
.name(format!("{}, {}", family.name, family.availability()))
|
||||
.width(Length::Fill)
|
||||
.padding(8)
|
||||
.class(tile_style(palette, selected))
|
||||
.on_press(on_select);
|
||||
radio(tile, id, selected, navigate)
|
||||
}
|
||||
|
||||
fn tile_style(palette: crate::Palette, selected: bool) -> cosmic::theme::Button {
|
||||
let style = move |focused: bool, hovered: bool| cosmic::widget::button::Style {
|
||||
background: Some(if hovered { palette.sidebar } else { palette.card }.into()),
|
||||
border_width: if selected { 2. } else { 0. },
|
||||
border_color: palette.accent,
|
||||
border_radius: 10.into(),
|
||||
outline_width: if focused { 2. } else { 0. },
|
||||
outline_color: palette.text,
|
||||
text_color: Some(palette.text),
|
||||
icon_color: Some(palette.text),
|
||||
..Default::default()
|
||||
};
|
||||
cosmic::theme::Button::Custom {
|
||||
active: Box::new(move |focused, _| style(focused, false)),
|
||||
hovered: Box::new(move |focused, _| style(focused, true)),
|
||||
pressed: Box::new(move |focused, _| style(focused, true)),
|
||||
disabled: Box::new(move |_| style(false, false)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn preview(
|
||||
family: &Family,
|
||||
variant: Option<Appearance>,
|
||||
active: Option<Appearance>,
|
||||
selected: bool,
|
||||
) -> cosmic::widget::icon::Icon {
|
||||
use cosmic::widget::icon;
|
||||
let key = format!("{family:?}/{variant:?}/{active:?}/{selected}");
|
||||
static CACHE: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, icon::Handle>>> =
|
||||
std::sync::OnceLock::new();
|
||||
let cache = CACHE.get_or_init(Default::default);
|
||||
let mut cache = cache.lock().unwrap();
|
||||
let handle = cache
|
||||
.entry(key)
|
||||
.or_insert_with(|| icon::from_svg_bytes(svg(family, variant, active, selected).into_bytes()).symbolic(false))
|
||||
.clone();
|
||||
// Bound cached previews when users repeatedly edit imported palettes.
|
||||
if cache.len() > 128 {
|
||||
cache.clear();
|
||||
}
|
||||
handle
|
||||
.icon()
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(112.))
|
||||
.content_fit(cosmic::iced::ContentFit::Fill)
|
||||
}
|
||||
|
||||
fn desktop(p: &Palette) -> String {
|
||||
format!(
|
||||
r#"<rect width="200" height="126" fill="{}"/>
|
||||
<rect x="8" y="8" width="184" height="14" rx="4" fill="{}" stroke="{}" stroke-opacity=".18"/>
|
||||
<path d="M15 15h10m145 0h15" stroke="{}" stroke-width="2" stroke-linecap="round"/>
|
||||
<rect x="24" y="35" width="152" height="77" rx="7" fill="{}"/>
|
||||
<path d="M24 51h152" stroke="{}" stroke-opacity=".22"/>
|
||||
<path d="M35 44h34M36 63h105m-105 10h90m-90 10h115" stroke="{}" stroke-width="2" stroke-linecap="round"/>
|
||||
<rect x="76" y="92" width="48" height="10" rx="3" fill="{}"/>"#,
|
||||
p.background, p.bar, p.muted, p.muted, p.surface, p.muted, p.text, p.accent
|
||||
)
|
||||
}
|
||||
|
||||
pub fn svg(family: &Family, variant: Option<Appearance>, active: Option<Appearance>, selected: bool) -> String {
|
||||
let mut result = String::from(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="126" viewBox="0 0 200 126"><defs>
|
||||
<clipPath id="light"><rect width="100" height="126"/></clipPath><clipPath id="dark"><rect x="100" width="100" height="126"/></clipPath>
|
||||
<pattern id="missing" width="8" height="8" patternUnits="userSpaceOnUse"><rect width="8" height="8" fill="#D9DDE2"/><path d="M-2 2l4-4M0 8l8-8M6 10l4-4" stroke="#C4C9D1"/></pattern></defs>"##,
|
||||
);
|
||||
for appearance in [Appearance::Light, Appearance::Dark] {
|
||||
if variant.is_some_and(|variant| variant != appearance) {
|
||||
continue;
|
||||
}
|
||||
let clip = match (variant, appearance) {
|
||||
(None, Appearance::Light) => "light",
|
||||
(None, Appearance::Dark) => "dark",
|
||||
_ => "",
|
||||
};
|
||||
result.push_str(&format!(
|
||||
"<g{}>",
|
||||
if clip.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" clip-path=\"url(#{clip})\"")
|
||||
}
|
||||
));
|
||||
if let Some(palette) = family.palette(appearance) {
|
||||
result.push_str(&desktop(palette));
|
||||
if active == Some(appearance) {
|
||||
let (x, width) = if variant.is_some() {
|
||||
(8, 184)
|
||||
} else if appearance == Appearance::Light {
|
||||
(8, 84)
|
||||
} else {
|
||||
(108, 84)
|
||||
};
|
||||
result.push_str(&format!(
|
||||
"<rect x=\"{x}\" y=\"120\" width=\"{width}\" height=\"3\" rx=\"1.5\" fill=\"{}\"/>",
|
||||
palette.accent
|
||||
));
|
||||
}
|
||||
} else {
|
||||
let x = if variant.is_some() || appearance == Appearance::Light {
|
||||
0
|
||||
} else {
|
||||
100
|
||||
};
|
||||
let width = if variant.is_some() { 200 } else { 100 };
|
||||
let label = if appearance == Appearance::Light {
|
||||
"No light variant"
|
||||
} else {
|
||||
"No dark variant"
|
||||
};
|
||||
result.push_str(&format!(r##"<rect x="{x}" width="{width}" height="126" fill="url(#missing)"/><text x="{}" y="68" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#353B45">{label}</text>"##, x + width / 2));
|
||||
}
|
||||
result.push_str("</g>");
|
||||
}
|
||||
if selected {
|
||||
let p = family
|
||||
.palette(active.unwrap_or(Appearance::Dark))
|
||||
.or(family.dark.as_ref())
|
||||
.or(family.light.as_ref())
|
||||
.unwrap();
|
||||
let foreground = ferese_config::theme::hex(ferese_config::theme::readable(
|
||||
[1.; 4],
|
||||
ferese_config::theme::rgba(&p.accent).unwrap(),
|
||||
4.5,
|
||||
));
|
||||
result.push_str(&format!(r#"<circle cx="183" cy="16" r="10" fill="{}"/><path d="M178 16l3 3 6-6" fill="none" stroke="{foreground}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>"#, p.accent));
|
||||
}
|
||||
result.push_str("</svg>");
|
||||
result
|
||||
}
|
||||
|
||||
pub fn neighbor(index: usize, count: usize, key: &keyboard::key::Named) -> Option<usize> {
|
||||
use keyboard::key::Named::*;
|
||||
if count == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(match key {
|
||||
ArrowRight => (index + 1) % count,
|
||||
ArrowLeft => (index + count - 1) % count,
|
||||
ArrowDown => {
|
||||
if index + 3 < count {
|
||||
index + 3
|
||||
} else {
|
||||
index % 3
|
||||
}
|
||||
}
|
||||
ArrowUp => {
|
||||
if index >= 3 {
|
||||
index - 3
|
||||
} else {
|
||||
((count - 1 - index) / 3) * 3 + index
|
||||
}
|
||||
}
|
||||
Enter => index,
|
||||
Home => 0,
|
||||
End => count - 1,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Focus(bool);
|
||||
impl cosmic::iced::advanced::widget::operation::Focusable for Focus {
|
||||
fn is_focused(&self) -> bool {
|
||||
self.0
|
||||
}
|
||||
fn focus(&mut self) {
|
||||
self.0 = true;
|
||||
}
|
||||
fn unfocus(&mut self) {
|
||||
self.0 = false;
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Radio<'a, M> {
|
||||
content: Element<'a, M>,
|
||||
id: Id,
|
||||
selected: bool,
|
||||
group: bool,
|
||||
label: String,
|
||||
navigate: Option<Box<dyn Fn(&keyboard::key::Named) -> Option<M> + 'a>>,
|
||||
}
|
||||
|
||||
pub fn radio<'a, M: Clone + 'a>(
|
||||
content: impl Into<Element<'a, M>>,
|
||||
id: Id,
|
||||
selected: bool,
|
||||
navigate: impl Fn(&keyboard::key::Named) -> Option<M> + 'a,
|
||||
) -> Element<'a, M> {
|
||||
Element::new(Radio {
|
||||
content: content.into(),
|
||||
id,
|
||||
selected,
|
||||
group: false,
|
||||
label: String::new(),
|
||||
navigate: Some(Box::new(navigate)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn group<'a, M: Clone + 'a>(content: impl Into<Element<'a, M>>, id: Id, label: &str) -> Element<'a, M> {
|
||||
Element::new(Radio {
|
||||
content: content.into(),
|
||||
id,
|
||||
selected: false,
|
||||
group: true,
|
||||
label: label.into(),
|
||||
navigate: None,
|
||||
})
|
||||
}
|
||||
|
||||
impl<M: Clone> Widget<M, Theme, Renderer> for Radio<'_, M> {
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<Focus>()
|
||||
}
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(Focus::default())
|
||||
}
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(&self.content)]
|
||||
}
|
||||
fn diff(&mut self, tree: &mut Tree) {
|
||||
tree.diff_children(std::slice::from_mut(&mut self.content));
|
||||
}
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
fn layout(&mut self, tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn operate(&mut self, tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, operation: &mut dyn Operation<()>) {
|
||||
if self.group {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
} else if self.selected {
|
||||
operation.focusable(Some(&self.id), layout.bounds(), tree.state.downcast_mut::<Focus>());
|
||||
} else {
|
||||
tree.state.downcast_mut::<Focus>().0 = false;
|
||||
}
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, M>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
if !self.group {
|
||||
if let Event::A11y(id, request) = event {
|
||||
if !iced_accessibility::IdEq::eq(&id.0, &self.id.0) {
|
||||
return;
|
||||
}
|
||||
if matches!(request.action, accesskit::Action::Focus | accesskit::Action::Click) {
|
||||
if let Some(message) = self
|
||||
.navigate
|
||||
.as_ref()
|
||||
.and_then(|navigate| navigate(&keyboard::key::Named::Enter))
|
||||
{
|
||||
shell.publish(message);
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(event, Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))) {
|
||||
tree.state.downcast_mut::<Focus>().0 = self.selected && cursor.is_over(layout.bounds());
|
||||
}
|
||||
}
|
||||
if !self.group && self.selected && tree.state.downcast_ref::<Focus>().0 {
|
||||
if let Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Character(key),
|
||||
..
|
||||
}) = event
|
||||
&& key.as_str() == " "
|
||||
&& let Some(message) = self
|
||||
.navigate
|
||||
.as_ref()
|
||||
.and_then(|navigate| navigate(&keyboard::key::Named::Enter))
|
||||
{
|
||||
shell.publish(message);
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
if let Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(key),
|
||||
..
|
||||
}) = event
|
||||
{
|
||||
if let Some(message) = self.navigate.as_ref().and_then(|navigate| navigate(key)) {
|
||||
shell.publish(message);
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content
|
||||
.as_widget()
|
||||
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
|
||||
if !self.group && self.selected && tree.state.downcast_ref::<Focus>().0 {
|
||||
use renderer::Renderer as _;
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: layout.bounds(),
|
||||
border: cosmic::iced::Border {
|
||||
color: style.text_color,
|
||||
width: 2.,
|
||||
radius: 10.into(),
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
cosmic::iced::Color::TRANSPARENT,
|
||||
);
|
||||
}
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
self.content
|
||||
.as_widget()
|
||||
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
|
||||
}
|
||||
fn a11y_nodes(&self, layout: Layout<'_>, tree: &Tree, cursor: mouse::Cursor) -> A11yTree {
|
||||
let children = self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor);
|
||||
if self.group {
|
||||
let mut node = accesskit::Node::new(accesskit::Role::RadioGroup);
|
||||
node.set_label(self.label.clone());
|
||||
A11yTree::node_with_child_tree(iced_accessibility::A11yNode::new(node, self.id.clone()), children)
|
||||
} else {
|
||||
let mut children = children;
|
||||
for node in children.root_mut() {
|
||||
node.node_mut().set_role(accesskit::Role::RadioButton);
|
||||
node.node_mut().set_toggled(if self.selected {
|
||||
accesskit::Toggled::True
|
||||
} else {
|
||||
accesskit::Toggled::False
|
||||
});
|
||||
}
|
||||
children
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn arrow_navigation_wraps_rows_and_columns() {
|
||||
use keyboard::key::Named::*;
|
||||
assert_eq!(neighbor(0, 6, &ArrowLeft), Some(5));
|
||||
assert_eq!(neighbor(1, 6, &ArrowUp), Some(4));
|
||||
assert_eq!(neighbor(4, 6, &ArrowDown), Some(1));
|
||||
assert_eq!(neighbor(5, 6, &Home), Some(0));
|
||||
assert_eq!(neighbor(0, 7, &End), Some(6));
|
||||
}
|
||||
#[test]
|
||||
fn paired_previews_share_one_window_across_the_seam() {
|
||||
let family = ferese_config::families::builtins().remove(1);
|
||||
let svg = svg(&family, None, Some(Appearance::Dark), true);
|
||||
assert_eq!(svg.matches("x=\"24\" y=\"35\"").count(), 2);
|
||||
assert!(svg.contains("clip-path=\"url(#light)\""));
|
||||
assert!(svg.contains("clip-path=\"url(#dark)\""));
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,17 @@ mod button;
|
||||
pub mod calendar;
|
||||
mod contrast;
|
||||
pub mod controls;
|
||||
pub mod gallery;
|
||||
pub mod icons;
|
||||
pub mod material;
|
||||
pub mod menus;
|
||||
mod palette;
|
||||
mod presets;
|
||||
pub mod service;
|
||||
mod typography;
|
||||
|
||||
pub use button::accent_button;
|
||||
pub use contrast::{accent_color, accent_pair, apply, composite, contrast, foreground, luminance};
|
||||
pub use palette::{Palette, material_opacity, mix, parse_color, surface_shade};
|
||||
pub use palette::{Palette, mix, parse_color, surface_shade};
|
||||
pub use presets::{PRESETS, Preset};
|
||||
pub use typography::{font, text};
|
||||
@@ -77,26 +77,11 @@ pub fn separator<'a, M: 'a>(foreground: Color, opacity: f32) -> Element<'a, M> {
|
||||
}
|
||||
|
||||
pub fn switch<'a, M: 'a>(enabled: bool, palette: crate::Palette, opacity: f32) -> Element<'a, M> {
|
||||
let track = if enabled {
|
||||
palette.accent
|
||||
} else {
|
||||
Color {
|
||||
a: 0.22,
|
||||
..palette.text
|
||||
}
|
||||
};
|
||||
let thumb = if enabled {
|
||||
crate::foreground(track, palette.text)
|
||||
} else {
|
||||
palette.text
|
||||
};
|
||||
let (track, thumb) = crate::controls::switch_colors(palette, enabled, false);
|
||||
let knob = container(cosmic::widget::Space::new().width(16).height(16))
|
||||
.width(16)
|
||||
.height(16)
|
||||
.class(crate::controls::surface(
|
||||
Color { a: opacity, ..thumb },
|
||||
palette.radius.min(8.),
|
||||
));
|
||||
.class(crate::controls::surface(Color { a: opacity, ..thumb }, 8.));
|
||||
container(knob)
|
||||
.width(36)
|
||||
.height(20)
|
||||
@@ -106,14 +91,11 @@ pub fn switch<'a, M: 'a>(enabled: bool, palette: crate::Palette, opacity: f32) -
|
||||
} else {
|
||||
cosmic::iced::alignment::Horizontal::Left
|
||||
})
|
||||
.class(crate::controls::surface(
|
||||
track.scale_alpha(opacity),
|
||||
palette.radius.min(10.),
|
||||
))
|
||||
.class(crate::controls::surface(track.scale_alpha(opacity), 10.))
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn slider(foreground: Color, opacity: f32, radius: f32) -> cosmic::theme::iced::Slider {
|
||||
pub fn slider(foreground: Color, opacity: f32) -> cosmic::theme::iced::Slider {
|
||||
let style = std::rc::Rc::new(move |_: &cosmic::Theme| {
|
||||
use cosmic::iced::widget::slider::{Breakpoint, Handle, HandleShape, Rail, Style};
|
||||
Style {
|
||||
@@ -128,16 +110,12 @@ pub fn slider(foreground: Color, opacity: f32, radius: f32) -> cosmic::theme::ic
|
||||
),
|
||||
width: 4.,
|
||||
border: cosmic::iced::Border {
|
||||
radius: radius.min(2.).into(),
|
||||
radius: 2.into(),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
handle: Handle {
|
||||
shape: HandleShape::Rectangle {
|
||||
width: 10,
|
||||
height: 10,
|
||||
border_radius: radius.min(5.).into(),
|
||||
},
|
||||
shape: HandleShape::Circle { radius: 6. },
|
||||
background: foreground.into(),
|
||||
border_width: 0.,
|
||||
border_color: Color::TRANSPARENT,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
use cosmic::iced::Color;
|
||||
use ferese_config::Document;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Palette {
|
||||
pub appearance: ferese_config::theme::Appearance,
|
||||
pub background: Color,
|
||||
pub sidebar: Color,
|
||||
pub card: Color,
|
||||
pub accent: Color,
|
||||
pub on_accent: Color,
|
||||
pub text: Color,
|
||||
pub muted: Color,
|
||||
pub error: Color,
|
||||
@@ -14,33 +15,23 @@ pub struct Palette {
|
||||
}
|
||||
|
||||
impl Palette {
|
||||
pub fn from_document(document: Option<&Document>) -> Self {
|
||||
let color = |key, fallback| {
|
||||
document
|
||||
.and_then(|document| document.get(key))
|
||||
.and_then(|value| value.as_str())
|
||||
.and_then(parse_color)
|
||||
.unwrap_or_else(|| parse_color(fallback).expect("valid default theme color"))
|
||||
};
|
||||
let base = color("theme.colors.surface_base", "#111821");
|
||||
pub fn from_resolved(theme: &ferese_config::theme::ResolvedTheme) -> Self {
|
||||
let color = |value: &str| parse_color(value).expect("validated resolved theme color");
|
||||
Self {
|
||||
background: mix(base, surface_shade(base), 0.025),
|
||||
sidebar: base,
|
||||
card: mix(base, surface_shade(base), 0.055),
|
||||
accent: color("theme.colors.accent", "#3D7BE6"),
|
||||
text: color("theme.colors.text_primary", "#F4F7FB"),
|
||||
muted: color("theme.colors.text_muted", "#8793A2"),
|
||||
error: mix(base, Color::from_rgb8(210, 80, 80), 0.2),
|
||||
radius: document
|
||||
.and_then(|document| {
|
||||
document
|
||||
.get("theme.geometry.shell_radius")
|
||||
.or_else(|| document.get("appearance.corner_radius"))
|
||||
})
|
||||
.and_then(|value| value.as_f64())
|
||||
.filter(|value| value.is_finite())
|
||||
.unwrap_or(14.)
|
||||
.clamp(0., 64.) as f32,
|
||||
appearance: theme.appearance,
|
||||
background: color(&theme.tokens.colors.application_background),
|
||||
sidebar: color(&theme.tokens.colors.surface_base),
|
||||
card: color(&theme.tokens.colors.surface_raised),
|
||||
accent: color(&theme.tokens.colors.accent),
|
||||
on_accent: color(&theme.tokens.colors.on_accent),
|
||||
text: color(&theme.tokens.colors.text_primary),
|
||||
muted: color(&theme.tokens.colors.text_muted),
|
||||
error: mix(
|
||||
color(&theme.tokens.colors.surface_base),
|
||||
Color::from_rgb8(210, 80, 80),
|
||||
0.2,
|
||||
),
|
||||
radius: theme.tokens.geometry.shell_radius as f32,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +43,7 @@ impl Palette {
|
||||
|
||||
pub fn native_theme(self) -> cosmic::Theme {
|
||||
let rgba = |c: Color| cosmic::cosmic_theme::palette::Srgba::new(c.r, c.g, c.b, 1.);
|
||||
let builder = if crate::luminance(self.sidebar) > 0.5 {
|
||||
let builder = if self.appearance == ferese_config::theme::Appearance::Light {
|
||||
cosmic::cosmic_theme::ThemeBuilder::light()
|
||||
} else {
|
||||
cosmic::cosmic_theme::ThemeBuilder::dark()
|
||||
@@ -70,9 +61,9 @@ impl Palette {
|
||||
.bg_color(rgba(self.background))
|
||||
.primary_container_bg(rgba(self.card))
|
||||
.text_tint(rgba(self.text).color)
|
||||
.accent(crate::accent_color(self.accent, self.card))
|
||||
.accent(rgba(self.accent).color)
|
||||
.build();
|
||||
crate::apply(&mut native, self.text);
|
||||
crate::apply(&mut native, self.on_accent);
|
||||
cosmic::Theme::custom(std::sync::Arc::new(native))
|
||||
}
|
||||
|
||||
@@ -113,22 +104,6 @@ pub fn surface_shade(base: Color) -> Color {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn material_opacity(document: Option<&Document>) -> f32 {
|
||||
let style = document
|
||||
.and_then(|document| document.get("theme.material.style"))
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("solid");
|
||||
if style != "translucent" {
|
||||
return 1.;
|
||||
}
|
||||
document
|
||||
.and_then(|document| document.get("theme.material.opacity"))
|
||||
.and_then(|value| value.as_f64())
|
||||
.filter(|value| value.is_finite())
|
||||
.unwrap_or(ferese_config::DEFAULT_MATERIAL_OPACITY)
|
||||
.clamp(0., 1.) as f32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -136,23 +111,15 @@ mod tests {
|
||||
#[test]
|
||||
fn every_preset_builds_an_opaque_control_palette_over_translucent_materials() {
|
||||
for preset in crate::PRESETS {
|
||||
let document = Document::parse(&format!(
|
||||
r##"theme {{
|
||||
colors {{
|
||||
surface-base "{}"
|
||||
accent "{}"
|
||||
text-primary "{}"
|
||||
text-muted "{}"
|
||||
}}
|
||||
material {{ style translucent; opacity 0.25; }}
|
||||
geometry {{ shell-radius 0; }}
|
||||
}}"##,
|
||||
preset.base, preset.accent, preset.text, preset.muted,
|
||||
))
|
||||
.unwrap();
|
||||
let palette = Palette::from_document(Some(&document));
|
||||
let mut resolved = ferese_config::theme::ResolvedTheme::default();
|
||||
resolved.appearance = preset.appearance;
|
||||
resolved.tokens = ferese_config::theme::preset(preset.id, preset.appearance).unwrap();
|
||||
resolved.tokens.material.style = "translucent".into();
|
||||
resolved.tokens.material.opacity = 0.25;
|
||||
resolved.tokens.geometry.shell_radius = 0.;
|
||||
let palette = Palette::from_resolved(&resolved);
|
||||
let native = palette.native_theme();
|
||||
assert_eq!(material_opacity(Some(&document)), 0.25);
|
||||
assert_eq!(crate::service::opacity(&resolved), 0.25);
|
||||
assert_eq!(native.cosmic().primary(false).base.alpha, 1.);
|
||||
assert_eq!(native.cosmic().corner_radii.radius_m, [0.; 4]);
|
||||
for component in [&native.cosmic().accent, &native.cosmic().accent_button] {
|
||||
@@ -172,12 +139,13 @@ mod tests {
|
||||
for (style, opacity, expected) in [
|
||||
("solid", 0.4, 1.),
|
||||
("translucent", 0.4, 0.4),
|
||||
("translucent", 2., 1.),
|
||||
("translucent", -1., 0.),
|
||||
("translucent", 1., 1.),
|
||||
("translucent", 0., 0.),
|
||||
] {
|
||||
let document =
|
||||
Document::parse(&format!("theme {{ material {{ style {style}; opacity {opacity}; }} }}")).unwrap();
|
||||
assert_eq!(material_opacity(Some(&document)), expected);
|
||||
let mut theme = ferese_config::theme::ResolvedTheme::default();
|
||||
theme.tokens.material.style = style.into();
|
||||
theme.tokens.material.opacity = opacity;
|
||||
assert_eq!(crate::service::opacity(&theme), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +1 @@
|
||||
pub struct Preset {
|
||||
pub name: &'static str,
|
||||
pub description: &'static str,
|
||||
pub accent: &'static str,
|
||||
pub base: &'static str,
|
||||
pub text: &'static str,
|
||||
pub muted: &'static str,
|
||||
pub border: &'static str,
|
||||
pub gradient_end: &'static str,
|
||||
}
|
||||
|
||||
pub const PRESETS: [Preset; 6] = [
|
||||
Preset {
|
||||
name: "Ferese Blue",
|
||||
description: "Default · logo blue",
|
||||
accent: "#3D7BE6",
|
||||
base: "#111821",
|
||||
text: "#F4F7FB",
|
||||
muted: "#8793A2",
|
||||
border: "#FFFFFF18",
|
||||
gradient_end: "#3D7BE6",
|
||||
},
|
||||
Preset {
|
||||
name: "Monochrome",
|
||||
description: "Dark · grayscale",
|
||||
accent: "#E5E5E5",
|
||||
base: "#101012",
|
||||
text: "#EDEDF0",
|
||||
muted: "#97979F",
|
||||
border: "#FFFFFF18",
|
||||
gradient_end: "#696973",
|
||||
},
|
||||
Preset {
|
||||
name: "Gruvbox",
|
||||
description: "Dark · warm orange",
|
||||
accent: "#FE8019",
|
||||
base: "#282828",
|
||||
text: "#EBDBB2",
|
||||
muted: "#BDAE93",
|
||||
border: "#504945",
|
||||
gradient_end: "#FABD2F",
|
||||
},
|
||||
Preset {
|
||||
name: "Dracula",
|
||||
description: "Dark · purple",
|
||||
accent: "#BD93F9",
|
||||
base: "#282A36",
|
||||
text: "#F8F8F2",
|
||||
muted: "#A4ADCD",
|
||||
border: "#44475A",
|
||||
gradient_end: "#FF79C6",
|
||||
},
|
||||
Preset {
|
||||
name: "Ayu Light",
|
||||
description: "Light · paper",
|
||||
accent: "#8F5300",
|
||||
base: "#F8F9FA",
|
||||
text: "#5C6166",
|
||||
muted: "#596574",
|
||||
border: "#C8CDD3",
|
||||
gradient_end: "#996000",
|
||||
},
|
||||
Preset {
|
||||
name: "Monokai",
|
||||
description: "Dark · warm green",
|
||||
accent: "#A6E22E",
|
||||
base: "#272822",
|
||||
text: "#F8F8F2",
|
||||
muted: "#B2B29F",
|
||||
border: "#414339",
|
||||
gradient_end: "#E6DB74",
|
||||
},
|
||||
];
|
||||
pub use ferese_config::presets::{PRESETS, Preset};
|
||||
@@ -0,0 +1,57 @@
|
||||
use cosmic::iced::futures::SinkExt;
|
||||
use ferese_config::theme::Snapshot;
|
||||
|
||||
pub fn current() -> Snapshot {
|
||||
ferese_ipc::theme::current()
|
||||
}
|
||||
|
||||
pub fn opacity(theme: &ferese_config::theme::ResolvedTheme) -> f32 {
|
||||
if theme.tokens.material.style == "translucent" {
|
||||
theme.tokens.material.opacity as f32
|
||||
} else {
|
||||
1.
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscription() -> cosmic::iced::Subscription<Snapshot> {
|
||||
cosmic::iced::Subscription::run(stream)
|
||||
}
|
||||
|
||||
fn stream() -> impl cosmic::iced::futures::Stream<Item = Snapshot> {
|
||||
cosmic::iced::stream::channel(1, async |mut output| {
|
||||
loop {
|
||||
let connection = tokio::task::spawn_blocking(ferese_ipc::theme::Connection::connect).await;
|
||||
let Ok(Ok(mut connection)) = connection else {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
continue;
|
||||
};
|
||||
let Ok(cancellation) = connection.cancellation() else {
|
||||
return;
|
||||
};
|
||||
let (send, mut receive) = tokio::sync::watch::channel(None);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let Ok(mut snapshot) = connection.get() else { return };
|
||||
loop {
|
||||
let revision = snapshot.revision;
|
||||
if send.send(Some(snapshot)).is_err() {
|
||||
return;
|
||||
}
|
||||
match connection.watch(revision) {
|
||||
Ok(next) => snapshot = next,
|
||||
Err(_) => return,
|
||||
}
|
||||
}
|
||||
});
|
||||
while receive.changed().await.is_ok() {
|
||||
let snapshot = receive.borrow_and_update().clone();
|
||||
if let Some(snapshot) = snapshot {
|
||||
if output.send(snapshot).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(cancellation);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
})
|
||||
}
|
||||
+26
-5
@@ -108,22 +108,43 @@ Changing default width preserves manually resized columns.
|
||||
|
||||
## Appearance
|
||||
|
||||
Settings → Appearance offers six presets: **Ferese Blue** (default),
|
||||
**Monochrome**, **Gruvbox**, **Dracula**, **Ayu Light**, and **Monokai**.
|
||||
Ferese Blue uses the logo accent, `#3D7BE6`. Presets update colors and preserve
|
||||
wallpaper, fonts, corners, and opacity. Colors remain individually editable.
|
||||
Settings → Appearance offers six paired themes: **Ferese Blue** (default),
|
||||
**Catppuccin**, **Gruvbox**, **Rosé Pine**, **Tokyo Night**, and **Everforest**.
|
||||
Ferese Blue uses the logo accent, `#3D7BE6`, and has light and dark variants.
|
||||
Choose Light, Dark, or Auto in Settings or Control Center. Auto follows local
|
||||
07:00 and 19:00 boundaries unless you change its schedule.
|
||||
|
||||
Selections store preset names. Partial KDL files can override shared colors or
|
||||
one appearance. See [Themes](theme-engine.md) for files, accessibility options,
|
||||
and live previews.
|
||||
|
||||
| Section / key | Type | Default | Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| `theme.mode` | `"light"`, `"dark"`, `"auto"` | `"dark"` | Selected appearance mode |
|
||||
| `theme.family` | family id | `"ferese-blue"` | Paired theme; automatically uses its light or dark variant |
|
||||
| `theme.split` | boolean | `false` | Use separate light/dark families; retains both selections when disabled |
|
||||
| `theme.light.family`, `theme.dark.family` | family id | `"ferese-blue"` | Stored selections used when split is enabled |
|
||||
| `theme.custom-themes.<id>.file` | path | unset | Imported KDL family with explicit light and/or dark sections |
|
||||
| `theme.light.preset` | preset name | `"ferese-blue-light"` | Light palette; see the [preset pairs](theme-engine.md#preset-pairs) |
|
||||
| `theme.dark.preset` | preset name | `"ferese-blue"` | Dark palette |
|
||||
| `theme.file` | path | unset | Shared partial KDL theme |
|
||||
| `theme.light.file`, `theme.dark.file` | path | unset | Partial KDL theme for one appearance |
|
||||
| `theme.accent` | hex color | unset | Accent override, adjusted for readability in each appearance |
|
||||
| `theme.schedule.source` | `"system"`, `"schedule"` | `"schedule"` | Follow GTK/GNOME appearance or a custom schedule; sunrise/sunset is deferred |
|
||||
| `theme.schedule.timezone` | `"system"` or IANA timezone | `"system"` | Auto schedule timezone |
|
||||
| `theme.schedule.light-at`, `theme.schedule.dark-at` | `HH:MM` | `"07:00"`, `"19:00"` | Auto boundaries; times must differ |
|
||||
| `theme.accessibility.increase-contrast` | boolean | `false` | Strengthen text and borders |
|
||||
| `theme.accessibility.reduce-transparency` | boolean | `false` | Solid surfaces; skip background blur |
|
||||
| `appearance.corner-radius` | number ≥ 0 | unset | Legacy fallback for shell radius |
|
||||
| `appearance.inactive-dim.enabled` | boolean | `false` | Dim unfocused windows |
|
||||
| `appearance.inactive-dim.amount` | number 0–1 | `0.15` | Darkening strength |
|
||||
| `appearance.inactive-dim.duration-ms` | number ≥ 0 | `150` | Dimming and focus-ring transition; 0 snaps |
|
||||
| `theme.typography.font-family` | string | system sans-serif | Shell, Settings and overview font |
|
||||
| `theme.typography.font-family` | string | `"Inter"` | Shell, Settings and overview font |
|
||||
| `theme.background.path` | string | bundled Ferese wallpaper | Wallpaper image path; an existing selection overrides the default |
|
||||
| `theme.background.mode` | `"fill"`, `"fit"` | `"fill"` | Crop or letterbox |
|
||||
| `theme.material.style` | `"solid"`, `"translucent"` | `"solid"` | Shell background material |
|
||||
| `theme.material.opacity` | number 0–1 | `0.78` | Shared shell background opacity for bars, menus, popovers, notifications and themed dialogs. Text/icons stay opaque; 0 hides the material. Solid mode is always opaque |
|
||||
| `theme.material.tint-strength` | number 0–1 | Dark `0.5`, light `1` | Color strength over the blurred backdrop |
|
||||
| `theme.material.blur-radius` | number ≥ 0 | `12` | Translucent backdrop blur, capped at 32; 0 disables |
|
||||
|
||||
| `theme.colors` key | Default | Meaning |
|
||||
|
||||
@@ -7,9 +7,10 @@ and defaults.
|
||||
|
||||
## Shared appearance
|
||||
|
||||
The six presets are Ferese Blue, Monochrome, Gruvbox, Dracula, Ayu Light, and
|
||||
Monokai. Ferese Blue uses the logo accent, `#3D7BE6`. Presets change colors while
|
||||
preserving wallpaper, typography, corners, and opacity.
|
||||
The six theme families are Ferese Blue, Catppuccin, Gruvbox, Rosé Pine, Tokyo
|
||||
Night, and Everforest. Each has a paired light and dark palette. Ferese Blue uses the
|
||||
logo accent, `#3D7BE6`. Presets supply colors and materials; explicit wallpaper,
|
||||
typography, geometry, and material overrides remain in place.
|
||||
|
||||
The interface font applies to shell text and native dialogs. Clock and note
|
||||
widgets can override it. Managed-window corners and shell corners are separate
|
||||
@@ -57,6 +58,7 @@ background so the dialog remains usable.
|
||||
| `palette` | Resolve configuration colors and build the native toolkit theme |
|
||||
| `controls` | Surfaces, navigation, text buttons, and input styles |
|
||||
| `menus` | Rows, headings, sections, separators, switches, and sliders |
|
||||
| `gallery` | Split theme previews, selected badges and accessible radio navigation |
|
||||
| `calendar` | Date/time labels, clock digits, and calendar grids |
|
||||
| `icons` | Shared SVG assets, sizing, and tint |
|
||||
| `typography` | Shared font selection and text |
|
||||
@@ -69,7 +71,7 @@ fonts, foreground colors, or opacity values.
|
||||
|
||||
## UI checks
|
||||
|
||||
- Check text and icons in all six presets, including Ayu Light.
|
||||
- Check text and icons in all twelve light and dark variants.
|
||||
- Check rest, hover, pressed, focused, selected, and disabled controls.
|
||||
- Keep accent-button labels readable after typing or changing focus.
|
||||
- Keep controls usable with solid materials and blur disabled.
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# Themes
|
||||
|
||||
The compositor resolves theme files and publishes a versioned snapshot. Native
|
||||
apps consume that snapshot; Settings edits references and requests isolated previews.
|
||||
|
||||
## Preset pairs
|
||||
|
||||
| Family | Dark preset | Light preset |
|
||||
| --- | --- | --- |
|
||||
| Ferese Blue (default) | `ferese-blue` | `ferese-blue-light` |
|
||||
| Catppuccin | `catppuccin-mocha` | `catppuccin-latte` |
|
||||
| Gruvbox | `gruvbox` | `gruvbox-light` |
|
||||
| Rosé Pine | `rose-pine-moon` | `rose-pine-dawn` |
|
||||
| Tokyo Night | `tokyo-night` | `tokyo-night-day` |
|
||||
| Everforest | `everforest-dark` | `everforest-light` |
|
||||
|
||||
Each variant supplies its own surfaces, tint strength, opacity, blur, borders,
|
||||
and shadows. Shared material overrides apply to both appearances; appearance
|
||||
specific overrides take precedence. [Palette sources and adjustments](theme-palette-sources.md)
|
||||
record the upstream colors and contrast tuning.
|
||||
|
||||
## Configuration
|
||||
|
||||
```kdl
|
||||
theme {
|
||||
mode "auto"
|
||||
family "catppuccin"
|
||||
split #false
|
||||
file "themes/custom_theme.kdl"
|
||||
light {
|
||||
preset "ferese-blue-light"
|
||||
file "themes/custom_theme_light.kdl"
|
||||
material { opacity 0.94; tint-strength 1; }
|
||||
}
|
||||
dark { preset "ferese-blue"; file "themes/custom_theme_dark.kdl"; }
|
||||
schedule { source "schedule"; timezone "system"; light-at "07:00"; dark-at "19:00"; }
|
||||
accessibility { increase-contrast #false; reduce-transparency #false; }
|
||||
accent "#3D7BE6"
|
||||
}
|
||||
```
|
||||
|
||||
Paths are relative to the configuration directory. Partial theme files contain
|
||||
`theme { colors { accent "#3D7BE6"; } }`, or the token sections directly.
|
||||
Merge order is preset, shared file, appearance file, shared inline tokens,
|
||||
appearance inline tokens, then accent and accessibility adjustments. Unknown tokens warn; invalid known values reject
|
||||
an entire update and retain the last working snapshot.
|
||||
|
||||
## Snapshot
|
||||
|
||||
`ResolvedTheme` contains effective appearance and typed colors, surfaces, material,
|
||||
geometry, typography, wallpaper, border paints, and shadow tokens. Base, raised,
|
||||
and application backgrounds are separate colors. Selected mode,
|
||||
accessibility inputs, requested accent, adjusted accent, warnings, schema version,
|
||||
and revision accompany it. Consumers do not infer appearance from brightness.
|
||||
|
||||
Material tokens differ by appearance. Light glass uses full tint strength and
|
||||
0.94 default opacity to stay pale over dark wallpapers; dark glass uses a softer
|
||||
0.5 tint. Shared material overrides apply to both unless the appearance overrides
|
||||
them. Reduce Transparency resolves to solid
|
||||
material with zero blur. Increase Contrast transforms text and border colors.
|
||||
Accents are adjusted against surfaces independently for each appearance, then
|
||||
on-accent text is selected for readability.
|
||||
|
||||
## Scheduling
|
||||
|
||||
Auto selects the most recent light or dark boundary in the configured timezone.
|
||||
The interval is circular, so light may start after dark. Equal boundaries are
|
||||
invalid. Repeated hours use their first occurrence; skipped hours shift forward
|
||||
by the timezone gap. The next transition arms a realtime timer. Resume, timezone
|
||||
replacement, and realtime clock jumps trigger recalculation without polling.
|
||||
|
||||
## Transitions
|
||||
|
||||
Prepare the incoming wallpaper before starting a 250 ms eased transition. Decode
|
||||
or upload failures and a bounded readiness timeout retain the old wallpaper and
|
||||
allow the theme change. Reduced motion switches immediately.
|
||||
|
||||
Surface, tint, border, shadow, and accent colors can transition. Fonts and geometry
|
||||
switch atomically. Foregrounds remain readable across dark and light backdrops. Near the neutral
|
||||
midpoint, the transition temporarily strengthens material tint instead of fading
|
||||
text through the background color. Tests sample primary, muted,
|
||||
and on-accent text throughout the transition. Wallpaper changes use prepared
|
||||
images rather than a placeholder.
|
||||
|
||||
Publish effective appearance at transition start. The portal emits changed
|
||||
`color-scheme`, `accent-color`, `contrast`, and `reduced-motion` values then.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Action |
|
||||
| --- | --- |
|
||||
| `feresectl theme get` | Read the current mode, effective appearance, and colors |
|
||||
| `feresectl theme status` | Read warnings and the last reload error |
|
||||
| `feresectl theme subscribe` | Stream changes as JSON; no polling |
|
||||
| `feresectl theme mode light` | Save Light mode; also accepts `dark` and `auto` |
|
||||
| `feresectl theme preview PATH` | Resolve a candidate config without applying it |
|
||||
|
||||
Settings shows six paired previews, with Light on the left and Dark on the right.
|
||||
Auto uses a switch in the page header. The adjacent button shows its source and,
|
||||
when enabled, the effective appearance. It opens source and schedule controls
|
||||
below the gallery, which can be edited before enabling Auto. Enabling Auto
|
||||
immediately follows the chosen source; disabling it keeps the current appearance.
|
||||
Manual appearance controls are in Control Center. Selecting a family
|
||||
applies the matching variant live and offers Undo for five
|
||||
seconds. Hovering does not change the desktop. Arrow keys navigate the gallery.
|
||||
Enable **Use different themes for light and dark** for two independent galleries.
|
||||
Disabling it uses the active family and retains the stored pair. A missing
|
||||
variant uses Ferese Blue for that appearance and displays a fallback note.
|
||||
|
||||
**Follow system** reads `org.gnome.desktop.interface color-scheme` and monitors
|
||||
changes. `default` and `prefer-light` mean Light; `prefer-dark` means Dark. If the
|
||||
setting is unavailable, Ferese uses Dark and displays a note. This source does
|
||||
not read Ferese's own appearance portal, avoiding a feedback loop. Sunrise/sunset
|
||||
is deferred.
|
||||
|
||||
The gallery’s Import button adds a family to the gallery without selecting it. Give each
|
||||
variant an explicit section; omitted tokens inherit Ferese Blue for that
|
||||
appearance. For example:
|
||||
|
||||
```kdl
|
||||
theme {
|
||||
name "My theme"
|
||||
light { colors { accent "#8F5300"; }; }
|
||||
dark { colors { accent "#CBA6F7"; }; }
|
||||
}
|
||||
```
|
||||
|
||||
Settings stores the file reference under `theme.custom-themes.<id>.file`.
|
||||
The compositor validates it and publishes its palettes alongside built-ins.
|
||||
Omit a variant to make a single-variant family. Token-only partial files remain
|
||||
supported as shared/per-appearance overrides; imports require explicit variants
|
||||
so appearance is never guessed from brightness. Imported and override files
|
||||
reload after a short debounce, including saves that replace the file. Invalid
|
||||
updates retain the last working theme and produce one desktop notification.
|
||||
|
||||
High contrast, Reduce Transparency and Reduce Motion are in Accessibility.
|
||||
Material, accent, font and geometry controls stay visible below the gallery. Theme
|
||||
overrides are under Advanced customization, in one file-picker row with a
|
||||
Shared/Light/Dark selector; each stored
|
||||
reference is retained when selecting another scope. Settings and shell switches
|
||||
share the same palette. Switch and slider thumbs stay circular regardless of
|
||||
shell corner radius.
|
||||
|
||||
When an older config first adopts mode or preset selection, Ferese moves its
|
||||
inline palette into the dark selection and preserves comments. Shared wallpaper,
|
||||
font, and geometry settings continue to apply to both appearances. For a wallpaper
|
||||
pair, set `background { path "..."; }` inside each appearance or its theme file.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Theme palette sources
|
||||
Ferese maps upstream palettes to desktop roles. Materials are authored for Ferese;
|
||||
text and accents have the adjustments below to meet desktop contrast requirements.
|
||||
| Family | Source revision | License |
|
||||
| --- | --- | --- |
|
||||
| catppuccin | [07d02aa110ef](https://github.com/catppuccin/palette/blob/07d02aa110ef9eb7e7427afca5c73ba9cf7f8ebd/palette.json) | MIT |
|
||||
| gruvbox | [ef8864bb42bf](https://github.com/morhetz/gruvbox/blob/ef8864bb42bf244f0295d1c5a403b27e3d139695/colors/gruvbox.vim) | MIT/X11 |
|
||||
| rose-pine | [ff483051a47e](https://github.com/rose-pine/neovim/blob/ff483051a47e27d84bdef47703538df1ed9f4a47/lua/rose-pine/palette.lua) | MIT |
|
||||
| tokyo-night | [cdc07ac78467](https://github.com/folke/tokyonight.nvim/blob/cdc07ac78467a233fd62c493de29a17e0cf2b2b6/extras/kitty/tokyonight_day.conf) | Apache-2.0; palette exports marked MIT |
|
||||
| everforest | [85a86eb62409](https://github.com/sainnhe/everforest/blob/85a86eb62409e3ec88713bff3d1b9d7374e112e4/autoload/everforest.vim) | MIT |
|
||||
|
||||
Tokyo Night Night also uses its [official export](https://github.com/folke/tokyonight.nvim/blob/cdc07ac78467a233fd62c493de29a17e0cf2b2b6/extras/kitty/tokyonight_night.conf).
|
||||
|
||||
## Color adjustments
|
||||
|
||||
| Variant | Token | Upstream | Ferese |
|
||||
| --- | --- | --- | --- |
|
||||
| rose-pine-moon | muted | `#908CAA` | `#918DAA` |
|
||||
| rose-pine-dawn | muted | `#797593` | `#716D89` |
|
||||
| tokyo-night-day | text | `#3760BF` | `#3358B0` |
|
||||
| tokyo-night-day | muted | `#6172B0` | `#4D5A8C` |
|
||||
| tokyo-night-day | accent | `#2E7DE9` | `#2C76DD` |
|
||||
| everforest-light | muted | `#829181` | `#677367` |
|
||||
| everforest-light | accent | `#8DA101` | `#849601` |
|
||||
|
||||
Catppuccin Latte uses upstream subtext1 for muted labels. Gruvbox uses readable
|
||||
foreground shades rather than its comment gray. Rosé Pine uses its subtle role
|
||||
for secondary labels and its love accent in Dawn. Everforest uses the Hard pair.
|
||||
Tokyo Night uses the upstream Night and Day exports.
|
||||
|
||||
License texts are bundled in [assets/themes/licenses](../assets/themes/licenses)
|
||||
and copied into each installed release. Older saved Monochrome, Dracula, Ayu Light,
|
||||
and Monokai selections remain readable, but are absent from the gallery.
|
||||
@@ -16,6 +16,9 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
}
|
||||
return autostart::run();
|
||||
}
|
||||
if env::args().nth(1).as_deref() == Some("theme") {
|
||||
return theme_command(env::args().skip(2).collect());
|
||||
}
|
||||
let (command, args) = parse_args(env::args().skip(1))?;
|
||||
let request = Request {
|
||||
version: VERSION,
|
||||
@@ -43,6 +46,44 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
}
|
||||
}
|
||||
|
||||
fn theme_command(args: Vec<String>) -> Result<(), Box<dyn Error>> {
|
||||
let mut connection = ferese_ipc::theme::Connection::connect()?;
|
||||
match args.as_slice() {
|
||||
[command] if command == "get" || command == "status" => {
|
||||
println!("{}", serde_json::to_string_pretty(&connection.get()?)?);
|
||||
}
|
||||
[command] if command == "subscribe" => {
|
||||
let mut snapshot = connection.get()?;
|
||||
loop {
|
||||
println!("{}", serde_json::to_string(&snapshot)?);
|
||||
snapshot = connection.watch(snapshot.revision)?;
|
||||
}
|
||||
}
|
||||
[command, mode] if command == "mode" && matches!(mode.as_str(), "light" | "dark" | "auto") => {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&connection.call("theme-set-mode", json!({"mode": mode}))?)?
|
||||
);
|
||||
}
|
||||
[command, path] if command == "preview" => {
|
||||
let source = fs::read_to_string(path)?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&connection.call(
|
||||
"theme-preview",
|
||||
json!({"source": source, "directory": std::path::Path::new(path).canonicalize()?.parent()})
|
||||
)?)?
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
return Err(
|
||||
"usage: feresectl theme <get|status|subscribe|mode light|mode dark|mode auto|preview PATH>".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// The compositor stages the PNG in a private directory and replies with its
|
||||
// path. Open it before unlinking so a failure never destroys the only copy, and
|
||||
// stream the bytes with explicit writes: println! would corrupt binary output.
|
||||
|
||||
+5
-17
@@ -89,22 +89,17 @@ appearance {
|
||||
}
|
||||
|
||||
theme {
|
||||
mode "dark"
|
||||
family "ferese-blue"
|
||||
split #false
|
||||
schedule { source "schedule"; timezone "system"; light-at "07:00"; dark-at "19:00"; }
|
||||
accessibility { increase-contrast #false; reduce-transparency #false; }
|
||||
background {
|
||||
mode "fill"
|
||||
// path "/absolute/path/to/wallpaper.png"
|
||||
}
|
||||
colors {
|
||||
accent "#3D7BE6"
|
||||
surface-base "#111821"
|
||||
text-primary "#F4F7FB"
|
||||
text-muted "#8793A2"
|
||||
border "#FFFFFF18"
|
||||
shadow "#00000055"
|
||||
}
|
||||
material {
|
||||
style "translucent"
|
||||
opacity 0.78
|
||||
blur-radius 25
|
||||
}
|
||||
geometry {
|
||||
window-radius 12
|
||||
@@ -125,13 +120,6 @@ theme {
|
||||
opacity 0.07
|
||||
}
|
||||
}
|
||||
surface {
|
||||
bar {
|
||||
background "#111821"
|
||||
text-primary "#F4F7FB"
|
||||
text-muted "#8793A2"
|
||||
}
|
||||
}
|
||||
// typography { font-family "JetBrainsMono Nerd Font"; }
|
||||
}
|
||||
|
||||
|
||||
+15
-7
@@ -29,6 +29,7 @@ pub(crate) struct ShortcutField {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Message {
|
||||
ThemeChanged(ferese_config::theme::Snapshot),
|
||||
Opened(cosmic::iced::window::Id),
|
||||
Attached(Result<(Option<Arc<crate::parent::Parent>>, Result<ModalMaterial, String>), String>),
|
||||
Accept,
|
||||
@@ -100,12 +101,7 @@ impl cosmic::Application for Consent {
|
||||
prompt,
|
||||
material: None,
|
||||
parent: None,
|
||||
palette: {
|
||||
let document = ferese_config::config_path()
|
||||
.and_then(|path| std::fs::read_to_string(path).ok())
|
||||
.and_then(|source| ferese_config::Document::parse(&source).ok());
|
||||
Palette::from_document(document.as_ref())
|
||||
},
|
||||
palette: Palette::from_resolved(&ferese_theme::service::current().presented),
|
||||
font,
|
||||
background,
|
||||
};
|
||||
@@ -113,6 +109,8 @@ impl cosmic::Application for Consent {
|
||||
}
|
||||
|
||||
fn subscription(&self) -> cosmic::iced::Subscription<Message> {
|
||||
cosmic::iced::Subscription::batch([
|
||||
ferese_theme::service::subscription().map(Message::ThemeChanged),
|
||||
cosmic::iced::event::listen_with(|event, _, id| match event {
|
||||
cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => Some(Message::Opened(id)),
|
||||
cosmic::iced::Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
|
||||
@@ -120,11 +118,21 @@ impl cosmic::Application for Consent {
|
||||
..
|
||||
}) => Some(Message::Cancel),
|
||||
_ => None,
|
||||
})
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
fn update(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::ThemeChanged(snapshot) => {
|
||||
self.palette = Palette::from_resolved(&snapshot.presented).flat();
|
||||
self.font = ferese_theme::font(Some(&snapshot.presented.tokens.typography.font_family));
|
||||
self.background = cosmic::iced::Color {
|
||||
a: ferese_theme::service::opacity(&snapshot.presented),
|
||||
..self.palette.sidebar
|
||||
};
|
||||
return cosmic::command::set_theme(self.palette.native_theme());
|
||||
}
|
||||
Message::Opened(id) => {
|
||||
let parent = self.prompt.parent.clone();
|
||||
return cosmic::iced::window::run(id, move |window| {
|
||||
|
||||
+21
-12
@@ -52,6 +52,7 @@ pub(crate) struct Selection {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Message {
|
||||
ThemeChanged(ferese_config::theme::Snapshot),
|
||||
WindowOpened(cosmic::iced::window::Id),
|
||||
Attached(
|
||||
Result<
|
||||
@@ -159,14 +160,28 @@ impl cosmic::Application for Picker {
|
||||
}
|
||||
|
||||
fn subscription(&self) -> cosmic::iced::Subscription<Message> {
|
||||
cosmic::iced::Subscription::batch([
|
||||
ferese_theme::service::subscription().map(Message::ThemeChanged),
|
||||
cosmic::iced::event::listen_with(|event, _, id| match event {
|
||||
cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => Some(Message::WindowOpened(id)),
|
||||
cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => {
|
||||
Some(Message::WindowOpened(id))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
fn update(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::ThemeChanged(snapshot) => {
|
||||
self.palette = ferese_theme::Palette::from_resolved(&snapshot.presented).flat();
|
||||
self.font = ferese_theme::font(Some(&snapshot.presented.tokens.typography.font_family));
|
||||
self.background = cosmic::iced::Color {
|
||||
a: ferese_theme::service::opacity(&snapshot.presented),
|
||||
..self.palette.sidebar
|
||||
};
|
||||
return cosmic::command::set_theme(self.palette.native_theme());
|
||||
}
|
||||
Message::WindowOpened(id) => {
|
||||
let parent = self.prompt.parent.clone();
|
||||
return cosmic::iced::window::run(id, move |window| {
|
||||
@@ -381,20 +396,14 @@ pub(crate) fn appearance() -> (
|
||||
cosmic::iced::Color,
|
||||
ferese_theme::Palette,
|
||||
) {
|
||||
let document = ferese_config::config_path()
|
||||
.and_then(|path| std::fs::read_to_string(path).ok())
|
||||
.and_then(|source| ferese_config::Document::parse(&source).ok());
|
||||
let palette = ferese_theme::Palette::from_document(document.as_ref()).flat();
|
||||
let family = document
|
||||
.as_ref()
|
||||
.and_then(|document| document.get("theme.typography.font_family"))
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("Inter");
|
||||
let snapshot = ferese_theme::service::current();
|
||||
let palette = ferese_theme::Palette::from_resolved(&snapshot.presented).flat();
|
||||
let family = &snapshot.presented.tokens.typography.font_family;
|
||||
(
|
||||
palette.native_theme(),
|
||||
ferese_theme::font(Some(family)),
|
||||
cosmic::iced::Color {
|
||||
a: ferese_theme::material_opacity(document.as_ref()),
|
||||
a: ferese_theme::service::opacity(&snapshot.presented),
|
||||
..palette.sidebar
|
||||
},
|
||||
palette,
|
||||
|
||||
+45
-45
@@ -2,8 +2,7 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use ferese_config::Document;
|
||||
use ferese_theme::Palette;
|
||||
use ferese_config::theme::{Appearance as ThemeAppearance, ResolvedTheme};
|
||||
use tokio::sync::RwLock;
|
||||
use zbus::Connection;
|
||||
use zbus::object_server::SignalEmitter;
|
||||
@@ -18,33 +17,21 @@ struct Appearance {
|
||||
scheme: u32,
|
||||
accent: (f64, f64, f64),
|
||||
reduced_motion: u32,
|
||||
contrast: u32,
|
||||
}
|
||||
|
||||
impl Appearance {
|
||||
fn from_document(document: Option<&Document>) -> Self {
|
||||
let palette = Palette::from_document(document);
|
||||
let animations = document
|
||||
.and_then(|document| document.get("animations.enabled"))
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
fn from_resolved(theme: &ResolvedTheme) -> Self {
|
||||
let accent = ferese_config::theme::rgba(&theme.tokens.colors.accent).expect("validated accent");
|
||||
Self {
|
||||
scheme: if ferese_theme::luminance(palette.sidebar) > 0.5 {
|
||||
scheme: if theme.appearance == ThemeAppearance::Light {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
},
|
||||
accent: (
|
||||
palette.accent.r as f64,
|
||||
palette.accent.g as f64,
|
||||
palette.accent.b as f64,
|
||||
),
|
||||
reduced_motion: u32::from(
|
||||
!animations
|
||||
|| document
|
||||
.and_then(|doc| doc.get("animations.reduced_motion"))
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
),
|
||||
accent: (accent[0], accent[1], accent[2]),
|
||||
reduced_motion: u32::from(theme.reduced_motion),
|
||||
contrast: u32::from(theme.accessibility.increase_contrast),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +39,7 @@ impl Appearance {
|
||||
HashMap::from([
|
||||
("color-scheme".into(), self.scheme.into()),
|
||||
("accent-color".into(), Value::from(self.accent).try_to_owned().unwrap()),
|
||||
("contrast".into(), 0u32.into()),
|
||||
("contrast".into(), self.contrast.into()),
|
||||
("reduced-motion".into(), self.reduced_motion.into()),
|
||||
])
|
||||
}
|
||||
@@ -63,16 +50,38 @@ pub(crate) struct Settings(Arc<RwLock<Appearance>>);
|
||||
|
||||
impl Settings {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self(Arc::new(RwLock::new(
|
||||
load().unwrap_or_else(|| Appearance::from_document(None)),
|
||||
)))
|
||||
Self(Arc::new(RwLock::new(Appearance::from_resolved(
|
||||
&ferese_ipc::theme::current().theme,
|
||||
))))
|
||||
}
|
||||
|
||||
pub(crate) async fn watch(self, connection: Connection) {
|
||||
let emitter = SignalEmitter::new(&connection, PATH).unwrap();
|
||||
loop {
|
||||
let connection = tokio::task::spawn_blocking(ferese_ipc::theme::Connection::connect).await;
|
||||
let Ok(Ok(mut connection)) = connection else {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
let Some(next) = load() else { continue };
|
||||
continue;
|
||||
};
|
||||
let Ok(cancellation) = connection.cancellation() else {
|
||||
return;
|
||||
};
|
||||
let (send, mut receive) = tokio::sync::mpsc::channel(1);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let Ok(mut snapshot) = connection.get() else { return };
|
||||
loop {
|
||||
let revision = snapshot.revision;
|
||||
if send.blocking_send(snapshot).is_err() {
|
||||
return;
|
||||
}
|
||||
match connection.watch(revision) {
|
||||
Ok(next) => snapshot = next,
|
||||
Err(_) => return,
|
||||
}
|
||||
}
|
||||
});
|
||||
while let Some(snapshot) = receive.recv().await {
|
||||
let next = Appearance::from_resolved(&snapshot.theme);
|
||||
let mut current = self.0.write().await;
|
||||
if *current == next {
|
||||
continue;
|
||||
@@ -86,6 +95,9 @@ impl Settings {
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(cancellation);
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,19 +147,6 @@ fn matches_namespace(filters: &[String], namespace: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn load() -> Option<Appearance> {
|
||||
let path = ferese_config::config_path()?;
|
||||
let source = match std::fs::read_to_string(path) {
|
||||
Ok(source) if source.len() <= 1024 * 1024 => source,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Some(Appearance::from_document(None));
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let document = Document::parse(&source).ok()?;
|
||||
Some(Appearance::from_document(Some(&document)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -170,18 +169,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn reports_ferese_theme_and_reduced_motion() {
|
||||
let doc = Document::parse(
|
||||
"theme { colors { surface-base \"#ffffff\"; accent \"#ff8000\"; }; }; animations { enabled #false; }",
|
||||
)
|
||||
.unwrap();
|
||||
let appearance = Appearance::from_document(Some(&doc));
|
||||
let mut resolved = ResolvedTheme::default();
|
||||
resolved.appearance = ThemeAppearance::Light;
|
||||
resolved.tokens.colors.accent = "#ff8000".into();
|
||||
resolved.reduced_motion = true;
|
||||
resolved.accessibility.increase_contrast = true;
|
||||
let appearance = Appearance::from_resolved(&resolved);
|
||||
assert_eq!(appearance.scheme, 2);
|
||||
assert_eq!(appearance.reduced_motion, 1);
|
||||
assert_eq!(appearance.accent.0, 1.);
|
||||
assert_eq!(appearance.accent.2, 0.);
|
||||
assert_eq!(
|
||||
u32::try_from(appearance.values().remove("contrast").unwrap()).unwrap(),
|
||||
0
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ PAGES = [
|
||||
('why-ferese', 'Why Ferese', ROOT / 'docs/why-ferese.md'),
|
||||
('installation', 'Installation', ROOT / 'docs/installation.md'),
|
||||
('configuration', 'Configuration', ROOT / 'docs/configuration.md'),
|
||||
('theme-engine', 'Themes', ROOT / 'docs/theme-engine.md'),
|
||||
('theme-palette-sources', 'Theme palette sources', ROOT / 'docs/theme-palette-sources.md'),
|
||||
('shortcuts', 'Shortcuts and gestures', ROOT / 'docs/shortcuts.md'),
|
||||
('desktop-widgets', 'Desktop widgets', ROOT / 'docs/desktop-widgets.md'),
|
||||
('notifications', 'Notifications', ROOT / 'docs/notifications.md'),
|
||||
|
||||
@@ -135,6 +135,9 @@ install -m 0755 -- "$repo_dir/packaging/ferese-screenshot" "$release_dir/ferese-
|
||||
install -m 0644 -- "$repo_dir/packaging/config.kdl" "$release_dir/config.example.kdl"
|
||||
install -D -m 0644 -- "$repo_dir/assets/fonts/Comfortaa-LICENSE.txt" "$release_dir/licenses/Comfortaa-LICENSE.txt"
|
||||
install -D -m 0644 -- "$repo_dir/assets/fonts/Cantarell-LICENSE.txt" "$release_dir/licenses/Cantarell-LICENSE.txt"
|
||||
for license in "$repo_dir"/assets/themes/licenses/*.txt; do
|
||||
install -D -m 0644 -- "$license" "$release_dir/licenses/theme-$(basename -- "$license")"
|
||||
done
|
||||
install -d -m 0755 -- "$release_dir/wallpapers"
|
||||
install -m 0644 -- "$repo_dir/assets/wallpapers/ferese.png" "$release_dir/wallpapers/ferese.png"
|
||||
install -m 0644 -- "$repo_dir/assets/wallpapers/ferese.svg" "$release_dir/wallpapers/ferese.svg"
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Opt-in theme integration test against a disposable nested desktop.
|
||||
|
||||
FERESE_TEST_THEME=1 python3 scripts/tests/test_theme_isolated.py
|
||||
Requires target/debug/ferese, feresectl, and a running Wayland desktop.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import select
|
||||
import signal
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get("FERESE_TEST_THEME") == "1", "nested theme test is opt-in")
|
||||
class ThemeTest(unittest.TestCase):
|
||||
def test_modes_files_preview_and_idle_subscription(self):
|
||||
repo = Path(__file__).resolve().parents[2]
|
||||
children = []
|
||||
with tempfile.TemporaryDirectory(prefix="ferese-theme-test-") as temporary:
|
||||
root = Path(temporary)
|
||||
runtime = root / "runtime"
|
||||
runtime.mkdir(mode=0o700)
|
||||
config = root / "config/ferese"
|
||||
config.mkdir(parents=True)
|
||||
source = config / "config.kdl"
|
||||
shared = config / "themes/shared.kdl"
|
||||
shared.parent.mkdir()
|
||||
shared.write_text('colors { accent "#3D7BE6"; }\n')
|
||||
source.write_text('// Keep my comment\ntheme { mode "dark"; file "themes/shared.kdl"; material { style "translucent"; opacity 0.78; }; }\n')
|
||||
display = Path(os.environ["WAYLAND_DISPLAY"])
|
||||
if not display.is_absolute():
|
||||
display = Path(os.environ["XDG_RUNTIME_DIR"]) / display
|
||||
env = dict(os.environ, WAYLAND_DISPLAY=str(display), XDG_RUNTIME_DIR=str(runtime),
|
||||
XDG_CONFIG_HOME=str(root / "config"))
|
||||
|
||||
def launch(command, **kwargs):
|
||||
child = subprocess.Popen(command, env=env, start_new_session=True, **kwargs)
|
||||
children.append(child)
|
||||
return child
|
||||
|
||||
def command(*args):
|
||||
return json.loads(subprocess.check_output([str(repo / "target/debug/feresectl"), *args], env=env, timeout=5))
|
||||
|
||||
def until(predicate, seconds=5):
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
value = predicate()
|
||||
if value:
|
||||
return value
|
||||
except (subprocess.SubprocessError, ConnectionError):
|
||||
pass
|
||||
time.sleep(.025)
|
||||
self.fail("Theme did not reach expected state")
|
||||
|
||||
def replace(path, text):
|
||||
temporary = path.with_suffix(".new")
|
||||
temporary.write_text(text)
|
||||
temporary.replace(path)
|
||||
|
||||
with (root / "compositor.log").open("w") as log:
|
||||
try:
|
||||
bus = launch(["dbus-daemon", "--session", "--nofork", "--print-address=1"], stdout=subprocess.PIPE, text=True)
|
||||
env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip()
|
||||
compositor = launch([str(repo / "target/debug/ferese"), "--backend", "nested"], stdout=log, stderr=log)
|
||||
until(lambda: (runtime / "ferese/control.sock").is_socket())
|
||||
self.assertEqual(command("theme", "get")["theme"]["appearance"], "dark")
|
||||
subscriber = launch([str(repo / "target/debug/feresectl"), "theme", "subscribe"], stdout=subprocess.PIPE, text=True)
|
||||
self.assertTrue(select.select([subscriber.stdout], [], [], 3)[0])
|
||||
initial = json.loads(subscriber.stdout.readline())
|
||||
command("theme", "mode", "light")
|
||||
ready = until(lambda: (snapshot := command("theme", "get"))["presented"] == snapshot["theme"] and snapshot["theme"]["appearance"] == "light" and snapshot)
|
||||
self.assertEqual(ready["mode"], "light")
|
||||
self.assertIn("// Keep my comment", source.read_text())
|
||||
self.assertGreater(ready["revision"], initial["revision"])
|
||||
candidate = root / "candidate.kdl"
|
||||
candidate.write_text('theme { mode "dark"; accent "#FE8019"; }\n')
|
||||
preview = command("theme", "preview", str(candidate))
|
||||
self.assertEqual(preview["theme"]["appearance"], "dark")
|
||||
self.assertEqual(command("theme", "get")["revision"], ready["revision"])
|
||||
replace(shared, 'colors { accent "invalid"; }\n')
|
||||
rejected = until(lambda: (snapshot := command("theme", "status"))["error"] and snapshot)
|
||||
self.assertEqual(rejected["theme"], ready["theme"])
|
||||
replace(shared, 'colors { accent "#8F5300"; }; future-token 1\n')
|
||||
repaired = until(lambda: (snapshot := command("theme", "get"))["error"] is None and snapshot["theme"]["requested_accent"] == "#8F5300" and snapshot["presented"] == snapshot["theme"] and snapshot)
|
||||
self.assertTrue(any("future_token" in warning for warning in repaired["warnings"]))
|
||||
time.sleep(.35)
|
||||
self.assertEqual(command("theme", "get")["revision"], repaired["revision"])
|
||||
replace(source, source.read_text().replace('file "themes/shared.kdl"', 'file "themes/missing.kdl"'))
|
||||
until(lambda: command("theme", "get")["error"])
|
||||
(shared.parent / "missing.kdl").write_text('material { style "translucent"; }; colors { accent "#3D7BE6"; }\n')
|
||||
until(lambda: command("theme", "get")["error"] is None)
|
||||
replace(source, source.read_text().replace(' mode ', ' accessibility { reduce-transparency #true; }\n mode '))
|
||||
solid = until(lambda: (snapshot := command("theme", "get"))["theme"]["accessibility"]["reduce_transparency"] and snapshot["presented"] == snapshot["theme"] and snapshot)
|
||||
self.assertEqual(solid["theme"]["tokens"]["material"]["blur_radius"], 0)
|
||||
self.assertEqual(solid["theme"]["tokens"]["material"]["style"], "solid")
|
||||
imported = shared.parent / "family.kdl"
|
||||
imported.write_text('theme { name "Imported"; dark { colors { accent "#CBA6F7"; }; }; }\n')
|
||||
replace(source, 'theme { mode "light"; family "custom-demo"; split #false; custom-themes { custom-demo { file "themes/family.kdl"; }; }; }\n')
|
||||
missing = until(lambda: (snapshot := command("theme", "get"))["fallback_note"] and "no light variant" in snapshot["fallback_note"] and snapshot)
|
||||
self.assertTrue(any(family["id"] == "custom-demo" and family["light"] is None for family in missing["families"]))
|
||||
command("theme", "mode", "dark")
|
||||
applied = until(lambda: (snapshot := command("theme", "get"))["theme"]["requested_accent"] == "#CBA6F7" and snapshot["presented"] == snapshot["theme"] and snapshot)
|
||||
self.assertIsNone(applied["fallback_note"])
|
||||
replace(imported, 'theme { dark { colors { accent "invalid"; }; }; }\n')
|
||||
rejected = until(lambda: (snapshot := command("theme", "get"))["error"] and snapshot)
|
||||
self.assertEqual(rejected["theme"], applied["theme"])
|
||||
replace(imported, 'theme { name "Repaired"; dark { colors { accent "#FE8019"; }; }; }\n')
|
||||
until(lambda: (snapshot := command("theme", "get"))["error"] is None and snapshot["theme"]["requested_accent"] == "#FE8019")
|
||||
replace(source, 'theme { mode "dark"; family "ferese-blue"; split #true; light { family "gruvbox"; }; dark { family "catppuccin"; }; }\n')
|
||||
until(lambda: command("theme", "get")["theme"]["tokens"]["colors"]["surface_base"] == "#1E1E2E")
|
||||
command("theme", "mode", "light")
|
||||
until(lambda: command("theme", "get")["theme"]["tokens"]["colors"]["surface_base"] == "#FBF1C7")
|
||||
if shutil.which("gsettings"):
|
||||
subprocess.run(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "prefer-dark"], env=env, check=True, timeout=5)
|
||||
replace(source, 'theme { mode "auto"; family "everforest"; schedule { source "system"; }; }\n')
|
||||
until(lambda: (snapshot := command("theme", "get"))["mode"] == "auto" and snapshot["theme"]["appearance"] == "dark" and snapshot["fallback_note"] is None)
|
||||
subprocess.run(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "prefer-light"], env=env, check=True, timeout=5)
|
||||
until(lambda: (snapshot := command("theme", "get"))["theme"]["appearance"] == "light" and snapshot["presented"] == snapshot["theme"])
|
||||
self.assertIsNone(compositor.poll())
|
||||
finally:
|
||||
for child in reversed(children):
|
||||
if child.poll() is None:
|
||||
os.killpg(child.pid, signal.SIGTERM)
|
||||
child.wait(timeout=5)
|
||||
if child.stdout:
|
||||
child.stdout.close()
|
||||
if compositor.returncode not in (0, -signal.SIGTERM):
|
||||
log.flush()
|
||||
print((root / "compositor.log").read_text())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,6 +6,8 @@ license.workspace = true
|
||||
rust-version = "1.95"
|
||||
|
||||
[dependencies]
|
||||
ferese-ipc = { path = "../../crates/ferese-ipc" }
|
||||
serde_json.workspace = true
|
||||
ferese-theme = { path = "../../crates/ferese-theme" }
|
||||
ferese-config = { path = "../../crates/ferese-config" }
|
||||
libcosmic.workspace = true
|
||||
|
||||
@@ -4,6 +4,7 @@ use ferese_config::Document;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Appearance {
|
||||
pub appearance: ferese_config::theme::Appearance,
|
||||
pub dim: f32,
|
||||
pub show_clock: bool,
|
||||
pub show_date: bool,
|
||||
@@ -11,17 +12,42 @@ pub struct Appearance {
|
||||
pub panel: Color,
|
||||
pub text: Color,
|
||||
pub accent: Color,
|
||||
pub on_accent: Color,
|
||||
pub radius: f32,
|
||||
pub font: cosmic::font::Font,
|
||||
pub wallpaper: image::Handle,
|
||||
pub wallpaper_path: std::path::PathBuf,
|
||||
pub wallpaper_blur: f32,
|
||||
pub wallpaper_revision: u64,
|
||||
pub reduce_transparency: bool,
|
||||
pub avatar: Option<image::Handle>,
|
||||
}
|
||||
|
||||
impl Appearance {
|
||||
pub fn load(user: &str, path: Option<&std::path::Path>) -> Self {
|
||||
let doc = path
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.and_then(|s| Document::parse(&s).ok());
|
||||
.and_then(|path| std::fs::read_to_string(path).ok())
|
||||
.and_then(|source| Document::parse(&source).ok());
|
||||
let mut theme = ferese_theme::service::current().presented;
|
||||
if let Some(path) = path.filter(|path| ferese_config::config_path().as_deref() != Some(*path))
|
||||
&& let Some(document) = &doc
|
||||
&& let Ok(mut connection) = ferese_ipc::theme::Connection::connect()
|
||||
{
|
||||
let args = serde_json::json!({
|
||||
"source": document.to_string(),
|
||||
"directory": path.parent(),
|
||||
});
|
||||
if let Ok(value) = connection.call("theme-preview", args)
|
||||
&& let Ok(preview) = serde_json::from_value(value["theme"].clone())
|
||||
{
|
||||
theme = preview;
|
||||
}
|
||||
}
|
||||
Self::from_document(user, doc, &theme)
|
||||
}
|
||||
|
||||
fn from_document(user: &str, doc: Option<Document>, theme: &ferese_config::theme::ResolvedTheme) -> Self {
|
||||
let doc = doc.map(|doc| doc.with_theme(theme));
|
||||
let string = |key: &str, fallback: &str| {
|
||||
doc.as_ref()
|
||||
.and_then(|d| d.get(key))
|
||||
@@ -29,7 +55,7 @@ impl Appearance {
|
||||
.unwrap_or(fallback)
|
||||
.to_owned()
|
||||
};
|
||||
let palette = ferese_theme::Palette::from_document(doc.as_ref());
|
||||
let palette = ferese_theme::Palette::from_resolved(&theme);
|
||||
let font = string("theme.typography.font_family", "Inter");
|
||||
let background = string("theme.background.path", ferese_config::default_wallpaper());
|
||||
let path = string("theme.background.lock_path", &background);
|
||||
@@ -40,29 +66,18 @@ impl Appearance {
|
||||
} else {
|
||||
path
|
||||
};
|
||||
let wallpaper = image::Handle::from_path(path);
|
||||
// Soften a bounded thumbnail once, before requesting the lock. The
|
||||
// renderer scales this shared image across outputs; no per-frame blur.
|
||||
let pixels = cosmic::iced::advanced::graphics::image::load(&wallpaper)
|
||||
.or_else(|_| {
|
||||
cosmic::iced::advanced::graphics::image::load(&image::Handle::from_bytes(
|
||||
include_bytes!("../../../assets/wallpapers/ferese.png").as_slice(),
|
||||
))
|
||||
})
|
||||
.expect("bundled wallpaper is valid");
|
||||
let wallpaper_path = std::path::PathBuf::from(path);
|
||||
let blur = doc
|
||||
.as_ref()
|
||||
.and_then(|d| d.get("lock_screen.background_blur"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.and_then(|doc| doc.get("lock_screen.background_blur"))
|
||||
.and_then(|value| value.as_f64())
|
||||
.unwrap_or(18.)
|
||||
.clamp(0., 40.) as f32;
|
||||
let wallpaper = if blur > 0. {
|
||||
let thumbnail = ::image::imageops::thumbnail(&pixels, 640, 640);
|
||||
let softened = ::image::imageops::blur(&thumbnail, blur);
|
||||
image::Handle::from_rgba(softened.width(), softened.height(), softened.into_raw())
|
||||
} else {
|
||||
image::Handle::from_rgba(pixels.width(), pixels.height(), pixels.into_raw())
|
||||
};
|
||||
let reduce_transparency = theme.accessibility.reduce_transparency;
|
||||
let wallpaper =
|
||||
load_wallpaper(&wallpaper_path, if reduce_transparency { 0. } else { blur }).unwrap_or_else(|_| {
|
||||
image::Handle::from_bytes(include_bytes!("../../../assets/wallpapers/ferese.png").as_slice())
|
||||
});
|
||||
let boolean = |key, fallback| {
|
||||
doc.as_ref()
|
||||
.and_then(|d| d.get(key))
|
||||
@@ -70,6 +85,7 @@ impl Appearance {
|
||||
.unwrap_or(fallback)
|
||||
};
|
||||
Self {
|
||||
appearance: theme.appearance,
|
||||
dim: doc
|
||||
.as_ref()
|
||||
.and_then(|d| d.get("lock_screen.background_dim"))
|
||||
@@ -82,9 +98,14 @@ impl Appearance {
|
||||
panel: palette.sidebar,
|
||||
text: palette.text,
|
||||
accent: palette.accent,
|
||||
on_accent: palette.on_accent,
|
||||
radius: palette.radius,
|
||||
font: ferese_theme::font(Some(&font)),
|
||||
wallpaper,
|
||||
wallpaper_path,
|
||||
wallpaper_blur: blur,
|
||||
wallpaper_revision: 0,
|
||||
reduce_transparency,
|
||||
avatar: account_picture(user),
|
||||
}
|
||||
}
|
||||
@@ -95,12 +116,14 @@ impl Appearance {
|
||||
|
||||
pub fn theme(&self) -> cosmic::Theme {
|
||||
ferese_theme::Palette {
|
||||
appearance: self.appearance,
|
||||
background: self.panel,
|
||||
sidebar: self.panel,
|
||||
card: self.panel,
|
||||
text: self.text,
|
||||
muted: self.text.scale_alpha(0.55),
|
||||
accent: self.accent,
|
||||
on_accent: self.on_accent,
|
||||
radius: self.radius,
|
||||
error: Color::from_rgb8(235, 98, 98),
|
||||
}
|
||||
@@ -166,12 +189,41 @@ mod tests {
|
||||
"##,
|
||||
)
|
||||
.unwrap();
|
||||
let appearance = Appearance::load("no-such-test-user", Some(&config));
|
||||
let document = Document::parse(&std::fs::read_to_string(&config).unwrap()).unwrap();
|
||||
let theme = ferese_config::theme::resolve(&document, directory.path(), jiff::Timestamp::now(), |_| {
|
||||
Err("unexpected file".into())
|
||||
})
|
||||
.unwrap()
|
||||
.theme;
|
||||
let appearance = Appearance::from_document("no-such-test-user", Some(document), &theme);
|
||||
assert_eq!(appearance.radius, 0.);
|
||||
assert_eq!(appearance.accent, Color::from_rgb8(255, 85, 0));
|
||||
assert_eq!(theme.requested_accent, "#FF5500");
|
||||
assert_eq!(
|
||||
appearance.accent,
|
||||
ferese_theme::parse_color(&theme.tokens.colors.accent).unwrap()
|
||||
);
|
||||
assert!(ferese_theme::contrast(appearance.accent.into(), appearance.on_accent.into()) >= 4.5);
|
||||
assert!(!appearance.show_clock && !appearance.show_date);
|
||||
assert_eq!(appearance.clock_format(), "%I:%M %p");
|
||||
assert_eq!(appearance.dim, 0.7);
|
||||
assert_eq!(appearance.theme().cosmic().corner_radii.radius_m, [0.; 4]);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_wallpaper(path: &std::path::Path, blur: f32) -> Result<image::Handle, String> {
|
||||
let (width, height) = ::image::image_dimensions(path).map_err(|e| e.to_string())?;
|
||||
if u64::from(width) * u64::from(height) * 4 > 256 * 1024 * 1024 {
|
||||
return Err("Lock wallpaper exceeds the decode limit".into());
|
||||
}
|
||||
let pixels = ::image::open(path).map_err(|e| e.to_string())?.into_rgba8();
|
||||
let pixels = if blur > 0. {
|
||||
::image::imageops::blur(&::image::imageops::thumbnail(&pixels, 640, 640), blur)
|
||||
} else {
|
||||
pixels
|
||||
};
|
||||
Ok(image::Handle::from_rgba(
|
||||
pixels.width(),
|
||||
pixels.height(),
|
||||
pixels.into_raw(),
|
||||
))
|
||||
}
|
||||
@@ -82,6 +82,8 @@ fn main() -> iced::Result {
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Message {
|
||||
ThemeChanged(ferese_config::theme::Snapshot),
|
||||
WallpaperLoaded(u64, Result<widget::image::Handle, String>),
|
||||
Event(Box<Event>),
|
||||
Input(String),
|
||||
Submit,
|
||||
@@ -188,6 +190,11 @@ impl cosmic::Application for Locker {
|
||||
|
||||
fn subscription(&self) -> Subscription<Message> {
|
||||
Subscription::batch([
|
||||
if self.preview {
|
||||
Subscription::none()
|
||||
} else {
|
||||
ferese_theme::service::subscription().map(Message::ThemeChanged)
|
||||
},
|
||||
event::listen_with(|event, _, _| match event {
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(
|
||||
wayland::Event::Output(..) | wayland::Event::SessionLock(..),
|
||||
@@ -202,6 +209,50 @@ impl cosmic::Application for Locker {
|
||||
|
||||
fn update(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::ThemeChanged(snapshot) => {
|
||||
let palette = ferese_theme::Palette::from_resolved(&snapshot.presented);
|
||||
self.appearance.appearance = snapshot.presented.appearance;
|
||||
self.appearance.panel = palette.sidebar;
|
||||
self.appearance.text = palette.text;
|
||||
self.appearance.accent = palette.accent;
|
||||
self.appearance.on_accent = palette.on_accent;
|
||||
self.appearance.radius = palette.radius;
|
||||
self.appearance.font = ferese_theme::font(Some(&snapshot.presented.tokens.typography.font_family));
|
||||
let theme = cosmic::command::set_theme(self.appearance.theme());
|
||||
let target = &snapshot.theme;
|
||||
let path = target
|
||||
.tokens
|
||||
.background
|
||||
.lock_path
|
||||
.as_ref()
|
||||
.or(target.tokens.background.path.as_ref())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ferese_config::default_wallpaper().into());
|
||||
let reduce = target.accessibility.reduce_transparency;
|
||||
if path != self.appearance.wallpaper_path || reduce != self.appearance.reduce_transparency {
|
||||
self.appearance.wallpaper_path = path.clone();
|
||||
self.appearance.reduce_transparency = reduce;
|
||||
self.appearance.wallpaper_revision = self.appearance.wallpaper_revision.wrapping_add(1);
|
||||
let revision = self.appearance.wallpaper_revision;
|
||||
let blur = if reduce { 0. } else { self.appearance.wallpaper_blur };
|
||||
let wallpaper = cosmic::task::future(async move {
|
||||
let result = tokio::task::spawn_blocking(move || appearance::load_wallpaper(&path, blur))
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()));
|
||||
Message::WallpaperLoaded(revision, result)
|
||||
});
|
||||
return Task::batch([theme, wallpaper]);
|
||||
}
|
||||
return theme;
|
||||
}
|
||||
Message::WallpaperLoaded(revision, result) => {
|
||||
if revision == self.appearance.wallpaper_revision {
|
||||
match result {
|
||||
Ok(image) => self.appearance.wallpaper = image,
|
||||
Err(error) => eprintln!("ferese-lock: retained wallpaper: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Event(box Event::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers))) => {
|
||||
self.caps_lock = modifiers.contains(iced::keyboard::Modifiers::CAPS_LOCK);
|
||||
}
|
||||
|
||||
@@ -13,33 +13,35 @@ use crate::PromptEvent;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Appearance {
|
||||
appearance: ferese_config::theme::Appearance,
|
||||
surface: Color,
|
||||
text: Color,
|
||||
muted: Color,
|
||||
accent: Color,
|
||||
on_accent: Color,
|
||||
radius: f32,
|
||||
font: cosmic::font::Font,
|
||||
}
|
||||
|
||||
impl Appearance {
|
||||
fn load() -> Self {
|
||||
let document = ferese_config::config_path()
|
||||
.and_then(|path| std::fs::read_to_string(path).ok())
|
||||
.and_then(|source| ferese_config::Document::parse(&source).ok());
|
||||
let palette = ferese_theme::Palette::from_document(document.as_ref()).flat();
|
||||
let family = document
|
||||
.as_ref()
|
||||
.and_then(|document| document.get("theme.typography.font_family"))
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("Inter");
|
||||
let snapshot = ferese_theme::service::current();
|
||||
Self::from_resolved(&snapshot.presented)
|
||||
}
|
||||
|
||||
fn from_resolved(theme: &ferese_config::theme::ResolvedTheme) -> Self {
|
||||
let palette = ferese_theme::Palette::from_resolved(theme).flat();
|
||||
let family = &theme.tokens.typography.font_family;
|
||||
Self {
|
||||
appearance: theme.appearance,
|
||||
surface: Color {
|
||||
a: ferese_theme::material_opacity(document.as_ref()),
|
||||
a: ferese_theme::service::opacity(theme),
|
||||
..palette.sidebar
|
||||
},
|
||||
text: palette.text,
|
||||
muted: palette.muted,
|
||||
accent: palette.accent,
|
||||
on_accent: palette.on_accent,
|
||||
radius: palette.radius,
|
||||
font: ferese_theme::font(Some(family)),
|
||||
}
|
||||
@@ -47,12 +49,14 @@ impl Appearance {
|
||||
|
||||
fn palette(&self) -> ferese_theme::Palette {
|
||||
ferese_theme::Palette {
|
||||
appearance: self.appearance,
|
||||
background: self.surface,
|
||||
sidebar: self.surface,
|
||||
card: self.surface,
|
||||
text: self.text,
|
||||
muted: self.muted,
|
||||
accent: self.accent,
|
||||
on_accent: self.on_accent,
|
||||
radius: self.radius,
|
||||
error: Color::from_rgb8(235, 98, 98),
|
||||
}
|
||||
@@ -65,6 +69,7 @@ impl Appearance {
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Message {
|
||||
ThemeChanged(ferese_config::theme::Snapshot),
|
||||
WindowOpened(cosmic::iced::window::Id),
|
||||
MaterialAttached(Result<ferese_theme::material::ModalMaterial, String>),
|
||||
Tick,
|
||||
@@ -224,6 +229,7 @@ impl cosmic::Application for Prompt {
|
||||
|
||||
fn subscription(&self) -> Subscription<Message> {
|
||||
Subscription::batch([
|
||||
ferese_theme::service::subscription().map(Message::ThemeChanged),
|
||||
cosmic::iced::time::every(Duration::from_millis(30)).map(|_| Message::Tick),
|
||||
cosmic::iced::event::listen_with(|event, _, id| match event {
|
||||
cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => {
|
||||
@@ -236,6 +242,10 @@ impl cosmic::Application for Prompt {
|
||||
|
||||
fn update(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::ThemeChanged(snapshot) => {
|
||||
self.appearance = Appearance::from_resolved(&snapshot.presented);
|
||||
return cosmic::command::set_theme(self.appearance.theme());
|
||||
}
|
||||
Message::WindowOpened(id) => {
|
||||
return cosmic::iced::window::run(id, ferese_theme::material::ModalMaterial::attach)
|
||||
.map(|result| cosmic::Action::App(Message::MaterialAttached(result)));
|
||||
|
||||
+539
-107
@@ -9,8 +9,9 @@ use std::path::PathBuf;
|
||||
|
||||
use cosmic::app::{Core, Settings, Task};
|
||||
use cosmic::iced::{Alignment, Length};
|
||||
use cosmic::widget::{button, column, container, row, scrollable, slider, text_input, toggler};
|
||||
use cosmic::widget::{button, column, container, row, scrollable, slider, text_input};
|
||||
use cosmic::{ApplicationExt, Element, widget};
|
||||
use ferese_theme::gallery;
|
||||
use schema::{Field, Kind, Page};
|
||||
use store::{Edit, Snapshot, set};
|
||||
|
||||
@@ -41,6 +42,7 @@ fn main() -> cosmic::iced::Result {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Message {
|
||||
ThemeChanged(ferese_config::theme::Snapshot),
|
||||
DragWindow,
|
||||
Page(Page),
|
||||
Search(String),
|
||||
@@ -54,7 +56,17 @@ enum Message {
|
||||
Reload,
|
||||
ExternalConfig(Result<Snapshot, String>),
|
||||
Undo,
|
||||
Preset(usize),
|
||||
Family(String, Option<ferese_config::theme::Appearance>),
|
||||
SplitThemes(bool),
|
||||
AutoAppearance(bool),
|
||||
AutoDetails(bool),
|
||||
AdvancedTheme(bool),
|
||||
ThemeFileTarget(usize),
|
||||
PickThemeFile(&'static str),
|
||||
ThemeFilePicked(&'static str, Result<Option<String>, String>),
|
||||
ImportTheme,
|
||||
ThemeImported(Result<Option<(String, String)>, String>),
|
||||
ExpireUndo(u64),
|
||||
PickWallpaper,
|
||||
PreviewLock,
|
||||
LockPreviewStarted(Result<(), String>),
|
||||
@@ -88,6 +100,11 @@ struct App {
|
||||
new_command: String,
|
||||
font: cosmic::font::Font,
|
||||
native_palette: visuals::Palette,
|
||||
resolved: ferese_config::theme::Snapshot,
|
||||
undo_revision: u64,
|
||||
auto_details: bool,
|
||||
advanced_theme: bool,
|
||||
theme_file_target: usize,
|
||||
thumbnail: Option<widget::image::Handle>,
|
||||
thumbnail_path: String,
|
||||
thumbnail_loading: bool,
|
||||
@@ -125,8 +142,9 @@ impl cosmic::Application for App {
|
||||
.into();
|
||||
let error = initial.as_ref().err().cloned();
|
||||
let current = initial.unwrap_or_else(|_| Snapshot::parse(String::new()).unwrap());
|
||||
let font = visuals::configured_font(¤t);
|
||||
let native_palette = visuals::Palette::from_document(Some(¤t.doc));
|
||||
let resolved = ferese_theme::service::current();
|
||||
let font = ferese_theme::font(Some(&resolved.presented.tokens.typography.font_family));
|
||||
let native_palette = visuals::Palette::from_resolved(&resolved.presented);
|
||||
let mut app = Self {
|
||||
note_editors: HashMap::new(),
|
||||
core,
|
||||
@@ -146,6 +164,11 @@ impl cosmic::Application for App {
|
||||
new_command: String::new(),
|
||||
font,
|
||||
native_palette,
|
||||
resolved,
|
||||
undo_revision: 0,
|
||||
auto_details: false,
|
||||
advanced_theme: false,
|
||||
theme_file_target: 0,
|
||||
thumbnail: None,
|
||||
thumbnail_path: String::new(),
|
||||
thumbnail_loading: false,
|
||||
@@ -163,6 +186,11 @@ impl cosmic::Application for App {
|
||||
|
||||
fn update(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::ThemeChanged(snapshot) => {
|
||||
self.resolved = snapshot;
|
||||
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family));
|
||||
return self.update_theme();
|
||||
}
|
||||
Message::NoteAction(id, action) => {
|
||||
if let Some(editor) = self.note_editors.get_mut(&id) {
|
||||
let edited = action.is_edit();
|
||||
@@ -232,7 +260,7 @@ impl cosmic::Application for App {
|
||||
self.current = snapshot.clone();
|
||||
self.draft = snapshot;
|
||||
self.sync_notes();
|
||||
self.font = visuals::configured_font(&self.current);
|
||||
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family));
|
||||
self.undo = None;
|
||||
self.error = None;
|
||||
self.status = "Updated from your config".into();
|
||||
@@ -319,7 +347,7 @@ impl cosmic::Application for App {
|
||||
Ok((snapshot, live)) => {
|
||||
self.undo = Some(std::mem::take(&mut self.saving_previous));
|
||||
self.current = snapshot;
|
||||
self.font = visuals::configured_font(&self.current);
|
||||
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family));
|
||||
self.draft = self.current.clone();
|
||||
|
||||
for edit in &self.pending {
|
||||
@@ -338,7 +366,13 @@ impl cosmic::Application for App {
|
||||
}
|
||||
.into();
|
||||
|
||||
return Task::batch([self.update_theme(), self.flush(), self.load_thumbnail()]);
|
||||
self.undo_revision = self.undo_revision.wrapping_add(1);
|
||||
let revision = self.undo_revision;
|
||||
let expire = cosmic::task::future(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
Message::ExpireUndo(revision)
|
||||
});
|
||||
return Task::batch([self.update_theme(), self.flush(), self.load_thumbnail(), expire]);
|
||||
}
|
||||
Err(error) => {
|
||||
self.error = Some(error);
|
||||
@@ -354,7 +388,7 @@ impl cosmic::Application for App {
|
||||
self.draft = snapshot;
|
||||
self.note_editors.clear();
|
||||
self.sync_notes();
|
||||
self.font = visuals::configured_font(&self.current);
|
||||
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family));
|
||||
self.pending.clear();
|
||||
self.inputs.clear();
|
||||
self.ranges.clear();
|
||||
@@ -371,58 +405,101 @@ impl cosmic::Application for App {
|
||||
return self.start_save(source);
|
||||
}
|
||||
}
|
||||
Message::Preset(index) => {
|
||||
for edit in visuals::preset(index) {
|
||||
if let Err(error) = self.draft.edit(&edit) {
|
||||
self.error = Some(error);
|
||||
return Task::none();
|
||||
Message::Family(id, appearance) => {
|
||||
let edits = visuals::choose_family(&id, appearance);
|
||||
let focus = cosmic::iced::advanced::widget::operate(
|
||||
cosmic::iced::advanced::widget::operation::focusable::focus(gallery_id(&id, appearance)),
|
||||
);
|
||||
return Task::batch([self.edit_many(edits), focus.map(cosmic::Action::App)]);
|
||||
}
|
||||
|
||||
self.pending.push(edit);
|
||||
Message::AutoDetails(enabled) => self.auto_details = enabled,
|
||||
Message::AdvancedTheme(enabled) => self.advanced_theme = enabled,
|
||||
Message::AutoAppearance(enabled) => {
|
||||
let mode = if enabled {
|
||||
"auto"
|
||||
} else if self.resolved.theme.appearance == ferese_config::theme::Appearance::Light {
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
};
|
||||
return self.change(set("theme.mode", mode));
|
||||
}
|
||||
return self.flush();
|
||||
}
|
||||
Message::PickWallpaper => {
|
||||
return cosmic::task::future(async {
|
||||
let (send, receive) = cosmic::iced::futures::channel::oneshot::channel();
|
||||
// Native GTK file chooser, independent of the desktop portal.
|
||||
// Waiting for user input must never block the UI executor.
|
||||
std::thread::spawn(move || {
|
||||
let result = std::process::Command::new("zenity")
|
||||
.args([
|
||||
"--file-selection",
|
||||
"--title=Choose a wallpaper",
|
||||
"--file-filter=Images | *.png *.jpg *.jpeg *.webp *.PNG *.JPG *.JPEG *.WEBP",
|
||||
"--file-filter=All files | *",
|
||||
])
|
||||
.output()
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"Could not open the file chooser: {error}. Install zenity or enter an image path."
|
||||
)
|
||||
Message::ThemeFileTarget(index) => self.theme_file_target = index.min(2),
|
||||
Message::PickThemeFile(path) => {
|
||||
return cosmic::task::future(async move {
|
||||
let result = tokio::task::spawn_blocking(|| {
|
||||
store::pick_file("Choose theme overrides", "KDL themes | *.kdl")
|
||||
})
|
||||
.and_then(|output| {
|
||||
if output.status.code() == Some(1) {
|
||||
return Ok(None); // User cancelled.
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()));
|
||||
Message::ThemeFilePicked(path, result)
|
||||
});
|
||||
}
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"File chooser failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
Message::ThemeFilePicked(path, Ok(Some(file))) => return self.change(set(path, file)),
|
||||
Message::ThemeFilePicked(_, Err(error)) => self.error = Some(error),
|
||||
Message::SplitThemes(enabled) => {
|
||||
return self.edit_many(visuals::toggle_split(
|
||||
&self.draft,
|
||||
enabled,
|
||||
self.resolved.theme.appearance,
|
||||
));
|
||||
}
|
||||
let path = String::from_utf8(output.stdout)
|
||||
.map_err(|_| "The image path is not valid UTF-8.".to_owned())?;
|
||||
let path = path.trim_end_matches(['\r', '\n']);
|
||||
Ok((!path.is_empty()).then(|| path.to_owned()))
|
||||
});
|
||||
let _ = send.send(result);
|
||||
});
|
||||
Message::WallpaperPicked(
|
||||
receive
|
||||
Message::ImportTheme => {
|
||||
let source = self.draft.source.clone();
|
||||
let directory = self.path.parent().map(ToOwned::to_owned);
|
||||
return cosmic::task::future(async move {
|
||||
let result = tokio::task::spawn_blocking(move || -> Result<_, String> {
|
||||
let Some(path) = store::pick_file("Import theme", "KDL themes | *.kdl")? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut snapshot = Snapshot::parse(source)?;
|
||||
let stem = PathBuf::from(&path)
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let stem: String = stem
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
let mut id = format!("custom-{stem}");
|
||||
let mut suffix = 2;
|
||||
while snapshot.item(&format!("theme.custom_themes.{id}")).is_some() {
|
||||
id = format!("custom-{stem}-{suffix}");
|
||||
suffix += 1;
|
||||
}
|
||||
snapshot.edit(&set(&format!("theme.custom_themes.{id}.file"), path.as_str()))?;
|
||||
let mut connection = ferese_ipc::theme::Connection::connect().map_err(|e| e.to_string())?;
|
||||
let value = connection.call(
|
||||
"theme-preview",
|
||||
serde_json::json!({"source": snapshot.source, "directory": directory}),
|
||||
)?;
|
||||
let _: Vec<ferese_config::families::Family> =
|
||||
serde_json::from_value(value["families"].clone()).map_err(|e| e.to_string())?;
|
||||
Ok(Some((id, path)))
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| Err("File chooser worker stopped.".into())),
|
||||
.unwrap_or_else(|e| Err(e.to_string()));
|
||||
Message::ThemeImported(result)
|
||||
});
|
||||
}
|
||||
Message::ThemeImported(Ok(Some((id, path)))) => {
|
||||
return self.change(set(&format!("theme.custom_themes.{id}.file"), path));
|
||||
}
|
||||
Message::ThemeImported(Err(error)) => self.error = Some(error),
|
||||
Message::ExpireUndo(revision) if revision == self.undo_revision => self.undo = None,
|
||||
Message::PickWallpaper => {
|
||||
return cosmic::task::future(async {
|
||||
let result = tokio::task::spawn_blocking(|| {
|
||||
store::pick_file(
|
||||
"Choose a wallpaper",
|
||||
"Images | *.png *.jpg *.jpeg *.webp *.PNG *.JPG *.JPEG *.WEBP",
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()));
|
||||
Message::WallpaperPicked(result)
|
||||
});
|
||||
}
|
||||
Message::WallpaperPicked(Ok(Some(path))) => {
|
||||
@@ -495,11 +572,14 @@ impl cosmic::Application for App {
|
||||
}
|
||||
|
||||
fn subscription(&self) -> cosmic::iced::Subscription<Message> {
|
||||
cosmic::iced::Subscription::run_with(self.path.clone(), watch::changes)
|
||||
cosmic::iced::Subscription::batch([
|
||||
cosmic::iced::Subscription::run_with(self.path.clone(), watch::changes),
|
||||
ferese_theme::service::subscription().map(Message::ThemeChanged),
|
||||
])
|
||||
}
|
||||
|
||||
fn view(&self) -> Element<'_, Message> {
|
||||
let palette = visuals::Palette::from_document(Some(&self.draft.doc));
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let mut sidebar = column([])
|
||||
.spacing(3)
|
||||
.push(
|
||||
@@ -550,7 +630,7 @@ impl cosmic::Application for App {
|
||||
.height(Length::Fill)
|
||||
.padding([20, 10])
|
||||
.class(visuals::surface(palette.sidebar, 0.));
|
||||
let heading = row([]).align_y(Alignment::Center).spacing(20).push(
|
||||
let mut heading = row([]).align_y(Alignment::Center).spacing(20).push(
|
||||
column([])
|
||||
.spacing(5)
|
||||
.push(self.label(
|
||||
@@ -574,6 +654,56 @@ impl cosmic::Application for App {
|
||||
)
|
||||
.width(Length::Fill),
|
||||
);
|
||||
if self.page == Page::Appearance && self.search.is_empty() {
|
||||
let automatic = self.draft.string("theme.mode", "dark") == "auto";
|
||||
heading = heading.push(
|
||||
row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(self.label("Auto", 12.))
|
||||
.push(
|
||||
ferese_theme::controls::switch(automatic, palette)
|
||||
.name(if automatic {
|
||||
"Auto appearance: on"
|
||||
} else {
|
||||
"Auto appearance: off"
|
||||
})
|
||||
.on_press(Message::AutoAppearance(!automatic)),
|
||||
),
|
||||
);
|
||||
let scheduled = self.draft.string("theme.schedule.source", "schedule") == "schedule";
|
||||
let source = if scheduled { "Schedule" } else { "System" };
|
||||
let label = if automatic {
|
||||
format!(
|
||||
"{source} · {}",
|
||||
if self.resolved.theme.appearance == ferese_config::theme::Appearance::Light {
|
||||
"Light"
|
||||
} else {
|
||||
"Dark"
|
||||
},
|
||||
)
|
||||
} else {
|
||||
source.to_owned()
|
||||
};
|
||||
let explanation = if scheduled {
|
||||
format!(
|
||||
"Auto uses Light from {} and Dark from {} ({})",
|
||||
self.draft.string("theme.schedule.light_at", "07:00"),
|
||||
self.draft.string("theme.schedule.dark_at", "19:00"),
|
||||
self.draft.string("theme.schedule.timezone", "system"),
|
||||
)
|
||||
} else {
|
||||
"Auto follows the GTK/GNOME appearance preference".to_owned()
|
||||
};
|
||||
heading = heading.push(widget::tooltip(
|
||||
ferese_theme::controls::text_button(label, self.font, palette, self.auto_details)
|
||||
.name("Automatic appearance settings")
|
||||
.on_press(Message::AutoDetails(!self.auto_details)),
|
||||
self.label(explanation, 12.),
|
||||
widget::tooltip::Position::Bottom,
|
||||
));
|
||||
}
|
||||
|
||||
let mut body = column([]).spacing(12);
|
||||
|
||||
if !self.search.is_empty() {
|
||||
@@ -596,54 +726,26 @@ impl cosmic::Application for App {
|
||||
}
|
||||
} else {
|
||||
if matches!(self.page, Page::Bar | Page::Windows) {
|
||||
body = body.push(visuals::preview(&self.draft));
|
||||
body = body.push(visuals::preview_resolved(&self.draft, &self.resolved.presented));
|
||||
}
|
||||
if self.page == Page::Appearance {
|
||||
let split = visuals::split(&self.draft);
|
||||
if split {
|
||||
body = body.push(self.theme_gallery(Some(ferese_config::theme::Appearance::Light)));
|
||||
body = body.push(self.theme_gallery(Some(ferese_config::theme::Appearance::Dark)));
|
||||
} else {
|
||||
body = body.push(self.theme_gallery(None));
|
||||
}
|
||||
body = body.push(
|
||||
column([])
|
||||
.spacing(4)
|
||||
.push(self.label("Theme", 15.))
|
||||
.push(self.note("Choose a look for your desktop.")),
|
||||
widget::checkbox(split)
|
||||
.label("Use different themes for light and dark")
|
||||
.text_size(12)
|
||||
.font(self.font)
|
||||
.on_toggle(Message::SplitThemes),
|
||||
);
|
||||
let mut presets = column([]).spacing(10);
|
||||
for (row_index, choices) in visuals::PRESETS.chunks(3).enumerate() {
|
||||
let mut tiles = row([]).spacing(10);
|
||||
for (column_index, preset) in choices.iter().enumerate() {
|
||||
let index = row_index * 3 + column_index;
|
||||
let selected = visuals::preset_selected(&self.draft, index);
|
||||
let indicator: Element<'_, Message> = if selected {
|
||||
visuals::action_icon("M5 12l4 4L19 6", palette.accent).into()
|
||||
} else {
|
||||
widget::Space::new().width(16).height(16).into()
|
||||
};
|
||||
let tile = button::custom(
|
||||
column([])
|
||||
.spacing(8)
|
||||
.push(visuals::preset_preview(index))
|
||||
.push(
|
||||
row([])
|
||||
.spacing(6)
|
||||
.align_y(Alignment::Center)
|
||||
.push(self.label(preset.name, 12.).width(Length::Fill))
|
||||
.push(indicator),
|
||||
)
|
||||
.push(
|
||||
self.label(preset.description, 10.)
|
||||
.class(cosmic::theme::Text::Color(palette.muted)),
|
||||
),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding(10)
|
||||
.class(visuals::button_style(palette, selected));
|
||||
tiles = tiles.push(if self.saving {
|
||||
tile
|
||||
} else {
|
||||
tile.on_press(Message::Preset(index))
|
||||
});
|
||||
if let Some(note) = &self.resolved.fallback_note {
|
||||
body = body.push(self.note(note));
|
||||
}
|
||||
presets = presets.push(tiles);
|
||||
}
|
||||
body = body.push(presets);
|
||||
}
|
||||
|
||||
if self.page == Page::Wallpaper {
|
||||
@@ -669,9 +771,51 @@ impl cosmic::Application for App {
|
||||
|
||||
if !fields.is_empty() {
|
||||
let mut group = column([]).spacing(1);
|
||||
if self.page == Page::Appearance {
|
||||
if self.auto_details {
|
||||
group = group.push(self.auto_controls());
|
||||
}
|
||||
}
|
||||
for field in fields {
|
||||
if self.page == Page::Appearance
|
||||
&& (field.path == "theme.mode"
|
||||
|| field.path.starts_with("theme.schedule.")
|
||||
|| field.path.ends_with(".file"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
group = group.push(self.field(field));
|
||||
}
|
||||
if self.page == Page::Appearance {
|
||||
group = group.push(
|
||||
button::custom(
|
||||
row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(self.label("Advanced customization", 13.))
|
||||
.push(visuals::action_icon(
|
||||
if self.advanced_theme {
|
||||
"M6 15l6-6 6 6"
|
||||
} else {
|
||||
"M6 9l6 6 6-6"
|
||||
},
|
||||
palette.muted,
|
||||
)),
|
||||
)
|
||||
.name(if self.advanced_theme {
|
||||
"Collapse advanced customization"
|
||||
} else {
|
||||
"Expand advanced customization"
|
||||
})
|
||||
.padding(12)
|
||||
.width(Length::Fill)
|
||||
.class(visuals::button_style(palette, false))
|
||||
.on_press(Message::AdvancedTheme(!self.advanced_theme)),
|
||||
);
|
||||
if self.advanced_theme {
|
||||
group = group.push(self.theme_file_picker());
|
||||
}
|
||||
}
|
||||
body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.)));
|
||||
}
|
||||
|
||||
@@ -963,12 +1107,12 @@ impl cosmic::Application for App {
|
||||
|
||||
impl App {
|
||||
fn update_theme(&mut self) -> Task<Message> {
|
||||
let palette = visuals::Palette::from_document(Some(&self.draft.doc));
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
if palette == self.native_palette {
|
||||
return Task::none();
|
||||
}
|
||||
self.native_palette = palette;
|
||||
cosmic::command::set_theme(visuals::native_theme(Some(&self.draft)))
|
||||
cosmic::command::set_theme(palette.native_theme())
|
||||
}
|
||||
|
||||
fn note_index(&self, id: &str) -> Option<usize> {
|
||||
@@ -1057,14 +1201,258 @@ impl App {
|
||||
}
|
||||
|
||||
fn note(&self, text: &str) -> Element<'static, Message> {
|
||||
let palette = visuals::Palette::from_document(Some(&self.draft.doc));
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
self.label(text.to_owned(), 12.)
|
||||
.class(cosmic::theme::Text::Color(palette.muted))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn field(&self, field: Field) -> Element<'static, Message> {
|
||||
let palette = visuals::Palette::from_document(Some(&self.draft.doc));
|
||||
fn theme_file_picker(&self) -> Element<'static, Message> {
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let path = ["theme.file", "theme.light.file", "theme.dark.file"][self.theme_file_target];
|
||||
let file = self.draft.string(path, "");
|
||||
let filename = if file.is_empty() {
|
||||
"No override file".to_owned()
|
||||
} else {
|
||||
PathBuf::from(&file)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
};
|
||||
let labels = column([])
|
||||
.spacing(3)
|
||||
.width(Length::Fill)
|
||||
.push(self.label("Theme overrides", 13.))
|
||||
.push(widget::tooltip(
|
||||
self.label(filename, 11.)
|
||||
.class(cosmic::theme::Text::Color(palette.muted)),
|
||||
self.label(
|
||||
if file.is_empty() {
|
||||
"Choose a partial KDL theme file".to_owned()
|
||||
} else {
|
||||
file.clone()
|
||||
},
|
||||
11.,
|
||||
),
|
||||
widget::tooltip::Position::Bottom,
|
||||
));
|
||||
let mut scopes = row([]).spacing(2);
|
||||
for (index, label) in ["Shared", "Light", "Dark"].into_iter().enumerate() {
|
||||
scopes = scopes.push(
|
||||
button::custom(self.label(label, 12.))
|
||||
.name(format!("Theme file: {label}"))
|
||||
.padding([6, 8])
|
||||
.class(visuals::button_style(palette, index == self.theme_file_target))
|
||||
.on_press(Message::ThemeFileTarget(index)),
|
||||
);
|
||||
}
|
||||
let mut controls = row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(
|
||||
container(scopes)
|
||||
.padding(3)
|
||||
.class(visuals::surface(palette.sidebar, 10.)),
|
||||
)
|
||||
.push(
|
||||
ferese_theme::controls::text_button(
|
||||
if file.is_empty() { "Choose…" } else { "Replace…" },
|
||||
self.font,
|
||||
palette,
|
||||
false,
|
||||
)
|
||||
.on_press(Message::PickThemeFile(path)),
|
||||
);
|
||||
if !file.is_empty() {
|
||||
controls = controls.push(widget::tooltip(
|
||||
button::custom(visuals::action_icon("M6 6l12 12M6 18L18 6", palette.muted))
|
||||
.padding(5)
|
||||
.on_press(Message::Change(Edit::Unset(path.into()))),
|
||||
self.label("Remove override", 11.),
|
||||
widget::tooltip::Position::Bottom,
|
||||
));
|
||||
}
|
||||
container(
|
||||
row([])
|
||||
.spacing(16)
|
||||
.align_y(Alignment::Center)
|
||||
.push(labels)
|
||||
.push(controls),
|
||||
)
|
||||
.padding([12, 12])
|
||||
.width(Length::Fill)
|
||||
.class(visuals::surface(palette.card, 0.))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn auto_controls(&self) -> Element<'static, Message> {
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let scheduled = self.draft.string("theme.schedule.source", "schedule") == "schedule";
|
||||
let mut sources = row([]).spacing(4);
|
||||
for (source, label) in [("system", "System"), ("schedule", "Schedule")] {
|
||||
sources = sources.push(
|
||||
ferese_theme::controls::text_button(label, self.font, palette, scheduled == (source == "schedule"))
|
||||
.name(format!("Auto source: {label}"))
|
||||
.on_press(Message::Change(set("theme.schedule.source", source))),
|
||||
);
|
||||
}
|
||||
let description = if scheduled {
|
||||
"Switch between light and dark at set times."
|
||||
} else {
|
||||
"Follow the GTK/GNOME appearance preference."
|
||||
};
|
||||
let mut options = column([]).spacing(8).push(
|
||||
row([])
|
||||
.spacing(12)
|
||||
.align_y(Alignment::Center)
|
||||
.push(
|
||||
column([])
|
||||
.spacing(3)
|
||||
.width(Length::Fill)
|
||||
.push(self.label("Automatic appearance", 13.))
|
||||
.push(self.note(description)),
|
||||
)
|
||||
.push(sources),
|
||||
);
|
||||
if scheduled {
|
||||
let mut times = row([]).spacing(16).align_y(Alignment::Center);
|
||||
for field in schema::fields(Page::Appearance)
|
||||
.into_iter()
|
||||
.filter(|field| field.path.starts_with("theme.schedule."))
|
||||
{
|
||||
let path = field.path.clone();
|
||||
let Kind::Text { default, .. } = &field.kind else {
|
||||
continue;
|
||||
};
|
||||
let value = self
|
||||
.inputs
|
||||
.get(&path)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| self.draft.string(&path, default));
|
||||
let submit = field.clone();
|
||||
let input = text_input(*default, value)
|
||||
.font(self.font)
|
||||
.size(12)
|
||||
.style(visuals::input_style(palette))
|
||||
.on_input(move |value| Message::Draft(path.clone(), value))
|
||||
.on_submit(move |_| Message::Commit(submit.clone()))
|
||||
.on_unfocus(Message::Commit(field.clone()))
|
||||
.width(if field.path.ends_with("timezone") { 150 } else { 75 });
|
||||
times = times.push(
|
||||
row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(self.label(field.label, 12.))
|
||||
.push(input),
|
||||
);
|
||||
}
|
||||
options = options.push(times);
|
||||
}
|
||||
container(options)
|
||||
.padding(12)
|
||||
.width(Length::Fill)
|
||||
.class(visuals::surface(palette.card, 0.))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn theme_gallery(&self, appearance: Option<ferese_config::theme::Appearance>) -> Element<'static, Message> {
|
||||
use ferese_config::theme::Appearance::{Dark, Light};
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let selected_id = visuals::family_selection(&self.draft, appearance);
|
||||
let choices = self.resolved.families.clone();
|
||||
let ids: Vec<_> = choices.iter().map(|family| family.id.clone()).collect();
|
||||
let mut heading = row([]).align_y(Alignment::Center).spacing(12).push(
|
||||
self.label(
|
||||
match appearance {
|
||||
Some(Light) => "Light theme",
|
||||
Some(Dark) => "Dark theme",
|
||||
None => "Theme",
|
||||
},
|
||||
15.,
|
||||
)
|
||||
.width(Length::Fill),
|
||||
);
|
||||
if appearance != Some(Dark) {
|
||||
if self.undo.is_some() {
|
||||
heading = heading.push(
|
||||
ferese_theme::controls::text_button("Undo", self.font, palette, false)
|
||||
.on_press_maybe((!self.saving).then_some(Message::Undo)),
|
||||
);
|
||||
}
|
||||
heading = heading.push(
|
||||
ferese_theme::controls::text_button("Import…", self.font, palette, false)
|
||||
.on_press(Message::ImportTheme),
|
||||
);
|
||||
}
|
||||
let mut gallery = column([]).spacing(10).push(heading);
|
||||
for (chunk, families) in choices.chunks(3).enumerate() {
|
||||
let mut tiles = row([]).spacing(12);
|
||||
for (offset, family) in families.iter().enumerate() {
|
||||
let selected = family.id == selected_id;
|
||||
let index = chunk * 3 + offset;
|
||||
let id = family.id.clone();
|
||||
let active = selected
|
||||
.then_some(self.resolved.theme.appearance)
|
||||
.filter(|a| appearance.is_none_or(|variant| variant == *a));
|
||||
let ids = ids.clone();
|
||||
let tile_id = gallery_id(&id, appearance);
|
||||
tiles = tiles.push(gallery::tile(
|
||||
family,
|
||||
appearance,
|
||||
active,
|
||||
selected,
|
||||
palette,
|
||||
self.font,
|
||||
tile_id,
|
||||
Message::Family(id, appearance),
|
||||
move |key| {
|
||||
gallery::neighbor(index, ids.len(), key)
|
||||
.map(|index| Message::Family(ids[index].clone(), appearance))
|
||||
},
|
||||
));
|
||||
}
|
||||
for _ in families.len()..3 {
|
||||
tiles = tiles.push(widget::Space::new().width(Length::Fill));
|
||||
}
|
||||
gallery = gallery.push(tiles);
|
||||
}
|
||||
gallery::group(
|
||||
container(gallery).width(Length::Fill).max_width(640),
|
||||
cosmic::iced::advanced::widget::Id::new(format!("theme-gallery-{appearance:?}")),
|
||||
match appearance {
|
||||
Some(Light) => "Light theme",
|
||||
Some(Dark) => "Dark theme",
|
||||
None => "Theme",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn edit_many(&mut self, edits: Vec<Edit>) -> Task<Message> {
|
||||
for edit in edits {
|
||||
if let Err(error) = self.draft.edit(&edit) {
|
||||
self.error = Some(error);
|
||||
return Task::none();
|
||||
}
|
||||
self.pending.push(edit);
|
||||
}
|
||||
self.flush()
|
||||
}
|
||||
|
||||
fn field(&self, mut field: Field) -> Element<'static, Message> {
|
||||
if field.path == "theme.material.opacity" {
|
||||
let appearance = self.resolved.theme.appearance;
|
||||
let name = match appearance {
|
||||
ferese_config::theme::Appearance::Light => "light",
|
||||
ferese_config::theme::Appearance::Dark => "dark",
|
||||
};
|
||||
field.path = format!("theme.{name}.material.opacity");
|
||||
field.description = format!("Opacity for the current {name} appearance. Text and icons stay opaque.");
|
||||
if let Kind::Range { default, .. } = &mut field.kind {
|
||||
*default = self.resolved.theme.tokens.material.opacity;
|
||||
}
|
||||
}
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let mut labels = column([]).spacing(3).push(self.label(field.label.clone(), 13.));
|
||||
if !field.description.is_empty() {
|
||||
labels = labels.push(
|
||||
@@ -1120,10 +1508,13 @@ impl App {
|
||||
)
|
||||
.into()
|
||||
}
|
||||
Kind::Toggle(default) => toggler(self.draft.boolean(&path, default))
|
||||
.size(22)
|
||||
.on_toggle(move |value| Message::Change(set(&path, value)))
|
||||
.into(),
|
||||
Kind::Toggle(default) => {
|
||||
let enabled = self.draft.boolean(&path, default);
|
||||
ferese_theme::controls::switch(enabled, palette)
|
||||
.name(format!("{}: {}", field.label, if enabled { "on" } else { "off" }))
|
||||
.on_press(Message::Change(set(&path, !enabled)))
|
||||
.into()
|
||||
}
|
||||
Kind::Range {
|
||||
default,
|
||||
min,
|
||||
@@ -1149,6 +1540,7 @@ impl App {
|
||||
.push(
|
||||
slider(min..=max, value, move |value| Message::Range(field.clone(), value))
|
||||
.step(step)
|
||||
.class(ferese_theme::menus::slider(palette.accent, 1.))
|
||||
.on_release(Message::Release(release))
|
||||
.width(145),
|
||||
)
|
||||
@@ -1277,6 +1669,10 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn gallery_id(id: &str, appearance: Option<ferese_config::theme::Appearance>) -> cosmic::iced::advanced::widget::Id {
|
||||
cosmic::iced::advanced::widget::Id::new(format!("theme-{appearance:?}-{id}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use cosmic::Application;
|
||||
@@ -1293,6 +1689,42 @@ mod tests {
|
||||
.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_auto_keeps_the_effective_appearance() {
|
||||
for appearance in [
|
||||
ferese_config::theme::Appearance::Light,
|
||||
ferese_config::theme::Appearance::Dark,
|
||||
] {
|
||||
let mut app = app();
|
||||
app.resolved.theme.appearance = appearance;
|
||||
let _ = app.update(Message::AutoAppearance(false));
|
||||
assert_eq!(
|
||||
app.draft.string("theme.mode", ""),
|
||||
if appearance == ferese_config::theme::Appearance::Light {
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
}
|
||||
);
|
||||
let _ = app.update(Message::AutoAppearance(true));
|
||||
assert_eq!(app.draft.string("theme.mode", ""), "auto");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_picker_applies_to_the_scope_it_was_opened_for_and_cancel_keeps_it() {
|
||||
let mut app = app();
|
||||
let _ = app.update(Message::ThemeFileTarget(2));
|
||||
let _ = app.update(Message::ThemeFilePicked(
|
||||
"theme.light.file",
|
||||
Ok(Some("/tmp/light.kdl".into())),
|
||||
));
|
||||
assert_eq!(app.draft.string("theme.light.file", ""), "/tmp/light.kdl");
|
||||
assert!(app.draft.item("theme.dark.file").is_none());
|
||||
let _ = app.update(Message::ThemeFilePicked("theme.light.file", Ok(None)));
|
||||
assert_eq!(app.draft.string("theme.light.file", ""), "/tmp/light.kdl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_save_only_the_latest_revision_and_preserve_multiline_text() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum Page {
|
||||
Appearance,
|
||||
Accessibility,
|
||||
Wallpaper,
|
||||
Desktop,
|
||||
Bar,
|
||||
@@ -15,8 +16,9 @@ pub enum Page {
|
||||
}
|
||||
|
||||
impl Page {
|
||||
pub const ALL: [Self; 12] = [
|
||||
pub const ALL: [Self; 13] = [
|
||||
Self::Appearance,
|
||||
Self::Accessibility,
|
||||
Self::Wallpaper,
|
||||
Self::Desktop,
|
||||
Self::Bar,
|
||||
@@ -33,6 +35,7 @@ impl Page {
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
Self::Appearance => "Appearance",
|
||||
Self::Accessibility => "Accessibility",
|
||||
Self::Wallpaper => "Wallpaper",
|
||||
Self::Desktop => "Desktop widgets",
|
||||
Self::Bar => "Menu bar",
|
||||
@@ -50,6 +53,7 @@ impl Page {
|
||||
pub fn subtitle(self) -> &'static str {
|
||||
match self {
|
||||
Self::Appearance => "A desktop that feels like yours.",
|
||||
Self::Accessibility => "Make the desktop easier to see and use.",
|
||||
Self::Wallpaper => "Set the scene for your workspace.",
|
||||
Self::Desktop => "A clock that feels at home on your wallpaper.",
|
||||
Self::Bar => "Everything you need, within reach.",
|
||||
@@ -66,6 +70,7 @@ impl Page {
|
||||
|
||||
pub fn icon(self) -> &'static str {
|
||||
match self {
|
||||
Self::Accessibility => "M12 3a2 2 0 1 0 0 4a2 2 0 1 0 0-4 M4 9h16 M12 9v6 M12 15l-5 6 M12 15l5 6",
|
||||
Self::Appearance => {
|
||||
"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"
|
||||
}
|
||||
@@ -178,7 +183,7 @@ pub fn text(
|
||||
Field::new(path, label, description, Kind::Text { default, argv: false })
|
||||
}
|
||||
|
||||
fn choice(
|
||||
pub fn choice(
|
||||
path: &str,
|
||||
label: &str,
|
||||
description: &str,
|
||||
@@ -591,7 +596,70 @@ pub fn fields(page: Page) -> Vec<Field> {
|
||||
false,
|
||||
),
|
||||
],
|
||||
Page::Accessibility => vec![
|
||||
toggle(
|
||||
"theme.accessibility.increase_contrast",
|
||||
"Increase contrast",
|
||||
"Stronger text and borders.",
|
||||
false,
|
||||
),
|
||||
toggle(
|
||||
"theme.accessibility.reduce_transparency",
|
||||
"Reduce transparency",
|
||||
"Solid surfaces; background blur is disabled.",
|
||||
false,
|
||||
),
|
||||
toggle(
|
||||
"animations.reduced_motion",
|
||||
"Reduce motion",
|
||||
"Use immediate changes in place of animations.",
|
||||
false,
|
||||
),
|
||||
],
|
||||
Page::Appearance => vec![
|
||||
choice(
|
||||
"theme.mode",
|
||||
"Appearance",
|
||||
"Choose a fixed appearance or let Auto follow a source.",
|
||||
"dark",
|
||||
&[("light", "Light"), ("dark", "Dark"), ("auto", "Auto")],
|
||||
),
|
||||
text(
|
||||
"theme.file",
|
||||
"Shared theme file",
|
||||
"Optional KDL overrides. Relative paths start in your config folder.",
|
||||
"",
|
||||
),
|
||||
text(
|
||||
"theme.light.file",
|
||||
"Light theme file",
|
||||
"Optional overrides for Light appearance.",
|
||||
"",
|
||||
),
|
||||
text(
|
||||
"theme.dark.file",
|
||||
"Dark theme file",
|
||||
"Optional overrides for Dark appearance.",
|
||||
"",
|
||||
),
|
||||
text(
|
||||
"theme.schedule.light_at",
|
||||
"Light at",
|
||||
"Local time in HH:MM format.",
|
||||
"07:00",
|
||||
),
|
||||
text(
|
||||
"theme.schedule.dark_at",
|
||||
"Dark at",
|
||||
"Local time in HH:MM format.",
|
||||
"19:00",
|
||||
),
|
||||
text(
|
||||
"theme.schedule.timezone",
|
||||
"Timezone",
|
||||
"system follows your desktop timezone; or enter an IANA timezone.",
|
||||
"system",
|
||||
),
|
||||
range(
|
||||
"theme.geometry.shell_radius",
|
||||
"Shell corner radius",
|
||||
@@ -604,17 +672,11 @@ pub fn fields(page: Page) -> Vec<Field> {
|
||||
false,
|
||||
),
|
||||
text(
|
||||
"theme.colors.accent",
|
||||
"theme.accent",
|
||||
"Accent color",
|
||||
"Focus rings and selected controls. Use a hex color.",
|
||||
"#3D7BE6",
|
||||
),
|
||||
text(
|
||||
"theme.colors.surface_base",
|
||||
"Surface color",
|
||||
"The base color for desktop surfaces.",
|
||||
"#111821",
|
||||
),
|
||||
choice(
|
||||
"theme.material.style",
|
||||
"Surface style",
|
||||
@@ -860,12 +922,6 @@ pub fn fields(page: Page) -> Vec<Field> {
|
||||
"×",
|
||||
false,
|
||||
),
|
||||
toggle(
|
||||
"animations.reduced_motion",
|
||||
"Reduce motion",
|
||||
"Use immediate changes in place of animations.",
|
||||
false,
|
||||
),
|
||||
range(
|
||||
"appearance.inactive_dim.duration_ms",
|
||||
"Focus transition",
|
||||
|
||||
@@ -78,6 +78,7 @@ impl Snapshot {
|
||||
|
||||
pub fn edit(&mut self, edit: &Edit) -> Result<(), String> {
|
||||
match edit {
|
||||
Edit::Unset(path) => self.doc.unset(path),
|
||||
Edit::Set(path, value) => self.doc.set(path, value.clone()),
|
||||
Edit::Add(path, fields) => self.doc.add(path, fields.clone()),
|
||||
Edit::Remove(path, index) => self.doc.remove(path, *index),
|
||||
@@ -90,6 +91,7 @@ impl Snapshot {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Edit {
|
||||
Unset(String),
|
||||
Set(String, Value),
|
||||
Add(String, Vec<(String, Value)>),
|
||||
Remove(String, usize),
|
||||
@@ -108,6 +110,35 @@ pub fn config_path() -> PathBuf {
|
||||
path.canonicalize().unwrap_or(path)
|
||||
}
|
||||
|
||||
pub fn pick_file(title: &str, filter: &str) -> Result<Option<String>, String> {
|
||||
let mut debug = std::env::var("GDK_DEBUG").unwrap_or_default();
|
||||
if !debug.is_empty() {
|
||||
debug.push(',');
|
||||
}
|
||||
debug.push_str("no-portals");
|
||||
let output = Command::new("zenity")
|
||||
.env("GDK_DEBUG", debug)
|
||||
.env("GTK_USE_PORTAL", "0")
|
||||
.arg("--file-selection")
|
||||
.arg(format!("--title={title}"))
|
||||
.arg(format!("--file-filter={filter}"))
|
||||
.arg("--file-filter=All files | *")
|
||||
.output()
|
||||
.map_err(|e| format!("Could not open the file chooser: {e}. Install zenity to choose files."))?;
|
||||
if output.status.code() == Some(1) {
|
||||
return Ok(None);
|
||||
}
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"File chooser failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
let path = String::from_utf8(output.stdout).map_err(|_| "The file path is not valid UTF-8.".to_owned())?;
|
||||
let path = path.trim_end_matches(['\r', '\n']);
|
||||
Ok((!path.is_empty()).then(|| path.to_owned()))
|
||||
}
|
||||
|
||||
pub fn sibling(name: &str) -> PathBuf {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use cosmic::Element;
|
||||
use cosmic::iced::{Color, Length};
|
||||
use cosmic::widget::icon as svg_icon;
|
||||
pub use ferese_theme::Palette;
|
||||
pub use ferese_theme::controls::{button_style, navigation_style, settings_input as input_style, surface};
|
||||
pub use ferese_theme::{Palette, mix, surface_shade};
|
||||
|
||||
use crate::Message;
|
||||
use crate::schema::Page;
|
||||
@@ -21,12 +21,8 @@ fn hex(c: Color) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn native_theme(snapshot: Option<&Snapshot>) -> cosmic::Theme {
|
||||
Palette::from_document(snapshot.map(|snapshot| &snapshot.doc)).native_theme()
|
||||
}
|
||||
|
||||
pub fn configured_font(snapshot: &Snapshot) -> cosmic::font::Font {
|
||||
ferese_theme::font(Some(&snapshot.string("theme.typography.font_family", "Inter")))
|
||||
pub fn native_theme(_snapshot: Option<&Snapshot>) -> cosmic::Theme {
|
||||
Palette::from_resolved(&ferese_theme::service::current().presented).native_theme()
|
||||
}
|
||||
|
||||
pub fn icon(page: Page, tint: Color) -> svg_icon::Icon {
|
||||
@@ -44,107 +40,149 @@ pub fn action_icon(path: &str, tint: Color) -> svg_icon::Icon {
|
||||
ferese_theme::icons::outline(path, tint, 16)
|
||||
}
|
||||
|
||||
pub use ferese_theme::{PRESETS, Preset};
|
||||
#[cfg(test)]
|
||||
pub use ferese_theme::PRESETS;
|
||||
|
||||
pub fn split(snapshot: &Snapshot) -> bool {
|
||||
snapshot
|
||||
.item("theme.split")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or_else(|| {
|
||||
ferese_config::families::family_id(&snapshot.string("theme.light.preset", "ferese-blue-light"))
|
||||
!= ferese_config::families::family_id(&snapshot.string("theme.dark.preset", "ferese-blue"))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn family_selection(snapshot: &Snapshot, appearance: Option<ferese_config::theme::Appearance>) -> String {
|
||||
let Some(appearance) = appearance else {
|
||||
return snapshot.string(
|
||||
"theme.family",
|
||||
ferese_config::families::family_id(&snapshot.string("theme.dark.preset", "ferese-blue")),
|
||||
);
|
||||
};
|
||||
let kind = if appearance == ferese_config::theme::Appearance::Light {
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
};
|
||||
let preset = snapshot.string(
|
||||
&format!("theme.{kind}.preset"),
|
||||
if kind == "light" {
|
||||
"ferese-blue-light"
|
||||
} else {
|
||||
"ferese-blue"
|
||||
},
|
||||
);
|
||||
snapshot.string(
|
||||
&format!("theme.{kind}.family"),
|
||||
ferese_config::families::family_id(&preset),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn choose_family(id: &str, appearance: Option<ferese_config::theme::Appearance>) -> Vec<Edit> {
|
||||
let kinds: &[&str] = match appearance {
|
||||
Some(ferese_config::theme::Appearance::Light) => &["light"],
|
||||
Some(ferese_config::theme::Appearance::Dark) => &["dark"],
|
||||
None => &[],
|
||||
};
|
||||
let mut edits = vec![set(
|
||||
&appearance.map_or_else(
|
||||
|| "theme.family".into(),
|
||||
|a| {
|
||||
format!(
|
||||
"theme.{}.family",
|
||||
if a == ferese_config::theme::Appearance::Light {
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
id,
|
||||
)];
|
||||
for kind in kinds {
|
||||
for token in ["colors", "surface", "border", "focus_ring"] {
|
||||
edits.push(Edit::Unset(format!("theme.{kind}.{token}")));
|
||||
}
|
||||
}
|
||||
edits
|
||||
}
|
||||
|
||||
pub fn toggle_split(snapshot: &Snapshot, enabled: bool, active: ferese_config::theme::Appearance) -> Vec<Edit> {
|
||||
let mut edits = vec![];
|
||||
if enabled {
|
||||
let id = family_selection(snapshot, None);
|
||||
for kind in ["light", "dark"] {
|
||||
if snapshot.item(&format!("theme.{kind}.family")).is_none() {
|
||||
let selection = if snapshot.item("theme.family").is_some() {
|
||||
id.clone()
|
||||
} else {
|
||||
family_selection(
|
||||
snapshot,
|
||||
Some(if kind == "light" {
|
||||
ferese_config::theme::Appearance::Light
|
||||
} else {
|
||||
ferese_config::theme::Appearance::Dark
|
||||
}),
|
||||
)
|
||||
};
|
||||
edits.push(set(&format!("theme.{kind}.family"), selection));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
edits.push(set("theme.family", family_selection(snapshot, Some(active))));
|
||||
}
|
||||
edits.push(set("theme.split", enabled));
|
||||
edits
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn preset(index: usize) -> Vec<Edit> {
|
||||
let Some(preset) = PRESETS.get(index) else {
|
||||
return Vec::new();
|
||||
return vec![];
|
||||
};
|
||||
|
||||
vec![
|
||||
set("theme.colors.accent", preset.accent),
|
||||
set("theme.colors.surface_base", preset.base),
|
||||
set("theme.colors.text_primary", preset.text),
|
||||
set("theme.colors.text_muted", preset.muted),
|
||||
set("theme.colors.border", preset.border),
|
||||
set("theme.colors.shadow", "#00000055"),
|
||||
set("theme.surface.bar.background", preset.base),
|
||||
set("theme.surface.bar.text_primary", preset.text),
|
||||
set("theme.surface.bar.text_muted", preset.muted),
|
||||
set("theme.focus_ring.gradient.from", preset.accent),
|
||||
set("theme.focus_ring.gradient.to", preset.gradient_end),
|
||||
set("theme.focus_ring.gradient.angle", 0.0),
|
||||
]
|
||||
let kind = if preset.appearance == ferese_config::theme::Appearance::Light {
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
};
|
||||
let mut edits = vec![set(&format!("theme.{kind}.preset"), preset.id)];
|
||||
for token in ["colors", "surface", "border", "focus_ring"] {
|
||||
edits.push(Edit::Unset(format!("theme.{kind}.{token}")));
|
||||
}
|
||||
edits
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn preset_selected(snapshot: &Snapshot, index: usize) -> bool {
|
||||
let Some(preset) = PRESETS.get(index) else {
|
||||
return false;
|
||||
let Some(preset) = PRESETS.get(index) else { return false };
|
||||
let kind = if preset.appearance == ferese_config::theme::Appearance::Light {
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
};
|
||||
|
||||
[
|
||||
("theme.colors.accent", preset.accent, "#3D7BE6"),
|
||||
("theme.colors.surface_base", preset.base, "#111821"),
|
||||
("theme.colors.text_primary", preset.text, "#F4F7FB"),
|
||||
("theme.colors.text_muted", preset.muted, "#8793A2"),
|
||||
("theme.colors.border", preset.border, "#FFFFFF18"),
|
||||
("theme.colors.shadow", "#00000055", "#00000055"),
|
||||
("theme.surface.bar.background", preset.base, "#1C202EF2"),
|
||||
("theme.surface.bar.text_primary", preset.text, "#F0F3FA"),
|
||||
("theme.surface.bar.text_muted", preset.muted, "#AAB4C7"),
|
||||
("theme.focus_ring.gradient.from", preset.accent, "#3D7BE6"),
|
||||
("theme.focus_ring.gradient.to", preset.gradient_end, "#3D7BE6"),
|
||||
]
|
||||
.into_iter()
|
||||
.all(|(path, expected, fallback)| {
|
||||
if index == 0 && snapshot.item(path).is_none() {
|
||||
return true;
|
||||
}
|
||||
snapshot.string(path, fallback).eq_ignore_ascii_case(expected)
|
||||
}) && snapshot.number("theme.focus_ring.gradient.angle", 0.) == 0.
|
||||
snapshot.string(
|
||||
&format!("theme.{kind}.preset"),
|
||||
if kind == "dark" {
|
||||
"ferese-blue"
|
||||
} else {
|
||||
"ferese-blue-light"
|
||||
},
|
||||
) == preset.id
|
||||
&& ["colors", "surface", "border", "focus_ring"]
|
||||
.iter()
|
||||
.all(|token| snapshot.item(&format!("theme.{kind}.{token}")).is_none())
|
||||
}
|
||||
|
||||
fn preset_preview_handle(preset: &Preset) -> svg_icon::Handle {
|
||||
let base = preset.base;
|
||||
let accent = preset.accent;
|
||||
let end = preset.gradient_end;
|
||||
let text = preset.text;
|
||||
let muted = preset.muted;
|
||||
let surface = color(base, Color::BLACK);
|
||||
let card = hex(mix(surface, surface_shade(surface), 0.055));
|
||||
let svg = format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="240" height="132" viewBox="0 0 240 132">
|
||||
<defs>
|
||||
<linearGradient id="wall" x2="1" y2="1">
|
||||
<stop stop-color="{accent}" stop-opacity=".24"/>
|
||||
<stop offset="1" stop-color="{end}" stop-opacity=".06"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="240" height="132" rx="10" fill="{base}"/>
|
||||
<rect width="240" height="132" rx="10" fill="url(#wall)"/>
|
||||
<path d="M0 112Q52 48 120 93T240 67V132H0Z" fill="{accent}" opacity=".09"/>
|
||||
<rect x="8" y="8" width="224" height="12" rx="4" fill="{base}"/>
|
||||
<rect x="14" y="12" width="6" height="4" rx="1" fill="{accent}"/>
|
||||
<path d="M26 14h4m4 0h4m164 0h6m4 0h6" stroke="{muted}" stroke-width="2" stroke-linecap="round"/>
|
||||
<rect x="16" y="34" width="121" height="82" rx="7" fill="{card}"/>
|
||||
<path d="M26 44h102" stroke="{muted}" opacity=".16"/>
|
||||
<rect x="23" y="50" width="25" height="59" rx="3" fill="{base}"/>
|
||||
<rect x="26" y="57" width="19" height="7" rx="2" fill="{accent}" opacity=".6"/>
|
||||
<path d="M29 73h11m-11 9h9m-9 9h12" stroke="{muted}" opacity=".6" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M56 59h38m-38 10h63m-63 8h49m-49 8h58" stroke="{text}" opacity=".55" stroke-width="2" stroke-linecap="round"/>
|
||||
<rect x="55" y="94" width="29" height="9" rx="3" fill="{accent}"/>
|
||||
<rect x="145" y="37" width="78" height="70" rx="7" fill="{card}" stroke="{accent}" stroke-width="1.2"/>
|
||||
<circle cx="154" cy="45" r="1.5" fill="{accent}"/>
|
||||
<path d="M161 45h17" stroke="{muted}" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M155 58h27m-27 9h56m-56 9h37m-37 9h49m-49 9h30" stroke="{text}" opacity=".55" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>"##,
|
||||
);
|
||||
svg_icon::from_svg_bytes(svg.into_bytes()).symbolic(false)
|
||||
pub fn preview_resolved<'a>(snapshot: &Snapshot, theme: &ferese_config::theme::ResolvedTheme) -> Element<'a, Message> {
|
||||
let mut snapshot = snapshot.clone();
|
||||
snapshot.doc = snapshot.doc.with_theme(theme);
|
||||
preview(&snapshot, theme)
|
||||
}
|
||||
|
||||
pub fn preset_preview(index: usize) -> Element<'static, Message> {
|
||||
static HANDLES: std::sync::OnceLock<Vec<svg_icon::Handle>> = std::sync::OnceLock::new();
|
||||
HANDLES.get_or_init(|| PRESETS.iter().map(preset_preview_handle).collect())[index]
|
||||
.clone()
|
||||
.icon()
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(90.))
|
||||
.content_fit(cosmic::iced::ContentFit::Contain)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn preview(snapshot: &Snapshot) -> Element<'static, Message> {
|
||||
let p = Palette::from_document(Some(&snapshot.doc));
|
||||
fn preview(snapshot: &Snapshot, theme: &ferese_config::theme::ResolvedTheme) -> Element<'static, Message> {
|
||||
let p = Palette::from_resolved(theme);
|
||||
let base = hex(p.sidebar);
|
||||
let card = hex(p.card);
|
||||
let accent = hex(p.accent);
|
||||
@@ -210,17 +248,64 @@ mod tests {
|
||||
ferese_theme::contrast(a, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn galleries_follow_mode_and_keep_selection_independent() {
|
||||
let mut snapshot = Snapshot::parse(String::from(r#"theme { mode "auto"; }"#)).unwrap();
|
||||
for index in 0..PRESETS.len() {
|
||||
for edit in preset(index) {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
assert_eq!(snapshot.string("theme.mode", ""), "auto");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_toggle_restores_the_pair_and_uses_the_active_family_when_linked() {
|
||||
use ferese_config::theme::Appearance::{Dark, Light};
|
||||
let mut snapshot = Snapshot::parse(r#"theme { family "ferese-blue"; split #true; light { family "gruvbox"; }; dark { family "catppuccin"; }; }"#.into()).unwrap();
|
||||
for edit in toggle_split(&snapshot, false, Light) {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
assert!(!split(&snapshot));
|
||||
assert_eq!(family_selection(&snapshot, None), "gruvbox");
|
||||
for edit in choose_family("everforest", None) {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
for edit in toggle_split(&snapshot, true, Dark) {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
assert!(split(&snapshot));
|
||||
assert_eq!(family_selection(&snapshot, Some(Light)), "gruvbox");
|
||||
assert_eq!(family_selection(&snapshot, Some(Dark)), "catppuccin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_split_starts_with_both_variants_of_the_single_family() {
|
||||
use ferese_config::theme::Appearance::{Dark, Light};
|
||||
let mut snapshot = Snapshot::parse(r#"theme { family "tokyo-night"; }"#.into()).unwrap();
|
||||
for edit in toggle_split(&snapshot, true, Dark) {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
assert_eq!(family_selection(&snapshot, Some(Light)), "tokyo-night");
|
||||
assert_eq!(family_selection(&snapshot, Some(Dark)), "tokyo-night");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_are_distinct_and_readable_on_settings_surfaces() {
|
||||
for (index, preset) in PRESETS.iter().enumerate() {
|
||||
let snapshot = configured_preset(index);
|
||||
let palette = Palette::from_document(Some(&snapshot.doc));
|
||||
let tokens = ferese_config::theme::preset(preset.id, preset.appearance).unwrap();
|
||||
let mut theme = ferese_config::theme::ResolvedTheme::default();
|
||||
theme.appearance = preset.appearance;
|
||||
theme.tokens = tokens;
|
||||
let palette = Palette::from_resolved(&theme);
|
||||
for background in [palette.background, palette.sidebar, palette.card] {
|
||||
assert!(contrast(palette.text, background) >= 4.5, "{} text", preset.name);
|
||||
assert!(contrast(palette.muted, background) >= 4.5, "{} muted text", preset.name);
|
||||
}
|
||||
for other in PRESETS.iter().skip(index + 1) {
|
||||
if preset.appearance == other.appearance {
|
||||
assert_ne!(preset.accent, other.accent);
|
||||
}
|
||||
assert_ne!(preset.base, other.base);
|
||||
}
|
||||
}
|
||||
@@ -231,12 +316,19 @@ mod tests {
|
||||
for index in 0..PRESETS.len() {
|
||||
let mut snapshot = configured_preset(index);
|
||||
assert!(preset_selected(&snapshot, index));
|
||||
let kind = if PRESETS[index].appearance == ferese_config::theme::Appearance::Light {
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
};
|
||||
snapshot
|
||||
.edit(&set("theme.surface.bar.text_primary", "#123456"))
|
||||
.edit(&set(&format!("theme.{kind}.surface.bar.text_primary"), "#123456"))
|
||||
.unwrap();
|
||||
assert!(!preset_selected(&snapshot, index));
|
||||
let mut snapshot = configured_preset(index);
|
||||
snapshot.edit(&set("theme.focus_ring.gradient.to", "#123456")).unwrap();
|
||||
snapshot
|
||||
.edit(&set(&format!("theme.{kind}.focus_ring.gradient.to"), "#123456"))
|
||||
.unwrap();
|
||||
assert!(!preset_selected(&snapshot, index));
|
||||
}
|
||||
assert!(preset_selected(&Snapshot::parse(String::new()).unwrap(), 0));
|
||||
@@ -250,8 +342,14 @@ mod tests {
|
||||
assert!(logo.contains(PRESETS[0].accent));
|
||||
|
||||
for (index, item) in PRESETS.iter().enumerate() {
|
||||
let snapshot = configured_preset(index);
|
||||
assert_eq!(native_theme(Some(&snapshot)).cosmic().is_dark, item.name != "Ayu Light");
|
||||
let _ = index;
|
||||
let mut resolved = ferese_config::theme::ResolvedTheme::default();
|
||||
resolved.appearance = item.appearance;
|
||||
resolved.tokens = ferese_config::theme::preset(item.id, item.appearance).unwrap();
|
||||
assert_eq!(
|
||||
Palette::from_resolved(&resolved).native_theme().cosmic().is_dark,
|
||||
item.appearance == ferese_config::theme::Appearance::Dark
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ pub(crate) struct ShellConfig {
|
||||
pub(crate) font_family: Option<String>,
|
||||
pub(crate) wallpaper: WallpaperConfig,
|
||||
pub(crate) theme: ShellTheme,
|
||||
pub(crate) theme_mode: ferese_config::theme::Mode,
|
||||
pub(crate) status: StatusConfig,
|
||||
}
|
||||
|
||||
@@ -40,6 +41,7 @@ impl Default for StatusConfig {
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct ShellTheme {
|
||||
pub(crate) appearance: ferese_config::theme::Appearance,
|
||||
pub(crate) bar_background: [u8; 4],
|
||||
pub(crate) bar_text_primary: [u8; 4],
|
||||
pub(crate) bar_text_muted: [u8; 4],
|
||||
@@ -48,6 +50,7 @@ pub(crate) struct ShellTheme {
|
||||
pub(crate) text_primary: [u8; 4],
|
||||
pub(crate) text_muted: [u8; 4],
|
||||
pub(crate) accent: [u8; 4],
|
||||
pub(crate) on_accent: [u8; 4],
|
||||
pub(crate) border: [u8; 4],
|
||||
pub(crate) shadow: [u8; 4],
|
||||
pub(crate) bar_height: f32,
|
||||
@@ -66,6 +69,7 @@ pub(crate) struct ShellTheme {
|
||||
impl Default for ShellTheme {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
appearance: Default::default(),
|
||||
bar_background: [28, 32, 46, 255],
|
||||
bar_text_primary: [240, 243, 250, 255],
|
||||
bar_text_muted: [170, 180, 199, 255],
|
||||
@@ -74,6 +78,7 @@ impl Default for ShellTheme {
|
||||
text_primary: [244, 247, 251, 255],
|
||||
text_muted: [135, 147, 162, 255],
|
||||
accent: [61, 123, 230, 255],
|
||||
on_accent: [244, 247, 251, 255],
|
||||
border: [255, 255, 255, 24],
|
||||
shadow: [0, 0, 0, 85],
|
||||
bar_height: 28.0,
|
||||
@@ -96,12 +101,14 @@ impl ShellTheme {
|
||||
let color = |[r, g, b, a]: [u8; 4]| cosmic::iced::Color::from_rgba8(r, g, b, f32::from(a) / 255.);
|
||||
let surface = color(self.surface_base);
|
||||
ferese_theme::Palette {
|
||||
appearance: self.appearance,
|
||||
background: surface,
|
||||
sidebar: surface,
|
||||
card: surface,
|
||||
text: color(self.text_primary),
|
||||
muted: color(self.text_muted),
|
||||
accent: color(self.accent),
|
||||
on_accent: color(self.on_accent),
|
||||
radius: self.material_radius,
|
||||
error: cosmic::iced::Color::from_rgb8(235, 98, 98),
|
||||
}
|
||||
@@ -229,6 +236,8 @@ struct ThemeColorsConfig {
|
||||
text_muted: String,
|
||||
#[serde(default = "default_accent")]
|
||||
accent: String,
|
||||
#[serde(default = "default_text_primary")]
|
||||
on_accent: String,
|
||||
#[serde(default = "default_border")]
|
||||
border: String,
|
||||
#[serde(default = "default_shadow")]
|
||||
@@ -242,6 +251,7 @@ impl Default for ThemeColorsConfig {
|
||||
text_primary: default_text_primary(),
|
||||
text_muted: default_text_muted(),
|
||||
accent: default_accent(),
|
||||
on_accent: default_text_primary(),
|
||||
border: default_border(),
|
||||
shadow: default_shadow(),
|
||||
}
|
||||
@@ -327,11 +337,28 @@ pub(crate) fn load() -> ShellConfig {
|
||||
}
|
||||
|
||||
pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, ferese_config::Error> {
|
||||
match ferese_config::from_str::<FereseConfig>(source) {
|
||||
let document = ferese_config::Document::parse(source)?;
|
||||
let snapshot = ferese_theme::service::current();
|
||||
parse_document(
|
||||
&document.with_theme(&snapshot.presented),
|
||||
snapshot.presented.appearance,
|
||||
snapshot.mode,
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_document(
|
||||
document: &ferese_config::Document,
|
||||
appearance: ferese_config::theme::Appearance,
|
||||
mode: ferese_config::theme::Mode,
|
||||
) -> Result<ShellConfig, ferese_config::Error> {
|
||||
match serde_json::from_value::<FereseConfig>(document.value().clone())
|
||||
.map_err(|error| ferese_config::Error::from(error.to_string()))
|
||||
{
|
||||
Ok(config) => {
|
||||
config.notifications.validate().map_err(ferese_config::Error::from)?;
|
||||
config.desktop_widgets.validate().map_err(ferese_config::Error::from)?;
|
||||
let mut theme = shell_theme(&config.theme);
|
||||
theme.appearance = appearance;
|
||||
theme.material_radius = nonnegative_or(
|
||||
config
|
||||
.theme
|
||||
@@ -351,6 +378,7 @@ pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, ferese_config::E
|
||||
font_family: config.theme.typography.font_family,
|
||||
wallpaper: config.theme.background,
|
||||
theme,
|
||||
theme_mode: mode,
|
||||
status: StatusConfig {
|
||||
low_battery_threshold: config.status.low_battery_threshold.min(100),
|
||||
..config.status
|
||||
@@ -361,6 +389,21 @@ pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, ferese_config::E
|
||||
}
|
||||
}
|
||||
|
||||
impl ShellConfig {
|
||||
pub(crate) fn apply_theme(&mut self, theme: &ferese_config::theme::ResolvedTheme) {
|
||||
let config: ThemeConfig = serde_json::from_value(serde_json::to_value(&theme.tokens).unwrap()).unwrap();
|
||||
self.theme = shell_theme(&config);
|
||||
self.theme.appearance = theme.appearance;
|
||||
self.theme.material_radius = theme.tokens.geometry.shell_radius as f32;
|
||||
self.theme.bar_radius = self.theme.material_radius;
|
||||
self.font_family = Some(theme.tokens.typography.font_family.clone());
|
||||
self.wallpaper = config.background;
|
||||
if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some() {
|
||||
self.wallpaper.path = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
|
||||
let defaults = ShellTheme::default();
|
||||
let opacity = if theme.material.style == "translucent" {
|
||||
@@ -378,6 +421,7 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
|
||||
let alpha = (opacity * 255.0).round() as u8;
|
||||
|
||||
ShellTheme {
|
||||
appearance: defaults.appearance,
|
||||
material_radius: defaults.material_radius,
|
||||
bar_background: {
|
||||
let mut background = parse_color(&theme.surface.bar.background).unwrap_or(defaults.bar_background);
|
||||
@@ -395,6 +439,7 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
|
||||
text_primary: parse_color(&theme.colors.text_primary).unwrap_or(defaults.text_primary),
|
||||
text_muted: parse_color(&theme.colors.text_muted).unwrap_or(defaults.text_muted),
|
||||
accent: parse_color(&theme.colors.accent).unwrap_or(defaults.accent),
|
||||
on_accent: parse_color(&theme.colors.on_accent).unwrap_or(defaults.on_accent),
|
||||
border: parse_color(&theme.colors.border).unwrap_or(defaults.border),
|
||||
shadow: parse_color(&theme.colors.shadow).unwrap_or(defaults.shadow),
|
||||
bar_height: positive_or(theme.geometry.top_bar_height, defaults.bar_height),
|
||||
@@ -527,7 +572,7 @@ pub(crate) fn config_path() -> Option<PathBuf> {
|
||||
mod tests {
|
||||
#[test]
|
||||
fn desktop_clock_parses_and_rejects_invalid_reload_values() {
|
||||
let clock = super::parse_source(
|
||||
let clock = parse_test_source(
|
||||
r#"desktop-widgets {
|
||||
clock {
|
||||
enabled #true
|
||||
@@ -544,16 +589,21 @@ mod tests {
|
||||
.clock;
|
||||
assert!(clock.enabled);
|
||||
assert_eq!(clock.anchor, ferese_core::desktop::Anchor::BottomRight);
|
||||
assert!(super::parse_source("desktop-widgets {\n clock {\n opacity 1.1\n }\n}\n").is_err());
|
||||
assert!(super::parse_source("desktop-widgets {\n clock {\n time-format \"%\"\n }\n}\n").is_err());
|
||||
assert!(parse_test_source("desktop-widgets {\n clock {\n opacity 1.1\n }\n}\n").is_err());
|
||||
assert!(parse_test_source("desktop-widgets {\n clock {\n time-format \"%\"\n }\n}\n").is_err());
|
||||
}
|
||||
use super::*;
|
||||
|
||||
fn parse_test_source(source: &str) -> Result<ShellConfig, ferese_config::Error> {
|
||||
let document = ferese_config::Document::parse(source)?;
|
||||
parse_document(&document, Default::default(), Default::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keybinding_guide_is_enabled_until_explicitly_disabled() {
|
||||
assert!(parse_source("").unwrap().status.keybinding_guide);
|
||||
assert!(parse_test_source("").unwrap().status.keybinding_guide);
|
||||
assert!(
|
||||
!parse_source("status { keybinding-guide #false; }")
|
||||
!parse_test_source("status { keybinding-guide #false; }")
|
||||
.unwrap()
|
||||
.status
|
||||
.keybinding_guide
|
||||
@@ -590,14 +640,14 @@ theme {
|
||||
#[test]
|
||||
fn shell_radius_unifies_surfaces_and_preserves_legacy_fallbacks() {
|
||||
for radius in [0.0, 18.0] {
|
||||
let config = parse_source(&format!("appearance {{\n corner-radius 9\n}}\ntheme {{\n geometry {{\n shell-radius {radius}\n top-bar-radius 5\n window-radius 23\n }}\n}}")).unwrap();
|
||||
let config = parse_test_source(&format!("appearance {{\n corner-radius 9\n}}\ntheme {{\n geometry {{\n shell-radius {radius}\n top-bar-radius 5\n window-radius 23\n }}\n}}")).unwrap();
|
||||
assert_eq!(config.theme.material_radius, radius);
|
||||
assert_eq!(config.theme.bar_radius, radius);
|
||||
}
|
||||
let legacy = parse_source("theme {\n geometry {\n top-bar-radius 7\n }\n}").unwrap();
|
||||
let legacy = parse_test_source("theme {\n geometry {\n top-bar-radius 7\n }\n}").unwrap();
|
||||
assert_eq!(legacy.theme.material_radius, 7.0);
|
||||
assert_eq!(legacy.theme.bar_radius, 7.0);
|
||||
let legacy = parse_source("appearance {\n corner-radius 6\n}").unwrap();
|
||||
let legacy = parse_test_source("appearance {\n corner-radius 6\n}").unwrap();
|
||||
assert_eq!(legacy.theme.material_radius, 6.0);
|
||||
assert_eq!(legacy.theme.bar_radius, 6.0);
|
||||
}
|
||||
|
||||
@@ -166,6 +166,7 @@ struct FereseShell {
|
||||
recorder: recording::Recorder,
|
||||
calendar_offset: i32,
|
||||
status_error: Option<String>,
|
||||
theme_error: Option<String>,
|
||||
menu: Option<status_ui::OpenMenu>,
|
||||
system_modal: Option<system_modal::SystemModal>,
|
||||
guide_shown: bool,
|
||||
@@ -212,6 +213,9 @@ struct OutputSurfaces {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Message {
|
||||
ThemeChanged(ferese_config::theme::Snapshot),
|
||||
ThemeMode(ferese_config::theme::Mode),
|
||||
ThemeModeSet(Result<(), String>),
|
||||
BeginNoteEdit(String),
|
||||
NoteAction(String, cosmic::widget::text_editor::Action),
|
||||
SaveNote(String, u64),
|
||||
@@ -282,6 +286,7 @@ impl cosmic::Application for FereseShell {
|
||||
recorder: recording::Recorder::default(),
|
||||
calendar_offset: 0,
|
||||
status_error: None,
|
||||
theme_error: None,
|
||||
menu: None,
|
||||
system_modal: None,
|
||||
guide_shown: false,
|
||||
@@ -322,6 +327,7 @@ impl cosmic::Application for FereseShell {
|
||||
|
||||
fn subscription(&self) -> Subscription<Self::Message> {
|
||||
Subscription::batch([
|
||||
ferese_theme::service::subscription().map(Message::ThemeChanged),
|
||||
if self
|
||||
.system_modal
|
||||
.as_ref()
|
||||
@@ -409,6 +415,38 @@ impl cosmic::Application for FereseShell {
|
||||
self.update_power_materials();
|
||||
|
||||
match message {
|
||||
Message::ThemeChanged(snapshot) => {
|
||||
let mut config = self.config.clone();
|
||||
config.apply_theme(&snapshot.presented);
|
||||
config.theme_mode = snapshot.mode;
|
||||
if snapshot.error != self.theme_error {
|
||||
if let Some(error) = &snapshot.error {
|
||||
self.notifications.service_error("Theme could not be loaded", error);
|
||||
}
|
||||
self.theme_error = snapshot.error;
|
||||
}
|
||||
return self.apply_config(config);
|
||||
}
|
||||
Message::ThemeMode(mode) => {
|
||||
return cosmic::task::future(async move {
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let mut connection = ferese_ipc::theme::Connection::connect().map_err(|e| e.to_string())?;
|
||||
connection
|
||||
.call("theme-set-mode", serde_json::json!({"mode": mode}))
|
||||
.map(|_| ())
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()));
|
||||
cosmic::Action::App(Message::ThemeModeSet(result))
|
||||
});
|
||||
}
|
||||
Message::ThemeModeSet(result) => {
|
||||
if let Err(error) = result {
|
||||
self.notifications
|
||||
.service_error("Appearance could not be changed", &error);
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
Message::BeginNoteEdit(id) => {
|
||||
let mut tasks = vec![self.finish_note_edit()];
|
||||
|
||||
@@ -814,7 +852,7 @@ impl cosmic::Application for FereseShell {
|
||||
|
||||
impl FereseShell {
|
||||
fn reload_config(&mut self, source: String) -> Task<Message> {
|
||||
let mut config = match config::parse_source(&source) {
|
||||
let config = match config::parse_source(&source) {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
eprintln!("ferese-shell: reload rejected; keeping current config: {error}");
|
||||
@@ -822,6 +860,10 @@ impl FereseShell {
|
||||
}
|
||||
};
|
||||
|
||||
self.apply_config(config)
|
||||
}
|
||||
|
||||
fn apply_config(&mut self, mut config: config::ShellConfig) -> Task<Message> {
|
||||
if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some() {
|
||||
config.wallpaper.path = None;
|
||||
}
|
||||
@@ -2017,9 +2059,7 @@ impl FereseShell {
|
||||
let clock = container(motion::button(
|
||||
button::custom(
|
||||
row![
|
||||
text(date)
|
||||
.size(12)
|
||||
.class(theme::Text::Color(color(shell_theme.text_muted))),
|
||||
text(date).size(12).class(theme::Text::Color(foreground)),
|
||||
text(time).size(bar.text_size).class(theme::Text::Color(foreground)),
|
||||
]
|
||||
.spacing(8)
|
||||
|
||||
@@ -34,6 +34,7 @@ pub enum Event {
|
||||
}
|
||||
|
||||
enum Command {
|
||||
Local(String, String),
|
||||
Close(u32, u64, u32),
|
||||
Action(u32, u64, String),
|
||||
}
|
||||
@@ -205,7 +206,7 @@ impl Service {
|
||||
let server = Server {
|
||||
events: sender.clone(),
|
||||
registry: registry.clone(),
|
||||
timeout: worker_timeout,
|
||||
timeout: worker_timeout.clone(),
|
||||
};
|
||||
let connection = match address.as_deref() {
|
||||
Some(address) => zbus::blocking::connection::Builder::address(address),
|
||||
@@ -224,6 +225,15 @@ impl Service {
|
||||
let _ = sender.try_send(Event::Ready);
|
||||
while let Ok(command) = receiver.recv() {
|
||||
let result = match command {
|
||||
Command::Local(title, body) => {
|
||||
let server = Server {
|
||||
events: sender.clone(),
|
||||
registry: registry.clone(),
|
||||
timeout: worker_timeout.clone(),
|
||||
};
|
||||
let _ = server.notify("Ferese", 0, "", &title, &body, vec![], HashMap::new(), -1);
|
||||
continue;
|
||||
}
|
||||
Command::Close(id, revision, reason) => {
|
||||
if !registry.lock().unwrap().close(id, revision, reason) {
|
||||
continue;
|
||||
@@ -287,6 +297,13 @@ impl Center {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn service_error(&self, title: &str, body: &str) {
|
||||
let _ = self
|
||||
.service
|
||||
.commands
|
||||
.try_send(Command::Local(title.into(), body.into()));
|
||||
}
|
||||
|
||||
pub fn configure(&mut self, config: ferese_core::notifications::NotificationConfig) {
|
||||
if self.config.do_not_disturb != config.do_not_disturb {
|
||||
self.dnd = config.do_not_disturb;
|
||||
|
||||
@@ -440,6 +440,35 @@ impl FereseShell {
|
||||
if kind != Menu::Calendar {
|
||||
rows = rows.push(heading);
|
||||
}
|
||||
if kind == Menu::System {
|
||||
use ferese_config::theme::{Appearance, Mode};
|
||||
let mut modes = row([]).spacing(6);
|
||||
for (mode, label) in [(Mode::Light, "☀ Light"), (Mode::Dark, "☾ Dark"), (Mode::Auto, "◐ Auto")] {
|
||||
modes = modes.push(
|
||||
button::custom(text(label).font(shell_font()).size(13))
|
||||
.width(Length::Fill)
|
||||
.padding(9)
|
||||
.class(ferese_theme::controls::button_style(
|
||||
theme.palette(),
|
||||
self.config.theme_mode == mode,
|
||||
))
|
||||
.on_press(cosmic::Action::App(Message::ThemeMode(mode))),
|
||||
);
|
||||
}
|
||||
rows = rows.push(modes);
|
||||
if self.config.theme_mode == Mode::Auto {
|
||||
rows = rows.push(
|
||||
text(if theme.appearance == Appearance::Light {
|
||||
"Auto · currently light"
|
||||
} else {
|
||||
"Auto · currently dark"
|
||||
})
|
||||
.font(shell_font())
|
||||
.size(12)
|
||||
.class(cosmic::theme::Text::Color(muted)),
|
||||
);
|
||||
}
|
||||
}
|
||||
if kind == Menu::Calendar {
|
||||
rows = rows.push(calendar_grid(self.calendar_offset, theme, p));
|
||||
} else {
|
||||
@@ -1119,7 +1148,7 @@ fn slider_row(
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(24)
|
||||
.class(ferese_theme::menus::slider(foreground, opacity, motion::radius(5.)));
|
||||
.class(ferese_theme::menus::slider(foreground, opacity));
|
||||
row, or try a [nested preview](installation.md#preview-and-logs).
|
||||
2. Open **Control Center → Settings** to choose a theme and configure your desktop.
|
||||
2. Choose Light, Dark, or Auto in **Control Center**. Open **Settings** for [theme files and presets](theme-engine.md).
|
||||
3. Use the [configuration reference](configuration.md) for shortcuts, gestures, window rules, and displays.
|
||||
|
||||
## Default shortcuts
|
||||
|
||||
Reference in new issue
Block a user