diff --git a/README.md b/README.md index 5348b34..65e517d 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/compositor/src/config.rs b/compositor/src/config.rs index a7d49f1..08499f0 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -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 [ diff --git a/compositor/src/config/appearance.rs b/compositor/src/config/appearance.rs index 7e247e1..ee1a09c 100644 --- a/compositor/src/config/appearance.rs +++ b/compositor/src/config/appearance.rs @@ -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, } @@ -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 + }) } } diff --git a/crates/ferese-config/src/theme.rs b/crates/ferese-config/src/theme.rs index 2682bf9..cb64fff 100644 --- a/crates/ferese-config/src/theme.rs +++ b/crates/ferese-config/src/theme.rs @@ -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 { + 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) { + 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 { }, 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; diff --git a/crates/ferese-theme-client/tests/theme_wire.rs b/crates/ferese-theme-client/tests/theme_wire.rs index 4d3a508..ba717e9 100644 --- a/crates/ferese-theme-client/tests/theme_wire.rs +++ b/crates/ferese-theme-client/tests/theme_wire.rs @@ -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("".into()); snapshot.presented.tokens.background.path = Some("".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(); diff --git a/crates/ferese-theme-model/src/color.rs b/crates/ferese-theme-model/src/color.rs index 9cacd2d..7dce953 100644 --- a/crates/ferese-theme-model/src/color.rs +++ b/crates/ferese-theme-model/src/color.rs @@ -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 = diff --git a/crates/ferese-theme-model/src/lib.rs b/crates/ferese-theme-model/src/lib.rs index cb68099..6d40034 100644 --- a/crates/ferese-theme-model/src/lib.rs +++ b/crates/ferese-theme-model/src/lib.rs @@ -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, } +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, diff --git a/crates/ferese-theme/src/controls.rs b/crates/ferese-theme/src/controls.rs index 77fb528..df97eff 100644 --- a/crates/ferese-theme/src/controls.rs +++ b/crates/ferese-theme/src/controls.rs @@ -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), diff --git a/crates/ferese-theme/src/palette.rs b/crates/ferese-theme/src/palette.rs index 69533c7..e8daa7e 100644 --- a/crates/ferese-theme/src/palette.rs +++ b/crates/ferese-theme/src/palette.rs @@ -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, 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 { diff --git a/docs/configuration.md b/docs/configuration.md index fad2f7b..6a83efe 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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, diff --git a/shell/ferese-lock/src/appearance.rs b/shell/ferese-lock/src/appearance.rs index 09891f0..aab0e41 100644 --- a/shell/ferese-lock/src/appearance.rs +++ b/shell/ferese-lock/src/appearance.rs @@ -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), diff --git a/shell/ferese-polkit-agent/src/prompt.rs b/shell/ferese-polkit-agent/src/prompt.rs index 6658e57..5eb47f0 100644 --- a/shell/ferese-polkit-agent/src/prompt.rs +++ b/shell/ferese-polkit-agent/src/prompt.rs @@ -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), diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index 0d23334..8b6e5cb 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -954,6 +954,14 @@ impl App { fn change(&mut self, edit: Edit) -> Task { 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. diff --git a/shell/ferese-settings/src/schema.rs b/shell/ferese-settings/src/schema.rs index 0a89037..617de8f 100644 --- a/shell/ferese-settings/src/schema.rs +++ b/shell/ferese-settings/src/schema.rs @@ -742,6 +742,20 @@ pub fn fields(page: Page) -> Vec { "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", diff --git a/shell/ferese-settings/src/visuals.rs b/shell/ferese-settings/src/visuals.rs index 6feee3f..829486e 100644 --- a/shell/ferese-settings/src/visuals.rs +++ b/shell/ferese-settings/src/visuals.rs @@ -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 { + 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 { 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) } diff --git a/shell/ferese-shell/src/config.rs b/shell/ferese-shell/src/config.rs index 18bf03b..95f8d30 100644 --- a/shell/ferese-shell/src/config.rs +++ b/shell/ferese-shell/src/config.rs @@ -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, 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), diff --git a/site/index.html b/site/index.html index 7ae34e2..a386934 100644 --- a/site/index.html +++ b/site/index.html @@ -80,12 +80,16 @@

Appearance

- 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.

Customize Ferese →
+
+

Continuum motion

+

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.

+ Explore the animation model → +

Getting started

diff --git a/site/styles.css b/site/styles.css index 6ab9fb0..5a8bb73 100644 --- a/site/styles.css +++ b/site/styles.css @@ -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;