detached 19ecdbf

perf: cut steady-frame allocations in Renderer.Render (48 → 22) (#10)

* perf(text): wrap once per measure and drop unused MinContent and MaxContent

measure ran three go-text line-wrap passes: min-content, max-content and the
requested width. No caller read Layout.MinContent or Layout.MaxContent, so
both fields and their passes are removed and measure wraps once, at the
requested width. Steady frame drops from 48 to 39 allocs, uncached short
measure from 33 to 24.

* perf(text): cache empty glyphs in the atlas

Spaces have empty masks and were never stored, so every frame rasterized them again. The atlas now caches them, bounded by maxEmptyGlyphs because no page eviction removes them. Steady frame drops from 39 to 37 allocs.

* perf(text): reuse segmenter, bidi levels and cache entries across measures

clusterFaces reuses one grapheme segmenter, bidi.ResolveInto writes left-to-right levels into scratch storage, and the measure cache recycles evicted entries and their family lists. Steady frame drops from 37 to 31 allocs, uncached short measure from 24 to 20.

* perf(layout): reuse result trees and display lists through arenas

layout.Options takes an optional Arena that owns the Result tree, child
slices, display commands, shadows, runs, glyphs and flex scratch. Layout
recycles it, sized by the previous demand, so a steady layout allocates
nothing. The runtime alternates two arenas: the committed output stays
intact while the next frame is laid out, and a frame that fails or panics
leaves it untouched.

Steady frame drops from 31 to 23 allocs; the demo frame from 619 to 514;
a 100-card layout and paint from 306 to 0.

* perf(render): reuse the quad buffer across submitted frames

Prepare allocated a new Quads array per frame because frames could outlive later calls. The session target is the only caller and copies quads into the GPU instance buffer before the next Prepare, so it now passes its previous buffer to PrepareInto. Prepare keeps returning detached frames. The clip stack starts on the stack. Steady frame drops from 23 to 22 allocs.

* docs(performance): refresh budgets and describe arena and quad reuse

Re-measure the CPU budget table on the current commit, add the uncached short-measure row, lower the demo-frame limit to 600 and state the steady renderer cycle at 22 allocations. Document the layout.Arena lifetime rule.

* fix(text): reuse scratch buffers in the lazy font coverage pass

The renderer loads the system catalog, which is indexed and lazy, so every
uncached measure runs the coverage pass. That pass still built a fresh
segmenter, missing-rune slice, choice slice and fallback window. It now uses
the engine scratch storage and builds the fallback window only when a rune is
missing: an uncached short measure on an indexed catalog drops from 29 to 20
allocations. TestAllocMeasureMissShort now covers both catalog kinds.

Also drop the unused fullBreak helper, document that bidi.ResolveInto reuses
dst only for left-to-right text, and make the measure-cache recycling test
fail when put stops taking entries from the free list.

* fix(layout): recycle decorated display lists and bound retained buffers

Editor and indicator decorations are now inserted with slices.Insert. The
runtime hands the grown list back through Arena.Keep, so views with inputs
no longer reallocate the display list every frame (demo frame 514 -> 509).

The arena drops command arrays larger than maxSlabKeep and releases the
previous frame's commands when a frame paints nothing. The target keeps an
empty quad buffer and drops one far larger than the last frame needed.

The arena rotation test now checks state after each build and covers a
failed layout followed by two successful builds.

* fix(render): size the quad shrink check by the frame reservation

Target.submit dropped its quad buffer when the visible quad count was far
below capacity. A mostly culled scene reserves up to maxQuadPrealloc slots
but shows few quads, so that check reallocated the buffer every frame.

PrepareInto now drops a reused buffer only when it exceeds bufferSlack times
both this frame's reservation and the last frame's length. A test covers a
steady, mostly culled frame (0 allocs) and the release of a peak buffer.
The arena also skips clearing a command list it is about to drop.

* test(text): cover ResolveInto's full path and recycled cache variations

ResolveInto must return correct levels for right-to-left text without
writing into the caller's reused dst. The measure-cache recycling test now
uses a request with variations so a stale variation list on a recycled
entry turns the expected hit into a miss.

* fix(layout): shrink arena storage by a ratio of the frame's need

Arena slabs and the command list dropped storage only above a fixed 65536
element cap. Above that cap any change in demand reallocated the block on
every frame; below it a past peak was pinned forever. They now use the
same rule as render's quad and instance buffers: drop when capacity
exceeds four times the need plus 64. maxSlabKeep is removed.

The arena rotation test warms both arenas first, since a fresh arena hands
out detached results that hid an early flip, and also checks the previous
display list. The paint count test asserts the list is sized once. The
layout docs note that the superseded layout stays reachable until its
arena is reused.

* fix(layout): size the kept display list by its decorated length

The oversized check compared the kept list against the layout's own
command count. A small view with many caller decorations, such as a
textarea selection over many lines, looked oversized, so the list was
dropped, regrown by slices.Insert and kept again on every frame.

Keep now records the decorated length and display sizes by the larger of
that and the layout count. A regression test lays out, inserts 1000
decorations and keeps the list with zero allocations. The paint count test
now asserts one allocation per block from an empty arena, which also
catches a partial pre-size; it skips under the race detector.

* fix(layout): keep the decorated length across a caret-scroll relayout

display reset the recorded Keep length after use. When the runtime laid
out twice in one frame to scroll a caret, the second pass sized only by
the layout's own commands, dropped the decorated list and regrew it. The
length now lasts until the next Keep, which still records a smaller list
when decorations go away so the large one is released.

Tests cover two layouts before one Keep (zero allocations) and the
release of the list after decorations are removed.
commit
19ecdbf5d850578166fdcea9cc946454f4cd96b2
author
bnema · · 2026-10-02 03:15 UTC
committer
GitHub · · 2026-10-02 03:15 UTC
parent
43c5a7c
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
28 files · +627 -172
M docs/layout.md +2 -0
@@ -6,4 +6,6 @@
66
77 `Output.Tree` is JSON-safe geometry and measured content extent. `Output.Display` contains ordered JSON-safe commands (`shadow`, `rect`, `border`, `outline`, `clip-push`, `text`, `image`, `clip-pop`). Glyph rune-cluster offsets and shaped glyph positions are copied to text commands. Each display-list `Run` also borrows its shaped `*text.Face` for in-process rasterization and carries serializable `FaceID` and `Size` (the logical font size used for shaping). `Face` is omitted from JSON; decoding JSON alone cannot reconstruct a face, so a renderer consuming serialized commands must resolve `FaceID` to a matching font before rasterizing. The original `text.Line` values remain on tree nodes for in-process use and are omitted from JSON. Image commands reference node IDs; the renderer adapter resolves their pixels. `Hit` applies ancestor clips and reverse child order. Pointer capture, focus and tab ordering are separate runtime responsibilities. `Physical` independently rounds logical rectangle edges using a fractional Wayland scale at the renderer boundary; do not use physical coordinates for hit tests. JSON snapshots under `internal/layout/testdata` validate tree and list structure, not rendered pixels.
88
9+`Options.Arena` supplies reusable storage for the tree and display list. An `Output` is valid until its arena is passed to `Layout` again; the runtime alternates two arenas so the committed layout stays readable while the next frame is laid out. The superseded layout, including its images and text, stays reachable until the next build reuses its arena. Without an arena, `Layout` returns a detached `Output`.
10+
911 Known bounds: layout does not implement CSS margin collapse, absolute positioning, baseline alignment, `align-content`, or CSS transforms. Shadow and border commands carry geometry/style data for the renderer; decoration rasterization is not owned here. Text requires a provided `text.Engine` or a synchronous measure callback. Computed line-height repositions shaped baselines, text-align offsets runs within each content line, and letter-spacing is applied to shaped clusters before the text engine wraps (via the additive `text.Request.LetterSpacing` option). A synchronous callback's result must be immutable for the duration of layout; do not share a mutable shaping engine across goroutines.
@@ -6,4 +6,6 @@
6 6
7 `Output.Tree` is JSON-safe geometry and measured content extent. `Output.Display` contains ordered JSON-safe commands (`shadow`, `rect`, `border`, `outline`, `clip-push`, `text`, `image`, `clip-pop`). Glyph rune-cluster offsets and shaped glyph positions are copied to text commands. Each display-list `Run` also borrows its shaped `*text.Face` for in-process rasterization and carries serializable `FaceID` and `Size` (the logical font size used for shaping). `Face` is omitted from JSON; decoding JSON alone cannot reconstruct a face, so a renderer consuming serialized commands must resolve `FaceID` to a matching font before rasterizing. The original `text.Line` values remain on tree nodes for in-process use and are omitted from JSON. Image commands reference node IDs; the renderer adapter resolves their pixels. `Hit` applies ancestor clips and reverse child order. Pointer capture, focus and tab ordering are separate runtime responsibilities. `Physical` independently rounds logical rectangle edges using a fractional Wayland scale at the renderer boundary; do not use physical coordinates for hit tests. JSON snapshots under `internal/layout/testdata` validate tree and list structure, not rendered pixels.7 `Output.Tree` is JSON-safe geometry and measured content extent. `Output.Display` contains ordered JSON-safe commands (`shadow`, `rect`, `border`, `outline`, `clip-push`, `text`, `image`, `clip-pop`). Glyph rune-cluster offsets and shaped glyph positions are copied to text commands. Each display-list `Run` also borrows its shaped `*text.Face` for in-process rasterization and carries serializable `FaceID` and `Size` (the logical font size used for shaping). `Face` is omitted from JSON; decoding JSON alone cannot reconstruct a face, so a renderer consuming serialized commands must resolve `FaceID` to a matching font before rasterizing. The original `text.Line` values remain on tree nodes for in-process use and are omitted from JSON. Image commands reference node IDs; the renderer adapter resolves their pixels. `Hit` applies ancestor clips and reverse child order. Pointer capture, focus and tab ordering are separate runtime responsibilities. `Physical` independently rounds logical rectangle edges using a fractional Wayland scale at the renderer boundary; do not use physical coordinates for hit tests. JSON snapshots under `internal/layout/testdata` validate tree and list structure, not rendered pixels.
8 8
9+`Options.Arena` supplies reusable storage for the tree and display list. An `Output` is valid until its arena is passed to `Layout` again; the runtime alternates two arenas so the committed layout stays readable while the next frame is laid out. The superseded layout, including its images and text, stays reachable until the next build reuses its arena. Without an arena, `Layout` returns a detached `Output`.
10+
9 Known bounds: layout does not implement CSS margin collapse, absolute positioning, baseline alignment, `align-content`, or CSS transforms. Shadow and border commands carry geometry/style data for the renderer; decoration rasterization is not owned here. Text requires a provided `text.Engine` or a synchronous measure callback. Computed line-height repositions shaped baselines, text-align offsets runs within each content line, and letter-spacing is applied to shaped clusters before the text engine wraps (via the additive `text.Request.LetterSpacing` option). A synchronous callback's result must be immutable for the duration of layout; do not share a mutable shaping engine across goroutines.11 Known bounds: layout does not implement CSS margin collapse, absolute positioning, baseline alignment, `align-content`, or CSS transforms. Shadow and border commands carry geometry/style data for the renderer; decoration rasterization is not owned here. Text requires a provided `text.Engine` or a synchronous measure callback. Computed line-height repositions shaped baselines, text-align offsets runs within each content line, and letter-spacing is applied to shaped clusters before the text engine wraps (via the additive `text.Request.LetterSpacing` option). A synchronous callback's result must be immutable for the duration of layout; do not share a mutable shaping engine across goroutines.
M docs/performance.md +28 -26
@@ -2,42 +2,44 @@
22
33 ## CPU budgets
44
5-Measured: **2026-09-30, 07:52–07:54 CEST (UTC+02:00)** on commit `7ad3316fd1add28c1507e28c36883afaeaaad434`.
5+Measured: **2026-10-01, 23:21–23:23 CEST (UTC+02:00)** on commit `89baf59a081fd26ff029288be50554f6b35faf88`.
66
7-AMD Ryzen 9 7900X3D; Go `go1.27.1-X:nodwarf5 linux/amd64`. Command: `CGO_ENABLED=0 GOWORK=off go test -mod=readonly -p=1 -run '^$' -bench . -benchmem -count=5 ./internal/ui ./internal/render ./internal/layout ./internal/text ./internal/css`. Package benchmarks run serially; external profiling runs are separate. Entries are medians of five runs. Budgets allow roughly twice the measured medians; zero-allocation paths stay at zero. DemoFrame has a deliberately tighter 1,100-allocation limit to catch editor-workspace regressions. CPU numbers vary with machine load.
7+AMD Ryzen 9 7900X3D; Go `go1.27.1-X:nodwarf5 linux/amd64`. Command: `CGO_ENABLED=0 GOWORK=off go test -mod=readonly -p=1 -run '^$' -bench . -benchmem -count=5 ./internal/ui ./internal/render ./internal/layout ./internal/text ./internal/css`. Package benchmarks run serially; external profiling runs are separate. Entries are medians of five runs. Budgets allow roughly twice the measured medians; zero-allocation paths stay at zero. DemoFrame has a deliberately tighter 600-allocation limit to catch editor-workspace regressions. CPU numbers vary with machine load.
88
99 The demo-frame benchmark builds the light, comfortable document workspace with a demo stylesheet (`internal/ui/testdata/demo.css`), computes layout and produces a display list after warmup. It includes the toolbar, document list, populated multiline editor and properties pane with input, radios and slider; it excludes GPU submission. Its workload is larger than the older unstyled fixture, so those results are not directly comparable. Prepare uses a representative text/box command mix and excludes GPU submission. Paint uses 100 cards with two shadows and text; ListBatches uses 2,000 quads with interleaved images. ListBufferReuse and LayoutPaintTree warm their buffers and text cache before resetting the timer; their figures measure steady state.
1010
1111 | Package / benchmark | Median ns/op | Median B/op | Median allocs/op | Budget ns/op | Budget B/op | Budget allocs/op |
1212 |---|---:|---:|---:|---:|---:|---:|
13-| internal/ui / DemoFrame | 212,479 | 373,984 | 955 | 430,000 | 750,000 | 1,100 |
14-| render / Prepare | 43,776 | 85,568 | 379 | 100,000 | 175,000 | 800 |
15-| render / PrepareBoxes | 67,867 | 229,377 | 1 | 140,000 | 460,000 | 2 |
16-| render / ListBatches | 114,240 | 420,482 | 2 | 230,000 | 850,000 | 4 |
17-| render / ListBufferReuse | 61,718 | 0 | 0 | 130,000 | 0 | 0 |
18-| layout / Paint | 114,138 | 479,360 | 4 | 240,000 | 960,000 | 8 |
19-| layout / LayoutPaintTree | 184,427 | 546,648 | 313 | 380,000 | 1,100,000 | 650 |
20-| layout / Layout1000 | 893,140 | 731,926 | 1,027 | 1,800,000 | 1,470,000 | 2,100 |
21-| text / AtlasWarmLookup | 31.80 | 0 | 0 | 65 | 0 | 0 |
22-| text / RasterDistinct1000 | 4,214,124 | 8,566,131 | 16,784 | 8,500,000 | 17,200,000 | 34,000 |
23-| text / ShapeParagraph (uncached) | 230,170 | 280,984 | 895 | 500,000 | 570,000 | 1,800 |
24-| text / ShapeParagraphCached | 80.76 | 0 | 0 | 160 | 0 | 0 |
25-| text / AtlasInsert | 558.2 | 968 | 3 | 1,150 | 2,000 | 6 |
26-| css / Engine/cold | 2,330,406 | 1,964,620 | 5,669 | 4,800,000 | 4,000,000 | 11,400 |
27-| css / Engine/warm | 101,909 | 1 | 0 | 205,000 | 2 | 0 |
28-| css / Engine/state-change | 339,806 | 149,105 | 363 | 700,000 | 300,000 | 750 |
29-
30-Natural container measurement prevents nested flex text from collapsing, at a CPU cost that grows with nesting depth. Layout1000 measures about 36% slower than the earlier 656 µs run; allocation count stays at 1,027. A per-layout node/width cache increased time and retained allocation in a trial and is not used. LayoutPaintTree stays near its previous cost. These are correctness/performance tradeoffs, not a claim of universal speed improvement.
31-
32-`TestAllocDemoFrame` checks the 1,100 allocs/op limit with `testing.AllocsPerRun`. `TestAllocRendererSteadyFrame` measures `Renderer.Render` for a view whose text changes through `TextInt`; it measures 48 allocations per steady Render+Released cycle on radv (the guard fails above that baseline; the target is 0), needs a GPU, and is skipped unless `NEFERGUI_RENDER_NODE` is set. `BenchmarkRendererSteadyFrame` profiles the same cycle. Most of the remainder is one uncached text measurement (go-text segmentation and wrapping) plus per-node layout results; `TestAllocMeasureMissShort` bounds the uncached measurement. Focused tests also check constant quad allocation, bounded paint allocation, allocation-free warm list-buffer reuse and allocation-free idle wait results (the real-ioctl variant is opt-in via `NEFERGUI_RENDER_NODE`). The other table budgets are reference targets, not automated assertions.
13+| internal/ui / DemoFrame | 176,882 | 277,695 | 509 | 360,000 | 560,000 | 600 |
14+| render / Prepare | 39,267 | 57,344 | 1 | 80,000 | 115,000 | 2 |
15+| render / PrepareBoxes | 61,463 | 229,378 | 1 | 125,000 | 460,000 | 2 |
16+| render / ListBatches | 102,822 | 420,481 | 2 | 210,000 | 850,000 | 4 |
17+| render / ListBufferReuse | 61,285 | 0 | 0 | 125,000 | 0 | 0 |
18+| layout / Paint | 73,307 | 29 | 0 | 150,000 | 64 | 0 |
19+| layout / LayoutPaintTree | 136,440 | 7 | 0 | 280,000 | 16 | 0 |
20+| layout / Layout1000 | 785,316 | 427,949 | 1,006 | 1,600,000 | 860,000 | 2,000 |
21+| text / AtlasWarmLookup | 28.79 | 0 | 0 | 60 | 0 | 0 |
22+| text / RasterDistinct1000 | 3,678,366 | 8,534,131 | 15,784 | 7,400,000 | 17,100,000 | 32,000 |
23+| text / ShapeParagraph (uncached) | 159,083 | 190,978 | 581 | 320,000 | 390,000 | 1,200 |
24+| text / ShapeParagraphCached | 76.74 | 0 | 0 | 155 | 0 | 0 |
25+| text / MeasureMissShort (uncached) | 3,952 | 1,668 | 20 | 8,000 | 3,400 | 40 |
26+| text / AtlasInsert | 439 | 966 | 3 | 900 | 2,000 | 6 |
27+| css / Engine/cold | 2,224,758 | 1,964,617 | 5,669 | 4,500,000 | 4,000,000 | 11,400 |
28+| css / Engine/warm | 95,060 | 1 | 0 | 190,000 | 2 | 0 |
29+| css / Engine/state-change | 319,098 | 149,102 | 363 | 640,000 | 300,000 | 750 |
30+
31+Natural container measurement prevents nested flex text from collapsing, at a CPU cost that grows with nesting depth. A per-layout node/width cache increased time and retained allocation in a trial and is not used. Layout1000 and Paint build a fresh layout arena per call; LayoutPaintTree reuses one, as the runtime does.
32+
33+`TestAllocDemoFrame` checks the 600 allocs/op limit with `testing.AllocsPerRun`. `TestAllocRendererSteadyFrame` measures `Renderer.Render` for a view whose text changes through `TextInt`; it measures 22 allocations per steady Render+Released cycle on radv (the guard fails above that baseline; the target is 0), needs a GPU, and is skipped unless `NEFERGUI_RENDER_NODE` is set. With unchanged text the same cycle costs 2. `BenchmarkRendererSteadyFrame` profiles the same cycle. The remainder is one uncached text measurement, mostly inside go-text (HarfBuzz shaping, the line breaker and its segmenter iterators), plus the `TextInt` string; `TestAllocMeasureMissShort` bounds the uncached measurement at 20. Focused tests also check allocation-free steady layout, paint and quad reuse, allocation-free warm list-buffer reuse and allocation-free idle wait results (the real-ioctl variant is opt-in via `NEFERGUI_RENDER_NODE`). The other table budgets are reference targets, not automated assertions.
3334
3435 Frame construction limits allocation churn through:
3536
36-- a detached quad array initially reserved up to 512 quads; larger visible frames grow after culling, so offscreen content cannot force an unbounded speculative allocation;
37-- detached display-list storage sized once for commands, shadows, runs and glyphs;
37+- a target-owned quad array reused across submitted frames (`Preparer.PrepareInto`); its initial reservation is capped at 512 quads, so offscreen content cannot force an unbounded speculative allocation. `Preparer.Prepare` still returns detached frames;
38+- two alternating layout arenas (`layout.Arena`) holding the result tree, display commands, shadows, runs, glyphs and flex scratch. The committed layout stays intact while the next frame is laid out, and a frame that fails or panics leaves it untouched;
39+- cached empty glyphs (spaces) in the glyph atlas, bounded separately because no page eviction removes them;
3840 - a target-owned instance/batch buffer, consumed synchronously before reuse, with image references cleared after use and oversized storage discarded when smaller frames arrive;
3941 - font family normalization at font load and identity path keys computed once per element;
40-- `text.Engine.Measure` caching by text, request and width. Entries unused for two frames are evicted; the cache holds at most 4,096 entries. Results are immutable; callers use `Layout.Clone` before adjusting positions.
42+- `text.Engine.Measure` caching by text, request and width. Entries unused for two frames are evicted and recycled; the cache holds at most 4,096 entries. Results are immutable; callers use `Layout.Clone` before adjusting positions. An uncached measure wraps the paragraph once and reuses its segmenter and bidi level buffers.
4143
4244 `Renderer.Render` builds a frame only on input or a redraw request. Idle frame construction stops.
4345
@@ -74,6 +76,6 @@ The minimal view starts at about 0.75 MiB live Go heap, reaches 5.1 MiB after th
7476
7577 DRM counters reported about 41 MiB VRAM and 4 MiB GTT once all three presentation buffers were active, unchanged from baseline and released at shutdown. Window dimensions, scale, fonts and driver affect these figures. These are measurements on one stack, not portable limits.
7678
77-Prepared quads and layout painting still allocate detached outputs. Editor and indicator command insertion can also reallocate the display list; those UI-owned costs are included in the demo measurements and are not optimized here. Glyph rasterization and glyph-key hashing also appear in cumulative allocation profiles.
79+Editor and indicator command insertion can reallocate the display list; those UI-owned costs are included in the demo measurements and are not optimized here. Glyph rasterization and glyph-key hashing also appear in cumulative allocation profiles.
7880
7981 Use `pprof`'s `alloc_space` to find cumulative allocation churn and `inuse_space` after GC to inspect retained Go memory. Use `/proc/<pid>/smaps_rollup` for RSS/PSS and DRM fdinfo for GPU counters; heap profiles do not include driver or GPU memory.
@@ -2,42 +2,44 @@
2 2
3 ## CPU budgets3 ## CPU budgets
4 4
5-Measured: **2026-09-30, 07:52–07:54 CEST (UTC+02:00)** on commit `7ad3316fd1add28c1507e28c36883afaeaaad434`.5+Measured: **2026-10-01, 23:21–23:23 CEST (UTC+02:00)** on commit `89baf59a081fd26ff029288be50554f6b35faf88`.
6 6
7-AMD Ryzen 9 7900X3D; Go `go1.27.1-X:nodwarf5 linux/amd64`. Command: `CGO_ENABLED=0 GOWORK=off go test -mod=readonly -p=1 -run '^$' -bench . -benchmem -count=5 ./internal/ui ./internal/render ./internal/layout ./internal/text ./internal/css`. Package benchmarks run serially; external profiling runs are separate. Entries are medians of five runs. Budgets allow roughly twice the measured medians; zero-allocation paths stay at zero. DemoFrame has a deliberately tighter 1,100-allocation limit to catch editor-workspace regressions. CPU numbers vary with machine load.7+AMD Ryzen 9 7900X3D; Go `go1.27.1-X:nodwarf5 linux/amd64`. Command: `CGO_ENABLED=0 GOWORK=off go test -mod=readonly -p=1 -run '^$' -bench . -benchmem -count=5 ./internal/ui ./internal/render ./internal/layout ./internal/text ./internal/css`. Package benchmarks run serially; external profiling runs are separate. Entries are medians of five runs. Budgets allow roughly twice the measured medians; zero-allocation paths stay at zero. DemoFrame has a deliberately tighter 600-allocation limit to catch editor-workspace regressions. CPU numbers vary with machine load.
8 8
9 The demo-frame benchmark builds the light, comfortable document workspace with a demo stylesheet (`internal/ui/testdata/demo.css`), computes layout and produces a display list after warmup. It includes the toolbar, document list, populated multiline editor and properties pane with input, radios and slider; it excludes GPU submission. Its workload is larger than the older unstyled fixture, so those results are not directly comparable. Prepare uses a representative text/box command mix and excludes GPU submission. Paint uses 100 cards with two shadows and text; ListBatches uses 2,000 quads with interleaved images. ListBufferReuse and LayoutPaintTree warm their buffers and text cache before resetting the timer; their figures measure steady state.9 The demo-frame benchmark builds the light, comfortable document workspace with a demo stylesheet (`internal/ui/testdata/demo.css`), computes layout and produces a display list after warmup. It includes the toolbar, document list, populated multiline editor and properties pane with input, radios and slider; it excludes GPU submission. Its workload is larger than the older unstyled fixture, so those results are not directly comparable. Prepare uses a representative text/box command mix and excludes GPU submission. Paint uses 100 cards with two shadows and text; ListBatches uses 2,000 quads with interleaved images. ListBufferReuse and LayoutPaintTree warm their buffers and text cache before resetting the timer; their figures measure steady state.
10 10
11 | Package / benchmark | Median ns/op | Median B/op | Median allocs/op | Budget ns/op | Budget B/op | Budget allocs/op |11 | Package / benchmark | Median ns/op | Median B/op | Median allocs/op | Budget ns/op | Budget B/op | Budget allocs/op |
12 |---|---:|---:|---:|---:|---:|---:|12 |---|---:|---:|---:|---:|---:|---:|
13-| internal/ui / DemoFrame | 212,479 | 373,984 | 955 | 430,000 | 750,000 | 1,100 |13+| internal/ui / DemoFrame | 176,882 | 277,695 | 509 | 360,000 | 560,000 | 600 |
14-| render / Prepare | 43,776 | 85,568 | 379 | 100,000 | 175,000 | 800 |14+| render / Prepare | 39,267 | 57,344 | 1 | 80,000 | 115,000 | 2 |
15-| render / PrepareBoxes | 67,867 | 229,377 | 1 | 140,000 | 460,000 | 2 |15+| render / PrepareBoxes | 61,463 | 229,378 | 1 | 125,000 | 460,000 | 2 |
16-| render / ListBatches | 114,240 | 420,482 | 2 | 230,000 | 850,000 | 4 |16+| render / ListBatches | 102,822 | 420,481 | 2 | 210,000 | 850,000 | 4 |
17-| render / ListBufferReuse | 61,718 | 0 | 0 | 130,000 | 0 | 0 |17+| render / ListBufferReuse | 61,285 | 0 | 0 | 125,000 | 0 | 0 |
18-| layout / Paint | 114,138 | 479,360 | 4 | 240,000 | 960,000 | 8 |18+| layout / Paint | 73,307 | 29 | 0 | 150,000 | 64 | 0 |
19-| layout / LayoutPaintTree | 184,427 | 546,648 | 313 | 380,000 | 1,100,000 | 650 |19+| layout / LayoutPaintTree | 136,440 | 7 | 0 | 280,000 | 16 | 0 |
20-| layout / Layout1000 | 893,140 | 731,926 | 1,027 | 1,800,000 | 1,470,000 | 2,100 |20+| layout / Layout1000 | 785,316 | 427,949 | 1,006 | 1,600,000 | 860,000 | 2,000 |
21-| text / AtlasWarmLookup | 31.80 | 0 | 0 | 65 | 0 | 0 |21+| text / AtlasWarmLookup | 28.79 | 0 | 0 | 60 | 0 | 0 |
22-| text / RasterDistinct1000 | 4,214,124 | 8,566,131 | 16,784 | 8,500,000 | 17,200,000 | 34,000 |22+| text / RasterDistinct1000 | 3,678,366 | 8,534,131 | 15,784 | 7,400,000 | 17,100,000 | 32,000 |
23-| text / ShapeParagraph (uncached) | 230,170 | 280,984 | 895 | 500,000 | 570,000 | 1,800 |23+| text / ShapeParagraph (uncached) | 159,083 | 190,978 | 581 | 320,000 | 390,000 | 1,200 |
24-| text / ShapeParagraphCached | 80.76 | 0 | 0 | 160 | 0 | 0 |24+| text / ShapeParagraphCached | 76.74 | 0 | 0 | 155 | 0 | 0 |
25-| text / AtlasInsert | 558.2 | 968 | 3 | 1,150 | 2,000 | 6 |25+| text / MeasureMissShort (uncached) | 3,952 | 1,668 | 20 | 8,000 | 3,400 | 40 |
26-| css / Engine/cold | 2,330,406 | 1,964,620 | 5,669 | 4,800,000 | 4,000,000 | 11,400 |26+| text / AtlasInsert | 439 | 966 | 3 | 900 | 2,000 | 6 |
27-| css / Engine/warm | 101,909 | 1 | 0 | 205,000 | 2 | 0 |27+| css / Engine/cold | 2,224,758 | 1,964,617 | 5,669 | 4,500,000 | 4,000,000 | 11,400 |
28-| css / Engine/state-change | 339,806 | 149,105 | 363 | 700,000 | 300,000 | 750 |28+| css / Engine/warm | 95,060 | 1 | 0 | 190,000 | 2 | 0 |
29-29+| css / Engine/state-change | 319,098 | 149,102 | 363 | 640,000 | 300,000 | 750 |
30-Natural container measurement prevents nested flex text from collapsing, at a CPU cost that grows with nesting depth. Layout1000 measures about 36% slower than the earlier 656 µs run; allocation count stays at 1,027. A per-layout node/width cache increased time and retained allocation in a trial and is not used. LayoutPaintTree stays near its previous cost. These are correctness/performance tradeoffs, not a claim of universal speed improvement.30+
31-31+Natural container measurement prevents nested flex text from collapsing, at a CPU cost that grows with nesting depth. A per-layout node/width cache increased time and retained allocation in a trial and is not used. Layout1000 and Paint build a fresh layout arena per call; LayoutPaintTree reuses one, as the runtime does.
32-`TestAllocDemoFrame` checks the 1,100 allocs/op limit with `testing.AllocsPerRun`. `TestAllocRendererSteadyFrame` measures `Renderer.Render` for a view whose text changes through `TextInt`; it measures 48 allocations per steady Render+Released cycle on radv (the guard fails above that baseline; the target is 0), needs a GPU, and is skipped unless `NEFERGUI_RENDER_NODE` is set. `BenchmarkRendererSteadyFrame` profiles the same cycle. Most of the remainder is one uncached text measurement (go-text segmentation and wrapping) plus per-node layout results; `TestAllocMeasureMissShort` bounds the uncached measurement. Focused tests also check constant quad allocation, bounded paint allocation, allocation-free warm list-buffer reuse and allocation-free idle wait results (the real-ioctl variant is opt-in via `NEFERGUI_RENDER_NODE`). The other table budgets are reference targets, not automated assertions.32+
33+`TestAllocDemoFrame` checks the 600 allocs/op limit with `testing.AllocsPerRun`. `TestAllocRendererSteadyFrame` measures `Renderer.Render` for a view whose text changes through `TextInt`; it measures 22 allocations per steady Render+Released cycle on radv (the guard fails above that baseline; the target is 0), needs a GPU, and is skipped unless `NEFERGUI_RENDER_NODE` is set. With unchanged text the same cycle costs 2. `BenchmarkRendererSteadyFrame` profiles the same cycle. The remainder is one uncached text measurement, mostly inside go-text (HarfBuzz shaping, the line breaker and its segmenter iterators), plus the `TextInt` string; `TestAllocMeasureMissShort` bounds the uncached measurement at 20. Focused tests also check allocation-free steady layout, paint and quad reuse, allocation-free warm list-buffer reuse and allocation-free idle wait results (the real-ioctl variant is opt-in via `NEFERGUI_RENDER_NODE`). The other table budgets are reference targets, not automated assertions.
33 34
34 Frame construction limits allocation churn through:35 Frame construction limits allocation churn through:
35 36
36-- a detached quad array initially reserved up to 512 quads; larger visible frames grow after culling, so offscreen content cannot force an unbounded speculative allocation;37+- a target-owned quad array reused across submitted frames (`Preparer.PrepareInto`); its initial reservation is capped at 512 quads, so offscreen content cannot force an unbounded speculative allocation. `Preparer.Prepare` still returns detached frames;
37-- detached display-list storage sized once for commands, shadows, runs and glyphs;38+- two alternating layout arenas (`layout.Arena`) holding the result tree, display commands, shadows, runs, glyphs and flex scratch. The committed layout stays intact while the next frame is laid out, and a frame that fails or panics leaves it untouched;
39+- cached empty glyphs (spaces) in the glyph atlas, bounded separately because no page eviction removes them;
38 - a target-owned instance/batch buffer, consumed synchronously before reuse, with image references cleared after use and oversized storage discarded when smaller frames arrive;40 - a target-owned instance/batch buffer, consumed synchronously before reuse, with image references cleared after use and oversized storage discarded when smaller frames arrive;
39 - font family normalization at font load and identity path keys computed once per element;41 - font family normalization at font load and identity path keys computed once per element;
40-- `text.Engine.Measure` caching by text, request and width. Entries unused for two frames are evicted; the cache holds at most 4,096 entries. Results are immutable; callers use `Layout.Clone` before adjusting positions.42+- `text.Engine.Measure` caching by text, request and width. Entries unused for two frames are evicted and recycled; the cache holds at most 4,096 entries. Results are immutable; callers use `Layout.Clone` before adjusting positions. An uncached measure wraps the paragraph once and reuses its segmenter and bidi level buffers.
41 43
42 `Renderer.Render` builds a frame only on input or a redraw request. Idle frame construction stops.44 `Renderer.Render` builds a frame only on input or a redraw request. Idle frame construction stops.
43 45
@@ -74,6 +76,6 @@ The minimal view starts at about 0.75 MiB live Go heap, reaches 5.1 MiB after th
74 76
75 DRM counters reported about 41 MiB VRAM and 4 MiB GTT once all three presentation buffers were active, unchanged from baseline and released at shutdown. Window dimensions, scale, fonts and driver affect these figures. These are measurements on one stack, not portable limits.77 DRM counters reported about 41 MiB VRAM and 4 MiB GTT once all three presentation buffers were active, unchanged from baseline and released at shutdown. Window dimensions, scale, fonts and driver affect these figures. These are measurements on one stack, not portable limits.
76 78
77-Prepared quads and layout painting still allocate detached outputs. Editor and indicator command insertion can also reallocate the display list; those UI-owned costs are included in the demo measurements and are not optimized here. Glyph rasterization and glyph-key hashing also appear in cumulative allocation profiles.79+Editor and indicator command insertion can reallocate the display list; those UI-owned costs are included in the demo measurements and are not optimized here. Glyph rasterization and glyph-key hashing also appear in cumulative allocation profiles.
78 80
79 Use `pprof`'s `alloc_space` to find cumulative allocation churn and `inuse_space` after GC to inspect retained Go memory. Use `/proc/<pid>/smaps_rollup` for RSS/PSS and DRM fdinfo for GPU counters; heap profiles do not include driver or GPU memory.81 Use `pprof`'s `alloc_space` to find cumulative allocation churn and `inuse_space` after GC to inspect retained Go memory. Use `/proc/<pid>/smaps_rollup` for RSS/PSS and DRM fdinfo for GPU counters; heap profiles do not include driver or GPU memory.
M internal/bidi/levels.go +14 -2
@@ -1,6 +1,9 @@
11 package bidi
22
3-import "fmt"
3+import (
4+ "fmt"
5+ "slices"
6+)
47
58 // BaseDirection selects the paragraph embedding level. Auto follows P2/P3.
69 // nefergui: expose the existing upstream paragraph algorithm's resolved levels.
@@ -25,6 +28,13 @@ func Levels(text []rune, base BaseDirection, lineBreaks []int) ([]uint8, error)
2528 // Resolve returns the paragraph base level and the resolved levels after L1.
2629 // nefergui: surface existing levels without copying or reclassifying UAX#9 classes.
2730 func Resolve(text []rune, base BaseDirection, lineBreaks []int) ([]uint8, uint8, error) {
31+ return ResolveInto(nil, text, base, lineBreaks)
32+}
33+
34+// ResolveInto is Resolve reusing dst's backing array for left-to-right-only
35+// text, so it does not allocate. Text that needs the full UAX#9 algorithm
36+// always gets a newly allocated slice; callers keep the returned slice.
37+func ResolveInto(dst []uint8, text []rune, base BaseDirection, lineBreaks []int) ([]uint8, uint8, error) {
2838 if base > RTL {
2939 return nil, 0, fmt.Errorf("invalid bidi base %d", base)
3040 }
@@ -48,7 +58,9 @@ func Resolve(text []rune, base BaseDirection, lineBreaks []int) ([]uint8, uint8,
4858 prev = end
4959 }
5060 if base != RTL && leftToRightOnly(text) {
51- return make([]uint8, len(text)), 0, nil
61+ dst = slices.Grow(dst[:0], len(text))[:len(text)]
62+ clear(dst)
63+ return dst, 0, nil
5264 }
5365 return resolveFull(text, base, lineBreaks)
5466 }
@@ -1,6 +1,9 @@
1 package bidi1 package bidi
2 2
3-import "fmt"3+import (
4+ "fmt"
5+ "slices"
6+)
4 7
5 // BaseDirection selects the paragraph embedding level. Auto follows P2/P3.8 // BaseDirection selects the paragraph embedding level. Auto follows P2/P3.
6 // nefergui: expose the existing upstream paragraph algorithm's resolved levels.9 // nefergui: expose the existing upstream paragraph algorithm's resolved levels.
@@ -25,6 +28,13 @@ func Levels(text []rune, base BaseDirection, lineBreaks []int) ([]uint8, error)
25 // Resolve returns the paragraph base level and the resolved levels after L1.28 // Resolve returns the paragraph base level and the resolved levels after L1.
26 // nefergui: surface existing levels without copying or reclassifying UAX#9 classes.29 // nefergui: surface existing levels without copying or reclassifying UAX#9 classes.
27 func Resolve(text []rune, base BaseDirection, lineBreaks []int) ([]uint8, uint8, error) {30 func Resolve(text []rune, base BaseDirection, lineBreaks []int) ([]uint8, uint8, error) {
31+ return ResolveInto(nil, text, base, lineBreaks)
32+}
33+
34+// ResolveInto is Resolve reusing dst's backing array for left-to-right-only
35+// text, so it does not allocate. Text that needs the full UAX#9 algorithm
36+// always gets a newly allocated slice; callers keep the returned slice.
37+func ResolveInto(dst []uint8, text []rune, base BaseDirection, lineBreaks []int) ([]uint8, uint8, error) {
28 if base > RTL {38 if base > RTL {
29 return nil, 0, fmt.Errorf("invalid bidi base %d", base)39 return nil, 0, fmt.Errorf("invalid bidi base %d", base)
30 }40 }
@@ -48,7 +58,9 @@ func Resolve(text []rune, base BaseDirection, lineBreaks []int) ([]uint8, uint8,
48 prev = end58 prev = end
49 }59 }
50 if base != RTL && leftToRightOnly(text) {60 if base != RTL && leftToRightOnly(text) {
51- return make([]uint8, len(text)), 0, nil61+ dst = slices.Grow(dst[:0], len(text))[:len(text)]
62+ clear(dst)
63+ return dst, 0, nil
52 }64 }
53 return resolveFull(text, base, lineBreaks)65 return resolveFull(text, base, lineBreaks)
54 }66 }
M internal/bidi/levels_test.go +30 -0
@@ -53,4 +53,34 @@ func TestAllocResolveLeftToRight(t *testing.T) {
5353 if got := testing.AllocsPerRun(100, func() { _, _, _ = Resolve(text, Auto, breaks) }); got > 1 {
5454 t.Fatalf("allocs = %v, want at most 1 (the result)", got)
5555 }
56+ dst := make([]uint8, 0, len(text))
57+ if got := testing.AllocsPerRun(100, func() { dst, _, _ = ResolveInto(dst, text, Auto, breaks) }); got != 0 {
58+ t.Fatalf("ResolveInto allocs = %v, want 0", got)
59+ }
60+ dst[0] = 7 // stale levels must be cleared on reuse
61+ if levels, _, _ := ResolveInto(dst, text, Auto, breaks); levels[0] != 0 {
62+ t.Fatalf("reused levels not cleared: %v", levels)
63+ }
64+}
65+
66+func TestResolveIntoFullPathIgnoresDst(t *testing.T) {
67+ text := []rune("abc \u05d0\u05d1\u05d2 12")
68+ breaks := []int{len(text)}
69+ for _, base := range []BaseDirection{Auto, RTL} {
70+ want, wantPara, err := Resolve(text, base, breaks)
71+ if err != nil {
72+ t.Fatal(err)
73+ }
74+ dst := make([]uint8, len(text))
75+ for i := range dst {
76+ dst[i] = 7
77+ }
78+ got, para, err := ResolveInto(dst, text, base, breaks)
79+ if err != nil || para != wantPara || !slices.Equal(got, want) {
80+ t.Fatalf("base %d: got %v %d %v, want %v %d", base, got, para, err, want, wantPara)
81+ }
82+ if &got[0] == &dst[0] || dst[0] != 7 {
83+ t.Fatalf("base %d: full path wrote into dst", base)
84+ }
85+ }
5686 }
@@ -53,4 +53,34 @@ func TestAllocResolveLeftToRight(t *testing.T) {
53 if got := testing.AllocsPerRun(100, func() { _, _, _ = Resolve(text, Auto, breaks) }); got > 1 {53 if got := testing.AllocsPerRun(100, func() { _, _, _ = Resolve(text, Auto, breaks) }); got > 1 {
54 t.Fatalf("allocs = %v, want at most 1 (the result)", got)54 t.Fatalf("allocs = %v, want at most 1 (the result)", got)
55 }55 }
56+ dst := make([]uint8, 0, len(text))
57+ if got := testing.AllocsPerRun(100, func() { dst, _, _ = ResolveInto(dst, text, Auto, breaks) }); got != 0 {
58+ t.Fatalf("ResolveInto allocs = %v, want 0", got)
59+ }
60+ dst[0] = 7 // stale levels must be cleared on reuse
61+ if levels, _, _ := ResolveInto(dst, text, Auto, breaks); levels[0] != 0 {
62+ t.Fatalf("reused levels not cleared: %v", levels)
63+ }
64+}
65+
66+func TestResolveIntoFullPathIgnoresDst(t *testing.T) {
67+ text := []rune("abc \u05d0\u05d1\u05d2 12")
68+ breaks := []int{len(text)}
69+ for _, base := range []BaseDirection{Auto, RTL} {
70+ want, wantPara, err := Resolve(text, base, breaks)
71+ if err != nil {
72+ t.Fatal(err)
73+ }
74+ dst := make([]uint8, len(text))
75+ for i := range dst {
76+ dst[i] = 7
77+ }
78+ got, para, err := ResolveInto(dst, text, base, breaks)
79+ if err != nil || para != wantPara || !slices.Equal(got, want) {
80+ t.Fatalf("base %d: got %v %d %v, want %v %d", base, got, para, err, want, wantPara)
81+ }
82+ if &got[0] == &dst[0] || dst[0] != 7 {
83+ t.Fatalf("base %d: full path wrote into dst", base)
84+ }
85+ }
56 }86 }
M internal/layout/layout.go +119 -45
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "image"
88 "math"
9+ "slices"
910
1011 "github.com/bnema/nefergui/internal/css"
1112 "github.com/bnema/nefergui/internal/text"
@@ -165,9 +166,38 @@ type Output struct {
165166 }
166167
167168 // Options provide logical viewport size and an optional text shaping engine.
169+// Arena, when set, supplies reusable storage for the Output.
168170 type Options struct {
169171 Width, Height float64
170172 TextEngine *text.Engine
173+ Arena *Arena
174+}
175+
176+// Arena holds the result tree and display storage of one Output, so a steady
177+// layout reuses it instead of allocating. An Output stays valid until its
178+// arena is passed to Layout again; alternate two arenas to keep the previous
179+// Output readable while building the next.
180+type Arena struct {
181+ results slab[Result]
182+ children slab[*Result]
183+ commands []Command
184+ kept int // length of the last list passed to Keep, until the next Keep
185+ // Flex scratch: valid during one Layout call only.
186+ flexItems slab[flexItem]
187+ flexEnds slab[int]
188+ shadows slab[css.Shadow]
189+ runs slab[Run]
190+ glyphs slab[Glyph]
191+}
192+
193+// Keep makes display, the Output.Display of the arena's last Layout after the
194+// caller appended to it, the list the arena recycles next. Without it, a list
195+// that outgrew its arena block is reallocated by the caller every frame.
196+func (a *Arena) Keep(display []Command) {
197+ if cap(display) > cap(a.commands) {
198+ a.commands = display[:0]
199+ }
200+ a.kept = len(display)
171201 }
172202
173203 var ErrDepth = errors.New("layout: tree depth exceeds 256")
@@ -177,7 +207,7 @@ func Layout(root *Node, options Options) (Output, error) {
177207 if root == nil {
178208 return Output{}, nil
179209 }
180- ctx := context{options: options}
210+ ctx := newContext(options)
181211 viewport := Rect{0, 0, safe(options.Width), safe(options.Height)}
182212 tree, err := ctx.place(root, viewport.X, viewport.Y, viewport.W, viewport.H, nil, nil, 0)
183213 if err != nil {
@@ -191,21 +221,44 @@ func Layout(root *Node, options Options) (Output, error) {
191221 // display paints the placed tree into a list sized once from the tree.
192222 func (c context) display(root *Node, tree *Result) []Command {
193223 n := countCommands(root, tree)
224+ a := c.arena
225+ // Size by what the list held last time, so caller decorations do not make
226+ // a kept list look oversized and get dropped every frame.
227+ need := max(n.commands, a.kept)
228+ if oversized(cap(a.commands), need) {
229+ a.commands = nil // do not pin one huge frame's list
230+ } else {
231+ clear(a.commands[:cap(a.commands)]) // callers may have appended in place
232+ }
233+ a.shadows.reset(n.shadows)
234+ a.runs.reset(n.runs)
235+ a.glyphs.reset(n.glyphs)
194236 if n.commands == 0 {
195237 return nil
196238 }
197- out := make([]Command, 0, n.commands)
198- c.shadows, c.runs, c.glyphs = newSlab[css.Shadow](n.shadows), newSlab[Run](n.runs), newSlab[Glyph](n.glyphs)
239+ out := slices.Grow(a.commands[:0], n.commands)
199240 c.paint(root, tree, &out)
241+ a.commands = out
200242 return out
201243 }
202244
203245 type context struct {
204246 options Options
205- // Painter-owned display storage, sized by countCommands; nil is valid.
206- shadows *slab[css.Shadow]
207- runs *slab[Run]
208- glyphs *slab[Glyph]
247+ arena *Arena // storage for results and display; never nil
248+}
249+
250+// newContext recycles options.Arena, or uses a fresh one.
251+func newContext(options Options) context {
252+ arena := options.Arena
253+ if arena == nil {
254+ arena = new(Arena)
255+ }
256+ // The tree size is only known after placing it: size by the last demand.
257+ arena.results.reset(arena.results.need)
258+ arena.children.reset(arena.children.need)
259+ arena.flexItems.reset(arena.flexItems.need)
260+ arena.flexEnds.reset(arena.flexEnds.need)
261+ return context{options: options, arena: arena}
209262 }
210263
211264 func style(n *Node) css.Style {
@@ -481,7 +534,7 @@ func (c context) place(n *Node, x, y, availableW, availableH float64, forcedW, f
481534 if !explicitH {
482535 h = boxBound(h, s.MinHeight, s.MaxHeight, availableH, insetH, borderBox)
483536 }
484- r := &Result{ID: n.ID, Kind: n.Kind, Border: Rect{coord(x + margin.Left), coord(y + margin.Top), w, h}, Margin: margin, Padding: padding, BorderWidths: border, Lines: lines}
537+ r := c.arena.results.one(Result{ID: n.ID, Kind: n.Kind, Border: Rect{coord(x + margin.Left), coord(y + margin.Top), w, h}, Margin: margin, Padding: padding, BorderWidths: border, Lines: lines})
485538 r.Content = Rect{coord(r.Border.X + border.Left + padding.Left), coord(r.Border.Y + border.Top + padding.Top), contentW, safe(h - insetH)}
486539 r.ContentSize = Size{measured.W, measured.H}
487540 // Block and stack auto heights depend on their children's envelopes.
@@ -580,7 +633,7 @@ func (c context) children(n *Node, r *Result, s css.Style, depth int) error {
580633 if absolute {
581634 cw, chh = safe(ch.Rect.X+ch.Rect.W), safe(ch.Rect.Y+ch.Rect.H)
582635 }
583- r.Children = appendChild(r.Children, child, len(n.Children))
636+ r.Children = c.appendChild(r.Children, child, len(n.Children))
584637 r.ContentSize.W = math.Max(r.ContentSize.W, cw)
585638 if n.Kind == Stack {
586639 r.ContentSize.H = math.Max(r.ContentSize.H, chh)
@@ -592,11 +645,11 @@ func (c context) children(n *Node, r *Result, s css.Style, depth int) error {
592645 return nil
593646 }
594647
595-// appendChild appends child, sizing the first allocation for the n input
596-// children so a node's result children cost one allocation, not a growth chain.
597-func appendChild(children []*Result, child *Result, n int) []*Result {
648+// appendChild appends child, taking room for the n input children from the
649+// arena on the first call so a node's children never regrow.
650+func (c context) appendChild(children []*Result, child *Result, n int) []*Result {
598651 if children == nil {
599- children = make([]*Result, 0, n)
652+ children = c.arena.children.take(n)
600653 }
601654 return append(children, child)
602655 }
@@ -735,7 +788,7 @@ func (c context) flex(n *Node, r *Result, s css.Style, depth int) error {
735788 mainSize, crossSize = crossSize, mainSize
736789 gap, crossGap = crossGap, gap
737790 }
738- items := make([]flexItem, 0, len(n.Children))
791+ items := c.arena.flexItems.take(len(n.Children))
739792 for _, ch := range n.Children {
740793 if ch == nil || style(ch).Display == css.KeywordNone {
741794 continue
@@ -800,33 +853,34 @@ func (c context) flex(n *Node, r *Result, s css.Style, depth int) error {
800853 it.cross = boxBound(it.cross, minCross, maxCross, crossSize, inCross, st.BoxSizing == css.KeywordBorderBox)
801854 items = append(items, it)
802855 }
803- lines := [][]flexItem{}
804- line := []flexItem{}
805- used := 0.0
806- for _, item := range items {
856+ // Each line is a run of consecutive items; ends holds each line's end index.
857+ ends := c.arena.flexEnds.take(len(items))
858+ start, used := 0, 0.0
859+ for i, item := range items {
807860 need := item.base + item.marginMain
808- if len(line) > 0 {
861+ if i > start {
809862 need += gap
810863 }
811- if s.FlexWrap == css.KeywordWrap && len(line) > 0 && used+need > mainSize {
812- lines = append(lines, line)
813- line = nil
814- used = 0
864+ if s.FlexWrap == css.KeywordWrap && i > start && used+need > mainSize {
865+ ends = append(ends, i)
866+ start, used = i, 0
815867 need = item.base + item.marginMain
816868 }
817- line = append(line, item)
818869 used += need
819870 }
820- if len(line) > 0 {
821- lines = append(lines, line)
871+ if len(items) > start {
872+ ends = append(ends, len(items))
822873 }
823874 crossPos := 0.0
824- for _, line := range lines {
875+ lineStart := 0
876+ for _, end := range ends {
877+ line := items[lineStart:end:end]
878+ lineStart = end
825879 lineCross := 0.0
826880 for _, it := range line {
827881 lineCross = math.Max(lineCross, it.cross+it.marginCross)
828882 }
829- if len(lines) == 1 && crossSize > 0 && (row && s.Height.Unit != "auto" && s.Height.Unit != "" || !row && s.Width.Unit != "auto" && s.Width.Unit != "") {
883+ if len(ends) == 1 && crossSize > 0 && (row && s.Height.Unit != "auto" && s.Height.Unit != "" || !row && s.Width.Unit != "auto" && s.Width.Unit != "") {
830884 lineCross = math.Max(lineCross, crossSize)
831885 }
832886 resolveFlex(line, mainSize-gap*float64(len(line)-1))
@@ -889,7 +943,7 @@ func (c context) flex(n *Node, r *Result, s css.Style, depth int) error {
889943 return err
890944 }
891945 if child != nil {
892- r.Children = appendChild(r.Children, child, len(n.Children))
946+ r.Children = c.appendChild(r.Children, child, len(n.Children))
893947 cw, ch := envelope(child)
894948 r.ContentSize.W = math.Max(r.ContentSize.W, child.Border.X-r.Content.X+cw)
895949 r.ContentSize.H = math.Max(r.ContentSize.H, child.Border.Y-r.Content.Y+ch)
@@ -901,24 +955,43 @@ func (c context) flex(n *Node, r *Result, s css.Style, depth int) error {
901955 return nil
902956 }
903957
904-// slab hands out detached values or sub-slices from one exactly sized block,
905-// so a display list costs a few allocations rather than one per shadow, run or
906-// text command. Blocks are never resized or reused, so pointers and slices
907-// stay valid for the life of the returned Output. Sub-slices have their
958+// slab hands out values or sub-slices from one block, so an Output costs no
959+// allocation per node, shadow, run or text command once the block is large
960+// enough. The block is reused by reset: pointers and slices from it are valid
961+// until the owning Arena is passed to Layout again. Sub-slices have their
908962 // capacity clipped, so a caller's append cannot overwrite a neighbour. A
909-// miscount, or a nil slab, only falls back to individual allocations.
910-type slab[T any] struct{ block []T }
963+// miscount only falls back to individual allocations, and need grows so the
964+// next reset fits.
965+type slab[T any] struct {
966+ block []T
967+ need int // demand since the last reset, including fallbacks
968+}
911969
912-func newSlab[T any](n int) *slab[T] {
913- if n <= 0 {
914- return nil
970+// retainSlack is how much larger than needed retained storage may be before
971+// it is dropped, so one huge frame does not pin its memory. render uses the
972+// same rule for its quad and instance buffers.
973+const retainSlack = 4
974+
975+// oversized reports that storage of the given capacity holds far more than
976+// need and should be dropped rather than reused.
977+func oversized(capacity, need int) bool { return capacity > retainSlack*need+64 }
978+
979+// reset recycles the block for n elements, zeroing the previous contents so
980+// they do not keep text, faces or images alive.
981+func (a *slab[T]) reset(n int) {
982+ clear(a.block)
983+ a.need = 0
984+ if cap(a.block) < n || oversized(cap(a.block), n) {
985+ a.block = make([]T, 0, n)
986+ return
915987 }
916- return &slab[T]{block: make([]T, 0, n)}
988+ a.block = a.block[:0]
917989 }
918990
919991 // one stores v in the slab and returns its address.
920992 func (a *slab[T]) one(v T) *T {
921- if a == nil || len(a.block) == cap(a.block) {
993+ a.need++
994+ if len(a.block) == cap(a.block) {
922995 p := new(T) // copy, so v itself never escapes
923996 *p = v
924997 return p
@@ -932,7 +1005,8 @@ func (a *slab[T]) take(n int) []T {
9321005 if n <= 0 {
9331006 return nil
9341007 }
935- if a == nil || cap(a.block)-len(a.block) < n {
1008+ a.need += n
1009+ if cap(a.block)-len(a.block) < n {
9361010 return make([]T, 0, n)
9371011 }
9381012 i := len(a.block)
@@ -1020,7 +1094,7 @@ func (c context) paint(n *Node, r *Result, out *[]Command) {
10201094 }
10211095 cmd := base
10221096 cmd.Op = "shadow"
1023- cmd.Shadow = c.shadows.one(s.BoxShadow[i])
1097+ cmd.Shadow = c.arena.shadows.one(s.BoxShadow[i])
10241098 cmd.Radii = borderRadii
10251099 *out = append(*out, cmd)
10261100 }
@@ -1046,7 +1120,7 @@ func (c context) paint(n *Node, r *Result, out *[]Command) {
10461120 }
10471121 cmd := base
10481122 cmd.Op = "shadow"
1049- cmd.Shadow = c.shadows.one(s.BoxShadow[i])
1123+ cmd.Shadow = c.arena.shadows.one(s.BoxShadow[i])
10501124 cmd.Radii = borderRadii
10511125 *out = append(*out, cmd)
10521126 }
@@ -1082,8 +1156,8 @@ func (c context) paint(n *Node, r *Result, out *[]Command) {
10821156 cmd.Text = n.Content
10831157 cmd.Color = s.Color
10841158 runs, glyphs := textCounts(r)
1085- cmd.Runs = c.runs.take(runs)
1086- backing := c.glyphs.take(glyphs)
1159+ cmd.Runs = c.arena.runs.take(runs)
1160+ backing := c.arena.glyphs.take(glyphs)
10871161 for _, line := range r.Lines {
10881162 offset := AlignOffset(s.TextAlign, line.Direction, r.Content.W, line.Width)
10891163 for _, run := range line.Runs {
@@ -6,6 +6,7 @@ import (
6 "errors"6 "errors"
7 "image"7 "image"
8 "math"8 "math"
9+ "slices"
9 10
10 "github.com/bnema/nefergui/internal/css"11 "github.com/bnema/nefergui/internal/css"
11 "github.com/bnema/nefergui/internal/text"12 "github.com/bnema/nefergui/internal/text"
@@ -165,9 +166,38 @@ type Output struct {
165 }166 }
166 167
167 // Options provide logical viewport size and an optional text shaping engine.168 // Options provide logical viewport size and an optional text shaping engine.
169+// Arena, when set, supplies reusable storage for the Output.
168 type Options struct {170 type Options struct {
169 Width, Height float64171 Width, Height float64
170 TextEngine *text.Engine172 TextEngine *text.Engine
173+ Arena *Arena
174+}
175+
176+// Arena holds the result tree and display storage of one Output, so a steady
177+// layout reuses it instead of allocating. An Output stays valid until its
178+// arena is passed to Layout again; alternate two arenas to keep the previous
179+// Output readable while building the next.
180+type Arena struct {
181+ results slab[Result]
182+ children slab[*Result]
183+ commands []Command
184+ kept int // length of the last list passed to Keep, until the next Keep
185+ // Flex scratch: valid during one Layout call only.
186+ flexItems slab[flexItem]
187+ flexEnds slab[int]
188+ shadows slab[css.Shadow]
189+ runs slab[Run]
190+ glyphs slab[Glyph]
191+}
192+
193+// Keep makes display, the Output.Display of the arena's last Layout after the
194+// caller appended to it, the list the arena recycles next. Without it, a list
195+// that outgrew its arena block is reallocated by the caller every frame.
196+func (a *Arena) Keep(display []Command) {
197+ if cap(display) > cap(a.commands) {
198+ a.commands = display[:0]
199+ }
200+ a.kept = len(display)
171 }201 }
172 202
173 var ErrDepth = errors.New("layout: tree depth exceeds 256")203 var ErrDepth = errors.New("layout: tree depth exceeds 256")
@@ -177,7 +207,7 @@ func Layout(root *Node, options Options) (Output, error) {
177 if root == nil {207 if root == nil {
178 return Output{}, nil208 return Output{}, nil
179 }209 }
180- ctx := context{options: options}210+ ctx := newContext(options)
181 viewport := Rect{0, 0, safe(options.Width), safe(options.Height)}211 viewport := Rect{0, 0, safe(options.Width), safe(options.Height)}
182 tree, err := ctx.place(root, viewport.X, viewport.Y, viewport.W, viewport.H, nil, nil, 0)212 tree, err := ctx.place(root, viewport.X, viewport.Y, viewport.W, viewport.H, nil, nil, 0)
183 if err != nil {213 if err != nil {
@@ -191,21 +221,44 @@ func Layout(root *Node, options Options) (Output, error) {
191 // display paints the placed tree into a list sized once from the tree.221 // display paints the placed tree into a list sized once from the tree.
192 func (c context) display(root *Node, tree *Result) []Command {222 func (c context) display(root *Node, tree *Result) []Command {
193 n := countCommands(root, tree)223 n := countCommands(root, tree)
224+ a := c.arena
225+ // Size by what the list held last time, so caller decorations do not make
226+ // a kept list look oversized and get dropped every frame.
227+ need := max(n.commands, a.kept)
228+ if oversized(cap(a.commands), need) {
229+ a.commands = nil // do not pin one huge frame's list
230+ } else {
231+ clear(a.commands[:cap(a.commands)]) // callers may have appended in place
232+ }
233+ a.shadows.reset(n.shadows)
234+ a.runs.reset(n.runs)
235+ a.glyphs.reset(n.glyphs)
194 if n.commands == 0 {236 if n.commands == 0 {
195 return nil237 return nil
196 }238 }
197- out := make([]Command, 0, n.commands)239+ out := slices.Grow(a.commands[:0], n.commands)
198- c.shadows, c.runs, c.glyphs = newSlab[css.Shadow](n.shadows), newSlab[Run](n.runs), newSlab[Glyph](n.glyphs)
199 c.paint(root, tree, &out)240 c.paint(root, tree, &out)
241+ a.commands = out
200 return out242 return out
201 }243 }
202 244
203 type context struct {245 type context struct {
204 options Options246 options Options
205- // Painter-owned display storage, sized by countCommands; nil is valid.247+ arena *Arena // storage for results and display; never nil
206- shadows *slab[css.Shadow]248+}
207- runs *slab[Run]249+
208- glyphs *slab[Glyph]250+// newContext recycles options.Arena, or uses a fresh one.
251+func newContext(options Options) context {
252+ arena := options.Arena
253+ if arena == nil {
254+ arena = new(Arena)
255+ }
256+ // The tree size is only known after placing it: size by the last demand.
257+ arena.results.reset(arena.results.need)
258+ arena.children.reset(arena.children.need)
259+ arena.flexItems.reset(arena.flexItems.need)
260+ arena.flexEnds.reset(arena.flexEnds.need)
261+ return context{options: options, arena: arena}
209 }262 }
210 263
211 func style(n *Node) css.Style {264 func style(n *Node) css.Style {
@@ -481,7 +534,7 @@ func (c context) place(n *Node, x, y, availableW, availableH float64, forcedW, f
481 if !explicitH {534 if !explicitH {
482 h = boxBound(h, s.MinHeight, s.MaxHeight, availableH, insetH, borderBox)535 h = boxBound(h, s.MinHeight, s.MaxHeight, availableH, insetH, borderBox)
483 }536 }
484- r := &Result{ID: n.ID, Kind: n.Kind, Border: Rect{coord(x + margin.Left), coord(y + margin.Top), w, h}, Margin: margin, Padding: padding, BorderWidths: border, Lines: lines}537+ r := c.arena.results.one(Result{ID: n.ID, Kind: n.Kind, Border: Rect{coord(x + margin.Left), coord(y + margin.Top), w, h}, Margin: margin, Padding: padding, BorderWidths: border, Lines: lines})
485 r.Content = Rect{coord(r.Border.X + border.Left + padding.Left), coord(r.Border.Y + border.Top + padding.Top), contentW, safe(h - insetH)}538 r.Content = Rect{coord(r.Border.X + border.Left + padding.Left), coord(r.Border.Y + border.Top + padding.Top), contentW, safe(h - insetH)}
486 r.ContentSize = Size{measured.W, measured.H}539 r.ContentSize = Size{measured.W, measured.H}
487 // Block and stack auto heights depend on their children's envelopes.540 // Block and stack auto heights depend on their children's envelopes.
@@ -580,7 +633,7 @@ func (c context) children(n *Node, r *Result, s css.Style, depth int) error {
580 if absolute {633 if absolute {
581 cw, chh = safe(ch.Rect.X+ch.Rect.W), safe(ch.Rect.Y+ch.Rect.H)634 cw, chh = safe(ch.Rect.X+ch.Rect.W), safe(ch.Rect.Y+ch.Rect.H)
582 }635 }
583- r.Children = appendChild(r.Children, child, len(n.Children))636+ r.Children = c.appendChild(r.Children, child, len(n.Children))
584 r.ContentSize.W = math.Max(r.ContentSize.W, cw)637 r.ContentSize.W = math.Max(r.ContentSize.W, cw)
585 if n.Kind == Stack {638 if n.Kind == Stack {
586 r.ContentSize.H = math.Max(r.ContentSize.H, chh)639 r.ContentSize.H = math.Max(r.ContentSize.H, chh)
@@ -592,11 +645,11 @@ func (c context) children(n *Node, r *Result, s css.Style, depth int) error {
592 return nil645 return nil
593 }646 }
594 647
595-// appendChild appends child, sizing the first allocation for the n input648+// appendChild appends child, taking room for the n input children from the
596-// children so a node's result children cost one allocation, not a growth chain.649+// arena on the first call so a node's children never regrow.
597-func appendChild(children []*Result, child *Result, n int) []*Result {650+func (c context) appendChild(children []*Result, child *Result, n int) []*Result {
598 if children == nil {651 if children == nil {
599- children = make([]*Result, 0, n)652+ children = c.arena.children.take(n)
600 }653 }
601 return append(children, child)654 return append(children, child)
602 }655 }
@@ -735,7 +788,7 @@ func (c context) flex(n *Node, r *Result, s css.Style, depth int) error {
735 mainSize, crossSize = crossSize, mainSize788 mainSize, crossSize = crossSize, mainSize
736 gap, crossGap = crossGap, gap789 gap, crossGap = crossGap, gap
737 }790 }
738- items := make([]flexItem, 0, len(n.Children))791+ items := c.arena.flexItems.take(len(n.Children))
739 for _, ch := range n.Children {792 for _, ch := range n.Children {
740 if ch == nil || style(ch).Display == css.KeywordNone {793 if ch == nil || style(ch).Display == css.KeywordNone {
741 continue794 continue
@@ -800,33 +853,34 @@ func (c context) flex(n *Node, r *Result, s css.Style, depth int) error {
800 it.cross = boxBound(it.cross, minCross, maxCross, crossSize, inCross, st.BoxSizing == css.KeywordBorderBox)853 it.cross = boxBound(it.cross, minCross, maxCross, crossSize, inCross, st.BoxSizing == css.KeywordBorderBox)
801 items = append(items, it)854 items = append(items, it)
802 }855 }
803- lines := [][]flexItem{}856+ // Each line is a run of consecutive items; ends holds each line's end index.
804- line := []flexItem{}857+ ends := c.arena.flexEnds.take(len(items))
805- used := 0.0858+ start, used := 0, 0.0
806- for _, item := range items {859+ for i, item := range items {
807 need := item.base + item.marginMain860 need := item.base + item.marginMain
808- if len(line) > 0 {861+ if i > start {
809 need += gap862 need += gap
810 }863 }
811- if s.FlexWrap == css.KeywordWrap && len(line) > 0 && used+need > mainSize {864+ if s.FlexWrap == css.KeywordWrap && i > start && used+need > mainSize {
812- lines = append(lines, line)865+ ends = append(ends, i)
813- line = nil866+ start, used = i, 0
814- used = 0
815 need = item.base + item.marginMain867 need = item.base + item.marginMain
816 }868 }
817- line = append(line, item)
818 used += need869 used += need
819 }870 }
820- if len(line) > 0 {871+ if len(items) > start {
821- lines = append(lines, line)872+ ends = append(ends, len(items))
822 }873 }
823 crossPos := 0.0874 crossPos := 0.0
824- for _, line := range lines {875+ lineStart := 0
876+ for _, end := range ends {
877+ line := items[lineStart:end:end]
878+ lineStart = end
825 lineCross := 0.0879 lineCross := 0.0
826 for _, it := range line {880 for _, it := range line {
827 lineCross = math.Max(lineCross, it.cross+it.marginCross)881 lineCross = math.Max(lineCross, it.cross+it.marginCross)
828 }882 }
829- if len(lines) == 1 && crossSize > 0 && (row && s.Height.Unit != "auto" && s.Height.Unit != "" || !row && s.Width.Unit != "auto" && s.Width.Unit != "") {883+ if len(ends) == 1 && crossSize > 0 && (row && s.Height.Unit != "auto" && s.Height.Unit != "" || !row && s.Width.Unit != "auto" && s.Width.Unit != "") {
830 lineCross = math.Max(lineCross, crossSize)884 lineCross = math.Max(lineCross, crossSize)
831 }885 }
832 resolveFlex(line, mainSize-gap*float64(len(line)-1))886 resolveFlex(line, mainSize-gap*float64(len(line)-1))
@@ -889,7 +943,7 @@ func (c context) flex(n *Node, r *Result, s css.Style, depth int) error {
889 return err943 return err
890 }944 }
891 if child != nil {945 if child != nil {
892- r.Children = appendChild(r.Children, child, len(n.Children))946+ r.Children = c.appendChild(r.Children, child, len(n.Children))
893 cw, ch := envelope(child)947 cw, ch := envelope(child)
894 r.ContentSize.W = math.Max(r.ContentSize.W, child.Border.X-r.Content.X+cw)948 r.ContentSize.W = math.Max(r.ContentSize.W, child.Border.X-r.Content.X+cw)
895 r.ContentSize.H = math.Max(r.ContentSize.H, child.Border.Y-r.Content.Y+ch)949 r.ContentSize.H = math.Max(r.ContentSize.H, child.Border.Y-r.Content.Y+ch)
@@ -901,24 +955,43 @@ func (c context) flex(n *Node, r *Result, s css.Style, depth int) error {
901 return nil955 return nil
902 }956 }
903 957
904-// slab hands out detached values or sub-slices from one exactly sized block,958+// slab hands out values or sub-slices from one block, so an Output costs no
905-// so a display list costs a few allocations rather than one per shadow, run or959+// allocation per node, shadow, run or text command once the block is large
906-// text command. Blocks are never resized or reused, so pointers and slices960+// enough. The block is reused by reset: pointers and slices from it are valid
907-// stay valid for the life of the returned Output. Sub-slices have their961+// until the owning Arena is passed to Layout again. Sub-slices have their
908 // capacity clipped, so a caller's append cannot overwrite a neighbour. A962 // capacity clipped, so a caller's append cannot overwrite a neighbour. A
909-// miscount, or a nil slab, only falls back to individual allocations.963+// miscount only falls back to individual allocations, and need grows so the
910-type slab[T any] struct{ block []T }964+// next reset fits.
965+type slab[T any] struct {
966+ block []T
967+ need int // demand since the last reset, including fallbacks
968+}
911 969
912-func newSlab[T any](n int) *slab[T] {970+// retainSlack is how much larger than needed retained storage may be before
913- if n <= 0 {971+// it is dropped, so one huge frame does not pin its memory. render uses the
914- return nil972+// same rule for its quad and instance buffers.
973+const retainSlack = 4
974+
975+// oversized reports that storage of the given capacity holds far more than
976+// need and should be dropped rather than reused.
977+func oversized(capacity, need int) bool { return capacity > retainSlack*need+64 }
978+
979+// reset recycles the block for n elements, zeroing the previous contents so
980+// they do not keep text, faces or images alive.
981+func (a *slab[T]) reset(n int) {
982+ clear(a.block)
983+ a.need = 0
984+ if cap(a.block) < n || oversized(cap(a.block), n) {
985+ a.block = make([]T, 0, n)
986+ return
915 }987 }
916- return &slab[T]{block: make([]T, 0, n)}988+ a.block = a.block[:0]
917 }989 }
918 990
919 // one stores v in the slab and returns its address.991 // one stores v in the slab and returns its address.
920 func (a *slab[T]) one(v T) *T {992 func (a *slab[T]) one(v T) *T {
921- if a == nil || len(a.block) == cap(a.block) {993+ a.need++
994+ if len(a.block) == cap(a.block) {
922 p := new(T) // copy, so v itself never escapes995 p := new(T) // copy, so v itself never escapes
923 *p = v996 *p = v
924 return p997 return p
@@ -932,7 +1005,8 @@ func (a *slab[T]) take(n int) []T {
932 if n <= 0 {1005 if n <= 0 {
933 return nil1006 return nil
934 }1007 }
935- if a == nil || cap(a.block)-len(a.block) < n {1008+ a.need += n
1009+ if cap(a.block)-len(a.block) < n {
936 return make([]T, 0, n)1010 return make([]T, 0, n)
937 }1011 }
938 i := len(a.block)1012 i := len(a.block)
@@ -1020,7 +1094,7 @@ func (c context) paint(n *Node, r *Result, out *[]Command) {
1020 }1094 }
1021 cmd := base1095 cmd := base
1022 cmd.Op = "shadow"1096 cmd.Op = "shadow"
1023- cmd.Shadow = c.shadows.one(s.BoxShadow[i])1097+ cmd.Shadow = c.arena.shadows.one(s.BoxShadow[i])
1024 cmd.Radii = borderRadii1098 cmd.Radii = borderRadii
1025 *out = append(*out, cmd)1099 *out = append(*out, cmd)
1026 }1100 }
@@ -1046,7 +1120,7 @@ func (c context) paint(n *Node, r *Result, out *[]Command) {
1046 }1120 }
1047 cmd := base1121 cmd := base
1048 cmd.Op = "shadow"1122 cmd.Op = "shadow"
1049- cmd.Shadow = c.shadows.one(s.BoxShadow[i])1123+ cmd.Shadow = c.arena.shadows.one(s.BoxShadow[i])
1050 cmd.Radii = borderRadii1124 cmd.Radii = borderRadii
1051 *out = append(*out, cmd)1125 *out = append(*out, cmd)
1052 }1126 }
@@ -1082,8 +1156,8 @@ func (c context) paint(n *Node, r *Result, out *[]Command) {
1082 cmd.Text = n.Content1156 cmd.Text = n.Content
1083 cmd.Color = s.Color1157 cmd.Color = s.Color
1084 runs, glyphs := textCounts(r)1158 runs, glyphs := textCounts(r)
1085- cmd.Runs = c.runs.take(runs)1159+ cmd.Runs = c.arena.runs.take(runs)
1086- backing := c.glyphs.take(glyphs)1160+ backing := c.arena.glyphs.take(glyphs)
1087 for _, line := range r.Lines {1161 for _, line := range r.Lines {
1088 offset := AlignOffset(s.TextAlign, line.Direction, r.Content.W, line.Width)1162 offset := AlignOffset(s.TextAlign, line.Direction, r.Content.W, line.Width)
1089 for _, run := range line.Runs {1163 for _, run := range line.Runs {
M internal/layout/layout_test.go +8 -0
@@ -75,6 +75,14 @@ func TestFlex(t *testing.T) {
7575 t.Fatalf("flex wrap: %+v", out.Tree.Children)
7676 }
7777 snapshot(t, "flex", out)
78+ arena := new(Arena)
79+ opts := Options{Width: 120, Height: 50, Arena: arena}
80+ if _, err := Layout(root, opts); err != nil {
81+ t.Fatal(err)
82+ }
83+ if n := testing.AllocsPerRun(20, func() { _, _ = Layout(root, opts) }); n != 0 {
84+ t.Fatalf("steady flex wrap layout allocs=%v, want 0", n)
85+ }
7886 s.FlexWrap = css.KeywordNowrap
7987 root.Style = &s
8088 out, err = Layout(root, Options{Width: 120, Height: 50})
@@ -75,6 +75,14 @@ func TestFlex(t *testing.T) {
75 t.Fatalf("flex wrap: %+v", out.Tree.Children)75 t.Fatalf("flex wrap: %+v", out.Tree.Children)
76 }76 }
77 snapshot(t, "flex", out)77 snapshot(t, "flex", out)
78+ arena := new(Arena)
79+ opts := Options{Width: 120, Height: 50, Arena: arena}
80+ if _, err := Layout(root, opts); err != nil {
81+ t.Fatal(err)
82+ }
83+ if n := testing.AllocsPerRun(20, func() { _, _ = Layout(root, opts) }); n != 0 {
84+ t.Fatalf("steady flex wrap layout allocs=%v, want 0", n)
85+ }
78 s.FlexWrap = css.KeywordNowrap86 s.FlexWrap = css.KeywordNowrap
79 root.Style = &s87 root.Style = &s
80 out, err = Layout(root, Options{Width: 120, Height: 50})88 out, err = Layout(root, Options{Width: 120, Height: 50})
M internal/layout/paint_bench_test.go +2 -1
@@ -37,7 +37,7 @@ func paintFixture(tb testing.TB, cards int) (*Node, Options) {
3737 // BenchmarkPaint measures only context.paint on an already placed tree.
3838 func BenchmarkPaint(b *testing.B) {
3939 root, opts := paintFixture(b, 100)
40- ctx := context{options: opts}
40+ ctx := newContext(opts)
4141 tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0)
4242 if err != nil {
4343 b.Fatal(err)
@@ -53,6 +53,7 @@ func BenchmarkPaint(b *testing.B) {
5353
5454 func BenchmarkLayoutPaintTree(b *testing.B) {
5555 root, opts := paintFixture(b, 100)
56+ opts.Arena = new(Arena) // as the runtime does: storage is reused per frame
5657 if _, err := Layout(root, opts); err != nil {
5758 b.Fatal(err)
5859 }
@@ -37,7 +37,7 @@ func paintFixture(tb testing.TB, cards int) (*Node, Options) {
37 // BenchmarkPaint measures only context.paint on an already placed tree.37 // BenchmarkPaint measures only context.paint on an already placed tree.
38 func BenchmarkPaint(b *testing.B) {38 func BenchmarkPaint(b *testing.B) {
39 root, opts := paintFixture(b, 100)39 root, opts := paintFixture(b, 100)
40- ctx := context{options: opts}40+ ctx := newContext(opts)
41 tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0)41 tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0)
42 if err != nil {42 if err != nil {
43 b.Fatal(err)43 b.Fatal(err)
@@ -53,6 +53,7 @@ func BenchmarkPaint(b *testing.B) {
53 53
54 func BenchmarkLayoutPaintTree(b *testing.B) {54 func BenchmarkLayoutPaintTree(b *testing.B) {
55 root, opts := paintFixture(b, 100)55 root, opts := paintFixture(b, 100)
56+ opts.Arena = new(Arena) // as the runtime does: storage is reused per frame
56 if _, err := Layout(root, opts); err != nil {57 if _, err := Layout(root, opts); err != nil {
57 b.Fatal(err)58 b.Fatal(err)
58 }59 }
M internal/layout/paint_test.go +76 -7
@@ -3,6 +3,7 @@ package layout
33 import (
44 "image"
55 "reflect"
6+ "slices"
67 "testing"
78
89 "github.com/bnema/nefergui/internal/css"
@@ -112,7 +113,7 @@ func TestPaintCountsMatch(t *testing.T) {
112113 &Node{ID: "outline", Kind: Box, Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20), OutlineStyle: css.KeywordSolid, OutlineWidth: px(2)}},
113114 &Node{ID: "radius", Kind: Box, Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20), BorderRadius: css.Radii{TopLeft: px(2)}}},
114115 )
115- ctx := context{options: opts}
116+ ctx := newContext(opts)
116117 tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0)
117118 if err != nil {
118119 t.Fatal(err)
@@ -129,21 +130,89 @@ func TestPaintCountsMatch(t *testing.T) {
129130 shadows++
130131 }
131132 }
132- if n.commands != len(out) || cap(out) != len(out) || n.runs != runs || n.glyphs != glyphs || n.shadows != shadows {
133- t.Fatalf("counts %+v vs commands=%d cap=%d runs=%d glyphs=%d shadows=%d", n, len(out), cap(out), runs, glyphs, shadows)
133+ if n.commands != len(out) || n.runs != runs || n.glyphs != glyphs || n.shadows != shadows {
134+ t.Fatalf("counts %+v vs commands=%d runs=%d glyphs=%d shadows=%d", n, len(out), runs, glyphs, shadows)
135+ }
136+ // An empty arena sizes each block once from the counts: one allocation
137+ // each for commands, shadows, runs and glyphs, with no growth.
138+ if raceEnabled {
139+ return
140+ }
141+ if got := testing.AllocsPerRun(10, func() {
142+ *ctx.arena = Arena{}
143+ ctx.display(root, tree)
144+ }); got != 4 {
145+ t.Fatalf("fresh display allocs=%v, want 4", got)
134146 }
135147 }
136148
137149 func TestPaintAllocations(t *testing.T) {
138150 root, opts := paintFixture(t, 50)
139- ctx := context{options: opts}
151+ ctx := newContext(opts)
140152 tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0)
141153 if err != nil {
142154 t.Fatal(err)
143155 }
144- // One block each for commands, shadows, runs and glyphs, whatever the tree size.
145- if n := testing.AllocsPerRun(20, func() { ctx.display(root, tree) }); n > 4 {
146- t.Fatalf("paint allocs=%v, want <= 4", n)
156+ // The arena's blocks are reused, so a repeated paint allocates nothing.
157+ if n := testing.AllocsPerRun(20, func() { ctx.display(root, tree) }); n != 0 {
158+ t.Fatalf("paint allocs=%v, want 0", n)
159+ }
160+}
161+
162+// TestLayoutArenaReuse checks that a steady layout into a reused arena
163+// allocates nothing, and that alternating two arenas keeps the previous
164+// Output intact while the next one is built.
165+func TestLayoutArenaReuse(t *testing.T) {
166+ root, opts := paintFixture(t, 20)
167+ var arenas [2]Arena
168+ layout := func(i int) Output {
169+ o := opts
170+ o.Arena = &arenas[i]
171+ out, err := Layout(root, o)
172+ if err != nil {
173+ t.Fatal(err)
174+ }
175+ return out
176+ }
177+ prev := layout(0)
178+ layout(1)
179+ wantID, wantX := prev.Tree.Children[3].ID, prev.Display[5].Rect.X
180+ root.Children[3].ID = "changed"
181+ layout(1)
182+ if prev.Tree.Children[3].ID != wantID || prev.Display[5].Rect.X != wantX {
183+ t.Fatal("building into one arena changed the Output of the other")
184+ }
185+ if n := testing.AllocsPerRun(20, func() { layout(1) }); n != 0 {
186+ t.Fatalf("steady layout allocs=%v, want 0", n)
187+ }
188+ // A list the caller grew with many decorations and handed back through
189+ // Keep is recycled, not dropped as oversized for the layout alone.
190+ decorations := make([]Command, 1000) // far beyond retainSlack times the layout's own
191+ decorate := func() {
192+ out := layout(1)
193+ arenas[1].Keep(slices.Insert(out.Display, 0, decorations...))
194+ }
195+ decorate()
196+ if n := testing.AllocsPerRun(20, decorate); n != 0 {
197+ t.Fatalf("decorated layout allocs=%v, want 0", n)
198+ }
199+ // The runtime may lay out twice before one Keep (caret scrolling).
200+ if n := testing.AllocsPerRun(20, func() { layout(1); decorate() }); n != 0 {
201+ t.Fatalf("relayout before keep allocs=%v, want 0", n)
202+ }
203+ // Once decorations go away, the large list is released.
204+ decorated := cap(arenas[1].commands)
205+ arenas[1].Keep(layout(1).Display)
206+ if layout(1); cap(arenas[1].commands) >= decorated {
207+ t.Fatalf("decorated list kept after decorations went away: cap %d", cap(arenas[1].commands))
208+ }
209+ // Growth falls back to individual allocations once, then fits.
210+ for range 30 {
211+ root.Children = append(root.Children, root.Children[0])
212+ }
213+ layout(1)
214+ if n := testing.AllocsPerRun(20, func() { layout(1) }); n != 0 {
215+ t.Fatalf("allocs after growth=%v, want 0", n)
147216 }
148217 }
149218
@@ -3,6 +3,7 @@ package layout
3 import (3 import (
4 "image"4 "image"
5 "reflect"5 "reflect"
6+ "slices"
6 "testing"7 "testing"
7 8
8 "github.com/bnema/nefergui/internal/css"9 "github.com/bnema/nefergui/internal/css"
@@ -112,7 +113,7 @@ func TestPaintCountsMatch(t *testing.T) {
112 &Node{ID: "outline", Kind: Box, Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20), OutlineStyle: css.KeywordSolid, OutlineWidth: px(2)}},113 &Node{ID: "outline", Kind: Box, Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20), OutlineStyle: css.KeywordSolid, OutlineWidth: px(2)}},
113 &Node{ID: "radius", Kind: Box, Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20), BorderRadius: css.Radii{TopLeft: px(2)}}},114 &Node{ID: "radius", Kind: Box, Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20), BorderRadius: css.Radii{TopLeft: px(2)}}},
114 )115 )
115- ctx := context{options: opts}116+ ctx := newContext(opts)
116 tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0)117 tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0)
117 if err != nil {118 if err != nil {
118 t.Fatal(err)119 t.Fatal(err)
@@ -129,21 +130,89 @@ func TestPaintCountsMatch(t *testing.T) {
129 shadows++130 shadows++
130 }131 }
131 }132 }
132- if n.commands != len(out) || cap(out) != len(out) || n.runs != runs || n.glyphs != glyphs || n.shadows != shadows {133+ if n.commands != len(out) || n.runs != runs || n.glyphs != glyphs || n.shadows != shadows {
133- t.Fatalf("counts %+v vs commands=%d cap=%d runs=%d glyphs=%d shadows=%d", n, len(out), cap(out), runs, glyphs, shadows)134+ t.Fatalf("counts %+v vs commands=%d runs=%d glyphs=%d shadows=%d", n, len(out), runs, glyphs, shadows)
135+ }
136+ // An empty arena sizes each block once from the counts: one allocation
137+ // each for commands, shadows, runs and glyphs, with no growth.
138+ if raceEnabled {
139+ return
140+ }
141+ if got := testing.AllocsPerRun(10, func() {
142+ *ctx.arena = Arena{}
143+ ctx.display(root, tree)
144+ }); got != 4 {
145+ t.Fatalf("fresh display allocs=%v, want 4", got)
134 }146 }
135 }147 }
136 148
137 func TestPaintAllocations(t *testing.T) {149 func TestPaintAllocations(t *testing.T) {
138 root, opts := paintFixture(t, 50)150 root, opts := paintFixture(t, 50)
139- ctx := context{options: opts}151+ ctx := newContext(opts)
140 tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0)152 tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0)
141 if err != nil {153 if err != nil {
142 t.Fatal(err)154 t.Fatal(err)
143 }155 }
144- // One block each for commands, shadows, runs and glyphs, whatever the tree size.156+ // The arena's blocks are reused, so a repeated paint allocates nothing.
145- if n := testing.AllocsPerRun(20, func() { ctx.display(root, tree) }); n > 4 {157+ if n := testing.AllocsPerRun(20, func() { ctx.display(root, tree) }); n != 0 {
146- t.Fatalf("paint allocs=%v, want <= 4", n)158+ t.Fatalf("paint allocs=%v, want 0", n)
159+ }
160+}
161+
162+// TestLayoutArenaReuse checks that a steady layout into a reused arena
163+// allocates nothing, and that alternating two arenas keeps the previous
164+// Output intact while the next one is built.
165+func TestLayoutArenaReuse(t *testing.T) {
166+ root, opts := paintFixture(t, 20)
167+ var arenas [2]Arena
168+ layout := func(i int) Output {
169+ o := opts
170+ o.Arena = &arenas[i]
171+ out, err := Layout(root, o)
172+ if err != nil {
173+ t.Fatal(err)
174+ }
175+ return out
176+ }
177+ prev := layout(0)
178+ layout(1)
179+ wantID, wantX := prev.Tree.Children[3].ID, prev.Display[5].Rect.X
180+ root.Children[3].ID = "changed"
181+ layout(1)
182+ if prev.Tree.Children[3].ID != wantID || prev.Display[5].Rect.X != wantX {
183+ t.Fatal("building into one arena changed the Output of the other")
184+ }
185+ if n := testing.AllocsPerRun(20, func() { layout(1) }); n != 0 {
186+ t.Fatalf("steady layout allocs=%v, want 0", n)
187+ }
188+ // A list the caller grew with many decorations and handed back through
189+ // Keep is recycled, not dropped as oversized for the layout alone.
190+ decorations := make([]Command, 1000) // far beyond retainSlack times the layout's own
191+ decorate := func() {
192+ out := layout(1)
193+ arenas[1].Keep(slices.Insert(out.Display, 0, decorations...))
194+ }
195+ decorate()
196+ if n := testing.AllocsPerRun(20, decorate); n != 0 {
197+ t.Fatalf("decorated layout allocs=%v, want 0", n)
198+ }
199+ // The runtime may lay out twice before one Keep (caret scrolling).
200+ if n := testing.AllocsPerRun(20, func() { layout(1); decorate() }); n != 0 {
201+ t.Fatalf("relayout before keep allocs=%v, want 0", n)
202+ }
203+ // Once decorations go away, the large list is released.
204+ decorated := cap(arenas[1].commands)
205+ arenas[1].Keep(layout(1).Display)
206+ if layout(1); cap(arenas[1].commands) >= decorated {
207+ t.Fatalf("decorated list kept after decorations went away: cap %d", cap(arenas[1].commands))
208+ }
209+ // Growth falls back to individual allocations once, then fits.
210+ for range 30 {
211+ root.Children = append(root.Children, root.Children[0])
212+ }
213+ layout(1)
214+ if n := testing.AllocsPerRun(20, func() { layout(1) }); n != 0 {
215+ t.Fatalf("allocs after growth=%v, want 0", n)
147 }216 }
148 }217 }
149 218
A internal/layout/race_off_test.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build !race
2+
3+package layout
4+
5+const raceEnabled = false
@@ -0,0 +1,5 @@
1+//go:build !race
2+
3+package layout
4+
5+const raceEnabled = false
A internal/layout/race_on_test.go +6 -0
@@ -0,0 +1,6 @@
1+//go:build race
2+
3+package layout
4+
5+// raceEnabled: the race detector adds allocations, so exact alloc counts skip.
6+const raceEnabled = true
@@ -0,0 +1,6 @@
1+//go:build race
2+
3+package layout
4+
5+// raceEnabled: the race detector adds allocations, so exact alloc counts skip.
6+const raceEnabled = true
M internal/presentation/session/target.go +3 -1
@@ -78,6 +78,7 @@ type Target struct {
7878 Slots map[uint64]*Slot
7979
8080 list render.ListBuffer
81+ quads []render.Quad // reused by submit; the instance upload copies them
8182 pipeline *vkdevice.Pipeline
8283 pipelineGen uint64 // pool generation the current pipeline belongs to
8384 retiredPipelines []retiredPipelines
@@ -306,10 +307,11 @@ func (t *Target) Draw(display []layout.Command, scale float64, out *Output) (boo
306307 func (t *Target) Waiting() bool { return t.waiting }
307308
308309 func (t *Target) submit(b *buffers.Buffer, slot *Slot, display []layout.Command, scale float64, out *Output) (bool, error) {
309- frame, err := t.Preparer.Prepare(display, scale, int(t.width), int(t.height))
310+ frame, err := t.Preparer.PrepareInto(t.quads, display, scale, int(t.width), int(t.height))
310311 if err != nil {
311312 return false, err
312313 }
314+ t.quads = frame.Quads
313315 defer t.list.Release()
314316 instances, batches, stats := t.list.List(frame)
315317 if len(stats.Skipped) != 0 {
@@ -78,6 +78,7 @@ type Target struct {
78 Slots map[uint64]*Slot78 Slots map[uint64]*Slot
79 79
80 list render.ListBuffer80 list render.ListBuffer
81+ quads []render.Quad // reused by submit; the instance upload copies them
81 pipeline *vkdevice.Pipeline82 pipeline *vkdevice.Pipeline
82 pipelineGen uint64 // pool generation the current pipeline belongs to83 pipelineGen uint64 // pool generation the current pipeline belongs to
83 retiredPipelines []retiredPipelines84 retiredPipelines []retiredPipelines
@@ -306,10 +307,11 @@ func (t *Target) Draw(display []layout.Command, scale float64, out *Output) (boo
306 func (t *Target) Waiting() bool { return t.waiting }307 func (t *Target) Waiting() bool { return t.waiting }
307 308
308 func (t *Target) submit(b *buffers.Buffer, slot *Slot, display []layout.Command, scale float64, out *Output) (bool, error) {309 func (t *Target) submit(b *buffers.Buffer, slot *Slot, display []layout.Command, scale float64, out *Output) (bool, error) {
309- frame, err := t.Preparer.Prepare(display, scale, int(t.width), int(t.height))310+ frame, err := t.Preparer.PrepareInto(t.quads, display, scale, int(t.width), int(t.height))
310 if err != nil {311 if err != nil {
311 return false, err312 return false, err
312 }313 }
314+ t.quads = frame.Quads
313 defer t.list.Release()315 defer t.list.Release()
314 instances, batches, stats := t.list.List(frame)316 instances, batches, stats := t.list.List(frame)
315 if len(stats.Skipped) != 0 {317 if len(stats.Skipped) != 0 {
M internal/render/instances.go +2 -1
@@ -42,7 +42,8 @@ type ListBuffer struct {
4242 }
4343
4444 // bufferSlack is how much larger than needed retained storage may be before
45-// List drops it, so one huge frame does not pin memory forever.
45+// List drops it, so one huge frame does not pin memory forever. It matches
46+// layout's retainSlack.
4647 const bufferSlack = 4
4748
4849 // List is ListBatches reusing this buffer's storage where capacity allows.
@@ -42,7 +42,8 @@ type ListBuffer struct {
42 }42 }
43 43
44 // bufferSlack is how much larger than needed retained storage may be before44 // bufferSlack is how much larger than needed retained storage may be before
45-// List drops it, so one huge frame does not pin memory forever.45+// List drops it, so one huge frame does not pin memory forever. It matches
46+// layout's retainSlack.
46 const bufferSlack = 447 const bufferSlack = 4
47 48
48 // List is ListBatches reusing this buffer's storage where capacity allows.49 // List is ListBatches reusing this buffer's storage where capacity allows.
M internal/render/prepare.go +20 -7
@@ -6,6 +6,7 @@ import (
66 "fmt"
77 "image"
88 "math"
9+ "slices"
910
1011 "github.com/bnema/nefergui/internal/css"
1112 "github.com/bnema/nefergui/internal/layout"
@@ -103,19 +104,31 @@ func quadCapacity(commands []layout.Command) int {
103104 // Image commands without a source are rejected. Failed frames do not produce
104105 // partial output.
105106 func (p *Preparer) Prepare(commands []layout.Command, scale float64, width, height int) (Frame, error) {
107+ // Frames are returned detached and may outlive later Prepare calls, so
108+ // the backing array is never reused.
109+ return p.PrepareInto(nil, commands, scale, width, height)
110+}
111+
112+// PrepareInto is Prepare reusing quads' backing array for the returned frame's
113+// Quads. The frame aliases quads, so it is valid only until quads is reused:
114+// callers that keep frames must use Prepare. Pass the last frame's Quads
115+// unchanged: its length tells how much of a past peak buffer is still needed.
116+func (p *Preparer) PrepareInto(quads []Quad, commands []layout.Command, scale float64, width, height int) (Frame, error) {
106117 if p == nil || p.Atlas == nil || scale <= 0 || math.IsNaN(scale) || math.IsInf(scale, 0) || width <= 0 || height <= 0 {
107118 return Frame{}, fmt.Errorf("render: invalid frame dimensions, scale, or atlas")
108119 }
109120 p.Atlas.BeginFrame()
110- frame := Frame{}
111- if n := quadCapacity(commands); n > 0 {
112- // Frames are returned detached and may outlive later Prepare calls, so
113- // the backing array is never reused. Bound the initial reservation;
114- // larger visible frames grow through append after culling.
115- frame.Quads = make([]Quad, 0, n)
121+ // Bound the initial reservation; larger visible frames grow through
122+ // append after culling.
123+ n := quadCapacity(commands)
124+ if cap(quads) > bufferSlack*max(n, len(quads))+64 {
125+ quads = nil // do not pin a past peak frame's buffer
116126 }
127+ clear(quads[:cap(quads)]) // drop image and glyph references from the last frame
128+ frame := Frame{Quads: slices.Grow(quads[:0], n)}
117129 clip := layout.Rect{W: float64(width), H: float64(height)}
118- stack := []layout.Rect{}
130+ var stackBuf [8]layout.Rect // nested clips beyond this depth grow on the heap
131+ stack := stackBuf[:0]
119132 for _, cmd := range commands {
120133 switch cmd.Op {
121134 case "clip-push":
@@ -6,6 +6,7 @@ import (
6 "fmt"6 "fmt"
7 "image"7 "image"
8 "math"8 "math"
9+ "slices"
9 10
10 "github.com/bnema/nefergui/internal/css"11 "github.com/bnema/nefergui/internal/css"
11 "github.com/bnema/nefergui/internal/layout"12 "github.com/bnema/nefergui/internal/layout"
@@ -103,19 +104,31 @@ func quadCapacity(commands []layout.Command) int {
103 // Image commands without a source are rejected. Failed frames do not produce104 // Image commands without a source are rejected. Failed frames do not produce
104 // partial output.105 // partial output.
105 func (p *Preparer) Prepare(commands []layout.Command, scale float64, width, height int) (Frame, error) {106 func (p *Preparer) Prepare(commands []layout.Command, scale float64, width, height int) (Frame, error) {
107+ // Frames are returned detached and may outlive later Prepare calls, so
108+ // the backing array is never reused.
109+ return p.PrepareInto(nil, commands, scale, width, height)
110+}
111+
112+// PrepareInto is Prepare reusing quads' backing array for the returned frame's
113+// Quads. The frame aliases quads, so it is valid only until quads is reused:
114+// callers that keep frames must use Prepare. Pass the last frame's Quads
115+// unchanged: its length tells how much of a past peak buffer is still needed.
116+func (p *Preparer) PrepareInto(quads []Quad, commands []layout.Command, scale float64, width, height int) (Frame, error) {
106 if p == nil || p.Atlas == nil || scale <= 0 || math.IsNaN(scale) || math.IsInf(scale, 0) || width <= 0 || height <= 0 {117 if p == nil || p.Atlas == nil || scale <= 0 || math.IsNaN(scale) || math.IsInf(scale, 0) || width <= 0 || height <= 0 {
107 return Frame{}, fmt.Errorf("render: invalid frame dimensions, scale, or atlas")118 return Frame{}, fmt.Errorf("render: invalid frame dimensions, scale, or atlas")
108 }119 }
109 p.Atlas.BeginFrame()120 p.Atlas.BeginFrame()
110- frame := Frame{}121+ // Bound the initial reservation; larger visible frames grow through
111- if n := quadCapacity(commands); n > 0 {122+ // append after culling.
112- // Frames are returned detached and may outlive later Prepare calls, so123+ n := quadCapacity(commands)
113- // the backing array is never reused. Bound the initial reservation;124+ if cap(quads) > bufferSlack*max(n, len(quads))+64 {
114- // larger visible frames grow through append after culling.125+ quads = nil // do not pin a past peak frame's buffer
115- frame.Quads = make([]Quad, 0, n)
116 }126 }
127+ clear(quads[:cap(quads)]) // drop image and glyph references from the last frame
128+ frame := Frame{Quads: slices.Grow(quads[:0], n)}
117 clip := layout.Rect{W: float64(width), H: float64(height)}129 clip := layout.Rect{W: float64(width), H: float64(height)}
118- stack := []layout.Rect{}130+ var stackBuf [8]layout.Rect // nested clips beyond this depth grow on the heap
131+ stack := stackBuf[:0]
119 for _, cmd := range commands {132 for _, cmd := range commands {
120 switch cmd.Op {133 switch cmd.Op {
121 case "clip-push":134 case "clip-push":
M internal/render/prepare_alloc_test.go +47 -0
@@ -124,6 +124,53 @@ func TestPrepareQuadAllocationsDoNotScaleWithCommands(t *testing.T) {
124124 }
125125 }
126126
127+// PrepareInto reuses the caller's quads, so a steady frame allocates nothing,
128+// and stale references from a longer frame are cleared.
129+func TestPrepareIntoReusesQuads(t *testing.T) {
130+ p, err := NewPreparer(256, 2)
131+ if err != nil {
132+ t.Fatal(err)
133+ }
134+ long, err := p.PrepareInto(nil, rectCommands(8), 1, 100, 1000)
135+ if err != nil {
136+ t.Fatal(err)
137+ }
138+ quads := long.Quads
139+ short, err := p.PrepareInto(quads, rectCommands(3), 1, 100, 1000)
140+ if err != nil || len(short.Quads) != 3 || &short.Quads[0] != &quads[0] {
141+ t.Fatalf("not reused: %v %d", err, len(short.Quads))
142+ }
143+ if tail := quads[3:8]; tail[0] != (Quad{}) || tail[4] != (Quad{}) {
144+ t.Fatal("stale quads kept past the new length")
145+ }
146+ cmds := rectCommands(64)
147+ if allocs := testing.AllocsPerRun(20, func() {
148+ f, err := p.PrepareInto(quads, cmds, 1, 100, 1000)
149+ if err != nil {
150+ t.Fatal(err)
151+ }
152+ quads = f.Quads
153+ }); allocs != 0 {
154+ t.Fatalf("PrepareInto allocs=%v, want 0", allocs)
155+ }
156+ // Mostly culled: the reservation stays large while few quads are visible.
157+ culled := rectCommands(400)
158+ if allocs := testing.AllocsPerRun(20, func() {
159+ f, err := p.PrepareInto(quads, culled, 1, 100, 10)
160+ if err != nil || len(f.Quads) > 10 {
161+ t.Fatalf("culled frame: %v %d", err, len(f.Quads))
162+ }
163+ quads = f.Quads
164+ }); allocs != 0 {
165+ t.Fatalf("culled PrepareInto allocs=%v, want 0", allocs)
166+ }
167+ // A much smaller frame drops the past peak buffer.
168+ f, err := p.PrepareInto(quads, rectCommands(2), 1, 100, 10)
169+ if err != nil || cap(f.Quads) >= cap(quads) {
170+ t.Fatalf("peak buffer kept: %v cap %d, was %d", err, cap(f.Quads), cap(quads))
171+ }
172+}
173+
127174 func BenchmarkPrepareBoxes(b *testing.B) {
128175 p, err := NewPreparer(256, 2)
129176 if err != nil {
@@ -124,6 +124,53 @@ func TestPrepareQuadAllocationsDoNotScaleWithCommands(t *testing.T) {
124 }124 }
125 }125 }
126 126
127+// PrepareInto reuses the caller's quads, so a steady frame allocates nothing,
128+// and stale references from a longer frame are cleared.
129+func TestPrepareIntoReusesQuads(t *testing.T) {
130+ p, err := NewPreparer(256, 2)
131+ if err != nil {
132+ t.Fatal(err)
133+ }
134+ long, err := p.PrepareInto(nil, rectCommands(8), 1, 100, 1000)
135+ if err != nil {
136+ t.Fatal(err)
137+ }
138+ quads := long.Quads
139+ short, err := p.PrepareInto(quads, rectCommands(3), 1, 100, 1000)
140+ if err != nil || len(short.Quads) != 3 || &short.Quads[0] != &quads[0] {
141+ t.Fatalf("not reused: %v %d", err, len(short.Quads))
142+ }
143+ if tail := quads[3:8]; tail[0] != (Quad{}) || tail[4] != (Quad{}) {
144+ t.Fatal("stale quads kept past the new length")
145+ }
146+ cmds := rectCommands(64)
147+ if allocs := testing.AllocsPerRun(20, func() {
148+ f, err := p.PrepareInto(quads, cmds, 1, 100, 1000)
149+ if err != nil {
150+ t.Fatal(err)
151+ }
152+ quads = f.Quads
153+ }); allocs != 0 {
154+ t.Fatalf("PrepareInto allocs=%v, want 0", allocs)
155+ }
156+ // Mostly culled: the reservation stays large while few quads are visible.
157+ culled := rectCommands(400)
158+ if allocs := testing.AllocsPerRun(20, func() {
159+ f, err := p.PrepareInto(quads, culled, 1, 100, 10)
160+ if err != nil || len(f.Quads) > 10 {
161+ t.Fatalf("culled frame: %v %d", err, len(f.Quads))
162+ }
163+ quads = f.Quads
164+ }); allocs != 0 {
165+ t.Fatalf("culled PrepareInto allocs=%v, want 0", allocs)
166+ }
167+ // A much smaller frame drops the past peak buffer.
168+ f, err := p.PrepareInto(quads, rectCommands(2), 1, 100, 10)
169+ if err != nil || cap(f.Quads) >= cap(quads) {
170+ t.Fatalf("peak buffer kept: %v cap %d, was %d", err, cap(f.Quads), cap(quads))
171+ }
172+}
173+
127 func BenchmarkPrepareBoxes(b *testing.B) {174 func BenchmarkPrepareBoxes(b *testing.B) {
128 p, err := NewPreparer(256, 2)175 p, err := NewPreparer(256, 2)
129 if err != nil {176 if err != nil {
M internal/text/atlas.go +22 -1
@@ -40,8 +40,13 @@ type Atlas struct {
4040 entries map[Key]atlasEntry
4141 frame uint64
4242 dirty map[int]image.Rectangle
43+ empty int // cached empty-glyph entries, bounded by maxEmptyGlyphs
4344 }
4445
46+// maxEmptyGlyphs bounds cached empty glyphs (spaces), which own no page and so
47+// are never dropped by page eviction.
48+const maxEmptyGlyphs = 1024
49+
4550 func NewAtlas(size, maxPages int) (*Atlas, error) {
4651 if size < 4 || size > 4096 || maxPages < 1 || maxPages > 64 {
4752 return nil, errors.New("invalid atlas bounds")
@@ -94,6 +99,9 @@ func (a *Atlas) Lookup(k Key) (Placement, bool) {
9499 if !ok {
95100 return Placement{}, false
96101 }
102+ if e.placement.Page < 0 {
103+ return e.placement, true // empty glyph: no page to keep alive
104+ }
97105 e.generation = a.frame
98106 a.entries[k] = e
99107 a.pages[e.placement.Page].generation = a.frame
@@ -111,7 +119,20 @@ func (a *Atlas) Insert(k Key, m Mask) (Placement, error) {
111119 return Placement{}, errors.New("glyph too large")
112120 }
113121 if w == 0 || h == 0 {
114- return Placement{Page: -1, Origin: m.Origin}, nil
122+ // Cache empty glyphs (spaces) too, so they are not rasterized every
123+ // frame. They own no page, so a full set is dropped instead of evicted.
124+ if a.empty >= maxEmptyGlyphs {
125+ for key, e := range a.entries {
126+ if e.placement.Page < 0 {
127+ delete(a.entries, key)
128+ }
129+ }
130+ a.empty = 0
131+ }
132+ place := Placement{Page: -1, Origin: m.Origin}
133+ a.entries[k] = atlasEntry{placement: place}
134+ a.empty++
135+ return place, nil
115136 }
116137 chosen := -1
117138 var x, y int
@@ -40,8 +40,13 @@ type Atlas struct {
40 entries map[Key]atlasEntry40 entries map[Key]atlasEntry
41 frame uint6441 frame uint64
42 dirty map[int]image.Rectangle42 dirty map[int]image.Rectangle
43+ empty int // cached empty-glyph entries, bounded by maxEmptyGlyphs
43 }44 }
44 45
46+// maxEmptyGlyphs bounds cached empty glyphs (spaces), which own no page and so
47+// are never dropped by page eviction.
48+const maxEmptyGlyphs = 1024
49+
45 func NewAtlas(size, maxPages int) (*Atlas, error) {50 func NewAtlas(size, maxPages int) (*Atlas, error) {
46 if size < 4 || size > 4096 || maxPages < 1 || maxPages > 64 {51 if size < 4 || size > 4096 || maxPages < 1 || maxPages > 64 {
47 return nil, errors.New("invalid atlas bounds")52 return nil, errors.New("invalid atlas bounds")
@@ -94,6 +99,9 @@ func (a *Atlas) Lookup(k Key) (Placement, bool) {
94 if !ok {99 if !ok {
95 return Placement{}, false100 return Placement{}, false
96 }101 }
102+ if e.placement.Page < 0 {
103+ return e.placement, true // empty glyph: no page to keep alive
104+ }
97 e.generation = a.frame105 e.generation = a.frame
98 a.entries[k] = e106 a.entries[k] = e
99 a.pages[e.placement.Page].generation = a.frame107 a.pages[e.placement.Page].generation = a.frame
@@ -111,7 +119,20 @@ func (a *Atlas) Insert(k Key, m Mask) (Placement, error) {
111 return Placement{}, errors.New("glyph too large")119 return Placement{}, errors.New("glyph too large")
112 }120 }
113 if w == 0 || h == 0 {121 if w == 0 || h == 0 {
114- return Placement{Page: -1, Origin: m.Origin}, nil122+ // Cache empty glyphs (spaces) too, so they are not rasterized every
123+ // frame. They own no page, so a full set is dropped instead of evicted.
124+ if a.empty >= maxEmptyGlyphs {
125+ for key, e := range a.entries {
126+ if e.placement.Page < 0 {
127+ delete(a.entries, key)
128+ }
129+ }
130+ a.empty = 0
131+ }
132+ place := Placement{Page: -1, Origin: m.Origin}
133+ a.entries[k] = atlasEntry{placement: place}
134+ a.empty++
135+ return place, nil
115 }136 }
116 chosen := -1137 chosen := -1
117 var x, y int138 var x, y int
M internal/text/atlas_dirty_test.go +38 -0
@@ -4,6 +4,8 @@ import (
44 "bytes"
55 "image"
66 "testing"
7+
8+ "github.com/go-text/typesetting/font"
79 )
810
911 func TestMarkAllDirty(t *testing.T) {
@@ -31,3 +33,39 @@ func TestMarkAllDirty(t *testing.T) {
3133 t.Fatalf("not drained: %v", got)
3234 }
3335 }
36+
37+// TestAtlasCachesEmptyGlyphs keeps spaces out of the per-frame raster path and
38+// bounds their entries, which no page eviction removes.
39+func TestAtlasCachesEmptyGlyphs(t *testing.T) {
40+ a, err := NewAtlas(16, 1)
41+ if err != nil {
42+ t.Fatal(err)
43+ }
44+ space := Mask{Alpha: image.NewAlpha(image.Rect(0, 0, 0, 0)), Origin: image.Pt(0, -3)}
45+ if p, err := a.Insert(Key{Glyph: 3}, space); err != nil || p.Page != -1 {
46+ t.Fatalf("insert: %+v %v", p, err)
47+ }
48+ a.BeginFrame()
49+ if p, ok := a.Lookup(Key{Glyph: 3}); !ok || p.Page != -1 || p.Origin != space.Origin {
50+ t.Fatalf("lookup: %+v %v", p, ok)
51+ }
52+ // Evicting the only page for a new glyph must keep the empty entry.
53+ ink := Mask{Alpha: image.NewAlpha(image.Rect(0, 0, 10, 10))}
54+ for g := range 3 {
55+ a.BeginFrame()
56+ if _, err := a.Insert(Key{Glyph: font.GID(10 + g)}, ink); err != nil {
57+ t.Fatal(err)
58+ }
59+ }
60+ if _, ok := a.Lookup(Key{Glyph: 3}); !ok {
61+ t.Fatal("page eviction dropped an empty glyph")
62+ }
63+ for g := range maxEmptyGlyphs + 10 {
64+ if _, err := a.Insert(Key{Glyph: font.GID(100 + g)}, space); err != nil {
65+ t.Fatal(err)
66+ }
67+ }
68+ if a.empty > maxEmptyGlyphs || len(a.entries) > maxEmptyGlyphs+1 {
69+ t.Fatalf("empty glyphs unbounded: %d counted, %d entries", a.empty, len(a.entries))
70+ }
71+}
@@ -4,6 +4,8 @@ import (
4 "bytes"4 "bytes"
5 "image"5 "image"
6 "testing"6 "testing"
7+
8+ "github.com/go-text/typesetting/font"
7 )9 )
8 10
9 func TestMarkAllDirty(t *testing.T) {11 func TestMarkAllDirty(t *testing.T) {
@@ -31,3 +33,39 @@ func TestMarkAllDirty(t *testing.T) {
31 t.Fatalf("not drained: %v", got)33 t.Fatalf("not drained: %v", got)
32 }34 }
33 }35 }
36+
37+// TestAtlasCachesEmptyGlyphs keeps spaces out of the per-frame raster path and
38+// bounds their entries, which no page eviction removes.
39+func TestAtlasCachesEmptyGlyphs(t *testing.T) {
40+ a, err := NewAtlas(16, 1)
41+ if err != nil {
42+ t.Fatal(err)
43+ }
44+ space := Mask{Alpha: image.NewAlpha(image.Rect(0, 0, 0, 0)), Origin: image.Pt(0, -3)}
45+ if p, err := a.Insert(Key{Glyph: 3}, space); err != nil || p.Page != -1 {
46+ t.Fatalf("insert: %+v %v", p, err)
47+ }
48+ a.BeginFrame()
49+ if p, ok := a.Lookup(Key{Glyph: 3}); !ok || p.Page != -1 || p.Origin != space.Origin {
50+ t.Fatalf("lookup: %+v %v", p, ok)
51+ }
52+ // Evicting the only page for a new glyph must keep the empty entry.
53+ ink := Mask{Alpha: image.NewAlpha(image.Rect(0, 0, 10, 10))}
54+ for g := range 3 {
55+ a.BeginFrame()
56+ if _, err := a.Insert(Key{Glyph: font.GID(10 + g)}, ink); err != nil {
57+ t.Fatal(err)
58+ }
59+ }
60+ if _, ok := a.Lookup(Key{Glyph: 3}); !ok {
61+ t.Fatal("page eviction dropped an empty glyph")
62+ }
63+ for g := range maxEmptyGlyphs + 10 {
64+ if _, err := a.Insert(Key{Glyph: font.GID(100 + g)}, space); err != nil {
65+ t.Fatal(err)
66+ }
67+ }
68+ if a.empty > maxEmptyGlyphs || len(a.entries) > maxEmptyGlyphs+1 {
69+ t.Fatalf("empty glyphs unbounded: %d counted, %d entries", a.empty, len(a.entries))
70+ }
71+}
M internal/text/measure_cache.go +15 -1
@@ -37,6 +37,7 @@ type measureEntry struct {
3737 type measureCache struct {
3838 entries map[measureKey]*measureEntry
3939 frame uint64
40+ free []*measureEntry // evicted entries, reused by put
4041 }
4142
4243 func requestKey(s string, r Request, width float64) measureKey {
@@ -72,13 +73,26 @@ func (c *measureCache) put(s string, r Request, width float64, l Layout) {
7273 if len(c.entries) >= maxMeasureEntries {
7374 return
7475 }
75- c.entries[requestKey(s, r, width)] = &measureEntry{families: slices.Clone(r.Families), variations: slices.Clone(r.Variations), layout: l, used: c.frame}
76+ var e *measureEntry
77+ if n := len(c.free); n > 0 {
78+ e, c.free = c.free[n-1], c.free[:n-1]
79+ } else {
80+ e = new(measureEntry)
81+ }
82+ // Reuse the evicted entry's slices; Clone of an empty request stays nil.
83+ e.families = append(e.families[:0], r.Families...)
84+ e.variations = append(e.variations[:0], r.Variations...)
85+ e.layout, e.used = l, c.frame
86+ c.entries[requestKey(s, r, width)] = e
7687 }
7788
7889 func (c *measureCache) endFrame() {
7990 for k, e := range c.entries {
8091 if c.frame-e.used >= 2 {
8192 delete(c.entries, k)
93+ // Callers may still hold e.layout; only the entry is recycled.
94+ e.layout = Layout{}
95+ c.free = append(c.free, e)
8296 }
8397 }
8498 c.frame++
@@ -37,6 +37,7 @@ type measureEntry struct {
37 type measureCache struct {37 type measureCache struct {
38 entries map[measureKey]*measureEntry38 entries map[measureKey]*measureEntry
39 frame uint6439 frame uint64
40+ free []*measureEntry // evicted entries, reused by put
40 }41 }
41 42
42 func requestKey(s string, r Request, width float64) measureKey {43 func requestKey(s string, r Request, width float64) measureKey {
@@ -72,13 +73,26 @@ func (c *measureCache) put(s string, r Request, width float64, l Layout) {
72 if len(c.entries) >= maxMeasureEntries {73 if len(c.entries) >= maxMeasureEntries {
73 return74 return
74 }75 }
75- c.entries[requestKey(s, r, width)] = &measureEntry{families: slices.Clone(r.Families), variations: slices.Clone(r.Variations), layout: l, used: c.frame}76+ var e *measureEntry
77+ if n := len(c.free); n > 0 {
78+ e, c.free = c.free[n-1], c.free[:n-1]
79+ } else {
80+ e = new(measureEntry)
81+ }
82+ // Reuse the evicted entry's slices; Clone of an empty request stays nil.
83+ e.families = append(e.families[:0], r.Families...)
84+ e.variations = append(e.variations[:0], r.Variations...)
85+ e.layout, e.used = l, c.frame
86+ c.entries[requestKey(s, r, width)] = e
76 }87 }
77 88
78 func (c *measureCache) endFrame() {89 func (c *measureCache) endFrame() {
79 for k, e := range c.entries {90 for k, e := range c.entries {
80 if c.frame-e.used >= 2 {91 if c.frame-e.used >= 2 {
81 delete(c.entries, k)92 delete(c.entries, k)
93+ // Callers may still hold e.layout; only the entry is recycled.
94+ e.layout = Layout{}
95+ c.free = append(c.free, e)
82 }96 }
83 }97 }
84 c.frame++98 c.frame++
M internal/text/measure_cache_test.go +20 -0
@@ -3,6 +3,9 @@ package text
33 import (
44 "fmt"
55 "testing"
6+
7+ "github.com/go-text/typesetting/font"
8+ ot "github.com/go-text/typesetting/font/opentype"
69 )
710
811 func TestMeasureCacheHitsKeysAndEviction(t *testing.T) {
@@ -53,6 +56,23 @@ func TestMeasureCacheHitsKeysAndEviction(t *testing.T) {
5356 if n := len(e.cache.entries); n != 1 {
5457 t.Fatalf("after eviction entries=%d, want 1", n)
5558 }
59+ // Evicted entries are recycled with a fresh family list and layout.
60+ if n := len(e.cache.free); n != 5 {
61+ t.Fatalf("free=%d, want 5", n)
62+ }
63+ serif := Request{Families: []string{"serif", "sans"}, Size: 16, Variations: []font.Variation{{Tag: ot.MustNewTag("wght"), Value: 500}}}
64+ l, err := e.Measure("recycled", serif, 0)
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ if n := len(e.cache.free); n != 4 {
69+ t.Fatalf("put did not take a recycled entry: free=%d, want 4", n)
70+ }
71+ // The hit verifies the entry's families and variations were replaced, not
72+ // kept stale.
73+ if hit, ok := e.cache.get("recycled", serif, 0); !ok || hit.Width != l.Width {
74+ t.Fatalf("recycled entry missed: %+v %v", hit, ok)
75+ }
5676 }
5777
5878 func TestMeasureCacheBoundAndCloneIsolation(t *testing.T) {
@@ -3,6 +3,9 @@ package text
3 import (3 import (
4 "fmt"4 "fmt"
5 "testing"5 "testing"
6+
7+ "github.com/go-text/typesetting/font"
8+ ot "github.com/go-text/typesetting/font/opentype"
6 )9 )
7 10
8 func TestMeasureCacheHitsKeysAndEviction(t *testing.T) {11 func TestMeasureCacheHitsKeysAndEviction(t *testing.T) {
@@ -53,6 +56,23 @@ func TestMeasureCacheHitsKeysAndEviction(t *testing.T) {
53 if n := len(e.cache.entries); n != 1 {56 if n := len(e.cache.entries); n != 1 {
54 t.Fatalf("after eviction entries=%d, want 1", n)57 t.Fatalf("after eviction entries=%d, want 1", n)
55 }58 }
59+ // Evicted entries are recycled with a fresh family list and layout.
60+ if n := len(e.cache.free); n != 5 {
61+ t.Fatalf("free=%d, want 5", n)
62+ }
63+ serif := Request{Families: []string{"serif", "sans"}, Size: 16, Variations: []font.Variation{{Tag: ot.MustNewTag("wght"), Value: 500}}}
64+ l, err := e.Measure("recycled", serif, 0)
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ if n := len(e.cache.free); n != 4 {
69+ t.Fatalf("put did not take a recycled entry: free=%d, want 4", n)
70+ }
71+ // The hit verifies the entry's families and variations were replaced, not
72+ // kept stale.
73+ if hit, ok := e.cache.get("recycled", serif, 0); !ok || hit.Width != l.Width {
74+ t.Fatalf("recycled entry missed: %+v %v", hit, ok)
75+ }
56 }76 }
57 77
58 func TestMeasureCacheBoundAndCloneIsolation(t *testing.T) {78 func TestMeasureCacheBoundAndCloneIsolation(t *testing.T) {
M internal/text/paragraph.go +0 -6
@@ -77,12 +77,6 @@ func (e *Engine) measureParagraphs(text []rune, paragraphs []paragraphRange, r R
7777 }
7878 }
7979 result.Height += float64(len(part.Lines)) * part.LineHeight
80- if part.MinContent > result.MinContent {
81- result.MinContent = part.MinContent
82- }
83- if part.MaxContent > result.MaxContent {
84- result.MaxContent = part.MaxContent
85- }
8680 }
8781 return result, nil
8882 }
@@ -77,12 +77,6 @@ func (e *Engine) measureParagraphs(text []rune, paragraphs []paragraphRange, r R
77 }77 }
78 }78 }
79 result.Height += float64(len(part.Lines)) * part.LineHeight79 result.Height += float64(len(part.Lines)) * part.LineHeight
80- if part.MinContent > result.MinContent {
81- result.MinContent = part.MinContent
82- }
83- if part.MaxContent > result.MaxContent {
84- result.MaxContent = part.MaxContent
85- }
86 }80 }
87 return result, nil81 return result, nil
88 }82 }
M internal/text/review_test.go +2 -1
@@ -8,6 +8,7 @@ import (
88
99 "github.com/go-text/typesetting/font"
1010 ot "github.com/go-text/typesetting/font/opentype"
11+ "github.com/go-text/typesetting/segmenter"
1112 )
1213
1314 func TestVariableDefaultAndDistinctKeys(t *testing.T) {
@@ -50,7 +51,7 @@ func TestClusterFallback(t *testing.T) {
5051 for _, s := range cases {
5152 t.Run(s, func(t *testing.T) {
5253 r := []rune(s)
53- faces := clusterFaces(nil, r, choices)
54+ faces := clusterFaces(new(segmenter.Segmenter), nil, r, choices)
5455 if len(faces) != len(r) {
5556 t.Fatalf("%d faces", len(faces))
5657 }
@@ -8,6 +8,7 @@ import (
8 8
9 "github.com/go-text/typesetting/font"9 "github.com/go-text/typesetting/font"
10 ot "github.com/go-text/typesetting/font/opentype"10 ot "github.com/go-text/typesetting/font/opentype"
11+ "github.com/go-text/typesetting/segmenter"
11 )12 )
12 13
13 func TestVariableDefaultAndDistinctKeys(t *testing.T) {14 func TestVariableDefaultAndDistinctKeys(t *testing.T) {
@@ -50,7 +51,7 @@ func TestClusterFallback(t *testing.T) {
50 for _, s := range cases {51 for _, s := range cases {
51 t.Run(s, func(t *testing.T) {52 t.Run(s, func(t *testing.T) {
52 r := []rune(s)53 r := []rune(s)
53- faces := clusterFaces(nil, r, choices)54+ faces := clusterFaces(new(segmenter.Segmenter), nil, r, choices)
54 if len(faces) != len(r) {55 if len(faces) != len(r) {
55 t.Fatalf("%d faces", len(faces))56 t.Fatalf("%d faces", len(faces))
56 }57 }
M internal/text/shape.go +39 -47
@@ -41,9 +41,9 @@ type Line struct {
4141 Width, Baseline, Height float64
4242 }
4343 type Layout struct {
44- Lines []Line
45- Direction di.Direction // paragraph basis, auto resolved by bidi analysis
46- Width, Height, MinContent, MaxContent, Baseline, LineHeight float64
44+ Lines []Line
45+ Direction di.Direction // paragraph basis, auto resolved by bidi analysis
46+ Width, Height, Baseline, LineHeight float64
4747 }
4848
4949 // Engine is single-goroutine only: Measure and EndFrame are not synchronized.
@@ -54,7 +54,6 @@ type Engine struct {
5454 seg shaping.Segmenter
5555 shaper shaping.HarfbuzzShaper
5656 wrapper shaping.LineWrapper
57- never shaping.LineWrapper
5857 cache measureCache
5958 scratch measureScratch
6059 }
@@ -62,15 +61,30 @@ type Engine struct {
6261 // measureScratch holds per-call buffers reused across measure calls. Nothing
6362 // in it may be referenced by a returned Layout.
6463 type measureScratch struct {
65- runes []rune
66- faces []*Face
67- reverse map[*font.Face]*Face
68- faceFor []*font.Face
69- items []shaping.Input
70- levelled []shaping.Input
71- outs []shaping.Output
72- runs shaping.RunIterator
73- visual visualScratch
64+ runes []rune
65+ faces []*Face
66+ reverse map[*font.Face]*Face
67+ faceFor []*font.Face
68+ items []shaping.Input
69+ levelled []shaping.Input
70+ outs []shaping.Output
71+ runs shaping.RunIterator
72+ graphemes segmenter.Segmenter
73+ missing []rune // lazy coverage: runes no named face covers
74+ choices []*Face // lazy coverage: faces selected for this measure
75+ levels []uint8
76+ breaks [1]int
77+ visual visualScratch
78+}
79+
80+// fullBreak returns the single paragraph-end line break (nil for empty text),
81+// backed by scratch storage.
82+func (s *measureScratch) fullBreak(text []rune) []int {
83+ if len(text) == 0 {
84+ return nil
85+ }
86+ s.breaks[0] = len(text)
87+ return s.breaks[:]
7488 }
7589
7690 // iterate returns the reusable run iterator positioned at the start of outs.
@@ -92,9 +106,9 @@ func (m constantFace) ResolveFace(r rune) *font.Face { return m.face }
92106 // clusterFaces selects a single face for every UAX#29 extended grapheme. Format
93107 // characters (ZWJ and variation selectors) do not need outline coverage; they
94108 // remain in the cluster to let HarfBuzz apply substitutions.
95-func clusterFaces(selected []*font.Face, text []rune, choices []*Face) []*font.Face {
109+// seg is reused scratch storage.
110+func clusterFaces(seg *segmenter.Segmenter, selected []*font.Face, text []rune, choices []*Face) []*font.Face {
96111 selected = slices.Grow(selected[:0], len(text))[:len(text)]
97- var seg segmenter.Segmenter
98112 seg.Init(text)
99113 it := seg.GraphemeIterator()
100114 for it.Next() {
@@ -173,11 +187,12 @@ func (e *Engine) shape(text []rune, r Request, direction di.Direction, levels []
173187 if e.catalog.source != nil {
174188 // Load named faces only as required for coverage. A failing face is
175189 // excluded permanently and ranking is recomputed to admit its runner-up.
190+ // The scratch segmenter is free here: clusterFaces re-inits it later.
176191 named := choices
177- var seg segmenter.Segmenter
192+ seg := &e.scratch.graphemes
178193 seg.Init(text)
179194 it := seg.GraphemeIterator()
180- var missing []rune
195+ missing := e.scratch.missing[:0]
181196 for it.Next() {
182197 cluster := it.Grapheme().Text
183198 covered := false
@@ -204,16 +219,16 @@ func (e *Engine) shape(text []rune, r Request, direction di.Direction, levels []
204219 }
205220 // Rebuild after failed loads. The fallback window excludes named
206221 // families by key, so it never depends on load or failure state.
222+ e.scratch.missing = missing
207223 named = e.catalog.candidates(r)
208- window := e.catalog.fallbackWindow(missing, r)
209- choices = nil
224+ choices = e.scratch.choices[:0]
210225 for _, f := range named {
211226 if f.Shape != nil && !f.bad {
212227 choices = append(choices, f)
213228 }
214229 }
215230 if len(missing) != 0 {
216- for _, f := range window {
231+ for _, f := range e.catalog.fallbackWindow(missing, r) {
217232 if !f.ready() {
218233 continue
219234 }
@@ -246,6 +261,7 @@ func (e *Engine) shape(text []rune, r Request, direction di.Direction, levels []
246261 choices = append(choices, f)
247262 }
248263 }
264+ e.scratch.choices = choices
249265 }
250266 if len(choices) == 0 {
251267 return nil, nil, errors.New("no usable fonts")
@@ -265,7 +281,7 @@ func (e *Engine) shape(text []rune, r Request, direction di.Direction, levels []
265281 sc.reverse[f.Shape] = f
266282 }
267283 sc.faces = faces
268- sc.faceFor = clusterFaces(sc.faceFor, text, faces)
284+ sc.faceFor = clusterFaces(&sc.graphemes, sc.faceFor, text, faces)
269285 input := shaping.Input{Text: text, RunEnd: len(text), Direction: direction, Size: fixed.Int26_6(math.Round(r.Size * 64))}
270286 sc.items = splitFaces(sc.items, e.seg.Split(input, constantFace{face: faces[0].Shape}), sc.faceFor)
271287 sc.levelled = splitLevels(sc.levelled, sc.items, levels)
@@ -281,7 +297,6 @@ func (e *Engine) shape(text []rune, r Request, direction di.Direction, levels []
281297 }
282298
283299 // Measure shapes, wraps (UAX#14) and positions runs. Width <= 0 means unbounded.
284-// MinContent is the largest unbreakable UAX#14 segment; max-content ignores soft breaks.
285300 // Every UAX#9 paragraph resolves its own direction; line baselines stack in logical px.
286301 //
287302 // Results are cached per (text, request, width) until EndFrame evicts them.
@@ -326,10 +341,11 @@ func (e *Engine) measure(s string, r Request, width float64) (Layout, error) {
326341 if r.Direction > bidi.RTL {
327342 return result, errors.New("invalid paragraph direction")
328343 }
329- paraLevels, baseLevel, err := bidi.Resolve(text, r.Direction, fullBreak(text))
344+ paraLevels, baseLevel, err := bidi.ResolveInto(e.scratch.levels, text, r.Direction, e.scratch.fullBreak(text))
330345 if err != nil {
331346 return result, err
332347 }
348+ e.scratch.levels = paraLevels
333349 direction := di.DirectionLTR
334350 if baseLevel%2 == 1 {
335351 direction = di.DirectionRTL
@@ -357,30 +373,6 @@ func (e *Engine) measure(s string, r Request, width float64) (Layout, error) {
357373 if len(text) == 0 {
358374 return result, nil
359375 }
360- var minWidth float64
361- // Compute exact word widths even when a shaped run crosses a break opportunity.
362- // Wrapper's Never policy at zero width supplies min-content using its native glyph-cluster mapping.
363- words, _ := e.never.WrapParagraphF(shaping.WrapConfig{Direction: direction, BreakPolicy: shaping.Never, DisableTrailingWhitespaceTrim: true}, 1, text, e.scratch.iterate(outs))
364- for _, line := range words {
365- w := 0.0
366- for _, o := range line {
367- w += float64(o.Advance) / 64
368- }
369- if w > minWidth {
370- minWidth = w
371- }
372- }
373- result.MinContent = minWidth
374- maxLines, _ := e.wrapper.WrapParagraphF(shaping.WrapConfig{Direction: direction, DisableTrailingWhitespaceTrim: true}, fixed.Int26_6(1<<30), text, e.scratch.iterate(outs))
375- for _, line := range maxLines {
376- w := 0.0
377- for _, o := range line {
378- w += float64(o.Advance) / 64
379- }
380- if w > result.MaxContent {
381- result.MaxContent = w
382- }
383- }
384376 limit := fixed.Int26_6(1 << 30)
385377 if width > 0 {
386378 limit = fixed.Int26_6(math.Round(width * 64))
@@ -41,9 +41,9 @@ type Line struct {
41 Width, Baseline, Height float6441 Width, Baseline, Height float64
42 }42 }
43 type Layout struct {43 type Layout struct {
44- Lines []Line44+ Lines []Line
45- Direction di.Direction // paragraph basis, auto resolved by bidi analysis45+ Direction di.Direction // paragraph basis, auto resolved by bidi analysis
46- Width, Height, MinContent, MaxContent, Baseline, LineHeight float6446+ Width, Height, Baseline, LineHeight float64
47 }47 }
48 48
49 // Engine is single-goroutine only: Measure and EndFrame are not synchronized.49 // Engine is single-goroutine only: Measure and EndFrame are not synchronized.
@@ -54,7 +54,6 @@ type Engine struct {
54 seg shaping.Segmenter54 seg shaping.Segmenter
55 shaper shaping.HarfbuzzShaper55 shaper shaping.HarfbuzzShaper
56 wrapper shaping.LineWrapper56 wrapper shaping.LineWrapper
57- never shaping.LineWrapper
58 cache measureCache57 cache measureCache
59 scratch measureScratch58 scratch measureScratch
60 }59 }
@@ -62,15 +61,30 @@ type Engine struct {
62 // measureScratch holds per-call buffers reused across measure calls. Nothing61 // measureScratch holds per-call buffers reused across measure calls. Nothing
63 // in it may be referenced by a returned Layout.62 // in it may be referenced by a returned Layout.
64 type measureScratch struct {63 type measureScratch struct {
65- runes []rune64+ runes []rune
66- faces []*Face65+ faces []*Face
67- reverse map[*font.Face]*Face66+ reverse map[*font.Face]*Face
68- faceFor []*font.Face67+ faceFor []*font.Face
69- items []shaping.Input68+ items []shaping.Input
70- levelled []shaping.Input69+ levelled []shaping.Input
71- outs []shaping.Output70+ outs []shaping.Output
72- runs shaping.RunIterator71+ runs shaping.RunIterator
73- visual visualScratch72+ graphemes segmenter.Segmenter
73+ missing []rune // lazy coverage: runes no named face covers
74+ choices []*Face // lazy coverage: faces selected for this measure
75+ levels []uint8
76+ breaks [1]int
77+ visual visualScratch
78+}
79+
80+// fullBreak returns the single paragraph-end line break (nil for empty text),
81+// backed by scratch storage.
82+func (s *measureScratch) fullBreak(text []rune) []int {
83+ if len(text) == 0 {
84+ return nil
85+ }
86+ s.breaks[0] = len(text)
87+ return s.breaks[:]
74 }88 }
75 89
76 // iterate returns the reusable run iterator positioned at the start of outs.90 // iterate returns the reusable run iterator positioned at the start of outs.
@@ -92,9 +106,9 @@ func (m constantFace) ResolveFace(r rune) *font.Face { return m.face }
92 // clusterFaces selects a single face for every UAX#29 extended grapheme. Format106 // clusterFaces selects a single face for every UAX#29 extended grapheme. Format
93 // characters (ZWJ and variation selectors) do not need outline coverage; they107 // characters (ZWJ and variation selectors) do not need outline coverage; they
94 // remain in the cluster to let HarfBuzz apply substitutions.108 // remain in the cluster to let HarfBuzz apply substitutions.
95-func clusterFaces(selected []*font.Face, text []rune, choices []*Face) []*font.Face {109+// seg is reused scratch storage.
110+func clusterFaces(seg *segmenter.Segmenter, selected []*font.Face, text []rune, choices []*Face) []*font.Face {
96 selected = slices.Grow(selected[:0], len(text))[:len(text)]111 selected = slices.Grow(selected[:0], len(text))[:len(text)]
97- var seg segmenter.Segmenter
98 seg.Init(text)112 seg.Init(text)
99 it := seg.GraphemeIterator()113 it := seg.GraphemeIterator()
100 for it.Next() {114 for it.Next() {
@@ -173,11 +187,12 @@ func (e *Engine) shape(text []rune, r Request, direction di.Direction, levels []
173 if e.catalog.source != nil {187 if e.catalog.source != nil {
174 // Load named faces only as required for coverage. A failing face is188 // Load named faces only as required for coverage. A failing face is
175 // excluded permanently and ranking is recomputed to admit its runner-up.189 // excluded permanently and ranking is recomputed to admit its runner-up.
190+ // The scratch segmenter is free here: clusterFaces re-inits it later.
176 named := choices191 named := choices
177- var seg segmenter.Segmenter192+ seg := &e.scratch.graphemes
178 seg.Init(text)193 seg.Init(text)
179 it := seg.GraphemeIterator()194 it := seg.GraphemeIterator()
180- var missing []rune195+ missing := e.scratch.missing[:0]
181 for it.Next() {196 for it.Next() {
182 cluster := it.Grapheme().Text197 cluster := it.Grapheme().Text
183 covered := false198 covered := false
@@ -204,16 +219,16 @@ func (e *Engine) shape(text []rune, r Request, direction di.Direction, levels []
204 }219 }
205 // Rebuild after failed loads. The fallback window excludes named220 // Rebuild after failed loads. The fallback window excludes named
206 // families by key, so it never depends on load or failure state.221 // families by key, so it never depends on load or failure state.
222+ e.scratch.missing = missing
207 named = e.catalog.candidates(r)223 named = e.catalog.candidates(r)
208- window := e.catalog.fallbackWindow(missing, r)224+ choices = e.scratch.choices[:0]
209- choices = nil
210 for _, f := range named {225 for _, f := range named {
211 if f.Shape != nil && !f.bad {226 if f.Shape != nil && !f.bad {
212 choices = append(choices, f)227 choices = append(choices, f)
213 }228 }
214 }229 }
215 if len(missing) != 0 {230 if len(missing) != 0 {
216- for _, f := range window {231+ for _, f := range e.catalog.fallbackWindow(missing, r) {
217 if !f.ready() {232 if !f.ready() {
218 continue233 continue
219 }234 }
@@ -246,6 +261,7 @@ func (e *Engine) shape(text []rune, r Request, direction di.Direction, levels []
246 choices = append(choices, f)261 choices = append(choices, f)
247 }262 }
248 }263 }
264+ e.scratch.choices = choices
249 }265 }
250 if len(choices) == 0 {266 if len(choices) == 0 {
251 return nil, nil, errors.New("no usable fonts")267 return nil, nil, errors.New("no usable fonts")
@@ -265,7 +281,7 @@ func (e *Engine) shape(text []rune, r Request, direction di.Direction, levels []
265 sc.reverse[f.Shape] = f281 sc.reverse[f.Shape] = f
266 }282 }
267 sc.faces = faces283 sc.faces = faces
268- sc.faceFor = clusterFaces(sc.faceFor, text, faces)284+ sc.faceFor = clusterFaces(&sc.graphemes, sc.faceFor, text, faces)
269 input := shaping.Input{Text: text, RunEnd: len(text), Direction: direction, Size: fixed.Int26_6(math.Round(r.Size * 64))}285 input := shaping.Input{Text: text, RunEnd: len(text), Direction: direction, Size: fixed.Int26_6(math.Round(r.Size * 64))}
270 sc.items = splitFaces(sc.items, e.seg.Split(input, constantFace{face: faces[0].Shape}), sc.faceFor)286 sc.items = splitFaces(sc.items, e.seg.Split(input, constantFace{face: faces[0].Shape}), sc.faceFor)
271 sc.levelled = splitLevels(sc.levelled, sc.items, levels)287 sc.levelled = splitLevels(sc.levelled, sc.items, levels)
@@ -281,7 +297,6 @@ func (e *Engine) shape(text []rune, r Request, direction di.Direction, levels []
281 }297 }
282 298
283 // Measure shapes, wraps (UAX#14) and positions runs. Width <= 0 means unbounded.299 // Measure shapes, wraps (UAX#14) and positions runs. Width <= 0 means unbounded.
284-// MinContent is the largest unbreakable UAX#14 segment; max-content ignores soft breaks.
285 // Every UAX#9 paragraph resolves its own direction; line baselines stack in logical px.300 // Every UAX#9 paragraph resolves its own direction; line baselines stack in logical px.
286 //301 //
287 // Results are cached per (text, request, width) until EndFrame evicts them.302 // Results are cached per (text, request, width) until EndFrame evicts them.
@@ -326,10 +341,11 @@ func (e *Engine) measure(s string, r Request, width float64) (Layout, error) {
326 if r.Direction > bidi.RTL {341 if r.Direction > bidi.RTL {
327 return result, errors.New("invalid paragraph direction")342 return result, errors.New("invalid paragraph direction")
328 }343 }
329- paraLevels, baseLevel, err := bidi.Resolve(text, r.Direction, fullBreak(text))344+ paraLevels, baseLevel, err := bidi.ResolveInto(e.scratch.levels, text, r.Direction, e.scratch.fullBreak(text))
330 if err != nil {345 if err != nil {
331 return result, err346 return result, err
332 }347 }
348+ e.scratch.levels = paraLevels
333 direction := di.DirectionLTR349 direction := di.DirectionLTR
334 if baseLevel%2 == 1 {350 if baseLevel%2 == 1 {
335 direction = di.DirectionRTL351 direction = di.DirectionRTL
@@ -357,30 +373,6 @@ func (e *Engine) measure(s string, r Request, width float64) (Layout, error) {
357 if len(text) == 0 {373 if len(text) == 0 {
358 return result, nil374 return result, nil
359 }375 }
360- var minWidth float64
361- // Compute exact word widths even when a shaped run crosses a break opportunity.
362- // Wrapper's Never policy at zero width supplies min-content using its native glyph-cluster mapping.
363- words, _ := e.never.WrapParagraphF(shaping.WrapConfig{Direction: direction, BreakPolicy: shaping.Never, DisableTrailingWhitespaceTrim: true}, 1, text, e.scratch.iterate(outs))
364- for _, line := range words {
365- w := 0.0
366- for _, o := range line {
367- w += float64(o.Advance) / 64
368- }
369- if w > minWidth {
370- minWidth = w
371- }
372- }
373- result.MinContent = minWidth
374- maxLines, _ := e.wrapper.WrapParagraphF(shaping.WrapConfig{Direction: direction, DisableTrailingWhitespaceTrim: true}, fixed.Int26_6(1<<30), text, e.scratch.iterate(outs))
375- for _, line := range maxLines {
376- w := 0.0
377- for _, o := range line {
378- w += float64(o.Advance) / 64
379- }
380- if w > result.MaxContent {
381- result.MaxContent = w
382- }
383- }
384 limit := fixed.Int26_6(1 << 30)376 limit := fixed.Int26_6(1 << 30)
385 if width > 0 {377 if width > 0 {
386 limit = fixed.Int26_6(math.Round(width * 64))378 limit = fixed.Int26_6(math.Round(width * 64))
M internal/text/text_test.go +28 -12
@@ -40,7 +40,7 @@ func TestScriptsAndMetrics(t *testing.T) {
4040 if err != nil {
4141 t.Fatal(err)
4242 }
43- if len(l.Lines) == 0 || l.Width <= 0 || l.Height <= 0 || l.MinContent > l.MaxContent+1 {
43+ if len(l.Lines) == 0 || l.Width <= 0 || l.Height <= 0 {
4444 t.Fatalf("bad metrics: %+v", l)
4545 }
4646 found, rtl, missing := false, false, false
@@ -90,10 +90,11 @@ func TestScriptsAndMetrics(t *testing.T) {
9090 }
9191 wide, _ := e.Measure("one two three four five", r, 220)
9292 narrow, _ := e.Measure("one two three four five", r, 60)
93- if narrow.Height < wide.Height || narrow.MinContent != wide.MinContent || wide.MaxContent != narrow.MaxContent {
93+ if narrow.Height < wide.Height || len(narrow.Lines) <= len(wide.Lines) {
9494 t.Errorf("wrap monotonicity: %+v %+v", narrow, wide)
9595 }
9696 }
97+
9798 func TestCorruptAndMatching(t *testing.T) {
9899 if _, err := Load(sourceMap{"broken.ttf": []byte("not a font")}); err == nil {
99100 t.Fatal("accepted corrupt font")
@@ -451,21 +452,36 @@ func BenchmarkMeasureMissShort(b *testing.B) {
451452 // allocBaselineMeasureMiss is the measured allocation count of an uncached
452453 // short label; most of the remainder is inside go-text segmentation and
453454 // wrapping. Lower it with each optimization.
454-const allocBaselineMeasureMiss = 33
455+const allocBaselineMeasureMiss = 20
455456
457+// TestAllocMeasureMissShort covers both catalog kinds: an eager directory
458+// catalog and a lazy indexed one, like the system catalog the renderer uses,
459+// which runs the on-demand coverage pass.
456460 func TestAllocMeasureMissShort(t *testing.T) {
457461 if raceEnabled {
458462 t.Skip("race instrumentation changes allocation counts")
459463 }
460- e := NewEngine(fixture(t))
461- r := Request{Families: []string{"Noto Sans"}, Size: 16}
462- measure := func() {
463- if _, err := e.measure("Value: 42", r, 0); err != nil {
464- t.Fatal(err)
465- }
464+ b, err := os.ReadFile("../../testdata/fonts/NotoSans-Regular.ttf")
465+ if err != nil {
466+ t.Fatal(err)
466467 }
467- measure() // load fonts and fill scratch buffers
468- if got := testing.AllocsPerRun(50, measure); got > allocBaselineMeasureMiss {
469- t.Fatalf("allocs per uncached measure = %v, baseline %v", got, allocBaselineMeasureMiss)
468+ lazy, err := Load(&countingSource{entries: []FontFile{{Path: "noto", Family: "Noto Sans", Aspect: font.Aspect{Weight: 400, Stretch: 1}}}, data: map[string][]byte{"noto": b}, reads: map[string]int{}})
469+ if err != nil {
470+ t.Fatal(err)
471+ }
472+ for name, catalog := range map[string]*Catalog{"directory": fixture(t), "indexed": lazy} {
473+ t.Run(name, func(t *testing.T) {
474+ e := NewEngine(catalog)
475+ r := Request{Families: []string{"Noto Sans"}, Size: 16}
476+ measure := func() {
477+ if _, err := e.measure("Value: 42", r, 0); err != nil {
478+ t.Fatal(err)
479+ }
480+ }
481+ measure() // load fonts and fill scratch buffers
482+ if got := testing.AllocsPerRun(50, measure); got > allocBaselineMeasureMiss {
483+ t.Fatalf("allocs per uncached measure = %v, baseline %v", got, allocBaselineMeasureMiss)
484+ }
485+ })
470486 }
471487 }
@@ -40,7 +40,7 @@ func TestScriptsAndMetrics(t *testing.T) {
40 if err != nil {40 if err != nil {
41 t.Fatal(err)41 t.Fatal(err)
42 }42 }
43- if len(l.Lines) == 0 || l.Width <= 0 || l.Height <= 0 || l.MinContent > l.MaxContent+1 {43+ if len(l.Lines) == 0 || l.Width <= 0 || l.Height <= 0 {
44 t.Fatalf("bad metrics: %+v", l)44 t.Fatalf("bad metrics: %+v", l)
45 }45 }
46 found, rtl, missing := false, false, false46 found, rtl, missing := false, false, false
@@ -90,10 +90,11 @@ func TestScriptsAndMetrics(t *testing.T) {
90 }90 }
91 wide, _ := e.Measure("one two three four five", r, 220)91 wide, _ := e.Measure("one two three four five", r, 220)
92 narrow, _ := e.Measure("one two three four five", r, 60)92 narrow, _ := e.Measure("one two three four five", r, 60)
93- if narrow.Height < wide.Height || narrow.MinContent != wide.MinContent || wide.MaxContent != narrow.MaxContent {93+ if narrow.Height < wide.Height || len(narrow.Lines) <= len(wide.Lines) {
94 t.Errorf("wrap monotonicity: %+v %+v", narrow, wide)94 t.Errorf("wrap monotonicity: %+v %+v", narrow, wide)
95 }95 }
96 }96 }
97+
97 func TestCorruptAndMatching(t *testing.T) {98 func TestCorruptAndMatching(t *testing.T) {
98 if _, err := Load(sourceMap{"broken.ttf": []byte("not a font")}); err == nil {99 if _, err := Load(sourceMap{"broken.ttf": []byte("not a font")}); err == nil {
99 t.Fatal("accepted corrupt font")100 t.Fatal("accepted corrupt font")
@@ -451,21 +452,36 @@ func BenchmarkMeasureMissShort(b *testing.B) {
451 // allocBaselineMeasureMiss is the measured allocation count of an uncached452 // allocBaselineMeasureMiss is the measured allocation count of an uncached
452 // short label; most of the remainder is inside go-text segmentation and453 // short label; most of the remainder is inside go-text segmentation and
453 // wrapping. Lower it with each optimization.454 // wrapping. Lower it with each optimization.
454-const allocBaselineMeasureMiss = 33455+const allocBaselineMeasureMiss = 20
455 456
457+// TestAllocMeasureMissShort covers both catalog kinds: an eager directory
458+// catalog and a lazy indexed one, like the system catalog the renderer uses,
459+// which runs the on-demand coverage pass.
456 func TestAllocMeasureMissShort(t *testing.T) {460 func TestAllocMeasureMissShort(t *testing.T) {
457 if raceEnabled {461 if raceEnabled {
458 t.Skip("race instrumentation changes allocation counts")462 t.Skip("race instrumentation changes allocation counts")
459 }463 }
460- e := NewEngine(fixture(t))464+ b, err := os.ReadFile("../../testdata/fonts/NotoSans-Regular.ttf")
461- r := Request{Families: []string{"Noto Sans"}, Size: 16}465+ if err != nil {
462- measure := func() {466+ t.Fatal(err)
463- if _, err := e.measure("Value: 42", r, 0); err != nil {
464- t.Fatal(err)
465- }
466 }467 }
467- measure() // load fonts and fill scratch buffers468+ lazy, err := Load(&countingSource{entries: []FontFile{{Path: "noto", Family: "Noto Sans", Aspect: font.Aspect{Weight: 400, Stretch: 1}}}, data: map[string][]byte{"noto": b}, reads: map[string]int{}})
468- if got := testing.AllocsPerRun(50, measure); got > allocBaselineMeasureMiss {469+ if err != nil {
469- t.Fatalf("allocs per uncached measure = %v, baseline %v", got, allocBaselineMeasureMiss)470+ t.Fatal(err)
471+ }
472+ for name, catalog := range map[string]*Catalog{"directory": fixture(t), "indexed": lazy} {
473+ t.Run(name, func(t *testing.T) {
474+ e := NewEngine(catalog)
475+ r := Request{Families: []string{"Noto Sans"}, Size: 16}
476+ measure := func() {
477+ if _, err := e.measure("Value: 42", r, 0); err != nil {
478+ t.Fatal(err)
479+ }
480+ }
481+ measure() // load fonts and fill scratch buffers
482+ if got := testing.AllocsPerRun(50, measure); got > allocBaselineMeasureMiss {
483+ t.Fatalf("allocs per uncached measure = %v, baseline %v", got, allocBaselineMeasureMiss)
484+ }
485+ })
470 }486 }
471 }487 }
M internal/text/visual.go +3 -8
@@ -9,13 +9,6 @@ import (
99 "github.com/go-text/typesetting/shaping"
1010 )
1111
12-func fullBreak(text []rune) []int {
13- if len(text) > 0 {
14- return []int{len(text)}
15- }
16- return nil
17-}
18-
1912 // splitLevels prevents a single shaping run from crossing resolved paragraph
2013 // levels (not merely direction parity). The wrapper can subsequently split
2114 // further at UAX#14 break opportunities.
@@ -58,6 +51,7 @@ type runItem struct {
5851 // are valid until the next call.
5952 type visualScratch struct {
6053 breaks []int
54+ levels []uint8
6155 runs []runItem
6256 }
6357
@@ -83,10 +77,11 @@ func (v *visualScratch) order(text []rune, line shaping.Line, base bidi.BaseDire
8377 breaks = append(breaks, len(text))
8478 }
8579 v.breaks = breaks
86- levels, _, err := bidi.Resolve(text, base, breaks)
80+ levels, _, err := bidi.ResolveInto(v.levels, text, base, breaks)
8781 if err != nil {
8882 return nil, err
8983 }
84+ v.levels = levels
9085 // The wrapper's runs are in logical order. A level change within one output
9186 // requires reshaping at that boundary; splitLevels does this before wrapping.
9287 clear(v.runs) // drop glyph references from the previous line
@@ -9,13 +9,6 @@ import (
9 "github.com/go-text/typesetting/shaping"9 "github.com/go-text/typesetting/shaping"
10 )10 )
11 11
12-func fullBreak(text []rune) []int {
13- if len(text) > 0 {
14- return []int{len(text)}
15- }
16- return nil
17-}
18-
19 // splitLevels prevents a single shaping run from crossing resolved paragraph12 // splitLevels prevents a single shaping run from crossing resolved paragraph
20 // levels (not merely direction parity). The wrapper can subsequently split13 // levels (not merely direction parity). The wrapper can subsequently split
21 // further at UAX#14 break opportunities.14 // further at UAX#14 break opportunities.
@@ -58,6 +51,7 @@ type runItem struct {
58 // are valid until the next call.51 // are valid until the next call.
59 type visualScratch struct {52 type visualScratch struct {
60 breaks []int53 breaks []int
54+ levels []uint8
61 runs []runItem55 runs []runItem
62 }56 }
63 57
@@ -83,10 +77,11 @@ func (v *visualScratch) order(text []rune, line shaping.Line, base bidi.BaseDire
83 breaks = append(breaks, len(text))77 breaks = append(breaks, len(text))
84 }78 }
85 v.breaks = breaks79 v.breaks = breaks
86- levels, _, err := bidi.Resolve(text, base, breaks)80+ levels, _, err := bidi.ResolveInto(v.levels, text, base, breaks)
87 if err != nil {81 if err != nil {
88 return nil, err82 return nil, err
89 }83 }
84+ v.levels = levels
90 // The wrapper's runs are in logical order. A level change within one output85 // The wrapper's runs are in logical order. A level change within one output
91 // requires reshaping at that boundary; splitLevels does this before wrapping.86 // requires reshaping at that boundary; splitLevels does this before wrapping.
92 clear(v.runs) // drop glyph references from the previous line87 clear(v.runs) // drop glyph references from the previous line
M internal/ui/indicator_paint.go +2 -1
@@ -2,6 +2,7 @@ package ui
22
33 import (
44 "math"
5+ "slices"
56
67 "github.com/bnema/nefergui/internal/css"
78 "github.com/bnema/nefergui/internal/layout"
@@ -152,5 +153,5 @@ func insertAt(out *layout.Output, at int, commands []layout.Command) {
152153 if at < 0 || len(commands) == 0 {
153154 return
154155 }
155- out.Display = append(out.Display[:at], append(commands, out.Display[at:]...)...)
156+ out.Display = slices.Insert(out.Display, at, commands...)
156157 }
@@ -2,6 +2,7 @@ package ui
2 2
3 import (3 import (
4 "math"4 "math"
5+ "slices"
5 6
6 "github.com/bnema/nefergui/internal/css"7 "github.com/bnema/nefergui/internal/css"
7 "github.com/bnema/nefergui/internal/layout"8 "github.com/bnema/nefergui/internal/layout"
@@ -152,5 +153,5 @@ func insertAt(out *layout.Output, at int, commands []layout.Command) {
152 if at < 0 || len(commands) == 0 {153 if at < 0 || len(commands) == 0 {
153 return154 return
154 }155 }
155- out.Display = append(out.Display[:at], append(commands, out.Display[at:]...)...)156+ out.Display = slices.Insert(out.Display, at, commands...)
156 }157 }
M internal/ui/performance_test.go +1 -1
@@ -103,7 +103,7 @@ func TestAllocDemoFrame(t *testing.T) {
103103 panic("demo frame failed")
104104 }
105105 })
106- const budget = 1100
106+ const budget = 600
107107 if allocs > budget {
108108 t.Fatalf("demo frame allocations: %.0f > %d", allocs, budget)
109109 }
@@ -103,7 +103,7 @@ func TestAllocDemoFrame(t *testing.T) {
103 panic("demo frame failed")103 panic("demo frame failed")
104 }104 }
105 })105 })
106- const budget = 1100106+ const budget = 600
107 if allocs > budget {107 if allocs > budget {
108 t.Fatalf("demo frame allocations: %.0f > %d", allocs, budget)108 t.Fatalf("demo frame allocations: %.0f > %d", allocs, budget)
109 }109 }
M internal/ui/renderer_gpu_test.go +1 -1
@@ -184,7 +184,7 @@ func TestRendererGPUResizeRetiresBuffers(t *testing.T) {
184184 // allocBaselineRenderer is the measured allocation count of one steady
185185 // Render+Released cycle (radv); it is lowered with each optimization until it
186186 // reaches the target of 0.
187-const allocBaselineRenderer = 48
187+const allocBaselineRenderer = 22
188188
189189 // TestAllocRendererSteadyFrame measures Render with a view whose number
190190 // changes on every frame and an unchanged structure. It runs on the real GPU
@@ -184,7 +184,7 @@ func TestRendererGPUResizeRetiresBuffers(t *testing.T) {
184 // allocBaselineRenderer is the measured allocation count of one steady184 // allocBaselineRenderer is the measured allocation count of one steady
185 // Render+Released cycle (radv); it is lowered with each optimization until it185 // Render+Released cycle (radv); it is lowered with each optimization until it
186 // reaches the target of 0.186 // reaches the target of 0.
187-const allocBaselineRenderer = 48187+const allocBaselineRenderer = 22
188 188
189 // TestAllocRendererSteadyFrame measures Render with a view whose number189 // TestAllocRendererSteadyFrame measures Render with a view whose number
190 // changes on every frame and an unchanged structure. It runs on the real GPU190 // changes on every frame and an unchanged structure. It runs on the real GPU
M internal/ui/runtime.go +11 -3
@@ -36,6 +36,8 @@ type runtime struct {
3636 committedArena int
3737 styles *css.Engine
3838 output layout.Output
39+ layouts [2]layout.Arena // one holds output, the other builds the next
40+ outputArena int
3941 state interactionState
4042 width, height, scale float64
4143 textEngine *text.Engine
@@ -149,7 +151,11 @@ func (r *runtime) Build(view func(*Frame)) bool {
149151 if f.root != nil {
150152 f.refresh(f.root)
151153 input := r.layoutTree(f.root)
152- out, err := layout.Layout(input, layout.Options{Width: r.width, Height: r.height, TextEngine: r.textEngine})
154+ // Lay out in the arena that does not hold r.output, which the view and
155+ // scrollCaret still read as the committed layout.
156+ nextLayout := 1 - r.outputArena
157+ opts := layout.Options{Width: r.width, Height: r.height, TextEngine: r.textEngine, Arena: &r.layouts[nextLayout]}
158+ out, err := layout.Layout(input, opts)
153159 if err != nil {
154160 r.mu.Unlock()
155161 r.Redraw()
@@ -157,9 +163,10 @@ func (r *runtime) Build(view func(*Frame)) bool {
157163 }
158164 // Re-layout at most once after moving a focused caret into its editor
159165 // viewport; the offset is per frame identity and bounded by measured extent.
166+ // Reusing the arena is safe: out is not read after scrollCaret returns.
160167 if r.scrollCaret(f.root, &out) {
161168 input = r.layoutTree(f.root)
162- out, err = layout.Layout(input, layout.Options{Width: r.width, Height: r.height, TextEngine: r.textEngine})
169+ out, err = layout.Layout(input, opts)
163170 if err != nil {
164171 r.mu.Unlock()
165172 r.Redraw()
@@ -168,7 +175,8 @@ func (r *runtime) Build(view func(*Frame)) bool {
168175 }
169176 r.paintEditors(f.root, &out)
170177 r.paintIndicators(f.root, &out)
171- r.output = out
178+ opts.Arena.Keep(out.Display) // recycle the list grown by the decorations
179+ r.output, r.outputArena = out, nextLayout
172180 }
173181 r.committed, r.committedArena = f.root, next
174182 // Events mutate the model while the view runs, so elements declared before
@@ -36,6 +36,8 @@ type runtime struct {
36 committedArena int36 committedArena int
37 styles *css.Engine37 styles *css.Engine
38 output layout.Output38 output layout.Output
39+ layouts [2]layout.Arena // one holds output, the other builds the next
40+ outputArena int
39 state interactionState41 state interactionState
40 width, height, scale float6442 width, height, scale float64
41 textEngine *text.Engine43 textEngine *text.Engine
@@ -149,7 +151,11 @@ func (r *runtime) Build(view func(*Frame)) bool {
149 if f.root != nil {151 if f.root != nil {
150 f.refresh(f.root)152 f.refresh(f.root)
151 input := r.layoutTree(f.root)153 input := r.layoutTree(f.root)
152- out, err := layout.Layout(input, layout.Options{Width: r.width, Height: r.height, TextEngine: r.textEngine})154+ // Lay out in the arena that does not hold r.output, which the view and
155+ // scrollCaret still read as the committed layout.
156+ nextLayout := 1 - r.outputArena
157+ opts := layout.Options{Width: r.width, Height: r.height, TextEngine: r.textEngine, Arena: &r.layouts[nextLayout]}
158+ out, err := layout.Layout(input, opts)
153 if err != nil {159 if err != nil {
154 r.mu.Unlock()160 r.mu.Unlock()
155 r.Redraw()161 r.Redraw()
@@ -157,9 +163,10 @@ func (r *runtime) Build(view func(*Frame)) bool {
157 }163 }
158 // Re-layout at most once after moving a focused caret into its editor164 // Re-layout at most once after moving a focused caret into its editor
159 // viewport; the offset is per frame identity and bounded by measured extent.165 // viewport; the offset is per frame identity and bounded by measured extent.
166+ // Reusing the arena is safe: out is not read after scrollCaret returns.
160 if r.scrollCaret(f.root, &out) {167 if r.scrollCaret(f.root, &out) {
161 input = r.layoutTree(f.root)168 input = r.layoutTree(f.root)
162- out, err = layout.Layout(input, layout.Options{Width: r.width, Height: r.height, TextEngine: r.textEngine})169+ out, err = layout.Layout(input, opts)
163 if err != nil {170 if err != nil {
164 r.mu.Unlock()171 r.mu.Unlock()
165 r.Redraw()172 r.Redraw()
@@ -168,7 +175,8 @@ func (r *runtime) Build(view func(*Frame)) bool {
168 }175 }
169 r.paintEditors(f.root, &out)176 r.paintEditors(f.root, &out)
170 r.paintIndicators(f.root, &out)177 r.paintIndicators(f.root, &out)
171- r.output = out178+ opts.Arena.Keep(out.Display) // recycle the list grown by the decorations
179+ r.output, r.outputArena = out, nextLayout
172 }180 }
173 r.committed, r.committedArena = f.root, next181 r.committed, r.committedArena = f.root, next
174 // Events mutate the model while the view runs, so elements declared before182 // Events mutate the model while the view runs, so elements declared before
M internal/ui/runtime_test.go +83 -0
@@ -99,6 +99,89 @@ func TestWake(t *testing.T) {
9999 }
100100 }
101101
102+// TestCommittedLayoutSurvivesNextBuild guards the layout arena rotation: the
103+// view reads the committed layout while the next one is built, and a frame
104+// that does not commit must leave it intact.
105+func TestCommittedLayoutSurvivesNextBuild(t *testing.T) {
106+ r := newRuntime()
107+ view := func(label string) func(*Frame) {
108+ return func(f *Frame) { f.Root().Box(Key(label), Inline("width:10px;height:10px;background:#fff")) }
109+ }
110+ // Warm both arenas: a fresh one allocates detached results, which would
111+ // hide an overwrite.
112+ for _, label := range []string{"w1", "w2", "a"} {
113+ r.Redraw()
114+ if !r.Build(view(label)) {
115+ t.Fatalf("warm-up build %q skipped", label)
116+ }
117+ }
118+ snapshot := func() (string, int) {
119+ leaf := r.output.Tree
120+ for len(leaf.Children) > 0 {
121+ leaf = leaf.Children[0]
122+ }
123+ return leaf.ID, len(r.output.Display)
124+ }
125+ id, cmds := snapshot()
126+ // Building the next frame must not write into the previous output, which
127+ // the view, hit-testing and the renderer read until the commit.
128+ prevTree, prevDisplay := r.output.Tree, r.output.Display
129+ first := prevDisplay[0].ID
130+ r.Redraw()
131+ r.Build(view("b"))
132+ leaf := prevTree
133+ for len(leaf.Children) > 0 {
134+ leaf = leaf.Children[0]
135+ }
136+ if leaf.ID != id || len(prevDisplay) != cmds || prevDisplay[0].ID != first {
137+ t.Fatalf("next build overwrote the previous layout: leaf %q (want %q), %d commands (want %d), first %q (want %q)", leaf.ID, id, len(prevDisplay), cmds, prevDisplay[0].ID, first)
138+ }
139+ idB, _ := snapshot()
140+ if idB == id {
141+ t.Fatalf("second build not committed: %q", idB)
142+ }
143+ // A panicking view does not commit: the output stays readable and intact.
144+ func() {
145+ defer func() { _ = recover() }()
146+ r.Redraw()
147+ r.Build(func(f *Frame) { view("c")(f); panic("view failure") })
148+ }()
149+ if got, _ := snapshot(); got != idB {
150+ t.Fatalf("panicked frame replaced the output: %q, want %q", got, idB)
151+ }
152+ // A failed layout writes into the free arena but commits nothing.
153+ r.Redraw()
154+ if r.Build(func(f *Frame) {
155+ n := f.Root()
156+ for range 300 { // deeper than layout allows
157+ n = n.Box()
158+ }
159+ }) {
160+ t.Fatal("too deep layout committed")
161+ }
162+ if got, _ := snapshot(); got != idB {
163+ t.Fatalf("failed layout replaced the output: %q, want %q", got, idB)
164+ }
165+ // Rotation still alternates after both failures: each build keeps the
166+ // previously committed layout intact.
167+ for _, label := range []string{"d", "e"} {
168+ prevTree, prevDisplay, before := r.output.Tree, r.output.Display, idB
169+ first, cmds := prevDisplay[0].ID, len(prevDisplay)
170+ r.Redraw()
171+ if !r.Build(view(label)) {
172+ t.Fatalf("build %q skipped", label)
173+ }
174+ leaf := prevTree
175+ for len(leaf.Children) > 0 {
176+ leaf = leaf.Children[0]
177+ }
178+ if leaf.ID != before || len(prevDisplay) != cmds || prevDisplay[0].ID != first {
179+ t.Fatalf("build %q overwrote the previous layout: leaf %q (want %q), first command %q (want %q)", label, leaf.ID, before, prevDisplay[0].ID, first)
180+ }
181+ idB, _ = snapshot()
182+ }
183+}
184+
102185 func TestConcurrentBuildDefersCommit(t *testing.T) {
103186 r := newRuntime()
104187 entered := make(chan struct{})
@@ -99,6 +99,89 @@ func TestWake(t *testing.T) {
99 }99 }
100 }100 }
101 101
102+// TestCommittedLayoutSurvivesNextBuild guards the layout arena rotation: the
103+// view reads the committed layout while the next one is built, and a frame
104+// that does not commit must leave it intact.
105+func TestCommittedLayoutSurvivesNextBuild(t *testing.T) {
106+ r := newRuntime()
107+ view := func(label string) func(*Frame) {
108+ return func(f *Frame) { f.Root().Box(Key(label), Inline("width:10px;height:10px;background:#fff")) }
109+ }
110+ // Warm both arenas: a fresh one allocates detached results, which would
111+ // hide an overwrite.
112+ for _, label := range []string{"w1", "w2", "a"} {
113+ r.Redraw()
114+ if !r.Build(view(label)) {
115+ t.Fatalf("warm-up build %q skipped", label)
116+ }
117+ }
118+ snapshot := func() (string, int) {
119+ leaf := r.output.Tree
120+ for len(leaf.Children) > 0 {
121+ leaf = leaf.Children[0]
122+ }
123+ return leaf.ID, len(r.output.Display)
124+ }
125+ id, cmds := snapshot()
126+ // Building the next frame must not write into the previous output, which
127+ // the view, hit-testing and the renderer read until the commit.
128+ prevTree, prevDisplay := r.output.Tree, r.output.Display
129+ first := prevDisplay[0].ID
130+ r.Redraw()
131+ r.Build(view("b"))
132+ leaf := prevTree
133+ for len(leaf.Children) > 0 {
134+ leaf = leaf.Children[0]
135+ }
136+ if leaf.ID != id || len(prevDisplay) != cmds || prevDisplay[0].ID != first {
137+ t.Fatalf("next build overwrote the previous layout: leaf %q (want %q), %d commands (want %d), first %q (want %q)", leaf.ID, id, len(prevDisplay), cmds, prevDisplay[0].ID, first)
138+ }
139+ idB, _ := snapshot()
140+ if idB == id {
141+ t.Fatalf("second build not committed: %q", idB)
142+ }
143+ // A panicking view does not commit: the output stays readable and intact.
144+ func() {
145+ defer func() { _ = recover() }()
146+ r.Redraw()
147+ r.Build(func(f *Frame) { view("c")(f); panic("view failure") })
148+ }()
149+ if got, _ := snapshot(); got != idB {
150+ t.Fatalf("panicked frame replaced the output: %q, want %q", got, idB)
151+ }
152+ // A failed layout writes into the free arena but commits nothing.
153+ r.Redraw()
154+ if r.Build(func(f *Frame) {
155+ n := f.Root()
156+ for range 300 { // deeper than layout allows
157+ n = n.Box()
158+ }
159+ }) {
160+ t.Fatal("too deep layout committed")
161+ }
162+ if got, _ := snapshot(); got != idB {
163+ t.Fatalf("failed layout replaced the output: %q, want %q", got, idB)
164+ }
165+ // Rotation still alternates after both failures: each build keeps the
166+ // previously committed layout intact.
167+ for _, label := range []string{"d", "e"} {
168+ prevTree, prevDisplay, before := r.output.Tree, r.output.Display, idB
169+ first, cmds := prevDisplay[0].ID, len(prevDisplay)
170+ r.Redraw()
171+ if !r.Build(view(label)) {
172+ t.Fatalf("build %q skipped", label)
173+ }
174+ leaf := prevTree
175+ for len(leaf.Children) > 0 {
176+ leaf = leaf.Children[0]
177+ }
178+ if leaf.ID != before || len(prevDisplay) != cmds || prevDisplay[0].ID != first {
179+ t.Fatalf("build %q overwrote the previous layout: leaf %q (want %q), first command %q (want %q)", label, leaf.ID, before, prevDisplay[0].ID, first)
180+ }
181+ idB, _ = snapshot()
182+ }
183+}
184+
102 func TestConcurrentBuildDefersCommit(t *testing.T) {185 func TestConcurrentBuildDefersCommit(t *testing.T) {
103 r := newRuntime()186 r := newRuntime()
104 entered := make(chan struct{})187 entered := make(chan struct{})

Keyboard shortcuts

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

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

Branches 1

Tags 12

↑↓ moveenter openesc close