Merge pull request #1024 from tidalcycles/fix-draw-bugs

better theme integration for visuals + various fixes
This commit is contained in:
Felix Roos 2024-03-28 11:37:56 +01:00 committed by GitHub
commit aa25265cc2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 119 additions and 107 deletions

View File

@ -128,6 +128,7 @@ export class StrudelMirror {
id, id,
initialCode = '', initialCode = '',
onDraw, onDraw,
drawContext,
drawTime = [0, 0], drawTime = [0, 0],
autodraw, autodraw,
prebake, prebake,
@ -140,13 +141,14 @@ export class StrudelMirror {
this.widgets = []; this.widgets = [];
this.painters = []; this.painters = [];
this.drawTime = drawTime; this.drawTime = drawTime;
this.onDraw = onDraw; this.drawContext = drawContext;
this.onDraw = onDraw || this.draw;
this.id = id || s4(); this.id = id || s4();
this.drawer = new Drawer((haps, time) => { this.drawer = new Drawer((haps, time) => {
const currentFrame = haps.filter((hap) => time >= hap.whole.begin && time <= hap.endClipped); const currentFrame = haps.filter((hap) => hap.isActive(time));
this.highlight(currentFrame, time); this.highlight(currentFrame, time);
this.onDraw?.(haps, time, currentFrame, this.painters); this.onDraw(haps, time, this.painters);
}, drawTime); }, drawTime);
this.prebaked = prebake(); this.prebaked = prebake();
@ -236,6 +238,9 @@ export class StrudelMirror {
// when no painters are set, [0,0] is enough (just highlighting) // when no painters are set, [0,0] is enough (just highlighting)
this.drawer.setDrawTime(this.painters.length ? this.drawTime : [0, 0]); this.drawer.setDrawTime(this.painters.length ? this.drawTime : [0, 0]);
} }
draw(haps, time) {
this.painters?.forEach((painter) => painter(this.drawContext, time, haps, this.drawTime));
}
async drawFirstFrame() { async drawFirstFrame() {
if (!this.onDraw) { if (!this.onDraw) {
return; return;
@ -246,7 +251,7 @@ export class StrudelMirror {
await this.repl.evaluate(this.code, false); await this.repl.evaluate(this.code, false);
this.drawer.invalidate(this.repl.scheduler, -0.001); this.drawer.invalidate(this.repl.scheduler, -0.001);
// draw at -0.001 to avoid haps at 0 to be visualized as active // draw at -0.001 to avoid haps at 0 to be visualized as active
this.onDraw?.(this.drawer.visibleHaps, -0.001, [], this.painters); this.onDraw?.(this.drawer.visibleHaps, -0.001, this.painters);
} catch (err) { } catch (err) {
console.warn('first frame could not be painted'); console.warn('first frame could not be painted');
} }

View File

@ -37,6 +37,7 @@ import whitescreen, { settings as whitescreenSettings } from './themes/whitescre
import teletext, { settings as teletextSettings } from './themes/teletext'; import teletext, { settings as teletextSettings } from './themes/teletext';
import algoboy, { settings as algoboySettings } from './themes/algoboy'; import algoboy, { settings as algoboySettings } from './themes/algoboy';
import terminal, { settings as terminalSettings } from './themes/terminal'; import terminal, { settings as terminalSettings } from './themes/terminal';
import { setTheme } from '@strudel/draw';
export const themes = { export const themes = {
strudelTheme, strudelTheme,
@ -513,6 +514,7 @@ export function activateTheme(name) {
.map(([key, value]) => `--${key}: ${value} !important;`) .map(([key, value]) => `--${key}: ${value} !important;`)
.join('\n')} .join('\n')}
}`; }`;
setTheme(themeSettings);
// tailwind dark mode // tailwind dark mode
if (themeSettings.light) { if (themeSettings.light) {
document.documentElement.classList.remove('dark'); document.documentElement.classList.remove('dark');

View File

@ -106,23 +106,23 @@ function getCanvasWidget(id, options = {}) {
registerWidget('_pianoroll', (id, options = {}, pat) => { registerWidget('_pianoroll', (id, options = {}, pat) => {
const ctx = getCanvasWidget(id, options).getContext('2d'); const ctx = getCanvasWidget(id, options).getContext('2d');
return pat.id(id).pianoroll({ fold: 1, ...options, ctx, id }); return pat.tag(id).pianoroll({ fold: 1, ...options, ctx, id });
}); });
registerWidget('_punchcard', (id, options = {}, pat) => { registerWidget('_punchcard', (id, options = {}, pat) => {
const ctx = getCanvasWidget(id, options).getContext('2d'); const ctx = getCanvasWidget(id, options).getContext('2d');
return pat.id(id).punchcard({ fold: 1, ...options, ctx, id }); return pat.tag(id).punchcard({ fold: 1, ...options, ctx, id });
}); });
registerWidget('_spiral', (id, options = {}, pat) => { registerWidget('_spiral', (id, options = {}, pat) => {
let _size = options.size || 275; let _size = options.size || 275;
options = { width: _size, height: _size, ...options, size: _size / 5 }; options = { width: _size, height: _size, ...options, size: _size / 5 };
const ctx = getCanvasWidget(id, options).getContext('2d'); const ctx = getCanvasWidget(id, options).getContext('2d');
return pat.id(id).spiral({ ...options, ctx, id }); return pat.tag(id).spiral({ ...options, ctx, id });
}); });
registerWidget('_scope', (id, options = {}, pat) => { registerWidget('_scope', (id, options = {}, pat) => {
options = { width: 500, height: 60, pos: 0.5, scale: 1, ...options }; options = { width: 500, height: 60, pos: 0.5, scale: 1, ...options };
const ctx = getCanvasWidget(id, options).getContext('2d'); const ctx = getCanvasWidget(id, options).getContext('2d');
return pat.id(id).scope({ ...options, ctx, id }); return pat.tag(id).scope({ ...options, ctx, id });
}); });

View File

@ -67,6 +67,9 @@ export class Cyclist {
); );
} }
now() { now() {
if (!this.started) {
return 0;
}
const secondsSinceLastTick = this.getTime() - this.lastTick - this.clock.duration; const secondsSinceLastTick = this.getTime() - this.lastTick - this.clock.duration;
return this.lastBegin + secondsSinceLastTick * this.cps; // + this.clock.minLatency; return this.lastBegin + secondsSinceLastTick * this.cps; // + this.clock.minLatency;
} }

View File

@ -49,6 +49,27 @@ export class Hap {
return this.whole.begin.add(this.duration); return this.whole.begin.add(this.duration);
} }
isActive(currentTime) {
return this.whole.begin <= currentTime && this.endClipped >= currentTime;
}
isInPast(currentTime) {
return currentTime > this.endClipped;
}
isInNearPast(margin, currentTime) {
return currentTime - margin <= this.endClipped;
}
isInFuture(currentTime) {
return currentTime < this.whole.begin;
}
isInNearFuture(margin, currentTime) {
return currentTime < this.whole.begin && currentTime > this.whole.begin - margin;
}
isWithinTime(min, max) {
return this.whole.begin <= max && this.endClipped >= min;
}
wholeOrPart() { wholeOrPart() {
return this.whole ? this.whole : this.part; return this.whole ? this.whole : this.part;
} }
@ -70,6 +91,10 @@ export class Hap {
return this.whole != undefined && this.whole.begin.equals(this.part.begin); return this.whole != undefined && this.whole.begin.equals(this.part.begin);
} }
hasTag(tag) {
return this.context.tags?.includes(tag);
}
resolveState(state) { resolveState(state) {
if (this.stateful && this.hasOnset()) { if (this.stateful && this.hasOnset()) {
console.log('stateful'); console.log('stateful');

View File

@ -2474,13 +2474,13 @@ export const hsl = register('hsl', (h, s, l, pat) => {
}); });
/** /**
* Sets the id of the hap in, for filtering in the future. * Tags each Hap with an identifier. Good for filtering. The function populates Hap.context.tags (Array).
* @name id * @name tag
* @noAutocomplete * @noAutocomplete
* @param {string} id anything unique * @param {string} tag anything unique
*/ */
Pattern.prototype.id = function (id) { Pattern.prototype.tag = function (tag) {
return this.withContext((ctx) => ({ ...ctx, id })); return this.withContext((ctx) => ({ ...ctx, tags: (ctx.tags || []).concat([tag]) }));
}; };
////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////

View File

@ -39,51 +39,36 @@ function stopAnimationFrame(id) {
function stopAllAnimations() { function stopAllAnimations() {
Object.keys(animationFrames).forEach((id) => stopAnimationFrame(id)); Object.keys(animationFrames).forEach((id) => stopAnimationFrame(id));
} }
Pattern.prototype.draw = function (callback, { id = 'std', from, to, onQuery, ctx } = {}) {
if (typeof window === 'undefined') {
return this;
}
stopAnimationFrame(id);
ctx = ctx || getDrawContext();
let cycle,
events = [];
const animate = (time) => {
const t = getTime();
if (from !== undefined && to !== undefined) {
const currentCycle = Math.floor(t);
if (cycle !== currentCycle) {
cycle = currentCycle;
const begin = currentCycle + from;
const end = currentCycle + to;
setTimeout(() => {
events = this.query(new State(new TimeSpan(begin, end)))
.filter(Boolean)
.filter((event) => event.part.begin.equals(event.whole.begin));
onQuery?.(events);
}, 0);
}
}
callback(ctx, events, t, time);
animationFrames[id] = requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
return this;
};
// this is a more generic helper to get a rendering callback for the currently active haps let memory = {};
// TODO: this misses events that are prolonged with clip or duration (would need state) Pattern.prototype.draw = function (fn, options) {
Pattern.prototype.onFrame = function (id, fn, offset = 0) {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return this; return this;
} }
let { id = 1, lookbehind = 0, lookahead = 0 } = options;
let __t = Math.max(getTime(), 0);
stopAnimationFrame(id); stopAnimationFrame(id);
lookbehind = Math.abs(lookbehind);
// init memory, clear future haps of old pattern
memory[id] = (memory[id] || []).filter((h) => !h.isInFuture(__t));
let newFuture = this.queryArc(__t, __t + lookahead).filter((h) => h.hasOnset());
memory[id] = memory[id].concat(newFuture);
let last;
const animate = () => { const animate = () => {
const t = getTime() + offset; const _t = getTime();
const haps = this.queryArc(t, t); const t = _t + lookahead;
fn(haps, t, this); // filter out haps that are too far in the past
memory[id] = memory[id].filter((h) => h.isInNearPast(lookbehind, _t));
// begin where we left off in last frame, but max -0.1s (inactive tab throttles to 1fps)
let begin = Math.max(last || t, t - 1 / 10);
const haps = this.queryArc(begin, t).filter((h) => h.hasOnset());
memory[id] = memory[id].concat(haps);
last = t; // makes sure no haps are missed
fn(memory[id], _t, t, this);
animationFrames[id] = requestAnimationFrame(animate); animationFrames[id] = requestAnimationFrame(animate);
}; };
requestAnimationFrame(animate); animationFrames[id] = requestAnimationFrame(animate);
return this; return this;
}; };
@ -198,3 +183,18 @@ export class Drawer {
} }
} }
} }
export function getComputedPropertyValue(name) {
if (typeof window === 'undefined') {
return '#fff';
}
return getComputedStyle(document.documentElement).getPropertyValue(name);
}
let theme = {};
export function getTheme() {
return theme;
}
export function setTheme(_theme) {
theme = _theme;
}

