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 Unverified
B5690EEEBB952194The key is unknown
28 files · +627 -172
M docs/layout.md +2 -0
| @@ -6,4 +6,6 @@ | |||
| 6 | 6 | ||
| 7 | 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 | 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. | |
| @@ -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 @@ | |||
| 2 | 2 | ||
| 3 | 3 | ## 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 | 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 | 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 | | |
| 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. | |
| 33 | 34 | ||
| 34 | 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 | - | - 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; | |
| 38 | 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 | 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 | 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 | 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 | 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. | |
| @@ -2,42 +2,44 @@ | |||||
| 2 | 2 | ||||
| 3 | ## CPU budgets | 3 | ## 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 @@ | |||
| 1 | 1 | package bidi | |
| 2 | 2 | ||
| 3 | - | import "fmt" | |
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "slices" | |
| 6 | + | ) | |
| 4 | 7 | ||
| 5 | 8 | // BaseDirection selects the paragraph embedding level. Auto follows P2/P3. | |
| 6 | 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 | 28 | // Resolve returns the paragraph base level and the resolved levels after L1. | |
| 26 | 29 | // nefergui: surface existing levels without copying or reclassifying UAX#9 classes. | |
| 27 | 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 | 38 | if base > RTL { | |
| 29 | 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 | 58 | prev = end | |
| 49 | 59 | } | |
| 50 | 60 | 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 | |
| 52 | 64 | } | |
| 53 | 65 | return resolveFull(text, base, lineBreaks) | |
| 54 | 66 | } | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | package bidi | 1 | 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 = end | 58 | prev = end | ||
| 49 | } | 59 | } | ||
| 50 | if base != RTL && leftToRightOnly(text) { | 60 | 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 | |||
| 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) { | |||
| 53 | 53 | if got := testing.AllocsPerRun(100, func() { _, _, _ = Resolve(text, Auto, breaks) }); got > 1 { | |
| 54 | 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 | } | |
| @@ -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 ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "image" | |
| 8 | 8 | "math" | |
| 9 | + | "slices" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/nefergui/internal/css" | |
| 11 | 12 | "github.com/bnema/nefergui/internal/text" | |
| @@ -165,9 +166,38 @@ type Output struct { | |||
| 165 | 166 | } | |
| 166 | 167 | ||
| 167 | 168 | // Options provide logical viewport size and an optional text shaping engine. | |
| 169 | + | // Arena, when set, supplies reusable storage for the Output. | |
| 168 | 170 | type Options struct { | |
| 169 | 171 | Width, Height float64 | |
| 170 | 172 | 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 | 203 | var ErrDepth = errors.New("layout: tree depth exceeds 256") | |
| @@ -177,7 +207,7 @@ func Layout(root *Node, options Options) (Output, error) { | |||
| 177 | 207 | if root == nil { | |
| 178 | 208 | return Output{}, nil | |
| 179 | 209 | } | |
| 180 | - | ctx := context{options: options} | |
| 210 | + | ctx := newContext(options) | |
| 181 | 211 | viewport := Rect{0, 0, safe(options.Width), safe(options.Height)} | |
| 182 | 212 | tree, err := ctx.place(root, viewport.X, viewport.Y, viewport.W, viewport.H, nil, nil, 0) | |
| 183 | 213 | if err != nil { | |
| @@ -191,21 +221,44 @@ func Layout(root *Node, options Options) (Output, error) { | |||
| 191 | 221 | // display paints the placed tree into a list sized once from the tree. | |
| 192 | 222 | func (c context) display(root *Node, tree *Result) []Command { | |
| 193 | 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 | 236 | if n.commands == 0 { | |
| 195 | 237 | return nil | |
| 196 | 238 | } | |
| 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) | |
| 199 | 240 | c.paint(root, tree, &out) | |
| 241 | + | a.commands = out | |
| 200 | 242 | return out | |
| 201 | 243 | } | |
| 202 | 244 | ||
| 203 | 245 | type context struct { | |
| 204 | 246 | 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} | |
| 209 | 262 | } | |
| 210 | 263 | ||
| 211 | 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 | 534 | if !explicitH { | |
| 482 | 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 | 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 | 539 | r.ContentSize = Size{measured.W, measured.H} | |
| 487 | 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 | 633 | if absolute { | |
| 581 | 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 | 637 | r.ContentSize.W = math.Max(r.ContentSize.W, cw) | |
| 585 | 638 | if n.Kind == Stack { | |
| 586 | 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 | 645 | return nil | |
| 593 | 646 | } | |
| 594 | 647 | ||
| 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 { | |
| 598 | 651 | if children == nil { | |
| 599 | - | children = make([]*Result, 0, n) | |
| 652 | + | children = c.arena.children.take(n) | |
| 600 | 653 | } | |
| 601 | 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 | 788 | mainSize, crossSize = crossSize, mainSize | |
| 736 | 789 | gap, crossGap = crossGap, gap | |
| 737 | 790 | } | |
| 738 | - | items := make([]flexItem, 0, len(n.Children)) | |
| 791 | + | items := c.arena.flexItems.take(len(n.Children)) | |
| 739 | 792 | for _, ch := range n.Children { | |
| 740 | 793 | if ch == nil || style(ch).Display == css.KeywordNone { | |
| 741 | 794 | continue | |
| @@ -800,33 +853,34 @@ func (c context) flex(n *Node, r *Result, s css.Style, depth int) error { | |||
| 800 | 853 | it.cross = boxBound(it.cross, minCross, maxCross, crossSize, inCross, st.BoxSizing == css.KeywordBorderBox) | |
| 801 | 854 | items = append(items, it) | |
| 802 | 855 | } | |
| 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 { | |
| 807 | 860 | need := item.base + item.marginMain | |
| 808 | - | if len(line) > 0 { | |
| 861 | + | if i > start { | |
| 809 | 862 | need += gap | |
| 810 | 863 | } | |
| 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 | |
| 815 | 867 | need = item.base + item.marginMain | |
| 816 | 868 | } | |
| 817 | - | line = append(line, item) | |
| 818 | 869 | used += need | |
| 819 | 870 | } | |
| 820 | - | if len(line) > 0 { | |
| 821 | - | lines = append(lines, line) | |
| 871 | + | if len(items) > start { | |
| 872 | + | ends = append(ends, len(items)) | |
| 822 | 873 | } | |
| 823 | 874 | 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 | 879 | lineCross := 0.0 | |
| 826 | 880 | for _, it := range line { | |
| 827 | 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 | 884 | lineCross = math.Max(lineCross, crossSize) | |
| 831 | 885 | } | |
| 832 | 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 | 943 | return err | |
| 890 | 944 | } | |
| 891 | 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 | 947 | cw, ch := envelope(child) | |
| 894 | 948 | r.ContentSize.W = math.Max(r.ContentSize.W, child.Border.X-r.Content.X+cw) | |
| 895 | 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 | 955 | return nil | |
| 902 | 956 | } | |
| 903 | 957 | ||
| 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 | |
| 908 | 962 | // 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 | + | } | |
| 911 | 969 | ||
| 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 | |
| 915 | 987 | } | |
| 916 | - | return &slab[T]{block: make([]T, 0, n)} | |
| 988 | + | a.block = a.block[:0] | |
| 917 | 989 | } | |
| 918 | 990 | ||
| 919 | 991 | // one stores v in the slab and returns its address. | |
| 920 | 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 | 995 | p := new(T) // copy, so v itself never escapes | |
| 923 | 996 | *p = v | |
| 924 | 997 | return p | |
| @@ -932,7 +1005,8 @@ func (a *slab[T]) take(n int) []T { | |||
| 932 | 1005 | if n <= 0 { | |
| 933 | 1006 | 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 | 1010 | return make([]T, 0, n) | |
| 937 | 1011 | } | |
| 938 | 1012 | i := len(a.block) | |
| @@ -1020,7 +1094,7 @@ func (c context) paint(n *Node, r *Result, out *[]Command) { | |||
| 1020 | 1094 | } | |
| 1021 | 1095 | cmd := base | |
| 1022 | 1096 | cmd.Op = "shadow" | |
| 1023 | - | cmd.Shadow = c.shadows.one(s.BoxShadow[i]) | |
| 1097 | + | cmd.Shadow = c.arena.shadows.one(s.BoxShadow[i]) | |
| 1024 | 1098 | cmd.Radii = borderRadii | |
| 1025 | 1099 | *out = append(*out, cmd) | |
| 1026 | 1100 | } | |
| @@ -1046,7 +1120,7 @@ func (c context) paint(n *Node, r *Result, out *[]Command) { | |||
| 1046 | 1120 | } | |
| 1047 | 1121 | cmd := base | |
| 1048 | 1122 | cmd.Op = "shadow" | |
| 1049 | - | cmd.Shadow = c.shadows.one(s.BoxShadow[i]) | |
| 1123 | + | cmd.Shadow = c.arena.shadows.one(s.BoxShadow[i]) | |
| 1050 | 1124 | cmd.Radii = borderRadii | |
| 1051 | 1125 | *out = append(*out, cmd) | |
| 1052 | 1126 | } | |
| @@ -1082,8 +1156,8 @@ func (c context) paint(n *Node, r *Result, out *[]Command) { | |||
| 1082 | 1156 | cmd.Text = n.Content | |
| 1083 | 1157 | cmd.Color = s.Color | |
| 1084 | 1158 | 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) | |
| 1087 | 1161 | for _, line := range r.Lines { | |
| 1088 | 1162 | offset := AlignOffset(s.TextAlign, line.Direction, r.Content.W, line.Width) | |
| 1089 | 1163 | 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 float64 | 171 | Width, Height float64 | ||
| 170 | TextEngine *text.Engine | 172 | 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{}, nil | 208 | 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 nil | 237 | 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 out | 242 | return out | ||
| 201 | } | 243 | } | ||
| 202 | 244 | ||||
| 203 | type context struct { | 245 | type context struct { | ||
| 204 | options Options | 246 | 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 nil | 645 | return nil | ||
| 593 | } | 646 | } | ||
| 594 | 647 | ||||
| 595 | - | // appendChild appends child, sizing the first allocation for the n input | 648 | + | // 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, mainSize | 788 | mainSize, crossSize = crossSize, mainSize | ||
| 736 | gap, crossGap = crossGap, gap | 789 | 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 | continue | 794 | 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.0 | 858 | + | start, used := 0, 0.0 |
| 806 | - | for _, item := range items { | 859 | + | for i, item := range items { |
| 807 | need := item.base + item.marginMain | 860 | need := item.base + item.marginMain | ||
| 808 | - | if len(line) > 0 { | 861 | + | if i > start { |
| 809 | need += gap | 862 | 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 = nil | 866 | + | start, used = i, 0 |
| 814 | - | used = 0 | |||
| 815 | need = item.base + item.marginMain | 867 | need = item.base + item.marginMain | ||
| 816 | } | 868 | } | ||
| 817 | - | line = append(line, item) | |||
| 818 | used += need | 869 | 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.0 | 874 | 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.0 | 879 | 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 err | 943 | 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 nil | 955 | 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 or | 959 | + | // 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 slices | 960 | + | // 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 their | 961 | + | // 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. A | 962 | // 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 nil | 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 | |||
| 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 escapes | 995 | p := new(T) // copy, so v itself never escapes | ||
| 923 | *p = v | 996 | *p = v | ||
| 924 | return p | 997 | return p | ||
| @@ -932,7 +1005,8 @@ func (a *slab[T]) take(n int) []T { | |||||
| 932 | if n <= 0 { | 1005 | if n <= 0 { | ||
| 933 | return nil | 1006 | 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 := base | 1095 | 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 = borderRadii | 1098 | 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 := base | 1121 | 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 = borderRadii | 1124 | 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.Content | 1156 | cmd.Text = n.Content | ||
| 1083 | cmd.Color = s.Color | 1157 | 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) { | |||
| 75 | 75 | t.Fatalf("flex wrap: %+v", out.Tree.Children) | |
| 76 | 76 | } | |
| 77 | 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 | 86 | s.FlexWrap = css.KeywordNowrap | |
| 79 | 87 | root.Style = &s | |
| 80 | 88 | 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.KeywordNowrap | 86 | s.FlexWrap = css.KeywordNowrap | ||
| 79 | root.Style = &s | 87 | 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) { | |||
| 37 | 37 | // BenchmarkPaint measures only context.paint on an already placed tree. | |
| 38 | 38 | func BenchmarkPaint(b *testing.B) { | |
| 39 | 39 | root, opts := paintFixture(b, 100) | |
| 40 | - | ctx := context{options: opts} | |
| 40 | + | ctx := newContext(opts) | |
| 41 | 41 | tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0) | |
| 42 | 42 | if err != nil { | |
| 43 | 43 | b.Fatal(err) | |
| @@ -53,6 +53,7 @@ func BenchmarkPaint(b *testing.B) { | |||
| 53 | 53 | ||
| 54 | 54 | func BenchmarkLayoutPaintTree(b *testing.B) { | |
| 55 | 55 | root, opts := paintFixture(b, 100) | |
| 56 | + | opts.Arena = new(Arena) // as the runtime does: storage is reused per frame | |
| 56 | 57 | if _, err := Layout(root, opts); err != nil { | |
| 57 | 58 | b.Fatal(err) | |
| 58 | 59 | } | |
| @@ -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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "image" | |
| 5 | 5 | "reflect" | |
| 6 | + | "slices" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/nefergui/internal/css" | |
| @@ -112,7 +113,7 @@ func TestPaintCountsMatch(t *testing.T) { | |||
| 112 | 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 | 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 | 117 | tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0) | |
| 117 | 118 | if err != nil { | |
| 118 | 119 | t.Fatal(err) | |
| @@ -129,21 +130,89 @@ func TestPaintCountsMatch(t *testing.T) { | |||
| 129 | 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 | - | 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) | |
| 134 | 146 | } | |
| 135 | 147 | } | |
| 136 | 148 | ||
| 137 | 149 | func TestPaintAllocations(t *testing.T) { | |
| 138 | 150 | root, opts := paintFixture(t, 50) | |
| 139 | - | ctx := context{options: opts} | |
| 151 | + | ctx := newContext(opts) | |
| 140 | 152 | tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0) | |
| 141 | 153 | if err != nil { | |
| 142 | 154 | t.Fatal(err) | |
| 143 | 155 | } | |
| 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) | |
| 147 | 216 | } | |
| 148 | 217 | } | |
| 149 | 218 | ||
| @@ -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 { | |||
| 78 | 78 | Slots map[uint64]*Slot | |
| 79 | 79 | ||
| 80 | 80 | list render.ListBuffer | |
| 81 | + | quads []render.Quad // reused by submit; the instance upload copies them | |
| 81 | 82 | pipeline *vkdevice.Pipeline | |
| 82 | 83 | pipelineGen uint64 // pool generation the current pipeline belongs to | |
| 83 | 84 | retiredPipelines []retiredPipelines | |
| @@ -306,10 +307,11 @@ func (t *Target) Draw(display []layout.Command, scale float64, out *Output) (boo | |||
| 306 | 307 | func (t *Target) Waiting() bool { return t.waiting } | |
| 307 | 308 | ||
| 308 | 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 | 311 | if err != nil { | |
| 311 | 312 | return false, err | |
| 312 | 313 | } | |
| 314 | + | t.quads = frame.Quads | |
| 313 | 315 | defer t.list.Release() | |
| 314 | 316 | instances, batches, stats := t.list.List(frame) | |
| 315 | 317 | if len(stats.Skipped) != 0 { | |
| @@ -78,6 +78,7 @@ type Target struct { | |||||
| 78 | Slots map[uint64]*Slot | 78 | Slots map[uint64]*Slot | ||
| 79 | 79 | ||||
| 80 | list render.ListBuffer | 80 | list render.ListBuffer | ||
| 81 | + | quads []render.Quad // reused by submit; the instance upload copies them | |||
| 81 | pipeline *vkdevice.Pipeline | 82 | pipeline *vkdevice.Pipeline | ||
| 82 | pipelineGen uint64 // pool generation the current pipeline belongs to | 83 | pipelineGen uint64 // pool generation the current pipeline belongs to | ||
| 83 | retiredPipelines []retiredPipelines | 84 | 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, err | 312 | 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 { | |||
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | 44 | // 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 | 47 | const bufferSlack = 4 | |
| 47 | 48 | ||
| 48 | 49 | // 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 before | 44 | // 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 = 4 | 47 | 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 ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "image" | |
| 8 | 8 | "math" | |
| 9 | + | "slices" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/nefergui/internal/css" | |
| 11 | 12 | "github.com/bnema/nefergui/internal/layout" | |
| @@ -103,19 +104,31 @@ func quadCapacity(commands []layout.Command) int { | |||
| 103 | 104 | // Image commands without a source are rejected. Failed frames do not produce | |
| 104 | 105 | // partial output. | |
| 105 | 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 | 117 | if p == nil || p.Atlas == nil || scale <= 0 || math.IsNaN(scale) || math.IsInf(scale, 0) || width <= 0 || height <= 0 { | |
| 107 | 118 | return Frame{}, fmt.Errorf("render: invalid frame dimensions, scale, or atlas") | |
| 108 | 119 | } | |
| 109 | 120 | 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 | |
| 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 | 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 | 132 | for _, cmd := range commands { | |
| 120 | 133 | switch cmd.Op { | |
| 121 | 134 | 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 produce | 104 | // 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, so | 123 | + | 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) { | |||
| 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 | 174 | func BenchmarkPrepareBoxes(b *testing.B) { | |
| 128 | 175 | p, err := NewPreparer(256, 2) | |
| 129 | 176 | 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 { | |||
| 40 | 40 | entries map[Key]atlasEntry | |
| 41 | 41 | frame uint64 | |
| 42 | 42 | 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 | 50 | func NewAtlas(size, maxPages int) (*Atlas, error) { | |
| 46 | 51 | if size < 4 || size > 4096 || maxPages < 1 || maxPages > 64 { | |
| 47 | 52 | return nil, errors.New("invalid atlas bounds") | |
| @@ -94,6 +99,9 @@ func (a *Atlas) Lookup(k Key) (Placement, bool) { | |||
| 94 | 99 | if !ok { | |
| 95 | 100 | 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 | 105 | e.generation = a.frame | |
| 98 | 106 | a.entries[k] = e | |
| 99 | 107 | a.pages[e.placement.Page].generation = a.frame | |
| @@ -111,7 +119,20 @@ func (a *Atlas) Insert(k Key, m Mask) (Placement, error) { | |||
| 111 | 119 | return Placement{}, errors.New("glyph too large") | |
| 112 | 120 | } | |
| 113 | 121 | 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 | |
| 115 | 136 | } | |
| 116 | 137 | chosen := -1 | |
| 117 | 138 | var x, y int | |
| @@ -40,8 +40,13 @@ type Atlas struct { | |||||
| 40 | entries map[Key]atlasEntry | 40 | entries map[Key]atlasEntry | ||
| 41 | frame uint64 | 41 | frame uint64 | ||
| 42 | dirty map[int]image.Rectangle | 42 | 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{}, false | 100 | 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.frame | 105 | e.generation = a.frame | ||
| 98 | a.entries[k] = e | 106 | a.entries[k] = e | ||
| 99 | a.pages[e.placement.Page].generation = a.frame | 107 | 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}, 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 | |||
| 115 | } | 136 | } | ||
| 116 | chosen := -1 | 137 | chosen := -1 | ||
| 117 | var x, y int | 138 | var x, y int | ||
M internal/text/atlas_dirty_test.go +38 -0
| @@ -4,6 +4,8 @@ import ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "image" | |
| 6 | 6 | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/go-text/typesetting/font" | |
| 7 | 9 | ) | |
| 8 | 10 | ||
| 9 | 11 | func TestMarkAllDirty(t *testing.T) { | |
| @@ -31,3 +33,39 @@ func TestMarkAllDirty(t *testing.T) { | |||
| 31 | 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 | + | } | |
| @@ -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 { | |||
| 37 | 37 | type measureCache struct { | |
| 38 | 38 | entries map[measureKey]*measureEntry | |
| 39 | 39 | frame uint64 | |
| 40 | + | free []*measureEntry // evicted entries, reused by put | |
| 40 | 41 | } | |
| 41 | 42 | ||
| 42 | 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 | 73 | if len(c.entries) >= maxMeasureEntries { | |
| 73 | 74 | 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 | 89 | func (c *measureCache) endFrame() { | |
| 79 | 90 | for k, e := range c.entries { | |
| 80 | 91 | if c.frame-e.used >= 2 { | |
| 81 | 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 | 98 | c.frame++ | |
| @@ -37,6 +37,7 @@ type measureEntry struct { | |||||
| 37 | type measureCache struct { | 37 | type measureCache struct { | ||
| 38 | entries map[measureKey]*measureEntry | 38 | entries map[measureKey]*measureEntry | ||
| 39 | frame uint64 | 39 | 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 | return | 74 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/go-text/typesetting/font" | |
| 8 | + | ot "github.com/go-text/typesetting/font/opentype" | |
| 6 | 9 | ) | |
| 7 | 10 | ||
| 8 | 11 | func TestMeasureCacheHitsKeysAndEviction(t *testing.T) { | |
| @@ -53,6 +56,23 @@ func TestMeasureCacheHitsKeysAndEviction(t *testing.T) { | |||
| 53 | 56 | if n := len(e.cache.entries); n != 1 { | |
| 54 | 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 | 78 | 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 | |||
| 77 | 77 | } | |
| 78 | 78 | } | |
| 79 | 79 | 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 | 81 | return result, nil | |
| 88 | 82 | } | |
| @@ -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.LineHeight | 79 | 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, nil | 81 | return result, nil | ||
| 88 | } | 82 | } | ||
M internal/text/review_test.go +2 -1
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | ||
| 9 | 9 | "github.com/go-text/typesetting/font" | |
| 10 | 10 | ot "github.com/go-text/typesetting/font/opentype" | |
| 11 | + | "github.com/go-text/typesetting/segmenter" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| 13 | 14 | func TestVariableDefaultAndDistinctKeys(t *testing.T) { | |
| @@ -50,7 +51,7 @@ func TestClusterFallback(t *testing.T) { | |||
| 50 | 51 | for _, s := range cases { | |
| 51 | 52 | t.Run(s, func(t *testing.T) { | |
| 52 | 53 | r := []rune(s) | |
| 53 | - | faces := clusterFaces(nil, r, choices) | |
| 54 | + | faces := clusterFaces(new(segmenter.Segmenter), nil, r, choices) | |
| 54 | 55 | if len(faces) != len(r) { | |
| 55 | 56 | t.Fatalf("%d faces", len(faces)) | |
| 56 | 57 | } | |
| @@ -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 { | |||
| 41 | 41 | Width, Baseline, Height float64 | |
| 42 | 42 | } | |
| 43 | 43 | 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 | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | 49 | // Engine is single-goroutine only: Measure and EndFrame are not synchronized. | |
| @@ -54,7 +54,6 @@ type Engine struct { | |||
| 54 | 54 | seg shaping.Segmenter | |
| 55 | 55 | shaper shaping.HarfbuzzShaper | |
| 56 | 56 | wrapper shaping.LineWrapper | |
| 57 | - | never shaping.LineWrapper | |
| 58 | 57 | cache measureCache | |
| 59 | 58 | scratch measureScratch | |
| 60 | 59 | } | |
| @@ -62,15 +61,30 @@ type Engine struct { | |||
| 62 | 61 | // measureScratch holds per-call buffers reused across measure calls. Nothing | |
| 63 | 62 | // in it may be referenced by a returned Layout. | |
| 64 | 63 | 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[:] | |
| 74 | 88 | } | |
| 75 | 89 | ||
| 76 | 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 | 106 | // clusterFaces selects a single face for every UAX#29 extended grapheme. Format | |
| 93 | 107 | // characters (ZWJ and variation selectors) do not need outline coverage; they | |
| 94 | 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 | 111 | selected = slices.Grow(selected[:0], len(text))[:len(text)] | |
| 97 | - | var seg segmenter.Segmenter | |
| 98 | 112 | seg.Init(text) | |
| 99 | 113 | it := seg.GraphemeIterator() | |
| 100 | 114 | for it.Next() { | |
| @@ -173,11 +187,12 @@ func (e *Engine) shape(text []rune, r Request, direction di.Direction, levels [] | |||
| 173 | 187 | if e.catalog.source != nil { | |
| 174 | 188 | // Load named faces only as required for coverage. A failing face is | |
| 175 | 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 | 191 | named := choices | |
| 177 | - | var seg segmenter.Segmenter | |
| 192 | + | seg := &e.scratch.graphemes | |
| 178 | 193 | seg.Init(text) | |
| 179 | 194 | it := seg.GraphemeIterator() | |
| 180 | - | var missing []rune | |
| 195 | + | missing := e.scratch.missing[:0] | |
| 181 | 196 | for it.Next() { | |
| 182 | 197 | cluster := it.Grapheme().Text | |
| 183 | 198 | covered := false | |
| @@ -204,16 +219,16 @@ func (e *Engine) shape(text []rune, r Request, direction di.Direction, levels [] | |||
| 204 | 219 | } | |
| 205 | 220 | // Rebuild after failed loads. The fallback window excludes named | |
| 206 | 221 | // families by key, so it never depends on load or failure state. | |
| 222 | + | e.scratch.missing = missing | |
| 207 | 223 | named = e.catalog.candidates(r) | |
| 208 | - | window := e.catalog.fallbackWindow(missing, r) | |
| 209 | - | choices = nil | |
| 224 | + | choices = e.scratch.choices[:0] | |
| 210 | 225 | for _, f := range named { | |
| 211 | 226 | if f.Shape != nil && !f.bad { | |
| 212 | 227 | choices = append(choices, f) | |
| 213 | 228 | } | |
| 214 | 229 | } | |
| 215 | 230 | if len(missing) != 0 { | |
| 216 | - | for _, f := range window { | |
| 231 | + | for _, f := range e.catalog.fallbackWindow(missing, r) { | |
| 217 | 232 | if !f.ready() { | |
| 218 | 233 | continue | |
| 219 | 234 | } | |
| @@ -246,6 +261,7 @@ func (e *Engine) shape(text []rune, r Request, direction di.Direction, levels [] | |||
| 246 | 261 | choices = append(choices, f) | |
| 247 | 262 | } | |
| 248 | 263 | } | |
| 264 | + | e.scratch.choices = choices | |
| 249 | 265 | } | |
| 250 | 266 | if len(choices) == 0 { | |
| 251 | 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 | 281 | sc.reverse[f.Shape] = f | |
| 266 | 282 | } | |
| 267 | 283 | sc.faces = faces | |
| 268 | - | sc.faceFor = clusterFaces(sc.faceFor, text, faces) | |
| 284 | + | sc.faceFor = clusterFaces(&sc.graphemes, sc.faceFor, text, faces) | |
| 269 | 285 | input := shaping.Input{Text: text, RunEnd: len(text), Direction: direction, Size: fixed.Int26_6(math.Round(r.Size * 64))} | |
| 270 | 286 | sc.items = splitFaces(sc.items, e.seg.Split(input, constantFace{face: faces[0].Shape}), sc.faceFor) | |
| 271 | 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 | 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 | 300 | // Every UAX#9 paragraph resolves its own direction; line baselines stack in logical px. | |
| 286 | 301 | // | |
| 287 | 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 | 341 | if r.Direction > bidi.RTL { | |
| 327 | 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 | 345 | if err != nil { | |
| 331 | 346 | return result, err | |
| 332 | 347 | } | |
| 348 | + | e.scratch.levels = paraLevels | |
| 333 | 349 | direction := di.DirectionLTR | |
| 334 | 350 | if baseLevel%2 == 1 { | |
| 335 | 351 | direction = di.DirectionRTL | |
| @@ -357,30 +373,6 @@ func (e *Engine) measure(s string, r Request, width float64) (Layout, error) { | |||
| 357 | 373 | if len(text) == 0 { | |
| 358 | 374 | 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 | 376 | limit := fixed.Int26_6(1 << 30) | |
| 385 | 377 | if width > 0 { | |
| 386 | 378 | limit = fixed.Int26_6(math.Round(width * 64)) | |
| @@ -41,9 +41,9 @@ type Line struct { | |||||
| 41 | Width, Baseline, Height float64 | 41 | Width, Baseline, Height float64 | ||
| 42 | } | 42 | } | ||
| 43 | type Layout struct { | 43 | type Layout struct { | ||
| 44 | - | Lines []Line | 44 | + | Lines []Line |
| 45 | - | Direction di.Direction // paragraph basis, auto resolved by bidi analysis | 45 | + | Direction di.Direction // paragraph basis, auto resolved by bidi analysis |
| 46 | - | Width, Height, MinContent, MaxContent, Baseline, LineHeight float64 | 46 | + | 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.Segmenter | 54 | seg shaping.Segmenter | ||
| 55 | shaper shaping.HarfbuzzShaper | 55 | shaper shaping.HarfbuzzShaper | ||
| 56 | wrapper shaping.LineWrapper | 56 | wrapper shaping.LineWrapper | ||
| 57 | - | never shaping.LineWrapper | |||
| 58 | cache measureCache | 57 | cache measureCache | ||
| 59 | scratch measureScratch | 58 | scratch measureScratch | ||
| 60 | } | 59 | } | ||
| @@ -62,15 +61,30 @@ type Engine struct { | |||||
| 62 | // measureScratch holds per-call buffers reused across measure calls. Nothing | 61 | // 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 []rune | 64 | + | runes []rune |
| 66 | - | faces []*Face | 65 | + | faces []*Face |
| 67 | - | reverse map[*font.Face]*Face | 66 | + | reverse map[*font.Face]*Face |
| 68 | - | faceFor []*font.Face | 67 | + | faceFor []*font.Face |
| 69 | - | items []shaping.Input | 68 | + | items []shaping.Input |
| 70 | - | levelled []shaping.Input | 69 | + | levelled []shaping.Input |
| 71 | - | outs []shaping.Output | 70 | + | outs []shaping.Output |
| 72 | - | runs shaping.RunIterator | 71 | + | runs shaping.RunIterator |
| 73 | - | visual visualScratch | 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[:] | |||
| 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. Format | 106 | // clusterFaces selects a single face for every UAX#29 extended grapheme. Format | ||
| 93 | // characters (ZWJ and variation selectors) do not need outline coverage; they | 107 | // 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 is | 188 | // 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 := choices | 191 | named := choices | ||
| 177 | - | var seg segmenter.Segmenter | 192 | + | seg := &e.scratch.graphemes |
| 178 | seg.Init(text) | 193 | seg.Init(text) | ||
| 179 | it := seg.GraphemeIterator() | 194 | it := seg.GraphemeIterator() | ||
| 180 | - | var missing []rune | 195 | + | missing := e.scratch.missing[:0] |
| 181 | for it.Next() { | 196 | for it.Next() { | ||
| 182 | cluster := it.Grapheme().Text | 197 | cluster := it.Grapheme().Text | ||
| 183 | covered := false | 198 | 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 named | 220 | // 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 | continue | 233 | 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] = f | 281 | sc.reverse[f.Shape] = f | ||
| 266 | } | 282 | } | ||
| 267 | sc.faces = faces | 283 | 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, err | 346 | return result, err | ||
| 332 | } | 347 | } | ||
| 348 | + | e.scratch.levels = paraLevels | |||
| 333 | direction := di.DirectionLTR | 349 | direction := di.DirectionLTR | ||
| 334 | if baseLevel%2 == 1 { | 350 | if baseLevel%2 == 1 { | ||
| 335 | direction = di.DirectionRTL | 351 | 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, nil | 374 | 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) { | |||
| 40 | 40 | if err != nil { | |
| 41 | 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 | 44 | t.Fatalf("bad metrics: %+v", l) | |
| 45 | 45 | } | |
| 46 | 46 | found, rtl, missing := false, false, false | |
| @@ -90,10 +90,11 @@ func TestScriptsAndMetrics(t *testing.T) { | |||
| 90 | 90 | } | |
| 91 | 91 | wide, _ := e.Measure("one two three four five", r, 220) | |
| 92 | 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 | 94 | t.Errorf("wrap monotonicity: %+v %+v", narrow, wide) | |
| 95 | 95 | } | |
| 96 | 96 | } | |
| 97 | + | ||
| 97 | 98 | func TestCorruptAndMatching(t *testing.T) { | |
| 98 | 99 | if _, err := Load(sourceMap{"broken.ttf": []byte("not a font")}); err == nil { | |
| 99 | 100 | t.Fatal("accepted corrupt font") | |
| @@ -451,21 +452,36 @@ func BenchmarkMeasureMissShort(b *testing.B) { | |||
| 451 | 452 | // allocBaselineMeasureMiss is the measured allocation count of an uncached | |
| 452 | 453 | // short label; most of the remainder is inside go-text segmentation and | |
| 453 | 454 | // wrapping. Lower it with each optimization. | |
| 454 | - | const allocBaselineMeasureMiss = 33 | |
| 455 | + | 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 | 460 | func TestAllocMeasureMissShort(t *testing.T) { | |
| 457 | 461 | if raceEnabled { | |
| 458 | 462 | t.Skip("race instrumentation changes allocation counts") | |
| 459 | 463 | } | |
| 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) | |
| 466 | 467 | } | |
| 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 | + | }) | |
| 470 | 486 | } | |
| 471 | 487 | } | |
| @@ -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, false | 46 | 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 uncached | 452 | // allocBaselineMeasureMiss is the measured allocation count of an uncached | ||
| 452 | // short label; most of the remainder is inside go-text segmentation and | 453 | // 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 = 33 | 455 | + | 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 buffers | 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{}}) |
| 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 ( | |||
| 9 | 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 | 12 | // splitLevels prevents a single shaping run from crossing resolved paragraph | |
| 20 | 13 | // levels (not merely direction parity). The wrapper can subsequently split | |
| 21 | 14 | // further at UAX#14 break opportunities. | |
| @@ -58,6 +51,7 @@ type runItem struct { | |||
| 58 | 51 | // are valid until the next call. | |
| 59 | 52 | type visualScratch struct { | |
| 60 | 53 | breaks []int | |
| 54 | + | levels []uint8 | |
| 61 | 55 | runs []runItem | |
| 62 | 56 | } | |
| 63 | 57 | ||
| @@ -83,10 +77,11 @@ func (v *visualScratch) order(text []rune, line shaping.Line, base bidi.BaseDire | |||
| 83 | 77 | breaks = append(breaks, len(text)) | |
| 84 | 78 | } | |
| 85 | 79 | v.breaks = breaks | |
| 86 | - | levels, _, err := bidi.Resolve(text, base, breaks) | |
| 80 | + | levels, _, err := bidi.ResolveInto(v.levels, text, base, breaks) | |
| 87 | 81 | if err != nil { | |
| 88 | 82 | return nil, err | |
| 89 | 83 | } | |
| 84 | + | v.levels = levels | |
| 90 | 85 | // The wrapper's runs are in logical order. A level change within one output | |
| 91 | 86 | // requires reshaping at that boundary; splitLevels does this before wrapping. | |
| 92 | 87 | 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 paragraph | 12 | // splitLevels prevents a single shaping run from crossing resolved paragraph | ||
| 20 | // levels (not merely direction parity). The wrapper can subsequently split | 13 | // 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 []int | 53 | breaks []int | ||
| 54 | + | levels []uint8 | |||
| 61 | runs []runItem | 55 | 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 = breaks | 79 | 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, err | 82 | return nil, err | ||
| 89 | } | 83 | } | ||
| 84 | + | v.levels = levels | |||
| 90 | // The wrapper's runs are in logical order. A level change within one output | 85 | // 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 line | 87 | clear(v.runs) // drop glyph references from the previous line | ||
M internal/ui/indicator_paint.go +2 -1
| @@ -2,6 +2,7 @@ package ui | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "math" | |
| 5 | + | "slices" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/nefergui/internal/css" | |
| 7 | 8 | "github.com/bnema/nefergui/internal/layout" | |
| @@ -152,5 +153,5 @@ func insertAt(out *layout.Output, at int, commands []layout.Command) { | |||
| 152 | 153 | if at < 0 || len(commands) == 0 { | |
| 153 | 154 | 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 | } | |
| @@ -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 | return | 154 | 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) { | |||
| 103 | 103 | panic("demo frame failed") | |
| 104 | 104 | } | |
| 105 | 105 | }) | |
| 106 | - | const budget = 1100 | |
| 106 | + | const budget = 600 | |
| 107 | 107 | if allocs > budget { | |
| 108 | 108 | t.Fatalf("demo frame allocations: %.0f > %d", allocs, budget) | |
| 109 | 109 | } | |
| @@ -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 = 1100 | 106 | + | 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) { | |||
| 184 | 184 | // allocBaselineRenderer is the measured allocation count of one steady | |
| 185 | 185 | // Render+Released cycle (radv); it is lowered with each optimization until it | |
| 186 | 186 | // reaches the target of 0. | |
| 187 | - | const allocBaselineRenderer = 48 | |
| 187 | + | const allocBaselineRenderer = 22 | |
| 188 | 188 | ||
| 189 | 189 | // TestAllocRendererSteadyFrame measures Render with a view whose number | |
| 190 | 190 | // 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 steady | 184 | // allocBaselineRenderer is the measured allocation count of one steady | ||
| 185 | // Render+Released cycle (radv); it is lowered with each optimization until it | 185 | // 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 = 48 | 187 | + | const allocBaselineRenderer = 22 |
| 188 | 188 | ||||
| 189 | // TestAllocRendererSteadyFrame measures Render with a view whose number | 189 | // TestAllocRendererSteadyFrame measures Render with a view whose number | ||
| 190 | // changes on every frame and an unchanged structure. It runs on the real GPU | 190 | // 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 { | |||
| 36 | 36 | committedArena int | |
| 37 | 37 | styles *css.Engine | |
| 38 | 38 | output layout.Output | |
| 39 | + | layouts [2]layout.Arena // one holds output, the other builds the next | |
| 40 | + | outputArena int | |
| 39 | 41 | state interactionState | |
| 40 | 42 | width, height, scale float64 | |
| 41 | 43 | textEngine *text.Engine | |
| @@ -149,7 +151,11 @@ func (r *runtime) Build(view func(*Frame)) bool { | |||
| 149 | 151 | if f.root != nil { | |
| 150 | 152 | f.refresh(f.root) | |
| 151 | 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 | 159 | if err != nil { | |
| 154 | 160 | r.mu.Unlock() | |
| 155 | 161 | r.Redraw() | |
| @@ -157,9 +163,10 @@ func (r *runtime) Build(view func(*Frame)) bool { | |||
| 157 | 163 | } | |
| 158 | 164 | // Re-layout at most once after moving a focused caret into its editor | |
| 159 | 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 | 167 | if r.scrollCaret(f.root, &out) { | |
| 161 | 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 | 170 | if err != nil { | |
| 164 | 171 | r.mu.Unlock() | |
| 165 | 172 | r.Redraw() | |
| @@ -168,7 +175,8 @@ func (r *runtime) Build(view func(*Frame)) bool { | |||
| 168 | 175 | } | |
| 169 | 176 | r.paintEditors(f.root, &out) | |
| 170 | 177 | 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 | |
| 172 | 180 | } | |
| 173 | 181 | r.committed, r.committedArena = f.root, next | |
| 174 | 182 | // Events mutate the model while the view runs, so elements declared before | |
| @@ -36,6 +36,8 @@ type runtime struct { | |||||
| 36 | committedArena int | 36 | committedArena int | ||
| 37 | styles *css.Engine | 37 | styles *css.Engine | ||
| 38 | output layout.Output | 38 | output layout.Output | ||
| 39 | + | layouts [2]layout.Arena // one holds output, the other builds the next | |||
| 40 | + | outputArena int | |||
| 39 | state interactionState | 41 | state interactionState | ||
| 40 | width, height, scale float64 | 42 | width, height, scale float64 | ||
| 41 | textEngine *text.Engine | 43 | 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 editor | 164 | // 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 = out | 178 | + | 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, next | 181 | r.committed, r.committedArena = f.root, next | ||
| 174 | // Events mutate the model while the view runs, so elements declared before | 182 | // 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) { | |||
| 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 | 185 | func TestConcurrentBuildDefersCommit(t *testing.T) { | |
| 103 | 186 | r := newRuntime() | |
| 104 | 187 | 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{}) | ||