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", "hashbrown 0.16.1",
] ]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]] [[package]]
name = "jni-sys" name = "jni-sys"
version = "0.3.1" version = "0.3.1"
@@ -492,6 +498,16 @@ dependencies = [
"jni-sys 0.3.1", "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]] [[package]]
name = "nix" name = "nix"
version = "0.29.0" version = "0.29.0"
@@ -731,6 +747,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytemuck", "bytemuck",
"niri-ipc",
"nix", "nix",
"pollster", "pollster",
"raw-window-handle", "raw-window-handle",
@@ -807,6 +824,19 @@ dependencies = [
"syn", "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]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
@@ -1366,3 +1396,9 @@ dependencies = [
"quote", "quote",
"syn", "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] [dependencies]
anyhow = "1.0" anyhow = "1.0"
bytemuck = { version = "1", features = ["derive"] } bytemuck = { version = "1", features = ["derive"] }
niri-ipc = "25.11.0"
nix = { version = "0.29", features = ["signal", "process", "poll"] } nix = { version = "0.29", features = ["signal", "process", "poll"] }
pollster = "0.4" pollster = "0.4"
raw-window-handle = "0.6" raw-window-handle = "0.6"
+8 -6
View File
@@ -37,8 +37,9 @@ fn main() -> Result<()> {
color, color,
)?; )?;
let mut cursor_x = w as f64 / 2.0; // Initial cursor offscreen — will update when pointer enters ring zone.
let mut cursor_y = h as f64 / 2.0; let mut cursor_x = -10000.0_f64;
let mut cursor_y = -10000.0_f64;
renderer.render(cursor_x, cursor_y)?; renderer.render(cursor_x, cursor_y)?;
@@ -76,6 +77,9 @@ fn main() -> Result<()> {
OverlayEvent::CursorMoved(x, y) => { OverlayEvent::CursorMoved(x, y) => {
cursor_x = x; cursor_x = x;
cursor_y = y; 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; dirty = true;
} }
OverlayEvent::Configure(_, _) => {} OverlayEvent::Configure(_, _) => {}
@@ -98,7 +102,7 @@ fn main() -> Result<()> {
fn parse_color_arg() -> [f32; 3] { fn parse_color_arg() -> [f32; 3] {
let arg = match std::env::args().nth(1) { let arg = match std::env::args().nth(1) {
Some(a) => a, 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() { let kelvin = match arg.to_lowercase().as_str() {
@@ -111,9 +115,7 @@ fn parse_color_arg() -> [f32; 3] {
other => match other.parse::<u32>() { other => match other.parse::<u32>() {
Ok(k) if (1000..=40000).contains(&k) => k, Ok(k) if (1000..=40000).contains(&k) => k,
_ => { _ => {
eprintln!( eprintln!("usage: ringa [white|warm|golden|studio|daylight|cool|<kelvin>]");
"usage: ringa [white|warm|golden|studio|daylight|cool|<kelvin>]"
);
std::process::exit(1); 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 //! The input region is dynamically updated to be the full screen MINUS a
//! for wgpu surface creation. No CPU pixel buffers — GPU renders directly. //! 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::collections::VecDeque;
use std::ptr::NonNull; use std::ptr::NonNull;
@@ -22,19 +25,20 @@ use raw_window_handle::{
RawDisplayHandle, RawWindowHandle, WaylandDisplayHandle, WaylandWindowHandle, 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 { pub enum OverlayEvent {
Close, Close,
CursorMoved(f64, f64), CursorMoved(f64, f64),
#[allow(dead_code)]
Configure(u32, u32), Configure(u32, u32),
} }
pub struct WaylandBackend { pub struct WaylandBackend {
state: WaylandState, state: WaylandState,
event_queue: EventQueue<WaylandState>, event_queue: EventQueue<WaylandState>,
#[allow(dead_code)]
queue_handle: QueueHandle<WaylandState>, queue_handle: QueueHandle<WaylandState>,
} }
@@ -96,14 +100,12 @@ impl WaylandBackend {
(self.state.screen_w, self.state.screen_h) (self.state.screen_w, self.state.screen_h)
} }
/// Raw Wayland display handle for wgpu surface creation.
pub fn raw_display_handle(&self) -> Result<RawDisplayHandle> { pub fn raw_display_handle(&self) -> Result<RawDisplayHandle> {
let ptr = self.state.conn.backend().display_ptr() as *mut std::ffi::c_void; 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")?; let nn = NonNull::new(ptr).context("wl_display pointer is null")?;
Ok(RawDisplayHandle::Wayland(WaylandDisplayHandle::new(nn))) Ok(RawDisplayHandle::Wayland(WaylandDisplayHandle::new(nn)))
} }
/// Raw Wayland surface handle for wgpu surface creation.
pub fn raw_window_handle(&self) -> Result<RawWindowHandle> { pub fn raw_window_handle(&self) -> Result<RawWindowHandle> {
let surface = self.state.surface.as_ref().context("no wl_surface")?; let surface = self.state.surface.as_ref().context("no wl_surface")?;
let ptr = surface.id().as_ptr() as *mut std::ffi::c_void; let ptr = surface.id().as_ptr() as *mut std::ffi::c_void;
@@ -111,7 +113,32 @@ impl WaylandBackend {
Ok(RawWindowHandle::Wayland(WaylandWindowHandle::new(nn))) 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>> { pub fn next_event(&mut self, timeout: Duration) -> Result<Option<OverlayEvent>> {
if let Some(ev) = self.state.pending_events.pop_front() { if let Some(ev) = self.state.pending_events.pop_front() {
return Ok(Some(ev)); return Ok(Some(ev));
@@ -124,15 +151,12 @@ impl WaylandBackend {
.flush() .flush()
.context("flush before poll")?; .context("flush before poll")?;
// Try to dispatch any already-buffered events first.
self.event_queue self.event_queue
.dispatch_pending(&mut self.state) .dispatch_pending(&mut self.state)
.context("dispatch_pending (pre-poll)")?; .context("dispatch_pending (pre-poll)")?;
if let Some(ev) = self.state.pending_events.pop_front() { 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); self.state.pending_events.push_front(ev);
} else { } else {
// No pending events — try to read from the fd with a timeout.
match self.event_queue.prepare_read() { match self.event_queue.prepare_read() {
Some(read_guard) => { Some(read_guard) => {
let fd = read_guard.connection_fd(); let fd = read_guard.connection_fd();
@@ -153,7 +177,6 @@ impl WaylandBackend {
.context("dispatch_pending (post-read)")?; .context("dispatch_pending (post-read)")?;
} }
None => { None => {
// Another queue has events buffered — dispatch ours.
self.event_queue self.event_queue
.dispatch_pending(&mut self.state) .dispatch_pending(&mut self.state)
.context("dispatch_pending (no-read)")?; .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 last_cursor: Option<(f64, f64)> = None;
let mut first_non_cursor: Option<OverlayEvent> = None; let mut first_non_cursor: Option<OverlayEvent> = None;
while let Some(ev) = self.state.pending_events.pop_front() { while let Some(ev) = self.state.pending_events.pop_front() {
@@ -236,8 +259,12 @@ impl WaylandState {
let surface = compositor.create_surface(qh, ()); 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, ()); 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)); surface.set_input_region(Some(&input_region));
let layer_surface = layer_shell.get_layer_surface( let layer_surface = layer_shell.get_layer_surface(
@@ -258,7 +285,7 @@ impl WaylandState {
); );
layer_surface.set_exclusive_zone(-1); layer_surface.set_exclusive_zone(-1);
layer_surface layer_surface
.set_keyboard_interactivity(zwlr_layer_surface_v1::KeyboardInteractivity::Exclusive); .set_keyboard_interactivity(zwlr_layer_surface_v1::KeyboardInteractivity::None);
surface.commit(); surface.commit();
@@ -320,9 +347,6 @@ impl Dispatch<wl_seat::WlSeat, ()> for WaylandState {
capabilities: WEnum::Value(caps), capabilities: WEnum::Value(caps),
} = event } = event
{ {
if caps.contains(wl_seat::Capability::Keyboard) {
seat.get_keyboard(qh, ());
}
if caps.contains(wl_seat::Capability::Pointer) { if caps.contains(wl_seat::Capability::Pointer) {
seat.get_pointer(qh, ()); 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 { impl Dispatch<wl_pointer::WlPointer, ()> for WaylandState {
fn event( fn event(
state: &mut Self, 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 { impl Dispatch<zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, ()> for WaylandState {
fn event( fn event(
state: &mut Self, state: &mut Self,