detached 47aaa0f

Merge pull request #2 from bnema/feat/gui-workspace-and-performance

feat(gui): add desktop workspace, rendering fixes and allocation improvements
commit
47aaa0f70c75d1508d1d193b05691f91e15f3fac
author
bnema · · 2026-09-30 06:23 UTC
committer
GitHub · · 2026-09-30 06:23 UTC
merge of
1488e4a + 1605038 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.1.0
32 files · +1799 -223
M docs/performance.md +65 -29
@@ -2,53 +2,89 @@
22
33 ## CPU budgets
44
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`.
66
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.
208
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.
2210
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:
2445
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+```
2850
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:
3052
3153 ```sh
3254 go test -run '^$' -bench BenchmarkDemoFrame -benchmem -memprofile mem.out -memprofilerate=1 ./internal/ui
3355 go tool pprof -sample_index=alloc_objects -top -cum mem.out
3456 ```
3557
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-
3858 ## Memory as a dependency
3959
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.
4165
4266 | Measurement | Minimal view | Demo view |
4367 |---|---:|---:|
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.
4783
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.
4985
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.
5187
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.
5389
5490 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 budgets3 ## 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 ```sh53 ```sh
32 go test -run '^$' -bench BenchmarkDemoFrame -benchmem -memprofile mem.out -memprofilerate=1 ./internal/ui54 go test -run '^$' -bench BenchmarkDemoFrame -benchmem -memprofile mem.out -memprofilerate=1 ./internal/ui
33 go tool pprof -sample_index=alloc_objects -top -cum mem.out55 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 dependency58 ## 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; }
1767 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
2424
2525 type Model struct {
2626 Page, Name, Status string
27+ Notes string
28+ NewPending bool
2729 DarkMode bool
2830 Density string
2931 Volume float64
@@ -39,39 +41,106 @@ func navButton(parent nefergui.Node, m *Model, label, page string) {
3941 }
4042 }
4143 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"
4953 }
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"))
5070 }
5171 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
5689 }
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"
5894 }
95+
5996 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))
61102 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"))
68134 switch m.Page {
69135 case "home":
70136 home(content, m)
71137 case "settings":
72138 settings(content, m)
73139 }
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"))
75144 }
76145 func main() {
77146 static := flag.Bool("static", false, "render a fixed number of frames and exit")
@@ -123,16 +192,12 @@ func main() {
123192 fail(err)
124193 }
125194 }
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."}
127196 start := time.Now()
128197 var requested time.Time
129198 commit := func(frame uint64) error {
130199 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)
136201 if err != nil {
137202 return err
138203 }
@@ -161,7 +226,7 @@ func main() {
161226 }
162227 return nil
163228 }
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)}
165230 var err error
166231 if *static {
167232 // 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 string26 Page, Name, Status string
27+ Notes string
28+ NewPending bool
27 DarkMode bool29 DarkMode bool
28 Density string30 Density string
29 Volume float6431 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.Name50+ 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.Time197 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 err202 return err
138 }203 }
@@ -161,7 +226,7 @@ func main() {
161 }226 }
162 return nil227 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 error230 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 (
77 "testing"
88 )
99
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+
1027 func TestUnexpectedKeepsCloseErrors(t *testing.T) {
1128 closeErr := errors.New("close failed")
1229 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 {
228228 {Op: "shadow", Rect: box, Radii: radius, Shadow: &sh, Opacity: 1},
229229 {Op: "rect", Rect: box, Radii: radius, Color: css.Color{R: 1, G: 1, B: 1, A: 1}, Opacity: 1},
230230 {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},
231235 // Layout has already expanded this rect by the 4px outline offset.
232236 {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},
233237 }
@@ -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 @@
11 module github.com/bnema/nefergui
22
3-go 1.27.0
3+go 1.27
44
55 require (
66 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
88 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
1111 golang.org/x/image v0.46.0
1212 golang.org/x/sys v0.48.0
1313 )
@@ -1,13 +1,13 @@
1 module github.com/bnema/nefergui1 module github.com/bnema/nefergui
2 2
3-go 1.27.03+go 1.27
4 4
5 require (5 require (
6 github.com/bnema/purego v0.13.0-bnema.16 github.com/bnema/purego v0.13.0-bnema.1
7- github.com/bnema/purego-vulkan v0.5.1-0.20260928084713-7e6b00ce8c4c7+ github.com/bnema/purego-vulkan v0.6.0
8 github.com/bnema/purego-xkbcommon v0.0.0-20260928071113-e79265dfcdc28 github.com/bnema/purego-xkbcommon v0.0.0-20260928071113-e79265dfcdc2
9- github.com/bnema/wlturbo v0.2.1-0.20260929151656-a2cb70a5513a9+ github.com/bnema/wlturbo v0.3.0
10- github.com/go-text/typesetting v0.3.410+ github.com/go-text/typesetting v0.3.5
11 golang.org/x/image v0.46.011 golang.org/x/image v0.46.0
12 golang.org/x/sys v0.48.012 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) {
178178 return Output{}, err
179179 }
180180 out := Output{Tree: tree}
181- ctx.paint(root, tree, &out.Display)
181+ out.Display = ctx.display(root, tree)
182182 return out, nil
183183 }
184184
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+}
186204
187205 func style(n *Node) css.Style {
188206 if n.Style != nil {
@@ -209,6 +227,13 @@ func boxBound(border float64, minL, maxL css.Length, basis, insets float64, bord
209227 return safe(bounded(safe(border-insets), minL, maxL, basis) + insets)
210228 }
211229 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+ }
212237 if n.Measure != nil {
213238 s, lines, err := n.Measure(w)
214239 if err != nil {
@@ -230,7 +255,13 @@ func (c context) intrinsic(n *Node, w float64) (Size, []text.Line, error) {
230255 if req.Size == 0 {
231256 req.Size = 16
232257 }
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)
234265 if err != nil {
235266 return Size{}, nil, err
236267 }
@@ -264,7 +295,71 @@ func (c context) intrinsic(n *Node, w float64) (Size, []text.Line, error) {
264295 }
265296 return Size{safe(l.Width), safe(l.Height)}, l.Lines, nil
266297 }
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
268363 }
269364 func (c context) place(n *Node, x, y, availableW, availableH float64, forcedW, forcedH *float64, depth int) (*Result, error) {
270365 if depth > 256 {
@@ -364,6 +459,18 @@ func (c context) place(n *Node, x, y, availableW, availableH float64, forcedW, f
364459 // Block and stack auto heights depend on their children's envelopes.
365460 flex := s.Display == css.KeywordFlex
366461 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+ }
367474 err = c.flex(n, r, s, depth)
368475 } else {
369476 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 {
609716 } else {
610717 measureWidth = crossSize
611718 }
612- measured, _, err := c.intrinsic(ch, measureWidth)
719+ measured, _, err := c.intrinsicDepth(ch, measureWidth, 1)
613720 if err != nil {
614721 return err
615722 }
@@ -743,56 +850,162 @@ func (c context) flex(n *Node, r *Result, s css.Style, depth int) error {
743850 return nil
744851 }
745852
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+
746959 func (c context) paint(n *Node, r *Result, out *[]Command) {
747960 if r == nil {
748961 return
749962 }
750963 s := style(n)
751964 base := Command{ID: r.ID, Rect: r.Border, Opacity: math.Min(1, safe(s.Opacity))}
965+ borderRadii := radiiOf(&s, r.Border.W)
752966 for i := range s.BoxShadow {
753- sh := s.BoxShadow[i]
754- if sh.Inset {
967+ if s.BoxShadow[i].Inset {
755968 continue
756969 }
757970 cmd := base
758971 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
761974 *out = append(*out, cmd)
762975 }
763976 if s.BackgroundColor.A > 0 {
764977 cmd := base
765978 cmd.Op = "rect"
766979 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
768981 *out = append(*out, cmd)
769982 }
770983 cmd := base
771984 cmd.Op = "border"
772985 cmd.Widths = r.BorderWidths
773986 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
775988 if cmd.Widths != (Edges{}) || cmd.Radii != ([4]float64{}) {
776989 cmd.Color = s.BorderColor.Top
777990 *out = append(*out, cmd)
778991 }
779992 for i := range s.BoxShadow {
780- sh := s.BoxShadow[i]
781- if !sh.Inset {
993+ if !s.BoxShadow[i].Inset {
782994 continue
783995 }
784996 cmd := base
785997 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
7881000 *out = append(*out, cmd)
7891001 }
790- if s.OutlineStyle == css.KeywordSolid && positive(s.OutlineWidth, r.Border.W) > 0 {
1002+ if hasOutline(&s, r) {
7911003 cmd = base
7921004 cmd.Op = "outline"
7931005 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}
7961009 offset := coordValue(s.OutlineOffset, r.Border.W)
7971010 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)}
7981011 *out = append(*out, cmd)
@@ -808,7 +1021,7 @@ func (c context) paint(n *Node, r *Result, out *[]Command) {
8081021 cmd.Op = "image"
8091022 cmd.Rect = r.Content
8101023 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)
8121025 *out = append(*out, cmd)
8131026 }
8141027 if n.Kind == Text {
@@ -817,6 +1030,9 @@ func (c context) paint(n *Node, r *Result, out *[]Command) {
8171030 cmd.Rect = r.Content
8181031 cmd.Text = n.Content
8191032 cmd.Color = s.Color
1033+ runs, glyphs := textCounts(r)
1034+ cmd.Runs = c.runs.take(runs)
1035+ backing := c.glyphs.take(glyphs)
8201036 for _, line := range r.Lines {
8211037 offset := AlignOffset(s.TextAlign, line.Direction, r.Content.W, line.Width)
8221038 for _, run := range line.Runs {
@@ -827,8 +1043,12 @@ func (c context) paint(n *Node, r *Result, out *[]Command) {
8271043 if run.Face != nil {
8281044 rr.FaceID = run.Face.ID
8291045 }
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)]
8321052 }
8331053 cmd.Runs = append(cmd.Runs, rr)
8341054 }
@@ -178,11 +178,29 @@ func Layout(root *Node, options Options) (Output, error) {
178 return Output{}, err178 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, nil182 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 = 16256 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, err266 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, nil296 return Size{safe(l.Width), safe(l.Height)}, l.Lines, nil
266 }297 }
267- return Size{}, nil, nil298+ 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.KeywordFlex460 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 = crossSize717 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 err721 return err
615 }722 }
@@ -743,56 +850,162 @@ func (c context) flex(n *Node, r *Result, s css.Style, depth int) error {
743 return nil850 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 return961 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 continue968 continue
756 }969 }
757 cmd := base970 cmd := base
758 cmd.Op = "shadow"971 cmd.Op = "shadow"
759- cmd.Shadow = &sh972+ 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 := base977 cmd := base
765 cmd.Op = "rect"978 cmd.Op = "rect"
766 cmd.Color = s.BackgroundColor979 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 := base983 cmd := base
771 cmd.Op = "border"984 cmd.Op = "border"
772 cmd.Widths = r.BorderWidths985 cmd.Widths = r.BorderWidths
773 cmd.Colors = s.BorderColor986 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.Top989 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 continue994 continue
783 }995 }
784 cmd := base996 cmd := base
785 cmd.Op = "shadow"997 cmd.Op = "shadow"
786- cmd.Shadow = &sh998+ 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 = base1003 cmd = base
792 cmd.Op = "outline"1004 cmd.Op = "outline"
793 cmd.Color = s.OutlineColor1005 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.Content1022 cmd.Rect = r.Content
810 cmd.Image = n.Image1023 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.Content1030 cmd.Rect = r.Content
818 cmd.Text = n.Content1031 cmd.Text = n.Content
819 cmd.Color = s.Color1032 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.ID1044 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
116116 return nil
117117 }
118118 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 {
120120 return nil, err
121121 }
122122 w.Shell = xdgshell.NewXdgWmBase(ctx)
@@ -116,7 +116,7 @@ func Connect(name string, width, height int32, transparent bool) (_ *Window, err
116 return nil116 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, err120 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 {
6363 }
6464 }
6565 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)
7167 })
7268 if err != nil {
7369 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 err69 return err
M internal/presentation/session/session.go +16 -5
@@ -49,6 +49,7 @@ type Session struct {
4949 Pipeline *vkdevice.Pipeline
5050 ListPipeline *vkdevice.Pipeline
5151 Preparer *render.Preparer
52+ list render.ListBuffer // owner-loop scratch; see tickFrame
5253 Slots map[uint64]*Slot
5354 retiredPipelines []retiredPipelines
5455 Acquire uint32
@@ -342,6 +343,20 @@ func (s *Session) TickList(instances []vkdevice.Instance, uploads []text.Upload)
342343 return s.tick(nil, instances, uploads, batches)
343344 }
344345
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+
345360 func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploads []text.Upload, batches []vkdevice.Batch) (bool, error) {
346361 started := time.Now()
347362 if s.Window.Dirty {
@@ -682,11 +697,7 @@ func (s *Session) RunPreparedListWithCommit(ctx context.Context, frames int, dra
682697 if err != nil {
683698 return false, err
684699 }
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)
690701 }, committed)
691702 }
692703
@@ -49,6 +49,7 @@ type Session struct {
49 Pipeline *vkdevice.Pipeline49 Pipeline *vkdevice.Pipeline
50 ListPipeline *vkdevice.Pipeline50 ListPipeline *vkdevice.Pipeline
51 Preparer *render.Preparer51 Preparer *render.Preparer
52+ list render.ListBuffer // owner-loop scratch; see tickFrame
52 Slots map[uint64]*Slot53 Slots map[uint64]*Slot
53 retiredPipelines []retiredPipelines54 retiredPipelines []retiredPipelines
54 Acquire uint3255 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, err698 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() {
8080 float hole = inner.z > 0.0 && inner.w > 0.0 ? cover(roundedDistance(p, inner, innerRadii)) : 0.0;
8181 coverage *= 1.0 - hole;
8282 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;
9094 }
9195 } else if (fragKind > 3.5) {
9296 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 the83+ // 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 @@
66 package syncobj
77
88 import (
9- "errors"
109 "fmt"
1110 "runtime"
1211 "time"
@@ -74,10 +73,25 @@ func (n *Node) Close() error {
7473 n.fd = -1
7574 return err
7675 }
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 {
7883 _, _, 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)
8195 }
8296 return nil
8397 }
@@ -173,17 +187,25 @@ func (n *Node) WaitPoint(handle uint32, point uint64, timeout time.Duration) (bo
173187 if err := unix.ClockGettime(unix.CLOCK_MONOTONIC, &now); err != nil {
174188 return false, err
175189 }
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))
178193 runtime.KeepAlive(h)
179194 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)
185208 }
186- return true, nil
187209 }
188210
189211 // Signaled performs a zero-time wait for tests and shutdown diagnostics.
@@ -197,11 +219,11 @@ func (n *Node) Query(handle uint32) (uint64, error) {
197219 h := handle
198220 var point uint64
199221 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))
201223 runtime.KeepAlive(h)
202224 runtime.KeepAlive(point)
203- if err != nil {
204- return 0, err
225+ if errno != 0 {
226+ return 0, ioctlError(ioctlQuery, errno)
205227 }
206228 return point, nil
207229 }
@@ -6,7 +6,6 @@
6 package syncobj6 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 = -173 n.fd = -1
75 return err74 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 nil96 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, err188 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, nil197+
183- }198+// waitResult maps a TIMELINE_WAIT errno. ETIME and EBUSY are the expected idle
184- return false, err199+// 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 := handle219 h := handle
198 var point uint64220 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, err226+ return 0, ioctlError(ioctlQuery, errno)
205 }227 }
206 return point, nil228 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) {
6363 x, y int
6464 want color.NRGBA
6565 }{
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}},
6868 } {
6969 x, y := p.x*scale, p.y*scale
7070 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) {
8181 x0, y0, x1, y1 int
8282 fill color.NRGBA
8383 }{
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}},
8888 } {
8989 n, total := ink(img, l.x0*scale, l.y0*scale, l.x1*scale, l.y1*scale, l.fill)
9090 if n < 40*scale*scale || n > total/2 {
@@ -125,7 +125,7 @@ func TestDemoCursorShapes(t *testing.T) {
125125 script := filepath.Join(t.TempDir(), "input.txt")
126126 // Same cold-start allowance as TestDemoInputAndResources: the script
127127 // 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 {
129129 t.Fatal(err)
130130 }
131131 res := runDemo(t, bin, 1, 400, script, 12*time.Second)
@@ -155,7 +155,7 @@ func TestDemoInputAndResources(t *testing.T) {
155155 // wait past cold start (font discovery) before injecting input. The demo
156156 // keeps presenting for 12 s so the run outlasts the ~10 s script however
157157 // 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 {
159159 t.Fatal(err)
160160 }
161161 res := runDemo(t, bin, 1, 400, script, 12*time.Second)
@@ -167,7 +167,7 @@ func TestDemoInputAndResources(t *testing.T) {
167167 if err = json.Unmarshal(data, &state); err != nil {
168168 t.Fatal(err)
169169 }
170- if state.Name != "Ada" || state.Status != "Bonjour Ada" {
170+ if state.Name != "Example" || state.Status != "Author set to Example · session only" {
171171 layout, _ := os.ReadFile(filepath.Join(res.Artifacts["debug"], "layout.json"))
172172 t.Fatalf("state %+v layout=%s", state, layout)
173173 }
@@ -186,7 +186,7 @@ func TestDemoInputAndResources(t *testing.T) {
186186 }
187187 }
188188
189-// TestDemoIndicators opens the settings page, selects the second radio and
189+// TestDemoIndicators returns to the editor, selects the second radio and
190190 // clicks the slider track, then checks the model and the painted indicators
191191 // in the captured frame: the selected radio and the slider fill up to the
192192 // thumb use the accent color, the rest of the track does not.
@@ -195,7 +195,7 @@ func TestDemoIndicators(t *testing.T) {
195195 bin := demoBinary(t)
196196 script := filepath.Join(t.TempDir(), "input.txt")
197197 // 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 {
199199 t.Fatal(err)
200200 }
201201 res := runDemo(t, bin, 1, 400, script, 13*time.Second)
@@ -210,23 +210,23 @@ func TestDemoIndicators(t *testing.T) {
210210 if err = json.Unmarshal(data, &state); err != nil {
211211 t.Fatal(err)
212212 }
213- if state.Page != "settings" || state.Density != "compact" || state.Volume != 70 {
213+ if state.Page != "home" || state.Density != "compact" || state.Volume != 70 {
214214 t.Fatalf("state %+v", state)
215215 }
216216 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
219219 // 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}
221221 for _, p := range []struct {
222222 name string
223223 x, y int
224224 want color.NRGBA
225225 }{
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},
230230 } {
231231 t.Logf("%s (%d,%d)=%v", p.name, p.x, p.y, color.NRGBAModel.Convert(img.At(p.x, p.y)))
232232 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 {
249249 if err != nil {
250250 continue // possibly still being written
251251 }
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 {
253253 return img
254254 }
255255 }
@@ -63,8 +63,8 @@ func TestDemoStatic(t *testing.T) {
63 x, y int63 x, y int
64 want color.NRGBA64 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*scale69 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 int81 x0, y0, x1, y1 int
82 fill color.NRGBA82 fill color.NRGBA
83 }{83 }{
84- {"Continuer", 110, 360, 222, 380, color.NRGBA{117, 148, 201, 255}}, // disabled primary84+ {"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 item85+ {"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 indicator86+ {"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}}, // input87+ {"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 script126 // 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 demo155 // 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 however156 // 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 and189+// TestDemoIndicators returns to the editor, selects the second radio and
190 // clicks the slider track, then checks the model and the painted indicators190 // 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 the191 // 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 #3584e4217+ accent := color.NRGBA{82, 82, 91, 255} // light theme accent
218- // Muted paint is the demo text color at 45% alpha over white. Each probe218+ // 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 string222 name string
223 x, y int223 x, y int
224 want color.NRGBA224 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 written250 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 img253 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) {
389389 {"rounded corner", 113 * scale, 113 * scale, bg},
390390 {"outline", 200 * scale, 114 * scale, color.NRGBA{255, 0, 255, 255}},
391391 {"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}},
392396 }
393397 for _, p := range probes {
394398 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) {
2121
2222 // ListBatches groups consecutive atlas draws and isolates each image draw.
2323 // First indexes the returned instances, retaining exact painter's order.
24+// The returned slices and map are freshly allocated and owned by the caller.
2425 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) {
72107 stats.Skipped[q.Op]++
108+ continue
73109 }
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))
74118 }
75119 stats.Drawn = len(out)
76120 return out, batches, stats
77121 }
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.Batch28+
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] = 645+// 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] = 248+// 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] = 350+ b.Release()
50- case "shadow":51+ if b.skipped == nil {
51- v.KindLayer[0] = 452+ b.skipped = make(map[string]int)
52- if q.Inset {53+ }
53- v.KindLayer[0] = 554+ 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, stats120 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 (
55 "github.com/bnema/nefergui/internal/layout"
66 "github.com/bnema/nefergui/internal/text"
77 "image"
8+ "reflect"
89 "testing"
910 )
1011
@@ -44,3 +45,70 @@ func TestListInstances(t *testing.T) {
4445 t.Fatalf("glyph=%+v", got[2])
4546 }
4647 }
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 {
7474 return layout.Edges{Top: e.Top * scale, Right: e.Right * scale, Bottom: e.Bottom * scale, Left: e.Left * scale}
7575 }
7676
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+
7799 // Prepare converts logical rectangles once, using layout.Physical as the sole
78100 // logical-to-physical edge-rounding boundary. A clip-push intersects its parent
79101 // 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
86108 }
87109 p.Atlas.BeginFrame()
88110 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+ }
89117 clip := layout.Rect{W: float64(width), H: float64(height)}
90118 stack := []layout.Rect{}
91119 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 sole99 // Prepare converts logical rectangles once, using layout.Physical as the sole
78 // logical-to-physical edge-rounding boundary. A clip-push intersects its parent100 // 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 @@
11 package ui
22
33 import (
4+ "os"
45 "testing"
56
7+ "github.com/bnema/nefergui/internal/css"
8+
69 "github.com/bnema/nefergui/internal/text"
710 )
811
912 // demoFrame mirrors the product example's home view without opening a display.
1013 func demoFrame(f *Frame) {
11- root := f.Root(Class("app"))
14+ root := f.Root(Class("app"), Class("light"), Class("comfortable"))
1215 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", &notes, 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"))
2759 }
2860
2961 func demoRuntime(tb testing.TB) *runtime {
@@ -33,6 +65,11 @@ func demoRuntime(tb testing.TB) *runtime {
3365 tb.Fatal(err)
3466 }
3567 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)))
3673 r.setTextEngine(text.NewEngine(catalog))
3774 return r
3875 }
@@ -66,7 +103,7 @@ func TestDemoFrameAllocationBudget(t *testing.T) {
66103 panic("demo frame failed")
67104 }
68105 })
69- const budget = 500
106+ const budget = 1100
70107 if allocs > budget {
71108 t.Fatalf("demo frame allocations: %.0f > %d", allocs, budget)
72109 }
@@ -1,29 +1,61 @@
1 package ui1 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 := false20+ 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", &notes, 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 r74 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 = 500106+ 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 }

Keyboard shortcuts

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

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

Branches 1

Tags 12

↑↓ moveenter openesc close