Skip to content

Typed Channel Bindings

Andy Williams edited this page Apr 2, 2024 · 6 revisions

Note

This document is archived as it refers to a design process. This proposal was not implemented instead going for the data/binding package that is in Fyne v2.

Goal

When the data changes, update the UI and when the UI changes, update the data.

Outline

Settings, Theme, and Widgets have 'properties' which are exported binding fields. A goroutine can call listen on these properties and receive a typed channel from which updates can be read. When a binding changes, the update is written to all channels.

Previous Work

  • Reflection - set function fields in widget.
  • Concurrent Notification - each binding listener is notified in a newly created goroutine.
  • Updater Thread - use a single looper to ensure updates are ordered.

This proposal differs from the previous by removing the binding updater thread which iterated through changes and notifying the listeners. Instead each listener is a goroutine reading from a channel, when the binding changes it sends the data on each channel. This approach guarantees channels are written in the order they were registered, but does not guaranteed the listener executes in order. This approach ensures that renderer objects are only updated by a single thread and therefore don't require synchronization.

API

Binding

// Binding is the base interface of the Data Binding API.
type Binding interface {
	// Update notifies all listeners after a change.
	Update()
}

// Collection is a binding holding a collection of multiple bindings.
type Collection interface {
	Binding
	// Length returns the length of the collection.
	Length() int
	// Listen returns a channel through which collection updates will be published.
	Listen() <-chan int
	// OnUpdate calls the given function whenever the collection updates.
	OnUpdate(func(int))
}

// List defines a data binding wrapping a list of bindings.
type List interface {
	Collection
	// Get returns the binding at the given index.
	Get(int) Binding
}

// Map defines a data binding wrapping a list of bindings.
type Map interface {
	Collection
	// Get returns the binding for the given key.
	Get(string) (Binding, bool)
}

Type-Specific Binding

Primitive Bindings;

  • bool
  • float64
  • int
  • int64
  • rune
  • string
  • *url.URL

Fyne Bindings;

  • fyne.Position
  • fyne.Resource
  • fyne.Size
type Bool interface {
    Binding
    Get() bool
    GetRef() *bool
    Set(bool)
    SetRef(*bool)
    Listen() <-chan bool
    OnUpdate(func(bool))
}

func EmptyBool() Bool { ... }
func NewBool(value bool) Bool { ... }
func NewBoolRef(reference *bool) Bool { ... }

type BoolList interface {
    List
    GetBinding(int) Bool
    GetBool(int) bool
    GetRef(int) *bool
    SetBinding(int, Bool)
    SetBool(int, bool)
    SetRef(int, *bool)
    AddBinding(Bool)
    AddBool(bool)
    AddRef(*bool)
}

func NewBoolList(values ...bool) BoolList { ... }
func NewBoolListRefs(references *[]*bool) BoolList { ... }

A Binding holds a reference to the underlying data so it can either be changes through the Binding or elsewhere.

i := 5
ib := NewIntRef(&i)

ib.Set(6)

// Or

i = 6
ib.Update()

Changes

Deprecate WidgetRenderer.Refresh - instead Widget.CreateRenderer will start a goroutine that is stopped by WidgetRenderer.Destroy and will listen to each of the Widget's properties, perform the appropriate action when an update occurs, and call canvas.Refresh() to enqueue the widget for rendering. This means that as soon as a property is changed, the UI updates, however it has the downside that multiple properties changes results in the widget being enqueued for render multiple times.

Should CanvasObjects use Properties and CanvasObject.Refresh be deprecated too? - no because CanvasObjects don't have a renderer to listen to properties and trigger canvas.Refresh.

Coding Style Changes

Idiomatic Go does not put a "Get" prefix on accessor methods of unexported fields Effective Go (Getters). However properties must be exported and thus should either be named weirdly, or the accessors must start with "Get".

For example, a Widget has either;

  • a 'SizeProperty binding.Size' field and implements 'Size() fyne.Size' and 'SetSize(fyne.Size)'
  • a 'Size binding.Size' field and implements 'GetSize() fyne.Size' and 'SetSize(fyne.Size)'

Widgets

Button

Button displays Text and/or an Image and can be Tapped.

Definition

type Button struct {
    DisableableWidget
    ExtendableWidget
    HoverableWidget
    HideShadow binding.Bool
    Icon       binding.Resource
    Text       binding.String
    OnTapped   func()
}

