Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add AdaptiveFaint, ForceFaint and HasDarkColorScheme #47

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
29 changes: 29 additions & 0 deletions examples/faint/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package main

import (
"fmt"

"github.com/muesli/termenv"
)

func main() {
var s termenv.Style

compare(s)

for i := 1; i < 255; i++ {
if i < 16 {
compare(s.Foreground(termenv.ANSIColor(i)))
} else {
compare(s.Foreground(termenv.ANSI256Color(i)))
}
}
}

func compare(s termenv.Style) {
fmt.Print(s.Styled("Regular") + " | ")
fmt.Print(s.Faint().Styled("Faint") + " | ")
fmt.Print(s.ForceFaint().Styled("Forced") + " | ")
fmt.Print(s.AdaptiveFaint().Styled("Adaptive"))
fmt.Println()
}
162 changes: 140 additions & 22 deletions style.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package termenv

import (
"fmt"
"strings"

"github.com/mattn/go-runewidth"
Expand All @@ -19,10 +18,29 @@ const (
OverlineSeq = "53"
)

// modifier is a function that is applied before a style is rendered. This
// provides the opportunity to apply post-processing based on the style's
// properties, even if the rendered style is a modified copy of the style to
// which the modifier was added.
type modifier func(*Style) string

// sequenceModifier creates a simple modifier that applies an ANSI sequence.
func sequenceModifier(sequence string) modifier {
return func(*Style) string {
if sequence == "" {
return sequence
}

return sequence + ";"
}
}

// Style is a string that various rendering styles can be applied to.
type Style struct {
string
styles []string
fgColor Color
bgColor Color
modifier []modifier
}

// String returns a new Style.
Expand All @@ -38,83 +56,183 @@ func (t Style) String() string {

// Styled renders s with all applied styles.
func (t Style) Styled(s string) string {
if len(t.styles) == 0 {
if len(t.modifier) == 0 && isNoColor(t.bgColor) && isNoColor(t.fgColor) {
return s
}

seq := strings.Join(t.styles, ";")
if seq == "" {
return s
var builder strings.Builder

builder.WriteString(CSI)

processed := t // apply modifiers on a copy
for _, mod := range t.modifier {
builder.WriteString(mod(&processed))
}

if !isNoColor(processed.fgColor) {
builder.WriteString(processed.fgColor.Sequence(false) + ";")
}

if !isNoColor(processed.bgColor) {
builder.WriteString(processed.bgColor.Sequence(true) + ";")
}

return fmt.Sprintf("%s%sm%s%sm", CSI, seq, s, CSI+ResetSeq)
return strings.TrimSuffix(builder.String(), ";") + "m" + s + CSI + ResetSeq + "m"
}

// Foreground sets a foreground color.
func (t Style) Foreground(c Color) Style {
if c != nil {
t.styles = append(t.styles, c.Sequence(false))
}
t.fgColor = c

return t
}

// Background sets a background color.
func (t Style) Background(c Color) Style {
if c != nil {
t.styles = append(t.styles, c.Sequence(true))
}
t.bgColor = c

return t
}

// Bold enables bold rendering.
func (t Style) Bold() Style {
t.styles = append(t.styles, BoldSeq)
t.modifier = append(t.modifier, sequenceModifier(BoldSeq))
return t
}

// Faint enables faint rendering.
// Faint enables faint rendering using the ANSI faint/dim sequence. Not all
// terminals render faint text appropriately, especially when a light color
// scheme is used. See ForcedFaint and AdaptiveFaint for alternative solutions.
func (t Style) Faint() Style {
t.styles = append(t.styles, FaintSeq)
t.modifier = append(t.modifier, sequenceModifier(FaintSeq))
return t
}

// ForceFaint produces a consistent faint effect by changing the foreground
// color to a blend of the foreground and background color of the Style. This
// foreground or background color was set, the terminal's default colors are
// used. If this fails, ForceFaint will produce a grey foreground color as
// fallback.
func (t Style) ForceFaint() Style {
t.modifier = append(t.modifier, func(s *Style) string {
bgColor := s.backgroundColor()
fgColor := s.foregroundColor()

*s = s.Foreground(blend(fgColor, bgColor))

return ""
})

return t
}

// AdaptiveFaint produces a faint effect using Faint for terminals with a dark
// color scheme and ForceFaint for terminals with a light color scheme (see
// HasDarkColorScheme). If the color scheme cannot be detected, it uses Faint.
// This behaviour remedies the fact that many terminals do produce an
// appropriate faint effect for light color schemes.
func (t Style) AdaptiveFaint() Style {
t.modifier = append(t.modifier, func(s *Style) string {
bgColor := s.backgroundColor()
fgColor := s.foregroundColor()

if isDarker(bgColor, fgColor) || (bgColor == NoColor{}) || (fgColor == NoColor{}) {
return FaintSeq + ";"
}

*s = s.Foreground(blend(fgColor, bgColor))

return ""
})

return t
}

// foregroundColor returns the foreground color when the style is applied
// meaning the style's foreground color or the terminal's current foreground
// color if the style does not have a foreground color set.
func (t *Style) foregroundColor() Color {
if t.fgColor != nil {
return t.fgColor
}

return ForegroundColor()
}

// backgroundColor returns the background color when the style is applied
// meaning the style's background color or the terminal's current background
// color if the style does not have a background color set.
func (t *Style) backgroundColor() Color {
if t.bgColor != nil {
return t.bgColor
}

return BackgroundColor()
}

// blend produces a blend between two colors. If one of the arguments is
// NoColor{} a grey color is returned.
func blend(c1 Color, c2 Color) Color {
profile := ColorProfile()
if profile == Ascii {
return NoColor{}
}

if (c1 == NoColor{}) || (c2 == NoColor{}) {
if profile != Ascii {
return ANSIColor(8)
}

return NoColor{}
}

c1Rgb := ConvertToRGB(c1)
c2Rgb := ConvertToRGB(c2)

return profile.FromColor(c1Rgb.BlendRgb(c2Rgb, 0.5))
}

// Italic enables italic rendering.
func (t Style) Italic() Style {
t.styles = append(t.styles, ItalicSeq)
t.modifier = append(t.modifier, sequenceModifier(ItalicSeq))
return t
}

// Underline enables underline rendering.
func (t Style) Underline() Style {
t.styles = append(t.styles, UnderlineSeq)
t.modifier = append(t.modifier, sequenceModifier(UnderlineSeq))
return t
}

// Overline enables overline rendering.
func (t Style) Overline() Style {
t.styles = append(t.styles, OverlineSeq)
t.modifier = append(t.modifier, sequenceModifier(OverlineSeq))
return t
}

// Blink enables blink mode.
func (t Style) Blink() Style {
t.styles = append(t.styles, BlinkSeq)
t.modifier = append(t.modifier, sequenceModifier(BlinkSeq))
return t
}

// Reverse enables reverse color mode.
func (t Style) Reverse() Style {
t.styles = append(t.styles, ReverseSeq)
t.modifier = append(t.modifier, sequenceModifier(ReverseSeq))
return t
}

// CrossOut enables crossed-out rendering.
func (t Style) CrossOut() Style {
t.styles = append(t.styles, CrossOutSeq)
t.modifier = append(t.modifier, sequenceModifier(CrossOutSeq))
return t
}

// Width returns the width required to print all runes in Style.
func (t Style) Width() int {
return runewidth.StringWidth(t.string)
}

func isNoColor(c Color) bool {
return c == nil || c == NoColor{}
}
26 changes: 26 additions & 0 deletions style_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,29 @@ func TestStyleWidth(t *testing.T) {
t.Errorf("Expected width of 11, got %d", s.Width())
}
}

func TestForceFaint(t *testing.T) {
p := ColorProfile()
fg := p.Convert(TrueColor.Color("#40ff00"))
bg := p.Convert(TrueColor.Color("#605e10"))
s := String("Hello World").Foreground(fg).Background(bg).ForceFaint()

var exp string

switch p {
case Ascii:
exp = "\x1b[mHello World\x1b[0m"
case ANSI:
exp = "\x1b[32;43mHello World\x1b[0m"
case ANSI256:
exp = "\x1b[38;5;70;48;5;58mHello World\x1b[0m"
case TrueColor:
exp = "\x1b[38;2;80;175;8;48;2;96;94;16mHello World\x1b[0m"
default:
t.Fatalf("missing test value for color profile %d", p)
}

if s.String() != exp {
t.Errorf("Expected %s (%q), got %s (%q)", exp, exp, s.String(), s.String())
}
}
18 changes: 10 additions & 8 deletions templatehelper.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,16 @@ func TemplateFuncs(p Profile) template.FuncMap {

return s.String()
},
"Bold": styleFunc(Style.Bold),
"Faint": styleFunc(Style.Faint),
"Italic": styleFunc(Style.Italic),
"Underline": styleFunc(Style.Underline),
"Overline": styleFunc(Style.Overline),
"Blink": styleFunc(Style.Blink),
"Reverse": styleFunc(Style.Reverse),
"CrossOut": styleFunc(Style.CrossOut),
"Bold": styleFunc(Style.Bold),
"Faint": styleFunc(Style.Faint),
"ForceFaint": styleFunc(Style.ForceFaint),
"AdaptiveFaint": styleFunc(Style.AdaptiveFaint),
"Italic": styleFunc(Style.Italic),
"Underline": styleFunc(Style.Underline),
"Overline": styleFunc(Style.Overline),
"Blink": styleFunc(Style.Blink),
"Reverse": styleFunc(Style.Reverse),
"CrossOut": styleFunc(Style.CrossOut),
}
}

Expand Down
19 changes: 16 additions & 3 deletions termenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@ import (
"github.com/mattn/go-isatty"
)

var (
ErrStatusReport = errors.New("unable to retrieve status report")
)
var ErrStatusReport = errors.New("unable to retrieve status report")

type Profile int

Expand Down Expand Up @@ -57,6 +55,21 @@ func HasDarkBackground() bool {
return l < 0.5
}

// HasDarkColorScheme returns true if the current background color is darker
// than the current foreground color.
func HasDarkColorScheme() bool {
return isDarker(BackgroundColor(), ForegroundColor())
}

// isDarker returns true when the lightness of the color in the first argument
// is lower than the lightness of the color in the second argument in the HSL
// color space.
func isDarker(this, other Color) bool {
_, _, thisLightness := ConvertToRGB(this).Hsl()
_, _, otherLightness := ConvertToRGB(other).Hsl()
return thisLightness < otherLightness
}

// EnvNoColor returns true if the environment variables explicitly disable color output
// by setting NO_COLOR (https://no-color.org/)
// or CLICOLOR/CLICOLOR_FORCE (https://bixense.com/clicolors/)
Expand Down
4 changes: 2 additions & 2 deletions termenv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func TestRendering(t *testing.T) {
out = out.Underline()
out = out.Blink()

exp := "\x1b[38;2;171;205;239;48;5;69;1;3;2;4;5mfoobar\x1b[0m"
exp := "\x1b[1;3;2;4;5;38;2;171;205;239;48;5;69mfoobar\x1b[0m"
if out.String() != exp {
t.Errorf("Expected %s, got %s", exp, out.String())
}
Expand Down Expand Up @@ -203,7 +203,7 @@ func TestStyles(t *testing.T) {

exp := "\x1b[32mfoobar\x1b[0m"
if s.String() != exp {
t.Errorf("Expected %s, got %s", exp, s.String())
t.Errorf("Expected %s %q, got %s %q", exp, exp, s.String(), s.String())
}
}

Expand Down