diff options
| -rw-r--r-- | docs/superpowers/plans/2026-09-16-conky-lua-dashboard.md | 1531 |
1 files changed, 1531 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-16-conky-lua-dashboard.md b/docs/superpowers/plans/2026-09-16-conky-lua-dashboard.md new file mode 100644 index 0000000..3445d6a --- /dev/null +++ b/docs/superpowers/plans/2026-09-16-conky-lua-dashboard.md @@ -0,0 +1,1531 @@ +# Conky Lua Dashboard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A fullscreen Conky dashboard for Hyprland, drawn with Cairo from Lua, on a toggleable special workspace, with a grid layout the user reorders by editing one table, and one clock widget end to end. + +**Architecture:** `conky.conf.in` is a UDT-templated config that loads `dashboard.lua` in `lua_draw_hook_post`. `dashboard.lua` owns a layout table of grid cells, converts cells to pixel rects, and calls each widget's `draw(cr, rect, colors)`. Cairo primitives live in `lib/card.lua`, `/proc` and `/sys` parsers in `lib/data.lua`, one file per widget in `widgets/`. `conky.text` stays empty because Cairo output covers it. + +**Tech Stack:** Conky 1.24.2-pre (Lua + Cairo + Imlib2, Wayland output), Lua 5.4.9, Cairo via Conky's bindings, Hyprland windowrules, UDT palette templating (Python, `bin/udt-palette`). + +--- + +## Critical platform facts + +These were verified experimentally on this machine. Each one invalidates an approach you will otherwise reach for. + +**1. Use `conky_surface()`, never `cairo_xlib_surface_create`.** Under `out_to_wayland = true`, `conky_window.drawable` and `conky_window.visual` are both `nil`. Every Conky-Lua tutorial online uses the Xlib idiom and all of them are wrong here. `conky_window.width` and `.height` DO work and return floats. + +**2. `own_window_type` must be `'normal'`, not `'desktop'`.** Desktop type is a layer-surface at level 0; Hyprland cannot assign it to a workspace. Normal type appears in `hyprctl clients` and a windowrule can place it. + +**3. A Lua error produces a blank screen and no message.** No stderr, no log. This is why Task 4 wraps the draw in `pcall` before any widget exists. + +**4. `grim` cannot capture a window on an inactive workspace.** It captures whatever occupies those screen coordinates. To screenshot the dashboard you must switch to its workspace first, and wait after switching. Do not trust a capture taken without switching. + +**5. Cairo has no Black font weight.** `CAIRO_FONT_WEIGHT_BOLD` is the maximum. To reach Noto Sans Black, pass the family name `"Noto Sans Black"` with `CAIRO_FONT_WEIGHT_NORMAL`. Verified: `"13"` at size 90 measures 100.0px wide as Black versus 94.0px as Noto Sans Bold, so the heavier face is genuinely selected. + +**6. `print()` from Lua reaches Conky's stdout.** Run Conky in the foreground to see it. This is the only debugging channel. + +--- + +## File structure + +**This repo (`~/Programming/GIT/conky-theme-udt`):** + +| Path | Responsibility | +|---|---| +| `conky.conf.in` | UDT template: Conky settings, `@ROLE@` colour placeholders, `lua_load`. Empty `conky.text`. | +| `dashboard.lua` | Entry point. Layout table, grid maths, palette parsing, `pcall` error overlay, widget dispatch. | +| `lib/card.lua` | Cairo primitives: rounded-rect card, text helpers, font selection. | +| `lib/data.lua` | `/proc` and `/sys` parsers. Pure functions over strings, so they are testable. | +| `widgets/clock.lua` | The only v1 widget. `draw(cr, rect, colors)`. | +| `test/fixtures/` | Committed sample `/proc/stat`, `/proc/meminfo`, `temp1_input`. | +| `test/test_data.lua` | `assert`-based parser check, run with `lua`. | +| `hypr/dashboard.conf` | Hyprland windowrules and keybind, sourced by the user's config. | +| `README.md` | What it is, install, layout editing, the Wayland Cairo gotcha. | + +**The UDT repo (`~/Programming/GIT/unified-desktop-theme`), modified in Task 9:** + +| Path | Change | +|---|---| +| `bin/udt-palette:545-555` | `gen_conky` reads its template from this repo and adds the `critical` role. | +| `install.sh:178-207` | Link this repo's rendered config; keep the existing restart logic. | +| `templates/conky.conf.in` | Deleted, replaced by this repo. | + +**Ordering rationale:** Tasks 1-3 build and test pure Lua with no Conky involved, so failures are readable. Task 4 gets pixels on screen with a hardcoded colour. Tasks 5-7 add the palette, the grid, and the widget. Task 8 places the window. Task 9 switches UDT over last, so a broken intermediate state never touches the working desktop. + +--- + +### Task 1: Repo scaffolding and the data parsers' first test + +**Files:** +- Create: `test/fixtures/proc_stat` +- Create: `test/fixtures/proc_meminfo` +- Create: `test/fixtures/temp1_input` +- Create: `test/test_data.lua` +- Create: `lib/data.lua` + +- [ ] **Step 1: Write the fixtures** + +These are real samples from this machine, trimmed. Create `test/fixtures/proc_stat`: + +``` +cpu 209094 2199 135469 11604898 24408 0 946 0 0 0 +cpu0 5294 11 2928 740049 460 0 592 0 0 0 +cpu1 5210 18 2801 740512 431 0 12 0 0 0 +intr 12345678 +ctxt 987654321 +``` + +Create `test/fixtures/proc_meminfo`: + +``` +MemTotal: 31943076 kB +MemFree: 4291244 kB +MemAvailable: 25627088 kB +Buffers: 123456 kB +Cached: 6789012 kB +SwapTotal: 8388604 kB +SwapFree: 8388604 kB +``` + +Create `test/fixtures/temp1_input`: + +``` +53125 +``` + +- [ ] **Step 2: Write the failing test** + +Create `test/test_data.lua`. Note `package.path` must be set so `require 'lib.data'` resolves when run from the repo root. + +```lua +-- Parser checks for lib/data.lua. +-- Run from the repo root: lua test/test_data.lua +-- Parsers are what break silently on a kernel or hardware change, so they are +-- what gets a test. Everything else in this project is verified by screenshot. + +package.path = './?.lua;' .. package.path +local data = require 'lib.data' + +local function read(path) + local f = assert(io.open(path, 'r')) + local s = f:read('*a') + f:close() + return s +end + +-- cpu_times: total and idle jiffies from the aggregate "cpu " line. +local total, idle = data.cpu_times(read('test/fixtures/proc_stat')) +-- 209094+2199+135469+11604898+24408+0+946 = 11977014 +assert(total == 11977014, 'cpu total, got ' .. tostring(total)) +-- idle field is the 4th value, 11604898; iowait (24408) counts as idle too +assert(idle == 11629306, 'cpu idle, got ' .. tostring(idle)) + +-- mem_info: values in kB, as the file gives them. +local mem = data.mem_info(read('test/fixtures/proc_meminfo')) +assert(mem.total == 31943076, 'mem total, got ' .. tostring(mem.total)) +assert(mem.available == 25627088, 'mem available, got ' .. tostring(mem.available)) +-- used is total minus available, which is what a user means by "used" +assert(mem.used == 6315988, 'mem used, got ' .. tostring(mem.used)) + +-- millidegrees: hwmon temp*_input is millidegrees C, rounded to whole degrees. +assert(data.millidegrees(read('test/fixtures/temp1_input')) == 53, + 'temp, got ' .. tostring(data.millidegrees(read('test/fixtures/temp1_input')))) +-- A missing or unreadable sensor must yield nil, not an error and not 0: +-- 0 degrees is a plausible reading and would be indistinguishable from failure. +assert(data.millidegrees(nil) == nil, 'nil input must give nil') +assert(data.millidegrees('') == nil, 'empty input must give nil') +assert(data.millidegrees('garbage') == nil, 'unparseable input must give nil') + +print('test_data: all assertions passed') +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `cd ~/Programming/GIT/conky-theme-udt && lua test/test_data.lua` + +Expected: failure, `module 'lib.data' not found`. + +- [ ] **Step 4: Write the minimal implementation** + +Create `lib/data.lua`: + +```lua +-- Readers for /proc and /sys. +-- +-- Every function takes the file *contents* as a string rather than reading the +-- file itself, so the parsers are testable against fixtures without mocking the +-- filesystem. The thin read-and-parse wrappers live at the bottom. + +local M = {} + +-- Aggregate CPU jiffies from /proc/stat. Returns total, idle. +-- iowait counts as idle: a core waiting on disk is not doing work, and +-- treating it as busy makes a disk-bound system look CPU-bound. +function M.cpu_times(stat) + local line = stat:match('^cpu%s+([^\n]+)') + if not line then return nil, nil end + local v = {} + for n in line:gmatch('%d+') do v[#v + 1] = tonumber(n) end + if #v < 5 then return nil, nil end + local total = 0 + for _, n in ipairs(v) do total = total + n end + return total, v[4] + v[5] -- idle + iowait +end + +-- /proc/meminfo, values in kB as the file states them. +function M.mem_info(meminfo) + local function field(name) + return tonumber(meminfo:match(name .. ':%s+(%d+)')) + end + local total = field('MemTotal') + local available = field('MemAvailable') + if not (total and available) then return nil end + return { + total = total, + free = field('MemFree'), + available = available, + -- MemAvailable already excludes reclaimable cache, so this is the figure a + -- user recognises as "used", unlike total-free which counts cache. + used = total - available, + } +end + +-- hwmon temp*_input is millidegrees C. Returns whole degrees, or nil. +-- nil rather than 0 on failure: 0 C is a legitimate reading. +function M.millidegrees(s) + if not s then return nil end + local n = tonumber(s:match('^%s*(-?%d+)')) + if not n then return nil end + return math.floor(n / 1000 + 0.5) +end + +-- Read a whole file, returning nil if it cannot be read. Used by the wrappers +-- so a vanished sysfs path degrades to nil instead of raising. +function M.slurp(path) + local f = io.open(path, 'r') + if not f then return nil end + local s = f:read('*a') + f:close() + return s +end + +-- Find a hwmon directory by the exact contents of its `name` file. +-- +-- Globbing by name, never by a fixed hwmon index: indices drift across kernel +-- and hardware reorders, and a stale index silently reports a different chip. +-- Carried over from the previous conky config, where this was the hard-won bit. +function M.hwmon_dir(name) + local p = io.popen('grep -lx ' .. ("%q"):format(name) + .. ' /sys/class/hwmon/hwmon*/name 2>/dev/null') + if not p then return nil end + local hit = p:read('*l') + p:close() + if not hit then return nil end + return hit:match('^(.*)/name$') +end + +return M +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `cd ~/Programming/GIT/conky-theme-udt && lua test/test_data.lua` + +Expected: `test_data: all assertions passed` + +- [ ] **Step 6: Commit** + +```bash +cd ~/Programming/GIT/conky-theme-udt +git add lib/data.lua test/ +git commit -m "feat: add /proc and /sys parsers with fixture tests + +Parsers take file contents as a string, not a path, so they test +against committed fixtures with no filesystem mocking. + +hwmon is globbed by its name file rather than a fixed index, carried +over from the old config: indices drift across kernel reorders and a +stale one silently reads a different chip. + +Failure returns nil, never 0. A sensor reading 0 C is legitimate, so 0 +cannot double as an error value." +``` + +--- + +### Task 2: CPU percentage from two samples + +CPU load is a delta between two readings, so it needs state. This is separate from Task 1 because it is the one parser with memory, and getting the first-call case wrong yields a bogus 100% spike on startup. + +**Files:** +- Modify: `test/test_data.lua` +- Modify: `lib/data.lua` + +- [ ] **Step 1: Write the failing test** + +Append to `test/test_data.lua`, before the final `print`: + +```lua +-- cpu_percent is a delta between two samples, so it holds state. +-- The first call has no previous sample and must report nil, not a number: +-- any number it invented would be wrong, and 100% on startup looks like a +-- real spike. +local c = data.new_cpu_counter() +assert(c:sample(1000, 900) == nil, 'first sample must give nil') + +-- Second sample: 100 more total jiffies, 50 of them idle, so 50% busy. +-- Held in a local first: calling sample() again inside the assert message would +-- advance the counter a third time. +local busy = c:sample(1100, 950) +assert(busy == 50.0, 'second sample, got ' .. tostring(busy)) + +-- A counter that did not advance means no elapsed time, not 0% load. +local c2 = data.new_cpu_counter() +c2:sample(1000, 900) +assert(c2:sample(1000, 900) == nil, 'zero delta must give nil, not a division by zero') +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd ~/Programming/GIT/conky-theme-udt && lua test/test_data.lua` + +Expected: failure, `attempt to call a nil value (field 'new_cpu_counter')`. + +- [ ] **Step 3: Write the minimal implementation** + +Add to `lib/data.lua`, before the final `return M`: + +```lua +-- A stateful CPU-load counter. +-- +-- Load is work done between two samples, so a single reading cannot produce a +-- percentage. Each counter keeps its own previous sample, which also means a +-- per-core counter is just another instance. +function M.new_cpu_counter() + return { + prev_total = nil, + prev_idle = nil, + -- Returns busy percent since the previous sample, or nil when there is no + -- usable delta (first call, or the counters did not advance). + sample = function(self, total, idle) + if not (total and idle) then return nil end + local pt, pi = self.prev_total, self.prev_idle + self.prev_total, self.prev_idle = total, idle + if not pt then return nil end + local dt = total - pt + if dt <= 0 then return nil end + local busy = (dt - (idle - pi)) / dt * 100 + -- Clamp: a counter reset or a suspend/resume can produce a nonsense + -- delta, and a bar drawn at -12% or 340% is worse than a clamped one. + if busy < 0 then busy = 0 elseif busy > 100 then busy = 100 end + return busy + end, + } +end +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd ~/Programming/GIT/conky-theme-udt && lua test/test_data.lua` + +Expected: `test_data: all assertions passed` + +- [ ] **Step 5: Commit** + +```bash +cd ~/Programming/GIT/conky-theme-udt +git add lib/data.lua test/test_data.lua +git commit -m "feat: add stateful CPU percentage counter + +Load is a delta, so one reading cannot yield a percentage. The first +call returns nil rather than a fabricated number: 100% on startup reads +as a real spike. + +A non-advancing counter also returns nil instead of dividing by zero, +and the result is clamped because a suspend/resume can produce a +nonsense delta." +``` + +--- + +### Task 3: Cairo card primitives + +Pure geometry, no Conky. Verified by asserting the path-building helpers do not raise and by the screenshot in Task 4. + +**Files:** +- Create: `lib/card.lua` + +- [ ] **Step 1: Write the implementation** + +Create `lib/card.lua`: + +```lua +-- Cairo drawing primitives shared by every widget. +-- +-- A widget is handed a rect and these helpers; it never computes its own +-- position and never touches the palette directly. + +local M = {} + +-- Cairo's font API tops out at CAIRO_FONT_WEIGHT_BOLD, so a Black face cannot +-- be requested by weight. Passing the family name that fontconfig registers for +-- it ("Noto Sans Black") with NORMAL weight does select it: measured at size +-- 90, "13" is 100.0px wide as Black against 94.0px as Noto Sans Bold. +M.FONT_MONO = 'Inconsolata Nerd Font' +M.FONT_UI = 'Noto Sans' +M.FONT_HEAVY = 'Noto Sans Black' + +function M.font(cr, family, size, bold) + cairo_select_font_face(cr, family, CAIRO_FONT_SLANT_NORMAL, + bold and CAIRO_FONT_WEIGHT_BOLD or CAIRO_FONT_WEIGHT_NORMAL) + cairo_set_font_size(cr, size) +end + +function M.rgba(cr, c, alpha) + cairo_set_source_rgba(cr, c[1], c[2], c[3], alpha or c[4] or 1) +end + +-- Text at (x, y), where y is the BASELINE, not the top of the glyphs. +function M.text(cr, x, y, s) + cairo_move_to(cr, x, y) + cairo_show_text(cr, s) +end + +-- Measured width and height of a string under the current font. +function M.measure(cr, s) + local e = cairo_text_extents_t:create() + cairo_text_extents(cr, s, e) + return e.width, e.height +end + +-- Right-aligned text: x is the RIGHT edge. +function M.text_right(cr, x, y, s) + local w = M.measure(cr, s) + M.text(cr, x - w, y, s) +end + +-- A rounded-rectangle path. Does not paint; the caller fills or strokes, so one +-- path can serve both a fill and its border. +function M.rounded_path(cr, x, y, w, h, r) + -- Clamp the radius: a radius over half the shorter side makes the arcs + -- overlap and Cairo draws a pinched, bowtie-looking shape. + local m = math.min(w, h) / 2 + if r > m then r = m end + cairo_new_path(cr) + cairo_arc(cr, x + w - r, y + r, r, -math.pi / 2, 0) + cairo_arc(cr, x + w - r, y + h - r, r, 0, math.pi / 2) + cairo_arc(cr, x + r, y + h - r, r, math.pi / 2, math.pi) + cairo_arc(cr, x + r, y + r, r, math.pi, math.pi * 1.5) + cairo_close_path(cr) +end + +-- A card: filled rounded rect with a hairline border, matching the mockups. +-- Returns the inner rect so a widget can lay out against padded bounds. +function M.card(cr, rect, colors, pad) + pad = pad or 18 + M.rounded_path(cr, rect.x, rect.y, rect.w, rect.h, 14) + M.rgba(cr, colors.surface, 0.55) + cairo_fill_preserve(cr) + -- 1px hairline. Cairo strokes astride the path, so a width of 1 on an integer + -- coordinate straddles two pixel rows and renders as a soft 2px line; the + -- cards read as thin outlines in the mockup, so keep it sub-pixel-crisp by + -- stroking at 1 and accepting the AA rather than offsetting by 0.5, which + -- would misalign the fill. + M.rgba(cr, colors.border, 0.9) + cairo_set_line_width(cr, 1) + cairo_stroke(cr) + return { x = rect.x + pad, y = rect.y + pad, + w = rect.w - pad * 2, h = rect.h - pad * 2 } +end + +-- A small-caps section label, as used across both mockups. +function M.label(cr, x, y, s, colors) + M.font(cr, M.FONT_MONO, 13, false) + M.rgba(cr, colors.label) + M.text(cr, x, y, s:upper()) +end + +return M +``` + +- [ ] **Step 2: Verify the file parses** + +Cairo functions are only defined inside Conky, so this cannot be unit-tested +here; it can be checked for syntax errors. + +Run: `cd ~/Programming/GIT/conky-theme-udt && luac -p lib/card.lua && echo "SYNTAX OK"` + +Expected: `SYNTAX OK` + +If `luac` is not installed, use: +`lua -e "assert(loadfile('lib/card.lua')); print('SYNTAX OK')"` + +- [ ] **Step 3: Commit** + +```bash +cd ~/Programming/GIT/conky-theme-udt +git add lib/card.lua +git commit -m "feat: add Cairo card primitives + +Rounded-rect card with a hairline border, text helpers with measured +alignment, and font selection. + +Cairo's font API stops at BOLD, so the Black face is reached by family +name instead of weight: at size 90, '13' measures 100px as Noto Sans +Black against 94px as Bold, confirming the heavier face is selected. + +The card radius is clamped to half the shorter side, because a larger +radius makes the corner arcs overlap into a bowtie." +``` + +--- + +### Task 4: Pixels on screen, with the error overlay first + +The `pcall` overlay comes before any widget, because from here on a Lua typo would otherwise present as an unexplained black screen. + +**Files:** +- Create: `dashboard.lua` +- Create: `conky.conf` (a hand-written dev config; the templated one arrives in Task 5) + +- [ ] **Step 1: Write the dev config** + +Create `conky.conf`. This is a temporary development config with literal +colours, replaced by the rendered template in Task 5. + +```lua +-- Development config with literal colours. Task 5 replaces this with the +-- UDT-rendered conky.conf.in. Not the file the installed dashboard uses. +conky.config = { + out_to_x = false, + out_to_wayland = true, + own_window = true, + own_window_type = 'normal', + own_window_class = 'conky-dash', + own_window_argb_visual = true, + own_window_argb_value = 200, + minimum_width = 1200, + minimum_height = 700, + double_buffer = true, + update_interval = 2, + total_run_times = 0, + draw_borders = false, + draw_shades = false, + override_utf8_locale = true, + lua_load = './dashboard.lua', + lua_draw_hook_post = 'main', + -- Literal dev palette, Catppuccin Macchiato values. + color1 = '#8aadf4', -- heading + color2 = '#a5adcb', -- label + color3 = '#494d64', -- rule / border + color4 = '#8bd5ca', -- value + color5 = '#c6a0f6', -- highlight + color6 = '#a6da95', -- ok + color7 = '#ed8796', -- critical + default_color = '#cad3f5', -- body +} + +-- Empty: Cairo output covers conky.text entirely, verified by experiment. +conky.text = [[]] +``` + +- [ ] **Step 2: Write dashboard.lua with the error overlay and a placeholder draw** + +Create `dashboard.lua`: + +```lua +-- Conky Lua dashboard: entry point. +-- +-- Drawn entirely with Cairo in lua_draw_hook_post; conky.text is empty because +-- Cairo output covers it. + +-- Resolve requires relative to this file's directory, since Conky's working +-- directory is wherever it was launched from, not the config's location. +local here = debug.getinfo(1, 'S').source:match('^@(.*/)') or './' +package.path = here .. '?.lua;' .. package.path + +require 'cairo' + +local card = require 'lib.card' + +-- === Layout =============================================================== +-- Order and position are yours: edit this table, nothing else. +-- col/row are grid cells, w/h span cells. Cell size is derived from the +-- surface, so the same table works on a 2560x1080 and a 1920x1080 screen. +local COLS, ROWS = 4, 2 + +local layout = { + { widget = 'clock', col = 1, row = 1, w = 1, h = 2 }, +} +-- ========================================================================== + +local GAP = 16 -- gap between cards, px +local MARGIN = 28 -- outer margin, px + +-- #rrggbb -> {r, g, b} as 0-1 floats, which is what Cairo wants. +-- UDT's gen_conky emits hex6, so the conversion lives here rather than in the +-- other repo's renderer. +local function hex(s, fallback) + local r, g, b = tostring(s or ''):match('^#?(%x%x)(%x%x)(%x%x)$') + if not r then return fallback or { 1, 0, 1 } end -- magenta: visibly wrong + return { tonumber(r, 16) / 255, tonumber(g, 16) / 255, tonumber(b, 16) / 255 } +end + +-- Palette, read once per draw from the Conky colour slots the template filled. +local function palette() + return { + heading = hex(conky_parse('${color1}')), + label = hex(conky_parse('${color2}')), + border = hex(conky_parse('${color3}')), + rule = hex(conky_parse('${color3}')), + value = hex(conky_parse('${color4}')), + highlight = hex(conky_parse('${color5}')), + ok = hex(conky_parse('${color6}')), + critical = hex(conky_parse('${color7}')), + body = hex(conky_parse('${color}')), + -- The card fill. Derived from the body background rather than given its own + -- role, so a scheme switch cannot leave the cards mismatched. + surface = hex(conky_parse('${color3}')), + } +end + +-- Cell rect for a layout entry. 1-indexed cols and rows, as the table reads. +local function rect_for(entry, sw, sh) + local cw = (sw - MARGIN * 2 - GAP * (COLS - 1)) / COLS + local ch = (sh - MARGIN * 2 - GAP * (ROWS - 1)) / ROWS + return { + x = MARGIN + (entry.col - 1) * (cw + GAP), + y = MARGIN + (entry.row - 1) * (ch + GAP), + w = cw * (entry.w or 1) + GAP * ((entry.w or 1) - 1), + h = ch * (entry.h or 1) + GAP * ((entry.h or 1) - 1), + } +end + +-- Widget modules, loaded once and cached. A widget that fails to load must not +-- take the frame down with it, so the require is wrapped. +local widgets = {} +local function widget(name) + if widgets[name] == nil then + local ok, mod = pcall(require, 'widgets.' .. name) + widgets[name] = ok and mod or false + if not ok then print('dashboard: cannot load widget ' .. name .. ': ' .. tostring(mod)) end + end + return widgets[name] or nil +end + +-- Draw an error where the dashboard should be. +-- +-- Conky reports a Lua fault as a blank screen with no message on any stream, so +-- without this every mistake looks identical to "nothing ran". +local function draw_error(cr, msg, sw) + cairo_select_font_face(cr, 'Inconsolata Nerd Font', CAIRO_FONT_SLANT_NORMAL, + CAIRO_FONT_WEIGHT_BOLD) + cairo_set_font_size(cr, 16) + cairo_set_source_rgba(cr, 0.93, 0.53, 0.59, 1) -- literal: the palette may be what failed + local y = 40 + for line in tostring(msg):gmatch('[^\n]+') do + cairo_move_to(cr, 24, y) + cairo_show_text(cr, line) + y = y + 20 + if y > 400 then break end + end + print('dashboard error: ' .. tostring(msg)) +end + +local function draw(cr, sw, sh, colors) + for _, entry in ipairs(layout) do + local w = widget(entry.widget) + if w then + w.draw(cr, rect_for(entry, sw, sh), colors) + else + -- Name the missing widget in place, rather than leaving a blank cell. + local r = rect_for(entry, sw, sh) + local inner = card.card(cr, r, colors) + card.label(cr, inner.x, inner.y + 16, 'missing: ' .. entry.widget, colors) + end + end +end + +function conky_main() + if conky_window == nil then return end + local s = conky_surface() + if s == nil then return end + local cr = cairo_create(s) + local sw, sh = conky_window.width, conky_window.height + local ok, err = pcall(function() + draw(cr, sw, sh, palette()) + end) + if not ok then draw_error(cr, err, sw) end + cairo_destroy(cr) +end +``` + +- [ ] **Step 3: Run Conky in the foreground and confirm the placeholder card draws** + +The clock widget does not exist yet, so the expected result is one card reading +`MISSING: CLOCK`. That proves the grid, the card primitive, the palette parse +and the missing-widget path all work. + +```bash +cd ~/Programming/GIT/conky-theme-udt +timeout 8 conky -c ./conky.conf +``` + +Expected: no Lua errors on stdout. Conky's own info lines about the Wayland +session are normal. + +- [ ] **Step 4: Screenshot to verify it reached the screen** + +Remember fact 4: `grim` captures screen coordinates, so you must be on the +dashboard's workspace. This script switches, captures, and switches back. + +```bash +cd ~/Programming/GIT/conky-theme-udt +orig=$(hyprctl activeworkspace -j | jq -r .id) +(conky -c ./conky.conf >/tmp/conky-dash.log 2>&1 &) +sleep 3 +ws=$(hyprctl clients -j | jq -r '.[]|select(.class=="conky-dash")|.workspace.id' | head -1) +hyprctl dispatch workspace "$ws" >/dev/null; sleep 2 +g=$(hyprctl clients -j | jq -r '.[]|select(.class=="conky-dash")|"\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"' | head -1) +grim -g "$g" /tmp/dash-task4.png && echo "captured $g" +hyprctl dispatch workspace "$orig" >/dev/null +pkill -f 'conky -c ./conky.conf' +cat /tmp/conky-dash.log +``` + +Expected: `captured <geometry>`, and `/tmp/dash-task4.png` shows a single +rounded card on a translucent dark background with the text `MISSING: CLOCK`. +View it to confirm; a capture of the wallpaper means the workspace switch did +not settle, so raise the `sleep` and retry. + +- [ ] **Step 5: Verify the error overlay actually works** + +This is the safety net for every later task, so prove it fires rather than +assuming it does. + +```bash +cd ~/Programming/GIT/conky-theme-udt +cp dashboard.lua /tmp/dashboard.lua.bak +# Introduce a deliberate fault inside the draw path. +sed -i 's|^ for _, entry in ipairs(layout) do| error("deliberate test fault")\n for _, entry in ipairs(layout) do|' dashboard.lua +timeout 8 conky -c ./conky.conf 2>&1 | grep -m1 'dashboard error' +cp /tmp/dashboard.lua.bak dashboard.lua +``` + +Expected: a line containing `dashboard error: ... deliberate test fault`. That +confirms `pcall` catches the fault and reports it instead of blanking. + +Then confirm the restore worked: `grep -c 'deliberate test fault' dashboard.lua` +must print `0`. + +- [ ] **Step 6: Commit** + +```bash +cd ~/Programming/GIT/conky-theme-udt +git add dashboard.lua conky.conf +git commit -m "feat: draw to a Wayland Cairo surface with an error overlay + +Entry point, grid maths, palette parsing and widget dispatch. + +Uses conky_surface(), not cairo_xlib_surface_create: under +out_to_wayland the Xlib drawable and visual are both nil, so the idiom +every Conky-Lua tutorial uses cannot work here. + +The pcall overlay lands before any widget exists, because Conky reports +a Lua fault as a blank screen with no message on any stream. Verified +by injecting a fault and seeing it reported. A missing widget names +itself in its own cell rather than leaving the cell empty. + +conky.conf here is a development config with literal colours; the +templated one arrives with the UDT wiring." +``` + +--- + +### Task 5: The UDT-templated config + +**Files:** +- Create: `conky.conf.in` +- Create: `.gitignore` (modify: add the rendered output) + +- [ ] **Step 1: Write the template** + +Create `conky.conf.in`. It is Task 4's dev config with the literal colours +replaced by UDT placeholders. `@SCHEME@` is substituted by `gen_conky` too and +serves as a marker of which scheme rendered the file. + +```lua +-- Conky Lua dashboard, rendered by unified-desktop-theme. +-- +-- Generated from conky.conf.in for scheme @SCHEME@. Do not edit the rendered +-- conky.conf: the next install.sh overwrites it. Edit this template. +conky.config = { + out_to_x = false, + out_to_wayland = true, + own_window = true, + -- 'normal', not 'desktop': a desktop-type window is a layer-surface at + -- level 0 and Hyprland cannot assign it to a workspace, which is the whole + -- point of this dashboard. + own_window_type = 'normal', + -- Distinct from the plain 'Conky' class used by any desktop-layer instance, + -- so the windowrules for one cannot match the other. + own_window_class = 'conky-dash', + own_window_argb_visual = true, + own_window_argb_value = 200, + minimum_width = 1200, + minimum_height = 700, + double_buffer = true, + update_interval = 2, + total_run_times = 0, + draw_borders = false, + draw_shades = false, + override_utf8_locale = true, + lua_load = '~/.config/conky/dashboard.lua', + lua_draw_hook_post = 'main', + color1 = '@HEADING@', + color2 = '@LABEL@', + color3 = '@RULE@', + color4 = '@VALUE@', + color5 = '@HIGHLIGHT@', + color6 = '@OK@', + color7 = '@CRITICAL@', + default_color = '@BODY@', + default_outline_color = '@BODY_OUTLINE@', + default_shade_color = '@BODY_SHADE@', +} + +-- Empty by design: Cairo output covers conky.text. +conky.text = [[]] +``` + +- [ ] **Step 2: Add the rendered output to .gitignore** + +The repo already ignores `HANDOFF.md` and `weather.env`. Append the rendered +config, for the same reason UDT ignores its generated files: tracking it would +turn every scheme switch into a diff. + +```bash +cd ~/Programming/GIT/conky-theme-udt +printf 'conky.conf\n' >> .gitignore +``` + +Note this also un-tracks the Task 4 dev config, which is intended: it was +scaffolding. Remove it from the index: + +```bash +git rm --cached conky.conf +``` + +- [ ] **Step 3: Verify the template renders** + +`gen_conky` does not know about `@CRITICAL@` yet, so a render now must leave it +unsubstituted. Confirm that, so Task 9's change is demonstrably necessary rather +than assumed. + +```bash +cd ~/Programming/GIT/conky-theme-udt +grep -c '@CRITICAL@' conky.conf.in +``` + +Expected: `1`. + +- [ ] **Step 4: Verify the template is valid Lua once substituted** + +A placeholder is not valid Lua, so check a substituted copy rather than the +template itself. + +```bash +cd ~/Programming/GIT/conky-theme-udt +sed -e 's/@SCHEME@/macchiato/' -e 's/@HEADING@/#8aadf4/' -e 's/@LABEL@/#a5adcb/' \ + -e 's/@RULE@/#494d64/' -e 's/@VALUE@/#8bd5ca/' -e 's/@HIGHLIGHT@/#c6a0f6/' \ + -e 's/@OK@/#a6da95/' -e 's/@CRITICAL@/#ed8796/' -e 's/@BODY@/#cad3f5/' \ + -e 's/@BODY_OUTLINE@/#494d64/' -e 's/@BODY_SHADE@/#1e2030/' \ + conky.conf.in > /tmp/conky-render-test.conf +lua -e "assert(loadfile('/tmp/conky-render-test.conf')); print('TEMPLATE OK')" +``` + +Expected: `TEMPLATE OK` + +- [ ] **Step 5: Commit** + +```bash +cd ~/Programming/GIT/conky-theme-udt +git add conky.conf.in .gitignore +git commit -m "feat: add the UDT-templated conky config + +Same settings as the dev config with the colours as @ROLE@ +placeholders, plus @CRITICAL@ for the error overlay, which gen_conky +does not substitute yet. + +The rendered conky.conf is gitignored for the reason UDT ignores its +generated files: tracking it turns every scheme switch into a diff." +``` + +--- + +### Task 6: The clock widget + +**Files:** +- Create: `widgets/clock.lua` + +- [ ] **Step 1: Write the widget** + +Shaped after `idea2.png`: hour stacked over minute in a very heavy face, +left-aligned and tightly leaded, then the weekday in small caps, then the date +with slash separators. + +Create `widgets/clock.lua`: + +```lua +-- Clock: hour stacked over minute, weekday, slashed date. +-- +-- Shape follows idea2.png; every colour comes from the palette. + +local card = require 'lib.card' + +local M = {} + +function M.draw(cr, rect, colors) + local inner = card.card(cr, rect, colors) + + -- Numeral size is derived from the cell, not fixed, so the same widget fills + -- a 1x2 cell on either monitor instead of needing a per-screen constant. + -- Two stacked numerals plus the date block: allow 40% of the height each. + local size = math.min(inner.h * 0.40, inner.w * 0.95) + + card.font(cr, card.FONT_HEAVY, size, false) + card.rgba(cr, colors.body) + + -- Baselines. Cairo's y is the baseline, so the first sits one cap-height + -- down; 0.78 of the font size approximates cap height for Noto Sans and + -- avoids measuring every frame. + local x = inner.x + local y1 = inner.y + size * 0.78 + -- Tight leading, as in the mockup: the numerals nearly touch. + local y2 = y1 + size * 0.92 + + card.text(cr, x, y1, os.date('%H')) + card.text(cr, x, y2, os.date('%M')) + + -- Weekday, small caps. + card.font(cr, card.FONT_UI, 15, true) + card.rgba(cr, colors.body) + local wy = y2 + 34 + card.text(cr, x, wy, os.date('%A'):upper()) + + -- Date as "16 / SEP / 2026". The slashes take the dimmer label colour so the + -- numerals read first, which is what gives the mockup's date line its rhythm. + card.font(cr, card.FONT_UI, 14, true) + local dy = wy + 24 + local parts = { + { os.date('%d'), colors.value }, + { ' / ', colors.label }, + { os.date('%b'):upper(), colors.value }, + { ' / ', colors.label }, + { os.date('%Y'), colors.value }, + } + local dx = x + for _, p in ipairs(parts) do + card.rgba(cr, p[2]) + card.text(cr, dx, dy, p[1]) + dx = dx + card.measure(cr, p[1]) + end +end + +return M +``` + +- [ ] **Step 2: Verify syntax** + +Run: `cd ~/Programming/GIT/conky-theme-udt && lua -e "assert(loadfile('widgets/clock.lua')); print('SYNTAX OK')"` + +Expected: `SYNTAX OK` + +- [ ] **Step 3: Render and screenshot** + +Regenerate the dev config, since Task 5 removed it from tracking but it is +still the fastest way to run this standalone: + +```bash +cd ~/Programming/GIT/conky-theme-udt +sed -e 's/@SCHEME@/macchiato/' -e 's/@HEADING@/#8aadf4/' -e 's/@LABEL@/#a5adcb/' \ + -e 's/@RULE@/#494d64/' -e 's/@VALUE@/#8bd5ca/' -e 's/@HIGHLIGHT@/#c6a0f6/' \ + -e 's/@OK@/#a6da95/' -e 's/@CRITICAL@/#ed8796/' -e 's/@BODY@/#cad3f5/' \ + -e 's/@BODY_OUTLINE@/#494d64/' -e 's/@BODY_SHADE@/#1e2030/' \ + -e "s|~/.config/conky/dashboard.lua|./dashboard.lua|" \ + conky.conf.in > conky.conf + +orig=$(hyprctl activeworkspace -j | jq -r .id) +(conky -c ./conky.conf >/tmp/conky-dash.log 2>&1 &) +sleep 3 +ws=$(hyprctl clients -j | jq -r '.[]|select(.class=="conky-dash")|.workspace.id' | head -1) +hyprctl dispatch workspace "$ws" >/dev/null; sleep 2 +g=$(hyprctl clients -j | jq -r '.[]|select(.class=="conky-dash")|"\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"' | head -1) +grim -g "$g" /tmp/dash-clock.png && echo "captured $g" +hyprctl dispatch workspace "$orig" >/dev/null +pkill -f 'conky -c ./conky.conf' +cat /tmp/conky-dash.log +``` + +Expected: no `dashboard error` in the log, and `/tmp/dash-clock.png` shows the +current hour above the current minute in a heavy face, the weekday in caps, and +the slashed date. View the image and compare against `idea2.png` for shape. + +- [ ] **Step 4: Commit** + +```bash +cd ~/Programming/GIT/conky-theme-udt +git add widgets/clock.lua +git commit -m "feat: add the clock widget + +Hour stacked over minute in Noto Sans Black, weekday in caps, date +slashed, following idea2.png. Colours are the palette's. + +Numeral size derives from the cell rather than a constant, so the same +widget fills its cell on either monitor. The slashes take the dimmer +label colour so the numerals read first." +``` + +--- + +### Task 7: Layout sanity check across both screen sizes + +The grid's entire justification is that one table works on both monitors. Verify that rather than trusting it. + +**Files:** +- Create: `test/test_layout.lua` +- Modify: `dashboard.lua` (expose `rect_for` for testing) + +- [ ] **Step 1: Expose the grid maths** + +`rect_for` is a local, so the test cannot reach it. Export it on a table without +changing how `conky_main` uses it. In `dashboard.lua`, immediately after the +`rect_for` function definition, add: + +```lua +-- Exported for test/test_layout.lua. The grid's whole claim is that one layout +-- table works on differently shaped screens, which is worth checking. +conky_dashboard_internal = { rect_for = rect_for, COLS = COLS, ROWS = ROWS, + GAP = GAP, MARGIN = MARGIN } +``` + +- [ ] **Step 2: Write the test** + +Create `test/test_layout.lua`: + +```lua +-- Grid maths check. +-- Run from the repo root: lua test/test_layout.lua +-- +-- dashboard.lua requires cairo, which only exists inside Conky, so stub the +-- pieces it touches at load time before requiring it. +package.path = './?.lua;' .. package.path +package.preload['cairo'] = function() return {} end +package.preload['lib.card'] = function() return {} end +conky_window = nil +function conky_parse(s) return '#000000' end + +dofile('dashboard.lua') +local G = conky_dashboard_internal +assert(G, 'dashboard.lua must export conky_dashboard_internal') + +-- Every cell must land inside the surface, on both real monitors. +for _, screen in ipairs({ { 2560, 1080 }, { 1920, 1080 } }) do + local sw, sh = screen[1], screen[2] + for col = 1, G.COLS do + for row = 1, G.ROWS do + local r = G.rect_for({ col = col, row = row, w = 1, h = 1 }, sw, sh) + assert(r.x >= G.MARGIN - 0.01, + ('x underflows margin at %dx%d col %d'):format(sw, sh, col)) + assert(r.y >= G.MARGIN - 0.01, + ('y underflows margin at %dx%d row %d'):format(sw, sh, row)) + assert(r.x + r.w <= sw - G.MARGIN + 0.01, + ('cell overflows width at %dx%d col %d: x=%f w=%f'):format(sw, sh, col, r.x, r.w)) + assert(r.y + r.h <= sh - G.MARGIN + 0.01, + ('cell overflows height at %dx%d row %d'):format(sw, sh, row)) + assert(r.w > 0 and r.h > 0, 'cell must have positive size') + end + end +end + +-- A spanning cell must cover its cells plus the gap between them, so two +-- side-by-side 1-wide cards and one 2-wide card occupy the same pixels. +local a = G.rect_for({ col = 1, row = 1, w = 1, h = 1 }, 2560, 1080) +local b = G.rect_for({ col = 2, row = 1, w = 1, h = 1 }, 2560, 1080) +local span = G.rect_for({ col = 1, row = 1, w = 2, h = 1 }, 2560, 1080) +assert(math.abs((b.x + b.w) - (span.x + span.w)) < 0.01, + ('a 2-wide card must end where the second 1-wide card ends: %f vs %f') + :format(b.x + b.w, span.x + span.w)) +assert(math.abs(span.w - (a.w * 2 + G.GAP)) < 0.01, 'span must absorb the gap') + +-- The shipped layout must not place anything outside the declared grid, which +-- is the mistake a user editing the table will actually make. +for _, e in ipairs(G.layout or {}) do + assert(e.col >= 1 and e.col + (e.w or 1) - 1 <= G.COLS, + 'layout entry out of columns: ' .. tostring(e.widget)) + assert(e.row >= 1 and e.row + (e.h or 1) - 1 <= G.ROWS, + 'layout entry out of rows: ' .. tostring(e.widget)) +end + +print('test_layout: all assertions passed') +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `cd ~/Programming/GIT/conky-theme-udt && lua test/test_layout.lua` + +Expected: failure. The last block reads `G.layout`, which Step 1 did not export. + +- [ ] **Step 4: Export the layout too** + +In `dashboard.lua`, change the exported table to include the layout: + +```lua +conky_dashboard_internal = { rect_for = rect_for, COLS = COLS, ROWS = ROWS, + GAP = GAP, MARGIN = MARGIN, layout = layout } +``` + +- [ ] **Step 5: Run both tests to verify they pass** + +```bash +cd ~/Programming/GIT/conky-theme-udt +lua test/test_data.lua && lua test/test_layout.lua +``` + +Expected: +``` +test_data: all assertions passed +test_layout: all assertions passed +``` + +- [ ] **Step 6: Commit** + +```bash +cd ~/Programming/GIT/conky-theme-udt +git add dashboard.lua test/test_layout.lua +git commit -m "test: check grid maths on both real screen sizes + +The grid's justification is that one layout table works on differently +shaped screens, so assert it: every cell lands inside the margins at +2560x1080 and 1920x1080, a spanning cell absorbs the gap it covers, and +no shipped layout entry falls outside the declared grid. + +dashboard.lua exports its internals for this; cairo and lib.card are +stubbed via package.preload since they only exist inside Conky." +``` + +--- + +### Task 8: Hyprland placement and the toggle + +**Files:** +- Create: `hypr/dashboard.conf` + +- [ ] **Step 1: Write the Hyprland config fragment** + +Create `hypr/dashboard.conf`: + +```bash +# Conky Lua dashboard: window placement and toggle. +# +# Source this from hyprland.conf: +# source = ~/Programming/GIT/conky-theme-udt/hypr/dashboard.conf +# +# The dashboard is a normal toplevel, not a desktop-layer surface, which is what +# lets these rules put it on a workspace at all. + +# Pin it to its own special workspace. `silent` keeps launching it from stealing +# focus, since it starts with the session rather than on request. +windowrulev2 = workspace special:dash silent, class:^(conky-dash)$ +windowrulev2 = float, class:^(conky-dash)$ +windowrulev2 = fullscreen, class:^(conky-dash)$ +windowrulev2 = noborder, class:^(conky-dash)$ +windowrulev2 = noshadow, class:^(conky-dash)$ +# It is a dashboard, not a window: never let it take focus or be tabbed to. +windowrulev2 = nofocus, class:^(conky-dash)$ + +# Toggle. Super+D shows and hides the special workspace. +bind = SUPER, D, togglespecialworkspace, dash + +# Always running, started with the session: hiding is a workspace switch, so +# showing is instant and any graph history survives. Draws are not skipped while +# hidden; that optimisation was rejected as unmeasured. +exec-once = conky -c ~/.config/conky/conky.conf +``` + +- [ ] **Step 2: Verify the rules match the real window** + +Do not assume the class matches; check it against a running instance. + +```bash +cd ~/Programming/GIT/conky-theme-udt +(conky -c ./conky.conf >/tmp/conky-dash.log 2>&1 &) +sleep 3 +hyprctl clients -j | jq -r '.[]|select(.class=="conky-dash")|"class=\(.class) ws=\(.workspace.name) floating=\(.floating) size=\(.size)"' +pkill -f 'conky -c ./conky.conf' +``` + +Expected: one line with `class=conky-dash`. If it prints nothing, the class in +the config and the rules disagree and the rules will never fire. + +- [ ] **Step 3: Verify the waybar launcher command** + +The waybar module lives in the UDT repo, so this task only confirms the command +the user will bind. Run it and check the workspace toggles: + +```bash +hyprctl dispatch togglespecialworkspace dash +sleep 1 +hyprctl activeworkspace -j | jq -r .name +hyprctl dispatch togglespecialworkspace dash +``` + +Expected: the middle command prints `special:dash`, then the state returns. + +- [ ] **Step 4: Commit** + +```bash +cd ~/Programming/GIT/conky-theme-udt +git add hypr/dashboard.conf +git commit -m "feat: add Hyprland placement rules and toggle + +Pins conky-dash to a special workspace, fullscreen, no border, no +focus, and binds Super+D to toggle it. Sourced from hyprland.conf +rather than edited into it. + +nofocus and silent matter because the dashboard starts with the session: +without them it steals focus at login and can be tabbed to like an app." +``` + +--- + +### Task 9: Switch UDT over + +Last, so no intermediate state touches the working desktop. This modifies the other repo. + +**Files:** +- Modify: `~/Programming/GIT/unified-desktop-theme/bin/udt-palette:545-555` and its `TARGETS` entry at line 654 +- Modify: `~/Programming/GIT/unified-desktop-theme/install.sh:178-207` +- Delete: `~/Programming/GIT/unified-desktop-theme/templates/conky.conf.in` + +- [ ] **Step 1: Read the current state of both files** + +Do not edit from this plan's quoted line numbers alone; confirm them first. + +```bash +cd ~/Programming/GIT/unified-desktop-theme +sed -n '540,560p' bin/udt-palette +sed -n '645,670p' bin/udt-palette +sed -n '175,210p' install.sh +``` + +- [ ] **Step 2: Add the critical role to gen_conky** + +In `bin/udt-palette`, replace the `gen_conky` function (around line 545) with: + +```python +def gen_conky(res, scheme, palette, template): + """conky: substitute into the dashboard template. + + The template lives in the conky-theme-udt repo, which owns the Lua + dashboard; this only fills in the colours. `critical` is included for the + dashboard's error overlay, which draws a caught Lua error on screen because + conky otherwise reports one as a blank window. + """ + out = template.replace("@SCHEME@", scheme) + for role in ("heading", "label", "rule", "value", "highlight", "ok", + "body", "body_outline", "body_shade", "critical"): + out = out.replace(f"@{role.upper()}@", hex6(res[role])) + return out +``` + +- [ ] **Step 3: Point the target at this repo** + +In `bin/udt-palette`, the `TARGETS` list entry at line 654 currently reads: + +```python + ("templates/conky.conf", gen_conky, "templates/conky.conf.in"), +``` + +The template and its output now live in the other repo. Paths in `TARGETS` are +relative to the UDT repo root, so this needs the dashboard repo's location. +Add near the top of the file, after the other module-level constants: + +```python +# The Lua dashboard lives in its own repo; UDT only renders its colours. +CONKY_REPO = Path("~/Programming/GIT/conky-theme-udt").expanduser() +``` + +and change the `TARGETS` entry to: + +```python + (str(CONKY_REPO / "conky.conf"), gen_conky, str(CONKY_REPO / "conky.conf.in")), +``` + +Confirm `Path` is already imported; if not, add `from pathlib import Path` to +the imports. + +- [ ] **Step 4: Run the palette selftest** + +This is the check that a scheme can satisfy every role, so it catches a missing +`critical` in any of the nine schemes. + +```bash +cd ~/Programming/GIT/unified-desktop-theme +./bin/udt-palette --selftest +``` + +Expected: a pass for every scheme. A failure naming `critical` means that +scheme's `roles-*.conf` lacks the role under `[state]`; add it there. + +- [ ] **Step 5: Render and confirm the placeholders are gone** + +```bash +cd ~/Programming/GIT/unified-desktop-theme +./bin/udt-palette +grep -c '@[A-Z_]*@' ~/Programming/GIT/conky-theme-udt/conky.conf +``` + +Expected: `0`. Any remaining placeholder is a role `gen_conky` does not +substitute. + +- [ ] **Step 6: Update install.sh** + +In `install.sh`, the conky block around line 182 currently reads: + +```bash +mkdir -p "$HOME/.config/conky" +ln -sfn "$repo/templates/conky.conf" "$HOME/.config/conky/conky.conf" +``` + +Replace with: + +```bash +# The Lua dashboard lives in its own repo; udt-palette renders its colours into +# conky.conf there. Both the config and the Lua it loads are linked, since +# lua_load points at ~/.config/conky/dashboard.lua. +conky_repo="$HOME/Programming/GIT/conky-theme-udt" +mkdir -p "$HOME/.config/conky" +ln -sfn "$conky_repo/conky.conf" "$HOME/.config/conky/conky.conf" +ln -sfn "$conky_repo/dashboard.lua" "$HOME/.config/conky/dashboard.lua" +ln -sfn "$conky_repo/lib" "$HOME/.config/conky/lib" +ln -sfn "$conky_repo/widgets" "$HOME/.config/conky/widgets" +``` + +- [ ] **Step 7: Delete the old template** + +```bash +cd ~/Programming/GIT/unified-desktop-theme +git rm templates/conky.conf.in +# The rendered output is gitignored, so remove it from disk only. +rm -f templates/conky.conf +``` + +- [ ] **Step 8: Run the full install and confirm the dashboard comes up** + +```bash +cd ~/Programming/GIT/unified-desktop-theme +./install.sh +sleep 3 +hyprctl clients -j | jq -r '.[]|select(.class=="conky-dash")|"class=\(.class) ws=\(.workspace.name)"' +``` + +Expected: the install reports conky among what it reloaded, and the window +appears with `class=conky-dash`. + +If nothing appears, run it in the foreground to see the Lua error, which is the +only way it will surface: +`timeout 8 conky -c ~/.config/conky/conky.conf` + +- [ ] **Step 9: Verify a scheme switch still recolours it** + +This is what the whole templating exists for, so prove it end to end. + +```bash +grep '^scheme' ~/.config/udt/roles.conf # note the current value +sed -i 's/^scheme = .*/scheme = nord/' ~/.config/udt/roles.conf +cd ~/Programming/GIT/unified-desktop-theme && ./install.sh >/dev/null +grep -E 'color1|default_color' ~/Programming/GIT/conky-theme-udt/conky.conf +``` + +Expected: Nord hex values, different from the Macchiato ones. Then restore the +original scheme and re-run `./install.sh`. + +- [ ] **Step 10: Commit both repos** + +```bash +cd ~/Programming/GIT/unified-desktop-theme +git add bin/udt-palette install.sh +git commit -m "refactor: render the conky template from the dashboard repo + +The Lua dashboard lives in conky-theme-udt now, so udt-palette renders +that repo's conky.conf.in instead of a local template and install.sh +links the Lua alongside the config. + +gen_conky also substitutes critical, for the dashboard's error overlay: +conky reports a Lua fault as a blank window, so the dashboard catches +it and draws the message instead. + +The old variables-and-execi template is deleted. Its hardware discovery +was not lost; the hwmon glob-by-name approach is carried into +lib/data.lua deliberately, since fixed indices drift across kernel +reorders." +``` + +--- + +### Task 10: README + +**Files:** +- Create: `README.md` + +- [ ] **Step 1: Write it** + +Create `README.md`: + +```markdown +# conky-theme-udt + +A fullscreen Conky dashboard for Hyprland, drawn with Cairo from Lua, living on +a special workspace that `SUPER+D` toggles. + +Colours come from [unified-desktop-theme](../unified-desktop-theme): this repo +holds `conky.conf.in`, and UDT's `bin/udt-palette` renders it into `conky.conf` +using the current scheme's `[conky]` roles. Switching scheme recolours the +dashboard. + +## Install + + cd ../unified-desktop-theme && ./install.sh + +That renders `conky.conf` and links it, `dashboard.lua`, `lib/` and `widgets/` +into `~/.config/conky/`. Then source the Hyprland rules once, from +`hyprland.conf`: + + source = ~/Programming/GIT/conky-theme-udt/hypr/dashboard.conf + +## Changing the layout + +Edit the `layout` table at the top of `dashboard.lua`. `col`/`row` are grid +cells and `w`/`h` span them; cell size is derived from the screen, so the same +table works on differently shaped monitors. + + local layout = { + { widget = 'clock', col = 1, row = 1, w = 1, h = 2 }, + } + +Reordering widgets is an edit to that table and nothing else: a widget is handed +a rectangle and draws inside it, so it cannot care where it is. Adding one is a +file in `widgets/` exporting `draw(cr, rect, colors)` plus a row in the table. + +`COLS` and `ROWS` above the table set the grid. Cards snap to it; there is no +absolute pixel placement, deliberately, because cells keep aligning when the +grid or the screen changes. + +## Development + +Run it in the foreground to see Lua output, which is the only debugging channel: + + conky -c ~/.config/conky/conky.conf + +Run the parser and layout checks: + + lua test/test_data.lua && lua test/test_layout.lua + +Screenshot it, remembering that `grim` captures screen coordinates and so needs +the dashboard's workspace to be the active one: + + hyprctl dispatch togglespecialworkspace dash + sleep 1 + grim -g "$(hyprctl clients -j | jq -r '.[]|select(.class=="conky-dash")|"\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')" /tmp/dash.png + +## Gotchas worth knowing + +**Cairo on Wayland needs `conky_surface()`.** Under `out_to_wayland`, +`conky_window.drawable` and `.visual` are both `nil`, so the +`cairo_xlib_surface_create` idiom used by essentially every Conky-Lua tutorial +cannot work here. + +**A Lua error is a blank screen and nothing else.** No stderr, no log. The draw +runs inside `pcall` and paints the caught error on the surface, which is the +only reason a mistake is visible at all. + +**`own_window_type` must be `normal`.** A `desktop`-type window is a +layer-surface at level 0 and Hyprland cannot assign it to a workspace. + +**Cairo has no Black font weight.** `CAIRO_FONT_WEIGHT_BOLD` is the maximum, so +the heavy clock numerals ask for the family `Noto Sans Black` at normal weight. + +**Sensors are globbed by hwmon `name`, never by index.** Indices drift across +kernel and hardware reorders, and a stale one silently reports a different chip. + +## License + +GPLv2 only. See `LICENSE`. + +## Development Approach + +This project is developed using AI-assisted tools. Code is generated with the help of AI based on human-provided specifications, design decisions, and iterative feedback. + +All contributions are reviewed, tested, and curated by the maintainer before being included in the codebase. AI is used as a productivity and exploration tool, while human oversight remains central to all decisions. + +The goal is to combine the flexibility of AI-assisted development with standard open-source practices such as transparency, review, and accountability. +``` + +- [ ] **Step 2: Verify the commands in it actually work** + +A README whose commands fail is worse than none. Run the two test commands and +the foreground command from it. + +```bash +cd ~/Programming/GIT/conky-theme-udt +lua test/test_data.lua && lua test/test_layout.lua +timeout 5 conky -c ~/.config/conky/conky.conf 2>&1 | grep -c 'dashboard error' || true +``` + +Expected: both tests pass, and the `grep -c` prints `0`. + +- [ ] **Step 3: Commit** + +```bash +cd ~/Programming/GIT/conky-theme-udt +git add README.md +git commit -m "docs: add README + +Install, how to edit the layout table, how to run the checks, and the +four gotchas that cost real time: conky_surface() on Wayland, a Lua +error presenting as a blank screen, own_window_type needing to be +normal, and Cairo having no Black font weight." +``` + +--- + +## Verification checklist + +Run after Task 10. Every item is a command with an expected result, not a judgement. + +- [ ] Parsers pass: `lua test/test_data.lua` prints `test_data: all assertions passed` +- [ ] Layout passes: `lua test/test_layout.lua` prints `test_layout: all assertions passed` +- [ ] No unsubstituted placeholders: `grep -c '@[A-Z_]*@' conky.conf` prints `0` +- [ ] No Lua faults: `timeout 8 conky -c ~/.config/conky/conky.conf 2>&1 | grep 'dashboard error'` prints nothing +- [ ] Window places correctly: `hyprctl clients -j | jq -r '.[]|select(.class=="conky-dash")|.workspace.name'` prints `special:dash` +- [ ] Toggle works: `hyprctl dispatch togglespecialworkspace dash` makes it visible, again hides it +- [ ] Clock is legible in a screenshot taken on its own workspace, and matches `idea2.png` in shape +- [ ] Scheme switch recolours it: change `scheme` in `~/.config/udt/roles.conf`, run UDT's `install.sh`, see different hex in `conky.conf` +- [ ] Both repos have signed commits: `git log --format='%h %G? %s' -5` shows `G` in each + +## What this plan does not build + +From the spec, deliberately deferred. None of it blocks the v1 slice. + +- The system, weather, network and media widgets. Each is one file in `widgets/` + plus one row in the layout table, which is what Tasks 4-7 exist to make true. +- The weather fetch script and `~/.config/udt/weather.env`. The spec settles the + source (OpenWeatherMap) and the condition mapping to port. +- Album art via Imlib2, which still needs a check that the bindings work on a + Wayland `conky_surface()`. +- The waybar launcher module, which belongs in the UDT repo next to the rest of + the waybar config. +- Per-monitor layouts, absolute placement, click interaction. |
