From 9209012be3691715db97b76ec2789db67ccb9285 Mon Sep 17 00:00:00 2001 From: Martyn R Date: Thu, 23 Dec 2021 12:16:16 +0000 Subject: [PATCH] New vumeter (numeter) based on Raster instead of boxes with z-level problems. --- examples/nuvumeter/main.go | 33 +++++++ pkg/numeter/bind_helper.go | 78 +++++++++++++++ pkg/numeter/numeter.go | 196 +++++++++++++++++++++++++++++++++++++ 3 files changed, 307 insertions(+) create mode 100644 examples/nuvumeter/main.go create mode 100644 pkg/numeter/bind_helper.go create mode 100644 pkg/numeter/numeter.go diff --git a/examples/nuvumeter/main.go b/examples/nuvumeter/main.go new file mode 100644 index 0000000..80cc483 --- /dev/null +++ b/examples/nuvumeter/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "fyne.io/fyne/v2/app" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/data/binding" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/widget" + "git.martyn.berlin/martyn/fyne-widgets/pkg/numeter" +) + +func main() { + a := app.New() + w := a.NewWindow("VUMeter") + level := binding.NewFloat() + levelPB := numeter.NewVUMeterWithData(level) + level.Set(0.95) + levelPB.TextFormatter = func() string { return " " } + progress := widget.NewProgressBarWithData(level) + setLevel := binding.NewFloat() + setLevel.Set(75.0) + slider := widget.NewSliderWithData(0.0, 100.0, setLevel) + slider.OnChanged = func(data float64) { + level.Set(data / 100) + } + + vLevelPB := numeter.NewVUMeterWithData(level) + vLevelPB.VUMeterDirection = numeter.VUMeterVertical + levelPB.TextFormatter = func() string { return " " } + contain := container.New(layout.NewVBoxLayout(), levelPB, progress, slider) + w.SetContent(container.New(layout.NewHBoxLayout(), contain, vLevelPB)) + w.ShowAndRun() +} diff --git a/pkg/numeter/bind_helper.go b/pkg/numeter/bind_helper.go new file mode 100644 index 0000000..f2858ca --- /dev/null +++ b/pkg/numeter/bind_helper.go @@ -0,0 +1,78 @@ +package numeter + +import ( + "sync" + + "fyne.io/fyne/v2/data/binding" +) + +// basicBinder stores a DataItem and a function to be called when it changes. +// It provides a convenient way to replace data and callback independently. +type basicBinder struct { + callbackLock sync.RWMutex + callback func(binding.DataItem) // access guarded by callbackLock + dataListenerPairLock sync.RWMutex + dataListenerPair annotatedListener // access guarded by dataListenerPairLock +} + +// Bind replaces the data item whose changes are tracked by the callback function. +func (binder *basicBinder) Bind(data binding.DataItem) { + listener := binding.NewDataListener(func() { // NB: listener captures `data` but always calls the up-to-date callback + binder.callbackLock.RLock() + f := binder.callback + binder.callbackLock.RUnlock() + if f != nil { + f(data) + } + }) + data.AddListener(listener) + listenerInfo := annotatedListener{ + data: data, + listener: listener, + } + + binder.dataListenerPairLock.Lock() + binder.unbindLocked() + binder.dataListenerPair = listenerInfo + binder.dataListenerPairLock.Unlock() +} + +// CallWithData passes the currently bound data item as an argument to the +// provided function. +func (binder *basicBinder) CallWithData(f func(data binding.DataItem)) { + binder.dataListenerPairLock.RLock() + data := binder.dataListenerPair.data + binder.dataListenerPairLock.RUnlock() + f(data) +} + +// SetCallback replaces the function to be called when the data changes. +func (binder *basicBinder) SetCallback(f func(data binding.DataItem)) { + binder.callbackLock.Lock() + binder.callback = f + binder.callbackLock.Unlock() +} + +// Unbind requests the callback to be no longer called when the previously bound +// data item changes. +func (binder *basicBinder) Unbind() { + binder.dataListenerPairLock.Lock() + binder.unbindLocked() + binder.dataListenerPairLock.Unlock() +} + +// unbindLocked expects the caller to hold dataListenerPairLock. +func (binder *basicBinder) unbindLocked() { + previousListener := binder.dataListenerPair + binder.dataListenerPair = annotatedListener{nil, nil} + + if previousListener.listener == nil || previousListener.data == nil { + return + } + previousListener.data.RemoveListener(previousListener.listener) +} + +type annotatedListener struct { + data binding.DataItem + listener binding.DataListener +} diff --git a/pkg/numeter/numeter.go b/pkg/numeter/numeter.go new file mode 100644 index 0000000..b57d5b3 --- /dev/null +++ b/pkg/numeter/numeter.go @@ -0,0 +1,196 @@ +package numeter + +import ( + "image" + "image/color" + "math" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/data/binding" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" +) + +type vuRenderer struct { + currentframe *canvas.Raster + meter *vuMeter +} + +func (v *vuRenderer) MinSize() fyne.Size { + var tsize fyne.Size + if text := v.meter.TextFormatter; text != nil { + tsize = fyne.MeasureText(text(), theme.TextSize(), fyne.TextStyle{false, false, false, 4}) + } else { + tsize = fyne.MeasureText("100%", theme.TextSize(), fyne.TextStyle{false, false, false, 4}) + } + if v.meter.VUMeterDirection == VUMeterVertical { + oldsize := tsize + tsize.Width = oldsize.Height + tsize.Height = oldsize.Width + } + return fyne.NewSize(tsize.Width+theme.Padding()*4, tsize.Height+theme.Padding()*2) +} + +func vuSet(i *image.RGBA, w int, h int, o VUMeterDirectionEnum, val float64, colGrn color.Color, colAmber color.Color, colRed color.Color) { + valPixels := int(math.Round(float64(w) * val)) + long := w + short := h + if o == VUMeterVertical { + valPixels = int(math.Round(float64(h) * val)) + long = h + short = w + } + for l := 0; l < valPixels; l++ { + c := colGrn + if float64(l)/float64(long) > 0.75 { + c = colAmber + } + if float64(l)/float64(long) > 0.85 { + c = colRed + } + for s := 0; s < short; s++ { + if o == VUMeterVertical { + i.Set(s, h-l, c) + } else { + i.Set(l, s, c) + } + } + } +} + +func (v *vuRenderer) Render(w int, h int) image.Image { + i := image.NewRGBA(image.Rect(0, 0, w, h)) + g := color.RGBA{0, 0x1f, 0, 0xff} + a := color.RGBA{0x1f, 0x1f, 0, 0xff} + r := color.RGBA{0x1f, 0, 0, 0xff} + vuSet(i, w, h, v.meter.VUMeterDirection, 1, g, a, r) + g = color.RGBA{0, 0xff, 0, 0xff} + a = color.RGBA{0xff, 0xff, 0, 0xff} + r = color.RGBA{0xff, 0, 0, 0xff} + val := (v.meter.Value - v.meter.Min) / (v.meter.Max - v.meter.Min) * 100 + vuSet(i, w, h, v.meter.VUMeterDirection, val, g, a, r) + return i +} + +// Layout the components of the widget +func (v *vuRenderer) Layout(size fyne.Size) { + v.currentframe.Resize(size) + v.currentframe.Refresh() +} + +// ApplyTheme is called when the vuMeter may need to update it's look +func (v *vuRenderer) ApplyTheme() { + v.Refresh() +} + +func (v *vuRenderer) BackgroundColor() color.Color { + return theme.ButtonColor() +} + +func (v *vuRenderer) Refresh() { + v.Layout(v.meter.Size()) + canvas.Refresh(v.meter) +} + +func (v *vuRenderer) Objects() []fyne.CanvasObject { + return []fyne.CanvasObject{v.currentframe} +} + +func (v *vuRenderer) Destroy() { +} + +type VUMeterDirectionEnum uint32 + +const ( + VUMeterVertical = iota + VUMeterHorizontal +) + +// vuMeter widget is a kind of custom progressbar but has "zones" of different color for peaking. +type vuMeter struct { + widget.BaseWidget + TextFormatter func() string + Value, Min, Max, + OptimumValueMin, OptimumValueMax float64 + VUMeterDirection VUMeterDirectionEnum + + binder basicBinder +} + +func NewVUMeterRenderer(m *vuMeter) *vuRenderer { + c := canvas.NewRaster(func(w int, h int) image.Image { return image.NewNRGBA(image.Rect(0, 0, 200, 200)) }) + renderer := vuRenderer{c, m} + c.Generator = renderer.Render + renderer.currentframe = c + + return &renderer +} + +func (m *vuMeter) Resize(s fyne.Size) { + m.BaseWidget.Resize(s) +} + +func (m *vuMeter) CreateRenderer() fyne.WidgetRenderer { + return NewVUMeterRenderer(m) +} + +// SetValue changes the current value of this progress bar (from p.Min to p.Max). +// The widget will be refreshed to indicate the change. +func (m *vuMeter) SetValue(v float64) { + m.Value = v + m.Refresh() +} + +func (m *vuMeter) updateFromData(data binding.DataItem) { + if data == nil { + return + } + floatSource, ok := data.(binding.Float) + if !ok { + return + } + + val, err := floatSource.Get() + if err != nil { + fyne.LogError("Error getting current data value", err) + return + } + m.SetValue(val) +} + +func (m *vuMeter) MinSize() fyne.Size { + m.ExtendBaseWidget(m) + return m.BaseWidget.MinSize() +} + +func (m *vuMeter) Bind(data binding.Float) { + m.binder.SetCallback(m.updateFromData) + m.binder.Bind(data) +} + +func (m *vuMeter) Unbind() { + m.binder.Unbind() +} + +// NewVUMeter creates a new meter widget with the specified value +func NewVUMeter(value float64) *vuMeter { + meter := &vuMeter{Value: value} + meter.OptimumValueMin = 75 + meter.OptimumValueMax = 85 + meter.Min = 0 + meter.Max = 100 + meter.ExtendBaseWidget(meter) + meter.VUMeterDirection = VUMeterHorizontal + return meter +} + +func NewVUMeterWithData(data binding.Float) *vuMeter { + f, err := data.Get() + if err != nil { + f = 25.0 + } + m := NewVUMeter(f) + m.Bind(data) + return m +}