Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,17 +90,17 @@ Filter all the console old logs and show only the last one

### Event Highlighting

The event highlighting allows to visualize the active events within a mini notation pattern. This means, that only events within quotation marks will be considered.
The event highlighting allows to visualize the active events within a mini notation pattern. This means, that only events within quotation marks will be considered.

#### TidalCycles configuration

TidalCycles needs to be configured to send editor highlight events. This is usually done by modifying the `BootTidal.hs` file and adding an editor highlight target. Here is a working example:

```haskell
let editorTarget = Target {oName = "editor", oAddress = "127.0.0.1", oPort = 6013, oLatency = 0.02, oSchedule = Pre BundleStamp, oWindow = Nothing, oHandshake = False, oBusPort = Nothing }
let editorTarget = Target {oName = "editor", oAddress = "127.0.0.1", oPort = 6013, oLatency = 0.2, oSchedule = Pre BundleStamp, oWindow = Nothing, oHandshake = False, oBusPort = Nothing }
let editorShape = OSCContext "/editor/highlights"

tidal <- startStream (defaultConfig {cFrameTimespan = 1/50}) [(superdirtTarget {oLatency = 0.2}, [superdirtShape]), (editorTarget, [editorShape])]
tidal <- startStream (defaultConfig {cFrameTimespan = 1/50, cProcessAhead = (1/20)}) [(superdirtTarget {oLatency = 0.02}, [superdirtShape]), (editorTarget, [editorShape])]
```

