Split the client into modules, and clear out what iteration left behind

main.rs had grown to 1290 lines holding everything: the CLI, the client
state, the capture engine, the drawing, the input handling and twelve
Dispatch impls. It is now orchestration only, and the concerns live where
you would look for them — cli, app, capture, overlay — with the module
docs saying what each owns. No behaviour changed; the code moved.

Iterating in response to review left residue, now gone:

- Tile::handle was only ever written. Dropping a wayland-rs proxy does
  not destroy the object, so nothing needed it held.
- Tile::failed likewise: an earlier captures_settled() read it, and
  `settled` is what everything waits on now.
- A blanket #[allow(dead_code)] on Tile hid both of those. Fields are
  pub(crate) rather than pub so the lint keeps working.
- Tile's doc comment had drifted onto the Format enum during a patch.
- Three consecutive `if args.verbose` blocks became describe() and
  report(), and the loose ticks/releases/starved/pool_bytes counters
  became one Stats. `releases` went: frames already imply it.
- quit + quit_why + activate became one Ending enum and picked, so
  "closed by the compositor" is a state rather than a string.
- App::new took seven positional arguments, two of them bare integers in
  a row; the four that always travel together are now Settings.
- sway::scale and the display listing each called get_outputs; one call
  does both.
- scaled(rect, n) became Rect::scaled(n), and Format lives with Target
  where render() dispatches on it.

