Fix floating placement and restore on overlapping outputs (#60)

Carry output identity through floating placement and restore, and retain the owning output when saved geometry overlaps multiple monitors equally.

Fixes #59.
This commit is contained in:
Samuel Onoja authored and GitHub committed 2026-10-24 19:48:16 +01:00
1 parent 2248ee6846
commit df36d1c9b0
3 files changed
+220 -88

No files matched your search

+27 -11
View File
@@ -72,9 +72,10 @@ impl Ferese {
let Some(window) = self.focused_window else { let Some(window) = self.focused_window else {
return; return;
}; };
let Some(bounds) = self.output_bounds() else { let Some((output, bounds)) = self.floating_work_area() else {
return; return;
}; };
let output = output.name();
let mut floating_rect = match self.workspaces.placement(window) { let mut floating_rect = match self.workspaces.placement(window) {
Some(WindowPlacement::Tiled) => self Some(WindowPlacement::Tiled) => self
.tiled_layout(bounds) .tiled_layout(bounds)
@@ -94,6 +95,7 @@ impl Ferese {
floating_rect = self.place_floating( floating_rect = self.place_floating(
&handle, &handle,
Some(window), Some(window),
&output,
bounds, bounds,
(floating_rect.width, floating_rect.height), (floating_rect.width, floating_rect.height),
None, None,
@@ -305,10 +307,14 @@ impl Ferese {
} }
pub(super) fn output_bounds(&self) -> Option<Rect> { pub(super) fn output_bounds(&self) -> Option<Rect> {
self.floating_work_area().map(|(_, work)| work)
}
pub(super) fn floating_work_area(&self) -> Option<(&Output, Rect)> {
let output = self let output = self
.focused_output() .focused_output()
.or_else(|| self.space.outputs().next())?; .or_else(|| self.space.outputs().next())?;
self.output_bounds_for(output) self.output_bounds_for(output).map(|work| (output, work))
} }
pub(super) fn floating_outputs(&self) -> Vec<(String, Rect)> { pub(super) fn floating_outputs(&self) -> Vec<(String, Rect)> {
@@ -377,17 +383,13 @@ impl Ferese {
&mut self, &mut self,
window: &Window, window: &Window,
id: Option<WindowId>, id: Option<WindowId>,
output: &str,
work: Rect, work: Rect,
size: (f64, f64), size: (f64, f64),
overrides: Option<(Option<f64>, Option<f64>)>, overrides: Option<(Option<f64>, Option<f64>)>,
) -> Rect { ) -> Rect {
use crate::floating::{centered, clamp_to_work, intersection, min_overlap}; use crate::floating::{centered, clamp_to_work, intersection, min_overlap};
let outputs = self.floating_outputs(); let outputs = self.floating_outputs();
let output = outputs
.iter()
.find(|(_, area)| *area == work)
.map(|(name, _)| name.as_str())
.unwrap_or("unknown");
let (app, parent) = Self::floating_metadata(window); let (app, parent) = Self::floating_metadata(window);
let parent_rect = parent let parent_rect = parent
.as_ref() .as_ref()
@@ -492,14 +494,20 @@ impl Ferese {
return; return;
} }
// A drag can finish on another output without changing workspace ownership. // A drag can finish on another output without changing workspace ownership.
// Equal overlap does not identify a monitor; retain the workspace owner.
let owner = self
.floating_work_area_for_window(id)
.map(|(output, _)| output.name());
let Some((output, work)) = self let Some((output, work)) = self
.floating_outputs() .floating_outputs()
.into_iter() .into_iter()
.max_by(|(_, a), (_, b)| { .max_by(|(a_name, a), (b_name, b)| {
let area = |work| { let area = |work| {
crate::floating::intersection(rect, work).map_or(0., |r| r.width * r.height) crate::floating::intersection(rect, work).map_or(0., |r| r.width * r.height)
}; };
area(*a).total_cmp(&area(*b)) area(*a).total_cmp(&area(*b)).then_with(|| {
(owner.as_ref() == Some(a_name)).cmp(&(owner.as_ref() == Some(b_name)))
})
}) })
.filter(|(_, work)| crate::floating::intersection(rect, *work).is_some()) .filter(|(_, work)| crate::floating::intersection(rect, *work).is_some())
else { else {
@@ -553,14 +561,22 @@ impl Ferese {
} }
pub(super) fn floating_bounds_for_window(&self, window: WindowId) -> Option<Rect> { pub(super) fn floating_bounds_for_window(&self, window: WindowId) -> Option<Rect> {
self.floating_work_area_for_window(window)
.map(|(_, work)| work)
}
pub(super) fn floating_work_area_for_window(
&self,
window: WindowId,
) -> Option<(&Output, Rect)> {
let output_id = self let output_id = self
.workspaces .workspaces
.workspace_for_window(window) .workspace_for_window(window)
.and_then(|workspace| self.output_workspaces.output_for_workspace(workspace)); .and_then(|workspace| self.output_workspaces.output_for_workspace(workspace));
output_id output_id
.and_then(|id| self.outputs_by_id.get(&id)) .and_then(|id| self.outputs_by_id.get(&id))
.and_then(|output| self.output_bounds_for(output)) .and_then(|output| self.output_bounds_for(output).map(|work| (output, work)))
.or_else(|| self.output_bounds()) .or_else(|| self.floating_work_area())
} }
pub(crate) fn output_bounds_for(&self, output: &Output) -> Option<Rect> { pub(crate) fn output_bounds_for(&self, output: &Output) -> Option<Rect> {
+160 -44
View File
@@ -424,54 +424,170 @@ fn floating_launch_restores_geometry_only_on_the_focused_output() {
) { ) {
return; return;
} }
for restore_on_focused_output in [false, true] { for second_x in [800, 400, 0] {
let (mut events, mut state, first, second) = floating_launch_fixture(); for restore_on_focused_output in [true, false] {
let saved_output = if restore_on_focused_output { let (mut events, mut state, first, second) = floating_launch_fixture();
&second state.space.map_output(&second, (second_x, 0));
} else { let saved_output = if restore_on_focused_output {
&first &second
}; } else {
let saved_work = state.output_bounds_for(saved_output).unwrap(); &first
let saved = Rect::new(saved_work.x + 100., saved_work.y + 80., 240., 160.); };
state.floating_memory.save( let saved_work = state.output_bounds_for(saved_output).unwrap();
"dev.ferese.Settings".into(), let saved = Rect::new(saved_work.x + 100., saved_work.y + 80., 240., 160.);
crate::floating::Remembered::new(saved_output.name(), saved, saved_work), state.floating_memory.save(
); "dev.ferese.Settings".into(),
// Keep the pointer on the launch output to isolate saved-placement crate::floating::Remembered::new(saved_output.name(), saved, saved_work),
// redirection from the separate keyboard-versus-pointer regression. );
let pointer = state.seat.get_pointer().unwrap(); let output_id = state.output_ids[&second];
pointer.motion( let destination = state.output_workspaces.active_workspace(output_id).unwrap();
&mut state, state.activate_output_workspace(output_id, destination);
None, let (window, _wire) = super::capture_privacy::tests::window_with_app_id(
&MotionEvent { &mut state,
location: (900., 100.).into(), &mut events,
serial: SERIAL_COUNTER.next_serial(), 0xff112233,
time: smithay::backend::input::InputTime::from_millis(1), "dev.ferese.Settings",
}, );
); let id = state.windows.ids()[&window];
state.focus_output_at(pointer.current_location()); assert_eq!(state.output_workspaces.focused_output(), Some(output_id));
let output_id = state.output_ids[&second]; assert_eq!(state.workspaces.workspace_for_window(id), Some(destination));
let destination = state.output_workspaces.active_workspace(output_id).unwrap(); let rect = state.windows.geometry(&id).unwrap().logical;
let (window, _wire) = super::capture_privacy::tests::window_with_app_id( let bounds = state.output_bounds_for(&second).unwrap();
&mut state, assert_eq!(crate::floating::intersection(rect, bounds), Some(rect));
&mut events, if restore_on_focused_output {
0xff112233, assert_eq!(rect, saved, "second output at x={second_x}");
"dev.ferese.Settings", } else {
); assert_eq!(
let id = state.windows.ids()[&window]; (rect.width, rect.height),
assert_eq!(state.output_workspaces.focused_output(), Some(output_id)); (64., 48.),
assert_eq!(state.workspaces.workspace_for_window(id), Some(destination)); "memory belongs to the other output; second output at x={second_x}"
let rect = state.windows.geometry(&id).unwrap().logical; );
let bounds = state.output_bounds_for(&second).unwrap(); }
assert_eq!(crate::floating::intersection(rect, bounds), Some(rect));
if restore_on_focused_output {
assert_eq!(rect, saved);
} else {
assert_eq!((rect.width, rect.height), (64., 48.));
} }
} }
} }
#[test]
fn floating_rule_restores_geometry_on_the_windows_output() {
if !crate::startup_tests::private_runtime(
"state::window_lifecycle_tests::floating_rule_restores_geometry_on_the_windows_output",
) {
return;
}
for second_x in [800, 0] {
let (mut events, mut state, first, second) = floating_launch_fixture();
state.space.map_output(&second, (second_x, 0));
let owner = state.output_ids[&second];
let workspace = state.output_workspaces.active_workspace(owner).unwrap();
state.activate_output_workspace(owner, workspace);
let (window, _wire) = window(&mut state, &mut events, 0xff112233);
let id = state.windows.ids()[&window];
let work = state.output_bounds_for(&second).unwrap();
let saved = Rect::new(work.x + 100., work.y + 80., 240., 160.);
state.windows.update(id, |record| {
record.floating_memory =
Some(crate::floating::Remembered::new(second.name(), saved, work));
});
let focused = state.output_ids[&first];
state.activate_output_workspace(
focused,
state.output_workspaces.active_workspace(focused).unwrap(),
);
state.apply_window_rule_result(
&window,
crate::window_rules::WindowRuleResult {
floating: Some(true),
..Default::default()
},
);
assert_eq!(state.output_workspaces.focused_output(), Some(focused));
assert_eq!(state.workspaces.workspace_for_window(id), Some(workspace));
assert_eq!(state.windows.geometry(&id).unwrap().logical, saved);
}
}
#[test]
fn floating_memory_round_trip_preserves_output_identity() {
if !crate::startup_tests::private_runtime(
"state::window_lifecycle_tests::floating_memory_round_trip_preserves_output_identity",
) {
return;
}
for second_x in [0, 400] {
for on_second in [false, true] {
let (mut events, mut state, first, second) = floating_launch_fixture();
state.space.map_output(&second, (second_x, 0));
let owner = if on_second { &second } else { &first };
let output_id = state.output_ids[owner];
let workspace = state.output_workspaces.active_workspace(output_id).unwrap();
state.activate_output_workspace(output_id, workspace);
let (window, wire) = super::capture_privacy::tests::window_with_app_id(
&mut state,
&mut events,
0xff112233,
"dev.ferese.Settings",
);
let id = state.windows.ids()[&window];
// The whole rectangle fits both outputs, so area alone cannot
// decide which monitor's placement memory should own it.
let saved = Rect::new(f64::from(second_x) + 100., 80., 240., 160.);
state.workspaces.set_floating_rect(id, saved).unwrap();
state.relayout_window(id);
state.remember_floating(&window);
assert_eq!(
state
.floating_memory
.get("dev.ferese.Settings")
.unwrap()
.output,
owner.name(),
"owner={} second_x={second_x}",
owner.name(),
);
drop(wire);
dispatch(&mut events, &mut state);
assert!(state.windows.ids().is_empty());
state.activate_output_workspace(output_id, workspace);
let (restored, _wire) = super::capture_privacy::tests::window_with_app_id(
&mut state,
&mut events,
0xff112233,
"dev.ferese.Settings",
);
let id = state.windows.ids()[&restored];
assert_eq!(state.windows.geometry(&id).unwrap().logical, saved);
}
}
}
#[test]
fn floating_memory_follows_a_drag_to_another_output() {
if !crate::startup_tests::private_runtime(
"state::window_lifecycle_tests::floating_memory_follows_a_drag_to_another_output",
) {
return;
}
let (mut events, mut state, first, second) = floating_launch_fixture();
let owner = state.output_ids[&first];
let workspace = state.output_workspaces.active_workspace(owner).unwrap();
state.activate_output_workspace(owner, workspace);
let (window, _wire) = super::capture_privacy::tests::window_with_app_id(
&mut state,
&mut events,
0xff112233,
"dev.ferese.Settings",
);
let id = state.windows.ids()[&window];
let saved = Rect::new(900., 80., 240., 160.);
state.workspaces.set_floating_rect(id, saved).unwrap();
state.relayout_window(id);
state.remember_floating(&window);
assert_eq!(state.workspaces.workspace_for_window(id), Some(workspace));
let memory = state.floating_memory.get("dev.ferese.Settings").unwrap();
assert_eq!(memory.output, second.name());
assert_eq!(memory.restore(&state.floating_outputs()), Some(saved));
}
#[test] #[test]
fn keyboard_floating_move_and_resize_work_in_both_layouts() { fn keyboard_floating_move_and_resize_work_in_both_layouts() {
if !crate::startup_tests::private_runtime( if !crate::startup_tests::private_runtime(
+33 -33
View File
@@ -58,19 +58,16 @@ impl Ferese {
return; return;
} }
let bounds = self.output_bounds(); let Some((output, work)) = self.floating_work_area() else {
return;
};
let output = output.name();
let outputs = self.floating_outputs(); let outputs = self.floating_outputs();
let Some((remembered, work)) = app_id let Some(remembered) = app_id
.as_deref() .as_deref()
.and_then(|app| self.floating_memory.get(app)) .and_then(|app| self.floating_memory.get(app))
.and_then(|entry| { .filter(|entry| entry.output == output)
let rect = entry.restore(&outputs)?; .and_then(|entry| entry.restore(&outputs))
let work = outputs
.iter()
.find(|(name, work)| *name == entry.output && Some(*work) == bounds)?
.1;
Some((rect, work))
})
else { else {
return; return;
}; };
@@ -80,6 +77,7 @@ impl Ferese {
let rect = self.place_floating( let rect = self.place_floating(
window, window,
None, None,
&output,
work, work,
(remembered.width, remembered.height), (remembered.width, remembered.height),
Some((rule.width, rule.height)), Some((rule.width, rule.height)),
@@ -110,24 +108,19 @@ impl Ferese {
return; return;
} }
let Some(bounds) = self.output_bounds() else { let Some((output, bounds)) = self.floating_work_area() else {
self.add_tiled_window(window); self.add_tiled_window(window);
return; return;
}; };
let output = output.name();
// Saved geometry may restore a position on this output, but must not // Saved geometry may restore a position on this output, but must not
// redirect a new window away from the output the user focused. // redirect a new window away from the output the user focused.
let remembered_work = app_id let remembered_rect = app_id
.filter(|app| !crate::window_rules::is_native_dialog(Some(app))) .filter(|app| !crate::window_rules::is_native_dialog(Some(app)))
.and_then(|app| self.floating_memory.get(app)) .and_then(|app| self.floating_memory.get(app))
.and_then(|entry| { .filter(|entry| entry.output == output)
let outputs = self.floating_outputs(); .and_then(|entry| entry.restore(&self.floating_outputs()));
entry.restore(&outputs)?;
outputs
.into_iter()
.find(|(name, work)| *name == entry.output && *work == bounds)
.map(|(_, work)| work)
});
let id = self.windows.allocate_id(); let id = self.windows.allocate_id();
let size_rect = client_size(&window) let size_rect = client_size(&window)
.map(|size| natural_floating_rect(bounds, size)) .map(|size| natural_floating_rect(bounds, size))
@@ -135,6 +128,7 @@ impl Ferese {
let rect = self.place_floating( let rect = self.place_floating(
&window, &window,
None, None,
&output,
bounds, bounds,
(size_rect.width, size_rect.height), (size_rect.width, size_rect.height),
None, None,
@@ -161,7 +155,7 @@ impl Ferese {
if rule.width.is_none() if rule.width.is_none()
&& rule.height.is_none() && rule.height.is_none()
&& client_size(&window).is_none() && client_size(&window).is_none()
&& remembered_work.is_none() && remembered_rect.is_none()
{ {
self.windows self.windows
.update(id, |w| w.natural_floating_pending = true); .update(id, |w| w.natural_floating_pending = true);
@@ -221,10 +215,11 @@ impl Ferese {
return; return;
}; };
let Some(bounds) = self.floating_bounds_for_window(parent) else { let Some((output, bounds)) = self.floating_work_area_for_window(parent) else {
self.add_tiled_window(window); self.add_tiled_window(window);
return; return;
}; };
let output = output.name();
let parent_rect = self.presented_window_rect(parent).unwrap_or(bounds); let parent_rect = self.presented_window_rect(parent).unwrap_or(bounds);
let visible_parent = crate::floating::intersection(parent_rect, bounds).unwrap_or(bounds); let visible_parent = crate::floating::intersection(parent_rect, bounds).unwrap_or(bounds);
@@ -234,6 +229,7 @@ impl Ferese {
let rect = self.place_floating( let rect = self.place_floating(
&window, &window,
None, None,
&output,
bounds, bounds,
(size_rect.width, size_rect.height), (size_rect.width, size_rect.height),
None, None,
@@ -384,9 +380,10 @@ impl Ferese {
return; return;
}; };
let Some(bounds) = self.floating_bounds_for_window(id) else { let Some((output, bounds)) = self.floating_work_area_for_window(id) else {
return; return;
}; };
let output = output.name();
if rule == Default::default() { if rule == Default::default() {
return; return;
@@ -414,7 +411,10 @@ impl Ferese {
tracing::warn!(%error, ?id, ?workspace, "failed to apply window workspace rule"); tracing::warn!(%error, ?id, ?workspace, "failed to apply window workspace rule");
} }
let bounds = self.floating_bounds_for_window(id).unwrap_or(bounds); let (output, bounds) = self
.floating_work_area_for_window(id)
.map(|(output, work)| (output.name(), work))
.unwrap_or((output, bounds));
let should_float = rule let should_float = rule
.floating .floating
@@ -432,11 +432,7 @@ impl Ferese {
.0 .0
.as_deref() .as_deref()
.and_then(|app| self.floating_memory.get(app)) .and_then(|app| self.floating_memory.get(app))
.filter(|entry| { .filter(|entry| entry.output == output)
self.floating_outputs()
.iter()
.any(|(name, work)| *name == entry.output && *work == bounds)
})
.and_then(|entry| entry.restore(&self.floating_outputs())) .and_then(|entry| entry.restore(&self.floating_outputs()))
.is_none() .is_none()
{ {
@@ -450,6 +446,7 @@ impl Ferese {
rect = self.place_floating( rect = self.place_floating(
window, window,
Some(id), Some(id),
&output,
bounds, bounds,
(rect.width, rect.height), (rect.width, rect.height),
Some((rule.width, rule.height)), Some((rule.width, rule.height)),
@@ -508,10 +505,10 @@ impl Ferese {
); );
return None; return None;
} }
self.floating_bounds_for_window(parent) self.floating_work_area_for_window(parent)
.map(|bounds| (parent, bounds)) .map(|(output, bounds)| (parent, output.name(), bounds))
}); });
let Some((parent, bounds)) = placement else { let Some((parent, output, bounds)) = placement else {
if removed { if removed {
self.relayout(); self.relayout();
} }
@@ -526,6 +523,7 @@ impl Ferese {
let rect = self.place_floating( let rect = self.place_floating(
window, window,
Some(id), Some(id),
&output,
bounds, bounds,
(size_rect.width, size_rect.height), (size_rect.width, size_rect.height),
None, None,
@@ -771,11 +769,13 @@ impl Ferese {
self.workspaces.placement(id), self.workspaces.placement(id),
Some(WindowPlacement::Floating { .. }) Some(WindowPlacement::Floating { .. })
) )
&& let Some(bounds) = self.floating_bounds_for_window(id) && let Some((output, bounds)) = self.floating_work_area_for_window(id)
{ {
let output = output.name();
let rect = self.place_floating( let rect = self.place_floating(
window, window,
Some(id), Some(id),
&output,
bounds, bounds,
(f64::from(size.width), f64::from(size.height)), (f64::from(size.width), f64::from(size.height)),
Some((Some(f64::from(size.width)), Some(f64::from(size.height)))), Some((Some(f64::from(size.width)), Some(f64::from(size.height)))),