diff --git a/packages/codemirror/codemirror.mjs b/packages/codemirror/codemirror.mjs index e29ae6d1..2094416e 100644 --- a/packages/codemirror/codemirror.mjs +++ b/packages/codemirror/codemirror.mjs @@ -1,11 +1,12 @@ -import { EditorState } from '@codemirror/state'; -import { EditorView, keymap, Decoration, lineNumbers, highlightActiveLineGutter } from '@codemirror/view'; import { defaultKeymap } from '@codemirror/commands'; -import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'; import { javascript } from '@codemirror/lang-javascript'; -import { StateField, StateEffect } from '@codemirror/state'; +import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'; +import { EditorState } from '@codemirror/state'; +import { EditorView, highlightActiveLineGutter, keymap, lineNumbers } from '@codemirror/view'; +import { Drawer, repl } from '@strudel.cycles/core'; +import { flashField, flash } from './flash.mjs'; +import { highlightExtension, highlightMiniLocations } from './highlight.mjs'; import { oneDark } from './themes/one-dark'; -import { repl, Drawer } from '@strudel.cycles/core'; // https://codemirror.net/docs/guide/ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, theme = oneDark, root }) { @@ -15,7 +16,7 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, the theme, javascript(), lineNumbers(), - highlightField, + highlightExtension, highlightActiveLineGutter(), syntaxHighlighting(defaultHighlightStyle), keymap.of(defaultKeymap), @@ -40,93 +41,6 @@ export function initEditor({ initialCode = '', onChange, onEvaluate, onStop, the }); } -// -// highlighting -// - -export const setHighlights = StateEffect.define(); -export const highlightField = StateField.define({ - create() { - return Decoration.none; - }, - update(highlights, tr) { - try { - for (let e of tr.effects) { - if (e.is(setHighlights)) { - const { haps } = e.value; - const marks = - haps - .map((hap) => - (hap.context.locations || []).map(({ start, end }) => { - // const color = hap.context.color || e.value.color || '#FFCA28'; - let from = tr.newDoc.line(start.line).from + start.column; - let to = tr.newDoc.line(end.line).from + end.column; - const l = tr.newDoc.length; - if (from > l || to > l) { - return; // dont mark outside of range, as it will throw an error - } - const mark = Decoration.mark({ - attributes: { style: `outline: 2px solid #FFCA28;` }, - }); - return mark.range(from, to); - }), - ) - .flat() - .filter(Boolean) || []; - highlights = Decoration.set(marks, true); - } - } - return highlights; - } catch (err) { - // console.warn('highlighting error', err); - return Decoration.set([]); - } - }, - provide: (f) => EditorView.decorations.from(f), -}); - -// helper to simply trigger highlighting for given haps -export function highlightHaps(view, haps) { - view.dispatch({ effects: setHighlights.of({ haps }) }); -} - -// -// flash -// - -export const setFlash = StateEffect.define(); -const flashField = StateField.define({ - create() { - return Decoration.none; - }, - update(flash, tr) { - try { - for (let e of tr.effects) { - if (e.is(setFlash)) { - if (e.value && tr.newDoc.length > 0) { - const mark = Decoration.mark({ attributes: { style: `background-color: #FFCA2880` } }); - flash = Decoration.set([mark.range(0, tr.newDoc.length)]); - } else { - flash = Decoration.set([]); - } - } - } - return flash; - } catch (err) { - console.warn('flash error', err); - return flash; - } - }, - provide: (f) => EditorView.decorations.from(f), -}); - -export const flash = (view, ms = 200) => { - view.dispatch({ effects: setFlash.of(true) }); - setTimeout(() => { - view.dispatch({ effects: setFlash.of(false) }); - }, ms); -}; - export class StrudelMirror { constructor(options) { const { root, initialCode = '', onDraw, drawTime = [-2, 2], prebake, ...replOptions } = options; @@ -134,7 +48,7 @@ export class StrudelMirror { this.drawer = new Drawer((haps, time) => { const currentFrame = haps.filter((hap) => time >= hap.whole.begin && time <= hap.endClipped); - this.highlight(currentFrame); + this.highlight(currentFrame, time); onDraw?.(haps, time, currentFrame); }, drawTime); @@ -193,7 +107,7 @@ export class StrudelMirror { flash(ms) { flash(this.editor, ms); } - highlight(haps) { - highlightHaps(this.editor, haps); + highlight(haps, time) { + highlightMiniLocations(this.editor.view, time, haps); } } diff --git a/packages/codemirror/flash.mjs b/packages/codemirror/flash.mjs new file mode 100644 index 00000000..9bc5c593 --- /dev/null +++ b/packages/codemirror/flash.mjs @@ -0,0 +1,35 @@ +import { StateEffect, StateField } from '@codemirror/state'; +import { Decoration, EditorView } from '@codemirror/view'; + +export const setFlash = StateEffect.define(); +export const flashField = StateField.define({ + create() { + return Decoration.none; + }, + update(flash, tr) { + try { + for (let e of tr.effects) { + if (e.is(setFlash)) { + if (e.value && tr.newDoc.length > 0) { + const mark = Decoration.mark({ attributes: { style: `background-color: #FFCA2880` } }); + flash = Decoration.set([mark.range(0, tr.newDoc.length)]); + } else { + flash = Decoration.set([]); + } + } + } + return flash; + } catch (err) { + console.warn('flash error', err); + return flash; + } + }, + provide: (f) => EditorView.decorations.from(f), +}); + +export const flash = (view, ms = 200) => { + view.dispatch({ effects: setFlash.of(true) }); + setTimeout(() => { + view.dispatch({ effects: setFlash.of(false) }); + }, ms); +}; diff --git a/packages/codemirror/highlight.mjs b/packages/codemirror/highlight.mjs new file mode 100644 index 00000000..317c5fdf --- /dev/null +++ b/packages/codemirror/highlight.mjs @@ -0,0 +1,126 @@ +import { RangeSetBuilder, StateEffect, StateField } from '@codemirror/state'; +import { Decoration, EditorView } from '@codemirror/view'; + +export const setMiniLocations = StateEffect.define(); +export const showMiniLocations = StateEffect.define(); +export const updateMiniLocations = (view, locations) => { + view.dispatch({ effects: setMiniLocations.of(locations) }); +}; +export const highlightMiniLocations = (view, atTime, haps) => { + view.dispatch({ effects: showMiniLocations.of({ atTime, haps }) }); +}; + +const miniLocations = StateField.define({ + create() { + return Decoration.none; + }, + update(locations, tr) { + if (tr.docChanged) { + locations = locations.map(tr.changes); + } + + for (let e of tr.effects) { + if (e.is(setMiniLocations)) { + // this is called on eval, with the mini locations obtained from the transpiler + // codemirror will automatically remap the marks when the document is edited + // create a mark for each mini location, adding the range to the spec to find it later + const marks = e.value + .filter(([from]) => from < tr.newDoc.length) + .map(([from, to]) => [from, Math.min(to, tr.newDoc.length)]) + .map( + (range) => + Decoration.mark({ + id: range.join(':'), + // this green is only to verify that the decoration moves when the document is edited + // it will be removed later, so the mark is not visible by default + attributes: { style: `background-color: #00CA2880` }, + }).range(...range), // -> Decoration + ); + + locations = Decoration.set(marks, true); // -> DecorationSet === RangeSet + } + } + + return locations; + }, +}); + +const visibleMiniLocations = StateField.define({ + create() { + return { atTime: 0, haps: new Map() }; + }, + update(visible, tr) { + for (let e of tr.effects) { + if (e.is(showMiniLocations)) { + // this is called every frame to show the locations that are currently active + // we can NOT create new marks because the context.locations haven't changed since eval time + // this is why we need to find a way to update the existing decorations, showing the ones that have an active range + const haps = new Map(); + for (let hap of e.value.haps) { + for (let { start, end } of hap.context.locations) { + let id = `${start}:${end}`; + if (!haps.has(id) || haps.get(id).whole.begin.lt(hap.whole.begin)) { + haps.set(id, hap); + } + } + } + + visible = { atTime: e.value.atTime, haps }; + } + } + + return visible; + }, +}); + +// // Derive the set of decorations from the miniLocations and visibleLocations +const miniLocationHighlights = EditorView.decorations.compute([miniLocations, visibleMiniLocations], (state) => { + const iterator = state.field(miniLocations).iter(); + const { haps } = state.field(visibleMiniLocations); + const builder = new RangeSetBuilder(); + + while (iterator.value) { + const { + from, + to, + value: { + spec: { id }, + }, + } = iterator; + + if (haps.has(id)) { + const hap = haps.get(id); + const color = hap.context.color ?? 'var(--foreground)'; + // Get explicit channels for color values + /* + const swatch = document.createElement('div'); + swatch.style.color = color; + document.body.appendChild(swatch); + let channels = getComputedStyle(swatch) + .color.match(/^rgba?\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})(?:,\s*(\d*(?:\.\d+)?))?\)$/) + .slice(1) + .map((c) => parseFloat(c || 1)); + document.body.removeChild(swatch); + + // Get percentage of event + const percent = 1 - (atTime - hap.whole.begin) / hap.whole.duration; + channels[3] *= percent; + */ + + builder.add( + from, + to, + Decoration.mark({ + // attributes: { style: `outline: solid 2px rgba(${channels.join(', ')})` }, + attributes: { style: `outline: solid 2px ${color}` }, + }), + ); + } + + iterator.next(); + } + + return builder.finish(); +}); + +export const highlightExtension = [miniLocations, visibleMiniLocations, miniLocationHighlights]; diff --git a/packages/codemirror/index.mjs b/packages/codemirror/index.mjs new file mode 100644 index 00000000..bf7ce971 --- /dev/null +++ b/packages/codemirror/index.mjs @@ -0,0 +1,3 @@ +export * from './codemirror.mjs'; +export * from './highlight.mjs'; +export * from './flash.mjs'; diff --git a/packages/codemirror/package.json b/packages/codemirror/package.json index ecbd408f..0e32fef6 100644 --- a/packages/codemirror/package.json +++ b/packages/codemirror/package.json @@ -2,7 +2,7 @@ "name": "@strudel/codemirror", "version": "0.8.4", "description": "Codemirror Extensions for Strudel", - "main": "codemirror.mjs", + "main": "index.mjs", "publishConfig": { "main": "dist/index.js", "module": "dist/index.mjs" diff --git a/packages/codemirror/vite.config.js b/packages/codemirror/vite.config.js index 8562915c..0fc63a6b 100644 --- a/packages/codemirror/vite.config.js +++ b/packages/codemirror/vite.config.js @@ -7,7 +7,7 @@ export default defineConfig({ plugins: [], build: { lib: { - entry: resolve(__dirname, 'codemirror.mjs'), + entry: resolve(__dirname, 'index.mjs'), formats: ['es', 'cjs'], fileName: (ext) => ({ es: 'index.mjs', cjs: 'index.js' }[ext]), }, diff --git a/packages/core/evaluate.mjs b/packages/core/evaluate.mjs index 1d7be8e4..0dada761 100644 --- a/packages/core/evaluate.mjs +++ b/packages/core/evaluate.mjs @@ -37,8 +37,12 @@ function safeEval(str, options = {}) { } export const evaluate = async (code, transpiler) => { + let meta = {}; if (transpiler) { - code = transpiler(code); // transform syntactically correct js code to semantically usable code + // transform syntactically correct js code to semantically usable code + const transpiled = transpiler(code); + code = transpiled.output; + meta = transpiled; } // if no transpiler is given, we expect a single instruction (!wrapExpression) const options = { wrapExpression: !!transpiler }; @@ -48,5 +52,5 @@ export const evaluate = async (code, transpiler) => { const message = `got "${typeof evaluated}" instead of pattern`; throw new Error(message + (typeof evaluated === 'function' ? ', did you forget to call a function?' : '.')); } - return { mode: 'javascript', pattern: evaluated }; + return { mode: 'javascript', pattern: evaluated, meta }; }; diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index 6396d97b..b234c9d1 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -472,15 +472,15 @@ export class Pattern { /** * Returns a new pattern with the given location information added to the * context of every hap. - * @param {Number} start - * @param {Number} end + * @param {Number} start start offset + * @param {Number} end end offset * @returns Pattern * @noAutocomplete */ - withLocation(start, end) { + withLoc(start, end) { const location = { - start: { line: start[0], column: start[1], offset: start[2] }, - end: { line: end[0], column: end[1], offset: end[2] }, + start, + end, }; return this.withContext((context) => { const locations = (context.locations || []).concat([location]); @@ -488,32 +488,6 @@ export class Pattern { }); } - withMiniLocation(start, end) { - const offset = { - start: { line: start[0], column: start[1], offset: start[2] }, - end: { line: end[0], column: end[1], offset: end[2] }, - }; - return this.withContext((context) => { - let locations = context.locations || []; - locations = locations.map(({ start, end }) => { - const colOffset = start.line === 1 ? offset.start.column : 0; - return { - start: { - ...start, - line: start.line - 1 + (offset.start.line - 1) + 1, - column: start.column - 1 + colOffset, - }, - end: { - ...end, - line: end.line - 1 + (offset.start.line - 1) + 1, - column: end.column - 1 + colOffset, - }, - }; - }); - return { ...context, locations }; - }); - } - /** * Returns a new Pattern, which only returns haps that meet the given test. * @param {Function} hap_test - a function which returns false for haps to be removed from the pattern diff --git a/packages/core/repl.mjs b/packages/core/repl.mjs index 93e86622..b6866b87 100644 --- a/packages/core/repl.mjs +++ b/packages/core/repl.mjs @@ -35,11 +35,10 @@ export function repl({ } try { await beforeEval?.({ code }); - let { pattern } = await _evaluate(code, transpiler); - + let { pattern, meta } = await _evaluate(code, transpiler); logger(`[eval] code updated`); setPattern(pattern, autostart); - afterEval?.({ code, pattern }); + afterEval?.({ code, pattern, meta }); return pattern; } catch (err) { // console.warn(`[repl] eval error: ${err.message}`); diff --git a/packages/eval/shapeshifter.mjs b/packages/eval/shapeshifter.mjs index f2edf604..19907341 100644 --- a/packages/eval/shapeshifter.mjs +++ b/packages/eval/shapeshifter.mjs @@ -129,8 +129,8 @@ export default (_code) => { if (shouldAddReturn) { addReturn(shifted); } - const generated = undisguiseImports(codegen(shifted)); - return generated; + const output = undisguiseImports(codegen(shifted)); + return { output }; }; // renames all import statements to "_mport" as Shift doesn't support dynamic import. diff --git a/packages/eval/test/evaluate.test.mjs b/packages/eval/test/evaluate.test.mjs deleted file mode 100644 index 674c581b..00000000 --- a/packages/eval/test/evaluate.test.mjs +++ /dev/null @@ -1,32 +0,0 @@ -/* -evaluate.test.mjs - -Copyright (C) 2022 Strudel contributors - see -This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . -*/ - -import { expect, describe, it } from 'vitest'; - -import { evaluate } from '../evaluate.mjs'; -import { mini } from '@strudel.cycles/mini'; -import * as strudel from '@strudel.cycles/core'; -const { fastcat, evalScope } = strudel; - -describe('evaluate', async () => { - await evalScope({ mini }, strudel); - const ev = async (code) => (await evaluate(code)).pattern.firstCycleValues; - it('Should evaluate strudel functions', async () => { - expect(await ev('pure("c3")')).toEqual(['c3']); - expect(await ev('cat("c3")')).toEqual(['c3']); - expect(await ev('fastcat("c3", "d3")')).toEqual(['c3', 'd3']); - expect(await ev('slowcat("c3", "d3")')).toEqual(['c3']); - }); - it('Scope should be extendable', async () => { - await evalScope({ myFunction: (...x) => fastcat(...x) }); - expect(await ev('myFunction("c3", "d3")')).toEqual(['c3', 'd3']); - }); - it('Should evaluate simple double quoted mini notation', async () => { - expect(await ev('"c3"')).toEqual(['c3']); - expect(await ev('"c3 d3"')).toEqual(['c3', 'd3']); - expect(await ev('""')).toEqual(['c3']); - }); -}); diff --git a/packages/eval/test/shapeshifter.test.mjs b/packages/eval/test/shapeshifter.test.mjs deleted file mode 100644 index ae958326..00000000 --- a/packages/eval/test/shapeshifter.test.mjs +++ /dev/null @@ -1,25 +0,0 @@ -/* -shapeshifter.test.mjs - -Copyright (C) 2022 Strudel contributors - see -This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . -*/ - -import { describe, it, expect } from 'vitest'; -import shapeshifter, { wrappedAsync } from '../shapeshifter.mjs'; - -describe('shapeshifter', () => { - it('Should shift simple double quote string', () => { - if (wrappedAsync) { - expect(shapeshifter('"c3"')).toEqual('(async()=>{return mini("c3").withMiniLocation([1,0,15],[1,4,19])})()'); - } else { - expect(shapeshifter('"c3"')).toEqual('return mini("c3").withMiniLocation([1,0,0],[1,4,4])'); - } - }); - if (wrappedAsync) { - it('Should handle dynamic imports', () => { - expect(shapeshifter('const { default: foo } = await import(\'https://bar.com/foo.js\');"c3"')).toEqual( - 'const{default:foo}=await import("https://bar.com/foo.js");return mini("c3").withMiniLocation([1,64,79],[1,68,83])', - ); - }); - } -}); diff --git a/packages/mini/mini.mjs b/packages/mini/mini.mjs index 3f9228d9..3321e7bc 100644 --- a/packages/mini/mini.mjs +++ b/packages/mini/mini.mjs @@ -9,7 +9,7 @@ import * as strudel from '@strudel.cycles/core'; const randOffset = 0.0003; -const applyOptions = (parent, code) => (pat, i) => { +const applyOptions = (parent, enter) => (pat, i) => { const ast = parent.source_[i]; const options = ast.options_; const ops = options?.ops; @@ -23,18 +23,14 @@ const applyOptions = (parent, code) => (pat, i) => { if (!legalTypes.includes(type)) { throw new Error(`mini: stretch: type must be one of ${legalTypes.join('|')} but got ${type}`); } - pat = strudel.reify(pat)[type](patternifyAST(amount, code)); + pat = strudel.reify(pat)[type](enter(amount)); break; } case 'bjorklund': { if (op.arguments_.rotation) { - pat = pat.euclidRot( - patternifyAST(op.arguments_.pulse, code), - patternifyAST(op.arguments_.step, code), - patternifyAST(op.arguments_.rotation, code), - ); + pat = pat.euclidRot(enter(op.arguments_.pulse), enter(op.arguments_.step), enter(op.arguments_.rotation)); } else { - pat = pat.euclid(patternifyAST(op.arguments_.pulse, code), patternifyAST(op.arguments_.step, code)); + pat = pat.euclid(enter(op.arguments_.pulse), enter(op.arguments_.step)); } break; } @@ -45,7 +41,7 @@ const applyOptions = (parent, code) => (pat, i) => { break; } case 'tail': { - const friend = patternifyAST(op.arguments_.element, code); + const friend = enter(op.arguments_.element); pat = pat.fmap((a) => (b) => Array.isArray(a) ? [...a, b] : [a, b]).appLeft(friend); break; } @@ -72,11 +68,14 @@ function resolveReplications(ast) { ); } -export function patternifyAST(ast, code) { +// expects ast from mini2ast + quoted mini string + optional callback when a node is entered +export function patternifyAST(ast, code, onEnter, offset = 0) { + onEnter?.(ast); + const enter = (node) => patternifyAST(node, code, onEnter, offset); switch (ast.type_) { case 'pattern': { resolveReplications(ast); - const children = ast.source_.map((child) => patternifyAST(child, code)).map(applyOptions(ast, code)); + const children = ast.source_.map((child) => enter(child)).map(applyOptions(ast, enter)); const alignment = ast.arguments_.alignment; if (alignment === 'stack') { return strudel.stack(...children); @@ -84,7 +83,7 @@ export function patternifyAST(ast, code) { if (alignment === 'polymeter') { // polymeter const stepsPerCycle = ast.arguments_.stepsPerCycle - ? patternifyAST(ast.arguments_.stepsPerCycle, code).fmap((x) => strudel.Fraction(x)) + ? enter(ast.arguments_.stepsPerCycle).fmap((x) => strudel.Fraction(x)) : strudel.pure(strudel.Fraction(children.length > 0 ? children[0].__weight : 1)); const aligned = children.map((child) => child.fast(stepsPerCycle.fmap((x) => x.div(child.__weight || 1)))); @@ -111,7 +110,7 @@ export function patternifyAST(ast, code) { return pat; } case 'element': { - return patternifyAST(ast.source_, code); + return enter(ast.source_); } case 'atom': { if (ast.source_ === '~') { @@ -121,66 +120,82 @@ export function patternifyAST(ast, code) { console.warn('no location for', ast); return ast.source_; } - const { start, end } = ast.location_; const value = !isNaN(Number(ast.source_)) ? Number(ast.source_) : ast.source_; - // the following line expects the shapeshifter append .withMiniLocation - // because location_ is only relative to the mini string, but we need it relative to whole code - // make sure whitespaces are not part of the highlight: - const actual = code?.split('').slice(start.offset, end.offset).join(''); - const [offsetStart = 0, offsetEnd = 0] = actual - ? actual.split(ast.source_).map((p) => p.split('').filter((c) => c === ' ').length) - : []; - return strudel - .pure(value) - .withLocation( - [start.line, start.column + offsetStart, start.offset + offsetStart], - [start.line, end.column - offsetEnd, end.offset - offsetEnd], - ); + if (offset === -1) { + // skip location handling (used when getting leaves to avoid confusion) + return strudel.pure(value); + } + const [from, to] = getLeafLocation(code, ast, offset); + return strudel.pure(value).withLoc(from, to); } case 'stretch': - return patternifyAST(ast.source_, code).slow(patternifyAST(ast.arguments_.amount, code)); - /* case 'scale': - let [tonic, scale] = Scale.tokenize(ast.arguments_.scale); - const intervals = Scale.get(scale).intervals; - const pattern = patternifyAST(ast.source_); - tonic = tonic || 'C4'; - // console.log('scale', ast, pattern, tonic, scale); - console.log('tonic', tonic); - return pattern.fmap((step: any) => { - step = Number(step); - if (isNaN(step)) { - console.warn(`scale step "${step}" not a number`); - return step; - } - const octaves = Math.floor(step / intervals.length); - const mod = (n: number, m: number): number => (n < 0 ? mod(n + m, m) : n % m); - const index = mod(step, intervals.length); // % with negative numbers. e.g. -1 % 3 = 2 - const interval = Interval.add(intervals[index], Interval.fromSemitones(octaves * 12)); - return Note.transpose(tonic, interval || '1P'); - }); */ - /* case 'struct': - // TODO: - return strudel.silence; */ + return enter(ast.source_).slow(enter(ast.arguments_.amount)); default: console.warn(`node type "${ast.type_}" not implemented -> returning silence`); return strudel.silence; } } +// takes quoted mini string + leaf node within, returns source location of node (whitespace corrected) +export const getLeafLocation = (code, leaf, globalOffset = 0) => { + // value is expected without quotes! + const { start, end } = leaf.location_; + const actual = code?.split('').slice(start.offset, end.offset).join(''); + // make sure whitespaces are not part of the highlight + const [offsetStart = 0, offsetEnd = 0] = actual + ? actual.split(leaf.source_).map((p) => p.split('').filter((c) => c === ' ').length) + : []; + return [start.offset + offsetStart + globalOffset, end.offset - offsetEnd + globalOffset]; +}; + +// takes quoted mini string, returns ast +export const mini2ast = (code) => krill.parse(code); + +// takes quoted mini string, returns all nodes that are leaves +export const getLeaves = (code) => { + const ast = mini2ast(code); + let leaves = []; + patternifyAST( + ast, + code, + (node) => { + if (node.type_ === 'atom') { + leaves.push(node); + } + }, + -1, + ); + return leaves; +}; + +// takes quoted mini string, returns locations [fromCol,toCol] of all leaf nodes +export const getLeafLocations = (code, offset = 0) => { + return getLeaves(code).map((l) => getLeafLocation(code, l, offset)); +}; + // mini notation only (wraps in "") export const mini = (...strings) => { const pats = strings.map((str) => { const code = `"${str}"`; - const ast = krill.parse(code); + const ast = mini2ast(code); return patternifyAST(ast, code); }); return strudel.sequence(...pats); }; +// turns str mini string (without quotes) into pattern +// offset is the position of the mini string in the JS code +// each leaf node will get .withLoc added +// this function is used by the transpiler for double quoted strings +export const m = (str, offset) => { + const code = `"${str}"`; + const ast = mini2ast(code); + return patternifyAST(ast, code, null, offset); +}; + // includes haskell style (raw krill parsing) export const h = (string) => { - const ast = krill.parse(string); - // console.log('ast', ast); + const ast = mini2ast(string); return patternifyAST(ast, string); }; diff --git a/packages/mini/test/mini.test.mjs b/packages/mini/test/mini.test.mjs index 70577ab0..0c7f381e 100644 --- a/packages/mini/test/mini.test.mjs +++ b/packages/mini/test/mini.test.mjs @@ -4,7 +4,7 @@ Copyright (C) 2022 Strudel contributors - see . */ -import { mini } from '../mini.mjs'; +import { getLeafLocation, getLeafLocations, mini, mini2ast } from '../mini.mjs'; import '@strudel.cycles/core/euclid.mjs'; import { describe, expect, it } from 'vitest'; @@ -185,3 +185,36 @@ describe('mini', () => { expect(minV('a:b c:d:[e:f] g')).toEqual([['a', 'b'], ['c', 'd', ['e', 'f']], 'g']); }); }); + +describe('getLeafLocation', () => { + it('gets location of leaf nodes', () => { + const code = '"bd sd"'; + const ast = mini2ast(code); + + const bd = ast.source_[0].source_; + expect(getLeafLocation(code, bd)).toEqual([1, 3]); + + const sd = ast.source_[1].source_; + expect(getLeafLocation(code, sd)).toEqual([4, 6]); + }); +}); + +describe('getLeafLocations', () => { + it('gets locations of leaf nodes', () => { + expect(getLeafLocations('"bd sd"')).toEqual([ + [1, 3], // bd columns + [4, 6], // sd columns + ]); + expect(getLeafLocations('"bd*2 [sd cp]"')).toEqual([ + [1, 3], // bd columns + [7, 9], // sd columns + [10, 12], // cp columns + [4, 5], // "2" columns + ]); + expect(getLeafLocations('"bd*<2 3>"')).toEqual([ + [1, 3], // bd columns + [5, 6], // "2" columns + [7, 8], // "3" columns + ]); + }); +}); diff --git a/packages/react/examples/nano-repl/src/App.jsx b/packages/react/examples/nano-repl/src/App.jsx index 70bf2af1..086923ac 100644 --- a/packages/react/examples/nano-repl/src/App.jsx +++ b/packages/react/examples/nano-repl/src/App.jsx @@ -82,9 +82,10 @@ function App() { code, defaultOutput: webaudioOutput, getTime, + afterEval: ({ meta }) => setMiniLocations(meta.miniLocations), }); - useHighlighting({ + const { setMiniLocations } = useHighlighting({ view, pattern, active: started && !activeCode?.includes('strudel disable-highlighting'), diff --git a/packages/react/package.json b/packages/react/package.json index bded6c9e..51916049 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -42,6 +42,7 @@ "@strudel.cycles/core": "workspace:*", "@strudel.cycles/transpiler": "workspace:*", "@strudel.cycles/webaudio": "workspace:*", + "@strudel/codemirror": "workspace:*", "@uiw/codemirror-themes": "^4.19.16", "@uiw/react-codemirror": "^4.19.16", "react-hook-inview": "^4.5.0" diff --git a/packages/react/src/components/CodeMirror6.jsx b/packages/react/src/components/CodeMirror6.jsx index fe538019..0f1b2274 100644 --- a/packages/react/src/components/CodeMirror6.jsx +++ b/packages/react/src/components/CodeMirror6.jsx @@ -1,100 +1,31 @@ -import React, { useMemo } from 'react'; -import _CodeMirror from '@uiw/react-codemirror'; -import { EditorView, Decoration } from '@codemirror/view'; -import { StateField, StateEffect } from '@codemirror/state'; -import { javascript, javascriptLanguage } from '@codemirror/lang-javascript'; -import strudelTheme from '../themes/strudel-theme'; -import './style.css'; -import { useCallback } from 'react'; import { autocompletion } from '@codemirror/autocomplete'; -import { strudelAutocomplete } from './Autocomplete'; -import { vim } from '@replit/codemirror-vim'; +import { javascript, javascriptLanguage } from '@codemirror/lang-javascript'; +import { EditorView } from '@codemirror/view'; import { emacs } from '@replit/codemirror-emacs'; +import { vim } from '@replit/codemirror-vim'; +import _CodeMirror from '@uiw/react-codemirror'; +import React, { useCallback, useMemo } from 'react'; +import strudelTheme from '../themes/strudel-theme'; +import { strudelAutocomplete } from './Autocomplete'; +import { + highlightExtension, + flashField, + flash, + highlightMiniLocations, + updateMiniLocations, +} from '@strudel/codemirror'; +import './style.css'; -export const setFlash = StateEffect.define(); -const flashField = StateField.define({ - create() { - return Decoration.none; - }, - update(flash, tr) { - try { - for (let e of tr.effects) { - if (e.is(setFlash)) { - if (e.value && tr.newDoc?.length?.length > 0) { - const mark = Decoration.mark({ attributes: { style: `background-color: #FFCA2880` } }); - flash = Decoration.set([mark.range(0, tr.newDoc.length)]); - } else { - flash = Decoration.set([]); - } - } - } - return flash; - } catch (err) { - console.warn('flash error', err); - return flash; - } - }, - provide: (f) => EditorView.decorations.from(f), -}); +export { flash, highlightMiniLocations, updateMiniLocations }; -export const flash = (view) => { - view.dispatch({ effects: setFlash.of(true) }); - setTimeout(() => { - view.dispatch({ effects: setFlash.of(false) }); - }, 200); -}; - -export const setHighlights = StateEffect.define(); -const highlightField = StateField.define({ - create() { - return Decoration.none; - }, - update(highlights, tr) { - try { - for (let e of tr.effects) { - if (e.is(setHighlights)) { - const { haps } = e.value; - const marks = - haps - .map((hap) => - (hap.context.locations || []).map(({ start, end }) => { - const color = hap.context.color || e.value.color; - let from = tr.newDoc.line(start.line).from + start.column; - let to = tr.newDoc.line(end.line).from + end.column; - const l = tr.newDoc.length; - if (from > l || to > l) { - return; // dont mark outside of range, as it will throw an error - } - let mark; - if (color) { - mark = Decoration.mark({ attributes: { style: `outline: 2px solid ${color};` } }); - } else { - mark = Decoration.mark({ attributes: { class: `outline outline-2 outline-foreground` } }); - } - return mark.range(from, to); - }), - ) - .flat() - .filter(Boolean) || []; - highlights = Decoration.set(marks, true); - } - } - return highlights; - } catch (err) { - // console.warn('highlighting error', err); - return Decoration.set([]); - } - }, - provide: (f) => EditorView.decorations.from(f), -}); - -const staticExtensions = [javascript(), highlightField, flashField]; +const staticExtensions = [javascript(), flashField, highlightExtension]; export default function CodeMirror({ value, onChange, onViewChanged, onSelectionChange, + onDocChange, theme, keybindings, isLineNumbersDisplayed, @@ -102,8 +33,6 @@ export default function CodeMirror({ isLineWrappingEnabled, fontSize = 18, fontFamily = 'monospace', - options, - editorDidMount, }) { const handleOnChange = useCallback( (value) => { @@ -121,6 +50,9 @@ export default function CodeMirror({ const handleOnUpdate = useCallback( (viewUpdate) => { + if (viewUpdate.docChanged && onDocChange) { + onDocChange?.(viewUpdate); + } if (viewUpdate.selectionSet && onSelectionChange) { onSelectionChange?.(viewUpdate.state.selection); } @@ -168,103 +100,3 @@ export default function CodeMirror({ ); } - -let parenMark; -export const markParens = (editor, data) => { - const v = editor.getDoc().getValue(); - const marked = getCurrentParenArea(v, data); - parenMark?.clear(); - parenMark = editor.getDoc().markText(...marked, { css: 'background-color: #00007720' }); // -}; - -// returns { line, ch } from absolute character offset -export function offsetToPosition(offset, code) { - const lines = code.split('\n'); - let line = 0; - let ch = 0; - for (let i = 0; i < offset; i++) { - if (ch === lines[line].length) { - line++; - ch = 0; - } else { - ch++; - } - } - return { line, ch }; -} - -// returns absolute character offset from { line, ch } -export function positionToOffset(position, code) { - const lines = code.split('\n'); - if (position.line > lines.length) { - // throw new Error('positionToOffset: position.line > lines.length'); - return 0; - } - let offset = 0; - for (let i = 0; i < position.line; i++) { - offset += lines[i].length + 1; - } - offset += position.ch; - return offset; -} - -// given code and caret position, the functions returns the indices of the parens we are in -export function getCurrentParenArea(code, caretPosition) { - const caret = positionToOffset(caretPosition, code); - let open, i, begin, end; - // walk left - i = caret; - open = 0; - while (i > 0) { - if (code[i - 1] === '(') { - open--; - } else if (code[i - 1] === ')') { - open++; - } - if (open === -1) { - break; - } - i--; - } - begin = i; - // walk right - i = caret; - open = 0; - while (i < code.length) { - if (code[i] === '(') { - open--; - } else if (code[i] === ')') { - open++; - } - if (open === 1) { - break; - } - i++; - } - end = i; - return [begin, end].map((o) => offsetToPosition(o, code)); -} - -/* -export const markEvent = (editor) => (time, event) => { - const locs = event.context.locations; - if (!locs || !editor) { - return; - } - const col = event.context?.color || '#FFCA28'; - // mark active event - const marks = locs.map(({ start, end }) => - editor.getDoc().markText( - { line: start.line - 1, ch: start.column }, - { line: end.line - 1, ch: end.column }, - //{ css: 'background-color: #FFCA28; color: black' } // background-color is now used by parent marking - { css: 'outline: 1px solid ' + col + '; box-sizing:border-box' }, - //{ css: `background-color: ${col};border-radius:5px` }, - ), - ); - //Tone.Transport.schedule(() => { // problem: this can be cleared by scheduler... - setTimeout(() => { - marks.forEach((mark) => mark.clear()); - // }, '+' + event.duration * 0.5); - }, event.duration * 1000); -}; */ diff --git a/packages/react/src/components/MiniRepl.jsx b/packages/react/src/components/MiniRepl.jsx index 1a6cce94..12500874 100644 --- a/packages/react/src/components/MiniRepl.jsx +++ b/packages/react/src/components/MiniRepl.jsx @@ -71,6 +71,7 @@ export function MiniRepl({ evalOnMount, drawContext, drawTime, + afterEval: ({ meta }) => setMiniLocations(meta.miniLocations), }); const [view, setView] = useState(); @@ -84,7 +85,7 @@ export function MiniRepl({ } return isVisible || wasVisible.current; }, [isVisible, hideOutsideView]); - useHighlighting({ + const { setMiniLocations } = useHighlighting({ view, pattern, active: started && !activeCode?.includes('strudel disable-highlighting'), diff --git a/packages/react/src/hooks/useHighlighting.mjs b/packages/react/src/hooks/useHighlighting.mjs index 26239871..7b522464 100644 --- a/packages/react/src/hooks/useHighlighting.mjs +++ b/packages/react/src/hooks/useHighlighting.mjs @@ -1,10 +1,18 @@ -import { useEffect, useRef } from 'react'; -import { setHighlights } from '../components/CodeMirror6'; +import { useEffect, useRef, useState } from 'react'; +import { highlightMiniLocations, updateMiniLocations } from '../components/CodeMirror6'; const round = (x) => Math.round(x * 1000) / 1000; function useHighlighting({ view, pattern, active, getTime }) { const highlights = useRef([]); const lastEnd = useRef(0); + + const [miniLocations, setMiniLocations] = useState([]); + useEffect(() => { + if (view) { + updateMiniLocations(view, miniLocations); + } + }, [view, miniLocations]); + useEffect(() => { if (view) { if (pattern && active) { @@ -20,9 +28,9 @@ function useHighlighting({ view, pattern, active, getTime }) { highlights.current = highlights.current.filter((hap) => hap.endClipped > audioTime); // keep only highlights that are still active const haps = pattern.queryArc(...span).filter((hap) => hap.hasOnset()); highlights.current = highlights.current.concat(haps); // add potential new onsets - view.dispatch({ effects: setHighlights.of({ haps: highlights.current }) }); // highlight all still active + new active haps + highlightMiniLocations(view, begin, highlights.current); } catch (err) { - view.dispatch({ effects: setHighlights.of({ haps: [] }) }); + highlightMiniLocations(view, 0, []); } frame = requestAnimationFrame(updateHighlights); }); @@ -30,11 +38,14 @@ function useHighlighting({ view, pattern, active, getTime }) { cancelAnimationFrame(frame); }; } else { + console.log('not active'); highlights.current = []; - view.dispatch({ effects: setHighlights.of({ haps: [] }) }); + highlightMiniLocations(view, 0, highlights.current); } } }, [pattern, active, view]); + + return { setMiniLocations }; } export default useHighlighting; diff --git a/packages/react/src/index.js b/packages/react/src/index.js index f9eca1cd..f5bdbb35 100644 --- a/packages/react/src/index.js +++ b/packages/react/src/index.js @@ -1,6 +1,6 @@ // import 'tailwindcss/tailwind.css'; -export { default as CodeMirror, flash } from './components/CodeMirror6'; // !SSR +export { default as CodeMirror, flash, updateMiniLocations, highlightMiniLocations } from './components/CodeMirror6'; // !SSR export * from './components/MiniRepl'; // !SSR export { default as useHighlighting } from './hooks/useHighlighting'; // !SSR export { default as useStrudel } from './hooks/useStrudel'; // !SSR diff --git a/packages/transpiler/package.json b/packages/transpiler/package.json index 314f71fc..8d979d57 100644 --- a/packages/transpiler/package.json +++ b/packages/transpiler/package.json @@ -31,6 +31,7 @@ "homepage": "https://github.com/tidalcycles/strudel#readme", "dependencies": { "@strudel.cycles/core": "workspace:*", + "@strudel.cycles/mini": "workspace:*", "acorn": "^8.8.1", "escodegen": "^2.0.0", "estree-walker": "^3.0.1" diff --git a/packages/transpiler/test/transpiler.test.mjs b/packages/transpiler/test/transpiler.test.mjs index 6fa79347..1d14986d 100644 --- a/packages/transpiler/test/transpiler.test.mjs +++ b/packages/transpiler/test/transpiler.test.mjs @@ -11,22 +11,20 @@ const simple = { wrapAsync: false, addReturn: false, simpleLocs: true }; describe('transpiler', () => { it('wraps double quote string with mini and adds location', () => { - expect(transpiler('"c3"', simple)).toEqual("mini('c3').withMiniLocation(0, 4);"); - expect(transpiler('stack("c3","bd sd")', simple)).toEqual( - "stack(mini('c3').withMiniLocation(6, 10), mini('bd sd').withMiniLocation(11, 18));", - ); + expect(transpiler('"c3"', simple).output).toEqual("m('c3', 0);"); + expect(transpiler('stack("c3","bd sd")', simple).output).toEqual("stack(m('c3', 6), m('bd sd', 11));"); }); it('wraps backtick string with mini and adds location', () => { - expect(transpiler('`c3`', simple)).toEqual("mini('c3').withMiniLocation(0, 4);"); + expect(transpiler('`c3`', simple).output).toEqual("m('c3', 0);"); }); it('replaces note variables with note strings', () => { - expect(transpiler('seq(c3, d3)', simple)).toEqual("seq('c3', 'd3');"); + expect(transpiler('seq(c3, d3)', simple).output).toEqual("seq('c3', 'd3');"); }); it('keeps tagged template literal as is', () => { - expect(transpiler('xxx`c3`', simple)).toEqual('xxx`c3`;'); + expect(transpiler('xxx`c3`', simple).output).toEqual('xxx`c3`;'); }); it('supports top level await', () => { - expect(transpiler("await samples('xxx');", simple)).toEqual("await samples('xxx');"); + expect(transpiler("await samples('xxx');", simple).output).toEqual("await samples('xxx');"); }); /* it('parses dynamic imports', () => { expect( @@ -36,4 +34,12 @@ describe('transpiler', () => { }), ).toEqual("const {default: foo} = await import('https://bar.com/foo.js');"); }); */ + it('collections locations', () => { + const { miniLocations } = transpiler(`s("bd", "hh oh")`, { ...simple, emitMiniLocations: true }); + expect(miniLocations).toEqual([ + [3, 5], + [9, 11], + [12, 14], + ]); + }); }); diff --git a/packages/transpiler/transpiler.mjs b/packages/transpiler/transpiler.mjs index fee9d542..78aae9f7 100644 --- a/packages/transpiler/transpiler.mjs +++ b/packages/transpiler/transpiler.mjs @@ -2,9 +2,10 @@ import escodegen from 'escodegen'; import { parse } from 'acorn'; import { walk } from 'estree-walker'; import { isNoteWithOctave } from '@strudel.cycles/core'; +import { getLeafLocations } from '@strudel.cycles/mini'; export function transpiler(input, options = {}) { - const { wrapAsync = false, addReturn = true, simpleLocs = false } = options; + const { wrapAsync = false, addReturn = true, emitMiniLocations = true } = options; let ast = parse(input, { ecmaVersion: 2022, @@ -12,18 +13,27 @@ export function transpiler(input, options = {}) { locations: true, }); + let miniLocations = []; + const collectMiniLocations = (value, node) => { + const leafLocs = getLeafLocations(`"${value}"`, node.start); // stimmt! + //const withOffset = leafLocs.map((offsets) => offsets.map((o) => o + node.start)); + miniLocations = miniLocations.concat(leafLocs); + }; + walk(ast, { - enter(node, parent, prop, index) { + enter(node, parent /* , prop, index */) { if (isBackTickString(node, parent)) { - const { quasis, start, end } = node; + const { quasis } = node; const { raw } = quasis[0].value; this.skip(); - return this.replace(miniWithLocation(raw, node, simpleLocs)); + emitMiniLocations && collectMiniLocations(raw, node); + return this.replace(miniWithLocation(raw, node)); } if (isStringWithDoubleQuotes(node)) { - const { value, start, end } = node; + const { value } = node; this.skip(); - return this.replace(miniWithLocation(value, node, simpleLocs)); + emitMiniLocations && collectMiniLocations(value, node); + return this.replace(miniWithLocation(value, node)); } // TODO: remove pseudo note variables? if (node.type === 'Identifier' && isNoteWithOctave(node.name)) { @@ -47,11 +57,14 @@ export function transpiler(input, options = {}) { argument: expression, }; } - const output = escodegen.generate(ast); + let output = escodegen.generate(ast); if (wrapAsync) { - return `(async ()=>{${output}})()`; + output = `(async ()=>{${output}})()`; } - return output; + if (!emitMiniLocations) { + return { output }; + } + return { output, miniLocations }; } function isStringWithDoubleQuotes(node, locations, code) { @@ -66,64 +79,18 @@ function isBackTickString(node, parent) { return node.type === 'TemplateLiteral' && parent.type !== 'TaggedTemplateExpression'; } -function miniWithLocation(value, node, simpleLocs) { - let locs; - const { start: fromOffset, end: toOffset } = node; - if (simpleLocs) { - locs = [ - { - type: 'Literal', - value: fromOffset, - }, - { - type: 'Literal', - value: toOffset, - }, - ]; - } else { - const { - loc: { - start: { line: fromLine, column: fromColumn }, - end: { line: toLine, column: toColumn }, - }, - } = node; - locs = [ - { - type: 'ArrayExpression', - elements: [fromLine, fromColumn, fromOffset].map((value) => ({ - type: 'Literal', - value, - })), - }, - { - type: 'ArrayExpression', - elements: [toLine, toColumn, toOffset].map((value) => ({ - type: 'Literal', - value, - })), - }, - ]; - } - // with location +function miniWithLocation(value, node) { + const { start: fromOffset } = node; return { type: 'CallExpression', callee: { - type: 'MemberExpression', - object: { - type: 'CallExpression', - callee: { - type: 'Identifier', - name: 'mini', - }, - arguments: [{ type: 'Literal', value }], - optional: false, - }, - property: { - type: 'Identifier', - name: 'withMiniLocation', - }, + type: 'Identifier', + name: 'm', }, - arguments: locs, + arguments: [ + { type: 'Literal', value }, + { type: 'Literal', value: fromOffset }, + ], optional: false, }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 859b1917..88355655 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -292,6 +292,9 @@ importers: '@strudel.cycles/webaudio': specifier: workspace:* version: link:../webaudio + '@strudel/codemirror': + specifier: workspace:* + version: link:../codemirror '@uiw/codemirror-themes': specifier: ^4.19.16 version: 4.19.16(@codemirror/language@6.6.0)(@codemirror/state@6.2.0)(@codemirror/view@6.10.0) @@ -460,6 +463,9 @@ importers: '@strudel.cycles/core': specifier: workspace:* version: link:../core + '@strudel.cycles/mini': + specifier: workspace:* + version: link:../mini acorn: specifier: ^8.8.1 version: 8.8.2 @@ -4619,7 +4625,7 @@ packages: '@babel/plugin-transform-react-jsx-self': 7.21.0(@babel/core@7.21.5) '@babel/plugin-transform-react-jsx-source': 7.19.6(@babel/core@7.21.5) react-refresh: 0.14.0 - vite: 4.3.3 + vite: 4.3.3(@types/node@18.16.3) transitivePeerDependencies: - supports-color dev: true @@ -13233,38 +13239,6 @@ packages: - supports-color dev: true - /vite@4.3.3: - resolution: {integrity: sha512-MwFlLBO4udZXd+VBcezo3u8mC77YQk+ik+fbc0GZWGgzfbPP+8Kf0fldhARqvSYmtIWoAJ5BXPClUbMTlqFxrA==} - engines: {node: ^14.18.0 || >=16.0.0} - hasBin: true - peerDependencies: - '@types/node': '>= 14' - less: '*' - sass: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 - peerDependenciesMeta: - '@types/node': - optional: true - less: - optional: true - sass: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - dependencies: - esbuild: 0.17.18 - postcss: 8.4.23 - rollup: 3.21.0 - optionalDependencies: - fsevents: 2.3.2 - dev: true - /vite@4.3.3(@types/node@18.11.18): resolution: {integrity: sha512-MwFlLBO4udZXd+VBcezo3u8mC77YQk+ik+fbc0GZWGgzfbPP+8Kf0fldhARqvSYmtIWoAJ5BXPClUbMTlqFxrA==} engines: {node: ^14.18.0 || >=16.0.0} diff --git a/test/runtime.mjs b/test/runtime.mjs index 4008aacc..94b5f659 100644 --- a/test/runtime.mjs +++ b/test/runtime.mjs @@ -10,7 +10,7 @@ import * as strudel from '@strudel.cycles/core'; import * as webaudio from '@strudel.cycles/webaudio'; import controls from '@strudel.cycles/core/controls.mjs'; // import gist from '@strudel.cycles/core/gist.js'; -import { mini } from '@strudel.cycles/mini/mini.mjs'; +import { mini, m } from '@strudel.cycles/mini/mini.mjs'; // import * as toneHelpers from '@strudel.cycles/tone/tone.mjs'; // import * as voicingHelpers from '@strudel.cycles/tonal/voicings.mjs'; // import * as uiHelpers from '@strudel.cycles/tone/ui.mjs'; @@ -174,6 +174,7 @@ evalScope( csound: id, loadOrc: id, mini, + m, getDrawContext, getAudioContext, loadSoundfont, diff --git a/website/src/pages/technical-manual/internals.mdx b/website/src/pages/technical-manual/internals.mdx index 23521e3f..43e87fc3 100644 --- a/website/src/pages/technical-manual/internals.mdx +++ b/website/src/pages/technical-manual/internals.mdx @@ -126,13 +126,9 @@ These functions are more low level, probably not needed by the live coder. -## withLocation +## withLoc - - -## withMiniLocation - - + ## filterHaps diff --git a/website/src/pages/technical-manual/repl.mdx b/website/src/pages/technical-manual/repl.mdx index f8a0bcf9..7f4ed03d 100644 --- a/website/src/pages/technical-manual/repl.mdx +++ b/website/src/pages/technical-manual/repl.mdx @@ -32,19 +32,17 @@ In the JavaScript world, using transpilation is a common practise to be able to In the same tradition, Strudel can add a transpilation step to simplify the user code in the context of live coding. For example, the Strudel REPL lets the user create mini-notation patterns using just double quoted strings, while single quoted strings remain what they are: -```js -'c3 [e3 g3]*2'; +```strudel +note("c3 [e3 g3]*2") ``` is transpiled to: -```js -mini('c3 [e3 g3]*2').withMiniLocation([1, 0, 0], [1, 14, 14]); +```strudel +note(m('c3 [e3 g3]', 5)) ``` -Here, the string is wrapped in `mini`, which will create a pattern from a mini-notation string. Additionally, the `withMiniLocation` method passes the original source code location of the string to the pattern, which enables highlighting active events. - -Other convenient features like pseudo variables, operator overloading and top level await are possible with transpilation. +Here, the string is wrapped in `m`, which will create a pattern from a mini-notation string. As the second parameter, it gets passed source code location of the string, which enables highlighting active events later. After the transpilation, the code is ready to be evaluated into a `Pattern`. @@ -56,16 +54,22 @@ While the transpilation allows JavaScript to express Patterns in a less verbose The mini-notation parser is implemented using `peggy`, which allows generating performant parsers for Domain Specific Languages (DSLs) using a concise grammar notation. The generated parser turns the mini-notation string into an AST which is used to call the respective Strudel functions with the given structure. For example, `"c3 [e3 g3]*2"` will result in the following calls: -```js +```strudel seq( - reify('c3').withLocation([1, 1, 1], [1, 4, 4]), - seq(reify('e3').withLocation([1, 5, 5], [1, 8, 8]), reify('g3').withLocation([1, 8, 8], [1, 10, 10])).fast(2), -); + reify('c3').withLoc(6, 9), + seq(reify('e3').withLoc(10, 12), reify('g3',).withLoc(13, 15)) +) ``` ### Highlighting Locations -As seen in the examples above, both the JS and the mini-notation parser add source code locations using `withMiniLocation` and `withLocation` methods. While the JS parser adds locations relative to the user code as a whole, the mini-notation adds locations relative to the position of the mini-notation string. The absolute location of elements within mini-notation can be calculated by simply adding both locations together. This absolute location can be used to highlight active events in real time. +As seen in the examples above, both the mini-notation parser adds the source code locations using `withLoc`. +This location is calculated inside the `m` function, as the sum of 2 locations: + +1. the location where the mini notation string begins, as obtained from the JS parser +2. the location of the substring inside the mini notation, as obtained from the mini notation parser + +The sum of both is passed to `withLoc` to tell each element its location, which can be later used for highlighting when it's active. ### Mini Notation diff --git a/website/src/repl/Repl.jsx b/website/src/repl/Repl.jsx index 02e14ca3..29fa2c16 100644 --- a/website/src/repl/Repl.jsx +++ b/website/src/repl/Repl.jsx @@ -5,7 +5,15 @@ This program is free software: you can redistribute it and/or modify it under th */ import { cleanupDraw, cleanupUi, controls, evalScope, getDrawContext, logger } from '@strudel.cycles/core'; -import { CodeMirror, cx, flash, useHighlighting, useStrudel, useKeydown } from '@strudel.cycles/react'; +import { + CodeMirror, + cx, + flash, + useHighlighting, + useStrudel, + useKeydown, + updateMiniLocations, +} from '@strudel.cycles/react'; import { getAudioContext, initAudioOnFirstClick, resetLoadedSounds, webaudioOutput } from '@strudel.cycles/webaudio'; import { createClient } from '@supabase/supabase-js'; import { nanoid } from 'nanoid'; @@ -106,7 +114,6 @@ export function Repl({ embedded = false }) { const [view, setView] = useState(); // codemirror view const [lastShared, setLastShared] = useState(); const [pending, setPending] = useState(true); - const { theme, keybindings, @@ -128,7 +135,8 @@ export function Repl({ embedded = false }) { cleanupUi(); cleanupDraw(); }, - afterEval: ({ code }) => { + afterEval: ({ code, meta }) => { + setMiniLocations(meta.miniLocations); setPending(false); setLatestCode(code); window.location.hash = '#' + encodeURIComponent(btoa(code)); @@ -178,7 +186,7 @@ export function Repl({ embedded = false }) { ); // highlighting - useHighlighting({ + const { setMiniLocations } = useHighlighting({ view, pattern, active: started && !activeCode?.includes('strudel disable-highlighting'), @@ -200,6 +208,7 @@ export function Repl({ embedded = false }) { // TODO: scroll to selected function in reference // console.log('selectino change', selection.ranges[0].from); }, []); + const handleTogglePlay = async () => { await getAudioContext().resume(); // fixes no sound in ios webkit if (!started) {