diff options
| author | historia <[not public]> | 2026-06-16 23:37:50 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-06-16 23:37:50 -0400 |
| commit | 389e307f205f9b7edf604fb9f86e1038ead736ef (patch) | |
| tree | 595095b5877cc2b0513d31fb126303d8796cc311 /internal/color | |
| parent | 085e728d22a3369bd110b77409914cfbe9ebe611 (diff) | |
| download | thehouseoficarus-389e307f205f9b7edf604fb9f86e1038ead736ef.tar.gz | |
feat: major xterm256 color overhaul, see worldbuilding docs.
Diffstat (limited to 'internal/color')
| -rw-r--r-- | internal/color/color.go | 475 |
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() +} |
