aboutsummaryrefslogtreecommitdiff
path: root/internal/render
diff options
context:
space:
mode:
Diffstat (limited to 'internal/render')
-rw-r--r--internal/render/ascii.go67
-rw-r--r--internal/render/ascii_test.go55
-rw-r--r--internal/render/chart.go205
-rw-r--r--internal/render/color.go62
-rw-r--r--internal/render/icons.go84
-rw-r--r--internal/render/render.go283
-rw-r--r--internal/render/render_test.go287
-rw-r--r--internal/render/scale.go92
-rw-r--r--internal/render/scale_test.go57
-rw-r--r--internal/render/table.go240
-rw-r--r--internal/render/width.go110
-rw-r--r--internal/render/width_test.go112
12 files changed, 1654 insertions, 0 deletions
diff --git a/internal/render/ascii.go b/internal/render/ascii.go
new file mode 100644
index 0000000..4203bc9
--- /dev/null
+++ b/internal/render/ascii.go
@@ -0,0 +1,67 @@
+package render
+
+import "strings"
+
+// asciiMap is deliberately one rune to one ASCII character.
+//
+// Substitution happens after the table has been laid out, so anything that
+// changed the number of characters would shear every column to its right. That
+// constraint is why the wind arrows become single letters rather than the
+// two-letter compass points that would read better.
+var asciiMap = map[rune]string{
+ // Polish diacritics, so a Polish forecast survives GSM-7.
+ 'ą': "a", 'ć': "c", 'ę': "e", 'ł': "l", 'ń': "n",
+ 'ó': "o", 'ś': "s", 'ź': "z", 'ż': "z",
+ 'Ą': "A", 'Ć': "C", 'Ę': "E", 'Ł': "L", 'Ń': "N",
+ 'Ó': "O", 'Ś': "S", 'Ź': "Z", 'Ż': "Z",
+
+ // Wind arrows. The arrow points the way the wind blows, so v is a northerly.
+ '↑': "^", '↓': "v", '←': "<", '→': ">",
+ '↖': "\\", '↗': "/", '↙': "/", '↘': "\\",
+
+ // Chart: blocks and the axis rule.
+ '█': "#", '▇': "#", '▆': "=", '▅': "=",
+ '▄': "_", '▃': ":", '▂': ".", '▁': ".",
+ '│': "|",
+}
+
+// ASCII rewrites output to pure ASCII, one character for one character.
+//
+// The point is SMS: a single non-ASCII character forces the whole message from
+// GSM-7 into UCS-2, which cuts a segment from 160 characters to 70. A degree
+// sign alone therefore more than doubles the cost of sending a forecast.
+//
+// The degree sign becomes the unit letter, which is both ASCII and clearer to
+// someone reading it cold: "29C" rather than "29".
+func ASCII(s, units string) string {
+ degree := "C"
+ if units == "imperial" {
+ degree = "F"
+ }
+ runes := []rune(s)
+ var b strings.Builder
+ b.Grow(len(s))
+ for i, r := range runes {
+ switch {
+ case r == '°':
+ // Prose from IMGW already writes "30°C"; appending the unit again
+ // would give "30CC". Only a bare degree sign gains the letter.
+ if i+1 < len(runes) && (runes[i+1] == 'C' || runes[i+1] == 'F') {
+ continue
+ }
+ b.WriteString(degree)
+ case r < 128:
+ b.WriteRune(r)
+ default:
+ if sub, ok := asciiMap[r]; ok {
+ b.WriteString(sub)
+ } else {
+ // Anything unmapped -- a place name in another script, a weather
+ // code description we have not transliterated -- becomes '?'
+ // rather than silently vanishing and misaligning the row.
+ b.WriteString("?")
+ }
+ }
+ }
+ return b.String()
+}
diff --git a/internal/render/ascii_test.go b/internal/render/ascii_test.go
new file mode 100644
index 0000000..7e6a805
--- /dev/null
+++ b/internal/render/ascii_test.go
@@ -0,0 +1,55 @@
+package render
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestASCIIIsPureASCII(t *testing.T) {
+ in := " ! Upał stopień 1\n 14 29° (28) zachmurzenie ↗\n 30°│███▄▄▁"
+ got := ASCII(in, "metric")
+ for _, r := range got {
+ if r > 127 {
+ t.Fatalf("non-ASCII %q survived in %q", r, got)
+ }
+ }
+}
+
+// Substitution runs after layout, so it must not change the character count --
+// otherwise every column to the right of a degree sign shears.
+func TestASCIIPreservesLength(t *testing.T) {
+ for _, in := range []string{
+ " 14 29° (28) zachmurzenie",
+ " 30°│███▄▄▁▂",
+ " godz temp odczuw warunki",
+ " słońce 12h03m z 14h45m dnia",
+ } {
+ if got := ASCII(in, "metric"); len([]rune(got)) != len([]rune(in)) {
+ t.Errorf("length changed: %q (%d) -> %q (%d)",
+ in, len([]rune(in)), got, len([]rune(got)))
+ }
+ }
+}
+
+// IMGW prose already writes "30°C"; the unit letter must not be doubled.
+func TestASCIIDoesNotDoubleTheUnitInProse(t *testing.T) {
+ got := ASCII("temperatura od 30°C do 33°C", "metric")
+ if strings.Contains(got, "CC") {
+ t.Fatalf("doubled unit: %q", got)
+ }
+ if got != "temperatura od 30C do 33C" {
+ t.Fatalf("got %q", got)
+ }
+}
+
+func TestASCIIUsesFahrenheitLetterInImperial(t *testing.T) {
+ if got := ASCII("85°", "imperial"); got != "85F" {
+ t.Fatalf("got %q, want 85F", got)
+ }
+}
+
+func TestASCIIMarksUnmappedRunesRatherThanDroppingThem(t *testing.T) {
+ if got := ASCII("東京", "metric"); got != "??" {
+ t.Fatalf("got %q, want ??", got)
+ }
+}
diff --git a/internal/render/chart.go b/internal/render/chart.go
new file mode 100644
index 0000000..cc20ca9
--- /dev/null
+++ b/internal/render/chart.go
@@ -0,0 +1,205 @@
+package render
+
+import (
+ "fmt"
+ "math"
+ "strings"
+
+ "github.com/lukaszkasprzak/prognosis/internal/openmeteo"
+)
+
+const blocks = "▁▂▃▄▅▆▇█"
+
+// column is one drawn chart column. Keeping level, colour, rain and hour in one
+// struct means they cannot drift apart, which four parallel slices would allow.
+type column struct {
+ level int
+ style string
+ rain float64
+ hour string
+}
+
+// chart draws the temperature over several rows with a labelled axis.
+//
+// A one-row sparkline gives only 8 levels, so a single cold hour flattens the
+// rest of the week into the top two blocks. Drawing over height rows with
+// half-block cells gives height*2 levels, enough to see the daily rise and fall.
+// Long spans are downsampled so the chart fits the terminal: a week is 168
+// hourly points and would otherwise wrap into mush.
+func (x ctx) chart(v View) []string {
+ height := x.cfg.GraphHeight
+ const gutter = 5 // "NN°" label plus the axis rule
+ cols := x.width - gutter - 1
+ if cols < 8 {
+ cols = 8
+ }
+
+ groups := buckets(min(len(v.Rows), cols), len(v.Rows))
+ temps := make([]float64, len(groups))
+ rains := make([]float64, len(groups))
+ for i, g := range groups {
+ sum, maxRain := 0.0, 0.0
+ for _, r := range v.Rows[g[0]:g[1]] {
+ t, _ := r.Val("temperature_2m")
+ sum += t
+ if mm, ok := r.Val("precipitation"); ok && mm > maxRain {
+ maxRain = mm
+ }
+ }
+ temps[i] = sum / float64(g[1]-g[0])
+ rains[i] = maxRain
+ }
+
+ lo, hi := temps[0], temps[0]
+ for _, t := range temps {
+ lo, hi = math.Min(lo, t), math.Max(hi, t)
+ }
+ span := hi - lo
+ if span == 0 {
+ span = 1
+ }
+ steps := height * 2
+
+ // A 12-hour chart would occupy 12 of 80 columns; widen each point to use the
+ // terminal rather than leaving the curve cramped in the corner.
+ scale := cols / len(groups)
+ if scale < 1 {
+ scale = 1
+ }
+ var drawn []column
+ for i, g := range groups {
+ lvl := int(math.Round((temps[i] - lo) / span * float64(steps)))
+ if lvl < 1 {
+ lvl = 1 // always one half-cell, so the coldest column still shows
+ }
+ for k := 0; k < scale; k++ {
+ drawn = append(drawn, column{
+ level: lvl,
+ style: TempStyle(x.celsius(temps[i])),
+ rain: rains[i],
+ hour: v.Rows[g[0]].When.Format("15"),
+ })
+ }
+ }
+
+ out := []string{""}
+ for r := 0; r < height; r++ {
+ full := (height - r) * 2
+ value := lo + (hi-lo)*float64(height-1-r)/float64(height-1)
+ label := " "
+ switch {
+ case r == 0:
+ label = x.c(TempStyle(x.celsius(hi)), PadLeft(fmt.Sprintf("%d°", Deg(hi)), 4))
+ case r == height-1:
+ label = x.c(TempStyle(x.celsius(lo)), PadLeft(fmt.Sprintf("%d°", Deg(lo)), 4))
+ case height >= 5 && r == height/2:
+ label = x.c(TempStyle(x.celsius(value)), PadLeft(fmt.Sprintf("%d°", Deg(value)), 4))
+ }
+ cells := make([]Cell, 0, len(drawn))
+ for _, d := range drawn {
+ switch {
+ case d.level >= full:
+ cells = append(cells, Cell{Style: d.style, Text: "█"})
+ case d.level == full-1:
+ cells = append(cells, Cell{Style: d.style, Text: "▄"})
+ default:
+ cells = append(cells, Cell{Text: " "})
+ }
+ }
+ out = append(out, label+x.c(Dim, "│")+Paint(cells, x.c))
+ }
+
+ note := fmt.Sprintf("%d-%d°", Deg(lo), Deg(hi))
+ if len(groups) < len(v.Rows) {
+ note += fmt.Sprintf(" %.0f%s", float64(len(v.Rows))/float64(len(groups)), x.cat.Word("h_per_col"))
+ }
+
+ // A flat row of empty blocks says nothing; only draw rain if there is any.
+ maxRain := 0.0
+ for _, d := range drawn {
+ maxRain = math.Max(maxRain, d.rain)
+ }
+ if maxRain > 0 {
+ series := make([]float64, len(drawn))
+ for i, d := range drawn {
+ series[i] = d.rain
+ }
+ out = append(out, x.c(Dim, PadLeft(x.cat.Word("rain_row"), 4)+"│")+
+ x.c(Cyan, spark(series))+
+ x.c(Dim, fmt.Sprintf(" %s %.1fmm", x.cat.Word("max"), maxRain)))
+ }
+
+ every := scale * maxInt(1, ceilDiv(len(groups), 8)) // at most 8 labels
+ hours := make([]string, len(drawn))
+ for i, d := range drawn {
+ hours[i] = d.hour
+ }
+ out = append(out, x.c(Dim, strings.Repeat(" ", gutter)+axis(hours, every)))
+ out = append(out, x.c(Dim, strings.Repeat(" ", gutter)+note))
+ return out
+}
+
+// axis places hour labels under the chart, one character per column so they
+// line up. A label that would run off the end is skipped rather than printed as
+// a half label.
+func axis(hours []string, every int) string {
+ line := []rune(strings.Repeat(" ", len(hours)))
+ for i := 0; i < len(hours); i += every {
+ label := []rune(hours[i])
+ if i+len(label) > len(line) {
+ continue
+ }
+ copy(line[i:], label)
+ }
+ return string(line)
+}
+
+// buckets splits total rows into count contiguous groups.
+func buckets(count, total int) [][2]int {
+ out := make([][2]int, count)
+ for i := range out {
+ lo := i * total / count
+ hi := (i + 1) * total / count
+ if hi <= lo {
+ hi = lo + 1
+ }
+ out[i] = [2]int{lo, hi}
+ }
+ return out
+}
+
+// spark renders one block character per value, scaled to the series' own range.
+func spark(values []float64) string {
+ lo, hi := values[0], values[0]
+ for _, v := range values {
+ lo, hi = math.Min(lo, v), math.Max(hi, v)
+ }
+ if hi-lo < 1e-9 { // flat: sit on the baseline rather than divide by zero
+ return strings.Repeat(string([]rune(blocks)[0]), len(values))
+ }
+ runes := []rune(blocks)
+ step := (hi - lo) / float64(len(runes)-1)
+ var b strings.Builder
+ for _, v := range values {
+ b.WriteRune(runes[int(math.Round((v-lo)/step))])
+ }
+ return b.String()
+}
+
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
+
+func maxInt(a, b int) int {
+ if a > b {
+ return a
+ }
+ return b
+}
+
+func ceilDiv(a, b int) int { return (a + b - 1) / b }
+
+var _ = openmeteo.Row{}
diff --git a/internal/render/color.go b/internal/render/color.go
new file mode 100644
index 0000000..cf2af89
--- /dev/null
+++ b/internal/render/color.go
@@ -0,0 +1,62 @@
+package render
+
+import "strings"
+
+// Colour uses ANSI slots 0-15 only -- codes 30-37 and 90-97, plus the
+// attributes 1 (bold), 2 (dim) and 4 (underline). Never 256-colour indices:
+// this runs on terminals whose palette remaps the low slots (the phone's is
+// entirely green), where a hardcoded 38;5;196 would be the one off-palette
+// thing on screen.
+const (
+ Reset = "0"
+ Bold = "1"
+ Dim = "2"
+ Underline = "4"
+
+ Blue = "34"
+ Cyan = "36"
+ Green = "32"
+ Yellow = "33"
+ Red = "31"
+ BrightBlue = "94"
+ BrightRed = "91"
+ BrightWhite = "97"
+)
+
+// Styler returns a function that wraps text in an ANSI code, or returns it
+// unchanged when colour is off.
+func Styler(colour bool) func(code, text string) string {
+ if !colour {
+ return func(_, text string) string { return text }
+ }
+ return func(code, text string) string {
+ if code == "" {
+ return text
+ }
+ return "\033[" + code + "m" + text + "\033[0m"
+ }
+}
+
+// Cell is one character of chart output with the style it should carry.
+type Cell struct {
+ Style string
+ Text string
+}
+
+// Paint joins cells, emitting one escape sequence per run of identical style
+// rather than one per character. A per-character version makes the chart around
+// ten times larger for output that looks exactly the same.
+func Paint(cells []Cell, c func(string, string) string) string {
+ var b strings.Builder
+ for i := 0; i < len(cells); {
+ j := i
+ var run strings.Builder
+ for j < len(cells) && cells[j].Style == cells[i].Style {
+ run.WriteString(cells[j].Text)
+ j++
+ }
+ b.WriteString(c(cells[i].Style, run.String()))
+ i = j
+ }
+ return b.String()
+}
diff --git a/internal/render/icons.go b/internal/render/icons.go
new file mode 100644
index 0000000..59ae3d3
--- /dev/null
+++ b/internal/render/icons.go
@@ -0,0 +1,84 @@
+package render
+
+// Weather glyphs per icon set, keyed by WMO code group.
+//
+// The "nerd" codepoints are from the Nerd Fonts Weather range (U+E300-U+E3E3),
+// which JetBrains Mono Nerd Font and MesloLGS NF both carry. They are single
+// cell and monochrome, so they take the terminal's foreground colour and do not
+// break a remapped palette.
+//
+// The "emoji" set is colour and comes from a fallback font. Its glyphs are not
+// all one cell wide -- see width.go -- which is why every pad goes through
+// DisplayWidth.
+var iconSets = map[string]map[string]string{
+ "nerd": {
+ "clear": "",
+ "partly": "",
+ "cloudy": "",
+ "fog": "",
+ "drizzle": "",
+ "rain": "",
+ "snow": "",
+ "storm": "",
+ },
+ "emoji": {
+ "clear": "☀",
+ "partly": "⛅",
+ "cloudy": "☁",
+ "fog": "\U0001F32B",
+ "drizzle": "\U0001F326",
+ "rain": "\U0001F327",
+ "snow": "\U0001F328",
+ "storm": "⛈",
+ },
+}
+
+// iconGroup maps a WMO weather code onto a glyph group.
+func iconGroup(code int) string {
+ switch {
+ case code == 0 || code == 1:
+ return "clear"
+ case code == 2:
+ return "partly"
+ case code == 3:
+ return "cloudy"
+ case code == 45 || code == 48:
+ return "fog"
+ case code >= 51 && code <= 57:
+ return "drizzle"
+ case code >= 61 && code <= 67, code >= 80 && code <= 82:
+ return "rain"
+ case code >= 71 && code <= 77, code == 85 || code == 86:
+ return "snow"
+ case code >= 95:
+ return "storm"
+ }
+ return "cloudy"
+}
+
+// Icon returns the glyph for a weather code in the named set. An unknown set,
+// or "none", yields an empty string so the column simply renders blank.
+func Icon(set string, code int) string {
+ glyphs, ok := iconSets[set]
+ if !ok {
+ return ""
+ }
+ return glyphs[iconGroup(code)]
+}
+
+// IconWidth is the display width the icon column should reserve for a set.
+// The emoji set contains wide glyphs, so its column is two cells even for the
+// entries that happen to be one.
+func IconWidth(set string) int {
+ glyphs, ok := iconSets[set]
+ if !ok {
+ return 0
+ }
+ w := 1
+ for _, g := range glyphs {
+ if gw := DisplayWidth(g); gw > w {
+ w = gw
+ }
+ }
+ return w
+}
diff --git a/internal/render/render.go b/internal/render/render.go
new file mode 100644
index 0000000..4417763
--- /dev/null
+++ b/internal/render/render.go
@@ -0,0 +1,283 @@
+// 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]
+}
diff --git a/internal/render/render_test.go b/internal/render/render_test.go
new file mode 100644
index 0000000..484a45b
--- /dev/null
+++ b/internal/render/render_test.go
@@ -0,0 +1,287 @@
+package render
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/lukaszkasprzak/prognosis/internal/config"
+ "github.com/lukaszkasprzak/prognosis/internal/imgw"
+ "github.com/lukaszkasprzak/prognosis/internal/openmeteo"
+)
+
+// row builds one hour. mm and pop default to dry.
+func row(hour int, temp float64, code int, mm, pop float64) openmeteo.Row {
+ return openmeteo.Row{
+ When: time.Date(2026, 8, 10, hour, 0, 0, 0, time.UTC),
+ Code: code,
+ Vals: map[string]float64{
+ "temperature_2m": temp,
+ "apparent_temperature": temp,
+ "precipitation": mm,
+ "precipitation_probability": pop,
+ "weather_code": float64(code),
+ },
+ }
+}
+
+func testConfig() config.Config {
+ c := config.Default()
+ c.Columns = []string{"hour", "temp", "feels", "conditions", "mm", "rain"}
+ c.Graph = false
+ c.Icons = "none"
+ c.DisplayLang = "en"
+ return c
+}
+
+func view(rows ...openmeteo.Row) View {
+ return View{Label: "Test, PL", Rows: rows, Sun: map[string][2]string{}}
+}
+
+// On a dry day the mm and rain columns are a block of zeroes pushing the real
+// content sideways.
+func TestDryWindowHidesTheRainColumns(t *testing.T) {
+ dry := Render(view(row(12, 25, 3, 0, 0), row(13, 26, 3, 0, 5)), testConfig(), 80, false)
+ if strings.Contains(dry, "mm") || strings.Contains(dry, "rain") {
+ t.Errorf("dry window still shows rain columns:\n%s", dry)
+ }
+
+ wet := Render(view(row(12, 25, 3, 0, 0), row(13, 26, 61, 0.4, 80)), testConfig(), 80, false)
+ if !strings.Contains(wet, "mm") || !strings.Contains(wet, "rain") {
+ t.Errorf("wet window must show rain columns:\n%s", wet)
+ }
+}
+
+// Probability alone is enough: rain that has not started yet still matters.
+func TestProbabilityAloneBringsBackTheRainColumns(t *testing.T) {
+ v := view(row(12, 25, 3, 0, RainInterestPct), row(13, 26, 3, 0, RainInterestPct))
+ if out := Render(v, testConfig(), 80, false); !strings.Contains(out, "rain") {
+ t.Errorf("%d%% chance must show the columns:\n%s", RainInterestPct, out)
+ }
+ below := view(row(12, 25, 3, 0, RainInterestPct-1), row(13, 26, 3, 0, 0))
+ if out := Render(below, testConfig(), 80, false); strings.Contains(out, "rain") {
+ t.Errorf("below the threshold the columns must stay hidden:\n%s", out)
+ }
+}
+
+// An unbroken column of "overcast" hides the hour it stops being overcast,
+// which is the only interesting part.
+func TestConditionsPrintOnlyWhenTheyChange(t *testing.T) {
+ v := view(
+ row(12, 25, 3, 0, 0), // overcast
+ row(13, 25, 3, 0, 0), // still overcast: blank
+ row(14, 25, 0, 0, 0), // clear: printed
+ row(15, 25, 0, 0, 0), // still clear: blank
+ )
+ out := Render(v, testConfig(), 80, false)
+ if n := strings.Count(out, "overcast"); n != 1 {
+ t.Errorf("overcast appears %d times, want 1:\n%s", n, out)
+ }
+ if n := strings.Count(out, "clear"); n != 1 {
+ t.Errorf("clear appears %d times, want 1:\n%s", n, out)
+ }
+}
+
+// Across a day boundary the conditions are repeated once, so a reader starting
+// at the new day is not looking at a blank column.
+func TestConditionsRepeatAfterADaySeparator(t *testing.T) {
+ next := row(0, 20, 3, 0, 0)
+ next.When = time.Date(2026, 8, 11, 0, 0, 0, 0, time.UTC)
+ v := view(row(23, 22, 3, 0, 0), next)
+ v.Sun["2026-08-11"] = [2]string{"05:16", "19:59"}
+
+ out := Render(v, testConfig(), 80, false)
+ if n := strings.Count(out, "overcast"); n != 2 {
+ t.Errorf("conditions must repeat once per day, appeared %d times:\n%s", n, out)
+ }
+ if !strings.Contains(out, "Tue 11 Aug") {
+ t.Errorf("missing the day separator:\n%s", out)
+ }
+}
+
+func TestFeelsLikeOnlyWhenItDiffers(t *testing.T) {
+ same := row(12, 25, 3, 0, 0)
+ diff := row(13, 25, 3, 0, 0)
+ diff.Vals["apparent_temperature"] = 28
+
+ out := Render(view(same, diff), testConfig(), 80, false)
+ if strings.Count(out, "(") != 1 {
+ t.Errorf("feels-like must appear once, only where it differs:\n%s", out)
+ }
+ if !strings.Contains(out, "(28)") {
+ t.Errorf("expected (28):\n%s", out)
+ }
+}
+
+// The four warning states must stay distinguishable: silence read as all-clear
+// is the failure that matters.
+func TestWarningStatesAreDistinct(t *testing.T) {
+ cfg := testConfig()
+ base := view(row(12, 25, 3, 0, 0))
+
+ inForce := base
+ inForce.Warnings = []imgw.Warning{{
+ Event: "Upal", Level: "1", Probability: "85",
+ From: "2026-08-10 11:00:00", To: "2026-08-10 20:00:00",
+ Text: "Prognozuje sie upal.",
+ }}
+ out := Render(inForce, cfg, 80, false)
+ if !strings.Contains(out, "Upal") || !strings.Contains(out, "level 1") {
+ t.Errorf("a live warning must be shown:\n%s", out)
+ }
+
+ if out := Render(base, cfg, 80, false); strings.Contains(out, "warnings:") {
+ t.Errorf("checked-and-none must print nothing about warnings:\n%s", out)
+ }
+
+ failed := base
+ failed.WarnFailed = true
+ if out := Render(failed, cfg, 80, false); !strings.Contains(out, "could not check") {
+ t.Errorf("a failed check must say so, not stay silent:\n%s", out)
+ }
+
+ abroad := base
+ abroad.WarnNote = "IMGW covers Poland only"
+ if out := Render(abroad, cfg, 80, false); !strings.Contains(out, "Poland only") {
+ t.Errorf("an abroad location must explain itself:\n%s", out)
+ }
+}
+
+func TestWarningsDisabledSuppressesEvenAFailure(t *testing.T) {
+ cfg := testConfig()
+ cfg.Warnings = false
+ v := view(row(12, 25, 3, 0, 0))
+ v.WarnFailed = true
+ if out := Render(v, cfg, 80, false); strings.Contains(out, "could not check") {
+ t.Errorf("warnings=false must suppress the notice too:\n%s", out)
+ }
+}
+
+// -weather strips everything that is not the forecast.
+func TestMinimalStripsTheExtras(t *testing.T) {
+ cfg := testConfig()
+ cfg.Minimal = true
+ v := view(row(12, 25, 3, 0, 0))
+ v.Sun["2026-08-10"] = [2]string{"05:15", "20:01"}
+ v.Daily = map[string]float64{"temperature_2m_min": 12, "temperature_2m_max": 30}
+ v.Pollen = map[string]float64{"grass": 10}
+
+ out := Render(v, cfg, 80, false)
+ for _, unwanted := range []string{"sun", "up", "day", "pollen", "grass"} {
+ if strings.Contains(out, unwanted) {
+ t.Errorf("minimal output still contains %q:\n%s", unwanted, out)
+ }
+ }
+ if !strings.Contains(out, "Test, PL") || !strings.Contains(out, "25°") {
+ t.Errorf("minimal output must still carry place and forecast:\n%s", out)
+ }
+}
+
+func chartOf(t *testing.T, hours int, width int) []string {
+ t.Helper()
+ cfg := testConfig()
+ cfg.Graph = true
+ var rows []openmeteo.Row
+ for i := 0; i < hours; i++ {
+ when := time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Hour)
+ r := row(0, 20+float64(i%10), 3, 0, 0)
+ r.When = when
+ rows = append(rows, r)
+ }
+ out := Render(view(rows...), cfg, width, false)
+ return strings.Split(out, "\n")
+}
+
+// A 12-hour chart drawn one column per hour would occupy 12 of 80 columns.
+func TestChartWidensShortSpansToFillTheTerminal(t *testing.T) {
+ lines := chartOf(t, 12, 80)
+ var widest int
+ for _, l := range lines {
+ if strings.Contains(l, "│") {
+ if w := DisplayWidth(l); w > widest {
+ widest = w
+ }
+ }
+ }
+ if widest < 40 {
+ t.Fatalf("chart is only %d columns wide for a 12-hour span; it should widen", widest)
+ }
+}
+
+// A week is 168 points and would wrap into mush; columns must cover several
+// hours and the label must say so.
+func TestChartDownsamplesLongSpansAndSaysSo(t *testing.T) {
+ out := strings.Join(chartOf(t, 168, 80), "\n")
+ if !strings.Contains(out, "h/col") {
+ t.Fatalf("a downsampled chart must disclose the ratio:\n%s", out)
+ }
+ for _, l := range strings.Split(out, "\n") {
+ if w := DisplayWidth(l); w > 80 {
+ t.Fatalf("chart line is %d columns wide, wider than the terminal:\n%s", w, l)
+ }
+ }
+}
+
+func TestChartNeverExceedsTheTerminalWidth(t *testing.T) {
+ for _, width := range []int{32, 53, 80, 96} {
+ for _, hours := range []int{1, 6, 24, 72} {
+ lines := chartOf(t, hours, width)
+ // Only the chart: the table has fixed column widths and is measured
+ // separately, below.
+ for i, l := range lines {
+ isChart := strings.Contains(l, "│") ||
+ (i >= len(lines)-2 && strings.TrimSpace(l) != "")
+ if !isChart {
+ continue
+ }
+ if w := DisplayWidth(l); w > width {
+ t.Errorf("width=%d hours=%d: chart line is %d columns:\n%s", width, hours, w, l)
+ }
+ }
+ }
+ }
+}
+
+// The table has fixed column widths, so unlike the chart it does not shrink to
+// fit. This pins the width the default column set needs: the phone is 53
+// columns, so there is headroom, but a narrower terminal will wrap and there is
+// no code preventing it. Narrow the columns instead -- see -columns.
+func TestTableMinimumWidthIsKnown(t *testing.T) {
+ cfg := testConfig()
+ out := Render(view(row(12, 25, 3, 0, 0)), cfg, 80, false)
+ widest := 0
+ for _, l := range strings.Split(out, "\n") {
+ if w := DisplayWidth(l); w > widest {
+ widest = w
+ }
+ }
+ const documented = 34
+ if widest != documented {
+ t.Fatalf("the default table now needs %d columns, not the documented %d; "+
+ "update the README if this is intended", widest, documented)
+ }
+ // A narrower column set must actually be narrower, or -columns is no remedy.
+ cfg.Columns = []string{"hour", "temp", "conditions"}
+ narrow := 0
+ for _, l := range strings.Split(Render(view(row(12, 25, 3, 0, 0)), cfg, 80, false), "\n") {
+ if w := DisplayWidth(l); w > narrow {
+ narrow = w
+ }
+ }
+ if narrow >= documented {
+ t.Errorf("narrow column set is %d columns, no better than %d", narrow, documented)
+ }
+}
+
+// A label that would run off the end is skipped, never printed as half a label.
+func TestChartAxisLabelsAreWholeOrAbsent(t *testing.T) {
+ for _, hours := range []int{1, 2, 3, 5, 12, 24} {
+ lines := chartOf(t, hours, 40)
+ axis := lines[len(lines)-2] // axis sits above the range note
+ for _, field := range strings.Fields(axis) {
+ if len(field) != 2 {
+ t.Errorf("hours=%d: axis has a partial label %q in %q", hours, field, axis)
+ }
+ }
+ }
+}
diff --git a/internal/render/scale.go b/internal/render/scale.go
new file mode 100644
index 0000000..a2eb2dc
--- /dev/null
+++ b/internal/render/scale.go
@@ -0,0 +1,92 @@
+package render
+
+import "math"
+
+// Temperature bands. The two ends are IMGW's own warning criteria, so a red
+// temperature means the met office would issue a warning about it rather than
+// that it looked hot to whoever wrote this:
+//
+// Tmin <= -15 silny mroz, stopien 1 -> bright blue
+// Tmax >= 30 upal, stopien 1 -> red
+// Tmax > 35 the higher heat level -> bright red
+//
+// The splits between (0, 10, 20) are round numbers, not thresholds from any
+// source; they only subdivide the range nobody warns about.
+var tempBands = []struct {
+ below float64
+ code string
+}{
+ {0, Blue}, {10, Cyan}, {20, Green}, {30, Yellow},
+}
+
+// Deg rounds to whole degrees. Rounding through int conversion avoids "-0",
+// which is arithmetically fine and visually wrong.
+func Deg(t float64) int {
+ return int(math.Round(t))
+}
+
+// TempStyle is the ANSI code for a temperature in Celsius.
+//
+// The warning edges are written out rather than folded into tempBands so they
+// match IMGW's criteria exactly, inclusive and exclusive included.
+//
+// The value is rounded first, so the colour always matches the number printed
+// beside it: otherwise -14.6 prints as "-15" in a different colour than a true
+// -15 and looks like a rendering bug.
+func TempStyle(celsius float64) string {
+ t := float64(Deg(celsius))
+ switch {
+ case t <= -15:
+ return BrightBlue
+ case t > 35:
+ return BrightRed
+ case t >= 30:
+ return Red
+ }
+ for _, b := range tempBands {
+ if t < b.below {
+ return b.code
+ }
+ }
+ return Yellow
+}
+
+// Pollen bands in grains/m3, from Polish clinical sources. Each entry is an
+// upper bound (exclusive) and a band key; a nil bound means "everything above".
+//
+// grass -- alergen.info.pl symptom table: 20 = first nasal symptoms in 25%
+// of sufferers, 50 = symptoms in all tested, 65 = intensified in
+// over 75%, 120 = dyspnoea after 30 minutes of exposure.
+// birch -- mp.pl: 80 provokes symptoms in over 95% of allergics.
+// mugwort -- mp.pl: over 70 counts as high, intensified symptoms.
+//
+// Birch and mugwort have a single published anchor each, so they get a two-way
+// split rather than four bands: their "low" is weaker evidence than grass's.
+// Alder, olive and ragweed have no Polish threshold that could be sourced and
+// are deliberately left unbanded rather than banded on a guess.
+var pollenBands = map[string][]struct {
+ below float64
+ band string
+}{
+ "grass": {{20, "low"}, {50, "medium"}, {65, "high"}, {math.Inf(1), "very high"}},
+ "birch": {{80, "low"}, {math.Inf(1), "high"}},
+ "mugwort": {{70, "low"}, {math.Inf(1), "high"}},
+}
+
+// PollenBand is the qualitative level for a count, or "" where no threshold
+// exists for that species.
+func PollenBand(species string, value float64) string {
+ if value < 1 {
+ return "none"
+ }
+ bands, ok := pollenBands[species]
+ if !ok {
+ return ""
+ }
+ for _, b := range bands {
+ if value < b.below {
+ return b.band
+ }
+ }
+ return ""
+}
diff --git a/internal/render/scale_test.go b/internal/render/scale_test.go
new file mode 100644
index 0000000..9754bdc
--- /dev/null
+++ b/internal/render/scale_test.go
@@ -0,0 +1,57 @@
+package render
+
+import (
+ "testing"
+
+ "github.com/lukaszkasprzak/prognosis/internal/config"
+)
+
+// IMGW's criteria are in Celsius. Switching the display to Fahrenheit must not
+// move the temperature at which the met office is said to warn.
+func TestThresholdsAreComparedInCelsiusWhateverTheDisplayUnits(t *testing.T) {
+ metric := ctx{cfg: config.Config{Units: "metric"}}
+ imperial := ctx{cfg: config.Config{Units: "imperial"}}
+
+ cases := []struct {
+ celsius float64
+ fahrenheit float64
+ want string
+ why string
+ }{
+ {29, 84.2, Yellow, "below the upal threshold"},
+ {30, 86, Red, "upal stopien 1 is Tmax >= 30C"},
+ {36, 96.8, BrightRed, "the higher heat level is Tmax > 35C"},
+ {-15, 5, BrightBlue, "silny mroz stopien 1 is Tmin <= -15C"},
+ {-14, 6.8, Blue, "just above the frost threshold"},
+ }
+ for _, c := range cases {
+ if got := TempStyle(metric.celsius(c.celsius)); got != c.want {
+ t.Errorf("%.0fC -> %s, want %s (%s)", c.celsius, got, c.want, c.why)
+ }
+ if got := TempStyle(imperial.celsius(c.fahrenheit)); got != c.want {
+ t.Errorf("%.1fF (=%.0fC) -> %s, want %s (%s)",
+ c.fahrenheit, c.celsius, got, c.want, c.why)
+ }
+ }
+}
+
+func TestPollenBandEdges(t *testing.T) {
+ cases := []struct {
+ species string
+ value float64
+ want string
+ }{
+ {"grass", 0.5, "none"}, {"grass", 19, "low"}, {"grass", 20, "medium"},
+ {"grass", 49, "medium"}, {"grass", 50, "high"}, {"grass", 64, "high"},
+ {"grass", 65, "very high"}, {"grass", 200, "very high"},
+ {"birch", 79, "low"}, {"birch", 80, "high"},
+ {"mugwort", 69, "low"}, {"mugwort", 70, "high"},
+ {"ragweed", 50, ""}, // no Polish threshold sourced: deliberately unbanded
+ {"alder", 500, ""},
+ }
+ for _, c := range cases {
+ if got := PollenBand(c.species, c.value); got != c.want {
+ t.Errorf("PollenBand(%q, %v) = %q, want %q", c.species, c.value, got, c.want)
+ }
+ }
+}
diff --git a/internal/render/table.go b/internal/render/table.go
new file mode 100644
index 0000000..3ade296
--- /dev/null
+++ b/internal/render/table.go
@@ -0,0 +1,240 @@
+package render
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/lukaszkasprzak/prognosis/internal/openmeteo"
+)
+
+// cell is one rendered table cell: text, its colour, and how it is aligned.
+type cell struct {
+ text string
+ style string
+ left bool
+}
+
+// colWidth is the reserved display width per column.
+func (x ctx) colWidth(name string) int {
+ switch name {
+ case "hour":
+ // Three, not two: the Python leaves a double space after the hour.
+ return 3
+ case "icon":
+ return IconWidth(x.cfg.Icons)
+ case "temp":
+ return 5
+ case "feels":
+ return 6
+ case "conditions":
+ return 16
+ case "mm", "rain", "wind", "gusts", "humidity", "dew", "uv", "cloud", "visibility":
+ return 5
+ case "dir":
+ return 3
+ case "pressure":
+ return 6
+ }
+ return 6
+}
+
+func (x ctx) leftAligned(name string) bool {
+ switch name {
+ case "hour", "icon", "temp", "feels", "conditions":
+ return true
+ }
+ return false
+}
+
+// visible drops columns that have nothing to say: the icon column when icons
+// are off, and the rain pair on a dry window.
+func (x ctx) visible() []string {
+ var out []string
+ for _, name := range x.cfg.Columns {
+ if name == "icon" && x.cfg.Icons == "none" {
+ continue
+ }
+ if (name == "mm" || name == "rain") && !x.wet {
+ continue
+ }
+ out = append(out, name)
+ }
+ return out
+}
+
+// row assembles one line from cells, padding each to its column width BEFORE
+// colouring it. Escape sequences carry no display width, so padding a coloured
+// string misaligns every column to its right -- invisible when piped, obvious
+// in a terminal.
+func (x ctx) row(cells map[string]cell) string {
+ var b strings.Builder
+ for _, name := range x.visible() {
+ c := cells[name]
+ w := x.colWidth(name)
+ padded := PadLeft(c.text, w)
+ if x.leftAligned(name) {
+ padded = Pad(c.text, w)
+ }
+ b.WriteString(" ")
+ b.WriteString(x.c(c.style, padded))
+ }
+ return strings.TrimRight(b.String(), " ")
+}
+
+func (x ctx) table(v View) []string {
+ out := []string{""}
+
+ headers := map[string]cell{}
+ for _, name := range x.visible() {
+ headers[name] = cell{text: x.cat.Header(name), style: ""}
+ }
+ out = append(out, x.c(Underline, x.rowPlain(headers)))
+
+ day := v.Rows[0].When.Format("2006-01-02")
+ prevCode := -1
+ for i, r := range v.Rows {
+ if d := r.When.Format("2006-01-02"); d != day {
+ day = d
+ sep := " -- " + x.cat.Date(r.When) + " --"
+ if sun, ok := v.Sun[d]; ok {
+ sep += fmt.Sprintf(" %s %s / %s", x.cat.Word("sun"), sun[0], sun[1])
+ }
+ out = append(out, x.c(Dim, sep))
+ prevCode = -1 // repeat the conditions once per day for context
+ }
+ out = append(out, x.row(x.cells(r, i == 0, &prevCode)))
+ }
+ return out
+}
+
+// rowPlain is the header row: padded like the data but never coloured per cell,
+// so the underline runs unbroken across it.
+func (x ctx) rowPlain(cells map[string]cell) string {
+ var b strings.Builder
+ for _, name := range x.visible() {
+ w := x.colWidth(name)
+ text := cells[name].text
+ padded := PadLeft(text, w)
+ if x.leftAligned(name) {
+ padded = Pad(text, w)
+ }
+ b.WriteString(" ")
+ b.WriteString(padded)
+ }
+ return b.String()
+}
+
+func (x ctx) cells(r openmeteo.Row, isNow bool, prevCode *int) map[string]cell {
+ out := map[string]cell{}
+ temp, hasTemp := r.Val("temperature_2m")
+
+ for _, name := range x.visible() {
+ switch name {
+ case "hour":
+ style := Reset
+ if isNow {
+ style = Bold
+ }
+ out[name] = cell{text: r.When.Format("15"), style: style}
+
+ case "icon":
+ out[name] = cell{text: Icon(x.cfg.Icons, r.Code)}
+
+ case "temp":
+ style := TempStyle(x.celsius(temp))
+ // The current hour keeps its emphasis on top of the heat colour.
+ if isNow {
+ style = Bold + ";" + style
+ }
+ out[name] = cell{text: fmt.Sprintf("%d°", Deg(temp)), style: style}
+
+ case "feels":
+ text := ""
+ if feels, ok := r.Val("apparent_temperature"); ok && hasTemp {
+ // Only shown when it differs; otherwise it is a column of noise.
+ if abs(feels-temp) >= 1 {
+ text = fmt.Sprintf("(%d)", Deg(feels))
+ }
+ }
+ out[name] = cell{text: text, style: Dim}
+
+ case "conditions":
+ text := ""
+ // Only when they change: an unbroken column of "overcast" hides the
+ // hour it stops being overcast, which is the only interesting part.
+ if r.Code != *prevCode {
+ text = Truncate(x.cat.Condition(r.Code), x.colWidth(name))
+ }
+ out[name] = cell{text: text}
+
+ case "mm":
+ mm, _ := r.Val("precipitation")
+ style := Dim
+ if mm > 0 {
+ style = Cyan
+ }
+ out[name] = cell{text: round1(mm), style: style}
+
+ case "rain":
+ p, _ := r.Val("precipitation_probability")
+ style := Dim
+ if p >= 50 {
+ style = Yellow
+ }
+ out[name] = cell{text: fmt.Sprintf("%d%%", int(p)), style: style}
+
+ case "wind":
+ v, _ := r.Val("wind_speed_10m")
+ out[name] = cell{text: fmt.Sprintf("%d", int(v))}
+
+ case "gusts":
+ v, _ := r.Val("wind_gusts_10m")
+ style := ""
+ if v >= 60 {
+ style = Yellow
+ }
+ out[name] = cell{text: fmt.Sprintf("%d", int(v)), style: style}
+
+ case "dir":
+ v, _ := r.Val("wind_direction_10m")
+ out[name] = cell{text: compass(v)}
+
+ case "humidity":
+ v, _ := r.Val("relative_humidity_2m")
+ out[name] = cell{text: fmt.Sprintf("%d%%", int(v)), style: Dim}
+
+ case "dew":
+ v, _ := r.Val("dew_point_2m")
+ out[name] = cell{text: fmt.Sprintf("%d°", Deg(v)), style: Dim}
+
+ case "uv":
+ v, _ := r.Val("uv_index")
+ style := Dim
+ if v >= 6 {
+ style = Yellow
+ }
+ out[name] = cell{text: round1(v), style: style}
+
+ case "cloud":
+ v, _ := r.Val("cloud_cover")
+ out[name] = cell{text: fmt.Sprintf("%d%%", int(v)), style: Dim}
+
+ case "pressure":
+ v, _ := r.Val("pressure_msl")
+ out[name] = cell{text: fmt.Sprintf("%d", int(v)), style: Dim}
+
+ case "visibility":
+ v, _ := r.Val("visibility")
+ out[name] = cell{text: fmt.Sprintf("%.0fkm", v/1000), style: Dim}
+ }
+ }
+ *prevCode = r.Code
+ return out
+}
+
+func abs(f float64) float64 {
+ if f < 0 {
+ return -f
+ }
+ return f
+}
diff --git a/internal/render/width.go b/internal/render/width.go
new file mode 100644
index 0000000..c5676d5
--- /dev/null
+++ b/internal/render/width.go
@@ -0,0 +1,110 @@
+package render
+
+import (
+ "strings"
+ "unicode"
+)
+
+// wideRanges are the code point ranges this program can emit that occupy two
+// terminal cells. Only the sets we actually produce are covered -- our own icon
+// glyphs, plus the CJK blocks a place name could contain -- rather than the
+// whole Unicode width table, which would be a dependency or a large generated
+// file for no gain.
+var wideRanges = [][2]rune{
+ {0x1100, 0x115F}, // Hangul Jamo
+ {0x2329, 0x232A},
+ {0x2E80, 0x303E}, // CJK radicals, Kangxi
+ {0x3041, 0x33FF}, // kana, CJK compatibility
+ {0x3400, 0x4DBF}, // CJK extension A
+ {0x4E00, 0x9FFF}, // CJK unified
+ {0xA000, 0xA4CF}, // Yi
+ {0xAC00, 0xD7A3}, // Hangul syllables
+ {0xF900, 0xFAFF}, // CJK compatibility ideographs
+ {0xFE30, 0xFE6F}, // CJK compatibility forms
+ {0xFF00, 0xFF60}, // fullwidth forms
+ {0xFFE0, 0xFFE6},
+ {0x1F300, 0x1F64F}, // emoji: weather, faces
+ {0x1F680, 0x1F6FF}, // emoji: transport and symbols
+ {0x1F900, 0x1F9FF}, // supplemental symbols
+ {0x26C4, 0x26C8}, // snowman, thundercloud
+ {0x2614, 0x2615}, // umbrella with rain, hot beverage
+}
+
+// ambiguousWide lists the individual code points we emit whose East-Asian width
+// is Ambiguous but which terminals in this estate render as two cells.
+var ambiguousWide = map[rune]bool{
+ 0x26C5: true, // sun behind cloud
+ 0x26C8: true, // thunder cloud and rain
+}
+
+func runeWidth(r rune) int {
+ switch {
+ case r == 0xFE0F:
+ // Variation Selector-16 requests emoji presentation. It has no width of
+ // its own; its effect is already counted on the base rune.
+ return 0
+ case r == 0xFE0E:
+ return 0
+ case unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r) || unicode.Is(unicode.Cf, r):
+ return 0 // combining and formatting marks occupy no cell
+ case r == '‍':
+ return 0 // zero-width joiner
+ case r < 0x20:
+ return 0
+ case ambiguousWide[r]:
+ return 2
+ }
+ for _, rng := range wideRanges {
+ if r >= rng[0] && r <= rng[1] {
+ return 2
+ }
+ }
+ return 1
+}
+
+// DisplayWidth is the number of terminal cells a string occupies.
+//
+// Neither len() nor a rune count will do: an emoji may be two cells, a
+// variation selector is zero, and combining marks are zero. Padding with the
+// wrong number shears every column to the right of it -- and only in a real
+// terminal, never when the output is piped, which is what makes it easy to miss.
+func DisplayWidth(s string) int {
+ w := 0
+ for _, r := range s {
+ w += runeWidth(r)
+ }
+ return w
+}
+
+// Pad returns s padded with spaces to at least w display cells (left aligned).
+func Pad(s string, w int) string {
+ if n := w - DisplayWidth(s); n > 0 {
+ return s + strings.Repeat(" ", n)
+ }
+ return s
+}
+
+// PadLeft returns s padded with spaces to at least w display cells (right aligned).
+func PadLeft(s string, w int) string {
+ if n := w - DisplayWidth(s); n > 0 {
+ return strings.Repeat(" ", n) + s
+ }
+ return s
+}
+
+// Truncate cuts s to at most w display cells, never splitting a rune.
+func Truncate(s string, w int) string {
+ if DisplayWidth(s) <= w {
+ return s
+ }
+ out, used := make([]rune, 0, len(s)), 0
+ for _, r := range s {
+ rw := runeWidth(r)
+ if used+rw > w {
+ break
+ }
+ out = append(out, r)
+ used += rw
+ }
+ return string(out)
+}
diff --git a/internal/render/width_test.go b/internal/render/width_test.go
new file mode 100644
index 0000000..f20038e
--- /dev/null
+++ b/internal/render/width_test.go
@@ -0,0 +1,112 @@
+package render
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestDisplayWidth(t *testing.T) {
+ cases := []struct {
+ name string
+ in string
+ want int
+ }{
+ {"ascii", "temp", 4},
+ {"empty", "", 0},
+ {"degree sign is one cell", "28°", 3},
+ {"polish diacritics are one cell each", "słońce", 6},
+ {"emoji with variation selector counts once", "☀️", 1},
+ {"bare BMP symbol", "☀", 1},
+ {"sun behind cloud is wide", "⛅", 2},
+ {"rain cloud is wide", "\U0001F327", 2},
+ {"nerd font glyph is one cell", "", 1},
+ {"block drawing is one cell", "█", 1},
+ {"combining acute adds nothing", "é", 1},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ if got := DisplayWidth(c.in); got != c.want {
+ t.Errorf("DisplayWidth(%q) = %d, want %d", c.in, got, c.want)
+ }
+ })
+ }
+}
+
+// The invariant that actually matters: whatever goes in a column, the padded
+// result occupies exactly the requested number of cells. If this fails, every
+// column to the right shears -- but only in a real terminal.
+func TestPadReachesExactWidthForEveryIconSet(t *testing.T) {
+ samples := []string{
+ "clear", "", "28°", "słońce",
+ "☀️", "⛅", "\U0001F327", "⛈", // emoji set
+ "", "", "", // nerd set
+ }
+ for _, s := range samples {
+ for _, w := range []int{1, 4, 8, 16} {
+ got := Pad(s, w)
+ if DisplayWidth(s) <= w && DisplayWidth(got) != w {
+ t.Errorf("Pad(%q, %d) has width %d, want %d", s, w, DisplayWidth(got), w)
+ }
+ if !strings.HasPrefix(got, s) {
+ t.Errorf("Pad(%q, %d) = %q, must not alter the content", s, w, got)
+ }
+ }
+ }
+}
+
+func TestPadLeft(t *testing.T) {
+ if got := PadLeft("5", 3); got != " 5" {
+ t.Fatalf("PadLeft = %q, want %q", got, " 5")
+ }
+ if got := PadLeft("⛅", 4); DisplayWidth(got) != 4 {
+ t.Fatalf("PadLeft of a wide glyph has width %d, want 4", DisplayWidth(got))
+ }
+}
+
+func TestPadDoesNotShrink(t *testing.T) {
+ if got := Pad("conditions", 4); got != "conditions" {
+ t.Fatalf("Pad must never truncate: got %q", got)
+ }
+}
+
+func TestTruncateNeverSplitsARune(t *testing.T) {
+ if got := Truncate("słońce", 3); got != "sło" {
+ t.Errorf("Truncate = %q, want %q", got, "sło")
+ }
+ // A wide glyph that does not fit is dropped whole, not halved.
+ if got := Truncate("a⛅", 2); got != "a" {
+ t.Errorf("Truncate = %q, want %q", got, "a")
+ }
+}
+
+func TestPaintGroupsRuns(t *testing.T) {
+ c := Styler(true)
+ cells := []Cell{
+ {Style: "31", Text: "a"}, {Style: "31", Text: "b"}, {Style: "31", Text: "c"},
+ {Style: "33", Text: "d"},
+ }
+ got := Paint(cells, c)
+ if n := strings.Count(got, "\033["); n != 4 { // 2 opens + 2 resets
+ t.Fatalf("expected one escape pair per run, got %d escapes in %q", n, got)
+ }
+ if strings.Count(got, "\033[31m") != 1 {
+ t.Errorf("the three red cells must share one escape: %q", got)
+ }
+}
+
+func TestPaintWithoutColourIsPlain(t *testing.T) {
+ c := Styler(false)
+ got := Paint([]Cell{{Style: "31", Text: "a"}, {Style: "33", Text: "b"}}, c)
+ if got != "ab" {
+ t.Fatalf("colour off must yield plain text, got %q", got)
+ }
+}
+
+// Colour must never reach for a 256-colour index.
+func TestNoExtendedColourCodes(t *testing.T) {
+ for _, code := range []string{Blue, Cyan, Green, Yellow, Red, BrightBlue, BrightRed, BrightWhite} {
+ if strings.Contains(code, "38;5;") || strings.Contains(code, "48;5;") {
+ t.Errorf("%q is a 256-colour index; only slots 0-15 are allowed", code)
+ }
+ }
+}