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) }