master
John Lauer v2.0.688 (insiders): Smoother taskbar tile text: every tile size is drawn at 4x and averaged down, so letters sit at ex… 80a3d14 1mo ago
//! The thread tile, rendered IN THE BINARY. Until 2.0.559 this was JavaScript evaluated inside whatever
//! page the window was on (the Node bridge had no way to draw text and the Rust port kept that), so the
//! icon depended on which site was open and a strict Content-Security-Policy (claude.ai, the wiki)
//! silently downgraded the button to a nameless icon. John, 2026-09-14: "it should just be in pup's rust
//! binary". Same layout as the browser version (two lines at most, whole words first, a size floor,
//! then a character-fit fallback), same plate (rx = s/5.5, #00b8b1, name bottom-left in #05221f), every
//! ICO entry rasterised at its own pixel size. No page, no lane, no CSP: the tile exists at birth.
use fontdue::{Font, FontSettings};

/// Familjen Grotesk 700, the same face the browser renderer loaded as "FGtile" (fg-700.woff2 -> TTF).
static FONT_TTF: &[u8] = include_bytes!("../assets/fg-700.ttf");
const BOX: f32 = 256.0;
const PLATE: [u8; 3] = [0x00, 0xb8, 0xb1];
const INK: [u8; 3] = [0x05, 0x22, 0x1f];

fn font() -> Option<Font> { Font::from_bytes(FONT_TTF, FontSettings::default()).ok() }

/// canvas measureText: advances plus kerning, at `px`.
fn width(f: &Font, text: &str, px: f32) -> f32 {
    let mut w = 0.0; let mut prev: Option<char> = None;
    for c in text.chars() {
        if let Some(p) = prev { if let Some(k) = f.horizontal_kern(p, c, px) { w += k; } }
        w += f.metrics(c, px).advance_width; prev = Some(c);
    }
    w
}

fn w_for(i: usize, total: usize) -> f32 { if total == 2 && i == 0 { BOX * 0.78 } else { BOX * 0.90 } }

fn fit_size(f: &Font, lines: &[String]) -> f32 {
    let mut s = BOX * 0.9;
    for _ in 0..90 {
        let ok = lines.iter().enumerate().all(|(j, l)| width(f, l, s) <= w_for(j, lines.len()));
        if ok && lines.len() as f32 * s * 0.95 <= BOX * 0.88 { break; }
        s *= 0.95;
    }
    s
}

/// The browser's `split(/(?<=[-\/])/)`: split AFTER every '-' or '/', keeping the delimiter on the left.
fn split_after_delims(t: &str) -> Vec<String> {
    let mut out = Vec::new(); let mut cur = String::new();
    for c in t.chars() { cur.push(c); if c == '-' || c == '/' { out.push(std::mem::take(&mut cur)); } }
    if !cur.is_empty() { out.push(cur); }
    out
}
fn glue(t: &[String]) -> String {
    t.iter().fold(String::new(), |a, x| { let sep = if !a.is_empty() && !(a.ends_with('-') || a.ends_with('/')) { " " } else { "" }; a + sep + x })
}
fn take_chars(s: &[char], n: usize) -> String { s[..n.min(s.len())].iter().collect() }

