aboutsummaryrefslogtreecommitdiffstats
path: root/widgets/updates.lua
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-30 19:56:47 +0200
committerDanilo M. <danix@danix.xyz>2026-09-30 19:56:47 +0200
commite55896ce41d6165105671da5b6f33813106ef1b8 (patch)
tree082c5a7dffda63bbe740363fb35ca6dd1927b5ab /widgets/updates.lua
parent9f6414838d4fabc174bb193f34225de8d9fc8079 (diff)
downloadconky-theme-udt-e55896ce41d6165105671da5b6f33813106ef1b8.tar.gz
conky-theme-udt-e55896ce41d6165105671da5b6f33813106ef1b8.zip
feat: add an updates card showing nvtable's package table
Moves the nvchecker/nvtable table the login shell printed onto the dashboard, as a wide card at the bottom centre. bin/updates-sample.sh caches `nvtable -C -E` every ten minutes and starts nvchecker, fully detached, at most every 30 minutes. The widget parses the cache and colours it in the board's language: installed behind upstream is critical, a shipped .info behind is a warning, with nvtable's status dots per row and a legend on the bottom line. The mouse wheel scrolls rows that do not fit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'widgets/updates.lua')
-rw-r--r--widgets/updates.lua195
1 files changed, 195 insertions, 0 deletions
diff --git a/widgets/updates.lua b/widgets/updates.lua
new file mode 100644
index 0000000..b50dcf3
--- /dev/null
+++ b/widgets/updates.lua
@@ -0,0 +1,195 @@
+-- Updates: nvtable's table of tracked packages that need action, the one the
+-- login shell used to print. Wide and short, so it scrolls: the mouse wheel
+-- steps through rows that do not fit.
+--
+-- Everything comes from the cache bin/updates-sample.sh writes; running
+-- nvtable here would cost ~0.7s per draw.
+
+local card = require 'lib.card'
+local data = require 'lib.data'
+
+local CACHE = (os.getenv('XDG_CACHE_HOME') or (os.getenv('HOME') .. '/.cache'))
+ .. '/udt/updates.txt'
+
+-- Packages behind upstream. One is worth a glance, ten is a backlog.
+local WARN_N, CRIT_N = 1, 10
+
+-- First row shown, moved by the wheel. max_offset is remembered from the last
+-- draw so the mouse handler can clamp without re-reading the cache.
+local offset, max_offset = 0, 0
+
+local M = {}
+
+-- nvtable's status dots, in the board's colours: 🔴 installed behind upstream,
+-- 🟡 shipped .info behind, 🟢 current, ⚪ not installed (a ring).
+local function dot(cr, x, y, radius, colour, hollow)
+ cairo_new_path(cr)
+ cairo_arc(cr, x, y, radius, 0, 2 * math.pi)
+ card.rgba(cr, colour)
+ if hollow then
+ cairo_set_line_width(cr, math.max(1, radius * 0.35))
+ cairo_stroke(cr)
+ else
+ cairo_fill(cr)
+ end
+end
+
+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, SUB_F = 0.30, 1.0, 0.32, 0.26
+
+ local up = data.nvtable_parse(data.slurp(CACHE) or '')
+ if not up then
+ card.notice(cr, inner, colors, 'UPDATES', 'no update data',
+ 'bin/updates-sample.sh')
+ return
+ end
+
+ local big = up.upgrade and tostring(up.upgrade) or '--'
+ local sub = {}
+ if up.bump then sub[#sub + 1] = up.bump .. (up.bump == 1 and ' repo' or ' repos') .. ' to bump' end
+ if up.current then sub[#sub + 1] = up.current .. ' current' end
+ if up.missing and up.missing > 0 then sub[#sub + 1] = up.missing .. ' not installed' end
+ sub = table.concat(sub, ' · ')
+
+ -- Header sized as if the card held ~12 rows: the table scrolls, so letting
+ -- the header grow with the cell's height would only squeeze the rows.
+ local S = clamp(math.min(inner.h * 0.94 / (1.5 + 12 * 0.66),
+ card.fit_unit(cr, inner.w * 0.96, {
+ { { card.title_str(card.ICON.updates, 'UPDATES'), card.FONT_MONO, LABEL_F },
+ { big, card.FONT_HEAVY, BIG_F } },
+ { { sub, card.FONT_UI, SUB_F } },
+ }, 100)), 10, 72)
+ local label_size = S * LABEL_F
+
+ card.font(cr, card.FONT_HEAVY, S * BIG_F, false)
+ card.rgba(cr, card.threshold(up.upgrade, WARN_N, CRIT_N, colors))
+ card.text_right(cr, inner.x + inner.w, inner.y + S * BIG_F, big)
+
+ card.font(cr, card.FONT_MONO, label_size, false)
+ card.title(cr, inner.x, inner.y + label_size, card.ICON.updates, 'UPDATES', colors)
+
+ card.font(cr, card.FONT_UI, S * SUB_F, false)
+ card.rgba(cr, colors.label, 0.65)
+ card.text(cr, inner.x, inner.y + label_size + S * SUB_F * 1.5, sub)
+
+ local top = inner.y + S * BIG_F + S * ROW_F * 1.2
+ local bottom = inner.y + inner.h
+
+ if #up == 0 then
+ card.font(cr, card.FONT_UI, S * ROW_F, false)
+ card.rgba(cr, colors.ok)
+ card.text(cr, inner.x, top + S * ROW_F * 1.5, 'everything current')
+ return
+ end
+
+ -- Column widths in characters. The font is monospaced, so padding with
+ -- spaces aligns columns exactly and one advance converts chars to pixels.
+ local wl, wn, wi, ws, wu = 2, 1, 2, 2, 0
+ for _, r in ipairs(up) do
+ wl = math.max(wl, #(r.repo or '--'))
+ wn = math.max(wn, #r.name)
+ wi = math.max(wi, #(r.installed or '--'))
+ ws = math.max(ws, #(r.shipped or '--'))
+ wu = math.max(wu, #(r.upstream or ''))
+ end
+ -- repo, gap, name, gap, installed, ' → ', shipped, gap, two dots, gap,
+ -- upstream
+ local chars = wl + 2 + wn + 2 + wi + 3 + ws + 2 + 3 + 2 + wu
+
+ -- Row type: the header's row ratio, shrunk until the widest row fits
+ -- beside the scrollbar gutter.
+ local gutter = 10
+ card.font(cr, card.FONT_MONO, 100, false)
+ local per_unit = card.advance(cr, '0') / 100
+ local row_size = math.max(8, math.min(S * ROW_F, (inner.w - gutter) / (chars * per_unit)))
+ local cw = per_unit * row_size
+ local step = row_size * 1.7
+ local rad = row_size * 0.28
+ local mid = row_size * 0.32 -- baseline to the digits' vertical centre
+
+ -- The legend takes the bottom line; the rows scroll above it.
+ bottom = bottom - row_size * 2
+
+ local visible = math.max(1, math.floor((bottom - top) / step))
+ max_offset = math.max(0, #up - visible)
+ offset = clamp(offset, 0, max_offset)
+
+ local x_name = inner.x + (wl + 2) * cw
+ local x_inst = inner.x + inner.w - gutter - (wi + 3 + ws + 2 + 5 + wu) * cw
+ local x_ship = x_inst + (wi + 3) * cw
+ local x_mark = x_ship + (ws + 2) * cw
+ local x_upst = x_mark + 5 * cw
+
+ card.font(cr, card.FONT_MONO, row_size, false)
+ local y = top + row_size
+ for i = offset + 1, math.min(#up, offset + visible) do
+ local r = up[i]
+ card.rgba(cr, colors.label, 0.65)
+ card.text(cr, inner.x, y, r.repo or '--')
+ card.rgba(cr, colors.value)
+ card.text(cr, x_name, y, r.name)
+
+ -- Installed: behind upstream is the critical state, caught up is ok.
+ if r.installed then card.rgba(cr, r.behind and colors.critical or colors.ok)
+ else card.rgba(cr, colors.label, 0.5) end
+ card.text(cr, x_inst, y, string.format('%' .. wi .. 's', r.installed or '--'))
+ card.rgba(cr, colors.label, 0.65)
+ card.text(cr, x_inst + wi * cw, y, ' → ')
+ -- Shipped: a .info behind upstream is packaging work waiting, a warning.
+ if r.shipped then card.rgba(cr, r.bump and colors.warning or colors.value)
+ else card.rgba(cr, colors.label, 0.5) end
+ card.text(cr, x_ship, y, r.shipped or '--')
+ -- Installed state, then a second dot when the .info is behind too: the
+ -- two are independent, as nvtable shows them.
+ if not r.installed then dot(cr, x_mark + cw / 2, y - mid, rad, colors.label, true)
+ else dot(cr, x_mark + cw / 2, y - mid, rad, r.behind and colors.critical or colors.ok) end
+ if r.bump then dot(cr, x_mark + cw * 2.5, y - mid, rad, colors.warning) end
+ card.font(cr, card.FONT_MONO, row_size, false)
+ if r.upstream then
+ card.rgba(cr, colors.highlight)
+ card.text(cr, x_upst, y, r.upstream)
+ end
+ y = y + step
+ end
+
+ -- Legend, on the card's bottom line.
+ local lx, ly = inner.x, inner.y + inner.h - row_size * 0.2
+ card.font(cr, card.FONT_UI, row_size * 0.85, false)
+ for _, l in ipairs({
+ { colors.critical, 'upstream newer' }, { colors.warning, 'repo behind' },
+ { colors.ok, 'current' }, { colors.label, 'not installed', true },
+ }) do
+ dot(cr, lx + rad, ly - mid, rad, l[1], l[3])
+ lx = lx + rad * 2 + cw * 0.7
+ card.rgba(cr, colors.label, 0.65)
+ card.text(cr, lx, ly, l[2])
+ lx = lx + card.advance(cr, l[2]) + cw * 2.5
+ end
+
+ -- Scrollbar, only when there is something to scroll.
+ if max_offset > 0 then
+ local track_h = visible * step
+ local thumb_h = math.max(row_size, track_h * visible / #up)
+ local thumb_y = top + (track_h - thumb_h) * offset / max_offset
+ local bx = inner.x + inner.w - 3
+ card.rounded_path(cr, bx, top, 3, track_h, 1.5)
+ card.rgba(cr, colors.rule, 0.35)
+ cairo_fill(cr)
+ card.rounded_path(cr, bx, thumb_y, 3, thumb_h, 1.5)
+ card.rgba(cr, colors.highlight, 0.8)
+ cairo_fill(cr)
+ end
+end
+
+-- The wheel steps one row. It shows at once because the dashboard draws at
+-- 0.1s while visible (see dashboard.lua).
+function M.mouse(event)
+ if event.type ~= 'mouse_scroll' then return false end
+ offset = math.max(0, math.min(max_offset,
+ offset + (event.direction == 'up' and -1 or 1)))
+ return true
+end
+
+return M