mirror of
https://github.com/JanDeDobbeleer/oh-my-posh.git
synced 2026-08-24 10:14:12 -05:00
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:
committed by
Jan De Dobbeleer
parent
4c0f1c245b
commit
5f2e723a6d
+180
-13
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user