Repository: cogentcore/core
Pull request #1026 (2024-07-20 00:20:31)

Splits implements tiled splits and fixes some remaining layout issues


Patch:
diff --git a/core/enumgen.go b/core/enumgen.go
index 97c46ae46..a686852f1 100644
--- a/core/enumgen.go
+++ b/core/enumgen.go
@@ -303,6 +303,49 @@ func (i *SliderTypes) UnmarshalText(text []byte) error {
 	return enums.UnmarshalText(i, text, "SliderTypes")
 }
 
+var _SplitsTilesValues = []SplitsTiles{0, 1, 2, 3}
+
+// SplitsTilesN is the highest valid value for type SplitsTiles, plus one.
+const SplitsTilesN SplitsTiles = 4
+
+var _SplitsTilesValueMap = map[string]SplitsTiles{`Span`: 0, `Split`: 1, `FirstLong`: 2, `SecondLong`: 3}
+
+var _SplitsTilesDescMap = map[SplitsTiles]string{0: `Span has a single element spanning the cross dimension, i.e., a vertical span for a horizontal main axis, or a horizontal span for a vertical main axis. It is the only valid value for 1D Splits, where it specifies a single element per split. If all tiles are Span, then a 1D line is generated.`, 1: `Split has a split between elements along the cross dimension, with the first of 2 elements in the first main axis line and the second in the second line.`, 2: `FirstLong has a long span of first element along the first main axis line and a split between the next two elements along the second line, with a split between the two lines. Visually, the splits form a T shape for a horizontal main axis.`, 3: `SecondLong has the first two elements split along the first line, and the third with a long span along the second main axis line, with a split between the two lines. Visually, the splits form an inverted T shape for a horizontal main axis.`}
+
+var _SplitsTilesMap = map[SplitsTiles]string{0: `Span`, 1: `Split`, 2: `FirstLong`, 3: `SecondLong`}
+
+// String returns the string representation of this SplitsTiles value.
+func (i SplitsTiles) String() string { return enums.String(i, _SplitsTilesMap) }
+
+// SetString sets the SplitsTiles value from its string representation,
+// and returns an error if the string is invalid.
+func (i *SplitsTiles) SetString(s string) error {
+	return enums.SetString(i, s, _SplitsTilesValueMap, "SplitsTiles")
+}
+
+// Int64 returns the SplitsTiles value as an int64.
+func (i SplitsTiles) Int64() int64 { return int64(i) }
+
+// SetInt64 sets the SplitsTiles value from an int64.
+func (i *SplitsTiles) SetInt64(in int64) { *i = SplitsTiles(in) }
+
+// Desc returns the description of the SplitsTiles value.
+func (i SplitsTiles) Desc() string { return enums.Desc(i, _SplitsTilesDescMap) }
+
+// SplitsTilesValues returns all possible values for the type SplitsTiles.
+func SplitsTilesValues() []SplitsTiles { return _SplitsTilesValues }
+
+// Values returns all possible values for the type SplitsTiles.
+func (i SplitsTiles) Values() []enums.Enum { return enums.Values(_SplitsTilesValues) }
+
+// MarshalText implements the [encoding.TextMarshaler] interface.
+func (i SplitsTiles) MarshalText() ([]byte, error) { return []byte(i.String()), nil }
+
+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
+func (i *SplitsTiles) UnmarshalText(text []byte) error {
+	return enums.UnmarshalText(i, text, "SplitsTiles")
+}
+
 var _StageTypesValues = []StageTypes{0, 1, 2, 3, 4, 5}
 
 // StageTypesN is the highest valid value for type StageTypes, plus one.
