You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
256 lines
11 KiB
256 lines
11 KiB
-- Weather forecast demo — a small app that exercises most of the `a` stdlib:
|
|
--
|
|
-- tui prompts (text w/ suggestions, select, confirm), styled markup,
|
|
-- custom presets, escape for untrusted text in markup, message &
|
|
-- outline blocks, raw output with <clear=line> redraws,
|
|
-- setTitle, setClipboard
|
|
-- http getJSON with a timeout, non-2xx handling
|
|
-- task.join a spinner animating *while* the request is in flight
|
|
-- table map / ifilter / ifilterMap / min / max / mean / concat
|
|
-- math round / clamp / scale
|
|
-- utils try, dump
|
|
-- log diagnostics on stderr (run with RUST_LOG=info to see them)
|
|
--
|
|
-- Data comes from https://wttr.in (no API key). The app prompts for a city,
|
|
-- shows current conditions and a 3-day forecast, then lets you drill into a
|
|
-- day's hourly detail. Esc backs out of any prompt.
|
|
--
|
|
-- Run with: cargo run -- lua/weather.lua
|
|
|
|
----------------------------------------------------------------------
|
|
-- Styling: custom presets keep the markup below readable
|
|
----------------------------------------------------------------------
|
|
tui.addPreset("accent", "cyan;b")
|
|
tui.addPreset("dim", "gray")
|
|
tui.addPreset("freezing", "#7ecbff")
|
|
tui.addPreset("chilly", "#b0e0ff")
|
|
tui.addPreset("mild", "green")
|
|
tui.addPreset("warm", "yellow")
|
|
tui.addPreset("hot", "#ff5f2e;b")
|
|
|
|
-- Pick a preset name for a temperature (thresholds are °C).
|
|
local function tempPreset(celsius)
|
|
if celsius <= 0 then return "freezing"
|
|
elseif celsius <= 10 then return "chilly"
|
|
elseif celsius <= 22 then return "mild"
|
|
elseif celsius <= 30 then return "warm"
|
|
else return "hot" end
|
|
end
|
|
|
|
-- "<mild>17°C</>" — a temperature with its color; `shown` is the display
|
|
-- value (may be °F), `celsius` drives the color choice.
|
|
local function fmtTemp(shown, celsius, suffix)
|
|
local p = tempPreset(celsius)
|
|
return ("<%s>%d%s</%s>"):format(p, math.round(shown), suffix, p)
|
|
end
|
|
|
|
-- A fixed-width bar: `lo..hi` is the full scale, `a..b` the filled span.
|
|
-- math.scale maps temperatures onto columns; clamp=true guards outliers.
|
|
local function spanBar(lo, hi, a, b, width)
|
|
local from = math.round(math.scale(a, lo, hi, 1, width, true))
|
|
local to = math.round(math.scale(b, lo, hi, 1, width, true))
|
|
from, to = math.clamp(from, 1, width), math.clamp(to, 1, width)
|
|
return ("·"):rep(from - 1) .. ("█"):rep(to - from + 1) .. ("·"):rep(width - to)
|
|
end
|
|
|
|
-- A 0-100% meter, colored by how full it is.
|
|
local function pctBar(pct, width)
|
|
local filled = math.round(math.scale(pct, 0, 100, 0, width, true))
|
|
local style = pct >= 60 and "accent" or "dim"
|
|
return ("<%s>%s</%s><dim>%s</dim> %3d%%"):format(
|
|
style, ("▪"):rep(filled), style, ("·"):rep(width - filled), pct)
|
|
end
|
|
|
|
----------------------------------------------------------------------
|
|
-- Fetching: task.join runs the HTTP request and a spinner concurrently
|
|
----------------------------------------------------------------------
|
|
local FRAMES = { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" }
|
|
|
|
local function fetchWeather(city)
|
|
local url = "https://wttr.in/" .. city:gsub(" ", "+") .. "?format=j1"
|
|
log.info("GET", url)
|
|
|
|
-- The city is user input headed for styled markup: escape it so a name
|
|
-- like "<error>" (or a stray backslash) can't derail the tags around it.
|
|
local cityLabel = tui.escape(city)
|
|
|
|
local data, err
|
|
local done = false
|
|
task.join(
|
|
function()
|
|
-- utils.try traps transport errors (DNS, TLS, timeout) instead
|
|
-- of aborting; an HTTP error status is not an error and is
|
|
-- reported through resp.status / resp.ok.
|
|
local resp
|
|
resp, err = utils.try(function()
|
|
return http.get(url, { timeout = 20 })
|
|
end)
|
|
if resp and not resp.ok then
|
|
err = ("wttr.in answered HTTP %d — probably not a place it knows"):format(resp.status)
|
|
elseif resp then
|
|
data, err = utils.try(resp.json)
|
|
end
|
|
done = true
|
|
end,
|
|
function()
|
|
-- The spinner keeps animating because http.getJSON suspends
|
|
-- instead of blocking. <clear=line> redraws in place.
|
|
local i = 1
|
|
while not done do
|
|
tui.raw(tui.styled(("<clear=line><accent>%s</accent> asking wttr.in about <b>%s</b>…")
|
|
:format(FRAMES[i], cityLabel)))
|
|
i = i % #FRAMES + 1
|
|
os.sleep(0.08)
|
|
end
|
|
tui.raw(tui.styled("<clear=line>"))
|
|
end
|
|
)
|
|
return data, err
|
|
end
|
|
|
|
----------------------------------------------------------------------
|
|
-- Rendering
|
|
----------------------------------------------------------------------
|
|
local BAR_W = 24
|
|
|
|
local function dayLabel(isoDate)
|
|
local y, m, d = isoDate:match("(%d+)-(%d+)-(%d+)")
|
|
return os.date("%a %b %e", os.time({ year = y, month = m, day = d, hour = 12 }))
|
|
end
|
|
|
|
local function showCurrent(data, T, W)
|
|
local cur = data.current_condition[1]
|
|
local area = data.nearest_area[1]
|
|
local place = ("%s, %s"):format(area.areaName[1].value, area.country[1].value)
|
|
log.debug("nearest_area:", utils.dump(area))
|
|
|
|
tui.block((" %s (%s, %s)"):format(place, area.latitude, area.longitude),
|
|
{ style = "black;on-cyan", padding = true })
|
|
|
|
local t, feels = tonumber(cur["temp_" .. T]), tonumber(cur["FeelsLike" .. T])
|
|
local tC = tonumber(cur.temp_C)
|
|
-- weatherDesc is free text from the API — escape it like any input.
|
|
print(tui.styled((" %s <b>%s</b> <dim>(feels like %s)</dim>")
|
|
:format(fmtTemp(t, tC, "°" .. T), tui.escape(cur.weatherDesc[1].value),
|
|
fmtTemp(feels, tonumber(cur.FeelsLikeC), "°" .. T))))
|
|
print(tui.styled((" wind <b>%s %s</b> %s · pressure <b>%s hPa</b> · UV <b>%s</b>")
|
|
:format(cur["windspeed" .. W], W == "Kmph" and "km/h" or "mph",
|
|
cur.winddir16Point, cur.pressure, cur.uvIndex)))
|
|
print(tui.styled(" humidity " .. pctBar(tonumber(cur.humidity), BAR_W)))
|
|
print()
|
|
end
|
|
|
|
local function showForecast(data, T)
|
|
-- Every hourly temperature across all days — table.map preserves the
|
|
-- sequence, table.min/max aggregate it — fixes one scale for the bars.
|
|
local allTemps = {}
|
|
for _, day in ipairs(data.weather) do
|
|
for _, h in ipairs(day.hourly) do
|
|
table.insert(allTemps, tonumber(h["temp" .. T]))
|
|
end
|
|
end
|
|
local lo, hi = table.min(allTemps), table.max(allTemps)
|
|
|
|
print(tui.styled((" <u>3-day forecast</u> <dim>(scale %d°%s … %d°%s)</dim>"):format(lo, T, hi, T)))
|
|
for _, day in ipairs(data.weather) do
|
|
local temps = table.map(day.hourly, function(h) return tonumber(h["temp" .. T]) end)
|
|
local rain = math.round(table.mean(
|
|
table.map(day.hourly, function(h) return tonumber(h.chanceofrain) end)) or 0)
|
|
local dMin, dMax = table.min(temps), table.max(temps)
|
|
print(tui.styled((" %-10s %s … %s <%s>%s</> rain %2d%% <dim>☀ %s → %s</dim>")
|
|
:format(dayLabel(day.date),
|
|
fmtTemp(dMin, tonumber(day.mintempC), "°"),
|
|
fmtTemp(dMax, tonumber(day.maxtempC), "°"),
|
|
tempPreset(tonumber(day.maxtempC)), spanBar(lo, hi, dMin, dMax, BAR_W),
|
|
rain, day.astronomy[1].sunrise, day.astronomy[1].sunset)))
|
|
end
|
|
print()
|
|
end
|
|
|
|
local function showHourly(day, T, W)
|
|
print(tui.styled(("\n <u>%s, hour by hour</u>"):format(dayLabel(day.date))))
|
|
for _, h in ipairs(day.hourly) do
|
|
local time = ("%04d"):format(tonumber(h.time)):sub(1, 2) .. ":00"
|
|
print(tui.styled((" <dim>%s</dim> %s %s <dim>wind %3s</dim> %-24s")
|
|
:format(time,
|
|
fmtTemp(tonumber(h["temp" .. T]), tonumber(h.tempC), "°"),
|
|
pctBar(tonumber(h.chanceofrain), 10),
|
|
h["windspeed" .. W], tui.escape(h.weatherDesc[1].value))))
|
|
end
|
|
|
|
-- table.ifilterMap: keep only the rainy hours, mapped to "HH:00".
|
|
local rainy = table.ifilterMap(day.hourly, function(h)
|
|
if tonumber(h.chanceofrain) >= 60 then
|
|
return ("%04d"):format(tonumber(h.time)):sub(1, 2) .. ":00"
|
|
end
|
|
end)
|
|
if #rainy > 0 then
|
|
tui.warning("Rain is likely around " .. table.concat(rainy, ", ") .. " — pack an umbrella.")
|
|
else
|
|
tui.success("No serious rain expected that day.")
|
|
end
|
|
end
|
|
|
|
----------------------------------------------------------------------
|
|
-- Main loop
|
|
----------------------------------------------------------------------
|
|
tui.outlineInfo("Weather forecast, courtesy of wttr.in.\nEsc backs out of any prompt; Ctrl-C quits.",
|
|
{ title = "a-weather" })
|
|
|
|
local units = tui.promptSelect("Units?", "metric", {
|
|
options = { "metric", "imperial" },
|
|
help = "°C & km/h, or °F & mph",
|
|
})
|
|
if units == nil then return end
|
|
local T = units == "metric" and "C" or "F" -- temp field suffix in wttr JSON
|
|
local W = units == "metric" and "Kmph" or "Miles" -- wind field suffix
|
|
|
|
while true do
|
|
local city = tui.promptText("City?", "Prague", {
|
|
placeholder = "any city name wttr.in knows",
|
|
suggestions = { "Prague", "Brno", "Berlin", "London", "Tokyo", "Reykjavik", "Dubai" },
|
|
})
|
|
if city == nil or city == "" then
|
|
tui.comment("Nothing to look up — bye.")
|
|
return
|
|
end
|
|
|
|
tui.setTitle("weather: " .. city) -- restored automatically on exit
|
|
|
|
local data, err = fetchWeather(city)
|
|
if not data then
|
|
tui.error({ "Weather lookup failed.", tostring(err) })
|
|
elseif not data.current_condition then
|
|
-- wttr answers 200 with an error payload for unknown places
|
|
tui.warning(("wttr.in has no forecast for %q — try another spelling."):format(city))
|
|
else
|
|
print()
|
|
showCurrent(data, T, W)
|
|
showForecast(data, T)
|
|
|
|
-- Drill into one day; options are built from the payload itself.
|
|
local labels = table.map(data.weather, function(d) return dayLabel(d.date) end)
|
|
local pick = tui.promptSelect("Hourly detail for which day?", labels[1], {
|
|
options = labels,
|
|
help = "Esc to skip",
|
|
})
|
|
if pick ~= nil then
|
|
local _, idx = table.find(labels, function(l) return l == pick end)
|
|
showHourly(data.weather[idx], T, W)
|
|
end
|
|
|
|
-- One-line summary to the clipboard (OSC 52), if the user wants it.
|
|
local cur = data.current_condition[1]
|
|
local summary = ("%s: %s°%s, %s"):format(city, cur["temp_" .. T], T, cur.weatherDesc[1].value)
|
|
if tui.confirm("Copy summary to clipboard?", false, { help = summary }) then
|
|
tui.setClipboard(summary)
|
|
tui.info("Copied: " .. summary)
|
|
end
|
|
end
|
|
|
|
print()
|
|
if not tui.confirm("Look up another city?", true) then
|
|
tui.comment("Done. (Terminal title resets on exit.)")
|
|
return
|
|
end
|
|
end
|
|
|