# 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. **7. Lua cannot read the palette through `conky_parse`.** `conky_parse('${color3}')` returns an **empty string**: colour variables emit escape codes into the text renderer rather than evaluating to hex. `${default_shade_color}` is not a variable at all and comes back as literal text. `conky.config` is not exposed either (`_G.conky` is `nil`), and `conky_info` holds only `cpu_count` and `update_interval`. What Lua does get is **`conky_config`**, the config file's path, so `dashboard.lua` reads that file and parses the hex out of it. All four behaviours were checked experimentally. --- ## 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.lua` | Hyprland window rules and the `SUPER+S` bind, as a Lua section for the user's `hyprland.lua`. | | `hypr/README.md` | How to wire that section in, and the one-line `autostart.lua` change it needs. | | `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 load is a delta between two samples, so the counter 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 -- One reused extents struct for every measurement, allocated at load. -- -- Not per call: cairo_text_extents_t:create() leaks. 5000 allocations grow -- Lua's heap by ~182KB that collectgarbage() never reclaims, and calling -- :destroy() on each one does NOT help (measured: same 182KB either way). -- Reusing a single struct costs 0KB. local extents = cairo_text_extents_t:create() -- Ink size of a string: how much space the glyphs actually cover. -- Use this to CENTRE or RIGHT-ALIGN text, never to advance a cursor. function M.measure(cr, s) cairo_text_extents(cr, s, extents) return extents.width, extents.height end -- How far the cursor moves after drawing a string. Use this to lay out runs of -- text left to right. -- -- Not measure(): that returns the INK width, which ignores leading and -- trailing spaces because a space carries no ink. " / " measures 6px of ink -- against a 14px advance, so stepping a cursor by the ink width renders a -- segmented date as "16 /SEP /2026", each slash jammed into the next glyph. function M.advance(cr, s) cairo_text_extents(cr, s, extents) return extents.x_advance 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', -- Translucency via own_window_colour '#AARRGGBB'. NOT own_window_argb_visual -- (removed in conky 1.24: ARGB is always on when available) nor -- own_window_argb_value (deprecated); both emit warnings on this build. -- c8 = 200/255 alpha. own_window_colour = '#c8181926', -- Deliberately small: this is the development config and a window this size -- is easier to screenshot and compare than a fullscreen one. The template -- uses the real monitor size. 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 -- Required: palette() reads this for the card fill. Omit it and every card -- draws in hex()'s magenta sentinel. default_shade_color = '#1e2030', -- body shade } -- 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 -- The colours, read out of the config file itself. -- -- NOT via conky_parse('${color3}'): that returns an EMPTY STRING. Conky's -- colour variables emit renderer escape codes into conky.text, they do not -- evaluate to a hex string, and ${default_shade_color} is not a variable at all -- (conky_parse hands the literal text straight back). Verified experimentally; -- both were checked before this approach was chosen. -- -- conky.config is also not exposed to Lua (_G.conky is nil) and conky_info -- carries only cpu_count and update_interval. What Lua does get is -- conky_config, the path of the config file, so the colours are parsed out of -- the rendered file. UDT has already substituted real hex into it by then. -- -- Read once at load, not per frame: the file cannot change without a conky -- restart, since conky never rereads its config. local function config_colors() local f = io.open(conky_config, 'r') if not f then return {} end local src = f:read('*a') f:close() local c = {} -- [%w_]+ not %w+: default_color and default_shade_color carry underscores. for k, v in src:gmatch("([%w_]+)%s*=%s*'(#%x%x%x%x%x%x)'") do c[k] = v end return c end local CFG = config_colors() local function palette() return { heading = hex(CFG.color1), label = hex(CFG.color2), border = hex(CFG.color3), rule = hex(CFG.color3), value = hex(CFG.color4), highlight = hex(CFG.color5), ok = hex(CFG.color6), critical = hex(CFG.color7), body = hex(CFG.default_color), -- The card fill, from default_shade_color (@BODY_SHADE@, `mantle` in -- Macchiato). It must NOT reuse color3: that is the border, and filling and -- stroking a card in one hue makes the hairline invisible, so the cards -- read as blobs instead of the mockup's thin outlines. Every scheme already -- defines body_shade, so this needs no palette change. surface = hex(CFG.default_shade_color), } 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 -x, not pkill -f: a -f pattern that matches this script's own # command line kills the shell's process group, returning 144 and # swallowing whatever command follows. pkill -x conky cat /tmp/conky-dash.log ``` Expected: `captured `, 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', -- Translucency via own_window_colour '#AARRGGBB'. NOT own_window_argb_visual -- (removed in conky 1.24: ARGB is always on when available) nor -- own_window_argb_value (deprecated); both emit warnings on this build. -- c8 = 200/255 alpha. The colour follows the palette: @BODY_SHADE@ with its -- leading '#' stripped, so a scheme switch recolours the backdrop too. own_window_colour = '#c8@BODY_SHADE_RAW@', -- The drawing surface, which Hyprland's fullscreen rule does NOT resize: -- the window goes fullscreen but conky keeps drawing at its minimum size, so -- a smaller value leaves the backdrop covering only part of the screen with -- bare wallpaper beside it. Set to the primary monitor (DP-1, 2560x1080). -- On a different monitor the grid still fills whatever surface it gets. minimum_width = 2560, minimum_height = 1080, 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_SHADE_RAW@/1e2030/' \ -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]) -- advance(), not measure(): the ink width of ' / ' omits its spaces. dx = dx + card.advance(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_SHADE_RAW@/1e2030/' \ -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 -x, not pkill -f: a -f pattern that matches this script's own # command line kills the shell's process group, returning 144 and # swallowing whatever command follows. pkill -x conky 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 -- dashboard.lua parses its colours out of the config file at load time, so -- conky_config must point at something readable. The rendered conky.conf is -- gitignored and may not exist, so aim the stub at the template: it parses to -- no colours (its values are still @PLACEHOLDER@), which is fine here because -- this test only exercises the grid maths. conky_config = 'conky.conf.in' 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. -- -- No `or {}` fallback here: with one, an unexported layout makes ipairs walk an -- empty table and the whole block passes vacuously, which is exactly the bug -- this assertion is supposed to catch. assert(G.layout, 'dashboard.lua must export layout') for _, e in ipairs(G.layout) 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 with `dashboard.lua must export layout`. The last block asserts on `G.layout`, which Step 1 deliberately did not export. If this run PASSES, the test is broken, not the code: an `ipairs(G.layout or {})` would walk an empty table and pass vacuously. Check the assertion is there before continuing. - [ ] **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 The user's Hyprland config is **Lua**, not `.conf`: `~/.config/hypr/hyprland.lua` requires modules from `~/.config/hypr/sections/`, and rules are declared through a helper API (`hl.window_rule`, `hl.bind`, `hl.exec_cmd`). A `.conf` fragment with `source =` does not fit that design, so this task writes a Lua section instead and the user copies it in. Three facts about the live config, verified before writing this task: - `sections/keybindings.lua:123` already binds `SUPER+D` to `hl.dsp.workspace.toggle_special("special")`. The dashboard therefore takes **`SUPER+S`**, which is unbound. - `sections/autostart.lua:23` already runs `hl.exec_cmd("conky")`. That line starts the OLD desktop-layer conky. Adding another `exec_cmd` for the dashboard would run two instances, so the existing line is what changes, not a new one. - The unnamed `special` workspace already hosts `kitty-scratchpad btop` (`autostart.lua`), which is why the dashboard gets its own named `special:dash` rather than sharing it. **Files:** - Create: `hypr/dashboard.lua` - Create: `hypr/README.md` - [ ] **Step 1: Write the Lua section** Create `hypr/dashboard.lua`. Note `hl.window_rule` takes a `name` and a `match` table, following the existing rules in `sections/look_and_feel.lua:112-152`. ```lua -- Conky Lua dashboard: window placement and toggle. -- -- Copy or symlink into ~/.config/hypr/sections/ and add to hyprland.lua: -- require("sections.dashboard") -- -- The dashboard is a normal toplevel, not a desktop-layer surface, which is -- what lets a window rule put it on a workspace at all. -- Its own named special workspace: the unnamed `special` already hosts the -- btop scratchpad. local WS = "special:dash" hl.window_rule({ name = "dash-workspace", match = { class = "conky-dash" }, workspace = WS, }) hl.window_rule({ name = "dash-float", match = { class = "conky-dash" }, float = true, }) hl.window_rule({ name = "dash-fullscreen", match = { class = "conky-dash" }, fullscreen = true, }) hl.window_rule({ name = "dash-no-border", match = { class = "conky-dash" }, border_size = 0, }) hl.window_rule({ name = "dash-no-rounding", match = { class = "conky-dash" }, rounding = 0, }) -- It is a dashboard, not a window: never let it take focus or be tabbed to. -- This matters because it starts with the session, so without it the dashboard -- would steal focus at login. hl.window_rule({ name = "dash-no-focus", match = { class = "conky-dash" }, no_focus = true, }) -- SUPER+S, not SUPER+D: keybindings.lua already binds D to the unnamed special -- workspace. -- Literal "SUPER", not hl.mainMod: keybindings.lua declares -- `local mainMod = "SUPER"`, a file-local never assigned onto hl, so -- hl.mainMod is nil in any other section. hl.bind("SUPER + s", hl.dsp.workspace.toggle_special("dash")) ``` Note on the two unusual rule keys: `workspace`, `float`, `border_size` and `rounding` all appear in existing rules, but `fullscreen` and `no_focus` appear nowhere in this config. They were checked against the runtime instead: `hl.window_rule` rejects an unknown field with `unknown field ''`, and neither of these produces that error, so the Lua API accepts both. - [ ] **Step 2: Verify it parses as Lua** The `hl` API only exists inside Hyprland, so this checks syntax, not semantics. Run: `cd ~/Programming/GIT/conky-theme-udt && lua -e "assert(loadfile('hypr/dashboard.lua')); print('SYNTAX OK')"` Expected: `SYNTAX OK` - [ ] **Step 3: Write the install note** Create `hypr/README.md`: ```markdown # Hyprland integration `dashboard.lua` is a section for the Lua-based Hyprland config in `~/.config/hypr/`. It is not sourced automatically; wire it in once: ln -s ~/Programming/GIT/conky-theme-udt/hypr/dashboard.lua \ ~/.config/hypr/sections/dashboard.lua and add to `~/.config/hypr/hyprland.lua`: require("sections.dashboard") ## One change to make by hand `sections/autostart.lua` runs `hl.exec_cmd("conky")`, which starts the old desktop-layer conky. The dashboard replaces it, so change that line to: hl.exec_cmd("conky -c ~/.config/conky/conky.conf") Leaving both would run two conky instances. ## Bindings `SUPER+S` toggles the dashboard. `SUPER+D` is already taken by the unnamed special workspace, and that workspace also hosts the btop scratchpad, which is why the dashboard uses its own `special:dash`. ``` - [ ] **Step 4: Verify the window class matches the rules** Do not assume the class matches; check it against a running instance. The rules are inert if the class in the config and the rules disagree. `conky.conf` is gitignored generated output, so regenerate it first if it is missing (Task 6's render command, repeated here so this step stands alone): ```bash cd ~/Programming/GIT/conky-theme-udt [ -f conky.conf ] || 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_SHADE_RAW@/1e2030/' \ -e 's/@BODY_OUTLINE@/#494d64/' -e 's/@BODY_SHADE@/#1e2030/' \ -e "s|~/.config/conky/dashboard.lua|./dashboard.lua|" \ conky.conf.in > conky.conf (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 -x, not pkill -f: a -f pattern matching this script's own command line # kills the shell's process group, which returns 144 and swallows any command # after it. pkill -x conky cat /tmp/conky-dash.log ``` Expected: one line with `class=conky-dash`. Nothing printed means the class is wrong and no rule will ever fire. - [ ] **Step 5: Commit** ```bash cd ~/Programming/GIT/conky-theme-udt git add hypr/ git commit -m "feat: add Hyprland placement rules and toggle A Lua section, not a .conf fragment: the live Hyprland config is hyprland.lua requiring sections/, with rules declared through the hl helper API. Pins conky-dash to its own special:dash workspace, fullscreen, no border, no focus, and binds SUPER+S. Not SUPER+D, which keybindings.lua already binds to the unnamed special workspace, and that workspace already hosts the btop scratchpad. no_focus matters because the dashboard starts with the session: without it, it steals focus at login and can be tabbed to like an app. autostart.lua already launches a bare conky, so the README says to change that line rather than add one, which would run two instances." ``` --- ### 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])) # own_window_colour takes '#AARRGGBB', so the window background needs the # shade colour without its leading '#' to sit after the alpha pair. out = out.replace("@BODY_SHADE_RAW@", hex6(res["body_shade"]).lstrip("#")) 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+S` 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 wire the Hyprland section in once, and change the bare `conky` in `sections/autostart.lua` to load this config. See [`hypr/README.md`](hypr/README.md) for both steps. ## 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 'hl.dsp.workspace.toggle_special("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 'hl.dsp.workspace.toggle_special("dash")'` makes it visible, again hides it (the bare `togglespecialworkspace dash` form errors under the Lua config) - [ ] 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.