detached 09733c2

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
8080 first, last := w.laneBounds(w.laneOf(i))
8181 S, u, sum := w.laneStats(first, last)
8282 x := w.gap()
83+ valid := w.validSpans(first, last)
8384 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()
8586 }
8687 return x
8788 }
@@ -143,7 +144,13 @@ func (w *Workspace) laneWeight(i, S, u int) int {
143144 // stats of laneStats: the usable width after gaps, split by weight. The
144145 // last column receives the pixel remainder, just as for stamped spans.
145146 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 {
147154 avail := max(w.Usable.W-w.gap()*(last-first+2), 0)
148155 if i == last {
149156 used := 0
@@ -205,8 +212,9 @@ func (w *Workspace) cascadeRectsAt(dst []Rect, ignoreFullWidth bool, shift int)
205212 w.walkBands(func(lane, first, last int) bool {
206213 S, u, sum := w.laneStats(first, last)
207214 x := g
215+ valid := w.validSpans(first, last)
208216 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)
210218 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)}
211219 if w.Columns[i].FullWidth && !ignoreFullWidth {
212220 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 x87 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. The144 // 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 := 0156 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 := g214 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 + g220 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 {
16491649 if w.policy().wraps || w.laneRow() {
16501650 return w.cellWidth(i)
16511651 }
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)
16531670 }
16541671
16551672 // specialWidth is the width of a maximized or fullscreen column.
@@ -1673,13 +1690,13 @@ func (w *Workspace) laneRow() bool {
16731690 // scrollWidth is the width of column i when it is sized on its own: auto
16741691 // columns share the usable width as the visible ones would, the others take
16751692 // 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 {
16771694 k := max(w.MaxColumns, 1)
16781695 if w.policy().equalCells {
16791696 // Fixed overflow past MaxColumns: the spiral's slots.
16801697 return w.equalShare(k)
16811698 }
1682- if w.validSpans(0, len(w.Columns)-1) {
1699+ if spansValid {
16831700 return w.Columns[i].Span * max(w.Usable.W-2*w.gap(), 0) / 10000
16841701 }
16851702 c := w.Columns[i].Cells
@@ -1689,10 +1706,28 @@ func (w *Workspace) scrollWidth(i int) int {
16891706 c = min(c, k)
16901707 return max(c*w.equalShare(k)+(c-1)*w.gap(), 0)
16911708 }
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+
16921726 func (w *Workspace) columnX(i int) int {
1727+ valid := w.scrollSpansValid()
16931728 x := w.Usable.X + w.gap()
16941729 for j := 0; j < i; j++ {
1695- x += w.columnWidth(j) + w.gap()
1730+ x += w.columnWidthWith(j, false, valid) + w.gap()
16961731 }
16971732 return x
16981733 }
@@ -1756,8 +1791,9 @@ func (w *Workspace) columnRectsAt(dst []Rect, ignoreFullWidth bool, shift int) [
17561791 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
17571792 view := w.View + shift
17581793 x := w.Usable.X + g
1794+ valid := w.scrollSpansValid()
17591795 for i := range w.Columns {
1760- width := w.columnWidthFor(i, ignoreFullWidth)
1796+ width := w.columnWidthWith(i, ignoreFullWidth, valid)
17611797 rects[i] = Rect{X: x - view, Y: y, W: width, H: h}
17621798 x += width + g
17631799 }
@@ -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: auto1690 // 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 take1691 // 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) / 100001700 return w.Columns[i].Span * max(w.Usable.W-2*w.gap(), 0) / 10000
1684 }1701 }
1685 c := w.Columns[i].Cells1702 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 x1732 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 + shift1792 view := w.View + shift
1758 x := w.Usable.X + g1793 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 + g1798 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 {
8686 // validity before clearing any stamps, so shared identities cannot become
8787 // valid merely because an earlier invalid lane was cleared.
8888 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+ }
89112 var stale []int
90113 for first := 0; first < len(w.Columns); {
91114 _, 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 become86 // 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 []int112 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) {
2727 })
2828 }
2929 }
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 {
8484 g := w.gap()
8585 minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g
8686 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)
8990 views := make([]int, 0, 2*len(w.Columns))
9091 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))
9293 }
9394 slices.Sort(views)
9495 minStep := float64(w.Usable.W) * snapSpacing
@@ -121,9 +122,10 @@ func (w *Workspace) snapFocus(view int, forward bool) (focus int) {
121122 }
122123 g := w.gap()
123124 minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g
125+ xs, widths := w.columnEdges()
124126 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
127129 }
128130 if full(w.Focus) && view == w.View {
129131 return w.Focus
@@ -138,7 +140,7 @@ func (w *Workspace) snapFocus(view int, forward bool) (focus int) {
138140 // Columns wider than the view: the one at the left edge.
139141 focus = 0
140142 for i := range w.Columns {
141- if w.columnX(i)-view <= minX {
143+ if xs[i]-view <= minX {
142144 focus = i
143145 }
144146 }
@@ -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-g85 minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g
86 last := len(w.Columns) - 186 last := len(w.Columns) - 1
87- lo := w.columnX(0) - minX87+ 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) * snapSpacing95 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-g124 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) - view127+ x := xs[i] - view
126- return x >= minX && x+w.columnWidth(i) <= maxX128+ 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.Focus131 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 = 0141 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 = i144 focus = i
143 }145 }
144 }146 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close