diff --git a/apps/nescope/src/handlers.rs b/apps/nescope/src/handlers.rs index c75212df..7c5f6886 100644 --- a/apps/nescope/src/handlers.rs +++ b/apps/nescope/src/handlers.rs @@ -537,14 +537,24 @@ impl XwmHandler for CalloopData { h: Option, _reorder: Option, ) { - // Honor size requests within reason but always keep position at (0,0). - let mut geo = window.geometry(); - if let Some(w) = w { - geo.size.w = w as i32; + // Honor size requests, but never past the output: a window larger + // than it is cut off, and the pointer cannot reach the cut part. The + // output keeps following the client's window instead. + if window.is_override_redirect() { + return; } - if let Some(h) = h { - geo.size.h = h as i32; + let (req_w, req_h) = (w, h); + let cur = window.geometry(); + let (x, y, w, h) = crate::state::clamp_to_output( + (cur.loc.x, cur.loc.y, cur.size.w, cur.size.h), + (w, h), + (self.state.width as i32, self.state.height as i32), + ); + let asked = (req_w, req_h); + if req_w.is_some_and(|v| v as i32 != w) || req_h.is_some_and(|v| v as i32 != h) { + self.state.note_clamp(window.window_id(), asked, (w, h)); } + let geo = Rectangle::new((x, y).into(), (w, h).into()); let _ = window.configure(geo); } diff --git a/apps/nescope/src/state.rs b/apps/nescope/src/state.rs index 622dcd04..4915d636 100644 --- a/apps/nescope/src/state.rs +++ b/apps/nescope/src/state.rs @@ -91,6 +91,43 @@ impl WinKey { } } +/// A window's geometry after it asked for `want` (either side may be absent), +/// held inside an `output` of its own size: no larger, and moved back in if +/// the request would put part of it outside. Pure so it can be tested. +pub fn clamp_to_output( + cur: (i32, i32, i32, i32), + want: (Option, Option), + output: (i32, i32), +) -> (i32, i32, i32, i32) { + let side = |req: Option, cur: i32, max: i32| { + let v = req.map_or(cur, |v| v.min(i32::MAX as u32) as i32); + v.clamp(1, max.max(1)) + }; + let w = side(want.0, cur.2, output.0); + let h = side(want.1, cur.3, output.1); + let x = cur.0.clamp(0, (output.0 - w).max(0)); + let y = cur.1.clamp(0, (output.1 - h).max(0)); + (x, y, w, h) +} + +impl NescopeState { + /// Say a request was clamped. A window that keeps asking is fighting the + /// configure: warn once when the run passes 50 inside 5 seconds. + pub fn note_clamp(&mut self, id: u32, asked: (Option, Option), got: (i32, i32)) { + let now = std::time::Instant::now(); + let run = self.clamp_runs.entry(id).or_insert((now, 0)); + if now.duration_since(run.0) > std::time::Duration::from_secs(5) { + *run = (now, 0); + } + run.1 += 1; + let n = run.1; + tracing::debug!(window = id, ?asked, clamped = ?got, repeats = n, "configure_request clamped"); + if n == 50 { + tracing::warn!(window = id, ?asked, clamped = ?got, "window keeps asking past the output: configure ping-pong?"); + } + } +} + pub struct CalloopData { pub state: NescopeState, pub display: Display, @@ -239,6 +276,8 @@ pub struct NescopeState { /// counted for, ref(d-0036): a client's window can shrink the output /// below it, never grow it past. pub launch_size: (u32, u32), + /// Per X11 window: when its run of clamped requests began and how many. + pub clamp_runs: std::collections::HashMap, pub fps: u32, // ── Lifecycle ───────────────────────────────────────────────────────── @@ -376,6 +415,7 @@ impl NescopeState { width, height, launch_size: (width, height), + clamp_runs: std::collections::HashMap::new(), fps, loop_handle, game_launched: false, @@ -1213,3 +1253,43 @@ mod fit_output_tests { assert_eq!(fit_output((10, 10), (1920, 1080)), (320, 320)); } } + +#[cfg(test)] +mod clamp_to_output_tests { + use super::clamp_to_output; + + const OUT: (i32, i32) = (1280, 720); + + #[test] + fn a_window_that_fits_is_left_alone() { + assert_eq!(clamp_to_output((0, 0, 800, 600), (Some(1000), Some(700)), OUT), (0, 0, 1000, 700)); + } + + #[test] + fn a_launch_size_game_is_held_to_the_shrunk_output() { + assert_eq!(clamp_to_output((0, 0, 1280, 720), (Some(1920), Some(1080)), OUT), (0, 0, 1280, 720)); + } + + #[test] + fn a_width_only_request_leaves_the_height() { + assert_eq!(clamp_to_output((0, 0, 1280, 500), (Some(1920), None), OUT), (0, 0, 1280, 500)); + } + + #[test] + fn a_height_only_request_leaves_the_width() { + assert_eq!(clamp_to_output((0, 0, 640, 720), (None, Some(2000)), OUT), (0, 0, 640, 720)); + } + + #[test] + fn a_window_pushed_past_the_edge_is_moved_back() { + assert_eq!(clamp_to_output((900, 500, 400, 300), (None, None), OUT), (880, 420, 400, 300)); + } + + #[test] + fn each_window_is_clamped_on_its_own() { + let a = clamp_to_output((0, 0, 1920, 1080), (None, None), OUT); + let b = clamp_to_output((100, 100, 300, 200), (Some(5000), None), OUT); + assert_eq!(a, (0, 0, 1280, 720)); + assert_eq!(b, (0, 100, 1280, 200)); + } +}