diff --git a/core/handle.go b/core/handle.go
index d1db3da80..99470f6ec 100644
--- a/core/handle.go
+++ b/core/handle.go
@@ -71,5 +71,5 @@ func (hl *Handle) Init() {
 // Value returns the value on a normalized scale of 0-1,
 // based on [Handle.Pos], [Handle.Min], and [Handle.Max].
 func (hl *Handle) Value() float32 {
-	return hl.Pos / (hl.Max - hl.Min)
+	return (hl.Pos - hl.Min) / (hl.Max - hl.Min)
 }
diff --git a/core/layout.go b/core/layout.go
index 9d4e6b9e2..0f872c344 100644
--- a/core/layout.go
+++ b/core/layout.go
@@ -1654,12 +1654,12 @@ func (fr *Frame) Position() {
 	fr.positionParts()
 }
 
-func (ly *Frame) positionCells() {
-	if ly.Styles.Display == styles.Flex && ly.Styles.Direction == styles.Column {
-		ly.positionCellsMainY()
+func (fr *Frame) positionCells() {
+	if fr.Styles.Display == styles.Flex && fr.Styles.Direction == styles.Column {
+		fr.positionCellsMainY()
 		return
 	}
-	ly.positionCellsMainX()
+	fr.positionCellsMainX()
 }
 
 // Main axis = X
@@ -1692,25 +1692,25 @@ func (fr *Frame) positionCellsMainX() {
 }
 
 // Main axis = Y
-func (ly *Frame) positionCellsMainY() {
-	gap := ly.layout.Gap
-	sz := &ly.Geom.Size
+func (fr *Frame) positionCellsMainY() {
+	gap := fr.layout.Gap
+	sz := &fr.Geom.Size
 	if DebugSettings.LayoutTraceDetail {
-		fmt.Println(ly, "PositionCells, actual", sz.Actual.Content, "internal:", sz.Internal)
+		fmt.Println(fr, "PositionCells, actual", sz.Actual.Content, "internal:", sz.Internal)
 	}
 	var lastSz math32.Vector2
 	var stPos math32.Vector2
-	stPos.Y = styles.AlignPos(ly.Styles.Justify.Content, sz.Internal.Y, sz.Actual.Content.Y)
-	stPos.X = styles.AlignPos(ly.Styles.Align.Content, sz.Internal.X, sz.Actual.Content.X)
+	stPos.Y = styles.AlignPos(fr.Styles.Justify.Content, sz.Internal.Y, sz.Actual.Content.Y)
+	stPos.X = styles.AlignPos(fr.Styles.Align.Content, sz.Internal.X, sz.Actual.Content.X)
 	pos := stPos
 	idx := 0
-	ly.forVisibleChildren(func(i int, cw Widget, cwb *WidgetBase) bool {
+	fr.forVisibleChildren(func(i int, cw Widget, cwb *WidgetBase) bool {
 		cidx := cwb.Geom.Cell
 		if cidx.Y == 0 && idx > 0 {
 			pos.Y = stPos.Y
 			pos.X += lastSz.X + gap.X
 		}
-		cwb.positionWithinAllocMainY(pos, ly.Styles.Justify.Items, ly.Styles.Align.Items)
+		cwb.positionWithinAllocMainY(pos, fr.Styles.Justify.Items, fr.Styles.Align.Items)
 		alloc := cwb.Geom.Size.Alloc.Total
 		pos.Y += alloc.Y + gap.Y
 		lastSz = alloc
@@ -1719,10 +1719,10 @@ func (ly *Frame) positionCellsMainY() {
 	})
 }
 
-func (ly *Frame) positionStacked() {
-	ly.ForWidgetChildren(func(i int, cw Widget, cwb *WidgetBase) bool {
+func (fr *Frame) positionStacked() {
+	fr.ForWidgetChildren(func(i int, cw Widget, cwb *WidgetBase) bool {
 		cwb.Geom.RelPos.SetZero()
-		if !ly.LayoutStackTopOnly || i == ly.StackTop {
+		if !fr.LayoutStackTopOnly || i == fr.StackTop {
 			cw.Position()
 		}
 		return tree.Continue
diff --git a/core/splits.go b/core/splits.go
index 95423ebbe..8d00dd248 100644
--- a/core/splits.go
+++ b/core/splits.go
@@ -5,6 +5,7 @@
 package core
 
 import (
+	"log/slog"
 	"strconv"
 	"strings"
 
@@ -17,32 +18,93 @@ import (
 	"cogentcore.org/core/tree"
 )
 
+// SplitsTiles specifies 2D tiles for organizing elements within the [Splits] Widget.
+// The [styles.Style.Direction] defines the main axis, and the cross axis is orthogonal
+// to that, which is organized into chunks of 2 cross-axis "rows".  In the case of a
+// 1D pattern, only Span is relevant, indicating a single element per split.
+type SplitsTiles int32 //enums:enum -trim-prefix Tile
+
+const (
+	// Span has a single element spanning the cross dimension, i.e.,
+	// a vertical span for a horizontal main axis, or a horizontal
+	// span for a vertical main axis.  It is the only valid value
+	// for 1D Splits, where it specifies a single element per split.
+	// If all tiles are Span, then a 1D line is generated.
+	TileSpan SplitsTiles = iota
+
+	// Split has a split between elements along the cross dimension,
+	// with the first of 2 elements in the first main axis line and
+	// the second in the second line.
+	TileSplit
+
+	// FirstLong has a long span of first element along the first
+	// main axis line and a split between the next two elements
+	// along the second line, with a split between the two lines.
+	// Visually, the splits form a T shape for a horizontal main axis.
+	TileFirstLong
+
+	// SecondLong has the first two elements split along the first line,
+	//	and the third with a long span along the second main axis line,
+	// with a split between the two lines.  Visually, the splits form
+	// an inverted T shape for a horizontal main axis.
+	TileSecondLong
+)
+
+var (
+	// tileNumElements is the number of elements per tile.
+	// the number of splitter handles is n-1.
+	tileNumElements = map[SplitsTiles]int{TileSpan: 1, TileSplit: 2, TileFirstLong: 3, TileSecondLong: 3}
+
+	// tileNumSubSplits is the number of SubSplits proportions per tile.
+	// The Long cases require 2 pairs, first for the split along the cross axis
+	// and second for the split along the main axis.
+	tileNumSubSplits = map[SplitsTiles]int{TileSpan: 1, TileSplit: 2, TileFirstLong: 4, TileSecondLong: 4}
+)
+
 // Splits allocates a certain proportion of its space to each of its children,
-// organized according to Order and [styles.Style.Columns], where Columns
-// can be an even divisor of the Order length to create 2D layouts,
-// or 1 to specify a vertical instead of horizontal layout.
-// It adds [Handle] widgets to its parts that allow the user to customize
-// the amount of space allocated to each child.
+// organized along [styles.Style.Direction] as the main axis, and supporting
+// [SplitsTiles] of 2D splits configurations along the cross axis.
+// There is always a split between each Tile segment along the main axis,
+// with the proportion of the total main axis space per Tile allocated
+// according to normalized Splits factors.
+// If all Tiles are Span then a 1D line is generated.  Children are allocated
+// in order along the main axis, according to each of the Tiles,
+// which consume 1, 2 or 3 elements, and have 0, 1 or 2 splits internally.
+// The internal split proportion are stored separately in SubSplits.
+// A [Handle] widget is added to the Parts for each split, allowing the user
+// to drag the relative size of each splits region.
+// If more complex geometries are required, use nested Splits.
 type Splits struct {
 	Frame
 
+	// Tiles specifies the 2D layout of elements along the [styles.Style.Direction]
+	// main axis and the orthogonal cross axis.  If all Tiles are TileSpan, then
+	// a 1D line is generated.  There is always a split between each Tile segment,
+	// and different tiles consume different numbers of elements in order, and
+	// have different numbers of SubSplits.  Because each Tile can represent a
+	// different number of elements, care must be taken to ensure that the full
+	// set of tiles corresponds to the actual number of children.  A default
+	// 1D configuration will be imposed if there is a mismatch.
+	Tiles []SplitsTiles
+
 	// Splits is the proportion (0-1 normalized, enforced) of space
-	// allocated to each element. 0 indicates that an element should
-	// be completely collapsed. By default, each element gets the
-	// same amount of space.
+	// allocated to each Tile element along the main axis.
+	// 0 indicates that an element should  be completely collapsed.
+	// By default, each element gets the same amount of space.
 	Splits []float32
 
-	// Order is the organization of the splits content, using indexes 0..n-1
-	// to specify what goes where, in generally increasing seqeuential order.
-	// This is used to specify 2D layouts when [styles.Style.Columns] is an
-	// even divisor of the Order length, e.g., 0012 with Columns = 2
-	// specifies the first element spanning the top row, with the next two
-	// elements splitting the bottom row.
-	Order []int
+	// SubSplits contains splits proportions for each Tile element, with
+	// a variable number depending on the Tile.  For the First and Second Long
+	// elements, there are 2 subsets of sub-splits, with 4 total subsplits.
+	SubSplits [][]float32
 
-	// savedSplits is a saved version of the splits that can be restored
+	// savedSplits is a saved version of the Splits that can be restored
 	// for dynamic collapse/expand operations.
 	savedSplits []float32
+
+	// savedSubSplits is a saved version of the SubSplits that can be restored
+	// for dynamic collapse/expand operations.
+	savedSubSplits [][]float32
 }
 
 func (sl *Splits) Init() {
@@ -87,13 +149,15 @@ func (sl *Splits) Init() {
 		kn := int(knc)
 		if kn == 0 {
 			e.SetHandled()
-			sl.evenSplits()
+			sl.evenSplits(sl.Splits)
+			sl.NeedsLayout()
+
 		} else if kn <= len(sl.Children) {
 			e.SetHandled()
 			if sl.Splits[kn-1] <= 0.01 {
 				sl.restoreChild(kn - 1)
 			} else {
-				sl.collapseChild(true, kn-1)
+				sl.collapseSplit(true, kn-1)
 			}
 		}
 	})
@@ -103,54 +167,124 @@ func (sl *Splits) Init() {
 	})
 	parts := sl.newParts()
 	parts.Maker(func(p *tree.Plan) {
-		for i := range len(sl.Children) - 1 { // one fewer handle than children
-			tree.AddAt(p, "handle-"+strconv.Itoa(i), func(w *Handle) {
+		// handles are organized first between tiles, then within tiles.
+		addHand := func(hidx int, hdir styles.Directions) {
+			tree.AddAt(p, "handle-"+strconv.Itoa(hidx), func(w *Handle) {
 				w.OnChange(func(e events.Event) {
-					sl.setSplit(w.IndexInParent(), w.Value())
+					sl.setHandlePos(w.IndexInParent(), w.Value())
 				})
 				w.Styler(func(s *styles.Style) {
-					s.Direction = sl.Styles.Direction
+					dir := sl.Styles.Direction
+					odir := dir.Other()
+					if hdir == styles.Column {
+						s.Direction = dir
+					} else {
+						s.Direction = odir
+					}
 				})
 			})
 		}
+
+		nt := len(sl.Tiles)
+		for i := range nt - 1 {
+			addHand(i, styles.Column)
+		}
+		hi := nt - 1
+		for _, t := range sl.Tiles {
+			switch t {
+			case TileSpan:
+			case TileSplit:
+				addHand(hi, styles.Row)
+				hi++
+			case TileFirstLong, TileSecondLong:
+				addHand(hi, styles.Row)      // long
+				addHand(hi+1, styles.Column) // sub
+				hi += 2
+			}
+		}
 	})
 }
 
-// updateSplits normalizes the splits and ensures that there are as
-// many split proportions as children.
+// tilesTotal returns the total number of child elements associated
+// with the current set of Tiles elements, and whether there are any
+// non-TileSpan elements, which has implications for error handling
+// if the total does not match the actual number of children in the Splits.
+func (sl *Splits) tilesTotal() (total int, hasNonSpans bool) {
+	for _, t := range sl.Tiles {
+		total += tileNumElements[t]
+		if t != TileSpan {
+			hasNonSpans = true
+		}
+	}
+	return
+}
+
+// updateSplits ensures the Tiles, Splits and SubSplits are all configured properly,
+// given the number of children in the splits.
 func (sl *Splits) updateSplits() *Splits {
-	n := len(sl.Children)
-	if n == 0 {
+	nc := len(sl.Children)
+	if nc == 0 {
 		return sl
 	}
-	sl.Splits = slicesx.SetLength(sl.Splits, n)
+	ntc, hasNonSpans := sl.tilesTotal()
+	if ntc != nc {
+		if ntc != 0 && hasNonSpans {
+			slog.Error("core.Splits: number of children for current Tiles != number of actual children, reverting to 1D", "children", nc, "tiles", ntc)
+		}
+		sl.Tiles = slicesx.SetLength(sl.Tiles, nc)
+		for i := range nc {
+			sl.Tiles[i] = TileSpan
+		}
+	}
+	nt := len(sl.Tiles)
+	sl.Splits = slicesx.SetLength(sl.Splits, nt)
+	sl.normSplits(sl.Splits)
+	sl.SubSplits = slicesx.SetLength(sl.SubSplits, nt)
+	for i, t := range sl.Tiles {
+		ssn := tileNumSubSplits[t]
+		ss := sl.SubSplits[i]
+		ss = slicesx.SetLength(ss, ssn)
+		switch t {
+		case TileSpan:
+			ss[0] = 1
+		case TileSplit:
+			sl.normSplits(ss)
+		case TileFirstLong, TileSecondLong:
+			sl.normSplits(ss[:2]) // first is cross-axis
+			sl.normSplits(ss[2:])
+		}
+		sl.SubSplits[i] = ss
+	}
+	return sl
+}
+
+// normSplits normalizes the given splits proportions,
+// using evenSplits if all zero
+func (sl *Splits) normSplits(s []float32) {
 	sum := float32(0)
-	for _, sp := range sl.Splits {
+	for _, sp := range s {
 		sum += sp
 	}
 	if sum == 0 { // set default even splits
-		sl.evenSplits()
-		sum = 1
+		sl.evenSplits(s)
 	} else {
 		norm := 1 / sum
 		for i := range sl.Splits {
 			sl.Splits[i] *= norm
 		}
 	}
-	return sl
 }
 
-// evenSplits splits space evenly across all panels
-func (sl *Splits) evenSplits() {
-	n := len(sl.Children)
+// evenSplits splits space evenly across all elements
+func (sl *Splits) evenSplits(s []float32) {
+	n := len(s)
 	if n == 0 {
 		return
 	}
 	even := 1.0 / float32(n)
-	for i := range sl.Splits {
-		sl.Splits[i] = even
+	for i := range s {
+		s[i] = even
 	}
-	sl.NeedsLayout()
 }
 
 // saveSplits saves the current set of splits in SavedSplits, for a later RestoreSplits
@@ -159,38 +293,159 @@ func (sl *Splits) saveSplits() {
 	if n == 0 {
 		return
 	}
-	if sl.savedSplits == nil || len(sl.savedSplits) != n {
-		sl.savedSplits = make([]float32, n)
-	}
+	sl.savedSplits = slicesx.SetLength(sl.savedSplits, n)
 	copy(sl.savedSplits, sl.Splits)
+	sl.savedSubSplits = slicesx.SetLength(sl.savedSubSplits, n)
+	for i, ss := range sl.SubSplits {
+		sv := sl.savedSubSplits[i]
+		sv = slicesx.SetLength(sv, len(ss))
+		copy(sv, ss)
+		sl.savedSubSplits[i] = sv
+	}
 }
 
 // restoreSplits restores a previously saved set of splits (if it exists), does an update
 func (sl *Splits) restoreSplits() {
-	if sl.savedSplits == nil {
+	if len(sl.savedSplits) != len(sl.Splits) {
 		return
 	}
-	sl.SetSplits(sl.savedSplits...).NeedsLayout()
+	sl.SetSplits(sl.savedSplits...)
+	for i, ss := range sl.SubSplits {
+		sv := sl.savedSubSplits[i]
+		if len(sv) == len(ss) {
+			copy(ss, sv)
+		}
+	}
+	sl.NeedsLayout()
+}
+
+// setSplit sets given proportional "Splits" space to given value.
+// Splits are indexed first by Tiles (major splits) and then
+// within tiles, where TileSplit has 2 and the Long cases,
+// have the long element first followed by the two smaller ones.
+// Calls updateSplits after to ensure renormalization and
+// NeedsLayout to ensure layout is updated.
+func (sl *Splits) setSplit(idx int, val float32) {
+	nt := len(sl.Tiles)
+	if nt == 0 {
+		return
+	}
+	if idx < nt {
+		sl.Splits[idx] = val
+		return
+	}
+	ci := nt
+	for i, t := range sl.Tiles {
+		tn := tileNumElements[t]
+		ri := idx - ci
+		if ri < 0 {
+			break
+		}
+		switch t {
+		case TileSpan:
+		case TileSplit:
+			if ri < 2 {
+				sl.SubSplits[i][ri] = val
+			}
+		case TileFirstLong, TileSecondLong:
+			if ri < 3 {
+				sl.SubSplits[i][ri] = val
+			}
+		}
+		ci += tn
+	}
+	sl.updateSplits()
+	sl.NeedsLayout()
 }
 
-// collapseChild collapses given child(ren) (sets split proportion to 0),
-// optionally saving the prior splits for later Restore function -- does an
-// Update -- triggered by double-click of splitter
-func (sl *Splits) collapseChild(save bool, idxs ...int) {
+// collapseSplit collapses the splitter region(s) at given index(es),
+// by setting splits value to 0.
+// optionally saving the prior splits for later Restore function.
+func (sl *Splits) collapseSplit(save bool, idxs ...int) {
 	if save {
 		sl.saveSplits()
 	}
-	n := len(sl.Children)
 	for _, idx := range idxs {
-		if idx >= 0 && idx < n {
-			sl.Splits[idx] = 0
+		sl.setSplit(idx, 0)
+	}
+}
+
+// setHandlePos sets given splits handle position to given 0-1 normalized value.
+// Handles are indexed 0..Tiles-1 for main tiles handles, then sequentially
+// for any additional child sub-splits depending on tile config.
+// Calls updateSplits after to ensure renormalization and
+// NeedsLayout to ensure layout is updated.
+func (sl *Splits) setHandlePos(idx int, val float32) {
+	val = math32.Clamp(val, 0, 1)
+
+	update := func(idx int, nw float32, s []float32) {
+		n := len(s)
+		old := s[idx]
+		sumTo := float32(0)
+		for i := range idx + 1 {
+			sumTo += s[i]
+		}
+		delta := nw - sumTo
+		uval := old + delta
+		if uval < 0 {
+			uval = 0
+			delta = -old
+			nw = sumTo + delta
+		}
+		rmdr := 1 - nw
+		oldrmdr := 1 - sumTo
+		if oldrmdr <= 0 {
+			if rmdr > 0 {
+				dper := rmdr / float32((n-1)-idx)
+				for i := idx + 1; i < n; i++ {
+					s[i] = dper
+				}
+			}
+		} else {
+			for i := idx + 1; i < n; i++ {
+				cur := s[i]
+				s[i] = rmdr * (cur / oldrmdr) // proportional
+			}
+		}
+		s[idx] = uval
+	}
+
+	nt := len(sl.Tiles)
+	if idx < nt-1 {
+		update(idx, val, sl.Splits)
+		sl.updateSplits()
+		sl.NeedsLayout()
+		return
+	}
+	ci := nt - 1
+	for i, t := range sl.Tiles {
+		tn := tileNumElements[t] - 1
+		if tn == 0 {
+			continue
+		}
+		if idx < ci || idx >= ci+tn {
+			ci += tn
+			continue
 		}
+		ri := idx - ci
+		switch t {
+		case TileSplit:
+			update(0, val, sl.SubSplits[i])
+		case TileFirstLong, TileSecondLong:
+			if ri == 0 {
+				update(0, val, sl.SubSplits[i][:2])
+			} else {
+				update(0, val, sl.SubSplits[i][2:])
+			}
+		}
+		ci += tn
 	}
 	sl.updateSplits()
 	sl.NeedsLayout()
 }
 
-// restoreChild restores given child(ren) -- does an Update
+// restoreChild restores given child(ren)
+// todo: not clear if this makes sense anymore
 func (sl *Splits) restoreChild(idxs ...int) {
 	n := len(sl.Children)
 	for _, idx := range idxs {
@@ -202,87 +457,127 @@ func (sl *Splits) restoreChild(idxs ...int) {
 	sl.NeedsLayout()
 }
 
-// isCollapsed returns true if given split number is collapsed
-func (sl *Splits) isCollapsed(idx int) bool {
-	n := len(sl.Children)
-	if idx >= 0 && idx < n {
-		return sl.Splits[idx] < 0.01
+// splitValue returns the split proportion for given child index
+func (sl *Splits) splitValue(idx int) float32 {
+	ci := 0
+	for i, t := range sl.Tiles {
+		tn := tileNumElements[t]
+		if idx < ci || idx >= ci+tn {
+			ci += tn
+			continue
+		}
+		ri := idx - ci
+		switch t {
+		case TileSpan:
+			return sl.Splits[i]
+		case TileSplit:
+			return sl.SubSplits[i][ri]
+		case TileFirstLong:
+			if ri == 0 {
+				return sl.SubSplits[i][0]
+			} else {
+				return sl.SubSplits[i][2+ri-1]
+			}
+		case TileSecondLong:
+			if ri == 2 {
+				return sl.SubSplits[i][1]
+			} else {
+				return sl.SubSplits[i][2+ri]
+			}
+		}
+		ci += tn
 	}
-	return false
+	return 0
 }
 
-// setSplit sets the new splitter value, for given splitter.
-// New value is 0..1 value of position of that splitter.
-// It is a sum of all the positions up to that point.
-// Splitters are updated to ensure that selected position is achieved,
-// while dividing remainder appropriately.
-func (sl *Splits) setSplit(idx int, nwval float32) {
-	n := len(sl.Splits)
-	oldsum := float32(0)
-	for i := 0; i <= idx; i++ {
-		oldsum += sl.Splits[i]
-	}
-	delta := nwval - oldsum
-	oldval := sl.Splits[idx]
-	uval := oldval + delta
-	if uval < 0 {
-		uval = 0
-		delta = -oldval
-		nwval = oldsum + delta
-	}
-	rmdr := 1 - nwval
-	if idx < n-1 {
-		oldrmdr := 1 - oldsum
-		if oldrmdr <= 0 {
-			if rmdr > 0 {
-				dper := rmdr / float32((n-1)-idx)
-				for i := idx + 1; i < n; i++ {
-					sl.Splits[i] = dper
-				}
+// isCollapsed returns true if the split proportion
+// for given child index is 0.  Also checks the overall tile
+// splits for the child.
+func (sl *Splits) isCollapsed(idx int) bool {
+	if sl.splitValue(idx) < 0.01 {
+		return true
+	}
+	ci := 0
+	for i, t := range sl.Tiles {
+		tn := tileNumElements[t]
+		if idx < ci || idx >= ci+tn {
+			ci += tn
+			continue
+		}
+		ri := idx - ci
+		if sl.Splits[i] < 0.01 {
+			return true
+		}
+		// extra consideration for long split onto subs:
+		switch t {
+		case TileFirstLong:
+			if ri > 0 && sl.SubSplits[i][1] < 0.01 {
+				return true
 			}
-		} else {
-			for i := idx + 1; i < n; i++ {
-				curval := sl.Splits[i]
-				sl.Splits[i] = rmdr * (curval / oldrmdr) // proportional
+		case TileSecondLong:
+			if ri < 2 && sl.SubSplits[i][0] < 0.01 {
+				return true
 			}
 		}
+		return false
 	}
-	sl.Splits[idx] = uval
-	sl.updateSplits()
-	sl.NeedsLayout()
+	return false
 }
 
 func (sl *Splits) SizeDownSetAllocs(iter int) {
+	if sl.NumChildren() <= 1 {
+		return
+	}
 	sl.updateSplits()
 	sz := &sl.Geom.Size
-	csz := sz.Alloc.Content.Sub(sz.InnerSpace)
+	// note: InnerSpace is computed based on n children -- not accurate!
+	csz := sz.Alloc.Content
 	dim := sl.Styles.Direction.Dim()
-	od := dim.Other()
+	odim := dim.Other()
 	cszd := csz.Dim(dim)
-	cszod := csz.Dim(od)
+	cszo := csz.Dim(odim)
+	gap := sl.Styles.Gap.Dots().Floor()
+	gapd := gap.Dim(dim)
+	gapo := gap.Dim(odim)
 	hand := sl.Parts.Child(0).(*Handle)
 	hwd := hand.Geom.Size.Actual.Total.Dim(dim)
-	cszd -= float32(len(sl.Splits)-1) * hwd
-	sl.ForWidgetChildren(func(i int, cw Widget, cwb *WidgetBase) bool {
-		sw := math32.Round(sl.Splits[i] * cszd)
+	cszd -= float32(len(sl.Splits)-1) * (hwd + gapd)
+
+	setCsz := func(idx int, szm, szc float32) {
+		cwb := AsWidget(sl.Child(idx))
 		ksz := &cwb.Geom.Size
-		ksz.Alloc.Total.SetDim(dim, sw)
-		ksz.Alloc.Total.SetDim(od, cszod)
+		ksz.Alloc.Total.SetDim(dim, szm)
+		ksz.Alloc.Total.SetDim(odim, szc)
 		ksz.setContentFromTotal(&ksz.Alloc)
-		// ksz.Actual = ksz.Alloc
-		return tree.Continue
-	})
-}
+	}
 
-func (sl *Splits) Position() {
-	if !sl.HasChildren() {
-		sl.Frame.Position()
-		return
+	ci := 0
+	for i, t := range sl.Tiles {
+		szt := math32.Round(sl.Splits[i] * cszd) // tile size, main axis
+		szcs := cszo - hwd - gapo                // cross axis spilt
+		szs := szt - hwd - gapd
+		tn := tileNumElements[t]
+		switch t {
+		case TileSpan:
+			setCsz(ci, szt, cszo)
+		case TileSplit:
+			setCsz(ci, szt, math32.Round(szcs*sl.SubSplits[i][0]))
+			setCsz(ci+1, szt, math32.Round(szcs*sl.SubSplits[i][1]))
+		case TileFirstLong:
+			fcht := math32.Round(szcs * sl.SubSplits[i][0])
+			scht := math32.Round(szcs * sl.SubSplits[i][1])
+			setCsz(ci, szt, fcht)
+			setCsz(ci+1, math32.Round(szs*sl.SubSplits[i][2]), scht)
+			setCsz(ci+2, math32.Round(szs*sl.SubSplits[i][3]), scht)
+		case TileSecondLong:
+			fcht := math32.Round(szcs * sl.SubSplits[i][0])
+			scht := math32.Round(szcs * sl.SubSplits[i][1])
+			setCsz(ci+2, szt, fcht)
+			setCsz(ci, math32.Round(szs*sl.SubSplits[i][2]), scht)
+			setCsz(ci+1, math32.Round(szs*sl.SubSplits[i][3]), scht)
+		}
+		ci += tn
 	}
-	sl.updateSplits()
-	sl.ConfigScrolls()
-	sl.positionSplits()
-	sl.positionChildren()
 }
 
 func (sl *Splits) positionSplits() {
@@ -294,45 +589,96 @@ func (sl *Splits) positionSplits() {
 	}
 	sz := &sl.Geom.Size
 	dim := sl.Styles.Direction.Dim()
-	od := dim.Other()
-	csz := sz.Alloc.Content.Sub(sz.InnerSpace)
+	odim := dim.Other()
+	csz := sz.Alloc.Content
 	cszd := csz.Dim(dim)
-	pos := float32(0)
+	cszo := csz.Dim(odim)
+	gap := sl.Styles.Gap.Dots().Floor()
+	gapd := gap.Dim(dim)
+	gapo := gap.Dim(odim)
 
 	hand := sl.Parts.Child(0).(*Handle)
 	hwd := hand.Geom.Size.Actual.Total.Dim(dim)
-	hht := hand.Geom.Size.Actual.Total.Dim(od)
-	mid := (csz.Dim(od) - hht) / 2
-	cszd -= float32(len(sl.Splits)-1) * hwd
+	hht := hand.Geom.Size.Actual.Total.Dim(odim)
+	cszd -= float32(len(sl.Splits)-1) * (hwd + gapd)
+	hwdg := hwd + 0.5*gapd
 
-	sl.ForWidgetChildren(func(i int, cw Widget, cwb *WidgetBase) bool {
-		cwb.Geom.RelPos.SetZero()
-		if i == 0 {
-			return tree.Continue
+	setChildPos := func(idx int, dpos, opos float32) {
+		cwb := AsWidget(sl.Child(idx))
+		cwb.Geom.RelPos.SetDim(dim, dpos)
+		cwb.Geom.RelPos.SetDim(odim, opos)
+	}
+	setHandlePos := func(idx int, dpos, opos, lpos, mn, mx float32) {
+		hl := sl.Parts.Child(idx).(*Handle)
+		hl.Geom.RelPos.SetDim(dim, dpos)
+		hl.Geom.RelPos.SetDim(odim, opos)
+		hl.Pos = lpos
+		hl.Min = mn
+		hl.Max = mx
+	}
+
+	tpos := float32(0) // tile position
+	ci := 0
+	nt := len(sl.Tiles)
+	hi := nt - 1 // extra handles
+
+	for i, t := range sl.Tiles {
+		szt := math32.Round(sl.Splits[i] * cszd) // tile size, main axis
+		szcs := cszo - hwd - gapo                // cross axis spilt
+		szs := szt - hwd - gapd
+		tn := tileNumElements[t]
+		if i > 0 {
+			setHandlePos(i-1, tpos-hwdg, .5*(cszo-hht), tpos, 0, cszd)
 		}
-		sw := math32.Round(sl.Splits[i-1] * cszd)
-		pos += sw + hwd
-		cwb.Geom.RelPos.SetDim(dim, pos)
-		hl := sl.Parts.Child(i - 1).(*Handle)
-		hl.Geom.RelPos.SetDim(dim, pos-hwd)
-		hl.Geom.RelPos.SetDim(od, mid)
-		hl.Min = 0
-		hl.Max = cszd
-		hl.Pos = pos
-		return tree.Continue
-	})
+		switch t {
+		case TileSpan:
+			setChildPos(ci, tpos, 0)
+		case TileSplit:
+			fcht := math32.Round(szcs * sl.SubSplits[i][0])
+			setHandlePos(hi, tpos+.5*(szt-hht), fcht+0.5*gapo, fcht, 0, szcs)
+			hi++
+			setChildPos(ci, tpos, 0)
+			setChildPos(ci+1, tpos, fcht+hwd+gapo)
+		case TileFirstLong, TileSecondLong:
+			fcht := math32.Round(szcs * sl.SubSplits[i][0])
+			scht := math32.Round(szcs * sl.SubSplits[i][1])
+			swd := math32.Round(szs * sl.SubSplits[i][2])
+			bot := fcht + hwd + gapo
+			setHandlePos(hi, tpos+.5*(szt-hht), fcht+0.5*gapo, fcht, 0, szcs) // long
+			if t == TileFirstLong {
+				setHandlePos(hi+1, tpos+swd+0.5*gapd, bot+0.5*(scht-hht), tpos+swd, tpos, tpos+szs)
+				setChildPos(ci, tpos, 0)
+				setChildPos(ci+1, tpos, bot)
+				setChildPos(ci+2, tpos+swd+hwd+gapd, bot)
+			} else {
+				setHandlePos(hi+1, tpos+swd+0.5*gapd, 0.5*(fcht-hht), tpos+swd, tpos, tpos+szs)
+				setChildPos(ci+2, tpos, bot)
+				setChildPos(ci, tpos, 0)
+				setChildPos(ci+1, tpos+swd+hwd+gapd, 0)
+			}
+			hi += 2
+		}
+		ci += tn
+		tpos += szt + hwd + gapd
+	}
+}
+
+func (sl *Splits) Position() {
+	if !sl.HasChildren() {
+		sl.Frame.Position()
+		return
+	}
+	sl.updateSplits()
+	sl.ConfigScrolls()
+	sl.positionSplits()
+	sl.positionChildren()
 }
 
 func (sl *Splits) RenderWidget() {
 	if sl.PushBounds() {
-		sl.ForWidgetChildren(func(i int, cw Widget, cwb *WidgetBase) bool {
-			sp := sl.Splits[i]
-			if sp <= 0.01 {
-				cwb.SetState(true, states.Invisible)
-			} else {
-				cwb.SetState(false, states.Invisible)
-			}
-			cw.RenderWidget()
+		sl.ForWidgetChildren(func(i int, kwi Widget, cwb *WidgetBase) bool {
+			cwb.SetState(sl.isCollapsed(i), states.Invisible)
+			kwi.RenderWidget()
 			return tree.Continue
 		})
 		sl.renderParts()
diff --git a/core/typegen.go b/core/typegen.go
index 5894d4051..56edc53f1 100644
--- a/core/typegen.go
+++ b/core/typegen.go
@@ -745,32 +745,47 @@ func (t *Spinner) SetPrecision(v int) *Spinner { t.Precision = v; return t }
 // converted to decimal prior to printing.
 func (t *Spinner) SetFormat(v string) *Spinner { t.Format = v; return t }
 
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.Splits", IDName: "splits", Doc: "Splits allocates a certain proportion of its space to each of its children,\norganized according to Order and [styles.Style.Columns], where Columns\ncan be an even divisor of the Order length to create 2D layouts,\nor 1 to specify a vertical instead of horizontal layout.\nIt adds [Handle] widgets to its parts that allow the user to customize\nthe amount of space allocated to each child.", Embeds: []types.Field{{Name: "Frame"}}, Fields: []types.Field{{Name: "Splits", Doc: "Splits is the proportion (0-1 normalized, enforced) of space\nallocated to each element. 0 indicates that an element should\nbe completely collapsed. By default, each element gets the\nsame amount of space."}, {Name: "Order", Doc: "Order is the organization of the splits content, using indexes 0..n-1\nto specify what goes where, in generally increasing seqeuential order.\nThis is used to specify 2D layouts when [styles.Style.Columns] is an\neven divisor of the Order length, e.g., 0012 with Columns = 2\nspecifies the first element spanning the top row, with the next two\nelements splitting the bottom row."}, {Name: "savedSplits", Doc: "savedSplits is a saved version of the splits that can be restored\nfor dynamic collapse/expand operations."}}})
+var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.Splits", IDName: "splits", Doc: "Splits allocates a certain proportion of its space to each of its children,\norganized along [styles.Style.Direction] as the main axis, and supporting\n[SplitsTiles] of 2D splits configurations along the cross axis.\nThere is always a split between each Tile segment along the main axis,\nwith the proportion of the total main axis space per Tile allocated\naccording to normalized Splits factors.\nIf all Tiles are Span then a 1D line is generated.  Children are allocated\nin order along the main axis, according to each of the Tiles,\nwhich consume 1, 2 or 3 elements, and have 0, 1 or 2 splits internally.\nThe internal split proportion are stored separately in SubSplits.\nA [Handle] widget is added to the Parts for each split, allowing the user\nto drag the relative size of each splits region.\nIf more complex geometries are required, use nested Splits.", Embeds: []types.Field{{Name: "Frame"}}, Fields: []types.Field{{Name: "Tiles", Doc: "Tiles specifies the 2D layout of elements along the [styles.Style.Direction]\nmain axis and the orthogonal cross axis.  If all Tiles are TileSpan, then\na 1D line is generated.  There is always a split between each Tile segment,\nand different tiles consume different numbers of elements in order, and\nhave different numbers of SubSplits.  Because each Tile can represent a\ndifferent number of elements, care must be taken to ensure that the full\nset of tiles corresponds to the actual number of children.  A default\n1D configuration will be imposed if there is a mismatch."}, {Name: "Splits", Doc: "Splits is the proportion (0-1 normalized, enforced) of space\nallocated to each Tile element along the main axis.\n0 indicates that an element should  be completely collapsed.\nBy default, each element gets the same amount of space."}, {Name: "SubSplits", Doc: "SubSplits contains splits proportions for each Tile element, with\na variable number depending on the Tile.  For the First and Second Long\nelements, there are 2 subsets of sub-splits, with 4 total subsplits."}, {Name: "savedSplits", Doc: "savedSplits is a saved version of the Splits that can be restored\nfor dynamic collapse/expand operations."}, {Name: "savedSubSplits", Doc: "savedSubSplits is a saved version of the SubSplits that can be restored\nfor dynamic collapse/expand operations."}}})
 
 // NewSplits returns a new [Splits] with the given optional parent:
 // Splits allocates a certain proportion of its space to each of its children,
-// organized according to Order and [styles.Style.Columns], where Columns
-// can be an even divisor of the Order length to create 2D layouts,
-// or 1 to specify a vertical instead of horizontal layout.
-// It adds [Handle] widgets to its parts that allow the user to customize
-// the amount of space allocated to each child.
+// organized along [styles.Style.Direction] as the main axis, and supporting
+// [SplitsTiles] of 2D splits configurations along the cross axis.
+// There is always a split between each Tile segment along the main axis,
+// with the proportion of the total main axis space per Tile allocated
+// according to normalized Splits factors.
+// If all Tiles are Span then a 1D line is generated.  Children are allocated
+// in order along the main axis, according to each of the Tiles,
+// which consume 1, 2 or 3 elements, and have 0, 1 or 2 splits internally.
+// The internal split proportion are stored separately in SubSplits.
+// A [Handle] widget is added to the Parts for each split, allowing the user
+// to drag the relative size of each splits region.
+// If more complex geometries are required, use nested Splits.
 func NewSplits(parent ...tree.Node) *Splits { return tree.New[Splits](parent...) }
 
+// SetTiles sets the [Splits.Tiles]:
+// Tiles specifies the 2D layout of elements along the [styles.Style.Direction]
+// main axis and the orthogonal cross axis.  If all Tiles are TileSpan, then
+// a 1D line is generated.  There is always a split between each Tile segment,
+// and different tiles consume different numbers of elements in order, and
+// have different numbers of SubSplits.  Because each Tile can represent a
+// different number of elements, care must be taken to ensure that the full
+// set of tiles corresponds to the actual number of children.  A default
+// 1D configuration will be imposed if there is a mismatch.
+func (t *Splits) SetTiles(v ...SplitsTiles) *Splits { t.Tiles = v; return t }
+
 // SetSplits sets the [Splits.Splits]:
 // Splits is the proportion (0-1 normalized, enforced) of space
-// allocated to each element. 0 indicates that an element should
-// be completely collapsed. By default, each element gets the
-// same amount of space.
+// allocated to each Tile element along the main axis.
+// 0 indicates that an element should  be completely collapsed.
+// By default, each element gets the same amount of space.
 func (t *Splits) SetSplits(v ...float32) *Splits { t.Splits = v; return t }
 
-// SetOrder sets the [Splits.Order]:
-// Order is the organization of the splits content, using indexes 0..n-1
-// to specify what goes where, in generally increasing seqeuential order.
-// This is used to specify 2D layouts when [styles.Style.Columns] is an
-// even divisor of the Order length, e.g., 0012 with Columns = 2
-// specifies the first element spanning the top row, with the next two
-// elements splitting the bottom row.
-func (t *Splits) SetOrder(v ...int) *Splits { t.Order = v; return t }
+// SetSubSplits sets the [Splits.SubSplits]:
+// SubSplits contains splits proportions for each Tile element, with
+// a variable number depending on the Tile.  For the First and Second Long
+// elements, there are 2 subsets of sub-splits, with 4 total subsplits.
+func (t *Splits) SetSubSplits(v ...[]float32) *Splits { t.SubSplits = v; return t }
 
 var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.Stage", IDName: "stage", Doc: "Stage is a container and manager for displaying a [Scene]\nin different functional ways, defined by [StageTypes].", Directives: []types.Directive{{Tool: "types", Directive: "add", Args: []string{"-setters"}}}, Fields: []types.Field{{Name: "Type", Doc: "Type is the type of [Stage], which determines behavior and styling."}, {Name: "Scene", Doc: "Scene contents of this [Stage] (what it displays)."}, {Name: "Context", Doc: "Context is a widget in another scene that requested this stage to be created\nand provides context."}, {Name: "Name", Doc: "Name is the name of the Stage, which is generally auto-set\nbased on the [Scene.Name]."}, {Name: "Title", Doc: "Title is the title of the Stage, which is generally auto-set\nbased on the [Body.Title]. It used for the title of [WindowStage]\nand [DialogStage] types."}, {Name: "Modal", Doc: "Modal, if true, blocks input to all other stages."}, {Name: "Scrim", Doc: "Scrim, if true, places a darkening scrim over other stages."}, {Name: "ClickOff", Doc: "ClickOff, if true, dismisses the [Stage] if the user clicks anywhere\noff of the [Stage]."}, {Name: "ignoreEvents", Doc: "ignoreEvents is whether to send no events to the stage and\njust pass them down to lower stages."}, {Name: "NewWindow", Doc: "NewWindow, if true, opens a [WindowStage] or [DialogStage] in its own\nseparate operating system window ([renderWindow]). This is true by\ndefault for [WindowStage] on non-mobile platforms, otherwise false."}, {Name: "FullWindow", Doc: "FullWindow, if [Stage.NewWindow] is false, makes [DialogStage]s and\n[WindowStage]s take up the entire window they are created in."}, {Name: "CloseOnBack", Doc: "CloseOnBack is whether to close the stage when the back button\nis pressed in the app bar. Otherwise, it goes back to the next\nstage but keeps this one open. This is on by default for\n[DialogStage]s and off for [WindowStage]s."}, {Name: "Timeout", Doc: "Timeout, if greater than 0, results in a popup stages disappearing\nafter this timeout duration."}, {Name: "Pos", Doc: "Pos is the target position for the [Stage] to be placed within\nthe surrounding window."}, {Name: "Main", Doc: "If a popup stage, this is the main stage that owns it (via its [Stage.popups]).\nIf a main stage, it points to itself."}, {Name: "popups", Doc: "For main stages, this is the stack of the popups within it\n(created specifically for the main stage).\nFor popups, this is the pointer to the popups within the\nmain stage managing it."}, {Name: "Mains", Doc: "For all stages, this is the main [Stages] that lives in a [renderWindow]\nand manages the main stages."}, {Name: "renderContext", Doc: "rendering context which has info about the RenderWindow onto which we render.\nThis should be used instead of the RenderWindow itself for all relevant\nrendering information. This is only available once a Stage is Run,\nand must always be checked for nil."}, {Name: "Sprites", Doc: "Sprites are named images that are rendered last overlaying everything else."}}})
 
diff --git a/docs/content/2-widgets/2-containers/splits.md b/docs/content/2-widgets/2-containers/splits.md
index 9dcdf82bf..cf56bfda3 100644
--- a/docs/content/2-widgets/2-containers/splits.md
+++ b/docs/content/2-widgets/2-containers/splits.md
@@ -26,6 +26,17 @@ core.NewText(sp).SetText("First")
 core.NewText(sp).SetText("Second")
 ```
 
+You can use [[core.Splits.Tiles]] to create 2D arrangements of widgets, without having to nest multiple splits.  This is simpler because it operates on the same list of child widgets, whereas the nesting approach requires moving child widgets around to switch between different arrangements.
+
+```Go
+sp := core.NewSplits(b)
+core.NewText(sp).SetText("First")
+core.NewText(sp).SetText("Second")
+core.NewText(sp).SetText("Third")
+core.NewText(sp).SetText("Fourth")
+sp.SetTiles(core.TileSpan, core.TileSecondLong)
+```
+
 You can arrange widgets in a column (by default, split widgets are arranged in a row on wide screens and a column on compact screens):
 
 ```Go
diff --git a/filetree/vcslog.go b/filetree/vcslog.go
index 3d78875a8..392f7cd40 100644
--- a/filetree/vcslog.go
+++ b/filetree/vcslog.go
@@ -133,7 +133,9 @@ func (lv *VCSLog) Init() {
 			buf.Filename = core.Filename(lv.File)
 			buf.Options.LineNumbers = true
 			buf.Stat()
-			texteditor.NewEditor(d).SetBuffer(buf)
+			texteditor.NewEditor(d).SetBuffer(buf).Styler(func(s *styles.Style) {
+				s.Grow.Set(1, 1)
+			})
 			buf.SetText(cinfo)
 			d.AddBottomBar(func(parent core.Widget) {
 				core.NewButton(parent).SetText("Copy to clipboard").SetIcon(icons.ContentCopy).
@@ -268,7 +270,9 @@ func fileAtRevisionDialog(ctx core.Widget, repo vcs.Repo, file, rev string) *cor
 	d := core.NewBody().AddTitle(title)
 
 	tb := texteditor.NewBuffer().SetText(fb).SetFilename(file) // file is key for getting lang
-	texteditor.NewEditor(d).SetBuffer(tb).SetReadOnly(true)
+	texteditor.NewEditor(d).SetBuffer(tb).SetReadOnly(true).Styler(func(s *styles.Style) {
+		s.Grow.Set(1, 1)
+	})
 	d.RunWindowDialog(ctx)
 	tb.StartDelayedReMarkup() // update markup
 	return d
diff --git a/yaegicore/symbols/cogentcore_org-core-core.go b/yaegicore/symbols/cogentcore_org-core-core.go
index 82d661ca9..3cf6098ba 100644
--- a/yaegicore/symbols/cogentcore_org-core-core.go
+++ b/yaegicore/symbols/cogentcore_org-core-core.go
@@ -31,7 +31,7 @@ func init() {
 		"AsTextField":                   reflect.ValueOf(core.AsTextField),
 		"AsTree":                        reflect.ValueOf(core.AsTree),
 		"AsWidget":                      reflect.ValueOf(core.AsWidget),
-		"Bind":                          reflect.ValueOf(interp.GenericFunc("func Bind[T Value](value any, vw T, tags ...string) T { //yaegi:add\n\twb := vw.AsWidget()\n\talreadyBound := wb.ValueUpdate != nil\n\twb.ValueUpdate = func() {\n\t\tif vws, ok := any(vw).(ValueSetter); ok {\n\t\t\tErrorSnackbar(vw, vws.SetWidgetValue(value))\n\t\t} else {\n\t\t\tErrorSnackbar(vw, reflectx.SetRobust(vw.WidgetValue(), value))\n\t\t}\n\t}\n\twb.ValueOnChange = func() {\n\t\tErrorSnackbar(vw, reflectx.SetRobust(value, vw.WidgetValue()))\n\t}\n\tif alreadyBound {\n\t\tResetWidgetValue(vw)\n\t}\n\twb.ValueTitle = labels.FriendlyTypeName(reflectx.NonPointerType(reflect.TypeOf(value)))\n\tif ob, ok := any(vw).(OnBinder); ok {\n\t\ttag := reflect.StructTag(\"\")\n\t\tif len(tags) > 0 {\n\t\t\ttag = reflect.StructTag(tags[0])\n\t\t}\n\t\tob.OnBind(value, tag)\n\t}\n\twb.ValueUpdate() // we update it with the initial value immediately\n\treturn vw\n}")),
+		"Bind":                          reflect.ValueOf(interp.GenericFunc("func Bind[T Value](value any, vw T, tags ...string) T { //yaegi:add\n\t// TODO: make tags be reflect.StructTag once yaegi is fixed to work with that\n\twb := vw.AsWidget()\n\talreadyBound := wb.ValueUpdate != nil\n\twb.ValueUpdate = func() {\n\t\tif vws, ok := any(vw).(ValueSetter); ok {\n\t\t\tErrorSnackbar(vw, vws.SetWidgetValue(value))\n\t\t} else {\n\t\t\tErrorSnackbar(vw, reflectx.SetRobust(vw.WidgetValue(), value))\n\t\t}\n\t}\n\twb.ValueOnChange = func() {\n\t\tErrorSnackbar(vw, reflectx.SetRobust(value, vw.WidgetValue()))\n\t}\n\tif alreadyBound {\n\t\tResetWidgetValue(vw)\n\t}\n\twb.ValueTitle = labels.FriendlyTypeName(reflectx.NonPointerType(reflect.TypeOf(value)))\n\tif ob, ok := any(vw).(OnBinder); ok {\n\t\ttag := reflect.StructTag(\"\")\n\t\tif len(tags) > 0 {\n\t\t\ttag = reflect.StructTag(tags[0])\n\t\t}\n\t\tob.OnBind(value, tag)\n\t}\n\twb.ValueUpdate() // we update it with the initial value immediately\n\treturn vw\n}")),
 		"ButtonAction":                  reflect.ValueOf(core.ButtonAction),
 		"ButtonElevated":                reflect.ValueOf(core.ButtonElevated),
 		"ButtonFilled":                  reflect.ValueOf(core.ButtonFilled),
@@ -157,6 +157,8 @@ func init() {
 		"SliderTypesN":                  reflect.ValueOf(core.SliderTypesN),
 		"SliderTypesValues":             reflect.ValueOf(core.SliderTypesValues),
 		"SnackbarStage":                 reflect.ValueOf(core.SnackbarStage),
+		"SplitsTilesN":                  reflect.ValueOf(core.SplitsTilesN),
+		"SplitsTilesValues":             reflect.ValueOf(core.SplitsTilesValues),
 		"StageTypesN":                   reflect.ValueOf(core.StageTypesN),
 		"StageTypesValues":              reflect.ValueOf(core.StageTypesValues),
 		"StandardTabs":                  reflect.ValueOf(core.StandardTabs),
@@ -198,6 +200,10 @@ func init() {
 		"ThemeLight":                    reflect.ValueOf(core.ThemeLight),
 		"ThemesN":                       reflect.ValueOf(core.ThemesN),
 		"ThemesValues":                  reflect.ValueOf(core.ThemesValues),
+		"TileFirstLong":                 reflect.ValueOf(core.TileFirstLong),
+		"TileSecondLong":                reflect.ValueOf(core.TileSecondLong),
+		"TileSpan":                      reflect.ValueOf(core.TileSpan),
+		"TileSplit":                     reflect.ValueOf(core.TileSplit),
 		"ToolbarStyles":                 reflect.ValueOf(core.ToolbarStyles),
 		"TooltipStage":                  reflect.ValueOf(core.TooltipStage),
 		"UpdateAll":                     reflect.ValueOf(core.UpdateAll),
@@ -279,6 +285,7 @@ func init() {
 		"Space":                  reflect.ValueOf((*core.Space)(nil)),
 		"Spinner":                reflect.ValueOf((*core.Spinner)(nil)),
 		"Splits":                 reflect.ValueOf((*core.Splits)(nil)),
+		"SplitsTiles":            reflect.ValueOf((*core.SplitsTiles)(nil)),
 		"Sprite":                 reflect.ValueOf((*core.Sprite)(nil)),
 		"Sprites":                reflect.ValueOf((*core.Sprites)(nil)),
 		"Stage":                  reflect.ValueOf((*core.Stage)(nil)),


Test patch:
diff --git a/base/slicesx/slicesx_test.go b/base/slicesx/slicesx_test.go
index 87da776ae..a4cb41ecf 100644
--- a/base/slicesx/slicesx_test.go
+++ b/base/slicesx/slicesx_test.go
@@ -23,4 +23,7 @@ func TestSetLength(t *testing.T) {
 	s = SetLength(s, 4)
 	assert.Equal(t, 4, len(s))
 	assert.Equal(t, 2, s[2])
+
+	s = SetLength(s, 0)
+	assert.Equal(t, 0, len(s))
 }
diff --git a/core/splits_test.go b/core/splits_test.go
index 2ef98f3cf..deba3eb84 100644
--- a/core/splits_test.go
+++ b/core/splits_test.go
@@ -5,57 +5,70 @@
 package core
 
 import (
+	"strconv"
 	"testing"
 
+	"cogentcore.org/core/colors"
 	"cogentcore.org/core/styles"
 )
 
-func TestSplits(t *testing.T) {
+func makeSplits(n, w, h int) (*Body, *Splits) {
 	b := NewBody()
+	b.Styler(func(s *styles.Style) {
+		s.Min.X.Em(float32(w))
+		s.Min.Y.Em(float32(h))
+	})
 	sp := NewSplits(b)
-	NewText(sp).SetText("First")
-	NewText(sp).SetText("Second")
-	b.AssertRender(t, "splits/basic")
+	for i := range n {
+		f := NewFrame(sp)
+		f.Styler(func(s *styles.Style) {
+			s.Background = colors.Scheme.Select.Container
+		})
+		NewText(f).SetText(strconv.Itoa(i))
+	}
+	return b, sp
 }
 
-func TestSplitsMany(t *testing.T) {
-	b := NewBody()
-	sp := NewSplits(b)
-	NewText(sp).SetText("First")
-	NewText(sp).SetText("Second")
-	NewText(sp).SetText("Third")
-	NewText(sp).SetText("Fourth")
-	b.AssertRender(t, "splits/many")
+func TestSplitsRow2(t *testing.T) {
+	b, _ := makeSplits(2, 40, 20)
+	b.AssertRender(t, "splits/row-2")
 }
 
-func TestSplitsSet(t *testing.T) {
-	b := NewBody()
-	sp := NewSplits(b).SetSplits(0.2, 0.8)
-	NewText(sp).SetText("First")
-	NewText(sp).SetText("Second")
-	b.AssertRender(t, "splits/set")
+func TestSplitsColumn2(t *testing.T) {
+	b, _ := makeSplits(2, 20, 40)
+	b.AssertRender(t, "splits/column-2")
 }
 
-func TestSplitsColumn(t *testing.T) {
-	b := NewBody()
-	sp := NewSplits(b)
+func TestSplitsRow4(t *testing.T) {
+	b, _ := makeSplits(4, 40, 20)
+	b.AssertRender(t, "splits/row-4")
+}
+
+func TestSplitsColumn4(t *testing.T) {
+	b, _ := makeSplits(4, 20, 40)
+	b.AssertRender(t, "splits/column-4")
+}
+
+func TestSplitsRow2Set(t *testing.T) {
+	b, sp := makeSplits(2, 40, 20)
+	sp.SetSplits(0.2, 0.8)
+	b.AssertRender(t, "splits/row-2-set")
+}
+
+func TestSplitsRow2Column(t *testing.T) {
+	b, sp := makeSplits(2, 40, 20)
 	sp.Styler(func(s *styles.Style) {
 		s.Direction = styles.Column
 	})
-	NewText(sp).SetText("First")
-	NewText(sp).SetText("Second")
-	b.AssertRender(t, "splits/column")
+	b.AssertRender(t, "splits/row-2-column")
 }
 
-func TestSplitsRow(t *testing.T) {
-	b := NewBody()
-	sp := NewSplits(b)
+func TestSplitsColumn2Row(t *testing.T) {
+	b, sp := makeSplits(2, 20, 40)
 	sp.Styler(func(s *styles.Style) {
 		s.Direction = styles.Row
 	})
-	NewText(sp).SetText("First")
-	NewText(sp).SetText("Second")
-	b.AssertRender(t, "splits/row")
+	b.AssertRender(t, "splits/column-2-row")
 }
 
 // For https://github.com/cogentcore/core/issues/850
@@ -72,3 +85,33 @@ func TestMixedVerticalSplits(t *testing.T) {
 	NewText(b).SetText(txt)
 	b.AssertRender(t, "splits/mixed-vertical")
 }
+
+func TestSplitsTilesRowSpanFirst(t *testing.T) {
+	b, sp := makeSplits(4, 40, 20)
+	sp.SetTiles(TileSpan, TileFirstLong)
+	b.AssertRender(t, "splits/tiles-row-span-first")
+}
+
+func TestSplitsTilesRowSpanSecond(t *testing.T) {
+	b, sp := makeSplits(4, 40, 20)
+	sp.SetTiles(TileSpan, TileSecondLong)
+	b.AssertRender(t, "splits/tiles-row-span-second")
+}
+
+func TestSplitsTilesColumnSpanFirst(t *testing.T) {
+	b, sp := makeSplits(4, 20, 40)
+	sp.SetTiles(TileSpan, TileFirstLong)
+	b.AssertRender(t, "splits/tiles-column-span-first")
+}
+
+func TestSplitsTilesColumnSpanSecond(t *testing.T) {
+	b, sp := makeSplits(4, 20, 40)
+	sp.SetTiles(TileSpan, TileSecondLong)
+	b.AssertRender(t, "splits/tiles-column-span-second")
+}
+
+func TestSplitsTilesRowSplitSpanSplit(t *testing.T) {
+	b, sp := makeSplits(5, 40, 20)
+	sp.SetTiles(TileSplit, TileSpan, TileSplit)
+	b.AssertRender(t, "splits/tiles-row-split-span-split")
+}