render.rs
raw
//! Software rendering of the launcher into a premultiplied-ARGB pixel buffer.
//!
//! The buffer format matches `wl_shm`'s `Argb8888` (premultiplied alpha, native
//! byte order): each `u32` is `0xAARRGGBB` with the color channels already
//! scaled by alpha. Pixels outside the rounded background are left fully
//! transparent so the compositor rounds our corners for us.
//!
//! Drawing is deliberately hand-rolled — a filled rounded rectangle with an
//! anti-aliased edge (via a signed-distance field) and per-glyph alpha blending
//! from cosmic-text. That keeps the whole pipeline in one pixel format with no
//! extra dependency and no channel-order conversion.
use cosmic_text::{Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, SwashCache};
/// Fixed overlay width in logical pixels.
pub const WIDTH: u32 = 640;
/// Most match rows shown at once; longer match lists are truncated to this.
pub const MAX_ROWS: usize = 8;
const PAD: f32 = 18.0;
const INPUT_FONT: f32 = 21.0;
const INPUT_LINE: f32 = 30.0;
const ROW_FONT: f32 = 17.0;
const ROW_LINE: f32 = 26.0;
const GAP: f32 = 10.0;
const CORNER_RADIUS: f32 = 12.0;
const ROW_RADIUS: f32 = 7.0;
const CARET_WIDTH: f32 = 2.0;
// Catppuccin Mocha palette.
const BG: Rgb = Rgb(0x1e, 0x1e, 0x2e);
const TEXT: Rgb = Rgb(0xcd, 0xd6, 0xf4);
const GHOST: Rgb = Rgb(0x6c, 0x70, 0x86);
const HIGHLIGHT_BG: Rgb = Rgb(0x31, 0x32, 0x44);
const ACCENT: Rgb = Rgb(0x89, 0xb4, 0xfa);
/// A straight (non-premultiplied) opaque RGB color.
#[derive(Clone, Copy)]
struct Rgb(u8, u8, u8);
impl Rgb {
fn to_cosmic(self) -> Color {
Color::rgb(self.0, self.1, self.2)
}
}
/// Height of the drawn box needed to show `rows` match rows beneath the input
/// line — the height of the visible rounded rectangle, not the Wayland surface.
/// Row count is clamped to [`MAX_ROWS`].
pub fn height_for(rows: usize) -> u32 {
let rows = rows.min(MAX_ROWS);
let mut h = PAD + INPUT_LINE;
if rows > 0 {
h += GAP + rows as f32 * ROW_LINE;
}
h += PAD;
h.ceil() as u32
}
/// The fixed height of the Wayland surface, sized for a full match list. The
/// surface is created at this height once and never resized: only the drawn box
/// grows and shrinks within it (the remainder is transparent). Keeping the
/// surface a constant size avoids compositors (Hyprland and others) replaying
/// their layer "open" animation on every resize as the user types.
pub fn surface_height() -> u32 {
height_for(MAX_ROWS)
}
/// What to draw: the solid input text, the dimmed completion "ghost" trailing
/// the cursor, and the list of matching command names with one highlighted.
pub struct View {
/// Solid text left of the cursor (the committed/typed command, plus args in
/// the argument phase).
pub typed: String,
/// Dimmed suffix shown right of the cursor while completing a command.
pub ghost: String,
/// Match rows to list beneath the input.
pub rows: Vec<String>,
/// Index within `rows` of the highlighted candidate (the one space/enter
/// acts on).
pub selected: usize,
}
/// Holds the font machinery, which is expensive to construct, so a single
/// renderer is reused across frames.
///
/// The font system is built on first use rather than up front. Constructing it
/// enumerates the system's installed fonts, which on a cold cache is slow enough
/// to be worth keeping off the startup path — and a frame with no text in it
/// (the empty input line the launcher opens with) does not need it at all. Use
/// [`Renderer::load_fonts`] to pay that cost at a chosen moment instead.
pub struct Renderer {
font_system: Option<FontSystem>,
swash_cache: SwashCache,
}
impl Renderer {
pub fn new() -> Self {
Renderer {
font_system: None,
swash_cache: SwashCache::new(),
}
}
/// Build the font system now, instead of leaving it to the first frame that
/// has text in it. Idempotent: after the first call this does nothing.
pub fn load_fonts(&mut self) {
self.font_system.get_or_insert_with(FontSystem::new);
}
/// Paint `view` into `buf`, a `width`×`height` premultiplied-ARGB buffer.
///
/// The buffer is the full (fixed) surface, but the opaque box is drawn only
/// as tall as the current content needs; the area below it stays transparent
/// so the overlay appears to hug its contents without the surface resizing.
pub fn render(&mut self, buf: &mut [u32], width: u32, height: u32, view: &View) {
let mut canvas = Canvas {
buf,
width: width as i32,
height: height as i32,
};
canvas.clear();
// Rounded, opaque background sized to the content; everything outside it
// (corners and the region below) stays transparent for the compositor.
let box_height = height_for(view.rows.len()) as f32;
canvas.fill_rounded_rect(0.0, 0.0, width as f32, box_height, CORNER_RADIUS, BG);
// Input line: solid typed text, cursor, then dimmed ghost completion.
let input_metrics = Metrics::new(INPUT_FONT, INPUT_LINE);
let baseline_x = PAD;
let input_y = PAD;
let inner_w = width as f32 - 2.0 * PAD;
let typed_w = self.draw_text(
&mut canvas,
&view.typed,
baseline_x,
input_y,
input_metrics,
TEXT,
inner_w,
);
let caret_x = baseline_x + typed_w;
canvas.fill_rounded_rect(caret_x, input_y + 3.0, CARET_WIDTH, INPUT_FONT, 1.0, ACCENT);
if !view.ghost.is_empty() {
let ghost_x = caret_x + CARET_WIDTH + 1.0;
let ghost_w = inner_w - (ghost_x - baseline_x);
self.draw_text(
&mut canvas,
&view.ghost,
ghost_x,
input_y,
input_metrics,
GHOST,
ghost_w.max(0.0),
);
}
// Match rows.
let row_metrics = Metrics::new(ROW_FONT, ROW_LINE);
let rows_top = PAD + INPUT_LINE + GAP;
for (i, row) in view.rows.iter().take(MAX_ROWS).enumerate() {
let y = rows_top + i as f32 * ROW_LINE;
// The highlighted candidate is marked by a grey backing band; text
// stays the normal foreground color in every row.
if i == view.selected {
canvas.fill_rounded_rect(
PAD - 6.0,
y,
width as f32 - 2.0 * (PAD - 6.0),
ROW_LINE,
ROW_RADIUS,
HIGHLIGHT_BG,
);
}
// Nudge text down slightly so it sits centered within the row band.
self.draw_text(
&mut canvas,
row,
baseline_x,
y + (ROW_LINE - ROW_FONT) / 2.0 - 2.0,
row_metrics,
TEXT,
inner_w,
);
}
}
/// Shape and blit a single line of `text` at `(x, y)` (top-left of the line
/// box) in `color`, clipped to `max_w`. Returns the advance width so callers
/// can place a cursor or trailing text.
fn draw_text(
&mut self,
canvas: &mut Canvas,
text: &str,
x: f32,
y: f32,
metrics: Metrics,
color: Rgb,
max_w: f32,
) -> f32 {
if text.is_empty() {
return 0.0;
}
// Borrow the two caches as separate fields: the font system is built on
// demand here, and `buffer.draw` needs both at once.
let font_system = self.font_system.get_or_insert_with(FontSystem::new);
let swash_cache = &mut self.swash_cache;
let mut buffer = Buffer::new(font_system, metrics);
buffer.set_size(Some(max_w), Some(metrics.line_height));
let attrs = Attrs::new()
.family(Family::SansSerif)
.color(color.to_cosmic());
buffer.set_text(text, &attrs, Shaping::Advanced, None);
buffer.shape_until_scroll(font_system, false);
let advance = buffer
.layout_runs()
.next()
.map(|run| run.line_w)
.unwrap_or(0.0);
buffer.draw(
font_system,
swash_cache,
color.to_cosmic(),
|gx, gy, gw, gh, gcolor| {
let (r, g, b, a) = gcolor.as_rgba_tuple();
if a == 0 {
return;
}
for dy in 0..gh as i32 {
for dx in 0..gw as i32 {
canvas.blend(x as i32 + gx + dx, y as i32 + gy + dy, r, g, b, a);
}
}
},
);
advance
}
}
impl Default for Renderer {
fn default() -> Self {
Self::new()
}
}
/// A mutable view over the destination pixel buffer with the drawing
/// primitives. Pixels are premultiplied ARGB (`0xAARRGGBB`).
struct Canvas<'a> {
buf: &'a mut [u32],
width: i32,
height: i32,
}
impl Canvas<'_> {
fn clear(&mut self) {
self.buf.fill(0);
}
/// Alpha-blend a straight-alpha source pixel over the premultiplied
/// destination at `(x, y)`, using `out = src_pm + dst * (1 - src_a)`.
fn blend(&mut self, x: i32, y: i32, sr: u8, sg: u8, sb: u8, sa: u8) {
if x < 0 || y < 0 || x >= self.width || y >= self.height {
return;
}
let idx = (y * self.width + x) as usize;
let dst = self.buf[idx];
let da = (dst >> 24) & 0xff;
let dr = (dst >> 16) & 0xff;
let dg = (dst >> 8) & 0xff;
let db = dst & 0xff;
let sa = sa as u32;
let inv = 255 - sa;
// Premultiply the source, then composite over the (already premultiplied)
// destination. Rounding via +127 keeps the AA edge from darkening.
let out_a = sa + (da * inv + 127) / 255;
let out_r = (sr as u32 * sa + 127) / 255 + (dr * inv + 127) / 255;
let out_g = (sg as u32 * sa + 127) / 255 + (dg * inv + 127) / 255;
let out_b = (sb as u32 * sa + 127) / 255 + (db * inv + 127) / 255;
self.buf[idx] = (out_a.min(255) << 24)
| (out_r.min(255) << 16)
| (out_g.min(255) << 8)
| out_b.min(255);
}
/// Fill a rounded rectangle in `color` (treated as opaque) with a ~1px
/// anti-aliased edge, blended over whatever is beneath it.
fn fill_rounded_rect(&mut self, x: f32, y: f32, w: f32, h: f32, radius: f32, color: Rgb) {
let radius = radius.min(w / 2.0).min(h / 2.0).max(0.0);
// Center and half-extents of the rectangle for the SDF.
let cx = x + w / 2.0;
let cy = y + h / 2.0;
let hx = w / 2.0;
let hy = h / 2.0;
let x0 = x.floor().max(0.0) as i32;
let y0 = y.floor().max(0.0) as i32;
let x1 = (x + w).ceil().min(self.width as f32) as i32;
let y1 = (y + h).ceil().min(self.height as f32) as i32;
for py in y0..y1 {
for px in x0..x1 {
let sample_x = px as f32 + 0.5;
let sample_y = py as f32 + 0.5;
let dist = rounded_rect_sdf(sample_x - cx, sample_y - cy, hx, hy, radius);
// coverage: fully inside at dist <= -0.5, fully outside at >= 0.5.
let coverage = (0.5 - dist).clamp(0.0, 1.0);
if coverage <= 0.0 {
continue;
}
let a = (coverage * 255.0).round() as u8;
self.blend(px, py, color.0, color.1, color.2, a);
}
}
}
}
/// Signed distance from a point (expressed relative to the rectangle center) to
/// a rounded rectangle with half-extents `(hx, hy)` and corner `radius`.
/// Negative inside, positive outside.
fn rounded_rect_sdf(px: f32, py: f32, hx: f32, hy: f32, radius: f32) -> f32 {
let qx = px.abs() - (hx - radius);
let qy = py.abs() - (hy - radius);
let outside = ((qx.max(0.0)).powi(2) + (qy.max(0.0)).powi(2)).sqrt();
let inside = qx.max(qy).min(0.0);
outside + inside - radius
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn height_grows_with_rows() {
let none = height_for(0);
let three = height_for(3);
let capped = height_for(MAX_ROWS + 5);
assert!(three > none, "rows should add height");
assert_eq!(capped, height_for(MAX_ROWS), "row count is clamped");
}
#[test]
fn sdf_sign_matches_inside_outside() {
// Center of a 100x40 box (half-extents 50x20), radius 10.
assert!(rounded_rect_sdf(0.0, 0.0, 50.0, 20.0, 10.0) < 0.0);
// Well outside on the x axis.
assert!(rounded_rect_sdf(80.0, 0.0, 50.0, 20.0, 10.0) > 0.0);
// The rounded corner is outside the square corner.
assert!(rounded_rect_sdf(50.0, 20.0, 50.0, 20.0, 10.0) > 0.0);
}
#[test]
fn opaque_fill_sets_full_alpha_in_the_interior() {
let w = 40u32;
let h = 20u32;
let mut buf = vec![0u32; (w * h) as usize];
let mut canvas = Canvas {
buf: &mut buf,
width: w as i32,
height: h as i32,
};
canvas.fill_rounded_rect(0.0, 0.0, w as f32, h as f32, 4.0, BG);
// A pixel near the center must be fully opaque with the bg color.
let center = buf[((h / 2) * w + w / 2) as usize];
assert_eq!(center >> 24, 0xff, "interior is opaque");
assert_eq!((center >> 16) & 0xff, BG.0 as u32);
// A corner pixel must remain (near) transparent.
let corner = buf[0];
assert!(corner >> 24 < 0x80, "corner stays mostly transparent");
}
#[test]
fn renders_without_panicking() {
let mut renderer = Renderer::new();
let height = height_for(2);
let mut buf = vec![0u32; (WIDTH * height) as usize];
let view = View {
typed: "fire".to_string(),
ghost: "fox".to_string(),
rows: vec!["firefox".to_string(), "firejail".to_string()],
selected: 0,
};
renderer.render(&mut buf, WIDTH, height, &view);
// Something was drawn: at least one opaque pixel exists.
assert!(buf.iter().any(|p| p >> 24 == 0xff));
}
}