View File

@ -5,6 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th
*/ */
import { Pattern, noteToMidi, freqToMidi } from '@strudel/core'; import { Pattern, noteToMidi, freqToMidi } from '@strudel/core';
import { getTheme, getDrawContext } from './draw.mjs';
const scale = (normalized, min, max) => normalized * (max - min) + min; const scale = (normalized, min, max) => normalized * (max - min) + min;
const getValue = (e) => { const getValue = (e) => {
@ -36,26 +37,23 @@ const getValue = (e) => {
}; };
Pattern.prototype.pianoroll = function (options = {}) { Pattern.prototype.pianoroll = function (options = {}) {
let { cycles = 4, playhead = 0.5, overscan = 1, hideNegative = false, ctx, id } = options; let { cycles = 4, playhead = 0.5, overscan = 0, hideNegative = false, ctx = getDrawContext(), id = 1 } = options;
let from = -cycles * playhead; let from = -cycles * playhead;
let to = cycles * (1 - playhead); let to = cycles * (1 - playhead);
const inFrame = (hap, t) => (!hideNegative || hap.whole.begin >= 0) && hap.isWithinTime(t + from, t + to);
this.draw( this.draw(
(ctx, haps, t) => { (haps, time) => {
const inFrame = (event) =>
(!hideNegative || event.whole.begin >= 0) && event.whole.begin <= t + to && event.endClipped >= t + from;
pianoroll({ pianoroll({
...options, ...options,
time: t, time,
ctx, ctx,
haps: haps.filter(inFrame), haps: haps.filter((hap) => inFrame(hap, time)),
}); });
}, },
{ {
from: from - overscan, lookbehind: from - overscan,
to: to + overscan, lookahead: to + overscan,
ctx,
id, id,
}, },
); );
@ -106,11 +104,8 @@ export function pianoroll({
flipTime = 0, flipTime = 0,
flipValues = 0, flipValues = 0,
hideNegative = false, hideNegative = false,
// inactive = '#C9E597', inactive = getTheme().foreground,
// inactive = '#FFCA28', active = getTheme().foreground,
inactive = '#7491D2',
active = '#FFCA28',
// background = '#2A3236',
background = 'transparent', background = 'transparent',
smear = 0, smear = 0,
playheadColor = 'white', playheadColor = 'white',
@ -137,7 +132,7 @@ export function pianoroll({
let to = cycles * (1 - playhead); let to = cycles * (1 - playhead);
if (id) { if (id) {
haps = haps.filter((hap) => hap.context.id === id); haps = haps.filter((hap) => hap.hasTag(id));
} }
if (timeframeProp) { if (timeframeProp) {
@ -277,8 +272,8 @@ export function getDrawOptions(drawTime, options = {}) {
export const getPunchcardPainter = export const getPunchcardPainter =
(options = {}) => (options = {}) =>
(ctx, time, haps, drawTime, paintOptions = {}) => (ctx, time, haps, drawTime) =>
pianoroll({ ctx, time, haps, ...getDrawOptions(drawTime, { ...paintOptions, ...options }) }); pianoroll({ ctx, time, haps, ...getDrawOptions(drawTime, options) });
Pattern.prototype.punchcard = function (options) { Pattern.prototype.punchcard = function (options) {
return this.onPaint(getPunchcardPainter(options)); return this.onPaint(getPunchcardPainter(options));

View File

@ -1,4 +1,5 @@
import { Pattern } from '@strudel/core'; import { Pattern } from '@strudel/core';
import { getTheme } from './draw.mjs';
// polar coords -> xy // polar coords -> xy
function fromPolar(angle, radius, cx, cy) { function fromPolar(angle, radius, cx, cy) {
@ -19,7 +20,7 @@ function spiralSegment(options) {
cy = 100, cy = 100,
rotate = 0, rotate = 0,
thickness = margin / 2, thickness = margin / 2,
color = 'steelblue', color = getTheme().foreground,
cap = 'round', cap = 'round',
stretch = 1, stretch = 1,
fromOpacity = 1, fromOpacity = 1,
@ -61,7 +62,8 @@ function drawSpiral(options) {
playheadThickness = thickness, playheadThickness = thickness,
padding = 0, padding = 0,
steady = 1, steady = 1,
inactiveColor = '#ffffff50', activeColor = getTheme().foreground,
inactiveColor = getTheme().gutterForeground,
colorizeInactive = 0, colorizeInactive = 0,
fade = true, fade = true,
// logSpiral = true, // logSpiral = true,
@ -73,7 +75,7 @@ function drawSpiral(options) {
} = options; } = options;
if (id) { if (id) {
haps = haps.filter((hap) => hap.context.id === id); haps = haps.filter((hap) => hap.hasTag(id));
} }
const [w, h] = [ctx.canvas.width, ctx.canvas.height]; const [w, h] = [ctx.canvas.width, ctx.canvas.height];
@ -102,7 +104,8 @@ function drawSpiral(options) {
const isActive = hap.whole.begin <= time && hap.endClipped > time; const isActive = hap.whole.begin <= time && hap.endClipped > time;
const from = hap.whole.begin - time + inset; const from = hap.whole.begin - time + inset;
const to = hap.endClipped - time + inset - padding; const to = hap.endClipped - time + inset - padding;
const color = hap.value?.color; const hapColor = hap.value?.color || activeColor;
const color = colorizeInactive || isActive ? hapColor : inactiveColor;
const opacity = fade ? 1 - Math.abs((hap.whole.begin - time) / min) : 1; const opacity = fade ? 1 - Math.abs((hap.whole.begin - time) / min) : 1;
spiralSegment({ spiralSegment({
ctx, ctx,
@ -110,7 +113,7 @@ function drawSpiral(options) {
from, from,
to, to,
rotate, rotate,
color: colorizeInactive || isActive ? color : inactiveColor, color,
fromOpacity: opacity, fromOpacity: opacity,
toOpacity: opacity, toOpacity: opacity,
}); });

View File

@ -41,17 +41,8 @@ if (typeof HTMLElement !== 'undefined') {
initialCode: '// LOADING', initialCode: '// LOADING',
pattern: silence, pattern: silence,
drawTime, drawTime,
onDraw: (haps, time, frame, painters) => { drawContext,
painters.length && drawContext.clearRect(0, 0, drawContext.canvas.width * 2, drawContext.canvas.height * 2);
painters?.forEach((painter) => {
// ctx time haps drawTime paintOptions
painter(drawContext, time, haps, drawTime, { clear: false });
});
},
prebake, prebake,
afterEval: ({ code }) => {
// window.location.hash = '#' + code2hash(code);
},
onUpdateState: (state) => { onUpdateState: (state) => {
const event = new CustomEvent('update', { const event = new CustomEvent('update', {
detail: state, detail: state,

View File

@ -53,10 +53,12 @@ export function transpiler(input, options = {}) {
return this.replace(sliderWithLocation(node)); return this.replace(sliderWithLocation(node));
} }
if (isWidgetMethod(node)) { if (isWidgetMethod(node)) {
const type = node.callee.property.name;
const index = widgets.filter((w) => w.type === type).length;
const widgetConfig = { const widgetConfig = {
to: node.end, to: node.end,
index: widgets.length, index,
type: node.callee.property.name, type,
}; };
emitWidgets && widgets.push(widgetConfig); emitWidgets && widgets.push(widgetConfig);
return this.replace(widgetWithLocation(node, widgetConfig)); return this.replace(widgetWithLocation(node, widgetConfig));

View File

@ -1,5 +1,5 @@
import { Pattern, clamp } from '@strudel/core'; import { Pattern, clamp } from '@strudel/core';
import { getDrawContext } from '../draw/index.mjs'; import { getDrawContext, getTheme } from '@strudel/draw';
import { analysers, getAnalyzerData } from 'superdough'; import { analysers, getAnalyzerData } from 'superdough';
export function drawTimeScope( export function drawTimeScope(
@ -132,10 +132,13 @@ Pattern.prototype.fscope = function (config = {}) {
* @example * @example
* s("sawtooth").scope() * s("sawtooth").scope()
*/ */
let latestColor = {};
Pattern.prototype.tscope = function (config = {}) { Pattern.prototype.tscope = function (config = {}) {
let id = config.id ?? 1; let id = config.id ?? 1;
return this.analyze(id).draw( return this.analyze(id).draw(
() => { (haps) => {
config.color = haps[0]?.value?.color || getTheme().foreground;
latestColor[id] = config.color;
clearScreen(config.smear, '0,0,0', config.ctx); clearScreen(config.smear, '0,0,0', config.ctx);
drawTimeScope(analysers[id], config); drawTimeScope(analysers[id], config);
}, },

View File

@ -39,16 +39,6 @@ export function MiniRepl({
const init = useCallback(({ code, shouldDraw }) => { const init = useCallback(({ code, shouldDraw }) => {
const drawContext = shouldDraw ? document.querySelector('#' + canvasId)?.getContext('2d') : null; const drawContext = shouldDraw ? document.querySelector('#' + canvasId)?.getContext('2d') : null;
let onDraw;
if (shouldDraw) {
onDraw = (haps, time, frame, painters) => {
painters.length && drawContext?.clearRect(0, 0, drawContext.canvas.width * 2, drawContext.canvas.height * 2);
painters?.forEach((painter) => {
// ctx time haps drawTime paintOptions
painter(drawContext, time, haps, drawTime, { clear: false });
});
};
}
const editor = new StrudelMirror({ const editor = new StrudelMirror({
id, id,
@ -60,7 +50,7 @@ export function MiniRepl({
initialCode: '// LOADING', initialCode: '// LOADING',
pattern: silence, pattern: silence,
drawTime, drawTime,
onDraw, drawContext,
editPattern: (pat, id) => { editPattern: (pat, id) => {
if (onTrigger) { if (onTrigger) {
pat = pat.onTrigger(onTrigger, false); pat = pat.onTrigger(onTrigger, false);

View File

@ -65,15 +65,8 @@ export function Repl({ embedded = false }) {
const init = useCallback(() => { const init = useCallback(() => {
const drawTime = [-2, 2]; const drawTime = [-2, 2];
const drawContext = getDrawContext(); const drawContext = getDrawContext();
const onDraw = (haps, time, frame, painters) => {
painters.length && drawContext.clearRect(0, 0, drawContext.canvas.width * 2, drawContext.canvas.height * 2);
painters?.forEach((painter) => {
// ctx time haps drawTime paintOptions
painter(drawContext, time, haps, drawTime, { clear: false });
});
};
const editor = new StrudelMirror({ const editor = new StrudelMirror({
sync: true, sync: false,
defaultOutput: webaudioOutput, defaultOutput: webaudioOutput,
getTime: () => getAudioContext().currentTime, getTime: () => getAudioContext().currentTime,
setInterval, setInterval,
@ -84,7 +77,7 @@ export function Repl({ embedded = false }) {
initialCode: '// LOADING', initialCode: '// LOADING',
pattern: silence, pattern: silence,
drawTime, drawTime,
onDraw, drawContext,
prebake: async () => Promise.all([modulesLoading, presets]), prebake: async () => Promise.all([modulesLoading, presets]),
onUpdateState: (state) => { onUpdateState: (state) => {
setReplState({ ...state }); setReplState({ ...state });