aboutsummaryrefslogtreecommitdiffstats
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
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>
-rw-r--r--README.md46
-rwxr-xr-xbin/calendar-sample.sh76
-rw-r--r--conky.conf.in2
-rw-r--r--dashboard.lua2
-rw-r--r--lib/data.lua115
-rw-r--r--test/fixtures/calendar_cache10
-rw-r--r--test/test_data.lua61
-rw-r--r--widgets/calendar.lua247
8 files changed, 556 insertions, 3 deletions
diff --git a/README.md b/README.md
index bf71d2d..c3a4261 100644
--- a/README.md
+++ b/README.md
@@ -83,19 +83,23 @@ 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
-five `${execi}` entries, even though that block renders nothing under Cairo.
+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. Tying them to
+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:
@@ -104,6 +108,7 @@ To run a sampler by hand:
./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.
@@ -142,6 +147,43 @@ 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, start time left in the
+calendar's colour and title right in the body colour. 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.
+
+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.
+
+**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
diff --git a/bin/calendar-sample.sh b/bin/calendar-sample.sh
new file mode 100755
index 0000000..1937cf6
--- /dev/null
+++ b/bin/calendar-sample.sh
@@ -0,0 +1,76 @@
+#!/bin/bash
+# Sample khal's upcoming events into a cache file, for widgets/calendar.lua.
+#
+# Conky's Lua has no way to run a process, and shelling out to khal every two
+# seconds would fork a Python interpreter per draw. khal takes ~200ms to start,
+# which is two orders of magnitude more than the whole frame budget.
+
+set -u
+
+CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/udt"
+CACHE="$CACHE_DIR/calendar.txt"
+
+umask 077
+mkdir -p "$CACHE_DIR"
+
+TMP="$CACHE.tmp.$$"
+trap 'rm -f "$TMP"' EXIT
+
+# {start-date-long}, not {start-date}: the latter renders with khal's
+# `dateformat`, which here is '%d.%m.' with no year at all, so an event in
+# January sorts before one in December of the year before and the widget cannot
+# tell which is which. The long form uses `longdateformat` and carries the
+# year. Both are the user's own format strings and khal offers no ISO override
+# (a strftime spec inside the field, '{start-date:%Y-%m-%d}', raises), so the
+# widget parses the three numbers out by position rather than assuming an
+# order.
+#
+# --day-format '' suppresses the 'Today, 18.09.2026' header lines khal
+# interleaves between days; without it every group of events is preceded by a
+# line that does not parse as a record.
+#
+# Fields are pipe-separated: a title may contain anything except a newline, so
+# the separator has to be a character no date, time or calendar name uses, and
+# the parser splits on the first four only, leaving the title intact even if it
+# contains a pipe itself.
+#
+# 8d, not 7d: khal's range is exclusive of the final day in practice for
+# all-day events, and one extra day costs nothing but avoids a birthday
+# vanishing from the card on the morning it matters.
+EVENTS=$(khal list --day-format '' \
+ --format '{start-date-long}|{start-time}|{end-time}|{calendar}|{title}' \
+ --notstarted today 8d 2>/dev/null)
+
+# khal's exit status is 0 even with no calendars configured, so emptiness is
+# not distinguishable from failure by status alone. The widget treats a missing
+# 'generated' key as "the sampler never ran" and an empty event list as "no
+# events", which are genuinely different states and read differently on the
+# card.
+{
+ echo "generated $(date +%s)"
+ date +'today %Y-%m-%d'
+ # Calendar colours, as khal itself declares them. Parsed from khal's config
+ # rather than hardcoded, so adding a calendar does not mean editing Lua.
+ # Only the [[name]] / color = value pairs inside [calendars]; the section
+ # test stops at the next top-level [section] so a 'color' key elsewhere in
+ # the config cannot be misread as a calendar's.
+ awk '
+ /^\[calendars\]/ { in_cal = 1; next }
+ /^\[[^[]/ { in_cal = 0 }
+ in_cal && /^\[\[/ {
+ name = $0
+ gsub(/^\[\[|\]\]$/, "", name)
+ next
+ }
+ in_cal && /^[ \t]*color[ \t]*=/ {
+ sub(/^[^=]*=[ \t]*/, "")
+ if (name != "") print "color " name " " $0
+ }
+ ' "${XDG_CONFIG_HOME:-$HOME/.config}/khal/config" 2>/dev/null
+
+ if [ -n "$EVENTS" ]; then
+ printf '%s\n' "$EVENTS" | sed 's/^/event /'
+ fi
+} > "$TMP"
+
+mv -f "$TMP" "$CACHE"
diff --git a/conky.conf.in b/conky.conf.in
index 6128616..44caf4e 100644
--- a/conky.conf.in
+++ b/conky.conf.in
@@ -52,4 +52,4 @@ conky.config = {
-- producing no output. This is what runs the weather, disks and cache
-- samplers, and tying it to conky means nothing fetches while the dashboard is
-- down.
-conky.text = [[${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}]]
+conky.text = [[${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}]]
diff --git a/dashboard.lua b/dashboard.lua
index c1501f9..cb3b8b3 100644
--- a/dashboard.lua
+++ b/dashboard.lua
@@ -30,6 +30,8 @@ local layout = {
-- arrangement for the board yet.
{ widget = 'network', col = 14, row = 9, w = 3, h = 3 },
{ widget = 'slackware', col = 12, row = 10, w = 2, h = 3 },
+ -- Left of the disks row, in the board's open middle column.
+ { widget = 'calendar', col = 4, row = 1, w = 3, h = 6 },
}
-- ==========================================================================
diff --git a/lib/data.lua b/lib/data.lua
index 4803f5b..6f1f7ea 100644
--- a/lib/data.lua
+++ b/lib/data.lua
@@ -403,4 +403,119 @@ function M.iface_addr(iface)
return M.iface_addr_parse(out)
end
+-- The calendar cache written by bin/calendar-sample.sh.
+--
+-- Returns { today = { y, m, d }, colors = { <calendar> = <khal colour name> },
+-- events = { { y, m, d, start, finish, calendar, title }, ... } }, or nil when
+-- the cache is absent or carries no 'generated' key. nil means "the sampler
+-- never ran", which the card names; an empty events list means "nothing in the
+-- next week", which is a different and equally valid state.
+--
+-- Dates arrive in khal's own `longdateformat`, whatever the user set it to, so
+-- the three numbers are pulled out by position and assigned by magnitude: the
+-- 4-digit group is the year, and of the remaining two the one that cannot be a
+-- month is the day. An unambiguous pair (03.04.2026) is read as day-first,
+-- matching khal's default and the European formats its config ships. This is a
+-- heuristic, and it is the honest one available: khal will not emit ISO.
+function M.calendar_parse(text)
+ if type(text) ~= 'string' then return nil end
+
+ local out = { colors = {}, events = {} }
+ local generated = false
+
+ for line in text:gmatch('[^\n]+') do
+ local kind, rest = line:match('^(%S+)%s+(.*)$')
+ if kind == 'generated' then
+ generated = true
+ elseif kind == 'today' then
+ local y, m, d = rest:match('^(%d+)-(%d+)-(%d+)$')
+ if y then out.today = { tonumber(y), tonumber(m), tonumber(d) } end
+ elseif kind == 'color' then
+ local name, colour = rest:match('^(%S+)%s+(.*)$')
+ if name and colour ~= '' then out.colors[name] = colour end
+ elseif kind == 'event' then
+ -- Split on the first four pipes only: a title may contain one.
+ local date, st, en, cal, title =
+ rest:match('^([^|]*)|([^|]*)|([^|]*)|([^|]*)|(.*)$')
+ local ev = date and M.calendar_date(date)
+ if ev then
+ out.events[#out.events + 1] = {
+ y = ev[1], m = ev[2], d = ev[3],
+ start = st ~= '' and st or nil,
+ finish = en ~= '' and en or nil,
+ calendar = cal ~= '' and cal or nil,
+ title = title,
+ }
+ end
+ end
+ end
+
+ if not generated then return nil end
+ return out
+end
+
+-- Three numbers out of a formatted date, as { year, month, day }.
+-- See calendar_parse for why this is positional rather than a format string.
+function M.calendar_date(s)
+ if type(s) ~= 'string' then return nil end
+ local nums = {}
+ for n in s:gmatch('%d+') do nums[#nums + 1] = n end
+ if #nums < 3 then return nil end
+
+ -- The year is the 4-digit group. A 2-digit year is not handled: khal's
+ -- shipped formats all use %Y, and guessing a century from two digits would
+ -- be a second heuristic stacked on the first.
+ local yi
+ for i = 1, 3 do
+ if #nums[i] == 4 then yi = i break end
+ end
+ if not yi then return nil end
+
+ local rest = {}
+ for i = 1, 3 do
+ if i ~= yi then rest[#rest + 1] = tonumber(nums[i]) end
+ end
+ local a, b = rest[1], rest[2]
+
+ local day, month
+ if a > 12 then day, month = a, b
+ elseif b > 12 then month, day = a, b
+ else day, month = a, b end -- ambiguous: day-first, as khal's defaults are
+
+ if month < 1 or month > 12 or day < 1 or day > 31 then return nil end
+ return { tonumber(nums[yi]), month, day }
+end
+
+-- Which days of a given month carry an event, and whose calendar owns each.
+-- Returns { [day] = <calendar name> }. First event of a day wins, so a day
+-- with two calendars takes the earlier one rather than blending into a colour
+-- that means neither.
+function M.calendar_month_days(events, year, month)
+ local out = {}
+ if type(events) ~= 'table' then return out end
+ for _, e in ipairs(events) do
+ if e.y == year and e.m == month and not out[e.d] then
+ out[e.d] = e.calendar or true
+ end
+ end
+ return out
+end
+
+-- Days in a month, and the weekday its 1st falls on.
+--
+-- os.time/os.date rather than a leap-year rule: the C library already knows,
+-- and a hand-rolled rule is one more thing to get wrong in a century year.
+-- Normalised to Monday = 1 .. Sunday = 7, because khal's firstweekday = 0
+-- means Monday while os.date's wday means Sunday = 1.
+function M.month_shape(year, month)
+ local first = os.date('*t', os.time({ year = year, month = month, day = 1,
+ hour = 12 }))
+ -- Day 0 of the next month is the last day of this one, which os.time
+ -- normalises for us across the December boundary.
+ local last = os.date('*t', os.time({ year = year, month = month + 1, day = 0,
+ hour = 12 }))
+ return last.day, (first.wday + 5) % 7 + 1
+end
+
+
return M
diff --git a/test/fixtures/calendar_cache b/test/fixtures/calendar_cache
new file mode 100644
index 0000000..c31ab94
--- /dev/null
+++ b/test/fixtures/calendar_cache
@@ -0,0 +1,10 @@
+generated 1789700000
+today 2026-09-18
+color personal light green
+color birthdays light magenta
+color work light blue
+event 18.09.2026|13:00|13:30|personal|Dentist
+event 18.09.2026|||birthdays|A birthday
+event 24.09.2026|14:30|15:30|work|Team sync | notes
+event 26.09.2026|||birthdays|Another birthday
+event 02.10.2026|09:00|10:00|work|Review
diff --git a/test/test_data.lua b/test/test_data.lua
index 34021c5..00616d6 100644
--- a/test/test_data.lua
+++ b/test/test_data.lua
@@ -322,4 +322,65 @@ assert(data.iface_addr_parse('5: br0: <BROADCAST,MULTICAST> mtu 1500 state DOWN\
assert(data.iface_addr_parse('') == nil, 'empty gives nil')
assert(data.iface_addr_parse(nil) == nil, 'nil gives nil')
+-- === Calendar =============================================================
+-- The fixture uses generic event titles. This repository is public: a real
+-- appointment names a real person or a real appointment, and neither belongs
+-- in a committed file.
+local cal = data.calendar_parse(read('test/fixtures/calendar_cache'))
+assert(cal, 'calendar cache parses')
+assert(cal.today[1] == 2026 and cal.today[2] == 9 and cal.today[3] == 18,
+ 'today parses')
+assert(cal.colors.personal == 'light green', 'calendar colour, got '
+ .. tostring(cal.colors.personal))
+assert(#cal.events == 5, 'event count, got ' .. tostring(#cal.events))
+
+-- A timed event keeps its times; an all-day one has neither, and the card
+-- shows 'all day' rather than an invented 00:00.
+assert(cal.events[1].start == '13:00' and cal.events[1].finish == '13:30',
+ 'timed event keeps its times')
+assert(cal.events[2].start == nil and cal.events[2].finish == nil,
+ 'all-day event has no times')
+
+-- A title containing a pipe survives: only the first four separators split.
+assert(cal.events[3].title == 'Team sync | notes',
+ 'title keeps its pipe, got ' .. tostring(cal.events[3].title))
+
+-- No 'generated' key means the sampler never ran, which is not the same as
+-- having run and found nothing.
+assert(data.calendar_parse('today 2026-09-18\n') == nil, 'no generated key: nil')
+assert(data.calendar_parse('') == nil, 'empty cache: nil')
+assert(data.calendar_parse(nil) == nil, 'nil cache: nil')
+local empty = data.calendar_parse('generated 1\ntoday 2026-09-18\n')
+assert(empty and #empty.events == 0, 'ran with no events: empty list, not nil')
+
+-- calendar_date: the year is the 4-digit group wherever it sits, and the
+-- day/month pair is resolved by magnitude.
+local function ymd(s)
+ local t = data.calendar_date(s)
+ return t and table.concat(t, '-') or nil
+end
+assert(ymd('18.09.2026') == '2026-9-18', 'day-first, got ' .. tostring(ymd('18.09.2026')))
+assert(ymd('09/18/2026') == '2026-9-18', 'month-first resolved by magnitude')
+assert(ymd('2026-09-18') == '2026-9-18', 'ISO, year leading')
+-- Genuinely ambiguous: both below 13. Day-first, matching khal's defaults.
+assert(ymd('03.04.2026') == '2026-4-3', 'ambiguous reads day-first')
+assert(ymd('18.09.26') == nil, 'two-digit year is refused, not guessed')
+assert(ymd('nonsense') == nil, 'unparseable date: nil')
+assert(ymd('18.13.2026') == nil, 'impossible month: nil')
+
+-- month_days: first calendar of a day wins, and only the asked-for month.
+local days = data.calendar_month_days(cal.events, 2026, 9)
+assert(days[18] == 'personal', 'first event of the day owns it, got '
+ .. tostring(days[18]))
+assert(days[24] == 'work' and days[26] == 'birthdays', 'later days marked')
+assert(days[2] == nil, 'October event does not mark September')
+
+-- month_shape: September 2026 has 30 days and starts on a Tuesday.
+-- Monday = 1, so Tuesday = 2.
+local ndays, first = data.month_shape(2026, 9)
+assert(ndays == 30 and first == 2, 'september 2026, got ' .. ndays .. '/' .. first)
+-- February in a leap year, and a December that must roll into January.
+assert((data.month_shape(2024, 2)) == 29, 'leap february has 29 days')
+assert((data.month_shape(2026, 12)) == 31, 'december rolls over correctly')
+
print('test_data: all assertions passed')
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