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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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() { | |||
| 601 | 601 | return | |
| 602 | 602 | } | |
| 603 | 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 | 610 | buf, isBuf := c.s.buffers[f.buf.GetResource()].(*buffer) | |
| 605 | 611 | switch { | |
| 606 | 612 | case !isBuf: | |
| @@ -621,7 +627,7 @@ func (f *captureExtFrame) attempt() { | |||
| 621 | 627 | f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown)) | |
| 622 | 628 | return | |
| 623 | 629 | } | |
| 624 | - | f.res.SendTransform(0) | |
| 630 | + | f.res.SendTransform(transform) | |
| 625 | 631 | f.res.SendDamage(0, 0, int32(region.Dx()), int32(region.Dy())) | |
| 626 | 632 | hi, lo, ns := captureTime(done.Time) | |
| 627 | 633 | f.res.SendPresentationTime(hi, lo, ns) | |
| @@ -601,6 +601,12 @@ func (f *captureExtFrame) attempt() { | |||||
| 601 | return | 601 | 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 | return | 628 | 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 | |||
| 159 | 159 | r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidTransform), "invalid transform") | |
| 160 | 160 | return | |
| 161 | 161 | } | |
| 162 | - | if v != 0 { | |
| 163 | - | h.unsupported = true | |
| 164 | - | } | |
| 162 | + | t := ports.BufferTransform(v) | |
| 163 | + | h.change.Transform = &t | |
| 165 | 164 | } | |
| 166 | 165 | func (h *headConfiguration) SetScale(r *wlr.ZwlrOutputConfigurationHeadV1, v server.Fixed) { | |
| 167 | 166 | 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 | return | 160 | return | ||
| 161 | } | 161 | } | ||
| 162 | - | if v != 0 { | 162 | + | t := ports.BufferTransform(v) |
| 163 | - | h.unsupported = true | 163 | + | 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) { | |||
| 169 | 169 | if place.Info.Name != "" { | |
| 170 | 170 | h.res.SendPosition(int32(place.X), int32(place.Y)) | |
| 171 | 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 | 177 | m.places[info.Info.Name] = m.s.placementFor(info.Info.Name) | |
| 176 | 178 | } | |
| @@ -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) { | |||
| 243 | 243 | {"custom mode", func(t *testing.T, c *wlturbo.Display, ch uint32) { | |
| 244 | 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 | 246 | {"adaptive sync off", func(t *testing.T, c *wlturbo.Display, ch uint32) { | |
| 250 | 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 | 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 | 368 | func TestOutputManagementProtocolErrors(t *testing.T) { | |
| 293 | 369 | const ( | |
| 294 | 370 | 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.ZwlrOutputConfigurationV1RequestEnableHead | 370 | enable = wlr.ZwlrOutputConfigurationV1RequestEnableHead | ||
M internal/adapters/wayland/outputs.go +3 -1
| @@ -65,7 +65,7 @@ func (o *output) sendAll(r *wayland.Output) { | |||
| 65 | 65 | if refresh == 0 { | |
| 66 | 66 | 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 | 69 | // The mode stays physical; the scale tells clients how to divide it. | |
| 70 | 70 | r.SendMode(3, int32(i.Width), int32(i.Height), int32(refresh)) | |
| 71 | 71 | if r.Version() >= 2 { | |
| @@ -209,6 +209,8 @@ func (s *Server) setOutputs(c ports.SetOutputs) { | |||
| 209 | 209 | continue | |
| 210 | 210 | } | |
| 211 | 211 | 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 | 214 | logicalChanged := o.place.Width != p.Width || o.place.Height != p.Height | |
| 213 | 215 | o.place = p | |
| 214 | 216 | if logicalChanged { | |
| @@ -65,7 +65,7 @@ func (o *output) sendAll(r *wayland.Output) { | |||||
| 65 | if refresh == 0 { | 65 | if refresh == 0 { | ||
| 66 | refresh = 60000 | 66 | 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 | continue | 209 | continue | ||
| 210 | } | 210 | } | ||
| 211 | scaleChanged := o.place.Scale != p.Scale | 211 | 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.Height | 214 | logicalChanged := o.place.Width != p.Width || o.place.Height != p.Height | ||
| 213 | o.place = p | 215 | o.place = p | ||
| 214 | if logicalChanged { | 216 | if logicalChanged { | ||
M internal/adapters/wayland/scale.go +11 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "github.com/bnema/go-wayland-bindings/server/fractionalscale" | |
| 7 | 7 | "github.com/bnema/go-wayland-bindings/server/viewporter" | |
| 8 | 8 | "github.com/bnema/go-wayland-bindings/server/wayland" | |
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |
| 9 | 10 | "github.com/bnema/purego-libwayland/server" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| @@ -76,6 +77,16 @@ func (surf *surface) sendScale() { | |||
| 76 | 77 | if o != nil { | |
| 77 | 78 | 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 | 90 | if scale == surf.scale { | |
| 80 | 91 | return | |
| 81 | 92 | } | |
| @@ -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.Scale | 78 | 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 | return | 91 | return | ||
| 81 | } | 92 | } | ||
M internal/adapters/wayland/scale_test.go +61 -0
| @@ -2,6 +2,7 @@ package wayland | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "slices" | |
| 5 | 6 | "testing" | |
| 6 | 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 | 54 | func contentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, chan ports.SurfaceContent, string) { | |
| 42 | 55 | t.Helper() | |
| 43 | 56 | dir := t.TempDir() | |
| @@ -55,6 +68,54 @@ func contentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.Cl | |||
| 55 | 68 | 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 | 119 | func TestFractionalScaleAndViewport(t *testing.T) { | |
| 59 | 120 | s, events, commands, contents, dir := contentServer(t) | |
| 60 | 121 | 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, dir | 68 | 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 ( | |||
| 19 | 19 | // release its slot: only the backend completion does. | |
| 20 | 20 | const maxCaptureInflight = ports.MaxCaptureInflight | |
| 21 | 21 | ||
| 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. | |
| 24 | 25 | func captureRegion(o *output, logical image.Rectangle) image.Rectangle { | |
| 25 | 26 | if o == nil { | |
| 26 | 27 | return image.Rectangle{} | |
| @@ -29,8 +30,19 @@ func captureRegion(o *output, logical image.Rectangle) image.Rectangle { | |||
| 29 | 30 | if scale <= 0 { | |
| 30 | 31 | scale = 1 | |
| 31 | 32 | } | |
| 33 | + | sw, sh := sceneSize(o) | |
| 32 | 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 | 47 | func captureTime(t time.Time) (uint32, uint32, uint32) { | |
| 36 | 48 | sec := uint64(t.Unix()) | |
| @@ -154,6 +166,7 @@ func logicalRegion(o *output, phys image.Rectangle) ports.Rect { | |||
| 154 | 166 | if phys == image.Rect(0, 0, o.place.Info.Width, o.place.Info.Height) { | |
| 155 | 167 | return ports.Rect{} | |
| 156 | 168 | } | |
| 169 | + | phys = o.place.Transform.Invert().RectToBuffer(phys, o.place.Info.Width, o.place.Info.Height) | |
| 157 | 170 | x0, y0 := int(math.Floor(float64(phys.Min.X)/scale)), int(math.Floor(float64(phys.Min.Y)/scale)) | |
| 158 | 171 | x1, y1 := int(math.Ceil(float64(phys.Max.X)/scale)), int(math.Ceil(float64(phys.Max.Y)/scale)) | |
| 159 | 172 | 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.MaxCaptureInflight | 20 | 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 = 1 | 31 | 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) { | |||
| 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 | 58 | func TestScreencopyProtocol(t *testing.T) { | |
| 43 | 59 | dir := t.TempDir() | |
| 44 | 60 | 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 { | |||
| 55 | 55 | // on is the output the surface entered; scale is the last scale sent. | |
| 56 | 56 | on *output | |
| 57 | 57 | 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 | 62 | // content is the last content built from this surface's own buffer; | |
| 59 | 63 | // has is false while no buffer is attached. | |
| 60 | 64 | 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 *output | 56 | on *output | ||
| 57 | scale float64 | 57 | 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 bool | 64 | inputAll bool | ||
M internal/app/outputmanagement.go +3 -0
| @@ -147,6 +147,9 @@ func (o *outputOverrides) apply(req ports.OutputApply) (ports.Config, error) { | |||
| 147 | 147 | if h.Scale > 0 { | |
| 148 | 148 | entry.Scale = h.Scale | |
| 149 | 149 | } | |
| 150 | + | if h.Transform != nil { | |
| 151 | + | entry.Transform = *h.Transform | |
| 152 | + | } | |
| 150 | 153 | if h.Pos != nil { | |
| 151 | 154 | entry.Pos = &image.Point{X: h.Pos.X, Y: h.Pos.Y} | |
| 152 | 155 | } | |
| @@ -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.Scale | 148 | 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) { | |||
| 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 | 350 | // A runtime position never replaces a file relation: it only takes | |
| 324 | 351 | // precedence over it in core, and the relation is followed again once the | |
| 325 | 352 | // 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 takes | 350 | // A runtime position never replaces a file relation: it only takes | ||
| 324 | // precedence over it in core, and the relation is followed again once the | 351 | // precedence over it in core, and the relation is followed again once the | ||
| 325 | // override is dropped. | 352 | // override is dropped. | ||