Add floating placement memory and edge snapping

This commit is contained in:
Samuel Onoja committed 2026-10-01 11:24:56 +01:00
1 parent 9c99b13c31
commit b29d271252
12 files changed
+1277 -84

No files matched your search

+597
View File
@@ -0,0 +1,597 @@
//! Floating placement and snapping in logical output coordinates.
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use ferese_layout::Rect;
use serde::{Deserialize, Serialize};
pub const SNAP_DISTANCE: f64 = 10.0;
const RELEASE_DISTANCE: f64 = 20.0;
pub const CASCADE_STEP: f64 = 32.0; // CSD titlebars have no protocol-supplied height.
pub fn intersection(a: Rect, b: Rect) -> Option<Rect> {
let x = a.x.max(b.x);
let y = a.y.max(b.y);
let width = (a.x + a.width).min(b.x + b.width) - x;
let height = (a.y + a.height).min(b.y + b.height) - y;
(width > 0.0 && height > 0.0).then_some(Rect::new(x, y, width, height))
}
pub fn clamp_to_work(mut rect: Rect, work: Rect) -> Rect {
// Preserve client minimum sizes even when the work area is smaller.
rect.x = rect.x.clamp(work.x, work.x + (work.width - rect.width).max(0.0));
rect.y = rect.y.clamp(work.y, work.y + (work.height - rect.height).max(0.0));
rect
}
pub fn centered(size: (f64, f64), anchor: Rect) -> Rect {
Rect::new(
anchor.x + (anchor.width - size.0) / 2.0,
anchor.y + (anchor.height - size.1) / 2.0,
size.0,
size.1,
)
}
pub fn anchored_size(rect: Rect, size: (f64, f64), left: bool, top: bool) -> Rect {
Rect::new(
if left { rect.x + rect.width - size.0 } else { rect.x },
if top { rect.y + rect.height - size.1 } else { rect.y },
size.0,
size.1,
)
}
pub fn min_overlap(size: (f64, f64), work: Rect, others: &[Rect], anchor: (f64, f64)) -> Option<Rect> {
let (width, height) = size;
if !width.is_finite()
|| !height.is_finite()
|| width > work.width
|| height > work.height
|| width <= 0.0
|| height <= 0.0
{
return None;
}
let mut xs = vec![work.x, work.x + work.width - width];
let mut ys = vec![work.y, work.y + work.height - height];
// Add the user's anchor to avoid corner-only placement in open space.
xs.push((anchor.0 - width / 2.0).clamp(work.x, work.x + work.width - width));
ys.push((anchor.1 - height / 2.0).clamp(work.y, work.y + work.height - height));
for other in others {
xs.extend([other.x + other.width, other.x - width]);
ys.extend([other.y + other.height, other.y - height]);
}
xs.retain(|x| *x >= work.x && *x + width <= work.x + work.width);
ys.retain(|y| *y >= work.y && *y + height <= work.y + work.height);
xs.sort_by(f64::total_cmp);
ys.sort_by(f64::total_cmp);
xs.dedup();
ys.dedup();
let mut best: Option<((f64, f64), Rect)> = None;
for x in xs {
for &y in &ys {
let rect = Rect::new(x, y, width, height);
let overlap = others
.iter()
.filter_map(|other| intersection(rect, *other))
.map(|r| r.width * r.height)
.sum::<f64>();
let distance = (x + width / 2.0 - anchor.0).powi(2) + (y + height / 2.0 - anchor.1).powi(2);
let cost = (overlap, distance);
if best
.as_ref()
.is_none_or(|(old, _)| cost.0.total_cmp(&old.0).then(cost.1.total_cmp(&old.1)).is_lt())
{
best = Some((cost, rect));
}
}
}
best.map(|(_, rect)| rect)
}
#[derive(Default)]
pub struct Cascade {
positions: HashMap<String, (f64, f64)>,
}
impl Cascade {
pub fn place(&mut self, output: &str, work: Rect, size: (f64, f64)) -> Rect {
let position = self.positions.entry(output.to_owned()).or_insert((work.x, work.y));
if position.0 < work.x
|| position.1 < work.y
|| position.0 + size.0 > work.x + work.width
|| position.1 + size.1 > work.y + work.height
{
*position = (work.x, work.y);
}
let rect = Rect::new(position.0, position.1, size.0, size.1);
*position = (position.0 + CASCADE_STEP, position.1 + CASCADE_STEP);
rect
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Remembered {
pub output: String,
fractions: [f64; 4],
}
impl Remembered {
pub fn new(output: String, rect: Rect, work: Rect) -> Self {
Self {
output,
fractions: [
(rect.x - work.x) / work.width,
(rect.y - work.y) / work.height,
rect.width / work.width,
rect.height / work.height,
],
}
}
pub fn restore(&self, outputs: &[(String, Rect)]) -> Option<Rect> {
let work = outputs.iter().find(|(name, _)| *name == self.output)?.1;
if !self.fractions.iter().all(|v| v.is_finite()) || self.fractions[2] <= 0.0 || self.fractions[3] <= 0.0 {
return None;
}
let [x, y, w, h] = self.fractions;
let rect = Rect::new(
work.x + x * work.width,
work.y + y * work.height,
w * work.width,
h * work.height,
);
if ![rect.x, rect.y, rect.width, rect.height].iter().all(|v| v.is_finite()) {
return None;
}
let visible = outputs.iter().any(|(_, area)| {
intersection(rect, *area).is_some_and(|r| r.width * r.height >= rect.width * rect.height * 0.25)
});
visible.then_some(rect)
}
}
#[derive(Clone, Default)]
pub struct Memory {
entries: BTreeMap<String, Remembered>,
}
#[derive(Default)]
struct SaveState {
pending: Option<(PathBuf, Memory)>,
closed: bool,
}
struct SaveThread {
shared: std::sync::Arc<(std::sync::Mutex<SaveState>, std::sync::Condvar)>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl SaveThread {
fn start(mut persist: impl FnMut(&Path, &Memory) -> std::io::Result<()> + Send + 'static) -> std::io::Result<Self> {
let shared = std::sync::Arc::new((std::sync::Mutex::new(SaveState::default()), std::sync::Condvar::new()));
let worker = shared.clone();
let thread = std::thread::Builder::new()
.name("floating-geometry-save".into())
.spawn(move || {
loop {
let job = {
let (mutex, ready) = &*worker;
let mut state = mutex.lock().unwrap();
while state.pending.is_none() && !state.closed {
state = ready.wait(state).unwrap();
}
match state.pending.take() {
Some(job) => job,
None => break,
}
};
// Serialization, directory creation, writes and fsync all run
// outside the queue lock and outside the compositor thread.
if let Err(error) = persist(&job.0, &job.1) {
tracing::warn!(%error, "could not save floating window placement");
}
}
})?;
Ok(Self {
shared,
thread: Some(thread),
})
}
fn queue(&self, path: PathBuf, memory: Memory) {
let (mutex, ready) = &*self.shared;
mutex.lock().unwrap().pending = Some((path, memory));
ready.notify_one();
}
}
impl Drop for SaveThread {
fn drop(&mut self) {
let (mutex, ready) = &*self.shared;
mutex.lock().unwrap().closed = true;
ready.notify_one();
// Flush the latest snapshot only during compositor shutdown, after
// the event loop has stopped. Drag completion never joins this thread.
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
#[derive(Default)]
pub struct SaveWorker {
worker: Option<SaveThread>,
}
impl SaveWorker {
pub fn queue(&mut self, path: PathBuf, memory: &Memory) -> std::io::Result<()> {
if self.worker.is_none() {
self.worker = Some(SaveThread::start(|path, memory| memory.persist(path))?);
}
self.worker.as_ref().unwrap().queue(path, memory.clone());
Ok(())
}
}
impl Memory {
pub fn get(&self, app_id: &str) -> Option<&Remembered> {
self.entries.get(app_id)
}
pub fn save(&mut self, app_id: String, entry: Remembered) {
if !app_id.is_empty() && app_id.len() <= 4096 {
self.entries.insert(app_id, entry);
}
}
pub fn load(path: &Path) -> Self {
let entries = std::fs::metadata(path)
.ok()
.filter(|m| m.len() <= 1024 * 1024)
.and_then(|_| std::fs::read(path).ok())
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default();
Self { entries }
}
pub fn persist(&self, path: &Path) -> std::io::Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let parent = path
.parent()
.ok_or_else(|| std::io::Error::other("memory path has no parent"))?;
std::fs::create_dir_all(parent)?;
let temporary = path.with_extension(format!("{}.tmp", std::process::id()));
let result = (|| {
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&temporary)?;
file.write_all(&serde_json::to_vec(&self.entries)?)?;
file.sync_all()?;
std::fs::rename(&temporary, path)
})();
if result.is_err() {
let _ = std::fs::remove_file(temporary);
}
result
}
pub fn path() -> Option<PathBuf> {
let root = std::env::var_os("XDG_STATE_HOME")
.filter(|p| !p.is_empty())
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|p| PathBuf::from(p).join(".local/state")))?;
Some(root.join("ferese/floating.json"))
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SnapLine {
pub target: u64,
pub position: f64,
}
#[derive(Default, Debug)]
pub struct AxisSnap {
stuck: Option<(SnapLine, bool)>,
}
impl AxisSnap {
pub fn clear(&mut self) {
self.stuck = None;
}
pub fn apply(&mut self, raw: f64, length: f64, lines: &[SnapLine]) -> f64 {
if let Some((line, high)) = self.stuck {
if let Some(current) = lines
.iter()
.find(|l| l.target == line.target && l.position == line.position)
{
let correction = current.position - raw - if high { length } else { 0.0 };
if correction.abs() < RELEASE_DISTANCE {
return raw + correction;
}
}
self.stuck = None;
}
let nearest = lines
.iter()
.flat_map(|line| {
[
(line.position - raw, *line, false),
(line.position - raw - length, *line, true),
]
})
.min_by(|a, b| a.0.abs().total_cmp(&b.0.abs()));
if let Some((correction, line, high)) = nearest
&& correction.abs() < SNAP_DISTANCE
{
self.stuck = Some((line, high));
raw + correction
} else {
raw
}
}
}
pub fn snap_lines(raw: Rect, work: Rect, others: &[(u64, Rect)]) -> (Vec<SnapLine>, Vec<SnapLine>) {
let mut xs = vec![
SnapLine {
target: 0,
position: work.x,
},
SnapLine {
target: 0,
position: work.x + work.width,
},
];
let mut ys = vec![
SnapLine {
target: 0,
position: work.y,
},
SnapLine {
target: 0,
position: work.y + work.height,
},
];
for &(target, rect) in others {
if raw.y - SNAP_DISTANCE <= rect.y + rect.height && raw.y + raw.height + SNAP_DISTANCE >= rect.y {
xs.extend([
SnapLine {
target,
position: rect.x,
},
SnapLine {
target,
position: rect.x + rect.width,
},
]);
}
if raw.x - SNAP_DISTANCE <= rect.x + rect.width && raw.x + raw.width + SNAP_DISTANCE >= rect.x {
ys.extend([
SnapLine {
target,
position: rect.y,
},
SnapLine {
target,
position: rect.y + rect.height,
},
]);
}
}
(xs, ys)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn saves_do_not_wait_for_storage_and_coalesce_to_the_latest_snapshot() {
use std::sync::{Arc, Mutex, mpsc};
let written = Arc::new(Mutex::new(Vec::new()));
let recorded = written.clone();
let (started_tx, started_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
let mut first = true;
let worker = SaveThread::start(move |_, memory| {
recorded
.lock()
.unwrap()
.push(memory.entries.keys().next().unwrap().clone());
if first {
first = false;
started_tx.send(()).unwrap();
release_rx.recv().unwrap(); // simulate arbitrarily slow storage
}
Ok(())
})
.unwrap();
let snapshot = |name: &str| {
let mut memory = Memory::default();
memory.save(
name.into(),
Remembered::new(
"panel".into(),
Rect::new(0., 0., 10., 10.),
Rect::new(0., 0., 100., 100.),
),
);
memory
};
worker.queue(PathBuf::from("unused"), snapshot("first"));
started_rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap();
worker.queue(PathBuf::from("unused"), snapshot("superseded"));
worker.queue(PathBuf::from("unused"), snapshot("last"));
assert_eq!(*written.lock().unwrap(), vec!["first"]);
release_tx.send(()).unwrap();
drop(worker); // shutdown drains the latest queued snapshot
assert_eq!(*written.lock().unwrap(), vec!["first", "last"]);
}
#[test]
fn transient_centers_on_visible_parent_and_clamps_without_shrinking() {
let work = Rect::new(100., 50., 1000., 800.);
let visible = intersection(Rect::new(-300., -100., 800., 600.), work).unwrap();
assert_eq!(visible, Rect::new(100., 50., 400., 450.));
assert_eq!(
clamp_to_work(centered((200., 100.), visible), work),
Rect::new(200., 225., 200., 100.)
);
assert!(intersection(Rect::new(-300., 50., 200., 100.), work).is_none());
assert_eq!(
clamp_to_work(Rect::new(-100., -100., 1200., 900.), work),
Rect::new(100., 50., 1200., 900.)
);
}
#[test]
fn client_rounded_resize_keeps_the_opposite_edges_anchored() {
let rect = Rect::new(100., 80., 800., 600.);
assert_eq!(
anchored_size(rect, (693., 547.), true, true),
Rect::new(207., 133., 693., 547.)
);
assert_eq!(
anchored_size(rect, (693., 600.), true, false),
Rect::new(207., 80., 693., 600.)
);
assert_eq!(
anchored_size(rect, (800., 547.), false, true),
Rect::new(100., 133., 800., 547.)
);
assert_eq!(
anchored_size(rect, (693., 547.), false, false),
Rect::new(100., 80., 693., 547.)
);
}
#[test]
fn placement_avoids_overlap_and_centers_empty_space() {
let work = Rect::new(10., 20., 100., 100.);
assert_eq!(
min_overlap((20., 20.), work, &[], (60., 70.)),
Some(Rect::new(50., 60., 20., 20.))
);
let obstacle = Rect::new(10., 20., 60., 100.);
let placed = min_overlap((20., 20.), work, &[obstacle], (60., 70.)).unwrap();
assert!(intersection(placed, obstacle).is_none());
// A tiny overlap must never win merely because it is nearer.
let placed = min_overlap(
(10., 10.),
Rect::new(0., 0., 1000., 10.),
&[Rect::new(0., 0., 10., 0.0001)],
(5., 5.),
)
.unwrap();
assert_eq!(placed.x, 10.);
}
#[test]
fn candidate_search_matches_exhaustive_positions() {
let mut seed = 719_u64;
let mut next = || {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
(seed >> 32) as u32
};
for _ in 0..200 {
let size = ((next() % 8 + 1) as f64, (next() % 8 + 1) as f64);
let others = (0..5)
.map(|_| {
Rect::new(
(next() % 12) as f64,
(next() % 12) as f64,
(next() % 8 + 1) as f64,
(next() % 8 + 1) as f64,
)
})
.collect::<Vec<_>>();
let overlap = |r| {
others
.iter()
.filter_map(|o| intersection(r, *o))
.map(|r| r.width * r.height)
.sum::<f64>()
};
let placed = min_overlap(size, Rect::new(0., 0., 16., 16.), &others, (8., 8.)).unwrap();
let brute = (0..=16 - size.0 as i32)
.flat_map(|x| (0..=16 - size.1 as i32).map(move |y| Rect::new(x as f64, y as f64, size.0, size.1)))
.map(overlap)
.min_by(f64::total_cmp)
.unwrap();
assert_eq!(overlap(placed), brute);
}
}
#[test]
fn memory_scales_and_rejects_missing_or_invisible_outputs() {
let entry = Remembered::new(
"panel".into(),
Rect::new(25., 50., 50., 25.),
Rect::new(0., 0., 100., 100.),
);
assert_eq!(
entry.restore(&[("panel".into(), Rect::new(100., 0., 200., 200.))]),
Some(Rect::new(150., 100., 100., 50.))
);
assert_eq!(entry.restore(&[]), None);
let offscreen = Remembered::new(
"panel".into(),
Rect::new(-90., 0., 100., 100.),
Rect::new(0., 0., 100., 100.),
);
assert_eq!(
offscreen.restore(&[("panel".into(), Rect::new(0., 0., 100., 100.))]),
None
);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("floating.json");
let mut memory = Memory::default();
memory.save("app".into(), entry);
memory.persist(&path).unwrap();
assert!(Memory::load(&path).get("app").is_some());
}
#[test]
fn cascade_is_per_output_and_wraps() {
let mut cascade = Cascade::default();
let work = Rect::new(100., 50., 100., 100.);
assert_eq!(cascade.place("one", work, (50., 50.)).x, 100.);
assert_eq!(cascade.place("one", work, (50., 50.)).x, 132.);
assert_eq!(cascade.place("two", work, (50., 50.)).x, 100.);
assert_eq!(cascade.place("one", work, (50., 50.)).x, 100.);
assert!(min_overlap((150., 50.), work, &[], (100., 50.)).is_none());
}
#[test]
fn snap_hysteresis_keeps_the_same_edge_and_releases_from_raw_motion() {
let mut snap = AxisSnap::default();
let lines = [SnapLine {
target: 1,
position: 100.,
}];
assert_eq!(snap.apply(95., 50., &lines), 100.);
assert_eq!(snap.apply(85., 50., &lines), 100.);
assert_eq!(snap.apply(79., 50., &lines), 79.);
assert_eq!(snap.apply(45., 50., &lines), 50.);
assert_eq!(snap.apply(35., 50., &lines), 50.);
assert_eq!(snap.apply(35., 50., &[]), 35.);
snap.clear();
assert_eq!(snap.apply(85., 50., &lines), 85.);
assert_eq!(snap.apply(90., 50., &lines), 90., "10px does not acquire");
assert_eq!(snap.apply(91., 50., &lines), 100.);
assert_eq!(snap.apply(80., 50., &lines), 80., "20px releases");
assert_eq!(snap.apply(95., 50., &lines), 100.);
// A narrow window must not switch edges while stuck to the same line.
assert_eq!(snap.apply(86., 8., &lines), 100.);
let mut other_axis = AxisSnap::default();
assert_eq!(other_axis.apply(85., 50., &lines), 85.);
}
#[test]
fn snapping_excludes_windows_across_the_screen() {
let (xs, ys) = snap_lines(
Rect::new(0., 0., 50., 50.),
Rect::new(0., 0., 1000., 1000.),
&[
(1, Rect::new(100., 500., 50., 50.)),
(2, Rect::new(100., 20., 50., 50.)),
],
);
assert!(!xs.iter().any(|l| l.target == 1));
assert!(xs.iter().any(|l| l.target == 2));
assert!(!ys.iter().any(|l| l.target == 1 || l.target == 2));
}
}
+22 -1
View File
@@ -8,6 +8,7 @@ use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::utils::{Logical, Point, Size};
use crate::Ferese;
use crate::floating::AxisSnap;
pub struct MoveSurfaceGrab {
pub start_data: GrabStartData<Ferese>,
@@ -15,6 +16,8 @@ pub struct MoveSurfaceGrab {
pub initial_location: Point<i32, Logical>,
pub initial_size: Size<i32, Logical>,
pub finished: bool,
pub snap_x: AxisSnap,
pub snap_y: AxisSnap,
}
impl PointerGrab<Ferese> for MoveSurfaceGrab {
@@ -27,7 +30,24 @@ impl PointerGrab<Ferese> for MoveSurfaceGrab {
) {
handle.motion(data, None, event);
let location = self.initial_location.to_f64() + (event.location - self.start_data.location);
let location = location.to_i32_round();
let raw = ferese_layout::Rect::new(
location.x,
location.y,
self.initial_size.w as f64,
self.initial_size.h as f64,
);
let (xs, ys) = data.floating_snap_lines(&self.window, raw);
let location = if data.floating_snap_bypassed() {
self.snap_x.clear();
self.snap_y.clear();
location
} else {
Point::from((
self.snap_x.apply(raw.x, raw.width, &xs),
self.snap_y.apply(raw.y, raw.height, &ys),
))
}
.to_i32_round();
data.set_floating_window_geometry(&self.window, location, self.initial_size);
}
@@ -45,6 +65,7 @@ impl PointerGrab<Ferese> for MoveSurfaceGrab {
handle.button(data, event);
if handle.current_pressed().is_empty() {
self.finished = true;
data.remember_floating(&self.window);
handle.unset_grab(self, data, event.serial, event.time, true);
}
}
+94 -1
View File
@@ -24,6 +24,29 @@ impl From<xdg_toplevel::ResizeEdge> for ResizeEdge {
}
impl ResizeEdge {
pub fn at(point: Point<f64, Logical>, rect: Rectangle<i32, Logical>) -> Self {
use xdg_toplevel::ResizeEdge::*;
let x = (point.x - rect.loc.x as f64) / rect.size.w.max(1) as f64;
let y = (point.y - rect.loc.y as f64) / rect.size.h.max(1) as f64;
let (mut left, mut right) = (x < 1. / 3., x > 2. / 3.);
let (mut top, mut bottom) = (y < 1. / 3., y > 2. / 3.);
if !(left || right || top || bottom) {
left = x < 0.5;
right = !left;
top = y < 0.5;
bottom = !top;
}
Self(match (left, right, top, bottom) {
(true, _, true, _) => TopLeft,
(_, true, true, _) => TopRight,
(true, _, _, true) => BottomLeft,
(_, true, _, true) => BottomRight,
(true, _, _, _) => Left,
(_, true, _, _) => Right,
(_, _, true, _) => Top,
_ => Bottom,
})
}
fn left(self) -> bool {
matches!(
self.0,
@@ -64,6 +87,8 @@ pub struct ResizeSurfaceGrab {
initial_rect: Rectangle<i32, Logical>,
last_size: Size<i32, Logical>,
finished: bool,
snap_x: crate::floating::AxisSnap,
snap_y: crate::floating::AxisSnap,
}
impl ResizeSurfaceGrab {
@@ -84,6 +109,8 @@ impl ResizeSurfaceGrab {
initial_rect,
last_size: initial_rect.size,
finished: false,
snap_x: Default::default(),
snap_y: Default::default(),
}
}
}
@@ -97,7 +124,32 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
event: &MotionEvent,
) {
handle.motion(data, None, event);
let delta = event.location - self.start_data.location;
let mut delta = event.location - self.start_data.location;
let initial = self.initial_rect;
let raw_left = initial.loc.x as f64 + if self.edges.left() { delta.x } else { 0. };
let raw_top = initial.loc.y as f64 + if self.edges.top() { delta.y } else { 0. };
let raw_right = (initial.loc.x + initial.size.w) as f64 + if self.edges.right() { delta.x } else { 0. };
let raw_bottom = (initial.loc.y + initial.size.h) as f64 + if self.edges.bottom() { delta.y } else { 0. };
let raw = ferese_layout::Rect::new(
raw_left,
raw_top,
(raw_right - raw_left).max(1.),
(raw_bottom - raw_top).max(1.),
);
let (xs, ys) = data.floating_snap_lines(&self.window, raw);
if data.floating_snap_bypassed() {
self.snap_x.clear();
self.snap_y.clear();
} else {
if self.edges.left() || self.edges.right() {
let moving = if self.edges.left() { raw_left } else { raw_right };
delta.x += self.snap_x.apply(moving, 0., &xs) - moving;
}
if self.edges.top() || self.edges.bottom() {
let moving = if self.edges.top() { raw_top } else { raw_bottom };
delta.y += self.snap_y.apply(moving, 0., &ys) - moving;
}
}
let surface = self.window.toplevel().expect("managed window has a toplevel");
let (minimum, maximum) = with_states(surface.wl_surface(), |states| {
let mut cached = states.cached_state.get::<SurfaceCachedState>();
@@ -107,6 +159,21 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
self.last_size = constrained_size(self.initial_rect.size, delta, self.edges, minimum, maximum);
let rect = resized_rect(self.initial_rect, self.last_size, self.edges);
data.set_floating_window_geometry(&self.window, rect.loc, rect.size);
if let Some(id) = data.window_ids.get(&self.window).copied() {
data.floating_resize_anchors.insert(
id,
(
self.edges.left(),
self.edges.top(),
ferese_layout::Rect::new(
initial.loc.x as f64,
initial.loc.y as f64,
initial.size.w as f64,
initial.size.h as f64,
),
),
);
}
surface.with_pending_state(|state| {
state.states.set(xdg_toplevel::State::Resizing);
@@ -129,6 +196,7 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
handle.button(data, event);
if handle.current_pressed().is_empty() {
self.finished = true;
data.remember_floating(&self.window);
handle.unset_grab(self, data, event.serial, event.time, true);
let surface = self.window.toplevel().expect("managed window has a toplevel");
surface.with_pending_state(|state| {
@@ -341,6 +409,31 @@ fn resized_rect(
mod tests {
use super::*;
#[test]
fn resize_grid_selects_edges_corners_and_center_fallback_once() {
use xdg_toplevel::ResizeEdge::*;
let rect = Rectangle::new((100, 50).into(), (300, 300).into());
for (x, y, edge) in [
(0.1, 0.1, TopLeft),
(0.5, 0.1, Top),
(0.9, 0.1, TopRight),
(0.1, 0.5, Left),
(0.5, 0.5, BottomRight),
(0.9, 0.5, Right),
(0.1, 0.9, BottomLeft),
(0.5, 0.9, Bottom),
(0.9, 0.9, BottomRight),
(0.4, 0.4, TopLeft),
(0.6, 0.4, TopRight),
(0.4, 0.6, BottomLeft),
] {
assert_eq!(
ResizeEdge::at((100. + 300. * x, 50. + 300. * y).into(), rect),
ResizeEdge(edge)
);
}
}
#[test]
fn left_and_top_edges_invert_pointer_delta() {
let size = constrained_size(
+2
View File
@@ -129,6 +129,8 @@ impl XdgShellHandler for Ferese {
initial_location: initial_rect.loc,
initial_size: initial_rect.size,
finished: false,
snap_x: Default::default(),
snap_y: Default::default(),
},
serial,
Focus::Clear,
+4 -10
View File
@@ -12,7 +12,6 @@ use smithay::input::pointer::{
GrabStartData, MotionEvent, PointerHandle, RelativeMotionEvent,
};
use smithay::input::touch::{DownEvent, MotionEvent as TouchMotionEvent, UpEvent};
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::ResizeEdge as XdgResizeEdge;
use smithay::reexports::wayland_server::Resource;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::utils::{Logical, Point, SERIAL_COUNTER, Serial};
@@ -693,22 +692,17 @@ impl Ferese {
initial_location: rect.loc,
initial_size: rect.size,
finished: false,
snap_x: Default::default(),
snap_y: Default::default(),
},
serial,
Focus::Clear,
);
} else {
let horizontal_right = location.x >= f64::from(rect.loc.x) + f64::from(rect.size.w) / 2.0;
let vertical_bottom = location.y >= f64::from(rect.loc.y) + f64::from(rect.size.h) / 2.0;
let edge = match (horizontal_right, vertical_bottom) {
(false, false) => XdgResizeEdge::TopLeft,
(true, false) => XdgResizeEdge::TopRight,
(false, true) => XdgResizeEdge::BottomLeft,
(true, true) => XdgResizeEdge::BottomRight,
};
let edge = ResizeEdge::at(location, rect);
pointer.set_grab(
self,
ResizeSurfaceGrab::new(start_data, window, ResizeEdge::from(edge), rect),
ResizeSurfaceGrab::new(start_data, window, edge, rect),
serial,
Focus::Clear,
);
+1
View File
@@ -6,6 +6,7 @@ mod cursor;
mod daemon;
mod dimming;
mod effects;
mod floating;
mod gestures;
mod grabs;
mod handlers;
+337 -50
View File
@@ -284,6 +284,12 @@ pub struct Ferese {
window_stack: WindowStack,
floating_above_fullscreen: HashMap<WindowId, WindowId>,
natural_floating_pending: HashSet<WindowId>,
floating_memory: crate::floating::Memory,
floating_save_worker: crate::floating::SaveWorker,
floating_window_memory: HashMap<WindowId, crate::floating::Remembered>,
floating_placement_anchors: HashMap<WindowId, Rect>,
floating_cascade: crate::floating::Cascade,
pub(crate) floating_resize_anchors: HashMap<WindowId, (bool, bool, Rect)>,
pub(crate) window_borders: HashMap<WindowId, WindowBorderBuffers>,
pub(crate) window_dims: HashMap<WindowId, WindowBorderBuffers>,
pub(crate) window_resize_fills: HashMap<WindowId, WindowBorderBuffers>,
@@ -505,6 +511,14 @@ impl Ferese {
window_stack: WindowStack::default(),
floating_above_fullscreen: HashMap::new(),
natural_floating_pending: HashSet::new(),
floating_memory: crate::floating::Memory::path()
.map(|p| crate::floating::Memory::load(&p))
.unwrap_or_default(),
floating_save_worker: Default::default(),
floating_window_memory: HashMap::new(),
floating_placement_anchors: HashMap::new(),
floating_cascade: Default::default(),
floating_resize_anchors: HashMap::new(),
window_borders: HashMap::new(),
window_dims: HashMap::new(),
window_resize_fills: HashMap::new(),
@@ -1234,15 +1248,32 @@ impl Ferese {
if let Some(pointer) = self.seat.get_pointer() {
self.focus_output_at(pointer.current_location());
}
let remembered_work = app_id.and_then(|app| self.floating_memory.get(app)).and_then(|entry| {
let outputs = self.floating_outputs();
entry.restore(&outputs)?;
outputs
.into_iter()
.find(|(name, _)| *name == entry.output)
.map(|(_, work)| work)
});
if let Some(work) = remembered_work {
self.focus_output_at((work.x + work.width / 2.0, work.y + work.height / 2.0).into());
}
let Some(bounds) = self.output_bounds() else {
self.add_tiled_window(window);
return;
};
let id = WindowId(self.next_window_id);
self.next_window_id += 1;
let rect = client_size(&window)
let size_rect = client_size(&window)
.map(|size| natural_floating_rect(bounds, size))
.unwrap_or_else(|| centered_floating_rect(bounds));
let rect = self.place_floating(&window, None, bounds, (size_rect.width, size_rect.height), None);
let anchor = self
.focused_window
.and_then(|focused| self.presented_window_rect(focused))
.unwrap_or(bounds);
self.floating_placement_anchors.insert(id, anchor);
let fullscreen = self.workspaces.active().fullscreen;
let focus = true;
if let Err(error) = self
@@ -1252,7 +1283,8 @@ impl Ferese {
tracing::error!(%error, ?id, "failed to insert rule-placed floating window");
return;
}
if rule.width.is_none() && rule.height.is_none() && client_size(&window).is_none() {
if rule.width.is_none() && rule.height.is_none() && client_size(&window).is_none() && remembered_work.is_none()
{
self.natural_floating_pending.insert(id);
}
self.window_ids.insert(window.clone(), id);
@@ -1306,12 +1338,16 @@ impl Ferese {
self.add_tiled_window(window);
return;
};
let Some(bounds) = self.output_bounds() else {
let Some(bounds) = self.floating_bounds_for_window(parent) else {
self.add_tiled_window(window);
return;
};
let parent_rect = self.logical_window_rect(parent, bounds).unwrap_or(bounds);
let rect = centered_transient_rect(parent_rect);
let parent_rect = self.presented_window_rect(parent).unwrap_or(bounds);
let visible_parent = crate::floating::intersection(parent_rect, bounds).unwrap_or(bounds);
let size_rect = client_size(&window)
.map(|s| natural_floating_rect(bounds, s))
.unwrap_or_else(|| centered_transient_rect(visible_parent));
let rect = self.place_floating(&window, None, bounds, (size_rect.width, size_rect.height), None);
let id = WindowId(self.next_window_id);
self.next_window_id += 1;
@@ -1324,6 +1360,9 @@ impl Ferese {
self.window_ids.insert(window.clone(), id);
self.window_stack.insert(id);
if client_size(&window).is_none() {
self.natural_floating_pending.insert(id);
}
if self
.workspaces
.workspace(workspace)
@@ -1414,14 +1453,27 @@ impl Ferese {
if should_float && rule.width.is_none() && rule.height.is_none() {
if let Some(size) = client_size(window) {
rect = natural_floating_rect(bounds, size);
} else {
} else if Self::floating_metadata(window)
.0
.as_deref()
.and_then(|app| self.floating_memory.get(app))
.and_then(|entry| entry.restore(&self.floating_outputs()))
.is_none()
{
self.natural_floating_pending.insert(id);
}
}
rect.width = rule.width.unwrap_or(rect.width).min(bounds.width);
rect.height = rule.height.unwrap_or(rect.height).min(bounds.height);
rect.x = bounds.x + (bounds.width - rect.width) / 2.0;
rect.y = bounds.y + (bounds.height - rect.height) / 2.0;
if should_float {
rect = self.place_floating(
window,
Some(id),
bounds,
(rect.width, rect.height),
Some((rule.width, rule.height)),
);
}
let result = if should_float != is_floating {
self.workspaces.toggle_floating(id, rect, axis, 0.5).map(|_| ())
@@ -1459,11 +1511,15 @@ impl Ferese {
tracing::warn!(?id, ?parent, "ignored transient parent on another workspace");
return;
}
let Some(bounds) = self.output_bounds() else {
let Some(bounds) = self.floating_bounds_for_window(parent) else {
return;
};
let parent_rect = self.logical_window_rect(parent, bounds).unwrap_or(bounds);
let rect = centered_transient_rect(parent_rect);
let parent_rect = self.presented_window_rect(parent).unwrap_or(bounds);
let visible_parent = crate::floating::intersection(parent_rect, bounds).unwrap_or(bounds);
let size_rect = client_size(window)
.map(|s| natural_floating_rect(bounds, s))
.unwrap_or_else(|| centered_transient_rect(visible_parent));
let rect = self.place_floating(window, Some(id), bounds, (size_rect.width, size_rect.height), None);
let result = match self.workspaces.placement(id) {
Some(WindowPlacement::Tiled) => self
.workspaces
@@ -1514,6 +1570,9 @@ impl Ferese {
self.window_stack.remove(id);
self.floating_above_fullscreen.remove(&id);
self.natural_floating_pending.remove(&id);
self.floating_window_memory.remove(&id);
self.floating_placement_anchors.remove(&id);
self.floating_resize_anchors.remove(&id);
self.window_borders.remove(&id);
self.window_dims.remove(&id);
self.window_dimming.remove(&id);
@@ -2275,11 +2334,32 @@ impl Ferese {
let Some(size) = client_size(window) else {
return;
};
if self.natural_floating_pending.remove(&id)
let fullscreen = self
.workspaces
.workspace_for_window(id)
.and_then(|workspace| self.workspaces.workspace(workspace))
.is_some_and(|workspace| workspace.fullscreen == Some(id));
let normal_geometry = !fullscreen
&& !self.maximized_windows.contains(&id)
&& self
.window_geometry
.get(&id)
.is_none_or(|geometry| !geometry.is_zooming());
let initial_floating =
self.natural_floating_pending.remove(&id) || self.floating_placement_anchors.contains_key(&id);
if initial_floating
&& normal_geometry
&& matches!(self.workspaces.placement(id), Some(WindowPlacement::Floating { .. }))
&& let Some(bounds) = self.floating_bounds_for_window(id)
{
let rect = natural_floating_rect(bounds, size);
let rect = self.place_floating(
window,
Some(id),
bounds,
(f64::from(size.width), f64::from(size.height)),
Some((Some(f64::from(size.width)), Some(f64::from(size.height)))),
);
self.floating_placement_anchors.remove(&id);
if self.workspaces.set_floating_rect(id, rect).is_ok() {
// This is the first mapped client buffer, not a user resize.
// Do not animate from the temporary placement box.
@@ -2292,6 +2372,7 @@ impl Ferese {
// terminal cell grids, or a dialog changing its contents). Its committed
// window geometry is authoritative once the latest configure is committed.
// Never let an old buffer undo a newer resize or a fullscreen transition.
self.floating_placement_anchors.remove(&id);
let settled_configure = window.toplevel().is_some_and(|toplevel| {
with_states(toplevel.wl_surface(), |states| {
states.data_map.get::<XdgToplevelSurfaceData>().is_some_and(|data| {
@@ -2305,22 +2386,17 @@ impl Ferese {
})
})
});
let fullscreen = self
.workspaces
.workspace_for_window(id)
.and_then(|workspace| self.workspaces.workspace(workspace))
.is_some_and(|workspace| workspace.fullscreen == Some(id));
if settled_configure
&& !fullscreen
&& !self.maximized_windows.contains(&id)
&& self
.window_geometry
.get(&id)
.is_some_and(|geometry| !geometry.is_zooming())
&& normal_geometry
&& let Some(WindowPlacement::Floating { rect }) = self.workspaces.placement(id)
&& ClientSize::from_rect(rect) != size
{
let rect = Rect::new(rect.x, rect.y, f64::from(size.width), f64::from(size.height));
let mut rect = Rect::new(rect.x, rect.y, f64::from(size.width), f64::from(size.height));
if let Some((left, top, anchor)) = self.floating_resize_anchors.get(&id) {
rect = crate::floating::anchored_size(*anchor, (rect.width, rect.height), *left, *top);
} else if initial_floating && let Some(work) = self.floating_bounds_for_window(id) {
rect = crate::floating::clamp_to_work(rect, work);
}
if self.workspaces.set_floating_rect(id, rect).is_ok() {
self.window_geometry.insert(id, WindowGeometry::new(rect, Some(size)));
self.resize_transactions.remove(&id);
@@ -2328,6 +2404,9 @@ impl Ferese {
self.relayout();
}
}
if settled_configure && self.floating_resize_anchors.remove(&id).is_some() {
self.remember_floating(window);
}
let Some(geometry) = self.window_geometry.get_mut(&id) else {
return;
};
@@ -2549,7 +2628,7 @@ impl Ferese {
let Some(bounds) = self.output_bounds() else {
return;
};
let floating_rect = match self.workspaces.placement(window) {
let mut floating_rect = match self.workspaces.placement(window) {
Some(WindowPlacement::Tiled) => self
.tiled_layout(bounds)
.ok()
@@ -2558,6 +2637,26 @@ impl Ferese {
Some(WindowPlacement::Floating { rect }) => rect,
None => return,
};
if let Some(handle) = self
.window_ids
.iter()
.find_map(|(handle, id)| (*id == window).then_some(handle.clone()))
{
if matches!(
self.workspaces.placement(window),
Some(WindowPlacement::Floating { .. })
) {
self.remember_floating(&handle);
} else {
floating_rect = self.place_floating(
&handle,
Some(window),
bounds,
(floating_rect.width, floating_rect.height),
None,
);
}
}
let axis = self
.workspaces
.active()
@@ -2778,6 +2877,7 @@ impl Ferese {
let Some(id) = self.window_ids.get(window).copied() else {
return;
};
self.floating_resize_anchors.remove(&id);
let rect = Rect::new(
location.x as f64,
location.y as f64,
@@ -3027,6 +3127,217 @@ impl Ferese {
self.output_bounds_for(output)
}
fn floating_outputs(&self) -> Vec<(String, Rect)> {
self.space
.outputs()
.filter_map(|output| self.output_bounds_for(output).map(|work| (output.name(), work)))
.collect()
}
fn floating_metadata(window: &Window) -> (Option<String>, Option<WlSurface>) {
window
.toplevel()
.map(|top| {
let app = with_states(top.wl_surface(), |states| {
states
.data_map
.get::<XdgToplevelSurfaceData>()
.and_then(|data| data.lock().ok().and_then(|data| data.app_id.clone()))
});
(app, top.parent())
})
.unwrap_or_default()
}
fn floating_obstacles(&self, exclude: Option<WindowId>, work: Rect) -> Vec<(u64, Rect)> {
self.space
.elements()
.filter_map(|window| {
let id = *self.window_ids.get(window)?;
if Some(id) == exclude {
return None;
}
let workspace = self.workspaces.workspace_for_window(id)?;
let owner = self.output_workspaces.output_for_workspace(workspace)?;
if self.output_workspaces.active_workspace(owner) != Some(workspace) {
return None;
}
if self
.workspaces
.workspace(workspace)?
.fullscreen
.is_some_and(|full| full != id)
&& !matches!(self.workspaces.placement(id), Some(WindowPlacement::Floating { .. }))
{
return None;
}
let rect = self.presented_window_rect(id)?;
crate::floating::intersection(rect, work).map(|visible| (id.0, visible))
})
.collect()
}
fn place_floating(
&mut self,
window: &Window,
id: Option<WindowId>,
work: Rect,
size: (f64, f64),
overrides: Option<(Option<f64>, Option<f64>)>,
) -> Rect {
use crate::floating::{centered, clamp_to_work, intersection, min_overlap};
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 parent_rect = parent
.as_ref()
.and_then(|surface| {
self.window_ids.iter().find_map(|(window, id)| {
window
.toplevel()
.is_some_and(|top| top.wl_surface() == surface)
.then_some(*id)
})
})
.and_then(|parent| self.presented_window_rect(parent))
.and_then(|rect| intersection(rect, work));
let remembered = id
.and_then(|id| self.floating_window_memory.get(&id))
.or_else(|| {
if parent.is_none() {
app.as_deref().and_then(|app| self.floating_memory.get(app))
} else {
None
}
})
.filter(|entry| entry.output == output)
.and_then(|entry| entry.restore(&outputs));
let (width, height) = overrides.unwrap_or_default();
let constraints = window
.toplevel()
.map(|top| {
with_states(top.wl_surface(), |states| {
let mut cache = states.cached_state.get::<SurfaceCachedState>();
let state = cache.current();
SizeConstraints {
min_width: state.min_size.w.max(1) as f64,
min_height: state.min_size.h.max(1) as f64,
max_width: (state.max_size.w > 0).then_some(state.max_size.w as f64),
max_height: (state.max_size.h > 0).then_some(state.max_size.h as f64),
}
})
})
.unwrap_or_default();
let stored_size = if parent_rect.is_none() {
remembered.map(|r| (r.width, r.height)).unwrap_or(size)
} else {
size
};
let sized = constrained_floating_rect(
Rect::new(0., 0., width.unwrap_or(stored_size.0), height.unwrap_or(stored_size.1)),
constraints,
);
let dimensions = (sized.width, sized.height);
let rect = if let Some(parent) = parent_rect {
centered(dimensions, parent)
} else if let Some(mut rect) = remembered {
rect.width = dimensions.0;
rect.height = dimensions.1;
rect
} else {
let others = self
.floating_obstacles(id, work)
.into_iter()
.map(|(_, r)| r)
.collect::<Vec<_>>();
let anchor = id
.and_then(|id| self.floating_placement_anchors.get(&id).copied())
.or_else(|| {
self.focused_window
.filter(|focused| Some(*focused) != id)
.and_then(|focused| self.presented_window_rect(focused))
})
.and_then(|rect| intersection(rect, work))
.unwrap_or(work);
min_overlap(
dimensions,
work,
&others,
(anchor.x + anchor.width / 2., anchor.y + anchor.height / 2.),
)
.unwrap_or_else(|| self.floating_cascade.place(output, work, dimensions))
};
clamp_to_work(rect, work)
}
pub(crate) fn remember_floating(&mut self, window: &Window) {
let Some(id) = self.window_ids.get(window).copied() else {
return;
};
let Some(WindowPlacement::Floating { rect }) = self.workspaces.placement(id) else {
return;
};
let (app, parent) = Self::floating_metadata(window);
if parent.is_some() {
return;
}
// A drag can finish on another output without changing workspace ownership.
let Some((output, work)) = self
.floating_outputs()
.into_iter()
.max_by(|(_, a), (_, b)| {
let area = |work| crate::floating::intersection(rect, work).map_or(0., |r| r.width * r.height);
area(*a).total_cmp(&area(*b))
})
.filter(|(_, work)| crate::floating::intersection(rect, *work).is_some())
else {
return;
};
let entry = crate::floating::Remembered::new(output, rect, work);
self.floating_window_memory.insert(id, entry.clone());
if let Some(app) = app {
self.floating_memory.save(app, entry);
}
if self.nested_backend.is_none()
&& let Some(path) = crate::floating::Memory::path()
&& let Err(error) = self.floating_save_worker.queue(path, &self.floating_memory)
{
tracing::warn!(%error,"could not queue floating window placement save");
}
}
pub(crate) fn floating_snap_lines(
&self,
window: &Window,
raw: Rect,
) -> (Vec<crate::floating::SnapLine>, Vec<crate::floating::SnapLine>) {
let id = self.window_ids.get(window).copied();
let work = self
.floating_outputs()
.into_iter()
.max_by(|(_, a), (_, b)| {
let area = |work| crate::floating::intersection(raw, work).map_or(0., |r| r.width * r.height);
area(*a).total_cmp(&area(*b))
})
.filter(|(_, work)| crate::floating::intersection(raw, *work).is_some())
.map(|(_, work)| work)
.or_else(|| id.and_then(|id| self.floating_bounds_for_window(id)));
let Some(work) = work else {
return Default::default();
};
crate::floating::snap_lines(raw, work, &self.floating_obstacles(id, work))
}
pub(crate) fn floating_snap_bypassed(&self) -> bool {
self.seat
.get_keyboard()
.is_some_and(|keyboard| keyboard.modifier_state().shift)
}
fn floating_bounds_for_window(&self, window: WindowId) -> Option<Rect> {
let output_id = self
.workspaces
@@ -3070,30 +3381,6 @@ impl Ferese {
}
}
fn logical_window_rect(&mut self, window: WindowId, bounds: Rect) -> Option<Rect> {
let workspace = self.workspaces.workspace_for_window(window)?;
let fullscreen = self.workspaces.workspace(workspace)?.fullscreen;
if fullscreen == Some(window) {
return Some(bounds);
}
let constraints = self.window_constraints();
let focused = self.focused_window;
match self.workspaces.placement(window)? {
WindowPlacement::Tiled => self
.workspaces
.workspace_mut(workspace)?
.layout
.geometry_with_constraints(bounds, self.gap_config, &constraints, focused)
.ok()?
.geometry
.get(&window)
.copied(),
WindowPlacement::Floating { rect } => Some(rect),
}
}
fn tiled_layout(&mut self, bounds: Rect) -> Result<LayoutResult, ferese_layout::LayoutError> {
let constraints = self.window_constraints();
let focused = self.focused_window;
+27 -4
View File
@@ -334,8 +334,15 @@ Super+Shift+S area screenshot; Print Screen whole-screen screenshot;
Super+Shift+E logout confirmation.
Hold Super and drag a floating window with the left mouse button to move it.
Hold Super and drag with the right mouse button to resize it from the nearest
corner. These work even when an app has no title bar or resize border.
Hold Super and drag with the right mouse button to resize. The starting position
selects an edge or corner using a 3×3 grid; the center selects the nearest corner.
The selected edges stay fixed for the whole drag. These work even when an app has
no title bar or resize border.
Moving and resizing snap to work-area edges and nearby visible window edges at
10 logical pixels, then resist movement until 20 pixels from the attached edge.
Window edges are eligible only when the windows overlap along the other axis,
with a 10-pixel allowance. Hold Shift during a drag to bypass snapping.
The screenshot shortcut runs `ferese-screenshot`: drag to select an area, or
press Escape to cancel. Captures open in Satty for annotation. Press Enter to
@@ -374,8 +381,24 @@ window rules can override that behavior.
At least one matcher is required; supplied matchers must all match. Rules apply
in order, with later fields overriding earlier ones. Dimensions imply floating
when `floating` is omitted. Floating apps without dimensions choose their own
size and open centered on the output. Title changes do not trigger new rules;
config rule changes apply to existing windows.
size. Placement first centers transients on the visible part of their parent,
then tries remembered geometry, then the position with least summed overlap.
Equal-overlap candidates favor the focused window's center. If the size cannot
fit, a per-output cascade advances by 32 logical pixels and wraps to the work-area
origin. Placement respects layer-shell exclusive zones and client minimum sizes;
an oversized window keeps its size with its top-left corner reachable.
Successful move/resize completion saves ordinary floating geometry by app ID in
`$XDG_STATE_HOME/ferese/floating.json` (normally `~/.local/state/ferese/floating.json`).
The output name and work-area fractions preserve placement across resolution
changes. Missing outputs and restores with less than 25% visibility are skipped.
Saves run on a worker and pending updates coalesce to the latest snapshot;
drag completion updates memory immediately without waiting for storage.
Transient dialogs and cancelled drags do not update this memory. Floating → tiled
→ floating also restores the window's last floating geometry. Nested previews
keep drag memory in process and do not write the user's saved placements.
Title changes do not trigger new rules; config rule changes apply to existing windows.
```kdl
window-rule app-id="dev.ferese.Settings" floating=#true
+4 -1
View File
@@ -9,11 +9,14 @@ guide. Your saved bindings can override these defaults.
| Action | Result |
| --- | --- |
| Super + left-button drag on a floating window | Move the window |
| Super + right-button drag on a floating window | Resize from the nearest corner |
| Super + right-button drag on a floating window | Resize the edge or corner selected by the starting 3×3 region |
| Hold Shift during a move or resize | Bypass edge snapping |
| Super + Shift + Space | Switch the focused window between tiled and floating |
You can drag from inside the window; a title bar or resize border is not required.
To place an app freely, toggle it to floating, then Super-drag it.
The central resize region selects the nearest corner. Moving and resizing snap
to nearby edges; continuing to drag beyond 20 logical pixels releases the snap.
## Focus, move, and resize
+33 -15
View File
@@ -14,6 +14,26 @@ static struct wl_shm *shm;
static struct xdg_wm_base *wm;
static struct wl_surface *surface;
static struct wl_buffer *buffer;
static int width = 640, height = 480;
static int honor_configure;
static int buffer_width, buffer_height;
static void make_buffer(void) {
if (buffer && buffer_width == width && buffer_height == height) return;
int fd = memfd_create("floating-size-test", MFD_CLOEXEC);
size_t bytes = (size_t)width * height * 4;
if (fd < 0 || ftruncate(fd, bytes)) exit(3);
uint32_t *pixels = mmap(NULL, bytes, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (pixels == MAP_FAILED) exit(4);
for (size_t i = 0; i < (size_t)width * height; i++) pixels[i] = 0xff12ed34;
struct wl_shm_pool *pool = wl_shm_create_pool(shm, fd, bytes);
if (buffer) wl_buffer_destroy(buffer);
buffer = wl_shm_pool_create_buffer(pool, 0, width, height, width * 4, WL_SHM_FORMAT_XRGB8888);
wl_shm_pool_destroy(pool);
munmap(pixels, bytes);
close(fd);
buffer_width = width; buffer_height = height;
}
static void ping(void *data, struct xdg_wm_base *base, uint32_t serial) {
(void)data;
xdg_wm_base_pong(base, serial);
@@ -38,42 +58,40 @@ static const struct wl_registry_listener registry_listener = {global, removed};
static void configure(void *data, struct xdg_surface *xdg, uint32_t serial) {
(void)data;
xdg_surface_ack_configure(xdg, serial);
xdg_surface_set_window_geometry(xdg, 0, 0, 640, 480);
make_buffer();
xdg_surface_set_window_geometry(xdg, 0, 0, width, height);
wl_surface_attach(surface, buffer, 0, 0);
wl_surface_damage(surface, 0, 0, 640, 480);
wl_surface_damage(surface, 0, 0, width, height);
wl_surface_commit(surface);
}
static const struct xdg_surface_listener surface_listener = {.configure = configure};
static void size(void *data, struct xdg_toplevel *top, int32_t w, int32_t h,
struct wl_array *states) {
(void)data; (void)top; (void)w; (void)h; (void)states;
(void)data; (void)top; (void)states;
if (honor_configure) {
if (w > 0) width = w;
if (h > 0) height = h;
}
}
static void close_window(void *data, struct xdg_toplevel *top) {
(void)data; (void)top; exit(0);
}
static const struct xdg_toplevel_listener top_listener = {.configure = size, .close = close_window};
int main(void) {
int main(int argc, char **argv) {
if (argc >= 4) { width = atoi(argv[2]); height = atoi(argv[3]); }
honor_configure = argc >= 5;
if (width < 1 || height < 1 || width > 2048 || height > 2048) return 5;
struct wl_display *display = wl_display_connect(NULL);
if (!display) return 1;
struct wl_registry *registry = wl_display_get_registry(display);
wl_registry_add_listener(registry, &registry_listener, NULL);
if (wl_display_roundtrip(display) < 0 || !compositor || !shm || !wm) return 2;
int fd = memfd_create("floating-size-test", MFD_CLOEXEC);
size_t bytes = 640 * 480 * 4;
if (fd < 0 || ftruncate(fd, bytes)) return 3;
uint32_t *pixels = mmap(NULL, bytes, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (pixels == MAP_FAILED) return 4;
for (size_t i = 0; i < 640 * 480; i++) pixels[i] = 0xff12ed34;
struct wl_shm_pool *pool = wl_shm_create_pool(shm, fd, bytes);
buffer = wl_shm_pool_create_buffer(pool, 0, 640, 480, 640 * 4, WL_SHM_FORMAT_XRGB8888);
wl_shm_pool_destroy(pool);
close(fd);
surface = wl_compositor_create_surface(compositor);
struct xdg_surface *xdg = xdg_wm_base_get_xdg_surface(wm, surface);
xdg_surface_add_listener(xdg, &surface_listener, NULL);
struct xdg_toplevel *top = xdg_surface_get_toplevel(xdg);
xdg_toplevel_add_listener(top, &top_listener, NULL);
xdg_toplevel_set_app_id(top, "ferese.test.floating-size");
xdg_toplevel_set_app_id(top, argc >= 2 ? argv[1] : "ferese.test.floating-size");
xdg_toplevel_set_title(top, "Floating size regression");
wl_surface_commit(surface);
while (wl_display_dispatch(display) != -1) {}
+4 -2
View File
@@ -90,9 +90,11 @@ class FloatingSizeTest(unittest.TestCase):
bounds = mask.getbbox()
self.assertIsNotNone(bounds, "client content was not rendered")
# The shader antialiases the outermost pixel even at radius zero.
for actual, expected in zip((bounds[2] - bounds[0], bounds[3] - bounds[1]), (640, 480)):
scale = first["scale"]
expected_size = (round(640 * scale), round(480 * scale))
for actual, expected in zip((bounds[2] - bounds[0], bounds[3] - bounds[1]), expected_size):
self.assertTrue(expected - 2 <= actual <= expected,
f"floating content cropped: {bounds}, expected 640x480")
f"floating content cropped: {bounds}, expected {expected_size}")
finally:
process.terminate()
try:
@@ -0,0 +1,152 @@
"""FERESE_TEST_FLOATING=1 python3 scripts/tests/test_floating_placement_isolated.py
Real client mapping, remembered placement, and overlap on a disposable compositor.
Requires built debug ferese/feresectl, cc, wayland-scanner and a Wayland host.
"""
import json
import os
from pathlib import Path
import signal
import subprocess
import tempfile
import time
import unittest
@unittest.skipUnless(os.environ.get("FERESE_TEST_FLOATING") == "1", "nested test is opt-in")
class FloatingPlacementTest(unittest.TestCase):
def test_first_buffer_preserves_memory_and_new_windows_avoid_overlap(self):
repo = Path(__file__).resolve().parents[2]
with tempfile.TemporaryDirectory(prefix="ferese-placement-test-") as temporary:
root = Path(temporary)
protocols = subprocess.check_output(["pkg-config", "--variable=pkgdatadir", "wayland-protocols"], text=True).strip()
xml = str(Path(protocols) / "stable/xdg-shell/xdg-shell.xml")
for kind, output in [("client-header", "xdg-shell-client-protocol.h"), ("private-code", "xdg-shell-protocol.c")]:
subprocess.run(["wayland-scanner", kind, xml, str(root / output)], check=True)
flags = subprocess.check_output(["pkg-config", "--cflags", "--libs", "wayland-client"], text=True).split()
subprocess.run(["cc", "-Wall", "-Wextra", "-I", str(root), str(repo / "scripts/tests/fixtures/floating-size.c"),
str(root / "xdg-shell-protocol.c"), "-o", str(root / "client"), *flags], check=True)
runtime = root / "runtime"
runtime.mkdir(mode=0o700)
config = root / "config/ferese"
config.mkdir(parents=True)
(config / "config.kdl").write_text('animations { enabled #false; }\n' + ''.join(
f'window-rule app-id="ferese.test.placement.{app}" floating=#true\n' for app in ["remembered", "second", "third"]) +
'window-rule app-id="ferese.test.placement.fullscreen" floating=#true width=240 height=160 fullscreen=#true\n' +
'window-rule app-id="ferese.test.placement.maximized" floating=#true width=240 height=160\n')
state = root / "state/ferese"
state.mkdir(parents=True)
memory = state / "floating.json"
memory.write_text(json.dumps({"ferese.test.placement.remembered": {"output": "ferese-winit", "fractions": [.1, .1, .2, .2]}}))
before = memory.read_bytes()
display = Path(os.environ["WAYLAND_DISPLAY"])
if not display.is_absolute():
display = Path(os.environ["XDG_RUNTIME_DIR"]) / display
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"),
XDG_STATE_HOME=str(root / "state"), WAYLAND_DISPLAY=str(display))
env.pop("WAYLAND_SOCKET", None)
env.pop("FERESE_SHELL_CONTROL_SOCKET", None)
children = []
def launch(command, **kwargs):
child = subprocess.Popen(command, env=env, start_new_session=True, **kwargs)
children.append(child)
return child
def command(*args):
return json.loads(subprocess.check_output([str(repo / "target/debug/feresectl"), *args], env=env, timeout=5))
with (root / "compositor.log").open("w") as log:
try:
bus = launch(["dbus-daemon", "--session", "--nofork", "--print-address=1"], stdout=subprocess.PIPE, text=True)
env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip()
compositor = launch([str(repo / "target/debug/ferese"), "--backend", "nested"], stdout=log, stderr=log)
deadline = time.monotonic() + 10
while not (runtime / "ferese/control.sock").is_socket():
if compositor.poll() is not None or time.monotonic() > deadline:
self.fail((root / "compositor.log").read_text())
time.sleep(.025)
socket = next(p for p in runtime.glob("wayland-*") if not p.name.endswith(".lock"))
env["WAYLAND_DISPLAY"] = str(socket)
for app in ["remembered", "second", "third"]:
launch([str(root / "client"), "ferese.test.placement." + app, "240", "160"], stdout=log, stderr=log)
deadline = time.monotonic() + 5
while True:
windows = command("get-windows")
if any(w["app_id"] == "ferese.test.placement." + app and w["width"] == 240 and w["height"] == 160 for w in windows):
break
if time.monotonic() > deadline:
self.fail((root / "compositor.log").read_text() + repr(windows))
time.sleep(.025)
remembered = next(w for w in windows if w["app_id"].endswith("remembered"))
output = next(o for o in command("get-outputs") if o["enabled"])
self.assertAlmostEqual(remembered["x"], output["x"] + .1 * output["width"], delta=1)
self.assertAlmostEqual(remembered["y"], output["y"] + .1 * output["height"], delta=1)
for i, a in enumerate(windows):
for b in windows[i+1:]:
width = max(0, min(a["x"]+a["width"], b["x"]+b["width"])-max(a["x"], b["x"]))
height = max(0, min(a["y"]+a["height"], b["y"]+b["height"])-max(a["y"], b["y"]))
self.assertEqual(width * height, 0, repr(windows))
self.assertEqual(memory.read_bytes(), before, "mapping must not save geometry every frame")
# The third window is focused. Its old floating rectangle
# must survive a trip through the tiled layout.
third = next(w for w in windows if w["app_id"].endswith("third"))
command("toggle-floating")
command("toggle-floating")
deadline = time.monotonic() + 5
expected = tuple(third[k] for k in ["x", "y", "width", "height"])
while True:
restored = next(w for w in command("get-windows") if w["app_id"].endswith("third"))
actual = tuple(restored[k] for k in ["x", "y", "width", "height"])
if actual == expected:
break
if time.monotonic() > deadline:
self.fail(f"floating rectangle changed after toggling: {expected} -> {actual}")
time.sleep(.025)
self.assertEqual(memory.read_bytes(), before, "nested previews must not persist drag memory")
for mode in ["fullscreen", "maximized"]:
app = "ferese.test.placement." + mode
if mode == "maximized":
# Tree mode exercises the full-work-area maximization
# path; scrolling mode uses column maximization.
command("toggle-layout")
launch([str(root / "client"), app, "240", "160", "configured"], stdout=log, stderr=log)
if mode == "maximized":
deadline = time.monotonic() + 5
while not any(w["app_id"] == app and w["capture_width"] == 240 for w in command("get-windows")):
if time.monotonic() > deadline:
self.fail("maximized regression client did not map")
time.sleep(.025)
command("toggle-maximized")
deadline = time.monotonic() + 5
while True:
enlarged = next((w for w in command("get-windows") if w["app_id"] == app), None)
if enlarged and enlarged["capture_width"] > 240 and enlarged["capture_height"] > 160:
break
if time.monotonic() > deadline:
self.fail(f"{mode} client did not commit its enlarged buffer: {enlarged}")
time.sleep(.025)
command("toggle-" + mode)
deadline = time.monotonic() + 5
while True:
restored = next(w for w in command("get-windows") if w["app_id"] == app)
if (restored["width"], restored["height"], restored["capture_width"], restored["capture_height"]) == (240, 160, 240, 160):
break
if time.monotonic() > deadline:
self.fail(f"{mode} first buffer overwrote normal floating geometry: {restored}")
time.sleep(.025)
finally:
for child in reversed(children):
if child.poll() is None:
os.killpg(child.pid, signal.SIGTERM)
try:
child.wait(timeout=3)
except subprocess.TimeoutExpired:
os.killpg(child.pid, signal.SIGKILL)
child.wait(timeout=3)
if child.stdout is not None:
child.stdout.close()
if __name__ == "__main__":
unittest.main()