Skip to content

Instantly share code, notes, and snippets.

@piratecarrot
Last active December 14, 2023 16:21
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save piratecarrot/3be6e4e5abbf08641078642c187744dc to your computer and use it in GitHub Desktop.
Save piratecarrot/3be6e4e5abbf08641078642c187744dc to your computer and use it in GitHub Desktop.
GoLang Fyne spinner widget
package uisim
import (
"fmt"
"image/color"
"reflect"
"strconv"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/data/binding"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
// extend entry to detect enter / and scrolling
// and propergate to spinner widget
type customEntry struct {
widget.Entry
onEnter func()
onScrolled func(s *fyne.ScrollEvent)
}
func newCustomEntry() *customEntry {
customEntry := &customEntry{}
customEntry.ExtendBaseWidget(customEntry)
return customEntry
}
func (e *customEntry) KeyDown(key *fyne.KeyEvent) {
if key.Name == fyne.KeyReturn {
if e.onEnter != nil {
e.onEnter()
}
} else {
e.Entry.KeyDown(key)
}
}
func (e *customEntry) Scrolled(s *fyne.ScrollEvent) {
if e.onScrolled != nil {
e.onScrolled(s)
}
}
type binderInt[V int | float64] interface {
binding.DataItem
Set(V) error
Get() (V, error)
}
type Spinner[V int | float64] struct {
fyne.Container
value V
Min V
Max V
Step V
format string
startVal V
binder binderInt[V]
parseEntry func()
buttonUp *widget.Button
buttonDown *widget.Button
entry *customEntry
isInt bool
isFloat bool
}
func NewSpinner[V int | float64](minVal, maxVal, step V) *Spinner[V] {
return newSpinnerImpl(minVal, maxVal, step, "")
}
func NewSpinnerWithFormat[V int | float64](minVal, maxVal, step V, format string) *Spinner[V] {
return newSpinnerImpl(minVal, maxVal, step, format)
}
func NewSpinnerWithData[V int | float64](minVal, maxVal, step V, data binderInt[V]) *Spinner[V] {
s := NewSpinner(minVal, maxVal, step)
s.binder = data
s.binder.AddListener(binding.NewDataListener(func() {
v, _ := data.Get()
s.SetValue(v)
}))
return s
}
func NewSpinnerWithDataAndFormat[V int | float64](minVal, maxVal, step V, data binderInt[V], format string) *Spinner[V] {
s := NewSpinnerWithFormat(minVal, maxVal, step, format)
s.binder = data
s.binder.AddListener(binding.NewDataListener(func() {
v, _ := data.Get()
s.SetValue(v)
}))
return s
}
func newSpinnerImpl[V int | float64](minVal, maxVal, step V, format string) *Spinner[V] {
k := reflect.TypeOf(minVal).Kind()
buttonUp := widget.NewButtonWithIcon("", theme.MenuDropUpIcon(), func() {})
buttonDown := widget.NewButtonWithIcon("", theme.MenuDropDownIcon(), func() {})
updown := container.New(layout.NewHBoxLayout(), buttonUp, buttonDown)
entry := newCustomEntry()
nip := &Spinner[V]{
buttonUp: buttonUp,
buttonDown: buttonDown,
entry: entry,
Min: minVal,
Max: maxVal,
value: min(max(minVal, V(1)), maxVal),
startVal: min(max(minVal, V(1)), maxVal),
Step: step,
format: format,
isInt: k == reflect.Int64,
isFloat: k == reflect.Float64,
}
if nip.format == "" {
if nip.isInt {
nip.format = "%d"
} else {
nip.format = "%.3f"
}
}
if nip.isInt {
nip.parseEntry = nip.parseEntryInt
} else {
nip.parseEntry = nip.parseEntryFloat
}
buttonDown.OnTapped = nip.onDown
buttonUp.OnTapped = nip.onUp
//entry.OnChanged = nip.onTextChanged
entry.onEnter = nip.onEnter
entry.onScrolled = nip.onScrolled
nip.Layout = layout.NewBorderLayout(nil, nil, nil, updown)
nip.Add(updown)
nip.Add(entry)
nip.updateVal()
return nip
}
func (spinner *Spinner[V]) Value() V {
spinner.parseEntry()
return spinner.value
}
func (spinner *Spinner[V]) SetValue(value V) {
spinner.value = value
spinner.updateVal()
}
func (spinner *Spinner[V]) onEnter() {
spinner.parseEntry()
}
func (spinner *Spinner[V]) parseEntryInt() {
if f, err := strconv.ParseInt(spinner.entry.Text, 10, 32); err != nil {
spinner.value = spinner.startVal
} else {
spinner.value = min(max(spinner.Min, V(f)), spinner.Max)
}
spinner.updateVal()
}
func (spinner *Spinner[V]) parseEntryFloat() {
if f, err := strconv.ParseFloat(spinner.entry.Text, 64); err != nil {
spinner.value = spinner.startVal
} else {
spinner.value = min(max(spinner.Min, V(f)), spinner.Max)
}
spinner.updateVal()
}
func (spinner *Spinner[V]) onScrolled(e *fyne.ScrollEvent) {
if e.Scrolled.DY != 0 {
spinner.value += spinner.Step * sgn(V(e.Scrolled.DY))
spinner.updateVal()
}
}
func (spinner *Spinner[V]) onUp() {
spinner.parseEntry()
spinner.value += spinner.Step
spinner.updateVal()
}
func (spinner *Spinner[V]) onDown() {
spinner.parseEntry()
spinner.value -= spinner.Step
spinner.updateVal()
}
func (spinner *Spinner[V]) updateVal() {
spinner.value = min(max(spinner.Min, spinner.value), spinner.Max)
spinner.entry.SetText(fmt.Sprintf(spinner.format, spinner.value))
if spinner.value <= spinner.Min {
spinner.buttonDown.Disable()
} else {
spinner.buttonDown.Enable()
}
if spinner.value >= spinner.Max {
spinner.buttonUp.Disable()
} else {
spinner.buttonUp.Enable()
}
if spinner.binder != nil {
spinner.binder.Set(spinner.value)
}
}
func (spinner *Spinner[V]) CreateRenderer() fyne.WidgetRenderer {
return &spinnerRenderer[V]{
spinner: spinner,
}
}
type spinnerRenderer[V int | float64] struct {
spinner *Spinner[V]
}
func (renderer *spinnerRenderer[V]) Layout(size fyne.Size) {
renderer.spinner.Layout.Layout(renderer.spinner.Objects, size)
}
func (renderer *spinnerRenderer[V]) MinSize() fyne.Size {
return renderer.spinner.MinSize()
}
func (renderer *spinnerRenderer[V]) Refresh() {
renderer.spinner.Refresh()
}
func (renderer *spinnerRenderer[V]) BackgroundColor() color.Color {
return theme.BackgroundColor()
}
func (renderer *spinnerRenderer[V]) Objects() []fyne.CanvasObject {
return renderer.spinner.Objects
}
func (renderer *spinnerRenderer[V]) Destroy() {
}
// Generic math functions
func sgn[V int | float64](a V) V {
switch {
case a < 0:
return -1
case a > 0:
return +1
}
return 0
}
func min[V int | float64](a V, b V) V {
if a < b {
return a
}
return b
}
func max[V int | float64](a V, b V) V {
if a > b {
return a
}
return b
}
@lordofscripts
Copy link

lordofscripts commented Dec 14, 2023

I don't know why they removed the Spinner from x/fyne when it is something so useful! I have been playing with this and...

Issues:

  • When NewSpinner[int]() is given 0 as minimum, it starts with 1 instead. Problem seems to be in lines #114 & #115 where you use V(1) which will always be greater than the chosen user minimum if it is less than one.
  • Both NewSpinner[int]() and NewSpinnerWithData[int] (default format) display a format error in the custom entry. The problem lies in lines #130 to #134 where it doesn't properly detect an int and tries for format an int as a float64.
  • The entry updateVal() is erroneous when using HEX formats. For example using the range [0..15] with format "%x" works fine between 0..10 with 10 displaying an "a", however, going up any more rather than upping to 11 ("b" in hex) rewinds to 1 again. That's because despite the hex format (base 16) parseEntryInt() line #163 assumes base 10 (decimal)

Nice to have:

  • That the Up/Down buttons are on top of each other (VBox) but with VBox having the same height as the adjacent custom Entry. However, Fyne seems to shoot itself in the foot making some things more difficult than they should. The original OP poster appeared to have done it but I could not find his revised code anywhere. How could that be done?
  • Ability to Enable and Disable the entire Spinner widget. Easily done by adding the corresponding methods that operate on spinner.entry, spinner.buttonUp and spinner.buttonDown
  • Spinner for alphabet letters

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment