// sloshcast player -- replays slosh's own composited frames into a DOM grid. // // No VT, no wasm, no dependency: the generator (castgen.py) already asked the // compositor for styled rows with colours resolved to hex, so playing one back // is innerHTML on the rows that changed. The whole player is this file. // // Box-drawing, block and shade characters are NOT left to the font. A font // glyph is hinted against its own metrics, so a column of │ shows seams and a // scaled ╭ goes jagged. Instead -- the approach learned from shellglass's // viewer -- a canvas overlay paints those cells as device-pixel geometry with // ROUNDED, SHARED cell boundaries (adjacent cells tile exactly), while the // real character stays in the DOM as transparent "ghost" text so selection // and copy still work. For the same reason the player never transform-scales: // it fits by choosing the font size, and pins the row height in pixels. // // mountSloshcast(el, data, {loop, autoplay, speed}) // // `data` is the parsed JSON; `el` becomes the player. Returns {play, pause, // toggle, restart, seek, fit}. "use strict"; // ── glyph geometry (after shellglass viewer.ts) ────────────────────────────── // Arm weights "urdl" (0 none, 1 light, 2 heavy) for U+2500-257F; "0000" = not // arms-coverable here (doubles/diagonals stay on the font path, dashes and // arcs have their own ops below). const SC_ARMS = "0101020210102020" + // 2500 ─ ━ │ ┃ "0000000000000000" + "0000000000000000" + // 2504-250B dashes (dashesOps) "0110021001200220" + // 250C ┌┍┎┏ "0011001200210022" + // 2510 ┐┑┒┓ "1100120021002200" + // 2514 └┕┖┗ "1001100220012002" + // 2518 ┘┙┚┛ "1110121021101120" + // 251C ├┝┞┟ "2120221012202220" + // 2520 ┠┡┢┣ "1011101220111021" + // 2524 ┤┥┦┧ "2021201210222022" + // 2528 ┨┩┪┫ "0111011202110212" + // 252C ┬┭┮┯ "0121012202210222" + // 2530 ┰┱┲┳ "1101110212011202" + // 2534 ┴┵┶┷ "2101210222012202" + // 2538 ┸┹┺┻ "1111111212111212" + // 253C ┼┽┾┿ "2111112121212112" + // 2540 ╀╁╂╃ "2211112212212212" + // 2544 ╄╅╆╇ "1222212222212222" + // 2548 ╈╉╊╋ "0000000000000000" + // 254C-254F dashes (dashesOps) "0000000000000000".repeat(8) + // 2550-256C doubles (font) // 256D-2570 arcs (arcOps), 2571-2573 diagonals (font) "0001100001000010" + // 2574 ╴╵╶╷ "0002200002000020" + // 2578 ╸╹╺╻ "0201102001022010"; // 257C ╼╽╾╿ function scBoxArms(cp) { if (cp < 0x2500 || cp > 0x257f) return null; const o = (cp - 0x2500) * 4; const u = +SC_ARMS[o], r = +SC_ARMS[o + 1], d = +SC_ARMS[o + 2], l = +SC_ARMS[o + 3]; return u || r || d || l ? [u, r, d, l] : null; } const scLw = (weight, light) => (weight === 2 ? 2 * light : light); // Arms (lines, corners, tees, crosses): each arm its own weight, extended past // centre by the crossing bar's half-extent so the junction fills solid. function scArmsOps(x0, y0, x1, y1, arms, light) { const [u, r, d, l] = arms; const midX = Math.round((x0 + x1) / 2); const midY = Math.round((y0 + y1) / 2); const vh = scLw(Math.max(u, d), light) >> 1; const hh = scLw(Math.max(l, r), light) >> 1; const ops = []; if (u) { const t = scLw(u, light); ops.push([midX - (t >> 1), y0, t, midY + hh - y0]); } if (d) { const t = scLw(d, light); ops.push([midX - (t >> 1), midY - hh, t, y1 - (midY - hh)]); } if (l) { const t = scLw(l, light); ops.push([x0, midY - (t >> 1), midX + vh - x0, t]); } if (r) { const t = scLw(r, light); ops.push([midX - vh, midY - (t >> 1), x1 - (midX - vh), t]); } return { rects: ops }; } function scDashesOps(x0, y0, x1, y1, cp, light) { let horiz, n, weight; if (cp <= 0x250b) { const k = cp - 0x2504; horiz = (k & 3) < 2; n = k < 4 ? 3 : 4; weight = k & 1 ? 2 : 1; } else { const k = cp - 0x254c; horiz = k < 2; n = 2; weight = k & 1 ? 2 : 1; } const t = scLw(weight, light); const midX = Math.round((x0 + x1) / 2); const midY = Math.round((y0 + y1) / 2); const rects = []; for (let i = 0; i < n; i++) { const s0 = (i + 0.2) / n, s1 = (i + 0.8) / n; if (horiz) { const a = Math.round(x0 + s0 * (x1 - x0)), b = Math.round(x0 + s1 * (x1 - x0)); rects.push([a, midY - (t >> 1), b - a, t]); } else { const a = Math.round(y0 + s0 * (y1 - y0)), b = Math.round(y0 + s1 * (y1 - y0)); rects.push([midX - (t >> 1), a, t, b - a]); } } return { rects }; } // Rounded corners ╭╮╯╰: a quarter ellipse from the far corner whose tangent // points land on the straight arms' centrelines, so it meets ─/│ flush. function scArcOps(x0, y0, x1, y1, cp, light) { const off = (light % 2) / 2; const mx = Math.round((x0 + x1) / 2) + off; const my = Math.round((y0 + y1) / 2) + off; const corners = [[x1, y1], [x0, y1], [x0, y0], [x1, y0]]; // ╭ ╮ ╯ ╰ const angles = [ [Math.PI, 1.5 * Math.PI], [1.5 * Math.PI, 2 * Math.PI], [0, 0.5 * Math.PI], [0.5 * Math.PI, Math.PI], ]; const [cx, cy] = corners[cp - 0x256d]; const [a0, a1] = angles[cp - 0x256d]; return { arc: [cx, cy, Math.abs(cx - mx), Math.abs(cy - my), a0, a1, light] }; } // Block elements: solid rects (halves/eighths/quadrants) and alpha shades. const SC_QUADRANTS = [4, 8, 1, 13, 9, 7, 11, 2, 6, 14]; // 2596-259F function scBlockOps(x0, y0, x1, y1, cp) { const W = x1 - x0, H = y1 - y0; const R = (u0, v0, u1, v1, alpha) => { const a = Math.round(x0 + u0 * W), b = Math.round(x0 + u1 * W); const c = Math.round(y0 + v0 * H), d = Math.round(y0 + v1 * H); return [a, c, b - a, d - c, alpha]; }; if (cp === 0x2580) return { rects: [R(0, 0, 1, 0.5)] }; if (cp >= 0x2581 && cp <= 0x2588) return { rects: [R(0, 1 - (cp - 0x2580) / 8, 1, 1)] }; if (cp >= 0x2589 && cp <= 0x258f) return { rects: [R(0, 0, (0x2590 - cp) / 8, 1)] }; if (cp === 0x2590) return { rects: [R(0.5, 0, 1, 1)] }; if (cp <= 0x2593) return { rects: [R(0, 0, 1, 1, (cp - 0x2590) / 4)] }; if (cp === 0x2594) return { rects: [R(0, 0, 1, 0.125)] }; if (cp === 0x2595) return { rects: [R(0.875, 0, 1, 1)] }; const m = SC_QUADRANTS[cp - 0x2596]; const rects = []; if (m & 1) rects.push(R(0, 0, 0.5, 0.5)); if (m & 2) rects.push(R(0.5, 0, 1, 0.5)); if (m & 4) rects.push(R(0, 0.5, 0.5, 1)); if (m & 8) rects.push(R(0.5, 0.5, 1, 1)); return { rects }; } function scGlyphOps(cp, x0, y0, x1, y1, light) { if (cp >= 0x2580 && cp <= 0x259f) return scBlockOps(x0, y0, x1, y1, cp); if ((cp >= 0x2504 && cp <= 0x250b) || (cp >= 0x254c && cp <= 0x254f)) return scDashesOps(x0, y0, x1, y1, cp, light); if (cp >= 0x256d && cp <= 0x2570) return scArcOps(x0, y0, x1, y1, cp, light); const arms = scBoxArms(cp); return arms ? scArmsOps(x0, y0, x1, y1, arms, light) : null; } // The cells the canvas paints (must agree with scGlyphOps): geometry replaces // the glyph, the DOM keeps it as transparent ghost text. function scIsCanvasCp(cp) { if (cp >= 0x2580 && cp <= 0x259f) return true; if (cp < 0x2500 || cp > 0x257f) return false; if ((cp >= 0x2504 && cp <= 0x250b) || (cp >= 0x254c && cp <= 0x254f)) return true; if (cp >= 0x256d && cp <= 0x2570) return true; return scBoxArms(cp) !== null; } // ── the player ──────────────────────────────────────────────────────────────── function mountSloshcast(el, data, opts = {}) { // Per-mount speed (data-speed) times the page's one global knob: set // window.SLOSHCAST_SPEED before this script loads and every cast on the // page plays that much faster, the recordings untouched. The clock still // counts cast-time, so 0:04 / 0:26 means the same thing at any speed. const speed = (opts.speed || 1) * (typeof window !== "undefined" && window.SLOSHCAST_SPEED > 0 ? window.SLOSHCAST_SPEED : 1); const PAD = 8; // .sc-screen padding, px const BASE_FONT = 14; const esc = (s) => s.replace(/&/g, "&").replace(//g, ">"); // -- one row: text + [x, w, fg, bg, attrbits] runs -> spans ------------ // Canvas-covered characters become ghost spans: transparent text under the // geometry, so what you select and copy is still the real characters. function ghostHtml(s) { let out = "", plain = ""; for (const ch of s) { if (scIsCanvasCp(ch.codePointAt(0))) { if (plain) { out += esc(plain); plain = ""; } out += `${esc(ch)}`; } else plain += ch; } return out + esc(plain); } function rowHtml(row) { const [text, runs] = row; let out = "", x = 0; for (const [rx, w, fg, bg, at] of runs) { if (rx > x) out += ghostHtml(text.slice(x, rx)); let st = ""; if (at & 16) { st += `color:${bg || data.bg};background:${fg || data.fg};`; } else { if (fg) st += `color:${fg};`; if (bg) st += `background:${bg};`; } if (at & 1) st += "font-weight:bold;"; if (at & 2) st += "font-style:italic;"; if (at & 4) st += "text-decoration:underline;"; if (at & 8) st += "opacity:.55;"; if (at & 32) st += "text-decoration:line-through;"; out += `${ghostHtml(text.slice(rx, rx + w))}`; x = rx + w; } return out + ghostHtml(text.slice(x)); } // -- the grid ---------------------------------------------------------- el.classList.add("sloshcast"); el.style.background = data.bg; el.innerHTML = `
` +
`` +
`