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

Generated
+36
View File
@@ -345,6 +345,12 @@ dependencies = [
"hashbrown 0.16.1",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jni-sys"
version = "0.3.1"
@@ -492,6 +498,16 @@ dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "niri-ipc"
version = "25.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12bd9eb4e437f5282ce853cf66837658379cb537b4b6bae687da59246005329a"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "nix"
version = "0.29.0"
@@ -731,6 +747,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"bytemuck",
"niri-ipc",
"nix",
"pollster",
"raw-window-handle",
@@ -807,6 +824,19 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "shlex"
version = "1.3.0"
@@ -1366,3 +1396,9 @@ dependencies = [
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+1
View File
@@ -9,6 +9,7 @@ rust-version = "1.85"
[dependencies]
anyhow = "1.0"
bytemuck = { version = "1", features = ["derive"] }
niri-ipc = "25.11.0"
nix = { version = "0.29", features = ["signal", "process", "poll"] }
pollster = "0.4"
raw-window-handle = "0.6"
+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,