diff --git a/widget/entry.go b/widget/entry.go index bc72eddc94..0ae0de4a1a 100644 --- a/widget/entry.go +++ b/widget/entry.go @@ -59,12 +59,17 @@ type Entry struct { cursorAnim *entryCursorAnimation + dirty bool focused bool text *textProvider placeholder *textProvider content *entryContent scroll *widget.Scroll + // useful for Form validation (as the error text should only be shown when + // the entry is unfocused) + onFocusChanged func(bool) + // selectRow and selectColumn represent the selection start location // The selection will span from selectRow/Column to CursorRow/Column -- note that the cursor // position may occur before or after the select start position in the text. @@ -142,7 +147,7 @@ func (e *Entry) Bind(data binding.String) { val, err := data.Get() if err != nil { convertErr = err - e.SetValidationError(e.Validate()) + e.Validate() return } e.Text = val @@ -156,7 +161,7 @@ func (e *Entry) Bind(data binding.String) { e.OnChanged = func(s string) { convertErr = data.Set(s) - e.SetValidationError(e.Validate()) + e.Validate() } } @@ -303,8 +308,12 @@ func (e *Entry) ExtendBaseWidget(wid fyne.Widget) { // Implements: fyne.Focusable func (e *Entry) FocusGained() { e.setFieldsAndRefresh(func() { + e.dirty = true e.focused = true }) + if e.onFocusChanged != nil { + e.onFocusChanged(true) + } } // FocusLost is called when the Entry has had focus removed. @@ -315,6 +324,9 @@ func (e *Entry) FocusLost() { e.focused = false e.selectKeyDown = false }) + if e.onFocusChanged != nil { + e.onFocusChanged(false) + } } // Hide hides the entry. @@ -1033,6 +1045,10 @@ func (e *Entry) textProvider() *textProvider { return e.text } + if e.Text != "" { + e.dirty = true + } + text := newTextProvider(e.Text, e) text.ExtendBaseWidget(text) text.inset = fyne.NewSize(0, theme.InputBorderSize()) @@ -1080,14 +1096,16 @@ func (e *Entry) updateText(text string) { changed := e.Text != text e.Text = text + if e.Text != "" { + e.dirty = true + } + if changed { callback = e.OnChanged } }) - if validate := e.Validator; validate != nil { - e.SetValidationError(validate(text)) - } + e.Validate() if callback != nil { callback(text) @@ -1287,7 +1305,7 @@ func (r *entryRenderer) Refresh() { } if r.entry.Validator != nil { - if !r.entry.focused && !r.entry.Disabled() && r.entry.Text != "" && r.entry.validationError != nil { + if !r.entry.focused && !r.entry.Disabled() && r.entry.dirty && r.entry.validationError != nil { r.line.FillColor = theme.ErrorColor() } r.ensureValidationSetup() @@ -1306,9 +1324,8 @@ func (r *entryRenderer) ensureValidationSetup() { r.objects = append(r.objects, r.entry.validationStatus) r.Layout(r.entry.size) - if r.entry.Text != "" { - r.entry.Validate() - } + r.entry.Validate() + r.Refresh() } } diff --git a/widget/entry_validation.go b/widget/entry_validation.go index 5a162ea841..a548e46342 100644 --- a/widget/entry_validation.go +++ b/widget/entry_validation.go @@ -95,15 +95,15 @@ func (r *validationStatusRenderer) MinSize() fyne.Size { func (r *validationStatusRenderer) Refresh() { r.entry.propertyLock.RLock() defer r.entry.propertyLock.RUnlock() - if r.entry.Text == "" || r.entry.disabled { + if r.entry.disabled { r.icon.Hide() return } - if r.entry.validationError == nil { + if r.entry.validationError == nil && r.entry.Text != "" { r.icon.Resource = theme.ConfirmIcon() r.icon.Show() - } else if !r.entry.focused { + } else if r.entry.validationError != nil && !r.entry.focused && r.entry.dirty { r.icon.Resource = theme.NewErrorThemedResource(theme.ErrorIcon()) r.icon.Show() } else { diff --git a/widget/entry_validation_test.go b/widget/entry_validation_test.go index 4e72353a9e..83f9320b09 100644 --- a/widget/entry_validation_test.go +++ b/widget/entry_validation_test.go @@ -63,6 +63,30 @@ func TestEntry_Validate(t *testing.T) { assert.Equal(t, entry.Validate(), entry.Validator(entry.Text)) } +func TestEntry_NotEmptyValidator(t *testing.T) { + test.NewApp() + defer test.NewApp() + entry := widget.NewEntry() + entry.Validator = func(s string) error { + if s == "" { + return errors.New("should not be empty") + } + return nil + } + w := test.NewWindow(entry) + defer w.Close() + + test.AssertRendersToMarkup(t, "entry/validator_not_empty_initial.xml", w.Canvas()) + + w.Canvas().Focus(entry) + + test.AssertRendersToMarkup(t, "entry/validator_not_empty_focused.xml", w.Canvas()) + + w.Canvas().Focus(nil) + + test.AssertRendersToMarkup(t, "entry/validator_not_empty_unfocused.xml", w.Canvas()) +} + func TestEntry_SetValidationError(t *testing.T) { entry, window := setupImageTest(t, false) fyne.CurrentApp().Settings().SetTheme(theme.LightTheme()) diff --git a/widget/form.go b/widget/form.go index 170cc1db54..f6c99c4879 100644 --- a/widget/form.go +++ b/widget/form.go @@ -190,21 +190,27 @@ func (f *Form) checkValidation(err error) { } func (f *Form) setUpValidation(widget fyne.CanvasObject, i int) { + updateValidation := func(err error) { + if err == errFormItemInitialState { + return + } + f.Items[i].validationError = err + f.Items[i].invalid = err != nil + f.checkValidation(err) + f.updateHelperText(f.Items[i]) + } if w, ok := widget.(fyne.Validatable); ok { f.Items[i].invalid = w.Validate() != nil - if e, ok := w.(*Entry); ok && e.Validator != nil && f.Items[i].invalid { - // set initial state error to guarantee next error (if triggers) is always different - e.SetValidationError(errFormItemInitialState) - } - w.SetOnValidationChanged(func(err error) { - if err == errFormItemInitialState { - return + if e, ok := w.(*Entry); ok { + e.onFocusChanged = func(bool) { + updateValidation(e.validationError) } - f.Items[i].validationError = err - f.Items[i].invalid = err != nil - f.checkValidation(err) - f.updateHelperText(f.Items[i]) - }) + if e.Validator != nil && f.Items[i].invalid { + // set initial state error to guarantee next error (if triggers) is always different + e.SetValidationError(errFormItemInitialState) + } + } + w.SetOnValidationChanged(updateValidation) } } @@ -212,7 +218,11 @@ func (f *Form) updateHelperText(item *FormItem) { if item.helperOutput == nil { return // testing probably, either way not rendered yet } - if item.validationError == nil { + showHintIfError := false + if e, ok := item.Widget.(*Entry); ok && (!e.dirty || e.focused) { + showHintIfError = true + } + if item.validationError == nil || showHintIfError { item.helperOutput.Text = item.HintText item.helperOutput.Color = theme.PlaceHolderColor() } else { diff --git a/widget/form_test.go b/widget/form_test.go index 086b00baf6..b60e1dfff7 100644 --- a/widget/form_test.go +++ b/widget/form_test.go @@ -213,9 +213,6 @@ func TestForm_EntryValidation_FirstTypeValid(t *testing.T) { w := test.NewWindow(form) defer w.Close() - assert.Equal(t, errFormItemInitialState, entry1.validationError) - assert.Equal(t, errFormItemInitialState, entry2.validationError) - test.AssertImageMatches(t, "form/validation_entry_first_type_initial.png", w.Canvas().Capture()) test.Type(entry1, "H") diff --git a/widget/testdata/entry/validator_not_empty_focused.xml b/widget/testdata/entry/validator_not_empty_focused.xml new file mode 100644 index 0000000000..44486a01c8 --- /dev/null +++ b/widget/testdata/entry/validator_not_empty_focused.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/widget/testdata/entry/validator_not_empty_initial.xml b/widget/testdata/entry/validator_not_empty_initial.xml new file mode 100644 index 0000000000..d0f109da87 --- /dev/null +++ b/widget/testdata/entry/validator_not_empty_initial.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/widget/testdata/entry/validator_not_empty_unfocused.xml b/widget/testdata/entry/validator_not_empty_unfocused.xml new file mode 100644 index 0000000000..e64db69c13 --- /dev/null +++ b/widget/testdata/entry/validator_not_empty_unfocused.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/widget/testdata/form/hint_invalid.png b/widget/testdata/form/hint_invalid.png index de16457c11..6fe6671471 100644 Binary files a/widget/testdata/form/hint_invalid.png and b/widget/testdata/form/hint_invalid.png differ diff --git a/widget/testdata/form/validation_entry_first_type_invalid.png b/widget/testdata/form/validation_entry_first_type_invalid.png index 2b613f3bf1..b887eb68ab 100644 Binary files a/widget/testdata/form/validation_entry_first_type_invalid.png and b/widget/testdata/form/validation_entry_first_type_invalid.png differ