aboutsummaryrefslogtreecommitdiffstats
path: root/widgets/calendar.lua
blob: da173c9113f02574f696f738b0dc616835dbc9e7 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
-- 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' 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