~eliasnaur/gio

2c5daf10a2d1a3e2ec062f489bf59d887232cf83 — Egon Elbre 7 months ago 9e85b43
widget: add Fit for scaling widgets

Currently adds four different variants Unscaled, Contain, Cover, ScaleDown and Fill.

Signed-off-by: Egon Elbre <egonelbre@gmail.com>
2 files changed, 223 insertions(+), 0 deletions(-)

A widget/fit.go
A widget/fit_test.go
A widget/fit.go => widget/fit.go +106 -0
@@ 0,0 1,106 @@
// SPDX-License-Identifier: Unlicense OR MIT

package widget

import (
	"image"

	"gioui.org/f32"
	"gioui.org/layout"
	"gioui.org/op"
	"gioui.org/op/clip"
)

// Fit scales a widget to fit and clip to the constraints.
type Fit uint8

const (
	// Unscaled does not alter the scale of a widget.
	Unscaled Fit = iota
	// Contain scales widget as large as possible without cropping
	// and it preserves aspect-ratio.
	Contain
	// Cover scales the widget to cover the constraint area and
	// preserves aspect-ratio.
	Cover
	// ScaleDown scales the widget smaller without cropping,
	// when it exceeds the constraint area.
	// It preserves aspect-ratio.
	ScaleDown
	// Fill stretches the widget to the constraints and does not
	// preserve aspect-ratio.
	Fill
)

// scale adds clip and scale operations to fit dims to the constraints.
// It positions the widget to the appropriate position.
// It returns dimensions modified accordingly.
func (fit Fit) scale(gtx layout.Context, pos layout.Direction, dims layout.Dimensions) layout.Dimensions {
	widgetSize := dims.Size

	if fit == Unscaled || dims.Size.X == 0 || dims.Size.Y == 0 {
		dims.Size = gtx.Constraints.Constrain(dims.Size)
		clip.Rect{Max: dims.Size}.Add(gtx.Ops)

		offset := pos.Position(widgetSize, dims.Size)
		op.Offset(layout.FPt(offset)).Add(gtx.Ops)
		dims.Baseline += offset.Y
		return dims
	}

	scale := f32.Point{
		X: float32(gtx.Constraints.Max.X) / float32(dims.Size.X),
		Y: float32(gtx.Constraints.Max.Y) / float32(dims.Size.Y),
	}

	switch fit {
	case Contain:
		if scale.Y < scale.X {
			scale.X = scale.Y
		} else {
			scale.Y = scale.X
		}
	case Cover:
		if scale.Y > scale.X {
			scale.X = scale.Y
		} else {
			scale.Y = scale.X
		}
	case ScaleDown:
		if scale.Y < scale.X {
			scale.X = scale.Y
		} else {
			scale.Y = scale.X
		}

		// The widget would need to be scaled up, no change needed.
		if scale.X >= 1 {
			dims.Size = gtx.Constraints.Constrain(dims.Size)
			clip.Rect{Max: dims.Size}.Add(gtx.Ops)

			offset := pos.Position(widgetSize, dims.Size)
			op.Offset(layout.FPt(offset)).Add(gtx.Ops)
			dims.Baseline += offset.Y
			return dims
		}
	case Fill:
	}

	var scaledSize image.Point
	scaledSize.X = int(float32(widgetSize.X) * scale.X)
	scaledSize.Y = int(float32(widgetSize.Y) * scale.Y)
	dims.Size = gtx.Constraints.Constrain(scaledSize)
	dims.Baseline = int(float32(dims.Baseline) * scale.Y)

	clip.Rect{Max: dims.Size}.Add(gtx.Ops)

	offset := pos.Position(scaledSize, dims.Size)
	op.Affine(f32.Affine2D{}.
		Scale(f32.Point{}, scale).
		Offset(layout.FPt(offset)),
	).Add(gtx.Ops)

	dims.Baseline += offset.Y

	return dims
}

