import { tokenize } from "./term-tokenizer.js";
import { createGestureRecognizer } from "./touch.js";
import { createSyntheticScroller } from "./synthetic-scroll.js";
import {
speak,
stopSpeech,
speechAvailable,
refreshEngineState,
} from "./speech.js";
import * as prefs from "./prefs.js";
import { linkToSettings } from "./settings-nav.js";
const RENDER_THROTTLE_MS = 50;
let speakingKey = null;
let speechNotice = null;
export function createReader({ host, document: doc, handlers = {} } = {}) {
let mounted = false;
let inner = null;
let statusBar = null;
let oscHint = null;
let scroller = null;
let renderTimer = null;
let changeSub = null;
let oscSub = null;
let panesSub = null;
let gestures = null;
const postRenderCallbacks = [];
const onBufferChanged = () => scheduleRender();
function scheduleRender() {
if (!mounted) return;
if (renderTimer !== null) return;
renderTimer = setTimeout(() => {
renderTimer = null;
render();
}, RENDER_THROTTLE_MS);
}
function render() {
if (!inner) return;
const { lines, status } = doc.snapshot();
scroller.contentChanged(() => {
renderStatusBar(statusBar, status);
const blocks = tokenize(lines);
const frag = window.document.createDocumentFragment();
for (const block of blocks) frag.appendChild(renderBlock(block));
inner.replaceChildren(frag);
reapplySpeakingState(inner);
});
const cbs = postRenderCallbacks.splice(0);
for (const cb of cbs) cb();
}
function buildOscHint() {
const el = window.document.createElement("div");
el.className = "reader-osc-hint";
el.hidden = true;
el.innerHTML =
'<span>Reader uses heuristics. <a class="reader-osc-link">Set up OSC 133 →</a></span>' +
'<button type="button" class="reader-osc-dismiss" aria-label="Dismiss">×</button>';
linkToSettings(el.querySelector(".reader-osc-link"), "/settings/shell");
el.querySelector(".reader-osc-dismiss").addEventListener("click", () => {
prefs.set("osc133_hint_dismissed", true);
el.hidden = true;
});
return el;
}
function buildSpeechNotice() {
const el = window.document.createElement("div");
el.className = "reader-speech-notice";
el.hidden = true;
el.setAttribute("role", "status");
return el;
}
function refreshOscHint() {
if (!oscHint) return;
const dismissed = prefs.get("osc133_hint_dismissed") === true;
oscHint.hidden = doc.oscDetected || doc.alternateOn !== false || dismissed;
}
function mountGestures() {
if (gestures) return;
gestures = createGestureRecognizer(
host,
{
onReconnect: () => handlers.onReconnect?.(),
onLongPress: () => handlers.onCommandMenu?.(),
onSwipeUp: () => handlers.onCommandMenu?.(),
onHSwipe: (dir) => handlers.onSwitchWindow?.(dir),
onTap: () => {},
onDoubleTap: () => handlers.onExit?.(),
onScroll: (dy) => scrollBy(dy),
onTwoPullMove: (pull, vh) => handlers.onTwoPullMove?.(pull, vh),
onTwoPullEnd: (pull, vh) => handlers.onTwoPullEnd?.(pull, vh),
},
{ passiveScroll: false },
);
}
function unmountGestures() {
if (!gestures) return;
gestures.destroy();
gestures = null;
}
function scrollBy(dy) {
if (!mounted) return;
scroller.scrollBy(dy);
}
function stickToBottom() {
if (!mounted) return;
scroller.stickToBottom();
}
function mount() {
if (mounted) return;
mounted = true;
host.classList.remove("hidden");
inner = window.document.createElement("div");
inner.className = "reader-inner";
statusBar = window.document.createElement("div");
statusBar.className = "reader-statusbar";
oscHint = buildOscHint();
speechNotice = buildSpeechNotice();
host.replaceChildren(inner, oscHint, speechNotice, statusBar);
refreshOscHint();
oscSub = doc.onOscDetected(() => refreshOscHint());
panesSub = doc.onPanes(() => refreshOscHint());
scroller = createSyntheticScroller({ host, inner, footerEl: statusBar });
changeSub = doc.subscribe(onBufferChanged);
mountGestures();
render();
refreshEngineState().then(() => scheduleRender());
}
function unmount() {
if (!mounted) return;
mounted = false;
host.classList.add("hidden");
unmountGestures();
if (changeSub) {
changeSub.dispose();
changeSub = null;
}
if (panesSub) {
panesSub.dispose();
panesSub = null;
}
if (oscSub) {
oscSub.dispose();
oscSub = null;
}
if (scroller) {
scroller.dispose();
scroller = null;
}
if (renderTimer !== null) {
clearTimeout(renderTimer);
renderTimer = null;
}
stopAllSpeech();
inner = null;
statusBar = null;
oscHint = null;
speechNotice = null;
}
function dispose() {
unmount();
}
return {
mount,
unmount,
dispose,
scrollBy,
stickToBottom,
forceRender: () => render(),
awaitNextRender: () =>
new Promise((resolve) => postRenderCallbacks.push(resolve)),
forceScrollTop: () => scroller?.scrollToTop(),
get mounted() {
return mounted;
},
get scrollY() {
return scroller ? scroller.scrollY : 0;
},
get maxScroll() {
return scroller ? scroller.maxScroll : 0;
},
get innerHeight() {
return scroller ? scroller.innerHeight : 0;
},
get atBottom() {
return scroller ? scroller.atBottom : true;
},
statusBarOffsetHeight: () => (statusBar ? statusBar.offsetHeight : 0),
statusBarFilled: () =>
!!statusBar && statusBar.classList.contains("reader-statusbar--filled"),
};
}
function renderStatusBar(hostEl, status) {
if (!hostEl) return;
if (!status || !status.rows || status.rows.length === 0) {
hostEl.replaceChildren();
hostEl.classList.remove("reader-statusbar--filled");
hostEl.style.background = "";
return;
}
const rows = status.rows.map(({ runs }) => {
const inner = window.document.createElement("div");
inner.className = "reader-statusbar-inner";
appendRuns(inner, runs);
return inner;
});
const runs = status.rows.flatMap((row) => row.runs);
hostEl.replaceChildren(...rows);
hostEl.classList.add("reader-statusbar--filled");
const bg = dominantBg(runs);
hostEl.style.background = bg || "";
}
function dominantBg(runs) {
const counts = new Map();
for (const r of runs) {
if (!r.attrs || !r.attrs.bg) continue;
counts.set(
r.attrs.bg,
(counts.get(r.attrs.bg) || 0) + (r.text ? r.text.length : 0),
);
}
let best = null;
let bestCount = 0;
for (const [bg, c] of counts)
if (c > bestCount) {
best = bg;
bestCount = c;
}
return best;
}
function renderBlock(block) {
switch (block.type) {
case "blank":
return makeEl("div", "rb rb-blank", "\u00A0");
case "rule":
return makeEl("hr", "rb rb-rule");
case "header":
return renderInlineBlock("rb rb-header", block.runs);
case "prompt":
return renderInlineBlock("rb rb-prompt", block.runs);
case "command":
return renderCommandBlock(block);
case "text":
return renderTextBlock(block);
case "code":
return renderCodeBlock(block);
default:
return makeEl("div", "rb", block.text || "");
}
}
function renderInlineBlock(className, runs) {
const el = window.document.createElement("div");
el.className = className;
appendRuns(el, runs);
if (className === "rb rb-prompt") {
addSpeakerIcon(el, "command", runs);
}
return el;
}
function renderCommandBlock(block) {
const wrap = window.document.createElement("div");
wrap.className = "rb rb-command";
const cmdEl = window.document.createElement("div");
cmdEl.className = "rb-command-line";
appendRuns(cmdEl, block.runs);
addSpeakerIcon(cmdEl, "command", block.runs);
wrap.appendChild(cmdEl);
if (block.lines.length > 0) {
const outputEl = window.document.createElement("div");
outputEl.className = "rb-command-output";
appendLinesWithBubbles(outputEl, block.lines, "rb-line");
addSpeakerIcons(outputEl, "output");
wrap.appendChild(outputEl);
}
if (block.exitCode !== null) {
const ok = block.exitCode === 0;
const status = window.document.createElement("span");
status.className = `rb-command-status ${ok ? "rb-status-ok" : "rb-status-fail"}`;
status.textContent = ok ? "✓" : `✗ ${block.exitCode}`;
status.title = `exit ${block.exitCode}`;
wrap.appendChild(status);
}
return wrap;
}
function renderTextBlock(block) {
const el = window.document.createElement("div");
el.className = "rb rb-text";
appendLinesWithBubbles(el, block.lines, "rb-line");
addSpeakerIcons(el, "prose", block);
return el;
}
function renderCodeBlock(block) {
const wrap = window.document.createElement("div");
wrap.className = "rb rb-code";
appendLinesWithBubbles(wrap, block.lines, "rb-codeline");
const text = block.lines.map((l) => l.text).join("\n");
const language = block.language || "";
addSpeakerIcon(wrap, "code", text, {
label: "Say what this code is",
language,
});
addSpeakerIcon(wrap, "code", text, {
label: "Read the code in full",
className: "rb-speaker rb-speaker-full",
glyph: "⤢",
expand: true,
language,
});
return wrap;
}
function appendLinesWithBubbles(parent, lines, lineClass) {
let i = 0;
while (i < lines.length) {
const bg = lines[i].bubbleBg;
if (bg) {
const bubble = window.document.createElement("div");
bubble.className = "rb-bubble";
bubble.style.background = bg;
bubble.style.borderColor = `color-mix(in srgb, ${bg} 78%, white 22%)`;
while (i < lines.length && lines[i].bubbleBg === bg) {
const lineEl = window.document.createElement("div");
lineEl.className = `${lineClass} rb-bubble-line`;
appendRuns(lineEl, lines[i].runs, { skipBg: true });
bubble.appendChild(lineEl);
i++;
}
parent.appendChild(bubble);
continue;
}
const lineEl = window.document.createElement("div");
lineEl.className = lineClass;
appendRuns(lineEl, lines[i].runs);
parent.appendChild(lineEl);
i++;
}
}
function appendRuns(parent, runs, opts) {
const skipBg = opts && opts.skipBg;
if (!runs || runs.length === 0) {
parent.appendChild(window.document.createTextNode("\u00A0"));
return;
}
for (const run of runs) {
if (!run.text) continue;
const span = window.document.createElement("span");
span.textContent = run.text;
applyAttrs(span, run.attrs, skipBg);
parent.appendChild(span);
}
}
function applyAttrs(el, a, skipBg) {
if (!a) return;
if (a.fg) el.style.color = a.fg;
if (a.bg && !skipBg) {
el.style.background = a.bg;
el.style.padding = "0 3px";
el.style.borderRadius = "3px";
el.style.border = `1px solid color-mix(in srgb, ${a.bg} 78%, white 22%)`;
el.classList.add("rb-chip");
}
if (a.bold) el.style.fontWeight = "600";
if (a.italic) el.style.fontStyle = "italic";
if (a.underline) el.style.textDecoration = "underline";
if (a.dim) el.style.opacity = "0.6";
if (a.inverse && !skipBg) {
const fg = el.style.color || "currentColor";
const bg = el.style.background || "transparent";
el.style.color = bg;
el.style.background = fg;
}
}
function makeEl(tag, className, text) {
const el = window.document.createElement(tag);
el.className = className;
if (text !== undefined) el.textContent = text;
return el;
}
function addSpeakerIcon(el, kind, content, options = {}) {
if (!speechAvailable()) return;
const text =
typeof content === "string" ? content : extractTextFromRuns(content);
const request = {
text,
kind,
expand: !!options.expand,
language: options.language || "",
};
const icon = window.document.createElement("button");
icon.className = options.className || "rb-speaker";
icon.type = "button";
icon.setAttribute("aria-label", options.label || "Speak");
icon.textContent = options.glyph || "▶";
icon.dataset.kind = kind;
icon.dataset.idle = icon.textContent;
icon.dataset.speechKey = speechKeyFor(request);
icon.addEventListener("click", (e) => {
e.stopPropagation();
handleSpeakerClick(icon, request);
});
el.appendChild(icon);
}
function addSpeakerIcons(el, kind) {
if (!speechAvailable()) return;
const bubbles = el.querySelectorAll(":scope > .rb-bubble");
bubbles.forEach((bubble) => {
const lines = bubble.querySelectorAll(".rb-bubble-line");
if (lines.length === 0) return;
const content = Array.from(lines)
.map((l) => l.textContent)
.join("\n");
addSpeakerIcon(bubble, kind, content);
});
const nakedLines = el.querySelectorAll(":scope > .rb-line");
if (nakedLines.length > 0) {
const content = Array.from(nakedLines)
.map((l) => l.textContent)
.join("\n");
addSpeakerIcon(el, kind, content);
}
}
function speechKeyFor(request) {
return `${request.kind}:${request.expand ? "full" : "brief"}::${request.text}`;
}
function handleSpeakerClick(icon, request) {
const isSpeaking = icon.classList.contains("rb-speaking");
stopAllSpeech();
if (isSpeaking) return;
const key = icon.dataset.speechKey || speechKeyFor(request);
markSpeaking(icon);
speakingKey = key;
clearSpeechNotice();
speak(request, {
onEnd: () => {
if (icon.isConnected) markIdle(icon);
window.document
.querySelectorAll(".rb-speaker.rb-speaking")
.forEach((other) => {
if (other.dataset.speechKey === key) markIdle(other);
});
if (speakingKey === key) speakingKey = null;
},
onError: (message) => showSpeechNotice(message),
});
}
function markSpeaking(icon) {
icon.classList.add("rb-speaking");
icon.textContent = "■";
}
function markIdle(icon) {
icon.classList.remove("rb-speaking");
icon.textContent = icon.dataset.idle || "▶";
}
function showSpeechNotice(message) {
if (!speechNotice) return;
speechNotice.textContent = message;
speechNotice.hidden = false;
}
function clearSpeechNotice() {
if (!speechNotice) return;
speechNotice.textContent = "";
speechNotice.hidden = true;
}
function extractTextFromRuns(runs) {
if (!runs) return "";
if (Array.isArray(runs)) return runs.map((r) => r.text || "").join("");
if (runs.lines && Array.isArray(runs.lines)) {
return runs.lines
.map((ln) => (ln.runs || []).map((r) => r.text || "").join(""))
.join("\n");
}
return "";
}
function stopAllSpeech() {
stopSpeech();
speakingKey = null;
window.document
.querySelectorAll(".rb-speaker.rb-speaking")
.forEach((icon) => markIdle(icon));
}
function reapplySpeakingState(root) {
if (!speakingKey || !root) return;
const icons = root.querySelectorAll(".rb-speaker");
for (const icon of icons) {
if (icon.dataset.speechKey === speakingKey) {
markSpeaking(icon);
return;
}
}
}