diff --git a/fluxo/config.toml b/fluxo/config.toml
index 9e6ff85..39f8447 100644
--- a/fluxo/config.toml
+++ b/fluxo/config.toml
@@ -5,6 +5,13 @@
# strings. The Quickshell config (~/.config/quickshell/services/Sys.qml) parses
# them and does its own formatting. The previous human-readable Waybar formats
# are preserved in config.toml.waybar-bak.
+#
+# Quickshell only consumes cpu, mem, gpu, disk, net, sys and power from fluxo.
+# Everything else it gets natively — Quickshell.Services.Mpris, .Pipewire,
+# .UPower, .Notifications, Quickshell.Bluetooth — and Brightness.qml reads
+# sysfs directly. Those fluxo modules are therefore switched off below: left on,
+# they held a libpulse context, a BlueZ D-Bus poll every 2 s and an MPRIS
+# marquee ticker firing twice a second, all feeding a bar that does not exist.
[general]
menu_command = "fuzzel --dmenu --prompt \"$FLUXO_PROMPT\""
@@ -44,12 +51,18 @@ format = "{mount}|{used:.1}|{total:.1}"
format = "{percentage}"
[audio]
+# Quickshell.Services.Pipewire drives Audio.qml; leaving this on kept a
+# libpulse context and its callbacks alive for nothing.
+enabled = false
format_sink_unmuted = "{name} {volume:>3}% {icon}"
format_sink_muted = "{name} {icon}"
format_source_unmuted = "{name} {volume:>3}% {icon}"
format_source_muted = "{name} {icon}"
[bt]
+# Quickshell.Bluetooth drives Bt.qml. This module polled BlueZ over D-Bus every
+# 2 seconds regardless.
+enabled = false
format_plugin = "{alias} [{left}|{right}] {anc} "
format_connected = "{alias} "
format_disconnected = "Disconnected "
@@ -60,9 +73,14 @@ format_active = ""
format_inactive = ""
[mpris]
+# Quickshell.Services.Mpris drives Media.qml, and MediaPill/MediaPopout do their
+# own eliding and scrolling. This module was the single most expensive thing in
+# the daemon: `scroll` woke a ticker every `scroll_speed` ms to advance a marquee
+# offset for a Waybar that is not running, and each tick drove the signaler.
+enabled = false
format = "{artist} - {title}"
max_length = 20
-scroll = true
+scroll = true
scroll_speed = 500
scroll_separator = " /// "
@@ -81,10 +99,14 @@ enabled = false
format = "{layout}"
[backlight]
-enable = true
+# Was `enable = true` — not a key fluxo reads, so this section was silently
+# running on its default of enabled. Brightness.qml reads
+# /sys/class/backlight directly, so the D-Bus watcher here is redundant.
+enabled = false
format = "{percentage}"
[dnd]
-enabled = true
+# Notifs.qml owns do-not-disturb via Quickshell.Services.Notifications.
+enabled = false
format_dnd = ""
format_normal = ""
diff --git a/quickshell/bar/ActiveWindow.qml b/quickshell/bar/ActiveWindow.qml
index f143c27..3dc263b 100644
--- a/quickshell/bar/ActiveWindow.qml
+++ b/quickshell/bar/ActiveWindow.qml
@@ -22,6 +22,11 @@ Item {
readonly property string incomingTitle: mine && toplevel.title ? toplevel.title : "—"
property string displayedTitle: "—"
+ // Which toplevel the landing transition last played for. The landing is a
+ // focus-change stamp, so it has to key off window identity rather than off
+ // the caption text — see commitTitle().
+ property var landedToplevel: null
+
// The bar shrinks this when the three clusters would otherwise collide.
property real maxWidth: 240
@@ -67,11 +72,28 @@ Item {
}
}
+ // Adopt the settled caption, and stamp it only when focus actually moved.
+ //
+ // The landing used to restart on any title change, which is not the same
+ // thing: plenty of windows rewrite their own title on a timer — a terminal
+ // running a task with a spinner or a percentage, a browser tab with a live
+ // clock — and each rewrite restarted seven overlapping animations totalling
+ // over half a second. At a title churning twice a second on a 240 Hz screen
+ // the bar never stopped animating, and repainting it that hard measured at
+ // roughly 18% of a CPU core with nothing else happening. Keying the stamp to
+ // window identity restores what the effect was described as doing.
function commitTitle(): void {
- if (root.displayedTitle === root.incomingTitle)
+ const focusMoved = root.toplevel !== root.landedToplevel;
+
+ if (root.displayedTitle !== root.incomingTitle)
+ root.displayedTitle = root.incomingTitle;
+ else if (!focusMoved)
return;
- root.displayedTitle = root.incomingTitle;
- titleLanding.restart();
+
+ if (focusMoved) {
+ root.landedToplevel = root.toplevel;
+ titleLanding.restart();
+ }
}
// Browser tabs, terminals and editors can update titles several times in a
@@ -85,7 +107,17 @@ Item {
}
onIncomingTitleChanged: titleSettle.restart()
- Component.onCompleted: displayedTitle = incomingTitle
+
+ // Focus can move to a window whose caption happens to match the outgoing
+ // one, which changes no title and so would otherwise never land.
+ onToplevelChanged: titleSettle.restart()
+
+ Component.onCompleted: {
+ displayedTitle = incomingTitle;
+ // Adopt the current window silently, so a config reload does not play a
+ // focus-change stamp for a focus that did not change.
+ landedToplevel = toplevel;
+ }
// A quick crimson wipe under the title on focus change.
Rectangle {
diff --git a/quickshell/bar/BarPill.qml b/quickshell/bar/BarPill.qml
index 611c3c5..4c13c26 100644
--- a/quickshell/bar/BarPill.qml
+++ b/quickshell/bar/BarPill.qml
@@ -109,15 +109,20 @@ Item {
// Track progress, shown until the pointer arrives and the hover rule
// takes the band over.
+ //
+ // Deliberately untweened. MPRIS position is polled once a second, and a
+ // 480 ms ease on a 1 Hz input meant this rule was mid-animation roughly
+ // half of every second — about 115 animated frames per second on a
+ // 240 Hz screen, each one rebuilding this Skew's Shape geometry through
+ // the curve renderer and re-rendering the pill's layer textures with it.
+ // Stepping once per second costs 1 frame instead of 115, matches the rate
+ // the underlying data actually arrives at, and makes a seek land exactly
+ // where it was dropped instead of gliding there.
Skew {
height: parent.height
width: parent.width * Math.max(0, Math.min(1, root.progress))
color: Theme.accent
visible: root.progress >= 0 && !root.hovered && !root.active
-
- Behavior on width {
- NumberAnimation { duration: 480; easing.type: Easing.OutQuad }
- }
}
Skew {
diff --git a/quickshell/bar/MediaPill.qml b/quickshell/bar/MediaPill.qml
index a45c20f..fadb305 100644
--- a/quickshell/bar/MediaPill.qml
+++ b/quickshell/bar/MediaPill.qml
@@ -1,5 +1,6 @@
import QtQuick
import QtQuick.Shapes
+import QtQuick.Window
import Quickshell
import "root:/config"
import "root:/components"
@@ -126,35 +127,77 @@ BarPill {
// Compact play-state equaliser. It moves only while audio is playing
// and freezes into three quiet bars when paused.
+ //
+ // Sampled from a timer rather than tweened by an infinite
+ // NumberAnimation. DP-1 runs at 240 Hz, and a frame-synced animation
+ // here repainted the entire bar 240 times a second: this pill sits
+ // inside ancestors that use layer.enabled together with
+ // Shape.CurveRenderer, and the album art carries a MultiEffect pass, so
+ // every one of those frames re-rendered several layer textures. Measured
+ // on its own, this one animation cost about two thirds of a CPU core
+ // whenever anything was playing.
+ //
+ // The timer samples a sine, it does not step through arbitrary levels: a
+ // low tick rate only looks like the original if consecutive ticks stay
+ // near each other. Each bar keeps the eased breathing motion and the
+ // period it had before — the curve is just read 25 times a second
+ // instead of 240.
Row {
+ id: eq
anchors.verticalCenter: parent.verticalCenter
spacing: 2
+ // Sampling interval — the one knob trading smoothness against cost.
+ // Frames are the whole expense: a full-bar repaint runs about 0.3% of
+ // a core (the bar spans 2544x40 on a 240 Hz output), so cost scales
+ // linearly with this rate. 16 ms holds 60 Hz, which is visually
+ // indistinguishable from the original frame-synced tween at a quarter
+ // of its ~75%.
+ readonly property int tickMs: 16
+ property int phase: 0
+
+ // Reproduces the original per-bar tween exactly, sampled instead of
+ // frame-synced. Each bar eased between two heights with its own pair
+ // of durations, so the three differed in both swing and period — the
+ // middle bar moved barely a pixel while the outer two ran in
+ // opposition. Giving all three the full swing, as a single symmetric
+ // sine would, is three times the motion and reads far busier than
+ // this meter is supposed to.
+ function level(i: int): real {
+ const lo = 4 + (i * 5) % 13; // old first NumberAnimation `to`
+ const hi = 14 - (i * 4) % 9; // old second NumberAnimation `to`
+ const d1 = 320 + i * 90; // ...and their durations
+ const d2 = 280 + i * 70;
+
+ const t = (eq.phase * eq.tickMs) % (d1 + d2);
+ // Easing.InOutSine, which is what both halves used.
+ const ease = x => (1 - Math.cos(Math.PI * x)) / 2;
+
+ return t < d1
+ ? hi + (lo - hi) * ease(t / d1)
+ : lo + (hi - lo) * ease((t - d1) / d2);
+ }
+
+ Timer {
+ interval: eq.tickMs
+ running: Media.playing && eq.visible && (eq.Window.window?.visible ?? true)
+ repeat: true
+ onTriggered: eq.phase++
+ // Restart the cycle from its trough, so playback always begins
+ // from a settled meter rather than mid-swing.
+ onRunningChanged: if (!running) eq.phase = 0
+ }
+
Repeater {
model: 3
Rectangle {
required property int index
width: 3
- height: 6
+ height: Media.playing ? eq.level(index) : 6
anchors.verticalCenter: parent.verticalCenter
color: Media.playing ? Theme.primary : Theme.muted
- SequentialAnimation on height {
- running: Media.playing
- loops: Animation.Infinite
- NumberAnimation {
- to: 4 + (index * 5) % 13
- duration: 320 + index * 90
- easing.type: Easing.InOutSine
- }
- NumberAnimation {
- to: 14 - (index * 4) % 9
- duration: 280 + index * 70
- easing.type: Easing.InOutSine
- }
- }
-
Behavior on color {
ColorAnimation { duration: Theme.durBase }
}
diff --git a/quickshell/bar/Workspaces.qml b/quickshell/bar/Workspaces.qml
index ae6d433..c6f582e 100644
--- a/quickshell/bar/Workspaces.qml
+++ b/quickshell/bar/Workspaces.qml
@@ -132,8 +132,13 @@ Item {
cursorShape: Qt.PointingHandCursor
}
+ // Hyprland evaluates IPC `dispatch` payloads as Lua now that the
+ // config is Lua (hyprland.lua rather than hyprland.conf), so the
+ // old `dispatch workspace 3` string is a Lua syntax error and the
+ // click silently does nothing. Dispatchers have to be called the
+ // same way lua/binds/workspaces.lua calls them.
TapHandler {
- onTapped: Hyprland.dispatch("workspace " + pip.wsId)
+ onTapped: Hyprland.dispatch("hl.dsp.focus({workspace = " + pip.wsId + "})")
}
}
}
diff --git a/quickshell/components/Marquee.qml b/quickshell/components/Marquee.qml
index 2fcb668..6aae228 100644
--- a/quickshell/components/Marquee.qml
+++ b/quickshell/components/Marquee.qml
@@ -1,4 +1,5 @@
import QtQuick
+import QtQuick.Window
import "root:/config"
// Horizontally scrolling text that only moves when it actually overflows, and
@@ -18,6 +19,13 @@ Item {
readonly property bool overflowing: label.implicitWidth > width + 1
+ // An Item inside an unmapped PopupWindow still reports itself visible, so a
+ // marquee in a closed popout would keep its infinite animation alive. That
+ // animation writes QML properties on every frame — at 240 Hz, three closed
+ // popout marquees are enough to keep the whole animation driver spinning and
+ // burn a fifth of a core with nothing on screen. Follow the window instead.
+ readonly property bool onScreen: Window.window?.visible ?? true
+
clip: true
implicitHeight: label.implicitHeight
implicitWidth: label.implicitWidth
@@ -38,7 +46,7 @@ Item {
SequentialAnimation {
id: scroll
- running: root.running && root.overflowing && root.visible
+ running: root.running && root.overflowing && root.visible && root.onScreen
loops: Animation.Infinite
PauseAnimation { duration: root.pause }
diff --git a/quickshell/services/Sys.qml b/quickshell/services/Sys.qml
index 2e58188..1235a6e 100644
--- a/quickshell/services/Sys.qml
+++ b/quickshell/services/Sys.qml
@@ -7,12 +7,18 @@ import Quickshell.Io
// Hardware telemetry, sourced from the user's `fluxo` daemon. fluxo's hardware
// modules are configured to emit raw pipe-delimited values (see
// ~/.config/fluxo/config.toml); everything here is parsing and formatting.
+//
+// Fed by a single long-lived `fluxo stream` subscription. This used to be two
+// Timers driving seven separate `Process` objects, each forking a `fluxo
+// ` client — five forks every 2 s plus two more every 20 s, so about
+// 2.5 process spawns a second, each one exec'ing and dynamically linking a 13 MB
+// binary to read a number the daemon already had in memory, and each metric
+// still up to one interval stale. `fluxo stream` keeps one connection open and
+// pushes a JSON line only when a module's output actually changes: no forks, and
+// values land as soon as the daemon has them.
Singleton {
id: root
- // Poll interval. The bar reads these continuously, so keep it modest.
- property int interval: 2000
-
// ------------------------------------------------------------------ cpu
property real cpuUsage: 0
property real cpuTemp: 0
@@ -56,157 +62,112 @@ Singleton {
// UPower drives the battery UI; fluxo supplies the TLP/AC detail line.
property string powerDetail: ""
- // fluxo wraps values in zero-width spaces for Waybar's benefit.
+ // The one-shot `fluxo ` client wraps text in zero-width spaces so
+ // Waybar's proportional font stops reflowing. The stream deliberately does
+ // not, but stripping them costs nothing and keeps this parser usable against
+ // either source.
function _clean(s: string): string {
return (s || "").replace(//g, "").trim();
}
- function _fields(json: string): var {
- try {
- const o = JSON.parse(json);
- return { parts: root._clean(o.text).split("|"), obj: o };
- } catch (e) {
- return null;
- }
- }
-
function _num(v): real {
const n = parseFloat(v);
return isNaN(n) ? 0 : n;
}
- Timer {
- interval: root.interval
+ // ------------------------------------------------------------------ feed
+ // Modules fluxo pushes to us. `disk` streams the default mount, which fluxo
+ // resolves to "/" (see `signaler_default_args` in its registry).
+ readonly property var modules: ["cpu", "mem", "gpu", "net", "sys", "disk", "power"]
+
+ // One line of the stream: {"module":"cpu","text":"7.4|41.0","tooltip":...}.
+ function _ingest(line: string): void {
+ let ev;
+ try {
+ ev = JSON.parse(line);
+ } catch (e) {
+ return;
+ }
+ if (!ev || !ev.module)
+ return;
+
+ const parts = root._clean(ev.text).split("|");
+ const tip = ev.tooltip || "";
+
+ switch (ev.module) {
+ case "cpu":
+ root.cpuUsage = root._num(parts[0]);
+ root.cpuTemp = root._num(parts[1]);
+ root.cpuModel = tip;
+ break;
+ case "mem":
+ root.memUsed = root._num(parts[0]);
+ root.memTotal = root._num(parts[1]);
+ break;
+ case "gpu":
+ // fluxo emits a single non-numeric field ("No GPU") when it cannot
+ // find one, so field count is the availability test.
+ if (parts.length < 4) {
+ root.gpuAvailable = false;
+ break;
+ }
+ root.gpuAvailable = true;
+ root.gpuUsage = root._num(parts[0]);
+ root.gpuVramUsed = root._num(parts[1]);
+ root.gpuVramTotal = root._num(parts[2]);
+ root.gpuTemp = root._num(parts[3]);
+ const gm = /Model:\s*(.+)/.exec(tip);
+ if (gm)
+ root.gpuModel = gm[1];
+ break;
+ case "net":
+ root.netInterface = parts[0] || "";
+ root.netIp = parts[1] || "";
+ root.netRx = root._num(parts[2]);
+ root.netTx = root._num(parts[3]);
+ break;
+ case "sys":
+ root.uptime = parts[0] || "";
+ root.load1 = root._num(parts[1]);
+ root.load5 = root._num(parts[2]);
+ root.load15 = root._num(parts[3]);
+ const pm = /Processes:\s*(\d+)/.exec(tip);
+ if (pm)
+ root.procs = parseInt(pm[1]);
+ break;
+ case "disk":
+ root.diskUsed = root._num(parts[1]);
+ root.diskTotal = root._num(parts[2]);
+ root.diskPercent = ev.percentage || 0;
+ const fm = /Free:\s*(\S+)/.exec(tip);
+ root.diskFree = fm ? fm[1] : "";
+ break;
+ case "power":
+ root.powerDetail = tip;
+ break;
+ }
+ }
+
+ Process {
+ id: source
+ command: ["fluxo", "stream"].concat(root.modules)
running: true
- repeat: true
- triggeredOnStart: true
- onTriggered: {
- cpuReader.running = true;
- memReader.running = true;
- gpuReader.running = true;
- netReader.running = true;
- sysReader.running = true;
+
+ stdout: SplitParser {
+ onRead: data => root._ingest(data)
}
+
+ // The daemon restarting (or not being up yet at login) drops us. Retry
+ // on a slow cadence: this is a reconnect, not a poll, so it only ever
+ // fires while the stream is actually down.
+ onExited: retry.restart()
}
- // Disk and battery move slowly; poll them a tenth as often.
Timer {
- interval: root.interval * 10
- running: true
- repeat: true
- triggeredOnStart: true
- onTriggered: {
- diskReader.running = true;
- powerReader.running = true;
- }
- }
-
- Process {
- id: cpuReader
- command: ["fluxo", "cpu"]
- stdout: StdioCollector {
- onStreamFinished: {
- const f = root._fields(text);
- if (!f) return;
- root.cpuUsage = root._num(f.parts[0]);
- root.cpuTemp = root._num(f.parts[1]);
- root.cpuModel = f.obj.tooltip || "";
- }
- }
- }
-
- Process {
- id: memReader
- command: ["fluxo", "mem"]
- stdout: StdioCollector {
- onStreamFinished: {
- const f = root._fields(text);
- if (!f) return;
- root.memUsed = root._num(f.parts[0]);
- root.memTotal = root._num(f.parts[1]);
- }
- }
- }
-
- Process {
- id: gpuReader
- command: ["fluxo", "gpu"]
- stdout: StdioCollector {
- onStreamFinished: {
- const f = root._fields(text);
- if (!f || f.parts.length < 4) {
- root.gpuAvailable = false;
- return;
- }
- root.gpuAvailable = true;
- root.gpuUsage = root._num(f.parts[0]);
- root.gpuVramUsed = root._num(f.parts[1]);
- root.gpuVramTotal = root._num(f.parts[2]);
- root.gpuTemp = root._num(f.parts[3]);
- const m = /Model:\s*(.+)/.exec(f.obj.tooltip || "");
- if (m) root.gpuModel = m[1];
- }
- }
- }
-
- Process {
- id: diskReader
- command: ["fluxo", "disk", "/"]
- stdout: StdioCollector {
- onStreamFinished: {
- const f = root._fields(text);
- if (!f) return;
- root.diskUsed = root._num(f.parts[1]);
- root.diskTotal = root._num(f.parts[2]);
- root.diskPercent = f.obj.percentage || 0;
- const m = /Free:\s*(\S+)/.exec(f.obj.tooltip || "");
- root.diskFree = m ? m[1] : "";
- }
- }
- }
-
- Process {
- id: netReader
- command: ["fluxo", "net"]
- stdout: StdioCollector {
- onStreamFinished: {
- const f = root._fields(text);
- if (!f) return;
- root.netInterface = f.parts[0] || "";
- root.netIp = f.parts[1] || "";
- root.netRx = root._num(f.parts[2]);
- root.netTx = root._num(f.parts[3]);
- }
- }
- }
-
- Process {
- id: sysReader
- command: ["fluxo", "sys"]
- stdout: StdioCollector {
- onStreamFinished: {
- const f = root._fields(text);
- if (!f) return;
- root.uptime = f.parts[0] || "";
- root.load1 = root._num(f.parts[1]);
- root.load5 = root._num(f.parts[2]);
- root.load15 = root._num(f.parts[3]);
- const m = /Processes:\s*(\d+)/.exec(f.obj.tooltip || "");
- if (m) root.procs = parseInt(m[1]);
- }
- }
- }
-
- Process {
- id: powerReader
- command: ["fluxo", "power"]
- stdout: StdioCollector {
- onStreamFinished: {
- try {
- root.powerDetail = JSON.parse(text).tooltip || "";
- } catch (e) {}
- }
- }
+ id: retry
+ interval: 2000
+ repeat: false
+ onTriggered: source.running = true
}
// ------------------------------------------------------------ formatting