//! `quibblectl` — the command-line remote control for the quibble compositor. //! //! Each subcommand maps to a single [`quibble_proto::Request`] sent over the //! compositor's control socket; the reply is printed (or, for screenshots, //! written out as a PNG). use std::io::{Read, Write}; use std::os::unix::net::UnixStream; use std::path::PathBuf; use anyhow::{Context, Result, bail}; use clap::{Parser, Subcommand, ValueEnum}; use quibble_proto::{Axis, ButtonState, KeyState, Layout, Rect, Request, Response}; #[derive(Parser)] #[command( name = "quibblectl", about = "Remote control for the quibble headless Wayland compositor" )] struct Cli { /// Path to the compositor control socket. Defaults to $QUIBBLE_SOCKET, or a /// path derived from $WAYLAND_DISPLAY. #[arg(long, global = true)] socket: Option, #[command(subcommand)] command: Command, } #[derive(Subcommand)] enum Command { /// Check that the compositor is alive. Ping, /// Capture a screenshot to a PNG file (or stdout with `-o -`). Screenshot { /// Output file, or `-` for stdout. #[arg(short, long, default_value = "screenshot.png")] output: String, /// Restrict to a region `X,Y,W,H` in logical coordinates. #[arg(long)] region: Option, }, /// List all mapped windows. ListWindows, /// Block until a window matching the filters maps. WaitWindow { #[arg(long)] app_id: Option, #[arg(long)] title: Option, #[arg(long, default_value_t = 5000)] timeout_ms: u64, }, /// Give keyboard focus to a window. Focus { id: u64 }, /// Move a window (floating layout). Move { id: u64, x: i32, y: i32 }, /// Resize a window (floating layout). Resize { id: u64, w: i32, h: i32 }, /// Switch the layout policy. Layout { layout: LayoutArg }, /// Resize/rescale the virtual output. SetOutput { width: i32, height: i32, #[arg(long, default_value_t = 1.0)] scale: f64, }, /// Move the pointer to an absolute location. PointerMove { x: f64, y: f64 }, /// Press or release a pointer button (evdev code; default left=272). PointerButton { state: PressArg, #[arg(default_value_t = 272)] button: u32, }, /// Click (press then release) a pointer button. Click { #[arg(default_value_t = 272)] button: u32, }, /// Emit a scroll event. Axis { axis: AxisArg, value: f64 }, /// Press or release a key (raw evdev keycode). Key { keycode: u32, state: PressArg }, /// Type a UTF-8 string. Type { text: String }, /// Begin a touch contact. TouchDown { slot: u32, x: f64, y: f64 }, /// Move a touch contact. TouchMotion { slot: u32, x: f64, y: f64 }, /// End a touch contact. TouchUp { slot: u32 }, /// Cancel all touch contacts. TouchCancel, /// Read the clipboard selection. ClipboardGet, /// Set the clipboard selection. ClipboardSet { text: String }, /// Open or close the monitor window. Monitor { state: OnOffArg }, /// Ask the compositor to shut down. Quit, } #[derive(Clone, Copy, ValueEnum)] enum LayoutArg { Tiled, Floating, } #[derive(Clone, Copy, ValueEnum)] enum PressArg { Press, Release, } #[derive(Clone, Copy, ValueEnum)] enum AxisArg { Vertical, Horizontal, } #[derive(Clone, Copy, ValueEnum)] enum OnOffArg { On, Off, } fn main() -> Result<()> { let cli = Cli::parse(); let socket = resolve_socket(cli.socket)?; // Some subcommands expand into more than one request (e.g. click). let requests = build_requests(&cli.command)?; for request in requests { let response = round_trip(&socket, &request)?; handle_response(&cli.command, response)?; } Ok(()) } /// Locate the control socket from the flag, `$QUIBBLE_SOCKET`, or /// `$WAYLAND_DISPLAY`. fn resolve_socket(explicit: Option) -> Result { if let Some(path) = explicit { return Ok(path); } if let Some(path) = std::env::var_os("QUIBBLE_SOCKET") { return Ok(PathBuf::from(path)); } if let Ok(display) = std::env::var("WAYLAND_DISPLAY") { let dir = std::env::var_os("XDG_RUNTIME_DIR") .map(PathBuf::from) .unwrap_or_else(std::env::temp_dir); return Ok(dir.join(format!("quibble-{display}.sock"))); } bail!("no control socket: pass --socket, or set QUIBBLE_SOCKET or WAYLAND_DISPLAY") } fn build_requests(command: &Command) -> Result> { let one = |r| Ok(vec![r]); match command { Command::Ping => one(Request::Ping), Command::Screenshot { region, .. } => one(Request::Screenshot { region: region.as_deref().map(parse_region).transpose()?, }), Command::ListWindows => one(Request::ListWindows), Command::WaitWindow { app_id, title, timeout_ms, } => one(Request::WaitForWindow { app_id: app_id.clone(), title: title.clone(), timeout_ms: *timeout_ms, }), Command::Focus { id } => one(Request::FocusWindow { id: quibble_proto::WindowId(*id), }), Command::Move { id, x, y } => one(Request::MoveWindow { id: quibble_proto::WindowId(*id), x: *x, y: *y, }), Command::Resize { id, w, h } => one(Request::ResizeWindow { id: quibble_proto::WindowId(*id), w: *w, h: *h, }), Command::Layout { layout } => one(Request::SetLayout { layout: match layout { LayoutArg::Tiled => Layout::Tiled, LayoutArg::Floating => Layout::Floating, }, }), Command::SetOutput { width, height, scale, } => one(Request::SetOutput { width: *width, height: *height, scale: *scale, }), Command::PointerMove { x, y } => one(Request::PointerMotion { x: *x, y: *y }), Command::PointerButton { state, button } => one(Request::PointerButton { button: *button, state: to_button_state(*state), }), Command::Click { button } => Ok(vec![ Request::PointerButton { button: *button, state: ButtonState::Pressed, }, Request::PointerButton { button: *button, state: ButtonState::Released, }, ]), Command::Axis { axis, value } => one(Request::PointerAxis { axis: match axis { AxisArg::Vertical => Axis::Vertical, AxisArg::Horizontal => Axis::Horizontal, }, value: *value, }), Command::Key { keycode, state } => one(Request::Key { keycode: *keycode, state: match state { PressArg::Press => KeyState::Pressed, PressArg::Release => KeyState::Released, }, }), Command::Type { text } => one(Request::Text { text: text.clone() }), Command::TouchDown { slot, x, y } => one(Request::TouchDown { slot: *slot, x: *x, y: *y, }), Command::TouchMotion { slot, x, y } => one(Request::TouchMotion { slot: *slot, x: *x, y: *y, }), Command::TouchUp { slot } => one(Request::TouchUp { slot: *slot }), Command::TouchCancel => one(Request::TouchCancel), Command::ClipboardGet => one(Request::ClipboardGet), Command::ClipboardSet { text } => one(Request::ClipboardSet { text: text.clone() }), Command::Monitor { state } => one(Request::Monitor { on: matches!(state, OnOffArg::On), }), Command::Quit => one(Request::Quit), } } fn to_button_state(state: PressArg) -> ButtonState { match state { PressArg::Press => ButtonState::Pressed, PressArg::Release => ButtonState::Released, } } fn parse_region(s: &str) -> Result { let parts: Vec = s .split(',') .map(|p| p.trim().parse::()) .collect::>() .with_context(|| format!("invalid region {s:?}, expected X,Y,W,H"))?; if parts.len() != 4 { bail!("invalid region {s:?}, expected X,Y,W,H"); } Ok(Rect { x: parts[0], y: parts[1], w: parts[2], h: parts[3], }) } /// Send one request and read back one newline-delimited response. fn round_trip(socket: &PathBuf, request: &Request) -> Result { let mut stream = UnixStream::connect(socket) .with_context(|| format!("failed to connect to {}", socket.display()))?; stream.write_all(request.to_line().as_bytes())?; stream.flush()?; let mut buf = Vec::new(); let mut byte = [0u8; 4096]; loop { let n = stream.read(&mut byte)?; if n == 0 { break; } buf.extend_from_slice(&byte[..n]); if buf.ends_with(b"\n") { break; } } let line = String::from_utf8(buf).context("response was not valid UTF-8")?; serde_json::from_str(line.trim_end()).context("failed to parse response") } fn handle_response(command: &Command, response: Response) -> Result<()> { match response { Response::Error { message } => bail!("{message}"), Response::Ok | Response::Pong => { if matches!(command, Command::Ping) { println!("pong"); } } Response::Screenshot { png } => { if let Command::Screenshot { output, .. } = command { write_screenshot(output, &png.0)?; } } Response::Windows(windows) => { if windows.is_empty() { println!("(no windows)"); } for w in windows { println!( "{}\t{}\t{}\t{},{} {}x{}\t{}", w.id.0, w.app_id.as_deref().unwrap_or("-"), w.title.as_deref().unwrap_or("-"), w.geometry.x, w.geometry.y, w.geometry.w, w.geometry.h, if w.focused { "focused" } else { "" } ); } } Response::Window(w) => { println!( "{}\t{}\t{}\t{},{} {}x{}", w.id.0, w.app_id.as_deref().unwrap_or("-"), w.title.as_deref().unwrap_or("-"), w.geometry.x, w.geometry.y, w.geometry.w, w.geometry.h ); } Response::Timeout => bail!("timed out waiting for window"), Response::Clipboard { text } => { if let Some(text) = text { print!("{text}"); } } } Ok(()) } fn write_screenshot(output: &str, png: &[u8]) -> Result<()> { if output == "-" { std::io::stdout().write_all(png)?; } else { std::fs::write(output, png).with_context(|| format!("failed to write {output}"))?; eprintln!("wrote {} ({} bytes)", output, png.len()); } Ok(()) }