Skip to main content

pixelflux/wayland/
outclient.rs

1//! `zwlr_output_management_v1` client: sets the output scale of a nested session
2//! 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//! Compositors without the protocol (KWin offers `kde_output_management_v2`)
10//! report [`ScaleOutcome::Unsupported`]; their scale comes from the capture
11//! output, which they follow. Blocking, off the compositor thread, with every
12//! round-trip deadline-bounded.
13
14use std::os::unix::net::UnixStream;
15use std::time::Instant;
16
17use wayland_client::protocol::wl_registry;
18use wayland_client::{delegate_noop, Connection, Dispatch, EventQueue, QueueHandle};
19use wayland_protocols_wlr::output_management::v1::client::{
20    zwlr_output_configuration_head_v1::ZwlrOutputConfigurationHeadV1,
21    zwlr_output_configuration_v1::{self, ZwlrOutputConfigurationV1},
22    zwlr_output_head_v1::{self, ZwlrOutputHeadV1},
23    zwlr_output_manager_v1::{self, ZwlrOutputManagerV1},
24    zwlr_output_mode_v1::ZwlrOutputModeV1,
25};
26
27use crate::wayland::wlclient::{bounded_roundtrip, impl_sync_callback, SyncState, IO_TIMEOUT};
28
29/// What a scale request did, from the caller's point of view.
30pub enum ScaleOutcome {
31    Applied,
32    /// The compositor manages no outputs for clients: scale it another way.
33    Unsupported,
34}
35
36#[derive(Default)]
37struct OutState {
38    manager: Option<ZwlrOutputManagerV1>,
39    /// Announced heads with their name and enabled state. The manager's order
40    /// is its own; screens are addressed by name below.
41    heads: Vec<(ZwlrOutputHeadV1, Option<String>, bool)>,
42    serial: Option<u32>,
43    applied: Option<bool>,
44    sync_done: bool,
45}
46
47impl SyncState for OutState {
48    fn sync_done_mut(&mut self) -> &mut bool {
49        &mut self.sync_done
50    }
51}
52impl_sync_callback!(OutState);
53
54impl Dispatch<wl_registry::WlRegistry, ()> for OutState {
55    fn event(
56        state: &mut Self,
57        registry: &wl_registry::WlRegistry,
58        event: wl_registry::Event,
59        _: &(),
60        _: &Connection,
61        qh: &QueueHandle<Self>,
62    ) {
63        if let wl_registry::Event::Global { name, interface, version } = event {
64            if interface == "zwlr_output_manager_v1" && state.manager.is_none() {
65                state.manager = Some(registry.bind(name, version.min(4), qh, ()));
66            }
67        }
68    }
69}
70
71impl Dispatch<ZwlrOutputManagerV1, ()> for OutState {
72    fn event(
73        state: &mut Self,
74        _: &ZwlrOutputManagerV1,
75        event: zwlr_output_manager_v1::Event,
76        _: &(),
77        _: &Connection,
78        _: &QueueHandle<Self>,
79    ) {
80        match event {
81            zwlr_output_manager_v1::Event::Head { head } => {
82                state.heads.push((head, None, false))
83            }
84            // Every configuration is built against the serial of the state it
85            // was read from; a stale one is refused by the compositor.
86            zwlr_output_manager_v1::Event::Done { serial } => state.serial = Some(serial),
87            _ => {}
88        }
89    }
90
91    wayland_client::event_created_child!(OutState, ZwlrOutputManagerV1, [
92        zwlr_output_manager_v1::EVT_HEAD_OPCODE => (ZwlrOutputHeadV1, ()),
93    ]);
94}
95
96impl Dispatch<ZwlrOutputHeadV1, ()> for OutState {
97    fn event(
98        state: &mut Self,
99        head: &ZwlrOutputHeadV1,
100        event: zwlr_output_head_v1::Event,
101        _: &(),
102        _: &Connection,
103        _: &QueueHandle<Self>,
104    ) {
105        let Some(entry) = state.heads.iter_mut().find(|(h, _, _)| h == head) else {
106            return;
107        };
108        match event {
109            zwlr_output_head_v1::Event::Name { name } => entry.1 = Some(name),
110            zwlr_output_head_v1::Event::Enabled { enabled } => entry.2 = enabled != 0,
111            _ => {}
112        }
113    }
114
115    wayland_client::event_created_child!(OutState, ZwlrOutputHeadV1, [
116        zwlr_output_head_v1::EVT_MODE_OPCODE => (ZwlrOutputModeV1, ()),
117    ]);
118}
119
120impl Dispatch<ZwlrOutputConfigurationV1, ()> for OutState {
121    fn event(
122        state: &mut Self,
123        _: &ZwlrOutputConfigurationV1,
124        event: zwlr_output_configuration_v1::Event,
125        _: &(),
126        _: &Connection,
127        _: &QueueHandle<Self>,
128    ) {
129        match event {
130            zwlr_output_configuration_v1::Event::Succeeded => state.applied = Some(true),
131            zwlr_output_configuration_v1::Event::Failed
132            | zwlr_output_configuration_v1::Event::Cancelled => state.applied = Some(false),
133            _ => {}
134        }
135    }
136}
137
138delegate_noop!(OutState: ignore ZwlrOutputModeV1);
139delegate_noop!(OutState: ZwlrOutputConfigurationHeadV1);
140
141/// Set the scale of the `index`-th screen of the compositor on `socket_path`,
142/// leaving its mode and position alone. Blocking; call off the compositor's
143/// calloop thread.
144pub fn set_output_scale(
145    socket_path: &str,
146    index: usize,
147    scale: f64,
148) -> Result<ScaleOutcome, String> {
149    if !(0.1..=16.0).contains(&scale) {
150        return Err(format!("scale {scale} out of range"));
151    }
152    configure(socket_path, |heads| {
153        let target = heads
154            .get(index)
155            .cloned()
156            .ok_or_else(|| format!("no enabled screen at index {index}"))?;
157        Ok(vec![(target, Plan { scale: Some(scale), ..Plan::default() })])
158    })
159    .map(|changed| if changed == 0 { ScaleOutcome::Unsupported } else { ScaleOutcome::Applied })
160}
161
162/// Give the `index`-th screen of the compositor on `socket_path` this mode and
163/// scale in one configuration.
164///
165/// A session lays its desktop out once per applied configuration, so setting the
166/// two separately leaves it briefly at a geometry that never exists — a screen
167/// still carrying the pre-connect mode at the new scale is a fraction of its
168/// final size, and a client that does not lay out again keeps that size.
169pub fn set_screen_geometry(
170    socket_path: &str,
171    index: usize,
172    size: (i32, i32),
173    scale: f64,
174) -> Result<ScaleOutcome, String> {
175    if !(0.1..=16.0).contains(&scale) {
176        return Err(format!("scale {scale} out of range"));
177    }
178    if size.0 <= 0 || size.1 <= 0 {
179        return Err(format!("size {}x{} out of range", size.0, size.1));
180    }
181    configure(socket_path, move |heads| {
182        let target = heads
183            .get(index)
184            .cloned()
185            .ok_or_else(|| format!("no enabled screen at index {index}"))?;
186        Ok(vec![(target, Plan { mode: Some(size), scale: Some(scale) })])
187    })
188    .map(|changed| if changed == 0 { ScaleOutcome::Unsupported } else { ScaleOutcome::Applied })
189}
190
191/// Hold every screen past the first `keep` at `size`. A session compositor opens
192/// the screens it was started with whether or not anything watches them, and one
193/// held at a real screen's size stretches the session's coordinate space onto a
194/// screen nobody sees. Returns how many were resized.
195pub fn hold_spare_screens(
196    socket_path: &str,
197    keep: usize,
198    size: (i32, i32),
199) -> Result<usize, String> {
200    configure(socket_path, move |heads| {
201        Ok(heads
202            .iter()
203            .skip(keep)
204            .cloned()
205            .map(|h| (h, Plan { mode: Some(size), ..Plan::default() }))
206            .collect())
207    })
208}
209
210/// The number a screen's name ends in (WL-2 -> 2), or none, which sorts first.
211fn trailing_number(name: &str) -> u32 {
212    let digits: String = name.chars().rev().take_while(|c| c.is_ascii_digit()).collect();
213    digits.chars().rev().collect::<String>().parse().unwrap_or(0)
214}
215
216/// What a head is being asked to change; an unset field keeps its current value.
217#[derive(Clone, Copy, Default)]
218struct Plan {
219    mode: Option<(i32, i32)>,
220    scale: Option<f64>,
221}
222
223/// Apply `plan` to the compositor's enabled heads. `plan` receives them in
224/// announcement order and answers with the ones to change; heads it leaves out
225/// keep their configuration. 0 = the compositor manages no outputs, or the plan
226/// asked for nothing.
227fn configure<F>(socket_path: &str, plan: F) -> Result<usize, String>
228where
229    F: FnOnce(&[ZwlrOutputHeadV1]) -> Result<Vec<(ZwlrOutputHeadV1, Plan)>, String>,
230{
231    let stream =
232        UnixStream::connect(socket_path).map_err(|e| format!("connect {socket_path}: {e}"))?;
233    let conn = Connection::from_socket(stream).map_err(|e| format!("wayland setup: {e}"))?;
234    let mut queue: EventQueue<OutState> = conn.new_event_queue();
235    let qh = queue.handle();
236    let _registry = conn.display().get_registry(&qh, ());
237    let mut state = OutState::default();
238    bounded_roundtrip(&conn, &mut queue, &mut state)?;
239    let Some(manager) = state.manager.clone() else {
240        return Ok(0);
241    };
242    // The heads and the serial that stamps them arrive after the bind.
243    bounded_roundtrip(&conn, &mut queue, &mut state)?;
244    let serial = state.serial.ok_or("output manager sent no state serial")?;
245    // Screens in the order their names put them (WL-1, WL-2, ...), which is the
246    // order a session opens them in and so the order they map to displays; the
247    // manager's own announcement order carries no such promise.
248    let mut named: Vec<(ZwlrOutputHeadV1, String)> = state
249        .heads
250        .iter()
251        .filter(|(_, _, on)| *on)
252        .map(|(h, name, _)| (h.clone(), name.clone().unwrap_or_default()))
253        .collect();
254    named.sort_by_key(|(_, name)| (trailing_number(name), name.clone()));
255    let enabled: Vec<ZwlrOutputHeadV1> = named.into_iter().map(|(h, _)| h).collect();
256    let wanted = plan(&enabled)?;
257    if wanted.is_empty() {
258        manager.stop();
259        let _ = queue.flush();
260        return Ok(0);
261    }
262
263    // A configuration describes every head: one left out would be disabled.
264    let config = manager.create_configuration(serial, &qh, ());
265    for head in &enabled {
266        let cfg_head = config.enable_head(head, &qh, ());
267        if let Some((_, want)) = wanted.iter().find(|(h, _)| h == head) {
268            if let Some((w, h)) = want.mode {
269                cfg_head.set_custom_mode(w, h, 0);
270            }
271            if let Some(scale) = want.scale {
272                cfg_head.set_scale(scale);
273            }
274        }
275    }
276    config.apply();
277    queue.flush().map_err(|e| format!("flush configuration: {e}"))?;
278    state.applied = None;
279    let deadline = Instant::now() + IO_TIMEOUT;
280    while state.applied.is_none() && Instant::now() < deadline {
281        bounded_roundtrip(&conn, &mut queue, &mut state)?;
282    }
283    config.destroy();
284    manager.stop();
285    let _ = queue.flush();
286    match state.applied {
287        Some(true) => Ok(wanted.len()),
288        _ => Err("the compositor refused the configuration".to_string()),
289    }
290}