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)