aboutsummaryrefslogtreecommitdiff
path: root/internal/color
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-16 23:37:50 -0400
committerhistoria <[not public]>2026-06-16 23:37:50 -0400
commit389e307f205f9b7edf604fb9f86e1038ead736ef (patch)
tree595095b5877cc2b0513d31fb126303d8796cc311 /internal/color
parent085e728d22a3369bd110b77409914cfbe9ebe611 (diff)
downloadthehouseoficarus-389e307f205f9b7edf604fb9f86e1038ead736ef.tar.gz
feat: major xterm256 color overhaul, see worldbuilding docs.
Diffstat (limited to 'internal/color')
-rw-r--r--internal/color/color.go475
1 files changed, 355 insertions, 120 deletions
diff --git a/internal/color/color.go b/internal/color/color.go
index 4ef66b8..9496a1d 100644
--- a/internal/color/color.go
+++ b/internal/color/color.go
@@ -2,185 +2,420 @@ package color
import (
"fmt"
+ "math"
+ "regexp"
+ "strconv"
"strings"
)
const Reset = "\033[0m"
-var ansiFg = map[string]int{
- "black": 30,
- "red": 31,
- "green": 32,
- "yellow": 33,
- "blue": 34,
- "magenta": 35,
- "cyan": 36,
- "white": 37,
-}
-
-var ansiBg = map[string]int{
- "black": 40,
- "red": 41,
- "green": 42,
- "yellow": 43,
- "blue": 44,
- "magenta": 45,
- "cyan": 46,
- "white": 47,
-}
-
-var ansiBrightFg = map[string]int{
- "bright_black": 90,
- "bright_red": 91,
- "bright_green": 92,
- "bright_yellow": 93,
- "bright_blue": 94,
- "bright_magenta": 95,
- "bright_cyan": 96,
- "bright_white": 97,
-}
-
-var ansiBrightBg = map[string]int{
- "bright_black": 100,
- "bright_red": 101,
- "bright_green": 102,
- "bright_yellow": 103,
- "bright_blue": 104,
- "bright_magenta": 105,
- "bright_cyan": 106,
- "bright_white": 107,
-}
-
-var xtermFg = map[string]int{
- "black": 0,
- "red": 1,
- "green": 2,
- "yellow": 3,
- "blue": 4,
- "magenta": 5,
- "cyan": 6,
- "white": 7,
- "bright_black": 8,
- "bright_red": 9,
- "bright_green": 10,
- "bright_yellow": 11,
- "bright_blue": 12,
- "bright_magenta": 13,
- "bright_cyan": 14,
- "bright_white": 15,
-}
-
-var ansiStyles = map[string]string{
- "bold": "\033[1m",
- "dim": "\033[2m",
- "italic": "\033[3m",
- "underline": "\033[4m",
-}
-
-func Fg(mode, name string) string {
- if mode == "none" || mode == "" {
- return ""
+var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
+
+var ansi16RGB = [16][3]int{
+ {0, 0, 0}, // 0 black
+ {170, 0, 0}, // 1 red
+ {0, 170, 0}, // 2 green
+ {170, 170, 0}, // 3 yellow
+ {0, 0, 170}, // 4 blue
+ {170, 0, 170}, // 5 magenta
+ {0, 170, 170}, // 6 cyan
+ {170, 170, 170}, // 7 white
+ {85, 85, 85}, // 8 bright black
+ {255, 85, 85}, // 9 bright red
+ {85, 255, 85}, // 10 bright green
+ {255, 255, 85}, // 11 bright yellow
+ {85, 85, 255}, // 12 bright blue
+ {255, 85, 255}, // 13 bright magenta
+ {85, 255, 255}, // 14 bright cyan
+ {255, 255, 255}, // 15 bright white
+}
+
+var cubeValues = [6]int{0, 95, 135, 175, 215, 255}
+
+func Xterm256ToRGB(index int) (int, int, int) {
+ if index < 16 {
+ return ansi16RGB[index][0], ansi16RGB[index][1], ansi16RGB[index][2]
}
- if mode == "xterm256" {
- if c, ok := xtermFg[name]; ok {
- return fmt.Sprintf("\033[38;5;%dm", c)
+ if index < 232 {
+ i := index - 16
+ b := cubeValues[i%6]
+ g := cubeValues[(i/6)%6]
+ r := cubeValues[(i/36)%6]
+ return r, g, b
+ }
+ v := 8 + (index-232)*10
+ return v, v, v
+}
+
+func colorDist(r1, g1, b1, r2, g2, b2 int) int {
+ dr := r1 - r2
+ dg := g1 - g2
+ db := b1 - b2
+ return dr*dr + dg*dg + db*db
+}
+
+func nearestANSI(index int) int {
+ if index < 16 {
+ if index < 8 {
+ return 30 + index
}
- return ""
+ return 90 + (index - 8)
}
- if c, ok := ansiFg[name]; ok {
- return fmt.Sprintf("\033[%dm", c)
+ r, g, b := Xterm256ToRGB(index)
+ bestDist := math.MaxInt
+ bestCode := 37
+ for i := 0; i < 16; i++ {
+ dist := colorDist(r, g, b, ansi16RGB[i][0], ansi16RGB[i][1], ansi16RGB[i][2])
+ if dist < bestDist {
+ bestDist = dist
+ if i < 8 {
+ bestCode = 30 + i
+ } else {
+ bestCode = 90 + (i - 8)
+ }
+ }
}
- if c, ok := ansiBrightFg[name]; ok {
- return fmt.Sprintf("\033[%dm", c)
+ return bestCode
+}
+
+func nearestANSIBg(index int) int {
+ fg := nearestANSI(index)
+ if fg >= 90 {
+ return fg - 90 + 100
}
- return ""
+ return fg - 30 + 40
+}
+
+func nearestCubeComponent(v int) int {
+ if v < 48 {
+ return 0
+ }
+ if v < 115 {
+ return 1
+ }
+ if v < 155 {
+ return 2
+ }
+ if v < 195 {
+ return 3
+ }
+ if v < 235 {
+ return 4
+ }
+ return 5
}
-func Bg(mode, name string) string {
- if mode == "none" || mode == "" {
+func NearestXterm256(r, g, b int) int {
+ ri := nearestCubeComponent(r)
+ gi := nearestCubeComponent(g)
+ bi := nearestCubeComponent(b)
+ cubeIdx := 16 + ri*36 + gi*6 + bi
+ cubeDist := colorDist(r, g, b, cubeValues[ri], cubeValues[gi], cubeValues[bi])
+
+ avg := (r + g + b) / 3
+ grayStep := (avg - 8 + 5) / 10
+ if grayStep < 0 {
+ grayStep = 0
+ }
+ if grayStep > 23 {
+ grayStep = 23
+ }
+ grayIdx := 232 + grayStep
+ gv := 8 + grayStep*10
+ grayDist := colorDist(r, g, b, gv, gv, gv)
+
+ bestIdx := cubeIdx
+ bestDist := cubeDist
+ if grayDist < bestDist {
+ bestIdx = grayIdx
+ bestDist = grayDist
+ }
+
+ for i := 0; i < 16; i++ {
+ d := colorDist(r, g, b, ansi16RGB[i][0], ansi16RGB[i][1], ansi16RGB[i][2])
+ if d < bestDist {
+ bestDist = d
+ bestIdx = i
+ }
+ }
+
+ return bestIdx
+}
+
+func FgCode(mode string, index int) string {
+ if mode == "none" || mode == "" || index < 0 || index > 255 {
return ""
}
if mode == "xterm256" {
- if c, ok := xtermFg[name]; ok {
- return fmt.Sprintf("\033[48;5;%dm", c)
- }
+ return fmt.Sprintf("\033[38;5;%dm", index)
+ }
+ return fmt.Sprintf("\033[%dm", nearestANSI(index))
+}
+
+func BgCode(mode string, index int) string {
+ if mode == "none" || mode == "" || index < 0 || index > 255 {
return ""
}
- if c, ok := ansiBg[name]; ok {
- return fmt.Sprintf("\033[%dm", c)
+ if mode == "xterm256" {
+ return fmt.Sprintf("\033[48;5;%dm", index)
}
- if c, ok := ansiBrightBg[name]; ok {
- return fmt.Sprintf("\033[%dm", c)
+ return fmt.Sprintf("\033[%dm", nearestANSIBg(index))
+}
+
+func StyleCode(name string) string {
+ switch name {
+ case "bold":
+ return "\033[1m"
+ case "dim":
+ return "\033[2m"
+ case "underline":
+ return "\033[4m"
}
return ""
}
-func Style(name string) string {
- if s, ok := ansiStyles[name]; ok {
- return s
+func VisibleLen(s string) int {
+ return len(ansiRe.ReplaceAllString(s, ""))
+}
+
+func ContrastFg(mode string, bgIndex int) string {
+ r, g, b := Xterm256ToRGB(bgIndex)
+ if r+g+b > 384 {
+ return FgCode(mode, 0)
}
- return ""
+ return FgCode(mode, 15)
}
type ColorSpec struct {
- Fg string
- Bg string
+ Fg int
+ Bg int
Bold bool
Dim bool
- Italic bool
Underline bool
+ Gradient []int
+}
+
+func NoColor() ColorSpec {
+ return ColorSpec{Fg: -1, Bg: -1}
}
func Parse(input string) ColorSpec {
- var spec ColorSpec
+ spec := ColorSpec{Fg: -1, Bg: -1}
for _, token := range strings.Fields(input) {
switch {
case token == "bold":
spec.Bold = true
case token == "dim":
spec.Dim = true
- case token == "italic":
- spec.Italic = true
case token == "underline":
spec.Underline = true
- case strings.HasPrefix(token, "fg="):
- spec.Fg = strings.TrimPrefix(token, "fg=")
- case strings.HasPrefix(token, "bg="):
- spec.Bg = strings.TrimPrefix(token, "bg=")
+ case strings.HasPrefix(token, "bg:"):
+ if n, err := strconv.Atoi(strings.TrimPrefix(token, "bg:")); err == nil && n >= 0 && n <= 255 {
+ spec.Bg = n
+ }
+ case strings.HasPrefix(token, "g:"):
+ parts := strings.Split(token[2:], ",")
+ var stops []int
+ for _, p := range parts {
+ if n, err := strconv.Atoi(strings.TrimSpace(p)); err == nil && n >= 0 && n <= 255 {
+ stops = append(stops, n)
+ }
+ }
+ if len(stops) >= 2 {
+ spec.Gradient = stops
+ }
+ default:
+ if n, err := strconv.Atoi(token); err == nil && n >= 0 && n <= 255 {
+ spec.Fg = n
+ }
}
}
return spec
}
func (s ColorSpec) Empty() bool {
- return s.Fg == "" && s.Bg == "" && !s.Bold && !s.Dim && !s.Italic && !s.Underline
+ return s.Fg < 0 && s.Bg < 0 && !s.Bold && !s.Dim && !s.Underline && len(s.Gradient) == 0
}
-func Render(mode string, spec ColorSpec, text string) string {
- if mode == "none" || mode == "" || spec.Empty() {
- return text
- }
+func stylePrefix(spec ColorSpec) string {
var codes []string
if spec.Bold {
- codes = append(codes, Style("bold"))
+ codes = append(codes, StyleCode("bold"))
}
if spec.Dim {
- codes = append(codes, Style("dim"))
- }
- if spec.Italic {
- codes = append(codes, Style("italic"))
+ codes = append(codes, StyleCode("dim"))
}
if spec.Underline {
- codes = append(codes, Style("underline"))
+ codes = append(codes, StyleCode("underline"))
+ }
+ return strings.Join(codes, "")
+}
+
+func Render(mode string, spec ColorSpec, text string) string {
+ if mode == "none" || mode == "" || spec.Empty() {
+ return text
+ }
+ if len(spec.Gradient) >= 2 {
+ return renderGradient(mode, spec, text)
}
- if spec.Fg != "" {
- codes = append(codes, Fg(mode, spec.Fg))
+ var codes []string
+ prefix := stylePrefix(spec)
+ if prefix != "" {
+ codes = append(codes, prefix)
+ }
+ if spec.Fg >= 0 {
+ codes = append(codes, FgCode(mode, spec.Fg))
}
- if spec.Bg != "" {
- codes = append(codes, Bg(mode, spec.Bg))
+ if spec.Bg >= 0 {
+ codes = append(codes, BgCode(mode, spec.Bg))
}
if len(codes) == 0 {
return text
}
return strings.Join(codes, "") + text + Reset
}
+
+func interpolateGradient(stops []int, t float64) int {
+ if t <= 0 {
+ return stops[0]
+ }
+ if t >= 1 {
+ return stops[len(stops)-1]
+ }
+ seg := t * float64(len(stops)-1)
+ i := int(seg)
+ if i >= len(stops)-1 {
+ i = len(stops) - 2
+ }
+ frac := seg - float64(i)
+ r1, g1, b1 := Xterm256ToRGB(stops[i])
+ r2, g2, b2 := Xterm256ToRGB(stops[i+1])
+ r := r1 + int(frac*float64(r2-r1))
+ g := g1 + int(frac*float64(g2-g1))
+ b := b1 + int(frac*float64(b2-b1))
+ return NearestXterm256(r, g, b)
+}
+
+func renderGradient(mode string, spec ColorSpec, text string) string {
+ runes := []rune(text)
+ n := len(runes)
+ if n == 0 {
+ return ""
+ }
+ var sb strings.Builder
+ prefix := stylePrefix(spec)
+ if spec.Bg >= 0 {
+ sb.WriteString(BgCode(mode, spec.Bg))
+ }
+ for i, r := range runes {
+ t := 0.0
+ if n > 1 {
+ t = float64(i) / float64(n-1)
+ }
+ idx := interpolateGradient(spec.Gradient, t)
+ if prefix != "" {
+ sb.WriteString(prefix)
+ }
+ sb.WriteString(FgCode(mode, idx))
+ sb.WriteRune(r)
+ }
+ sb.WriteString(Reset)
+ return sb.String()
+}
+
+func ExpandTags(mode string, text string) string {
+ return ExpandTagsWithDefault(mode, NoColor(), text)
+}
+
+func ExpandTagsWithDefault(mode string, def ColorSpec, text string) string {
+ if !strings.Contains(text, "{") {
+ if !def.Empty() {
+ return Render(mode, def, text)
+ }
+ return text
+ }
+
+ var sb strings.Builder
+ pos := 0
+ for pos < len(text) {
+ start := strings.Index(text[pos:], "{")
+ if start < 0 {
+ remaining := text[pos:]
+ if remaining != "" {
+ if !def.Empty() {
+ sb.WriteString(Render(mode, def, remaining))
+ } else {
+ sb.WriteString(remaining)
+ }
+ }
+ break
+ }
+ start += pos
+
+ end := strings.Index(text[start+1:], "}")
+ if end < 0 {
+ remaining := text[pos:]
+ if !def.Empty() {
+ sb.WriteString(Render(mode, def, remaining))
+ } else {
+ sb.WriteString(remaining)
+ }
+ break
+ }
+ end += start + 1
+
+ specStr := text[start+1 : end]
+
+ if specStr == "" || specStr == "/" {
+ before := text[pos : start+end-start+1]
+ if !def.Empty() {
+ sb.WriteString(Render(mode, def, before))
+ } else {
+ sb.WriteString(before)
+ }
+ pos = end + 1
+ continue
+ }
+
+ spec := Parse(specStr)
+ if spec.Empty() {
+ before := text[pos : end+1]
+ if !def.Empty() {
+ sb.WriteString(Render(mode, def, before))
+ } else {
+ sb.WriteString(before)
+ }
+ pos = end + 1
+ continue
+ }
+
+ closeStart := strings.Index(text[end+1:], "{/}")
+ if closeStart < 0 {
+ remaining := text[pos:]
+ if !def.Empty() {
+ sb.WriteString(Render(mode, def, remaining))
+ } else {
+ sb.WriteString(remaining)
+ }
+ break
+ }
+ closeStart += end + 1
+
+ before := text[pos:start]
+ if before != "" {
+ if !def.Empty() {
+ sb.WriteString(Render(mode, def, before))
+ } else {
+ sb.WriteString(before)
+ }
+ }
+
+ inner := text[end+1 : closeStart]
+ sb.WriteString(Render(mode, spec, inner))
+
+ pos = closeStart + 3
+ }
+
+ return sb.String()
+}