diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0c2231c0ed..99b420c363 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,27 @@
This file lists the main changes with each version of the Fyne toolkit.
More detailed release notes can be found on the [releases page](https://github.com/fyne-io/fyne/releases).
+## 2.4.4 - 13 February 2024
+
+### Fixed
+
+* Spaces could be appended to linux Exec command during packaging
+* Secondary mobile windows would not size correctly when padded
+* Setting Icon.Resource to nil will not clear rendering
+* Dismiss iOS keyboard if "Done" is tapped
+* Large speed improvement in Entry and GridWrap widgets
+* tests fail with macOS Assertion failure in NSMenu (#4572)
+* Fix image test failures on Apple Silicon
+* High CPU use when showing CustomDialogs (#4574)
+* Entry does not show the last (few) changes when updating a binding.String in a fast succession (#4082)
+* Calling Entry.SetText and then Entry.Bind immediately will ignore the bound value (#4235)
+* Changing theme while application is running doesn't change some parameters on some widgets (#4344)
+* Check widget: hovering/tapping to the right of the label area should not activate widget (#4527)
+* Calling entry.SetPlaceHolder inside of OnChanged callback freezes app (#4516)
+* Hyperlink enhancement: underline and tappable area shouldn't be wider than the text label (#3528)
+* Fix possible compile error from go-text/typesetting
+
+
## 2.4.3 - 23 December 2023
### Fixed
diff --git a/cmd/fyne/internal/templates/bundled.go b/cmd/fyne/internal/templates/bundled.go
index 97e86e5cb1..71dff47380 100644
--- a/cmd/fyne/internal/templates/bundled.go
+++ b/cmd/fyne/internal/templates/bundled.go
@@ -23,7 +23,7 @@ var resourceMakefile = &fyne.StaticResource{
var resourceAppDesktop = &fyne.StaticResource{
StaticName: "app.desktop",
StaticContent: []byte(
- "[Desktop Entry]\nType=Application\nName={{.Name}}\n{{- if ne .GenericName \"\"}}\nGenericName={{.GenericName}}{{end}}\nExec={{.Exec}} {{.ExecParams}}\nIcon={{.Name}}\n{{- if ne .Comment \"\"}}\nComment={{.Comment}}{{end}}\n{{- if ne .Categories \"\"}}\nCategories={{.Categories}}{{end}}\n{{if ne .Keywords \"\"}}Keywords={{.Keywords}}{{else}}Keywords=fyne;{{end}}"),
+ "[Desktop Entry]\nType=Application\nName={{.Name}}\n{{- if ne .GenericName \"\"}}\nGenericName={{.GenericName}}{{end}}\nExec={{.Exec}} {{- .ExecParams}}\nIcon={{.Name}}\n{{- if ne .Comment \"\"}}\nComment={{.Comment}}{{end}}\n{{- if ne .Categories \"\"}}\nCategories={{.Categories}}{{end}}\n{{if ne .Keywords \"\"}}Keywords={{.Keywords}}{{else}}Keywords=fyne;{{end}}"),
}
var resourceAppManifest = &fyne.StaticResource{
StaticName: "app.manifest",
diff --git a/cmd/fyne/internal/templates/data/app.desktop b/cmd/fyne/internal/templates/data/app.desktop
index a74aea5aa5..a8ae72d8a7 100644
--- a/cmd/fyne/internal/templates/data/app.desktop
+++ b/cmd/fyne/internal/templates/data/app.desktop
@@ -3,7 +3,7 @@ Type=Application
Name={{.Name}}
{{- if ne .GenericName ""}}
GenericName={{.GenericName}}{{end}}
-Exec={{.Exec}} {{.ExecParams}}
+Exec={{.Exec}} {{- .ExecParams}}
Icon={{.Name}}
{{- if ne .Comment ""}}
Comment={{.Comment}}{{end}}
diff --git a/container.go b/container.go
index 60a988ec95..4959211c61 100644
--- a/container.go
+++ b/container.go
@@ -129,12 +129,12 @@ func (c *Container) Refresh() {
// This method is not intended to be used inside a loop, to remove all the elements.
// It is much more efficient to call RemoveAll() instead.
func (c *Container) Remove(rem CanvasObject) {
+ c.lock.Lock()
+ defer c.lock.Unlock()
if len(c.Objects) == 0 {
return
}
- c.lock.Lock()
- defer c.lock.Unlock()
for i, o := range c.Objects {
if o != rem {
continue
diff --git a/container/apptabs.go b/container/apptabs.go
index b02300e59a..f7b7c8bf25 100644
--- a/container/apptabs.go
+++ b/container/apptabs.go
@@ -57,6 +57,7 @@ func (t *AppTabs) CreateRenderer() fyne.WidgetRenderer {
appTabs: t,
}
r.action = r.buildOverflowTabsButton()
+ r.tabs = t
// Initially setup the tab bar to only show one tab, all others will be in overflow.
// When the widget is laid out, and we know the size, the tab bar will be updated to show as many as can fit.
diff --git a/container/doctabs.go b/container/doctabs.go
index cb276f6b91..46e6d376e7 100644
--- a/container/doctabs.go
+++ b/container/doctabs.go
@@ -67,6 +67,8 @@ func (t *DocTabs) CreateRenderer() fyne.WidgetRenderer {
}
r.action = r.buildAllTabsButton()
r.create = r.buildCreateTabsButton()
+ r.tabs = t
+
r.box = NewHBox(r.create, r.action)
r.scroller.OnScrolled = func(offset fyne.Position) {
r.updateIndicator(false)
diff --git a/container/tabs.go b/container/tabs.go
index ac3d65c3dd..97611c7605 100644
--- a/container/tabs.go
+++ b/container/tabs.go
@@ -292,6 +292,8 @@ type baseTabsRenderer struct {
action *widget.Button
bar *fyne.Container
divider, indicator *canvas.Rectangle
+
+ tabs baseTabs
}
func (r *baseTabsRenderer) Destroy() {
@@ -304,6 +306,10 @@ func (r *baseTabsRenderer) applyTheme(t baseTabs) {
r.divider.FillColor = theme.ShadowColor()
r.indicator.FillColor = theme.PrimaryColor()
r.indicator.CornerRadius = theme.SelectionRadiusSize()
+
+ for _, tab := range r.tabs.items() {
+ tab.Content.Refresh()
+ }
}
func (r *baseTabsRenderer) layout(t baseTabs, size fyne.Size) {
diff --git a/container/tabs_test.go b/container/tabs_test.go
index 7149cb8f00..c5a224d715 100644
--- a/container/tabs_test.go
+++ b/container/tabs_test.go
@@ -3,10 +3,14 @@ package container
import (
"testing"
+ "github.com/stretchr/testify/assert"
+
+ "fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/internal/cache"
+ "fyne.io/fyne/v2/test"
"fyne.io/fyne/v2/theme"
- "github.com/stretchr/testify/assert"
+ "fyne.io/fyne/v2/widget"
)
func TestTabButton_Icon_Change(t *testing.T) {
@@ -19,3 +23,26 @@ func TestTabButton_Icon_Change(t *testing.T) {
b.Refresh()
assert.NotEqual(t, oldResource, icon.Resource)
}
+
+func TestTab_ThemeChange(t *testing.T) {
+ a := test.NewApp()
+ defer test.NewApp()
+ a.Settings().SetTheme(theme.LightTheme())
+
+ tabs := NewAppTabs(
+ NewTabItem("a", widget.NewLabel("a")),
+ NewTabItem("b", widget.NewLabel("b")))
+ w := test.NewWindow(tabs)
+ w.Resize(fyne.NewSize(180, 120))
+
+ initial := w.Canvas().Capture()
+
+ a.Settings().SetTheme(theme.DarkTheme())
+ tabs.SelectIndex(1)
+ second := w.Canvas().Capture()
+ assert.NotEqual(t, initial, second)
+
+ a.Settings().SetTheme(theme.LightTheme())
+ tabs.SelectIndex(0)
+ assert.Equal(t, initial, w.Canvas().Capture())
+}
diff --git a/dialog/base.go b/dialog/base.go
index 3594a586e8..4fe8990ac0 100644
--- a/dialog/base.go
+++ b/dialog/base.go
@@ -8,6 +8,7 @@ import (
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
col "fyne.io/fyne/v2/internal/color"
+ "fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
@@ -108,8 +109,15 @@ func (d *dialog) hideWithResponse(resp bool) {
func (d *dialog) create(buttons fyne.CanvasObject) {
label := widget.NewLabelWithStyle(d.title, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
+ var image fyne.CanvasObject
+ if d.icon != nil {
+ image = &canvas.Image{Resource: d.icon}
+ } else {
+ image = &layout.Spacer{}
+ }
+
content := container.New(&dialogLayout{d: d},
- &canvas.Image{Resource: d.icon},
+ image,
newThemedBackground(),
d.content,
buttons,
diff --git a/dialog/file.go b/dialog/file.go
index 5327d24e64..8a3c621a41 100644
--- a/dialog/file.go
+++ b/dialog/file.go
@@ -689,7 +689,7 @@ func (f *FileDialog) SetDismissText(label string) {
f.dialog.win.Refresh()
}
-// SetLocation tells this FileDirectory which location to display.
+// SetLocation tells this FileDialog which location to display.
// This is normally called before the dialog is shown.
//
// Since: 1.4
diff --git a/dialog/testdata/dialog-custom-custom-buttons.xml b/dialog/testdata/dialog-custom-custom-buttons.xml
index 54a9928653..4c2599e8ae 100644
--- a/dialog/testdata/dialog-custom-custom-buttons.xml
+++ b/dialog/testdata/dialog-custom-custom-buttons.xml
@@ -17,7 +17,7 @@
-
+
diff --git a/dialog/testdata/dialog-custom-no-buttons.xml b/dialog/testdata/dialog-custom-no-buttons.xml
index b2db91aa04..af999b38a9 100644
--- a/dialog/testdata/dialog-custom-no-buttons.xml
+++ b/dialog/testdata/dialog-custom-no-buttons.xml
@@ -17,7 +17,7 @@
-
+
diff --git a/go.mod b/go.mod
index 899268e30d..cf8a3ba8c5 100644
--- a/go.mod
+++ b/go.mod
@@ -15,7 +15,7 @@ require (
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20221017161538-93cebf72946b
github.com/go-ole/go-ole v1.2.6
github.com/go-text/render v0.0.0-20230619120952-35bccb6164b8
- github.com/go-text/typesetting v0.0.0-20230616162802-9c17dd34aa4a
+ github.com/go-text/typesetting v0.1.0
github.com/godbus/dbus/v5 v5.1.0
github.com/gopherjs/gopherjs v1.17.2
github.com/jackmordaunt/icns/v2 v2.2.6
diff --git a/go.sum b/go.sum
index 79b30237b6..172d93c3c0 100644
--- a/go.sum
+++ b/go.sum
@@ -105,10 +105,12 @@ github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-text/render v0.0.0-20230619120952-35bccb6164b8 h1:VkKnvzbvHqgEfm351rfr8Uclu5fnwq8HP2ximUzJsBM=
github.com/go-text/render v0.0.0-20230619120952-35bccb6164b8/go.mod h1:h29xCucjNsDcYb7+0rJokxVwYAq+9kQ19WiFuBKkYtc=
-github.com/go-text/typesetting v0.0.0-20230616162802-9c17dd34aa4a h1:VjN8ttdfklC0dnAdKbZqGNESdERUxtE3l8a/4Grgarc=
github.com/go-text/typesetting v0.0.0-20230616162802-9c17dd34aa4a/go.mod h1:evDBbvNR/KaVFZ2ZlDSOWWXIUKq0wCOEtzLxRM8SG3k=
-github.com/go-text/typesetting-utils v0.0.0-20230616150549-2a7df14b6a22 h1:LBQTFxP2MfsyEDqSKmUBZaDuDHN1vpqDyOZjcqS7MYI=
+github.com/go-text/typesetting v0.1.0 h1:vioSaLPYcHwPEPLT7gsjCGDCoYSbljxoHJzMnKwVvHw=
+github.com/go-text/typesetting v0.1.0/go.mod h1:d22AnmeKq/on0HNv73UFriMKc4Ez6EqZAofLhAzpSzI=
github.com/go-text/typesetting-utils v0.0.0-20230616150549-2a7df14b6a22/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
+github.com/go-text/typesetting-utils v0.0.0-20231211103740-d9332ae51f04 h1:zBx+p/W2aQYtNuyZNcTfinWvXBQwYtDfme051PR/lAY=
+github.com/go-text/typesetting-utils v0.0.0-20231211103740-d9332ae51f04/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
diff --git a/img/hello-dark.png b/img/hello-dark.png
index 13fc8e59c4..02f8589f8b 100644
Binary files a/img/hello-dark.png and b/img/hello-dark.png differ
diff --git a/img/hello-light.png b/img/hello-light.png
index 6e16e9ebae..58b46a821a 100644
Binary files a/img/hello-light.png and b/img/hello-light.png differ
diff --git a/img/widgets-dark.png b/img/widgets-dark.png
index 9b46de8fd5..4c4bb0223d 100644
Binary files a/img/widgets-dark.png and b/img/widgets-dark.png differ
diff --git a/img/widgets-light.png b/img/widgets-light.png
index ec725fc5b4..c9b59ffbce 100644
Binary files a/img/widgets-light.png and b/img/widgets-light.png differ
diff --git a/img/widgets-mobile-light.png b/img/widgets-mobile-light.png
index 157ea33ae4..e3af076464 100644
Binary files a/img/widgets-mobile-light.png and b/img/widgets-mobile-light.png differ
diff --git a/internal/driver/glfw/glfw_test.go b/internal/driver/glfw/glfw_test.go
index 656a3828e7..78bb91364f 100644
--- a/internal/driver/glfw/glfw_test.go
+++ b/internal/driver/glfw/glfw_test.go
@@ -24,7 +24,7 @@ func assertCanvasSize(t *testing.T, w *window, size fyne.Size) {
}
func ensureCanvasSize(t *testing.T, w *window, size fyne.Size) {
- if runtime.GOOS == "linux" {
+ if runtime.GOOS == "linux" || runtime.GOOS == "darwin" {
// TODO: find the root cause for these problems and solve them without additional repaint
// fixes issues where the window does not have the correct size
waitForCanvasSize(t, w, size, true)
diff --git a/internal/driver/glfw/menu_darwin_test.go b/internal/driver/glfw/menu_darwin_test.go
index 9ddc72012f..309ffa6be4 100644
--- a/internal/driver/glfw/menu_darwin_test.go
+++ b/internal/driver/glfw/menu_darwin_test.go
@@ -17,7 +17,9 @@ func TestDarwinMenu(t *testing.T) {
setExceptionCallback(func(msg string) { t.Error("Obj-C exception:", msg) })
defer setExceptionCallback(nil)
- resetMainMenu()
+ runOnMain(func() {
+ resetMainMenu()
+ })
w := createWindow("Test").(*window)
@@ -59,7 +61,9 @@ func TestDarwinMenu(t *testing.T) {
menuSettings := fyne.NewMenu("Settings", itemSettings, fyne.NewMenuItemSeparator(), itemMoreSetings)
mainMenu := fyne.NewMainMenu(menuEdit, menuHelp, menuMore, menuSettings)
- setupNativeMenu(w, mainMenu)
+ runOnMain(func() {
+ setupNativeMenu(w, mainMenu)
+ })
mm := testDarwinMainMenu()
// The custom “Preferences” menu should be moved to the system app menu completely.
@@ -247,13 +251,17 @@ func TestDarwinMenu_specialKeyShortcuts(t *testing.T) {
},
} {
t.Run(name, func(t *testing.T) {
- resetMainMenu()
+ runOnMain(func() {
+ resetMainMenu()
+ })
w := createWindow("Test").(*window)
item := fyne.NewMenuItem("Special", func() {})
item.Shortcut = &desktop.CustomShortcut{KeyName: tt.key, Modifier: fyne.KeyModifierShortcutDefault}
menu := fyne.NewMenu("Special", item)
mainMenu := fyne.NewMainMenu(menu)
- setupNativeMenu(w, mainMenu)
+ runOnMain(func() {
+ setupNativeMenu(w, mainMenu)
+ })
mm := testDarwinMainMenu()
m := testNSMenuItemSubmenu(testNSMenuItemAtIndex(mm, 1))
diff --git a/internal/driver/glfw/window_test.go b/internal/driver/glfw/window_test.go
index b7d156bbe0..b1dc32f511 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/internal/driver/mobile/app/darwin_ios.m b/internal/driver/mobile/app/darwin_ios.m
index 6cce1d07a3..62d13748b2 100644
--- a/internal/driver/mobile/app/darwin_ios.m
+++ b/internal/driver/mobile/app/darwin_ios.m
@@ -214,6 +214,19 @@ -(BOOL)textField:(UITextField *)textField shouldChangeCharactersInRange:(NSRange
return NO;
}
+- (BOOL)textFieldShouldReturn:(UITextField *)textField {
+ if ([self returnKeyType] != UIReturnKeyDone) {
+ keyboardTyped("\n");
+ return YES;
+ }
+
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [self resignFirstResponder];
+ });
+
+ return NO;
+}
+
@end
void runApp(void) {
diff --git a/internal/driver/mobile/canvas.go b/internal/driver/mobile/canvas.go
index ec2375ba3b..9ad5826d1b 100644
--- a/internal/driver/mobile/canvas.go
+++ b/internal/driver/mobile/canvas.go
@@ -196,7 +196,11 @@ func (c *mobileCanvas) sizeContent(size fyne.Size) {
if c.windowHead != nil {
topHeight := c.windowHead.MinSize().Height
- if len(c.windowHead.(*fyne.Container).Objects) > 1 {
+ chromeBox := c.windowHead.(*fyne.Container)
+ if c.padded {
+ chromeBox = chromeBox.Objects[0].(*fyne.Container) // the padded container
+ }
+ if len(chromeBox.Objects) > 1 {
c.windowHead.Resize(fyne.NewSize(areaSize.Width, topHeight))
offset = fyne.NewPos(0, topHeight)
areaSize = areaSize.Subtract(offset)
diff --git a/internal/painter/font.go b/internal/painter/font.go
index 937bad4a3b..9ce954b9c7 100644
--- a/internal/painter/font.go
+++ b/internal/painter/font.go
@@ -206,7 +206,7 @@ func walkString(faces []font.Face, s string, textSize fixed.Int26_6, tabWidth in
}
*advance = x
- return fyne.NewSize(*advance, fixed266ToFloat32(out.LineBounds.LineHeight())),
+ return fyne.NewSize(*advance, fixed266ToFloat32(out.LineBounds.LineThickness())),
fixed266ToFloat32(out.LineBounds.Ascent)
}
diff --git a/internal/test/util.go b/internal/test/util.go
index 0829fbddf8..81e9e2153d 100644
--- a/internal/test/util.go
+++ b/internal/test/util.go
@@ -8,6 +8,7 @@ import (
"math"
"os"
"path/filepath"
+ "runtime"
"testing"
"github.com/stretchr/testify/assert"
@@ -40,6 +41,13 @@ func AssertImageMatches(t *testing.T, masterFilename string, img image.Image, ms
masterPix := pixelsForImage(t, raw) // let's just compare the pixels directly
capturePix := pixelsForImage(t, img)
+ // On darwin/arm64, there are slight differences in the rendering.
+ // Use a slower, more lenient comparison. If that fails,
+ // fall back to the strict comparison for a more detailed error message.
+ if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" && pixCloseEnough(masterPix, capturePix) {
+ return true
+ }
+
var msg string
if len(msgAndArgs) > 0 {
msg = fmt.Sprintf(msgAndArgs[0].(string)+"\n", msgAndArgs[1:]...)
@@ -51,6 +59,33 @@ func AssertImageMatches(t *testing.T, masterFilename string, img image.Image, ms
return true
}
+// pixCloseEnough reports whether a and b are mostly the same.
+func pixCloseEnough(a, b []uint8) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ mismatches := 0
+
+ for i, v := range a {
+ w := b[i]
+ if v == w {
+ continue
+ }
+ // Allow a small delta for rendering variation.
+ delta := int(v) - int(w) // use int to avoid overflow
+ if delta < 0 {
+ delta *= -1
+ }
+ if delta > 4 {
+ return false
+ }
+ mismatches++
+ }
+
+ // Allow up to 1% of pixels to mismatch.
+ return mismatches < len(a)/100
+}
+
// NewCheckedImage returns a new black/white checked image with the specified size
// and the specified amount of horizontal and vertical tiles.
func NewCheckedImage(w, h, hTiles, vTiles int) image.Image {
@@ -83,7 +118,7 @@ func pixelsForImage(t *testing.T, img image.Image) []uint8 {
}
func writeImage(path string, img image.Image) error {
- if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
f, err := os.Create(path)
diff --git a/widget/check.go b/widget/check.go
index 19d7dbced4..ee50f80056 100644
--- a/widget/check.go
+++ b/widget/check.go
@@ -12,103 +12,6 @@ import (
"fyne.io/fyne/v2/theme"
)
-type checkRenderer struct {
- widget.BaseRenderer
- bg, icon *canvas.Image
- label *canvas.Text
- focusIndicator *canvas.Circle
- check *Check
-}
-
-// MinSize calculates the minimum size of a check.
-// This is based on the contained text, the check icon and a standard amount of padding added.
-func (c *checkRenderer) MinSize() fyne.Size {
- pad4 := theme.InnerPadding() * 2
- min := c.label.MinSize().Add(fyne.NewSize(theme.IconInlineSize()+pad4, pad4))
- if c.check.Text != "" {
- min.Add(fyne.NewSize(theme.Padding(), 0))
- }
-
- return min
-}
-
-// Layout the components of the check widget
-func (c *checkRenderer) Layout(size fyne.Size) {
- focusIndicatorSize := fyne.NewSquareSize(theme.IconInlineSize() + theme.InnerPadding())
- c.focusIndicator.Resize(focusIndicatorSize)
- c.focusIndicator.Move(fyne.NewPos(theme.InputBorderSize(), (size.Height-focusIndicatorSize.Height)/2))
-
- xOff := focusIndicatorSize.Width + theme.InputBorderSize()*2
- labelSize := size.SubtractWidthHeight(xOff, 0)
- c.label.Resize(labelSize)
- c.label.Move(fyne.NewPos(xOff, 0))
-
- iconPos := fyne.NewPos(theme.InnerPadding()/2+theme.InputBorderSize(), (size.Height-theme.IconInlineSize())/2)
- iconSize := fyne.NewSquareSize(theme.IconInlineSize())
- c.bg.Move(iconPos)
- c.bg.Resize(iconSize)
- c.icon.Resize(iconSize)
- c.icon.Move(iconPos)
-}
-
-// applyTheme updates this Check to the current theme
-func (c *checkRenderer) applyTheme() {
- c.label.Color = theme.ForegroundColor()
- c.label.TextSize = theme.TextSize()
- if c.check.disabled {
- c.label.Color = theme.DisabledColor()
- }
-}
-
-func (c *checkRenderer) Refresh() {
- c.check.propertyLock.RLock()
- c.applyTheme()
- c.updateLabel()
- c.updateResource()
- c.updateFocusIndicator()
- c.check.propertyLock.RUnlock()
- canvas.Refresh(c.check.super())
-}
-
-func (c *checkRenderer) updateLabel() {
- c.label.Text = c.check.Text
-}
-
-func (c *checkRenderer) updateResource() {
- res := theme.NewThemedResource(theme.CheckButtonIcon())
- res.ColorName = theme.ColorNameInputBorder
- // TODO move to `theme.CheckButtonFillIcon()` when we add it in 2.4
- bgRes := theme.NewThemedResource(fyne.CurrentApp().Settings().Theme().Icon("iconNameCheckButtonFill"))
- bgRes.ColorName = theme.ColorNameInputBackground
-
- if c.check.Checked {
- res = theme.NewThemedResource(theme.CheckButtonCheckedIcon())
- res.ColorName = theme.ColorNamePrimary
- bgRes.ColorName = theme.ColorNameBackground
- }
- if c.check.disabled {
- if c.check.Checked {
- res = theme.NewThemedResource(theme.CheckButtonCheckedIcon())
- }
- res.ColorName = theme.ColorNameDisabled
- bgRes.ColorName = theme.ColorNameBackground
- }
- c.icon.Resource = res
- c.bg.Resource = bgRes
-}
-
-func (c *checkRenderer) updateFocusIndicator() {
- if c.check.disabled {
- c.focusIndicator.FillColor = color.Transparent
- } else if c.check.focused {
- c.focusIndicator.FillColor = theme.FocusColor()
- } else if c.check.hovered {
- c.focusIndicator.FillColor = theme.HoverColor()
- } else {
- c.focusIndicator.FillColor = color.Transparent
- }
-}
-
// Check widget has a text label and a checked (or unchecked) icon and triggers an event func when toggled
type Check struct {
DisableableWidget
@@ -121,6 +24,29 @@ type Check struct {
hovered bool
binder basicBinder
+
+ minSize fyne.Size // cached for hover/tap position calculations
+}
+
+// NewCheck creates a new check widget with the set label and change handler
+func NewCheck(label string, changed func(bool)) *Check {
+ c := &Check{
+ Text: label,
+ OnChanged: changed,
+ }
+
+ c.ExtendBaseWidget(c)
+ return c
+}
+
+// NewCheckWithData returns a check widget connected with the specified data source.
+//
+// Since: 2.0
+func NewCheckWithData(label string, data binding.Bool) *Check {
+ check := NewCheck(label, nil)
+ check.Bind(data)
+
+ return check
}
// Bind connects the specified data source to this Check.
@@ -167,26 +93,47 @@ func (c *Check) Hide() {
}
// MouseIn is called when a desktop pointer enters the widget
-func (c *Check) MouseIn(*desktop.MouseEvent) {
- if c.Disabled() {
- return
- }
- c.hovered = true
- c.Refresh()
+func (c *Check) MouseIn(me *desktop.MouseEvent) {
+ c.MouseMoved(me)
}
// MouseOut is called when a desktop pointer exits the widget
func (c *Check) MouseOut() {
- c.hovered = false
- c.Refresh()
+ if c.hovered {
+ c.hovered = false
+ c.Refresh()
+ }
}
// MouseMoved is called when a desktop pointer hovers over the widget
-func (c *Check) MouseMoved(*desktop.MouseEvent) {
+func (c *Check) MouseMoved(me *desktop.MouseEvent) {
+ if c.Disabled() {
+ return
+ }
+
+ oldHovered := c.hovered
+
+ // only hovered if cached minSize has not been initialized (test code)
+ // or the pointer is within the "active" area of the widget (its minSize)
+ c.hovered = c.minSize.IsZero() ||
+ (me.Position.X <= c.minSize.Width && me.Position.Y <= c.minSize.Height)
+
+ if oldHovered != c.hovered {
+ c.Refresh()
+ }
}
// Tapped is called when a pointer tapped event is captured and triggers any change handler
-func (c *Check) Tapped(*fyne.PointEvent) {
+func (c *Check) Tapped(pe *fyne.PointEvent) {
+ if c.Disabled() {
+ return
+ }
+ if !c.minSize.IsZero() &&
+ (pe.Position.X > c.minSize.Width || pe.Position.Y > c.minSize.Height) {
+ // tapped outside the active area of the widget
+ return
+ }
+
if !c.focused && !fyne.CurrentDevice().IsMobile() {
impl := c.super()
@@ -194,15 +141,14 @@ func (c *Check) Tapped(*fyne.PointEvent) {
c.Focus(impl.(fyne.Focusable))
}
}
- if !c.Disabled() {
- c.SetChecked(!c.Checked)
- }
+ c.SetChecked(!c.Checked)
}
// MinSize returns the size that this widget should not shrink below
func (c *Check) MinSize() fyne.Size {
c.ExtendBaseWidget(c)
- return c.BaseWidget.MinSize()
+ c.minSize = c.BaseWidget.MinSize()
+ return c.minSize
}
// CreateRenderer is a private method to Fyne which links this widget to its renderer
@@ -233,27 +179,6 @@ func (c *Check) CreateRenderer() fyne.WidgetRenderer {
return r
}
-// NewCheck creates a new check widget with the set label and change handler
-func NewCheck(label string, changed func(bool)) *Check {
- c := &Check{
- Text: label,
- OnChanged: changed,
- }
-
- c.ExtendBaseWidget(c)
- return c
-}
-
-// NewCheckWithData returns a check widget connected with the specified data source.
-//
-// Since: 2.0
-func NewCheckWithData(label string, data binding.Bool) *Check {
- check := NewCheck(label, nil)
- check.Bind(data)
-
- return check
-}
-
// FocusGained is called when the Check has been given focus.
func (c *Check) FocusGained() {
if c.Disabled() {
@@ -336,3 +261,100 @@ func (c *Check) writeData(data binding.DataItem) {
}
}
}
+
+type checkRenderer struct {
+ widget.BaseRenderer
+ bg, icon *canvas.Image
+ label *canvas.Text
+ focusIndicator *canvas.Circle
+ check *Check
+}
+
+// MinSize calculates the minimum size of a check.
+// This is based on the contained text, the check icon and a standard amount of padding added.
+func (c *checkRenderer) MinSize() fyne.Size {
+ pad4 := theme.InnerPadding() * 2
+ min := c.label.MinSize().Add(fyne.NewSize(theme.IconInlineSize()+pad4, pad4))
+ if c.check.Text != "" {
+ min.Add(fyne.NewSize(theme.Padding(), 0))
+ }
+
+ return min
+}
+
+// Layout the components of the check widget
+func (c *checkRenderer) Layout(size fyne.Size) {
+ focusIndicatorSize := fyne.NewSquareSize(theme.IconInlineSize() + theme.InnerPadding())
+ c.focusIndicator.Resize(focusIndicatorSize)
+ c.focusIndicator.Move(fyne.NewPos(theme.InputBorderSize(), (size.Height-focusIndicatorSize.Height)/2))
+
+ xOff := focusIndicatorSize.Width + theme.InputBorderSize()*2
+ labelSize := size.SubtractWidthHeight(xOff, 0)
+ c.label.Resize(labelSize)
+ c.label.Move(fyne.NewPos(xOff, 0))
+
+ iconPos := fyne.NewPos(theme.InnerPadding()/2+theme.InputBorderSize(), (size.Height-theme.IconInlineSize())/2)
+ iconSize := fyne.NewSquareSize(theme.IconInlineSize())
+ c.bg.Move(iconPos)
+ c.bg.Resize(iconSize)
+ c.icon.Resize(iconSize)
+ c.icon.Move(iconPos)
+}
+
+// applyTheme updates this Check to the current theme
+func (c *checkRenderer) applyTheme() {
+ c.label.Color = theme.ForegroundColor()
+ c.label.TextSize = theme.TextSize()
+ if c.check.disabled {
+ c.label.Color = theme.DisabledColor()
+ }
+}
+
+func (c *checkRenderer) Refresh() {
+ c.check.propertyLock.RLock()
+ c.applyTheme()
+ c.updateLabel()
+ c.updateResource()
+ c.updateFocusIndicator()
+ c.check.propertyLock.RUnlock()
+ canvas.Refresh(c.check.super())
+}
+
+func (c *checkRenderer) updateLabel() {
+ c.label.Text = c.check.Text
+}
+
+func (c *checkRenderer) updateResource() {
+ res := theme.NewThemedResource(theme.CheckButtonIcon())
+ res.ColorName = theme.ColorNameInputBorder
+ // TODO move to `theme.CheckButtonFillIcon()` when we add it in 2.4
+ bgRes := theme.NewThemedResource(fyne.CurrentApp().Settings().Theme().Icon("iconNameCheckButtonFill"))
+ bgRes.ColorName = theme.ColorNameInputBackground
+
+ if c.check.Checked {
+ res = theme.NewThemedResource(theme.CheckButtonCheckedIcon())
+ res.ColorName = theme.ColorNamePrimary
+ bgRes.ColorName = theme.ColorNameBackground
+ }
+ if c.check.disabled {
+ if c.check.Checked {
+ res = theme.NewThemedResource(theme.CheckButtonCheckedIcon())
+ }
+ res.ColorName = theme.ColorNameDisabled
+ bgRes.ColorName = theme.ColorNameBackground
+ }
+ c.icon.Resource = res
+ c.bg.Resource = bgRes
+}
+
+func (c *checkRenderer) updateFocusIndicator() {
+ if c.check.disabled {
+ c.focusIndicator.FillColor = color.Transparent
+ } else if c.check.focused {
+ c.focusIndicator.FillColor = theme.FocusColor()
+ } else if c.check.hovered {
+ c.focusIndicator.FillColor = theme.HoverColor()
+ } else {
+ c.focusIndicator.FillColor = color.Transparent
+ }
+}
diff --git a/widget/check_internal_test.go b/widget/check_internal_test.go
index 6047db9ac1..7e07dccd97 100644
--- a/widget/check_internal_test.go
+++ b/widget/check_internal_test.go
@@ -210,6 +210,38 @@ func TestCheck_Hovered(t *testing.T) {
assert.Equal(t, color.Transparent, render.focusIndicator.FillColor)
}
+func TestCheck_HoveredOutsideActiveArea(t *testing.T) {
+ check := NewCheck("Test", func(on bool) {})
+ w := test.NewWindow(check)
+ defer w.Close()
+ render := test.WidgetRenderer(check).(*checkRenderer)
+
+ check.SetChecked(true)
+ assert.False(t, check.hovered)
+ assert.Equal(t, color.Transparent, render.focusIndicator.FillColor)
+
+ ms := check.MinSize()
+ check.MouseIn(&desktop.MouseEvent{PointEvent: fyne.PointEvent{
+ Position: fyne.NewPos(ms.Width+2, 1),
+ }})
+ assert.False(t, check.hovered)
+ assert.Equal(t, color.Transparent, render.focusIndicator.FillColor)
+}
+
+func TestCheck_TappedOutsideActiveArea(t *testing.T) {
+ check := NewCheck("Test", func(on bool) {})
+ w := test.NewWindow(check)
+ defer w.Close()
+
+ check.SetChecked(true)
+
+ ms := check.MinSize()
+ check.Tapped(&fyne.PointEvent{
+ Position: fyne.NewPos(ms.Width+2, 1),
+ })
+ assert.True(t, check.Checked)
+}
+
func TestCheck_TypedRune(t *testing.T) {
check := NewCheck("Test", func(on bool) {})
w := test.NewWindow(check)
diff --git a/widget/entry.go b/widget/entry.go
index 1cbb707fcf..853d55b1b9 100644
--- a/widget/entry.go
+++ b/widget/entry.go
@@ -96,7 +96,7 @@ type Entry struct {
ActionItem fyne.CanvasObject `json:"-"`
binder basicBinder
conversionError error
- lastChange time.Time
+ minCache *fyne.Size
multiLineRows int // override global default number of visible lines
}
@@ -386,6 +386,13 @@ func (e *Entry) KeyUp(key *fyne.KeyEvent) {
//
// Implements: fyne.Widget
func (e *Entry) MinSize() fyne.Size {
+ e.propertyLock.RLock()
+ cached := e.minCache
+ e.propertyLock.RUnlock()
+ if cached != nil {
+ return *cached
+ }
+
e.ExtendBaseWidget(e)
min := e.BaseWidget.MinSize()
@@ -396,6 +403,9 @@ func (e *Entry) MinSize() fyne.Size {
min = min.Add(fyne.NewSize(theme.IconInlineSize()+theme.LineSpacing(), 0))
}
+ e.propertyLock.Lock()
+ e.minCache = &min
+ e.propertyLock.Unlock()
return min
}
@@ -435,6 +445,14 @@ func (e *Entry) MouseUp(m *desktop.MouseEvent) {
}
}
+func (e *Entry) Refresh() {
+ e.propertyLock.Lock()
+ e.minCache = nil
+ e.propertyLock.Unlock()
+
+ e.BaseWidget.Refresh()
+}
+
// SelectedText returns the text currently selected in this Entry.
// If there is no selection it will return the empty string.
func (e *Entry) SelectedText() string {
@@ -461,6 +479,7 @@ func (e *Entry) SelectedText() string {
// Since: 2.2
func (e *Entry) SetMinRowsVisible(count int) {
e.multiLineRows = count
+ e.Refresh()
}
// SetPlaceHolder sets the text that will be displayed if the entry is otherwise empty
@@ -476,7 +495,11 @@ func (e *Entry) SetPlaceHolder(text string) {
// SetText manually sets the text of the Entry to the given text value.
func (e *Entry) SetText(text string) {
- e.updateTextAndRefresh(text)
+ e.setText(text, false)
+}
+
+func (e *Entry) setText(text string, fromBinding bool) {
+ e.updateTextAndRefresh(text, fromBinding)
e.updateCursorAndSelection()
}
@@ -489,7 +512,7 @@ func (e *Entry) Append(text string) {
provider := e.textProvider()
provider.insertAt(provider.len(), text)
content := provider.String()
- changed := e.updateText(content)
+ changed := e.updateText(content, false)
e.propertyLock.Unlock()
if changed {
@@ -665,7 +688,7 @@ func (e *Entry) TypedKey(key *fyne.KeyEvent) {
e.propertyLock.Lock()
content := provider.String()
- changed := e.updateText(content)
+ changed := e.updateText(content, false)
if e.CursorRow == e.selectRow && e.CursorColumn == e.selectColumn {
e.selecting = false
}
@@ -784,7 +807,7 @@ func (e *Entry) TypedRune(r rune) {
provider.insertAt(pos, string(runes))
content := provider.String()
- e.updateText(content)
+ e.updateText(content, false)
e.CursorRow, e.CursorColumn = e.rowColFromTextPos(pos + len(runes))
e.propertyLock.Unlock()
@@ -846,11 +869,12 @@ func (e *Entry) cutToClipboard(clipboard fyne.Clipboard) {
e.copyToClipboard(clipboard)
e.setFieldsAndRefresh(e.eraseSelection)
- e.propertyLock.Lock()
+ e.propertyLock.RLock()
+ content := e.Text
+ e.propertyLock.RUnlock()
if e.OnChanged != nil {
- e.OnChanged(e.Text)
+ e.OnChanged(content)
}
- e.propertyLock.Unlock()
e.Validate()
}
@@ -871,7 +895,7 @@ func (e *Entry) eraseSelection() {
e.CursorRow, e.CursorColumn = e.rowColFromTextPos(posA)
e.selectRow, e.selectColumn = e.CursorRow, e.CursorColumn
e.selecting = false
- e.updateText(provider.String())
+ e.updateText(provider.String(), false)
}
func (e *Entry) getRowCol(p fyne.Position) (int, int) {
@@ -909,7 +933,7 @@ func (e *Entry) pasteFromClipboard(clipboard fyne.Clipboard) {
pos := e.cursorTextPos()
provider.insertAt(pos, text)
- e.updateTextAndRefresh(provider.String())
+ e.updateTextAndRefresh(provider.String(), false)
e.CursorRow, e.CursorColumn = e.rowColFromTextPos(pos + len(runes))
e.Refresh() // placing the cursor (and refreshing) happens last
}
@@ -1092,11 +1116,12 @@ func (e *Entry) selectingKeyHandler(key *fyne.KeyEvent) bool {
case fyne.KeyBackspace, fyne.KeyDelete:
// clears the selection -- return handled
e.setFieldsAndRefresh(e.eraseSelection)
- e.propertyLock.Lock()
+ e.propertyLock.RLock()
+ content := e.Text
+ e.propertyLock.RUnlock()
if e.OnChanged != nil {
- e.OnChanged(e.Text)
+ e.OnChanged(content)
}
- e.propertyLock.Unlock()
e.Validate()
return true
case fyne.KeyReturn, fyne.KeyEnter:
@@ -1248,7 +1273,7 @@ func (e *Entry) updateCursorAndSelection() {
}
func (e *Entry) updateFromData(data binding.DataItem) {
- if data == nil || e.lastChange.After(time.Now().Add(-bindIgnoreDelay)) {
+ if data == nil {
return
}
textSource, ok := data.(binding.String)
@@ -1262,7 +1287,7 @@ func (e *Entry) updateFromData(data binding.DataItem) {
if err != nil {
return
}
- e.SetText(val)
+ e.setText(val, true)
}
func (e *Entry) truncatePosition(row, col int) (int, int) {
@@ -1304,7 +1329,7 @@ func (e *Entry) updateMousePointer(p fyne.Position, rightClick bool) {
// updateText updates the internal text to the given value.
// It assumes that a lock exists on the widget.
-func (e *Entry) updateText(text string) bool {
+func (e *Entry) updateText(text string, fromBinding bool) bool {
changed := e.Text != text
e.Text = text
e.syncSegments()
@@ -1314,8 +1339,7 @@ func (e *Entry) updateText(text string) bool {
e.dirty = true
}
- e.lastChange = time.Now()
- if changed {
+ if changed && !fromBinding {
if e.binder.dataListenerPair.listener != nil {
e.binder.SetCallback(nil)
e.binder.CallWithData(e.writeData)
@@ -1327,10 +1351,10 @@ func (e *Entry) updateText(text string) bool {
// updateTextAndRefresh updates the internal text to the given value then refreshes it.
// This should not be called under a property lock
-func (e *Entry) updateTextAndRefresh(text string) {
+func (e *Entry) updateTextAndRefresh(text string, fromBinding bool) {
var callback func(string)
e.setFieldsAndRefresh(func() {
- changed := e.updateText(text)
+ changed := e.updateText(text, fromBinding)
if changed {
callback = e.OnChanged
diff --git a/widget/entry_cursor_anim.go b/widget/entry_cursor_anim.go
index 9a4d0ec77c..3380ea10e5 100644
--- a/widget/entry_cursor_anim.go
+++ b/widget/entry_cursor_anim.go
@@ -11,7 +11,11 @@ import (
"fyne.io/fyne/v2/theme"
)
-const cursorInterruptTime = 300 * time.Millisecond
+const (
+ cursorInterruptTime = 300 * time.Millisecond
+ cursorFadeAlpha = uint8(0x16)
+ cursorFadeRatio = 0.1
+)
type entryCursorAnimation struct {
mu *sync.RWMutex
@@ -30,14 +34,26 @@ func newEntryCursorAnimation(cursor *canvas.Rectangle) *entryCursorAnimation {
// creates fyne animation
func (a *entryCursorAnimation) createAnim(inverted bool) *fyne.Animation {
cursorOpaque := theme.PrimaryColor()
- r, g, b, _ := col.ToNRGBA(theme.PrimaryColor())
- cursorDim := color.NRGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 0x16}
- start, end := color.Color(cursorDim), cursorOpaque
+ ri, gi, bi, ai := col.ToNRGBA(theme.PrimaryColor())
+ r := uint8(ri >> 8)
+ g := uint8(gi >> 8)
+ b := uint8(bi >> 8)
+ endA := uint8(ai >> 8)
+ startA := cursorFadeAlpha
+ cursorDim := color.NRGBA{R: r, G: g, B: b, A: cursorFadeAlpha}
if inverted {
- start, end = cursorOpaque, color.Color(cursorDim)
+ a.cursor.FillColor = cursorOpaque
+ startA, endA = endA, startA
+ } else {
+ a.cursor.FillColor = cursorDim
}
+
+ deltaA := endA - startA
+ fadeStart := float32(0.5 - cursorFadeRatio)
+ fadeStop := float32(0.5 + cursorFadeRatio)
+
interrupted := false
- anim := canvas.NewColorRGBAAnimation(start, end, time.Second/2, func(c color.Color) {
+ anim := fyne.NewAnimation(time.Second/2, func(f float32) {
a.mu.RLock()
shouldInterrupt := a.timeNow().Sub(a.lastInterruptTime) <= cursorInterruptTime
a.mu.RUnlock()
@@ -67,7 +83,26 @@ func (a *entryCursorAnimation) createAnim(inverted bool) *fyne.Animation {
}()
return
}
- a.cursor.FillColor = c
+
+ alpha := uint8(0)
+ if f < fadeStart {
+ if _, _, _, al := a.cursor.FillColor.RGBA(); uint8(al>>8) == cursorFadeAlpha {
+ return
+ }
+
+ a.cursor.FillColor = cursorDim
+ } else if f >= fadeStop {
+ if _, _, _, al := a.cursor.FillColor.RGBA(); al == 0xffff {
+ return
+ }
+
+ a.cursor.FillColor = cursorOpaque
+ } else {
+ fade := (f + cursorFadeRatio - 0.5) * (1 / (cursorFadeRatio * 2))
+ alpha = uint8(float32(deltaA) * fade)
+ a.cursor.FillColor = color.NRGBA{R: r, G: g, B: b, A: alpha}
+ }
+
a.cursor.Refresh()
})
diff --git a/widget/entry_cursor_anim_test.go b/widget/entry_cursor_anim_test.go
index fc094e1f77..587f2d4b2f 100644
--- a/widget/entry_cursor_anim_test.go
+++ b/widget/entry_cursor_anim_test.go
@@ -21,7 +21,7 @@ func TestEntryCursorAnim(t *testing.T) {
alphaEquals := func(color1, color2 color.Color) bool {
_, _, _, a1 := col.ToNRGBA(color1)
_, _, _, a2 := col.ToNRGBA(color2)
- return a1 == a2
+ return uint8(a1>>8) == uint8(a2>>8) // only check 8bit colour channels
}
cursor := canvas.NewRectangle(color.Black)
diff --git a/widget/entry_internal_test.go b/widget/entry_internal_test.go
index db56c05fc4..cffbb583f7 100644
--- a/widget/entry_internal_test.go
+++ b/widget/entry_internal_test.go
@@ -276,13 +276,28 @@ func TestEntry_EraseSelection(t *testing.T) {
keyPress(&fyne.KeyEvent{Name: fyne.KeyRight})
e.eraseSelection()
- e.updateText(e.textProvider().String())
+ e.updateText(e.textProvider().String(), false)
assert.Equal(t, "Testing\nTeng\nTesting", e.Text)
a, b := e.selection()
assert.Equal(t, -1, a)
assert.Equal(t, -1, b)
}
+func TestEntry_CallbackLocking(t *testing.T) {
+ e := &Entry{}
+ called := 0
+ e.OnChanged = func(_ string) {
+ e.propertyLock.Lock()
+ called++ // Just to not have an empty critical section.
+ e.propertyLock.Unlock()
+ }
+
+ test.Type(e, "abc123")
+ e.selectAll()
+ e.TypedKey(&fyne.KeyEvent{Name: fyne.KeyBackspace})
+ assert.Equal(t, 7, called)
+}
+
func TestEntry_MouseClickAndDragOutsideText(t *testing.T) {
entry := NewEntry()
entry.SetText("A\nB\n")
diff --git a/widget/entry_test.go b/widget/entry_test.go
index c82ee1f373..842d605939 100644
--- a/widget/entry_test.go
+++ b/widget/entry_test.go
@@ -45,6 +45,21 @@ func TestEntry_Binding(t *testing.T) {
assert.Equal(t, "Typed", entry.Text)
}
+func TestEntry_Binding_Bounce(t *testing.T) {
+ entry := widget.NewEntry()
+ entry.SetText("Init")
+ assert.Equal(t, "Init", entry.Text)
+ waitForBinding() // this time it is the de-echo before binding
+
+ str := binding.NewString()
+ entry.Bind(str)
+ str.Set("1")
+ time.Sleep(10 * time.Millisecond)
+ str.Set("2")
+ waitForBinding()
+ assert.Equal(t, "2", entry.Text)
+}
+
func TestEntry_Binding_Replace(t *testing.T) {
entry := widget.NewEntry()
str := binding.NewString()
@@ -438,6 +453,7 @@ func TestEntry_MinSize(t *testing.T) {
min = entry.MinSize()
entry.ActionItem = canvas.NewCircle(color.Black)
+ entry.Refresh()
assert.Equal(t, min.Add(fyne.NewSize(theme.IconInlineSize()+theme.Padding(), 0)), entry.MinSize())
}
@@ -462,6 +478,7 @@ func TestEntryMultiline_MinSize(t *testing.T) {
min = entry.MinSize()
entry.ActionItem = canvas.NewCircle(color.Black)
+ entry.Refresh()
assert.Equal(t, min.Add(fyne.NewSize(theme.IconInlineSize()+theme.Padding(), 0)), entry.MinSize())
}
@@ -1764,6 +1781,7 @@ func TestMultiLineEntry_MinSize(t *testing.T) {
assert.True(t, multiMin.Height > singleMin.Height)
multi.MultiLine = false
+ multi.Refresh()
multiMin = multi.MinSize()
assert.Equal(t, singleMin.Height, multiMin.Height)
}
diff --git a/widget/gridwrap.go b/widget/gridwrap.go
index 0221c9bb08..334064e37c 100644
--- a/widget/gridwrap.go
+++ b/widget/gridwrap.go
@@ -44,6 +44,7 @@ type GridWrap struct {
itemMin fyne.Size
offsetY float32
offsetUpdated func(fyne.Position)
+ colCountCache int
}
// NewGridWrap creates and returns a GridWrap widget for displaying items in
@@ -123,8 +124,8 @@ func (l *GridWrap) scrollTo(id GridWrapItemID) {
y := float32(row)*l.itemMin.Height + float32(row)*theme.Padding()
if y < l.scroller.Offset.Y {
l.scroller.Offset.Y = y
- } else if y+l.itemMin.Height > l.scroller.Offset.Y+l.scroller.Size().Height {
- l.scroller.Offset.Y = y + l.itemMin.Height - l.scroller.Size().Height
+ } else if size := l.scroller.Size(); y+l.itemMin.Height > l.scroller.Offset.Y+size.Height {
+ l.scroller.Offset.Y = y + l.itemMin.Height - size.Height
}
l.offsetUpdated(l.scroller.Offset)
}
@@ -148,6 +149,7 @@ func (l *GridWrap) RefreshItem(id GridWrapItemID) {
// Resize is called when this GridWrap should change size. We refresh to ensure invisible items are drawn.
func (l *GridWrap) Resize(s fyne.Size) {
+ l.colCountCache = 0
l.BaseWidget.Resize(s)
l.offsetUpdated(l.scroller.Offset)
l.scroller.Content.(*fyne.Container).Layout.(*gridWrapLayout).updateGrid(true)
@@ -493,11 +495,12 @@ func (l *gridWrapLayout) Layout(_ []fyne.CanvasObject, _ fyne.Size) {
}
func (l *gridWrapLayout) MinSize(_ []fyne.CanvasObject) fyne.Size {
+ padding := theme.Padding()
if lenF := l.list.Length; lenF != nil {
cols := l.list.getColCount()
rows := float32(math.Ceil(float64(lenF()) / float64(cols)))
return fyne.NewSize(l.list.itemMin.Width,
- (l.list.itemMin.Height+theme.Padding())*rows-theme.Padding())
+ (l.list.itemMin.Height+padding)*rows-padding)
}
return fyne.NewSize(0, 0)
}
@@ -555,16 +558,20 @@ func (l *gridWrapLayout) setupGridItem(li *gridWrapItem, id GridWrapItemID, focu
}
func (l *GridWrap) getColCount() int {
- colCount := 1
- width := l.Size().Width
- if width > l.itemMin.Width {
- colCount = int(math.Floor(float64(width+theme.Padding()) / float64(l.itemMin.Width+theme.Padding())))
+ if l.colCountCache < 1 {
+ padding := theme.Padding()
+ l.colCountCache = 1
+ width := l.Size().Width
+ if width > l.itemMin.Width {
+ l.colCountCache = int(math.Floor(float64(width+padding) / float64(l.itemMin.Width+padding)))
+ }
}
- return colCount
+ return l.colCountCache
}
func (l *gridWrapLayout) updateGrid(refresh bool) {
// code here is a mashup of listLayout.updateList and gridWrapLayout.Layout
+ padding := theme.Padding()
l.renderLock.Lock()
length := 0
@@ -573,10 +580,10 @@ func (l *gridWrapLayout) updateGrid(refresh bool) {
}
colCount := l.list.getColCount()
- visibleRowsCount := int(math.Ceil(float64(l.list.scroller.Size().Height)/float64(l.list.itemMin.Height+theme.Padding()))) + 1
+ visibleRowsCount := int(math.Ceil(float64(l.list.scroller.Size().Height)/float64(l.list.itemMin.Height+padding))) + 1
- offY := l.list.offsetY - float32(math.Mod(float64(l.list.offsetY), float64(l.list.itemMin.Height+theme.Padding())))
- minRow := int(offY / (l.list.itemMin.Height + theme.Padding()))
+ offY := l.list.offsetY - float32(math.Mod(float64(l.list.offsetY), float64(l.list.itemMin.Height+padding)))
+ minRow := int(offY / (l.list.itemMin.Height + padding))
minItem := GridWrapItemID(minRow * colCount)
maxRow := int(math.Min(float64(minRow+visibleRowsCount), math.Ceil(float64(length)/float64(colCount))))
maxItem := GridWrapItemID(math.Min(float64(maxRow*colCount), float64(length-1)))
@@ -616,12 +623,12 @@ func (l *gridWrapLayout) updateGrid(refresh bool) {
item.Resize(l.list.itemMin)
}
- x += l.list.itemMin.Width + theme.Padding()
+ x += l.list.itemMin.Width + padding
l.visible = append(l.visible, gridItemAndID{item: item, id: curItemID})
c.Objects = append(c.Objects, item)
curItemID++
}
- y += l.list.itemMin.Height + theme.Padding()
+ y += l.list.itemMin.Height + padding
}
l.nilOldSliceData(c.Objects, len(c.Objects), oldObjLen)
l.nilOldVisibleSliceData(l.visible, len(l.visible), oldVisibleLen)
diff --git a/widget/hyperlink.go b/widget/hyperlink.go
index 6f424f50ff..03b959011b 100644
--- a/widget/hyperlink.go
+++ b/widget/hyperlink.go
@@ -28,6 +28,7 @@ type Hyperlink struct {
// Since: 2.2
OnTapped func() `json:"-"`
+ textSize fyne.Size // updated in syncSegments
focused, hovered bool
provider *RichText
}
@@ -67,7 +68,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.
@@ -83,19 +87,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.
@@ -156,12 +199,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()
}
@@ -172,7 +223,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()
}
}
@@ -196,6 +247,7 @@ func (hl *Hyperlink) syncSegments() {
},
Text: hl.Text,
}}
+ hl.textSize = fyne.MeasureText(hl.Text, theme.TextSize(), hl.TextStyle)
}
var _ fyne.WidgetRenderer = (*hyperlinkRenderer)(nil)
@@ -212,11 +264,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)
- r.focus.Move(fyne.NewPos(theme.InnerPadding()/2, theme.InnerPadding()/2))
- r.focus.Resize(fyne.NewSize(s.Width-theme.InnerPadding(), s.Height-theme.InnerPadding()))
- r.under.Move(fyne.NewPos(theme.InnerPadding(), s.Height-theme.InnerPadding()))
- r.under.Resize(fyne.NewSize(s.Width-theme.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 29dc6e31fb..59f52506d3 100644
--- a/widget/hyperlink_test.go
+++ b/widget/hyperlink_test.go
@@ -39,6 +39,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())
}
diff --git a/widget/icon.go b/widget/icon.go
index f88a1a9e60..bb222cd272 100644
--- a/widget/icon.go
+++ b/widget/icon.go
@@ -34,6 +34,10 @@ func (i *iconRenderer) Refresh() {
i.image.propertyLock.RLock()
i.raster.Resource = i.image.Resource
i.image.cachedRes = i.image.Resource
+
+ if i.image.Resource == nil {
+ i.raster.Image = nil // reset the internal caching too...
+ }
i.image.propertyLock.RUnlock()
i.raster.Refresh()
diff --git a/widget/importance.go b/widget/importance.go
index c9d5a468b6..0a395b0d7e 100644
--- a/widget/importance.go
+++ b/widget/importance.go
@@ -15,15 +15,15 @@ const (
// DangerImportance applies an error theme to the widget.
//
- // Since 2.3
+ // Since: 2.3
DangerImportance
// WarningImportance applies a warning theme to the widget.
//
- // Since 2.3
+ // Since: 2.3
WarningImportance
// SuccessImportance applies a success theme to the widget.
//
- // Since 2.4
+ // Since: 2.4
SuccessImportance
)
diff --git a/widget/label.go b/widget/label.go
index 4f8ddc77fb..8ca9f72c3f 100644
--- a/widget/label.go
+++ b/widget/label.go
@@ -10,15 +10,22 @@ import (
// Label widget is a label component with appropriate padding and layout.
type Label struct {
BaseWidget
- Text string
- Alignment fyne.TextAlign // The alignment of the text
- Wrapping fyne.TextWrap // The wrapping of the text
- TextStyle fyne.TextStyle // The style of the label text
- Truncation fyne.TextTruncation // The truncation mode of the text
- provider *RichText
+ Text string
+ Alignment fyne.TextAlign // The alignment of the text
+ Wrapping fyne.TextWrap // The wrapping of the text
+ TextStyle fyne.TextStyle // The style of the label text
+
+ // The truncation mode of the text
+ //
+ // Since: 2.4
+ Truncation fyne.TextTruncation
+ // Importance informs how the label should be styled, i.e. warning or disabled
+ //
+ // Since: 2.4
Importance Importance
- binder basicBinder
+ provider *RichText
+ binder basicBinder
}
// NewLabel creates a new label widget with the set text content
diff --git a/widget/progressbarinfinite_test.go b/widget/progressbarinfinite_test.go
index 853255ea30..3deae45312 100644
--- a/widget/progressbarinfinite_test.go
+++ b/widget/progressbarinfinite_test.go
@@ -54,7 +54,7 @@ func TestInfiniteProgressRenderer_Layout(t *testing.T) {
render.updateBar(0.0)
// start at the smallest size
- assert.Equal(t, width*minProgressBarInfiniteWidthRatio, render.bar.Size().Width)
+ assert.InEpsilon(t, width*minProgressBarInfiniteWidthRatio, render.bar.Size().Width, 0.0001)
// make sure the inner progress bar grows in size
// call updateBar() enough times to grow the inner bar
diff --git a/widget/richtext.go b/widget/richtext.go
index 6b14fb9f71..1937594117 100644
--- a/widget/richtext.go
+++ b/widget/richtext.go
@@ -28,9 +28,13 @@ const (
// Since: 2.1
type RichText struct {
BaseWidget
- Segments []RichTextSegment
- Wrapping fyne.TextWrap
- Scroll widget.ScrollDirection
+ Segments []RichTextSegment
+ Wrapping fyne.TextWrap
+ Scroll widget.ScrollDirection
+
+ // The truncation mode of the text
+ //
+ // Since: 2.4
Truncation fyne.TextTruncation
inset fyne.Size // this varies due to how the widget works (entry with scroller vs others with padding)
@@ -1089,9 +1093,9 @@ func truncateLimit(s string, text *canvas.Text, limit int, ellipsis []rune) (int
out := shaper.Shape(in)
l.Prepare(conf, runes, shaping.NewSliceIterator([]shaping.Output{out}))
- finalLine, _, done := l.WrapNextLine(limit)
+ wrapped, done := l.WrapNextLine(limit)
- count := finalLine[0].Runes.Count
+ count := wrapped.Line[0].Runes.Count
full := done && count == len(runes)
if !full && len(ellipsis) > 0 {
count--
diff --git a/widget/select_test.go b/widget/select_test.go
index 57e8621a29..ac97654d09 100644
--- a/widget/select_test.go
+++ b/widget/select_test.go
@@ -100,7 +100,7 @@ func TestSelect_ClipValue(t *testing.T) {
r2 := cache.Renderer(text)
assert.Equal(t, 1, len(r2.Objects()))
- assert.Equal(t, "som…", r2.Objects()[0].(*canvas.Text).Text)
+ assert.Equal(t, "some…", r2.Objects()[0].(*canvas.Text).Text)
}
func TestSelect_Disable(t *testing.T) {
diff --git a/widget/slider.go b/widget/slider.go
index d799c253a9..9a2de69a6d 100644
--- a/widget/slider.go
+++ b/widget/slider.go
@@ -208,16 +208,16 @@ func (s *Slider) TypedKey(key *fyne.KeyEvent) {
func (s *Slider) TypedRune(_ rune) {
}
-func (s *Slider) buttonDiameter() float32 {
- return theme.IconInlineSize() - 4 // match radio icons
+func (s *Slider) buttonDiameter(inlineIconSize float32) float32 {
+ return inlineIconSize - 4 // match radio icons
}
-func (s *Slider) endOffset() float32 {
- return s.buttonDiameter()/2 + theme.InnerPadding() - 1.5 // align with radio icons
+func (s *Slider) endOffset(inlineIconSize, innerPadding float32) float32 {
+ return s.buttonDiameter(inlineIconSize)/2 + innerPadding - 1.5 // align with radio icons
}
func (s *Slider) getRatio(e *fyne.PointEvent) float64 {
- pad := s.endOffset()
+ pad := s.endOffset(theme.IconInlineSize(), theme.InnerPadding())
x := e.Position.X
y := e.Position.Y
@@ -374,7 +374,7 @@ type sliderRenderer struct {
func (s *sliderRenderer) Refresh() {
s.track.FillColor = theme.InputBackgroundColor()
s.thumb.FillColor = theme.ForegroundColor()
- s.active.FillColor = theme.ForegroundColor()
+ s.active.FillColor = s.thumb.FillColor
if s.slider.focused {
s.focusIndicator.FillColor = theme.FocusColor()
@@ -393,9 +393,12 @@ func (s *sliderRenderer) Refresh() {
// Layout the components of the widget.
func (s *sliderRenderer) Layout(size fyne.Size) {
- trackWidth := theme.InputBorderSize() * 2
- diameter := s.slider.buttonDiameter()
- endPad := s.slider.endOffset()
+ inputBorderSize := theme.InputBorderSize()
+ trackWidth := inputBorderSize * 2
+ inlineIconSize := theme.IconInlineSize()
+ innerPadding := theme.InnerPadding()
+ diameter := s.slider.buttonDiameter(inlineIconSize)
+ endPad := s.slider.endOffset(inlineIconSize, innerPadding)
var trackPos, activePos, thumbPos fyne.Position
var trackSize, activeSize fyne.Size
@@ -403,17 +406,17 @@ func (s *sliderRenderer) Layout(size fyne.Size) {
// some calculations are relative to trackSize, so we must update that first
switch s.slider.Orientation {
case Vertical:
- trackPos = fyne.NewPos(size.Width/2-theme.InputBorderSize(), endPad)
+ trackPos = fyne.NewPos(size.Width/2-inputBorderSize, endPad)
trackSize = fyne.NewSize(trackWidth, size.Height-endPad*2)
case Horizontal:
- trackPos = fyne.NewPos(endPad, size.Height/2-theme.InputBorderSize())
+ trackPos = fyne.NewPos(endPad, size.Height/2-inputBorderSize)
trackSize = fyne.NewSize(size.Width-endPad*2, trackWidth)
}
s.track.Move(trackPos)
s.track.Resize(trackSize)
- activeOffset := s.getOffset() // TODO based on old size...0
+ activeOffset := s.getOffset(inlineIconSize, innerPadding) // TODO based on old size...0
switch s.slider.Orientation {
case Vertical:
activePos = fyne.NewPos(trackPos.X, activeOffset)
@@ -435,7 +438,7 @@ func (s *sliderRenderer) Layout(size fyne.Size) {
s.thumb.Move(thumbPos)
s.thumb.Resize(fyne.NewSize(diameter, diameter))
- focusIndicatorSize := fyne.NewSquareSize(theme.IconInlineSize() + theme.InnerPadding())
+ focusIndicatorSize := fyne.NewSquareSize(inlineIconSize + innerPadding)
delta := (focusIndicatorSize.Width - diameter) / 2
s.focusIndicator.Resize(focusIndicatorSize)
s.focusIndicator.Move(thumbPos.SubtractXY(delta, delta))
@@ -443,7 +446,7 @@ func (s *sliderRenderer) Layout(size fyne.Size) {
// MinSize calculates the minimum size of a widget.
func (s *sliderRenderer) MinSize() fyne.Size {
- dia := s.slider.buttonDiameter()
+ dia := s.slider.buttonDiameter(theme.IconInlineSize())
s1, s2 := minLongSide+dia, dia
switch s.slider.Orientation {
@@ -456,8 +459,8 @@ func (s *sliderRenderer) MinSize() fyne.Size {
return fyne.Size{Width: 0, Height: 0}
}
-func (s *sliderRenderer) getOffset() float32 {
- endPad := s.slider.endOffset()
+func (s *sliderRenderer) getOffset(iconInlineSize, innerPadding float32) float32 {
+ endPad := s.slider.endOffset(iconInlineSize, innerPadding)
w := s.slider
size := s.track.Size()
if w.Value == w.Min || w.Min == w.Max {
diff --git a/widget/slider_test.go b/widget/slider_test.go
index faf24a1b48..398af3389e 100644
--- a/widget/slider_test.go
+++ b/widget/slider_test.go
@@ -66,7 +66,8 @@ func TestSlider_HorizontalLayout(t *testing.T) {
assert.Greater(t, wSize.Width, wSize.Height)
- assert.Equal(t, wSize.Width-slider.endOffset()*2, tSize.Width)
+ endOffset := slider.endOffset(theme.IconInlineSize(), theme.InnerPadding())
+ assert.Equal(t, wSize.Width-endOffset*2, tSize.Width)
assert.Equal(t, theme.InputBorderSize()*2, tSize.Height)
assert.Greater(t, wSize.Width, aSize.Width)
@@ -102,7 +103,8 @@ func TestSlider_VerticalLayout(t *testing.T) {
assert.Greater(t, wSize.Height, wSize.Width)
- assert.Equal(t, wSize.Height-slider.endOffset()*2, tSize.Height)
+ endOffset := slider.endOffset(theme.IconInlineSize(), theme.InnerPadding())
+ assert.Equal(t, wSize.Height-endOffset*2, tSize.Height)
assert.Equal(t, theme.InputBorderSize()*2, tSize.Width)
assert.Greater(t, wSize.Height, aSize.Height)