diff options
| -rw-r--r-- | DESIGN.md | 11 | ||||
| -rw-r--r-- | README.md | 5 | ||||
| -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/card.lua | 2 | ||||
| -rw-r--r-- | lib/data.lua | 74 | ||||
| -rw-r--r-- | test/fixtures/breaktimer_cache | 6 | ||||
| -rw-r--r-- | test/test_data.lua | 62 | ||||
| -rw-r--r-- | widgets/breaktimer.lua | 79 |
10 files changed, 9 insertions, 307 deletions
@@ -38,7 +38,7 @@ Everything else exists to keep those two readable at any cell size. of a width fit and a height budget. `card.fit_unit` measures candidate rows and returns the largest base size that fits the width, and a row that would collide pulls the size down. Bigger cell, bigger type instead of empty space. - The `breaktimer` card does this; a card that cannot afford the + The `immich` card does this; a card that cannot afford the measuring pass may key off width with a clamped factor. - **Roles.** `FONT_MONO` for labels and row text, `FONT_HEAVY` for the big number, `FONT_UI` for prose (a condition description, a notice). Sizes within @@ -52,17 +52,16 @@ One language for every measured quantity, so state is read rather than decoded: | State | Role | Example | |---|---|---| -| fine | `ok` | breaktimer working | -| busy | `warning` | breaktimer paused | +| fine | `ok` | a quantity under its thresholds | +| busy | `warning` | a quantity past its lower threshold | | hot / full | `critical` | a quantity past its upper threshold | - Proportions use **25% / 75%**. - `warning` is the palette's middle role. The board should show all three states in normal use, or the language is not earning its place. - The big value's colour follows what it is: `body` for a plain number, the - threshold roles when it is a measured quantity, the phase's own role in - `breaktimer`, and `heading` when it names rather than measures (the calendar - month). + threshold roles when it is a measured quantity, and `heading` when it names + rather than measures (the calendar month). ## Glyphs @@ -89,7 +89,6 @@ Sampling is scheduled by conky itself: `conky.text` carries a threaded ${texeci 900 ~/.config/conky/bin/weather-fetch.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} The calendar sampler runs every five minutes: khal costs about 200ms of Python @@ -244,8 +243,8 @@ cell is left sparse; size it off the height alone and a short cell shrinks the text to a whisper. The robust way is a measured fit: `card.fit_unit()` returns the largest base size at which every row still fits the width, and the widget takes the smaller of that and its height budget. Larger cell, larger type; a row -that would collide pulls the size back down. The breaktimer and -immich cards do this. A card that cannot afford the measuring pass can still key off width with +that would collide pulls the size back down. The immich card +does this. A card that cannot afford the measuring pass can still key off width with a clamp, `math.max(lo, math.min(hi, inner.w * k))`; the clock does. **Decide where the slack goes, and let one element absorb it.** The clock pins diff --git a/bin/breaktimer-sample.sh b/bin/breaktimer-sample.sh deleted file mode 100755 index 2bd6f85..0000000 --- a/bin/breaktimer-sample.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/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 3e31272..995a63f 100644 --- a/conky.conf.in +++ b/conky.conf.in @@ -54,10 +54,9 @@ 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, calendar and other -- 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. +-- down. -- -- texeci, not execi: execi runs in conky's main loop and blocks the draw until -- the script exits. At 0.1s while visible, a slow sampler 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 300 ~/.config/conky/bin/calendar-sample.sh}${texeci 5 ~/.config/conky/bin/breaktimer-sample.sh}${texeci 1800 ~/.config/conky/bin/immich-fetch.sh}]] +conky.text = [[${texeci 900 ~/.config/conky/bin/weather-fetch.sh}${texeci 300 ~/.config/conky/bin/calendar-sample.sh}${texeci 1800 ~/.config/conky/bin/immich-fetch.sh}]] diff --git a/dashboard.lua b/dashboard.lua index 5545ef1..d591824 100644 --- a/dashboard.lua +++ b/dashboard.lua @@ -24,8 +24,6 @@ local layout = { { widget = 'now', col = 1, row = 7, w = 3, h = 3.75 }, -- In the board's open middle column, left of immich. { widget = 'calendar', col = 2.5, row = 1, w = 3, h = 6 }, - -- Clustered near now and clock - { widget = 'breaktimer', col = 4, row = 7, w = 2, h = 3 }, -- Centred on the board. Twice as many rows as columns because cells are -- about twice as wide as tall, so this span comes out square. { widget = 'immich', col = 5.5, row = 1, w = 4, h = 6 }, diff --git a/lib/card.lua b/lib/card.lua index 3dd4b12..221d8d2 100644 --- a/lib/card.lua +++ b/lib/card.lua @@ -224,7 +224,6 @@ end -- fontconfig alone: a missing glyph draws as an invisible blank and a wrong -- codepoint as a plausible neighbour, so neither fails visibly. M.ICON = { - breaktimer = '\u{F13AB}', -- timer calendar = '\u{F0E17}', -- calendar-month immich = '\u{F02F9}', -- image-multiple hourly = '\u{F0150}', -- clock-outline @@ -331,7 +330,6 @@ end -- -- Returns the y where the drawn content ends and S, so a card can put its -- variable band in what is left and size the band's type off the same S. --- The proportions are the breaktimer card's, which this generalises. function M.standard(cr, inner, colors, spec) local LABEL_F, BIG_F, ROW_F = 0.30, 1.0, 0.32 local rows = spec.rows or {} diff --git a/lib/data.lua b/lib/data.lua index 818bbf1..dca039a 100644 --- a/lib/data.lua +++ b/lib/data.lua @@ -162,80 +162,6 @@ 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 - -- The Immich memories index written by bin/immich-fetch.sh. -- -- Returns a list of { id, year, taken = { y, m, d, H, M }, place }, plus diff --git a/test/fixtures/breaktimer_cache b/test/fixtures/breaktimer_cache deleted file mode 100644 index 23a147a..0000000 --- a/test/fixtures/breaktimer_cache +++ /dev/null @@ -1,6 +0,0 @@ -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 01a55cc..c33e5ae 100644 --- a/test/test_data.lua +++ b/test/test_data.lua @@ -125,68 +125,6 @@ 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') - -- immich_parse: one entry per photo line, place optional, garbage skipped. local im = data.immich_parse(read('test/fixtures/immich_index')) assert(im and #im == 3, 'immich photo count, got ' .. tostring(im and #im)) diff --git a/widgets/breaktimer.lua b/widgets/breaktimer.lua deleted file mode 100644 index fa8649b..0000000 --- a/widgets/breaktimer.lua +++ /dev/null @@ -1,79 +0,0 @@ --- 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, 1.0, 0.32 - - local bt = data.breaktimer_parse(data.slurp(CACHE) or '') - - -- No cache at all: the sampler has not run. Name it, as every cached 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 - card.notice(cr, inner, colors, 'BREAKTIMER', 'no breaktimer data', - '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 = { - { { card.title_str(card.ICON.breaktimer, '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.title(cr, inner.x, inner.y + label_size, card.ICON.breaktimer, - 'BREAKTIMER', colors) - - 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 |
