From 7cf2f9dc6d6d52515af8130907af2bf98bf6d67c Mon Sep 17 00:00:00 2001 From: predator Date: Tue, 22 Sep 2026 20:29:22 -0500 Subject: [PATCH 1/2] Draw an animated sky behind the weather popup The weather popup gets an animated pixel-art scene behind the forecast that follows the current conditions: a sun with rays, glow, a light beam and bokeh; a haloed crescent with twinkling stars and a shooting star at night; partly cloudy, overcast and fog; rain, snow and sleet at three intensities; thunderstorms whose lightning lights up the clouds, with hail for WMO 96/99; and wind streaks from 30 km/h. The scene comes from the Open-Meteo weather code and day flag the widget already fetches, through a table of named WMO codes in Model.js, and falls back to matching the bar glyph against iconForCode when only wttr.in data is present. Drawing is kept cheap: a static canvas repainted only on open or scene change, cloud and fog strips painted once from horizontally periodic noise and slid sideways, and a 30 Hz canvas for the parts that move, which stays under 2 ms a frame and stops when the popup closes. Colours are the theme accent and background plus an ink that is white on dark themes and the theme foreground on light ones. A boolean "fx" widget setting, shown as "Animated weather effects", turns it off. Co-Authored-By: Claude Opus 5.5 (1M context) --- shell/plugins/panels/weather/Model.js | 108 +++++- shell/plugins/panels/weather/Panel.qml | 412 +++++++++++++++++++++ shell/plugins/panels/weather/manifest.json | 14 +- test/shell.d/weather-test.sh | 69 ++++ 4 files changed, 601 insertions(+), 2 deletions(-) diff --git a/shell/plugins/panels/weather/Model.js b/shell/plugins/panels/weather/Model.js index 6562f83d0ac..5a1fb2156a7 100644 --- a/shell/plugins/panels/weather/Model.js +++ b/shell/plugins/panels/weather/Model.js @@ -265,6 +265,105 @@ function iconForCode(code, night) { } } +// ---- Sky scenes for the panel's animated background. +// A scene name plus night/intensity/hail/wind modifiers, resolved from the +// Open-Meteo WMO weather code and day flag when present, else from the +// resolved bar glyph. +var SKY_SCENES = ["sun", "partly", "clouds", "fog", "rain", "storm", "snow", "sleet"] + +var SKY_LEVEL = { LIGHT: 0, MODERATE: 1, HEAVY: 2 } +var WINDY_KMPH = 30 + +// WMO weather interpretation codes, as Open-Meteo reports them. +var WMO = { + CLEAR: 0, MAINLY_CLEAR: 1, PARTLY_CLOUDY: 2, OVERCAST: 3, + FOG: 45, RIME_FOG: 48, + DRIZZLE_LIGHT: 51, DRIZZLE_MODERATE: 53, DRIZZLE_DENSE: 55, + FREEZING_DRIZZLE_LIGHT: 56, FREEZING_DRIZZLE_DENSE: 57, + RAIN_SLIGHT: 61, RAIN_MODERATE: 63, RAIN_HEAVY: 65, + FREEZING_RAIN_LIGHT: 66, FREEZING_RAIN_HEAVY: 67, + SNOW_SLIGHT: 71, SNOW_MODERATE: 73, SNOW_HEAVY: 75, SNOW_GRAINS: 77, + RAIN_SHOWERS_SLIGHT: 80, RAIN_SHOWERS_MODERATE: 81, RAIN_SHOWERS_VIOLENT: 82, + SNOW_SHOWERS_SLIGHT: 85, SNOW_SHOWERS_HEAVY: 86, + THUNDERSTORM: 95, THUNDERSTORM_HAIL_SLIGHT: 96, THUNDERSTORM_HAIL_HEAVY: 99 +} + +function skyEntry(scene, level, hail) { return { scene: scene, level: level, hail: hail === true } } + +var SKY_BY_WMO = {} +SKY_BY_WMO[WMO.CLEAR] = skyEntry("sun", SKY_LEVEL.MODERATE) +SKY_BY_WMO[WMO.MAINLY_CLEAR] = skyEntry("partly", SKY_LEVEL.MODERATE) +SKY_BY_WMO[WMO.PARTLY_CLOUDY] = skyEntry("partly", SKY_LEVEL.MODERATE) +SKY_BY_WMO[WMO.OVERCAST] = skyEntry("clouds", SKY_LEVEL.MODERATE) +SKY_BY_WMO[WMO.FOG] = skyEntry("fog", SKY_LEVEL.MODERATE) +SKY_BY_WMO[WMO.RIME_FOG] = skyEntry("fog", SKY_LEVEL.MODERATE) +SKY_BY_WMO[WMO.DRIZZLE_LIGHT] = skyEntry("rain", SKY_LEVEL.LIGHT) +SKY_BY_WMO[WMO.DRIZZLE_MODERATE] = skyEntry("rain", SKY_LEVEL.LIGHT) +SKY_BY_WMO[WMO.DRIZZLE_DENSE] = skyEntry("rain", SKY_LEVEL.LIGHT) +SKY_BY_WMO[WMO.FREEZING_DRIZZLE_LIGHT] = skyEntry("sleet", SKY_LEVEL.LIGHT) +SKY_BY_WMO[WMO.FREEZING_DRIZZLE_DENSE] = skyEntry("sleet", SKY_LEVEL.LIGHT) +SKY_BY_WMO[WMO.RAIN_SLIGHT] = skyEntry("rain", SKY_LEVEL.LIGHT) +SKY_BY_WMO[WMO.RAIN_MODERATE] = skyEntry("rain", SKY_LEVEL.MODERATE) +SKY_BY_WMO[WMO.RAIN_HEAVY] = skyEntry("rain", SKY_LEVEL.HEAVY) +SKY_BY_WMO[WMO.FREEZING_RAIN_LIGHT] = skyEntry("sleet", SKY_LEVEL.MODERATE) +SKY_BY_WMO[WMO.FREEZING_RAIN_HEAVY] = skyEntry("sleet", SKY_LEVEL.HEAVY) +SKY_BY_WMO[WMO.SNOW_SLIGHT] = skyEntry("snow", SKY_LEVEL.LIGHT) +SKY_BY_WMO[WMO.SNOW_MODERATE] = skyEntry("snow", SKY_LEVEL.MODERATE) +SKY_BY_WMO[WMO.SNOW_HEAVY] = skyEntry("snow", SKY_LEVEL.HEAVY) +SKY_BY_WMO[WMO.SNOW_GRAINS] = skyEntry("snow", SKY_LEVEL.LIGHT) +SKY_BY_WMO[WMO.RAIN_SHOWERS_SLIGHT] = skyEntry("rain", SKY_LEVEL.LIGHT) +SKY_BY_WMO[WMO.RAIN_SHOWERS_MODERATE] = skyEntry("rain", SKY_LEVEL.MODERATE) +SKY_BY_WMO[WMO.RAIN_SHOWERS_VIOLENT] = skyEntry("rain", SKY_LEVEL.HEAVY) +SKY_BY_WMO[WMO.SNOW_SHOWERS_SLIGHT] = skyEntry("snow", SKY_LEVEL.LIGHT) +SKY_BY_WMO[WMO.SNOW_SHOWERS_HEAVY] = skyEntry("snow", SKY_LEVEL.HEAVY) +SKY_BY_WMO[WMO.THUNDERSTORM] = skyEntry("storm", SKY_LEVEL.MODERATE) +SKY_BY_WMO[WMO.THUNDERSTORM_HAIL_SLIGHT] = skyEntry("storm", SKY_LEVEL.HEAVY, true) +SKY_BY_WMO[WMO.THUNDERSTORM_HAIL_HEAVY] = skyEntry("storm", SKY_LEVEL.HEAVY, true) + +// wttr.in condition codes, one per bar glyph, for the glyph-only fallback. +var WTTR = { + SUNNY: 113, PARTLY_CLOUDY: 116, CLOUDY: 119, MIST: 143, + PATCHY_RAIN: 176, PATCHY_SNOW: 179, PATCHY_SLEET: 182, + LIGHT_DRIZZLE: 266, HEAVY_SNOW: 338, THUNDERY_RAIN: 389 +} +var SKY_BY_WTTR = [ + [WTTR.SUNNY, "sun"], [WTTR.PARTLY_CLOUDY, "partly"], [WTTR.CLOUDY, "clouds"], + [WTTR.MIST, "fog"], [WTTR.PATCHY_RAIN, "rain"], [WTTR.LIGHT_DRIZZLE, "rain"], + [WTTR.THUNDERY_RAIN, "storm"], [WTTR.PATCHY_SNOW, "snow"], [WTTR.HEAVY_SNOW, "snow"], + [WTTR.PATCHY_SLEET, "sleet"] +] + +// Scene and night flag for a bar glyph, by matching it against the glyphs +// iconForCode draws for each wttr.in code. +function skySceneForGlyph(glyph) { + for (var n = 0; n < SKY_BY_WTTR.length; n++) { + var day = iconForCode(SKY_BY_WTTR[n][0], false), night = iconForCode(SKY_BY_WTTR[n][0], true) + if (glyph === day || glyph === night) return { scene: SKY_BY_WTTR[n][1], night: glyph === night && glyph !== day } + } + return { scene: "off", night: false } +} + +function resolveSkyScene(current, glyph) { + var windK = current ? parseFloat(current.windspeedKmph) : NaN + var fromGlyph = skySceneForGlyph(glyph) + var r = { scene: fromGlyph.scene, night: fromGlyph.night, level: SKY_LEVEL.MODERATE, hail: false, + windy: isFinite(windK) && windK >= WINDY_KMPH } + if (current && current.isDay !== undefined && current.isDay !== null) r.night = Number(current.isDay) === 0 + var code = current && current.openMeteoWeatherCode !== undefined && current.openMeteoWeatherCode !== null + ? parseInt(String(current.openMeteoWeatherCode), 10) : NaN + if (isNaN(code)) return r + var entry = SKY_BY_WMO[code] || skyEntry("clouds", SKY_LEVEL.MODERATE) + r.scene = entry.scene; r.level = entry.level; r.hail = entry.hail + return r +} + +// The scene actually drawn: the base scene with night applied. +function skyMode(base, night) { + if (base === "sun" && night) return "moon" + if (base === "partly" && night) return "partly-night" + return base +} + if (typeof module !== "undefined") { module.exports = { parseLocationFile: parseLocationFile, @@ -290,6 +389,13 @@ if (typeof module !== "undefined") { bareTempForDay: bareTempForDay, dayIcon: dayIcon, iconForOpenMeteoCode: iconForOpenMeteoCode, - iconForCode: iconForCode + iconForCode: iconForCode, + SKY_SCENES: SKY_SCENES, + SKY_LEVEL: SKY_LEVEL, + WMO: WMO, + SKY_BY_WMO: SKY_BY_WMO, + skySceneForGlyph: skySceneForGlyph, + resolveSkyScene: resolveSkyScene, + skyMode: skyMode } } diff --git a/shell/plugins/panels/weather/Panel.qml b/shell/plugins/panels/weather/Panel.qml index d9d43b684b7..c6b5283e8ec 100644 --- a/shell/plugins/panels/weather/Panel.qml +++ b/shell/plugins/panels/weather/Panel.qml @@ -149,6 +149,18 @@ Panel { readonly property string reportWind: current ? (useImperial ? (current.windspeedMiles + " mph") : (current.windspeedKmph + " km/h")) : "" readonly property string reportHumidity: current ? (current.humidity + "%") : "" + // ---- Sky scene drawn behind the panel content (see the skyFx item below). + // It follows the Open-Meteo weather code and day flag when they are + // present, else the bar glyph. The "fx" widget setting turns it off. + readonly property bool fxEnabled: setting("fx", true) !== false + readonly property var fxResolved: Model.resolveSkyScene(current, label) + readonly property bool fxNight: fxResolved.night + readonly property int fxLevel: fxResolved.level + readonly property bool fxHail: fxResolved.hail + readonly property bool fxWindy: fxResolved.windy + readonly property string fxMode: fxEnabled ? Model.skyMode(fxResolved.scene, fxNight) : "off" + onOpenedChanged: if (opened) skyFx.replay() + function refresh() { // Each full refresh cycle gets a fresh retry budget, so an earlier // exhausted round (e.g. waking with the network still down) doesn't @@ -505,6 +517,406 @@ Panel { onCloseRequested: root.close() onTabRequested: function(direction) { root.switchPanel(direction) } + // ---- Pixel-art sky. Two canvases on a 2px cell grid behind the content: + // `stat` holds what only changes on open (glow, sun/moon body, haze), + // `dyn` holds motion (rays, bokeh, clouds, drops, flakes, wind, bolts) + // and repaints at 30 Hz while the popup is open. All geometry below + // is in cells. Palette is theme accent, theme background and ink + // (white on dark themes, the theme foreground on light ones). + Item { + id: skyFx + anchors.fill: parent + anchors.margins: -panel.padding + clip: true + visible: root.fxMode !== "off" + z: 0 + + property real t: 0 // 0 → 1 while the panel opens + property int tick: 0 // 30 Hz clock since the panel opened; drives all motion + readonly property int cell: 2 + // Overall strength of the effect; the hero text has to stay readable. + // Dark ink on a pale card needs more coverage for the same contrast. + readonly property real strength: lightTheme ? 0.7 : 0.45 + + // `c` blended over `base` by `k`. + function blend(base, c, k) { return Qt.tint(base, Qt.rgba(c.r, c.g, c.b, k)) } + // Everything is drawn in "ink": ink on dark themes, the theme's own + // foreground on light ones, where ink would vanish into the card. + readonly property bool lightTheme: Color.background.hslLightness > 0.5 + readonly property color inkColor: lightTheme ? Color.foreground : "#ffffff" + readonly property string sunCore: blend(Color.accent, inkColor, 0.30).toString() + readonly property string sunMid: Color.accent.toString() + readonly property string sunRim: blend(Color.accent, Color.background, 0.35).toString() + readonly property string ink: inkColor.toString() + readonly property string inkSoft: blend(inkColor, Color.background, 0.25).toString() + readonly property string bgTint: blend(Color.background, inkColor, 0.55).toString() + readonly property string cloudDark: blend(Color.background, Color.accent, 0.25).toString() + readonly property string cloudShade: blend(inkSoft, Color.background, 0.45).toString() + readonly property string cloudDarker: blend(cloudDark, Color.background, 0.45).toString() + // 4x4 ordered-dither thresholds, flattened. + readonly property var ditherThresholds: [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map(function(b) { return (b + 0.5) / 16 }) + + // 64x64 lattice of random values for value noise. + property var noiseTable: [] + Component.onCompleted: { var tbl = []; for (var n = 0; n < 4096; n++) tbl.push(Math.random()); noiseTable = tbl } + + function replay() { tick = 0; openAnim.restart() } + + NumberAnimation { + id: openAnim + target: skyFx + property: "t" + from: 0; to: 1 + duration: 900 + easing.type: Easing.OutCubic + } + // The dynamic canvas repaints on every tick and the scrolling layers + // only move, so only the static canvas needs nudging: when the open + // animation, the scene or the theme changes. + onTChanged: stat.requestPaint() + onSunRimChanged: stat.requestPaint() + Connections { + target: root + function onFxModeChanged() { stat.requestPaint() } + } + + Timer { + interval: 33; repeat: true + running: skyFx.visible && root.opened + onTriggered: { skyFx.tick++; dyn.requestPaint() } + } + + // Value noise that repeats every `period` lattice cells horizontally + // (period <= 16, so the finest octave still fits the 64-wide lattice). + function hashT(ix, iy) { return noiseTable[((ix & 63) << 6) | (iy & 63)] } + function vnoise(x, y, period) { + var ix = Math.floor(x), iy = Math.floor(y), fx = x - ix, fy = y - iy + fx = fx * fx * (3 - 2 * fx); fy = fy * fy * (3 - 2 * fy) + var x0 = ((ix % period) + period) % period, x1 = x0 + 1 === period ? 0 : x0 + 1 + var a = hashT(x0, iy), b = hashT(x1, iy), c = hashT(x0, iy + 1), d = hashT(x1, iy + 1) + return a + (b - a) * fx + (c - a) * fy + (a - b - c + d) * fx * fy + } + function fbm(x, y, period) { + return 0.55 * vnoise(x, y, period) + 0.30 * vnoise(x * 2 + 7.3, y * 2 + 3.1, period * 2) + 0.15 * vnoise(x * 4 + 11.7, y * 4 + 5.9, period * 4) + } + + // Clouds and fog only drift sideways, so each is painted once into a + // strip one noise period wider than the card and slid along each tick. + function layerSpec(kind, speed, nsx, nsy, extra) { + var spec = { kind: kind, speed: speed, nsx: nsx, nsy: nsy } + for (var key in extra) spec[key] = extra[key] + return spec + } + // colors = [lit top, body, shaded underside]. With dark ink the "lit" + // colour is the darkest, so light themes swap the ends to keep tops + // lighter than undersides. + function cloudSpec(speed, topOnly, colors, alpha, dens) { + var lit = lightTheme ? colors[2] : colors[0], shade = lightTheme ? colors[0] : colors[2] + return layerSpec("cloud", speed, 51, 30, { topOnly: topOnly, lit: lit, body: colors[1], shade: shade, alpha: alpha, dens: dens }) + } + // Lightning: a short flash at the end of each period, faster when heavier. + readonly property real flashPeriod: [4.0, 2.6, 1.6][root.fxLevel] + readonly property bool flashing: root.fxMode === "storm" && (tick / 30) % flashPeriod > flashPeriod - 0.14 + // The storm band repainted in its lit palette, shown in place of the + // normal band while a flash lasts, so the clouds light up themselves. + function stormSpec(colors) { return cloudSpec(4.2 * (root.fxWindy ? 2.2 : 1), true, colors, 0.50, 0.05 + root.fxLevel * 0.02) } + readonly property var flashLayer: root.fxMode === "storm" ? stormSpec([ink, inkSoft, cloudDarker]) : null + readonly property var layers: { + var mode = root.fxMode, night = root.fxNight, lvl = root.fxLevel, wind = root.fxWindy ? 2.2 : 1 + var nightAlpha = night ? 0.8 : 1, precipDens = 0.04 + lvl * 0.02 + var bright = night ? [inkSoft, bgTint, cloudDarker] : [ink, inkSoft, cloudShade] + var dim = night ? [bgTint, cloudDark, cloudDarker] : [inkSoft, bgTint, cloudDarker] + switch (mode) { + case "partly": return [cloudSpec(1.95 * wind, true, bright, 0.30, -0.02)] + case "partly-night": return [cloudSpec(1.5 * wind, true, bright, 0.28, -0.02)] + case "clouds": return [cloudSpec(3.3 * wind, false, bright, 0.38 * nightAlpha, 0.03)] + case "rain": return [cloudSpec(2.1 * wind, true, dim, 0.34 * nightAlpha, precipDens)] + case "storm": return [stormSpec([inkSoft, cloudDark, cloudDarker])] + case "snow": return [cloudSpec(1.35 * wind, true, bright, 0.26 * nightAlpha, precipDens)] + case "sleet": return [cloudSpec(2.4 * wind, true, dim, 0.34 * nightAlpha, precipDens)] + case "fog": return [layerSpec("fog", 2.4, 66, 24, { seed: 0 }), layerSpec("fog", -1.35, 42, 16.5, { seed: 3.7 })] + } + return [] + } + + function paintLayer(ctx, spec, cols, rows, period, width, height) { + ctx.clearRect(0, 0, width, height) + if (!spec || noiseTable.length === 0) return + var c = cell, thr = ditherThresholds + function rect(i, j, w, h, color, a) { + ctx.fillStyle = color; ctx.globalAlpha = a + ctx.fillRect(i * c, j * c, w * c, h * c) + } + // One row of cells as runs: level(i) returns a key (falsy = empty) + // and style(key) its [color, alpha]. + function runs(j, level, style) { + var runKey = null, runStart = 0 + for (var i = 0; i <= cols; i++) { + var key = i < cols ? level(i) : null + if (key === runKey) continue + if (runKey) { var st = style(runKey); rect(runStart, j, i - runStart, 1, st[0], st[1]) } + runKey = key; runStart = i + } + } + if (spec.kind === "cloud") { + // Thresholded into a lit top, a body and a shaded underside, with a + // dithered rim; topOnly fades the band out toward mid-card. + var up = 5, th = 0.52 - spec.dens + var H = spec.topOnly ? Math.round(rows * 0.70) : rows, FH = H + up + var fld = new Array(cols * FH) + for (var j = 0; j < FH; j++) { + var env = spec.topOnly ? Math.max(0, Math.min(1, 1.7 - j / (rows * 0.40))) : (1 - 0.2 * j / rows) + for (var i = 0; i < cols; i++) fld[j * cols + i] = fbm(i / spec.nsx, j / spec.nsy, period) * env + } + var styles = { lit: [spec.lit, spec.alpha], body: [spec.body, spec.alpha], shade: [spec.shade, spec.alpha], rim: [spec.body, spec.alpha * 0.7] } + for (var row = 0; row < H; row++) { + runs(row, function(i) { + var v = fld[row * cols + i] + if (v >= th + 0.05) { + var above = row >= up ? fld[(row - up) * cols + i] : v, below = fld[(row + up) * cols + i] + return below < v - 0.03 ? "shade" : (above < v - 0.03 ? "lit" : "body") + } + return v >= th && (v - th) / 0.05 >= thr[((row & 3) << 2) | (i & 3)] ? "rim" : null + }, function(key) { return styles[key] }) + } + } else { + // Fog: density quantised to three alpha levels, thicker near the bottom. + for (var fj = 0; fj < rows; fj++) { + var fenv = (0.25 + 0.75 * fj / rows) * 1.6 + runs(fj, function(i) { + var d = (fbm(i / spec.nsx + spec.seed, fj / spec.nsy + spec.seed, period) - 0.3) * fenv + return d <= 0.15 ? 0 : (d <= 0.4 ? 1 : (d <= 0.7 ? 2 : 3)) + }, function(q) { return [inkSoft, 0.07 * q] }) + } + } + } + + component ScrollLayer: Canvas { + property var spec: null + readonly property int cardCols: Math.ceil(skyFx.width / skyFx.cell) + // Noise period in lattice cells, and the strip's repeat length in cells. + readonly property int period: spec ? Math.max(2, Math.min(16, Math.round(2 * cardCols / spec.nsx))) : 1 + readonly property int repeatCols: spec ? period * spec.nsx : 0 + visible: spec !== null + height: parent.height + width: (repeatCols + cardCols) * skyFx.cell + x: spec ? -Math.round((((skyFx.tick / 30 * spec.speed) % repeatCols) + repeatCols) % repeatCols * skyFx.cell) : 0 + opacity: skyFx.strength * skyFx.t + renderStrategy: Canvas.Cooperative + onSpecChanged: requestPaint() + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + onPaint: skyFx.paintLayer(getContext("2d"), spec, repeatCols + cardCols, Math.ceil(height / skyFx.cell), period, width, height) + } + + component SkyCanvas: Canvas { + property bool dynamic: false + anchors.fill: parent + renderStrategy: Canvas.Cooperative + opacity: skyFx.strength + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + onPaint: skyFx.paint(getContext("2d"), width, height, dynamic) + } + SkyCanvas { id: stat } + ScrollLayer { spec: skyFx.layers[0] || null; visible: spec !== null && !skyFx.flashing } + ScrollLayer { spec: skyFx.flashLayer; visible: skyFx.flashing } + ScrollLayer { spec: skyFx.layers[1] || null } + SkyCanvas { id: dyn; dynamic: true } + + function paint(ctx, width, height, dynamic) { + ctx.clearRect(0, 0, width, height) + var c = cell, t = skyFx.t, mode = root.fxMode + if (t <= 0) return + var cols = Math.ceil(width / c), rows = Math.ceil(height / c) + var time = skyFx.tick / 30 // seconds since the panel opened + var frame = Math.floor(time * 2.4) % 4 + var lvl = root.fxLevel, windy = root.fxWindy + var thr = ditherThresholds + + // The one drawing primitive: a w×h block of cells. The canvas clips. + function rect(i, j, w, h, color, a) { + ctx.fillStyle = color + ctx.globalAlpha = a < 0 ? 0 : (a > 1 ? 1 : a) + ctx.fillRect(i * c, j * c, w * c, h * c) + } + function wash(color, a) { rect(0, 0, cols, rows, color, a) } + function dither(i, j) { return thr[((j & 3) << 2) | (i & 3)] } + function rnd(n) { var x = Math.sin(n * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x) } + // Dithered radial falloff: the pixel-art stand-in for a soft gradient. + function glow(cx, cy, radius, color, gain, a) { + var G = Math.round(radius) + for (var j = Math.max(0, cy - G); j < Math.min(rows, cy + G); j++) + for (var i = Math.max(0, cx - G); i < Math.min(cols, cx + G); i++) { + var dx = i - cx, dy = j - cy + var g = Math.max(0, 1 - Math.sqrt(dx * dx + dy * dy) / G) * gain * t + if (g > 0.03 && g > dither(i, j)) rect(i, j, 1, 1, color, a) + } + } + + // Streaks falling at varied speeds; the last cell is the bright tip. + function rain(count, color, a, speed, slant) { + for (var n = 0; n < count; n++) { + var y = Math.round(((rnd(n) + time * speed * (0.7 + rnd(n + 100) * 0.6)) % 1) * (rows + 5) - 5) + var x = Math.round(rnd(n + 300) * (cols + 45) - 22 - y * slant) + rect(x, y, 1, 4, color, a * 0.7) + rect(x, y + 4, 1, 1, color, a) + } + } + function snow(count, color, a, speed, drift) { + for (var n = 0; n < count; n++) { + var y = Math.round(((rnd(n + 500) + time * speed * (0.6 + rnd(n + 700) * 0.8)) % 1) * (rows + 4) - 2) + var x = Math.round(rnd(n + 900) * cols + Math.sin(time * 0.9 + n) * 4.5 * drift + time * 18 * (drift - 1)) + x = ((x % cols) + cols) % cols + var size = rnd(n + 1100) > 0.6 ? 2 : 1 + rect(x, y, size, size, color, a) + } + } + function hailfall(count, color, a, speed) { + for (var n = 0; n < count; n++) { + var y = Math.round(((rnd(n + 1500) + time * speed * (0.8 + rnd(n + 1300) * 0.5)) % 1) * (rows + 4) - 2) + rect(Math.round(rnd(n + 1700) * cols - y * 0.05), y, 2, 2, color, a) + } + } + // Horizontal streaks racing left to right, fading in toward the head. + function wind(count, color, a) { + var span = cols + 30 + for (var n = 0; n < count; n++) { + var len = Math.round(7.5 + rnd(n + 2100) * 7.5), half = Math.round(len / 2) + var x = Math.round(((rnd(n + 2500) * span + time * (45 + rnd(n + 2300) * 45)) % span) - 15) + var y = Math.round(rnd(n + 2700) * rows + Math.sin(time * 3 + n) * 1.5) + rect(x, y, half, 1, color, a * 0.55) + rect(x + half, y, len - half, 1, color, a) + } + } + function bolt(index, x, y) { + for (var seg = 0; seg < 5; seg++) { + var dx = (rnd(index * 10 + seg) - 0.5) * 12 + var dy = 4.5 + rnd(index * 10 + seg + 50) * 6 + var steps = Math.ceil(Math.max(Math.abs(dx), dy)) + for (var k = 0; k <= steps; k++) { + var px = Math.round(x + dx * k / steps), py = Math.round(y + dy * k / steps) + rect(px, py, 1, 1, ink, 0.95) + rect(px + 1, py, 1, 1, ink, 0.5) + } + x += dx; y += dy + } + } + + // The sun or moon sits in the top-right corner; its light path runs + // to the bottom-left corner. + var sx = cols - 22, sy = 14 + var ldx = 12 - sx, ldy = rows - 12 - sy, llen = Math.sqrt(ldx * ldx + ldy * ldy) + var sunR = 15 * (0.6 + 0.4 * t) + + function sunStatic() { + glow(sx, sy, 99, sunMid, 0.55, 0.30) + // Light beam: a soft band along the light path, fading with distance. + var bw = 24 + for (var j = Math.max(0, sy); j < rows; j++) { + var along = (j - sy) / ldy + if (along > 1) break + var cxl = sx + ldx * along + for (var i = Math.max(0, Math.floor(cxl - bw)); i < Math.min(cols, Math.ceil(cxl + bw)); i++) { + var d = Math.abs(((i - sx) * ldy - (j - sy) * ldx) / llen) + var g = Math.max(0, 1 - d / bw) * (1 - along) * 0.45 * t + if (g > dither(i, j)) rect(i, j, 1, 1, sunCore, 0.16) + } + } + var box = Math.ceil(sunR) + 1 + for (var jj = -box; jj <= box; jj++) for (var ii = -box; ii <= box; ii++) { + var dd = Math.sqrt(ii * ii + jj * jj) + if (dd <= sunR) rect(sx + ii, sy + jj, 1, 1, dd <= sunR * 0.5 ? sunCore : (dd <= sunR * 0.82 ? sunMid : sunRim), t) + } + } + function sunDynamic() { + for (var k = 0; k < 8; k++) { + var ang = k * Math.PI / 4, len = 9 + ((k + frame) % 3) * 4.5 + for (var s = sunR + 4; s <= sunR + 4 + len; s++) + rect(Math.round(sx + Math.cos(ang) * s), Math.round(sy + Math.sin(ang) * s), 1, 1, sunMid, 0.75 * t) + } + // Bokeh: three soft discs along the light path, breathing slowly. + var bok = [[0.34, 13.5, ink, 0.22], [0.56, 7.5, sunCore, 0.30], [0.80, 19.5, sunMid, 0.16]] + for (var n = 0; n < bok.length; n++) { + var p = bok[n][0] * t + var fade = Math.max(0, Math.min(1, (t - bok[n][0] * 0.5) / 0.5)) + glow(Math.round(sx + ldx * p), Math.round(sy + ldy * p), bok[n][1] * (1 + 0.12 * Math.sin(time * 0.8 + n * 2.1)), bok[n][2], 1.2, bok[n][3] * fade) + } + } + // Night: an accent-tinted sky fading down from the top, a crescent + // with a halo, earthshine on its dark side and a few craters. + function moonStatic() { + for (var j = 0; j < rows; j++) { + var sky = Math.max(0, 1 - j / (rows * 0.85)) * 0.5 * t + for (var i = 0; i < cols; i++) if (sky > dither(i, j)) rect(i, j, 1, 1, cloudDark, 0.35) + } + var mr = 13.5 * (0.6 + 0.4 * t), mb = Math.ceil(mr) + 1 + glow(sx, sy, 90, inkSoft, 1.0, 0.34) + var craters = [[0.35, -0.35, 0.16], [0.55, 0.25, 0.12], [0.15, 0.55, 0.10]] + for (var mj = -mb; mj <= mb; mj++) for (var mi = -mb; mi <= mb; mi++) { + var d = Math.sqrt(mi * mi + mj * mj) + if (d > mr) continue + var bx = mi + mr * 0.45, by = mj - mr * 0.2 // the bite: an offset disc + if (Math.sqrt(bx * bx + by * by) <= mr * 0.85) { rect(sx + mi, sy + mj, 1, 1, bgTint, 0.22 * t); continue } + var crater = false + for (var k = 0; k < craters.length; k++) { + var cx = mi - craters[k][0] * mr, cy = mj - craters[k][1] * mr + if (Math.sqrt(cx * cx + cy * cy) <= craters[k][2] * mr) crater = true + } + rect(sx + mi, sy + mj, 1, 1, crater ? inkSoft : (d < mr * 0.8 ? ink : inkSoft), t) + } + } + // Stars twinkling smoothly at their own rates, and a shooting star + // crossing toward the bottom-left every few seconds. + function moonDynamic() { + for (var st = 0; st < 60; st++) { + var x = Math.round(rnd(st) * cols), y = Math.round(rnd(st + 40) * rows * 0.75) + if (Math.abs(x - sx) < 22 && Math.abs(y - sy) < 22) continue + var a = (0.6 + rnd(st + 80) * 0.4) * t * (0.6 + 0.4 * Math.sin(time * (1.2 + rnd(st + 160) * 2) + st * 7)) + var color = st % 5 === 0 ? sunMid : ink + rect(x, y, 1, 1, color, a) + if (rnd(st + 120) > 0.65) { rect(x - 1, y, 3, 1, color, a * 0.6); rect(x, y - 1, 1, 3, color, a * 0.6) } + } + var shootEvery = 7, shootFor = 0.9, phase = time % shootEvery + if (time > shootEvery * 0.5 && phase < shootFor) { + var n = Math.floor(time / shootEvery), p = phase / shootFor + var x0 = cols * (0.35 + rnd(n + 3000) * 0.45), y0 = rows * (0.05 + rnd(n + 3100) * 0.25) + var hx = x0 - 70 * p, hy = y0 + 28 * p + rect(Math.round(hx) - 1, Math.round(hy), 2, 2, ink, t) + for (var k = 1; k < 22; k++) + rect(Math.round(hx + k * 2.5), Math.round(hy - k), 1, 1, ink, (1 - k / 22) * t * (1 - p * 0.5)) + } + } + + var rainN = [40, 75, 120][lvl], rainSp = [0.45, 0.6, 0.85][lvl], rainSl = [0.08, 0.14, 0.24][lvl], rainA = [0.35, 0.45, 0.55][lvl] + var snowN = [30, 60, 110][lvl], snowSp = [0.12, 0.17, 0.26][lvl], snowDrift = windy ? 2.5 : 1 + + if (!dynamic) { + if (mode === "sun" || mode === "partly") sunStatic() + else if (mode === "moon" || mode === "partly-night") moonStatic() + else if (mode === "fog") wash(inkSoft, 0.07 * t) + } else { + if (mode === "sun" || mode === "partly") sunDynamic() + else if (mode === "moon" || mode === "partly-night") moonDynamic() + else if (mode === "rain") rain(rainN, inkSoft, rainA * t, rainSp, rainSl + (windy ? 0.2 : 0)) + else if (mode === "storm") { + var inFlash = skyFx.flashing + if (inFlash) wash(ink, 0.07) + rain(Math.round(rainN * 1.2), inkSoft, 0.5 * t, Math.max(0.7, rainSp), 0.22 + (windy ? 0.15 : 0)) + if (root.fxHail) hailfall(35, bgTint, 0.7 * t, 0.9) + var flashIdx = Math.floor(time / skyFx.flashPeriod) + if (inFlash) bolt(flashIdx, Math.round((0.2 + rnd(flashIdx) * 0.6) * cols), sy) + } + else if (mode === "snow") snow(snowN, ink, 0.55 * t, snowSp, snowDrift) + else if (mode === "sleet") { + rain(Math.round(rainN * 0.55), inkSoft, 0.4 * t, rainSp * 0.9, rainSl) + snow(Math.round(snowN * 0.5), ink, 0.5 * t, snowSp * 1.3, snowDrift) + } + if (windy) wind(24, inkSoft, 0.28 * t) + } + } + } + Flickable { id: weatherScroll anchors.fill: parent diff --git a/shell/plugins/panels/weather/manifest.json b/shell/plugins/panels/weather/manifest.json index 23fa84f8b90..2bed500d2e4 100644 --- a/shell/plugins/panels/weather/manifest.json +++ b/shell/plugins/panels/weather/manifest.json @@ -16,6 +16,18 @@ "description": "Weather pill with detail popup", "category": "Info", "allowMultiple": false, - "settingsForm": "weatherSettings" + "settingsForm": "weatherSettings", + "defaults": { + "fx": true + }, + "schema": [ + { + "key": "fx", + "type": "boolean", + "label": "Animated weather effects", + "description": "Draw an animated scene of the current conditions behind the forecast popup.", + "defaultValue": true + } + ] } } diff --git a/test/shell.d/weather-test.sh b/test/shell.d/weather-test.sh index 85aca8ec33c..f831e7318dd 100644 --- a/test/shell.d/weather-test.sh +++ b/test/shell.d/weather-test.sh @@ -153,6 +153,75 @@ assertEqual( weather.iconForCode(389, false), 'weather picks hourly forecast icon nearest noon' ) + +// ---- Sky scenes behind the popup. +assertDeepEqual( + weather.resolveSkyScene({ openMeteoWeatherCode: 0, isDay: 1, windspeedKmph: '8' }, ''), + { scene: 'sun', night: false, level: 1, hail: false, windy: false }, + 'weather resolves a clear day to the sun scene' +) +assertEqual(weather.resolveSkyScene({ openMeteoWeatherCode: 0, isDay: 0 }, '').night, true, 'weather resolves night from the Open-Meteo day flag') +assertEqual(weather.resolveSkyScene({ openMeteoWeatherCode: 2, isDay: 1 }, '').scene, 'partly', 'weather resolves partly cloudy codes') +assertEqual(weather.resolveSkyScene({ openMeteoWeatherCode: 3, isDay: 1 }, '').scene, 'clouds', 'weather resolves overcast') +assertEqual(weather.resolveSkyScene({ openMeteoWeatherCode: 45, isDay: 1 }, '').scene, 'fog', 'weather resolves fog') +assertDeepEqual( + [51, 61, 63, 65, 80, 82].map(code => weather.resolveSkyScene({ openMeteoWeatherCode: code, isDay: 1 }, '')).map(r => r.scene + r.level), + ['rain0', 'rain0', 'rain1', 'rain2', 'rain0', 'rain2'], + 'weather grades drizzle, rain and showers into three rain intensities' +) +assertDeepEqual( + [71, 73, 75, 77, 85, 86].map(code => weather.resolveSkyScene({ openMeteoWeatherCode: code, isDay: 1 }, '')).map(r => r.scene + r.level), + ['snow0', 'snow1', 'snow2', 'snow0', 'snow0', 'snow2'], + 'weather grades snow into three intensities' +) +assertDeepEqual( + [56, 66, 67].map(code => weather.resolveSkyScene({ openMeteoWeatherCode: code, isDay: 1 }, '')).map(r => r.scene + r.level), + ['sleet0', 'sleet1', 'sleet2'], + 'weather resolves freezing drizzle and rain to sleet' +) +assertDeepEqual( + weather.resolveSkyScene({ openMeteoWeatherCode: 96, isDay: 0 }, ''), + { scene: 'storm', night: true, level: 2, hail: true, windy: false }, + 'weather resolves a hail thunderstorm' +) +assertEqual(weather.resolveSkyScene({ openMeteoWeatherCode: 95, isDay: 1 }, '').hail, false, 'weather keeps plain thunderstorms hail-free') +assertEqual(weather.resolveSkyScene({ openMeteoWeatherCode: 1, isDay: 1, windspeedKmph: '31' }, '').windy, true, 'weather flags wind from 30 km/h') +assertEqual(weather.resolveSkyScene({ openMeteoWeatherCode: 1, isDay: 1, windspeedKmph: '29' }, '').windy, false, 'weather stays calm below 30 km/h') +assertDeepEqual( + weather.resolveSkyScene({ weatherCode: 389 }, weather.iconForCode(389, false)), + { scene: 'storm', night: false, level: 1, hail: false, windy: false }, + 'weather falls back to the bar glyph without an Open-Meteo code' +) +assertEqual(weather.resolveSkyScene(null, weather.iconForCode(113, true)).night, true, 'weather infers night from a night glyph without a day flag') +assertEqual(weather.resolveSkyScene(null, '').scene, 'off', 'weather draws nothing without any condition') +assertEqual(weather.skyMode('sun', true), 'moon', 'weather draws the moon for a clear night') +assertEqual(weather.skyMode('partly', true), 'partly-night', 'weather draws the night variant of partly cloudy') +assertEqual(weather.skyMode('rain', true), 'rain', 'weather keeps precipitation scenes under one name at night') +assertDeepEqual( + Object.keys(weather.WMO).map(name => weather.WMO[name]).sort((x, y) => x - y), + Object.keys(weather.SKY_BY_WMO).map(Number).sort((x, y) => x - y), + 'weather has a sky entry for exactly the named WMO codes' +) +assert( + Object.keys(weather.SKY_BY_WMO).every(code => weather.SKY_SCENES.indexOf(weather.SKY_BY_WMO[code].scene) >= 0), + 'weather maps every WMO code to a drawable scene' +) +assertEqual(weather.resolveSkyScene({ openMeteoWeatherCode: 42, isDay: 1 }, '').scene, 'clouds', 'weather treats an unlisted WMO code as clouds') +assertEqual(weather.resolveSkyScene(null, weather.iconForCode(182, false)).scene, 'sleet', 'weather maps the sleet glyph to sleet') + +const manifest = JSON.parse(fs.readFileSync(root + '/shell/plugins/panels/weather/manifest.json', 'utf8')) +const fxSetting = (manifest.barWidget.schema || []).find(entry => entry.key === 'fx') +assert(fxSetting && fxSetting.type === 'boolean' && fxSetting.defaultValue === true, 'weather manifest declares the fx toggle, on by default') +assert(panelSource.includes('fxEnabled ? Model.skyMode('), 'weather panel draws nothing when the fx toggle is off') +assert(panelSource.includes('visible: root.fxMode !== "off"'), 'weather panel hides the sky layer when the scene is off') +assert(panelSource.includes('running: skyFx.visible && root.opened'), 'weather panel only animates the sky while the popup is open') +assert(!panelSource.includes('onTextKey'), 'weather panel adds no key bindings for the sky') +const skySource = panelSource.slice(panelSource.indexOf('id: skyFx'), panelSource.indexOf('id: weatherScroll')) +assertDeepEqual( + [...new Set((skySource.match(/#[0-9a-fA-F]{3,8}\b/g) || []).map(hex => hex.toLowerCase()))], + ['#ffffff'], + 'weather sky layer takes its colours from the theme apart from white' +) JS test_tmp=$(mktemp -d) From ce9471c368c752883e6af90a29cd200a347505e0 Mon Sep 17 00:00:00 2001 From: predator Date: Thu, 24 Sep 2026 12:05:09 -0500 Subject: [PATCH 2/2] Fix weather sky data, caching and popup colours --- shell/plugins/panels/weather/Panel.qml | 86 ++++++++++++++++++-------- test/shell.d/weather-test.sh | 75 ++++++++++++++++++++++ 2 files changed, 136 insertions(+), 25 deletions(-) diff --git a/shell/plugins/panels/weather/Panel.qml b/shell/plugins/panels/weather/Panel.qml index c6b5283e8ec..afe56ee2e98 100644 --- a/shell/plugins/panels/weather/Panel.qml +++ b/shell/plugins/panels/weather/Panel.qml @@ -153,7 +153,7 @@ Panel { // It follows the Open-Meteo weather code and day flag when they are // present, else the bar glyph. The "fx" widget setting turns it off. readonly property bool fxEnabled: setting("fx", true) !== false - readonly property var fxResolved: Model.resolveSkyScene(current, label) + readonly property var fxResolved: Model.resolveSkyScene(openMeteoCurrent || current, label) readonly property bool fxNight: fxResolved.night readonly property int fxLevel: fxResolved.level readonly property bool fxHail: fxResolved.hail @@ -542,22 +542,25 @@ Panel { function blend(base, c, k) { return Qt.tint(base, Qt.rgba(c.r, c.g, c.b, k)) } // Everything is drawn in "ink": ink on dark themes, the theme's own // foreground on light ones, where ink would vanish into the card. - readonly property bool lightTheme: Color.background.hslLightness > 0.5 - readonly property color inkColor: lightTheme ? Color.foreground : "#ffffff" + readonly property color surfaceBackground: Color.popups.background + readonly property bool lightTheme: surfaceBackground.hslLightness > 0.5 + readonly property color inkColor: lightTheme ? Color.popups.text : "#ffffff" readonly property string sunCore: blend(Color.accent, inkColor, 0.30).toString() readonly property string sunMid: Color.accent.toString() - readonly property string sunRim: blend(Color.accent, Color.background, 0.35).toString() + readonly property string sunRim: blend(Color.accent, surfaceBackground, 0.35).toString() readonly property string ink: inkColor.toString() - readonly property string inkSoft: blend(inkColor, Color.background, 0.25).toString() - readonly property string bgTint: blend(Color.background, inkColor, 0.55).toString() - readonly property string cloudDark: blend(Color.background, Color.accent, 0.25).toString() - readonly property string cloudShade: blend(inkSoft, Color.background, 0.45).toString() - readonly property string cloudDarker: blend(cloudDark, Color.background, 0.45).toString() + readonly property string inkSoft: blend(inkColor, surfaceBackground, 0.25).toString() + readonly property string bgTint: blend(surfaceBackground, inkColor, 0.55).toString() + readonly property string cloudDark: blend(surfaceBackground, Color.accent, 0.25).toString() + readonly property string cloudShade: blend(inkSoft, surfaceBackground, 0.45).toString() + readonly property string cloudDarker: blend(cloudDark, surfaceBackground, 0.45).toString() + readonly property string paletteKey: [sunCore, sunMid, sunRim, ink, inkSoft, bgTint, cloudDark, cloudShade, cloudDarker].join("|") // 4x4 ordered-dither thresholds, flattened. readonly property var ditherThresholds: [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map(function(b) { return (b + 0.5) / 16 }) // 64x64 lattice of random values for value noise. property var noiseTable: [] + onNoiseTableChanged: layerCache = [] Component.onCompleted: { var tbl = []; for (var n = 0; n < 4096; n++) tbl.push(Math.random()); noiseTable = tbl } function replay() { tick = 0; openAnim.restart() } @@ -574,7 +577,7 @@ Panel { // only move, so only the static canvas needs nudging: when the open // animation, the scene or the theme changes. onTChanged: stat.requestPaint() - onSunRimChanged: stat.requestPaint() + onPaletteKeyChanged: stat.requestPaint() Connections { target: root function onFxModeChanged() { stat.requestPaint() } @@ -600,8 +603,9 @@ Panel { return 0.55 * vnoise(x, y, period) + 0.30 * vnoise(x * 2 + 7.3, y * 2 + 3.1, period * 2) + 0.15 * vnoise(x * 4 + 11.7, y * 4 + 5.9, period * 4) } - // Clouds and fog only drift sideways, so each is painted once into a - // strip one noise period wider than the card and slid along each tick. + // Clouds and fog only drift sideways. Cache their geometry outside + // Canvas: closing the layer-shell window discards its painted image. + // Reopening only redraws the cached runs, then slides the strip. function layerSpec(kind, speed, nsx, nsy, extra) { var spec = { kind: kind, speed: speed, nsx: nsx, nsy: nsy } for (var key in extra) spec[key] = extra[key] @@ -639,22 +643,34 @@ Panel { return [] } - function paintLayer(ctx, spec, cols, rows, period, width, height) { - ctx.clearRect(0, 0, width, height) - if (!spec || noiseTable.length === 0) return - var c = cell, thr = ditherThresholds - function rect(i, j, w, h, color, a) { - ctx.fillStyle = color; ctx.globalAlpha = a - ctx.fillRect(i * c, j * c, w * c, h * c) + // At most two recent geometries (the two fog strips). Storm and flash + // share an entry. Colours, alpha and scrolling speed don't shape runs. + property var layerCache: [] + function layerRuns(spec, cols, rows, period) { + var key = JSON.stringify([cols, rows, period, spec.kind, spec.nsx, spec.nsy, spec.topOnly, spec.dens, spec.seed]) + for (var n = 0; n < layerCache.length; n++) { + if (layerCache[n].key === key) { + var hit = layerCache.splice(n, 1)[0] + layerCache.push(hit) + return hit.runs + } } + var result = buildLayerRuns(spec, cols, rows, period) + layerCache.push({ key: key, runs: result }) + if (layerCache.length > 2) layerCache.shift() + return result + } + + function buildLayerRuns(spec, cols, rows, period) { + var result = [], thr = ditherThresholds // One row of cells as runs: level(i) returns a key (falsy = empty) - // and style(key) its [color, alpha]. - function runs(j, level, style) { + // kept with the geometry so palettes can change without new noise. + function runs(j, level) { var runKey = null, runStart = 0 for (var i = 0; i <= cols; i++) { var key = i < cols ? level(i) : null if (key === runKey) continue - if (runKey) { var st = style(runKey); rect(runStart, j, i - runStart, 1, st[0], st[1]) } + if (runKey) result.push([runStart, j, i - runStart, runKey]) runKey = key; runStart = i } } @@ -668,7 +684,6 @@ Panel { var env = spec.topOnly ? Math.max(0, Math.min(1, 1.7 - j / (rows * 0.40))) : (1 - 0.2 * j / rows) for (var i = 0; i < cols; i++) fld[j * cols + i] = fbm(i / spec.nsx, j / spec.nsy, period) * env } - var styles = { lit: [spec.lit, spec.alpha], body: [spec.body, spec.alpha], shade: [spec.shade, spec.alpha], rim: [spec.body, spec.alpha * 0.7] } for (var row = 0; row < H; row++) { runs(row, function(i) { var v = fld[row * cols + i] @@ -677,7 +692,7 @@ Panel { return below < v - 0.03 ? "shade" : (above < v - 0.03 ? "lit" : "body") } return v >= th && (v - th) / 0.05 >= thr[((row & 3) << 2) | (i & 3)] ? "rim" : null - }, function(key) { return styles[key] }) + }) } } else { // Fog: density quantised to three alpha levels, thicker near the bottom. @@ -686,12 +701,28 @@ Panel { runs(fj, function(i) { var d = (fbm(i / spec.nsx + spec.seed, fj / spec.nsy + spec.seed, period) - 0.3) * fenv return d <= 0.15 ? 0 : (d <= 0.4 ? 1 : (d <= 0.7 ? 2 : 3)) - }, function(q) { return [inkSoft, 0.07 * q] }) + }) } } + return result + } + + function paintLayer(ctx, spec, cols, rows, period, width, height) { + ctx.clearRect(0, 0, width, height) + if (!spec || noiseTable.length === 0) return + var runs = layerRuns(spec, cols, rows, period), c = cell + var styles = spec.kind === "cloud" + ? { lit: [spec.lit, spec.alpha], body: [spec.body, spec.alpha], shade: [spec.shade, spec.alpha], rim: [spec.body, spec.alpha * 0.7] } + : { 1: [inkSoft, 0.07], 2: [inkSoft, 0.14], 3: [inkSoft, 0.07 * 3] } + for (var n = 0; n < runs.length; n++) { + var run = runs[n], style = styles[run[3]] + ctx.fillStyle = style[0]; ctx.globalAlpha = style[1] + ctx.fillRect(run[0] * c, run[1] * c, run[2] * c, c) + } } component ScrollLayer: Canvas { + id: strip property var spec: null readonly property int cardCols: Math.ceil(skyFx.width / skyFx.cell) // Noise period in lattice cells, and the strip's repeat length in cells. @@ -706,6 +737,11 @@ Panel { onSpecChanged: requestPaint() onWidthChanged: requestPaint() onHeightChanged: requestPaint() + Connections { + target: skyFx + function onPaletteKeyChanged() { strip.requestPaint() } + function onNoiseTableChanged() { strip.requestPaint() } + } onPaint: skyFx.paintLayer(getContext("2d"), spec, repeatCols + cardCols, Math.ceil(height / skyFx.cell), period, width, height) } diff --git a/test/shell.d/weather-test.sh b/test/shell.d/weather-test.sh index f831e7318dd..a0e71277ffb 100644 --- a/test/shell.d/weather-test.sh +++ b/test/shell.d/weather-test.sh @@ -209,6 +209,81 @@ assert( assertEqual(weather.resolveSkyScene({ openMeteoWeatherCode: 42, isDay: 1 }, '').scene, 'clouds', 'weather treats an unlisted WMO code as clouds') assertEqual(weather.resolveSkyScene(null, weather.iconForCode(182, false)).scene, 'sleet', 'weather maps the sleet glyph to sleet') +// Evaluate the panel's actual bindings so changing its source selection back +// to wttr cannot pass just because the resolver works in isolation. +const vm = require('vm') +function panelBinding(name, context) { + const expression = panelSource.match(new RegExp('readonly property \\w+ ' + name + ': (.+)'))[1] + return vm.runInNewContext(expression, { Model: weather, ...context }) +} +const wmoCurrent = weather.openMeteoCurrentCondition({ current: { + temperature_2m: 12, weather_code: 96, is_day: 0, wind_speed_10m: 35 +} }) +const wttrCurrent = { weatherCode: 389, windspeedKmph: '8' } +for (const hasConfiguredCoordinates of [false, true]) { + const context = { hasConfiguredCoordinates, openMeteoCurrent: wmoCurrent, + report: { current_condition: [wttrCurrent] }, label: weather.iconForCode(389, false) } + context.current = panelBinding('current', context) + assertDeepEqual(panelBinding('fxResolved', context), + { scene: 'storm', night: true, level: 2, hail: true, windy: true }, + 'weather panel uses WMO sky data with configured coordinates ' + hasConfiguredCoordinates) +} +assertDeepEqual(panelBinding('fxResolved', { + openMeteoCurrent: null, current: wttrCurrent, label: weather.iconForCode(389, false) +}), { scene: 'storm', night: false, level: 1, hail: false, windy: false }, +'weather panel retains glyph fallback without Open-Meteo') + +// Exercise the actual strip painter with a recording context. Geometry must +// survive a new drawing context, while colours remain live and storage bounded. +const sky = vm.createContext({ + cell: 2, layerCache: [], inkSoft: '#aabbcc', + noiseTable: Array.from({ length: 4096 }, (_, n) => ((n * 7919) % 4096) / 4096), + ditherThresholds: [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map(b => (b + 0.5) / 16) +}) +for (const name of ['hashT', 'vnoise', 'fbm', 'layerRuns', 'buildLayerRuns', 'paintLayer']) { + const rest = panelSource.slice(panelSource.indexOf(' function ' + name + '(')) + const firstLine = rest.split('\n')[0] + const source = firstLine.endsWith('}') ? firstLine : rest.slice(0, rest.indexOf('\n }') + 10) + vm.runInContext(source, sky) +} +let noiseCalls = 0 +const fbm = sky.fbm +sky.fbm = (...args) => { noiseCalls++; return fbm(...args) } +function drawStrip(spec, cols = 180, rows = 50) { + const output = [] + const ctx = { clearRect() {}, fillRect(...rect) { output.push([...rect, this.fillStyle, this.globalAlpha]) } } + sky.paintLayer(ctx, spec, cols, rows, 2, cols * 2, rows * 2) + return output +} +const cloud = { kind: 'cloud', nsx: 51, nsy: 30, topOnly: true, dens: 0.09, + lit: '#eeeeee', body: '#bbbbbb', shade: '#777777', alpha: 0.5, speed: 4.2 } +const coldCloud = drawStrip(cloud) +assert(coldCloud.length > 0 && noiseCalls > 0, 'weather builds drawable cloud geometry on first paint') +noiseCalls = 0 +assertDeepEqual(drawStrip(cloud), coldCloud, 'weather replays identical cloud runs into a new context') +assertEqual(noiseCalls, 0, 'weather reopening does not regenerate cloud noise') +const flashCloud = drawStrip({ ...cloud, lit: '#ffffff', body: '#dddddd', alpha: 0.7, speed: 9 }) +assertEqual(noiseCalls, 0, 'weather storm flash, opacity and wind reuse cloud geometry') +assert(JSON.stringify(flashCloud) !== JSON.stringify(coldCloud), 'weather cached cloud geometry uses the new palette') +drawStrip({ ...cloud, dens: 0.03 }) +assert(noiseCalls > 0, 'weather rebuilds geometry when cloud density changes') +noiseCalls = 0 +drawStrip(cloud, 200) +assert(noiseCalls > 0, 'weather rebuilds geometry when strip dimensions change') +const fog = { kind: 'fog', nsx: 66, nsy: 24, seed: 0 } +const fogOther = { kind: 'fog', nsx: 42, nsy: 16.5, seed: 3.7 } +const coldFog = drawStrip(fog) +drawStrip(fogOther) +noiseCalls = 0 +assertDeepEqual(drawStrip(fog), coldFog, 'weather replays identical fog runs into a new context') +drawStrip(fogOther) +assertEqual(noiseCalls, 0, 'weather retains both fog geometries across reopens') +sky.inkSoft = '#112233' +const recoloredFog = drawStrip(fog) +assert(recoloredFog.length > 0 && recoloredFog.every(run => run[4] === '#112233'), 'weather fog repaints in the current palette') +assertEqual(noiseCalls, 0, 'weather recoloring fog does not regenerate noise') +assertEqual(sky.layerCache.length, 2, 'weather bounds strip storage after scene and size changes') + const manifest = JSON.parse(fs.readFileSync(root + '/shell/plugins/panels/weather/manifest.json', 'utf8')) const fxSetting = (manifest.barWidget.schema || []).find(entry => entry.key === 'fx') assert(fxSetting && fxSetting.type === 'boolean' && fxSetting.defaultValue === true, 'weather manifest declares the fx toggle, on by default')