README had drifted too: the usage line still advertised --print, which
no longer exists, and omitted --format and --no-outputs. Its flag list is
now checked against --help, the memory figures are re-measured, and there
is a source layout for anyone arriving cold.
This commit is contained in:
Milad Alizadeh
2026-08-23 19:17:04 +01:00
parent 5b98615c41
commit 8d8a27c4c1
10 changed files with 1446 additions and 1265 deletions
+326
View File
@@ -0,0 +1,326 @@
//! The client: everything the compositor talks to us through.
//!
//! `App` is the one state the Wayland event queue dispatches into, so it holds
//! the bound globals, the tiles, and the pieces of the overlay. The work itself
//! lives next door: capture.rs drives the sessions, overlay.rs draws and reads
//! the keyboard.
use std::error::Error;
use wayland_client::globals::{GlobalList, GlobalListContents};
use wayland_client::protocol::{
wl_buffer::WlBuffer,
wl_compositor::WlCompositor,
wl_output::{self, WlOutput},
wl_registry::WlRegistry,
wl_seat::WlSeat,
wl_shm::WlShm,
wl_shm_pool::WlShmPool,
wl_subcompositor::WlSubcompositor,
wl_subsurface::WlSubsurface,
wl_surface::WlSurface,
};
use wayland_client::{
Connection, Dispatch, Proxy, QueueHandle, delegate_noop, event_created_child,
};
use wayland_protocols::ext::foreign_toplevel_list::v1::client::{
ext_foreign_toplevel_handle_v1::{self, ExtForeignToplevelHandleV1},
ext_foreign_toplevel_list_v1::{self, ExtForeignToplevelListV1},
};
use wayland_protocols::ext::image_capture_source::v1::client::{
ext_foreign_toplevel_image_capture_source_manager_v1::ExtForeignToplevelImageCaptureSourceManagerV1,
ext_image_capture_source_v1::ExtImageCaptureSourceV1,
ext_output_image_capture_source_manager_v1::ExtOutputImageCaptureSourceManagerV1,
};
use wayland_protocols::ext::image_copy_capture::v1::client::ext_image_copy_capture_manager_v1::ExtImageCopyCaptureManagerV1;
use wayland_protocols::wp::viewporter::client::{
wp_viewport::WpViewport, wp_viewporter::WpViewporter,
};
use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_shell_v1::ZwlrLayerShellV1;
use crate::capture::{Live, Tile};
use crate::shm;
use crate::target::Target;
use crate::text;
use crate::theme::{Layout, Theme};
/// What the caller decided before any of this started: the look, and how much
/// live capture to do. Passed as one value because `theme`, `live`, `fps` and
/// `scale` all arrive together and two of them are bare integers.
pub struct Settings {
pub theme: Theme,
pub live: Live,
pub fps: u32,
/// Integer output scale the overlay renders at.
pub scale: i32,
}
pub struct App {
pub(crate) compositor: WlCompositor,
pub(crate) subcompositor: WlSubcompositor,
pub(crate) shm: WlShm,
pub(crate) viewporter: WpViewporter,
pub(crate) layer_shell: ZwlrLayerShellV1,
pub(crate) copy_mgr: ExtImageCopyCaptureManagerV1,
pub(crate) src_mgr: ExtForeignToplevelImageCaptureSourceManagerV1,
/// Toplevel handles as the compositor announces them, paired with the
/// identifier that joins them to sway's tree.
pub(crate) toplevels: Vec<(ExtForeignToplevelHandleV1, String)>,
/// Displays, paired with the name the compositor gives them (wl_output v4).
pub(crate) outputs: Vec<(WlOutput, String)>,
pub(crate) output_src_mgr: Option<ExtOutputImageCaptureSourceManagerV1>,
pub(crate) tiles: Vec<Tile>,
pub(crate) theme: Theme,
pub(crate) layout: Layout,
pub(crate) live: Live,
pub(crate) fps: u32,
pub(crate) scale: i32,
pub(crate) sel: usize,
pub(crate) shift: bool,
pub(crate) labels: Option<text::Labels>,
pub(crate) surface: Option<WlSurface>,
pub(crate) chrome: Option<shm::Chrome>,
pub(crate) chrome_buffers: Vec<WlBuffer>,
pub(crate) configured: bool,
pub(crate) ending: Ending,
pub(crate) picked: Option<Target>,
pub(crate) stats: Stats,
}
/// Counters worth reporting with --verbose. Live capture is easy to get subtly
/// wrong — a starved buffer pool or a clock that never ticks both look like
/// "nothing updates" — so the numbers that distinguish those stay available.
#[derive(Default)]
pub struct Stats {
/// Frame callbacks received, i.e. how often the live clock fired.
pub(crate) ticks: u32,
/// Re-captures skipped because every buffer was still held.
pub(crate) starved: u32,
/// Bytes of shm handed to the compositor for capture buffers.
pub(crate) pool_bytes: usize,
}
/// Why the overlay stopped, for --verbose. An exit status of 1 cannot tell a
/// cancel from a surface the compositor took away.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Ending {
Running,
Picked,
Cancelled,
Closed,
}
impl Ending {
pub fn as_str(self) -> &'static str {
match self {
Ending::Running => "still running",
Ending::Picked => "picked",
Ending::Cancelled => "cancelled",
Ending::Closed => "the compositor closed the overlay",
}
}
}
impl App {
pub fn new(
globals: &GlobalList,
qh: &QueueHandle<Self>,
targets: Vec<Target>,
settings: Settings,
) -> Result<Self, Box<dyn Error>> {
let Settings {
theme,
live,
fps,
scale,
} = settings;
let layout = Layout::new(&theme, targets.len() as i32);
// Bind everything up front so a compositor missing a protocol fails
// here, with a name, rather than halfway through a capture.
let mut app = Self {
compositor: globals.bind(qh, 1..=6, ())?,
subcompositor: globals.bind(qh, 1..=1, ())?,
shm: globals.bind(qh, 1..=1, ())?,
viewporter: globals.bind(qh, 1..=1, ())?,
layer_shell: globals.bind(qh, 1..=5, ())?,
copy_mgr: globals.bind(qh, 1..=1, ())?,
src_mgr: globals.bind(qh, 1..=1, ())?,
toplevels: Vec::new(),
outputs: Vec::new(),
// Optional: a compositor without it simply gets no display tiles.
output_src_mgr: globals.bind(qh, 1..=1, ()).ok(),
tiles: targets.into_iter().map(Tile::new).collect(),
theme,
layout,
live,
fps,
scale,
sel: 0,
shift: false,
labels: None,
surface: None,
chrome: None,
chrome_buffers: Vec::new(),
configured: false,
ending: Ending::Running,
picked: None,
stats: Stats::default(),
};
let _: ExtForeignToplevelListV1 = globals.bind(qh, 1..=1, ())?;
// One wl_output per display, bound at v4 so it tells us its name.
for global in globals.contents().clone_list() {
if global.interface == WlOutput::interface().name {
let version = global.version.min(4);
if version >= 4 {
let output: WlOutput = globals.registry().bind(global.name, version, qh, ());
app.outputs.push((output, String::new()));
}
}
}
let _: WlSeat = globals.bind(qh, 1..=7, ())?;
Ok(app)
}
pub fn finished(&self) -> bool {
self.ending != Ending::Running
}
/// The pick, if there was one.
pub fn picked(&self) -> Option<&Target> {
self.picked.as_ref()
}
/// What the grid is about to show: one line per tile, then the geometry.
pub fn describe(&self) {
for (i, t) in self.tiles.iter().enumerate() {
let mark = if t.ready { "" } else { " (no thumbnail)" };
eprintln!(" [{i}] {}{mark}", t.target.tsv());
}
eprintln!(
"wl-pick: {} tile(s), {} captured; grid {}x{}, surface {}x{} logical \
at scale {}, {} MB of capture buffers",
self.tiles.len(),
self.tiles.iter().filter(|t| t.ready).count(),
self.layout.cols,
self.layout.rows,
self.layout.width,
self.layout.height,
self.scale,
self.stats.pool_bytes >> 20,
);
}
/// What live capture actually did, once the overlay is closing.
pub fn report(&self, open_for: std::time::Duration) {
let frames: u32 = self.tiles.iter().map(|t| t.frames).sum();
let secs = open_for.as_secs_f64();
eprintln!(
"wl-pick: {}, {frames} frame(s) over {secs:.1}s = {:.1}/s, {} tick(s), \
{} starved; per tile: {}",
self.ending.as_str(),
frames as f64 / secs,
self.stats.ticks,
self.stats.starved,
self.tiles
.iter()
.map(|t| t.frames.to_string())
.collect::<Vec<_>>()
.join(",")
);
}
pub fn captures_settled(&self) -> bool {
self.tiles.iter().all(|t| t.settled)
}
}
// --- enumeration ----------------------------------------------------------
impl Dispatch<WlRegistry, GlobalListContents> for App {
fn event(
_: &mut Self,
_: &WlRegistry,
_: <WlRegistry as Proxy>::Event,
_: &GlobalListContents,
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<ExtForeignToplevelListV1, ()> for App {
fn event(
app: &mut Self,
_: &ExtForeignToplevelListV1,
event: ext_foreign_toplevel_list_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let ext_foreign_toplevel_list_v1::Event::Toplevel { toplevel } = event {
app.toplevels.push((toplevel, String::new()));
}
}
event_created_child!(App, ExtForeignToplevelListV1, [
ext_foreign_toplevel_list_v1::EVT_TOPLEVEL_OPCODE => (ExtForeignToplevelHandleV1, ()),
]);
}
impl Dispatch<ExtForeignToplevelHandleV1, ()> for App {
fn event(
app: &mut Self,
handle: &ExtForeignToplevelHandleV1,
event: ext_foreign_toplevel_handle_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let ext_foreign_toplevel_handle_v1::Event::Identifier { identifier } = event
&& let Some(entry) = app.toplevels.iter_mut().find(|(h, _)| h == handle)
{
entry.1 = identifier;
}
}
}
/// wl_output tells us its name (v4), which is how a display tile is labelled
/// and how `focus output NAME` finds it again.
impl Dispatch<WlOutput, ()> for App {
fn event(
app: &mut Self,
output: &WlOutput,
event: wl_output::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_output::Event::Name { name } = event
&& let Some(entry) = app.outputs.iter_mut().find(|(o, _)| o == output)
{
entry.1 = name;
}
}
}
// Interfaces we drive but never listen to.
delegate_noop!(App: WlCompositor);
delegate_noop!(App: WlSubcompositor);
delegate_noop!(App: WlSubsurface);
delegate_noop!(App: ignore WlShm);
delegate_noop!(App: WlShmPool);
delegate_noop!(App: WpViewporter);
delegate_noop!(App: WpViewport);
delegate_noop!(App: ZwlrLayerShellV1);
delegate_noop!(App: ExtImageCopyCaptureManagerV1);
delegate_noop!(App: ExtForeignToplevelImageCaptureSourceManagerV1);
delegate_noop!(App: ExtImageCaptureSourceV1);
delegate_noop!(App: ExtOutputImageCaptureSourceManagerV1);
delegate_noop!(App: ignore WlSurface);
// The chrome's own buffers: two slots alternating on keypresses, so their
// release timing does not matter.
delegate_noop!(App: ignore WlBuffer);