feat: render configurable window shadows

This commit is contained in:
Samuel Onoja committed 2026-09-25 03:39:03 +01:00
1 parent b9f0ab044e
commit b8d6c9b02a
4 files changed
+312 -11

No files matched your search

+89 -1
View File
@@ -43,7 +43,11 @@ pub struct ThemeSettings {
pub focus_ring_width: f64,
pub border_color: RgbaColor,
pub accent_color: RgbaColor,
pub shadow_color: RgbaColor,
pub window_radius: f64,
pub shadow_offset_y: f64,
pub shadow_blur: f64,
pub shadow_opacity: f64,
}
#[derive(Debug, Default, Deserialize)]
@@ -52,6 +56,8 @@ struct ThemeConfig {
colors: ThemeColorsConfig,
#[serde(default)]
geometry: ThemeGeometryConfig,
#[serde(default)]
shadow: ThemeShadowConfig,
}
#[derive(Debug, Deserialize)]
@@ -60,6 +66,8 @@ struct ThemeColorsConfig {
border: String,
#[serde(default = "default_accent_color")]
accent: String,
#[serde(default = "default_shadow_color")]
shadow: String,
}
impl Default for ThemeColorsConfig {
@@ -67,6 +75,33 @@ impl Default for ThemeColorsConfig {
Self {
border: default_border_color(),
accent: default_accent_color(),
shadow: default_shadow_color(),
}
}
}
#[derive(Debug, Default, Deserialize)]
struct ThemeShadowConfig {
#[serde(default)]
soft: SoftShadowConfig,
}
#[derive(Debug, Deserialize)]
struct SoftShadowConfig {
#[serde(default = "default_shadow_offset_y")]
offset_y: f64,
#[serde(default = "default_shadow_blur")]
blur: f64,
#[serde(default = "default_shadow_opacity")]
opacity: f64,
}
impl Default for SoftShadowConfig {
fn default() -> Self {
Self {
offset_y: default_shadow_offset_y(),
blur: default_shadow_blur(),
opacity: default_shadow_opacity(),
}
}
}
@@ -596,13 +631,22 @@ impl Config {
)?;
let window_radius =
nonnegative_theme_value(self.theme.geometry.window_radius, "geometry.window_radius")?;
let shadow_offset_y =
finite_theme_value(self.theme.shadow.soft.offset_y, "shadow.soft.offset_y")?;
let shadow_blur = nonnegative_theme_value(self.theme.shadow.soft.blur, "shadow.soft.blur")?;
let shadow_opacity =
unit_theme_value(self.theme.shadow.soft.opacity, "shadow.soft.opacity")?;
Ok(ThemeSettings {
border_width,
focus_ring_width,
border_color: parse_color(&self.theme.colors.border, "colors.border")?,
accent_color: parse_color(&self.theme.colors.accent, "colors.accent")?,
shadow_color: parse_color(&self.theme.colors.shadow, "colors.shadow")?,
window_radius,
shadow_offset_y,
shadow_blur,
shadow_opacity,
})
}
@@ -1005,6 +1049,28 @@ fn nonnegative_theme_value(value: f64, field: &'static str) -> Result<f64, Confi
}
}
fn finite_theme_value(value: f64, field: &'static str) -> Result<f64, ConfigError> {
if value.is_finite() {
Ok(value)
} else {
Err(ConfigError::InvalidThemeValue {
field,
value: value.to_string(),
})
}
}
fn unit_theme_value(value: f64, field: &'static str) -> Result<f64, ConfigError> {
if value.is_finite() && (0.0..=1.0).contains(&value) {
Ok(value)
} else {
Err(ConfigError::InvalidThemeValue {
field,
value: value.to_string(),
})
}
}
fn parse_color(value: &str, field: &'static str) -> Result<RgbaColor, ConfigError> {
let digits = value.strip_prefix('#').unwrap_or(value);
if !digits.is_ascii() || !matches!(digits.len(), 6 | 8) {
@@ -1062,6 +1128,10 @@ fn default_accent_color() -> String {
"#5B8CFF".to_owned()
}
fn default_shadow_color() -> String {
"#00000055".to_owned()
}
const fn default_border_width() -> f64 {
1.0
}
@@ -1074,6 +1144,18 @@ const fn default_window_radius() -> f64 {
14.0
}
const fn default_shadow_offset_y() -> f64 {
4.0
}
const fn default_shadow_blur() -> f64 {
18.0
}
const fn default_shadow_opacity() -> f64 {
0.20
}
fn default_xkb_layout() -> String {
"us".to_owned()
}
@@ -1265,10 +1347,11 @@ mod tests {
#[test]
fn parses_and_validates_theme_window_tokens() {
let configured = parse(
"[theme.colors]\nborder = \"#11223344\"\naccent = \"#AABBCC\"\n\n[theme.geometry]\nborder_width = 1.5\nfocus_ring_width = 3.0\nwindow_radius = 12.0",
"[theme.colors]\nborder = \"#11223344\"\naccent = \"#AABBCC\"\nshadow = \"#01020380\"\n\n[theme.geometry]\nborder_width = 1.5\nfocus_ring_width = 3.0\nwindow_radius = 12.0\n\n[theme.shadow.soft]\noffset_y = -2.0\nblur = 24.0\nopacity = 0.4",
);
let invalid_color = parse("[theme.colors]\naccent = \"blue\"");
let invalid_radius = parse("[theme.geometry]\nwindow_radius = -1.0");
let invalid_opacity = parse("[theme.shadow.soft]\nopacity = 1.1");
assert_eq!(
configured.theme_settings().unwrap(),
@@ -1277,11 +1360,16 @@ mod tests {
focus_ring_width: 3.0,
border_color: RgbaColor([17.0 / 255.0, 34.0 / 255.0, 51.0 / 255.0, 68.0 / 255.0,]),
accent_color: RgbaColor([170.0 / 255.0, 187.0 / 255.0, 0.8, 1.0]),
shadow_color: RgbaColor([1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 128.0 / 255.0]),
window_radius: 12.0,
shadow_offset_y: -2.0,
shadow_blur: 24.0,
shadow_opacity: 0.4,
}
);
assert!(invalid_color.theme_settings().is_err());
assert!(invalid_radius.theme_settings().is_err());
assert!(invalid_opacity.theme_settings().is_err());
}
#[test]
+3
View File
@@ -94,6 +94,7 @@ pub struct Ferese {
pub window_ids: HashMap<Window, WindowId>,
pub window_geometry: HashMap<WindowId, WindowGeometry>,
pub(crate) window_borders: HashMap<WindowId, crate::winit::WindowBorderBuffers>,
pub(crate) window_shadows: HashMap<WindowId, crate::winit::WindowShadowBuffers>,
pub(crate) rounded_clip_programs: HashMap<ErasedContextId, crate::winit::RoundedClipPrograms>,
viewport_animations: HashMap<WorkspaceId, AnimatedValue>,
scrolling_world_x: HashMap<WindowId, (WorkspaceId, AnimatedValue)>,
@@ -398,6 +399,7 @@ impl Ferese {
window_ids: HashMap::new(),
window_geometry: HashMap::new(),
window_borders: HashMap::new(),
window_shadows: HashMap::new(),
rounded_clip_programs: HashMap::new(),
viewport_animations: HashMap::new(),
scrolling_world_x: HashMap::new(),
@@ -784,6 +786,7 @@ impl Ferese {
self.space.unmap_elem(window);
self.window_geometry.remove(&id);
self.window_borders.remove(&id);
self.window_shadows.remove(&id);
self.window_rules_applied.remove(&id);
self.scrolling_world_x.remove(&id);
if let Err(error) = self.workspaces.remove_window(id) {
+211 -8
View File
@@ -170,10 +170,48 @@ void main() {
}
"#;
const WINDOW_SHADOW_SHADER: &str = r#"
precision mediump float;
uniform float alpha;
uniform vec4 shadow_rect;
uniform float radius;
uniform float blur;
uniform float opacity;
uniform vec4 shadow_color;
varying vec2 v_coords;
#if defined(DEBUG_FLAGS)
uniform float tint;
#endif
void main() {
vec2 point = gl_FragCoord.xy - shadow_rect.xy;
vec2 half_size = shadow_rect.zw * 0.5;
vec2 distance = abs(point - half_size) - (half_size - vec2(radius));
float signed_distance = length(max(distance, 0.0))
+ min(max(distance.x, distance.y), 0.0)
- radius;
float sigma = max(blur * 0.5, 0.5);
float normalized_distance = max(signed_distance, 0.0) / sigma;
float coverage = exp(-0.5 * normalized_distance * normalized_distance);
vec4 color = vec4(shadow_color.rgb * shadow_color.a, shadow_color.a)
* coverage * opacity * alpha;
#if defined(DEBUG_FLAGS)
if (tint == 1.0)
color = vec4(0.0, 0.2, 0.0, 0.2) + color * 0.8;
#endif
gl_FragColor = color;
}
"#;
#[derive(Clone, Debug)]
pub(crate) struct RoundedClipPrograms {
texture: GlesTexProgram,
border: GlesPixelProgram,
shadow: GlesPixelProgram,
}
#[derive(Clone, Debug, PartialEq)]
@@ -196,6 +234,27 @@ pub(crate) struct WindowBorderBuffers {
contexts: HashMap<ErasedContextId, CachedBorder>,
}
#[derive(Clone, Debug, PartialEq)]
struct ShadowParameters {
bounds: Rectangle<i32, Logical>,
shadow_rect: [f32; 4],
radius: f32,
blur: f32,
opacity: f32,
color: [f32; 4],
}
#[derive(Debug)]
struct CachedShadow {
element: PixelShaderElement,
parameters: ShadowParameters,
}
#[derive(Debug, Default)]
pub(crate) struct WindowShadowBuffers {
contexts: HashMap<ErasedContextId, CachedShadow>,
}
#[derive(Debug)]
struct RoundedSurfaceRenderElement {
inner: WaylandSurfaceRenderElement<GlesRenderer>,
@@ -513,6 +572,11 @@ fn output_elements(
);
if !fullscreen && let Some(programs) = rounded_clip_program.clone() {
let window_radius = state.theme_settings.window_radius;
let shadow_offset_y = state.theme_settings.shadow_offset_y;
let shadow_blur = state.theme_settings.shadow_blur;
let shadow_opacity = state.theme_settings.shadow_opacity;
let shadow_color = state.theme_settings.shadow_color.0;
let focused = state.focused_window == Some(id);
let border_width = if focused {
state.theme_settings.focus_ring_width
@@ -531,7 +595,7 @@ fn output_elements(
id,
constrain,
scale,
state.theme_settings.window_radius,
window_radius,
border_width,
border_color,
output,
@@ -539,15 +603,32 @@ fn output_elements(
) {
elements.push(border.into());
}
let shadow = window_shadow_element(
state,
renderer,
id,
constrain,
scale,
window_radius,
shadow_offset_y,
shadow_blur,
shadow_opacity,
shadow_color,
output,
&programs,
);
elements.extend(rounded_window_elements(
renderer,
&window,
constrain,
scale,
state.theme_settings.window_radius,
window_radius,
output,
programs.texture,
programs.texture.clone(),
));
if let Some(shadow) = shadow {
elements.push(shadow.into());
}
} else {
elements.extend(constrain_space_element::<
GlesRenderer,
@@ -595,11 +676,26 @@ fn rounded_clip_program(
UniformName::new("border_width", UniformType::_1f),
UniformName::new("border_color", UniformType::_4f),
];
let shadow_uniforms = [
UniformName::new("shadow_rect", UniformType::_4f),
UniformName::new("radius", UniformType::_1f),
UniformName::new("blur", UniformType::_1f),
UniformName::new("opacity", UniformType::_1f),
UniformName::new("shadow_color", UniformType::_4f),
];
let texture = renderer.compile_custom_texture_shader(ROUNDED_TEXTURE_SHADER, &texture_uniforms);
let border = renderer.compile_custom_pixel_shader(ROUNDED_BORDER_SHADER, &border_uniforms);
match texture.and_then(|texture| border.map(|border| (texture, border))) {
Ok((texture, border)) => {
let programs = RoundedClipPrograms { texture, border };
let shadow = renderer.compile_custom_pixel_shader(WINDOW_SHADOW_SHADER, &shadow_uniforms);
let compiled = texture.and_then(|texture| {
border.and_then(|border| shadow.map(|shadow| (texture, border, shadow)))
});
match compiled {
Ok((texture, border, shadow)) => {
let programs = RoundedClipPrograms {
texture,
border,
shadow,
};
state
.rounded_clip_programs
.insert(context, programs.clone());
@@ -683,6 +779,99 @@ fn border_uniforms(parameters: &BorderParameters) -> Vec<Uniform<'static>> {
]
}
#[allow(clippy::too_many_arguments)]
fn window_shadow_element(
state: &mut Ferese,
renderer: &GlesRenderer,
id: ferese_layout::WindowId,
geometry: Rectangle<i32, Logical>,
scale: f64,
requested_radius: f64,
offset_y: f64,
blur: f64,
opacity: f64,
color: [f32; 4],
output: &Output,
programs: &RoundedClipPrograms,
) -> Option<PixelShaderElement> {
let mode = output.current_mode()?;
if opacity == 0.0 || color[3] == 0.0 {
return None;
}
let shadow_geometry = Rectangle::new(
(geometry.loc.x, geometry.loc.y + offset_y.round() as i32).into(),
geometry.size,
);
let bounds = shadow_bounds(geometry, offset_y, blur);
let parameters = ShadowParameters {
bounds,
shadow_rect: framebuffer_clip_rect(
shadow_geometry.to_physical_precise_round(scale),
mode.size,
output.current_transform().invert(),
),
radius: scaled_effect_value(requested_radius, geometry, scale),
blur: (blur * scale) as f32,
opacity: opacity as f32,
color,
};
let context = renderer.context_id().erased();
let buffers = state.window_shadows.entry(id).or_default();
if !buffers.contexts.contains_key(&context) {
let element = PixelShaderElement::new(
programs.shadow.clone(),
bounds,
None,
1.0,
shadow_uniforms(&parameters),
RenderElementKind::Unspecified,
);
buffers.contexts.insert(
context.clone(),
CachedShadow {
element,
parameters: parameters.clone(),
},
);
}
let cached = buffers.contexts.get_mut(&context)?;
if cached.parameters != parameters {
if cached.parameters.bounds != parameters.bounds {
cached.element.resize(parameters.bounds, None);
}
cached.element.update_uniforms(shadow_uniforms(&parameters));
cached.parameters = parameters;
}
Some(cached.element.clone())
}
fn shadow_uniforms(parameters: &ShadowParameters) -> Vec<Uniform<'static>> {
vec![
Uniform::new("shadow_rect", parameters.shadow_rect).into_owned(),
Uniform::new("radius", parameters.radius).into_owned(),
Uniform::new("blur", parameters.blur).into_owned(),
Uniform::new("opacity", parameters.opacity).into_owned(),
Uniform::new("shadow_color", parameters.color).into_owned(),
]
}
fn shadow_bounds(
geometry: Rectangle<i32, Logical>,
offset_y: f64,
blur: f64,
) -> Rectangle<i32, Logical> {
let extent = (blur * 2.0).ceil() as i32;
let offset_y = offset_y.round() as i32;
Rectangle::new(
(geometry.loc.x - extent, geometry.loc.y + offset_y - extent).into(),
(geometry.size.w + extent * 2, geometry.size.h + extent * 2).into(),
)
}
fn scaled_effect_value(requested: f64, geometry: Rectangle<i32, Logical>, scale: f64) -> f32 {
requested
.min(f64::from(geometry.size.w.min(geometry.size.h)) / 2.0)
@@ -957,9 +1146,9 @@ fn normalized_scale(scale: f64) -> f64 {
#[cfg(test)]
mod tests {
use smithay::utils::{Physical, Rectangle, Transform};
use smithay::utils::{Logical, Physical, Rectangle, Transform};
use super::{framebuffer_clip_rect, normalized_scale};
use super::{framebuffer_clip_rect, normalized_scale, shadow_bounds};
#[test]
fn accepts_positive_finite_scale() {
@@ -996,4 +1185,18 @@ mod tests {
[60.0, 5.0, 30.0, 40.0]
);
}
#[test]
fn shadow_bounds_include_blur_and_offset() {
let geometry = Rectangle::<i32, Logical>::new((100, 80).into(), (400, 300).into());
assert_eq!(
shadow_bounds(geometry, 4.0, 18.0),
Rectangle::new((64, 48).into(), (472, 372).into())
);
assert_eq!(
shadow_bounds(geometry, -6.0, 10.0),
Rectangle::new((80, 54).into(), (440, 340).into())
);
}
}