diff --git a/banner.go b/banner.go new file mode 100644 index 0000000..11bae86 --- /dev/null +++ b/banner.go @@ -0,0 +1,327 @@ +package main + +import ( + "fmt" + "image" + "image/color" + "image/png" + "math/rand/v2" + "os" + "path/filepath" + "time" + + "github.com/charmbracelet/log" + "golang.org/x/image/font" + "golang.org/x/image/math/fixed" +) + +const maxPositioningAttempts = 150 + +type TextData struct { + Text []string + Size float64 + Positioned bool + X int // left edge of bounding box + Y int // top edge of bounding box + W int // width of bounding box + H int // height of bounding box +} + +func NewTextData(text string, size float64, multiline bool) *TextData { + var splits []string + if multiline { + cutPos := int(len(text) / 2) + splits = []string{text[:cutPos], text[cutPos:]} + } else { + splits = []string{text} + } + + return &TextData{splits, size, false, -1, -1, -1, -1} +} + +// Consistent outline thickness based on font size +func getOutlineThickness(size float64) int { + thickness := int(size / 8) + if thickness < 1 { + thickness = 1 + } else if thickness > 3 { + thickness = 3 + } + return thickness +} + +// Actual bounding box for text including outline +func getTextBounds(text []string, size float64) (width, height int) { + face, err := createFontFace(size) + if err != nil { + // fallback + charWidth := int(size * 0.6) + lineHeight := int(size * 1.3) + maxWidth := 0 + for _, line := range text { + w := len(line) * charWidth + if w > maxWidth { + maxWidth = w + } + } + return maxWidth, len(text) * lineHeight + } + + // dimensions for multiline text + drawer := &font.Drawer{Face: face} + metrics := face.Metrics() + lineHeight := int(metrics.Height >> 6) + + maxWidth := 0 + for _, line := range text { + advance := drawer.MeasureString(line) + w := int(advance >> 6) + if w > maxWidth { + maxWidth = w + } + } + + // add outline padding + outlineThickness := getOutlineThickness(size) + padding := outlineThickness * 2 + + // add line spacing + totalHeight := len(text)*lineHeight + (len(text)-1)*int(size*0.3) + + return maxWidth + padding, totalHeight + padding +} + +func rectsOverlap(r1, r2 struct{ x, y, w, h int }) bool { + return !(r1.x+r1.w <= r2.x || r2.x+r2.w <= r1.x || + r1.y+r1.h <= r2.y || r2.y+r2.h <= r1.y) +} + +func drawTextWithOutline(canvas *image.RGBA, text []string, x, y int, textColor, outlineColor color.RGBA, size float64) { + face, err := createFontFace(size) + if err != nil { + log.Fatalf("Error creating font face: %s", err) + return + } + + outlineThickness := getOutlineThickness(size) + metrics := face.Metrics() + ascent := int(metrics.Ascent >> 6) + lineHeight := int(metrics.Height >> 6) + + for i, line := range text { + // baseline position for this line + baselineY := y + ascent + i*(lineHeight+int(size*0.3)) + outlineThickness + baselineX := x + outlineThickness + + // draw outline + for dy := -outlineThickness; dy <= outlineThickness; dy++ { + for dx := -outlineThickness; dx <= outlineThickness; dx++ { + if dx != 0 || dy != 0 { + d := &font.Drawer{ + Dst: canvas, + Src: image.NewUniform(outlineColor), + Face: face, + Dot: fixed.Point26_6{ + X: fixed.I(baselineX + dx), + Y: fixed.I(baselineY + dy), + }, + } + d.DrawString(line) + } + } + } + + // draw main text + d := &font.Drawer{ + Dst: canvas, + Src: image.NewUniform(textColor), + Face: face, + Dot: fixed.Point26_6{ + X: fixed.I(baselineX), + Y: fixed.I(baselineY), + }, + } + d.DrawString(line) + } +} + +// Place all elements to canvas without overlaps +func findTextPositions(textData []*TextData) { + padding := 10 + + for i, data := range textData { + // get actual bounding box dimensions + w, h := getTextBounds(data.Text, data.Size) + data.W = w + data.H = h + + placed := false + + // safe bounds for positioning + minX := padding + maxX := config.BannerWidth - w - padding + minY := padding + maxY := config.BannerHeight - h - padding + + // check if text can fit + if maxX < minX || maxY < minY { + log.Warnf("Text '%v' is too large for canvas", data.Text) + + // place at top-left as fallback + data.X = minX + data.Y = minY + data.Positioned = true + continue + } + + // try random positions + for attempt := 0; attempt < maxPositioningAttempts && !placed; attempt++ { + // gen. random pos. + x := rand.IntN(maxX-minX+1) + minX + y := rand.IntN(maxY-minY+1) + minY + + // bounding box for cur. text + currentRect := struct{ x, y, w, h int }{ + x: x, + y: y, + w: w, + h: h, + } + + // checks of overlaps with already placed texts + overlaps := false + for j := 0; j < i; j++ { + other := textData[j] + if !other.Positioned { + continue + } + + otherRect := struct{ x, y, w, h int }{ + x: other.X - padding/2, + y: other.Y - padding/2, + w: other.W + padding, + h: other.H + padding, + } + + if rectsOverlap(currentRect, otherRect) { + overlaps = true + break + } + } + + if !overlaps { + data.X = x + data.Y = y + data.Positioned = true + placed = true + log.Debugf("Placed text '%v' at (%d, %d)", data.Text, x, y) + } + } + + // fallback: use grid positioning + if !placed { + log.Warnf("Using fallback position for text '%v'", data.Text) + + gridCols := 2 + col := i % gridCols + row := i / gridCols + + cellWidth := config.BannerWidth / gridCols + cellHeight := config.BannerHeight / 3 // NOTE: assuming max 3 rows + + data.X = col*cellWidth + (cellWidth-w)/2 + data.Y = row*cellHeight + (cellHeight-h)/2 + + // ensure within bounds + if data.X < padding { + data.X = padding + } + if data.Y < padding { + data.Y = padding + } + if data.X+w > config.BannerWidth-padding { + data.X = config.BannerWidth - w - padding + } + if data.Y+h > config.BannerHeight-padding { + data.Y = config.BannerHeight - h - padding + } + + data.Positioned = true + } + } +} + +func genPerlinBG() *image.RGBA { + palette, theme := getRandomThemePalette() + img := image.NewRGBA(image.Rect(0, 0, config.BannerWidth, config.BannerHeight)) + + // noise params for interesting patterns + scale1 := 0.01 + rand.Float64()*0.02 // primary pattern scale (adjusted by lib) + scale2 := 0.03 + rand.Float64()*0.03 // secondary detail + scale3 := 0.08 + rand.Float64()*0.05 // fine detail + + // variation offsets + offsetX := rand.Float64() * 1000 + offsetY := rand.Float64() * 1000 + + log.Infof("Generating background with theme: %s, scales: %.4f, %.4f, %.4f", theme, scale1, scale2, scale3) + + for y := range config.BannerHeight { + for x := range config.BannerWidth { + // multi-octave noise + fx := (float64(x) + offsetX) * scale1 + fy := (float64(y) + offsetY) * scale1 + + // noise values at different scales + noise1 := perlinNoise.Noise2D(fx, fy) + noise2 := perlinNoise.Noise2D(fx/scale1*scale2, fy/scale1*scale2) + noise3 := perlinNoise.Noise2D(fx/scale1*scale3, fy/scale1*scale3) + + // combined noise layers with different weights + combined := noise1*0.6 + noise2*0.3 + noise3*0.1 + + // position-based gradient for a more dynamic look + gradientX := float64(x) / float64(config.BannerWidth) * 0.2 + gradientY := float64(y) / float64(config.BannerHeight) * 0.1 + + // combined and normalized to [0, 1] range + noise := (combined + gradientX + gradientY + 1) / 2 + if noise < 0 { + noise = 0 + } + if noise > 1 { + noise = 1 + } + + pixelColor := palette.interpolate(noise) + img.Set(x, y, pixelColor) + } + } + + return img +} + +func genBanner(textData []*TextData) error { + canvas := genPerlinBG() + + findTextPositions(textData) + + for _, td := range textData { + if td.Positioned { + textColor, outlineColor := getRandomTextColors() + drawTextWithOutline(canvas, td.Text, td.X, td.Y, textColor, outlineColor, td.Size) + } + } + + fn := fmt.Sprintf("banner_%d.png", time.Now().UnixMicro()) + fp := filepath.Join(bannersDir, fn) + file, err := os.Create(fp) + if err != nil { + return err + } + defer file.Close() + + return png.Encode(file, canvas) +} + +// TODO: functions for banner rotation implementation diff --git a/font.go b/font.go new file mode 100644 index 0000000..a77d7f7 --- /dev/null +++ b/font.go @@ -0,0 +1,75 @@ +package main + +import ( + "errors" + "os" + + "github.com/charmbracelet/log" + "github.com/golang/freetype/truetype" + "golang.org/x/image/font" + "golang.org/x/image/font/opentype" +) + +const defaultFontSize = 24.0 + +var fontManager *FontManager + +type FontManager struct { + fontData []byte + fontType string // opentype or truetype +} + +func initFontManager() error { + fontBytes, err := os.ReadFile(fontPath) + if err != nil { + return err + } + + // initially try to parse as opentype/truetype + if _, err := opentype.Parse(fontBytes); err == nil { + fontManager = &FontManager{ + fontData: fontBytes, + fontType: "opentype", + } + log.Debug("Initialized OpenType font manager") + } else if _, err := truetype.Parse(fontBytes); err == nil { + fontManager = &FontManager{ + fontData: fontBytes, + fontType: "truetype", + } + log.Debug("Initialized TrueType font manager") + } else { + return err + } + + return nil +} + +func createFontFace(size float64) (font.Face, error) { + if fontManager == nil { + return nil, errors.New("font manager not initialized") + } + + switch fontManager.fontType { + case "opentype": + parsedFont, err := opentype.Parse(fontManager.fontData) + if err != nil { + return nil, err + } + return opentype.NewFace(parsedFont, &opentype.FaceOptions{ + Size: size, + DPI: 72, + }) + case "truetype": + ttf, err := truetype.Parse(fontManager.fontData) + if err != nil { + return nil, err + } + return truetype.NewFace(ttf, &truetype.Options{ + Size: size, + DPI: 72, + }), nil + default: + return nil, errors.New("unknown font type") + } +} diff --git a/fonts/PixelOperator-Bold.ttf b/fonts/PixelOperator-Bold.ttf new file mode 100644 index 0000000..745734f Binary files /dev/null and b/fonts/PixelOperator-Bold.ttf differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0a80a21 --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module git.umbrella.haus/ae/mandala + +go 1.24.2 + +require ( + github.com/aquilax/go-perlin v1.1.0 + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/log v0.4.2 // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/fogleman/gg v1.3.0 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/image v0.27.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.25.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..294a45c --- /dev/null +++ b/go.sum @@ -0,0 +1,44 @@ +github.com/aquilax/go-perlin v1.1.0 h1:Gg+3jQ24wT4Y5GI7TCRLmYarzUG0k+n/JATFqOimb7s= +github.com/aquilax/go-perlin v1.1.0/go.mod h1:z9Rl7EM4BZY0Ikp2fEN1I5mKSOJ26HQpk0O2TBdN2HE= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= +github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= +golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= diff --git a/main.go b/main.go new file mode 100644 index 0000000..6ac2456 --- /dev/null +++ b/main.go @@ -0,0 +1,95 @@ +package main + +import ( + "encoding/json" + "math/rand" + "os" + "time" + + "github.com/aquilax/go-perlin" + "github.com/charmbracelet/log" +) + +const ( + bannersDir = "./banners" // mountable + fontPath = "./fonts/PixelOperator-Bold.ttf" + configFile = "./config.json" // mountable +) + +var ( + config Config + perlinNoise perlin.Perlin +) + +type Config struct { + ClearwebURL string `json:"clearweb_url"` + OnionURL string `json:"onion_url"` + ColorThemes []string `json:"color_themes"` + MaxBanners uint `json:"max_banners"` + BannerWidth int `json:"banner_width"` + BannerHeight int `json:"banner_height"` + LogLevel log.Level `json:"log_level"` // -4=DBG, 0=INFO, 4=WARN, 8=ERR, 12=FATAL +} + +type BannerInfo struct { + Filename string + CreatedAt time.Time +} + +func init() { + config = Config{ + ClearwebURL: "https://example.com", + OnionURL: "http://example.onion", + ColorThemes: []string{"vibrant", "sunset", "ocean", "neon", "forest", "cosmic"}, + MaxBanners: 50, + BannerWidth: 600, + BannerHeight: 120, + LogLevel: log.InfoLevel, + } + + if data, err := os.ReadFile(configFile); err == nil { + if err := json.Unmarshal(data, &config); err != nil { + log.Fatalf("Error parsing config: %s", err) + os.Exit(1) + } + } else { + data, _ := json.MarshalIndent(config, "", " ") + os.WriteFile(configFile, data, 0644) + log.Info("Created default config - please update your URLs into it") + os.Exit(0) + } + + log.SetLevel(config.LogLevel) + + if err := os.MkdirAll(bannersDir, 0755); err != nil { + log.Fatalf("Error creating banner output directory: %s", err) + os.Exit(1) + } + + if err := initFontManager(); err != nil { + log.Fatalf("Error initializing font manager: %s", err) + os.Exit(1) + } + + // perlin input params: alpha, beta, octaves, seed + perlinNoise = *perlin.NewPerlin(2.0, 2.0, 3, rand.Int63()) +} + +func main() { + // TODO: cli param `--portable ` for instant generation of n banners + + // TODO: ticker setup for banner rotation + + // NOTE: makes the life a lot easier to draw the bigger element first, + // then find a place for the smaller one + textData := []*TextData{ + NewTextData(config.OnionURL, defaultFontSize, true), + NewTextData(config.ClearwebURL, defaultFontSize+4.0, false), + } + + for range 10 { + if err := genBanner(textData); err != nil { + log.Errorf("Error generating banner: %s", err) + } + } +} diff --git a/palette.go b/palette.go new file mode 100644 index 0000000..eba739e --- /dev/null +++ b/palette.go @@ -0,0 +1,239 @@ +package main + +import ( + "image/color" + "math/rand/v2" +) + +type Palette []color.RGBA + +var textColors = []color.RGBA{ + // basic bright colors + {255, 100, 100, 255}, // red + {100, 255, 100, 255}, // green + {100, 100, 255, 255}, // blue + {255, 255, 100, 255}, // yellow + {255, 100, 255, 255}, // magenta + {100, 255, 255, 255}, // cyan + {255, 255, 255, 255}, // white + {255, 150, 50, 255}, // orange + {150, 255, 50, 255}, // lime + {255, 50, 150, 255}, // pink + {50, 150, 255, 255}, // sky blue + {200, 200, 255, 255}, // light blue + + // extended primary variations + {255, 50, 50, 255}, // bright red + {255, 150, 150, 255}, // light red + {50, 255, 50, 255}, // bright green + {150, 255, 150, 255}, // light green + {50, 50, 255, 255}, // bright blue + {150, 150, 255, 255}, // light blue variant + + // warm colors + {255, 200, 50, 255}, // golden yellow + {255, 175, 0, 255}, // amber + {255, 69, 0, 255}, // red orange + {255, 20, 147, 255}, // deep pink + {255, 105, 180, 255}, // hot pink + {255, 182, 193, 255}, // light pink + {255, 160, 122, 255}, // light salmon + {255, 99, 71, 255}, // tomato + {255, 127, 80, 255}, // coral + + // cool colors + {0, 255, 127, 255}, // spring green + {0, 250, 154, 255}, // medium spring green + {64, 224, 208, 255}, // turquoise + {72, 209, 204, 255}, // medium turquoise + {135, 206, 250, 255}, // light sky blue + {70, 130, 180, 255}, // steel blue + {100, 149, 237, 255}, // cornflower blue + {123, 104, 238, 255}, // medium slate blue + {138, 43, 226, 255}, // blue violet + + // purple spectrum + {147, 112, 219, 255}, // medium purple + {186, 85, 211, 255}, // medium orchid + {218, 112, 214, 255}, // orchid + {221, 160, 221, 255}, // plum + {238, 130, 238, 255}, // violet + {255, 0, 255, 255}, // pure magenta + {199, 21, 133, 255}, // medium violet red + {219, 112, 147, 255}, // pale violet red + + // neon/electric colors + {57, 255, 20, 255}, // electric green + {255, 20, 147, 255}, // electric pink + {0, 255, 255, 255}, // electric cyan + {255, 255, 0, 255}, // electric yellow + {255, 0, 127, 255}, // electric rose + {127, 255, 0, 255}, // electric lime + {255, 127, 0, 255}, // electric orange + {0, 127, 255, 255}, // electric blue + + // pastel variants + {255, 218, 185, 255}, // peach puff + {255, 228, 196, 255}, // bisque + {255, 239, 213, 255}, // papaya whip + {240, 248, 255, 255}, // alice blue + {230, 230, 250, 255}, // lavender + {255, 240, 245, 255}, // lavender blush + {255, 228, 225, 255}, // misty rose + {245, 255, 250, 255}, // mint cream + + // earth tones (brighter versions) + {210, 180, 140, 255}, // tan + {222, 184, 135, 255}, // burlywood + {238, 203, 173, 255}, // navajo white + {255, 218, 185, 255}, // peach + {255, 165, 79, 255}, // sandy brown + {205, 133, 63, 255}, // peru + {160, 82, 45, 255}, // saddle brown (lighter) + {210, 105, 30, 255}, // chocolate (lighter) + + // jewel tones + {50, 205, 50, 255}, // lime green + {220, 20, 60, 255}, // crimson + {75, 0, 130, 255}, // indigo (brightened) + {128, 0, 128, 255}, // purple (brightened) + {165, 42, 42, 255}, // brown (brightened) + {128, 128, 0, 255}, // olive (brightened) + {0, 128, 128, 255}, // teal (brightened) + {128, 0, 0, 255}, // maroon (brightened) + + // unique/exotic colors + {255, 215, 0, 255}, // gold + {192, 192, 192, 255}, // silver + {255, 20, 147, 255}, // deep pink + {0, 191, 255, 255}, // deep sky blue + {30, 144, 255, 255}, // dodger blue + {255, 69, 0, 255}, // orange red + {154, 205, 50, 255}, // yellow green + {32, 178, 170, 255}, // light sea green + {60, 179, 113, 255}, // medium sea green + + // vibrant spectrum + {127, 255, 212, 255}, // aquamarine + {240, 230, 140, 255}, // khaki + {238, 232, 170, 255}, // pale goldenrod + {250, 240, 230, 255}, // linen + {253, 245, 230, 255}, // old lace + {255, 250, 240, 255}, // floral white + {248, 248, 255, 255}, // ghost white + {245, 245, 220, 255}, // beige +} + +var themePalettes = map[string]Palette{ + "vibrant": { + {255, 0, 150, 255}, // hot pink + {255, 100, 0, 255}, // orange + {255, 255, 0, 255}, // yellow + {0, 255, 100, 255}, // lime + {0, 150, 255, 255}, // sky blue + {150, 0, 255, 255}, // purple + }, + "sunset": { + {255, 94, 77, 255}, // coral + {255, 154, 0, 255}, // orange + {255, 206, 84, 255}, // yellow + {255, 118, 117, 255}, // light coral + {240, 147, 43, 255}, // dark orange + {235, 77, 75, 255}, // red + }, + "ocean": { + {0, 123, 255, 255}, // blue + {0, 200, 255, 255}, // light blue + {0, 255, 200, 255}, // cyan + {100, 200, 255, 255}, // sky blue + {0, 150, 200, 255}, // dark blue + {50, 255, 150, 255}, // aqua + }, + "neon": { + {255, 0, 255, 255}, // magenta + {0, 255, 255, 255}, // cyan + {255, 255, 0, 255}, // yellow + {255, 0, 100, 255}, // hot pink + {100, 255, 0, 255}, // lime + {150, 0, 255, 255}, // purple + }, + "forest": { + {34, 139, 34, 255}, // forest green + {107, 142, 35, 255}, // olive + {154, 205, 50, 255}, // yellow green + {50, 205, 50, 255}, // lime green + {0, 128, 0, 255}, // green + {85, 107, 47, 255}, // dark olive + }, + "cosmic": { + {138, 43, 226, 255}, // blue violet + {75, 0, 130, 255}, // indigo + {148, 0, 211, 255}, // dark violet + {255, 20, 147, 255}, // deep pink + {255, 0, 255, 255}, // magenta + {72, 61, 139, 255}, // dark slate blue + }, +} + +func getRandomTextColors() (color.RGBA, color.RGBA) { + textColor := textColors[rand.IntN(len(textColors))] + + // calculate perceived brightness using luminance formula + // std. weights for human eye sensitivity: 0.299*R + 0.587*G + 0.114*B + brightness := 0.299*float64(textColor.R) + 0.587*float64(textColor.G) + 0.114*float64(textColor.B) + + var outlineColor color.RGBA + if brightness < 128 { // Dark text - use white outline + outlineColor = color.RGBA{255, 255, 255, 255} + } else { // Bright text - use black outline + outlineColor = color.RGBA{0, 0, 0, 255} + } + + return textColor, outlineColor +} + +func getThemePalette(name string) Palette { + if palette, exists := themePalettes[name]; exists { + return palette + } + return themePalettes["vibrant"] // default +} + +func getRandomThemePalette() (Palette, string) { + name := config.ColorThemes[rand.IntN(len(config.ColorThemes))] + return getThemePalette(name), name +} + +func (p Palette) interpolate(t float64) color.RGBA { + if len(p) == 0 { + return color.RGBA{128, 128, 128, 255} + } + + // clamp to to [0, 1] + if t < 0 { + t = 0 + } + if t > 1 { + t = 1 + } + + // calculate which colors to interpolate between + scaledT := t * float64(len(p)-1) + index := int(scaledT) + localT := scaledT - float64(index) + + if index >= len(p)-1 { + return p[len(p)-1] + } + + c1 := p[index] + c2 := p[index+1] + + // linear interpolation between the two colors + r := uint8(float64(c1.R)*(1-localT) + float64(c2.R)*localT) + g := uint8(float64(c1.G)*(1-localT) + float64(c2.G)*localT) + b := uint8(float64(c1.B)*(1-localT) + float64(c2.B)*localT) + a := uint8(float64(c1.A)*(1-localT) + float64(c2.A)*localT) + + return color.RGBA{r, g, b, a} +}