/// Lines and font size for a name: a direct port of the browser layout.
pub fn layout(f: &Font, nm: &str) -> (Vec<String>, f32) {
    let floor = BOX * 0.36;
    let mut best: Option<Vec<String>> = None; let mut bf = 0.0f32;
    let toks: Vec<String> = nm.split_whitespace().flat_map(split_after_delims).filter(|t| !t.is_empty()).collect();
    if toks.len() > 1 {
        for k in 1..toks.len() {
            let c = vec![glue(&toks[..k]), glue(&toks[k..])];
            let fs = fit_size(f, &c);
            if fs >= floor && fs > bf { bf = fs; best = Some(c); }
        }
        let one = fit_size(f, &[nm.to_string()]);
        if one >= floor && one > bf { bf = one; best = Some(vec![nm.to_string()]); }
    }
    if best.is_none() {
        let chars: Vec<char> = nm.chars().collect();
        let mut cands: Vec<Vec<String>> = vec![vec![nm.to_string()]];
        let sp = nm.rfind(' '); let hy = nm.find('-');
        if let Some(sp) = sp.filter(|&i| i > 0) { cands.push(vec![nm[..sp].to_string(), nm[sp + 1..].to_string()]); }
        else if let Some(hy) = hy.filter(|&i| i > 0) { cands.push(vec![nm[..hy + 1].to_string(), nm[hy + 1..].to_string()]); }
        if sp.is_none() && hy.is_none() && chars.len() >= 7 { let m = (chars.len() + 1) / 2; cands.push(vec![take_chars(&chars, m), chars[m..].iter().collect()]); }
        let mut b = vec![nm.to_string()]; bf = 0.0;
        for c in &cands { let fs = fit_size(f, c); if fs > bf { bf = fs; b = c.clone(); } }
        if bf < floor {
            bf = floor;
            let take = |s: &[char], maxw: f32| -> usize { let mut n = 0; for i in 1..=s.len() { if width(f, &take_chars(s, i), floor) <= maxw { n = i; } else { break; } } n };
            let mut rest: Vec<char> = nm.trim().chars().collect(); let mut out: Vec<String> = Vec::new();
            for ln in 0..2 {
                if rest.is_empty() { break; }
                let mut n = take(&rest, w_for(ln, 2));
                if n >= rest.len() { out.push(rest.iter().collect()); rest.clear(); break; }
                if n < 1 { n = 1; }
                let seg: Vec<char> = rest[..(n + 1).min(rest.len())].to_vec();
                let bp = seg.iter().rposition(|&c| c == ' ').map(|a| a as i64).unwrap_or(-1).max(seg.iter().rposition(|&c| c == '-').map(|a| a as i64).unwrap_or(-1));
                let cut = if bp >= ((n as f32) * 0.5).ceil() as i64 { bp as usize + if seg[bp as usize] == '-' { 1 } else { 0 } } else { n };
                out.push(rest[..cut].iter().collect::<String>().trim().to_string());
                let mut r2: Vec<char> = rest[cut..].to_vec(); while r2.first().map(|&c| c == ' ' || c == '-').unwrap_or(false) { r2.remove(0); } rest = r2;
            }
            b = out.into_iter().filter(|l| !l.is_empty()).collect();
        }
        best = Some(b);
    }
    (best.unwrap_or_else(|| vec![nm.to_string()]), bf)
}

/// One RGBA tile at `s` px: anti-aliased rounded plate, the name bottom-left, glyphs rasterised at this size.
pub fn draw(f: &Font, lines: &[String], bf: f32, s: u32) -> Vec<u8> {
    let sf = s as f32; let k = sf / BOX; let r = sf / 5.5;
    let mut px = vec![0u8; (s * s * 4) as usize];
    // plate: signed distance to the rounded rectangle, one sample per pixel centre with a half-pixel ramp
    for y in 0..s { for x in 0..s {
        let (cx, cy) = (x as f32 + 0.5 - sf / 2.0, y as f32 + 0.5 - sf / 2.0);
        let (dx, dy) = ((cx.abs() - (sf / 2.0 - r)).max(0.0), (cy.abs() - (sf / 2.0 - r)).max(0.0));
        let d = (dx * dx + dy * dy).sqrt() - r;
        let cov = (0.5 - d).clamp(0.0, 1.0);
        let i = ((y * s + x) * 4) as usize;
        px[i] = PLATE[0]; px[i + 1] = PLATE[1]; px[i + 2] = PLATE[2]; px[i + 3] = (cov * 255.0).round() as u8;
    } }
    // text: canvas textAlign left, textBaseline alphabetic, lh = 0.95 em, margin 7.5%, baseline lifted 0.14 em
    let fpx = bf * k; let lh = fpx * 0.95; let mx = sf * 0.075; let base_y = sf - sf * 0.075 - fpx * 0.14;
    for (i, line) in lines.iter().enumerate() {
        let by = base_y - (lines.len() as f32 - 1.0 - i as f32) * lh;
        draw_text(f, &mut px, s, s, line, fpx, mx, by, INK);
    }
    px
}

/// 2.0.687 (John, 2026-10-03: "the rendered text in your taskbar icon still looks grainy; it used to look smoother").
/// The browser renderer it replaced (pre-2.0.559) placed glyphs at sub-pixel positions and blended in linear light.
/// draw() snaps every glyph to a whole pixel and blends in sRGB, which at 24-32 px makes uneven letter spacing and
/// blotchy stems. Draw the tile at SS x its size, then area-average it down (premultiplied alpha, gamma space, as
/// browsers blend text): glyph positions become exact to 1/SS px and the edges get real coverage. The plate gets the same benefit.
const SS: u32 = 4;
pub fn draw_smooth(f: &Font, lines: &[String], bf: f32, s: u32) -> Vec<u8> {
    let big = draw(f, lines, bf, s * SS);
    let bs = s * SS;
    let mut out = vec![0u8; (s * s * 4) as usize];
    let n = (SS * SS) as f32;
    for y in 0..s { for x in 0..s {
        let (mut r, mut g, mut b, mut a) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
        for sy in 0..SS { for sx in 0..SS {
            let i = (((y * SS + sy) * bs + (x * SS + sx)) * 4) as usize;
            let al = big[i + 3] as f32 / 255.0;
            // gamma-space average (what browsers do for text): linear-light averaging measurably THINNED the dark
            // strokes on the bright plate (tried first; the letters went faint)
            r += big[i] as f32 / 255.0 * al; g += big[i + 1] as f32 / 255.0 * al; b += big[i + 2] as f32 / 255.0 * al; a += al;
        } }
        let o = ((y * s + x) * 4) as usize;
        if a > 0.0 { out[o] = ((r / a).clamp(0.0, 1.0) * 255.0).round() as u8; out[o + 1] = ((g / a).clamp(0.0, 1.0) * 255.0).round() as u8; out[o + 2] = ((b / a).clamp(0.0, 1.0) * 255.0).round() as u8; }
        out[o + 3] = ((a / n) * 255.0).round() as u8;
    } }
    out
}

