From 15f966a69d9ad8943891420220d6188da920a3db Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 29 May 2024 20:08:23 -0400 Subject: [PATCH 1/5] Add hyperlink getters and setters on Style --- get.go | 29 +++++++++++++++++++++++++++-- set.go | 13 +++++++++++++ style.go | 4 ++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/get.go b/get.go index 9c2f06fe..10e50102 100644 --- a/get.go +++ b/get.go @@ -414,6 +414,10 @@ func (s Style) GetTransform() func(string) string { return s.getAsTransform(transformKey) } +func (s Style) GetHyperlink() (url string, params map[string]string) { + return s.getAsHyperlink(hyperlinkKey) +} + // Returns whether or not the given property is set. func (s Style) isSet(k propKey) bool { return s.props.has(k) @@ -519,13 +523,34 @@ func (s Style) getBorderStyle() Border { return s.borderStyle } -func (s Style) getAsTransform(propKey) func(string) string { - if !s.isSet(transformKey) { +func (s Style) getAsTransform(k propKey) func(string) string { + if !s.isSet(k) { return nil } return s.transform } +func (s Style) getAsHyperlink(k propKey) (string, map[string]string) { + if !s.isSet(k) || len(s.hyperlink) == 0 { + return "", nil + } + + var ( + url = s.hyperlink[0] + params = s.hyperlink[1:] + m map[string]string + ) + + if len(params) >= 2 { + m = make(map[string]string) + for i := 0; i < len(params); i += 2 { + m[params[i]] = params[i+1] + } + } + + return url, m +} + // Split a string into lines, additionally returning the size of the widest // line. func getLines(s string) (lines []string, widest int) { diff --git a/set.go b/set.go index ed6e272c..1006d1b0 100644 --- a/set.go +++ b/set.go @@ -65,6 +65,8 @@ func (s *Style) set(key propKey, value interface{}) { s.tabWidth = value.(int) case transformKey: s.transform = value.(func(string) string) + case hyperlinkKey: + s.hyperlink = value.([]string) default: if v, ok := value.(bool); ok { //nolint:nestif if v { @@ -145,6 +147,8 @@ func (s *Style) setFrom(key propKey, i Style) { s.set(tabWidthKey, i.tabWidth) case transformKey: s.set(transformKey, i.transform) + case hyperlinkKey: + s.set(hyperlinkKey, i.hyperlink) default: // Set attributes for set bool properties s.set(key, i.attrs) @@ -685,6 +689,15 @@ func (s Style) Transform(fn func(string) string) Style { return s } +func (s Style) Hyperlink(url string, params ...string) Style { + if url == "" { + return s + } + params = append([]string{url}, params...) + s.set(hyperlinkKey, params) + return s +} + // Renderer sets the renderer for the style. This is useful for changing the // renderer for a style that is being used in a different context. func (s Style) Renderer(r *Renderer) Style { diff --git a/style.go b/style.go index 28ddccbe..df430159 100644 --- a/style.go +++ b/style.go @@ -75,6 +75,8 @@ const ( tabWidthKey transformKey + + hyperlinkKey ) // props is a set of properties. @@ -157,6 +159,8 @@ type Style struct { tabWidth int transform func(string) string + + hyperlink []string } // joinString joins a list of strings into a single string separated with a From 8fdfad38307ea6ab2f9d2c19ed94acf523ec449a Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 29 May 2024 20:16:21 -0400 Subject: [PATCH 2/5] Add hyperlink getter and setter test --- hyperlink_test.go | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 hyperlink_test.go diff --git a/hyperlink_test.go b/hyperlink_test.go new file mode 100644 index 00000000..50f3c3e2 --- /dev/null +++ b/hyperlink_test.go @@ -0,0 +1,35 @@ +package lipgloss + +import "testing" + +func TestHyperlinkGetter(t *testing.T) { + for i, tt := range []struct { + style Style + expectedURL string + expectedParams map[string]string + }{ + { + style: NewStyle().Hyperlink("https://charm.sh"), + expectedURL: "https://charm.sh", + expectedParams: nil, + }, + { + style: NewStyle().Hyperlink("https://charm.sh", "id", "IDK"), + expectedURL: "https://charm.sh", + expectedParams: map[string]string{"id": "IDK"}, + }, + } { + url, params := tt.style.GetHyperlink() + if url != tt.expectedURL { + t.Errorf("Test %d: expected URL %q, got %q", i, tt.expectedURL, url) + } + if len(params) != len(tt.expectedParams) { + t.Errorf("Test %d: expected %d params, got %d", i, len(tt.expectedParams), len(params)) + } + for k, v := range tt.expectedParams { + if params[k] != v { + t.Errorf("Test %d: expected param %q to be %q, got %q", i, k, v, params[k]) + } + } + } +} From 5ed623a5c291071648b7d0e95d40c1aa0c3ad9f6 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 29 May 2024 21:58:12 -0400 Subject: [PATCH 3/5] Automatically set ID in hyperlinks. Disallow setting/getting other params. --- get.go | 27 +++++++++++---------------- go.mod | 1 + go.sum | 2 ++ hyperlink_test.go | 46 ++++++++++++++++++++++++++++------------------ set.go | 20 +++++++++++++++++--- 5 files changed, 59 insertions(+), 37 deletions(-) diff --git a/get.go b/get.go index 10e50102..70ddcade 100644 --- a/get.go +++ b/get.go @@ -414,7 +414,9 @@ func (s Style) GetTransform() func(string) string { return s.getAsTransform(transformKey) } -func (s Style) GetHyperlink() (url string, params map[string]string) { +// GetHyperlink returns the hyperlink set on the style. If no hyperlink is set +// the empty string and nil is returned. +func (s Style) GetHyperlink() string { return s.getAsHyperlink(hyperlinkKey) } @@ -530,25 +532,18 @@ func (s Style) getAsTransform(k propKey) func(string) string { return s.transform } -func (s Style) getAsHyperlink(k propKey) (string, map[string]string) { - if !s.isSet(k) || len(s.hyperlink) == 0 { - return "", nil +// getAsHyperlink returns the hyperlink URL set on the style. No other +// parameters will be returned. +func (s Style) getAsHyperlink(k propKey) string { + if !s.isSet(k) { + return "" } - var ( - url = s.hyperlink[0] - params = s.hyperlink[1:] - m map[string]string - ) - - if len(params) >= 2 { - m = make(map[string]string) - for i := 0; i < len(params); i += 2 { - m[params[i]] = params[i+1] - } + if len(s.hyperlink) == 0 { + return "" } - return url, m + return s.hyperlink[0] } // Split a string into lines, additionally returning the size of the widest diff --git a/go.mod b/go.mod index bf419526..1afaee77 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ go 1.18 require ( github.com/aymanbagabas/go-udiff v0.2.0 github.com/charmbracelet/x/ansi v0.1.1 + github.com/hashicorp/uuid v0.0.0-20230117211644-212010c9d616 github.com/muesli/termenv v0.15.2 github.com/rivo/uniseg v0.4.7 ) diff --git a/go.sum b/go.sum index 183e44ce..d313306a 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/charmbracelet/x/ansi v0.1.1 h1:CGAduulr6egay/YVbGc8Hsu8deMg1xZ/bkaXTPi1JDk= github.com/charmbracelet/x/ansi v0.1.1/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/hashicorp/uuid v0.0.0-20230117211644-212010c9d616 h1:XVHNaBfj9Dp+skr2rRHK3GdgF0m/tsCc/ClnBKrlN0k= +github.com/hashicorp/uuid v0.0.0-20230117211644-212010c9d616/go.mod h1:fHzc09UnyJyqyW+bFuq864eh+wC7dj65aXmXLRe5to0= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/hyperlink_test.go b/hyperlink_test.go index 50f3c3e2..f7f04250 100644 --- a/hyperlink_test.go +++ b/hyperlink_test.go @@ -4,32 +4,42 @@ import "testing" func TestHyperlinkGetter(t *testing.T) { for i, tt := range []struct { - style Style - expectedURL string - expectedParams map[string]string + style Style + url string }{ { - style: NewStyle().Hyperlink("https://charm.sh"), - expectedURL: "https://charm.sh", - expectedParams: nil, + style: NewStyle(), + url: "https://charm.sh", }, { - style: NewStyle().Hyperlink("https://charm.sh", "id", "IDK"), - expectedURL: "https://charm.sh", - expectedParams: map[string]string{"id": "IDK"}, + style: NewStyle().Bold(true), + url: "https://charm.sh/blog/", }, } { - url, params := tt.style.GetHyperlink() - if url != tt.expectedURL { - t.Errorf("Test %d: expected URL %q, got %q", i, tt.expectedURL, url) + // Check that URL is set correctly. + s := tt.style.Hyperlink(tt.url) + url := s.GetHyperlink() + if url != tt.url { + t.Errorf("Test %d: expected URL %q, got %q", i, tt.url, url) } - if len(params) != len(tt.expectedParams) { - t.Errorf("Test %d: expected %d params, got %d", i, len(tt.expectedParams), len(params)) + + if len(s.hyperlink) < 3 { + t.Errorf("Test %d: hyperlink parameters missing (we're looking for an ID)", i) + } + + // slice to map + params := make(map[string]string) + for n := 1; n < len(s.hyperlink); n += 2 { + params[s.hyperlink[n]] = s.hyperlink[n+1] + } + + // Check that ID is set. + id, ok := params["id"] + if !ok { + t.Errorf("Test %d: ID key missing in hyperlink data", i) } - for k, v := range tt.expectedParams { - if params[k] != v { - t.Errorf("Test %d: expected param %q to be %q, got %q", i, k, v, params[k]) - } + if id == "" { + t.Errorf("Test %d: value missing in hyperlink data", i) } } } diff --git a/set.go b/set.go index 1006d1b0..6e36bbc6 100644 --- a/set.go +++ b/set.go @@ -1,5 +1,7 @@ package lipgloss +import "github.com/hashicorp/uuid" + // Set a value on the underlying rules map. func (s *Style) set(key propKey, value interface{}) { // We don't allow negative integers on any of our other values, so just keep @@ -689,12 +691,24 @@ func (s Style) Transform(fn func(string) string) Style { return s } -func (s Style) Hyperlink(url string, params ...string) Style { +// Hyperlink sets a hyperlink on the style. You're responsible for setting +// a style on the on the text so that it visually identifiable as a hyperlink +// (or not). +// +// Note that hyperlinks are not available in all terminal emulators and there's +// a no way to detect if hyperlinks are supported. If hyperlinks are not +// the text will still render as normal. +// +// Hyperlinks in terminal emulators can contain an ID. This is important for +// ensuring that when long links are wrapped and broken apart they are still +// understood as a single hyperlink. In Lip Gloss, this ID is set +// automatically. +func (s Style) Hyperlink(url string) Style { if url == "" { return s } - params = append([]string{url}, params...) - s.set(hyperlinkKey, params) + + s.set(hyperlinkKey, []string{url, "id", uuid.GenerateUUID()}) return s } From ee87556e57d8d6ca6cd0d3543ff12f639e631e2a Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Mon, 3 Jun 2024 16:55:39 -0400 Subject: [PATCH 4/5] Break hyperlink parameters into separate methods --- get.go | 61 ++++++++++++++++++++++++++++++++++++++++------- go.mod | 1 - go.sum | 2 -- hyperlink_test.go | 45 ---------------------------------- set.go | 32 ++++++++++++++++--------- style.go | 6 +++-- 6 files changed, 77 insertions(+), 70 deletions(-) delete mode 100644 hyperlink_test.go diff --git a/get.go b/get.go index 70ddcade..aa41c001 100644 --- a/get.go +++ b/get.go @@ -414,10 +414,16 @@ func (s Style) GetTransform() func(string) string { return s.getAsTransform(transformKey) } -// GetHyperlink returns the hyperlink set on the style. If no hyperlink is set -// the empty string and nil is returned. +// GetHyperlink returns the hyperlink URL set on the style. If no hyperlink is +// set the empty string and nil is returned. func (s Style) GetHyperlink() string { - return s.getAsHyperlink(hyperlinkKey) + return s.getAsHyperlinkURL(hyperlinkURLKey) +} + +// GetHyperlink returns the hyperlink URL set on the style. If no hyperlink is +// set the empty string and nil is returned. +func (s Style) GetHyperlinkParams() map[string]string { + return s.getAsHyperlinkParams(hyperlinkParamsKey) } // Returns whether or not the given property is set. @@ -532,18 +538,20 @@ func (s Style) getAsTransform(k propKey) func(string) string { return s.transform } -// getAsHyperlink returns the hyperlink URL set on the style. No other -// parameters will be returned. -func (s Style) getAsHyperlink(k propKey) string { +func (s Style) getAsHyperlinkURL(k propKey) string { if !s.isSet(k) { return "" } - if len(s.hyperlink) == 0 { - return "" + return s.hyperlinkURL +} + +func (s Style) getAsHyperlinkParams(k propKey) map[string]string { + if !s.isSet(k) { + return nil } - return s.hyperlink[0] + return decodeHyperlinkParams(s.hyperlinkParams) } // Split a string into lines, additionally returning the size of the widest @@ -560,3 +568,38 @@ func getLines(s string) (lines []string, widest int) { return lines, widest } + +func encodeHyperlinkParams(m map[string]string) string { + if len(m) == 0 { + return "" + } + + var parts []string + for k, v := range m { + parts = append(parts, k+"="+v) + } + + return strings.Join(parts, ";") +} + +func decodeHyperlinkParams(s string) map[string]string { + if len(s) == 0 { + return nil + } + + parts := strings.Split(s, ";") + if len(parts) == 0 { + return nil + } + + m := make(map[string]string) + for _, p := range parts { + kv := strings.Split(p, "=") + if len(kv) != 2 { + continue + } + m[kv[0]] = kv[1] + } + + return m +} diff --git a/go.mod b/go.mod index 1afaee77..bf419526 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,6 @@ go 1.18 require ( github.com/aymanbagabas/go-udiff v0.2.0 github.com/charmbracelet/x/ansi v0.1.1 - github.com/hashicorp/uuid v0.0.0-20230117211644-212010c9d616 github.com/muesli/termenv v0.15.2 github.com/rivo/uniseg v0.4.7 ) diff --git a/go.sum b/go.sum index d313306a..183e44ce 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,6 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/charmbracelet/x/ansi v0.1.1 h1:CGAduulr6egay/YVbGc8Hsu8deMg1xZ/bkaXTPi1JDk= github.com/charmbracelet/x/ansi v0.1.1/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= -github.com/hashicorp/uuid v0.0.0-20230117211644-212010c9d616 h1:XVHNaBfj9Dp+skr2rRHK3GdgF0m/tsCc/ClnBKrlN0k= -github.com/hashicorp/uuid v0.0.0-20230117211644-212010c9d616/go.mod h1:fHzc09UnyJyqyW+bFuq864eh+wC7dj65aXmXLRe5to0= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/hyperlink_test.go b/hyperlink_test.go deleted file mode 100644 index f7f04250..00000000 --- a/hyperlink_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package lipgloss - -import "testing" - -func TestHyperlinkGetter(t *testing.T) { - for i, tt := range []struct { - style Style - url string - }{ - { - style: NewStyle(), - url: "https://charm.sh", - }, - { - style: NewStyle().Bold(true), - url: "https://charm.sh/blog/", - }, - } { - // Check that URL is set correctly. - s := tt.style.Hyperlink(tt.url) - url := s.GetHyperlink() - if url != tt.url { - t.Errorf("Test %d: expected URL %q, got %q", i, tt.url, url) - } - - if len(s.hyperlink) < 3 { - t.Errorf("Test %d: hyperlink parameters missing (we're looking for an ID)", i) - } - - // slice to map - params := make(map[string]string) - for n := 1; n < len(s.hyperlink); n += 2 { - params[s.hyperlink[n]] = s.hyperlink[n+1] - } - - // Check that ID is set. - id, ok := params["id"] - if !ok { - t.Errorf("Test %d: ID key missing in hyperlink data", i) - } - if id == "" { - t.Errorf("Test %d: value missing in hyperlink data", i) - } - } -} diff --git a/set.go b/set.go index 6e36bbc6..ada43e26 100644 --- a/set.go +++ b/set.go @@ -1,7 +1,5 @@ package lipgloss -import "github.com/hashicorp/uuid" - // Set a value on the underlying rules map. func (s *Style) set(key propKey, value interface{}) { // We don't allow negative integers on any of our other values, so just keep @@ -67,8 +65,10 @@ func (s *Style) set(key propKey, value interface{}) { s.tabWidth = value.(int) case transformKey: s.transform = value.(func(string) string) - case hyperlinkKey: - s.hyperlink = value.([]string) + case hyperlinkURLKey: + s.hyperlinkURL = value.(string) + case hyperlinkParamsKey: + s.hyperlinkParams = value.(string) default: if v, ok := value.(bool); ok { //nolint:nestif if v { @@ -149,8 +149,10 @@ func (s *Style) setFrom(key propKey, i Style) { s.set(tabWidthKey, i.tabWidth) case transformKey: s.set(transformKey, i.transform) - case hyperlinkKey: - s.set(hyperlinkKey, i.hyperlink) + case hyperlinkURLKey: + s.set(hyperlinkURLKey, i.hyperlinkURL) + case hyperlinkParamsKey: + s.set(hyperlinkParamsKey, i.hyperlinkParams) default: // Set attributes for set bool properties s.set(key, i.attrs) @@ -699,16 +701,24 @@ func (s Style) Transform(fn func(string) string) Style { // a no way to detect if hyperlinks are supported. If hyperlinks are not // the text will still render as normal. // -// Hyperlinks in terminal emulators can contain an ID. This is important for -// ensuring that when long links are wrapped and broken apart they are still -// understood as a single hyperlink. In Lip Gloss, this ID is set -// automatically. +// Hyperlinks in terminal emulators can optionally contain parameters. To set +// those see [Style.hyperlinkParams]. func (s Style) Hyperlink(url string) Style { if url == "" { return s } - s.set(hyperlinkKey, []string{url, "id", uuid.GenerateUUID()}) + s.set(hyperlinkURLKey, url) + return s +} + +// HyperlinkParams sets parameters for a hyperlink, such as an ID. If no +// URL is set via [Style.Hyperlink], hyperlink parameters will be ignored. +func (s Style) HyperlinkParams(p map[string]string) Style { + if p == nil { + return s + } + s.set(hyperlinkParamsKey, encodeHyperlinkParams(p)) return s } diff --git a/style.go b/style.go index df430159..669f8791 100644 --- a/style.go +++ b/style.go @@ -76,7 +76,8 @@ const ( transformKey - hyperlinkKey + hyperlinkURLKey + hyperlinkParamsKey ) // props is a set of properties. @@ -160,7 +161,8 @@ type Style struct { transform func(string) string - hyperlink []string + hyperlinkURL string + hyperlinkParams string } // joinString joins a list of strings into a single string separated with a From 70a044bf3baa14433b94c98bb399f710496e0715 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Mon, 3 Jun 2024 17:24:32 -0400 Subject: [PATCH 5/5] Render hyperlinks --- style.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/style.go b/style.go index 669f8791..d9c44475 100644 --- a/style.go +++ b/style.go @@ -289,12 +289,23 @@ func (s Style) Render(strs ...string) string { useSpaceStyler = (underline && !underlineSpaces) || (strikethrough && !strikethroughSpaces) || underlineSpaces || strikethroughSpaces transform = s.getAsTransform(transformKey) + + hyperlinkURL = s.getAsHyperlinkURL(hyperlinkURLKey) + hyperlinkParams = s.getAsHyperlinkParams(hyperlinkParamsKey) ) if transform != nil { str = transform(str) } + if hyperlinkURL != "" { + var params []string + for k, v := range hyperlinkParams { + params = append(params, k, v) + } + str = ansi.SetHyperlink(hyperlinkURL, params...) + str + } + if s.props == 0 { return s.maybeConvertTabs(str) } @@ -471,6 +482,10 @@ func (s Style) Render(strs ...string) string { } } + if hyperlinkURL != "" { + str += ansi.ResetHyperlink() + } + return str }