aboutsummaryrefslogtreecommitdiffstats
path: root/widgets
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-18 09:26:32 +0200
committerDanilo M. <danix@danix.xyz>2026-09-18 09:26:32 +0200
commitc64bf67e15b075182deb4eebead8315c9081ac46 (patch)
treefaa8bbce23d9f2358b606727f5c36707e2ac2acb /widgets
parent6aee635dd0cc5bab4fe3a989e41cea7faa75a9e1 (diff)
downloadconky-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.lua247
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