detached 0868cc3

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
237237 o, _, _ := testOutput(t)
238238 o.cursor.off = true
239239 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) {
241241 loaded = true
242242 return ports.CursorImage{}, nil
243- }, ports.CursorChange{}, 1)
243+ }, ports.CursorChange{}, 1, 0)
244244 if loaded || o.cursor.desired().on {
245245 t.Fatal("cursor used while off")
246246 }
@@ -272,7 +272,7 @@ func TestRunReportsSeenAfterCursorCommit(t *testing.T) {
272272 k.EXPECT().createBlob(mock.Anything).Return(99, nil)
273273 k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
274274 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) {
276276 return ports.CursorImage{Pixels: []byte{1, 2, 3, 4}, W: 1, H: 1}, nil
277277 }
278278 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 = true238 o.cursor.off = true
239 loaded := false239 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 = true241 loaded = true
242 return ports.CursorImage{}, nil242 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}, nil276 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 {
156156 // Ready reports the result of the first modeset exactly once.
157157 func (o *Output) Ready() <-chan error { return o.ready }
158158
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)
162163
163164 // newOutput reads the CRTC's planes and properties for a connector on crtc.
164165 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
930931 haveScene, dirty := false, false
931932 var want ports.CursorChange
932933 cursorScale := -1.0 // not loaded yet
934+ var cursorTransform ports.BufferTransform
933935 frame := 0
934936 var requestStorage [capture.MaxRequests]ports.CaptureRequest
935937 requests := requestStorage[:0]
@@ -1000,7 +1002,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
10001002 for {
10011003 if o.observeSecurity() || haveScene && !o.sceneCurrent(scene) {
10021004 scene, haveScene, dirty = ports.Scene{}, false, false
1003- want, cursorScale = ports.CursorChange{}, -1
1005+ want, cursorScale, cursorTransform = ports.CursorChange{}, -1, 0
10041006 if o.cursor != nil {
10051007 o.cursor.image, o.cursor.later = false, nil
10061008 }
@@ -1230,9 +1232,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
12301232 o.invalidateSecurity()
12311233 }
12321234 }
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)
12361238 stateDirty = true
12371239 }
12381240 scene, haveScene, dirty = s, true, true
@@ -1245,7 +1247,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
12451247 want = c
12461248 // Before the first scene the scale is unknown: loaded then.
12471249 if o.cursor != nil && loadCursor != nil && cursorScale > 0 {
1248- o.setCursor(r, loadCursor, want, cursorScale)
1250+ o.setCursor(r, loadCursor, want, cursorScale, cursorTransform)
12491251 stateDirty = true
12501252 }
12511253 case c := <-contents:
@@ -1462,25 +1464,26 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {
14621464 return true
14631465 }
14641466
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) {
14671470 o.observeSecurity()
14681471 if o.protected || o.cursor.off {
14691472 return
14701473 }
1471- img, err := load(c, scale, o.cursor.Limit())
1474+ img, err := load(c, scale, t, o.cursor.Limit())
14721475 if err == nil {
14731476 err = o.cursor.setImage(r, img.Pixels, img.W, img.H, img.HotX, img.HotY)
14741477 }
14751478 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")
14771480 return
14781481 }
14791482 if c.Image != nil {
14801483 // Client cursors (games) reload on every change: too many to log.
14811484 return
14821485 }
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")
14841487 }
14851488
14861489 // 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 most159+// 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, false931 haveScene, dirty := false, false
931 var want ports.CursorChange932 var want ports.CursorChange
932 cursorScale := -1.0 // not loaded yet933 cursorScale := -1.0 // not loaded yet
934+ var cursorTransform ports.BufferTransform
933 frame := 0935 frame := 0
934 var requestStorage [capture.MaxRequests]ports.CaptureRequest936 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, false1004 scene, haveScene, dirty = ports.Scene{}, false, false
1003- want, cursorScale = ports.CursorChange{}, -11005+ 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, nil1007 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.Scale1236+ 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 = true1238 stateDirty = true
1237 }1239 }
1238 scene, haveScene, dirty = s, true, true1240 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 = c1247 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 = true1251 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 true1464 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 return1472 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 return1480 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 return1484 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 {
5858 // dmabuf at integer physical coordinates, drawn 1:1, with no other window
5959 // or layer above it. reason is why none.
6060 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+ }
6164 scale := s.Scale
6265 if scale <= 0 {
6366 scale = 1
@@ -58,6 +58,9 @@ type overlayWin struct {
58 // dmabuf at integer physical coordinates, drawn 1:1, with no other window58 // 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.Scale64 scale := s.Scale
62 if scale <= 0 {65 if scale <= 0 {
63 scale = 166 scale = 1
M internal/adapters/drm/overlay_test.go +6 -0
@@ -68,6 +68,12 @@ func TestOverlayCandidate(t *testing.T) {
6868 if w, _, reason := overlayCandidate(s, c); reason != "" || w.ID != 2 {
6969 t.Fatalf("candidate %v %q", w.ID, reason)
7070 }
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+ }
7177 c[2] = ports.SurfaceContent{ID: 2, Width: 100, Height: 100, Opaque: true, DMABuf: &ports.DMABuf{Format: fourccNV12}}
7278 if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {
7379 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 {
8686 // directly, or a reason why it cannot. w, h are the output's physical size.
8787 // The plane's formats are checked by scanoutFB.
8888 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+ }
8993 if s.WorkspaceClip != (ports.Rect{}) && s.WorkspaceClip != (ports.Rect{W: s.OutputWidth, H: s.OutputHeight}) {
9094 return ports.SurfaceContent{}, "workspace_clip"
9195 }
@@ -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) {
2525 s.Windows = append(s.Windows, ports.SceneWindow{ID: 2, Hidden: true})
2626 }, nil, ""},
2727 {"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"},
2829 {"dimmed", func(s *ports.Scene) { s.Windows[0].Dim = 0.5 }, nil, "dim"},
2930 {"faded", nil, func(c *ports.SurfaceContent) { c.Fade = 0.5 }, "fade"},
3031 {"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 {
2828 Instance ports.OutputInstance
2929 SecurityEvents chan<- ports.SecurityBackendEvent
3030 // 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.
3233 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)
3435 Width, Height int
3536 ScreenshotDir string
3637 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
120121 }()
121122 var want ports.CursorChange
122123 cursorScale := -1.0 // not loaded yet
124+ var cursorTransform ports.BufferTransform
123125 seen := map[ports.WindowID]uint64{}
124126 // reports holds what the channel could not take yet, retried soon.
125127 var reports presented.Queue
@@ -369,7 +371,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
369371 return err
370372 }
371373 // The cursor is only drawn into screenshots: no new frame.
372- want, cursorScale = c, -1
374+ want, cursorScale, cursorTransform = c, -1, 0
373375 }
374376 // Drain all queued updates before presenting one frame.
375377 drain:
@@ -426,9 +428,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
426428 continue
427429 }
428430 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 {
432434 if !opts.protected() {
433435 opts.Cursor.set(img)
434436 }
@@ -28,9 +28,10 @@ type Options struct {
28 Instance ports.OutputInstance28 Instance ports.OutputInstance
29 SecurityEvents chan<- ports.SecurityBackendEvent29 SecurityEvents chan<- ports.SecurityBackendEvent
30 // Cursor, when set, is drawn into screenshots with the image from30 // 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 *Cursor33 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 int35 Width, Height int
35 ScreenshotDir string36 ScreenshotDir string
36 HDR bool // virtual HDR output for protocol testing only37 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.CursorChange122 var want ports.CursorChange
122 cursorScale := -1.0 // not loaded yet123 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.Queue127 var reports presented.Queue
@@ -369,7 +371,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
369 return err371 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, -1374+ 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 continue428 continue
427 }429 }
428 dirty = false430 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.Scale432+ 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 {
6767 // loadCursor returns the cursor a client asked for at an output scale, at
6868 // most limit physical pixels on a side: a XCURSOR_THEME shape at
6969 // 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) {
72104 scale = max(scale, 1)
73105 if c.Hidden {
74106 return ports.CursorImage{}, nil
@@ -67,8 +67,40 @@ type themeKey struct {
67 // loadCursor returns the cursor a client asked for at an output scale, at67 // 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 at68 // most limit physical pixels on a side: a XCURSOR_THEME shape at
69 // XCURSOR_SIZE × scale, the client image resized to the output scale, or69 // 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{}, nil106 return ports.CursorImage{}, nil
M internal/app/cursor_test.go +48 -7
@@ -61,7 +61,7 @@ func TestLoadCursorShapes(t *testing.T) {
6161 // Unknown shapes fall back to the arrow.
6262 {ports.CursorChange{Shape: "zoom-in"}, 1},
6363 } {
64- img, err := loadCursor(tc.change, 2, 256)
64+ img, err := loadCursor(tc.change, 2, 0, 256)
6565 if err != nil {
6666 t.Fatal(tc.change, err)
6767 }
@@ -73,10 +73,10 @@ func TestLoadCursorShapes(t *testing.T) {
7373 if err := os.RemoveAll(dir); err != nil {
7474 t.Fatal(err)
7575 }
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 {
7777 t.Fatalf("cached: %v", err)
7878 }
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 {
8080 t.Fatalf("hidden: %+v %v", img, err)
8181 }
8282 }
@@ -84,16 +84,16 @@ func TestLoadCursorShapes(t *testing.T) {
8484 // A client cursor drawn at buffer scale 2 is shown at the output scale.
8585 func TestLoadCursorClientImage(t *testing.T) {
8686 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)
8888 if err != nil || img.W != 2 || img.H != 1 || img.HotX != 1 || img.HotY != 0 {
8989 t.Fatalf("%+v %v", img, err)
9090 }
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)
9292 if err != nil || img.W != 4 || img.HotX != 2 {
9393 t.Fatalf("%+v %v", img, err)
9494 }
9595 // 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)
9797 if err != nil || img.W != 8 || img.H != 4 {
9898 t.Fatalf("%+v %v", img, err)
9999 }
@@ -118,8 +118,49 @@ func TestLoadCursorInheritedShape(t *testing.T) {
118118 if err := os.Rename(filepath.Join(dir, "theme", "cursors", "left_ptr"), child); err != nil {
119119 t.Fatal(err)
120120 }
121- img, err := loadCursor(ports.CursorChange{Shape: "text"}, 1, 256)
121+ img, err := loadCursor(ports.CursorChange{Shape: "text"}, 1, 0, 256)
122122 if err != nil || img.Pixels[0] != 3 {
123123 t.Fatalf("got arrow instead of the inherited text cursor: %v", err)
124124 }
125125 }
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+}

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close