Skip to content

Commit c53f9d6

Browse files
dweymouthclaudeCopilot
authored
richtext: underline all wrapped hyperlink segments on hover (#6227)
* richtext: underline all wrapped hyperlink segments on hover When a HyperlinkSegment wraps across multiple rows, each row gets its own Hyperlink widget instance. Hovering one now propagates the hovered state to all sibling instances so the underline appears across the full link, not just the segment under the cursor. Siblings are linked inline during textRenderer.Refresh using the existing visualCache: reuse equals the current visual's cache offset and the count of prior row visuals, so earlier instances are fetched at offsets 0..reuse-1 and wired into an all-to-all sibling list with no extra allocations per refresh. Fixes #6226 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * update test mouse position Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * simplify comment * move to separate func * Update richtext.go --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent d3046f6 commit c53f9d6

3 files changed

Lines changed: 68 additions & 3 deletions

File tree

widget/hyperlink.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ type Hyperlink struct {
4444
textSize fyne.Size // updated in syncSegments
4545
focused, hovered bool
4646
provider RichText
47+
48+
siblings []*Hyperlink // other visual instances of the same HyperlinkSegment when wrapped in RichText
4749
}
4850

4951
// NewHyperlink creates a new hyperlink widget with the set text content
@@ -111,6 +113,9 @@ func (hl *Hyperlink) MouseMoved(e *desktop.MouseEvent) {
111113
hl.hovered = hl.isPosOverText(e.Position)
112114
if hl.hovered != oldHovered {
113115
hl.BaseWidget.Refresh()
116+
for _, s := range hl.siblings {
117+
s.setHovered(hl.hovered)
118+
}
114119
}
115120
}
116121

@@ -120,7 +125,19 @@ func (hl *Hyperlink) MouseOut() {
120125
hl.hovered = false
121126
if changed {
122127
hl.BaseWidget.Refresh()
128+
for _, s := range hl.siblings {
129+
s.setHovered(false)
130+
}
131+
}
132+
}
133+
134+
// setHovered updates the hovered state without propagating to siblings, to avoid recursion.
135+
func (hl *Hyperlink) setHovered(hovered bool) {
136+
if hl.hovered == hovered {
137+
return
123138
}
139+
hl.hovered = hovered
140+
hl.BaseWidget.Refresh()
124141
}
125142

126143
func (hl *Hyperlink) focusWidth() float32 {

widget/richtext.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -700,7 +700,7 @@ func (r *textRenderer) Refresh() {
700700
for _, bound := range bounds {
701701
for i, seg := range bound.segments {
702702
_, isText := seg.(*TextSegment)
703-
_, isHyperlink := seg.(*HyperlinkSegment)
703+
hlSeg, isHyperlink := seg.(*HyperlinkSegment)
704704
if !isText && !isHyperlink {
705705
obj := r.obj.cachedSegmentVisual(seg, 0)
706706
seg.Update(obj)
@@ -739,8 +739,10 @@ func (r *textRenderer) Refresh() {
739739
if isText {
740740
obj.(*canvas.Text).Text = txt
741741
} else if isHyperlink {
742-
obj.(*fyne.Container).Objects[0].(*Hyperlink).Text = txt
743-
obj.(*fyne.Container).Objects[0].(*Hyperlink).Refresh()
742+
hl := obj.(*fyne.Container).Objects[0].(*Hyperlink)
743+
hl.Text = txt
744+
r.associateSiblings(hl, hlSeg, reuse)
745+
hl.Refresh()
744746
}
745747
objs = append(objs, obj)
746748
}
@@ -763,6 +765,15 @@ func (r *textRenderer) Refresh() {
763765
r.obj.cleanVisualCache()
764766
}
765767

768+
func (r *textRenderer) associateSiblings(hl *Hyperlink, hlSeg *HyperlinkSegment, reuse int) {
769+
hl.siblings = hl.siblings[:0]
770+
for prev := 0; prev < reuse; prev++ {
771+
prevHL := r.obj.cachedSegmentVisual(hlSeg, prev).(*fyne.Container).Objects[0].(*Hyperlink)
772+
prevHL.siblings = append(prevHL.siblings, hl)
773+
hl.siblings = append(hl.siblings, prevHL)
774+
}
775+
}
776+
766777
func (r *textRenderer) layoutRow(texts []fyne.CanvasObject, align fyne.TextAlign, xPos, yPos, lineWidth float32) (float32, float32) {
767778
initialX := xPos
768779
if len(texts) == 1 {

widget/richtext_objects_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
package widget
22

33
import (
4+
"net/url"
45
"strings"
56
"testing"
67

78
"github.com/stretchr/testify/assert"
89

910
"fyne.io/fyne/v2"
1011
"fyne.io/fyne/v2/canvas"
12+
"fyne.io/fyne/v2/driver/desktop"
1113
"fyne.io/fyne/v2/storage"
1214
"fyne.io/fyne/v2/test"
1315
)
@@ -71,6 +73,41 @@ func TestRichText_OrderedList(t *testing.T) {
7173
assert.Equal(t, "Two", texts[3].(*canvas.Text).Text)
7274
}
7375

76+
func TestRichText_HyperLink_WrappedHoverSynced(t *testing.T) {
77+
u, _ := url.Parse("https://fyne.io")
78+
seg := &HyperlinkSegment{Text: "this is a long hyperlink that wraps", URL: u}
79+
rt := NewRichText(seg)
80+
rt.Wrapping = fyne.TextWrapWord
81+
// Render narrow enough to force wrapping into at least 2 rows.
82+
rt.Resize(fyne.NewSize(100, 200))
83+
84+
objs := test.TempWidgetRenderer(t, rt).Objects()
85+
// Collect all Hyperlink visuals for the segment (one per wrapped row).
86+
var links []*Hyperlink
87+
for _, obj := range objs {
88+
if c, ok := obj.(*fyne.Container); ok {
89+
if hl, ok := c.Objects[0].(*Hyperlink); ok {
90+
links = append(links, hl)
91+
}
92+
}
93+
}
94+
assert.GreaterOrEqual(t, len(links), 2, "expected hyperlink to wrap into multiple segments")
95+
96+
// MouseIn on the first segment — choose a position inside the hyperlink's bounds.
97+
center := fyne.NewPos(links[0].Size().Width/2, links[0].Size().Height/2)
98+
inside := &desktop.MouseEvent{PointEvent: fyne.PointEvent{Position: center}}
99+
links[0].MouseIn(inside)
100+
for i, hl := range links {
101+
assert.True(t, hl.hovered, "expected link[%d] to be hovered after MouseIn on link[0]", i)
102+
}
103+
104+
// MouseOut on the first segment — all siblings should be unhovered too.
105+
links[0].MouseOut()
106+
for i, hl := range links {
107+
assert.False(t, hl.hovered, "expected link[%d] to be unhovered after MouseOut on link[0]", i)
108+
}
109+
}
110+
74111
func TestRichText_OrderedListDifferentIndex(t *testing.T) {
75112
for name, tt := range map[string]struct {
76113
index int

0 commit comments

Comments
 (0)