aboutsummaryrefslogtreecommitdiff
path: root/chart.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-15 23:44:32 -0400
committerhistoria <[not public]>2026-07-15 23:44:32 -0400
commitecf4c872bb33381abc065f1816e204befcde610c (patch)
tree65e08a55bd577fcba6d714a13124b4912443be08 /chart.go
parent1068432d94252b9b4dec447e424f85f712fa16c2 (diff)
downloadxterm256-color-palette-ecf4c872bb33381abc065f1816e204befcde610c.tar.gz
initial commit
Diffstat (limited to 'chart.go')
-rw-r--r--chart.go507
1 files changed, 483 insertions, 24 deletions
diff --git a/chart.go b/chart.go
index dd4ac10..ba85a15 100644
--- a/chart.go
+++ b/chart.go
@@ -3,8 +3,10 @@ package main
import (
"fmt"
"math"
+ "math/rand"
+ "time"
- "github.com/kutuluk/xterm-color-chart/color"
+ "codeberg.org/historia/xterm256-color-palette/color"
"github.com/nsf/termbox-go"
)
@@ -44,6 +46,196 @@ func termNative(c int) termbox.Attribute {
return termbox.Attribute(c + 1)
}
+type hitRegion struct {
+ x, y, w, h int
+ leftAction func(x, y int)
+ rightAction func(x, y int)
+}
+
+var hitRegions []hitRegion
+
+func addHitRegion(x, y, w, h int, leftAction, rightAction func(x, y int)) {
+ hitRegions = append(hitRegions, hitRegion{x, y, w, h, leftAction, rightAction})
+}
+
+func hitTest(px, py int) *hitRegion {
+ for i := len(hitRegions) - 1; i >= 0; i-- {
+ r := &hitRegions[i]
+ if px >= r.x && px < r.x+r.w && py >= r.y && py < r.y+r.h {
+ return r
+ }
+ }
+ return nil
+}
+
+var selectedColors []int
+var paletteScroll int
+var autoScrollPalette bool
+var paletteLeft int
+var paletteTop int
+var lastMouseEvent time.Time
+var dragIndex = -1
+var dragY int
+
+func addSelectedColor(c int) {
+ for _, sc := range selectedColors {
+ if sc == c {
+ return
+ }
+ }
+ selectedColors = append(selectedColors, c)
+ autoScrollPalette = true
+}
+
+func removeSelectedColor(i int) {
+ if i >= 0 && i < len(selectedColors) {
+ selectedColors = append(selectedColors[:i], selectedColors[i+1:]...)
+ }
+ if paletteScroll > len(selectedColors)-1 {
+ paletteScroll = len(selectedColors) - 1
+ }
+ if paletteScroll < 0 {
+ paletteScroll = 0
+ }
+}
+
+func moveSelectedColor(fromIdx, toIdx int) {
+ if fromIdx < 0 || fromIdx >= len(selectedColors) || toIdx < 0 || toIdx >= len(selectedColors) || fromIdx == toIdx {
+ return
+ }
+ c := selectedColors[fromIdx]
+ selectedColors = append(selectedColors[:fromIdx], selectedColors[fromIdx+1:]...)
+ selectedColors = append(selectedColors[:toIdx], append([]int{c}, selectedColors[toIdx:]...)...)
+}
+
+var harmonies = []struct {
+ name string
+ offsets []float64
+}{
+ {"complementary", []float64{0, 180}},
+ {"analogous", []float64{0, 330, 30}},
+ {"triadic", []float64{0, 120, 240}},
+ {"split-comp", []float64{0, 150, 210}},
+ {"tetradic", []float64{0, 60, 180, 240}},
+ {"square", []float64{0, 90, 180, 270}},
+ {"monochromatic", nil},
+}
+
+var currentHarmony = 2 // triadic
+
+// включать ли 16 системных цветов терминала в генерацию палитр
+// (их реальный вид зависит от темы терминала)
+var useSystemColors bool
+
+// привязка LCh-цвета к палитре xterm с уменьшением насыщенности
+// до попадания в sRGB-охват (сохраняя светлоту и тон)
+func snapColor(lch color.LChColor) int {
+ for lch.C > 0.5 && !color.InGamut(lch) {
+ lch.C *= 0.95
+ }
+ var col int
+ if useSystemColors {
+ _, col = color.ApproximateAll(lch, comparers[currentComparer])
+ } else {
+ _, col = color.Approximate(lch, comparers[currentComparer])
+ }
+ return col
+}
+
+// генерация гармоничной палитры в пространстве LCh
+func generateHarmony() {
+ har := harmonies[currentHarmony]
+
+ var base color.LChColor
+ seeded := false
+ if len(selectedColors) > 0 {
+ c := selectedColors[0]
+ if useSystemColors || c-1 >= 16 {
+ base = color.TermLab(c).LCh()
+ seeded = true
+ }
+ }
+ if !seeded {
+ rnd := color.LChColor{
+ L: 40.0 + 40.0*rand.Float64(),
+ C: 40.0 + 50.0*rand.Float64(),
+ H: 360.0 * rand.Float64(),
+ }
+ // привязка к ближайшему цвету xterm, чтобы повторная генерация давала тот же результат
+ c := snapColor(rnd)
+ base = color.TermLab(c).LCh()
+ }
+
+ selectedColors = selectedColors[:0]
+ paletteScroll = 0
+
+ if har.offsets == nil {
+ // монохроматическая: один тон, разная светлота
+ for i := 0; i < 5; i++ {
+ l := 20.0 + 15.0*float64(i)
+ addSelectedColor(snapColor(color.LChColor{L: l, C: base.C, H: base.H}))
+ }
+ } else {
+ for _, off := range har.offsets {
+ hue := math.Mod(base.H+off, 360.0)
+ addSelectedColor(snapColor(color.LChColor{L: base.L, C: base.C, H: hue}))
+ }
+ }
+}
+
+// расширение палитры: для каждого цвета добавляются тень, блик и приглушенный
+// вариант того же тона, сгруппированные сразу после исходного цвета
+func expandPalette() {
+ if len(selectedColors) == 0 {
+ return
+ }
+
+ clampL := func(l float64) float64 {
+ if l < 10.0 {
+ return 10.0
+ }
+ if l > 90.0 {
+ return 90.0
+ }
+ return l
+ }
+
+ expanded := make([]int, 0, len(selectedColors)*4)
+ contains := func(c int) bool {
+ for _, sc := range expanded {
+ if sc == c {
+ return true
+ }
+ }
+ return false
+ }
+
+ for _, c := range selectedColors {
+ if !contains(c) {
+ expanded = append(expanded, c)
+ }
+ if !useSystemColors && c-1 < 16 {
+ continue
+ }
+ lch := color.TermLab(c).LCh()
+ variants := []color.LChColor{
+ {L: clampL(lch.L - 25.0), C: lch.C, H: lch.H},
+ {L: clampL(lch.L + 25.0), C: lch.C, H: lch.H},
+ {L: lch.L, C: lch.C * 0.5, H: lch.H},
+ }
+ for _, v := range variants {
+ col := snapColor(v)
+ if !contains(col) {
+ expanded = append(expanded, col)
+ }
+ }
+ }
+
+ selectedColors = expanded
+ paletteScroll = 0
+ autoScrollPalette = false
+}
+
func printString(x, y int, s string) {
// TODO срабатывает перенос на следующую строку при достижении правого края
// и ошибка при выходе из границ буфера снизу
@@ -112,6 +304,13 @@ func DrawColor(x, y int, c int) {
printLineAttr(x+1, y, fmt.Sprintf("%03d #%s", int(c), color.XtermRGBPalette[c]), fg, termbox.Attribute(c))
}
+var (
+ boxCacheSize int
+ boxCacheLight int
+ boxCacheComp int
+ boxCacheBlocks []struct{ fg, bg termbox.Attribute }
+)
+
func DrawBox(left, top, d, l int) {
var approxer string
switch currentComparer {
@@ -123,30 +322,44 @@ func DrawBox(left, top, d, l int) {
approxer = "CIE2000"
}
- r := d / 2
- for y := 0; y < d; y++ {
- for x := 0; x < d; x++ {
- a := 100.0 * float64(x-r) / float64(r)
- b := -100.0 * float64(y-r) / float64(r)
- _, c := color.Approximate(color.LabColor{float64(l), a, b}, comparers[currentComparer])
- //termbox.SetCell(x*2+left, y+top, ' ', background, termbox.Attribute(color))
- //termbox.SetCell(x*2+left+1, y+top, ' ', background, termbox.Attribute(color))
- fg := int(background)
- if y == 1 || y == 2 || y == d-2 {
- fgLab := color.XtermRGBPalette[c-17].Lab()
- if l > 50 {
- fgLab.L -= 50
- //fgLab.L = 20
- } else {
- fgLab.L += 50
- //fgLab.L = 70
+ hit := d == boxCacheSize && l == boxCacheLight && currentComparer == boxCacheComp && boxCacheBlocks != nil
+
+ if hit {
+ idx := 0
+ for y := 0; y < d; y++ {
+ for x := 0; x < d; x++ {
+ blk := boxCacheBlocks[idx]
+ termbox.SetCell(x*2+left, y+top, ' ', blk.fg, blk.bg)
+ termbox.SetCell(x*2+left+1, y+top, ' ', blk.fg, blk.bg)
+ idx++
+ }
+ }
+ } else {
+ r := d / 2
+ boxCacheBlocks = make([]struct{ fg, bg termbox.Attribute }, d*d)
+ idx := 0
+ for y := 0; y < d; y++ {
+ for x := 0; x < d; x++ {
+ a := 100.0 * float64(x-r) / float64(r)
+ b := -100.0 * float64(y-r) / float64(r)
+ _, c := color.Approximate(color.LabColor{float64(l), a, b}, comparers[currentComparer])
+ fg := int(background)
+ if y == 1 || y == 2 || y == d-2 {
+ fgLab := color.TermLab(c)
+ if l > 50 {
+ fgLab.L -= 50
+ } else {
+ fgLab.L += 50
+ }
+ _, fg = color.Approximate(fgLab, color.DeltaCIE2000{})
}
- _, fg = color.Approximate(fgLab, color.DeltaCIE2000{})
+ boxCacheBlocks[idx] = struct{ fg, bg termbox.Attribute }{termbox.Attribute(fg), termbox.Attribute(c)}
+ termbox.SetCell(x*2+left, y+top, ' ', termbox.Attribute(fg), termbox.Attribute(c))
+ termbox.SetCell(x*2+left+1, y+top, ' ', termbox.Attribute(fg), termbox.Attribute(c))
+ idx++
}
- termbox.SetCell(x*2+left, y+top, ' ', termbox.Attribute(fg), termbox.Attribute(c))
- termbox.SetCell(x*2+left+1, y+top, ' ', termbox.Attribute(fg), termbox.Attribute(c))
}
-
+ boxCacheSize, boxCacheLight, boxCacheComp = d, l, currentComparer
}
labLabel := "CIE L*a*b color space"
@@ -212,6 +425,10 @@ func DrawCircle(left, top, r int, l float64) {
func DrawColorCell(x, y, c int) {
termbox.SetCell(x, y, ' ', termbox.Attribute(1), termbox.Attribute(c))
termbox.SetCell(x+1, y, ' ', termbox.Attribute(1), termbox.Attribute(c))
+ val := c
+ addHitRegion(x, y, 2, 1, func(_, _ int) {
+ addSelectedColor(val)
+ }, nil)
}
// left, top - левый верхний угол отрисовки палитры
@@ -286,6 +503,108 @@ func Fill(x1, y1, x2, y2 int, fg, bg termbox.Attribute) {
}
}
+func drawPaletteEntry(x, y, c, idx int) {
+ termbox.SetCell(x, y, ' ', termbox.Attribute(1), termbox.Attribute(c))
+ termbox.SetCell(x+1, y, ' ', termbox.Attribute(1), termbox.Attribute(c))
+ label := fmt.Sprintf("%03d / %02X", c-1, c-1)
+ if dragIndex == idx {
+ printLineAttr(x+3, y, label, background, background+7)
+ } else {
+ printLineAttr(x+3, y, label, text, background)
+ }
+ addHitRegion(x, y, 3+len(label), 1,
+ func(_, _ int) {
+ if dragIndex < 0 {
+ dragIndex = idx
+ }
+ },
+ func(_, _ int) {
+ removeSelectedColor(idx)
+ if dragIndex == idx {
+ dragIndex = -1
+ } else if dragIndex > idx {
+ dragIndex--
+ }
+ })
+}
+
+// панель пользовательской палитры
+func DrawUserPalette(left, top int) {
+ paletteLeft = left
+ paletteTop = top
+
+ printLineAttr(left, top, fmt.Sprintf("Palette (%d) [%s]", len(selectedColors), harmonies[currentHarmony].name), text, background)
+
+ n := len(selectedColors)
+ if n == 0 {
+ paletteScroll = 0
+ autoScrollPalette = false
+ printLineAttr(left, top+2, "Click a color to add it here,", background+6, background)
+ printLineAttr(left, top+3, "or press Space to generate", background+6, background)
+ return
+ }
+
+ rows := screen.Height - (top + 2)
+ if rows <= 0 {
+ autoScrollPalette = false
+ return
+ }
+
+ if n <= rows {
+ paletteScroll = 0
+ autoScrollPalette = false
+ for i, c := range selectedColors {
+ drawPaletteEntry(left, top+2+i, c, i)
+ }
+ return
+ }
+
+ maxScroll := n - (rows - 1)
+ if maxScroll > n-1 {
+ maxScroll = n - 1
+ }
+ if autoScrollPalette {
+ paletteScroll = maxScroll
+ autoScrollPalette = false
+ }
+ if paletteScroll > maxScroll {
+ paletteScroll = maxScroll
+ }
+ if paletteScroll < 0 {
+ paletteScroll = 0
+ }
+
+ visible := rows
+ if paletteScroll > 0 {
+ visible--
+ }
+ if paletteScroll+visible < n {
+ visible--
+ }
+ if visible < 1 {
+ visible = 1
+ }
+
+ y := top + 2
+ if paletteScroll > 0 {
+ printLineAttr(left, y, fmt.Sprintf("^ %d more", paletteScroll), background+7, background)
+ y++
+ }
+
+ end := paletteScroll + visible
+ if end > n {
+ end = n
+ }
+ for i := paletteScroll; i < end; i++ {
+ drawPaletteEntry(left, y, selectedColors[i], i)
+ y++
+ }
+
+ if below := n - end; below > 0 && y < screen.Height {
+ printLineAttr(left, y, fmt.Sprintf("v %d more", below), background+7, background)
+ }
+}
+
var (
text, background termbox.Attribute
lightness int
@@ -293,6 +612,7 @@ var (
)
func DrawChart() {
+ hitRegions = hitRegions[:0]
screen.Clear(text, background)
//Fill(0, 0, screen.Width-1, screen.Height-1, text, background)
@@ -313,6 +633,27 @@ func DrawChart() {
DrawBox(2, 2, boxSize, lightness)
// http://snag.gy/XMro8.jpg - картинка для сравнения
+ boxLeft, boxTop := 2, 2
+ boxW, boxH := boxSize*2, boxSize
+ bs := boxSize
+ lt := lightness
+ addHitRegion(boxLeft, boxTop, boxW, boxH, func(px, py int) {
+ cx := (px - boxLeft) / 2
+ cy := py - boxTop
+ r := bs / 2
+ if r > 0 && cx >= 0 && cx < bs && cy >= 0 && cy < bs {
+ a := 100.0 * float64(cx-r) / float64(r)
+ b := -100.0 * float64(cy-r) / float64(r)
+ _, col := color.Approximate(color.LabColor{float64(lt), a, b}, comparers[currentComparer])
+ addSelectedColor(col)
+ }
+ }, nil)
+
+ sysState := "off"
+ if useSystemColors {
+ sysState = "on"
+ }
+
helpText := []string{
"Control keys:",
"",
@@ -324,6 +665,22 @@ func DrawChart() {
"CIE94 - middle",
"CIE2000 - slowest",
"",
+ "Click a color to add it to the palette",
+ "L-click palette entry to pick up / drop",
+ "R-click palette entry to remove",
+ "Backspace - remove last palette entry",
+ "Space - generate harmony palette",
+ " (seeds from first entry if any)",
+ "h - change harmony mode",
+ "e - expand palette with similar colors",
+ "t - use 16 system colors in generated",
+ fmt.Sprintf(" palettes, as seeds and outputs (%s).", sysState),
+ " When off, a system color seed is",
+ " replaced by a random one. Note: system",
+ " colors depend on your terminal theme,",
+ " so results assume xterm defaults",
+ "q/Esc/F10 - quit",
+ "Wheel/PgUp/PgDn - scroll palette",
"",
}
@@ -341,6 +698,8 @@ func DrawChart() {
DrawPalette(xc, yc, 3, true)
}
+ DrawUserPalette(xc, yc+3*7+11)
+
screen.Flush()
}
@@ -355,7 +714,7 @@ func main() {
}
defer termbox.Close()
termbox.SetOutputMode(termbox.Output256)
- //termbox.SetInputMode(termbox.InputEsc)
+ termbox.SetInputMode(termbox.InputEsc | termbox.InputMouse)
background = termNative(234)
text = background + 12
@@ -374,7 +733,16 @@ func main() {
switch ev.Key {
case termbox.KeyCtrlQ, termbox.KeyF10:
loop = false
- case termbox.KeyCtrlH, termbox.KeyF1:
+ case termbox.KeyEsc:
+ if time.Since(lastMouseEvent) > 150*time.Millisecond {
+ loop = false
+ }
+ case termbox.KeyBackspace, termbox.KeyBackspace2:
+ if len(selectedColors) > 0 {
+ removeSelectedColor(len(selectedColors) - 1)
+ DrawChart()
+ }
+ case termbox.KeyF1:
helpVisible = !helpVisible
DrawChart()
case termbox.KeyArrowUp:
@@ -401,6 +769,97 @@ func main() {
currentComparer = 0
}
DrawChart()
+ case termbox.KeySpace:
+ generateHarmony()
+ DrawChart()
+ case termbox.KeyPgup:
+ if paletteScroll > 0 {
+ paletteScroll--
+ DrawChart()
+ }
+ case termbox.KeyPgdn:
+ paletteScroll++
+ DrawChart()
+ default:
+ switch ev.Ch {
+ case 'q', 'Q':
+ if time.Since(lastMouseEvent) > 150*time.Millisecond {
+ loop = false
+ }
+ case 'h', 'H':
+ currentHarmony = (currentHarmony + 1) % len(harmonies)
+ DrawChart()
+ case 'e', 'E':
+ expandPalette()
+ DrawChart()
+ case 't', 'T':
+ useSystemColors = !useSystemColors
+ DrawChart()
+ }
+ }
+ case termbox.EventMouse:
+ lastMouseEvent = time.Now()
+ switch ev.Key {
+ case termbox.MouseLeft:
+ if dragIndex < 0 {
+ if r := hitTest(ev.MouseX, ev.MouseY); r != nil && r.leftAction != nil {
+ r.leftAction(ev.MouseX, ev.MouseY)
+ }
+ }
+ dragY = ev.MouseY
+ if dragIndex >= 0 {
+ target := func() int {
+ n := len(selectedColors)
+ rows := screen.Height - (paletteTop + 2)
+ if n <= rows {
+ t := dragY - (paletteTop + 2)
+ if t < 0 {
+ t = 0
+ }
+ if t >= n {
+ t = n - 1
+ }
+ return t
+ }
+ firstEntryY := paletteTop + 2
+ if paletteScroll > 0 {
+ firstEntryY++
+ }
+ t := paletteScroll + (dragY - firstEntryY)
+ if t < 0 {
+ t = 0
+ }
+ if t >= n {
+ t = n - 1
+ }
+ return t
+ }()
+ if target != dragIndex {
+ moveSelectedColor(dragIndex, target)
+ dragIndex = target
+ }
+ }
+ DrawChart()
+ case termbox.MouseRight:
+ if r := hitTest(ev.MouseX, ev.MouseY); r != nil && r.rightAction != nil {
+ r.rightAction(ev.MouseX, ev.MouseY)
+ DrawChart()
+ }
+ case termbox.MouseRelease:
+ if dragIndex >= 0 {
+ dragIndex = -1
+ DrawChart()
+ }
+ case termbox.MouseWheelUp:
+ if ev.MouseX >= paletteLeft && paletteScroll > 0 {
+ paletteScroll--
+ DrawChart()
+ }
+ case termbox.MouseWheelDown:
+ if ev.MouseX >= paletteLeft && len(selectedColors) > 0 {
+ paletteScroll++
+ DrawChart()
+ }
}
case termbox.EventResize:
DrawChart()