diff options
| -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 |
