diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-18 09:26:32 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-18 09:26:32 +0200 |
| commit | c64bf67e15b075182deb4eebead8315c9081ac46 (patch) | |
| tree | faa8bbce23d9f2358b606727f5c36707e2ac2acb /widgets | |
| parent | 6aee635dd0cc5bab4fe3a989e41cea7faa75a9e1 (diff) | |
| download | conky-theme-udt-c64bf67e15b075182deb4eebead8315c9081ac46.tar.gz conky-theme-udt-c64bf67e15b075182deb4eebead8315c9081ac46.zip | |
feat: add a calendar card reading khal
The month grid with the week's appointments below it, colour-coded by
calendar. Events come from a cache file rather than a live call: khal
costs about 200ms of Python startup, which is two orders of magnitude
over the 2-second draw budget, so bin/calendar-sample.sh writes the
cache on a 5-minute execi and the widget only parses.
Calendar colours are read out of khal's own config by the sampler and
mapped onto the board's palette roles, so the card follows a scheme
change instead of pinning three literal hues.
khal will not emit an ISO date: {start-date} uses the user's dateformat,
which carries no year here, and a strftime spec inside the field raises.
The sampler asks for {start-date-long} and data.calendar_date recovers
the numbers by position, resolving day/month by magnitude and reading a
genuinely ambiguous pair day-first. A 2-digit year is refused rather
than guessed at.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'widgets')
| -rw-r--r-- | widgets/calendar.lua | 247 |
1 files changed, 247 insertions, 0 deletions
diff --git a/widgets/calendar.lua b/widgets/calendar.lua new file mode 100644 index 0000000..82e10d3 --- /dev/null +++ b/widgets/calendar.lua @@ -0,0 +1,247 @@ +-- Calendar: the current month, and the week's appointments below it. +-- +-- Events come from a cache file written by bin/calendar-sample.sh, because +-- conky's Lua cannot run a process and khal costs ~200ms of Python startup, +-- which is two orders of magnitude over the frame budget. + +local card = require 'lib.card' +local data = require 'lib.data' + +local CACHE = (os.getenv('XDG_CACHE_HOME') or (os.getenv('HOME') .. '/.cache')) + .. '/udt/calendar.txt' + +-- khal's colour names mapped onto the board's palette roles, so the card +-- follows a scheme switch instead of pinning three literal hues that stop +-- matching the rest of the dashboard. +-- +-- The palette has no green/magenta/blue as such; it has roles. `ok` is the +-- green-ish one in every UDT scheme, `critical` the red/magenta one and +-- `highlight` the blue/accent one, which is why the mapping reads as it does. +-- A calendar whose colour is not listed falls back to `value`, so an added +-- calendar is visible and unstyled rather than invisible. +local COLOUR_ROLE = { + ['light green'] = 'ok', ['green'] = 'ok', + ['light magenta'] = 'critical', ['magenta'] = 'critical', + ['light red'] = 'critical', ['red'] = 'critical', + ['light blue'] = 'highlight', ['blue'] = 'highlight', + ['light cyan'] = 'highlight', ['cyan'] = 'highlight', + ['light yellow'] = 'warning', ['yellow'] = 'warning', + ['brown'] = 'warning', +} + +local WEEKDAYS = { 'M', 'T', 'W', 'T', 'F', 'S', 'S' } + +local M = {} + +local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end + +-- The palette colour for a calendar name, via khal's declared colour. +local function cal_colour(name, colors, khal_colors) + local role = COLOUR_ROLE[tostring(khal_colors[name] or ''):lower()] + return colors[role] or colors.value +end + +-- 'Dentist' at 13:00 -> '13:00', an all-day event -> 'all day'. +-- Never '00:00' for an all-day event: midnight is a legitimate start time and +-- the two must not read the same. +local function when(e) + return e.start or 'all day' +end + +-- The failure face: the sampler has not run. Named, as the other cached cards +-- do, rather than a blank cell that cannot be told from a crashed widget. +local function draw_empty(cr, inner, colors, label_f, note, hint) + local S = clamp(inner.h * 0.94 / (1.78 + 2 * 0.72), 10, 72) + S = math.min(S, card.fit_unit(cr, inner.w * 0.96, { + { { 'CALENDAR', card.FONT_MONO, label_f } }, + { { note, card.FONT_UI, 0.36 } }, + { { hint, card.FONT_MONO, 0.32 } }, + }, 100)) + card.font(cr, card.FONT_MONO, S * label_f, false) + card.rgba(cr, colors.label) + card.text(cr, inner.x, inner.y + S * label_f, 'CALENDAR') + card.font(cr, card.FONT_UI, S * 0.36, true) + card.text(cr, inner.x, inner.y + S + S * label_f * 2.6, note) + card.font(cr, card.FONT_MONO, S * 0.32, false) + card.text(cr, inner.x, inner.y + S + S * label_f * 2.6 + S * 0.72, hint) +end + +function M.draw(cr, rect, colors) + local inner = card.card(cr, rect, colors) + local LABEL_F = 0.30 + + local cal = data.calendar_parse(data.slurp(CACHE) or '') + if not cal then + draw_empty(cr, inner, colors, LABEL_F, 'no calendar data', + 'bin/calendar-sample.sh') + return + end + + -- The cache's own date, not os.date: they agree while the sampler is + -- running, and when it has stopped the grid should show the month the data + -- describes rather than silently drifting to a month with no marks in it. + local today = cal.today or { tonumber(os.date('%Y')), tonumber(os.date('%m')), + tonumber(os.date('%d')) } + local year, month, day = today[1], today[2], today[3] + local ndays, first_wd = data.month_shape(year, month) + local marked = data.calendar_month_days(cal.events, year, month) + + -- === Type =============================================================== + -- The grid's cell width is what actually constrains this card: seven columns + -- of two digits plus gaps. The event rows then take what is left. + local weeks = math.ceil((first_wd - 1 + ndays) / 7) + local grid_w = inner.w + local cell_w = grid_w / 7 + -- Day numbers sized to the column, with a ceiling so a wide card does not + -- grow the digits past the event type below them. + local day_size = clamp(cell_w * 0.46, 7, 26) + + local header_h = inner.h * 0.16 + local label_size = clamp(header_h * LABEL_F * 2.2, 9, 22) + local big_size = clamp(header_h * 0.46, 11, 28) + + -- === Header ============================================================= + -- The month name is the card's big value, top-right, per DESIGN.md. + local title = os.date('%B', os.time({ year = year, month = month, day = 1, + hour = 12 })):upper() + card.font(cr, card.FONT_HEAVY, big_size, false) + card.rgba(cr, colors.heading) + card.text_right(cr, inner.x + inner.w, inner.y + big_size, title) + + card.font(cr, card.FONT_MONO, label_size, false) + card.rgba(cr, colors.label) + card.text(cr, inner.x, inner.y + label_size, 'CALENDAR') + + -- === Month grid ========================================================= + local gy = inner.y + big_size * 1.5 + local row_h = day_size * 1.55 + + -- Weekday initials. Monday-first, matching khal's firstweekday = 0. + card.font(cr, card.FONT_MONO, day_size * 0.72, false) + card.rgba(cr, colors.label, 0.7) + for i = 1, 7 do + local w = card.measure(cr, WEEKDAYS[i]) + card.text(cr, inner.x + (i - 0.5) * cell_w - w / 2, gy, WEEKDAYS[i]) + end + + gy = gy + day_size * 0.9 + local col = first_wd + local gy_row = gy + for d = 1, ndays do + local s = tostring(d) + local cx = inner.x + (col - 0.5) * cell_w + local baseline = gy_row + day_size + + -- Today gets a filled pill behind the number, so the eye finds it before + -- reading any digits. + if d == day then + -- The pill takes the day's calendar colour when there is an event, so + -- today reads as both 'today' and 'busy' without the number having to + -- carry two codes at once. + card.rgba(cr, marked[d] and cal_colour(marked[d], colors, cal.colors) + or colors.highlight, 0.30) + card.rounded_path(cr, cx - cell_w * 0.40, baseline - day_size * 0.95, + cell_w * 0.80, day_size * 1.25, day_size * 0.3) + cairo_fill(cr) + end + + -- A day with an event takes its calendar's colour; the rest stay in the + -- body colour, so colour on this grid always means "something is on". + local owner = marked[d] + + card.font(cr, card.FONT_MONO, day_size, owner ~= nil or d == day) + if d == day then + -- Over the pill the calendar colour loses too much contrast, so today's + -- number stays in the heading colour and the pill behind it carries the + -- calendar code. + card.rgba(cr, colors.heading) + elseif owner then + card.rgba(cr, cal_colour(owner, colors, cal.colors)) + else + -- A past day dims: the month grid is mostly about what is still coming. + card.rgba(cr, colors.body, d < day and 0.45 or 0.85) + end + local w = card.measure(cr, s) + card.text(cr, cx - w / 2, baseline, s) + + col = col + 1 + if col > 7 then col = 1; gy_row = gy_row + row_h end + end + + local grid_bottom = gy + weeks * row_h + + -- === Event table ======================================================== + -- Everything below the grid, however much that is. A card too short for even + -- one row draws the grid alone rather than a clipped half-row. + local avail = (inner.y + inner.h) - grid_bottom + local ev_size = clamp(day_size * 0.78, 7, 18) + local ev_step = ev_size * 1.75 + if avail < ev_step then return end + + local ey = grid_bottom + ev_size * 1.1 + + if #cal.events == 0 then + card.font(cr, card.FONT_UI, ev_size, false) + card.rgba(cr, colors.label, 0.8) + card.text(cr, inner.x, ey, 'nothing this week') + return + end + + -- The time column is fixed-width so the titles align down the card; it is + -- measured from the widest time actually present rather than assumed, since + -- 'all day' is wider than '13:00'. + card.font(cr, card.FONT_MONO, ev_size, false) + local time_w = 0 + for _, e in ipairs(cal.events) do + local w = card.advance(cr, when(e)) + if w > time_w then time_w = w end + end + local gap = ev_size * 0.6 + local title_x = inner.x + time_w + gap + local title_w = (inner.x + inner.w) - title_x + + -- A rough characters-per-pixel budget for the title column, so a long title + -- is cut rather than drawn over the card's edge. Measured from a digit's + -- advance in the row font: proportional titles vary, but this is a cut + -- point, not a layout the rest depends on. + local char_w = card.advance(cr, '0') + local title_chars = math.max(4, math.floor(title_w / math.max(char_w, 1))) + + -- How many rows actually fit, so the last one can say what it is hiding. + -- A card that silently drops the rest of the week is worse than one showing + -- fewer events and admitting it: the whole point of the card is knowing + -- what is coming. + local room = math.floor(((inner.y + inner.h) - ey + ev_step) / ev_step) + local shown = #cal.events + if room < shown then shown = math.max(room - 1, 1) end + local hidden = #cal.events - shown + + for i = 1, shown do + local e = cal.events[i] + if ey + ev_step > inner.y + inner.h then break end + local colour = cal_colour(e.calendar, colors, cal.colors) + + card.font(cr, card.FONT_MONO, ev_size, false) + card.rgba(cr, colour) + card.text(cr, inner.x, ey, when(e)) + + -- The title in the body colour, not the calendar's: colour-coding every + -- glyph in the row makes three calendars read as three unrelated cards. + -- The time carries the code, the title stays readable. + card.font(cr, card.FONT_UI, ev_size, false) + card.rgba(cr, colors.value) + card.text(cr, title_x, ey, card.truncate(e.title, title_chars)) + + ey = ey + ev_step + end + + -- The overflow line, in the label colour so it reads as chrome rather than + -- as another appointment. + if hidden > 0 and ey + ev_step <= inner.y + inner.h + ev_step then + card.font(cr, card.FONT_UI, ev_size * 0.92, false) + card.rgba(cr, colors.label, 0.8) + card.text(cr, inner.x, ey, string.format('+%d more', hidden)) + end +end + +return M |
