definition-inspector.ts (15776B)
1 import type { SourceEditor } from './source-editor.js'; 2 export { definitionSites } from './syntax.js'; 3 import { outputType, valueText, type OutputType, type ValueView } from './codecs.js'; 4 import { interpretationLabels, type InspectionFormat } from './auto-interpretation.js'; 5 import type { WorkerMessage, InspectionReply } from './protocol.js'; 6 import type { CompilerMessage } from './compiler-protocol.js'; 7 interface IOControls { 8 run(name: string, output: OutputType, step: boolean): void; 9 stop(): void; 10 resume(): void; 11 } 12 13 export { valueText as inspectionText } from './codecs.js'; 14 export function definitionInspector(input: SourceEditor, request: (id: number, name: string, output: InspectionFormat) => void, io: IOControls) { 15 const wrapper = input.dom; 16 const card = document.createElement('section'); card.className = 'definition-card'; card.hidden = true; 17 card.id = 'inline-definition'; card.setAttribute('aria-label', 'Compiled definition'); 18 const title = document.createElement('strong'), format = document.createElement('select'); 19 format.setAttribute('aria-label', 'Definition interpretation'); 20 for (const [value, label] of [['auto', 'Auto'], ['tricu', 'tricu · t source'], ['triangle', '△ source'], ['dag', 'DAG · shared △'], ['ternary', 'Ternary'], ['string', 'Text'], ['number', 'Natural number'], ['bool', 'Boolean'], ['bytes', 'Bytes'], ['values', 'List · text / numbers']]) format.add(new Option(label, value)); 21 const close = document.createElement('button'); close.className = 'definition-card-close'; close.textContent = '×'; close.type = 'button'; 22 close.setAttribute('aria-label', 'Close definition inspection'); close.title = 'Close (Escape)'; 23 const heading = document.createElement('div'); heading.className = 'definition-card-controls'; const identity = document.createElement('div'); identity.className = 'inspection-title'; 24 const kind = document.createElement('span'); kind.className = 'inspection-kind'; kind.textContent = 'Definition'; 25 identity.append(kind, title); heading.append(identity, format, close); 26 const status = document.createElement('p'); status.className = 'hint'; status.setAttribute('role', 'status'); 27 const result = document.createElement('pre'); result.tabIndex = 0; result.setAttribute('aria-label', 'Compiled definition result'); 28 card.append(heading, result, status); wrapper.append(card); 29 const actions = document.createElement('div'); actions.className = 'definition-actions'; 30 const copy = document.createElement('button'), download = document.createElement('button'); 31 copy.textContent = 'Copy'; download.textContent = 'Download'; const edit = document.createElement('button'); edit.type = 'button'; edit.textContent = 'Edit name'; 32 actions.append(copy, download); card.append(actions, edit); edit.className = 'edit-definition-name'; edit.hidden = true; 33 const ioPanel = document.createElement('section'); ioPanel.className = 'inline-io'; ioPanel.hidden = true; 34 ioPanel.innerHTML = `<div class="inline-io-actions"><button type="button" id="run-io">Run IO</button><button type="button" id="stop-io" disabled>Stop</button></div> 35 <p class="hint">Recognized tricuIO envelope. Only Run IO executes actions.</p> 36 <div id="terminal-io"></div> 37 <details><summary>IO details</summary><label><input id="step-mode" type="checkbox"> Pause before each scheduler turn</label><button id="next" type="button" disabled>Next scheduler turn</button> 38 <label for="io-return-format">Returned value (next run)</label><select id="io-return-format"><option value="tricu">tricu source</option><option value="string">Text</option><option value="number">Natural number</option><option value="values">List · text / numbers</option><option value="bool">Boolean</option><option value="bytes">Bytes</option><option value="triangle">△ source</option><option value="ternary">Ternary</option><option value="dag">DAG</option></select><ol id="trace"></ol></details>`; 39 card.append(ioPanel); 40 const get = <T extends HTMLElement>(id: string) => ioPanel.querySelector<T>('#' + id)!; 41 const run = get<HTMLButtonElement>('run-io'), stop = get<HTMLButtonElement>('stop-io'); 42 const next = get<HTMLButtonElement>('next'), trace = get('trace'); 43 const returnFormat = get<HTMLSelectElement>('io-return-format'), step = get<HTMLInputElement>('step-mode'); 44 let saved: { text: string; filename: string } | undefined, ioDetected = false, ioRunning = false, suspended = false; 45 function save(value: ValueView): void { 46 const text = valueText(value); 47 const name = (selected ?? 'definition').replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 64); 48 saved = { text, filename: 'file' in value ? `${name}.${value.file.extension}` : `${name}.${value.type === 'bytes' ? 'json' : 'txt'}` }; 49 result.textContent = text.slice(0, 16_384); 50 if (text.length > 16_384) status.textContent += ' · Preview: first 16,384 characters. Copy/Download include the complete output.'; 51 actions.hidden = false; copy.disabled = download.disabled = false; 52 } 53 function clear(): void { actions.hidden = true; saved = undefined; copy.disabled = download.disabled = true; result.textContent = ''; } 54 copy.onclick = async () => { 55 const value = saved; if (!value) return; 56 try { await navigator.clipboard.writeText(value.text); } 57 catch { if (saved === value) status.textContent += ' · Clipboard unavailable; use Download.'; } 58 }; 59 download.onclick = () => { 60 if (!saved) return; 61 const url = URL.createObjectURL(new Blob([saved.text], { type: 'text/plain;charset=utf-8' })); 62 const anchor = document.createElement('a'); anchor.href = url; anchor.download = saved.filename; 63 document.body.append(anchor); anchor.click(); anchor.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); 64 }; 65 run.onclick = () => { 66 if (!selected || !ready || busy || !ioDetected) return; 67 ioRunning = true; clear(); get('terminal-io').replaceChildren(); trace.replaceChildren(); 68 status.textContent = 'Running IO…'; io.run(selected, outputType(returnFormat.value), step.checked); 69 }; 70 stop.onclick = () => io.stop(); next.onclick = () => io.resume(); 71 clear(); 72 let selected: string | undefined, source = '', ready = false, busy = false, serial = 0; 73 let pending: number | undefined, last = '', generation = 0, wasReady = false, scheduled = false; 74 let published:string[]|undefined; 75 const wired = new WeakSet<HTMLElement>(); 76 function selectName(name: string): void { 77 if (busy || input.isDisabled || input.isReadOnly || wrapper.classList.contains('composing')) return; 78 const site = input.declarations().find(site => site.name === name); 79 if (site) { hide(); input.select(site.start, site.start + site.name.length); } 80 } 81 function wireTarget(target: HTMLElement): void { 82 if (wired.has(target)) return; wired.add(target); 83 const name = () => target.dataset.definition ?? ''; 84 const enabled = () => ready && !busy && target.matches('.definition-name[aria-disabled="false"]') && !wrapper.classList.contains('composing'); 85 let gesture: {x:number;y:number;time:number;cancelled:boolean;moved:boolean;touch:boolean}|undefined; 86 let hold:ReturnType<typeof setTimeout>|undefined; 87 const cancel = () => {clearTimeout(hold);hold=undefined;}; 88 target.onpointerdown = event => { 89 cancel(); const current={x:event.clientX,y:event.clientY,time:performance.now(),cancelled:false,moved:false,touch:event.pointerType==='touch'||event.pointerType==='pen'}; gesture=current; 90 // Short taps inspect; holds select the identifier for editing. Pointer 91 // cancellation preserves native scrolling. 92 if(current.touch) {event.preventDefault();hold=setTimeout(()=>{if(gesture===current&&!current.cancelled&&enabled()){current.cancelled=true;selectName(name());}},500);} 93 94 }; 95 target.onpointermove = event => {if(gesture&&Math.hypot(event.clientX-gesture.x,event.clientY-gesture.y)>8){gesture.cancelled=gesture.moved=true;cancel();}}; 96 target.onpointerup = () => {cancel();if(gesture?.touch&&!gesture.moved&&performance.now()-gesture.time>=500&&enabled()){gesture.cancelled=true;selectName(name());}}; 97 target.onpointercancel = () => {if(gesture)gesture.cancelled=true;cancel();}; 98 target.oncontextmenu = () => {cancel();if(gesture){if(gesture.touch&&!gesture.moved&&enabled())selectName(name());gesture.cancelled=true;}}; 99 target.onclick = event => { 100 cancel(); if(event.detail&&gesture&&(gesture.cancelled||performance.now()-gesture.time>500))return; 101 if(!enabled())return; 102 const selection=document.getSelection(); 103 if(event.detail>1)return; 104 // Touch gesture state distinguishes taps, holds and pans even when the 105 // browser retains a previous DOM selection. 106 if(event.detail&&!gesture?.touch&&selection&&!selection.isCollapsed&&(target.contains(selection.anchorNode)||target.contains(selection.focusNode)))return; 107 choose(name());if(!card.hidden)result.focus({preventScroll:true}); 108 }; 109 target.addEventListener('keydown',event=>{ 110 if(['Enter',' '].includes(event.key)){event.preventDefault();event.stopPropagation();if(enabled()){choose(name());if(!card.hidden)result.focus({preventScroll:true});}} 111 if(event.key==='Escape'){event.preventDefault();event.stopPropagation();hide();} 112 },true); 113 } 114 function position(): void { 115 input.setDefinitions({ready,busy,...(selected&&!card.hidden?{selected}:{}),...(published?{published}:{})}); 116 for(const target of wrapper.querySelectorAll<HTMLElement>('.definition-name'))wireTarget(target); 117 const available=selected&&input.declarations().some(site=>site.name===selected); 118 edit.hidden = input.dom.hidden || !available; edit.disabled = busy || input.isDisabled || input.isReadOnly; 119 edit.title = input.isReadOnly ? 'This source is read-only.' : 'Select the declaration name in the editor.'; 120 const height=input.dom.clientHeight; 121 card.style.maxHeight = `${input.dom.hidden ? Math.min(540,innerHeight*.75) : Math.min(height-16,innerHeight*.75,ioDetected?540:400)}px`; 122 if(selected&&!card.hidden&&!input.dom.hidden){ 123 const site=input.declarations().find(site=>site.name===selected); 124 const rect=site?input.view.coordsAtPos(site.start+site.name.length):null; 125 const top=rect?rect.bottom-wrapper.getBoundingClientRect().top+4:8; 126 card.style.top=`${Math.max(8,Math.min(top,height-card.offsetHeight-8))}px`; 127 } 128 } 129 function inspect(): void { 130 if (!selected || !ready || busy || suspended || pending !== undefined) return; 131 const key = `${generation}:${selected}:${format.value}`; if (last === key) return; 132 last = key; pending = ++serial; status.textContent = 'Reading compiled tree… Stop is available in the toolbar.'; result.textContent = ''; 133 request(pending, selected, format.value === 'auto' ? 'auto' : outputType(format.value)); 134 } 135 function schedule(): void { 136 if (scheduled) return; scheduled = true; 137 queueMicrotask(() => { scheduled = false; inspect(); }); 138 } 139 function choose(name: string): void { 140 if (selected === name && !card.hidden && !suspended) { hide(); return; } 141 selected = name; suspended = false; ioDetected = false; next.disabled = true; ioPanel.hidden = true; get('terminal-io').replaceChildren(); trace.replaceChildren(); clear(); format.value = 'auto'; title.textContent = name; card.hidden = false; last = ''; result.textContent = ''; 142 position(); schedule(); 143 } 144 function hide(): void { 145 if (ioRunning) io.stop(); 146 const name = selected; selected = undefined; card.hidden = true; 147 position(); 148 const target=[...wrapper.querySelectorAll<HTMLElement>('.definition-name')].find(target=>target.dataset.definition===name&&target.getAttribute('aria-disabled')==='false'); 149 if(target)target.focus({preventScroll:true});else if(busy)document.getElementById('stop')?.focus({preventScroll:true}); 150 151 } 152 close.onclick = hide; 153 card.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); hide(); } }); 154 edit.onclick = () => { if (selected && !input.isReadOnly) selectName(selected); }; 155 format.onchange = () => { suspended = false; last = ''; schedule(); }; 156 function update(current: boolean, working: boolean, publishedNames?: string[]): void { 157 ready = current; busy = working; published=publishedNames; 158 if (!input.dom.hidden && card.parentElement !== wrapper) { wrapper.append(card); card.classList.remove('external-definition'); } 159 if (ready && !wasReady) { generation++; last = ''; } 160 wasReady = ready; 161 if (source !== input.getSource()) { 162 ioDetected = false; ioPanel.hidden = true; clear(); 163 source = input.getSource(); 164 if(selected&&!input.declarations().some(site=>site.name===selected)){selected=undefined;card.hidden=true;result.textContent='';} 165 } 166 167 if (selected && publishedNames && !publishedNames.includes(selected)) { 168 selected = undefined; pending = undefined; last = ''; card.hidden = true; result.textContent = ''; 169 } 170 format.disabled = !ready || busy; 171 run.disabled = !ready || busy || !ioDetected; stop.disabled = !ioRunning; returnFormat.disabled = step.disabled = busy; 172 copy.disabled = download.disabled = busy || !saved; 173 if (!ready) { 174 pending = undefined; last = ''; ioDetected = false; ioPanel.hidden = true; clear(); 175 if (selected) { status.textContent = suspended ? 'Runtime stopped. Recompile/reload, then click the definition name to inspect again.' : 'Source changed or runtime unavailable. Waiting for successful compilation.'; result.textContent = ''; } 176 } 177 position(); schedule(); 178 } 179 input.onLayout(position); 180 new ResizeObserver(position).observe(card); 181 return { 182 open(name: string): void { card.classList.toggle('external-definition', input.dom.hidden); if (input.dom.hidden) { card.style.top = ''; document.getElementById('package-definition-view')!.append(card); } else wrapper.append(card); choose(name); }, 183 event(message: WorkerMessage | CompilerMessage): void { 184 if (!ioRunning) return; 185 if (message.type === 'trace') { const row = document.createElement('li'); row.textContent = `${message.event.step} ${message.event.name}: ${message.event.detail}`; trace.append(row); } 186 if (message.type === 'state') { next.disabled = message.state !== 'step'; status.textContent = message.state === 'step' ? 'Paused before action' : 'Running IO…'; } 187 if (message.type === 'done') { ioRunning = false; next.disabled = true; status.textContent = 'IO finished · Returned value'; save(message.result); } 188 position(); 189 }, 190 // Stop suspends observation until the user reopens the definition. 191 stopped(message: string, suspend = false): void { suspended = suspend; ioRunning = false; next.disabled = true; stop.disabled = true; status.textContent = message; }, 192 reset(): void { suspended = false; ioRunning = false; ioDetected = false; ioPanel.hidden = true; clear(); selected = undefined; pending = undefined; last = ''; card.hidden = true; result.textContent = ''; position(); }, 193 update, 194 reply(message: InspectionReply): boolean { 195 if (message.id !== pending) return false; 196 pending = undefined; 197 if (!selected || !ready) return true; 198 if (message.type === 'inspection-error') { status.textContent = `Cannot interpret this tree: ${message.message}`; clear(); } 199 else if ('io' in message && message.io) { ioDetected = true; ioPanel.hidden = false; status.textContent = 'IO program · authenticated tricuIO envelope'; clear(); } 200 else if ('result' in message) { status.textContent = format.value === 'auto' ? 'Auto → ' + (interpretationLabels[message.result.type] ?? message.result.type) : format.selectedOptions[0]!.textContent; save(message.result); } 201 position(); return true; 202 }, 203 }; 204 }