diff options
Diffstat (limited to 'widgets/immich.lua')
| -rw-r--r-- | widgets/immich.lua | 164 |
1 files changed, 164 insertions, 0 deletions
diff --git a/widgets/immich.lua b/widgets/immich.lua new file mode 100644 index 0000000..17e0b02 --- /dev/null +++ b/widgets/immich.lua @@ -0,0 +1,164 @@ +-- Immich: today's "N years ago" memories, one photo at a time. +-- +-- Everything comes from the cache bin/immich-fetch.sh writes: the photos as +-- PNGs (Cairo loads nothing else) and an index with the year, when each was +-- taken and, when the API key may read it, where. The card rotates through +-- them on the wall clock, so it needs no state beyond the loaded surface. + +local card = require 'lib.card' +local data = require 'lib.data' + +local DIR = (os.getenv('XDG_CACHE_HOME') or (os.getenv('HOME') .. '/.cache')) + .. '/udt/immich/' + +local ROTATE = 30 -- seconds per photo + +-- Photos stepped by scrolling, added to the wall-clock rotation. Kept across +-- draws, and read by the click handler to open the photo on screen. +local offset = 0 +local shown = nil -- { url, id } of the photo drawn last + +local M = {} + +-- The loaded photo. One surface held at a time: decoding a PNG every 2s draw +-- would be wasted work, and holding every photo would be wasted memory. +local loaded = { path = nil, surf = nil } + +local function surface(path) + if loaded.path == path then return loaded.surf end + if loaded.surf then cairo_surface_destroy(loaded.surf) end + local s = cairo_image_surface_create_from_png(path) + if cairo_surface_status(s) ~= CAIRO_STATUS_SUCCESS then + cairo_surface_destroy(s) + s = nil + end + loaded.path, loaded.surf = path, s + return s +end + +-- The photo scaled to cover the band and clipped to its rounded rect, the way +-- a cropped thumbnail reads: no letterbox bars for a portrait photo. +local function draw_photo(cr, s, x, y, w, h, colors) + card.rounded_path(cr, x, y, w, h, 10) + if not s then + card.rgba(cr, colors.rule, 0.3) + cairo_fill(cr) + return + end + cairo_save(cr) + cairo_clip(cr) + local iw, ih = cairo_image_surface_get_width(s), cairo_image_surface_get_height(s) + local k = math.max(w / iw, h / ih) + cairo_translate(cr, x + (w - iw * k) / 2, y + (h - ih * k) / 2) + cairo_scale(cr, k, k) + cairo_set_source_surface(cr, s, 0, 0) + cairo_paint(cr) + cairo_restore(cr) +end + +function M.draw(cr, rect, colors) + local inner = card.card(cr, rect, colors) + local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end + local LABEL_F, BIG_F, ROW_F, SUB_F = 0.30, 1.0, 0.32, 0.26 + + local photos = data.immich_parse(data.slurp(DIR .. 'index.txt') or '') + if not photos then + card.notice(cr, inner, colors, 'MEMORIES', 'no immich data', + 'bin/immich-fetch.sh') + return + end + if #photos == 0 then + card.notice(cr, inner, colors, 'MEMORIES', 'no memories today', + os.date('%d/%m')) + return + end + + local n = #photos + local i = (os.time() // ROTATE + offset) % n + 1 + local p = photos[i] + shown = { url = photos.url, id = p.id } + + local ago = p.year and (tonumber(os.date('%Y')) - p.year) or nil + local big = ago and tostring(ago) or '--' + local sub = (ago == 1 and 'year ago' or 'years ago') + .. (n > 1 and string.format(' %d/%d', i, n) or '') + local t = p.taken + local taken = t and string.format('%02d/%02d/%04d %02d:%02d', + t[3], t[2], t[1], t[4], t[5]) or '--' + + -- Fluid type, sized so header and rows take at most ~40% of the card and + -- the photo absorbs the rest. The place is left out of the fit: its length + -- varies per photo, so it is truncated to the width instead of shrinking + -- the whole card's type every time a long place name comes round. + local groups = { + { { card.title_str(card.ICON.immich, 'MEMORIES'), card.FONT_MONO, LABEL_F }, + { big, card.FONT_HEAVY, BIG_F } }, + { { sub, card.FONT_UI, SUB_F } }, + { { 'TAKEN ', card.FONT_MONO, ROW_F }, { taken, card.FONT_MONO, ROW_F } }, + } + local S = clamp(math.min(inner.h * 0.40 / (1.5 + 2 * 0.66), + card.fit_unit(cr, inner.w * 0.96, groups, 100)), 10, 72) + local label_size, row_size = S * LABEL_F, S * ROW_F + + card.font(cr, card.FONT_HEAVY, S * BIG_F, false) + card.rgba(cr, colors.body) + card.text_right(cr, inner.x + inner.w, inner.y + S * BIG_F, big) + + card.font(cr, card.FONT_MONO, label_size, false) + card.title(cr, inner.x, inner.y + label_size, card.ICON.immich, 'MEMORIES', colors) + + card.font(cr, card.FONT_UI, S * SUB_F, false) + card.rgba(cr, colors.label, 0.65) + card.text(cr, inner.x, inner.y + label_size + S * SUB_F * 1.5, sub) + + -- Two rows pinned to the bottom; the photo takes what lies between. + local step = row_size * 2.0 + local rows_top = inner.y + inner.h - step * 2 + row_size * 0.4 + local band_y = inner.y + S * BIG_F + row_size + draw_photo(cr, surface(DIR .. p.id .. '.png'), inner.x, band_y, inner.w, + rows_top - band_y - row_size * 0.4, colors) + + card.font(cr, card.FONT_MONO, row_size, false) + local label_w = card.advance(cr, 'PLACE ') + local place = p.place or '--' + -- Cut by characters until it fits beside its label; truncate() keeps UTF-8 + -- intact, which a place name with an accent needs. + local limit = utf8.len(place) or #place + while limit > 1 and (card.measure(cr, card.truncate(place, limit))) + > inner.w - label_w do + limit = limit - 1 + end + place = card.truncate(place, limit) + + local ey = rows_top + row_size + for _, r in ipairs({ { 'TAKEN', taken }, { 'PLACE', place } }) do + card.rgba(cr, colors.label) + card.text(cr, inner.x, ey, r[1]) + card.rgba(cr, colors.value) + card.text_right(cr, inner.x + inner.w, ey, r[2]) + ey = ey + step + end +end + +-- Scroll steps through today's photos; a left click opens the one on screen +-- in Immich and hides the dashboard, since the browser would otherwise open +-- underneath the special workspace. +-- +-- ponytail: a scroll shows at the next draw, up to update_interval (2s) later. +-- Conky offers Lua no way to request a redraw. +function M.mouse(event) + if event.type == 'mouse_scroll' then + offset = offset + (event.direction == 'up' and -1 or 1) + return true + end + if event.type == 'button_down' and event.button == 'left' + and shown and shown.url then + local url = shown.url .. '/photos/' .. shown.id + os.execute("xdg-open '" .. url:gsub("'", "'\\''") .. "' >/dev/null 2>&1 &") + os.execute([[hyprctl dispatch 'hl.dsp.workspace.toggle_special("dash")' >/dev/null 2>&1 &]]) + return true + end + return false +end + +return M |
