From 64c95f0b970fc5338af4485eec1a71a6390d8c38 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Sat, 26 Sep 2026 09:12:51 +0100 Subject: [PATCH] Replace glass effects with plain backdrop blur and refine shell layout --- README.md | 37 +- compositor/src/config.rs | 194 +++--- compositor/src/effects.rs | 287 +++----- compositor/src/handlers/compositor.rs | 6 +- compositor/src/metrics.rs | 30 +- compositor/src/state.rs | 20 +- compositor/src/winit.rs | 921 +++++++++---------------- protocols/ferese-effects-v1.xml | 14 +- shell/ferese-shell/src/config.rs | 2 +- shell/ferese-shell/src/main.rs | 103 ++- shell/ferese-shell/src/motion.rs | 93 ++- shell/ferese-shell/src/status_ui.rs | 84 +-- tools/ferese-effects-probe/src/main.rs | 68 +- 13 files changed, 823 insertions(+), 1036 deletions(-) diff --git a/README.md b/README.md index 63c4e8a..9710dd4 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,7 @@ decorations). Leaving fullscreen restores the previous maximized/tiled state. Set `FERESE_TRACE_PERFORMANCE=1` to emit five-second per-output summaries under the `ferese::render` tracing target. Summaries include rendered frames, damaged -pixels, average and longest render time, DRM missed-deadline totals, blur-active -frames, blur-region count, and sampled blur pixels. +pixels, average and longest render time, and DRM missed-deadline totals. The native-client soak runner and its nested/DRM procedure are documented in [docs/native-soak-testing.md](docs/native-soak-testing.md). @@ -48,7 +47,7 @@ cargo build -p ferese-effects-probe cargo run -p ferese -- --grant-effects -- target/debug/ferese-effects-probe ``` -Add `--preview` to keep a glass popover visible in the nested compositor: +Add `--preview` to keep a solid popover visible in the nested compositor: ```bash cargo build -p ferese-effects-probe @@ -57,8 +56,7 @@ cargo run -p ferese -- --backend nested --grant-effects -- \ ``` Press `Super+Enter` in the Ferese window to open `foot`, then move or update the -terminal beneath the popover to see backdrop capture, blur, tint, and damage -invalidation. Stop the preview with `Ctrl+C` in the host terminal. +terminal beneath the popover to inspect stacking, rounded clipping, and shadows. Stop the preview with `Ctrl+C` in the host terminal. To verify that the private global is absent from the public Wayland socket: @@ -108,29 +106,25 @@ blur = 18.0 opacity = 0.20 [theme.material] -style = "glass" # glass, translucent, or solid +style = "solid" # solid or translucent +blur_radius = 12.0 # logical pixels; translucent only, 0 disables blur ``` -Glass roles use live backdrop sampling, a dark tint, diagonal lighting, inset -edges, static overlay grain, and role-specific shadows. Configure them with: +Material roles use the configured surface color and role-specific shadows. +Solid is the default. Translucent adds backdrop blur behind the same surface +color, without grain, glossy lighting, saturation boosts, or inset highlights. +The radius is clamped to 32 logical pixels. A shader/allocation failure falls +back to a plain translucent fill. Shell popovers report rounded card regions; +their outer padding and gaps stay transparent. Each surface shares one capture +across its cards, so cards do not sample already blurred neighboring cards. +Use `--preview --regions` with the effects probe to inspect two cards over a +patterned background, including the sharp gap between them. ```toml [appearance] corner_radius = 14.0 -reduced_effects = false # true selects reduced_blur or a stricter quality tier - -[appearance.glass] -tint_color = "#1C202E" -blur_scale = 1.0 # clamped to 0.5–1.5; non-finite values are rejected -grain = true -quality = "full" ``` -Quality tiers are `full`, `no_grain`, `no_saturation`, `reduced_blur` -(at most 12 logical pixels with fewer samples), `translucent`, and `solid`. -Each tier retains earlier reductions without reducing tint opacity or changing -layout. Backdrop shader or texture allocation failure falls back to translucent. -The legacy `surface.panel_elevated` role uses the panel tint with popover elevation. Shell shadows use restrained logical-pixel presets: panel `1/5/4%`, popover and menu `2/8/7%`, HUD `1/4/4%`, and notification/modal `3/10/9%` (vertical offset / blur / opacity). Private effects capabilities @@ -173,8 +167,7 @@ shadow. Window spacing uses the workspace outer gap; `top_bar_window_gap` under shell surfaces also omit outlines. The dark startup fallback matches the material palette. The shell decodes wallpaper in parallel with startup and prefers the lightweight -software renderer, with GPU rendering available as a fallback. Glass effects -still run on the compositor GPU. Set `ICED_BACKEND=wgpu` to explicitly use GPU +software renderer, with GPU rendering available as a fallback. Set `ICED_BACKEND=wgpu` to explicitly use GPU rendering for shell content. Use release builds when evaluating startup and animation performance; debug image processing is considerably slower. diff --git a/compositor/src/config.rs b/compositor/src/config.rs index b37902c..1dfafca 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -113,12 +113,13 @@ pub struct ThemeSettings { pub accent_color: RgbaColor, pub shadow_color: RgbaColor, pub surface_base_color: RgbaColor, + pub panel_opacity: f64, pub window_radius: f64, pub shadow_offset_y: f64, pub shadow_blur: f64, pub shadow_opacity: f64, pub material_style: MaterialStyle, - pub glass: GlassSettings, + pub backdrop_blur: f64, pub material_radius: f64, pub panel_radius: f64, } @@ -126,64 +127,20 @@ pub struct ThemeSettings { #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] #[serde(rename_all = "snake_case")] pub enum MaterialStyle { - #[default] - Glass, Translucent, - Solid, -} - -/// Ordered quality tiers; each step retains the preceding reductions. -#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Ord, PartialOrd)] -#[serde(rename_all = "snake_case")] -pub enum GlassQuality { #[default] - Full, - NoGrain, - NoSaturation, - ReducedBlur, - Translucent, Solid, } -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct GlassSettings { - pub tint: RgbaColor, - pub blur_scale: f32, - pub grain: bool, - pub quality: GlassQuality, -} - -impl Default for GlassSettings { - fn default() -> Self { - Self { - tint: RgbaColor([28.0 / 255.0, 32.0 / 255.0, 46.0 / 255.0, 1.0]), - blur_scale: 1.0, - grain: true, - quality: GlassQuality::Full, - } - } -} - #[derive(Debug, Default, Deserialize)] struct AppearanceConfig { corner_radius: Option, - #[serde(default)] - reduced_effects: bool, - #[serde(default)] - glass: GlassConfig, -} - -#[derive(Debug, Default, Deserialize)] -struct GlassConfig { - tint_color: Option, - blur_scale: Option, - grain: Option, - #[serde(default)] - quality: GlassQuality, } #[derive(Debug, Default, Deserialize)] struct ThemeConfig { + #[serde(default)] + surface: ThemeSurfaceConfig, #[serde(default)] colors: ThemeColorsConfig, #[serde(default)] @@ -195,9 +152,35 @@ struct ThemeConfig { } #[derive(Debug, Default, Deserialize)] +struct ThemeSurfaceConfig { + #[serde(default)] + bar: ThemeBarConfig, +} + +#[derive(Debug, Default, Deserialize)] +struct ThemeBarConfig { + opacity: Option, +} + +#[derive(Debug, Deserialize)] struct ThemeMaterialConfig { #[serde(default)] style: MaterialStyle, + #[serde(default = "default_backdrop_blur")] + blur_radius: f64, +} + +impl Default for ThemeMaterialConfig { + fn default() -> Self { + Self { + style: MaterialStyle::Solid, + blur_radius: default_backdrop_blur(), + } + } +} + +fn default_backdrop_blur() -> f64 { + 12.0 } #[derive(Debug, Deserialize)] @@ -861,22 +844,6 @@ impl Config { let shadow_opacity = unit_theme_value(self.theme.shadow.soft.opacity, "shadow.soft.opacity")?; - let mut glass = GlassSettings::default(); - if let Some(tint) = &self.appearance.glass.tint_color { - glass.tint = parse_color(tint, "appearance.glass.tint_color")?; - // Opacity belongs to the semantic role, never the color token. - glass.tint.0[3] = 1.0; - } - glass.blur_scale = finite_theme_value( - self.appearance.glass.blur_scale.unwrap_or(1.0), - "appearance.glass.blur_scale", - )? - .clamp(0.5, 1.5) as f32; - glass.grain = self.appearance.glass.grain.unwrap_or(true); - glass.quality = self.appearance.glass.quality; - if self.appearance.reduced_effects { - glass.quality = glass.quality.max(GlassQuality::ReducedBlur); - } let material_radius = nonnegative_theme_value( self.appearance.corner_radius.unwrap_or(14.0), "appearance.corner_radius", @@ -891,12 +858,20 @@ impl Config { &self.theme.colors.surface_base, "colors.surface_base", )?, + panel_opacity: unit_theme_value( + self.theme.surface.bar.opacity.unwrap_or(0.78), + "surface.bar.opacity", + )?, window_radius, shadow_offset_y, shadow_blur, shadow_opacity, material_style: self.theme.material.style, - glass, + backdrop_blur: nonnegative_theme_value( + self.theme.material.blur_radius, + "material.blur_radius", + )? + .min(32.0), material_radius, panel_radius: nonnegative_theme_value( self.theme.geometry.top_bar_radius, @@ -1665,30 +1640,6 @@ mod tests { assert!(invalid.gap_config().is_err()); } - #[test] - fn glass_configuration_clamps_cost_and_rejects_nonfinite_values() { - for (value, expected) in [(0.1, 0.5), (1.2, 1.2), (100.0, 1.5)] { - let config = parse(&format!("[appearance.glass]\nblur_scale = {value}")); - assert_eq!(config.theme_settings().unwrap().glass.blur_scale, expected); - } - for value in ["nan", "inf", "-inf"] { - assert!( - parse(&format!("[appearance.glass]\nblur_scale = {value}")) - .theme_settings() - .is_err() - ); - } - let settings = parse("[appearance]\nreduced_effects = true\ncorner_radius = 17.0\n[appearance.glass]\ntint_color = \"#123456\"\ngrain = false") - .theme_settings().unwrap(); - assert_eq!(settings.glass.quality, GlassQuality::ReducedBlur); - assert!(!settings.glass.grain); - assert_eq!(settings.material_radius, 17.0); - assert_eq!( - settings.glass.tint, - RgbaColor([18.0 / 255.0, 52.0 / 255.0, 86.0 / 255.0, 1.0]) - ); - } - #[test] fn parses_and_validates_theme_window_tokens() { let configured = parse( @@ -1707,12 +1658,13 @@ mod tests { 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]), surface_base_color: RgbaColor([17.0 / 255.0, 24.0 / 255.0, 33.0 / 255.0, 1.0,]), + panel_opacity: 0.78, window_radius: 12.0, shadow_offset_y: -2.0, shadow_blur: 24.0, shadow_opacity: 0.4, material_style: MaterialStyle::Translucent, - glass: GlassSettings::default(), + backdrop_blur: 12.0, material_radius: 14.0, panel_radius: 0.0, } @@ -1723,6 +1675,70 @@ mod tests { assert!(toml::from_str::("[theme.material]\nstyle = \"mist\"").is_err()); } + #[test] + fn materials_default_to_solid_and_reject_removed_style() { + assert_eq!( + parse("").theme_settings().unwrap().material_style, + MaterialStyle::Solid + ); + assert!(toml::from_str::("[theme.material]\nstyle = \"glass\"").is_err()); + } + + #[test] + fn material_color_comes_from_surface_base() { + let theme = parse("[theme.colors]\nsurface_base = \"#000000\"\n[theme.surface.bar]\nbackground = \"#FFFFFF\"") + .theme_settings() + .unwrap(); + assert_eq!(theme.surface_base_color, RgbaColor([0.0, 0.0, 0.0, 1.0])); + } + + #[test] + fn bar_opacity_uses_config_and_rejects_invalid_values() { + assert_eq!(parse("").theme_settings().unwrap().panel_opacity, 0.78); + for opacity in [0.0, 0.65, 1.0] { + assert_eq!( + parse(&format!("[theme.surface.bar]\nopacity = {opacity}")) + .theme_settings() + .unwrap() + .panel_opacity, + opacity + ); + } + for opacity in ["-0.1", "1.1", "nan", "inf"] { + assert!( + parse(&format!("[theme.surface.bar]\nopacity = {opacity}")) + .theme_settings() + .is_err() + ); + } + } + + #[test] + fn backdrop_blur_is_configurable_without_glass_settings() { + assert_eq!(parse("").theme_settings().unwrap().backdrop_blur, 12.0); + assert_eq!( + parse("[theme.material]\nstyle = \"translucent\"\nblur_radius = 100") + .theme_settings() + .unwrap() + .backdrop_blur, + 32.0 + ); + assert_eq!( + parse("[theme.material]\nblur_radius = 0") + .theme_settings() + .unwrap() + .backdrop_blur, + 0.0 + ); + for value in ["-1", "nan", "inf"] { + assert!( + parse(&format!("[theme.material]\nblur_radius = {value}")) + .theme_settings() + .is_err() + ); + } + } + #[test] fn parses_input_and_touchpad_settings() { let config = parse( diff --git a/compositor/src/effects.rs b/compositor/src/effects.rs index 8781911..90103aa 100644 --- a/compositor/src/effects.rs +++ b/compositor/src/effects.rs @@ -16,10 +16,7 @@ use smithay::{ }; use crate::{ - Ferese, - config::{GlassQuality, GlassSettings, MaterialStyle}, - private_client::ClientCapabilities, - state::ClientState, + Ferese, config::MaterialStyle, private_client::ClientCapabilities, state::ClientState, }; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -37,75 +34,27 @@ pub(crate) enum SemanticRole { pub(crate) struct ResolvedMaterial { pub style: MaterialStyle, pub opacity: f32, - pub blur: f32, - pub saturation: f32, - pub brightness: f32, - pub noise: f32, pub shadow: [f64; 3], } -pub(crate) fn resolve_material( - role: SemanticRole, - style: MaterialStyle, - settings: GlassSettings, -) -> ResolvedMaterial { - // Restrained shell shadows in logical pixels: the full-width bar needs - // less elevation than popovers, while dialogs retain a little more depth. - let (opacity, blur, saturation, shadow) = match role { - SemanticRole::Panel => (0.55, 24.0, 1.35, [1.0, 5.0, 0.04]), - SemanticRole::PanelElevated => (0.55, 24.0, 1.35, [2.0, 8.0, 0.07]), - SemanticRole::Popover => (0.55, 22.0, 1.35, [2.0, 8.0, 0.07]), - SemanticRole::Menu => (0.60, 20.0, 1.30, [2.0, 8.0, 0.07]), - SemanticRole::Hud => (0.65, 18.0, 1.25, [1.0, 4.0, 0.04]), - SemanticRole::Notification => (0.58, 24.0, 1.35, [3.0, 10.0, 0.09]), - SemanticRole::Modal => (0.62, 28.0, 1.40, [3.0, 10.0, 0.09]), - }; - // Preserve the separately defined translucent family when selected - // directly. Glass degradation keeps the glass role's contrast floor. - let opacity = if style == MaterialStyle::Translucent { - match role { - SemanticRole::Panel => 0.78, - SemanticRole::Hud | SemanticRole::Modal => 0.88, - _ => 0.84, +pub(crate) fn resolve_material(role: SemanticRole, style: MaterialStyle) -> ResolvedMaterial { + let shadow = match role { + SemanticRole::Panel => [1.0, 5.0, 0.04], + SemanticRole::PanelElevated | SemanticRole::Popover | SemanticRole::Menu => { + [2.0, 8.0, 0.07] } - } else { - opacity + SemanticRole::Hud => [1.0, 4.0, 0.04], + SemanticRole::Notification | SemanticRole::Modal => [3.0, 10.0, 0.09], }; - let style = if style == MaterialStyle::Glass { - match settings.quality { - GlassQuality::Solid => MaterialStyle::Solid, - GlassQuality::Translucent => MaterialStyle::Translucent, - _ => style, - } - } else { - style + let opacity = match (style, role) { + (MaterialStyle::Solid, _) => 1.0, + (_, SemanticRole::Panel) => 0.78, + (_, SemanticRole::Hud | SemanticRole::Modal) => 0.88, + _ => 0.84, }; - let glass = style == MaterialStyle::Glass; ResolvedMaterial { style, - opacity: if style == MaterialStyle::Solid { - 1.0 - } else { - opacity - }, - blur: if !glass { - 0.0 - } else if settings.quality >= GlassQuality::ReducedBlur { - (blur * settings.blur_scale).min(12.0) - } else { - blur * settings.blur_scale - }, - saturation: if glass && settings.quality < GlassQuality::NoSaturation { - saturation - } else { - 1.0 - }, - brightness: 1.0, - noise: if glass && settings.grain && settings.quality < GlassQuality::NoGrain { - 0.05 - } else { - 0.0 - }, + opacity, shadow, } } @@ -115,6 +64,7 @@ struct SurfaceEffectsState { attached: AtomicBool, generation: AtomicU64, role: Mutex>, + regions: Mutex>>, } impl SurfaceEffectsState { @@ -123,6 +73,7 @@ impl SurfaceEffectsState { attached: AtomicBool::new(false), generation: AtomicU64::new(0), role: Mutex::new(None), + regions: Mutex::new(None), } } @@ -152,7 +103,39 @@ impl SurfaceEffectsUserData { } pub(crate) fn init_global(display: &DisplayHandle) { - display.create_global::(1, ()); + display.create_global::(2, ()); +} + +pub(crate) fn surface_regions(surface: &WlSurface) -> Option> { + with_states(surface, |states| { + states + .data_map + .get::()? + .regions + .lock() + .unwrap() + .clone() + }) +} + +fn decode_regions(bytes: &[u8]) -> Option> { + if bytes.len() % 20 != 0 || bytes.len() > 32 * 20 { + return None; + } + bytes + .chunks_exact(20) + .map(|tuple| { + let values: Vec<_> = tuple + .chunks_exact(4) + .map(|v| i32::from_ne_bytes(v.try_into().unwrap())) + .collect(); + (values[2] > 0 + && values[3] > 0 + && values[4] >= 0 + && values.iter().all(|v| v.abs_diff(0) <= 32768)) + .then(|| [values[0], values[1], values[2], values[3], values[4]]) + }) + .collect() } // Consumed by the material renderer in the next M7 slice. @@ -254,19 +237,36 @@ impl Dispatch for Ferese { WEnum::Unknown(_) | WEnum::Value(_) => return, }; if set_surface_role(&surface, Some(role)) { - state.invalidate_material_scene(); + crate::backends::direct::render_all(state); + } + } + ferese_surface_effects_v1::Request::SetRegions { regions } => { + let Some(regions) = decode_regions(®ions) else { + return; + }; + let changed = with_states(&surface, |states| { + let Some(effects) = states.data_map.get::() else { + return false; + }; + let mut old = effects.regions.lock().unwrap(); + if old.as_ref() == Some(®ions) { + return false; + } + *old = Some(regions); + effects.generation.fetch_add(1, Ordering::Release); + true + }); + if changed { crate::backends::direct::render_all(state); } } ferese_surface_effects_v1::Request::ClearRole => { if set_surface_role(&surface, None) { - state.invalidate_material_scene(); crate::backends::direct::render_all(state); } } ferese_surface_effects_v1::Request::Destroy => { if detach(&surface) { - state.invalidate_material_scene(); crate::backends::direct::render_all(state); } } @@ -310,6 +310,31 @@ fn detach(surface: &WlSurface) -> bool { mod tests { use super::*; + #[test] + fn rounded_regions_are_bounded_and_validated() { + let region = [12_i32, 8, 120, 48, 11]; + let bytes: Vec<_> = region.into_iter().flat_map(i32::to_ne_bytes).collect(); + assert_eq!(decode_regions(&bytes), Some(vec![region])); + assert_eq!(decode_regions(&[]), Some(vec![])); + assert!(decode_regions(&bytes[..19]).is_none()); + assert!(decode_regions(&bytes.repeat(33)).is_none()); + for invalid in [ + [0_i32, 0, 0, 48, 11], + [0, 0, 120, 48, -1], + [i32::MIN, 0, 120, 48, 11], + ] { + assert!( + decode_regions( + &invalid + .into_iter() + .flat_map(i32::to_ne_bytes) + .collect::>() + ) + .is_none() + ); + } + } + #[test] fn role_changes_advance_generation_only_when_material_changes() { let effects = SurfaceEffectsState::new(); @@ -324,118 +349,20 @@ mod tests { } #[test] - fn styles_preserve_semantics_but_disable_unsupported_effects() { - let glass = resolve_material( - SemanticRole::Popover, - MaterialStyle::Glass, - GlassSettings::default(), - ); - let translucent = resolve_material( - SemanticRole::Popover, - MaterialStyle::Translucent, - GlassSettings::default(), - ); - let solid = resolve_material( - SemanticRole::Popover, - MaterialStyle::Solid, - GlassSettings::default(), - ); - - assert!(glass.blur > 0.0); - assert!(glass.noise > 0.0); - assert!(glass.opacity <= translucent.opacity); - assert_eq!(translucent.blur, 0.0); - assert_eq!(translucent.noise, 0.0); - assert_eq!(solid.opacity, 1.0); - assert_eq!(solid.blur, 0.0); - } - - #[test] - fn glass_presets_use_role_specific_tints_and_restrained_shadows() { - for (role, blur, saturation, opacity, shadow) in [ - (SemanticRole::Panel, 24.0, 1.35, 0.55, [1.0, 5.0, 0.04]), - (SemanticRole::Popover, 22.0, 1.35, 0.55, [2.0, 8.0, 0.07]), - (SemanticRole::Menu, 20.0, 1.30, 0.60, [2.0, 8.0, 0.07]), - (SemanticRole::Hud, 18.0, 1.25, 0.65, [1.0, 4.0, 0.04]), - ( - SemanticRole::Notification, - 24.0, - 1.35, - 0.58, - [3.0, 10.0, 0.09], - ), - (SemanticRole::Modal, 28.0, 1.40, 0.62, [3.0, 10.0, 0.09]), - ] { - let material = resolve_material(role, MaterialStyle::Glass, GlassSettings::default()); - assert_eq!( - ( - material.blur, - material.saturation, - material.opacity, - material.shadow - ), - (blur, saturation, opacity, shadow) - ); - } - } - - #[test] - fn degradation_is_ordered_and_preserves_contrast_and_elevation() { - let reference = resolve_material( - SemanticRole::Modal, - MaterialStyle::Glass, - GlassSettings::default(), - ); - for quality in [ - GlassQuality::Full, - GlassQuality::NoGrain, - GlassQuality::NoSaturation, - GlassQuality::ReducedBlur, - GlassQuality::Translucent, - GlassQuality::Solid, - ] { - let material = resolve_material( - SemanticRole::Modal, - MaterialStyle::Glass, - GlassSettings { - quality, - ..GlassSettings::default() - }, - ); - assert!(material.opacity >= reference.opacity); - assert_eq!(material.shadow, reference.shadow); - if quality >= GlassQuality::NoGrain { - assert_eq!(material.noise, 0.0); - } - if quality >= GlassQuality::NoSaturation { - assert_eq!(material.saturation, 1.0); - } - if quality >= GlassQuality::ReducedBlur { - assert!(material.blur <= 12.0); - } - if quality >= GlassQuality::Translucent { - assert_eq!(material.blur, 0.0); - } - if quality == GlassQuality::Solid { - assert_eq!(material.opacity, 1.0); - } - } - } - - #[test] - fn popover_uses_the_same_tint_with_a_smaller_blur_than_panel() { - let panel = resolve_material( + fn solid_materials_are_opaque_and_keep_role_specific_elevation() { + for role in [ SemanticRole::Panel, - MaterialStyle::Glass, - GlassSettings::default(), - ); - let popover = resolve_material( SemanticRole::Popover, - MaterialStyle::Glass, - GlassSettings::default(), - ); - - assert_eq!(popover.opacity, panel.opacity); - assert!(popover.blur < panel.blur); + SemanticRole::Menu, + SemanticRole::Hud, + SemanticRole::Notification, + SemanticRole::Modal, + ] { + let solid = resolve_material(role, MaterialStyle::Solid); + let translucent = resolve_material(role, MaterialStyle::Translucent); + assert_eq!(solid.opacity, 1.0); + assert!(translucent.opacity < 1.0); + assert_eq!(solid.shadow, translucent.shadow); + } } } diff --git a/compositor/src/handlers/compositor.rs b/compositor/src/handlers/compositor.rs index a23f4bc..51d6d8a 100644 --- a/compositor/src/handlers/compositor.rs +++ b/compositor/src/handlers/compositor.rs @@ -31,8 +31,8 @@ impl CompositorHandler for Ferese { } fn commit(&mut self, surface: &WlSurface) { + self.backdrop_generation = self.backdrop_generation.wrapping_add(1); on_commit_buffer_handler::(surface); - self.invalidate_material_scene(); if !is_sync_subsurface(surface) { let mut root = surface.clone(); while let Some(parent) = get_parent(&root) { @@ -63,9 +63,7 @@ impl CompositorHandler for Ferese { } fn destroyed(&mut self, surface: &WlSurface) { - // A removed popup changes the backdrop even without a final buffer - // commit. Invalidate expanded sampling regions as well as its bounds. - self.invalidate_material_scene(); + self.backdrop_generation = self.backdrop_generation.wrapping_add(1); crate::backends::direct::render_all(self); if self.idle_inhibitors.remove(surface).is_some() { self.idle_notifier_state diff --git a/compositor/src/metrics.rs b/compositor/src/metrics.rs index dc00488..9364997 100644 --- a/compositor/src/metrics.rs +++ b/compositor/src/metrics.rs @@ -5,10 +5,7 @@ use smithay::utils::{Physical, Rectangle}; const REPORT_INTERVAL: Duration = Duration::from_secs(5); #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub(crate) struct FrameEffectMetrics { - pub(crate) blur_regions: u64, - pub(crate) blur_sample_pixels: u64, -} +pub(crate) struct FrameEffectMetrics; #[derive(Debug)] pub(crate) struct RenderMetrics { @@ -19,9 +16,6 @@ pub(crate) struct RenderMetrics { damaged_pixels: u64, total_render_time: Duration, longest_render_time: Duration, - blur_frames: u64, - blur_regions: u64, - blur_sample_pixels: u64, } impl RenderMetrics { @@ -42,9 +36,6 @@ impl RenderMetrics { damaged_pixels: 0, total_render_time: Duration::ZERO, longest_render_time: Duration::ZERO, - blur_frames: 0, - blur_regions: 0, - blur_sample_pixels: 0, } } @@ -53,7 +44,7 @@ impl RenderMetrics { render_time: Duration, damage: &[Rectangle], missed_deadlines: u64, - effects: FrameEffectMetrics, + _effects: FrameEffectMetrics, ) { if !self.enabled { return; @@ -63,9 +54,6 @@ impl RenderMetrics { self.damaged_pixels += damage.iter().map(rectangle_area).sum::(); self.total_render_time += render_time; self.longest_render_time = self.longest_render_time.max(render_time); - self.blur_frames += u64::from(effects.blur_regions > 0); - self.blur_regions += effects.blur_regions; - self.blur_sample_pixels += effects.blur_sample_pixels; let now = Instant::now(); let elapsed = now.saturating_duration_since(self.interval_started); @@ -87,9 +75,6 @@ impl RenderMetrics { average_render_us, longest_render_us = self.longest_render_time.as_micros(), missed_deadlines, - blur_frames = self.blur_frames, - blur_regions = self.blur_regions, - blur_sample_pixels = self.blur_sample_pixels, "render performance" ); @@ -98,9 +83,6 @@ impl RenderMetrics { self.damaged_pixels = 0; self.total_render_time = Duration::ZERO; self.longest_render_time = Duration::ZERO; - self.blur_frames = 0; - self.blur_regions = 0; - self.blur_sample_pixels = 0; } } @@ -139,18 +121,12 @@ mod tests { Rectangle::new((10, 10).into(), (20, 10).into()), ], 0, - FrameEffectMetrics { - blur_regions: 2, - blur_sample_pixels: 4_096, - }, + FrameEffectMetrics, ); assert_eq!(metrics.rendered_frames, 1); assert_eq!(metrics.damaged_pixels, 5_200); assert_eq!(metrics.total_render_time, Duration::from_millis(2)); assert_eq!(metrics.longest_render_time, Duration::from_millis(2)); - assert_eq!(metrics.blur_frames, 1); - assert_eq!(metrics.blur_regions, 2); - assert_eq!(metrics.blur_sample_pixels, 4_096); } } diff --git a/compositor/src/state.rs b/compositor/src/state.rs index badce98..4863232 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -125,9 +125,9 @@ pub struct Ferese { pub(crate) rounded_clip_programs: HashMap, pub(crate) overview_scrims: HashMap, pub(crate) material_programs: HashMap, - pub(crate) backdrop_programs: HashMap, pub(crate) material_buffers: HashMap, - material_scene_generation: u64, + pub(crate) blur_programs: HashMap, + pub(crate) backdrop_generation: u64, closing_windows: HashMap, viewport_animations: HashMap, scrolling_world_x: HashMap, @@ -457,9 +457,9 @@ impl Ferese { rounded_clip_programs: HashMap::new(), overview_scrims: HashMap::new(), material_programs: HashMap::new(), - backdrop_programs: HashMap::new(), material_buffers: HashMap::new(), - material_scene_generation: 0, + blur_programs: HashMap::new(), + backdrop_generation: 0, closing_windows: HashMap::new(), viewport_animations: HashMap::new(), scrolling_world_x: HashMap::new(), @@ -930,6 +930,7 @@ impl Ferese { } pub fn relayout(&mut self) { + self.backdrop_generation = self.backdrop_generation.wrapping_add(1); self.arrange_layers(); let mut previous_scrolling_world_x = std::mem::take(&mut self.scrolling_world_x); let pending_column_width_cycles = std::mem::take(&mut self.pending_column_width_cycles); @@ -1346,20 +1347,11 @@ impl Ferese { self.sync_window_stacking(); if active_animation { - self.invalidate_material_scene(); + self.backdrop_generation = self.backdrop_generation.wrapping_add(1); } - active_animation } - pub(crate) fn invalidate_material_scene(&mut self) { - self.material_scene_generation = self.material_scene_generation.wrapping_add(1); - } - - pub(crate) fn material_scene_generation(&self) -> u64 { - self.material_scene_generation - } - pub(crate) fn animations_enabled(&self) -> bool { self.animations_enabled } diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index 3831c4b..ace4db2 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -80,7 +80,7 @@ render_elements! { Memory=MemoryRenderElement, Solid=SolidColorRenderElement, Border=PixelShaderElement, - Backdrop=BackdropRenderElement, + Blur=BlurRenderElement, } render_elements! { @@ -229,13 +229,7 @@ uniform float alpha; uniform vec4 tint; uniform vec4 visible_rect; uniform float material_radius; -uniform float pixel_scale; -uniform vec2 local_origin; -uniform vec4 local_axes; -uniform vec2 logical_size; uniform float paint_mode; -uniform float glass_edges; -uniform float edge_bar; uniform vec4 shadow_rect; uniform vec2 shadow_values; varying vec2 v_coords; @@ -249,29 +243,12 @@ float rounded_distance(vec2 point, vec4 rect) { void main() { float sdf = rounded_distance(gl_FragCoord.xy, visible_rect); float coverage = 1.0 - smoothstep(-0.5, 0.5, sdf); - if (paint_mode > 1.5) { + if (paint_mode > 0.5) { float distance = max(rounded_distance(gl_FragCoord.xy, shadow_rect), 0.0); float sigma = max(shadow_values.x * 0.5, 0.5); float opacity = exp(-0.5 * distance * distance / (sigma * sigma)) * shadow_values.y * (1.0 - coverage) * alpha; gl_FragColor = vec4(0.0, 0.0, 0.0, opacity); - } else if (paint_mode > 0.5) { - // Screen-edge bars and non-glass materials have no outline. - if (edge_bar > 0.5 || glass_edges < 0.5) { - gl_FragColor = vec4(0.0); - return; - } - float border = smoothstep(-pixel_scale - 0.5, -pixel_scale + 0.5, sdf); - float hairline = smoothstep(-2.0 * pixel_scale - 0.5, -2.0 * pixel_scale + 0.5, sdf) * (1.0 - border); - // Inset only on the logical top, including rotated/flipped outputs. - float top = dot(gl_FragCoord.xy - local_origin, local_axes.zw); - float top_limit = min(2.0 * pixel_scale, logical_size.y * pixel_scale); - float highlight = (1.0 - smoothstep(top_limit - 0.5, top_limit + 0.5, top)) * (1.0 - border); - float opacity = 1.0 - (1.0 - 0.14 * border) - * (1.0 - glass_edges * 0.04 * hairline) - * (1.0 - glass_edges * 0.18 * highlight); - opacity *= coverage * alpha; - gl_FragColor = vec4(vec3(opacity), opacity); } else { float opacity = tint.a * coverage * alpha; gl_FragColor = vec4(tint.rgb * opacity, opacity); @@ -279,15 +256,12 @@ void main() { } "#; -const BACKDROP_SHADER: &str = r#"#version 100 - +const BLUR_SHADER: &str = r#"#version 100 //_DEFINES_ - #if defined(EXTERNAL) #extension GL_OES_EGL_image_external : require #endif - -precision mediump float; +precision highp float; #if defined(EXTERNAL) uniform samplerExternalOES tex; #else @@ -295,161 +269,46 @@ uniform sampler2D tex; #endif uniform float alpha; uniform vec4 visible_rect; +uniform float material_radius; uniform vec2 texture_size; uniform float blur_radius; -uniform float tap_scale; -uniform float material_radius; -uniform float saturation; -uniform float brightness; uniform vec4 tint; -uniform float noise_amount; -uniform float pixel_scale; -uniform vec2 local_origin; -uniform vec4 local_axes; -uniform vec2 logical_size; varying vec2 v_coords; - -// Static, surface-local two-octave fractal noise. A small polynomial hash -// avoids the precision-dependent sine hash and does not animate with time. -float random(vec2 point) { - vec3 p = fract(vec3(point.xyx) * 0.1031); - p += dot(p, p.yzx + 33.33); - return fract((p.x + p.y) * p.z); -} -float noise(vec2 p) { - vec2 i = floor(p); - vec2 f = fract(p); - f = f * f * (3.0 - 2.0 * f); - return mix(mix(random(i), random(i + vec2(1.0, 0.0)), f.x), - mix(random(i + vec2(0.0, 1.0)), random(i + vec2(1.0)), f.x), f.y); -} - -float kernel_weight(float distance) { - return exp(-distance * distance / 18.0); -} - void main() { - vec2 point = gl_FragCoord.xy; - vec2 visible_max = visible_rect.xy + visible_rect.zw; - if (point.x < visible_rect.x || point.y < visible_rect.y - || point.x >= visible_max.x || point.y >= visible_max.y) { - gl_FragColor = vec4(0.0); - return; - } - + vec2 half_size = visible_rect.zw * 0.5; + vec2 d = abs(gl_FragCoord.xy - visible_rect.xy - half_size) - (half_size - vec2(material_radius)); + float sdf = length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - material_radius; + float coverage = alpha * (1.0 - smoothstep(-0.5, 0.5, sdf)); + if (coverage <= 0.0) { gl_FragColor = vec4(0.0); return; } + // Denser Gaussian sampling preserves the same radius and softness without + // the visible grid left by widely spaced taps on detailed wallpapers. vec2 step_size = vec2(blur_radius / 6.0) / max(texture_size, vec2(1.0)); vec4 color = vec4(0.0); - float weight_sum = 0.0; - for (int y = -6; y <= 6; y++) { - if (abs(float(y)) * tap_scale > 6.0) { continue; } - for (int x = -6; x <= 6; x++) { - if (abs(float(x)) * tap_scale > 6.0) { continue; } - float weight = kernel_weight(float(x) * tap_scale) * kernel_weight(float(y) * tap_scale); - vec2 offset = vec2(float(x), float(y)) * step_size * tap_scale; - color += texture2D(tex, v_coords + offset) * weight; - weight_sum += weight; + float weights = 0.0; + for (int y=-6; y<=6; y++) { + for (int x=-6; x<=6; x++) { + float weight = exp(-float(x*x+y*y) / 18.0); + color += texture2D(tex, v_coords + vec2(float(x),float(y))*step_size) * weight; + weights += weight; } } - color /= weight_sum; - - float luma = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722)); - vec3 adjusted = mix(vec3(luma), color.rgb, saturation) * brightness; - adjusted = mix(adjusted, tint.rgb, tint.a); - vec2 relative = point - local_origin; - vec2 local = vec2(dot(relative, local_axes.xy), dot(relative, local_axes.zw)) / pixel_scale; - vec2 size = logical_size; - // CSS 155deg: direction (sin(155deg), -cos(155deg)). Project onto - // the full gradient line, including the aspect ratio of the surface. - vec2 direction = vec2(0.422618, 0.906308); - float position = dot(local, direction) / max(dot(size, direction), 1.0); - adjusted = mix(adjusted, vec3(1.0), 0.10 * clamp(1.0 - position / 0.40, 0.0, 1.0)); - float grain = (noise(local * 0.8) + 0.5 * noise(local * 1.6)) / 1.5; - vec3 overlay = mix(2.0 * adjusted * grain, - 1.0 - 2.0 * (1.0 - adjusted) * (1.0 - grain), step(vec3(0.5), adjusted)); - adjusted = mix(adjusted, overlay, noise_amount); - vec2 half_size = visible_rect.zw * 0.5; - vec2 distance = abs(point - visible_rect.xy - half_size) - (half_size - vec2(material_radius)); - float sdf = length(max(distance, 0.0)) + min(max(distance.x, distance.y), 0.0) - material_radius; - float coverage = alpha * (1.0 - smoothstep(-0.5, 0.5, sdf)); - gl_FragColor = vec4(clamp(adjusted, 0.0, 1.0) * coverage, coverage); + vec3 background = color.rgb / weights; + gl_FragColor = vec4(mix(background, tint.rgb, tint.a) * coverage, coverage); } "#; #[derive(Clone, Debug)] -pub(crate) struct RoundedClipPrograms { - texture: GlesTexProgram, - border: GlesPixelProgram, - shadow: GlesPixelProgram, -} - -#[derive(Debug)] -pub(crate) struct OverviewScrim { - buffer: SolidColorBuffer, -} - -impl OverviewScrim { - fn new(size: Size) -> Self { - Self { - buffer: SolidColorBuffer::new(size, [0.02, 0.025, 0.035, 0.62]), - } - } -} +pub(crate) struct BlurProgram(GlesTexProgram); #[derive(Clone, Debug)] -pub(crate) struct MaterialProgram(GlesPixelProgram); - -#[derive(Clone, Debug)] -pub(crate) struct BackdropProgram(GlesTexProgram); - -#[derive(Clone, Debug, PartialEq)] -struct MaterialParameters { - radius: f32, - pixel_scale: f32, - local_origin: [f32; 2], - local_axes: [f32; 4], - logical_size: [f32; 2], - glass_edges: f32, - edge_bar: f32, - shadow_rect: [f32; 4], - shadow_values: [f32; 2], - shadow_bounds: Rectangle, +struct BlurCapture { + texture: GlesTexture, + dirty: Arc, geometry: Rectangle, - tint: [f32; 4], - noise: f32, - generation: u64, - scene_generation: u64, - opaque: bool, - blur: f32, - tap_scale: f32, - saturation: f32, - brightness: f32, - sample_geometry: Rectangle, - sample_physical: Rectangle, - visible_framebuffer: [f32; 4], - sample_framebuffer: [f32; 4], -} - -#[derive(Debug)] -struct CachedMaterial { - element: CachedMaterialElement, - parameters: MaterialParameters, - edge: PixelShaderElement, - shadow: PixelShaderElement, } #[derive(Clone, Debug)] -enum CachedMaterialElement { - Tint(PixelShaderElement), - Backdrop(BackdropRenderElement), -} - -#[derive(Debug, Default)] -pub(crate) struct MaterialBuffers { - contexts: HashMap, -} - -#[derive(Clone, Debug)] -struct BackdropRenderElement { +struct BlurRenderElement { capture_dirty: Arc, texture: GlesTexture, program: GlesTexProgram, @@ -460,17 +319,22 @@ struct BackdropRenderElement { uniforms: Vec>, } -impl BackdropRenderElement { - fn new(texture: GlesTexture, program: GlesTexProgram, parameters: &MaterialParameters) -> Self { +impl BlurRenderElement { + fn new( + texture: GlesTexture, + program: GlesTexProgram, + parameters: &MaterialParameters, + capture_dirty: Arc, + ) -> Self { Self { - capture_dirty: Arc::new(AtomicBool::new(true)), + capture_dirty, texture, program, id: Id::new(), commit: CommitCounter::default(), geometry: parameters.sample_geometry, capture_rect: framebuffer_capture_rect(parameters.sample_framebuffer), - uniforms: backdrop_uniforms(parameters), + uniforms: blur_uniforms(parameters), } } @@ -478,12 +342,12 @@ impl BackdropRenderElement { self.capture_dirty.store(true, Ordering::Relaxed); self.geometry = parameters.sample_geometry; self.capture_rect = framebuffer_capture_rect(parameters.sample_framebuffer); - self.uniforms = backdrop_uniforms(parameters); + self.uniforms = blur_uniforms(parameters); self.commit.increment(); } } -impl Element for BackdropRenderElement { +impl Element for BlurRenderElement { fn id(&self) -> &Id { &self.id } @@ -505,7 +369,7 @@ impl Element for BackdropRenderElement { scale: RenderScale, commit: Option, ) -> DamageSet { - backdrop_damage(self.geometry(scale).size, self.commit, commit) + blur_damage(self.geometry(scale).size, self.commit, commit) } fn opaque_regions(&self, _scale: RenderScale) -> OpaqueRegions { @@ -523,7 +387,7 @@ impl Element for BackdropRenderElement { } } -fn backdrop_damage( +fn blur_damage( size: Size, current: CommitCounter, previous: Option, @@ -536,7 +400,7 @@ fn backdrop_damage( } } -impl RenderElement for BackdropRenderElement { +impl RenderElement for BlurRenderElement { fn draw( &self, frame: &mut GlesFrame<'_, '_>, @@ -549,7 +413,7 @@ impl RenderElement for BackdropRenderElement { let [x, y, width, height] = self.capture_rect; // Buffer-age restoration and unrelated partial damage must reuse the // clean captured scene. Recapturing a partly repainted framebuffer - // would feed previous glass pixels back into the blur. + // would feed previous blurred pixels back into the blur. if self.capture_dirty.load(Ordering::Relaxed) { frame.with_context(|gl| unsafe { gl.BindTexture(smithay::backend::renderer::gles::ffi::TEXTURE_2D, texture); @@ -596,6 +460,66 @@ impl RenderElement for BackdropRenderElement { } } +#[derive(Clone, Debug)] +pub(crate) struct RoundedClipPrograms { + texture: GlesTexProgram, + border: GlesPixelProgram, + shadow: GlesPixelProgram, +} + +#[derive(Debug)] +pub(crate) struct OverviewScrim { + buffer: SolidColorBuffer, +} + +impl OverviewScrim { + fn new(size: Size) -> Self { + Self { + buffer: SolidColorBuffer::new(size, [0.02, 0.025, 0.035, 0.62]), + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct MaterialProgram(GlesPixelProgram); + +#[derive(Clone, Debug, PartialEq)] +struct MaterialParameters { + blur: f32, + scene_generation: u64, + sample_geometry: Rectangle, + sample_physical: Rectangle, + sample_framebuffer: [f32; 4], + radius: f32, + shadow_rect: [f32; 4], + shadow_values: [f32; 2], + shadow_bounds: Rectangle, + geometry: Rectangle, + tint: [f32; 4], + generation: u64, + opaque: bool, + visible_framebuffer: [f32; 4], +} + +#[derive(Clone, Debug)] +enum MaterialElement { + Fill(PixelShaderElement), + Blur(BlurRenderElement), +} + +#[derive(Debug)] +struct CachedMaterial { + element: MaterialElement, + parameters: MaterialParameters, + shadow: PixelShaderElement, +} + +#[derive(Debug, Default)] +pub(crate) struct MaterialBuffers { + contexts: HashMap<(ErasedContextId, usize), CachedMaterial>, + captures: HashMap, +} + #[derive(Clone, Debug, PartialEq)] struct BorderParameters { geometry: Rectangle, @@ -618,10 +542,10 @@ pub(crate) struct WindowBorderBuffers { #[derive(Clone, Debug, PartialEq)] struct ShadowParameters { + blur: f32, bounds: Rectangle, shadow_rect: [f32; 4], radius: f32, - blur: f32, opacity: f32, color: [f32; 4], } @@ -939,20 +863,10 @@ pub(crate) fn cursorless_window_elements( } pub(crate) fn frame_effect_metrics( - elements: &[AnimatedWindowRenderElement], - scale: f64, + _elements: &[AnimatedWindowRenderElement], + _scale: f64, ) -> FrameEffectMetrics { - elements - .iter() - .fold(FrameEffectMetrics::default(), |mut metrics, element| { - if let AnimatedWindowRenderElement::Backdrop(backdrop) = element { - let geometry = backdrop.geometry(scale.into()); - metrics.blur_regions += 1; - metrics.blur_sample_pixels += u64::try_from(geometry.size.w.max(0)).unwrap_or(0) - * u64::try_from(geometry.size.h.max(0)).unwrap_or(0); - } - metrics - }) + FrameEffectMetrics::default() } fn output_elements( @@ -1275,6 +1189,7 @@ fn window_shadow_element( ); let bounds = shadow_bounds(geometry, offset_y, blur); let parameters = ShadowParameters { + blur: (blur * scale) as f32, bounds, shadow_rect: framebuffer_clip_rect( shadow_geometry.to_physical_precise_round(scale), @@ -1282,7 +1197,6 @@ fn window_shadow_element( output.current_transform().invert(), ), radius: scaled_effect_value(requested_radius, geometry, scale), - blur: (blur * scale) as f32, opacity: opacity as f32, color, }; @@ -1604,7 +1518,36 @@ fn append_material_surface( } else { state.theme_settings.material_radius }; - let material = material_element(state, renderer, output, surface, geometry, radius as f32); + let regions = crate::effects::surface_regions(surface); + let targets: Vec<_> = match ®ions { + None => vec![(geometry, radius as f32)], + Some(regions) => regions + .iter() + .filter_map(|r| { + Rectangle::new( + (content_origin.x + r[0], content_origin.y + r[1]).into(), + (r[2], r[3]).into(), + ) + .intersection(geometry) + .map(|rect| (rect, r[4] as f32)) + }) + .collect(), + }; + let materials: Vec<_> = targets + .iter() + .enumerate() + .filter_map(|(index, (rect, radius))| { + material_element( + state, renderer, output, surface, *rect, *radius, index, geometry, + ) + }) + .collect(); + if let Some(buffers) = state.material_buffers.get_mut(surface) { + buffers + .contexts + .retain(|(_, index), _| *index < targets.len()); + } + let material = (!materials.is_empty() && regions.is_none()).then_some(()); let clip = material.as_ref().and_then(|_| { let mode = output.current_mode()?; let program = rounded_clip_program(state, renderer)?; @@ -1618,11 +1561,7 @@ fn append_material_surface( scaled_effect_value(radius, geometry, scale), )) }); - // Smithay element lists are front to back: edges, content, material, shadow. - let material = material.map(|(edge, background, shadow)| { - elements.push(edge); - (background, shadow) - }); + // Smithay element lists are front to back: content, material, shadow. let content = render_elements_from_surface_tree::< GlesRenderer, WaylandSurfaceRenderElement, @@ -1651,7 +1590,7 @@ fn append_material_surface( let element = RelocateRenderElement::from_element(element, origin, Relocate::Relative); CropRenderElement::from_element(element, scale, output_crop).map(Into::into) })); - if let Some((background, shadow)) = material { + for (background, shadow) in materials { elements.push(background); elements.push(shadow); } @@ -1664,54 +1603,20 @@ fn material_element( surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface, geometry: Rectangle, radius: f32, -) -> Option<( - AnimatedWindowRenderElement, - AnimatedWindowRenderElement, - AnimatedWindowRenderElement, -)> { - material_element_for_style( - state, - renderer, - output, - surface, - geometry, - radius, - state.theme_settings.material_style, - ) -} - -#[allow(clippy::too_many_arguments)] -fn material_element_for_style( - state: &mut Ferese, - renderer: &mut GlesRenderer, - output: &Output, - surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface, - geometry: Rectangle, - radius: f32, - style: crate::config::MaterialStyle, -) -> Option<( - AnimatedWindowRenderElement, - AnimatedWindowRenderElement, - AnimatedWindowRenderElement, -)> { + index: usize, + capture_geometry: Rectangle, +) -> Option<(AnimatedWindowRenderElement, AnimatedWindowRenderElement)> { let (role, generation) = crate::effects::surface_role(surface)?; - let material = crate::effects::resolve_material(role, style, state.theme_settings.glass); + let mut material = crate::effects::resolve_material(role, state.theme_settings.material_style); + if role == crate::effects::SemanticRole::Panel + && material.style == crate::config::MaterialStyle::Translucent + { + material.opacity = state.theme_settings.panel_opacity as f32; + } let mode = output.current_mode()?; let scale = output.current_scale().fractional_scale(); - let output_logical = smithay::utils::Size::::from(( - (f64::from(mode.size.w) / scale).ceil() as i32, - (f64::from(mode.size.h) / scale).ceil() as i32, - )); - let sample_geometry = - expanded_blur_region(geometry, material.blur.ceil() as i32, output_logical); - let sample_physical = sample_geometry.to_physical_precise_round(scale); let transform = output.current_transform().invert(); - let [red, green, blue, _] = - if state.theme_settings.material_style == crate::config::MaterialStyle::Glass { - state.theme_settings.glass.tint.0 - } else { - state.theme_settings.surface_base_color.0 - }; + let [red, green, blue, _] = state.theme_settings.surface_base_color.0; let radius = radius.min(geometry.size.w.min(geometry.size.h) as f32 * 0.5); let [offset_y, shadow_blur, shadow_opacity] = material.shadow; let edge_bar = @@ -1721,24 +1626,30 @@ fn material_element_for_style( (geometry.loc.x, geometry.loc.y + offset_y.round() as i32).into(), geometry.size, ); - let tint = [red, green, blue, material.opacity]; - let (local_origin, local_axes) = material_local_coordinates( - geometry.loc.to_physical_precise_round(scale), - mode.size, - transform, - ); + let blur = if material.style == crate::config::MaterialStyle::Translucent { + state.theme_settings.backdrop_blur + } else { + 0.0 + }; + let output_size = output + .current_transform() + .transform_size(mode.size) + .to_f64() + .to_logical(scale) + .to_i32_ceil(); + let sample_geometry = expanded_blur_region(capture_geometry, blur.ceil() as i32, output_size); + let sample_physical = sample_geometry.to_physical_precise_round(scale); let parameters = MaterialParameters { - radius: radius * scale as f32, - pixel_scale: scale as f32, - local_origin, - local_axes, - logical_size: [geometry.size.w as f32, geometry.size.h as f32], - edge_bar: if edge_bar { 1.0 } else { 0.0 }, - glass_edges: if material.style == crate::config::MaterialStyle::Glass { - 1.0 + blur: (blur * scale) as f32, + scene_generation: if blur > 0.0 { + state.backdrop_generation } else { - 0.0 + 0 }, + sample_geometry, + sample_physical, + sample_framebuffer: framebuffer_clip_rect(sample_physical, mode.size, transform), + radius: radius * scale as f32, shadow_rect: framebuffer_clip_rect( shadow_geometry.to_physical_precise_round(scale), mode.size, @@ -1747,164 +1658,183 @@ fn material_element_for_style( shadow_values: [(shadow_blur * scale) as f32, shadow_opacity as f32], shadow_bounds: shadow_bounds(geometry, offset_y, shadow_blur), geometry, - tint, - noise: material.noise, + tint: [red, green, blue, material.opacity], generation, - scene_generation: matches!(material.style, crate::config::MaterialStyle::Glass) - .then(|| state.material_scene_generation()) - .unwrap_or(0), opaque: material.opacity == 1.0 && radius == 0.0, - blur: material.blur * scale as f32, - tap_scale: if state.theme_settings.glass.quality >= crate::config::GlassQuality::ReducedBlur - { - 2.0 - } else { - 1.0 - }, - saturation: material.saturation, - brightness: material.brightness, - sample_geometry, - sample_physical, visible_framebuffer: framebuffer_clip_rect( geometry.to_physical_precise_round(scale), mode.size, transform, ), - sample_framebuffer: framebuffer_clip_rect(sample_physical, mode.size, transform), }; let context = renderer.context_id().erased(); - let needs_replacement = state - .material_buffers - .get(surface) - .and_then(|buffers| buffers.contexts.get(&context)) - .is_none_or(|cached| match (&cached.element, material.style) { - (CachedMaterialElement::Backdrop(_), crate::config::MaterialStyle::Glass) => { - cached.parameters.sample_physical.size != parameters.sample_physical.size - } - ( - CachedMaterialElement::Tint(_), - crate::config::MaterialStyle::Translucent | crate::config::MaterialStyle::Solid, - ) => false, - _ => true, - }); - let mut replacement = needs_replacement - .then(|| match material.style { - crate::config::MaterialStyle::Glass => { - let program = backdrop_program(state, renderer)?; - let texture_size = smithay::utils::Size::::from(( - sample_physical.size.w, - sample_physical.size.h, - )); - let texture = Offscreen::::create_buffer( - renderer, - Fourcc::Abgr8888, - texture_size, - ) - .ok()?; - Some(CachedMaterialElement::Backdrop(BackdropRenderElement::new( - texture, - program.0, - ¶meters, - ))) - } - crate::config::MaterialStyle::Translucent | crate::config::MaterialStyle::Solid => { - let program = material_program(state, renderer)?; - Some(CachedMaterialElement::Tint(PixelShaderElement::new( - program.0, - geometry, - parameters - .opaque - .then(|| vec![Rectangle::from_size(geometry.size)]), - 1.0, - material_uniforms(¶meters), - RenderElementKind::Unspecified, - ))) - } - }) + let program = material_program(state, renderer)?; + let blur_program = (blur > 0.0) + .then(|| blur_program(state, renderer)) .flatten(); - if needs_replacement - && replacement.is_none() - && material.style == crate::config::MaterialStyle::Glass - { - return material_element_for_style( - state, - renderer, - output, - surface, - geometry, - radius, - crate::config::MaterialStyle::Translucent, - ); - } - let decoration_program = material_program(state, renderer)?; let buffers = state.material_buffers.entry(surface.clone()).or_default(); - if !buffers.contexts.contains_key(&context) { - buffers.contexts.insert( - context.clone(), - CachedMaterial { - element: replacement.take()?, - parameters: parameters.clone(), - edge: PixelShaderElement::new( - decoration_program.0.clone(), - geometry, - None, - 1.0, - decoration_uniforms(¶meters, 1.0), - RenderElementKind::Unspecified, - ), - shadow: PixelShaderElement::new( - decoration_program.0.clone(), - parameters.shadow_bounds, - None, - 1.0, - decoration_uniforms(¶meters, 2.0), - RenderElementKind::Unspecified, - ), - }, - ); - } - - let cached = buffers.contexts.get_mut(&context)?; - if let Some(replacement) = replacement.take() { - cached.element = replacement; + let capture = if let Some(blur_program) = blur_program { + if buffers.captures.get(&context).is_none_or(|c| { + c.geometry != sample_geometry + || c.texture.size() != Size::from((sample_physical.size.w, sample_physical.size.h)) + }) { + let texture = Offscreen::::create_buffer( + renderer, + Fourcc::Abgr8888, + (sample_physical.size.w, sample_physical.size.h).into(), + ) + .ok(); + if let Some(texture) = texture { + buffers.captures.insert( + context.clone(), + BlurCapture { + texture, + dirty: Arc::new(AtomicBool::new(true)), + geometry: sample_geometry, + }, + ); + } else { + buffers.captures.remove(&context); + } + } + buffers + .captures + .get(&context) + .cloned() + .map(|capture| (capture, blur_program)) + } else { + buffers.captures.remove(&context); + None + }; + let make_element = || { + if let Some((capture, program)) = &capture { + MaterialElement::Blur(BlurRenderElement::new( + capture.texture.clone(), + program.0.clone(), + ¶meters, + capture.dirty.clone(), + )) + } else { + MaterialElement::Fill(PixelShaderElement::new( + program.0.clone(), + geometry, + parameters + .opaque + .then(|| vec![Rectangle::from_size(geometry.size)]), + 1.0, + material_uniforms(¶meters), + RenderElementKind::Unspecified, + )) + } + }; + let cached = buffers + .contexts + .entry((context, index)) + .or_insert_with(|| CachedMaterial { + element: make_element(), + parameters: parameters.clone(), + shadow: PixelShaderElement::new( + program.0.clone(), + parameters.shadow_bounds, + None, + 1.0, + decoration_uniforms(¶meters, 2.0), + RenderElementKind::Unspecified, + ), + }); + let replace = match &cached.element { + MaterialElement::Fill(_) => capture.is_some(), + MaterialElement::Blur(element) => capture + .as_ref() + .is_none_or(|(capture, _)| capture.texture.tex_id() != element.texture.tex_id()), + }; + if replace { + cached.element = make_element(); } if cached.parameters != parameters { - cached.edge.resize(geometry, None); - cached - .edge - .update_uniforms(decoration_uniforms(¶meters, 1.0)); cached.shadow.resize(parameters.shadow_bounds, None); cached .shadow .update_uniforms(decoration_uniforms(¶meters, 2.0)); match &mut cached.element { - CachedMaterialElement::Tint(element) => { - if cached.parameters.geometry != parameters.geometry - || cached.parameters.opaque != parameters.opaque - { - let opaque = parameters + MaterialElement::Fill(element) => { + element.resize( + geometry, + parameters .opaque - .then(|| vec![Rectangle::from_size(parameters.geometry.size)]); - element.resize(parameters.geometry, opaque); - } + .then(|| vec![Rectangle::from_size(geometry.size)]), + ); element.update_uniforms(material_uniforms(¶meters)); } - CachedMaterialElement::Backdrop(element) => { - element.update(¶meters); - } + MaterialElement::Blur(element) => element.update(¶meters), } cached.parameters = parameters; } - let background = match &cached.element { - CachedMaterialElement::Tint(element) => element.clone().into(), - CachedMaterialElement::Backdrop(element) => element.clone().into(), + MaterialElement::Fill(element) => element.clone().into(), + MaterialElement::Blur(element) => element.clone().into(), }; - Some(( - cached.edge.clone().into(), - background, - cached.shadow.clone().into(), - )) + Some((background, cached.shadow.clone().into())) +} + +fn blur_program(state: &mut Ferese, renderer: &mut GlesRenderer) -> Option { + let context = renderer.context_id().erased(); + if let Some(program) = state.blur_programs.get(&context) { + return Some(program.clone()); + } + let uniforms = [ + UniformName::new("visible_rect", UniformType::_4f), + UniformName::new("material_radius", UniformType::_1f), + UniformName::new("texture_size", UniformType::_2f), + UniformName::new("blur_radius", UniformType::_1f), + UniformName::new("tint", UniformType::_4f), + ]; + match renderer.compile_custom_texture_shader(BLUR_SHADER, &uniforms) { + Ok(program) => { + let program = BlurProgram(program); + state.blur_programs.insert(context, program.clone()); + Some(program) + } + Err(error) => { + tracing::warn!(%error,"backdrop blur unavailable; using plain translucent fill"); + None + } + } +} + +fn blur_uniforms(p: &MaterialParameters) -> Vec> { + vec![ + Uniform::new("visible_rect", p.visible_framebuffer).into_owned(), + Uniform::new("material_radius", p.radius).into_owned(), + Uniform::new( + "texture_size", + [ + p.sample_physical.size.w as f32, + p.sample_physical.size.h as f32, + ], + ) + .into_owned(), + Uniform::new("blur_radius", p.blur).into_owned(), + Uniform::new("tint", p.tint).into_owned(), + ] +} + +fn framebuffer_capture_rect(rect: [f32; 4]) -> [i32; 4] { + rect.map(|v| v.round() as i32) +} +fn expanded_blur_region( + visible: Rectangle, + radius: i32, + output_size: Size, +) -> Rectangle { + let left = (visible.loc.x - radius).max(0); + let top = (visible.loc.y - radius).max(0); + let right = (visible.loc.x + visible.size.w + radius).min(output_size.w); + let bottom = (visible.loc.y + visible.size.h + radius).min(output_size.h); + Rectangle::new( + (left, top).into(), + ((right - left).max(1), (bottom - top).max(1)).into(), + ) } fn material_program(state: &mut Ferese, renderer: &mut GlesRenderer) -> Option { @@ -1917,13 +1847,7 @@ fn material_program(state: &mut Ferese, renderer: &mut GlesRenderer) -> Option Option Option { - let context = renderer.context_id().erased(); - if let Some(program) = state.backdrop_programs.get(&context) { - return Some(program.clone()); - } - - let uniforms = [ - UniformName::new("visible_rect", UniformType::_4f), - UniformName::new("material_radius", UniformType::_1f), - UniformName::new("texture_size", UniformType::_2f), - UniformName::new("blur_radius", UniformType::_1f), - UniformName::new("tap_scale", UniformType::_1f), - UniformName::new("saturation", UniformType::_1f), - UniformName::new("brightness", UniformType::_1f), - UniformName::new("tint", UniformType::_4f), - UniformName::new("noise_amount", UniformType::_1f), - UniformName::new("pixel_scale", UniformType::_1f), - UniformName::new("local_origin", UniformType::_2f), - UniformName::new("local_axes", UniformType::_4f), - UniformName::new("logical_size", UniformType::_2f), - ]; - match renderer.compile_custom_texture_shader(BACKDROP_SHADER, &uniforms) { - Ok(program) => { - let program = BackdropProgram(program); - state.backdrop_programs.insert(context, program.clone()); - Some(program) - } - Err(error) => { - tracing::error!(%error, "failed to compile backdrop material shader"); - None - } - } -} - fn material_uniforms(parameters: &MaterialParameters) -> Vec> { decoration_uniforms(parameters, 0.0) } fn decoration_uniforms(parameters: &MaterialParameters, paint_mode: f32) -> Vec> { vec![ - Uniform::new("pixel_scale", parameters.pixel_scale).into_owned(), - Uniform::new("local_origin", parameters.local_origin).into_owned(), - Uniform::new("local_axes", parameters.local_axes).into_owned(), - Uniform::new("logical_size", parameters.logical_size).into_owned(), Uniform::new("paint_mode", paint_mode).into_owned(), - Uniform::new("glass_edges", parameters.glass_edges).into_owned(), - Uniform::new("edge_bar", parameters.edge_bar).into_owned(), Uniform::new("shadow_rect", parameters.shadow_rect).into_owned(), Uniform::new("shadow_values", parameters.shadow_values).into_owned(), Uniform::new("visible_rect", parameters.visible_framebuffer).into_owned(), @@ -1995,72 +1879,6 @@ fn decoration_uniforms(parameters: &MaterialParameters, paint_mode: f32) -> Vec< ] } -fn backdrop_uniforms(parameters: &MaterialParameters) -> Vec> { - vec![ - Uniform::new("pixel_scale", parameters.pixel_scale).into_owned(), - Uniform::new("local_origin", parameters.local_origin).into_owned(), - Uniform::new("local_axes", parameters.local_axes).into_owned(), - Uniform::new("logical_size", parameters.logical_size).into_owned(), - Uniform::new("material_radius", parameters.radius).into_owned(), - Uniform::new("visible_rect", parameters.visible_framebuffer).into_owned(), - Uniform::new( - "texture_size", - [ - parameters.sample_physical.size.w as f32, - parameters.sample_physical.size.h as f32, - ], - ) - .into_owned(), - Uniform::new("blur_radius", parameters.blur).into_owned(), - Uniform::new("tap_scale", parameters.tap_scale).into_owned(), - Uniform::new("saturation", parameters.saturation).into_owned(), - Uniform::new("brightness", parameters.brightness).into_owned(), - Uniform::new("tint", parameters.tint).into_owned(), - Uniform::new("noise_amount", parameters.noise).into_owned(), - ] -} - -fn material_local_coordinates( - origin: Point, - output_size: Size, - transform: Transform, -) -> ([f32; 2], [f32; 4]) { - let area = transform.transform_size(output_size); - let mapped = transform.transform_point_in(origin, &area); - let x = transform.transform_point_in(origin + Point::from((1, 0)), &area) - mapped; - let y = transform.transform_point_in(origin + Point::from((0, 1)), &area) - mapped; - ( - [mapped.x as f32, mapped.y as f32], - [x.x as f32, x.y as f32, y.x as f32, y.y as f32], - ) -} - -fn framebuffer_capture_rect(rect: [f32; 4]) -> [i32; 4] { - [ - rect[0].round() as i32, - rect[1].round() as i32, - rect[2].round() as i32, - rect[3].round() as i32, - ] -} - -fn expanded_blur_region( - visible: Rectangle, - radius: i32, - output_size: smithay::utils::Size, -) -> Rectangle { - let radius = radius.max(0); - let left = (visible.loc.x - radius).max(0); - let top = (visible.loc.y - radius).max(0); - let right = (visible.loc.x + visible.size.w + radius).min(output_size.w); - let bottom = (visible.loc.y + visible.size.h + radius).min(output_size.h); - - Rectangle::new( - (left, top).into(), - ((right - left).max(1), (bottom - top).max(1)).into(), - ) -} - fn cursor_elements( state: &Ferese, renderer: &mut GlesRenderer, @@ -2152,13 +1970,12 @@ mod tests { element::{Element, Id}, utils::CommitCounter, }, - utils::{Buffer, Logical, Physical, Point, Rectangle, Scale, Size, Transform}, + utils::{Buffer, Logical, Physical, Rectangle, Scale, Transform}, }; use super::{ - color_with_alpha, expanded_blur_region, framebuffer_capture_rect, framebuffer_clip_rect, - material_local_coordinates, normalized_scale, rounded_visual_rect, scaled_visual_rect, - shadow_bounds, + color_with_alpha, framebuffer_clip_rect, normalized_scale, rounded_visual_rect, + scaled_visual_rect, shadow_bounds, }; #[derive(Debug)] @@ -2205,30 +2022,12 @@ mod tests { scale: Scale, commit: Option, ) -> smithay::backend::renderer::utils::DamageSet { - super::backdrop_damage(self.geometry(scale).size, self.current_commit(), commit) - } - } - - #[test] - fn backdrop_is_idle_until_scene_changes_then_repaints_full_sampling_region() { - for scale in [1.0, 1.25, 1.8, 2.0] { - for transform in [Transform::Normal, Transform::Flipped180] { - let mut tracker = OutputDamageTracker::new((1600, 1200), scale, transform); - let mut backdrop = - DamageElement::new(Rectangle::new((50, 40).into(), (400, 240).into())); - tracker.damage_output(0, &[&backdrop]).unwrap(); - for _ in 0..3 { - assert!(tracker.damage_output(1, &[&backdrop]).unwrap().0.is_none()); - backdrop.commit.increment(); - let damage = tracker - .damage_output(1, &[&backdrop]) - .unwrap() - .0 - .cloned() - .unwrap(); - let bounds = backdrop.geometry(scale.into()); - assert!(damage.iter().any(|rect| rect.contains_rect(bounds))); - } + if commit == Some(self.current_commit()) { + smithay::backend::renderer::utils::DamageSet::default() + } else { + smithay::backend::renderer::utils::DamageSet::from_slice(&[Rectangle::from_size( + self.geometry(scale).size, + )]) } } } @@ -2255,42 +2054,6 @@ mod tests { ); } - #[test] - fn glass_lighting_coordinates_follow_every_output_transform() { - let origin = Point::::from((120, 90)); - let size = Size::::from((1000, 800)); - for transform in [ - Transform::Normal, - Transform::_90, - Transform::_180, - Transform::_270, - Transform::Flipped, - Transform::Flipped90, - Transform::Flipped180, - Transform::Flipped270, - ] { - let (mapped_origin, axes) = material_local_coordinates(origin, size, transform); - for local in [(0, 0), (24, 0), (0, 35), (57, 81)] { - let point = transform.transform_point_in( - origin + Point::from(local), - &transform.transform_size(size), - ); - let relative = [ - point.x as f32 - mapped_origin[0], - point.y as f32 - mapped_origin[1], - ]; - assert_eq!( - relative[0] * axes[0] + relative[1] * axes[1], - local.0 as f32 - ); - assert_eq!( - relative[0] * axes[2] + relative[1] * axes[3], - local.1 as f32 - ); - } - } - } - #[test] fn clip_rect_follows_output_transform() { let geometry = Rectangle::::new((10, 5).into(), (30, 40).into()); @@ -2363,34 +2126,6 @@ mod tests { } } - #[test] - fn blur_sampling_expands_around_the_visible_surface() { - let visible = Rectangle::::new((100, 80).into(), (400, 300).into()); - - assert_eq!( - expanded_blur_region(visible, 24, (1_000, 800).into()), - Rectangle::new((76, 56).into(), (448, 348).into()) - ); - } - - #[test] - fn blur_sampling_clips_to_output_edges() { - let visible = Rectangle::::new((0, 760).into(), (300, 40).into()); - - assert_eq!( - expanded_blur_region(visible, 24, (1_000, 800).into()), - Rectangle::new((0, 736).into(), (324, 64).into()) - ); - } - - #[test] - fn framebuffer_capture_rounds_fractional_bounds() { - assert_eq!( - framebuffer_capture_rect([10.4, 20.6, 300.2, 199.8]), - [10, 21, 300, 200] - ); - } - #[test] fn shadow_bounds_include_blur_and_offset() { let geometry = Rectangle::::new((100, 80).into(), (400, 300).into()); diff --git a/protocols/ferese-effects-v1.xml b/protocols/ferese-effects-v1.xml index 2b566dd..a4f87c0 100644 --- a/protocols/ferese-effects-v1.xml +++ b/protocols/ferese-effects-v1.xml @@ -22,7 +22,7 @@ DEALINGS IN THE SOFTWARE. - + This private global is visible only to clients granted the Ferese effects capability. Roles describe compositor-owned appearance policy and do not @@ -48,7 +48,7 @@ - + The role affects only compositor rendering. It does not change surface geometry, input, focus, stacking, or the surface's Wayland role. @@ -78,5 +78,15 @@ + + + + An array of native-endian signed 32-bit tuples (x, y, width, height, + radius), measured in logical pixels. At most 32 rectangles are allowed. + An empty array disables material painting without clearing the role. + Surfaces which never send this request retain full-surface materials. + + + diff --git a/shell/ferese-shell/src/config.rs b/shell/ferese-shell/src/config.rs index 616770b..d551742 100644 --- a/shell/ferese-shell/src/config.rs +++ b/shell/ferese-shell/src/config.rs @@ -86,7 +86,7 @@ impl Default for ShellTheme { } impl ShellTheme { - // The bar has its own foreground tokens, matched to its dark glass surface. + // The bar has its own foreground tokens, matched to its surface. pub(crate) fn for_bar(mut self) -> Self { self.text_primary = self.bar_text_primary; self.text_muted = self.bar_text_muted; diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 02145d0..f6ec45e 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -22,8 +22,7 @@ use cosmic::iced::platform_specific::{ shell::commands::layer_surface::{Anchor, KeyboardInteractivity, Layer}, }; use cosmic::iced::{ - Background, Border, Color, ContentFit, Event, Length, Limits, Shadow, Subscription, Vector, - window, + Background, Border, Color, ContentFit, Event, Length, Limits, Subscription, window, }; use cosmic::theme; use cosmic::widget::{button, container, icon, image, row}; @@ -218,6 +217,9 @@ impl cosmic::Application for FereseShell { anchor: Anchor::TOP | Anchor::RIGHT | Anchor::BOTTOM | Anchor::LEFT, output: IcedOutput::Active, namespace: "ferese-shell-wallpaper".to_owned(), + // Wallpaper covers the full output, including beneath the bar + // and its floating margins. Zero would use the remaining workspace. + exclusive_zone: -1, size: Some((None, None)), size_limits: Limits::NONE, ..Default::default() @@ -294,6 +296,14 @@ impl cosmic::Application for FereseShell { } fn update(&mut self, message: Self::Message) -> Task { + if let Some(menu) = &self.menu + && let Some(effects) = &menu.effects + { + let regions = menu.regions.lock().unwrap().clone(); + if let Err(error) = effects.set_regions(®ions) { + eprintln!("ferese-shell: could not update card materials: {error}"); + } + } match message { Message::WallpaperLoaded(result) => { match result { @@ -312,10 +322,7 @@ impl cosmic::Application for FereseShell { && menu.id == id && menu.effects.is_none() { - match EffectsBinding::attach_role( - &surface, - Some(ferese_surface_effects_v1::Role::Popover), - ) { + match EffectsBinding::attach_role(&surface, menu.kind.material_role()) { Ok(binding) => menu.effects = Some(binding), Err(error) => { eprintln!("ferese-shell: popover material unavailable: {error}") @@ -467,7 +474,7 @@ impl FereseShell { if menu.effects.is_none() { match EffectsBinding::attach_role( &surface, - Some(ferese_surface_effects_v1::Role::Popover), + menu.kind.material_role(), ) { Ok(binding) => menu.effects = Some(binding), Err(error) => eprintln!( @@ -481,7 +488,7 @@ impl FereseShell { if menu.effects.is_none() { match EffectsBinding::attach_role( &surface, - Some(ferese_surface_effects_v1::Role::Popover), + menu.kind.material_role(), ) { Ok(binding) => menu.effects = Some(binding), Err(error) => eprintln!( @@ -513,10 +520,7 @@ impl FereseShell { ))) if self.menu.as_ref().is_some_and(|menu| menu.id == frame_id) => { if let Some(menu) = &mut self.menu { if menu.effects.is_none() { - match EffectsBinding::attach_role( - &surface, - Some(ferese_surface_effects_v1::Role::Popover), - ) { + match EffectsBinding::attach_role(&surface, menu.kind.material_role()) { Ok(binding) => menu.effects = Some(binding), Err(error) => { eprintln!("ferese-shell: popover material unavailable: {error}") @@ -634,10 +638,13 @@ impl FereseShell { }); windows = windows.push( button::custom( - text(label) - .size(bar.text_size) - .wrapping(cosmic::iced::core::text::Wrapping::None) - .class(theme::Text::Color(foreground)), + container( + text(label) + .size(bar.text_size) + .wrapping(cosmic::iced::core::text::Wrapping::None) + .class(theme::Text::Color(foreground)), + ) + .center_y(bar.control_height), ) .padding([0, 8]) .height(bar.control_height) @@ -664,7 +671,7 @@ impl FereseShell { ) .padding([0, 6]); let right = row![self.view_status_bar(), clock] - .spacing(10) + .spacing(6) .align_y(cosmic::iced::Alignment::Center); let content = row![ container(left).width(Length::Fill), @@ -860,7 +867,23 @@ fn display_app_name(window: &control::WindowSnapshot) -> String { } fn current_time() -> String { - Zoned::now().strftime("%a · %H:%M").to_string() + format_bar_time(&Zoned::now()) +} + +fn format_bar_time(now: &Zoned) -> String { + let months = [ + "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sept", "oct", "nov", "dec", + ]; + let hour = now.hour(); + let hour12 = if hour % 12 == 0 { 12 } else { hour % 12 }; + format!( + "{} {}, {}:{:02} {}", + now.day(), + months[(now.month() - 1) as usize], + hour12, + now.minute(), + if hour < 12 { "am" } else { "pm" } + ) } fn compact_window_label(window: &crate::control::WindowSnapshot) -> String { @@ -963,6 +986,7 @@ struct EffectsBinding { _manager: FereseEffectsManagerV1, surface: FereseSurfaceEffectsV1, _queue: EventQueue, + regions: std::cell::RefCell>>, } impl EffectsBinding { @@ -987,7 +1011,7 @@ impl EffectsBinding { let connection = Connection::from_backend(backend); let (globals, queue) = registry_queue_init::(&connection)?; let qh = queue.handle(); - let manager = globals.bind::(&qh, 1..=1, ())?; + let manager = globals.bind::(&qh, 1..=2, ())?; let effects = manager.get_surface_effects(surface, &qh, ()); if let Some(role) = role { @@ -1002,9 +1026,31 @@ impl EffectsBinding { _manager: manager, surface: effects, _queue: queue, + regions: Default::default(), }) } + fn set_regions(&self, regions: &[[i32; 5]]) -> Result<(), Box> { + if self.surface.version() < 2 || self.regions.borrow().as_deref() == Some(regions) { + return Ok(()); + } + self.surface.set_regions( + regions + .iter() + .flat_map(|r| r.iter().flat_map(|v| v.to_ne_bytes())) + .collect(), + ); + if regions.is_empty() { + self.surface.clear_role(); + } else { + self.surface + .set_role(ferese_surface_effects_v1::Role::Popover); + } + self.connection.flush()?; + *self.regions.borrow_mut() = Some(regions.to_vec()); + Ok(()) + } + fn set_visible(&self, visible: bool) -> Result<(), Box> { if visible { self.surface @@ -1037,6 +1083,25 @@ delegate_noop!(EffectsState: ignore FereseSurfaceEffectsV1); #[cfg(test)] mod tests { + #[test] + fn clock_uses_lowercase_date_and_twelve_hour_time() { + for (stamp, expected) in [ + ( + "2026-09-28T21:32:00+01:00[Africa/Lagos]", + "28 sept, 9:32 pm", + ), + ( + "2026-09-28T00:05:00+01:00[Africa/Lagos]", + "28 sept, 12:05 am", + ), + ( + "2026-09-28T12:00:00+01:00[Africa/Lagos]", + "28 sept, 12:00 pm", + ), + ] { + assert_eq!(super::format_bar_time(&stamp.parse().unwrap()), expected); + } + } use super::*; use crate::control::{OutputSnapshot, WindowSnapshot}; diff --git a/shell/ferese-shell/src/motion.rs b/shell/ferese-shell/src/motion.rs index 9c67259..bb912e5 100644 --- a/shell/ferese-shell/src/motion.rs +++ b/shell/ferese-shell/src/motion.rs @@ -4,12 +4,23 @@ use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, re use cosmic::iced::{Event, Length, Rectangle, Size, Transformation, Vector}; use cosmic::{Element, Theme}; -pub fn animated<'a, M: 'a>(content: Element<'a, M>, progress: f32) -> Element<'a, M> { - Element::new(Motion { content, progress }) +pub type Regions = std::sync::Arc>>; + +pub fn animated<'a, M: 'a>( + content: Element<'a, M>, + progress: f32, + regions: Regions, +) -> Element<'a, M> { + Element::new(Motion { + content, + progress, + regions, + }) } struct Motion<'a, M> { content: Element<'a, M>, progress: f32, + regions: Regions, } impl Motion<'_, M> { fn translation(&self) -> Vector { @@ -56,6 +67,35 @@ impl Widget for Motion<'_, M> { shell: &mut Shell<'_, M>, viewport: &Rectangle, ) { + let mut collector = CollectRegions(Vec::new()); + self.content + .as_widget_mut() + .operate(tree, layout, renderer, &mut collector); + if collector.0.is_empty() { + // A single-content popover (e.g. battery) has one inner card, not + // a full-surface rectangle including the outer transparent padding. + if let Some(content) = layout.children().next() { + collector.0.push(content.bounds()); + } + } + let origin = layout.bounds().position(); + let translation = self.translation(); + let regions = collector + .0 + .into_iter() + .take(32) + .filter(|r| r.width > 0.0 && r.height > 0.0) + .map(|r| { + [ + (r.x - origin.x + translation.x).round() as i32, + (r.y - origin.y + translation.y).round() as i32, + r.width.round() as i32, + r.height.round() as i32, + 11, + ] + }) + .collect(); + *self.regions.lock().unwrap() = regions; let cursor = self.cursor(cursor); self.content.as_widget_mut().update( tree, event, layout, cursor, renderer, clipboard, shell, viewport, @@ -113,3 +153,52 @@ impl Widget for Motion<'_, M> { .operate(tree, layout, renderer, operation); } } + +struct CollectRegions(Vec); +impl widget::Operation for CollectRegions { + fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn widget::Operation)) { + children(self); + } + fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) { + if id == Some(&widget::Id::new("ferese-blur-card")) { + let contains = |outer: &Rectangle, inner: &Rectangle| { + outer.x <= inner.x + && outer.y <= inner.y + && outer.x + outer.width >= inner.x + inner.width + && outer.y + outer.height >= inner.y + inner.height + }; + // Nested controls belong to their enclosing card's single blur pass. + if self.0.iter().any(|outer| contains(outer, &bounds)) { + return; + } + self.0.retain(|inner| !contains(&bounds, inner)); + self.0.push(bounds); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn material_regions_include_cards_but_not_the_parent_or_gaps() { + let mut collector = CollectRegions(Vec::new()); + let id = widget::Id::new("ferese-blur-card"); + widget::Operation::container( + &mut collector, + None, + Rectangle::new((0.0, 0.0).into(), (320.0, 200.0).into()), + ); + let first = Rectangle::new((12.0, 12.0).into(), (140.0, 80.0).into()); + let second = Rectangle::new((160.0, 12.0).into(), (140.0, 80.0).into()); + widget::Operation::container(&mut collector, Some(&id), first); + widget::Operation::container(&mut collector, Some(&id), second); + assert_eq!(collector.0, vec![first, second]); + widget::Operation::container( + &mut collector, + Some(&id), + Rectangle::new((20.0, 20.0).into(), (40.0, 40.0).into()), + ); + assert_eq!(collector.0, vec![first, second]); + } +} diff --git a/shell/ferese-shell/src/status_ui.rs b/shell/ferese-shell/src/status_ui.rs index ac84964..9348c6c 100644 --- a/shell/ferese-shell/src/status_ui.rs +++ b/shell/ferese-shell/src/status_ui.rs @@ -17,15 +17,9 @@ pub enum Menu { System, } impl Menu { - fn title(self) -> &'static str { - match self { - Self::Network => "Wi-Fi", - Self::Bluetooth => "Bluetooth", - Self::Audio => "Volume", - Self::Battery => "Battery", - Self::Notifications => "Notifications", - Self::System => "Control Center", - } + pub(super) fn material_role(self) -> Option { + // Popovers contain independent cards, with no shared material backing. + None } fn available(self, status: &Snapshot) -> bool { match self { @@ -45,6 +39,7 @@ pub struct OpenMenu { opened: Instant, pub confirm: Option, pub effects: Option, + pub regions: super::motion::Regions, } impl OpenMenu { pub fn progress(&self) -> f32 { @@ -77,6 +72,7 @@ impl FereseShell { opened: Instant::now(), confirm: None, effects: None, + regions: Default::default(), }); super::EFFECT_FRAME_PENDING.store(true, std::sync::atomic::Ordering::Relaxed); self.status_error = None; @@ -150,7 +146,7 @@ impl FereseShell { pub fn view_status_bar(&self) -> Element<'_, cosmic::Action> { let theme = self.config.theme.for_bar(); let metrics = BarMetrics::from(theme); - let mut controls = row::with_capacity(6).spacing(3).align_y(Alignment::Center); + let mut controls = row::with_capacity(6).spacing(1).align_y(Alignment::Center); for kind in [ Menu::System, Menu::Network, @@ -210,7 +206,7 @@ impl FereseShell { .name(status_label(kind, &self.status)) .padding([ 0.0, - ((metrics.height - f32::from(metrics.icon_size)) * 0.5).max(7.0), + ((metrics.height - f32::from(metrics.icon_size)) * 0.5).max(4.0), ]) .height(metrics.height) .class(button_style(foreground, selected, 1.0)) @@ -238,15 +234,7 @@ impl FereseShell { let p = menu.progress(); let primary = color_with_opacity(theme.text_primary, p); let muted = color_with_opacity(theme.text_muted, p); - // The bar has already established whether this connection supports - // Ferese materials. Keep a glass popup's client buffer transparent - // from its FIRST frame, not only after its own focus/effects event. - // Otherwise the initial opaque fallback can be captured beneath glass - // while popup registration and client repaint happen asynchronously. - let compositor_material = self.effects.is_some(); let mut rows = column::with_capacity(12).spacing(8).width(Length::Fill); - let mut heading = - row![text(menu.kind.title()).size(16).width(Length::Fill)].align_y(Alignment::Center); let radio = match menu.kind { Menu::Network => self .status @@ -261,14 +249,13 @@ impl FereseShell { _ => None, }; if let Some((enabled, action)) = radio { - heading = heading.push(menu_button( + rows = rows.push(menu_button( if enabled { "Turn off" } else { "Turn on" }, Message::Control(action), primary, p, )); } - rows = rows.push(heading); if let Some(action) = &menu.confirm { let title = match action { Action::Poweroff => "Power off this computer?", @@ -471,10 +458,8 @@ impl FereseShell { } if combined || menu.kind == Menu::Notifications { if let Some(n) = &self.status.notifications { - rows = rows.push(toggle_row( - "Do Not Disturb", - n.dnd, - Action::Dnd(!n.dnd), + rows = rows.push(control_card( + toggle_row("Do Not Disturb", n.dnd, Action::Dnd(!n.dnd), primary, p), primary, p, )); @@ -581,7 +566,7 @@ impl FereseShell { p, )); } - rows = rows.push(actions.wrap()); + rows = rows.push(control_card(actions.wrap().into(), primary, p)); } if !menu.kind.available(&self.status) { rows = rows.push( @@ -601,39 +586,18 @@ impl FereseShell { .width(WIDTH) .padding(12) .class(theme::Container::custom(move |_| container::Style { - background: if compositor_material { - None - } else { - Some(Background::Color(color_with_opacity( - theme.surface_popover, - p, - ))) - }, + background: None, text_color: Some(primary), icon_color: Some(primary), border: Border { radius: theme.material_radius.into(), ..Default::default() }, - // Iced's shadow fills the silhouette behind the quad. With - // transparent client content that fill remains visible over - // the compositor's glass, creating a second dark rectangle. - shadow: if compositor_material { - Shadow::default() - } else { - Shadow { - color: color_with_opacity(theme.shadow, p * theme.shadow_opacity.min(0.18)), - offset: Vector::new(0.0, 2.0), - blur_radius: 6.0, - } - }, ..Default::default() })); - // Keep the client panel and compositor material on the same surface - // bounds. A second outer padding box exposes a glass ring around the - // fallback fill and makes the first committed frame visibly jump. + // Keep the outer surface transparent; only inner cards paint a background. cosmic::widget::autosize::autosize( - super::motion::animated(panel.into(), p), + super::motion::animated(panel.into(), p, menu.regions.clone()), cosmic::iced::advanced::widget::Id::new("ferese-status-menu"), ) .limits( @@ -684,6 +648,7 @@ fn status_summary<'a>( .spacing(12) .align_y(Alignment::Center), ) + .id("ferese-blur-card") .padding([12, 0]) .into() } @@ -730,6 +695,7 @@ fn control_card<'a>( opacity: f32, ) -> Element<'a, cosmic::Action> { container(content) + .id("ferese-blur-card") .padding(10) .width(Length::Fill) .class(theme::Container::custom(move |_| container::Style { @@ -798,16 +764,21 @@ fn connection_control<'a>( // giving it a misleading clickable/disabled-button appearance. let Some(action) = action else { return container(content) + .id("ferese-blur-card") .width(Length::FillPortion(1)) .padding(4) .into(); }; - button::custom(content) - .width(Length::FillPortion(1)) - .padding(4) - .class(button_style(foreground, false, opacity)) - .on_press(cosmic::Action::App(Message::Control(action))) - .into() + container( + button::custom(content) + .width(Length::FillPortion(1)) + .padding(4) + .class(button_style(foreground, false, opacity)) + .on_press(cosmic::Action::App(Message::Control(action))), + ) + .id("ferese-blur-card") + .width(Length::FillPortion(1)) + .into() } fn menu_button<'a>( @@ -1056,6 +1027,7 @@ mod tests { opened: past, confirm: None, effects: None, + regions: Default::default(), }; assert_eq!(menu.progress(), 1.0); assert!(!menu.animating()); diff --git a/tools/ferese-effects-probe/src/main.rs b/tools/ferese-effects-probe/src/main.rs index 2c1164e..5f93bca 100644 --- a/tools/ferese-effects-probe/src/main.rs +++ b/tools/ferese-effects-probe/src/main.rs @@ -8,7 +8,7 @@ use ferese_protocols::effects::v1::client::{ ferese_surface_effects_v1::{self, FereseSurfaceEffectsV1}, }; use wayland_client::{ - Connection, Dispatch, QueueHandle, delegate_noop, + Connection, Dispatch, Proxy, QueueHandle, delegate_noop, protocol::{wl_buffer, wl_compositor, wl_registry, wl_shm, wl_shm_pool, wl_surface}, }; use wayland_protocols_wlr::layer_shell::v1::client::{ @@ -65,7 +65,7 @@ fn main() -> Result<(), Box> { effects.set_role(role); queue.roundtrip(&mut state)?; // Exercise each role in an actual presented frame, including changes - // to the cached backdrop allocation and shadow bounds. + // to the material geometry and shadow bounds. std::thread::sleep(Duration::from_millis(50)); if state.configure_count != initial_configures { return Err(format!("semantic role {role:?} changed layer geometry").into()); @@ -73,9 +73,23 @@ fn main() -> Result<(), Box> { } if preview { - effects.set_role(ferese_surface_effects_v1::Role::Panel); + if std::env::args().any(|arg| arg == "--regions") { + if effects.version() < 2 { + return Err("rounded material regions require effects v2".into()); + } + let regions = [[20_i32, 20, 200, 180, 14], [240, 20, 200, 180, 14]]; + effects.set_regions( + regions + .iter() + .flat_map(|r| r.iter().flat_map(|v| v.to_ne_bytes())) + .collect(), + ); + effects.set_role(ferese_surface_effects_v1::Role::Popover); + } else { + effects.set_role(ferese_surface_effects_v1::Role::Panel); + } queue.roundtrip(&mut state)?; - println!("PREVIEW glass panel is mapped over the patterned card; press Ctrl+C to stop"); + println!("PREVIEW material is mapped over the patterned card; press Ctrl+C to stop"); while !state.closed { queue.blocking_dispatch(&mut state)?; @@ -84,7 +98,7 @@ fn main() -> Result<(), Box> { return Ok(()); } - // Keep the final glass role mapped long enough for the compositor to run + // Keep the final solid role mapped long enough for the compositor to run // at least one presentation. This turns shader and capture failures into // probe failures instead of disconnecting before the first rendered frame. std::thread::sleep(Duration::from_millis(100)); @@ -112,9 +126,9 @@ struct ProbeState { layer_surface: Option, effects: Option, buffer: Option, - preview_backdrop_surface: Option, - preview_backdrop_layer: Option, - preview_backdrop_buffer: Option, + preview_background_surface: Option, + preview_background_layer: Option, + preview_background_buffer: Option, preview: bool, configured: bool, closed: bool, @@ -174,25 +188,25 @@ impl ProbeState { surface.commit(); if self.preview { - let backdrop_surface = compositor.create_surface(qh, ()); - let backdrop_layer = layer_shell.get_layer_surface( - &backdrop_surface, + let background_surface = compositor.create_surface(qh, ()); + let background_layer = layer_shell.get_layer_surface( + &background_surface, None, zwlr_layer_shell_v1::Layer::Top, - "ferese-effects-preview-backdrop".to_owned(), + "ferese-effects-preview-background".to_owned(), qh, (), ); - backdrop_layer.set_anchor( + background_layer.set_anchor( zwlr_layer_surface_v1::Anchor::Top | zwlr_layer_surface_v1::Anchor::Right, ); - backdrop_layer.set_margin(12, 12, 0, 0); - backdrop_layer.set_size(520, 260); - backdrop_layer.set_exclusive_zone(0); - backdrop_surface.commit(); + background_layer.set_margin(12, 12, 0, 0); + background_layer.set_size(520, 260); + background_layer.set_exclusive_zone(0); + background_surface.commit(); - self.preview_backdrop_surface = Some(backdrop_surface); - self.preview_backdrop_layer = Some(backdrop_layer); + self.preview_background_surface = Some(background_surface); + self.preview_background_layer = Some(background_layer); } self.surface = Some(surface); @@ -237,7 +251,7 @@ fn create_buffer( Ok(buffer) } -fn create_preview_backdrop_buffer( +fn create_preview_background_buffer( shm: &wl_shm::WlShm, qh: &QueueHandle, width: u32, @@ -304,7 +318,7 @@ impl Dispatch for ProbeState { state.layer_shell = Some(registry.bind(name, version.min(4), qh, ())) } "ferese_effects_manager_v1" => { - state.effects_manager = Some(registry.bind(name, 1, qh, ())) + state.effects_manager = Some(registry.bind(name, version.min(2), qh, ())) } _ => {} } @@ -328,20 +342,20 @@ impl Dispatch for ProbeState { } => { layer_surface.ack_configure(serial); - if state.preview_backdrop_layer.as_ref() == Some(layer_surface) { + if state.preview_background_layer.as_ref() == Some(layer_surface) { let width = if width == 0 { 520 } else { width }; let height = if height == 0 { 260 } else { height }; let surface = state - .preview_backdrop_surface + .preview_background_surface .clone() - .expect("preview backdrop surface initialized"); + .expect("preview background surface initialized"); let shm = state.shm.as_ref().expect("shm initialized"); - let buffer = create_preview_backdrop_buffer(shm, qh, width, height) - .expect("create preview backdrop buffer"); + let buffer = create_preview_background_buffer(shm, qh, width, height) + .expect("create preview background buffer"); surface.attach(Some(&buffer), 0, 0); surface.damage_buffer(0, 0, width as i32, height as i32); surface.commit(); - state.preview_backdrop_buffer = Some(buffer); + state.preview_background_buffer = Some(buffer); return; }