~whereswaldon/public-inbox

This thread contains a patchset. You're looking at the original emails, but you may wish to use the patch review UI. Review patch
2 2

[PATCH gio-x 0/1] form abstraction

~jackmordaunt
Details
Message ID
<162011776183.24949.391456120741957828-0@git.sr.ht>
DKIM signature
missing
Download raw message
Implement form primitives. API is designed around specifying form fields
exactly once. There's 3 core concepts: `Form`, `Value` and `Input`. A
`Field` binds a `Value` to an `Input`.

- `Form` processes groups of
fields in a consistent fashion, providing batch and real time
validation.
- `Value` abstracts data transformations (to and from text
to structured data).
- `Input` abstracts the interactive graphical
widget that accepts user input. 

The API is as declarative as I could
achieve in a first pass, much more concise than gluing together
individual form fields.
Ideally I would like a fully declarative API
wherein the zero-value of a form could be used, but I'm not sure how to
achieve this. Thus the API is "experimental" but "useful". 

Possibly
code generation or reflection could be used to achieve a zero-value form
API.

Read package documentation for more.

Jack Mordaunt (1):
  feat: form implementation

 form/README.md         |  75 ++++++++++++++++++++++++
 form/doc.go            |  58 +++++++++++++++++++
 form/form.go           | 126 +++++++++++++++++++++++++++++++++++++++++
 form/go.mod            |   3 +
 form/internal/parse.go |  80 ++++++++++++++++++++++++++
 form/value/value.go    | 125 ++++++++++++++++++++++++++++++++++++++++
 6 files changed, 467 insertions(+)
 create mode 100644 form/README.md
 create mode 100644 form/doc.go
 create mode 100644 form/form.go
 create mode 100644 form/go.mod
 create mode 100644 form/internal/parse.go
 create mode 100644 form/value/value.go

-- 
2.30.2

[PATCH gio-x 1/1] feat: form implementation

~jackmordaunt
Details
Message ID
<162011776183.24949.391456120741957828-1@git.sr.ht>
In-Reply-To
<162011776183.24949.391456120741957828-0@git.sr.ht> (view parent)
DKIM signature
missing
Download raw message
Patch: +467 -0
From: Jack Mordaunt <jackmordaunt@gmail.com>

Signed-off-by: Jack Mordaunt <jackmordaunt@gmail.com>
---
 form/README.md         |  75 ++++++++++++++++++++++++
 form/doc.go            |  58 +++++++++++++++++++
 form/form.go           | 126 +++++++++++++++++++++++++++++++++++++++++
 form/go.mod            |   3 +
 form/internal/parse.go |  80 ++++++++++++++++++++++++++
 form/value/value.go    | 125 ++++++++++++++++++++++++++++++++++++++++
 6 files changed, 467 insertions(+)
 create mode 100644 form/README.md
 create mode 100644 form/doc.go
 create mode 100644 form/form.go
 create mode 100644 form/go.mod
 create mode 100644 form/internal/parse.go
 create mode 100644 form/value/value.go

diff --git a/form/README.md b/form/README.md
new file mode 100644
index 0000000..f09fdb4
--- /dev/null
+++ b/form/README.md
@@ -0,0 +1,75 @@
# form

