aboutsummaryrefslogtreecommitdiff
path: root/internal/render/width.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/render/width.go')
-rw-r--r--internal/render/width.go110
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)
+}