Skip to content

Commit 8f4f7c8

Browse files
feat: add linear-gradient segment colors
Foreground and background accept linear-gradient(stop, stop, ...) with hex or palette-reference stops, interpolated per visible cell in HCL space and emitted as truecolor escapes (256-color fallback when the terminal lacks truecolor support). Powerline separators, diamond caps, and parentBackground/parentForeground collapse to the matching gradient edge so adjacent glyphs connect with the correct color. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: d84718082c10
1 parent 4fdfbbb commit 8f4f7c8

16 files changed

Lines changed: 2237 additions & 33 deletions

src/color/colors.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,21 @@ func (c Ansi) ToForeground() Ansi {
136136
return c
137137
}
138138

139+
// ToChannel returns the color code adjusted for the requested channel, converting
140+
// between the foreground (38;...) and background (48;...) escape payload prefixes.
141+
func (c Ansi) ToChannel(isBackground bool) Ansi {
142+
colorString := c.String()
143+
144+
switch {
145+
case isBackground && strings.HasPrefix(colorString, "38;"):
146+
return Ansi("48;" + colorString[3:])
147+
case !isBackground && strings.HasPrefix(colorString, "48;"):
148+
return Ansi("38;" + colorString[3:])
149+
default:
150+
return c
151+
}
152+
}
153+
139154
func (c Ansi) ResolveTemplate() Ansi {
140155
if c.IsEmpty() {
141156
return c
@@ -181,6 +196,12 @@ var unresolvedAccent = &Set{}
181196
func (d *Defaults) SetAccentColor(env runtime.Environment, defaultColor Ansi) {
182197
defer log.Trace(time.Now())
183198

199+
// a gradient accent_color cannot serve as the single accent value; collapse to
200+
// its first stop so the cached accent Set never holds a raw gradient string.
201+
if defaultColor.IsGradient() {
202+
defaultColor = defaultColor.GradientFirst()
203+
}
204+
184205
// get the resolved OS accent color from the device cache first, regardless
185206
// of whether a default was configured, so we never repeat the underlying
186207
// OS query (e.g. a DWM registry read) once we know the answer.
@@ -255,6 +276,12 @@ func (d *Defaults) ToAnsi(ansiColor Ansi, isBackground bool) Ansi {
255276
return ansiColor
256277
}
257278

279+
// a gradient rides the ANSI plumbing as a plain string; the terminal writer detects
280+
// and renders it per cell, so it must never be mangled into hex/256 parsing here.
281+
if ansiColor.IsGradient() {
282+
return ansiColor
283+
}
284+
258285
if ansiColor == Accent {
259286
if d.accent == nil {
260287
return emptyColor
@@ -329,6 +356,12 @@ type PaletteColors struct {
329356
}
330357

331358
func (p *PaletteColors) ToAnsi(colorString Ansi, isBackground bool) Ansi {
359+
// a gradient string is not a palette key; guard it explicitly so it never round-trips
360+
// through palette resolution and reaches the next decorator untouched.
361+
if colorString.IsGradient() {
362+
return colorString
363+
}
364+
332365
paletteColor, err := p.palette.ResolveColor(colorString)
333366
if err != nil {
334367
return emptyColor

src/color/colors_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,27 @@ func TestMakeColors(t *testing.T) {
6767
assert.IsType(t, &Defaults{}, colors.(*Cached).ansiColors.(*PaletteColors).ansiColors)
6868
}
6969

70+
// TestGradientPassesThroughAnsiColorDecorators verifies a gradient string is never mangled
71+
// by hex/256 parsing or palette resolution; it must round-trip untouched through every
72+
// String decorator so the terminal writer can render it per cell.
73+
func TestGradientPassesThroughAnsiColorDecorators(t *testing.T) {
74+
gradient := Ansi("linear-gradient(#FF0000, #0000FF)")
75+
76+
cases := []struct {
77+
Colors String
78+
Case string
79+
}{
80+
{Case: "Defaults", Colors: &Defaults{}},
81+
{Case: "PaletteColors", Colors: &PaletteColors{ansiColors: &Defaults{}, palette: testPalette}},
82+
{Case: "Cached", Colors: &Cached{ansiColors: &Defaults{}}},
83+
}
84+
85+
for _, tc := range cases {
86+
assert.Equal(t, gradient, tc.Colors.ToAnsi(gradient, false), tc.Case)
87+
assert.Equal(t, gradient, tc.Colors.ToAnsi(gradient, true), tc.Case)
88+
}
89+
}
90+
7091
func TestAnsiRender(t *testing.T) {
7192
cases := []struct {
7293
Case string

src/color/gradient.go

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
package color
2+
3+
import (
4+
"strconv"
5+
"strings"
6+
7+
"github.com/gookit/color"
8+
"github.com/jandedobbeleer/oh-my-posh/src/log"
9+
"github.com/lucasb-eyer/go-colorful"
10+
)
11+
12+
const (
13+
gradientPrefix = "linear-gradient("
14+
gradientSuffix = ")"
15+
)
16+
17+
// IsGradient reports whether c is a gradient definition, e.g. `linear-gradient(#FF0000, #0000FF)`.
18+
func (c Ansi) IsGradient() bool {
19+
return strings.HasPrefix(c.String(), gradientPrefix)
20+
}
21+
22+
// GradientStops performs syntax parsing only: it splits the comma-separated stop list inside
23+
// `linear-gradient(...)` and trims whitespace around each stop. It does not resolve palette
24+
// references or validate that a stop is a color. Returns nil when c is not a gradient, the
25+
// closing paren is missing, the body contains a nested paren (angle/direction syntax is
26+
// reserved but not implemented), or any stop is empty.
27+
func (c Ansi) GradientStops() []Ansi {
28+
if !c.IsGradient() {
29+
return nil
30+
}
31+
32+
value := c.String()
33+
if !strings.HasSuffix(value, gradientSuffix) {
34+
return nil
35+
}
36+
37+
body := value[len(gradientPrefix) : len(value)-len(gradientSuffix)]
38+
if strings.ContainsAny(body, "()") {
39+
return nil
40+
}
41+
42+
parts := strings.Split(body, ",")
43+
stops := make([]Ansi, 0, len(parts))
44+
45+
for _, part := range parts {
46+
stop := strings.TrimSpace(part)
47+
if stop == "" {
48+
return nil
49+
}
50+
51+
stops = append(stops, Ansi(stop))
52+
}
53+
54+
return stops
55+
}
56+
57+
// GradientFirst returns the first stop of the gradient. It returns c unchanged when c is not
58+
// a gradient, or when the gradient syntax is invalid.
59+
func (c Ansi) GradientFirst() Ansi {
60+
stops := c.GradientStops()
61+
if len(stops) == 0 {
62+
return c
63+
}
64+
65+
return stops[0]
66+
}
67+
68+
// GradientLast returns the last stop of the gradient. It returns c unchanged when c is not
69+
// a gradient, or when the gradient syntax is invalid.
70+
func (c Ansi) GradientLast() Ansi {
71+
stops := c.GradientStops()
72+
if len(stops) == 0 {
73+
return c
74+
}
75+
76+
return stops[len(stops)-1]
77+
}
78+
79+
// GradientCells resolves each stop of the gradient c — keywords like parentBackground against
80+
// current/parents, palette references through resolver — parses the result as a hex color, and
81+
// interpolates across cells steps in HCL space. It returns one ready-to-print ANSI color code
82+
// per cell, honoring the package-level TrueColor flag: a truecolor escape when true, a gookit
83+
// C256 downgrade when false. cells == 1 returns only the first stop. Returns nil when fewer
84+
// than two stops resolve to a valid color; the caller falls back to a single collapsed color
85+
// per the gradient-invalid rule.
86+
func GradientCells(c Ansi, cells int, resolver String, isBackground bool, current *Set, parents []*Set) []Ansi {
87+
if cells <= 0 {
88+
return nil
89+
}
90+
91+
stops := c.GradientStops()
92+
if len(stops) == 0 {
93+
log.Errorf("gradient %s: invalid syntax, expected linear-gradient(stop, stop, ...)", c)
94+
return nil
95+
}
96+
97+
colors := make([]colorful.Color, 0, len(stops))
98+
99+
for _, stop := range stops {
100+
// a keyword stop (parentBackground, foreground, ...) resolves against the
101+
// segment context first; a parent gradient collapses to its last stop there.
102+
resolved := stop.Resolve(current, parents)
103+
104+
resolved, err := resolver.Resolve(resolved)
105+
if err != nil {
106+
log.Errorf("gradient %s: unable to resolve stop %s: %s", c, stop, err)
107+
continue
108+
}
109+
110+
// the OS accent color only resolves in ToAnsi, to a truecolor payload
111+
// rather than hex; parseTrueColor recovers the RGB triplet from it.
112+
if resolved == Accent {
113+
resolved = resolver.ToAnsi(Accent, false)
114+
}
115+
116+
clr, err := colorful.Hex(resolved.String())
117+
if err != nil {
118+
var ok bool
119+
if clr, ok = parseTrueColor(resolved); !ok {
120+
log.Errorf("gradient %s: stop %s does not resolve to a color, only hex colors, palette references, and keywords resolving to a color can be interpolated", c, stop)
121+
continue
122+
}
123+
}
124+
125+
colors = append(colors, clr)
126+
}
127+
128+
if len(colors) < 2 {
129+
log.Errorf("gradient %s: needs at least two valid stops, rendering the last stop as a solid color", c)
130+
return nil
131+
}
132+
133+
if cached, ok := gradientCellCache[gradientKey(colors, cells, isBackground)]; ok {
134+
return cached
135+
}
136+
137+
if cells == 1 {
138+
return cacheGradientCells(colors, cells, isBackground, []Ansi{ansiFromColorful(colors[0], isBackground)})
139+
}
140+
141+
segments := len(colors) - 1
142+
result := make([]Ansi, cells)
143+
144+
for i := range cells {
145+
position := float64(i) / float64(cells-1) * float64(segments)
146+
147+
segment := int(position)
148+
if segment >= segments {
149+
segment = segments - 1
150+
}
151+
152+
blended := colors[segment].BlendHcl(colors[segment+1], position-float64(segment)).Clamped()
153+
result[i] = ansiFromColorful(blended, isBackground)
154+
}
155+
156+
return cacheGradientCells(colors, cells, isBackground, result)
157+
}
158+
159+
// gradientCellCache memoizes interpolation results keyed on the RESOLVED stop colors
160+
// (keyword and palette stops resolve before the key is built, so context changes miss
161+
// the cache correctly), the cell count, the channel, and the TrueColor mode. Prompt
162+
// rendering is single-threaded (see the terminal writer's package state), so a plain
163+
// map suffices. Bounded to keep long-lived daemons from growing it unchecked.
164+
var gradientCellCache = make(map[string][]Ansi)
165+
166+
const gradientCellCacheLimit = 128
167+
168+
func gradientKey(colors []colorful.Color, cells int, isBackground bool) string {
169+
buf := make([]byte, 0, 8+len(colors)*12)
170+
171+
for _, clr := range colors {
172+
r, g, b := clr.RGB255()
173+
buf = strconv.AppendUint(buf, uint64(r), 10)
174+
buf = append(buf, ';')
175+
buf = strconv.AppendUint(buf, uint64(g), 10)
176+
buf = append(buf, ';')
177+
buf = strconv.AppendUint(buf, uint64(b), 10)
178+
buf = append(buf, ',')
179+
}
180+
181+
buf = strconv.AppendInt(buf, int64(cells), 10)
182+
183+
if isBackground {
184+
buf = append(buf, 'b')
185+
}
186+
187+
if TrueColor {
188+
buf = append(buf, 't')
189+
}
190+
191+
return string(buf)
192+
}
193+
194+
func cacheGradientCells(colors []colorful.Color, cells int, isBackground bool, result []Ansi) []Ansi {
195+
if len(gradientCellCache) >= gradientCellCacheLimit {
196+
gradientCellCache = make(map[string][]Ansi)
197+
}
198+
199+
gradientCellCache[gradientKey(colors, cells, isBackground)] = result
200+
return result
201+
}
202+
203+
// parseTrueColor parses a truecolor ANSI payload ("38;2;r;g;b" or "48;2;r;g;b") back into
204+
// a colorful.Color. The OS accent color resolves to this form instead of hex.
205+
func parseTrueColor(c Ansi) (colorful.Color, bool) {
206+
parts := strings.Split(c.String(), ";")
207+
if len(parts) != 5 || (parts[0] != "38" && parts[0] != "48") || parts[1] != "2" {
208+
return colorful.Color{}, false
209+
}
210+
211+
rgb := make([]uint8, 3)
212+
for i, part := range parts[2:] {
213+
val, err := strconv.ParseUint(part, 10, 8)
214+
if err != nil {
215+
return colorful.Color{}, false
216+
}
217+
218+
rgb[i] = uint8(val)
219+
}
220+
221+
return colorful.Color{R: float64(rgb[0]) / 255.0, G: float64(rgb[1]) / 255.0, B: float64(rgb[2]) / 255.0}, true
222+
}
223+
224+
// ansiFromColorful converts an interpolated HCL color to a ready-to-print ANSI code, honoring
225+
// the package-level TrueColor flag. The truecolor payload is built with strconv appends
226+
// rather than gookit's fmt.Sprintf path: one allocation per cell instead of four, on what
227+
// is the gradient hot path's dominant allocation site.
228+
func ansiFromColorful(c colorful.Color, isBackground bool) Ansi {
229+
r, g, b := c.RGB255()
230+
231+
if !TrueColor {
232+
return Ansi(color.RGB(r, g, b, isBackground).C256().String())
233+
}
234+
235+
buf := make([]byte, 0, 16)
236+
237+
if isBackground {
238+
buf = append(buf, "48;2;"...)
239+
} else {
240+
buf = append(buf, "38;2;"...)
241+
}
242+
243+
buf = strconv.AppendUint(buf, uint64(r), 10)
244+
buf = append(buf, ';')
245+
buf = strconv.AppendUint(buf, uint64(g), 10)
246+
buf = append(buf, ';')
247+
buf = strconv.AppendUint(buf, uint64(b), 10)
248+
249+
return Ansi(buf)
250+
}

0 commit comments

Comments
 (0)