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 } ResolvedMaterial { style, opacity, shadow }
} }
#[derive(Debug)]
struct PendingPresentation {
role: SemanticRole,
regions: Vec<[f64; 5]>,
opacity: u32,
region_opacities: Vec<u32>,
}
#[derive(Debug)] #[derive(Debug)]
struct SurfaceEffectsState { struct SurfaceEffectsState {
attached: AtomicBool, attached: AtomicBool,
@@ -58,6 +66,7 @@ struct SurfaceEffectsState {
role: Mutex<Option<SemanticRole>>, role: Mutex<Option<SemanticRole>>,
regions: Mutex<Option<Vec<[f64; 5]>>>, regions: Mutex<Option<Vec<[f64; 5]>>>,
opacity: Mutex<u32>, opacity: Mutex<u32>,
pending: Mutex<Option<PendingPresentation>>,
region_opacities: Mutex<Vec<u32>>, region_opacities: Mutex<Vec<u32>>,
presentation_supported: AtomicBool, presentation_supported: AtomicBool,
dismissing: AtomicBool, dismissing: AtomicBool,
@@ -71,12 +80,26 @@ impl SurfaceEffectsState {
role: Mutex::new(None), role: Mutex::new(None),
regions: Mutex::new(None), regions: Mutex::new(None),
opacity: Mutex::new(1000), opacity: Mutex::new(1000),
pending: Mutex::new(None),
region_opacities: Mutex::new(Vec::new()), region_opacities: Mutex::new(Vec::new()),
presentation_supported: AtomicBool::new(false), presentation_supported: AtomicBool::new(false),
dismissing: 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 { fn set_role(&self, role: Option<SemanticRole>) -> bool {
let mut current = self.role.lock().unwrap(); let mut current = self.role.lock().unwrap();
if *current != role { if *current != role {
@@ -103,7 +126,7 @@ impl SurfaceEffectsUserData {
} }
pub(crate) fn init_global(display: &DisplayHandle) { 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, ()); 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]>> { pub(crate) fn surface_regions(surface: &WlSurface) -> Option<Vec<[f64; 5]>> {
with_states(surface, |states| { with_states(surface, |states| {
states states
@@ -330,6 +361,30 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
}; };
match request { 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 } => { ferese_surface_effects_v1::Request::SetOpacity { opacity } => {
let changed = with_states(&surface, |states| { let changed = with_states(&surface, |states| {
let Some(effects) = states.data_map.get::<SurfaceEffectsState>() else { 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 } => { ferese_surface_effects_v1::Request::SetRole { role } => {
let role = match role { let Some(role) = decode_role(role) else {
WEnum::Value(ferese_surface_effects_v1::Role::Panel) => SemanticRole::Panel, return;
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,
}; };
if set_surface_role(&surface, Some(role)) { if set_surface_role(&surface, Some(role)) {
crate::backends::direct::render_surface(state, &surface); 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 { fn set_surface_role(surface: &WlSurface, role: Option<SemanticRole>) -> bool {
with_states(surface, |states| { with_states(surface, |states| {
states states
@@ -448,6 +509,7 @@ fn detach(surface: &WlSurface) -> bool {
}; };
let role_changed = effects.set_role(None); let role_changed = effects.set_role(None);
effects.region_opacities.lock().unwrap().clear(); effects.region_opacities.lock().unwrap().clear();
effects.pending.lock().unwrap().take();
effects.dismissing.store(false, Ordering::Release); effects.dismissing.store(false, Ordering::Release);
let mut opacity = effects.opacity.lock().unwrap(); let mut opacity = effects.opacity.lock().unwrap();
let opacity_changed = *opacity != 1000; let opacity_changed = *opacity != 1000;
@@ -489,6 +551,38 @@ pub(crate) fn fade_dismissed_surface(surface: &WlSurface, opacity: f64) {
mod tests { mod tests {
use super::*; 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] #[test]
fn region_opacities_validate_independent_fades() { fn region_opacities_validate_independent_fades() {
let bytes: Vec<_> = [0u32, 500, 1000].into_iter().flat_map(u32::to_ne_bytes).collect(); 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_resize_before_commit(surface);
self.capture_close_before_commit(surface); self.capture_close_before_commit(surface);
on_commit_buffer_handler::<Self>(surface); on_commit_buffer_handler::<Self>(surface);
crate::effects::commit_presentation(surface);
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) {
+16 -2
View File
@@ -22,7 +22,7 @@
DEALINGS IN THE SOFTWARE. DEALINGS IN THE SOFTWARE.
</copyright> </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"> <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="4"> <interface name="ferese_surface_effects_v1" version="5">
<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.
@@ -107,5 +107,19 @@
</description> </description>
<arg name="opacities" type="array"/> <arg name="opacities" type="array"/>
</request> </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> </interface>
</protocol> </protocol>
+2 -2
View File
@@ -119,10 +119,10 @@ class ShellMotionTest(unittest.TestCase):
# Clock/status updates still redraw settled surfaces. Count # Clock/status updates still redraw settled surfaces. Count
# commits: Iced can request two callbacks for one redraw. # commits: Iced can request two callbacks for one redraw.
self.assertLessEqual(idle_frames, 12, "settled toast kept requesting animation frames") 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) dbus("CloseNotification", notice)
wait_for(lambda: destroyed(surface), timeout=5) 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") "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) 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 if let Some(surface) = &self.notification_surface
&& let Some(effects) = &surface.effects && let Some(effects) = &surface.effects
{ {
let _ = effects.set_regions(&surface.regions.lock().unwrap()); let _ = effects.set_presentation(
let _ = effects.set_region_opacities( &surface.regions.lock().unwrap(),
1.0,
self.notifications self.notifications
.popup_groups_at(now) .popup_groups_at(now)
.iter() .iter()
.map(|(_, opacity, _)| *opacity), .map(|(_, opacity, _)| *opacity),
ferese_surface_effects_v1::Role::Popover,
); );
} }
}, },
@@ -145,9 +145,6 @@ impl FereseShell {
pub fn animate_menu(&mut self) -> Task<Message> { pub fn animate_menu(&mut self) -> Task<Message> {
if let Some(menu) = &self.menu { 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() { if menu.motion.closing() && !menu.animating() {
return self.destroy_menu(); return self.destroy_menu();
} }
+7 -2
View File
@@ -120,6 +120,7 @@ impl OpenMenu {
super::EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); super::EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
} }
#[cfg(test)]
pub fn progress(&self) -> f32 { pub fn progress(&self) -> f32 {
self.motion.progress() self.motion.progress()
} }
@@ -138,8 +139,12 @@ impl FereseShell {
|now| menu.motion.frame_active(now), |now| menu.motion.frame_active(now),
|now| { |now| {
if let Some(effects) = &menu.effects { if let Some(effects) = &menu.effects {
let _ = effects.set_regions(&menu.regions.lock().unwrap()); let _ = effects.set_presentation(
let _ = effects.set_opacity(menu.motion.progress_at(now).clamp(0.0, 1.0)); &menu.regions.lock().unwrap(),
menu.motion.progress_at(now),
[],
super::ferese_surface_effects_v1::Role::Popover,
);
} }
}, },
|now| { |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() Task::none()
} }
@@ -488,7 +485,7 @@ pub(super) struct EffectsBinding {
_queue: EventQueue<EffectsState>, _queue: EventQueue<EffectsState>,
regions: std::cell::RefCell<Option<Vec<[f32; 5]>>>, regions: std::cell::RefCell<Option<Vec<[f32; 5]>>>,
opacity: std::cell::Cell<Option<u32>>, 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 { impl EffectsBinding {
@@ -512,7 +509,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, 4..=4, ())?; let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 5..=5, ())?;
let effects = manager.get_surface_effects(surface, &qh, ()); let effects = manager.get_surface_effects(surface, &qh, ());
let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32; let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
@@ -531,24 +528,33 @@ impl EffectsBinding {
_queue: queue, _queue: queue,
regions: Default::default(), regions: Default::default(),
opacity: std::cell::Cell::new(Some(opacity)), 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, &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>> { ) -> 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() .into_iter()
.take(32) .take(32)
.map(|value| (value.clamp(0.0, 1.0) * 1000.0).round() as u32) .map(|value| (value.clamp(0.0, 1.0) * 1000.0).round() as u32)
.collect(); .collect();
if *self.region_opacities.borrow() != values { let next = (regions.to_vec(), opacity, values, role);
self.surface if self.presentation.borrow().as_ref() != Some(&next) {
.set_region_opacities(values.iter().flat_map(|value| value.to_ne_bytes()).collect()); self.surface.set_presentation(
role,
encode_regions(regions),
opacity,
next.2.iter().flat_map(|value| value.to_ne_bytes()).collect(),
);
self.connection.flush()?; self.connection.flush()?;
*self.region_opacities.borrow_mut() = values; *self.presentation.borrow_mut() = Some(next);
} }
Ok(()) 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> { 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.system_modal.as_ref().is_some_and(SystemModal::is_display_mode) {
if self.display_mode.busy { if self.display_mode.busy {
@@ -339,7 +327,6 @@ impl FereseShell {
{ {
return self.destroy_system_modal(false); return self.destroy_system_modal(false);
} }
self.update_power_materials();
Task::none() Task::none()
} }
@@ -483,9 +470,12 @@ impl FereseShell {
|now| modal.motion.frame_active(now), |now| modal.motion.frame_active(now),
|now| { |now| {
if let Some(effects) = &surface.effects { if let Some(effects) = &surface.effects {
let _ = effects let _ = effects.set_presentation(
.set_material_regions(&surface.regions.lock().unwrap(), ferese_surface_effects_v1::Role::Modal); &surface.regions.lock().unwrap(),
let _ = effects.set_opacity(modal.motion.progress_at(now).clamp(0.0, 1.0)); modal.motion.progress_at(now),
[],
ferese_surface_effects_v1::Role::Modal,
);
} }
}, },
|now| { |now| {