Usage

i := binding.NewResource(theme.InfoIcon())
t := binding.NewString("Foo")

w := widget.Button{
    Icon:     i,
    Text:     t,
    OnTapped: func() {
        log.Println("Tapped!")
    },
}

go func() {
    time.Sleep(5 * time.Second)
    i.Set(theme.WarningIcon())
}()

go func() {
    time.Sleep(10 * time.Second)
    t.Set("Bar")
}()

Check

Check is a specialization of Button which can be toggled.

Definition

type Check struct {
    DisableableWidget
    ExtendableWidget
    FocusableWidget
    HoverableWidget
    Checked   binding.Bool
    Text      binding.String
    OnChanged func(bool)
}

Usage

t := binding.NewString("Foo")

w := widget.Check{
    Text:      t,
    OnChanged: func(c bool) {
        if c {
            log.Println("Checked!")
        } else {
            log.Println("Unchecked!")
        }
    },
}

go func() {
    time.Sleep(5 * time.Second)
    t.Set("Bar")
}()

go func() {
    time.Sleep(10 * time.Second)
    w.Checked.Set(true)
}()

Entry

Entry is a specialization of Label which supports editing.

Definition

Usage

Form

Form is a specialization of List which renders each item with a Label, and has function fields to Clear and Submit.

Group

Group is a specialization of List which renders a title and visual border.

Hyperlink

Hyperlink is a specialization of Label which opens a URL in the default browser when tapped.

Icon

Icon displays an image.

Label

Label displays text.

List

List displays a collection of items.

Definition

type List struct {
	ExtendableWidget
	DisableableWidget
	HoverableWidget
	Horizontal binding.Bool
	Items      binding.List
	Selected   binding.Int

	OnCreateCell func() fyne.CanvasObject
	OnBindCell   func(fyne.CanvasObject, binding.Binding)
	OnSelected   func(int)

	indexFirst   binding.Int
	indexHovered binding.Int
	indexLast    binding.Int
	offsetItem   binding.Int
	offsetScroll binding.Int
}

Usage

w := widget.NewList([]string{"1", "2", "3"}, func(index int) {
    fmt.Printf("selected: %d\n", index)
})
// If OnCreateCell is nil, a Label will be used.
// If OnBindCell is nil, and the cell is a Label, its Text will be bound.

w := &widget.List{
    Items: binding.NewStringList("https://fyne.io", "https://github.com/fyne-io"),
    OnCreateCell: func() {
        return &Hyperlink{}
    },
    OnBindCell: func(c fyne.CanvasObject, b binding.Binding) {
        hl, ok := c.(*Hyperlink)
        if ok {
            s, ok := b.(binding.String)
            if ok {
                hl.Text = s
                u, err := url.Parse(s)
                if err != nil {
                    fyne.LogError(fmt.Sprintf("Could not parse URL: %s", s), err)
                } else {
                    hl.URL = u
                }
            }
            hl.Color = theme.TextColor.Get()
            hl.TextSize = theme.TextSize.Get()
        }
    },
    OnSelected: func(index int) {
        fmt.Printf("selected: %d\n", index)
    },
}

Menu

Menu is a specialization of Tree which renders with Popups.

Pop Up

Progress Bar

Radio

Radio is a specialization of List which renders items with an icon and ensures only one element can be selected.

Definition

type Radio struct {
    List
}

Usage

w := &widget.Radio{
    Items: binding.NewStringList("A", "B", "C"),
    OnSelected: func(index int) {
        fmt.Printf("selected: %d\n", index)
    },
}

Select

Select is a specialization of List which renders the list in a popup when activated.

Definition

type Select struct {
    List
}

Usage

w := &widget.Select{
    Items: binding.NewStringList("A", "B", "C"),
    OnSelected: func(index int) {
        fmt.Printf("selected: %d\n", index)
    },
}

Table

Table is a specialization of List which renders each row as a List of columns.

Tree

Tree is a specialization of List which renders hierarchical data where each item is a List.

Custom Widget Template

