diff --git a/README.md b/README.md index ae1ca95..61c76ea 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/compositor/src/config.rs b/compositor/src/config.rs index e9d0d2c..6d3f798 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -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 Result { + 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 { + 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 { 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] diff --git a/compositor/src/state.rs b/compositor/src/state.rs index ecef294..09261fe 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -94,6 +94,7 @@ pub struct Ferese { pub window_ids: HashMap, pub window_geometry: HashMap, pub(crate) window_borders: HashMap, + pub(crate) window_shadows: HashMap, pub(crate) rounded_clip_programs: HashMap, viewport_animations: HashMap, scrolling_world_x: HashMap, @@ -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) { diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index 574242a..6a63ef5 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -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, } +#[derive(Clone, Debug, PartialEq)] +struct ShadowParameters { + bounds: Rectangle, + 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, +} + #[derive(Debug)] struct RoundedSurfaceRenderElement { inner: WaylandSurfaceRenderElement, @@ -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> { ] } +#[allow(clippy::too_many_arguments)] +fn window_shadow_element( + state: &mut Ferese, + renderer: &GlesRenderer, + id: ferese_layout::WindowId, + geometry: Rectangle, + scale: f64, + requested_radius: f64, + offset_y: f64, + blur: f64, + opacity: f64, + color: [f32; 4], + output: &Output, + programs: &RoundedClipPrograms, +) -> Option { + 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(¶meters), + 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(¶meters)); + cached.parameters = parameters; + } + + Some(cached.element.clone()) +} + +fn shadow_uniforms(parameters: &ShadowParameters) -> Vec> { + 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, + offset_y: f64, + blur: f64, +) -> Rectangle { + 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, 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::::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()) + ); + } }