[![Go Reference](https://pkg.go.dev/badge/gioui.org/x/form.svg)](https://pkg.go.dev/gioui.org/x/form)

Primitives for specifying forms.

The fundamental idea explored here is in separating form concerns.
The three core abstractions are the `Form`, the `Value` and the `Input`.
`Value` and `Input` combine together into a `Field`.

- `Input` abstracts the interactible graphical widget that accepts input.
- `Value` abstracts the transformation of text to structured data.
- `Field` maps an `Input` to a `Value`.
- `Form` provides a consistent api for handling a group of fields, namely batch and realtime validation.

## Status

This repo is experimental, and does not have a stable interface.

Ideally form initialization would be fully declarative, where the zero value is directly usable.
However it's not clear how to achieve such an api.

Some potential avenues to explore:

- code generation: take a struct definition and generate the form code.
- reflection: introspect a struct definition and generate `form.Field`s on the fly.

## Use

Since the api is experimental, there is no "idiomatic usage".

Fundamentally, a form binds inputs to values and there are numerous ways to compose it.

This is an example of one way to use the package: one-time initialization that you can call in an
update function once per frame.

```go
type Person struct {
    Age    int
    Name   string
    Salary float64
}

type PersonForm struct {
    form.Form
    // Model contains the structured data.
    Model Person
    // Inputs contains the input state.
    Inputs struct {
        Age    component.TextField
        Name   component.TextField
        Salary component.TextField
    }
    init sync.Once
}

func (pf *PersonForm) Update() {
    pf.init.Do(func() {
        pf.Form.Load([]form.Field{
            {
                Value: value.Int{Value: &pf.Model.Age, Default: 18},
                Input: &pf.Inputs.Age,
            },
            {
                Value: value.Required{value.Text{Value: &pf.Model.Name, Default: ""}},
                Input: &pf.Inputs.Name,
            },
            {
                Value: value.Float{Value: &pf.Model.Salary, Default: 0},
                Input: &pf.Inputs.Salary,
            },
        })
    })
}
```
diff --git a/form/doc.go b/form/doc.go
new file mode 100644
index 0000000..ae9fd37
--- /dev/null
+++ b/form/doc.go
@@ -0,0 +1,58 @@
/*
Package form implements primitives that reduce form boilerplate by allowing the caller to specify
their fields exactly once.

All values are processed via a chain of transformations that map text into a structured value, and
visa versa. Each transformation is encapsulated in a `form.Value` implementation, for instance a
`value.Int` will transform text into a Go integer and signal any errors that occur during that
transformation.

Forms are initialized once with all the fields via a call to `form.Load`.
Each field binds an input to a value.

By contention, value objects depend on pointer variables, this means you can simply point into a
predefined "model" struct. Once the form is submitted, the model will contain the validated values
ready to use. However this is only a convention, a value object can arbitrarily handle it's internal
state.

The following is an example of one way to use the form:

	type Person struct {
		Age    int
		Name   string
		Salary float64
	}

	type PersonForm struct {
		form.Form
		// Model contains the structured data.
		Model Person
		// Inputs contains the input state.
		Inputs struct {
			Age    component.TextField
			Name   component.TextField
			Salary component.TextField
		}
		init sync.Once
	}

	func (pf *PersonForm) Update() {
		pf.init.Do(func() {
			pf.Form.Load([]form.Field{
				{
					Value: value.Int{Value: &pf.Model.Age, Default: 18},
					Input: &pf.Inputs.Age,
				},
				{
					Value: value.Required{value.Text{Value: &pf.Model.Name, Default: ""}},
					Input: &pf.Inputs.Name,
				},
				{
					Value: value.Float{Value: &pf.Model.Salary, Default: 0},
					Input: &pf.Inputs.Salary,
				},
			})
		})
	}
*/
package form
diff --git a/form/form.go b/form/form.go
new file mode 100644
index 0000000..4f2b6a5
--- /dev/null
+++ b/form/form.go
@@ -0,0 +1,126 @@
package form

// Value implements a bi-directional mapping between textual data and structured data. Value handles
// data validation, which is expresssed by the error return.
type Value interface {
	// To converts the value into textual form.
	To() (text string, err error)
	// From parses the value from textual form.
	From(text string) (err error)
	// Clear resets the value.
	Clear()
}

// Input objects display text, and handle user input events.
// Input will typically be implemented by a graphic widget, such as the
// `gioui.org/x/component#TextField`.
type Input interface {
	Text() string
	SetText(string)
	SetError(string)
	ClearError()
}

// Field binds a Value to an Input.
type Field struct {
	Value Value
	Input Input
}

// Validate the field by running the text through the Valuer.
// Precise validation logic is implemented by the Valuer.
// Returns a boolean indicating success.
func (field *Field) Validate() bool {
	err := field.Value.From(field.Input.Text())
	if err != nil {
		field.Input.SetError(err.Error())
	} else {
		field.Input.ClearError()
	}
	return err == nil
}

// Form exercises field bindings.
//
// There's two primary ways of using a Form:
// 1. Realtime Validation
// 2. Batch Validation
//
// Realtime validation, expressed by `Form.Validate`, processes all non-zero and changed fields.
// That is, they must have a value and that value must be different since the last validation.
//
// Batch validation, expressed by `Form.Submit`, processes _all_ fields including zero-value fields.
//
// The semantic difference is that an unsubmitted zero-value input is not in an errored state
// because the user hasn't input a value yet. If you attempt to submit that zero-value input then it
// submission error and the field is now in an errored state.
//
// Realtime validation is useful for providing fast feedback on input events. You can create a
// `form.Value` that maps to some complex data source. For example, you can run queries on the fly
// to figure out if an entry exists as the user is typing.
//
// Batch validation is useful for quickly testing if the whole form is valid before using the field
// data.
//
// `form.Submit` must be called to synchronize field values.
// If not called, the values stored in each `form.Value` could be different to what is displayed in
// the graphical input.
type Form struct {
	Fields []Field
	// cache contains the previous contents of each field to detect changes.
	cache []string
}

// Load values into inputs.
func (f *Form) Load(fields []Field) {
	if len(fields) > 0 {
		f.Fields = fields
	}
	f.cache = make([]string, len(fields))
	for ii, field := range f.Fields {
		if text, err := field.Value.To(); err != nil {
			field.Input.SetError(err.Error())
		} else {
			f.cache[ii] = field.Input.Text()
			field.Input.ClearError()
			field.Input.SetText(text)
		}
	}
}

// Submit batch validates the fields and returns a boolean indication success.
// If true, all the fields validated and you can use the data.
func (f *Form) Submit() (ok bool) {
	ok = true
	for _, field := range f.Fields {
		if !field.Validate() {
			ok = false
		}
	}
	return ok
}

// Validate form fields.
// Can be used per frame for realtime validation.
func (f *Form) Validate() {
	for ii, field := range f.Fields {
		var (
			text    = field.Input.Text()
			changed = f.cache[ii] != text
		)
		if changed {
			f.cache[ii] = text
			field.Validate()
		}
	}
}

func (f *Form) Clear() {
	for ii, field := range f.Fields {
		field.Value.Clear()
		text, _ := field.Value.To()
		field.Input.ClearError()
		field.Input.SetText(text)
		f.cache[ii] = text
	}
}
diff --git a/form/go.mod b/form/go.mod
new file mode 100644
index 0000000..eca2078
--- /dev/null
+++ b/form/go.mod
@@ -0,0 +1,3 @@
module gioui.org/x/form

go 1.16
diff --git a/form/internal/parse.go b/form/internal/parse.go
new file mode 100644
index 0000000..112f156
--- /dev/null
+++ b/form/internal/parse.go
@@ -0,0 +1,80 @@
package internal

import (
	"fmt"
	"strconv"
	"strings"
	"time"
)

// ParseInt parses an integer from digit characters.
func ParseInt(s string) (int, error) {
	n, err := strconv.Atoi(s)
	if err != nil {
		return 0, fmt.Errorf("must be a valid number")
	}
	return n, nil
}

// ParseFloat parses a floating point number from digit characters.
func ParseFloat(s string) (float64, error) {
	n, err := strconv.ParseFloat(s, 64)
	if err != nil {
		return 0, fmt.Errorf("must be a valid number")
	}
	return n, nil
}

// ParseInt parses an unsigned integer from digit characters.
func ParseUint(s string) (uint, error) {
	n, err := strconv.Atoi(s)
	if err != nil {
		return 0, fmt.Errorf("must be a valid number")
	} else if n < 1 {
		return 0, fmt.Errorf("must be an amount greater than 0")
	}
	return uint(n), nil
}

// ParseDay parses a day from digit characters.
func ParseDay(s string) (time.Duration, error) {
	n, err := ParseUint(s)
	if err != nil {
		return time.Duration(0), err
	}
	return time.Hour * 24 * time.Duration(n), nil
}

// FieldRequired ensures that a string is not empty.
func FieldRequired(s string) (string, error) {
	if strings.TrimSpace(s) == "" {
		return "", fmt.Errorf("required")
	}
	return s, nil
}

// FormatTime formats a time object into a string.
func FormatTime(t time.Time) string {
	return fmt.Sprintf("%d/%d/%d", t.Day(), t.Month(), t.Year())
}

// ParseDate parses a time object from a textual dd/mm/yyyy format.
func ParseDate(s string) (date time.Time, err error) {
	parts := strings.Split(s, "/")
	if len(parts) != 3 {
		return date, fmt.Errorf("must be dd/mm/yyyy")
	}
	year, err := strconv.Atoi(parts[2])
	if err != nil {
		return date, fmt.Errorf("year not a number: %s", parts[2])
	}
	month, err := strconv.Atoi(parts[1])
	if err != nil {
		return date, fmt.Errorf("month not a number: %s", parts[2])
	}
	day, err := strconv.Atoi(parts[0])
	if err != nil {
		return date, fmt.Errorf("day not a number: %s", parts[2])
	}
	return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.Local), nil
}
diff --git a/form/value/value.go b/form/value/value.go
new file mode 100644
index 0000000..f687a1d
--- /dev/null
+++ b/form/value/value.go
@@ -0,0 +1,125 @@
// Package value defines some `form.Value` implementations for common data types.
package value

import (
	"fmt"
	"strconv"
	"strings"
	"time"

	"gioui.org/x/form"
	"gioui.org/x/form/internal"
)

// Int maps text to an integer number.
type Int struct {
	Value   *int
	Default int
}

func (v Int) To() (string, error) {
	var n = *v.Value
	if n == 0 {
		n = v.Default
	}
	return strconv.Itoa(n), nil
}

func (v Int) From(text string) (err error) {
	*v.Value, err = internal.ParseInt(text)
	return err
}

func (v Int) Clear() {
	*v.Value = 0
}

// Float maps text to a floating point number.
type Float struct {
	Value *float64
}

func (v Float) To() (string, error) {
	return strconv.FormatFloat(*v.Value, 'f', 2, 64), nil
}

func (v Float) From(text string) (err error) {
	*v.Value, err = internal.ParseFloat(text)
	return err
}

func (v Float) Clear() {
	*v.Value = 0
}

// Text wraps a text value.
type Text struct {
	Value *string
}

func (v Text) To() (string, error) {
	return *v.Value, nil
}

func (v Text) From(text string) error {
	*v.Value = text
	return nil
}

func (v Text) Clear() {
	*v.Value = ""
}

// Days maps text to 24 hour units of time.
type Days struct {
	Value *time.Duration
}

func (v Days) To() (string, error) {
	days := (*v.Value) / (time.Hour * 24)
	return strconv.Itoa(int(days)), nil
}

func (v Days) From(text string) (err error) {
	*v.Value, err = internal.ParseDay(text)
	return err
}

func (v Days) Clear() {
	*v.Value = time.Hour * 24
}

// Required errors when the field is empty.
type Required struct {
	form.Value
}

func (v Required) From(text string) error {
	if len(strings.TrimSpace(text)) == 0 {
		return fmt.Errorf("required")
	}
	return v.Value.From(text)
}

// Date maps text to a structured date.
type Date struct {
	Value   *time.Time
	Default time.Time
}

func (v Date) To() (string, error) {
	var t = *v.Value
	if t.IsZero() {
		t = v.Default
	}
	return fmt.Sprintf("%d/%d/%d", t.Day(), t.Month(), t.Year()), nil
}

func (v Date) From(text string) (err error) {
	*v.Value, err = internal.ParseDate(text)
	return err
}

func (v Date) Clear() {
	*v.Value = time.Now()
}
-- 
2.30.2

[gio-x/patches/linux.yml] build success

builds.sr.ht
Details
Message ID
<CB4BUCML6OFB.2C1N9RKYF45MI@cirno2>
In-Reply-To
<162011776183.24949.391456120741957828-1@git.sr.ht> (view parent)
DKIM signature
missing
Download raw message
gio-x/patches/linux.yml: SUCCESS in 1m16s

[form abstraction][0] from [~jackmordaunt][1]

[0]: https://lists.sr.ht/~whereswaldon/public-inbox/patches/22507
[1]: mailto:jackmordaunt@gmail.com

✓ #500738 SUCCESS gio-x/patches/linux.yml https://builds.sr.ht/~whereswaldon/job/500738
Reply to thread Export thread (mbox)