# 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 Rendering and linking are automatic: cd ../unified-desktop-theme && ./install.sh That renders `conky.conf` from `conky.conf.in` and symlinks it, along with `dashboard.lua`, `lib/`, `widgets/` and `bin/`, into `~/.config/conky/`. Re-running it after a scheme change re-renders the colours. The Hyprland side is **not** installed by that script and is still yours to do. Until you do it, the dashboard opens as an ordinary floating window on whatever workspace is current, with no keybind, because the window rules that place it are not loaded. Two things are needed, both described in [`hypr/README.md`](hypr/README.md): symlinking `hypr/dashboard.lua` into `~/.config/hypr/sections/` and requiring it from `hyprland.lua`, and changing the bare `hl.exec_cmd("conky")` in `sections/autostart.lua` so it loads this config. Both are deliberately left as your choice rather than done for you. ## Weather The weather card needs an [OpenWeatherMap](https://openweathermap.org/api) API key. Copy the example and fill it in: cp weather.env.example ~/.config/udt/weather.env chmod 600 ~/.config/udt/weather.env That path is outside the repo because it holds a key, and anything committed is potentially public. `weather.env` is gitignored here as a second line of defence. **A new key returns `401 Invalid API key` for anywhere from a few minutes to a few hours after you create it.** That is the API activating it, not a mistake in the file. Until it works, and whenever the cache is missing, the card draws its own chrome with `no weather data` and the thing to fix, rather than vanishing. Fetching is scheduled by conky itself: `conky.text` carries a single `${execi 900 ~/.config/conky/bin/weather-fetch.sh}`, so the cache refreshes every 15 minutes while the dashboard runs and never while it does not. The widget only ever reads `~/.cache/udt/weather.json`; it makes no network call and cannot block the draw. To check the fetch by hand: ./bin/weather-fetch.sh; echo "exit: $?" It prints nothing and exits 0 on success. On failure it says why on stderr and leaves any existing cache alone, so a transient error never replaces good data with an error body. Location and units live in `weather.env` too (`CITY`, `COUNTRY`, `UNITS`), not in the source, so no real location appears in committed files. ## System widgets Four cards cover the machine itself: - **System**: CPU load as a per-core equaliser, RAM used/total, and every temperature (CPU package, CCD die, NVMe and two board sensors). - **GPU**: Arc B580 package and VRAM temperatures, and fan speed. - **Disks**: `/`, `/home`, `/data` and one NFS mount per server, as usage rings. Two servers are configured, each with the same export mounted twice; the card shows one mount per server so no number appears twice. - **Cache**: `~/.cache` total and its four largest subdirectories. The disks and cache cards read a cache file that a sampler writes; they never call `du` or `statfs` themselves. `statfs` on an unreachable NFS path blocks, and the disks sampler exists so that a dead server costs a stale cache rather than a frozen draw. Sampling is scheduled by conky itself: `conky.text` carries three `${execi}` entries, even though that block renders nothing under Cairo. ${execi 900 ~/.config/conky/bin/weather-fetch.sh} ${execi 60 ~/.config/conky/bin/disks-sample.sh} ${execi 900 ~/.config/conky/bin/cache-sample.sh} The disks sampler runs once a minute; the cache one every 15 minutes, because `du -sh ~/.cache` walks the tree and takes about 100ms warm, which is fine on a slow cycle and unthinkable on the 2-second draw. Tying them to conky means nothing samples while the dashboard is down. To run a sampler by hand: ./bin/disks-sample.sh; echo "exit: $?" ./bin/cache-sample.sh; echo "exit: $?" Both print nothing and exit 0 on success, and both write a temp file and rename it over the target, so the widget never reads a half-written file. ### Host bindings These hwmon bindings, and the ceilings beside them, are specific to this machine and will not port. They are written down because the alternative is rediscovering them from a wrong reading. | chip | file | label | warning / critical | |---|---|---|---| | `k10temp` | `temp1_input` | CPU package | 75 / 90 | | `k10temp` | `temp3_input` | CCD die | 75 / 90 | | `nvme` | `temp1_input` | WD_BLACK SN850X | 60 / 70 | | `xe` | `temp2_input` | Arc B580 package | 75 / 85 | | `xe` | `temp3_input` | Arc B580 memory | 80 / 90 | | `xe` | `fan1_input` | Arc B580 fan RPM | none | | `gigabyte_wmi` | `temp2_input` | board | 60 / 70 | | `gigabyte_wmi` | `temp3_input` | board | 60 / 70 | Chips are globbed by the contents of their hwmon `name` file, never by index: indices drift across kernel and hardware reorders and a stale one silently reports a different chip. `Tccd1` is read straight from sysfs rather than by shelling out to `sensors`; same number, no subprocess. The `gigabyte_wmi` chip exposes five unlabelled temperatures, two of which the old conky config showed; they are labelled "board" rather than given a meaning they may not have. ### One colour language across all four cards Anything with a comfortable range and an uncomfortable one is coloured by the same three roles, so a card reads without learning a new scheme: `ok` below the first threshold, `warning` between them, `critical` above the second. Filesystems, cache usage and CPU load use 25% and 75%. Temperatures use a per-sensor pair (the table above), because 70C is unremarkable on a CPU package and alarming on an NVMe. These are starting values, host-specific exactly as the bindings are, and sit in one table per widget so they are easy to retune once real numbers under load are known. ## 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 = 1 }, { widget = 'weather', col = 1, row = 2, w = 1, h = 1 }, } 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. ## Writing a widget `DESIGN.md` is the visual identity every card follows; read it first. A widget module exports one function, `draw(cr, rect, colors)`. `lib/card.lua` carries the primitives it should use rather than reaching for Cairo directly: - `card(cr, rect, colors)` draws the rounded card and returns the padded inner rect to lay out against. - `font(cr, family, size, bold)` and `rgba(cr, colour)` set the state. - `text(cr, x, y, s)` draws at a baseline, `text_right(cr, x, y, s)` aligns to a right edge, `label(cr, x, y, s, colors)` draws a small-caps section label. - `measure(cr, s)` returns the **ink** size, what the glyphs actually cover. Use it to centre or right-align. - `advance(cr, s)` returns the **x_advance**, how far the cursor moves after drawing. Use it to step along a run of text. `measure` and `advance` are not interchangeable, and conflating them was a real bug here: ink width ignores leading and trailing spaces, so `' / '` measures 6px of ink against a 14px advance, and stepping a cursor by the ink width renders the date as `16 /SEP /2026`. ### Widgets are fluid A widget is handed a rectangle and must fill it, whatever shape the grid makes it. That is what lets you retune `COLS`/`ROWS` or move a card without touching widget code. Three rules, all learned by getting them wrong: **Derive every size and offset from the rect.** No fixed pixel drops between elements. A fixed `+34` under a numeral that scales leaves a dead band in a tall cell and overlaps in a short one. **Fill the cell with type, and shrink only on collision.** Height is what changes when you add a row to the grid. Size type off the width alone and a tall cell is left sparse; size it off the height alone and a short cell shrinks the text to a whisper. The robust way is a measured fit: `card.fit_unit()` returns the largest base size at which every row still fits the width, and the widget takes the smaller of that and its height budget. Larger cell, larger type; a row that would collide pulls the size back down. The GPU, cache and system cards do this. A card that cannot afford the measuring pass can still key off width with a clamp, `math.max(lo, math.min(hi, inner.w * k))`; the clock, weather and disks cards do. **Decide where the slack goes, and let one element absorb it.** The clock pins its date block to the bottom and gives the numerals everything above. The weather card stacks from the top and lets the arc take the remainder. Either is fine; what fails is leaving the slack wherever it lands. An element that cannot shrink below some size should drop out rather than overlap. The weather arc does this: below 70px it is not drawn at all, because a curve crossing the stat rows is worse than no curve. Check a widget at several spans before believing it is fluid, which the offscreen renderer above makes cheap. ## Development After editing `dashboard.lua` or a widget: ./restart.sh Conky never rereads its config and the Lua is symlinked into `~/.config/conky/`, so a restart is the whole update path. The script syntax-checks first and refuses to restart on a Lua error, since the alternative is a blank screen with no message. Only a change to `conky.conf.in` or the palette needs UDT's `install.sh` to re-render first. To see Lua output, which is the only debugging channel, run it in the foreground instead: pkill -x conky; conky -c ~/.config/conky/conky.conf Run the parser and layout checks: lua test/test_data.lua && lua test/test_layout.lua && lua test/test_weather.lua Screenshot it. `grim` captures screen coordinates, not a window, so the dashboard's workspace has to be the active one first, and the toggle needs the Lua dispatch form because this Hyprland config is Lua (the plain `togglespecialworkspace dash` form fails with `')' expected near 'dash'`). **Poll for the switch, do not sleep through it.** A fixed `sleep` races the compositor and captures whatever was on screen before; that is how a review screenshot here once came back showing a terminal. Capture the whole output instead of the window geometry, too: a window parked on a hidden special workspace reports a negative Y (`0,-540`), and feeding that to `grim -g` grabs the wrong region entirely. hyprctl dispatch 'hl.dsp.workspace.toggle_special("dash")' for i in $(seq 40); do [ "$(hyprctl monitors -j | jq -r '.[0].specialWorkspace.name')" = "special:dash" ] && break sleep 0.15 done sleep 2.5 # conky redraws every 2s; make sure a frame has landed grim -o DP-1 /tmp/dash.png hyprctl dispatch 'hl.dsp.workspace.toggle_special("dash")' Then **look at the PNG**. `grim` exits 0 whether the card drew correctly, drew nothing, or drew an error, so the exit status says nothing about the thing you are checking. Faster, for iterating on a widget's layout: conky's Cairo bindings load in plain Lua, so a widget can be rendered to a PNG without conky at all. Point `package.cpath` at `/usr/lib64/conky/lib?.so`, `require 'cairo'`, build an image surface, and call the widget's `draw` with a hand-made rect and palette. That turns install-restart-toggle-screenshot into one command. Be aware it crops away the neighbouring cards, so check the real dashboard before believing anything about relative type size. Before the Hyprland section is installed there is no `special:dash` to toggle, so the window is simply wherever it opened and a bare `grim` captures it. ## 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. **Lua cannot read the palette through `conky_parse`.** `conky_parse('${color3}')` comes back as an **empty string**: conky's colour variables emit renderer escape codes into the text stream rather than evaluating to hex, and `${default_shade_color}` is not a variable at all. `conky.config` is not exposed to Lua either. What Lua does get is `conky_config`, the config file's path, so `dashboard.lua` opens that file and parses the hex out of it. **Cairo has no Black font weight.** `CAIRO_FONT_WEIGHT_BOLD` is the maximum, so a heavier cut is selected by the family name fontconfig registers for it, e.g. `Noto Sans Black` or `Oswald SemiBold` at normal weight, rather than by asking for bold. **Fonts are per-widget when a widget wants one.** `lib/card.lua` holds the shared `FONT_*` families, but `card.font()` takes any family string, so a widget passes its own. The clock uses `FONT_CLOCK` (Oswald, OFL-1.1, under `~/.fonts/o/Oswald`) for squared numerals while the weather card keeps `FONT_HEAVY`. Check a new family with `fc-match` first: fontconfig silently substitutes a default for a name it does not know, which looks exactly like the font "not applying". **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. **`${execi}` fires even though `conky.text` renders nothing.** Cairo output covers the text stream, so the `execi` that refreshes the weather cache is invisible, but it still runs on schedule. This was checked with a probe config rather than assumed, because the whole refresh mechanism rests on it. **The weather cache is written to a temp file and renamed.** The widget reads it on the 2-second draw cadence, entirely unrelated to the 15-minute fetch, so a fetch killed mid-write would otherwise hand the parser half a response. Rename is only atomic within a filesystem, so the temp file sits in the same directory. **A bad OWM key returns 401 and an unknown city returns 404**, so `curl -f` catches both. The fetch script also checks `.cod` is 200 before replacing the cache, which covers the remaining case of a 200 whose body is not usable weather. The polybar script this was ported from needed that check as its only defence, because it ran curl without `-f`. **Nothing in `lib/weather.lua` raises.** Every function returns nil, or a sentinel, on input it cannot use, and that is verified against every function for nil, string, table, boolean, NaN and negative arguments. It matters because the parser's input is a file written by a network fetch, and a raise here is a blank dashboard rather than a message. **Type keyed on one dimension alone leaves the card broken or sparse.** The weather text once shrank to a whisper beside the clock when its cell got shorter because the size keyed on height; a card keyed only on width leaves a tall cell half empty. Fill the cell instead: `card.fit_unit()` measures the rows and returns the largest size that fits the width, the widget takes the smaller of that and its height budget, and a row that would collide pulls the size down. **A bare `df` would silently mis-read every disk figure.** The user's shell aliases `df` to `df -h`, so `bin/disks-sample.sh` calls `/usr/bin/df -P -B1` by absolute path. `-P` is the POSIX format, one line per filesystem with the mountpoint last, and `-B1` is bytes so the parser never interprets a suffix. An alias or a function would hand the parser `1.6G` where it expects an integer, and every figure would be quietly wrong. **The Arc B580 reports no utilisation through any stable interface.** The `xe` driver exposes no `gpu_busy_percent` (unlike `amdgpu`), `intel_gpu_top` refuses the device, and `gputop` prints per-process rows with ANSI escapes, which is not something to build a widget on. The GPU card therefore has no load bar; it shows what the hardware actually reports rather than inventing a number. **`/data` has no temperature.** It sits on a spinning disk with no hwmon chip; reading its SMART temperature would need `smartctl` as root and would spin up a sleeping disk on every poll. The NVMe composite temperature is freely readable and is shown; `/data` simply has none. **Per-core counters are module-level in `widgets/system.lua`.** Load is a delta between frames, so the counter has to outlive the draw; one created inside `draw` would sample against nothing and report nil forever. The aggregate counter is the same. ## 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.