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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 68 | 68 | } | |
| 69 | 69 | type Core struct { | |
| 70 | 70 | // 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 | |
| 72 | 75 | security ports.SecurityState | |
| 73 | 76 | lockSurfaces []ports.LockSurfacePlacement | |
| 74 | 77 | lockFocus WindowID | |
| @@ -590,7 +593,8 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error { | |||
| 590 | 593 | clear(c.overviewReal) | |
| 591 | 594 | real = c.overviewReal | |
| 592 | 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 | 598 | real[p.ID] = p | |
| 595 | 599 | } | |
| 596 | 600 | } | |
| @@ -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]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 | |||
| 72 | security ports.SecurityState | 75 | security ports.SecurityState | ||
| 73 | lockSurfaces []ports.LockSurfacePlacement | 76 | lockSurfaces []ports.LockSurfacePlacement | ||
| 74 | lockFocus WindowID | 77 | 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.overviewReal | 594 | 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] = p | 598 | real[p.ID] = p | ||
| 595 | } | 599 | } | ||
| 596 | } | 600 | } | ||
M internal/core/layout.go +99 -22
| @@ -216,6 +216,14 @@ type Workspace struct { | |||
| 216 | 216 | homePos int | |
| 217 | 217 | // termAt is when core last spawned a terminal for this workspace. | |
| 218 | 218 | 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 | 229 | // origPlace remembers where a stashed window was: its column, its row in a | |
| @@ -254,6 +262,35 @@ func (w *Workspace) empty() bool { | |||
| 254 | 262 | 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 | 294 | // windows lists every window: columns first, then the stash, then native | |
| 258 | 295 | // floats. | |
| 259 | 296 | func (w *Workspace) windows() []WindowID { | |
| @@ -1361,15 +1398,22 @@ func (w *Workspace) scroll() { | |||
| 1361 | 1398 | ||
| 1362 | 1399 | // columnRects returns each column's area on screen, before stacking windows. | |
| 1363 | 1400 | func (w *Workspace) columnRects() []Rect { | |
| 1364 | - | return w.columnRectsFor(false) | |
| 1401 | + | return w.columnRectsInto(nil, false) | |
| 1365 | 1402 | } | |
| 1366 | 1403 | ||
| 1367 | 1404 | // columnRectsFor shares the fixed layout geometry with the overview's hidden | |
| 1368 | 1405 | // columns card, without changing FullWidth or the clients' saved buffers. | |
| 1369 | 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 | 1414 | g := w.gap() | |
| 1371 | 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 | 1417 | view := w.ViewX + w.shiftPixels() | |
| 1374 | 1418 | x := w.Usable.X + g | |
| 1375 | 1419 | for i := range w.Columns { | |
| @@ -1383,7 +1427,8 @@ func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect { | |||
| 1383 | 1427 | } | |
| 1384 | 1428 | k := max(w.MaxColumns, 1) | |
| 1385 | 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 | 1433 | if w.Overflow != OverflowFixed || len(w.Columns) <= k { | |
| 1389 | 1434 | return rects | |
| @@ -1400,7 +1445,7 @@ func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect { | |||
| 1400 | 1445 | // expandedRects places expanded column e over k-1 cells in its place. The | |
| 1401 | 1446 | // columns before it stack in a strip on its left, those after it on its | |
| 1402 | 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 | 1449 | g := w.gap() | |
| 1405 | 1450 | cell := max((w.Usable.W-g*(k+1))/k, 0) | |
| 1406 | 1451 | wide := (k-1)*cell + (k-2)*g | |
| @@ -1411,15 +1456,14 @@ func (w *Workspace) expandedRects(e, k, y, h int) []Rect { | |||
| 1411 | 1456 | leftW = max((rest-g)/2, 0) | |
| 1412 | 1457 | rightW = max(rest-g-leftW, 0) | |
| 1413 | 1458 | } | |
| 1414 | - | rects := make([]Rect, 0, len(w.Columns)) | |
| 1415 | 1459 | x := w.Usable.X + g | |
| 1416 | 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 | 1462 | x += leftW + g | |
| 1419 | 1463 | } | |
| 1420 | 1464 | rects = append(rects, Rect{X: x, Y: y, W: wide, H: h}) | |
| 1421 | 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 | 1468 | // Gaps wider than a narrow output would push columns past its edge. | |
| 1425 | 1469 | right := w.Usable.X + w.Usable.W | |
| @@ -1434,7 +1478,15 @@ func (w *Workspace) expandedRects(e, k, y, h int) []Rect { | |||
| 1434 | 1478 | // the rounding remainder. Rows past the bottom (gaps taller than r) are | |
| 1435 | 1479 | // clamped to it, like windows stacked in a column. | |
| 1436 | 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 | 1490 | avail := max(r.H-(n-1)*gap, 0) | |
| 1439 | 1491 | height := avail / n | |
| 1440 | 1492 | yy, bottom := r.Y, r.Y+r.H | |
| @@ -1448,17 +1500,25 @@ func stackRects(r Rect, n, gap int) []Rect { | |||
| 1448 | 1500 | rows[i] = Rect{X: r.X, Y: yy, W: r.W, H: hh} | |
| 1449 | 1501 | yy += hh + gap | |
| 1450 | 1502 | } | |
| 1451 | - | return rows | |
| 1503 | + | return dst | |
| 1452 | 1504 | } | |
| 1453 | 1505 | ||
| 1454 | 1506 | // rowRects splits column rect r into the rows of c: by c.Shares when they | |
| 1455 | 1507 | // are valid, else equally (stackRects). | |
| 1456 | 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 | 1515 | n := len(c.Windows) | |
| 1458 | 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 | 1522 | avail := max(r.H-(n-1)*gap, 0) | |
| 1463 | 1523 | yy, bottom, used := r.Y, r.Y+r.H, 0 | |
| 1464 | 1524 | for i, share := range c.Shares { | |
| @@ -1472,7 +1532,7 @@ func rowRects(r Rect, c Column, gap int) []Rect { | |||
| 1472 | 1532 | rows[i] = Rect{X: r.X, Y: yy, W: r.W, H: hh} | |
| 1473 | 1533 | yy += hh + gap | |
| 1474 | 1534 | } | |
| 1475 | - | return rows | |
| 1535 | + | return dst | |
| 1476 | 1536 | } | |
| 1477 | 1537 | ||
| 1478 | 1538 | // 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 | 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 { | |
| 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 { | |
| 1506 | 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 | 1582 | focusedID, _ := w.Focused() | |
| 1509 | 1583 | cover := w.cover() | |
| 1584 | + | tiles := w.tileBuf[:0] | |
| 1510 | 1585 | for i, c := range w.Columns { | |
| 1511 | 1586 | col := cols[i] | |
| 1512 | 1587 | n := len(c.Windows) | |
| @@ -1517,7 +1592,8 @@ func (w *Workspace) Layout() []Placement { | |||
| 1517 | 1592 | if fullColumn { | |
| 1518 | 1593 | 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 | 1597 | for j, id := range c.Windows { | |
| 1522 | 1598 | r := rows[j] | |
| 1523 | 1599 | full := w.fullscreen == id && id != 0 | |
| @@ -1535,14 +1611,15 @@ func (w *Workspace) Layout() []Placement { | |||
| 1535 | 1611 | if hidden { | |
| 1536 | 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 | 1620 | // The column/stash group sits between demoted covering floats and | |
| 1543 | 1621 | // floats above it. Keep the order within each group stable. | |
| 1544 | - | tiles := result | |
| 1545 | - | result = nil | |
| 1622 | + | result := dst | |
| 1546 | 1623 | const ( | |
| 1547 | 1624 | belowTiles = iota | |
| 1548 | 1625 | coveringFloats | |
| @@ -1582,9 +1659,9 @@ func (w *Workspace) Layout() []Placement { | |||
| 1582 | 1659 | // The stash the user showed over a covering window tops it, under | |
| 1583 | 1660 | // the dialogs it may open meanwhile. | |
| 1584 | 1661 | appendFloats(coveringFloats) | |
| 1585 | - | result = append(result, w.stashLayout(focusedID, cover)...) | |
| 1662 | + | result = w.appendStash(result, focusedID, cover) | |
| 1586 | 1663 | } else { | |
| 1587 | - | result = append(result, w.stashLayout(focusedID, cover)...) | |
| 1664 | + | result = w.appendStash(result, focusedID, cover) | |
| 1588 | 1665 | appendFloats(coveringFloats) | |
| 1589 | 1666 | } | |
| 1590 | 1667 | appendFloats(dialogs) | |
| @@ -216,6 +216,14 @@ type Workspace struct { | |||||
| 216 | homePos int | 216 | 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.Time | 218 | 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 a | 229 | // 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) == 0 | 262 | 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 native | 294 | // 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 hidden | 1404 | // 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 + g | 1418 | 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 rects | 1434 | 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. The | 1445 | // 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 its | 1446 | // 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)*g | 1451 | 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 + g | 1459 | 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 + g | 1462 | 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.W | 1469 | 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) are | 1478 | // 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 / n | 1491 | height := avail / n | ||
| 1440 | yy, bottom := r.Y, r.Y+r.H | 1492 | 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 + gap | 1501 | yy += hh + gap | ||
| 1450 | } | 1502 | } | ||
| 1451 | - | return rows | 1503 | + | return dst |
| 1452 | } | 1504 | } | ||
| 1453 | 1505 | ||||
| 1454 | // rowRects splits column rect r into the rows of c: by c.Shares when they | 1506 | // 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, 0 | 1523 | 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 + gap | 1533 | yy += hh + gap | ||
| 1474 | } | 1534 | } | ||
| 1475 | - | return rows | 1535 | + | return dst |
| 1476 | } | 1536 | } | ||
| 1477 | 1537 | ||||
| 1478 | // validShares reports whether c.Shares has one share per window, each at | 1538 | // 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 []Placement | 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 { | |||
| 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.W | 1593 | 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 != 0 | 1599 | 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 and | 1620 | // 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 := result | 1622 | + | result := dst |
| 1545 | - | result = nil | |||
| 1546 | const ( | 1623 | const ( | ||
| 1547 | belowTiles = iota | 1624 | belowTiles = iota | ||
| 1548 | coveringFloats | 1625 | coveringFloats | ||
| @@ -1582,9 +1659,9 @@ func (w *Workspace) Layout() []Placement { | |||||
| 1582 | // The stash the user showed over a covering window tops it, under | 1659 | // 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 { | |||
| 24 | 24 | shown *Workspace // hidden workspace on screen, nil for Workspaces[Active] | |
| 25 | 25 | back *Workspace // where a named workspace toggle returns to | |
| 26 | 26 | 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 | 31 | nextID *uint64 // shared by the monitors of one Core; owner goroutine only | |
| 28 | 32 | named []NamedWorkspace | |
| 29 | 33 | // followMove shows the target workspace after a move to it. | |
| @@ -157,6 +161,7 @@ func (m *Monitor) Current() *Workspace { | |||
| 157 | 161 | ||
| 158 | 162 | func (m *Monitor) newWorkspace() *Workspace { | |
| 159 | 163 | w := m.template | |
| 164 | + | w.colBuf, w.rowBuf, w.tileBuf, w.zoomBuf = nil, nil, nil, nil | |
| 160 | 165 | w.presets = append([]Width(nil), m.template.presets...) | |
| 161 | 166 | w.Columns, w.Floats, w.maximized, w.floatFocus, w.home = nil, nil, nil, false, "" | |
| 162 | 167 | w.overviewAfter = nil | |
| @@ -408,20 +413,24 @@ func (m *Monitor) Focused() (WindowID, bool) { | |||
| 408 | 413 | } | |
| 409 | 414 | ||
| 410 | 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 | 423 | if m.ov.open { | |
| 413 | - | return m.overviewLayout() | |
| 424 | + | return m.overviewLayout(dst[:0]) | |
| 414 | 425 | } | |
| 415 | - | var result []Placement | |
| 426 | + | result := dst[:0] | |
| 416 | 427 | cur := m.Current() | |
| 417 | 428 | for w := range m.all() { | |
| 418 | 429 | if w == cur { | |
| 419 | - | result = append(result, w.Layout()...) | |
| 430 | + | result = w.appendLayout(result) | |
| 420 | 431 | continue | |
| 421 | 432 | } | |
| 422 | - | for _, id := range w.windows() { | |
| 423 | - | result = append(result, Placement{ID: id, Hidden: true}) | |
| 424 | - | } | |
| 433 | + | result = w.appendHidden(result, true) | |
| 425 | 434 | } | |
| 426 | 435 | return m.slideLayout(result) | |
| 427 | 436 | } | |
| @@ -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 to | 25 | back *Workspace // where a named workspace toggle returns to | ||
| 26 | template Workspace | 26 | 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 only | 31 | nextID *uint64 // shared by the monitors of one Core; owner goroutine only | ||
| 28 | named []NamedWorkspace | 32 | 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.template | 163 | 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 = nil | 167 | 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 []Placement | 426 | + | 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 | continue | 431 | 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) { | |||
| 518 | 518 | // overviewLayout places the current workspace and its overview neighbors, | |
| 519 | 519 | // each with its full-width stash pile and covering-float stack. Other rows | |
| 520 | 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 | 522 | cur := m.Current() | |
| 523 | 523 | rows, _ := m.overviewRows() | |
| 524 | - | var result []Placement | |
| 525 | 524 | for w := range m.all() { | |
| 526 | 525 | ry, shown := rows[w] | |
| 527 | 526 | if shown { | |
| @@ -535,11 +534,8 @@ func (m *Monitor) overviewLayout() []Placement { | |||
| 535 | 534 | } | |
| 536 | 535 | } | |
| 537 | 536 | 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) | |
| 543 | 539 | } | |
| 544 | 540 | } | |
| 545 | 541 | return result | |
| @@ -664,7 +660,9 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement { | |||
| 664 | 660 | // overviewZoom is the scale that fits every column of w in the usable | |
| 665 | 661 | // width, within the overview bounds. | |
| 666 | 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 | 666 | if span <= 0 { | |
| 669 | 667 | return overviewMaxZoom | |
| 670 | 668 | } | |
| @@ -705,13 +703,23 @@ func (w *Workspace) rowHeight() int { | |||
| 705 | 703 | // corner. Fixed overflow uses its on-screen geometry; scroll overflow uses | |
| 706 | 704 | // an unscrolled row. span is the row's width; sel is the focused column. | |
| 707 | 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 | 715 | if w.pinned() { | |
| 709 | 716 | c := w.cover() | |
| 710 | 717 | // The row shows the columns a pinned fullscreen window hides, | |
| 711 | 718 | // laid out as without it; its own tile stays marked fullscreen. | |
| 712 | 719 | v := *w | |
| 713 | 720 | 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 | 723 | for i := range tiles { | |
| 716 | 724 | tiles[i].Fullscreen = tiles[i].ID == c | |
| 717 | 725 | } | |
| @@ -721,7 +729,8 @@ func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) { | |||
| 721 | 729 | h := max(w.Usable.H-2*g, 0) | |
| 722 | 730 | var rects []Rect | |
| 723 | 731 | if w.Overflow == OverflowFixed { | |
| 724 | - | rects = w.columnRects() | |
| 732 | + | w.colBuf = w.columnRectsInto(w.colBuf, false) | |
| 733 | + | rects = w.colBuf | |
| 725 | 734 | span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile. | |
| 726 | 735 | } | |
| 727 | 736 | 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 | 753 | 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 | 757 | tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus}) | |
| 748 | 758 | } | |
| 749 | 759 | 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 rows | 519 | // 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 result | 541 | 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 usable | 660 | // 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 overviewMaxZoom | 667 | return overviewMaxZoom | ||
| 670 | } | 668 | } | ||
| @@ -705,13 +703,23 @@ func (w *Workspace) rowHeight() int { | |||||
| 705 | // corner. Fixed overflow uses its on-screen geometry; scroll overflow uses | 703 | // 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 := *w | 719 | v := *w | ||
| 713 | v.fullscreen = 0 | 720 | 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 == c | 724 | 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 []Rect | 730 | 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].FullWidth | 736 | 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 | continue | 753 | 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 | |||
| 257 | 257 | r := rects[i] | |
| 258 | 258 | r.X -= w.Usable.X | |
| 259 | 259 | 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 | 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 | 288 | r.X = (w.Usable.W - r.W) / 2 | |
| 288 | 289 | r.Y = (w.Usable.H - r.H) / 2 | |
| 289 | 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 | 293 | tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus, Fullscreen: c.Windows[j] == w.cover()}) | |
| 292 | 294 | } | |
| 293 | 295 | 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.X | 258 | r.X -= w.Usable.X | ||
| 259 | r.Y -= w.Usable.Y | 259 | 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) / 2 | 288 | r.X = (w.Usable.W - r.W) / 2 | ||
| 288 | r.Y = (w.Usable.H - r.H) / 2 | 289 | 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 ( | |||
| 12 | 12 | // windows each, with camera and rect motions (position, fade, dim, scale) | |
| 13 | 13 | // running, and on one screen with the overview opening (its cards' scale | |
| 14 | 14 | // 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). | |
| 23 | 27 | const ( | |
| 24 | - | publishAllocBudget = 32 | |
| 25 | - | publishLeavingAllocBudget = 24 | |
| 26 | - | publishOverviewAllocBudget = 70 | |
| 28 | + | publishAllocBudget = 6 | |
| 29 | + | publishLeavingAllocBudget = 4 | |
| 30 | + | publishOverviewAllocBudget = 18 | |
| 27 | 31 | ) | |
| 28 | 32 | ||
| 29 | 33 | // 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 | 174 | // TestPublishLeavingAllocations pins the cost of a frame with leaving | |
| 171 | 175 | // 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 | |
| 173 | 177 | // them (the leaving entries take no configure target). | |
| 174 | 178 | func TestPublishLeavingAllocations(t *testing.T) { | |
| 175 | 179 | 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' scale | 13 | // running, and on one screen with the overview opening (its cards' scale | ||
| 14 | // and fade motions running). Every scene sent is freshly built (immutable | 14 | // 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 | 15 | + | // 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 frame | 17 | + | // 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 with | 18 | + | // flip path with another screen animating keeps it and costs the same), then |
| 19 | - | // another screen animating keeps it and costs the same). Leaving entries | 19 | + | // 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 six | 20 | + | // the screens' layout, the workspaces' column, row and tile rects) instead of |
| 21 | - | // moving windows (fewer configure targets). The opening overview costs 70 | 21 | + | // 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 = 32 | 28 | + | publishAllocBudget = 6 |
| 25 | - | publishLeavingAllocBudget = 24 | 29 | + | publishLeavingAllocBudget = 4 |
| 26 | - | publishOverviewAllocBudget = 70 | 30 | + | publishOverviewAllocBudget = 18 |
| 27 | ) | 31 | ) | ||
| 28 | 32 | ||||
| 29 | // publishRig is a Core on the real clock (a mock clock would count its own | 33 | // 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 leaving | 174 | // TestPublishLeavingAllocations pins the cost of a frame with leaving | ||
| 171 | // entries on every screen (two of the six windows closed, fading out, the | 175 | // 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 |
| 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 ( | |||
| 9 | 9 | ||
| 10 | 10 | // BenchmarkRefreshShown is one publish's layout build on a screen of 24 | |
| 11 | 11 | // 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 | 14 | func BenchmarkRefreshShown(b *testing.B) { | |
| 14 | 15 | var cfg ports.Config | |
| 15 | 16 | cfg.Keyboard.CmdKey = "super" | |
| @@ -9,7 +9,8 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | // BenchmarkRefreshShown is one publish's layout build on a screen of 24 | 10 | // 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 of | 11 | // 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.Config | 15 | 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 { | |||
| 37 | 37 | // runs. Both are built once per publish (refreshShown); shown is nil | |
| 38 | 38 | // before the first publish, then callers measure the monitor's layout. | |
| 39 | 39 | 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 | |
| 43 | 47 | // leaveIDs is withLeaving's scratch list. | |
| 44 | 48 | leaveIDs []WindowID | |
| 45 | 49 | // 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 nil | 37 | // 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 []Placement | 39 | settledLayout, shown []Placement | ||
| 40 | - | // shownBuf and settledBuf back shown and settledLayout while rect | 40 | + | // 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 []Placement | 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 | |||
| 43 | // leaveIDs is withLeaving's scratch list. | 47 | // leaveIDs is withLeaving's scratch list. | ||
| 44 | leaveIDs []WindowID | 48 | leaveIDs []WindowID | ||
| 45 | // rects are the running per-window rect motions (transition.go) of | 49 | // 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 { | |||
| 174 | 174 | if k == base || k < 0 || k >= len(list) || !m.has(list[k]) { | |
| 175 | 175 | continue | |
| 176 | 176 | } | |
| 177 | - | next := list[k].Layout() | |
| 177 | + | next := list[k].layoutInto(m.slideBuf) | |
| 178 | + | m.slideBuf = next | |
| 178 | 179 | for i := range next { | |
| 179 | 180 | next[i].Focused = false | |
| 180 | 181 | } | |
| 181 | 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 | 188 | for _, p := range offset(next, k) { | |
| 184 | 189 | shown[p.ID] = p | |
| 185 | 190 | } | |
| @@ -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 | continue | 175 | 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 = false | 180 | 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] = p | 189 | shown[p.ID] = p | ||
| 185 | } | 190 | } | ||
M internal/core/stash.go +2 -3
| @@ -217,18 +217,17 @@ func (w *Workspace) stashRect() Rect { | |||
| 217 | 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, its | |
| 220 | + | // appendStash appends the placements of the stash to out: the selected window centred, its | |
| 221 | 221 | // neighbors gap% of the usable width away, showing what the margins | |
| 222 | 222 | // leave of them, the others hidden. cover is the covering fullscreen | |
| 223 | 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 | 225 | u := w.Usable | |
| 226 | 226 | center := w.stashRect() | |
| 227 | 227 | fw, fh := center.W, center.H | |
| 228 | 228 | // Peeks stay in the margins: they never overlap the selected window. | |
| 229 | 229 | gap := u.W * w.stashGap / 100 | |
| 230 | 230 | peek := max((u.W-fw)/2-gap, 0) | |
| 231 | - | out := make([]Placement, 0, len(w.Stash)) | |
| 232 | 231 | for i, f := range w.Stash { | |
| 233 | 232 | p := Placement{ID: f.ID, Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll} | |
| 234 | 233 | 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, its | 220 | + | // 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 margins | 221 | // neighbors gap% of the usable width away, showing what the margins | ||
| 222 | // leave of them, the others hidden. cover is the covering fullscreen | 222 | // 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.Usable | 225 | u := w.Usable | ||
| 226 | center := w.stashRect() | 226 | center := w.stashRect() | ||
| 227 | fw, fh := center.W, center.H | 227 | 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 / 100 | 229 | 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 { | |||
| 38 | 38 | st.Outputs = append(st.Outputs, o) | |
| 39 | 39 | // Visible follows the configures: drawn on the output. | |
| 40 | 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 | 45 | shown[p.ID] = onScreen(p, m.Frame()) | |
| 43 | 46 | } | |
| 44 | 47 | 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() { | |||
| 232 | 232 | if len(sc.rects) > 0 && sc.rectsWS != sc.rectsOwner() { | |
| 233 | 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 | 242 | if len(sc.rects) > 0 { | |
| 237 | 243 | // A window that left the layout (or is hidden) keeps no motion: | |
| 238 | 244 | // 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 one | 244 | // it would ask for frames with nothing to move. A leaving one | ||