Derive theme gradients and add solid paint controls
This commit is contained in:
1 parent
20395007a5
commit
831ac3a77f
18 files changed
+604
-20
No files matched your search
@@ -25,7 +25,7 @@ Ferese combines a window manager and desktop shell with one configuration.
|
|||||||
- [Continuum motion](docs/configuration.md#animations): interruptible springs, gesture momentum and motion settings shared by the compositor and shell
|
- [Continuum motion](docs/configuration.md#animations): interruptible springs, gesture momentum and motion settings shared by the compositor and shell
|
||||||
- Multiple monitors with fractional scaling and automatic refresh-rate switching on low battery
|
- Multiple monitors with fractional scaling and automatic refresh-rate switching on low battery
|
||||||
- Themes, wallpapers and blur that update live, with Light/Dark/Auto modes and custom KDL themes
|
- Themes, wallpapers and blur that update live, with Light/Dark/Auto modes and custom KDL themes
|
||||||
- Squircle window and shell corners with matching borders, shadows and blur masks
|
- Continuous corners across windows and shell
|
||||||
- Desktop widgets, including a clock and sticky notes
|
- Desktop widgets, including a clock and sticky notes
|
||||||
- Screenshots with annotation, screen sharing and built-in recording
|
- Screenshots with annotation, screen sharing and built-in recording
|
||||||
- A built-in lock screen and authentication dialogs
|
- A built-in lock screen and authentication dialogs
|
||||||
@@ -43,6 +43,7 @@ compares current upstream features and names separate tools where they are neede
|
|||||||
| Floating windows | Built in | Built in | Built in |
|
| Floating windows | Built in | Built in | Built in |
|
||||||
| Workspace overview | Built in | Plugins such as [HyprExpo](https://github.com/sandwichfarm/hyprexpo) | Built in |
|
| Workspace overview | Built in | Plugins such as [HyprExpo](https://github.com/sandwichfarm/hyprexpo) | Built in |
|
||||||
| Animations | [Continuum](docs/animation-model.md): interruptible springs, gesture momentum; speed and reduced-motion settings shared by the compositor and shell | Bézier and spring curves | [Spring and easing animations; custom shaders](https://niri-wm.github.io/niri/Configuration:-Animations.html) |
|
| Animations | [Continuum](docs/animation-model.md): interruptible springs, gesture momentum; speed and reduced-motion settings shared by the compositor and shell | Bézier and spring curves | [Spring and easing animations; custom shaders](https://niri-wm.github.io/niri/Configuration:-Animations.html) |
|
||||||
|
| Corner shapes | Continuous corners across windows and shell | [Rounded corners and squircles](https://wiki.hypr.land/configuring/core/config-options/#decoration) | [Rounded corners with optional clipping](https://niri-wm.github.io/niri/Configuration:-Window-Rules.html#geometry-corner-radius) |
|
||||||
| Live configuration reload | Yes | Yes | Yes |
|
| Live configuration reload | Yes | Yes | Yes |
|
||||||
| Monitor mirroring | Built in | [Built in](https://wiki.hypr.land/configuring/core/monitors/) | Separate tool, such as [wl-mirror](https://niri-wm.github.io/niri/Screencasting.html#screen-mirroring) |
|
| Monitor mirroring | Built in | [Built in](https://wiki.hypr.land/configuring/core/monitors/) | Separate tool, such as [wl-mirror](https://niri-wm.github.io/niri/Screencasting.html#screen-mirroring) |
|
||||||
| Desktop controls | Included Settings and Control Center | [Separate utilities](https://wiki.hypr.land/hypr-ecosystem/) or a desktop shell | [Separate desktop shell or tools](https://niri-wm.github.io/niri/Integrating-niri.html#desktop-components) |
|
| Desktop controls | Included Settings and Control Center | [Separate utilities](https://wiki.hypr.land/hypr-ecosystem/) or a desktop shell | [Separate desktop shell or tools](https://niri-wm.github.io/niri/Integrating-niri.html#desktop-components) |
|
||||||
|
|||||||
@@ -717,6 +717,18 @@ mod tests {
|
|||||||
assert_eq!(configured.accent_color, defaults.accent_color);
|
assert_eq!(configured.accent_color, defaults.accent_color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolved_paint_styles_reach_window_decorations() {
|
||||||
|
let mut theme = ferese_config::theme::default_theme();
|
||||||
|
let settings = Config::resolved_theme_settings(&theme).unwrap();
|
||||||
|
assert!(settings.border_gradient.is_some());
|
||||||
|
assert!(settings.focus_ring_gradient.is_some());
|
||||||
|
theme.tokens.focus_ring.style = ferese_config::theme::PaintStyle::Solid;
|
||||||
|
let settings = Config::resolved_theme_settings(&theme).unwrap();
|
||||||
|
assert!(settings.focus_ring_gradient.is_none());
|
||||||
|
assert!(settings.border_gradient.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn border_gradients_reject_invalid_colors_and_nonfinite_angles() {
|
fn border_gradients_reject_invalid_colors_and_nonfinite_angles() {
|
||||||
for settings in [
|
for settings in [
|
||||||
|
|||||||
@@ -165,6 +165,8 @@ pub struct BorderGradient {
|
|||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
pub(super) struct BorderPaintConfig {
|
pub(super) struct BorderPaintConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub(super) style: ferese_config::theme::PaintStyle,
|
||||||
pub(super) gradient: Option<BorderGradientConfig>,
|
pub(super) gradient: Option<BorderGradientConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +189,8 @@ impl BorderPaintConfig {
|
|||||||
} else {
|
} else {
|
||||||
("border.gradient.from", "border.gradient.to", "border.gradient.angle")
|
("border.gradient.from", "border.gradient.to", "border.gradient.angle")
|
||||||
};
|
};
|
||||||
self.gradient
|
let gradient = self
|
||||||
|
.gradient
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|gradient| {
|
.map(|gradient| {
|
||||||
Ok(BorderGradient {
|
Ok(BorderGradient {
|
||||||
@@ -196,7 +199,13 @@ impl BorderPaintConfig {
|
|||||||
angle: finite_theme_value(gradient.angle, angle_name)?.rem_euclid(360.0),
|
angle: finite_theme_value(gradient.angle, angle_name)?.rem_euclid(360.0),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.transpose()
|
.transpose()?;
|
||||||
|
|
||||||
|
Ok(if self.style == ferese_config::theme::PaintStyle::Solid {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
gradient
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -213,16 +213,19 @@ pub fn default_tokens() -> Tokens {
|
|||||||
"background":{"path":crate::default_wallpaper(),"lock_path":null,"mode":"fill"},
|
"background":{"path":crate::default_wallpaper(),"lock_path":null,"mode":"fill"},
|
||||||
"surface":{"bar":{"background":"#111821","text_primary":"#F4F7FB","text_muted":"#8793A2"}},
|
"surface":{"bar":{"background":"#111821","text_primary":"#F4F7FB","text_muted":"#8793A2"}},
|
||||||
"shadow":{"soft":{"offset_y":4.0,"blur":18.0,"opacity":0.2}},
|
"shadow":{"soft":{"offset_y":4.0,"blur":18.0,"opacity":0.2}},
|
||||||
"border":{"gradient":null},"focus_ring":{"gradient":null}
|
"border":{"style":"auto","gradient":null},"focus_ring":{"style":"auto","gradient":null}
|
||||||
}))
|
}))
|
||||||
.expect("valid built-in theme")
|
.expect("valid built-in theme")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_theme() -> ResolvedTheme {
|
pub fn default_theme() -> ResolvedTheme {
|
||||||
|
let mut tokens = default_tokens();
|
||||||
|
resolve_gradients(&mut tokens, None);
|
||||||
|
|
||||||
ResolvedTheme {
|
ResolvedTheme {
|
||||||
appearance: Appearance::Dark,
|
appearance: Appearance::Dark,
|
||||||
requested_accent: "#3D7BE6".into(),
|
requested_accent: "#3D7BE6".into(),
|
||||||
tokens: default_tokens(),
|
tokens,
|
||||||
accessibility: Accessibility::default(),
|
accessibility: Accessibility::default(),
|
||||||
reduced_motion: false,
|
reduced_motion: false,
|
||||||
}
|
}
|
||||||
@@ -323,6 +326,7 @@ pub fn resolve_with_context(
|
|||||||
pair.1.clone()
|
pair.1.clone()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
let custom_variant = variant.is_some();
|
||||||
let mut tokens = if let Some(tokens) = variant {
|
let mut tokens = if let Some(tokens) = variant {
|
||||||
tokens
|
tokens
|
||||||
} else if let Some(id) = crate::families::variant(family_id, kind) {
|
} else if let Some(id) = crate::families::variant(family_id, kind) {
|
||||||
@@ -366,6 +370,19 @@ pub fn resolve_with_context(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let authored_base = tokens.colors.surface_base.clone();
|
let authored_base = tokens.colors.surface_base.clone();
|
||||||
|
let authored_accent = tokens.colors.accent.clone();
|
||||||
|
let preset_end = (!custom_variant)
|
||||||
|
.then(|| {
|
||||||
|
let id = crate::families::variant(family_id, kind).unwrap_or(if kind == Appearance::Light {
|
||||||
|
"ferese-blue-light"
|
||||||
|
} else {
|
||||||
|
"ferese-blue"
|
||||||
|
});
|
||||||
|
crate::presets::PRESETS.iter().find(|preset| preset.id == id)
|
||||||
|
})
|
||||||
|
.flatten()
|
||||||
|
.map(|preset| preset.gradient_end)
|
||||||
|
.filter(|end| *end != authored_accent);
|
||||||
let mut explicit_surfaces = std::collections::HashSet::new();
|
let mut explicit_surfaces = std::collections::HashSet::new();
|
||||||
if root
|
if root
|
||||||
.get("geometry")
|
.get("geometry")
|
||||||
@@ -378,7 +395,7 @@ pub fn resolve_with_context(
|
|||||||
{
|
{
|
||||||
tokens.geometry.shell_radius = radius;
|
tokens.geometry.shell_radius = radius;
|
||||||
}
|
}
|
||||||
let mut value = serde_json::to_value(&tokens).map_err(|e| e.to_string())?;
|
let mut value = token_value(&tokens)?;
|
||||||
for file in [&policy.file, &selection.file].into_iter().flatten() {
|
for file in [&policy.file, &selection.file].into_iter().flatten() {
|
||||||
let path = theme_path(directory, file);
|
let path = theme_path(directory, file);
|
||||||
if !all_files.contains(&path) {
|
if !all_files.contains(&path) {
|
||||||
@@ -406,7 +423,7 @@ pub fn resolve_with_context(
|
|||||||
merge(&mut value, &root, "", &mut warnings, true)?;
|
merge(&mut value, &root, "", &mut warnings, true)?;
|
||||||
let mut overrides = selection.overrides.clone();
|
let mut overrides = selection.overrides.clone();
|
||||||
if !split && policy.family.is_some() {
|
if !split && policy.family.is_some() {
|
||||||
for key in ["colors", "surface", "border", "focus_ring"] {
|
for key in ["colors", "surface"] {
|
||||||
overrides.remove(key);
|
overrides.remove(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -443,6 +460,7 @@ pub fn resolve_with_context(
|
|||||||
validate(&tokens)?;
|
validate(&tokens)?;
|
||||||
let requested_accent = tokens.colors.accent.clone();
|
let requested_accent = tokens.colors.accent.clone();
|
||||||
transform(&mut tokens, &policy.accessibility, &mut warnings);
|
transform(&mut tokens, &policy.accessibility, &mut warnings);
|
||||||
|
resolve_gradients(&mut tokens, preset_end.filter(|_| requested_accent == authored_accent));
|
||||||
let theme = ResolvedTheme {
|
let theme = ResolvedTheme {
|
||||||
appearance: kind,
|
appearance: kind,
|
||||||
tokens,
|
tokens,
|
||||||
@@ -489,6 +507,15 @@ pub fn theme_path(directory: &Path, path: &Path) -> PathBuf {
|
|||||||
path
|
path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn token_value(tokens: &Tokens) -> Result<Value, String> {
|
||||||
|
let mut value = serde_json::to_value(tokens).map_err(|error| error.to_string())?;
|
||||||
|
// Keep authoring defaults available for merging. The wire format omits
|
||||||
|
// automatic styles so older snapshots still round-trip unchanged.
|
||||||
|
value["focus_ring"]["style"] = serde_json::to_value(tokens.focus_ring.style).unwrap();
|
||||||
|
value["border"]["style"] = serde_json::to_value(tokens.border.style).unwrap();
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
fn merge(
|
fn merge(
|
||||||
target: &mut Value,
|
target: &mut Value,
|
||||||
layer: &Value,
|
layer: &Value,
|
||||||
@@ -664,7 +691,7 @@ fn validate(tokens: &Tokens) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
Value::Number(n) => {
|
Value::Number(n) => {
|
||||||
let n = n.as_f64().ok_or("invalid theme number")?;
|
let n = n.as_f64().ok_or("invalid theme number")?;
|
||||||
if !n.is_finite() || (path != ".shadow.soft.offset_y" && n < 0.) {
|
if !n.is_finite() || (path != ".shadow.soft.offset_y" && !path.ends_with(".angle") && n < 0.) {
|
||||||
return Err(format!("Invalid theme value {path}"));
|
return Err(format!("Invalid theme value {path}"));
|
||||||
}
|
}
|
||||||
if (path.ends_with("opacity") || path.ends_with("tint_strength")) && n > 1. {
|
if (path.ends_with("opacity") || path.ends_with("tint_strength")) && n > 1. {
|
||||||
@@ -776,6 +803,51 @@ fn transform(t: &mut Tokens, accessibility: &Accessibility, warnings: &mut Vec<S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_gradients(tokens: &mut Tokens, preset_end: Option<&str>) {
|
||||||
|
let shade = |color: &str| {
|
||||||
|
let source = rgba(color).expect("validated paint color");
|
||||||
|
let endpoint = if luminance(source) > 0.4 {
|
||||||
|
[0., 0., 0., source[3]]
|
||||||
|
} else {
|
||||||
|
[1., 1., 1., source[3]]
|
||||||
|
};
|
||||||
|
hex(blend(source, endpoint, 0.18))
|
||||||
|
};
|
||||||
|
|
||||||
|
let backgrounds = [
|
||||||
|
rgba(&tokens.colors.surface_base).unwrap(),
|
||||||
|
rgba(&tokens.colors.surface_raised).unwrap(),
|
||||||
|
rgba(&tokens.colors.application_background).unwrap(),
|
||||||
|
rgba(&tokens.surface.bar.background).unwrap(),
|
||||||
|
];
|
||||||
|
for (paint, color, endpoint, focus) in [
|
||||||
|
(&mut tokens.focus_ring, &tokens.colors.accent, preset_end, true),
|
||||||
|
(&mut tokens.border, &tokens.colors.border, None, false),
|
||||||
|
] {
|
||||||
|
if paint.style == PaintStyle::Solid {
|
||||||
|
paint.gradient = None;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(gradient) = &mut paint.gradient {
|
||||||
|
gradient.angle = gradient.angle.rem_euclid(360.);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let to = endpoint.map_or_else(|| shade(color), str::to_owned);
|
||||||
|
let to = if focus {
|
||||||
|
hex(readable_across(rgba(&to).unwrap(), &backgrounds, 3.))
|
||||||
|
} else {
|
||||||
|
to
|
||||||
|
};
|
||||||
|
paint.gradient = Some(Gradient {
|
||||||
|
from: color.clone(),
|
||||||
|
to,
|
||||||
|
angle: 135.,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn resolve_material_contrast(tokens: &mut Tokens, minimum: f64) {
|
fn resolve_material_contrast(tokens: &mut Tokens, minimum: f64) {
|
||||||
if tokens.material.style == "solid" {
|
if tokens.material.style == "solid" {
|
||||||
return;
|
return;
|
||||||
@@ -835,7 +907,7 @@ pub fn import_family(id: &str, source: &str) -> Result<ImportedFamily, String> {
|
|||||||
},
|
},
|
||||||
appearance,
|
appearance,
|
||||||
)?;
|
)?;
|
||||||
let mut value = serde_json::to_value(base).map_err(|e| e.to_string())?;
|
let mut value = token_value(&base)?;
|
||||||
merge(&mut value, layer, "", &mut warnings, false)?;
|
merge(&mut value, layer, "", &mut warnings, false)?;
|
||||||
let tokens: Tokens = serde_json::from_value(value).map_err(|e| e.to_string())?;
|
let tokens: Tokens = serde_json::from_value(value).map_err(|e| e.to_string())?;
|
||||||
validate(&tokens)?;
|
validate(&tokens)?;
|
||||||
@@ -890,6 +962,140 @@ mod tests {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn automatic_gradients_follow_presets_and_preserve_border_alpha() {
|
||||||
|
for preset in crate::presets::PRESETS {
|
||||||
|
let mode = if preset.appearance == Appearance::Light {
|
||||||
|
"light"
|
||||||
|
} else {
|
||||||
|
"dark"
|
||||||
|
};
|
||||||
|
let theme = candidate(&format!(
|
||||||
|
"theme {{ mode {mode}; family {}; }}",
|
||||||
|
crate::families::family_id(preset.id)
|
||||||
|
))
|
||||||
|
.unwrap()
|
||||||
|
.theme;
|
||||||
|
let gradient = theme.tokens.focus_ring.gradient.as_ref().unwrap();
|
||||||
|
assert_eq!(gradient.from, theme.tokens.colors.accent);
|
||||||
|
if preset.gradient_end != preset.accent {
|
||||||
|
let backgrounds = [
|
||||||
|
rgba(&theme.tokens.colors.surface_base).unwrap(),
|
||||||
|
rgba(&theme.tokens.colors.surface_raised).unwrap(),
|
||||||
|
rgba(&theme.tokens.colors.application_background).unwrap(),
|
||||||
|
rgba(&theme.tokens.surface.bar.background).unwrap(),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
gradient.to,
|
||||||
|
hex(readable_across(rgba(preset.gradient_end).unwrap(), &backgrounds, 3.))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
backgrounds
|
||||||
|
.iter()
|
||||||
|
.all(|background| contrast(rgba(&gradient.to).unwrap(), *background) >= 3.)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert_ne!(rgba(&gradient.from).unwrap(), rgba(&gradient.to).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
let border = theme.tokens.border.gradient.as_ref().unwrap();
|
||||||
|
let alpha = rgba(&theme.tokens.colors.border).unwrap()[3];
|
||||||
|
assert_eq!(rgba(&border.from).unwrap()[3], alpha);
|
||||||
|
assert_eq!(rgba(&border.to).unwrap()[3], alpha);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accent_overrides_and_imported_themes_do_not_keep_old_preset_endpoints() {
|
||||||
|
let theme = candidate("theme { family catppuccin; accent \"#E17B38\"; }")
|
||||||
|
.unwrap()
|
||||||
|
.theme;
|
||||||
|
let gradient = theme.tokens.focus_ring.gradient.as_ref().unwrap();
|
||||||
|
assert_eq!(gradient.from, theme.tokens.colors.accent);
|
||||||
|
assert_ne!(gradient.to, "#B4BEFE");
|
||||||
|
let from = rgba(&gradient.from).unwrap();
|
||||||
|
let to = rgba(&gradient.to).unwrap();
|
||||||
|
assert!(from[0] > from[1] && from[1] > from[2]);
|
||||||
|
assert!(to[0] > to[1] && to[1] > to[2]);
|
||||||
|
|
||||||
|
let document =
|
||||||
|
Document::parse("theme { family custom; custom-themes { custom { file \"custom.kdl\"; }; }; }").unwrap();
|
||||||
|
let theme = resolve(
|
||||||
|
&document,
|
||||||
|
Path::new("/config"),
|
||||||
|
"2026-09-30T12:00:00Z".parse().unwrap(),
|
||||||
|
|_| Ok("theme { dark { colors { accent \"#E17B38\"; }; }; }".into()),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.theme;
|
||||||
|
let gradient = theme.tokens.focus_ring.gradient.as_ref().unwrap();
|
||||||
|
assert_eq!(gradient.from, theme.tokens.colors.accent);
|
||||||
|
assert_eq!(
|
||||||
|
gradient.to,
|
||||||
|
candidate("theme { accent \"#E17B38\"; }")
|
||||||
|
.unwrap()
|
||||||
|
.theme
|
||||||
|
.tokens
|
||||||
|
.focus_ring
|
||||||
|
.gradient
|
||||||
|
.unwrap()
|
||||||
|
.to
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_paints_win_with_paired_families_and_solid_can_disable_them() {
|
||||||
|
let source = r##"theme {
|
||||||
|
family catppuccin
|
||||||
|
border { style solid; }
|
||||||
|
dark { focus-ring { gradient { from "#123456"; to "#ABCDEF80"; angle -45; }; }; }
|
||||||
|
}"##;
|
||||||
|
let theme = candidate(source).unwrap().theme;
|
||||||
|
let gradient = theme.tokens.focus_ring.gradient.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(gradient.from.as_str(), gradient.to.as_str(), gradient.angle),
|
||||||
|
("#123456", "#ABCDEF80", 315.)
|
||||||
|
);
|
||||||
|
assert!(theme.tokens.border.gradient.is_none());
|
||||||
|
|
||||||
|
let source = source.replace("family catppuccin", "family catppuccin\nfocus-ring { style solid; }");
|
||||||
|
let theme = candidate(&source).unwrap().theme;
|
||||||
|
assert!(theme.tokens.focus_ring.gradient.is_none());
|
||||||
|
assert_eq!(theme.tokens.focus_ring.style, PaintStyle::Solid);
|
||||||
|
assert!(candidate("theme { focus-ring { style rainbow; }; }").is_err());
|
||||||
|
assert!(candidate("theme { focus-ring { gradient { from \"bad\"; to \"#FFFFFF\"; }; }; }").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gradients_interpolate_stops_alpha_angles_and_solid_transitions() {
|
||||||
|
let mut from = default_theme();
|
||||||
|
let mut to = from.clone();
|
||||||
|
from.tokens.focus_ring.gradient = Some(Gradient {
|
||||||
|
from: "#FF000000".into(),
|
||||||
|
to: "#112233".into(),
|
||||||
|
angle: 350.,
|
||||||
|
});
|
||||||
|
to.tokens.focus_ring.gradient = Some(Gradient {
|
||||||
|
from: "#0000FFFF".into(),
|
||||||
|
to: "#ABCDEF".into(),
|
||||||
|
angle: 10.,
|
||||||
|
});
|
||||||
|
let frame = from.transition(&to, 0.5);
|
||||||
|
let gradient = frame.tokens.focus_ring.gradient.unwrap();
|
||||||
|
assert_eq!(gradient.from, "#0000FF80");
|
||||||
|
assert!(gradient.angle.abs() < 0.001);
|
||||||
|
assert_eq!(from.transition(&to, 0.), from);
|
||||||
|
assert_eq!(from.transition(&to, 1.), to);
|
||||||
|
|
||||||
|
to.tokens.focus_ring.style = PaintStyle::Solid;
|
||||||
|
to.tokens.focus_ring.gradient = None;
|
||||||
|
let middle = from.transition(&to, 0.5);
|
||||||
|
assert_eq!(middle.tokens.focus_ring.style, PaintStyle::Auto);
|
||||||
|
assert!(middle.tokens.focus_ring.gradient.is_some());
|
||||||
|
assert_eq!(from.transition(&to, 1.), to);
|
||||||
|
assert!(to.transition(&from, 0.5).tokens.focus_ring.gradient.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_bundled_pair_has_authored_materials_and_readable_tokens() {
|
fn every_bundled_pair_has_authored_materials_and_readable_tokens() {
|
||||||
use crate::presets::PRESETS;
|
use crate::presets::PRESETS;
|
||||||
|
|||||||
@@ -7,16 +7,40 @@ fn fixture() -> Value {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn default_snapshot_preserves_wire_values_and_environment_wallpaper() {
|
fn default_snapshot_resolves_gradients_and_preserves_other_wire_values() {
|
||||||
let mut snapshot = fallback();
|
let mut snapshot = fallback();
|
||||||
let wallpaper = Some(ferese_config::default_wallpaper().into());
|
let wallpaper = Some(ferese_config::default_wallpaper().into());
|
||||||
assert_eq!(snapshot.theme.tokens.background.path, wallpaper);
|
assert_eq!(snapshot.theme.tokens.background.path, wallpaper);
|
||||||
assert_eq!(snapshot.presented.tokens.background.path, wallpaper);
|
assert_eq!(snapshot.presented.tokens.background.path, wallpaper);
|
||||||
snapshot.theme.tokens.background.path = Some("<wallpaper>".into());
|
snapshot.theme.tokens.background.path = Some("<wallpaper>".into());
|
||||||
snapshot.presented.tokens.background.path = Some("<wallpaper>".into());
|
snapshot.presented.tokens.background.path = Some("<wallpaper>".into());
|
||||||
|
for theme in [&mut snapshot.theme, &mut snapshot.presented] {
|
||||||
|
let focus = theme.tokens.focus_ring.gradient.take().unwrap();
|
||||||
|
assert_eq!(focus.from, theme.tokens.colors.accent);
|
||||||
|
assert_ne!(focus.from, focus.to);
|
||||||
|
assert_eq!(focus.angle, 135.);
|
||||||
|
let border = theme.tokens.border.gradient.take().unwrap();
|
||||||
|
assert_eq!(border.from, theme.tokens.colors.border);
|
||||||
|
assert_eq!(
|
||||||
|
ferese_config::theme::rgba(&border.from).unwrap()[3],
|
||||||
|
ferese_config::theme::rgba(&border.to).unwrap()[3],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
assert_eq!(serde_json::to_value(snapshot).unwrap(), fixture());
|
assert_eq!(serde_json::to_value(snapshot).unwrap(), fixture());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_solid_style_round_trips_without_resolving_published_values() {
|
||||||
|
let mut value = fixture();
|
||||||
|
for key in ["theme", "presented"] {
|
||||||
|
value[key]["tokens"]["focus_ring"]["style"] = json!("solid");
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot = Snapshot::decode(value.clone(), || panic!("unexpected catalog resolution")).unwrap();
|
||||||
|
assert_eq!(serde_json::to_value(snapshot).unwrap(), value);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn older_snapshots_supply_catalog_but_explicit_empty_catalog_stays_empty() {
|
fn older_snapshots_supply_catalog_but_explicit_empty_catalog_stays_empty() {
|
||||||
let mut value = fixture();
|
let mut value = fixture();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::ResolvedTheme;
|
use crate::{Gradient, Paint, PaintStyle, ResolvedTheme};
|
||||||
|
|
||||||
pub fn rgba(s: &str) -> Result<[f64; 4], String> {
|
pub fn rgba(s: &str) -> Result<[f64; 4], String> {
|
||||||
let hex = s.strip_prefix('#').ok_or_else(|| format!("Invalid color: {s}"))?;
|
let hex = s.strip_prefix('#').ok_or_else(|| format!("Invalid color: {s}"))?;
|
||||||
@@ -123,6 +123,59 @@ fn material_foreground(preferred: [f64; 4], backgrounds: [[f64; 4]; 2]) -> [f64;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn transition_paint(from: &Paint, to: &Paint, from_solid: &str, to_solid: &str, p: f64) -> Paint {
|
||||||
|
if from.effective_gradient().is_none() && to.effective_gradient().is_none() {
|
||||||
|
return to.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let endpoints = |paint: &Paint, solid: &str| {
|
||||||
|
paint.effective_gradient().map_or_else(
|
||||||
|
|| (solid.to_owned(), solid.to_owned()),
|
||||||
|
|gradient| (gradient.from.clone(), gradient.to.clone()),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let (a, b) = endpoints(from, from_solid);
|
||||||
|
let (c, d) = endpoints(to, to_solid);
|
||||||
|
let color = |a: &str, b: &str| {
|
||||||
|
let mut a = rgba(a).unwrap();
|
||||||
|
let mut b = rgba(b).unwrap();
|
||||||
|
for channel in 0..3 {
|
||||||
|
a[channel] *= a[3];
|
||||||
|
b[channel] *= b[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut c = blend(a, b, p);
|
||||||
|
if c[3] > 0. {
|
||||||
|
for channel in 0..3 {
|
||||||
|
c[channel] /= c[3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hex(c)
|
||||||
|
};
|
||||||
|
let from_angle = from
|
||||||
|
.effective_gradient()
|
||||||
|
.or(to.effective_gradient())
|
||||||
|
.unwrap()
|
||||||
|
.angle
|
||||||
|
.rem_euclid(360.);
|
||||||
|
let to_angle = to
|
||||||
|
.effective_gradient()
|
||||||
|
.or(from.effective_gradient())
|
||||||
|
.unwrap()
|
||||||
|
.angle
|
||||||
|
.rem_euclid(360.);
|
||||||
|
let delta = (to_angle - from_angle + 180.).rem_euclid(360.) - 180.;
|
||||||
|
Paint {
|
||||||
|
style: PaintStyle::Auto,
|
||||||
|
gradient: Some(Gradient {
|
||||||
|
from: color(&a, &c),
|
||||||
|
to: color(&b, &d),
|
||||||
|
angle: (from_angle + delta * p).rem_euclid(360.),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ResolvedTheme {
|
impl ResolvedTheme {
|
||||||
/// Bound contrast across all backdrops instead of interpolating foregrounds.
|
/// Bound contrast across all backdrops instead of interpolating foregrounds.
|
||||||
pub fn transition(&self, to: &Self, progress: f64) -> Self {
|
pub fn transition(&self, to: &Self, progress: f64) -> Self {
|
||||||
@@ -145,6 +198,20 @@ impl ResolvedTheme {
|
|||||||
frame.tokens.colors.accent = mix(&self.tokens.colors.accent, &to.tokens.colors.accent);
|
frame.tokens.colors.accent = mix(&self.tokens.colors.accent, &to.tokens.colors.accent);
|
||||||
frame.tokens.colors.border = mix(&self.tokens.colors.border, &to.tokens.colors.border);
|
frame.tokens.colors.border = mix(&self.tokens.colors.border, &to.tokens.colors.border);
|
||||||
frame.tokens.colors.shadow = mix(&self.tokens.colors.shadow, &to.tokens.colors.shadow);
|
frame.tokens.colors.shadow = mix(&self.tokens.colors.shadow, &to.tokens.colors.shadow);
|
||||||
|
frame.tokens.focus_ring = transition_paint(
|
||||||
|
&self.tokens.focus_ring,
|
||||||
|
&to.tokens.focus_ring,
|
||||||
|
&self.tokens.colors.accent,
|
||||||
|
&to.tokens.colors.accent,
|
||||||
|
p,
|
||||||
|
);
|
||||||
|
frame.tokens.border = transition_paint(
|
||||||
|
&self.tokens.border,
|
||||||
|
&to.tokens.border,
|
||||||
|
&self.tokens.colors.border,
|
||||||
|
&to.tokens.colors.border,
|
||||||
|
p,
|
||||||
|
);
|
||||||
frame.tokens.surface.bar.background =
|
frame.tokens.surface.bar.background =
|
||||||
mix(&self.tokens.surface.bar.background, &to.tokens.surface.bar.background);
|
mix(&self.tokens.surface.bar.background, &to.tokens.surface.bar.background);
|
||||||
frame.tokens.material.opacity =
|
frame.tokens.material.opacity =
|
||||||
|
|||||||
@@ -107,11 +107,37 @@ pub struct Gradient {
|
|||||||
pub angle: f64,
|
pub angle: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum PaintStyle {
|
||||||
|
#[default]
|
||||||
|
Auto,
|
||||||
|
Solid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PaintStyle {
|
||||||
|
fn is_auto(&self) -> bool {
|
||||||
|
*self == Self::Auto
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
|
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
|
||||||
pub struct Paint {
|
pub struct Paint {
|
||||||
|
#[serde(default, skip_serializing_if = "PaintStyle::is_auto")]
|
||||||
|
pub style: PaintStyle,
|
||||||
pub gradient: Option<Gradient>,
|
pub gradient: Option<Gradient>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Paint {
|
||||||
|
pub fn effective_gradient(&self) -> Option<&Gradient> {
|
||||||
|
if self.style == PaintStyle::Solid {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
self.gradient.as_ref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||||
pub struct Tokens {
|
pub struct Tokens {
|
||||||
pub colors: Colors,
|
pub colors: Colors,
|
||||||
|
|||||||
@@ -176,10 +176,16 @@ fn styled_button(p: Palette, selected: bool, navigation: bool, opacity: f32, foc
|
|||||||
opacity
|
opacity
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let fill = if selected {
|
||||||
|
p.selected_background(if hover { 0.24 } else { 0.17 }, background.a, on, background)
|
||||||
|
} else {
|
||||||
|
Background::Color(background)
|
||||||
|
};
|
||||||
|
|
||||||
button::Style {
|
button::Style {
|
||||||
shape: Some(BorderShape::Continuous),
|
shape: Some(BorderShape::Continuous),
|
||||||
outline: None,
|
outline: None,
|
||||||
background: Some(Background::Color(background)),
|
background: Some(fill),
|
||||||
text_color: Some(on),
|
text_color: Some(on),
|
||||||
icon_color: Some(on),
|
icon_color: Some(on),
|
||||||
border_radius: p.radius.min(9.).into(),
|
border_radius: p.radius.min(9.).into(),
|
||||||
@@ -445,6 +451,7 @@ mod tests {
|
|||||||
sidebar: Color::from_rgb8(17, 24, 33),
|
sidebar: Color::from_rgb8(17, 24, 33),
|
||||||
card: Color::from_rgb8(17, 24, 33),
|
card: Color::from_rgb8(17, 24, 33),
|
||||||
accent: Color::from_rgb8(61, 123, 230),
|
accent: Color::from_rgb8(61, 123, 230),
|
||||||
|
accent_gradient: None,
|
||||||
on_accent: Color::from_rgb8(17, 24, 33),
|
on_accent: Color::from_rgb8(17, 24, 33),
|
||||||
text: Color::from_rgb8(244, 247, 251),
|
text: Color::from_rgb8(244, 247, 251),
|
||||||
muted: Color::from_rgb8(135, 147, 162),
|
muted: Color::from_rgb8(135, 147, 162),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use cosmic::iced::Color;
|
|
||||||
use cosmic::iced::border::Shape as BorderShape;
|
use cosmic::iced::border::Shape as BorderShape;
|
||||||
|
use cosmic::iced::{Background, Color, gradient::Linear};
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub struct Palette {
|
pub struct Palette {
|
||||||
@@ -9,6 +9,7 @@ pub struct Palette {
|
|||||||
pub sidebar: Color,
|
pub sidebar: Color,
|
||||||
pub card: Color,
|
pub card: Color,
|
||||||
pub accent: Color,
|
pub accent: Color,
|
||||||
|
pub accent_gradient: Option<Linear>,
|
||||||
pub on_accent: Color,
|
pub on_accent: Color,
|
||||||
pub text: Color,
|
pub text: Color,
|
||||||
pub muted: Color,
|
pub muted: Color,
|
||||||
@@ -26,6 +27,12 @@ impl Palette {
|
|||||||
sidebar: color(&theme.tokens.colors.surface_base),
|
sidebar: color(&theme.tokens.colors.surface_base),
|
||||||
card: color(&theme.tokens.colors.surface_raised),
|
card: color(&theme.tokens.colors.surface_raised),
|
||||||
accent: color(&theme.tokens.colors.accent),
|
accent: color(&theme.tokens.colors.accent),
|
||||||
|
accent_gradient: theme.tokens.focus_ring.effective_gradient().map(|gradient| {
|
||||||
|
// Iced's zero angle points up; theme angles start left-to-right.
|
||||||
|
Linear::new((gradient.angle.rem_euclid(360.) as f32 + 90.).to_radians())
|
||||||
|
.add_stop(0., color(&gradient.from))
|
||||||
|
.add_stop(1., color(&gradient.to))
|
||||||
|
}),
|
||||||
on_accent: color(&theme.tokens.colors.on_accent),
|
on_accent: color(&theme.tokens.colors.on_accent),
|
||||||
text: color(&theme.tokens.colors.text_primary),
|
text: color(&theme.tokens.colors.text_primary),
|
||||||
muted: color(&theme.tokens.colors.text_muted),
|
muted: color(&theme.tokens.colors.text_muted),
|
||||||
@@ -44,6 +51,52 @@ impl Palette {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn selected_background(
|
||||||
|
self,
|
||||||
|
strength: f32,
|
||||||
|
opacity: f32,
|
||||||
|
foreground: Color,
|
||||||
|
fallback: Color,
|
||||||
|
) -> Background {
|
||||||
|
let Some(mut gradient) = self.accent_gradient else {
|
||||||
|
return Background::Color(fallback);
|
||||||
|
};
|
||||||
|
|
||||||
|
for stop in gradient.stops.iter_mut().flatten() {
|
||||||
|
stop.color = mix(self.sidebar, crate::composite(stop.color, self.sidebar), strength);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bound every interpolated color, rather than sampling a few points.
|
||||||
|
// Adjust both stops together so one foreground stays readable.
|
||||||
|
let minimum = if self.high_contrast { 7. } else { 4.5 };
|
||||||
|
let shade = if crate::contrast(Color::BLACK, foreground) > crate::contrast(Color::WHITE, foreground) {
|
||||||
|
Color::BLACK
|
||||||
|
} else {
|
||||||
|
Color::WHITE
|
||||||
|
};
|
||||||
|
for step in 0..=20 {
|
||||||
|
let mut candidate = gradient;
|
||||||
|
for stop in candidate.stops.iter_mut().flatten() {
|
||||||
|
stop.color = mix(stop.color, shade, step as f32 / 20.);
|
||||||
|
stop.color.a = opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
let from = crate::composite(candidate.stops[0].unwrap().color, self.sidebar);
|
||||||
|
let to = crate::composite(candidate.stops[1].unwrap().color, self.sidebar);
|
||||||
|
let lower = Color::from_rgb(from.r.min(to.r), from.g.min(to.g), from.b.min(to.b));
|
||||||
|
let upper = Color::from_rgb(from.r.max(to.r), from.g.max(to.g), from.b.max(to.b));
|
||||||
|
let lightness = crate::luminance(foreground);
|
||||||
|
if (lightness < crate::luminance(lower) || lightness > crate::luminance(upper))
|
||||||
|
&& crate::contrast(lower, foreground) >= minimum
|
||||||
|
&& crate::contrast(upper, foreground) >= minimum
|
||||||
|
{
|
||||||
|
return candidate.into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Background::Color(fallback)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn native_theme(self) -> cosmic::Theme {
|
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 rgba = |c: Color| cosmic::cosmic_theme::palette::Srgba::new(c.r, c.g, c.b, 1.);
|
||||||
use cosmic::cosmic_theme::ThemeBuilder;
|
use cosmic::cosmic_theme::ThemeBuilder;
|
||||||
@@ -144,6 +197,74 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selected_gradients_preserve_material_alpha_and_text_contrast() {
|
||||||
|
use cosmic::widget::button::Catalog;
|
||||||
|
for preset in ferese_config::presets::PRESETS {
|
||||||
|
let mode = if preset.appearance == ferese_theme_model::Appearance::Light {
|
||||||
|
"light"
|
||||||
|
} else {
|
||||||
|
"dark"
|
||||||
|
};
|
||||||
|
let document = ferese_config::Document::parse(&format!(
|
||||||
|
"theme {{ mode {mode}; family {}; }}",
|
||||||
|
ferese_config::families::family_id(preset.id)
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let resolved = ferese_config::theme::resolve(
|
||||||
|
&document,
|
||||||
|
std::path::Path::new("/config"),
|
||||||
|
"2026-09-30T12:00:00Z".parse().unwrap(),
|
||||||
|
|_| unreachable!(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.theme;
|
||||||
|
let palette = Palette::from_resolved(&resolved);
|
||||||
|
let native = palette.native_theme();
|
||||||
|
for opacity in [1., 0.5] {
|
||||||
|
let class = crate::controls::material_button_style(palette, true, opacity);
|
||||||
|
for style in [native.active(false, true, &class), native.hovered(false, true, &class)] {
|
||||||
|
let Some(Background::Gradient(cosmic::iced::Gradient::Linear(gradient))) = style.background else {
|
||||||
|
panic!("{} selected control lost its gradient", preset.id);
|
||||||
|
};
|
||||||
|
let from = gradient.stops[0].unwrap().color;
|
||||||
|
let to = gradient.stops[1].unwrap().color;
|
||||||
|
assert_eq!(from.a, to.a);
|
||||||
|
assert!(if opacity == 1. { from.a == 1. } else { from.a < 1. });
|
||||||
|
for step in 0..=32 {
|
||||||
|
let fill = crate::composite(
|
||||||
|
Color {
|
||||||
|
a: from.a,
|
||||||
|
..mix(from, to, step as f32 / 32.)
|
||||||
|
},
|
||||||
|
palette.sidebar,
|
||||||
|
);
|
||||||
|
assert!(crate::contrast(fill, style.text_color.unwrap()) >= 4.5, "{}", preset.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let class = crate::controls::button_style(palette, false);
|
||||||
|
assert!(matches!(
|
||||||
|
native.active(false, false, &class).background,
|
||||||
|
Some(Background::Color(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gradient_angles_and_solid_paints_reach_the_control_palette() {
|
||||||
|
let mut resolved = ferese_config::theme::default_theme();
|
||||||
|
resolved.tokens.focus_ring.gradient.as_mut().unwrap().angle = 0.;
|
||||||
|
let palette = Palette::from_resolved(&resolved);
|
||||||
|
assert!((palette.accent_gradient.unwrap().angle.0 - std::f32::consts::FRAC_PI_2).abs() < 0.001);
|
||||||
|
resolved.tokens.focus_ring.style = ferese_theme_model::PaintStyle::Solid;
|
||||||
|
assert!(Palette::from_resolved(&resolved).accent_gradient.is_none());
|
||||||
|
resolved.tokens.focus_ring.style = ferese_theme_model::PaintStyle::Auto;
|
||||||
|
resolved.tokens.focus_ring.gradient = None;
|
||||||
|
assert!(Palette::from_resolved(&resolved).accent_gradient.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_preset_builds_an_opaque_control_palette_over_translucent_materials() {
|
fn every_preset_builds_an_opaque_control_palette_over_translucent_materials() {
|
||||||
for preset in ferese_config::presets::PRESETS {
|
for preset in ferese_config::presets::PRESETS {
|
||||||
|
|||||||
+25
-4
@@ -322,10 +322,22 @@ Filled controls choose a contrasting text color automatically.
|
|||||||
Solid materials omit blur; translucent materials blur behind the surface color.
|
Solid materials omit blur; translucent materials blur behind the surface color.
|
||||||
True fullscreen removes decorations. Focus transitions never scale the content.
|
True fullscreen removes decorations. Focus transitions never scale the content.
|
||||||
|
|
||||||
Optional `theme.focus-ring.gradient` and `theme.border.gradient` use the same
|
Window focus borders and selected controls use an accent gradient by default.
|
||||||
keys: required `from` and `to` colors, and `angle` (number, default `0`). Angles
|
Built-in themes supply the second color; when a theme has one accent color,
|
||||||
are clockwise: 0 is left-to-right, 90 top-to-bottom. Omit the gradient section
|
Ferese derives a nearby lighter or darker shade. Changing the accent replaces
|
||||||
for solid `accent` or `border` colors.
|
the automatic gradient. Inactive window borders derive their gradient from the
|
||||||
|
border color and keep its transparency.
|
||||||
|
Automatic accent endpoints are adjusted for contrast against the theme's surfaces.
|
||||||
|
|
||||||
|
In Settings → Appearance, choose **Gradient** or **Solid** for **Accent style**
|
||||||
|
and **Inactive border style**. In KDL, `theme.focus-ring.style` and
|
||||||
|
`theme.border.style` accept `"auto"` (default) or `"solid"`. Solid disables the
|
||||||
|
gradient, including any explicit endpoints, without deleting them from the config.
|
||||||
|
|
||||||
|
For custom endpoints, set `theme.focus-ring.gradient` or `theme.border.gradient`.
|
||||||
|
Both require `from` and `to` colors; `angle` is optional and defaults to `0`.
|
||||||
|
Angles are clockwise: 0 is left-to-right, 90 top-to-bottom. Explicit gradients
|
||||||
|
take precedence over automatically derived colors. The automatic angle is 135°.
|
||||||
|
|
||||||
```kdl
|
```kdl
|
||||||
theme {
|
theme {
|
||||||
@@ -339,6 +351,15 @@ theme {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For solid colors:
|
||||||
|
|
||||||
|
```kdl
|
||||||
|
theme {
|
||||||
|
focus-ring { style "solid"; }
|
||||||
|
border { style "solid"; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Workspaces
|
## Workspaces
|
||||||
|
|
||||||
Each monitor has an independent list of workspaces, numbered from 1. Its bar,
|
Each monitor has an independent list of workspaces, numbered from 1. Its bar,
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ impl Appearance {
|
|||||||
text: self.text,
|
text: self.text,
|
||||||
muted: self.text.scale_alpha(0.55),
|
muted: self.text.scale_alpha(0.55),
|
||||||
accent: self.accent,
|
accent: self.accent,
|
||||||
|
accent_gradient: None,
|
||||||
on_accent: self.on_accent,
|
on_accent: self.on_accent,
|
||||||
radius: self.radius,
|
radius: self.radius,
|
||||||
error: Color::from_rgb8(235, 98, 98),
|
error: Color::from_rgb8(235, 98, 98),
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ impl Appearance {
|
|||||||
text: self.text,
|
text: self.text,
|
||||||
muted: self.muted,
|
muted: self.muted,
|
||||||
accent: self.accent,
|
accent: self.accent,
|
||||||
|
accent_gradient: None,
|
||||||
on_accent: self.on_accent,
|
on_accent: self.on_accent,
|
||||||
radius: self.radius,
|
radius: self.radius,
|
||||||
error: Color::from_rgb8(235, 98, 98),
|
error: Color::from_rgb8(235, 98, 98),
|
||||||
|
|||||||
@@ -954,6 +954,14 @@ impl App {
|
|||||||
fn change(&mut self, edit: Edit) -> Task<Message> {
|
fn change(&mut self, edit: Edit) -> Task<Message> {
|
||||||
match self.draft.edit(&edit) {
|
match self.draft.edit(&edit) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
|
// A shared style selection applies to both appearances while
|
||||||
|
// retaining their authored gradient endpoints.
|
||||||
|
for unset in visuals::paint_style_overrides(&self.draft, &edit) {
|
||||||
|
if self.draft.edit(&unset).is_ok() {
|
||||||
|
self.pending.push(unset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// When a gradient exists, changing Accent also changes its
|
// When a gradient exists, changing Accent also changes its
|
||||||
// leading stop; otherwise the visible focus border would stay
|
// leading stop; otherwise the visible focus border would stay
|
||||||
// on the old palette despite the control saying it changed.
|
// on the old palette despite the control saying it changed.
|
||||||
|
|||||||
@@ -742,6 +742,20 @@ pub fn fields(page: Page) -> Vec<Field> {
|
|||||||
"Focus rings and selected controls. Use a hex color.",
|
"Focus rings and selected controls. Use a hex color.",
|
||||||
"#3D7BE6",
|
"#3D7BE6",
|
||||||
),
|
),
|
||||||
|
choice(
|
||||||
|
"theme.focus_ring.style",
|
||||||
|
"Accent style",
|
||||||
|
"Window focus borders and selected controls. Gradients use the theme colors unless overridden.",
|
||||||
|
"auto",
|
||||||
|
&[("auto", "Gradient"), ("solid", "Solid")],
|
||||||
|
),
|
||||||
|
choice(
|
||||||
|
"theme.border.style",
|
||||||
|
"Inactive border style",
|
||||||
|
"Gradients retain the border color's transparency.",
|
||||||
|
"auto",
|
||||||
|
&[("auto", "Gradient"), ("solid", "Solid")],
|
||||||
|
),
|
||||||
choice(
|
choice(
|
||||||
"theme.material.style",
|
"theme.material.style",
|
||||||
"Surface style",
|
"Surface style",
|
||||||
|
|||||||
@@ -137,6 +137,21 @@ pub fn toggle_split(snapshot: &Snapshot, enabled: bool, active: ferese_config::t
|
|||||||
edits
|
edits
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn paint_style_overrides(snapshot: &Snapshot, edit: &Edit) -> Vec<Edit> {
|
||||||
|
let Edit::Set(path, _) = edit else { return vec![] };
|
||||||
|
let section = match path.as_str() {
|
||||||
|
"theme.focus_ring.style" => "focus_ring",
|
||||||
|
"theme.border.style" => "border",
|
||||||
|
_ => return vec![],
|
||||||
|
};
|
||||||
|
["light", "dark"]
|
||||||
|
.into_iter()
|
||||||
|
.map(|kind| format!("theme.{kind}.{section}.style"))
|
||||||
|
.filter(|path| snapshot.item(path).is_some())
|
||||||
|
.map(Edit::Unset)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub fn preset(index: usize) -> Vec<Edit> {
|
pub fn preset(index: usize) -> Vec<Edit> {
|
||||||
let Some(preset) = PRESETS.get(index) else {
|
let Some(preset) = PRESETS.get(index) else {
|
||||||
@@ -244,6 +259,44 @@ mod tests {
|
|||||||
snapshot
|
snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_style_controls_replace_scoped_styles_without_deleting_endpoints() {
|
||||||
|
let mut snapshot = Snapshot::parse(
|
||||||
|
r##"theme {
|
||||||
|
family catppuccin
|
||||||
|
light { focus-ring { style solid; gradient { from "#123456"; to "#654321"; }; }; }
|
||||||
|
dark { focus-ring { style solid; gradient { from "#ABCDEF"; to "#FEDCBA"; }; }; }
|
||||||
|
}"##
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for (style, enabled) in [("auto", true), ("solid", false), ("auto", true)] {
|
||||||
|
let edit = set("theme.focus_ring.style", style);
|
||||||
|
for unset in paint_style_overrides(&snapshot, &edit) {
|
||||||
|
snapshot.edit(&unset).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot.edit(&edit).unwrap();
|
||||||
|
for (mode, from) in [("light", "#123456"), ("dark", "#ABCDEF")] {
|
||||||
|
snapshot.edit(&set("theme.mode", mode)).unwrap();
|
||||||
|
let theme = ferese_config::theme::resolve(
|
||||||
|
&snapshot.doc,
|
||||||
|
std::path::Path::new("/config"),
|
||||||
|
"2026-09-30T12:00:00Z".parse().unwrap(),
|
||||||
|
|_| unreachable!(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.theme;
|
||||||
|
assert_eq!(theme.tokens.focus_ring.gradient.is_some(), enabled);
|
||||||
|
if enabled {
|
||||||
|
assert_eq!(theme.tokens.focus_ring.gradient.unwrap().from, from);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(snapshot.item(&format!("theme.{mode}.focus_ring.gradient")).is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn contrast(a: Color, b: Color) -> f32 {
|
fn contrast(a: Color, b: Color) -> f32 {
|
||||||
ferese_theme::contrast(a, b)
|
ferese_theme::contrast(a, b)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ pub(crate) struct ShellTheme {
|
|||||||
pub(crate) text_primary: [u8; 4],
|
pub(crate) text_primary: [u8; 4],
|
||||||
pub(crate) text_muted: [u8; 4],
|
pub(crate) text_muted: [u8; 4],
|
||||||
pub(crate) accent: [u8; 4],
|
pub(crate) accent: [u8; 4],
|
||||||
|
pub(crate) accent_gradient: Option<cosmic::iced::gradient::Linear>,
|
||||||
pub(crate) on_accent: [u8; 4],
|
pub(crate) on_accent: [u8; 4],
|
||||||
pub(crate) border: [u8; 4],
|
pub(crate) border: [u8; 4],
|
||||||
pub(crate) shadow: [u8; 4],
|
pub(crate) shadow: [u8; 4],
|
||||||
@@ -80,6 +81,7 @@ impl Default for ShellTheme {
|
|||||||
text_primary: [244, 247, 251, 255],
|
text_primary: [244, 247, 251, 255],
|
||||||
text_muted: [135, 147, 162, 255],
|
text_muted: [135, 147, 162, 255],
|
||||||
accent: [61, 123, 230, 255],
|
accent: [61, 123, 230, 255],
|
||||||
|
accent_gradient: None,
|
||||||
on_accent: [244, 247, 251, 255],
|
on_accent: [244, 247, 251, 255],
|
||||||
border: [255, 255, 255, 24],
|
border: [255, 255, 255, 24],
|
||||||
shadow: [0, 0, 0, 85],
|
shadow: [0, 0, 0, 85],
|
||||||
@@ -111,6 +113,7 @@ impl ShellTheme {
|
|||||||
text: color(self.text_primary),
|
text: color(self.text_primary),
|
||||||
muted: color(self.text_muted),
|
muted: color(self.text_muted),
|
||||||
accent: color(self.accent),
|
accent: color(self.accent),
|
||||||
|
accent_gradient: self.accent_gradient,
|
||||||
on_accent: color(self.on_accent),
|
on_accent: color(self.on_accent),
|
||||||
radius: self.material_radius,
|
radius: self.material_radius,
|
||||||
error: cosmic::iced::Color::from_rgb8(235, 98, 98),
|
error: cosmic::iced::Color::from_rgb8(235, 98, 98),
|
||||||
@@ -401,6 +404,7 @@ impl ShellConfig {
|
|||||||
self.theme = shell_theme(&config);
|
self.theme = shell_theme(&config);
|
||||||
self.theme.appearance = theme.appearance;
|
self.theme.appearance = theme.appearance;
|
||||||
self.theme.high_contrast = theme.accessibility.increase_contrast;
|
self.theme.high_contrast = theme.accessibility.increase_contrast;
|
||||||
|
self.theme.accent_gradient = ferese_theme::Palette::from_resolved(theme).accent_gradient;
|
||||||
self.theme.material_radius = theme.tokens.geometry.shell_radius as f32;
|
self.theme.material_radius = theme.tokens.geometry.shell_radius as f32;
|
||||||
self.theme.bar_radius = self.theme.material_radius;
|
self.theme.bar_radius = self.theme.material_radius;
|
||||||
self.font_family = Some(theme.tokens.typography.font_family.clone());
|
self.font_family = Some(theme.tokens.typography.font_family.clone());
|
||||||
@@ -447,6 +451,7 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
|
|||||||
text_primary: parse_color(&theme.colors.text_primary).unwrap_or(defaults.text_primary),
|
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),
|
text_muted: parse_color(&theme.colors.text_muted).unwrap_or(defaults.text_muted),
|
||||||
accent: parse_color(&theme.colors.accent).unwrap_or(defaults.accent),
|
accent: parse_color(&theme.colors.accent).unwrap_or(defaults.accent),
|
||||||
|
accent_gradient: defaults.accent_gradient,
|
||||||
on_accent: parse_color(&theme.colors.on_accent).unwrap_or(defaults.on_accent),
|
on_accent: parse_color(&theme.colors.on_accent).unwrap_or(defaults.on_accent),
|
||||||
border: parse_color(&theme.colors.border).unwrap_or(defaults.border),
|
border: parse_color(&theme.colors.border).unwrap_or(defaults.border),
|
||||||
shadow: parse_color(&theme.colors.shadow).unwrap_or(defaults.shadow),
|
shadow: parse_color(&theme.colors.shadow).unwrap_or(defaults.shadow),
|
||||||
|
|||||||
+6
-2
@@ -80,12 +80,16 @@
|
|||||||
<article>
|
<article>
|
||||||
<h2>Appearance</h2>
|
<h2>Appearance</h2>
|
||||||
<p>
|
<p>
|
||||||
Choose your theme, wallpaper, colors, and motion. Change settings
|
Choose your theme, wallpaper, and colors through Settings or a KDL config, and see changes as you make them.
|
||||||
through the app or a KDL config, with live reload.
|
|
||||||
</p>
|
</p>
|
||||||
<a href="docs/configuration.html#appearance">Customize Ferese →</a>
|
<a href="docs/configuration.html#appearance">Customize Ferese →</a>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="product-motion page-width" aria-labelledby="continuum-heading">
|
||||||
|
<h2 id="continuum-heading">Continuum motion</h2>
|
||||||
|
<p>Continuum carries your gesture’s momentum into workspace transitions and keeps motion continuous when you change direction. Adjust spring response and speed to your taste, or enable reduced motion.</p>
|
||||||
|
<a href="docs/animation-model.html">Explore the animation model →</a>
|
||||||
|
</section>
|
||||||
<section class="product-getting-started page-width">
|
<section class="product-getting-started page-width">
|
||||||
<h2>Getting started</h2>
|
<h2>Getting started</h2>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
+5
-1
@@ -1078,6 +1078,7 @@ kbd {
|
|||||||
padding-block: 40px;
|
padding-block: 40px;
|
||||||
}
|
}
|
||||||
.product-features h2,
|
.product-features h2,
|
||||||
|
.product-motion h2,
|
||||||
.product-getting-started h2 {
|
.product-getting-started h2 {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -1085,18 +1086,21 @@ kbd {
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
.product-features p,
|
.product-features p,
|
||||||
|
.product-motion p,
|
||||||
.product-getting-started p {
|
.product-getting-started p {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
}
|
}
|
||||||
.product-features a {
|
.product-features a,
|
||||||
|
.product-motion a {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--blue);
|
color: var(--blue);
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
.product-motion,
|
||||||
.product-getting-started {
|
.product-getting-started {
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
padding-block: 28px 42px;
|
padding-block: 28px 42px;
|
||||||
|
|||||||
Reference in new issue
Block a user