app
Pup - the AI's own browser
Public Made by Adomby adom
pup gives your AI a real browser on your desktop that it drives completely, in the background, signed in as you: windows that never jump in front of your work, one shared profile that learns your logins, and every window labeled on the taskbar with its AI thread's name.
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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
//! 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");
}
}
}