Last active
December 14, 2023 16:21
-
-
Save piratecarrot/3be6e4e5abbf08641078642c187744dc to your computer and use it in GitHub Desktop.
GoLang Fyne spinner widget
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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:
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.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.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:
spinner.entry, spinner.buttonUp and spinner.buttonDown