Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 49 additions & 18 deletions color.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func noColorIsSet() bool {
type Color struct {
params []Attribute
noColor *bool
link string
}

// Attribute defines a single SGR Code
Expand Down Expand Up @@ -208,12 +209,13 @@ func (c *Color) Set() *Color {
return c
}

func (c *Color) unset() {
func (c *Color) Unset() *Color {
if c.isNoColorSet() {
return
return c
}

Unset()
fmt.Fprint(Output, c.unformat())
return c
}

// SetWriter is used to set the SGR sequence with the given io.Writer. This is
Expand All @@ -235,7 +237,7 @@ func (c *Color) UnsetWriter(w io.Writer) {
return
}

fmt.Fprintf(w, "%s[%dm", escape, Reset)
fmt.Fprint(w, c.unformat())
}

// Add is used to chain SGR parameters. Use as many as parameters to combine
Expand Down Expand Up @@ -264,7 +266,7 @@ func (c *Color) Fprint(w io.Writer, a ...interface{}) (n int, err error) {
// color.
func (c *Color) Print(a ...interface{}) (n int, err error) {
c.Set()
defer c.unset()
defer c.Unset()

return fmt.Fprint(Output, a...)
}
Expand All @@ -285,7 +287,7 @@ func (c *Color) Fprintf(w io.Writer, format string, a ...interface{}) (n int, er
// This is the standard fmt.Printf() method wrapped with the given color.
func (c *Color) Printf(format string, a ...interface{}) (n int, err error) {
c.Set()
defer c.unset()
defer c.Unset()

return fmt.Fprintf(Output, format, a...)
}
Expand Down Expand Up @@ -411,8 +413,8 @@ func (c *Color) sequence() string {
return strings.Join(format, ";")
}

// wrap wraps the s string with the colors attributes. The string is ready to
// be printed.
// wrap wraps the s string with the colors attributes and link if specified.
// The string is ready to be printed.
func (c *Color) wrap(s string) string {
if c.isNoColorSet() {
return s
Expand All @@ -422,22 +424,42 @@ func (c *Color) wrap(s string) string {
}

func (c *Color) format() string {
return fmt.Sprintf("%s[%sm", escape, c.sequence())
var sgrStart string
if len(c.params) > 0 {
sgrStart = fmt.Sprintf("%s[%sm", escape, c.sequence())
}

if c.link != "" {
// OSC 8 start: ESC]8;;URL ESC\ (or ST for String Terminator)
oscStart := fmt.Sprintf("%s]8;;%s%s\\", escape, c.link, escape)
return oscStart + sgrStart
}

return sgrStart
}

func (c *Color) unformat() string {
//return fmt.Sprintf("%s[%dm", escape, Reset)
//for each element in sequence let's use the specific reset escape, or the generic one if not found
format := make([]string, len(c.params))
for i, v := range c.params {
format[i] = strconv.Itoa(int(Reset))
ra, ok := mapResetAttributes[v]
if ok {
format[i] = strconv.Itoa(int(ra))
var sgrReset string
if len(c.params) > 0 {
// for each element in sequence let's use the specific reset escape, or the generic one if not found
format := make([]string, len(c.params))
for i, v := range c.params {
format[i] = strconv.Itoa(int(Reset))
ra, ok := mapResetAttributes[v]
if ok {
format[i] = strconv.Itoa(int(ra))
}
}
sgrReset = fmt.Sprintf("%s[%sm", escape, strings.Join(format, ";"))
}

if c.link != "" {
// OSC 8 end: ESC]8;;ESC\ (or ST for String Terminator)
oscReset := fmt.Sprintf("%s]8;;%s\\", escape, escape)
return sgrReset + oscReset
}

return fmt.Sprintf("%s[%sm", escape, strings.Join(format, ";"))
return sgrReset
}

// DisableColor disables the color output. Useful to not change any existing
Expand All @@ -453,6 +475,15 @@ func (c *Color) EnableColor() {
c.noColor = boolPtr(false)
}

// Hyperlink wraps the output with OSC hyperlink escape codes.
// It takes a URL and applies it to the color object.
// When printed, it wraps the colored text with the hyperlink escape sequence.
// Returns the color for chaining.
func (c *Color) Hyperlink(url string) *Color {
c.link = url
return c
}

func (c *Color) isNoColorSet() bool {
// check first if we have user set action
if c.noColor != nil {
Expand Down
148 changes: 148 additions & 0 deletions color_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ func Test_noColorIsSet(t *testing.T) {
func TestColorVisual(t *testing.T) {
// First Visual Test
Output = colorable.NewColorableStdout()
NoColor = false

New(FgRed).Printf("red\t")
New(BgRed).Print(" ")
Expand Down Expand Up @@ -266,6 +267,8 @@ func TestColorVisual(t *testing.T) {
New(FgWhite).Printf("white\t")
New(BgWhite).Print(" ")
New(FgWhite, Bold).Println(" white")
New(FgGreen).Hyperlink("https://example.com").Println("This should be clickable if your terminal supports it!")

fmt.Println("")

// Second Visual test
Expand Down Expand Up @@ -553,3 +556,148 @@ func TestRGB(t *testing.T) {
})
}
}

func TestSpecificUnset(t *testing.T) {
originalOutput := Output
t.Cleanup(func() {
Output = originalOutput
NoColor = false
})
NoColor = false

boldC := New(Bold)
underlineC := New(Underline)

t.Run("Print retains outer style", func(t *testing.T) {
buf := new(bytes.Buffer)
Output = buf

fmt.Fprint(buf, underlineC.format())
boldC.Print("bold text")
fmt.Fprint(buf, " still underlined?")
buf.Reset()

fmt.Fprint(buf, underlineC.format())
boldC.Printf("bold text")
fmt.Fprint(buf, " still underlined?")
fmt.Fprint(buf, New(Reset).format())

want := "\x1b[4m\x1b[1mbold text\x1b[22m still underlined?\x1b[0m"
got := readRaw(t, buf)

if want != got {
t.Errorf("Print specific reset failed:\n want: %q\n got: %q", want, got)
}
})

t.Run("Fprint retains outer style", func(t *testing.T) {
buf := new(bytes.Buffer)
fmt.Fprint(buf, underlineC.format())
boldC.Fprintf(buf, "bold text")
fmt.Fprint(buf, " still underlined?")
fmt.Fprint(buf, New(Reset).format())

want := "\x1b[4m\x1b[1mbold text\x1b[22m still underlined?\x1b[0m"
got := readRaw(t, buf)

if want != got {
t.Errorf("Fprint specific reset failed:\n want: %q\n got: %q", want, got)
}
})

t.Run("Manual Set/Unset retains outer style", func(t *testing.T) {
buf := new(bytes.Buffer)
Output = buf

fmt.Fprint(buf, underlineC.format())
boldC.Set()
fmt.Fprint(buf, "bold text")
boldC.Unset()
fmt.Fprint(buf, " still underlined?")
fmt.Fprint(buf, New(Reset).format())

want := "\x1b[4m\x1b[1mbold text\x1b[22m still underlined?\x1b[0m"
got := readRaw(t, buf)

if want != got {
t.Errorf("Manual Set/Unset specific reset failed:\n want: %q\n got: %q", want, got)
}
})

t.Run("UnsetWriter retains outer style", func(t *testing.T) {
buf := new(bytes.Buffer)

fmt.Fprint(buf, underlineC.format())
boldC.SetWriter(buf)
fmt.Fprint(buf, "bold text")
boldC.UnsetWriter(buf)
fmt.Fprint(buf, " still underlined?")
fmt.Fprint(buf, New(Reset).format())

want := "\x1b[4m\x1b[1mbold text\x1b[22m still underlined?\x1b[0m"
got := readRaw(t, buf)

if want != got {
t.Errorf("UnsetWriter specific reset failed:\n want: %q\n got: %q", want, got)
}
})
}

func TestHyperlink_WithColor(t *testing.T) {
NoColor = false

link := "https://example.com"
text := "click me"
c := New(FgBlue).Hyperlink(link)
got := c.Sprint(text)
want := "\x1b]8;;" + link + "\x1b\\" + "\x1b[34m" + text + "\x1b[0m" + "\x1b]8;;\x1b\\"
if got != want {
t.Errorf("Expected %q, got %q", want, got)
}
}

func TestHyperlink_NoColor(t *testing.T) {
link := "https://example.com"
text := "click me"
c := New(FgBlue).Hyperlink(link)
c.DisableColor()
got := c.Sprint(text)
if got != text {
t.Errorf("Expected plain text %q when color is disabled, got %q", text, got)
}
}

func TestHyperlink_Fprint(t *testing.T) {
NoColor = false

link := "https://example.com"
text := "test output"
var buf bytes.Buffer
c := New(FgGreen).Hyperlink(link)
c.Fprint(&buf, text)
want := "\x1b]8;;" + link + "\x1b\\" + "\x1b[32m" + text + "\x1b[0m" + "\x1b]8;;\x1b\\"
if buf.String() != want {
t.Errorf("Expected %q, got %q", want, buf.String())
}
}

func TestHyperlink_NestedInColor(t *testing.T) {
NoColor = false

link := "https://example.com/"
innerColor := New().Hyperlink(link)
result := innerColor.Sprint("link")

expectedWithoutSGR := "\x1b]8;;" + link + "\x1b\\link\x1b]8;;\x1b\\"

if result != expectedWithoutSGR {
t.Errorf("Hyperlink without color attributes should not emit SGR codes.\nExpected: %q\nGot: %q", expectedWithoutSGR, result)
}

outerResult := fmt.Sprintf("\x1b[36m%s\x1b[0m", fmt.Sprintf("before %s after", result))
expectedNested := "\x1b[36mbefore \x1b]8;;" + link + "\x1b\\link\x1b]8;;\x1b\\ after\x1b[0m"

if outerResult != expectedNested {
t.Errorf("Nested hyperlink should preserve outer color.\nExpected: %q\nGot: %q", expectedNested, outerResult)
}
}