Skip to content
Open
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
161 changes: 137 additions & 24 deletions versions/V1/modules/ParticleStream.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<epoch_ms>\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 = []
Expand All @@ -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
Expand Down Expand Up @@ -640,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
Expand Down Expand Up @@ -769,6 +813,10 @@ 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
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")
Expand Down Expand Up @@ -1158,14 +1206,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)
Expand Down Expand Up @@ -1248,6 +1288,66 @@ Item {
}
}

// ── 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)
// 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(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
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 = 30 // slow drift, ~33 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.18 : 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++) {
Expand Down Expand Up @@ -1379,34 +1479,47 @@ 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, 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 wxT = fx1 + hash(sdT + ti * 7 + 5) * (fx2 - fx1) + shift7
var pxT, pyT, rgT, rcT
if (af7) {
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
+ 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)
}
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)
}
canvas.recruited = af7 ? Math.min(Ptot, N9) : 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 ? 30 : 250)
ctx.globalAlpha = 1.0
return
}
Expand Down