WIP essentials 11
This commit is contained in:
4 files changed
+217
No files matched your search
+139
@@ -0,0 +1,139 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"log/slog"
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// MasonryOptions configure a Masonry.
|
||||
type MasonryOptions struct {
|
||||
// Key is required: a stable, unique key per item.
|
||||
Key func(i int) string
|
||||
// ColumnWidth is the narrowest column; zero is 220. Columns fill the width.
|
||||
ColumnWidth float32
|
||||
// Gap is between items; zero is 3 spacing units.
|
||||
Gap float32
|
||||
// Estimate is the height assumed until an item is measured; zero is 160.
|
||||
Estimate float32
|
||||
}
|
||||
|
||||
// masonryState is the scroll, the measured heights and the cached placement.
|
||||
type masonryState struct {
|
||||
scroll ui.ScrollState
|
||||
heights map[string]float32
|
||||
version int
|
||||
laidOut masonryLayout
|
||||
// built are the items built last frame; their keys are checked for changed data.
|
||||
built []int
|
||||
// sized: a frame was asked for once the box had a width.
|
||||
sized bool
|
||||
}
|
||||
|
||||
// masonryLayout places n items in cols columns; byCol lists each column's items top to bottom.
|
||||
type masonryLayout struct {
|
||||
n, cols, version int
|
||||
colW, gap float32
|
||||
keys []string
|
||||
x, y, h []float32
|
||||
byCol [][]int
|
||||
total float32
|
||||
}
|
||||
|
||||
// Masonry lays n items in as many columns as fit, each into the shortest column, building only those in view.
|
||||
func Masonry(c *ui.Context, n int, opts MasonryOptions, item func(i int)) *ui.Element {
|
||||
if opts.Key == nil || item == nil {
|
||||
panic("mujica: Masonry needs Key and item")
|
||||
}
|
||||
if n < 0 || opts.ColumnWidth < 0 || opts.Gap < 0 || opts.Estimate < 0 {
|
||||
panic("mujica: Masonry count and sizes must not be negative")
|
||||
}
|
||||
u := Density(c).Unit()
|
||||
colMin := cmp.Or(opts.ColumnWidth, 220)
|
||||
gap := cmp.Or(opts.Gap, 3*u)
|
||||
est := cmp.Or(opts.Estimate, 160)
|
||||
sc := ui.Scroll(c)
|
||||
st := ui.Local(sc, "masonry", func() *masonryState { return &masonryState{heights: map[string]float32{}} })
|
||||
s := *st
|
||||
sc.TrackScroll(&s.scroll)
|
||||
view := sc.Bounds()
|
||||
if view.W == 0 && !s.sized {
|
||||
// Unmeasured: lay out again once the box has its width.
|
||||
s.sized = true
|
||||
c.Invalidate()
|
||||
}
|
||||
cols := max(1, int((view.W+gap)/(colMin+gap)))
|
||||
colW := (view.W - gap*float32(cols-1)) / float32(cols)
|
||||
l := &s.laidOut
|
||||
stale := l.n != n || l.cols != cols || l.colW != colW || l.gap != gap || l.version != s.version
|
||||
for _, i := range s.built {
|
||||
stale = stale || i >= n || l.keys[i] != opts.Key(i)
|
||||
}
|
||||
if stale {
|
||||
*l = masonryPlace(n, cols, colW, gap, est, s.heights, opts.Key)
|
||||
l.version = s.version
|
||||
slog.Debug("mujica: masonry layout", "items", n, "columns", cols)
|
||||
}
|
||||
top, bottom := s.scroll.Y-view.H/2, s.scroll.Y+view.H*1.5
|
||||
var seen []int
|
||||
sc.Children(func() {
|
||||
ui.Box(c).FillWidth().Height(l.total).Children(func() { masonryBuild(c, s, l, colW, top, bottom, item, &seen) })
|
||||
})
|
||||
s.built = seen
|
||||
return sc
|
||||
}
|
||||
|
||||
// masonryBuild builds the items between top and bottom and records measured heights.
|
||||
func masonryBuild(c *ui.Context, s *masonryState, l *masonryLayout, colW, top, bottom float32, item func(int), seen *[]int) {
|
||||
for _, list := range l.byCol {
|
||||
from := sort.Search(len(list), func(j int) bool { i := list[j]; return l.y[i]+l.h[i] >= top })
|
||||
for _, i := range list[from:] {
|
||||
if l.y[i] > bottom {
|
||||
break
|
||||
}
|
||||
*seen = append(*seen, i)
|
||||
key := l.keys[i]
|
||||
box := ui.Column(c).Key(key).Absolute().Left(l.x[i]).Top(l.y[i]).Width(colW)
|
||||
box.Children(func() { item(i) })
|
||||
if h := box.Bounds().H; h > 0 && math.Abs(float64(h-l.h[i])) > 0.5 {
|
||||
s.heights[key] = h
|
||||
s.version++
|
||||
c.Invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// masonryPlace puts each item into the shortest column, using measured heights or est.
|
||||
func masonryPlace(n, cols int, colW, gap, est float32, heights map[string]float32, key func(int) string) masonryLayout {
|
||||
l := masonryLayout{n: n, cols: cols, colW: colW, gap: gap, keys: make([]string, n), x: make([]float32, n), y: make([]float32, n), h: make([]float32, n), byCol: make([][]int, cols)}
|
||||
colH := make([]float32, cols)
|
||||
seen := make(map[string]bool, n)
|
||||
for i := range n {
|
||||
k := key(i)
|
||||
if seen[k] {
|
||||
panic("mujica: Masonry key repeated: " + k)
|
||||
}
|
||||
seen[k] = true
|
||||
col := 0
|
||||
for j := range cols {
|
||||
if colH[j] < colH[col] {
|
||||
col = j
|
||||
}
|
||||
}
|
||||
h, ok := heights[k]
|
||||
if !ok {
|
||||
h = est
|
||||
}
|
||||
l.keys[i], l.x[i], l.y[i], l.h[i] = k, float32(col)*(colW+gap), colH[col], h
|
||||
l.byCol[col] = append(l.byCol[col], i)
|
||||
colH[col] += h + gap
|
||||
}
|
||||
for _, h := range colH {
|
||||
l.total = max(l.total, h-gap)
|
||||
}
|
||||
return l
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestMasonryPlaceShortestColumn(t *testing.T) {
|
||||
hs := map[string]float32{"0": 100, "1": 40, "2": 40, "3": 50}
|
||||
l := masonryPlace(4, 2, 100, 10, 160, hs, strconv.Itoa)
|
||||
// 0 left; 1 right; 2 right again (50 < 110); 3 right (100 = 110? no: left 110, right 100).
|
||||
want := [][2]float32{{0, 0}, {110, 0}, {110, 50}, {110, 100}}
|
||||
for i, w := range want {
|
||||
if l.x[i] != w[0] || l.y[i] != w[1] {
|
||||
t.Errorf("item %d at %v,%v, want %v", i, l.x[i], l.y[i], w)
|
||||
}
|
||||
}
|
||||
if l.total != 150 {
|
||||
t.Errorf("total %v", l.total)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Error("repeated key accepted")
|
||||
}
|
||||
}()
|
||||
masonryPlace(2, 1, 100, 0, 10, nil, func(int) string { return "same" })
|
||||
}
|
||||
|
||||
func TestMasonryVirtualizesAndMeasures(t *testing.T) {
|
||||
built := map[int]bool{}
|
||||
n := 100000
|
||||
width := 700
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Masonry(c, n, MasonryOptions{Key: strconv.Itoa, ColumnWidth: 200, Gap: 10}, func(i int) {
|
||||
built[i] = true
|
||||
ui.Box(c).Height(float32(40 + i%3*30)).Label("tile " + strconv.Itoa(i))
|
||||
}).Size(float32(width), 500)
|
||||
}, 800, 600)
|
||||
tt.Frame()
|
||||
clear(built)
|
||||
tt.Frame()
|
||||
if len(built) == 0 || len(built) > 120 {
|
||||
t.Fatalf("built %d of %d", len(built), n)
|
||||
}
|
||||
if !built[0] || !built[2] || built[5000] {
|
||||
t.Fatalf("wrong items built: %v", built[5000])
|
||||
}
|
||||
a, _ := tt.Find("tile 0")
|
||||
b, _ := tt.Find("tile 1")
|
||||
d, _ := tt.Find("tile 3")
|
||||
if a.W < 226 || a.W > 227 || b.X < 236 || b.X > 237 {
|
||||
t.Fatalf("three columns in 700: tile 0 %v, tile 1 %v", a, b)
|
||||
}
|
||||
// Measured: tile 0 is 40 high, so tile 3 goes under it at 40 + 10.
|
||||
if d.X != 0 || d.Y != 50 {
|
||||
t.Fatalf("tile 3 at %v; texts %q", d, tt.Texts())
|
||||
}
|
||||
tt.Scroll(100, 100, 0, 600000)
|
||||
clear(built)
|
||||
tt.Frame()
|
||||
if len(built) == 0 || len(built) > 120 || built[0] {
|
||||
t.Fatalf("after scrolling built %d, includes first %v", len(built), built[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMasonrySnapshot(t *testing.T) {
|
||||
snapshot(t, "masonry", 520, 300, func(c *ui.Context) {
|
||||
k := Tokens(c)
|
||||
Masonry(c, 9, MasonryOptions{Key: strconv.Itoa, ColumnWidth: 150}, func(i int) {
|
||||
ui.Column(c).Height(float32(50+i%4*25)).Padding(8).Background(k.Surface).Border(1, k.Border).Radius(4).Children(func() {
|
||||
ui.Text(c, "Card "+strconv.Itoa(i+1))
|
||||
})
|
||||
}).Size(500, 280).Margin(10)
|
||||
})
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Reference in new issue
Block a user