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]
border = "#FFFFFF18"
accent = "#5B8CFF"
shadow = "#00000055"
[theme.geometry]
border_width = 1.0
focus_ring_width = 2.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
by the rounded-clipping pass described below in the M6 rendering work.
Fullscreen windows are borderless and shadowless. Other managed windows use GPU
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
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 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())
);
}
}