aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rwxr-xr-xbin/breaktimer-sample.sh70
-rw-r--r--conky.conf.in5
-rw-r--r--dashboard.lua2
-rw-r--r--lib/data.lua73
-rw-r--r--test/fixtures/breaktimer_cache6
-rw-r--r--test/test_data.lua62
-rw-r--r--widgets/breaktimer.lua91
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