Add wlgrid: a window-thumbnail grid overlay for wlroots
A switcher to replace a wlthumbs + rofi pipeline, with the same look (gruvbox, ceil(sqrt(n)) columns capped at 4, 16:9 tiles, a yellow selection filling the element padding) but no thumbnails anywhere. Each window is captured straight into a wl_shm buffer that is handed to its own wl_subsurface, with wp_viewporter giving the compositor the rectangle to scale it into. So there is no PNG encode, no scaler, no full-resolution bitmap in this process, and the capture buffers are never even mapped here — the compositor writes those pages and samples them again for display. Opens in ~65ms for 8 windows (55ms of which is the compositor reading pixels back out of the GPU) and holds ~9MB of RSS. All capture sessions are opened before a single roundtrip and every frame goes in flight together, the same batching wlthumbs uses, because the readback is bandwidth-bound rather than latency-bound. sway remains the source of truth: the window list, the con_ids and the focusing all come from its IPC socket, joined to the Wayland side by foreign_toplevel_identifier. Navigation reads raw evdev keycodes so it is layout-independent, which does mean virtual-keyboard clients that invent their own keymap can't drive it; that resolves when filtering brings xkb. Labels, type-to-filter and live previews are next.
This commit is contained in:
+811
@@ -0,0 +1,811 @@
|
||||
//! wlgrid shows a thumbnail grid of every open window as a layer-shell overlay
|
||||
//! and focuses the one you pick. It replaces a wlthumbs + rofi pipeline, so it
|
||||
//! keeps that pipeline's contract: sway owns the window list and the focusing,
|
||||
//! and the look comes straight from the rofi theme (see theme.rs).
|
||||
//!
|
||||
//! The pixels never pass through this process. Each window is captured into an
|
||||
//! shm buffer handed straight to a subsurface, with wp_viewporter telling the
|
||||
//! compositor which rectangle to scale it into — so there is no thumbnail
|
||||
//! encoding, no scaler, and no full-resolution image in our address space.
|
||||
|
||||
mod shm;
|
||||
mod sway;
|
||||
mod theme;
|
||||
|
||||
use std::error::Error;
|
||||
use std::os::fd::AsFd;
|
||||
use std::process::ExitCode;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use wayland_client::globals::{GlobalList, GlobalListContents, registry_queue_init};
|
||||
use wayland_client::protocol::{
|
||||
wl_buffer::WlBuffer,
|
||||
wl_compositor::WlCompositor,
|
||||
wl_keyboard::{self, WlKeyboard},
|
||||
wl_output,
|
||||
wl_registry::WlRegistry,
|
||||
wl_seat::{self, WlSeat},
|
||||
wl_shm::{self, WlShm},
|
||||
wl_shm_pool::WlShmPool,
|
||||
wl_subcompositor::WlSubcompositor,
|
||||
wl_subsurface::WlSubsurface,
|
||||
wl_surface::WlSurface,
|
||||
};
|
||||
use wayland_client::{
|
||||
Connection, Dispatch, EventQueue, Proxy, QueueHandle, WEnum, 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,
|
||||
};
|
||||
use wayland_protocols::ext::image_copy_capture::v1::client::{
|
||||
ext_image_copy_capture_frame_v1::{self, ExtImageCopyCaptureFrameV1},
|
||||
ext_image_copy_capture_manager_v1::{self, ExtImageCopyCaptureManagerV1},
|
||||
ext_image_copy_capture_session_v1::{self, ExtImageCopyCaptureSessionV1},
|
||||
};
|
||||
use wayland_protocols::wp::viewporter::client::{
|
||||
wp_viewport::WpViewport, wp_viewporter::WpViewporter,
|
||||
};
|
||||
use wayland_protocols_wlr::layer_shell::v1::client::{
|
||||
zwlr_layer_shell_v1::{Layer, ZwlrLayerShellV1},
|
||||
zwlr_layer_surface_v1::{self, KeyboardInteractivity, ZwlrLayerSurfaceV1},
|
||||
};
|
||||
|
||||
use theme::{Layout, Rect, Theme, fit_centred};
|
||||
|
||||
// evdev keycodes: physical positions, so navigation works on any keyboard layout
|
||||
// without an xkb keymap. Typing (and therefore xkb) arrives with filtering.
|
||||
const KEY_ESC: u32 = 1;
|
||||
const KEY_TAB: u32 = 15;
|
||||
const KEY_Q: u32 = 16;
|
||||
const KEY_ENTER: u32 = 28;
|
||||
const KEY_LEFTSHIFT: u32 = 42;
|
||||
const KEY_RIGHTSHIFT: u32 = 54;
|
||||
const KEY_KPENTER: u32 = 96;
|
||||
const KEY_HOME: u32 = 102;
|
||||
const KEY_UP: u32 = 103;
|
||||
const KEY_LEFT: u32 = 105;
|
||||
const KEY_RIGHT: u32 = 106;
|
||||
const KEY_END: u32 = 107;
|
||||
const KEY_DOWN: u32 = 108;
|
||||
|
||||
/// One window: its sway identity, its capture plumbing, and its subsurface.
|
||||
#[allow(dead_code)] // `handle` is held to keep the toplevel alive
|
||||
struct Tile {
|
||||
win: sway::Win,
|
||||
handle: Option<ExtForeignToplevelHandleV1>,
|
||||
|
||||
session: Option<ExtImageCopyCaptureSessionV1>,
|
||||
frame: Option<ExtImageCopyCaptureFrameV1>,
|
||||
buffer: Option<WlBuffer>,
|
||||
formats: Vec<wl_shm::Format>,
|
||||
format: Option<wl_shm::Format>,
|
||||
/// Buffer size the session requires: the window's full resolution.
|
||||
size: (u32, u32),
|
||||
transform: wl_output::Transform,
|
||||
offset: usize,
|
||||
session_done: bool,
|
||||
ready: bool,
|
||||
failed: bool,
|
||||
settled: bool,
|
||||
|
||||
surface: Option<WlSurface>,
|
||||
subsurface: Option<WlSubsurface>,
|
||||
viewport: Option<WpViewport>,
|
||||
}
|
||||
|
||||
impl Tile {
|
||||
fn new(win: sway::Win) -> Self {
|
||||
Self {
|
||||
win,
|
||||
handle: None,
|
||||
session: None,
|
||||
frame: None,
|
||||
buffer: None,
|
||||
formats: Vec::new(),
|
||||
format: None,
|
||||
size: (0, 0),
|
||||
transform: wl_output::Transform::Normal,
|
||||
offset: 0,
|
||||
session_done: false,
|
||||
ready: false,
|
||||
failed: false,
|
||||
settled: false,
|
||||
surface: None,
|
||||
subsurface: None,
|
||||
viewport: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn bytes(&self) -> usize {
|
||||
self.size.0 as usize * 4 * self.size.1 as usize
|
||||
}
|
||||
|
||||
/// Whether the buffer's contents are turned on their side relative to the
|
||||
/// window, which flips the aspect ratio we have to fit.
|
||||
fn rotated(&self) -> bool {
|
||||
use wl_output::Transform;
|
||||
matches!(
|
||||
self.transform,
|
||||
Transform::_90 | Transform::_270 | Transform::Flipped90 | Transform::Flipped270
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct App {
|
||||
compositor: WlCompositor,
|
||||
subcompositor: WlSubcompositor,
|
||||
shm: WlShm,
|
||||
viewporter: WpViewporter,
|
||||
layer_shell: ZwlrLayerShellV1,
|
||||
copy_mgr: ExtImageCopyCaptureManagerV1,
|
||||
src_mgr: ExtForeignToplevelImageCaptureSourceManagerV1,
|
||||
|
||||
/// Toplevel handles as the compositor announces them, paired with the
|
||||
/// identifier that joins them to sway's tree.
|
||||
toplevels: Vec<(ExtForeignToplevelHandleV1, String)>,
|
||||
tiles: Vec<Tile>,
|
||||
|
||||
theme: Theme,
|
||||
layout: Layout,
|
||||
scale: i32,
|
||||
sel: usize,
|
||||
shift: bool,
|
||||
|
||||
surface: Option<WlSurface>,
|
||||
chrome: Option<shm::Chrome>,
|
||||
chrome_buffers: Vec<WlBuffer>,
|
||||
configured: bool,
|
||||
|
||||
quit: bool,
|
||||
activate: Option<i64>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn new(
|
||||
globals: &GlobalList,
|
||||
qh: &QueueHandle<Self>,
|
||||
wins: Vec<sway::Win>,
|
||||
theme: Theme,
|
||||
scale: i32,
|
||||
) -> Result<Self, Box<dyn Error>> {
|
||||
let layout = Layout::new(&theme, wins.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 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(),
|
||||
tiles: wins.into_iter().map(Tile::new).collect(),
|
||||
theme,
|
||||
layout,
|
||||
scale,
|
||||
sel: 0,
|
||||
shift: false,
|
||||
surface: None,
|
||||
chrome: None,
|
||||
chrome_buffers: Vec::new(),
|
||||
configured: false,
|
||||
quit: false,
|
||||
activate: None,
|
||||
};
|
||||
let _: ExtForeignToplevelListV1 = globals.bind(qh, 1..=1, ())?;
|
||||
let _: WlSeat = globals.bind(qh, 1..=7, ())?;
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
/// Open one capture session per window whose toplevel we recognise. They are
|
||||
/// all opened before a single roundtrip, so every session's buffer
|
||||
/// constraints arrive together instead of costing a round trip each.
|
||||
fn open_sessions(&mut self, qh: &QueueHandle<Self>) {
|
||||
for (i, tile) in self.tiles.iter_mut().enumerate() {
|
||||
let Some(handle) = self
|
||||
.toplevels
|
||||
.iter()
|
||||
.find(|(_, id)| !id.is_empty() && *id == tile.win.ft_id)
|
||||
.map(|(h, _)| h.clone())
|
||||
else {
|
||||
// No identifier match: the tile stays label-only, and must not
|
||||
// be waited on.
|
||||
tile.settled = true;
|
||||
continue;
|
||||
};
|
||||
let source: ExtImageCaptureSourceV1 = self.src_mgr.create_source(&handle, qh, ());
|
||||
tile.handle = Some(handle);
|
||||
tile.session = Some(self.copy_mgr.create_session(
|
||||
&source,
|
||||
ext_image_copy_capture_manager_v1::Options::empty(),
|
||||
qh,
|
||||
i,
|
||||
));
|
||||
source.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocate one pool for every capture buffer and put all the frames in
|
||||
/// flight at once: the compositor is bandwidth-bound reading pixels back, so
|
||||
/// serialising the captures only adds latency.
|
||||
fn start_captures(&mut self, qh: &QueueHandle<Self>) -> Result<(), Box<dyn Error>> {
|
||||
const PAGE: usize = 4096;
|
||||
let mut total = 0usize;
|
||||
for tile in &mut self.tiles {
|
||||
if tile.session.is_none() {
|
||||
continue;
|
||||
}
|
||||
if !tile.session_done || tile.size.0 == 0 || tile.size.1 == 0 {
|
||||
tile.settled = true;
|
||||
continue;
|
||||
}
|
||||
// Any 32-bit format will do: we never read these pixels, we hand the
|
||||
// buffer straight back for display, so byte order stays the
|
||||
// compositor's business on both ends.
|
||||
tile.format = tile
|
||||
.formats
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|f| matches!(f, wl_shm::Format::Xrgb8888 | wl_shm::Format::Argb8888))
|
||||
.or_else(|| tile.formats.first().copied());
|
||||
if tile.format.is_none() {
|
||||
tile.settled = true;
|
||||
continue;
|
||||
}
|
||||
tile.offset = total;
|
||||
total += tile.bytes().div_ceil(PAGE) * PAGE;
|
||||
}
|
||||
if total == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Note: no mmap. The compositor writes these pages and samples them
|
||||
// again for display; mapping them here would only cost us the faults.
|
||||
let file = shm::memfd("wlgrid-capture", total)?;
|
||||
let pool = self.shm.create_pool(file.as_fd(), total as i32, qh, ());
|
||||
for i in 0..self.tiles.len() {
|
||||
let (w, h, format, offset) = {
|
||||
let t = &self.tiles[i];
|
||||
if t.settled || t.session.is_none() || t.format.is_none() {
|
||||
continue;
|
||||
}
|
||||
(
|
||||
t.size.0 as i32,
|
||||
t.size.1 as i32,
|
||||
t.format.unwrap(),
|
||||
t.offset as i32,
|
||||
)
|
||||
};
|
||||
let buffer = pool.create_buffer(offset, w, h, w * 4, format, qh, ());
|
||||
let session = self.tiles[i].session.clone().unwrap();
|
||||
let frame = session.create_frame(qh, i);
|
||||
frame.attach_buffer(&buffer);
|
||||
frame.damage_buffer(0, 0, w, h);
|
||||
frame.capture();
|
||||
let t = &mut self.tiles[i];
|
||||
t.buffer = Some(buffer);
|
||||
t.frame = Some(frame);
|
||||
}
|
||||
pool.destroy(); // the buffers keep the mapping alive
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn captures_settled(&self) -> bool {
|
||||
self.tiles.iter().all(|t| t.settled)
|
||||
}
|
||||
|
||||
/// Map the overlay: a layer surface sized to hug the grid, plus the shm the
|
||||
/// chrome is painted into.
|
||||
fn show(&mut self, qh: &QueueHandle<Self>) -> Result<(), Box<dyn Error>> {
|
||||
let (lw, lh) = (self.layout.width, self.layout.height);
|
||||
let surface = self.compositor.create_surface(qh, ());
|
||||
let layer = self.layer_shell.get_layer_surface(
|
||||
&surface,
|
||||
None, // let the compositor place it on the active output
|
||||
Layer::Overlay,
|
||||
"wlgrid".to_string(),
|
||||
qh,
|
||||
(),
|
||||
);
|
||||
layer.set_size(lw as u32, lh as u32);
|
||||
layer.set_keyboard_interactivity(KeyboardInteractivity::Exclusive);
|
||||
surface.set_buffer_scale(self.scale);
|
||||
surface.commit();
|
||||
|
||||
let (pw, ph) = (lw * self.scale, lh * self.scale);
|
||||
let len = shm::Chrome::slot_len(pw, ph) * shm::Chrome::SLOTS;
|
||||
let file = shm::memfd("wlgrid-chrome", len)?;
|
||||
let pool = self.shm.create_pool(file.as_fd(), len as i32, qh, ());
|
||||
for slot in 0..shm::Chrome::SLOTS {
|
||||
self.chrome_buffers.push(pool.create_buffer(
|
||||
(slot * shm::Chrome::slot_len(pw, ph)) as i32,
|
||||
pw,
|
||||
ph,
|
||||
shm::Chrome::stride(pw),
|
||||
wl_shm::Format::Argb8888,
|
||||
qh,
|
||||
(),
|
||||
));
|
||||
}
|
||||
pool.destroy();
|
||||
self.chrome = Some(shm::Chrome::new(&file, pw, ph)?);
|
||||
self.surface = Some(surface);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attach each captured buffer to its own subsurface and let the compositor
|
||||
/// scale it into the tile rectangle.
|
||||
fn place_tiles(&mut self, qh: &QueueHandle<Self>) {
|
||||
let parent = self.surface.clone().expect("show() runs first");
|
||||
for i in 0..self.tiles.len() {
|
||||
if !self.tiles[i].ready {
|
||||
continue;
|
||||
}
|
||||
let (bw, bh) = self.tiles[i].size;
|
||||
let (fit_w, fit_h) = if self.tiles[i].rotated() {
|
||||
(bh as i32, bw as i32)
|
||||
} else {
|
||||
(bw as i32, bh as i32)
|
||||
};
|
||||
let dst = fit_centred(fit_w, fit_h, self.layout.tile(i as i32));
|
||||
let surface = self.compositor.create_surface(qh, ());
|
||||
let subsurface = self.subcompositor.get_subsurface(&surface, &parent, qh, ());
|
||||
let viewport = self.viewporter.get_viewport(&surface, qh, ());
|
||||
subsurface.set_position(dst.x, dst.y);
|
||||
// Tiles change independently of the chrome (selection moves now,
|
||||
// live frames later), so they must not wait on a parent commit.
|
||||
subsurface.set_desync();
|
||||
// The capture protocol reports the transform the compositor already
|
||||
// applied to the buffer, which is exactly what this request means,
|
||||
// so it passes straight through and the compositor un-rotates it.
|
||||
surface.set_buffer_transform(self.tiles[i].transform);
|
||||
viewport.set_destination(dst.w, dst.h);
|
||||
surface.attach(self.tiles[i].buffer.as_ref(), 0, 0);
|
||||
surface.damage_buffer(0, 0, bw as i32, bh as i32);
|
||||
surface.commit();
|
||||
let t = &mut self.tiles[i];
|
||||
t.surface = Some(surface);
|
||||
t.subsurface = Some(subsurface);
|
||||
t.viewport = Some(viewport);
|
||||
}
|
||||
// Subsurface placement is *parent* state: it only takes effect when the
|
||||
// parent commits, desynced children included.
|
||||
parent.commit();
|
||||
}
|
||||
|
||||
/// Repaint background, selection highlight and border.
|
||||
fn paint(&mut self) {
|
||||
let (theme, scale, sel) = (&self.theme, self.scale, self.sel);
|
||||
let elem = self.layout.elem(sel as i32);
|
||||
let Some(chrome) = self.chrome.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let slot = chrome.next_slot();
|
||||
let (cw, ch) = (chrome.w, chrome.h);
|
||||
let mut p = chrome.painter();
|
||||
p.fill(theme.bg);
|
||||
// The selection fills the whole element box, padding included — the same
|
||||
// thing rofi's element background does.
|
||||
p.rect(
|
||||
Rect {
|
||||
x: elem.x * scale,
|
||||
y: elem.y * scale,
|
||||
w: elem.w * scale,
|
||||
h: elem.h * scale,
|
||||
},
|
||||
theme.sel_bg,
|
||||
);
|
||||
p.frame(theme.border_px * scale, theme.border);
|
||||
|
||||
let surface = self.surface.clone().expect("show() runs first");
|
||||
surface.attach(self.chrome_buffers.get(slot), 0, 0);
|
||||
surface.damage_buffer(0, 0, cw, ch);
|
||||
surface.commit();
|
||||
}
|
||||
|
||||
fn move_sel(&mut self, delta: i32) {
|
||||
let n = self.tiles.len() as i32;
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
self.sel = (self.sel as i32 + delta).rem_euclid(n) as usize;
|
||||
self.paint();
|
||||
}
|
||||
|
||||
fn move_row(&mut self, rows: i32) {
|
||||
let n = self.tiles.len() as i32;
|
||||
let target = self.sel as i32 + rows * self.layout.cols;
|
||||
if target >= 0 && target < n {
|
||||
self.sel = target as usize;
|
||||
self.paint();
|
||||
}
|
||||
}
|
||||
|
||||
fn key(&mut self, code: u32) {
|
||||
match code {
|
||||
KEY_LEFTSHIFT | KEY_RIGHTSHIFT => self.shift = true,
|
||||
KEY_ESC | KEY_Q => self.quit = true,
|
||||
KEY_ENTER | KEY_KPENTER => {
|
||||
self.activate = self.tiles.get(self.sel).map(|t| t.win.con_id);
|
||||
self.quit = true;
|
||||
}
|
||||
KEY_TAB if self.shift => self.move_sel(-1),
|
||||
KEY_TAB | KEY_RIGHT => self.move_sel(1),
|
||||
KEY_LEFT => self.move_sel(-1),
|
||||
KEY_DOWN => self.move_row(1),
|
||||
KEY_UP => self.move_row(-1),
|
||||
KEY_HOME => {
|
||||
self.sel = 0;
|
||||
self.paint();
|
||||
}
|
||||
KEY_END => {
|
||||
self.sel = self.tiles.len().saturating_sub(1);
|
||||
self.paint();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase timings, printed with --verbose. Opening latency is the whole point of
|
||||
/// this tool, so it stays measurable.
|
||||
struct Phases {
|
||||
on: bool,
|
||||
last: Instant,
|
||||
}
|
||||
|
||||
impl Phases {
|
||||
fn new(on: bool) -> Self {
|
||||
Self {
|
||||
on,
|
||||
last: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mark(&mut self, label: &str) {
|
||||
if self.on {
|
||||
let now = Instant::now();
|
||||
eprintln!(
|
||||
"{label:<12} {:6.1}ms",
|
||||
(now - self.last).as_secs_f64() * 1000.0
|
||||
);
|
||||
self.last = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pump(
|
||||
queue: &mut EventQueue<App>,
|
||||
app: &mut App,
|
||||
done: impl Fn(&App) -> bool,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
while !done(app) {
|
||||
queue.blocking_dispatch(app)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Args {
|
||||
print: bool,
|
||||
verbose: bool,
|
||||
timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Args, String> {
|
||||
let mut args = Args {
|
||||
print: false,
|
||||
verbose: false,
|
||||
timeout: None,
|
||||
};
|
||||
let mut it = std::env::args().skip(1);
|
||||
while let Some(arg) = it.next() {
|
||||
match arg.as_str() {
|
||||
"--print" => args.print = true,
|
||||
"-v" | "--verbose" => args.verbose = true,
|
||||
"--timeout" => {
|
||||
let v = it.next().ok_or("--timeout needs seconds")?;
|
||||
let secs: f64 = v.parse().map_err(|_| format!("bad --timeout: {v}"))?;
|
||||
args.timeout = Some(Duration::from_secs_f64(secs));
|
||||
}
|
||||
"-h" | "--help" => {
|
||||
println!("usage: wlgrid [--print] [--verbose] [--timeout SECS]");
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => return Err(format!("unknown argument: {other}")),
|
||||
}
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(code) => code,
|
||||
Err(e) => {
|
||||
eprintln!("wlgrid: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<ExitCode, Box<dyn Error>> {
|
||||
let args = parse_args().map_err(|e| -> Box<dyn Error> { e.into() })?;
|
||||
// An exclusive keyboard grab makes a hung overlay unusable, so keep an
|
||||
// escape hatch that cannot itself deadlock.
|
||||
if let Some(d) = args.timeout {
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(d);
|
||||
eprintln!("wlgrid: timeout");
|
||||
std::process::exit(2);
|
||||
});
|
||||
}
|
||||
|
||||
let mut phases = Phases::new(args.verbose);
|
||||
let mut sway_conn = swayipc::Connection::new()?;
|
||||
let wins = sway::windows(&mut sway_conn)?;
|
||||
if wins.is_empty() {
|
||||
return Ok(ExitCode::SUCCESS);
|
||||
}
|
||||
let scale = sway_conn
|
||||
.get_outputs()?
|
||||
.iter()
|
||||
.filter(|o| o.active)
|
||||
.map(|o| o.scale.unwrap_or(1.0).ceil() as i32)
|
||||
.max()
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
|
||||
phases.mark("sway-tree");
|
||||
|
||||
let conn = Connection::connect_to_env()?;
|
||||
let (globals, mut queue) = registry_queue_init::<App>(&conn)?;
|
||||
let qh = queue.handle();
|
||||
let mut app = App::new(&globals, &qh, wins, Theme::default(), scale)?;
|
||||
|
||||
// Two roundtrips: one for the toplevel list, one for each handle's state.
|
||||
queue.roundtrip(&mut app)?;
|
||||
queue.roundtrip(&mut app)?;
|
||||
|
||||
phases.mark("toplevels");
|
||||
|
||||
app.open_sessions(&qh);
|
||||
queue.roundtrip(&mut app)?; // every session's constraints at once
|
||||
phases.mark("constraints");
|
||||
app.start_captures(&qh)?;
|
||||
pump(&mut queue, &mut app, |a| a.captures_settled())?;
|
||||
phases.mark("capture");
|
||||
|
||||
if args.verbose {
|
||||
let ready = app.tiles.iter().filter(|t| t.ready).count();
|
||||
let matched = app.tiles.iter().filter(|t| t.handle.is_some()).count();
|
||||
eprintln!(
|
||||
"wlgrid: {} window(s), {matched} matched, {ready} captured; \
|
||||
grid {}x{}, surface {}x{} logical at scale {}",
|
||||
app.tiles.len(),
|
||||
app.layout.cols,
|
||||
app.layout.rows,
|
||||
app.layout.width,
|
||||
app.layout.height,
|
||||
app.scale,
|
||||
);
|
||||
}
|
||||
app.show(&qh)?;
|
||||
pump(&mut queue, &mut app, |a| a.configured)?;
|
||||
app.paint();
|
||||
app.place_tiles(&qh);
|
||||
conn.flush()?;
|
||||
phases.mark("mapped");
|
||||
|
||||
pump(&mut queue, &mut app, |a| a.quit)?;
|
||||
|
||||
if let Some(con_id) = app.activate {
|
||||
if args.print {
|
||||
println!("{con_id}");
|
||||
} else {
|
||||
sway::focus(&mut sway_conn, con_id)?;
|
||||
}
|
||||
}
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
// --- event plumbing -------------------------------------------------------
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ExtImageCopyCaptureSessionV1, usize> for App {
|
||||
fn event(
|
||||
app: &mut Self,
|
||||
_: &ExtImageCopyCaptureSessionV1,
|
||||
event: ext_image_copy_capture_session_v1::Event,
|
||||
&i: &usize,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
let Some(tile) = app.tiles.get_mut(i) else {
|
||||
return;
|
||||
};
|
||||
match event {
|
||||
ext_image_copy_capture_session_v1::Event::BufferSize { width, height } => {
|
||||
tile.size = (width, height)
|
||||
}
|
||||
ext_image_copy_capture_session_v1::Event::ShmFormat {
|
||||
format: WEnum::Value(f),
|
||||
} => tile.formats.push(f),
|
||||
ext_image_copy_capture_session_v1::Event::Done => tile.session_done = true,
|
||||
ext_image_copy_capture_session_v1::Event::Stopped => {
|
||||
tile.failed = true;
|
||||
tile.settled = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ExtImageCopyCaptureFrameV1, usize> for App {
|
||||
fn event(
|
||||
app: &mut Self,
|
||||
_: &ExtImageCopyCaptureFrameV1,
|
||||
event: ext_image_copy_capture_frame_v1::Event,
|
||||
&i: &usize,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
let Some(tile) = app.tiles.get_mut(i) else {
|
||||
return;
|
||||
};
|
||||
match event {
|
||||
ext_image_copy_capture_frame_v1::Event::Transform {
|
||||
transform: WEnum::Value(t),
|
||||
} => tile.transform = t,
|
||||
ext_image_copy_capture_frame_v1::Event::Ready => {
|
||||
tile.ready = true;
|
||||
tile.settled = true;
|
||||
// The protocol wants the frame destroyed once ready; the buffer
|
||||
// stays ours to display.
|
||||
if let Some(frame) = tile.frame.take() {
|
||||
frame.destroy();
|
||||
}
|
||||
}
|
||||
ext_image_copy_capture_frame_v1::Event::Failed { reason } => {
|
||||
eprintln!(
|
||||
"wlgrid: capture failed for {:?} ({reason:?})",
|
||||
tile.win.title
|
||||
);
|
||||
tile.failed = true;
|
||||
tile.settled = true;
|
||||
if let Some(frame) = tile.frame.take() {
|
||||
frame.destroy();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ZwlrLayerSurfaceV1, ()> for App {
|
||||
fn event(
|
||||
app: &mut Self,
|
||||
layer: &ZwlrLayerSurfaceV1,
|
||||
event: zwlr_layer_surface_v1::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
zwlr_layer_surface_v1::Event::Configure { serial, .. } => {
|
||||
layer.ack_configure(serial);
|
||||
app.configured = true;
|
||||
}
|
||||
zwlr_layer_surface_v1::Event::Closed => app.quit = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlSeat, ()> for App {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
seat: &WlSeat,
|
||||
event: wl_seat::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_seat::Event::Capabilities {
|
||||
capabilities: WEnum::Value(caps),
|
||||
} = event
|
||||
&& caps.contains(wl_seat::Capability::Keyboard)
|
||||
{
|
||||
seat.get_keyboard(qh, ());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlKeyboard, ()> for App {
|
||||
fn event(
|
||||
app: &mut Self,
|
||||
_: &WlKeyboard,
|
||||
event: wl_keyboard::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_keyboard::Event::Key { key, state, .. } = event {
|
||||
match state {
|
||||
WEnum::Value(wl_keyboard::KeyState::Pressed) => app.key(key),
|
||||
WEnum::Value(wl_keyboard::KeyState::Released)
|
||||
if key == KEY_LEFTSHIFT || key == KEY_RIGHTSHIFT =>
|
||||
{
|
||||
app.shift = false
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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: ignore WlSurface);
|
||||
delegate_noop!(App: ignore WlBuffer);
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
//! Shared memory: an anonymous memfd handed to wl_shm, plus a tiny ARGB
|
||||
//! painter for the parts we draw ourselves (background, border, selection).
|
||||
//!
|
||||
//! Capture buffers deliberately never get mapped into this process. The
|
||||
//! compositor writes the window pixels and then samples them again for display,
|
||||
//! so we only need the fd — mapping them would fault ~7 MB per window into our
|
||||
//! address space for nothing.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
|
||||
use memmap2::MmapMut;
|
||||
use rustix::fs::{MemfdFlags, memfd_create};
|
||||
|
||||
use crate::theme::{Argb, Rect};
|
||||
|
||||
/// An anonymous in-memory file of the given size, for wl_shm.create_pool.
|
||||
pub fn memfd(name: &str, len: usize) -> io::Result<File> {
|
||||
let fd = memfd_create(name, MemfdFlags::CLOEXEC)?;
|
||||
let file = File::from(fd);
|
||||
file.set_len(len as u64)?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
/// A mapped memfd we paint into. Holds two slots so we can draw the next frame
|
||||
/// without touching the one the compositor is currently reading.
|
||||
pub struct Chrome {
|
||||
map: MmapMut,
|
||||
pub w: i32,
|
||||
pub h: i32,
|
||||
slot: usize,
|
||||
}
|
||||
|
||||
impl Chrome {
|
||||
pub const SLOTS: usize = 2;
|
||||
|
||||
pub fn new(file: &File, w: i32, h: i32) -> io::Result<Self> {
|
||||
let map = unsafe { MmapMut::map_mut(file)? };
|
||||
Ok(Self { map, w, h, slot: 0 })
|
||||
}
|
||||
|
||||
pub fn stride(w: i32) -> i32 {
|
||||
w * 4
|
||||
}
|
||||
|
||||
pub fn slot_len(w: i32, h: i32) -> usize {
|
||||
(Self::stride(w) * h) as usize
|
||||
}
|
||||
|
||||
/// Flip to the other slot and return its byte offset in the pool, so the
|
||||
/// caller can attach the matching wl_buffer.
|
||||
pub fn next_slot(&mut self) -> usize {
|
||||
self.slot = (self.slot + 1) % Self::SLOTS;
|
||||
self.slot
|
||||
}
|
||||
|
||||
pub fn painter(&mut self) -> Painter<'_> {
|
||||
let (w, h) = (self.w, self.h);
|
||||
let len = Self::slot_len(w, h);
|
||||
let off = self.slot * len;
|
||||
Painter {
|
||||
px: &mut self.map[off..off + len],
|
||||
w,
|
||||
h,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flat ARGB8888 painter. Everything in this UI is axis-aligned solid fills, so
|
||||
/// there is no need for a rasteriser.
|
||||
pub struct Painter<'a> {
|
||||
px: &'a mut [u8],
|
||||
w: i32,
|
||||
h: i32,
|
||||
}
|
||||
|
||||
impl Painter<'_> {
|
||||
pub fn fill(&mut self, c: Argb) {
|
||||
for p in self.px.chunks_exact_mut(4) {
|
||||
p.copy_from_slice(&c.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rect(&mut self, r: Rect, c: Argb) {
|
||||
let bytes = c.to_le_bytes();
|
||||
let (x0, y0) = (r.x.max(0), r.y.max(0));
|
||||
let (x1, y1) = ((r.x + r.w).min(self.w), (r.y + r.h).min(self.h));
|
||||
for y in y0..y1 {
|
||||
let row = (y * self.w * 4) as usize;
|
||||
for x in x0..x1 {
|
||||
let o = row + (x * 4) as usize;
|
||||
self.px[o..o + 4].copy_from_slice(&bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `width`-thick frame just inside the surface edge.
|
||||
pub fn frame(&mut self, width: i32, c: Argb) {
|
||||
let (w, h) = (self.w, self.h);
|
||||
self.rect(
|
||||
Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w,
|
||||
h: width,
|
||||
},
|
||||
c,
|
||||
);
|
||||
self.rect(
|
||||
Rect {
|
||||
x: 0,
|
||||
y: h - width,
|
||||
w,
|
||||
h: width,
|
||||
},
|
||||
c,
|
||||
);
|
||||
self.rect(
|
||||
Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: width,
|
||||
h,
|
||||
},
|
||||
c,
|
||||
);
|
||||
self.rect(
|
||||
Rect {
|
||||
x: w - width,
|
||||
y: 0,
|
||||
w: width,
|
||||
h,
|
||||
},
|
||||
c,
|
||||
);
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
//! sway stays the source of truth for the window list and for focusing, exactly
|
||||
//! as the shell script this replaces did (`swaymsg -t get_tree` + `[con_id=N]
|
||||
//! focus`). The Wayland side only supplies pixels; the join between the two is
|
||||
//! `foreign_toplevel_identifier`, which sway reports per view.
|
||||
|
||||
use swayipc::{Connection, Node, NodeType};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Win {
|
||||
pub con_id: i64,
|
||||
pub app: String,
|
||||
pub title: String,
|
||||
/// ext-foreign-toplevel-list-v1 identifier; the key we match capture
|
||||
/// sources on.
|
||||
pub ft_id: String,
|
||||
}
|
||||
|
||||
impl Win {
|
||||
/// "title · app", the label rofigrid was given (app dropped when empty).
|
||||
/// Used once tiles are labelled.
|
||||
#[allow(dead_code)]
|
||||
pub fn label(&self) -> String {
|
||||
if self.app.is_empty() {
|
||||
self.title.clone()
|
||||
} else {
|
||||
format!("{} · {}", self.title, self.app)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every view in the tree, in tree order (same traversal the jq filter did, so
|
||||
/// the grid keeps the ordering the muscle memory expects).
|
||||
pub fn windows(conn: &mut Connection) -> Result<Vec<Win>, swayipc::Error> {
|
||||
let mut out = Vec::new();
|
||||
collect(&conn.get_tree()?, &mut out);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn collect(node: &Node, out: &mut Vec<Win>) {
|
||||
let is_con = matches!(node.node_type, NodeType::Con | NodeType::FloatingCon);
|
||||
let class = node
|
||||
.window_properties
|
||||
.as_ref()
|
||||
.and_then(|p| p.class.clone());
|
||||
if is_con && (node.app_id.is_some() || class.is_some()) {
|
||||
// A view with no identifier can't be captured, but it still belongs in
|
||||
// the list: it gets a tile with no thumbnail.
|
||||
out.push(Win {
|
||||
con_id: node.id,
|
||||
app: node.app_id.clone().or(class).unwrap_or_default(),
|
||||
title: node.name.clone().unwrap_or_default(),
|
||||
ft_id: node.foreign_toplevel_identifier.clone().unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
for child in node.nodes.iter().chain(node.floating_nodes.iter()) {
|
||||
collect(child, out);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focus(conn: &mut Connection, con_id: i64) -> Result<(), swayipc::Error> {
|
||||
for res in conn.run_command(format!("[con_id={con_id}] focus"))? {
|
||||
res?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
//! Look and layout, ported from the rofi setup this replaces (mytheme.rasi +
|
||||
//! the -theme-str rofigrid builds): gruvbox dark, a yellow selection that fills
|
||||
//! the element padding, and a window that hugs the grid.
|
||||
|
||||
/// 0xAARRGGBB, premultiplied (everything here is opaque).
|
||||
pub type Argb = u32;
|
||||
|
||||
pub struct Theme {
|
||||
pub bg: Argb,
|
||||
/// Label colours; used once tiles are labelled.
|
||||
#[allow(dead_code)]
|
||||
pub fg: Argb,
|
||||
pub sel_bg: Argb,
|
||||
#[allow(dead_code)]
|
||||
pub sel_fg: Argb,
|
||||
pub border: Argb,
|
||||
/// Window border, logical px (rasi `border: 0.18em` at 12pt ~ 2px).
|
||||
pub border_px: i32,
|
||||
/// Thumbnail cell, logical px. 16:9 so wide windows fill it instead of
|
||||
/// letterboxing in a square box.
|
||||
pub tile_w: i32,
|
||||
pub tile_h: i32,
|
||||
/// Padding inside one element, i.e. around its thumbnail (rasi `element`).
|
||||
pub pad: i32,
|
||||
/// Space between elements (rasi `listview { spacing }`).
|
||||
pub gap: i32,
|
||||
/// Margin between the grid and the window edge.
|
||||
pub margin: i32,
|
||||
pub max_cols: i32,
|
||||
}
|
||||
|
||||
impl Default for Theme {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bg: 0xff282828, // gruvbox-dark-bg0
|
||||
fg: 0xffebdbb2, // gruvbox-dark-fg1
|
||||
sel_bg: 0xffd79921, // gruvbox-dark-yellow-dark
|
||||
sel_fg: 0xff282828,
|
||||
border: 0xffd79921,
|
||||
border_px: 2,
|
||||
tile_w: 220,
|
||||
tile_h: 220 * 9 / 16,
|
||||
pad: 12,
|
||||
gap: 15,
|
||||
margin: 12,
|
||||
max_cols: 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where every element and thumbnail goes, in logical px.
|
||||
pub struct Layout {
|
||||
pub cols: i32,
|
||||
pub rows: i32,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
elem_w: i32,
|
||||
elem_h: i32,
|
||||
margin: i32,
|
||||
gap: i32,
|
||||
pad: i32,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
/// A balanced grid: ceil(sqrt(n)) columns, capped, so the last row isn't
|
||||
/// ragged (6 windows -> 3x2, not 4x2 with two holes). Same rule rofigrid uses.
|
||||
pub fn new(t: &Theme, n: i32) -> Self {
|
||||
let mut cols = (n as f64).sqrt() as i32;
|
||||
if cols * cols < n {
|
||||
cols += 1;
|
||||
}
|
||||
cols = cols.clamp(1, t.max_cols);
|
||||
let rows = (n + cols - 1) / cols;
|
||||
let (elem_w, elem_h) = (t.tile_w + 2 * t.pad, t.tile_h + 2 * t.pad);
|
||||
Self {
|
||||
cols,
|
||||
rows,
|
||||
width: cols * elem_w + (cols - 1) * t.gap + 2 * t.margin,
|
||||
height: rows * elem_h + (rows - 1) * t.gap + 2 * t.margin,
|
||||
elem_w,
|
||||
elem_h,
|
||||
margin: t.margin,
|
||||
gap: t.gap,
|
||||
pad: t.pad,
|
||||
}
|
||||
}
|
||||
|
||||
/// The element box for index i — what the selection highlight fills.
|
||||
pub fn elem(&self, i: i32) -> Rect {
|
||||
let (col, row) = (i % self.cols, i / self.cols);
|
||||
Rect {
|
||||
x: self.margin + col * (self.elem_w + self.gap),
|
||||
y: self.margin + row * (self.elem_h + self.gap),
|
||||
w: self.elem_w,
|
||||
h: self.elem_h,
|
||||
}
|
||||
}
|
||||
|
||||
/// The thumbnail box for index i, i.e. the element box minus its padding.
|
||||
pub fn tile(&self, i: i32) -> Rect {
|
||||
let e = self.elem(i);
|
||||
Rect {
|
||||
x: e.x + self.pad,
|
||||
y: e.y + self.pad,
|
||||
w: e.w - 2 * self.pad,
|
||||
h: e.h - 2 * self.pad,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Rect {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub w: i32,
|
||||
pub h: i32,
|
||||
}
|
||||
|
||||
/// Scale (w, h) to fit inside (bw, bh), keeping the aspect ratio, and centre it.
|
||||
/// Windows are usually portrait-ish next to a 16:9 cell, so this letterboxes the
|
||||
/// same way rofi's `element-icon { size: W H }` does.
|
||||
pub fn fit_centred(w: i32, h: i32, box_: Rect) -> Rect {
|
||||
if w <= 0 || h <= 0 {
|
||||
return box_;
|
||||
}
|
||||
let (mut dw, mut dh) = (box_.w, box_.w * h / w);
|
||||
if dh > box_.h {
|
||||
dh = box_.h;
|
||||
dw = box_.h * w / h;
|
||||
}
|
||||
let (dw, dh) = (dw.max(1), dh.max(1));
|
||||
Rect {
|
||||
x: box_.x + (box_.w - dw) / 2,
|
||||
y: box_.y + (box_.h - dh) / 2,
|
||||
w: dw,
|
||||
h: dh,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The grid maths must match rofigrid's, or the window stops hugging the grid.
|
||||
#[test]
|
||||
fn grid_matches_rofigrid() {
|
||||
let t = Theme::default();
|
||||
// (n, cols, rows) from rofigrid: cols = min(ceil(sqrt(n)), 4)
|
||||
for (n, cols, rows) in [
|
||||
(1, 1, 1),
|
||||
(2, 2, 1),
|
||||
(4, 2, 2),
|
||||
(6, 3, 2),
|
||||
(12, 4, 3),
|
||||
(17, 4, 5),
|
||||
] {
|
||||
let l = Layout::new(&t, n);
|
||||
assert_eq!((l.cols, l.rows), (cols, rows), "n = {n}");
|
||||
// rofigrid: win_w = cols*(ICON+24) + (cols-1)*15 + 24
|
||||
assert_eq!(
|
||||
l.width,
|
||||
cols * (t.tile_w + 24) + (cols - 1) * 15 + 24,
|
||||
"width n = {n}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elements_stay_inside_the_window() {
|
||||
let t = Theme::default();
|
||||
for n in 1..=20 {
|
||||
let l = Layout::new(&t, n);
|
||||
for i in 0..n {
|
||||
let e = l.elem(i);
|
||||
assert!(e.x >= 0 && e.x + e.w <= l.width, "n = {n}, i = {i}");
|
||||
assert!(e.y >= 0 && e.y + e.h <= l.height, "n = {n}, i = {i}");
|
||||
let tile = l.tile(i);
|
||||
assert!(tile.w == t.tile_w && tile.h == t.tile_h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_preserves_aspect_and_centres() {
|
||||
let box_ = Rect {
|
||||
x: 10,
|
||||
y: 20,
|
||||
w: 220,
|
||||
h: 123,
|
||||
};
|
||||
// A portrait window letterboxes: height-bound, centred horizontally.
|
||||
let r = fit_centred(1000, 2000, box_);
|
||||
assert_eq!((r.w, r.h), (61, 123));
|
||||
assert_eq!(r.x, 10 + (220 - 61) / 2);
|
||||
assert_eq!(r.y, 20);
|
||||
// A wide window is width-bound.
|
||||
let r = fit_centred(4000, 1000, box_);
|
||||
assert_eq!((r.w, r.h), (220, 55));
|
||||
assert_eq!(r.y, 20 + (123 - 55) / 2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user