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