detached ea9abc7

feat(gui): draw layer surfaces with neferclient and nefergui Renderer (#2)

Draws layer surfaces with neferclient and the nefergui Renderer. Stacked on refactor/wayland-bindings: merge that PR first.

Reviewed-on: https://git.bnema.dev/bnema/nefercap/pulls/2
commit
ea9abc7a501d76d9632177b875806e71d06ffa22
author
brice · · 2026-10-02 11:57 UTC
committer
bnema · · 2026-10-02 11:57 UTC
parent
298ec90
signature
unsigned
refs
tag: v0.3.0
18 files · +763 -241
M AGENTS.md +1 -1
@@ -2,7 +2,7 @@
22
33 - Use Go 1.27; normal builds and tests use CGO_ENABLED=0. Enable CGO only for race tests.
44 - Follow lightweight hexagonal architecture: cmd/nefercap and internal/app wire internal/core, internal/ports, and internal/adapters. Core imports only stdlib and ports; ports contain no framework types.
5-- Use the NeferGUI version pinned in go.mod for layer-shell interfaces, and zerowrap with a component field for logging. Reuse NeferWL dependency versions where applicable; do not add unrelated compositor dependencies.
5+- Layer surfaces go through internal/adapters/layerui: neferclient owns the Wayland surface and NeferGUI's Renderer draws it. Use the versions pinned in go.mod, and zerowrap with a component field for logging. Reuse NeferWL dependency versions where applicable; do not add unrelated compositor dependencies.
66 - Sign every commit and tag; commit format: type(scope): description.
77 - Minimize footprint. Bound buffers, queues and subprocess lifetimes. Reuse frame storage; test and benchmark steady-state allocations. Do not claim zero allocations without measurements.
88 - Test doubles are generated by Mockery v3 with the testify template. No handwritten fakes, stubs, spies or function-field replacements. Cross-layer interfaces live in internal/ports; generated mocks in internal/mocks/ports. Adapter-private seams stay in the adapter with generated *_mock_test.go files. Use .EXPECT().
@@ -2,7 +2,7 @@
2 2
3 - Use Go 1.27; normal builds and tests use CGO_ENABLED=0. Enable CGO only for race tests.3 - Use Go 1.27; normal builds and tests use CGO_ENABLED=0. Enable CGO only for race tests.
4 - Follow lightweight hexagonal architecture: cmd/nefercap and internal/app wire internal/core, internal/ports, and internal/adapters. Core imports only stdlib and ports; ports contain no framework types.4 - Follow lightweight hexagonal architecture: cmd/nefercap and internal/app wire internal/core, internal/ports, and internal/adapters. Core imports only stdlib and ports; ports contain no framework types.
5-- Use the NeferGUI version pinned in go.mod for layer-shell interfaces, and zerowrap with a component field for logging. Reuse NeferWL dependency versions where applicable; do not add unrelated compositor dependencies.5+- Layer surfaces go through internal/adapters/layerui: neferclient owns the Wayland surface and NeferGUI's Renderer draws it. Use the versions pinned in go.mod, and zerowrap with a component field for logging. Reuse NeferWL dependency versions where applicable; do not add unrelated compositor dependencies.
6 - Sign every commit and tag; commit format: type(scope): description.6 - Sign every commit and tag; commit format: type(scope): description.
7 - Minimize footprint. Bound buffers, queues and subprocess lifetimes. Reuse frame storage; test and benchmark steady-state allocations. Do not claim zero allocations without measurements.7 - Minimize footprint. Bound buffers, queues and subprocess lifetimes. Reuse frame storage; test and benchmark steady-state allocations. Do not claim zero allocations without measurements.
8 - Test doubles are generated by Mockery v3 with the testify template. No handwritten fakes, stubs, spies or function-field replacements. Cross-layer interfaces live in internal/ports; generated mocks in internal/mocks/ports. Adapter-private seams stay in the adapter with generated *_mock_test.go files. Use .EXPECT().8 - Test doubles are generated by Mockery v3 with the testify template. No handwritten fakes, stubs, spies or function-field replacements. Cross-layer interfaces live in internal/ports; generated mocks in internal/mocks/ports. Adapter-private seams stay in the adapter with generated *_mock_test.go files. Use .EXPECT().
M go.mod +4 -3
@@ -4,9 +4,10 @@ go 1.27
44
55 require (
66 github.com/bnema/go-wayland-bindings v0.1.0
7- github.com/bnema/nefergui v0.3.0
7+ github.com/bnema/neferclient v0.3.0
8+ github.com/bnema/nefergui v0.6.1
89 github.com/bnema/purego-libwayland v0.8.0
9- github.com/bnema/wlturbo v0.5.0
10+ github.com/bnema/wlturbo v0.6.2
1011 github.com/bnema/zerowrap v1.4.1
1112 github.com/stretchr/testify v1.12.1
1213 golang.org/x/sys v0.48.0
@@ -15,7 +16,7 @@ require (
1516 require (
1617 github.com/bnema/purego v0.13.0-bnema.1 // indirect
1718 github.com/bnema/purego-vulkan v0.6.0 // indirect
18- github.com/bnema/purego-xkbcommon v0.1.0 // indirect
19+ github.com/bnema/purego-xkbcommon v0.2.0 // indirect
1920 github.com/go-text/typesetting v0.3.5 // indirect
2021 github.com/mattn/go-colorable v0.1.15 // indirect
2122 github.com/mattn/go-isatty v0.0.22 // indirect
@@ -4,9 +4,10 @@ go 1.27
4 4
5 require (5 require (
6 github.com/bnema/go-wayland-bindings v0.1.06 github.com/bnema/go-wayland-bindings v0.1.0
7- github.com/bnema/nefergui v0.3.07+ github.com/bnema/neferclient v0.3.0
8+ github.com/bnema/nefergui v0.6.1
8 github.com/bnema/purego-libwayland v0.8.09 github.com/bnema/purego-libwayland v0.8.0
9- github.com/bnema/wlturbo v0.5.010+ github.com/bnema/wlturbo v0.6.2
10 github.com/bnema/zerowrap v1.4.111 github.com/bnema/zerowrap v1.4.1
11 github.com/stretchr/testify v1.12.112 github.com/stretchr/testify v1.12.1
12 golang.org/x/sys v0.48.013 golang.org/x/sys v0.48.0
@@ -15,7 +16,7 @@ require (
15 require (16 require (
16 github.com/bnema/purego v0.13.0-bnema.1 // indirect17 github.com/bnema/purego v0.13.0-bnema.1 // indirect
17 github.com/bnema/purego-vulkan v0.6.0 // indirect18 github.com/bnema/purego-vulkan v0.6.0 // indirect
18- github.com/bnema/purego-xkbcommon v0.1.0 // indirect19+ github.com/bnema/purego-xkbcommon v0.2.0 // indirect
19 github.com/go-text/typesetting v0.3.5 // indirect20 github.com/go-text/typesetting v0.3.5 // indirect
20 github.com/mattn/go-colorable v0.1.15 // indirect21 github.com/mattn/go-colorable v0.1.15 // indirect
21 github.com/mattn/go-isatty v0.0.22 // indirect22 github.com/mattn/go-isatty v0.0.22 // indirect
M go.sum +8 -6
@@ -1,17 +1,19 @@
11 github.com/bnema/go-wayland-bindings v0.1.0 h1:mi2vNa8BB5ZCo/hq/9d6Af01yhqY6QXAZ6pkljFI1xk=
22 github.com/bnema/go-wayland-bindings v0.1.0/go.mod h1:eWOgjp9vzZEqxm059q1G9F5NXplmv9UAmrDJiivxVjA=
3-github.com/bnema/nefergui v0.3.0 h1:JNzvFWt6SNOQ75WSPcTox+M7fHUF/WuyOR/FVWLc/Mg=
4-github.com/bnema/nefergui v0.3.0/go.mod h1:M4jp5ZWXBSN94txcrnnsHo7uJESF68ioT7IR86Tz/ZU=
3+github.com/bnema/neferclient v0.3.0 h1:sEBphi11YpgMOPYHW1OcDtQYA8aD3WSaFILnb8fdp90=
4+github.com/bnema/neferclient v0.3.0/go.mod h1:CzZflJIvinLP8u0rYPjdmAnZjl4Ec2cvdnEGvqFO4Yw=
5+github.com/bnema/nefergui v0.6.1 h1:PkEccPjc5SVXfaGvclS4wO2l15d4TJPSinZzgIWbYmM=
6+github.com/bnema/nefergui v0.6.1/go.mod h1:G2iRRleA9bjSohU1RySwRqqxS0BbkPziK407HkuyEBc=
57 github.com/bnema/purego v0.13.0-bnema.1 h1:fKRdztnvTmRqAjANMoN+utEIH73dvPqVPJh2iZCCAO8=
68 github.com/bnema/purego v0.13.0-bnema.1/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM=
79 github.com/bnema/purego-libwayland v0.8.0 h1:e82G1nMre4HvnR1Q8TvdnVNITUStSc+hD3eMwkYjxQY=
810 github.com/bnema/purego-libwayland v0.8.0/go.mod h1:+2zIEBX+iNlRB27xrm5Z8Dnd+cmqX+1AYvuwguxK2dg=
911 github.com/bnema/purego-vulkan v0.6.0 h1:SJgu0smuDC2vtBv0dJ6dmRKewJW5EUXQwTXyb/p9JSE=
1012 github.com/bnema/purego-vulkan v0.6.0/go.mod h1:7kFoR1p01JbknuZlfA20oeImryBe8Fx+Zd9hm2uSUM4=
11-github.com/bnema/purego-xkbcommon v0.1.0 h1:VHNf9shPpafKxdTERcwCNJKHhSd4IG2tjmLsDACKnuw=
12-github.com/bnema/purego-xkbcommon v0.1.0/go.mod h1:GE8JGmR/rqdklz96tFaFU7M5kI7jN1ma5mh+rz+XXrg=
13-github.com/bnema/wlturbo v0.5.0 h1:AUP3IAd4ZXM3ZrJ8zu65lAejKtYMwSoSDE2TS6k3mn4=
14-github.com/bnema/wlturbo v0.5.0/go.mod h1:4i1k1wyw7FDWOkh2hg1ZwPfrPRn3+E6WrYg9Qrt74CA=
13+github.com/bnema/purego-xkbcommon v0.2.0 h1:UWh5rSJIBhYImg8wZ98UzK4QNt7gvkKR6gFseO21mtg=
14+github.com/bnema/purego-xkbcommon v0.2.0/go.mod h1:GE8JGmR/rqdklz96tFaFU7M5kI7jN1ma5mh+rz+XXrg=
15+github.com/bnema/wlturbo v0.6.2 h1:wV+NlplUoh6wG9E9ZKsPATMDSrTaiez3+T0xCDFZZjg=
16+github.com/bnema/wlturbo v0.6.2/go.mod h1:4i1k1wyw7FDWOkh2hg1ZwPfrPRn3+E6WrYg9Qrt74CA=
1517 github.com/bnema/zerowrap v1.4.1 h1:v70w8LDU2+FU+EX/etY9DjVwOAWN0fUKShwZyZHnyLU=
1618 github.com/bnema/zerowrap v1.4.1/go.mod h1:8M8Qz3uRpbszYkXqi0Dnr1TZ4lDrQY8/orGm30zwKKg=
1719 github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk=
@@ -1,17 +1,19 @@
1 github.com/bnema/go-wayland-bindings v0.1.0 h1:mi2vNa8BB5ZCo/hq/9d6Af01yhqY6QXAZ6pkljFI1xk=1 github.com/bnema/go-wayland-bindings v0.1.0 h1:mi2vNa8BB5ZCo/hq/9d6Af01yhqY6QXAZ6pkljFI1xk=
2 github.com/bnema/go-wayland-bindings v0.1.0/go.mod h1:eWOgjp9vzZEqxm059q1G9F5NXplmv9UAmrDJiivxVjA=2 github.com/bnema/go-wayland-bindings v0.1.0/go.mod h1:eWOgjp9vzZEqxm059q1G9F5NXplmv9UAmrDJiivxVjA=
3-github.com/bnema/nefergui v0.3.0 h1:JNzvFWt6SNOQ75WSPcTox+M7fHUF/WuyOR/FVWLc/Mg=3+github.com/bnema/neferclient v0.3.0 h1:sEBphi11YpgMOPYHW1OcDtQYA8aD3WSaFILnb8fdp90=
4-github.com/bnema/nefergui v0.3.0/go.mod h1:M4jp5ZWXBSN94txcrnnsHo7uJESF68ioT7IR86Tz/ZU=4+github.com/bnema/neferclient v0.3.0/go.mod h1:CzZflJIvinLP8u0rYPjdmAnZjl4Ec2cvdnEGvqFO4Yw=
5+github.com/bnema/nefergui v0.6.1 h1:PkEccPjc5SVXfaGvclS4wO2l15d4TJPSinZzgIWbYmM=
6+github.com/bnema/nefergui v0.6.1/go.mod h1:G2iRRleA9bjSohU1RySwRqqxS0BbkPziK407HkuyEBc=
5 github.com/bnema/purego v0.13.0-bnema.1 h1:fKRdztnvTmRqAjANMoN+utEIH73dvPqVPJh2iZCCAO8=7 github.com/bnema/purego v0.13.0-bnema.1 h1:fKRdztnvTmRqAjANMoN+utEIH73dvPqVPJh2iZCCAO8=
6 github.com/bnema/purego v0.13.0-bnema.1/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM=8 github.com/bnema/purego v0.13.0-bnema.1/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM=
7 github.com/bnema/purego-libwayland v0.8.0 h1:e82G1nMre4HvnR1Q8TvdnVNITUStSc+hD3eMwkYjxQY=9 github.com/bnema/purego-libwayland v0.8.0 h1:e82G1nMre4HvnR1Q8TvdnVNITUStSc+hD3eMwkYjxQY=
8 github.com/bnema/purego-libwayland v0.8.0/go.mod h1:+2zIEBX+iNlRB27xrm5Z8Dnd+cmqX+1AYvuwguxK2dg=10 github.com/bnema/purego-libwayland v0.8.0/go.mod h1:+2zIEBX+iNlRB27xrm5Z8Dnd+cmqX+1AYvuwguxK2dg=
9 github.com/bnema/purego-vulkan v0.6.0 h1:SJgu0smuDC2vtBv0dJ6dmRKewJW5EUXQwTXyb/p9JSE=11 github.com/bnema/purego-vulkan v0.6.0 h1:SJgu0smuDC2vtBv0dJ6dmRKewJW5EUXQwTXyb/p9JSE=
10 github.com/bnema/purego-vulkan v0.6.0/go.mod h1:7kFoR1p01JbknuZlfA20oeImryBe8Fx+Zd9hm2uSUM4=12 github.com/bnema/purego-vulkan v0.6.0/go.mod h1:7kFoR1p01JbknuZlfA20oeImryBe8Fx+Zd9hm2uSUM4=
11-github.com/bnema/purego-xkbcommon v0.1.0 h1:VHNf9shPpafKxdTERcwCNJKHhSd4IG2tjmLsDACKnuw=13+github.com/bnema/purego-xkbcommon v0.2.0 h1:UWh5rSJIBhYImg8wZ98UzK4QNt7gvkKR6gFseO21mtg=
12-github.com/bnema/purego-xkbcommon v0.1.0/go.mod h1:GE8JGmR/rqdklz96tFaFU7M5kI7jN1ma5mh+rz+XXrg=14+github.com/bnema/purego-xkbcommon v0.2.0/go.mod h1:GE8JGmR/rqdklz96tFaFU7M5kI7jN1ma5mh+rz+XXrg=
13-github.com/bnema/wlturbo v0.5.0 h1:AUP3IAd4ZXM3ZrJ8zu65lAejKtYMwSoSDE2TS6k3mn4=15+github.com/bnema/wlturbo v0.6.2 h1:wV+NlplUoh6wG9E9ZKsPATMDSrTaiez3+T0xCDFZZjg=
14-github.com/bnema/wlturbo v0.5.0/go.mod h1:4i1k1wyw7FDWOkh2hg1ZwPfrPRn3+E6WrYg9Qrt74CA=16+github.com/bnema/wlturbo v0.6.2/go.mod h1:4i1k1wyw7FDWOkh2hg1ZwPfrPRn3+E6WrYg9Qrt74CA=
15 github.com/bnema/zerowrap v1.4.1 h1:v70w8LDU2+FU+EX/etY9DjVwOAWN0fUKShwZyZHnyLU=17 github.com/bnema/zerowrap v1.4.1 h1:v70w8LDU2+FU+EX/etY9DjVwOAWN0fUKShwZyZHnyLU=
16 github.com/bnema/zerowrap v1.4.1/go.mod h1:8M8Qz3uRpbszYkXqi0Dnr1TZ4lDrQY8/orGm30zwKKg=18 github.com/bnema/zerowrap v1.4.1/go.mod h1:8M8Qz3uRpbszYkXqi0Dnr1TZ4lDrQY8/orGm30zwKKg=
17 github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk=19 github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk=
M internal/adapters/indicator/hud.go +1 -1
@@ -58,7 +58,7 @@ func keepRune(c rune) bool {
5858 return unicode.IsPrint(c)
5959 }
6060
61-// hud is the indicator model. The view and every write run on the NeferGUI
61+// hud is the indicator model. The view and every write run on the layerui
6262 // owner loop, so nothing is locked. The elapsed time is computed from the
6363 // monotonic clock reading the view passes to refresh, never from a counter
6464 // another goroutine advances. start carries a monotonic reading (time.Now), so
@@ -58,7 +58,7 @@ func keepRune(c rune) bool {
58 return unicode.IsPrint(c)58 return unicode.IsPrint(c)
59 }59 }
60 60
61-// hud is the indicator model. The view and every write run on the NeferGUI61+// hud is the indicator model. The view and every write run on the layerui
62 // owner loop, so nothing is locked. The elapsed time is computed from the62 // owner loop, so nothing is locked. The elapsed time is computed from the
63 // monotonic clock reading the view passes to refresh, never from a counter63 // monotonic clock reading the view passes to refresh, never from a counter
64 // another goroutine advances. start carries a monotonic reading (time.Now), so64 // another goroutine advances. start carries a monotonic reading (time.Now), so
M internal/adapters/indicator/indicator.go +29 -23
@@ -8,8 +8,8 @@
88 // recorded area is not drawn here: the compositor marks it.
99 //
1010 // Capture exclusion is explicit authorization, not a property of how the HUD is
11-// built. The caller supplies authorize, which NeferGUI calls with the surface
12-// once it is configured and before its first buffer, to register whatever
11+// built. The caller supplies authorize, which runs with the HUD's display and
12+// wl_surface once it is configured and before its first buffer, to register whatever
1313 // exclusion the compositor contract requires on that same client; Run refuses
1414 // to start without it. The layer namespace is only a role hint and authorizes
1515 // nothing. If authorize fails, Run fails and nothing is shown.
@@ -23,8 +23,11 @@ import (
2323 "sync"
2424 "time"
2525
26+ "github.com/bnema/neferclient"
2627 "github.com/bnema/nefergui"
28+ "github.com/bnema/wlturbo"
2729
30+ "github.com/bnema/nefercap/internal/adapters/layerui"
2831 "github.com/bnema/nefercap/internal/adapters/uierrors"
2932 "github.com/bnema/nefercap/internal/adapters/uistyle"
3033 "github.com/bnema/nefercap/internal/ports"
@@ -67,16 +70,20 @@ type Indicator struct {
6770 // place. Set it before Run.
6871 Frame ports.Region
6972
70- authorize func(context.Context, nefergui.WaylandSurface) error
73+ authorize Authorize
7174 stop chan<- struct{}
7275 }
7376
77+// Authorize registers the HUD's surface with the compositor's capture
78+// exclusion; see layerui.Config.OnSurface.
79+type Authorize = func(ctx context.Context, display *wlturbo.Display, surface wlturbo.Proxy) error
80+
7481 // New returns an indicator. authorize is required and runs once per Run, on
75-// the window's owner loop; its context ends when Run's does, so a blocking wait
82+// its own goroutine; its context ends when Run's does, so a blocking wait
7683 // for the compositor's acknowledgement is interrupted by cancellation. stop
7784 // should be buffered; a click sends one non-blocking value. The caller reacts
7885 // by ending the recording.
79-func New(authorize func(context.Context, nefergui.WaylandSurface) error, stop chan<- struct{}) *Indicator {
86+func New(authorize Authorize, stop chan<- struct{}) *Indicator {
8087 return &Indicator{authorize: authorize, stop: stop}
8188 }
8289
@@ -117,15 +124,12 @@ func (i *Indicator) run(ctx context.Context, output ports.Output, start time.Tim
117124 go func() { defer wg.Done(); ticker(child, wake) }()
118125 defer func() { cancel(); wg.Wait() }()
119126
120- runErr := nefergui.Run(child, newHUD(start, i.stop, i.TargetLabel), view,
121- nefergui.Title("NeferCap recording"),
122- nefergui.Size(Width, Height),
123- nefergui.Styles(sheet),
124- nefergui.Transparent(),
125- nefergui.Layer(layerConfig(output.Name, i.Frame)),
126- nefergui.OnSurface(i.authorize),
127- nefergui.Wake(wake),
128- )
127+ runErr := layerui.Run(child, layerui.Config{
128+ Layer: layerConfig(output.Name, i.Frame),
129+ Styles: sheet,
130+ Wake: wake,
131+ OnSurface: i.authorize,
132+ }, newHUD(start, i.stop, i.TargetLabel), view)
129133 // Run is only ever ended by ctx: the HUD never decides by itself. Read the
130134 // parent once; a bare Canceled is then our own cancellation, and anything
131135 // else, including an end with a live parent, is a failure that keeps every
@@ -138,26 +142,28 @@ func (i *Indicator) run(ctx context.Context, output ports.Output, start time.Tim
138142 // pointer input only inside the Stop button. Centre is that of the output, or
139143 // of frame when it is set: then the HUD is anchored to the top left of the
140144 // output and moved by margins.
141-func layerConfig(output string, frame ports.Region) nefergui.LayerConfig {
142- c := nefergui.LayerConfig{
145+func layerConfig(output string, frame ports.Region) neferclient.LayerConfig {
146+ c := neferclient.LayerConfig{
143147 Output: output,
144148 Namespace: namespace,
145- Level: nefergui.LayerOverlay,
146- Anchors: nefergui.AnchorTop,
147- Keyboard: nefergui.KeyboardNone,
149+ Level: neferclient.LayerOverlay,
150+ Anchors: neferclient.AnchorTop,
151+ Keyboard: neferclient.KeyboardNone,
148152 ExclusiveZone: -1,
149153 Margin: [4]int32{topMargin, 0, 0, 0},
150- InputRects: []nefergui.Rect{stopRect()},
154+ InputRects: []neferclient.Rect{stopRect()},
155+ Width: Width,
156+ Height: Height,
151157 }
152158 if frame.Width > 0 && frame.Height > 0 {
153- c.Anchors = nefergui.AnchorTop | nefergui.AnchorLeft
159+ c.Anchors = neferclient.AnchorTop | neferclient.AnchorLeft
154160 c.Margin = [4]int32{int32(frame.Y) + topMargin, 0, 0, int32(frame.X + max((frame.Width-Width)/2, 0))}
155161 }
156162 return c
157163 }
158164
159-func stopRect() nefergui.Rect {
160- return nefergui.Rect{X: Width - stopWidth, Y: 0, Width: stopWidth, Height: Height}
165+func stopRect() neferclient.Rect {
166+ return neferclient.Rect{X: Width - stopWidth, Y: 0, Width: stopWidth, Height: Height}
161167 }
162168
163169 // ticker sends a non-blocking wake token every second until ctx ends.
@@ -8,8 +8,8 @@
8 // recorded area is not drawn here: the compositor marks it.8 // recorded area is not drawn here: the compositor marks it.
9 //9 //
10 // Capture exclusion is explicit authorization, not a property of how the HUD is10 // Capture exclusion is explicit authorization, not a property of how the HUD is
11-// built. The caller supplies authorize, which NeferGUI calls with the surface11+// built. The caller supplies authorize, which runs with the HUD's display and
12-// once it is configured and before its first buffer, to register whatever12+// wl_surface once it is configured and before its first buffer, to register whatever
13 // exclusion the compositor contract requires on that same client; Run refuses13 // exclusion the compositor contract requires on that same client; Run refuses
14 // to start without it. The layer namespace is only a role hint and authorizes14 // to start without it. The layer namespace is only a role hint and authorizes
15 // nothing. If authorize fails, Run fails and nothing is shown.15 // nothing. If authorize fails, Run fails and nothing is shown.
@@ -23,8 +23,11 @@ import (
23 "sync"23 "sync"
24 "time"24 "time"
25 25
26+ "github.com/bnema/neferclient"
26 "github.com/bnema/nefergui"27 "github.com/bnema/nefergui"
28+ "github.com/bnema/wlturbo"
27 29
30+ "github.com/bnema/nefercap/internal/adapters/layerui"
28 "github.com/bnema/nefercap/internal/adapters/uierrors"31 "github.com/bnema/nefercap/internal/adapters/uierrors"
29 "github.com/bnema/nefercap/internal/adapters/uistyle"32 "github.com/bnema/nefercap/internal/adapters/uistyle"
30 "github.com/bnema/nefercap/internal/ports"33 "github.com/bnema/nefercap/internal/ports"
@@ -67,16 +70,20 @@ type Indicator struct {
67 // place. Set it before Run.70 // place. Set it before Run.
68 Frame ports.Region71 Frame ports.Region
69 72
70- authorize func(context.Context, nefergui.WaylandSurface) error73+ authorize Authorize
71 stop chan<- struct{}74 stop chan<- struct{}
72 }75 }
73 76
77+// Authorize registers the HUD's surface with the compositor's capture
78+// exclusion; see layerui.Config.OnSurface.
79+type Authorize = func(ctx context.Context, display *wlturbo.Display, surface wlturbo.Proxy) error
80+
74 // New returns an indicator. authorize is required and runs once per Run, on81 // New returns an indicator. authorize is required and runs once per Run, on
75-// the window's owner loop; its context ends when Run's does, so a blocking wait82+// its own goroutine; its context ends when Run's does, so a blocking wait
76 // for the compositor's acknowledgement is interrupted by cancellation. stop83 // for the compositor's acknowledgement is interrupted by cancellation. stop
77 // should be buffered; a click sends one non-blocking value. The caller reacts84 // should be buffered; a click sends one non-blocking value. The caller reacts
78 // by ending the recording.85 // by ending the recording.
79-func New(authorize func(context.Context, nefergui.WaylandSurface) error, stop chan<- struct{}) *Indicator {86+func New(authorize Authorize, stop chan<- struct{}) *Indicator {
80 return &Indicator{authorize: authorize, stop: stop}87 return &Indicator{authorize: authorize, stop: stop}
81 }88 }
82 89
@@ -117,15 +124,12 @@ func (i *Indicator) run(ctx context.Context, output ports.Output, start time.Tim
117 go func() { defer wg.Done(); ticker(child, wake) }()124 go func() { defer wg.Done(); ticker(child, wake) }()
118 defer func() { cancel(); wg.Wait() }()125 defer func() { cancel(); wg.Wait() }()
119 126
120- runErr := nefergui.Run(child, newHUD(start, i.stop, i.TargetLabel), view,127+ runErr := layerui.Run(child, layerui.Config{
121- nefergui.Title("NeferCap recording"),128+ Layer: layerConfig(output.Name, i.Frame),
122- nefergui.Size(Width, Height),129+ Styles: sheet,
123- nefergui.Styles(sheet),130+ Wake: wake,
124- nefergui.Transparent(),131+ OnSurface: i.authorize,
125- nefergui.Layer(layerConfig(output.Name, i.Frame)),132+ }, newHUD(start, i.stop, i.TargetLabel), view)
126- nefergui.OnSurface(i.authorize),
127- nefergui.Wake(wake),
128- )
129 // Run is only ever ended by ctx: the HUD never decides by itself. Read the133 // Run is only ever ended by ctx: the HUD never decides by itself. Read the
130 // parent once; a bare Canceled is then our own cancellation, and anything134 // parent once; a bare Canceled is then our own cancellation, and anything
131 // else, including an end with a live parent, is a failure that keeps every135 // else, including an end with a live parent, is a failure that keeps every
@@ -138,26 +142,28 @@ func (i *Indicator) run(ctx context.Context, output ports.Output, start time.Tim
138 // pointer input only inside the Stop button. Centre is that of the output, or142 // pointer input only inside the Stop button. Centre is that of the output, or
139 // of frame when it is set: then the HUD is anchored to the top left of the143 // of frame when it is set: then the HUD is anchored to the top left of the
140 // output and moved by margins.144 // output and moved by margins.
141-func layerConfig(output string, frame ports.Region) nefergui.LayerConfig {145+func layerConfig(output string, frame ports.Region) neferclient.LayerConfig {
142- c := nefergui.LayerConfig{146+ c := neferclient.LayerConfig{
143 Output: output,147 Output: output,
144 Namespace: namespace,148 Namespace: namespace,
145- Level: nefergui.LayerOverlay,149+ Level: neferclient.LayerOverlay,
146- Anchors: nefergui.AnchorTop,150+ Anchors: neferclient.AnchorTop,
147- Keyboard: nefergui.KeyboardNone,151+ Keyboard: neferclient.KeyboardNone,
148 ExclusiveZone: -1,152 ExclusiveZone: -1,
149 Margin: [4]int32{topMargin, 0, 0, 0},153 Margin: [4]int32{topMargin, 0, 0, 0},
150- InputRects: []nefergui.Rect{stopRect()},154+ InputRects: []neferclient.Rect{stopRect()},
155+ Width: Width,
156+ Height: Height,
151 }157 }
152 if frame.Width > 0 && frame.Height > 0 {158 if frame.Width > 0 && frame.Height > 0 {
153- c.Anchors = nefergui.AnchorTop | nefergui.AnchorLeft159+ c.Anchors = neferclient.AnchorTop | neferclient.AnchorLeft
154 c.Margin = [4]int32{int32(frame.Y) + topMargin, 0, 0, int32(frame.X + max((frame.Width-Width)/2, 0))}160 c.Margin = [4]int32{int32(frame.Y) + topMargin, 0, 0, int32(frame.X + max((frame.Width-Width)/2, 0))}
155 }161 }
156 return c162 return c
157 }163 }
158 164
159-func stopRect() nefergui.Rect {165+func stopRect() neferclient.Rect {
160- return nefergui.Rect{X: Width - stopWidth, Y: 0, Width: stopWidth, Height: Height}166+ return neferclient.Rect{X: Width - stopWidth, Y: 0, Width: stopWidth, Height: Height}
161 }167 }
162 168
163 // ticker sends a non-blocking wake token every second until ctx ends.169 // ticker sends a non-blocking wake token every second until ctx ends.
M internal/adapters/indicator/indicator_test.go +12 -10
@@ -6,7 +6,8 @@ import (
66 "testing"
77 "time"
88
9- "github.com/bnema/nefergui"
9+ "github.com/bnema/neferclient"
10+ "github.com/bnema/wlturbo"
1011 "github.com/stretchr/testify/assert"
1112 "github.com/stretchr/testify/require"
1213
@@ -123,27 +124,28 @@ func TestRequestStopNeverBlocks(t *testing.T) {
123124
124125 func TestLayerConfigTakesNoFocusAndOnlyStopInput(t *testing.T) {
125126 c := layerConfig("DP-1", ports.Region{})
126- assert.Equal(t, nefergui.KeyboardNone, c.Keyboard)
127- assert.Equal(t, nefergui.LayerOverlay, c.Level)
128- assert.Equal(t, nefergui.AnchorTop, c.Anchors)
127+ assert.Equal(t, neferclient.KeyboardNone, c.Keyboard)
128+ assert.Equal(t, neferclient.LayerOverlay, c.Level)
129+ assert.Equal(t, neferclient.AnchorTop, c.Anchors)
130+ assert.Equal(t, [2]int32{Width, Height}, [2]int32{c.Width, c.Height}, "the compositor needs the size on unanchored axes")
129131 assert.Equal(t, int32(-1), c.ExclusiveZone)
130132 require.Len(t, c.InputRects, 1)
131133 r := c.InputRects[0]
132- assert.Equal(t, nefergui.Rect{X: Width - stopWidth, Width: stopWidth, Height: Height}, r)
134+ assert.Equal(t, neferclient.Rect{X: Width - stopWidth, Width: stopWidth, Height: Height}, r)
133135 assert.LessOrEqual(t, r.X+r.Width, int32(Width))
134136 }
135137
136138 func TestLayerConfigCentresOnTheRecordedFrame(t *testing.T) {
137139 c := layerConfig("DP-1", ports.Region{X: 100, Y: 50, Width: 1000, Height: 600})
138- assert.Equal(t, nefergui.AnchorTop|nefergui.AnchorLeft, c.Anchors)
140+ assert.Equal(t, neferclient.AnchorTop|neferclient.AnchorLeft, c.Anchors)
139141 assert.Equal(t, [4]int32{50 + topMargin, 0, 0, 100 + (1000-Width)/2}, c.Margin)
140- assert.Equal(t, nefergui.KeyboardNone, c.Keyboard)
142+ assert.Equal(t, neferclient.KeyboardNone, c.Keyboard)
141143 // A frame narrower than the HUD starts at its left edge.
142144 c = layerConfig("DP-1", ports.Region{X: 7, Width: 100, Height: 100})
143145 assert.Equal(t, int32(7), c.Margin[3])
144146 // No frame: the whole output, centred by the compositor.
145147 c = layerConfig("DP-1", ports.Region{})
146- assert.Equal(t, nefergui.AnchorTop, c.Anchors)
148+ assert.Equal(t, neferclient.AnchorTop, c.Anchors)
147149 assert.Equal(t, [4]int32{topMargin, 0, 0, 0}, c.Margin)
148150 }
149151
@@ -155,7 +157,7 @@ func TestHUDReadabilityAndStopGeometry(t *testing.T) {
155157 assert.Contains(t, styleSheet, "width: 5.625rem")
156158 assert.Contains(t, styleSheet, "line-height: 2.5rem")
157159 assert.Contains(t, styleSheet, "padding: 0; line-height:")
158- assert.Equal(t, nefergui.Rect{X: 294, Y: 0, Width: 90, Height: 40}, stopRect())
160+ assert.Equal(t, neferclient.Rect{X: 294, Y: 0, Width: 90, Height: 40}, stopRect())
159161 }
160162
161163 func BenchmarkRefreshUnchanged(b *testing.B) {
@@ -170,7 +172,7 @@ func BenchmarkRefreshUnchanged(b *testing.B) {
170172
171173 func TestRunRejectsBadSetup(t *testing.T) {
172174 stop := make(chan struct{}, 1)
173- ok := func(context.Context, nefergui.WaylandSurface) error { return nil }
175+ ok := func(context.Context, *wlturbo.Display, wlturbo.Proxy) error { return nil }
174176 out := ports.Output{ID: 1, Name: "DP-1"}
175177
176178 assert.ErrorIs(t, New(nil, stop).Run(context.Background(), out), ErrNoAuthorize)
@@ -6,7 +6,8 @@ import (
6 "testing"6 "testing"
7 "time"7 "time"
8 8
9- "github.com/bnema/nefergui"9+ "github.com/bnema/neferclient"
10+ "github.com/bnema/wlturbo"
10 "github.com/stretchr/testify/assert"11 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
12 13
@@ -123,27 +124,28 @@ func TestRequestStopNeverBlocks(t *testing.T) {
123 124
124 func TestLayerConfigTakesNoFocusAndOnlyStopInput(t *testing.T) {125 func TestLayerConfigTakesNoFocusAndOnlyStopInput(t *testing.T) {
125 c := layerConfig("DP-1", ports.Region{})126 c := layerConfig("DP-1", ports.Region{})
126- assert.Equal(t, nefergui.KeyboardNone, c.Keyboard)127+ assert.Equal(t, neferclient.KeyboardNone, c.Keyboard)
127- assert.Equal(t, nefergui.LayerOverlay, c.Level)128+ assert.Equal(t, neferclient.LayerOverlay, c.Level)
128- assert.Equal(t, nefergui.AnchorTop, c.Anchors)129+ assert.Equal(t, neferclient.AnchorTop, c.Anchors)
130+ assert.Equal(t, [2]int32{Width, Height}, [2]int32{c.Width, c.Height}, "the compositor needs the size on unanchored axes")
129 assert.Equal(t, int32(-1), c.ExclusiveZone)131 assert.Equal(t, int32(-1), c.ExclusiveZone)
130 require.Len(t, c.InputRects, 1)132 require.Len(t, c.InputRects, 1)
131 r := c.InputRects[0]133 r := c.InputRects[0]
132- assert.Equal(t, nefergui.Rect{X: Width - stopWidth, Width: stopWidth, Height: Height}, r)134+ assert.Equal(t, neferclient.Rect{X: Width - stopWidth, Width: stopWidth, Height: Height}, r)
133 assert.LessOrEqual(t, r.X+r.Width, int32(Width))135 assert.LessOrEqual(t, r.X+r.Width, int32(Width))
134 }136 }
135 137
136 func TestLayerConfigCentresOnTheRecordedFrame(t *testing.T) {138 func TestLayerConfigCentresOnTheRecordedFrame(t *testing.T) {
137 c := layerConfig("DP-1", ports.Region{X: 100, Y: 50, Width: 1000, Height: 600})139 c := layerConfig("DP-1", ports.Region{X: 100, Y: 50, Width: 1000, Height: 600})
138- assert.Equal(t, nefergui.AnchorTop|nefergui.AnchorLeft, c.Anchors)140+ assert.Equal(t, neferclient.AnchorTop|neferclient.AnchorLeft, c.Anchors)
139 assert.Equal(t, [4]int32{50 + topMargin, 0, 0, 100 + (1000-Width)/2}, c.Margin)141 assert.Equal(t, [4]int32{50 + topMargin, 0, 0, 100 + (1000-Width)/2}, c.Margin)
140- assert.Equal(t, nefergui.KeyboardNone, c.Keyboard)142+ assert.Equal(t, neferclient.KeyboardNone, c.Keyboard)
141 // A frame narrower than the HUD starts at its left edge.143 // A frame narrower than the HUD starts at its left edge.
142 c = layerConfig("DP-1", ports.Region{X: 7, Width: 100, Height: 100})144 c = layerConfig("DP-1", ports.Region{X: 7, Width: 100, Height: 100})
143 assert.Equal(t, int32(7), c.Margin[3])145 assert.Equal(t, int32(7), c.Margin[3])
144 // No frame: the whole output, centred by the compositor.146 // No frame: the whole output, centred by the compositor.
145 c = layerConfig("DP-1", ports.Region{})147 c = layerConfig("DP-1", ports.Region{})
146- assert.Equal(t, nefergui.AnchorTop, c.Anchors)148+ assert.Equal(t, neferclient.AnchorTop, c.Anchors)
147 assert.Equal(t, [4]int32{topMargin, 0, 0, 0}, c.Margin)149 assert.Equal(t, [4]int32{topMargin, 0, 0, 0}, c.Margin)
148 }150 }
149 151
@@ -155,7 +157,7 @@ func TestHUDReadabilityAndStopGeometry(t *testing.T) {
155 assert.Contains(t, styleSheet, "width: 5.625rem")157 assert.Contains(t, styleSheet, "width: 5.625rem")
156 assert.Contains(t, styleSheet, "line-height: 2.5rem")158 assert.Contains(t, styleSheet, "line-height: 2.5rem")
157 assert.Contains(t, styleSheet, "padding: 0; line-height:")159 assert.Contains(t, styleSheet, "padding: 0; line-height:")
158- assert.Equal(t, nefergui.Rect{X: 294, Y: 0, Width: 90, Height: 40}, stopRect())160+ assert.Equal(t, neferclient.Rect{X: 294, Y: 0, Width: 90, Height: 40}, stopRect())
159 }161 }
160 162
161 func BenchmarkRefreshUnchanged(b *testing.B) {163 func BenchmarkRefreshUnchanged(b *testing.B) {
@@ -170,7 +172,7 @@ func BenchmarkRefreshUnchanged(b *testing.B) {
170 172
171 func TestRunRejectsBadSetup(t *testing.T) {173 func TestRunRejectsBadSetup(t *testing.T) {
172 stop := make(chan struct{}, 1)174 stop := make(chan struct{}, 1)
173- ok := func(context.Context, nefergui.WaylandSurface) error { return nil }175+ ok := func(context.Context, *wlturbo.Display, wlturbo.Proxy) error { return nil }
174 out := ports.Output{ID: 1, Name: "DP-1"}176 out := ports.Output{ID: 1, Name: "DP-1"}
175 177
176 assert.ErrorIs(t, New(nil, stop).Run(context.Background(), out), ErrNoAuthorize)178 assert.ErrorIs(t, New(nil, stop).Run(context.Background(), out), ErrNoAuthorize)
M internal/adapters/indicator/smoke_test.go +3 -3
@@ -6,7 +6,7 @@ import (
66 "testing"
77 "time"
88
9- "github.com/bnema/nefergui"
9+ "github.com/bnema/wlturbo"
1010 "github.com/stretchr/testify/assert"
1111 "github.com/stretchr/testify/require"
1212
@@ -31,8 +31,8 @@ func TestSmokeRun(t *testing.T) {
3131 defer cancel()
3232 stop := make(chan struct{}, 1)
3333 var authorized bool
34- ind := New(func(_ context.Context, s nefergui.WaylandSurface) error {
35- authorized = s.Display != nil && s.Surface != nil
34+ ind := New(func(_ context.Context, display *wlturbo.Display, surface wlturbo.Proxy) error {
35+ authorized = display != nil && surface != nil && surface.Context() == display.Context()
3636 return nil
3737 }, stop)
3838 done := make(chan error, 1)
@@ -6,7 +6,7 @@ import (
6 "testing"6 "testing"
7 "time"7 "time"
8 8
9- "github.com/bnema/nefergui"9+ "github.com/bnema/wlturbo"
10 "github.com/stretchr/testify/assert"10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
12 12
@@ -31,8 +31,8 @@ func TestSmokeRun(t *testing.T) {
31 defer cancel()31 defer cancel()
32 stop := make(chan struct{}, 1)32 stop := make(chan struct{}, 1)
33 var authorized bool33 var authorized bool
34- ind := New(func(_ context.Context, s nefergui.WaylandSurface) error {34+ ind := New(func(_ context.Context, display *wlturbo.Display, surface wlturbo.Proxy) error {
35- authorized = s.Display != nil && s.Surface != nil35+ authorized = display != nil && surface != nil && surface.Context() == display.Context()
36 return nil36 return nil
37 }, stop)37 }, stop)
38 done := make(chan error, 1)38 done := make(chan error, 1)
A internal/adapters/layerui/layerui.go +467 -0
@@ -0,0 +1,467 @@
1+// Package layerui shows one NeferGUI view on a layer surface: neferclient owns
2+// the Wayland connection and surface, nefergui.Renderer draws into DMA-BUF
3+// buffers, and this package copies plain fields between the two. Each Run has
4+// its own connection and runs everything on the calling goroutine.
5+package layerui
6+
7+import (
8+ "context"
9+ "errors"
10+ "fmt"
11+ "time"
12+
13+ "github.com/bnema/neferclient"
14+ "github.com/bnema/nefergui"
15+ "github.com/bnema/wlturbo"
16+)
17+
18+// guiMods is the set of modifiers NeferGUI knows. Both libraries use the same
19+// bit order, so the plain bits can be copied.
20+const guiMods = neferclient.ModShift | neferclient.ModCtrl | neferclient.ModAlt | neferclient.ModSuper |
21+ neferclient.ModCapsLock | neferclient.ModNumLock
22+
23+// pendingRetry is how soon a frame that waits for the GPU is tried again.
24+const pendingRetry = 2 * time.Millisecond
25+
26+// Config describes one layer surface and the hooks of its owner loop. Every
27+// hook but OnSurface runs on the goroutine that called Run.
28+type Config struct {
29+ Layer neferclient.LayerConfig
30+ Styles string // CSS file path for nefergui.RendererConfig.Styles
31+
32+ // Wake redraws the view for each value received.
33+ Wake <-chan struct{}
34+ // OnResize reports the logical size and scale at the first configure and
35+ // on every change.
36+ OnResize func(width, height int, scale float64)
37+ // OnInput sees every input event before NeferGUI routes it to controls;
38+ // returning true redraws the view.
39+ OnInput func(nefergui.Input) bool
40+ // OnSurface runs once, after the layer is configured and before its first
41+ // buffer is shown, with the connection's display and the layer's
42+ // wl_surface. An error ends Run and nothing is shown. It runs on its own
43+ // goroutine while the owner loop keeps applying events, so it may wait for
44+ // the compositor's answer to a request of its own; it must only send
45+ // requests, and return when ctx ends: Run waits for it.
46+ //
47+ // The input region starts as Layer.InputRects; a view's
48+ // Frame.SetInputRects replaces it.
49+ OnSurface func(ctx context.Context, display *wlturbo.Display, surface wlturbo.Proxy) error
50+}
51+
52+// Run shows view on a transparent layer surface until ctx ends (it returns
53+// ctx.Err()), the compositor closes the surface (nil) or something fails.
54+func Run[T any](ctx context.Context, cfg Config, model *T, view func(*nefergui.Frame, *T)) (err error) {
55+ conn, err := neferclient.Connect(ctx, "") // WAYLAND_DISPLAY
56+ if err != nil {
57+ return fmt.Errorf("connect: %w", err)
58+ }
59+ l := &loop[T]{ctx: ctx, cfg: cfg, conn: conn, model: model, view: view, cursor: neferclient.CursorDefault}
60+ // Close the connection first (it destroys every Wayland object), then
61+ // free the renderer and its GPU resources.
62+ defer func() {
63+ err = errors.Join(err, conn.Close())
64+ if l.r != nil {
65+ err = errors.Join(err, l.r.Close())
66+ }
67+ }()
68+ if l.surf, err = conn.NewLayerSurface(cfg.Layer); err != nil {
69+ return fmt.Errorf("layer surface: %w", err)
70+ }
71+ l.seat = conn.Seat()
72+ defer l.waitHook()
73+ return l.run()
74+}
75+
76+type loop[T any] struct {
77+ neferclient.NopHandler
78+
79+ ctx context.Context
80+ cfg Config
81+ conn *neferclient.Conn
82+ surf *neferclient.Surface
83+ seat *neferclient.Seat
84+ model *T
85+ view func(*nefergui.Frame, *T)
86+
87+ r *nefergui.Renderer
88+ rwake <-chan struct{} // Renderer.Wake: nil until the renderer exists
89+ out nefergui.Output
90+ damage []neferclient.Rect
91+ region []neferclient.Rect // reused by setInputRegion
92+
93+ w, h int32
94+ scale float64
95+ configured bool
96+ canPresent bool // configured, and the last frame callback fired
97+ authorized bool // OnSurface succeeded, or there is none
98+ hookStarted bool
99+ hookDone chan error // OnSurface's result while it is pending
100+ hookCancel context.CancelFunc
101+ haveAcquire bool
102+ cursor neferclient.CursorShape
103+
104+ closed bool
105+ err error
106+}
107+
108+func (l *loop[T]) run() error {
109+ l.authorized = l.cfg.OnSurface == nil
110+ retry := time.NewTimer(pendingRetry) // reused: Pending can last many frames
111+ retry.Stop()
112+ defer retry.Stop()
113+ var tick <-chan time.Time
114+ for !l.closed {
115+ select {
116+ case <-l.ctx.Done():
117+ return l.ctx.Err()
118+ case <-l.conn.Wake():
119+ if err := l.conn.Dispatch(l); err != nil {
120+ return fmt.Errorf("dispatch: %w", err)
121+ }
122+ case _, ok := <-l.cfg.Wake:
123+ if !ok {
124+ l.cfg.Wake = nil // a closed channel would spin
125+ } else if l.r != nil {
126+ l.r.Invalidate()
127+ }
128+ case err := <-l.hookDone:
129+ l.hookDone = nil
130+ if err != nil {
131+ return err
132+ }
133+ l.authorized = true
134+ case <-l.rwake: // another goroutine asked for a frame
135+ case <-tick: // a built frame was waiting for the GPU
136+ }
137+ if l.err != nil {
138+ return l.err
139+ }
140+ if l.closed {
141+ return nil
142+ }
143+ if err := l.draw(); err != nil {
144+ return err
145+ }
146+ tick = nil
147+ if l.r != nil && l.r.Pending() {
148+ retry.Reset(pendingRetry)
149+ tick = retry.C
150+ }
151+ }
152+ return nil
153+}
154+
155+// FeedbackDone: dmabuf feedback → nefergui.RendererConfig. Only the first
156+// complete feedback is used: one GPU is assumed.
157+func (l *loop[T]) FeedbackDone(neferclient.SurfaceID) { l.setup() }
158+
159+func (l *loop[T]) Configure(neferclient.SurfaceID, int32, int32) {
160+ if !l.configured { // later configures must not lift the frame-callback gate
161+ l.canPresent = true
162+ }
163+ l.configured = true
164+ l.resize()
165+ l.setup()
166+}
167+
168+func (l *loop[T]) Scale(neferclient.SurfaceID, float64) { l.resize() }
169+
170+func (l *loop[T]) setup() {
171+ fb := l.surf.Feedback()
172+ if l.r != nil || !l.configured || fb == nil {
173+ return
174+ }
175+ cfg := nefergui.RendererConfig{MainDevice: fb.MainDevice, Transparent: true, Styles: l.cfg.Styles,
176+ Formats: make([]nefergui.Format, len(fb.Formats))}
177+ for i, f := range fb.Formats {
178+ cfg.Formats[i] = nefergui.Format{FourCC: f.FourCC, Modifier: f.Modifier}
179+ }
180+ r, err := nefergui.NewRenderer(cfg)
181+ if err != nil {
182+ l.fail(fmt.Errorf("renderer: %w", err))
183+ return
184+ }
185+ l.r, l.rwake = r, r.Wake()
186+ w, h, scale := l.surf.Size()
187+ r.Resize(int(w), int(h), scale)
188+}
189+
190+// resize forwards a changed logical size or scale to OnResize and the
191+// renderer. Before the first configure the size is not known yet.
192+func (l *loop[T]) resize() {
193+ if !l.configured {
194+ return
195+ }
196+ w, h, scale := l.surf.Size()
197+ if w == l.w && h == l.h && scale == l.scale {
198+ return
199+ }
200+ l.w, l.h, l.scale = w, h, scale
201+ if l.cfg.OnResize != nil {
202+ l.cfg.OnResize(int(w), int(h), scale)
203+ }
204+ if l.r != nil {
205+ l.r.Resize(int(w), int(h), scale)
206+ }
207+}
208+
209+// Frame: the compositor showed the last commit; the next Present is allowed.
210+func (l *loop[T]) Frame(neferclient.SurfaceID) { l.canPresent = true }
211+
212+func (l *loop[T]) Closed(neferclient.SurfaceID) { l.closed = true }
213+
214+func (l *loop[T]) Error(err error) { l.fail(err) }
215+
216+func (l *loop[T]) fail(err error) {
217+ if l.err == nil {
218+ l.err = err
219+ }
220+}
221+
222+// FDReady: a buffer's release eventfd is readable (id is the buffer).
223+func (l *loop[T]) FDReady(id uint64) {
224+ if err := l.r.Released(id); err != nil {
225+ l.fail(fmt.Errorf("release buffer %d: %w", id, err))
226+ }
227+}
228+
229+func (l *loop[T]) Pointer(ev *neferclient.PointerEvent) {
230+ in, ok := pointerInput(ev)
231+ if !ok {
232+ return
233+ }
234+ if ev.Kind == neferclient.PointerEnter {
235+ // The cursor shape is only valid after the enter: set it again.
236+ if err := l.seat.SetCursor(l.cursor); err != nil {
237+ l.fail(fmt.Errorf("cursor: %w", err))
238+ }
239+ }
240+ l.input(&in)
241+}
242+
243+func (l *loop[T]) Key(ev *neferclient.KeyEvent) {
244+ in := keyInput(ev)
245+ l.input(&in)
246+}
247+
248+func (l *loop[T]) KeyboardFocus(_ neferclient.SurfaceID, focused bool) {
249+ in := nefergui.Input{Kind: nefergui.InputFocusOut}
250+ if focused {
251+ in.Kind = nefergui.InputFocusIn
252+ }
253+ l.input(&in)
254+}
255+
256+// input gives ev to OnInput, then to NeferGUI. Before the renderer exists
257+// nothing is shown, so nothing is delivered.
258+func (l *loop[T]) input(ev *nefergui.Input) {
259+ if l.r == nil {
260+ return
261+ }
262+ if l.cfg.OnInput != nil && l.cfg.OnInput(*ev) {
263+ l.r.Invalidate()
264+ }
265+ l.r.Input(ev)
266+}
267+
268+// pointerInput converts a neferclient pointer event; ok is false for events
269+// NeferGUI does not take.
270+func pointerInput(ev *neferclient.PointerEvent) (in nefergui.Input, ok bool) {
271+ in = nefergui.Input{X: ev.X, Y: ev.Y}
272+ switch ev.Kind {
273+ case neferclient.PointerEnter, neferclient.PointerMotion:
274+ in.Kind = nefergui.InputPointerMotion
275+ case neferclient.PointerLeave:
276+ in.Kind = nefergui.InputPointerLeave
277+ case neferclient.PointerButton:
278+ in.Kind, in.Button, in.Pressed = nefergui.InputPointerRelease, ev.Button, ev.Pressed
279+ if ev.Pressed {
280+ in.Kind = nefergui.InputPointerPress
281+ }
282+ case neferclient.PointerAxis:
283+ in.Kind, in.DX, in.DY = nefergui.InputPointerAxis, ev.DX, ev.DY
284+ default:
285+ return nefergui.Input{}, false
286+ }
287+ return in, true
288+}
289+
290+// keyInput converts a neferclient key event. Text aliases neferclient storage
291+// that is valid only during the Key call, as NeferGUI requires.
292+func keyInput(ev *neferclient.KeyEvent) nefergui.Input {
293+ return nefergui.Input{
294+ Kind: nefergui.InputKey,
295+ Keysym: ev.Keysym,
296+ Text: ev.Text,
297+ Pressed: ev.Pressed,
298+ Repeat: ev.Repeat,
299+ Modifiers: nefergui.Modifiers(ev.Modifiers & guiMods),
300+ }
301+}
302+
303+// draw renders a frame when something changed and presents it.
304+func (l *loop[T]) draw() error {
305+ if l.r == nil || !l.canPresent {
306+ return nil
307+ }
308+ if !l.authorized {
309+ l.startHook()
310+ return nil
311+ }
312+ ok, err := l.r.Render(&l.out, l.model, l.view)
313+ if err != nil {
314+ return fmt.Errorf("render: %w", err)
315+ }
316+ if !ok {
317+ return nil
318+ }
319+ if err = l.setInputRegion(); err != nil {
320+ return err
321+ }
322+ if err = l.present(); err != nil {
323+ return err
324+ }
325+ return l.setCursor()
326+}
327+
328+// setInputRegion: Output.InputRects → Surface.SetInputRegion, only when the
329+// view changed it. Both are logical surface pixels; nil means the whole
330+// surface and an empty slice click-through in both libraries.
331+func (l *loop[T]) setInputRegion() error {
332+ region, changed := inputRegion(l.region[:0], &l.out)
333+ if !changed {
334+ return nil
335+ }
336+ if region != nil {
337+ l.region = region
338+ }
339+ if err := l.surf.SetInputRegion(region); err != nil {
340+ return fmt.Errorf("input region: %w", err)
341+ }
342+ return nil
343+}
344+
345+// inputRegion converts out's input region into dst; changed is false when
346+// the view did not change it.
347+func inputRegion(dst []neferclient.Rect, out *nefergui.Output) (region []neferclient.Rect, changed bool) {
348+ if !out.InputRectsChanged {
349+ return nil, false
350+ }
351+ if out.InputRects == nil {
352+ return nil, true
353+ }
354+ if dst == nil {
355+ dst = []neferclient.Rect{}
356+ }
357+ for _, r := range out.InputRects {
358+ dst = append(dst, neferclient.Rect{X: r.X, Y: r.Y, Width: r.Width, Height: r.Height})
359+ }
360+ return dst, true
361+}
362+
363+// startHook starts OnSurface once. The owner loop keeps dispatching while it
364+// waits: neferclient's reader stops reading when the event queue is full.
365+func (l *loop[T]) startHook() {
366+ if l.hookStarted {
367+ return
368+ }
369+ l.hookStarted = true
370+ ctx, cancel := context.WithCancel(l.ctx)
371+ done := make(chan error, 1)
372+ l.hookDone, l.hookCancel = done, cancel
373+ display, surface := l.conn.Display(), l.surf.WLSurface()
374+ go func() { done <- l.cfg.OnSurface(ctx, display, surface) }()
375+}
376+
377+// waitHook ends a pending OnSurface and waits for it before the connection
378+// closes.
379+func (l *loop[T]) waitHook() {
380+ if l.hookCancel == nil {
381+ return
382+ }
383+ l.hookCancel()
384+ if l.hookDone != nil {
385+ <-l.hookDone
386+ }
387+}
388+
389+// present: Renderer.Render output → ImportBuffer, ImportTimeline, Present.
390+func (l *loop[T]) present() error {
391+ out := &l.out
392+ // A resize retired buffers: stop watching and destroy them first.
393+ for _, rt := range out.Retired {
394+ if err := l.conn.UnwatchFD(rt.ReleaseFD); err != nil {
395+ return fmt.Errorf("unwatch retired buffer %d: %w", rt.Buffer, err)
396+ }
397+ if err := l.surf.DestroyBuffer(rt.Buffer); err != nil {
398+ return fmt.Errorf("destroy retired buffer %d: %w", rt.Buffer, err)
399+ }
400+ // The release timeline of a buffer is imported under the buffer's id.
401+ if err := l.surf.DestroyTimeline(rt.Buffer); err != nil {
402+ return fmt.Errorf("destroy retired timeline %d: %w", rt.Buffer, err)
403+ }
404+ }
405+ if out.NewBuffer {
406+ buf := neferclient.Buffer{
407+ Width: out.Width, Height: out.Height, FourCC: out.FourCC, Modifier: out.Modifier,
408+ PlaneCount: out.PlaneCount,
409+ }
410+ for i, p := range out.Planes {
411+ buf.Planes[i] = neferclient.Plane{FD: p.FD, Offset: p.Offset, Stride: p.Stride}
412+ }
413+ if err := l.surf.ImportBuffer(out.Buffer, &buf); err != nil {
414+ return fmt.Errorf("import buffer: %w", err)
415+ }
416+ // The release eventfd is stable per buffer: watch it once.
417+ if err := l.conn.WatchFD(out.ReleaseFD, out.Buffer); err != nil {
418+ return fmt.Errorf("watch release fd: %w", err)
419+ }
420+ }
421+ if out.NewTimelines {
422+ if !l.haveAcquire { // the acquire timeline is shared by every buffer
423+ if err := l.surf.ImportTimeline(out.Acquire.ID, out.Acquire.FD); err != nil {
424+ return fmt.Errorf("import acquire timeline: %w", err)
425+ }
426+ l.haveAcquire = true
427+ }
428+ if err := l.surf.ImportTimeline(out.Release.ID, out.Release.FD); err != nil {
429+ return fmt.Errorf("import release timeline: %w", err)
430+ }
431+ }
432+ l.damage = l.damage[:0]
433+ for _, d := range out.Damage {
434+ l.damage = append(l.damage, neferclient.Rect{X: d.X, Y: d.Y, Width: d.Width, Height: d.Height})
435+ }
436+ err := l.surf.Present(&neferclient.Present{
437+ Buffer: out.Buffer,
438+ AcquireTimeline: out.Acquire.ID,
439+ ReleaseTimeline: out.Release.ID,
440+ AcquirePoint: out.AcquirePoint,
441+ ReleasePoint: out.ReleasePoint,
442+ Damage: l.damage,
443+ })
444+ if err != nil {
445+ return fmt.Errorf("present: %w", err)
446+ }
447+ l.canPresent = false // until Frame
448+ return nil
449+}
450+
451+// setCursor: the cursor the hovered element asked for → Seat.SetCursor.
452+func (l *loop[T]) setCursor() error {
453+ switch l.out.Cursor {
454+ case nefergui.CursorPointer:
455+ l.cursor = neferclient.CursorPointer
456+ case nefergui.CursorText:
457+ l.cursor = neferclient.CursorText
458+ case nefergui.CursorNotAllowed:
459+ l.cursor = neferclient.CursorNotAllowed
460+ default:
461+ l.cursor = neferclient.CursorDefault
462+ }
463+ if err := l.seat.SetCursor(l.cursor); err != nil {
464+ return fmt.Errorf("cursor: %w", err)
465+ }
466+ return nil
467+}
@@ -0,0 +1,467 @@
1+// Package layerui shows one NeferGUI view on a layer surface: neferclient owns
2+// the Wayland connection and surface, nefergui.Renderer draws into DMA-BUF
3+// buffers, and this package copies plain fields between the two. Each Run has
4+// its own connection and runs everything on the calling goroutine.
5+package layerui
6+
7+import (
8+ "context"
9+ "errors"
10+ "fmt"
11+ "time"
12+
13+ "github.com/bnema/neferclient"
14+ "github.com/bnema/nefergui"
15+ "github.com/bnema/wlturbo"
16+)
17+
18+// guiMods is the set of modifiers NeferGUI knows. Both libraries use the same
19+// bit order, so the plain bits can be copied.
20+const guiMods = neferclient.ModShift | neferclient.ModCtrl | neferclient.ModAlt | neferclient.ModSuper |
21+ neferclient.ModCapsLock | neferclient.ModNumLock
22+
23+// pendingRetry is how soon a frame that waits for the GPU is tried again.
24+const pendingRetry = 2 * time.Millisecond
25+
26+// Config describes one layer surface and the hooks of its owner loop. Every
27+// hook but OnSurface runs on the goroutine that called Run.
28+type Config struct {
29+ Layer neferclient.LayerConfig
30+ Styles string // CSS file path for nefergui.RendererConfig.Styles
31+
32+ // Wake redraws the view for each value received.
33+ Wake <-chan struct{}
34+ // OnResize reports the logical size and scale at the first configure and
35+ // on every change.
36+ OnResize func(width, height int, scale float64)
37+ // OnInput sees every input event before NeferGUI routes it to controls;
38+ // returning true redraws the view.
39+ OnInput func(nefergui.Input) bool
40+ // OnSurface runs once, after the layer is configured and before its first
41+ // buffer is shown, with the connection's display and the layer's
42+ // wl_surface. An error ends Run and nothing is shown. It runs on its own
43+ // goroutine while the owner loop keeps applying events, so it may wait for
44+ // the compositor's answer to a request of its own; it must only send
45+ // requests, and return when ctx ends: Run waits for it.
46+ //
47+ // The input region starts as Layer.InputRects; a view's
48+ // Frame.SetInputRects replaces it.
49+ OnSurface func(ctx context.Context, display *wlturbo.Display, surface wlturbo.Proxy) error
50+}
51+
52+// Run shows view on a transparent layer surface until ctx ends (it returns
53+// ctx.Err()), the compositor closes the surface (nil) or something fails.
54+func Run[T any](ctx context.Context, cfg Config, model *T, view func(*nefergui.Frame, *T)) (err error) {
55+ conn, err := neferclient.Connect(ctx, "") // WAYLAND_DISPLAY
56+ if err != nil {
57+ return fmt.Errorf("connect: %w", err)
58+ }
59+ l := &loop[T]{ctx: ctx, cfg: cfg, conn: conn, model: model, view: view, cursor: neferclient.CursorDefault}
60+ // Close the connection first (it destroys every Wayland object), then
61+ // free the renderer and its GPU resources.
62+ defer func() {
63+ err = errors.Join(err, conn.Close())
64+ if l.r != nil {
65+ err = errors.Join(err, l.r.Close())
66+ }
67+ }()
68+ if l.surf, err = conn.NewLayerSurface(cfg.Layer); err != nil {
69+ return fmt.Errorf("layer surface: %w", err)
70+ }
71+ l.seat = conn.Seat()
72+ defer l.waitHook()
73+ return l.run()
74+}
75+
76+type loop[T any] struct {
77+ neferclient.NopHandler
78+
79+ ctx context.Context
80+ cfg Config
81+ conn *neferclient.Conn
82+ surf *neferclient.Surface
83+ seat *neferclient.Seat
84+ model *T
85+ view func(*nefergui.Frame, *T)
86+
87+ r *nefergui.Renderer
88+ rwake <-chan struct{} // Renderer.Wake: nil until the renderer exists
89+ out nefergui.Output
90+ damage []neferclient.Rect
91+ region []neferclient.Rect // reused by setInputRegion
92+
93+ w, h int32
94+ scale float64
95+ configured bool
96+ canPresent bool // configured, and the last frame callback fired
97+ authorized bool // OnSurface succeeded, or there is none
98+ hookStarted bool
99+ hookDone chan error // OnSurface's result while it is pending
100+ hookCancel context.CancelFunc
101+ haveAcquire bool
102+ cursor neferclient.CursorShape
103+
104+ closed bool
105+ err error
106+}
107+
108+func (l *loop[T]) run() error {
109+ l.authorized = l.cfg.OnSurface == nil
110+ retry := time.NewTimer(pendingRetry) // reused: Pending can last many frames
111+ retry.Stop()
112+ defer retry.Stop()
113+ var tick <-chan time.Time
114+ for !l.closed {
115+ select {
116+ case <-l.ctx.Done():
117+ return l.ctx.Err()
118+ case <-l.conn.Wake():
119+ if err := l.conn.Dispatch(l); err != nil {
120+ return fmt.Errorf("dispatch: %w", err)
121+ }
122+ case _, ok := <-l.cfg.Wake:
123+ if !ok {
124+ l.cfg.Wake = nil // a closed channel would spin
125+ } else if l.r != nil {
126+ l.r.Invalidate()
127+ }
128+ case err := <-l.hookDone:
129+ l.hookDone = nil
130+ if err != nil {
131+ return err
132+ }
133+ l.authorized = true
134+ case <-l.rwake: // another goroutine asked for a frame
135+ case <-tick: // a built frame was waiting for the GPU
136+ }
137+ if l.err != nil {
138+ return l.err
139+ }
140+ if l.closed {
141+ return nil
142+ }
143+ if err := l.draw(); err != nil {
144+ return err
145+ }
146+ tick = nil
147+ if l.r != nil && l.r.Pending() {
148+ retry.Reset(pendingRetry)
149+ tick = retry.C
150+ }
151+ }
152+ return nil
153+}
154+
155+// FeedbackDone: dmabuf feedback → nefergui.RendererConfig. Only the first
156+// complete feedback is used: one GPU is assumed.
157+func (l *loop[T]) FeedbackDone(neferclient.SurfaceID) { l.setup() }
158+
159+func (l *loop[T]) Configure(neferclient.SurfaceID, int32, int32) {
160+ if !l.configured { // later configures must not lift the frame-callback gate
161+ l.canPresent = true
162+ }
163+ l.configured = true
164+ l.resize()
165+ l.setup()
166+}
167+
168+func (l *loop[T]) Scale(neferclient.SurfaceID, float64) { l.resize() }
169+
170+func (l *loop[T]) setup() {
171+ fb := l.surf.Feedback()
172+ if l.r != nil || !l.configured || fb == nil {
173+ return
174+ }
175+ cfg := nefergui.RendererConfig{MainDevice: fb.MainDevice, Transparent: true, Styles: l.cfg.Styles,
176+ Formats: make([]nefergui.Format, len(fb.Formats))}
177+ for i, f := range fb.Formats {
178+ cfg.Formats[i] = nefergui.Format{FourCC: f.FourCC, Modifier: f.Modifier}
179+ }
180+ r, err := nefergui.NewRenderer(cfg)
181+ if err != nil {
182+ l.fail(fmt.Errorf("renderer: %w", err))
183+ return
184+ }
185+ l.r, l.rwake = r, r.Wake()
186+ w, h, scale := l.surf.Size()
187+ r.Resize(int(w), int(h), scale)
188+}
189+
190+// resize forwards a changed logical size or scale to OnResize and the
191+// renderer. Before the first configure the size is not known yet.
192+func (l *loop[T]) resize() {
193+ if !l.configured {
194+ return
195+ }
196+ w, h, scale := l.surf.Size()
197+ if w == l.w && h == l.h && scale == l.scale {
198+ return
199+ }
200+ l.w, l.h, l.scale = w, h, scale
201+ if l.cfg.OnResize != nil {
202+ l.cfg.OnResize(int(w), int(h), scale)
203+ }
204+ if l.r != nil {
205+ l.r.Resize(int(w), int(h), scale)
206+ }
207+}
208+
209+// Frame: the compositor showed the last commit; the next Present is allowed.
210+func (l *loop[T]) Frame(neferclient.SurfaceID) { l.canPresent = true }
211+
212+func (l *loop[T]) Closed(neferclient.SurfaceID) { l.closed = true }
213+
214+func (l *loop[T]) Error(err error) { l.fail(err) }
215+
216+func (l *loop[T]) fail(err error) {
217+ if l.err == nil {
218+ l.err = err
219+ }
220+}
221+
222+// FDReady: a buffer's release eventfd is readable (id is the buffer).
223+func (l *loop[T]) FDReady(id uint64) {
224+ if err := l.r.Released(id); err != nil {
225+ l.fail(fmt.Errorf("release buffer %d: %w", id, err))
226+ }
227+}
228+
229+func (l *loop[T]) Pointer(ev *neferclient.PointerEvent) {
230+ in, ok := pointerInput(ev)
231+ if !ok {
232+ return
233+ }
234+ if ev.Kind == neferclient.PointerEnter {
235+ // The cursor shape is only valid after the enter: set it again.
236+ if err := l.seat.SetCursor(l.cursor); err != nil {
237+ l.fail(fmt.Errorf("cursor: %w", err))
238+ }
239+ }
240+ l.input(&in)
241+}
242+
243+func (l *loop[T]) Key(ev *neferclient.KeyEvent) {
244+ in := keyInput(ev)
245+ l.input(&in)
246+}
247+
248+func (l *loop[T]) KeyboardFocus(_ neferclient.SurfaceID, focused bool) {
249+ in := nefergui.Input{Kind: nefergui.InputFocusOut}
250+ if focused {
251+ in.Kind = nefergui.InputFocusIn
252+ }
253+ l.input(&in)
254+}
255+
256+// input gives ev to OnInput, then to NeferGUI. Before the renderer exists
257+// nothing is shown, so nothing is delivered.
258+func (l *loop[T]) input(ev *nefergui.Input) {
259+ if l.r == nil {
260+ return
261+ }
262+ if l.cfg.OnInput != nil && l.cfg.OnInput(*ev) {
263+ l.r.Invalidate()
264+ }
265+ l.r.Input(ev)
266+}
267+
268+// pointerInput converts a neferclient pointer event; ok is false for events
269+// NeferGUI does not take.
270+func pointerInput(ev *neferclient.PointerEvent) (in nefergui.Input, ok bool) {
271+ in = nefergui.Input{X: ev.X, Y: ev.Y}
272+ switch ev.Kind {
273+ case neferclient.PointerEnter, neferclient.PointerMotion:
274+ in.Kind = nefergui.InputPointerMotion
275+ case neferclient.PointerLeave:
276+ in.Kind = nefergui.InputPointerLeave
277+ case neferclient.PointerButton:
278+ in.Kind, in.Button, in.Pressed = nefergui.InputPointerRelease, ev.Button, ev.Pressed
279+ if ev.Pressed {
280+ in.Kind = nefergui.InputPointerPress
281+ }
282+ case neferclient.PointerAxis:
283+ in.Kind, in.DX, in.DY = nefergui.InputPointerAxis, ev.DX, ev.DY
284+ default:
285+ return nefergui.Input{}, false
286+ }
287+ return in, true
288+}
289+
290+// keyInput converts a neferclient key event. Text aliases neferclient storage
291+// that is valid only during the Key call, as NeferGUI requires.
292+func keyInput(ev *neferclient.KeyEvent) nefergui.Input {
293+ return nefergui.Input{
294+ Kind: nefergui.InputKey,
295+ Keysym: ev.Keysym,
296+ Text: ev.Text,
297+ Pressed: ev.Pressed,
298+ Repeat: ev.Repeat,
299+ Modifiers: nefergui.Modifiers(ev.Modifiers & guiMods),
300+ }
301+}
302+
303+// draw renders a frame when something changed and presents it.
304+func (l *loop[T]) draw() error {
305+ if l.r == nil || !l.canPresent {
306+ return nil
307+ }
308+ if !l.authorized {
309+ l.startHook()
310+ return nil
311+ }
312+ ok, err := l.r.Render(&l.out, l.model, l.view)
313+ if err != nil {
314+ return fmt.Errorf("render: %w", err)
315+ }
316+ if !ok {
317+ return nil
318+ }
319+ if err = l.setInputRegion(); err != nil {
320+ return err
321+ }
322+ if err = l.present(); err != nil {
323+ return err
324+ }
325+ return l.setCursor()
326+}
327+
328+// setInputRegion: Output.InputRects → Surface.SetInputRegion, only when the
329+// view changed it. Both are logical surface pixels; nil means the whole
330+// surface and an empty slice click-through in both libraries.
331+func (l *loop[T]) setInputRegion() error {
332+ region, changed := inputRegion(l.region[:0], &l.out)
333+ if !changed {
334+ return nil
335+ }
336+ if region != nil {
337+ l.region = region
338+ }
339+ if err := l.surf.SetInputRegion(region); err != nil {
340+ return fmt.Errorf("input region: %w", err)
341+ }
342+ return nil
343+}
344+
345+// inputRegion converts out's input region into dst; changed is false when
346+// the view did not change it.
347+func inputRegion(dst []neferclient.Rect, out *nefergui.Output) (region []neferclient.Rect, changed bool) {
348+ if !out.InputRectsChanged {
349+ return nil, false
350+ }
351+ if out.InputRects == nil {
352+ return nil, true
353+ }
354+ if dst == nil {
355+ dst = []neferclient.Rect{}
356+ }
357+ for _, r := range out.InputRects {
358+ dst = append(dst, neferclient.Rect{X: r.X, Y: r.Y, Width: r.Width, Height: r.Height})
359+ }
360+ return dst, true
361+}
362+
363+// startHook starts OnSurface once. The owner loop keeps dispatching while it
364+// waits: neferclient's reader stops reading when the event queue is full.
365+func (l *loop[T]) startHook() {
366+ if l.hookStarted {
367+ return
368+ }
369+ l.hookStarted = true
370+ ctx, cancel := context.WithCancel(l.ctx)
371+ done := make(chan error, 1)
372+ l.hookDone, l.hookCancel = done, cancel
373+ display, surface := l.conn.Display(), l.surf.WLSurface()
374+ go func() { done <- l.cfg.OnSurface(ctx, display, surface) }()
375+}
376+
377+// waitHook ends a pending OnSurface and waits for it before the connection
378+// closes.
379+func (l *loop[T]) waitHook() {
380+ if l.hookCancel == nil {
381+ return
382+ }
383+ l.hookCancel()
384+ if l.hookDone != nil {
385+ <-l.hookDone
386+ }
387+}
388+
389+// present: Renderer.Render output → ImportBuffer, ImportTimeline, Present.
390+func (l *loop[T]) present() error {
391+ out := &l.out
392+ // A resize retired buffers: stop watching and destroy them first.
393+ for _, rt := range out.Retired {
394+ if err := l.conn.UnwatchFD(rt.ReleaseFD); err != nil {
395+ return fmt.Errorf("unwatch retired buffer %d: %w", rt.Buffer, err)
396+ }
397+ if err := l.surf.DestroyBuffer(rt.Buffer); err != nil {
398+ return fmt.Errorf("destroy retired buffer %d: %w", rt.Buffer, err)
399+ }
400+ // The release timeline of a buffer is imported under the buffer's id.
401+ if err := l.surf.DestroyTimeline(rt.Buffer); err != nil {
402+ return fmt.Errorf("destroy retired timeline %d: %w", rt.Buffer, err)
403+ }
404+ }
405+ if out.NewBuffer {
406+ buf := neferclient.Buffer{
407+ Width: out.Width, Height: out.Height, FourCC: out.FourCC, Modifier: out.Modifier,
408+ PlaneCount: out.PlaneCount,
409+ }
410+ for i, p := range out.Planes {
411+ buf.Planes[i] = neferclient.Plane{FD: p.FD, Offset: p.Offset, Stride: p.Stride}
412+ }
413+ if err := l.surf.ImportBuffer(out.Buffer, &buf); err != nil {
414+ return fmt.Errorf("import buffer: %w", err)
415+ }
416+ // The release eventfd is stable per buffer: watch it once.
417+ if err := l.conn.WatchFD(out.ReleaseFD, out.Buffer); err != nil {
418+ return fmt.Errorf("watch release fd: %w", err)
419+ }
420+ }
421+ if out.NewTimelines {
422+ if !l.haveAcquire { // the acquire timeline is shared by every buffer
423+ if err := l.surf.ImportTimeline(out.Acquire.ID, out.Acquire.FD); err != nil {
424+ return fmt.Errorf("import acquire timeline: %w", err)
425+ }
426+ l.haveAcquire = true
427+ }
428+ if err := l.surf.ImportTimeline(out.Release.ID, out.Release.FD); err != nil {
429+ return fmt.Errorf("import release timeline: %w", err)
430+ }
431+ }
432+ l.damage = l.damage[:0]
433+ for _, d := range out.Damage {
434+ l.damage = append(l.damage, neferclient.Rect{X: d.X, Y: d.Y, Width: d.Width, Height: d.Height})
435+ }
436+ err := l.surf.Present(&neferclient.Present{
437+ Buffer: out.Buffer,
438+ AcquireTimeline: out.Acquire.ID,
439+ ReleaseTimeline: out.Release.ID,
440+ AcquirePoint: out.AcquirePoint,
441+ ReleasePoint: out.ReleasePoint,
442+ Damage: l.damage,
443+ })
444+ if err != nil {
445+ return fmt.Errorf("present: %w", err)
446+ }
447+ l.canPresent = false // until Frame
448+ return nil
449+}
450+
451+// setCursor: the cursor the hovered element asked for → Seat.SetCursor.
452+func (l *loop[T]) setCursor() error {
453+ switch l.out.Cursor {
454+ case nefergui.CursorPointer:
455+ l.cursor = neferclient.CursorPointer
456+ case nefergui.CursorText:
457+ l.cursor = neferclient.CursorText
458+ case nefergui.CursorNotAllowed:
459+ l.cursor = neferclient.CursorNotAllowed
460+ default:
461+ l.cursor = neferclient.CursorDefault
462+ }
463+ if err := l.seat.SetCursor(l.cursor); err != nil {
464+ return fmt.Errorf("cursor: %w", err)
465+ }
466+ return nil
467+}
A internal/adapters/layerui/layerui_test.go +87 -0
@@ -0,0 +1,87 @@
1+package layerui
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferclient"
7+ "github.com/bnema/nefergui"
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+func TestPointerInput(t *testing.T) {
12+ cases := []struct {
13+ ev neferclient.PointerEvent
14+ want nefergui.Input
15+ ok bool
16+ }{
17+ {neferclient.PointerEvent{Kind: neferclient.PointerEnter, X: 1, Y: 2}, nefergui.Input{Kind: nefergui.InputPointerMotion, X: 1, Y: 2}, true},
18+ {neferclient.PointerEvent{Kind: neferclient.PointerMotion, X: 3, Y: 4}, nefergui.Input{Kind: nefergui.InputPointerMotion, X: 3, Y: 4}, true},
19+ {neferclient.PointerEvent{Kind: neferclient.PointerLeave, X: 5, Y: 6}, nefergui.Input{Kind: nefergui.InputPointerLeave, X: 5, Y: 6}, true},
20+ {neferclient.PointerEvent{Kind: neferclient.PointerButton, X: 7, Y: 8, Button: 0x110, Pressed: true},
21+ nefergui.Input{Kind: nefergui.InputPointerPress, X: 7, Y: 8, Button: 0x110, Pressed: true}, true},
22+ {neferclient.PointerEvent{Kind: neferclient.PointerButton, X: 7, Y: 8, Button: 0x110},
23+ nefergui.Input{Kind: nefergui.InputPointerRelease, X: 7, Y: 8, Button: 0x110}, true},
24+ {neferclient.PointerEvent{Kind: neferclient.PointerAxis, DX: 1, DY: -2}, nefergui.Input{Kind: nefergui.InputPointerAxis, DX: 1, DY: -2}, true},
25+ {neferclient.PointerEvent{Kind: neferclient.PointerAxisStop}, nefergui.Input{}, false},
26+ }
27+ for _, c := range cases {
28+ got, ok := pointerInput(&c.ev)
29+ assert.Equal(t, c.ok, ok, "kind %d", c.ev.Kind)
30+ assert.Equal(t, c.want, got, "kind %d", c.ev.Kind)
31+ }
32+}
33+
34+func TestKeyInputKeepsKnownModifiersAndText(t *testing.T) {
35+ text := []byte("é")
36+ ev := neferclient.KeyEvent{Keysym: 0xe9, Pressed: true, Repeat: true, Text: text,
37+ Modifiers: neferclient.ModShift | neferclient.ModCtrl | neferclient.ModCapsLock | 1<<7}
38+ got := keyInput(&ev)
39+ assert.Equal(t, nefergui.InputKey, got.Kind)
40+ assert.Equal(t, uint32(0xe9), got.Keysym)
41+ assert.True(t, got.Pressed)
42+ assert.True(t, got.Repeat)
43+ assert.Equal(t, nefergui.ModShift|nefergui.ModCtrl|nefergui.ModCapsLock, got.Modifiers, "unknown bits are dropped")
44+ assert.Equal(t, text, got.Text)
45+}
46+
47+func TestInputRegion(t *testing.T) {
48+ _, changed := inputRegion(nil, &nefergui.Output{InputRects: []nefergui.Rect{{Width: 1}}})
49+ assert.False(t, changed, "unchanged region is not sent")
50+
51+ got, changed := inputRegion(nil, &nefergui.Output{InputRectsChanged: true})
52+ assert.True(t, changed)
53+ assert.Nil(t, got, "nil: the whole surface")
54+
55+ got, _ = inputRegion(nil, &nefergui.Output{InputRectsChanged: true, InputRects: []nefergui.Rect{}})
56+ assert.NotNil(t, got, "empty: click-through")
57+ assert.Empty(t, got)
58+
59+ buf := make([]neferclient.Rect, 0, 2)
60+ got, _ = inputRegion(buf, &nefergui.Output{InputRectsChanged: true, InputRects: []nefergui.Rect{{X: 1, Y: 2, Width: 3, Height: 4}}})
61+ assert.Equal(t, []neferclient.Rect{{X: 1, Y: 2, Width: 3, Height: 4}}, got)
62+ assert.Same(t, &buf[:1][0], &got[0], "storage is reused")
63+}
64+
65+// NeferWL sends the preferred scale before the first configure: the size is
66+// still unknown (zero for an anchored layer), so OnResize must wait.
67+func TestScaleBeforeConfigureIsNotAResize(t *testing.T) {
68+ calls := 0
69+ l := &loop[int]{cfg: Config{OnResize: func(int, int, float64) { calls++ }}}
70+ l.Scale(0, 1.5)
71+ assert.Zero(t, calls)
72+}
73+
74+// Both libraries number the shared modifiers the same way, so the bits copy.
75+func TestModifierBitsMatch(t *testing.T) {
76+ pairs := [][2]uint8{
77+ {uint8(neferclient.ModShift), uint8(nefergui.ModShift)},
78+ {uint8(neferclient.ModCtrl), uint8(nefergui.ModCtrl)},
79+ {uint8(neferclient.ModAlt), uint8(nefergui.ModAlt)},
80+ {uint8(neferclient.ModSuper), uint8(nefergui.ModSuper)},
81+ {uint8(neferclient.ModCapsLock), uint8(nefergui.ModCapsLock)},
82+ {uint8(neferclient.ModNumLock), uint8(nefergui.ModNumLock)},
83+ }
84+ for _, p := range pairs {
85+ assert.Equal(t, p[0], p[1])
86+ }
87+}
@@ -0,0 +1,87 @@
1+package layerui
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferclient"
7+ "github.com/bnema/nefergui"
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+func TestPointerInput(t *testing.T) {
12+ cases := []struct {
13+ ev neferclient.PointerEvent
14+ want nefergui.Input
15+ ok bool
16+ }{
17+ {neferclient.PointerEvent{Kind: neferclient.PointerEnter, X: 1, Y: 2}, nefergui.Input{Kind: nefergui.InputPointerMotion, X: 1, Y: 2}, true},
18+ {neferclient.PointerEvent{Kind: neferclient.PointerMotion, X: 3, Y: 4}, nefergui.Input{Kind: nefergui.InputPointerMotion, X: 3, Y: 4}, true},
19+ {neferclient.PointerEvent{Kind: neferclient.PointerLeave, X: 5, Y: 6}, nefergui.Input{Kind: nefergui.InputPointerLeave, X: 5, Y: 6}, true},
20+ {neferclient.PointerEvent{Kind: neferclient.PointerButton, X: 7, Y: 8, Button: 0x110, Pressed: true},
21+ nefergui.Input{Kind: nefergui.InputPointerPress, X: 7, Y: 8, Button: 0x110, Pressed: true}, true},
22+ {neferclient.PointerEvent{Kind: neferclient.PointerButton, X: 7, Y: 8, Button: 0x110},
23+ nefergui.Input{Kind: nefergui.InputPointerRelease, X: 7, Y: 8, Button: 0x110}, true},
24+ {neferclient.PointerEvent{Kind: neferclient.PointerAxis, DX: 1, DY: -2}, nefergui.Input{Kind: nefergui.InputPointerAxis, DX: 1, DY: -2}, true},
25+ {neferclient.PointerEvent{Kind: neferclient.PointerAxisStop}, nefergui.Input{}, false},
26+ }
27+ for _, c := range cases {
28+ got, ok := pointerInput(&c.ev)
29+ assert.Equal(t, c.ok, ok, "kind %d", c.ev.Kind)
30+ assert.Equal(t, c.want, got, "kind %d", c.ev.Kind)
31+ }
32+}
33+
34+func TestKeyInputKeepsKnownModifiersAndText(t *testing.T) {
35+ text := []byte("é")
36+ ev := neferclient.KeyEvent{Keysym: 0xe9, Pressed: true, Repeat: true, Text: text,
37+ Modifiers: neferclient.ModShift | neferclient.ModCtrl | neferclient.ModCapsLock | 1<<7}
38+ got := keyInput(&ev)
39+ assert.Equal(t, nefergui.InputKey, got.Kind)
40+ assert.Equal(t, uint32(0xe9), got.Keysym)
41+ assert.True(t, got.Pressed)
42+ assert.True(t, got.Repeat)
43+ assert.Equal(t, nefergui.ModShift|nefergui.ModCtrl|nefergui.ModCapsLock, got.Modifiers, "unknown bits are dropped")
44+ assert.Equal(t, text, got.Text)
45+}
46+
47+func TestInputRegion(t *testing.T) {
48+ _, changed := inputRegion(nil, &nefergui.Output{InputRects: []nefergui.Rect{{Width: 1}}})
49+ assert.False(t, changed, "unchanged region is not sent")
50+
51+ got, changed := inputRegion(nil, &nefergui.Output{InputRectsChanged: true})
52+ assert.True(t, changed)
53+ assert.Nil(t, got, "nil: the whole surface")
54+
55+ got, _ = inputRegion(nil, &nefergui.Output{InputRectsChanged: true, InputRects: []nefergui.Rect{}})
56+ assert.NotNil(t, got, "empty: click-through")
57+ assert.Empty(t, got)
58+
59+ buf := make([]neferclient.Rect, 0, 2)
60+ got, _ = inputRegion(buf, &nefergui.Output{InputRectsChanged: true, InputRects: []nefergui.Rect{{X: 1, Y: 2, Width: 3, Height: 4}}})
61+ assert.Equal(t, []neferclient.Rect{{X: 1, Y: 2, Width: 3, Height: 4}}, got)
62+ assert.Same(t, &buf[:1][0], &got[0], "storage is reused")
63+}
64+
65+// NeferWL sends the preferred scale before the first configure: the size is
66+// still unknown (zero for an anchored layer), so OnResize must wait.
67+func TestScaleBeforeConfigureIsNotAResize(t *testing.T) {
68+ calls := 0
69+ l := &loop[int]{cfg: Config{OnResize: func(int, int, float64) { calls++ }}}
70+ l.Scale(0, 1.5)
71+ assert.Zero(t, calls)
72+}
73+
74+// Both libraries number the shared modifiers the same way, so the bits copy.
75+func TestModifierBitsMatch(t *testing.T) {
76+ pairs := [][2]uint8{
77+ {uint8(neferclient.ModShift), uint8(nefergui.ModShift)},
78+ {uint8(neferclient.ModCtrl), uint8(nefergui.ModCtrl)},
79+ {uint8(neferclient.ModAlt), uint8(nefergui.ModAlt)},
80+ {uint8(neferclient.ModSuper), uint8(nefergui.ModSuper)},
81+ {uint8(neferclient.ModCapsLock), uint8(nefergui.ModCapsLock)},
82+ {uint8(neferclient.ModNumLock), uint8(nefergui.ModNumLock)},
83+ }
84+ for _, p := range pairs {
85+ assert.Equal(t, p[0], p[1])
86+ }
87+}
M internal/adapters/selection/model.go +26 -7
@@ -23,7 +23,7 @@ const (
2323 )
2424
2525 // model is the state of one output's selector overlay. All methods run on that
26-// overlay's NeferGUI owner loop (OnResize, OnInput and the view), so nothing
26+// overlay's layerui owner loop (OnResize, OnInput and the view), so nothing
2727 // here is locked. The decision leaves through the shared session, never through
2828 // this struct.
2929 type model struct {
@@ -206,7 +206,7 @@ func (m *model) snapshot() visible {
206206 // events, other buttons, key releases and repeats) costs no redraw. Accepting
207207 // or canceling reports true: the overlay is closing. It allocates only when the
208208 // dimension label text changes.
209-func (m *model) input(ev nefergui.InputEvent) bool {
209+func (m *model) input(ev nefergui.Input) bool {
210210 if m.end != endNone || !m.sized {
211211 return false
212212 }
@@ -223,13 +223,9 @@ func (m *model) input(ev nefergui.InputEvent) bool {
223223 if ev.Button == btnLeft {
224224 m.picker.MouseUp(ev.X, ev.Y)
225225 }
226- case nefergui.InputReset:
227- // Input was dropped: a release may never arrive. Abandon any press or
228- // drag, keeping the chosen kind, grid and workspace.
229- m.picker.AbandonPress()
230226 case nefergui.InputKey:
231227 if ev.Pressed && !ev.Repeat && ev.Modifiers&nefergui.ModCtrl == 0 {
232- m.key(ev.KeyName)
228+ m.key(keyName(ev.Keysym))
233229 }
234230 }
235231 m.settle()
@@ -264,6 +260,29 @@ func (m *model) key(name string) {
264260 }
265261 }
266262
263+// keyName is the xkb name of the keysyms the selector handles, "" for the
264+// others. Latin-1 keysyms are their code points. It never allocates.
265+func keyName(sym uint32) string {
266+ switch sym {
267+ case 0xff1b:
268+ return "Escape"
269+ case 0xff09:
270+ return "Tab"
271+ case 0xff0d:
272+ return "Return"
273+ case 0xff8d:
274+ return "KP_Enter"
275+ }
276+ if sym >= '1' && sym <= '9' || sym >= 'A' && sym <= 'Z' || sym >= 'a' && sym <= 'z' {
277+ return asciiNames[sym-'1' : sym-'1'+1]
278+ }
279+ return ""
280+}
281+
282+// asciiNames holds every character from '1' to 'z', so a one-character key
283+// name is a substring of it.
284+const asciiNames = "123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz"
285+
267286 // outputKey maps "1".."9" to an output index below n.
268287 func outputKey(name string, n int) (int, bool) {
269288 if len(name) != 1 || name[0] < '1' || name[0] > '9' {
@@ -23,7 +23,7 @@ const (
23 )23 )
24 24
25 // model is the state of one output's selector overlay. All methods run on that25 // model is the state of one output's selector overlay. All methods run on that
26-// overlay's NeferGUI owner loop (OnResize, OnInput and the view), so nothing26+// overlay's layerui owner loop (OnResize, OnInput and the view), so nothing
27 // here is locked. The decision leaves through the shared session, never through27 // here is locked. The decision leaves through the shared session, never through
28 // this struct.28 // this struct.
29 type model struct {29 type model struct {
@@ -206,7 +206,7 @@ func (m *model) snapshot() visible {
206 // events, other buttons, key releases and repeats) costs no redraw. Accepting206 // events, other buttons, key releases and repeats) costs no redraw. Accepting
207 // or canceling reports true: the overlay is closing. It allocates only when the207 // or canceling reports true: the overlay is closing. It allocates only when the
208 // dimension label text changes.208 // dimension label text changes.
209-func (m *model) input(ev nefergui.InputEvent) bool {209+func (m *model) input(ev nefergui.Input) bool {
210 if m.end != endNone || !m.sized {210 if m.end != endNone || !m.sized {
211 return false211 return false
212 }212 }
@@ -223,13 +223,9 @@ func (m *model) input(ev nefergui.InputEvent) bool {
223 if ev.Button == btnLeft {223 if ev.Button == btnLeft {
224 m.picker.MouseUp(ev.X, ev.Y)224 m.picker.MouseUp(ev.X, ev.Y)
225 }225 }
226- case nefergui.InputReset:
227- // Input was dropped: a release may never arrive. Abandon any press or
228- // drag, keeping the chosen kind, grid and workspace.
229- m.picker.AbandonPress()
230 case nefergui.InputKey:226 case nefergui.InputKey:
231 if ev.Pressed && !ev.Repeat && ev.Modifiers&nefergui.ModCtrl == 0 {227 if ev.Pressed && !ev.Repeat && ev.Modifiers&nefergui.ModCtrl == 0 {
232- m.key(ev.KeyName)228+ m.key(keyName(ev.Keysym))
233 }229 }
234 }230 }
235 m.settle()231 m.settle()
@@ -264,6 +260,29 @@ func (m *model) key(name string) {
264 }260 }
265 }261 }
266 262
263+// keyName is the xkb name of the keysyms the selector handles, "" for the
264+// others. Latin-1 keysyms are their code points. It never allocates.
265+func keyName(sym uint32) string {
266+ switch sym {
267+ case 0xff1b:
268+ return "Escape"
269+ case 0xff09:
270+ return "Tab"
271+ case 0xff0d:
272+ return "Return"
273+ case 0xff8d:
274+ return "KP_Enter"
275+ }
276+ if sym >= '1' && sym <= '9' || sym >= 'A' && sym <= 'Z' || sym >= 'a' && sym <= 'z' {
277+ return asciiNames[sym-'1' : sym-'1'+1]
278+ }
279+ return ""
280+}
281+
282+// asciiNames holds every character from '1' to 'z', so a one-character key
283+// name is a substring of it.
284+const asciiNames = "123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz"
285+
267 // outputKey maps "1".."9" to an output index below n.286 // outputKey maps "1".."9" to an output index below n.
268 func outputKey(name string, n int) (int, bool) {287 func outputKey(name string, n int) (int, bool) {
269 if len(name) != 1 || name[0] < '1' || name[0] > '9' {288 if len(name) != 1 || name[0] < '1' || name[0] > '9' {
M internal/adapters/selection/selection_test.go +55 -47
@@ -9,6 +9,7 @@ import (
99 "strings"
1010 "testing"
1111
12+ "github.com/bnema/neferclient"
1213 "github.com/bnema/nefergui"
1314 "github.com/stretchr/testify/assert"
1415 "github.com/stretchr/testify/require"
@@ -43,20 +44,49 @@ func newBareModel() (*model, context.Context) {
4344 return newModel(ports.Screenshot, testOutputs, 0, sess, false), ctx
4445 }
4546
46-func motion(x, y float64) nefergui.InputEvent {
47- return nefergui.InputEvent{Kind: nefergui.InputPointerMotion, X: x, Y: y}
47+func motion(x, y float64) nefergui.Input {
48+ return nefergui.Input{Kind: nefergui.InputPointerMotion, X: x, Y: y}
4849 }
4950
50-func button(x, y float64, pressed bool) nefergui.InputEvent {
51+func button(x, y float64, pressed bool) nefergui.Input {
5152 k := nefergui.InputPointerRelease
5253 if pressed {
5354 k = nefergui.InputPointerPress
5455 }
55- return nefergui.InputEvent{Kind: k, X: x, Y: y, Button: btnLeft, Pressed: pressed}
56+ return nefergui.Input{Kind: k, X: x, Y: y, Button: btnLeft, Pressed: pressed}
5657 }
5758
58-func key(name string) nefergui.InputEvent {
59- return nefergui.InputEvent{Kind: nefergui.InputKey, KeyName: name, Pressed: true}
59+func key(name string) nefergui.Input {
60+ return nefergui.Input{Kind: nefergui.InputKey, Keysym: keysym(name), Pressed: true}
61+}
62+
63+// keysym is the xkb keysym of a key name used by the tests; an unknown name
64+// is a keysym the selector ignores.
65+func keysym(name string) uint32 {
66+ switch name {
67+ case "Escape":
68+ return 0xff1b
69+ case "Tab":
70+ return 0xff09
71+ case "Return":
72+ return 0xff0d
73+ case "KP_Enter":
74+ return 0xff8d
75+ }
76+ if len(name) == 1 {
77+ return uint32(name[0])
78+ }
79+ return 0xffffff
80+}
81+
82+func TestKeyNameRoundTrip(t *testing.T) {
83+ for _, name := range []string{"Escape", "Tab", "Return", "KP_Enter", "1", "9", "a", "z", "A", "Z", "m", "W"} {
84+ assert.Equal(t, name, keyName(keysym(name)), name)
85+ }
86+ for _, sym := range []uint32{0, '0', ' ', ':', '@', '[', '`', '{', 0xffffff, 0xff08} {
87+ assert.Empty(t, keyName(sym), "%#x", sym)
88+ }
89+ assert.Zero(t, testing.AllocsPerRun(100, func() { _ = keyName('m') }))
6090 }
6191
6292 func TestDragReleaseAcceptsRegion(t *testing.T) {
@@ -152,19 +182,7 @@ func TestOtherButtonsAndReleasedKeysIgnored(t *testing.T) {
152182 assert.Equal(t, endNone, m.end)
153183 }
154184
155-func TestInputResetDropsDrag(t *testing.T) {
156- m := newTestModel(t)
157- m.input(button(10, 10, true))
158- m.input(motion(200, 200))
159- require.True(t, m.picker.Dragging())
160- m.input(nefergui.InputEvent{Kind: nefergui.InputReset})
161- assert.False(t, m.picker.Dragging())
162- assert.Empty(t, m.label.text)
163- assert.Equal(t, endNone, m.end)
164- assert.Equal(t, core.PickActive, m.picker.Status())
165-}
166-
167-func TestInputResetBeforeThresholdAndKeepsChoices(t *testing.T) {
185+func TestWorkspaceSelectionTarget(t *testing.T) {
168186 ws := []ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Y: 30, Width: 1920, Height: 1050}}}
169187 m, _ := newBareModel()
170188 m.setWorkspaces(ws)
@@ -173,19 +191,6 @@ func TestInputResetBeforeThresholdAndKeepsChoices(t *testing.T) {
173191 m.input(key("g"))
174192 require.Equal(t, core.PickWorkspace, m.picker.Kind())
175193
176- // A press that never reached the drag threshold is still abandoned, so a
177- // missing release cannot turn the next release into a click-accept.
178- m.input(button(500, 500, true))
179- m.input(nefergui.InputEvent{Kind: nefergui.InputReset})
180- m.input(button(500, 500, false)) // stray release: nothing is pressed
181- assert.Equal(t, endNone, m.end)
182-
183- assert.Equal(t, core.PickWorkspace, m.picker.Kind(), "kind survives a reset")
184- assert.True(t, m.grid, "grid survives a reset")
185- id, _, ok := m.picker.Workspace()
186- assert.True(t, ok)
187- assert.Equal(t, uint64(11), id, "workspace survives a reset")
188-
189194 m.input(key("Return"))
190195 require.Equal(t, endAccepted, m.end)
191196 res, ok, err := m.sess.outcome(context.Background())
@@ -408,14 +413,14 @@ func TestSessionRunEnded(t *testing.T) {
408413 }
409414
410415 func TestLayerConfig(t *testing.T) {
411- c := layerConfig("DP-1", nefergui.KeyboardExclusive)
416+ c := layerConfig("DP-1", neferclient.KeyboardExclusive)
412417 assert.Equal(t, "DP-1", c.Output)
413- assert.Equal(t, nefergui.LayerOverlay, c.Level)
414- assert.Equal(t, nefergui.AnchorTop|nefergui.AnchorBottom|nefergui.AnchorLeft|nefergui.AnchorRight, c.Anchors)
415- assert.Equal(t, nefergui.KeyboardExclusive, c.Keyboard)
418+ assert.Equal(t, neferclient.LayerOverlay, c.Level)
419+ assert.Equal(t, neferclient.AnchorTop|neferclient.AnchorBottom|neferclient.AnchorLeft|neferclient.AnchorRight, c.Anchors)
420+ assert.Equal(t, neferclient.KeyboardExclusive, c.Keyboard)
416421 assert.Equal(t, int32(-1), c.ExclusiveZone)
417422 assert.Nil(t, c.InputRects, "whole surface takes input")
418- assert.Equal(t, nefergui.KeyboardOnDemand, layerConfig("DP-2", nefergui.KeyboardOnDemand).Keyboard)
423+ assert.Equal(t, neferclient.KeyboardOnDemand, layerConfig("DP-2", neferclient.KeyboardOnDemand).Keyboard)
419424 }
420425
421426 func TestSceneGeometry(t *testing.T) {
@@ -682,7 +687,7 @@ func TestSelectorAllocations(t *testing.T) {
682687 }
683688 m := newTestModel(t)
684689 m.input(button(100, 50, true))
685- same := []nefergui.InputEvent{motion(300, 250), motion(300, 250), motion(300, 250)}
690+ same := []nefergui.Input{motion(300, 250), motion(300, 250), motion(300, 250)}
686691 for _, ev := range same { // warm the label buffer
687692 m.input(ev)
688693 }
@@ -730,16 +735,20 @@ func TestIdleSceneEmitsNoEmptyChip(t *testing.T) {
730735 func TestInputReportsOnlyVisibleChanges(t *testing.T) {
731736 m := newTestModel(t)
732737 assert.False(t, m.input(motion(50, 50)), "hover draws nothing")
733- assert.False(t, m.input(nefergui.InputEvent{Kind: nefergui.InputPointerAxis, DX: 1, DY: 2}), "axis is ignored")
738+ assert.False(t, m.input(nefergui.Input{Kind: nefergui.InputPointerAxis, DX: 1, DY: 2}), "axis is ignored")
734739 other := button(50, 50, true)
735740 other.Button = 0x111
736741 assert.False(t, m.input(other), "other buttons are ignored")
737- assert.False(t, m.input(nefergui.InputEvent{Kind: nefergui.InputFocusIn}))
738- assert.False(t, m.input(nefergui.InputEvent{Kind: nefergui.InputPointerLeave}))
739- assert.False(t, m.input(nefergui.InputEvent{Kind: nefergui.InputKey, KeyName: "g", Pressed: false}), "release")
740- assert.False(t, m.input(nefergui.InputEvent{Kind: nefergui.InputKey, KeyName: "g", Pressed: true, Repeat: true}), "repeat")
741- assert.False(t, m.input(nefergui.InputEvent{Kind: nefergui.InputKey, KeyName: "q", Pressed: true}), "unknown key")
742- assert.False(t, m.input(nefergui.InputEvent{Kind: nefergui.InputKey, KeyName: "w", Pressed: true}), "W without a workspace")
742+ assert.False(t, m.input(nefergui.Input{Kind: nefergui.InputFocusIn}))
743+ assert.False(t, m.input(nefergui.Input{Kind: nefergui.InputPointerLeave}))
744+ release := key("g")
745+ release.Pressed = false
746+ assert.False(t, m.input(release), "release")
747+ repeat := key("g")
748+ repeat.Repeat = true
749+ assert.False(t, m.input(repeat), "repeat")
750+ assert.False(t, m.input(key("q")), "unknown key")
751+ assert.False(t, m.input(key("w")), "W without a workspace")
743752
744753 assert.False(t, m.input(button(100, 50, true)), "a press alone changes nothing visible")
745754 assert.False(t, m.input(motion(102, 51)), "below the drag threshold")
@@ -747,9 +756,8 @@ func TestInputReportsOnlyVisibleChanges(t *testing.T) {
747756 assert.False(t, m.input(motion(300, 250)), "same position")
748757 assert.False(t, m.input(motion(300, 250)))
749758 assert.True(t, m.input(motion(300, 251)), "the rectangle grew")
750- assert.True(t, m.input(nefergui.InputEvent{Kind: nefergui.InputReset}), "a drag preview is dropped")
751- assert.False(t, m.input(nefergui.InputEvent{Kind: nefergui.InputReset}), "nothing left to drop")
752759
760+ m = newTestModel(t)
753761 assert.True(t, m.input(key("g")), "grid toggles")
754762 assert.True(t, m.input(key("m")), "monitor outline")
755763 assert.False(t, m.input(key("m")), "already chosen")
@@ -9,6 +9,7 @@ import (
9 "strings"9 "strings"
10 "testing"10 "testing"
11 11
12+ "github.com/bnema/neferclient"
12 "github.com/bnema/nefergui"13 "github.com/bnema/nefergui"
13 "github.com/stretchr/testify/assert"14 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
@@ -43,20 +44,49 @@ func newBareModel() (*model, context.Context) {
43 return newModel(ports.Screenshot, testOutputs, 0, sess, false), ctx44 return newModel(ports.Screenshot, testOutputs, 0, sess, false), ctx
44 }45 }
45 46
46-func motion(x, y float64) nefergui.InputEvent {47+func motion(x, y float64) nefergui.Input {
47- return nefergui.InputEvent{Kind: nefergui.InputPointerMotion, X: x, Y: y}48+ return nefergui.Input{Kind: nefergui.InputPointerMotion, X: x, Y: y}
48 }49 }
49 50
50-func button(x, y float64, pressed bool) nefergui.InputEvent {51+func button(x, y float64, pressed bool) nefergui.Input {
51 k := nefergui.InputPointerRelease52 k := nefergui.InputPointerRelease
52 if pressed {53 if pressed {
53 k = nefergui.InputPointerPress54 k = nefergui.InputPointerPress
54 }55 }
55- return nefergui.InputEvent{Kind: k, X: x, Y: y, Button: btnLeft, Pressed: pressed}56+ return nefergui.Input{Kind: k, X: x, Y: y, Button: btnLeft, Pressed: pressed}
56 }57 }
57 58
58-func key(name string) nefergui.InputEvent {59+func key(name string) nefergui.Input {
59- return nefergui.InputEvent{Kind: nefergui.InputKey, KeyName: name, Pressed: true}60+ return nefergui.Input{Kind: nefergui.InputKey, Keysym: keysym(name), Pressed: true}
61+}
62+
63+// keysym is the xkb keysym of a key name used by the tests; an unknown name
64+// is a keysym the selector ignores.
65+func keysym(name string) uint32 {
66+ switch name {
67+ case "Escape":
68+ return 0xff1b
69+ case "Tab":
70+ return 0xff09
71+ case "Return":
72+ return 0xff0d
73+ case "KP_Enter":
74+ return 0xff8d
75+ }
76+ if len(name) == 1 {
77+ return uint32(name[0])
78+ }
79+ return 0xffffff
80+}
81+
82+func TestKeyNameRoundTrip(t *testing.T) {
83+ for _, name := range []string{"Escape", "Tab", "Return", "KP_Enter", "1", "9", "a", "z", "A", "Z", "m", "W"} {
84+ assert.Equal(t, name, keyName(keysym(name)), name)
85+ }
86+ for _, sym := range []uint32{0, '0', ' ', ':', '@', '[', '`', '{', 0xffffff, 0xff08} {
87+ assert.Empty(t, keyName(sym), "%#x", sym)
88+ }
89+ assert.Zero(t, testing.AllocsPerRun(100, func() { _ = keyName('m') }))
60 }90 }
61 91
62 func TestDragReleaseAcceptsRegion(t *testing.T) {92 func TestDragReleaseAcceptsRegion(t *testing.T) {
@@ -152,19 +182,7 @@ func TestOtherButtonsAndReleasedKeysIgnored(t *testing.T) {
152 assert.Equal(t, endNone, m.end)182 assert.Equal(t, endNone, m.end)
153 }183 }
154 184
155-func TestInputResetDropsDrag(t *testing.T) {185+func TestWorkspaceSelectionTarget(t *testing.T) {
156- m := newTestModel(t)
157- m.input(button(10, 10, true))
158- m.input(motion(200, 200))
159- require.True(t, m.picker.Dragging())
160- m.input(nefergui.InputEvent{Kind: nefergui.InputReset})
161- assert.False(t, m.picker.Dragging())
162- assert.Empty(t, m.label.text)
163- assert.Equal(t, endNone, m.end)
164- assert.Equal(t, core.PickActive, m.picker.Status())
165-}
166-
167-func TestInputResetBeforeThresholdAndKeepsChoices(t *testing.T) {
168 ws := []ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Y: 30, Width: 1920, Height: 1050}}}186 ws := []ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Y: 30, Width: 1920, Height: 1050}}}
169 m, _ := newBareModel()187 m, _ := newBareModel()
170 m.setWorkspaces(ws)188 m.setWorkspaces(ws)
@@ -173,19 +191,6 @@ func TestInputResetBeforeThresholdAndKeepsChoices(t *testing.T) {
173 m.input(key("g"))191 m.input(key("g"))
174 require.Equal(t, core.PickWorkspace, m.picker.Kind())192 require.Equal(t, core.PickWorkspace, m.picker.Kind())
175 193
176- // A press that never reached the drag threshold is still abandoned, so a
177- // missing release cannot turn the next release into a click-accept.
178- m.input(button(500, 500, true))
179- m.input(nefergui.InputEvent{Kind: nefergui.InputReset})
180- m.input(button(500, 500, false)) // stray release: nothing is pressed
181- assert.Equal(t, endNone, m.end)
182-
183- assert.Equal(t, core.PickWorkspace, m.picker.Kind(), "kind survives a reset")
184- assert.True(t, m.grid, "grid survives a reset")
185- id, _, ok := m.picker.Workspace()
186- assert.True(t, ok)
187- assert.Equal(t, uint64(11), id, "workspace survives a reset")
188-
189 m.input(key("Return"))194 m.input(key("Return"))
190 require.Equal(t, endAccepted, m.end)195 require.Equal(t, endAccepted, m.end)
191 res, ok, err := m.sess.outcome(context.Background())196 res, ok, err := m.sess.outcome(context.Background())
@@ -408,14 +413,14 @@ func TestSessionRunEnded(t *testing.T) {
408 }413 }
409 414
410 func TestLayerConfig(t *testing.T) {415 func TestLayerConfig(t *testing.T) {
411- c := layerConfig("DP-1", nefergui.KeyboardExclusive)416+ c := layerConfig("DP-1", neferclient.KeyboardExclusive)
412 assert.Equal(t, "DP-1", c.Output)417 assert.Equal(t, "DP-1", c.Output)
413- assert.Equal(t, nefergui.LayerOverlay, c.Level)418+ assert.Equal(t, neferclient.LayerOverlay, c.Level)
414- assert.Equal(t, nefergui.AnchorTop|nefergui.AnchorBottom|nefergui.AnchorLeft|nefergui.AnchorRight, c.Anchors)419+ assert.Equal(t, neferclient.AnchorTop|neferclient.AnchorBottom|neferclient.AnchorLeft|neferclient.AnchorRight, c.Anchors)
415- assert.Equal(t, nefergui.KeyboardExclusive, c.Keyboard)420+ assert.Equal(t, neferclient.KeyboardExclusive, c.Keyboard)
416 assert.Equal(t, int32(-1), c.ExclusiveZone)421 assert.Equal(t, int32(-1), c.ExclusiveZone)
417 assert.Nil(t, c.InputRects, "whole surface takes input")422 assert.Nil(t, c.InputRects, "whole surface takes input")
418- assert.Equal(t, nefergui.KeyboardOnDemand, layerConfig("DP-2", nefergui.KeyboardOnDemand).Keyboard)423+ assert.Equal(t, neferclient.KeyboardOnDemand, layerConfig("DP-2", neferclient.KeyboardOnDemand).Keyboard)
419 }424 }
420 425
421 func TestSceneGeometry(t *testing.T) {426 func TestSceneGeometry(t *testing.T) {
@@ -682,7 +687,7 @@ func TestSelectorAllocations(t *testing.T) {
682 }687 }
683 m := newTestModel(t)688 m := newTestModel(t)
684 m.input(button(100, 50, true))689 m.input(button(100, 50, true))
685- same := []nefergui.InputEvent{motion(300, 250), motion(300, 250), motion(300, 250)}690+ same := []nefergui.Input{motion(300, 250), motion(300, 250), motion(300, 250)}
686 for _, ev := range same { // warm the label buffer691 for _, ev := range same { // warm the label buffer
687 m.input(ev)692 m.input(ev)
688 }693 }
@@ -730,16 +735,20 @@ func TestIdleSceneEmitsNoEmptyChip(t *testing.T) {
730 func TestInputReportsOnlyVisibleChanges(t *testing.T) {735 func TestInputReportsOnlyVisibleChanges(t *testing.T) {
731 m := newTestModel(t)736 m := newTestModel(t)
732 assert.False(t, m.input(motion(50, 50)), "hover draws nothing")737 assert.False(t, m.input(motion(50, 50)), "hover draws nothing")
733- assert.False(t, m.input(nefergui.InputEvent{Kind: nefergui.InputPointerAxis, DX: 1, DY: 2}), "axis is ignored")738+ assert.False(t, m.input(nefergui.Input{Kind: nefergui.InputPointerAxis, DX: 1, DY: 2}), "axis is ignored")
734 other := button(50, 50, true)739 other := button(50, 50, true)
735 other.Button = 0x111740 other.Button = 0x111
736 assert.False(t, m.input(other), "other buttons are ignored")741 assert.False(t, m.input(other), "other buttons are ignored")
737- assert.False(t, m.input(nefergui.InputEvent{Kind: nefergui.InputFocusIn}))742+ assert.False(t, m.input(nefergui.Input{Kind: nefergui.InputFocusIn}))
738- assert.False(t, m.input(nefergui.InputEvent{Kind: nefergui.InputPointerLeave}))743+ assert.False(t, m.input(nefergui.Input{Kind: nefergui.InputPointerLeave}))
739- assert.False(t, m.input(nefergui.InputEvent{Kind: nefergui.InputKey, KeyName: "g", Pressed: false}), "release")744+ release := key("g")
740- assert.False(t, m.input(nefergui.InputEvent{Kind: nefergui.InputKey, KeyName: "g", Pressed: true, Repeat: true}), "repeat")745+ release.Pressed = false
741- assert.False(t, m.input(nefergui.InputEvent{Kind: nefergui.InputKey, KeyName: "q", Pressed: true}), "unknown key")746+ assert.False(t, m.input(release), "release")
742- assert.False(t, m.input(nefergui.InputEvent{Kind: nefergui.InputKey, KeyName: "w", Pressed: true}), "W without a workspace")747+ repeat := key("g")
748+ repeat.Repeat = true
749+ assert.False(t, m.input(repeat), "repeat")
750+ assert.False(t, m.input(key("q")), "unknown key")
751+ assert.False(t, m.input(key("w")), "W without a workspace")
743 752
744 assert.False(t, m.input(button(100, 50, true)), "a press alone changes nothing visible")753 assert.False(t, m.input(button(100, 50, true)), "a press alone changes nothing visible")
745 assert.False(t, m.input(motion(102, 51)), "below the drag threshold")754 assert.False(t, m.input(motion(102, 51)), "below the drag threshold")
@@ -747,9 +756,8 @@ func TestInputReportsOnlyVisibleChanges(t *testing.T) {
747 assert.False(t, m.input(motion(300, 250)), "same position")756 assert.False(t, m.input(motion(300, 250)), "same position")
748 assert.False(t, m.input(motion(300, 250)))757 assert.False(t, m.input(motion(300, 250)))
749 assert.True(t, m.input(motion(300, 251)), "the rectangle grew")758 assert.True(t, m.input(motion(300, 251)), "the rectangle grew")
750- assert.True(t, m.input(nefergui.InputEvent{Kind: nefergui.InputReset}), "a drag preview is dropped")
751- assert.False(t, m.input(nefergui.InputEvent{Kind: nefergui.InputReset}), "nothing left to drop")
752 759
760+ m = newTestModel(t)
753 assert.True(t, m.input(key("g")), "grid toggles")761 assert.True(t, m.input(key("g")), "grid toggles")
754 assert.True(t, m.input(key("m")), "monitor outline")762 assert.True(t, m.input(key("m")), "monitor outline")
755 assert.False(t, m.input(key("m")), "already chosen")763 assert.False(t, m.input(key("m")), "already chosen")
M internal/adapters/selection/selector.go +19 -23
@@ -1,4 +1,4 @@
1-// Package selection is the NeferGUI layer-shell capture selector.
1+// Package selection is the layer-shell capture selector, drawn with NeferGUI.
22 //
33 // Select opens one transparent overlay layer surface on every output. Each
44 // dims everything except the dragged region, draws thin accent lines and a
@@ -8,8 +8,9 @@
88 // One overlay per output: every monitor is selectable at once. A Wayland
99 // client cannot hold exclusive keyboard focus on several surfaces, so the first
1010 // output's overlay is keyboard-exclusive and the others take the keyboard on
11-// demand (on click). Each overlay is a separate NeferGUI window with its own
12-// model on its own owner loop; they share only a first-decision-wins session.
11+// demand (on click). Each overlay is a separate layer surface on its own
12+// Wayland connection, with its own model on its own owner loop; they share only
13+// a first-decision-wins session.
1314 package selection
1415
1516 import (
@@ -19,8 +20,9 @@ import (
1920 "fmt"
2021 "sync"
2122
22- "github.com/bnema/nefergui"
23+ "github.com/bnema/neferclient"
2324
25+ "github.com/bnema/nefercap/internal/adapters/layerui"
2426 "github.com/bnema/nefercap/internal/adapters/uistyle"
2527 "github.com/bnema/nefercap/internal/core"
2628 "github.com/bnema/nefercap/internal/ports"
@@ -148,33 +150,27 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se
148150
149151 // run shows one overlay on its own output until the context ends.
150152 func run(ctx context.Context, sheet string, m *model) error {
151- kb := nefergui.KeyboardOnDemand
153+ kb := neferclient.KeyboardOnDemand
152154 if m.index == 0 {
153- kb = nefergui.KeyboardExclusive
155+ kb = neferclient.KeyboardExclusive
154156 }
155- opts := []nefergui.WindowOption{
156- nefergui.Title("NeferCap selector"),
157- nefergui.Size(1, 1),
158- nefergui.Styles(sheet),
159- nefergui.Transparent(),
160- nefergui.Layer(layerConfig(m.outputs[m.index].Name, kb)),
161- nefergui.OnResize(m.resize),
162- nefergui.OnInput(m.input),
163- }
164- if m.wake != nil {
165- opts = append(opts, nefergui.Wake(m.wake))
166- }
167- return nefergui.Run(ctx, m, view, opts...)
157+ return layerui.Run(ctx, layerui.Config{
158+ Layer: layerConfig(m.outputs[m.index].Name, kb),
159+ Styles: sheet,
160+ Wake: m.wake, // nil without toggle: never ready
161+ OnResize: m.resize,
162+ OnInput: m.input,
163+ }, m, view)
168164 }
169165
170166 // layerConfig is one overlay: every edge, above windows, the given keyboard
171167 // mode, and an exclusive zone of -1 so no bar shifts it.
172-func layerConfig(output string, keyboard nefergui.KeyboardMode) nefergui.LayerConfig {
173- return nefergui.LayerConfig{
168+func layerConfig(output string, keyboard neferclient.KeyboardMode) neferclient.LayerConfig {
169+ return neferclient.LayerConfig{
174170 Output: output,
175171 Namespace: namespace,
176- Level: nefergui.LayerOverlay,
177- Anchors: nefergui.AnchorTop | nefergui.AnchorBottom | nefergui.AnchorLeft | nefergui.AnchorRight,
172+ Level: neferclient.LayerOverlay,
173+ Anchors: neferclient.AnchorTop | neferclient.AnchorBottom | neferclient.AnchorLeft | neferclient.AnchorRight,
178174 Keyboard: keyboard,
179175 ExclusiveZone: -1,
180176 }
@@ -1,4 +1,4 @@
1-// Package selection is the NeferGUI layer-shell capture selector.1+// Package selection is the layer-shell capture selector, drawn with NeferGUI.
2 //2 //
3 // Select opens one transparent overlay layer surface on every output. Each3 // Select opens one transparent overlay layer surface on every output. Each
4 // dims everything except the dragged region, draws thin accent lines and a4 // dims everything except the dragged region, draws thin accent lines and a
@@ -8,8 +8,9 @@
8 // One overlay per output: every monitor is selectable at once. A Wayland8 // One overlay per output: every monitor is selectable at once. A Wayland
9 // client cannot hold exclusive keyboard focus on several surfaces, so the first9 // client cannot hold exclusive keyboard focus on several surfaces, so the first
10 // output's overlay is keyboard-exclusive and the others take the keyboard on10 // output's overlay is keyboard-exclusive and the others take the keyboard on
11-// demand (on click). Each overlay is a separate NeferGUI window with its own11+// demand (on click). Each overlay is a separate layer surface on its own
12-// model on its own owner loop; they share only a first-decision-wins session.12+// Wayland connection, with its own model on its own owner loop; they share only
13+// a first-decision-wins session.
13 package selection14 package selection
14 15
15 import (16 import (
@@ -19,8 +20,9 @@ import (
19 "fmt"20 "fmt"
20 "sync"21 "sync"
21 22
22- "github.com/bnema/nefergui"23+ "github.com/bnema/neferclient"
23 24
25+ "github.com/bnema/nefercap/internal/adapters/layerui"
24 "github.com/bnema/nefercap/internal/adapters/uistyle"26 "github.com/bnema/nefercap/internal/adapters/uistyle"
25 "github.com/bnema/nefercap/internal/core"27 "github.com/bnema/nefercap/internal/core"
26 "github.com/bnema/nefercap/internal/ports"28 "github.com/bnema/nefercap/internal/ports"
@@ -148,33 +150,27 @@ func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Se
148 150
149 // run shows one overlay on its own output until the context ends.151 // run shows one overlay on its own output until the context ends.
150 func run(ctx context.Context, sheet string, m *model) error {152 func run(ctx context.Context, sheet string, m *model) error {
151- kb := nefergui.KeyboardOnDemand153+ kb := neferclient.KeyboardOnDemand
152 if m.index == 0 {154 if m.index == 0 {
153- kb = nefergui.KeyboardExclusive155+ kb = neferclient.KeyboardExclusive
154 }156 }
155- opts := []nefergui.WindowOption{157+ return layerui.Run(ctx, layerui.Config{
156- nefergui.Title("NeferCap selector"),158+ Layer: layerConfig(m.outputs[m.index].Name, kb),
157- nefergui.Size(1, 1),159+ Styles: sheet,
158- nefergui.Styles(sheet),160+ Wake: m.wake, // nil without toggle: never ready
159- nefergui.Transparent(),161+ OnResize: m.resize,
160- nefergui.Layer(layerConfig(m.outputs[m.index].Name, kb)),162+ OnInput: m.input,
161- nefergui.OnResize(m.resize),163+ }, m, view)
162- nefergui.OnInput(m.input),
163- }
164- if m.wake != nil {
165- opts = append(opts, nefergui.Wake(m.wake))
166- }
167- return nefergui.Run(ctx, m, view, opts...)
168 }164 }
169 165
170 // layerConfig is one overlay: every edge, above windows, the given keyboard166 // layerConfig is one overlay: every edge, above windows, the given keyboard
171 // mode, and an exclusive zone of -1 so no bar shifts it.167 // mode, and an exclusive zone of -1 so no bar shifts it.
172-func layerConfig(output string, keyboard nefergui.KeyboardMode) nefergui.LayerConfig {168+func layerConfig(output string, keyboard neferclient.KeyboardMode) neferclient.LayerConfig {
173- return nefergui.LayerConfig{169+ return neferclient.LayerConfig{
174 Output: output,170 Output: output,
175 Namespace: namespace,171 Namespace: namespace,
176- Level: nefergui.LayerOverlay,172+ Level: neferclient.LayerOverlay,
177- Anchors: nefergui.AnchorTop | nefergui.AnchorBottom | nefergui.AnchorLeft | nefergui.AnchorRight,173+ Anchors: neferclient.AnchorTop | neferclient.AnchorBottom | neferclient.AnchorLeft | neferclient.AnchorRight,
178 Keyboard: keyboard,174 Keyboard: keyboard,
179 ExclusiveZone: -1,175 ExclusiveZone: -1,
180 }176 }
M internal/adapters/uierrors/uierrors.go +4 -3
@@ -1,4 +1,5 @@
1-// Package uierrors holds the error helper shared by the NeferGUI adapters.
1+// Package uierrors holds the error helper shared by the layer-surface UI
2+// adapters.
23 package uierrors
34
45 import (
@@ -11,7 +12,7 @@ import (
1112 // merely wraps a target (fmt.Errorf("x: %w", context.Canceled)) is kept, and so
1213 // is every sibling of a dropped leaf. It returns nil when nothing else remains.
1314 //
14-// Use it to discard the context.Canceled a NeferGUI Run reports after the
15+// Use it to discard the context.Canceled a layerui.Run reports after the
1516 // adapter itself ended it, without hiding a real error joined to it.
1617 func Drop(err error, targets ...error) error {
1718 if err == nil {
@@ -40,7 +41,7 @@ func Drop(err error, targets ...error) error {
4041 return err
4142 }
4243
43-// Ended classifies how a NeferGUI Run ended. It is pure: the caller snapshots
44+// Ended classifies how a layerui.Run ended. It is pure: the caller snapshots
4445 // its state once and passes it, so a context that ends between two reads cannot
4546 // flip the answer.
4647 //
@@ -1,4 +1,5 @@
1-// Package uierrors holds the error helper shared by the NeferGUI adapters.1+// Package uierrors holds the error helper shared by the layer-surface UI
2+// adapters.
2 package uierrors3 package uierrors
3 4
4 import (5 import (
@@ -11,7 +12,7 @@ import (
11 // merely wraps a target (fmt.Errorf("x: %w", context.Canceled)) is kept, and so12 // merely wraps a target (fmt.Errorf("x: %w", context.Canceled)) is kept, and so
12 // is every sibling of a dropped leaf. It returns nil when nothing else remains.13 // is every sibling of a dropped leaf. It returns nil when nothing else remains.
13 //14 //
14-// Use it to discard the context.Canceled a NeferGUI Run reports after the15+// Use it to discard the context.Canceled a layerui.Run reports after the
15 // adapter itself ended it, without hiding a real error joined to it.16 // adapter itself ended it, without hiding a real error joined to it.
16 func Drop(err error, targets ...error) error {17 func Drop(err error, targets ...error) error {
17 if err == nil {18 if err == nil {
@@ -40,7 +41,7 @@ func Drop(err error, targets ...error) error {
40 return err41 return err
41 }42 }
42 43
43-// Ended classifies how a NeferGUI Run ended. It is pure: the caller snapshots44+// Ended classifies how a layerui.Run ended. It is pure: the caller snapshots
44 // its state once and passes it, so a context that ends between two reads cannot45 // its state once and passes it, so a context that ends between two reads cannot
45 // flip the answer.46 // flip the answer.
46 //47 //
M internal/adapters/wayland/extensions.go +26 -14
@@ -376,8 +376,8 @@ func (e *LayerDetachedError) Is(target error) bool {
376376 }
377377
378378 // layerProxy is one neferwl_capture_layer_v1 attachment. Its events are
379-// dispatched by whichever goroutine reads the GUI connection: during
380-// authorizeLayer that is the caller, afterwards the connection's reader.
379+// dispatched by the goroutine that reads the GUI connection; answered is
380+// closed on the first answer or violation, so authorizeLayer can wait for it.
381381 //
382382 // State machine: pending -> attached | failed; attached -> detached. Any other
383383 // event is a protocol violation: the exclusion cannot be trusted any more.
@@ -385,6 +385,7 @@ type layerProxy struct {
385385 wlturbo.BaseProxy
386386 display *wlturbo.Display
387387 onDetached func(error)
388+ answered chan struct{}
388389
389390 mu sync.Mutex
390391 state layerState
@@ -440,7 +441,11 @@ func (l *layerProxy) Dispatch(e *wlturbo.Event) {
440441 l.notified, notify = true, errLayerProtocol
441442 }
442443 }
444+ settled := l.state != layerPending || l.violation
443445 l.mu.Unlock()
446+ if settled {
447+ l.answer()
448+ }
444449 if closeDisplay && returned {
445450 _ = l.display.Close() // the surface could be visible in captures
446451 }
@@ -449,11 +454,13 @@ func (l *layerProxy) Dispatch(e *wlturbo.Event) {
449454 }
450455 }
451456
452-// settled reports that the answer arrived, or the layer misbehaved.
453-func (l *layerProxy) settled() bool {
454- l.mu.Lock()
455- defer l.mu.Unlock()
456- return l.state != layerPending || l.violation
457+// answer closes answered once; wlturbo serializes Dispatch calls.
458+func (l *layerProxy) answer() {
459+ select {
460+ case <-l.answered:
461+ default:
462+ close(l.answered)
463+ }
457464 }
458465
459466 // hand marks the layer as owned by the caller of authorizeLayer, and reports
@@ -473,9 +480,11 @@ func (l *layerProxy) hand() (state layerState, reason uint32, violation bool) {
473480 // confirmed the exclusion for this very attachment, so the surface is safe to
474481 // show.
475482 //
476-// AuthorizeLayer dispatches display's events itself until the attachment
477-// answers, so it must run before the connection's event loop starts. A
478-// refusal returns an *AttachFailedError. A protocol error is returned as the
483+// Other goroutines must keep reading display's events meanwhile (with
484+// neferclient: its reader, and the owner calling Conn.Dispatch so the reader
485+// is never stalled by a full queue). AuthorizeLayer only sends the request
486+// and waits for the answer, so it never competes with them. A refusal returns an
487+// *AttachFailedError. A protocol error is returned as the
479488 // display's error. A cancelled ctx, or no answer within DefaultAttachTimeout
480489 // (an error wrapping context.DeadlineExceeded), closes display before
481490 // returning: the surface must then stay unmapped. So does a violation of the
@@ -492,7 +501,7 @@ func (l *layerProxy) hand() (state layerState, reason uint32, violation bool) {
492501 // would not release the exclusion, and closing the connection ends it.
493502 //
494503 // surface is the layer's wl_surface proxy, whichever bindings created it; it
495-// must belong to display, and nothing else may dispatch display meanwhile.
504+// must belong to display.
496505 func AuthorizeLayer(ctx context.Context, display *wlturbo.Display, surface wlturbo.Proxy, token string, onDetached func(error)) error {
497506 return authorizeLayer(ctx, display, surface, token, onDetached, DefaultAttachTimeout)
498507 }
@@ -519,11 +528,14 @@ func authorizeLayer(ctx context.Context, display *wlturbo.Display, surface wltur
519528 stop := context.AfterFunc(wctx, func() { _ = display.Close() })
520529 defer stop()
521530
522- l := &layerProxy{display: display, onDetached: onDetached}
531+ l := &layerProxy{display: display, onDetached: onDetached, answered: make(chan struct{})}
523532 l.SetContext(display.Context())
524533 err := display.Context().Request(wlturbo.Request{Proxy: m, Opcode: reqAttachSurface, Name: "neferwl_capture_exclusion_manager_v1.attach_surface", Child: l}, l, token, surface)
525- for err == nil && !l.settled() {
526- err = display.Dispatch()
534+ if err == nil {
535+ select {
536+ case <-l.answered:
537+ case <-wctx.Done(): // the watcher closes display
538+ }
527539 }
528540 // Detach the watcher before anything else is decided or sent. When stop
529541 // reports false the watcher has fired or is firing: cancellation and the
@@ -376,8 +376,8 @@ func (e *LayerDetachedError) Is(target error) bool {
376 }376 }
377 377
378 // layerProxy is one neferwl_capture_layer_v1 attachment. Its events are378 // layerProxy is one neferwl_capture_layer_v1 attachment. Its events are
379-// dispatched by whichever goroutine reads the GUI connection: during379+// dispatched by the goroutine that reads the GUI connection; answered is
380-// authorizeLayer that is the caller, afterwards the connection's reader.380+// closed on the first answer or violation, so authorizeLayer can wait for it.
381 //381 //
382 // State machine: pending -> attached | failed; attached -> detached. Any other382 // State machine: pending -> attached | failed; attached -> detached. Any other
383 // event is a protocol violation: the exclusion cannot be trusted any more.383 // event is a protocol violation: the exclusion cannot be trusted any more.
@@ -385,6 +385,7 @@ type layerProxy struct {
385 wlturbo.BaseProxy385 wlturbo.BaseProxy
386 display *wlturbo.Display386 display *wlturbo.Display
387 onDetached func(error)387 onDetached func(error)
388+ answered chan struct{}
388 389
389 mu sync.Mutex390 mu sync.Mutex
390 state layerState391 state layerState
@@ -440,7 +441,11 @@ func (l *layerProxy) Dispatch(e *wlturbo.Event) {
440 l.notified, notify = true, errLayerProtocol441 l.notified, notify = true, errLayerProtocol
441 }442 }
442 }443 }
444+ settled := l.state != layerPending || l.violation
443 l.mu.Unlock()445 l.mu.Unlock()
446+ if settled {
447+ l.answer()
448+ }
444 if closeDisplay && returned {449 if closeDisplay && returned {
445 _ = l.display.Close() // the surface could be visible in captures450 _ = l.display.Close() // the surface could be visible in captures
446 }451 }
@@ -449,11 +454,13 @@ func (l *layerProxy) Dispatch(e *wlturbo.Event) {
449 }454 }
450 }455 }
451 456
452-// settled reports that the answer arrived, or the layer misbehaved.457+// answer closes answered once; wlturbo serializes Dispatch calls.
453-func (l *layerProxy) settled() bool {458+func (l *layerProxy) answer() {
454- l.mu.Lock()459+ select {
455- defer l.mu.Unlock()460+ case <-l.answered:
456- return l.state != layerPending || l.violation461+ default:
462+ close(l.answered)
463+ }
457 }464 }
458 465
459 // hand marks the layer as owned by the caller of authorizeLayer, and reports466 // hand marks the layer as owned by the caller of authorizeLayer, and reports
@@ -473,9 +480,11 @@ func (l *layerProxy) hand() (state layerState, reason uint32, violation bool) {
473 // confirmed the exclusion for this very attachment, so the surface is safe to480 // confirmed the exclusion for this very attachment, so the surface is safe to
474 // show.481 // show.
475 //482 //
476-// AuthorizeLayer dispatches display's events itself until the attachment483+// Other goroutines must keep reading display's events meanwhile (with
477-// answers, so it must run before the connection's event loop starts. A484+// neferclient: its reader, and the owner calling Conn.Dispatch so the reader
478-// refusal returns an *AttachFailedError. A protocol error is returned as the485+// is never stalled by a full queue). AuthorizeLayer only sends the request
486+// and waits for the answer, so it never competes with them. A refusal returns an
487+// *AttachFailedError. A protocol error is returned as the
479 // display's error. A cancelled ctx, or no answer within DefaultAttachTimeout488 // display's error. A cancelled ctx, or no answer within DefaultAttachTimeout
480 // (an error wrapping context.DeadlineExceeded), closes display before489 // (an error wrapping context.DeadlineExceeded), closes display before
481 // returning: the surface must then stay unmapped. So does a violation of the490 // returning: the surface must then stay unmapped. So does a violation of the
@@ -492,7 +501,7 @@ func (l *layerProxy) hand() (state layerState, reason uint32, violation bool) {
492 // would not release the exclusion, and closing the connection ends it.501 // would not release the exclusion, and closing the connection ends it.
493 //502 //
494 // surface is the layer's wl_surface proxy, whichever bindings created it; it503 // surface is the layer's wl_surface proxy, whichever bindings created it; it
495-// must belong to display, and nothing else may dispatch display meanwhile.504+// must belong to display.
496 func AuthorizeLayer(ctx context.Context, display *wlturbo.Display, surface wlturbo.Proxy, token string, onDetached func(error)) error {505 func AuthorizeLayer(ctx context.Context, display *wlturbo.Display, surface wlturbo.Proxy, token string, onDetached func(error)) error {
497 return authorizeLayer(ctx, display, surface, token, onDetached, DefaultAttachTimeout)506 return authorizeLayer(ctx, display, surface, token, onDetached, DefaultAttachTimeout)
498 }507 }
@@ -519,11 +528,14 @@ func authorizeLayer(ctx context.Context, display *wlturbo.Display, surface wltur
519 stop := context.AfterFunc(wctx, func() { _ = display.Close() })528 stop := context.AfterFunc(wctx, func() { _ = display.Close() })
520 defer stop()529 defer stop()
521 530
522- l := &layerProxy{display: display, onDetached: onDetached}531+ l := &layerProxy{display: display, onDetached: onDetached, answered: make(chan struct{})}
523 l.SetContext(display.Context())532 l.SetContext(display.Context())
524 err := display.Context().Request(wlturbo.Request{Proxy: m, Opcode: reqAttachSurface, Name: "neferwl_capture_exclusion_manager_v1.attach_surface", Child: l}, l, token, surface)533 err := display.Context().Request(wlturbo.Request{Proxy: m, Opcode: reqAttachSurface, Name: "neferwl_capture_exclusion_manager_v1.attach_surface", Child: l}, l, token, surface)
525- for err == nil && !l.settled() {534+ if err == nil {
526- err = display.Dispatch()535+ select {
536+ case <-l.answered:
537+ case <-wctx.Done(): // the watcher closes display
538+ }
527 }539 }
528 // Detach the watcher before anything else is decided or sent. When stop540 // Detach the watcher before anything else is decided or sent. When stop
529 // reports false the watcher has fired or is firing: cancellation and the541 // reports false the watcher has fired or is firing: cancellation and the
M internal/adapters/wayland/extensions_test.go +18 -8
@@ -120,7 +120,8 @@ func TestExclusionEndsWithTargetChange(t *testing.T) {
120120 require.Equal(t, 1, srv.Stats().DestroyedExclusions)
121121 }
122122
123-// hudConnection is a second connection, like the HUD's, with a surface.
123+// hudConnection is a second connection, like the HUD's, with a surface and a
124+// goroutine that reads its events, as neferclient's reader does.
124125 func hudConnection(t *testing.T, srv *testserver.Server) (*wlturbo.Display, *core.Surface) {
125126 t.Helper()
126127 conn, err := net.Dial("unix", srv.Path)
@@ -134,6 +135,13 @@ func hudConnection(t *testing.T, srv *testserver.Server) (*wlturbo.Display, *cor
134135 require.NoError(t, err)
135136 surface, err := comp.CreateSurface()
136137 require.NoError(t, err)
138+ read := make(chan struct{})
139+ go func() {
140+ defer close(read)
141+ for display.Dispatch() == nil {
142+ }
143+ }()
144+ t.Cleanup(func() { _ = display.Close(); <-read })
137145 return display, surface
138146 }
139147
@@ -178,8 +186,10 @@ func TestAuthorizeLayerDetachedCallback(t *testing.T) {
178186 }))
179187 require.Nil(t, got.Load())
180188 srv.Detach(0, uint32(DetachExclusionEnded))
181- for len(calls) == 0 {
182- require.NoError(t, display.Roundtrip())
189+ select {
190+ case <-calls:
191+ case <-time.After(5 * time.Second):
192+ t.Fatal("onDetached was not called")
183193 }
184194 d := got.Load()
185195 require.NotNil(t, d)
@@ -193,12 +203,12 @@ func TestAuthorizeLayerProtocolViolationClosesDisplay(t *testing.T) {
193203 got := make(chan error, 1)
194204 require.NoError(t, AuthorizeLayer(context.Background(), display, surface, testserver.Token, func(err error) { got <- err }))
195205 srv.SendRaw(0, 0, 0) // a second attached
196- for !display.Closed() {
197- if err := display.Dispatch(); err != nil {
198- break
199- }
206+ select {
207+ case err := <-got:
208+ require.ErrorIs(t, err, ErrExclusionUnavailable)
209+ case <-time.After(5 * time.Second):
210+ t.Fatal("onDetached was not called")
200211 }
201- require.ErrorIs(t, <-got, ErrExclusionUnavailable)
202212 require.True(t, display.Closed(), "the surface could be visible in captures: the connection ends")
203213 }
204214
@@ -120,7 +120,8 @@ func TestExclusionEndsWithTargetChange(t *testing.T) {
120 require.Equal(t, 1, srv.Stats().DestroyedExclusions)120 require.Equal(t, 1, srv.Stats().DestroyedExclusions)
121 }121 }
122 122
123-// hudConnection is a second connection, like the HUD's, with a surface.123+// hudConnection is a second connection, like the HUD's, with a surface and a
124+// goroutine that reads its events, as neferclient's reader does.
124 func hudConnection(t *testing.T, srv *testserver.Server) (*wlturbo.Display, *core.Surface) {125 func hudConnection(t *testing.T, srv *testserver.Server) (*wlturbo.Display, *core.Surface) {
125 t.Helper()126 t.Helper()
126 conn, err := net.Dial("unix", srv.Path)127 conn, err := net.Dial("unix", srv.Path)
@@ -134,6 +135,13 @@ func hudConnection(t *testing.T, srv *testserver.Server) (*wlturbo.Display, *cor
134 require.NoError(t, err)135 require.NoError(t, err)
135 surface, err := comp.CreateSurface()136 surface, err := comp.CreateSurface()
136 require.NoError(t, err)137 require.NoError(t, err)
138+ read := make(chan struct{})
139+ go func() {
140+ defer close(read)
141+ for display.Dispatch() == nil {
142+ }
143+ }()
144+ t.Cleanup(func() { _ = display.Close(); <-read })
137 return display, surface145 return display, surface
138 }146 }
139 147
@@ -178,8 +186,10 @@ func TestAuthorizeLayerDetachedCallback(t *testing.T) {
178 }))186 }))
179 require.Nil(t, got.Load())187 require.Nil(t, got.Load())
180 srv.Detach(0, uint32(DetachExclusionEnded))188 srv.Detach(0, uint32(DetachExclusionEnded))
181- for len(calls) == 0 {189+ select {
182- require.NoError(t, display.Roundtrip())190+ case <-calls:
191+ case <-time.After(5 * time.Second):
192+ t.Fatal("onDetached was not called")
183 }193 }
184 d := got.Load()194 d := got.Load()
185 require.NotNil(t, d)195 require.NotNil(t, d)
@@ -193,12 +203,12 @@ func TestAuthorizeLayerProtocolViolationClosesDisplay(t *testing.T) {
193 got := make(chan error, 1)203 got := make(chan error, 1)
194 require.NoError(t, AuthorizeLayer(context.Background(), display, surface, testserver.Token, func(err error) { got <- err }))204 require.NoError(t, AuthorizeLayer(context.Background(), display, surface, testserver.Token, func(err error) { got <- err }))
195 srv.SendRaw(0, 0, 0) // a second attached205 srv.SendRaw(0, 0, 0) // a second attached
196- for !display.Closed() {206+ select {
197- if err := display.Dispatch(); err != nil {207+ case err := <-got:
198- break208+ require.ErrorIs(t, err, ErrExclusionUnavailable)
199- }209+ case <-time.After(5 * time.Second):
210+ t.Fatal("onDetached was not called")
200 }211 }
201- require.ErrorIs(t, <-got, ErrExclusionUnavailable)
202 require.True(t, display.Closed(), "the surface could be visible in captures: the connection ends")212 require.True(t, display.Closed(), "the surface could be visible in captures: the connection ends")
203 }213 }
204 214
M internal/app/record_interactive.go +3 -3
@@ -14,7 +14,7 @@ import (
1414 "github.com/bnema/nefercap/internal/core"
1515 "github.com/bnema/nefercap/internal/logging"
1616 "github.com/bnema/nefercap/internal/ports"
17- "github.com/bnema/nefergui"
17+ "github.com/bnema/wlturbo"
1818 )
1919
2020 // recordInteractive records and listens for stop requests. With the
@@ -160,8 +160,8 @@ type hudHandle struct {
160160 func startHUD(ctx context.Context, output ports.Output, frame ports.Region, token string, stop chan<- struct{}, life *lifecycle) hudHandle {
161161 ready := make(chan struct{}, 1)
162162 done := make(chan error, 1)
163- authorize := func(hookCtx context.Context, surface nefergui.WaylandSurface) error {
164- if err := wayland.AuthorizeLayer(hookCtx, surface.Display, surface.Surface, token, life.detached); err != nil {
163+ authorize := func(hookCtx context.Context, display *wlturbo.Display, surface wlturbo.Proxy) error {
164+ if err := wayland.AuthorizeLayer(hookCtx, display, surface, token, life.detached); err != nil {
165165 return err
166166 }
167167 ready <- struct{}{}
@@ -14,7 +14,7 @@ import (
14 "github.com/bnema/nefercap/internal/core"14 "github.com/bnema/nefercap/internal/core"
15 "github.com/bnema/nefercap/internal/logging"15 "github.com/bnema/nefercap/internal/logging"
16 "github.com/bnema/nefercap/internal/ports"16 "github.com/bnema/nefercap/internal/ports"
17- "github.com/bnema/nefergui"17+ "github.com/bnema/wlturbo"
18 )18 )
19 19
20 // recordInteractive records and listens for stop requests. With the20 // recordInteractive records and listens for stop requests. With the
@@ -160,8 +160,8 @@ type hudHandle struct {
160 func startHUD(ctx context.Context, output ports.Output, frame ports.Region, token string, stop chan<- struct{}, life *lifecycle) hudHandle {160 func startHUD(ctx context.Context, output ports.Output, frame ports.Region, token string, stop chan<- struct{}, life *lifecycle) hudHandle {
161 ready := make(chan struct{}, 1)161 ready := make(chan struct{}, 1)
162 done := make(chan error, 1)162 done := make(chan error, 1)
163- authorize := func(hookCtx context.Context, surface nefergui.WaylandSurface) error {163+ authorize := func(hookCtx context.Context, display *wlturbo.Display, surface wlturbo.Proxy) error {
164- if err := wayland.AuthorizeLayer(hookCtx, surface.Display, surface.Surface, token, life.detached); err != nil {164+ if err := wayland.AuthorizeLayer(hookCtx, display, surface, token, life.detached); err != nil {
165 return err165 return err
166 }166 }
167 ready <- struct{}{}167 ready <- struct{}{}
M internal/core/selection.go +0 -14
@@ -245,20 +245,6 @@ func (p *Picker) MouseUp(x, y float64) bool {
245245 return true
246246 }
247247
248-// AbandonPress drops an unfinished press and any drag preview, for example
249-// when the pointer leaves the surface or input focus is lost, so a button
250-// release that is never delivered cannot leave the picker stuck. The chosen
251-// kind and workspace are kept and the status stays PickActive. It does
252-// nothing after acceptance or cancellation. It reports whether state changed.
253-func (p *Picker) AbandonPress() bool {
254- if p.status != PickActive || !(p.pressed || p.dragging || p.rect != (ports.Region{})) {
255- return false
256- }
257- p.pressed, p.dragging = false, false
258- p.rect = ports.Region{}
259- return true
260-}
261-
262248 // Confirm accepts the chosen monitor or workspace. It does nothing for the
263249 // default region kind, whose selection is made by the mouse.
264250 func (p *Picker) Confirm() bool {
@@ -245,20 +245,6 @@ func (p *Picker) MouseUp(x, y float64) bool {
245 return true245 return true
246 }246 }
247 247
248-// AbandonPress drops an unfinished press and any drag preview, for example
249-// when the pointer leaves the surface or input focus is lost, so a button
250-// release that is never delivered cannot leave the picker stuck. The chosen
251-// kind and workspace are kept and the status stays PickActive. It does
252-// nothing after acceptance or cancellation. It reports whether state changed.
253-func (p *Picker) AbandonPress() bool {
254- if p.status != PickActive || !(p.pressed || p.dragging || p.rect != (ports.Region{})) {
255- return false
256- }
257- p.pressed, p.dragging = false, false
258- p.rect = ports.Region{}
259- return true
260-}
261-
262 // Confirm accepts the chosen monitor or workspace. It does nothing for the248 // Confirm accepts the chosen monitor or workspace. It does nothing for the
263 // default region kind, whose selection is made by the mouse.249 // default region kind, whose selection is made by the mouse.
264 func (p *Picker) Confirm() bool {250 func (p *Picker) Confirm() bool {
M internal/core/selection_test.go +0 -75
@@ -303,78 +303,6 @@ func TestPickerChangedFlags(t *testing.T) {
303303 assert.False(t, p.Confirm(), "no confirm mid-press")
304304 }
305305
306-func TestPickerAbandonPressWithoutMotion(t *testing.T) {
307- p := newPicker(t, 4, 1920, 1080)
308- const id = 1<<40 + 9
309- p.SetWorkspace(id, ports.Region{Width: 1900, Height: 1000})
310- assert.False(t, p.AbandonPress(), "nothing to abandon")
311- p.MouseDown(100, 100)
312- assert.False(t, p.SelectWorkspace(), "blocked while pressed")
313- assert.True(t, p.AbandonPress())
314- assert.False(t, p.AbandonPress(), "already clear")
315- assert.False(t, p.MouseUp(100, 100), "release after abandon is ignored")
316- assert.Equal(t, core.PickActive, p.Status())
317-
318- assert.True(t, p.SelectWorkspace())
319- assert.True(t, p.Confirm())
320- res, ok := p.Result()
321- require.True(t, ok)
322- assert.Equal(t, ports.Target{OutputID: 4, WorkspaceID: id}, res.Target())
323-}
324-
325-func TestPickerAbandonDragKeepsModes(t *testing.T) {
326- p := newPicker(t, 4, 1000, 800)
327- ws := ports.Region{X: 10, Y: 10, Width: 500, Height: 400}
328- p.SetWorkspace(21, ws)
329- p.SelectWorkspace()
330- p.MouseDown(100, 100)
331- p.MouseMove(300, 300)
332- _, dragging := p.Rect()
333- require.True(t, dragging)
334-
335- assert.True(t, p.AbandonPress())
336- _, dragging = p.Rect()
337- assert.False(t, dragging)
338- assert.False(t, p.Dragging())
339- assert.Equal(t, core.PickWorkspace, p.Kind())
340- id, r, ok := p.Workspace()
341- assert.True(t, ok)
342- assert.Equal(t, uint64(21), id)
343- assert.Equal(t, ws, r)
344- assert.Equal(t, core.PickActive, p.Status())
345-
346- // A fresh press starts from a clean anchor and drags normally.
347- p.MouseDown(600, 600)
348- p.MouseMove(700, 650)
349- p.MouseUp(700, 650)
350- res, ok := p.Result()
351- require.True(t, ok)
352- assert.Equal(t, ports.Region{X: 600, Y: 600, Width: 100, Height: 50}, res.Region)
353- assert.Equal(t, core.PickRegion, res.Kind)
354-}
355-
356-func TestPickerAbandonPressAfterEnd(t *testing.T) {
357- a := newPicker(t, 1, 100, 100)
358- a.MouseDown(10, 10)
359- a.MouseMove(60, 60)
360- a.MouseUp(60, 60)
361- before, _ := a.Result()
362- assert.False(t, a.AbandonPress())
363- after, ok := a.Result()
364- assert.True(t, ok)
365- assert.Equal(t, before, after)
366-
367- c := newPicker(t, 1, 100, 100)
368- c.MouseDown(10, 10)
369- c.Cancel()
370- assert.False(t, c.AbandonPress())
371- assert.Equal(t, core.PickCanceled, c.Status())
372-
373- var idle core.Picker
374- assert.False(t, idle.AbandonPress())
375- assert.Equal(t, core.PickIdle, idle.Status())
376-}
377-
378306 func TestPickerAllocations(t *testing.T) {
379307 p := newPicker(t, 1, 1920, 1080)
380308 p.SetWorkspace(1, ports.Region{Width: 100, Height: 100})
@@ -385,9 +313,6 @@ func TestPickerAllocations(t *testing.T) {
385313 _, _ = p.Rect()
386314 _, _ = p.Result()
387315 }
388- p.AbandonPress()
389- p.MouseDown(5, 5)
390- p.AbandonPress()
391316 p.SelectMonitor()
392317 p.SelectRegion()
393318 p.Cancel()
@@ -303,78 +303,6 @@ func TestPickerChangedFlags(t *testing.T) {
303 assert.False(t, p.Confirm(), "no confirm mid-press")303 assert.False(t, p.Confirm(), "no confirm mid-press")
304 }304 }
305 305
306-func TestPickerAbandonPressWithoutMotion(t *testing.T) {
307- p := newPicker(t, 4, 1920, 1080)
308- const id = 1<<40 + 9
309- p.SetWorkspace(id, ports.Region{Width: 1900, Height: 1000})
310- assert.False(t, p.AbandonPress(), "nothing to abandon")
311- p.MouseDown(100, 100)
312- assert.False(t, p.SelectWorkspace(), "blocked while pressed")
313- assert.True(t, p.AbandonPress())
314- assert.False(t, p.AbandonPress(), "already clear")
315- assert.False(t, p.MouseUp(100, 100), "release after abandon is ignored")
316- assert.Equal(t, core.PickActive, p.Status())
317-
318- assert.True(t, p.SelectWorkspace())
319- assert.True(t, p.Confirm())
320- res, ok := p.Result()
321- require.True(t, ok)
322- assert.Equal(t, ports.Target{OutputID: 4, WorkspaceID: id}, res.Target())
323-}
324-
325-func TestPickerAbandonDragKeepsModes(t *testing.T) {
326- p := newPicker(t, 4, 1000, 800)
327- ws := ports.Region{X: 10, Y: 10, Width: 500, Height: 400}
328- p.SetWorkspace(21, ws)
329- p.SelectWorkspace()
330- p.MouseDown(100, 100)
331- p.MouseMove(300, 300)
332- _, dragging := p.Rect()
333- require.True(t, dragging)
334-
335- assert.True(t, p.AbandonPress())
336- _, dragging = p.Rect()
337- assert.False(t, dragging)
338- assert.False(t, p.Dragging())
339- assert.Equal(t, core.PickWorkspace, p.Kind())
340- id, r, ok := p.Workspace()
341- assert.True(t, ok)
342- assert.Equal(t, uint64(21), id)
343- assert.Equal(t, ws, r)
344- assert.Equal(t, core.PickActive, p.Status())
345-
346- // A fresh press starts from a clean anchor and drags normally.
347- p.MouseDown(600, 600)
348- p.MouseMove(700, 650)
349- p.MouseUp(700, 650)
350- res, ok := p.Result()
351- require.True(t, ok)
352- assert.Equal(t, ports.Region{X: 600, Y: 600, Width: 100, Height: 50}, res.Region)
353- assert.Equal(t, core.PickRegion, res.Kind)
354-}
355-
356-func TestPickerAbandonPressAfterEnd(t *testing.T) {
357- a := newPicker(t, 1, 100, 100)
358- a.MouseDown(10, 10)
359- a.MouseMove(60, 60)
360- a.MouseUp(60, 60)
361- before, _ := a.Result()
362- assert.False(t, a.AbandonPress())
363- after, ok := a.Result()
364- assert.True(t, ok)
365- assert.Equal(t, before, after)
366-
367- c := newPicker(t, 1, 100, 100)
368- c.MouseDown(10, 10)
369- c.Cancel()
370- assert.False(t, c.AbandonPress())
371- assert.Equal(t, core.PickCanceled, c.Status())
372-
373- var idle core.Picker
374- assert.False(t, idle.AbandonPress())
375- assert.Equal(t, core.PickIdle, idle.Status())
376-}
377-
378 func TestPickerAllocations(t *testing.T) {306 func TestPickerAllocations(t *testing.T) {
379 p := newPicker(t, 1, 1920, 1080)307 p := newPicker(t, 1, 1920, 1080)
380 p.SetWorkspace(1, ports.Region{Width: 100, Height: 100})308 p.SetWorkspace(1, ports.Region{Width: 100, Height: 100})
@@ -385,9 +313,6 @@ func TestPickerAllocations(t *testing.T) {
385 _, _ = p.Rect()313 _, _ = p.Rect()
386 _, _ = p.Result()314 _, _ = p.Result()
387 }315 }
388- p.AbandonPress()
389- p.MouseDown(5, 5)
390- p.AbandonPress()
391 p.SelectMonitor()316 p.SelectMonitor()
392 p.SelectRegion()317 p.SelectRegion()
393 p.Cancel()318 p.Cancel()

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 2

Tags 9

↑↓ moveenter openesc close