definition-inspector.js (17696B)
1 export { definitionSites } from './syntax.js'; 2 import { outputType, valueText } from './codecs.js'; 3 import { interpretationLabels } from './auto-interpretation.js'; 4 export { valueText as inspectionText } from './codecs.js'; 5 export function definitionInspector(input, request, io) { 6 const wrapper = input.dom; 7 const card = document.createElement('section'); 8 card.className = 'definition-card'; 9 card.hidden = true; 10 card.id = 'inline-definition'; 11 card.setAttribute('aria-label', 'Compiled definition'); 12 const title = document.createElement('strong'), format = document.createElement('select'); 13 format.setAttribute('aria-label', 'Definition interpretation'); 14 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']]) 15 format.add(new Option(label, value)); 16 const close = document.createElement('button'); 17 close.className = 'definition-card-close'; 18 close.textContent = '×'; 19 close.type = 'button'; 20 close.setAttribute('aria-label', 'Close definition inspection'); 21 close.title = 'Close (Escape)'; 22 const heading = document.createElement('div'); 23 heading.className = 'definition-card-controls'; 24 const identity = document.createElement('div'); 25 identity.className = 'inspection-title'; 26 const kind = document.createElement('span'); 27 kind.className = 'inspection-kind'; 28 kind.textContent = 'Definition'; 29 identity.append(kind, title); 30 heading.append(identity, format, close); 31 const status = document.createElement('p'); 32 status.className = 'hint'; 33 status.setAttribute('role', 'status'); 34 const result = document.createElement('pre'); 35 result.tabIndex = 0; 36 result.setAttribute('aria-label', 'Compiled definition result'); 37 card.append(heading, result, status); 38 wrapper.append(card); 39 const actions = document.createElement('div'); 40 actions.className = 'definition-actions'; 41 const copy = document.createElement('button'), download = document.createElement('button'); 42 copy.textContent = 'Copy'; 43 download.textContent = 'Download'; 44 const edit = document.createElement('button'); 45 edit.type = 'button'; 46 edit.textContent = 'Edit name'; 47 actions.append(copy, download); 48 card.append(actions, edit); 49 edit.className = 'edit-definition-name'; 50 edit.hidden = true; 51 const ioPanel = document.createElement('section'); 52 ioPanel.className = 'inline-io'; 53 ioPanel.hidden = true; 54 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> 55 <p class="hint">Recognized tricuIO envelope. Only Run IO executes actions.</p> 56 <div id="terminal-io"></div> 57 <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> 58 <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>`; 59 card.append(ioPanel); 60 const get = (id) => ioPanel.querySelector('#' + id); 61 const run = get('run-io'), stop = get('stop-io'); 62 const next = get('next'), trace = get('trace'); 63 const returnFormat = get('io-return-format'), step = get('step-mode'); 64 let saved, ioDetected = false, ioRunning = false, suspended = false; 65 function save(value) { 66 const text = valueText(value); 67 const name = (selected ?? 'definition').replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 64); 68 saved = { text, filename: 'file' in value ? `${name}.${value.file.extension}` : `${name}.${value.type === 'bytes' ? 'json' : 'txt'}` }; 69 result.textContent = text.slice(0, 16_384); 70 if (text.length > 16_384) 71 status.textContent += ' · Preview: first 16,384 characters. Copy/Download include the complete output.'; 72 actions.hidden = false; 73 copy.disabled = download.disabled = false; 74 } 75 function clear() { actions.hidden = true; saved = undefined; copy.disabled = download.disabled = true; result.textContent = ''; } 76 copy.onclick = async () => { 77 const value = saved; 78 if (!value) 79 return; 80 try { 81 await navigator.clipboard.writeText(value.text); 82 } 83 catch { 84 if (saved === value) 85 status.textContent += ' · Clipboard unavailable; use Download.'; 86 } 87 }; 88 download.onclick = () => { 89 if (!saved) 90 return; 91 const url = URL.createObjectURL(new Blob([saved.text], { type: 'text/plain;charset=utf-8' })); 92 const anchor = document.createElement('a'); 93 anchor.href = url; 94 anchor.download = saved.filename; 95 document.body.append(anchor); 96 anchor.click(); 97 anchor.remove(); 98 setTimeout(() => URL.revokeObjectURL(url), 1000); 99 }; 100 run.onclick = () => { 101 if (!selected || !ready || busy || !ioDetected) 102 return; 103 ioRunning = true; 104 clear(); 105 get('terminal-io').replaceChildren(); 106 trace.replaceChildren(); 107 status.textContent = 'Running IO…'; 108 io.run(selected, outputType(returnFormat.value), step.checked); 109 }; 110 stop.onclick = () => io.stop(); 111 next.onclick = () => io.resume(); 112 clear(); 113 let selected, source = '', ready = false, busy = false, serial = 0; 114 let pending, last = '', generation = 0, wasReady = false, scheduled = false; 115 let published; 116 const wired = new WeakSet(); 117 function selectName(name) { 118 if (busy || input.isDisabled || input.isReadOnly || wrapper.classList.contains('composing')) 119 return; 120 const site = input.declarations().find(site => site.name === name); 121 if (site) { 122 hide(); 123 input.select(site.start, site.start + site.name.length); 124 } 125 } 126 function wireTarget(target) { 127 if (wired.has(target)) 128 return; 129 wired.add(target); 130 const name = () => target.dataset.definition ?? ''; 131 const enabled = () => ready && !busy && target.matches('.definition-name[aria-disabled="false"]') && !wrapper.classList.contains('composing'); 132 let gesture; 133 let hold; 134 const cancel = () => { clearTimeout(hold); hold = undefined; }; 135 target.onpointerdown = event => { 136 cancel(); 137 const current = { x: event.clientX, y: event.clientY, time: performance.now(), cancelled: false, moved: false, touch: event.pointerType === 'touch' || event.pointerType === 'pen' }; 138 gesture = current; 139 // Short taps inspect; holds select the identifier for editing. Pointer 140 // cancellation preserves native scrolling. 141 if (current.touch) { 142 event.preventDefault(); 143 hold = setTimeout(() => { if (gesture === current && !current.cancelled && enabled()) { 144 current.cancelled = true; 145 selectName(name()); 146 } }, 500); 147 } 148 }; 149 target.onpointermove = event => { if (gesture && Math.hypot(event.clientX - gesture.x, event.clientY - gesture.y) > 8) { 150 gesture.cancelled = gesture.moved = true; 151 cancel(); 152 } }; 153 target.onpointerup = () => { cancel(); if (gesture?.touch && !gesture.moved && performance.now() - gesture.time >= 500 && enabled()) { 154 gesture.cancelled = true; 155 selectName(name()); 156 } }; 157 target.onpointercancel = () => { if (gesture) 158 gesture.cancelled = true; cancel(); }; 159 target.oncontextmenu = () => { cancel(); if (gesture) { 160 if (gesture.touch && !gesture.moved && enabled()) 161 selectName(name()); 162 gesture.cancelled = true; 163 } }; 164 target.onclick = event => { 165 cancel(); 166 if (event.detail && gesture && (gesture.cancelled || performance.now() - gesture.time > 500)) 167 return; 168 if (!enabled()) 169 return; 170 const selection = document.getSelection(); 171 if (event.detail > 1) 172 return; 173 // Touch gesture state distinguishes taps, holds and pans even when the 174 // browser retains a previous DOM selection. 175 if (event.detail && !gesture?.touch && selection && !selection.isCollapsed && (target.contains(selection.anchorNode) || target.contains(selection.focusNode))) 176 return; 177 choose(name()); 178 if (!card.hidden) 179 result.focus({ preventScroll: true }); 180 }; 181 target.addEventListener('keydown', event => { 182 if (['Enter', ' '].includes(event.key)) { 183 event.preventDefault(); 184 event.stopPropagation(); 185 if (enabled()) { 186 choose(name()); 187 if (!card.hidden) 188 result.focus({ preventScroll: true }); 189 } 190 } 191 if (event.key === 'Escape') { 192 event.preventDefault(); 193 event.stopPropagation(); 194 hide(); 195 } 196 }, true); 197 } 198 function position() { 199 input.setDefinitions({ ready, busy, ...(selected && !card.hidden ? { selected } : {}), ...(published ? { published } : {}) }); 200 for (const target of wrapper.querySelectorAll('.definition-name')) 201 wireTarget(target); 202 const available = selected && input.declarations().some(site => site.name === selected); 203 edit.hidden = input.dom.hidden || !available; 204 edit.disabled = busy || input.isDisabled || input.isReadOnly; 205 edit.title = input.isReadOnly ? 'This source is read-only.' : 'Select the declaration name in the editor.'; 206 const height = input.dom.clientHeight; 207 card.style.maxHeight = `${input.dom.hidden ? Math.min(540, innerHeight * .75) : Math.min(height - 16, innerHeight * .75, ioDetected ? 540 : 400)}px`; 208 if (selected && !card.hidden && !input.dom.hidden) { 209 const site = input.declarations().find(site => site.name === selected); 210 const rect = site ? input.view.coordsAtPos(site.start + site.name.length) : null; 211 const top = rect ? rect.bottom - wrapper.getBoundingClientRect().top + 4 : 8; 212 card.style.top = `${Math.max(8, Math.min(top, height - card.offsetHeight - 8))}px`; 213 } 214 } 215 function inspect() { 216 if (!selected || !ready || busy || suspended || pending !== undefined) 217 return; 218 const key = `${generation}:${selected}:${format.value}`; 219 if (last === key) 220 return; 221 last = key; 222 pending = ++serial; 223 status.textContent = 'Reading compiled tree… Stop is available in the toolbar.'; 224 result.textContent = ''; 225 request(pending, selected, format.value === 'auto' ? 'auto' : outputType(format.value)); 226 } 227 function schedule() { 228 if (scheduled) 229 return; 230 scheduled = true; 231 queueMicrotask(() => { scheduled = false; inspect(); }); 232 } 233 function choose(name) { 234 if (selected === name && !card.hidden && !suspended) { 235 hide(); 236 return; 237 } 238 selected = name; 239 suspended = false; 240 ioDetected = false; 241 next.disabled = true; 242 ioPanel.hidden = true; 243 get('terminal-io').replaceChildren(); 244 trace.replaceChildren(); 245 clear(); 246 format.value = 'auto'; 247 title.textContent = name; 248 card.hidden = false; 249 last = ''; 250 result.textContent = ''; 251 position(); 252 schedule(); 253 } 254 function hide() { 255 if (ioRunning) 256 io.stop(); 257 const name = selected; 258 selected = undefined; 259 card.hidden = true; 260 position(); 261 const target = [...wrapper.querySelectorAll('.definition-name')].find(target => target.dataset.definition === name && target.getAttribute('aria-disabled') === 'false'); 262 if (target) 263 target.focus({ preventScroll: true }); 264 else if (busy) 265 document.getElementById('stop')?.focus({ preventScroll: true }); 266 } 267 close.onclick = hide; 268 card.addEventListener('keydown', event => { if (event.key === 'Escape') { 269 event.preventDefault(); 270 hide(); 271 } }); 272 edit.onclick = () => { if (selected && !input.isReadOnly) 273 selectName(selected); }; 274 format.onchange = () => { suspended = false; last = ''; schedule(); }; 275 function update(current, working, publishedNames) { 276 ready = current; 277 busy = working; 278 published = publishedNames; 279 if (!input.dom.hidden && card.parentElement !== wrapper) { 280 wrapper.append(card); 281 card.classList.remove('external-definition'); 282 } 283 if (ready && !wasReady) { 284 generation++; 285 last = ''; 286 } 287 wasReady = ready; 288 if (source !== input.getSource()) { 289 ioDetected = false; 290 ioPanel.hidden = true; 291 clear(); 292 source = input.getSource(); 293 if (selected && !input.declarations().some(site => site.name === selected)) { 294 selected = undefined; 295 card.hidden = true; 296 result.textContent = ''; 297 } 298 } 299 if (selected && publishedNames && !publishedNames.includes(selected)) { 300 selected = undefined; 301 pending = undefined; 302 last = ''; 303 card.hidden = true; 304 result.textContent = ''; 305 } 306 format.disabled = !ready || busy; 307 run.disabled = !ready || busy || !ioDetected; 308 stop.disabled = !ioRunning; 309 returnFormat.disabled = step.disabled = busy; 310 copy.disabled = download.disabled = busy || !saved; 311 if (!ready) { 312 pending = undefined; 313 last = ''; 314 ioDetected = false; 315 ioPanel.hidden = true; 316 clear(); 317 if (selected) { 318 status.textContent = suspended ? 'Runtime stopped. Recompile/reload, then click the definition name to inspect again.' : 'Source changed or runtime unavailable. Waiting for successful compilation.'; 319 result.textContent = ''; 320 } 321 } 322 position(); 323 schedule(); 324 } 325 input.onLayout(position); 326 new ResizeObserver(position).observe(card); 327 return { 328 open(name) { card.classList.toggle('external-definition', input.dom.hidden); if (input.dom.hidden) { 329 card.style.top = ''; 330 document.getElementById('package-definition-view').append(card); 331 } 332 else 333 wrapper.append(card); choose(name); }, 334 event(message) { 335 if (!ioRunning) 336 return; 337 if (message.type === 'trace') { 338 const row = document.createElement('li'); 339 row.textContent = `${message.event.step} ${message.event.name}: ${message.event.detail}`; 340 trace.append(row); 341 } 342 if (message.type === 'state') { 343 next.disabled = message.state !== 'step'; 344 status.textContent = message.state === 'step' ? 'Paused before action' : 'Running IO…'; 345 } 346 if (message.type === 'done') { 347 ioRunning = false; 348 next.disabled = true; 349 status.textContent = 'IO finished · Returned value'; 350 save(message.result); 351 } 352 position(); 353 }, 354 // Stop suspends observation until the user reopens the definition. 355 stopped(message, suspend = false) { suspended = suspend; ioRunning = false; next.disabled = true; stop.disabled = true; status.textContent = message; }, 356 reset() { suspended = false; ioRunning = false; ioDetected = false; ioPanel.hidden = true; clear(); selected = undefined; pending = undefined; last = ''; card.hidden = true; result.textContent = ''; position(); }, 357 update, 358 reply(message) { 359 if (message.id !== pending) 360 return false; 361 pending = undefined; 362 if (!selected || !ready) 363 return true; 364 if (message.type === 'inspection-error') { 365 status.textContent = `Cannot interpret this tree: ${message.message}`; 366 clear(); 367 } 368 else if ('io' in message && message.io) { 369 ioDetected = true; 370 ioPanel.hidden = false; 371 status.textContent = 'IO program · authenticated tricuIO envelope'; 372 clear(); 373 } 374 else if ('result' in message) { 375 status.textContent = format.value === 'auto' ? 'Auto → ' + (interpretationLabels[message.result.type] ?? message.result.type) : format.selectedOptions[0].textContent; 376 save(message.result); 377 } 378 position(); 379 return true; 380 }, 381 }; 382 }