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: 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: 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 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
Six cards cover the machine itself:
- System: CPU load as a per-core equaliser, the CPU and motherboard model as two rows under the header, 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,/dataand one NFS mount per server, as usage rings, with free space and total size printed under each ring. Two servers are configured, each with the same export mounted twice; the card shows one mount per server so no number appears twice. - Cache:
~/.cachetotal and its four largest subdirectories. - Network: LAN and public address, and download/upload throughput as two lines on one chart. See below.
- Slackware: distribution version, package count, kernel, install age, and
time since the last
slackpkgChangeLog update. See below.
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
six ${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}
${execi 1800 ~/.config/conky/bin/pubip-sample.sh}
${execi 900 ~/.config/conky/bin/slackware-sample.sh}
${execi 300 ~/.config/conky/bin/calendar-sample.sh}
The disks sampler runs once a minute; the cache, Slackware and public-IP
samplers run every 15, 15 and 30 minutes respectively, 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, and a residential public address
does not change often enough to warrant asking more frequently. The calendar
sampler runs every five minutes: khal costs about 200ms of Python startup,
which rules out calling it from a draw, and the cache has to notice both a
vdirsyncer run and the day rolling over. 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: $?"
./bin/pubip-sample.sh; echo "exit: $?"
./bin/slackware-sample.sh; echo "exit: $?"
./bin/calendar-sample.sh; echo "exit: $?"
All print nothing and exit 0 on success, and all write a temp file and rename it over the target, so the widget never reads a half-written file.
Network
The chart plots download and upload as two lines on one shared vertical
scale: scaled independently they would lie about their relative size, which
is the entire reason to draw them together. The window peak is printed at the
chart's top-left and the live rates at the bottom corners, each in its line's
colour. History is module state inside widgets/network.lua, one sample per
pixel column of the chart so it is exactly as wide as the card and never
interpolates; it does not survive a restart.sh or a conky restart, so the
chart starts empty and fills left to right.
The rates come from the graphed interface's own byte counters under
/sys/class/net, read on every draw: two file reads, no subprocess. The LAN
address comes from ip -4 addr show br0 and is memoized on first success,
retried while nil because a bridge may not be up yet when conky starts. The
public address comes from the cache file bin/pubip-sample.sh writes; once
that cache is older than four hours the card shows -- instead, because an
address that may no longer be yours displayed with confidence is worse than a
dash.
br0 is a bridge, not a physical interface, and the IFACE constant at
the top of widgets/network.lua is the one edit that changes which interface
is graphed. VM-to-host traffic crosses a bridge without reaching the router,
so a local copy of that traffic appears in the chart. This carries over
unchanged from the previous text config and is accepted behaviour, not a bug.
Slackware
The big top-right value is the time since /var/lib/slackpkg/ChangeLog.txt
last changed, coloured ok under 24 hours, warning to a week, critical
beyond, the same three-role language the system, GPU, disks and cache cards
use. Below it: distribution version, package count, kernel, and install age
(the root filesystem's birth time, where the filesystem records one).
Calendar
The month grid with the week's appointments under it. A day carrying an event
takes its calendar's colour; today takes a filled pill, coloured by its own
calendar when something is on and highlight otherwise, with the number over
it in the heading colour because a calendar colour loses too much contrast
against the fill. Past days in the month dim: the grid is mostly about what is
still coming. Below the grid, one row per appointment: date and start time
left in the calendar's colour, title right in the body colour. The date is
day/month with no year, since the window is a week and the grid above already
names the month. Colour-coding the whole
row makes three calendars read as three unrelated cards, so the time carries
the code and the title stays readable. All-day events read all day, never
00:00, because midnight is a legitimate start time.
A legend of the calendar colours is pinned along the card's bottom, in name order so it does not reshuffle between frames, and the event rows end above it rather than running under it. It lists every calendar khal declared a colour for, including ones with nothing on this week: it is a colour key, not a second list of what is coming.
Events come from a cache file written by bin/calendar-sample.sh, which shells
out to khal. The card shows as many rows as the cell affords and ends with
+N more when the week does not fit, since a card that silently drops the rest
of the week defeats its own purpose. A cell too short for even one row draws
the grid alone, without the legend, rather than a clipped half-row.
Calendar colours come from khal's own config, parsed out of the
[calendars] section by the sampler, then mapped onto the board's palette
roles in COLOUR_ROLE at the top of widgets/calendar.lua: green to ok,
magenta and red to critical, blue and cyan to highlight, yellow and brown
to warning. The card follows a scheme change that way instead of pinning
literal hues that drift out of step with the rest of the board. A calendar
whose colour is not in the table falls back to value, so it is visible and
unstyled rather than invisible.
khal will not emit an ISO date. {start-date} renders with the user's
dateformat, which here is %d.%m. and carries no year at all, and a
strftime spec inside the field ({start-date:%Y-%m-%d}) raises. The sampler
asks for {start-date-long} instead, which uses longdateformat and does
carry the year, and data.calendar_date pulls the three numbers out by
position: the 4-digit group is the year, and of the other two the one over 12
must be the day. A genuinely ambiguous pair like 03.04.2026 is read
day-first, matching khal's own defaults. A 2-digit year is refused rather than
guessed at.
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 every card
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. The Slackware card's ChangeLog age uses the same three roles on an hour scale (24 / 168) instead of a percentage. 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)andrgba(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 disks card shows df's available column, not size - used. On this
machine's root filesystem the two differ by about 13GB of root-reserved
blocks, which exist on disk but are not available to any non-root user. A
card that computed free space as size - used would overstate it by exactly
that reserve.
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.
