From e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 13 Aug 2026 13:04:10 +0200 Subject: Initial commit: prognosis, the Go implementation An hour-by-hour forecast for the terminal, with official IMGW warnings for Polish locations. Replaces the Python version, whose cache file format it keeps so the two can coexist until this reaches parity. Open-Meteo provides the forecast, geocoding and pollen; GUGiK turns coordinates into a TERYT powiat code; IMGW supplies the warnings, filtered to that powiat rather than the whole country. Only the two lookups that never change are cached. Forecasts never are. Silence is never allowed to read as all-clear: "no warnings in force" and "the check failed" are reported as distinct states. Place names are resolved without guessing. A name matching several places is refused with a numbered list carrying each candidate's region and coordinates, and -pick N chooses one and remembers it. A stray positional beside -l is an error, so an unquoted "Wiry, PL" cannot silently resolve to somewhere else. The cache is written one entry per line with sorted keys, and treated as disposable but not worthless: an entry that will not parse is skipped and the rest kept, and a file that will not parse at all is moved to cache.json.bad rather than overwritten. No third-party dependencies. `make ci` is the gate: gofmt clean, vet, tests. --- internal/render/ascii.go | 67 ++++++++++ internal/render/ascii_test.go | 55 ++++++++ internal/render/chart.go | 205 +++++++++++++++++++++++++++++ internal/render/color.go | 62 +++++++++ internal/render/icons.go | 84 ++++++++++++ internal/render/render.go | 283 ++++++++++++++++++++++++++++++++++++++++ internal/render/render_test.go | 287 +++++++++++++++++++++++++++++++++++++++++ internal/render/scale.go | 92 +++++++++++++ internal/render/scale_test.go | 57 ++++++++ internal/render/table.go | 240 ++++++++++++++++++++++++++++++++++ internal/render/width.go | 110 ++++++++++++++++ internal/render/width_test.go | 112 ++++++++++++++++ 12 files changed, 1654 insertions(+) create mode 100644 internal/render/ascii.go create mode 100644 internal/render/ascii_test.go create mode 100644 internal/render/chart.go create mode 100644 internal/render/color.go create mode 100644 internal/render/icons.go create mode 100644 internal/render/render.go create mode 100644 internal/render/render_test.go create mode 100644 internal/render/scale.go create mode 100644 internal/render/scale_test.go create mode 100644 internal/render/table.go create mode 100644 internal/render/width.go create mode 100644 internal/render/width_test.go (limited to 'internal/render') 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) + } + } +} -- cgit v1.3