A widget/fit_test.go => widget/fit_test.go +117 -0
@@ 0,0 1,117 @@
// SPDX-License-Identifier: Unlicense OR MIT

package widget

import (
	"bytes"
	"encoding/binary"
	"image"
	"math"
	"testing"

	"gioui.org/f32"
	"gioui.org/layout"
	"gioui.org/op"
)

func TestFit(t *testing.T) {
	type test struct {
		Dims   image.Point
		Scale  f32.Point
		Result image.Point
	}

	fittests := [...][]test{
		Unscaled: {
			{
				Dims:   image.Point{0, 0},
				Scale:  f32.Point{X: 1, Y: 1},
				Result: image.Point{X: 0, Y: 0},
			}, {
				Dims:   image.Point{50, 25},
				Scale:  f32.Point{X: 1, Y: 1},
				Result: image.Point{X: 50, Y: 25},
			}, {
				Dims:   image.Point{50, 200},
				Scale:  f32.Point{X: 1, Y: 1},
				Result: image.Point{X: 50, Y: 100},
			}},
		Contain: {
			{
				Dims:   image.Point{50, 25},
				Scale:  f32.Point{X: 2, Y: 2},
				Result: image.Point{X: 100, Y: 50},
			}, {
				Dims:   image.Point{50, 200},
				Scale:  f32.Point{X: 0.5, Y: 0.5},
				Result: image.Point{X: 25, Y: 100},
			}},
		Cover: {
			{
				Dims:   image.Point{50, 25},
				Scale:  f32.Point{X: 4, Y: 4},
				Result: image.Point{X: 100, Y: 100},
			}, {
				Dims:   image.Point{50, 200},
				Scale:  f32.Point{X: 2, Y: 2},
				Result: image.Point{X: 100, Y: 100},
			}},
		ScaleDown: {
			{
				Dims:   image.Point{50, 25},
				Scale:  f32.Point{X: 1, Y: 1},
				Result: image.Point{X: 50, Y: 25},
			}, {
				Dims:   image.Point{50, 200},
				Scale:  f32.Point{X: 0.5, Y: 0.5},
				Result: image.Point{X: 25, Y: 100},
			}},
		Fill: {
			{
				Dims:   image.Point{50, 25},
				Scale:  f32.Point{X: 2, Y: 4},
				Result: image.Point{X: 100, Y: 100},
			}, {
				Dims:   image.Point{50, 200},
				Scale:  f32.Point{X: 2, Y: 0.5},
				Result: image.Point{X: 100, Y: 100},
			}},
	}

	for fit, tests := range fittests {
		fit := Fit(fit)
		for i, test := range tests {
			ops := new(op.Ops)
			gtx := layout.Context{
				Ops: ops,
				Constraints: layout.Constraints{
					Max: image.Point{X: 100, Y: 100},
				},
			}

			result := fit.scale(gtx, layout.NW, layout.Dimensions{Size: test.Dims})

			if test.Scale.X != 1 || test.Scale.Y != 1 {
				opsdata := gtx.Ops.Data()
				scaleX := float32Bytes(test.Scale.X)
				scaleY := float32Bytes(test.Scale.Y)
				if !bytes.Contains(opsdata, scaleX) {
					t.Errorf("did not find scale.X:%v (%x) in ops: %x", test.Scale.X, scaleX, opsdata)
				}
				if !bytes.Contains(opsdata, scaleY) {
					t.Errorf("did not find scale.Y:%v (%x) in ops: %x", test.Scale.Y, scaleY, opsdata)
				}
			}

			if result.Size != test.Result {
				t.Errorf("fit %v, #%v: expected %#v, got %#v", fit, i, test.Result, result.Size)
			}
		}
	}
}

func float32Bytes(v float32) []byte {
	var dst [4]byte
	binary.LittleEndian.PutUint32(dst[:], math.Float32bits(v))
	return dst[:]
}