diff --git a/color.go b/color.go index b2795fed..407eaff7 100644 --- a/color.go +++ b/color.go @@ -44,6 +44,7 @@ func noColorIsSet() bool { type Color struct { params []Attribute noColor *bool + link string } // Attribute defines a single SGR Code @@ -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 @@ -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 @@ -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...) } @@ -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...) } @@ -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 @@ -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 @@ -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 { diff --git a/color_test.go b/color_test.go index 586039b4..5e9ca27f 100644 --- a/color_test.go +++ b/color_test.go @@ -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(" ") @@ -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 @@ -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) + } +}