Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
Add Colour.ClampBrightness and StyleBuilder.Transform.
These functions can make it easier to to update a style's
contrast when viewed against light and dark backgrounds.
See #353.

Also avoid a segfault when Get is called on a StyleBuilder
that was created using NewStyleBuilder (as opposed to
Style.Builder).
  • Loading branch information
derat authored and alecthomas committed Aug 31, 2022
1 parent e1a35d4 commit dbb09a5
Show file tree
Hide file tree
Showing 4 changed files with 138 additions and 4 deletions.
32 changes: 30 additions & 2 deletions colour.go
Expand Up @@ -92,15 +92,43 @@ 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)
}
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
}
Expand Down
46 changes: 46 additions & 0 deletions colour_test.go
@@ -1,6 +1,7 @@
package chroma

import (
"math"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -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)
}
26 changes: 24 additions & 2 deletions style.go
Expand Up @@ -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.
Expand All @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions style_test.go
Expand Up @@ -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())
}

0 comments on commit dbb09a5

Please sign in to comment.