v0.1.1 true color extracted to a standalone package, lut256 lazy build

This commit is contained in:
2026-07-14 13:04:27 -04:00
parent aa22225c61
commit 21041633b2
33 changed files with 480 additions and 745 deletions
-187
View File
@@ -1,187 +0,0 @@
package terminal
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
// Y = R*0.299 + G*0.587 + B*0.114, integer math
func Grayscale(c RGB) RGB {
gray := uint8((int(c.R)*299 + int(c.G)*587 + int(c.B)*114) / 1000)
return RGB{R: gray, G: gray, B: gray}
}
+72 -71
View File
@@ -1,5 +1,12 @@
package terminal
import (
"sync"
"sync/atomic"
"github.com/lixenwraith/color"
)
// ColorMode indicates terminal color capability
type ColorMode uint8
@@ -8,85 +15,89 @@ const (
ColorModeTrueColor // 24-bit RGB
)
// RGB represents a 24-bit color
type RGB struct {
R uint8 `toml:"r"`
G uint8 `toml:"g"`
B uint8 `toml:"b"`
}
// 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),
}
}
// RGBBlack is the zero value black color
var RGBBlack = RGB{0, 0, 0}
// 6-bit quantized LUT for Redmean-based 256-color mapping
// 64×64×64 = 262,144 bytes, fits in L2 cache
var lut256 [64 * 64 * 64]uint8
const lut256Size = 64 * 64 * 64
func init() {
// Pre-compute Redmean-based palette mapping for all 6-bit quantized RGB values
for r := 0; r < 64; r++ {
for g := 0; g < 64; g++ {
for b := 0; b < 64; b++ {
// Expand 6-bit to 8-bit (shift left 2, add 2 for midpoint)
r8 := (r << 2) | 2
g8 := (g << 2) | 2
b8 := (b << 2) | 2
lut256[r<<12|g<<6|b] = computeRedmean256(r8, g8, b8)
}
}
// init() -> first-use build. Construction is ~63M Redmean evaluations
// and 256 KiB resident; truecolor sessions never read the table.
var (
lut256Ptr atomic.Pointer[[lut256Size]uint8]
lut256Once sync.Once
)
// lut256 returns the palette LUT, building it on first use.
// Fast path is an acquire load plus a predictable branch, and inlines into RGBTo256.
func lut256() *[lut256Size]uint8 {
if p := lut256Ptr.Load(); p != nil {
return p
}
return lut256Build()
}
// computeRedmean256 finds the nearest 256-palette index using Redmean distance
// Called only at init() to populate LUT
func computeRedmean256(r, g, b int) uint8 {
// lut256Build populates the table. Cold path, kept out of line so lut256 stays inlinable.
// The release store publishes the fully written array to acquire loads in lut256.
//
//go:noinline
func lut256Build() *[lut256Size]uint8 {
lut256Once.Do(func() {
t := new([lut256Size]uint8)
for r := range 64 {
for g := range 64 {
for b := range 64 {
// Expand 6-bit to 8-bit (shift left 2, add 2 for midpoint)
c := color.RGB{
R: uint8(r<<2 | 2),
G: uint8(g<<2 | 2),
B: uint8(b<<2 | 2),
}
t[r<<12|g<<6|b] = computeRedmean256(c)
}
}
}
lut256Ptr.Store(t)
})
return lut256Ptr.Load()
}
// WarmPalette256 forces LUT construction. Idempotent, safe for concurrent use.
// Call before the first render when ColorMode is ColorMode256.
// RGBTo256 runs on the render path under the terminal mutex; a lazy build there stalls the frame.
func WarmPalette256() { _ = lut256() }
// computeRedmean256 finds the nearest 256-palette index using Redmean distance called from lut256Build, not init()
func computeRedmean256(c color.RGB) uint8 {
// Grayscale fast path
if r == g && g == b {
if r < 8 {
if c.R == c.G && c.G == c.B {
if c.R < 8 {
return 16
}
if r > 238 {
if c.R > 238 {
return 231
}
return uint8(232 + (r-8)/10)
return uint8(232 + (int(c.R)-8)/10)
}
bestIdx := uint8(16)
minDist := 1 << 30
// Search 6×6×6 cube (indices 16-231)
for i := 0; i < 216; i++ {
cr := cubeValues[i/36]
cg := cubeValues[(i/6)%6]
cb := cubeValues[i%6]
d := redmeanDistance(r, g, b, cr, cg, cb)
if d < minDist {
for i := range 216 {
cand := color.RGB{
R: cubeValues[i/36],
G: cubeValues[(i/6)%6],
B: cubeValues[i%6],
}
if d := color.RedmeanDistance(c, cand); d < minDist {
minDist = d
bestIdx = uint8(16 + i)
}
}
// Search grayscale ramp (indices 232-255)
for i := 0; i < 24; i++ {
gray := 8 + i*10
d := redmeanDistance(r, g, b, gray, gray, gray)
if d < minDist {
for i := range 24 {
g := uint8(8 + i*10)
if d := color.RedmeanDistance(c, color.RGB{R: g, G: g, B: g}); d < minDist {
minDist = d
bestIdx = uint8(232 + i)
}
@@ -95,21 +106,11 @@ func computeRedmean256(r, g, b int) uint8 {
return bestIdx
}
// redmeanDistance calculates perceptually-weighted color distance
// Formula: https://en.wikipedia.org/wiki/Color_difference#sRGB
func redmeanDistance(r1, g1, b1, r2, g2, b2 int) int {
rmean := (r1 + r2) / 2
dr := r1 - r2
dg := g1 - g2
db := b1 - b2
return (((512 + rmean) * dr * dr) >> 8) + 4*dg*dg + (((767 - rmean) * db * db) >> 8)
}
// Color cube values for 6×6×6 palette (indices 16-231)
var cubeValues = [6]int{0, 95, 135, 175, 215, 255}
var cubeValues = [6]uint8{0, 95, 135, 175, 215, 255}
// RGBTo256 converts RGB to nearest 256-color palette index
// O(1) lookup via pre-computed Redmean LUT
func RGBTo256(c RGB) uint8 {
return lut256[int(c.R>>2)<<12|int(c.G>>2)<<6|int(c.B>>2)]
// RGBTo256 converts RGB to nearest 256-color palette index.
// O(1) via the Redmean LUT; the first call builds it (see WarmPalette256).
func RGBTo256(c color.RGB) uint8 {
return lut256()[int(c.R>>2)<<12|int(c.G>>2)<<6|int(c.B>>2)]
}
+4 -3
View File
@@ -5,6 +5,7 @@ import (
"os"
"time"
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
"github.com/lixenwraith/terminal/inline"
)
@@ -12,9 +13,9 @@ import (
func main() {
p := inline.New(os.Stdout)
name := inline.Fg(terminal.LightSkyBlue).Attr(terminal.AttrBold)
okSt := inline.Fg(terminal.LimeGreen).Attr(terminal.AttrBold)
dim := inline.Fg(terminal.IronGray)
name := inline.Fg(color.LightSkyBlue).Attr(terminal.AttrBold)
okSt := inline.Fg(color.LimeGreen).Attr(terminal.AttrBold)
dim := inline.Fg(color.IronGray)
pkgs := []string{"openssl", "zlib", "curl", "git", "go"}
frame := 0
+2 -1
View File
@@ -1,8 +1,9 @@
module github.com/lixenwraith/terminal
go 1.26.4
go 1.26.0
require (
github.com/lixenwraith/color v0.0.0-20260714170240-79433f872c42
golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0
)
+2
View File
@@ -1,3 +1,5 @@
github.com/lixenwraith/color v0.0.0-20260714170240-79433f872c42 h1:wBqu4zX4jtEOhQVTI1x492N/DKrNpawY4RSxGyDlEXM=
github.com/lixenwraith/color v0.0.0-20260714170240-79433f872c42/go.mod h1:p02MsAGqlmZu3sc6BPYCKmaedF+lqBoijnuu5cOrYeo=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
+4
View File
@@ -48,6 +48,10 @@ func New(w io.Writer) *Printer {
}
p.color = p.tty != nil && os.Getenv("NO_COLOR") == ""
p.mode = terminal.DetectColorMode()
// Keep the LUT build out of the first Paint call
if p.color && p.mode == terminal.ColorMode256 {
terminal.WarmPalette256()
}
return p
}
+4 -3
View File
@@ -7,22 +7,23 @@ import (
"strings"
"unicode/utf8"
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
// Style describes text appearance; zero value is unstyled.
// Composable: inline.Fg(terminal.Amber).Attr(terminal.AttrBold)
type Style struct {
fg, bg terminal.RGB
fg, bg color.RGB
hasFg, hasBg bool
attr terminal.Attr
}
// Fg starts a style with foreground color
func Fg(c terminal.RGB) Style { return Style{fg: c, hasFg: true} }
func Fg(c color.RGB) Style { return Style{fg: c, hasFg: true} }
// Bg sets background color
func (s Style) Bg(c terminal.RGB) Style { s.bg, s.hasBg = c, true; return s }
func (s Style) Bg(c color.RGB) Style { s.bg, s.hasBg = c, true; return s }
// Attr adds attribute bits
func (s Style) Attr(a terminal.Attr) Style { s.attr |= a; return s }
+10 -8
View File
@@ -2,6 +2,8 @@ package terminal
import (
"bufio"
"github.com/lixenwraith/color"
)
// outputBuffer manages double-buffered terminal output with diffing
@@ -17,8 +19,8 @@ type outputBuffer struct {
cursorValid bool
// Style state for coalescing
lastFg RGB
lastBg RGB
lastFg color.RGB
lastBg color.RGB
lastAttr Attr
lastValid bool
}
@@ -251,7 +253,7 @@ func (o *outputBuffer) moveCursorTo(w *bufio.Writer, x, y int) {
}
// writeStyleCoalesced emits a single combined SGR sequence when style changes
func (o *outputBuffer) writeStyleCoalesced(w *bufio.Writer, fg, bg RGB, attr Attr) {
func (o *outputBuffer) writeStyleCoalesced(w *bufio.Writer, fg, bg color.RGB, attr Attr) {
// Check what changed
fgChanged := !o.lastValid || fg != o.lastFg || (attr&AttrFg256) != (o.lastAttr&AttrFg256)
bgChanged := !o.lastValid || bg != o.lastBg || (attr&AttrBg256) != (o.lastAttr&AttrBg256)
@@ -340,7 +342,7 @@ func (o *outputBuffer) writeStyleCoalesced(w *bufio.Writer, fg, bg RGB, attr Att
}
// writeFgInline writes fg color parameters (no CSI prefix, no 'm' suffix)
func (o *outputBuffer) writeFgInline(w *bufio.Writer, fg RGB, attr Attr) {
func (o *outputBuffer) writeFgInline(w *bufio.Writer, fg color.RGB, attr Attr) {
w.WriteByte(';')
if attr&AttrFg256 != 0 {
// 256-color: 38;5;N
@@ -362,7 +364,7 @@ func (o *outputBuffer) writeFgInline(w *bufio.Writer, fg RGB, attr Attr) {
}
// writeBgInline writes bg color parameters (no CSI prefix, no 'm' suffix)
func (o *outputBuffer) writeBgInline(w *bufio.Writer, bg RGB, attr Attr) {
func (o *outputBuffer) writeBgInline(w *bufio.Writer, bg color.RGB, attr Attr) {
w.WriteByte(';')
if attr&AttrBg256 != 0 {
// 256-color: 48;5;N
@@ -384,7 +386,7 @@ func (o *outputBuffer) writeBgInline(w *bufio.Writer, bg RGB, attr Attr) {
}
// writeFgFull writes complete fg color sequence
func (o *outputBuffer) writeFgFull(w *bufio.Writer, fg RGB, attr Attr) {
func (o *outputBuffer) writeFgFull(w *bufio.Writer, fg color.RGB, attr Attr) {
if attr&AttrFg256 != 0 {
w.Write(csiFg256)
writeInt(w, int(fg.R))
@@ -405,7 +407,7 @@ func (o *outputBuffer) writeFgFull(w *bufio.Writer, fg RGB, attr Attr) {
}
// writeBgFull writes complete bg color sequence
func (o *outputBuffer) writeBgFull(w *bufio.Writer, bg RGB, attr Attr) {
func (o *outputBuffer) writeBgFull(w *bufio.Writer, bg color.RGB, attr Attr) {
if attr&AttrBg256 != 0 {
w.Write(csiBg256)
writeInt(w, int(bg.R))
@@ -435,7 +437,7 @@ func (o *outputBuffer) forceFullRedraw() {
}
// clear writes a clear screen with specified background
func (o *outputBuffer) clear(bg RGB) {
func (o *outputBuffer) clear(bg color.RGB) {
w := o.writer
w.Write(csiSGR0)
o.writeBgFull(w, bg, 0)
-159
View File
@@ -1,159 +0,0 @@
package terminal
// Generic TrueColor palette — pure RGB definitions without game semantics
// Game systems and renderers reference these via aliases 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.
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}
)
+13 -6
View File
@@ -5,6 +5,8 @@ import (
"os"
"sync"
"sync/atomic"
"github.com/lixenwraith/color"
)
// Attr represents text attributes (bitmask)
@@ -28,8 +30,8 @@ const AttrStyle Attr = AttrBold | AttrDim | AttrItalic | AttrUnderline | AttrBli
// Cell represents a single terminal cell
type Cell struct {
Rune rune
Fg RGB
Bg RGB
Fg color.RGB
Bg color.RGB
Attrs Attr
}
@@ -55,7 +57,7 @@ type Terminal interface {
Flush(cells []Cell, width, height int)
// Clear fills screen with specified background color
Clear(bg RGB)
Clear(bg color.RGB)
// SetCursorVisible shows/hides cursor
SetCursorVisible(visible bool)
@@ -172,8 +174,13 @@ func (t *termImpl) Init() error {
// Invisible cursor
t.cursorVisible.Store(false)
// Build the LUT at startup, not on the first frame under t.mu
if t.output.colorMode == ColorMode256 {
WarmPalette256()
}
// Clear screen
t.output.clear(RGBBlack)
t.output.clear(color.Black)
// Start input reader
t.input.start()
@@ -259,7 +266,7 @@ func (t *termImpl) Flush(cells []Cell, width, height int) {
}
// Clear fills screen with background color
func (t *termImpl) Clear(bg RGB) {
func (t *termImpl) Clear(bg color.RGB) {
t.mu.Lock()
defer t.mu.Unlock()
@@ -336,7 +343,7 @@ func (t *termImpl) Sync() {
// Clear terminal before full redraw
// Diff-based rendering assumes physical terminal matches front buffer state
t.output.clear(RGBBlack)
t.output.clear(color.Black)
t.output.forceFullRedraw()
}
+1 -1
View File
@@ -200,7 +200,7 @@ Pure logic, no rendering — usable independently:
- Width calculations count runes, not terminal columns; East Asian wide
characters and combining marks are not width-aware.
- Zero-value `terminal.RGB` in style fields generally means "inherit"
- Zero-value `color.RGB` in style fields generally means "inherit"
(widget default or row background) — check specific widget docs.
- Mouse hit testing: `TabBar` returns `[]TabBounds`; other widgets require
application-side geometry from the regions used.
+14 -12
View File
@@ -1,6 +1,7 @@
package tui
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
@@ -36,7 +37,7 @@ const (
// --- Box Rendering ---
// Box draws border around region edge
func (r Region) Box(line LineType, fg terminal.RGB) {
func (r Region) Box(line LineType, fg color.RGB) {
if r.W < 2 || r.H < 2 {
return
}
@@ -45,7 +46,7 @@ func (r Region) Box(line LineType, fg terminal.RGB) {
}
chars := boxChars[line]
bg := terminal.RGB{} // Transparent (use existing bg)
bg := color.RGB{} // Transparent (use existing bg)
// Corners
r.Cell(0, 0, chars[boxTL], fg, bg, terminal.AttrNone)
@@ -67,7 +68,7 @@ func (r Region) Box(line LineType, fg terminal.RGB) {
}
// BoxFilled draws border and fills interior with background
func (r Region) BoxFilled(line LineType, fg, bg terminal.RGB) {
func (r Region) BoxFilled(line LineType, fg, bg color.RGB) {
// Fill interior first
for y := 1; y < r.H-1; y++ {
for x := 1; x < r.W-1; x++ {
@@ -81,7 +82,7 @@ func (r Region) BoxFilled(line LineType, fg, bg terminal.RGB) {
// --- Line rendering ---
// HLine draws horizontal line across region width at row y
func (r Region) HLine(y int, line LineType, fg terminal.RGB) {
func (r Region) HLine(y int, line LineType, fg color.RGB) {
if y < 0 || y >= r.H {
return
}
@@ -90,12 +91,12 @@ func (r Region) HLine(y int, line LineType, fg terminal.RGB) {
}
ch := boxChars[line][boxH]
for x := 0; x < r.W; x++ {
r.Cell(x, y, ch, fg, terminal.RGB{}, terminal.AttrNone)
r.Cell(x, y, ch, fg, color.RGB{}, terminal.AttrNone)
}
}
// VLine draws vertical line across region height at column x
func (r Region) VLine(x int, line LineType, fg terminal.RGB) {
func (r Region) VLine(x int, line LineType, fg color.RGB) {
if x < 0 || x >= r.W {
return
}
@@ -104,12 +105,12 @@ func (r Region) VLine(x int, line LineType, fg terminal.RGB) {
}
ch := boxChars[line][boxV]
for y := 0; y < r.H; y++ {
r.Cell(x, y, ch, fg, terminal.RGB{}, terminal.AttrNone)
r.Cell(x, y, ch, fg, color.RGB{}, terminal.AttrNone)
}
}
// Divider draws horizontal line with optional centered label
func (r Region) Divider(y int, label string, line LineType, fg terminal.RGB) {
func (r Region) Divider(y int, label string, line LineType, fg color.RGB) {
if y < 0 || y >= r.H {
return
}
@@ -121,7 +122,7 @@ func (r Region) Divider(y int, label string, line LineType, fg terminal.RGB) {
// Fill with horizontal line
for x := 0; x < r.W; x++ {
r.Cell(x, y, hChar, fg, terminal.RGB{}, terminal.AttrNone)
r.Cell(x, y, hChar, fg, color.RGB{}, terminal.AttrNone)
}
// Center label if provided
@@ -134,7 +135,7 @@ func (r Region) Divider(y int, label string, line LineType, fg terminal.RGB) {
}
startX := (r.W - textLen) / 2
for i, ch := range text {
r.Cell(startX+i, y, ch, fg, terminal.RGB{}, terminal.AttrBold)
r.Cell(startX+i, y, ch, fg, color.RGB{}, terminal.AttrBold)
}
}
}
@@ -142,7 +143,7 @@ func (r Region) Divider(y int, label string, line LineType, fg terminal.RGB) {
// --- Card rendering ---
// Card draws titled border and returns inner content region
func (r Region) Card(title string, line LineType, fg terminal.RGB) Region {
func (r Region) Card(title string, line LineType, fg color.RGB) Region {
r.Box(line, fg)
if title != "" && r.W > 4 {
@@ -152,8 +153,9 @@ func (r Region) Card(title string, line LineType, fg terminal.RGB) Region {
displayTitle = Truncate(displayTitle, maxTitleLen)
}
titleX := (r.W - RuneLen(displayTitle) - 2) / 2
r.Text(titleX, 0, " "+displayTitle+" ", fg, terminal.RGB{}, terminal.AttrBold)
r.Text(titleX, 0, " "+displayTitle+" ", fg, color.RGB{}, terminal.AttrBold)
}
return r.Inset(1)
}
+18 -14
View File
@@ -1,6 +1,9 @@
package tui
import "github.com/lixenwraith/terminal"
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
// Button defines a single button in a button bar
type Button struct {
@@ -18,27 +21,27 @@ type ButtonBarOpts struct {
// ButtonStyle defines button bar colors
type ButtonStyle struct {
LabelFg terminal.RGB
LabelBg terminal.RGB
KeyFg terminal.RGB
FocusFg terminal.RGB
FocusBg terminal.RGB
Bg terminal.RGB
LabelFg color.RGB
LabelBg color.RGB
KeyFg color.RGB
FocusFg color.RGB
FocusBg color.RGB
Bg color.RGB
}
// DefaultButtonStyle returns default button colors with dark background
func DefaultButtonStyle() ButtonStyle {
return DefaultButtonStyleFrom(terminal.RGB{R: 25, G: 25, B: 35})
return DefaultButtonStyleFrom(color.RGB{R: 25, G: 25, B: 35})
}
// DefaultButtonStyleFrom returns default button colors using the given background
func DefaultButtonStyleFrom(bg terminal.RGB) ButtonStyle {
func DefaultButtonStyleFrom(bg color.RGB) ButtonStyle {
return ButtonStyle{
LabelFg: terminal.RGB{R: 200, G: 200, B: 200},
LabelBg: terminal.RGB{R: 50, G: 50, B: 60},
KeyFg: terminal.RGB{R: 130, G: 130, B: 150},
FocusFg: terminal.RGB{R: 255, G: 255, B: 255},
FocusBg: terminal.RGB{R: 60, G: 80, B: 120},
LabelFg: color.RGB{R: 200, G: 200, B: 200},
LabelBg: color.RGB{R: 50, G: 50, B: 60},
KeyFg: color.RGB{R: 130, G: 130, B: 150},
FocusFg: color.RGB{R: 255, G: 255, B: 255},
FocusBg: color.RGB{R: 60, G: 80, B: 120},
Bg: bg,
}
}
@@ -125,3 +128,4 @@ func (r Region) ButtonBar(y int, buttons []Button, opts ButtonBarOpts) {
}
}
}
+6 -4
View File
@@ -1,6 +1,7 @@
package tui
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
@@ -15,7 +16,7 @@ const (
)
// Checkbox draws a checkbox indicator
func (r Region) Checkbox(x, y int, state CheckState, fg terminal.RGB) {
func (r Region) Checkbox(x, y int, state CheckState, fg color.RGB) {
if x < 0 || x+2 >= r.W || y < 0 || y >= r.H {
return
}
@@ -30,7 +31,8 @@ func (r Region) Checkbox(x, y int, state CheckState, fg terminal.RGB) {
case CheckPlus:
ch = '+'
}
r.Cell(x, y, '[', fg, terminal.RGB{}, terminal.AttrNone)
r.Cell(x+1, y, ch, fg, terminal.RGB{}, terminal.AttrNone)
r.Cell(x+2, y, ']', fg, terminal.RGB{}, terminal.AttrNone)
r.Cell(x, y, '[', fg, color.RGB{}, terminal.AttrNone)
r.Cell(x+1, y, ch, fg, color.RGB{}, terminal.AttrNone)
r.Cell(x+2, y, ']', fg, color.RGB{}, terminal.AttrNone)
}
+25 -21
View File
@@ -1,6 +1,9 @@
package tui
import "github.com/lixenwraith/terminal"
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
// ConfirmResult represents dialog outcome
type ConfirmResult uint8
@@ -98,31 +101,31 @@ type ConfirmOpts struct {
// ConfirmStyle defines dialog colors
type ConfirmStyle struct {
BorderFg terminal.RGB
TitleFg terminal.RGB
MessageFg terminal.RGB
Bg terminal.RGB
ButtonFg terminal.RGB
ButtonBg terminal.RGB
ButtonFocusFg terminal.RGB
ButtonFocusBg terminal.RGB
DestructiveFg terminal.RGB
DestructiveBg terminal.RGB
BorderFg color.RGB
TitleFg color.RGB
MessageFg color.RGB
Bg color.RGB
ButtonFg color.RGB
ButtonBg color.RGB
ButtonFocusFg color.RGB
ButtonFocusBg color.RGB
DestructiveFg color.RGB
DestructiveBg color.RGB
}
// DefaultConfirmStyle returns default dialog colors
func DefaultConfirmStyle() ConfirmStyle {
return ConfirmStyle{
BorderFg: terminal.RGB{R: 100, G: 100, B: 120},
TitleFg: terminal.RGB{R: 255, G: 255, B: 255},
MessageFg: terminal.RGB{R: 200, G: 200, B: 200},
Bg: terminal.RGB{R: 30, G: 30, B: 40},
ButtonFg: terminal.RGB{R: 180, G: 180, B: 180},
ButtonBg: terminal.RGB{R: 50, G: 50, B: 60},
ButtonFocusFg: terminal.RGB{R: 255, G: 255, B: 255},
ButtonFocusBg: terminal.RGB{R: 60, G: 80, B: 120},
DestructiveFg: terminal.RGB{R: 255, G: 255, B: 255},
DestructiveBg: terminal.RGB{R: 180, G: 60, B: 60},
BorderFg: color.RGB{R: 100, G: 100, B: 120},
TitleFg: color.RGB{R: 255, G: 255, B: 255},
MessageFg: color.RGB{R: 200, G: 200, B: 200},
Bg: color.RGB{R: 30, G: 30, B: 40},
ButtonFg: color.RGB{R: 180, G: 180, B: 180},
ButtonBg: color.RGB{R: 50, G: 50, B: 60},
ButtonFocusFg: color.RGB{R: 255, G: 255, B: 255},
ButtonFocusBg: color.RGB{R: 60, G: 80, B: 120},
DestructiveFg: color.RGB{R: 255, G: 255, B: 255},
DestructiveBg: color.RGB{R: 180, G: 60, B: 60},
}
}
@@ -322,3 +325,4 @@ func (r Region) AlertDialog(opts AlertOpts) Region {
return content.Sub(0, 0, content.W, buttonY-1)
}
+18 -16
View File
@@ -1,6 +1,7 @@
package tui
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
@@ -16,27 +17,27 @@ type EditorOpts struct {
// EditorStyle defines editor colors
type EditorStyle struct {
TextFg terminal.RGB
TextBg terminal.RGB
CursorFg terminal.RGB
CursorBg terminal.RGB
LineNumFg terminal.RGB
LineNumBg terminal.RGB
CurrentLineBg terminal.RGB
BorderFg terminal.RGB
TextFg color.RGB
TextBg color.RGB
CursorFg color.RGB
CursorBg color.RGB
LineNumFg color.RGB
LineNumBg color.RGB
CurrentLineBg color.RGB
BorderFg color.RGB
}
// DefaultEditorStyle returns default colors
func DefaultEditorStyle() EditorStyle {
return EditorStyle{
TextFg: terminal.RGB{R: 220, G: 220, B: 220},
TextBg: terminal.RGB{R: 25, G: 25, B: 35},
CursorFg: terminal.RGB{R: 0, G: 0, B: 0},
CursorBg: terminal.RGB{R: 200, G: 200, B: 200},
LineNumFg: terminal.RGB{R: 100, G: 100, B: 120},
LineNumBg: terminal.RGB{R: 30, G: 30, B: 40},
CurrentLineBg: terminal.RGB{R: 35, G: 35, B: 50},
BorderFg: terminal.RGB{R: 80, G: 80, B: 100},
TextFg: color.RGB{R: 220, G: 220, B: 220},
TextBg: color.RGB{R: 25, G: 25, B: 35},
CursorFg: color.RGB{R: 0, G: 0, B: 0},
CursorBg: color.RGB{R: 200, G: 200, B: 200},
LineNumFg: color.RGB{R: 100, G: 100, B: 120},
LineNumBg: color.RGB{R: 30, G: 30, B: 40},
CurrentLineBg: color.RGB{R: 35, G: 35, B: 50},
BorderFg: color.RGB{R: 80, G: 80, B: 100},
}
}
@@ -195,3 +196,4 @@ func formatLineNum(num, width int) string {
}
return s
}
+19 -15
View File
@@ -1,6 +1,9 @@
package tui
import "github.com/lixenwraith/terminal"
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
// FormField pairs a label with an editable text field
type FormField struct {
@@ -103,25 +106,25 @@ type FormOpts struct {
// FormStyle defines form colors
type FormStyle struct {
LabelFg terminal.RGB
FieldFg terminal.RGB
FieldBg terminal.RGB
FocusBg terminal.RGB
CursorFg terminal.RGB
CursorBg terminal.RGB
Bg terminal.RGB
LabelFg color.RGB
FieldFg color.RGB
FieldBg color.RGB
FocusBg color.RGB
CursorFg color.RGB
CursorBg color.RGB
Bg color.RGB
}
// DefaultFormStyle returns default form colors
func DefaultFormStyle() FormStyle {
return FormStyle{
LabelFg: terminal.RGB{R: 150, G: 150, B: 180},
FieldFg: terminal.RGB{R: 220, G: 220, B: 220},
FieldBg: terminal.RGB{R: 35, G: 35, B: 45},
FocusBg: terminal.RGB{R: 45, G: 45, B: 60},
CursorFg: terminal.RGB{R: 0, G: 0, B: 0},
CursorBg: terminal.RGB{R: 200, G: 200, B: 200},
Bg: terminal.RGB{R: 25, G: 25, B: 35},
LabelFg: color.RGB{R: 150, G: 150, B: 180},
FieldFg: color.RGB{R: 220, G: 220, B: 220},
FieldBg: color.RGB{R: 35, G: 35, B: 45},
FocusBg: color.RGB{R: 45, G: 45, B: 60},
CursorFg: color.RGB{R: 0, G: 0, B: 0},
CursorBg: color.RGB{R: 200, G: 200, B: 200},
Bg: color.RGB{R: 25, G: 25, B: 35},
}
}
@@ -226,3 +229,4 @@ func (r Region) Form(state *FormState, opts FormOpts) int {
return y
}
+6 -4
View File
@@ -1,18 +1,19 @@
package tui
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
// InputOpts configures single-line input field
type InputOpts struct {
Label string
LabelFg terminal.RGB
LabelFg color.RGB
Text string
Cursor int // Cursor position in text (rune index)
CursorBg terminal.RGB
TextFg terminal.RGB
Bg terminal.RGB
CursorBg color.RGB
TextFg color.RGB
Bg color.RGB
}
// Input renders labeled text input field on row y, handling cursor display and horizontal scrolling
@@ -76,3 +77,4 @@ func (r Region) Input(y int, opts InputOpts) {
r.Cell(x+cursor-scroll, y, ' ', opts.TextFg, opts.CursorBg, terminal.AttrNone)
}
}
+12 -8
View File
@@ -1,22 +1,25 @@
package tui
import "github.com/lixenwraith/terminal"
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
// ListItem represents a single row in a scrollable list
type ListItem struct {
Indent int // Left padding in cells
Icon rune // Expand indicator or bullet, 0 = none
IconFg terminal.RGB
IconFg color.RGB
Check CheckState // CheckNone to skip checkbox
CheckFg terminal.RGB
CheckFg color.RGB
Text string
TextStyle Style
}
// ListOpts configures list rendering
type ListOpts struct {
CursorBg terminal.RGB
DefaultBg terminal.RGB
CursorBg color.RGB
DefaultBg color.RGB
IconWidth int // Width reserved for icon, default 2
}
@@ -49,7 +52,7 @@ func (r Region) List(items []ListItem, cursor, scroll int, opts ListOpts) int {
// Clear row
for x := 0; x < r.W; x++ {
r.Cell(x, y, ' ', terminal.RGB{}, bg, terminal.AttrNone)
r.Cell(x, y, ' ', color.RGB{}, bg, terminal.AttrNone)
}
x := item.Indent
@@ -61,7 +64,7 @@ func (r Region) List(items []ListItem, cursor, scroll int, opts ListOpts) int {
x += iconW
// Checkbox
if item.Check != CheckNone || item.CheckFg != (terminal.RGB{}) {
if item.Check != CheckNone || item.CheckFg != (color.RGB{}) {
if x+3 <= r.W {
var ch rune
switch item.Check {
@@ -83,7 +86,7 @@ func (r Region) List(items []ListItem, cursor, scroll int, opts ListOpts) int {
// Text
textStyle := item.TextStyle
if textStyle.Bg == (terminal.RGB{}) {
if textStyle.Bg == (color.RGB{}) {
textStyle.Bg = bg
}
text := item.Text
@@ -97,3 +100,4 @@ func (r Region) List(items []ListItem, cursor, scroll int, opts ListOpts) int {
return rendered
}
+6 -4
View File
@@ -1,6 +1,7 @@
package tui
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
@@ -9,10 +10,10 @@ type ModalOpts struct {
Title string
Hint string // Right-aligned hint text
Border LineType
BorderFg terminal.RGB
TitleFg terminal.RGB
HintFg terminal.RGB
Bg terminal.RGB
BorderFg color.RGB
TitleFg color.RGB
HintFg color.RGB
Bg color.RGB
}
// Modal fills region with background, draws border with title/hint, returns content region
@@ -64,3 +65,4 @@ func (r Region) Modal(opts ModalOpts) Region {
// Return content region
return r.Sub(1, 1, r.W-2, r.H-2)
}
+27 -23
View File
@@ -1,6 +1,9 @@
package tui
import "github.com/lixenwraith/terminal"
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
// OverlayStyle specifies overlay appearance
type OverlayStyle uint8
@@ -17,10 +20,10 @@ type OverlayOpts struct {
Style OverlayStyle
Title string
Border LineType
Bg terminal.RGB
Fg terminal.RGB // Border and title color
TitleBg terminal.RGB // Title bar background, zero = same as Fg
TitleFg terminal.RGB // Title text color, zero = same as Bg
Bg color.RGB
Fg color.RGB // Border and title color
TitleBg color.RGB // Title bar background, zero = same as Fg
TitleFg color.RGB // Title text color, zero = same as Bg
// Modal/Floating positioning (ignored for Fullscreen)
Width int // 0 = 80% of region
@@ -28,7 +31,7 @@ type OverlayOpts struct {
X, Y int // Offset from center, 0 = centered
// Shadow for Floating style
ShadowColor terminal.RGB
ShadowColor color.RGB
}
// DefaultOverlayOpts returns sensible defaults for modal overlay
@@ -37,10 +40,10 @@ func DefaultOverlayOpts(title string) OverlayOpts {
Style: OverlayModal,
Title: title,
Border: LineDouble,
Bg: terminal.RGB{R: 25, G: 25, B: 35},
Fg: terminal.RGB{R: 100, G: 140, B: 180},
TitleBg: terminal.RGB{R: 40, G: 60, B: 90},
TitleFg: terminal.RGB{R: 255, G: 255, B: 255},
Bg: color.RGB{R: 25, G: 25, B: 35},
Fg: color.RGB{R: 100, G: 140, B: 180},
TitleBg: color.RGB{R: 40, G: 60, B: 90},
TitleFg: color.RGB{R: 255, G: 255, B: 255},
}
}
@@ -49,10 +52,10 @@ func FullscreenOverlayOpts(title string) OverlayOpts {
return OverlayOpts{
Style: OverlayFullscreen,
Title: title,
Bg: terminal.RGB{R: 20, G: 20, B: 30},
Fg: terminal.RGB{R: 100, G: 140, B: 180},
TitleBg: terminal.RGB{R: 40, G: 60, B: 90},
TitleFg: terminal.RGB{R: 255, G: 255, B: 255},
Bg: color.RGB{R: 20, G: 20, B: 30},
Fg: color.RGB{R: 100, G: 140, B: 180},
TitleBg: color.RGB{R: 40, G: 60, B: 90},
TitleFg: color.RGB{R: 255, G: 255, B: 255},
}
}
@@ -98,11 +101,11 @@ func (r Region) renderFullscreenOverlay(opts OverlayOpts) OverlayResult {
// Title bar
if opts.Title != "" {
titleBg := opts.TitleBg
if titleBg == (terminal.RGB{}) {
if titleBg == (color.RGB{}) {
titleBg = opts.Fg
}
titleFg := opts.TitleFg
if titleFg == (terminal.RGB{}) {
if titleFg == (color.RGB{}) {
titleFg = opts.Bg
}
@@ -188,11 +191,11 @@ func (r Region) renderModalOverlay(opts OverlayOpts) OverlayResult {
// Title in top border
if opts.Title != "" && contentW > 2 {
titleBg := opts.TitleBg
if titleBg == (terminal.RGB{}) {
if titleBg == (color.RGB{}) {
titleBg = opts.Fg
}
titleFg := opts.TitleFg
if titleFg == (terminal.RGB{}) {
if titleFg == (color.RGB{}) {
titleFg = opts.Bg
}
@@ -224,8 +227,8 @@ func (r Region) renderModalOverlay(opts OverlayOpts) OverlayResult {
func (r Region) renderFloatingOverlay(opts OverlayOpts) OverlayResult {
// Same as modal but with shadow
shadowColor := opts.ShadowColor
if shadowColor == (terminal.RGB{}) {
shadowColor = terminal.RGB{R: 10, G: 10, B: 15}
if shadowColor == (color.RGB{}) {
shadowColor = color.RGB{R: 10, G: 10, B: 15}
}
// Calculate dimensions (same as modal)
@@ -285,11 +288,11 @@ func (r Region) renderFloatingOverlay(opts OverlayOpts) OverlayResult {
if opts.Title != "" && contentW > 2 {
titleBg := opts.TitleBg
if titleBg == (terminal.RGB{}) {
if titleBg == (color.RGB{}) {
titleBg = opts.Fg
}
titleFg := opts.TitleFg
if titleFg == (terminal.RGB{}) {
if titleFg == (color.RGB{}) {
titleFg = opts.Bg
}
@@ -327,7 +330,7 @@ func (r Region) renderBorderTitleOverlay(opts OverlayOpts) OverlayResult {
if opts.Title != "" && r.W > 6 {
titleFg := opts.TitleFg
if titleFg == (terminal.RGB{}) {
if titleFg == (color.RGB{}) {
titleFg = opts.Fg
}
title := " " + opts.Title + " "
@@ -371,3 +374,4 @@ func (o *OverlayState) Hide() {
func (o *OverlayState) Toggle() {
o.Visible = !o.Visible
}
+7 -5
View File
@@ -1,6 +1,7 @@
package tui
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
@@ -8,9 +9,9 @@ import (
type PaneOpts struct {
Title string
Border LineType
BorderFg terminal.RGB
Bg terminal.RGB
TitleFg terminal.RGB
BorderFg color.RGB
Bg color.RGB
TitleFg color.RGB
}
// Pane draws bordered pane with optional title, returns content region
@@ -49,7 +50,7 @@ func (r Region) Pane(opts PaneOpts) Region {
// TitledPane fills region with background, draws centered title at top, returns content region
// Content region starts at row 1 with full width
func (r Region) TitledPane(title string, titleFg, bg terminal.RGB) Region {
func (r Region) TitledPane(title string, titleFg, bg color.RGB) Region {
r.Fill(bg)
if title != "" && r.H > 0 {
r.TextCenter(0, title, titleFg, bg, terminal.AttrBold)
@@ -61,9 +62,10 @@ func (r Region) TitledPane(title string, titleFg, bg terminal.RGB) Region {
}
// TitledPaneFocused is TitledPane with focus-dependent background
func (r Region) TitledPaneFocused(title string, titleFg, bg, focusBg terminal.RGB, focused bool) Region {
func (r Region) TitledPaneFocused(title string, titleFg, bg, focusBg color.RGB, focused bool) Region {
if focused {
bg = focusBg
}
return r.TitledPane(title, titleFg, bg)
}
+7 -5
View File
@@ -1,6 +1,7 @@
package tui
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
@@ -12,7 +13,7 @@ const (
)
// Progress draws horizontal progress bar (0.0-1.0)
func (r Region) Progress(x, y, w int, pct float64, fg, bg terminal.RGB) {
func (r Region) Progress(x, y, w int, pct float64, fg, bg color.RGB) {
if y < 0 || y >= r.H || w <= 0 {
return
}
@@ -43,7 +44,7 @@ func (r Region) Progress(x, y, w int, pct float64, fg, bg terminal.RGB) {
}
// ProgressV draws vertical progress bar (fills bottom-up)
func (r Region) ProgressV(x, y, h int, pct float64, fg, bg terminal.RGB) {
func (r Region) ProgressV(x, y, h int, pct float64, fg, bg color.RGB) {
if x < 0 || x >= r.W || h <= 0 {
return
}
@@ -72,7 +73,7 @@ func (r Region) ProgressV(x, y, h int, pct float64, fg, bg terminal.RGB) {
}
// Spinner draws spinner character based on frame counter
func (r Region) Spinner(x, y int, frame int, fg terminal.RGB) {
func (r Region) Spinner(x, y int, frame int, fg color.RGB) {
if x < 0 || x >= r.W || y < 0 || y >= r.H {
return
}
@@ -80,11 +81,11 @@ func (r Region) Spinner(x, y int, frame int, fg terminal.RGB) {
if idx < 0 {
idx = -idx
}
r.Cell(x, y, spinnerFrames[idx], fg, terminal.RGB{}, terminal.AttrNone)
r.Cell(x, y, spinnerFrames[idx], fg, color.RGB{}, terminal.AttrNone)
}
// Gauge draws labeled gauge with percentage
func (r Region) Gauge(x, y, w int, value, max int, fg, bg terminal.RGB) {
func (r Region) Gauge(x, y, w int, value, max int, fg, bg color.RGB) {
if w < 5 || y < 0 || y >= r.H {
return
}
@@ -122,3 +123,4 @@ func (r Region) Gauge(x, y, w int, value, max int, fg, bg terminal.RGB) {
}
r.Text(x+2+barW, y, label, fg, bg, terminal.AttrNone)
}
+24 -20
View File
@@ -1,6 +1,9 @@
package tui
import "github.com/lixenwraith/terminal"
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
// ProgressType specifies progress indicator variant
type ProgressType uint8
@@ -93,11 +96,11 @@ type ProgressOverlayOpts struct {
Width int // Overlay width, 0 = auto
Cancelable bool // Show cancel hint
CancelKey string // e.g., "Esc"
Fg terminal.RGB
Bg terminal.RGB
BarFg terminal.RGB
BarBg terminal.RGB
AccentFg terminal.RGB // Spinner/highlight color
Fg color.RGB
Bg color.RGB
BarFg color.RGB
BarBg color.RGB
AccentFg color.RGB // Spinner/highlight color
}
// DefaultProgressOpts returns sensible defaults
@@ -111,11 +114,11 @@ func DefaultProgressOpts(title, message string, ptype ProgressType) ProgressOver
BarStyle: BarStyleBlock,
ShowPercent: true,
Width: 40,
Fg: terminal.RGB{R: 220, G: 220, B: 220},
Bg: terminal.RGB{R: 30, G: 30, B: 40},
BarFg: terminal.RGB{R: 80, G: 160, B: 255},
BarBg: terminal.RGB{R: 50, G: 50, B: 60},
AccentFg: terminal.RGB{R: 100, G: 200, B: 255},
Fg: color.RGB{R: 220, G: 220, B: 220},
Bg: color.RGB{R: 30, G: 30, B: 40},
BarFg: color.RGB{R: 80, G: 160, B: 255},
BarBg: color.RGB{R: 50, G: 50, B: 60},
AccentFg: color.RGB{R: 100, G: 200, B: 255},
}
}
@@ -169,12 +172,12 @@ func (r Region) ProgressOverlay(opts ProgressOverlayOpts) Region {
case ProgressStyleShadow:
// Draw shadow first
shadow := r.Sub(overlay.X-r.X+1, overlay.Y-r.Y+1, overlayW, overlayH)
shadow.Fill(terminal.RGB{R: 10, G: 10, B: 15})
shadow.Fill(color.RGB{R: 10, G: 10, B: 15})
borderLine = LineSingle
case ProgressStyleNeon:
borderLine = LineDouble
opts.AccentFg = terminal.RGB{R: 0, G: 255, B: 200}
opts.BarFg = terminal.RGB{R: 255, G: 0, B: 255}
opts.AccentFg = color.RGB{R: 0, G: 255, B: 200}
opts.BarFg = color.RGB{R: 255, G: 0, B: 255}
case ProgressStyleRetro:
borderLine = LineHeavy
opts.BarStyle = BarStyleBlock
@@ -242,7 +245,7 @@ func (r Region) ProgressOverlay(opts ProgressOverlayOpts) Region {
if opts.CancelKey == "" {
hint = "Esc to cancel"
}
content.TextCenter(y, hint, terminal.RGB{R: 120, G: 120, B: 130}, opts.Bg, terminal.AttrDim)
content.TextCenter(y, hint, color.RGB{R: 120, G: 120, B: 130}, opts.Bg, terminal.AttrDim)
}
return overlay
@@ -301,7 +304,7 @@ func (r Region) renderProgressBar(bar Region, opts ProgressOverlayOpts) {
for x := 0; x < barW; x++ {
var ch rune
var fg terminal.RGB
var fg color.RGB
if x < filled {
ch = chars[0]
fg = opts.BarFg
@@ -334,7 +337,7 @@ func (r Region) renderProgressBar(bar Region, opts ProgressOverlayOpts) {
for x := 0; x < barW; x++ {
var ch rune
var fg terminal.RGB
var fg color.RGB
if x >= pos && x < pos+markerW {
ch = chars[0]
fg = opts.BarFg
@@ -365,11 +368,11 @@ func (r Region) renderProgressBar(bar Region, opts ProgressOverlayOpts) {
for x := 0; x < barW; x++ {
var ch rune
var fg terminal.RGB
var fg color.RGB
if x < filled {
ch = chars[0]
// Pulse the color
fg = terminal.RGB{
fg = color.RGB{
R: uint8(float64(opts.BarFg.R) * (0.5 + intensity*0.5)),
G: uint8(float64(opts.BarFg.G) * (0.5 + intensity*0.5)),
B: uint8(float64(opts.BarFg.B) * (0.5 + intensity*0.5)),
@@ -390,7 +393,7 @@ func (r Region) renderProgressBar(bar Region, opts ProgressOverlayOpts) {
// ETA
if opts.ShowETA != "" {
etaX := bar.W - RuneLen(opts.ShowETA)
bar.Text(etaX, 0, opts.ShowETA, terminal.RGB{R: 150, G: 150, B: 160}, opts.Bg, terminal.AttrDim)
bar.Text(etaX, 0, opts.ShowETA, color.RGB{R: 150, G: 150, B: 160}, opts.Bg, terminal.AttrDim)
}
}
@@ -464,3 +467,4 @@ func (p *ProgressState) Dismiss() {
func (p *ProgressState) Show() {
p.Visible = true
}
+9 -5
View File
@@ -1,6 +1,9 @@
package tui
import "github.com/lixenwraith/terminal"
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
// Region represents a rectangular area within a cell buffer
// All coordinates are relative to the region's origin
@@ -63,7 +66,7 @@ func (r Region) Inset(n int) Region {
}
// Cell sets a single cell with bounds checking
func (r Region) Cell(x, y int, ch rune, fg, bg terminal.RGB, attr terminal.Attr) {
func (r Region) Cell(x, y int, ch rune, fg, bg color.RGB, attr terminal.Attr) {
if x < 0 || x >= r.W || y < 0 || y >= r.H {
return
}
@@ -83,17 +86,17 @@ func (r Region) Cell(x, y int, ch rune, fg, bg terminal.RGB, attr terminal.Attr)
}
// Fill fills entire region with background color
func (r Region) Fill(bg terminal.RGB) {
func (r Region) Fill(bg color.RGB) {
for y := 0; y < r.H; y++ {
for x := 0; x < r.W; x++ {
r.Cell(x, y, ' ', terminal.RGB{}, bg, terminal.AttrNone)
r.Cell(x, y, ' ', color.RGB{}, bg, terminal.AttrNone)
}
}
}
// Clear fills region with spaces and zero colors
func (r Region) Clear() {
r.Fill(terminal.RGB{})
r.Fill(color.RGB{})
}
// Width returns region width
@@ -110,3 +113,4 @@ func (r Region) Height() int {
func (r Region) Bounds() (x, y, w, h int) {
return r.X, r.Y, r.W, r.H
}
+8 -6
View File
@@ -1,12 +1,15 @@
package tui
import "github.com/lixenwraith/terminal"
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
// Default spinner frames for Region.Spinner
var spinnerFrames = spinnerSets[SpinnerBraille]
// Text renders text at position, truncates at region edge
func (r Region) Text(x, y int, s string, fg, bg terminal.RGB, attr terminal.Attr) {
func (r Region) Text(x, y int, s string, fg, bg color.RGB, attr terminal.Attr) {
if y < 0 || y >= r.H {
return
}
@@ -40,19 +43,19 @@ func (r Region) TextStyled(x, y int, s string, style Style) {
}
// TextRight renders text right-aligned on row
func (r Region) TextRight(y int, s string, fg, bg terminal.RGB, attr terminal.Attr) {
func (r Region) TextRight(y int, s string, fg, bg color.RGB, attr terminal.Attr) {
x := r.W - RuneLen(s)
r.Text(x, y, s, fg, bg, attr)
}
// TextCenter renders text centered on row
func (r Region) TextCenter(y int, s string, fg, bg terminal.RGB, attr terminal.Attr) {
func (r Region) TextCenter(y int, s string, fg, bg color.RGB, attr terminal.Attr) {
x := (r.W - RuneLen(s)) / 2
r.Text(x, y, s, fg, bg, attr)
}
// TextBlock renders wrapped text within region bounds, returns number of lines rendered
func (r Region) TextBlock(x, y int, text string, fg, bg terminal.RGB, attr terminal.Attr) int {
func (r Region) TextBlock(x, y int, text string, fg, bg color.RGB, attr terminal.Attr) int {
if x >= r.W || y >= r.H || text == "" {
return 0
}
@@ -81,4 +84,3 @@ func (r Region) TextBlock(x, y int, text string, fg, bg terminal.RGB, attr termi
func (r Region) TextBlockStyled(x, y int, text string, style Style) int {
return r.TextBlock(x, y, text, style.Fg, style.Bg, style.Attr)
}
+6 -6
View File
@@ -1,11 +1,12 @@
package tui
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
// ScrollBar draws vertical scrollbar track with thumb
func (r Region) ScrollBar(x int, offset, visible, total int, fg terminal.RGB) {
func (r Region) ScrollBar(x int, offset, visible, total int, fg color.RGB) {
if x < 0 || x >= r.W || r.H < 1 {
return
}
@@ -14,7 +15,7 @@ func (r Region) ScrollBar(x int, offset, visible, total int, fg terminal.RGB) {
if total <= visible || trackH < 3 {
// No scrolling needed or track too small
for y := range trackH {
r.Cell(x, y, '│', fg, terminal.RGB{}, terminal.AttrDim)
r.Cell(x, y, '│', fg, color.RGB{}, terminal.AttrDim)
}
return
}
@@ -42,12 +43,12 @@ func (r Region) ScrollBar(x int, offset, visible, total int, fg terminal.RGB) {
} else {
ch = '░'
}
r.Cell(x, y, ch, fg, terminal.RGB{}, terminal.AttrNone)
r.Cell(x, y, ch, fg, color.RGB{}, terminal.AttrNone)
}
}
// ScrollIndicator draws compact indicator text (Top/Bot/XX%)
func (r Region) ScrollIndicator(y int, offset, visible, total int, fg terminal.RGB) {
func (r Region) ScrollIndicator(y int, offset, visible, total int, fg color.RGB) {
if y < 0 || y >= r.H {
return
}
@@ -68,6 +69,5 @@ func (r Region) ScrollIndicator(y int, offset, visible, total int, fg terminal.R
}
}
r.TextRight(y, text, fg, terminal.RGB{}, terminal.AttrDim)
r.TextRight(y, text, fg, color.RGB{}, terminal.AttrDim)
}
+8 -5
View File
@@ -1,6 +1,9 @@
package tui
import "github.com/lixenwraith/terminal"
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
// BarSection represents one segment of a status bar
type BarSection struct {
@@ -25,7 +28,7 @@ const (
type BarOpts struct {
Separator string // Between sections, default " │ "
SepStyle Style // Separator styling
Bg terminal.RGB
Bg color.RGB
Align BarAlign
Padding int // Left/right padding, default 1
}
@@ -34,7 +37,7 @@ type BarOpts struct {
func DefaultBarOpts() BarOpts {
return BarOpts{
Separator: " │ ",
SepStyle: Style{Fg: terminal.RGB{R: 80, G: 80, B: 100}},
SepStyle: Style{Fg: color.RGB{R: 80, G: 80, B: 100}},
Padding: 1,
Align: BarAlignRight,
}
@@ -55,7 +58,7 @@ func (r Region) StatusBar(y int, sections []BarSection, opts BarOpts) {
// Fill background
for x := 0; x < r.W; x++ {
r.Cell(x, y, ' ', terminal.RGB{}, opts.Bg, terminal.AttrNone)
r.Cell(x, y, ' ', color.RGB{}, opts.Bg, terminal.AttrNone)
}
sepLen := RuneLen(opts.Separator)
@@ -193,7 +196,7 @@ func truncateSections(sections []BarSection, widths []int, sepLen, availW int) (
}
// QuickStatusBar renders simple label:value pairs right-aligned
func (r Region) QuickStatusBar(y int, pairs [][2]string, labelFg, valueFg, bg terminal.RGB) {
func (r Region) QuickStatusBar(y int, pairs [][2]string, labelFg, valueFg, bg color.RGB) {
sections := make([]BarSection, len(pairs))
for i, p := range pairs {
sections[i] = BarSection{
+6 -4
View File
@@ -1,22 +1,24 @@
package tui
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
// Style bundles foreground, background, and attributes for text rendering
type Style struct {
Fg terminal.RGB
Bg terminal.RGB
Fg color.RGB
Bg color.RGB
Attr terminal.Attr
}
// DefaultStyle returns style with zero values (transparent bg)
func DefaultStyle(fg terminal.RGB) Style {
func DefaultStyle(fg color.RGB) Style {
return Style{Fg: fg}
}
// IsZero returns true if style has no colors or attributes set
func (s Style) IsZero() bool {
return s.Fg == (terminal.RGB{}) && s.Bg == (terminal.RGB{}) && s.Attr == terminal.AttrNone
return s.Fg == (color.RGB{}) && s.Bg == (color.RGB{}) && s.Attr == terminal.AttrNone
}
+16 -14
View File
@@ -1,6 +1,7 @@
package tui
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
@@ -18,25 +19,25 @@ type TextFieldOpts struct {
// DefaultTextFieldStyle returns default colors
func DefaultTextFieldStyle() TextFieldStyle {
return TextFieldStyle{
TextFg: terminal.RGB{R: 220, G: 220, B: 220},
TextBg: terminal.RGB{R: 30, G: 30, B: 40},
CursorFg: terminal.RGB{R: 0, G: 0, B: 0},
CursorBg: terminal.RGB{R: 200, G: 200, B: 200},
PlaceholderFg: terminal.RGB{R: 100, G: 100, B: 110},
PrefixFg: terminal.RGB{R: 150, G: 150, B: 180},
BorderFg: terminal.RGB{R: 80, G: 80, B: 100},
TextFg: color.RGB{R: 220, G: 220, B: 220},
TextBg: color.RGB{R: 30, G: 30, B: 40},
CursorFg: color.RGB{R: 0, G: 0, B: 0},
CursorBg: color.RGB{R: 200, G: 200, B: 200},
PlaceholderFg: color.RGB{R: 100, G: 100, B: 110},
PrefixFg: color.RGB{R: 150, G: 150, B: 180},
BorderFg: color.RGB{R: 80, G: 80, B: 100},
}
}
// TextFieldStyle defines text field colors
type TextFieldStyle struct {
TextFg terminal.RGB
TextBg terminal.RGB
CursorFg terminal.RGB
CursorBg terminal.RGB
PlaceholderFg terminal.RGB
PrefixFg terminal.RGB
BorderFg terminal.RGB
TextFg color.RGB
TextBg color.RGB
CursorFg color.RGB
CursorBg color.RGB
PlaceholderFg color.RGB
PrefixFg color.RGB
BorderFg color.RGB
}
// TextField renders text field and returns content height used
@@ -169,3 +170,4 @@ func boolToInt(b bool) int {
}
return 0
}
+52 -49
View File
@@ -1,63 +1,66 @@
package tui
import "github.com/lixenwraith/terminal"
import (
"github.com/lixenwraith/color"
)
// Theme defines semantic colors for TUI components
type Theme struct {
Bg terminal.RGB
Fg terminal.RGB
FocusBg terminal.RGB
CursorBg terminal.RGB
Bg color.RGB
Fg color.RGB
FocusBg color.RGB
CursorBg color.RGB
Selected terminal.RGB
Unselected terminal.RGB
Partial terminal.RGB
Error terminal.RGB
Warning terminal.RGB
Selected color.RGB
Unselected color.RGB
Partial color.RGB
Error color.RGB
Warning color.RGB
Border terminal.RGB
HeaderBg terminal.RGB
HeaderFg terminal.RGB
StatusFg terminal.RGB
HintFg terminal.RGB
InputBg terminal.RGB
Border color.RGB
HeaderBg color.RGB
HeaderFg color.RGB
StatusFg color.RGB
HintFg color.RGB
InputBg color.RGB
DirFg terminal.RGB
FileFg terminal.RGB
SymbolFg terminal.RGB
DirFg color.RGB
FileFg color.RGB
SymbolFg color.RGB
SyntaxComment terminal.RGB
SyntaxString terminal.RGB
SyntaxKeyword terminal.RGB
SyntaxType terminal.RGB
SyntaxNumber terminal.RGB
SyntaxSymbol terminal.RGB
SyntaxComment color.RGB
SyntaxString color.RGB
SyntaxKeyword color.RGB
SyntaxType color.RGB
SyntaxNumber color.RGB
SyntaxSymbol color.RGB
}
// DefaultTheme provides reasonable defaults
var DefaultTheme = Theme{
Bg: terminal.RGB{R: 20, G: 20, B: 30},
Fg: terminal.RGB{R: 200, G: 200, B: 200},
FocusBg: terminal.RGB{R: 30, G: 35, B: 45},
CursorBg: terminal.RGB{R: 50, G: 50, B: 70},
Selected: terminal.RGB{R: 80, G: 200, B: 80},
Unselected: terminal.RGB{R: 100, G: 100, B: 100},
Partial: terminal.RGB{R: 80, G: 160, B: 220},
Error: terminal.RGB{R: 255, G: 80, B: 80},
Warning: terminal.RGB{R: 255, G: 80, B: 80},
Border: terminal.RGB{R: 60, G: 80, B: 100},
HeaderBg: terminal.RGB{R: 40, G: 60, B: 90},
HeaderFg: terminal.RGB{R: 255, G: 255, B: 255},
StatusFg: terminal.RGB{R: 140, G: 140, B: 140},
HintFg: terminal.RGB{R: 100, G: 180, B: 200},
InputBg: terminal.RGB{R: 30, G: 30, B: 50},
DirFg: terminal.RGB{R: 130, G: 170, B: 220},
FileFg: terminal.RGB{R: 200, G: 200, B: 200},
SymbolFg: terminal.RGB{R: 180, G: 220, B: 220},
SyntaxComment: terminal.RGB{R: 100, G: 110, B: 120},
SyntaxString: terminal.RGB{R: 180, G: 220, B: 140},
SyntaxKeyword: terminal.RGB{R: 180, G: 140, B: 220},
SyntaxType: terminal.RGB{R: 80, G: 200, B: 200},
SyntaxNumber: terminal.RGB{R: 220, G: 180, B: 120},
SyntaxSymbol: terminal.RGB{R: 220, G: 180, B: 80},
Bg: color.RGB{R: 20, G: 20, B: 30},
Fg: color.RGB{R: 200, G: 200, B: 200},
FocusBg: color.RGB{R: 30, G: 35, B: 45},
CursorBg: color.RGB{R: 50, G: 50, B: 70},
Selected: color.RGB{R: 80, G: 200, B: 80},
Unselected: color.RGB{R: 100, G: 100, B: 100},
Partial: color.RGB{R: 80, G: 160, B: 220},
Error: color.RGB{R: 255, G: 80, B: 80},
Warning: color.RGB{R: 255, G: 80, B: 80},
Border: color.RGB{R: 60, G: 80, B: 100},
HeaderBg: color.RGB{R: 40, G: 60, B: 90},
HeaderFg: color.RGB{R: 255, G: 255, B: 255},
StatusFg: color.RGB{R: 140, G: 140, B: 140},
HintFg: color.RGB{R: 100, G: 180, B: 200},
InputBg: color.RGB{R: 30, G: 30, B: 50},
DirFg: color.RGB{R: 130, G: 170, B: 220},
FileFg: color.RGB{R: 200, G: 200, B: 200},
SymbolFg: color.RGB{R: 180, G: 220, B: 220},
SyntaxComment: color.RGB{R: 100, G: 110, B: 120},
SyntaxString: color.RGB{R: 180, G: 220, B: 140},
SyntaxKeyword: color.RGB{R: 180, G: 140, B: 220},
SyntaxType: color.RGB{R: 80, G: 200, B: 200},
SyntaxNumber: color.RGB{R: 220, G: 180, B: 120},
SyntaxSymbol: color.RGB{R: 220, G: 180, B: 80},
}
+26 -22
View File
@@ -1,6 +1,9 @@
package tui
import "github.com/lixenwraith/terminal"
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
// ToastPosition specifies where toast renders
type ToastPosition uint8
@@ -46,26 +49,26 @@ var ToastIcons = map[ToastSeverity]rune{
}
// ToastColors default colors per severity
var ToastColors = map[ToastSeverity]struct{ Fg, Bg, Icon terminal.RGB }{
var ToastColors = map[ToastSeverity]struct{ Fg, Bg, Icon color.RGB }{
ToastInfo: {
Fg: terminal.RGB{R: 200, G: 200, B: 200},
Bg: terminal.RGB{R: 40, G: 40, B: 50},
Icon: terminal.RGB{R: 100, G: 150, B: 255},
Fg: color.RGB{R: 200, G: 200, B: 200},
Bg: color.RGB{R: 40, G: 40, B: 50},
Icon: color.RGB{R: 100, G: 150, B: 255},
},
ToastSuccess: {
Fg: terminal.RGB{R: 220, G: 255, B: 220},
Bg: terminal.RGB{R: 30, G: 60, B: 30},
Icon: terminal.RGB{R: 80, G: 220, B: 80},
Fg: color.RGB{R: 220, G: 255, B: 220},
Bg: color.RGB{R: 30, G: 60, B: 30},
Icon: color.RGB{R: 80, G: 220, B: 80},
},
ToastWarning: {
Fg: terminal.RGB{R: 255, G: 240, B: 200},
Bg: terminal.RGB{R: 60, G: 50, B: 20},
Icon: terminal.RGB{R: 255, G: 200, B: 60},
Fg: color.RGB{R: 255, G: 240, B: 200},
Bg: color.RGB{R: 60, G: 50, B: 20},
Icon: color.RGB{R: 255, G: 200, B: 60},
},
ToastError: {
Fg: terminal.RGB{R: 255, G: 220, B: 220},
Bg: terminal.RGB{R: 60, G: 25, B: 25},
Icon: terminal.RGB{R: 255, G: 80, B: 80},
Fg: color.RGB{R: 255, G: 220, B: 220},
Bg: color.RGB{R: 60, G: 25, B: 25},
Icon: color.RGB{R: 255, G: 80, B: 80},
},
}
@@ -81,9 +84,9 @@ type ToastOpts struct {
Padding int // Horizontal padding, default 1
MarginX int // Margin from edge for floating positions
MarginY int // Margin from edge for floating positions
CustomFg terminal.RGB
CustomBg terminal.RGB
CustomIcon terminal.RGB
CustomFg color.RGB
CustomBg color.RGB
CustomIcon color.RGB
}
// DefaultToastOpts returns sensible defaults
@@ -109,13 +112,13 @@ func (r Region) Toast(opts ToastOpts) Region {
// Resolve colors
fg, bg, iconFg := opts.CustomFg, opts.CustomBg, opts.CustomIcon
if fg == (terminal.RGB{}) {
if fg == (color.RGB{}) {
fg = ToastColors[opts.Severity].Fg
}
if bg == (terminal.RGB{}) {
if bg == (color.RGB{}) {
bg = ToastColors[opts.Severity].Bg
}
if iconFg == (terminal.RGB{}) {
if iconFg == (color.RGB{}) {
iconFg = ToastColors[opts.Severity].Icon
}
@@ -218,7 +221,7 @@ func (r Region) Toast(opts ToastOpts) Region {
case ToastStyleShadow:
// Shadow offset
shadowRegion := r.Sub(toastX+1, toastY+1, toastW, toastH)
shadowRegion.Fill(terminal.RGB{R: 10, G: 10, B: 15})
shadowRegion.Fill(color.RGB{R: 10, G: 10, B: 15})
toastRegion.BoxFilled(LineSingle, fg, bg)
r.renderToastContent(toastRegion.Inset(1), opts, fg, bg, iconFg, 0)
}
@@ -226,7 +229,7 @@ func (r Region) Toast(opts ToastOpts) Region {
return toastRegion
}
func (r Region) renderToastContent(content Region, opts ToastOpts, fg, bg, iconFg terminal.RGB, _ int) {
func (r Region) renderToastContent(content Region, opts ToastOpts, fg, bg, iconFg color.RGB, _ int) {
if content.W < 1 || content.H < 1 {
return
}
@@ -299,3 +302,4 @@ func (t *ToastState) Show(opts ToastOpts, frames int) {
t.FramesLeft = frames
t.Visible = true
}
+23 -19
View File
@@ -1,6 +1,9 @@
package tui
import "github.com/lixenwraith/terminal"
import (
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
// ExpandIcon chars
const (
@@ -30,12 +33,12 @@ type TreeNode struct {
Key string // Unique identifier for expansion state
Label string // Display text
Icon rune // Custom icon, 0 = auto (▶/▼ for expandable, • for leaf)
IconFg terminal.RGB
IconFg color.RGB
Expandable bool // Has children
Expanded bool // Currently expanded
Depth int // Nesting level (0 = root)
Check CheckState // Optional checkbox, CheckNone to skip
CheckFg terminal.RGB
CheckFg color.RGB
Style Style // Text styling
IsLast bool // Last sibling at this depth (for tree lines)
Data any // Application payload
@@ -43,7 +46,7 @@ type TreeNode struct {
Suffix string // Secondary text after label (e.g., "(5 files)")
SuffixStyle Style // Styling for suffix, zero = dimmed version of Style
Badge rune // Icon between checkbox and label (e.g., '★'), 0 = none
BadgeFg terminal.RGB // Badge color
BadgeFg color.RGB // Badge color
}
// ancestorHasMoreSiblings checks if there are more nodes at given depth after idx
@@ -59,12 +62,12 @@ func (r Region) ancestorHasMoreSiblings(nodes []TreeNode, idx, depth int) bool {
// TreeOpts configures tree rendering
type TreeOpts struct {
CursorBg terminal.RGB
DefaultBg terminal.RGB
CursorBg color.RGB
DefaultBg color.RGB
IndentWidth int // Cells per depth, default 2
IconWidth int // Width for icon column, default 2
LineMode TreeLineMode
LineFg terminal.RGB
LineFg color.RGB
}
// DefaultTreeOpts returns sensible defaults
@@ -73,7 +76,7 @@ func DefaultTreeOpts() TreeOpts {
IndentWidth: 2,
IconWidth: 2,
LineMode: TreeLinesNone,
LineFg: terminal.RGB{R: 80, G: 80, B: 100},
LineFg: color.RGB{R: 80, G: 80, B: 100},
}
}
@@ -94,7 +97,7 @@ func (r Region) Tree(nodes []TreeNode, cursor, scroll int, opts TreeOpts) int {
}
lineFg := opts.LineFg
if lineFg == (terminal.RGB{}) {
if lineFg == (color.RGB{}) {
lineFg = DefaultTheme.Border
}
@@ -114,7 +117,7 @@ func (r Region) Tree(nodes []TreeNode, cursor, scroll int, opts TreeOpts) int {
}
for x := 0; x < r.W; x++ {
r.Cell(x, y, ' ', terminal.RGB{}, bg, terminal.AttrNone)
r.Cell(x, y, ' ', color.RGB{}, bg, terminal.AttrNone)
}
x := 0
@@ -139,7 +142,7 @@ func (r Region) Tree(nodes []TreeNode, cursor, scroll int, opts TreeOpts) int {
icon = IconBullet
}
}
if iconFg == (terminal.RGB{}) {
if iconFg == (color.RGB{}) {
iconFg = lineFg
}
if x < r.W {
@@ -148,10 +151,10 @@ func (r Region) Tree(nodes []TreeNode, cursor, scroll int, opts TreeOpts) int {
x += iconW
// Checkbox
if node.Check != CheckNone || node.CheckFg != (terminal.RGB{}) {
if node.Check != CheckNone || node.CheckFg != (color.RGB{}) {
if x+3 <= r.W {
checkFg := node.CheckFg
if checkFg == (terminal.RGB{}) {
if checkFg == (color.RGB{}) {
checkFg = node.Style.Fg
}
var ch rune
@@ -176,7 +179,7 @@ func (r Region) Tree(nodes []TreeNode, cursor, scroll int, opts TreeOpts) int {
if node.Badge != 0 {
if x < r.W {
badgeFg := node.BadgeFg
if badgeFg == (terminal.RGB{}) {
if badgeFg == (color.RGB{}) {
badgeFg = node.Style.Fg
}
r.Cell(x, y, node.Badge, badgeFg, bg, terminal.AttrNone)
@@ -186,7 +189,7 @@ func (r Region) Tree(nodes []TreeNode, cursor, scroll int, opts TreeOpts) int {
// Label
style := node.Style
if style.Bg == (terminal.RGB{}) {
if style.Bg == (color.RGB{}) {
style.Bg = bg
}
@@ -214,15 +217,15 @@ func (r Region) Tree(nodes []TreeNode, cursor, scroll int, opts TreeOpts) int {
// Suffix
if suffix != "" {
suffixStyle := node.SuffixStyle
if suffixStyle.Fg == (terminal.RGB{}) {
if suffixStyle.Fg == (color.RGB{}) {
// Default: dimmed version of label style
suffixStyle.Fg = terminal.RGB{
suffixStyle.Fg = color.RGB{
R: node.Style.Fg.R / 2,
G: node.Style.Fg.G / 2,
B: node.Style.Fg.B / 2,
}
}
if suffixStyle.Bg == (terminal.RGB{}) {
if suffixStyle.Bg == (color.RGB{}) {
suffixStyle.Bg = bg
}
r.TextStyled(x, y, suffix, suffixStyle)
@@ -235,7 +238,7 @@ func (r Region) Tree(nodes []TreeNode, cursor, scroll int, opts TreeOpts) int {
}
// renderTreeLines draws connector lines for tree structure, returns x position after lines
func (r Region) renderTreeLines(y, startX int, node TreeNode, nodes []TreeNode, idx, scroll, indentW int, fg terminal.RGB, bg terminal.RGB) int {
func (r Region) renderTreeLines(y, startX int, node TreeNode, nodes []TreeNode, idx, scroll, indentW int, fg color.RGB, bg color.RGB) int {
x := startX
for d := 0; d < node.Depth; d++ {
@@ -335,3 +338,4 @@ func FindPrevSiblingIndex(nodes []TreeNode, idx int) int {
}
return -1
}