detached 5343c40

feat(wayland): advertise and apply output transforms

commit
5343c40e3d1b99ec88778e739396c94109daa4ff
author
brice · · 2026-10-03 18:46 UTC
committer
brice · · 2026-10-03 18:47 UTC
parent
7d665d6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +232 -12
M internal/adapters/wayland/imagecopy.go +7 -1
@@ -601,6 +601,12 @@ func (f *captureExtFrame) attempt() {
601601 return
602602 }
603603 tag, ok := c.tag(hidden)
604+ // On-screen frames are target pixels, which the output holds under its
605+ // transform; off-screen child images are unrotated.
606+ transform := uint32(0)
607+ if !hidden {
608+ transform = uint32(o.place.Transform)
609+ }
604610 buf, isBuf := c.s.buffers[f.buf.GetResource()].(*buffer)
605611 switch {
606612 case !isBuf:
@@ -621,7 +627,7 @@ func (f *captureExtFrame) attempt() {
621627 f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
622628 return
623629 }
624- f.res.SendTransform(0)
630+ f.res.SendTransform(transform)
625631 f.res.SendDamage(0, 0, int32(region.Dx()), int32(region.Dy()))
626632 hi, lo, ns := captureTime(done.Time)
627633 f.res.SendPresentationTime(hi, lo, ns)
@@ -601,6 +601,12 @@ func (f *captureExtFrame) attempt() {
601 return601 return
602 }602 }
603 tag, ok := c.tag(hidden)603 tag, ok := c.tag(hidden)
604+ // On-screen frames are target pixels, which the output holds under its
605+ // transform; off-screen child images are unrotated.
606+ transform := uint32(0)
607+ if !hidden {
608+ transform = uint32(o.place.Transform)
609+ }
604 buf, isBuf := c.s.buffers[f.buf.GetResource()].(*buffer)610 buf, isBuf := c.s.buffers[f.buf.GetResource()].(*buffer)
605 switch {611 switch {
606 case !isBuf:612 case !isBuf:
@@ -621,7 +627,7 @@ func (f *captureExtFrame) attempt() {
621 f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))627 f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
622 return628 return
623 }629 }
624- f.res.SendTransform(0)630+ f.res.SendTransform(transform)
625 f.res.SendDamage(0, 0, int32(region.Dx()), int32(region.Dy()))631 f.res.SendDamage(0, 0, int32(region.Dx()), int32(region.Dy()))
626 hi, lo, ns := captureTime(done.Time)632 hi, lo, ns := captureTime(done.Time)
627 f.res.SendPresentationTime(hi, lo, ns)633 f.res.SendPresentationTime(hi, lo, ns)
M internal/adapters/wayland/outputconfiguration.go +2 -3
@@ -159,9 +159,8 @@ func (h *headConfiguration) SetTransform(r *wlr.ZwlrOutputConfigurationHeadV1, v
159159 r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidTransform), "invalid transform")
160160 return
161161 }
162- if v != 0 {
163- h.unsupported = true
164- }
162+ t := ports.BufferTransform(v)
163+ h.change.Transform = &t
165164 }
166165 func (h *headConfiguration) SetScale(r *wlr.ZwlrOutputConfigurationHeadV1, v server.Fixed) {
167166 if !h.once(&h.scaleSet, r) {
@@ -159,9 +159,8 @@ func (h *headConfiguration) SetTransform(r *wlr.ZwlrOutputConfigurationHeadV1, v
159 r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidTransform), "invalid transform")159 r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidTransform), "invalid transform")
160 return160 return
161 }161 }
162- if v != 0 {162+ t := ports.BufferTransform(v)
163- h.unsupported = true163+ h.change.Transform = &t
164- }
165 }164 }
166 func (h *headConfiguration) SetScale(r *wlr.ZwlrOutputConfigurationHeadV1, v server.Fixed) {165 func (h *headConfiguration) SetScale(r *wlr.ZwlrOutputConfigurationHeadV1, v server.Fixed) {
167 if !h.once(&h.scaleSet, r) {166 if !h.once(&h.scaleSet, r) {
M internal/adapters/wayland/outputmanagement.go +3 -1
@@ -169,8 +169,10 @@ func (m *outputManager) sendState(h *managementHead, info ports.OutputHead) {
169169 if place.Info.Name != "" {
170170 h.res.SendPosition(int32(place.X), int32(place.Y))
171171 h.res.SendScale(server.FixedFromFloat(place.Scale))
172+ h.res.SendTransform(int32(place.Transform))
173+ } else {
174+ h.res.SendTransform(0)
172175 }
173- h.res.SendTransform(0)
174176 }
175177 m.places[info.Info.Name] = m.s.placementFor(info.Info.Name)
176178 }
@@ -169,8 +169,10 @@ func (m *outputManager) sendState(h *managementHead, info ports.OutputHead) {
169 if place.Info.Name != "" {169 if place.Info.Name != "" {
170 h.res.SendPosition(int32(place.X), int32(place.Y))170 h.res.SendPosition(int32(place.X), int32(place.Y))
171 h.res.SendScale(server.FixedFromFloat(place.Scale))171 h.res.SendScale(server.FixedFromFloat(place.Scale))
172+ h.res.SendTransform(int32(place.Transform))
173+ } else {
174+ h.res.SendTransform(0)
172 }175 }
173- h.res.SendTransform(0)
174 }176 }
175 m.places[info.Info.Name] = m.s.placementFor(info.Info.Name)177 m.places[info.Info.Name] = m.s.placementFor(info.Info.Name)
176 }178 }
M internal/adapters/wayland/outputmanagement_test.go +79 -3
@@ -243,9 +243,6 @@ func TestOutputManagementUnsupportedSettings(t *testing.T) {
243243 {"custom mode", func(t *testing.T, c *wlturbo.Display, ch uint32) {
244244 requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetCustomMode, int32(800), int32(600), int32(60000))
245245 }},
246- {"transform", func(t *testing.T, c *wlturbo.Display, ch uint32) {
247- requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetTransform, int32(1))
248- }},
249246 {"adaptive sync off", func(t *testing.T, c *wlturbo.Display, ch uint32) {
250247 requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetAdaptiveSync, uint32(0))
251248 }},
@@ -289,6 +286,85 @@ func TestOutputManagementUnsupportedSettings(t *testing.T) {
289286 }
290287
291288 // Each case uses a new connection because wl_display.error terminates that client.
289+// set_transform reaches the backend, and heads report the placement's
290+// transform, at bind time and when it changes.
291+func TestOutputManagementTransform(t *testing.T) {
292+ s, _, apply, replies, dir := outputTestServer(t)
293+ heads := testHead()
294+ if !s.display.Do(func() {
295+ s.setOutputHeads(heads)
296+ s.setOutputs(ports.SetOutputs{Outputs: ports.Layout{{Info: heads.Heads[0].Info, Scale: 1, Transform: 1}}})
297+ }) {
298+ t.Fatal("display stopped")
299+ }
300+ c := protocolClient(t, s, dir)
301+ id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
302+ p := &managementEvents{events: make(chan [2]uint32, 32), headEvents: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}
303+ p.SetID(id)
304+ registerWireProxy(c, p)
305+ if err := c.Roundtrip(); err != nil {
306+ t.Fatal(err)
307+ }
308+ var head, serial [2]uint32
309+ for ev := recvManagement(t, p.events); ; ev = recvManagement(t, p.events) {
310+ if ev[0] == wlr.ZwlrOutputManagerV1EventHead {
311+ head = ev
312+ }
313+ if ev[0] == wlr.ZwlrOutputManagerV1EventDone {
314+ serial = ev
315+ break
316+ }
317+ }
318+ transform := func() (uint32, bool) {
319+ v, ok := uint32(0), false
320+ for len(p.headEvents) > 0 {
321+ if ev := <-p.headEvents; ev[0] == wlr.ZwlrOutputHeadV1EventTransform {
322+ v, ok = ev[1], true
323+ }
324+ }
325+ return v, ok
326+ }
327+ if v, ok := transform(); !ok || v != 1 {
328+ t.Fatalf("initial transform: %d, %v", v, ok)
329+ }
330+ configID := c.AllocateID()
331+ configProxy := &managementEvents{events: make(chan [2]uint32, 8), client: c}
332+ configProxy.SetID(configID)
333+ registerWireProxy(c, configProxy)
334+ requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, serial[1])
335+ chID := c.AllocateID()
336+ registerProtocol(t, c, chID)
337+ requestProtocol(t, c, configID, wlr.ZwlrOutputConfigurationV1RequestEnableHead, chID, head[1])
338+ requestProtocol(t, c, chID, wlr.ZwlrOutputConfigurationHeadV1RequestSetTransform, int32(3))
339+ requestProtocol(t, c, configID, wlr.ZwlrOutputConfigurationV1RequestApply)
340+ if err := c.Roundtrip(); err != nil {
341+ t.Fatal(err)
342+ }
343+ var req ports.OutputApply
344+ select {
345+ case req = <-apply:
346+ case <-time.After(time.Second):
347+ t.Fatal("no apply request")
348+ }
349+ if len(req.Heads) != 1 || req.Heads[0].Transform == nil || *req.Heads[0].Transform != 3 {
350+ t.Fatalf("apply: %+v", req)
351+ }
352+ replies <- ports.OutputApplied{ID: req.ID}
353+ waitManagementEvent(t, c, configProxy.events, uint32(wlr.ZwlrOutputConfigurationV1EventSucceeded))
354+ if !s.display.Do(func() {
355+ s.setOutputs(ports.SetOutputs{Outputs: ports.Layout{{Info: heads.Heads[0].Info, Scale: 1, Transform: 3}}})
356+ s.setOutputHeads(heads)
357+ }) {
358+ t.Fatal("display stopped")
359+ }
360+ if err := c.Roundtrip(); err != nil {
361+ t.Fatal(err)
362+ }
363+ if v, ok := transform(); !ok || v != 3 {
364+ t.Fatalf("updated transform: %d, %v", v, ok)
365+ }
366+}
367+
292368 func TestOutputManagementProtocolErrors(t *testing.T) {
293369 const (
294370 enable = wlr.ZwlrOutputConfigurationV1RequestEnableHead
@@ -243,9 +243,6 @@ func TestOutputManagementUnsupportedSettings(t *testing.T) {
243 {"custom mode", func(t *testing.T, c *wlturbo.Display, ch uint32) {243 {"custom mode", func(t *testing.T, c *wlturbo.Display, ch uint32) {
244 requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetCustomMode, int32(800), int32(600), int32(60000))244 requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetCustomMode, int32(800), int32(600), int32(60000))
245 }},245 }},
246- {"transform", func(t *testing.T, c *wlturbo.Display, ch uint32) {
247- requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetTransform, int32(1))
248- }},
249 {"adaptive sync off", func(t *testing.T, c *wlturbo.Display, ch uint32) {246 {"adaptive sync off", func(t *testing.T, c *wlturbo.Display, ch uint32) {
250 requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetAdaptiveSync, uint32(0))247 requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetAdaptiveSync, uint32(0))
251 }},248 }},
@@ -289,6 +286,85 @@ func TestOutputManagementUnsupportedSettings(t *testing.T) {
289 }286 }
290 287
291 // Each case uses a new connection because wl_display.error terminates that client.288 // Each case uses a new connection because wl_display.error terminates that client.
289+// set_transform reaches the backend, and heads report the placement's
290+// transform, at bind time and when it changes.
291+func TestOutputManagementTransform(t *testing.T) {
292+ s, _, apply, replies, dir := outputTestServer(t)
293+ heads := testHead()
294+ if !s.display.Do(func() {
295+ s.setOutputHeads(heads)
296+ s.setOutputs(ports.SetOutputs{Outputs: ports.Layout{{Info: heads.Heads[0].Info, Scale: 1, Transform: 1}}})
297+ }) {
298+ t.Fatal("display stopped")
299+ }
300+ c := protocolClient(t, s, dir)
301+ id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
302+ p := &managementEvents{events: make(chan [2]uint32, 32), headEvents: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}
303+ p.SetID(id)
304+ registerWireProxy(c, p)
305+ if err := c.Roundtrip(); err != nil {
306+ t.Fatal(err)
307+ }
308+ var head, serial [2]uint32
309+ for ev := recvManagement(t, p.events); ; ev = recvManagement(t, p.events) {
310+ if ev[0] == wlr.ZwlrOutputManagerV1EventHead {
311+ head = ev
312+ }
313+ if ev[0] == wlr.ZwlrOutputManagerV1EventDone {
314+ serial = ev
315+ break
316+ }
317+ }
318+ transform := func() (uint32, bool) {
319+ v, ok := uint32(0), false
320+ for len(p.headEvents) > 0 {
321+ if ev := <-p.headEvents; ev[0] == wlr.ZwlrOutputHeadV1EventTransform {
322+ v, ok = ev[1], true
323+ }
324+ }
325+ return v, ok
326+ }
327+ if v, ok := transform(); !ok || v != 1 {
328+ t.Fatalf("initial transform: %d, %v", v, ok)
329+ }
330+ configID := c.AllocateID()
331+ configProxy := &managementEvents{events: make(chan [2]uint32, 8), client: c}
332+ configProxy.SetID(configID)
333+ registerWireProxy(c, configProxy)
334+ requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, serial[1])
335+ chID := c.AllocateID()
336+ registerProtocol(t, c, chID)
337+ requestProtocol(t, c, configID, wlr.ZwlrOutputConfigurationV1RequestEnableHead, chID, head[1])
338+ requestProtocol(t, c, chID, wlr.ZwlrOutputConfigurationHeadV1RequestSetTransform, int32(3))
339+ requestProtocol(t, c, configID, wlr.ZwlrOutputConfigurationV1RequestApply)
340+ if err := c.Roundtrip(); err != nil {
341+ t.Fatal(err)
342+ }
343+ var req ports.OutputApply
344+ select {
345+ case req = <-apply:
346+ case <-time.After(time.Second):
347+ t.Fatal("no apply request")
348+ }
349+ if len(req.Heads) != 1 || req.Heads[0].Transform == nil || *req.Heads[0].Transform != 3 {
350+ t.Fatalf("apply: %+v", req)
351+ }
352+ replies <- ports.OutputApplied{ID: req.ID}
353+ waitManagementEvent(t, c, configProxy.events, uint32(wlr.ZwlrOutputConfigurationV1EventSucceeded))
354+ if !s.display.Do(func() {
355+ s.setOutputs(ports.SetOutputs{Outputs: ports.Layout{{Info: heads.Heads[0].Info, Scale: 1, Transform: 3}}})
356+ s.setOutputHeads(heads)
357+ }) {
358+ t.Fatal("display stopped")
359+ }
360+ if err := c.Roundtrip(); err != nil {
361+ t.Fatal(err)
362+ }
363+ if v, ok := transform(); !ok || v != 3 {
364+ t.Fatalf("updated transform: %d, %v", v, ok)
365+ }
366+}
367+
292 func TestOutputManagementProtocolErrors(t *testing.T) {368 func TestOutputManagementProtocolErrors(t *testing.T) {
293 const (369 const (
294 enable = wlr.ZwlrOutputConfigurationV1RequestEnableHead370 enable = wlr.ZwlrOutputConfigurationV1RequestEnableHead
M internal/adapters/wayland/outputs.go +3 -1
@@ -65,7 +65,7 @@ func (o *output) sendAll(r *wayland.Output) {
6565 if refresh == 0 {
6666 refresh = 60000
6767 }
68- r.SendGeometry(int32(o.place.X), int32(o.place.Y), int32(i.PhysicalW), int32(i.PhysicalH), 0, make, model, 0)
68+ r.SendGeometry(int32(o.place.X), int32(o.place.Y), int32(i.PhysicalW), int32(i.PhysicalH), 0, make, model, int32(o.place.Transform))
6969 // The mode stays physical; the scale tells clients how to divide it.
7070 r.SendMode(3, int32(i.Width), int32(i.Height), int32(refresh))
7171 if r.Version() >= 2 {
@@ -209,6 +209,8 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
209209 continue
210210 }
211211 scaleChanged := o.place.Scale != p.Scale
212+ // A 90° to 270° change keeps the logical size: lock surfaces are
213+ // logical-sized and need no reconfigure then.
212214 logicalChanged := o.place.Width != p.Width || o.place.Height != p.Height
213215 o.place = p
214216 if logicalChanged {
@@ -65,7 +65,7 @@ func (o *output) sendAll(r *wayland.Output) {
65 if refresh == 0 {65 if refresh == 0 {
66 refresh = 6000066 refresh = 60000
67 }67 }
68- r.SendGeometry(int32(o.place.X), int32(o.place.Y), int32(i.PhysicalW), int32(i.PhysicalH), 0, make, model, 0)68+ r.SendGeometry(int32(o.place.X), int32(o.place.Y), int32(i.PhysicalW), int32(i.PhysicalH), 0, make, model, int32(o.place.Transform))
69 // The mode stays physical; the scale tells clients how to divide it.69 // The mode stays physical; the scale tells clients how to divide it.
70 r.SendMode(3, int32(i.Width), int32(i.Height), int32(refresh))70 r.SendMode(3, int32(i.Width), int32(i.Height), int32(refresh))
71 if r.Version() >= 2 {71 if r.Version() >= 2 {
@@ -209,6 +209,8 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
209 continue209 continue
210 }210 }
211 scaleChanged := o.place.Scale != p.Scale211 scaleChanged := o.place.Scale != p.Scale
212+ // A 90° to 270° change keeps the logical size: lock surfaces are
213+ // logical-sized and need no reconfigure then.
212 logicalChanged := o.place.Width != p.Width || o.place.Height != p.Height214 logicalChanged := o.place.Width != p.Width || o.place.Height != p.Height
213 o.place = p215 o.place = p
214 if logicalChanged {216 if logicalChanged {
M internal/adapters/wayland/scale.go +11 -0
@@ -6,6 +6,7 @@ import (
66 "github.com/bnema/go-wayland-bindings/server/fractionalscale"
77 "github.com/bnema/go-wayland-bindings/server/viewporter"
88 "github.com/bnema/go-wayland-bindings/server/wayland"
9+ "github.com/bnema/neferwl/internal/ports"
910 "github.com/bnema/purego-libwayland/server"
1011 )
1112
@@ -76,6 +77,16 @@ func (surf *surface) sendScale() {
7677 if o != nil {
7778 scale = o.place.Scale
7879 }
80+ // The output holds the scene under its transform, so a client that
81+ // renders with the same buffer transform avoids a compositing pass.
82+ var t ports.BufferTransform
83+ if o != nil {
84+ t = o.place.Transform
85+ }
86+ if surf.wl.Version() >= 6 && (!surf.prefTransformSent || t != surf.prefTransform) {
87+ surf.prefTransform, surf.prefTransformSent = t, true
88+ surf.wl.SendPreferredBufferTransform(uint32(t))
89+ }
7990 if scale == surf.scale {
8091 return
8192 }
@@ -6,6 +6,7 @@ import (
6 "github.com/bnema/go-wayland-bindings/server/fractionalscale"6 "github.com/bnema/go-wayland-bindings/server/fractionalscale"
7 "github.com/bnema/go-wayland-bindings/server/viewporter"7 "github.com/bnema/go-wayland-bindings/server/viewporter"
8 "github.com/bnema/go-wayland-bindings/server/wayland"8 "github.com/bnema/go-wayland-bindings/server/wayland"
9+ "github.com/bnema/neferwl/internal/ports"
9 "github.com/bnema/purego-libwayland/server"10 "github.com/bnema/purego-libwayland/server"
10 )11 )
11 12
@@ -76,6 +77,16 @@ func (surf *surface) sendScale() {
76 if o != nil {77 if o != nil {
77 scale = o.place.Scale78 scale = o.place.Scale
78 }79 }
80+ // The output holds the scene under its transform, so a client that
81+ // renders with the same buffer transform avoids a compositing pass.
82+ var t ports.BufferTransform
83+ if o != nil {
84+ t = o.place.Transform
85+ }
86+ if surf.wl.Version() >= 6 && (!surf.prefTransformSent || t != surf.prefTransform) {
87+ surf.prefTransform, surf.prefTransformSent = t, true
88+ surf.wl.SendPreferredBufferTransform(uint32(t))
89+ }
79 if scale == surf.scale {90 if scale == surf.scale {
80 return91 return
81 }92 }
M internal/adapters/wayland/scale_test.go +61 -0
@@ -2,6 +2,7 @@ package wayland
22
33 import (
44 "context"
5+ "slices"
56 "testing"
67 "time"
78
@@ -38,6 +39,18 @@ func (p *surfaceScaleProxy) Dispatch(e *wlturbo.Event) {
3839 }
3940 }
4041
42+// surfaceTransformProxy records wl_surface.preferred_buffer_transform (opcode 3).
43+type surfaceTransformProxy struct {
44+ wlturbo.BaseProxy
45+ transforms chan uint32
46+}
47+
48+func (p *surfaceTransformProxy) Dispatch(e *wlturbo.Event) {
49+ if e.Opcode == 3 {
50+ p.transforms <- e.Uint32()
51+ }
52+}
53+
4154 func contentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, chan ports.SurfaceContent, string) {
4255 t.Helper()
4356 dir := t.TempDir()
@@ -55,6 +68,54 @@ func contentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.Cl
5568 return s, events, commands, contents, dir
5669 }
5770
71+// A v6 surface learns the transform of its output once, and again only when
72+// the transform changes.
73+func TestPreferredBufferTransform(t *testing.T) {
74+ s, _, _, _, dir := contentServer(t)
75+ info := testOutputs[0].Info
76+ setOutputs := func(tr ports.BufferTransform, scale float64) {
77+ if !s.display.Do(func() {
78+ s.setOutputs(ports.SetOutputs{Outputs: ports.Layout{{Info: info, Width: info.Height, Height: info.Width, Scale: scale, Transform: tr}}})
79+ }) {
80+ t.Fatal("display stopped")
81+ }
82+ }
83+ setOutputs(1, 1)
84+ c := protocolClient(t, s, dir)
85+ g, _ := c.Registry().FindGlobal("wl_compositor")
86+ comp, err := bindWireID(c, g.Name, g.Interface, 6)
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ surf := c.AllocateID()
91+ p := &surfaceTransformProxy{transforms: make(chan uint32, 8)}
92+ p.SetID(surf)
93+ registerWireProxy(c, p)
94+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
95+ drain := func() []uint32 {
96+ if err := c.Roundtrip(); err != nil {
97+ t.Fatal(err)
98+ }
99+ var got []uint32
100+ for len(p.transforms) > 0 {
101+ got = append(got, <-p.transforms)
102+ }
103+ return got
104+ }
105+ if got := drain(); !slices.Equal(got, []uint32{1}) {
106+ t.Fatalf("initial transforms %v, want [1]", got)
107+ }
108+ // A scale change alone does not repeat the transform.
109+ setOutputs(1, 2)
110+ if got := drain(); len(got) != 0 {
111+ t.Fatalf("unchanged transform resent: %v", got)
112+ }
113+ setOutputs(3, 2)
114+ if got := drain(); !slices.Equal(got, []uint32{3}) {
115+ t.Fatalf("changed transforms %v, want [3]", got)
116+ }
117+}
118+
58119 func TestFractionalScaleAndViewport(t *testing.T) {
59120 s, events, commands, contents, dir := contentServer(t)
60121 c := protocolClient(t, s, dir)
@@ -2,6 +2,7 @@ package wayland
2 2
3 import (3 import (
4 "context"4 "context"
5+ "slices"
5 "testing"6 "testing"
6 "time"7 "time"
7 8
@@ -38,6 +39,18 @@ func (p *surfaceScaleProxy) Dispatch(e *wlturbo.Event) {
38 }39 }
39 }40 }
40 41
42+// surfaceTransformProxy records wl_surface.preferred_buffer_transform (opcode 3).
43+type surfaceTransformProxy struct {
44+ wlturbo.BaseProxy
45+ transforms chan uint32
46+}
47+
48+func (p *surfaceTransformProxy) Dispatch(e *wlturbo.Event) {
49+ if e.Opcode == 3 {
50+ p.transforms <- e.Uint32()
51+ }
52+}
53+
41 func contentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, chan ports.SurfaceContent, string) {54 func contentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, chan ports.SurfaceContent, string) {
42 t.Helper()55 t.Helper()
43 dir := t.TempDir()56 dir := t.TempDir()
@@ -55,6 +68,54 @@ func contentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.Cl
55 return s, events, commands, contents, dir68 return s, events, commands, contents, dir
56 }69 }
57 70
71+// A v6 surface learns the transform of its output once, and again only when
72+// the transform changes.
73+func TestPreferredBufferTransform(t *testing.T) {
74+ s, _, _, _, dir := contentServer(t)
75+ info := testOutputs[0].Info
76+ setOutputs := func(tr ports.BufferTransform, scale float64) {
77+ if !s.display.Do(func() {
78+ s.setOutputs(ports.SetOutputs{Outputs: ports.Layout{{Info: info, Width: info.Height, Height: info.Width, Scale: scale, Transform: tr}}})
79+ }) {
80+ t.Fatal("display stopped")
81+ }
82+ }
83+ setOutputs(1, 1)
84+ c := protocolClient(t, s, dir)
85+ g, _ := c.Registry().FindGlobal("wl_compositor")
86+ comp, err := bindWireID(c, g.Name, g.Interface, 6)
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ surf := c.AllocateID()
91+ p := &surfaceTransformProxy{transforms: make(chan uint32, 8)}
92+ p.SetID(surf)
93+ registerWireProxy(c, p)
94+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
95+ drain := func() []uint32 {
96+ if err := c.Roundtrip(); err != nil {
97+ t.Fatal(err)
98+ }
99+ var got []uint32
100+ for len(p.transforms) > 0 {
101+ got = append(got, <-p.transforms)
102+ }
103+ return got
104+ }
105+ if got := drain(); !slices.Equal(got, []uint32{1}) {
106+ t.Fatalf("initial transforms %v, want [1]", got)
107+ }
108+ // A scale change alone does not repeat the transform.
109+ setOutputs(1, 2)
110+ if got := drain(); len(got) != 0 {
111+ t.Fatalf("unchanged transform resent: %v", got)
112+ }
113+ setOutputs(3, 2)
114+ if got := drain(); !slices.Equal(got, []uint32{3}) {
115+ t.Fatalf("changed transforms %v, want [3]", got)
116+ }
117+}
118+
58 func TestFractionalScaleAndViewport(t *testing.T) {119 func TestFractionalScaleAndViewport(t *testing.T) {
59 s, events, commands, contents, dir := contentServer(t)120 s, events, commands, contents, dir := contentServer(t)
60 c := protocolClient(t, s, dir)121 c := protocolClient(t, s, dir)
M internal/adapters/wayland/screencopy.go +16 -3
@@ -19,8 +19,9 @@ import (
1919 // release its slot: only the backend completion does.
2020 const maxCaptureInflight = ports.MaxCaptureInflight
2121
22-// The renderer's scene and wl_output modes are already in physical orientation;
23-// this compositor does not advertise rotated wl_output transforms.
22+// captureRegion maps a logical rectangle to target pixels: scaled to the
23+// scene-physical space, then through the output transform into the target
24+// (the framebuffer, mode size) that captures read.
2425 func captureRegion(o *output, logical image.Rectangle) image.Rectangle {
2526 if o == nil {
2627 return image.Rectangle{}
@@ -29,8 +30,19 @@ func captureRegion(o *output, logical image.Rectangle) image.Rectangle {
2930 if scale <= 0 {
3031 scale = 1
3132 }
33+ sw, sh := sceneSize(o)
3234 p := image.Rect(int(math.Floor(float64(logical.Min.X)*scale)), int(math.Floor(float64(logical.Min.Y)*scale)), int(math.Ceil(float64(logical.Max.X)*scale)), int(math.Ceil(float64(logical.Max.Y)*scale)))
33- return p.Intersect(image.Rect(0, 0, o.place.Info.Width, o.place.Info.Height))
35+ return o.place.Transform.RectToBuffer(p, sw, sh).Intersect(image.Rect(0, 0, o.place.Info.Width, o.place.Info.Height))
36+}
37+
38+// sceneSize is the scene-physical size of the output: the mode size, swapped
39+// for a 90° or 270° transform.
40+func sceneSize(o *output) (int, int) {
41+ w, h := o.place.Info.Width, o.place.Info.Height
42+ if o.place.Transform.Rotated() {
43+ return h, w
44+ }
45+ return w, h
3446 }
3547 func captureTime(t time.Time) (uint32, uint32, uint32) {
3648 sec := uint64(t.Unix())
@@ -154,6 +166,7 @@ func logicalRegion(o *output, phys image.Rectangle) ports.Rect {
154166 if phys == image.Rect(0, 0, o.place.Info.Width, o.place.Info.Height) {
155167 return ports.Rect{}
156168 }
169+ phys = o.place.Transform.Invert().RectToBuffer(phys, o.place.Info.Width, o.place.Info.Height)
157170 x0, y0 := int(math.Floor(float64(phys.Min.X)/scale)), int(math.Floor(float64(phys.Min.Y)/scale))
158171 x1, y1 := int(math.Ceil(float64(phys.Max.X)/scale)), int(math.Ceil(float64(phys.Max.Y)/scale))
159172 return ports.Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}
@@ -19,8 +19,9 @@ import (
19 // release its slot: only the backend completion does.19 // release its slot: only the backend completion does.
20 const maxCaptureInflight = ports.MaxCaptureInflight20 const maxCaptureInflight = ports.MaxCaptureInflight
21 21
22-// The renderer's scene and wl_output modes are already in physical orientation;22+// captureRegion maps a logical rectangle to target pixels: scaled to the
23-// this compositor does not advertise rotated wl_output transforms.23+// scene-physical space, then through the output transform into the target
24+// (the framebuffer, mode size) that captures read.
24 func captureRegion(o *output, logical image.Rectangle) image.Rectangle {25 func captureRegion(o *output, logical image.Rectangle) image.Rectangle {
25 if o == nil {26 if o == nil {
26 return image.Rectangle{}27 return image.Rectangle{}
@@ -29,8 +30,19 @@ func captureRegion(o *output, logical image.Rectangle) image.Rectangle {
29 if scale <= 0 {30 if scale <= 0 {
30 scale = 131 scale = 1
31 }32 }
33+ sw, sh := sceneSize(o)
32 p := image.Rect(int(math.Floor(float64(logical.Min.X)*scale)), int(math.Floor(float64(logical.Min.Y)*scale)), int(math.Ceil(float64(logical.Max.X)*scale)), int(math.Ceil(float64(logical.Max.Y)*scale)))34 p := image.Rect(int(math.Floor(float64(logical.Min.X)*scale)), int(math.Floor(float64(logical.Min.Y)*scale)), int(math.Ceil(float64(logical.Max.X)*scale)), int(math.Ceil(float64(logical.Max.Y)*scale)))
33- return p.Intersect(image.Rect(0, 0, o.place.Info.Width, o.place.Info.Height))35+ return o.place.Transform.RectToBuffer(p, sw, sh).Intersect(image.Rect(0, 0, o.place.Info.Width, o.place.Info.Height))
36+}
37+
38+// sceneSize is the scene-physical size of the output: the mode size, swapped
39+// for a 90° or 270° transform.
40+func sceneSize(o *output) (int, int) {
41+ w, h := o.place.Info.Width, o.place.Info.Height
42+ if o.place.Transform.Rotated() {
43+ return h, w
44+ }
45+ return w, h
34 }46 }
35 func captureTime(t time.Time) (uint32, uint32, uint32) {47 func captureTime(t time.Time) (uint32, uint32, uint32) {
36 sec := uint64(t.Unix())48 sec := uint64(t.Unix())
@@ -154,6 +166,7 @@ func logicalRegion(o *output, phys image.Rectangle) ports.Rect {
154 if phys == image.Rect(0, 0, o.place.Info.Width, o.place.Info.Height) {166 if phys == image.Rect(0, 0, o.place.Info.Width, o.place.Info.Height) {
155 return ports.Rect{}167 return ports.Rect{}
156 }168 }
169+ phys = o.place.Transform.Invert().RectToBuffer(phys, o.place.Info.Width, o.place.Info.Height)
157 x0, y0 := int(math.Floor(float64(phys.Min.X)/scale)), int(math.Floor(float64(phys.Min.Y)/scale))170 x0, y0 := int(math.Floor(float64(phys.Min.X)/scale)), int(math.Floor(float64(phys.Min.Y)/scale))
158 x1, y1 := int(math.Ceil(float64(phys.Max.X)/scale)), int(math.Ceil(float64(phys.Max.Y)/scale))171 x1, y1 := int(math.Ceil(float64(phys.Max.X)/scale)), int(math.Ceil(float64(phys.Max.Y)/scale))
159 return ports.Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}172 return ports.Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}
M internal/adapters/wayland/screencopy_test.go +16 -0
@@ -39,6 +39,22 @@ func TestCaptureRegion(t *testing.T) {
3939 }
4040 }
4141 }
42+
43+// Regions are logical; a rotated output holds them in its target under the
44+// transform, and logicalRegion maps them back.
45+func TestCaptureRegionTransform(t *testing.T) {
46+ o := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Width: 200, Height: 100}, Scale: 1, Transform: 1, Width: 100, Height: 200}}
47+ got := captureRegion(o, image.Rect(0, 0, 10, 20))
48+ if want := image.Rect(0, 90, 20, 100); got != want {
49+ t.Fatalf("captureRegion %v, want %v", got, want)
50+ }
51+ if back := logicalRegion(o, got); back != (ports.Rect{X: 0, Y: 0, W: 10, H: 20}) {
52+ t.Fatalf("logicalRegion %+v", back)
53+ }
54+ if full := logicalRegion(o, image.Rect(0, 0, 200, 100)); full != (ports.Rect{}) {
55+ t.Fatalf("full target %+v", full)
56+ }
57+}
4258 func TestScreencopyProtocol(t *testing.T) {
4359 dir := t.TempDir()
4460 requests := make(chan ports.CaptureRequest, 1)
@@ -39,6 +39,22 @@ func TestCaptureRegion(t *testing.T) {
39 }39 }
40 }40 }
41 }41 }
42+
43+// Regions are logical; a rotated output holds them in its target under the
44+// transform, and logicalRegion maps them back.
45+func TestCaptureRegionTransform(t *testing.T) {
46+ o := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Width: 200, Height: 100}, Scale: 1, Transform: 1, Width: 100, Height: 200}}
47+ got := captureRegion(o, image.Rect(0, 0, 10, 20))
48+ if want := image.Rect(0, 90, 20, 100); got != want {
49+ t.Fatalf("captureRegion %v, want %v", got, want)
50+ }
51+ if back := logicalRegion(o, got); back != (ports.Rect{X: 0, Y: 0, W: 10, H: 20}) {
52+ t.Fatalf("logicalRegion %+v", back)
53+ }
54+ if full := logicalRegion(o, image.Rect(0, 0, 200, 100)); full != (ports.Rect{}) {
55+ t.Fatalf("full target %+v", full)
56+ }
57+}
42 func TestScreencopyProtocol(t *testing.T) {58 func TestScreencopyProtocol(t *testing.T) {
43 dir := t.TempDir()59 dir := t.TempDir()
44 requests := make(chan ports.CaptureRequest, 1)60 requests := make(chan ports.CaptureRequest, 1)
M internal/adapters/wayland/surface.go +4 -0
@@ -55,6 +55,10 @@ type surface struct {
5555 // on is the output the surface entered; scale is the last scale sent.
5656 on *output
5757 scale float64
58+ // prefTransform is the last preferred buffer transform sent (wl_surface
59+ // v6+); prefTransformSent is false until the first one.
60+ prefTransform ports.BufferTransform
61+ prefTransformSent bool
5862 // content is the last content built from this surface's own buffer;
5963 // has is false while no buffer is attached.
6064 inputAll bool
@@ -55,6 +55,10 @@ type surface struct {
55 // on is the output the surface entered; scale is the last scale sent.55 // on is the output the surface entered; scale is the last scale sent.
56 on *output56 on *output
57 scale float6457 scale float64
58+ // prefTransform is the last preferred buffer transform sent (wl_surface
59+ // v6+); prefTransformSent is false until the first one.
60+ prefTransform ports.BufferTransform
61+ prefTransformSent bool
58 // content is the last content built from this surface's own buffer;62 // content is the last content built from this surface's own buffer;
59 // has is false while no buffer is attached.63 // has is false while no buffer is attached.
60 inputAll bool64 inputAll bool
M internal/app/outputmanagement.go +3 -0
@@ -147,6 +147,9 @@ func (o *outputOverrides) apply(req ports.OutputApply) (ports.Config, error) {
147147 if h.Scale > 0 {
148148 entry.Scale = h.Scale
149149 }
150+ if h.Transform != nil {
151+ entry.Transform = *h.Transform
152+ }
150153 if h.Pos != nil {
151154 entry.Pos = &image.Point{X: h.Pos.X, Y: h.Pos.Y}
152155 }
@@ -147,6 +147,9 @@ func (o *outputOverrides) apply(req ports.OutputApply) (ports.Config, error) {
147 if h.Scale > 0 {147 if h.Scale > 0 {
148 entry.Scale = h.Scale148 entry.Scale = h.Scale
149 }149 }
150+ if h.Transform != nil {
151+ entry.Transform = *h.Transform
152+ }
150 if h.Pos != nil {153 if h.Pos != nil {
151 entry.Pos = &image.Point{X: h.Pos.X, Y: h.Pos.Y}154 entry.Pos = &image.Point{X: h.Pos.X, Y: h.Pos.Y}
152 }155 }
M internal/app/outputmanagement_test.go +27 -0
@@ -320,6 +320,33 @@ func TestReloadScaleOnlyKeepsOverrides(t *testing.T) {
320320 }
321321 }
322322
323+// A runtime transform lands in the effective configuration and survives a
324+// reload that only changes file scales.
325+func TestOutputApplyTransform(t *testing.T) {
326+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
327+ file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
328+ state := newOutputOverrides(file, false)
329+ state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
330+ tr := ports.BufferTransform(3)
331+ cfg, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Transform: &tr}}})
332+ if err != nil {
333+ t.Fatal(err)
334+ }
335+ if len(cfg.Outputs) != 1 || cfg.Outputs[0].Transform != 3 {
336+ t.Fatalf("applied transform: %+v", cfg.Outputs)
337+ }
338+ scaled := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}}
339+ cfg = state.reload(scaled)
340+ if cfg.Outputs[0].Transform != 3 || cfg.Outputs[0].Scale != 1.5 {
341+ t.Fatalf("scale reload lost the runtime transform: %+v", cfg.Outputs)
342+ }
343+ // Without a transform in the change, the current one stays.
344+ cfg, err = state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true}}})
345+ if err != nil || cfg.Outputs[0].Transform != 3 {
346+ t.Fatalf("unset transform changed: %+v, %v", cfg.Outputs, err)
347+ }
348+}
349+
323350 // A runtime position never replaces a file relation: it only takes
324351 // precedence over it in core, and the relation is followed again once the
325352 // override is dropped.
@@ -320,6 +320,33 @@ func TestReloadScaleOnlyKeepsOverrides(t *testing.T) {
320 }320 }
321 }321 }
322 322
323+// A runtime transform lands in the effective configuration and survives a
324+// reload that only changes file scales.
325+func TestOutputApplyTransform(t *testing.T) {
326+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
327+ file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
328+ state := newOutputOverrides(file, false)
329+ state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
330+ tr := ports.BufferTransform(3)
331+ cfg, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Transform: &tr}}})
332+ if err != nil {
333+ t.Fatal(err)
334+ }
335+ if len(cfg.Outputs) != 1 || cfg.Outputs[0].Transform != 3 {
336+ t.Fatalf("applied transform: %+v", cfg.Outputs)
337+ }
338+ scaled := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}}
339+ cfg = state.reload(scaled)
340+ if cfg.Outputs[0].Transform != 3 || cfg.Outputs[0].Scale != 1.5 {
341+ t.Fatalf("scale reload lost the runtime transform: %+v", cfg.Outputs)
342+ }
343+ // Without a transform in the change, the current one stays.
344+ cfg, err = state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true}}})
345+ if err != nil || cfg.Outputs[0].Transform != 3 {
346+ t.Fatalf("unset transform changed: %+v, %v", cfg.Outputs, err)
347+ }
348+}
349+
323 // A runtime position never replaces a file relation: it only takes350 // A runtime position never replaces a file relation: it only takes
324 // precedence over it in core, and the relation is followed again once the351 // precedence over it in core, and the relation is followed again once the
325 // override is dropped.352 // override is dropped.

Keyboard shortcuts

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

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

Branches 23

Tags 10

↑↓ moveenter openesc close