Skip to content

Commit 5bb9ee6

Browse files
committed
tui/messages: Add URL click detection for terminals with mouse tracking
When mouse cell motion tracking is enabled (tea.MouseModeCellMotion), terminals like Kitty cannot detect and open URLs natively because all mouse events are captured by the application. Add URL detection on single click so that clicking on a URL in rendered messages opens it in the browser via OpenURLMsg. Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
1 parent 429fed8 commit 5bb9ee6

File tree

3 files changed

+304
-0
lines changed

3 files changed

+304
-0
lines changed

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)