v0.1.0 initial commit

This commit is contained in:
2026-07-14 13:00:12 -04:00
commit 46847d0529
8 changed files with 649 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
.idea
bin/
dev/
logs/
log/
examples/
catalog.txt
combined.txt
+28
View File
@@ -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.
+96
View File
@@ -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`.
+187
View File
@@ -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}
}
+28
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
module github.com/lixenwraith/color
go 1.26.0
+163
View File
@@ -0,0 +1,163 @@
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}
)
`
+136
View File
@@ -0,0 +1,136 @@
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
}
`