aboutsummaryrefslogtreecommitdiffstats
path: root/widgets/calendar.lua
blob: 82e10d347d28b573a052d65ddbc52fd61a0a0aa5 (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
-- 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