Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New Activity widget :) #4262

Merged
merged 8 commits into from
Sep 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion cmd/fyne_demo/tutorials/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ var (
makeAccordionTab,
true,
},
"activity": {"Activity",
"A spinner indicating activity used in buttons etc.",
makeActivityTab,
true,
},
"button": {"Button",
"Simple widget for user tap handling.",
makeButtonTab,
Expand Down Expand Up @@ -182,6 +187,6 @@ var (
"": {"welcome", "canvas", "animations", "icons", "widgets", "collections", "containers", "dialogs", "windows", "binding", "advanced"},
"collections": {"list", "table", "tree", "gridwrap"},
"containers": {"apptabs", "border", "box", "center", "doctabs", "grid", "scroll", "split"},
"widgets": {"accordion", "button", "card", "entry", "form", "input", "progress", "text", "toolbar"},
"widgets": {"accordion", "activity", "button", "card", "entry", "form", "input", "progress", "text", "toolbar"},
}
)
50 changes: 50 additions & 0 deletions cmd/fyne_demo/tutorials/widget.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"fyne.io/fyne/v2/cmd/fyne_demo/data"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/data/validation"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/driver/mobile"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
Expand Down Expand Up @@ -55,6 +56,55 @@ func makeAccordionTab(_ fyne.Window) fyne.CanvasObject {
return ac
}

func makeActivityTab(win fyne.Window) fyne.CanvasObject {
a1 := widget.NewActivity()
a2 := widget.NewActivity()

var button *widget.Button
start := func() {
button.Disable()
a1.Start()
a1.Show()
a2.Start()
a2.Show()

defer func() {
go func() {
time.Sleep(time.Second * 10)
a1.Stop()
a1.Hide()
a2.Stop()
a2.Hide()

button.Enable()
}()
}()
}

button = widget.NewButton("Animate", start)
start()

return container.NewCenter(container.NewGridWithColumns(1,
container.NewCenter(container.NewVBox(
container.NewHBox(widget.NewLabel("Working..."), a1),
container.NewStack(button, a2))),
container.NewCenter(widget.NewButton("Show dialog", func() {
prop := canvas.NewRectangle(color.Transparent)
prop.SetMinSize(fyne.NewSize(50, 50))

a3 := widget.NewActivity()
d := dialog.NewCustomWithoutButtons("Please wait...", container.NewStack(prop, a3), win)
a3.Start()
d.Show()

go func() {
time.Sleep(time.Second * 5)
a3.Stop()
d.Hide()
}()
}))))
}

