Add floating placement memory and edge snapping
This commit is contained in:
1 parent
9c99b13c31
commit
b29d271252
12 files changed
+1277
-84
No files matched your search
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
|
|||||||
use smithay::utils::{Logical, Point, Size};
|
use smithay::utils::{Logical, Point, Size};
|
||||||
|
|
||||||
use crate::Ferese;
|
use crate::Ferese;
|
||||||
|
use crate::floating::AxisSnap;
|
||||||
|
|
||||||
pub struct MoveSurfaceGrab {
|
pub struct MoveSurfaceGrab {
|
||||||
pub start_data: GrabStartData<Ferese>,
|
pub start_data: GrabStartData<Ferese>,
|
||||||
@@ -15,6 +16,8 @@ pub struct MoveSurfaceGrab {
|
|||||||
pub initial_location: Point<i32, Logical>,
|
pub initial_location: Point<i32, Logical>,
|
||||||
pub initial_size: Size<i32, Logical>,
|
pub initial_size: Size<i32, Logical>,
|
||||||
pub finished: bool,
|
pub finished: bool,
|
||||||
|
pub snap_x: AxisSnap,
|
||||||
|
pub snap_y: AxisSnap,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PointerGrab<Ferese> for MoveSurfaceGrab {
|
impl PointerGrab<Ferese> for MoveSurfaceGrab {
|
||||||
@@ -27,7 +30,24 @@ impl PointerGrab<Ferese> for MoveSurfaceGrab {
|
|||||||
) {
|
) {
|
||||||
handle.motion(data, None, event);
|
handle.motion(data, None, event);
|
||||||
let location = self.initial_location.to_f64() + (event.location - self.start_data.location);
|
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);
|
data.set_floating_window_geometry(&self.window, location, self.initial_size);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +65,7 @@ impl PointerGrab<Ferese> for MoveSurfaceGrab {
|
|||||||
handle.button(data, event);
|
handle.button(data, event);
|
||||||
if handle.current_pressed().is_empty() {
|
if handle.current_pressed().is_empty() {
|
||||||
self.finished = true;
|
self.finished = true;
|
||||||
|
data.remember_floating(&self.window);
|
||||||
handle.unset_grab(self, data, event.serial, event.time, true);
|
handle.unset_grab(self, data, event.serial, event.time, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,29 @@ impl From<xdg_toplevel::ResizeEdge> for ResizeEdge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
fn left(self) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
self.0,
|
self.0,
|
||||||
@@ -64,6 +87,8 @@ pub struct ResizeSurfaceGrab {
|
|||||||
initial_rect: Rectangle<i32, Logical>,
|
initial_rect: Rectangle<i32, Logical>,
|
||||||
last_size: Size<i32, Logical>,
|
last_size: Size<i32, Logical>,
|
||||||
finished: bool,
|
finished: bool,
|
||||||
|
snap_x: crate::floating::AxisSnap,
|
||||||
|
snap_y: crate::floating::AxisSnap,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResizeSurfaceGrab {
|
impl ResizeSurfaceGrab {
|
||||||
@@ -84,6 +109,8 @@ impl ResizeSurfaceGrab {
|
|||||||
initial_rect,
|
initial_rect,
|
||||||
last_size: initial_rect.size,
|
last_size: initial_rect.size,
|
||||||
finished: false,
|
finished: false,
|
||||||
|
snap_x: Default::default(),
|
||||||
|
snap_y: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,7 +124,32 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
|
|||||||
event: &MotionEvent,
|
event: &MotionEvent,
|
||||||
) {
|
) {
|
||||||
handle.motion(data, None, event);
|
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 surface = self.window.toplevel().expect("managed window has a toplevel");
|
||||||
let (minimum, maximum) = with_states(surface.wl_surface(), |states| {
|
let (minimum, maximum) = with_states(surface.wl_surface(), |states| {
|
||||||
let mut cached = states.cached_state.get::<SurfaceCachedState>();
|
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);
|
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);
|
let rect = resized_rect(self.initial_rect, self.last_size, self.edges);
|
||||||
data.set_floating_window_geometry(&self.window, rect.loc, rect.size);
|
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| {
|
surface.with_pending_state(|state| {
|
||||||
state.states.set(xdg_toplevel::State::Resizing);
|
state.states.set(xdg_toplevel::State::Resizing);
|
||||||
@@ -129,6 +196,7 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
|
|||||||
handle.button(data, event);
|
handle.button(data, event);
|
||||||
if handle.current_pressed().is_empty() {
|
if handle.current_pressed().is_empty() {
|
||||||
self.finished = true;
|
self.finished = true;
|
||||||
|
data.remember_floating(&self.window);
|
||||||
handle.unset_grab(self, data, event.serial, event.time, true);
|
handle.unset_grab(self, data, event.serial, event.time, true);
|
||||||
let surface = self.window.toplevel().expect("managed window has a toplevel");
|
let surface = self.window.toplevel().expect("managed window has a toplevel");
|
||||||
surface.with_pending_state(|state| {
|
surface.with_pending_state(|state| {
|
||||||
@@ -341,6 +409,31 @@ fn resized_rect(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn left_and_top_edges_invert_pointer_delta() {
|
fn left_and_top_edges_invert_pointer_delta() {
|
||||||
let size = constrained_size(
|
let size = constrained_size(
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ impl XdgShellHandler for Ferese {
|
|||||||
initial_location: initial_rect.loc,
|
initial_location: initial_rect.loc,
|
||||||
initial_size: initial_rect.size,
|
initial_size: initial_rect.size,
|
||||||
finished: false,
|
finished: false,
|
||||||
|
snap_x: Default::default(),
|
||||||
|
snap_y: Default::default(),
|
||||||
},
|
},
|
||||||
serial,
|
serial,
|
||||||
Focus::Clear,
|
Focus::Clear,
|
||||||
|
|||||||
+4
-10
@@ -12,7 +12,6 @@ use smithay::input::pointer::{
|
|||||||
GrabStartData, MotionEvent, PointerHandle, RelativeMotionEvent,
|
GrabStartData, MotionEvent, PointerHandle, RelativeMotionEvent,
|
||||||
};
|
};
|
||||||
use smithay::input::touch::{DownEvent, MotionEvent as TouchMotionEvent, UpEvent};
|
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::Resource;
|
||||||
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
|
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
|
||||||
use smithay::utils::{Logical, Point, SERIAL_COUNTER, Serial};
|
use smithay::utils::{Logical, Point, SERIAL_COUNTER, Serial};
|
||||||
@@ -693,22 +692,17 @@ impl Ferese {
|
|||||||
initial_location: rect.loc,
|
initial_location: rect.loc,
|
||||||
initial_size: rect.size,
|
initial_size: rect.size,
|
||||||
finished: false,
|
finished: false,
|
||||||
|
snap_x: Default::default(),
|
||||||
|
snap_y: Default::default(),
|
||||||
},
|
},
|
||||||
serial,
|
serial,
|
||||||
Focus::Clear,
|
Focus::Clear,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
let horizontal_right = location.x >= f64::from(rect.loc.x) + f64::from(rect.size.w) / 2.0;
|
let edge = ResizeEdge::at(location, rect);
|
||||||
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,
|
|
||||||
};
|
|
||||||
pointer.set_grab(
|
pointer.set_grab(
|
||||||
self,
|
self,
|
||||||
ResizeSurfaceGrab::new(start_data, window, ResizeEdge::from(edge), rect),
|
ResizeSurfaceGrab::new(start_data, window, edge, rect),
|
||||||
serial,
|
serial,
|
||||||
Focus::Clear,
|
Focus::Clear,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ mod cursor;
|
|||||||
mod daemon;
|
mod daemon;
|
||||||
mod dimming;
|
mod dimming;
|
||||||
mod effects;
|
mod effects;
|
||||||
|
mod floating;
|
||||||
mod gestures;
|
mod gestures;
|
||||||
mod grabs;
|
mod grabs;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
|
|||||||
+337
-50
@@ -284,6 +284,12 @@ pub struct Ferese {
|
|||||||
window_stack: WindowStack,
|
window_stack: WindowStack,
|
||||||
floating_above_fullscreen: HashMap<WindowId, WindowId>,
|
floating_above_fullscreen: HashMap<WindowId, WindowId>,
|
||||||
natural_floating_pending: HashSet<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_borders: HashMap<WindowId, WindowBorderBuffers>,
|
||||||
pub(crate) window_dims: HashMap<WindowId, WindowBorderBuffers>,
|
pub(crate) window_dims: HashMap<WindowId, WindowBorderBuffers>,
|
||||||
pub(crate) window_resize_fills: HashMap<WindowId, WindowBorderBuffers>,
|
pub(crate) window_resize_fills: HashMap<WindowId, WindowBorderBuffers>,
|
||||||
@@ -505,6 +511,14 @@ impl Ferese {
|
|||||||
window_stack: WindowStack::default(),
|
window_stack: WindowStack::default(),
|
||||||
floating_above_fullscreen: HashMap::new(),
|
floating_above_fullscreen: HashMap::new(),
|
||||||
natural_floating_pending: HashSet::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_borders: HashMap::new(),
|
||||||
window_dims: HashMap::new(),
|
window_dims: HashMap::new(),
|
||||||
window_resize_fills: HashMap::new(),
|
window_resize_fills: HashMap::new(),
|
||||||
@@ -1234,15 +1248,32 @@ impl Ferese {
|
|||||||
if let Some(pointer) = self.seat.get_pointer() {
|
if let Some(pointer) = self.seat.get_pointer() {
|
||||||
self.focus_output_at(pointer.current_location());
|
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 {
|
let Some(bounds) = self.output_bounds() else {
|
||||||
self.add_tiled_window(window);
|
self.add_tiled_window(window);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let id = WindowId(self.next_window_id);
|
let id = WindowId(self.next_window_id);
|
||||||
self.next_window_id += 1;
|
self.next_window_id += 1;
|
||||||
let rect = client_size(&window)
|
let size_rect = client_size(&window)
|
||||||
.map(|size| natural_floating_rect(bounds, size))
|
.map(|size| natural_floating_rect(bounds, size))
|
||||||
.unwrap_or_else(|| centered_floating_rect(bounds));
|
.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 fullscreen = self.workspaces.active().fullscreen;
|
||||||
let focus = true;
|
let focus = true;
|
||||||
if let Err(error) = self
|
if let Err(error) = self
|
||||||
@@ -1252,7 +1283,8 @@ impl Ferese {
|
|||||||
tracing::error!(%error, ?id, "failed to insert rule-placed floating window");
|
tracing::error!(%error, ?id, "failed to insert rule-placed floating window");
|
||||||
return;
|
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.natural_floating_pending.insert(id);
|
||||||
}
|
}
|
||||||
self.window_ids.insert(window.clone(), id);
|
self.window_ids.insert(window.clone(), id);
|
||||||
@@ -1306,12 +1338,16 @@ impl Ferese {
|
|||||||
self.add_tiled_window(window);
|
self.add_tiled_window(window);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(bounds) = self.output_bounds() else {
|
let Some(bounds) = self.floating_bounds_for_window(parent) else {
|
||||||
self.add_tiled_window(window);
|
self.add_tiled_window(window);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let parent_rect = self.logical_window_rect(parent, bounds).unwrap_or(bounds);
|
let parent_rect = self.presented_window_rect(parent).unwrap_or(bounds);
|
||||||
let rect = centered_transient_rect(parent_rect);
|
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);
|
let id = WindowId(self.next_window_id);
|
||||||
self.next_window_id += 1;
|
self.next_window_id += 1;
|
||||||
|
|
||||||
@@ -1324,6 +1360,9 @@ impl Ferese {
|
|||||||
|
|
||||||
self.window_ids.insert(window.clone(), id);
|
self.window_ids.insert(window.clone(), id);
|
||||||
self.window_stack.insert(id);
|
self.window_stack.insert(id);
|
||||||
|
if client_size(&window).is_none() {
|
||||||
|
self.natural_floating_pending.insert(id);
|
||||||
|
}
|
||||||
if self
|
if self
|
||||||
.workspaces
|
.workspaces
|
||||||
.workspace(workspace)
|
.workspace(workspace)
|
||||||
@@ -1414,14 +1453,27 @@ impl Ferese {
|
|||||||
if should_float && rule.width.is_none() && rule.height.is_none() {
|
if should_float && rule.width.is_none() && rule.height.is_none() {
|
||||||
if let Some(size) = client_size(window) {
|
if let Some(size) = client_size(window) {
|
||||||
rect = natural_floating_rect(bounds, size);
|
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);
|
self.natural_floating_pending.insert(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rect.width = rule.width.unwrap_or(rect.width).min(bounds.width);
|
rect.width = rule.width.unwrap_or(rect.width).min(bounds.width);
|
||||||
rect.height = rule.height.unwrap_or(rect.height).min(bounds.height);
|
rect.height = rule.height.unwrap_or(rect.height).min(bounds.height);
|
||||||
rect.x = bounds.x + (bounds.width - rect.width) / 2.0;
|
if should_float {
|
||||||
rect.y = bounds.y + (bounds.height - rect.height) / 2.0;
|
rect = self.place_floating(
|
||||||
|
window,
|
||||||
|
Some(id),
|
||||||
|
bounds,
|
||||||
|
(rect.width, rect.height),
|
||||||
|
Some((rule.width, rule.height)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let result = if should_float != is_floating {
|
let result = if should_float != is_floating {
|
||||||
self.workspaces.toggle_floating(id, rect, axis, 0.5).map(|_| ())
|
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");
|
tracing::warn!(?id, ?parent, "ignored transient parent on another workspace");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some(bounds) = self.output_bounds() else {
|
let Some(bounds) = self.floating_bounds_for_window(parent) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let parent_rect = self.logical_window_rect(parent, bounds).unwrap_or(bounds);
|
let parent_rect = self.presented_window_rect(parent).unwrap_or(bounds);
|
||||||
let rect = centered_transient_rect(parent_rect);
|
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) {
|
let result = match self.workspaces.placement(id) {
|
||||||
Some(WindowPlacement::Tiled) => self
|
Some(WindowPlacement::Tiled) => self
|
||||||
.workspaces
|
.workspaces
|
||||||
@@ -1514,6 +1570,9 @@ impl Ferese {
|
|||||||
self.window_stack.remove(id);
|
self.window_stack.remove(id);
|
||||||
self.floating_above_fullscreen.remove(&id);
|
self.floating_above_fullscreen.remove(&id);
|
||||||
self.natural_floating_pending.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_borders.remove(&id);
|
||||||
self.window_dims.remove(&id);
|
self.window_dims.remove(&id);
|
||||||
self.window_dimming.remove(&id);
|
self.window_dimming.remove(&id);
|
||||||
@@ -2275,11 +2334,32 @@ impl Ferese {
|
|||||||
let Some(size) = client_size(window) else {
|
let Some(size) = client_size(window) else {
|
||||||
return;
|
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 { .. }))
|
&& matches!(self.workspaces.placement(id), Some(WindowPlacement::Floating { .. }))
|
||||||
&& let Some(bounds) = self.floating_bounds_for_window(id)
|
&& 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() {
|
if self.workspaces.set_floating_rect(id, rect).is_ok() {
|
||||||
// This is the first mapped client buffer, not a user resize.
|
// This is the first mapped client buffer, not a user resize.
|
||||||
// Do not animate from the temporary placement box.
|
// 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
|
// terminal cell grids, or a dialog changing its contents). Its committed
|
||||||
// window geometry is authoritative once the latest configure is committed.
|
// window geometry is authoritative once the latest configure is committed.
|
||||||
// Never let an old buffer undo a newer resize or a fullscreen transition.
|
// 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| {
|
let settled_configure = window.toplevel().is_some_and(|toplevel| {
|
||||||
with_states(toplevel.wl_surface(), |states| {
|
with_states(toplevel.wl_surface(), |states| {
|
||||||
states.data_map.get::<XdgToplevelSurfaceData>().is_some_and(|data| {
|
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
|
if settled_configure
|
||||||
&& !fullscreen
|
&& normal_geometry
|
||||||
&& !self.maximized_windows.contains(&id)
|
|
||||||
&& self
|
|
||||||
.window_geometry
|
|
||||||
.get(&id)
|
|
||||||
.is_some_and(|geometry| !geometry.is_zooming())
|
|
||||||
&& let Some(WindowPlacement::Floating { rect }) = self.workspaces.placement(id)
|
&& let Some(WindowPlacement::Floating { rect }) = self.workspaces.placement(id)
|
||||||
&& ClientSize::from_rect(rect) != size
|
&& 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() {
|
if self.workspaces.set_floating_rect(id, rect).is_ok() {
|
||||||
self.window_geometry.insert(id, WindowGeometry::new(rect, Some(size)));
|
self.window_geometry.insert(id, WindowGeometry::new(rect, Some(size)));
|
||||||
self.resize_transactions.remove(&id);
|
self.resize_transactions.remove(&id);
|
||||||
@@ -2328,6 +2404,9 @@ impl Ferese {
|
|||||||
self.relayout();
|
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 {
|
let Some(geometry) = self.window_geometry.get_mut(&id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -2549,7 +2628,7 @@ impl Ferese {
|
|||||||
let Some(bounds) = self.output_bounds() else {
|
let Some(bounds) = self.output_bounds() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let floating_rect = match self.workspaces.placement(window) {
|
let mut floating_rect = match self.workspaces.placement(window) {
|
||||||
Some(WindowPlacement::Tiled) => self
|
Some(WindowPlacement::Tiled) => self
|
||||||
.tiled_layout(bounds)
|
.tiled_layout(bounds)
|
||||||
.ok()
|
.ok()
|
||||||
@@ -2558,6 +2637,26 @@ impl Ferese {
|
|||||||
Some(WindowPlacement::Floating { rect }) => rect,
|
Some(WindowPlacement::Floating { rect }) => rect,
|
||||||
None => return,
|
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
|
let axis = self
|
||||||
.workspaces
|
.workspaces
|
||||||
.active()
|
.active()
|
||||||
@@ -2778,6 +2877,7 @@ impl Ferese {
|
|||||||
let Some(id) = self.window_ids.get(window).copied() else {
|
let Some(id) = self.window_ids.get(window).copied() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
self.floating_resize_anchors.remove(&id);
|
||||||
let rect = Rect::new(
|
let rect = Rect::new(
|
||||||
location.x as f64,
|
location.x as f64,
|
||||||
location.y as f64,
|
location.y as f64,
|
||||||
@@ -3027,6 +3127,217 @@ impl Ferese {
|
|||||||
self.output_bounds_for(output)
|
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> {
|
fn floating_bounds_for_window(&self, window: WindowId) -> Option<Rect> {
|
||||||
let output_id = self
|
let output_id = self
|
||||||
.workspaces
|
.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> {
|
fn tiled_layout(&mut self, bounds: Rect) -> Result<LayoutResult, ferese_layout::LayoutError> {
|
||||||
let constraints = self.window_constraints();
|
let constraints = self.window_constraints();
|
||||||
let focused = self.focused_window;
|
let focused = self.focused_window;
|
||||||
|
|||||||
+27
-4
@@ -334,8 +334,15 @@ Super+Shift+S area screenshot; Print Screen whole-screen screenshot;
|
|||||||
Super+Shift+E logout confirmation.
|
Super+Shift+E logout confirmation.
|
||||||
|
|
||||||
Hold Super and drag a floating window with the left mouse button to move it.
|
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
|
Hold Super and drag with the right mouse button to resize. The starting position
|
||||||
corner. These work even when an app has no title bar or resize border.
|
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
|
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
|
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
|
At least one matcher is required; supplied matchers must all match. Rules apply
|
||||||
in order, with later fields overriding earlier ones. Dimensions imply floating
|
in order, with later fields overriding earlier ones. Dimensions imply floating
|
||||||
when `floating` is omitted. Floating apps without dimensions choose their own
|
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;
|
size. Placement first centers transients on the visible part of their parent,
|
||||||
config rule changes apply to existing windows.
|
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
|
```kdl
|
||||||
window-rule app-id="dev.ferese.Settings" floating=#true
|
window-rule app-id="dev.ferese.Settings" floating=#true
|
||||||
|
|||||||
+4
-1
@@ -9,11 +9,14 @@ guide. Your saved bindings can override these defaults.
|
|||||||
| Action | Result |
|
| Action | Result |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Super + left-button drag on a floating window | Move the window |
|
| 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 |
|
| 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.
|
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.
|
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
|
## Focus, move, and resize
|
||||||
|
|
||||||
|
|||||||
+33
-15
@@ -14,6 +14,26 @@ static struct wl_shm *shm;
|
|||||||
static struct xdg_wm_base *wm;
|
static struct xdg_wm_base *wm;
|
||||||
static struct wl_surface *surface;
|
static struct wl_surface *surface;
|
||||||
static struct wl_buffer *buffer;
|
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) {
|
static void ping(void *data, struct xdg_wm_base *base, uint32_t serial) {
|
||||||
(void)data;
|
(void)data;
|
||||||
xdg_wm_base_pong(base, serial);
|
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) {
|
static void configure(void *data, struct xdg_surface *xdg, uint32_t serial) {
|
||||||
(void)data;
|
(void)data;
|
||||||
xdg_surface_ack_configure(xdg, serial);
|
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_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);
|
wl_surface_commit(surface);
|
||||||
}
|
}
|
||||||
static const struct xdg_surface_listener surface_listener = {.configure = configure};
|
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,
|
static void size(void *data, struct xdg_toplevel *top, int32_t w, int32_t h,
|
||||||
struct wl_array *states) {
|
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) {
|
static void close_window(void *data, struct xdg_toplevel *top) {
|
||||||
(void)data; (void)top; exit(0);
|
(void)data; (void)top; exit(0);
|
||||||
}
|
}
|
||||||
static const struct xdg_toplevel_listener top_listener = {.configure = size, .close = close_window};
|
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);
|
struct wl_display *display = wl_display_connect(NULL);
|
||||||
if (!display) return 1;
|
if (!display) return 1;
|
||||||
struct wl_registry *registry = wl_display_get_registry(display);
|
struct wl_registry *registry = wl_display_get_registry(display);
|
||||||
wl_registry_add_listener(registry, ®istry_listener, NULL);
|
wl_registry_add_listener(registry, ®istry_listener, NULL);
|
||||||
if (wl_display_roundtrip(display) < 0 || !compositor || !shm || !wm) return 2;
|
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);
|
surface = wl_compositor_create_surface(compositor);
|
||||||
struct xdg_surface *xdg = xdg_wm_base_get_xdg_surface(wm, surface);
|
struct xdg_surface *xdg = xdg_wm_base_get_xdg_surface(wm, surface);
|
||||||
xdg_surface_add_listener(xdg, &surface_listener, NULL);
|
xdg_surface_add_listener(xdg, &surface_listener, NULL);
|
||||||
struct xdg_toplevel *top = xdg_surface_get_toplevel(xdg);
|
struct xdg_toplevel *top = xdg_surface_get_toplevel(xdg);
|
||||||
xdg_toplevel_add_listener(top, &top_listener, NULL);
|
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");
|
xdg_toplevel_set_title(top, "Floating size regression");
|
||||||
wl_surface_commit(surface);
|
wl_surface_commit(surface);
|
||||||
while (wl_display_dispatch(display) != -1) {}
|
while (wl_display_dispatch(display) != -1) {}
|
||||||
|
|||||||
@@ -90,9 +90,11 @@ class FloatingSizeTest(unittest.TestCase):
|
|||||||
bounds = mask.getbbox()
|
bounds = mask.getbbox()
|
||||||
self.assertIsNotNone(bounds, "client content was not rendered")
|
self.assertIsNotNone(bounds, "client content was not rendered")
|
||||||
# The shader antialiases the outermost pixel even at radius zero.
|
# 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,
|
self.assertTrue(expected - 2 <= actual <= expected,
|
||||||
f"floating content cropped: {bounds}, expected 640x480")
|
f"floating content cropped: {bounds}, expected {expected_size}")
|
||||||
finally:
|
finally:
|
||||||
process.terminate()
|
process.terminate()
|
||||||
try:
|
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()
|
||||||
Reference in new issue
Block a user