aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-30 19:56:48 +0200
committerDanilo M. <danix@danix.xyz>2026-09-30 19:56:48 +0200
commit96ac1ee65da31055704e0a14cafacc33914d7e86 (patch)
tree8a8fa33ec27607f40d6d23661a89681ffbaa4456
parente55896ce41d6165105671da5b6f33813106ef1b8 (diff)
downloadconky-theme-udt-96ac1ee65da31055704e0a14cafacc33914d7e86.tar.gz
conky-theme-udt-96ac1ee65da31055704e0a14cafacc33914d7e86.zip
perf(dashboard): draw at 0.1s only while the dashboard is visible
Conky gives Lua no way to request one redraw, so a scroll showed up to 2s late. Pointer enter and leave, which Hyprland sends on every toggle of the special workspace, now switch conky_set_update_interval between 0.1s and the config's 2s. At that rate the CPU and network counters would sample over 0.1s windows and the chart would fill 20x faster, so both now sample through data.new_gate on the /proc/uptime clock, about every 2s, and redraw their last values in between. The samplers move to texeci: execi runs in the main loop and a 0.7s nvtable or an Immich download stalled the scroll. Measured: about 12% of one core while visible, 0.5% while hidden. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--README.md22
-rw-r--r--conky.conf.in6
-rw-r--r--dashboard.lua17
-rw-r--r--lib/data.lua20
-rw-r--r--test/test_data.lua7
-rw-r--r--widgets/immich.lua3
-rw-r--r--widgets/network.lua16
-rw-r--r--widgets/system.lua23
8 files changed, 87 insertions, 27 deletions
diff --git a/README.md b/README.md
index 59e5797..3de25f7 100644
--- a/README.md
+++ b/README.md
@@ -45,7 +45,7 @@ the file. Until it works, and whenever the cache is missing, the card draws its
own chrome with `no weather data` and the thing to fix, rather than vanishing.
Fetching is scheduled by conky itself: `conky.text` carries a single
-`${execi 900 ~/.config/conky/bin/weather-fetch.sh}`, so the cache refreshes
+`${texeci 900 ~/.config/conky/bin/weather-fetch.sh}`, so the cache refreshes
every 15 minutes while the dashboard runs and never while it does not. The
widget only ever reads `~/.cache/udt/weather.json`; it makes no network call and
cannot block the draw.
@@ -85,16 +85,16 @@ The disks and cache cards read a cache file that a sampler writes; they never
call `du` or `statfs` themselves. `statfs` on an unreachable NFS path blocks,
and the disks sampler exists so that a dead server costs a stale cache rather
than a frozen draw. Sampling is scheduled by conky itself: `conky.text` carries
-an `${execi}` per sampler, even though that block renders nothing under Cairo.
-
- ${execi 900 ~/.config/conky/bin/weather-fetch.sh}
- ${execi 60 ~/.config/conky/bin/disks-sample.sh}
- ${execi 900 ~/.config/conky/bin/cache-sample.sh}
- ${execi 1800 ~/.config/conky/bin/pubip-sample.sh}
- ${execi 900 ~/.config/conky/bin/slackware-sample.sh}
- ${execi 300 ~/.config/conky/bin/calendar-sample.sh}
- ${execi 1800 ~/.config/conky/bin/immich-fetch.sh}
- ${execi 600 ~/.config/conky/bin/updates-sample.sh}
+a threaded `${texeci}` per sampler, even though that block renders nothing under Cairo.
+
+ ${texeci 900 ~/.config/conky/bin/weather-fetch.sh}
+ ${texeci 60 ~/.config/conky/bin/disks-sample.sh}
+ ${texeci 900 ~/.config/conky/bin/cache-sample.sh}
+ ${texeci 1800 ~/.config/conky/bin/pubip-sample.sh}
+ ${texeci 900 ~/.config/conky/bin/slackware-sample.sh}
+ ${texeci 300 ~/.config/conky/bin/calendar-sample.sh}
+ ${texeci 1800 ~/.config/conky/bin/immich-fetch.sh}
+ ${texeci 600 ~/.config/conky/bin/updates-sample.sh}
The disks sampler runs once a minute; the cache, Slackware and public-IP
samplers run every 15, 15 and 30 minutes respectively, because `du -sh
diff --git a/conky.conf.in b/conky.conf.in
index a0b354e..d12ffc1 100644
--- a/conky.conf.in
+++ b/conky.conf.in
@@ -56,4 +56,8 @@ conky.config = {
-- samplers, and tying it to conky means nothing fetches while the dashboard is
-- down. breaktimer runs at 5s, its daemon's own tick, so the countdown advances
-- in the steps the daemon counts in.
-conky.text = [[${execi 900 ~/.config/conky/bin/weather-fetch.sh}${execi 60 ~/.config/conky/bin/disks-sample.sh}${execi 900 ~/.config/conky/bin/cache-sample.sh}${execi 1800 ~/.config/conky/bin/pubip-sample.sh}${execi 900 ~/.config/conky/bin/slackware-sample.sh}${execi 300 ~/.config/conky/bin/calendar-sample.sh}${execi 5 ~/.config/conky/bin/breaktimer-sample.sh}${execi 1800 ~/.config/conky/bin/immich-fetch.sh}${execi 600 ~/.config/conky/bin/updates-sample.sh}]]
+--
+-- texeci, not execi: execi runs in conky's main loop and blocks the draw until
+-- the script exits. At 0.1s while visible, a 0.7s nvtable or an Immich
+-- download stalls the scroll; texeci runs each in its own thread.
+conky.text = [[${texeci 900 ~/.config/conky/bin/weather-fetch.sh}${texeci 60 ~/.config/conky/bin/disks-sample.sh}${texeci 900 ~/.config/conky/bin/cache-sample.sh}${texeci 1800 ~/.config/conky/bin/pubip-sample.sh}${texeci 900 ~/.config/conky/bin/slackware-sample.sh}${texeci 300 ~/.config/conky/bin/calendar-sample.sh}${texeci 5 ~/.config/conky/bin/breaktimer-sample.sh}${texeci 1800 ~/.config/conky/bin/immich-fetch.sh}${texeci 600 ~/.config/conky/bin/updates-sample.sh}]]
diff --git a/dashboard.lua b/dashboard.lua
index 18ddf80..1db0b6e 100644
--- a/dashboard.lua
+++ b/dashboard.lua
@@ -168,6 +168,17 @@ local function draw(cr, sw, sh, colors)
end
end
+-- Draw rate follows visibility. The dashboard lives on a special workspace
+-- and is hidden most of the time, so it idles at the config's update_interval
+-- (2s) and draws at VISIBLE_INTERVAL while shown, which is what makes the wheel
+-- scroll live: conky has no way for Lua to request a single redraw.
+--
+-- Shown and hidden are the pointer entering and leaving the window, which
+-- Hyprland sends on every toggle of the workspace. The pointer is on the
+-- focused monitor, where the special workspace opens, so enter follows show.
+local VISIBLE_INTERVAL = 0.1
+local hidden_interval = nil
+
-- Pointer events, handed to the widget whose cell is under the pointer, if it
-- defines mouse(event, rect). Coordinates are window-relative, and the window
-- is the whole surface, so they index the same grid the draw uses.
@@ -175,6 +186,12 @@ end
-- Wrapped like the draw: a fault here must not take conky down. Returning true
-- tells conky the event was consumed.
function conky_mouse(event)
+ if event.type == 'mouse_enter' or event.type == 'mouse_leave' then
+ hidden_interval = hidden_interval or conky_info.update_interval
+ conky_set_update_interval(event.type == 'mouse_enter'
+ and VISIBLE_INTERVAL or hidden_interval)
+ return false
+ end
if conky_window == nil then return false end
local sw, sh = conky_window.width, conky_window.height
for _, entry in ipairs(layout) do
diff --git a/lib/data.lua b/lib/data.lua
index 4b31f43..7ae96a7 100644
--- a/lib/data.lua
+++ b/lib/data.lua
@@ -631,6 +631,26 @@ function M.immich_parse(text)
return out
end
+-- A sampling gate for counters that are read per draw. The dashboard draws
+-- every 0.1s while it is on screen, and a CPU or byte counter sampled that
+-- often gives coarse, flickering deltas, so those cards sample on this gate
+-- and redraw their last values in between.
+--
+-- The returned function answers true at most once per `every` seconds, plus
+-- the clock it used: /proc/uptime, because os.time() counts whole seconds and
+-- a rate divided by a rounded interval is off by up to half. `now` is for the
+-- test. An unreadable clock opens the gate, which is the old per-draw behaviour.
+function M.new_gate(every)
+ local last
+ return function(now)
+ now = now or tonumber((M.slurp('/proc/uptime') or ''):match('^(%S+)'))
+ if not now then return true, nil end
+ if last and now - last < every then return false, now end
+ last = now
+ return true, now
+ end
+end
+
-- nvtable's plain table (`nvtable -C -E`), as bin/updates-sample.sh caches it:
--
-- [personal] foo-bin 1.14.9 -> 1.14.9 !! ~ 1.14.10
diff --git a/test/test_data.lua b/test/test_data.lua
index ec95fae..2d4d5c7 100644
--- a/test/test_data.lua
+++ b/test/test_data.lua
@@ -478,3 +478,10 @@ assert(not up[6].behind and not up[6].bump, 'updates row 6: current')
assert(up.upgrade == 3 and up.bump == 1 and up.current == 46 and up.missing == 18,
'updates summary')
assert(data.nvtable_parse('') == nil, 'updates: empty cache is nil')
+
+-- new_gate: opens at most once per interval on the clock it is given.
+local gate = data.new_gate(1.9)
+assert(gate(100.0) == true, 'gate: first call opens')
+assert(gate(100.5) == false, 'gate: closed inside the interval')
+assert(gate(101.95) == true, 'gate: opens after it')
+assert(gate(102.0) == false, 'gate: interval restarts from the last open')
diff --git a/widgets/immich.lua b/widgets/immich.lua
index 8590dbf..d700d8a 100644
--- a/widgets/immich.lua
+++ b/widgets/immich.lua
@@ -159,9 +159,6 @@ end
-- Scroll steps through today's photos; a left click opens the one on screen
-- in Immich and hides the dashboard, since the browser would otherwise open
-- underneath the special workspace.
---
--- ponytail: a scroll shows at the next draw, up to update_interval (2s) later.
--- Conky offers Lua no way to request a redraw.
function M.mouse(event)
if event.type == 'mouse_scroll' then
offset = offset + (event.direction == 'up' and -1 or 1)
diff --git a/widgets/network.lua b/widgets/network.lua
index 3a11521..20b0573 100644
--- a/widgets/network.lua
+++ b/widgets/network.lua
@@ -37,6 +37,11 @@ local rx_hist, tx_hist = {}, {}
local hist_len = 0
local lan_addr = nil
+-- Sampled every ~2s whatever the draw rate, so the chart keeps one point per
+-- sample rather than per draw. 1.9 not 2: draws jitter by a few ms.
+local gate = data.new_gate(1.9)
+local down, up = nil, nil
+
-- Append to a fixed-length history, dropping the oldest. A plain array with
-- table.remove(1) rather than a circular buffer with an index: the lengths
-- here are a few hundred at most and the draw is every two seconds, so the
@@ -73,8 +78,11 @@ function M.draw(cr, rect, colors)
rx_counter = rx_counter or data.new_rate_counter(interval)
tx_counter = tx_counter or data.new_rate_counter(interval)
- local down = rx_counter:sample(read_counter('rx_bytes'))
- local up = tx_counter:sample(read_counter('tx_bytes'))
+ local sampled, now = gate()
+ if sampled then
+ down = rx_counter:sample(read_counter('rx_bytes'), now)
+ up = tx_counter:sample(read_counter('tx_bytes'), now)
+ end
-- Fluid type, as every other card does: one base size S drives everything,
-- taken as the smaller of a height budget and a measured width fit.
@@ -140,8 +148,8 @@ function M.draw(cr, rect, colors)
while #rx_hist > hist_len do table.remove(rx_hist, 1) end
while #tx_hist > hist_len do table.remove(tx_hist, 1) end
end
- if down then push(rx_hist, down, hist_len) end
- if up then push(tx_hist, up, hist_len) end
+ if sampled and down then push(rx_hist, down, hist_len) end
+ if sampled and up then push(tx_hist, up, hist_len) end
-- One ceiling for both series. Scaled independently they would lie about
-- their relative size, which is the entire reason to draw them together.
diff --git a/widgets/system.lua b/widgets/system.lua
index 98e0ae1..c1cdf67 100644
--- a/widgets/system.lua
+++ b/widgets/system.lua
@@ -27,6 +27,11 @@ local TEMPS = {
local counters = {}
local total_counter = data.new_cpu_counter()
+-- Sampled every ~2s whatever the draw rate; the values between samples are
+-- the last ones read. 1.9 not 2: draws jitter by a few ms either way.
+local gate = data.new_gate(1.9)
+local load, pcts = nil, {}
+
-- Read once, not per frame. Neither can change without a reboot, and
-- re-reading /proc/cpuinfo every two seconds for a constant is waste: the file
-- repeats its model-name line once per thread.
@@ -51,11 +56,15 @@ function M.draw(cr, rect, colors)
local inner = card.card(cr, rect, colors)
local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end
- local stat = data.slurp('/proc/stat')
- local agg_total, agg_idle = data.cpu_times(stat or '')
- local load = total_counter:sample(agg_total, agg_idle)
+ if gate() then
+ local stat = data.slurp('/proc/stat') or ''
+ load = total_counter:sample(data.cpu_times(stat))
+ for i, c in ipairs(data.per_cpu_times(stat)) do
+ counters[i] = counters[i] or data.new_cpu_counter()
+ pcts[i] = counters[i]:sample(c.total, c.idle) or 0
+ end
+ end
local load_txt = load and string.format('%d%%', math.floor(load + 0.5)) or '--'
- local cores = data.per_cpu_times(stat or '')
local temp_n = #TEMPS
local cpu_txt, board_txt = hardware()
@@ -137,16 +146,14 @@ function M.draw(cr, rect, colors)
-- legitimate outcome: the guard below skips the band when it has no height.
local eh = clamp((inner.y + inner.h) - eq_top - (S * 1.13 + temp_n * step),
0, math.huge)
- local n = #cores
+ local n = #pcts
if n > 0 and eh >= 8 then
local gap = math.max(2, inner.w * 0.006)
local bw = (inner.w - gap * (n - 1)) / n
-- Below about 3px a row of sixteen bars is an illegible smear; drop to the
-- aggregate bar instead of drawing one.
if bw >= 3 then
- for i, c in ipairs(cores) do
- counters[i] = counters[i] or data.new_cpu_counter()
- local pct = counters[i]:sample(c.total, c.idle) or 0
+ for i, pct in ipairs(pcts) do
card.vbar(cr, inner.x + (i - 1) * (bw + gap), eq_top + eh, bw, eh,
pct / 100, card.threshold(pct, 25, 75, colors), colors)
end