diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-18 18:20:27 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-18 18:20:27 +0200 |
| commit | 37cabe2fea2f86d481516aae6ddaeb14bac2e64f (patch) | |
| tree | 1249429fa55c501f5b6fa74823ce6eba8130ea88 | |
| parent | 46d5607764fb29ae5dbbe001519571f934379917 (diff) | |
| download | conky-theme-udt-37cabe2fea2f86d481516aae6ddaeb14bac2e64f.tar.gz conky-theme-udt-37cabe2fea2f86d481516aae6ddaeb14bac2e64f.zip | |
feat: add a breaktimer card
Replaces the Waybar module that used to show breaktimer.sh with a card
on the dashboard. The card's big value is the phase countdown, coloured
by phase with the same language the Waybar module used, and two rows
name the phase and what the countdown leads to.
Data comes from bin/breaktimer-sample.sh, run by conky at execi 5, which
reads the daemon's runtime files into a cache. The sampler owns the
kill -0 liveness check, because conky's Lua cannot run a process and a
dead daemon must read as 'fermo' rather than a stale 'running'.
Drops outside the work-hours window freeze the countdown while the phase
stays 'working'. The sampler detects this from the remain file's age and
the card reads it as 'outside working hours', which also covers a hung
daemon. The phase words and colour roles live in data.breaktimer_parse
so they are unit-tested; the card only draws the result.
| -rwxr-xr-x | bin/breaktimer-sample.sh | 70 | ||||
| -rw-r--r-- | conky.conf.in | 5 | ||||
| -rw-r--r-- | dashboard.lua | 2 | ||||
| -rw-r--r-- | lib/data.lua | 73 | ||||
| -rw-r--r-- | test/fixtures/breaktimer_cache | 6 | ||||
| -rw-r--r-- | test/test_data.lua | 62 | ||||
| -rw-r--r-- | widgets/breaktimer.lua | 91 |
7 files changed, 307 insertions, 2 deletions
diff --git a/bin/breaktimer-sample.sh b/bin/breaktimer-sample.sh new file mode 100755 index 0000000..2bd6f85 --- /dev/null +++ b/bin/breaktimer-sample.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# Sample breaktimer's state into a cache file, for widgets/breaktimer.lua. +# +# Conky's Lua cannot run a process, so it cannot call `kill -0` to learn whether +# the daemon is alive, and shelling out every two seconds to ask breaktimer.sh +# `status` would parse prose rather than read the four small files the daemon +# already writes under $XDG_RUNTIME_DIR. Those files are the whole interface; +# this script is the one place that interprets them. + +set -u + +CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/udt" +CACHE="$CACHE_DIR/breaktimer.txt" + +umask 077 +mkdir -p "$CACHE_DIR" + +TMP="$CACHE.tmp.$$" +trap 'rm -f "$TMP"' EXIT + +RUNTIME="${XDG_RUNTIME_DIR:-/tmp}" +PID_FILE="$RUNTIME/breaktimer.pid" +STATE_FILE="$RUNTIME/breaktimer.state" +PHASE_FILE="$RUNTIME/breaktimer.phase" +REMAIN_FILE="$RUNTIME/breaktimer.remain" + +# The daemon's own tick. Used only to decide how stale the remain file may be +# before the clock counts as frozen. +TICK=5 + +running=no +if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE" 2>/dev/null)" 2>/dev/null; then + running=yes +fi + +state=stopped +phase=stopped +remain=0 +if [ "$running" = yes ]; then + state=$(cat "$STATE_FILE" 2>/dev/null) + phase=$(cat "$PHASE_FILE" 2>/dev/null) + remain=$(cat "$REMAIN_FILE" 2>/dev/null) +fi +[ -z "$state" ] && state=stopped +[ -z "$phase" ] && phase=stopped +case "$remain" in ''|*[!0-9]*) remain=0 ;; esac + +# Outside the work-hours window the daemon leaves the phase at 'working' without +# decrementing, so the remain file stops being rewritten; its age is the honest +# signal that the clock is frozen. Three ticks of slack, so a sample landing a +# moment before the next tick never reads as frozen. A paused daemon freezes the +# file too but reports 'paused' and is handled on its own, and a hung daemon +# freezes it as well, which is a state worth showing rather than hiding. +frozen=no +if [ "$running" = yes ] && [ "$state" = running ] && [ "$phase" = working ] \ + && [ -f "$REMAIN_FILE" ]; then + mt=$(stat -c %Y "$REMAIN_FILE" 2>/dev/null || echo 0) + [ $(( $(date +%s) - mt )) -gt $((TICK * 3)) ] && frozen=yes +fi + +{ + echo "generated $(date +%s)" + echo "running $running" + echo "state $state" + echo "phase $phase" + echo "remain $remain" + echo "frozen $frozen" +} > "$TMP" + +mv -f "$TMP" "$CACHE" diff --git a/conky.conf.in b/conky.conf.in index 44caf4e..0d88ab6 100644 --- a/conky.conf.in +++ b/conky.conf.in @@ -51,5 +51,6 @@ conky.config = { -- fires on schedule: verified with a probe config whose only text was an execi -- producing no output. This is what runs the weather, disks and cache -- samplers, and tying it to conky means nothing fetches while the dashboard is --- down. -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}]] +-- 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}]] diff --git a/dashboard.lua b/dashboard.lua index 9d1e515..b9316bb 100644 --- a/dashboard.lua +++ b/dashboard.lua @@ -24,6 +24,8 @@ local layout = { { widget = 'weather', col = 1, row = 7, w = 3, h = 5 }, -- Left of the disks row, in the board's open middle column. { widget = 'calendar', col = 2.5, row = 1, w = 3, h = 6 }, + -- Clustered near weather and clock + { widget = 'breaktimer', col = 4, row = 7, w = 2, h = 3 }, { widget = 'system', col = 14, row = 4, w = 3, h = 5 }, { widget = 'gpu', col = 12, row = 4, w = 2, h = 3 }, { widget = 'disks', col = 12, row = 1, w = 5, h = 3 }, diff --git a/lib/data.lua b/lib/data.lua index 6f1f7ea..8e60d6d 100644 --- a/lib/data.lua +++ b/lib/data.lua @@ -517,5 +517,78 @@ function M.month_shape(year, month) return last.day, (first.wday + 5) % 7 + 1 end +-- The breaktimer cache written by bin/breaktimer-sample.sh. +-- +-- Returns a table describing the daemon, or nil when the cache is absent or +-- carries no 'generated' key (the sampler never ran). The display strings are +-- derived here rather than in the widget so the phase-to-word and +-- phase-to-colour decisions can be tested without Cairo: +-- +-- { running, state, phase, frozen, remain, +-- countdown = '24:31' or '--', +-- phase_label = 'working' | 'micro-pausa' | 'pausa lunga' | 'in pausa' +-- | 'outside working hours' | 'fermo', +-- next = 'pausa' | 'lavoro' | '--', +-- role = 'ok' | 'heading' | 'highlight' | 'warning' | 'label' } +-- +-- `frozen` is the sampler's read of whether the clock is ticking: the phase is +-- 'working' but the remain file has stopped being rewritten. Outside the work +-- window that is normal and reads as such; a hung daemon looks the same, and +-- should. +-- +-- The countdown leads to the next phase, but the daemon keeps the cycle count +-- in memory and never writes it, so whether the NEXT break is a micro or a long +-- one is not knowable here. 'pausa' is the honest answer; the phase word carries +-- the rest. +function M.breaktimer_parse(text) + if type(text) ~= 'string' then return nil end + local kv = M.kv_parse(text) + if not kv.generated then return nil end + + local running = kv.running == 'yes' + local state = kv.state or 'stopped' + local phase = kv.phase or 'stopped' + local remain = tonumber(kv.remain) or 0 + if remain < 0 then remain = 0 end + + local out = { + running = running, + state = state, + phase = phase, + frozen = kv.frozen == 'yes', + remain = remain, + } + + -- Not running and stopped are the same thing on the card. The sampler's + -- liveness check is what turns a dead PID into this reading: the state file + -- can still say 'running' after a crash or a kill -9. + if not running or state == 'stopped' then + out.countdown = '--' + out.phase_label = 'fermo' + out.next = '--' + out.role = 'label' + return out + end + + out.countdown = string.format('%d:%02d', math.floor(remain / 60), remain % 60) + + if state == 'paused' then + out.phase_label = 'in pausa' + out.role = 'warning' + elseif out.frozen then + out.phase_label = 'outside working hours' + out.role = 'label' + else + local labels = { working = 'working', breaking = 'micro-pausa', + longbreak = 'pausa lunga' } + local roles = { working = 'ok', breaking = 'heading', + longbreak = 'highlight' } + out.phase_label = labels[phase] or phase + out.role = roles[phase] or 'value' + end + + out.next = (phase == 'working') and 'pausa' or 'lavoro' + return out +end return M diff --git a/test/fixtures/breaktimer_cache b/test/fixtures/breaktimer_cache new file mode 100644 index 0000000..23a147a --- /dev/null +++ b/test/fixtures/breaktimer_cache @@ -0,0 +1,6 @@ +generated 1758200000 +running yes +state running +phase working +remain 1471 +frozen no diff --git a/test/test_data.lua b/test/test_data.lua index 00616d6..65f92e1 100644 --- a/test/test_data.lua +++ b/test/test_data.lua @@ -383,4 +383,66 @@ assert(ndays == 30 and first == 2, 'september 2026, got ' .. ndays .. '/' .. fir assert((data.month_shape(2024, 2)) == 29, 'leap february has 29 days') assert((data.month_shape(2026, 12)) == 31, 'december rolls over correctly') +-- === breaktimer =========================================================== +-- The phase-to-word and phase-to-colour decisions live in breaktimer_parse so +-- they can be checked here; the card only draws the result. +local bt = data.breaktimer_parse(read('test/fixtures/breaktimer_cache')) +assert(bt, 'breaktimer cache parses') +assert(bt.countdown == '24:31', 'countdown, got ' .. tostring(bt.countdown)) +assert(bt.phase_label == 'working', 'phase label, got ' .. tostring(bt.phase_label)) +assert(bt.next == 'pausa', 'next during work is a break, got ' .. tostring(bt.next)) +assert(bt.role == 'ok', 'working is ok, got ' .. tostring(bt.role)) + +-- Every phase has its own word and colour role. Build caches rather than files: +-- the sampler's output is six plain lines and the parse is the seam that +-- matters. +local function bt_cache(fields) + local out = { 'generated 1', 'running ' .. (fields.running or 'yes'), + 'state ' .. (fields.state or 'running'), + 'phase ' .. (fields.phase or 'working'), + 'remain ' .. (fields.remain or 60), + 'frozen ' .. (fields.frozen or 'no') } + return table.concat(out, '\n') .. '\n' +end + +local b + +b = data.breaktimer_parse(bt_cache({ phase = 'breaking', remain = 125 })) +assert(b.phase_label == 'micro-pausa' and b.role == 'heading' and b.next == 'lavoro', + 'micro-pause: ' .. b.phase_label .. '/' .. b.role .. '/' .. b.next) +assert(b.countdown == '2:05', 'micro-pause countdown, got ' .. b.countdown) + +b = data.breaktimer_parse(bt_cache({ phase = 'longbreak', remain = 600 })) +assert(b.phase_label == 'pausa lunga' and b.role == 'highlight' and b.next == 'lavoro', + 'long pause: ' .. b.phase_label .. '/' .. b.role) + +-- Paused freezes the number, but the phase underneath still decides what the +-- countdown leads to. +b = data.breaktimer_parse(bt_cache({ state = 'paused', phase = 'working' })) +assert(b.phase_label == 'in pausa' and b.role == 'warning' and b.next == 'pausa', + 'paused: ' .. b.phase_label .. '/' .. b.role .. '/' .. b.next) + +-- Frozen: the clock stopped while the phase stayed 'working'. Outside working +-- hours, or a hung daemon; both read the same. +b = data.breaktimer_parse(bt_cache({ frozen = 'yes' })) +assert(b.phase_label == 'outside working hours' and b.role == 'label', + 'frozen: ' .. b.phase_label .. '/' .. b.role) +assert(b.next == 'pausa', 'frozen working still leads to a break') + +-- A dead daemon: the sampler's liveness check sets running no even when the +-- state file was left saying running. +b = data.breaktimer_parse(bt_cache({ running = 'no' })) +assert(b.countdown == '--' and b.phase_label == 'fermo' and b.next == '--' + and b.role == 'label', 'stopped reads as fermo, got ' .. b.phase_label) + +-- A negative remain must clamp, not print '-1:59'. +b = data.breaktimer_parse(bt_cache({ remain = -5 })) +assert(b.countdown == '0:00', 'negative remain clamps, got ' .. b.countdown) + +-- No 'generated' key means the sampler never ran, which is different from a +-- stopped daemon (which still writes a cache). +assert(data.breaktimer_parse('running no\n') == nil, 'no generated key: nil') +assert(data.breaktimer_parse('') == nil, 'empty cache: nil') +assert(data.breaktimer_parse(nil) == nil, 'nil cache: nil') + print('test_data: all assertions passed') diff --git a/widgets/breaktimer.lua b/widgets/breaktimer.lua new file mode 100644 index 0000000..698be80 --- /dev/null +++ b/widgets/breaktimer.lua @@ -0,0 +1,91 @@ +-- Breaktimer: the phase of breaktimer.sh and the time left in it. +-- +-- Everything comes from a cache file written by bin/breaktimer-sample.sh: +-- conky's Lua cannot run a process, so it can neither call `kill -0` to learn +-- whether the daemon is alive nor ask breaktimer.sh itself. The sampler owns +-- that; this card owns only how the state looks. +-- +-- The countdown is the big value, coloured by phase: green for a work block, +-- blue during a micro-pause, purple during a long one, yellow when paused, and +-- the dim label colour when the clock is frozen. That is the same phase-to- +-- colour language the old Waybar module used, mapped onto the palette roles. + +local card = require 'lib.card' +local data = require 'lib.data' + +local CACHE = (os.getenv('XDG_CACHE_HOME') or (os.getenv('HOME') .. '/.cache')) + .. '/udt/breaktimer.txt' + +local M = {} + +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 LABEL_F, BIG_F, ROW_F = 0.30, 0.85, 0.32 + + local bt = data.breaktimer_parse(data.slurp(CACHE) or '') + + -- No cache at all: the sampler has not run. Name it, as the slackware card + -- does, rather than leaving a blank cell that is indistinguishable from a + -- crash. A stopped daemon is NOT this case: the sampler still writes a cache, + -- with running no, which the card draws as 'fermo'. + if not bt then + local S = clamp(inner.h * 0.94 / (1.78 + 2 * 0.72), 10, 72) + S = math.min(S, card.fit_unit(cr, inner.w * 0.96, { + { { 'BREAKTIMER', card.FONT_MONO, LABEL_F } }, + { { 'no breaktimer data', card.FONT_UI, 0.36 } }, + { { 'bin/breaktimer-sample.sh', card.FONT_MONO, ROW_F } }, + }, 100)) + card.font(cr, card.FONT_MONO, S * LABEL_F, false) + card.rgba(cr, colors.label) + card.text(cr, inner.x, inner.y + S * LABEL_F, 'BREAKTIMER') + card.font(cr, card.FONT_UI, S * 0.36, true) + card.text(cr, inner.x, inner.y + S + S * LABEL_F * 2.6, 'no breaktimer data') + card.font(cr, card.FONT_MONO, S * ROW_F, false) + card.text(cr, inner.x, inner.y + S + S * LABEL_F * 2.6 + S * 0.72, + 'bin/breaktimer-sample.sh') + return + end + + local rows = { + { 'PHASE', bt.phase_label }, + { 'NEXT', bt.next }, + } + + -- Fluid type: the height budget grows the content to fill the cell, the + -- measured width fit pulls it back where the phase label would overrun. + local groups = { + { { 'BREAKTIMER', card.FONT_MONO, LABEL_F }, + { bt.countdown, card.FONT_HEAVY, BIG_F } }, + } + for _, r in ipairs(rows) do + groups[#groups + 1] = { { r[1], card.FONT_MONO, ROW_F }, + { tostring(r[2]), card.FONT_MONO, ROW_F } } + end + local S = clamp(math.min(inner.h * 0.94 / (1.5 + #rows * 0.66), + card.fit_unit(cr, inner.w * 0.96, groups, 100)), 10, 72) + local label_size = S * LABEL_F + local row_size = S * ROW_F + + card.font(cr, card.FONT_HEAVY, S * BIG_F, false) + card.rgba(cr, colors[bt.role] or colors.value) + card.text_right(cr, inner.x + inner.w, inner.y + S * BIG_F, bt.countdown) + + card.font(cr, card.FONT_MONO, label_size, false) + card.rgba(cr, colors.label) + card.text(cr, inner.x, inner.y + label_size, 'BREAKTIMER') + + local ey = inner.y + S * BIG_F + row_size * 1.6 + local step = row_size * 2.0 + card.font(cr, card.FONT_MONO, row_size, false) + for _, r in ipairs(rows) do + if ey + step > inner.y + inner.h then break end + card.rgba(cr, colors.label) + card.text(cr, inner.x, ey, r[1]) + card.rgba(cr, colors.value) + card.text_right(cr, inner.x + inner.w, ey, tostring(r[2])) + ey = ey + step + end +end + +return M |
