package themer import ( "os" "strconv" "strings" "github.com/pelletier/go-toml/v2" ) type Theme struct { Meta struct { Name string Type string Version string } Primitive map[string]string Colors struct { Semantic struct { Background Color Surface Color SurfaceAlt Color Disabled Color Border Color Text Color Accent Color Warn Color } } `toml:"Colors"` Spacing struct { Padding string PaddingSmall string PaddingLarge string Margin string MarginSmall string Radius string RadiusSmall string RadiusLarge string } `toml:"Spacing"` } type Color struct { raw string Value string } func (c Color) Alpha(bg Color, amount float64) string { r1, g1, b1, ok1 := parseHexRGB(c.Value) r2, g2, b2, ok2 := parseHexRGB(bg.Value) if !ok1 || !ok2 { return c.Value } r := blend8(r2, r1, amount) g := blend8(g2, g1, amount) b := blend8(b2, b1, amount) return formatHexRGB(r, g, b) } func (c Color) RGBA(alpha int) string { r, g, b, ok := parseHexRGB(c.Value) if !ok { return c.Value } if alpha < 100 { return "rgba ( " + strconv.Itoa(int(r)) + ", " + strconv.Itoa(int(g)) + ", " + strconv.Itoa(int(b)) + ", " + strconv.Itoa(alpha) + " % )" } return c.Value } func (c *Color) UnmarshalText(text []byte) error { c.raw = strings.TrimSpace(string(text)) return nil } func LoadTheme(path string) (Theme, error) { theme := Theme{} if themeFile, err := os.ReadFile(path); err != nil { return theme, err } else { if err := toml.Unmarshal(themeFile, &theme); err != nil { return theme, err } } theme.resolveColor(&theme.Colors.Semantic.Background) theme.resolveColor(&theme.Colors.Semantic.Surface) theme.resolveColor(&theme.Colors.Semantic.SurfaceAlt) theme.resolveColor(&theme.Colors.Semantic.Disabled) theme.resolveColor(&theme.Colors.Semantic.Border) theme.resolveColor(&theme.Colors.Semantic.Text) theme.resolveColor(&theme.Colors.Semantic.Accent) theme.resolveColor(&theme.Colors.Semantic.Warn) return theme, nil } func (t Theme) resolveColor(color *Color) { raw, shadeRaw, hasShade := strings.Cut(color.raw, "|") base := strings.TrimSpace(raw) shade, err := strconv.Atoi(strings.TrimSpace(shadeRaw)) if primitive, ok := t.Primitive[base]; ok { base = primitive } else { base = raw } base = normalizeHex(base) if !hasShade || err != nil { color.Value = base return } if shade < 0 { shade = 0 } if shade > 1000 { shade = 1000 } r, g, b, ok := parseHexRGB(base) if !ok { color.Value = base return } switch { case shade == 500: // unchanged case shade < 500: amount := float64(500-shade) / 500.0 r = blend8(r, 255, amount) g = blend8(g, 255, amount) b = blend8(b, 255, amount) default: amount := float64(shade-500) / 500.0 r = blend8(r, 0, amount) g = blend8(g, 0, amount) b = blend8(b, 0, amount) } color.Value = formatHexRGB(r, g, b) } func normalizeHex(s string) string { s = strings.TrimSpace(s) if s == "" { return s } if strings.HasPrefix(s, "#") { return s } return "#" + s } func parseHexRGB(s string) (uint8, uint8, uint8, bool) { s = strings.TrimSpace(s) if strings.HasPrefix(s, "#") { s = s[1:] } if len(s) != 6 { return 0, 0, 0, false } // Manual hex parsing to avoid extra deps; strconv handles base 16 fine. rv, err1 := strconv.ParseUint(s[0:2], 16, 8) gv, err2 := strconv.ParseUint(s[2:4], 16, 8) bv, err3 := strconv.ParseUint(s[4:6], 16, 8) if err1 != nil || err2 != nil || err3 != nil { return 0, 0, 0, false } return uint8(rv), uint8(gv), uint8(bv), true } func formatHexRGB(r, g, b uint8) string { const hex = "0123456789abcdef" out := make([]byte, 7) out[0] = '#' out[1] = hex[r>>4] out[2] = hex[r&0x0f] out[3] = hex[g>>4] out[4] = hex[g&0x0f] out[5] = hex[b>>4] out[6] = hex[b&0x0f] return string(out) } func blend8(a, b uint8, t float64) uint8 { // result = a*(1-t) + b*t, rounded v := float64(a)*(1.0-t) + float64(b)*t if v < 0 { v = 0 } if v > 255 { v = 255 } return uint8(v + 0.5) }