-- Weather: current conditions over a sunrise-to-sunset arc. -- -- Shape follows idea1.png, which splits this across two cards; this merges -- them into one vertical cell. Every colour comes from the palette, every size -- derives from the rect, so the card composes on either monitor. local card = require 'lib.card' local weather = require 'lib.weather' local M = {} local CACHE = (os.getenv('XDG_CACHE_HOME') or (os.getenv('HOME') .. '/.cache')) .. '/udt/weather.json' -- Draw the card chrome with a message in it. Used for every no-data state, so -- the dashboard keeps its shape instead of showing an empty cell, which is -- indistinguishable from a crashed widget. local function draw_notice(cr, inner, colors, line1, line2) card.font(cr, card.FONT_UI, 15, true) card.rgba(cr, colors.label) card.text(cr, inner.x, inner.y + 24, line1) card.font(cr, card.FONT_MONO, 12, false) card.text(cr, inner.x, inner.y + 48, line2) end -- The daylight arc: a curve, its baseline, the sun, and the two times. -- -- The dot is placed by evaluating the curve at t rather than by computing a -- point on a circle: the curve IS the path, so evaluating it keeps the dot on -- the arc if the control points are ever adjusted. local function draw_arc(cr, x, y, w, h, colors, w_data, now) local pad = 4 local x0, x1 = x + pad, x + w - pad -- Room under the curve for the time labels. local base = y + h - 20 local top = y + 6 -- Cubic Bezier control points, chosen so the curve peaks near the middle at -- about two thirds of the band height. local p0 = { x0, base } local c1 = { x0 + (x1 - x0) * 0.22, top - 10 } local c2 = { x1 - (x1 - x0) * 0.22, top - 10 } local p3 = { x1, base } -- The baseline: the horizon the sun rises from and sets into. card.rgba(cr, colors.rule, 0.6) cairo_set_line_width(cr, 1) cairo_move_to(cr, x0, base) cairo_line_to(cr, x1, base) cairo_stroke(cr) -- The arc itself. card.rgba(cr, colors.rule, 0.9) cairo_set_line_width(cr, 1.5) cairo_move_to(cr, p0[1], p0[2]) cairo_curve_to(cr, c1[1], c1[2], c2[1], c2[2], p3[1], p3[2]) cairo_stroke(cr) local t = weather.sun_t(now, w_data.sunrise, w_data.sunset) or 0 local day = weather.is_day(now, w_data.sunrise, w_data.sunset) -- Evaluate the cubic at t. local function bez(a, b, c, d) local u = 1 - t return u * u * u * a + 3 * u * u * t * b + 3 * u * t * t * c + t * t * t * d end local sx = bez(p0[1], c1[1], c2[1], p3[1]) local sy = bez(p0[2], c1[2], c2[2], p3[2]) -- At night the dot is parked at an end, so it takes the dim colour: a bright -- dot sitting on the horizon would read as a sun that is still up. card.rgba(cr, day and colors.body or colors.label, 1) cairo_arc(cr, sx, sy, 5, 0, math.pi * 2) cairo_fill(cr) -- The times, each behind its own glyph, at the ends of the arc. card.font(cr, card.FONT_MONO, 12, false) card.rgba(cr, colors.label) local ty = y + h - 2 card.text(cr, x0, ty, weather.ICON.sunrise .. ' ' .. os.date('%H:%M', w_data.sunrise)) card.text_right(cr, x1, ty, weather.ICON.sunset .. ' ' .. os.date('%H:%M', w_data.sunset)) end function M.draw(cr, rect, colors) local inner = card.card(cr, rect, colors) local now = os.time() local src = nil local f = io.open(CACHE, 'r') if f then src = f:read('*a'); f:close() end local w = weather.parse(src) if not w then -- Closed explicitly: an unclosed handle here would accumulate one per -- draw (every update_interval), since this whole branch reruns each -- frame until a fetch succeeds. local envf = io.open(os.getenv('HOME') .. '/.config/udt/weather.env', 'r') if envf then envf:close() draw_notice(cr, inner, colors, 'no weather data', 'waiting for first fetch') else draw_notice(cr, inner, colors, 'no weather data', 'set ~/.config/udt/weather.env') end return end -- The card is much taller than idea1's, so the bands are placed against the -- rect rather than at fixed offsets: content sits in a block at the top, the -- arc gets a bounded band pinned to the bottom, and the slack falls between -- them. Letting the arc take all the leftover height instead drew a 794px -- cathedral arch on this monitor, with the content crammed into the top -- fifth. Scaled, not fixed, so the 1920 monitor gets the same proportions. local y = inner.y -- Type sizes scale with the card, within limits that keep them legible on a -- small cell and stop them ballooning on a tall one. -- Sizes scale off the WIDTH, not the height: the widget sits in a 1x1 cell -- whose height changes with the grid, and scaling off height made the type -- shrink to a whisper beside the clock's numerals when the cell got shorter. -- Width is the stable dimension here, and it is what constrains the text -- anyway, since the stat rows run edge to edge. local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end local temp_size = clamp(inner.w * 0.135, 40, 84) local glyph_size = clamp(temp_size * 0.95, 38, 78) local label_size = clamp(inner.w * 0.030, 12, 19) local desc_size = clamp(inner.w * 0.038, 15, 24) local row_size = clamp(inner.w * 0.032, 13, 20) local row_step = row_size * 2.0 -- Header, mirrored to match the other cards: the condition glyph large on -- the left, the big temperature and the rest of the text against the right -- edge. text_right takes the RIGHT edge as its x, and measures ink rather -- than advance, which is what you want for aligning to an edge. local glyph = weather.icon(w.id, now, w.sunrise, w.sunset) card.font(cr, card.FONT_MONO, glyph_size, false) card.rgba(cr, colors.highlight) card.text(cr, inner.x, y + glyph_size * 0.95, glyph) card.font(cr, card.FONT_HEAVY, temp_size, false) card.rgba(cr, colors.body) card.text_right(cr, inner.x + inner.w, y + temp_size * 0.86, string.format('%d\u{00B0}', math.floor(w.temp + 0.5))) card.font(cr, card.FONT_MONO, label_size, false) card.rgba(cr, colors.label) local city = (w.city or ''):upper() if weather.is_stale(w.dt, now) then city = city .. ' stale ' .. weather.age_str(w.dt, now) end card.text_right(cr, inner.x + inner.w, y + temp_size * 1.28, city) -- Condition, in OWM's own words, first letter capitalised. card.font(cr, card.FONT_UI, desc_size, false) card.rgba(cr, colors.body) local desc = w.description or '' desc = desc:sub(1, 1):upper() .. desc:sub(2) local dy = y + temp_size * 1.95 card.text_right(cr, inner.x + inner.w, dy, desc) -- Rule. local ry = dy + desc_size * 1.4 card.rgba(cr, colors.rule, 0.8) cairo_set_line_width(cr, 1) cairo_move_to(cr, inner.x, ry) cairo_line_to(cr, inner.x + inner.w, ry) cairo_stroke(cr) -- Stats: label left, value right. local kmh = weather.kmh(w.wind_speed) local wind_txt = kmh and string.format('%s %d km/h', weather.arrow(w.wind_deg), math.floor(kmh + 0.5)) or '--' local rows = { { 'FEELS LIKE', w.feels_like and string.format('%d\u{00B0}', math.floor(w.feels_like + 0.5)) or '--' }, { 'HUMIDITY', w.humidity and (w.humidity .. '%') or '--' }, { 'WIND', wind_txt }, } local sy = ry + row_step for _, row in ipairs(rows) do card.font(cr, card.FONT_MONO, row_size, false) card.rgba(cr, colors.label) card.text(cr, inner.x, sy, row[1]) card.rgba(cr, colors.value) card.text_right(cr, inner.x + inner.w, sy, row[2]) sy = sy + row_step end -- The arc takes what is genuinely left below the stats, with no minimum: a -- floor here meant a short cell pulled the curve up THROUGH the stat rows, -- drawing the sunrise time over the word WIND. Below the height where it -- can be read it is dropped entirely, because no arc beats a broken one. -- This is what lets one widget sit in a 2x2 or a 2x3 cell unchanged. local bottom = inner.y + inner.h local arc_y = sy + row_step * 0.6 local arc_h = math.min(bottom - arc_y, inner.h * 0.34, 240) if arc_h >= 70 then draw_arc(cr, inner.x, arc_y, inner.w, arc_h, colors, w, now) end end return M