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) {
+16 -2
View File
@@ -22,7 +22,7 @@
DEALINGS IN THE SOFTWARE.
</copyright>
<interface name="ferese_effects_manager_v1" version="4">
<interface name="ferese_effects_manager_v1" version="5">
<description summary="assign semantic material roles to surfaces">
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 @@
</enum>
</interface>
<interface name="ferese_surface_effects_v1" version="4">
<interface name="ferese_surface_effects_v1" version="5">
<description summary="semantic material role for one surface">
The role affects only compositor rendering. It does not change surface
geometry, input, focus, stacking, or the surface's Wayland role.
@@ -107,5 +107,19 @@
</description>
<arg name="opacities" type="array"/>
</request>
<request name="set_presentation" since="5">
<description summary="apply material presentation with the next surface commit">
Queue the role, regions, surface opacity and region opacities together.
They take effect on the next wl_surface.commit, with its content buffer.
Array formats and limits match set_regions and set_region_opacities.
Invalid arrays or roles leave the pending state unchanged. Surface opacity
is clamped to 1000. The last valid request before a commit wins.
Compositor-owned dismissal retains control of surface opacity.
</description>
<arg name="role" type="uint" enum="role"/>
<arg name="regions" type="array"/>
<arg name="opacity" type="uint"/>
<arg name="region_opacities" type="array"/>
</request>
</interface>
</protocol>
+2 -2
View File
@@ -119,10 +119,10 @@ class ShellMotionTest(unittest.TestCase):
# Clock/status updates still redraw settled surfaces. Count
# commits: Iced can request two callbacks for one redraw.
self.assertLessEqual(idle_frames, 12, "settled toast kept requesting animation frames")
material_updates = log_path.read_text().count('.set_region_opacities(')
material_updates = log_path.read_text().count('.set_presentation(')
dbus("CloseNotification", notice)
wait_for(lambda: destroyed(surface), timeout=5)
self.assertGreater(log_path.read_text().count('.set_region_opacities(') - material_updates, 5,
self.assertGreater(log_path.read_text().count('.set_presentation(') - material_updates, 5,
"notification materials did not follow their fade")
print(f"Toast: {opening_frames} opening callback requests, {idle_frames / 2:g} settled commits/s; destroyed", flush=True)
+4 -2
View File
@@ -524,12 +524,14 @@ impl FereseShell {
if let Some(surface) = &self.notification_surface
&& let Some(effects) = &surface.effects
{
let _ = effects.set_regions(&surface.regions.lock().unwrap());
let _ = effects.set_region_opacities(
let _ = effects.set_presentation(
&surface.regions.lock().unwrap(),
1.0,
self.notifications
.popup_groups_at(now)
.iter()
.map(|(_, opacity, _)| *opacity),
ferese_surface_effects_v1::Role::Popover,
);
}
},
@@ -145,9 +145,6 @@ impl FereseShell {
pub fn animate_menu(&mut self) -> Task<Message> {
if let Some(menu) = &self.menu {
if let Some(effects) = &menu.effects {
let _ = effects.set_opacity(menu.progress().clamp(0.0, 1.0));
}
if menu.motion.closing() && !menu.animating() {
return self.destroy_menu();
}
+7 -2
View File
@@ -120,6 +120,7 @@ impl OpenMenu {
super::EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
}
#[cfg(test)]
pub fn progress(&self) -> f32 {
self.motion.progress()
}
@@ -138,8 +139,12 @@ impl FereseShell {
|now| menu.motion.frame_active(now),
|now| {
if let Some(effects) = &menu.effects {
let _ = effects.set_regions(&menu.regions.lock().unwrap());
let _ = effects.set_opacity(menu.motion.progress_at(now).clamp(0.0, 1.0));
let _ = effects.set_presentation(
&menu.regions.lock().unwrap(),
menu.motion.progress_at(now),
[],
super::ferese_surface_effects_v1::Role::Popover,
);
}
},
|now| {
+19 -13
View File
@@ -333,9 +333,6 @@ impl FereseShell {
}
_ => {}
}
if let Some(effects) = &menu.effects {
let _ = effects.set_opacity(menu.progress());
}
}
Task::none()
}
@@ -488,7 +485,7 @@ pub(super) struct EffectsBinding {
_queue: EventQueue<EffectsState>,
regions: std::cell::RefCell<Option<Vec<[f32; 5]>>>,
opacity: std::cell::Cell<Option<u32>>,
region_opacities: std::cell::RefCell<Vec<u32>>,
presentation: std::cell::RefCell<Option<(Vec<[f32; 5]>, u32, Vec<u32>, ferese_surface_effects_v1::Role)>>,
}
impl EffectsBinding {
@@ -512,7 +509,7 @@ impl EffectsBinding {
let connection = Connection::from_backend(backend);
let (globals, queue) = registry_queue_init::<EffectsState>(&connection)?;
let qh = queue.handle();
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 4..=4, ())?;
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 5..=5, ())?;
let effects = manager.get_surface_effects(surface, &qh, ());
let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
@@ -531,24 +528,33 @@ impl EffectsBinding {
_queue: queue,
regions: Default::default(),
opacity: std::cell::Cell::new(Some(opacity)),
region_opacities: Default::default(),
presentation: Default::default(),
})
}
pub(super) fn set_region_opacities(
pub(super) fn set_presentation(
&self,
values: impl IntoIterator<Item = f32>,
regions: &[[f32; 5]],
opacity: f32,
region_opacities: impl IntoIterator<Item = f32>,
role: ferese_surface_effects_v1::Role,
) -> Result<(), Box<dyn std::error::Error>> {
let values: Vec<u32> = values
let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
let values: Vec<u32> = region_opacities
.into_iter()
.take(32)
.map(|value| (value.clamp(0.0, 1.0) * 1000.0).round() as u32)
.collect();
if *self.region_opacities.borrow() != values {
self.surface
.set_region_opacities(values.iter().flat_map(|value| value.to_ne_bytes()).collect());
let next = (regions.to_vec(), opacity, values, role);
if self.presentation.borrow().as_ref() != Some(&next) {
self.surface.set_presentation(
role,
encode_regions(regions),
opacity,
next.2.iter().flat_map(|value| value.to_ne_bytes()).collect(),
);
self.connection.flush()?;
*self.region_opacities.borrow_mut() = values;
*self.presentation.borrow_mut() = Some(next);
}
Ok(())
}
+6 -16
View File
@@ -291,18 +291,6 @@ impl FereseShell {
}
}
pub(super) fn update_power_materials(&self) {
if let Some(modal) = &self.system_modal {
for entry in &modal.surfaces {
if let Some(effects) = &entry.effects {
let regions = entry.regions.lock().unwrap().clone();
let _ = effects.set_material_regions(&regions, ferese_surface_effects_v1::Role::Modal);
let _ = effects.set_opacity(modal.motion.progress().clamp(0.0, 1.0));
}
}
}
}
pub(super) fn close_system_modal(&mut self) -> Task<Message> {
if self.system_modal.as_ref().is_some_and(SystemModal::is_display_mode) {
if self.display_mode.busy {
@@ -339,7 +327,6 @@ impl FereseShell {
{
return self.destroy_system_modal(false);
}
self.update_power_materials();
Task::none()
}
@@ -483,9 +470,12 @@ impl FereseShell {
|now| modal.motion.frame_active(now),
|now| {
if let Some(effects) = &surface.effects {
let _ = effects
.set_material_regions(&surface.regions.lock().unwrap(), ferese_surface_effects_v1::Role::Modal);
let _ = effects.set_opacity(modal.motion.progress_at(now).clamp(0.0, 1.0));
let _ = effects.set_presentation(
&surface.regions.lock().unwrap(),
modal.motion.progress_at(now),
[],
ferese_surface_effects_v1::Role::Modal,
);
}
},
|now| {