|
| 1 | +'use babel' |
| 2 | + |
| 3 | +import OscServer from './osc-server'; |
| 4 | +import LineProcessor from './line-processor'; |
| 5 | + |
| 6 | +const CLASS = Object.freeze({ |
| 7 | + base: "event-highlight", |
| 8 | + idPrefix: "event-highlight-", |
| 9 | +}); |
| 10 | + |
| 11 | +/** Helper that creates (or returns existing) nested maps. */ |
| 12 | +function ensureNestedMap(root, key) { |
| 13 | + if (!root.has(key)) root.set(key, new Map()); |
| 14 | + return root.get(key); |
| 15 | +} |
| 16 | + |
| 17 | +/** |
| 18 | + * Responsible for receiving OSC events describing source‑code positions and |
| 19 | + * visually highlighting those regions in Pulsar TextEditor. |
| 20 | + */ |
| 21 | +export default class EventHighlighter { |
| 22 | + |
| 23 | + constructor(consoleView, editors) { |
| 24 | + this.consoleView = consoleView; |
| 25 | + |
| 26 | + this.editors = editors; |
| 27 | + |
| 28 | + // Data‑structures ------------------------------------------------------- |
| 29 | + this.markers = new Map(); // textbuffer.id → row → col → Marker |
| 30 | + this.highlights = new Map(); // eventId → texteditor.id → col → Marker |
| 31 | + this.filteredMessages = new Map(); // eventId → event |
| 32 | + this.receivedThisFrame = new Map(); // eventId → event |
| 33 | + this.addedThisFrame = new Set(); // Set<event> |
| 34 | + this.eventIds = []; // [{textbufferId, row}] |
| 35 | + |
| 36 | + // Animation state ------------------------------------------------------- |
| 37 | + this.then = 0; // time at previous frame |
| 38 | + |
| 39 | + // Bind instance methods used as callbacks ----------------------------- |
| 40 | + this.animate = this.animate.bind(this); |
| 41 | + } |
| 42 | + |
| 43 | + // ---------------------------------------------------------------------- |
| 44 | + // Public API |
| 45 | + // ---------------------------------------------------------------------- |
| 46 | + |
| 47 | + /** Initialise OSC listeners & start animation loop */ |
| 48 | + init() { |
| 49 | + this.#installBaseHighlightStyle(); |
| 50 | + |
| 51 | + // Kick‑off animation loop |
| 52 | + this.then = window.performance.now(); |
| 53 | + requestAnimationFrame(this.animate); |
| 54 | + } |
| 55 | + |
| 56 | + /** Clean‑up resources when package is deactivated */ |
| 57 | + destroy() { |
| 58 | + try { |
| 59 | + this.server?.destroy(); |
| 60 | + } catch (e) { |
| 61 | + this.consoleView.logStderr(`OSC server encountered an error while destroying:\n${e}`); |
| 62 | + } |
| 63 | + } |
| 64 | + |
| 65 | + /** |
| 66 | + * Inject additional metadata into a TidalCycles mini‑notation line. |
| 67 | + * Returns transformed line (string). |
| 68 | + */ |
| 69 | + addMetadata(line, lineNumber) { |
| 70 | + this.#createPositionMarkers(line, lineNumber); |
| 71 | + |
| 72 | + // Replace quoted segments with `(deltaContext …)` unless they belong to |
| 73 | + // known control‑pattern contexts. |
| 74 | + return line.replace(LineProcessor.controlPatternsRegex(), (match, content, offset) => { |
| 75 | + const before = line.slice(0, offset); |
| 76 | + if (LineProcessor.exceptedFunctionPatterns().test(before)) return match; // ignore control‑patterns |
| 77 | + |
| 78 | + this.eventIds.push({ |
| 79 | + rowStart: lineNumber, |
| 80 | + bufferId: this.editors.currentEditor().buffer.id |
| 81 | + }); |
| 82 | + |
| 83 | + return `(deltaContext ${offset} ${this.eventIds.length - 1} "${content}")`; |
| 84 | + }); |
| 85 | + } |
| 86 | + |
| 87 | + /** Handle OSC message describing a highlight event */ |
| 88 | + oscHighlightSubscriber() { |
| 89 | + return (args: {}): void => { |
| 90 | + const message = OscServer.asDictionary(this.highlightTransformer(args)); |
| 91 | + this.#queueEvent(message); |
| 92 | + } |
| 93 | + } |
| 94 | + |
| 95 | + highlightTransformer(args) { |
| 96 | + const result = [ |
| 97 | + {value: "id"}, args[0], |
| 98 | + {value: "duration"}, args[1], |
| 99 | + {value: "cycle"}, args[2], |
| 100 | + {value: "colStart"}, args[3], |
| 101 | + {value: "eventId"}, {value: args[4].value - 1}, |
| 102 | + {value: "colEnd"}, args[5], |
| 103 | + ]; |
| 104 | + |
| 105 | + return result; |
| 106 | + } |
| 107 | + |
| 108 | + /** requestAnimationFrame callback */ |
| 109 | + animate(now) { |
| 110 | + const elapsed = now - this.then; |
| 111 | + const configFPS = atom.config.get('tidalcycles.eventHighlighting.fps'); |
| 112 | + const fpsInterval = 1000 / configFPS; |
| 113 | + |
| 114 | + if (elapsed >= fpsInterval) { |
| 115 | + this.then = now - (elapsed % fpsInterval); |
| 116 | + |
| 117 | + // Add newly‑received highlights ------------------------------------- |
| 118 | + this.addedThisFrame.forEach((evt) => { |
| 119 | + this.#addHighlight(evt); |
| 120 | + }); |
| 121 | + |
| 122 | + // Remove highlights no longer present ------------------------------ |
| 123 | + const { updated, removed } = this.#diffEventMaps( |
| 124 | + this.filteredMessages, |
| 125 | + this.receivedThisFrame, |
| 126 | + ); |
| 127 | + this.filteredMessages = updated; |
| 128 | + removed.forEach((evt) => this.#removeHighlight(evt)); |
| 129 | + |
| 130 | + // Reset per‑frame collections -------------------------------------- |
| 131 | + this.receivedThisFrame.clear(); |
| 132 | + this.addedThisFrame.clear(); |
| 133 | + } |
| 134 | + |
| 135 | + requestAnimationFrame(this.animate); |
| 136 | + } |
| 137 | + |
| 138 | + // ---------------------------------------------------------------------- |
| 139 | + // Private helpers |
| 140 | + // ---------------------------------------------------------------------- |
| 141 | + |
| 142 | + /** Injects the base CSS rule used for all highlights */ |
| 143 | + #installBaseHighlightStyle() { |
| 144 | + atom.styles.addStyleSheet(` |
| 145 | + .${CLASS.base} { |
| 146 | + outline: 2px solid red; |
| 147 | + outline-offset: 0; |
| 148 | + } |
| 149 | + `); |
| 150 | + } |
| 151 | + |
| 152 | + /** Store events until the next animation frame */ |
| 153 | + #queueEvent(event) { |
| 154 | + const eventMap = ensureNestedMap(this.filteredMessages, event.eventId); |
| 155 | + const recvMap = ensureNestedMap(this.receivedThisFrame, event.eventId); |
| 156 | + |
| 157 | + if (!eventMap.has(event.colStart)) eventMap.set(event.colStart, event); |
| 158 | + if (!recvMap.has(event.colStart)) { |
| 159 | + this.addedThisFrame.add(event); |
| 160 | + recvMap.set(event.colStart, event); |
| 161 | + } |
| 162 | + } |
| 163 | + |
| 164 | + // Highlight management |
| 165 | + #addHighlight({ id, colStart, eventId }) { |
| 166 | + const bufferId = this.eventIds[eventId].bufferId; |
| 167 | + const editors = this.editors.editorsGroupedByTextBufferId()[bufferId]; |
| 168 | + |
| 169 | + if (!editors) return; |
| 170 | + |
| 171 | + const textBufferIdMarkers = this.markers.get(bufferId); |
| 172 | + const rowMarkers = textBufferIdMarkers.get(this.eventIds[eventId].rowStart); |
| 173 | + const baseMarker = rowMarkers?.get(colStart); |
| 174 | + |
| 175 | + if (!baseMarker?.isValid()) return; |
| 176 | + |
| 177 | + const highlightEvent = ensureNestedMap(this.highlights, eventId); |
| 178 | + |
| 179 | + editors.forEach(editor => { |
| 180 | + const textEditorEvent = ensureNestedMap(highlightEvent, editor.id); |
| 181 | + |
| 182 | + if (textEditorEvent.has(colStart)) return; // already highlighted |
| 183 | + |
| 184 | + const marker = editor.markBufferRange(baseMarker.getBufferRange(), { |
| 185 | + invalidate: "inside", |
| 186 | + }); |
| 187 | + |
| 188 | + // Base style |
| 189 | + editor.decorateMarker(marker, { type: "text", class: CLASS.base }); |
| 190 | + |
| 191 | + // Style by numeric id |
| 192 | + editor.decorateMarker(marker, { type: "text", class: `${CLASS.idPrefix}${id}` }); |
| 193 | + |
| 194 | + textEditorEvent.set(colStart, marker); |
| 195 | + // eventId → texteditor.id → col → Marker |
| 196 | + }); |
| 197 | + } |
| 198 | + |
| 199 | + #removeHighlight({ colStart, eventId }) { |
| 200 | + |
| 201 | + const highlightEvents = this.highlights.get(eventId); |
| 202 | + // console.log("removeHighlight", highlightEvents, eventId, colStart); |
| 203 | + |
| 204 | + if (!highlightEvents.size) return; |
| 205 | + |
| 206 | + highlightEvents.forEach(textEditorIdEvent => { |
| 207 | + const marker = textEditorIdEvent.get(colStart); |
| 208 | + textEditorIdEvent.delete(colStart); |
| 209 | + |
| 210 | + if (!marker) return; |
| 211 | + marker.destroy(); |
| 212 | + }) |
| 213 | + |
| 214 | + |
| 215 | + } |
| 216 | + |
| 217 | + // Marker generation (per line) |
| 218 | + |
| 219 | + /** |
| 220 | + * Builds Atom markers for every word inside quoted mini‑notation strings so |
| 221 | + * that OSC events can map onto them later. |
| 222 | + */ |
| 223 | + #createPositionMarkers(line, lineNumber) { |
| 224 | + const currentEditor = this.editors.currentEditor(); |
| 225 | + const currentTextBufferId = this.editors.currentEditor().buffer.id; |
| 226 | + |
| 227 | + const textBufferIdMarkers = ensureNestedMap(this.markers, currentTextBufferId); |
| 228 | + const rowMarkers = ensureNestedMap(textBufferIdMarkers, lineNumber); |
| 229 | + |
| 230 | + LineProcessor.findTidalWordRanges(line, (range) => { |
| 231 | + const bufferRange = [[lineNumber, range.start], [lineNumber, range.end + 1]]; |
| 232 | + const marker = currentEditor.markBufferRange(bufferRange, { invalidate: "inside" }); |
| 233 | + rowMarkers.set(range.start, marker); |
| 234 | + }); |
| 235 | + } |
| 236 | + |
| 237 | + #diffEventMaps(prevEvents, currentEvents) { |
| 238 | + const removed = new Set(); |
| 239 | + const updated = new Map(prevEvents); |
| 240 | + |
| 241 | + for (const [event, prevCols] of prevEvents) { |
| 242 | + const currCols = currentEvents.get(event); |
| 243 | + if (!currCols) { |
| 244 | + for (const [, prevEvt] of prevCols) removed.add(prevEvt); |
| 245 | + updated.delete(event); |
| 246 | + continue; |
| 247 | + } |
| 248 | + |
| 249 | + for (const [col, prevEvt] of prevCols) { |
| 250 | + if (!currCols.has(col)) { |
| 251 | + removed.add(prevEvt); |
| 252 | + updated.get(event).delete(col); |
| 253 | + } |
| 254 | + } |
| 255 | + } |
| 256 | + |
| 257 | + return { updated, removed }; |
| 258 | + } |
| 259 | +} |
0 commit comments