commit 79433f872c425af7d830f5062518870cc81ca391 Author: Lixen Wraith Date: Tue Jul 14 12:56:03 2026 -0400 v0.1.0 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f0d689 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.idea +bin/ +dev/ +logs/ +log/ +examples/ +catalog.txt +combined.txt diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1a36bbd --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2026, Lixen Wraith + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1fe8303 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# color + +24-bit RGB values, perceptual metrics, and blend operations. No output device, no dependencies. + +Extracted from [lixenwraith/terminal](https://github.com/lixenwraith/terminal) so renderers — terminal, GUI, image, framebuffer — share one color type and one set of operations without linking terminal I/O, `x/sys`, or termios. + +## Install + +``` +go get github.com/lixenwraith/color +``` + +Go 1.26+. Standard library only. + +## Type + +```go +type RGB struct { + R uint8 `toml:"r"` + G uint8 `toml:"g"` + B uint8 `toml:"b"` +} +``` + +Three bytes, comparable, pointer-free: safe to embed in dense cell or pixel buffers and to bind directly from config. + +`RGB` implements `image/color.Color`, so values pass into `image`, `draw`, and GUI toolkit pipelines unchanged. `From` converts back, un-premultiplying alpha and discarding it. + +```go +img.Set(x, y, color.Amber) // RGB satisfies image/color.Color +c := color.From(img.At(x, y)) // back to RGB +``` + +Where both packages are needed at one site, alias the standard library: `import stdcolor "image/color"`. + +## Operations + +| Call | Behavior | +| --- | --- | +| `Blend(dst, src, alpha)` | Linear alpha compositing | +| `SoftLight(dst, src, intensity)` | Perez soft light; gentler than linear alpha | +| `Overlay(dst, src, alpha)` | Multiply on darks, screen on lights | +| `Screen(dst, src, alpha)` | Always lightens; glow accumulation without `Add` clipping | +| `Add(dst, src, alpha)` | Saturating additive | +| `Max(dst, src, alpha)` | Per-channel maximum | +| `Scale(c, factor)` | Channel multiply, saturating | +| `Grayscale(c)`, `Luma(c)` | Rec. 601 luma | +| `c.Lerp(other, t)` | Linear interpolation | +| `RedmeanDistance(a, b)` | Squared perceptual distance, for nearest-color search | + +All operations are pure. `alpha` and `t` clamp to `[0,1]`; channels saturate. Integer paths avoid division; soft light is table-driven, no `sqrt` per channel. + +```go +bg := color.Obsidian +glow := color.Screen(bg, color.Amber, 0.4) +edge := bg.Lerp(color.Amber, 0.75) +warm := color.SoftLight(edge, color.Terracotta, 0.3) +``` + +## Palette + +~120 named colors, grouped by hue and ordered dark-to-light: `Obsidian`, `Amber`, `EmeraldGreen`, `LightSkyBlue`, `Vermilion`, … Standard names (CSS, X11) where the RGB matches; descriptive compounds otherwise. + +Package-level `var`s. **Read-only by contract** — the language permits assignment, the package does not. Alias them into domain parameter files rather than mutating them. + +## Hex + +```go +c, err := color.ParseHex("#4a90d9") // also "4a90d9", "#abc", "abc" +s := c.Hex() // "#4a90d9" +var Accent = color.MustParseHex("#ff8800") +``` + +`RGB` deliberately does **not** implement `encoding.TextUnmarshaler`. TOML and JSON decoders prefer it over struct-field unification, which would silently break table-form config: + +```toml +accent = { r = 255, g = 136, b = 0 } +``` + +## With terminal + +`terminal.Cell` carries `color.RGB` directly. Quantization stays device-side: + +```go +idx := terminal.RGBTo256(color.EmeraldGreen) // xterm-256 index +``` + +Note: when `Cell.Attrs` sets `AttrFg256` / `AttrBg256`, `Cell.Fg.R` / `Cell.Bg.R` hold a palette index, not a channel. Such values are not colors and must not be passed to this package. + +## Concurrency + +Values are immutable, operations are pure, lookup tables are built at package init. Safe for concurrent use. + +## License + +See `LICENSE`. diff --git a/blend.go b/blend.go new file mode 100644 index 0000000..4cc3420 --- /dev/null +++ b/blend.go @@ -0,0 +1,187 @@ +package color + +import "math" + +const softLightLUTSize = 256 + +// Perez SoftLight lookup tables (array access, no pointers) +// Pre-computed at init to avoid sqrt/division in per-cell loops +var ( + softLightG [softLightLUTSize]float64 + softLightDF [softLightLUTSize]float64 +) + +func init() { + for i := range softLightLUTSize { + df := float64(i) / 255.0 + softLightDF[i] = df + if df <= 0.25 { + softLightG[i] = ((16.0*df-12.0)*df + 4.0) * df + } else { + softLightG[i] = math.Sqrt(df) + } + } +} + +// clampU8 converts float to uint8 with saturation +func clampU8(v float64) uint8 { + if v >= 255.0 { + return 255 + } + if v <= 0.0 { + return 0 + } + // Round-half-up; unifies rounding across Blend/Scale/Lerp/SoftLight + return uint8(v + 0.5) +} + +// addU8 is saturating uint8 addition +func addU8(a, b uint8) uint8 { + sum := int(a) + int(b) + if sum > 255 { + return 255 + } + return uint8(sum) +} + +// fastDiv255 approximates x / 255 using integer math: (x + (x >> 8) + 1) >> 8 +// Faster than DIV instruction, exact for x in [0, 255*255] +func fastDiv255(x int) int { + return (x + (x >> 8) + 1) >> 8 +} + +// softLightChannel applies Perez soft light to one channel via LUTs +func softLightChannel(d, s uint8, intensity float64) uint8 { + df := softLightDF[d] + sf := softLightDF[s] + + var result float64 + if sf < 0.5 { + result = df - (1.0-2.0*sf)*df*(1.0-df) + } else { + // LUT replaces math.Sqrt + result = df + (2.0*sf-1.0)*(softLightG[d]-df) + } + + // Lerp toward result by intensity, single dependency chain + result = df + (result-df)*intensity + + return clampU8(result * 255.0) +} + +// overlayChannel combines multiply (d < 128) and screen (d >= 128), +// preserving destination highlights and shadows +func overlayChannel(d, s uint8) uint8 { + if d < 128 { + return uint8(fastDiv255(2 * int(d) * int(s))) + } + return uint8(255 - fastDiv255(2*(255-int(d))*(255-int(s)))) +} + +// Blend performs linear alpha blend of src over dst +// alpha <= 0 returns dst, alpha >= 1 returns src +func Blend(dst, src RGB, alpha float64) RGB { + if alpha >= 1.0 { + return src + } + if alpha <= 0.0 { + return dst + } + inv := 1.0 - alpha + return RGB{ + R: clampU8(float64(src.R)*alpha + float64(dst.R)*inv), + G: clampU8(float64(src.G)*alpha + float64(dst.G)*inv), + B: clampU8(float64(src.B)*alpha + float64(dst.B)*inv), + } +} + +// SoftLight applies Perez soft light blend, gentler than linear alpha +// intensity in [0,1] mixes between dst and the blended result +func SoftLight(dst, src RGB, intensity float64) RGB { + return RGB{ + R: softLightChannel(dst.R, src.R, intensity), + G: softLightChannel(dst.G, src.G, intensity), + B: softLightChannel(dst.B, src.B, intensity), + } +} + +// Max returns per-channel maximum, alpha-blended over dst +func Max(dst, src RGB, alpha float64) RGB { + if alpha <= 0.0 { + return dst + } + maxed := RGB{ + R: max(dst.R, src.R), + G: max(dst.G, src.G), + B: max(dst.B, src.B), + } + if alpha >= 1.0 { + return maxed + } + return Blend(dst, maxed, alpha) +} + +// Add performs saturating additive blend, alpha-blended over dst +func Add(dst, src RGB, alpha float64) RGB { + if alpha <= 0.0 { + return dst + } + added := RGB{ + R: addU8(dst.R, src.R), + G: addU8(dst.G, src.G), + B: addU8(dst.B, src.B), + } + if alpha >= 1.0 { + return added + } + return Blend(dst, added, alpha) +} + +// Screen applies 1-(1-dst)*(1-src), alpha-blended over dst +// Always lightens; useful for glow accumulation without clipping harshness of Add +func Screen(dst, src RGB, alpha float64) RGB { + if alpha <= 0.0 { + return dst + } + screened := RGB{ + R: uint8(255 - fastDiv255((255-int(dst.R))*(255-int(src.R)))), + G: uint8(255 - fastDiv255((255-int(dst.G))*(255-int(src.G)))), + B: uint8(255 - fastDiv255((255-int(dst.B))*(255-int(src.B)))), + } + if alpha >= 1.0 { + return screened + } + return Blend(dst, screened, alpha) +} + +// Overlay combines multiply (darks) and screen (lights), alpha-blended over dst +func Overlay(dst, src RGB, alpha float64) RGB { + if alpha <= 0.0 { + return dst + } + overlaid := RGB{ + R: overlayChannel(dst.R, src.R), + G: overlayChannel(dst.G, src.G), + B: overlayChannel(dst.B, src.B), + } + if alpha >= 1.0 { + return overlaid + } + return Blend(dst, overlaid, alpha) +} + +// Scale multiplies all channels by factor, saturating (factor > 1.0 brightens) +func Scale(c RGB, factor float64) RGB { + return RGB{ + R: clampU8(float64(c.R) * factor), + G: clampU8(float64(c.G) * factor), + B: clampU8(float64(c.B) * factor), + } +} + +// Grayscale converts to grayscale using Rec. 601 luma coefficients +func Grayscale(c RGB) RGB { + // CHANGED: shared with Luma + g := Luma(c) + return RGB{R: g, G: g, B: g} +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..69d022f --- /dev/null +++ b/doc.go @@ -0,0 +1,28 @@ +// Package color provides 24-bit RGB values, perceptual metrics, and blend +// operations, independent of any output device. +// +// RGB is a plain 3-byte value with toml field tags, safe for config binding +// and for embedding in dense cell or pixel buffers. It satisfies +// image/color.Color, so values pass directly into image, draw, and GUI +// toolkit pipelines without conversion. +// +// Device concerns — terminal capability detection, xterm-256 quantization, +// SGR emission, framebuffer formats — belong in the consuming package. +// +// # Operations +// +// Blend linear alpha compositing +// SoftLight Perez soft light, gentler than linear alpha +// Overlay multiply on darks, screen on lights +// Screen always lightens, avoids the clipping harshness of Add +// Add saturating additive +// Max per-channel maximum +// Scale channel multiply, saturating +// Grayscale Rec. 601 luma +// Lerp linear interpolation +// +// # Concurrency +// +// All values are immutable and all operations are pure. Package-level palette +// variables are writable by the language but are read-only by contract. +package color diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ca52a27 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/lixenwraith/color + +go 1.26.0 diff --git a/palette.go b/palette.go new file mode 100644 index 0000000..0fcbc9a --- /dev/null +++ b/palette.go @@ -0,0 +1,162 @@ +package color + +// Named 24-bit palette. Pure RGB definitions, no device or domain semantics. +// Consumers alias these in their own parameter files. +// +// Naming: standard color names where RGB closely matches (CSS, X11, +// Pantone-adjacent), descriptive compound names otherwise. Ordered dark-to-light +// within each hue group. +// +// Read-only by contract. + +var ( + // --- Achromatic --- + Black = RGB{0, 0, 0} + Charcoal = RGB{5, 5, 5} + Obsidian = RGB{20, 20, 30} // Blue-black + Gunmetal = RGB{26, 27, 38} // Blue-tinted near-black + DarkSlate = RGB{35, 36, 48} // Blue-gray near-black + DimGray = RGB{55, 55, 55} + DarkGray = RGB{60, 60, 60} + IronGray = RGB{80, 80, 80} + SlateGray = RGB{80, 80, 90} // Cool-tinted + Taupe = RGB{100, 95, 85} // Warm gray + Gray = RGB{120, 120, 120} + MidGray = RGB{128, 128, 128} + CoolSilver = RGB{140, 145, 155} // Blue-tinted silver + DimSilver = RGB{155, 155, 155} + Silver = RGB{180, 180, 180} + LightGray = RGB{200, 200, 200} + NearWhite = RGB{250, 250, 250} + White = RGB{255, 255, 255} + + // --- Brown / Earth --- + Chocolate = RGB{90, 25, 15} + SaddleBrown = RGB{101, 67, 33} + DarkRust = RGB{140, 35, 25} + Sienna = RGB{140, 60, 0} + DarkPlum = RGB{60, 30, 40} // Warm dark purple-brown + BlueCharcoal = RGB{40, 45, 60} // Cool dark blue-gray + + // --- Red --- + BlackRed = RGB{50, 15, 15} + Oxblood = RGB{100, 20, 20} + DarkBurgundy = RGB{100, 25, 20} + DarkCrimson = RGB{139, 0, 0} + Brick = RGB{180, 40, 40} + Cinnabar = RGB{200, 60, 50} + IndianRed = RGB{180, 60, 60} + BurntSienna = RGB{200, 60, 25} + Vermilion = RGB{227, 66, 82} + Red = RGB{255, 0, 0} + BrightRed = RGB{255, 60, 60} + Coral = RGB{255, 80, 80} + Salmon = RGB{255, 100, 100} + LightCoral = RGB{255, 140, 140} + LightRose = RGB{255, 150, 150} + MistyRose = RGB{255, 200, 200} + + // --- Orange --- + DarkAmber = RGB{60, 40, 0} + Rust = RGB{180, 60, 20} + Amber = RGB{180, 120, 0} + Bronze = RGB{200, 100, 0} + BurntOrange = RGB{200, 110, 0} + Terracotta = RGB{220, 100, 50} + FlameOrange = RGB{240, 100, 30} + OrangeRed = RGB{255, 69, 0} + RedOrange = RGB{255, 80, 40} + Mango = RGB{255, 120, 50} + TigerOrange = RGB{255, 140, 0} + WarmOrange = RGB{255, 140, 40} + Apricot = RGB{255, 160, 60} + Orange = RGB{255, 165, 0} + + // --- Yellow --- + DarkGold = RGB{200, 150, 0} + OliveYellow = RGB{200, 180, 60} + Gold = RGB{255, 215, 0} + LemonYellow = RGB{255, 240, 60} + Yellow = RGB{255, 255, 0} + PaleGold = RGB{255, 200, 100} + Buttercream = RGB{255, 250, 150} + PaleLemon = RGB{255, 255, 100} + Ivory = RGB{255, 255, 220} + Cream = RGB{255, 255, 200} + + // --- Green --- + BlackGreen = RGB{0, 40, 0} + DarkFern = RGB{30, 80, 25} + DeepForest = RGB{25, 80, 35} + HunterGreen = RGB{35, 90, 30} + DarkGreen = RGB{15, 130, 15} + ForestGreen = RGB{34, 139, 34} + MediumGreen = RGB{40, 150, 40} + FernGreen = RGB{50, 140, 45} + LeafGreen = RGB{60, 160, 60} + SeaGreen = RGB{60, 180, 80} + SageGreen = RGB{70, 170, 100} + GrassGreen = RGB{70, 180, 55} + EmeraldGreen = RGB{60, 220, 100} + MintGreen = RGB{100, 220, 130} + BrightGreen = RGB{20, 200, 20} + YellowGreen = RGB{100, 220, 80} + LimeGreen = RGB{50, 205, 50} + NeonGreen = RGB{50, 255, 50} + BrightLime = RGB{120, 255, 80} + Lime = RGB{0, 255, 0} + LightGreen = RGB{144, 238, 144} + PaleGreen = RGB{120, 255, 120} + PastelGreen = RGB{100, 220, 100} + PaleMint = RGB{150, 255, 180} + Honeydew = RGB{200, 255, 200} + + // --- Cyan / Teal --- + Teal = RGB{0, 139, 139} + DimCyan = RGB{0, 160, 160} + VibrantCyan = RGB{0, 200, 200} + DarkTurquoise = RGB{0, 206, 209} + BrightCyan = RGB{0, 220, 220} + Cyan = RGB{0, 255, 255} + SkyTeal = RGB{80, 200, 220} + PaleCyan = RGB{200, 255, 255} + AliceBlue = RGB{230, 245, 255} + IceCyan = RGB{240, 255, 255} + + // --- Blue --- + DeepNavy = RGB{15, 25, 50} + DeepIndigo = RGB{40, 0, 180} + NavyBlue = RGB{30, 60, 120} + CobaltBlue = RGB{50, 80, 200} + SteelBlue = RGB{60, 100, 180} + MediumBlue = RGB{60, 120, 200} + RoyalBlue = RGB{65, 105, 225} + CeruleanBlue = RGB{80, 140, 220} + Cornflower = RGB{80, 130, 255} + DodgerBlue = RGB{40, 180, 255} + LightBlue = RGB{120, 170, 255} + LightSkyBlue = RGB{135, 206, 250} + BabyBlue = RGB{160, 210, 255} + Blue = RGB{0, 0, 255} + + // --- Purple / Violet --- + DeepPurple = RGB{60, 20, 80} + DarkViolet = RGB{120, 40, 180} + MutedPurple = RGB{160, 100, 160} + MediumPurple = RGB{170, 100, 210} + ElectricViolet = RGB{180, 130, 255} + LightOrchid = RGB{200, 130, 210} + Orchid = RGB{200, 120, 220} + PaleVioletRed = RGB{219, 112, 147} + SoftLavender = RGB{220, 150, 230} + PaleLavender = RGB{220, 180, 255} + + // --- Pink / Rose --- + RoseRed = RGB{255, 60, 120} + HotMagenta = RGB{255, 60, 200} + HotPink = RGB{255, 140, 200} + PalePink = RGB{255, 145, 220} + LightPink = RGB{255, 182, 193} + Pink = RGB{255, 192, 203} + Magenta = RGB{255, 0, 255} +) diff --git a/rgb.go b/rgb.go new file mode 100644 index 0000000..04bd449 --- /dev/null +++ b/rgb.go @@ -0,0 +1,135 @@ +package color + +import ( + "errors" + stdcolor "image/color" +) + +// RGB represents a 24-bit color +type RGB struct { + R uint8 `toml:"r"` + G uint8 `toml:"g"` + B uint8 `toml:"b"` +} + +var _ stdcolor.Color = RGB{} + +// RGBA implements image/color.Color. Alpha is always opaque, so the +// premultiplied result equals the non-premultiplied one. +func (c RGB) RGBA() (r, g, b, a uint32) { + return uint32(c.R) * 0x101, uint32(c.G) * 0x101, uint32(c.B) * 0x101, 0xffff +} + +// From converts any image/color.Color to RGB, un-premultiplying by alpha and +// discarding it. Fully transparent input yields Black. +func From(c stdcolor.Color) RGB { + r, g, b, a := c.RGBA() + switch a { + case 0: + return RGB{} + case 0xffff: + return RGB{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8)} + } + // r,g,b <= a <= 0xffff, so r*0xffff <= 0xfffe0001 and stays in uint32 + return RGB{ + R: uint8(r * 0xffff / a >> 8), + G: uint8(g * 0xffff / a >> 8), + B: uint8(b * 0xffff / a >> 8), + } +} + +// Lerp linearly interpolates from c to other by t, clamped to [0,1] +func (c RGB) Lerp(other RGB, t float64) RGB { + if t <= 0 { + return c + } + if t >= 1 { + return other + } + return RGB{ + R: clampU8(float64(c.R) + (float64(other.R)-float64(c.R))*t), + G: clampU8(float64(c.G) + (float64(other.G)-float64(c.G))*t), + B: clampU8(float64(c.B) + (float64(other.B)-float64(c.B))*t), + } +} + +// Luma returns Rec. 601 luminance: R*0.299 + G*0.587 + B*0.114 +func Luma(c RGB) uint8 { + return uint8((int(c.R)*299 + int(c.G)*587 + int(c.B)*114) / 1000) +} + +// RedmeanDistance returns squared perceptually-weighted distance between a and b. +// Monotonic in perceived difference; use for nearest-color search, not as an +// absolute metric. +// Formula: https://en.wikipedia.org/wiki/Color_difference#sRGB +func RedmeanDistance(a, b RGB) int { + rmean := (int(a.R) + int(b.R)) / 2 + dr := int(a.R) - int(b.R) + dg := int(a.G) - int(b.G) + db := int(a.B) - int(b.B) + return (((512 + rmean) * dr * dr) >> 8) + 4*dg*dg + (((767 - rmean) * db * db) >> 8) +} + +// Hex returns the color as "#rrggbb" +func (c RGB) Hex() string { + const d = "0123456789abcdef" + b := [7]byte{ + '#', + d[c.R>>4], d[c.R&0xf], + d[c.G>>4], d[c.G&0xf], + d[c.B>>4], d[c.B&0xf], + } + return string(b[:]) +} + +var errHex = errors.New("color: invalid hex string") + +// ParseHex accepts "#rgb", "#rrggbb", and the same forms without the leading '#' +func ParseHex(s string) (RGB, error) { + if len(s) > 0 && s[0] == '#' { + s = s[1:] + } + switch len(s) { + case 3: + r, ok0 := nibble(s[0]) + g, ok1 := nibble(s[1]) + b, ok2 := nibble(s[2]) + if !ok0 || !ok1 || !ok2 { + return RGB{}, errHex + } + return RGB{R: r * 0x11, G: g * 0x11, B: b * 0x11}, nil + case 6: + var v [3]uint8 + for i := range v { + hi, ok0 := nibble(s[i*2]) + lo, ok1 := nibble(s[i*2+1]) + if !ok0 || !ok1 { + return RGB{}, errHex + } + v[i] = hi<<4 | lo + } + return RGB{R: v[0], G: v[1], B: v[2]}, nil + } + return RGB{}, errHex +} + +// MustParseHex panics on invalid input; for package-level initializers +func MustParseHex(s string) RGB { + c, err := ParseHex(s) + if err != nil { + panic(err) + } + return c +} + +func nibble(b byte) (uint8, bool) { + switch { + case b >= '0' && b <= '9': + return b - '0', true + case b >= 'a' && b <= 'f': + return b - 'a' + 10, true + case b >= 'A' && b <= 'F': + return b - 'A' + 10, true + } + return 0, false +}