diff --git a/colour.go b/colour.go index 15d794ce2..b7fd6e0e3 100644 --- a/colour.go +++ b/colour.go @@ -92,7 +92,7 @@ func (c Colour) Brighten(factor float64) Colour { return NewColour(uint8(r), uint8(g), uint8(b)) } -// BrightenOrDarken brightens a colour if it is < 0.5 brighteness or darkens if > 0.5 brightness. +// BrightenOrDarken brightens a colour if it is < 0.5 brightness or darkens if > 0.5 brightness. func (c Colour) BrightenOrDarken(factor float64) Colour { if c.Brightness() < 0.5 { return c.Brighten(factor) @@ -100,7 +100,35 @@ func (c Colour) BrightenOrDarken(factor float64) Colour { return c.Brighten(-factor) } -// Brightness of the colour (roughly) in the range 0.0 to 1.0 +// ClampBrightness returns a copy of this colour with its brightness adjusted such that +// it falls within the range [min, max] (or very close to it due to rounding errors). +// The supplied values use the same [0.0, 1.0] range as Brightness. +func (c Colour) ClampBrightness(min, max float64) Colour { + if !c.IsSet() { + return c + } + + min = math.Max(min, 0) + max = math.Min(max, 1) + current := c.Brightness() + target := math.Min(math.Max(current, min), max) + if current == target { + return c + } + + r := float64(c.Red()) + g := float64(c.Green()) + b := float64(c.Blue()) + rgb := r + g + b + if target > current { + // Solve for x: target == ((255-r)*x + r + (255-g)*x + g + (255-b)*x + b) / 255 / 3 + return c.Brighten((target*255*3 - rgb) / (255*3 - rgb)) + } + // Solve for x: target == (r*(x+1) + g*(x+1) + b*(x+1)) / 255 / 3 + return c.Brighten((target*255*3)/rgb - 1) +} + +// Brightness of the colour (roughly) in the range 0.0 to 1.0. func (c Colour) Brightness() float64 { return (float64(c.Red()) + float64(c.Green()) + float64(c.Blue())) / 255.0 / 3.0 } diff --git a/colour_test.go b/colour_test.go index 9281399c3..87f32a642 100644 --- a/colour_test.go +++ b/colour_test.go @@ -1,6 +1,7 @@ package chroma import ( + "math" "testing" "github.com/stretchr/testify/assert" @@ -40,3 +41,48 @@ func TestColourBrightess(t *testing.T) { actual := NewColour(128, 128, 128).Brightness() assert.True(t, distance(128, uint8(actual*255.0)) <= 2) } + +// hue returns c's hue. See https://stackoverflow.com/a/23094494. +func hue(c Colour) float64 { + r := float64(c.Red()) / 255 + g := float64(c.Green()) / 255 + b := float64(c.Blue()) / 255 + + min := math.Min(math.Min(r, g), b) + max := math.Max(math.Max(r, g), b) + + switch { + case r == min: + return (g - b) / (max - min) + case g == min: + return 2 + (b-r)/(max-min) + default: + return 4 + (r-g)/(max-min) + } +} + +func TestColourClampBrightness(t *testing.T) { + const delta = 0.01 // used for brightness and hue comparisons + + // Start with a colour with a brightness close to 0.5. + initial := NewColour(0, 128, 255) + br := initial.Brightness() + assert.InDelta(t, 0.5, br, delta) + + // Passing a range that includes the colour's brightness should be a no-op. + assert.Equal(t, initial.String(), initial.ClampBrightness(br-0.01, br+0.01).String()) + + // Clamping to [0, 0] or [1, 1] should produce black or white, respectively. + assert.Equal(t, "#000000", initial.ClampBrightness(0, 0).String()) + assert.Equal(t, "#ffffff", initial.ClampBrightness(1, 1).String()) + + // Clamping to a brighter or darker range should produce the requested + // brightness while preserving the colour's hue. + brighter := initial.ClampBrightness(0.75, 1) + assert.InDelta(t, 0.75, brighter.Brightness(), delta) + assert.InDelta(t, hue(initial), hue(brighter), delta) + + darker := initial.ClampBrightness(0, 0.25) + assert.InDelta(t, 0.25, darker.Brightness(), delta) + assert.InDelta(t, hue(initial), hue(darker), delta) +} diff --git a/style.go b/style.go index 1319fc424..8edea1325 100644 --- a/style.go +++ b/style.go @@ -157,9 +157,12 @@ func (s *StyleBuilder) AddAll(entries StyleEntries) *StyleBuilder { } func (s *StyleBuilder) Get(ttype TokenType) StyleEntry { - // This is less than ideal, but it's the price for having to check errors on each Add(). + // This is less than ideal, but it's the price for not having to check errors on each Add(). entry, _ := ParseStyleEntry(s.entries[ttype]) - return entry.Inherit(s.parent.Get(ttype)) + if s.parent != nil { + entry = entry.Inherit(s.parent.Get(ttype)) + } + return entry } // Add an entry to the Style map. @@ -175,6 +178,25 @@ func (s *StyleBuilder) AddEntry(ttype TokenType, entry StyleEntry) *StyleBuilder return s } +// Transform passes each style entry currently defined in the builder to the supplied +// function and saves the returned value. This can be used to adjust a style's colours; +// see Colour's ClampBrightness function, for example. +func (s *StyleBuilder) Transform(transform func(StyleEntry) StyleEntry) *StyleBuilder { + types := make(map[TokenType]struct{}) + for tt := range s.entries { + types[tt] = struct{}{} + } + if s.parent != nil { + for _, tt := range s.parent.Types() { + types[tt] = struct{}{} + } + } + for tt := range types { + s.AddEntry(tt, transform(s.Get(tt))) + } + return s +} + func (s *StyleBuilder) Build() (*Style, error) { style := &Style{ Name: s.name, diff --git a/style_test.go b/style_test.go index dcd36c035..c3952765e 100644 --- a/style_test.go +++ b/style_test.go @@ -63,3 +63,41 @@ func TestSynthesisedStyleClone(t *testing.T) { assert.Equal(t, "bg:#ffffff", style.Get(LineHighlight).String()) assert.Equal(t, "bg:#fffff1", style.Get(LineNumbers).String()) } + +func TestStyleBuilderTransform(t *testing.T) { + orig, err := NewStyle("test", StyleEntries{ + Name: "#000", + NameVariable: "bold #f00", + }) + assert.NoError(t, err) + + // Derive a style that inherits entries from orig. + builder := orig.Builder() + builder.Add(NameVariableGlobal, "#f30") + deriv, err := builder.Build() + assert.NoError(t, err) + + // Use Transform to brighten or darken all of the colours in the derived style. + light, err := deriv.Builder().Transform(func(se StyleEntry) StyleEntry { + se.Colour = se.Colour.ClampBrightness(0.9, 1) + return se + }).Build() + assert.Nilf(t, err, "Transform failed: %v", err) + assert.GreaterOrEqual(t, light.Get(Name).Colour.Brightness(), 0.89) + assert.GreaterOrEqual(t, light.Get(NameVariable).Colour.Brightness(), 0.89) + assert.GreaterOrEqual(t, light.Get(NameVariableGlobal).Colour.Brightness(), 0.89) + + dark, err := deriv.Builder().Transform(func(se StyleEntry) StyleEntry { + se.Colour = se.Colour.ClampBrightness(0, 0.1) + return se + }).Build() + assert.Nilf(t, err, "Transform failed: %v", err) + assert.LessOrEqual(t, dark.Get(Name).Colour.Brightness(), 0.11) + assert.LessOrEqual(t, dark.Get(NameVariable).Colour.Brightness(), 0.11) + assert.LessOrEqual(t, dark.Get(NameVariableGlobal).Colour.Brightness(), 0.11) + + // The original styles should be unchanged. + assert.Equal(t, "#000000", orig.Get(Name).Colour.String()) + assert.Equal(t, "#ff0000", orig.Get(NameVariable).Colour.String()) + assert.Equal(t, "#ff3300", deriv.Get(NameVariableGlobal).Colour.String()) +}