type MyWidget struct {
    // Reuse existing Behaviour and Properties
    widget.DisableableWidget // Implements Disableable and provides Disabled Property.
    widget.ExtendableWidget  // Enables custom Widgets to extend existing Widgets.
    widget.FocusableWidget   // Implements Focusable and provides Focused Property.
    canvas.GeometricObject   // Implements CanvasObject and provides Size, Position, and Hidden Properties.
    desktop.HoverableWidget  // Implements Hoverable and provides Hovered Property.

    // Custom Behaviour and Properties
    Mode         binding.Int
    OnModeChange func(int)
}

// NewMyWidget creates a new widget with the mode and change handler
func NewMyWidget(mode int, changed func()) *MyWidget {
    w := &MyWidget{
        Mode:         binding.NewInt(mode),
        OnModeChange: changed,
    }
    w.ExtendWidget(w)
    return w
}

func (w *MyWidget) CreateRenderer() fyne.WidgetRenderer {
    w.ExtendWidget(w)

    // Ensure all Properties are set.
    if w.Disabled == nil {
        w.Disabled = binding.EmptyBool()
    }
    if w.Hidden == nil {
        w.Hidden = binding.EmptyBool()
    }
    if w.Hovered == nil {
        w.Hovered = binding.EmptyBool()
    }
    if w.Mode == nil {
        w.Mode = binding.EmptyInt()
    }
    if w.Position == nil {
        w.Position = binding.EmptyPosition()
    }
    if w.Size == nil {
        w.Size = binding.EmptySize()
    }
    if theme.TextColor == nil {
        theme.TextColor = binding.EmptyColor()
    }
    if theme.TextSize == nil {
        theme.TextSize = binding.EmptyInt()
    }

    // Create objects.
    label := &canvas.Text{}

    // Create Renderer.
    r := &myWidgetRenderer{
        myWidget: w,
        label:    label,
        done:     make(chan bool),
    }

    // Listen to all Property Channels.
    disabledChan := w.Disabled.Listen()
    hiddenChan := w.Hidden.Listen()
    hoveredChan := w.Hovered.Listen()
    modeChan := w.Mode.Listen()
    positionChan := w.Position.Listen()
    sizeChan := w.Size.Listen()
    themeTextColorChan := theme.TextColor.Listen()
    themeTextSizeChan := theme.TextSize.Listen()

    // Create goroutine to respond to changes, and trigger refresh.
    go func() {
        for {
            select {
            case d := <-disabledChan:
                log.Println("disabled:", d)
            case h := <-hiddenChan:
                log.Println("hidden:", h)
            case h := <-hoveredChan:
                log.Println("hovered:", h)
            case m := <-modeChan:
                log.Println("mode:", m)
                label.Text = fmt.Sprintf("Mode: %d", m) // Update Label when Mode Changes.
            case p := <-positionChan:
                log.Println("position:", p)
            case s := <-sizeChan:
                log.Println("size:", s)
            case c := <-themeTextColorChan:
                log.Println("textcolor:", c)
                label.Color = c // Update Label when Theme Color Changes.
            case s := <-themeTextSizeChan:
                log.Println("textsize:", s)
                label.TextSize = s // Update Label when Theme Text Size Changes.
            case <-r.done:
                return
            }
            // FIXME multiple updates can cause multiple layouts and renders.
            r.Layout(b.CurrentSize())
            canvas.Refresh(b.super())
        }
    }()

    return r
}

func (w *MyWidget) MinSize() fyne.Size {
    w.ExtendWidget(w)
    return w.ExtendableWidget.MinSize()
}

func (w *MyWidget) Tapped(*fyne.PointEvent) {
    if w.IsDisabled() {
        return
    }
    mode.Set(mode.Get()+1)
    if w.OnModeChange != nil {
        w.OnModeChange(mode.Get())
    }
}

type myWidgetRenderer struct {
    myWidget *MyWidget
    label    *canvas.Text
    done     chan bool
}

func (r *myWidgetRenderer) BackgroundColor() color.Color {
    if r.myWidget.IsHovered() {
        return theme.HoverColor.Get()
    }
    return theme.PrimaryColor.Get()
}

func (r *myWidgetRenderer) Destroy() {
    r.done <- true
}

func (r *myWidgetRenderer) Layout(size fyne.Size) {
    // ...
}

func (r *myWidgetRenderer) MinSize() fyne.Size {
    // ...
}

func (r *myWidgetRenderer) Objects() []CanvasObject {
    return []fyne.CanvasObject{r.label}
}

Welcome to the Fyne wiki.

Project documentation

Getting involved

Platform details

Clone this wiki locally