-- 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 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 -- "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"):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 %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 "" (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. redraws in place. local i = 1 while not done do tui.raw(tui.styled(("%s asking wttr.in about %s…") :format(FRAMES[i], cityLabel))) i = i % #FRAMES + 1 os.sleep(0.08) end tui.raw(tui.styled("")) 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 %s (feels like %s)") :format(fmtTemp(t, tC, "°" .. T), tui.escape(cur.weatherDesc[1].value), fmtTemp(feels, tonumber(cur.FeelsLikeC), "°" .. T)))) print(tui.styled((" wind %s %s %s · pressure %s hPa · UV %s") :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((" 3-day forecast (scale %d°%s … %d°%s)"):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%% ☀ %s → %s") :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 %s, hour by hour"):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((" %s %s %s wind %3s %-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