tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

commit eb0509a086b697d572f2a0b1444a31f1bd6b0551
parent 47e80a01d8bdaa2c6b86d3d91fe215a863e889df
Author: James Eversole <james@eversole.co>
Date:   Mon, 26 Oct 2026 19:31:08 -0500

Make import folding cursor-driven and add inline module browsing

Diffstat:
Mext/demo-site/README.md | 8+++++---
Mext/demo-site/dist/import-folds.js | 85+++++++++++++++++--------------------------------------------------------------
Mext/demo-site/dist/import-picker.js | 28+++++++++++-----------------
Aext/demo-site/dist/library-browser.js | 20++++++++++++++++++++
Aext/demo-site/dist/module-inspector.js | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mext/demo-site/dist/source-editor.js | 5++---
Mext/demo-site/src/import-folds.ts | 70++++++++++++++++++----------------------------------------------------
Mext/demo-site/src/import-picker.ts | 23+++++++++++------------
Aext/demo-site/src/library-browser.ts | 16++++++++++++++++
Aext/demo-site/src/module-inspector.ts | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mext/demo-site/src/source-editor.ts | 5++---
Mext/demo-site/style.css | 3+++
Mext/demo-site/test/import-fold-inputs.mjs | 4++--
Mext/demo-site/test/import-folds-probe.mjs | 10+++++-----
Aext/demo-site/test/import-folds.mjs | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mext/demo-site/test/import-folds.test.mjs | 16++++++++--------
16 files changed, 365 insertions(+), 172 deletions(-)

