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 | |
| 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>
| -rw-r--r-- | README.md | 46 | ||||
| -rwxr-xr-x | bin/calendar-sample.sh | 76 | ||||
| -rw-r--r-- | conky.conf.in | 2 | ||||
| -rw-r--r-- | dashboard.lua | 2 | ||||
| -rw-r--r-- | lib/data.lua | 115 | ||||
| -rw-r--r-- | test/fixtures/calendar_cache | 10 | ||||
| -rw-r--r-- | test/test_data.lua | 61 | ||||
| -rw-r--r-- | widgets/calendar.lua | 247 |
8 files changed, 556 insertions, 3 deletions
@@ -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 |
