-- 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 = { 'L', 'M', 'M', 'G', 'V', 'S', 'D' } 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' on 24 September at 13:00 -> '24/09 13:00', an all-day event -> -- '26/09 all day'. Never '00:00' for an all-day event: midnight is a -- legitimate start time and the two must not read the same. -- -- Day/month, no year: the grid above already names the month, and the window -- is a week, so a year would be three characters of the row spent on the one -- field that cannot vary. local function when(e) return string.format('%02d/%02d %s', e.d, e.m, e.start or 'all day') end -- The legend: one coloured dot and name per calendar, along the card's bottom. -- Wraps to a second line when the names do not fit, and drops any that still -- do not: a legend spilling off the card is worse than a short one, since the -- colours are also readable from the grid above. local function draw_legend(cr, inner, y, size, names, colors, khal_colors) card.font(cr, card.FONT_MONO, size, false) local r = size * 0.30 local x = inner.x local right = inner.x + inner.w for _, name in ipairs(names) do local w = r * 2 + size * 0.45 + card.advance(cr, name) if x + w > right then -- Next line, if the card has one to give. x = inner.x y = y + size * 1.5 if y > inner.y + inner.h then return end end card.rgba(cr, cal_colour(name, colors, khal_colors)) cairo_new_path(cr) cairo_arc(cr, x + r, y - size * 0.32, r, 0, math.pi * 2) cairo_fill(cr) card.rgba(cr, colors.label, 0.85) card.text(cr, x + r * 2 + size * 0.45, y, name) x = x + w + size * 0.9 end 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 card.notice(cr, inner, colors, 'CALENDAR', '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.title(cr, inner.x, inner.y + label_size, card.ICON.calendar, 'CALENDAR', colors) -- === Month grid ========================================================= -- 2.3, not 1.5: the weekday row sat close enough to the header that the -- initials read as part of it rather than as the top of the grid. local gy = inner.y + big_size * 2.3 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 ev_size = clamp(day_size * 0.78, 7, 18) local ev_step = ev_size * 1.75 -- The legend is pinned to the card's bottom, so the event rows end above it -- rather than running under it. Only calendars that khal declared a colour -- for appear, in the config's own order, which is why the names are sorted -- rather than taken from a hash walk: pairs() would reorder the legend -- between frames. local legend = {} for name in pairs(cal.colors) do legend[#legend + 1] = name end table.sort(legend) local legend_size = ev_size * 0.82 local legend_h = #legend > 0 and legend_size * 2.0 or 0 local floor_y = inner.y + inner.h - legend_h local avail = floor_y - grid_bottom if avail < ev_step then legend = {}; legend_h = 0; floor_y = inner.y + inner.h end if (floor_y - grid_bottom) < 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') if #legend > 0 then draw_legend(cr, inner, inner.y + inner.h - legend_size * 0.5, legend_size, legend, colors, cal.colors) end 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. -- -- Counted from the same test the drawing loop uses (a row at `ey` fits when -- ey <= floor_y), not an independent division: the two disagreeing by one -- row is what made the overflow line vanish exactly when it was needed, -- since the loop cut the row the count had already spent. local room = 0 local probe = ey while probe <= floor_y do room = room + 1; probe = probe + ev_step end local shown = #cal.events -- The overflow line costs a row, so it is only worth taking when it reports -- more than the row it displaces. if room < shown then shown = (room >= 2) and (room - 1) or room end local hidden = #cal.events - shown for i = 1, shown do local e = cal.events[i] if ey > floor_y 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 <= floor_y 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 if #legend > 0 then draw_legend(cr, inner, inner.y + inner.h - legend_size * 0.5, legend_size, legend, colors, cal.colors) end end return M