Skip to main content

pixelflux/wayland/
outclient.rs

1//! `zwlr_output_management_v1` client: scales and arranges the screens of a
2//! nested session compositor, in-process rather than through a `wlr-randr` fork.
3//!
4//! Applications draw larger when the compositor they are on scales its own
5//! output. Scaling pixelflux's capture output instead shrinks the logical size
6//! the session is handed, which upscales the desktop rather than enlarging its
7//! interface, so a DPI change for a nested session lands here.
8//!
9//! A session that opens one screen per capture output (a wlroots compositor
10//! under `WLR_WL_OUTPUTS`) arranges them by its own rule — side by side in the
11//! order they were opened — and that layout, not the capture one, is what
12//! places its windows and carries its pointer between screens. So the capture
13//! arrangement is mirrored into it (`set_screen_layout`), read back
14//! (`list_screens`), and the screens no capture output drives yet are held at a
15//! token size (`hold_spare_screens`) so the desktop does not lay out across a
16//! screen nobody sees. Screens are addressed by position in name order
17//! (WL-1, WL-2, ...), the order the session opened them in.
18//!
19//! Compositors without the protocol report [`ScaleOutcome::Unsupported`] and
20//! position nothing here; their scale comes from the capture output, which
21//! they follow, and a KWin session's arrangement lands through `kdeclient`
22//! (`kde_output_management_v2`) instead. Blocking, off the compositor thread,
23//! with every round-trip deadline-bounded.
24
25use std::os::unix::net::UnixStream;
26use std::time::{Duration, Instant};
27
28use wayland_client::protocol::wl_registry;
29use wayland_client::{Connection, Dispatch, EventQueue, QueueHandle, delegate_noop};
30use wayland_protocols_wlr::output_management::v1::client::{
31    zwlr_output_configuration_head_v1::ZwlrOutputConfigurationHeadV1,
32    zwlr_output_configuration_v1::{self, ZwlrOutputConfigurationV1},
33    zwlr_output_head_v1::{self, ZwlrOutputHeadV1},
34    zwlr_output_manager_v1::{self, ZwlrOutputManagerV1},
35    zwlr_output_mode_v1::{self, ZwlrOutputModeV1},
36};
37
38use crate::wayland::wlclient::{IO_TIMEOUT, SyncState, bounded_roundtrip, impl_sync_callback};
39
40/// What a scale request did, from the caller's point of view.
41pub enum ScaleOutcome {
42    Applied,
43    /// The compositor manages no outputs for clients: scale it another way.
44    Unsupported,
45}
46
47/// An announced head with its name, enabled state, layout position, and scale.
48struct Head {
49    head: ZwlrOutputHeadV1,
50    name: Option<String>,
51    enabled: bool,
52    pos: (i32, i32),
53    scale: f64,
54}
55
56/// An enabled screen as a plan sees it: its head, logical position, current mode,
57/// and scale.
58#[derive(Clone)]
59struct Screen {
60    head: ZwlrOutputHeadV1,
61    pos: (i32, i32),
62    mode: (i32, i32),
63    scale: f64,
64}
65
66/// The logical size wlroots gives a mode at a scale (each axis truncated).
67fn logical_size(mode: (i32, i32), scale: f64) -> (i32, i32) {
68    (
69        (mode.0 as f64 / scale) as i32,
70        (mode.1 as f64 / scale) as i32,
71    )
72}
73
74/// Rectangles arranged in one space, each about to take a new size: the same
75/// arrangement in the space of the new sizes. One whose edge met another's far
76/// edge meets it still; every other coordinate is kept. A session places its
77/// screens in its logical space, where a scaled screen is smaller than its mode,
78/// so an arrangement stated in capture pixels closes up around it here, and a
79/// screen changing scale carries its neighbors with it.
80pub(crate) fn close_gaps(
81    rects: &[(i32, i32, i32, i32)],
82    sizes: &[(i32, i32)],
83) -> Vec<(i32, i32, i32, i32)> {
84    let mut out: Vec<(i32, i32, i32, i32)> = rects
85        .iter()
86        .zip(sizes)
87        .map(|(r, s)| (r.0, r.1, s.0, s.1))
88        .collect();
89    // Each axis in the order of the old coordinate, so a neighbor is placed
90    // before the rectangle that follows it.
91    let mut order: Vec<usize> = (0..out.len()).collect();
92    order.sort_by_key(|&i| rects[i].0);
93    for &i in &order {
94        if let Some(&j) = order
95            .iter()
96            .find(|&&j| rects[j].0 < rects[i].0 && rects[j].0 + rects[j].2 == rects[i].0)
97        {
98            out[i].0 = out[j].0 + out[j].2;
99        }
100    }
101    order.sort_by_key(|&i| rects[i].1);
102    for &i in &order {
103        if let Some(&j) = order
104            .iter()
105            .find(|&&j| rects[j].1 < rects[i].1 && rects[j].1 + rects[j].3 == rects[i].1)
106        {
107            out[i].1 = out[j].1 + out[j].3;
108        }
109    }
110    out
111}
112
113#[derive(Default)]
114struct OutState {
115    manager: Option<ZwlrOutputManagerV1>,
116    /// The manager's order is its own; screens are addressed by name below.
117    heads: Vec<Head>,
118    /// Size per announced mode object, and the mode each head currently holds:
119    /// a head carries no size of its own, so the two are joined to report one.
120    modes: Vec<(ZwlrOutputModeV1, (i32, i32))>,
121    current: Vec<(ZwlrOutputHeadV1, ZwlrOutputModeV1)>,
122    serial: Option<u32>,
123    applied: Option<bool>,
124    /// The refusal was a cancellation: the configuration went stale under a
125    /// state change of the compositor's own, so the same plan on a fresh
126    /// serial can still land.
127    canceled: bool,
128    sync_done: bool,
129}
130
131impl SyncState for OutState {
132    fn sync_done_mut(&mut self) -> &mut bool {
133        &mut self.sync_done
134    }
135}
136
137impl OutState {
138    /// The size of the mode a head holds; `(0, 0)` where it announced none.
139    fn mode_size(&self, head: &ZwlrOutputHeadV1) -> (i32, i32) {
140        self.current
141            .iter()
142            .find(|(h, _)| h == head)
143            .and_then(|(_, mode)| self.modes.iter().find(|(m, _)| m == mode))
144            .map(|(_, size)| *size)
145            .unwrap_or((0, 0))
146    }
147
148    /// The enabled screens in the order their names put them (WL-1, WL-2, ...),
149    /// which is the order a session opens them in and so the order they map to
150    /// displays; the manager's own announcement order carries no such promise.
151    fn screens(&self) -> Vec<Screen> {
152        let mut screens: Vec<(String, Screen)> = self
153            .heads
154            .iter()
155            .filter(|h| h.enabled)
156            .map(|h| {
157                let screen = Screen {
158                    head: h.head.clone(),
159                    pos: h.pos,
160                    mode: self.mode_size(&h.head),
161                    scale: h.scale,
162                };
163                (h.name.clone().unwrap_or_default(), screen)
164            })
165            .collect();
166        screens.sort_by_key(|(name, _)| (trailing_number(name), name.clone()));
167        screens.into_iter().map(|(_, s)| s).collect()
168    }
169}
170impl_sync_callback!(OutState);
171
172impl Dispatch<wl_registry::WlRegistry, ()> for OutState {
173    fn event(
174        state: &mut Self,
175        registry: &wl_registry::WlRegistry,
176        event: wl_registry::Event,
177        _: &(),
178        _: &Connection,
179        qh: &QueueHandle<Self>,
180    ) {
181        if let wl_registry::Event::Global {
182            name,
183            interface,
184            version,
185        } = event
186            && interface == "zwlr_output_manager_v1"
187            && state.manager.is_none()
188        {
189            state.manager = Some(registry.bind(name, version.min(4), qh, ()));
190        }
191    }
192}
193
194impl Dispatch<ZwlrOutputManagerV1, ()> for OutState {
195    fn event(
196        state: &mut Self,
197        _: &ZwlrOutputManagerV1,
198        event: zwlr_output_manager_v1::Event,
199        _: &(),
200        _: &Connection,
201        _: &QueueHandle<Self>,
202    ) {
203        match event {
204            zwlr_output_manager_v1::Event::Head { head } => state.heads.push(Head {
205                head,
206                name: None,
207                enabled: false,
208                pos: (0, 0),
209                scale: 1.0,
210            }),
211            // Every configuration is built against the serial of the state it
212            // was read from; a stale one is refused by the compositor.
213            zwlr_output_manager_v1::Event::Done { serial } => state.serial = Some(serial),
214            _ => {}
215        }
216    }
217
218    wayland_client::event_created_child!(OutState, ZwlrOutputManagerV1, [
219        zwlr_output_manager_v1::EVT_HEAD_OPCODE => (ZwlrOutputHeadV1, ()),
220    ]);
221}
222
223impl Dispatch<ZwlrOutputHeadV1, ()> for OutState {
224    fn event(
225        state: &mut Self,
226        head: &ZwlrOutputHeadV1,
227        event: zwlr_output_head_v1::Event,
228        _: &(),
229        _: &Connection,
230        _: &QueueHandle<Self>,
231    ) {
232        let Some(entry) = state.heads.iter_mut().find(|h| h.head == *head) else {
233            return;
234        };
235        match event {
236            zwlr_output_head_v1::Event::Name { name } => entry.name = Some(name),
237            zwlr_output_head_v1::Event::Enabled { enabled } => entry.enabled = enabled != 0,
238            zwlr_output_head_v1::Event::Position { x, y } => entry.pos = (x, y),
239            zwlr_output_head_v1::Event::Scale { scale } => entry.scale = scale,
240            zwlr_output_head_v1::Event::CurrentMode { mode } => {
241                let head = head.clone();
242                state.current.retain(|(h, _)| *h != head);
243                state.current.push((head, mode));
244            }
245            _ => {}
246        }
247    }
248
249    wayland_client::event_created_child!(OutState, ZwlrOutputHeadV1, [
250        zwlr_output_head_v1::EVT_MODE_OPCODE => (ZwlrOutputModeV1, ()),
251    ]);
252}
253
254impl Dispatch<ZwlrOutputConfigurationV1, ()> for OutState {
255    fn event(
256        state: &mut Self,
257        _: &ZwlrOutputConfigurationV1,
258        event: zwlr_output_configuration_v1::Event,
259        _: &(),
260        _: &Connection,
261        _: &QueueHandle<Self>,
262    ) {
263        match event {
264            zwlr_output_configuration_v1::Event::Succeeded => state.applied = Some(true),
265            zwlr_output_configuration_v1::Event::Failed => state.applied = Some(false),
266            zwlr_output_configuration_v1::Event::Cancelled => {
267                state.canceled = true;
268                state.applied = Some(false);
269            }
270            _ => {}
271        }
272    }
273}
274
275impl Dispatch<ZwlrOutputModeV1, ()> for OutState {
276    fn event(
277        state: &mut Self,
278        mode: &ZwlrOutputModeV1,
279        event: zwlr_output_mode_v1::Event,
280        _: &(),
281        _: &Connection,
282        _: &QueueHandle<Self>,
283    ) {
284        if let zwlr_output_mode_v1::Event::Size { width, height } = event {
285            let mode = mode.clone();
286            state.modes.retain(|(m, _)| *m != mode);
287            state.modes.push((mode, (width, height)));
288        }
289    }
290}
291delegate_noop!(OutState: ZwlrOutputConfigurationHeadV1);
292
293/// Set the scale of the `index`-th screen of the compositor on `socket_path`,
294/// leaving its mode and position alone. Blocking; call off the compositor's
295/// calloop thread.
296pub fn set_output_scale(
297    socket_path: &str,
298    index: usize,
299    scale: f64,
300) -> Result<ScaleOutcome, String> {
301    if !(0.1..=16.0).contains(&scale) {
302        return Err(format!("scale {scale} out of range"));
303    }
304    configure(socket_path, |screens| rescale(screens, index, None, scale)).map(|changed| {
305        if changed == 0 {
306            ScaleOutcome::Unsupported
307        } else {
308            ScaleOutcome::Applied
309        }
310    })
311}
312
313/// The plan that gives screen `index` a scale, and the mode asked for, and
314/// closes the others up around the logical size it takes.
315fn rescale(
316    screens: &[Screen],
317    index: usize,
318    mode: Option<(i32, i32)>,
319    scale: f64,
320) -> Result<Vec<(ZwlrOutputHeadV1, Plan)>, String> {
321    if index >= screens.len() {
322        return Err(format!("no enabled screen at index {index}"));
323    }
324    let rects: Vec<(i32, i32, i32, i32)> = screens
325        .iter()
326        .map(|s| {
327            let (w, h) = logical_size(s.mode, s.scale);
328            (s.pos.0, s.pos.1, w, h)
329        })
330        .collect();
331    let mut sizes: Vec<(i32, i32)> = rects.iter().map(|r| (r.2, r.3)).collect();
332    sizes[index] = logical_size(mode.unwrap_or(screens[index].mode), scale);
333    let placed = close_gaps(&rects, &sizes);
334    Ok(screens
335        .iter()
336        .enumerate()
337        .filter_map(|(i, s)| {
338            let position = (placed[i].0, placed[i].1);
339            let mut plan = Plan {
340                position: (position != s.pos).then_some(position),
341                ..Plan::default()
342            };
343            if i == index {
344                plan.mode = mode;
345                plan.scale = Some(scale);
346            }
347            (i == index || plan.position.is_some()).then(|| (s.head.clone(), plan))
348        })
349        .collect())
350}
351
352/// Give the `index`-th screen of the compositor on `socket_path` this mode and
353/// scale in one configuration.
354///
355/// A session lays its desktop out once per applied configuration, so setting the
356/// two separately leaves it briefly at a geometry that never exists — a screen
357/// still carrying the pre-connect mode at the new scale is a fraction of its
358/// final size, and a client that does not lay out again keeps that size.
359pub fn set_screen_geometry(
360    socket_path: &str,
361    index: usize,
362    size: (i32, i32),
363    scale: f64,
364) -> Result<ScaleOutcome, String> {
365    if !(0.1..=16.0).contains(&scale) {
366        return Err(format!("scale {scale} out of range"));
367    }
368    if size.0 <= 0 || size.1 <= 0 {
369        return Err(format!("size {}x{} out of range", size.0, size.1));
370    }
371    configure(socket_path, move |screens| {
372        rescale(screens, index, Some(size), scale)
373    })
374    .map(|changed| {
375        if changed == 0 {
376            ScaleOutcome::Unsupported
377        } else {
378            ScaleOutcome::Applied
379        }
380    })
381}
382
383/// The session's enabled screens as `(name, x, y, width, height)`, in screen
384/// order — what the compositor actually did with a layout and a mode, which is
385/// not always what it was asked for. A head carries no size itself, so the size
386/// is its current mode's; `(0, 0)` where it announced none. Empty when the
387/// compositor manages no outputs for clients.
388pub fn list_screens(socket_path: &str) -> Result<Vec<super::AppScreen>, String> {
389    let stream =
390        UnixStream::connect(socket_path).map_err(|e| format!("connect {socket_path}: {e}"))?;
391    let conn = Connection::from_socket(stream).map_err(|e| format!("wayland setup: {e}"))?;
392    let mut queue: EventQueue<OutState> = conn.new_event_queue();
393    let qh = queue.handle();
394    let _registry = conn.display().get_registry(&qh, ());
395    let mut state = OutState::default();
396    bounded_roundtrip(&conn, &mut queue, &mut state)?;
397    let Some(manager) = state.manager.clone() else {
398        return Ok(Vec::new());
399    };
400    bounded_roundtrip(&conn, &mut queue, &mut state)?;
401    let mut screens: Vec<(String, i32, i32, i32, i32)> = state
402        .heads
403        .iter()
404        .filter(|h| h.enabled)
405        .map(|h| {
406            let (w, hgt) = state.mode_size(&h.head);
407            (h.name.clone().unwrap_or_default(), h.pos.0, h.pos.1, w, hgt)
408        })
409        .collect();
410    screens.sort_by_key(|(name, _, _, _, _)| (trailing_number(name), name.clone()));
411    manager.stop();
412    let _ = queue.flush();
413    Ok(screens)
414}
415
416/// Lay the session's screens out at `rects`, one `(x, y, width, height)` per
417/// screen in screen order, so its own layout matches the arrangement the capture
418/// outputs were placed in.
419///
420/// A session compositor arranges the screens it opens by its own rule — wlroots
421/// puts them side by side in the order they appear — and that layout, not the
422/// capture one, is what places windows and carries the pointer between screens.
423/// Every screen is positioned in one configuration: applied one at a time, an
424/// intermediate state overlaps two screens and the compositor reflows around it.
425/// The rectangles are capture pixels; the positions land in the session's logical
426/// space, where a scaled screen is its mode over its scale, closed up so a screen
427/// beside a scaled one meets its logical edge rather than its pixel edge.
428///
429/// Returns how many screens were positioned; 0 when the compositor manages no
430/// outputs for clients, which is where a session on KWin lands (it offers
431/// `kde_output_management_v2` instead) and where the arrangement stays whatever
432/// that compositor chose.
433pub fn set_screen_layout(
434    socket_path: &str,
435    rects: Vec<(i32, i32, i32, i32)>,
436) -> Result<usize, String> {
437    if rects.iter().any(|(_, _, w, h)| *w <= 0 || *h <= 0) {
438        return Err("a screen rectangle has a non-positive size".to_string());
439    }
440    configure(socket_path, move |screens| {
441        let n = screens.len().min(rects.len());
442        let sizes: Vec<(i32, i32)> = screens
443            .iter()
444            .zip(&rects)
445            .map(|(s, r)| logical_size((r.2, r.3), s.scale))
446            .collect();
447        let placed = close_gaps(&rects[..n], &sizes);
448        Ok(screens
449            .iter()
450            .zip(&rects)
451            .zip(placed)
452            .map(|((s, r), p)| {
453                (
454                    s.head.clone(),
455                    Plan {
456                        mode: Some((r.2, r.3)),
457                        position: Some((p.0, p.1)),
458                        ..Plan::default()
459                    },
460                )
461            })
462            .collect())
463    })
464}
465
466/// Hold every screen past the first `keep` at `size`. A session compositor opens
467/// the screens it was started with whether or not anything watches them, and one
468/// held at a real screen's size stretches the session's coordinate space onto a
469/// screen nobody sees. Returns how many were resized.
470pub fn hold_spare_screens(
471    socket_path: &str,
472    keep: usize,
473    size: (i32, i32),
474) -> Result<usize, String> {
475    configure(socket_path, move |screens| {
476        Ok(screens
477            .iter()
478            .skip(keep)
479            .map(|s| {
480                (
481                    s.head.clone(),
482                    Plan {
483                        mode: Some(size),
484                        ..Plan::default()
485                    },
486                )
487            })
488            .collect())
489    })
490}
491
492/// The number a screen's name ends in (WL-2 -> 2), or none, which sorts first.
493pub(crate) fn trailing_number(name: &str) -> u32 {
494    let digits: String = name
495        .chars()
496        .rev()
497        .take_while(|c| c.is_ascii_digit())
498        .collect();
499    digits
500        .chars()
501        .rev()
502        .collect::<String>()
503        .parse()
504        .unwrap_or(0)
505}
506
507/// What a head is being asked to change; an unset field keeps its current value.
508#[derive(Clone, Copy, Default)]
509struct Plan {
510    mode: Option<(i32, i32)>,
511    scale: Option<f64>,
512    position: Option<(i32, i32)>,
513}
514
515/// Why a configuration did not land.
516enum ConfigErr {
517    /// The compositor canceled it, which says only that its own state moved
518    /// while the configuration was alive. The serial is what went stale, not
519    /// the plan.
520    Stale,
521    Other(String),
522}
523
524impl From<String> for ConfigErr {
525    fn from(e: String) -> Self {
526        ConfigErr::Other(e)
527    }
528}
529
530/// Rebuilds a canceled configuration until this much time is spent. A screen
531/// arriving or leaving cancels whatever is in flight, and that is the same
532/// moment a display's mode and scale are being applied. Cancellations arrive in
533/// bursts, so the budget is a duration rather than a count of attempts: a count
534/// that outlasts a burst on one machine is spent inside one on a busier machine,
535/// where the attempts slow down and the burst does not. It can be generous
536/// because the caller is off the event loop with the GIL released, while giving
537/// up leaves the display on a capture-side scale until something asks again.
538const CONFIGURE_BUDGET: Duration = Duration::from_secs(1);
539
540/// Apply `plan` to the compositor's enabled screens, retrying while the
541/// compositor cancels. `plan` receives them in screen order and answers with
542/// the heads to change; heads it leaves out keep their configuration. It is
543/// called once per attempt, against the screens of that attempt. 0 = the
544/// compositor manages no outputs, or the plan asked for nothing.
545fn configure<F>(socket_path: &str, plan: F) -> Result<usize, String>
546where
547    F: Fn(&[Screen]) -> Result<Vec<(ZwlrOutputHeadV1, Plan)>, String>,
548{
549    let deadline = Instant::now() + CONFIGURE_BUDGET;
550    let mut stale = 0;
551    loop {
552        match configure_once(socket_path, &plan) {
553            Ok(applied) => return Ok(applied),
554            Err(ConfigErr::Other(e)) => return Err(e),
555            Err(ConfigErr::Stale) => stale += 1,
556        }
557        if Instant::now() >= deadline {
558            return Err(format!(
559                "the compositor canceled the configuration {stale} times"
560            ));
561        }
562    }
563}
564
565/// One attempt: a connection of its own, the heads and the serial that
566/// stamps them, and `plan` applied against that state. A retry reconnects
567/// rather than waiting for a fresh serial on the one it has, because the change
568/// that canceled it is often a head arriving or leaving, and the heads a kept
569/// connection holds do not survive that.
570fn configure_once<F>(socket_path: &str, plan: &F) -> Result<usize, ConfigErr>
571where
572    F: Fn(&[Screen]) -> Result<Vec<(ZwlrOutputHeadV1, Plan)>, String>,
573{
574    let stream =
575        UnixStream::connect(socket_path).map_err(|e| format!("connect {socket_path}: {e}"))?;
576    let conn = Connection::from_socket(stream).map_err(|e| format!("wayland setup: {e}"))?;
577    let mut queue: EventQueue<OutState> = conn.new_event_queue();
578    let qh = queue.handle();
579    let _registry = conn.display().get_registry(&qh, ());
580    let mut state = OutState::default();
581    bounded_roundtrip(&conn, &mut queue, &mut state)?;
582    let Some(manager) = state.manager.clone() else {
583        return Ok(0);
584    };
585    // The heads and the serial that stamps them arrive after the bind.
586    bounded_roundtrip(&conn, &mut queue, &mut state)?;
587    let serial = state
588        .serial
589        .ok_or_else(|| "output manager sent no state serial".to_string())?;
590    let screens = state.screens();
591    let wanted = plan(&screens)?;
592    if wanted.is_empty() {
593        manager.stop();
594        let _ = queue.flush();
595        return Ok(0);
596    }
597
598    // A configuration describes every head: one left out would be disabled.
599    let config = manager.create_configuration(serial, &qh, ());
600    for screen in &screens {
601        let cfg_head = config.enable_head(&screen.head, &qh, ());
602        if let Some((_, want)) = wanted.iter().find(|(h, _)| *h == screen.head) {
603            if let Some((w, h)) = want.mode {
604                cfg_head.set_custom_mode(w, h, 0);
605            }
606            if let Some(scale) = want.scale {
607                cfg_head.set_scale(scale);
608            }
609            if let Some((x, y)) = want.position {
610                cfg_head.set_position(x, y);
611            }
612        }
613    }
614    config.apply();
615    queue
616        .flush()
617        .map_err(|e| format!("flush configuration: {e}"))?;
618    state.applied = None;
619    let deadline = Instant::now() + IO_TIMEOUT;
620    while state.applied.is_none() && Instant::now() < deadline {
621        bounded_roundtrip(&conn, &mut queue, &mut state)?;
622    }
623    config.destroy();
624    manager.stop();
625    let _ = queue.flush();
626    match state.applied {
627        Some(true) => Ok(wanted.len()),
628        _ if state.canceled => Err(ConfigErr::Stale),
629        _ => Err(ConfigErr::Other(
630            "the compositor refused the configuration".to_string(),
631        )),
632    }
633}
634
635#[cfg(test)]
636mod tests {
637    use super::close_gaps;
638
639    /// The arrangement of the capture outputs, in pixels, becomes the session's
640    /// logical one: a screen at scale 2 is half its mode there, and whichever
641    /// screen sat against it closes up to that edge, on either axis and down a
642    /// chain; nothing moves at scale 1.
643    #[test]
644    fn scaled_screens_close_up() {
645        let primary_right = [(3024, 0, 1920, 992), (0, 0, 3024, 1764)];
646        assert_eq!(
647            close_gaps(&primary_right, &[(1920, 992), (1512, 882)]),
648            vec![(1512, 0, 1920, 992), (0, 0, 1512, 882)]
649        );
650        let primary_left = [(0, 0, 1920, 1080), (1920, 0, 1920, 1080)];
651        assert_eq!(
652            close_gaps(&primary_left, &[(960, 540), (1920, 1080)]),
653            vec![(0, 0, 960, 540), (960, 0, 1920, 1080)]
654        );
655        assert_eq!(
656            close_gaps(&primary_left, &[(1920, 1080), (1920, 1080)]),
657            primary_left.to_vec()
658        );
659        let below = [(0, 1764, 1920, 992), (0, 0, 3024, 1764)];
660        assert_eq!(
661            close_gaps(&below, &[(1920, 992), (1512, 882)]),
662            vec![(0, 882, 1920, 992), (0, 0, 1512, 882)]
663        );
664        let chain = [
665            (0, 0, 1000, 500),
666            (1000, 0, 1000, 500),
667            (2000, 0, 1000, 500),
668        ];
669        assert_eq!(
670            close_gaps(&chain, &[(500, 250), (500, 250), (1000, 500)]),
671            vec![(0, 0, 500, 250), (500, 0, 500, 250), (1000, 0, 1000, 500)]
672        );
673    }
674}