// Package render turns fetched weather into terminal output. It is pure: no // network, no clock beyond what it is given, so every rule below is testable. package render import ( "fmt" "math" "strings" "github.com/lukaszkasprzak/prognosis/internal/config" "github.com/lukaszkasprzak/prognosis/internal/i18n" "github.com/lukaszkasprzak/prognosis/internal/imgw" "github.com/lukaszkasprzak/prognosis/internal/openmeteo" ) // RainInterestPct is the chance of rain below which the mm and rain columns are // hidden: on a dry day they are a block of zeroes that pushes real content // sideways. const RainInterestPct = 20 // View is everything one run needs to render. type View struct { Label string TZ string Rows []openmeteo.Row Sun map[string][2]string Daily map[string]float64 Pollen map[string]float64 Warnings []imgw.Warning WarnNote string WarnFailed bool } type ctx struct { cfg config.Config cat *i18n.Catalog c func(string, string) string width int wet bool } // celsius converts a displayed temperature back to Celsius. // // IMGW's thresholds are defined in Celsius, so they must be compared in // Celsius: a warning threshold does not move because the display was switched // to Fahrenheit. Without this, 85F (a mild 29C) renders in the "IMGW would warn // about this" red. func (x ctx) celsius(v float64) float64 { if x.cfg.Units == "imperial" { return (v - 32) * 5 / 9 } return v } // Render produces the whole output. func Render(v View, cfg config.Config, width int, colour bool) string { cx := ctx{ cfg: cfg, cat: i18n.For(cfg.DisplayLang), c: Styler(colour), width: width, wet: isWet(v.Rows), } var out []string out = append(out, cx.warnings(v)...) out = append(out, cx.header(v)...) out = append(out, cx.table(v)...) if cfg.Graph { out = append(out, cx.chart(v)...) } text := strings.Join(out, "\n") // Applied last, one character for one, so the layout above is unaffected. if cfg.ASCII { text = ASCII(text, cfg.Units) } return text } func isWet(rows []openmeteo.Row) bool { for _, r := range rows { if mm, ok := r.Val("precipitation"); ok && mm > 0 { return true } if p, ok := r.Val("precipitation_probability"); ok && p >= RainInterestPct { return true } } return false } // warnings renders IMGW warnings, or an explicit line saying why none are shown. // // WarnFailed must never look the same as "none in force": silence would be read // as all-clear. func (x ctx) warnings(v View) []string { var out []string switch { case x.WarnDisabled(): return nil case v.WarnFailed: out = append(out, x.c(Dim, " "+x.cat.Word("warnings")+": "+x.cat.Word("could_not_check"))) case v.WarnNote != "": out = append(out, x.c(Dim, " "+x.cat.Word("warnings")+": "+v.WarnNote)) } for _, w := range v.Warnings { style := Yellow switch w.Level { case "2": style = Red case "3": style = Bold + ";" + Red } head := fmt.Sprintf(" ! %s %s %s %s -> %s (%s%%)", w.Event, x.cat.Word("level"), w.Level, clip(w.From), clip(w.To), w.Probability) out = append(out, x.c(style, head)) for _, line := range wrap(w.Text, x.width) { out = append(out, x.c(Dim, " "+line)) } } if len(out) > 0 { out = append(out, "") } return out } // WarnDisabled reports whether warnings were switched off in config. func (x ctx) WarnDisabled() bool { return !x.cfg.Warnings } // clip shortens "2026-08-10 11:00:00" to "08-10 11:00". func clip(s string) string { if len(s) >= 16 { return s[5:16] } return s } func (x ctx) header(v View) []string { var out []string first := v.Rows[0].When title := v.Label + " " + x.cat.Date(first) if x.cfg.Minimal { // Just the place and the date: no sun times, no summary, no pollen. return append(out, x.c(Bold, title)) } sun, hasSun := v.Sun[first.Format("2006-01-02")] suffix := "" if hasSun { suffix = fmt.Sprintf(" %s %s %s %s", sun[0], x.cat.Word("up"), sun[1], x.cat.Word("down")) } tz := "" if v.TZ != "" { tz = " " + v.TZ } // Keep it to one line where it fits; a phone is narrow enough that it often // does not, and a wrapped title reads worse than two deliberate lines. if hasSun && DisplayWidth(title)+DisplayWidth(suffix)+DisplayWidth(tz) <= x.width { out = append(out, x.c(Bold, title)+x.c(Dim, suffix+tz)) } else { out = append(out, x.c(Bold, title)+x.c(Dim, tz)) if hasSun { out = append(out, x.c(Dim, fmt.Sprintf(" %s %s %s %s %s", x.cat.Word("sun"), sun[0], x.cat.Word("up"), sun[1], x.cat.Word("down")))) } } if len(v.Daily) > 0 { var bits []string lo, okLo := v.Daily["temperature_2m_min"] hi, okHi := v.Daily["temperature_2m_max"] if okLo && okHi { bits = append(bits, fmt.Sprintf("%d-%d°", Deg(lo), Deg(hi))) } if mm, ok := v.Daily["precipitation_sum"]; ok { if mm == 0 { bits = append(bits, x.cat.Word("dry")) } else { bits = append(bits, fmt.Sprintf("%s %.1fmm %s %.0fh", x.cat.Word("rainfall"), mm, x.cat.Word("over"), v.Daily["precipitation_hours"])) } } if sun, ok := v.Daily["sunshine_duration"]; ok { if day, ok2 := v.Daily["daylight_duration"]; ok2 { bits = append(bits, fmt.Sprintf("%s %s %s %s %s", x.cat.Word("sun"), hm(sun), x.cat.Word("of"), hm(day), x.cat.Word("daylight"))) } } label := " " + Pad(x.cat.Word("day"), 6) + " " // Joined with wide separators when it fits; only a line too long for the // terminal is re-wrapped, and then on single spaces. joined := strings.Join(bits, " ") lines := []string{joined} if DisplayWidth(label)+DisplayWidth(joined) > x.width { lines = wrap(joined, x.width-DisplayWidth(label)) } for i, line := range lines { prefix := label if i > 0 { prefix = strings.Repeat(" ", DisplayWidth(label)) } out = append(out, x.c(Dim, prefix+line)) } } if len(v.Pollen) > 0 { var bits []string for _, s := range sortedByValue(v.Pollen) { band := PollenBand(s, v.Pollen[s]) // Skip taxa that are simply absent, but never hide grass: it is the // one someone may be allergic to and its absence is information. if band == "none" && s != "grass" { continue } text := fmt.Sprintf("%s %.1f", x.cat.Species(s), v.Pollen[s]) if band != "" { text += " " + x.cat.Band(band) } bits = append(bits, text) } if len(bits) > 0 { if len(bits) > 4 { bits = bits[:4] } out = append(out, x.c(Dim, " "+Pad(x.cat.Word("pollen"), 6)+" ")+strings.Join(bits, " ")) } } return out } func hm(seconds float64) string { s := int(seconds) return fmt.Sprintf("%dh%02dm", s/3600, (s%3600)/60) } func sortedByValue(m map[string]float64) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } for i := 1; i < len(keys); i++ { for j := i; j > 0 && m[keys[j]] > m[keys[j-1]]; j-- { keys[j], keys[j-1] = keys[j-1], keys[j] } } return keys } func wrap(text string, width int) []string { if width < 8 { width = 8 } var lines []string var line string for _, word := range strings.Fields(text) { switch { case line == "": line = word case DisplayWidth(line)+1+DisplayWidth(word) <= width: line += " " + word default: lines = append(lines, line) line = word } } if line != "" { lines = append(lines, line) } return lines } func round1(v float64) string { return fmt.Sprintf("%.1f", v) } func compass(deg float64) string { dirs := []string{"↓", "↙", "←", "↖", "↑", "↗", "→", "↘"} i := int(math.Mod(math.Round(deg/45), 8)) if i < 0 { i += 8 } return dirs[i] }