main.rs
raw
//! `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<PathBuf>,
#[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<String>,
},
/// List all mapped windows.
ListWindows,
/// Block until a window matching the filters maps.
WaitWindow {
#[arg(long)]
app_id: Option<String>,
#[arg(long)]
title: Option<String>,
#[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<PathBuf>) -> Result<PathBuf> {
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<Vec<Request>> {
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<Rect> {
let parts: Vec<i32> = s
.split(',')
.map(|p| p.trim().parse::<i32>())
.collect::<Result<_, _>>()
.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<Response> {
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(())
}