Publish output topology through a desktop reconciliation boundary
This commit is contained in:
1 parent
647eaf4ad9
commit
f5de30d037
16 files changed
+1316
-263
No files matched your search
@@ -528,11 +528,14 @@ fn update_power_policy(state: &mut Ferese) {
|
||||
};
|
||||
if let Some(next) = super::power::policy_change(backend.active, backend.low_power, battery) {
|
||||
backend.low_power = next;
|
||||
reload_outputs(state);
|
||||
reload_outputs(state, false);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reload_outputs(state: &mut Ferese) {
|
||||
pub(crate) fn reload_outputs(state: &mut Ferese, layout_dirty: bool) {
|
||||
if let Some(backend) = state.direct_backend.as_mut() {
|
||||
backend.topology.layout_dirty |= layout_dirty;
|
||||
}
|
||||
reconcile_outputs(state, false);
|
||||
}
|
||||
|
||||
@@ -1088,6 +1091,14 @@ pub(crate) fn render_surface(
|
||||
}
|
||||
|
||||
fn redraw(state: &mut Ferese, selected: Option<&[Output]>, source: &'static std::panic::Location<'static>) {
|
||||
if let Some(transition) = state.desktop_transition.as_mut() {
|
||||
transition.redraw.extend(
|
||||
selected
|
||||
.map(|outputs| outputs.to_vec())
|
||||
.unwrap_or_else(|| state.space.outputs().cloned().collect()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if state
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
@@ -1448,6 +1459,11 @@ fn refresh_connected_info(state: &mut Ferese) {
|
||||
|
||||
fn remember_applied_configuration(state: &mut Ferese) {
|
||||
let backend = state.direct_backend.as_mut().unwrap();
|
||||
if backend.topology.reconciling {
|
||||
// Applied positions are read only after the coherent logical publish.
|
||||
backend.topology.remember_applied = true;
|
||||
return;
|
||||
}
|
||||
let mut applied = backend.desired_outputs.clone();
|
||||
for desired in &mut applied.outputs {
|
||||
let monitor = backend.monitors.iter().find(|monitor| monitor.key == desired.key);
|
||||
|
||||
@@ -77,6 +77,8 @@ pub(super) struct Topology {
|
||||
pub invalidated: HashSet<DrmNode>,
|
||||
pub reconciling: bool,
|
||||
pub dirty: bool,
|
||||
pub layout_dirty: bool,
|
||||
pub remember_applied: bool,
|
||||
pub retry_timer: Option<RegistrationToken>,
|
||||
pub settle_timer: Option<RegistrationToken>,
|
||||
pub coalescer: crate::output_policy::Debouncer,
|
||||
@@ -165,6 +167,7 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
|
||||
}
|
||||
paths.sort();
|
||||
let mut ready = Vec::new();
|
||||
state.begin_desktop_transition();
|
||||
|
||||
for node in invalidated {
|
||||
remove_device(state, node);
|
||||
@@ -227,7 +230,14 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
|
||||
output.frame_pending = false;
|
||||
output.lock_frame_pending = false;
|
||||
output.power.suspended();
|
||||
state.session_lock.output_added(&output.output);
|
||||
state.display_presentation.remove_output(&output.output);
|
||||
state
|
||||
.desktop_transition
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.redraw
|
||||
.insert(output.output.clone());
|
||||
|
||||
if let Err(error) = output.surface.clear().and_then(|()| output.surface.reset_state()) {
|
||||
tracing::warn!(%error, ?node, ?crtc, "output reset failed; recreating output");
|
||||
@@ -327,10 +337,19 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
|
||||
}
|
||||
|
||||
transaction::recover_last_output(state);
|
||||
refresh_connected_info(state);
|
||||
let mut desktop_changes = publish_runtime_desktop(state);
|
||||
if std::mem::take(&mut state.direct_backend.as_mut().unwrap().topology.layout_dirty) {
|
||||
desktop_changes.layout = true;
|
||||
desktop_changes.shell = true;
|
||||
desktop_changes.outputs.extend(state.space.outputs().cloned());
|
||||
}
|
||||
let backend = state.direct_backend.as_mut().unwrap();
|
||||
backend.topology.reconciling = false;
|
||||
let retry = backend.topology.retry_deadline(backend.active);
|
||||
if std::mem::take(&mut backend.topology.remember_applied) {
|
||||
remember_applied_configuration(state);
|
||||
}
|
||||
refresh_connected_info(state);
|
||||
if let Some(deadline) = retry {
|
||||
match state
|
||||
.loop_handle
|
||||
@@ -346,16 +365,81 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
state.refresh_lock_outputs();
|
||||
state.refresh_idle_inhibition();
|
||||
sync_battery_timer(state);
|
||||
state.restore_output_focus();
|
||||
state.relayout();
|
||||
state.finish_desktop_transition(desktop_changes);
|
||||
state.notify_monitor_state();
|
||||
render_all(state);
|
||||
lid::apply_suspend_policy(state);
|
||||
}
|
||||
|
||||
/// Publish only hardware present after application, compensation and recovery.
|
||||
fn publish_runtime_desktop(state: &mut Ferese) -> crate::state::DesktopChanges {
|
||||
let mut outputs = state
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.devices
|
||||
.values()
|
||||
.flat_map(|device| device.outputs.values())
|
||||
.filter(|output| output.mirror_source.is_none())
|
||||
.map(|output| {
|
||||
(
|
||||
output.identity.clone(),
|
||||
output.output.clone(),
|
||||
output.mode,
|
||||
output.settings.clone(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
outputs.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
let mut right_edge = 0;
|
||||
let runtime = outputs
|
||||
.into_iter()
|
||||
.map(|(identity, output, mode, settings)| {
|
||||
let position = settings.position.unwrap_or_else(|| {
|
||||
state
|
||||
.space
|
||||
.output_geometry(&output)
|
||||
.map(|geometry| [geometry.loc.x, geometry.loc.y])
|
||||
.unwrap_or([right_edge, 0])
|
||||
});
|
||||
let mode = OutputMode::from(mode);
|
||||
let transform = output_transform(settings.transform);
|
||||
let width = (transform.transform_size(mode.size).w as f64 / settings.scale).ceil() as i32;
|
||||
right_edge = right_edge.max(position[0].saturating_add(width));
|
||||
crate::state::DesktopOutput {
|
||||
output,
|
||||
identity,
|
||||
mode,
|
||||
transform,
|
||||
scale: Scale::Fractional(settings.scale),
|
||||
position: (position[0], position[1]).into(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let changes = state
|
||||
.publish_desktop(runtime)
|
||||
.expect("validated usable output inventory");
|
||||
for device in state.direct_backend.as_mut().unwrap().devices.values_mut() {
|
||||
for output in device.outputs.values_mut() {
|
||||
if output.mirror_source.is_none() {
|
||||
if output.global.is_none() {
|
||||
output.global = Some(output.output.create_global::<Ferese>(&state.display_handle));
|
||||
}
|
||||
} else {
|
||||
// Mirrors are physical scanouts, not workspace-owning outputs.
|
||||
output.output.change_current_state(
|
||||
Some(OutputMode::from(output.mode)),
|
||||
Some(output_transform(output.settings.transform)),
|
||||
Some(Scale::Fractional(output.settings.scale)),
|
||||
None,
|
||||
);
|
||||
}
|
||||
output.surface.set_output_mode_source((&output.output).into());
|
||||
}
|
||||
}
|
||||
changes
|
||||
}
|
||||
|
||||
const SETTLE: Duration = Duration::from_millis(150);
|
||||
|
||||
/// Trailing-edge coalescing; inactive events retain dirtiness without arming a timer.
|
||||
|
||||
@@ -365,6 +365,13 @@ impl crate::output_transaction::HardwareTransaction for DeviceTransaction<'_> {
|
||||
})
|
||||
.and_then(|()| output.surface.commit_frame().map_err(|error| error.to_string()));
|
||||
self.state.session_lock.output_added(&output.output);
|
||||
self.state.display_presentation.remove_output(&output.output);
|
||||
self.state
|
||||
.desktop_transition
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.redraw
|
||||
.insert(output.output.clone());
|
||||
output.lock_frame_pending = false;
|
||||
output.surface.set_output_mode_source((&output.output).into());
|
||||
cancel_output_timers(&self.state.loop_handle, output);
|
||||
@@ -373,8 +380,8 @@ impl crate::output_transaction::HardwareTransaction for DeviceTransaction<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// All allocations/tests precede commits. Logical publication follows every
|
||||
/// successful commit on this device; other devices are separately rollbackable.
|
||||
/// All allocations/tests precede commits. Logical publication waits for the
|
||||
/// final usable inventory; devices are independently applied and restored.
|
||||
pub(super) fn apply_device(
|
||||
state: &mut Ferese,
|
||||
node: DrmNode,
|
||||
@@ -436,8 +443,6 @@ pub(super) fn apply_device(
|
||||
for crtc in &changed {
|
||||
let selection = &selections[crtc];
|
||||
let mut output = staged.remove(crtc).or_else(|| device.outputs.remove(crtc)).unwrap();
|
||||
let old_geometry = state.space.output_geometry(&output.output);
|
||||
let old_id = state.output_id(&output.output);
|
||||
if output.connector != selection.connector.handle() || output.identity != selection.identity {
|
||||
retire(state, &mut output, node, *crtc);
|
||||
let (handle, _) = create_output(
|
||||
@@ -459,65 +464,18 @@ pub(super) fn apply_device(
|
||||
output.settings = selection.settings.clone();
|
||||
output.mode = selection.mode;
|
||||
output.mirror_source = selection.mirror_source.clone();
|
||||
let position = selection.settings.position.unwrap_or_else(|| {
|
||||
old_geometry
|
||||
.map(|geometry| [geometry.loc.x, geometry.loc.y])
|
||||
.unwrap_or_else(|| {
|
||||
[
|
||||
// Resource metadata describes applied hardware. Wayland output state,
|
||||
// workspace ownership and floating positions publish once after all GPUs.
|
||||
state
|
||||
.space
|
||||
.outputs()
|
||||
.filter_map(|output| state.space.output_geometry(output))
|
||||
.map(|geometry| geometry.loc.x + geometry.size.w)
|
||||
.max()
|
||||
.unwrap_or(0),
|
||||
0,
|
||||
]
|
||||
})
|
||||
});
|
||||
output.output.change_current_state(
|
||||
Some(OutputMode::from(output.mode)),
|
||||
Some(output_transform(output.settings.transform)),
|
||||
Some(Scale::Fractional(output.settings.scale)),
|
||||
Some((position[0], position[1]).into()),
|
||||
);
|
||||
if output.mirror_source.is_none() {
|
||||
if output.global.is_none() {
|
||||
output.global = Some(output.output.create_global::<Ferese>(&state.display_handle));
|
||||
}
|
||||
state.space.map_output(&output.output, (position[0], position[1]));
|
||||
if state.output_id(&output.output).is_none() {
|
||||
state.register_output(&output.output, output.identity.clone());
|
||||
}
|
||||
if let Some(id) = state.output_id(&output.output)
|
||||
&& let Some(geometry) = state.space.output_geometry(&output.output)
|
||||
{
|
||||
state.output_workspaces.update_geometry(
|
||||
id,
|
||||
ferese_core::OutputGeometry::new(geometry.loc.x, geometry.loc.y, geometry.size.w, geometry.size.h),
|
||||
);
|
||||
if old_id == Some(id)
|
||||
&& let Some(old) = old_geometry
|
||||
{
|
||||
state.reposition_output_floats(
|
||||
id,
|
||||
ferese_layout::Rect::new(
|
||||
old.loc.x as f64,
|
||||
old.loc.y as f64,
|
||||
old.size.w as f64,
|
||||
old.size.h as f64,
|
||||
),
|
||||
ferese_layout::Rect::new(
|
||||
geometry.loc.x as f64,
|
||||
geometry.loc.y as f64,
|
||||
geometry.size.w as f64,
|
||||
geometry.size.h as f64,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
.desktop_transition
|
||||
.as_mut()
|
||||
.expect("output reconciliation boundary")
|
||||
.redraw
|
||||
.insert(output.output.clone());
|
||||
state.session_lock.output_added(&output.output);
|
||||
if hardware_changes.contains(crtc) {
|
||||
state.display_presentation.remove_output(&output.output);
|
||||
}
|
||||
output.lock_frame_pending = false;
|
||||
output.surface.set_output_mode_source((&output.output).into());
|
||||
cancel_output_timers(&state.loop_handle, &mut output);
|
||||
@@ -536,7 +494,7 @@ pub(super) fn apply_device(
|
||||
clock.reset_timing();
|
||||
device.outputs.insert(*crtc, output);
|
||||
}
|
||||
// Add/register destinations before removing former owners, reusing evacuation.
|
||||
// Retire resources now; the final inventory determines evacuation once.
|
||||
for crtc in removals {
|
||||
let mut output = device.outputs.remove(&crtc).unwrap();
|
||||
retire(state, &mut output, node, crtc);
|
||||
@@ -605,7 +563,7 @@ pub(super) fn recover_last_output(state: &mut Ferese) {
|
||||
.unwrap()
|
||||
.devices
|
||||
.values()
|
||||
.any(|device| device.outputs.values().any(|output| output.global.is_some()))
|
||||
.any(|device| device.outputs.values().any(|output| output.mirror_source.is_none()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -654,7 +612,7 @@ pub(super) fn recover_last_output(state: &mut Ferese) {
|
||||
.unwrap()
|
||||
.devices
|
||||
.values()
|
||||
.any(|device| device.outputs.values().any(|output| output.global.is_some()))
|
||||
.any(|device| device.outputs.values().any(|output| output.mirror_source.is_none()))
|
||||
{
|
||||
remember_applied_configuration(state);
|
||||
state.direct_backend.as_mut().unwrap().output_error = original_error;
|
||||
|
||||
@@ -146,4 +146,51 @@ mod tests {
|
||||
assert_eq!(failure.unavailable, vec![1]);
|
||||
assert!(!fake.active.contains(&1));
|
||||
}
|
||||
#[test]
|
||||
fn desktop_publication_uses_survivors_after_apply_and_rollback_failures() {
|
||||
use ferese_core::{OutputGeometry, OutputId, OutputWorkspaceMap, WorkspaceId};
|
||||
let inventory = |active: &BTreeSet<u8>| {
|
||||
active
|
||||
.iter()
|
||||
.map(|id| (OutputId(*id as u64), OutputGeometry::new(*id as i32 * 800, 0, 800, 600)))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
for rollback_fails in [false, true] {
|
||||
let mut hardware = Fake::new();
|
||||
hardware.active.insert(2);
|
||||
hardware.previous = hardware.active.clone();
|
||||
let published = OutputWorkspaceMap::default()
|
||||
.plan_desktop(
|
||||
&inventory(&hardware.active),
|
||||
&[WorkspaceId(1), WorkspaceId(2)],
|
||||
WorkspaceId(3),
|
||||
)
|
||||
.unwrap()
|
||||
.outputs;
|
||||
hardware.fail = Some(("commit", 1));
|
||||
hardware.restore_fails = rollback_fails;
|
||||
let result = apply(&mut hardware, &[1, 3], &[]);
|
||||
assert!(result.is_err());
|
||||
// Neither attempted commits nor recovery mutate the published map.
|
||||
assert_eq!(
|
||||
published.connected_outputs().collect::<Vec<_>>(),
|
||||
vec![OutputId(1), OutputId(2)]
|
||||
);
|
||||
let plan = published
|
||||
.plan_desktop(
|
||||
&inventory(&hardware.active),
|
||||
&[WorkspaceId(1), WorkspaceId(2)],
|
||||
WorkspaceId(3),
|
||||
)
|
||||
.unwrap();
|
||||
if rollback_fails {
|
||||
assert_eq!(plan.outputs.connected_outputs().collect::<Vec<_>>(), vec![OutputId(2)]);
|
||||
assert_eq!(plan.outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(2)));
|
||||
} else {
|
||||
assert_eq!(plan.outputs, published);
|
||||
assert!(plan.affected_outputs.is_empty());
|
||||
}
|
||||
assert_eq!(plan.outputs.output_for_workspace(WorkspaceId(3)), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,6 +291,10 @@ impl Ferese {
|
||||
}
|
||||
|
||||
pub(crate) fn send_shell_snapshots(&mut self) {
|
||||
if let Some(transition) = self.desktop_transition.as_mut() {
|
||||
transition.shell_pending = true;
|
||||
return;
|
||||
}
|
||||
if self
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
@@ -390,6 +394,11 @@ impl Ferese {
|
||||
}
|
||||
|
||||
fn send_shell_snapshot(&mut self, shell: &FereseShellV1) {
|
||||
if let Some(transition) = self.desktop_transition.as_mut() {
|
||||
transition.shell_pending = true;
|
||||
self.last_shell_snapshot = None;
|
||||
return;
|
||||
}
|
||||
// New subscribers always receive a complete snapshot. Do not update the
|
||||
// broadcast cache here: existing subscribers may still need this state.
|
||||
self.shell_snapshot_serial = self.shell_snapshot_serial.wrapping_add(1);
|
||||
|
||||
+10
-7
@@ -5,6 +5,8 @@ mod hit_testing;
|
||||
mod layout;
|
||||
mod navigation;
|
||||
mod outputs;
|
||||
mod reconciliation;
|
||||
pub(crate) use reconciliation::{DesktopChanges, DesktopOutput, DesktopTransition};
|
||||
mod prediction;
|
||||
pub(crate) use prediction::FrameScene;
|
||||
mod window_registry;
|
||||
@@ -330,6 +332,7 @@ pub struct Ferese {
|
||||
pub space: Space<Window>,
|
||||
pub workspaces: WorkspaceSet,
|
||||
pub output_workspaces: OutputWorkspaceMap,
|
||||
pub(crate) desktop_transition: Option<DesktopTransition>,
|
||||
pub(crate) output_ids: HashMap<Output, OutputId>,
|
||||
outputs_by_id: HashMap<OutputId, Output>,
|
||||
pub(crate) output_names: HashMap<OutputId, String>,
|
||||
@@ -562,6 +565,7 @@ impl Ferese {
|
||||
config.scrolling_focus_strategy,
|
||||
),
|
||||
output_workspaces: OutputWorkspaceMap::default(),
|
||||
desktop_transition: None,
|
||||
output_ids: HashMap::new(),
|
||||
outputs_by_id: HashMap::new(),
|
||||
output_names: HashMap::new(),
|
||||
@@ -688,7 +692,8 @@ impl Ferese {
|
||||
let input_changed = changed(&["input"]);
|
||||
let daemons_changed = changed(&["autostart"]);
|
||||
let lock_changed = changed(&["lock_screen"]);
|
||||
let scene_changed = layout_changed || changed(&["animations", "appearance", "window_rules", "output_profiles"]);
|
||||
let non_output_scene_changed = layout_changed || changed(&["animations", "appearance", "window_rules"]);
|
||||
let scene_changed = non_output_scene_changed || changed(&["output_profiles"]);
|
||||
|
||||
if self.output_profiles != config.output_profiles {
|
||||
crate::backends::direct::validate_live_outputs(self, &config.output_profiles)?;
|
||||
@@ -789,10 +794,6 @@ impl Ferese {
|
||||
crate::backends::direct::reload_input_devices(self);
|
||||
}
|
||||
|
||||
if outputs_changed {
|
||||
crate::backends::direct::reload_outputs(self);
|
||||
}
|
||||
|
||||
if old_rules != self.window_rules {
|
||||
self.reapply_window_rules(&old_rules);
|
||||
}
|
||||
@@ -802,9 +803,11 @@ impl Ferese {
|
||||
self.wallpaper.reload(config.wallpaper);
|
||||
}
|
||||
|
||||
if scene_changed {
|
||||
if outputs_changed && self.direct_backend.is_some() {
|
||||
// Output publication also finalizes any accompanying scene changes.
|
||||
crate::backends::direct::reload_outputs(self, non_output_scene_changed);
|
||||
} else if scene_changed {
|
||||
self.relayout();
|
||||
crate::backends::direct::render_all(self);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -128,7 +128,7 @@ impl Ferese {
|
||||
}
|
||||
|
||||
// The production caller supplies monotonic wall time; tests supply a fake clock.
|
||||
fn advance_animations_at(&mut self, delta: Duration, now: Duration) -> bool {
|
||||
pub(super) fn advance_animations_at(&mut self, delta: Duration, now: Duration) -> bool {
|
||||
if !self.animations_need_tick() {
|
||||
self.sync_window_stacking();
|
||||
return false;
|
||||
|
||||
@@ -52,7 +52,7 @@ impl Ferese {
|
||||
}
|
||||
|
||||
pub fn relayout(&mut self) {
|
||||
self.relayout_outputs(None);
|
||||
self.relayout_outputs(None, true);
|
||||
}
|
||||
|
||||
pub(crate) fn relayout_window(&mut self, id: WindowId) {
|
||||
@@ -66,10 +66,21 @@ impl Ferese {
|
||||
}
|
||||
|
||||
pub(crate) fn relayout_on(&mut self, outputs: &[Output]) {
|
||||
self.relayout_outputs(Some(outputs.to_vec()));
|
||||
self.relayout_outputs(Some(outputs.to_vec()), true);
|
||||
}
|
||||
|
||||
fn relayout_outputs(&mut self, mut redraw_outputs: Option<Vec<Output>>) {
|
||||
pub(super) fn relayout_desktop(&mut self) {
|
||||
self.relayout_outputs(None, false);
|
||||
}
|
||||
|
||||
fn relayout_outputs(&mut self, mut redraw_outputs: Option<Vec<Output>>, finalize: bool) {
|
||||
if self
|
||||
.desktop_transition
|
||||
.as_ref()
|
||||
.is_some_and(|transition| !transition.publishing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
@@ -416,6 +427,9 @@ impl Ferese {
|
||||
|
||||
self.sync_window_stacking();
|
||||
self.retarget_overview();
|
||||
if !finalize {
|
||||
return;
|
||||
}
|
||||
self.send_shell_snapshots();
|
||||
|
||||
if let Some(outputs) = redraw_outputs {
|
||||
|
||||
+61
-150
@@ -26,104 +26,82 @@ impl Ferese {
|
||||
}
|
||||
|
||||
pub fn register_output(&mut self, output: &Output, identity: String) {
|
||||
if self.output_ids.is_empty() {
|
||||
self.reset_animation_clock();
|
||||
}
|
||||
|
||||
if self.session_lock.active() {
|
||||
self.session_lock.output_added(output);
|
||||
self.lock_input_activity();
|
||||
}
|
||||
assert!(
|
||||
self.desktop_transition.is_none(),
|
||||
"backend must publish its complete inventory"
|
||||
);
|
||||
let Some(geometry) = self.space.output_geometry(output) else {
|
||||
tracing::error!(output = %output.name(), "cannot register an unmapped output");
|
||||
return;
|
||||
};
|
||||
let output_id = self.ensure_output_identity(&identity);
|
||||
let fallback_workspace = self
|
||||
.workspaces
|
||||
let mut runtime = self.current_desktop_outputs();
|
||||
runtime.retain(|entry| entry.output != *output);
|
||||
runtime.push(DesktopOutput {
|
||||
output: output.clone(),
|
||||
identity,
|
||||
mode: output.current_mode().expect("mapped output has a mode"),
|
||||
transform: output.current_transform(),
|
||||
scale: output.current_scale(),
|
||||
position: geometry.loc,
|
||||
});
|
||||
self.begin_desktop_transition();
|
||||
let changes = self
|
||||
.publish_desktop(runtime)
|
||||
.expect("valid registered output inventory");
|
||||
self.finish_desktop_transition(changes);
|
||||
}
|
||||
|
||||
pub(crate) fn current_desktop_outputs(&self) -> Vec<DesktopOutput> {
|
||||
self.output_ids
|
||||
.iter()
|
||||
.filter(|workspace| {
|
||||
self.output_workspaces.output_for_workspace(workspace.id).is_none()
|
||||
&& self
|
||||
.output_workspaces
|
||||
.home_output(workspace.id)
|
||||
.is_none_or(|home| home == output_id)
|
||||
.filter_map(|(output, id)| {
|
||||
Some(DesktopOutput {
|
||||
output: output.clone(),
|
||||
identity: self
|
||||
.output_identity_ids
|
||||
.iter()
|
||||
.find(|(_, candidate)| *candidate == id)?
|
||||
.0
|
||||
.clone(),
|
||||
mode: output.current_mode()?,
|
||||
transform: output.current_transform(),
|
||||
scale: output.current_scale(),
|
||||
position: self.space.output_geometry(output)?.loc,
|
||||
})
|
||||
.map(|workspace| workspace.id)
|
||||
.min_by_key(|id| id.0)
|
||||
.unwrap_or_else(|| self.workspaces.create_workspace());
|
||||
let geometry = OutputGeometry::new(geometry.loc.x, geometry.loc.y, geometry.size.w, geometry.size.h);
|
||||
|
||||
let returning_floats = self
|
||||
.windows
|
||||
.ids()
|
||||
.values()
|
||||
.filter_map(|id| {
|
||||
let workspace = self.workspaces.workspace_for_window(*id)?;
|
||||
if self.output_workspaces.home_output(workspace) != Some(output_id) {
|
||||
return None;
|
||||
}
|
||||
let owner = self.output_workspaces.output_for_workspace(workspace)?;
|
||||
let old = self.output_workspaces.geometry(owner)?;
|
||||
let WindowPlacement::Floating { rect } = self.workspaces.placement(*id)? else {
|
||||
return None;
|
||||
};
|
||||
Some((*id, workspace, rect, old))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
.collect()
|
||||
}
|
||||
|
||||
let registration = self.output_workspaces.connect(output_id, geometry, fallback_workspace);
|
||||
|
||||
match registration {
|
||||
Ok(_) => {
|
||||
self.output_ids.insert(output.clone(), output_id);
|
||||
self.outputs_by_id.insert(output_id, output.clone());
|
||||
self.output_names.insert(output_id, output.name());
|
||||
for (id, workspace, rect, old) in returning_floats {
|
||||
if self.output_workspaces.output_for_workspace(workspace) == Some(output_id) {
|
||||
let old = Rect::new(old.x as f64, old.y as f64, old.width as f64, old.height as f64);
|
||||
let new = Rect::new(
|
||||
geometry.x as f64,
|
||||
geometry.y as f64,
|
||||
geometry.width as f64,
|
||||
geometry.height as f64,
|
||||
);
|
||||
let _ = self
|
||||
.workspaces
|
||||
.set_floating_rect(id, moved_floating_rect(rect, old, new));
|
||||
/// Resource invalidation is immediate; ownership and focus wait for the
|
||||
/// complete usable inventory, including when the final output disappears.
|
||||
pub fn unregister_output(&mut self, output: &Output) {
|
||||
let standalone = self.desktop_transition.is_none();
|
||||
if standalone {
|
||||
self.begin_desktop_transition();
|
||||
}
|
||||
self.retire_output_resources(output);
|
||||
if standalone {
|
||||
let runtime = self
|
||||
.current_desktop_outputs()
|
||||
.into_iter()
|
||||
.filter(|entry| entry.output != *output)
|
||||
.collect();
|
||||
let changes = self.publish_desktop(runtime).expect("valid surviving output inventory");
|
||||
self.finish_desktop_transition(changes);
|
||||
}
|
||||
}
|
||||
if self
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
.is_some_and(|backend| backend.reconciling())
|
||||
|
||||
pub(super) fn retire_output_resources(&mut self, output: &Output) {
|
||||
if let Some(transition) = self.desktop_transition.as_mut()
|
||||
&& !transition.retired.insert(output.clone())
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.restore_output_focus();
|
||||
let visible = self.visible_workspace_ids();
|
||||
self.reconcile_workspaces(&visible);
|
||||
self.send_shell_snapshots();
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::error!(%error, output = %output.name(), "failed to register output");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unregister_output(&mut self, output: &Output) {
|
||||
self.display_presentation.remove_output(output);
|
||||
self.refresh_idle_inhibition();
|
||||
|
||||
let Some(output_id) = self.output_ids.remove(output) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.output_redraw_pending.retain(|pending| pending != output);
|
||||
self.outputs_by_id.remove(&output_id);
|
||||
self.output_names.remove(&output_id);
|
||||
self.workspace_slides.remove(&output_id);
|
||||
self.render.remove_output(output_id);
|
||||
if let Some(id) = self.output_ids.get(output) {
|
||||
self.render.remove_output(*id);
|
||||
}
|
||||
self.pending_screencopies.retain(|capture| {
|
||||
if capture.output == *output {
|
||||
capture.fail();
|
||||
@@ -132,76 +110,9 @@ impl Ferese {
|
||||
true
|
||||
}
|
||||
});
|
||||
self.space.unmap_output(output);
|
||||
self.session_lock.surfaces.remove(output);
|
||||
self.session_lock.backgrounds.remove(output);
|
||||
self.session_lock.output_removed(output);
|
||||
self.refresh_lock_outputs();
|
||||
|
||||
let old_geometry = self.output_workspaces.geometry(output_id);
|
||||
let evacuated = self
|
||||
.output_workspaces
|
||||
.assigned_workspaces(output_id)
|
||||
.collect::<Vec<_>>();
|
||||
match self.output_workspaces.disconnect(output_id) {
|
||||
Ok(Some(target)) => {
|
||||
if let (Some(old), Some(new)) = (old_geometry, self.output_workspaces.geometry(target)) {
|
||||
let old = Rect::new(old.x as f64, old.y as f64, old.width as f64, old.height as f64);
|
||||
let new = Rect::new(new.x as f64, new.y as f64, new.width as f64, new.height as f64);
|
||||
let floats = self
|
||||
.windows
|
||||
.ids()
|
||||
.values()
|
||||
.filter_map(|id| {
|
||||
let workspace = self.workspaces.workspace_for_window(*id)?;
|
||||
if !evacuated.contains(&workspace) {
|
||||
return None;
|
||||
}
|
||||
match self.workspaces.placement(*id)? {
|
||||
WindowPlacement::Floating { rect } => Some((*id, moved_floating_rect(rect, old, new))),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for (id, rect) in floats {
|
||||
let _ = self.workspaces.set_floating_rect(id, rect);
|
||||
}
|
||||
}
|
||||
if let Some(workspace) = self.output_workspaces.active_workspace(target) {
|
||||
self.activate_output_workspace(target, workspace);
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
self.focused_window = None;
|
||||
self.restore_keyboard_focus();
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::error!(%error, output = %output.name(), "failed to unregister output")
|
||||
}
|
||||
}
|
||||
|
||||
self.relayout();
|
||||
}
|
||||
|
||||
pub(crate) fn reposition_output_floats(&mut self, output: OutputId, old: Rect, new: Rect) {
|
||||
let floats = self
|
||||
.windows
|
||||
.ids()
|
||||
.values()
|
||||
.filter_map(|id| {
|
||||
let workspace = self.workspaces.workspace_for_window(*id)?;
|
||||
if self.output_workspaces.output_for_workspace(workspace) != Some(output) {
|
||||
return None;
|
||||
}
|
||||
match self.workspaces.placement(*id)? {
|
||||
WindowPlacement::Floating { rect } => Some((*id, moved_floating_rect(rect, old, new))),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for (id, rect) in floats {
|
||||
let _ = self.workspaces.set_floating_rect(id, rect);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn activate_output_workspace(&mut self, output: OutputId, workspace: ferese_core::WorkspaceId) {
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
//! Exceptional topology publication. Ordinary input/damage never constructs a plan.
|
||||
use super::*;
|
||||
use smithay::output::{Mode, Scale};
|
||||
use smithay::utils::Transform;
|
||||
|
||||
/// Mutable resource bookkeeping lives outside the pure ownership plan.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct DesktopTransition {
|
||||
pub retired: HashSet<Output>,
|
||||
pub redraw: HashSet<Output>,
|
||||
pub publishing: bool,
|
||||
pub shell_pending: bool,
|
||||
}
|
||||
|
||||
/// The backend reports hardware that actually survived application/rollback.
|
||||
pub(crate) struct DesktopOutput {
|
||||
pub output: Output,
|
||||
pub identity: String,
|
||||
pub mode: Mode,
|
||||
pub transform: Transform,
|
||||
pub scale: Scale,
|
||||
pub position: Point<i32, Logical>,
|
||||
}
|
||||
|
||||
impl DesktopOutput {
|
||||
fn geometry(&self) -> OutputGeometry {
|
||||
let size = self
|
||||
.transform
|
||||
.transform_size(self.mode.size)
|
||||
.to_f64()
|
||||
.to_logical(self.scale.fractional_scale())
|
||||
.to_i32_ceil();
|
||||
OutputGeometry::new(self.position.x, self.position.y, size.w, size.h)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct DesktopChanges {
|
||||
pub layout: bool,
|
||||
pub focus: bool,
|
||||
pub shell: bool,
|
||||
pub outputs: HashSet<Output>,
|
||||
}
|
||||
|
||||
impl Ferese {
|
||||
pub(crate) fn begin_desktop_transition(&mut self) {
|
||||
assert!(self.desktop_transition.is_none(), "nested desktop transition");
|
||||
self.desktop_transition = Some(DesktopTransition::default());
|
||||
}
|
||||
|
||||
/// No event-loop dispatch occurs between planning and publication. Only the
|
||||
/// small ownership map is staged; layout and animation remain authoritative.
|
||||
pub(crate) fn publish_desktop(
|
||||
&mut self,
|
||||
runtime: Vec<DesktopOutput>,
|
||||
) -> Result<DesktopChanges, ferese_core::OutputError> {
|
||||
assert!(self.desktop_transition.is_some());
|
||||
let inventory = runtime
|
||||
.iter()
|
||||
.map(|output| (self.ensure_output_identity(&output.identity), output.geometry()))
|
||||
.collect::<Vec<_>>();
|
||||
let workspaces = self.workspaces.iter().map(|workspace| workspace.id).collect::<Vec<_>>();
|
||||
let plan = self
|
||||
.output_workspaces
|
||||
.plan_desktop(&inventory, &workspaces, self.workspaces.next_workspace_id())?;
|
||||
let mut changes = DesktopChanges {
|
||||
layout: !plan.affected_outputs.is_empty(),
|
||||
focus: plan.focus_changed,
|
||||
shell: !plan.affected_outputs.is_empty(),
|
||||
outputs: self.desktop_transition.as_ref().unwrap().redraw.clone(),
|
||||
};
|
||||
let floats = self
|
||||
.windows
|
||||
.ids()
|
||||
.values()
|
||||
.filter_map(|id| {
|
||||
let workspace = self.workspaces.workspace_for_window(*id)?;
|
||||
let WindowPlacement::Floating { rect } = self.workspaces.placement(*id)? else {
|
||||
return None;
|
||||
};
|
||||
let destination = plan.outputs.output_for_workspace(workspace)?;
|
||||
let new = plan.outputs.geometry(destination)?;
|
||||
let old_owner = self.output_workspaces.output_for_workspace(workspace);
|
||||
let old = old_owner.and_then(|owner| self.output_workspaces.geometry(owner));
|
||||
(old_owner != Some(destination) || old != Some(new)).then(|| {
|
||||
let bounds = |g: OutputGeometry| Rect::new(g.x as f64, g.y as f64, g.width as f64, g.height as f64);
|
||||
(*id, moved_floating_rect(rect, bounds(old.unwrap_or(new)), bounds(new)))
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// All fallible planning precedes publication. Resource retirement may
|
||||
// already have invalidated unusable scanouts, but not desktop policy.
|
||||
for id in plan.created_workspaces {
|
||||
assert_eq!(self.workspaces.create_workspace(), id);
|
||||
}
|
||||
let surviving = runtime.iter().map(|output| output.output.clone()).collect::<Vec<_>>();
|
||||
let old_outputs = self.output_ids.keys().cloned().collect::<Vec<_>>();
|
||||
for output in old_outputs {
|
||||
if !surviving.contains(&output) {
|
||||
self.retire_output_resources(&output);
|
||||
self.space.unmap_output(&output);
|
||||
}
|
||||
}
|
||||
if self.output_ids.is_empty() && !runtime.is_empty() {
|
||||
self.reset_animation_clock();
|
||||
}
|
||||
self.output_ids.clear();
|
||||
self.outputs_by_id.clear();
|
||||
self.output_names.clear();
|
||||
for (output, (id, geometry)) in runtime.into_iter().zip(inventory) {
|
||||
let state_changed = output.output.current_mode() != Some(output.mode)
|
||||
|| output.output.current_transform() != output.transform
|
||||
|| output.output.current_scale().fractional_scale() != output.scale.fractional_scale()
|
||||
|| self.space.output_geometry(&output.output).is_none_or(|old| {
|
||||
old.loc != output.position || old.size.w != geometry.width || old.size.h != geometry.height
|
||||
});
|
||||
if state_changed {
|
||||
output.output.change_current_state(
|
||||
Some(output.mode),
|
||||
Some(output.transform),
|
||||
Some(output.scale),
|
||||
Some(output.position),
|
||||
);
|
||||
self.space.map_output(&output.output, output.position);
|
||||
changes.layout = true;
|
||||
changes.shell = true;
|
||||
}
|
||||
if state_changed || plan.affected_outputs.contains(&id) {
|
||||
changes.outputs.insert(output.output.clone());
|
||||
}
|
||||
if state_changed && self.session_lock.active() {
|
||||
self.session_lock.output_added(&output.output);
|
||||
}
|
||||
self.output_names.insert(id, output.output.name());
|
||||
self.outputs_by_id.insert(id, output.output.clone());
|
||||
self.output_ids.insert(output.output, id);
|
||||
}
|
||||
// Slides referencing evacuated workspaces cannot survive ownership
|
||||
// changes. Unaffected slide/world/viewport/resize owners remain intact.
|
||||
self.workspace_slides.retain(|id, slide| {
|
||||
plan.outputs.geometry(*id).is_some()
|
||||
&& slide
|
||||
.items
|
||||
.iter()
|
||||
.all(|item| plan.outputs.output_for_workspace(item.workspace) == Some(*id))
|
||||
});
|
||||
self.output_workspaces = plan.outputs;
|
||||
for (id, rect) in floats {
|
||||
self.workspaces
|
||||
.set_floating_rect(id, rect)
|
||||
.expect("planned floating window exists");
|
||||
}
|
||||
self.refresh_workspace_slide_offsets();
|
||||
if changes.focus {
|
||||
if let Some(output) = self.output_workspaces.focused_output()
|
||||
&& let Some(workspace) = self.output_workspaces.active_workspace(output)
|
||||
{
|
||||
self.activate_output_workspace(output, workspace);
|
||||
} else {
|
||||
self.focused_window = None;
|
||||
}
|
||||
}
|
||||
Ok(changes)
|
||||
}
|
||||
|
||||
pub(crate) fn finish_desktop_transition(&mut self, mut changes: DesktopChanges) {
|
||||
self.desktop_transition.as_mut().unwrap().publishing = true;
|
||||
if changes.layout {
|
||||
self.relayout_desktop();
|
||||
}
|
||||
if changes.focus || self.session_lock.active() {
|
||||
self.restore_keyboard_focus();
|
||||
}
|
||||
self.refresh_lock_outputs();
|
||||
self.refresh_idle_inhibition();
|
||||
let batch = self.desktop_transition.take().unwrap();
|
||||
changes.outputs.extend(batch.redraw);
|
||||
// Animation/layout helpers may request snapshots, but only this point
|
||||
// can emit the complete topology generation.
|
||||
if changes.shell || changes.layout || changes.focus || batch.shell_pending {
|
||||
self.send_shell_snapshots();
|
||||
}
|
||||
changes.outputs.retain(|output| {
|
||||
self.output_ids.contains_key(output)
|
||||
|| self
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
.is_some_and(|backend| backend.physical_outputs().any(|physical| physical == output))
|
||||
});
|
||||
if !changes.outputs.is_empty() {
|
||||
crate::backends::direct::render_on(self, &changes.outputs.into_iter().collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use smithay::output::{PhysicalProperties, Subpixel};
|
||||
use smithay::reexports::wayland_server::Resource;
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
fn output(name: &str, x: i32, width: i32) -> DesktopOutput {
|
||||
DesktopOutput {
|
||||
output: Output::new(
|
||||
name.into(),
|
||||
PhysicalProperties {
|
||||
size: (0, 0).into(),
|
||||
subpixel: Subpixel::Unknown,
|
||||
make: "test".into(),
|
||||
model: "test".into(),
|
||||
},
|
||||
),
|
||||
identity: name.into(),
|
||||
mode: Mode {
|
||||
size: (width, 800).into(),
|
||||
refresh: 60_000,
|
||||
},
|
||||
transform: Transform::Normal,
|
||||
scale: Scale::Fractional(1.0),
|
||||
position: (x, 0).into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn request(wire: &mut UnixStream, object: u32, opcode: u32, args: &[u32]) {
|
||||
for word in [object, (((args.len() + 2) * 4) as u32) << 16 | opcode]
|
||||
.into_iter()
|
||||
.chain(args.iter().copied())
|
||||
{
|
||||
wire.write_all(&word.to_ne_bytes()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn window(state: &mut Ferese, event_loop: &mut EventLoop<'static, Ferese>) -> (Window, UnixStream) {
|
||||
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_wm_base::XdgWmBase;
|
||||
use smithay::reexports::wayland_server::protocol::wl_compositor::WlCompositor;
|
||||
use smithay::wayland::shell::xdg::XdgWmBaseUserData;
|
||||
let (server, mut wire) = UnixStream::pair().unwrap();
|
||||
let client = state
|
||||
.display_handle
|
||||
.insert_client(server, Arc::new(ClientState::default()))
|
||||
.unwrap();
|
||||
let compositor = client
|
||||
.create_resource::<WlCompositor, (), Ferese>(&state.display_handle, 6, ())
|
||||
.unwrap();
|
||||
let shell = client
|
||||
.create_resource::<XdgWmBase, XdgWmBaseUserData, Ferese>(&state.display_handle, 6, Default::default())
|
||||
.unwrap();
|
||||
request(&mut wire, compositor.id().protocol_id(), 0, &[2]);
|
||||
request(&mut wire, shell.id().protocol_id(), 2, &[3, 2]);
|
||||
request(&mut wire, 3, 1, &[4]);
|
||||
event_loop.dispatch(Duration::from_millis(10), state).unwrap();
|
||||
let window = state
|
||||
.space
|
||||
.elements()
|
||||
.find(|window| {
|
||||
window
|
||||
.toplevel()
|
||||
.unwrap()
|
||||
.wl_surface()
|
||||
.client()
|
||||
.is_some_and(|owner| owner.id() == client.id())
|
||||
})
|
||||
.unwrap()
|
||||
.clone();
|
||||
(window, wire)
|
||||
}
|
||||
|
||||
fn events(state: &mut Ferese, wire: &mut UnixStream) -> Vec<(u32, u32, Vec<u32>)> {
|
||||
state.display_handle.flush_clients().unwrap();
|
||||
wire.set_nonblocking(true).unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
match wire.read_to_end(&mut bytes) {
|
||||
Ok(_) => {}
|
||||
Err(error) => assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock),
|
||||
}
|
||||
let mut result = Vec::new();
|
||||
let mut offset = 0;
|
||||
while offset < bytes.len() {
|
||||
let word = |at| u32::from_ne_bytes(bytes[at..at + 4].try_into().unwrap());
|
||||
let header = word(offset + 4);
|
||||
let end = offset + (header >> 16) as usize;
|
||||
result.push((
|
||||
word(offset),
|
||||
header & 0xffff,
|
||||
(offset + 8..end).step_by(4).map(word).collect(),
|
||||
));
|
||||
offset = end;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planned_geometry_matches_smithay_at_fractional_scale_and_rotation() {
|
||||
for transform in [Transform::Normal, Transform::_90, Transform::Flipped270] {
|
||||
let mut entry = output("geometry", -500, 1920);
|
||||
entry.mode.size.h = 1080;
|
||||
entry.scale = Scale::Fractional(1.75);
|
||||
entry.transform = transform;
|
||||
entry.output.change_current_state(
|
||||
Some(entry.mode),
|
||||
Some(transform),
|
||||
Some(entry.scale),
|
||||
Some(entry.position),
|
||||
);
|
||||
let mut space = Space::<Window>::default();
|
||||
space.map_output(&entry.output, entry.position);
|
||||
let actual = space.output_geometry(&entry.output).unwrap();
|
||||
assert_eq!(
|
||||
entry.geometry(),
|
||||
OutputGeometry::new(actual.loc.x, actual.loc.y, actual.size.w, actual.size.h)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topology_publication_protocol_and_readiness_regressions() {
|
||||
// The subprocess owns private sockets and config; never touches a live session.
|
||||
if std::env::var_os("FERESE_DESKTOP_TEST_CHILD").is_none() {
|
||||
let runtime = tempfile::tempdir().unwrap();
|
||||
let result = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"state::reconciliation::tests::topology_publication_protocol_and_readiness_regressions",
|
||||
"--nocapture",
|
||||
])
|
||||
.env("FERESE_DESKTOP_TEST_CHILD", "1")
|
||||
.env("XDG_RUNTIME_DIR", runtime.path())
|
||||
.env("XDG_CONFIG_HOME", runtime.path())
|
||||
.env("XDG_STATE_HOME", runtime.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&result.stdout),
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
return;
|
||||
}
|
||||
let mut event_loop = EventLoop::try_new().unwrap();
|
||||
let config = crate::config::Config::default().runtime_config().unwrap();
|
||||
let mut state = Ferese::new(&mut event_loop, Display::new().unwrap(), config).unwrap();
|
||||
let a = output("a", 0, 1200);
|
||||
let b = output("b", 1200, 800);
|
||||
let a_handle = a.output.clone();
|
||||
let b_handle = b.output.clone();
|
||||
state.begin_desktop_transition();
|
||||
let changes = state.publish_desktop(vec![a, b]).unwrap();
|
||||
state.finish_desktop_transition(changes);
|
||||
let a_id = state.output_ids[&a_handle];
|
||||
let b_id = state.output_ids[&b_handle];
|
||||
let b_workspace = state.output_workspaces.active_workspace(b_id).unwrap();
|
||||
|
||||
// Real protocol resources let us observe exactly what clients receive.
|
||||
let (server, mut shell_wire) = UnixStream::pair().unwrap();
|
||||
let client = state
|
||||
.display_handle
|
||||
.insert_client(server, Arc::new(ClientState::default()))
|
||||
.unwrap();
|
||||
let shell = client
|
||||
.create_resource::<FereseShellV1, (), Ferese>(&state.display_handle, 6, ())
|
||||
.unwrap();
|
||||
state.shell_resources.push(shell.downgrade());
|
||||
state.send_shell_snapshots();
|
||||
events(&mut state, &mut shell_wire);
|
||||
let original_serial = state.shell_snapshot_serial;
|
||||
|
||||
// Invalid planning leaves output metadata, ownership and shell unchanged.
|
||||
let original = state.output_workspaces.clone();
|
||||
state.begin_desktop_transition();
|
||||
let mut invalid = state.current_desktop_outputs();
|
||||
invalid[0].mode.size.w = 0;
|
||||
assert!(state.publish_desktop(invalid).is_err());
|
||||
assert_eq!(state.output_workspaces, original);
|
||||
assert_eq!(a_handle.current_mode().unwrap().size.w, 1200);
|
||||
assert!(events(&mut state, &mut shell_wire).is_empty());
|
||||
state.finish_desktop_transition(DesktopChanges::default());
|
||||
assert_eq!(state.shell_snapshot_serial, original_serial);
|
||||
|
||||
// A real XDG toplevel needs a new size; it deliberately never acknowledges it.
|
||||
let (slow, _slow_wire) = window(&mut state, &mut event_loop);
|
||||
let slow_id = WindowId(101);
|
||||
let initial = Rect::new(1500.0, 500.0, 600.0, 600.0);
|
||||
state
|
||||
.workspaces
|
||||
.insert_floating_window(slow_id, b_workspace, initial, true)
|
||||
.unwrap();
|
||||
state.windows.register_window(slow.clone(), slow_id);
|
||||
let mut geometry = WindowGeometry::new(initial, None);
|
||||
geometry.client.committed_size = Some(ClientSize::from_rect(initial));
|
||||
state.windows.set_geometry(slow_id, geometry);
|
||||
state.output_workspaces.focus_output(b_id).unwrap();
|
||||
state.restore_output_focus();
|
||||
state.relayout();
|
||||
events(&mut state, &mut shell_wire);
|
||||
let before_serial = state.shell_snapshot_serial;
|
||||
let spare = state.workspaces.create_workspace();
|
||||
state.output_workspaces.assign_workspace(b_id, spare).unwrap();
|
||||
state.workspace_slides.insert(
|
||||
b_id,
|
||||
WorkspaceSlide::new(None, spare, b_workspace, SwipeDirection::Right),
|
||||
);
|
||||
|
||||
state.begin_desktop_transition();
|
||||
state.unregister_output(&b_handle);
|
||||
state.unregister_output(&b_handle); // Duplicate retirement is harmless.
|
||||
assert_eq!(
|
||||
state.workspaces.placement(slow_id),
|
||||
Some(WindowPlacement::Floating { rect: initial })
|
||||
);
|
||||
assert_eq!(state.output_workspaces.output_for_workspace(b_workspace), Some(b_id));
|
||||
state.send_shell_snapshots();
|
||||
assert!(events(&mut state, &mut shell_wire).is_empty());
|
||||
let mut runtime = state.current_desktop_outputs();
|
||||
runtime.retain(|entry| entry.output == a_handle);
|
||||
runtime[0].mode.size = (500, 400).into();
|
||||
let changes = state.publish_desktop(runtime).unwrap();
|
||||
assert_eq!(state.output_workspaces.output_for_workspace(b_workspace), Some(a_id));
|
||||
assert_eq!(state.focused_window, Some(slow_id));
|
||||
assert!(!state.workspace_slides.contains_key(&b_id));
|
||||
state.send_shell_snapshots();
|
||||
assert!(events(&mut state, &mut shell_wire).is_empty());
|
||||
state.finish_desktop_transition(changes);
|
||||
assert_eq!(state.shell_snapshot_serial, before_serial + 1);
|
||||
let published = events(&mut state, &mut shell_wire);
|
||||
let shell_events = published
|
||||
.iter()
|
||||
.filter(|(id, _, _)| *id == shell.id().protocol_id())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(shell_events.iter().filter(|(_, opcode, _)| *opcode == 0).count(), 1);
|
||||
assert_eq!(shell_events.iter().filter(|(_, opcode, _)| *opcode == 4).count(), 1);
|
||||
assert_eq!(shell_events.iter().filter(|(_, opcode, _)| *opcode == 2).count(), 1);
|
||||
for (_, opcode, args) in &shell_events {
|
||||
if *opcode == 3 {
|
||||
assert_eq!(args[3], a_id.0 as u32);
|
||||
if args[1] == b_workspace.0 as u32 {
|
||||
assert_eq!(&args[args.len() - 2..], &[1, 1]);
|
||||
}
|
||||
} else if *opcode == 2 {
|
||||
let active = 3 + args[2].div_ceil(4) as usize;
|
||||
assert_eq!(args[active + 1], b_workspace.0 as u32);
|
||||
assert_eq!(args[active + 2], 1);
|
||||
} else if *opcode == 1 {
|
||||
assert_eq!(args[1], slow_id.0 as u32);
|
||||
assert_eq!(args[3], b_workspace.0 as u32);
|
||||
}
|
||||
}
|
||||
let WindowPlacement::Floating { rect } = state.workspaces.placement(slow_id).unwrap() else {
|
||||
panic!()
|
||||
};
|
||||
assert!(rect.x >= 0.0 && rect.y >= 0.0 && rect.x + rect.width <= 500.0 && rect.y + rect.height <= 400.0);
|
||||
assert!(
|
||||
state.windows.transaction(&slow_id).is_some(),
|
||||
"topology commit must not await XDG acknowledgement"
|
||||
);
|
||||
assert_eq!(
|
||||
state.windows.geometry(&slow_id).unwrap().client.committed_size,
|
||||
Some(ClientSize::from_rect(initial))
|
||||
);
|
||||
|
||||
// A separate workspace/output keeps progressing while the slow client waits.
|
||||
state.begin_desktop_transition();
|
||||
let mut runtime = state.current_desktop_outputs();
|
||||
runtime.push(output("c", 500, 800));
|
||||
let changes = state.publish_desktop(runtime).unwrap();
|
||||
state.finish_desktop_transition(changes);
|
||||
let c_id = state.persistent_output_id("c").unwrap();
|
||||
let c_workspace = state.output_workspaces.active_workspace(c_id).unwrap();
|
||||
let mut viewport = AnimatedValue::new(0.0);
|
||||
viewport.set_target(200.0);
|
||||
state.viewport_animations.insert(c_workspace, viewport);
|
||||
let now = state.start_time.elapsed();
|
||||
state.advance_animations_at(Duration::from_millis(16), now + Duration::from_millis(16));
|
||||
assert!(state.viewport_animations[&c_workspace].current > 0.0);
|
||||
assert!(state.windows.transaction(&slow_id).is_some());
|
||||
state.advance_animations_at(Duration::from_millis(334), now + Duration::from_millis(350));
|
||||
assert!(state.windows.transaction(&slow_id).is_none());
|
||||
|
||||
// A no-op/resume inventory keeps ownership and emits no duplicate generation.
|
||||
events(&mut state, &mut shell_wire);
|
||||
let before = state.output_workspaces.clone();
|
||||
for _ in 0..3 {
|
||||
state.begin_desktop_transition();
|
||||
let runtime = state.current_desktop_outputs();
|
||||
let changes = state.publish_desktop(runtime).unwrap();
|
||||
assert!(!changes.layout && !changes.focus && !changes.shell);
|
||||
assert!(changes.outputs.is_empty());
|
||||
state.finish_desktop_transition(changes);
|
||||
}
|
||||
assert_eq!(state.output_workspaces, before);
|
||||
assert!(events(&mut state, &mut shell_wire).is_empty());
|
||||
assert_eq!(state.workspaces.workspace_for_window(slow_id), Some(b_workspace));
|
||||
|
||||
let unfocused = state.outputs_by_id[&c_id].clone();
|
||||
let focused = state.focused_window;
|
||||
state.unregister_output(&unfocused);
|
||||
assert_eq!(
|
||||
state.focused_window, focused,
|
||||
"removing a non-focused output preserves window focus"
|
||||
);
|
||||
|
||||
// Lock protection remains authoritative through headless loss/recovery.
|
||||
use smithay::reexports::wayland_protocols::ext::session_lock::v1::server::ext_session_lock_manager_v1::ExtSessionLockManagerV1;
|
||||
let (server, mut lock_wire) = UnixStream::pair().unwrap();
|
||||
let client = state
|
||||
.display_handle
|
||||
.insert_client(server, Arc::new(ClientState::default()))
|
||||
.unwrap();
|
||||
let lock = client
|
||||
.create_resource::<ExtSessionLockManagerV1, (), Ferese>(&state.display_handle, 1, ())
|
||||
.unwrap();
|
||||
request(&mut lock_wire, lock.id().protocol_id(), 1, &[2]);
|
||||
event_loop.dispatch(Duration::from_millis(10), &mut state).unwrap();
|
||||
assert!(state.session_lock.active());
|
||||
state.begin_desktop_transition();
|
||||
for output in state.output_ids.keys().cloned().collect::<Vec<_>>() {
|
||||
state.unregister_output(&output);
|
||||
}
|
||||
let changes = state.publish_desktop(Vec::new()).unwrap();
|
||||
state.finish_desktop_transition(changes);
|
||||
assert!(state.output_ids.is_empty());
|
||||
assert!(state.space.outputs().next().is_none());
|
||||
assert!(state.output_workspaces.focused_output().is_none());
|
||||
assert!(state.focused_window.is_none());
|
||||
assert!(state.workspace_slides.is_empty());
|
||||
assert!(state.seat.get_keyboard().unwrap().current_focus().is_none());
|
||||
assert_eq!(state.workspaces.workspace_for_window(slow_id), Some(b_workspace));
|
||||
state.begin_desktop_transition();
|
||||
let changes = state.publish_desktop(vec![output("a", 0, 1200)]).unwrap();
|
||||
state.finish_desktop_transition(changes);
|
||||
assert_eq!(state.output_workspaces.output_for_workspace(b_workspace), Some(a_id));
|
||||
assert!(state.session_lock.active());
|
||||
assert!(state.surface_under((20.0, 20.0).into()).is_none());
|
||||
}
|
||||
}
|
||||
@@ -103,14 +103,13 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
|
||||
.unwrap_or(60_000) as i32;
|
||||
refresh.set(Duration::from_nanos(1_000_000_000_000 / rate.max(1) as u64));
|
||||
|
||||
output.change_current_state(
|
||||
Some(Mode { size, refresh: rate }),
|
||||
None,
|
||||
Some(Scale::Fractional(scale)),
|
||||
None,
|
||||
);
|
||||
|
||||
state.relayout();
|
||||
let mut runtime = state.current_desktop_outputs();
|
||||
let resized = runtime.iter_mut().find(|entry| entry.output == output).unwrap();
|
||||
resized.mode = Mode { size, refresh: rate };
|
||||
resized.scale = Scale::Fractional(scale);
|
||||
state.begin_desktop_transition();
|
||||
let changes = state.publish_desktop(runtime).expect("valid nested output geometry");
|
||||
state.finish_desktop_transition(changes);
|
||||
if let Err(error) = state.display_handle.flush_clients() {
|
||||
tracing::debug!(%error, "failed to flush output-resize configure");
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ mod workspace;
|
||||
mod workspace_policy;
|
||||
|
||||
pub use focus::{FocusCycle, FocusHistory};
|
||||
pub use output::{OutputError, OutputGeometry, OutputId, OutputWorkspaceMap, WorkspaceSwitch};
|
||||
pub use output::{DesktopPlan, OutputError, OutputGeometry, OutputId, OutputWorkspaceMap, WorkspaceSwitch};
|
||||
pub use workspace::{
|
||||
LayoutMode, WindowPlacement, Workspace, WorkspaceError, WorkspaceId, WorkspaceLayout, WorkspaceSet,
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ impl OutputGeometry {
|
||||
pub enum OutputError {
|
||||
AlreadyConnected(OutputId),
|
||||
UnknownOutput(OutputId),
|
||||
InvalidGeometry(OutputId),
|
||||
WorkspaceAlreadyAssigned { workspace: WorkspaceId, output: OutputId },
|
||||
}
|
||||
|
||||
@@ -40,6 +41,7 @@ impl fmt::Display for OutputError {
|
||||
match self {
|
||||
Self::AlreadyConnected(output) => write!(formatter, "output {output:?} is connected"),
|
||||
Self::UnknownOutput(output) => write!(formatter, "unknown output {output:?}"),
|
||||
Self::InvalidGeometry(output) => write!(formatter, "invalid geometry for output {output:?}"),
|
||||
Self::WorkspaceAlreadyAssigned { workspace, output } => {
|
||||
write!(formatter, "workspace {workspace:?} is assigned to {output:?}")
|
||||
}
|
||||
@@ -55,7 +57,7 @@ pub enum WorkspaceSwitch {
|
||||
FocusedExisting(OutputId),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct OutputState {
|
||||
geometry: OutputGeometry,
|
||||
active: WorkspaceId,
|
||||
@@ -72,20 +74,20 @@ impl OutputState {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct EvacuatedWorkspace {
|
||||
workspace: WorkspaceId,
|
||||
target: Option<OutputId>,
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct EvacuationRecord {
|
||||
active: WorkspaceId,
|
||||
workspaces: Vec<EvacuatedWorkspace>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct OutputWorkspaceMap {
|
||||
outputs: BTreeMap<OutputId, OutputState>,
|
||||
assignments: HashMap<WorkspaceId, OutputId>,
|
||||
@@ -96,7 +98,110 @@ pub struct OutputWorkspaceMap {
|
||||
orphaned_focus: Option<WorkspaceId>,
|
||||
}
|
||||
|
||||
/// A staged ownership decision. No windows, renderers or protocol resources are copied.
|
||||
#[derive(Debug)]
|
||||
pub struct DesktopPlan {
|
||||
pub outputs: OutputWorkspaceMap,
|
||||
pub created_workspaces: Vec<WorkspaceId>,
|
||||
pub affected_outputs: BTreeSet<OutputId>,
|
||||
pub focus_changed: bool,
|
||||
}
|
||||
|
||||
impl OutputWorkspaceMap {
|
||||
/// Plan against the final usable inventory, never a per-device intermediate.
|
||||
/// An empty inventory is a valid headless desktop after physical loss.
|
||||
pub fn plan_desktop(
|
||||
&self,
|
||||
usable: &[(OutputId, OutputGeometry)],
|
||||
workspaces: &[WorkspaceId],
|
||||
mut next_workspace: WorkspaceId,
|
||||
) -> Result<DesktopPlan, OutputError> {
|
||||
let mut desired = BTreeMap::new();
|
||||
for &(id, geometry) in usable {
|
||||
if geometry.width <= 0
|
||||
|| geometry.height <= 0
|
||||
|| geometry.x.checked_add(geometry.width).is_none()
|
||||
|| geometry.y.checked_add(geometry.height).is_none()
|
||||
{
|
||||
return Err(OutputError::InvalidGeometry(id));
|
||||
}
|
||||
if desired.insert(id, geometry).is_some() {
|
||||
return Err(OutputError::AlreadyConnected(id));
|
||||
}
|
||||
}
|
||||
let returning = desired
|
||||
.keys()
|
||||
.filter(|id| !self.outputs.contains_key(id))
|
||||
.filter_map(|id| self.evacuations.get(id).map(|record| (*id, record.clone())))
|
||||
.collect::<Vec<_>>();
|
||||
let mut outputs = self.clone();
|
||||
let mut created_workspaces = Vec::new();
|
||||
// New destinations exist before evacuation. Temporary removals must
|
||||
// never become migration destinations, even when several GPUs disappear.
|
||||
for (&id, &geometry) in &desired {
|
||||
if outputs.outputs.contains_key(&id) {
|
||||
outputs.update_geometry(id, geometry);
|
||||
continue;
|
||||
}
|
||||
let fallback = workspaces
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|workspace| {
|
||||
outputs.output_for_workspace(*workspace).is_none()
|
||||
&& outputs.home_output(*workspace).is_none_or(|home| home == id)
|
||||
})
|
||||
.min()
|
||||
.unwrap_or_else(|| {
|
||||
let workspace = next_workspace;
|
||||
next_workspace.0 = next_workspace.0.checked_add(1).expect("workspace IDs exhausted");
|
||||
created_workspaces.push(workspace);
|
||||
workspace
|
||||
});
|
||||
outputs.connect(id, geometry, fallback)?;
|
||||
}
|
||||
let mut removed = self
|
||||
.connected_outputs()
|
||||
.filter(|id| !desired.contains_key(id))
|
||||
.map(|id| (id, outputs.outputs.remove(&id).unwrap()))
|
||||
.collect::<Vec<_>>();
|
||||
// Preserve the original focused workspace when several outputs vanish.
|
||||
removed.sort_by_key(|(id, _)| (Some(*id) == self.focused, *id));
|
||||
for (id, state) in removed {
|
||||
outputs.evacuate(id, state);
|
||||
}
|
||||
// A returning output may initially be unable to reclaim the only
|
||||
// workspace of a donor that is itself retiring. Retry the same policy
|
||||
// against the final inventory, where that donor no longer needs it.
|
||||
for (id, record) in returning {
|
||||
let reclaimed = outputs.reclaim(id, &record);
|
||||
let state = outputs.outputs.get_mut(&id).unwrap();
|
||||
state.workspaces.extend(reclaimed.iter().copied());
|
||||
if reclaimed.contains(&record.active) {
|
||||
state.activate(record.active);
|
||||
}
|
||||
}
|
||||
debug_assert!(outputs.validate());
|
||||
let mut affected_outputs = BTreeSet::new();
|
||||
for id in self.connected_outputs().chain(outputs.connected_outputs()) {
|
||||
if self.outputs.get(&id) != outputs.outputs.get(&id) {
|
||||
affected_outputs.insert(id);
|
||||
}
|
||||
}
|
||||
let focus_changed = self.focused != outputs.focused
|
||||
|| self.focused.and_then(|id| self.active_workspace(id))
|
||||
!= outputs.focused.and_then(|id| outputs.active_workspace(id));
|
||||
if focus_changed {
|
||||
affected_outputs.extend(self.focused);
|
||||
affected_outputs.extend(outputs.focused);
|
||||
}
|
||||
Ok(DesktopPlan {
|
||||
outputs,
|
||||
created_workspaces,
|
||||
affected_outputs,
|
||||
focus_changed,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn assigned_workspaces(&self, output: OutputId) -> impl DoubleEndedIterator<Item = WorkspaceId> + '_ {
|
||||
self.outputs
|
||||
.get(&output)
|
||||
@@ -170,19 +275,8 @@ impl OutputWorkspaceMap {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn connect(
|
||||
&mut self,
|
||||
output: OutputId,
|
||||
geometry: OutputGeometry,
|
||||
fallback_workspace: WorkspaceId,
|
||||
) -> Result<WorkspaceId, OutputError> {
|
||||
if self.outputs.contains_key(&output) {
|
||||
return Err(OutputError::AlreadyConnected(output));
|
||||
}
|
||||
|
||||
let record = self.evacuations.remove(&output);
|
||||
fn reclaim(&mut self, output: OutputId, record: &EvacuationRecord) -> Vec<WorkspaceId> {
|
||||
let mut reclaimed = Vec::new();
|
||||
if let Some(record) = &record {
|
||||
for evacuated in &record.workspaces {
|
||||
let target = self
|
||||
.assignments
|
||||
@@ -193,14 +287,16 @@ impl OutputWorkspaceMap {
|
||||
let revision_matches = self.revision(evacuated.workspace) == evacuated.revision;
|
||||
|
||||
if assignment_matches && revision_matches {
|
||||
if target == Some(output) {
|
||||
reclaimed.push(evacuated.workspace);
|
||||
continue;
|
||||
}
|
||||
// Cleanup may have removed every other workspace while this
|
||||
// monitor was disconnected. Never strand the donor output;
|
||||
// the reconnecting monitor can use its fresh fallback.
|
||||
if target
|
||||
.and_then(|target| self.outputs.get(&target))
|
||||
.is_some_and(|state| {
|
||||
state.workspaces.len() == 1 && state.workspaces.contains(&evacuated.workspace)
|
||||
})
|
||||
.is_some_and(|state| state.workspaces.len() == 1 && state.workspaces.contains(&evacuated.workspace))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -226,8 +322,25 @@ impl OutputWorkspaceMap {
|
||||
reclaimed.push(evacuated.workspace);
|
||||
}
|
||||
}
|
||||
reclaimed
|
||||
}
|
||||
|
||||
pub fn connect(
|
||||
&mut self,
|
||||
output: OutputId,
|
||||
geometry: OutputGeometry,
|
||||
fallback_workspace: WorkspaceId,
|
||||
) -> Result<WorkspaceId, OutputError> {
|
||||
if self.outputs.contains_key(&output) {
|
||||
return Err(OutputError::AlreadyConnected(output));
|
||||
}
|
||||
|
||||
let record = self.evacuations.remove(&output);
|
||||
let mut reclaimed = record
|
||||
.as_ref()
|
||||
.map(|record| self.reclaim(output, record))
|
||||
.unwrap_or_default();
|
||||
|
||||
let active = record
|
||||
.as_ref()
|
||||
.and_then(|record| reclaimed.contains(&record.active).then_some(record.active))
|
||||
@@ -286,6 +399,12 @@ impl OutputWorkspaceMap {
|
||||
|
||||
pub fn disconnect(&mut self, output: OutputId) -> Result<Option<OutputId>, OutputError> {
|
||||
let removed = self.outputs.remove(&output).ok_or(OutputError::UnknownOutput(output))?;
|
||||
let target = self.evacuate(output, removed);
|
||||
debug_assert!(self.validate());
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
fn evacuate(&mut self, output: OutputId, removed: OutputState) -> Option<OutputId> {
|
||||
let target = self.migration_target(output, removed.geometry);
|
||||
let removed_had_focus = self.focused == Some(output);
|
||||
let mut evacuated = removed.workspaces.into_iter().collect::<Vec<_>>();
|
||||
@@ -332,8 +451,7 @@ impl OutputWorkspaceMap {
|
||||
},
|
||||
);
|
||||
|
||||
debug_assert!(self.validate());
|
||||
Ok(target)
|
||||
target
|
||||
}
|
||||
|
||||
pub fn focus_output(&mut self, output: OutputId) -> Result<(), OutputError> {
|
||||
@@ -445,6 +563,9 @@ impl OutputWorkspaceMap {
|
||||
}
|
||||
if donor.active == workspace {
|
||||
donor.active = *donor.workspaces.first().expect("donor retains a workspace");
|
||||
if donor.previous == Some(donor.active) {
|
||||
donor.previous = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.assignments.insert(workspace, output);
|
||||
@@ -516,6 +637,199 @@ impl OutputWorkspaceMap {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn plan(map: &OutputWorkspaceMap, ids: &[u64]) -> DesktopPlan {
|
||||
map.plan_desktop(
|
||||
&ids.iter()
|
||||
.map(|id| (OutputId(*id), geometry(*id as i32 * 1920)))
|
||||
.collect::<Vec<_>>(),
|
||||
&(1..=32).map(WorkspaceId).collect::<Vec<_>>(),
|
||||
WorkspaceId(33),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staged_add_remove_and_noop_leave_the_source_unchanged() {
|
||||
let original = OutputWorkspaceMap::default();
|
||||
let first = plan(&original, &[1]);
|
||||
assert_eq!(original, OutputWorkspaceMap::default());
|
||||
let second = plan(&first.outputs, &[1, 2]);
|
||||
assert_eq!(first.outputs.connected_outputs().count(), 1);
|
||||
assert_eq!(second.outputs.connected_outputs().count(), 2);
|
||||
let unchanged = plan(&second.outputs, &[2, 1]);
|
||||
assert_eq!(unchanged.outputs, second.outputs);
|
||||
assert!(unchanged.affected_outputs.is_empty());
|
||||
assert!(!unchanged.focus_changed);
|
||||
let removed = plan(&second.outputs, &[1]);
|
||||
assert_eq!(removed.outputs.focused_output(), Some(OutputId(1)));
|
||||
assert_eq!(removed.outputs.active_workspace(OutputId(1)), Some(WorkspaceId(1)));
|
||||
assert_eq!(removed.outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simultaneous_removals_migrate_directly_to_final_outputs_and_preserve_focus() {
|
||||
let mut before = plan(&OutputWorkspaceMap::default(), &[1, 2, 3]).outputs;
|
||||
before.focus_output(OutputId(2)).unwrap();
|
||||
let after = plan(&before, &[4]);
|
||||
assert_eq!(after.outputs.focused_output(), Some(OutputId(4)));
|
||||
assert_eq!(after.outputs.active_workspace(OutputId(4)), Some(WorkspaceId(2)));
|
||||
for workspace in 1..=3 {
|
||||
assert_eq!(
|
||||
after.outputs.output_for_workspace(WorkspaceId(workspace)),
|
||||
Some(OutputId(4))
|
||||
);
|
||||
}
|
||||
for evacuation in after.outputs.evacuations.values() {
|
||||
assert!(
|
||||
evacuation
|
||||
.workspaces
|
||||
.iter()
|
||||
.all(|workspace| workspace.target == Some(OutputId(4)))
|
||||
);
|
||||
}
|
||||
assert_eq!(before.connected_outputs().count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staged_reconnect_respects_explicit_reassignment() {
|
||||
let before = plan(&OutputWorkspaceMap::default(), &[1, 2]).outputs;
|
||||
let mut evacuated = plan(&before, &[1]).outputs;
|
||||
let restored = plan(&evacuated, &[1, 2]);
|
||||
assert_eq!(restored.outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(2)));
|
||||
evacuated
|
||||
.reassign_workspace(OutputId(1), WorkspaceId(2), WorkspaceId(9))
|
||||
.unwrap();
|
||||
let reassigned = plan(&evacuated, &[1, 2]);
|
||||
assert_eq!(
|
||||
reassigned.outputs.output_for_workspace(WorkspaceId(2)),
|
||||
Some(OutputId(1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returning_home_reclaims_from_a_donor_retired_in_the_same_plan() {
|
||||
let mut before = plan(&OutputWorkspaceMap::default(), &[1, 2, 3]).outputs;
|
||||
before.focus_output(OutputId(2)).unwrap();
|
||||
before.disconnect(OutputId(1)).unwrap();
|
||||
before.switch_workspace(OutputId(2), WorkspaceId(1)).unwrap();
|
||||
assert!(before.forget_workspace(WorkspaceId(2)));
|
||||
before.focus_output(OutputId(3)).unwrap();
|
||||
let after = plan(&before, &[1, 3]).outputs;
|
||||
assert_eq!(after.output_for_workspace(WorkspaceId(1)), Some(OutputId(1)));
|
||||
assert_eq!(after.active_workspace(OutputId(1)), Some(WorkspaceId(1)));
|
||||
assert_eq!(after.focused_output(), Some(OutputId(3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_inventory_cannot_mutate_the_published_map() {
|
||||
let before = plan(&OutputWorkspaceMap::default(), &[1, 2]).outputs;
|
||||
let saved = before.clone();
|
||||
assert!(
|
||||
before
|
||||
.plan_desktop(
|
||||
&[(OutputId(1), geometry(0)), (OutputId(1), geometry(1))],
|
||||
&[],
|
||||
WorkspaceId(3)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
before
|
||||
.plan_desktop(&[(OutputId(3), OutputGeometry::new(0, 0, 0, 800))], &[], WorkspaceId(3))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(before, saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn physical_loss_can_publish_headless_and_recover_all_workspaces() {
|
||||
let mut before = plan(&OutputWorkspaceMap::default(), &[1, 2]).outputs;
|
||||
before.focus_output(OutputId(2)).unwrap();
|
||||
let headless = plan(&before, &[]).outputs;
|
||||
assert_eq!(headless.focused_output(), None);
|
||||
assert!(headless.assignments.is_empty());
|
||||
let recovered = plan(&headless, &[3]).outputs;
|
||||
assert_eq!(recovered.active_workspace(OutputId(3)), Some(WorkspaceId(2)));
|
||||
for id in [1, 2] {
|
||||
assert_eq!(recovered.output_for_workspace(WorkspaceId(id)), Some(OutputId(3)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_topology_sequence_is_coherent_and_idempotent() {
|
||||
let mut map = OutputWorkspaceMap::default();
|
||||
let mut seed = 0x5eed_u64;
|
||||
for step in 0..512 {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
let ids = (1..=4).filter(|id| seed & (1 << id) != 0).collect::<Vec<_>>();
|
||||
let next = plan(&map, &ids);
|
||||
assert!(next.outputs.validate(), "step {step}");
|
||||
assert_eq!(plan(&next.outputs, &ids).outputs, next.outputs);
|
||||
map = next.outputs;
|
||||
if let Some(&id) = ids.first() {
|
||||
map.focus_output(OutputId(id)).unwrap();
|
||||
let workspace = WorkspaceId(16 + step % 8);
|
||||
map.switch_workspace(OutputId(id), workspace).unwrap();
|
||||
if step % 3 == 0 {
|
||||
map.reassign_workspace(OutputId(id), workspace, WorkspaceId(31))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
assert!(map.validate());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_desktop_sequence_preserves_every_window() {
|
||||
use crate::WorkspaceSet;
|
||||
use ferese_layout::{Axis, WindowId};
|
||||
let mut workspaces = WorkspaceSet::default();
|
||||
let mut outputs = OutputWorkspaceMap::default();
|
||||
let mut seed = 42u64;
|
||||
let mut windows = std::collections::HashSet::new();
|
||||
for step in 1..=256 {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
let inventory = (1..=3)
|
||||
.filter(|id| seed & (1 << id) != 0)
|
||||
.map(|id| (OutputId(id), geometry(id as i32 * 800)))
|
||||
.collect::<Vec<_>>();
|
||||
let ids = workspaces.iter().map(|workspace| workspace.id).collect::<Vec<_>>();
|
||||
let plan = outputs
|
||||
.plan_desktop(&inventory, &ids, workspaces.next_workspace_id())
|
||||
.unwrap();
|
||||
for id in plan.created_workspaces {
|
||||
assert_eq!(workspaces.create_workspace(), id);
|
||||
}
|
||||
outputs = plan.outputs;
|
||||
outputs.reconcile_workspaces(&mut workspaces, &Default::default());
|
||||
let window = WindowId(step);
|
||||
workspaces.insert_window(window, Axis::Horizontal, 0.5).unwrap();
|
||||
windows.insert(window);
|
||||
if let Some((output, _)) = inventory.last() {
|
||||
outputs.focus_output(*output).unwrap();
|
||||
let target = outputs.active_workspace(*output).unwrap();
|
||||
workspaces
|
||||
.move_window_to_workspace(window, target, Axis::Horizontal, 0.5)
|
||||
.unwrap();
|
||||
workspaces.activate(target).unwrap();
|
||||
}
|
||||
assert!(outputs.validate());
|
||||
workspaces.validate().unwrap();
|
||||
let actual = workspaces
|
||||
.iter()
|
||||
.flat_map(|workspace| workspace.layout.window_ids().chain(workspace.floating.iter().copied()))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(actual.len(), windows.len(), "duplicate or lost window at {step}");
|
||||
assert_eq!(actual.into_iter().collect::<std::collections::HashSet<_>>(), windows);
|
||||
for workspace in workspaces.iter().filter(|workspace| !workspace.is_empty()) {
|
||||
assert_eq!(
|
||||
outputs.output_for_workspace(workspace.id).is_some(),
|
||||
!inventory.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnect_cannot_reclaim_the_donor_monitors_only_workspace() {
|
||||
let mut outputs = OutputWorkspaceMap::default();
|
||||
|
||||
@@ -672,6 +672,11 @@ impl WorkspaceSet {
|
||||
.ok_or(WorkspaceError::UnknownWorkspace(id))
|
||||
}
|
||||
|
||||
/// Reserve workspace identities in a pure topology plan without creating them.
|
||||
pub fn next_workspace_id(&self) -> WorkspaceId {
|
||||
WorkspaceId(self.next_id)
|
||||
}
|
||||
|
||||
/// Creates an identity; display positions are assigned by the output owner.
|
||||
pub fn create_workspace(&mut self) -> WorkspaceId {
|
||||
let id = WorkspaceId(self.next_id);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
# Desktop reconciliation and presentation
|
||||
|
||||
During an output topology change, Ferese publishes one logical desktop after
|
||||
the usable hardware inventory is known.
|
||||
The boundary is synchronous within one event-loop dispatch;
|
||||
it does not wait for client commits, animation completion or page flips.
|
||||
|
||||
## Previous flow
|
||||
|
||||
`direct/topology.rs::reconcile_outputs` already centralized hotplug, profile
|
||||
changes, retry and resume. It debounced connector events, validated device-wide
|
||||
KMS requests, applied devices independently and attempted compensation on failure.
|
||||
Its `reconciling` guard suppressed relayout, shell broadcasts and frame scheduling.
|
||||
|
||||
Inside that guard, `apply_device` still changed live Wayland output metadata,
|
||||
registered each output and updated output/workspace geometry. Registration could
|
||||
restore evacuated workspaces and reposition floating windows. Retirement called
|
||||
`unregister_output`, which combined render/capture/lock cleanup with workspace
|
||||
migration, focus changes, floating movement and relayout. Thus later devices or
|
||||
rollback operated on desktop policy already changed by earlier devices.
|
||||
|
||||
The end of reconciliation restored focus, performed global relayout (including
|
||||
client configures, surface preferences, a shell snapshot and redraw), then made
|
||||
another global redraw request. Profile configuration reload could relayout again.
|
||||
|
||||
## Current phases
|
||||
|
||||
1. **Observe and choose desired hardware.** Reconciliation uses the existing
|
||||
monitor identities, profile selection, position policy, hotplug coalescer and
|
||||
lid/resume handling.
|
||||
2. **Validate and apply hardware.** The backend uses device-wide atomic TEST_ONLY
|
||||
validation, Smithay resource staging/commits, per-device rollback, cross-device
|
||||
compensation and last-output recovery.
|
||||
These operations update resource metadata, not live
|
||||
workspace ownership or published output geometry.
|
||||
3. **Plan the publishable desktop.** After all hardware outcomes are known,
|
||||
`publish_runtime_desktop` reports usable logical outputs. Physical mirror
|
||||
scanouts do not acquire workspaces. `OutputWorkspaceMap::plan_desktop` stages a
|
||||
small copy of ownership, geometry, focus and evacuation history. It uses the
|
||||
existing connect/reclaim/evacuate policy and reserves any needed workspace IDs.
|
||||
It rejects duplicate IDs and invalid geometry without changing the source map.
|
||||
4. **Publish logical state.** `Ferese::publish_desktop` computes floating moves
|
||||
from the old geometry directly to the final geometry, then installs output
|
||||
registries, Wayland output metadata, ownership, floating destinations and logical
|
||||
focus. Slides that reference evacuated workspaces are retired. Unaffected
|
||||
viewport, world-coordinate, window-animation and resize state remain intact.
|
||||
5. **Configure and finalize.** `finish_desktop_transition` performs one relayout
|
||||
when needed, restores keyboard/lock focus, refreshes lock/idle policy, releases
|
||||
the shell publication guard and requests redraws for affected outputs.
|
||||
Existing layout
|
||||
remains responsible for tiled destinations, layer work areas, surface-tree
|
||||
scale/transform preferences, XDG configures and animation target ownership.
|
||||
6. **Present asynchronously.** The existing resize barriers, native-content
|
||||
snapshots, animations, frame scheduling and `DisplayPresentation` handle
|
||||
client readiness, motion, frame submission and displayed-content tracking.
|
||||
Logical publication does not assert that new pixels were displayed.
|
||||
|
||||
The desired hardware plan precedes application; the ownership plan is calculated
|
||||
from the result of application and recovery. This avoids computing
|
||||
and then undoing migrations to requested outputs that never became usable.
|
||||
|
||||
## Types and scope
|
||||
|
||||
- `DesktopPlan` is pure data: the staged `OutputWorkspaceMap`, reserved workspace
|
||||
IDs, affected output IDs and focus dirtiness. It copies neither window layouts
|
||||
nor client buffers nor the whole compositor. Window layout is not duplicated.
|
||||
- `DesktopOutput` adapts actual backend resources to that planner. Its Wayland
|
||||
handles, mode, scale and transform stay in the compositor, outside the pure map.
|
||||
- `DesktopTransition` holds short-lived resource retirement/redraw bookkeeping and
|
||||
guards shell publication while the synchronous transition is in progress.
|
||||
- `DesktopChanges` distinguishes layout, focus, shell and redraw consequences for
|
||||
the finalizer. Resume can request fresh frames without forcing a topology change.
|
||||
|
||||
Registration/removal outside DRM and nested output resizing use the same logical
|
||||
publication path. Profile reload carries accompanying scene dirtiness into its
|
||||
finalization rather than immediately relayouting the desktop a second time.
|
||||
Pointer movement, client damage, ordinary focus, title changes, cursor updates and
|
||||
frame callbacks do not construct or run a desktop plan.
|
||||
|
||||
## Failure, retirement and security
|
||||
|
||||
A rejected hardware configuration leaves the desktop unchanged if the previous
|
||||
hardware remains usable. After physical loss or failed restoration,
|
||||
unusable scanouts are retired immediately, and the final plan excludes them.
|
||||
There is no atomicity guarantee across independent DRM devices.
|
||||
|
||||
Low-level retirement still disables globals, cancels frame timers and removes DRM
|
||||
presentation clocks. `retire_output_resources` clears records of displayed client
|
||||
content, output render caches, pending screencopies and lock resources.
|
||||
It does **not**
|
||||
migrate workspaces, change logical focus or relayout. Repeated retirement within
|
||||
the same boundary is harmless. Publication removes the output from Space and the
|
||||
logical registries once the final inventory is available.
|
||||
|
||||
If every physical output disappears, a headless desktop is valid: windows and
|
||||
workspace restoration history survive, while output ownership and keyboard focus
|
||||
are cleared. A requested all-disabled configuration still follows the existing
|
||||
validation/recovery policy. A powered-off output is not the same as a retired
|
||||
output; runtime output power remains independent of topology.
|
||||
|
||||
Session-lock protection remains mandatory for new/reconfigured scanouts. Hardware
|
||||
resets invalidate records of previously displayed content and lock frames.
|
||||
The boundary does not
|
||||
allow a saved desktop frame to substitute for a protected lock frame.
|
||||
|
||||
## Readiness, motion and shell visibility
|
||||
|
||||
`ResizeTransaction` remains the only configure readiness barrier. Its existing
|
||||
300 ms deadline and workspace coupling are unchanged: neighbors sharing the same
|
||||
viewport may pause together, but an unrelated workspace/output continues advancing.
|
||||
Existing committed client size/content may lag a newly published logical size.
|
||||
Reconciliation does not acknowledge client configures, advance springs itself,
|
||||
reset unrelated velocity or introduce another snapshot/animation system.
|
||||
|
||||
Shell broadcasts and initial subscription snapshots are deferred while the
|
||||
boundary is open. The finalizer releases that guard only after output ownership,
|
||||
focus, workspace cleanup and logical window layout agree. The existing shell
|
||||
snapshot protocol and deduplication remain in use. A snapshot describes the
|
||||
committed logical state; it does not mean every output has displayed that state.
|
||||
|
||||
`DisplayPresentation` remains a record of queued/displayed client visibility,
|
||||
not a topology transaction or a frame buffer store. Existing native resize
|
||||
snapshots survive output-cache retirement; GPU context loss still invalidates
|
||||
textures owned by that context. No new cross-GPU snapshot transport is introduced.
|
||||
Hardware preparation still uses a black modeset frame where required by the
|
||||
existing backend. This change prevents unintended logical intermediate desktops;
|
||||
it does not guarantee uninterrupted physical scanout through a modeset or
|
||||
device loss.
|
||||
|
||||
## Regression coverage
|
||||
|
||||
Pure planner tests cover addition/removal, focused and non-focused removal,
|
||||
replacement of multiple outputs, reconnect/explicit reassignment, a returning
|
||||
output whose donor retires in the same plan, headless recovery, failed planning,
|
||||
no-op/idempotent reconciliation and deterministic 512-step ownership sequences.
|
||||
A separate 256-step sequence mutates topology, focus, workspace/window membership
|
||||
and validates ownership and preservation of every window after each step.
|
||||
|
||||
The fake hardware transaction test applies commit and rollback failures, then
|
||||
plans from survivors: successful rollback produces no logical change; failed
|
||||
rollback produces a coherent reduced desktop without the requested new output.
|
||||
|
||||
The private Wayland integration regression uses actual shell, XDG toplevel and
|
||||
session-lock resources. It checks no intermediate shell generation, one complete
|
||||
final snapshot, repeated retirement, floating clamping, focused-window migration,
|
||||
removal during a workspace slide, pending XDG resize after publication, another
|
||||
workspace's progress during the wait, deadline expiry, no-op inventories and
|
||||
locked headless loss/recovery. Fractional-scale/rotation tests compare planner
|
||||
geometry directly with Smithay's Space geometry.
|
||||
|
||||
Physical KMS commits, cross-GPU recovery and resume still require the native
|
||||
checks in [output-management-testing.md](output-management-testing.md). Pure and
|
||||
private-protocol tests do not validate a DRM driver's behavior.
|
||||
@@ -14,6 +14,8 @@ Keep another desktop available for direct-session recovery.
|
||||
|
||||
## Test guides
|
||||
|
||||
- [Desktop reconciliation](desktop-reconciliation.md): topology publication, hardware failure and presentation ownership.
|
||||
|
||||
- [Output management tests](output-management-testing.md): profiles, hotplug, mirroring, lid handling and failed DRM commits.
|
||||
- [Nested resize checks](nested-resize-testing.md): wallpaper and bar responsiveness.
|
||||
- [Client soak testing](native-soak-testing.md): repeated client lifecycle and resource growth.
|
||||
|
||||
Reference in new issue
Block a user