From c7e882e0010724945d79e292b9d79da6cd97560d Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sat, 1 Jul 2023 23:52:31 +0200 Subject: [PATCH 01/19] mini: leaf location retrieval - add onEnter callback for patternifyAST - add leaf node location helpers - add tests --- packages/mini/mini.mjs | 105 +++++++++++++++---------------- packages/mini/test/mini.test.mjs | 31 ++++++++- 2 files changed, 82 insertions(+), 54 deletions(-) diff --git a/packages/mini/mini.mjs b/packages/mini/mini.mjs index 3f9228d9..10a82410 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) { + onEnter?.(ast); + const enter = (node) => patternifyAST(node, code, onEnter); 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,57 +120,58 @@ 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], - ); + const [from, to] = getLeafLocation(code, ast); + return strudel.pure(value).withLocation(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) => { + // 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.line, start.column + offsetStart, start.offset + offsetStart], + [start.line, end.column - offsetEnd, end.offset - offsetEnd], + ]; +}; + +// 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); + } + }); + return leaves; +}; + +// takes quoted mini string, returns locations [fromCol,toCol] of all leaf nodes +export const getLeafLocations = (code) => { + return getLeaves(code).map((l) => getLeafLocation(code, l).map((l) => l[2])); +}; + // 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); @@ -179,8 +179,7 @@ export const mini = (...strings) => { // 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..1778785d 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,32 @@ 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, 2, 1], + [1, 4, 3], + ]); + + const sd = ast.source_[1].source_; + expect(getLeafLocation(code, sd)).toEqual([ + [1, 5, 4], + [1, 7, 6], + ]); + }); +}); + +describe('getLeafLocations', () => { + it('gets locations of leaf nodes', () => { + const code = '"bd sd"'; + expect(getLeafLocations(code)).toEqual([ + [1, 3], // bd columns + [4, 6], // sd columns + ]); + }); +}); From d686b65dbea237904c19e1e8bfdb6a3e713bc342 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sat, 1 Jul 2023 23:55:41 +0200 Subject: [PATCH 02/19] add 2 more tests --- packages/mini/test/mini.test.mjs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/mini/test/mini.test.mjs b/packages/mini/test/mini.test.mjs index 1778785d..677dc80a 100644 --- a/packages/mini/test/mini.test.mjs +++ b/packages/mini/test/mini.test.mjs @@ -207,10 +207,20 @@ describe('getLeafLocation', () => { describe('getLeafLocations', () => { it('gets locations of leaf nodes', () => { - const code = '"bd sd"'; - expect(getLeafLocations(code)).toEqual([ + 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 + ]); }); }); From 5f271ed12779abd92cfab0f7976750d7b7088e2d Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sun, 2 Jul 2023 13:42:41 +0200 Subject: [PATCH 03/19] add emitMiniLocations flag to transpiler --- packages/transpiler/package.json | 1 + packages/transpiler/test/transpiler.test.mjs | 8 +++++ packages/transpiler/transpiler.mjs | 31 ++++++++++++++------ 3 files changed, 31 insertions(+), 9 deletions(-) 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..ecfbe5a3 100644 --- a/packages/transpiler/test/transpiler.test.mjs +++ b/packages/transpiler/test/transpiler.test.mjs @@ -36,4 +36,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..1bb3b85b 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, simpleLocs = false, emitMiniLocations = false } = 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}"`); + const withOffset = leafLocs.map((offsets) => offsets.map((o) => o + node.start)); + miniLocations = miniLocations.concat(withOffset); + }; + 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, simpleLocs, miniLocations)); } 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, simpleLocs, miniLocations)); } // 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) { From 8e717d2ea15398847b4b888d415f50722df42e79 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sun, 2 Jul 2023 14:15:54 +0200 Subject: [PATCH 04/19] - transpiler now always returns an object - emit transpiler metadata from evaluate / afterEval - currently logging miniLocations from Repl.jsx --- packages/core/evaluate.mjs | 8 ++++++-- packages/core/repl.mjs | 5 ++--- packages/eval/shapeshifter.mjs | 4 ++-- packages/eval/test/shapeshifter.test.mjs | 8 +++++--- packages/transpiler/test/transpiler.test.mjs | 12 ++++++------ packages/transpiler/transpiler.mjs | 4 ++-- website/src/repl/Repl.jsx | 3 ++- 7 files changed, 25 insertions(+), 19 deletions(-) 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/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/shapeshifter.test.mjs b/packages/eval/test/shapeshifter.test.mjs index ae958326..a76c2aae 100644 --- a/packages/eval/test/shapeshifter.test.mjs +++ b/packages/eval/test/shapeshifter.test.mjs @@ -10,14 +10,16 @@ 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])})()'); + expect(shapeshifter('"c3"').output).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])'); + expect(shapeshifter('"c3"').output).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( + expect(shapeshifter('const { default: foo } = await import(\'https://bar.com/foo.js\');"c3"').output).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/transpiler/test/transpiler.test.mjs b/packages/transpiler/test/transpiler.test.mjs index ecfbe5a3..23b0e64f 100644 --- a/packages/transpiler/test/transpiler.test.mjs +++ b/packages/transpiler/test/transpiler.test.mjs @@ -11,22 +11,22 @@ 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( + expect(transpiler('"c3"', simple).output).toEqual("mini('c3').withMiniLocation(0, 4);"); + expect(transpiler('stack("c3","bd sd")', simple).output).toEqual( "stack(mini('c3').withMiniLocation(6, 10), mini('bd sd').withMiniLocation(11, 18));", ); }); 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("mini('c3').withMiniLocation(0, 4);"); }); 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( diff --git a/packages/transpiler/transpiler.mjs b/packages/transpiler/transpiler.mjs index 1bb3b85b..d2f9eed6 100644 --- a/packages/transpiler/transpiler.mjs +++ b/packages/transpiler/transpiler.mjs @@ -5,7 +5,7 @@ import { isNoteWithOctave } from '@strudel.cycles/core'; import { getLeafLocations } from '@strudel.cycles/mini'; export function transpiler(input, options = {}) { - const { wrapAsync = false, addReturn = true, simpleLocs = false, emitMiniLocations = false } = options; + const { wrapAsync = false, addReturn = true, simpleLocs = false, emitMiniLocations = true } = options; let ast = parse(input, { ecmaVersion: 2022, @@ -62,7 +62,7 @@ export function transpiler(input, options = {}) { output = `(async ()=>{${output}})()`; } if (!emitMiniLocations) { - return output; + return { output }; } return { output, miniLocations }; } diff --git a/website/src/repl/Repl.jsx b/website/src/repl/Repl.jsx index 02e14ca3..2030fad1 100644 --- a/website/src/repl/Repl.jsx +++ b/website/src/repl/Repl.jsx @@ -128,7 +128,8 @@ export function Repl({ embedded = false }) { cleanupUi(); cleanupDraw(); }, - afterEval: ({ code }) => { + afterEval: ({ code, meta }) => { + console.log('miniLocations', meta.miniLocations); setPending(false); setLatestCode(code); window.location.hash = '#' + encodeURIComponent(btoa(code)); From f7bd373ce6029df4002ff42200c55949ab8464f0 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sun, 2 Jul 2023 14:17:15 +0200 Subject: [PATCH 05/19] update package-lock --- pnpm-lock.yaml | 37 ++++--------------------------------- 1 file changed, 4 insertions(+), 33 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 859b1917..e7017134 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -460,6 +460,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 +4622,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 +13236,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} From 63c23736ade95bf7c1d2073afd87ba4ad04d2d8b Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sun, 2 Jul 2023 18:33:44 +0200 Subject: [PATCH 06/19] wip: adaptive highlighting --- packages/react/src/components/CodeMirror6.jsx | 83 ++++++++++++++++++- packages/react/src/hooks/useHighlighting.mjs | 3 +- packages/react/src/index.js | 2 +- website/src/repl/Repl.jsx | 42 +++++++++- 4 files changed, 124 insertions(+), 6 deletions(-) diff --git a/packages/react/src/components/CodeMirror6.jsx b/packages/react/src/components/CodeMirror6.jsx index fe538019..e9734421 100644 --- a/packages/react/src/components/CodeMirror6.jsx +++ b/packages/react/src/components/CodeMirror6.jsx @@ -44,12 +44,89 @@ export const flash = (view) => { }, 200); }; +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, haps) => { + view.dispatch({ effects: showMiniLocations.of(haps) }); +}; + +const miniLocations = StateField.define({ + create() { + return Decoration.none; + }, + update(locations, tr) { + 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.map( + (range) => + Decoration.mark({ + range, + // 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` }, + }), // -> Decoration + ); + // + const decorations = marks + .map((mark) => { + let { range } = mark.spec; + range = range.map((v) => Math.min(v, tr.newDoc.length)); + const [from, to] = range; + if (from < to) { + return mark.range(from, to); // -> Range + } + }) + .filter(Boolean); + locations = Decoration.set(decorations); // -> DecorationSet === RangeSet + } + 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 visible = e.value + .map((hap) => hap.context.locations.map(({ start, end }) => `${start.offset}:${end.offset}`)) + .flat() + .filter((v, i, a) => a.indexOf(v) === i); + console.log('visible', visible); // e.g. [ "1:3", "8:9", "4:6" ] + + // TODO: iterate over "locations" variable, get access to underlying mark.spec.range + // for each mark that is visible, change color (later remove green color...) + // How to iterate over DecorationSet ??? + + /* console.log('iter', iter.value.spec.range); + while (iter.next().value) { + console.log('iter', iter.value); + } */ + /* locations = locations.update({ + filter: (from, to) => { + //console.log('filter', from, to); + // const id = `${from}:${to}`; + //return visible.includes(`${from}:${to}`); + return true; + }, + }); */ + } + } + return locations; + }, + provide: (f) => EditorView.decorations.from(f), +}); + export const setHighlights = StateEffect.define(); const highlightField = StateField.define({ create() { return Decoration.none; }, update(highlights, tr) { + highlights = highlights.map(tr.changes); try { for (let e of tr.effects) { if (e.is(setHighlights)) { @@ -88,13 +165,14 @@ const highlightField = StateField.define({ provide: (f) => EditorView.decorations.from(f), }); -const staticExtensions = [javascript(), highlightField, flashField]; +const staticExtensions = [javascript(), highlightField, flashField, miniLocations]; export default function CodeMirror({ value, onChange, onViewChanged, onSelectionChange, + onDocChange, theme, keybindings, isLineNumbersDisplayed, @@ -121,6 +199,9 @@ export default function CodeMirror({ const handleOnUpdate = useCallback( (viewUpdate) => { + if (viewUpdate.docChanged && onDocChange) { + onDocChange?.(viewUpdate); + } if (viewUpdate.selectionSet && onSelectionChange) { onSelectionChange?.(viewUpdate.state.selection); } diff --git a/packages/react/src/hooks/useHighlighting.mjs b/packages/react/src/hooks/useHighlighting.mjs index 26239871..f622bdd9 100644 --- a/packages/react/src/hooks/useHighlighting.mjs +++ b/packages/react/src/hooks/useHighlighting.mjs @@ -1,5 +1,5 @@ import { useEffect, useRef } from 'react'; -import { setHighlights } from '../components/CodeMirror6'; +import { setHighlights, highlightMiniLocations } from '../components/CodeMirror6'; const round = (x) => Math.round(x * 1000) / 1000; function useHighlighting({ view, pattern, active, getTime }) { @@ -21,6 +21,7 @@ function useHighlighting({ view, pattern, active, getTime }) { 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, highlights.current); // <- new method, replaces above line when done } catch (err) { view.dispatch({ effects: setHighlights.of({ haps: [] }) }); } 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/website/src/repl/Repl.jsx b/website/src/repl/Repl.jsx index 2030fad1..84babdef 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'; @@ -101,12 +109,13 @@ const { code: randomTune, name } = getRandomTune(); export const ReplContext = createContext(null); +let init = false; // this is bad! only for testing! + export function Repl({ embedded = false }) { const isEmbedded = embedded || window.location !== window.parent.location; const [view, setView] = useState(); // codemirror view const [lastShared, setLastShared] = useState(); const [pending, setPending] = useState(true); - const { theme, keybindings, @@ -129,7 +138,9 @@ export function Repl({ embedded = false }) { cleanupDraw(); }, afterEval: ({ code, meta }) => { - console.log('miniLocations', meta.miniLocations); + console.log('miniLocations', meta.miniLocations, view); + // TODO: find a way to get hold of the codemirror view + // then call updateMiniLocations setPending(false); setLatestCode(code); window.location.hash = '#' + encodeURIComponent(btoa(code)); @@ -201,6 +212,30 @@ export function Repl({ embedded = false }) { // TODO: scroll to selected function in reference // console.log('selectino change', selection.ranges[0].from); }, []); + + const handleDocChanged = useCallback( + (v) => { + if (!init) { + // this is only for testing! try this pattern: + /* +stack( + s("bd"), + s("hh oh*<2 3>") +) + */ + updateMiniLocations(view, [ + [12, 14], + [23, 25], + [26, 28], + [30, 31], + [32, 33], + ]); + init = true; + } + }, + [view], + ); + const handleTogglePlay = async () => { await getAudioContext().resume(); // fixes no sound in ios webkit if (!started) { @@ -295,6 +330,7 @@ export function Repl({ embedded = false }) { onChange={handleChangeCode} onViewChanged={handleViewChanged} onSelectionChange={handleSelectionChange} + onDocChange={handleDocChanged} /> {error && ( From 08abec8fd5f65bb995d1b04b8670676f20550c9e Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Sun, 2 Jul 2023 18:37:56 +0200 Subject: [PATCH 07/19] fix: init phase --- website/src/repl/Repl.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/repl/Repl.jsx b/website/src/repl/Repl.jsx index 84babdef..e22e0421 100644 --- a/website/src/repl/Repl.jsx +++ b/website/src/repl/Repl.jsx @@ -214,7 +214,7 @@ export function Repl({ embedded = false }) { }, []); const handleDocChanged = useCallback( - (v) => { + ({ view }) => { if (!init) { // this is only for testing! try this pattern: /* From 0b5d905120e63cfee90c2e25c929a02b63d096b2 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Mon, 3 Jul 2023 05:15:32 +0200 Subject: [PATCH 08/19] fix: adaptive highlighting - transpiler now uses m function with globalOffset - patternifyAST now accepts global offset - patternifyAST now calls .withLoc with global leaf location - .withLoc replaces .withLocation + .withMiniLocation - simple locs (offsets) are now used everywhere - some tests fail, seems some haps have reordered... - wip: Repl still uses hardcoded updateMiniLocations - todo: find way to call updateMiniLocations dynamically --- packages/core/pattern.mjs | 20 +++++ packages/mini/mini.mjs | 48 ++++++++---- packages/mini/test/mini.test.mjs | 10 +-- packages/react/src/components/CodeMirror6.jsx | 53 ++++++++----- packages/react/src/hooks/useHighlighting.mjs | 2 +- packages/transpiler/test/transpiler.test.mjs | 6 +- packages/transpiler/transpiler.mjs | 74 ++++--------------- test/runtime.mjs | 3 +- website/src/repl/Repl.jsx | 2 +- 9 files changed, 108 insertions(+), 110 deletions(-) diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index 6396d97b..f7cb6ce8 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -472,6 +472,25 @@ export class Pattern { /** * Returns a new pattern with the given location information added to the * context of every hap. + * @param {Number} start start offset + * @param {Number} end end offset + * @returns Pattern + * @noAutocomplete + */ + withLoc(start, end) { + const location = { + start, + end, + }; + return this.withContext((context) => { + const locations = (context.locations || []).concat([location]); + return { ...context, locations }; + }); + } + + /** + * Deprecated: Returns a new pattern with the given location information added to the + * context of every hap. * @param {Number} start * @param {Number} end * @returns Pattern @@ -488,6 +507,7 @@ export class Pattern { }); } + // DEPRECATED: withMiniLocation(start, end) { const offset = { start: { line: start[0], column: start[1], offset: start[2] }, diff --git a/packages/mini/mini.mjs b/packages/mini/mini.mjs index 10a82410..3321e7bc 100644 --- a/packages/mini/mini.mjs +++ b/packages/mini/mini.mjs @@ -69,9 +69,9 @@ function resolveReplications(ast) { } // expects ast from mini2ast + quoted mini string + optional callback when a node is entered -export function patternifyAST(ast, code, onEnter) { +export function patternifyAST(ast, code, onEnter, offset = 0) { onEnter?.(ast); - const enter = (node) => patternifyAST(node, code, onEnter); + const enter = (node) => patternifyAST(node, code, onEnter, offset); switch (ast.type_) { case 'pattern': { resolveReplications(ast); @@ -121,8 +121,12 @@ export function patternifyAST(ast, code, onEnter) { return ast.source_; } const value = !isNaN(Number(ast.source_)) ? Number(ast.source_) : ast.source_; - const [from, to] = getLeafLocation(code, ast); - return strudel.pure(value).withLocation(from, to); + 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 enter(ast.source_).slow(enter(ast.arguments_.amount)); @@ -133,7 +137,7 @@ export function patternifyAST(ast, code, onEnter) { } // takes quoted mini string + leaf node within, returns source location of node (whitespace corrected) -export const getLeafLocation = (code, leaf) => { +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(''); @@ -141,10 +145,7 @@ export const getLeafLocation = (code, leaf) => { const [offsetStart = 0, offsetEnd = 0] = actual ? actual.split(leaf.source_).map((p) => p.split('').filter((c) => c === ' ').length) : []; - return [ - [start.line, start.column + offsetStart, start.offset + offsetStart], - [start.line, end.column - offsetEnd, end.offset - offsetEnd], - ]; + return [start.offset + offsetStart + globalOffset, end.offset - offsetEnd + globalOffset]; }; // takes quoted mini string, returns ast @@ -154,17 +155,22 @@ export const mini2ast = (code) => krill.parse(code); export const getLeaves = (code) => { const ast = mini2ast(code); let leaves = []; - patternifyAST(ast, code, (node) => { - if (node.type_ === 'atom') { - leaves.push(node); - } - }); + 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) => { - return getLeaves(code).map((l) => getLeafLocation(code, l).map((l) => l[2])); +export const getLeafLocations = (code, offset = 0) => { + return getLeaves(code).map((l) => getLeafLocation(code, l, offset)); }; // mini notation only (wraps in "") @@ -177,6 +183,16 @@ export const mini = (...strings) => { 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 = mini2ast(string); diff --git a/packages/mini/test/mini.test.mjs b/packages/mini/test/mini.test.mjs index 677dc80a..0c7f381e 100644 --- a/packages/mini/test/mini.test.mjs +++ b/packages/mini/test/mini.test.mjs @@ -192,16 +192,10 @@ describe('getLeafLocation', () => { const ast = mini2ast(code); const bd = ast.source_[0].source_; - expect(getLeafLocation(code, bd)).toEqual([ - [1, 2, 1], - [1, 4, 3], - ]); + expect(getLeafLocation(code, bd)).toEqual([1, 3]); const sd = ast.source_[1].source_; - expect(getLeafLocation(code, sd)).toEqual([ - [1, 5, 4], - [1, 7, 6], - ]); + expect(getLeafLocation(code, sd)).toEqual([4, 6]); }); }); diff --git a/packages/react/src/components/CodeMirror6.jsx b/packages/react/src/components/CodeMirror6.jsx index e9734421..e695eeed 100644 --- a/packages/react/src/components/CodeMirror6.jsx +++ b/packages/react/src/components/CodeMirror6.jsx @@ -92,27 +92,40 @@ const miniLocations = StateField.define({ // 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 visible = e.value - .map((hap) => hap.context.locations.map(({ start, end }) => `${start.offset}:${end.offset}`)) + .map((hap) => hap.context.locations.map(({ start, end }) => `${start}:${end}`)) .flat() .filter((v, i, a) => a.indexOf(v) === i); - console.log('visible', visible); // e.g. [ "1:3", "8:9", "4:6" ] + // console.log('visible', visible); // e.g. [ "1:3", "8:9", "4:6" ] - // TODO: iterate over "locations" variable, get access to underlying mark.spec.range - // for each mark that is visible, change color (later remove green color...) - // How to iterate over DecorationSet ??? + const iterator = locations.iter(); - /* console.log('iter', iter.value.spec.range); - while (iter.next().value) { - console.log('iter', iter.value); - } */ - /* locations = locations.update({ - filter: (from, to) => { - //console.log('filter', from, to); - // const id = `${from}:${to}`; - //return visible.includes(`${from}:${to}`); - return true; - }, - }); */ + let mapping = {}; + while (!!iterator.value) { + const { + from, + to, + value: { + spec: { range }, + }, + } = iterator; + const id = `${range[0]}:${range[1]}`; + mapping[id] = [from, to]; + iterator.next(); + } + + const decorations = Object.entries(mapping) + .map(([range, [from, to]]) => { + let color = visible.includes(range) ? 'red' : 'transparent'; + const mark = Decoration.mark({ + range: range.split(':'), + // 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: ${color}` }, + }); + return mark.range(from, to); // -> Range + }) + .filter(Boolean); + locations = Decoration.set(decorations); } } return locations; @@ -134,10 +147,10 @@ const highlightField = StateField.define({ const marks = haps .map((hap) => - (hap.context.locations || []).map(({ start, end }) => { + (hap.context.locations || []).map(({ start: from, end: to }) => { 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; + /* 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 diff --git a/packages/react/src/hooks/useHighlighting.mjs b/packages/react/src/hooks/useHighlighting.mjs index f622bdd9..a678fdc5 100644 --- a/packages/react/src/hooks/useHighlighting.mjs +++ b/packages/react/src/hooks/useHighlighting.mjs @@ -20,7 +20,7 @@ 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 + // view.dispatch({ effects: setHighlights.of({ haps: highlights.current }) }); // highlight all still active + new active haps highlightMiniLocations(view, highlights.current); // <- new method, replaces above line when done } catch (err) { view.dispatch({ effects: setHighlights.of({ haps: [] }) }); diff --git a/packages/transpiler/test/transpiler.test.mjs b/packages/transpiler/test/transpiler.test.mjs index 23b0e64f..c65b014a 100644 --- a/packages/transpiler/test/transpiler.test.mjs +++ b/packages/transpiler/test/transpiler.test.mjs @@ -11,13 +11,13 @@ const simple = { wrapAsync: false, addReturn: false, simpleLocs: true }; describe('transpiler', () => { it('wraps double quote string with mini and adds location', () => { - expect(transpiler('"c3"', simple).output).toEqual("mini('c3').withMiniLocation(0, 4);"); + expect(transpiler('"c3"', simple).output).toEqual("m('c3', 0);"); expect(transpiler('stack("c3","bd sd")', simple).output).toEqual( - "stack(mini('c3').withMiniLocation(6, 10), mini('bd sd').withMiniLocation(11, 18));", + "stack(m('c3', 6), m('bd sd', 11));", ); }); it('wraps backtick string with mini and adds location', () => { - expect(transpiler('`c3`', simple).output).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).output).toEqual("seq('c3', 'd3');"); diff --git a/packages/transpiler/transpiler.mjs b/packages/transpiler/transpiler.mjs index d2f9eed6..78aae9f7 100644 --- a/packages/transpiler/transpiler.mjs +++ b/packages/transpiler/transpiler.mjs @@ -5,7 +5,7 @@ import { isNoteWithOctave } from '@strudel.cycles/core'; import { getLeafLocations } from '@strudel.cycles/mini'; export function transpiler(input, options = {}) { - const { wrapAsync = false, addReturn = true, simpleLocs = false, emitMiniLocations = true } = options; + const { wrapAsync = false, addReturn = true, emitMiniLocations = true } = options; let ast = parse(input, { ecmaVersion: 2022, @@ -15,9 +15,9 @@ export function transpiler(input, options = {}) { let miniLocations = []; const collectMiniLocations = (value, node) => { - const leafLocs = getLeafLocations(`"${value}"`); - const withOffset = leafLocs.map((offsets) => offsets.map((o) => o + node.start)); - miniLocations = miniLocations.concat(withOffset); + const leafLocs = getLeafLocations(`"${value}"`, node.start); // stimmt! + //const withOffset = leafLocs.map((offsets) => offsets.map((o) => o + node.start)); + miniLocations = miniLocations.concat(leafLocs); }; walk(ast, { @@ -27,13 +27,13 @@ export function transpiler(input, options = {}) { const { raw } = quasis[0].value; this.skip(); emitMiniLocations && collectMiniLocations(raw, node); - return this.replace(miniWithLocation(raw, node, simpleLocs, miniLocations)); + return this.replace(miniWithLocation(raw, node)); } if (isStringWithDoubleQuotes(node)) { const { value } = node; this.skip(); emitMiniLocations && collectMiniLocations(value, node); - return this.replace(miniWithLocation(value, node, simpleLocs, miniLocations)); + return this.replace(miniWithLocation(value, node)); } // TODO: remove pseudo note variables? if (node.type === 'Identifier' && isNoteWithOctave(node.name)) { @@ -79,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/test/runtime.mjs b/test/runtime.mjs index 08acf831..b43898c4 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/repl/Repl.jsx b/website/src/repl/Repl.jsx index e22e0421..8629a9ac 100644 --- a/website/src/repl/Repl.jsx +++ b/website/src/repl/Repl.jsx @@ -220,7 +220,7 @@ export function Repl({ embedded = false }) { /* stack( s("bd"), - s("hh oh*<2 3>") + s("hh oh*<2 3>") ) */ updateMiniLocations(view, [ From aded178ab7db3ec852c33e08836e410ae4f8c739 Mon Sep 17 00:00:00 2001 From: Matthew Kaney Date: Mon, 3 Jul 2023 03:55:49 -0400 Subject: [PATCH 09/19] Pass mininotation locations into highlight state --- packages/react/src/components/CodeMirror6.jsx | 2 +- website/src/repl/Repl.jsx | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/react/src/components/CodeMirror6.jsx b/packages/react/src/components/CodeMirror6.jsx index e695eeed..f4ce3a07 100644 --- a/packages/react/src/components/CodeMirror6.jsx +++ b/packages/react/src/components/CodeMirror6.jsx @@ -85,7 +85,7 @@ const miniLocations = StateField.define({ } }) .filter(Boolean); - locations = Decoration.set(decorations); // -> DecorationSet === RangeSet + locations = Decoration.set(decorations, true); // -> DecorationSet === RangeSet } if (e.is(showMiniLocations)) { // this is called every frame to show the locations that are currently active diff --git a/website/src/repl/Repl.jsx b/website/src/repl/Repl.jsx index 8629a9ac..fdbfa7db 100644 --- a/website/src/repl/Repl.jsx +++ b/website/src/repl/Repl.jsx @@ -126,6 +126,13 @@ export function Repl({ embedded = false }) { isLineWrappingEnabled, } = useSettings(); + const [miniLocations, setMiniLocations] = useState([]); + useEffect(() => { + if (view) { + updateMiniLocations(view, miniLocations); + } + }, [view, miniLocations]); + const { code, setCode, scheduler, evaluate, activateCode, isDirty, activeCode, pattern, started, stop, error } = useStrudel({ initialCode: '// LOADING...', @@ -138,9 +145,7 @@ export function Repl({ embedded = false }) { cleanupDraw(); }, afterEval: ({ code, meta }) => { - console.log('miniLocations', meta.miniLocations, view); - // TODO: find a way to get hold of the codemirror view - // then call updateMiniLocations + setMiniLocations(meta.miniLocations); setPending(false); setLatestCode(code); window.location.hash = '#' + encodeURIComponent(btoa(code)); From ba9562f000db46f83c0ba8a23069e96e46a86427 Mon Sep 17 00:00:00 2001 From: Matthew Kaney Date: Mon, 3 Jul 2023 10:52:06 -0400 Subject: [PATCH 10/19] Remove highlights when code stops --- packages/react/src/hooks/useHighlighting.mjs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/react/src/hooks/useHighlighting.mjs b/packages/react/src/hooks/useHighlighting.mjs index a678fdc5..5881269c 100644 --- a/packages/react/src/hooks/useHighlighting.mjs +++ b/packages/react/src/hooks/useHighlighting.mjs @@ -20,10 +20,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, highlights.current); // <- new method, replaces above line when done + highlightMiniLocations(view, highlights.current); } catch (err) { - view.dispatch({ effects: setHighlights.of({ haps: [] }) }); + highlightMiniLocations(view, []) } frame = requestAnimationFrame(updateHighlights); }); @@ -31,8 +30,7 @@ function useHighlighting({ view, pattern, active, getTime }) { cancelAnimationFrame(frame); }; } else { - highlights.current = []; - view.dispatch({ effects: setHighlights.of({ haps: [] }) }); + highlightMiniLocations(view, []); } } }, [pattern, active, view]); From f5b092acf283f7aef886485ad19ebb2d19bc54ee Mon Sep 17 00:00:00 2001 From: Matthew Kaney Date: Mon, 3 Jul 2023 17:06:39 -0400 Subject: [PATCH 11/19] Split up highlighting logic and add progress flash --- packages/react/src/components/CodeMirror6.jsx | 155 +++++++++++------- packages/react/src/hooks/useHighlighting.mjs | 9 +- 2 files changed, 100 insertions(+), 64 deletions(-) diff --git a/packages/react/src/components/CodeMirror6.jsx b/packages/react/src/components/CodeMirror6.jsx index f4ce3a07..00daf7d0 100644 --- a/packages/react/src/components/CodeMirror6.jsx +++ b/packages/react/src/components/CodeMirror6.jsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react'; import _CodeMirror from '@uiw/react-codemirror'; import { EditorView, Decoration } from '@codemirror/view'; -import { StateField, StateEffect } from '@codemirror/state'; +import { StateField, StateEffect, RangeSetBuilder } from '@codemirror/state'; import { javascript, javascriptLanguage } from '@codemirror/lang-javascript'; import strudelTheme from '../themes/strudel-theme'; import './style.css'; @@ -49,8 +49,8 @@ export const showMiniLocations = StateEffect.define(); export const updateMiniLocations = (view, locations) => { view.dispatch({ effects: setMiniLocations.of(locations) }); }; -export const highlightMiniLocations = (view, haps) => { - view.dispatch({ effects: showMiniLocations.of(haps) }); +export const highlightMiniLocations = (view, atTime, haps) => { + view.dispatch({ effects: showMiniLocations.of({ atTime, haps }) }); }; const miniLocations = StateField.define({ @@ -58,81 +58,115 @@ const miniLocations = StateField.define({ return Decoration.none; }, update(locations, tr) { - locations = locations.map(tr.changes); + 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.map( - (range) => - Decoration.mark({ - range, - // 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` }, - }), // -> Decoration - ); - // - const decorations = marks - .map((mark) => { - let { range } = mark.spec; - range = range.map((v) => Math.min(v, tr.newDoc.length)); - const [from, to] = range; - if (from < to) { - return mark.range(from, to); // -> Range - } - }) - .filter(Boolean); - locations = Decoration.set(decorations, true); // -> DecorationSet === RangeSet + 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 visible = e.value - .map((hap) => hap.context.locations.map(({ start, end }) => `${start}:${end}`)) - .flat() - .filter((v, i, a) => a.indexOf(v) === i); - // console.log('visible', visible); // e.g. [ "1:3", "8:9", "4:6" ] + const haps = new Map(); - const iterator = locations.iter(); - - let mapping = {}; - while (!!iterator.value) { - const { - from, - to, - value: { - spec: { range }, - }, - } = iterator; - const id = `${range[0]}:${range[1]}`; - mapping[id] = [from, to]; - iterator.next(); + 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); + } + } } - const decorations = Object.entries(mapping) - .map(([range, [from, to]]) => { - let color = visible.includes(range) ? 'red' : 'transparent'; - const mark = Decoration.mark({ - range: range.split(':'), - // 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: ${color}` }, - }); - return mark.range(from, to); // -> Range - }) - .filter(Boolean); - locations = Decoration.set(decorations); + visible = { atTime: e.value.atTime, haps }; } } - return locations; + + return visible; }, - provide: (f) => EditorView.decorations.from(f), }); +// // 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 { atTime, 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(', ')})` }, + }), + ); + } + + iterator.next(); + } + + return builder.finish(); +}); + +const highlightExtension = [miniLocations, visibleMiniLocations, miniLocationHighlights]; + export const setHighlights = StateEffect.define(); const highlightField = StateField.define({ create() { @@ -149,6 +183,7 @@ const highlightField = StateField.define({ .map((hap) => (hap.context.locations || []).map(({ start: from, end: to }) => { const color = hap.context.color || e.value.color; + console.log(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; @@ -178,7 +213,7 @@ const highlightField = StateField.define({ provide: (f) => EditorView.decorations.from(f), }); -const staticExtensions = [javascript(), highlightField, flashField, miniLocations]; +const staticExtensions = [javascript(), highlightField, flashField, highlightExtension]; export default function CodeMirror({ value, diff --git a/packages/react/src/hooks/useHighlighting.mjs b/packages/react/src/hooks/useHighlighting.mjs index 5881269c..e0cf3b79 100644 --- a/packages/react/src/hooks/useHighlighting.mjs +++ b/packages/react/src/hooks/useHighlighting.mjs @@ -1,5 +1,5 @@ import { useEffect, useRef } from 'react'; -import { setHighlights, highlightMiniLocations } from '../components/CodeMirror6'; +import { highlightMiniLocations } from '../components/CodeMirror6'; const round = (x) => Math.round(x * 1000) / 1000; function useHighlighting({ view, pattern, active, getTime }) { @@ -20,9 +20,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 - highlightMiniLocations(view, highlights.current); + highlightMiniLocations(view, begin, highlights.current); } catch (err) { - highlightMiniLocations(view, []) + highlightMiniLocations(view, 0, []) } frame = requestAnimationFrame(updateHighlights); }); @@ -30,7 +30,8 @@ function useHighlighting({ view, pattern, active, getTime }) { cancelAnimationFrame(frame); }; } else { - highlightMiniLocations(view, []); + highlights.current = []; + highlightMiniLocations(view, 0, highlights.current); } } }, [pattern, active, view]); From 78770888a58a0c29c2e87531d0acf5c3ebf072c3 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Tue, 4 Jul 2023 18:16:58 +0200 Subject: [PATCH 12/19] remove mock positions --- website/src/repl/Repl.jsx | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/website/src/repl/Repl.jsx b/website/src/repl/Repl.jsx index fdbfa7db..9f54a51e 100644 --- a/website/src/repl/Repl.jsx +++ b/website/src/repl/Repl.jsx @@ -218,29 +218,6 @@ export function Repl({ embedded = false }) { // console.log('selectino change', selection.ranges[0].from); }, []); - const handleDocChanged = useCallback( - ({ view }) => { - if (!init) { - // this is only for testing! try this pattern: - /* -stack( - s("bd"), - s("hh oh*<2 3>") -) - */ - updateMiniLocations(view, [ - [12, 14], - [23, 25], - [26, 28], - [30, 31], - [32, 33], - ]); - init = true; - } - }, - [view], - ); - const handleTogglePlay = async () => { await getAudioContext().resume(); // fixes no sound in ios webkit if (!started) { @@ -335,7 +312,6 @@ stack( onChange={handleChangeCode} onViewChanged={handleViewChanged} onSelectionChange={handleSelectionChange} - onDocChange={handleDocChanged} /> {error && ( From 3fc5bb31d03b8bc4685eb838523466a55198c64d Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Tue, 4 Jul 2023 18:22:22 +0200 Subject: [PATCH 13/19] fix: do not recreate haps mapping on update --- packages/react/src/components/CodeMirror6.jsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/react/src/components/CodeMirror6.jsx b/packages/react/src/components/CodeMirror6.jsx index 00daf7d0..e1f55014 100644 --- a/packages/react/src/components/CodeMirror6.jsx +++ b/packages/react/src/components/CodeMirror6.jsx @@ -98,18 +98,17 @@ const visibleMiniLocations = StateField.define({ // 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); + if (!visible.haps.has(id) || visible.haps.get(id).whole.begin.lt(hap.whole.begin)) { + visible.haps.set(id, hap); } } } - visible = { atTime: e.value.atTime, haps }; + visible = { atTime: e.value.atTime, haps: visible.haps }; } } From dacd9afac06dff044beef751d4febd6f8faa1508 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Tue, 4 Jul 2023 18:34:22 +0200 Subject: [PATCH 14/19] comment out opacity logic for now --- packages/react/src/components/CodeMirror6.jsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/react/src/components/CodeMirror6.jsx b/packages/react/src/components/CodeMirror6.jsx index e1f55014..2041456f 100644 --- a/packages/react/src/components/CodeMirror6.jsx +++ b/packages/react/src/components/CodeMirror6.jsx @@ -98,17 +98,17 @@ const visibleMiniLocations = StateField.define({ // 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 (!visible.haps.has(id) || visible.haps.get(id).whole.begin.lt(hap.whole.begin)) { - visible.haps.set(id, hap); + if (!haps.has(id) || haps.get(id).whole.begin.lt(hap.whole.begin)) { + haps.set(id, hap); } } } - visible = { atTime: e.value.atTime, haps: visible.haps }; + visible = { atTime: e.value.atTime, haps }; } } @@ -136,6 +136,7 @@ const miniLocationHighlights = EditorView.decorations.compute([miniLocations, vi 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); @@ -148,12 +149,14 @@ const miniLocationHighlights = EditorView.decorations.compute([miniLocations, vi // 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 rgba(${channels.join(', ')})` }, + attributes: { style: `outline: solid 2px ${color}` }, }), ); } From 7f12ce9b458e0668318296eadda32cd825a38364 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Tue, 4 Jul 2023 18:47:44 +0200 Subject: [PATCH 15/19] fix: format --- packages/react/src/hooks/useHighlighting.mjs | 2 +- packages/transpiler/test/transpiler.test.mjs | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/react/src/hooks/useHighlighting.mjs b/packages/react/src/hooks/useHighlighting.mjs index e0cf3b79..56136369 100644 --- a/packages/react/src/hooks/useHighlighting.mjs +++ b/packages/react/src/hooks/useHighlighting.mjs @@ -22,7 +22,7 @@ function useHighlighting({ view, pattern, active, getTime }) { highlights.current = highlights.current.concat(haps); // add potential new onsets highlightMiniLocations(view, begin, highlights.current); } catch (err) { - highlightMiniLocations(view, 0, []) + highlightMiniLocations(view, 0, []); } frame = requestAnimationFrame(updateHighlights); }); diff --git a/packages/transpiler/test/transpiler.test.mjs b/packages/transpiler/test/transpiler.test.mjs index c65b014a..1d14986d 100644 --- a/packages/transpiler/test/transpiler.test.mjs +++ b/packages/transpiler/test/transpiler.test.mjs @@ -12,9 +12,7 @@ const simple = { wrapAsync: false, addReturn: false, simpleLocs: true }; describe('transpiler', () => { it('wraps double quote string with mini and adds location', () => { 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));", - ); + 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).output).toEqual("m('c3', 0);"); From 34176ab5f8caa547630a00cc913c38fd8d4e8f84 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Tue, 4 Jul 2023 21:49:39 +0200 Subject: [PATCH 16/19] refactor: - dedupe flash / highlighting logic - codemirror logic now lives only in codemirror package - remove old highlighting logic - use codemirror package in react package - cleanup CodeMirror6.jsx - pull setMiniLocations into useHighlighting - migrate MiniRepl, nano-repl + Repl to new highlighting --- packages/codemirror/codemirror.mjs | 106 +----- packages/codemirror/flash.mjs | 35 ++ packages/codemirror/highlight.mjs | 126 +++++++ packages/codemirror/index.mjs | 3 + packages/codemirror/package.json | 2 +- packages/codemirror/vite.config.js | 2 +- packages/react/examples/nano-repl/src/App.jsx | 3 +- packages/react/package.json | 1 + packages/react/src/components/CodeMirror6.jsx | 333 +----------------- packages/react/src/components/MiniRepl.jsx | 3 +- packages/react/src/hooks/useHighlighting.mjs | 15 +- pnpm-lock.yaml | 3 + website/src/repl/Repl.jsx | 9 +- 13 files changed, 215 insertions(+), 426 deletions(-) create mode 100644 packages/codemirror/flash.mjs create mode 100644 packages/codemirror/highlight.mjs create mode 100644 packages/codemirror/index.mjs 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/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 2041456f..0f1b2274 100644 --- a/packages/react/src/components/CodeMirror6.jsx +++ b/packages/react/src/components/CodeMirror6.jsx @@ -1,221 +1,24 @@ -import React, { useMemo } from 'react'; -import _CodeMirror from '@uiw/react-codemirror'; -import { EditorView, Decoration } from '@codemirror/view'; -import { StateField, StateEffect, RangeSetBuilder } 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 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 { atTime, 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(); -}); - -const highlightExtension = [miniLocations, visibleMiniLocations, miniLocationHighlights]; - -export const setHighlights = StateEffect.define(); -const highlightField = StateField.define({ - create() { - return Decoration.none; - }, - update(highlights, tr) { - highlights = highlights.map(tr.changes); - 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: from, end: to }) => { - const color = hap.context.color || e.value.color; - console.log(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, highlightExtension]; +const staticExtensions = [javascript(), flashField, highlightExtension]; export default function CodeMirror({ value, @@ -230,8 +33,6 @@ export default function CodeMirror({ isLineWrappingEnabled, fontSize = 18, fontFamily = 'monospace', - options, - editorDidMount, }) { const handleOnChange = useCallback( (value) => { @@ -299,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 56136369..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 { highlightMiniLocations } 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) { @@ -30,11 +38,14 @@ function useHighlighting({ view, pattern, active, getTime }) { cancelAnimationFrame(frame); }; } else { + console.log('not active'); highlights.current = []; highlightMiniLocations(view, 0, highlights.current); } } }, [pattern, active, view]); + + return { setMiniLocations }; } export default useHighlighting; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7017134..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) diff --git a/website/src/repl/Repl.jsx b/website/src/repl/Repl.jsx index 9f54a51e..878c77ea 100644 --- a/website/src/repl/Repl.jsx +++ b/website/src/repl/Repl.jsx @@ -126,13 +126,6 @@ export function Repl({ embedded = false }) { isLineWrappingEnabled, } = useSettings(); - const [miniLocations, setMiniLocations] = useState([]); - useEffect(() => { - if (view) { - updateMiniLocations(view, miniLocations); - } - }, [view, miniLocations]); - const { code, setCode, scheduler, evaluate, activateCode, isDirty, activeCode, pattern, started, stop, error } = useStrudel({ initialCode: '// LOADING...', @@ -195,7 +188,7 @@ export function Repl({ embedded = false }) { ); // highlighting - useHighlighting({ + const { setMiniLocations } = useHighlighting({ view, pattern, active: started && !activeCode?.includes('strudel disable-highlighting'), From 66f8ca72c1b364f72f25b8cff041cac13b380432 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Tue, 4 Jul 2023 22:13:40 +0200 Subject: [PATCH 17/19] refactor: remove old location methods docs: update repl chapter to reflect new transpiler behavior --- packages/core/pattern.mjs | 46 ------------------- .../src/pages/technical-manual/internals.mdx | 8 +--- website/src/pages/technical-manual/repl.mdx | 28 ++++++----- 3 files changed, 18 insertions(+), 64 deletions(-) diff --git a/packages/core/pattern.mjs b/packages/core/pattern.mjs index f7cb6ce8..b234c9d1 100644 --- a/packages/core/pattern.mjs +++ b/packages/core/pattern.mjs @@ -488,52 +488,6 @@ export class Pattern { }); } - /** - * Deprecated: Returns a new pattern with the given location information added to the - * context of every hap. - * @param {Number} start - * @param {Number} end - * @returns Pattern - * @noAutocomplete - */ - withLocation(start, end) { - const location = { - start: { line: start[0], column: start[1], offset: start[2] }, - end: { line: end[0], column: end[1], offset: end[2] }, - }; - return this.withContext((context) => { - const locations = (context.locations || []).concat([location]); - return { ...context, locations }; - }); - } - - // DEPRECATED: - 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/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 From 64693ffd26e85a734cdd2e8280b96b6319e93daf Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Tue, 4 Jul 2023 22:16:02 +0200 Subject: [PATCH 18/19] fix: remove test flag --- website/src/repl/Repl.jsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/website/src/repl/Repl.jsx b/website/src/repl/Repl.jsx index 878c77ea..29fa2c16 100644 --- a/website/src/repl/Repl.jsx +++ b/website/src/repl/Repl.jsx @@ -109,8 +109,6 @@ const { code: randomTune, name } = getRandomTune(); export const ReplContext = createContext(null); -let init = false; // this is bad! only for testing! - export function Repl({ embedded = false }) { const isEmbedded = embedded || window.location !== window.parent.location; const [view, setView] = useState(); // codemirror view From 6e4c873248c8f0cb351006194e710f377684a8b5 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Tue, 4 Jul 2023 22:17:44 +0200 Subject: [PATCH 19/19] remove tests from soon removed package --- packages/eval/test/evaluate.test.mjs | 32 ------------------------ packages/eval/test/shapeshifter.test.mjs | 27 -------------------- 2 files changed, 59 deletions(-) delete mode 100644 packages/eval/test/evaluate.test.mjs delete mode 100644 packages/eval/test/shapeshifter.test.mjs 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 a76c2aae..00000000 --- a/packages/eval/test/shapeshifter.test.mjs +++ /dev/null @@ -1,27 +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"').output).toEqual( - '(async()=>{return mini("c3").withMiniLocation([1,0,15],[1,4,19])})()', - ); - } else { - expect(shapeshifter('"c3"').output).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"').output).toEqual( - 'const{default:foo}=await import("https://bar.com/foo.js");return mini("c3").withMiniLocation([1,64,79],[1,68,83])', - ); - }); - } -});