Lua runner with rich builtin stdlib
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.
 
 
a/lua/demo/weather.lua

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