feat: initial project with CPU-rendered ring light overlay

Wayland layer-shell overlay using tiny-skia for rendering a glowing
rounded rectangle ring. Includes PID-based lock file toggle via SIGUSR1,
keyboard capture (Escape to exit), and pointer tracking for cursor
avoidance. Configurable ring geometry and glow parameters.
This commit is contained in:
swaits committed 2026-04-07 12:28:32 -06:00
commit 81d6237e2f
7 files changed
+1484

No files matched your search

+105
View File
@@ -0,0 +1,105 @@
//! PID-based lock file for toggle-on/toggle-off behavior.
//!
//! The first invocation creates a lock file and runs the overlay.
//! A second invocation sees the lock, sends SIGUSR1 to the running
//! instance, and exits immediately.
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::{Context, Result};
use nix::sys::signal::{self, SaFlags, SigAction, SigHandler, SigSet, Signal};
use nix::unistd::Pid;
static SHOULD_EXIT: AtomicBool = AtomicBool::new(false);
extern "C" fn handle_sigusr1(_: nix::libc::c_int) {
SHOULD_EXIT.store(true, Ordering::Relaxed);
}
pub enum ToggleResult {
RunNew,
SignaledExisting,
}
/// Check if another instance is running and signal it, or indicate we should run.
pub fn try_toggle() -> Result<ToggleResult> {
let path = lock_file_path();
let contents = match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ToggleResult::RunNew),
Err(e) => return Err(e).context("read lock file"),
};
let pid: i32 = contents
.trim()
.parse()
.context("parse PID from lock file")?;
let pid = Pid::from_raw(pid);
// Check if the process is alive.
match signal::kill(pid, None) {
Ok(()) => {}
Err(nix::errno::Errno::ESRCH) => {
// Stale lock — process is gone.
let _ = fs::remove_file(&path);
return Ok(ToggleResult::RunNew);
}
Err(e) => return Err(e).context("check if PID is alive"),
}
// Process is alive — send SIGUSR1 to tell it to exit.
match signal::kill(pid, Signal::SIGUSR1) {
Ok(()) => Ok(ToggleResult::SignaledExisting),
Err(nix::errno::Errno::ESRCH) => {
// Raced — process died between check and signal.
let _ = fs::remove_file(&path);
Ok(ToggleResult::RunNew)
}
Err(e) => Err(e).context("send SIGUSR1"),
}
}
/// Returns true if SIGUSR1 has been received.
pub fn should_exit() -> bool {
SHOULD_EXIT.load(Ordering::Relaxed)
}
/// Create the lock file and install the SIGUSR1 handler. Returns a guard
/// that removes the lock file on drop.
pub fn create_lock() -> Result<LockGuard> {
let path = lock_file_path();
// Install SIGUSR1 handler before writing the lock file.
let action = SigAction::new(
SigHandler::Handler(handle_sigusr1),
SaFlags::SA_RESTART,
SigSet::empty(),
);
unsafe { signal::sigaction(Signal::SIGUSR1, &action) }.context("install SIGUSR1 handler")?;
let pid = std::process::id();
fs::write(&path, pid.to_string()).context("write lock file")?;
Ok(LockGuard { path })
}
pub struct LockGuard {
path: PathBuf,
}
impl Drop for LockGuard {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
fn lock_file_path() -> PathBuf {
if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
PathBuf::from(dir).join("ringa.lock")
} else {
PathBuf::from("/tmp/ringa.lock")
}
}
+74
View File
@@ -0,0 +1,74 @@
mod lock;
mod render;
mod wayland;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use tiny_skia::Pixmap;
use lock::ToggleResult;
use wayland::{OverlayEvent, WaylandBackend};
const DISPLAY_DURATION: Duration = Duration::from_secs(5);
fn main() -> Result<()> {
match lock::try_toggle()? {
ToggleResult::SignaledExisting => return Ok(()),
ToggleResult::RunNew => {}
}
let _lock = lock::create_lock()?;
let mut wl = WaylandBackend::new()?;
let (w, h) = wl.screen_size();
let mut pixmap = Pixmap::new(w, h).context("create pixmap")?;
let mut argb_buf = vec![0u8; (w as usize) * (h as usize) * 4];
let mut cursor_x = w as f64 / 2.0;
let mut cursor_y = h as f64 / 2.0;
draw(&mut wl, &mut pixmap, &mut argb_buf, cursor_x, cursor_y)?;
let start = Instant::now();
loop {
let elapsed = start.elapsed();
if elapsed >= DISPLAY_DURATION || lock::should_exit() {
break;
}
let remaining = DISPLAY_DURATION - elapsed;
let event = match wl.next_event(remaining)? {
Some(ev) => ev,
None => continue, // poll timeout — loop back to check elapsed time
};
match event {
OverlayEvent::Close => break,
OverlayEvent::CursorMoved(x, y) => {
cursor_x = x;
cursor_y = y;
draw(&mut wl, &mut pixmap, &mut argb_buf, cursor_x, cursor_y)?;
}
OverlayEvent::Configure(new_w, new_h) => {
if new_w != w || new_h != h {
break;
}
}
}
}
Ok(())
}
fn draw(
wl: &mut WaylandBackend,
pixmap: &mut Pixmap,
argb_buf: &mut [u8],
cursor_x: f64,
cursor_y: f64,
) -> Result<()> {
render::render_ring_light(pixmap, cursor_x, cursor_y);
render::pixmap_to_argb8888(pixmap, argb_buf);
let (w, h) = wl.screen_size();
wl.present(argb_buf, w, h)
}
+183
View File
@@ -0,0 +1,183 @@
//! Ring light glow rendering.
//!
//! The ring is a thick stroke centered on a single rounded rectangle SDF.
//! This guarantees uniform width everywhere, including at corners.
//! Glow bleeds outward and inward from the stroke edges.
use tiny_skia::Pixmap;
// The rounded rect centerline is inset this far from the screen edge.
// The visible ring extends RING_HALF_WIDTH on either side of this line.
const CENTERLINE_INSET: f32 = 80.0;
// Half the stroke width. Total ring thickness = 2 * RING_HALF_WIDTH.
const RING_HALF_WIDTH: f32 = 30.0;
// Corner radius of the centerline rounded rect.
const CORNER_RADIUS: f32 = 30.0;
// Soft glow spread outward and inward from the ring edges.
const GLOW_SPREAD: f32 = 50.0;
const RING_ALPHA: f32 = 1.00;
// Cursor avoidance.
const CURSOR_AVOID_RADIUS: f32 = 120.0;
const CURSOR_AVOID_FALLOFF: f32 = 60.0;
/// Render the ring light glow into `pixmap`, avoiding the cursor at `(cx, cy)`.
pub fn render_ring_light(pixmap: &mut Pixmap, cx: f64, cy: f64) {
let w = pixmap.width() as f32;
let h = pixmap.height() as f32;
let cx = cx as f32;
let cy = cy as f32;
let wi = pixmap.width() as usize;
let hi = pixmap.height() as usize;
let data = pixmap.data_mut();
data.fill(0);
let screen_cx = w / 2.0;
let screen_cy = h / 2.0;
// Single rounded rect centerline.
let hw = w / 2.0 - CENTERLINE_INSET;
let hh = h / 2.0 - CENTERLINE_INSET;
// The affected band: from (CENTERLINE_INSET - RING_HALF_WIDTH - GLOW_SPREAD)
// to (CENTERLINE_INSET + RING_HALF_WIDTH + GLOW_SPREAD) from each screen edge.
let band_start = (CENTERLINE_INSET - RING_HALF_WIDTH - GLOW_SPREAD)
.max(0.0)
.floor() as usize;
let band_end = (CENTERLINE_INSET + RING_HALF_WIDTH + GLOW_SPREAD).ceil() as usize + 1;
// Top band
for py in band_start..band_end.min(hi) {
for px in 0..wi {
let a = pixel_alpha(px as f32, py as f32, screen_cx, screen_cy, hw, hh, cx, cy);
write_pixel(data, py * wi + px, a);
}
}
// Bottom band
let bottom_start = hi.saturating_sub(band_end);
let bottom_end = hi.saturating_sub(band_start);
for py in bottom_start..bottom_end.min(hi) {
if py < band_end {
continue;
}
for px in 0..wi {
let a = pixel_alpha(px as f32, py as f32, screen_cx, screen_cy, hw, hh, cx, cy);
write_pixel(data, py * wi + px, a);
}
}
// Left band (between top and bottom)
let vert_start = band_end.min(hi);
let vert_end = hi.saturating_sub(band_end);
for py in vert_start..vert_end {
for px in band_start..band_end.min(wi) {
let a = pixel_alpha(px as f32, py as f32, screen_cx, screen_cy, hw, hh, cx, cy);
write_pixel(data, py * wi + px, a);
}
}
// Right band (between top and bottom)
let right_start = wi.saturating_sub(band_end);
let right_end = wi.saturating_sub(band_start);
for py in vert_start..vert_end {
for px in right_start..right_end.min(wi) {
if px < band_end {
continue;
}
let a = pixel_alpha(px as f32, py as f32, screen_cx, screen_cy, hw, hh, cx, cy);
write_pixel(data, py * wi + px, a);
}
}
}
/// Convert a tiny-skia `Pixmap` (premultiplied RGBA) to Wayland's ARGB8888
/// format (premultiplied BGRA in memory on little-endian).
pub fn pixmap_to_argb8888(pixmap: &Pixmap, out: &mut [u8]) {
let src = pixmap.data();
assert_eq!(out.len(), src.len());
for (dst, src) in out.chunks_exact_mut(4).zip(src.chunks_exact(4)) {
dst[0] = src[2]; // B
dst[1] = src[1]; // G
dst[2] = src[0]; // R
dst[3] = src[3]; // A
}
}
// ---- Private helpers --------------------------------------------------------
/// Compute glow alpha for a single pixel using a single rounded rect SDF.
///
/// The ring is all pixels where |sdf| <= RING_HALF_WIDTH (a thick stroke).
/// Glow fades from the ring edges outward/inward over GLOW_SPREAD pixels.
#[allow(clippy::too_many_arguments)]
fn pixel_alpha(
px: f32,
py: f32,
scx: f32,
scy: f32,
hw: f32,
hh: f32,
cursor_x: f32,
cursor_y: f32,
) -> u8 {
// Signed distance to the rounded rect centerline.
let sd = rounded_rect_sdf(px, py, scx, scy, hw, hh, CORNER_RADIUS);
let abs_sd = sd.abs();
let glow = if abs_sd <= RING_HALF_WIDTH {
// Inside the ring stroke — full brightness.
RING_ALPHA
} else {
// Distance from the nearest ring edge.
let dist = abs_sd - RING_HALF_WIDTH;
if dist >= GLOW_SPREAD {
return 0;
}
let t = dist / GLOW_SPREAD;
smoothstep(1.0 - t) * RING_ALPHA
};
// Cursor avoidance.
let dx = px - cursor_x;
let dy = py - cursor_y;
let cursor_dist = (dx * dx + dy * dy).sqrt();
let cursor_factor =
smoothstep(((cursor_dist - CURSOR_AVOID_RADIUS) / CURSOR_AVOID_FALLOFF).clamp(0.0, 1.0));
let alpha = glow * cursor_factor;
(alpha * 255.0) as u8
}
/// Signed distance field for a rounded rectangle.
///
/// Returns negative inside, positive outside, zero on the boundary.
fn rounded_rect_sdf(px: f32, py: f32, cx: f32, cy: f32, hw: f32, hh: f32, r: f32) -> f32 {
let dx = (px - cx).abs() - (hw - r);
let dy = (py - cy).abs() - (hh - r);
let outside = dx.max(0.0);
let outside_y = dy.max(0.0);
let outside_len = (outside * outside + outside_y * outside_y).sqrt();
let inside = dx.max(dy).min(0.0);
outside_len + inside - r
}
/// Write a premultiplied white pixel with the given alpha.
fn write_pixel(data: &mut [u8], idx: usize, alpha: u8) {
if alpha == 0 {
return;
}
let offset = idx * 4;
data[offset] = alpha;
data[offset + 1] = alpha;
data[offset + 2] = alpha;
data[offset + 3] = alpha;
}
/// Hermite smoothstep: 3t^2 - 2t^3
fn smoothstep(t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
+409
View File
@@ -0,0 +1,409 @@
//! Wayland backend: layer-shell overlay with keyboard and pointer tracking.
//!
//! Creates a full-screen layer-shell surface for the ring light overlay.
//! Tracks pointer position for cursor avoidance and captures keyboard
//! for Escape to exit.
use std::collections::VecDeque;
use std::io::Write;
use std::os::fd::AsFd;
use std::time::Duration;
use wayland_client::{
delegate_noop,
protocol::{
wl_buffer, wl_compositor, wl_keyboard, wl_pointer, wl_region, wl_registry, wl_seat,
wl_shm, wl_shm_pool, wl_surface,
},
Connection, Dispatch, EventQueue, QueueHandle, WEnum,
};
use wayland_protocols_wlr::layer_shell::v1::client::{zwlr_layer_shell_v1, zwlr_layer_surface_v1};
use anyhow::{Context, Result};
// Linux evdev key code for Escape.
const KEY_ESCAPE: u32 = 1;
/// Events emitted by the Wayland backend to the main loop.
pub enum OverlayEvent {
/// Escape pressed or surface closed by compositor.
Close,
/// Pointer moved to (x, y) in surface-local coordinates.
CursorMoved(f64, f64),
/// Surface (re-)configured with new dimensions.
Configure(u32, u32),
}
// ===========================================================================
// WaylandBackend — public API.
// ===========================================================================
pub struct WaylandBackend {
state: WaylandState,
event_queue: EventQueue<WaylandState>,
queue_handle: QueueHandle<WaylandState>,
}
impl WaylandBackend {
pub fn new() -> Result<Self> {
let conn = Connection::connect_to_env().context("connect to Wayland display")?;
let mut event_queue = conn.new_event_queue();
let queue_handle = event_queue.handle();
conn.display().get_registry(&queue_handle, ());
let mut state = WaylandState {
conn,
compositor: None,
shm: None,
surface: None,
layer_shell: None,
layer_surface: None,
seat: None,
screen_w: 0,
screen_h: 0,
configured: false,
pending_events: VecDeque::new(),
};
event_queue
.roundtrip(&mut state)
.context("initial roundtrip")?;
if state.compositor.is_none() {
anyhow::bail!("compositor does not advertise wl_compositor");
}
if state.shm.is_none() {
anyhow::bail!("compositor does not advertise wl_shm");
}
if state.layer_shell.is_none() {
anyhow::bail!(
"compositor does not support wlr-layer-shell-unstable-v1. \
Supported compositors: Sway, Hyprland, river, etc."
);
}
state.init_layer_surface(&queue_handle)?;
event_queue
.roundtrip(&mut state)
.context("roundtrip after layer surface")?;
while !state.configured {
event_queue
.blocking_dispatch(&mut state)
.context("waiting for configure")?;
}
Ok(Self {
state,
event_queue,
queue_handle,
})
}
pub fn screen_size(&self) -> (u32, u32) {
(self.state.screen_w, self.state.screen_h)
}
/// Wait up to `timeout` for the next event. Returns `None` on timeout
/// or if the surface has been torn down.
pub fn next_event(&mut self, timeout: Duration) -> Result<Option<OverlayEvent>> {
// First drain any already-buffered events.
if let Some(ev) = self.state.pending_events.pop_front() {
return Ok(Some(ev));
}
if self.state.surface.is_none() {
return Ok(None);
}
// Flush outgoing requests, then poll the fd with a timeout.
self.event_queue
.flush()
.context("flush before poll")?;
let read_guard = self.event_queue.prepare_read().unwrap();
let fd = read_guard.connection_fd();
let mut pollfd = [nix::poll::PollFd::new(fd, nix::poll::PollFlags::POLLIN)];
let millis = timeout.as_millis().min(u16::MAX as u128) as u16;
let n = nix::poll::poll(&mut pollfd, millis).context("poll")?;
if n > 0 {
// Data available — read and dispatch.
let _ = read_guard.read();
} else {
// Timeout — drop the read guard (cancels the read).
drop(read_guard);
return Ok(None);
}
self.event_queue
.dispatch_pending(&mut self.state)
.context("dispatch_pending")?;
Ok(self.state.pending_events.pop_front())
}
/// Copy an ARGB8888 pixel buffer to the overlay surface via wl_shm.
pub fn present(&mut self, pixels: &[u8], width: u32, height: u32) -> Result<()> {
let shm = self.state.shm.as_ref().context("wl_shm not available")?;
let surface = self
.state
.surface
.as_ref()
.context("wl_surface not available")?;
let stride = width * 4;
let mut file = tempfile::tempfile().context("create shm tempfile")?;
file.write_all(pixels).context("write pixel buffer")?;
let pool = shm.create_pool(file.as_fd(), pixels.len() as i32, &self.queue_handle, ());
let buffer = pool.create_buffer(
0,
width as i32,
height as i32,
stride as i32,
wl_shm::Format::Argb8888,
&self.queue_handle,
(),
);
surface.attach(Some(&buffer), 0, 0);
surface.damage_buffer(0, 0, width as i32, height as i32);
surface.commit();
pool.destroy();
Ok(())
}
}
impl Drop for WaylandBackend {
fn drop(&mut self) {
if let Some(ls) = self.state.layer_surface.take() {
ls.destroy();
}
if let Some(s) = self.state.surface.take() {
s.destroy();
}
let _ = self.state.conn.flush();
}
}
// ===========================================================================
// Internal state.
// ===========================================================================
struct WaylandState {
conn: Connection,
compositor: Option<wl_compositor::WlCompositor>,
shm: Option<wl_shm::WlShm>,
seat: Option<wl_seat::WlSeat>,
layer_shell: Option<zwlr_layer_shell_v1::ZwlrLayerShellV1>,
surface: Option<wl_surface::WlSurface>,
layer_surface: Option<zwlr_layer_surface_v1::ZwlrLayerSurfaceV1>,
screen_w: u32,
screen_h: u32,
configured: bool,
pending_events: VecDeque<OverlayEvent>,
}
impl WaylandState {
fn init_layer_surface(&mut self, qh: &QueueHandle<Self>) -> Result<()> {
let compositor = self
.compositor
.as_ref()
.context("wl_compositor not available")?;
let layer_shell = self
.layer_shell
.as_ref()
.context("zwlr_layer_shell_v1 not available")?;
let surface = compositor.create_surface(qh, ());
let layer_surface = layer_shell.get_layer_surface(
&surface,
None,
zwlr_layer_shell_v1::Layer::Overlay,
"ringa".to_string(),
qh,
(),
);
layer_surface.set_size(0, 0);
layer_surface.set_anchor(
zwlr_layer_surface_v1::Anchor::Top
| zwlr_layer_surface_v1::Anchor::Bottom
| zwlr_layer_surface_v1::Anchor::Left
| zwlr_layer_surface_v1::Anchor::Right,
);
layer_surface.set_exclusive_zone(-1);
layer_surface
.set_keyboard_interactivity(zwlr_layer_surface_v1::KeyboardInteractivity::Exclusive);
surface.commit();
self.surface = Some(surface);
self.layer_surface = Some(layer_surface);
Ok(())
}
}
// ===========================================================================
// Wayland dispatch implementations.
// ===========================================================================
impl Dispatch<wl_registry::WlRegistry, ()> for WaylandState {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
let wl_registry::Event::Global {
name,
interface,
version,
} = event
else {
return;
};
match interface.as_str() {
"wl_compositor" => {
state.compositor = Some(registry.bind(name, version.min(4), qh, ()));
}
"wl_shm" => {
state.shm = Some(registry.bind(name, 1, qh, ()));
}
"wl_seat" => {
state.seat = Some(registry.bind(name, version.min(7), qh, ()));
}
"zwlr_layer_shell_v1" => {
state.layer_shell = Some(registry.bind(name, version.min(4), qh, ()));
}
_ => {}
}
}
}
impl Dispatch<wl_seat::WlSeat, ()> for WaylandState {
fn event(
_state: &mut Self,
seat: &wl_seat::WlSeat,
event: wl_seat::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
if let wl_seat::Event::Capabilities {
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, ());
}
}
}
}
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,
_: &wl_pointer::WlPointer,
event: wl_pointer::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
match event {
wl_pointer::Event::Enter {
surface_x,
surface_y,
..
} => {
state
.pending_events
.push_back(OverlayEvent::CursorMoved(surface_x, surface_y));
}
wl_pointer::Event::Motion {
surface_x,
surface_y,
..
} => {
state
.pending_events
.push_back(OverlayEvent::CursorMoved(surface_x, surface_y));
}
_ => {}
}
}
}
impl Dispatch<zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, ()> for WaylandState {
fn event(
state: &mut Self,
layer_surface: &zwlr_layer_surface_v1::ZwlrLayerSurfaceV1,
event: zwlr_layer_surface_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
match event {
zwlr_layer_surface_v1::Event::Configure {
serial,
width,
height,
} => {
layer_surface.ack_configure(serial);
const MAX_SCREEN_DIM: u32 = 16384;
if width > 0 && height > 0 && width <= MAX_SCREEN_DIM && height <= MAX_SCREEN_DIM {
state.screen_w = width;
state.screen_h = height;
state.configured = true;
state
.pending_events
.push_back(OverlayEvent::Configure(width, height));
}
}
zwlr_layer_surface_v1::Event::Closed => {
state.pending_events.push_back(OverlayEvent::Close);
}
_ => {}
}
}
}
delegate_noop!(WaylandState: ignore wl_compositor::WlCompositor);
delegate_noop!(WaylandState: ignore wl_region::WlRegion);
delegate_noop!(WaylandState: ignore wl_surface::WlSurface);
delegate_noop!(WaylandState: ignore wl_shm::WlShm);
delegate_noop!(WaylandState: ignore wl_shm_pool::WlShmPool);
delegate_noop!(WaylandState: ignore wl_buffer::WlBuffer);
delegate_noop!(WaylandState: ignore zwlr_layer_shell_v1::ZwlrLayerShellV1);