Files
ringa/src/render.rs
T
swaits 81d6237e2f 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.
2026-04-07 12:28:32 -06:00

184 lines
5.9 KiB
Rust

//! 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)
}