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 @@ | |||
| 2 | 2 | ||
| 3 | 3 | - Use Go 1.27; normal builds and tests use CGO_ENABLED=0. Enable CGO only for race tests. | |
| 4 | 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 | 6 | - Sign every commit and tag; commit format: type(scope): description. | |
| 7 | 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 | 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(). | |
| @@ -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 | |||
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | 6 | 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 | |
| 8 | 9 | github.com/bnema/purego-libwayland v0.8.0 | |
| 9 | - | github.com/bnema/wlturbo v0.5.0 | |
| 10 | + | github.com/bnema/wlturbo v0.6.2 | |
| 10 | 11 | github.com/bnema/zerowrap v1.4.1 | |
| 11 | 12 | github.com/stretchr/testify v1.12.1 | |
| 12 | 13 | golang.org/x/sys v0.48.0 | |
| @@ -15,7 +16,7 @@ require ( | |||
| 15 | 16 | require ( | |
| 16 | 17 | github.com/bnema/purego v0.13.0-bnema.1 // indirect | |
| 17 | 18 | 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 | |
| 19 | 20 | github.com/go-text/typesetting v0.3.5 // indirect | |
| 20 | 21 | github.com/mattn/go-colorable v0.1.15 // indirect | |
| 21 | 22 | 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.0 | 6 | 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 | |||
| 8 | github.com/bnema/purego-libwayland v0.8.0 | 9 | github.com/bnema/purego-libwayland v0.8.0 | ||
| 9 | - | github.com/bnema/wlturbo v0.5.0 | 10 | + | github.com/bnema/wlturbo v0.6.2 |
| 10 | github.com/bnema/zerowrap v1.4.1 | 11 | github.com/bnema/zerowrap v1.4.1 | ||
| 11 | github.com/stretchr/testify v1.12.1 | 12 | github.com/stretchr/testify v1.12.1 | ||
| 12 | golang.org/x/sys v0.48.0 | 13 | 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 // indirect | 17 | github.com/bnema/purego v0.13.0-bnema.1 // indirect | ||
| 17 | github.com/bnema/purego-vulkan v0.6.0 // indirect | 18 | 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 |
| 19 | github.com/go-text/typesetting v0.3.5 // indirect | 20 | github.com/go-text/typesetting v0.3.5 // indirect | ||
| 20 | github.com/mattn/go-colorable v0.1.15 // indirect | 21 | github.com/mattn/go-colorable v0.1.15 // indirect | ||
| 21 | github.com/mattn/go-isatty v0.0.22 // indirect | 22 | github.com/mattn/go-isatty v0.0.22 // indirect | ||
M go.sum +8 -6 generated file: collapsed
M internal/adapters/indicator/hud.go +1 -1
| @@ -58,7 +58,7 @@ func keepRune(c rune) bool { | |||
| 58 | 58 | return unicode.IsPrint(c) | |
| 59 | 59 | } | |
| 60 | 60 | ||
| 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 | |
| 62 | 62 | // owner loop, so nothing is locked. The elapsed time is computed from the | |
| 63 | 63 | // monotonic clock reading the view passes to refresh, never from a counter | |
| 64 | 64 | // 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 NeferGUI | 61 | + | // 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 the | 62 | // 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 counter | 63 | // monotonic clock reading the view passes to refresh, never from a counter | ||
| 64 | // another goroutine advances. start carries a monotonic reading (time.Now), so | 64 | // another goroutine advances. start carries a monotonic reading (time.Now), so | ||
M internal/adapters/indicator/indicator.go +29 -23
| @@ -8,8 +8,8 @@ | |||
| 8 | 8 | // recorded area is not drawn here: the compositor marks it. | |
| 9 | 9 | // | |
| 10 | 10 | // 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 | |
| 13 | 13 | // exclusion the compositor contract requires on that same client; Run refuses | |
| 14 | 14 | // to start without it. The layer namespace is only a role hint and authorizes | |
| 15 | 15 | // nothing. If authorize fails, Run fails and nothing is shown. | |
| @@ -23,8 +23,11 @@ import ( | |||
| 23 | 23 | "sync" | |
| 24 | 24 | "time" | |
| 25 | 25 | ||
| 26 | + | "github.com/bnema/neferclient" | |
| 26 | 27 | "github.com/bnema/nefergui" | |
| 28 | + | "github.com/bnema/wlturbo" | |
| 27 | 29 | ||
| 30 | + | "github.com/bnema/nefercap/internal/adapters/layerui" | |
| 28 | 31 | "github.com/bnema/nefercap/internal/adapters/uierrors" | |
| 29 | 32 | "github.com/bnema/nefercap/internal/adapters/uistyle" | |
| 30 | 33 | "github.com/bnema/nefercap/internal/ports" | |
| @@ -67,16 +70,20 @@ type Indicator struct { | |||
| 67 | 70 | // place. Set it before Run. | |
| 68 | 71 | Frame ports.Region | |
| 69 | 72 | ||
| 70 | - | authorize func(context.Context, nefergui.WaylandSurface) error | |
| 73 | + | authorize Authorize | |
| 71 | 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 | 81 | // 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 | |
| 76 | 83 | // for the compositor's acknowledgement is interrupted by cancellation. stop | |
| 77 | 84 | // should be buffered; a click sends one non-blocking value. The caller reacts | |
| 78 | 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 | 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 | 124 | go func() { defer wg.Done(); ticker(child, wake) }() | |
| 118 | 125 | defer func() { cancel(); wg.Wait() }() | |
| 119 | 126 | ||
| 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) | |
| 129 | 133 | // Run is only ever ended by ctx: the HUD never decides by itself. Read the | |
| 130 | 134 | // parent once; a bare Canceled is then our own cancellation, and anything | |
| 131 | 135 | // 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 | 142 | // pointer input only inside the Stop button. Centre is that of the output, or | |
| 139 | 143 | // of frame when it is set: then the HUD is anchored to the top left of the | |
| 140 | 144 | // 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{ | |
| 143 | 147 | Output: output, | |
| 144 | 148 | 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, | |
| 148 | 152 | ExclusiveZone: -1, | |
| 149 | 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 | 158 | if frame.Width > 0 && frame.Height > 0 { | |
| 153 | - | c.Anchors = nefergui.AnchorTop | nefergui.AnchorLeft | |
| 159 | + | c.Anchors = neferclient.AnchorTop | neferclient.AnchorLeft | |
| 154 | 160 | c.Margin = [4]int32{int32(frame.Y) + topMargin, 0, 0, int32(frame.X + max((frame.Width-Width)/2, 0))} | |
| 155 | 161 | } | |
| 156 | 162 | return c | |
| 157 | 163 | } | |
| 158 | 164 | ||
| 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} | |
| 161 | 167 | } | |
| 162 | 168 | ||
| 163 | 169 | // 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 is | 10 | // 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 | 11 | + | // 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 whatever | 12 | + | // 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 refuses | 13 | // 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 authorizes | 14 | // 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.Region | 71 | Frame ports.Region | ||
| 69 | 72 | ||||
| 70 | - | authorize func(context.Context, nefergui.WaylandSurface) error | 73 | + | 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, on | 81 | // 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 |
| 76 | // for the compositor's acknowledgement is interrupted by cancellation. stop | 83 | // for the compositor's acknowledgement is interrupted by cancellation. stop | ||
| 77 | // should be buffered; a click sends one non-blocking value. The caller reacts | 84 | // 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 the | 133 | // 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 anything | 134 | // 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 every | 135 | // 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, or | 142 | // 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 the | 143 | // 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.AnchorLeft | 159 | + | 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 c | 162 | 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 ( | |||
| 6 | 6 | "testing" | |
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | - | "github.com/bnema/nefergui" | |
| 9 | + | "github.com/bnema/neferclient" | |
| 10 | + | "github.com/bnema/wlturbo" | |
| 10 | 11 | "github.com/stretchr/testify/assert" | |
| 11 | 12 | "github.com/stretchr/testify/require" | |
| 12 | 13 | ||
| @@ -123,27 +124,28 @@ func TestRequestStopNeverBlocks(t *testing.T) { | |||
| 123 | 124 | ||
| 124 | 125 | func TestLayerConfigTakesNoFocusAndOnlyStopInput(t *testing.T) { | |
| 125 | 126 | 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") | |
| 129 | 131 | assert.Equal(t, int32(-1), c.ExclusiveZone) | |
| 130 | 132 | require.Len(t, c.InputRects, 1) | |
| 131 | 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 | 135 | assert.LessOrEqual(t, r.X+r.Width, int32(Width)) | |
| 134 | 136 | } | |
| 135 | 137 | ||
| 136 | 138 | func TestLayerConfigCentresOnTheRecordedFrame(t *testing.T) { | |
| 137 | 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 | 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 | 143 | // A frame narrower than the HUD starts at its left edge. | |
| 142 | 144 | c = layerConfig("DP-1", ports.Region{X: 7, Width: 100, Height: 100}) | |
| 143 | 145 | assert.Equal(t, int32(7), c.Margin[3]) | |
| 144 | 146 | // No frame: the whole output, centred by the compositor. | |
| 145 | 147 | c = layerConfig("DP-1", ports.Region{}) | |
| 146 | - | assert.Equal(t, nefergui.AnchorTop, c.Anchors) | |
| 148 | + | assert.Equal(t, neferclient.AnchorTop, c.Anchors) | |
| 147 | 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 | 157 | assert.Contains(t, styleSheet, "width: 5.625rem") | |
| 156 | 158 | assert.Contains(t, styleSheet, "line-height: 2.5rem") | |
| 157 | 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 | 163 | func BenchmarkRefreshUnchanged(b *testing.B) { | |
| @@ -170,7 +172,7 @@ func BenchmarkRefreshUnchanged(b *testing.B) { | |||
| 170 | 172 | ||
| 171 | 173 | func TestRunRejectsBadSetup(t *testing.T) { | |
| 172 | 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 | 176 | out := ports.Output{ID: 1, Name: "DP-1"} | |
| 175 | 177 | ||
| 176 | 178 | 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 ( | |||
| 6 | 6 | "testing" | |
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | - | "github.com/bnema/nefergui" | |
| 9 | + | "github.com/bnema/wlturbo" | |
| 10 | 10 | "github.com/stretchr/testify/assert" | |
| 11 | 11 | "github.com/stretchr/testify/require" | |
| 12 | 12 | ||
| @@ -31,8 +31,8 @@ func TestSmokeRun(t *testing.T) { | |||
| 31 | 31 | defer cancel() | |
| 32 | 32 | stop := make(chan struct{}, 1) | |
| 33 | 33 | 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() | |
| 36 | 36 | return nil | |
| 37 | 37 | }, stop) | |
| 38 | 38 | 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 bool | 33 | 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 != nil | 35 | + | authorized = display != nil && surface != nil && surface.Context() == display.Context() |
| 36 | return nil | 36 | 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 ( | |||
| 23 | 23 | ) | |
| 24 | 24 | ||
| 25 | 25 | // 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 | |
| 27 | 27 | // here is locked. The decision leaves through the shared session, never through | |
| 28 | 28 | // this struct. | |
| 29 | 29 | type model struct { | |
| @@ -206,7 +206,7 @@ func (m *model) snapshot() visible { | |||
| 206 | 206 | // events, other buttons, key releases and repeats) costs no redraw. Accepting | |
| 207 | 207 | // or canceling reports true: the overlay is closing. It allocates only when the | |
| 208 | 208 | // dimension label text changes. | |
| 209 | - | func (m *model) input(ev nefergui.InputEvent) bool { | |
| 209 | + | func (m *model) input(ev nefergui.Input) bool { | |
| 210 | 210 | if m.end != endNone || !m.sized { | |
| 211 | 211 | return false | |
| 212 | 212 | } | |
| @@ -223,13 +223,9 @@ func (m *model) input(ev nefergui.InputEvent) bool { | |||
| 223 | 223 | if ev.Button == btnLeft { | |
| 224 | 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 | 226 | case nefergui.InputKey: | |
| 231 | 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 | 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 | 286 | // outputKey maps "1".."9" to an output index below n. | |
| 268 | 287 | func outputKey(name string, n int) (int, bool) { | |
| 269 | 288 | 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 that | 25 | // 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 |
| 27 | // here is locked. The decision leaves through the shared session, never through | 27 | // 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. Accepting | 206 | // events, other buttons, key releases and repeats) costs no redraw. Accepting | ||
| 207 | // or canceling reports true: the overlay is closing. It allocates only when the | 207 | // 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 false | 211 | 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 ( | |||
| 9 | 9 | "strings" | |
| 10 | 10 | "testing" | |
| 11 | 11 | ||
| 12 | + | "github.com/bnema/neferclient" | |
| 12 | 13 | "github.com/bnema/nefergui" | |
| 13 | 14 | "github.com/stretchr/testify/assert" | |
| 14 | 15 | "github.com/stretchr/testify/require" | |
| @@ -43,20 +44,49 @@ func newBareModel() (*model, context.Context) { | |||
| 43 | 44 | return newModel(ports.Screenshot, testOutputs, 0, sess, false), ctx | |
| 44 | 45 | } | |
| 45 | 46 | ||
| 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} | |
| 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 | 52 | k := nefergui.InputPointerRelease | |
| 52 | 53 | if pressed { | |
| 53 | 54 | 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 | - | 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') })) | |
| 60 | 90 | } | |
| 61 | 91 | ||
| 62 | 92 | func TestDragReleaseAcceptsRegion(t *testing.T) { | |
| @@ -152,19 +182,7 @@ func TestOtherButtonsAndReleasedKeysIgnored(t *testing.T) { | |||
| 152 | 182 | assert.Equal(t, endNone, m.end) | |
| 153 | 183 | } | |
| 154 | 184 | ||
| 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) { | |
| 168 | 186 | ws := []ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Y: 30, Width: 1920, Height: 1050}}} | |
| 169 | 187 | m, _ := newBareModel() | |
| 170 | 188 | m.setWorkspaces(ws) | |
| @@ -173,19 +191,6 @@ func TestInputResetBeforeThresholdAndKeepsChoices(t *testing.T) { | |||
| 173 | 191 | m.input(key("g")) | |
| 174 | 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 | 194 | m.input(key("Return")) | |
| 190 | 195 | require.Equal(t, endAccepted, m.end) | |
| 191 | 196 | res, ok, err := m.sess.outcome(context.Background()) | |
| @@ -408,14 +413,14 @@ func TestSessionRunEnded(t *testing.T) { | |||
| 408 | 413 | } | |
| 409 | 414 | ||
| 410 | 415 | func TestLayerConfig(t *testing.T) { | |
| 411 | - | c := layerConfig("DP-1", nefergui.KeyboardExclusive) | |
| 416 | + | c := layerConfig("DP-1", neferclient.KeyboardExclusive) | |
| 412 | 417 | 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) | |
| 416 | 421 | assert.Equal(t, int32(-1), c.ExclusiveZone) | |
| 417 | 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 | 426 | func TestSceneGeometry(t *testing.T) { | |
| @@ -682,7 +687,7 @@ func TestSelectorAllocations(t *testing.T) { | |||
| 682 | 687 | } | |
| 683 | 688 | m := newTestModel(t) | |
| 684 | 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 | 691 | for _, ev := range same { // warm the label buffer | |
| 687 | 692 | m.input(ev) | |
| 688 | 693 | } | |
| @@ -730,16 +735,20 @@ func TestIdleSceneEmitsNoEmptyChip(t *testing.T) { | |||
| 730 | 735 | func TestInputReportsOnlyVisibleChanges(t *testing.T) { | |
| 731 | 736 | m := newTestModel(t) | |
| 732 | 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 | 739 | other := button(50, 50, true) | |
| 735 | 740 | other.Button = 0x111 | |
| 736 | 741 | 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") | |
| 743 | 752 | ||
| 744 | 753 | assert.False(t, m.input(button(100, 50, true)), "a press alone changes nothing visible") | |
| 745 | 754 | assert.False(t, m.input(motion(102, 51)), "below the drag threshold") | |
| @@ -747,9 +756,8 @@ func TestInputReportsOnlyVisibleChanges(t *testing.T) { | |||
| 747 | 756 | assert.False(t, m.input(motion(300, 250)), "same position") | |
| 748 | 757 | assert.False(t, m.input(motion(300, 250))) | |
| 749 | 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 | 761 | assert.True(t, m.input(key("g")), "grid toggles") | |
| 754 | 762 | assert.True(t, m.input(key("m")), "monitor outline") | |
| 755 | 763 | 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), ctx | 44 | 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.InputPointerRelease | 52 | k := nefergui.InputPointerRelease | ||
| 52 | if pressed { | 53 | if pressed { | ||
| 53 | k = nefergui.InputPointerPress | 54 | 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 buffer | 691 | 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 = 0x111 | 740 | 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. | |
| 2 | 2 | // | |
| 3 | 3 | // Select opens one transparent overlay layer surface on every output. Each | |
| 4 | 4 | // dims everything except the dragged region, draws thin accent lines and a | |
| @@ -8,8 +8,9 @@ | |||
| 8 | 8 | // One overlay per output: every monitor is selectable at once. A Wayland | |
| 9 | 9 | // client cannot hold exclusive keyboard focus on several surfaces, so the first | |
| 10 | 10 | // 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. | |
| 13 | 14 | package selection | |
| 14 | 15 | ||
| 15 | 16 | import ( | |
| @@ -19,8 +20,9 @@ import ( | |||
| 19 | 20 | "fmt" | |
| 20 | 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 | 26 | "github.com/bnema/nefercap/internal/adapters/uistyle" | |
| 25 | 27 | "github.com/bnema/nefercap/internal/core" | |
| 26 | 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 | 151 | // run shows one overlay on its own output until the context ends. | |
| 150 | 152 | func run(ctx context.Context, sheet string, m *model) error { | |
| 151 | - | kb := nefergui.KeyboardOnDemand | |
| 153 | + | kb := neferclient.KeyboardOnDemand | |
| 152 | 154 | if m.index == 0 { | |
| 153 | - | kb = nefergui.KeyboardExclusive | |
| 155 | + | kb = neferclient.KeyboardExclusive | |
| 154 | 156 | } | |
| 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) | |
| 168 | 164 | } | |
| 169 | 165 | ||
| 170 | 166 | // layerConfig is one overlay: every edge, above windows, the given keyboard | |
| 171 | 167 | // 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{ | |
| 174 | 170 | Output: output, | |
| 175 | 171 | 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, | |
| 178 | 174 | Keyboard: keyboard, | |
| 179 | 175 | ExclusiveZone: -1, | |
| 180 | 176 | } | |
| @@ -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. Each | 3 | // Select opens one transparent overlay layer surface on every output. Each | ||
| 4 | // dims everything except the dragged region, draws thin accent lines and a | 4 | // 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 Wayland | 8 | // One overlay per output: every monitor is selectable at once. A Wayland | ||
| 9 | // client cannot hold exclusive keyboard focus on several surfaces, so the first | 9 | // 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 on | 10 | // 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 | 11 | + | // 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 selection | 14 | 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.KeyboardOnDemand | 153 | + | kb := neferclient.KeyboardOnDemand |
| 152 | if m.index == 0 { | 154 | if m.index == 0 { | ||
| 153 | - | kb = nefergui.KeyboardExclusive | 155 | + | 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 keyboard | 166 | // 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. | |
| 2 | 3 | package uierrors | |
| 3 | 4 | ||
| 4 | 5 | import ( | |
| @@ -11,7 +12,7 @@ import ( | |||
| 11 | 12 | // merely wraps a target (fmt.Errorf("x: %w", context.Canceled)) is kept, and so | |
| 12 | 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 the | |
| 15 | + | // Use it to discard the context.Canceled a layerui.Run reports after the | |
| 15 | 16 | // adapter itself ended it, without hiding a real error joined to it. | |
| 16 | 17 | func Drop(err error, targets ...error) error { | |
| 17 | 18 | if err == nil { | |
| @@ -40,7 +41,7 @@ func Drop(err error, targets ...error) error { | |||
| 40 | 41 | return err | |
| 41 | 42 | } | |
| 42 | 43 | ||
| 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 | |
| 44 | 45 | // its state once and passes it, so a context that ends between two reads cannot | |
| 45 | 46 | // flip the answer. | |
| 46 | 47 | // | |
| @@ -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 uierrors | 3 | 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 so | 12 | // 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 the | 15 | + | // 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 err | 41 | return err | ||
| 41 | } | 42 | } | ||
| 42 | 43 | ||||
| 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 |
| 44 | // its state once and passes it, so a context that ends between two reads cannot | 45 | // 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 { | |||
| 376 | 376 | } | |
| 377 | 377 | ||
| 378 | 378 | // 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. | |
| 381 | 381 | // | |
| 382 | 382 | // State machine: pending -> attached | failed; attached -> detached. Any other | |
| 383 | 383 | // event is a protocol violation: the exclusion cannot be trusted any more. | |
| @@ -385,6 +385,7 @@ type layerProxy struct { | |||
| 385 | 385 | wlturbo.BaseProxy | |
| 386 | 386 | display *wlturbo.Display | |
| 387 | 387 | onDetached func(error) | |
| 388 | + | answered chan struct{} | |
| 388 | 389 | ||
| 389 | 390 | mu sync.Mutex | |
| 390 | 391 | state layerState | |
| @@ -440,7 +441,11 @@ func (l *layerProxy) Dispatch(e *wlturbo.Event) { | |||
| 440 | 441 | l.notified, notify = true, errLayerProtocol | |
| 441 | 442 | } | |
| 442 | 443 | } | |
| 444 | + | settled := l.state != layerPending || l.violation | |
| 443 | 445 | l.mu.Unlock() | |
| 446 | + | if settled { | |
| 447 | + | l.answer() | |
| 448 | + | } | |
| 444 | 449 | if closeDisplay && returned { | |
| 445 | 450 | _ = 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. | |
| 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 | + | } | |
| 457 | 464 | } | |
| 458 | 465 | ||
| 459 | 466 | // 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 | 480 | // confirmed the exclusion for this very attachment, so the surface is safe to | |
| 474 | 481 | // show. | |
| 475 | 482 | // | |
| 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 | |
| 479 | 488 | // display's error. A cancelled ctx, or no answer within DefaultAttachTimeout | |
| 480 | 489 | // (an error wrapping context.DeadlineExceeded), closes display before | |
| 481 | 490 | // 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 | 501 | // would not release the exclusion, and closing the connection ends it. | |
| 493 | 502 | // | |
| 494 | 503 | // 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 | 505 | func AuthorizeLayer(ctx context.Context, display *wlturbo.Display, surface wlturbo.Proxy, token string, onDetached func(error)) error { | |
| 497 | 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 | 528 | stop := context.AfterFunc(wctx, func() { _ = display.Close() }) | |
| 520 | 529 | defer stop() | |
| 521 | 530 | ||
| 522 | - | l := &layerProxy{display: display, onDetached: onDetached} | |
| 531 | + | l := &layerProxy{display: display, onDetached: onDetached, answered: make(chan struct{})} | |
| 523 | 532 | l.SetContext(display.Context()) | |
| 524 | 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() { | |
| 526 | - | err = display.Dispatch() | |
| 534 | + | if err == nil { | |
| 535 | + | select { | |
| 536 | + | case <-l.answered: | |
| 537 | + | case <-wctx.Done(): // the watcher closes display | |
| 538 | + | } | |
| 527 | 539 | } | |
| 528 | 540 | // Detach the watcher before anything else is decided or sent. When stop | |
| 529 | 541 | // 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 are | 378 | // layerProxy is one neferwl_capture_layer_v1 attachment. Its events are | ||
| 379 | - | // dispatched by whichever goroutine reads the GUI connection: during | 379 | + | // 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 other | 382 | // 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.BaseProxy | 385 | wlturbo.BaseProxy | ||
| 386 | display *wlturbo.Display | 386 | display *wlturbo.Display | ||
| 387 | onDetached func(error) | 387 | onDetached func(error) | ||
| 388 | + | answered chan struct{} | |||
| 388 | 389 | ||||
| 389 | mu sync.Mutex | 390 | mu sync.Mutex | ||
| 390 | state layerState | 391 | state layerState | ||
| @@ -440,7 +441,11 @@ func (l *layerProxy) Dispatch(e *wlturbo.Event) { | |||||
| 440 | l.notified, notify = true, errLayerProtocol | 441 | 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 captures | 450 | _ = 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.violation | 461 | + | default: |
| 462 | + | close(l.answered) | |||
| 463 | + | } | |||
| 457 | } | 464 | } | ||
| 458 | 465 | ||||
| 459 | // hand marks the layer as owned by the caller of authorizeLayer, and reports | 466 | // 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 to | 480 | // 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 attachment | 483 | + | // Other goroutines must keep reading display's events meanwhile (with |
| 477 | - | // answers, so it must run before the connection's event loop starts. A | 484 | + | // neferclient: its reader, and the owner calling Conn.Dispatch so the reader |
| 478 | - | // refusal returns an *AttachFailedError. A protocol error is returned as the | 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 | |||
| 479 | // display's error. A cancelled ctx, or no answer within DefaultAttachTimeout | 488 | // display's error. A cancelled ctx, or no answer within DefaultAttachTimeout | ||
| 480 | // (an error wrapping context.DeadlineExceeded), closes display before | 489 | // (an error wrapping context.DeadlineExceeded), closes display before | ||
| 481 | // returning: the surface must then stay unmapped. So does a violation of the | 490 | // 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; it | 503 | // 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 stop | 540 | // Detach the watcher before anything else is decided or sent. When stop | ||
| 529 | // reports false the watcher has fired or is firing: cancellation and the | 541 | // 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) { | |||
| 120 | 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 | 125 | func hudConnection(t *testing.T, srv *testserver.Server) (*wlturbo.Display, *core.Surface) { | |
| 125 | 126 | t.Helper() | |
| 126 | 127 | conn, err := net.Dial("unix", srv.Path) | |
| @@ -134,6 +135,13 @@ func hudConnection(t *testing.T, srv *testserver.Server) (*wlturbo.Display, *cor | |||
| 134 | 135 | require.NoError(t, err) | |
| 135 | 136 | surface, err := comp.CreateSurface() | |
| 136 | 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 | 145 | return display, surface | |
| 138 | 146 | } | |
| 139 | 147 | ||
| @@ -178,8 +186,10 @@ func TestAuthorizeLayerDetachedCallback(t *testing.T) { | |||
| 178 | 186 | })) | |
| 179 | 187 | require.Nil(t, got.Load()) | |
| 180 | 188 | 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") | |
| 183 | 193 | } | |
| 184 | 194 | d := got.Load() | |
| 185 | 195 | require.NotNil(t, d) | |
| @@ -193,12 +203,12 @@ func TestAuthorizeLayerProtocolViolationClosesDisplay(t *testing.T) { | |||
| 193 | 203 | got := make(chan error, 1) | |
| 194 | 204 | require.NoError(t, AuthorizeLayer(context.Background(), display, surface, testserver.Token, func(err error) { got <- err })) | |
| 195 | 205 | 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") | |
| 200 | 211 | } | |
| 201 | - | require.ErrorIs(t, <-got, ErrExclusionUnavailable) | |
| 202 | 212 | require.True(t, display.Closed(), "the surface could be visible in captures: the connection ends") | |
| 203 | 213 | } | |
| 204 | 214 | ||
| @@ -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, surface | 145 | 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 attached | 205 | 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 | - | break | 208 | + | 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 ( | |||
| 14 | 14 | "github.com/bnema/nefercap/internal/core" | |
| 15 | 15 | "github.com/bnema/nefercap/internal/logging" | |
| 16 | 16 | "github.com/bnema/nefercap/internal/ports" | |
| 17 | - | "github.com/bnema/nefergui" | |
| 17 | + | "github.com/bnema/wlturbo" | |
| 18 | 18 | ) | |
| 19 | 19 | ||
| 20 | 20 | // recordInteractive records and listens for stop requests. With the | |
| @@ -160,8 +160,8 @@ type hudHandle struct { | |||
| 160 | 160 | func startHUD(ctx context.Context, output ports.Output, frame ports.Region, token string, stop chan<- struct{}, life *lifecycle) hudHandle { | |
| 161 | 161 | ready := make(chan struct{}, 1) | |
| 162 | 162 | 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 { | |
| 165 | 165 | return err | |
| 166 | 166 | } | |
| 167 | 167 | 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 the | 20 | // 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 err | 165 | 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 { | |||
| 245 | 245 | 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 | 248 | // Confirm accepts the chosen monitor or workspace. It does nothing for the | |
| 263 | 249 | // default region kind, whose selection is made by the mouse. | |
| 264 | 250 | func (p *Picker) Confirm() bool { | |
| @@ -245,20 +245,6 @@ func (p *Picker) MouseUp(x, y float64) bool { | |||||
| 245 | return true | 245 | 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 the | 248 | // 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) { | |||
| 303 | 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 | 306 | func TestPickerAllocations(t *testing.T) { | |
| 379 | 307 | p := newPicker(t, 1, 1920, 1080) | |
| 380 | 308 | p.SetWorkspace(1, ports.Region{Width: 100, Height: 100}) | |
| @@ -385,9 +313,6 @@ func TestPickerAllocations(t *testing.T) { | |||
| 385 | 313 | _, _ = p.Rect() | |
| 386 | 314 | _, _ = p.Result() | |
| 387 | 315 | } | |
| 388 | - | p.AbandonPress() | |
| 389 | - | p.MouseDown(5, 5) | |
| 390 | - | p.AbandonPress() | |
| 391 | 316 | p.SelectMonitor() | |
| 392 | 317 | p.SelectRegion() | |
| 393 | 318 | 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() | ||