Merge pull request #2 from bnema/feat/gui-workspace-and-performance
feat(gui): add desktop workspace, rendering fixes and allocation improvements
32 files · +1799 -223
M docs/performance.md +65 -29
| @@ -2,53 +2,89 @@ | |||
| 2 | 2 | ||
| 3 | 3 | ## CPU budgets | |
| 4 | 4 | ||
| 5 | - | AMD Ryzen 9 7900X3D 12-Core Processor; `CGO_ENABLED=0 GOWORK=off go test -run '^$' -bench . -benchmem -count=5` in the indicated packages. Entries are medians of five runs. Budgets are approximately twice the measured medians (zero allocations remain zero). CPU numbers vary with machine load. The demo-frame benchmark builds a headless home-view tree, computes layout and produces a display list after warmup; Prepare uses a representative warm text/box command mix and excludes GPU submission. | |
| 5 | + | Measured: **2026-09-30, 07:52–07:54 CEST (UTC+02:00)** on commit `7ad3316fd1add28c1507e28c36883afaeaaad434`. | |
| 6 | 6 | ||
| 7 | - | | Package / benchmark | Median ns/op | Median B/op | Median allocs/op | Budget ns/op | Budget B/op | Budget allocs/op | | |
| 8 | - | |---|---:|---:|---:|---:|---:|---:| | |
| 9 | - | | internal/ui / DemoFrame | 21,238 | 32,816 | 220 | 45,000 | 66,000 | 500 | | |
| 10 | - | | render / Prepare | 59,024 | 158,720 | 386 | 125,000 | 320,000 | 800 | | |
| 11 | - | | text / AtlasWarmLookup | 29.07 | 0 | 0 | 60 | 0 | 0 | | |
| 12 | - | | text / RasterDistinct1000 | 4,139,638 | 8,566,137 | 16,784 | 8,300,000 | 17,200,000 | 34,000 | | |
| 13 | - | | text / ShapeParagraph (uncached) | 226,223 | 300,146 | 950 | 460,000 | 600,000 | 1,900 | | |
| 14 | - | | text / ShapeParagraphCached | 78.16 | 0 | 0 | 160 | 0 | 0 | | |
| 15 | - | | text / AtlasInsert | 508.3 | 966 | 3 | 1,050 | 2,000 | 6 | | |
| 16 | - | | css / Engine/cold | 2,310,093 | 1,956,258 | 5,608 | 4,700,000 | 4,000,000 | 11,300 | | |
| 17 | - | | css / Engine/warm | 95,553 | 1 | 0 | 195,000 | 2 | 0 | | |
| 18 | - | | css / Engine/state-change | 334,293 | 149,130 | 363 | 680,000 | 300,000 | 750 | | |
| 19 | - | | layout / Layout1000 | 623,154 | 731,626 | 1,027 | 1,250,000 | 1,470,000 | 2,100 | | |
| 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. | |
| 20 | 8 | ||
| 21 | - | `TestDemoFrameAllocationBudget` checks the 500 allocs/op limit with `testing.AllocsPerRun`. | |
| 9 | + | The demo-frame benchmark builds the light, comfortable document workspace with the example's actual stylesheet, 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. | |
| 22 | 10 | ||
| 23 | - | Frame construction stays low-allocation because: | |
| 11 | + | | Package / benchmark | Median ns/op | Median B/op | Median allocs/op | Budget ns/op | Budget B/op | Budget allocs/op | | |
| 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 | + | `TestDemoFrameAllocationBudget` checks the 1,100 allocs/op limit with `testing.AllocsPerRun`. 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 allocation test is opt-in via `NEFERGUI_RENDER_NODE`. The other table budgets are reference targets, not automated assertions. | |
| 33 | + | ||
| 34 | + | Frame construction limits allocation churn through: | |
| 35 | + | ||
| 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; | |
| 38 | + | - a session-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; | |
| 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. | |
| 41 | + | ||
| 42 | + | `Run` builds a frame only on input or a redraw request. Idle frame construction stops. Expected release-wait timeouts return without constructing errors; real ioctl failures retain their context and wrapped errno. | |
| 43 | + | ||
| 44 | + | On commit `9064a73`, measured **2026-09-30, 06:34 CEST**, `BenchmarkWaitPointIdle` on an unsignaled real DRM timeline took a median **21,370,548 ns/op, 0 B/op, 0 allocs/op**. This includes the requested 20 ms timeout, not active CPU time: | |
| 24 | 45 | ||
| 25 | - | - font family names are normalized once when fonts load, not per face on every match; | |
| 26 | - | - `text.Engine.Measure` caches results by text, request and width; entries unused for two frames are evicted and the cache holds at most 4,096 entries (callers treat results as immutable and use `Layout.Clone` before adjusting positions); | |
| 27 | - | - identity path keys are computed once per element. | |
| 46 | + | ```sh | |
| 47 | + | NEFERGUI_RENDER_NODE=/dev/dri/renderD128 CGO_ENABLED=0 GOWORK=off \ | |
| 48 | + | go test -run '^$' -bench BenchmarkWaitPointIdle -benchtime=10x -benchmem -count=5 ./internal/presentation/syncobj | |
| 49 | + | ``` | |
| 28 | 50 | ||
| 29 | - | `Run` builds a frame only on input or a redraw request. Idle frame construction stops, but platform release-wait polling can still allocate small amounts. Profile with: | |
| 51 | + | Profile frame construction with: | |
| 30 | 52 | ||
| 31 | 53 | ```sh | |
| 32 | 54 | go test -run '^$' -bench BenchmarkDemoFrame -benchmem -memprofile mem.out -memprofilerate=1 ./internal/ui | |
| 33 | 55 | go tool pprof -sample_index=alloc_objects -top -cum mem.out | |
| 34 | 56 | ``` | |
| 35 | 57 | ||
| 36 | - | A 400-frame demo harness run (960×640, scale 1, input: name Ada then continue) recorded `submit_to_commit_ns` median **184,545 ns** and nearest-rank p95 **481,550 ns** in `frame-timings.jsonl`. This is host-side submission-to-Wayland-commit latency, not GPU execution time. `atlas_pages` stayed at **1** allocated 1024×1024 grayscale page (maximum 4) for all 400 frames. These figures depend on the compositor, driver and machine load. | |
| 37 | - | ||
| 38 | 58 | ## Memory as a dependency | |
| 39 | 59 | ||
| 40 | - | An external Go module with a minimal counter view, a 960×640 window, and a private headless NeferWL compositor was profiled on AMD/RADV. Baseline: NeferGUI `068ba85`, Go 1.27.1, Mesa/RADV 26.2.3, and NeferWL `1d16b83bf90f9e8189ad21b27772382c96b9f9a8`. A minimal-consumer repeat at `84c1f24` matched retained heap within 0.02 MiB and used identical GPU allocations. Debug readbacks were disabled. Live Go heap was sampled after forced GC; RSS includes shared driver libraries, while PSS apportions shared pages. | |
| 60 | + | These external profiles use the earlier minimal and simplified demo fixtures, not the desktop document workspace measured above. They describe library allocation behavior at the pinned commit, not the current example's full memory footprint. | |
| 61 | + | ||
| 62 | + | Measured: **2026-09-30, 06:34–06:36 CEST (UTC+02:00)**. Measured commit: **`9064a730c898b48f967b3f2228fb705474b4fdeb`**. Comparison baseline: **`1488e4a390e93603976ae3f5e9ac3fdb1bd00c1a`**, measured **2026-09-30, 06:06–06:10 CEST**. | |
| 63 | + | ||
| 64 | + | An external Go module with minimal-counter and demo views, a 960×640 window at scale 1, and a private headless NeferWL compositor was profiled on AMD/RADV. Stack: Go `go1.27.1-X:nodwarf5 linux/amd64`, Mesa/RADV 26.2.3, NeferWL `1d16b83bf90f9e8189ad21b27772382c96b9f9a8`. Debug readbacks were disabled. Live Go heap was sampled after forced GC; RSS includes shared driver libraries, while PSS apportions shared pages. | |
| 41 | 65 | ||
| 42 | 66 | | Measurement | Minimal view | Demo view | | |
| 43 | 67 | |---|---:|---:| | |
| 44 | - | | Live Go heap after 1,000 frames | 5.3 MiB | 5.4 MiB | | |
| 45 | - | | Process RSS / PSS after 1,000 frames | 60 / 43 MiB | 64 / 47 MiB | | |
| 46 | - | | Estimated temporary allocations per rendered frame, measurement sampler's own allocations excluded | about 65 KiB | about 230 KiB | | |
| 68 | + | | Live Go heap after 1,000 frames | 5.19 MiB | 5.32 MiB | | |
| 69 | + | | Baseline live Go heap after 1,000 frames | 5.17 MiB | 5.27 MiB | | |
| 70 | + | | Live Go heap after 3,000 frames | 5.23 MiB | 5.34 MiB | | |
| 71 | + | | Baseline live Go heap after 3,000 frames | 5.20 MiB | 5.31 MiB | | |
| 72 | + | | Process RSS / PSS after 1,000 frames | 65 / 42 MiB | 59 / 40 MiB | | |
| 73 | + | | Baseline process RSS / PSS after 1,000 frames | 63 / 40 MiB | 60 / 41 MiB | | |
| 74 | + | | GPU VRAM / GTT with three buffers, current and baseline | 41 / 4 MiB | 41 / 4 MiB | | |
| 75 | + | | Temporary allocations per rendered frame | about 35.5 KiB | about 136 KiB | | |
| 76 | + | | Baseline temporary allocations per rendered frame | about 61 KiB | about 238 KiB | | |
| 77 | + | ||
| 78 | + | Heap/RSS/PSS use the default Go profiling rate. Allocation figures use separate `GODEBUG=memprofilerate=1` runs: subtract cumulative profiles at frames 1 and 1,000, exclude stacks containing the measurement sampler or `runtime/pprof`, sum the remaining flat allocated bytes, then divide by 999 frames. These runs recorded about **41–43% less allocation churn** than the baseline. They do not demonstrate an equivalent reduction in RSS or frame latency. Single-run process-memory values vary with runtime and driver behavior. | |
| 79 | + | ||
| 80 | + | The minimal view starts at about 0.75 MiB live Go heap, reaches 5.1 MiB after the first frame, and returns to about 0.9 MiB after closing. No sustained live-heap growth was observed over 3,000 frames. Retained heap stayed close to baseline; the session's reusable conversion buffers retain a small amount of storage. Driver mappings and Go heap capacity can remain resident after closing; RSS is not a leak measurement on its own. | |
| 81 | + | ||
| 82 | + | 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. | |
| 47 | 83 | ||
| 48 | - | The minimal view starts at about 0.75 MiB live Go heap, reaches 5.2 MiB after the first frame, and returns to about 0.9 MiB after closing. No sustained live-heap growth was observed over 1,000–3,000 frames. Driver mappings and Go heap capacity can remain resident after closing; RSS is not a leak measurement on its own. | |
| 84 | + | A separate approximately 20-second idle profile recorded about **91 KiB** allocated under `syncobj.WaitPoint` in the baseline and **no allocations attributed to that path** on the measured implementation. Other runtime and consumer instrumentation allocations remain; this is not a claim that the whole process allocates nothing at idle. | |
| 49 | 85 | ||
| 50 | - | DRM counters reported about 41 MiB VRAM and 4 MiB GTT once all three presentation buffers were active. Window dimensions, scale, fonts and driver affect these figures. These are measurements on one stack, not portable limits. | |
| 86 | + | The measured commit includes `purego-vulkan` v0.6.0, `wlturbo` v0.3.0 and `typesetting` v0.3.5. The subsequent `2e4532d` commit adds an empty-glyph regression test and raises the minimum compositor version checked at startup; it does not change the measured frame paths on this version-6 compositor. The comparison therefore includes both library optimizations and dependency updates, not isolated effects for each. | |
| 51 | 87 | ||
| 52 | - | The largest frame-allocation sources are prepared quads, render batches, and layout painting. Release-wait polling allocated about 150 KiB over a 38-second idle interval. `NEFERGUI_DEBUG_DIR` enables readbacks and PNG work and raised observed peak RSS to about 113 MiB; leave it unset when measuring ordinary application cost. | |
| 88 | + | 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. `NEFERGUI_DEBUG_DIR` enables readbacks and PNG work; leave it unset when measuring ordinary application cost. | |
| 53 | 89 | ||
| 54 | 90 | 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,53 +2,89 @@ | |||||
| 2 | 2 | ||||
| 3 | ## CPU budgets | 3 | ## CPU budgets | ||
| 4 | 4 | ||||
| 5 | - | AMD Ryzen 9 7900X3D 12-Core Processor; `CGO_ENABLED=0 GOWORK=off go test -run '^$' -bench . -benchmem -count=5` in the indicated packages. Entries are medians of five runs. Budgets are approximately twice the measured medians (zero allocations remain zero). CPU numbers vary with machine load. The demo-frame benchmark builds a headless home-view tree, computes layout and produces a display list after warmup; Prepare uses a representative warm text/box command mix and excludes GPU submission. | 5 | + | Measured: **2026-09-30, 07:52–07:54 CEST (UTC+02:00)** on commit `7ad3316fd1add28c1507e28c36883afaeaaad434`. |
| 6 | 6 | ||||
| 7 | - | | Package / benchmark | Median ns/op | Median B/op | Median allocs/op | Budget ns/op | Budget B/op | Budget allocs/op | | 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. |
| 8 | - | |---|---:|---:|---:|---:|---:|---:| | |||
| 9 | - | | internal/ui / DemoFrame | 21,238 | 32,816 | 220 | 45,000 | 66,000 | 500 | | |||
| 10 | - | | render / Prepare | 59,024 | 158,720 | 386 | 125,000 | 320,000 | 800 | | |||
| 11 | - | | text / AtlasWarmLookup | 29.07 | 0 | 0 | 60 | 0 | 0 | | |||
| 12 | - | | text / RasterDistinct1000 | 4,139,638 | 8,566,137 | 16,784 | 8,300,000 | 17,200,000 | 34,000 | | |||
| 13 | - | | text / ShapeParagraph (uncached) | 226,223 | 300,146 | 950 | 460,000 | 600,000 | 1,900 | | |||
| 14 | - | | text / ShapeParagraphCached | 78.16 | 0 | 0 | 160 | 0 | 0 | | |||
| 15 | - | | text / AtlasInsert | 508.3 | 966 | 3 | 1,050 | 2,000 | 6 | | |||
| 16 | - | | css / Engine/cold | 2,310,093 | 1,956,258 | 5,608 | 4,700,000 | 4,000,000 | 11,300 | | |||
| 17 | - | | css / Engine/warm | 95,553 | 1 | 0 | 195,000 | 2 | 0 | | |||
| 18 | - | | css / Engine/state-change | 334,293 | 149,130 | 363 | 680,000 | 300,000 | 750 | | |||
| 19 | - | | layout / Layout1000 | 623,154 | 731,626 | 1,027 | 1,250,000 | 1,470,000 | 2,100 | | |||
| 20 | 8 | ||||
| 21 | - | `TestDemoFrameAllocationBudget` checks the 500 allocs/op limit with `testing.AllocsPerRun`. | 9 | + | The demo-frame benchmark builds the light, comfortable document workspace with the example's actual stylesheet, 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. |
| 22 | 10 | ||||
| 23 | - | Frame construction stays low-allocation because: | 11 | + | | Package / benchmark | Median ns/op | Median B/op | Median allocs/op | Budget ns/op | Budget B/op | Budget allocs/op | |
| 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 | + | `TestDemoFrameAllocationBudget` checks the 1,100 allocs/op limit with `testing.AllocsPerRun`. 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 allocation test is opt-in via `NEFERGUI_RENDER_NODE`. The other table budgets are reference targets, not automated assertions. | |||
| 33 | + | ||||
| 34 | + | Frame construction limits allocation churn through: | |||
| 35 | + | ||||
| 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; | |||
| 38 | + | - a session-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; | |||
| 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. | |||
| 41 | + | ||||
| 42 | + | `Run` builds a frame only on input or a redraw request. Idle frame construction stops. Expected release-wait timeouts return without constructing errors; real ioctl failures retain their context and wrapped errno. | |||
| 43 | + | ||||
| 44 | + | On commit `9064a73`, measured **2026-09-30, 06:34 CEST**, `BenchmarkWaitPointIdle` on an unsignaled real DRM timeline took a median **21,370,548 ns/op, 0 B/op, 0 allocs/op**. This includes the requested 20 ms timeout, not active CPU time: | |||
| 24 | 45 | ||||
| 25 | - | - font family names are normalized once when fonts load, not per face on every match; | 46 | + | ```sh |
| 26 | - | - `text.Engine.Measure` caches results by text, request and width; entries unused for two frames are evicted and the cache holds at most 4,096 entries (callers treat results as immutable and use `Layout.Clone` before adjusting positions); | 47 | + | NEFERGUI_RENDER_NODE=/dev/dri/renderD128 CGO_ENABLED=0 GOWORK=off \ |
| 27 | - | - identity path keys are computed once per element. | 48 | + | go test -run '^$' -bench BenchmarkWaitPointIdle -benchtime=10x -benchmem -count=5 ./internal/presentation/syncobj |
| 49 | + | ``` | |||
| 28 | 50 | ||||
| 29 | - | `Run` builds a frame only on input or a redraw request. Idle frame construction stops, but platform release-wait polling can still allocate small amounts. Profile with: | 51 | + | Profile frame construction with: |
| 30 | 52 | ||||
| 31 | ```sh | 53 | ```sh | ||
| 32 | go test -run '^$' -bench BenchmarkDemoFrame -benchmem -memprofile mem.out -memprofilerate=1 ./internal/ui | 54 | go test -run '^$' -bench BenchmarkDemoFrame -benchmem -memprofile mem.out -memprofilerate=1 ./internal/ui | ||
| 33 | go tool pprof -sample_index=alloc_objects -top -cum mem.out | 55 | go tool pprof -sample_index=alloc_objects -top -cum mem.out | ||
| 34 | ``` | 56 | ``` | ||
| 35 | 57 | ||||
| 36 | - | A 400-frame demo harness run (960×640, scale 1, input: name Ada then continue) recorded `submit_to_commit_ns` median **184,545 ns** and nearest-rank p95 **481,550 ns** in `frame-timings.jsonl`. This is host-side submission-to-Wayland-commit latency, not GPU execution time. `atlas_pages` stayed at **1** allocated 1024×1024 grayscale page (maximum 4) for all 400 frames. These figures depend on the compositor, driver and machine load. | |||
| 37 | - | ||||
| 38 | ## Memory as a dependency | 58 | ## Memory as a dependency | ||
| 39 | 59 | ||||
| 40 | - | An external Go module with a minimal counter view, a 960×640 window, and a private headless NeferWL compositor was profiled on AMD/RADV. Baseline: NeferGUI `068ba85`, Go 1.27.1, Mesa/RADV 26.2.3, and NeferWL `1d16b83bf90f9e8189ad21b27772382c96b9f9a8`. A minimal-consumer repeat at `84c1f24` matched retained heap within 0.02 MiB and used identical GPU allocations. Debug readbacks were disabled. Live Go heap was sampled after forced GC; RSS includes shared driver libraries, while PSS apportions shared pages. | 60 | + | These external profiles use the earlier minimal and simplified demo fixtures, not the desktop document workspace measured above. They describe library allocation behavior at the pinned commit, not the current example's full memory footprint. |
| 61 | + | ||||
| 62 | + | Measured: **2026-09-30, 06:34–06:36 CEST (UTC+02:00)**. Measured commit: **`9064a730c898b48f967b3f2228fb705474b4fdeb`**. Comparison baseline: **`1488e4a390e93603976ae3f5e9ac3fdb1bd00c1a`**, measured **2026-09-30, 06:06–06:10 CEST**. | |||
| 63 | + | ||||
| 64 | + | An external Go module with minimal-counter and demo views, a 960×640 window at scale 1, and a private headless NeferWL compositor was profiled on AMD/RADV. Stack: Go `go1.27.1-X:nodwarf5 linux/amd64`, Mesa/RADV 26.2.3, NeferWL `1d16b83bf90f9e8189ad21b27772382c96b9f9a8`. Debug readbacks were disabled. Live Go heap was sampled after forced GC; RSS includes shared driver libraries, while PSS apportions shared pages. | |||
| 41 | 65 | ||||
| 42 | | Measurement | Minimal view | Demo view | | 66 | | Measurement | Minimal view | Demo view | | ||
| 43 | |---|---:|---:| | 67 | |---|---:|---:| | ||
| 44 | - | | Live Go heap after 1,000 frames | 5.3 MiB | 5.4 MiB | | 68 | + | | Live Go heap after 1,000 frames | 5.19 MiB | 5.32 MiB | |
| 45 | - | | Process RSS / PSS after 1,000 frames | 60 / 43 MiB | 64 / 47 MiB | | 69 | + | | Baseline live Go heap after 1,000 frames | 5.17 MiB | 5.27 MiB | |
| 46 | - | | Estimated temporary allocations per rendered frame, measurement sampler's own allocations excluded | about 65 KiB | about 230 KiB | | 70 | + | | Live Go heap after 3,000 frames | 5.23 MiB | 5.34 MiB | |
| 71 | + | | Baseline live Go heap after 3,000 frames | 5.20 MiB | 5.31 MiB | | |||
| 72 | + | | Process RSS / PSS after 1,000 frames | 65 / 42 MiB | 59 / 40 MiB | | |||
| 73 | + | | Baseline process RSS / PSS after 1,000 frames | 63 / 40 MiB | 60 / 41 MiB | | |||
| 74 | + | | GPU VRAM / GTT with three buffers, current and baseline | 41 / 4 MiB | 41 / 4 MiB | | |||
| 75 | + | | Temporary allocations per rendered frame | about 35.5 KiB | about 136 KiB | | |||
| 76 | + | | Baseline temporary allocations per rendered frame | about 61 KiB | about 238 KiB | | |||
| 77 | + | ||||
| 78 | + | Heap/RSS/PSS use the default Go profiling rate. Allocation figures use separate `GODEBUG=memprofilerate=1` runs: subtract cumulative profiles at frames 1 and 1,000, exclude stacks containing the measurement sampler or `runtime/pprof`, sum the remaining flat allocated bytes, then divide by 999 frames. These runs recorded about **41–43% less allocation churn** than the baseline. They do not demonstrate an equivalent reduction in RSS or frame latency. Single-run process-memory values vary with runtime and driver behavior. | |||
| 79 | + | ||||
| 80 | + | The minimal view starts at about 0.75 MiB live Go heap, reaches 5.1 MiB after the first frame, and returns to about 0.9 MiB after closing. No sustained live-heap growth was observed over 3,000 frames. Retained heap stayed close to baseline; the session's reusable conversion buffers retain a small amount of storage. Driver mappings and Go heap capacity can remain resident after closing; RSS is not a leak measurement on its own. | |||
| 81 | + | ||||
| 82 | + | 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. | |||
| 47 | 83 | ||||
| 48 | - | The minimal view starts at about 0.75 MiB live Go heap, reaches 5.2 MiB after the first frame, and returns to about 0.9 MiB after closing. No sustained live-heap growth was observed over 1,000–3,000 frames. Driver mappings and Go heap capacity can remain resident after closing; RSS is not a leak measurement on its own. | 84 | + | A separate approximately 20-second idle profile recorded about **91 KiB** allocated under `syncobj.WaitPoint` in the baseline and **no allocations attributed to that path** on the measured implementation. Other runtime and consumer instrumentation allocations remain; this is not a claim that the whole process allocates nothing at idle. |
| 49 | 85 | ||||
| 50 | - | DRM counters reported about 41 MiB VRAM and 4 MiB GTT once all three presentation buffers were active. Window dimensions, scale, fonts and driver affect these figures. These are measurements on one stack, not portable limits. | 86 | + | The measured commit includes `purego-vulkan` v0.6.0, `wlturbo` v0.3.0 and `typesetting` v0.3.5. The subsequent `2e4532d` commit adds an empty-glyph regression test and raises the minimum compositor version checked at startup; it does not change the measured frame paths on this version-6 compositor. The comparison therefore includes both library optimizations and dependency updates, not isolated effects for each. |
| 51 | 87 | ||||
| 52 | - | The largest frame-allocation sources are prepared quads, render batches, and layout painting. Release-wait polling allocated about 150 KiB over a 38-second idle interval. `NEFERGUI_DEBUG_DIR` enables readbacks and PNG work and raised observed peak RSS to about 113 MiB; leave it unset when measuring ordinary application cost. | 88 | + | 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. `NEFERGUI_DEBUG_DIR` enables readbacks and PNG work; leave it unset when measuring ordinary application cost. |
| 53 | 89 | ||||
| 54 | 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. | 90 | 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 examples/demo/app.css +71 -18
| @@ -1,19 +1,72 @@ | |||
| 1 | - | app.app { background-color: #f5f7fa; color: #18253b; padding: 20px; } | |
| 2 | - | header.header { background-color: #dae5f5; padding: 14px; height: 130px; } | |
| 3 | - | heading.logo { color: #203d7a; font-size: 32px; } | |
| 4 | - | nav.menu { display: flex; flex-direction: row; column-gap: 8px; padding: 8px; } | |
| 5 | - | button.menu-item { background-color: #aabbd8; padding: 6px 12px; } | |
| 6 | - | button.menu-item:hover { background-color: #bccae2; } | |
| 7 | - | button.menu-item.active { background-color: #8fa6cf; } | |
| 8 | - | main.content { padding: 24px; } | |
| 9 | - | section.welcome { background-color: #ffffff; padding: 20px; } | |
| 10 | - | heading { color: #203d7a; font-size: 28px; } | |
| 11 | - | input { background-color: #f1f5fc; border: 2px solid #5675a4; padding: 8px; width: 300px; height: 42px; } | |
| 12 | - | row.actions { padding: 12px; } | |
| 13 | - | button.primary { background-color: #2357bb; color: #ffffff; padding: 13px 10px; width: 160px; } | |
| 14 | - | button.primary:hover { background-color: #1b4699; } | |
| 15 | - | button.primary:disabled { background-color: #7594c9; } | |
| 16 | - | input:hover { border-color: #203d7a; } | |
| 1 | + | /* Neutral desktop surfaces; tokens keep both themes on the same hierarchy. */ | |
| 2 | + | app.app { | |
| 3 | + | --canvas: #fafafa; --surface: #ffffff; --sidebar: #f4f4f5; | |
| 4 | + | --ink: #18181b; --secondary: #52525b; --quiet: #71717a; | |
| 5 | + | --line: #e4e4e7; --hover: #ededf0; --selected: #e4e4e7; | |
| 6 | + | --control: #fafafa; --action: #27272a; --action-ink: #ffffff; | |
| 7 | + | --disabled: #d4d4d8; --disabled-ink: #52525b; | |
| 8 | + | display: flex; flex-direction: column; width: 100%; height: 100%; | |
| 9 | + | box-sizing: border-box; | |
| 10 | + | background: var(--canvas); color: var(--ink); font-size: 14px; line-height: 1.4; | |
| 11 | + | accent-color: #52525b; | |
| 12 | + | } | |
| 13 | + | app.app.dark { | |
| 14 | + | --canvas: #18181b; --surface: #202023; --sidebar: #1c1c1f; | |
| 15 | + | --ink: #f4f4f5; --secondary: #d4d4d8; --quiet: #a1a1aa; | |
| 16 | + | --line: #36363c; --hover: #303035; --selected: #36363c; | |
| 17 | + | --control: #18181b; --action: #e4e4e7; --action-ink: #18181b; | |
| 18 | + | --disabled: #36363c; --disabled-ink: #a1a1aa; | |
| 19 | + | accent-color: #a1a1aa; | |
| 20 | + | } | |
| 21 | + | app, header, footer, aside, main, section, row, nav, button, input { box-sizing: border-box; } | |
| 22 | + | header.header { | |
| 23 | + | display: flex; flex-direction: row; align-items: center; column-gap: 20px; | |
| 24 | + | height: 48px; flex-shrink: 0; padding: 6px 12px; | |
| 25 | + | background: var(--surface); border-bottom: 1px solid var(--line); | |
| 26 | + | } | |
| 27 | + | heading { font-size: 24px; font-weight: 600; margin: 0; color: var(--ink); } | |
| 28 | + | heading.logo { font-size: 18px; font-weight: 700; } | |
| 29 | + | section heading { font-size: 16px; } | |
| 30 | + | text.muted { color: var(--secondary); } | |
| 31 | + | text.hint { color: var(--quiet); font-size: 12px; } | |
| 32 | + | text.eyebrow { color: var(--quiet); font-size: 11px; font-weight: 600; letter-spacing: 1px; } | |
| 33 | + | spacer.stretch { flex-grow: 1; min-height: 0; min-width: 0; } | |
| 34 | + | row.body { flex-grow: 1; min-height: 0; height: 100%; align-items: stretch; } | |
| 35 | + | aside.sidebar { | |
| 36 | + | display: flex; flex-direction: column; width: 200px; flex-shrink: 0; | |
| 37 | + | padding: 24px 12px 20px; background: var(--sidebar); border-right: 1px solid var(--line); | |
| 38 | + | } | |
| 39 | + | aside.sidebar > text { margin-left: 12px; } | |
| 40 | + | nav.menu { display: flex; flex-direction: column; row-gap: 4px; margin-top: 16px; flex-shrink: 0; height: 84px; } | |
| 41 | + | button { color: var(--ink); background: var(--surface); border: 1px solid var(--line); border-radius: 6px; padding: 8px 12px; } | |
| 42 | + | button:hover { background: var(--hover); } | |
| 43 | + | button:active { background: var(--selected); } | |
| 44 | + | button:focus-visible, input:focus-visible, radio:focus-visible, slider:focus-visible { | |
| 45 | + | outline: 2px solid var(--quiet); outline-offset: 2px; | |
| 46 | + | } | |
| 47 | + | button.menu-item { height: 40px; flex-shrink: 0; width: 176px; text-align: left; border: 1px solid transparent; padding: 10px 12px; background: transparent; } | |
| 48 | + | button.menu-item:hover { background: var(--hover); } | |
| 49 | + | button.menu-item.active { background: var(--selected); font-weight: 600; } | |
| 50 | + | button.theme-toggle { width: 104px; font-size: 13px; } | |
| 51 | + | main.content { flex-grow: 1; min-width: 0; padding: 0; overflow: auto; } | |
| 52 | + | row.workspace { height: 100%; align-items: stretch; } | |
| 53 | + | column.editor-pane { flex-grow: 1; flex-basis: 0px; min-width: 0; background: var(--surface); } | |
| 54 | + | row.pane-bar { width: 100%; height: 36px; flex-shrink: 0; align-items: center; padding: 8px 16px; border-bottom: 1px solid var(--line); background: var(--canvas); } | |
| 55 | + | text.document-title { font-size: 13px; font-weight: 600; } | |
| 56 | + | textarea.document-editor { flex-grow: 1; min-height: 0; padding: 20px; background: var(--surface); color: var(--ink); font-size: 14px; line-height: 1.6; } | |
| 57 | + | aside.inspector { display: flex; flex-direction: column; width: 240px; flex-shrink: 0; padding: 16px; border-left: 1px solid var(--line); background: var(--sidebar); } | |
| 58 | + | aside.inspector input, aside.inspector slider { width: 208px; } | |
| 59 | + | aside.inspector button { margin-top: 8px; height: 32px; } | |
| 60 | + | text.field-label { margin-top: 16px; margin-bottom: 8px; font-size: 13px; font-weight: 600; } | |
| 61 | + | separator { background: var(--line); margin-top: 16px; margin-bottom: 4px; flex-shrink: 0; } | |
| 62 | + | button:disabled { background: var(--disabled); color: var(--disabled-ink); } | |
| 63 | + | column.preferences-pane { padding: 16px 24px; row-gap: 8px; } | |
| 64 | + | input { background: var(--control); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: 8px 12px; width: 320px; height: 40px; } | |
| 65 | + | input:hover { border-color: var(--quiet); } | |
| 66 | + | text.sidebar-note { color: var(--secondary); font-size: 12px; margin-bottom: 4px; } | |
| 17 | 67 | radio { padding-top: 6px; padding-bottom: 6px; } | |
| 18 | - | slider { width: 300px; margin-top: 12px; } | |
| 19 | - | footer.status-bar { background-color: #dae5f5; padding: 12px; height: 42px; } | |
| 68 | + | slider { width: 320px; margin-top: 8px; } | |
| 69 | + | footer.status-bar { display: flex; flex-direction: row; align-items: center; height: 28px; flex-shrink: 0; padding: 4px 12px; background: var(--surface); border-top: 1px solid var(--line); color: var(--secondary); font-size: 12px; } | |
| 70 | + | app.app.compact aside.inspector { padding-top: 8px; } | |
| 71 | + | app.app.compact textarea.document-editor { padding: 12px; } | |
| 72 | + | app.app.compact button.menu-item { padding-top: 6px; padding-bottom: 6px; } | |
| @@ -1,19 +1,72 @@ | |||||
| 1 | - | app.app { background-color: #f5f7fa; color: #18253b; padding: 20px; } | 1 | + | /* Neutral desktop surfaces; tokens keep both themes on the same hierarchy. */ |
| 2 | - | header.header { background-color: #dae5f5; padding: 14px; height: 130px; } | 2 | + | app.app { |
| 3 | - | heading.logo { color: #203d7a; font-size: 32px; } | 3 | + | --canvas: #fafafa; --surface: #ffffff; --sidebar: #f4f4f5; |
| 4 | - | nav.menu { display: flex; flex-direction: row; column-gap: 8px; padding: 8px; } | 4 | + | --ink: #18181b; --secondary: #52525b; --quiet: #71717a; |
| 5 | - | button.menu-item { background-color: #aabbd8; padding: 6px 12px; } | 5 | + | --line: #e4e4e7; --hover: #ededf0; --selected: #e4e4e7; |
| 6 | - | button.menu-item:hover { background-color: #bccae2; } | 6 | + | --control: #fafafa; --action: #27272a; --action-ink: #ffffff; |
| 7 | - | button.menu-item.active { background-color: #8fa6cf; } | 7 | + | --disabled: #d4d4d8; --disabled-ink: #52525b; |
| 8 | - | main.content { padding: 24px; } | 8 | + | display: flex; flex-direction: column; width: 100%; height: 100%; |
| 9 | - | section.welcome { background-color: #ffffff; padding: 20px; } | 9 | + | box-sizing: border-box; |
| 10 | - | heading { color: #203d7a; font-size: 28px; } | 10 | + | background: var(--canvas); color: var(--ink); font-size: 14px; line-height: 1.4; |
| 11 | - | input { background-color: #f1f5fc; border: 2px solid #5675a4; padding: 8px; width: 300px; height: 42px; } | 11 | + | accent-color: #52525b; |
| 12 | - | row.actions { padding: 12px; } | 12 | + | } |
| 13 | - | button.primary { background-color: #2357bb; color: #ffffff; padding: 13px 10px; width: 160px; } | 13 | + | app.app.dark { |
| 14 | - | button.primary:hover { background-color: #1b4699; } | 14 | + | --canvas: #18181b; --surface: #202023; --sidebar: #1c1c1f; |
| 15 | - | button.primary:disabled { background-color: #7594c9; } | 15 | + | --ink: #f4f4f5; --secondary: #d4d4d8; --quiet: #a1a1aa; |
| 16 | - | input:hover { border-color: #203d7a; } | 16 | + | --line: #36363c; --hover: #303035; --selected: #36363c; |
| 17 | + | --control: #18181b; --action: #e4e4e7; --action-ink: #18181b; | |||
| 18 | + | --disabled: #36363c; --disabled-ink: #a1a1aa; | |||
| 19 | + | accent-color: #a1a1aa; | |||
| 20 | + | } | |||
| 21 | + | app, header, footer, aside, main, section, row, nav, button, input { box-sizing: border-box; } | |||
| 22 | + | header.header { | |||
| 23 | + | display: flex; flex-direction: row; align-items: center; column-gap: 20px; | |||
| 24 | + | height: 48px; flex-shrink: 0; padding: 6px 12px; | |||
| 25 | + | background: var(--surface); border-bottom: 1px solid var(--line); | |||
| 26 | + | } | |||
| 27 | + | heading { font-size: 24px; font-weight: 600; margin: 0; color: var(--ink); } | |||
| 28 | + | heading.logo { font-size: 18px; font-weight: 700; } | |||
| 29 | + | section heading { font-size: 16px; } | |||
| 30 | + | text.muted { color: var(--secondary); } | |||
| 31 | + | text.hint { color: var(--quiet); font-size: 12px; } | |||
| 32 | + | text.eyebrow { color: var(--quiet); font-size: 11px; font-weight: 600; letter-spacing: 1px; } | |||
| 33 | + | spacer.stretch { flex-grow: 1; min-height: 0; min-width: 0; } | |||
| 34 | + | row.body { flex-grow: 1; min-height: 0; height: 100%; align-items: stretch; } | |||
| 35 | + | aside.sidebar { | |||
| 36 | + | display: flex; flex-direction: column; width: 200px; flex-shrink: 0; | |||
| 37 | + | padding: 24px 12px 20px; background: var(--sidebar); border-right: 1px solid var(--line); | |||
| 38 | + | } | |||
| 39 | + | aside.sidebar > text { margin-left: 12px; } | |||
| 40 | + | nav.menu { display: flex; flex-direction: column; row-gap: 4px; margin-top: 16px; flex-shrink: 0; height: 84px; } | |||
| 41 | + | button { color: var(--ink); background: var(--surface); border: 1px solid var(--line); border-radius: 6px; padding: 8px 12px; } | |||
| 42 | + | button:hover { background: var(--hover); } | |||
| 43 | + | button:active { background: var(--selected); } | |||
| 44 | + | button:focus-visible, input:focus-visible, radio:focus-visible, slider:focus-visible { | |||
| 45 | + | outline: 2px solid var(--quiet); outline-offset: 2px; | |||
| 46 | + | } | |||
| 47 | + | button.menu-item { height: 40px; flex-shrink: 0; width: 176px; text-align: left; border: 1px solid transparent; padding: 10px 12px; background: transparent; } | |||
| 48 | + | button.menu-item:hover { background: var(--hover); } | |||
| 49 | + | button.menu-item.active { background: var(--selected); font-weight: 600; } | |||
| 50 | + | button.theme-toggle { width: 104px; font-size: 13px; } | |||
| 51 | + | main.content { flex-grow: 1; min-width: 0; padding: 0; overflow: auto; } | |||
| 52 | + | row.workspace { height: 100%; align-items: stretch; } | |||
| 53 | + | column.editor-pane { flex-grow: 1; flex-basis: 0px; min-width: 0; background: var(--surface); } | |||
| 54 | + | row.pane-bar { width: 100%; height: 36px; flex-shrink: 0; align-items: center; padding: 8px 16px; border-bottom: 1px solid var(--line); background: var(--canvas); } | |||
| 55 | + | text.document-title { font-size: 13px; font-weight: 600; } | |||
| 56 | + | textarea.document-editor { flex-grow: 1; min-height: 0; padding: 20px; background: var(--surface); color: var(--ink); font-size: 14px; line-height: 1.6; } | |||
| 57 | + | aside.inspector { display: flex; flex-direction: column; width: 240px; flex-shrink: 0; padding: 16px; border-left: 1px solid var(--line); background: var(--sidebar); } | |||
| 58 | + | aside.inspector input, aside.inspector slider { width: 208px; } | |||
| 59 | + | aside.inspector button { margin-top: 8px; height: 32px; } | |||
| 60 | + | text.field-label { margin-top: 16px; margin-bottom: 8px; font-size: 13px; font-weight: 600; } | |||
| 61 | + | separator { background: var(--line); margin-top: 16px; margin-bottom: 4px; flex-shrink: 0; } | |||
| 62 | + | button:disabled { background: var(--disabled); color: var(--disabled-ink); } | |||
| 63 | + | column.preferences-pane { padding: 16px 24px; row-gap: 8px; } | |||
| 64 | + | input { background: var(--control); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: 8px 12px; width: 320px; height: 40px; } | |||
| 65 | + | input:hover { border-color: var(--quiet); } | |||
| 66 | + | text.sidebar-note { color: var(--secondary); font-size: 12px; margin-bottom: 4px; } | |||
| 17 | radio { padding-top: 6px; padding-bottom: 6px; } | 67 | radio { padding-top: 6px; padding-bottom: 6px; } | ||
| 18 | - | slider { width: 300px; margin-top: 12px; } | 68 | + | slider { width: 320px; margin-top: 8px; } |
| 19 | - | footer.status-bar { background-color: #dae5f5; padding: 12px; height: 42px; } | 69 | + | footer.status-bar { display: flex; flex-direction: row; align-items: center; height: 28px; flex-shrink: 0; padding: 4px 12px; background: var(--surface); border-top: 1px solid var(--line); color: var(--secondary); font-size: 12px; } |
| 70 | + | app.app.compact aside.inspector { padding-top: 8px; } | |||
| 71 | + | app.app.compact textarea.document-editor { padding: 12px; } | |||
| 72 | + | app.app.compact button.menu-item { padding-top: 6px; padding-bottom: 6px; } | |||
M examples/demo/main.go +92 -27
| @@ -24,6 +24,8 @@ var stylesheet []byte | |||
| 24 | 24 | ||
| 25 | 25 | type Model struct { | |
| 26 | 26 | Page, Name, Status string | |
| 27 | + | Notes string | |
| 28 | + | NewPending bool | |
| 27 | 29 | DarkMode bool | |
| 28 | 30 | Density string | |
| 29 | 31 | Volume float64 | |
| @@ -39,39 +41,106 @@ func navButton(parent nefergui.Node, m *Model, label, page string) { | |||
| 39 | 41 | } | |
| 40 | 42 | } | |
| 41 | 43 | func home(parent nefergui.Node, m *Model) { | |
| 42 | - | section := parent.Section(nefergui.Class("welcome")) | |
| 43 | - | section.Heading("Bienvenue", nefergui.Level(2)) | |
| 44 | - | section.Text("Une interface native écrite entièrement en Go.") | |
| 45 | - | section.Input("Votre nom", &m.Name, nefergui.Key("name"), nefergui.Placeholder("Ada")) | |
| 46 | - | actions := section.Row(nefergui.Class("actions")) | |
| 47 | - | if actions.Button("Continuer", nefergui.Key("continue"), nefergui.Class("primary"), nefergui.Disabled(m.Name == "")).Activated() { | |
| 48 | - | m.Status = "Bonjour " + m.Name | |
| 44 | + | workspace := parent.Row(nefergui.Class("workspace")) | |
| 45 | + | editor := workspace.Column(nefergui.Class("editor-pane")) | |
| 46 | + | tab := editor.Row(nefergui.Class("pane-bar")) | |
| 47 | + | tab.Text("Untitled.txt", nefergui.Class("document-title")) | |
| 48 | + | tab.Spacer(nefergui.Class("stretch")) | |
| 49 | + | tab.Text("Plain text", nefergui.Class("hint")) | |
| 50 | + | if editor.Textarea("Document", &m.Notes, nefergui.Key("document"), nefergui.Class("document-editor"), nefergui.Placeholder("Start writing…")).Changed() { | |
| 51 | + | m.NewPending = false | |
| 52 | + | m.Status = "Unsaved changes · session only" | |
| 49 | 53 | } | |
| 54 | + | inspector := workspace.Aside(nefergui.Class("inspector")) | |
| 55 | + | inspector.Text("PROPERTIES", nefergui.Class("eyebrow")) | |
| 56 | + | inspector.Text("Author", nefergui.Class("field-label")) | |
| 57 | + | inspector.Input("Author", &m.Name, nefergui.Key("name"), nefergui.Placeholder("Your name")) | |
| 58 | + | if inspector.Button("Apply", nefergui.Key("continue"), nefergui.Disabled(m.Name == "")).Activated() { | |
| 59 | + | m.Status = "Author set to " + m.Name + " · session only" | |
| 60 | + | } | |
| 61 | + | inspector.Separator() | |
| 62 | + | inspector.Text("Interface density", nefergui.Class("field-label")) | |
| 63 | + | for _, d := range []struct{ label, value string }{{"Comfortable", "comfortable"}, {"Compact", "compact"}} { | |
| 64 | + | inspector.Radio(d.label, d.value, &m.Density, nefergui.Key(d.value)) | |
| 65 | + | } | |
| 66 | + | inspector.Separator() | |
| 67 | + | inspector.Slider(fmt.Sprintf("Volume %.0f %%", m.Volume), &m.Volume, 0, 100, 5, nefergui.Key("volume")) | |
| 68 | + | inspector.Spacer(nefergui.Class("stretch")) | |
| 69 | + | inspector.Text("Changes stay in this session.", nefergui.Class("hint")) | |
| 50 | 70 | } | |
| 51 | 71 | func settings(parent nefergui.Node, m *Model) { | |
| 52 | - | section := parent.Section(nefergui.Class("welcome")) | |
| 53 | - | section.Heading("Paramètres", nefergui.Level(2)) | |
| 54 | - | for _, d := range []struct{ label, value string }{{"Confortable", "comfortable"}, {"Compacte", "compact"}} { | |
| 55 | - | section.Radio(d.label, d.value, &m.Density, nefergui.Key(d.value)) | |
| 72 | + | bar := parent.Row(nefergui.Class("pane-bar")) | |
| 73 | + | bar.Text("Preferences", nefergui.Class("document-title")) | |
| 74 | + | pane := parent.Column(nefergui.Class("preferences-pane")) | |
| 75 | + | pane.Text("Appearance", nefergui.Class("field-label")) | |
| 76 | + | pane.Checkbox("Dark mode", &m.DarkMode, nefergui.Key("appearance")) | |
| 77 | + | pane.Text("Rendering", nefergui.Class("field-label")) | |
| 78 | + | pane.Text("Wayland · Vulkan · UTF-8", nefergui.Class("muted")) | |
| 79 | + | pane.Text("Document properties are available in the editor's right pane.", nefergui.Class("hint")) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // requestNew arms confirmation without changing the document. A second request | |
| 83 | + | // discards it; editing or Cancel disarms the pending request. | |
| 84 | + | func (m *Model) requestNew() { | |
| 85 | + | if m.Notes != "" && !m.NewPending { | |
| 86 | + | m.NewPending = true | |
| 87 | + | m.Status = "Click Discard & new to replace the current document" | |
| 88 | + | return | |
| 56 | 89 | } | |
| 57 | - | section.Slider(fmt.Sprintf("Volume %.0f %%", m.Volume), &m.Volume, 0, 100, 5, nefergui.Key("volume")) | |
| 90 | + | m.Notes = "" | |
| 91 | + | m.NewPending = false | |
| 92 | + | m.Page = "home" | |
| 93 | + | m.Status = "New document · session only" | |
| 58 | 94 | } | |
| 95 | + | ||
| 59 | 96 | func app(f *nefergui.Frame, m *Model) { | |
| 60 | - | root := f.Root(nefergui.Class("app")) | |
| 97 | + | theme := "light" | |
| 98 | + | if m.DarkMode { | |
| 99 | + | theme = "dark" | |
| 100 | + | } | |
| 101 | + | root := f.Root(nefergui.Class("app"), nefergui.Class(theme), nefergui.Class(m.Density)) | |
| 61 | 102 | header := root.Header(nefergui.Class("header")) | |
| 62 | - | header.Heading("Demo", nefergui.Level(1), nefergui.Class("logo")) | |
| 63 | - | menu := header.Nav(nefergui.Class("menu")) | |
| 64 | - | navButton(menu, m, "Accueil", "home") | |
| 65 | - | navButton(menu, m, "Paramètres", "settings") | |
| 66 | - | header.Checkbox("Thème sombre", &m.DarkMode, nefergui.Key("dark-mode")) | |
| 67 | - | content := root.Main(nefergui.Class("content")) | |
| 103 | + | header.Heading("NeferGUI", nefergui.Level(1), nefergui.Class("logo")) | |
| 104 | + | newLabel := "New" | |
| 105 | + | if m.NewPending { | |
| 106 | + | newLabel = "Discard & new" | |
| 107 | + | } | |
| 108 | + | if header.Button(newLabel, nefergui.Key("new-document")).Activated() { | |
| 109 | + | m.requestNew() | |
| 110 | + | } | |
| 111 | + | if m.NewPending && header.Button("Cancel", nefergui.Key("cancel-new")).Activated() { | |
| 112 | + | m.NewPending = false | |
| 113 | + | m.Status = "Ready" | |
| 114 | + | } | |
| 115 | + | header.Text("Untitled.txt", nefergui.Class("muted")) | |
| 116 | + | header.Spacer(nefergui.Class("stretch")) | |
| 117 | + | label := "Dark mode" | |
| 118 | + | if m.DarkMode { | |
| 119 | + | label = "Light mode" | |
| 120 | + | } | |
| 121 | + | if header.Button(label, nefergui.Key("dark-mode"), nefergui.Class("theme-toggle")).Activated() { | |
| 122 | + | m.DarkMode = !m.DarkMode | |
| 123 | + | } | |
| 124 | + | body := root.Row(nefergui.Class("body")) | |
| 125 | + | sidebar := body.Aside(nefergui.Class("sidebar")) | |
| 126 | + | sidebar.Text("DOCUMENTS", nefergui.Class("eyebrow")) | |
| 127 | + | menu := sidebar.Nav(nefergui.Class("menu")) | |
| 128 | + | navButton(menu, m, "Untitled.txt", "home") | |
| 129 | + | navButton(menu, m, "Preferences", "settings") | |
| 130 | + | sidebar.Spacer(nefergui.Class("stretch")) | |
| 131 | + | sidebar.Text("Local workspace", nefergui.Class("sidebar-note")) | |
| 132 | + | sidebar.Text("1 document · in memory", nefergui.Class("hint")) | |
| 133 | + | content := body.Main(nefergui.Class("content")) | |
| 68 | 134 | switch m.Page { | |
| 69 | 135 | case "home": | |
| 70 | 136 | home(content, m) | |
| 71 | 137 | case "settings": | |
| 72 | 138 | settings(content, m) | |
| 73 | 139 | } | |
| 74 | - | root.Footer(nefergui.Class("status-bar")).Text(m.Status) | |
| 140 | + | footer := root.Footer(nefergui.Class("status-bar")) | |
| 141 | + | footer.Text(m.Status) | |
| 142 | + | footer.Spacer(nefergui.Class("stretch")) | |
| 143 | + | footer.Text("UTF-8 • Plain text", nefergui.Class("hint")) | |
| 75 | 144 | } | |
| 76 | 145 | func main() { | |
| 77 | 146 | static := flag.Bool("static", false, "render a fixed number of frames and exit") | |
| @@ -123,16 +192,12 @@ func main() { | |||
| 123 | 192 | fail(err) | |
| 124 | 193 | } | |
| 125 | 194 | } | |
| 126 | - | model := Model{Page: "home", Status: "Prêt", Density: "comfortable", Volume: 40} | |
| 195 | + | model := Model{Page: "home", Status: "Ready", Density: "comfortable", Volume: 40, Notes: "Project notes\n\nA native text workspace built with NeferGUI.\n\nSelect text, edit a paragraph, or paste from your clipboard.\nUse the properties pane to try radio buttons and the slider.\n\nEverything stays in memory for this session."} | |
| 127 | 196 | start := time.Now() | |
| 128 | 197 | var requested time.Time | |
| 129 | 198 | commit := func(frame uint64) error { | |
| 130 | 199 | if dir != "" { | |
| 131 | - | data, err := json.Marshal(struct { | |
| 132 | - | Page, Name, Status, Density string | |
| 133 | - | DarkMode bool | |
| 134 | - | Volume float64 | |
| 135 | - | }{model.Page, model.Name, model.Status, model.Density, model.DarkMode, model.Volume}) | |
| 200 | + | data, err := json.Marshal(model) | |
| 136 | 201 | if err != nil { | |
| 137 | 202 | return err | |
| 138 | 203 | } | |
| @@ -161,7 +226,7 @@ func main() { | |||
| 161 | 226 | } | |
| 162 | 227 | return nil | |
| 163 | 228 | } | |
| 164 | - | opts := []nefergui.WindowOption{nefergui.Title("Demo"), nefergui.Size(960, 640), nefergui.Styles(path)} | |
| 229 | + | opts := []nefergui.WindowOption{nefergui.Title("NeferGUI — Untitled.txt"), nefergui.Size(960, 640), nefergui.Styles(path)} | |
| 165 | 230 | var err error | |
| 166 | 231 | if *static { | |
| 167 | 232 | // The commit callback ends the run with errDone; the frame limit is a backstop. | |
| @@ -24,6 +24,8 @@ var stylesheet []byte | |||||
| 24 | 24 | ||||
| 25 | type Model struct { | 25 | type Model struct { | ||
| 26 | Page, Name, Status string | 26 | Page, Name, Status string | ||
| 27 | + | Notes string | |||
| 28 | + | NewPending bool | |||
| 27 | DarkMode bool | 29 | DarkMode bool | ||
| 28 | Density string | 30 | Density string | ||
| 29 | Volume float64 | 31 | Volume float64 | ||
| @@ -39,39 +41,106 @@ func navButton(parent nefergui.Node, m *Model, label, page string) { | |||||
| 39 | } | 41 | } | ||
| 40 | } | 42 | } | ||
| 41 | func home(parent nefergui.Node, m *Model) { | 43 | func home(parent nefergui.Node, m *Model) { | ||
| 42 | - | section := parent.Section(nefergui.Class("welcome")) | 44 | + | workspace := parent.Row(nefergui.Class("workspace")) |
| 43 | - | section.Heading("Bienvenue", nefergui.Level(2)) | 45 | + | editor := workspace.Column(nefergui.Class("editor-pane")) |
| 44 | - | section.Text("Une interface native écrite entièrement en Go.") | 46 | + | tab := editor.Row(nefergui.Class("pane-bar")) |
| 45 | - | section.Input("Votre nom", &m.Name, nefergui.Key("name"), nefergui.Placeholder("Ada")) | 47 | + | tab.Text("Untitled.txt", nefergui.Class("document-title")) |
| 46 | - | actions := section.Row(nefergui.Class("actions")) | 48 | + | tab.Spacer(nefergui.Class("stretch")) |
| 47 | - | if actions.Button("Continuer", nefergui.Key("continue"), nefergui.Class("primary"), nefergui.Disabled(m.Name == "")).Activated() { | 49 | + | tab.Text("Plain text", nefergui.Class("hint")) |
| 48 | - | m.Status = "Bonjour " + m.Name | 50 | + | if editor.Textarea("Document", &m.Notes, nefergui.Key("document"), nefergui.Class("document-editor"), nefergui.Placeholder("Start writing…")).Changed() { |
| 51 | + | m.NewPending = false | |||
| 52 | + | m.Status = "Unsaved changes · session only" | |||
| 49 | } | 53 | } | ||
| 54 | + | inspector := workspace.Aside(nefergui.Class("inspector")) | |||
| 55 | + | inspector.Text("PROPERTIES", nefergui.Class("eyebrow")) | |||
| 56 | + | inspector.Text("Author", nefergui.Class("field-label")) | |||
| 57 | + | inspector.Input("Author", &m.Name, nefergui.Key("name"), nefergui.Placeholder("Your name")) | |||
| 58 | + | if inspector.Button("Apply", nefergui.Key("continue"), nefergui.Disabled(m.Name == "")).Activated() { | |||
| 59 | + | m.Status = "Author set to " + m.Name + " · session only" | |||
| 60 | + | } | |||
| 61 | + | inspector.Separator() | |||
| 62 | + | inspector.Text("Interface density", nefergui.Class("field-label")) | |||
| 63 | + | for _, d := range []struct{ label, value string }{{"Comfortable", "comfortable"}, {"Compact", "compact"}} { | |||
| 64 | + | inspector.Radio(d.label, d.value, &m.Density, nefergui.Key(d.value)) | |||
| 65 | + | } | |||
| 66 | + | inspector.Separator() | |||
| 67 | + | inspector.Slider(fmt.Sprintf("Volume %.0f %%", m.Volume), &m.Volume, 0, 100, 5, nefergui.Key("volume")) | |||
| 68 | + | inspector.Spacer(nefergui.Class("stretch")) | |||
| 69 | + | inspector.Text("Changes stay in this session.", nefergui.Class("hint")) | |||
| 50 | } | 70 | } | ||
| 51 | func settings(parent nefergui.Node, m *Model) { | 71 | func settings(parent nefergui.Node, m *Model) { | ||
| 52 | - | section := parent.Section(nefergui.Class("welcome")) | 72 | + | bar := parent.Row(nefergui.Class("pane-bar")) |
| 53 | - | section.Heading("Paramètres", nefergui.Level(2)) | 73 | + | bar.Text("Preferences", nefergui.Class("document-title")) |
| 54 | - | for _, d := range []struct{ label, value string }{{"Confortable", "comfortable"}, {"Compacte", "compact"}} { | 74 | + | pane := parent.Column(nefergui.Class("preferences-pane")) |
| 55 | - | section.Radio(d.label, d.value, &m.Density, nefergui.Key(d.value)) | 75 | + | pane.Text("Appearance", nefergui.Class("field-label")) |
| 76 | + | pane.Checkbox("Dark mode", &m.DarkMode, nefergui.Key("appearance")) | |||
| 77 | + | pane.Text("Rendering", nefergui.Class("field-label")) | |||
| 78 | + | pane.Text("Wayland · Vulkan · UTF-8", nefergui.Class("muted")) | |||
| 79 | + | pane.Text("Document properties are available in the editor's right pane.", nefergui.Class("hint")) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // requestNew arms confirmation without changing the document. A second request | |||
| 83 | + | // discards it; editing or Cancel disarms the pending request. | |||
| 84 | + | func (m *Model) requestNew() { | |||
| 85 | + | if m.Notes != "" && !m.NewPending { | |||
| 86 | + | m.NewPending = true | |||
| 87 | + | m.Status = "Click Discard & new to replace the current document" | |||
| 88 | + | return | |||
| 56 | } | 89 | } | ||
| 57 | - | section.Slider(fmt.Sprintf("Volume %.0f %%", m.Volume), &m.Volume, 0, 100, 5, nefergui.Key("volume")) | 90 | + | m.Notes = "" |
| 91 | + | m.NewPending = false | |||
| 92 | + | m.Page = "home" | |||
| 93 | + | m.Status = "New document · session only" | |||
| 58 | } | 94 | } | ||
| 95 | + | ||||
| 59 | func app(f *nefergui.Frame, m *Model) { | 96 | func app(f *nefergui.Frame, m *Model) { | ||
| 60 | - | root := f.Root(nefergui.Class("app")) | 97 | + | theme := "light" |
| 98 | + | if m.DarkMode { | |||
| 99 | + | theme = "dark" | |||
| 100 | + | } | |||
| 101 | + | root := f.Root(nefergui.Class("app"), nefergui.Class(theme), nefergui.Class(m.Density)) | |||
| 61 | header := root.Header(nefergui.Class("header")) | 102 | header := root.Header(nefergui.Class("header")) | ||
| 62 | - | header.Heading("Demo", nefergui.Level(1), nefergui.Class("logo")) | 103 | + | header.Heading("NeferGUI", nefergui.Level(1), nefergui.Class("logo")) |
| 63 | - | menu := header.Nav(nefergui.Class("menu")) | 104 | + | newLabel := "New" |
| 64 | - | navButton(menu, m, "Accueil", "home") | 105 | + | if m.NewPending { |
| 65 | - | navButton(menu, m, "Paramètres", "settings") | 106 | + | newLabel = "Discard & new" |
| 66 | - | header.Checkbox("Thème sombre", &m.DarkMode, nefergui.Key("dark-mode")) | 107 | + | } |
| 67 | - | content := root.Main(nefergui.Class("content")) | 108 | + | if header.Button(newLabel, nefergui.Key("new-document")).Activated() { |
| 109 | + | m.requestNew() | |||
| 110 | + | } | |||
| 111 | + | if m.NewPending && header.Button("Cancel", nefergui.Key("cancel-new")).Activated() { | |||
| 112 | + | m.NewPending = false | |||
| 113 | + | m.Status = "Ready" | |||
| 114 | + | } | |||
| 115 | + | header.Text("Untitled.txt", nefergui.Class("muted")) | |||
| 116 | + | header.Spacer(nefergui.Class("stretch")) | |||
| 117 | + | label := "Dark mode" | |||
| 118 | + | if m.DarkMode { | |||
| 119 | + | label = "Light mode" | |||
| 120 | + | } | |||
| 121 | + | if header.Button(label, nefergui.Key("dark-mode"), nefergui.Class("theme-toggle")).Activated() { | |||
| 122 | + | m.DarkMode = !m.DarkMode | |||
| 123 | + | } | |||
| 124 | + | body := root.Row(nefergui.Class("body")) | |||
| 125 | + | sidebar := body.Aside(nefergui.Class("sidebar")) | |||
| 126 | + | sidebar.Text("DOCUMENTS", nefergui.Class("eyebrow")) | |||
| 127 | + | menu := sidebar.Nav(nefergui.Class("menu")) | |||
| 128 | + | navButton(menu, m, "Untitled.txt", "home") | |||
| 129 | + | navButton(menu, m, "Preferences", "settings") | |||
| 130 | + | sidebar.Spacer(nefergui.Class("stretch")) | |||
| 131 | + | sidebar.Text("Local workspace", nefergui.Class("sidebar-note")) | |||
| 132 | + | sidebar.Text("1 document · in memory", nefergui.Class("hint")) | |||
| 133 | + | content := body.Main(nefergui.Class("content")) | |||
| 68 | switch m.Page { | 134 | switch m.Page { | ||
| 69 | case "home": | 135 | case "home": | ||
| 70 | home(content, m) | 136 | home(content, m) | ||
| 71 | case "settings": | 137 | case "settings": | ||
| 72 | settings(content, m) | 138 | settings(content, m) | ||
| 73 | } | 139 | } | ||
| 74 | - | root.Footer(nefergui.Class("status-bar")).Text(m.Status) | 140 | + | footer := root.Footer(nefergui.Class("status-bar")) |
| 141 | + | footer.Text(m.Status) | |||
| 142 | + | footer.Spacer(nefergui.Class("stretch")) | |||
| 143 | + | footer.Text("UTF-8 • Plain text", nefergui.Class("hint")) | |||
| 75 | } | 144 | } | ||
| 76 | func main() { | 145 | func main() { | ||
| 77 | static := flag.Bool("static", false, "render a fixed number of frames and exit") | 146 | static := flag.Bool("static", false, "render a fixed number of frames and exit") | ||
| @@ -123,16 +192,12 @@ func main() { | |||||
| 123 | fail(err) | 192 | fail(err) | ||
| 124 | } | 193 | } | ||
| 125 | } | 194 | } | ||
| 126 | - | model := Model{Page: "home", Status: "Prêt", Density: "comfortable", Volume: 40} | 195 | + | model := Model{Page: "home", Status: "Ready", Density: "comfortable", Volume: 40, Notes: "Project notes\n\nA native text workspace built with NeferGUI.\n\nSelect text, edit a paragraph, or paste from your clipboard.\nUse the properties pane to try radio buttons and the slider.\n\nEverything stays in memory for this session."} |
| 127 | start := time.Now() | 196 | start := time.Now() | ||
| 128 | var requested time.Time | 197 | var requested time.Time | ||
| 129 | commit := func(frame uint64) error { | 198 | commit := func(frame uint64) error { | ||
| 130 | if dir != "" { | 199 | if dir != "" { | ||
| 131 | - | data, err := json.Marshal(struct { | 200 | + | data, err := json.Marshal(model) |
| 132 | - | Page, Name, Status, Density string | |||
| 133 | - | DarkMode bool | |||
| 134 | - | Volume float64 | |||
| 135 | - | }{model.Page, model.Name, model.Status, model.Density, model.DarkMode, model.Volume}) | |||
| 136 | if err != nil { | 201 | if err != nil { | ||
| 137 | return err | 202 | return err | ||
| 138 | } | 203 | } | ||
| @@ -161,7 +226,7 @@ func main() { | |||||
| 161 | } | 226 | } | ||
| 162 | return nil | 227 | return nil | ||
| 163 | } | 228 | } | ||
| 164 | - | opts := []nefergui.WindowOption{nefergui.Title("Demo"), nefergui.Size(960, 640), nefergui.Styles(path)} | 229 | + | opts := []nefergui.WindowOption{nefergui.Title("NeferGUI — Untitled.txt"), nefergui.Size(960, 640), nefergui.Styles(path)} |
| 165 | var err error | 230 | var err error | ||
| 166 | if *static { | 231 | if *static { | ||
| 167 | // The commit callback ends the run with errDone; the frame limit is a backstop. | 232 | // The commit callback ends the run with errDone; the frame limit is a backstop. | ||
M examples/demo/main_test.go +17 -0
| @@ -7,6 +7,23 @@ import ( | |||
| 7 | 7 | "testing" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | + | func TestNewDocumentRequiresConfirmation(t *testing.T) { | |
| 11 | + | m := Model{Notes: "Keep this text", Page: "home"} | |
| 12 | + | m.requestNew() | |
| 13 | + | if m.Notes != "Keep this text" || !m.NewPending { | |
| 14 | + | t.Fatalf("first request discarded text: %+v", m) | |
| 15 | + | } | |
| 16 | + | m.NewPending = false // Cancel | |
| 17 | + | m.requestNew() | |
| 18 | + | if m.Notes != "Keep this text" || !m.NewPending { | |
| 19 | + | t.Fatalf("cancel failed to reset confirmation: %+v", m) | |
| 20 | + | } | |
| 21 | + | m.requestNew() | |
| 22 | + | if m.Notes != "" || m.NewPending { | |
| 23 | + | t.Fatalf("confirmation did not clear document: %+v", m) | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 10 | 27 | func TestUnexpectedKeepsCloseErrors(t *testing.T) { | |
| 11 | 28 | closeErr := errors.New("close failed") | |
| 12 | 29 | cases := []struct { | |
| @@ -7,6 +7,23 @@ import ( | |||||
| 7 | "testing" | 7 | "testing" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | + | func TestNewDocumentRequiresConfirmation(t *testing.T) { | |||
| 11 | + | m := Model{Notes: "Keep this text", Page: "home"} | |||
| 12 | + | m.requestNew() | |||
| 13 | + | if m.Notes != "Keep this text" || !m.NewPending { | |||
| 14 | + | t.Fatalf("first request discarded text: %+v", m) | |||
| 15 | + | } | |||
| 16 | + | m.NewPending = false // Cancel | |||
| 17 | + | m.requestNew() | |||
| 18 | + | if m.Notes != "Keep this text" || !m.NewPending { | |||
| 19 | + | t.Fatalf("cancel failed to reset confirmation: %+v", m) | |||
| 20 | + | } | |||
| 21 | + | m.requestNew() | |||
| 22 | + | if m.Notes != "" || m.NewPending { | |||
| 23 | + | t.Fatalf("confirmation did not clear document: %+v", m) | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | ||||
| 10 | func TestUnexpectedKeepsCloseErrors(t *testing.T) { | 27 | func TestUnexpectedKeepsCloseErrors(t *testing.T) { | ||
| 11 | closeErr := errors.New("close failed") | 28 | closeErr := errors.New("close failed") | ||
| 12 | cases := []struct { | 29 | cases := []struct { | ||
M examples/rect/main.go +4 -0
| @@ -228,6 +228,10 @@ func styleCommands() []layout.Command { | |||
| 228 | 228 | {Op: "shadow", Rect: box, Radii: radius, Shadow: &sh, Opacity: 1}, | |
| 229 | 229 | {Op: "rect", Rect: box, Radii: radius, Color: css.Color{R: 1, G: 1, B: 1, A: 1}, Opacity: 1}, | |
| 230 | 230 | {Op: "border", Rect: box, Radii: radius, Widths: layout.Edges{Top: 8, Right: 8, Bottom: 8, Left: 8}, Colors: css.ColorSides{Top: css.Color{R: 1, A: 1}, Right: css.Color{G: 1, A: 1}, Bottom: css.Color{B: 1, A: 1}, Left: css.Color{R: 1, G: 1, A: 1}}, Color: css.Color{A: 1}, Opacity: 1}, | |
| 231 | + | // Zero-width sides retain deliberately contrasting colors to catch | |
| 232 | + | // accidental corner contributions from unpainted edges. | |
| 233 | + | {Op: "border", Rect: layout.Rect{X: 300, Y: 40, W: 80, H: 50}, Widths: layout.Edges{Top: 8}, Colors: css.ColorSides{Top: css.Color{R: 1, A: 1}, Left: css.Color{G: 1, A: 1}, Right: css.Color{G: 1, A: 1}}, Color: css.Color{A: 1}, Opacity: 1}, | |
| 234 | + | {Op: "border", Rect: layout.Rect{X: 300, Y: 240, W: 80, H: 50}, Widths: layout.Edges{Top: 8, Left: 2}, Colors: css.ColorSides{Top: css.Color{R: 1, A: 1}, Left: css.Color{B: 1, A: 1}}, Color: css.Color{A: 1}, Opacity: 1}, | |
| 231 | 235 | // Layout has already expanded this rect by the 4px outline offset. | |
| 232 | 236 | {Op: "outline", Rect: layout.Rect{X: 116, Y: 116, W: 168, H: 128}, Radii: radius, Widths: layout.Edges{Top: 3, Right: 3, Bottom: 3, Left: 3}, Color: css.Color{R: 1, G: 0, B: 1, A: 1}, Opacity: 1}, | |
| 233 | 237 | } | |
| @@ -228,6 +228,10 @@ func styleCommands() []layout.Command { | |||||
| 228 | {Op: "shadow", Rect: box, Radii: radius, Shadow: &sh, Opacity: 1}, | 228 | {Op: "shadow", Rect: box, Radii: radius, Shadow: &sh, Opacity: 1}, | ||
| 229 | {Op: "rect", Rect: box, Radii: radius, Color: css.Color{R: 1, G: 1, B: 1, A: 1}, Opacity: 1}, | 229 | {Op: "rect", Rect: box, Radii: radius, Color: css.Color{R: 1, G: 1, B: 1, A: 1}, Opacity: 1}, | ||
| 230 | {Op: "border", Rect: box, Radii: radius, Widths: layout.Edges{Top: 8, Right: 8, Bottom: 8, Left: 8}, Colors: css.ColorSides{Top: css.Color{R: 1, A: 1}, Right: css.Color{G: 1, A: 1}, Bottom: css.Color{B: 1, A: 1}, Left: css.Color{R: 1, G: 1, A: 1}}, Color: css.Color{A: 1}, Opacity: 1}, | 230 | {Op: "border", Rect: box, Radii: radius, Widths: layout.Edges{Top: 8, Right: 8, Bottom: 8, Left: 8}, Colors: css.ColorSides{Top: css.Color{R: 1, A: 1}, Right: css.Color{G: 1, A: 1}, Bottom: css.Color{B: 1, A: 1}, Left: css.Color{R: 1, G: 1, A: 1}}, Color: css.Color{A: 1}, Opacity: 1}, | ||
| 231 | + | // Zero-width sides retain deliberately contrasting colors to catch | |||
| 232 | + | // accidental corner contributions from unpainted edges. | |||
| 233 | + | {Op: "border", Rect: layout.Rect{X: 300, Y: 40, W: 80, H: 50}, Widths: layout.Edges{Top: 8}, Colors: css.ColorSides{Top: css.Color{R: 1, A: 1}, Left: css.Color{G: 1, A: 1}, Right: css.Color{G: 1, A: 1}}, Color: css.Color{A: 1}, Opacity: 1}, | |||
| 234 | + | {Op: "border", Rect: layout.Rect{X: 300, Y: 240, W: 80, H: 50}, Widths: layout.Edges{Top: 8, Left: 2}, Colors: css.ColorSides{Top: css.Color{R: 1, A: 1}, Left: css.Color{B: 1, A: 1}}, Color: css.Color{A: 1}, Opacity: 1}, | |||
| 231 | // Layout has already expanded this rect by the 4px outline offset. | 235 | // Layout has already expanded this rect by the 4px outline offset. | ||
| 232 | {Op: "outline", Rect: layout.Rect{X: 116, Y: 116, W: 168, H: 128}, Radii: radius, Widths: layout.Edges{Top: 3, Right: 3, Bottom: 3, Left: 3}, Color: css.Color{R: 1, G: 0, B: 1, A: 1}, Opacity: 1}, | 236 | {Op: "outline", Rect: layout.Rect{X: 116, Y: 116, W: 168, H: 128}, Radii: radius, Widths: layout.Edges{Top: 3, Right: 3, Bottom: 3, Left: 3}, Color: css.Color{R: 1, G: 0, B: 1, A: 1}, Opacity: 1}, | ||
| 233 | } | 237 | } | ||
M go.mod +4 -4
| @@ -1,13 +1,13 @@ | |||
| 1 | 1 | module github.com/bnema/nefergui | |
| 2 | 2 | ||
| 3 | - | go 1.27.0 | |
| 3 | + | go 1.27 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/bnema/purego v0.13.0-bnema.1 | |
| 7 | - | github.com/bnema/purego-vulkan v0.5.1-0.20260928084713-7e6b00ce8c4c | |
| 7 | + | github.com/bnema/purego-vulkan v0.6.0 | |
| 8 | 8 | github.com/bnema/purego-xkbcommon v0.0.0-20260928071113-e79265dfcdc2 | |
| 9 | - | github.com/bnema/wlturbo v0.2.1-0.20260929151656-a2cb70a5513a | |
| 10 | - | github.com/go-text/typesetting v0.3.4 | |
| 9 | + | github.com/bnema/wlturbo v0.3.0 | |
| 10 | + | github.com/go-text/typesetting v0.3.5 | |
| 11 | 11 | golang.org/x/image v0.46.0 | |
| 12 | 12 | golang.org/x/sys v0.48.0 | |
| 13 | 13 | ) | |
| @@ -1,13 +1,13 @@ | |||||
| 1 | module github.com/bnema/nefergui | 1 | module github.com/bnema/nefergui | ||
| 2 | 2 | ||||
| 3 | - | go 1.27.0 | 3 | + | go 1.27 |
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | github.com/bnema/purego v0.13.0-bnema.1 | 6 | github.com/bnema/purego v0.13.0-bnema.1 | ||
| 7 | - | github.com/bnema/purego-vulkan v0.5.1-0.20260928084713-7e6b00ce8c4c | 7 | + | github.com/bnema/purego-vulkan v0.6.0 |
| 8 | github.com/bnema/purego-xkbcommon v0.0.0-20260928071113-e79265dfcdc2 | 8 | github.com/bnema/purego-xkbcommon v0.0.0-20260928071113-e79265dfcdc2 | ||
| 9 | - | github.com/bnema/wlturbo v0.2.1-0.20260929151656-a2cb70a5513a | 9 | + | github.com/bnema/wlturbo v0.3.0 |
| 10 | - | github.com/go-text/typesetting v0.3.4 | 10 | + | github.com/go-text/typesetting v0.3.5 |
| 11 | golang.org/x/image v0.46.0 | 11 | golang.org/x/image v0.46.0 | ||
| 12 | golang.org/x/sys v0.48.0 | 12 | golang.org/x/sys v0.48.0 | ||
| 13 | ) | 13 | ) | ||
M go.sum +8 -8 generated file: collapsed
A internal/layout/intrinsic_container_test.go +56 -0
| @@ -0,0 +1,56 @@ | |||
| 1 | + | package layout | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/nefergui/internal/css" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestAutoFlexColumnReservesChildrenAndGaps(t *testing.T) { | |
| 10 | + | leaf := func(id string) *Node { | |
| 11 | + | return node(id, Box, css.Style{Display: css.KeywordBlock, Height: px(20), Width: px(80)}) | |
| 12 | + | } | |
| 13 | + | column := node("column", Column, css.Style{Display: css.KeywordFlex, FlexDirection: css.KeywordColumn, RowGap: px(8)}, leaf("one"), leaf("two")) | |
| 14 | + | out, err := Layout(node("root", Box, css.Style{Display: css.KeywordBlock, Width: px(300), Height: px(200)}, column), Options{Width: 300, Height: 200}) | |
| 15 | + | if err != nil { | |
| 16 | + | t.Fatal(err) | |
| 17 | + | } | |
| 18 | + | got := out.Tree.Children[0] | |
| 19 | + | if got.Content.H != 48 || got.Children[1].Border.Y != 28 { | |
| 20 | + | t.Fatalf("column geometry: %+v", got) | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestNestedFlexPercentWidthUsesNaturalSize(t *testing.T) { | |
| 25 | + | leaf := node("leaf", Box, css.Style{Display: css.KeywordBlock, Width: px(80), Height: px(24)}) | |
| 26 | + | percent := node("percent", Box, css.Style{Display: css.KeywordBlock, Width: css.Length{Value: 100, Unit: "%"}}, leaf) | |
| 27 | + | nested := node("nested", Column, css.Style{Display: css.KeywordFlex, FlexDirection: css.KeywordColumn}, percent) | |
| 28 | + | out, err := Layout(node("root", Row, css.Style{Display: css.KeywordFlex, Width: px(300), Height: px(100)}, nested), Options{Width: 300, Height: 100}) | |
| 29 | + | if err != nil { | |
| 30 | + | t.Fatal(err) | |
| 31 | + | } | |
| 32 | + | if got := out.Tree.Children[0].Border.W; got != 80 { | |
| 33 | + | t.Fatalf("nested width=%g", got) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestAutoFlexHeightBoundsBeforeDistribution(t *testing.T) { | |
| 38 | + | for _, tc := range []struct { | |
| 39 | + | name string | |
| 40 | + | min, max css.Length | |
| 41 | + | child, want float64 | |
| 42 | + | }{{"minimum", px(200), css.Length{}, 0, 200}, {"maximum", css.Length{}, px(40), 100, 40}} { | |
| 43 | + | t.Run(tc.name, func(t *testing.T) { | |
| 44 | + | child := node("child", Box, css.Style{Display: css.KeywordBlock, Height: px(tc.child), FlexGrow: 1, FlexShrink: 1}) | |
| 45 | + | column := node("column", Column, css.Style{Display: css.KeywordFlex, FlexDirection: css.KeywordColumn, MinHeight: tc.min, MaxHeight: tc.max}, child) | |
| 46 | + | out, err := Layout(node("root", Box, css.Style{Display: css.KeywordBlock, Width: px(300), Height: px(300)}, column), Options{Width: 300, Height: 300}) | |
| 47 | + | if err != nil { | |
| 48 | + | t.Fatal(err) | |
| 49 | + | } | |
| 50 | + | got := out.Tree.Children[0] | |
| 51 | + | if got.Border.H != tc.want || got.Children[0].Border.H != tc.want { | |
| 52 | + | t.Fatalf("container=%g child=%g want=%g", got.Border.H, got.Children[0].Border.H, tc.want) | |
| 53 | + | } | |
| 54 | + | }) | |
| 55 | + | } | |
| 56 | + | } | |
| @@ -0,0 +1,56 @@ | |||||
| 1 | + | package layout | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/nefergui/internal/css" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestAutoFlexColumnReservesChildrenAndGaps(t *testing.T) { | |||
| 10 | + | leaf := func(id string) *Node { | |||
| 11 | + | return node(id, Box, css.Style{Display: css.KeywordBlock, Height: px(20), Width: px(80)}) | |||
| 12 | + | } | |||
| 13 | + | column := node("column", Column, css.Style{Display: css.KeywordFlex, FlexDirection: css.KeywordColumn, RowGap: px(8)}, leaf("one"), leaf("two")) | |||
| 14 | + | out, err := Layout(node("root", Box, css.Style{Display: css.KeywordBlock, Width: px(300), Height: px(200)}, column), Options{Width: 300, Height: 200}) | |||
| 15 | + | if err != nil { | |||
| 16 | + | t.Fatal(err) | |||
| 17 | + | } | |||
| 18 | + | got := out.Tree.Children[0] | |||
| 19 | + | if got.Content.H != 48 || got.Children[1].Border.Y != 28 { | |||
| 20 | + | t.Fatalf("column geometry: %+v", got) | |||
| 21 | + | } | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func TestNestedFlexPercentWidthUsesNaturalSize(t *testing.T) { | |||
| 25 | + | leaf := node("leaf", Box, css.Style{Display: css.KeywordBlock, Width: px(80), Height: px(24)}) | |||
| 26 | + | percent := node("percent", Box, css.Style{Display: css.KeywordBlock, Width: css.Length{Value: 100, Unit: "%"}}, leaf) | |||
| 27 | + | nested := node("nested", Column, css.Style{Display: css.KeywordFlex, FlexDirection: css.KeywordColumn}, percent) | |||
| 28 | + | out, err := Layout(node("root", Row, css.Style{Display: css.KeywordFlex, Width: px(300), Height: px(100)}, nested), Options{Width: 300, Height: 100}) | |||
| 29 | + | if err != nil { | |||
| 30 | + | t.Fatal(err) | |||
| 31 | + | } | |||
| 32 | + | if got := out.Tree.Children[0].Border.W; got != 80 { | |||
| 33 | + | t.Fatalf("nested width=%g", got) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestAutoFlexHeightBoundsBeforeDistribution(t *testing.T) { | |||
| 38 | + | for _, tc := range []struct { | |||
| 39 | + | name string | |||
| 40 | + | min, max css.Length | |||
| 41 | + | child, want float64 | |||
| 42 | + | }{{"minimum", px(200), css.Length{}, 0, 200}, {"maximum", css.Length{}, px(40), 100, 40}} { | |||
| 43 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 44 | + | child := node("child", Box, css.Style{Display: css.KeywordBlock, Height: px(tc.child), FlexGrow: 1, FlexShrink: 1}) | |||
| 45 | + | column := node("column", Column, css.Style{Display: css.KeywordFlex, FlexDirection: css.KeywordColumn, MinHeight: tc.min, MaxHeight: tc.max}, child) | |||
| 46 | + | out, err := Layout(node("root", Box, css.Style{Display: css.KeywordBlock, Width: px(300), Height: px(300)}, column), Options{Width: 300, Height: 300}) | |||
| 47 | + | if err != nil { | |||
| 48 | + | t.Fatal(err) | |||
| 49 | + | } | |||
| 50 | + | got := out.Tree.Children[0] | |||
| 51 | + | if got.Border.H != tc.want || got.Children[0].Border.H != tc.want { | |||
| 52 | + | t.Fatalf("container=%g child=%g want=%g", got.Border.H, got.Children[0].Border.H, tc.want) | |||
| 53 | + | } | |||
| 54 | + | }) | |||
| 55 | + | } | |||
| 56 | + | } | |||
M internal/layout/layout.go +241 -21
| @@ -178,11 +178,29 @@ func Layout(root *Node, options Options) (Output, error) { | |||
| 178 | 178 | return Output{}, err | |
| 179 | 179 | } | |
| 180 | 180 | out := Output{Tree: tree} | |
| 181 | - | ctx.paint(root, tree, &out.Display) | |
| 181 | + | out.Display = ctx.display(root, tree) | |
| 182 | 182 | return out, nil | |
| 183 | 183 | } | |
| 184 | 184 | ||
| 185 | - | type context struct{ options Options } | |
| 185 | + | // display paints the placed tree into a list sized once from the tree. | |
| 186 | + | func (c context) display(root *Node, tree *Result) []Command { | |
| 187 | + | n := countCommands(root, tree) | |
| 188 | + | if n.commands == 0 { | |
| 189 | + | return nil | |
| 190 | + | } | |
| 191 | + | out := make([]Command, 0, n.commands) | |
| 192 | + | c.shadows, c.runs, c.glyphs = newSlab[css.Shadow](n.shadows), newSlab[Run](n.runs), newSlab[Glyph](n.glyphs) | |
| 193 | + | c.paint(root, tree, &out) | |
| 194 | + | return out | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | type context struct { | |
| 198 | + | options Options | |
| 199 | + | // Painter-owned display storage, sized by countCommands; nil is valid. | |
| 200 | + | shadows *slab[css.Shadow] | |
| 201 | + | runs *slab[Run] | |
| 202 | + | glyphs *slab[Glyph] | |
| 203 | + | } | |
| 186 | 204 | ||
| 187 | 205 | func style(n *Node) css.Style { | |
| 188 | 206 | if n.Style != nil { | |
| @@ -209,6 +227,13 @@ func boxBound(border float64, minL, maxL css.Length, basis, insets float64, bord | |||
| 209 | 227 | return safe(bounded(safe(border-insets), minL, maxL, basis) + insets) | |
| 210 | 228 | } | |
| 211 | 229 | func (c context) intrinsic(n *Node, w float64) (Size, []text.Line, error) { | |
| 230 | + | return c.intrinsicDepth(n, w, 0) | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func (c context) intrinsicDepth(n *Node, w float64, depth int) (Size, []text.Line, error) { | |
| 234 | + | if depth > 256 { | |
| 235 | + | return Size{}, nil, ErrDepth | |
| 236 | + | } | |
| 212 | 237 | if n.Measure != nil { | |
| 213 | 238 | s, lines, err := n.Measure(w) | |
| 214 | 239 | if err != nil { | |
| @@ -230,7 +255,13 @@ func (c context) intrinsic(n *Node, w float64) (Size, []text.Line, error) { | |||
| 230 | 255 | if req.Size == 0 { | |
| 231 | 256 | req.Size = 16 | |
| 232 | 257 | } | |
| 233 | - | l, err := c.options.TextEngine.Measure(n.Content, req, w) | |
| 258 | + | content := n.Content | |
| 259 | + | if content == "" && n.EditorScroll { | |
| 260 | + | // Preserve one font-metric line for empty editors, caret geometry | |
| 261 | + | // and IME composition without painting a visible glyph. | |
| 262 | + | content = " " | |
| 263 | + | } | |
| 264 | + | l, err := c.options.TextEngine.Measure(content, req, w) | |
| 234 | 265 | if err != nil { | |
| 235 | 266 | return Size{}, nil, err | |
| 236 | 267 | } | |
| @@ -264,7 +295,71 @@ func (c context) intrinsic(n *Node, w float64) (Size, []text.Line, error) { | |||
| 264 | 295 | } | |
| 265 | 296 | return Size{safe(l.Width), safe(l.Height)}, l.Lines, nil | |
| 266 | 297 | } | |
| 267 | - | return Size{}, nil, nil | |
| 298 | + | if depth == 0 { | |
| 299 | + | return Size{}, nil, nil | |
| 300 | + | } | |
| 301 | + | // Containers contribute their children to auto flex sizing. Measuring them | |
| 302 | + | // as zero compresses nested columns and makes empty editors collapse. | |
| 303 | + | st := style(n) | |
| 304 | + | row := st.Display == css.KeywordFlex && st.FlexDirection != css.KeywordColumn | |
| 305 | + | gap := positive(st.RowGap, w) | |
| 306 | + | if row { | |
| 307 | + | gap = positive(st.ColumnGap, w) | |
| 308 | + | } | |
| 309 | + | var size Size | |
| 310 | + | count := 0 | |
| 311 | + | for _, child := range n.Children { | |
| 312 | + | if child == nil || style(child).Display == css.KeywordNone { | |
| 313 | + | continue | |
| 314 | + | } | |
| 315 | + | cs := style(child) | |
| 316 | + | inW, inH := flexInsets(cs, w) | |
| 317 | + | measured, _, err := c.intrinsicDepth(child, safe(w-inW), depth+1) | |
| 318 | + | if err != nil { | |
| 319 | + | return Size{}, nil, err | |
| 320 | + | } | |
| 321 | + | cw, ch := measured.W+inW, measured.H+inH | |
| 322 | + | if value, ok := length(cs.Width, w); ok && cs.Width.Unit != "%" { | |
| 323 | + | cw = value | |
| 324 | + | if cs.BoxSizing != css.KeywordBorderBox { | |
| 325 | + | cw += inW | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | // Percent heights need a definite containing height, unavailable here. | |
| 329 | + | if cs.Height.Unit != "%" { | |
| 330 | + | if value, ok := length(cs.Height, 0); ok { | |
| 331 | + | ch = value | |
| 332 | + | if cs.BoxSizing != css.KeywordBorderBox { | |
| 333 | + | ch += inH | |
| 334 | + | } | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | cw = boxBound(cw, cs.MinWidth, cs.MaxWidth, w, inW, cs.BoxSizing == css.KeywordBorderBox) | |
| 338 | + | ch = boxBound(ch, cs.MinHeight, cs.MaxHeight, 0, inH, cs.BoxSizing == css.KeywordBorderBox) | |
| 339 | + | // Natural measurement is an unwrapped envelope; actual placement | |
| 340 | + | // distributes flex-basis and wraps against the definite container size. | |
| 341 | + | margin := edges(cs.Margin, w) | |
| 342 | + | cw += margin.horizontal() | |
| 343 | + | ch += margin.vertical() | |
| 344 | + | if row { | |
| 345 | + | if count > 0 { | |
| 346 | + | size.W += gap | |
| 347 | + | } | |
| 348 | + | size.W += cw | |
| 349 | + | size.H = math.Max(size.H, ch) | |
| 350 | + | } else if n.Kind == Stack { | |
| 351 | + | size.W = math.Max(size.W, cw) | |
| 352 | + | size.H = math.Max(size.H, ch) | |
| 353 | + | } else { | |
| 354 | + | if count > 0 && st.Display == css.KeywordFlex { | |
| 355 | + | size.H += gap | |
| 356 | + | } | |
| 357 | + | size.W = math.Max(size.W, cw) | |
| 358 | + | size.H += ch | |
| 359 | + | } | |
| 360 | + | count++ | |
| 361 | + | } | |
| 362 | + | return size, nil, nil | |
| 268 | 363 | } | |
| 269 | 364 | func (c context) place(n *Node, x, y, availableW, availableH float64, forcedW, forcedH *float64, depth int) (*Result, error) { | |
| 270 | 365 | if depth > 256 { | |
| @@ -364,6 +459,18 @@ func (c context) place(n *Node, x, y, availableW, availableH float64, forcedW, f | |||
| 364 | 459 | // Block and stack auto heights depend on their children's envelopes. | |
| 365 | 460 | flex := s.Display == css.KeywordFlex | |
| 366 | 461 | if flex { | |
| 462 | + | // An auto-height flex container needs its children's natural height | |
| 463 | + | // before distributing space. Otherwise every child is shrunk into zero. | |
| 464 | + | // This repeats subtree measurement for nested auto-height containers; | |
| 465 | + | // a node/width map cache cost more time and memory in our benchmarks. | |
| 466 | + | if !explicitH { | |
| 467 | + | natural, _, measureErr := c.intrinsicDepth(n, contentW, 1) | |
| 468 | + | if measureErr != nil { | |
| 469 | + | return nil, measureErr | |
| 470 | + | } | |
| 471 | + | r.Border.H = boxBound(math.Max(natural.H+insetH, h), s.MinHeight, s.MaxHeight, availableH, insetH, borderBox) | |
| 472 | + | r.Content.H = safe(r.Border.H - insetH) | |
| 473 | + | } | |
| 367 | 474 | err = c.flex(n, r, s, depth) | |
| 368 | 475 | } else { | |
| 369 | 476 | err = c.children(n, r, s, depth) | |
| @@ -609,7 +716,7 @@ func (c context) flex(n *Node, r *Result, s css.Style, depth int) error { | |||
| 609 | 716 | } else { | |
| 610 | 717 | measureWidth = crossSize | |
| 611 | 718 | } | |
| 612 | - | measured, _, err := c.intrinsic(ch, measureWidth) | |
| 719 | + | measured, _, err := c.intrinsicDepth(ch, measureWidth, 1) | |
| 613 | 720 | if err != nil { | |
| 614 | 721 | return err | |
| 615 | 722 | } | |
| @@ -743,56 +850,162 @@ func (c context) flex(n *Node, r *Result, s css.Style, depth int) error { | |||
| 743 | 850 | return nil | |
| 744 | 851 | } | |
| 745 | 852 | ||
| 853 | + | // slab hands out detached values or sub-slices from one exactly sized block, | |
| 854 | + | // so a display list costs a few allocations rather than one per shadow, run or | |
| 855 | + | // text command. Blocks are never resized or reused, so pointers and slices | |
| 856 | + | // stay valid for the life of the returned Output. Sub-slices have their | |
| 857 | + | // capacity clipped, so a caller's append cannot overwrite a neighbour. A | |
| 858 | + | // miscount, or a nil slab, only falls back to individual allocations. | |
| 859 | + | type slab[T any] struct{ block []T } | |
| 860 | + | ||
| 861 | + | func newSlab[T any](n int) *slab[T] { | |
| 862 | + | if n <= 0 { | |
| 863 | + | return nil | |
| 864 | + | } | |
| 865 | + | return &slab[T]{block: make([]T, 0, n)} | |
| 866 | + | } | |
| 867 | + | ||
| 868 | + | // one stores v in the slab and returns its address. | |
| 869 | + | func (a *slab[T]) one(v T) *T { | |
| 870 | + | if a == nil || len(a.block) == cap(a.block) { | |
| 871 | + | p := new(T) // copy, so v itself never escapes | |
| 872 | + | *p = v | |
| 873 | + | return p | |
| 874 | + | } | |
| 875 | + | a.block = append(a.block, v) | |
| 876 | + | return &a.block[len(a.block)-1] | |
| 877 | + | } | |
| 878 | + | ||
| 879 | + | // take returns an empty slice with capacity exactly n (nil for n == 0). | |
| 880 | + | func (a *slab[T]) take(n int) []T { | |
| 881 | + | if n <= 0 { | |
| 882 | + | return nil | |
| 883 | + | } | |
| 884 | + | if a == nil || cap(a.block)-len(a.block) < n { | |
| 885 | + | return make([]T, 0, n) | |
| 886 | + | } | |
| 887 | + | i := len(a.block) | |
| 888 | + | a.block = a.block[:i+n] | |
| 889 | + | return a.block[i : i : i+n] | |
| 890 | + | } | |
| 891 | + | ||
| 892 | + | // radiiOf resolves border-radius percentages against basis. | |
| 893 | + | func radiiOf(s *css.Style, basis float64) [4]float64 { | |
| 894 | + | return [4]float64{positive(s.BorderRadius.TopLeft, basis), positive(s.BorderRadius.TopRight, basis), positive(s.BorderRadius.BottomRight, basis), positive(s.BorderRadius.BottomLeft, basis)} | |
| 895 | + | } | |
| 896 | + | ||
| 897 | + | func hasOutline(s *css.Style, r *Result) bool { | |
| 898 | + | return s.OutlineStyle == css.KeywordSolid && positive(s.OutlineWidth, r.Border.W) > 0 | |
| 899 | + | } | |
| 900 | + | ||
| 901 | + | // countCommands returns how many commands and shadow copies paint emits for | |
| 902 | + | // the subtree, so Layout can size the display list once. It mirrors paint's | |
| 903 | + | // conditions; if it ever drifts, paint still appends correctly and only regrows. | |
| 904 | + | func countCommands(n *Node, r *Result) (c counts) { | |
| 905 | + | if r == nil { | |
| 906 | + | return c | |
| 907 | + | } | |
| 908 | + | s := style(n) | |
| 909 | + | total := len(s.BoxShadow) | |
| 910 | + | c.shadows = len(s.BoxShadow) | |
| 911 | + | if s.BackgroundColor.A > 0 { | |
| 912 | + | total++ | |
| 913 | + | } | |
| 914 | + | if r.BorderWidths != (Edges{}) || radiiOf(&s, r.Border.W) != ([4]float64{}) { | |
| 915 | + | total++ | |
| 916 | + | } | |
| 917 | + | if hasOutline(&s, r) { | |
| 918 | + | total++ | |
| 919 | + | } | |
| 920 | + | if r.Clipped { | |
| 921 | + | total += 2 | |
| 922 | + | } | |
| 923 | + | if n.Kind == Image || n.Kind == Text { | |
| 924 | + | total++ | |
| 925 | + | } | |
| 926 | + | if n.Kind == Text { | |
| 927 | + | c.runs, c.glyphs = textCounts(r) | |
| 928 | + | } | |
| 929 | + | j := 0 | |
| 930 | + | for _, child := range n.Children { | |
| 931 | + | if child == nil || style(child).Display == css.KeywordNone { | |
| 932 | + | continue | |
| 933 | + | } | |
| 934 | + | if j < len(r.Children) { | |
| 935 | + | cc := countCommands(child, r.Children[j]) | |
| 936 | + | total += cc.commands | |
| 937 | + | c.shadows += cc.shadows | |
| 938 | + | c.runs += cc.runs | |
| 939 | + | c.glyphs += cc.glyphs | |
| 940 | + | j++ | |
| 941 | + | } | |
| 942 | + | } | |
| 943 | + | c.commands += total | |
| 944 | + | return c | |
| 945 | + | } | |
| 946 | + | ||
| 947 | + | type counts struct{ commands, shadows, runs, glyphs int } | |
| 948 | + | ||
| 949 | + | func textCounts(r *Result) (runs, glyphs int) { | |
| 950 | + | for i := range r.Lines { | |
| 951 | + | runs += len(r.Lines[i].Runs) | |
| 952 | + | for j := range r.Lines[i].Runs { | |
| 953 | + | glyphs += len(r.Lines[i].Runs[j].Glyphs) | |
| 954 | + | } | |
| 955 | + | } | |
| 956 | + | return runs, glyphs | |
| 957 | + | } | |
| 958 | + | ||
| 746 | 959 | func (c context) paint(n *Node, r *Result, out *[]Command) { | |
| 747 | 960 | if r == nil { | |
| 748 | 961 | return | |
| 749 | 962 | } | |
| 750 | 963 | s := style(n) | |
| 751 | 964 | base := Command{ID: r.ID, Rect: r.Border, Opacity: math.Min(1, safe(s.Opacity))} | |
| 965 | + | borderRadii := radiiOf(&s, r.Border.W) | |
| 752 | 966 | for i := range s.BoxShadow { | |
| 753 | - | sh := s.BoxShadow[i] | |
| 754 | - | if sh.Inset { | |
| 967 | + | if s.BoxShadow[i].Inset { | |
| 755 | 968 | continue | |
| 756 | 969 | } | |
| 757 | 970 | cmd := base | |
| 758 | 971 | cmd.Op = "shadow" | |
| 759 | - | cmd.Shadow = &sh | |
| 760 | - | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)} | |
| 972 | + | cmd.Shadow = c.shadows.one(s.BoxShadow[i]) | |
| 973 | + | cmd.Radii = borderRadii | |
| 761 | 974 | *out = append(*out, cmd) | |
| 762 | 975 | } | |
| 763 | 976 | if s.BackgroundColor.A > 0 { | |
| 764 | 977 | cmd := base | |
| 765 | 978 | cmd.Op = "rect" | |
| 766 | 979 | cmd.Color = s.BackgroundColor | |
| 767 | - | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)} | |
| 980 | + | cmd.Radii = borderRadii | |
| 768 | 981 | *out = append(*out, cmd) | |
| 769 | 982 | } | |
| 770 | 983 | cmd := base | |
| 771 | 984 | cmd.Op = "border" | |
| 772 | 985 | cmd.Widths = r.BorderWidths | |
| 773 | 986 | cmd.Colors = s.BorderColor | |
| 774 | - | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)} | |
| 987 | + | cmd.Radii = borderRadii | |
| 775 | 988 | if cmd.Widths != (Edges{}) || cmd.Radii != ([4]float64{}) { | |
| 776 | 989 | cmd.Color = s.BorderColor.Top | |
| 777 | 990 | *out = append(*out, cmd) | |
| 778 | 991 | } | |
| 779 | 992 | for i := range s.BoxShadow { | |
| 780 | - | sh := s.BoxShadow[i] | |
| 781 | - | if !sh.Inset { | |
| 993 | + | if !s.BoxShadow[i].Inset { | |
| 782 | 994 | continue | |
| 783 | 995 | } | |
| 784 | 996 | cmd := base | |
| 785 | 997 | cmd.Op = "shadow" | |
| 786 | - | cmd.Shadow = &sh | |
| 787 | - | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)} | |
| 998 | + | cmd.Shadow = c.shadows.one(s.BoxShadow[i]) | |
| 999 | + | cmd.Radii = borderRadii | |
| 788 | 1000 | *out = append(*out, cmd) | |
| 789 | 1001 | } | |
| 790 | - | if s.OutlineStyle == css.KeywordSolid && positive(s.OutlineWidth, r.Border.W) > 0 { | |
| 1002 | + | if hasOutline(&s, r) { | |
| 791 | 1003 | cmd = base | |
| 792 | 1004 | cmd.Op = "outline" | |
| 793 | 1005 | cmd.Color = s.OutlineColor | |
| 794 | - | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)} | |
| 795 | - | cmd.Widths = Edges{Top: positive(s.OutlineWidth, r.Border.W), Right: positive(s.OutlineWidth, r.Border.W), Bottom: positive(s.OutlineWidth, r.Border.W), Left: positive(s.OutlineWidth, r.Border.W)} | |
| 1006 | + | cmd.Radii = borderRadii | |
| 1007 | + | width := positive(s.OutlineWidth, r.Border.W) | |
| 1008 | + | cmd.Widths = Edges{Top: width, Right: width, Bottom: width, Left: width} | |
| 796 | 1009 | offset := coordValue(s.OutlineOffset, r.Border.W) | |
| 797 | 1010 | cmd.Rect = Rect{X: coord(r.Border.X - offset), Y: coord(r.Border.Y - offset), W: safe(r.Border.W + 2*offset), H: safe(r.Border.H + 2*offset)} | |
| 798 | 1011 | *out = append(*out, cmd) | |
| @@ -808,7 +1021,7 @@ func (c context) paint(n *Node, r *Result, out *[]Command) { | |||
| 808 | 1021 | cmd.Op = "image" | |
| 809 | 1022 | cmd.Rect = r.Content | |
| 810 | 1023 | cmd.Image = n.Image | |
| 811 | - | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Content.W), positive(s.BorderRadius.TopRight, r.Content.W), positive(s.BorderRadius.BottomRight, r.Content.W), positive(s.BorderRadius.BottomLeft, r.Content.W)} | |
| 1024 | + | cmd.Radii = radiiOf(&s, r.Content.W) | |
| 812 | 1025 | *out = append(*out, cmd) | |
| 813 | 1026 | } | |
| 814 | 1027 | if n.Kind == Text { | |
| @@ -817,6 +1030,9 @@ func (c context) paint(n *Node, r *Result, out *[]Command) { | |||
| 817 | 1030 | cmd.Rect = r.Content | |
| 818 | 1031 | cmd.Text = n.Content | |
| 819 | 1032 | cmd.Color = s.Color | |
| 1033 | + | runs, glyphs := textCounts(r) | |
| 1034 | + | cmd.Runs = c.runs.take(runs) | |
| 1035 | + | backing := c.glyphs.take(glyphs) | |
| 820 | 1036 | for _, line := range r.Lines { | |
| 821 | 1037 | offset := AlignOffset(s.TextAlign, line.Direction, r.Content.W, line.Width) | |
| 822 | 1038 | for _, run := range line.Runs { | |
| @@ -827,8 +1043,12 @@ func (c context) paint(n *Node, r *Result, out *[]Command) { | |||
| 827 | 1043 | if run.Face != nil { | |
| 828 | 1044 | rr.FaceID = run.Face.ID | |
| 829 | 1045 | } | |
| 830 | - | for _, g := range run.Glyphs { | |
| 831 | - | rr.Glyphs = append(rr.Glyphs, Glyph{uint32(g.ID), g.X + r.Content.X + offset - r.ScrollX, g.Y + r.Content.Y - r.ScrollY, g.Advance, g.Cluster}) | |
| 1046 | + | if len(run.Glyphs) > 0 { // keep nil for glyph-less runs | |
| 1047 | + | first := len(backing) | |
| 1048 | + | for _, g := range run.Glyphs { | |
| 1049 | + | backing = append(backing, Glyph{uint32(g.ID), g.X + r.Content.X + offset - r.ScrollX, g.Y + r.Content.Y - r.ScrollY, g.Advance, g.Cluster}) | |
| 1050 | + | } | |
| 1051 | + | rr.Glyphs = backing[first:len(backing):len(backing)] | |
| 832 | 1052 | } | |
| 833 | 1053 | cmd.Runs = append(cmd.Runs, rr) | |
| 834 | 1054 | } | |
| @@ -178,11 +178,29 @@ func Layout(root *Node, options Options) (Output, error) { | |||||
| 178 | return Output{}, err | 178 | return Output{}, err | ||
| 179 | } | 179 | } | ||
| 180 | out := Output{Tree: tree} | 180 | out := Output{Tree: tree} | ||
| 181 | - | ctx.paint(root, tree, &out.Display) | 181 | + | out.Display = ctx.display(root, tree) |
| 182 | return out, nil | 182 | return out, nil | ||
| 183 | } | 183 | } | ||
| 184 | 184 | ||||
| 185 | - | type context struct{ options Options } | 185 | + | // display paints the placed tree into a list sized once from the tree. |
| 186 | + | func (c context) display(root *Node, tree *Result) []Command { | |||
| 187 | + | n := countCommands(root, tree) | |||
| 188 | + | if n.commands == 0 { | |||
| 189 | + | return nil | |||
| 190 | + | } | |||
| 191 | + | out := make([]Command, 0, n.commands) | |||
| 192 | + | c.shadows, c.runs, c.glyphs = newSlab[css.Shadow](n.shadows), newSlab[Run](n.runs), newSlab[Glyph](n.glyphs) | |||
| 193 | + | c.paint(root, tree, &out) | |||
| 194 | + | return out | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | type context struct { | |||
| 198 | + | options Options | |||
| 199 | + | // Painter-owned display storage, sized by countCommands; nil is valid. | |||
| 200 | + | shadows *slab[css.Shadow] | |||
| 201 | + | runs *slab[Run] | |||
| 202 | + | glyphs *slab[Glyph] | |||
| 203 | + | } | |||
| 186 | 204 | ||||
| 187 | func style(n *Node) css.Style { | 205 | func style(n *Node) css.Style { | ||
| 188 | if n.Style != nil { | 206 | if n.Style != nil { | ||
| @@ -209,6 +227,13 @@ func boxBound(border float64, minL, maxL css.Length, basis, insets float64, bord | |||||
| 209 | return safe(bounded(safe(border-insets), minL, maxL, basis) + insets) | 227 | return safe(bounded(safe(border-insets), minL, maxL, basis) + insets) | ||
| 210 | } | 228 | } | ||
| 211 | func (c context) intrinsic(n *Node, w float64) (Size, []text.Line, error) { | 229 | func (c context) intrinsic(n *Node, w float64) (Size, []text.Line, error) { | ||
| 230 | + | return c.intrinsicDepth(n, w, 0) | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | func (c context) intrinsicDepth(n *Node, w float64, depth int) (Size, []text.Line, error) { | |||
| 234 | + | if depth > 256 { | |||
| 235 | + | return Size{}, nil, ErrDepth | |||
| 236 | + | } | |||
| 212 | if n.Measure != nil { | 237 | if n.Measure != nil { | ||
| 213 | s, lines, err := n.Measure(w) | 238 | s, lines, err := n.Measure(w) | ||
| 214 | if err != nil { | 239 | if err != nil { | ||
| @@ -230,7 +255,13 @@ func (c context) intrinsic(n *Node, w float64) (Size, []text.Line, error) { | |||||
| 230 | if req.Size == 0 { | 255 | if req.Size == 0 { | ||
| 231 | req.Size = 16 | 256 | req.Size = 16 | ||
| 232 | } | 257 | } | ||
| 233 | - | l, err := c.options.TextEngine.Measure(n.Content, req, w) | 258 | + | content := n.Content |
| 259 | + | if content == "" && n.EditorScroll { | |||
| 260 | + | // Preserve one font-metric line for empty editors, caret geometry | |||
| 261 | + | // and IME composition without painting a visible glyph. | |||
| 262 | + | content = " " | |||
| 263 | + | } | |||
| 264 | + | l, err := c.options.TextEngine.Measure(content, req, w) | |||
| 234 | if err != nil { | 265 | if err != nil { | ||
| 235 | return Size{}, nil, err | 266 | return Size{}, nil, err | ||
| 236 | } | 267 | } | ||
| @@ -264,7 +295,71 @@ func (c context) intrinsic(n *Node, w float64) (Size, []text.Line, error) { | |||||
| 264 | } | 295 | } | ||
| 265 | return Size{safe(l.Width), safe(l.Height)}, l.Lines, nil | 296 | return Size{safe(l.Width), safe(l.Height)}, l.Lines, nil | ||
| 266 | } | 297 | } | ||
| 267 | - | return Size{}, nil, nil | 298 | + | if depth == 0 { |
| 299 | + | return Size{}, nil, nil | |||
| 300 | + | } | |||
| 301 | + | // Containers contribute their children to auto flex sizing. Measuring them | |||
| 302 | + | // as zero compresses nested columns and makes empty editors collapse. | |||
| 303 | + | st := style(n) | |||
| 304 | + | row := st.Display == css.KeywordFlex && st.FlexDirection != css.KeywordColumn | |||
| 305 | + | gap := positive(st.RowGap, w) | |||
| 306 | + | if row { | |||
| 307 | + | gap = positive(st.ColumnGap, w) | |||
| 308 | + | } | |||
| 309 | + | var size Size | |||
| 310 | + | count := 0 | |||
| 311 | + | for _, child := range n.Children { | |||
| 312 | + | if child == nil || style(child).Display == css.KeywordNone { | |||
| 313 | + | continue | |||
| 314 | + | } | |||
| 315 | + | cs := style(child) | |||
| 316 | + | inW, inH := flexInsets(cs, w) | |||
| 317 | + | measured, _, err := c.intrinsicDepth(child, safe(w-inW), depth+1) | |||
| 318 | + | if err != nil { | |||
| 319 | + | return Size{}, nil, err | |||
| 320 | + | } | |||
| 321 | + | cw, ch := measured.W+inW, measured.H+inH | |||
| 322 | + | if value, ok := length(cs.Width, w); ok && cs.Width.Unit != "%" { | |||
| 323 | + | cw = value | |||
| 324 | + | if cs.BoxSizing != css.KeywordBorderBox { | |||
| 325 | + | cw += inW | |||
| 326 | + | } | |||
| 327 | + | } | |||
| 328 | + | // Percent heights need a definite containing height, unavailable here. | |||
| 329 | + | if cs.Height.Unit != "%" { | |||
| 330 | + | if value, ok := length(cs.Height, 0); ok { | |||
| 331 | + | ch = value | |||
| 332 | + | if cs.BoxSizing != css.KeywordBorderBox { | |||
| 333 | + | ch += inH | |||
| 334 | + | } | |||
| 335 | + | } | |||
| 336 | + | } | |||
| 337 | + | cw = boxBound(cw, cs.MinWidth, cs.MaxWidth, w, inW, cs.BoxSizing == css.KeywordBorderBox) | |||
| 338 | + | ch = boxBound(ch, cs.MinHeight, cs.MaxHeight, 0, inH, cs.BoxSizing == css.KeywordBorderBox) | |||
| 339 | + | // Natural measurement is an unwrapped envelope; actual placement | |||
| 340 | + | // distributes flex-basis and wraps against the definite container size. | |||
| 341 | + | margin := edges(cs.Margin, w) | |||
| 342 | + | cw += margin.horizontal() | |||
| 343 | + | ch += margin.vertical() | |||
| 344 | + | if row { | |||
| 345 | + | if count > 0 { | |||
| 346 | + | size.W += gap | |||
| 347 | + | } | |||
| 348 | + | size.W += cw | |||
| 349 | + | size.H = math.Max(size.H, ch) | |||
| 350 | + | } else if n.Kind == Stack { | |||
| 351 | + | size.W = math.Max(size.W, cw) | |||
| 352 | + | size.H = math.Max(size.H, ch) | |||
| 353 | + | } else { | |||
| 354 | + | if count > 0 && st.Display == css.KeywordFlex { | |||
| 355 | + | size.H += gap | |||
| 356 | + | } | |||
| 357 | + | size.W = math.Max(size.W, cw) | |||
| 358 | + | size.H += ch | |||
| 359 | + | } | |||
| 360 | + | count++ | |||
| 361 | + | } | |||
| 362 | + | return size, nil, nil | |||
| 268 | } | 363 | } | ||
| 269 | func (c context) place(n *Node, x, y, availableW, availableH float64, forcedW, forcedH *float64, depth int) (*Result, error) { | 364 | func (c context) place(n *Node, x, y, availableW, availableH float64, forcedW, forcedH *float64, depth int) (*Result, error) { | ||
| 270 | if depth > 256 { | 365 | if depth > 256 { | ||
| @@ -364,6 +459,18 @@ func (c context) place(n *Node, x, y, availableW, availableH float64, forcedW, f | |||||
| 364 | // Block and stack auto heights depend on their children's envelopes. | 459 | // Block and stack auto heights depend on their children's envelopes. | ||
| 365 | flex := s.Display == css.KeywordFlex | 460 | flex := s.Display == css.KeywordFlex | ||
| 366 | if flex { | 461 | if flex { | ||
| 462 | + | // An auto-height flex container needs its children's natural height | |||
| 463 | + | // before distributing space. Otherwise every child is shrunk into zero. | |||
| 464 | + | // This repeats subtree measurement for nested auto-height containers; | |||
| 465 | + | // a node/width map cache cost more time and memory in our benchmarks. | |||
| 466 | + | if !explicitH { | |||
| 467 | + | natural, _, measureErr := c.intrinsicDepth(n, contentW, 1) | |||
| 468 | + | if measureErr != nil { | |||
| 469 | + | return nil, measureErr | |||
| 470 | + | } | |||
| 471 | + | r.Border.H = boxBound(math.Max(natural.H+insetH, h), s.MinHeight, s.MaxHeight, availableH, insetH, borderBox) | |||
| 472 | + | r.Content.H = safe(r.Border.H - insetH) | |||
| 473 | + | } | |||
| 367 | err = c.flex(n, r, s, depth) | 474 | err = c.flex(n, r, s, depth) | ||
| 368 | } else { | 475 | } else { | ||
| 369 | err = c.children(n, r, s, depth) | 476 | err = c.children(n, r, s, depth) | ||
| @@ -609,7 +716,7 @@ func (c context) flex(n *Node, r *Result, s css.Style, depth int) error { | |||||
| 609 | } else { | 716 | } else { | ||
| 610 | measureWidth = crossSize | 717 | measureWidth = crossSize | ||
| 611 | } | 718 | } | ||
| 612 | - | measured, _, err := c.intrinsic(ch, measureWidth) | 719 | + | measured, _, err := c.intrinsicDepth(ch, measureWidth, 1) |
| 613 | if err != nil { | 720 | if err != nil { | ||
| 614 | return err | 721 | return err | ||
| 615 | } | 722 | } | ||
| @@ -743,56 +850,162 @@ func (c context) flex(n *Node, r *Result, s css.Style, depth int) error { | |||||
| 743 | return nil | 850 | return nil | ||
| 744 | } | 851 | } | ||
| 745 | 852 | ||||
| 853 | + | // slab hands out detached values or sub-slices from one exactly sized block, | |||
| 854 | + | // so a display list costs a few allocations rather than one per shadow, run or | |||
| 855 | + | // text command. Blocks are never resized or reused, so pointers and slices | |||
| 856 | + | // stay valid for the life of the returned Output. Sub-slices have their | |||
| 857 | + | // capacity clipped, so a caller's append cannot overwrite a neighbour. A | |||
| 858 | + | // miscount, or a nil slab, only falls back to individual allocations. | |||
| 859 | + | type slab[T any] struct{ block []T } | |||
| 860 | + | ||||
| 861 | + | func newSlab[T any](n int) *slab[T] { | |||
| 862 | + | if n <= 0 { | |||
| 863 | + | return nil | |||
| 864 | + | } | |||
| 865 | + | return &slab[T]{block: make([]T, 0, n)} | |||
| 866 | + | } | |||
| 867 | + | ||||
| 868 | + | // one stores v in the slab and returns its address. | |||
| 869 | + | func (a *slab[T]) one(v T) *T { | |||
| 870 | + | if a == nil || len(a.block) == cap(a.block) { | |||
| 871 | + | p := new(T) // copy, so v itself never escapes | |||
| 872 | + | *p = v | |||
| 873 | + | return p | |||
| 874 | + | } | |||
| 875 | + | a.block = append(a.block, v) | |||
| 876 | + | return &a.block[len(a.block)-1] | |||
| 877 | + | } | |||
| 878 | + | ||||
| 879 | + | // take returns an empty slice with capacity exactly n (nil for n == 0). | |||
| 880 | + | func (a *slab[T]) take(n int) []T { | |||
| 881 | + | if n <= 0 { | |||
| 882 | + | return nil | |||
| 883 | + | } | |||
| 884 | + | if a == nil || cap(a.block)-len(a.block) < n { | |||
| 885 | + | return make([]T, 0, n) | |||
| 886 | + | } | |||
| 887 | + | i := len(a.block) | |||
| 888 | + | a.block = a.block[:i+n] | |||
| 889 | + | return a.block[i : i : i+n] | |||
| 890 | + | } | |||
| 891 | + | ||||
| 892 | + | // radiiOf resolves border-radius percentages against basis. | |||
| 893 | + | func radiiOf(s *css.Style, basis float64) [4]float64 { | |||
| 894 | + | return [4]float64{positive(s.BorderRadius.TopLeft, basis), positive(s.BorderRadius.TopRight, basis), positive(s.BorderRadius.BottomRight, basis), positive(s.BorderRadius.BottomLeft, basis)} | |||
| 895 | + | } | |||
| 896 | + | ||||
| 897 | + | func hasOutline(s *css.Style, r *Result) bool { | |||
| 898 | + | return s.OutlineStyle == css.KeywordSolid && positive(s.OutlineWidth, r.Border.W) > 0 | |||
| 899 | + | } | |||
| 900 | + | ||||
| 901 | + | // countCommands returns how many commands and shadow copies paint emits for | |||
| 902 | + | // the subtree, so Layout can size the display list once. It mirrors paint's | |||
| 903 | + | // conditions; if it ever drifts, paint still appends correctly and only regrows. | |||
| 904 | + | func countCommands(n *Node, r *Result) (c counts) { | |||
| 905 | + | if r == nil { | |||
| 906 | + | return c | |||
| 907 | + | } | |||
| 908 | + | s := style(n) | |||
| 909 | + | total := len(s.BoxShadow) | |||
| 910 | + | c.shadows = len(s.BoxShadow) | |||
| 911 | + | if s.BackgroundColor.A > 0 { | |||
| 912 | + | total++ | |||
| 913 | + | } | |||
| 914 | + | if r.BorderWidths != (Edges{}) || radiiOf(&s, r.Border.W) != ([4]float64{}) { | |||
| 915 | + | total++ | |||
| 916 | + | } | |||
| 917 | + | if hasOutline(&s, r) { | |||
| 918 | + | total++ | |||
| 919 | + | } | |||
| 920 | + | if r.Clipped { | |||
| 921 | + | total += 2 | |||
| 922 | + | } | |||
| 923 | + | if n.Kind == Image || n.Kind == Text { | |||
| 924 | + | total++ | |||
| 925 | + | } | |||
| 926 | + | if n.Kind == Text { | |||
| 927 | + | c.runs, c.glyphs = textCounts(r) | |||
| 928 | + | } | |||
| 929 | + | j := 0 | |||
| 930 | + | for _, child := range n.Children { | |||
| 931 | + | if child == nil || style(child).Display == css.KeywordNone { | |||
| 932 | + | continue | |||
| 933 | + | } | |||
| 934 | + | if j < len(r.Children) { | |||
| 935 | + | cc := countCommands(child, r.Children[j]) | |||
| 936 | + | total += cc.commands | |||
| 937 | + | c.shadows += cc.shadows | |||
| 938 | + | c.runs += cc.runs | |||
| 939 | + | c.glyphs += cc.glyphs | |||
| 940 | + | j++ | |||
| 941 | + | } | |||
| 942 | + | } | |||
| 943 | + | c.commands += total | |||
| 944 | + | return c | |||
| 945 | + | } | |||
| 946 | + | ||||
| 947 | + | type counts struct{ commands, shadows, runs, glyphs int } | |||
| 948 | + | ||||
| 949 | + | func textCounts(r *Result) (runs, glyphs int) { | |||
| 950 | + | for i := range r.Lines { | |||
| 951 | + | runs += len(r.Lines[i].Runs) | |||
| 952 | + | for j := range r.Lines[i].Runs { | |||
| 953 | + | glyphs += len(r.Lines[i].Runs[j].Glyphs) | |||
| 954 | + | } | |||
| 955 | + | } | |||
| 956 | + | return runs, glyphs | |||
| 957 | + | } | |||
| 958 | + | ||||
| 746 | func (c context) paint(n *Node, r *Result, out *[]Command) { | 959 | func (c context) paint(n *Node, r *Result, out *[]Command) { | ||
| 747 | if r == nil { | 960 | if r == nil { | ||
| 748 | return | 961 | return | ||
| 749 | } | 962 | } | ||
| 750 | s := style(n) | 963 | s := style(n) | ||
| 751 | base := Command{ID: r.ID, Rect: r.Border, Opacity: math.Min(1, safe(s.Opacity))} | 964 | base := Command{ID: r.ID, Rect: r.Border, Opacity: math.Min(1, safe(s.Opacity))} | ||
| 965 | + | borderRadii := radiiOf(&s, r.Border.W) | |||
| 752 | for i := range s.BoxShadow { | 966 | for i := range s.BoxShadow { | ||
| 753 | - | sh := s.BoxShadow[i] | 967 | + | if s.BoxShadow[i].Inset { |
| 754 | - | if sh.Inset { | |||
| 755 | continue | 968 | continue | ||
| 756 | } | 969 | } | ||
| 757 | cmd := base | 970 | cmd := base | ||
| 758 | cmd.Op = "shadow" | 971 | cmd.Op = "shadow" | ||
| 759 | - | cmd.Shadow = &sh | 972 | + | cmd.Shadow = c.shadows.one(s.BoxShadow[i]) |
| 760 | - | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)} | 973 | + | cmd.Radii = borderRadii |
| 761 | *out = append(*out, cmd) | 974 | *out = append(*out, cmd) | ||
| 762 | } | 975 | } | ||
| 763 | if s.BackgroundColor.A > 0 { | 976 | if s.BackgroundColor.A > 0 { | ||
| 764 | cmd := base | 977 | cmd := base | ||
| 765 | cmd.Op = "rect" | 978 | cmd.Op = "rect" | ||
| 766 | cmd.Color = s.BackgroundColor | 979 | cmd.Color = s.BackgroundColor | ||
| 767 | - | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)} | 980 | + | cmd.Radii = borderRadii |
| 768 | *out = append(*out, cmd) | 981 | *out = append(*out, cmd) | ||
| 769 | } | 982 | } | ||
| 770 | cmd := base | 983 | cmd := base | ||
| 771 | cmd.Op = "border" | 984 | cmd.Op = "border" | ||
| 772 | cmd.Widths = r.BorderWidths | 985 | cmd.Widths = r.BorderWidths | ||
| 773 | cmd.Colors = s.BorderColor | 986 | cmd.Colors = s.BorderColor | ||
| 774 | - | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)} | 987 | + | cmd.Radii = borderRadii |
| 775 | if cmd.Widths != (Edges{}) || cmd.Radii != ([4]float64{}) { | 988 | if cmd.Widths != (Edges{}) || cmd.Radii != ([4]float64{}) { | ||
| 776 | cmd.Color = s.BorderColor.Top | 989 | cmd.Color = s.BorderColor.Top | ||
| 777 | *out = append(*out, cmd) | 990 | *out = append(*out, cmd) | ||
| 778 | } | 991 | } | ||
| 779 | for i := range s.BoxShadow { | 992 | for i := range s.BoxShadow { | ||
| 780 | - | sh := s.BoxShadow[i] | 993 | + | if !s.BoxShadow[i].Inset { |
| 781 | - | if !sh.Inset { | |||
| 782 | continue | 994 | continue | ||
| 783 | } | 995 | } | ||
| 784 | cmd := base | 996 | cmd := base | ||
| 785 | cmd.Op = "shadow" | 997 | cmd.Op = "shadow" | ||
| 786 | - | cmd.Shadow = &sh | 998 | + | cmd.Shadow = c.shadows.one(s.BoxShadow[i]) |
| 787 | - | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)} | 999 | + | cmd.Radii = borderRadii |
| 788 | *out = append(*out, cmd) | 1000 | *out = append(*out, cmd) | ||
| 789 | } | 1001 | } | ||
| 790 | - | if s.OutlineStyle == css.KeywordSolid && positive(s.OutlineWidth, r.Border.W) > 0 { | 1002 | + | if hasOutline(&s, r) { |
| 791 | cmd = base | 1003 | cmd = base | ||
| 792 | cmd.Op = "outline" | 1004 | cmd.Op = "outline" | ||
| 793 | cmd.Color = s.OutlineColor | 1005 | cmd.Color = s.OutlineColor | ||
| 794 | - | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)} | 1006 | + | cmd.Radii = borderRadii |
| 795 | - | cmd.Widths = Edges{Top: positive(s.OutlineWidth, r.Border.W), Right: positive(s.OutlineWidth, r.Border.W), Bottom: positive(s.OutlineWidth, r.Border.W), Left: positive(s.OutlineWidth, r.Border.W)} | 1007 | + | width := positive(s.OutlineWidth, r.Border.W) |
| 1008 | + | cmd.Widths = Edges{Top: width, Right: width, Bottom: width, Left: width} | |||
| 796 | offset := coordValue(s.OutlineOffset, r.Border.W) | 1009 | offset := coordValue(s.OutlineOffset, r.Border.W) | ||
| 797 | cmd.Rect = Rect{X: coord(r.Border.X - offset), Y: coord(r.Border.Y - offset), W: safe(r.Border.W + 2*offset), H: safe(r.Border.H + 2*offset)} | 1010 | cmd.Rect = Rect{X: coord(r.Border.X - offset), Y: coord(r.Border.Y - offset), W: safe(r.Border.W + 2*offset), H: safe(r.Border.H + 2*offset)} | ||
| 798 | *out = append(*out, cmd) | 1011 | *out = append(*out, cmd) | ||
| @@ -808,7 +1021,7 @@ func (c context) paint(n *Node, r *Result, out *[]Command) { | |||||
| 808 | cmd.Op = "image" | 1021 | cmd.Op = "image" | ||
| 809 | cmd.Rect = r.Content | 1022 | cmd.Rect = r.Content | ||
| 810 | cmd.Image = n.Image | 1023 | cmd.Image = n.Image | ||
| 811 | - | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Content.W), positive(s.BorderRadius.TopRight, r.Content.W), positive(s.BorderRadius.BottomRight, r.Content.W), positive(s.BorderRadius.BottomLeft, r.Content.W)} | 1024 | + | cmd.Radii = radiiOf(&s, r.Content.W) |
| 812 | *out = append(*out, cmd) | 1025 | *out = append(*out, cmd) | ||
| 813 | } | 1026 | } | ||
| 814 | if n.Kind == Text { | 1027 | if n.Kind == Text { | ||
| @@ -817,6 +1030,9 @@ func (c context) paint(n *Node, r *Result, out *[]Command) { | |||||
| 817 | cmd.Rect = r.Content | 1030 | cmd.Rect = r.Content | ||
| 818 | cmd.Text = n.Content | 1031 | cmd.Text = n.Content | ||
| 819 | cmd.Color = s.Color | 1032 | cmd.Color = s.Color | ||
| 1033 | + | runs, glyphs := textCounts(r) | |||
| 1034 | + | cmd.Runs = c.runs.take(runs) | |||
| 1035 | + | backing := c.glyphs.take(glyphs) | |||
| 820 | for _, line := range r.Lines { | 1036 | for _, line := range r.Lines { | ||
| 821 | offset := AlignOffset(s.TextAlign, line.Direction, r.Content.W, line.Width) | 1037 | offset := AlignOffset(s.TextAlign, line.Direction, r.Content.W, line.Width) | ||
| 822 | for _, run := range line.Runs { | 1038 | for _, run := range line.Runs { | ||
| @@ -827,8 +1043,12 @@ func (c context) paint(n *Node, r *Result, out *[]Command) { | |||||
| 827 | if run.Face != nil { | 1043 | if run.Face != nil { | ||
| 828 | rr.FaceID = run.Face.ID | 1044 | rr.FaceID = run.Face.ID | ||
| 829 | } | 1045 | } | ||
| 830 | - | for _, g := range run.Glyphs { | 1046 | + | if len(run.Glyphs) > 0 { // keep nil for glyph-less runs |
| 831 | - | rr.Glyphs = append(rr.Glyphs, Glyph{uint32(g.ID), g.X + r.Content.X + offset - r.ScrollX, g.Y + r.Content.Y - r.ScrollY, g.Advance, g.Cluster}) | 1047 | + | first := len(backing) |
| 1048 | + | for _, g := range run.Glyphs { | |||
| 1049 | + | backing = append(backing, Glyph{uint32(g.ID), g.X + r.Content.X + offset - r.ScrollX, g.Y + r.Content.Y - r.ScrollY, g.Advance, g.Cluster}) | |||
| 1050 | + | } | |||
| 1051 | + | rr.Glyphs = backing[first:len(backing):len(backing)] | |||
| 832 | } | 1052 | } | ||
| 833 | cmd.Runs = append(cmd.Runs, rr) | 1053 | cmd.Runs = append(cmd.Runs, rr) | ||
| 834 | } | 1054 | } | ||
A internal/layout/paint_bench_test.go +66 -0
| @@ -0,0 +1,66 @@ | |||
| 1 | + | package layout | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/nefergui/internal/css" | |
| 8 | + | "github.com/bnema/nefergui/internal/text" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // paintFixture is a product-like tree: cards with background, border, shadows, | |
| 12 | + | // a clip and shaped text children. | |
| 13 | + | func paintFixture(tb testing.TB, cards int) (*Node, Options) { | |
| 14 | + | tb.Helper() | |
| 15 | + | catalog, err := text.Load(text.DirectorySource("../../testdata/fonts")) | |
| 16 | + | if err != nil { | |
| 17 | + | tb.Fatal(err) | |
| 18 | + | } | |
| 19 | + | engine := text.NewEngine(catalog) | |
| 20 | + | card := css.Style{ | |
| 21 | + | Display: css.KeywordBlock, Height: px(60), Padding: css.LengthSides{Top: px(4), Left: px(4)}, | |
| 22 | + | BackgroundColor: css.Color{B: 1, A: 1}, BorderWidth: css.LengthSides{Top: px(1), Right: px(1), Bottom: px(1), Left: px(1)}, | |
| 23 | + | BorderStyle: css.KeywordSides{Top: css.KeywordSolid, Right: css.KeywordSolid, Bottom: css.KeywordSolid, Left: css.KeywordSolid}, | |
| 24 | + | OverflowX: css.KeywordHidden, OverflowY: css.KeywordHidden, | |
| 25 | + | BoxShadow: []css.Shadow{{Y: px(2), Blur: px(4), Color: css.Color{A: .3}}, {Inset: true, Blur: px(2), Color: css.Color{A: .2}}}, | |
| 26 | + | } | |
| 27 | + | label := css.Style{Display: css.KeywordBlock, FontSize: px(14), Color: css.Color{A: 1}} | |
| 28 | + | children := make([]*Node, cards) | |
| 29 | + | for i := range children { | |
| 30 | + | t1 := node("t", Text, label) | |
| 31 | + | t1.Content = fmt.Sprintf("Élément %d — une interface native écrite en Go", i) | |
| 32 | + | children[i] = node("card", Box, card, t1) | |
| 33 | + | } | |
| 34 | + | return node("root", Column, css.Style{Display: css.KeywordBlock}, children...), Options{Width: 400, Height: 3000, TextEngine: engine} | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // BenchmarkPaint measures only context.paint on an already placed tree. | |
| 38 | + | func BenchmarkPaint(b *testing.B) { | |
| 39 | + | root, opts := paintFixture(b, 100) | |
| 40 | + | ctx := context{options: opts} | |
| 41 | + | tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0) | |
| 42 | + | if err != nil { | |
| 43 | + | b.Fatal(err) | |
| 44 | + | } | |
| 45 | + | b.ReportAllocs() | |
| 46 | + | b.ResetTimer() | |
| 47 | + | for i := 0; i < b.N; i++ { | |
| 48 | + | if display := ctx.display(root, tree); len(display) == 0 { | |
| 49 | + | b.Fatal("empty") | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func BenchmarkLayoutPaintTree(b *testing.B) { | |
| 55 | + | root, opts := paintFixture(b, 100) | |
| 56 | + | if _, err := Layout(root, opts); err != nil { | |
| 57 | + | b.Fatal(err) | |
| 58 | + | } | |
| 59 | + | b.ReportAllocs() | |
| 60 | + | b.ResetTimer() | |
| 61 | + | for i := 0; i < b.N; i++ { | |
| 62 | + | if _, err := Layout(root, opts); err != nil { | |
| 63 | + | b.Fatal(err) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | } | |
| @@ -0,0 +1,66 @@ | |||||
| 1 | + | package layout | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/nefergui/internal/css" | |||
| 8 | + | "github.com/bnema/nefergui/internal/text" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // paintFixture is a product-like tree: cards with background, border, shadows, | |||
| 12 | + | // a clip and shaped text children. | |||
| 13 | + | func paintFixture(tb testing.TB, cards int) (*Node, Options) { | |||
| 14 | + | tb.Helper() | |||
| 15 | + | catalog, err := text.Load(text.DirectorySource("../../testdata/fonts")) | |||
| 16 | + | if err != nil { | |||
| 17 | + | tb.Fatal(err) | |||
| 18 | + | } | |||
| 19 | + | engine := text.NewEngine(catalog) | |||
| 20 | + | card := css.Style{ | |||
| 21 | + | Display: css.KeywordBlock, Height: px(60), Padding: css.LengthSides{Top: px(4), Left: px(4)}, | |||
| 22 | + | BackgroundColor: css.Color{B: 1, A: 1}, BorderWidth: css.LengthSides{Top: px(1), Right: px(1), Bottom: px(1), Left: px(1)}, | |||
| 23 | + | BorderStyle: css.KeywordSides{Top: css.KeywordSolid, Right: css.KeywordSolid, Bottom: css.KeywordSolid, Left: css.KeywordSolid}, | |||
| 24 | + | OverflowX: css.KeywordHidden, OverflowY: css.KeywordHidden, | |||
| 25 | + | BoxShadow: []css.Shadow{{Y: px(2), Blur: px(4), Color: css.Color{A: .3}}, {Inset: true, Blur: px(2), Color: css.Color{A: .2}}}, | |||
| 26 | + | } | |||
| 27 | + | label := css.Style{Display: css.KeywordBlock, FontSize: px(14), Color: css.Color{A: 1}} | |||
| 28 | + | children := make([]*Node, cards) | |||
| 29 | + | for i := range children { | |||
| 30 | + | t1 := node("t", Text, label) | |||
| 31 | + | t1.Content = fmt.Sprintf("Élément %d — une interface native écrite en Go", i) | |||
| 32 | + | children[i] = node("card", Box, card, t1) | |||
| 33 | + | } | |||
| 34 | + | return node("root", Column, css.Style{Display: css.KeywordBlock}, children...), Options{Width: 400, Height: 3000, TextEngine: engine} | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // BenchmarkPaint measures only context.paint on an already placed tree. | |||
| 38 | + | func BenchmarkPaint(b *testing.B) { | |||
| 39 | + | root, opts := paintFixture(b, 100) | |||
| 40 | + | ctx := context{options: opts} | |||
| 41 | + | tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0) | |||
| 42 | + | if err != nil { | |||
| 43 | + | b.Fatal(err) | |||
| 44 | + | } | |||
| 45 | + | b.ReportAllocs() | |||
| 46 | + | b.ResetTimer() | |||
| 47 | + | for i := 0; i < b.N; i++ { | |||
| 48 | + | if display := ctx.display(root, tree); len(display) == 0 { | |||
| 49 | + | b.Fatal("empty") | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func BenchmarkLayoutPaintTree(b *testing.B) { | |||
| 55 | + | root, opts := paintFixture(b, 100) | |||
| 56 | + | if _, err := Layout(root, opts); err != nil { | |||
| 57 | + | b.Fatal(err) | |||
| 58 | + | } | |||
| 59 | + | b.ReportAllocs() | |||
| 60 | + | b.ResetTimer() | |||
| 61 | + | for i := 0; i < b.N; i++ { | |||
| 62 | + | if _, err := Layout(root, opts); err != nil { | |||
| 63 | + | b.Fatal(err) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | } | |||
A internal/layout/paint_test.go +173 -0
| @@ -0,0 +1,173 @@ | |||
| 1 | + | package layout | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "image" | |
| 5 | + | "reflect" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/nefergui/internal/css" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Layout output is detached: neither the shared paint storage nor a later | |
| 12 | + | // Layout call, nor a mutation of the input style, may change earlier results. | |
| 13 | + | func TestPaintOutputDetached(t *testing.T) { | |
| 14 | + | root, opts := paintFixture(t, 6) | |
| 15 | + | first, err := Layout(root, opts) | |
| 16 | + | if err != nil { | |
| 17 | + | t.Fatal(err) | |
| 18 | + | } | |
| 19 | + | want := deepCopyDisplay(first.Display) | |
| 20 | + | shadows := 0 | |
| 21 | + | for _, cmd := range first.Display { | |
| 22 | + | if cmd.Shadow != nil { | |
| 23 | + | shadows++ | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | if shadows != 12 { | |
| 27 | + | t.Fatalf("fixture shadows=%d, want 12", shadows) | |
| 28 | + | } | |
| 29 | + | // Mutating the input styles must not reach painted shadows. | |
| 30 | + | for _, card := range root.Children { | |
| 31 | + | card.Style.BoxShadow[0].Blur = px(99) | |
| 32 | + | } | |
| 33 | + | second, err := Layout(root, opts) | |
| 34 | + | if err != nil { | |
| 35 | + | t.Fatal(err) | |
| 36 | + | } | |
| 37 | + | if !reflect.DeepEqual(first.Display, want) { | |
| 38 | + | t.Fatal("first output changed after style mutation and second Layout") | |
| 39 | + | } | |
| 40 | + | if reflect.DeepEqual(second.Display, want) { | |
| 41 | + | t.Fatal("second output should reflect the mutated style") | |
| 42 | + | } | |
| 43 | + | // Writing through one command's storage must not change any other. | |
| 44 | + | for i := range second.Display { | |
| 45 | + | if second.Display[i].Shadow != nil { | |
| 46 | + | second.Display[i].Shadow.X = px(-7) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | if !reflect.DeepEqual(first.Display, want) { | |
| 50 | + | t.Fatal("outputs share shadow storage") | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func deepCopyDisplay(in []Command) []Command { | |
| 55 | + | out := make([]Command, len(in)) | |
| 56 | + | for i, c := range in { | |
| 57 | + | if c.Shadow != nil { | |
| 58 | + | s := *c.Shadow | |
| 59 | + | c.Shadow = &s | |
| 60 | + | } | |
| 61 | + | c.Runs = append([]Run(nil), c.Runs...) | |
| 62 | + | for j := range c.Runs { | |
| 63 | + | c.Runs[j].Glyphs = append([]Glyph(nil), c.Runs[j].Glyphs...) | |
| 64 | + | } | |
| 65 | + | out[i] = c | |
| 66 | + | } | |
| 67 | + | return out | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // Runs and glyphs come from shared blocks; a caller appending to one must | |
| 71 | + | // reallocate rather than overwrite the next run or command. | |
| 72 | + | func TestPaintRunAndGlyphCapacityIsolated(t *testing.T) { | |
| 73 | + | root, opts := paintFixture(t, 3) | |
| 74 | + | out, err := Layout(root, opts) | |
| 75 | + | if err != nil { | |
| 76 | + | t.Fatal(err) | |
| 77 | + | } | |
| 78 | + | want := deepCopyDisplay(out.Display) | |
| 79 | + | texts := 0 | |
| 80 | + | for i := range out.Display { | |
| 81 | + | cmd := &out.Display[i] | |
| 82 | + | if cmd.Op != "text" { | |
| 83 | + | continue | |
| 84 | + | } | |
| 85 | + | texts++ | |
| 86 | + | if len(cmd.Runs) == 0 || cap(cmd.Runs) != len(cmd.Runs) { | |
| 87 | + | t.Fatalf("runs len=%d cap=%d", len(cmd.Runs), cap(cmd.Runs)) | |
| 88 | + | } | |
| 89 | + | for j := range cmd.Runs { | |
| 90 | + | if g := cmd.Runs[j].Glyphs; len(g) == 0 || cap(g) != len(g) { | |
| 91 | + | t.Fatalf("glyphs len=%d cap=%d", len(g), cap(g)) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | _ = append(cmd.Runs, Run{Start: -1}) | |
| 95 | + | for j := range cmd.Runs { | |
| 96 | + | _ = append(cmd.Runs[j].Glyphs, Glyph{ID: 0xdead}) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | if texts != 3 || !reflect.DeepEqual(out.Display, want) { | |
| 100 | + | t.Fatal("append through a command overwrote neighbouring data") | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // countCommands must match paint exactly, otherwise the single-allocation | |
| 105 | + | // guarantee silently degrades. | |
| 106 | + | func TestPaintCountsMatch(t *testing.T) { | |
| 107 | + | root, opts := paintFixture(t, 10) | |
| 108 | + | root.Children = append(root.Children, | |
| 109 | + | nil, | |
| 110 | + | &Node{ID: "hidden", Kind: Text, Content: "hidden", Style: &css.Style{Display: css.KeywordNone}}, | |
| 111 | + | &Node{ID: "image", Kind: Image, Image: image.NewRGBA(image.Rect(0, 0, 2, 2)), Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20)}}, | |
| 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: "radius", Kind: Box, Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20), BorderRadius: css.Radii{TopLeft: px(2)}}}, | |
| 114 | + | ) | |
| 115 | + | ctx := context{options: opts} | |
| 116 | + | tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0) | |
| 117 | + | if err != nil { | |
| 118 | + | t.Fatal(err) | |
| 119 | + | } | |
| 120 | + | n := countCommands(root, tree) | |
| 121 | + | out := ctx.display(root, tree) | |
| 122 | + | runs, glyphs, shadows := 0, 0, 0 | |
| 123 | + | for _, cmd := range out { | |
| 124 | + | runs += len(cmd.Runs) | |
| 125 | + | for _, r := range cmd.Runs { | |
| 126 | + | glyphs += len(r.Glyphs) | |
| 127 | + | } | |
| 128 | + | if cmd.Shadow != nil { | |
| 129 | + | shadows++ | |
| 130 | + | } | |
| 131 | + | } | |
| 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) | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func TestPaintAllocations(t *testing.T) { | |
| 138 | + | root, opts := paintFixture(t, 50) | |
| 139 | + | ctx := context{options: opts} | |
| 140 | + | tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0) | |
| 141 | + | if err != nil { | |
| 142 | + | t.Fatal(err) | |
| 143 | + | } | |
| 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) | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func TestPaintEmptyAndShadowOrder(t *testing.T) { | |
| 151 | + | out, err := Layout(&Node{ID: "n", Kind: Box, Style: &css.Style{Display: css.KeywordBlock}}, Options{Width: 10, Height: 10}) | |
| 152 | + | if err != nil || out.Display != nil { | |
| 153 | + | t.Fatalf("empty paint: %+v %v", out.Display, err) | |
| 154 | + | } | |
| 155 | + | s := css.Style{Display: css.KeywordBlock, Width: px(10), Height: px(10), BackgroundColor: css.Color{A: 1}, BoxShadow: []css.Shadow{ | |
| 156 | + | {Inset: true, Blur: px(1)}, {Blur: px(2)}, {Blur: px(3)}, {Inset: true, Blur: px(4)}, | |
| 157 | + | }} | |
| 158 | + | out, err = Layout(node("n", Box, s), Options{Width: 10, Height: 10}) | |
| 159 | + | if err != nil { | |
| 160 | + | t.Fatal(err) | |
| 161 | + | } | |
| 162 | + | var got []float64 | |
| 163 | + | var ops []string | |
| 164 | + | for _, c := range out.Display { | |
| 165 | + | ops = append(ops, c.Op) | |
| 166 | + | if c.Shadow != nil { | |
| 167 | + | got = append(got, c.Shadow.Blur.Value) | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | if !reflect.DeepEqual(got, []float64{2, 3, 1, 4}) || !reflect.DeepEqual(ops, []string{"shadow", "shadow", "rect", "shadow", "shadow"}) { | |
| 171 | + | t.Fatalf("order ops=%v blur=%v", ops, got) | |
| 172 | + | } | |
| 173 | + | } | |
| @@ -0,0 +1,173 @@ | |||||
| 1 | + | package layout | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "image" | |||
| 5 | + | "reflect" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/nefergui/internal/css" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // Layout output is detached: neither the shared paint storage nor a later | |||
| 12 | + | // Layout call, nor a mutation of the input style, may change earlier results. | |||
| 13 | + | func TestPaintOutputDetached(t *testing.T) { | |||
| 14 | + | root, opts := paintFixture(t, 6) | |||
| 15 | + | first, err := Layout(root, opts) | |||
| 16 | + | if err != nil { | |||
| 17 | + | t.Fatal(err) | |||
| 18 | + | } | |||
| 19 | + | want := deepCopyDisplay(first.Display) | |||
| 20 | + | shadows := 0 | |||
| 21 | + | for _, cmd := range first.Display { | |||
| 22 | + | if cmd.Shadow != nil { | |||
| 23 | + | shadows++ | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | if shadows != 12 { | |||
| 27 | + | t.Fatalf("fixture shadows=%d, want 12", shadows) | |||
| 28 | + | } | |||
| 29 | + | // Mutating the input styles must not reach painted shadows. | |||
| 30 | + | for _, card := range root.Children { | |||
| 31 | + | card.Style.BoxShadow[0].Blur = px(99) | |||
| 32 | + | } | |||
| 33 | + | second, err := Layout(root, opts) | |||
| 34 | + | if err != nil { | |||
| 35 | + | t.Fatal(err) | |||
| 36 | + | } | |||
| 37 | + | if !reflect.DeepEqual(first.Display, want) { | |||
| 38 | + | t.Fatal("first output changed after style mutation and second Layout") | |||
| 39 | + | } | |||
| 40 | + | if reflect.DeepEqual(second.Display, want) { | |||
| 41 | + | t.Fatal("second output should reflect the mutated style") | |||
| 42 | + | } | |||
| 43 | + | // Writing through one command's storage must not change any other. | |||
| 44 | + | for i := range second.Display { | |||
| 45 | + | if second.Display[i].Shadow != nil { | |||
| 46 | + | second.Display[i].Shadow.X = px(-7) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | if !reflect.DeepEqual(first.Display, want) { | |||
| 50 | + | t.Fatal("outputs share shadow storage") | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func deepCopyDisplay(in []Command) []Command { | |||
| 55 | + | out := make([]Command, len(in)) | |||
| 56 | + | for i, c := range in { | |||
| 57 | + | if c.Shadow != nil { | |||
| 58 | + | s := *c.Shadow | |||
| 59 | + | c.Shadow = &s | |||
| 60 | + | } | |||
| 61 | + | c.Runs = append([]Run(nil), c.Runs...) | |||
| 62 | + | for j := range c.Runs { | |||
| 63 | + | c.Runs[j].Glyphs = append([]Glyph(nil), c.Runs[j].Glyphs...) | |||
| 64 | + | } | |||
| 65 | + | out[i] = c | |||
| 66 | + | } | |||
| 67 | + | return out | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // Runs and glyphs come from shared blocks; a caller appending to one must | |||
| 71 | + | // reallocate rather than overwrite the next run or command. | |||
| 72 | + | func TestPaintRunAndGlyphCapacityIsolated(t *testing.T) { | |||
| 73 | + | root, opts := paintFixture(t, 3) | |||
| 74 | + | out, err := Layout(root, opts) | |||
| 75 | + | if err != nil { | |||
| 76 | + | t.Fatal(err) | |||
| 77 | + | } | |||
| 78 | + | want := deepCopyDisplay(out.Display) | |||
| 79 | + | texts := 0 | |||
| 80 | + | for i := range out.Display { | |||
| 81 | + | cmd := &out.Display[i] | |||
| 82 | + | if cmd.Op != "text" { | |||
| 83 | + | continue | |||
| 84 | + | } | |||
| 85 | + | texts++ | |||
| 86 | + | if len(cmd.Runs) == 0 || cap(cmd.Runs) != len(cmd.Runs) { | |||
| 87 | + | t.Fatalf("runs len=%d cap=%d", len(cmd.Runs), cap(cmd.Runs)) | |||
| 88 | + | } | |||
| 89 | + | for j := range cmd.Runs { | |||
| 90 | + | if g := cmd.Runs[j].Glyphs; len(g) == 0 || cap(g) != len(g) { | |||
| 91 | + | t.Fatalf("glyphs len=%d cap=%d", len(g), cap(g)) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | _ = append(cmd.Runs, Run{Start: -1}) | |||
| 95 | + | for j := range cmd.Runs { | |||
| 96 | + | _ = append(cmd.Runs[j].Glyphs, Glyph{ID: 0xdead}) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | if texts != 3 || !reflect.DeepEqual(out.Display, want) { | |||
| 100 | + | t.Fatal("append through a command overwrote neighbouring data") | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // countCommands must match paint exactly, otherwise the single-allocation | |||
| 105 | + | // guarantee silently degrades. | |||
| 106 | + | func TestPaintCountsMatch(t *testing.T) { | |||
| 107 | + | root, opts := paintFixture(t, 10) | |||
| 108 | + | root.Children = append(root.Children, | |||
| 109 | + | nil, | |||
| 110 | + | &Node{ID: "hidden", Kind: Text, Content: "hidden", Style: &css.Style{Display: css.KeywordNone}}, | |||
| 111 | + | &Node{ID: "image", Kind: Image, Image: image.NewRGBA(image.Rect(0, 0, 2, 2)), Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20)}}, | |||
| 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: "radius", Kind: Box, Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20), BorderRadius: css.Radii{TopLeft: px(2)}}}, | |||
| 114 | + | ) | |||
| 115 | + | ctx := context{options: opts} | |||
| 116 | + | tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0) | |||
| 117 | + | if err != nil { | |||
| 118 | + | t.Fatal(err) | |||
| 119 | + | } | |||
| 120 | + | n := countCommands(root, tree) | |||
| 121 | + | out := ctx.display(root, tree) | |||
| 122 | + | runs, glyphs, shadows := 0, 0, 0 | |||
| 123 | + | for _, cmd := range out { | |||
| 124 | + | runs += len(cmd.Runs) | |||
| 125 | + | for _, r := range cmd.Runs { | |||
| 126 | + | glyphs += len(r.Glyphs) | |||
| 127 | + | } | |||
| 128 | + | if cmd.Shadow != nil { | |||
| 129 | + | shadows++ | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 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) | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func TestPaintAllocations(t *testing.T) { | |||
| 138 | + | root, opts := paintFixture(t, 50) | |||
| 139 | + | ctx := context{options: opts} | |||
| 140 | + | tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0) | |||
| 141 | + | if err != nil { | |||
| 142 | + | t.Fatal(err) | |||
| 143 | + | } | |||
| 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) | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func TestPaintEmptyAndShadowOrder(t *testing.T) { | |||
| 151 | + | out, err := Layout(&Node{ID: "n", Kind: Box, Style: &css.Style{Display: css.KeywordBlock}}, Options{Width: 10, Height: 10}) | |||
| 152 | + | if err != nil || out.Display != nil { | |||
| 153 | + | t.Fatalf("empty paint: %+v %v", out.Display, err) | |||
| 154 | + | } | |||
| 155 | + | s := css.Style{Display: css.KeywordBlock, Width: px(10), Height: px(10), BackgroundColor: css.Color{A: 1}, BoxShadow: []css.Shadow{ | |||
| 156 | + | {Inset: true, Blur: px(1)}, {Blur: px(2)}, {Blur: px(3)}, {Inset: true, Blur: px(4)}, | |||
| 157 | + | }} | |||
| 158 | + | out, err = Layout(node("n", Box, s), Options{Width: 10, Height: 10}) | |||
| 159 | + | if err != nil { | |||
| 160 | + | t.Fatal(err) | |||
| 161 | + | } | |||
| 162 | + | var got []float64 | |||
| 163 | + | var ops []string | |||
| 164 | + | for _, c := range out.Display { | |||
| 165 | + | ops = append(ops, c.Op) | |||
| 166 | + | if c.Shadow != nil { | |||
| 167 | + | got = append(got, c.Shadow.Blur.Value) | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | if !reflect.DeepEqual(got, []float64{2, 3, 1, 4}) || !reflect.DeepEqual(ops, []string{"shadow", "shadow", "rect", "shadow", "shadow"}) { | |||
| 171 | + | t.Fatalf("order ops=%v blur=%v", ops, got) | |||
| 172 | + | } | |||
| 173 | + | } | |||
M internal/platform/wayland/window.go +1 -1
| @@ -116,7 +116,7 @@ func Connect(name string, width, height int32, transparent bool) (_ *Window, err | |||
| 116 | 116 | return nil | |
| 117 | 117 | } | |
| 118 | 118 | w.Compositor = core.NewCompositor(ctx) | |
| 119 | - | if err = bind(core.CompositorInterface, 6, 1, w.Compositor); err != nil { | |
| 119 | + | if err = bind(core.CompositorInterface, 6, 4, w.Compositor); err != nil { | |
| 120 | 120 | return nil, err | |
| 121 | 121 | } | |
| 122 | 122 | w.Shell = xdgshell.NewXdgWmBase(ctx) | |
| @@ -116,7 +116,7 @@ func Connect(name string, width, height int32, transparent bool) (_ *Window, err | |||||
| 116 | return nil | 116 | return nil | ||
| 117 | } | 117 | } | ||
| 118 | w.Compositor = core.NewCompositor(ctx) | 118 | w.Compositor = core.NewCompositor(ctx) | ||
| 119 | - | if err = bind(core.CompositorInterface, 6, 1, w.Compositor); err != nil { | 119 | + | if err = bind(core.CompositorInterface, 6, 4, w.Compositor); err != nil { |
| 120 | return nil, err | 120 | return nil, err | ||
| 121 | } | 121 | } | ||
| 122 | w.Shell = xdgshell.NewXdgWmBase(ctx) | 122 | w.Shell = xdgshell.NewXdgWmBase(ctx) | ||
M internal/presentation/session/app.go +1 -5
| @@ -63,11 +63,7 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error { | |||
| 63 | 63 | } | |
| 64 | 64 | } | |
| 65 | 65 | committed, err := sched.Step(s.Window.FrameReady, app.Draw, func(prepared render.Frame) (bool, error) { | |
| 66 | - | instances, batches, stats := render.ListBatches(prepared) | |
| 67 | - | if len(stats.Skipped) != 0 { | |
| 68 | - | return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped) | |
| 69 | - | } | |
| 70 | - | return s.tick(nil, instances, prepared.Uploads, batches) | |
| 66 | + | return s.tickFrame(prepared) | |
| 71 | 67 | }) | |
| 72 | 68 | if err != nil { | |
| 73 | 69 | return err | |
| @@ -63,11 +63,7 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error { | |||||
| 63 | } | 63 | } | ||
| 64 | } | 64 | } | ||
| 65 | committed, err := sched.Step(s.Window.FrameReady, app.Draw, func(prepared render.Frame) (bool, error) { | 65 | committed, err := sched.Step(s.Window.FrameReady, app.Draw, func(prepared render.Frame) (bool, error) { | ||
| 66 | - | instances, batches, stats := render.ListBatches(prepared) | 66 | + | return s.tickFrame(prepared) |
| 67 | - | if len(stats.Skipped) != 0 { | |||
| 68 | - | return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped) | |||
| 69 | - | } | |||
| 70 | - | return s.tick(nil, instances, prepared.Uploads, batches) | |||
| 71 | }) | 67 | }) | ||
| 72 | if err != nil { | 68 | if err != nil { | ||
| 73 | return err | 69 | return err | ||
M internal/presentation/session/session.go +16 -5
| @@ -49,6 +49,7 @@ type Session struct { | |||
| 49 | 49 | Pipeline *vkdevice.Pipeline | |
| 50 | 50 | ListPipeline *vkdevice.Pipeline | |
| 51 | 51 | Preparer *render.Preparer | |
| 52 | + | list render.ListBuffer // owner-loop scratch; see tickFrame | |
| 52 | 53 | Slots map[uint64]*Slot | |
| 53 | 54 | retiredPipelines []retiredPipelines | |
| 54 | 55 | Acquire uint32 | |
| @@ -342,6 +343,20 @@ func (s *Session) TickList(instances []vkdevice.Instance, uploads []text.Upload) | |||
| 342 | 343 | return s.tick(nil, instances, uploads, batches) | |
| 343 | 344 | } | |
| 344 | 345 | ||
| 346 | + | // tickFrame lists and submits one prepared frame on the owner loop. The list | |
| 347 | + | // buffer's slices are borrowed only for this call: tick copies instances into | |
| 348 | + | // mapped GPU memory and resolve copies batches before returning, and no pending | |
| 349 | + | // GPU work or retained frame refers to them (a blocked frame is re-listed from | |
| 350 | + | // its render.Frame on retry). Release then drops borrowed image references. | |
| 351 | + | func (s *Session) tickFrame(frame render.Frame) (bool, error) { | |
| 352 | + | defer s.list.Release() | |
| 353 | + | instances, batches, stats := s.list.List(frame) | |
| 354 | + | if len(stats.Skipped) != 0 { | |
| 355 | + | return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped) | |
| 356 | + | } | |
| 357 | + | return s.tick(nil, instances, frame.Uploads, batches) | |
| 358 | + | } | |
| 359 | + | ||
| 345 | 360 | func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploads []text.Upload, batches []vkdevice.Batch) (bool, error) { | |
| 346 | 361 | started := time.Now() | |
| 347 | 362 | if s.Window.Dirty { | |
| @@ -682,11 +697,7 @@ func (s *Session) RunPreparedListWithCommit(ctx context.Context, frames int, dra | |||
| 682 | 697 | if err != nil { | |
| 683 | 698 | return false, err | |
| 684 | 699 | } | |
| 685 | - | instances, batches, stats := render.ListBatches(frame) | |
| 686 | - | if len(stats.Skipped) != 0 { | |
| 687 | - | return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped) | |
| 688 | - | } | |
| 689 | - | return s.tick(nil, instances, frame.Uploads, batches) | |
| 700 | + | return s.tickFrame(frame) | |
| 690 | 701 | }, committed) | |
| 691 | 702 | } | |
| 692 | 703 | ||
| @@ -49,6 +49,7 @@ type Session struct { | |||||
| 49 | Pipeline *vkdevice.Pipeline | 49 | Pipeline *vkdevice.Pipeline | ||
| 50 | ListPipeline *vkdevice.Pipeline | 50 | ListPipeline *vkdevice.Pipeline | ||
| 51 | Preparer *render.Preparer | 51 | Preparer *render.Preparer | ||
| 52 | + | list render.ListBuffer // owner-loop scratch; see tickFrame | |||
| 52 | Slots map[uint64]*Slot | 53 | Slots map[uint64]*Slot | ||
| 53 | retiredPipelines []retiredPipelines | 54 | retiredPipelines []retiredPipelines | ||
| 54 | Acquire uint32 | 55 | Acquire uint32 | ||
| @@ -342,6 +343,20 @@ func (s *Session) TickList(instances []vkdevice.Instance, uploads []text.Upload) | |||||
| 342 | return s.tick(nil, instances, uploads, batches) | 343 | return s.tick(nil, instances, uploads, batches) | ||
| 343 | } | 344 | } | ||
| 344 | 345 | ||||
| 346 | + | // tickFrame lists and submits one prepared frame on the owner loop. The list | |||
| 347 | + | // buffer's slices are borrowed only for this call: tick copies instances into | |||
| 348 | + | // mapped GPU memory and resolve copies batches before returning, and no pending | |||
| 349 | + | // GPU work or retained frame refers to them (a blocked frame is re-listed from | |||
| 350 | + | // its render.Frame on retry). Release then drops borrowed image references. | |||
| 351 | + | func (s *Session) tickFrame(frame render.Frame) (bool, error) { | |||
| 352 | + | defer s.list.Release() | |||
| 353 | + | instances, batches, stats := s.list.List(frame) | |||
| 354 | + | if len(stats.Skipped) != 0 { | |||
| 355 | + | return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped) | |||
| 356 | + | } | |||
| 357 | + | return s.tick(nil, instances, frame.Uploads, batches) | |||
| 358 | + | } | |||
| 359 | + | ||||
| 345 | func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploads []text.Upload, batches []vkdevice.Batch) (bool, error) { | 360 | func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploads []text.Upload, batches []vkdevice.Batch) (bool, error) { | ||
| 346 | started := time.Now() | 361 | started := time.Now() | ||
| 347 | if s.Window.Dirty { | 362 | if s.Window.Dirty { | ||
| @@ -682,11 +697,7 @@ func (s *Session) RunPreparedListWithCommit(ctx context.Context, frames int, dra | |||||
| 682 | if err != nil { | 697 | if err != nil { | ||
| 683 | return false, err | 698 | return false, err | ||
| 684 | } | 699 | } | ||
| 685 | - | instances, batches, stats := render.ListBatches(frame) | 700 | + | return s.tickFrame(frame) |
| 686 | - | if len(stats.Skipped) != 0 { | |||
| 687 | - | return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped) | |||
| 688 | - | } | |||
| 689 | - | return s.tick(nil, instances, frame.Uploads, batches) | |||
| 690 | }, committed) | 701 | }, committed) | ||
| 691 | } | 702 | } | ||
| 692 | 703 | ||||
A internal/presentation/session/tickframe_test.go +33 -0
| @@ -0,0 +1,33 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package session | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "image" | |
| 7 | + | "runtime" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | "weak" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/nefergui/internal/render" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // An unsupported quad is rejected before any GPU work, and the borrowed list | |
| 16 | + | // buffer is released so it never pins the frame's image. | |
| 17 | + | func TestTickFrameRejectsUnsupportedAndReleasesImages(t *testing.T) { | |
| 18 | + | s := &Session{} | |
| 19 | + | ref := func() weak.Pointer[image.RGBA] { | |
| 20 | + | img := image.NewRGBA(image.Rect(0, 0, 1, 1)) | |
| 21 | + | ref := weak.Make(img) | |
| 22 | + | _, err := s.tickFrame(render.Frame{Quads: []render.Quad{{Op: "image", Image: img}, {Op: "bogus"}}}) | |
| 23 | + | if err == nil || !strings.Contains(err.Error(), "unsupported list operations") { | |
| 24 | + | t.Fatalf("err=%v", err) | |
| 25 | + | } | |
| 26 | + | return ref | |
| 27 | + | }() | |
| 28 | + | runtime.GC() | |
| 29 | + | if ref.Value() != nil { | |
| 30 | + | t.Fatal("idle list buffer retains the frame image") | |
| 31 | + | } | |
| 32 | + | runtime.KeepAlive(s) | |
| 33 | + | } | |
| @@ -0,0 +1,33 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package session | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "image" | |||
| 7 | + | "runtime" | |||
| 8 | + | "strings" | |||
| 9 | + | "testing" | |||
| 10 | + | "weak" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/nefergui/internal/render" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // An unsupported quad is rejected before any GPU work, and the borrowed list | |||
| 16 | + | // buffer is released so it never pins the frame's image. | |||
| 17 | + | func TestTickFrameRejectsUnsupportedAndReleasesImages(t *testing.T) { | |||
| 18 | + | s := &Session{} | |||
| 19 | + | ref := func() weak.Pointer[image.RGBA] { | |||
| 20 | + | img := image.NewRGBA(image.Rect(0, 0, 1, 1)) | |||
| 21 | + | ref := weak.Make(img) | |||
| 22 | + | _, err := s.tickFrame(render.Frame{Quads: []render.Quad{{Op: "image", Image: img}, {Op: "bogus"}}}) | |||
| 23 | + | if err == nil || !strings.Contains(err.Error(), "unsupported list operations") { | |||
| 24 | + | t.Fatalf("err=%v", err) | |||
| 25 | + | } | |||
| 26 | + | return ref | |||
| 27 | + | }() | |||
| 28 | + | runtime.GC() | |||
| 29 | + | if ref.Value() != nil { | |||
| 30 | + | t.Fatal("idle list buffer retains the frame image") | |||
| 31 | + | } | |||
| 32 | + | runtime.KeepAlive(s) | |||
| 33 | + | } | |||
M internal/presentation/shaders/list.frag +11 -7
| @@ -80,13 +80,17 @@ void main() { | |||
| 80 | 80 | float hole = inner.z > 0.0 && inner.w > 0.0 ? cover(roundedDistance(p, inner, innerRadii)) : 0.0; | |
| 81 | 81 | coverage *= 1.0 - hole; | |
| 82 | 82 | if (fragKind < 2.5) { | |
| 83 | - | // Diagonal split: classify by the nearest normalized edge of the | |
| 84 | - | // outer box. Ties go to top/bottom (stable at 45-degree corners). | |
| 85 | - | vec2 uv = (p - fragBounds.xy) / fragBounds.zw; | |
| 86 | - | float h = min(uv.x, 1.0 - uv.x); | |
| 87 | - | float v = min(uv.y, 1.0 - uv.y); | |
| 88 | - | color = v <= h ? (uv.y < .5 ? fragSideTop : fragSideBottom) | |
| 89 | - | : (uv.x < .5 ? fragSideLeft : fragSideRight); | |
| 83 | + | // Split corners using each painted side's width. A zero-width | |
| 84 | + | // side must never contribute its default color at the corners. | |
| 85 | + | vec2 edge = p - fragBounds.xy; | |
| 86 | + | vec2 farEdge = fragBounds.zw - edge; | |
| 87 | + | float top = fragWidths.x > 0.0 ? edge.y / fragWidths.x : 1e20; | |
| 88 | + | float right = fragWidths.y > 0.0 ? farEdge.x / fragWidths.y : 1e20; | |
| 89 | + | float bottom = fragWidths.z > 0.0 ? farEdge.y / fragWidths.z : 1e20; | |
| 90 | + | float left = fragWidths.w > 0.0 ? edge.x / fragWidths.w : 1e20; | |
| 91 | + | float nearest = min(min(top, bottom), min(left, right)); | |
| 92 | + | color = nearest == top ? fragSideTop : nearest == bottom ? fragSideBottom | |
| 93 | + | : nearest == left ? fragSideLeft : fragSideRight; | |
| 90 | 94 | } | |
| 91 | 95 | } else if (fragKind > 3.5) { | |
| 92 | 96 | vec4 box = fragShape; | |
| @@ -80,13 +80,17 @@ void main() { | |||||
| 80 | float hole = inner.z > 0.0 && inner.w > 0.0 ? cover(roundedDistance(p, inner, innerRadii)) : 0.0; | 80 | float hole = inner.z > 0.0 && inner.w > 0.0 ? cover(roundedDistance(p, inner, innerRadii)) : 0.0; | ||
| 81 | coverage *= 1.0 - hole; | 81 | coverage *= 1.0 - hole; | ||
| 82 | if (fragKind < 2.5) { | 82 | if (fragKind < 2.5) { | ||
| 83 | - | // Diagonal split: classify by the nearest normalized edge of the | 83 | + | // Split corners using each painted side's width. A zero-width |
| 84 | - | // outer box. Ties go to top/bottom (stable at 45-degree corners). | 84 | + | // side must never contribute its default color at the corners. |
| 85 | - | vec2 uv = (p - fragBounds.xy) / fragBounds.zw; | 85 | + | vec2 edge = p - fragBounds.xy; |
| 86 | - | float h = min(uv.x, 1.0 - uv.x); | 86 | + | vec2 farEdge = fragBounds.zw - edge; |
| 87 | - | float v = min(uv.y, 1.0 - uv.y); | 87 | + | float top = fragWidths.x > 0.0 ? edge.y / fragWidths.x : 1e20; |
| 88 | - | color = v <= h ? (uv.y < .5 ? fragSideTop : fragSideBottom) | 88 | + | float right = fragWidths.y > 0.0 ? farEdge.x / fragWidths.y : 1e20; |
| 89 | - | : (uv.x < .5 ? fragSideLeft : fragSideRight); | 89 | + | float bottom = fragWidths.z > 0.0 ? farEdge.y / fragWidths.z : 1e20; |
| 90 | + | float left = fragWidths.w > 0.0 ? edge.x / fragWidths.w : 1e20; | |||
| 91 | + | float nearest = min(min(top, bottom), min(left, right)); | |||
| 92 | + | color = nearest == top ? fragSideTop : nearest == bottom ? fragSideBottom | |||
| 93 | + | : nearest == left ? fragSideLeft : fragSideRight; | |||
| 90 | } | 94 | } | ||
| 91 | } else if (fragKind > 3.5) { | 95 | } else if (fragKind > 3.5) { | ||
| 92 | vec4 box = fragShape; | 96 | vec4 box = fragShape; | ||
M internal/presentation/shaders/list.frag.spv +0 -0 binary file: no text diff
binary file: no text diff
M internal/presentation/syncobj/syncobj.go +37 -15
| @@ -6,7 +6,6 @@ | |||
| 6 | 6 | package syncobj | |
| 7 | 7 | ||
| 8 | 8 | import ( | |
| 9 | - | "errors" | |
| 10 | 9 | "fmt" | |
| 11 | 10 | "runtime" | |
| 12 | 11 | "time" | |
| @@ -74,10 +73,25 @@ func (n *Node) Close() error { | |||
| 74 | 73 | n.fd = -1 | |
| 75 | 74 | return err | |
| 76 | 75 | } | |
| 77 | - | func (n *Node) ioctl(request uintptr, arg unsafe.Pointer) error { | |
| 76 | + | ||
| 77 | + | // ioctlErrno performs the ioctl and returns the raw errno without allocating. | |
| 78 | + | // Embedded addresses must not move: no split-checked call may occur between | |
| 79 | + | // encoding them and the syscall. KeepAlive alone does not prevent stack moves. | |
| 80 | + | // | |
| 81 | + | //go:nosplit | |
| 82 | + | func (n *Node) ioctlErrno(request uintptr, arg unsafe.Pointer) unix.Errno { | |
| 78 | 83 | _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(n.fd), request, uintptr(arg)) | |
| 79 | - | if errno != 0 { | |
| 80 | - | return fmt.Errorf("DRM syncobj ioctl %#x: %w", request, errno) | |
| 84 | + | return errno | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // ioctlError adds request context to a real errno. Only failure paths pay for it. | |
| 88 | + | func ioctlError(request uintptr, errno unix.Errno) error { | |
| 89 | + | return fmt.Errorf("DRM syncobj ioctl %#x: %w", request, errno) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (n *Node) ioctl(request uintptr, arg unsafe.Pointer) error { | |
| 93 | + | if errno := n.ioctlErrno(request, arg); errno != 0 { | |
| 94 | + | return ioctlError(request, errno) | |
| 81 | 95 | } | |
| 82 | 96 | return nil | |
| 83 | 97 | } | |
| @@ -173,17 +187,25 @@ func (n *Node) WaitPoint(handle uint32, point uint64, timeout time.Duration) (bo | |||
| 173 | 187 | if err := unix.ClockGettime(unix.CLOCK_MONOTONIC, &now); err != nil { | |
| 174 | 188 | return false, err | |
| 175 | 189 | } | |
| 176 | - | a := timelineWaitArg{Handles: uint64(uintptr(unsafe.Pointer(&h))), Points: uint64(uintptr(unsafe.Pointer(&p))), TimeoutNS: now.Nano() + timeout.Nanoseconds(), Count: 1, Flags: 2} | |
| 177 | - | err := n.ioctl(ioctlTimelineWait, unsafe.Pointer(&a)) | |
| 190 | + | deadline := now.Nano() + timeout.Nanoseconds() | |
| 191 | + | a := timelineWaitArg{Handles: uint64(uintptr(unsafe.Pointer(&h))), Points: uint64(uintptr(unsafe.Pointer(&p))), TimeoutNS: deadline, Count: 1, Flags: 2} | |
| 192 | + | errno := n.ioctlErrno(ioctlTimelineWait, unsafe.Pointer(&a)) | |
| 178 | 193 | runtime.KeepAlive(h) | |
| 179 | 194 | runtime.KeepAlive(p) | |
| 180 | - | if err != nil { | |
| 181 | - | if errors.Is(err, unix.ETIME) || errors.Is(err, unix.EBUSY) { | |
| 182 | - | return false, nil | |
| 183 | - | } | |
| 184 | - | return false, err | |
| 195 | + | return waitResult(errno) | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // waitResult maps a TIMELINE_WAIT errno. ETIME and EBUSY are the expected idle | |
| 199 | + | // timeout outcomes and build no error value; anything else keeps its context. | |
| 200 | + | func waitResult(errno unix.Errno) (bool, error) { | |
| 201 | + | switch errno { | |
| 202 | + | case 0: | |
| 203 | + | return true, nil | |
| 204 | + | case unix.ETIME, unix.EBUSY: | |
| 205 | + | return false, nil | |
| 206 | + | default: | |
| 207 | + | return false, ioctlError(ioctlTimelineWait, errno) | |
| 185 | 208 | } | |
| 186 | - | return true, nil | |
| 187 | 209 | } | |
| 188 | 210 | ||
| 189 | 211 | // Signaled performs a zero-time wait for tests and shutdown diagnostics. | |
| @@ -197,11 +219,11 @@ func (n *Node) Query(handle uint32) (uint64, error) { | |||
| 197 | 219 | h := handle | |
| 198 | 220 | var point uint64 | |
| 199 | 221 | a := queryArg{Handles: uint64(uintptr(unsafe.Pointer(&h))), Points: uint64(uintptr(unsafe.Pointer(&point))), Count: 1} | |
| 200 | - | err := n.ioctl(ioctlQuery, unsafe.Pointer(&a)) | |
| 222 | + | errno := n.ioctlErrno(ioctlQuery, unsafe.Pointer(&a)) | |
| 201 | 223 | runtime.KeepAlive(h) | |
| 202 | 224 | runtime.KeepAlive(point) | |
| 203 | - | if err != nil { | |
| 204 | - | return 0, err | |
| 225 | + | if errno != 0 { | |
| 226 | + | return 0, ioctlError(ioctlQuery, errno) | |
| 205 | 227 | } | |
| 206 | 228 | return point, nil | |
| 207 | 229 | } | |
| @@ -6,7 +6,6 @@ | |||||
| 6 | package syncobj | 6 | package syncobj | ||
| 7 | 7 | ||||
| 8 | import ( | 8 | import ( | ||
| 9 | - | "errors" | |||
| 10 | "fmt" | 9 | "fmt" | ||
| 11 | "runtime" | 10 | "runtime" | ||
| 12 | "time" | 11 | "time" | ||
| @@ -74,10 +73,25 @@ func (n *Node) Close() error { | |||||
| 74 | n.fd = -1 | 73 | n.fd = -1 | ||
| 75 | return err | 74 | return err | ||
| 76 | } | 75 | } | ||
| 77 | - | func (n *Node) ioctl(request uintptr, arg unsafe.Pointer) error { | 76 | + | |
| 77 | + | // ioctlErrno performs the ioctl and returns the raw errno without allocating. | |||
| 78 | + | // Embedded addresses must not move: no split-checked call may occur between | |||
| 79 | + | // encoding them and the syscall. KeepAlive alone does not prevent stack moves. | |||
| 80 | + | // | |||
| 81 | + | //go:nosplit | |||
| 82 | + | func (n *Node) ioctlErrno(request uintptr, arg unsafe.Pointer) unix.Errno { | |||
| 78 | _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(n.fd), request, uintptr(arg)) | 83 | _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(n.fd), request, uintptr(arg)) | ||
| 79 | - | if errno != 0 { | 84 | + | return errno |
| 80 | - | return fmt.Errorf("DRM syncobj ioctl %#x: %w", request, errno) | 85 | + | } |
| 86 | + | ||||
| 87 | + | // ioctlError adds request context to a real errno. Only failure paths pay for it. | |||
| 88 | + | func ioctlError(request uintptr, errno unix.Errno) error { | |||
| 89 | + | return fmt.Errorf("DRM syncobj ioctl %#x: %w", request, errno) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func (n *Node) ioctl(request uintptr, arg unsafe.Pointer) error { | |||
| 93 | + | if errno := n.ioctlErrno(request, arg); errno != 0 { | |||
| 94 | + | return ioctlError(request, errno) | |||
| 81 | } | 95 | } | ||
| 82 | return nil | 96 | return nil | ||
| 83 | } | 97 | } | ||
| @@ -173,17 +187,25 @@ func (n *Node) WaitPoint(handle uint32, point uint64, timeout time.Duration) (bo | |||||
| 173 | if err := unix.ClockGettime(unix.CLOCK_MONOTONIC, &now); err != nil { | 187 | if err := unix.ClockGettime(unix.CLOCK_MONOTONIC, &now); err != nil { | ||
| 174 | return false, err | 188 | return false, err | ||
| 175 | } | 189 | } | ||
| 176 | - | a := timelineWaitArg{Handles: uint64(uintptr(unsafe.Pointer(&h))), Points: uint64(uintptr(unsafe.Pointer(&p))), TimeoutNS: now.Nano() + timeout.Nanoseconds(), Count: 1, Flags: 2} | 190 | + | deadline := now.Nano() + timeout.Nanoseconds() |
| 177 | - | err := n.ioctl(ioctlTimelineWait, unsafe.Pointer(&a)) | 191 | + | a := timelineWaitArg{Handles: uint64(uintptr(unsafe.Pointer(&h))), Points: uint64(uintptr(unsafe.Pointer(&p))), TimeoutNS: deadline, Count: 1, Flags: 2} |
| 192 | + | errno := n.ioctlErrno(ioctlTimelineWait, unsafe.Pointer(&a)) | |||
| 178 | runtime.KeepAlive(h) | 193 | runtime.KeepAlive(h) | ||
| 179 | runtime.KeepAlive(p) | 194 | runtime.KeepAlive(p) | ||
| 180 | - | if err != nil { | 195 | + | return waitResult(errno) |
| 181 | - | if errors.Is(err, unix.ETIME) || errors.Is(err, unix.EBUSY) { | 196 | + | } |
| 182 | - | return false, nil | 197 | + | |
| 183 | - | } | 198 | + | // waitResult maps a TIMELINE_WAIT errno. ETIME and EBUSY are the expected idle |
| 184 | - | return false, err | 199 | + | // timeout outcomes and build no error value; anything else keeps its context. |
| 200 | + | func waitResult(errno unix.Errno) (bool, error) { | |||
| 201 | + | switch errno { | |||
| 202 | + | case 0: | |||
| 203 | + | return true, nil | |||
| 204 | + | case unix.ETIME, unix.EBUSY: | |||
| 205 | + | return false, nil | |||
| 206 | + | default: | |||
| 207 | + | return false, ioctlError(ioctlTimelineWait, errno) | |||
| 185 | } | 208 | } | ||
| 186 | - | return true, nil | |||
| 187 | } | 209 | } | ||
| 188 | 210 | ||||
| 189 | // Signaled performs a zero-time wait for tests and shutdown diagnostics. | 211 | // Signaled performs a zero-time wait for tests and shutdown diagnostics. | ||
| @@ -197,11 +219,11 @@ func (n *Node) Query(handle uint32) (uint64, error) { | |||||
| 197 | h := handle | 219 | h := handle | ||
| 198 | var point uint64 | 220 | var point uint64 | ||
| 199 | a := queryArg{Handles: uint64(uintptr(unsafe.Pointer(&h))), Points: uint64(uintptr(unsafe.Pointer(&point))), Count: 1} | 221 | a := queryArg{Handles: uint64(uintptr(unsafe.Pointer(&h))), Points: uint64(uintptr(unsafe.Pointer(&point))), Count: 1} | ||
| 200 | - | err := n.ioctl(ioctlQuery, unsafe.Pointer(&a)) | 222 | + | errno := n.ioctlErrno(ioctlQuery, unsafe.Pointer(&a)) |
| 201 | runtime.KeepAlive(h) | 223 | runtime.KeepAlive(h) | ||
| 202 | runtime.KeepAlive(point) | 224 | runtime.KeepAlive(point) | ||
| 203 | - | if err != nil { | 225 | + | if errno != 0 { |
| 204 | - | return 0, err | 226 | + | return 0, ioctlError(ioctlQuery, errno) |
| 205 | } | 227 | } | ||
| 206 | return point, nil | 228 | return point, nil | ||
| 207 | } | 229 | } | ||
A internal/presentation/syncobj/wait_test.go +144 -0
| @@ -0,0 +1,144 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package syncobj | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "os" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "golang.org/x/sys/unix" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestWaitResultMapping(t *testing.T) { | |
| 16 | + | if ok, err := waitResult(0); !ok || err != nil { | |
| 17 | + | t.Fatalf("success: got %v, %v", ok, err) | |
| 18 | + | } | |
| 19 | + | for _, e := range []unix.Errno{unix.ETIME, unix.EBUSY} { | |
| 20 | + | if ok, err := waitResult(e); ok || err != nil { | |
| 21 | + | t.Fatalf("%v: want (false,nil), got %v, %v", e, ok, err) | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | for _, e := range []unix.Errno{unix.EINTR, unix.EBADF, unix.EINVAL, unix.ENOENT, unix.ENOTTY, unix.EFAULT} { | |
| 25 | + | ok, err := waitResult(e) | |
| 26 | + | if ok || err == nil { | |
| 27 | + | t.Fatalf("%v: want error, got %v, %v", e, ok, err) | |
| 28 | + | } | |
| 29 | + | if !errors.Is(err, e) { | |
| 30 | + | t.Errorf("%v: error does not wrap errno: %v", e, err) | |
| 31 | + | } | |
| 32 | + | if !strings.Contains(err.Error(), "DRM syncobj ioctl 0xc03064ca") { | |
| 33 | + | t.Errorf("%v: lost request context: %v", e, err) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestWaitResultIdleDoesNotAllocate(t *testing.T) { | |
| 39 | + | for _, e := range []unix.Errno{unix.ETIME, unix.EBUSY} { | |
| 40 | + | if n := testing.AllocsPerRun(1000, func() { _, _ = waitResult(e) }); n != 0 { | |
| 41 | + | t.Errorf("%v: %v allocs per idle result, want 0", e, n) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestWaitPointBadFDKeepsRealError(t *testing.T) { | |
| 47 | + | for _, fd := range []int{-1, 1 << 30} { | |
| 48 | + | n := &Node{fd: fd} | |
| 49 | + | ok, err := n.WaitPoint(1, 1, 20*time.Millisecond) | |
| 50 | + | if ok || !errors.Is(err, unix.EBADF) { | |
| 51 | + | t.Fatalf("fd %d: want (false, EBADF), got %v, %v", fd, ok, err) | |
| 52 | + | } | |
| 53 | + | if !strings.Contains(err.Error(), "DRM syncobj ioctl") { | |
| 54 | + | t.Errorf("fd %d: lost context: %v", fd, err) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | // Closed Node (fd -1 after Close) must also surface EBADF. | |
| 58 | + | n := &Node{fd: -1} | |
| 59 | + | if _, err := n.Signaled(1, 1); !errors.Is(err, unix.EBADF) { | |
| 60 | + | t.Fatalf("Signaled on closed node: %v", err) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestWaitPointNonDRMFDIsNotSwallowed(t *testing.T) { | |
| 65 | + | fd, err := unix.Eventfd(0, unix.EFD_CLOEXEC) | |
| 66 | + | if err != nil { | |
| 67 | + | t.Fatal(err) | |
| 68 | + | } | |
| 69 | + | defer unix.Close(fd) | |
| 70 | + | n := &Node{fd: fd} | |
| 71 | + | if ok, err := n.WaitPoint(1, 1, 0); ok || !errors.Is(err, unix.ENOTTY) { | |
| 72 | + | t.Fatalf("want (false, ENOTTY), got %v, %v", ok, err) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestWaitPointTimeoutBounds(t *testing.T) { | |
| 77 | + | n := &Node{fd: -1} | |
| 78 | + | for _, d := range []time.Duration{-1, time.Second + 1} { | |
| 79 | + | if _, err := n.WaitPoint(1, 1, d); err == nil || errors.Is(err, unix.EBADF) { | |
| 80 | + | t.Errorf("timeout %s: want bounds error before ioctl, got %v", d, err) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | // Boundaries stay accepted (fail later at the ioctl with EBADF). | |
| 84 | + | for _, d := range []time.Duration{0, time.Second} { | |
| 85 | + | if _, err := n.WaitPoint(1, 1, d); !errors.Is(err, unix.EBADF) { | |
| 86 | + | t.Errorf("timeout %s: want EBADF, got %v", d, err) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // openBenchNode opts in to a real render node, like the other hardware tests. | |
| 92 | + | func openBenchNode(tb testing.TB) *Node { | |
| 93 | + | path := os.Getenv("NEFERGUI_RENDER_NODE") | |
| 94 | + | if path == "" { | |
| 95 | + | tb.Skip("set NEFERGUI_RENDER_NODE=/dev/dri/renderD... for real idle-wait measurement") | |
| 96 | + | } | |
| 97 | + | n, err := Open(path) | |
| 98 | + | if err != nil { | |
| 99 | + | tb.Fatal(err) | |
| 100 | + | } | |
| 101 | + | tb.Cleanup(func() { _ = n.Close() }) | |
| 102 | + | return n | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestWaitPointIdleTimeoutRealNodeNoAllocs(t *testing.T) { | |
| 106 | + | n := openBenchNode(t) | |
| 107 | + | h, err := n.Create() | |
| 108 | + | if err != nil { | |
| 109 | + | t.Fatal(err) | |
| 110 | + | } | |
| 111 | + | defer n.Destroy(h) | |
| 112 | + | ready, err := n.WaitPoint(h, 1, 0) | |
| 113 | + | if ready || err != nil { | |
| 114 | + | t.Fatalf("idle unsignaled point: got %v, %v", ready, err) | |
| 115 | + | } | |
| 116 | + | if a := testing.AllocsPerRun(200, func() { _, _ = n.WaitPoint(h, 1, 0) }); a != 0 { | |
| 117 | + | t.Errorf("idle WaitPoint allocs/op = %v, want 0", a) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func BenchmarkWaitResultIdle(b *testing.B) { | |
| 122 | + | b.ReportAllocs() | |
| 123 | + | for i := 0; i < b.N; i++ { | |
| 124 | + | _, _ = waitResult(unix.ETIME) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // BenchmarkWaitPointIdle measures the presentation waiter's idle path on a | |
| 129 | + | // real render node: an unsignaled point that times out (ETIME/EBUSY). | |
| 130 | + | func BenchmarkWaitPointIdle(b *testing.B) { | |
| 131 | + | n := openBenchNode(b) | |
| 132 | + | h, err := n.Create() | |
| 133 | + | if err != nil { | |
| 134 | + | b.Fatal(err) | |
| 135 | + | } | |
| 136 | + | defer n.Destroy(h) | |
| 137 | + | b.ReportAllocs() | |
| 138 | + | b.ResetTimer() | |
| 139 | + | for i := 0; i < b.N; i++ { | |
| 140 | + | if ready, err := n.WaitPoint(h, 1, 20*time.Millisecond); ready || err != nil { | |
| 141 | + | b.Fatalf("got %v, %v", ready, err) | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | } | |
| @@ -0,0 +1,144 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package syncobj | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "errors" | |||
| 7 | + | "os" | |||
| 8 | + | "strings" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "golang.org/x/sys/unix" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestWaitResultMapping(t *testing.T) { | |||
| 16 | + | if ok, err := waitResult(0); !ok || err != nil { | |||
| 17 | + | t.Fatalf("success: got %v, %v", ok, err) | |||
| 18 | + | } | |||
| 19 | + | for _, e := range []unix.Errno{unix.ETIME, unix.EBUSY} { | |||
| 20 | + | if ok, err := waitResult(e); ok || err != nil { | |||
| 21 | + | t.Fatalf("%v: want (false,nil), got %v, %v", e, ok, err) | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | for _, e := range []unix.Errno{unix.EINTR, unix.EBADF, unix.EINVAL, unix.ENOENT, unix.ENOTTY, unix.EFAULT} { | |||
| 25 | + | ok, err := waitResult(e) | |||
| 26 | + | if ok || err == nil { | |||
| 27 | + | t.Fatalf("%v: want error, got %v, %v", e, ok, err) | |||
| 28 | + | } | |||
| 29 | + | if !errors.Is(err, e) { | |||
| 30 | + | t.Errorf("%v: error does not wrap errno: %v", e, err) | |||
| 31 | + | } | |||
| 32 | + | if !strings.Contains(err.Error(), "DRM syncobj ioctl 0xc03064ca") { | |||
| 33 | + | t.Errorf("%v: lost request context: %v", e, err) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestWaitResultIdleDoesNotAllocate(t *testing.T) { | |||
| 39 | + | for _, e := range []unix.Errno{unix.ETIME, unix.EBUSY} { | |||
| 40 | + | if n := testing.AllocsPerRun(1000, func() { _, _ = waitResult(e) }); n != 0 { | |||
| 41 | + | t.Errorf("%v: %v allocs per idle result, want 0", e, n) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestWaitPointBadFDKeepsRealError(t *testing.T) { | |||
| 47 | + | for _, fd := range []int{-1, 1 << 30} { | |||
| 48 | + | n := &Node{fd: fd} | |||
| 49 | + | ok, err := n.WaitPoint(1, 1, 20*time.Millisecond) | |||
| 50 | + | if ok || !errors.Is(err, unix.EBADF) { | |||
| 51 | + | t.Fatalf("fd %d: want (false, EBADF), got %v, %v", fd, ok, err) | |||
| 52 | + | } | |||
| 53 | + | if !strings.Contains(err.Error(), "DRM syncobj ioctl") { | |||
| 54 | + | t.Errorf("fd %d: lost context: %v", fd, err) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | // Closed Node (fd -1 after Close) must also surface EBADF. | |||
| 58 | + | n := &Node{fd: -1} | |||
| 59 | + | if _, err := n.Signaled(1, 1); !errors.Is(err, unix.EBADF) { | |||
| 60 | + | t.Fatalf("Signaled on closed node: %v", err) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestWaitPointNonDRMFDIsNotSwallowed(t *testing.T) { | |||
| 65 | + | fd, err := unix.Eventfd(0, unix.EFD_CLOEXEC) | |||
| 66 | + | if err != nil { | |||
| 67 | + | t.Fatal(err) | |||
| 68 | + | } | |||
| 69 | + | defer unix.Close(fd) | |||
| 70 | + | n := &Node{fd: fd} | |||
| 71 | + | if ok, err := n.WaitPoint(1, 1, 0); ok || !errors.Is(err, unix.ENOTTY) { | |||
| 72 | + | t.Fatalf("want (false, ENOTTY), got %v, %v", ok, err) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestWaitPointTimeoutBounds(t *testing.T) { | |||
| 77 | + | n := &Node{fd: -1} | |||
| 78 | + | for _, d := range []time.Duration{-1, time.Second + 1} { | |||
| 79 | + | if _, err := n.WaitPoint(1, 1, d); err == nil || errors.Is(err, unix.EBADF) { | |||
| 80 | + | t.Errorf("timeout %s: want bounds error before ioctl, got %v", d, err) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | // Boundaries stay accepted (fail later at the ioctl with EBADF). | |||
| 84 | + | for _, d := range []time.Duration{0, time.Second} { | |||
| 85 | + | if _, err := n.WaitPoint(1, 1, d); !errors.Is(err, unix.EBADF) { | |||
| 86 | + | t.Errorf("timeout %s: want EBADF, got %v", d, err) | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // openBenchNode opts in to a real render node, like the other hardware tests. | |||
| 92 | + | func openBenchNode(tb testing.TB) *Node { | |||
| 93 | + | path := os.Getenv("NEFERGUI_RENDER_NODE") | |||
| 94 | + | if path == "" { | |||
| 95 | + | tb.Skip("set NEFERGUI_RENDER_NODE=/dev/dri/renderD... for real idle-wait measurement") | |||
| 96 | + | } | |||
| 97 | + | n, err := Open(path) | |||
| 98 | + | if err != nil { | |||
| 99 | + | tb.Fatal(err) | |||
| 100 | + | } | |||
| 101 | + | tb.Cleanup(func() { _ = n.Close() }) | |||
| 102 | + | return n | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func TestWaitPointIdleTimeoutRealNodeNoAllocs(t *testing.T) { | |||
| 106 | + | n := openBenchNode(t) | |||
| 107 | + | h, err := n.Create() | |||
| 108 | + | if err != nil { | |||
| 109 | + | t.Fatal(err) | |||
| 110 | + | } | |||
| 111 | + | defer n.Destroy(h) | |||
| 112 | + | ready, err := n.WaitPoint(h, 1, 0) | |||
| 113 | + | if ready || err != nil { | |||
| 114 | + | t.Fatalf("idle unsignaled point: got %v, %v", ready, err) | |||
| 115 | + | } | |||
| 116 | + | if a := testing.AllocsPerRun(200, func() { _, _ = n.WaitPoint(h, 1, 0) }); a != 0 { | |||
| 117 | + | t.Errorf("idle WaitPoint allocs/op = %v, want 0", a) | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func BenchmarkWaitResultIdle(b *testing.B) { | |||
| 122 | + | b.ReportAllocs() | |||
| 123 | + | for i := 0; i < b.N; i++ { | |||
| 124 | + | _, _ = waitResult(unix.ETIME) | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // BenchmarkWaitPointIdle measures the presentation waiter's idle path on a | |||
| 129 | + | // real render node: an unsignaled point that times out (ETIME/EBUSY). | |||
| 130 | + | func BenchmarkWaitPointIdle(b *testing.B) { | |||
| 131 | + | n := openBenchNode(b) | |||
| 132 | + | h, err := n.Create() | |||
| 133 | + | if err != nil { | |||
| 134 | + | b.Fatal(err) | |||
| 135 | + | } | |||
| 136 | + | defer n.Destroy(h) | |||
| 137 | + | b.ReportAllocs() | |||
| 138 | + | b.ResetTimer() | |||
| 139 | + | for i := 0; i < b.N; i++ { | |||
| 140 | + | if ready, err := n.WaitPoint(h, 1, 20*time.Millisecond); ready || err != nil { | |||
| 141 | + | b.Fatalf("got %v, %v", ready, err) | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | } | |||
A internal/presentation/systemtest/demo_discard_test.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package systemtest | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "encoding/json" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestDemoDiscardProtection(t *testing.T) { | |
| 15 | + | requireHarness(t) | |
| 16 | + | bin := demoBinary(t) | |
| 17 | + | for _, tc := range []struct { | |
| 18 | + | name, input string | |
| 19 | + | empty, pending bool | |
| 20 | + | }{ | |
| 21 | + | {"armed", "move 140 24\nclick left\n", false, true}, | |
| 22 | + | {"cancel", "move 140 24\nclick left\nsleep 300ms\nmove 275 24\nclick left\n", false, false}, | |
| 23 | + | {"confirmed", "move 140 24\nclick left\nsleep 300ms\nmove 140 24\nclick left\n", true, false}, | |
| 24 | + | {"edited", "move 140 24\nclick left\nsleep 300ms\nmove 300 120\nclick left\ntype Added\n", false, false}, | |
| 25 | + | } { | |
| 26 | + | t.Run(tc.name, func(t *testing.T) { | |
| 27 | + | script := filepath.Join(t.TempDir(), "input.txt") | |
| 28 | + | if err := os.WriteFile(script, []byte("sleep 8s\n"+tc.input+"sleep 1s\n"), 0600); err != nil { | |
| 29 | + | t.Fatal(err) | |
| 30 | + | } | |
| 31 | + | res := runDemo(t, bin, 1, 400, script, 11*time.Second) | |
| 32 | + | data, err := os.ReadFile(filepath.Join(res.Artifacts["debug"], "demo-state.json")) | |
| 33 | + | if err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | var state struct { | |
| 37 | + | Notes string | |
| 38 | + | NewPending bool | |
| 39 | + | } | |
| 40 | + | if err := json.Unmarshal(data, &state); err != nil { | |
| 41 | + | t.Fatal(err) | |
| 42 | + | } | |
| 43 | + | if (state.Notes == "") != tc.empty || state.NewPending != tc.pending { | |
| 44 | + | t.Fatalf("discard state: %+v", state) | |
| 45 | + | } | |
| 46 | + | if tc.name == "edited" && !strings.Contains(state.Notes, "Added") { | |
| 47 | + | t.Fatal("editor input not delivered") | |
| 48 | + | } | |
| 49 | + | }) | |
| 50 | + | } | |
| 51 | + | } | |
| @@ -0,0 +1,51 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package systemtest | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "strings" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestDemoDiscardProtection(t *testing.T) { | |||
| 15 | + | requireHarness(t) | |||
| 16 | + | bin := demoBinary(t) | |||
| 17 | + | for _, tc := range []struct { | |||
| 18 | + | name, input string | |||
| 19 | + | empty, pending bool | |||
| 20 | + | }{ | |||
| 21 | + | {"armed", "move 140 24\nclick left\n", false, true}, | |||
| 22 | + | {"cancel", "move 140 24\nclick left\nsleep 300ms\nmove 275 24\nclick left\n", false, false}, | |||
| 23 | + | {"confirmed", "move 140 24\nclick left\nsleep 300ms\nmove 140 24\nclick left\n", true, false}, | |||
| 24 | + | {"edited", "move 140 24\nclick left\nsleep 300ms\nmove 300 120\nclick left\ntype Added\n", false, false}, | |||
| 25 | + | } { | |||
| 26 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 27 | + | script := filepath.Join(t.TempDir(), "input.txt") | |||
| 28 | + | if err := os.WriteFile(script, []byte("sleep 8s\n"+tc.input+"sleep 1s\n"), 0600); err != nil { | |||
| 29 | + | t.Fatal(err) | |||
| 30 | + | } | |||
| 31 | + | res := runDemo(t, bin, 1, 400, script, 11*time.Second) | |||
| 32 | + | data, err := os.ReadFile(filepath.Join(res.Artifacts["debug"], "demo-state.json")) | |||
| 33 | + | if err != nil { | |||
| 34 | + | t.Fatal(err) | |||
| 35 | + | } | |||
| 36 | + | var state struct { | |||
| 37 | + | Notes string | |||
| 38 | + | NewPending bool | |||
| 39 | + | } | |||
| 40 | + | if err := json.Unmarshal(data, &state); err != nil { | |||
| 41 | + | t.Fatal(err) | |||
| 42 | + | } | |||
| 43 | + | if (state.Notes == "") != tc.empty || state.NewPending != tc.pending { | |||
| 44 | + | t.Fatalf("discard state: %+v", state) | |||
| 45 | + | } | |||
| 46 | + | if tc.name == "edited" && !strings.Contains(state.Notes, "Added") { | |||
| 47 | + | t.Fatal("editor input not delivered") | |||
| 48 | + | } | |||
| 49 | + | }) | |||
| 50 | + | } | |||
| 51 | + | } | |||
M internal/presentation/systemtest/demo_test.go +20 -20
| @@ -63,8 +63,8 @@ func TestDemoStatic(t *testing.T) { | |||
| 63 | 63 | x, y int | |
| 64 | 64 | want color.NRGBA | |
| 65 | 65 | }{ | |
| 66 | - | {"background", 5, 5, color.NRGBA{245, 247, 250, 255}}, | |
| 67 | - | {"button", 110, 390, color.NRGBA{117, 148, 201, 255}}, | |
| 66 | + | {"background", 5, 5, color.NRGBA{255, 255, 255, 255}}, | |
| 67 | + | {"button", 740, 185, color.NRGBA{212, 212, 216, 255}}, | |
| 68 | 68 | } { | |
| 69 | 69 | x, y := p.x*scale, p.y*scale | |
| 70 | 70 | t.Logf("%s (%d,%d)=%v", p.name, x, y, color.NRGBAModel.Convert(img.At(x, y))) | |
| @@ -81,10 +81,10 @@ func TestDemoStatic(t *testing.T) { | |||
| 81 | 81 | x0, y0, x1, y1 int | |
| 82 | 82 | fill color.NRGBA | |
| 83 | 83 | }{ | |
| 84 | - | {"Continuer", 110, 360, 222, 380, color.NRGBA{117, 148, 201, 255}}, // disabled primary | |
| 85 | - | {"Accueil", 48, 88, 108, 114, color.NRGBA{143, 166, 207, 255}}, // active menu item | |
| 86 | - | {"Thème sombre", 60, 126, 200, 152, color.NRGBA{218, 229, 245, 255}}, // header, right of the checkbox indicator | |
| 87 | - | {"Ada placeholder", 68, 290, 180, 318, color.NRGBA{241, 245, 252, 255}}, // input | |
| 84 | + | {"Apply", 810, 177, 866, 198, color.NRGBA{212, 212, 216, 255}}, | |
| 85 | + | {"Untitled.txt", 24, 113, 115, 136, color.NRGBA{228, 228, 231, 255}}, | |
| 86 | + | {"Dark mode", 850, 14, 935, 35, color.NRGBA{255, 255, 255, 255}}, | |
| 87 | + | {"Name placeholder", 749, 130, 840, 152, color.NRGBA{250, 250, 250, 255}}, | |
| 88 | 88 | } { | |
| 89 | 89 | n, total := ink(img, l.x0*scale, l.y0*scale, l.x1*scale, l.y1*scale, l.fill) | |
| 90 | 90 | if n < 40*scale*scale || n > total/2 { | |
| @@ -125,7 +125,7 @@ func TestDemoCursorShapes(t *testing.T) { | |||
| 125 | 125 | script := filepath.Join(t.TempDir(), "input.txt") | |
| 126 | 126 | // Same cold-start allowance as TestDemoInputAndResources: the script | |
| 127 | 127 | // starts at compositor launch, not when the demo maps. | |
| 128 | - | if err := os.WriteFile(script, []byte("sleep 8s\nmove 180 102\nsleep 1s\nmove 150 310\nsleep 1s\nmove 600 500\nsleep 1s\n"), 0600); err != nil { | |
| 128 | + | if err := os.WriteFile(script, []byte("sleep 8s\nmove 80 123\nsleep 1s\nmove 800 140\nsleep 1s\nmove 100 580\nsleep 1s\n"), 0600); err != nil { | |
| 129 | 129 | t.Fatal(err) | |
| 130 | 130 | } | |
| 131 | 131 | res := runDemo(t, bin, 1, 400, script, 12*time.Second) | |
| @@ -155,7 +155,7 @@ func TestDemoInputAndResources(t *testing.T) { | |||
| 155 | 155 | // wait past cold start (font discovery) before injecting input. The demo | |
| 156 | 156 | // keeps presenting for 12 s so the run outlasts the ~10 s script however | |
| 157 | 157 | // fast frames render. | |
| 158 | - | if err := os.WriteFile(script, []byte("sleep 8s\nmove 120 310\nsleep 200ms\nclick left\nsleep 200ms\ntype Ada\nsleep 200ms\nmove 130 390\nsleep 200ms\nclick left\nsleep 1s\n"), 0600); err != nil { | |
| 158 | + | if err := os.WriteFile(script, []byte("sleep 8s\nmove 800 140\nsleep 200ms\nclick left\nsleep 200ms\ntype Example\nsleep 200ms\nmove 820 186\nsleep 200ms\nclick left\nsleep 1s\n"), 0600); err != nil { | |
| 159 | 159 | t.Fatal(err) | |
| 160 | 160 | } | |
| 161 | 161 | res := runDemo(t, bin, 1, 400, script, 12*time.Second) | |
| @@ -167,7 +167,7 @@ func TestDemoInputAndResources(t *testing.T) { | |||
| 167 | 167 | if err = json.Unmarshal(data, &state); err != nil { | |
| 168 | 168 | t.Fatal(err) | |
| 169 | 169 | } | |
| 170 | - | if state.Name != "Ada" || state.Status != "Bonjour Ada" { | |
| 170 | + | if state.Name != "Example" || state.Status != "Author set to Example · session only" { | |
| 171 | 171 | layout, _ := os.ReadFile(filepath.Join(res.Artifacts["debug"], "layout.json")) | |
| 172 | 172 | t.Fatalf("state %+v layout=%s", state, layout) | |
| 173 | 173 | } | |
| @@ -186,7 +186,7 @@ func TestDemoInputAndResources(t *testing.T) { | |||
| 186 | 186 | } | |
| 187 | 187 | } | |
| 188 | 188 | ||
| 189 | - | // TestDemoIndicators opens the settings page, selects the second radio and | |
| 189 | + | // TestDemoIndicators returns to the editor, selects the second radio and | |
| 190 | 190 | // clicks the slider track, then checks the model and the painted indicators | |
| 191 | 191 | // in the captured frame: the selected radio and the slider fill up to the | |
| 192 | 192 | // thumb use the accent color, the rest of the track does not. | |
| @@ -195,7 +195,7 @@ func TestDemoIndicators(t *testing.T) { | |||
| 195 | 195 | bin := demoBinary(t) | |
| 196 | 196 | script := filepath.Join(t.TempDir(), "input.txt") | |
| 197 | 197 | // Same cold-start allowance as TestDemoInputAndResources. | |
| 198 | - | if err := os.WriteFile(script, []byte("sleep 8s\nmove 180 102\nsleep 200ms\nclick left\nsleep 800ms\nmove 74 311\nsleep 200ms\nclick left\nsleep 500ms\nmove 274 371\nsleep 200ms\nclick left\nsleep 300ms\nmove 700 560\nsleep 1s\n"), 0600); err != nil { | |
| 198 | + | if err := os.WriteFile(script, []byte("sleep 8s\nmove 80 166\nsleep 200ms\nclick left\nsleep 800ms\nmove 80 123\nsleep 200ms\nclick left\nsleep 500ms\nmove 800 312\nsleep 200ms\nclick left\nsleep 300ms\nmove 883 377\nclick left\nsleep 300ms\nmove 700 560\nsleep 1s\n"), 0600); err != nil { | |
| 199 | 199 | t.Fatal(err) | |
| 200 | 200 | } | |
| 201 | 201 | res := runDemo(t, bin, 1, 400, script, 13*time.Second) | |
| @@ -210,23 +210,23 @@ func TestDemoIndicators(t *testing.T) { | |||
| 210 | 210 | if err = json.Unmarshal(data, &state); err != nil { | |
| 211 | 211 | t.Fatal(err) | |
| 212 | 212 | } | |
| 213 | - | if state.Page != "settings" || state.Density != "compact" || state.Volume != 70 { | |
| 213 | + | if state.Page != "home" || state.Density != "compact" || state.Volume != 70 { | |
| 214 | 214 | t.Fatalf("state %+v", state) | |
| 215 | 215 | } | |
| 216 | 216 | img := lastMappedFrame(t, res.Artifacts["frames"]) | |
| 217 | - | accent := color.NRGBA{53, 132, 228, 255} // UA accent-color #3584e4 | |
| 218 | - | // Muted paint is the demo text color at 45% alpha over white. Each probe | |
| 217 | + | accent := color.NRGBA{82, 82, 91, 255} // light theme accent | |
| 218 | + | // Muted paint is the demo text color at 45% alpha over the inspector. Each probe | |
| 219 | 219 | // expects a specific color, so missing paint (plain white) fails too. | |
| 220 | - | muted := color.NRGBA{151, 157, 167, 255} | |
| 220 | + | muted := color.NRGBA{151, 151, 152, 255} | |
| 221 | 221 | for _, p := range []struct { | |
| 222 | 222 | name string | |
| 223 | 223 | x, y int | |
| 224 | 224 | want color.NRGBA | |
| 225 | 225 | }{ | |
| 226 | - | {"selected radio", 68, 311, accent}, | |
| 227 | - | {"unselected radio outline", 68, 276, muted}, | |
| 228 | - | {"slider fill", 150, 371, accent}, | |
| 229 | - | {"slider rail", 330, 371, muted}, | |
| 226 | + | {"selected radio", 743, 304, accent}, | |
| 227 | + | {"unselected radio outline", 741, 273, muted}, | |
| 228 | + | {"slider fill", 790, 377, accent}, | |
| 229 | + | {"slider rail", 930, 377, muted}, | |
| 230 | 230 | } { | |
| 231 | 231 | t.Logf("%s (%d,%d)=%v", p.name, p.x, p.y, color.NRGBAModel.Convert(img.At(p.x, p.y))) | |
| 232 | 232 | if err := harness.PixelProbe(img, p.x, p.y, p.want, 12); err != nil { | |
| @@ -249,7 +249,7 @@ func lastMappedFrame(t *testing.T, dir string) image.Image { | |||
| 249 | 249 | if err != nil { | |
| 250 | 250 | continue // possibly still being written | |
| 251 | 251 | } | |
| 252 | - | if harness.PixelProbe(img, 5, 5, color.NRGBA{245, 247, 250, 255}, 8) == nil { | |
| 252 | + | if harness.PixelProbe(img, 5, 5, color.NRGBA{255, 255, 255, 255}, 8) == nil || harness.PixelProbe(img, 5, 5, color.NRGBA{32, 32, 35, 255}, 8) == nil { | |
| 253 | 253 | return img | |
| 254 | 254 | } | |
| 255 | 255 | } | |
| @@ -63,8 +63,8 @@ func TestDemoStatic(t *testing.T) { | |||||
| 63 | x, y int | 63 | x, y int | ||
| 64 | want color.NRGBA | 64 | want color.NRGBA | ||
| 65 | }{ | 65 | }{ | ||
| 66 | - | {"background", 5, 5, color.NRGBA{245, 247, 250, 255}}, | 66 | + | {"background", 5, 5, color.NRGBA{255, 255, 255, 255}}, |
| 67 | - | {"button", 110, 390, color.NRGBA{117, 148, 201, 255}}, | 67 | + | {"button", 740, 185, color.NRGBA{212, 212, 216, 255}}, |
| 68 | } { | 68 | } { | ||
| 69 | x, y := p.x*scale, p.y*scale | 69 | x, y := p.x*scale, p.y*scale | ||
| 70 | t.Logf("%s (%d,%d)=%v", p.name, x, y, color.NRGBAModel.Convert(img.At(x, y))) | 70 | t.Logf("%s (%d,%d)=%v", p.name, x, y, color.NRGBAModel.Convert(img.At(x, y))) | ||
| @@ -81,10 +81,10 @@ func TestDemoStatic(t *testing.T) { | |||||
| 81 | x0, y0, x1, y1 int | 81 | x0, y0, x1, y1 int | ||
| 82 | fill color.NRGBA | 82 | fill color.NRGBA | ||
| 83 | }{ | 83 | }{ | ||
| 84 | - | {"Continuer", 110, 360, 222, 380, color.NRGBA{117, 148, 201, 255}}, // disabled primary | 84 | + | {"Apply", 810, 177, 866, 198, color.NRGBA{212, 212, 216, 255}}, |
| 85 | - | {"Accueil", 48, 88, 108, 114, color.NRGBA{143, 166, 207, 255}}, // active menu item | 85 | + | {"Untitled.txt", 24, 113, 115, 136, color.NRGBA{228, 228, 231, 255}}, |
| 86 | - | {"Thème sombre", 60, 126, 200, 152, color.NRGBA{218, 229, 245, 255}}, // header, right of the checkbox indicator | 86 | + | {"Dark mode", 850, 14, 935, 35, color.NRGBA{255, 255, 255, 255}}, |
| 87 | - | {"Ada placeholder", 68, 290, 180, 318, color.NRGBA{241, 245, 252, 255}}, // input | 87 | + | {"Name placeholder", 749, 130, 840, 152, color.NRGBA{250, 250, 250, 255}}, |
| 88 | } { | 88 | } { | ||
| 89 | n, total := ink(img, l.x0*scale, l.y0*scale, l.x1*scale, l.y1*scale, l.fill) | 89 | n, total := ink(img, l.x0*scale, l.y0*scale, l.x1*scale, l.y1*scale, l.fill) | ||
| 90 | if n < 40*scale*scale || n > total/2 { | 90 | if n < 40*scale*scale || n > total/2 { | ||
| @@ -125,7 +125,7 @@ func TestDemoCursorShapes(t *testing.T) { | |||||
| 125 | script := filepath.Join(t.TempDir(), "input.txt") | 125 | script := filepath.Join(t.TempDir(), "input.txt") | ||
| 126 | // Same cold-start allowance as TestDemoInputAndResources: the script | 126 | // Same cold-start allowance as TestDemoInputAndResources: the script | ||
| 127 | // starts at compositor launch, not when the demo maps. | 127 | // starts at compositor launch, not when the demo maps. | ||
| 128 | - | if err := os.WriteFile(script, []byte("sleep 8s\nmove 180 102\nsleep 1s\nmove 150 310\nsleep 1s\nmove 600 500\nsleep 1s\n"), 0600); err != nil { | 128 | + | if err := os.WriteFile(script, []byte("sleep 8s\nmove 80 123\nsleep 1s\nmove 800 140\nsleep 1s\nmove 100 580\nsleep 1s\n"), 0600); err != nil { |
| 129 | t.Fatal(err) | 129 | t.Fatal(err) | ||
| 130 | } | 130 | } | ||
| 131 | res := runDemo(t, bin, 1, 400, script, 12*time.Second) | 131 | res := runDemo(t, bin, 1, 400, script, 12*time.Second) | ||
| @@ -155,7 +155,7 @@ func TestDemoInputAndResources(t *testing.T) { | |||||
| 155 | // wait past cold start (font discovery) before injecting input. The demo | 155 | // wait past cold start (font discovery) before injecting input. The demo | ||
| 156 | // keeps presenting for 12 s so the run outlasts the ~10 s script however | 156 | // keeps presenting for 12 s so the run outlasts the ~10 s script however | ||
| 157 | // fast frames render. | 157 | // fast frames render. | ||
| 158 | - | if err := os.WriteFile(script, []byte("sleep 8s\nmove 120 310\nsleep 200ms\nclick left\nsleep 200ms\ntype Ada\nsleep 200ms\nmove 130 390\nsleep 200ms\nclick left\nsleep 1s\n"), 0600); err != nil { | 158 | + | if err := os.WriteFile(script, []byte("sleep 8s\nmove 800 140\nsleep 200ms\nclick left\nsleep 200ms\ntype Example\nsleep 200ms\nmove 820 186\nsleep 200ms\nclick left\nsleep 1s\n"), 0600); err != nil { |
| 159 | t.Fatal(err) | 159 | t.Fatal(err) | ||
| 160 | } | 160 | } | ||
| 161 | res := runDemo(t, bin, 1, 400, script, 12*time.Second) | 161 | res := runDemo(t, bin, 1, 400, script, 12*time.Second) | ||
| @@ -167,7 +167,7 @@ func TestDemoInputAndResources(t *testing.T) { | |||||
| 167 | if err = json.Unmarshal(data, &state); err != nil { | 167 | if err = json.Unmarshal(data, &state); err != nil { | ||
| 168 | t.Fatal(err) | 168 | t.Fatal(err) | ||
| 169 | } | 169 | } | ||
| 170 | - | if state.Name != "Ada" || state.Status != "Bonjour Ada" { | 170 | + | if state.Name != "Example" || state.Status != "Author set to Example · session only" { |
| 171 | layout, _ := os.ReadFile(filepath.Join(res.Artifacts["debug"], "layout.json")) | 171 | layout, _ := os.ReadFile(filepath.Join(res.Artifacts["debug"], "layout.json")) | ||
| 172 | t.Fatalf("state %+v layout=%s", state, layout) | 172 | t.Fatalf("state %+v layout=%s", state, layout) | ||
| 173 | } | 173 | } | ||
| @@ -186,7 +186,7 @@ func TestDemoInputAndResources(t *testing.T) { | |||||
| 186 | } | 186 | } | ||
| 187 | } | 187 | } | ||
| 188 | 188 | ||||
| 189 | - | // TestDemoIndicators opens the settings page, selects the second radio and | 189 | + | // TestDemoIndicators returns to the editor, selects the second radio and |
| 190 | // clicks the slider track, then checks the model and the painted indicators | 190 | // clicks the slider track, then checks the model and the painted indicators | ||
| 191 | // in the captured frame: the selected radio and the slider fill up to the | 191 | // in the captured frame: the selected radio and the slider fill up to the | ||
| 192 | // thumb use the accent color, the rest of the track does not. | 192 | // thumb use the accent color, the rest of the track does not. | ||
| @@ -195,7 +195,7 @@ func TestDemoIndicators(t *testing.T) { | |||||
| 195 | bin := demoBinary(t) | 195 | bin := demoBinary(t) | ||
| 196 | script := filepath.Join(t.TempDir(), "input.txt") | 196 | script := filepath.Join(t.TempDir(), "input.txt") | ||
| 197 | // Same cold-start allowance as TestDemoInputAndResources. | 197 | // Same cold-start allowance as TestDemoInputAndResources. | ||
| 198 | - | if err := os.WriteFile(script, []byte("sleep 8s\nmove 180 102\nsleep 200ms\nclick left\nsleep 800ms\nmove 74 311\nsleep 200ms\nclick left\nsleep 500ms\nmove 274 371\nsleep 200ms\nclick left\nsleep 300ms\nmove 700 560\nsleep 1s\n"), 0600); err != nil { | 198 | + | if err := os.WriteFile(script, []byte("sleep 8s\nmove 80 166\nsleep 200ms\nclick left\nsleep 800ms\nmove 80 123\nsleep 200ms\nclick left\nsleep 500ms\nmove 800 312\nsleep 200ms\nclick left\nsleep 300ms\nmove 883 377\nclick left\nsleep 300ms\nmove 700 560\nsleep 1s\n"), 0600); err != nil { |
| 199 | t.Fatal(err) | 199 | t.Fatal(err) | ||
| 200 | } | 200 | } | ||
| 201 | res := runDemo(t, bin, 1, 400, script, 13*time.Second) | 201 | res := runDemo(t, bin, 1, 400, script, 13*time.Second) | ||
| @@ -210,23 +210,23 @@ func TestDemoIndicators(t *testing.T) { | |||||
| 210 | if err = json.Unmarshal(data, &state); err != nil { | 210 | if err = json.Unmarshal(data, &state); err != nil { | ||
| 211 | t.Fatal(err) | 211 | t.Fatal(err) | ||
| 212 | } | 212 | } | ||
| 213 | - | if state.Page != "settings" || state.Density != "compact" || state.Volume != 70 { | 213 | + | if state.Page != "home" || state.Density != "compact" || state.Volume != 70 { |
| 214 | t.Fatalf("state %+v", state) | 214 | t.Fatalf("state %+v", state) | ||
| 215 | } | 215 | } | ||
| 216 | img := lastMappedFrame(t, res.Artifacts["frames"]) | 216 | img := lastMappedFrame(t, res.Artifacts["frames"]) | ||
| 217 | - | accent := color.NRGBA{53, 132, 228, 255} // UA accent-color #3584e4 | 217 | + | accent := color.NRGBA{82, 82, 91, 255} // light theme accent |
| 218 | - | // Muted paint is the demo text color at 45% alpha over white. Each probe | 218 | + | // Muted paint is the demo text color at 45% alpha over the inspector. Each probe |
| 219 | // expects a specific color, so missing paint (plain white) fails too. | 219 | // expects a specific color, so missing paint (plain white) fails too. | ||
| 220 | - | muted := color.NRGBA{151, 157, 167, 255} | 220 | + | muted := color.NRGBA{151, 151, 152, 255} |
| 221 | for _, p := range []struct { | 221 | for _, p := range []struct { | ||
| 222 | name string | 222 | name string | ||
| 223 | x, y int | 223 | x, y int | ||
| 224 | want color.NRGBA | 224 | want color.NRGBA | ||
| 225 | }{ | 225 | }{ | ||
| 226 | - | {"selected radio", 68, 311, accent}, | 226 | + | {"selected radio", 743, 304, accent}, |
| 227 | - | {"unselected radio outline", 68, 276, muted}, | 227 | + | {"unselected radio outline", 741, 273, muted}, |
| 228 | - | {"slider fill", 150, 371, accent}, | 228 | + | {"slider fill", 790, 377, accent}, |
| 229 | - | {"slider rail", 330, 371, muted}, | 229 | + | {"slider rail", 930, 377, muted}, |
| 230 | } { | 230 | } { | ||
| 231 | t.Logf("%s (%d,%d)=%v", p.name, p.x, p.y, color.NRGBAModel.Convert(img.At(p.x, p.y))) | 231 | t.Logf("%s (%d,%d)=%v", p.name, p.x, p.y, color.NRGBAModel.Convert(img.At(p.x, p.y))) | ||
| 232 | if err := harness.PixelProbe(img, p.x, p.y, p.want, 12); err != nil { | 232 | if err := harness.PixelProbe(img, p.x, p.y, p.want, 12); err != nil { | ||
| @@ -249,7 +249,7 @@ func lastMappedFrame(t *testing.T, dir string) image.Image { | |||||
| 249 | if err != nil { | 249 | if err != nil { | ||
| 250 | continue // possibly still being written | 250 | continue // possibly still being written | ||
| 251 | } | 251 | } | ||
| 252 | - | if harness.PixelProbe(img, 5, 5, color.NRGBA{245, 247, 250, 255}, 8) == nil { | 252 | + | if harness.PixelProbe(img, 5, 5, color.NRGBA{255, 255, 255, 255}, 8) == nil || harness.PixelProbe(img, 5, 5, color.NRGBA{32, 32, 35, 255}, 8) == nil { |
| 253 | return img | 253 | return img | ||
| 254 | } | 254 | } | ||
| 255 | } | 255 | } | ||
A internal/presentation/systemtest/demo_theme_test.go +47 -0
| @@ -0,0 +1,47 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package systemtest | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "encoding/json" | |
| 7 | + | "image/color" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/nefergui/internal/harness" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestDemoThemeAndPreferences(t *testing.T) { | |
| 17 | + | requireHarness(t) | |
| 18 | + | bin := demoBinary(t) | |
| 19 | + | script := filepath.Join(t.TempDir(), "input.txt") | |
| 20 | + | if err := os.WriteFile(script, []byte("sleep 8s\nmove 890 24\nclick left\nsleep 500ms\nmove 80 166\nclick left\nsleep 500ms\nmove 600 580\nsleep 1s\n"), 0600); err != nil { | |
| 21 | + | t.Fatal(err) | |
| 22 | + | } | |
| 23 | + | res := runDemo(t, bin, 1, 400, script, 12*time.Second) | |
| 24 | + | data, err := os.ReadFile(filepath.Join(res.Artifacts["debug"], "demo-state.json")) | |
| 25 | + | if err != nil { | |
| 26 | + | t.Fatal(err) | |
| 27 | + | } | |
| 28 | + | var state struct { | |
| 29 | + | Page string | |
| 30 | + | DarkMode bool | |
| 31 | + | } | |
| 32 | + | if err := json.Unmarshal(data, &state); err != nil { | |
| 33 | + | t.Fatal(err) | |
| 34 | + | } | |
| 35 | + | if !state.DarkMode || state.Page != "settings" { | |
| 36 | + | t.Fatalf("theme/navigation did not update: %+v", state) | |
| 37 | + | } | |
| 38 | + | img := lastMappedFrame(t, res.Artifacts["frames"]) | |
| 39 | + | for _, p := range []struct { | |
| 40 | + | x, y int | |
| 41 | + | want color.NRGBA | |
| 42 | + | }{{600, 80, color.NRGBA{24, 24, 27, 255}}, {240, 230, color.NRGBA{24, 24, 27, 255}}} { | |
| 43 | + | if err := harness.PixelProbe(img, p.x, p.y, p.want, 3); err != nil { | |
| 44 | + | t.Error(err) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | } | |
| @@ -0,0 +1,47 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package systemtest | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "image/color" | |||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/nefergui/internal/harness" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestDemoThemeAndPreferences(t *testing.T) { | |||
| 17 | + | requireHarness(t) | |||
| 18 | + | bin := demoBinary(t) | |||
| 19 | + | script := filepath.Join(t.TempDir(), "input.txt") | |||
| 20 | + | if err := os.WriteFile(script, []byte("sleep 8s\nmove 890 24\nclick left\nsleep 500ms\nmove 80 166\nclick left\nsleep 500ms\nmove 600 580\nsleep 1s\n"), 0600); err != nil { | |||
| 21 | + | t.Fatal(err) | |||
| 22 | + | } | |||
| 23 | + | res := runDemo(t, bin, 1, 400, script, 12*time.Second) | |||
| 24 | + | data, err := os.ReadFile(filepath.Join(res.Artifacts["debug"], "demo-state.json")) | |||
| 25 | + | if err != nil { | |||
| 26 | + | t.Fatal(err) | |||
| 27 | + | } | |||
| 28 | + | var state struct { | |||
| 29 | + | Page string | |||
| 30 | + | DarkMode bool | |||
| 31 | + | } | |||
| 32 | + | if err := json.Unmarshal(data, &state); err != nil { | |||
| 33 | + | t.Fatal(err) | |||
| 34 | + | } | |||
| 35 | + | if !state.DarkMode || state.Page != "settings" { | |||
| 36 | + | t.Fatalf("theme/navigation did not update: %+v", state) | |||
| 37 | + | } | |||
| 38 | + | img := lastMappedFrame(t, res.Artifacts["frames"]) | |||
| 39 | + | for _, p := range []struct { | |||
| 40 | + | x, y int | |||
| 41 | + | want color.NRGBA | |||
| 42 | + | }{{600, 80, color.NRGBA{24, 24, 27, 255}}, {240, 230, color.NRGBA{24, 24, 27, 255}}} { | |||
| 43 | + | if err := harness.PixelProbe(img, p.x, p.y, p.want, 3); err != nil { | |||
| 44 | + | t.Error(err) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | } | |||
M internal/presentation/systemtest/system_test.go +4 -0
| @@ -389,6 +389,10 @@ func TestListStyles(t *testing.T) { | |||
| 389 | 389 | {"rounded corner", 113 * scale, 113 * scale, bg}, | |
| 390 | 390 | {"outline", 200 * scale, 114 * scale, color.NRGBA{255, 0, 255, 255}}, | |
| 391 | 391 | {"far", 200 * scale, 285 * scale, bg}, | |
| 392 | + | {"single-side left corner", 300 * scale, 43 * scale, color.NRGBA{255, 0, 0, 255}}, | |
| 393 | + | {"single-side right corner", 379 * scale, 43 * scale, color.NRGBA{255, 0, 0, 255}}, | |
| 394 | + | {"unequal corner top", 301 * scale, 242 * scale, color.NRGBA{255, 0, 0, 255}}, | |
| 395 | + | {"unequal corner left", 300 * scale, 246 * scale, color.NRGBA{0, 0, 255, 255}}, | |
| 392 | 396 | } | |
| 393 | 397 | for _, p := range probes { | |
| 394 | 398 | got := color.NRGBAModel.Convert(img.At(p.x, p.y)).(color.NRGBA) | |
| @@ -389,6 +389,10 @@ func TestListStyles(t *testing.T) { | |||||
| 389 | {"rounded corner", 113 * scale, 113 * scale, bg}, | 389 | {"rounded corner", 113 * scale, 113 * scale, bg}, | ||
| 390 | {"outline", 200 * scale, 114 * scale, color.NRGBA{255, 0, 255, 255}}, | 390 | {"outline", 200 * scale, 114 * scale, color.NRGBA{255, 0, 255, 255}}, | ||
| 391 | {"far", 200 * scale, 285 * scale, bg}, | 391 | {"far", 200 * scale, 285 * scale, bg}, | ||
| 392 | + | {"single-side left corner", 300 * scale, 43 * scale, color.NRGBA{255, 0, 0, 255}}, | |||
| 393 | + | {"single-side right corner", 379 * scale, 43 * scale, color.NRGBA{255, 0, 0, 255}}, | |||
| 394 | + | {"unequal corner top", 301 * scale, 242 * scale, color.NRGBA{255, 0, 0, 255}}, | |||
| 395 | + | {"unequal corner left", 300 * scale, 246 * scale, color.NRGBA{0, 0, 255, 255}}, | |||
| 392 | } | 396 | } | ||
| 393 | for _, p := range probes { | 397 | for _, p := range probes { | ||
| 394 | got := color.NRGBAModel.Convert(img.At(p.x, p.y)).(color.NRGBA) | 398 | got := color.NRGBAModel.Convert(img.At(p.x, p.y)).(color.NRGBA) | ||
A internal/render/empty_glyph_test.go +48 -0
| @@ -0,0 +1,48 @@ | |||
| 1 | + | package render | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/nefergui/internal/layout" | |
| 7 | + | "github.com/bnema/nefergui/internal/text" | |
| 8 | + | "github.com/go-text/typesetting/font" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestUncoveredSpaceRemainsInvisible(t *testing.T) { | |
| 12 | + | catalog, err := text.Load(text.DirectorySource("../../testdata/fonts")) | |
| 13 | + | if err != nil { | |
| 14 | + | t.Fatal(err) | |
| 15 | + | } | |
| 16 | + | measured, err := text.NewEngine(catalog).Measure("a\u1680b", text.Request{Families: []string{"Noto Sans"}, Size: 20}, 0) | |
| 17 | + | if err != nil { | |
| 18 | + | t.Fatal(err) | |
| 19 | + | } | |
| 20 | + | cmd := layout.Command{Op: "text", Opacity: 1} | |
| 21 | + | spaces := 0 | |
| 22 | + | for _, line := range measured.Lines { | |
| 23 | + | for _, shaped := range line.Runs { | |
| 24 | + | r := layout.Run{Face: shaped.Face, FaceID: shaped.Face.ID, Size: 20} | |
| 25 | + | for _, g := range shaped.Glyphs { | |
| 26 | + | if g.ID == font.EmptyGlyph { | |
| 27 | + | spaces++ | |
| 28 | + | if g.Missing || g.Advance <= 0 { | |
| 29 | + | t.Fatalf("empty space glyph: %+v", g) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | r.Glyphs = append(r.Glyphs, layout.Glyph{ID: uint32(g.ID), X: 6 + g.X, Y: 30 + g.Y}) | |
| 33 | + | } | |
| 34 | + | cmd.Runs = append(cmd.Runs, r) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | if spaces != 1 { | |
| 38 | + | t.Fatalf("want one uncovered space, got %d", spaces) | |
| 39 | + | } | |
| 40 | + | p, err := NewPreparer(256, 2) | |
| 41 | + | if err != nil { | |
| 42 | + | t.Fatal(err) | |
| 43 | + | } | |
| 44 | + | frame, err := p.Prepare([]layout.Command{cmd}, 1, 200, 100) | |
| 45 | + | if err != nil || len(frame.Quads) != 2 { | |
| 46 | + | t.Fatalf("space must emit no quad: %d quads, error %v", len(frame.Quads), err) | |
| 47 | + | } | |
| 48 | + | } | |
| @@ -0,0 +1,48 @@ | |||||
| 1 | + | package render | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/nefergui/internal/layout" | |||
| 7 | + | "github.com/bnema/nefergui/internal/text" | |||
| 8 | + | "github.com/go-text/typesetting/font" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestUncoveredSpaceRemainsInvisible(t *testing.T) { | |||
| 12 | + | catalog, err := text.Load(text.DirectorySource("../../testdata/fonts")) | |||
| 13 | + | if err != nil { | |||
| 14 | + | t.Fatal(err) | |||
| 15 | + | } | |||
| 16 | + | measured, err := text.NewEngine(catalog).Measure("a\u1680b", text.Request{Families: []string{"Noto Sans"}, Size: 20}, 0) | |||
| 17 | + | if err != nil { | |||
| 18 | + | t.Fatal(err) | |||
| 19 | + | } | |||
| 20 | + | cmd := layout.Command{Op: "text", Opacity: 1} | |||
| 21 | + | spaces := 0 | |||
| 22 | + | for _, line := range measured.Lines { | |||
| 23 | + | for _, shaped := range line.Runs { | |||
| 24 | + | r := layout.Run{Face: shaped.Face, FaceID: shaped.Face.ID, Size: 20} | |||
| 25 | + | for _, g := range shaped.Glyphs { | |||
| 26 | + | if g.ID == font.EmptyGlyph { | |||
| 27 | + | spaces++ | |||
| 28 | + | if g.Missing || g.Advance <= 0 { | |||
| 29 | + | t.Fatalf("empty space glyph: %+v", g) | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | r.Glyphs = append(r.Glyphs, layout.Glyph{ID: uint32(g.ID), X: 6 + g.X, Y: 30 + g.Y}) | |||
| 33 | + | } | |||
| 34 | + | cmd.Runs = append(cmd.Runs, r) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | if spaces != 1 { | |||
| 38 | + | t.Fatalf("want one uncovered space, got %d", spaces) | |||
| 39 | + | } | |||
| 40 | + | p, err := NewPreparer(256, 2) | |||
| 41 | + | if err != nil { | |||
| 42 | + | t.Fatal(err) | |||
| 43 | + | } | |||
| 44 | + | frame, err := p.Prepare([]layout.Command{cmd}, 1, 200, 100) | |||
| 45 | + | if err != nil || len(frame.Quads) != 2 { | |||
| 46 | + | t.Fatalf("space must emit no quad: %d quads, error %v", len(frame.Quads), err) | |||
| 47 | + | } | |||
| 48 | + | } | |||
M internal/render/instances.go +128 -47
| @@ -21,57 +21,138 @@ func ListInstances(frame Frame) ([]vkdevice.Instance, ListStats) { | |||
| 21 | 21 | ||
| 22 | 22 | // ListBatches groups consecutive atlas draws and isolates each image draw. | |
| 23 | 23 | // First indexes the returned instances, retaining exact painter's order. | |
| 24 | + | // The returned slices and map are freshly allocated and owned by the caller. | |
| 24 | 25 | func ListBatches(frame Frame) ([]vkdevice.Instance, []vkdevice.Batch, ListStats) { | |
| 25 | - | out := make([]vkdevice.Instance, 0, len(frame.Quads)) | |
| 26 | - | stats := ListStats{Skipped: make(map[string]int)} | |
| 27 | - | var batches []vkdevice.Batch | |
| 28 | - | for _, q := range frame.Quads { | |
| 29 | - | switch q.Op { | |
| 30 | - | case "rect", "selection", "caret", "preedit-underline", "preedit-caret", "glyph", "border", "outline", "shadow", "image": | |
| 31 | - | v := vkdevice.Instance{ | |
| 32 | - | Bounds: [4]float32{float32(q.Rect.X), float32(q.Rect.Y), float32(q.Rect.W), float32(q.Rect.H)}, | |
| 33 | - | Clip: [4]float32{float32(q.Clip.X), float32(q.Clip.Y), float32(q.Clip.W), float32(q.Clip.H)}, | |
| 34 | - | Color: [4]float32{float32(q.Color.R), float32(q.Color.G), float32(q.Color.B), float32(q.Color.A)}, | |
| 35 | - | } | |
| 36 | - | v.Shape = [4]float32{float32(q.Shape.X), float32(q.Shape.Y), float32(q.Shape.W), float32(q.Shape.H)} | |
| 37 | - | v.Widths = [4]float32{float32(q.Widths.Top), float32(q.Widths.Right), float32(q.Widths.Bottom), float32(q.Widths.Left)} | |
| 38 | - | v.Shadow = [4]float32{float32(q.Shadow[0]), float32(q.Shadow[1]), float32(q.Shadow[2]), float32(q.Shadow[3])} | |
| 39 | - | for i, c := range [4]css.Color{q.Colors.Top, q.Colors.Right, q.Colors.Bottom, q.Colors.Left} { | |
| 40 | - | v.Sides[i] = [4]float32{float32(c.R), float32(c.G), float32(c.B), float32(c.A * float64(v.Color[3]))} | |
| 41 | - | } | |
| 42 | - | switch q.Op { | |
| 43 | - | case "image": | |
| 44 | - | v.KindLayer[0] = 6 | |
| 45 | - | v.UV = [4]float32{0, 0, 1, 1} | |
| 46 | - | case "border": | |
| 47 | - | v.KindLayer[0] = 2 | |
| 48 | - | case "outline": | |
| 49 | - | v.KindLayer[0] = 3 | |
| 50 | - | case "shadow": | |
| 51 | - | v.KindLayer[0] = 4 | |
| 52 | - | if q.Inset { | |
| 53 | - | v.KindLayer[0] = 5 | |
| 54 | - | } | |
| 55 | - | } | |
| 56 | - | for i, r := range q.Radii { | |
| 57 | - | v.Radii[i] = float32(r) | |
| 58 | - | } | |
| 59 | - | if q.Op == "glyph" { | |
| 60 | - | v.KindLayer = [4]float32{1, float32(q.Glyph.Page)} | |
| 61 | - | v.UV = [4]float32{float32(q.Glyph.Rect.Min.X) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Min.Y) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Max.X) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Max.Y) / vkdevice.AtlasSize} | |
| 62 | - | } | |
| 63 | - | if q.Op == "image" { | |
| 64 | - | batches = append(batches, vkdevice.Batch{First: uint32(len(out)), Count: 1, Source: q.Image}) | |
| 65 | - | } else if len(batches) == 0 || batches[len(batches)-1].Source != nil { | |
| 66 | - | batches = append(batches, vkdevice.Batch{First: uint32(len(out)), Count: 1}) | |
| 67 | - | } else { | |
| 68 | - | batches[len(batches)-1].Count++ | |
| 69 | - | } | |
| 70 | - | out = append(out, v) | |
| 71 | - | default: | |
| 26 | + | return listBatches(frame, nil, nil, make(map[string]int)) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // ListBuffer is a single-owner scratch area for repeated ListBatches calls. | |
| 30 | + | // The slices it returns alias its storage: they are valid only until the next | |
| 31 | + | // List or Release call, and must not be retained, shared across goroutines, or | |
| 32 | + | // used while another List may run. This is safe for consumers that copy the | |
| 33 | + | // data synchronously (Session.tick uploads instances into GPU memory and | |
| 34 | + | // resolve copies the batches before recording returns); anything that keeps the | |
| 35 | + | // slices past that point must use ListBatches instead. Retained frames hold | |
| 36 | + | // only render.Frame, never these slices, so a blocked frame is re-listed on retry. | |
| 37 | + | // Stats.Skipped shares the same lifetime. The zero value is ready to use. | |
| 38 | + | type ListBuffer struct { | |
| 39 | + | instances []vkdevice.Instance | |
| 40 | + | batches []vkdevice.Batch | |
| 41 | + | skipped map[string]int | |
| 42 | + | } | |
| 43 | + | ||
| 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. | |
| 46 | + | const bufferSlack = 4 | |
| 47 | + | ||
| 48 | + | // List is ListBatches reusing this buffer's storage where capacity allows. | |
| 49 | + | func (b *ListBuffer) List(frame Frame) ([]vkdevice.Instance, []vkdevice.Batch, ListStats) { | |
| 50 | + | b.Release() | |
| 51 | + | if b.skipped == nil { | |
| 52 | + | b.skipped = make(map[string]int) | |
| 53 | + | } | |
| 54 | + | clear(b.skipped) | |
| 55 | + | instances, batches, stats := listBatches(frame, b.instances[:0], b.batches[:0], b.skipped) | |
| 56 | + | b.instances, b.batches = instances[:0], batches[:0] | |
| 57 | + | return instances, batches, stats | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Release drops borrowed image references from the previous result. Call it | |
| 61 | + | // once the result has been consumed so an idle buffer does not pin images. | |
| 62 | + | func (b *ListBuffer) Release() { | |
| 63 | + | clear(b.batches[:cap(b.batches)]) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // drawn reports whether the operation draws an instance. | |
| 67 | + | func drawn(op string) bool { | |
| 68 | + | switch op { | |
| 69 | + | case "rect", "selection", "caret", "preedit-underline", "preedit-caret", "glyph", "border", "outline", "shadow", "image": | |
| 70 | + | return true | |
| 71 | + | } | |
| 72 | + | return false | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // listBatches fills out and batches (both length zero), growing them only when | |
| 76 | + | // their capacity is short of the exact counts or absurdly larger than needed. | |
| 77 | + | func listBatches(frame Frame, out []vkdevice.Instance, batches []vkdevice.Batch, skipped map[string]int) ([]vkdevice.Instance, []vkdevice.Batch, ListStats) { | |
| 78 | + | // Count first so both slices are sized exactly: no append growth, and no | |
| 79 | + | // over-allocation for frames that skip quads. | |
| 80 | + | drawCount, batchCount, open := 0, 0, false | |
| 81 | + | for i := range frame.Quads { | |
| 82 | + | op := frame.Quads[i].Op | |
| 83 | + | if !drawn(op) { | |
| 84 | + | continue | |
| 85 | + | } | |
| 86 | + | drawCount++ | |
| 87 | + | if op == "image" { | |
| 88 | + | batchCount++ | |
| 89 | + | open = false | |
| 90 | + | } else if !open { | |
| 91 | + | batchCount++ | |
| 92 | + | open = true | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | if out == nil || cap(out) < drawCount || cap(out) > bufferSlack*drawCount+64 { | |
| 96 | + | out = make([]vkdevice.Instance, 0, drawCount) | |
| 97 | + | } | |
| 98 | + | if batchCount == 0 { | |
| 99 | + | batches = nil // as before: no draws, no batches | |
| 100 | + | } else if cap(batches) < batchCount || cap(batches) > bufferSlack*batchCount+16 { | |
| 101 | + | batches = make([]vkdevice.Batch, 0, batchCount) | |
| 102 | + | } | |
| 103 | + | stats := ListStats{Skipped: skipped} | |
| 104 | + | for i := range frame.Quads { | |
| 105 | + | q := &frame.Quads[i] | |
| 106 | + | if !drawn(q.Op) { | |
| 72 | 107 | stats.Skipped[q.Op]++ | |
| 108 | + | continue | |
| 73 | 109 | } | |
| 110 | + | if q.Op == "image" { | |
| 111 | + | batches = append(batches, vkdevice.Batch{First: uint32(len(out)), Count: 1, Source: q.Image}) | |
| 112 | + | } else if len(batches) == 0 || batches[len(batches)-1].Source != nil { | |
| 113 | + | batches = append(batches, vkdevice.Batch{First: uint32(len(out)), Count: 1}) | |
| 114 | + | } else { | |
| 115 | + | batches[len(batches)-1].Count++ | |
| 116 | + | } | |
| 117 | + | out = append(out, instanceOf(q)) | |
| 74 | 118 | } | |
| 75 | 119 | stats.Drawn = len(out) | |
| 76 | 120 | return out, batches, stats | |
| 77 | 121 | } | |
| 122 | + | ||
| 123 | + | // instanceOf converts one drawable quad to its GPU instance. | |
| 124 | + | func instanceOf(q *Quad) vkdevice.Instance { | |
| 125 | + | v := vkdevice.Instance{ | |
| 126 | + | Bounds: [4]float32{float32(q.Rect.X), float32(q.Rect.Y), float32(q.Rect.W), float32(q.Rect.H)}, | |
| 127 | + | Clip: [4]float32{float32(q.Clip.X), float32(q.Clip.Y), float32(q.Clip.W), float32(q.Clip.H)}, | |
| 128 | + | Color: [4]float32{float32(q.Color.R), float32(q.Color.G), float32(q.Color.B), float32(q.Color.A)}, | |
| 129 | + | } | |
| 130 | + | v.Shape = [4]float32{float32(q.Shape.X), float32(q.Shape.Y), float32(q.Shape.W), float32(q.Shape.H)} | |
| 131 | + | v.Widths = [4]float32{float32(q.Widths.Top), float32(q.Widths.Right), float32(q.Widths.Bottom), float32(q.Widths.Left)} | |
| 132 | + | v.Shadow = [4]float32{float32(q.Shadow[0]), float32(q.Shadow[1]), float32(q.Shadow[2]), float32(q.Shadow[3])} | |
| 133 | + | for i, c := range [4]css.Color{q.Colors.Top, q.Colors.Right, q.Colors.Bottom, q.Colors.Left} { | |
| 134 | + | v.Sides[i] = [4]float32{float32(c.R), float32(c.G), float32(c.B), float32(c.A * float64(v.Color[3]))} | |
| 135 | + | } | |
| 136 | + | switch q.Op { | |
| 137 | + | case "image": | |
| 138 | + | v.KindLayer[0] = 6 | |
| 139 | + | v.UV = [4]float32{0, 0, 1, 1} | |
| 140 | + | case "border": | |
| 141 | + | v.KindLayer[0] = 2 | |
| 142 | + | case "outline": | |
| 143 | + | v.KindLayer[0] = 3 | |
| 144 | + | case "shadow": | |
| 145 | + | v.KindLayer[0] = 4 | |
| 146 | + | if q.Inset { | |
| 147 | + | v.KindLayer[0] = 5 | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | for i, r := range q.Radii { | |
| 151 | + | v.Radii[i] = float32(r) | |
| 152 | + | } | |
| 153 | + | if q.Op == "glyph" { | |
| 154 | + | v.KindLayer = [4]float32{1, float32(q.Glyph.Page)} | |
| 155 | + | v.UV = [4]float32{float32(q.Glyph.Rect.Min.X) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Min.Y) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Max.X) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Max.Y) / vkdevice.AtlasSize} | |
| 156 | + | } | |
| 157 | + | return v | |
| 158 | + | } | |
| @@ -21,57 +21,138 @@ func ListInstances(frame Frame) ([]vkdevice.Instance, ListStats) { | |||||
| 21 | 21 | ||||
| 22 | // ListBatches groups consecutive atlas draws and isolates each image draw. | 22 | // ListBatches groups consecutive atlas draws and isolates each image draw. | ||
| 23 | // First indexes the returned instances, retaining exact painter's order. | 23 | // First indexes the returned instances, retaining exact painter's order. | ||
| 24 | + | // The returned slices and map are freshly allocated and owned by the caller. | |||
| 24 | func ListBatches(frame Frame) ([]vkdevice.Instance, []vkdevice.Batch, ListStats) { | 25 | func ListBatches(frame Frame) ([]vkdevice.Instance, []vkdevice.Batch, ListStats) { | ||
| 25 | - | out := make([]vkdevice.Instance, 0, len(frame.Quads)) | 26 | + | return listBatches(frame, nil, nil, make(map[string]int)) |
| 26 | - | stats := ListStats{Skipped: make(map[string]int)} | 27 | + | } |
| 27 | - | var batches []vkdevice.Batch | 28 | + | |
| 28 | - | for _, q := range frame.Quads { | 29 | + | // ListBuffer is a single-owner scratch area for repeated ListBatches calls. |
| 29 | - | switch q.Op { | 30 | + | // The slices it returns alias its storage: they are valid only until the next |
| 30 | - | case "rect", "selection", "caret", "preedit-underline", "preedit-caret", "glyph", "border", "outline", "shadow", "image": | 31 | + | // List or Release call, and must not be retained, shared across goroutines, or |
| 31 | - | v := vkdevice.Instance{ | 32 | + | // used while another List may run. This is safe for consumers that copy the |
| 32 | - | Bounds: [4]float32{float32(q.Rect.X), float32(q.Rect.Y), float32(q.Rect.W), float32(q.Rect.H)}, | 33 | + | // data synchronously (Session.tick uploads instances into GPU memory and |
| 33 | - | Clip: [4]float32{float32(q.Clip.X), float32(q.Clip.Y), float32(q.Clip.W), float32(q.Clip.H)}, | 34 | + | // resolve copies the batches before recording returns); anything that keeps the |
| 34 | - | Color: [4]float32{float32(q.Color.R), float32(q.Color.G), float32(q.Color.B), float32(q.Color.A)}, | 35 | + | // slices past that point must use ListBatches instead. Retained frames hold |
| 35 | - | } | 36 | + | // only render.Frame, never these slices, so a blocked frame is re-listed on retry. |
| 36 | - | v.Shape = [4]float32{float32(q.Shape.X), float32(q.Shape.Y), float32(q.Shape.W), float32(q.Shape.H)} | 37 | + | // Stats.Skipped shares the same lifetime. The zero value is ready to use. |
| 37 | - | v.Widths = [4]float32{float32(q.Widths.Top), float32(q.Widths.Right), float32(q.Widths.Bottom), float32(q.Widths.Left)} | 38 | + | type ListBuffer struct { |
| 38 | - | v.Shadow = [4]float32{float32(q.Shadow[0]), float32(q.Shadow[1]), float32(q.Shadow[2]), float32(q.Shadow[3])} | 39 | + | instances []vkdevice.Instance |
| 39 | - | for i, c := range [4]css.Color{q.Colors.Top, q.Colors.Right, q.Colors.Bottom, q.Colors.Left} { | 40 | + | batches []vkdevice.Batch |
| 40 | - | v.Sides[i] = [4]float32{float32(c.R), float32(c.G), float32(c.B), float32(c.A * float64(v.Color[3]))} | 41 | + | skipped map[string]int |
| 41 | - | } | 42 | + | } |
| 42 | - | switch q.Op { | 43 | + | |
| 43 | - | case "image": | 44 | + | // bufferSlack is how much larger than needed retained storage may be before |
| 44 | - | v.KindLayer[0] = 6 | 45 | + | // List drops it, so one huge frame does not pin memory forever. |
| 45 | - | v.UV = [4]float32{0, 0, 1, 1} | 46 | + | const bufferSlack = 4 |
| 46 | - | case "border": | 47 | + | |
| 47 | - | v.KindLayer[0] = 2 | 48 | + | // List is ListBatches reusing this buffer's storage where capacity allows. |
| 48 | - | case "outline": | 49 | + | func (b *ListBuffer) List(frame Frame) ([]vkdevice.Instance, []vkdevice.Batch, ListStats) { |
| 49 | - | v.KindLayer[0] = 3 | 50 | + | b.Release() |
| 50 | - | case "shadow": | 51 | + | if b.skipped == nil { |
| 51 | - | v.KindLayer[0] = 4 | 52 | + | b.skipped = make(map[string]int) |
| 52 | - | if q.Inset { | 53 | + | } |
| 53 | - | v.KindLayer[0] = 5 | 54 | + | clear(b.skipped) |
| 54 | - | } | 55 | + | instances, batches, stats := listBatches(frame, b.instances[:0], b.batches[:0], b.skipped) |
| 55 | - | } | 56 | + | b.instances, b.batches = instances[:0], batches[:0] |
| 56 | - | for i, r := range q.Radii { | 57 | + | return instances, batches, stats |
| 57 | - | v.Radii[i] = float32(r) | 58 | + | } |
| 58 | - | } | 59 | + | |
| 59 | - | if q.Op == "glyph" { | 60 | + | // Release drops borrowed image references from the previous result. Call it |
| 60 | - | v.KindLayer = [4]float32{1, float32(q.Glyph.Page)} | 61 | + | // once the result has been consumed so an idle buffer does not pin images. |
| 61 | - | v.UV = [4]float32{float32(q.Glyph.Rect.Min.X) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Min.Y) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Max.X) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Max.Y) / vkdevice.AtlasSize} | 62 | + | func (b *ListBuffer) Release() { |
| 62 | - | } | 63 | + | clear(b.batches[:cap(b.batches)]) |
| 63 | - | if q.Op == "image" { | 64 | + | } |
| 64 | - | batches = append(batches, vkdevice.Batch{First: uint32(len(out)), Count: 1, Source: q.Image}) | 65 | + | |
| 65 | - | } else if len(batches) == 0 || batches[len(batches)-1].Source != nil { | 66 | + | // drawn reports whether the operation draws an instance. |
| 66 | - | batches = append(batches, vkdevice.Batch{First: uint32(len(out)), Count: 1}) | 67 | + | func drawn(op string) bool { |
| 67 | - | } else { | 68 | + | switch op { |
| 68 | - | batches[len(batches)-1].Count++ | 69 | + | case "rect", "selection", "caret", "preedit-underline", "preedit-caret", "glyph", "border", "outline", "shadow", "image": |
| 69 | - | } | 70 | + | return true |
| 70 | - | out = append(out, v) | 71 | + | } |
| 71 | - | default: | 72 | + | return false |
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // listBatches fills out and batches (both length zero), growing them only when | |||
| 76 | + | // their capacity is short of the exact counts or absurdly larger than needed. | |||
| 77 | + | func listBatches(frame Frame, out []vkdevice.Instance, batches []vkdevice.Batch, skipped map[string]int) ([]vkdevice.Instance, []vkdevice.Batch, ListStats) { | |||
| 78 | + | // Count first so both slices are sized exactly: no append growth, and no | |||
| 79 | + | // over-allocation for frames that skip quads. | |||
| 80 | + | drawCount, batchCount, open := 0, 0, false | |||
| 81 | + | for i := range frame.Quads { | |||
| 82 | + | op := frame.Quads[i].Op | |||
| 83 | + | if !drawn(op) { | |||
| 84 | + | continue | |||
| 85 | + | } | |||
| 86 | + | drawCount++ | |||
| 87 | + | if op == "image" { | |||
| 88 | + | batchCount++ | |||
| 89 | + | open = false | |||
| 90 | + | } else if !open { | |||
| 91 | + | batchCount++ | |||
| 92 | + | open = true | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | if out == nil || cap(out) < drawCount || cap(out) > bufferSlack*drawCount+64 { | |||
| 96 | + | out = make([]vkdevice.Instance, 0, drawCount) | |||
| 97 | + | } | |||
| 98 | + | if batchCount == 0 { | |||
| 99 | + | batches = nil // as before: no draws, no batches | |||
| 100 | + | } else if cap(batches) < batchCount || cap(batches) > bufferSlack*batchCount+16 { | |||
| 101 | + | batches = make([]vkdevice.Batch, 0, batchCount) | |||
| 102 | + | } | |||
| 103 | + | stats := ListStats{Skipped: skipped} | |||
| 104 | + | for i := range frame.Quads { | |||
| 105 | + | q := &frame.Quads[i] | |||
| 106 | + | if !drawn(q.Op) { | |||
| 72 | stats.Skipped[q.Op]++ | 107 | stats.Skipped[q.Op]++ | ||
| 108 | + | continue | |||
| 73 | } | 109 | } | ||
| 110 | + | if q.Op == "image" { | |||
| 111 | + | batches = append(batches, vkdevice.Batch{First: uint32(len(out)), Count: 1, Source: q.Image}) | |||
| 112 | + | } else if len(batches) == 0 || batches[len(batches)-1].Source != nil { | |||
| 113 | + | batches = append(batches, vkdevice.Batch{First: uint32(len(out)), Count: 1}) | |||
| 114 | + | } else { | |||
| 115 | + | batches[len(batches)-1].Count++ | |||
| 116 | + | } | |||
| 117 | + | out = append(out, instanceOf(q)) | |||
| 74 | } | 118 | } | ||
| 75 | stats.Drawn = len(out) | 119 | stats.Drawn = len(out) | ||
| 76 | return out, batches, stats | 120 | return out, batches, stats | ||
| 77 | } | 121 | } | ||
| 122 | + | ||||
| 123 | + | // instanceOf converts one drawable quad to its GPU instance. | |||
| 124 | + | func instanceOf(q *Quad) vkdevice.Instance { | |||
| 125 | + | v := vkdevice.Instance{ | |||
| 126 | + | Bounds: [4]float32{float32(q.Rect.X), float32(q.Rect.Y), float32(q.Rect.W), float32(q.Rect.H)}, | |||
| 127 | + | Clip: [4]float32{float32(q.Clip.X), float32(q.Clip.Y), float32(q.Clip.W), float32(q.Clip.H)}, | |||
| 128 | + | Color: [4]float32{float32(q.Color.R), float32(q.Color.G), float32(q.Color.B), float32(q.Color.A)}, | |||
| 129 | + | } | |||
| 130 | + | v.Shape = [4]float32{float32(q.Shape.X), float32(q.Shape.Y), float32(q.Shape.W), float32(q.Shape.H)} | |||
| 131 | + | v.Widths = [4]float32{float32(q.Widths.Top), float32(q.Widths.Right), float32(q.Widths.Bottom), float32(q.Widths.Left)} | |||
| 132 | + | v.Shadow = [4]float32{float32(q.Shadow[0]), float32(q.Shadow[1]), float32(q.Shadow[2]), float32(q.Shadow[3])} | |||
| 133 | + | for i, c := range [4]css.Color{q.Colors.Top, q.Colors.Right, q.Colors.Bottom, q.Colors.Left} { | |||
| 134 | + | v.Sides[i] = [4]float32{float32(c.R), float32(c.G), float32(c.B), float32(c.A * float64(v.Color[3]))} | |||
| 135 | + | } | |||
| 136 | + | switch q.Op { | |||
| 137 | + | case "image": | |||
| 138 | + | v.KindLayer[0] = 6 | |||
| 139 | + | v.UV = [4]float32{0, 0, 1, 1} | |||
| 140 | + | case "border": | |||
| 141 | + | v.KindLayer[0] = 2 | |||
| 142 | + | case "outline": | |||
| 143 | + | v.KindLayer[0] = 3 | |||
| 144 | + | case "shadow": | |||
| 145 | + | v.KindLayer[0] = 4 | |||
| 146 | + | if q.Inset { | |||
| 147 | + | v.KindLayer[0] = 5 | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | for i, r := range q.Radii { | |||
| 151 | + | v.Radii[i] = float32(r) | |||
| 152 | + | } | |||
| 153 | + | if q.Op == "glyph" { | |||
| 154 | + | v.KindLayer = [4]float32{1, float32(q.Glyph.Page)} | |||
| 155 | + | v.UV = [4]float32{float32(q.Glyph.Rect.Min.X) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Min.Y) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Max.X) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Max.Y) / vkdevice.AtlasSize} | |||
| 156 | + | } | |||
| 157 | + | return v | |||
| 158 | + | } | |||
A internal/render/instances_bench_test.go +56 -0
| @@ -0,0 +1,56 @@ | |||
| 1 | + | package render | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "image" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/nefergui/internal/css" | |
| 8 | + | "github.com/bnema/nefergui/internal/layout" | |
| 9 | + | "github.com/bnema/nefergui/internal/text" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // listFixture is a product-like mix: atlas draws with an image every 50 quads. | |
| 13 | + | func listFixture(n int) Frame { | |
| 14 | + | img := image.NewRGBA(image.Rect(0, 0, 2, 2)) | |
| 15 | + | quads := make([]Quad, n) | |
| 16 | + | for i := range quads { | |
| 17 | + | switch { | |
| 18 | + | case i%50 == 49: | |
| 19 | + | quads[i] = Quad{Op: "image", Image: img, Rect: layout.Rect{W: 4, H: 4}} | |
| 20 | + | case i%3 == 0: | |
| 21 | + | quads[i] = Quad{Op: "glyph", Glyph: text.Placement{Page: 1, Rect: image.Rect(1, 2, 9, 12)}, Color: css.Color{A: 1}} | |
| 22 | + | case i%7 == 0: | |
| 23 | + | quads[i] = Quad{Op: "border", Color: css.Color{A: 1}} | |
| 24 | + | default: | |
| 25 | + | quads[i] = Quad{Op: "rect", Rect: layout.Rect{X: float64(i), W: 3, H: 3}, Color: css.Color{A: 1}} | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | return Frame{Quads: quads} | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func BenchmarkListBatches(b *testing.B) { | |
| 32 | + | frame := listFixture(2000) | |
| 33 | + | b.ReportAllocs() | |
| 34 | + | b.ResetTimer() | |
| 35 | + | for i := 0; i < b.N; i++ { | |
| 36 | + | instances, batches, stats := ListBatches(frame) | |
| 37 | + | if len(instances) != 2000 || len(batches) == 0 || len(stats.Skipped) != 0 { | |
| 38 | + | b.Fatal("list") | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func BenchmarkListBufferReuse(b *testing.B) { | |
| 44 | + | frame := listFixture(2000) | |
| 45 | + | var buf ListBuffer | |
| 46 | + | buf.List(frame) | |
| 47 | + | buf.Release() | |
| 48 | + | b.ReportAllocs() | |
| 49 | + | b.ResetTimer() | |
| 50 | + | for i := 0; i < b.N; i++ { | |
| 51 | + | instances, batches, stats := buf.List(frame) | |
| 52 | + | if len(instances) != 2000 || len(batches) == 0 || len(stats.Skipped) != 0 { | |
| 53 | + | b.Fatal("list") | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | } | |
| @@ -0,0 +1,56 @@ | |||||
| 1 | + | package render | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "image" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/nefergui/internal/css" | |||
| 8 | + | "github.com/bnema/nefergui/internal/layout" | |||
| 9 | + | "github.com/bnema/nefergui/internal/text" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // listFixture is a product-like mix: atlas draws with an image every 50 quads. | |||
| 13 | + | func listFixture(n int) Frame { | |||
| 14 | + | img := image.NewRGBA(image.Rect(0, 0, 2, 2)) | |||
| 15 | + | quads := make([]Quad, n) | |||
| 16 | + | for i := range quads { | |||
| 17 | + | switch { | |||
| 18 | + | case i%50 == 49: | |||
| 19 | + | quads[i] = Quad{Op: "image", Image: img, Rect: layout.Rect{W: 4, H: 4}} | |||
| 20 | + | case i%3 == 0: | |||
| 21 | + | quads[i] = Quad{Op: "glyph", Glyph: text.Placement{Page: 1, Rect: image.Rect(1, 2, 9, 12)}, Color: css.Color{A: 1}} | |||
| 22 | + | case i%7 == 0: | |||
| 23 | + | quads[i] = Quad{Op: "border", Color: css.Color{A: 1}} | |||
| 24 | + | default: | |||
| 25 | + | quads[i] = Quad{Op: "rect", Rect: layout.Rect{X: float64(i), W: 3, H: 3}, Color: css.Color{A: 1}} | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | return Frame{Quads: quads} | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func BenchmarkListBatches(b *testing.B) { | |||
| 32 | + | frame := listFixture(2000) | |||
| 33 | + | b.ReportAllocs() | |||
| 34 | + | b.ResetTimer() | |||
| 35 | + | for i := 0; i < b.N; i++ { | |||
| 36 | + | instances, batches, stats := ListBatches(frame) | |||
| 37 | + | if len(instances) != 2000 || len(batches) == 0 || len(stats.Skipped) != 0 { | |||
| 38 | + | b.Fatal("list") | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func BenchmarkListBufferReuse(b *testing.B) { | |||
| 44 | + | frame := listFixture(2000) | |||
| 45 | + | var buf ListBuffer | |||
| 46 | + | buf.List(frame) | |||
| 47 | + | buf.Release() | |||
| 48 | + | b.ReportAllocs() | |||
| 49 | + | b.ResetTimer() | |||
| 50 | + | for i := 0; i < b.N; i++ { | |||
| 51 | + | instances, batches, stats := buf.List(frame) | |||
| 52 | + | if len(instances) != 2000 || len(batches) == 0 || len(stats.Skipped) != 0 { | |||
| 53 | + | b.Fatal("list") | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | } | |||
M internal/render/instances_test.go +68 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "github.com/bnema/nefergui/internal/layout" | |
| 6 | 6 | "github.com/bnema/nefergui/internal/text" | |
| 7 | 7 | "image" | |
| 8 | + | "reflect" | |
| 8 | 9 | "testing" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| @@ -44,3 +45,70 @@ func TestListInstances(t *testing.T) { | |||
| 44 | 45 | t.Fatalf("glyph=%+v", got[2]) | |
| 45 | 46 | } | |
| 46 | 47 | } | |
| 48 | + | ||
| 49 | + | func TestListBufferMatchesListBatches(t *testing.T) { | |
| 50 | + | a := image.NewRGBA(image.Rect(0, 0, 2, 2)) | |
| 51 | + | frames := []Frame{ | |
| 52 | + | listFixture(200), | |
| 53 | + | {Quads: []Quad{{Op: "rect"}, {Op: "mystery"}, {Op: "image", Image: a}, {Op: "mystery"}, {Op: "glyph"}, {Op: "glyph"}}}, | |
| 54 | + | {}, | |
| 55 | + | listFixture(7), | |
| 56 | + | } | |
| 57 | + | var buf ListBuffer | |
| 58 | + | for round := 0; round < 2; round++ { | |
| 59 | + | for i, f := range frames { | |
| 60 | + | wi, wb, ws := ListBatches(f) | |
| 61 | + | gi, gb, gs := buf.List(f) | |
| 62 | + | if !reflect.DeepEqual(wi, gi) || !reflect.DeepEqual(wb, gb) || !reflect.DeepEqual(ws, gs) { | |
| 63 | + | t.Fatalf("round %d frame %d differs:\nwant %v %v %v\ngot %v %v %v", round, i, len(wi), wb, ws, len(gi), gb, gs) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // A result stays valid until the next List; a later smaller frame must not | |
| 70 | + | // leave stale instances, batches, image references or skip counts visible. | |
| 71 | + | func TestListBufferReuseLifetime(t *testing.T) { | |
| 72 | + | a := image.NewRGBA(image.Rect(0, 0, 2, 2)) | |
| 73 | + | var buf ListBuffer | |
| 74 | + | i1, b1, s1 := buf.List(Frame{Quads: []Quad{{Op: "rect"}, {Op: "image", Image: a}, {Op: "nope"}}}) | |
| 75 | + | if len(i1) != 2 || len(b1) != 2 || s1.Skipped["nope"] != 1 { | |
| 76 | + | t.Fatalf("first: %v %v %v", i1, b1, s1) | |
| 77 | + | } | |
| 78 | + | i2, b2, s2 := buf.List(Frame{Quads: []Quad{{Op: "glyph"}}}) | |
| 79 | + | if len(i2) != 1 || len(b2) != 1 || b2[0].Source != nil || len(s2.Skipped) != 0 || i2[0].KindLayer[0] != 1 { | |
| 80 | + | t.Fatalf("second: %v %v %v", i2, b2, s2) | |
| 81 | + | } | |
| 82 | + | buf.Release() | |
| 83 | + | for _, batch := range b2[:cap(b2)] { | |
| 84 | + | if batch.Source != nil { | |
| 85 | + | t.Fatal("Release left an image reference") | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | // Results from the non-buffer API never alias the buffer. | |
| 89 | + | fi, fb, _ := ListBatches(Frame{Quads: []Quad{{Op: "rect"}}}) | |
| 90 | + | buf.List(listFixture(50)) | |
| 91 | + | if fi[0].KindLayer != [4]float32{} || len(fb) != 1 || fb[0].Count != 1 { | |
| 92 | + | t.Fatalf("ListBatches result aliased: %v %v", fi, fb) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestListBufferDropsOversizedStorage(t *testing.T) { | |
| 97 | + | var buf ListBuffer | |
| 98 | + | buf.List(listFixture(20000)) | |
| 99 | + | if _, _, _ = buf.List(listFixture(10)); cap(buf.instances) > 4*10+64 { | |
| 100 | + | t.Fatalf("retained %d instances for 10 quads", cap(buf.instances)) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func TestListBatchesAllocations(t *testing.T) { | |
| 105 | + | frame := listFixture(2000) | |
| 106 | + | if n := testing.AllocsPerRun(20, func() { ListBatches(frame) }); n > 4 { | |
| 107 | + | t.Fatalf("ListBatches allocs=%v, want <= 4 (instances, batches, skipped map)", n) | |
| 108 | + | } | |
| 109 | + | var buf ListBuffer | |
| 110 | + | buf.List(frame) | |
| 111 | + | if n := testing.AllocsPerRun(20, func() { buf.List(frame) }); n != 0 { | |
| 112 | + | t.Fatalf("ListBuffer.List allocs=%v, want 0 after warmup", n) | |
| 113 | + | } | |
| 114 | + | } | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "github.com/bnema/nefergui/internal/layout" | 5 | "github.com/bnema/nefergui/internal/layout" | ||
| 6 | "github.com/bnema/nefergui/internal/text" | 6 | "github.com/bnema/nefergui/internal/text" | ||
| 7 | "image" | 7 | "image" | ||
| 8 | + | "reflect" | |||
| 8 | "testing" | 9 | "testing" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| @@ -44,3 +45,70 @@ func TestListInstances(t *testing.T) { | |||||
| 44 | t.Fatalf("glyph=%+v", got[2]) | 45 | t.Fatalf("glyph=%+v", got[2]) | ||
| 45 | } | 46 | } | ||
| 46 | } | 47 | } | ||
| 48 | + | ||||
| 49 | + | func TestListBufferMatchesListBatches(t *testing.T) { | |||
| 50 | + | a := image.NewRGBA(image.Rect(0, 0, 2, 2)) | |||
| 51 | + | frames := []Frame{ | |||
| 52 | + | listFixture(200), | |||
| 53 | + | {Quads: []Quad{{Op: "rect"}, {Op: "mystery"}, {Op: "image", Image: a}, {Op: "mystery"}, {Op: "glyph"}, {Op: "glyph"}}}, | |||
| 54 | + | {}, | |||
| 55 | + | listFixture(7), | |||
| 56 | + | } | |||
| 57 | + | var buf ListBuffer | |||
| 58 | + | for round := 0; round < 2; round++ { | |||
| 59 | + | for i, f := range frames { | |||
| 60 | + | wi, wb, ws := ListBatches(f) | |||
| 61 | + | gi, gb, gs := buf.List(f) | |||
| 62 | + | if !reflect.DeepEqual(wi, gi) || !reflect.DeepEqual(wb, gb) || !reflect.DeepEqual(ws, gs) { | |||
| 63 | + | t.Fatalf("round %d frame %d differs:\nwant %v %v %v\ngot %v %v %v", round, i, len(wi), wb, ws, len(gi), gb, gs) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // A result stays valid until the next List; a later smaller frame must not | |||
| 70 | + | // leave stale instances, batches, image references or skip counts visible. | |||
| 71 | + | func TestListBufferReuseLifetime(t *testing.T) { | |||
| 72 | + | a := image.NewRGBA(image.Rect(0, 0, 2, 2)) | |||
| 73 | + | var buf ListBuffer | |||
| 74 | + | i1, b1, s1 := buf.List(Frame{Quads: []Quad{{Op: "rect"}, {Op: "image", Image: a}, {Op: "nope"}}}) | |||
| 75 | + | if len(i1) != 2 || len(b1) != 2 || s1.Skipped["nope"] != 1 { | |||
| 76 | + | t.Fatalf("first: %v %v %v", i1, b1, s1) | |||
| 77 | + | } | |||
| 78 | + | i2, b2, s2 := buf.List(Frame{Quads: []Quad{{Op: "glyph"}}}) | |||
| 79 | + | if len(i2) != 1 || len(b2) != 1 || b2[0].Source != nil || len(s2.Skipped) != 0 || i2[0].KindLayer[0] != 1 { | |||
| 80 | + | t.Fatalf("second: %v %v %v", i2, b2, s2) | |||
| 81 | + | } | |||
| 82 | + | buf.Release() | |||
| 83 | + | for _, batch := range b2[:cap(b2)] { | |||
| 84 | + | if batch.Source != nil { | |||
| 85 | + | t.Fatal("Release left an image reference") | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | // Results from the non-buffer API never alias the buffer. | |||
| 89 | + | fi, fb, _ := ListBatches(Frame{Quads: []Quad{{Op: "rect"}}}) | |||
| 90 | + | buf.List(listFixture(50)) | |||
| 91 | + | if fi[0].KindLayer != [4]float32{} || len(fb) != 1 || fb[0].Count != 1 { | |||
| 92 | + | t.Fatalf("ListBatches result aliased: %v %v", fi, fb) | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func TestListBufferDropsOversizedStorage(t *testing.T) { | |||
| 97 | + | var buf ListBuffer | |||
| 98 | + | buf.List(listFixture(20000)) | |||
| 99 | + | if _, _, _ = buf.List(listFixture(10)); cap(buf.instances) > 4*10+64 { | |||
| 100 | + | t.Fatalf("retained %d instances for 10 quads", cap(buf.instances)) | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func TestListBatchesAllocations(t *testing.T) { | |||
| 105 | + | frame := listFixture(2000) | |||
| 106 | + | if n := testing.AllocsPerRun(20, func() { ListBatches(frame) }); n > 4 { | |||
| 107 | + | t.Fatalf("ListBatches allocs=%v, want <= 4 (instances, batches, skipped map)", n) | |||
| 108 | + | } | |||
| 109 | + | var buf ListBuffer | |||
| 110 | + | buf.List(frame) | |||
| 111 | + | if n := testing.AllocsPerRun(20, func() { buf.List(frame) }); n != 0 { | |||
| 112 | + | t.Fatalf("ListBuffer.List allocs=%v, want 0 after warmup", n) | |||
| 113 | + | } | |||
| 114 | + | } | |||
M internal/render/prepare.go +28 -0
| @@ -74,6 +74,28 @@ func scaled(e layout.Edges, scale float64) layout.Edges { | |||
| 74 | 74 | return layout.Edges{Top: e.Top * scale, Right: e.Right * scale, Bottom: e.Bottom * scale, Left: e.Left * scale} | |
| 75 | 75 | } | |
| 76 | 76 | ||
| 77 | + | // maxQuadPrealloc bounds speculative storage for mostly clipped documents. | |
| 78 | + | // Visible frames larger than this grow normally as their quads are emitted. | |
| 79 | + | const maxQuadPrealloc = 512 | |
| 80 | + | ||
| 81 | + | // quadCapacity estimates one quad per glyph and drawable command, capped so | |
| 82 | + | // offscreen content cannot force a large allocation before culling. | |
| 83 | + | func quadCapacity(commands []layout.Command) int { | |
| 84 | + | n := 0 | |
| 85 | + | for i := range commands { | |
| 86 | + | switch cmd := &commands[i]; cmd.Op { | |
| 87 | + | case "clip-push", "clip-pop": | |
| 88 | + | case "text", "preedit-text": | |
| 89 | + | for j := range cmd.Runs { | |
| 90 | + | n += len(cmd.Runs[j].Glyphs) | |
| 91 | + | } | |
| 92 | + | default: | |
| 93 | + | n++ | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | return min(n, maxQuadPrealloc) | |
| 97 | + | } | |
| 98 | + | ||
| 77 | 99 | // Prepare converts logical rectangles once, using layout.Physical as the sole | |
| 78 | 100 | // logical-to-physical edge-rounding boundary. A clip-push intersects its parent | |
| 79 | 101 | // clip; rounded clips intentionally use only their axis-aligned bounds in V1. | |
| @@ -86,6 +108,12 @@ func (p *Preparer) Prepare(commands []layout.Command, scale float64, width, heig | |||
| 86 | 108 | } | |
| 87 | 109 | p.Atlas.BeginFrame() | |
| 88 | 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) | |
| 116 | + | } | |
| 89 | 117 | clip := layout.Rect{W: float64(width), H: float64(height)} | |
| 90 | 118 | stack := []layout.Rect{} | |
| 91 | 119 | for _, cmd := range commands { | |
| @@ -74,6 +74,28 @@ func scaled(e layout.Edges, scale float64) layout.Edges { | |||||
| 74 | return layout.Edges{Top: e.Top * scale, Right: e.Right * scale, Bottom: e.Bottom * scale, Left: e.Left * scale} | 74 | return layout.Edges{Top: e.Top * scale, Right: e.Right * scale, Bottom: e.Bottom * scale, Left: e.Left * scale} | ||
| 75 | } | 75 | } | ||
| 76 | 76 | ||||
| 77 | + | // maxQuadPrealloc bounds speculative storage for mostly clipped documents. | |||
| 78 | + | // Visible frames larger than this grow normally as their quads are emitted. | |||
| 79 | + | const maxQuadPrealloc = 512 | |||
| 80 | + | ||||
| 81 | + | // quadCapacity estimates one quad per glyph and drawable command, capped so | |||
| 82 | + | // offscreen content cannot force a large allocation before culling. | |||
| 83 | + | func quadCapacity(commands []layout.Command) int { | |||
| 84 | + | n := 0 | |||
| 85 | + | for i := range commands { | |||
| 86 | + | switch cmd := &commands[i]; cmd.Op { | |||
| 87 | + | case "clip-push", "clip-pop": | |||
| 88 | + | case "text", "preedit-text": | |||
| 89 | + | for j := range cmd.Runs { | |||
| 90 | + | n += len(cmd.Runs[j].Glyphs) | |||
| 91 | + | } | |||
| 92 | + | default: | |||
| 93 | + | n++ | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | return min(n, maxQuadPrealloc) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 77 | // Prepare converts logical rectangles once, using layout.Physical as the sole | 99 | // Prepare converts logical rectangles once, using layout.Physical as the sole | ||
| 78 | // logical-to-physical edge-rounding boundary. A clip-push intersects its parent | 100 | // logical-to-physical edge-rounding boundary. A clip-push intersects its parent | ||
| 79 | // clip; rounded clips intentionally use only their axis-aligned bounds in V1. | 101 | // clip; rounded clips intentionally use only their axis-aligned bounds in V1. | ||
| @@ -86,6 +108,12 @@ func (p *Preparer) Prepare(commands []layout.Command, scale float64, width, heig | |||||
| 86 | } | 108 | } | ||
| 87 | p.Atlas.BeginFrame() | 109 | p.Atlas.BeginFrame() | ||
| 88 | frame := Frame{} | 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) | |||
| 116 | + | } | |||
| 89 | clip := layout.Rect{W: float64(width), H: float64(height)} | 117 | clip := layout.Rect{W: float64(width), H: float64(height)} | ||
| 90 | stack := []layout.Rect{} | 118 | stack := []layout.Rect{} | ||
| 91 | for _, cmd := range commands { | 119 | for _, cmd := range commands { | ||
A internal/render/prepare_alloc_test.go +140 -0
| @@ -0,0 +1,140 @@ | |||
| 1 | + | package render | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/nefergui/internal/css" | |
| 7 | + | "github.com/bnema/nefergui/internal/layout" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func rectCommands(n int) []layout.Command { | |
| 11 | + | cmds := make([]layout.Command, n) | |
| 12 | + | for i := range cmds { | |
| 13 | + | cmds[i] = layout.Command{Op: "rect", Rect: layout.Rect{X: 1, Y: float64(i), W: 10, H: 1}, Color: css.Color{A: 1}, Opacity: 1} | |
| 14 | + | } | |
| 15 | + | return cmds | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func TestQuadCapacityIsSufficientAndExactWithoutCulling(t *testing.T) { | |
| 19 | + | glyphs := glyphCommand(t, "office") | |
| 20 | + | n := 0 | |
| 21 | + | for _, r := range glyphs.Runs { | |
| 22 | + | n += len(r.Glyphs) | |
| 23 | + | } | |
| 24 | + | cmds := []layout.Command{{Op: "clip-push", Rect: layout.Rect{W: 200, H: 100}}, rectCommands(1)[0], glyphs, {Op: "clip-pop"}} | |
| 25 | + | if got := quadCapacity(cmds); got != n+1 { | |
| 26 | + | t.Fatalf("capacity %d, want %d", got, n+1) | |
| 27 | + | } | |
| 28 | + | p, err := NewPreparer(256, 2) | |
| 29 | + | if err != nil { | |
| 30 | + | t.Fatal(err) | |
| 31 | + | } | |
| 32 | + | f, err := p.Prepare(cmds, 1, 200, 100) | |
| 33 | + | if err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | if len(f.Quads) == 0 || len(f.Quads) > n+1 || cap(f.Quads) != n+1 { | |
| 37 | + | t.Fatalf("len=%d cap=%d, want cap %d", len(f.Quads), cap(f.Quads), n+1) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // Frames are detached: a later Prepare must not alias or overwrite an earlier | |
| 42 | + | // frame that is still queued, blocked or superseded. | |
| 43 | + | func TestSuccessiveFramesAreDetached(t *testing.T) { | |
| 44 | + | p, err := NewPreparer(256, 2) | |
| 45 | + | if err != nil { | |
| 46 | + | t.Fatal(err) | |
| 47 | + | } | |
| 48 | + | a, err := p.Prepare(rectCommands(4), 1, 100, 100) | |
| 49 | + | if err != nil { | |
| 50 | + | t.Fatal(err) | |
| 51 | + | } | |
| 52 | + | want := append([]Quad(nil), a.Quads...) | |
| 53 | + | b, err := p.Prepare(rectCommands(3), 2, 100, 100) | |
| 54 | + | if err != nil { | |
| 55 | + | t.Fatal(err) | |
| 56 | + | } | |
| 57 | + | if len(a.Quads) != 4 || len(b.Quads) != 3 || &a.Quads[0] == &b.Quads[0] { | |
| 58 | + | t.Fatalf("frames alias: %d %d", len(a.Quads), len(b.Quads)) | |
| 59 | + | } | |
| 60 | + | for i := range want { | |
| 61 | + | if a.Quads[i] != want[i] { | |
| 62 | + | t.Fatalf("earlier frame quad %d overwritten", i) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | // Appending to a returned frame must not write into another frame. | |
| 66 | + | b.Quads = append(b.Quads[:len(b.Quads):len(b.Quads)], Quad{Op: "x"}) | |
| 67 | + | if a.Quads[0] != want[0] { | |
| 68 | + | t.Fatal("append leaked into earlier frame") | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestFailedPrepareReturnsNoPartialFrame(t *testing.T) { | |
| 73 | + | p, err := NewPreparer(256, 2) | |
| 74 | + | if err != nil { | |
| 75 | + | t.Fatal(err) | |
| 76 | + | } | |
| 77 | + | cmds := append(rectCommands(8), layout.Command{Op: "bogus"}) | |
| 78 | + | f, err := p.Prepare(cmds, 1, 100, 100) | |
| 79 | + | if err == nil || f.Quads != nil || f.Uploads != nil { | |
| 80 | + | t.Fatalf("partial frame on failure: %v %+v", err, f) | |
| 81 | + | } | |
| 82 | + | if f, err = p.Prepare(nil, 1, 100, 100); err != nil || f.Quads != nil { | |
| 83 | + | t.Fatalf("empty frame: %v %+v", err, f) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // Preallocation must not disturb owed-upload accounting across frames whose | |
| 88 | + | // quads were sized differently. | |
| 89 | + | func TestPreallocatedFramesKeepUploadDebt(t *testing.T) { | |
| 90 | + | cmd := glyphCommand(t, "office") | |
| 91 | + | p, err := NewPreparer(256, 2) | |
| 92 | + | if err != nil { | |
| 93 | + | t.Fatal(err) | |
| 94 | + | } | |
| 95 | + | first, err := p.Prepare([]layout.Command{cmd}, 1, 200, 100) | |
| 96 | + | if err != nil || len(first.Uploads) == 0 { | |
| 97 | + | t.Fatalf("first: %v %d", err, len(first.Uploads)) | |
| 98 | + | } | |
| 99 | + | // Superseding frame with no glyphs still resends the owed pages. | |
| 100 | + | second, err := p.Prepare(rectCommands(2), 1, 200, 100) | |
| 101 | + | if err != nil || len(second.Uploads) == 0 { | |
| 102 | + | t.Fatalf("debt lost: %v %d", err, len(second.Uploads)) | |
| 103 | + | } | |
| 104 | + | p.Submitted(first.Uploads) | |
| 105 | + | if f, err := p.Prepare(rectCommands(2), 1, 200, 100); err != nil || len(f.Uploads) == 0 { | |
| 106 | + | t.Fatalf("stale ack cleared debt: %v %d", err, len(f.Uploads)) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // Box-only frames must cost one Quads allocation regardless of command count. | |
| 111 | + | func TestPrepareQuadAllocationsDoNotScaleWithCommands(t *testing.T) { | |
| 112 | + | p, err := NewPreparer(256, 2) | |
| 113 | + | if err != nil { | |
| 114 | + | t.Fatal(err) | |
| 115 | + | } | |
| 116 | + | cmds := rectCommands(512) | |
| 117 | + | allocs := testing.AllocsPerRun(20, func() { | |
| 118 | + | if _, err := p.Prepare(cmds, 1, 100, 1000); err != nil { | |
| 119 | + | t.Fatal(err) | |
| 120 | + | } | |
| 121 | + | }) | |
| 122 | + | if allocs > 2 { | |
| 123 | + | t.Fatalf("%v allocs for 512 rects, want a small constant", allocs) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func BenchmarkPrepareBoxes(b *testing.B) { | |
| 128 | + | p, err := NewPreparer(256, 2) | |
| 129 | + | if err != nil { | |
| 130 | + | b.Fatal(err) | |
| 131 | + | } | |
| 132 | + | cmds := rectCommands(512) | |
| 133 | + | b.ReportAllocs() | |
| 134 | + | b.ResetTimer() | |
| 135 | + | for i := 0; i < b.N; i++ { | |
| 136 | + | if f, err := p.Prepare(cmds, 1, 100, 1000); err != nil || len(f.Quads) != 512 { | |
| 137 | + | b.Fatal(err) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | } | |
| @@ -0,0 +1,140 @@ | |||||
| 1 | + | package render | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/nefergui/internal/css" | |||
| 7 | + | "github.com/bnema/nefergui/internal/layout" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func rectCommands(n int) []layout.Command { | |||
| 11 | + | cmds := make([]layout.Command, n) | |||
| 12 | + | for i := range cmds { | |||
| 13 | + | cmds[i] = layout.Command{Op: "rect", Rect: layout.Rect{X: 1, Y: float64(i), W: 10, H: 1}, Color: css.Color{A: 1}, Opacity: 1} | |||
| 14 | + | } | |||
| 15 | + | return cmds | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func TestQuadCapacityIsSufficientAndExactWithoutCulling(t *testing.T) { | |||
| 19 | + | glyphs := glyphCommand(t, "office") | |||
| 20 | + | n := 0 | |||
| 21 | + | for _, r := range glyphs.Runs { | |||
| 22 | + | n += len(r.Glyphs) | |||
| 23 | + | } | |||
| 24 | + | cmds := []layout.Command{{Op: "clip-push", Rect: layout.Rect{W: 200, H: 100}}, rectCommands(1)[0], glyphs, {Op: "clip-pop"}} | |||
| 25 | + | if got := quadCapacity(cmds); got != n+1 { | |||
| 26 | + | t.Fatalf("capacity %d, want %d", got, n+1) | |||
| 27 | + | } | |||
| 28 | + | p, err := NewPreparer(256, 2) | |||
| 29 | + | if err != nil { | |||
| 30 | + | t.Fatal(err) | |||
| 31 | + | } | |||
| 32 | + | f, err := p.Prepare(cmds, 1, 200, 100) | |||
| 33 | + | if err != nil { | |||
| 34 | + | t.Fatal(err) | |||
| 35 | + | } | |||
| 36 | + | if len(f.Quads) == 0 || len(f.Quads) > n+1 || cap(f.Quads) != n+1 { | |||
| 37 | + | t.Fatalf("len=%d cap=%d, want cap %d", len(f.Quads), cap(f.Quads), n+1) | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // Frames are detached: a later Prepare must not alias or overwrite an earlier | |||
| 42 | + | // frame that is still queued, blocked or superseded. | |||
| 43 | + | func TestSuccessiveFramesAreDetached(t *testing.T) { | |||
| 44 | + | p, err := NewPreparer(256, 2) | |||
| 45 | + | if err != nil { | |||
| 46 | + | t.Fatal(err) | |||
| 47 | + | } | |||
| 48 | + | a, err := p.Prepare(rectCommands(4), 1, 100, 100) | |||
| 49 | + | if err != nil { | |||
| 50 | + | t.Fatal(err) | |||
| 51 | + | } | |||
| 52 | + | want := append([]Quad(nil), a.Quads...) | |||
| 53 | + | b, err := p.Prepare(rectCommands(3), 2, 100, 100) | |||
| 54 | + | if err != nil { | |||
| 55 | + | t.Fatal(err) | |||
| 56 | + | } | |||
| 57 | + | if len(a.Quads) != 4 || len(b.Quads) != 3 || &a.Quads[0] == &b.Quads[0] { | |||
| 58 | + | t.Fatalf("frames alias: %d %d", len(a.Quads), len(b.Quads)) | |||
| 59 | + | } | |||
| 60 | + | for i := range want { | |||
| 61 | + | if a.Quads[i] != want[i] { | |||
| 62 | + | t.Fatalf("earlier frame quad %d overwritten", i) | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | // Appending to a returned frame must not write into another frame. | |||
| 66 | + | b.Quads = append(b.Quads[:len(b.Quads):len(b.Quads)], Quad{Op: "x"}) | |||
| 67 | + | if a.Quads[0] != want[0] { | |||
| 68 | + | t.Fatal("append leaked into earlier frame") | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestFailedPrepareReturnsNoPartialFrame(t *testing.T) { | |||
| 73 | + | p, err := NewPreparer(256, 2) | |||
| 74 | + | if err != nil { | |||
| 75 | + | t.Fatal(err) | |||
| 76 | + | } | |||
| 77 | + | cmds := append(rectCommands(8), layout.Command{Op: "bogus"}) | |||
| 78 | + | f, err := p.Prepare(cmds, 1, 100, 100) | |||
| 79 | + | if err == nil || f.Quads != nil || f.Uploads != nil { | |||
| 80 | + | t.Fatalf("partial frame on failure: %v %+v", err, f) | |||
| 81 | + | } | |||
| 82 | + | if f, err = p.Prepare(nil, 1, 100, 100); err != nil || f.Quads != nil { | |||
| 83 | + | t.Fatalf("empty frame: %v %+v", err, f) | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // Preallocation must not disturb owed-upload accounting across frames whose | |||
| 88 | + | // quads were sized differently. | |||
| 89 | + | func TestPreallocatedFramesKeepUploadDebt(t *testing.T) { | |||
| 90 | + | cmd := glyphCommand(t, "office") | |||
| 91 | + | p, err := NewPreparer(256, 2) | |||
| 92 | + | if err != nil { | |||
| 93 | + | t.Fatal(err) | |||
| 94 | + | } | |||
| 95 | + | first, err := p.Prepare([]layout.Command{cmd}, 1, 200, 100) | |||
| 96 | + | if err != nil || len(first.Uploads) == 0 { | |||
| 97 | + | t.Fatalf("first: %v %d", err, len(first.Uploads)) | |||
| 98 | + | } | |||
| 99 | + | // Superseding frame with no glyphs still resends the owed pages. | |||
| 100 | + | second, err := p.Prepare(rectCommands(2), 1, 200, 100) | |||
| 101 | + | if err != nil || len(second.Uploads) == 0 { | |||
| 102 | + | t.Fatalf("debt lost: %v %d", err, len(second.Uploads)) | |||
| 103 | + | } | |||
| 104 | + | p.Submitted(first.Uploads) | |||
| 105 | + | if f, err := p.Prepare(rectCommands(2), 1, 200, 100); err != nil || len(f.Uploads) == 0 { | |||
| 106 | + | t.Fatalf("stale ack cleared debt: %v %d", err, len(f.Uploads)) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // Box-only frames must cost one Quads allocation regardless of command count. | |||
| 111 | + | func TestPrepareQuadAllocationsDoNotScaleWithCommands(t *testing.T) { | |||
| 112 | + | p, err := NewPreparer(256, 2) | |||
| 113 | + | if err != nil { | |||
| 114 | + | t.Fatal(err) | |||
| 115 | + | } | |||
| 116 | + | cmds := rectCommands(512) | |||
| 117 | + | allocs := testing.AllocsPerRun(20, func() { | |||
| 118 | + | if _, err := p.Prepare(cmds, 1, 100, 1000); err != nil { | |||
| 119 | + | t.Fatal(err) | |||
| 120 | + | } | |||
| 121 | + | }) | |||
| 122 | + | if allocs > 2 { | |||
| 123 | + | t.Fatalf("%v allocs for 512 rects, want a small constant", allocs) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func BenchmarkPrepareBoxes(b *testing.B) { | |||
| 128 | + | p, err := NewPreparer(256, 2) | |||
| 129 | + | if err != nil { | |||
| 130 | + | b.Fatal(err) | |||
| 131 | + | } | |||
| 132 | + | cmds := rectCommands(512) | |||
| 133 | + | b.ReportAllocs() | |||
| 134 | + | b.ResetTimer() | |||
| 135 | + | for i := 0; i < b.N; i++ { | |||
| 136 | + | if f, err := p.Prepare(cmds, 1, 100, 1000); err != nil || len(f.Quads) != 512 { | |||
| 137 | + | b.Fatal(err) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | } | |||
A internal/render/prepare_culling_test.go +47 -0
| @@ -0,0 +1,47 @@ | |||
| 1 | + | package render | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/nefergui/internal/layout" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestPrepareBoundsReservationForClippedGlyphs(t *testing.T) { | |
| 10 | + | for _, op := range []string{"text", "preedit-text"} { | |
| 11 | + | t.Run(op, func(t *testing.T) { | |
| 12 | + | cmd := glyphCommand(t, "a") | |
| 13 | + | cmd.Op = op | |
| 14 | + | run := cmd.Runs[0] | |
| 15 | + | glyph := run.Glyphs[0] | |
| 16 | + | run.Glyphs = make([]layout.Glyph, 50000) | |
| 17 | + | for i := range run.Glyphs { | |
| 18 | + | run.Glyphs[i] = glyph | |
| 19 | + | run.Glyphs[i].Y = 10000 | |
| 20 | + | } | |
| 21 | + | cmd.Runs = []layout.Run{run} | |
| 22 | + | p, err := NewPreparer(256, 2) | |
| 23 | + | if err != nil { | |
| 24 | + | t.Fatal(err) | |
| 25 | + | } | |
| 26 | + | f, err := p.Prepare([]layout.Command{cmd}, 1, 100, 100) | |
| 27 | + | if err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | if len(f.Quads) != 0 || cap(f.Quads) > maxQuadPrealloc { | |
| 31 | + | t.Fatalf("clipped glyphs: len=%d cap=%d", len(f.Quads), cap(f.Quads)) | |
| 32 | + | } | |
| 33 | + | }) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestPrepareGrowsBeyondReservationForVisibleQuads(t *testing.T) { | |
| 38 | + | p, err := NewPreparer(256, 2) | |
| 39 | + | if err != nil { | |
| 40 | + | t.Fatal(err) | |
| 41 | + | } | |
| 42 | + | cmds := rectCommands(maxQuadPrealloc + 100) | |
| 43 | + | f, err := p.Prepare(cmds, 1, 100, len(cmds)+1) | |
| 44 | + | if err != nil || len(f.Quads) != len(cmds) { | |
| 45 | + | t.Fatalf("visible quads: got %d want %d, error %v", len(f.Quads), len(cmds), err) | |
| 46 | + | } | |
| 47 | + | } | |
| @@ -0,0 +1,47 @@ | |||||
| 1 | + | package render | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/nefergui/internal/layout" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestPrepareBoundsReservationForClippedGlyphs(t *testing.T) { | |||
| 10 | + | for _, op := range []string{"text", "preedit-text"} { | |||
| 11 | + | t.Run(op, func(t *testing.T) { | |||
| 12 | + | cmd := glyphCommand(t, "a") | |||
| 13 | + | cmd.Op = op | |||
| 14 | + | run := cmd.Runs[0] | |||
| 15 | + | glyph := run.Glyphs[0] | |||
| 16 | + | run.Glyphs = make([]layout.Glyph, 50000) | |||
| 17 | + | for i := range run.Glyphs { | |||
| 18 | + | run.Glyphs[i] = glyph | |||
| 19 | + | run.Glyphs[i].Y = 10000 | |||
| 20 | + | } | |||
| 21 | + | cmd.Runs = []layout.Run{run} | |||
| 22 | + | p, err := NewPreparer(256, 2) | |||
| 23 | + | if err != nil { | |||
| 24 | + | t.Fatal(err) | |||
| 25 | + | } | |||
| 26 | + | f, err := p.Prepare([]layout.Command{cmd}, 1, 100, 100) | |||
| 27 | + | if err != nil { | |||
| 28 | + | t.Fatal(err) | |||
| 29 | + | } | |||
| 30 | + | if len(f.Quads) != 0 || cap(f.Quads) > maxQuadPrealloc { | |||
| 31 | + | t.Fatalf("clipped glyphs: len=%d cap=%d", len(f.Quads), cap(f.Quads)) | |||
| 32 | + | } | |||
| 33 | + | }) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestPrepareGrowsBeyondReservationForVisibleQuads(t *testing.T) { | |||
| 38 | + | p, err := NewPreparer(256, 2) | |||
| 39 | + | if err != nil { | |||
| 40 | + | t.Fatal(err) | |||
| 41 | + | } | |||
| 42 | + | cmds := rectCommands(maxQuadPrealloc + 100) | |||
| 43 | + | f, err := p.Prepare(cmds, 1, 100, len(cmds)+1) | |||
| 44 | + | if err != nil || len(f.Quads) != len(cmds) { | |||
| 45 | + | t.Fatalf("visible quads: got %d want %d, error %v", len(f.Quads), len(cmds), err) | |||
| 46 | + | } | |||
| 47 | + | } | |||
A internal/ui/empty_caret_test.go +69 -0
| @@ -0,0 +1,69 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/nefergui/internal/css" | |
| 8 | + | "github.com/bnema/nefergui/internal/text" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestEmptyLabelsDoNotReserveEditorLine(t *testing.T) { | |
| 12 | + | catalog, err := text.Load(text.DirectorySource("../../testdata/fonts")) | |
| 13 | + | if err != nil { | |
| 14 | + | t.Fatal(err) | |
| 15 | + | } | |
| 16 | + | r := newRuntime() | |
| 17 | + | r.setTextEngine(text.NewEngine(catalog)) | |
| 18 | + | r.Build(func(f *Frame) { | |
| 19 | + | root := f.Root() | |
| 20 | + | root.Text("") | |
| 21 | + | root.Heading("") | |
| 22 | + | }) | |
| 23 | + | if len(r.output.Tree.Children) != 2 { | |
| 24 | + | t.Fatalf("empty labels: got %d children, want 2", len(r.output.Tree.Children)) | |
| 25 | + | } | |
| 26 | + | for _, child := range r.output.Tree.Children { | |
| 27 | + | if child.Content.H != 0 || len(child.Lines) != 0 { | |
| 28 | + | t.Fatalf("empty label reserves line: %+v", child) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestEmptyTextareaCaretUsesLineHeight(t *testing.T) { | |
| 34 | + | catalog, err := text.Load(text.DirectorySource("../../testdata/fonts")) | |
| 35 | + | if err != nil { | |
| 36 | + | t.Fatal(err) | |
| 37 | + | } | |
| 38 | + | for _, height := range []string{"normal", "1.5", "30px", "1.5em", "150%"} { | |
| 39 | + | t.Run(height, func(t *testing.T) { | |
| 40 | + | r := newRuntime() | |
| 41 | + | r.setTextEngine(text.NewEngine(catalog)) | |
| 42 | + | r.styles = css.Compile(css.UA(), css.Parse(fmt.Sprintf(`textarea {height:400px;width:300px;font-size:20px;line-height:%s}`, height))) | |
| 43 | + | value := "" | |
| 44 | + | view := func(f *Frame) { f.Root().Textarea("Document", &value) } | |
| 45 | + | r.Build(view) | |
| 46 | + | r.route(key("Tab")) | |
| 47 | + | r.Build(view) | |
| 48 | + | caretHeight := func() float64 { | |
| 49 | + | for _, cmd := range r.output.Display { | |
| 50 | + | if cmd.Op == "caret" { | |
| 51 | + | return cmd.Rect.H | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | t.Fatal("missing caret") | |
| 55 | + | return 0 | |
| 56 | + | } | |
| 57 | + | empty := caretHeight() | |
| 58 | + | if empty <= 0 || empty >= 100 { | |
| 59 | + | t.Fatalf("empty caret height=%g", empty) | |
| 60 | + | } | |
| 61 | + | value = "x" | |
| 62 | + | r.Redraw() | |
| 63 | + | r.Build(view) | |
| 64 | + | if got := caretHeight(); got != empty { | |
| 65 | + | t.Fatalf("empty=%g populated=%g", empty, got) | |
| 66 | + | } | |
| 67 | + | }) | |
| 68 | + | } | |
| 69 | + | } | |
| @@ -0,0 +1,69 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/nefergui/internal/css" | |||
| 8 | + | "github.com/bnema/nefergui/internal/text" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestEmptyLabelsDoNotReserveEditorLine(t *testing.T) { | |||
| 12 | + | catalog, err := text.Load(text.DirectorySource("../../testdata/fonts")) | |||
| 13 | + | if err != nil { | |||
| 14 | + | t.Fatal(err) | |||
| 15 | + | } | |||
| 16 | + | r := newRuntime() | |||
| 17 | + | r.setTextEngine(text.NewEngine(catalog)) | |||
| 18 | + | r.Build(func(f *Frame) { | |||
| 19 | + | root := f.Root() | |||
| 20 | + | root.Text("") | |||
| 21 | + | root.Heading("") | |||
| 22 | + | }) | |||
| 23 | + | if len(r.output.Tree.Children) != 2 { | |||
| 24 | + | t.Fatalf("empty labels: got %d children, want 2", len(r.output.Tree.Children)) | |||
| 25 | + | } | |||
| 26 | + | for _, child := range r.output.Tree.Children { | |||
| 27 | + | if child.Content.H != 0 || len(child.Lines) != 0 { | |||
| 28 | + | t.Fatalf("empty label reserves line: %+v", child) | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestEmptyTextareaCaretUsesLineHeight(t *testing.T) { | |||
| 34 | + | catalog, err := text.Load(text.DirectorySource("../../testdata/fonts")) | |||
| 35 | + | if err != nil { | |||
| 36 | + | t.Fatal(err) | |||
| 37 | + | } | |||
| 38 | + | for _, height := range []string{"normal", "1.5", "30px", "1.5em", "150%"} { | |||
| 39 | + | t.Run(height, func(t *testing.T) { | |||
| 40 | + | r := newRuntime() | |||
| 41 | + | r.setTextEngine(text.NewEngine(catalog)) | |||
| 42 | + | r.styles = css.Compile(css.UA(), css.Parse(fmt.Sprintf(`textarea {height:400px;width:300px;font-size:20px;line-height:%s}`, height))) | |||
| 43 | + | value := "" | |||
| 44 | + | view := func(f *Frame) { f.Root().Textarea("Document", &value) } | |||
| 45 | + | r.Build(view) | |||
| 46 | + | r.route(key("Tab")) | |||
| 47 | + | r.Build(view) | |||
| 48 | + | caretHeight := func() float64 { | |||
| 49 | + | for _, cmd := range r.output.Display { | |||
| 50 | + | if cmd.Op == "caret" { | |||
| 51 | + | return cmd.Rect.H | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | t.Fatal("missing caret") | |||
| 55 | + | return 0 | |||
| 56 | + | } | |||
| 57 | + | empty := caretHeight() | |||
| 58 | + | if empty <= 0 || empty >= 100 { | |||
| 59 | + | t.Fatalf("empty caret height=%g", empty) | |||
| 60 | + | } | |||
| 61 | + | value = "x" | |||
| 62 | + | r.Redraw() | |||
| 63 | + | r.Build(view) | |||
| 64 | + | if got := caretHeight(); got != empty { | |||
| 65 | + | t.Fatalf("empty=%g populated=%g", empty, got) | |||
| 66 | + | } | |||
| 67 | + | }) | |||
| 68 | + | } | |||
| 69 | + | } | |||
M internal/ui/performance_test.go +53 -16
| @@ -1,29 +1,61 @@ | |||
| 1 | 1 | package ui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "os" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 7 | + | "github.com/bnema/nefergui/internal/css" | |
| 8 | + | ||
| 6 | 9 | "github.com/bnema/nefergui/internal/text" | |
| 7 | 10 | ) | |
| 8 | 11 | ||
| 9 | 12 | // demoFrame mirrors the product example's home view without opening a display. | |
| 10 | 13 | func demoFrame(f *Frame) { | |
| 11 | - | root := f.Root(Class("app")) | |
| 14 | + | root := f.Root(Class("app"), Class("light"), Class("comfortable")) | |
| 12 | 15 | header := root.Header(Class("header")) | |
| 13 | - | header.Heading("Demo", Level(1), Class("logo")) | |
| 14 | - | menu := header.Nav(Class("menu")) | |
| 15 | - | menu.Button("Accueil", Key("home"), Class("menu-item")) | |
| 16 | - | menu.Button("Paramètres", Key("settings"), Class("menu-item")) | |
| 17 | - | dark := false | |
| 18 | - | header.Checkbox("Thème sombre", &dark, Key("dark-mode")) | |
| 19 | - | content := root.Main(Class("content")) | |
| 20 | - | section := content.Section(Class("welcome")) | |
| 21 | - | section.Heading("Bienvenue", Level(2)) | |
| 22 | - | section.Text("Une interface native écrite entièrement en Go.") | |
| 23 | - | name := "Ada" | |
| 24 | - | section.Input("Votre nom", &name, Key("name"), Placeholder("Ada")) | |
| 25 | - | section.Row(Class("actions")).Button("Continuer", Key("continue"), Class("primary")) | |
| 26 | - | root.Footer(Class("status-bar")).Text("Bonjour Ada") | |
| 16 | + | header.Heading("NeferGUI", Level(1), Class("logo")) | |
| 17 | + | header.Button("New", Key("new-document")) | |
| 18 | + | header.Text("Untitled.txt", Class("muted")) | |
| 19 | + | header.Spacer(Class("stretch")) | |
| 20 | + | header.Button("Dark mode", Key("dark-mode"), Class("theme-toggle")) | |
| 21 | + | body := root.Row(Class("body")) | |
| 22 | + | sidebar := body.Aside(Class("sidebar")) | |
| 23 | + | sidebar.Text("DOCUMENTS", Class("eyebrow")) | |
| 24 | + | menu := sidebar.Nav(Class("menu")) | |
| 25 | + | menu.Button("Untitled.txt", Key("home"), Class("menu-item"), Class("active")) | |
| 26 | + | menu.Button("Preferences", Key("settings"), Class("menu-item")) | |
| 27 | + | sidebar.Spacer(Class("stretch")) | |
| 28 | + | sidebar.Text("Local workspace", Class("sidebar-note")) | |
| 29 | + | sidebar.Text("1 document · in memory", Class("hint")) | |
| 30 | + | content := body.Main(Class("content")) | |
| 31 | + | workspace := content.Row(Class("workspace")) | |
| 32 | + | editor := workspace.Column(Class("editor-pane")) | |
| 33 | + | tab := editor.Row(Class("pane-bar")) | |
| 34 | + | tab.Text("Untitled.txt", Class("document-title")) | |
| 35 | + | tab.Spacer(Class("stretch")) | |
| 36 | + | tab.Text("Plain text", Class("hint")) | |
| 37 | + | notes := "Project notes\n\nA native text workspace built with NeferGUI.\n\nSelect text, edit a paragraph, or paste from your clipboard.\nUse the properties pane to try radio buttons and the slider.\n\nEverything stays in memory for this session." | |
| 38 | + | editor.Textarea("Document", ¬es, Key("document"), Class("document-editor"), Placeholder("Start writing…")) | |
| 39 | + | inspector := workspace.Aside(Class("inspector")) | |
| 40 | + | inspector.Text("PROPERTIES", Class("eyebrow")) | |
| 41 | + | inspector.Text("Author", Class("field-label")) | |
| 42 | + | name := "" | |
| 43 | + | inspector.Input("Author", &name, Key("name"), Placeholder("Your name")) | |
| 44 | + | inspector.Button("Apply", Key("continue"), Disabled(true)) | |
| 45 | + | inspector.Separator() | |
| 46 | + | inspector.Text("Interface density", Class("field-label")) | |
| 47 | + | density := "comfortable" | |
| 48 | + | inspector.Radio("Comfortable", "comfortable", &density, Key("comfortable")) | |
| 49 | + | inspector.Radio("Compact", "compact", &density, Key("compact")) | |
| 50 | + | inspector.Separator() | |
| 51 | + | volume := 40.0 | |
| 52 | + | inspector.Slider("Volume 40 %", &volume, 0, 100, 5, Key("volume")) | |
| 53 | + | inspector.Spacer(Class("stretch")) | |
| 54 | + | inspector.Text("Changes stay in this session.", Class("hint")) | |
| 55 | + | footer := root.Footer(Class("status-bar")) | |
| 56 | + | footer.Text("Ready") | |
| 57 | + | footer.Spacer(Class("stretch")) | |
| 58 | + | footer.Text("UTF-8 • Plain text", Class("hint")) | |
| 27 | 59 | } | |
| 28 | 60 | ||
| 29 | 61 | func demoRuntime(tb testing.TB) *runtime { | |
| @@ -33,6 +65,11 @@ func demoRuntime(tb testing.TB) *runtime { | |||
| 33 | 65 | tb.Fatal(err) | |
| 34 | 66 | } | |
| 35 | 67 | r := newRuntime() | |
| 68 | + | stylesheet, err := os.ReadFile("../../examples/demo/app.css") | |
| 69 | + | if err != nil { | |
| 70 | + | tb.Fatal(err) | |
| 71 | + | } | |
| 72 | + | r.styles = css.Compile(css.UA(), css.Parse(string(stylesheet))) | |
| 36 | 73 | r.setTextEngine(text.NewEngine(catalog)) | |
| 37 | 74 | return r | |
| 38 | 75 | } | |
| @@ -66,7 +103,7 @@ func TestDemoFrameAllocationBudget(t *testing.T) { | |||
| 66 | 103 | panic("demo frame failed") | |
| 67 | 104 | } | |
| 68 | 105 | }) | |
| 69 | - | const budget = 500 | |
| 106 | + | const budget = 1100 | |
| 70 | 107 | if allocs > budget { | |
| 71 | 108 | t.Fatalf("demo frame allocations: %.0f > %d", allocs, budget) | |
| 72 | 109 | } | |
| @@ -1,29 +1,61 @@ | |||||
| 1 | package ui | 1 | package ui | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "os" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | 6 | ||||
| 7 | + | "github.com/bnema/nefergui/internal/css" | |||
| 8 | + | ||||
| 6 | "github.com/bnema/nefergui/internal/text" | 9 | "github.com/bnema/nefergui/internal/text" | ||
| 7 | ) | 10 | ) | ||
| 8 | 11 | ||||
| 9 | // demoFrame mirrors the product example's home view without opening a display. | 12 | // demoFrame mirrors the product example's home view without opening a display. | ||
| 10 | func demoFrame(f *Frame) { | 13 | func demoFrame(f *Frame) { | ||
| 11 | - | root := f.Root(Class("app")) | 14 | + | root := f.Root(Class("app"), Class("light"), Class("comfortable")) |
| 12 | header := root.Header(Class("header")) | 15 | header := root.Header(Class("header")) | ||
| 13 | - | header.Heading("Demo", Level(1), Class("logo")) | 16 | + | header.Heading("NeferGUI", Level(1), Class("logo")) |
| 14 | - | menu := header.Nav(Class("menu")) | 17 | + | header.Button("New", Key("new-document")) |
| 15 | - | menu.Button("Accueil", Key("home"), Class("menu-item")) | 18 | + | header.Text("Untitled.txt", Class("muted")) |
| 16 | - | menu.Button("Paramètres", Key("settings"), Class("menu-item")) | 19 | + | header.Spacer(Class("stretch")) |
| 17 | - | dark := false | 20 | + | header.Button("Dark mode", Key("dark-mode"), Class("theme-toggle")) |
| 18 | - | header.Checkbox("Thème sombre", &dark, Key("dark-mode")) | 21 | + | body := root.Row(Class("body")) |
| 19 | - | content := root.Main(Class("content")) | 22 | + | sidebar := body.Aside(Class("sidebar")) |
| 20 | - | section := content.Section(Class("welcome")) | 23 | + | sidebar.Text("DOCUMENTS", Class("eyebrow")) |
| 21 | - | section.Heading("Bienvenue", Level(2)) | 24 | + | menu := sidebar.Nav(Class("menu")) |
| 22 | - | section.Text("Une interface native écrite entièrement en Go.") | 25 | + | menu.Button("Untitled.txt", Key("home"), Class("menu-item"), Class("active")) |
| 23 | - | name := "Ada" | 26 | + | menu.Button("Preferences", Key("settings"), Class("menu-item")) |
| 24 | - | section.Input("Votre nom", &name, Key("name"), Placeholder("Ada")) | 27 | + | sidebar.Spacer(Class("stretch")) |
| 25 | - | section.Row(Class("actions")).Button("Continuer", Key("continue"), Class("primary")) | 28 | + | sidebar.Text("Local workspace", Class("sidebar-note")) |
| 26 | - | root.Footer(Class("status-bar")).Text("Bonjour Ada") | 29 | + | sidebar.Text("1 document · in memory", Class("hint")) |
| 30 | + | content := body.Main(Class("content")) | |||
| 31 | + | workspace := content.Row(Class("workspace")) | |||
| 32 | + | editor := workspace.Column(Class("editor-pane")) | |||
| 33 | + | tab := editor.Row(Class("pane-bar")) | |||
| 34 | + | tab.Text("Untitled.txt", Class("document-title")) | |||
| 35 | + | tab.Spacer(Class("stretch")) | |||
| 36 | + | tab.Text("Plain text", Class("hint")) | |||
| 37 | + | notes := "Project notes\n\nA native text workspace built with NeferGUI.\n\nSelect text, edit a paragraph, or paste from your clipboard.\nUse the properties pane to try radio buttons and the slider.\n\nEverything stays in memory for this session." | |||
| 38 | + | editor.Textarea("Document", ¬es, Key("document"), Class("document-editor"), Placeholder("Start writing…")) | |||
| 39 | + | inspector := workspace.Aside(Class("inspector")) | |||
| 40 | + | inspector.Text("PROPERTIES", Class("eyebrow")) | |||
| 41 | + | inspector.Text("Author", Class("field-label")) | |||
| 42 | + | name := "" | |||
| 43 | + | inspector.Input("Author", &name, Key("name"), Placeholder("Your name")) | |||
| 44 | + | inspector.Button("Apply", Key("continue"), Disabled(true)) | |||
| 45 | + | inspector.Separator() | |||
| 46 | + | inspector.Text("Interface density", Class("field-label")) | |||
| 47 | + | density := "comfortable" | |||
| 48 | + | inspector.Radio("Comfortable", "comfortable", &density, Key("comfortable")) | |||
| 49 | + | inspector.Radio("Compact", "compact", &density, Key("compact")) | |||
| 50 | + | inspector.Separator() | |||
| 51 | + | volume := 40.0 | |||
| 52 | + | inspector.Slider("Volume 40 %", &volume, 0, 100, 5, Key("volume")) | |||
| 53 | + | inspector.Spacer(Class("stretch")) | |||
| 54 | + | inspector.Text("Changes stay in this session.", Class("hint")) | |||
| 55 | + | footer := root.Footer(Class("status-bar")) | |||
| 56 | + | footer.Text("Ready") | |||
| 57 | + | footer.Spacer(Class("stretch")) | |||
| 58 | + | footer.Text("UTF-8 • Plain text", Class("hint")) | |||
| 27 | } | 59 | } | ||
| 28 | 60 | ||||
| 29 | func demoRuntime(tb testing.TB) *runtime { | 61 | func demoRuntime(tb testing.TB) *runtime { | ||
| @@ -33,6 +65,11 @@ func demoRuntime(tb testing.TB) *runtime { | |||||
| 33 | tb.Fatal(err) | 65 | tb.Fatal(err) | ||
| 34 | } | 66 | } | ||
| 35 | r := newRuntime() | 67 | r := newRuntime() | ||
| 68 | + | stylesheet, err := os.ReadFile("../../examples/demo/app.css") | |||
| 69 | + | if err != nil { | |||
| 70 | + | tb.Fatal(err) | |||
| 71 | + | } | |||
| 72 | + | r.styles = css.Compile(css.UA(), css.Parse(string(stylesheet))) | |||
| 36 | r.setTextEngine(text.NewEngine(catalog)) | 73 | r.setTextEngine(text.NewEngine(catalog)) | ||
| 37 | return r | 74 | return r | ||
| 38 | } | 75 | } | ||
| @@ -66,7 +103,7 @@ func TestDemoFrameAllocationBudget(t *testing.T) { | |||||
| 66 | panic("demo frame failed") | 103 | panic("demo frame failed") | ||
| 67 | } | 104 | } | ||
| 68 | }) | 105 | }) | ||
| 69 | - | const budget = 500 | 106 | + | const budget = 1100 |
| 70 | if allocs > budget { | 107 | if allocs > budget { | ||
| 71 | t.Fatalf("demo frame allocations: %.0f > %d", allocs, budget) | 108 | t.Fatalf("demo frame allocations: %.0f > %d", allocs, budget) | ||
| 72 | } | 109 | } | ||