detached 9cb58f8

perf(core): build per-frame layouts in reused owner buffers

publish rebuilt every screen's layout from fresh slices each frame
(Monitor.Layout, Workspace.Layout, the column, row and stash rects, the
overview rows and zoom tiles): 33 allocs per refreshShown, 32 per
published frame.

Add layoutInto(dst) to Monitor and Workspace and use it only on the
per-frame owner paths (refreshShown into screen.layoutBuf, publish's real
layouts and state() into Core.realBuf). Layout() keeps returning a fresh
slice for every other caller, so retained layouts (snapshots, captures,
drag, transitions) never alias a buffer; Scenes copy placements and never
alias one either. Per-workspace scratch (column, row, tile and zoom
buffers) and the slide's neighbor buffer back the helpers, which are dead
once the placements are built; no cache, nothing to invalidate.

BenchmarkRefreshShown: 33 -> 0 allocs/op, 4.7 -> 3.3 us/op.
Pinned budgets: publish 32 -> 6, leaving 24 -> 4, overview 70 -> 18.
Tests: reused-buffer results equal fresh ones, retained Layout() results
survive later layouts, sent scenes are never mutated (run under -race).
commit
9cb58f8111e4da41c23a1657be4dd3582f047d39
author
brice · · 2026-10-05 03:35 UTC
parent
76bd51c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +461 -67
M internal/core/core.go +6 -2
@@ -68,7 +68,10 @@ type binding struct {
6868 }
6969 type Core struct {
7070 // overviewReal is publish's scratch map of the real layouts under an open overview.
71- overviewReal map[WindowID]Placement
71+ overviewReal map[WindowID]Placement
72+ // realBuf backs the layout read through at once by publish (each
73+ // workspace while overviewReal is filled) and by state(); never retained.
74+ realBuf []Placement
7275 security ports.SecurityState
7376 lockSurfaces []ports.LockSurfacePlacement
7477 lockFocus WindowID
@@ -590,7 +593,8 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
590593 clear(c.overviewReal)
591594 real = c.overviewReal
592595 for w := range sc.mon.all() {
593- for _, p := range w.Layout() {
596+ c.realBuf = w.layoutInto(c.realBuf)
597+ for _, p := range c.realBuf {
594598 real[p.ID] = p
595599 }
596600 }
@@ -68,7 +68,10 @@ type binding struct {
68 }68 }
69 type Core struct {69 type Core struct {
70 // overviewReal is publish's scratch map of the real layouts under an open overview.70 // overviewReal is publish's scratch map of the real layouts under an open overview.
71- overviewReal map[WindowID]Placement71+ overviewReal map[WindowID]Placement
72+ // realBuf backs the layout read through at once by publish (each
73+ // workspace while overviewReal is filled) and by state(); never retained.
74+ realBuf []Placement
72 security ports.SecurityState75 security ports.SecurityState
73 lockSurfaces []ports.LockSurfacePlacement76 lockSurfaces []ports.LockSurfacePlacement
74 lockFocus WindowID77 lockFocus WindowID
@@ -590,7 +593,8 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
590 clear(c.overviewReal)593 clear(c.overviewReal)
591 real = c.overviewReal594 real = c.overviewReal
592 for w := range sc.mon.all() {595 for w := range sc.mon.all() {
593- for _, p := range w.Layout() {596+ c.realBuf = w.layoutInto(c.realBuf)
597+ for _, p := range c.realBuf {
594 real[p.ID] = p598 real[p.ID] = p
595 }599 }
596 }600 }
M internal/core/layout.go +99 -22
@@ -216,6 +216,14 @@ type Workspace struct {
216216 homePos int
217217 // termAt is when core last spawned a terminal for this workspace.
218218 termAt time.Time
219+ // colBuf, rowBuf and tileBuf are appendLayout's scratch (column rects,
220+ // the rows of one column, the tiles before the floats are merged in),
221+ // reused across calls and never handed out: owner goroutine only, and
222+ // appendLayout does not nest on one workspace.
223+ colBuf, rowBuf []Rect
224+ tileBuf []Placement
225+ // zoomBuf is overviewZoom's scratch for the tiles it measures.
226+ zoomBuf []Placement
219227 }
220228
221229 // origPlace remembers where a stashed window was: its column, its row in a
@@ -254,6 +262,35 @@ func (w *Workspace) empty() bool {
254262 return len(w.Columns) == 0 && len(w.Floats) == 0 && len(w.Stash) == 0
255263 }
256264
265+// columnWindows counts the windows in the columns.
266+func (w *Workspace) columnWindows() int {
267+ n := 0
268+ for _, c := range w.Columns {
269+ n += len(c.Windows)
270+ }
271+ return n
272+}
273+
274+// appendHidden appends every window of w as a Hidden placement, in the order
275+// of windows (columns, stash, then floats when floats is set), without
276+// building the list.
277+func (w *Workspace) appendHidden(dst []Placement, floats bool) []Placement {
278+ for _, c := range w.Columns {
279+ for _, id := range c.Windows {
280+ dst = append(dst, Placement{ID: id, Hidden: true})
281+ }
282+ }
283+ for _, f := range w.Stash {
284+ dst = append(dst, Placement{ID: f.ID, Hidden: true})
285+ }
286+ if floats {
287+ for _, f := range w.Floats {
288+ dst = append(dst, Placement{ID: f.ID, Hidden: true})
289+ }
290+ }
291+ return dst
292+}
293+
257294 // windows lists every window: columns first, then the stash, then native
258295 // floats.
259296 func (w *Workspace) windows() []WindowID {
@@ -1361,15 +1398,22 @@ func (w *Workspace) scroll() {
13611398
13621399 // columnRects returns each column's area on screen, before stacking windows.
13631400 func (w *Workspace) columnRects() []Rect {
1364- return w.columnRectsFor(false)
1401+ return w.columnRectsInto(nil, false)
13651402 }
13661403
13671404 // columnRectsFor shares the fixed layout geometry with the overview's hidden
13681405 // columns card, without changing FullWidth or the clients' saved buffers.
13691406 func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect {
1407+ return w.columnRectsInto(nil, ignoreFullWidth)
1408+}
1409+
1410+// columnRectsInto is columnRectsFor built in dst's storage (dst[:0] grown
1411+// when too small): the result aliases dst, so a caller passing a reused
1412+// buffer must be done with the previous result.
1413+func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
13701414 g := w.gap()
13711415 y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)
1372- rects := make([]Rect, len(w.Columns))
1416+ rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
13731417 view := w.ViewX + w.shiftPixels()
13741418 x := w.Usable.X + g
13751419 for i := range w.Columns {
@@ -1383,7 +1427,8 @@ func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect {
13831427 }
13841428 k := max(w.MaxColumns, 1)
13851429 if e := slices.IndexFunc(w.Columns, func(c Column) bool { return c.Expanded }); w.Overflow == OverflowFixed && e >= 0 && k > 1 && len(w.Columns) > 1 {
1386- return w.expandedRects(e, k, y, h)
1430+ // rects is dead here: the expanded rects reuse its storage.
1431+ return w.expandedRects(rects[:0], e, k, y, h)
13871432 }
13881433 if w.Overflow != OverflowFixed || len(w.Columns) <= k {
13891434 return rects
@@ -1400,7 +1445,7 @@ func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect {
14001445 // expandedRects places expanded column e over k-1 cells in its place. The
14011446 // columns before it stack in a strip on its left, those after it on its
14021447 // right; the two strips share the last cell.
1403-func (w *Workspace) expandedRects(e, k, y, h int) []Rect {
1448+func (w *Workspace) expandedRects(rects []Rect, e, k, y, h int) []Rect {
14041449 g := w.gap()
14051450 cell := max((w.Usable.W-g*(k+1))/k, 0)
14061451 wide := (k-1)*cell + (k-2)*g
@@ -1411,15 +1456,14 @@ func (w *Workspace) expandedRects(e, k, y, h int) []Rect {
14111456 leftW = max((rest-g)/2, 0)
14121457 rightW = max(rest-g-leftW, 0)
14131458 }
1414- rects := make([]Rect, 0, len(w.Columns))
14151459 x := w.Usable.X + g
14161460 if len(before) > 0 {
1417- rects = append(rects, stackRects(Rect{X: x, Y: y, W: leftW, H: h}, len(before), g)...)
1461+ rects = stackRectsInto(rects, Rect{X: x, Y: y, W: leftW, H: h}, len(before), g)
14181462 x += leftW + g
14191463 }
14201464 rects = append(rects, Rect{X: x, Y: y, W: wide, H: h})
14211465 if len(after) > 0 {
1422- rects = append(rects, stackRects(Rect{X: x + wide + g, Y: y, W: rightW, H: h}, len(after), g)...)
1466+ rects = stackRectsInto(rects, Rect{X: x + wide + g, Y: y, W: rightW, H: h}, len(after), g)
14231467 }
14241468 // Gaps wider than a narrow output would push columns past its edge.
14251469 right := w.Usable.X + w.Usable.W
@@ -1434,7 +1478,15 @@ func (w *Workspace) expandedRects(e, k, y, h int) []Rect {
14341478 // the rounding remainder. Rows past the bottom (gaps taller than r) are
14351479 // clamped to it, like windows stacked in a column.
14361480 func stackRects(r Rect, n, gap int) []Rect {
1437- rows := make([]Rect, n)
1481+ return stackRectsInto(nil, r, n, gap)
1482+}
1483+
1484+// stackRectsInto appends the n rows of stackRects to dst (reset with dst[:0]
1485+// to reuse its storage).
1486+func stackRectsInto(dst []Rect, r Rect, n, gap int) []Rect {
1487+ base := len(dst)
1488+ dst = slices.Grow(dst, n)[:base+n]
1489+ rows := dst[base:]
14381490 avail := max(r.H-(n-1)*gap, 0)
14391491 height := avail / n
14401492 yy, bottom := r.Y, r.Y+r.H
@@ -1448,17 +1500,25 @@ func stackRects(r Rect, n, gap int) []Rect {
14481500 rows[i] = Rect{X: r.X, Y: yy, W: r.W, H: hh}
14491501 yy += hh + gap
14501502 }
1451- return rows
1503+ return dst
14521504 }
14531505
14541506 // rowRects splits column rect r into the rows of c: by c.Shares when they
14551507 // are valid, else equally (stackRects).
14561508 func rowRects(r Rect, c Column, gap int) []Rect {
1509+ return rowRectsInto(nil, r, c, gap)
1510+}
1511+
1512+// rowRectsInto appends the rows of rowRects to dst (reset with dst[:0] to
1513+// reuse its storage).
1514+func rowRectsInto(dst []Rect, r Rect, c Column, gap int) []Rect {
14571515 n := len(c.Windows)
14581516 if !validShares(c) {
1459- return stackRects(r, n, gap)
1517+ return stackRectsInto(dst, r, n, gap)
14601518 }
1461- rows := make([]Rect, n)
1519+ base := len(dst)
1520+ dst = slices.Grow(dst, n)[:base+n]
1521+ rows := dst[base:]
14621522 avail := max(r.H-(n-1)*gap, 0)
14631523 yy, bottom, used := r.Y, r.Y+r.H, 0
14641524 for i, share := range c.Shares {
@@ -1472,7 +1532,7 @@ func rowRects(r Rect, c Column, gap int) []Rect {
14721532 rows[i] = Rect{X: r.X, Y: yy, W: r.W, H: hh}
14731533 yy += hh + gap
14741534 }
1475- return rows
1535+ return dst
14761536 }
14771537
14781538 // validShares reports whether c.Shares has one share per window, each at
@@ -1501,12 +1561,27 @@ func split(r Rect, gap int, vertical bool) (Rect, Rect) {
15011561 return Rect{X: r.X, Y: r.Y, W: w, H: r.H}, Rect{X: r.X + w + gap, Y: r.Y, W: max(r.W-gap-w, 0), H: r.H}
15021562 }
15031563
1504-func (w *Workspace) Layout() []Placement {
1505- var result []Placement
1564+// Layout places every window of the workspace in a fresh slice the caller
1565+// owns. The per-frame paths use layoutInto instead.
1566+func (w *Workspace) Layout() []Placement { return w.appendLayout(nil) }
1567+
1568+// layoutInto is Layout built in dst[:0]: the result aliases dst's storage
1569+// (a reused buffer's previous result is overwritten), so only a caller that
1570+// is done with the previous one may pass it. It never returns the
1571+// workspace's own scratch.
1572+func (w *Workspace) layoutInto(dst []Placement) []Placement { return w.appendLayout(dst[:0]) }
1573+
1574+// appendLayout appends the workspace's placements to dst.
1575+func (w *Workspace) appendLayout(dst []Placement) []Placement {
15061576 gap := w.gap()
1507- cols := w.columnRects()
1577+ // The column and row rects live in the workspace's scratch: each is dead
1578+ // once the placements it feeds are built (owner goroutine only, and
1579+ // layoutInto does not nest on a workspace).
1580+ w.colBuf = w.columnRectsInto(w.colBuf, false)
1581+ cols := w.colBuf
15081582 focusedID, _ := w.Focused()
15091583 cover := w.cover()
1584+ tiles := w.tileBuf[:0]
15101585 for i, c := range w.Columns {
15111586 col := cols[i]
15121587 n := len(c.Windows)
@@ -1517,7 +1592,8 @@ func (w *Workspace) Layout() []Placement {
15171592 if fullColumn {
15181593 col.W = w.Output.W
15191594 }
1520- rows := rowRects(col, c, gap)
1595+ w.rowBuf = rowRectsInto(w.rowBuf[:0], col, c, gap)
1596+ rows := w.rowBuf
15211597 for j, id := range c.Windows {
15221598 r := rows[j]
15231599 full := w.fullscreen == id && id != 0
@@ -1535,14 +1611,15 @@ func (w *Workspace) Layout() []Placement {
15351611 if hidden {
15361612 r = Rect{}
15371613 }
1538- result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: id == focusedID, Hidden: hidden})
1614+ tiles = append(tiles, Placement{ID: id, Rect: r, Fullscreen: full, Focused: id == focusedID, Hidden: hidden})
15391615 }
15401616 }
1541- setVisibleNeighbors(result, gap, w.Output)
1617+ setVisibleNeighbors(tiles, gap, w.Output)
1618+ // tiles lives in the workspace's scratch until they are copied below.
1619+ w.tileBuf = tiles
15421620 // The column/stash group sits between demoted covering floats and
15431621 // floats above it. Keep the order within each group stable.
1544- tiles := result
1545- result = nil
1622+ result := dst
15461623 const (
15471624 belowTiles = iota
15481625 coveringFloats
@@ -1582,9 +1659,9 @@ func (w *Workspace) Layout() []Placement {
15821659 // The stash the user showed over a covering window tops it, under
15831660 // the dialogs it may open meanwhile.
15841661 appendFloats(coveringFloats)
1585- result = append(result, w.stashLayout(focusedID, cover)...)
1662+ result = w.appendStash(result, focusedID, cover)
15861663 } else {
1587- result = append(result, w.stashLayout(focusedID, cover)...)
1664+ result = w.appendStash(result, focusedID, cover)
15881665 appendFloats(coveringFloats)
15891666 }
15901667 appendFloats(dialogs)
@@ -216,6 +216,14 @@ type Workspace struct {
216 homePos int216 homePos int
217 // termAt is when core last spawned a terminal for this workspace.217 // termAt is when core last spawned a terminal for this workspace.
218 termAt time.Time218 termAt time.Time
219+ // colBuf, rowBuf and tileBuf are appendLayout's scratch (column rects,
220+ // the rows of one column, the tiles before the floats are merged in),
221+ // reused across calls and never handed out: owner goroutine only, and
222+ // appendLayout does not nest on one workspace.
223+ colBuf, rowBuf []Rect
224+ tileBuf []Placement
225+ // zoomBuf is overviewZoom's scratch for the tiles it measures.
226+ zoomBuf []Placement
219 }227 }
220 228
221 // origPlace remembers where a stashed window was: its column, its row in a229 // origPlace remembers where a stashed window was: its column, its row in a
@@ -254,6 +262,35 @@ func (w *Workspace) empty() bool {
254 return len(w.Columns) == 0 && len(w.Floats) == 0 && len(w.Stash) == 0262 return len(w.Columns) == 0 && len(w.Floats) == 0 && len(w.Stash) == 0
255 }263 }
256 264
265+// columnWindows counts the windows in the columns.
266+func (w *Workspace) columnWindows() int {
267+ n := 0
268+ for _, c := range w.Columns {
269+ n += len(c.Windows)
270+ }
271+ return n
272+}
273+
274+// appendHidden appends every window of w as a Hidden placement, in the order
275+// of windows (columns, stash, then floats when floats is set), without
276+// building the list.
277+func (w *Workspace) appendHidden(dst []Placement, floats bool) []Placement {
278+ for _, c := range w.Columns {
279+ for _, id := range c.Windows {
280+ dst = append(dst, Placement{ID: id, Hidden: true})
281+ }
282+ }
283+ for _, f := range w.Stash {
284+ dst = append(dst, Placement{ID: f.ID, Hidden: true})
285+ }
286+ if floats {
287+ for _, f := range w.Floats {
288+ dst = append(dst, Placement{ID: f.ID, Hidden: true})
289+ }
290+ }
291+ return dst
292+}
293+
257 // windows lists every window: columns first, then the stash, then native294 // windows lists every window: columns first, then the stash, then native
258 // floats.295 // floats.
259 func (w *Workspace) windows() []WindowID {296 func (w *Workspace) windows() []WindowID {
@@ -1361,15 +1398,22 @@ func (w *Workspace) scroll() {
1361 1398
1362 // columnRects returns each column's area on screen, before stacking windows.1399 // columnRects returns each column's area on screen, before stacking windows.
1363 func (w *Workspace) columnRects() []Rect {1400 func (w *Workspace) columnRects() []Rect {
1364- return w.columnRectsFor(false)1401+ return w.columnRectsInto(nil, false)
1365 }1402 }
1366 1403
1367 // columnRectsFor shares the fixed layout geometry with the overview's hidden1404 // columnRectsFor shares the fixed layout geometry with the overview's hidden
1368 // columns card, without changing FullWidth or the clients' saved buffers.1405 // columns card, without changing FullWidth or the clients' saved buffers.
1369 func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect {1406 func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect {
1407+ return w.columnRectsInto(nil, ignoreFullWidth)
1408+}
1409+
1410+// columnRectsInto is columnRectsFor built in dst's storage (dst[:0] grown
1411+// when too small): the result aliases dst, so a caller passing a reused
1412+// buffer must be done with the previous result.
1413+func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
1370 g := w.gap()1414 g := w.gap()
1371 y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)1415 y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)
1372- rects := make([]Rect, len(w.Columns))1416+ rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
1373 view := w.ViewX + w.shiftPixels()1417 view := w.ViewX + w.shiftPixels()
1374 x := w.Usable.X + g1418 x := w.Usable.X + g
1375 for i := range w.Columns {1419 for i := range w.Columns {
@@ -1383,7 +1427,8 @@ func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect {
1383 }1427 }
1384 k := max(w.MaxColumns, 1)1428 k := max(w.MaxColumns, 1)
1385 if e := slices.IndexFunc(w.Columns, func(c Column) bool { return c.Expanded }); w.Overflow == OverflowFixed && e >= 0 && k > 1 && len(w.Columns) > 1 {1429 if e := slices.IndexFunc(w.Columns, func(c Column) bool { return c.Expanded }); w.Overflow == OverflowFixed && e >= 0 && k > 1 && len(w.Columns) > 1 {
1386- return w.expandedRects(e, k, y, h)1430+ // rects is dead here: the expanded rects reuse its storage.
1431+ return w.expandedRects(rects[:0], e, k, y, h)
1387 }1432 }
1388 if w.Overflow != OverflowFixed || len(w.Columns) <= k {1433 if w.Overflow != OverflowFixed || len(w.Columns) <= k {
1389 return rects1434 return rects
@@ -1400,7 +1445,7 @@ func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect {
1400 // expandedRects places expanded column e over k-1 cells in its place. The1445 // expandedRects places expanded column e over k-1 cells in its place. The
1401 // columns before it stack in a strip on its left, those after it on its1446 // columns before it stack in a strip on its left, those after it on its
1402 // right; the two strips share the last cell.1447 // right; the two strips share the last cell.
1403-func (w *Workspace) expandedRects(e, k, y, h int) []Rect {1448+func (w *Workspace) expandedRects(rects []Rect, e, k, y, h int) []Rect {
1404 g := w.gap()1449 g := w.gap()
1405 cell := max((w.Usable.W-g*(k+1))/k, 0)1450 cell := max((w.Usable.W-g*(k+1))/k, 0)
1406 wide := (k-1)*cell + (k-2)*g1451 wide := (k-1)*cell + (k-2)*g
@@ -1411,15 +1456,14 @@ func (w *Workspace) expandedRects(e, k, y, h int) []Rect {
1411 leftW = max((rest-g)/2, 0)1456 leftW = max((rest-g)/2, 0)
1412 rightW = max(rest-g-leftW, 0)1457 rightW = max(rest-g-leftW, 0)
1413 }1458 }
1414- rects := make([]Rect, 0, len(w.Columns))
1415 x := w.Usable.X + g1459 x := w.Usable.X + g
1416 if len(before) > 0 {1460 if len(before) > 0 {
1417- rects = append(rects, stackRects(Rect{X: x, Y: y, W: leftW, H: h}, len(before), g)...)1461+ rects = stackRectsInto(rects, Rect{X: x, Y: y, W: leftW, H: h}, len(before), g)
1418 x += leftW + g1462 x += leftW + g
1419 }1463 }
1420 rects = append(rects, Rect{X: x, Y: y, W: wide, H: h})1464 rects = append(rects, Rect{X: x, Y: y, W: wide, H: h})
1421 if len(after) > 0 {1465 if len(after) > 0 {
1422- rects = append(rects, stackRects(Rect{X: x + wide + g, Y: y, W: rightW, H: h}, len(after), g)...)1466+ rects = stackRectsInto(rects, Rect{X: x + wide + g, Y: y, W: rightW, H: h}, len(after), g)
1423 }1467 }
1424 // Gaps wider than a narrow output would push columns past its edge.1468 // Gaps wider than a narrow output would push columns past its edge.
1425 right := w.Usable.X + w.Usable.W1469 right := w.Usable.X + w.Usable.W
@@ -1434,7 +1478,15 @@ func (w *Workspace) expandedRects(e, k, y, h int) []Rect {
1434 // the rounding remainder. Rows past the bottom (gaps taller than r) are1478 // the rounding remainder. Rows past the bottom (gaps taller than r) are
1435 // clamped to it, like windows stacked in a column.1479 // clamped to it, like windows stacked in a column.
1436 func stackRects(r Rect, n, gap int) []Rect {1480 func stackRects(r Rect, n, gap int) []Rect {
1437- rows := make([]Rect, n)1481+ return stackRectsInto(nil, r, n, gap)
1482+}
1483+
1484+// stackRectsInto appends the n rows of stackRects to dst (reset with dst[:0]
1485+// to reuse its storage).
1486+func stackRectsInto(dst []Rect, r Rect, n, gap int) []Rect {
1487+ base := len(dst)
1488+ dst = slices.Grow(dst, n)[:base+n]
1489+ rows := dst[base:]
1438 avail := max(r.H-(n-1)*gap, 0)1490 avail := max(r.H-(n-1)*gap, 0)
1439 height := avail / n1491 height := avail / n
1440 yy, bottom := r.Y, r.Y+r.H1492 yy, bottom := r.Y, r.Y+r.H
@@ -1448,17 +1500,25 @@ func stackRects(r Rect, n, gap int) []Rect {
1448 rows[i] = Rect{X: r.X, Y: yy, W: r.W, H: hh}1500 rows[i] = Rect{X: r.X, Y: yy, W: r.W, H: hh}
1449 yy += hh + gap1501 yy += hh + gap
1450 }1502 }
1451- return rows1503+ return dst
1452 }1504 }
1453 1505
1454 // rowRects splits column rect r into the rows of c: by c.Shares when they1506 // rowRects splits column rect r into the rows of c: by c.Shares when they
1455 // are valid, else equally (stackRects).1507 // are valid, else equally (stackRects).
1456 func rowRects(r Rect, c Column, gap int) []Rect {1508 func rowRects(r Rect, c Column, gap int) []Rect {
1509+ return rowRectsInto(nil, r, c, gap)
1510+}
1511+
1512+// rowRectsInto appends the rows of rowRects to dst (reset with dst[:0] to
1513+// reuse its storage).
1514+func rowRectsInto(dst []Rect, r Rect, c Column, gap int) []Rect {
1457 n := len(c.Windows)1515 n := len(c.Windows)
1458 if !validShares(c) {1516 if !validShares(c) {
1459- return stackRects(r, n, gap)1517+ return stackRectsInto(dst, r, n, gap)
1460 }1518 }
1461- rows := make([]Rect, n)1519+ base := len(dst)
1520+ dst = slices.Grow(dst, n)[:base+n]
1521+ rows := dst[base:]
1462 avail := max(r.H-(n-1)*gap, 0)1522 avail := max(r.H-(n-1)*gap, 0)
1463 yy, bottom, used := r.Y, r.Y+r.H, 01523 yy, bottom, used := r.Y, r.Y+r.H, 0
1464 for i, share := range c.Shares {1524 for i, share := range c.Shares {
@@ -1472,7 +1532,7 @@ func rowRects(r Rect, c Column, gap int) []Rect {
1472 rows[i] = Rect{X: r.X, Y: yy, W: r.W, H: hh}1532 rows[i] = Rect{X: r.X, Y: yy, W: r.W, H: hh}
1473 yy += hh + gap1533 yy += hh + gap
1474 }1534 }
1475- return rows1535+ return dst
1476 }1536 }
1477 1537
1478 // validShares reports whether c.Shares has one share per window, each at1538 // validShares reports whether c.Shares has one share per window, each at
@@ -1501,12 +1561,27 @@ func split(r Rect, gap int, vertical bool) (Rect, Rect) {
1501 return Rect{X: r.X, Y: r.Y, W: w, H: r.H}, Rect{X: r.X + w + gap, Y: r.Y, W: max(r.W-gap-w, 0), H: r.H}1561 return Rect{X: r.X, Y: r.Y, W: w, H: r.H}, Rect{X: r.X + w + gap, Y: r.Y, W: max(r.W-gap-w, 0), H: r.H}
1502 }1562 }
1503 1563
1504-func (w *Workspace) Layout() []Placement {1564+// Layout places every window of the workspace in a fresh slice the caller
1505- var result []Placement1565+// owns. The per-frame paths use layoutInto instead.
1566+func (w *Workspace) Layout() []Placement { return w.appendLayout(nil) }
1567+
1568+// layoutInto is Layout built in dst[:0]: the result aliases dst's storage
1569+// (a reused buffer's previous result is overwritten), so only a caller that
1570+// is done with the previous one may pass it. It never returns the
1571+// workspace's own scratch.
1572+func (w *Workspace) layoutInto(dst []Placement) []Placement { return w.appendLayout(dst[:0]) }
1573+
1574+// appendLayout appends the workspace's placements to dst.
1575+func (w *Workspace) appendLayout(dst []Placement) []Placement {
1506 gap := w.gap()1576 gap := w.gap()
1507- cols := w.columnRects()1577+ // The column and row rects live in the workspace's scratch: each is dead
1578+ // once the placements it feeds are built (owner goroutine only, and
1579+ // layoutInto does not nest on a workspace).
1580+ w.colBuf = w.columnRectsInto(w.colBuf, false)
1581+ cols := w.colBuf
1508 focusedID, _ := w.Focused()1582 focusedID, _ := w.Focused()
1509 cover := w.cover()1583 cover := w.cover()
1584+ tiles := w.tileBuf[:0]
1510 for i, c := range w.Columns {1585 for i, c := range w.Columns {
1511 col := cols[i]1586 col := cols[i]
1512 n := len(c.Windows)1587 n := len(c.Windows)
@@ -1517,7 +1592,8 @@ func (w *Workspace) Layout() []Placement {
1517 if fullColumn {1592 if fullColumn {
1518 col.W = w.Output.W1593 col.W = w.Output.W
1519 }1594 }
1520- rows := rowRects(col, c, gap)1595+ w.rowBuf = rowRectsInto(w.rowBuf[:0], col, c, gap)
1596+ rows := w.rowBuf
1521 for j, id := range c.Windows {1597 for j, id := range c.Windows {
1522 r := rows[j]1598 r := rows[j]
1523 full := w.fullscreen == id && id != 01599 full := w.fullscreen == id && id != 0
@@ -1535,14 +1611,15 @@ func (w *Workspace) Layout() []Placement {
1535 if hidden {1611 if hidden {
1536 r = Rect{}1612 r = Rect{}
1537 }1613 }
1538- result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: id == focusedID, Hidden: hidden})1614+ tiles = append(tiles, Placement{ID: id, Rect: r, Fullscreen: full, Focused: id == focusedID, Hidden: hidden})
1539 }1615 }
1540 }1616 }
1541- setVisibleNeighbors(result, gap, w.Output)1617+ setVisibleNeighbors(tiles, gap, w.Output)
1618+ // tiles lives in the workspace's scratch until they are copied below.
1619+ w.tileBuf = tiles
1542 // The column/stash group sits between demoted covering floats and1620 // The column/stash group sits between demoted covering floats and
1543 // floats above it. Keep the order within each group stable.1621 // floats above it. Keep the order within each group stable.
1544- tiles := result1622+ result := dst
1545- result = nil
1546 const (1623 const (
1547 belowTiles = iota1624 belowTiles = iota
1548 coveringFloats1625 coveringFloats
@@ -1582,9 +1659,9 @@ func (w *Workspace) Layout() []Placement {
1582 // The stash the user showed over a covering window tops it, under1659 // The stash the user showed over a covering window tops it, under
1583 // the dialogs it may open meanwhile.1660 // the dialogs it may open meanwhile.
1584 appendFloats(coveringFloats)1661 appendFloats(coveringFloats)
1585- result = append(result, w.stashLayout(focusedID, cover)...)1662+ result = w.appendStash(result, focusedID, cover)
1586 } else {1663 } else {
1587- result = append(result, w.stashLayout(focusedID, cover)...)1664+ result = w.appendStash(result, focusedID, cover)
1588 appendFloats(coveringFloats)1665 appendFloats(coveringFloats)
1589 }1666 }
1590 appendFloats(dialogs)1667 appendFloats(dialogs)
A internal/core/layout_buffers_test.go +270 -0
@@ -0,0 +1,270 @@
1+package core
2+
3+import (
4+ "reflect"
5+ "slices"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// layoutVariants builds monitors covering the layout paths that use scratch:
13+// scroll and fixed overflow, an expanded column, row shares, floats, a stash,
14+// a fullscreen window, a hidden neighbor workspace, the overview and a slide
15+// toward a neighbor workspace.
16+func layoutVariants(t *testing.T) map[string]*Monitor {
17+ t.Helper()
18+ base := func(o Overflow, n int) *Monitor {
19+ m := newMonitor("", "")
20+ m.SetOutput(300, 200)
21+ m.SetOverflow(o)
22+ m.SetGaps(5)
23+ m.SetMaxColumns(3)
24+ for id := WindowID(1); id <= WindowID(n); id++ {
25+ m.AddWindow(id)
26+ }
27+ return m
28+ }
29+ v := map[string]*Monitor{}
30+ v["scroll"] = base(OverflowScroll, 5)
31+ v["fixed"] = base(OverflowFixed, 5)
32+ v["fixed-spiral"] = base(OverflowFixed, 7)
33+
34+ m := base(OverflowFixed, 4)
35+ m.Current().Columns[1].Expanded = true
36+ v["fixed-expanded"] = m
37+
38+ m = base(OverflowScroll, 4)
39+ w := m.Current()
40+ w.Columns[0].Windows = []WindowID{1, 5, 6}
41+ w.Columns[0].Shares = []int{50, 30, 20}
42+ m.AddWindow(5)
43+ v["shares"] = m
44+
45+ m = base(OverflowScroll, 4)
46+ m.AddFloating(10, 100, 80)
47+ m.AddFloating(11, 60, 40)
48+ v["floats"] = m
49+
50+ m = base(OverflowScroll, 4)
51+ m.Current().Apply(ActionToggleWindowStash)
52+ m.Current().Apply(ActionToggleWindowStash)
53+ m.Focus(2)
54+ m.Current().Apply(ActionToggleWindowStash)
55+ v["stash"] = m
56+
57+ m = base(OverflowScroll, 4)
58+ m.Current().SetFullscreen(2, true)
59+ v["fullscreen"] = m
60+
61+ m = base(OverflowScroll, 3)
62+ m.Apply(ActionFocusWorkspaceDown)
63+ m.AddWindow(20)
64+ m.AddWindow(21)
65+ m.Apply(ActionFocusWorkspaceUp)
66+ v["two-workspaces"] = m
67+
68+ m = base(OverflowScroll, 3)
69+ m.AddFloating(10, 100, 80)
70+ m.ToggleOverview()
71+ v["overview"] = m
72+
73+ m = base(OverflowScroll, 3)
74+ m.Apply(ActionFocusWorkspaceDown)
75+ m.AddWindow(20)
76+ m.Apply(ActionFocusWorkspaceUp)
77+ m.switchOff = 0.4
78+ v["slide"] = m
79+ return v
80+}
81+
82+// TestLayoutIntoMatchesLayout: the buffer-reusing API gives the same
83+// placements as the fresh one, whatever the buffer held before (a larger
84+// stale layout, another monitor's), and repeatedly.
85+func TestLayoutIntoMatchesLayout(t *testing.T) {
86+ variants := layoutVariants(t)
87+ var buf []Placement
88+ for _, name := range slices.Sorted(mapsKeys(variants)) {
89+ m := variants[name]
90+ want := m.Layout()
91+ if len(want) == 0 {
92+ t.Fatalf("%s: empty layout", name)
93+ }
94+ for range 3 {
95+ // Poison the reused buffer: stale entries must not leak.
96+ for i := range buf[:cap(buf)] {
97+ buf[:cap(buf)][i] = Placement{ID: 999, Rect: Rect{X: 1, Y: 1, W: 1, H: 1}, Focused: true, Preview: 3}
98+ }
99+ buf = m.layoutInto(buf)
100+ if !slices.Equal(buf, want) {
101+ t.Fatalf("%s: layoutInto differs\n got %+v\nwant %+v", name, buf, want)
102+ }
103+ if again := m.Layout(); !slices.Equal(again, want) {
104+ t.Fatalf("%s: Layout changed after layoutInto\n got %+v\nwant %+v", name, again, want)
105+ }
106+ }
107+ for w := range m.all() {
108+ ws := w.Layout()
109+ if got := w.layoutInto(buf); !slices.Equal(got, ws) {
110+ t.Fatalf("%s: workspace layoutInto differs", name)
111+ }
112+ }
113+ }
114+}
115+
116+func mapsKeys[K comparable, V any](m map[K]V) func(func(K) bool) {
117+ return func(yield func(K) bool) {
118+ for k := range m {
119+ if !yield(k) {
120+ return
121+ }
122+ }
123+ }
124+}
125+
126+// TestLayoutResultsDoNotAlias: Layout() returns a slice the caller owns. A
127+// retained result stays unchanged after further layouts of the same monitor
128+// or workspace (nested or later, reused-buffer calls included), and two
129+// results never share storage.
130+func TestLayoutResultsDoNotAlias(t *testing.T) {
131+ for name, m := range layoutVariants(t) {
132+ first := m.Layout()
133+ kept := slices.Clone(first)
134+ wfirst := m.Current().Layout()
135+ wkept := slices.Clone(wfirst)
136+
137+ second := m.Layout()
138+ if &first[0] == &second[0] {
139+ t.Fatalf("%s: two Layout() calls share storage", name)
140+ }
141+ var buf []Placement
142+ for range 2 {
143+ buf = m.layoutInto(buf)
144+ buf = m.Current().layoutInto(buf)
145+ m.Current().previewTiles()
146+ m.Current().overviewZoom()
147+ }
148+ // Change the layout itself, then lay out again.
149+ if m.ov.open {
150+ m.OverviewMove(1, 0)
151+ } else {
152+ m.Current().Apply(ActionCycleColumnWidth)
153+ }
154+ _ = m.Layout()
155+ _ = m.Current().Layout()
156+ m.switchOff = 0.3
157+ _ = m.Layout()
158+ m.switchOff = 0
159+
160+ if !slices.Equal(first, kept) {
161+ t.Fatalf("%s: a retained Monitor.Layout() changed\n got %+v\nwant %+v", name, first, kept)
162+ }
163+ if !slices.Equal(wfirst, wkept) {
164+ t.Fatalf("%s: a retained Workspace.Layout() changed", name)
165+ }
166+ }
167+}
168+
169+// TestRectHelpersKeepAppendSemantics: the Into helpers append after dst's
170+// length and give the same rows as the fresh ones.
171+func TestRectHelpersKeepAppendSemantics(t *testing.T) {
172+ r := Rect{X: 3, Y: 4, W: 50, H: 103}
173+ pre := []Rect{{W: 1}, {W: 2}}
174+ got := stackRectsInto(slices.Clone(pre), r, 3, 5)
175+ if !slices.Equal(got[:2], pre) || !slices.Equal(got[2:], stackRects(r, 3, 5)) {
176+ t.Fatalf("stackRectsInto: %+v", got)
177+ }
178+ c := Column{Windows: []WindowID{1, 2, 3}, Shares: []int{50, 30, 20}}
179+ got = rowRectsInto(slices.Clone(pre), r, c, 5)
180+ if !slices.Equal(got[:2], pre) || !slices.Equal(got[2:], rowRects(r, c, 5)) {
181+ t.Fatalf("rowRectsInto: %+v", got)
182+ }
183+ // A reused destination overwrites in place.
184+ buf := make([]Rect, 0, 8)
185+ a := stackRectsInto(buf, r, 3, 5)
186+ b := stackRectsInto(buf, r, 2, 5)
187+ if &a[0] != &b[0] || len(b) != 2 {
188+ t.Fatalf("not reused: %d %d", len(a), len(b))
189+ }
190+}
191+
192+// cloneScene copies every slice a scene carries.
193+func cloneScene(s ports.Scene) ports.Scene {
194+ s.Windows = slices.Clone(s.Windows)
195+ s.Separators = slices.Clone(s.Separators)
196+ s.DropHints = slices.Clone(s.DropHints)
197+ s.Layers = slices.Clone(s.Layers)
198+ s.CaptureIndicators = slices.Clone(s.CaptureIndicators)
199+ return s
200+}
201+
202+// TestPublishedScenesAreNeverMutated: scenes cross to another goroutine and
203+// are immutable, so none may alias the layout scratch that the next publish
204+// rebuilds. Frames run with camera and rect motions, then with the overview
205+// opening; every earlier scene is compared with its copy at the end. Under
206+// -race it also catches a later publish writing what a sent scene reads.
207+func TestPublishedScenesAreNeverMutated(t *testing.T) {
208+ type kept struct{ sent, copy ports.Scene }
209+ var all []kept
210+ take := func(c *Core) {
211+ t.Helper()
212+ select {
213+ case set := <-c.ch.Scenes:
214+ for _, s := range set {
215+ all = append(all, kept{s, cloneScene(s)})
216+ }
217+ default:
218+ t.Fatal("no scene published")
219+ }
220+ }
221+
222+ c := publishRig(t, 2)
223+ t0 := time.Now()
224+ for _, sc := range c.screens {
225+ if sc.name() == "" {
226+ continue
227+ }
228+ ws := sc.mon.Current()
229+ ws.motion = c.spring(viewSpring(100, 0), t0)
230+ ws.shift = 100
231+ startRectMotions(c, sc, t0)
232+ }
233+ for range 12 {
234+ if err := c.step(t.Context(), nil); err != nil {
235+ t.Fatal(err)
236+ }
237+ take(c)
238+ }
239+
240+ // The overview opening: card motions, the real layouts, separators.
241+ o := publishRig(t, 1)
242+ now := time.Now()
243+ shots := o.snapshot(now)
244+ o.cur().mon.ToggleOverview()
245+ o.transition(shots, now)
246+ for range 12 {
247+ if err := o.step(t.Context(), nil); err != nil {
248+ t.Fatal(err)
249+ }
250+ take(o)
251+ }
252+
253+ if len(all) < 36 {
254+ t.Fatalf("only %d scenes", len(all))
255+ }
256+ changed := 0
257+ for i := 1; i < len(all); i++ {
258+ if !reflect.DeepEqual(all[i].sent, all[i-1].sent) {
259+ changed++
260+ }
261+ }
262+ if changed == 0 {
263+ t.Fatal("frames never differ: the test would catch nothing")
264+ }
265+ for i, k := range all {
266+ if !reflect.DeepEqual(k.sent, k.copy) {
267+ t.Fatalf("scene %d (%s) was mutated after it was sent", i, k.sent.Output)
268+ }
269+ }
270+}
@@ -0,0 +1,270 @@
1+package core
2+
3+import (
4+ "reflect"
5+ "slices"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// layoutVariants builds monitors covering the layout paths that use scratch:
13+// scroll and fixed overflow, an expanded column, row shares, floats, a stash,
14+// a fullscreen window, a hidden neighbor workspace, the overview and a slide
15+// toward a neighbor workspace.
16+func layoutVariants(t *testing.T) map[string]*Monitor {
17+ t.Helper()
18+ base := func(o Overflow, n int) *Monitor {
19+ m := newMonitor("", "")
20+ m.SetOutput(300, 200)
21+ m.SetOverflow(o)
22+ m.SetGaps(5)
23+ m.SetMaxColumns(3)
24+ for id := WindowID(1); id <= WindowID(n); id++ {
25+ m.AddWindow(id)
26+ }
27+ return m
28+ }
29+ v := map[string]*Monitor{}
30+ v["scroll"] = base(OverflowScroll, 5)
31+ v["fixed"] = base(OverflowFixed, 5)
32+ v["fixed-spiral"] = base(OverflowFixed, 7)
33+
34+ m := base(OverflowFixed, 4)
35+ m.Current().Columns[1].Expanded = true
36+ v["fixed-expanded"] = m
37+
38+ m = base(OverflowScroll, 4)
39+ w := m.Current()
40+ w.Columns[0].Windows = []WindowID{1, 5, 6}
41+ w.Columns[0].Shares = []int{50, 30, 20}
42+ m.AddWindow(5)
43+ v["shares"] = m
44+
45+ m = base(OverflowScroll, 4)
46+ m.AddFloating(10, 100, 80)
47+ m.AddFloating(11, 60, 40)
48+ v["floats"] = m
49+
50+ m = base(OverflowScroll, 4)
51+ m.Current().Apply(ActionToggleWindowStash)
52+ m.Current().Apply(ActionToggleWindowStash)
53+ m.Focus(2)
54+ m.Current().Apply(ActionToggleWindowStash)
55+ v["stash"] = m
56+
57+ m = base(OverflowScroll, 4)
58+ m.Current().SetFullscreen(2, true)
59+ v["fullscreen"] = m
60+
61+ m = base(OverflowScroll, 3)
62+ m.Apply(ActionFocusWorkspaceDown)
63+ m.AddWindow(20)
64+ m.AddWindow(21)
65+ m.Apply(ActionFocusWorkspaceUp)
66+ v["two-workspaces"] = m
67+
68+ m = base(OverflowScroll, 3)
69+ m.AddFloating(10, 100, 80)
70+ m.ToggleOverview()
71+ v["overview"] = m
72+
73+ m = base(OverflowScroll, 3)
74+ m.Apply(ActionFocusWorkspaceDown)
75+ m.AddWindow(20)
76+ m.Apply(ActionFocusWorkspaceUp)
77+ m.switchOff = 0.4
78+ v["slide"] = m
79+ return v
80+}
81+
82+// TestLayoutIntoMatchesLayout: the buffer-reusing API gives the same
83+// placements as the fresh one, whatever the buffer held before (a larger
84+// stale layout, another monitor's), and repeatedly.
85+func TestLayoutIntoMatchesLayout(t *testing.T) {
86+ variants := layoutVariants(t)
87+ var buf []Placement
88+ for _, name := range slices.Sorted(mapsKeys(variants)) {
89+ m := variants[name]
90+ want := m.Layout()
91+ if len(want) == 0 {
92+ t.Fatalf("%s: empty layout", name)
93+ }
94+ for range 3 {
95+ // Poison the reused buffer: stale entries must not leak.
96+ for i := range buf[:cap(buf)] {
97+ buf[:cap(buf)][i] = Placement{ID: 999, Rect: Rect{X: 1, Y: 1, W: 1, H: 1}, Focused: true, Preview: 3}
98+ }
99+ buf = m.layoutInto(buf)
100+ if !slices.Equal(buf, want) {
101+ t.Fatalf("%s: layoutInto differs\n got %+v\nwant %+v", name, buf, want)
102+ }
103+ if again := m.Layout(); !slices.Equal(again, want) {
104+ t.Fatalf("%s: Layout changed after layoutInto\n got %+v\nwant %+v", name, again, want)
105+ }
106+ }
107+ for w := range m.all() {
108+ ws := w.Layout()
109+ if got := w.layoutInto(buf); !slices.Equal(got, ws) {
110+ t.Fatalf("%s: workspace layoutInto differs", name)
111+ }
112+ }
113+ }
114+}
115+
116+func mapsKeys[K comparable, V any](m map[K]V) func(func(K) bool) {
117+ return func(yield func(K) bool) {
118+ for k := range m {
119+ if !yield(k) {
120+ return
121+ }
122+ }
123+ }
124+}
125+
126+// TestLayoutResultsDoNotAlias: Layout() returns a slice the caller owns. A
127+// retained result stays unchanged after further layouts of the same monitor
128+// or workspace (nested or later, reused-buffer calls included), and two
129+// results never share storage.
130+func TestLayoutResultsDoNotAlias(t *testing.T) {
131+ for name, m := range layoutVariants(t) {
132+ first := m.Layout()
133+ kept := slices.Clone(first)
134+ wfirst := m.Current().Layout()
135+ wkept := slices.Clone(wfirst)
136+
137+ second := m.Layout()
138+ if &first[0] == &second[0] {
139+ t.Fatalf("%s: two Layout() calls share storage", name)
140+ }
141+ var buf []Placement
142+ for range 2 {
143+ buf = m.layoutInto(buf)
144+ buf = m.Current().layoutInto(buf)
145+ m.Current().previewTiles()
146+ m.Current().overviewZoom()
147+ }
148+ // Change the layout itself, then lay out again.
149+ if m.ov.open {
150+ m.OverviewMove(1, 0)
151+ } else {
152+ m.Current().Apply(ActionCycleColumnWidth)
153+ }
154+ _ = m.Layout()
155+ _ = m.Current().Layout()
156+ m.switchOff = 0.3
157+ _ = m.Layout()
158+ m.switchOff = 0
159+
160+ if !slices.Equal(first, kept) {
161+ t.Fatalf("%s: a retained Monitor.Layout() changed\n got %+v\nwant %+v", name, first, kept)
162+ }
163+ if !slices.Equal(wfirst, wkept) {
164+ t.Fatalf("%s: a retained Workspace.Layout() changed", name)
165+ }
166+ }
167+}
168+
169+// TestRectHelpersKeepAppendSemantics: the Into helpers append after dst's
170+// length and give the same rows as the fresh ones.
171+func TestRectHelpersKeepAppendSemantics(t *testing.T) {
172+ r := Rect{X: 3, Y: 4, W: 50, H: 103}
173+ pre := []Rect{{W: 1}, {W: 2}}
174+ got := stackRectsInto(slices.Clone(pre), r, 3, 5)
175+ if !slices.Equal(got[:2], pre) || !slices.Equal(got[2:], stackRects(r, 3, 5)) {
176+ t.Fatalf("stackRectsInto: %+v", got)
177+ }
178+ c := Column{Windows: []WindowID{1, 2, 3}, Shares: []int{50, 30, 20}}
179+ got = rowRectsInto(slices.Clone(pre), r, c, 5)
180+ if !slices.Equal(got[:2], pre) || !slices.Equal(got[2:], rowRects(r, c, 5)) {
181+ t.Fatalf("rowRectsInto: %+v", got)
182+ }
183+ // A reused destination overwrites in place.
184+ buf := make([]Rect, 0, 8)
185+ a := stackRectsInto(buf, r, 3, 5)
186+ b := stackRectsInto(buf, r, 2, 5)
187+ if &a[0] != &b[0] || len(b) != 2 {
188+ t.Fatalf("not reused: %d %d", len(a), len(b))
189+ }
190+}
191+
192+// cloneScene copies every slice a scene carries.
193+func cloneScene(s ports.Scene) ports.Scene {
194+ s.Windows = slices.Clone(s.Windows)
195+ s.Separators = slices.Clone(s.Separators)
196+ s.DropHints = slices.Clone(s.DropHints)
197+ s.Layers = slices.Clone(s.Layers)
198+ s.CaptureIndicators = slices.Clone(s.CaptureIndicators)
199+ return s
200+}
201+
202+// TestPublishedScenesAreNeverMutated: scenes cross to another goroutine and
203+// are immutable, so none may alias the layout scratch that the next publish
204+// rebuilds. Frames run with camera and rect motions, then with the overview
205+// opening; every earlier scene is compared with its copy at the end. Under
206+// -race it also catches a later publish writing what a sent scene reads.
207+func TestPublishedScenesAreNeverMutated(t *testing.T) {
208+ type kept struct{ sent, copy ports.Scene }
209+ var all []kept
210+ take := func(c *Core) {
211+ t.Helper()
212+ select {
213+ case set := <-c.ch.Scenes:
214+ for _, s := range set {
215+ all = append(all, kept{s, cloneScene(s)})
216+ }
217+ default:
218+ t.Fatal("no scene published")
219+ }
220+ }
221+
222+ c := publishRig(t, 2)
223+ t0 := time.Now()
224+ for _, sc := range c.screens {
225+ if sc.name() == "" {
226+ continue
227+ }
228+ ws := sc.mon.Current()
229+ ws.motion = c.spring(viewSpring(100, 0), t0)
230+ ws.shift = 100
231+ startRectMotions(c, sc, t0)
232+ }
233+ for range 12 {
234+ if err := c.step(t.Context(), nil); err != nil {
235+ t.Fatal(err)
236+ }
237+ take(c)
238+ }
239+
240+ // The overview opening: card motions, the real layouts, separators.
241+ o := publishRig(t, 1)
242+ now := time.Now()
243+ shots := o.snapshot(now)
244+ o.cur().mon.ToggleOverview()
245+ o.transition(shots, now)
246+ for range 12 {
247+ if err := o.step(t.Context(), nil); err != nil {
248+ t.Fatal(err)
249+ }
250+ take(o)
251+ }
252+
253+ if len(all) < 36 {
254+ t.Fatalf("only %d scenes", len(all))
255+ }
256+ changed := 0
257+ for i := 1; i < len(all); i++ {
258+ if !reflect.DeepEqual(all[i].sent, all[i-1].sent) {
259+ changed++
260+ }
261+ }
262+ if changed == 0 {
263+ t.Fatal("frames never differ: the test would catch nothing")
264+ }
265+ for i, k := range all {
266+ if !reflect.DeepEqual(k.sent, k.copy) {
267+ t.Fatalf("scene %d (%s) was mutated after it was sent", i, k.sent.Output)
268+ }
269+ }
270+}
M internal/core/monitor.go +16 -7
@@ -24,6 +24,10 @@ type Monitor struct {
2424 shown *Workspace // hidden workspace on screen, nil for Workspaces[Active]
2525 back *Workspace // where a named workspace toggle returns to
2626 template Workspace
27+ // slideBuf and slideShown are slideLayout's scratch (the neighbor's
28+ // layout and its placements by window), reused and never handed out.
29+ slideBuf []Placement
30+ slideShown map[WindowID]Placement
2731 nextID *uint64 // shared by the monitors of one Core; owner goroutine only
2832 named []NamedWorkspace
2933 // followMove shows the target workspace after a move to it.
@@ -157,6 +161,7 @@ func (m *Monitor) Current() *Workspace {
157161
158162 func (m *Monitor) newWorkspace() *Workspace {
159163 w := m.template
164+ w.colBuf, w.rowBuf, w.tileBuf, w.zoomBuf = nil, nil, nil, nil
160165 w.presets = append([]Width(nil), m.template.presets...)
161166 w.Columns, w.Floats, w.maximized, w.floatFocus, w.home = nil, nil, nil, false, ""
162167 w.overviewAfter = nil
@@ -408,20 +413,24 @@ func (m *Monitor) Focused() (WindowID, bool) {
408413 }
409414
410415 // Layout places every window: the workspace on screen laid out, others hidden.
411-func (m *Monitor) Layout() []Placement {
416+// The slice is fresh, the caller's to keep; the per-frame paths use layoutInto.
417+func (m *Monitor) Layout() []Placement { return m.layoutInto(nil) }
418+
419+// layoutInto is Layout built in dst[:0]: the result aliases dst's storage,
420+// so a reused dst's previous result is overwritten and only a caller done
421+// with it may pass it. It is never the monitor's own scratch.
422+func (m *Monitor) layoutInto(dst []Placement) []Placement {
412423 if m.ov.open {
413- return m.overviewLayout()
424+ return m.overviewLayout(dst[:0])
414425 }
415- var result []Placement
426+ result := dst[:0]
416427 cur := m.Current()
417428 for w := range m.all() {
418429 if w == cur {
419- result = append(result, w.Layout()...)
430+ result = w.appendLayout(result)
420431 continue
421432 }
422- for _, id := range w.windows() {
423- result = append(result, Placement{ID: id, Hidden: true})
424- }
433+ result = w.appendHidden(result, true)
425434 }
426435 return m.slideLayout(result)
427436 }
@@ -24,6 +24,10 @@ type Monitor struct {
24 shown *Workspace // hidden workspace on screen, nil for Workspaces[Active]24 shown *Workspace // hidden workspace on screen, nil for Workspaces[Active]
25 back *Workspace // where a named workspace toggle returns to25 back *Workspace // where a named workspace toggle returns to
26 template Workspace26 template Workspace
27+ // slideBuf and slideShown are slideLayout's scratch (the neighbor's
28+ // layout and its placements by window), reused and never handed out.
29+ slideBuf []Placement
30+ slideShown map[WindowID]Placement
27 nextID *uint64 // shared by the monitors of one Core; owner goroutine only31 nextID *uint64 // shared by the monitors of one Core; owner goroutine only
28 named []NamedWorkspace32 named []NamedWorkspace
29 // followMove shows the target workspace after a move to it.33 // followMove shows the target workspace after a move to it.
@@ -157,6 +161,7 @@ func (m *Monitor) Current() *Workspace {
157 161
158 func (m *Monitor) newWorkspace() *Workspace {162 func (m *Monitor) newWorkspace() *Workspace {
159 w := m.template163 w := m.template
164+ w.colBuf, w.rowBuf, w.tileBuf, w.zoomBuf = nil, nil, nil, nil
160 w.presets = append([]Width(nil), m.template.presets...)165 w.presets = append([]Width(nil), m.template.presets...)
161 w.Columns, w.Floats, w.maximized, w.floatFocus, w.home = nil, nil, nil, false, ""166 w.Columns, w.Floats, w.maximized, w.floatFocus, w.home = nil, nil, nil, false, ""
162 w.overviewAfter = nil167 w.overviewAfter = nil
@@ -408,20 +413,24 @@ func (m *Monitor) Focused() (WindowID, bool) {
408 }413 }
409 414
410 // Layout places every window: the workspace on screen laid out, others hidden.415 // Layout places every window: the workspace on screen laid out, others hidden.
411-func (m *Monitor) Layout() []Placement {416+// The slice is fresh, the caller's to keep; the per-frame paths use layoutInto.
417+func (m *Monitor) Layout() []Placement { return m.layoutInto(nil) }
418+
419+// layoutInto is Layout built in dst[:0]: the result aliases dst's storage,
420+// so a reused dst's previous result is overwritten and only a caller done
421+// with it may pass it. It is never the monitor's own scratch.
422+func (m *Monitor) layoutInto(dst []Placement) []Placement {
412 if m.ov.open {423 if m.ov.open {
413- return m.overviewLayout()424+ return m.overviewLayout(dst[:0])
414 }425 }
415- var result []Placement426+ result := dst[:0]
416 cur := m.Current()427 cur := m.Current()
417 for w := range m.all() {428 for w := range m.all() {
418 if w == cur {429 if w == cur {
419- result = append(result, w.Layout()...)430+ result = w.appendLayout(result)
420 continue431 continue
421 }432 }
422- for _, id := range w.windows() {433+ result = w.appendHidden(result, true)
423- result = append(result, Placement{ID: id, Hidden: true})
424- }
425 }434 }
426 return m.slideLayout(result)435 return m.slideLayout(result)
427 }436 }
M internal/core/overview.go +21 -11
@@ -518,10 +518,9 @@ func (m *Monitor) overviewRows() (map[*Workspace]int, []ports.Separator) {
518518 // overviewLayout places the current workspace and its overview neighbors,
519519 // each with its full-width stash pile and covering-float stack. Other rows
520520 // stay hidden; numbered and named workspaces share the same preview geometry.
521-func (m *Monitor) overviewLayout() []Placement {
521+func (m *Monitor) overviewLayout(result []Placement) []Placement {
522522 cur := m.Current()
523523 rows, _ := m.overviewRows()
524- var result []Placement
525524 for w := range m.all() {
526525 ry, shown := rows[w]
527526 if shown {
@@ -535,11 +534,8 @@ func (m *Monitor) overviewLayout() []Placement {
535534 }
536535 }
537536 if !shown {
538- for _, id := range w.windows() {
539- if w.floatIndex(id) < 0 {
540- result = append(result, Placement{ID: id, Hidden: true})
541- }
542- }
537+ // Columns and stash only: the floats were listed above.
538+ result = w.appendHidden(result, false)
543539 }
544540 }
545541 return result
@@ -664,7 +660,9 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement {
664660 // overviewZoom is the scale that fits every column of w in the usable
665661 // width, within the overview bounds.
666662 func (w *Workspace) overviewZoom() float64 {
667- _, span, _ := w.previewTiles()
663+ // Only the span is read: the tiles go in the zoom scratch, dead on return.
664+ var span int
665+ w.zoomBuf, span, _ = w.previewTilesInto(w.zoomBuf[:0])
668666 if span <= 0 {
669667 return overviewMaxZoom
670668 }
@@ -705,13 +703,23 @@ func (w *Workspace) rowHeight() int {
705703 // corner. Fixed overflow uses its on-screen geometry; scroll overflow uses
706704 // an unscrolled row. span is the row's width; sel is the focused column.
707705 func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {
706+ return w.previewTilesInto(make([]Placement, 0, w.columnWindows()))
707+}
708+
709+// previewTilesInto is previewTiles appending to tiles (dst[:0] to reuse its
710+// storage: the result then aliases it). The column rects use w.colBuf, dead
711+// when it returns.
712+func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) {
713+ var span int
714+ var sel Rect
708715 if w.pinned() {
709716 c := w.cover()
710717 // The row shows the columns a pinned fullscreen window hides,
711718 // laid out as without it; its own tile stays marked fullscreen.
712719 v := *w
713720 v.fullscreen = 0
714- tiles, span, sel = v.previewTiles()
721+ tiles, span, sel = v.previewTilesInto(tiles)
722+ w.colBuf, w.rowBuf = v.colBuf, v.rowBuf
715723 for i := range tiles {
716724 tiles[i].Fullscreen = tiles[i].ID == c
717725 }
@@ -721,7 +729,8 @@ func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {
721729 h := max(w.Usable.H-2*g, 0)
722730 var rects []Rect
723731 if w.Overflow == OverflowFixed {
724- rects = w.columnRects()
732+ w.colBuf = w.columnRectsInto(w.colBuf, false)
733+ rects = w.colBuf
725734 span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile.
726735 }
727736 maximized := w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth
@@ -743,7 +752,8 @@ func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {
743752 }
744753 continue
745754 }
746- for j, t := range rowRects(r, c, g) {
755+ w.rowBuf = rowRectsInto(w.rowBuf[:0], r, c, g)
756+ for j, t := range w.rowBuf {
747757 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus})
748758 }
749759 if w.Overflow != OverflowFixed {
@@ -518,10 +518,9 @@ func (m *Monitor) overviewRows() (map[*Workspace]int, []ports.Separator) {
518 // overviewLayout places the current workspace and its overview neighbors,518 // overviewLayout places the current workspace and its overview neighbors,
519 // each with its full-width stash pile and covering-float stack. Other rows519 // each with its full-width stash pile and covering-float stack. Other rows
520 // stay hidden; numbered and named workspaces share the same preview geometry.520 // stay hidden; numbered and named workspaces share the same preview geometry.
521-func (m *Monitor) overviewLayout() []Placement {521+func (m *Monitor) overviewLayout(result []Placement) []Placement {
522 cur := m.Current()522 cur := m.Current()
523 rows, _ := m.overviewRows()523 rows, _ := m.overviewRows()
524- var result []Placement
525 for w := range m.all() {524 for w := range m.all() {
526 ry, shown := rows[w]525 ry, shown := rows[w]
527 if shown {526 if shown {
@@ -535,11 +534,8 @@ func (m *Monitor) overviewLayout() []Placement {
535 }534 }
536 }535 }
537 if !shown {536 if !shown {
538- for _, id := range w.windows() {537+ // Columns and stash only: the floats were listed above.
539- if w.floatIndex(id) < 0 {538+ result = w.appendHidden(result, false)
540- result = append(result, Placement{ID: id, Hidden: true})
541- }
542- }
543 }539 }
544 }540 }
545 return result541 return result
@@ -664,7 +660,9 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement {
664 // overviewZoom is the scale that fits every column of w in the usable660 // overviewZoom is the scale that fits every column of w in the usable
665 // width, within the overview bounds.661 // width, within the overview bounds.
666 func (w *Workspace) overviewZoom() float64 {662 func (w *Workspace) overviewZoom() float64 {
667- _, span, _ := w.previewTiles()663+ // Only the span is read: the tiles go in the zoom scratch, dead on return.
664+ var span int
665+ w.zoomBuf, span, _ = w.previewTilesInto(w.zoomBuf[:0])
668 if span <= 0 {666 if span <= 0 {
669 return overviewMaxZoom667 return overviewMaxZoom
670 }668 }
@@ -705,13 +703,23 @@ func (w *Workspace) rowHeight() int {
705 // corner. Fixed overflow uses its on-screen geometry; scroll overflow uses703 // corner. Fixed overflow uses its on-screen geometry; scroll overflow uses
706 // an unscrolled row. span is the row's width; sel is the focused column.704 // an unscrolled row. span is the row's width; sel is the focused column.
707 func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {705 func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {
706+ return w.previewTilesInto(make([]Placement, 0, w.columnWindows()))
707+}
708+
709+// previewTilesInto is previewTiles appending to tiles (dst[:0] to reuse its
710+// storage: the result then aliases it). The column rects use w.colBuf, dead
711+// when it returns.
712+func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect) {
713+ var span int
714+ var sel Rect
708 if w.pinned() {715 if w.pinned() {
709 c := w.cover()716 c := w.cover()
710 // The row shows the columns a pinned fullscreen window hides,717 // The row shows the columns a pinned fullscreen window hides,
711 // laid out as without it; its own tile stays marked fullscreen.718 // laid out as without it; its own tile stays marked fullscreen.
712 v := *w719 v := *w
713 v.fullscreen = 0720 v.fullscreen = 0
714- tiles, span, sel = v.previewTiles()721+ tiles, span, sel = v.previewTilesInto(tiles)
722+ w.colBuf, w.rowBuf = v.colBuf, v.rowBuf
715 for i := range tiles {723 for i := range tiles {
716 tiles[i].Fullscreen = tiles[i].ID == c724 tiles[i].Fullscreen = tiles[i].ID == c
717 }725 }
@@ -721,7 +729,8 @@ func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {
721 h := max(w.Usable.H-2*g, 0)729 h := max(w.Usable.H-2*g, 0)
722 var rects []Rect730 var rects []Rect
723 if w.Overflow == OverflowFixed {731 if w.Overflow == OverflowFixed {
724- rects = w.columnRects()732+ w.colBuf = w.columnRectsInto(w.colBuf, false)
733+ rects = w.colBuf
725 span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile.734 span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile.
726 }735 }
727 maximized := w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth736 maximized := w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth
@@ -743,7 +752,8 @@ func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {
743 }752 }
744 continue753 continue
745 }754 }
746- for j, t := range rowRects(r, c, g) {755+ w.rowBuf = rowRectsInto(w.rowBuf[:0], r, c, g)
756+ for j, t := range w.rowBuf {
747 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus})757 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus})
748 }758 }
749 if w.Overflow != OverflowFixed {759 if w.Overflow != OverflowFixed {
M internal/core/overview_stack.go +4 -2
@@ -257,7 +257,8 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool
257257 r := rects[i]
258258 r.X -= w.Usable.X
259259 r.Y -= w.Usable.Y
260- for j, t := range rowRects(r, c, g) {
260+ w.rowBuf = rowRectsInto(w.rowBuf[:0], r, c, g)
261+ for j, t := range w.rowBuf {
261262 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == selected && j == c.Focus})
262263 }
263264 }
@@ -287,7 +288,8 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool
287288 r.X = (w.Usable.W - r.W) / 2
288289 r.Y = (w.Usable.H - r.H) / 2
289290 tiles := make([]Placement, 0, len(c.Windows))
290- for j, t := range rowRects(r, c, g) {
291+ w.rowBuf = rowRectsInto(w.rowBuf[:0], r, c, g)
292+ for j, t := range w.rowBuf {
291293 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus, Fullscreen: c.Windows[j] == w.cover()})
292294 }
293295 return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, r)
@@ -257,7 +257,8 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool
257 r := rects[i]257 r := rects[i]
258 r.X -= w.Usable.X258 r.X -= w.Usable.X
259 r.Y -= w.Usable.Y259 r.Y -= w.Usable.Y
260- for j, t := range rowRects(r, c, g) {260+ w.rowBuf = rowRectsInto(w.rowBuf[:0], r, c, g)
261+ for j, t := range w.rowBuf {
261 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == selected && j == c.Focus})262 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == selected && j == c.Focus})
262 }263 }
263 }264 }
@@ -287,7 +288,8 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool
287 r.X = (w.Usable.W - r.W) / 2288 r.X = (w.Usable.W - r.W) / 2
288 r.Y = (w.Usable.H - r.H) / 2289 r.Y = (w.Usable.H - r.H) / 2
289 tiles := make([]Placement, 0, len(c.Windows))290 tiles := make([]Placement, 0, len(c.Windows))
290- for j, t := range rowRects(r, c, g) {291+ w.rowBuf = rowRectsInto(w.rowBuf[:0], r, c, g)
292+ for j, t := range w.rowBuf {
291 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus, Fullscreen: c.Windows[j] == w.cover()})293 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus, Fullscreen: c.Windows[j] == w.cover()})
292 }294 }
293 return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, r)295 return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, r)
M internal/core/publish_alloc_test.go +16 -12
@@ -12,18 +12,22 @@ import (
1212 // windows each, with camera and rect motions (position, fade, dim, scale)
1313 // running, and on one screen with the overview opening (its cards' scale
1414 // and fade motions running). Every scene sent is freshly built (immutable
15-// once sent), and the layouts are rebuilt for every publish, so the floor
16-// is not 0. Measured: 43 before presizing the scene windows and separators,
17-// 36 after, 32 once the fallback timer is made once and reset per frame
18-// (the fallback path, step(ctx, nil), re-arms it; the flip path with
19-// another screen animating keeps it and costs the same). Leaving entries
20-// (a closed window still fading) add none: 24, less than a frame with six
21-// moving windows (fewer configure targets). The opening overview costs 70
22-// (its rows and card layouts are rebuilt too).
15+// once sent), so the floor is not 0. Measured: 43 before presizing the scene
16+// windows and separators, 36 after, 32 once the fallback timer is made once
17+// and reset per frame (the fallback path, step(ctx, nil), re-arms it; the
18+// flip path with another screen animating keeps it and costs the same), then
19+// 6 once the layouts are built in the owner's reused buffers (layoutInto:
20+// the screens' layout, the workspaces' column, row and tile rects) instead of
21+// fresh slices. What is left is the scenes themselves (windows, separators,
22+// the scene set) and the screen placements. Leaving entries (a closed window
23+// still fading) add none: 4, less than a frame with six moving windows
24+// (fewer configure targets). The opening overview costs 18 (the stack
25+// items, the row map and the neighbor list it rebuilds every frame; its
26+// layouts reuse the buffers too).
2327 const (
24- publishAllocBudget = 32
25- publishLeavingAllocBudget = 24
26- publishOverviewAllocBudget = 70
28+ publishAllocBudget = 6
29+ publishLeavingAllocBudget = 4
30+ publishOverviewAllocBudget = 18
2731 )
2832
2933 // publishRig is a Core on the real clock (a mock clock would count its own
@@ -169,7 +173,7 @@ func TestPublishOverviewAllocations(t *testing.T) {
169173
170174 // TestPublishLeavingAllocations pins the cost of a frame with leaving
171175 // entries on every screen (two of the six windows closed, fading out, the
172-// other four with rect motions): the measured 24, under a frame without
176+// other four with rect motions): the measured 4, under a frame without
173177 // them (the leaving entries take no configure target).
174178 func TestPublishLeavingAllocations(t *testing.T) {
175179 c := publishRig(t, 2)
@@ -12,18 +12,22 @@ import (
12 // windows each, with camera and rect motions (position, fade, dim, scale)12 // windows each, with camera and rect motions (position, fade, dim, scale)
13 // running, and on one screen with the overview opening (its cards' scale13 // running, and on one screen with the overview opening (its cards' scale
14 // and fade motions running). Every scene sent is freshly built (immutable14 // and fade motions running). Every scene sent is freshly built (immutable
15-// once sent), and the layouts are rebuilt for every publish, so the floor15+// once sent), so the floor is not 0. Measured: 43 before presizing the scene
16-// is not 0. Measured: 43 before presizing the scene windows and separators,16+// windows and separators, 36 after, 32 once the fallback timer is made once
17-// 36 after, 32 once the fallback timer is made once and reset per frame17+// and reset per frame (the fallback path, step(ctx, nil), re-arms it; the
18-// (the fallback path, step(ctx, nil), re-arms it; the flip path with18+// flip path with another screen animating keeps it and costs the same), then
19-// another screen animating keeps it and costs the same). Leaving entries19+// 6 once the layouts are built in the owner's reused buffers (layoutInto:
20-// (a closed window still fading) add none: 24, less than a frame with six20+// the screens' layout, the workspaces' column, row and tile rects) instead of
21-// moving windows (fewer configure targets). The opening overview costs 7021+// fresh slices. What is left is the scenes themselves (windows, separators,
22-// (its rows and card layouts are rebuilt too).22+// the scene set) and the screen placements. Leaving entries (a closed window
23+// still fading) add none: 4, less than a frame with six moving windows
24+// (fewer configure targets). The opening overview costs 18 (the stack
25+// items, the row map and the neighbor list it rebuilds every frame; its
26+// layouts reuse the buffers too).
23 const (27 const (
24- publishAllocBudget = 3228+ publishAllocBudget = 6
25- publishLeavingAllocBudget = 2429+ publishLeavingAllocBudget = 4
26- publishOverviewAllocBudget = 7030+ publishOverviewAllocBudget = 18
27 )31 )
28 32
29 // publishRig is a Core on the real clock (a mock clock would count its own33 // publishRig is a Core on the real clock (a mock clock would count its own
@@ -169,7 +173,7 @@ func TestPublishOverviewAllocations(t *testing.T) {
169 173
170 // TestPublishLeavingAllocations pins the cost of a frame with leaving174 // TestPublishLeavingAllocations pins the cost of a frame with leaving
171 // entries on every screen (two of the six windows closed, fading out, the175 // entries on every screen (two of the six windows closed, fading out, the
172-// other four with rect motions): the measured 24, under a frame without176+// other four with rect motions): the measured 4, under a frame without
173 // them (the leaving entries take no configure target).177 // them (the leaving entries take no configure target).
174 func TestPublishLeavingAllocations(t *testing.T) {178 func TestPublishLeavingAllocations(t *testing.T) {
175 c := publishRig(t, 2)179 c := publishRig(t, 2)
M internal/core/refresh_bench_test.go +2 -1
@@ -9,7 +9,8 @@ import (
99
1010 // BenchmarkRefreshShown is one publish's layout build on a screen of 24
1111 // windows, 20 of them with a rect motion and 6 more leaving: the cost of
12-// matching the motions to the layout every frame.
12+// matching the motions to the layout every frame. The layout is built in the
13+// screen's reused buffers: 0 allocs/op (33 before).
1314 func BenchmarkRefreshShown(b *testing.B) {
1415 var cfg ports.Config
1516 cfg.Keyboard.CmdKey = "super"
@@ -9,7 +9,8 @@ import (
9 9
10 // BenchmarkRefreshShown is one publish's layout build on a screen of 2410 // BenchmarkRefreshShown is one publish's layout build on a screen of 24
11 // windows, 20 of them with a rect motion and 6 more leaving: the cost of11 // windows, 20 of them with a rect motion and 6 more leaving: the cost of
12-// matching the motions to the layout every frame.12+// matching the motions to the layout every frame. The layout is built in the
13+// screen's reused buffers: 0 allocs/op (33 before).
13 func BenchmarkRefreshShown(b *testing.B) {14 func BenchmarkRefreshShown(b *testing.B) {
14 var cfg ports.Config15 var cfg ports.Config
15 cfg.Keyboard.CmdKey = "super"16 cfg.Keyboard.CmdKey = "super"
M internal/core/screens.go +7 -3
@@ -37,9 +37,13 @@ type screen struct {
3737 // runs. Both are built once per publish (refreshShown); shown is nil
3838 // before the first publish, then callers measure the monitor's layout.
3939 settledLayout, shown []Placement
40- // shownBuf and settledBuf back shown and settledLayout while rect
41- // motions run (refreshShown), reused across publishes.
42- shownBuf, settledBuf []Placement
40+ // layoutBuf backs settledLayout (and shown, while no rect motion runs),
41+ // rebuilt in place by every refreshShown; shownBuf and settledBuf back
42+ // shown and settledLayout while rect motions run. All three are owner
43+ // goroutine scratch reused across publishes: a Scene never aliases them
44+ // (its windows are copied from the placements), and a caller that
45+ // keeps a layout past the next publish copies it (Layout() is fresh).
46+ layoutBuf, shownBuf, settledBuf []Placement
4347 // leaveIDs is withLeaving's scratch list.
4448 leaveIDs []WindowID
4549 // rects are the running per-window rect motions (transition.go) of
@@ -37,9 +37,13 @@ type screen struct {
37 // runs. Both are built once per publish (refreshShown); shown is nil37 // runs. Both are built once per publish (refreshShown); shown is nil
38 // before the first publish, then callers measure the monitor's layout.38 // before the first publish, then callers measure the monitor's layout.
39 settledLayout, shown []Placement39 settledLayout, shown []Placement
40- // shownBuf and settledBuf back shown and settledLayout while rect40+ // layoutBuf backs settledLayout (and shown, while no rect motion runs),
41- // motions run (refreshShown), reused across publishes.41+ // rebuilt in place by every refreshShown; shownBuf and settledBuf back
42- shownBuf, settledBuf []Placement42+ // shown and settledLayout while rect motions run. All three are owner
43+ // goroutine scratch reused across publishes: a Scene never aliases them
44+ // (its windows are copied from the placements), and a caller that
45+ // keeps a layout past the next publish copies it (Layout() is fresh).
46+ layoutBuf, shownBuf, settledBuf []Placement
43 // leaveIDs is withLeaving's scratch list.47 // leaveIDs is withLeaving's scratch list.
44 leaveIDs []WindowID48 leaveIDs []WindowID
45 // rects are the running per-window rect motions (transition.go) of49 // rects are the running per-window rect motions (transition.go) of
M internal/core/slide.go +7 -2
@@ -174,12 +174,17 @@ func (m *Monitor) slideLayout(cur []Placement) []Placement {
174174 if k == base || k < 0 || k >= len(list) || !m.has(list[k]) {
175175 continue
176176 }
177- next := list[k].Layout()
177+ next := list[k].layoutInto(m.slideBuf)
178+ m.slideBuf = next
178179 for i := range next {
179180 next[i].Focused = false
180181 }
181182 // Windows of the neighbor are already listed hidden: replace them.
182- shown := map[WindowID]Placement{}
183+ if m.slideShown == nil {
184+ m.slideShown = map[WindowID]Placement{}
185+ }
186+ shown := m.slideShown
187+ clear(shown)
183188 for _, p := range offset(next, k) {
184189 shown[p.ID] = p
185190 }
@@ -174,12 +174,17 @@ func (m *Monitor) slideLayout(cur []Placement) []Placement {
174 if k == base || k < 0 || k >= len(list) || !m.has(list[k]) {174 if k == base || k < 0 || k >= len(list) || !m.has(list[k]) {
175 continue175 continue
176 }176 }
177- next := list[k].Layout()177+ next := list[k].layoutInto(m.slideBuf)
178+ m.slideBuf = next
178 for i := range next {179 for i := range next {
179 next[i].Focused = false180 next[i].Focused = false
180 }181 }
181 // Windows of the neighbor are already listed hidden: replace them.182 // Windows of the neighbor are already listed hidden: replace them.
182- shown := map[WindowID]Placement{}183+ if m.slideShown == nil {
184+ m.slideShown = map[WindowID]Placement{}
185+ }
186+ shown := m.slideShown
187+ clear(shown)
183 for _, p := range offset(next, k) {188 for _, p := range offset(next, k) {
184 shown[p.ID] = p189 shown[p.ID] = p
185 }190 }
M internal/core/stash.go +2 -3
@@ -217,18 +217,17 @@ func (w *Workspace) stashRect() Rect {
217217 return Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh}
218218 }
219219
220-// stashLayout places the stash: the selected window centred, its
220+// appendStash appends the placements of the stash to out: the selected window centred, its
221221 // neighbors gap% of the usable width away, showing what the margins
222222 // leave of them, the others hidden. cover is the covering fullscreen
223223 // window, if any.
224-func (w *Workspace) stashLayout(focusedID, cover WindowID) []Placement {
224+func (w *Workspace) appendStash(out []Placement, focusedID, cover WindowID) []Placement {
225225 u := w.Usable
226226 center := w.stashRect()
227227 fw, fh := center.W, center.H
228228 // Peeks stay in the margins: they never overlap the selected window.
229229 gap := u.W * w.stashGap / 100
230230 peek := max((u.W-fw)/2-gap, 0)
231- out := make([]Placement, 0, len(w.Stash))
232231 for i, f := range w.Stash {
233232 p := Placement{ID: f.ID, Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll}
234233 switch {
@@ -217,18 +217,17 @@ func (w *Workspace) stashRect() Rect {
217 return Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh}217 return Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh}
218 }218 }
219 219
220-// stashLayout places the stash: the selected window centred, its220+// appendStash appends the placements of the stash to out: the selected window centred, its
221 // neighbors gap% of the usable width away, showing what the margins221 // neighbors gap% of the usable width away, showing what the margins
222 // leave of them, the others hidden. cover is the covering fullscreen222 // leave of them, the others hidden. cover is the covering fullscreen
223 // window, if any.223 // window, if any.
224-func (w *Workspace) stashLayout(focusedID, cover WindowID) []Placement {224+func (w *Workspace) appendStash(out []Placement, focusedID, cover WindowID) []Placement {
225 u := w.Usable225 u := w.Usable
226 center := w.stashRect()226 center := w.stashRect()
227 fw, fh := center.W, center.H227 fw, fh := center.W, center.H
228 // Peeks stay in the margins: they never overlap the selected window.228 // Peeks stay in the margins: they never overlap the selected window.
229 gap := u.W * w.stashGap / 100229 gap := u.W * w.stashGap / 100
230 peek := max((u.W-fw)/2-gap, 0)230 peek := max((u.W-fw)/2-gap, 0)
231- out := make([]Placement, 0, len(w.Stash))
232 for i, f := range w.Stash {231 for i, f := range w.Stash {
233 p := Placement{ID: f.ID, Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll}232 p := Placement{ID: f.ID, Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll}
234 switch {233 switch {
M internal/core/state.go +4 -1
@@ -38,7 +38,10 @@ func (c *Core) state() ports.State {
3838 st.Outputs = append(st.Outputs, o)
3939 // Visible follows the configures: drawn on the output.
4040 shown := map[WindowID]bool{}
41- for _, p := range m.Layout() {
41+ // Read at once, into publish's scratch: the snapshot holds no
42+ // placement.
43+ c.realBuf = m.layoutInto(c.realBuf)
44+ for _, p := range c.realBuf {
4245 shown[p.ID] = onScreen(p, m.Frame())
4346 }
4447 for w := range m.all() {
@@ -38,7 +38,10 @@ func (c *Core) state() ports.State {
38 st.Outputs = append(st.Outputs, o)38 st.Outputs = append(st.Outputs, o)
39 // Visible follows the configures: drawn on the output.39 // Visible follows the configures: drawn on the output.
40 shown := map[WindowID]bool{}40 shown := map[WindowID]bool{}
41- for _, p := range m.Layout() {41+ // Read at once, into publish's scratch: the snapshot holds no
42+ // placement.
43+ c.realBuf = m.layoutInto(c.realBuf)
44+ for _, p := range c.realBuf {
42 shown[p.ID] = onScreen(p, m.Frame())45 shown[p.ID] = onScreen(p, m.Frame())
43 }46 }
44 for w := range m.all() {47 for w := range m.all() {
M internal/core/transition.go +7 -1
@@ -232,7 +232,13 @@ func (c *Core) refreshShown() {
232232 if len(sc.rects) > 0 && sc.rectsWS != sc.rectsOwner() {
233233 sc.stopRects()
234234 }
235- sc.settledLayout = m.Layout()
235+ sc.layoutBuf = m.layoutInto(sc.layoutBuf)
236+ sc.settledLayout = sc.layoutBuf
237+ if len(sc.settledLayout) == 0 {
238+ // Empty stays nil, as a fresh Layout() was: shown nil falls
239+ // back to the live layout until the next publish.
240+ sc.settledLayout = nil
241+ }
236242 if len(sc.rects) > 0 {
237243 // A window that left the layout (or is hidden) keeps no motion:
238244 // it would ask for frames with nothing to move. A leaving one
@@ -232,7 +232,13 @@ func (c *Core) refreshShown() {
232 if len(sc.rects) > 0 && sc.rectsWS != sc.rectsOwner() {232 if len(sc.rects) > 0 && sc.rectsWS != sc.rectsOwner() {
233 sc.stopRects()233 sc.stopRects()
234 }234 }
235- sc.settledLayout = m.Layout()235+ sc.layoutBuf = m.layoutInto(sc.layoutBuf)
236+ sc.settledLayout = sc.layoutBuf
237+ if len(sc.settledLayout) == 0 {
238+ // Empty stays nil, as a fresh Layout() was: shown nil falls
239+ // back to the live layout until the next publish.
240+ sc.settledLayout = nil
241+ }
236 if len(sc.rects) > 0 {242 if len(sc.rects) > 0 {
237 // A window that left the layout (or is hidden) keeps no motion:243 // A window that left the layout (or is hidden) keeps no motion:
238 // it would ask for frames with nothing to move. A leaving one244 // it would ask for frames with nothing to move. A leaving one

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 23

Tags 10

↑↓ moveenter openesc close