feat(drm): compose rotated outputs and rotate the cursor
A rotated output never direct-scans-out or uses an overlay plane (reason output_transform). The cursor loader takes the output transform and returns the image rotated with its hotspot; DRM and headless reload it when the scale or the transform changes. Black/protected scenes carry no windows and a zero transform, so they still fill the whole target; no change there.
- commit
- 0868cc31977ce48841b809078b93b79c19d14eb0
- author
- brice · · 2026-10-03 18:44 UTC
- parent
- f77b8dc
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +123 -31
M internal/adapters/drm/commit_test.go +3 -3
| @@ -237,10 +237,10 @@ func TestCursorOffSkipsLoads(t *testing.T) { | |||
| 237 | 237 | o, _, _ := testOutput(t) | |
| 238 | 238 | o.cursor.off = true | |
| 239 | 239 | loaded := false | |
| 240 | - | o.setCursor(nil, func(ports.CursorChange, float64, int) (ports.CursorImage, error) { | |
| 240 | + | o.setCursor(nil, func(ports.CursorChange, float64, ports.BufferTransform, int) (ports.CursorImage, error) { | |
| 241 | 241 | loaded = true | |
| 242 | 242 | return ports.CursorImage{}, nil | |
| 243 | - | }, ports.CursorChange{}, 1) | |
| 243 | + | }, ports.CursorChange{}, 1, 0) | |
| 244 | 244 | if loaded || o.cursor.desired().on { | |
| 245 | 245 | t.Fatal("cursor used while off") | |
| 246 | 246 | } | |
| @@ -272,7 +272,7 @@ func TestRunReportsSeenAfterCursorCommit(t *testing.T) { | |||
| 272 | 272 | k.EXPECT().createBlob(mock.Anything).Return(99, nil) | |
| 273 | 273 | k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe() | |
| 274 | 274 | k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe() | |
| 275 | - | load := func(ports.CursorChange, float64, int) (ports.CursorImage, error) { | |
| 275 | + | load := func(ports.CursorChange, float64, ports.BufferTransform, int) (ports.CursorImage, error) { | |
| 276 | 276 | return ports.CursorImage{Pixels: []byte{1, 2, 3, 4}, W: 1, H: 1}, nil | |
| 277 | 277 | } | |
| 278 | 278 | last := func(kind uint64) (commitRec, bool) { | |
| @@ -237,10 +237,10 @@ func TestCursorOffSkipsLoads(t *testing.T) { | |||||
| 237 | o, _, _ := testOutput(t) | 237 | o, _, _ := testOutput(t) | ||
| 238 | o.cursor.off = true | 238 | o.cursor.off = true | ||
| 239 | loaded := false | 239 | loaded := false | ||
| 240 | - | o.setCursor(nil, func(ports.CursorChange, float64, int) (ports.CursorImage, error) { | 240 | + | o.setCursor(nil, func(ports.CursorChange, float64, ports.BufferTransform, int) (ports.CursorImage, error) { |
| 241 | loaded = true | 241 | loaded = true | ||
| 242 | return ports.CursorImage{}, nil | 242 | return ports.CursorImage{}, nil | ||
| 243 | - | }, ports.CursorChange{}, 1) | 243 | + | }, ports.CursorChange{}, 1, 0) |
| 244 | if loaded || o.cursor.desired().on { | 244 | if loaded || o.cursor.desired().on { | ||
| 245 | t.Fatal("cursor used while off") | 245 | t.Fatal("cursor used while off") | ||
| 246 | } | 246 | } | ||
| @@ -272,7 +272,7 @@ func TestRunReportsSeenAfterCursorCommit(t *testing.T) { | |||||
| 272 | k.EXPECT().createBlob(mock.Anything).Return(99, nil) | 272 | k.EXPECT().createBlob(mock.Anything).Return(99, nil) | ||
| 273 | k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe() | 273 | k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe() | ||
| 274 | k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe() | 274 | k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe() | ||
| 275 | - | load := func(ports.CursorChange, float64, int) (ports.CursorImage, error) { | 275 | + | load := func(ports.CursorChange, float64, ports.BufferTransform, int) (ports.CursorImage, error) { |
| 276 | return ports.CursorImage{Pixels: []byte{1, 2, 3, 4}, W: 1, H: 1}, nil | 276 | return ports.CursorImage{Pixels: []byte{1, 2, 3, 4}, W: 1, H: 1}, nil | ||
| 277 | } | 277 | } | ||
| 278 | last := func(kind uint64) (commitRec, bool) { | 278 | last := func(kind uint64) (commitRec, bool) { | ||
M internal/adapters/drm/output.go +16 -13
| @@ -156,9 +156,10 @@ type Output struct { | |||
| 156 | 156 | // Ready reports the result of the first modeset exactly once. | |
| 157 | 157 | func (o *Output) Ready() <-chan error { return o.ready } | |
| 158 | 158 | ||
| 159 | - | // CursorLoader returns the image of a cursor at an output scale, at most | |
| 160 | - | // limit pixels on a side; an empty image hides the cursor. | |
| 161 | - | type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error) | |
| 159 | + | // CursorLoader returns the image of a cursor at an output scale, already | |
| 160 | + | // rotated by the output transform t (the cursor plane is not rotated by the | |
| 161 | + | // hardware), at most limit pixels on a side; an empty image hides the cursor. | |
| 162 | + | type CursorLoader func(c ports.CursorChange, scale float64, t ports.BufferTransform, limit int) (ports.CursorImage, error) | |
| 162 | 163 | ||
| 163 | 164 | // newOutput reads the CRTC's planes and properties for a connector on crtc. | |
| 164 | 165 | func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) { | |
| @@ -930,6 +931,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 930 | 931 | haveScene, dirty := false, false | |
| 931 | 932 | var want ports.CursorChange | |
| 932 | 933 | cursorScale := -1.0 // not loaded yet | |
| 934 | + | var cursorTransform ports.BufferTransform | |
| 933 | 935 | frame := 0 | |
| 934 | 936 | var requestStorage [capture.MaxRequests]ports.CaptureRequest | |
| 935 | 937 | requests := requestStorage[:0] | |
| @@ -1000,7 +1002,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1000 | 1002 | for { | |
| 1001 | 1003 | if o.observeSecurity() || haveScene && !o.sceneCurrent(scene) { | |
| 1002 | 1004 | scene, haveScene, dirty = ports.Scene{}, false, false | |
| 1003 | - | want, cursorScale = ports.CursorChange{}, -1 | |
| 1005 | + | want, cursorScale, cursorTransform = ports.CursorChange{}, -1, 0 | |
| 1004 | 1006 | if o.cursor != nil { | |
| 1005 | 1007 | o.cursor.image, o.cursor.later = false, nil | |
| 1006 | 1008 | } | |
| @@ -1230,9 +1232,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1230 | 1232 | o.invalidateSecurity() | |
| 1231 | 1233 | } | |
| 1232 | 1234 | } | |
| 1233 | - | if !o.protected && o.cursor != nil && loadCursor != nil && s.Scale != cursorScale { | |
| 1234 | - | cursorScale = s.Scale | |
| 1235 | - | o.setCursor(r, loadCursor, want, s.Scale) | |
| 1235 | + | if !o.protected && o.cursor != nil && loadCursor != nil && (s.Scale != cursorScale || s.Transform != cursorTransform) { | |
| 1236 | + | cursorScale, cursorTransform = s.Scale, s.Transform | |
| 1237 | + | o.setCursor(r, loadCursor, want, s.Scale, s.Transform) | |
| 1236 | 1238 | stateDirty = true | |
| 1237 | 1239 | } | |
| 1238 | 1240 | scene, haveScene, dirty = s, true, true | |
| @@ -1245,7 +1247,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1245 | 1247 | want = c | |
| 1246 | 1248 | // Before the first scene the scale is unknown: loaded then. | |
| 1247 | 1249 | if o.cursor != nil && loadCursor != nil && cursorScale > 0 { | |
| 1248 | - | o.setCursor(r, loadCursor, want, cursorScale) | |
| 1250 | + | o.setCursor(r, loadCursor, want, cursorScale, cursorTransform) | |
| 1249 | 1251 | stateDirty = true | |
| 1250 | 1252 | } | |
| 1251 | 1253 | case c := <-contents: | |
| @@ -1462,25 +1464,26 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool { | |||
| 1462 | 1464 | return true | |
| 1463 | 1465 | } | |
| 1464 | 1466 | ||
| 1465 | - | // setCursor loads a cursor for scale into the cursor images. | |
| 1466 | - | func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorChange, scale float64) { | |
| 1467 | + | // setCursor loads a cursor for scale and output transform t into the cursor | |
| 1468 | + | // images. | |
| 1469 | + | func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorChange, scale float64, t ports.BufferTransform) { | |
| 1467 | 1470 | o.observeSecurity() | |
| 1468 | 1471 | if o.protected || o.cursor.off { | |
| 1469 | 1472 | return | |
| 1470 | 1473 | } | |
| 1471 | - | img, err := load(c, scale, o.cursor.Limit()) | |
| 1474 | + | img, err := load(c, scale, t, o.cursor.Limit()) | |
| 1472 | 1475 | if err == nil { | |
| 1473 | 1476 | err = o.cursor.setImage(r, img.Pixels, img.W, img.H, img.HotX, img.HotY) | |
| 1474 | 1477 | } | |
| 1475 | 1478 | if err != nil { | |
| 1476 | - | o.log.Warn().Err(err).Msg("cursor") | |
| 1479 | + | o.log.Warn().Err(err).Float64("scale", scale).Int("transform", int(t)).Msg("cursor") | |
| 1477 | 1480 | return | |
| 1478 | 1481 | } | |
| 1479 | 1482 | if c.Image != nil { | |
| 1480 | 1483 | // Client cursors (games) reload on every change: too many to log. | |
| 1481 | 1484 | return | |
| 1482 | 1485 | } | |
| 1483 | - | o.log.Debug().Float64("scale", scale).Str("shape", c.Shape).Int("w", img.W).Int("h", img.H).Msg("cursor image") | |
| 1486 | + | o.log.Debug().Float64("scale", scale).Int("transform", int(t)).Str("shape", c.Shape).Int("w", img.W).Int("h", img.H).Msg("cursor image") | |
| 1484 | 1487 | } | |
| 1485 | 1488 | ||
| 1486 | 1489 | // refused reports a modeset error meaning KMS rejects the images. | |
| @@ -156,9 +156,10 @@ type Output struct { | |||||
| 156 | // Ready reports the result of the first modeset exactly once. | 156 | // Ready reports the result of the first modeset exactly once. | ||
| 157 | func (o *Output) Ready() <-chan error { return o.ready } | 157 | func (o *Output) Ready() <-chan error { return o.ready } | ||
| 158 | 158 | ||||
| 159 | - | // CursorLoader returns the image of a cursor at an output scale, at most | 159 | + | // CursorLoader returns the image of a cursor at an output scale, already |
| 160 | - | // limit pixels on a side; an empty image hides the cursor. | 160 | + | // rotated by the output transform t (the cursor plane is not rotated by the |
| 161 | - | type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error) | 161 | + | // hardware), at most limit pixels on a side; an empty image hides the cursor. |
| 162 | + | type CursorLoader func(c ports.CursorChange, scale float64, t ports.BufferTransform, limit int) (ports.CursorImage, error) | |||
| 162 | 163 | ||||
| 163 | // newOutput reads the CRTC's planes and properties for a connector on crtc. | 164 | // newOutput reads the CRTC's planes and properties for a connector on crtc. | ||
| 164 | func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) { | 165 | func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) { | ||
| @@ -930,6 +931,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 930 | haveScene, dirty := false, false | 931 | haveScene, dirty := false, false | ||
| 931 | var want ports.CursorChange | 932 | var want ports.CursorChange | ||
| 932 | cursorScale := -1.0 // not loaded yet | 933 | cursorScale := -1.0 // not loaded yet | ||
| 934 | + | var cursorTransform ports.BufferTransform | |||
| 933 | frame := 0 | 935 | frame := 0 | ||
| 934 | var requestStorage [capture.MaxRequests]ports.CaptureRequest | 936 | var requestStorage [capture.MaxRequests]ports.CaptureRequest | ||
| 935 | requests := requestStorage[:0] | 937 | requests := requestStorage[:0] | ||
| @@ -1000,7 +1002,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1000 | for { | 1002 | for { | ||
| 1001 | if o.observeSecurity() || haveScene && !o.sceneCurrent(scene) { | 1003 | if o.observeSecurity() || haveScene && !o.sceneCurrent(scene) { | ||
| 1002 | scene, haveScene, dirty = ports.Scene{}, false, false | 1004 | scene, haveScene, dirty = ports.Scene{}, false, false | ||
| 1003 | - | want, cursorScale = ports.CursorChange{}, -1 | 1005 | + | want, cursorScale, cursorTransform = ports.CursorChange{}, -1, 0 |
| 1004 | if o.cursor != nil { | 1006 | if o.cursor != nil { | ||
| 1005 | o.cursor.image, o.cursor.later = false, nil | 1007 | o.cursor.image, o.cursor.later = false, nil | ||
| 1006 | } | 1008 | } | ||
| @@ -1230,9 +1232,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1230 | o.invalidateSecurity() | 1232 | o.invalidateSecurity() | ||
| 1231 | } | 1233 | } | ||
| 1232 | } | 1234 | } | ||
| 1233 | - | if !o.protected && o.cursor != nil && loadCursor != nil && s.Scale != cursorScale { | 1235 | + | if !o.protected && o.cursor != nil && loadCursor != nil && (s.Scale != cursorScale || s.Transform != cursorTransform) { |
| 1234 | - | cursorScale = s.Scale | 1236 | + | cursorScale, cursorTransform = s.Scale, s.Transform |
| 1235 | - | o.setCursor(r, loadCursor, want, s.Scale) | 1237 | + | o.setCursor(r, loadCursor, want, s.Scale, s.Transform) |
| 1236 | stateDirty = true | 1238 | stateDirty = true | ||
| 1237 | } | 1239 | } | ||
| 1238 | scene, haveScene, dirty = s, true, true | 1240 | scene, haveScene, dirty = s, true, true | ||
| @@ -1245,7 +1247,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1245 | want = c | 1247 | want = c | ||
| 1246 | // Before the first scene the scale is unknown: loaded then. | 1248 | // Before the first scene the scale is unknown: loaded then. | ||
| 1247 | if o.cursor != nil && loadCursor != nil && cursorScale > 0 { | 1249 | if o.cursor != nil && loadCursor != nil && cursorScale > 0 { | ||
| 1248 | - | o.setCursor(r, loadCursor, want, cursorScale) | 1250 | + | o.setCursor(r, loadCursor, want, cursorScale, cursorTransform) |
| 1249 | stateDirty = true | 1251 | stateDirty = true | ||
| 1250 | } | 1252 | } | ||
| 1251 | case c := <-contents: | 1253 | case c := <-contents: | ||
| @@ -1462,25 +1464,26 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool { | |||||
| 1462 | return true | 1464 | return true | ||
| 1463 | } | 1465 | } | ||
| 1464 | 1466 | ||||
| 1465 | - | // setCursor loads a cursor for scale into the cursor images. | 1467 | + | // setCursor loads a cursor for scale and output transform t into the cursor |
| 1466 | - | func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorChange, scale float64) { | 1468 | + | // images. |
| 1469 | + | func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorChange, scale float64, t ports.BufferTransform) { | |||
| 1467 | o.observeSecurity() | 1470 | o.observeSecurity() | ||
| 1468 | if o.protected || o.cursor.off { | 1471 | if o.protected || o.cursor.off { | ||
| 1469 | return | 1472 | return | ||
| 1470 | } | 1473 | } | ||
| 1471 | - | img, err := load(c, scale, o.cursor.Limit()) | 1474 | + | img, err := load(c, scale, t, o.cursor.Limit()) |
| 1472 | if err == nil { | 1475 | if err == nil { | ||
| 1473 | err = o.cursor.setImage(r, img.Pixels, img.W, img.H, img.HotX, img.HotY) | 1476 | err = o.cursor.setImage(r, img.Pixels, img.W, img.H, img.HotX, img.HotY) | ||
| 1474 | } | 1477 | } | ||
| 1475 | if err != nil { | 1478 | if err != nil { | ||
| 1476 | - | o.log.Warn().Err(err).Msg("cursor") | 1479 | + | o.log.Warn().Err(err).Float64("scale", scale).Int("transform", int(t)).Msg("cursor") |
| 1477 | return | 1480 | return | ||
| 1478 | } | 1481 | } | ||
| 1479 | if c.Image != nil { | 1482 | if c.Image != nil { | ||
| 1480 | // Client cursors (games) reload on every change: too many to log. | 1483 | // Client cursors (games) reload on every change: too many to log. | ||
| 1481 | return | 1484 | return | ||
| 1482 | } | 1485 | } | ||
| 1483 | - | o.log.Debug().Float64("scale", scale).Str("shape", c.Shape).Int("w", img.W).Int("h", img.H).Msg("cursor image") | 1486 | + | o.log.Debug().Float64("scale", scale).Int("transform", int(t)).Str("shape", c.Shape).Int("w", img.W).Int("h", img.H).Msg("cursor image") |
| 1484 | } | 1487 | } | ||
| 1485 | 1488 | ||||
| 1486 | // refused reports a modeset error meaning KMS rejects the images. | 1489 | // refused reports a modeset error meaning KMS rejects the images. | ||
M internal/adapters/drm/overlay.go +3 -0
| @@ -58,6 +58,9 @@ type overlayWin struct { | |||
| 58 | 58 | // dmabuf at integer physical coordinates, drawn 1:1, with no other window | |
| 59 | 59 | // or layer above it. reason is why none. | |
| 60 | 60 | func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (ports.SceneWindow, ports.SurfaceContent, string) { | |
| 61 | + | if s.Transform != 0 { | |
| 62 | + | return ports.SceneWindow{}, ports.SurfaceContent{}, "output_transform" | |
| 63 | + | } | |
| 61 | 64 | scale := s.Scale | |
| 62 | 65 | if scale <= 0 { | |
| 63 | 66 | scale = 1 | |
| @@ -58,6 +58,9 @@ type overlayWin struct { | |||||
| 58 | // dmabuf at integer physical coordinates, drawn 1:1, with no other window | 58 | // dmabuf at integer physical coordinates, drawn 1:1, with no other window | ||
| 59 | // or layer above it. reason is why none. | 59 | // or layer above it. reason is why none. | ||
| 60 | func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (ports.SceneWindow, ports.SurfaceContent, string) { | 60 | func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (ports.SceneWindow, ports.SurfaceContent, string) { | ||
| 61 | + | if s.Transform != 0 { | |||
| 62 | + | return ports.SceneWindow{}, ports.SurfaceContent{}, "output_transform" | |||
| 63 | + | } | |||
| 61 | scale := s.Scale | 64 | scale := s.Scale | ||
| 62 | if scale <= 0 { | 65 | if scale <= 0 { | ||
| 63 | scale = 1 | 66 | scale = 1 | ||
M internal/adapters/drm/overlay_test.go +6 -0
| @@ -68,6 +68,12 @@ func TestOverlayCandidate(t *testing.T) { | |||
| 68 | 68 | if w, _, reason := overlayCandidate(s, c); reason != "" || w.ID != 2 { | |
| 69 | 69 | t.Fatalf("candidate %v %q", w.ID, reason) | |
| 70 | 70 | } | |
| 71 | + | // A rotated output composes: planes are not rotated. | |
| 72 | + | rs, rc := overlayScene() | |
| 73 | + | rs.Transform = 1 | |
| 74 | + | if _, _, reason := overlayCandidate(rs, rc); reason != "output_transform" { | |
| 75 | + | t.Fatalf("rotated output reason %q", reason) | |
| 76 | + | } | |
| 71 | 77 | c[2] = ports.SurfaceContent{ID: 2, Width: 100, Height: 100, Opaque: true, DMABuf: &ports.DMABuf{Format: fourccNV12}} | |
| 72 | 78 | if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" { | |
| 73 | 79 | t.Fatalf("YUV overlay reason %q", reason) | |
| @@ -68,6 +68,12 @@ func TestOverlayCandidate(t *testing.T) { | |||||
| 68 | if w, _, reason := overlayCandidate(s, c); reason != "" || w.ID != 2 { | 68 | if w, _, reason := overlayCandidate(s, c); reason != "" || w.ID != 2 { | ||
| 69 | t.Fatalf("candidate %v %q", w.ID, reason) | 69 | t.Fatalf("candidate %v %q", w.ID, reason) | ||
| 70 | } | 70 | } | ||
| 71 | + | // A rotated output composes: planes are not rotated. | |||
| 72 | + | rs, rc := overlayScene() | |||
| 73 | + | rs.Transform = 1 | |||
| 74 | + | if _, _, reason := overlayCandidate(rs, rc); reason != "output_transform" { | |||
| 75 | + | t.Fatalf("rotated output reason %q", reason) | |||
| 76 | + | } | |||
| 71 | c[2] = ports.SurfaceContent{ID: 2, Width: 100, Height: 100, Opaque: true, DMABuf: &ports.DMABuf{Format: fourccNV12}} | 77 | c[2] = ports.SurfaceContent{ID: 2, Width: 100, Height: 100, Opaque: true, DMABuf: &ports.DMABuf{Format: fourccNV12}} | ||
| 72 | if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" { | 78 | if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" { | ||
| 73 | t.Fatalf("YUV overlay reason %q", reason) | 79 | t.Fatalf("YUV overlay reason %q", reason) | ||
M internal/adapters/drm/scanout.go +4 -0
| @@ -86,6 +86,10 @@ func fullscreenShown(s *ports.Scene) bool { | |||
| 86 | 86 | // directly, or a reason why it cannot. w, h are the output's physical size. | |
| 87 | 87 | // The plane's formats are checked by scanoutFB. | |
| 88 | 88 | func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int) (ports.SurfaceContent, string) { | |
| 89 | + | if s.Transform != 0 { | |
| 90 | + | // The scanout buffer would have to be pre-rotated: rotated outputs compose. | |
| 91 | + | return ports.SurfaceContent{}, "output_transform" | |
| 92 | + | } | |
| 89 | 93 | if s.WorkspaceClip != (ports.Rect{}) && s.WorkspaceClip != (ports.Rect{W: s.OutputWidth, H: s.OutputHeight}) { | |
| 90 | 94 | return ports.SurfaceContent{}, "workspace_clip" | |
| 91 | 95 | } | |
| @@ -86,6 +86,10 @@ func fullscreenShown(s *ports.Scene) bool { | |||||
| 86 | // directly, or a reason why it cannot. w, h are the output's physical size. | 86 | // directly, or a reason why it cannot. w, h are the output's physical size. | ||
| 87 | // The plane's formats are checked by scanoutFB. | 87 | // The plane's formats are checked by scanoutFB. | ||
| 88 | func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int) (ports.SurfaceContent, string) { | 88 | func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int) (ports.SurfaceContent, string) { | ||
| 89 | + | if s.Transform != 0 { | |||
| 90 | + | // The scanout buffer would have to be pre-rotated: rotated outputs compose. | |||
| 91 | + | return ports.SurfaceContent{}, "output_transform" | |||
| 92 | + | } | |||
| 89 | if s.WorkspaceClip != (ports.Rect{}) && s.WorkspaceClip != (ports.Rect{W: s.OutputWidth, H: s.OutputHeight}) { | 93 | if s.WorkspaceClip != (ports.Rect{}) && s.WorkspaceClip != (ports.Rect{W: s.OutputWidth, H: s.OutputHeight}) { | ||
| 90 | return ports.SurfaceContent{}, "workspace_clip" | 94 | return ports.SurfaceContent{}, "workspace_clip" | ||
| 91 | } | 95 | } | ||
M internal/adapters/drm/scanout_test.go +1 -0
| @@ -25,6 +25,7 @@ func TestScanoutCandidate(t *testing.T) { | |||
| 25 | 25 | s.Windows = append(s.Windows, ports.SceneWindow{ID: 2, Hidden: true}) | |
| 26 | 26 | }, nil, ""}, | |
| 27 | 27 | {"no window", func(s *ports.Scene) { s.Windows = nil }, nil, "no_fullscreen"}, | |
| 28 | + | {"rotated output", func(s *ports.Scene) { s.Transform = 1 }, nil, "output_transform"}, | |
| 28 | 29 | {"dimmed", func(s *ports.Scene) { s.Windows[0].Dim = 0.5 }, nil, "dim"}, | |
| 29 | 30 | {"faded", nil, func(c *ports.SurfaceContent) { c.Fade = 0.5 }, "fade"}, | |
| 30 | 31 | {"tiled", func(s *ports.Scene) { s.Windows[0].Fullscreen = false }, nil, "other_windows"}, | |
| @@ -25,6 +25,7 @@ func TestScanoutCandidate(t *testing.T) { | |||||
| 25 | s.Windows = append(s.Windows, ports.SceneWindow{ID: 2, Hidden: true}) | 25 | s.Windows = append(s.Windows, ports.SceneWindow{ID: 2, Hidden: true}) | ||
| 26 | }, nil, ""}, | 26 | }, nil, ""}, | ||
| 27 | {"no window", func(s *ports.Scene) { s.Windows = nil }, nil, "no_fullscreen"}, | 27 | {"no window", func(s *ports.Scene) { s.Windows = nil }, nil, "no_fullscreen"}, | ||
| 28 | + | {"rotated output", func(s *ports.Scene) { s.Transform = 1 }, nil, "output_transform"}, | |||
| 28 | {"dimmed", func(s *ports.Scene) { s.Windows[0].Dim = 0.5 }, nil, "dim"}, | 29 | {"dimmed", func(s *ports.Scene) { s.Windows[0].Dim = 0.5 }, nil, "dim"}, | ||
| 29 | {"faded", nil, func(c *ports.SurfaceContent) { c.Fade = 0.5 }, "fade"}, | 30 | {"faded", nil, func(c *ports.SurfaceContent) { c.Fade = 0.5 }, "fade"}, | ||
| 30 | {"tiled", func(s *ports.Scene) { s.Windows[0].Fullscreen = false }, nil, "other_windows"}, | 31 | {"tiled", func(s *ports.Scene) { s.Windows[0].Fullscreen = false }, nil, "other_windows"}, | ||
M internal/adapters/headless/output.go +8 -6
| @@ -28,9 +28,10 @@ type Options struct { | |||
| 28 | 28 | Instance ports.OutputInstance | |
| 29 | 29 | SecurityEvents chan<- ports.SecurityBackendEvent | |
| 30 | 30 | // Cursor, when set, is drawn into screenshots with the image from | |
| 31 | - | // LoadCursor at the scene scale. | |
| 31 | + | // LoadCursor at the scene scale and transform. The cursor is drawn in | |
| 32 | + | // target space: the image comes rotated and Move takes target pixels. | |
| 32 | 33 | Cursor *Cursor | |
| 33 | - | LoadCursor func(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error) | |
| 34 | + | LoadCursor func(c ports.CursorChange, scale float64, t ports.BufferTransform, limit int) (ports.CursorImage, error) | |
| 34 | 35 | Width, Height int | |
| 35 | 36 | ScreenshotDir string | |
| 36 | 37 | HDR bool // virtual HDR output for protocol testing only | |
| @@ -120,6 +121,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 120 | 121 | }() | |
| 121 | 122 | var want ports.CursorChange | |
| 122 | 123 | cursorScale := -1.0 // not loaded yet | |
| 124 | + | var cursorTransform ports.BufferTransform | |
| 123 | 125 | seen := map[ports.WindowID]uint64{} | |
| 124 | 126 | // reports holds what the channel could not take yet, retried soon. | |
| 125 | 127 | var reports presented.Queue | |
| @@ -369,7 +371,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 369 | 371 | return err | |
| 370 | 372 | } | |
| 371 | 373 | // The cursor is only drawn into screenshots: no new frame. | |
| 372 | - | want, cursorScale = c, -1 | |
| 374 | + | want, cursorScale, cursorTransform = c, -1, 0 | |
| 373 | 375 | } | |
| 374 | 376 | // Drain all queued updates before presenting one frame. | |
| 375 | 377 | drain: | |
| @@ -426,9 +428,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 426 | 428 | continue | |
| 427 | 429 | } | |
| 428 | 430 | dirty = false | |
| 429 | - | if !opts.protected() && opts.Cursor != nil && opts.LoadCursor != nil && scene.Scale != cursorScale { | |
| 430 | - | cursorScale = scene.Scale | |
| 431 | - | if img, err := opts.LoadCursor(want, scene.Scale, 256); err == nil { | |
| 431 | + | if !opts.protected() && opts.Cursor != nil && opts.LoadCursor != nil && (scene.Scale != cursorScale || scene.Transform != cursorTransform) { | |
| 432 | + | cursorScale, cursorTransform = scene.Scale, scene.Transform | |
| 433 | + | if img, err := opts.LoadCursor(want, scene.Scale, scene.Transform, 256); err == nil { | |
| 432 | 434 | if !opts.protected() { | |
| 433 | 435 | opts.Cursor.set(img) | |
| 434 | 436 | } | |
| @@ -28,9 +28,10 @@ type Options struct { | |||||
| 28 | Instance ports.OutputInstance | 28 | Instance ports.OutputInstance | ||
| 29 | SecurityEvents chan<- ports.SecurityBackendEvent | 29 | SecurityEvents chan<- ports.SecurityBackendEvent | ||
| 30 | // Cursor, when set, is drawn into screenshots with the image from | 30 | // Cursor, when set, is drawn into screenshots with the image from | ||
| 31 | - | // LoadCursor at the scene scale. | 31 | + | // LoadCursor at the scene scale and transform. The cursor is drawn in |
| 32 | + | // target space: the image comes rotated and Move takes target pixels. | |||
| 32 | Cursor *Cursor | 33 | Cursor *Cursor | ||
| 33 | - | LoadCursor func(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error) | 34 | + | LoadCursor func(c ports.CursorChange, scale float64, t ports.BufferTransform, limit int) (ports.CursorImage, error) |
| 34 | Width, Height int | 35 | Width, Height int | ||
| 35 | ScreenshotDir string | 36 | ScreenshotDir string | ||
| 36 | HDR bool // virtual HDR output for protocol testing only | 37 | HDR bool // virtual HDR output for protocol testing only | ||
| @@ -120,6 +121,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 120 | }() | 121 | }() | ||
| 121 | var want ports.CursorChange | 122 | var want ports.CursorChange | ||
| 122 | cursorScale := -1.0 // not loaded yet | 123 | cursorScale := -1.0 // not loaded yet | ||
| 124 | + | var cursorTransform ports.BufferTransform | |||
| 123 | seen := map[ports.WindowID]uint64{} | 125 | seen := map[ports.WindowID]uint64{} | ||
| 124 | // reports holds what the channel could not take yet, retried soon. | 126 | // reports holds what the channel could not take yet, retried soon. | ||
| 125 | var reports presented.Queue | 127 | var reports presented.Queue | ||
| @@ -369,7 +371,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 369 | return err | 371 | return err | ||
| 370 | } | 372 | } | ||
| 371 | // The cursor is only drawn into screenshots: no new frame. | 373 | // The cursor is only drawn into screenshots: no new frame. | ||
| 372 | - | want, cursorScale = c, -1 | 374 | + | want, cursorScale, cursorTransform = c, -1, 0 |
| 373 | } | 375 | } | ||
| 374 | // Drain all queued updates before presenting one frame. | 376 | // Drain all queued updates before presenting one frame. | ||
| 375 | drain: | 377 | drain: | ||
| @@ -426,9 +428,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 426 | continue | 428 | continue | ||
| 427 | } | 429 | } | ||
| 428 | dirty = false | 430 | dirty = false | ||
| 429 | - | if !opts.protected() && opts.Cursor != nil && opts.LoadCursor != nil && scene.Scale != cursorScale { | 431 | + | if !opts.protected() && opts.Cursor != nil && opts.LoadCursor != nil && (scene.Scale != cursorScale || scene.Transform != cursorTransform) { |
| 430 | - | cursorScale = scene.Scale | 432 | + | cursorScale, cursorTransform = scene.Scale, scene.Transform |
| 431 | - | if img, err := opts.LoadCursor(want, scene.Scale, 256); err == nil { | 433 | + | if img, err := opts.LoadCursor(want, scene.Scale, scene.Transform, 256); err == nil { |
| 432 | if !opts.protected() { | 434 | if !opts.protected() { | ||
| 433 | opts.Cursor.set(img) | 435 | opts.Cursor.set(img) | ||
| 434 | } | 436 | } | ||
M internal/app/cursor.go +34 -2
| @@ -67,8 +67,40 @@ type themeKey struct { | |||
| 67 | 67 | // loadCursor returns the cursor a client asked for at an output scale, at | |
| 68 | 68 | // most limit physical pixels on a side: a XCURSOR_THEME shape at | |
| 69 | 69 | // XCURSOR_SIZE × scale, the client image resized to the output scale, or | |
| 70 | - | // an empty image when hidden. Unknown shapes fall back to the arrow. | |
| 71 | - | func loadCursor(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error) { | |
| 70 | + | // an empty image when hidden, then rotated by the output transform t (the | |
| 71 | + | // cursor plane and the headless cursor are drawn in target space). Unknown | |
| 72 | + | // shapes fall back to the arrow. | |
| 73 | + | func loadCursor(c ports.CursorChange, scale float64, t ports.BufferTransform, limit int) (ports.CursorImage, error) { | |
| 74 | + | img, err := loadCursorUpright(c, scale, limit) | |
| 75 | + | if err != nil { | |
| 76 | + | return img, err | |
| 77 | + | } | |
| 78 | + | return rotateCursor(img, t), nil | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // rotateCursor returns img as an output with transform t holds it, hotspot | |
| 82 | + | // included. It never modifies img (theme images are cached). | |
| 83 | + | func rotateCursor(img ports.CursorImage, t ports.BufferTransform) ports.CursorImage { | |
| 84 | + | if t == 0 || img.W <= 0 || img.H <= 0 { | |
| 85 | + | return img | |
| 86 | + | } | |
| 87 | + | dw, dh := img.W, img.H | |
| 88 | + | if t.Rotated() { | |
| 89 | + | dw, dh = dh, dw | |
| 90 | + | } | |
| 91 | + | out := make([]byte, dw*dh*4) | |
| 92 | + | for y := range img.H { | |
| 93 | + | for x := range img.W { | |
| 94 | + | bx, by := t.ToBuffer(float64(x)+.5, float64(y)+.5, float64(img.W), float64(img.H)) | |
| 95 | + | src := (y*img.W + x) * 4 | |
| 96 | + | copy(out[(int(by)*dw+int(bx))*4:], img.Pixels[src:src+4]) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | hx, hy := t.ToBuffer(float64(img.HotX)+.5, float64(img.HotY)+.5, float64(img.W), float64(img.H)) | |
| 100 | + | return ports.CursorImage{W: dw, H: dh, HotX: int(hx), HotY: int(hy), Pixels: out} | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func loadCursorUpright(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error) { | |
| 72 | 104 | scale = max(scale, 1) | |
| 73 | 105 | if c.Hidden { | |
| 74 | 106 | return ports.CursorImage{}, nil | |
| @@ -67,8 +67,40 @@ type themeKey struct { | |||||
| 67 | // loadCursor returns the cursor a client asked for at an output scale, at | 67 | // loadCursor returns the cursor a client asked for at an output scale, at | ||
| 68 | // most limit physical pixels on a side: a XCURSOR_THEME shape at | 68 | // most limit physical pixels on a side: a XCURSOR_THEME shape at | ||
| 69 | // XCURSOR_SIZE × scale, the client image resized to the output scale, or | 69 | // XCURSOR_SIZE × scale, the client image resized to the output scale, or | ||
| 70 | - | // an empty image when hidden. Unknown shapes fall back to the arrow. | 70 | + | // an empty image when hidden, then rotated by the output transform t (the |
| 71 | - | func loadCursor(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error) { | 71 | + | // cursor plane and the headless cursor are drawn in target space). Unknown |
| 72 | + | // shapes fall back to the arrow. | |||
| 73 | + | func loadCursor(c ports.CursorChange, scale float64, t ports.BufferTransform, limit int) (ports.CursorImage, error) { | |||
| 74 | + | img, err := loadCursorUpright(c, scale, limit) | |||
| 75 | + | if err != nil { | |||
| 76 | + | return img, err | |||
| 77 | + | } | |||
| 78 | + | return rotateCursor(img, t), nil | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // rotateCursor returns img as an output with transform t holds it, hotspot | |||
| 82 | + | // included. It never modifies img (theme images are cached). | |||
| 83 | + | func rotateCursor(img ports.CursorImage, t ports.BufferTransform) ports.CursorImage { | |||
| 84 | + | if t == 0 || img.W <= 0 || img.H <= 0 { | |||
| 85 | + | return img | |||
| 86 | + | } | |||
| 87 | + | dw, dh := img.W, img.H | |||
| 88 | + | if t.Rotated() { | |||
| 89 | + | dw, dh = dh, dw | |||
| 90 | + | } | |||
| 91 | + | out := make([]byte, dw*dh*4) | |||
| 92 | + | for y := range img.H { | |||
| 93 | + | for x := range img.W { | |||
| 94 | + | bx, by := t.ToBuffer(float64(x)+.5, float64(y)+.5, float64(img.W), float64(img.H)) | |||
| 95 | + | src := (y*img.W + x) * 4 | |||
| 96 | + | copy(out[(int(by)*dw+int(bx))*4:], img.Pixels[src:src+4]) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | hx, hy := t.ToBuffer(float64(img.HotX)+.5, float64(img.HotY)+.5, float64(img.W), float64(img.H)) | |||
| 100 | + | return ports.CursorImage{W: dw, H: dh, HotX: int(hx), HotY: int(hy), Pixels: out} | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func loadCursorUpright(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error) { | |||
| 72 | scale = max(scale, 1) | 104 | scale = max(scale, 1) | ||
| 73 | if c.Hidden { | 105 | if c.Hidden { | ||
| 74 | return ports.CursorImage{}, nil | 106 | return ports.CursorImage{}, nil | ||
M internal/app/cursor_test.go +48 -7
| @@ -61,7 +61,7 @@ func TestLoadCursorShapes(t *testing.T) { | |||
| 61 | 61 | // Unknown shapes fall back to the arrow. | |
| 62 | 62 | {ports.CursorChange{Shape: "zoom-in"}, 1}, | |
| 63 | 63 | } { | |
| 64 | - | img, err := loadCursor(tc.change, 2, 256) | |
| 64 | + | img, err := loadCursor(tc.change, 2, 0, 256) | |
| 65 | 65 | if err != nil { | |
| 66 | 66 | t.Fatal(tc.change, err) | |
| 67 | 67 | } | |
| @@ -73,10 +73,10 @@ func TestLoadCursorShapes(t *testing.T) { | |||
| 73 | 73 | if err := os.RemoveAll(dir); err != nil { | |
| 74 | 74 | t.Fatal(err) | |
| 75 | 75 | } | |
| 76 | - | if img, err := loadCursor(ports.CursorChange{Shape: "pointer"}, 2, 256); err != nil || img.Pixels[0] != 2 { | |
| 76 | + | if img, err := loadCursor(ports.CursorChange{Shape: "pointer"}, 2, 0, 256); err != nil || img.Pixels[0] != 2 { | |
| 77 | 77 | t.Fatalf("cached: %v", err) | |
| 78 | 78 | } | |
| 79 | - | if img, err := loadCursor(ports.CursorChange{Hidden: true}, 1, 256); err != nil || img.W != 0 { | |
| 79 | + | if img, err := loadCursor(ports.CursorChange{Hidden: true}, 1, 0, 256); err != nil || img.W != 0 { | |
| 80 | 80 | t.Fatalf("hidden: %+v %v", img, err) | |
| 81 | 81 | } | |
| 82 | 82 | } | |
| @@ -84,16 +84,16 @@ func TestLoadCursorShapes(t *testing.T) { | |||
| 84 | 84 | // A client cursor drawn at buffer scale 2 is shown at the output scale. | |
| 85 | 85 | func TestLoadCursorClientImage(t *testing.T) { | |
| 86 | 86 | src := &ports.CursorImage{W: 4, H: 2, HotX: 2, HotY: 1, Pixels: make([]byte, 4*2*4)} | |
| 87 | - | img, err := loadCursor(ports.CursorChange{Image: src, Scale: 2}, 1, 256) | |
| 87 | + | img, err := loadCursor(ports.CursorChange{Image: src, Scale: 2}, 1, 0, 256) | |
| 88 | 88 | if err != nil || img.W != 2 || img.H != 1 || img.HotX != 1 || img.HotY != 0 { | |
| 89 | 89 | t.Fatalf("%+v %v", img, err) | |
| 90 | 90 | } | |
| 91 | - | img, err = loadCursor(ports.CursorChange{Image: src, Scale: 2}, 2, 256) | |
| 91 | + | img, err = loadCursor(ports.CursorChange{Image: src, Scale: 2}, 2, 0, 256) | |
| 92 | 92 | if err != nil || img.W != 4 || img.HotX != 2 { | |
| 93 | 93 | t.Fatalf("%+v %v", img, err) | |
| 94 | 94 | } | |
| 95 | 95 | // Bounded by the cursor plane. | |
| 96 | - | img, err = loadCursor(ports.CursorChange{Image: src, Scale: 1}, 4, 8) | |
| 96 | + | img, err = loadCursor(ports.CursorChange{Image: src, Scale: 1}, 4, 0, 8) | |
| 97 | 97 | if err != nil || img.W != 8 || img.H != 4 { | |
| 98 | 98 | t.Fatalf("%+v %v", img, err) | |
| 99 | 99 | } | |
| @@ -118,8 +118,49 @@ func TestLoadCursorInheritedShape(t *testing.T) { | |||
| 118 | 118 | if err := os.Rename(filepath.Join(dir, "theme", "cursors", "left_ptr"), child); err != nil { | |
| 119 | 119 | t.Fatal(err) | |
| 120 | 120 | } | |
| 121 | - | img, err := loadCursor(ports.CursorChange{Shape: "text"}, 1, 256) | |
| 121 | + | img, err := loadCursor(ports.CursorChange{Shape: "text"}, 1, 0, 256) | |
| 122 | 122 | if err != nil || img.Pixels[0] != 3 { | |
| 123 | 123 | t.Fatalf("got arrow instead of the inherited text cursor: %v", err) | |
| 124 | 124 | } | |
| 125 | 125 | } | |
| 126 | + | ||
| 127 | + | // A rotated output holds the cursor image like any buffer: pixels and | |
| 128 | + | // hotspot follow BufferTransform.ToBuffer. | |
| 129 | + | func TestRotateCursor(t *testing.T) { | |
| 130 | + | // 3×2, pixel i is {i, 0, 0, 255}. | |
| 131 | + | px := make([]byte, 3*2*4) | |
| 132 | + | for i := range 6 { | |
| 133 | + | copy(px[i*4:], []byte{byte(i), 0, 0, 255}) | |
| 134 | + | } | |
| 135 | + | img := ports.CursorImage{W: 3, H: 2, Pixels: px} | |
| 136 | + | if got := rotateCursor(img, 0); &got.Pixels[0] != &img.Pixels[0] || got.W != 3 || got.H != 2 { | |
| 137 | + | t.Fatalf("identity: %+v", got) | |
| 138 | + | } | |
| 139 | + | got := rotateCursor(img, 1) | |
| 140 | + | if got.W != 2 || got.H != 3 || got.HotX != 0 || got.HotY != 2 { | |
| 141 | + | t.Fatalf("90: %dx%d hot (%d,%d)", got.W, got.H, got.HotX, got.HotY) | |
| 142 | + | } | |
| 143 | + | if &img.Pixels[0] == &got.Pixels[0] || img.Pixels[0] != 0 || img.W != 3 { | |
| 144 | + | t.Fatal("source modified") | |
| 145 | + | } | |
| 146 | + | for tr := ports.BufferTransform(1); tr < 8; tr++ { | |
| 147 | + | img.HotX, img.HotY = 2, 1 | |
| 148 | + | got := rotateCursor(img, tr) | |
| 149 | + | for y := range 2 { | |
| 150 | + | for x := range 3 { | |
| 151 | + | bx, by := tr.ToBuffer(float64(x)+.5, float64(y)+.5, 3, 2) | |
| 152 | + | if v := got.Pixels[(int(by)*got.W+int(bx))*4]; v != byte(y*3+x) { | |
| 153 | + | t.Fatalf("transform %d: (%d,%d) landed with value %d", tr, x, y, v) | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | // The hotspot pixel keeps its value. | |
| 158 | + | if v := got.Pixels[(got.HotY*got.W+got.HotX)*4]; v != 5 { | |
| 159 | + | t.Fatalf("transform %d: hotspot (%d,%d) on pixel %d, want 5", tr, got.HotX, got.HotY, v) | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | // 90 degrees: the top left pixel goes to the bottom left. | |
| 163 | + | if got := rotateCursor(ports.CursorImage{W: 3, H: 2, Pixels: px}, 1); got.Pixels[(2*2+0)*4] != 0 { | |
| 164 | + | t.Fatalf("90: top-left pixel at %v", got.Pixels) | |
| 165 | + | } | |
| 166 | + | } | |
| @@ -61,7 +61,7 @@ func TestLoadCursorShapes(t *testing.T) { | |||||
| 61 | // Unknown shapes fall back to the arrow. | 61 | // Unknown shapes fall back to the arrow. | ||
| 62 | {ports.CursorChange{Shape: "zoom-in"}, 1}, | 62 | {ports.CursorChange{Shape: "zoom-in"}, 1}, | ||
| 63 | } { | 63 | } { | ||
| 64 | - | img, err := loadCursor(tc.change, 2, 256) | 64 | + | img, err := loadCursor(tc.change, 2, 0, 256) |
| 65 | if err != nil { | 65 | if err != nil { | ||
| 66 | t.Fatal(tc.change, err) | 66 | t.Fatal(tc.change, err) | ||
| 67 | } | 67 | } | ||
| @@ -73,10 +73,10 @@ func TestLoadCursorShapes(t *testing.T) { | |||||
| 73 | if err := os.RemoveAll(dir); err != nil { | 73 | if err := os.RemoveAll(dir); err != nil { | ||
| 74 | t.Fatal(err) | 74 | t.Fatal(err) | ||
| 75 | } | 75 | } | ||
| 76 | - | if img, err := loadCursor(ports.CursorChange{Shape: "pointer"}, 2, 256); err != nil || img.Pixels[0] != 2 { | 76 | + | if img, err := loadCursor(ports.CursorChange{Shape: "pointer"}, 2, 0, 256); err != nil || img.Pixels[0] != 2 { |
| 77 | t.Fatalf("cached: %v", err) | 77 | t.Fatalf("cached: %v", err) | ||
| 78 | } | 78 | } | ||
| 79 | - | if img, err := loadCursor(ports.CursorChange{Hidden: true}, 1, 256); err != nil || img.W != 0 { | 79 | + | if img, err := loadCursor(ports.CursorChange{Hidden: true}, 1, 0, 256); err != nil || img.W != 0 { |
| 80 | t.Fatalf("hidden: %+v %v", img, err) | 80 | t.Fatalf("hidden: %+v %v", img, err) | ||
| 81 | } | 81 | } | ||
| 82 | } | 82 | } | ||
| @@ -84,16 +84,16 @@ func TestLoadCursorShapes(t *testing.T) { | |||||
| 84 | // A client cursor drawn at buffer scale 2 is shown at the output scale. | 84 | // A client cursor drawn at buffer scale 2 is shown at the output scale. | ||
| 85 | func TestLoadCursorClientImage(t *testing.T) { | 85 | func TestLoadCursorClientImage(t *testing.T) { | ||
| 86 | src := &ports.CursorImage{W: 4, H: 2, HotX: 2, HotY: 1, Pixels: make([]byte, 4*2*4)} | 86 | src := &ports.CursorImage{W: 4, H: 2, HotX: 2, HotY: 1, Pixels: make([]byte, 4*2*4)} | ||
| 87 | - | img, err := loadCursor(ports.CursorChange{Image: src, Scale: 2}, 1, 256) | 87 | + | img, err := loadCursor(ports.CursorChange{Image: src, Scale: 2}, 1, 0, 256) |
| 88 | if err != nil || img.W != 2 || img.H != 1 || img.HotX != 1 || img.HotY != 0 { | 88 | if err != nil || img.W != 2 || img.H != 1 || img.HotX != 1 || img.HotY != 0 { | ||
| 89 | t.Fatalf("%+v %v", img, err) | 89 | t.Fatalf("%+v %v", img, err) | ||
| 90 | } | 90 | } | ||
| 91 | - | img, err = loadCursor(ports.CursorChange{Image: src, Scale: 2}, 2, 256) | 91 | + | img, err = loadCursor(ports.CursorChange{Image: src, Scale: 2}, 2, 0, 256) |
| 92 | if err != nil || img.W != 4 || img.HotX != 2 { | 92 | if err != nil || img.W != 4 || img.HotX != 2 { | ||
| 93 | t.Fatalf("%+v %v", img, err) | 93 | t.Fatalf("%+v %v", img, err) | ||
| 94 | } | 94 | } | ||
| 95 | // Bounded by the cursor plane. | 95 | // Bounded by the cursor plane. | ||
| 96 | - | img, err = loadCursor(ports.CursorChange{Image: src, Scale: 1}, 4, 8) | 96 | + | img, err = loadCursor(ports.CursorChange{Image: src, Scale: 1}, 4, 0, 8) |
| 97 | if err != nil || img.W != 8 || img.H != 4 { | 97 | if err != nil || img.W != 8 || img.H != 4 { | ||
| 98 | t.Fatalf("%+v %v", img, err) | 98 | t.Fatalf("%+v %v", img, err) | ||
| 99 | } | 99 | } | ||
| @@ -118,8 +118,49 @@ func TestLoadCursorInheritedShape(t *testing.T) { | |||||
| 118 | if err := os.Rename(filepath.Join(dir, "theme", "cursors", "left_ptr"), child); err != nil { | 118 | if err := os.Rename(filepath.Join(dir, "theme", "cursors", "left_ptr"), child); err != nil { | ||
| 119 | t.Fatal(err) | 119 | t.Fatal(err) | ||
| 120 | } | 120 | } | ||
| 121 | - | img, err := loadCursor(ports.CursorChange{Shape: "text"}, 1, 256) | 121 | + | img, err := loadCursor(ports.CursorChange{Shape: "text"}, 1, 0, 256) |
| 122 | if err != nil || img.Pixels[0] != 3 { | 122 | if err != nil || img.Pixels[0] != 3 { | ||
| 123 | t.Fatalf("got arrow instead of the inherited text cursor: %v", err) | 123 | t.Fatalf("got arrow instead of the inherited text cursor: %v", err) | ||
| 124 | } | 124 | } | ||
| 125 | } | 125 | } | ||
| 126 | + | ||||
| 127 | + | // A rotated output holds the cursor image like any buffer: pixels and | |||
| 128 | + | // hotspot follow BufferTransform.ToBuffer. | |||
| 129 | + | func TestRotateCursor(t *testing.T) { | |||
| 130 | + | // 3×2, pixel i is {i, 0, 0, 255}. | |||
| 131 | + | px := make([]byte, 3*2*4) | |||
| 132 | + | for i := range 6 { | |||
| 133 | + | copy(px[i*4:], []byte{byte(i), 0, 0, 255}) | |||
| 134 | + | } | |||
| 135 | + | img := ports.CursorImage{W: 3, H: 2, Pixels: px} | |||
| 136 | + | if got := rotateCursor(img, 0); &got.Pixels[0] != &img.Pixels[0] || got.W != 3 || got.H != 2 { | |||
| 137 | + | t.Fatalf("identity: %+v", got) | |||
| 138 | + | } | |||
| 139 | + | got := rotateCursor(img, 1) | |||
| 140 | + | if got.W != 2 || got.H != 3 || got.HotX != 0 || got.HotY != 2 { | |||
| 141 | + | t.Fatalf("90: %dx%d hot (%d,%d)", got.W, got.H, got.HotX, got.HotY) | |||
| 142 | + | } | |||
| 143 | + | if &img.Pixels[0] == &got.Pixels[0] || img.Pixels[0] != 0 || img.W != 3 { | |||
| 144 | + | t.Fatal("source modified") | |||
| 145 | + | } | |||
| 146 | + | for tr := ports.BufferTransform(1); tr < 8; tr++ { | |||
| 147 | + | img.HotX, img.HotY = 2, 1 | |||
| 148 | + | got := rotateCursor(img, tr) | |||
| 149 | + | for y := range 2 { | |||
| 150 | + | for x := range 3 { | |||
| 151 | + | bx, by := tr.ToBuffer(float64(x)+.5, float64(y)+.5, 3, 2) | |||
| 152 | + | if v := got.Pixels[(int(by)*got.W+int(bx))*4]; v != byte(y*3+x) { | |||
| 153 | + | t.Fatalf("transform %d: (%d,%d) landed with value %d", tr, x, y, v) | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | // The hotspot pixel keeps its value. | |||
| 158 | + | if v := got.Pixels[(got.HotY*got.W+got.HotX)*4]; v != 5 { | |||
| 159 | + | t.Fatalf("transform %d: hotspot (%d,%d) on pixel %d, want 5", tr, got.HotX, got.HotY, v) | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | // 90 degrees: the top left pixel goes to the bottom left. | |||
| 163 | + | if got := rotateCursor(ports.CursorImage{W: 3, H: 2, Pixels: px}, 1); got.Pixels[(2*2+0)*4] != 0 { | |||
| 164 | + | t.Fatalf("90: top-left pixel at %v", got.Pixels) | |||
| 165 | + | } | |||
| 166 | + | } | |||