func makeButtonTab(_ fyne.Window) fyne.CanvasObject {
disabled := widget.NewButton("Disabled", func() {})
disabled.Disable()
Expand Down
9 changes: 5 additions & 4 deletions internal/painter/draw.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ const quarterCircleControl = 1 - 0.55228
// The bounds of the output image will be increased by vectorPad to allow for stroke overflow at the edges.
// The scale function is used to understand how many pixels are required per unit of size.
func DrawCircle(circle *canvas.Circle, vectorPad float32, scale func(float32) float32) *image.RGBA {
radius := fyne.Min(circle.Size().Width, circle.Size().Height) / 2
size := circle.Size()
radius := fyne.Min(size.Width, size.Height) / 2

width := int(scale(circle.Size().Width + vectorPad*2))
height := int(scale(circle.Size().Height + vectorPad*2))
width := int(scale(size.Width + vectorPad*2))
height := int(scale(size.Height + vectorPad*2))
stroke := scale(circle.StrokeWidth)

raw := image.NewRGBA(image.Rect(0, 0, width, height))
scanner := rasterx.NewScannerGV(int(circle.Size().Width), int(circle.Size().Height), raw, raw.Bounds())
scanner := rasterx.NewScannerGV(int(size.Width), int(size.Height), raw, raw.Bounds())

if circle.FillColor != nil {
filler := rasterx.NewFiller(width, height, scanner)
Expand Down
83 changes: 79 additions & 4 deletions internal/painter/gl/draw.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,65 @@ func (p *painter) defineVertexArray(prog Program, name string, size, stride, off
func (p *painter) drawCircle(circle *canvas.Circle, pos fyne.Position, frame fyne.Size) {
p.drawTextureWithDetails(circle, p.newGlCircleTexture, pos, circle.Size(), frame, canvas.ImageFillStretch,
1.0, paint.VectorPad(circle))

size := circle.Size()
radius := size.Width / 2
if size.Height < size.Width {
radius = size.Height / 2
}
program := p.roundRectangleProgram

// Vertex: BEG
bounds, points := p.vecSquareCoords(pos, circle, frame)
p.ctx.UseProgram(program)
vbo := p.createBuffer(points)
p.defineVertexArray(program, "vert", 2, 4, 0)
p.defineVertexArray(program, "normal", 2, 4, 2)

p.ctx.BlendFunc(srcAlpha, oneMinusSrcAlpha)
p.logError()
// Vertex: END

// Fragment: BEG
frameSizeUniform := p.ctx.GetUniformLocation(program, "frame_size")
frameWidthScaled, frameHeightScaled := p.scaleFrameSize(frame)
p.ctx.Uniform2f(frameSizeUniform, frameWidthScaled, frameHeightScaled)

rectCoordsUniform := p.ctx.GetUniformLocation(program, "rect_coords")
x1Scaled, x2Scaled, y1Scaled, y2Scaled := p.scaleRectCoords(bounds[0], bounds[2], bounds[1], bounds[3])
p.ctx.Uniform4f(rectCoordsUniform, x1Scaled, x2Scaled, y1Scaled, y2Scaled)

strokeWidthScaled := roundToPixel(circle.StrokeWidth*p.pixScale, 1.0)
strokeUniform := p.ctx.GetUniformLocation(program, "stroke_width_half")
p.ctx.Uniform1f(strokeUniform, strokeWidthScaled*0.5)

rectSizeUniform := p.ctx.GetUniformLocation(program, "rect_size_half")
rectSizeWidthScaled := x2Scaled - x1Scaled - strokeWidthScaled
rectSizeHeightScaled := y2Scaled - y1Scaled - strokeWidthScaled
p.ctx.Uniform2f(rectSizeUniform, rectSizeWidthScaled*0.5, rectSizeHeightScaled*0.5)

radiusUniform := p.ctx.GetUniformLocation(program, "radius")
radiusScaled := roundToPixel(radius*p.pixScale, 1.0)
p.ctx.Uniform1f(radiusUniform, radiusScaled)

var r, g, b, a float32
fillColorUniform := p.ctx.GetUniformLocation(program, "fill_color")
r, g, b, a = getFragmentColor(circle.FillColor)
p.ctx.Uniform4f(fillColorUniform, r, g, b, a)

strokeColorUniform := p.ctx.GetUniformLocation(program, "stroke_color")
strokeColor := circle.StrokeColor
if strokeColor == nil {
strokeColor = color.Transparent
}
r, g, b, a = getFragmentColor(strokeColor)
p.ctx.Uniform4f(strokeColorUniform, r, g, b, a)
p.logError()
// Fragment: END

p.ctx.DrawArrays(triangleStrip, 0, 4)
p.logError()
p.freeBuffer(vbo)
}

func (p *painter) drawGradient(o fyne.CanvasObject, texCreator func(fyne.CanvasObject) Texture, pos fyne.Position, frame fyne.Size) {
Expand Down Expand Up @@ -338,15 +397,19 @@ func rectInnerCoords(size fyne.Size, pos fyne.Position, fill canvas.ImageFill, a
}

func (p *painter) vecRectCoords(pos fyne.Position, rect *canvas.Rectangle, frame fyne.Size) ([4]float32, []float32) {
return p.vecRectCoordsWithPad(pos, rect, frame, 0, 0)
}

func (p *painter) vecRectCoordsWithPad(pos fyne.Position, rect fyne.CanvasObject, frame fyne.Size, xPad, yPad float32) ([4]float32, []float32) {
size := rect.Size()
pos1 := rect.Position()

xPosDiff := pos.X - pos1.X
yPosDiff := pos.Y - pos1.Y
xPosDiff := pos.X - pos1.X + xPad
yPosDiff := pos.Y - pos1.Y + yPad
pos1.X = roundToPixel(pos1.X+xPosDiff, p.pixScale)
pos1.Y = roundToPixel(pos1.Y+yPosDiff, p.pixScale)
size.Width = roundToPixel(size.Width, p.pixScale)
size.Height = roundToPixel(size.Height, p.pixScale)
size.Width = roundToPixel(size.Width-2*xPad, p.pixScale)
size.Height = roundToPixel(size.Height-2*yPad, p.pixScale)

x1Pos := pos1.X
x1Norm := -1 + x1Pos*2/frame.Width
Expand All @@ -367,6 +430,18 @@ func (p *painter) vecRectCoords(pos fyne.Position, rect *canvas.Rectangle, frame
return [4]float32{x1Pos, y1Pos, x2Pos, y2Pos}, coords
}

func (p *painter) vecSquareCoords(pos fyne.Position, rect fyne.CanvasObject, frame fyne.Size) ([4]float32, []float32) {
xPad, yPad := float32(0), float32(0)
size := rect.Size()
if size.Width > size.Height {
xPad = (size.Width - size.Height) / 2
} else {
yPad = (size.Height - size.Width) / 2
}

return p.vecRectCoordsWithPad(pos, rect, frame, xPad, yPad)
}

func roundToPixel(v float32, pixScale float32) float32 {
if pixScale == 1.0 {
return float32(math.Round(float64(v)))
Expand Down
148 changes: 148 additions & 0 deletions widget/activity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package widget

import (
"image/color"
"time"

"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/internal/cache"
"fyne.io/fyne/v2/theme"
)

var _ fyne.Widget = (*Activity)(nil)

// Activity is used to indicate that something is happening that should be waited for,
// or is in the background (depending on usage).
//
// Since: 2.5
type Activity struct {
BaseWidget
}

// NewActivity returns a widget for indicating activity
//
// Since: 2.5
func NewActivity() *Activity {
a := &Activity{}
a.ExtendBaseWidget(a)
return a
}

func (a *Activity) MinSize() fyne.Size {
a.ExtendBaseWidget(a)

return fyne.NewSquareSize(theme.IconInlineSize())
}

// Start the activity indicator animation
func (a *Activity) Start() {
if r, ok := cache.Renderer(a.super()).(*activityRenderer); ok {
r.start()
}
}

// Stop the activity indicator animation
func (a *Activity) Stop() {
if r, ok := cache.Renderer(a.super()).(*activityRenderer); ok {
r.stop()
}
}

func (a *Activity) CreateRenderer() fyne.WidgetRenderer {
dots := make([]fyne.CanvasObject, 3)
for i := range dots {
dots[i] = canvas.NewCircle(theme.ForegroundColor())
}
r := &activityRenderer{dots: dots}
r.anim = &fyne.Animation{
Duration: time.Second * 2,
RepeatCount: fyne.AnimationRepeatForever,
Tick: r.animate}
r.updateColor()
return r
}

var _ fyne.WidgetRenderer = (*activityRenderer)(nil)

type activityRenderer struct {
anim *fyne.Animation
dots []fyne.CanvasObject

bound fyne.Size
maxCol color.NRGBA
maxRad float32
}

func (a *activityRenderer) Destroy() {
a.stop()
}

func (a *activityRenderer) Layout(size fyne.Size) {
a.maxRad = fyne.Min(size.Width, size.Height) / 2
a.bound = size
}

func (a *activityRenderer) MinSize() fyne.Size {
return fyne.NewSquareSize(theme.IconInlineSize())
}

func (a *activityRenderer) Objects() []fyne.CanvasObject {
return a.dots
}

func (a *activityRenderer) Refresh() {
a.updateColor()
}

func (a *activityRenderer) animate(done float32) {
off := done * 2
if off > 1 {
off = 2 - off
}

off1 := (done + 0.25) * 2
if done >= 0.75 {
off1 = (done - 0.75) * 2
}
if off1 > 1 {
off1 = 2 - off1
}

off2 := (done + 0.75) * 2
if done >= 0.25 {
off2 = (done - 0.25) * 2
}
if off2 > 1 {
off2 = 2 - off2
}

a.scaleDot(a.dots[0].(*canvas.Circle), off)
a.scaleDot(a.dots[1].(*canvas.Circle), off1)
a.scaleDot(a.dots[2].(*canvas.Circle), off2)
}

func (a *activityRenderer) scaleDot(dot *canvas.Circle, off float32) {
rad := a.maxRad - a.maxRad*off/1.2
mid := fyne.NewPos(a.bound.Width/2, a.bound.Height/2)

dot.Move(mid.Subtract(fyne.NewSquareOffsetPos(rad)))
dot.Resize(fyne.NewSquareSize(rad * 2))

alpha := uint8(0 + int(float32(a.maxCol.A)*off))
dot.FillColor = color.NRGBA{R: a.maxCol.R, G: a.maxCol.G, B: a.maxCol.B, A: alpha}
dot.Refresh()
}

func (a *activityRenderer) start() {
a.anim.Start()
}

func (a *activityRenderer) stop() {
a.anim.Stop()
}

func (a *activityRenderer) updateColor() {
rr, gg, bb, aa := theme.ForegroundColor().RGBA()
a.maxCol = color.NRGBA{R: uint8(rr >> 8), G: uint8(gg >> 8), B: uint8(bb >> 8), A: uint8(aa >> 8)}
}
33 changes: 33 additions & 0 deletions widget/activity_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package widget

import (
"testing"

"fyne.io/fyne/v2/test"
)

func TestActivity_Animation(t *testing.T) {
test.NewApp()
defer test.NewApp()
test.ApplyTheme(t, test.NewTheme())

a := NewActivity()
w := test.NewWindow(a)
w.SetPadded(false)
defer w.Close()
w.Resize(a.MinSize())

render := test.WidgetRenderer(a).(*activityRenderer)
render.anim.Tick(0)
test.AssertImageMatches(t, "activity/animate_0.0.png", w.Canvas().Capture())

render.anim.Tick(0.25)
test.AssertImageMatches(t, "activity/animate_0.25.png", w.Canvas().Capture())

render.anim.Tick(0.5)
test.AssertImageMatches(t, "activity/animate_0.5.png", w.Canvas().Capture())

// check reset to loop
render.anim.Tick(1.0)
test.AssertImageMatches(t, "activity/animate_0.0.png", w.Canvas().Capture())
}
Loading
Loading