aboutsummaryrefslogtreecommitdiffstats
path: root/README.md
blob: eb3502016174a01f70ee7b1a8c9054a2e74045f3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
# 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

Five cards cover the weather: `now` (current conditions), `hourly` (the next
24 hours), `week` (seven days), `details` (wind, humidity, UV, air quality) and
`sky` (sun, moon and the daylight arc). The forecast cards are adapted from
[Conky-Mirage](https://github.com/alexsson-xexpanderx/Conky-themes/tree/master/Conky-Mirage)
by alexsson-xexpanderx, redrawn in this dashboard's palette, glyphs and fonts.

Data comes from [Open-Meteo](https://open-meteo.com), which needs no key. Set
the location:

    cp weather.env.example ~/.config/udt/weather.env
    chmod 600 ~/.config/udt/weather.env

The file is sourced by bash, so a value with spaces needs quotes, as in
`CITY="Example City"`. That path is outside the repo because a location is
personal data, and anything committed is potentially public. `weather.env` is
gitignored here as a second line of defence. `CITY` is geocoded once through
Open-Meteo and the result cached in `~/.cache/udt/weather-loc`; changing `CITY`
or `COUNTRY` re-geocodes on the next fetch.

Fetching is scheduled by conky itself: `conky.text` carries
`${texeci 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
cards only ever read `~/.cache/udt/weather.txt`; they make no network call and
cannot block the draw. Until the first fetch lands, each card draws its own
chrome with `no weather data` rather than vanishing. A leftover
`~/.cache/udt/weather.json` from the old OpenWeatherMap version is no longer
read and can be deleted.

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. A failed air-quality call is not a failure:
the AQI row reads `--` and everything else updates.

## System widgets

CPU, RAM, temperatures, the GPU, disks, network and the public address used to
be cards here. They moved to the performance panel of the quickshell island
(`desktop/island/` in the quickshell repo), which also warns from the status
pill. The island keeps writing `~/.cache/udt/pubip.txt`. The cache and
Slackware cards followed them to quickshell.

The updates card, a copy of nvtable's table, moved to the updates page of the
quickshell drawer (`desktop/modules/updates/`), which sorts each pending update
into the action it needs and covers Slackware itself.

## Samplers

Cards read cache files that samplers write; none shells out from a draw.
Sampling is scheduled by conky itself: `conky.text` carries a threaded
`${texeci}` per sampler, even though that block renders nothing under Cairo.

    ${texeci 900  ~/.config/conky/bin/weather-fetch.sh}
    ${texeci 300  ~/.config/conky/bin/calendar-sample.sh}
    ${texeci 5    ~/.config/conky/bin/breaktimer-sample.sh}
    ${texeci 1800 ~/.config/conky/bin/immich-fetch.sh}

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/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.

### 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` for the rest of the month (at least 8 days), so the grid marks
every remaining day with an event while the rows stay the coming week. 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.

### Memories

Today's "N years ago" photos from an Immich server, one at a time, rotating
every 30 seconds. The big value is how many years ago, marked "Y ago" the way
a temperature carries its degree sign, with an "i/n" counter under the title
when there are several. Under the photo, when it was taken (the camera's
local time) and where, as city and country, or coordinates when Immich has no
place name for them.

`bin/immich-fetch.sh` runs every 30 minutes. It asks `/api/memories` for
today, or for each of the past six days when today has none, downloads each
photo's preview, converts it to PNG with ImageMagick (Cairo loads nothing
else) and writes an index beside the photos in `~/.cache/udt/immich/`,
dropping photos that are no longer in it. It needs
`curl`, `jq` and `magick`, and a key in `~/.config/udt/immich.env`: copy
`immich.env.example`. The key wants `memory.read` and `asset.view`; with
`asset.read` too, the card shows the place, and without it the row reads `--`.

The key sits in a plain file because the fetch runs
unattended from conky: reading it from a GPG-backed store would raise a
pinentry whenever the agent's cache has expired.

### 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.

Thresholds are starting values, and sit in one table per widget so they are
easy to retune.

## 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 = 'now', 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 and `text_right(cr, x, y, s)` aligns
  to a right edge.
- `notice(cr, inner, colors, title, note, hint)` draws the no-data face every
  cached card shares when its sampler has not run.
- `title(cr, x, y, icon, text, colors)` draws a card title: the icon in
  `highlight`, the text in `label`. `title_str(icon, text)` returns the same run
  as a string, for `fit_unit` to measure. The glyphs live in `card.ICON`.
- `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 breaktimer and
immich 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 does.

**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
sky card stacks its rows from the top and pins the arc to the bottom. 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 sky arc does this: below 50px it is not drawn at all, because a
curve crossing the 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 other cards keep
`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".

**`${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 200 is not proof of a forecast.** `curl -f` rejects HTTP errors, and the
fetch script also checks the flattened cache has a current temperature before
replacing the old one, so an unusable body never reaches the cards.

**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
old weather card's 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.

## 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.