import { tokenize, extractRuns } from './term-tokenizer.js';
import { loadPrefs } from './listen-prefs.js';
const SPEECH_AVAILABLE = 'speechSynthesis' in window;
const RENDER_THROTTLE_MS = 50;
const STICK_TO_BOTTOM_PX = 80;
let speakingKey = null;
let speakingOnEnd = null;
export class ReaderView {
constructor({ host, core, overlay }) {
this.host = host;
this.core = core;
this.overlay = overlay;
this.mounted = false;
this._inner = null;
this._scrollY = 0;
this._maxScroll = 0;
this._atBottom = true;
this._renderTimer = null;
this._writeSub = null;
this._resizeObserver = null;
this._onWindowResize = () => this._handleResize();
this._onWriteParsed = () => this._scheduleRender();
this._postRenderCallbacks = [];
}
get scrollY() { return this._scrollY; }
get maxScroll() { return this._maxScroll; }
get innerHeight() { return this._inner ? this._inner.scrollHeight : 0; }
awaitNextRender() {
return new Promise((resolve) => {
this._postRenderCallbacks.push(resolve);
});
}
mount() {
if (this.mounted) return;
this.mounted = true;
this.host.classList.remove('hidden');
if (this.overlay) this.overlay.classList.add('hidden');
this._inner = document.createElement('div');
this._inner.className = 'reader-inner';
this._statusBar = document.createElement('div');
this._statusBar.className = 'reader-statusbar';
this._oscHint = this._buildOscHint();
this.host.replaceChildren(this._inner, this._oscHint, this._statusBar);
this._refreshOscHint();
this._onOscDetected = () => this._refreshOscHint();
this.core.addEventListener('osc-detected', this._onOscDetected);
this._scrollY = 0;
this._maxScroll = 0;
this._atBottom = true;
this._writeSub = this.core.term.onWriteParsed(this._onWriteParsed);
if (typeof ResizeObserver !== 'undefined') {
this._resizeObserver = new ResizeObserver(() => this._handleResize());
this._resizeObserver.observe(this.host);
}
window.addEventListener('resize', this._onWindowResize);
this._render();
}
unmount() {
if (!this.mounted) return;
this.mounted = false;
this.host.classList.add('hidden');
if (this.overlay) this.overlay.classList.remove('hidden');
if (this._writeSub) { this._writeSub.dispose(); this._writeSub = null; }
if (this._resizeObserver) { this._resizeObserver.disconnect(); this._resizeObserver = null; }
window.removeEventListener('resize', this._onWindowResize);
if (this._onOscDetected) {
this.core.removeEventListener('osc-detected', this._onOscDetected);
this._onOscDetected = null;
}
if (this._renderTimer !== null) {
clearTimeout(this._renderTimer);
this._renderTimer = null;
}
this._inner = null;
this._statusBar = null;
this._oscHint = null;
}
_buildOscHint() {
const el = 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', () => {
try { localStorage.setItem('mobux.osc133.dismissed', '1'); } catch (_) {}
el.hidden = true;
});
return el;
}
_refreshOscHint() {
if (!this._oscHint) return;
let dismissed = false;
try { dismissed = localStorage.getItem('mobux.osc133.dismissed') === '1'; } catch (_) {}
this._oscHint.hidden = this.core.oscDetected || dismissed;
}
scrollBy(dy) {
if (!this.mounted) return;
this._setScroll(this._scrollY + dy);
}
stickToBottom() {
if (!this.mounted) return;
this._atBottom = true;
this._setScroll(this._maxScroll);
}
_setScroll(y) {
const clamped = Math.max(0, Math.min(this._maxScroll, y));
this._atBottom = clamped >= this._maxScroll - STICK_TO_BOTTOM_PX;
if (clamped === this._scrollY) return;
this._scrollY = clamped;
this._applyTransform();
}
_applyTransform() {
if (!this._inner) return;
this._inner.style.transform = `translate3d(0, ${-this._scrollY}px, 0)`;
}
_scheduleRender() {
if (!this.mounted) return;
if (this._renderTimer !== null) return;
this._renderTimer = setTimeout(() => {
this._renderTimer = null;
this._render();
}, RENDER_THROTTLE_MS);
}
_handleResize() {
if (!this.mounted || !this._inner) return;
this._recomputeBounds();
if (this._atBottom) this._scrollY = this._maxScroll;
else this._scrollY = Math.min(this._scrollY, this._maxScroll);
this._applyTransform();
}
_recomputeBounds() {
if (!this._inner) { this._maxScroll = 0; return; }
const innerH = this._inner.scrollHeight;
const statusH = this._statusBar ? this._statusBar.offsetHeight : 0;
const hostH = this.host.clientHeight - statusH;
this._maxScroll = Math.max(0, innerH - hostH);
}
_render() {
if (!this._inner) return;
const buffer = this.core.getActiveBuffer();
const cols = this.core.term.cols;
const wasAtBottom = this._atBottom;
const total = buffer.length;
const statusEndY = total > 0 ? total - 1 : 0;
renderStatusBar(this._statusBar, buffer, cols, statusEndY);
const blocks = tokenize(buffer, cols, {
endY: statusEndY,
oscMarkers: this.core.oscMarkers,
});
const frag = document.createDocumentFragment();
for (const block of blocks) frag.appendChild(renderBlock(block));
this._inner.replaceChildren(frag);
reapplySpeakingState(this._inner);
this._recomputeBounds();
if (wasAtBottom) this._scrollY = this._maxScroll;
else this._scrollY = Math.min(this._scrollY, this._maxScroll);
this._applyTransform();
const cbs = this._postRenderCallbacks.splice(0);
for (const cb of cbs) cb();
}
}
function renderStatusBar(host, buffer, cols, rowIndex) {
if (!host) return;
const line = buffer.getLine(rowIndex);
if (!line) { host.replaceChildren(); host.classList.remove('reader-statusbar--filled'); return; }
const runs = extractRuns([line], cols);
const hasContent = runs.some((r) => r.text && r.text.trim().length > 0);
if (!hasContent) {
host.replaceChildren();
host.classList.remove('reader-statusbar--filled');
return;
}
const inner = document.createElement('div');
inner.className = 'reader-statusbar-inner';
appendRuns(inner, runs);
host.replaceChildren(inner);
host.classList.add('reader-statusbar--filled');
const bg = dominantBg(runs);
host.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 'text': return renderTextBlock(block);
case 'code': return renderCodeBlock(block);
default: return makeEl('div', 'rb', block.text || '');
}
}
function renderInlineBlock(className, runs) {
const el = document.createElement('div');
el.className = className;
appendRuns(el, runs);
if (className === 'rb rb-prompt') {
addSpeakerIcon(el, 'prompt', runs);
}
return el;
}
function renderTextBlock(block) {
const el = 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 = 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 = 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 = 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 = 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(document.createTextNode('\u00a0'));
return;
}
for (const run of runs) {
if (!run.text) continue;
const span = 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 = 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 = 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 = '▶';
}
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;
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 prefs = 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 = prefs.rate;
utterance.pitch = prefs.pitch;
if (prefs.voice) {
const voices = window.speechSynthesis.getVoices();
const selected = voices.find((v) => v.name === prefs.voice);
if (selected) utterance.voice = selected;
}
utterance.onend = () => {
index++;
speakNext();
};
utterance.onerror = () => {
if (onEnd) onEnd();
};
window.speechSynthesis.speak(utterance);
}
speakNext();
}