/// Blit a line of text (left-aligned, alphabetic baseline) onto an RGBA buffer, glyphs rasterised by
/// fontdue at exactly `fpx`, coverage-blended into RGB. Shared by the tile and the annotate caption bar.
pub fn draw_text(f: &Font, px: &mut [u8], w: u32, h: u32, text: &str, fpx: f32, x0: f32, baseline: f32, ink: [u8; 3]) {
    let mut x = x0; let mut prev: Option<char> = None;
    for c in text.chars() {
        if let Some(p) = prev { if let Some(kn) = f.horizontal_kern(p, c, fpx) { x += kn; } }
        let (m, bm) = f.rasterize(c, fpx);
        let ox = (x + m.xmin as f32).round() as i64; let oy = (baseline - (m.ymin as f32 + m.height as f32)).round() as i64;
        for gy in 0..m.height { for gx in 0..m.width {
            let (tx, ty) = (ox + gx as i64, oy + gy as i64);
            if tx < 0 || ty < 0 || tx >= w as i64 || ty >= h as i64 { continue; }
            let a = bm[gy * m.width + gx] as f32 / 255.0; if a <= 0.0 { continue; }
            let idx = ((ty as u32 * w + tx as u32) * 4) as usize;
            for ch in 0..3 { px[idx + ch] = (px[idx + ch] as f32 * (1.0 - a) + ink[ch] as f32 * a).round() as u8; }
        } }
        x += m.advance_width; prev = Some(c);
    }
}
pub fn font_handle() -> Option<Font> { font() }

/// The finished .ico for a thread name: every TILE_SIZES entry drawn at its own size.
pub fn render_ico(name: &str) -> Option<Vec<u8>> {
    let f = font()?;
    let (lines, bf) = layout(&f, name);
    let mut raw: Vec<(u32, Vec<u8>)> = Vec::new(); let mut png256: Vec<u8> = Vec::new();
    for &sz in crate::TILE_SIZES.iter() {
        let px = if sz >= 256 { draw(&f, &lines, bf, sz) } else { draw_smooth(&f, &lines, bf, sz) };
        if sz >= 256 { png256 = crate::png_encode_rgba(&px, sz, sz); } else { raw.push((sz, px)); }
    }
    Some(crate::ico_from_tiles(&raw, &png256))
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn renders_every_entry_and_writes_samples() {
        let f = font().expect("font");
        let dir = std::env::temp_dir().join("pup-tile-samples"); let _ = std::fs::create_dir_all(&dir);
        for name in ["pup-bridge", "pup-ab-smoke", "kicad-bridge-esc-routing", "bt556", "sxsw", "Logic gate molecule family"] {
            let (lines, bf) = layout(&f, name);
            assert!(!lines.is_empty() && lines.len() <= 2 && bf > 0.0, "{name}: {lines:?} {bf}");
            for sz in [256u32, 48, 32, 24, 16] {
                let px = draw(&f, &lines, bf, sz);
                std::fs::write(dir.join(format!("{}-{sz}-old.png", crate::thread_slug(name))), crate::png_encode_rgba(&px, sz, sz)).unwrap();
                let px2 = draw_smooth(&f, &lines, bf, sz);
                std::fs::write(dir.join(format!("{}-{sz}.png", crate::thread_slug(name))), crate::png_encode_rgba(&px2, sz, sz)).unwrap();
            }
            let ico = render_ico(name).expect("ico");
            assert!(ico.len() > 2000, "{name}: ico {} bytes", ico.len());
            std::fs::write(dir.join(format!("{}.ico", crate::thread_slug(name))), ico).unwrap();
            eprintln!("{name}: {lines:?} at {bf:.1}px");
        }
    }
}