import { tokenize } from "./term-tokenizer.js";
import { createGestureRecognizer } from "./touch.js";
import { loadPrefs } from "./listen-prefs.js";
import * as prefs from "./prefs.js";
const SPEECH_AVAILABLE = "speechSynthesis" in window;
const RENDER_THROTTLE_MS = 50;
const BOTTOM_EPSILON_PX = 2;
let speakingKey = null;
let speakingOnEnd = null;
export function createReader({ host, document: doc, handlers = {} } = {}) {
let mounted = false;
let inner = null;
let statusBar = null;
let oscHint = null;
let scrollY = 0;
let maxScroll = 0;
let atBottom = true;
let renderTimer = null;
let changeSub = null;
let oscSub = null;
let resizeObserver = null;
let gestures = null;
const postRenderCallbacks = [];
const onWindowResize = () => handleResize();
const onBufferChanged = () => scheduleRender();
function applyTransform() {
if (!inner) return;
inner.style.transform = `translate3d(0, ${-scrollY}px, 0)`;
}
function setScroll(y) {
const clamped = Math.max(0, Math.min(maxScroll, y));
atBottom = clamped >= maxScroll - BOTTOM_EPSILON_PX;
if (clamped === scrollY) return;
scrollY = clamped;
applyTransform();
}
function recomputeBounds() {
if (!inner) {
maxScroll = 0;
return;
}
const innerH = inner.scrollHeight;
const statusH = statusBar ? statusBar.offsetHeight : 0;
const hostH = host.clientHeight - statusH;
maxScroll = Math.max(0, innerH - hostH);
}
function handleResize() {
if (!mounted || !inner) return;
recomputeBounds();
if (atBottom) scrollY = maxScroll;
else scrollY = Math.min(scrollY, maxScroll);
applyTransform();
}
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();
const wasAtBottom = atBottom;
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);
recomputeBounds();
if (wasAtBottom) scrollY = maxScroll;
else scrollY = Math.min(scrollY, maxScroll);
applyTransform();
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 href="/settings#shell-integration">Set up OSC 133 →</a></span>' +
'<button type="button" class="reader-osc-dismiss" aria-label="Dismiss">×</button>';
el.querySelector(".reader-osc-dismiss").addEventListener("click", () => {
prefs.set("osc133_hint_dismissed", true);
el.hidden = true;
});
return el;
}
function refreshOscHint() {
if (!oscHint) return;
const dismissed = prefs.get("osc133_hint_dismissed") === true;
oscHint.hidden = doc.oscDetected || 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;
setScroll(scrollY + dy);
}
function stickToBottom() {
if (!mounted) return;
atBottom = true;
setScroll(maxScroll);
}
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();
host.replaceChildren(inner, oscHint, statusBar);
refreshOscHint();
oscSub = doc.onOscDetected(() => refreshOscHint());
scrollY = 0;
maxScroll = 0;
atBottom = true;
changeSub = doc.subscribe(onBufferChanged);
if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(() => handleResize());
resizeObserver.observe(host);
}
window.addEventListener("resize", onWindowResize);
mountGestures();
render();
}
function unmount() {
if (!mounted) return;
mounted = false;
host.classList.add("hidden");
unmountGestures();
if (changeSub) {
changeSub.dispose();
changeSub = null;
}
if (oscSub) {
oscSub.dispose();
oscSub = null;
}
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
window.removeEventListener("resize", onWindowResize);
if (renderTimer !== null) {
clearTimeout(renderTimer);
renderTimer = null;
}
inner = null;
statusBar = null;
oscHint = null;
}
function dispose() {
unmount();
}
return {
mount,
unmount,
dispose,
scrollBy,
stickToBottom,
forceRender: () => render(),
awaitNextRender: () =>
new Promise((resolve) => postRenderCallbacks.push(resolve)),
forceScrollTop: () => {
atBottom = false;
scrollY = 0;
applyTransform();
},
get mounted() {
return mounted;
},
get scrollY() {
return scrollY;
},
get maxScroll() {
return maxScroll;
},
get innerHeight() {
return inner ? inner.scrollHeight : 0;
},
get atBottom() {
return atBottom;
},
statusBarOffsetHeight: () => (statusBar ? statusBar.offsetHeight : 0),
statusBarFilled: () =>
!!statusBar && statusBar.classList.contains("reader-statusbar--filled"),
};
}
function renderStatusBar(hostEl, status) {
if (!hostEl) return;
if (!status || !status.runs || status.runs.length === 0) {
hostEl.replaceChildren();
hostEl.classList.remove("reader-statusbar--filled");
hostEl.style.background = "";
return;
}
const runs = status.runs;
const inner = window.document.createElement("div");
inner.className = "reader-statusbar-inner";
appendRuns(inner, runs);
hostEl.replaceChildren(inner);
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, "prompt", 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, "prompt", 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, "text");
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, "text", block);
return el;
}
function renderCodeBlock(block) {
const wrap = window.document.createElement("div");
wrap.className = "rb rb-code";
appendLinesWithBubbles(wrap, block.lines, "rb-codeline");
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) {
if (!SPEECH_AVAILABLE) return;
const icon = window.document.createElement("button");
icon.className = "rb-speaker";
icon.type = "button";
icon.setAttribute("aria-label", "Speak");
icon.textContent = "▶";
icon.dataset.kind = kind;
const text =
typeof content === "string" ? content : extractTextFromRuns(content);
icon.dataset.speechKey = speechKeyFor(kind, text);
icon.addEventListener("click", (e) => {
e.stopPropagation();
handleSpeakerClick(icon, kind, text);
});
el.appendChild(icon);
}
function addSpeakerIcons(el, kind) {
if (!SPEECH_AVAILABLE) 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(kind, text) {
return `${kind}::${text}`;
}
function handleSpeakerClick(icon, kind, text) {
const isSpeaking = icon.classList.contains("rb-speaking");
stopAllSpeech();
if (isSpeaking) return;
const key = icon.dataset.speechKey || speechKeyFor(kind, text);
icon.classList.add("rb-speaking");
icon.textContent = "■";
let utteranceText = text;
if (kind === "prompt") {
utteranceText = "command: " + utteranceText;
}
const onEnd = () => {
if (icon.isConnected) {
icon.classList.remove("rb-speaking");
icon.textContent = "▶";
}
window.document
.querySelectorAll(`.rb-speaker.rb-speaking`)
.forEach((other) => {
if (other.dataset.speechKey === key) {
other.classList.remove("rb-speaking");
other.textContent = "▶";
}
});
if (speakingOnEnd === onEnd) {
speakingKey = null;
speakingOnEnd = null;
}
};
speakingKey = key;
speakingOnEnd = onEnd;
speakText(utteranceText, onEnd);
}
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() {
window.speechSynthesis.cancel();
speakingKey = null;
speakingOnEnd = null;
window.document
.querySelectorAll(".rb-speaker.rb-speaking")
.forEach((icon) => {
icon.classList.remove("rb-speaking");
icon.textContent = "▶";
});
}
function reapplySpeakingState(root) {
if (!speakingKey || !root) return;
const icons = root.querySelectorAll(".rb-speaker");
for (const icon of icons) {
if (icon.dataset.speechKey === speakingKey) {
icon.classList.add("rb-speaking");
icon.textContent = "■";
return;
}
}
}
function splitIntoSentences(text) {
const chunks = text.split(/([.!?])\s+/);
const sentences = [];
for (let i = 0; i < chunks.length; i += 2) {
const base = chunks[i];
const punct = chunks[i + 1] || "";
if (base.trim()) sentences.push(base + punct);
}
return sentences.length > 0 ? sentences : [text];
}
function speakText(text, onEnd) {
const listenPrefs = loadPrefs();
const sentences = splitIntoSentences(text.trim());
let index = 0;
function speakNext() {
if (index >= sentences.length) {
if (onEnd) onEnd();
return;
}
const utterance = new SpeechSynthesisUtterance(sentences[index]);
utterance.rate = listenPrefs.rate;
utterance.pitch = listenPrefs.pitch;
if (listenPrefs.voice) {
const voices = window.speechSynthesis.getVoices();
const selected = voices.find((v) => v.name === listenPrefs.voice);
if (selected) utterance.voice = selected;
}
utterance.onend = () => {
index++;
speakNext();
};
utterance.onerror = () => {
if (onEnd) onEnd();
};
window.speechSynthesis.speak(utterance);
}
speakNext();
}