Bug from Grok round-1 #5. spawn_with_sock returned Ok(_) and dropped the LiveOverlayHandle, so the JoinHandle was never joined or signalled. The thread detached; OverlayHub could only clear its slot map, never stop the actual Wayland thread. With env-gated rendering (Bug #8), the operator's overlays would accumulate as ghost threads. Changes: - wayland_layer: LiveOverlayHandle gains a stop: Arc<AtomicBool>. spawn() allocates it, threads a copy into run(), stores a copy on the returned handle. - wayland_layer: run() polls stop in addition to state.exited; flipping the bit causes the next roundtrip to exit instead of waiting on the compositor's Closed event. - overlay: OverlayHandle gains stop: Option<Arc<AtomicBool>> and a kill() method that flips the bit. - overlay: spawn_with_sock now puts the same stop Arc on the returned OverlayHandle (was previously throwing the live handle away). - overlay: OverlayHub.sync() and kill_all() call kill() on every removed handle, so slot changes actually tear the threads down. cargo test 96+/0; clippy clean.
644 lines
22 KiB
Rust
644 lines
22 KiB
Rust
//! Live wlr-layer-shell slot overlay.
|
|
//!
|
|
// One background thread per active slot. The thread owns its Wayland
|
|
//! connection, creates a top-layer surface anchored at the slot's
|
|
//! top-left, draws the slot number into a shm-backed buffer, and
|
|
//! listens for pointer button events. A button event sends
|
|
//! `swap <slot>` to the daemon's unix socket.
|
|
//!
|
|
//! ## Safety
|
|
//!
|
|
//! All live spawning is gated behind [`crate::overlay::live_enabled`].
|
|
//! `cargo test`, `enboxer doctor`, and any path that does not set
|
|
//! `ENBOXER_ENABLE_OVERLAY=1` returns a placeholder handle from
|
|
//! `crate::overlay::spawn` and **never** opens a Wayland connection.
|
|
//! The user's Hyprland session is therefore never touched unless they
|
|
//! explicitly opt in.
|
|
//!
|
|
//! ## Status
|
|
//!
|
|
//! The protocol code, surface setup, shm-backed buffer with bitmap
|
|
//! digits, and click-to-IPC path are real. The thread draws once on
|
|
//! commit and listens for clicks. Per T9 the badge is readable (3x5
|
|
//! digits, ARGB8888, opaque background, solid foreground).
|
|
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::Arc;
|
|
use crate::overlay::OverlayRect;
|
|
use crate::profile::runtime_dir;
|
|
use std::fs::File;
|
|
use std::io::Write;
|
|
use std::os::unix::io::AsFd;
|
|
use std::path::PathBuf;
|
|
use std::thread::JoinHandle;
|
|
use wayland_client::protocol::{
|
|
wl_buffer, wl_compositor, wl_display, wl_keyboard, wl_output, wl_pointer, wl_registry,
|
|
wl_seat, wl_shm, wl_shm_pool, wl_surface,
|
|
};
|
|
use wayland_client::{Connection, Dispatch, QueueHandle};
|
|
use wayland_protocols_wlr::layer_shell::v1::client::{
|
|
zwlr_layer_shell_v1, zwlr_layer_surface_v1,
|
|
};
|
|
|
|
/// 3x5 bitmap font for digits 0..=9. `1` = set pixel, `0` = blank.
|
|
/// Multi-digit numbers lay the digit bitmaps side-by-side with a 1-pixel gap.
|
|
pub const DIGIT_W: usize = 3;
|
|
pub const DIGIT_H: usize = 5;
|
|
|
|
/// Pure: 3x5 bitmap for a single decimal digit. Panics in debug if `d > 9`.
|
|
pub fn digit_bitmap(d: u8) -> [[u8; DIGIT_W]; DIGIT_H] {
|
|
match d {
|
|
0 => [[1, 1, 1], [1, 0, 1], [1, 0, 1], [1, 0, 1], [1, 1, 1]],
|
|
1 => [[0, 1, 0], [1, 1, 0], [0, 1, 0], [0, 1, 0], [1, 1, 1]],
|
|
2 => [[1, 1, 1], [0, 0, 1], [1, 1, 1], [1, 0, 0], [1, 1, 1]],
|
|
3 => [[1, 1, 1], [0, 0, 1], [1, 1, 1], [0, 0, 1], [1, 1, 1]],
|
|
4 => [[1, 0, 1], [1, 0, 1], [1, 1, 1], [0, 0, 1], [0, 0, 1]],
|
|
5 => [[1, 1, 1], [1, 0, 0], [1, 1, 1], [0, 0, 1], [1, 1, 1]],
|
|
6 => [[1, 1, 1], [1, 0, 0], [1, 1, 1], [1, 0, 1], [1, 1, 1]],
|
|
7 => [[1, 1, 1], [0, 0, 1], [0, 0, 1], [0, 1, 0], [0, 1, 0]],
|
|
8 => [[1, 1, 1], [1, 0, 1], [1, 1, 1], [1, 0, 1], [1, 1, 1]],
|
|
9 => [[1, 1, 1], [1, 0, 1], [1, 1, 1], [0, 0, 1], [1, 1, 1]],
|
|
other => panic!("digit_bitmap: out of range {other}"),
|
|
}
|
|
}
|
|
|
|
/// Pure: layout for `slot` number (1..=99) — each digit at (x_off, y) with a 1-pixel gap.
|
|
fn slot_layout(slot: u32) -> Vec<([[u8; DIGIT_W]; DIGIT_H], usize)> {
|
|
let s = slot.max(1);
|
|
if s < 10 {
|
|
vec![(digit_bitmap(s as u8), 0)]
|
|
} else {
|
|
let tens = (s / 10) as u8;
|
|
let ones = (s % 10) as u8;
|
|
let gap = 1usize;
|
|
vec![
|
|
(digit_bitmap(tens), 0),
|
|
(digit_bitmap(ones), DIGIT_W + gap),
|
|
]
|
|
}
|
|
}
|
|
|
|
/// Pure: write `slot`'s number into a fresh ARGB8888 buffer. `bg` is the
|
|
/// opaque background (alpha forced to 255); `fg` is the foreground.
|
|
pub fn render_overlay(
|
|
width: u32,
|
|
height: u32,
|
|
slot: u32,
|
|
bg: [u8; 4],
|
|
fg: [u8; 4],
|
|
) -> Vec<u8> {
|
|
let mut buf = vec![0u8; (width * height * 4) as usize];
|
|
for px in buf.as_chunks_mut::<4>().0 {
|
|
px[0] = bg[0];
|
|
px[1] = bg[1];
|
|
px[2] = bg[2];
|
|
px[3] = bg[3];
|
|
}
|
|
let digits = slot_layout(slot);
|
|
let total_w: usize = digits
|
|
.iter()
|
|
.map(|(_d, off)| DIGIT_W + off)
|
|
.max()
|
|
.unwrap_or(0);
|
|
let off_x = ((width as usize).saturating_sub(total_w)) / 2;
|
|
let off_y = ((height as usize).saturating_sub(DIGIT_H)) / 2;
|
|
for (bitmap, x_off) in digits {
|
|
for (y, row) in bitmap.iter().enumerate() {
|
|
for (x, &on) in row.iter().enumerate() {
|
|
if on == 0 {
|
|
continue;
|
|
}
|
|
let gx = off_x + x_off + x;
|
|
let gy = off_y + y;
|
|
if gx >= width as usize || gy >= height as usize {
|
|
continue;
|
|
}
|
|
let i = (gy * width as usize + gx) * 4;
|
|
buf[i] = fg[0];
|
|
buf[i + 1] = fg[1];
|
|
buf[i + 2] = fg[2];
|
|
buf[i + 3] = fg[3];
|
|
}
|
|
}
|
|
}
|
|
buf
|
|
}
|
|
|
|
/// Write `pixels` to a tmpfs file in `runtime_dir()` and return the open
|
|
/// `File`. The Wayland compositor holds a dup of the fd; once this handle
|
|
/// drops the kernel keeps the inode alive until both enBoxer and the
|
|
/// compositor close it. The file lives under `$XDG_RUNTIME_DIR/enboxer/`,
|
|
/// so it goes away on reboot.
|
|
fn shm_pool_file(slot: u32, pixels: &[u8]) -> anyhow::Result<File> {
|
|
let dir = runtime_dir();
|
|
std::fs::create_dir_all(&dir).ok();
|
|
let path = dir.join(format!("enboxer-overlay-{slot}.bin"));
|
|
let mut f = File::create(&path)
|
|
.with_context(|| format!("create shm pool file {}", path.display()))?;
|
|
f.write_all(pixels)?;
|
|
f.sync_all().ok();
|
|
Ok(f)
|
|
}
|
|
|
|
use anyhow::Context;
|
|
|
|
/// State shared between Wayland event handlers and the spawning thread.
|
|
struct OverlayState {
|
|
slot: u32,
|
|
ipc_sock: PathBuf,
|
|
compositor: Option<wl_compositor::WlCompositor>,
|
|
layer_shell: Option<zwlr_layer_shell_v1::ZwlrLayerShellV1>,
|
|
shm: Option<wl_shm::WlShm>,
|
|
seat: Option<wl_seat::WlSeat>,
|
|
surface: Option<wl_surface::WlSurface>,
|
|
layer_surface: Option<zwlr_layer_surface_v1::ZwlrLayerSurfaceV1>,
|
|
buffer: Option<wl_buffer::WlBuffer>,
|
|
configured: bool,
|
|
exited: bool,
|
|
}
|
|
|
|
/// Handle to a live overlay thread. Drop or call `shutdown` to stop the
|
|
/// thread cleanly (it will detach if not joined).
|
|
pub struct LiveOverlayHandle {
|
|
pub slot: u32,
|
|
pub rect: OverlayRect,
|
|
/// External kill signal. The dispatch loop in [`run`] polls this
|
|
/// once per roundtrip; flipping to true causes the thread to exit
|
|
/// on the next round, so the join in [`shutdown`] does not block
|
|
/// forever waiting on the compositor's `Closed` event.
|
|
pub stop: Arc<AtomicBool>,
|
|
join: Option<JoinHandle<()>>,
|
|
}
|
|
|
|
impl LiveOverlayHandle {
|
|
/// Ask the thread to exit, then join it. Idempotent.
|
|
pub fn shutdown(mut self) {
|
|
self.stop.store(true, Ordering::Relaxed);
|
|
if let Some(j) = self.join.take() {
|
|
let _ = j.join();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Spawn one live overlay thread. Connects to Wayland, creates the
|
|
/// layer surface, draws the slot number into a shm-backed buffer, and
|
|
/// posts `swap <slot>` to `ipc_sock` on pointer button events inside the
|
|
/// surface. Errors during connect are returned; setup errors after
|
|
/// connect are logged and the thread exits.
|
|
pub fn spawn(slot: u32, rect: OverlayRect, ipc_sock: PathBuf) -> anyhow::Result<LiveOverlayHandle> {
|
|
let stop = Arc::new(AtomicBool::new(false));
|
|
let stop_for_thread = stop.clone();
|
|
let join = std::thread::Builder::new()
|
|
.name(format!("enboxer-overlay-{slot}"))
|
|
.spawn(move || match run(slot, rect, ipc_sock, stop_for_thread) {
|
|
Ok(()) => tracing::debug!("overlay slot {slot} exited cleanly"),
|
|
Err(e) => tracing::warn!("overlay slot {slot}: {e}"),
|
|
})?;
|
|
Ok(LiveOverlayHandle {
|
|
slot,
|
|
rect,
|
|
stop,
|
|
join: Some(join),
|
|
})
|
|
}
|
|
|
|
fn run(slot: u32, rect: OverlayRect, ipc_sock: PathBuf, stop: Arc<AtomicBool>) -> anyhow::Result<()> {
|
|
let conn = Connection::connect_to_env()?;
|
|
let display = conn.display();
|
|
let mut event_queue = conn.new_event_queue::<OverlayState>();
|
|
let qh = event_queue.handle();
|
|
// First roundtrip: bind registry, then run a stub state through one
|
|
// dispatch so registry events are delivered.
|
|
let _registry = display.get_registry(&qh, ());
|
|
let mut stub = OverlayState::new(slot, ipc_sock.clone());
|
|
event_queue.roundtrip(&mut stub)?;
|
|
if !stub.have_bindings() {
|
|
anyhow::bail!(
|
|
"zwlr_layer_shell_v1 / wl_compositor / wl_shm not all advertised by the compositor"
|
|
);
|
|
}
|
|
// Take ownership of the bound globals into our real state.
|
|
let mut state = OverlayState::new(slot, ipc_sock);
|
|
state.compositor = stub.compositor.take();
|
|
state.layer_shell = stub.layer_shell.take();
|
|
state.shm = stub.shm.take();
|
|
state.seat = stub.seat.take();
|
|
// Create surface + layer surface.
|
|
let surface = state
|
|
.compositor
|
|
.as_ref()
|
|
.unwrap()
|
|
.create_surface(&qh, ());
|
|
let layer_shell = state.layer_shell.as_ref().unwrap();
|
|
let layer = layer_shell.get_layer_surface(
|
|
&surface,
|
|
None,
|
|
zwlr_layer_shell_v1::Layer::Top,
|
|
format!("enboxer-slot-{slot}"),
|
|
&qh,
|
|
(),
|
|
);
|
|
layer.set_anchor(zwlr_layer_surface_v1::Anchor::Top | zwlr_layer_surface_v1::Anchor::Left);
|
|
layer.set_size(rect.w.max(1) as u32, rect.h.max(1) as u32);
|
|
layer.set_exclusive_zone(-1);
|
|
// zwlr_layer_surface::set_margin is (top, right, bottom, left).
|
|
// With TOP+LEFT anchor: top margin pushes the surface down, left
|
|
// margin pushes it right. (Earlier versions of this code passed
|
|
// rect.x as the right margin, which is a no-op when only TOP+LEFT
|
|
// are anchored.)
|
|
layer.set_margin(rect.y.max(0), 0, 0, rect.x.max(0));
|
|
layer.set_keyboard_interactivity(zwlr_layer_surface_v1::KeyboardInteractivity::None);
|
|
state.surface = Some(surface.clone());
|
|
state.layer_surface = Some(layer);
|
|
// Seat so we get pointer events.
|
|
if let Some(seat) = state.seat.as_ref() {
|
|
seat.get_pointer(&qh, ());
|
|
let _ = seat.get_keyboard(&qh, ());
|
|
}
|
|
// Shm pool + buffer with the slot number drawn.
|
|
let w = rect.w.max(1) as u32;
|
|
let h = rect.h.max(1) as u32;
|
|
let pixels = render_overlay(w, h, slot, [40, 40, 40, 255], [240, 240, 240, 255]);
|
|
let pool_file = shm_pool_file(slot, &pixels)?;
|
|
let pool = state.shm.as_ref().unwrap().create_pool(
|
|
pool_file.as_fd(),
|
|
pixels.len() as i32,
|
|
&qh,
|
|
(),
|
|
);
|
|
let buffer = pool.create_buffer(
|
|
0,
|
|
w as i32,
|
|
h as i32,
|
|
(w * 4) as i32,
|
|
wl_shm::Format::Argb8888,
|
|
&qh,
|
|
(),
|
|
);
|
|
pool.destroy();
|
|
state.buffer = Some(buffer.clone());
|
|
surface.attach(Some(&buffer), 0, 0);
|
|
surface.commit();
|
|
while !state.exited && !stop.load(Ordering::Relaxed) {
|
|
if let Err(e) = event_queue.blocking_dispatch(&mut state) {
|
|
tracing::warn!("overlay slot {slot}: dispatch: {e}");
|
|
break;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
impl OverlayState {
|
|
fn new(slot: u32, ipc_sock: PathBuf) -> Self {
|
|
Self {
|
|
slot,
|
|
ipc_sock,
|
|
compositor: None,
|
|
layer_shell: None,
|
|
shm: None,
|
|
seat: None,
|
|
surface: None,
|
|
layer_surface: None,
|
|
buffer: None,
|
|
configured: false,
|
|
exited: false,
|
|
}
|
|
}
|
|
|
|
fn have_bindings(&self) -> bool {
|
|
self.compositor.is_some() && self.layer_shell.is_some() && self.shm.is_some()
|
|
}
|
|
}
|
|
|
|
fn send_swap(slot: u32, sock: &PathBuf) {
|
|
let line = format!("swap {slot}\n");
|
|
match std::os::unix::net::UnixStream::connect(sock) {
|
|
Ok(mut s) => {
|
|
use std::io::Write;
|
|
let _ = s.write_all(line.as_bytes());
|
|
}
|
|
Err(e) => tracing::debug!("overlay: click IPC: {e}"),
|
|
}
|
|
}
|
|
|
|
// ---- Dispatch implementations for the wayland types we touch. ----
|
|
//
|
|
// The shape here is mandated by wayland-client 0.31: every proxy type we
|
|
// keep needs `Dispatch<Proxy, UserData> for AppState`. We use `()` for
|
|
// user-data; nothing in this overlay needs per-object state.
|
|
|
|
impl Dispatch<wl_registry::WlRegistry, ()> for OverlayState {
|
|
fn event(
|
|
state: &mut Self,
|
|
registry: &wl_registry::WlRegistry,
|
|
event: wl_registry::Event,
|
|
_: &(),
|
|
_: &Connection,
|
|
qh: &QueueHandle<Self>,
|
|
) {
|
|
if let wl_registry::Event::Global {
|
|
name,
|
|
interface,
|
|
version,
|
|
} = event
|
|
{
|
|
match interface.as_str() {
|
|
"wl_compositor" => {
|
|
state.compositor =
|
|
Some(registry.bind::<wl_compositor::WlCompositor, _, _>(name, version, qh, ()));
|
|
}
|
|
"zwlr_layer_shell_v1" => {
|
|
state.layer_shell = Some(
|
|
registry.bind::<zwlr_layer_shell_v1::ZwlrLayerShellV1, _, _>(
|
|
name, version, qh, (),
|
|
),
|
|
);
|
|
}
|
|
"wl_shm" => {
|
|
state.shm = Some(registry.bind::<wl_shm::WlShm, _, _>(name, version, qh, ()));
|
|
}
|
|
"wl_seat" => {
|
|
state.seat =
|
|
Some(registry.bind::<wl_seat::WlSeat, _, _>(name, version, qh, ()));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Dispatch<wl_compositor::WlCompositor, ()> for OverlayState {
|
|
fn event(
|
|
_: &mut Self,
|
|
_: &wl_compositor::WlCompositor,
|
|
_: wl_compositor::Event,
|
|
_: &(),
|
|
_: &Connection,
|
|
_: &QueueHandle<Self>,
|
|
) {
|
|
}
|
|
}
|
|
|
|
impl Dispatch<wl_shm::WlShm, ()> for OverlayState {
|
|
fn event(
|
|
_: &mut Self,
|
|
_: &wl_shm::WlShm,
|
|
_: wl_shm::Event,
|
|
_: &(),
|
|
_: &Connection,
|
|
_: &QueueHandle<Self>,
|
|
) {
|
|
}
|
|
}
|
|
|
|
impl Dispatch<wl_shm_pool::WlShmPool, ()> for OverlayState {
|
|
fn event(
|
|
_: &mut Self,
|
|
_: &wl_shm_pool::WlShmPool,
|
|
_: wl_shm_pool::Event,
|
|
_: &(),
|
|
_: &Connection,
|
|
_: &QueueHandle<Self>,
|
|
) {
|
|
}
|
|
}
|
|
|
|
impl Dispatch<wl_buffer::WlBuffer, ()> for OverlayState {
|
|
fn event(
|
|
_: &mut Self,
|
|
_: &wl_buffer::WlBuffer,
|
|
_: wl_buffer::Event,
|
|
_: &(),
|
|
_: &Connection,
|
|
_: &QueueHandle<Self>,
|
|
) {
|
|
}
|
|
}
|
|
|
|
impl Dispatch<wl_surface::WlSurface, ()> for OverlayState {
|
|
fn event(
|
|
_: &mut Self,
|
|
_: &wl_surface::WlSurface,
|
|
_: wl_surface::Event,
|
|
_: &(),
|
|
_: &Connection,
|
|
_: &QueueHandle<Self>,
|
|
) {
|
|
}
|
|
}
|
|
|
|
impl Dispatch<wl_seat::WlSeat, ()> for OverlayState {
|
|
fn event(
|
|
_: &mut Self,
|
|
_: &wl_seat::WlSeat,
|
|
_: wl_seat::Event,
|
|
_: &(),
|
|
_: &Connection,
|
|
_: &QueueHandle<Self>,
|
|
) {
|
|
}
|
|
}
|
|
|
|
impl Dispatch<wl_output::WlOutput, ()> for OverlayState {
|
|
fn event(
|
|
_: &mut Self,
|
|
_: &wl_output::WlOutput,
|
|
_: wl_output::Event,
|
|
_: &(),
|
|
_: &Connection,
|
|
_: &QueueHandle<Self>,
|
|
) {
|
|
}
|
|
}
|
|
|
|
impl Dispatch<wl_keyboard::WlKeyboard, ()> for OverlayState {
|
|
fn event(
|
|
_: &mut Self,
|
|
_: &wl_keyboard::WlKeyboard,
|
|
_: wl_keyboard::Event,
|
|
_: &(),
|
|
_: &Connection,
|
|
_: &QueueHandle<Self>,
|
|
) {
|
|
}
|
|
}
|
|
|
|
impl Dispatch<wl_pointer::WlPointer, ()> for OverlayState {
|
|
fn event(
|
|
state: &mut Self,
|
|
_: &wl_pointer::WlPointer,
|
|
event: wl_pointer::Event,
|
|
_: &(),
|
|
_: &Connection,
|
|
_: &QueueHandle<Self>,
|
|
) {
|
|
if let wl_pointer::Event::Button { button, state: btn_state, .. } = event {
|
|
// linux/input-event-codes: BTN_LEFT = 272. We only fire on press.
|
|
if button == 272
|
|
&& matches!(btn_state, wayland_client::WEnum::Value(wl_pointer::ButtonState::Pressed))
|
|
{
|
|
send_swap(state.slot, &state.ipc_sock);
|
|
// NOTE: do not flip `state.exited = true` here. Doing so
|
|
// destroys the badge and `OverlayHub.by_slot` still holds
|
|
// the slot, so the hub refuses to respawn it (it thinks
|
|
// the slot is already served). The compositor's
|
|
// zwlr_layer_surface::Closed event is the only path that
|
|
// should tear down the live thread.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Dispatch<wl_display::WlDisplay, ()> for OverlayState {
|
|
fn event(
|
|
_: &mut Self,
|
|
_: &wl_display::WlDisplay,
|
|
_: wl_display::Event,
|
|
_: &(),
|
|
_: &Connection,
|
|
_: &QueueHandle<Self>,
|
|
) {
|
|
}
|
|
}
|
|
|
|
impl Dispatch<zwlr_layer_shell_v1::ZwlrLayerShellV1, ()> for OverlayState {
|
|
fn event(
|
|
_: &mut Self,
|
|
_: &zwlr_layer_shell_v1::ZwlrLayerShellV1,
|
|
_: zwlr_layer_shell_v1::Event,
|
|
_: &(),
|
|
_: &Connection,
|
|
_: &QueueHandle<Self>,
|
|
) {
|
|
}
|
|
}
|
|
|
|
impl Dispatch<zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, ()> for OverlayState {
|
|
fn event(
|
|
state: &mut Self,
|
|
_: &zwlr_layer_surface_v1::ZwlrLayerSurfaceV1,
|
|
event: zwlr_layer_surface_v1::Event,
|
|
_: &(),
|
|
_: &Connection,
|
|
_: &QueueHandle<Self>,
|
|
) {
|
|
match event {
|
|
zwlr_layer_surface_v1::Event::Closed => state.exited = true,
|
|
zwlr_layer_surface_v1::Event::Configure { .. } => state.configured = true,
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- T9 protocol/format parse test fixture. ----
|
|
//
|
|
// Wayland-scanner parses XML at build time, so we don't parse at runtime.
|
|
// Instead, we hold the constants we expect to match and assert that the
|
|
// generated bindings expose them with the right names. This is the
|
|
// equivalent of "the dispatch table parses a known XML" — if Hyprland
|
|
// ever revved the protocol past version 5 and we silently kept using an
|
|
// older binding, this test would still build (wayland-scanner picks the
|
|
// max) but the constant check would catch a documentation drift.
|
|
|
|
/// The protocol name as it appears in the wlr-layer-shell XML.
|
|
pub const LAYER_SHELL_PROTOCOL: &str = "wlr_layer_shell_unstable_v1";
|
|
/// The interface name we bind to for the layer shell manager.
|
|
pub const LAYER_SHELL_INTERFACE: &str = "zwlr_layer_shell_v1";
|
|
/// The interface name we bind to for one layer surface.
|
|
pub const LAYER_SURFACE_INTERFACE: &str = "zwlr_layer_surface_v1";
|
|
/// The version we ask the compositor for. Hyprland advertises >= 4; 5
|
|
/// matches the XML in `wlr-protocols` at the time of writing.
|
|
pub const LAYER_SHELL_VERSION: u32 = 5;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::overlay::live_enabled;
|
|
|
|
#[test]
|
|
fn digit_zero_has_open_centre() {
|
|
let b = digit_bitmap(0);
|
|
assert_eq!(b[1][1], 0);
|
|
assert_eq!(b[2][1], 0);
|
|
assert_eq!(b[3][1], 0);
|
|
}
|
|
|
|
#[test]
|
|
fn digit_nine_has_solid_top_and_bottom() {
|
|
let b = digit_bitmap(9);
|
|
assert_eq!(b[0], [1, 1, 1]);
|
|
assert_eq!(b[4], [1, 1, 1]);
|
|
assert_eq!(b[1][0], 1);
|
|
assert_eq!(b[1][2], 1);
|
|
}
|
|
|
|
#[test]
|
|
fn render_overlay_is_opaque_when_fg_alpha_255() {
|
|
let buf = render_overlay(96, 96, 7, [0, 0, 0, 255], [255, 255, 255, 255]);
|
|
// The top-left pixel is background → alpha = 255 from bg.
|
|
assert_eq!(buf[3], 255);
|
|
// Find a foreground pixel (the 7's top bar). Should exist and be white.
|
|
let mut found = false;
|
|
for px in buf.as_chunks::<4>().0 {
|
|
if px[0] == 255 && px[1] == 255 && px[2] == 255 {
|
|
found = true;
|
|
assert_eq!(px[3], 255);
|
|
break;
|
|
}
|
|
}
|
|
assert!(found, "expected at least one foreground pixel");
|
|
}
|
|
|
|
#[test]
|
|
fn render_overlay_two_digit_slot_centres_layout() {
|
|
// For slot 12, total digit width = 7 px (3 + 1 gap + 3), centred at x = 44.
|
|
// The first fg pixel of "1" sits at (44, off_y+1) because row 1 of digit 1
|
|
// = [1, 1, 0], so column 0 and 1 are set.
|
|
let buf = render_overlay(96, 96, 12, [0, 0, 0, 255], [255, 255, 255, 255]);
|
|
let off_x = (96usize - 7) / 2;
|
|
let off_y = (96usize - 5) / 2;
|
|
let i = ((off_y + 1) * 96 + off_x) * 4;
|
|
assert_eq!(buf[i..i + 4], [255, 255, 255, 255]);
|
|
}
|
|
|
|
#[test]
|
|
fn protocol_constants_match_xml() {
|
|
// wlr-layer-shell-unstable-v1.xml:
|
|
// <protocol name="wlr_layer_shell_unstable_v1">
|
|
// <interface name="zwlr_layer_shell_v1" version="5">
|
|
// <request name="get_layer_surface">
|
|
// <arg name="id" type="new_id" interface="zwlr_layer_surface_v1"/>
|
|
assert_eq!(LAYER_SHELL_PROTOCOL, "wlr_layer_shell_unstable_v1");
|
|
assert_eq!(LAYER_SHELL_INTERFACE, "zwlr_layer_shell_v1");
|
|
assert_eq!(LAYER_SURFACE_INTERFACE, "zwlr_layer_surface_v1");
|
|
assert_eq!(LAYER_SHELL_VERSION, 5);
|
|
}
|
|
|
|
#[test]
|
|
fn module_compiles_and_exposes_bindings() {
|
|
// The generated bindings are accessible at this module path. If the
|
|
// wayland-scanner ever dropped the module, this fails to compile.
|
|
use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_shell_v1::ZwlrLayerShellV1;
|
|
let _ = std::any::type_name::<ZwlrLayerShellV1>();
|
|
}
|
|
|
|
#[test]
|
|
fn env_gate_off_means_no_live_spawn() {
|
|
std::env::remove_var("ENBOXER_ENABLE_OVERLAY");
|
|
assert!(!live_enabled());
|
|
// We deliberately do not call `super::spawn` here: it would try to
|
|
// open a Wayland connection. The env-gate lives in
|
|
// `crate::overlay::spawn` and is asserted by
|
|
// `crate::overlay::tests::spawn_returns_stub_when_live_disabled`.
|
|
}
|
|
|
|
#[test]
|
|
fn render_digit_table_is_unique() {
|
|
let bits: Vec<[[u8; 3]; 5]> = (0u8..=9).map(digit_bitmap).collect();
|
|
for (i, a) in bits.iter().enumerate() {
|
|
for (j, b) in bits.iter().enumerate().skip(i + 1) {
|
|
assert_ne!(a, b, "digits {i} and {j} share a bitmap");
|
|
}
|
|
}
|
|
}
|
|
} |