diff options
Diffstat (limited to 'internal/render')
| -rw-r--r-- | internal/render/ascii.go | 67 | ||||
| -rw-r--r-- | internal/render/ascii_test.go | 55 | ||||
| -rw-r--r-- | internal/render/chart.go | 205 | ||||
| -rw-r--r-- | internal/render/color.go | 62 | ||||
| -rw-r--r-- | internal/render/icons.go | 84 | ||||
| -rw-r--r-- | internal/render/render.go | 283 | ||||
| -rw-r--r-- | internal/render/render_test.go | 287 | ||||
| -rw-r--r-- | internal/render/scale.go | 92 | ||||
| -rw-r--r-- | internal/render/scale_test.go | 57 | ||||
| -rw-r--r-- | internal/render/table.go | 240 | ||||
| -rw-r--r-- | internal/render/width.go | 110 | ||||
| -rw-r--r-- | internal/render/width_test.go | 112 |
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) + } + } +} |
