Replace glass effects with plain backdrop blur and refine shell layout

This commit is contained in:
Samuel Onoja committed 2026-09-26 09:12:51 +01:00
1 parent af32c29a49
commit 64c95f0b97
13 files changed
+823 -1036

No files matched your search

+15 -22
View File
@@ -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 Set `FERESE_TRACE_PERFORMANCE=1` to emit five-second per-output summaries under
the `ferese::render` tracing target. Summaries include rendered frames, damaged the `ferese::render` tracing target. Summaries include rendered frames, damaged
pixels, average and longest render time, DRM missed-deadline totals, blur-active pixels, average and longest render time, and DRM missed-deadline totals.
frames, blur-region count, and sampled blur pixels.
The native-client soak runner and its nested/DRM procedure are documented in The native-client soak runner and its nested/DRM procedure are documented in
[docs/native-soak-testing.md](docs/native-soak-testing.md). [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 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 ```bash
cargo build -p ferese-effects-probe 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 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 terminal beneath the popover to inspect stacking, rounded clipping, and shadows. Stop the preview with `Ctrl+C` in the host terminal.
invalidation. Stop the preview with `Ctrl+C` in the host terminal.
To verify that the private global is absent from the public Wayland socket: To verify that the private global is absent from the public Wayland socket:
@@ -108,29 +106,25 @@ blur = 18.0
opacity = 0.20 opacity = 0.20
[theme.material] [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 Material roles use the configured surface color and role-specific shadows.
edges, static overlay grain, and role-specific shadows. Configure them with: 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 ```toml
[appearance] [appearance]
corner_radius = 14.0 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 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%` and menu `2/8/7%`, HUD `1/4/4%`, and notification/modal `3/10/9%`
(vertical offset / blur / opacity). Private effects capabilities (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. 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 The shell decodes wallpaper in parallel with startup and prefers the lightweight
software renderer, with GPU rendering available as a fallback. Glass effects software renderer, with GPU rendering available as a fallback. Set `ICED_BACKEND=wgpu` to explicitly use GPU
still run on the compositor GPU. Set `ICED_BACKEND=wgpu` to explicitly use GPU
rendering for shell content. Use release builds when evaluating startup and rendering for shell content. Use release builds when evaluating startup and
animation performance; debug image processing is considerably slower. animation performance; debug image processing is considerably slower.
+105 -89
View File
@@ -113,12 +113,13 @@ pub struct ThemeSettings {
pub accent_color: RgbaColor, pub accent_color: RgbaColor,
pub shadow_color: RgbaColor, pub shadow_color: RgbaColor,
pub surface_base_color: RgbaColor, pub surface_base_color: RgbaColor,
pub panel_opacity: f64,
pub window_radius: f64, pub window_radius: f64,
pub shadow_offset_y: f64, pub shadow_offset_y: f64,
pub shadow_blur: f64, pub shadow_blur: f64,
pub shadow_opacity: f64, pub shadow_opacity: f64,
pub material_style: MaterialStyle, pub material_style: MaterialStyle,
pub glass: GlassSettings, pub backdrop_blur: f64,
pub material_radius: f64, pub material_radius: f64,
pub panel_radius: f64, pub panel_radius: f64,
} }
@@ -126,64 +127,20 @@ pub struct ThemeSettings {
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum MaterialStyle { pub enum MaterialStyle {
#[default]
Glass,
Translucent, 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] #[default]
Full,
NoGrain,
NoSaturation,
ReducedBlur,
Translucent,
Solid, 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)] #[derive(Debug, Default, Deserialize)]
struct AppearanceConfig { struct AppearanceConfig {
corner_radius: Option<f64>, corner_radius: Option<f64>,
#[serde(default)]
reduced_effects: bool,
#[serde(default)]
glass: GlassConfig,
}
#[derive(Debug, Default, Deserialize)]
struct GlassConfig {
tint_color: Option<String>,
blur_scale: Option<f64>,
grain: Option<bool>,
#[serde(default)]
quality: GlassQuality,
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
struct ThemeConfig { struct ThemeConfig {
#[serde(default)]
surface: ThemeSurfaceConfig,
#[serde(default)] #[serde(default)]
colors: ThemeColorsConfig, colors: ThemeColorsConfig,
#[serde(default)] #[serde(default)]
@@ -195,9 +152,35 @@ struct ThemeConfig {
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
struct ThemeSurfaceConfig {
#[serde(default)]
bar: ThemeBarConfig,
}
#[derive(Debug, Default, Deserialize)]
struct ThemeBarConfig {
opacity: Option<f64>,
}
#[derive(Debug, Deserialize)]
struct ThemeMaterialConfig { struct ThemeMaterialConfig {
#[serde(default)] #[serde(default)]
style: MaterialStyle, 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)] #[derive(Debug, Deserialize)]
@@ -861,22 +844,6 @@ impl Config {
let shadow_opacity = let shadow_opacity =
unit_theme_value(self.theme.shadow.soft.opacity, "shadow.soft.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( let material_radius = nonnegative_theme_value(
self.appearance.corner_radius.unwrap_or(14.0), self.appearance.corner_radius.unwrap_or(14.0),
"appearance.corner_radius", "appearance.corner_radius",
@@ -891,12 +858,20 @@ impl Config {
&self.theme.colors.surface_base, &self.theme.colors.surface_base,
"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, window_radius,
shadow_offset_y, shadow_offset_y,
shadow_blur, shadow_blur,
shadow_opacity, shadow_opacity,
material_style: self.theme.material.style, 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, material_radius,
panel_radius: nonnegative_theme_value( panel_radius: nonnegative_theme_value(
self.theme.geometry.top_bar_radius, self.theme.geometry.top_bar_radius,
@@ -1665,30 +1640,6 @@ mod tests {
assert!(invalid.gap_config().is_err()); 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] #[test]
fn parses_and_validates_theme_window_tokens() { fn parses_and_validates_theme_window_tokens() {
let configured = parse( let configured = parse(
@@ -1707,12 +1658,13 @@ mod tests {
accent_color: RgbaColor([170.0 / 255.0, 187.0 / 255.0, 0.8, 1.0]), accent_color: RgbaColor([170.0 / 255.0, 187.0 / 255.0, 0.8, 1.0]),
shadow_color: RgbaColor([1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 128.0 / 255.0]), 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,]), 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, window_radius: 12.0,
shadow_offset_y: -2.0, shadow_offset_y: -2.0,
shadow_blur: 24.0, shadow_blur: 24.0,
shadow_opacity: 0.4, shadow_opacity: 0.4,
material_style: MaterialStyle::Translucent, material_style: MaterialStyle::Translucent,
glass: GlassSettings::default(), backdrop_blur: 12.0,
material_radius: 14.0, material_radius: 14.0,
panel_radius: 0.0, panel_radius: 0.0,
} }
@@ -1723,6 +1675,70 @@ mod tests {
assert!(toml::from_str::<Config>("[theme.material]\nstyle = \"mist\"").is_err()); assert!(toml::from_str::<Config>("[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::<Config>("[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] #[test]
fn parses_input_and_touchpad_settings() { fn parses_input_and_touchpad_settings() {
let config = parse( let config = parse(
+107 -180
View File
@@ -16,10 +16,7 @@ use smithay::{
}; };
use crate::{ use crate::{
Ferese, Ferese, config::MaterialStyle, private_client::ClientCapabilities, state::ClientState,
config::{GlassQuality, GlassSettings, MaterialStyle},
private_client::ClientCapabilities,
state::ClientState,
}; };
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -37,75 +34,27 @@ pub(crate) enum SemanticRole {
pub(crate) struct ResolvedMaterial { pub(crate) struct ResolvedMaterial {
pub style: MaterialStyle, pub style: MaterialStyle,
pub opacity: f32, pub opacity: f32,
pub blur: f32,
pub saturation: f32,
pub brightness: f32,
pub noise: f32,
pub shadow: [f64; 3], pub shadow: [f64; 3],
} }
pub(crate) fn resolve_material( pub(crate) fn resolve_material(role: SemanticRole, style: MaterialStyle) -> ResolvedMaterial {
role: SemanticRole, let shadow = match role {
style: MaterialStyle, SemanticRole::Panel => [1.0, 5.0, 0.04],
settings: GlassSettings, SemanticRole::PanelElevated | SemanticRole::Popover | SemanticRole::Menu => {
) -> ResolvedMaterial { [2.0, 8.0, 0.07]
// 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,
} }
} else { SemanticRole::Hud => [1.0, 4.0, 0.04],
opacity SemanticRole::Notification | SemanticRole::Modal => [3.0, 10.0, 0.09],
}; };
let style = if style == MaterialStyle::Glass { let opacity = match (style, role) {
match settings.quality { (MaterialStyle::Solid, _) => 1.0,
GlassQuality::Solid => MaterialStyle::Solid, (_, SemanticRole::Panel) => 0.78,
GlassQuality::Translucent => MaterialStyle::Translucent, (_, SemanticRole::Hud | SemanticRole::Modal) => 0.88,
_ => style, _ => 0.84,
}
} else {
style
}; };
let glass = style == MaterialStyle::Glass;
ResolvedMaterial { ResolvedMaterial {
style, style,
opacity: if style == MaterialStyle::Solid { opacity,
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
},
shadow, shadow,
} }
} }
@@ -115,6 +64,7 @@ struct SurfaceEffectsState {
attached: AtomicBool, attached: AtomicBool,
generation: AtomicU64, generation: AtomicU64,
role: Mutex<Option<SemanticRole>>, role: Mutex<Option<SemanticRole>>,
regions: Mutex<Option<Vec<[i32; 5]>>>,
} }
impl SurfaceEffectsState { impl SurfaceEffectsState {
@@ -123,6 +73,7 @@ impl SurfaceEffectsState {
attached: AtomicBool::new(false), attached: AtomicBool::new(false),
generation: AtomicU64::new(0), generation: AtomicU64::new(0),
role: Mutex::new(None), role: Mutex::new(None),
regions: Mutex::new(None),
} }
} }
@@ -152,7 +103,39 @@ impl SurfaceEffectsUserData {
} }
pub(crate) fn init_global(display: &DisplayHandle) { pub(crate) fn init_global(display: &DisplayHandle) {
display.create_global::<Ferese, FereseEffectsManagerV1, _>(1, ()); display.create_global::<Ferese, FereseEffectsManagerV1, _>(2, ());
}
pub(crate) fn surface_regions(surface: &WlSurface) -> Option<Vec<[i32; 5]>> {
with_states(surface, |states| {
states
.data_map
.get::<SurfaceEffectsState>()?
.regions
.lock()
.unwrap()
.clone()
})
}
fn decode_regions(bytes: &[u8]) -> Option<Vec<[i32; 5]>> {
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. // Consumed by the material renderer in the next M7 slice.
@@ -254,19 +237,36 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
WEnum::Unknown(_) | WEnum::Value(_) => return, WEnum::Unknown(_) | WEnum::Value(_) => return,
}; };
if set_surface_role(&surface, Some(role)) { 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(&regions) else {
return;
};
let changed = with_states(&surface, |states| {
let Some(effects) = states.data_map.get::<SurfaceEffectsState>() else {
return false;
};
let mut old = effects.regions.lock().unwrap();
if old.as_ref() == Some(&regions) {
return false;
}
*old = Some(regions);
effects.generation.fetch_add(1, Ordering::Release);
true
});
if changed {
crate::backends::direct::render_all(state); crate::backends::direct::render_all(state);
} }
} }
ferese_surface_effects_v1::Request::ClearRole => { ferese_surface_effects_v1::Request::ClearRole => {
if set_surface_role(&surface, None) { if set_surface_role(&surface, None) {
state.invalidate_material_scene();
crate::backends::direct::render_all(state); crate::backends::direct::render_all(state);
} }
} }
ferese_surface_effects_v1::Request::Destroy => { ferese_surface_effects_v1::Request::Destroy => {
if detach(&surface) { if detach(&surface) {
state.invalidate_material_scene();
crate::backends::direct::render_all(state); crate::backends::direct::render_all(state);
} }
} }
@@ -310,6 +310,31 @@ fn detach(surface: &WlSurface) -> bool {
mod tests { mod tests {
use super::*; 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::<Vec<_>>()
)
.is_none()
);
}
}
#[test] #[test]
fn role_changes_advance_generation_only_when_material_changes() { fn role_changes_advance_generation_only_when_material_changes() {
let effects = SurfaceEffectsState::new(); let effects = SurfaceEffectsState::new();
@@ -324,118 +349,20 @@ mod tests {
} }
#[test] #[test]
fn styles_preserve_semantics_but_disable_unsupported_effects() { fn solid_materials_are_opaque_and_keep_role_specific_elevation() {
let glass = resolve_material( for role in [
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(
SemanticRole::Panel, SemanticRole::Panel,
MaterialStyle::Glass,
GlassSettings::default(),
);
let popover = resolve_material(
SemanticRole::Popover, SemanticRole::Popover,
MaterialStyle::Glass, SemanticRole::Menu,
GlassSettings::default(), SemanticRole::Hud,
); SemanticRole::Notification,
SemanticRole::Modal,
assert_eq!(popover.opacity, panel.opacity); ] {
assert!(popover.blur < panel.blur); 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);
}
} }
} }
+2 -4
View File
@@ -31,8 +31,8 @@ impl CompositorHandler for Ferese {
} }
fn commit(&mut self, surface: &WlSurface) { fn commit(&mut self, surface: &WlSurface) {
self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
on_commit_buffer_handler::<Self>(surface); on_commit_buffer_handler::<Self>(surface);
self.invalidate_material_scene();
if !is_sync_subsurface(surface) { if !is_sync_subsurface(surface) {
let mut root = surface.clone(); let mut root = surface.clone();
while let Some(parent) = get_parent(&root) { while let Some(parent) = get_parent(&root) {
@@ -63,9 +63,7 @@ impl CompositorHandler for Ferese {
} }
fn destroyed(&mut self, surface: &WlSurface) { fn destroyed(&mut self, surface: &WlSurface) {
// A removed popup changes the backdrop even without a final buffer self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
// commit. Invalidate expanded sampling regions as well as its bounds.
self.invalidate_material_scene();
crate::backends::direct::render_all(self); crate::backends::direct::render_all(self);
if self.idle_inhibitors.remove(surface).is_some() { if self.idle_inhibitors.remove(surface).is_some() {
self.idle_notifier_state self.idle_notifier_state
+3 -27
View File
@@ -5,10 +5,7 @@ use smithay::utils::{Physical, Rectangle};
const REPORT_INTERVAL: Duration = Duration::from_secs(5); const REPORT_INTERVAL: Duration = Duration::from_secs(5);
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) struct FrameEffectMetrics { pub(crate) struct FrameEffectMetrics;
pub(crate) blur_regions: u64,
pub(crate) blur_sample_pixels: u64,
}
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct RenderMetrics { pub(crate) struct RenderMetrics {
@@ -19,9 +16,6 @@ pub(crate) struct RenderMetrics {
damaged_pixels: u64, damaged_pixels: u64,
total_render_time: Duration, total_render_time: Duration,
longest_render_time: Duration, longest_render_time: Duration,
blur_frames: u64,
blur_regions: u64,
blur_sample_pixels: u64,
} }
impl RenderMetrics { impl RenderMetrics {
@@ -42,9 +36,6 @@ impl RenderMetrics {
damaged_pixels: 0, damaged_pixels: 0,
total_render_time: Duration::ZERO, total_render_time: Duration::ZERO,
longest_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, render_time: Duration,
damage: &[Rectangle<i32, Physical>], damage: &[Rectangle<i32, Physical>],
missed_deadlines: u64, missed_deadlines: u64,
effects: FrameEffectMetrics, _effects: FrameEffectMetrics,
) { ) {
if !self.enabled { if !self.enabled {
return; return;
@@ -63,9 +54,6 @@ impl RenderMetrics {
self.damaged_pixels += damage.iter().map(rectangle_area).sum::<u64>(); self.damaged_pixels += damage.iter().map(rectangle_area).sum::<u64>();
self.total_render_time += render_time; self.total_render_time += render_time;
self.longest_render_time = self.longest_render_time.max(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 now = Instant::now();
let elapsed = now.saturating_duration_since(self.interval_started); let elapsed = now.saturating_duration_since(self.interval_started);
@@ -87,9 +75,6 @@ impl RenderMetrics {
average_render_us, average_render_us,
longest_render_us = self.longest_render_time.as_micros(), longest_render_us = self.longest_render_time.as_micros(),
missed_deadlines, missed_deadlines,
blur_frames = self.blur_frames,
blur_regions = self.blur_regions,
blur_sample_pixels = self.blur_sample_pixels,
"render performance" "render performance"
); );
@@ -98,9 +83,6 @@ impl RenderMetrics {
self.damaged_pixels = 0; self.damaged_pixels = 0;
self.total_render_time = Duration::ZERO; self.total_render_time = Duration::ZERO;
self.longest_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()), Rectangle::new((10, 10).into(), (20, 10).into()),
], ],
0, 0,
FrameEffectMetrics { FrameEffectMetrics,
blur_regions: 2,
blur_sample_pixels: 4_096,
},
); );
assert_eq!(metrics.rendered_frames, 1); assert_eq!(metrics.rendered_frames, 1);
assert_eq!(metrics.damaged_pixels, 5_200); assert_eq!(metrics.damaged_pixels, 5_200);
assert_eq!(metrics.total_render_time, Duration::from_millis(2)); assert_eq!(metrics.total_render_time, Duration::from_millis(2));
assert_eq!(metrics.longest_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);
} }
} }
+6 -14
View File
@@ -125,9 +125,9 @@ pub struct Ferese {
pub(crate) rounded_clip_programs: HashMap<ErasedContextId, crate::winit::RoundedClipPrograms>, pub(crate) rounded_clip_programs: HashMap<ErasedContextId, crate::winit::RoundedClipPrograms>,
pub(crate) overview_scrims: HashMap<OutputId, crate::winit::OverviewScrim>, pub(crate) overview_scrims: HashMap<OutputId, crate::winit::OverviewScrim>,
pub(crate) material_programs: HashMap<ErasedContextId, crate::winit::MaterialProgram>, pub(crate) material_programs: HashMap<ErasedContextId, crate::winit::MaterialProgram>,
pub(crate) backdrop_programs: HashMap<ErasedContextId, crate::winit::BackdropProgram>,
pub(crate) material_buffers: HashMap<WlSurface, crate::winit::MaterialBuffers>, pub(crate) material_buffers: HashMap<WlSurface, crate::winit::MaterialBuffers>,
material_scene_generation: u64, pub(crate) blur_programs: HashMap<ErasedContextId, crate::winit::BlurProgram>,
pub(crate) backdrop_generation: u64,
closing_windows: HashMap<WindowId, ClosingAnimation>, closing_windows: HashMap<WindowId, ClosingAnimation>,
viewport_animations: HashMap<WorkspaceId, AnimatedValue>, viewport_animations: HashMap<WorkspaceId, AnimatedValue>,
scrolling_world_x: HashMap<WindowId, (WorkspaceId, AnimatedValue)>, scrolling_world_x: HashMap<WindowId, (WorkspaceId, AnimatedValue)>,
@@ -457,9 +457,9 @@ impl Ferese {
rounded_clip_programs: HashMap::new(), rounded_clip_programs: HashMap::new(),
overview_scrims: HashMap::new(), overview_scrims: HashMap::new(),
material_programs: HashMap::new(), material_programs: HashMap::new(),
backdrop_programs: HashMap::new(),
material_buffers: HashMap::new(), material_buffers: HashMap::new(),
material_scene_generation: 0, blur_programs: HashMap::new(),
backdrop_generation: 0,
closing_windows: HashMap::new(), closing_windows: HashMap::new(),
viewport_animations: HashMap::new(), viewport_animations: HashMap::new(),
scrolling_world_x: HashMap::new(), scrolling_world_x: HashMap::new(),
@@ -930,6 +930,7 @@ impl Ferese {
} }
pub fn relayout(&mut self) { pub fn relayout(&mut self) {
self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
self.arrange_layers(); self.arrange_layers();
let mut previous_scrolling_world_x = std::mem::take(&mut self.scrolling_world_x); 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); let pending_column_width_cycles = std::mem::take(&mut self.pending_column_width_cycles);
@@ -1346,20 +1347,11 @@ impl Ferese {
self.sync_window_stacking(); self.sync_window_stacking();
if active_animation { if active_animation {
self.invalidate_material_scene(); self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
} }
active_animation 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 { pub(crate) fn animations_enabled(&self) -> bool {
self.animations_enabled self.animations_enabled
} }
+328 -593
View File
File diff suppressed because it is too large. Load diff
+12 -2
View File
@@ -22,7 +22,7 @@
DEALINGS IN THE SOFTWARE. DEALINGS IN THE SOFTWARE.
</copyright> </copyright>
<interface name="ferese_effects_manager_v1" version="1"> <interface name="ferese_effects_manager_v1" version="2">
<description summary="assign semantic material roles to surfaces"> <description summary="assign semantic material roles to surfaces">
This private global is visible only to clients granted the Ferese effects This private global is visible only to clients granted the Ferese effects
capability. Roles describe compositor-owned appearance policy and do not capability. Roles describe compositor-owned appearance policy and do not
@@ -48,7 +48,7 @@
</enum> </enum>
</interface> </interface>
<interface name="ferese_surface_effects_v1" version="1"> <interface name="ferese_surface_effects_v1" version="2">
<description summary="semantic material role for one surface"> <description summary="semantic material role for one surface">
The role affects only compositor rendering. It does not change surface The role affects only compositor rendering. It does not change surface
geometry, input, focus, stacking, or the surface's Wayland role. geometry, input, focus, stacking, or the surface's Wayland role.
@@ -78,5 +78,15 @@
<entry name="hud" value="5" summary="heads-up display surface"/> <entry name="hud" value="5" summary="heads-up display surface"/>
<entry name="modal" value="6" summary="modal surface"/> <entry name="modal" value="6" summary="modal surface"/>
</enum> </enum>
<request name="set_regions" since="2">
<description summary="restrict material to rounded surface-local rectangles">
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.
</description>
<arg name="regions" type="array"/>
</request>
</interface> </interface>
</protocol> </protocol>
+1 -1
View File
@@ -86,7 +86,7 @@ impl Default for ShellTheme {
} }
impl 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 { pub(crate) fn for_bar(mut self) -> Self {
self.text_primary = self.bar_text_primary; self.text_primary = self.bar_text_primary;
self.text_muted = self.bar_text_muted; self.text_muted = self.bar_text_muted;
+84 -19
View File
@@ -22,8 +22,7 @@ use cosmic::iced::platform_specific::{
shell::commands::layer_surface::{Anchor, KeyboardInteractivity, Layer}, shell::commands::layer_surface::{Anchor, KeyboardInteractivity, Layer},
}; };
use cosmic::iced::{ use cosmic::iced::{
Background, Border, Color, ContentFit, Event, Length, Limits, Shadow, Subscription, Vector, Background, Border, Color, ContentFit, Event, Length, Limits, Subscription, window,
window,
}; };
use cosmic::theme; use cosmic::theme;
use cosmic::widget::{button, container, icon, image, row}; 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, anchor: Anchor::TOP | Anchor::RIGHT | Anchor::BOTTOM | Anchor::LEFT,
output: IcedOutput::Active, output: IcedOutput::Active,
namespace: "ferese-shell-wallpaper".to_owned(), 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: Some((None, None)),
size_limits: Limits::NONE, size_limits: Limits::NONE,
..Default::default() ..Default::default()
@@ -294,6 +296,14 @@ impl cosmic::Application for FereseShell {
} }
fn update(&mut self, message: Self::Message) -> Task<Self::Message> { fn update(&mut self, message: Self::Message) -> Task<Self::Message> {
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(&regions) {
eprintln!("ferese-shell: could not update card materials: {error}");
}
}
match message { match message {
Message::WallpaperLoaded(result) => { Message::WallpaperLoaded(result) => {
match result { match result {
@@ -312,10 +322,7 @@ impl cosmic::Application for FereseShell {
&& menu.id == id && menu.id == id
&& menu.effects.is_none() && menu.effects.is_none()
{ {
match EffectsBinding::attach_role( match EffectsBinding::attach_role(&surface, menu.kind.material_role()) {
&surface,
Some(ferese_surface_effects_v1::Role::Popover),
) {
Ok(binding) => menu.effects = Some(binding), Ok(binding) => menu.effects = Some(binding),
Err(error) => { Err(error) => {
eprintln!("ferese-shell: popover material unavailable: {error}") eprintln!("ferese-shell: popover material unavailable: {error}")
@@ -467,7 +474,7 @@ impl FereseShell {
if menu.effects.is_none() { if menu.effects.is_none() {
match EffectsBinding::attach_role( match EffectsBinding::attach_role(
&surface, &surface,
Some(ferese_surface_effects_v1::Role::Popover), menu.kind.material_role(),
) { ) {
Ok(binding) => menu.effects = Some(binding), Ok(binding) => menu.effects = Some(binding),
Err(error) => eprintln!( Err(error) => eprintln!(
@@ -481,7 +488,7 @@ impl FereseShell {
if menu.effects.is_none() { if menu.effects.is_none() {
match EffectsBinding::attach_role( match EffectsBinding::attach_role(
&surface, &surface,
Some(ferese_surface_effects_v1::Role::Popover), menu.kind.material_role(),
) { ) {
Ok(binding) => menu.effects = Some(binding), Ok(binding) => menu.effects = Some(binding),
Err(error) => eprintln!( Err(error) => eprintln!(
@@ -513,10 +520,7 @@ impl FereseShell {
))) if self.menu.as_ref().is_some_and(|menu| menu.id == frame_id) => { ))) if self.menu.as_ref().is_some_and(|menu| menu.id == frame_id) => {
if let Some(menu) = &mut self.menu { if let Some(menu) = &mut self.menu {
if menu.effects.is_none() { if menu.effects.is_none() {
match EffectsBinding::attach_role( match EffectsBinding::attach_role(&surface, menu.kind.material_role()) {
&surface,
Some(ferese_surface_effects_v1::Role::Popover),
) {
Ok(binding) => menu.effects = Some(binding), Ok(binding) => menu.effects = Some(binding),
Err(error) => { Err(error) => {
eprintln!("ferese-shell: popover material unavailable: {error}") eprintln!("ferese-shell: popover material unavailable: {error}")
@@ -634,10 +638,13 @@ impl FereseShell {
}); });
windows = windows.push( windows = windows.push(
button::custom( button::custom(
text(label) container(
.size(bar.text_size) text(label)
.wrapping(cosmic::iced::core::text::Wrapping::None) .size(bar.text_size)
.class(theme::Text::Color(foreground)), .wrapping(cosmic::iced::core::text::Wrapping::None)
.class(theme::Text::Color(foreground)),
)
.center_y(bar.control_height),
) )
.padding([0, 8]) .padding([0, 8])
.height(bar.control_height) .height(bar.control_height)
@@ -664,7 +671,7 @@ impl FereseShell {
) )
.padding([0, 6]); .padding([0, 6]);
let right = row![self.view_status_bar(), clock] let right = row![self.view_status_bar(), clock]
.spacing(10) .spacing(6)
.align_y(cosmic::iced::Alignment::Center); .align_y(cosmic::iced::Alignment::Center);
let content = row![ let content = row![
container(left).width(Length::Fill), container(left).width(Length::Fill),
@@ -860,7 +867,23 @@ fn display_app_name(window: &control::WindowSnapshot) -> String {
} }
fn current_time() -> 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 { fn compact_window_label(window: &crate::control::WindowSnapshot) -> String {
@@ -963,6 +986,7 @@ struct EffectsBinding {
_manager: FereseEffectsManagerV1, _manager: FereseEffectsManagerV1,
surface: FereseSurfaceEffectsV1, surface: FereseSurfaceEffectsV1,
_queue: EventQueue<EffectsState>, _queue: EventQueue<EffectsState>,
regions: std::cell::RefCell<Option<Vec<[i32; 5]>>>,
} }
impl EffectsBinding { impl EffectsBinding {
@@ -987,7 +1011,7 @@ impl EffectsBinding {
let connection = Connection::from_backend(backend); let connection = Connection::from_backend(backend);
let (globals, queue) = registry_queue_init::<EffectsState>(&connection)?; let (globals, queue) = registry_queue_init::<EffectsState>(&connection)?;
let qh = queue.handle(); let qh = queue.handle();
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 1..=1, ())?; let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 1..=2, ())?;
let effects = manager.get_surface_effects(surface, &qh, ()); let effects = manager.get_surface_effects(surface, &qh, ());
if let Some(role) = role { if let Some(role) = role {
@@ -1002,9 +1026,31 @@ impl EffectsBinding {
_manager: manager, _manager: manager,
surface: effects, surface: effects,
_queue: queue, _queue: queue,
regions: Default::default(),
}) })
} }
fn set_regions(&self, regions: &[[i32; 5]]) -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> { fn set_visible(&self, visible: bool) -> Result<(), Box<dyn std::error::Error>> {
if visible { if visible {
self.surface self.surface
@@ -1037,6 +1083,25 @@ delegate_noop!(EffectsState: ignore FereseSurfaceEffectsV1);
#[cfg(test)] #[cfg(test)]
mod tests { 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 super::*;
use crate::control::{OutputSnapshot, WindowSnapshot}; use crate::control::{OutputSnapshot, WindowSnapshot};
+91 -2
View File
@@ -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::iced::{Event, Length, Rectangle, Size, Transformation, Vector};
use cosmic::{Element, Theme}; use cosmic::{Element, Theme};
pub fn animated<'a, M: 'a>(content: Element<'a, M>, progress: f32) -> Element<'a, M> { pub type Regions = std::sync::Arc<std::sync::Mutex<Vec<[i32; 5]>>>;
Element::new(Motion { content, progress })
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> { struct Motion<'a, M> {
content: Element<'a, M>, content: Element<'a, M>,
progress: f32, progress: f32,
regions: Regions,
} }
impl<M> Motion<'_, M> { impl<M> Motion<'_, M> {
fn translation(&self) -> Vector { fn translation(&self) -> Vector {
@@ -56,6 +67,35 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
shell: &mut Shell<'_, M>, shell: &mut Shell<'_, M>,
viewport: &Rectangle, 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); let cursor = self.cursor(cursor);
self.content.as_widget_mut().update( self.content.as_widget_mut().update(
tree, event, layout, cursor, renderer, clipboard, shell, viewport, tree, event, layout, cursor, renderer, clipboard, shell, viewport,
@@ -113,3 +153,52 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
.operate(tree, layout, renderer, operation); .operate(tree, layout, renderer, operation);
} }
} }
struct CollectRegions(Vec<Rectangle>);
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]);
}
}
+28 -56
View File
@@ -17,15 +17,9 @@ pub enum Menu {
System, System,
} }
impl Menu { impl Menu {
fn title(self) -> &'static str { pub(super) fn material_role(self) -> Option<ferese_surface_effects_v1::Role> {
match self { // Popovers contain independent cards, with no shared material backing.
Self::Network => "Wi-Fi", None
Self::Bluetooth => "Bluetooth",
Self::Audio => "Volume",
Self::Battery => "Battery",
Self::Notifications => "Notifications",
Self::System => "Control Center",
}
} }
fn available(self, status: &Snapshot) -> bool { fn available(self, status: &Snapshot) -> bool {
match self { match self {
@@ -45,6 +39,7 @@ pub struct OpenMenu {
opened: Instant, opened: Instant,
pub confirm: Option<Action>, pub confirm: Option<Action>,
pub effects: Option<EffectsBinding>, pub effects: Option<EffectsBinding>,
pub regions: super::motion::Regions,
} }
impl OpenMenu { impl OpenMenu {
pub fn progress(&self) -> f32 { pub fn progress(&self) -> f32 {
@@ -77,6 +72,7 @@ impl FereseShell {
opened: Instant::now(), opened: Instant::now(),
confirm: None, confirm: None,
effects: None, effects: None,
regions: Default::default(),
}); });
super::EFFECT_FRAME_PENDING.store(true, std::sync::atomic::Ordering::Relaxed); super::EFFECT_FRAME_PENDING.store(true, std::sync::atomic::Ordering::Relaxed);
self.status_error = None; self.status_error = None;
@@ -150,7 +146,7 @@ impl FereseShell {
pub fn view_status_bar(&self) -> Element<'_, cosmic::Action<Message>> { pub fn view_status_bar(&self) -> Element<'_, cosmic::Action<Message>> {
let theme = self.config.theme.for_bar(); let theme = self.config.theme.for_bar();
let metrics = BarMetrics::from(theme); 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 [ for kind in [
Menu::System, Menu::System,
Menu::Network, Menu::Network,
@@ -210,7 +206,7 @@ impl FereseShell {
.name(status_label(kind, &self.status)) .name(status_label(kind, &self.status))
.padding([ .padding([
0.0, 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) .height(metrics.height)
.class(button_style(foreground, selected, 1.0)) .class(button_style(foreground, selected, 1.0))
@@ -238,15 +234,7 @@ impl FereseShell {
let p = menu.progress(); let p = menu.progress();
let primary = color_with_opacity(theme.text_primary, p); let primary = color_with_opacity(theme.text_primary, p);
let muted = color_with_opacity(theme.text_muted, 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 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 { let radio = match menu.kind {
Menu::Network => self Menu::Network => self
.status .status
@@ -261,14 +249,13 @@ impl FereseShell {
_ => None, _ => None,
}; };
if let Some((enabled, action)) = radio { if let Some((enabled, action)) = radio {
heading = heading.push(menu_button( rows = rows.push(menu_button(
if enabled { "Turn off" } else { "Turn on" }, if enabled { "Turn off" } else { "Turn on" },
Message::Control(action), Message::Control(action),
primary, primary,
p, p,
)); ));
} }
rows = rows.push(heading);
if let Some(action) = &menu.confirm { if let Some(action) = &menu.confirm {
let title = match action { let title = match action {
Action::Poweroff => "Power off this computer?", Action::Poweroff => "Power off this computer?",
@@ -471,10 +458,8 @@ impl FereseShell {
} }
if combined || menu.kind == Menu::Notifications { if combined || menu.kind == Menu::Notifications {
if let Some(n) = &self.status.notifications { if let Some(n) = &self.status.notifications {
rows = rows.push(toggle_row( rows = rows.push(control_card(
"Do Not Disturb", toggle_row("Do Not Disturb", n.dnd, Action::Dnd(!n.dnd), primary, p),
n.dnd,
Action::Dnd(!n.dnd),
primary, primary,
p, p,
)); ));
@@ -581,7 +566,7 @@ impl FereseShell {
p, p,
)); ));
} }
rows = rows.push(actions.wrap()); rows = rows.push(control_card(actions.wrap().into(), primary, p));
} }
if !menu.kind.available(&self.status) { if !menu.kind.available(&self.status) {
rows = rows.push( rows = rows.push(
@@ -601,39 +586,18 @@ impl FereseShell {
.width(WIDTH) .width(WIDTH)
.padding(12) .padding(12)
.class(theme::Container::custom(move |_| container::Style { .class(theme::Container::custom(move |_| container::Style {
background: if compositor_material { background: None,
None
} else {
Some(Background::Color(color_with_opacity(
theme.surface_popover,
p,
)))
},
text_color: Some(primary), text_color: Some(primary),
icon_color: Some(primary), icon_color: Some(primary),
border: Border { border: Border {
radius: theme.material_radius.into(), radius: theme.material_radius.into(),
..Default::default() ..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() ..Default::default()
})); }));
// Keep the client panel and compositor material on the same surface // Keep the outer surface transparent; only inner cards paint a background.
// bounds. A second outer padding box exposes a glass ring around the
// fallback fill and makes the first committed frame visibly jump.
cosmic::widget::autosize::autosize( 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"), cosmic::iced::advanced::widget::Id::new("ferese-status-menu"),
) )
.limits( .limits(
@@ -684,6 +648,7 @@ fn status_summary<'a>(
.spacing(12) .spacing(12)
.align_y(Alignment::Center), .align_y(Alignment::Center),
) )
.id("ferese-blur-card")
.padding([12, 0]) .padding([12, 0])
.into() .into()
} }
@@ -730,6 +695,7 @@ fn control_card<'a>(
opacity: f32, opacity: f32,
) -> Element<'a, cosmic::Action<Message>> { ) -> Element<'a, cosmic::Action<Message>> {
container(content) container(content)
.id("ferese-blur-card")
.padding(10) .padding(10)
.width(Length::Fill) .width(Length::Fill)
.class(theme::Container::custom(move |_| container::Style { .class(theme::Container::custom(move |_| container::Style {
@@ -798,16 +764,21 @@ fn connection_control<'a>(
// giving it a misleading clickable/disabled-button appearance. // giving it a misleading clickable/disabled-button appearance.
let Some(action) = action else { let Some(action) = action else {
return container(content) return container(content)
.id("ferese-blur-card")
.width(Length::FillPortion(1)) .width(Length::FillPortion(1))
.padding(4) .padding(4)
.into(); .into();
}; };
button::custom(content) container(
.width(Length::FillPortion(1)) button::custom(content)
.padding(4) .width(Length::FillPortion(1))
.class(button_style(foreground, false, opacity)) .padding(4)
.on_press(cosmic::Action::App(Message::Control(action))) .class(button_style(foreground, false, opacity))
.into() .on_press(cosmic::Action::App(Message::Control(action))),
)
.id("ferese-blur-card")
.width(Length::FillPortion(1))
.into()
} }
fn menu_button<'a>( fn menu_button<'a>(
@@ -1056,6 +1027,7 @@ mod tests {
opened: past, opened: past,
confirm: None, confirm: None,
effects: None, effects: None,
regions: Default::default(),
}; };
assert_eq!(menu.progress(), 1.0); assert_eq!(menu.progress(), 1.0);
assert!(!menu.animating()); assert!(!menu.animating());
+41 -27
View File
@@ -8,7 +8,7 @@ use ferese_protocols::effects::v1::client::{
ferese_surface_effects_v1::{self, FereseSurfaceEffectsV1}, ferese_surface_effects_v1::{self, FereseSurfaceEffectsV1},
}; };
use wayland_client::{ 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}, protocol::{wl_buffer, wl_compositor, wl_registry, wl_shm, wl_shm_pool, wl_surface},
}; };
use wayland_protocols_wlr::layer_shell::v1::client::{ use wayland_protocols_wlr::layer_shell::v1::client::{
@@ -65,7 +65,7 @@ fn main() -> Result<(), Box<dyn Error>> {
effects.set_role(role); effects.set_role(role);
queue.roundtrip(&mut state)?; queue.roundtrip(&mut state)?;
// Exercise each role in an actual presented frame, including changes // 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)); std::thread::sleep(Duration::from_millis(50));
if state.configure_count != initial_configures { if state.configure_count != initial_configures {
return Err(format!("semantic role {role:?} changed layer geometry").into()); return Err(format!("semantic role {role:?} changed layer geometry").into());
@@ -73,9 +73,23 @@ fn main() -> Result<(), Box<dyn Error>> {
} }
if preview { 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)?; 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 { while !state.closed {
queue.blocking_dispatch(&mut state)?; queue.blocking_dispatch(&mut state)?;
@@ -84,7 +98,7 @@ fn main() -> Result<(), Box<dyn Error>> {
return Ok(()); 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 // at least one presentation. This turns shader and capture failures into
// probe failures instead of disconnecting before the first rendered frame. // probe failures instead of disconnecting before the first rendered frame.
std::thread::sleep(Duration::from_millis(100)); std::thread::sleep(Duration::from_millis(100));
@@ -112,9 +126,9 @@ struct ProbeState {
layer_surface: Option<ZwlrLayerSurfaceV1>, layer_surface: Option<ZwlrLayerSurfaceV1>,
effects: Option<FereseSurfaceEffectsV1>, effects: Option<FereseSurfaceEffectsV1>,
buffer: Option<wl_buffer::WlBuffer>, buffer: Option<wl_buffer::WlBuffer>,
preview_backdrop_surface: Option<wl_surface::WlSurface>, preview_background_surface: Option<wl_surface::WlSurface>,
preview_backdrop_layer: Option<ZwlrLayerSurfaceV1>, preview_background_layer: Option<ZwlrLayerSurfaceV1>,
preview_backdrop_buffer: Option<wl_buffer::WlBuffer>, preview_background_buffer: Option<wl_buffer::WlBuffer>,
preview: bool, preview: bool,
configured: bool, configured: bool,
closed: bool, closed: bool,
@@ -174,25 +188,25 @@ impl ProbeState {
surface.commit(); surface.commit();
if self.preview { if self.preview {
let backdrop_surface = compositor.create_surface(qh, ()); let background_surface = compositor.create_surface(qh, ());
let backdrop_layer = layer_shell.get_layer_surface( let background_layer = layer_shell.get_layer_surface(
&backdrop_surface, &background_surface,
None, None,
zwlr_layer_shell_v1::Layer::Top, zwlr_layer_shell_v1::Layer::Top,
"ferese-effects-preview-backdrop".to_owned(), "ferese-effects-preview-background".to_owned(),
qh, qh,
(), (),
); );
backdrop_layer.set_anchor( background_layer.set_anchor(
zwlr_layer_surface_v1::Anchor::Top | zwlr_layer_surface_v1::Anchor::Right, zwlr_layer_surface_v1::Anchor::Top | zwlr_layer_surface_v1::Anchor::Right,
); );
backdrop_layer.set_margin(12, 12, 0, 0); background_layer.set_margin(12, 12, 0, 0);
backdrop_layer.set_size(520, 260); background_layer.set_size(520, 260);
backdrop_layer.set_exclusive_zone(0); background_layer.set_exclusive_zone(0);
backdrop_surface.commit(); background_surface.commit();
self.preview_backdrop_surface = Some(backdrop_surface); self.preview_background_surface = Some(background_surface);
self.preview_backdrop_layer = Some(backdrop_layer); self.preview_background_layer = Some(background_layer);
} }
self.surface = Some(surface); self.surface = Some(surface);
@@ -237,7 +251,7 @@ fn create_buffer(
Ok(buffer) Ok(buffer)
} }
fn create_preview_backdrop_buffer( fn create_preview_background_buffer(
shm: &wl_shm::WlShm, shm: &wl_shm::WlShm,
qh: &QueueHandle<ProbeState>, qh: &QueueHandle<ProbeState>,
width: u32, width: u32,
@@ -304,7 +318,7 @@ impl Dispatch<wl_registry::WlRegistry, ()> for ProbeState {
state.layer_shell = Some(registry.bind(name, version.min(4), qh, ())) state.layer_shell = Some(registry.bind(name, version.min(4), qh, ()))
} }
"ferese_effects_manager_v1" => { "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<ZwlrLayerSurfaceV1, ()> for ProbeState {
} => { } => {
layer_surface.ack_configure(serial); 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 width = if width == 0 { 520 } else { width };
let height = if height == 0 { 260 } else { height }; let height = if height == 0 { 260 } else { height };
let surface = state let surface = state
.preview_backdrop_surface .preview_background_surface
.clone() .clone()
.expect("preview backdrop surface initialized"); .expect("preview background surface initialized");
let shm = state.shm.as_ref().expect("shm initialized"); let shm = state.shm.as_ref().expect("shm initialized");
let buffer = create_preview_backdrop_buffer(shm, qh, width, height) let buffer = create_preview_background_buffer(shm, qh, width, height)
.expect("create preview backdrop buffer"); .expect("create preview background buffer");
surface.attach(Some(&buffer), 0, 0); surface.attach(Some(&buffer), 0, 0);
surface.damage_buffer(0, 0, width as i32, height as i32); surface.damage_buffer(0, 0, width as i32, height as i32);
surface.commit(); surface.commit();
state.preview_backdrop_buffer = Some(buffer); state.preview_background_buffer = Some(buffer);
return; return;
} }