From b29d271252c39649a53b41bf9f5ff19b2a361905 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Thu, 1 Oct 2026 11:24:56 +0100 Subject: [PATCH] Add floating placement memory and edge snapping --- compositor/src/floating.rs | 597 ++++++++++++++++++ compositor/src/grabs/move_grab.rs | 23 +- compositor/src/grabs/resize_grab.rs | 95 ++- compositor/src/handlers/xdg_shell.rs | 2 + compositor/src/input.rs | 14 +- compositor/src/main.rs | 1 + compositor/src/state.rs | 387 ++++++++++-- docs/configuration.md | 31 +- docs/shortcuts.md | 5 +- scripts/tests/fixtures/floating-size.c | 48 +- scripts/tests/test_floating_isolated.py | 6 +- .../tests/test_floating_placement_isolated.py | 152 +++++ 12 files changed, 1277 insertions(+), 84 deletions(-) create mode 100644 compositor/src/floating.rs create mode 100644 scripts/tests/test_floating_placement_isolated.py diff --git a/compositor/src/floating.rs b/compositor/src/floating.rs new file mode 100644 index 0000000..0125d90 --- /dev/null +++ b/compositor/src/floating.rs @@ -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 { + 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 { + 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::(); + 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, +} + +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 { + 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, +} + +#[derive(Default)] +struct SaveState { + pending: Option<(PathBuf, Memory)>, + closed: bool, +} + +struct SaveThread { + shared: std::sync::Arc<(std::sync::Mutex, std::sync::Condvar)>, + thread: Option>, +} + +impl SaveThread { + fn start(mut persist: impl FnMut(&Path, &Memory) -> std::io::Result<()> + Send + 'static) -> std::io::Result { + 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, +} + +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 { + 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, Vec) { + 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::>(); + let overlap = |r| { + others + .iter() + .filter_map(|o| intersection(r, *o)) + .map(|r| r.width * r.height) + .sum::() + }; + 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)); + } +} diff --git a/compositor/src/grabs/move_grab.rs b/compositor/src/grabs/move_grab.rs index eb30dbc..a8e25fb 100644 --- a/compositor/src/grabs/move_grab.rs +++ b/compositor/src/grabs/move_grab.rs @@ -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, @@ -15,6 +16,8 @@ pub struct MoveSurfaceGrab { pub initial_location: Point, pub initial_size: Size, pub finished: bool, + pub snap_x: AxisSnap, + pub snap_y: AxisSnap, } impl PointerGrab for MoveSurfaceGrab { @@ -27,7 +30,24 @@ impl PointerGrab 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 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); } } diff --git a/compositor/src/grabs/resize_grab.rs b/compositor/src/grabs/resize_grab.rs index 80c7dd3..d723d41 100644 --- a/compositor/src/grabs/resize_grab.rs +++ b/compositor/src/grabs/resize_grab.rs @@ -24,6 +24,29 @@ impl From for ResizeEdge { } impl ResizeEdge { + pub fn at(point: Point, rect: Rectangle) -> 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, last_size: Size, 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 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::(); @@ -107,6 +159,21 @@ impl PointerGrab 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 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( diff --git a/compositor/src/handlers/xdg_shell.rs b/compositor/src/handlers/xdg_shell.rs index ef83e14..eaddd5c 100644 --- a/compositor/src/handlers/xdg_shell.rs +++ b/compositor/src/handlers/xdg_shell.rs @@ -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, diff --git a/compositor/src/input.rs b/compositor/src/input.rs index d37af9e..bda836e 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -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, ); diff --git a/compositor/src/main.rs b/compositor/src/main.rs index b1c8dde..080e655 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -6,6 +6,7 @@ mod cursor; mod daemon; mod dimming; mod effects; +mod floating; mod gestures; mod grabs; mod handlers; diff --git a/compositor/src/state.rs b/compositor/src/state.rs index f9aa344..5f18737 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -284,6 +284,12 @@ pub struct Ferese { window_stack: WindowStack, floating_above_fullscreen: HashMap, natural_floating_pending: HashSet, + floating_memory: crate::floating::Memory, + floating_save_worker: crate::floating::SaveWorker, + floating_window_memory: HashMap, + floating_placement_anchors: HashMap, + floating_cascade: crate::floating::Cascade, + pub(crate) floating_resize_anchors: HashMap, pub(crate) window_borders: HashMap, pub(crate) window_dims: HashMap, pub(crate) window_resize_fills: HashMap, @@ -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::().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, Option) { + window + .toplevel() + .map(|top| { + let app = with_states(top.wl_surface(), |states| { + states + .data_map + .get::() + .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, 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, + work: Rect, + size: (f64, f64), + overrides: Option<(Option, Option)>, + ) -> 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::(); + 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::>(); + 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, Vec) { + 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 { let output_id = self .workspaces @@ -3070,30 +3381,6 @@ impl Ferese { } } - fn logical_window_rect(&mut self, window: WindowId, bounds: Rect) -> Option { - 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 { let constraints = self.window_constraints(); let focused = self.focused_window; diff --git a/docs/configuration.md b/docs/configuration.md index d9023cc..442bd04 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 diff --git a/docs/shortcuts.md b/docs/shortcuts.md index f35f14f..9d42771 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -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 diff --git a/scripts/tests/fixtures/floating-size.c b/scripts/tests/fixtures/floating-size.c index 241c86e..9a1c785 100644 --- a/scripts/tests/fixtures/floating-size.c +++ b/scripts/tests/fixtures/floating-size.c @@ -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, ®istry_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) {} diff --git a/scripts/tests/test_floating_isolated.py b/scripts/tests/test_floating_isolated.py index 21bf79d..29a929c 100644 --- a/scripts/tests/test_floating_isolated.py +++ b/scripts/tests/test_floating_isolated.py @@ -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: diff --git a/scripts/tests/test_floating_placement_isolated.py b/scripts/tests/test_floating_placement_isolated.py new file mode 100644 index 0000000..af3e3a4 --- /dev/null +++ b/scripts/tests/test_floating_placement_isolated.py @@ -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()