diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-13 13:04:10 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-13 13:04:10 +0200 |
| commit | e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b (patch) | |
| tree | 670ef0897839871a64d3a3bb2e17e242e7d6c385 /internal/render/width.go | |
| download | prognosis-e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b.tar.gz prognosis-e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b.zip | |
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.
Diffstat (limited to 'internal/render/width.go')
| -rw-r--r-- | internal/render/width.go | 110 |
1 files changed, 110 insertions, 0 deletions
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) +} |
