Synchronize shell material presentation with surface commits

Queue popup, modal and notification material geometry and opacity until the matching content buffer commits. Remove event-handler opacity writes that could present material state ahead of content, while preserving compositor dismissal ownership.

Add pending-presentation regression coverage and update the nested shell lifecycle check.
This commit is contained in:
Samuel Onoja committed 2026-10-03 04:45:12 +01:00
1 parent e6e1a2a8cf
commit bffd538dd4
9 files changed
+159 -50

No files matched your search

+104 -10
View File
@@ -51,6 +51,14 @@ pub(crate) fn resolve_material(role: SemanticRole, style: MaterialStyle, opacity
ResolvedMaterial { style, opacity, shadow }
}
#[derive(Debug)]
struct PendingPresentation {
role: SemanticRole,
regions: Vec<[f64; 5]>,
opacity: u32,
region_opacities: Vec<u32>,
}
#[derive(Debug)]
struct SurfaceEffectsState {
attached: AtomicBool,
@@ -58,6 +66,7 @@ struct SurfaceEffectsState {
role: Mutex<Option<SemanticRole>>,
regions: Mutex<Option<Vec<[f64; 5]>>>,
opacity: Mutex<u32>,
pending: Mutex<Option<PendingPresentation>>,
region_opacities: Mutex<Vec<u32>>,
presentation_supported: AtomicBool,
dismissing: AtomicBool,
@@ -71,12 +80,26 @@ impl SurfaceEffectsState {
role: Mutex::new(None),
regions: Mutex::new(None),
opacity: Mutex::new(1000),
pending: Mutex::new(None),
region_opacities: Mutex::new(Vec::new()),
presentation_supported: AtomicBool::new(false),
dismissing: AtomicBool::new(false),
}
}
fn commit_presentation(&self) {
let Some(pending) = self.pending.lock().unwrap().take() else {
return;
};
*self.role.lock().unwrap() = Some(pending.role);
*self.regions.lock().unwrap() = Some(pending.regions);
*self.region_opacities.lock().unwrap() = pending.region_opacities;
if !self.dismissing.load(Ordering::Acquire) {
*self.opacity.lock().unwrap() = pending.opacity;
}
self.generation.fetch_add(1, Ordering::Release);
}
fn set_role(&self, role: Option<SemanticRole>) -> bool {
let mut current = self.role.lock().unwrap();
if *current != role {
@@ -103,7 +126,7 @@ impl SurfaceEffectsUserData {
}
pub(crate) fn init_global(display: &DisplayHandle) {
display.create_global::<Ferese, FereseEffectsManagerV1, _>(4, ());
display.create_global::<Ferese, FereseEffectsManagerV1, _>(5, ());
display.create_global::<Ferese, FereseMaterialManagerV1, _>(1, ());
}
@@ -185,6 +208,14 @@ impl Dispatch<FereseSurfaceMaterialV1, SurfaceEffectsUserData> for Ferese {
}
}
pub(crate) fn commit_presentation(surface: &WlSurface) {
with_states(surface, |states| {
if let Some(effects) = states.data_map.get::<SurfaceEffectsState>() {
effects.commit_presentation();
}
});
}
pub(crate) fn surface_regions(surface: &WlSurface) -> Option<Vec<[f64; 5]>> {
with_states(surface, |states| {
states
@@ -330,6 +361,30 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
};
match request {
ferese_surface_effects_v1::Request::SetPresentation {
role,
regions,
opacity,
region_opacities,
} => {
let (Some(role), Some(regions), Some(region_opacities)) = (
decode_role(role),
decode_regions(&regions),
decode_region_opacities(&region_opacities),
) else {
return;
};
with_states(&surface, |states| {
if let Some(effects) = states.data_map.get::<SurfaceEffectsState>() {
*effects.pending.lock().unwrap() = Some(PendingPresentation {
role,
regions,
opacity: opacity.min(1000),
region_opacities,
});
}
});
}
ferese_surface_effects_v1::Request::SetOpacity { opacity } => {
let changed = with_states(&surface, |states| {
let Some(effects) = states.data_map.get::<SurfaceEffectsState>() else {
@@ -352,15 +407,8 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
}
}
ferese_surface_effects_v1::Request::SetRole { role } => {
let role = match role {
WEnum::Value(ferese_surface_effects_v1::Role::Panel) => SemanticRole::Panel,
WEnum::Value(ferese_surface_effects_v1::Role::PanelElevated) => SemanticRole::PanelElevated,
WEnum::Value(ferese_surface_effects_v1::Role::Popover) => SemanticRole::Popover,
WEnum::Value(ferese_surface_effects_v1::Role::Menu) => SemanticRole::Menu,
WEnum::Value(ferese_surface_effects_v1::Role::Notification) => SemanticRole::Notification,
WEnum::Value(ferese_surface_effects_v1::Role::Hud) => SemanticRole::Hud,
WEnum::Value(ferese_surface_effects_v1::Role::Modal) => SemanticRole::Modal,
WEnum::Unknown(_) | WEnum::Value(_) => return,
let Some(role) = decode_role(role) else {
return;
};
if set_surface_role(&surface, Some(role)) {
crate::backends::direct::render_surface(state, &surface);
@@ -432,6 +480,19 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
}
}
fn decode_role(role: WEnum<ferese_surface_effects_v1::Role>) -> Option<SemanticRole> {
Some(match role {
WEnum::Value(ferese_surface_effects_v1::Role::Panel) => SemanticRole::Panel,
WEnum::Value(ferese_surface_effects_v1::Role::PanelElevated) => SemanticRole::PanelElevated,
WEnum::Value(ferese_surface_effects_v1::Role::Popover) => SemanticRole::Popover,
WEnum::Value(ferese_surface_effects_v1::Role::Menu) => SemanticRole::Menu,
WEnum::Value(ferese_surface_effects_v1::Role::Notification) => SemanticRole::Notification,
WEnum::Value(ferese_surface_effects_v1::Role::Hud) => SemanticRole::Hud,
WEnum::Value(ferese_surface_effects_v1::Role::Modal) => SemanticRole::Modal,
WEnum::Unknown(_) | WEnum::Value(_) => return None,
})
}
fn set_surface_role(surface: &WlSurface, role: Option<SemanticRole>) -> bool {
with_states(surface, |states| {
states
@@ -448,6 +509,7 @@ fn detach(surface: &WlSurface) -> bool {
};
let role_changed = effects.set_role(None);
effects.region_opacities.lock().unwrap().clear();
effects.pending.lock().unwrap().take();
effects.dismissing.store(false, Ordering::Release);
let mut opacity = effects.opacity.lock().unwrap();
let opacity_changed = *opacity != 1000;
@@ -489,6 +551,38 @@ pub(crate) fn fade_dismissed_surface(surface: &WlSurface, opacity: f64) {
mod tests {
use super::*;
#[test]
fn presentation_waits_for_buffer_commit_and_keeps_dismissal_ownership() {
let state = SurfaceEffectsState::new();
let queue = |opacity| PendingPresentation {
role: SemanticRole::Popover,
regions: vec![[1.25, 2.5, 100.0, 80.0, 12.0]],
opacity,
region_opacities: vec![500],
};
*state.pending.lock().unwrap() = Some(queue(900));
assert_eq!(*state.opacity.lock().unwrap(), 1000);
assert!(state.regions.lock().unwrap().is_none());
assert_eq!(state.generation.load(Ordering::Acquire), 0);
// Several requests before one buffer commit present only the last.
*state.pending.lock().unwrap() = Some(queue(800));
state.commit_presentation();
assert_eq!(*state.opacity.lock().unwrap(), 800);
assert_eq!(state.regions.lock().unwrap().as_ref().unwrap()[0][0], 1.25);
assert_eq!(*state.region_opacities.lock().unwrap(), [500]);
assert_eq!(state.generation.load(Ordering::Acquire), 1);
state.commit_presentation();
assert_eq!(state.generation.load(Ordering::Acquire), 1);
state.dismissing.store(true, Ordering::Release);
*state.pending.lock().unwrap() = Some(queue(1000));
state.commit_presentation();
assert_eq!(
*state.opacity.lock().unwrap(),
800,
"client cannot restart compositor fade"
);
}
#[test]
fn region_opacities_validate_independent_fades() {
let bytes: Vec<_> = [0u32, 500, 1000].into_iter().flat_map(u32::to_ne_bytes).collect();
+1
View File
@@ -39,6 +39,7 @@ impl CompositorHandler for Ferese {
self.capture_resize_before_commit(surface);
self.capture_close_before_commit(surface);
on_commit_buffer_handler::<Self>(surface);
crate::effects::commit_presentation(surface);
if !is_sync_subsurface(surface) {
let mut root = surface.clone();
while let Some(parent) = get_parent(&root) {