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));
    }
}