feat(color): auto-shade single-color gradients with dark-gradient/light-gradient

A single-color gradient effect for segments too narrow for a real
two-stop gradient to render as anything but a solid color:
dark-gradient(#color) runs from that exact color to a darker
shade, light-gradient(#color) to a lighter one.

The shade drops (or raises) HCL lightness only, by a fraction of
the base color's own headroom toward the target end, keeping hue
exact and walking chroma down only as far as the sRGB gamut
forces - blending toward black/white directly pulls chroma down
with it, reading as the color going muddy rather than
deepening/brightening. The reference delta is tuned so a 3-cell
segment - the common case this exists for - lands its shade close
to a just-noticeable-difference rather than a hard color swap; a
width multiplier grows that delta for wider segments as a
saturating curve (not linear growth with a hard clamp - that hit
its clamp by ~15-20 cells and crushed every wider segment to the
exact same near-white/near-black color, which is what "the
gradient stops working on a larger segment" turned out to be),
so a very wide segment still shades further than a moderately
wide one while topping out at a moderate, recognizably-still-the-
same-hue color instead of washing out to white or black.

GradientLastForCells mirrors the same shading (for a given cell
count) on the raw stop text, so powerline separators, diamond
caps, and inline color-override edges (via a new
gradientRenderCells package var in the terminal writer) line up
with the color the body actually ends on for that segment's own
width. GradientLast (no cell count - used by parentBackground/
parentForeground crossing into another segment, and wherever
width isn't known) falls back to the narrowest, gentlest shade.

WithGradientStops rebuilds a gradient string keeping its own
prefix (linear-gradient, dark-gradient, or light-gradient), so
resolving a palette-referenced stop (dark-gradient(p:teal)) keeps
its darken semantics instead of silently becoming a plain
linear-gradient - whole-string palette resolution only ever
expanded a bare "p:name" that itself resolved to a gradient,
never a "p:name" used as one stop inside a gradient literal, so a
palette-referenced stop reached GradientLast unresolved and fell
back to the raw, unshaded base color.

linear-gradient itself goes back to requiring two or more stops;
the single-stop overload it briefly had is now dark-gradient's job.

Entire-Checkpoint: 85522027570b
This commit is contained in:
Jan De Dobbeleer
2026-07-24 14:33:13 +02:00
committed by Jan De Dobbeleer
parent 4c0f1c245b
commit 5f2e723a6d
8 changed files with 465 additions and 31 deletions
+180 -13
View File
@@ -1,6 +1,7 @@
package color
import (
"math"
"strconv"
"strings"
@@ -10,22 +11,68 @@ import (
)
const (
gradientPrefix = "linear-gradient("
gradientSuffix = ")"
linearGradientPrefix = "linear-gradient("
darkGradientPrefix = "dark-gradient("
lightGradientPrefix = "light-gradient("
gradientSuffix = ")"
)
// IsGradient reports whether c is a gradient definition, e.g. `linear-gradient(#FF0000, #0000FF)`.
// gradientPrefixes lists every recognized gradient prefix, checked in order.
var gradientPrefixes = [...]string{linearGradientPrefix, darkGradientPrefix, lightGradientPrefix}
// IsGradient reports whether c is a gradient definition: a multi-stop
// `linear-gradient(#FF0000, #0000FF)`, or a single-color auto-shade
// `dark-gradient(#3465a4)` / `light-gradient(#3465a4)`.
func (c Ansi) IsGradient() bool {
return strings.HasPrefix(c.String(), gradientPrefix)
_, ok := c.gradientPrefix()
return ok
}
// gradientPrefix returns the gradient prefix c starts with, and whether it matched any.
func (c Ansi) gradientPrefix() (string, bool) {
s := c.String()
for _, prefix := range gradientPrefixes {
if strings.HasPrefix(s, prefix) {
return prefix, true
}
}
return "", false
}
// shadeDirection identifies an auto-shade gradient's single stop as darkening
// (dark-gradient) or lightening (light-gradient); shadeNone means c is a plain
// linear-gradient, or not a gradient at all.
type shadeDirection int
const (
shadeNone shadeDirection = iota
shadeDark
shadeLight
)
func (c Ansi) shadeDirection() shadeDirection {
s := c.String()
switch {
case strings.HasPrefix(s, darkGradientPrefix):
return shadeDark
case strings.HasPrefix(s, lightGradientPrefix):
return shadeLight
default:
return shadeNone
}
}
// GradientStops performs syntax parsing only: it splits the comma-separated stop list inside
// `linear-gradient(...)` and trims whitespace around each stop. It does not resolve palette
// references or validate that a stop is a color. Returns nil when c is not a gradient, the
// closing paren is missing, the body contains a nested paren (angle/direction syntax is
// reserved but not implemented), or any stop is empty.
// `linear-gradient(...)`/`dark-gradient(...)`/`light-gradient(...)` and trims whitespace around
// each stop. It does not resolve palette references or validate that a stop is a color. Returns
// nil when c is not a gradient, the closing paren is missing, the body contains a nested paren
// (angle/direction syntax is reserved but not implemented), or any stop is empty.
func (c Ansi) GradientStops() []Ansi {
if !c.IsGradient() {
prefix, ok := c.gradientPrefix()
if !ok {
return nil
}
@@ -34,7 +81,7 @@ func (c Ansi) GradientStops() []Ansi {
return nil
}
body := value[len(gradientPrefix) : len(value)-len(gradientSuffix)]
body := value[len(prefix) : len(value)-len(gradientSuffix)]
if strings.ContainsAny(body, "()") {
return nil
}
@@ -54,6 +101,23 @@ func (c Ansi) GradientStops() []Ansi {
return stops
}
// WithGradientStops rebuilds c with the same gradient prefix (linear-gradient, dark-gradient,
// or light-gradient) but stops in place of the original ones. Returns c unchanged when c is
// not a gradient, so a caller can use it unconditionally on a value that might not be one.
func (c Ansi) WithGradientStops(stops []Ansi) Ansi {
prefix, ok := c.gradientPrefix()
if !ok {
return c
}
parts := make([]string, len(stops))
for i, stop := range stops {
parts[i] = stop.String()
}
return Ansi(prefix + strings.Join(parts, ", ") + gradientSuffix)
}
// GradientFirst returns the first stop of the gradient. It returns c unchanged when c is not
// a gradient, or when the gradient syntax is invalid.
func (c Ansi) GradientFirst() Ansi {
@@ -66,14 +130,35 @@ func (c Ansi) GradientFirst() Ansi {
}
// GradientLast returns the last stop of the gradient. It returns c unchanged when c is not
// a gradient, or when the gradient syntax is invalid.
// a gradient, or when the gradient syntax is invalid. Equivalent to GradientLastForCells(0):
// a dark-gradient/light-gradient shades using the narrowest (gentlest) auto-shade step, since
// the segment's actual width isn't known here. Callers that know it — the segment's own
// separators, diamond caps, and inline overrides — should call GradientLastForCells instead,
// so the edge matches the actual last cell GradientCells renders rather than the fallback.
func (c Ansi) GradientLast() Ansi {
return c.GradientLastForCells(0)
}
// GradientLastForCells is GradientLast, but for a dark-gradient/light-gradient it shades by
// exactly as much as GradientCells(c, cells, ...) would shade the segment's actual last cell
// by, so a separator/cap/parent color reference for a segment of this width matches the body
// precisely instead of jumping to a different shade right after it. cells <= 0 (width unknown)
// uses the same gentle single-step shade as a 2-cell segment.
func (c Ansi) GradientLastForCells(cells int) Ansi {
stops := c.GradientStops()
if len(stops) == 0 {
return c
}
return stops[len(stops)-1]
last := stops[len(stops)-1]
if dir := c.shadeDirection(); dir != shadeNone && len(stops) == 1 {
if clr, err := colorful.Hex(last.String()); err == nil {
return Ansi(autoShade(clr, dir, cells).Hex())
}
}
return last
}
// GradientCells resolves each stop of the gradient c — keywords like parentBackground against
@@ -90,7 +175,7 @@ func GradientCells(c Ansi, cells int, resolver String, isBackground bool, curren
stops := c.GradientStops()
if len(stops) == 0 {
log.Errorf("gradient %s: invalid syntax, expected linear-gradient(stop, stop, ...)", c)
log.Errorf("gradient %s: invalid syntax, expected linear-gradient(stop, stop, ...), dark-gradient(color), or light-gradient(color)", c)
return nil
}
@@ -125,6 +210,25 @@ func GradientCells(c Ansi, cells int, resolver String, isBackground bool, curren
colors = append(colors, clr)
}
// dark-gradient(color)/light-gradient(color) is an auto-shade request: turn the one
// resolved color into a real two-stop gradient running from that exact color to a
// darker/lighter shade of it, sized to cells, reusing the interpolation below
// unchanged. See GradientLastForCells for the matching edge (separators, diamond
// caps, parent color refs).
if dir := c.shadeDirection(); dir != shadeNone {
if len(stops) != 1 {
log.Errorf("gradient %s: expects exactly one color stop, e.g. dark-gradient(#3465a4)", c)
return nil
}
if len(colors) == 0 {
log.Errorf("gradient %s: stop does not resolve to a color", c)
return nil
}
colors = []colorful.Color{colors[0], autoShade(colors[0], dir, cells)}
}
if len(colors) < 2 {
log.Errorf("gradient %s: needs at least two valid stops, rendering the last stop as a solid color", c)
return nil
@@ -156,6 +260,69 @@ func GradientCells(c Ansi, cells int, resolver String, isBackground bool, curren
return cacheGradientCells(colors, cells, isBackground, result)
}
// autoShadeBaseSlope/Floor/Ceiling shape the lightness shift autoShade targets AT THE
// REFERENCE WIDTH (2 steps, i.e. a 3-cell segment - the tuned-by-feel narrow case dark-
// gradient/light-gradient exists for), as a fraction of the base color's own headroom
// toward black or white (so a color already close to the target end still shifts
// visibly, and one far from it doesn't overshoot), clamped to a floor and a ceiling.
//
// autoShadeWidthMultiplier grows that reference delta for a wider segment, so a wide
// gradient still reads as a clear effect instead of fading into an imperceptibly fine
// ramp - but as a SATURATING curve (autoShadeMaxMultiplier, approached over roughly
// autoShadeWidthSteepness steps), not linear growth with a hard clamp: linear growth
// hit its clamp by ~15-20 cells and stayed there, crushing every segment wider than
// that to the exact same near-white/near-black color regardless of how much wider it
// got. The curve instead keeps easing toward the cap, so a 90-cell segment shades
// further than a 20-cell one rather than looking identical to it, while a very wide
// segment still tops out at a moderate, recognizably-still-the-same-hue shade instead
// of washing out to white or crushing to black.
const (
autoShadeBaseSlope = 0.08
autoShadeBaseFloor = 0.025
autoShadeBaseCeiling = 0.05
autoShadeMaxMultiplier = 4.0
autoShadeWidthSteepness = 15.0
autoShadeMinLightness = 0.02
autoShadeMaxLightness = 0.98
autoShadeChromaStep = 0.005
)
// autoShade derives a dark-gradient/light-gradient's second stop: same hue, same chroma
// other than what the sRGB gamut forces away, lightness shifted toward black (shadeDark)
// or white (shadeLight) by the reference delta (see autoShadeBaseSlope) times a width
// multiplier that saturates as cells grows (see autoShadeMaxMultiplier), so
// GradientCells(c, cells, ...) always lands its actual last cell here regardless of
// width. Blending toward black/white directly (an earlier approach) pulls chroma along
// with it, which reads as the color going muddy rather than deepening/brightening;
// walking chroma down only as far as IsValid demands keeps the hue exact and preserves as
// much saturation as the gamut allows at the new lightness. GradientLastForCells mirrors
// this on the raw (unresolved) stop text so a segment's trailing separator/cap matches
// the color GradientCells renders the last cell as, for the SAME cells.
func autoShade(base colorful.Color, dir shadeDirection, cells int) colorful.Color {
h, c, l := base.Hcl()
// steps == 2 (a 3-cell segment) is the reference width: widthMultiplier == 1 there,
// so the delta below equals exactly what a 3-cell segment was tuned to look like.
steps := float64(max(1, cells-1))
widthMultiplier := 1 + (autoShadeMaxMultiplier-1)*(1-math.Exp(-(steps-2)/autoShadeWidthSteepness))
if dir == shadeLight {
reference := math.Max(autoShadeBaseFloor, math.Min(autoShadeBaseCeiling, autoShadeBaseSlope*(1-l)))
l = math.Min(autoShadeMaxLightness, l+reference*widthMultiplier)
} else {
reference := math.Max(autoShadeBaseFloor, math.Min(autoShadeBaseCeiling, autoShadeBaseSlope*l))
l = math.Max(autoShadeMinLightness, l-reference*widthMultiplier)
}
shade := colorful.Hcl(h, c, l)
for !shade.IsValid() && c > 0 {
c -= autoShadeChromaStep
shade = colorful.Hcl(h, c, l)
}
return shade.Clamped()
}
// gradientCellCache memoizes interpolation results keyed on the RESOLVED stop colors
// (keyword and palette stops resolve before the key is built, so context changes miss
// the cache correctly), the cell count, the channel, and the TrueColor mode. Prompt
+105 -1
View File
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/alecthomas/assert"
"github.com/lucasb-eyer/go-colorful"
)
func TestIsGradient(t *testing.T) {
@@ -14,6 +15,8 @@ func TestIsGradient(t *testing.T) {
Expected bool
}{
{Case: "gradient", Color: "linear-gradient(#FF0000, #0000FF)", Expected: true},
{Case: "dark-gradient", Color: "dark-gradient(#3465A4)", Expected: true},
{Case: "light-gradient", Color: "light-gradient(#3465A4)", Expected: true},
{Case: "hex", Color: "#FF0000", Expected: false},
{Case: "empty", Color: "", Expected: false},
{Case: "keyword", Color: Background, Expected: false},
@@ -34,6 +37,8 @@ func TestGradientStops(t *testing.T) {
{Case: "three stops", Color: "linear-gradient(#FF0000, #00FF00, #0000FF)", Expected: []Ansi{"#FF0000", "#00FF00", "#0000FF"}},
{Case: "whitespace variants", Color: "linear-gradient( #FF0000 , #0000FF )", Expected: []Ansi{"#FF0000", "#0000FF"}},
{Case: "palette ref stops", Color: "linear-gradient(p:red, p:blue)", Expected: []Ansi{"p:red", "p:blue"}},
{Case: "dark-gradient single stop", Color: "dark-gradient(#3465A4)", Expected: []Ansi{"#3465A4"}},
{Case: "light-gradient single stop", Color: "light-gradient(#3465A4)", Expected: []Ansi{"#3465A4"}},
{Case: "not a gradient", Color: "#FF0000", Expected: nil},
{Case: "empty string", Color: "", Expected: nil},
{Case: "no closing paren", Color: "linear-gradient(#FF0000, #0000FF", Expected: nil},
@@ -145,7 +150,10 @@ func TestGradientCellsInvalidReturnsNil(t *testing.T) {
Cells int
}{
{Case: "one invalid stop of two", Color: "linear-gradient(#FF0000, notacolor)", Cells: 3},
{Case: "single stop", Color: "linear-gradient(#FF0000)", Cells: 3},
{Case: "linear-gradient with a single stop needs a real second stop", Color: "linear-gradient(#FF0000)", Cells: 3},
{Case: "dark-gradient unresolvable stop", Color: "dark-gradient(notacolor)", Cells: 3},
{Case: "light-gradient unresolvable stop", Color: "light-gradient(notacolor)", Cells: 3},
{Case: "dark-gradient rejects more than one stop", Color: "dark-gradient(#FF0000, #0000FF)", Cells: 3},
{Case: "not a gradient", Color: "#FF0000", Cells: 3},
{Case: "invalid syntax", Color: "linear-gradient(#FF0000, #0000FF", Cells: 3},
{Case: "zero cells", Color: "linear-gradient(#FF0000, #0000FF)", Cells: 0},
@@ -157,6 +165,102 @@ func TestGradientCellsInvalidReturnsNil(t *testing.T) {
}
}
// TestGradientCellsAutoShade verifies dark-gradient(#color)/light-gradient(#color) — a
// single explicit stop — spreads into a two-stop gradient running from the exact
// configured color to a darker/lighter shade of it, instead of collapsing like an
// ordinary invalid (< 2 stop) linear-gradient. The first cell must be the unmodified
// configured color (matching GradientFirst) and the last must match
// GradientLastForCells for the SAME cell count, so separators/caps line up.
func TestGradientCellsAutoShade(t *testing.T) {
cases := []struct {
Case string
Kind string
}{
{Case: "dark-gradient darkens", Kind: "dark"},
{Case: "light-gradient lightens", Kind: "light"},
}
for _, tc := range cases {
gradient := Ansi(tc.Kind + "-gradient(#3465A4)")
result := GradientCells(gradient, 5, &Defaults{}, false, nil, nil)
assert.Len(t, result, 5, tc.Case)
assert.Equal(t, Ansi("38;2;52;101;164"), result[0], tc.Case+": the first cell must be the unmodified configured color")
expectedLast := GradientCells(Ansi("linear-gradient(#3465A4, "+gradient.GradientLastForCells(5).String()+")"), 5, &Defaults{}, false, nil, nil)
assert.Equal(t, expectedLast[len(expectedLast)-1], result[len(result)-1], tc.Case+": the last cell must match GradientLastForCells(5), the color separators/caps use")
assert.NotEqual(t, result[0], result[len(result)-1], tc.Case+": the segment must actually shade, not render solid")
}
}
// TestGradientCellsAutoShadeScalesWithWidth verifies the total base-to-shade delta
// grows with the segment's cell count instead of staying fixed: a wide segment must
// end further from its base color than a narrow one, so a wide gradient still reads
// as a clear effect instead of fading into an imperceptibly fine ramp - the report
// behind this fix was that a wide segment's gradient looked completely flat.
func TestGradientCellsAutoShadeScalesWithWidth(t *testing.T) {
narrow := GradientCells("dark-gradient(#179299)", 3, &Defaults{}, true, nil, nil)
wide := GradientCells("dark-gradient(#179299)", 15, &Defaults{}, true, nil, nil)
base, err := colorful.Hex("#179299")
assert.Nil(t, err)
narrowLast, ok := parseTrueColor(narrow[len(narrow)-1])
assert.True(t, ok)
wideLast, ok := parseTrueColor(wide[len(wide)-1])
assert.True(t, ok)
assert.True(t, base.DistanceLab(wideLast) > base.DistanceLab(narrowLast), "a 15-cell segment must end further from the base color than a 3-cell one")
}
// TestGradientCellsAutoShadeSingleCell verifies a single-cell segment (too narrow to
// show any blend) renders the configured color unmodified, not a shaded endpoint.
func TestGradientCellsAutoShadeSingleCell(t *testing.T) {
result := GradientCells("dark-gradient(#3465A4)", 1, &Defaults{}, false, nil, nil)
assert.Equal(t, []Ansi{"38;2;52;101;164"}, result)
}
// TestGradientLastAutoShade verifies GradientLast (width unknown, the gentlest single-
// step shade) and GradientLastForCells (matching GradientCells for a given cell count)
// darken for dark-gradient and lighten for light-gradient, and fall back to the raw
// stop for one that can't be shaded without a resolver (keyword, palette reference).
func TestGradientLastAutoShade(t *testing.T) {
unshadeable := "can't be shaded without a resolver, so it passes through unchanged"
assert.Equal(t, Ansi("#2b5f9d"), Ansi("dark-gradient(#3465A4)").GradientLast(), "dark-gradient's width-unknown shade uses the gentlest (single-step) delta")
assert.Equal(t, Ansi("#3f6eae"), Ansi("light-gradient(#3465A4)").GradientLast(), "light-gradient's width-unknown shade uses the gentlest (single-step) delta")
assert.Equal(t, Ansi("#245a98"), Ansi("dark-gradient(#3465A4)").GradientLastForCells(5), "dark-gradient at 5 cells shades further than the width-unknown fallback")
assert.Equal(t, Ansi("parentBackground"), Ansi("dark-gradient(parentBackground)").GradientLast(), "a keyword "+unshadeable)
assert.Equal(t, Ansi("p:red"), Ansi("dark-gradient(p:red)").GradientLast(), "a palette reference "+unshadeable)
// linear-gradient with a single stop is not an auto-shade request; it degrades like
// any other invalid (< 2 stop) gradient, returning the lone stop unshaded.
assert.Equal(t, Ansi("#3465A4"), Ansi("linear-gradient(#3465A4)").GradientLast(), "a single-stop linear-gradient is not auto-shaded")
}
// TestWithGradientStops verifies the rebuilt string keeps c's own prefix, so a palette
// reference resolved inside a dark-gradient/light-gradient stays that same kind instead
// of silently becoming a plain linear-gradient.
func TestWithGradientStops(t *testing.T) {
cases := []struct {
Case string
Color Ansi
Stops []Ansi
Expected Ansi
}{
{Case: "linear-gradient", Color: "linear-gradient(#FF0000, #0000FF)", Stops: []Ansi{"#111111", "#222222"}, Expected: "linear-gradient(#111111, #222222)"},
{Case: "dark-gradient", Color: "dark-gradient(p:teal)", Stops: []Ansi{"#179299"}, Expected: "dark-gradient(#179299)"},
{Case: "light-gradient", Color: "light-gradient(p:teal)", Stops: []Ansi{"#179299"}, Expected: "light-gradient(#179299)"},
{Case: "not a gradient", Color: "#FF0000", Stops: []Ansi{"#111111"}, Expected: "#FF0000"},
}
for _, tc := range cases {
assert.Equal(t, tc.Expected, tc.Color.WithGradientStops(tc.Stops), tc.Case)
}
}
func TestGradientCellsColor256Fallback(t *testing.T) {
origTrueColor := TrueColor
t.Cleanup(func() { TrueColor = origTrueColor })
+57 -4
View File
@@ -447,7 +447,56 @@ func resolvePaletteReference(c color.Ansi) color.Ansi {
return c
}
return resolved
if !resolved.IsGradient() {
return resolved
}
return resolveGradientStopReferences(resolved)
}
// resolveGradientStopReferences expands a palette reference (p:name) used as an
// individual STOP inside a gradient, e.g. dark-gradient(p:teal): the whole-string
// resolve above only catches a bare "p:name" that itself resolves to a gradient, so a
// palette-referenced stop reaches GradientLast, separators, diamond caps, and the
// parentBackground/parentForeground chain in keywords.go as a raw, unresolvable
// "p:name" string — none of those have resolver access to expand it themselves. A
// keyword stop (parentBackground, foreground, accent, ...) or literal hex stop is not
// a palette key and passes through Resolve unchanged, so this only ever rewrites actual
// palette references. A stop that resolves to a gradient itself (a palette entry holding
// a gradient) is left as its raw "p:name" text: GradientStops already rejects a nested
// gradient, so it must degrade through the normal per-stop resolve-and-skip path in
// GradientCells instead, same as before this function existed.
func resolveGradientStopReferences(c color.Ansi) color.Ansi {
stops := c.GradientStops()
if len(stops) == 0 {
return c
}
resolvedStops := make([]color.Ansi, len(stops))
changed := false
for i, stop := range stops {
resolved, err := terminal.Colors.Resolve(stop)
if err != nil || resolved.IsGradient() {
resolvedStops[i] = stop
continue
}
if resolved != stop {
changed = true
}
resolvedStops[i] = resolved
}
if !changed {
return c
}
// WithGradientStops preserves c's own prefix (linear-gradient, dark-gradient, or
// light-gradient), so a resolved dark-gradient(p:teal) stays a dark-gradient with
// its darken semantics, not a plain linear-gradient.
return c.WithGradientStops(resolvedStops)
}
// collapseGradient reports whether c must collapse to a single solid color because the
@@ -474,15 +523,19 @@ func collapseGradient(c color.Ansi, cells int) (color.Ansi, bool) {
// backgroundEdge collapses a segment's background gradient to its last stop,
// resolving a keyword stop (foreground, background) against the SAME segment's
// colors so edge consumers never leak a keyword into the wrong context.
// colors so edge consumers never leak a keyword into the wrong context. Uses the
// segment's own visible cell count so a dark-gradient/light-gradient's edge matches
// the actual last cell GradientCells renders it as (see GradientLastForCells).
func backgroundEdge(segment *config.Segment) color.Ansi {
cells := terminal.VisibleCells(segment.Text())
background := resolvePaletteReference(segment.ResolveBackground())
stop := background.GradientLast()
stop := background.GradientLastForCells(cells)
switch stop { //nolint:exhaustive
case color.Foreground:
stop = resolvePaletteReference(segment.ResolveForeground()).GradientLast()
stop = resolvePaletteReference(segment.ResolveForeground()).GradientLastForCells(cells)
case color.Background:
// self-reference has no resolvable edge
return color.Transparent
+78 -3
View File
@@ -16,14 +16,30 @@ import (
// keeps the first/last stops trivially distinguishable in assertions.
const gradientStops = color.Ansi("linear-gradient(#FF0000, #0000FF)")
// newPowerlineSegment builds a fully-initialized Powerline-style segment (writer
// mapped, text rendered) so backgroundEdge's VisibleCells(segment.Text()) call has
// something real to measure instead of panicking on a nil writer.
func newPowerlineSegment(t *testing.T, engine *Engine, template string, background color.Ansi) *config.Segment {
t.Helper()
segment := &config.Segment{Type: "text", Template: template, Style: config.Powerline, Background: background}
assert.NoError(t, segment.MapSegmentWithWriter(engine.Env))
segment.Render(0, true)
return segment
}
// TestGetPowerlineColorGradient covers engine.go's getPowerlineColor: the
// powerline separator symbol sits at the previous segment's right edge, so a
// gradient background must collapse to its last stop, never the first.
func TestGetPowerlineColorGradient(t *testing.T) {
previous := &config.Segment{Type: "text", Style: config.Powerline, Background: gradientStops}
active := &config.Segment{Type: "text", Style: config.Powerline, Background: "green"}
engine := New(&runtime.Flags{IsPrimary: true})
engine := &Engine{previousActiveSegment: previous, activeSegment: active}
previous := newPowerlineSegment(t, engine, "abc", gradientStops)
active := newPowerlineSegment(t, engine, "def", "green")
engine.previousActiveSegment = previous
engine.activeSegment = active
got := engine.getPowerlineColor()
@@ -31,6 +47,65 @@ func TestGetPowerlineColorGradient(t *testing.T) {
assert.NotEqual(t, gradientStops.GradientFirst(), got, "powerline separator color must not be the gradient's first stop")
}
// TestGetPowerlineColorSingleStopAutoShade covers the bug report behind the auto-shade
// fix: a single-stop gradient's powerline separator must render the same shaded color
// GradientCells renders the segment's last cell as, not the unshaded configured color -
// otherwise the separator visibly jumps back to a different shade right after the body.
func TestGetPowerlineColorSingleStopAutoShade(t *testing.T) {
engine := New(&runtime.Flags{IsPrimary: true})
singleStop := color.Ansi("dark-gradient(#3465A4)")
previous := newPowerlineSegment(t, engine, "abc", singleStop)
active := newPowerlineSegment(t, engine, "def", "green")
engine.previousActiveSegment = previous
engine.activeSegment = active
got := engine.getPowerlineColor()
assert.Equal(t, singleStop.GradientLastForCells(3), got, "powerline separator must be the auto-shaded last stop, sized to the segment's 3 visible cells")
assert.NotEqual(t, singleStop.GradientFirst(), got, "powerline separator must not be the unshaded configured color")
}
// TestGetPowerlineColorPaletteReferencedStop pins the fix for a palette reference used
// as an individual gradient STOP rather than the whole gradient value:
// dark-gradient(p:teal) must resolve p:teal before GradientLast shades it, so the
// powerline separator matches the auto-shaded color GradientCells renders the segment's
// last cell as, instead of the raw "p:teal" text GradientLast previously couldn't shade
// without a resolver (it fell through unshaded to the bright, unmodified base color).
func TestGetPowerlineColorPaletteReferencedStop(t *testing.T) {
engine := New(&runtime.Flags{IsPrimary: true})
terminal.String()
palette := color.Palette{"teal": "#179299"}
origColors := terminal.Colors
terminal.Colors = color.MakeColors(palette, false, "", engine.Env)
t.Cleanup(func() { terminal.Colors = origColors })
previous := newPowerlineSegment(t, engine, "abc", "dark-gradient(p:teal)")
active := newPowerlineSegment(t, engine, "def", "green")
engine.previousActiveSegment = previous
engine.activeSegment = active
got := engine.getPowerlineColor()
assert.Equal(t, color.Ansi("#179299").GradientFirst(), color.Ansi("#179299"), "sanity: base color unchanged by GradientFirst")
assert.NotEqual(t, color.Ansi("#179299"), got, "the separator must not render the raw, unshaded base color")
unresolvedLast := color.Ansi("dark-gradient(p:teal)").GradientLastForCells(3)
assert.NotEqual(t, unresolvedLast, got, "GradientLastForCells on the raw p:teal stop can't shade without a resolver; the engine must resolve the palette reference first")
resolvedLast := color.Ansi("dark-gradient(#179299)").GradientLastForCells(3)
assert.Equal(t, resolvedLast, got, "the separator must match the auto-shaded color GradientCells renders the last body cell as, sized to the segment's 3 visible cells")
// the resolved gradient must keep its dark-gradient prefix, not silently become a
// plain linear-gradient: unlike dark-gradient, a single-stop linear-gradient is not
// auto-shaded, so its GradientLast is the raw, unshaded base color.
assert.Equal(t, color.Ansi("#179299"), color.Ansi("linear-gradient(#179299)").GradientLast(), "sanity: a single-stop linear-gradient is not auto-shaded")
assert.NotEqual(t, color.Ansi("#179299"), got, "the resolved gradient must have kept dark-gradient's auto-shade semantics")
}
// TestWriteSeparatorTrailingDiamondGradient covers writeSeparator's final
// trailing-diamond branch: the glyph sits at the segment's right edge, so a
// gradient background must render as its last stop rather than the writer's
+12 -1
View File
@@ -66,6 +66,13 @@ var (
fgGradientCells []color.Ansi
cellIndex int
// gradientRenderCells is the segment currently being written's visible cell count,
// set once cells is known (see Write). collapseGradientLast reads it so a
// dark-gradient/light-gradient color override edge mid-body matches the same shade
// GradientCells rendered the segment's actual last cell as (see GradientLastForCells).
// Zero (its reset value) falls back to GradientLast's gentlest single-step shade.
gradientRenderCells int
isTransparent bool
isInvisible bool
isHyperlink bool
@@ -377,6 +384,7 @@ func Write(background, foreground color.Ansi, txt string) {
// reset gradient state left over from a previous Write call
bgGradientCells, fgGradientCells = nil, nil
cellIndex = 0
gradientRenderCells = 0
// isTransparent is per-segment state: a previous Write's transparent rendering
// must not suppress gradient stamping (or trigger a spurious transparentEnd in
@@ -423,6 +431,7 @@ func Write(background, foreground color.Ansi, txt string) {
// so GradientCells can hand back one color per cell up front.
if bgGradient || fgGradient {
cells := countVisibleCells(body, match.Anchor == hyperLinkStart)
gradientRenderCells = cells
if bgGradient {
bgGradientCells = color.GradientCells(backgroundColor, cells, Colors, true, CurrentColors, ParentColors)
@@ -838,8 +847,10 @@ func collapseGradientFirst(c color.Ansi, isBackground bool) color.Ansi {
// collapseGradientLast is collapseGradientFirst's right-edge counterpart, used for
// the invalid-gradient fallback so the body matches the last-stop color the engine's
// width collapse and every edge consumer (separators, parent keywords) already use.
// Uses gradientRenderCells so a dark-gradient/light-gradient edge matches the actual
// last cell GradientCells rendered THIS segment's body as (see GradientLastForCells).
func collapseGradientLast(c color.Ansi, isBackground bool) color.Ansi {
return collapseGradientStop(c.GradientLast(), isBackground)
return collapseGradientStop(c.GradientLastForCells(gradientRenderCells), isBackground)
}
func collapseGradientStop(stop color.Ansi, isBackground bool) color.Ansi {
+4 -4
View File
@@ -44,7 +44,7 @@ func TestWriteGradientRendering(t *testing.T) {
bgGradient := color.Ansi("linear-gradient(#FF0000, #0000FF)")
fgGradient := color.Ansi("linear-gradient(#00FF00, #FF00FF)")
invalidGradient := color.Ansi("linear-gradient(#FF0000)")
singleStopGradient := color.Ansi("dark-gradient(#FF0000)")
resolver := &color.Defaults{}
@@ -88,10 +88,10 @@ func TestWriteGradientRendering(t *testing.T) {
Expected: colorise("37") + colorise(bgCells4[0]) + "a" + colorise(bgCells4[1]) + "漢" + colorise(bgCells4[3]) + "b" + gradientReset,
},
{
Case: "single-stop gradient falls back to its only stop",
Case: "single-stop gradient auto-shades across cells",
Input: "ab",
Colors: &color.Set{Foreground: "white", Background: invalidGradient},
Expected: colorise("48;2;255;0;0") + colorise("37") + "ab" + gradientReset,
Colors: &color.Set{Foreground: "white", Background: singleStopGradient},
Expected: colorise("37") + colorise("48;2;255;0;0") + "a" + colorise("48;2;238;3;1") + "b" + gradientReset,
},
{
Case: "syntactically invalid gradient renders no background escape",
+1 -1
View File
@@ -33,7 +33,7 @@
},
"gradient": {
"type": "string",
"pattern": "^linear-gradient\\(\\s*[^,()]+(\\s*,\\s*[^,()]+)*\\s*\\)$",
"pattern": "^(linear|dark|light)-gradient\\(\\s*[^,()]+(\\s*,\\s*[^,()]+)*\\s*\\)$",
"title": "Gradient color",
"description": "https://ohmyposh.dev/docs/configuration/colors#gradients"
},
+28 -4
View File
@@ -52,6 +52,27 @@ Powerline separators, diamond caps, and the `parentBackground`/`parentForeground
automatically pick up the matching edge of the gradient, so a segment next to a gradient still
connects with the right color.
### Auto-shade from a single color
`dark-gradient(#3465A4)` and `light-gradient(#3465A4)` take a single color and run from that
exact color to a darker or lighter shade of it, letting one configured color produce a gradient
effect on a segment too narrow for a real two-stop gradient to render as anything but a solid
color, see [Stops and segment width](#stops-and-segment-width).
<Config
data={{
type: "text",
style: "plain",
background: "dark-gradient(#3465A4)",
}}
/>
The first cell always renders the exact configured color; the last renders the shade. Everything
in between interpolates the same way a two-stop gradient does, so a wide segment shows a gentler
ramp and a narrow one a coarser one. A single-cell segment renders the configured color unmodified.
Each takes exactly one color; `dark-gradient`/`light-gradient` with more than one stop is invalid,
same as a `linear-gradient` with fewer than two.
### Supported stops
A stop must resolve to a hex color before it can be interpolated:
@@ -77,10 +98,13 @@ segment resolves to the gradient's color at that position in the text: a leading
`<background,transparent>` cap picks up the first stop, a trailing one the color where the
gradient ends, so template caps stay seamless without hardcoding edge colors.
A gradient without at least two supported stops renders as a solid color: the last stop goes through
the regular color pipeline, so `linear-gradient(red, blue)` renders solid blue rather than failing —
matching the color its separators and width-collapsed form already use. Rejected stops are reported
in `oh-my-posh debug` output.
A `linear-gradient` that resolves to fewer than two supported colors — including a `linear-gradient`
written with only one stop — renders as a solid color: the last stop goes through the regular color
pipeline, so `linear-gradient(red, blue)` renders solid blue rather than failing, and
`linear-gradient(#3465A4)` renders solid `#3465A4` rather than shading — matching the color its
separators and width-collapsed form already use. Rejected stops are reported in `oh-my-posh debug`
output. Use [`dark-gradient`/`light-gradient`](#auto-shade-from-a-single-color) for a one-color
gradient effect instead.
### Stops and segment width