From 69a7fcdbd768c560c4fbf385c997c60cfe6ff437 Mon Sep 17 00:00:00 2001 From: k8rito01 Date: Mon, 6 Jul 2026 15:46:56 +0200 Subject: [PATCH 1/3] feat(reactor): persistent ambient particle field + external event feed Two opt-in enhancements to the Event Reactor (bar anim mode 7): 1. Ambient particle field. Instead of being empty at rest, the reactor keeps a single pool of particles drifting through the gaps. On an event, these SAME particles reorganise to spell the text (reusing the swarm condensation) and then disperse back to drifting. Non-recruited particles dim while a message is held. Gated by `ambientField7` (default on here). When on, the paint loop no longer sleeps: ~15 fps drift in the calm, ~4-5% CPU at rest (set `ambientField7: false` to restore the pure zero-idle reactor). 2. External event feed. A tool can push a message to the reactor by writing one line "\tLEFT\tRIGHT" to ~/.cache/qs-reactor-event. First load only baselines, so old content is not replayed on start. Enables things like a "CLAUDE CODE / FABLE 5" swarm when an AI CLI is invoked, driven by that tool's own hook/wrapper. Co-Authored-By: Claude Fable 5 --- versions/V1/modules/ParticleStream.qml | 123 +++++++++++++++++++++---- 1 file changed, 103 insertions(+), 20 deletions(-) diff --git a/versions/V1/modules/ParticleStream.qml b/versions/V1/modules/ParticleStream.qml index f1bd188..008ee30 100644 --- a/versions/V1/modules/ParticleStream.qml +++ b/versions/V1/modules/ParticleStream.qml @@ -28,6 +28,36 @@ Item { property string pendingUrgentAddr7: "" property string pendingUrgentCls7: "" property var recentOpen7: ({}) + // Persistent ambient particle field for the reactor (mode 7): the very + // particles that condense into event text stay visible drifting at rest and + // reorganise to form the text on events, then disperse. Set false to keep the + // pure zero-idle reactor (empty at rest, ~0% CPU). + property bool ambientField7: true + + // External-event feed: any tool can push a message to the reactor by writing + // one line "\tLEFT\tRIGHT" to ~/.cache/qs-reactor-event (truncate). + // First load only baselines, so pre-existing content is not replayed on start. + property double lastReactorTs: -1 + property bool reactorBaselined: false + FileView { + id: reactorEventFile + path: Quickshell.env("HOME") + "/.cache/qs-reactor-event" + watchChanges: true + onFileChanged: reactorEventFile.reload() + onLoaded: { + if (!root.reactorBaselined) { root.reactorBaselined = true; root.lastReactorTs = Date.now(); return } + var raw = (reactorEventFile.text() || "").trim() + if (raw === "") return + var nl = raw.lastIndexOf("\n") + if (nl >= 0) raw = raw.slice(nl + 1) + var parts = raw.split("\t") + var ts = parseFloat(parts[0]) + if (!isFinite(ts) || ts <= root.lastReactorTs) return + root.lastReactorTs = ts + if (root.armed7 && root.active && root.mode === 7) + root.pushText(parts[1] || "", parts[2] || "", 1, "long") + } + } onModeChanged: { warnQueue7 = [] @@ -44,7 +74,7 @@ Item { urgentProbe7.stop() } else { pulses = [] - animating7 = false + animating7 = root.ambientField7 // field needs a running paint loop var ws7 = Hyprland.focusedWorkspace lastWsId = ws7 && ws7.id > 0 ? ws7.id : -1 canvas.tick7 = 16 @@ -769,6 +799,8 @@ Item { // mode 8 quotes cache, separate from the mode-7 event cache property var quoteData: null property var quoteSwarm: null + property real fieldFade: 1.0 // ambient-field dim while text is forming + property int recruited: 0 // pool particles currently spelling the text onPaint: { var ctx = getContext("2d") @@ -1158,14 +1190,6 @@ Item { "<": [[2,0],[1,1],[0,2],[1,3],[2,4]] } } } - var ps7 = root.pulses - if (ps7.length === 0) { - root.animating7 = false - canvas.tick7 = 250 - ctx.globalAlpha = 1.0 - return - } - var gaps7 = [] for (var gi = 0; gi + 1 < runs.length; gi++) { var gx1 = root.layout.runRightEdge(runs[gi].e) @@ -1248,6 +1272,51 @@ Item { } } + // ── ambient pool: one set of particles drifting through the gaps; + // on an event these same particles reorganise into the text ── + var ps7 = root.pulses + var N9 = 110 + var driftPos = function(i) { + var r1 = hash(i * 3 + 1), r2 = hash(i * 3 + 2), r3 = hash(i * 3 + 3) + var bf = hash(i * 5 + 7) + return { x: fx1 + bf * (fx2 - fx1) + + 16 * Math.sin(now / (1700 + 900 * r1) + 6.283 * r2), + y: cy + (height / 2 - 5) * 0.78 * Math.sin(now / (1300 + 800 * r3) + 6.283 * r1) } + } + var paintField = function(fromIdx, alpha) { + if (alpha <= 0.01) return + for (var fi = fromIdx; fi < N9; fi++) { + var d = driftPos(fi) + var tw = 0.55 + 0.30 * Math.sin(now / 640 + fi * 1.3) + dot7(d.x, d.y, 1.3, 0.5, alpha * tw) + } + } + var hasText7 = false + for (var ht = 0; ht < ps7.length; ht++) + if (ps7[ht].k === "text" && now - ps7[ht].t < root.pulseLife7(ps7[ht])) hasText7 = true + if (!hasText7) canvas.recruited = 0 + if (ps7.length === 0) { + if (root.ambientField7) { + canvas.fieldFade += (1.0 - canvas.fieldFade) * 0.06 + canvas.recruited = 0 + paintField(0, 0.5 * canvas.fieldFade) + root.animating7 = true + canvas.tick7 = 64 // slow drift, ~15 fps + ctx.globalAlpha = 1.0 + return + } + root.animating7 = false + canvas.tick7 = 250 + ctx.globalAlpha = 1.0 + return + } + if (root.ambientField7) { + // recruited particles are drawn by the text swarm below; the rest + // keep drifting, dimmed while a message is on screen + canvas.fieldFade += ((hasText7 ? 0.4 : 1.0) - canvas.fieldFade) * 0.06 + paintField(canvas.recruited, 0.5 * canvas.fieldFade) + } + var alive7 = false var livePs7 = [] for (var pi7 = 0; pi7 < ps7.length; pi7++) { @@ -1379,34 +1448,48 @@ Item { var leave7 = age > p.r1 ? (age - p.r1) / (p.life - p.r1) : 0; leave7 = leave7 * leave7 var shift7 = p.d * (fx2 - fx1) * 0.45 * (leave7 - enter7) var sdT = p.t % 86400000 - for (var ti = 0; ti < G.pts.length; ti++) { + var Ptot = G.pts.length + var af7 = root.ambientField7 + // with the field on, the SAME pool particles form the text + // (subsampled if the message has more dots than the pool); + // with it off, the original fly-in from the edge is used + var step7 = (af7 && Ptot > N9) ? Math.ceil(Ptot / N9) : 1 + var jj7 = 0 + for (var ti = 0; ti < Ptot; ti += step7) { var ptT = G.pts[ti] var gT = geoT[ptT[2]] - var wxT = fx1 + hash(sdT + ti * 7 + 5) * (fx2 - fx1) + shift7 + var pxT, pyT, rgT, rcT + if (af7) { + var d07 = driftPos(jj7) // start = pool drift + pxT = d07.x; pyT = d07.y; rgT = 1.3; rcT = 0.5 + } else { + pxT = fx1 + hash(sdT + ti * 7 + 5) * (fx2 - fx1) + shift7 + 12 * Math.sin(now / (800 + 500 * hash(sdT + ti * 7 + 1)) + 6.283 * hash(sdT + ti * 7 + 2)) - var wyT = cy + (height / 2 - 6) * 0.85 + pyT = cy + (height / 2 - 6) * 0.85 * Math.sin(now / (600 + 500 * hash(sdT + ti * 7 + 3)) + 6.283 * hash(sdT + ti * 7 + 4)) - var rgT = 2.2, rcT = 1.0 - var pxT = wxT, pyT = wyT + rgT = 2.2; rcT = 1.0 + } if (gT && qT > 0) { pxT += (gT.ox + ptT[0] * gT.cell - pxT) * qT pyT += (gT.oy + ptT[1] * gT.cell - pyT) * qT if (qT > 0.98) { // the held text breathes - pxT += 0.4 * Math.sin(now / 240 + ti) - pyT += 0.4 * Math.cos(now / 300 + ti * 1.7) + pxT += 0.4 * Math.sin(now / 240 + jj7) + pyT += 0.4 * Math.cos(now / 300 + jj7 * 1.7) } - rgT += (gT.cell * 0.62 - 2.2) * qT - rcT += (gT.cell * 0.30 - 1.0) * qT + rgT += (gT.cell * 0.62 - rgT) * qT + rcT += (gT.cell * 0.30 - rcT) * qT } dot7(pxT, pyT, rgT * wS, rcT * wS, alT * wA) + jj7++ } + canvas.recruited = af7 ? jj7 : 0 } } if (livePs7.length !== ps7.length) root.pulses = livePs7 - root.animating7 = alive7 - canvas.tick7 = alive7 ? 16 : 250 + root.animating7 = root.ambientField7 ? true : alive7 + canvas.tick7 = alive7 ? 16 : (root.ambientField7 ? 64 : 250) ctx.globalAlpha = 1.0 return } From 0ed8168b20173502f453a9ac19e2a6cb5a3171ff Mon Sep 17 00:00:00 2001 From: k8rito01 Date: Mon, 6 Jul 2026 15:52:52 +0200 Subject: [PATCH 2/3] fix(reactor): don't subsample event text into the pool Long messages (e.g. a track title) have more glyph dots than the pool, and subsampling them dropped dots so letters became unreadable. Render every glyph dot instead: the first N9 start from the visible pool particles, the extra ones emerge to complete the word. The name/message always reads in full. Co-Authored-By: Claude Fable 5 --- versions/V1/modules/ParticleStream.qml | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/versions/V1/modules/ParticleStream.qml b/versions/V1/modules/ParticleStream.qml index 008ee30..4e12a62 100644 --- a/versions/V1/modules/ParticleStream.qml +++ b/versions/V1/modules/ParticleStream.qml @@ -1450,17 +1450,17 @@ Item { var sdT = p.t % 86400000 var Ptot = G.pts.length var af7 = root.ambientField7 - // with the field on, the SAME pool particles form the text - // (subsampled if the message has more dots than the pool); - // with it off, the original fly-in from the edge is used - var step7 = (af7 && Ptot > N9) ? Math.ceil(Ptot / N9) : 1 - var jj7 = 0 - for (var ti = 0; ti < Ptot; ti += step7) { + // with the field on, each glyph dot starts from a pool drift + // position (driftPos(ti)); the first N9 ARE the visible pool, + // the extra (long text) emerge to complete the word — no + // subsampling, so the message always reads in full. Field off + // keeps the original fly-in from the edge. + for (var ti = 0; ti < Ptot; ti++) { var ptT = G.pts[ti] var gT = geoT[ptT[2]] var pxT, pyT, rgT, rcT if (af7) { - var d07 = driftPos(jj7) // start = pool drift + var d07 = driftPos(ti) // start = pool drift pxT = d07.x; pyT = d07.y; rgT = 1.3; rcT = 0.5 } else { pxT = fx1 + hash(sdT + ti * 7 + 5) * (fx2 - fx1) + shift7 @@ -1475,16 +1475,15 @@ Item { pxT += (gT.ox + ptT[0] * gT.cell - pxT) * qT pyT += (gT.oy + ptT[1] * gT.cell - pyT) * qT if (qT > 0.98) { // the held text breathes - pxT += 0.4 * Math.sin(now / 240 + jj7) - pyT += 0.4 * Math.cos(now / 300 + jj7 * 1.7) + pxT += 0.4 * Math.sin(now / 240 + ti) + pyT += 0.4 * Math.cos(now / 300 + ti * 1.7) } rgT += (gT.cell * 0.62 - rgT) * qT rcT += (gT.cell * 0.30 - rcT) * qT } dot7(pxT, pyT, rgT * wS, rcT * wS, alT * wA) - jj7++ } - canvas.recruited = af7 ? jj7 : 0 + canvas.recruited = af7 ? Math.min(Ptot, N9) : 0 } } if (livePs7.length !== ps7.length) root.pulses = livePs7 From 64a74f80716a1951972f5df27a6ec3fdb9c26416 Mon Sep 17 00:00:00 2001 From: lito Date: Mon, 6 Jul 2026 16:37:16 +0200 Subject: [PATCH 3/3] fix(reactor): debounce track changes + smoother, stall-proof drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes to the mode-7 reactor, both surfaced by web players (YouTube / YouTube Music) that publish MPRIS metadata in stages as the next track buffers. Track-change restart These players emit a transient generic/channel title first, then the real one ~0.5s later. onPsTrackChanged fired on each stage, so the swarm formed once, then a second event restarted it mid-flight — a visible reset on every song change. Now debounced (800ms) with a last-announced guard: one swarm per track, always the settled title. Stall-proof, more organic ambient drift - Drift is driven by a clamped clock (fieldT) instead of Date.now(): if the paint loop stalls a moment (metadata burst, media relayout) the field micro-pauses instead of JUMPING on the next frame. - driftPos is now anchored in absolute bar coords (bf*width) rather than gap-relative, so a relayout no longer remaps the pool. - Two harmonics per axis + size "breathing" for a more alive field. - Idle drift 15fps -> ~33fps for smoother motion (reaction unchanged at 60fps; still auto-sleeps when nothing is happening). Co-Authored-By: Claude Fable 5 --- versions/V1/modules/ParticleStream.qml | 61 +++++++++++++++++++------- 1 file changed, 46 insertions(+), 15 deletions(-) diff --git a/versions/V1/modules/ParticleStream.qml b/versions/V1/modules/ParticleStream.qml index 4e12a62..558d80a 100644 --- a/versions/V1/modules/ParticleStream.qml +++ b/versions/V1/modules/ParticleStream.qml @@ -670,14 +670,28 @@ Item { onTriggered: root.warnCheck() } - // track change → swarm forms TITLE (left) and ARTIST - ALBUM (right) + // track change → swarm forms TITLE (left) and ARTIST - ALBUM (right). + // Debounced: web players (YouTube, YouTube Music, etc.) publish the metadata + // in stages while the next track buffers — a transient generic/channel title + // first, then the real one ~0.5s later. Firing on each stage restarts the + // swarm mid-flight (a visible "reset"). Wait for the title to settle, then + // announce once, and never re-announce the same track. MprisSelect { id: psSel } readonly property string psTrack: psSel.player ? (psSel.player.trackTitle || "") : "" - onPsTrackChanged: { - if (!armed7 || psTrack === "") return - var ar = psSel.player ? (psSel.player.trackArtist || "") : "" - var al = psSel.player ? (psSel.player.trackAlbum || "") : "" - pushText(psTrack, ar + (al ? " - " + al : ""), 1, "long") + property string lastAnnouncedTrack: "" + onPsTrackChanged: if (armed7) trackDebounce.restart() + Timer { + id: trackDebounce + interval: 800 + repeat: false + onTriggered: { + var t = root.psTrack + if (!root.armed7 || t === "" || t === root.lastAnnouncedTrack) return + root.lastAnnouncedTrack = t + var ar = psSel.player ? (psSel.player.trackArtist || "") : "" + var al = psSel.player ? (psSel.player.trackAlbum || "") : "" + root.pushText(t, ar + (al ? " - " + al : ""), 1, "long") + } } // new notification (the bar's mako poll counts up) → fetch newest, show @@ -801,6 +815,8 @@ Item { property var quoteSwarm: null property real fieldFade: 1.0 // ambient-field dim while text is forming property int recruited: 0 // pool particles currently spelling the text + property real fieldT: 0 // drift clock, advanced with a per-paint clamp + property real lastPaintNow: 0 // (stall-proof: see driftPos below) onPaint: { var ctx = getContext("2d") @@ -1272,23 +1288,38 @@ Item { } } - // ── ambient pool: one set of particles drifting through the gaps; + // ── ambient pool: one set of particles drifting through the bar; // on an event these same particles reorganise into the text ── var ps7 = root.pulses var N9 = 110 + // Drift clock with a per-paint CLAMP. If Quickshell's loop stalls a + // moment (an MPRIS metadata burst, a media relayout on track change…) + // wall-clock keeps running but no frame paints — driving the drift by + // Date.now() would make the field JUMP on the next paint (reads as a + // "reset"). Advance at most ~1 frame per paint → a micro-pause instead + // of a jump. + var dtF = now - canvas.lastPaintNow + canvas.lastPaintNow = now + canvas.fieldT += (dtF > 0 && dtF < 60) ? dtF : (dtF >= 60 ? 16 : 0) + var ftF = canvas.fieldT var driftPos = function(i) { var r1 = hash(i * 3 + 1), r2 = hash(i * 3 + 2), r3 = hash(i * 3 + 3) var bf = hash(i * 5 + 7) - return { x: fx1 + bf * (fx2 - fx1) - + 16 * Math.sin(now / (1700 + 900 * r1) + 6.283 * r2), - y: cy + (height / 2 - 5) * 0.78 * Math.sin(now / (1300 + 800 * r3) + 6.283 * r1) } + // x in ABSOLUTE bar coords (constant width), not gap-relative, so a + // relayout doesn't remap the pool. Two harmonics per axis → organic. + return { x: bf * width + + 16 * Math.sin(ftF / (1700 + 900 * r1) + 6.283 * r2) + + 6 * Math.sin(ftF / (620 + 300 * r3) + r1 * 5.0), + y: cy + (height / 2 - 5) * 0.78 * Math.sin(ftF / (1300 + 800 * r3) + 6.283 * r1) + + 2.5 * Math.sin(ftF / (500 + 260 * r2) + r3 * 4.0) } } var paintField = function(fromIdx, alpha) { if (alpha <= 0.01) return for (var fi = fromIdx; fi < N9; fi++) { var d = driftPos(fi) - var tw = 0.55 + 0.30 * Math.sin(now / 640 + fi * 1.3) - dot7(d.x, d.y, 1.3, 0.5, alpha * tw) + var tw = 0.55 + 0.30 * Math.sin(ftF / 640 + fi * 1.3) + var sz = 1.25 + 0.28 * Math.sin(ftF / 820 + fi * 2.1) // breathing + dot7(d.x, d.y, sz, sz * 0.4, alpha * tw) } } var hasText7 = false @@ -1301,7 +1332,7 @@ Item { canvas.recruited = 0 paintField(0, 0.5 * canvas.fieldFade) root.animating7 = true - canvas.tick7 = 64 // slow drift, ~15 fps + canvas.tick7 = 30 // slow drift, ~33 fps ctx.globalAlpha = 1.0 return } @@ -1313,7 +1344,7 @@ Item { if (root.ambientField7) { // recruited particles are drawn by the text swarm below; the rest // keep drifting, dimmed while a message is on screen - canvas.fieldFade += ((hasText7 ? 0.4 : 1.0) - canvas.fieldFade) * 0.06 + canvas.fieldFade += ((hasText7 ? 0.18 : 1.0) - canvas.fieldFade) * 0.06 paintField(canvas.recruited, 0.5 * canvas.fieldFade) } @@ -1488,7 +1519,7 @@ Item { } if (livePs7.length !== ps7.length) root.pulses = livePs7 root.animating7 = root.ambientField7 ? true : alive7 - canvas.tick7 = alive7 ? 16 : (root.ambientField7 ? 64 : 250) + canvas.tick7 = alive7 ? 16 : (root.ambientField7 ? 30 : 250) ctx.globalAlpha = 1.0 return }