perf(core): validate span lanes once per layout pass
- commit
- 09733c2ab74bb6628950e46382709966b3a70408
- author
- brice · · 2026-10-21 08:06 UTC
- committer
- brice · · 2026-10-21 08:07 UTC
- parent
- 14b0437
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +166 -14
M internal/core/cascade.go +11 -3
| @@ -80,8 +80,9 @@ func (w *Workspace) cellX(i int) int { | |||
| 80 | 80 | first, last := w.laneBounds(w.laneOf(i)) | |
| 81 | 81 | S, u, sum := w.laneStats(first, last) | |
| 82 | 82 | x := w.gap() | |
| 83 | + | valid := w.validSpans(first, last) | |
| 83 | 84 | for j := first; j < i; j++ { | |
| 84 | - | x += w.laneWidth(j, first, last, S, u, sum) + w.gap() | |
| 85 | + | x += w.laneWidthWith(j, first, last, S, u, sum, valid) + w.gap() | |
| 85 | 86 | } | |
| 86 | 87 | return x | |
| 87 | 88 | } | |
| @@ -143,7 +144,13 @@ func (w *Workspace) laneWeight(i, S, u int) int { | |||
| 143 | 144 | // stats of laneStats: the usable width after gaps, split by weight. The | |
| 144 | 145 | // last column receives the pixel remainder, just as for stamped spans. | |
| 145 | 146 | func (w *Workspace) laneWidth(i, first, last, S, u, sum int) int { | |
| 146 | - | if w.validSpans(first, last) { | |
| 147 | + | return w.laneWidthWith(i, first, last, S, u, sum, w.validSpans(first, last)) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // laneWidthWith is laneWidth with the lane's validSpans given, so a loop | |
| 151 | + | // over the lane checks it once. | |
| 152 | + | func (w *Workspace) laneWidthWith(i, first, last, S, u, sum int, spansValid bool) int { | |
| 153 | + | if spansValid { | |
| 147 | 154 | avail := max(w.Usable.W-w.gap()*(last-first+2), 0) | |
| 148 | 155 | if i == last { | |
| 149 | 156 | used := 0 | |
| @@ -205,8 +212,9 @@ func (w *Workspace) cascadeRectsAt(dst []Rect, ignoreFullWidth bool, shift int) | |||
| 205 | 212 | w.walkBands(func(lane, first, last int) bool { | |
| 206 | 213 | S, u, sum := w.laneStats(first, last) | |
| 207 | 214 | x := g | |
| 215 | + | valid := w.validSpans(first, last) | |
| 208 | 216 | for i := first; i <= last; i++ { | |
| 209 | - | cw := w.laneWidth(i, first, last, S, u, sum) | |
| 217 | + | cw := w.laneWidthWith(i, first, last, S, u, sum, valid) | |
| 210 | 218 | r := Rect{X: w.Usable.X + x, Y: w.Usable.Y + g + lane*w.Usable.H - view, W: cw, H: max(w.Usable.H-2*g, 0)} | |
| 211 | 219 | if w.Columns[i].FullWidth && !ignoreFullWidth { | |
| 212 | 220 | r.X = w.Usable.X + g | |
| @@ -80,8 +80,9 @@ func (w *Workspace) cellX(i int) int { | |||||
| 80 | first, last := w.laneBounds(w.laneOf(i)) | 80 | first, last := w.laneBounds(w.laneOf(i)) | ||
| 81 | S, u, sum := w.laneStats(first, last) | 81 | S, u, sum := w.laneStats(first, last) | ||
| 82 | x := w.gap() | 82 | x := w.gap() | ||
| 83 | + | valid := w.validSpans(first, last) | |||
| 83 | for j := first; j < i; j++ { | 84 | for j := first; j < i; j++ { | ||
| 84 | - | x += w.laneWidth(j, first, last, S, u, sum) + w.gap() | 85 | + | x += w.laneWidthWith(j, first, last, S, u, sum, valid) + w.gap() |
| 85 | } | 86 | } | ||
| 86 | return x | 87 | return x | ||
| 87 | } | 88 | } | ||
| @@ -143,7 +144,13 @@ func (w *Workspace) laneWeight(i, S, u int) int { | |||||
| 143 | // stats of laneStats: the usable width after gaps, split by weight. The | 144 | // stats of laneStats: the usable width after gaps, split by weight. The | ||
| 144 | // last column receives the pixel remainder, just as for stamped spans. | 145 | // last column receives the pixel remainder, just as for stamped spans. | ||
| 145 | func (w *Workspace) laneWidth(i, first, last, S, u, sum int) int { | 146 | func (w *Workspace) laneWidth(i, first, last, S, u, sum int) int { | ||
| 146 | - | if w.validSpans(first, last) { | 147 | + | return w.laneWidthWith(i, first, last, S, u, sum, w.validSpans(first, last)) |
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | // laneWidthWith is laneWidth with the lane's validSpans given, so a loop | |||
| 151 | + | // over the lane checks it once. | |||
| 152 | + | func (w *Workspace) laneWidthWith(i, first, last, S, u, sum int, spansValid bool) int { | |||
| 153 | + | if spansValid { | |||
| 147 | avail := max(w.Usable.W-w.gap()*(last-first+2), 0) | 154 | avail := max(w.Usable.W-w.gap()*(last-first+2), 0) | ||
| 148 | if i == last { | 155 | if i == last { | ||
| 149 | used := 0 | 156 | used := 0 | ||
| @@ -205,8 +212,9 @@ func (w *Workspace) cascadeRectsAt(dst []Rect, ignoreFullWidth bool, shift int) | |||||
| 205 | w.walkBands(func(lane, first, last int) bool { | 212 | w.walkBands(func(lane, first, last int) bool { | ||
| 206 | S, u, sum := w.laneStats(first, last) | 213 | S, u, sum := w.laneStats(first, last) | ||
| 207 | x := g | 214 | x := g | ||
| 215 | + | valid := w.validSpans(first, last) | |||
| 208 | for i := first; i <= last; i++ { | 216 | for i := first; i <= last; i++ { | ||
| 209 | - | cw := w.laneWidth(i, first, last, S, u, sum) | 217 | + | cw := w.laneWidthWith(i, first, last, S, u, sum, valid) |
| 210 | r := Rect{X: w.Usable.X + x, Y: w.Usable.Y + g + lane*w.Usable.H - view, W: cw, H: max(w.Usable.H-2*g, 0)} | 218 | r := Rect{X: w.Usable.X + x, Y: w.Usable.Y + g + lane*w.Usable.H - view, W: cw, H: max(w.Usable.H-2*g, 0)} | ||
| 211 | if w.Columns[i].FullWidth && !ignoreFullWidth { | 219 | if w.Columns[i].FullWidth && !ignoreFullWidth { | ||
| 212 | r.X = w.Usable.X + g | 220 | r.X = w.Usable.X + g | ||
M internal/core/layout.go +41 -5
| @@ -1649,7 +1649,24 @@ func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int { | |||
| 1649 | 1649 | if w.policy().wraps || w.laneRow() { | |
| 1650 | 1650 | return w.cellWidth(i) | |
| 1651 | 1651 | } | |
| 1652 | - | return w.scrollWidth(i) | |
| 1652 | + | return w.scrollWidth(i, w.scrollSpansValid()) | |
| 1653 | + | } | |
| 1654 | + | ||
| 1655 | + | // scrollSpansValid is the lane validity scrollWidth needs. It costs O(n): | |
| 1656 | + | // loops over columns compute it once and pass it down. | |
| 1657 | + | func (w *Workspace) scrollSpansValid() bool { | |
| 1658 | + | return !w.policy().equalCells && w.validSpans(0, len(w.Columns)-1) | |
| 1659 | + | } | |
| 1660 | + | ||
| 1661 | + | // columnWidthWith is columnWidthFor with the scroll lane's validity given. | |
| 1662 | + | func (w *Workspace) columnWidthWith(i int, ignoreFullWidth, spansValid bool) int { | |
| 1663 | + | if width, ok := w.specialWidth(i, ignoreFullWidth); ok { | |
| 1664 | + | return width | |
| 1665 | + | } | |
| 1666 | + | if w.policy().wraps || w.laneRow() { | |
| 1667 | + | return w.cellWidth(i) | |
| 1668 | + | } | |
| 1669 | + | return w.scrollWidth(i, spansValid) | |
| 1653 | 1670 | } | |
| 1654 | 1671 | ||
| 1655 | 1672 | // specialWidth is the width of a maximized or fullscreen column. | |
| @@ -1673,13 +1690,13 @@ func (w *Workspace) laneRow() bool { | |||
| 1673 | 1690 | // scrollWidth is the width of column i when it is sized on its own: auto | |
| 1674 | 1691 | // columns share the usable width as the visible ones would, the others take | |
| 1675 | 1692 | // their cells (at most MaxColumns) and the gaps between them. | |
| 1676 | - | func (w *Workspace) scrollWidth(i int) int { | |
| 1693 | + | func (w *Workspace) scrollWidth(i int, spansValid bool) int { | |
| 1677 | 1694 | k := max(w.MaxColumns, 1) | |
| 1678 | 1695 | if w.policy().equalCells { | |
| 1679 | 1696 | // Fixed overflow past MaxColumns: the spiral's slots. | |
| 1680 | 1697 | return w.equalShare(k) | |
| 1681 | 1698 | } | |
| 1682 | - | if w.validSpans(0, len(w.Columns)-1) { | |
| 1699 | + | if spansValid { | |
| 1683 | 1700 | return w.Columns[i].Span * max(w.Usable.W-2*w.gap(), 0) / 10000 | |
| 1684 | 1701 | } | |
| 1685 | 1702 | c := w.Columns[i].Cells | |
| @@ -1689,10 +1706,28 @@ func (w *Workspace) scrollWidth(i int) int { | |||
| 1689 | 1706 | c = min(c, k) | |
| 1690 | 1707 | return max(c*w.equalShare(k)+(c-1)*w.gap(), 0) | |
| 1691 | 1708 | } | |
| 1709 | + | ||
| 1710 | + | // columnEdges returns the x and width of every column in one pass, the | |
| 1711 | + | // prefix sums columnX(i) and columnWidth(i) would each recompute in O(i). | |
| 1712 | + | // Both slices share one backing array. | |
| 1713 | + | func (w *Workspace) columnEdges() (xs, widths []int) { | |
| 1714 | + | n := len(w.Columns) | |
| 1715 | + | buf := make([]int, 2*n) | |
| 1716 | + | xs, widths = buf[:n:n], buf[n:] | |
| 1717 | + | valid := w.scrollSpansValid() | |
| 1718 | + | x := w.Usable.X + w.gap() | |
| 1719 | + | for i := range w.Columns { | |
| 1720 | + | xs[i], widths[i] = x, w.columnWidthWith(i, false, valid) | |
| 1721 | + | x += widths[i] + w.gap() | |
| 1722 | + | } | |
| 1723 | + | return xs, widths | |
| 1724 | + | } | |
| 1725 | + | ||
| 1692 | 1726 | func (w *Workspace) columnX(i int) int { | |
| 1727 | + | valid := w.scrollSpansValid() | |
| 1693 | 1728 | x := w.Usable.X + w.gap() | |
| 1694 | 1729 | for j := 0; j < i; j++ { | |
| 1695 | - | x += w.columnWidth(j) + w.gap() | |
| 1730 | + | x += w.columnWidthWith(j, false, valid) + w.gap() | |
| 1696 | 1731 | } | |
| 1697 | 1732 | return x | |
| 1698 | 1733 | } | |
| @@ -1756,8 +1791,9 @@ func (w *Workspace) columnRectsAt(dst []Rect, ignoreFullWidth bool, shift int) [ | |||
| 1756 | 1791 | rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)] | |
| 1757 | 1792 | view := w.View + shift | |
| 1758 | 1793 | x := w.Usable.X + g | |
| 1794 | + | valid := w.scrollSpansValid() | |
| 1759 | 1795 | for i := range w.Columns { | |
| 1760 | - | width := w.columnWidthFor(i, ignoreFullWidth) | |
| 1796 | + | width := w.columnWidthWith(i, ignoreFullWidth, valid) | |
| 1761 | 1797 | rects[i] = Rect{X: x - view, Y: y, W: width, H: h} | |
| 1762 | 1798 | x += width + g | |
| 1763 | 1799 | } | |
| @@ -1649,7 +1649,24 @@ func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int { | |||||
| 1649 | if w.policy().wraps || w.laneRow() { | 1649 | if w.policy().wraps || w.laneRow() { | ||
| 1650 | return w.cellWidth(i) | 1650 | return w.cellWidth(i) | ||
| 1651 | } | 1651 | } | ||
| 1652 | - | return w.scrollWidth(i) | 1652 | + | return w.scrollWidth(i, w.scrollSpansValid()) |
| 1653 | + | } | |||
| 1654 | + | ||||
| 1655 | + | // scrollSpansValid is the lane validity scrollWidth needs. It costs O(n): | |||
| 1656 | + | // loops over columns compute it once and pass it down. | |||
| 1657 | + | func (w *Workspace) scrollSpansValid() bool { | |||
| 1658 | + | return !w.policy().equalCells && w.validSpans(0, len(w.Columns)-1) | |||
| 1659 | + | } | |||
| 1660 | + | ||||
| 1661 | + | // columnWidthWith is columnWidthFor with the scroll lane's validity given. | |||
| 1662 | + | func (w *Workspace) columnWidthWith(i int, ignoreFullWidth, spansValid bool) int { | |||
| 1663 | + | if width, ok := w.specialWidth(i, ignoreFullWidth); ok { | |||
| 1664 | + | return width | |||
| 1665 | + | } | |||
| 1666 | + | if w.policy().wraps || w.laneRow() { | |||
| 1667 | + | return w.cellWidth(i) | |||
| 1668 | + | } | |||
| 1669 | + | return w.scrollWidth(i, spansValid) | |||
| 1653 | } | 1670 | } | ||
| 1654 | 1671 | ||||
| 1655 | // specialWidth is the width of a maximized or fullscreen column. | 1672 | // specialWidth is the width of a maximized or fullscreen column. | ||
| @@ -1673,13 +1690,13 @@ func (w *Workspace) laneRow() bool { | |||||
| 1673 | // scrollWidth is the width of column i when it is sized on its own: auto | 1690 | // scrollWidth is the width of column i when it is sized on its own: auto | ||
| 1674 | // columns share the usable width as the visible ones would, the others take | 1691 | // columns share the usable width as the visible ones would, the others take | ||
| 1675 | // their cells (at most MaxColumns) and the gaps between them. | 1692 | // their cells (at most MaxColumns) and the gaps between them. | ||
| 1676 | - | func (w *Workspace) scrollWidth(i int) int { | 1693 | + | func (w *Workspace) scrollWidth(i int, spansValid bool) int { |
| 1677 | k := max(w.MaxColumns, 1) | 1694 | k := max(w.MaxColumns, 1) | ||
| 1678 | if w.policy().equalCells { | 1695 | if w.policy().equalCells { | ||
| 1679 | // Fixed overflow past MaxColumns: the spiral's slots. | 1696 | // Fixed overflow past MaxColumns: the spiral's slots. | ||
| 1680 | return w.equalShare(k) | 1697 | return w.equalShare(k) | ||
| 1681 | } | 1698 | } | ||
| 1682 | - | if w.validSpans(0, len(w.Columns)-1) { | 1699 | + | if spansValid { |
| 1683 | return w.Columns[i].Span * max(w.Usable.W-2*w.gap(), 0) / 10000 | 1700 | return w.Columns[i].Span * max(w.Usable.W-2*w.gap(), 0) / 10000 | ||
| 1684 | } | 1701 | } | ||
| 1685 | c := w.Columns[i].Cells | 1702 | c := w.Columns[i].Cells | ||
| @@ -1689,10 +1706,28 @@ func (w *Workspace) scrollWidth(i int) int { | |||||
| 1689 | c = min(c, k) | 1706 | c = min(c, k) | ||
| 1690 | return max(c*w.equalShare(k)+(c-1)*w.gap(), 0) | 1707 | return max(c*w.equalShare(k)+(c-1)*w.gap(), 0) | ||
| 1691 | } | 1708 | } | ||
| 1709 | + | ||||
| 1710 | + | // columnEdges returns the x and width of every column in one pass, the | |||
| 1711 | + | // prefix sums columnX(i) and columnWidth(i) would each recompute in O(i). | |||
| 1712 | + | // Both slices share one backing array. | |||
| 1713 | + | func (w *Workspace) columnEdges() (xs, widths []int) { | |||
| 1714 | + | n := len(w.Columns) | |||
| 1715 | + | buf := make([]int, 2*n) | |||
| 1716 | + | xs, widths = buf[:n:n], buf[n:] | |||
| 1717 | + | valid := w.scrollSpansValid() | |||
| 1718 | + | x := w.Usable.X + w.gap() | |||
| 1719 | + | for i := range w.Columns { | |||
| 1720 | + | xs[i], widths[i] = x, w.columnWidthWith(i, false, valid) | |||
| 1721 | + | x += widths[i] + w.gap() | |||
| 1722 | + | } | |||
| 1723 | + | return xs, widths | |||
| 1724 | + | } | |||
| 1725 | + | ||||
| 1692 | func (w *Workspace) columnX(i int) int { | 1726 | func (w *Workspace) columnX(i int) int { | ||
| 1727 | + | valid := w.scrollSpansValid() | |||
| 1693 | x := w.Usable.X + w.gap() | 1728 | x := w.Usable.X + w.gap() | ||
| 1694 | for j := 0; j < i; j++ { | 1729 | for j := 0; j < i; j++ { | ||
| 1695 | - | x += w.columnWidth(j) + w.gap() | 1730 | + | x += w.columnWidthWith(j, false, valid) + w.gap() |
| 1696 | } | 1731 | } | ||
| 1697 | return x | 1732 | return x | ||
| 1698 | } | 1733 | } | ||
| @@ -1756,8 +1791,9 @@ func (w *Workspace) columnRectsAt(dst []Rect, ignoreFullWidth bool, shift int) [ | |||||
| 1756 | rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)] | 1791 | rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)] | ||
| 1757 | view := w.View + shift | 1792 | view := w.View + shift | ||
| 1758 | x := w.Usable.X + g | 1793 | x := w.Usable.X + g | ||
| 1794 | + | valid := w.scrollSpansValid() | |||
| 1759 | for i := range w.Columns { | 1795 | for i := range w.Columns { | ||
| 1760 | - | width := w.columnWidthFor(i, ignoreFullWidth) | 1796 | + | width := w.columnWidthWith(i, ignoreFullWidth, valid) |
| 1761 | rects[i] = Rect{X: x - view, Y: y, W: width, H: h} | 1797 | rects[i] = Rect{X: x - view, Y: y, W: width, H: h} | ||
| 1762 | x += width + g | 1798 | x += width + g | ||
| 1763 | } | 1799 | } | ||
M internal/core/layout_policy.go +23 -0
| @@ -86,6 +86,29 @@ func (w *Workspace) validSpans(first, last int) bool { | |||
| 86 | 86 | // validity before clearing any stamps, so shared identities cannot become | |
| 87 | 87 | // valid merely because an earlier invalid lane was cleared. | |
| 88 | 88 | func (w *Workspace) dropStaleSpans() { | |
| 89 | + | stamped := false | |
| 90 | + | for _, c := range w.Columns { | |
| 91 | + | if c.Span != 0 || c.spanStamp != 0 { | |
| 92 | + | stamped = true | |
| 93 | + | break | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | if !stamped { | |
| 97 | + | return | |
| 98 | + | } | |
| 99 | + | // The common case has only valid lanes: find out without allocating. | |
| 100 | + | anyStale := false | |
| 101 | + | for first := 0; first < len(w.Columns); { | |
| 102 | + | _, last := w.laneBounds(w.laneOf(first)) | |
| 103 | + | if !w.validSpans(first, last) { | |
| 104 | + | anyStale = true | |
| 105 | + | break | |
| 106 | + | } | |
| 107 | + | first = last + 1 | |
| 108 | + | } | |
| 109 | + | if !anyStale { | |
| 110 | + | return | |
| 111 | + | } | |
| 89 | 112 | var stale []int | |
| 90 | 113 | for first := 0; first < len(w.Columns); { | |
| 91 | 114 | _, last := w.laneBounds(w.laneOf(first)) | |
| @@ -86,6 +86,29 @@ func (w *Workspace) validSpans(first, last int) bool { | |||||
| 86 | // validity before clearing any stamps, so shared identities cannot become | 86 | // validity before clearing any stamps, so shared identities cannot become | ||
| 87 | // valid merely because an earlier invalid lane was cleared. | 87 | // valid merely because an earlier invalid lane was cleared. | ||
| 88 | func (w *Workspace) dropStaleSpans() { | 88 | func (w *Workspace) dropStaleSpans() { | ||
| 89 | + | stamped := false | |||
| 90 | + | for _, c := range w.Columns { | |||
| 91 | + | if c.Span != 0 || c.spanStamp != 0 { | |||
| 92 | + | stamped = true | |||
| 93 | + | break | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | if !stamped { | |||
| 97 | + | return | |||
| 98 | + | } | |||
| 99 | + | // The common case has only valid lanes: find out without allocating. | |||
| 100 | + | anyStale := false | |||
| 101 | + | for first := 0; first < len(w.Columns); { | |||
| 102 | + | _, last := w.laneBounds(w.laneOf(first)) | |||
| 103 | + | if !w.validSpans(first, last) { | |||
| 104 | + | anyStale = true | |||
| 105 | + | break | |||
| 106 | + | } | |||
| 107 | + | first = last + 1 | |||
| 108 | + | } | |||
| 109 | + | if !anyStale { | |||
| 110 | + | return | |||
| 111 | + | } | |||
| 89 | var stale []int | 112 | var stale []int | ||
| 90 | for first := 0; first < len(w.Columns); { | 113 | for first := 0; first < len(w.Columns); { | ||
| 91 | _, last := w.laneBounds(w.laneOf(first)) | 114 | _, last := w.laneBounds(w.laneOf(first)) | ||
M internal/core/layout_policy_test.go +83 -0
| @@ -27,3 +27,86 @@ func TestWorkspaceStepFollowsWorkspaceAxis(t *testing.T) { | |||
| 27 | 27 | }) | |
| 28 | 28 | } | |
| 29 | 29 | } | |
| 30 | + | ||
| 31 | + | // oldValidSpans is the reference implementation validSpans must match. | |
| 32 | + | func oldValidSpans(w *Workspace, first, last int) bool { | |
| 33 | + | if first > last || w.Overflow == OverflowFixed && !w.laneRow() { | |
| 34 | + | return false | |
| 35 | + | } | |
| 36 | + | stamp := w.Columns[first].spanStamp | |
| 37 | + | if stamp == 0 { | |
| 38 | + | return false | |
| 39 | + | } | |
| 40 | + | total, count := 0, 0 | |
| 41 | + | for i := first; i <= last; i++ { | |
| 42 | + | if w.Columns[i].Span <= 0 || w.Columns[i].spanStamp != stamp { | |
| 43 | + | return false | |
| 44 | + | } | |
| 45 | + | total += w.Columns[i].Span | |
| 46 | + | } | |
| 47 | + | for _, c := range w.Columns { | |
| 48 | + | if c.spanStamp == stamp { | |
| 49 | + | count++ | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | return count == last-first+1 && (!w.policy().equalCells || total == 10000) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestScrollWidthsMatchPerColumnValidation(t *testing.T) { | |
| 56 | + | for _, n := range []int{1, 2, 5, 9} { | |
| 57 | + | for _, state := range []string{"unstamped", "stamped", "stale", "foreign", "zero"} { | |
| 58 | + | w := spanWorkspace(OverflowScroll, n) | |
| 59 | + | switch state { | |
| 60 | + | case "stamped": | |
| 61 | + | stampSpans(w, 0, n-1) | |
| 62 | + | case "stale": | |
| 63 | + | stampSpans(w, 0, n-1) | |
| 64 | + | w.Columns[n-1].spanStamp = 99 | |
| 65 | + | case "foreign": | |
| 66 | + | stampSpans(w, 0, n-1) | |
| 67 | + | w.Columns = append(w.Columns, Column{Windows: []WindowID{100}, Span: 5, spanStamp: w.Columns[0].spanStamp}) | |
| 68 | + | case "zero": | |
| 69 | + | stampSpans(w, 0, n-1) | |
| 70 | + | w.Columns[0].Span = 0 | |
| 71 | + | } | |
| 72 | + | last := len(w.Columns) - 1 | |
| 73 | + | if got, want := w.validSpans(0, last), oldValidSpans(w, 0, last); got != want { | |
| 74 | + | t.Fatal(n, state, got, want) | |
| 75 | + | } | |
| 76 | + | valid := w.scrollSpansValid() | |
| 77 | + | xs, widths := w.columnEdges() | |
| 78 | + | rs := w.columnRects() | |
| 79 | + | for i := range w.Columns { | |
| 80 | + | if widths[i] != w.scrollWidth(i, oldValidSpans(w, 0, last)) || valid != oldValidSpans(w, 0, last) { | |
| 81 | + | t.Fatal(n, state, i, "width") | |
| 82 | + | } | |
| 83 | + | if xs[i] != w.columnX(i) || widths[i] != w.columnWidth(i) || rs[i].W != widths[i] { | |
| 84 | + | t.Fatal(n, state, i, "edges") | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func TestDropStaleSpansKeepsValidLanes(t *testing.T) { | |
| 92 | + | w := spanWorkspace(OverflowScroll, 4) | |
| 93 | + | w.dropStaleSpans() | |
| 94 | + | if w.Columns[0].Span != 0 || w.Columns[0].spanStamp != 0 { | |
| 95 | + | t.Fatal("unstamped workspace changed") | |
| 96 | + | } | |
| 97 | + | stampSpans(w, 0, 3) | |
| 98 | + | spans := []int{w.Columns[0].Span, w.Columns[3].Span} | |
| 99 | + | if n := testing.AllocsPerRun(10, w.dropStaleSpans); n != 0 { | |
| 100 | + | t.Fatalf("valid lane allocates %v", n) | |
| 101 | + | } | |
| 102 | + | if w.Columns[0].Span != spans[0] || w.Columns[3].Span != spans[1] { | |
| 103 | + | t.Fatal("valid lane dropped") | |
| 104 | + | } | |
| 105 | + | w.Columns[2].spanStamp = 77 | |
| 106 | + | w.dropStaleSpans() | |
| 107 | + | for i, c := range w.Columns { | |
| 108 | + | if c.Span != 0 || c.spanStamp != 0 { | |
| 109 | + | t.Fatal("stale lane kept", i) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | } | |
| @@ -27,3 +27,86 @@ func TestWorkspaceStepFollowsWorkspaceAxis(t *testing.T) { | |||||
| 27 | }) | 27 | }) | ||
| 28 | } | 28 | } | ||
| 29 | } | 29 | } | ||
| 30 | + | ||||
| 31 | + | // oldValidSpans is the reference implementation validSpans must match. | |||
| 32 | + | func oldValidSpans(w *Workspace, first, last int) bool { | |||
| 33 | + | if first > last || w.Overflow == OverflowFixed && !w.laneRow() { | |||
| 34 | + | return false | |||
| 35 | + | } | |||
| 36 | + | stamp := w.Columns[first].spanStamp | |||
| 37 | + | if stamp == 0 { | |||
| 38 | + | return false | |||
| 39 | + | } | |||
| 40 | + | total, count := 0, 0 | |||
| 41 | + | for i := first; i <= last; i++ { | |||
| 42 | + | if w.Columns[i].Span <= 0 || w.Columns[i].spanStamp != stamp { | |||
| 43 | + | return false | |||
| 44 | + | } | |||
| 45 | + | total += w.Columns[i].Span | |||
| 46 | + | } | |||
| 47 | + | for _, c := range w.Columns { | |||
| 48 | + | if c.spanStamp == stamp { | |||
| 49 | + | count++ | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | return count == last-first+1 && (!w.policy().equalCells || total == 10000) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestScrollWidthsMatchPerColumnValidation(t *testing.T) { | |||
| 56 | + | for _, n := range []int{1, 2, 5, 9} { | |||
| 57 | + | for _, state := range []string{"unstamped", "stamped", "stale", "foreign", "zero"} { | |||
| 58 | + | w := spanWorkspace(OverflowScroll, n) | |||
| 59 | + | switch state { | |||
| 60 | + | case "stamped": | |||
| 61 | + | stampSpans(w, 0, n-1) | |||
| 62 | + | case "stale": | |||
| 63 | + | stampSpans(w, 0, n-1) | |||
| 64 | + | w.Columns[n-1].spanStamp = 99 | |||
| 65 | + | case "foreign": | |||
| 66 | + | stampSpans(w, 0, n-1) | |||
| 67 | + | w.Columns = append(w.Columns, Column{Windows: []WindowID{100}, Span: 5, spanStamp: w.Columns[0].spanStamp}) | |||
| 68 | + | case "zero": | |||
| 69 | + | stampSpans(w, 0, n-1) | |||
| 70 | + | w.Columns[0].Span = 0 | |||
| 71 | + | } | |||
| 72 | + | last := len(w.Columns) - 1 | |||
| 73 | + | if got, want := w.validSpans(0, last), oldValidSpans(w, 0, last); got != want { | |||
| 74 | + | t.Fatal(n, state, got, want) | |||
| 75 | + | } | |||
| 76 | + | valid := w.scrollSpansValid() | |||
| 77 | + | xs, widths := w.columnEdges() | |||
| 78 | + | rs := w.columnRects() | |||
| 79 | + | for i := range w.Columns { | |||
| 80 | + | if widths[i] != w.scrollWidth(i, oldValidSpans(w, 0, last)) || valid != oldValidSpans(w, 0, last) { | |||
| 81 | + | t.Fatal(n, state, i, "width") | |||
| 82 | + | } | |||
| 83 | + | if xs[i] != w.columnX(i) || widths[i] != w.columnWidth(i) || rs[i].W != widths[i] { | |||
| 84 | + | t.Fatal(n, state, i, "edges") | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func TestDropStaleSpansKeepsValidLanes(t *testing.T) { | |||
| 92 | + | w := spanWorkspace(OverflowScroll, 4) | |||
| 93 | + | w.dropStaleSpans() | |||
| 94 | + | if w.Columns[0].Span != 0 || w.Columns[0].spanStamp != 0 { | |||
| 95 | + | t.Fatal("unstamped workspace changed") | |||
| 96 | + | } | |||
| 97 | + | stampSpans(w, 0, 3) | |||
| 98 | + | spans := []int{w.Columns[0].Span, w.Columns[3].Span} | |||
| 99 | + | if n := testing.AllocsPerRun(10, w.dropStaleSpans); n != 0 { | |||
| 100 | + | t.Fatalf("valid lane allocates %v", n) | |||
| 101 | + | } | |||
| 102 | + | if w.Columns[0].Span != spans[0] || w.Columns[3].Span != spans[1] { | |||
| 103 | + | t.Fatal("valid lane dropped") | |||
| 104 | + | } | |||
| 105 | + | w.Columns[2].spanStamp = 77 | |||
| 106 | + | w.dropStaleSpans() | |||
| 107 | + | for i, c := range w.Columns { | |||
| 108 | + | if c.Span != 0 || c.spanStamp != 0 { | |||
| 109 | + | t.Fatal("stale lane kept", i) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | } | |||
M internal/core/slide.go +8 -6
| @@ -84,11 +84,12 @@ func (w *Workspace) snapPoints() []float64 { | |||
| 84 | 84 | g := w.gap() | |
| 85 | 85 | minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g | |
| 86 | 86 | last := len(w.Columns) - 1 | |
| 87 | - | lo := w.columnX(0) - minX | |
| 88 | - | hi := max(w.columnX(last)+w.columnWidth(last)-maxX, lo) | |
| 87 | + | xs, widths := w.columnEdges() | |
| 88 | + | lo := xs[0] - minX | |
| 89 | + | hi := max(xs[last]+widths[last]-maxX, lo) | |
| 89 | 90 | views := make([]int, 0, 2*len(w.Columns)) | |
| 90 | 91 | for i := range w.Columns { | |
| 91 | - | views = append(views, min(max(w.columnX(i)-minX, lo), hi), min(max(w.columnX(i)+w.columnWidth(i)-maxX, lo), hi)) | |
| 92 | + | views = append(views, min(max(xs[i]-minX, lo), hi), min(max(xs[i]+widths[i]-maxX, lo), hi)) | |
| 92 | 93 | } | |
| 93 | 94 | slices.Sort(views) | |
| 94 | 95 | minStep := float64(w.Usable.W) * snapSpacing | |
| @@ -121,9 +122,10 @@ func (w *Workspace) snapFocus(view int, forward bool) (focus int) { | |||
| 121 | 122 | } | |
| 122 | 123 | g := w.gap() | |
| 123 | 124 | minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g | |
| 125 | + | xs, widths := w.columnEdges() | |
| 124 | 126 | full := func(i int) bool { | |
| 125 | - | x := w.columnX(i) - view | |
| 126 | - | return x >= minX && x+w.columnWidth(i) <= maxX | |
| 127 | + | x := xs[i] - view | |
| 128 | + | return x >= minX && x+widths[i] <= maxX | |
| 127 | 129 | } | |
| 128 | 130 | if full(w.Focus) && view == w.View { | |
| 129 | 131 | return w.Focus | |
| @@ -138,7 +140,7 @@ func (w *Workspace) snapFocus(view int, forward bool) (focus int) { | |||
| 138 | 140 | // Columns wider than the view: the one at the left edge. | |
| 139 | 141 | focus = 0 | |
| 140 | 142 | for i := range w.Columns { | |
| 141 | - | if w.columnX(i)-view <= minX { | |
| 143 | + | if xs[i]-view <= minX { | |
| 142 | 144 | focus = i | |
| 143 | 145 | } | |
| 144 | 146 | } | |
| @@ -84,11 +84,12 @@ func (w *Workspace) snapPoints() []float64 { | |||||
| 84 | g := w.gap() | 84 | g := w.gap() | ||
| 85 | minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g | 85 | minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g | ||
| 86 | last := len(w.Columns) - 1 | 86 | last := len(w.Columns) - 1 | ||
| 87 | - | lo := w.columnX(0) - minX | 87 | + | xs, widths := w.columnEdges() |
| 88 | - | hi := max(w.columnX(last)+w.columnWidth(last)-maxX, lo) | 88 | + | lo := xs[0] - minX |
| 89 | + | hi := max(xs[last]+widths[last]-maxX, lo) | |||
| 89 | views := make([]int, 0, 2*len(w.Columns)) | 90 | views := make([]int, 0, 2*len(w.Columns)) | ||
| 90 | for i := range w.Columns { | 91 | for i := range w.Columns { | ||
| 91 | - | views = append(views, min(max(w.columnX(i)-minX, lo), hi), min(max(w.columnX(i)+w.columnWidth(i)-maxX, lo), hi)) | 92 | + | views = append(views, min(max(xs[i]-minX, lo), hi), min(max(xs[i]+widths[i]-maxX, lo), hi)) |
| 92 | } | 93 | } | ||
| 93 | slices.Sort(views) | 94 | slices.Sort(views) | ||
| 94 | minStep := float64(w.Usable.W) * snapSpacing | 95 | minStep := float64(w.Usable.W) * snapSpacing | ||
| @@ -121,9 +122,10 @@ func (w *Workspace) snapFocus(view int, forward bool) (focus int) { | |||||
| 121 | } | 122 | } | ||
| 122 | g := w.gap() | 123 | g := w.gap() | ||
| 123 | minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g | 124 | minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g | ||
| 125 | + | xs, widths := w.columnEdges() | |||
| 124 | full := func(i int) bool { | 126 | full := func(i int) bool { | ||
| 125 | - | x := w.columnX(i) - view | 127 | + | x := xs[i] - view |
| 126 | - | return x >= minX && x+w.columnWidth(i) <= maxX | 128 | + | return x >= minX && x+widths[i] <= maxX |
| 127 | } | 129 | } | ||
| 128 | if full(w.Focus) && view == w.View { | 130 | if full(w.Focus) && view == w.View { | ||
| 129 | return w.Focus | 131 | return w.Focus | ||
| @@ -138,7 +140,7 @@ func (w *Workspace) snapFocus(view int, forward bool) (focus int) { | |||||
| 138 | // Columns wider than the view: the one at the left edge. | 140 | // Columns wider than the view: the one at the left edge. | ||
| 139 | focus = 0 | 141 | focus = 0 | ||
| 140 | for i := range w.Columns { | 142 | for i := range w.Columns { | ||
| 141 | - | if w.columnX(i)-view <= minX { | 143 | + | if xs[i]-view <= minX { |
| 142 | focus = i | 144 | focus = i | ||
| 143 | } | 145 | } | ||
| 144 | } | 146 | } | ||