feat: full desktop passthrough with dynamic cursor hole

Replace static input region with a dynamically updated wl_region that
follows the cursor. The full screen receives pointer events except for a
512px hole around the cursor position, allowing clicks and scrolls to
pass through to the desktop. Cursor avoidance shader updates in sync.
This commit is contained in:
swaits committed 2026-04-07 12:28:40 -06:00
1 parent dacc631d57
commit d73ba4af11
4 files changed
+100 -47

No files matched your search

+8 -6
View File
@@ -37,8 +37,9 @@ fn main() -> Result<()> {
color,
)?;
let mut cursor_x = w as f64 / 2.0;
let mut cursor_y = h as f64 / 2.0;
// Initial cursor offscreen — will update when pointer enters ring zone.
let mut cursor_x = -10000.0_f64;
let mut cursor_y = -10000.0_f64;
renderer.render(cursor_x, cursor_y)?;
@@ -76,6 +77,9 @@ fn main() -> Result<()> {
OverlayEvent::CursorMoved(x, y) => {
cursor_x = x;
cursor_y = y;
// Punch a passthrough hole at the new cursor position so
// clicks go to the desktop, then re-render the shader.
wl.update_cursor_hole(cursor_x, cursor_y)?;
dirty = true;
}
OverlayEvent::Configure(_, _) => {}
@@ -98,7 +102,7 @@ fn main() -> Result<()> {
fn parse_color_arg() -> [f32; 3] {
let arg = match std::env::args().nth(1) {
Some(a) => a,
None => return [1.0, 1.0, 1.0], // pure white
None => return [1.0, 1.0, 1.0],
};
let kelvin = match arg.to_lowercase().as_str() {
@@ -111,9 +115,7 @@ fn parse_color_arg() -> [f32; 3] {
other => match other.parse::<u32>() {
Ok(k) if (1000..=40000).contains(&k) => k,
_ => {
eprintln!(
"usage: ringa [white|warm|golden|studio|daylight|cool|<kelvin>]"
);
eprintln!("usage: ringa [white|warm|golden|studio|daylight|cool|<kelvin>]");
std::process::exit(1);
}
},
+55 -41
View File
@@ -1,7 +1,10 @@
//! Wayland backend: layer-shell overlay with keyboard and pointer tracking.
//! Wayland backend: layer-shell overlay with pointer tracking.
//!
//! Creates a full-screen layer-shell surface. Exposes raw Wayland handles
//! for wgpu surface creation. No CPU pixel buffers — GPU renders directly.
//! The input region is dynamically updated to be the full screen MINUS a
//! hole around the cursor. This lets the cursor pass through to the desktop
//! while still letting us track its position: when the cursor moves outside
//! the hole, it lands on our surface, we get the event, punch a new hole,
//! and the cursor passes through again.
use std::collections::VecDeque;
use std::ptr::NonNull;
@@ -22,19 +25,20 @@ use raw_window_handle::{
RawDisplayHandle, RawWindowHandle, WaylandDisplayHandle, WaylandWindowHandle,
};
const KEY_ESCAPE: u32 = 1;
/// Size of the passthrough hole around the cursor. Should be large enough
/// that normal mouse movement stays within the hole between frames.
const CURSOR_HOLE_SIZE: i32 = 512;
#[allow(dead_code)]
pub enum OverlayEvent {
Close,
CursorMoved(f64, f64),
#[allow(dead_code)]
Configure(u32, u32),
}
pub struct WaylandBackend {
state: WaylandState,
event_queue: EventQueue<WaylandState>,
#[allow(dead_code)]
queue_handle: QueueHandle<WaylandState>,
}
@@ -96,14 +100,12 @@ impl WaylandBackend {
(self.state.screen_w, self.state.screen_h)
}
/// Raw Wayland display handle for wgpu surface creation.
pub fn raw_display_handle(&self) -> Result<RawDisplayHandle> {
let ptr = self.state.conn.backend().display_ptr() as *mut std::ffi::c_void;
let nn = NonNull::new(ptr).context("wl_display pointer is null")?;
Ok(RawDisplayHandle::Wayland(WaylandDisplayHandle::new(nn)))
}
/// Raw Wayland surface handle for wgpu surface creation.
pub fn raw_window_handle(&self) -> Result<RawWindowHandle> {
let surface = self.state.surface.as_ref().context("no wl_surface")?;
let ptr = surface.id().as_ptr() as *mut std::ffi::c_void;
@@ -111,7 +113,32 @@ impl WaylandBackend {
Ok(RawWindowHandle::Wayland(WaylandWindowHandle::new(nn)))
}
/// Wait up to `timeout` for the next event. Returns `None` on timeout.
/// Update the input region to punch a passthrough hole at the cursor.
pub fn update_cursor_hole(&mut self, cx: f64, cy: f64) -> Result<()> {
let compositor = self
.state
.compositor
.as_ref()
.context("wl_compositor not available")?;
let surface = self.state.surface.as_ref().context("no wl_surface")?;
let w = self.state.screen_w as i32;
let h = self.state.screen_h as i32;
let half = CURSOR_HOLE_SIZE / 2;
let hx = (cx as i32).clamp(0, w);
let hy = (cy as i32).clamp(0, h);
// Full screen minus a hole around the cursor.
let region = compositor.create_region(&self.queue_handle, ());
region.add(0, 0, w, h);
region.subtract(hx - half, hy - half, CURSOR_HOLE_SIZE, CURSOR_HOLE_SIZE);
surface.set_input_region(Some(&region));
surface.commit();
self.state.conn.flush().context("flush after cursor hole")?;
Ok(())
}
pub fn next_event(&mut self, timeout: Duration) -> Result<Option<OverlayEvent>> {
if let Some(ev) = self.state.pending_events.pop_front() {
return Ok(Some(ev));
@@ -124,15 +151,12 @@ impl WaylandBackend {
.flush()
.context("flush before poll")?;
// Try to dispatch any already-buffered events first.
self.event_queue
.dispatch_pending(&mut self.state)
.context("dispatch_pending (pre-poll)")?;
if let Some(ev) = self.state.pending_events.pop_front() {
// Re-run coalescing below with this event re-queued.
self.state.pending_events.push_front(ev);
} else {
// No pending events — try to read from the fd with a timeout.
match self.event_queue.prepare_read() {
Some(read_guard) => {
let fd = read_guard.connection_fd();
@@ -153,7 +177,6 @@ impl WaylandBackend {
.context("dispatch_pending (post-read)")?;
}
None => {
// Another queue has events buffered — dispatch ours.
self.event_queue
.dispatch_pending(&mut self.state)
.context("dispatch_pending (no-read)")?;
@@ -161,7 +184,7 @@ impl WaylandBackend {
}
}
// Coalesce CursorMoved events.
// Coalesce CursorMoved — only keep the latest position.
let mut last_cursor: Option<(f64, f64)> = None;
let mut first_non_cursor: Option<OverlayEvent> = None;
while let Some(ev) = self.state.pending_events.pop_front() {
@@ -236,8 +259,12 @@ impl WaylandState {
let surface = compositor.create_surface(qh, ());
// Start with full-screen input region. The cursor hole will be
// punched on the first pointer event.
let input_region = compositor.create_region(qh, ());
input_region.add(0, 0, i32::MAX, i32::MAX);
let w = i32::MAX;
let h = i32::MAX;
input_region.add(0, 0, w, h);
surface.set_input_region(Some(&input_region));
let layer_surface = layer_shell.get_layer_surface(
@@ -258,7 +285,7 @@ impl WaylandState {
);
layer_surface.set_exclusive_zone(-1);
layer_surface
.set_keyboard_interactivity(zwlr_layer_surface_v1::KeyboardInteractivity::Exclusive);
.set_keyboard_interactivity(zwlr_layer_surface_v1::KeyboardInteractivity::None);
surface.commit();
@@ -320,9 +347,6 @@ impl Dispatch<wl_seat::WlSeat, ()> for WaylandState {
capabilities: WEnum::Value(caps),
} = event
{
if caps.contains(wl_seat::Capability::Keyboard) {
seat.get_keyboard(qh, ());
}
if caps.contains(wl_seat::Capability::Pointer) {
seat.get_pointer(qh, ());
}
@@ -330,28 +354,6 @@ impl Dispatch<wl_seat::WlSeat, ()> for WaylandState {
}
}
impl Dispatch<wl_keyboard::WlKeyboard, ()> for WaylandState {
fn event(
state: &mut Self,
_: &wl_keyboard::WlKeyboard,
event: wl_keyboard::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_keyboard::Event::Key {
key,
state: WEnum::Value(wl_keyboard::KeyState::Pressed),
..
} = event
{
if key == KEY_ESCAPE {
state.pending_events.push_back(OverlayEvent::Close);
}
}
}
}
impl Dispatch<wl_pointer::WlPointer, ()> for WaylandState {
fn event(
state: &mut Self,
@@ -381,6 +383,18 @@ impl Dispatch<wl_pointer::WlPointer, ()> for WaylandState {
}
}
impl Dispatch<wl_keyboard::WlKeyboard, ()> for WaylandState {
fn event(
_: &mut Self,
_: &wl_keyboard::WlKeyboard,
_: wl_keyboard::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, ()> for WaylandState {
fn event(
state: &mut Self,