tiki/util/gradient/gradient.go
2026-04-10 16:07:34 -04:00

172 lines
4.5 KiB
Go

package gradient
import (
"fmt"
"math"
"strings"
"sync"
"github.com/boolean-maybe/tiki/config"
"github.com/gdamore/tcell/v2"
)
type gradientCacheKey struct {
text string
start [3]int
end [3]int
}
var (
cache = make(map[gradientCacheKey]string)
cacheMu sync.RWMutex
)
// ResetGradientCache clears the gradient render cache. Intended for tests.
func ResetGradientCache() {
cacheMu.Lock()
clear(cache)
cacheMu.Unlock()
}
// InterpolateRGB performs linear RGB interpolation with proper rounding.
// t should be in [0, 1] range (automatically clamped).
func InterpolateRGB(from, to [3]int, t float64) [3]int {
// Clamp t to [0, 1]
if t < 0 {
t = 0
}
if t > 1 {
t = 1
}
return [3]int{
int(math.Round(float64(from[0]) + t*float64(to[0]-from[0]))),
int(math.Round(float64(from[1]) + t*float64(to[1]-from[1]))),
int(math.Round(float64(from[2]) + t*float64(to[2]-from[2]))),
}
}
// InterpolateColor is a convenience wrapper returning tcell.Color.
func InterpolateColor(gradient config.Gradient, t float64) tcell.Color {
rgb := InterpolateRGB(gradient.Start, gradient.End, t)
//nolint:gosec // G115: RGB values are 0-255, safe to convert to int32
return tcell.NewRGBColor(int32(rgb[0]), int32(rgb[1]), int32(rgb[2]))
}
// ClampRGB ensures RGB value stays within [0, 255].
func ClampRGB(value int) int {
if value < 0 {
return 0
}
if value > 255 {
return 255
}
return value
}
// LightenRGB increases brightness toward white by ratio [0, 1].
func LightenRGB(rgb [3]int, ratio float64) [3]int {
return [3]int{
ClampRGB(rgb[0] + int(math.Round(float64(255-rgb[0])*ratio))),
ClampRGB(rgb[1] + int(math.Round(float64(255-rgb[1])*ratio))),
ClampRGB(rgb[2] + int(math.Round(float64(255-rgb[2])*ratio))),
}
}
// DarkenRGB decreases brightness toward black by ratio [0, 1].
func DarkenRGB(rgb [3]int, ratio float64) [3]int {
return [3]int{
ClampRGB(int(math.Round(float64(rgb[0]) * (1 - ratio)))),
ClampRGB(int(math.Round(float64(rgb[1]) * (1 - ratio)))),
ClampRGB(int(math.Round(float64(rgb[2]) * (1 - ratio)))),
}
}
// RenderGradientText renders text with character-by-character gradient coloring.
// Results are cached by (text, gradient) key to avoid redundant computation on redraws.
func RenderGradientText(text string, gradient config.Gradient) string {
if len(text) == 0 {
return ""
}
key := gradientCacheKey{text: text, start: gradient.Start, end: gradient.End}
cacheMu.RLock()
if cached, ok := cache[key]; ok {
cacheMu.RUnlock()
return cached
}
cacheMu.RUnlock()
var builder strings.Builder
// each char produces "[#rrggbb]c" = 11 bytes for ASCII, up to 17 for wide runes
builder.Grow(len(text) * 17)
for i, char := range text {
t := float64(i) / float64(len(text)-1)
if len(text) == 1 {
t = 0
}
rgb := InterpolateRGB(gradient.Start, gradient.End, t)
fmt.Fprintf(&builder, "[#%02x%02x%02x]%c", rgb[0], rgb[1], rgb[2], char)
}
result := builder.String()
cacheMu.Lock()
cache[key] = result
cacheMu.Unlock()
return result
}
// RenderAdaptiveGradientText renders text with gradient or solid color based on config.UseGradients.
// When gradients are disabled, uses the gradient's end color as a solid color fallback.
func RenderAdaptiveGradientText(text string, gradient config.Gradient, fallbackColor config.Color) string {
if len(text) == 0 {
return ""
}
if !config.UseGradients {
// Use solid fallback color
r, g, b := fallbackColor.RGB()
return fmt.Sprintf("[#%02x%02x%02x]%s", r, g, b, text)
}
// Render full gradient
return RenderGradientText(text, gradient)
}
// GradientFromColor derives a gradient by lightening the base color.
func GradientFromColor(primary config.Color, ratio float64, fallback config.Gradient) config.Gradient {
r, g, b := primary.RGB()
if r == 0 && g == 0 && b == 0 {
return fallback
}
baseRGB := [3]int{int(r), int(g), int(b)}
lighterRGB := LightenRGB(baseRGB, ratio)
return config.Gradient{
Start: baseRGB,
End: lighterRGB,
}
}
// GradientFromColorVibrant derives a vibrant gradient by boosting RGB values.
func GradientFromColorVibrant(primary config.Color, boost float64, fallback config.Gradient) config.Gradient {
r, g, b := primary.RGB()
if r == 0 && g == 0 && b == 0 {
return fallback
}
baseRGB := [3]int{int(r), int(g), int(b)}
boostedRGB := [3]int{
ClampRGB(int(math.Round(float64(baseRGB[0]) * boost))),
ClampRGB(int(math.Round(float64(baseRGB[1]) * boost))),
ClampRGB(int(math.Round(float64(baseRGB[2]) * boost))),
}
return config.Gradient{
Start: baseRGB,
End: boostedRGB,
}
}