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

+9 -2
View File
@@ -52,15 +52,22 @@ adding inner padding or changing client geometry:
[theme.colors] [theme.colors]
border = "#FFFFFF18" border = "#FFFFFF18"
accent = "#5B8CFF" accent = "#5B8CFF"
shadow = "#00000055"
[theme.geometry] [theme.geometry]
border_width = 1.0 border_width = 1.0
focus_ring_width = 2.0 focus_ring_width = 2.0
window_radius = 14.0 window_radius = 14.0
[theme.shadow.soft]
offset_y = 4.0
blur = 18.0
opacity = 0.20
``` ```
Fullscreen windows are borderless. `window_radius` is validated now and is used Fullscreen windows are borderless and shadowless. Other managed windows use GPU
by the rounded-clipping pass described below in the M6 rendering work. rounded clipping, matching rounded focus rings, and an analytic soft shadow.
These effects overlay or surround the allocation without adding client padding.
Scrolling keeps windows in stable workspace coordinates and animates the Scrolling keeps windows in stable workspace coordinates and animates the
viewport only when focus must be revealed. Set viewport only when focus must be revealed. Set
+89 -1
View File
@@ -43,7 +43,11 @@ pub struct ThemeSettings {
pub focus_ring_width: f64, pub focus_ring_width: f64,
pub border_color: RgbaColor, pub border_color: RgbaColor,
pub accent_color: RgbaColor, pub accent_color: RgbaColor,
pub shadow_color: RgbaColor,
pub window_radius: f64, pub window_radius: f64,
pub shadow_offset_y: f64,
pub shadow_blur: f64,
pub shadow_opacity: f64,
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
@@ -52,6 +56,8 @@ struct ThemeConfig {
colors: ThemeColorsConfig, colors: ThemeColorsConfig,
#[serde(default)] #[serde(default)]
geometry: ThemeGeometryConfig, geometry: ThemeGeometryConfig,
#[serde(default)]
shadow: ThemeShadowConfig,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -60,6 +66,8 @@ struct ThemeColorsConfig {
border: String, border: String,
#[serde(default = "default_accent_color")] #[serde(default = "default_accent_color")]
accent: String, accent: String,
#[serde(default = "default_shadow_color")]
shadow: String,
} }
impl Default for ThemeColorsConfig { impl Default for ThemeColorsConfig {
@@ -67,6 +75,33 @@ impl Default for ThemeColorsConfig {
Self { Self {
border: default_border_color(), border: default_border_color(),
accent: default_accent_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 = let window_radius =
nonnegative_theme_value(self.theme.geometry.window_radius, "geometry.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 { Ok(ThemeSettings {
border_width, border_width,
focus_ring_width, focus_ring_width,
border_color: parse_color(&self.theme.colors.border, "colors.border")?, border_color: parse_color(&self.theme.colors.border, "colors.border")?,
accent_color: parse_color(&self.theme.colors.accent, "colors.accent")?, accent_color: parse_color(&self.theme.colors.accent, "colors.accent")?,
shadow_color: parse_color(&self.theme.colors.shadow, "colors.shadow")?,
window_radius, 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> { fn parse_color(value: &str, field: &'static str) -> Result<RgbaColor, ConfigError> {
let digits = value.strip_prefix('#').unwrap_or(value); let digits = value.strip_prefix('#').unwrap_or(value);
if !digits.is_ascii() || !matches!(digits.len(), 6 | 8) { if !digits.is_ascii() || !matches!(digits.len(), 6 | 8) {
@@ -1062,6 +1128,10 @@ fn default_accent_color() -> String {
"#5B8CFF".to_owned() "#5B8CFF".to_owned()
} }
fn default_shadow_color() -> String {
"#00000055".to_owned()
}
const fn default_border_width() -> f64 { const fn default_border_width() -> f64 {
1.0 1.0
} }
@@ -1074,6 +1144,18 @@ const fn default_window_radius() -> f64 {
14.0 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 { fn default_xkb_layout() -> String {
"us".to_owned() "us".to_owned()
} }
@@ -1265,10 +1347,11 @@ mod tests {
#[test] #[test]
fn parses_and_validates_theme_window_tokens() { fn parses_and_validates_theme_window_tokens() {
let configured = parse( 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_color = parse("[theme.colors]\naccent = \"blue\"");
let invalid_radius = parse("[theme.geometry]\nwindow_radius = -1.0"); let invalid_radius = parse("[theme.geometry]\nwindow_radius = -1.0");
let invalid_opacity = parse("[theme.shadow.soft]\nopacity = 1.1");
assert_eq!( assert_eq!(
configured.theme_settings().unwrap(), configured.theme_settings().unwrap(),
@@ -1277,11 +1360,16 @@ mod tests {
focus_ring_width: 3.0, focus_ring_width: 3.0,
border_color: RgbaColor([17.0 / 255.0, 34.0 / 255.0, 51.0 / 255.0, 68.0 / 255.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]), 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, 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_color.theme_settings().is_err());
assert!(invalid_radius.theme_settings().is_err()); assert!(invalid_radius.theme_settings().is_err());
assert!(invalid_opacity.theme_settings().is_err());
} }
#[test] #[test]
+3
View File
@@ -94,6 +94,7 @@ pub struct Ferese {
pub window_ids: HashMap<Window, WindowId>, pub window_ids: HashMap<Window, WindowId>,
pub window_geometry: HashMap<WindowId, WindowGeometry>, pub window_geometry: HashMap<WindowId, WindowGeometry>,
pub(crate) window_borders: HashMap<WindowId, crate::winit::WindowBorderBuffers>, 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>, pub(crate) rounded_clip_programs: HashMap<ErasedContextId, crate::winit::RoundedClipPrograms>,
viewport_animations: HashMap<WorkspaceId, AnimatedValue>, viewport_animations: HashMap<WorkspaceId, AnimatedValue>,
scrolling_world_x: HashMap<WindowId, (WorkspaceId, AnimatedValue)>, scrolling_world_x: HashMap<WindowId, (WorkspaceId, AnimatedValue)>,
@@ -398,6 +399,7 @@ impl Ferese {
window_ids: HashMap::new(), window_ids: HashMap::new(),
window_geometry: HashMap::new(), window_geometry: HashMap::new(),
window_borders: HashMap::new(), window_borders: HashMap::new(),
window_shadows: HashMap::new(),
rounded_clip_programs: HashMap::new(), rounded_clip_programs: HashMap::new(),
viewport_animations: HashMap::new(), viewport_animations: HashMap::new(),
scrolling_world_x: HashMap::new(), scrolling_world_x: HashMap::new(),
@@ -784,6 +786,7 @@ impl Ferese {
self.space.unmap_elem(window); self.space.unmap_elem(window);
self.window_geometry.remove(&id); self.window_geometry.remove(&id);
self.window_borders.remove(&id); self.window_borders.remove(&id);
self.window_shadows.remove(&id);
self.window_rules_applied.remove(&id); self.window_rules_applied.remove(&id);
self.scrolling_world_x.remove(&id); self.scrolling_world_x.remove(&id);
if let Err(error) = self.workspaces.remove_window(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)] #[derive(Clone, Debug)]
pub(crate) struct RoundedClipPrograms { pub(crate) struct RoundedClipPrograms {
texture: GlesTexProgram, texture: GlesTexProgram,
border: GlesPixelProgram, border: GlesPixelProgram,
shadow: GlesPixelProgram,
} }
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
@@ -196,6 +234,27 @@ pub(crate) struct WindowBorderBuffers {
contexts: HashMap<ErasedContextId, CachedBorder>, 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)] #[derive(Debug)]
struct RoundedSurfaceRenderElement { struct RoundedSurfaceRenderElement {
inner: WaylandSurfaceRenderElement<GlesRenderer>, inner: WaylandSurfaceRenderElement<GlesRenderer>,
@@ -513,6 +572,11 @@ fn output_elements(
); );
if !fullscreen && let Some(programs) = rounded_clip_program.clone() { 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 focused = state.focused_window == Some(id);
let border_width = if focused { let border_width = if focused {
state.theme_settings.focus_ring_width state.theme_settings.focus_ring_width
@@ -531,7 +595,7 @@ fn output_elements(
id, id,
constrain, constrain,
scale, scale,
state.theme_settings.window_radius, window_radius,
border_width, border_width,
border_color, border_color,
output, output,
@@ -539,15 +603,32 @@ fn output_elements(
) { ) {
elements.push(border.into()); 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( elements.extend(rounded_window_elements(
renderer, renderer,
&window, &window,
constrain, constrain,
scale, scale,
state.theme_settings.window_radius, window_radius,
output, output,
programs.texture, programs.texture.clone(),
)); ));
if let Some(shadow) = shadow {
elements.push(shadow.into());
}
} else { } else {
elements.extend(constrain_space_element::< elements.extend(constrain_space_element::<
GlesRenderer, GlesRenderer,
@@ -595,11 +676,26 @@ fn rounded_clip_program(
UniformName::new("border_width", UniformType::_1f), UniformName::new("border_width", UniformType::_1f),
UniformName::new("border_color", UniformType::_4f), 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 texture = renderer.compile_custom_texture_shader(ROUNDED_TEXTURE_SHADER, &texture_uniforms);
let border = renderer.compile_custom_pixel_shader(ROUNDED_BORDER_SHADER, &border_uniforms); let border = renderer.compile_custom_pixel_shader(ROUNDED_BORDER_SHADER, &border_uniforms);
match texture.and_then(|texture| border.map(|border| (texture, border))) { let shadow = renderer.compile_custom_pixel_shader(WINDOW_SHADOW_SHADER, &shadow_uniforms);
Ok((texture, border)) => { let compiled = texture.and_then(|texture| {
let programs = RoundedClipPrograms { texture, border }; border.and_then(|border| shadow.map(|shadow| (texture, border, shadow)))
});
match compiled {
Ok((texture, border, shadow)) => {
let programs = RoundedClipPrograms {
texture,
border,
shadow,
};
state state
.rounded_clip_programs .rounded_clip_programs
.insert(context, programs.clone()); .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 { fn scaled_effect_value(requested: f64, geometry: Rectangle<i32, Logical>, scale: f64) -> f32 {
requested requested
.min(f64::from(geometry.size.w.min(geometry.size.h)) / 2.0) .min(f64::from(geometry.size.w.min(geometry.size.h)) / 2.0)
@@ -957,9 +1146,9 @@ fn normalized_scale(scale: f64) -> f64 {
#[cfg(test)] #[cfg(test)]
mod tests { 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] #[test]
fn accepts_positive_finite_scale() { fn accepts_positive_finite_scale() {
@@ -996,4 +1185,18 @@ mod tests {
[60.0, 5.0, 30.0, 40.0] [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())
);
}
} }