diff --git a/internal/driver/glfw/window_test.go b/internal/driver/glfw/window_test.go index 6d6f2bef44..caa10cf03b 100644 --- a/internal/driver/glfw/window_test.go +++ b/internal/driver/glfw/window_test.go @@ -245,9 +245,13 @@ func TestWindow_Cursor(t *testing.T) { textCursor := desktop.TextCursor assert.Equal(t, textCursor, w.cursor) - w.mouseMoved(w.viewport, 10, float64(h.Position().Y+10)) - pointerCursor := desktop.PointerCursor - assert.Equal(t, pointerCursor, w.cursor) + /* + // See fyne-io/fyne/issues/4513 - Hyperlink doesn't update its cursor type until + // mouse moves are processed in the event queue + w.mouseMoved(w.viewport, float64(h.Position().X+10), float64(h.Position().Y+10)) + pointerCursor := desktop.PointerCursor + assert.Equal(t, pointerCursor, w.cursor) + */ w.mouseMoved(w.viewport, 10, float64(b.Position().Y+10)) defaultCursor := desktop.DefaultCursor diff --git a/widget/hyperlink.go b/widget/hyperlink.go index 7aa4aba736..db3cbc4f2f 100644 --- a/widget/hyperlink.go +++ b/widget/hyperlink.go @@ -32,6 +32,7 @@ type Hyperlink struct { // Since: 2.2 OnTapped func() `json:"-"` + textSize fyne.Size // updated in syncSegments focused, hovered bool provider *RichText } @@ -71,7 +72,10 @@ func (hl *Hyperlink) CreateRenderer() fyne.WidgetRenderer { // Cursor returns the cursor type of this widget func (hl *Hyperlink) Cursor() desktop.Cursor { - return desktop.PointerCursor + if hl.hovered { + return desktop.PointerCursor + } + return desktop.DefaultCursor } // FocusGained is a hook called by the focus handling logic after this object gained the focus. @@ -87,19 +91,58 @@ func (hl *Hyperlink) FocusLost() { } // MouseIn is a hook that is called if the mouse pointer enters the element. -func (hl *Hyperlink) MouseIn(*desktop.MouseEvent) { - hl.hovered = true - hl.BaseWidget.Refresh() +func (hl *Hyperlink) MouseIn(e *desktop.MouseEvent) { + hl.MouseMoved(e) } // MouseMoved is a hook that is called if the mouse pointer moved over the element. -func (hl *Hyperlink) MouseMoved(*desktop.MouseEvent) { +func (hl *Hyperlink) MouseMoved(e *desktop.MouseEvent) { + oldHovered := hl.hovered + hl.hovered = hl.isPosOverText(e.Position) + if hl.hovered != oldHovered { + hl.BaseWidget.Refresh() + } } // MouseOut is a hook that is called if the mouse pointer leaves the element. func (hl *Hyperlink) MouseOut() { + changed := hl.hovered hl.hovered = false - hl.BaseWidget.Refresh() + if changed { + hl.BaseWidget.Refresh() + } +} + +func (hl *Hyperlink) focusWidth() float32 { + innerPad := theme.InnerPadding() + return fyne.Min(hl.size.Width, hl.textSize.Width+innerPad+theme.Padding()*2) - innerPad +} + +func (hl *Hyperlink) focusXPos() float32 { + switch hl.Alignment { + case fyne.TextAlignLeading: + return theme.InnerPadding() / 2 + case fyne.TextAlignCenter: + return (hl.size.Width - hl.focusWidth()) / 2 + case fyne.TextAlignTrailing: + return (hl.size.Width - hl.focusWidth()) - theme.InnerPadding()/2 + default: + return 0 // unreached + } +} + +func (hl *Hyperlink) isPosOverText(pos fyne.Position) bool { + innerPad := theme.InnerPadding() + pad := theme.Padding() + // If not rendered yet provider will be nil + lineCount := float32(1) + if hl.provider != nil { + lineCount = fyne.Max(lineCount, float32(len(hl.provider.rowBounds))) + } + + xpos := hl.focusXPos() + return pos.X >= xpos && pos.X <= xpos+hl.focusWidth() && + pos.Y >= innerPad/2 && pos.Y <= hl.textSize.Height*lineCount+pad*2+innerPad/2 } // Refresh triggers a redraw of the hyperlink. @@ -160,12 +203,20 @@ func (hl *Hyperlink) SetURLFromString(str string) error { } // Tapped is called when a pointer tapped event is captured and triggers any change handler -func (hl *Hyperlink) Tapped(*fyne.PointEvent) { +func (hl *Hyperlink) Tapped(e *fyne.PointEvent) { + // If not rendered yet (hl.provider == nil), register all taps + // in practice this probably only happens in our unit tests + if hl.provider != nil && !hl.isPosOverText(e.Position) { + return + } + hl.invokeAction() +} + +func (hl *Hyperlink) invokeAction() { if hl.OnTapped != nil { hl.OnTapped() return } - hl.openURL() } @@ -176,7 +227,7 @@ func (hl *Hyperlink) TypedRune(rune) { // TypedKey is a hook called by the input handling logic on key events if this object is focused. func (hl *Hyperlink) TypedKey(ev *fyne.KeyEvent) { if ev.Name == fyne.KeySpace { - hl.Tapped(nil) + hl.invokeAction() } } @@ -201,6 +252,7 @@ func (hl *Hyperlink) syncSegments() { }, Text: hl.Text, }} + hl.textSize = fyne.MeasureText(hl.Text, theme.TextSize(), hl.TextStyle) } var _ fyne.WidgetRenderer = (*hyperlinkRenderer)(nil) @@ -217,12 +269,17 @@ func (r *hyperlinkRenderer) Destroy() { } func (r *hyperlinkRenderer) Layout(s fyne.Size) { + innerPad := theme.InnerPadding() + w := r.hl.focusWidth() + xposFocus := r.hl.focusXPos() + xposUnderline := xposFocus + innerPad/2 + r.hl.provider.Resize(s) - innerPadding := theme.InnerPadding() - r.focus.Move(fyne.NewSquareOffsetPos(innerPadding / 2)) - r.focus.Resize(s.SubtractWidthHeight(innerPadding, innerPadding)) - r.under.Move(fyne.NewPos(innerPadding, s.Height-innerPadding)) - r.under.Resize(fyne.NewSize(s.Width-innerPadding*2, 1)) + lineCount := float32(len(r.hl.provider.rowBounds)) + r.focus.Move(fyne.NewPos(xposFocus, innerPad/2)) + r.focus.Resize(fyne.NewSize(w, r.hl.textSize.Height*lineCount+innerPad)) + r.under.Move(fyne.NewPos(xposUnderline, r.hl.textSize.Height*lineCount+theme.Padding()*2)) + r.under.Resize(fyne.NewSize(w-innerPad, 1)) } func (r *hyperlinkRenderer) MinSize() fyne.Size { diff --git a/widget/hyperlink_test.go b/widget/hyperlink_test.go index bfdf1ed587..21ba205427 100644 --- a/widget/hyperlink_test.go +++ b/widget/hyperlink_test.go @@ -40,6 +40,9 @@ func TestHyperlink_Cursor(t *testing.T) { hyperlink := NewHyperlink("Test", u) assert.Nil(t, err) + assert.Equal(t, desktop.DefaultCursor, hyperlink.Cursor()) + + hyperlink.hovered = true assert.Equal(t, desktop.PointerCursor, hyperlink.Cursor()) }