Derive theme gradients and add solid paint controls

This commit is contained in:
Samuel Onoja committed 2026-10-04 09:03:28 +01:00
1 parent 20395007a5
commit 831ac3a77f
18 files changed
+604 -20

No files matched your search

+2 -1
View File
@@ -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
- 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
- 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
- Screenshots with annotation, screen sharing and built-in recording
- 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 |
| 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) |
| 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 |
| 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) |
+12
View File
@@ -717,6 +717,18 @@ mod tests {
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]
fn border_gradients_reject_invalid_colors_and_nonfinite_angles() {
for settings in [
+11 -2
View File
@@ -165,6 +165,8 @@ pub struct BorderGradient {
#[derive(Debug, Default, Deserialize)]
pub(super) struct BorderPaintConfig {
#[serde(default)]
pub(super) style: ferese_config::theme::PaintStyle,
pub(super) gradient: Option<BorderGradientConfig>,
}
@@ -187,7 +189,8 @@ impl BorderPaintConfig {
} else {
("border.gradient.from", "border.gradient.to", "border.gradient.angle")
};
self.gradient
let gradient = self
.gradient
.as_ref()
.map(|gradient| {
Ok(BorderGradient {
@@ -196,7 +199,13 @@ impl BorderPaintConfig {
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
})
}
}
+212 -6
View File
@@ -213,16 +213,19 @@ pub fn default_tokens() -> Tokens {
"background":{"path":crate::default_wallpaper(),"lock_path":null,"mode":"fill"},
"surface":{"bar":{"background":"#111821","text_primary":"#F4F7FB","text_muted":"#8793A2"}},
"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")
}
pub fn default_theme() -> ResolvedTheme {
let mut tokens = default_tokens();
resolve_gradients(&mut tokens, None);
ResolvedTheme {
appearance: Appearance::Dark,
requested_accent: "#3D7BE6".into(),
tokens: default_tokens(),
tokens,
accessibility: Accessibility::default(),
reduced_motion: false,
}
@@ -323,6 +326,7 @@ pub fn resolve_with_context(
pair.1.clone()
}
});
let custom_variant = variant.is_some();
let mut tokens = if let Some(tokens) = variant {
tokens
} 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_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();
if root
.get("geometry")
@@ -378,7 +395,7 @@ pub fn resolve_with_context(
{
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() {
let path = theme_path(directory, file);
if !all_files.contains(&path) {
@@ -406,7 +423,7 @@ pub fn resolve_with_context(
merge(&mut value, &root, "", &mut warnings, true)?;
let mut overrides = selection.overrides.clone();
if !split && policy.family.is_some() {
for key in ["colors", "surface", "border", "focus_ring"] {
for key in ["colors", "surface"] {
overrides.remove(key);
}
}
@@ -443,6 +460,7 @@ pub fn resolve_with_context(
validate(&tokens)?;
let requested_accent = tokens.colors.accent.clone();
transform(&mut tokens, &policy.accessibility, &mut warnings);
resolve_gradients(&mut tokens, preset_end.filter(|_| requested_accent == authored_accent));
let theme = ResolvedTheme {
appearance: kind,
tokens,
@@ -489,6 +507,15 @@ pub fn theme_path(directory: &Path, path: &Path) -> PathBuf {
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(
target: &mut Value,
layer: &Value,
@@ -664,7 +691,7 @@ fn validate(tokens: &Tokens) -> Result<(), String> {
}
Value::Number(n) => {
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}"));
}
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) {
if tokens.material.style == "solid" {
return;
@@ -835,7 +907,7 @@ pub fn import_family(id: &str, source: &str) -> Result<ImportedFamily, String> {
},
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)?;
let tokens: Tokens = serde_json::from_value(value).map_err(|e| e.to_string())?;
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]
fn every_bundled_pair_has_authored_materials_and_readable_tokens() {
use crate::presets::PRESETS;
+25 -1
View File
@@ -7,16 +7,40 @@ fn fixture() -> Value {
}
#[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 wallpaper = Some(ferese_config::default_wallpaper().into());
assert_eq!(snapshot.theme.tokens.background.path, wallpaper);
assert_eq!(snapshot.presented.tokens.background.path, wallpaper);
snapshot.theme.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());
}
#[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]
fn older_snapshots_supply_catalog_but_explicit_empty_catalog_stays_empty() {
let mut value = fixture();
+68 -1
View File
@@ -1,4 +1,4 @@
use crate::ResolvedTheme;
use crate::{Gradient, Paint, PaintStyle, ResolvedTheme};
pub fn rgba(s: &str) -> Result<[f64; 4], String> {
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 {
/// Bound contrast across all backdrops instead of interpolating foregrounds.
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.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.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 =
mix(&self.tokens.surface.bar.background, &to.tokens.surface.bar.background);
frame.tokens.material.opacity =
+26
View File
@@ -107,11 +107,37 @@ pub struct Gradient {
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)]
pub struct Paint {
#[serde(default, skip_serializing_if = "PaintStyle::is_auto")]
pub style: PaintStyle,
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)]
pub struct Tokens {
pub colors: Colors,
+8 -1
View File
@@ -176,10 +176,16 @@ fn styled_button(p: Palette, selected: bool, navigation: bool, opacity: f32, foc
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 {
shape: Some(BorderShape::Continuous),
outline: None,
background: Some(Background::Color(background)),
background: Some(fill),
text_color: Some(on),
icon_color: Some(on),
border_radius: p.radius.min(9.).into(),
@@ -445,6 +451,7 @@ mod tests {
sidebar: Color::from_rgb8(17, 24, 33),
card: Color::from_rgb8(17, 24, 33),
accent: Color::from_rgb8(61, 123, 230),
accent_gradient: None,
on_accent: Color::from_rgb8(17, 24, 33),
text: Color::from_rgb8(244, 247, 251),
muted: Color::from_rgb8(135, 147, 162),
+122 -1
View File
@@ -1,5 +1,5 @@
use cosmic::iced::Color;
use cosmic::iced::border::Shape as BorderShape;
use cosmic::iced::{Background, Color, gradient::Linear};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Palette {
@@ -9,6 +9,7 @@ pub struct Palette {
pub sidebar: Color,
pub card: Color,
pub accent: Color,
pub accent_gradient: Option<Linear>,
pub on_accent: Color,
pub text: Color,
pub muted: Color,
@@ -26,6 +27,12 @@ impl Palette {
sidebar: color(&theme.tokens.colors.surface_base),
card: color(&theme.tokens.colors.surface_raised),
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),
text: color(&theme.tokens.colors.text_primary),
muted: color(&theme.tokens.colors.text_muted),
@@ -44,6 +51,52 @@ impl Palette {
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 {
let rgba = |c: Color| cosmic::cosmic_theme::palette::Srgba::new(c.r, c.g, c.b, 1.);
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]
fn every_preset_builds_an_opaque_control_palette_over_translucent_materials() {
for preset in ferese_config::presets::PRESETS {
+25 -4
View File
@@ -322,10 +322,22 @@ Filled controls choose a contrasting text color automatically.
Solid materials omit blur; translucent materials blur behind the surface color.
True fullscreen removes decorations. Focus transitions never scale the content.
Optional `theme.focus-ring.gradient` and `theme.border.gradient` use the same
keys: required `from` and `to` colors, and `angle` (number, default `0`). Angles
are clockwise: 0 is left-to-right, 90 top-to-bottom. Omit the gradient section
for solid `accent` or `border` colors.
Window focus borders and selected controls use an accent gradient by default.
Built-in themes supply the second color; when a theme has one accent color,
Ferese derives a nearby lighter or darker shade. Changing the accent replaces
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
theme {
@@ -339,6 +351,15 @@ theme {
}
```
For solid colors:
```kdl
theme {
focus-ring { style "solid"; }
border { style "solid"; }
}
```
## Workspaces
Each monitor has an independent list of workspaces, numbered from 1. Its bar,
+1
View File
@@ -126,6 +126,7 @@ impl Appearance {
text: self.text,
muted: self.text.scale_alpha(0.55),
accent: self.accent,
accent_gradient: None,
on_accent: self.on_accent,
radius: self.radius,
error: Color::from_rgb8(235, 98, 98),
+1
View File
@@ -59,6 +59,7 @@ impl Appearance {
text: self.text,
muted: self.muted,
accent: self.accent,
accent_gradient: None,
on_accent: self.on_accent,
radius: self.radius,
error: Color::from_rgb8(235, 98, 98),
+8
View File
@@ -954,6 +954,14 @@ impl App {
fn change(&mut self, edit: Edit) -> Task<Message> {
match self.draft.edit(&edit) {
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
// leading stop; otherwise the visible focus border would stay
// on the old palette despite the control saying it changed.
+14
View File
@@ -742,6 +742,20 @@ pub fn fields(page: Page) -> Vec<Field> {
"Focus rings and selected controls. Use a hex color.",
"#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(
"theme.material.style",
"Surface style",
+53
View File
@@ -137,6 +137,21 @@ pub fn toggle_split(snapshot: &Snapshot, enabled: bool, active: ferese_config::t
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)]
pub fn preset(index: usize) -> Vec<Edit> {
let Some(preset) = PRESETS.get(index) else {
@@ -244,6 +259,44 @@ mod tests {
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 {
ferese_theme::contrast(a, b)
}
+5
View File
@@ -51,6 +51,7 @@ pub(crate) struct ShellTheme {
pub(crate) text_primary: [u8; 4],
pub(crate) text_muted: [u8; 4],
pub(crate) accent: [u8; 4],
pub(crate) accent_gradient: Option<cosmic::iced::gradient::Linear>,
pub(crate) on_accent: [u8; 4],
pub(crate) border: [u8; 4],
pub(crate) shadow: [u8; 4],
@@ -80,6 +81,7 @@ impl Default for ShellTheme {
text_primary: [244, 247, 251, 255],
text_muted: [135, 147, 162, 255],
accent: [61, 123, 230, 255],
accent_gradient: None,
on_accent: [244, 247, 251, 255],
border: [255, 255, 255, 24],
shadow: [0, 0, 0, 85],
@@ -111,6 +113,7 @@ impl ShellTheme {
text: color(self.text_primary),
muted: color(self.text_muted),
accent: color(self.accent),
accent_gradient: self.accent_gradient,
on_accent: color(self.on_accent),
radius: self.material_radius,
error: cosmic::iced::Color::from_rgb8(235, 98, 98),
@@ -401,6 +404,7 @@ impl ShellConfig {
self.theme = shell_theme(&config);
self.theme.appearance = theme.appearance;
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.bar_radius = self.theme.material_radius;
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_muted: parse_color(&theme.colors.text_muted).unwrap_or(defaults.text_muted),
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),
border: parse_color(&theme.colors.border).unwrap_or(defaults.border),
shadow: parse_color(&theme.colors.shadow).unwrap_or(defaults.shadow),
+6 -2
View File
@@ -80,12 +80,16 @@
<article>
<h2>Appearance</h2>
<p>
Choose your theme, wallpaper, colors, and motion. Change settings
through the app or a KDL config, with live reload.
Choose your theme, wallpaper, and colors through Settings or a KDL config, and see changes as you make them.
</p>
<a href="docs/configuration.html#appearance">Customize Ferese →</a>
</article>
</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">
<h2>Getting started</h2>
<p>
+5 -1
View File
@@ -1078,6 +1078,7 @@ kbd {
padding-block: 40px;
}
.product-features h2,
.product-motion h2,
.product-getting-started h2 {
font-size: 20px;
font-weight: 600;
@@ -1085,18 +1086,21 @@ kbd {
line-height: 1.4;
}
.product-features p,
.product-motion p,
.product-getting-started p {
font-size: 14px;
color: var(--muted);
margin-top: 12px;
line-height: 1.8;
}
.product-features a {
.product-features a,
.product-motion a {
display: inline-block;
font-size: 12px;
color: var(--blue);
margin-top: 12px;
}
.product-motion,
.product-getting-started {
border-top: 1px solid var(--line);
padding-block: 28px 42px;