use std::sync::mpsc;
use std::time::{Duration, Instant};
#[derive(Debug, thiserror::Error)]
enum OverlayError {
#[error("Wayland connection error: {0}")]
Connect(#[from] wayland_client::ConnectError),
#[error("Wayland globals error: {0}")]
Globals(#[from] wayland_client::globals::GlobalError),
#[error("smithay bind error: {0}")]
Bind(#[from] wayland_client::globals::BindError),
#[error("smithay shm create error: {0}")]
Shm(#[from] smithay_client_toolkit::shm::CreatePoolError),
#[error("Wayland dispatch error: {0}")]
Dispatch(#[from] wayland_client::DispatchError),
#[error("X11 display connect error: {0}")]
X11Connect(#[from] x11rb::errors::ConnectError),
#[error("X11 protocol/IO error: {0}")]
X11Connection(#[from] x11rb::errors::ConnectionError),
#[error("X11 reply error: {0}")]
X11Reply(#[from] x11rb::errors::ReplyError),
#[error("X11 reply/id error: {0}")]
X11ReplyOrId(#[from] x11rb::errors::ReplyOrIdError),
#[error("X11 ARGB visual not available")]
X11Visual,
#[error("D-Bus error: {0}")]
DBus(#[from] zbus::Error),
#[error("D-Bus signal error: {0}")]
DBusSignal(#[from] zbus::fdo::Error),
#[error("tiny-skia pixmap allocation failed for {0}x{1}")]
Pixmap(u32, u32),
}
use smithay_client_toolkit::{
compositor::{CompositorHandler, CompositorState},
delegate_compositor, delegate_layer, delegate_output, delegate_registry, delegate_shm,
output::{OutputHandler, OutputState},
registry::{ProvidesRegistryState, RegistryState},
registry_handlers,
shell::{
wlr_layer::{
Anchor, KeyboardInteractivity, Layer, LayerShell, LayerShellHandler, LayerSurface,
LayerSurfaceConfigure,
},
WaylandSurface,
},
shm::{slot::SlotPool, Shm, ShmHandler},
};
use tiny_skia::{
Color, FillRule, Paint, PathBuilder, Pixmap, PremultipliedColorU8, Rect, Transform,
};
use tokio::sync::watch;
use tracing::{info, warn};
use wayland_client::{
globals::registry_queue_init,
protocol::{wl_output, wl_region, wl_shm, wl_surface},
Connection as WaylandConnection, Dispatch, QueueHandle,
};
use x11rb::connection::{Connection as X11Connection, RequestConnection};
use x11rb::protocol::{
randr::{ConnectionExt as X11RandrConnectionExt, MonitorInfo, X11_EXTENSION_NAME as RANDR_EXT},
shape::{ConnectionExt as X11ShapeConnectionExt, SK, SO, X11_EXTENSION_NAME as SHAPE_EXT},
xproto::{
AtomEnum, ClipOrdering, ColormapAlloc, ConfigureWindowAux,
ConnectionExt as X11ProtoConnectionExt, CreateGCAux, CreateWindowAux, EventMask,
ImageFormat, PropMode, Rectangle, Screen, StackMode, VisualClass, Visualid, Window,
WindowClass,
},
};
use x11rb::rust_connection::RustConnection;
use x11rb::wrapper::ConnectionExt as X11WrapperConnectionExt;
use crate::{OverlayConfig, State};
const BOTTOM_MARGIN: i32 = 16;
const FRAME_MS: u64 = 16;
const SPAWN_IN_MS: f32 = 220.0;
const SPAWN_OUT_MS: f32 = 140.0;
const SPAWN_PILL_MIN_H: f32 = 4.0;
const SPAWN_OVERSHOOT_C: f32 = 0.4;
const BARS_GRACE_MS: f32 = 80.0;
const BARS_FADE_MS: f32 = 80.0;
const BAR_COUNT: u32 = 7;
const BAR_W: f32 = 3.0;
const BAR_GAP: f32 = 2.0;
const BAR_PITCH: f32 = BAR_W + BAR_GAP;
const BAR_BLOCK_W: f32 = BAR_COUNT as f32 * BAR_W + (BAR_COUNT - 1) as f32 * BAR_GAP;
const BAR_BASELINE: f32 = 6.0;
const BAR_VPAD: f32 = 6.0;
#[derive(Debug, Clone, Copy)]
struct Theme {
bg: [u8; 4],
ring: [u8; 4],
rec_bar: [u8; 4],
trans_bar: [u8; 4],
glow: [u8; 4],
}
impl Theme {
const fn ember() -> Self {
Self {
bg: [235, 14, 14, 16], ring: [64, 249, 115, 22], rec_bar: [255, 249, 115, 22], trans_bar: [255, 240, 237, 245], glow: [60, 249, 115, 22],
}
}
const fn carbon() -> Self {
Self {
bg: [235, 14, 14, 16],
ring: [80, 58, 58, 64], rec_bar: [255, 240, 237, 245], trans_bar: [255, 156, 163, 175], glow: [40, 240, 237, 245],
}
}
const fn cyan() -> Self {
Self {
bg: [235, 10, 15, 20],
ring: [64, 34, 211, 238], rec_bar: [255, 34, 211, 238],
trans_bar: [255, 56, 189, 248], glow: [50, 34, 211, 238],
}
}
fn from_config(cfg: &OverlayConfig) -> Self {
let base = match cfg.theme.as_str() {
"carbon" => Self::carbon(),
"cyan" => Self::cyan(),
"ember" | "custom" => Self::ember(),
other => {
warn!("unknown overlay theme {other:?}, falling back to ember");
Self::ember()
}
};
if cfg.theme != "custom" {
return base;
}
let Some(c) = cfg.colors.as_ref() else {
return base;
};
Self {
bg: c
.background
.as_deref()
.and_then(crate::parse_hex_color)
.unwrap_or(base.bg),
ring: c
.ring
.as_deref()
.and_then(crate::parse_hex_color)
.unwrap_or(base.ring),
rec_bar: c
.recording
.as_deref()
.and_then(crate::parse_hex_color)
.unwrap_or(base.rec_bar),
trans_bar: c
.transcribing
.as_deref()
.and_then(crate::parse_hex_color)
.unwrap_or(base.trans_bar),
glow: c
.glow
.as_deref()
.and_then(crate::parse_hex_color)
.unwrap_or(base.glow),
}
}
}
pub async fn spawn_overlay(
mut state_rx: watch::Receiver<State>,
mut level_rx: watch::Receiver<f32>,
config: OverlayConfig,
) {
if is_gnome_desktop() {
let gnome_state_rx = state_rx.clone();
let gnome_level_rx = level_rx.clone();
let gnome_theme = config.theme.clone();
tokio::spawn(async move {
if let Err(e) = run_gnome_broadcaster(gnome_state_rx, gnome_level_rx, gnome_theme).await
{
warn!("GNOME overlay D-Bus broadcaster unavailable: {e:#}");
}
});
}
let (tx, rx) = mpsc::channel::<State>();
let (level_tx, level_rx_thread) = mpsc::channel::<f32>();
let backend = OverlayBackend::detect();
info!("overlay backend selected: {backend:?}");
let overlay_config = config;
std::thread::Builder::new()
.name("whisrs-overlay".to_string())
.spawn(move || {
let result = match backend {
OverlayBackend::Wayland => run_overlay(rx, level_rx_thread, overlay_config),
OverlayBackend::X11 => run_x11_overlay(rx, level_rx_thread, overlay_config),
OverlayBackend::Unavailable => {
warn!("overlay unavailable: no Wayland or X11 display in environment");
return;
}
};
if let Err(e) = result {
warn!("overlay unavailable: {e:#}");
}
})
.map_err(|e| warn!("failed to spawn overlay thread: {e}"))
.ok();
tokio::spawn(async move {
let _ = tx.send(*state_rx.borrow());
let _ = level_tx.send(*level_rx.borrow());
loop {
tokio::select! {
changed = state_rx.changed() => {
if changed.is_err() { break; }
if tx.send(*state_rx.borrow()).is_err() { break; }
}
changed = level_rx.changed() => {
if changed.is_err() { break; }
let _ = level_tx.send(*level_rx.borrow());
}
}
}
});
}
#[derive(Debug, Clone, Copy)]
enum OverlayBackend {
Wayland,
X11,
Unavailable,
}
impl OverlayBackend {
fn detect() -> Self {
let session_is_wayland = matches_env("XDG_SESSION_TYPE", "wayland");
let session_is_x11 = matches_env("XDG_SESSION_TYPE", "x11");
if (env_var_is_set("WAYLAND_DISPLAY") || session_is_wayland) && !session_is_x11 {
Self::Wayland
} else if env_var_is_set("DISPLAY") {
Self::X11
} else {
Self::Unavailable
}
}
}
fn env_var_is_set(name: &str) -> bool {
std::env::var_os(name).is_some_and(|value| !value.is_empty())
}
fn matches_env(name: &str, expected: &str) -> bool {
std::env::var(name).is_ok_and(|value| value.eq_ignore_ascii_case(expected))
}
fn is_gnome_desktop() -> bool {
std::env::var("XDG_CURRENT_DESKTOP")
.map(|value| {
value
.split(':')
.any(|part| part.eq_ignore_ascii_case("GNOME"))
})
.unwrap_or(false)
}
async fn run_gnome_broadcaster(
mut state_rx: watch::Receiver<State>,
level_rx: watch::Receiver<f32>,
theme: String,
) -> Result<(), OverlayError> {
let advertised_theme = match theme.as_str() {
"carbon" | "cyan" | "ember" => theme.clone(),
_ => "ember".to_string(),
};
let conn = zbus::connection::Builder::session()?
.serve_at("/org/whisrs/Overlay", GnomeOverlayBus)?
.name("org.whisrs.Overlay")?
.build()
.await?;
info!("GNOME overlay D-Bus broadcaster started");
emit_gnome_theme(&conn, &advertised_theme).await?;
let initial_state = *state_rx.borrow();
emit_gnome_state(&conn, initial_state).await?;
let initial_level = *level_rx.borrow();
emit_gnome_level(&conn, initial_level).await?;
let mut level_interval = tokio::time::interval(Duration::from_millis(33));
level_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
changed = state_rx.changed() => {
if changed.is_err() {
break;
}
let state = *state_rx.borrow();
emit_gnome_state(&conn, state).await?;
}
_ = level_interval.tick() => {
let level = *level_rx.borrow();
emit_gnome_level(&conn, level).await?;
}
}
}
Ok(())
}
async fn emit_gnome_state(conn: &zbus::Connection, state: State) -> zbus::Result<()> {
conn.emit_signal(
None::<&str>,
"/org/whisrs/Overlay",
"org.whisrs.Overlay",
"StateChanged",
&(state.to_string()),
)
.await
}
async fn emit_gnome_level(conn: &zbus::Connection, level: f32) -> zbus::Result<()> {
conn.emit_signal(
None::<&str>,
"/org/whisrs/Overlay",
"org.whisrs.Overlay",
"LevelChanged",
&level.clamp(0.0, 1.0),
)
.await
}
async fn emit_gnome_theme(conn: &zbus::Connection, theme: &str) -> zbus::Result<()> {
conn.emit_signal(
None::<&str>,
"/org/whisrs/Overlay",
"org.whisrs.Overlay",
"ThemeChanged",
&theme,
)
.await
}
struct GnomeOverlayBus;
#[zbus::interface(name = "org.whisrs.Overlay")]
impl GnomeOverlayBus {
fn ping(&self) -> &'static str {
"ok"
}
}
fn run_overlay(
state_rx: mpsc::Receiver<State>,
level_rx: mpsc::Receiver<f32>,
config: OverlayConfig,
) -> Result<(), OverlayError> {
let width = config.clamped_width();
let height = config.clamped_height();
let theme = Theme::from_config(&config);
let conn = WaylandConnection::connect_to_env()?;
let (globals, mut event_queue) = registry_queue_init(&conn)?;
let qh = event_queue.handle();
let compositor = CompositorState::bind(&globals, &qh)?;
let layer_shell = LayerShell::bind(&globals, &qh)?;
let shm = Shm::bind(&globals, &qh)?;
let surface = compositor.create_surface(&qh);
let layer =
layer_shell.create_layer_surface(&qh, surface, Layer::Overlay, Some("whisrs"), None);
layer.set_anchor(Anchor::BOTTOM);
layer.set_margin(0, 0, BOTTOM_MARGIN, 0);
layer.set_exclusive_zone(0);
layer.set_keyboard_interactivity(KeyboardInteractivity::None);
layer.set_size(width, height);
let input_region = compositor.wl_compositor().create_region(&qh, ());
layer.set_input_region(Some(&input_region));
input_region.destroy();
layer.commit();
let pool = SlotPool::new((width * height * 4) as usize, &shm)?;
let mut overlay = Overlay {
registry_state: RegistryState::new(&globals),
output_state: OutputState::new(&globals, &qh),
shm,
pool,
layer,
renderer: OverlayRenderer::new(state_rx, level_rx, width, height, theme)?,
exit: false,
first_configure: true,
};
info!("recording overlay started");
while !overlay.exit {
overlay.renderer.apply_state_updates();
if overlay.renderer.disconnected {
break;
}
event_queue.blocking_dispatch(&mut overlay)?;
}
Ok(())
}
fn run_x11_overlay(
state_rx: mpsc::Receiver<State>,
level_rx: mpsc::Receiver<f32>,
config: OverlayConfig,
) -> Result<(), OverlayError> {
let width = config.clamped_width() as u16;
let height = config.clamped_height() as u16;
let theme = Theme::from_config(&config);
let (conn, screen_num) = RustConnection::connect(None)?;
let screen = &conn.setup().roots[screen_num];
let visual = find_argb_visual(screen).ok_or(OverlayError::X11Visual)?;
let atoms = X11Atoms::new(&conn)?;
let colormap = conn.generate_id()?;
conn.create_colormap(ColormapAlloc::NONE, colormap, screen.root, visual.visual_id)?;
let x = centered_x(screen, width);
let y = bottom_y(screen, height);
let window = conn.generate_id()?;
conn.create_window(
visual.depth,
window,
screen.root,
x,
y,
width,
height,
0,
WindowClass::INPUT_OUTPUT,
visual.visual_id,
&CreateWindowAux::default()
.background_pixel(0)
.border_pixel(0)
.colormap(colormap)
.override_redirect(1)
.event_mask(EventMask::EXPOSURE),
)?;
set_x11_window_hints(&conn, window, &atoms)?;
let shape_supported = make_x11_window_click_through(&conn, window)?;
let gc = conn.generate_id()?;
conn.create_gc(gc, window, &CreateGCAux::default().graphics_exposures(0))?;
conn.flush()?;
let mut renderer =
OverlayRenderer::new(state_rx, level_rx, width as u32, height as u32, theme)?;
let mut frame = vec![0_u8; width as usize * height as usize * 4];
let mut mapped = false;
let mut last_shape: Vec<Rectangle> = Vec::new();
info!("recording X11 overlay started");
loop {
while conn.poll_for_event()?.is_some() {}
renderer.draw_frame();
if renderer.disconnected {
break;
}
let visible_shape = alpha_shape_rectangles(&renderer.pixmap);
if visible_shape.is_empty() {
if mapped {
conn.unmap_window(window)?;
conn.flush()?;
mapped = false;
last_shape.clear();
}
std::thread::sleep(Duration::from_millis(FRAME_MS));
continue;
}
if shape_supported && !shape_rectangles_eq(&visible_shape, &last_shape) {
set_x11_bounding_shape(&conn, window, &visible_shape)?;
last_shape.clear();
last_shape.extend_from_slice(&visible_shape);
}
copy_pixmap_to_x11_zpixmap(&renderer.pixmap, visual, &mut frame);
if !mapped {
let (x, y) = x11_overlay_position(&conn, screen, &atoms, width, height);
conn.configure_window(
window,
&ConfigureWindowAux::default()
.x(i32::from(x))
.y(i32::from(y))
.stack_mode(StackMode::ABOVE),
)?;
conn.map_window(window)?;
mapped = true;
}
conn.put_image(
ImageFormat::Z_PIXMAP,
window,
gc,
width,
height,
0,
0,
0,
visual.depth,
&frame,
)?;
conn.flush()?;
std::thread::sleep(Duration::from_millis(FRAME_MS));
}
if mapped {
let _ = conn.unmap_window(window);
let _ = conn.flush();
}
Ok(())
}
#[derive(Clone, Copy)]
struct X11ArgbVisual {
depth: u8,
visual_id: Visualid,
red_mask: u32,
green_mask: u32,
blue_mask: u32,
alpha_mask: u32,
}
fn find_argb_visual(screen: &Screen) -> Option<X11ArgbVisual> {
screen.allowed_depths.iter().find_map(|depth| {
if depth.depth != 32 {
return None;
}
let full_mask = depth_mask(depth.depth);
depth.visuals.iter().find_map(|visual| {
if visual.class != VisualClass::TRUE_COLOR {
return None;
}
let color_mask = visual.red_mask | visual.green_mask | visual.blue_mask;
let alpha_mask = full_mask & !color_mask;
(alpha_mask != 0).then_some(X11ArgbVisual {
depth: depth.depth,
visual_id: visual.visual_id,
red_mask: visual.red_mask,
green_mask: visual.green_mask,
blue_mask: visual.blue_mask,
alpha_mask,
})
})
})
}
fn depth_mask(depth: u8) -> u32 {
if depth >= 32 {
u32::MAX
} else {
(1_u32 << depth) - 1
}
}
struct X11Atoms {
atom: u32,
net_active_window: u32,
net_wm_name: u32,
net_wm_window_type: u32,
net_wm_window_type_notification: u32,
net_wm_state: u32,
net_wm_state_above: u32,
net_wm_state_skip_pager: u32,
net_wm_state_skip_taskbar: u32,
net_wm_state_sticky: u32,
net_wm_bypass_compositor: u32,
utf8_string: u32,
}
impl X11Atoms {
fn new(conn: &RustConnection) -> Result<Self, OverlayError> {
Ok(Self {
atom: u32::from(AtomEnum::ATOM),
net_active_window: intern_atom(conn, b"_NET_ACTIVE_WINDOW")?,
net_wm_name: intern_atom(conn, b"_NET_WM_NAME")?,
net_wm_window_type: intern_atom(conn, b"_NET_WM_WINDOW_TYPE")?,
net_wm_window_type_notification: intern_atom(
conn,
b"_NET_WM_WINDOW_TYPE_NOTIFICATION",
)?,
net_wm_state: intern_atom(conn, b"_NET_WM_STATE")?,
net_wm_state_above: intern_atom(conn, b"_NET_WM_STATE_ABOVE")?,
net_wm_state_skip_pager: intern_atom(conn, b"_NET_WM_STATE_SKIP_PAGER")?,
net_wm_state_skip_taskbar: intern_atom(conn, b"_NET_WM_STATE_SKIP_TASKBAR")?,
net_wm_state_sticky: intern_atom(conn, b"_NET_WM_STATE_STICKY")?,
net_wm_bypass_compositor: intern_atom(conn, b"_NET_WM_BYPASS_COMPOSITOR")?,
utf8_string: intern_atom(conn, b"UTF8_STRING")?,
})
}
}
fn intern_atom(conn: &RustConnection, name: &[u8]) -> Result<u32, OverlayError> {
Ok(conn.intern_atom(false, name)?.reply()?.atom)
}
fn set_x11_window_hints(
conn: &RustConnection,
window: Window,
atoms: &X11Atoms,
) -> Result<(), OverlayError> {
conn.change_property8(
PropMode::REPLACE,
window,
atoms.net_wm_name,
atoms.utf8_string,
b"whisrs overlay",
)?;
conn.change_property8(
PropMode::REPLACE,
window,
AtomEnum::WM_NAME,
AtomEnum::STRING,
b"whisrs overlay",
)?;
conn.change_property32(
PropMode::REPLACE,
window,
atoms.net_wm_window_type,
atoms.atom,
&[atoms.net_wm_window_type_notification],
)?;
conn.change_property32(
PropMode::REPLACE,
window,
atoms.net_wm_state,
atoms.atom,
&[
atoms.net_wm_state_above,
atoms.net_wm_state_skip_pager,
atoms.net_wm_state_skip_taskbar,
atoms.net_wm_state_sticky,
],
)?;
conn.change_property32(
PropMode::REPLACE,
window,
atoms.net_wm_bypass_compositor,
AtomEnum::CARDINAL,
&[2],
)?;
Ok(())
}
fn make_x11_window_click_through(
conn: &RustConnection,
window: Window,
) -> Result<bool, OverlayError> {
if conn.extension_information(SHAPE_EXT)?.is_none() {
warn!("X11 SHAPE extension unavailable; overlay may intercept clicks");
return Ok(false);
}
conn.shape_rectangles(
SO::SET,
SK::INPUT,
ClipOrdering::UNSORTED,
window,
0,
0,
&[],
)?;
Ok(true)
}
fn set_x11_bounding_shape(
conn: &RustConnection,
window: Window,
rectangles: &[Rectangle],
) -> Result<(), OverlayError> {
conn.shape_rectangles(
SO::SET,
SK::BOUNDING,
ClipOrdering::Y_SORTED,
window,
0,
0,
rectangles,
)?;
Ok(())
}
fn shape_rectangles_eq(a: &[Rectangle], b: &[Rectangle]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b.iter()).all(|(lhs, rhs)| {
lhs.x == rhs.x && lhs.y == rhs.y && lhs.width == rhs.width && lhs.height == rhs.height
})
}
fn alpha_shape_rectangles(pixmap: &Pixmap) -> Vec<Rectangle> {
let width = pixmap.width() as usize;
let mut rectangles = Vec::new();
for (y, row) in pixmap.pixels().chunks_exact(width).enumerate() {
let Some(first) = row.iter().position(|px| px.alpha() != 0) else {
continue;
};
let last = row
.iter()
.rposition(|px| px.alpha() != 0)
.expect("row has a first alpha pixel");
rectangles.push(Rectangle {
x: first as i16,
y: y as i16,
width: (last - first + 1) as u16,
height: 1,
});
}
rectangles
}
fn x11_overlay_position(
conn: &RustConnection,
screen: &Screen,
atoms: &X11Atoms,
width: u16,
height: u16,
) -> (i16, i16) {
let monitors = match active_x11_monitors(conn, screen.root) {
Ok(monitors) => monitors,
Err(e) => {
warn!("failed to query XRandR monitors for overlay placement: {e:#}");
Vec::new()
}
};
if let Some((cx, cy)) = match active_x11_window_center(conn, screen.root, atoms) {
Ok(center) => center,
Err(e) => {
warn!("failed to query active X11 window for overlay placement: {e:#}");
None
}
} {
if let Some(monitor) = monitors
.iter()
.find(|m| point_in_rect(cx, cy, i32::from(m.x), i32::from(m.y), m.width, m.height))
{
return position_in_rect(
i32::from(monitor.x),
i32::from(monitor.y),
u32::from(monitor.width),
u32::from(monitor.height),
width,
height,
);
}
}
if let Some(monitor) = monitors
.iter()
.find(|m| m.primary)
.or_else(|| monitors.first())
{
return position_in_rect(
i32::from(monitor.x),
i32::from(monitor.y),
u32::from(monitor.width),
u32::from(monitor.height),
width,
height,
);
}
(centered_x(screen, width), bottom_y(screen, height))
}
fn active_x11_monitors(
conn: &RustConnection,
root: Window,
) -> Result<Vec<MonitorInfo>, OverlayError> {
if conn.extension_information(RANDR_EXT)?.is_none() {
return Ok(Vec::new());
}
let reply = conn.randr_get_monitors(root, true)?.reply()?;
Ok(reply
.monitors
.into_iter()
.filter(|monitor| monitor.width > 0 && monitor.height > 0)
.collect())
}
fn active_x11_window_center(
conn: &RustConnection,
root: Window,
atoms: &X11Atoms,
) -> Result<Option<(i32, i32)>, OverlayError> {
let reply = conn
.get_property(false, root, atoms.net_active_window, AtomEnum::WINDOW, 0, 1)?
.reply()?;
let Some(bytes) = reply.value.get(..4) else {
return Ok(None);
};
let window = u32::from_ne_bytes(bytes.try_into().expect("slice is exactly 4 bytes"));
if window == 0 {
return Ok(None);
}
let geometry = conn.get_geometry(window)?.reply()?;
let translated = conn.translate_coordinates(window, root, 0, 0)?.reply()?;
if !translated.same_screen {
return Ok(None);
}
Ok(Some((
i32::from(translated.dst_x) + i32::from(geometry.width / 2),
i32::from(translated.dst_y) + i32::from(geometry.height / 2),
)))
}
fn point_in_rect(x: i32, y: i32, rx: i32, ry: i32, width: u16, height: u16) -> bool {
let right = rx.saturating_add(i32::from(width));
let bottom = ry.saturating_add(i32::from(height));
x >= rx && x < right && y >= ry && y < bottom
}
fn position_in_rect(
x: i32,
y: i32,
rect_width: u32,
rect_height: u32,
width: u16,
height: u16,
) -> (i16, i16) {
let width = i32::from(width);
let height = i32::from(height);
let rect_width = i32::try_from(rect_width).unwrap_or(i32::MAX);
let rect_height = i32::try_from(rect_height).unwrap_or(i32::MAX);
let min_x = x;
let max_x = x
.saturating_add(rect_width)
.saturating_sub(width)
.max(min_x);
let centered_x = x.saturating_add((rect_width - width) / 2);
let min_y = y;
let max_y = y
.saturating_add(rect_height)
.saturating_sub(height)
.max(min_y);
let bottom_y = y
.saturating_add(rect_height)
.saturating_sub(height)
.saturating_sub(BOTTOM_MARGIN);
(
to_i16_coord(centered_x.clamp(min_x, max_x)),
to_i16_coord(bottom_y.clamp(min_y, max_y)),
)
}
fn centered_x(screen: &Screen, width: u16) -> i16 {
let x = (i32::from(screen.width_in_pixels) - i32::from(width)) / 2;
to_i16_coord(x.max(0))
}
fn bottom_y(screen: &Screen, height: u16) -> i16 {
let y = i32::from(screen.height_in_pixels) - i32::from(height) - BOTTOM_MARGIN;
to_i16_coord(y.max(0))
}
fn to_i16_coord(value: i32) -> i16 {
value.clamp(i32::from(i16::MIN), i32::from(i16::MAX)) as i16
}
struct Overlay {
registry_state: RegistryState,
output_state: OutputState,
shm: Shm,
pool: SlotPool,
layer: LayerSurface,
renderer: OverlayRenderer,
exit: bool,
first_configure: bool,
}
struct OverlayRenderer {
state_rx: mpsc::Receiver<State>,
level_rx: mpsc::Receiver<f32>,
width: u32,
height: u32,
pixmap: Pixmap,
target_state: State,
visible_state: State,
spawn_started: Instant,
spawn_in: bool,
disconnected: bool,
frame: u32,
level: f32,
level_target: f32,
level_velocity: f32,
last_update: Instant,
theme: Theme,
}
#[derive(Debug, Clone, Copy)]
struct AnimState {
pill_height: f32,
pill_alpha: f32,
bar_alpha: f32,
bars_locked: bool,
}
impl OverlayRenderer {
fn new(
state_rx: mpsc::Receiver<State>,
level_rx: mpsc::Receiver<f32>,
width: u32,
height: u32,
theme: Theme,
) -> Result<Self, OverlayError> {
let pixmap = Pixmap::new(width, height).ok_or(OverlayError::Pixmap(width, height))?;
Ok(Self {
state_rx,
level_rx,
width,
height,
pixmap,
target_state: State::Idle,
visible_state: State::Idle,
spawn_started: Instant::now(),
spawn_in: false,
disconnected: false,
frame: 0,
level: 0.0,
level_target: 0.0,
level_velocity: 0.0,
last_update: Instant::now(),
theme,
})
}
fn apply_state_updates(&mut self) {
loop {
match self.state_rx.try_recv() {
Ok(state) => {
let was_idle = self.target_state == State::Idle;
let now_idle = state == State::Idle;
self.target_state = state;
if !now_idle {
self.visible_state = state;
}
if was_idle && !now_idle {
self.spawn_in = true;
self.spawn_started = Instant::now();
} else if !was_idle && now_idle {
self.spawn_in = false;
self.spawn_started = Instant::now();
}
}
Err(mpsc::TryRecvError::Empty) => break,
Err(mpsc::TryRecvError::Disconnected) => {
self.disconnected = true;
break;
}
}
}
loop {
match self.level_rx.try_recv() {
Ok(new) => self.level_target = new.clamp(0.0, 1.0),
Err(mpsc::TryRecvError::Empty) => break,
Err(mpsc::TryRecvError::Disconnected) => {
self.disconnected = true;
break;
}
}
}
const STIFFNESS: f32 = 360.0;
const DAMPING: f32 = 32.0;
let now = Instant::now();
let dt = now.duration_since(self.last_update).as_secs_f32().min(0.1);
self.last_update = now;
if dt > 0.0 {
let force = (self.level_target - self.level) * STIFFNESS;
let drag = self.level_velocity * DAMPING;
self.level_velocity += (force - drag) * dt;
self.level = (self.level + self.level_velocity * dt).clamp(0.0, 1.0);
}
if !self.spawn_in && self.spawn_t() >= 1.0 {
self.visible_state = State::Idle;
}
}
fn spawn_t(&self) -> f32 {
let duration = if self.spawn_in {
SPAWN_IN_MS
} else {
SPAWN_OUT_MS
};
let elapsed_ms = self.spawn_started.elapsed().as_secs_f32() * 1000.0;
(elapsed_ms / duration).clamp(0.0, 1.0)
}
fn anim(&self, full_height: f32) -> AnimState {
let t = self.spawn_t();
if self.spawn_in {
let h_curve = ease_out_back(t, SPAWN_OVERSHOOT_C).clamp(0.0, 1.4);
let pill_height = SPAWN_PILL_MIN_H + h_curve * (full_height - SPAWN_PILL_MIN_H);
let alpha_t = (t / 0.64).clamp(0.0, 1.0);
let pill_alpha = 1.0 - (1.0 - alpha_t) * (1.0 - alpha_t);
let grace_t = BARS_GRACE_MS / SPAWN_IN_MS;
let fade_t = BARS_FADE_MS / SPAWN_IN_MS;
let bar_t = ((t - grace_t) / fade_t).clamp(0.0, 1.0);
let bar_alpha = 1.0 - (1.0 - bar_t) * (1.0 - bar_t);
let bars_locked = t < grace_t + fade_t;
AnimState {
pill_height,
pill_alpha,
bar_alpha,
bars_locked,
}
} else {
let e = ease_in_cubic(t);
let pill_height = full_height - e * (full_height - SPAWN_PILL_MIN_H);
let pill_alpha = 1.0 - e;
let bar_t = (t / 0.7).clamp(0.0, 1.0);
let bar_alpha = 1.0 - bar_t * bar_t;
AnimState {
pill_height,
pill_alpha,
bar_alpha,
bars_locked: true,
}
}
}
fn draw_frame(&mut self) {
self.apply_state_updates();
let anim = self.anim(self.height as f32);
let level_gated = if anim.bars_locked { 0.0 } else { self.level };
draw_overlay(
&mut self.pixmap,
self.visible_state,
self.frame,
level_gated,
anim,
&self.theme,
);
self.frame = self.frame.wrapping_add(1);
}
}
impl Overlay {
fn draw(&mut self, qh: &QueueHandle<Self>) {
let width = self.renderer.width;
let height = self.renderer.height;
let stride = width as i32 * 4;
self.renderer.draw_frame();
let Ok((buffer, canvas)) = self.pool.create_buffer(
width as i32,
height as i32,
stride,
wl_shm::Format::Argb8888,
) else {
warn!("failed to allocate overlay buffer");
return;
};
copy_pixmap_to_argb8888(&self.renderer.pixmap, canvas);
self.layer
.wl_surface()
.damage_buffer(0, 0, width as i32, height as i32);
self.layer
.wl_surface()
.frame(qh, self.layer.wl_surface().clone());
if let Err(e) = buffer.attach_to(self.layer.wl_surface()) {
warn!("failed to attach overlay buffer: {e}");
return;
}
self.layer.commit();
std::thread::sleep(Duration::from_millis(FRAME_MS));
}
}
fn ease_in_cubic(t: f32) -> f32 {
t * t * t
}
fn ease_out_back(t: f32, c: f32) -> f32 {
let t1 = t - 1.0;
1.0 + (c + 1.0) * t1 * t1 * t1 + c * t1 * t1
}
fn copy_pixmap_to_argb8888(pixmap: &Pixmap, canvas: &mut [u8]) {
let src = pixmap.pixels();
debug_assert_eq!(src.len() * 4, canvas.len());
for (i, px) in src.iter().enumerate() {
let dst = &mut canvas[i * 4..i * 4 + 4];
let pre: PremultipliedColorU8 = *px;
dst[0] = pre.blue();
dst[1] = pre.green();
dst[2] = pre.red();
dst[3] = pre.alpha();
}
}
fn copy_pixmap_to_x11_zpixmap(pixmap: &Pixmap, visual: X11ArgbVisual, canvas: &mut [u8]) {
let src = pixmap.pixels();
debug_assert_eq!(src.len() * 4, canvas.len());
for (i, px) in src.iter().enumerate() {
let pre: PremultipliedColorU8 = *px;
let pixel = pack_masked_channel(pre.red(), visual.red_mask)
| pack_masked_channel(pre.green(), visual.green_mask)
| pack_masked_channel(pre.blue(), visual.blue_mask)
| pack_masked_channel(pre.alpha(), visual.alpha_mask);
canvas[i * 4..i * 4 + 4].copy_from_slice(&pixel.to_ne_bytes());
}
}
fn pack_masked_channel(value: u8, mask: u32) -> u32 {
if mask == 0 {
return 0;
}
let bits = mask.count_ones();
let shift = mask.trailing_zeros();
let max = (1_u64 << bits) - 1;
let scaled = (u64::from(value) * max + 127) / 255;
((scaled << shift) as u32) & mask
}
impl CompositorHandler for Overlay {
fn scale_factor_changed(
&mut self,
_conn: &WaylandConnection,
_qh: &QueueHandle<Self>,
_surface: &wl_surface::WlSurface,
_new_factor: i32,
) {
}
fn transform_changed(
&mut self,
_conn: &WaylandConnection,
_qh: &QueueHandle<Self>,
_surface: &wl_surface::WlSurface,
_new_transform: wl_output::Transform,
) {
}
fn frame(
&mut self,
_conn: &WaylandConnection,
qh: &QueueHandle<Self>,
_surface: &wl_surface::WlSurface,
_time: u32,
) {
self.draw(qh);
}
fn surface_enter(
&mut self,
_conn: &WaylandConnection,
_qh: &QueueHandle<Self>,
_surface: &wl_surface::WlSurface,
_output: &wl_output::WlOutput,
) {
}
fn surface_leave(
&mut self,
_conn: &WaylandConnection,
_qh: &QueueHandle<Self>,
_surface: &wl_surface::WlSurface,
_output: &wl_output::WlOutput,
) {
}
}
impl OutputHandler for Overlay {
fn output_state(&mut self) -> &mut OutputState {
&mut self.output_state
}
fn new_output(
&mut self,
_conn: &WaylandConnection,
_qh: &QueueHandle<Self>,
_output: wl_output::WlOutput,
) {
}
fn update_output(
&mut self,
_conn: &WaylandConnection,
_qh: &QueueHandle<Self>,
_output: wl_output::WlOutput,
) {
}
fn output_destroyed(
&mut self,
_conn: &WaylandConnection,
_qh: &QueueHandle<Self>,
_output: wl_output::WlOutput,
) {
}
}
impl LayerShellHandler for Overlay {
fn closed(
&mut self,
_conn: &WaylandConnection,
_qh: &QueueHandle<Self>,
_layer: &LayerSurface,
) {
self.exit = true;
}
fn configure(
&mut self,
_conn: &WaylandConnection,
qh: &QueueHandle<Self>,
_layer: &LayerSurface,
_configure: LayerSurfaceConfigure,
_serial: u32,
) {
if self.first_configure {
self.first_configure = false;
self.draw(qh);
}
}
}
impl ShmHandler for Overlay {
fn shm_state(&mut self) -> &mut Shm {
&mut self.shm
}
}
impl Dispatch<wl_region::WlRegion, ()> for Overlay {
fn event(
_state: &mut Self,
_proxy: &wl_region::WlRegion,
_event: wl_region::Event,
_data: &(),
_conn: &WaylandConnection,
_qh: &QueueHandle<Self>,
) {
}
}
delegate_compositor!(Overlay);
delegate_output!(Overlay);
delegate_shm!(Overlay);
delegate_layer!(Overlay);
delegate_registry!(Overlay);
impl ProvidesRegistryState for Overlay {
fn registry(&mut self) -> &mut RegistryState {
&mut self.registry_state
}
registry_handlers![OutputState];
}
fn draw_overlay(
pixmap: &mut Pixmap,
state: State,
frame: u32,
level: f32,
anim: AnimState,
theme: &Theme,
) {
pixmap.fill(Color::TRANSPARENT);
if anim.pill_alpha <= 0.0 || state == State::Idle {
return;
}
let surface_w = pixmap.width() as f32;
let surface_h = pixmap.height() as f32;
let pill_h = anim.pill_height.clamp(SPAWN_PILL_MIN_H, surface_h);
let pill_y = surface_h - pill_h; let pill_w = surface_w;
let outer = build_stadium(0.0, pill_y, pill_w, pill_h);
if let Some(path) = &outer {
let mut paint = Paint {
anti_alias: true,
..Default::default()
};
paint.set_color(theme_color(theme.ring, anim.pill_alpha));
pixmap.fill_path(path, &paint, FillRule::Winding, Transform::identity(), None);
}
if pill_w > 2.0 && pill_h > 2.0 {
let inner = build_stadium(1.0, pill_y + 1.0, pill_w - 2.0, pill_h - 2.0);
if let Some(path) = &inner {
let mut paint = Paint {
anti_alias: true,
..Default::default()
};
paint.set_color(theme_color(theme.bg, anim.pill_alpha));
pixmap.fill_path(path, &paint, FillRule::Winding, Transform::identity(), None);
}
}
if anim.bar_alpha <= 0.0 {
return;
}
let pill_cy = surface_h / 2.0;
match state {
State::Recording => draw_bars(pixmap, theme, level, anim, pill_cy),
State::Transcribing => draw_sweep(pixmap, theme, frame, anim, pill_cy),
State::Idle => {}
}
}
fn build_stadium(x: f32, y: f32, w: f32, h: f32) -> Option<tiny_skia::Path> {
if w <= 0.0 || h <= 0.0 {
return None;
}
if w >= h {
let r = h / 2.0;
if (w - 2.0 * r).abs() < 0.01 {
return PathBuilder::from_circle(x + r, y + r, r);
}
let mut pb = PathBuilder::new();
if let Some(rect) = Rect::from_xywh(x + r, y, w - 2.0 * r, h) {
pb.push_rect(rect);
}
if let Some(cap) = PathBuilder::from_circle(x + r, y + r, r) {
pb.push_path(&cap);
}
if let Some(cap) = PathBuilder::from_circle(x + w - r, y + r, r) {
pb.push_path(&cap);
}
pb.finish()
} else {
let r = w / 2.0;
let mut pb = PathBuilder::new();
if let Some(rect) = Rect::from_xywh(x, y + r, w, h - 2.0 * r) {
pb.push_rect(rect);
}
if let Some(cap) = PathBuilder::from_circle(x + r, y + r, r) {
pb.push_path(&cap);
}
if let Some(cap) = PathBuilder::from_circle(x + r, y + h - r, r) {
pb.push_path(&cap);
}
pb.finish()
}
}
fn theme_color(bytes: [u8; 4], extra_alpha: f32) -> Color {
let a = (bytes[0] as f32 / 255.0 * extra_alpha.clamp(0.0, 1.0)).clamp(0.0, 1.0);
Color::from_rgba(
bytes[1] as f32 / 255.0,
bytes[2] as f32 / 255.0,
bytes[3] as f32 / 255.0,
a,
)
.unwrap_or(Color::TRANSPARENT)
}
fn taper_factor(i: u32, count: u32) -> f32 {
if count <= 1 {
return 1.0;
}
let center = (count as f32 - 1.0) / 2.0;
let d = (i as f32 - center) / center; let envelope = (-d * d).exp(); let wave = 0.75 + 0.25 * (std::f32::consts::PI * (i as f32 - center)).cos();
envelope * wave
}
fn draw_bars(pixmap: &mut Pixmap, theme: &Theme, level: f32, anim: AnimState, pill_cy: f32) {
let surface_w = pixmap.width() as f32;
let max_h = (anim.pill_height - BAR_VPAD * 2.0).max(BAR_BASELINE + 2.0);
let bar_x_start = (surface_w - BAR_BLOCK_W) / 2.0;
for i in 0..BAR_COUNT {
let taper = taper_factor(i, BAR_COUNT);
let effective = (level * taper).clamp(0.0, 1.0);
let h = (BAR_BASELINE + effective * (max_h - BAR_BASELINE)).max(BAR_BASELINE);
let bx = bar_x_start + i as f32 * BAR_PITCH;
let by = pill_cy - h / 2.0;
if effective > 0.02 {
let glow_intensity = (effective * 0.9 + 0.1).clamp(0.0, 1.0);
let glow_a = theme.glow[0] as f32 / 255.0 * glow_intensity * anim.bar_alpha;
let glow_color = Color::from_rgba(
theme.glow[1] as f32 / 255.0,
theme.glow[2] as f32 / 255.0,
theme.glow[3] as f32 / 255.0,
glow_a.clamp(0.0, 1.0),
)
.unwrap_or(Color::TRANSPARENT);
let glow_w = BAR_W + 2.0;
let glow_h = (h + 2.0).max(BAR_BASELINE + 2.0);
if let Some(path) = build_stadium(bx - 1.0, pill_cy - glow_h / 2.0, glow_w, glow_h) {
let mut paint = Paint {
anti_alias: true,
..Default::default()
};
paint.set_color(glow_color);
pixmap.fill_path(
&path,
&paint,
FillRule::Winding,
Transform::identity(),
None,
);
}
}
if let Some(path) = build_stadium(bx, by, BAR_W, h) {
let mut paint = Paint {
anti_alias: true,
..Default::default()
};
paint.set_color(theme_color(theme.rec_bar, anim.bar_alpha));
pixmap.fill_path(
&path,
&paint,
FillRule::Winding,
Transform::identity(),
None,
);
}
}
}
fn draw_sweep(pixmap: &mut Pixmap, theme: &Theme, frame: u32, anim: AnimState, pill_cy: f32) {
let surface_w = pixmap.width() as f32;
let max_h = (anim.pill_height - BAR_VPAD * 2.0).max(BAR_BASELINE + 2.0);
let bar_x_start = (surface_w - BAR_BLOCK_W) / 2.0;
let cycle = (BAR_COUNT as i32) * 2 - 2;
let pos = ((frame / 3) as i32) % cycle.max(1);
let active = if pos < BAR_COUNT as i32 {
pos as f32
} else {
(cycle - pos) as f32
};
for i in 0..BAR_COUNT {
let taper = taper_factor(i, BAR_COUNT);
let dist = (i as f32 - active).abs();
let intensity = (-dist * dist / 4.0).exp().max(0.15);
let dynamic = intensity * taper;
let h = (BAR_BASELINE + dynamic * (max_h - BAR_BASELINE) * 0.85).max(BAR_BASELINE);
let bx = bar_x_start + i as f32 * BAR_PITCH;
let by = pill_cy - h / 2.0;
let bar_a = theme.trans_bar[0] as f32 / 255.0 * (0.3 + 0.7 * intensity) * anim.bar_alpha;
let bar_color = Color::from_rgba(
theme.trans_bar[1] as f32 / 255.0,
theme.trans_bar[2] as f32 / 255.0,
theme.trans_bar[3] as f32 / 255.0,
bar_a.clamp(0.0, 1.0),
)
.unwrap_or(Color::TRANSPARENT);
if let Some(path) = build_stadium(bx, by, BAR_W, h) {
let mut paint = Paint {
anti_alias: true,
..Default::default()
};
paint.set_color(bar_color);
pixmap.fill_path(
&path,
&paint,
FillRule::Winding,
Transform::identity(),
None,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const W: u32 = 100;
const H: u32 = 64;
fn fresh_pixmap() -> Pixmap {
Pixmap::new(W, H).unwrap()
}
fn shown() -> AnimState {
AnimState {
pill_height: H as f32,
pill_alpha: 1.0,
bar_alpha: 1.0,
bars_locked: false,
}
}
fn hidden() -> AnimState {
AnimState {
pill_height: SPAWN_PILL_MIN_H,
pill_alpha: 0.0,
bar_alpha: 0.0,
bars_locked: true,
}
}
#[test]
fn idle_draw_is_transparent() {
let mut pm = fresh_pixmap();
let t = Theme::ember();
draw_overlay(&mut pm, State::Idle, 0, 0.0, hidden(), &t);
assert!(pm.data().iter().all(|b| *b == 0));
}
#[test]
fn faded_out_draw_is_transparent() {
let mut pm = fresh_pixmap();
let t = Theme::ember();
draw_overlay(&mut pm, State::Recording, 0, 1.0, hidden(), &t);
assert!(pm.data().iter().all(|b| *b == 0));
}
#[test]
fn active_draw_has_visible_pixels() {
let mut pm = fresh_pixmap();
let t = Theme::ember();
draw_overlay(&mut pm, State::Recording, 0, 1.0, shown(), &t);
assert!(pm.data().chunks_exact(4).any(|px| px[3] != 0));
}
#[test]
fn taper_is_strongest_in_center() {
let center = taper_factor(BAR_COUNT / 2, BAR_COUNT);
let edge_left = taper_factor(0, BAR_COUNT);
let edge_right = taper_factor(BAR_COUNT - 1, BAR_COUNT);
assert!(center > edge_left);
assert!(center > edge_right);
assert!(edge_left < 0.5);
assert!(edge_right < 0.5);
}
#[test]
fn ease_curves_hit_endpoints() {
assert!((ease_in_cubic(0.0) - 0.0).abs() < 1e-6);
assert!((ease_in_cubic(1.0) - 1.0).abs() < 1e-6);
assert!((ease_out_back(0.0, 0.4) - 0.0).abs() < 1e-6);
assert!((ease_out_back(1.0, 0.4) - 1.0).abs() < 1e-6);
assert!(ease_out_back(0.85, 0.4) > 1.0);
}
#[test]
fn x11_position_in_rect_uses_monitor_bounds() {
assert_eq!(
position_in_rect(1920, 360, 1920, 1200, 100, 40),
(2830, 1504)
);
assert_eq!(position_in_rect(3840, 0, 1200, 1920, 100, 40), (4390, 1864));
}
#[test]
fn x11_point_in_rect_uses_half_open_bounds() {
assert!(point_in_rect(2830, 1504, 1920, 360, 1920, 1200));
assert!(!point_in_rect(3840, 1504, 1920, 360, 1920, 1200));
}
#[test]
fn silence_draws_minimal_baseline() {
fn amber_pixels(data: &[u8]) -> usize {
data.chunks_exact(4)
.filter(|px| px[0] > 200 && px[1] > 70 && px[1] < 180 && px[2] < 60)
.count()
}
let t = Theme::ember();
let mut quiet = fresh_pixmap();
let mut loud = fresh_pixmap();
draw_overlay(&mut quiet, State::Recording, 0, 0.0, shown(), &t);
draw_overlay(&mut loud, State::Recording, 0, 1.0, shown(), &t);
let count_quiet = amber_pixels(quiet.data());
let count_loud = amber_pixels(loud.data());
assert!(
count_loud > count_quiet,
"loud audio should fill more bar area than silence (silence={count_quiet}, loud={count_loud})"
);
}
}