Skip to content

Commit 8a128f5

Browse files
authored
Merge pull request #2308 from vvoland/fix-url-click
tui/messages: Add URL click detection for terminals with mouse tracking
2 parents c47377b + 7a30d0d commit 8a128f5

3 files changed

Lines changed: 304 additions & 0 deletions

File tree

pkg/tui/components/messages/messages.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,16 @@ func (m *model) handleMouseRelease(msg tea.MouseReleaseMsg) (layout.Model, tea.C
396396
if m.selection.active {
397397
line, col := m.mouseToLineCol(msg.X, msg.Y)
398398
m.selection.update(line, col)
399+
400+
// If the mouse didn't move, this was a plain click — open URL if any
401+
if line == m.selection.startLine && col == m.selection.startCol {
402+
m.selection.clear()
403+
if url := m.urlAt(line, col); url != "" {
404+
return m, core.CmdHandler(messages.OpenURLMsg{URL: url})
405+
}
406+
return m, nil
407+
}
408+
399409
m.selection.end()
400410
cmd := m.copySelectionToClipboard()
401411
return m, cmd
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package messages
2+
3+
import (
4+
"strings"
5+
6+
"github.com/charmbracelet/x/ansi"
7+
"github.com/mattn/go-runewidth"
8+
)
9+
10+
// urlAtPosition extracts a URL from the rendered line at the given display column.
11+
// Returns the URL string if found, or empty string if the click position is not on a URL.
12+
func urlAtPosition(renderedLine string, col int) string {
13+
plainLine := ansi.Strip(renderedLine)
14+
if plainLine == "" {
15+
return ""
16+
}
17+
18+
// Find all URL spans in the plain text
19+
for _, span := range findURLSpans(plainLine) {
20+
if col >= span.startCol && col < span.endCol {
21+
return span.url
22+
}
23+
}
24+
return ""
25+
}
26+
27+
type urlSpan struct {
28+
url string
29+
startCol int // display column where URL starts
30+
endCol int // display column where URL ends (exclusive)
31+
}
32+
33+
// findURLSpans finds all URLs in plain text and returns their display column ranges.
34+
func findURLSpans(text string) []urlSpan {
35+
var spans []urlSpan
36+
runes := []rune(text)
37+
n := len(runes)
38+
39+
for i := 0; i < n; {
40+
// Look for http:// or https://
41+
remaining := string(runes[i:])
42+
var prefixLen int
43+
switch {
44+
case strings.HasPrefix(remaining, "https://"):
45+
prefixLen = len("https://")
46+
case strings.HasPrefix(remaining, "http://"):
47+
prefixLen = len("http://")
48+
default:
49+
i++
50+
continue
51+
}
52+
53+
// Must not be preceded by a word character (avoid matching mid-word)
54+
if i > 0 && isURLWordChar(runes[i-1]) {
55+
i++
56+
continue
57+
}
58+
59+
urlStart := i
60+
j := i + prefixLen
61+
// Extend to cover the URL body
62+
for j < n && isURLChar(runes[j]) {
63+
j++
64+
}
65+
// Strip common trailing punctuation that's unlikely part of the URL
66+
for j > urlStart+prefixLen && isTrailingPunct(runes[j-1]) {
67+
j--
68+
}
69+
// Balance parentheses: strip trailing ')' only if unmatched
70+
url := string(runes[urlStart:j])
71+
url = balanceParens(url)
72+
j = urlStart + len([]rune(url))
73+
74+
startCol := runeSliceWidth(runes[:urlStart])
75+
endCol := startCol + runeSliceWidth(runes[urlStart:j])
76+
77+
spans = append(spans, urlSpan{
78+
url: url,
79+
startCol: startCol,
80+
endCol: endCol,
81+
})
82+
i = j
83+
}
84+
return spans
85+
}
86+
87+
func runeSliceWidth(runes []rune) int {
88+
w := 0
89+
for _, r := range runes {
90+
w += runewidth.RuneWidth(r)
91+
}
92+
return w
93+
}
94+
95+
func isURLChar(r rune) bool {
96+
if r <= ' ' || r == '"' || r == '<' || r == '>' || r == '{' || r == '}' || r == '|' || r == '\\' || r == '^' || r == '`' {
97+
return false
98+
}
99+
return true
100+
}
101+
102+
func isURLWordChar(r rune) bool {
103+
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
104+
}
105+
106+
func isTrailingPunct(r rune) bool {
107+
return r == '.' || r == ',' || r == ';' || r == ':' || r == '!' || r == '?'
108+
}
109+
110+
// balanceParens strips a trailing ')' if there are more closing than opening parens.
111+
// This handles the common case of URLs wrapped in parentheses like (https://example.com).
112+
func balanceParens(url string) string {
113+
if !strings.HasSuffix(url, ")") {
114+
return url
115+
}
116+
open := strings.Count(url, "(")
117+
if strings.Count(url, ")") > open {
118+
return url[:len(url)-1]
119+
}
120+
return url
121+
}
122+
123+
// urlAt returns the URL at the given global line and display column, or empty string.
124+
func (m *model) urlAt(line, col int) string {
125+
m.ensureAllItemsRendered()
126+
if line < 0 || line >= len(m.renderedLines) {
127+
return ""
128+
}
129+
return urlAtPosition(m.renderedLines[line], col)
130+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package messages
2+
3+
import (
4+
"testing"
5+
6+
"gotest.tools/v3/assert"
7+
)
8+
9+
func TestFindURLSpans(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
text string
13+
wantURLs []string
14+
wantCols [][2]int // [startCol, endCol] pairs
15+
}{
16+
{
17+
name: "no URLs",
18+
text: "hello world",
19+
wantURLs: nil,
20+
},
21+
{
22+
name: "simple https URL",
23+
text: "visit https://example.com for more",
24+
wantURLs: []string{"https://example.com"},
25+
wantCols: [][2]int{{6, 25}},
26+
},
27+
{
28+
name: "http URL",
29+
text: "go to http://example.com/path",
30+
wantURLs: []string{"http://example.com/path"},
31+
wantCols: [][2]int{{6, 29}},
32+
},
33+
{
34+
name: "URL at start",
35+
text: "https://example.com is a site",
36+
wantURLs: []string{"https://example.com"},
37+
wantCols: [][2]int{{0, 19}},
38+
},
39+
{
40+
name: "URL at end",
41+
text: "visit https://example.com",
42+
wantURLs: []string{"https://example.com"},
43+
wantCols: [][2]int{{6, 25}},
44+
},
45+
{
46+
name: "URL with path and query",
47+
text: "see https://example.com/path?q=1&b=2#frag for details",
48+
wantURLs: []string{"https://example.com/path?q=1&b=2#frag"},
49+
wantCols: [][2]int{{4, 41}},
50+
},
51+
{
52+
name: "URL followed by period",
53+
text: "Visit https://example.com.",
54+
wantURLs: []string{"https://example.com"},
55+
wantCols: [][2]int{{6, 25}},
56+
},
57+
{
58+
name: "URL in parentheses",
59+
text: "(https://example.com)",
60+
wantURLs: []string{"https://example.com"},
61+
wantCols: [][2]int{{1, 20}},
62+
},
63+
{
64+
name: "URL with balanced parens in path",
65+
text: "see https://en.wikipedia.org/wiki/Go_(programming_language) for more",
66+
wantURLs: []string{"https://en.wikipedia.org/wiki/Go_(programming_language)"},
67+
wantCols: [][2]int{{4, 59}},
68+
},
69+
{
70+
name: "multiple URLs",
71+
text: "see https://a.com and https://b.com for info",
72+
wantURLs: []string{"https://a.com", "https://b.com"},
73+
wantCols: [][2]int{{4, 17}, {22, 35}},
74+
},
75+
}
76+
77+
for _, tt := range tests {
78+
t.Run(tt.name, func(t *testing.T) {
79+
got := findURLSpans(tt.text)
80+
assert.Equal(t, len(tt.wantURLs), len(got), "span count mismatch")
81+
for i, span := range got {
82+
assert.Equal(t, tt.wantURLs[i], span.url, "url mismatch at index %d", i)
83+
assert.Equal(t, tt.wantCols[i][0], span.startCol, "startCol mismatch at index %d", i)
84+
assert.Equal(t, tt.wantCols[i][1], span.endCol, "endCol mismatch at index %d", i)
85+
}
86+
})
87+
}
88+
}
89+
90+
func TestURLAtPosition(t *testing.T) {
91+
tests := []struct {
92+
name string
93+
line string
94+
col int
95+
expected string
96+
}{
97+
{
98+
name: "click on URL",
99+
line: "visit https://example.com for more",
100+
col: 10,
101+
expected: "https://example.com",
102+
},
103+
{
104+
name: "click before URL",
105+
line: "visit https://example.com for more",
106+
col: 3,
107+
expected: "",
108+
},
109+
{
110+
name: "click after URL",
111+
line: "visit https://example.com for more",
112+
col: 28,
113+
expected: "",
114+
},
115+
{
116+
name: "click on URL start",
117+
line: "visit https://example.com for more",
118+
col: 6,
119+
expected: "https://example.com",
120+
},
121+
{
122+
name: "click on URL last char",
123+
line: "visit https://example.com for more",
124+
col: 24,
125+
expected: "https://example.com",
126+
},
127+
{
128+
name: "line with ANSI codes",
129+
line: "visit \x1b[34mhttps://example.com\x1b[0m for more",
130+
col: 10,
131+
expected: "https://example.com",
132+
},
133+
{
134+
name: "empty line",
135+
line: "",
136+
col: 0,
137+
expected: "",
138+
},
139+
}
140+
141+
for _, tt := range tests {
142+
t.Run(tt.name, func(t *testing.T) {
143+
got := urlAtPosition(tt.line, tt.col)
144+
assert.Equal(t, tt.expected, got)
145+
})
146+
}
147+
}
148+
149+
func TestBalanceParens(t *testing.T) {
150+
tests := []struct {
151+
input string
152+
expected string
153+
}{
154+
{"https://example.com)", "https://example.com"},
155+
{"https://example.com/wiki/Go_(lang)", "https://example.com/wiki/Go_(lang)"},
156+
{"https://example.com", "https://example.com"},
157+
}
158+
159+
for _, tt := range tests {
160+
t.Run(tt.input, func(t *testing.T) {
161+
assert.Equal(t, tt.expected, balanceParens(tt.input))
162+
})
163+
}
164+
}

0 commit comments

Comments
 (0)