diff --git a/ext/demo-site/README.md b/ext/demo-site/README.md @@ -73,9 +73,11 @@ input limit with diagnostics. Import **hashes** display their first three and last three characters with literal dots (`abc...xyz`), in muted teal with a dotted underline. Editing an import line -reveals its full hash; a range selection reveals every affected line, leaving -unrelated imports folded. Each hash also has an independent keyboard-accessible -expand/collapse control. Copying, saving and compilation use complete canonical +reveals its full hash. Only cursor lines (the head of each selection) expand, +including when focus moves outside the editor. Clicking a folded hash places the +cursor at its end. Clicking an expanded hash opens an inline, searchable list of +the exact module’s definitions, using the same library index and export rendering +as library search. Local modules use their registered exports. Copying, saving and compilation use complete canonical source; folding and selection preserve the document and undo history. The pinned editor sources, fixed-output hashes, licenses and declaration build diff --git a/ext/demo-site/dist/import-folds.js b/ext/demo-site/dist/import-folds.js @@ -1,6 +1,6 @@ // Hash decorations project canonical UTF-16 document ranges. Editing a revealed // hash uses ordinary character-level selection and deletion. -import { StateField, StateEffect, Transaction, EditorSelection, EditorView, Decoration, WidgetType } from './codemirror.js'; +import { StateField, EditorSelection, EditorView, Decoration, WidgetType } from './codemirror.js'; import { tokenize } from './syntax.js'; const cachedSites = new WeakMap(); export function importSites(state) { @@ -21,89 +21,40 @@ export function importSites(state) { cachedSites.set(state.doc, sites); return sites; } -export const importFocus = StateEffect.define(); -const toggle = StateEffect.define(); const short = (hash) => hash.slice(0, 3) + '...' + hash.slice(-3); class ImportHash extends WidgetType { site; - expanded; - constructor(site, expanded) { + constructor(site) { super(); this.site = site; - this.expanded = expanded; } - eq(other) { return this.site.from === other.site.from && this.site.hash === other.site.hash && this.expanded === other.expanded; } + eq(other) { return this.site.from === other.site.from && this.site.hash === other.site.hash; } toDOM(view) { const button = document.createElement('button'); button.type = 'button'; button.className = 'import-hash'; - button.textContent = this.expanded ? this.site.hash : short(this.site.hash); - button.dataset.hashFrom = String(this.site.from); - button.setAttribute('aria-expanded', String(this.expanded)); - button.setAttribute('aria-label', `${this.expanded ? 'Collapse' : 'Expand'} import hash ${this.site.hash}`); + button.textContent = short(this.site.hash); + button.setAttribute('aria-label', `Place cursor at import ${this.site.hash}`); button.onclick = () => { - view.dispatch({ effects: toggle.of(this.site.from), annotations: Transaction.addToHistory.of(false) }); - view.dom.querySelector(`[data-hash-from="${this.site.from}"]`)?.focus({ preventScroll: true }); - }; - // Ctrl+A on a hash control selects the complete canonical program. - button.onkeydown = event => { - if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { - event.preventDefault(); - view.dispatch({ selection: EditorSelection.single(0, view.state.doc.length), effects: importFocus.of(true) }); - view.focus(); - } + view.dispatch({ selection: EditorSelection.single(this.site.to) }); + view.focus(); }; return button; } ignoreEvent() { return true; } } -export function selectedImportLines(state, active) { - const lines = new Set(); - for (const range of state.selection.ranges) { - if (!active && range.empty) - continue; - const from = state.doc.lineAt(range.from).number, to = state.doc.lineAt(range.empty ? range.to : Math.max(range.from, range.to - 1)).number; - for (let line = from; line <= to; line++) - lines.add(line); - } - return lines; +export function selectedImportLines(state) { + return new Set(state.selection.ranges.map(range => state.doc.lineAt(range.head).number)); } -function decorate(state, open, active) { - const sites = importSites(state), selected = selectedImportLines(state, active), ranges = []; - for (const site of sites) - ranges.push((selected.has(site.line) ? Decoration.mark({ class: 'import-hash-full' }) : - Decoration.replace({ widget: new ImportHash(site, open.has(site.from)) })).range(site.from, site.to)); - return { open, active, sites, decorations: Decoration.set(ranges, true) }; +function decorate(state) { + const selected = selectedImportLines(state), ranges = []; + for (const site of importSites(state)) + ranges.push((selected.has(site.line) ? Decoration.mark({ class: 'import-hash-full', attributes: { 'data-import-hash': site.hash, role: 'button', tabindex: '0', 'aria-label': 'Browse imported module ' + site.hash } }) : + Decoration.replace({ widget: new ImportHash(site) })).range(site.from, site.to)); + return Decoration.set(ranges, true); } export const importFolds = StateField.define({ - create: state => decorate(state, new Set(), false), - update: (old, tr) => { - let active = old.active; - const open = tr.docChanged ? new Set(old.sites.filter(site => old.open.has(site.from) && !tr.changes.touchesRange(site.from, site.to)) - .map(site => tr.changes.mapPos(site.from, 1))) : new Set(old.open); - if (tr.selection && tr.isUserEvent('select')) - active = true; - for (const effect of tr.effects) { - if (effect.is(toggle)) { - if (open.has(effect.value)) - open.delete(effect.value); - else - open.add(effect.value); - } - if (effect.is(importFocus)) - active = effect.value; - } - if (!tr.docChanged && !tr.selection && active === old.active && open.size === old.open.size && [...open].every(pos => old.open.has(pos))) - return old; - return decorate(tr.state, open, active); - }, - provide: field => EditorView.decorations.from(field, value => value.decorations) + create: decorate, + update: (old, tr) => tr.docChanged || tr.selection ? decorate(tr.state) : old, + provide: field => EditorView.decorations.from(field) }); -export function syncImportFocus(view) { - queueMicrotask(() => { - const old = view.state.field(importFolds).active, element = view.dom.ownerDocument.activeElement; - const active = element === view.contentDOM ? true : view.dom.contains(element) ? old : false; - if (old !== active) - view.dispatch({ effects: importFocus.of(active), annotations: Transaction.addToHistory.of(false) }); - }); -} diff --git a/ext/demo-site/dist/import-picker.js b/ext/demo-site/dist/import-picker.js @@ -1,7 +1,9 @@ -import { fetchBytes } from './bundle.js'; -import { libraryIndex, latestLibraries, matchesLibrary } from './library-index.js'; +import { loadLibraries, renderLibraryExports } from './library-browser.js'; +import { moduleInspector } from './module-inspector.js'; +import { latestLibraries, matchesLibrary } from './library-index.js'; import { packageReleases } from './packages.js'; export function importPicker(options) { + const inspector = moduleInspector(options.source, options.local); const get = (id) => document.getElementById(id); const dialog = get('import-dialog'), open = get('add-import'); const search = get('import-search'), results = get('import-results'), detail = get('import-detail'); @@ -112,18 +114,10 @@ export function importPicker(options) { } function renderExports() { const query = get('import-export-search').value.toLowerCase(); - const list = get('import-exports'); - list.replaceChildren(); - for (const name of selected?.names.filter(n => n.toLowerCase().includes(query)) ?? []) { - const button = document.createElement('button'); - button.type = 'button'; - button.textContent = name; - button.onclick = () => { if (selected) - show({ ...selected, alias: alias.value, definition: name }, false); }; - list.append(button); - } - if (!list.children.length) - list.textContent = 'No matching exports.'; + renderLibraryExports(get('import-exports'), selected?.names ?? [], query, name => { + if (selected) + show({ ...selected, alias: alias.value, definition: name }, false); + }); } function browse() { stop(); @@ -191,8 +185,8 @@ export function importPicker(options) { search.focus(); if (!libraries) { message('Loading library references…'); - void fetchBytes(new URL('../reference/libraries.json', import.meta.url), 2_097_152).then(bytes => { - libraries = libraryIndex(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))); + void loadLibraries().then(rows => { + libraries = rows; if (dialog.open && !selected) browse(); }).catch(error => { if (dialog.open) @@ -244,6 +238,6 @@ export function importPicker(options) { options.source.select(0, checked.statement.length); } insert.onclick = () => applyPlan(); - return { cancel: () => { if (dialog.open) + return { cancel: () => { inspector.hide(); if (dialog.open) finish(); } }; } diff --git a/ext/demo-site/dist/library-browser.js b/ext/demo-site/dist/library-browser.js @@ -0,0 +1,20 @@ +import { fetchBytes } from './bundle.js'; +import { libraryIndex } from './library-index.js'; +let pending; +export function loadLibraries() { + return pending ??= fetchBytes(new URL('../reference/libraries.json', import.meta.url), 2_097_152) + .then(bytes => libraryIndex(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)))) + .catch(error => { pending = undefined; throw error; }); +} +export function renderLibraryExports(list, names, query, choose) { + list.replaceChildren(); + for (const name of names.filter(name => name.toLowerCase().includes(query.toLowerCase()))) { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = name; + button.onclick = () => choose(name); + list.append(button); + } + if (!list.children.length) + list.textContent = 'No matching exports.'; +} diff --git a/ext/demo-site/dist/module-inspector.js b/ext/demo-site/dist/module-inspector.js @@ -0,0 +1,102 @@ +import { importSites } from './import-folds.js'; +import { loadLibraries, renderLibraryExports } from './library-browser.js'; +export function moduleInspector(input, local) { + const card = document.createElement('section'); + card.className = 'definition-card module-card'; + card.hidden = true; + card.setAttribute('aria-label', 'Imported module'); + const heading = document.createElement('div'); + heading.className = 'definition-card-controls'; + const title = document.createElement('strong'), close = document.createElement('button'); + close.type = 'button'; + close.textContent = '×'; + close.className = 'definition-card-close'; + close.setAttribute('aria-label', 'Close module inspection'); + const search = document.createElement('input'); + search.type = 'search'; + search.placeholder = 'Search definitions'; + search.setAttribute('aria-label', 'Search module definitions'); + const list = document.createElement('div'); + list.className = 'module-exports'; + const status = document.createElement('p'); + status.className = 'hint'; + status.setAttribute('role', 'status'); + heading.append(title, close); + card.append(heading, search, list, status); + input.dom.append(card); + let selected, names = [], revision = 0; + function hide() { revision++; selected = undefined; card.hidden = true; } + function position() { + if (!selected) + return; + const site = importSites(input.view.state).find(site => site.from === selected.from && site.hash === selected.hash); + if (!site || input.view.state.doc.lineAt(input.view.state.selection.main.head).number !== site.line) { + hide(); + return; + } + const rect = input.view.coordsAtPos(site.to), height = input.dom.clientHeight; + card.style.maxHeight = `${Math.max(80, Math.min(height - 16, innerHeight * .75, 300))}px`; + const top = rect ? rect.bottom - input.dom.getBoundingClientRect().top + 4 : 8; + card.style.top = `${Math.max(8, Math.min(top, height - card.offsetHeight - 8))}px`; + } + function render() { + renderLibraryExports(list, names, search.value, name => { + void navigator.clipboard.writeText(name).then(() => { status.textContent = 'Definition name copied.'; }, () => { status.textContent = name; }); + }); + position(); + } + async function show(target) { + const hash = target.dataset.importHash; + const site = importSites(input.view.state).find(site => site.hash === hash && site.line === input.view.state.doc.lineAt(input.view.state.selection.main.head).number); + if (!site) + return; + const current = ++revision; + selected = { hash: site.hash, from: site.from }; + names = []; + search.value = ''; + list.replaceChildren(); + title.textContent = 'Imported module'; + status.textContent = 'Loading module definitions…'; + card.hidden = false; + position(); + search.focus({ preventScroll: true }); + try { + const nearby = local().find(row => row.manifest === hash); + const published = nearby ? undefined : (await loadLibraries()).find(row => row.manifest === hash); + if (current !== revision) + return; + title.textContent = nearby?.label ?? published?.title ?? 'Imported module'; + names = nearby?.names ?? published?.names ?? []; + status.textContent = nearby || published ? `${names.length} definitions · Click a name to copy it.` : 'This module is not in the library index or local modules.'; + render(); + } + catch (error) { + if (current === revision) + status.textContent = `Could not load module definitions: ${String(error)}`; + } + } + input.dom.addEventListener('click', event => { + const target = event.target.closest('.import-hash-full'); + if (target && event.detail <= 1) + void show(target); + }); + input.dom.addEventListener('keydown', event => { + const target = event.target.closest('.import-hash-full'); + if (target && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault(); + event.stopPropagation(); + void show(target); + } + }, true); + function dismiss() { hide(); input.focus(); } + close.onclick = dismiss; + card.addEventListener('keydown', event => { if (event.key === 'Escape') { + event.preventDefault(); + dismiss(); + } }); + search.oninput = render; + input.onLayout(position); + input.onChange(hide); + new ResizeObserver(position).observe(card); + return { hide }; +} diff --git a/ext/demo-site/dist/source-editor.js b/ext/demo-site/dist/source-editor.js @@ -1,4 +1,4 @@ -import { importFolds, importFocus, importSites, syncImportFocus } from './import-folds.js'; +import { importFolds, importSites } from './import-folds.js'; import { EditorState, StateField, StateEffect, Transaction, EditorSelection, Compartment, EditorView, Decoration, keymap, drawSelection, history, historyKeymap, standardKeymap, isolateHistory, undo, redo, insertNewline } from './codemirror.js'; import { tokenize, bracketPair, definitionSites } from './syntax.js'; // Enter inserts a plain newline, preserving explicit source indentation. @@ -78,7 +78,6 @@ export class SourceEditor { definitionState, syntax, importFolds, history(), drawSelection(), EditorState.tabSize.of(2), keymap.of([{ key: 'Enter', run: plainNewline, shift: plainNewline }, ...historyKeymap, ...standardKeymap]), EditorView.lineWrapping, this.availability.of([EditorState.readOnly.of(false), EditorView.editable.of(true)]), - EditorView.domEventHandlers({ focus: (_event, view) => syncImportFocus(view), blur: (_event, view) => syncImportFocus(view) }), EditorView.contentAttributes.of({ 'aria-label': 'tricu source (up to 16,384 characters)', spellcheck: 'false', autocorrect: 'off', autocapitalize: 'off' }), EditorView.updateListener.of(update => { if (update.docChanged) @@ -133,7 +132,7 @@ export class SourceEditor { this.view.dispatch({ effects: EditorView.scrollIntoView(site.start, { y: 'center' }) }); return true; } - select(from, to = from) { this.view.dispatch({ selection: EditorSelection.single(from, to), effects: importFocus.of(true), scrollIntoView: true }); this.focus(); } + select(from, to = from) { this.view.dispatch({ selection: EditorSelection.single(from, to), scrollIntoView: true }); this.focus(); } // Loading/restoring documents retires old history; import insertion is a // separate, isolated undoable edit. Notification is explicit for controller edits. replaceSource(source, notify = false) { diff --git a/ext/demo-site/src/import-folds.ts b/ext/demo-site/src/import-folds.ts @@ -1,6 +1,6 @@ // Hash decorations project canonical UTF-16 document ranges. Editing a revealed // hash uses ordinary character-level selection and deletion. -import {EditorState, StateField, StateEffect, Transaction, EditorSelection, EditorView, Decoration, WidgetType, type Range} from './codemirror.js'; +import {EditorState, StateField, EditorSelection, EditorView, Decoration, WidgetType, type Range} from './codemirror.js'; import {tokenize} from './syntax.js'; export interface ImportSite {from:number;to:number;hash:string;line:number} const cachedSites=new WeakMap<EditorState['doc'],ImportSite[]>(); @@ -17,67 +17,33 @@ export function importSites(state:EditorState):ImportSite[] { }); cachedSites.set(state.doc,sites);return sites; } -export const importFocus=StateEffect.define<boolean>(); -const toggle=StateEffect.define<number>(); const short=(hash:string)=>hash.slice(0,3)+'...'+hash.slice(-3); class ImportHash extends WidgetType { - constructor(readonly site:ImportSite,readonly expanded:boolean){super();} - eq(other:ImportHash):boolean{return this.site.from===other.site.from&&this.site.hash===other.site.hash&&this.expanded===other.expanded;} + constructor(readonly site:ImportSite){super();} + eq(other:ImportHash):boolean{return this.site.from===other.site.from&&this.site.hash===other.site.hash;} toDOM(view:EditorView):HTMLElement { const button=document.createElement('button');button.type='button';button.className='import-hash'; - button.textContent=this.expanded?this.site.hash:short(this.site.hash);button.dataset.hashFrom=String(this.site.from); - button.setAttribute('aria-expanded',String(this.expanded)); - button.setAttribute('aria-label',`${this.expanded?'Collapse':'Expand'} import hash ${this.site.hash}`); + button.textContent=short(this.site.hash); + button.setAttribute('aria-label',`Place cursor at import ${this.site.hash}`); button.onclick=()=>{ - view.dispatch({effects:toggle.of(this.site.from),annotations:Transaction.addToHistory.of(false)}); - view.dom.querySelector<HTMLElement>(`[data-hash-from="${this.site.from}"]`)?.focus({preventScroll:true}); - }; - // Ctrl+A on a hash control selects the complete canonical program. - button.onkeydown=event=>{ - if((event.ctrlKey||event.metaKey)&&event.key.toLowerCase()==='a'){ - event.preventDefault();view.dispatch({selection:EditorSelection.single(0,view.state.doc.length),effects:importFocus.of(true)});view.focus(); - } + view.dispatch({selection:EditorSelection.single(this.site.to)}); + view.focus(); }; return button; } ignoreEvent():boolean{return true;} } -interface Folds {open:Set<number>;active:boolean;sites:ImportSite[];decorations:ReturnType<typeof Decoration.set>} -export function selectedImportLines(state:EditorState,active:boolean):Set<number> { - const lines=new Set<number>(); - for(const range of state.selection.ranges){ - if(!active&&range.empty)continue; - const from=state.doc.lineAt(range.from).number,to=state.doc.lineAt(range.empty?range.to:Math.max(range.from,range.to-1)).number; - for(let line=from;line<=to;line++)lines.add(line); - } - return lines; +export function selectedImportLines(state:EditorState):Set<number> { + return new Set(state.selection.ranges.map(range=>state.doc.lineAt(range.head).number)); } -function decorate(state:EditorState,open:Set<number>,active:boolean):Folds { - const sites=importSites(state),selected=selectedImportLines(state,active),ranges:Range<Decoration>[]=[]; - for(const site of sites)ranges.push((selected.has(site.line)?Decoration.mark({class:'import-hash-full'}): - Decoration.replace({widget:new ImportHash(site,open.has(site.from))})).range(site.from,site.to)); - return {open,active,sites,decorations:Decoration.set(ranges,true)}; +function decorate(state:EditorState):ReturnType<typeof Decoration.set> { + const selected=selectedImportLines(state),ranges:Range<Decoration>[]=[]; + for(const site of importSites(state))ranges.push((selected.has(site.line)?Decoration.mark({class:'import-hash-full',attributes:{'data-import-hash':site.hash,role:'button',tabindex:'0','aria-label':'Browse imported module '+site.hash}}): + Decoration.replace({widget:new ImportHash(site)})).range(site.from,site.to)); + return Decoration.set(ranges,true); } -export const importFolds=StateField.define<Folds>({ - create:state=>decorate(state,new Set(),false), - update:(old,tr)=>{ - let active=old.active; - const open=tr.docChanged?new Set(old.sites.filter(site=>old.open.has(site.from)&&!tr.changes.touchesRange(site.from,site.to)) - .map(site=>tr.changes.mapPos(site.from,1))):new Set(old.open); - if(tr.selection&&tr.isUserEvent('select'))active=true; - for(const effect of tr.effects){ - if(effect.is(toggle)){if(open.has(effect.value))open.delete(effect.value);else open.add(effect.value);} - if(effect.is(importFocus))active=effect.value; - } - if(!tr.docChanged&&!tr.selection&&active===old.active&&open.size===old.open.size&&[...open].every(pos=>old.open.has(pos)))return old; - return decorate(tr.state,open,active); - }, - provide:field=>EditorView.decorations.from(field,value=>value.decorations) +export const importFolds=StateField.define<ReturnType<typeof Decoration.set>>({ + create:decorate, + update:(old,tr)=>tr.docChanged||tr.selection?decorate(tr.state):old, + provide:field=>EditorView.decorations.from(field) }); -export function syncImportFocus(view:EditorView):void { - queueMicrotask(()=>{ - const old=view.state.field(importFolds).active,element=view.dom.ownerDocument.activeElement; - const active=element===view.contentDOM?true:view.dom.contains(element)?old:false; - if(old!==active)view.dispatch({effects:importFocus.of(active),annotations:Transaction.addToHistory.of(false)}); - }); -} diff --git a/ext/demo-site/src/import-picker.ts b/ext/demo-site/src/import-picker.ts @@ -1,6 +1,7 @@ import type { SourceEditor } from './source-editor.js'; -import { fetchBytes } from './bundle.js'; -import { libraryIndex, latestLibraries, matchesLibrary, type Library } from './library-index.js'; +import { loadLibraries, renderLibraryExports } from './library-browser.js'; +import { moduleInspector } from './module-inspector.js'; +import { latestLibraries, matchesLibrary, type Library } from './library-index.js'; import { packageReleases } from './packages.js'; import type { ImportPlan } from './import-plan.js'; import type { LocalModuleInput } from './local-modules.js'; @@ -15,6 +16,7 @@ interface Options { inputs(): LocalModuleInput[]; } export function importPicker(options: Options): { cancel(): void } { + const inspector = moduleInspector(options.source, options.local); const get = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T; const dialog = get<HTMLDialogElement>('import-dialog'), open = get<HTMLButtonElement>('add-import'); const search = get<HTMLInputElement>('import-search'), results = get('import-results'), detail = get('import-detail'); @@ -78,14 +80,11 @@ export function importPicker(options: Options): { cancel(): void } { } function renderExports(): void { const query = get<HTMLInputElement>('import-export-search').value.toLowerCase(); - const list = get('import-exports'); list.replaceChildren(); - for (const name of selected?.names.filter(n => n.toLowerCase().includes(query)) ?? []) { - const button = document.createElement('button'); button.type = 'button'; button.textContent = name; - button.onclick = () => { if (selected) show({ ...selected, alias: alias.value, definition: name }, false); }; - list.append(button); - } - if (!list.children.length) list.textContent = 'No matching exports.'; + renderLibraryExports(get('import-exports'), selected?.names ?? [], query, name => { + if (selected) show({ ...selected, alias: alias.value, definition: name }, false); + }); } + function browse(): void { stop(); selected = undefined; plan = undefined; detail.hidden = true; get('import-actions').hidden = true; get('import-content').scrollTop = 0; get('import-browse').hidden = false; results.hidden = false; message(''); results.replaceChildren(); const choices: Choice[] = [ @@ -120,8 +119,8 @@ export function importPicker(options: Options): { cancel(): void } { snapshot = options.source.getSource(); backend = options.backend(); search.value = ''; dialog.showModal(); browse(); search.focus(); if (!libraries) { message('Loading library references…'); - void fetchBytes(new URL('../reference/libraries.json', import.meta.url), 2_097_152).then(bytes => { - libraries = libraryIndex(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) as unknown); + void loadLibraries().then(rows => { + libraries = rows; if (dialog.open && !selected) browse(); }).catch(error => { if (dialog.open) message(`Library references could not be loaded: ${String(error)}. Close and reopen to retry. Local modules remain available.`); }); } @@ -153,5 +152,5 @@ export function importPicker(options: Options): { cancel(): void } { } insert.onclick = () => applyPlan(); - return { cancel: () => { if (dialog.open) finish(); } }; + return { cancel: () => { inspector.hide(); if (dialog.open) finish(); } }; } diff --git a/ext/demo-site/src/library-browser.ts b/ext/demo-site/src/library-browser.ts @@ -0,0 +1,16 @@ +import { fetchBytes } from './bundle.js'; +import { libraryIndex, type Library } from './library-index.js'; +let pending: Promise<Library[]> | undefined; +export function loadLibraries(): Promise<Library[]> { + return pending ??= fetchBytes(new URL('../reference/libraries.json', import.meta.url), 2_097_152) + .then(bytes => libraryIndex(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) as unknown)) + .catch(error => { pending = undefined; throw error; }); +} +export function renderLibraryExports(list: HTMLElement, names: string[], query: string, choose: (name: string) => void): void { + list.replaceChildren(); + for (const name of names.filter(name => name.toLowerCase().includes(query.toLowerCase()))) { + const button = document.createElement('button'); button.type = 'button'; button.textContent = name; + button.onclick = () => choose(name); list.append(button); + } + if (!list.children.length) list.textContent = 'No matching exports.'; +} diff --git a/ext/demo-site/src/module-inspector.ts b/ext/demo-site/src/module-inspector.ts @@ -0,0 +1,63 @@ +import type { SourceEditor } from './source-editor.js'; +import { importSites } from './import-folds.js'; +import { loadLibraries, renderLibraryExports } from './library-browser.js'; + +export function moduleInspector(input: SourceEditor, local: () => { label: string; manifest: string; names: string[] }[]) { + const card = document.createElement('section'); card.className = 'definition-card module-card'; card.hidden = true; + card.setAttribute('aria-label', 'Imported module'); + const heading = document.createElement('div'); heading.className = 'definition-card-controls'; + const title = document.createElement('strong'), close = document.createElement('button'); + close.type = 'button'; close.textContent = '×'; close.className = 'definition-card-close'; close.setAttribute('aria-label', 'Close module inspection'); + const search = document.createElement('input'); search.type = 'search'; search.placeholder = 'Search definitions'; search.setAttribute('aria-label', 'Search module definitions'); + const list = document.createElement('div'); list.className = 'module-exports'; + const status = document.createElement('p'); status.className = 'hint'; status.setAttribute('role', 'status'); + heading.append(title, close); card.append(heading, search, list, status); input.dom.append(card); + let selected: {hash: string; from: number} | undefined, names: string[] = [], revision = 0; + function hide(): void { revision++; selected = undefined; card.hidden = true; } + function position(): void { + if (!selected) return; + const site = importSites(input.view.state).find(site => site.from === selected!.from && site.hash === selected!.hash); + if (!site || input.view.state.doc.lineAt(input.view.state.selection.main.head).number !== site.line) { hide(); return; } + const rect = input.view.coordsAtPos(site.to), height = input.dom.clientHeight; + card.style.maxHeight = `${Math.max(80, Math.min(height - 16, innerHeight * .75, 300))}px`; + const top = rect ? rect.bottom - input.dom.getBoundingClientRect().top + 4 : 8; + card.style.top = `${Math.max(8, Math.min(top, height - card.offsetHeight - 8))}px`; + } + function render(): void { + renderLibraryExports(list, names, search.value, name => { + void navigator.clipboard.writeText(name).then(() => { status.textContent = 'Definition name copied.'; }, () => { status.textContent = name; }); + }); + position(); + } + async function show(target: HTMLElement): Promise<void> { + const hash = target.dataset.importHash; + const site = importSites(input.view.state).find(site => site.hash === hash && site.line === input.view.state.doc.lineAt(input.view.state.selection.main.head).number); + if (!site) return; + const current = ++revision; + selected = { hash: site.hash, from: site.from }; names = []; search.value = ''; list.replaceChildren(); + title.textContent = 'Imported module'; status.textContent = 'Loading module definitions…'; card.hidden = false; position(); search.focus({preventScroll:true}); + try { + const nearby = local().find(row => row.manifest === hash); + const published = nearby ? undefined : (await loadLibraries()).find(row => row.manifest === hash); + if (current !== revision) return; + title.textContent = nearby?.label ?? published?.title ?? 'Imported module'; + names = nearby?.names ?? published?.names ?? []; + status.textContent = nearby || published ? `${names.length} definitions · Click a name to copy it.` : 'This module is not in the library index or local modules.'; + render(); + } catch (error) { if (current === revision) status.textContent = `Could not load module definitions: ${String(error)}`; } + } + input.dom.addEventListener('click', event => { + const target = (event.target as Element).closest<HTMLElement>('.import-hash-full'); + if (target && event.detail <= 1) void show(target); + }); + input.dom.addEventListener('keydown', event => { + const target = (event.target as Element).closest<HTMLElement>('.import-hash-full'); + if (target && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); event.stopPropagation(); void show(target); } + }, true); + function dismiss(): void { hide(); input.focus(); } + close.onclick = dismiss; + card.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); dismiss(); } }); + search.oninput = render; input.onLayout(position); input.onChange(hide); + new ResizeObserver(position).observe(card); + return { hide }; +} diff --git a/ext/demo-site/src/source-editor.ts b/ext/demo-site/src/source-editor.ts @@ -1,4 +1,4 @@ -import { importFolds, importFocus, importSites, syncImportFocus } from './import-folds.js'; +import { importFolds, importSites } from './import-folds.js'; import {EditorState, StateField, StateEffect, Transaction, EditorSelection, Compartment, type Range, type Extension, EditorView, Decoration, keymap, drawSelection, history, historyKeymap, standardKeymap, isolateHistory, undo, redo, insertNewline} from './codemirror.js'; import {tokenize, bracketPair, definitionSites} from './syntax.js'; @@ -69,7 +69,6 @@ export class SourceEditor { definitionState,syntax,importFolds,history(),drawSelection(),EditorState.tabSize.of(2), keymap.of([{key:'Enter',run:plainNewline,shift:plainNewline},...historyKeymap,...standardKeymap]),EditorView.lineWrapping, this.availability.of([EditorState.readOnly.of(false),EditorView.editable.of(true)]), - EditorView.domEventHandlers({focus:(_event,view)=>syncImportFocus(view),blur:(_event,view)=>syncImportFocus(view)}), EditorView.contentAttributes.of({'aria-label':'tricu source (up to 16,384 characters)',spellcheck:'false',autocorrect:'off',autocapitalize:'off'}), EditorView.updateListener.of(update=>{ if(update.docChanged)this.notify(); @@ -110,7 +109,7 @@ export class SourceEditor { const site=this.declarations().find(site=>site.name===name);if(!site)return false; this.view.dispatch({effects:EditorView.scrollIntoView(site.start,{y:'center'})});return true; } - select(from:number,to=from):void{this.view.dispatch({selection:EditorSelection.single(from,to),effects:importFocus.of(true),scrollIntoView:true});this.focus();} + select(from:number,to=from):void{this.view.dispatch({selection:EditorSelection.single(from,to),scrollIntoView:true});this.focus();} // Loading/restoring documents retires old history; import insertion is a // separate, isolated undoable edit. Notification is explicit for controller edits. replaceSource(source:string,notify=false):void { diff --git a/ext/demo-site/style.css b/ext/demo-site/style.css @@ -88,3 +88,6 @@ header nav{align-items:center;gap:12px}#theme-separator{color:var(--muted)} .source-editor .import-hash{display:inline;min-width:0;min-height:0;max-width:100%;margin:0;padding:0;border:0;border-radius:0;font:inherit;line-height:inherit;vertical-align:baseline;color:#8accc8;background:transparent;white-space:pre-wrap;overflow-wrap:anywhere;text-decoration:underline dotted;text-underline-offset:3px;cursor:pointer} .source-editor .import-hash:hover{background:#8bd3ad24}.source-editor .import-hash:focus-visible{outline:2px solid #bfe2ba;outline-offset:2px}.source-editor .import-hash-full{color:#8accc8} @media(forced-colors:active){.source-editor .import-hash{color:LinkText}.source-editor .import-hash-full{color:CanvasText}} +.source-editor .import-hash-full{cursor:pointer;text-decoration:underline dotted;text-underline-offset:3px} +.module-card input{width:100%;box-sizing:border-box;margin:.5rem 0} +.module-exports{display:flex;flex-wrap:wrap;gap:.4rem} diff --git a/ext/demo-site/test/import-fold-inputs.mjs b/ext/demo-site/test/import-fold-inputs.mjs @@ -15,12 +15,12 @@ export async function importFoldInputs({send,evaluate,report,prefix}){ await send('Emulation.setDeviceMetricsOverride',{width,height:1000,deviceScaleFactor:1,mobile:width<741}); await evaluate(`${e}.replaceSource(${JSON.stringify(initial)},true)`);await wait("document.getElementById('status').textContent==='Compiled'"); assert.deepEqual(await evaluate(counts),{folded:3,full:0}); - await click(widget);await key('Enter','Enter',13);assert((await evaluate(`${widget}.textContent`)).includes('...'),'Keyboard toggles are presentation only'); + await click(widget);assert.deepEqual(await evaluate(counts),{folded:2,full:1},'Folded click moves cursor to import'); await click(alias);assert.deepEqual(await evaluate(counts),{folded:2,full:1}); assert.equal(await evaluate(`${e}.getSelection().from`),initial.indexOf(' as N')+4,'Caret maps past hidden 64-character hash'); await send('Input.insertText',{text:'M'});await key('z','KeyZ',90,2);assert.equal(await evaluate(`${e}.getSource()`),initial,'Typing undo'); await evaluate(`${e}.select(${initial.indexOf('!import')},${initial.indexOf('!import '+report.math+' as P')})`); - assert.deepEqual(await evaluate(counts),{folded:1,full:2}); + assert.deepEqual(await evaluate(counts),{folded:2,full:1}); await key('c','KeyC',67,2);const selected=await evaluate('navigator.clipboard.readText()');assert(selected.includes(report.math)&&!selected.includes('...'),'Partial selection copies complete hashes'); await key('a','KeyA',65,2);await key('c','KeyC',67,2);assert.equal(await evaluate('navigator.clipboard.readText()'),initial,'Native full-source clipboard'); await evaluate(`${e}.select(0)`);const inserted=`!import ${report.math} as Q\n`; diff --git a/ext/demo-site/test/import-folds-probe.mjs b/ext/demo-site/test/import-folds-probe.mjs @@ -13,10 +13,10 @@ export async function run(_control,backend='haskell-wasm'){ assert(widgets().length===3&&widgets()[0].textContent===math.slice(0,3)+'...'+math.slice(-3),'Exact per-hash abbreviation'); assert(editor.getSource()===source,'Compilation sees canonical source'); let edits=0;const unsubscribe=editor.onChange(()=>edits++); - widgets()[0].click();assert(widgets()[0].textContent===math&&widgets()[1].textContent.includes('...'),'Independent expansion'); - widgets()[0].click();assert(widgets()[0].textContent.includes('...'),'Independent collapse'); - editor.select(source.indexOf(' as N')+4);assert(full()===1&&widgets().length===2,'Only edited line reveals'); - editor.select(source.indexOf('!import'),source.indexOf('!import '+math+' as P'));assert(full()===2&&widgets().length===1,'Only selected lines reveal'); + widgets()[0].click();assert(full()===1&&widgets().length===2,'Folded click places cursor on import'); + editor.view.contentDOM.blur();await pause(0);assert(full()===1,'Focus changes preserve cursor-line expansion'); + editor.select(source.indexOf(' as N')+4);assert(full()===1&&widgets().length===2,'Only cursor line reveals'); + editor.select(source.indexOf('!import'),source.indexOf('!import '+math+' as P'));assert(full()===1&&widgets().length===2,'Only selection head line reveals'); editor.select(source.length);await pause(0);assert(widgets().length===3&&edits===0,'Presentation/selection preserves source and revision'); editor.edit(0,0,`!import ${math} as Q\n`);editor.select(editor.getSource().length); assert(widgets().length===4,'New import receives decoration');assert(editor.undo(),'Insertion undo');assert(editor.getSource()===source,'One undo restores entire canonical document'); @@ -24,5 +24,5 @@ export async function run(_control,backend='haskell-wasm'){ await status('Compiled');assert(await inspect('answer','number')==='42','Actual compiler/runtime accepts full-hash imports'); document.querySelector('[aria-label="Close definition inspection"]').click(); editor.select(source.length);editor.view.contentDOM.blur();await pause(0);unsubscribe(); - return {checks:['per-line import hash folding; independent controls; selected ranges; immutable canonical source/revisions; dynamic import undo/redo; actual compiler/runtime'],source,math}; + return {checks:['per-line import hash folding; cursor placement; selection head; focus independence; immutable canonical source/revisions; dynamic import undo/redo; actual compiler/runtime'],source,math}; } diff --git a/ext/demo-site/test/import-folds.mjs b/ext/demo-site/test/import-folds.mjs @@ -0,0 +1,79 @@ +// Cursor-driven import folding and inline module browsing; no runtime assets required. +// nix shell nixpkgs#nodejs nixpkgs#chromium -c node ext/demo-site/test/import-folds.mjs +import assert from 'node:assert/strict'; +import {createServer} from 'node:http'; +import {spawn} from 'node:child_process'; +import {mkdtemp,readFile,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +const dir=await mkdtemp(join(tmpdir(),'arboricx-import-folds-')); +let browser,socket,server; +try{ + const root=new URL('../',import.meta.url); + browser=spawn(process.argv[2]||'chromium',['--headless','--no-sandbox','--disable-dev-shm-usage','--remote-debugging-port=0',`--user-data-dir=${join(dir,'profile')}`,'about:blank'],{stdio:['ignore','ignore','pipe']}); + const endpoint=await new Promise((resolve,reject)=>{ + let log='';const timer=setTimeout(()=>reject(Error(log)),15000); + browser.on('error',reject); + browser.stderr.on('data',chunk=>{log+=chunk;const m=log.match(/DevTools listening on (ws:\/\/\S+)/);if(m){clearTimeout(timer);resolve(new URL(m[1]));}}); + }); + const tab=await(await fetch(`http://${endpoint.host}/json/new?about:blank`,{method:'PUT'})).json(); + socket=new WebSocket(tab.webSocketDebuggerUrl); + await new Promise((resolve,reject)=>{socket.onopen=resolve;socket.onerror=reject;}); + let id=0;const pending=new Map(); + socket.onmessage=({data})=>{const event=JSON.parse(data);if(event.id){const p=pending.get(event.id);pending.delete(event.id);event.error?p.reject(Error(JSON.stringify(event.error))):p.resolve(event.result);}}; + const send=(method,params={})=>new Promise((resolve,reject)=>{pending.set(++id,{resolve,reject});socket.send(JSON.stringify({id,method,params}));}); + const evaluate=async expression=>{const r=await send('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});if(r.exceptionDetails)throw Error(JSON.stringify(r.exceptionDetails));return r.result.value;}; + + server=createServer(async(req,res)=>{ + try { + if(req.url==='/'){res.setHeader('Content-Type','text/html');res.end('<link rel="stylesheet" href="/style.css"><div id="editor" style="height:500px;position:relative"></div>');return;} + res.setHeader('Content-Type',req.url.endsWith('.js')?'text/javascript':'text/css'); + res.end(await readFile(new URL('.'+req.url,root))); + }catch{res.statusCode=404;res.end();} + }); + await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)); + await send('Page.navigate',{url:`http://127.0.0.1:${server.address().port}/`}); + for(let i=0;!await evaluate("!!document.getElementById('editor')");i++){assert(i<200);await new Promise(r=>setTimeout(r,25));} + const result=await evaluate(`(async()=>{ + const {SourceEditor}=await import('/dist/source-editor.js'); + const {moduleInspector}=await import('/dist/module-inspector.js'); + const editor=new SourceEditor(document.getElementById('editor'));globalThis.editor=editor; + const hash='a'.repeat(64),other='b'.repeat(64); + const source='!import '+hash+' as A\\n!import '+other+' as B\\nanswer = 42'; + editor.replaceSource(source); + moduleInspector(editor,()=>[{manifest:hash,label:'Local test',names:['one','two']}]); + const check=(ok,message)=>{if(!ok)throw Error(message);}; + const full=()=>editor.dom.querySelectorAll('.import-hash-full'); + const folded=()=>editor.dom.querySelectorAll('.import-hash'); + check(full().length===1&&folded().length===1,'Initial cursor line expands'); + editor.select(source.length);check(full().length===0,'Leaving folds all imports'); + folded()[0].click();check(full().length===1,'Folded click selects line'); + check(editor.view.state.selection.main.head===8+64,'Folded click selects end'); + check(editor.dom.querySelector('.module-card').hidden,'Folded click does not open card'); + full()[0].click();await Promise.resolve(); + check(!editor.dom.querySelector('.module-card').hidden,'Expanded click opens card'); + check(editor.dom.querySelectorAll('.module-exports button').length===2,'Exact module exports'); + const search=editor.dom.querySelector('.module-card input');search.value='two';search.dispatchEvent(new Event('input')); + check(editor.dom.querySelectorAll('.module-exports button').length===1,'Shared export filtering'); + check(full().length===1,'Card focus preserves expansion'); + editor.select(0,source.indexOf('!import '+other));await Promise.resolve(); + check(full().length===1&&full()[0].textContent===other,'Selection head alone expands'); + check(editor.dom.querySelector('.module-card').hidden,'Moving cursor dismisses card'); + check(editor.getSource()===source&&!editor.undo(),'Presentation preserves document/history'); + return 'cursor-line folding and module card passed'; + })()`); + // Exercise native hit testing as well as programmatic/keyboard cursor changes. + await evaluate('editor.select(editor.getSource().length)'); + const click=async selector=>{ + const point=await evaluate(`(()=>{const r=document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2};})()`); + await send('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',clickCount:1}); + await send('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',clickCount:1}); + }; + await click('.import-hash'); + assert.equal(await evaluate("document.querySelectorAll('.import-hash-full').length"),1); + assert.equal(await evaluate("document.querySelector('.module-card').hidden"),true); + await click('.import-hash-full'); + assert.equal(await evaluate("document.querySelector('.module-card').hidden"),false); + assert.equal(await evaluate("document.querySelectorAll('.module-exports button').length"),2); + console.log(result+'; trusted mouse clicks passed'); +}finally{socket?.close();browser?.kill();server?.close();await rm(dir,{recursive:true,force:true,maxRetries:10,retryDelay:100});} diff --git a/ext/demo-site/test/import-folds.test.mjs b/ext/demo-site/test/import-folds.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test';import assert from 'node:assert/strict'; import {EditorState,EditorSelection,EditorView} from '../dist/codemirror.js'; -import {importSites,selectedImportLines,importFolds,importFocus} from '../dist/import-folds.js'; +import {importSites,selectedImportLines,importFolds} from '../dist/import-folds.js'; const hash='0123456789abcdef'.repeat(4),other='abcdef0123456789'.repeat(4); test('import projection recognizes only real, complete, line-head import hashes',()=>{ const source=`-- !import ${hash}\ntext = "!import ${hash}"\n|- !import ${hash} -|\nx = !import ${hash}\n!import ${hash.slice(1)} as short\n!import ${hash.toUpperCase()} as upper\n!import ${hash}x as extra\n!import ${hash} as A\n !import\t${other} as B`; @@ -8,21 +8,21 @@ test('import projection recognizes only real, complete, line-head import hashes' assert.equal(sites.length,2);assert.deepEqual(sites.map(site=>site.hash),[hash,other]); for(const site of sites)assert.equal(source.slice(site.from,site.to),site.hash); }); -test('selection exposes only affected import lines, including exclusive end boundaries',()=>{ +test('selection exposes only cursor head lines',()=>{ const doc=`-- λ🙂\n!import ${hash} as A\n!import ${other} as B\nanswer = 42`; let state=EditorState.create({doc}); - assert.deepEqual([...selectedImportLines(state,false)],[]); + assert.deepEqual([...selectedImportLines(state)],[1]); state=state.update({selection:EditorSelection.single(doc.indexOf(' as A')+4)}).state; - assert.deepEqual([...selectedImportLines(state,true)],[2]); + assert.deepEqual([...selectedImportLines(state)],[2]); state=state.update({selection:EditorSelection.single(state.doc.line(2).from,state.doc.line(4).from)}).state; - assert.deepEqual([...selectedImportLines(state,false)],[2,3]); + assert.deepEqual([...selectedImportLines(state)],[4]); }); test('projection transactions preserve canonical source and character-level ranges',()=>{ const doc=`!import ${hash} as A\n!import ${other} as B\nanswer = 42`; let state=EditorState.create({doc,extensions:[importFolds]}); - assert.equal(state.field(importFolds).decorations.size,2); - state=state.update({selection:EditorSelection.single(state.doc.line(3).from),effects:importFocus.of(true)}).state; + assert.equal(state.field(importFolds).size,2); + state=state.update({selection:EditorSelection.single(state.doc.line(3).from)}).state; assert.equal(state.doc.toString(),doc);assert.deepEqual(state.facet(EditorView.atomicRanges),[]); state=state.update({changes:{from:0,insert:`!import ${hash} as C\n`}}).state; - assert.equal(state.field(importFolds).sites.length,3);assert.equal(state.doc.toString(),`!import ${hash} as C\n`+doc); + assert.equal(importSites(state).length,3);assert.equal(state.doc.toString(),`!import ${hash} as C\n`+doc); });