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