The path to the `BootTidal.hs` file can be found in the TidalCycles output console after TidalCycles has been booted in the editor.
Expand Down
9 changes: 7 additions & 2 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,14 @@ export default {
},
'fps': {
type: 'number',
default: 30,
description: 'Reduce this value if the event highlighting flickers. Higher values make the result smoother, but require more computational capacity and it is limited by the cFrameTimespan value configured in TidalCycles. It is recommended to use a value between 20 and 30 fps.',
order: 100,
default: 30,
order: 96,
},
'delay': {
type: 'number',
default: 0.0,
order: 98,
},
'ip': {
type: 'string',
Expand Down
208 changes: 124 additions & 84 deletions lib/event-highlighter.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,12 @@ export default class EventHighlighter {
// Data‑structures -------------------------------------------------------
this.markers = new Map(); // textbuffer.id → row → col → Marker
this.highlights = new Map(); // eventId → texteditor.id → col → Marker
this.filteredMessages = new Map(); // eventId → event
this.receivedThisFrame = new Map(); // eventId → event
this.addedThisFrame = new Set(); // Set<event>
this.eventIds = []; // [{textbufferId, row}]
this.messageBuffer = new Map();
this.activeMessages = new Map();

// Animation state -------------------------------------------------------
this.then = 0; // time at previous frame
this.then = 0;

// Bind instance methods used as callbacks -----------------------------
this.animate = this.animate.bind(this);
}

Expand All @@ -48,11 +45,61 @@ export default class EventHighlighter {
init() {
this.#installBaseHighlightStyle();

// Kick‑off animation loop
this.then = window.performance.now();
requestAnimationFrame(this.animate);
}

/** requestAnimationFrame callback */
animate(now) {
const elapsed = now - this.then;

const configFPS = atom.config.get('tidalcycles.eventHighlighting.fps');
const delay = atom.config.get('tidalcycles.eventHighlighting.delay');

const fpsInterval = 1000 / configFPS;
let activeThisFrame = new Set();

if (elapsed >= fpsInterval) {
this.then = now - (elapsed % fpsInterval);

[...this.messageBuffer.entries()]
.filter(([ts]) => (ts + (delay * -1)) < this.then)
.forEach(([ts, event]) => {
activeThisFrame = new Set([...activeThisFrame, ...event]);
this.messageBuffer.delete(ts);
}
);

const { active, added, removed } = this.#diffEventMaps(
this.activeMessages,
this.#transformedEvents(activeThisFrame)
);

removed.forEach(evt => {
const cols = this.activeMessages.get(evt.eventId);
if (cols) {
cols.delete(evt.colStart);
if (cols.size === 0) {
this.activeMessages.delete(evt.eventId);
}
}
});

this.activeMessages = this.#transformedEvents(
[...active, ...added]
);

added.forEach((evt) => {
this.#addHighlight(evt);
});

removed.forEach((evt) => this.#removeHighlight(evt));

}

requestAnimationFrame(this.animate);
}

/** Clean‑up resources when package is deactivated */
destroy() {
try {
Expand Down Expand Up @@ -86,59 +133,27 @@ export default class EventHighlighter {

/** Handle OSC message describing a highlight event */
oscHighlightSubscriber() {
return (args: {}): void => {
const message = OscServer.asDictionary(this.highlightTransformer(args));
this.#queueEvent(message);
}
}
return (args, time) => {

highlightTransformer(args) {
const result = [
{value: "id"}, args[0],
{value: "duration"}, args[1],
{value: "cycle"}, args[2],
{value: "colStart"}, args[3],
{value: "eventId"}, {value: args[4].value - 1},
{value: "colEnd"}, args[5],
];

return result;
}
const transformedArgs = [
{ value: "time" }, { value: time },
{ value: "id" }, args[0],
{ value: "duration" }, args[1],
{ value: "cycle" }, args[2],
{ value: "colStart" }, args[3],
{ value: "eventId" }, { value: args[4].value - 1 },
{ value: "colEnd" }, args[5],
];

/** requestAnimationFrame callback */
animate(now) {
const elapsed = now - this.then;
const configFPS = atom.config.get('tidalcycles.eventHighlighting.fps');
const fpsInterval = 1000 / configFPS;
const message = OscServer.asDictionary(transformedArgs);
Comment on lines +138 to +148
Copy link
Collaborator

@ndr-brt ndr-brt Aug 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd avoid to call OscServer.asDictionary here, it will be easier to just create the message directly from args


if (elapsed >= fpsInterval) {
this.then = now - (elapsed % fpsInterval);

// Add newly‑received highlights -------------------------------------
this.addedThisFrame.forEach((evt) => {
this.#addHighlight(evt);
});

// Remove highlights no longer present ------------------------------
const { updated, removed } = this.#diffEventMaps(
this.filteredMessages,
this.receivedThisFrame,
);
this.filteredMessages = updated;
removed.forEach((evt) => this.#removeHighlight(evt));

// Reset per‑frame collections --------------------------------------
this.receivedThisFrame.clear();
this.addedThisFrame.clear();
this.#queueEvent(message);
}

requestAnimationFrame(this.animate);
}

// ----------------------------------------------------------------------
// Private helpers
// ----------------------------------------------------------------------

/** Injects the base CSS rule used for all highlights */
#installBaseHighlightStyle() {
atom.styles.addStyleSheet(`
Expand All @@ -149,18 +164,6 @@ export default class EventHighlighter {
`);
}

/** Store events until the next animation frame */
#queueEvent(event) {
const eventMap = ensureNestedMap(this.filteredMessages, event.eventId);
const recvMap = ensureNestedMap(this.receivedThisFrame, event.eventId);

if (!eventMap.has(event.colStart)) eventMap.set(event.colStart, event);
if (!recvMap.has(event.colStart)) {
this.addedThisFrame.add(event);
recvMap.set(event.colStart, event);
}
}

// Highlight management
#addHighlight({ id, colStart, eventId }) {
const bufferId = this.eventIds[eventId].bufferId;
Expand All @@ -177,22 +180,21 @@ export default class EventHighlighter {
const highlightEvent = ensureNestedMap(this.highlights, eventId);

editors.forEach(editor => {
const textEditorEvent = ensureNestedMap(highlightEvent, editor.id);
const textEditorEvent = ensureNestedMap(highlightEvent, editor.id);

if (textEditorEvent.has(colStart)) return; // already highlighted
if (textEditorEvent.has(colStart)) return; // already highlighted

const marker = editor.markBufferRange(baseMarker.getBufferRange(), {
invalidate: "inside",
});
const marker = editor.markBufferRange(baseMarker.getBufferRange(), {
invalidate: "inside",
});

// Base style
editor.decorateMarker(marker, { type: "text", class: CLASS.base });
// Base style
editor.decorateMarker(marker, { type: "text", class: CLASS.base });

// Style by numeric id
editor.decorateMarker(marker, { type: "text", class: `${CLASS.idPrefix}${id}` });
// Style by numeric id
editor.decorateMarker(marker, { type: "text", class: `${CLASS.idPrefix}${id}` });

textEditorEvent.set(colStart, marker);
// eventId → texteditor.id → col → Marker
textEditorEvent.set(colStart, marker);
});
}

Expand All @@ -202,15 +204,13 @@ export default class EventHighlighter {

if (!highlightEvents || !highlightEvents.size) return;

highlightEvents.forEach(textEditorIdEvent => {
highlightEvents.forEach(textEditorIdEvent => {
const marker = textEditorIdEvent.get(colStart);
textEditorIdEvent.delete(colStart);

if (!marker) return;
marker.destroy();
})


}

// Marker generation (per line)
Expand All @@ -227,32 +227,72 @@ export default class EventHighlighter {
const rowMarkers = ensureNestedMap(textBufferIdMarkers, lineNumber);

LineProcessor.findTidalWordRanges(line, (range) => {
const bufferRange = [[lineNumber, range.start], [lineNumber, range.end + 1]];
const marker = currentEditor.markBufferRange(bufferRange, { invalidate: "inside" });
rowMarkers.set(range.start, marker);
const bufferRange = [[lineNumber, range.start], [lineNumber, range.end + 1]];
const marker = currentEditor.markBufferRange(bufferRange, { invalidate: "inside" });
rowMarkers.set(range.start, marker);
});
}

#ensureNestedMap(root, key) {
if (!root.has(key)) root.set(key, new Map());
return root.get(key);
}

#queueEvent(event) {
if (!this.messageBuffer.has(event.time)) this.messageBuffer.set(event.time, new Set());
this.messageBuffer.get(event.time).add(event);
}

#diffEventMaps(prevEvents, currentEvents) {
const removed = new Set();
const updated = new Map(prevEvents);
const added = new Set();
const active = new Set();

for (const [event, prevCols] of prevEvents) {
const currCols = currentEvents.get(event);
if (!currCols) {
for (const [, prevEvt] of prevCols) removed.add(prevEvt);
updated.delete(event);
continue;
}

for (const [col, prevEvt] of prevCols) {
if (!currCols.has(col)) {
removed.add(prevEvt);
updated.get(event).delete(col);
} else {
active.add(prevEvt);
}
}
}

return { updated, removed };
for (const [event, currCols] of currentEvents) {
const prevCols = prevEvents.get(event);
if (!prevCols) {
for (const [, currEvt] of currCols) added.add(currEvt);
continue;
}

for (const [col, currEvt] of currCols) {
if (!prevCols.has(col)) {
added.add(currEvt);
}
}
}

return { removed, added, active };
}

#transformedEvents(events) {
const resultEvents = new Map();
events.forEach(event => {
if (!resultEvents.get(event.eventId)) {
resultEvents.set(event.eventId, new Map());
}
const cols = resultEvents.get(event.eventId);

cols.set(event.colStart, event);
});

return resultEvents;
}

}
20 changes: 11 additions & 9 deletions lib/osc-eval.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,19 @@ import {Editors} from './editors';
import OscServer from './osc-server';

export function oscEvalSubscriber(tidalRepl: Repl, editors: Editors) {
return (args: {}): void => {
const message = OscServer.asDictionary(args);
return (args: {}): void => {
const message = OscServer.asDictionary(args);

if (message['tab'] !== undefined) {
atom.workspace.getPanes()[0].setActiveItem(atom.workspace.getTextEditors()[message['tab']])
}
if (message['tab'] !== undefined) {
atom.workspace.getPanes()[0].setActiveItem(atom.workspace.getTextEditors()[message['tab']])
}

if (message['row'] && message['column']) {
editors.goTo(message['row'] - 1, message['column'])
}
if (message['row'] && message['column']) {
editors.goTo(message['row'] - 1, message['column'])
}

tidalRepl.eval(message['type'], false);
if (message['type']) {
tidalRepl.eval(message['type'], false);
}
}
}
Loading
Loading