detached 6f4012a

feat(output): rotate and flip outputs (output.<name>.transform) (#118)

* refactor(core): single screen rect and allocation-free pointer hit tests

* feat(config): output.<name>.transform

* feat(core): lay out rotated outputs in their transformed size

* feat(vulkan): compose rotated outputs

* 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.

* feat(input): place the cursor on rotated outputs

* feat(wayland): advertise and apply output transforms

* docs(config): document output transforms

* refactor(ports): share the rotated size of a transform

* fix(wayland): send preferred_buffer_transform only when it differs and never to cursors

* fix(app): validate output transforms and guard cursor rotation

* fix(core): clamp the pointer after outputs change

* test(vulkan): cover rotated steady-state allocations in the perf gate

* docs(config): keep the placement example consistent
commit
6f4012af80bc1bff61f543b8e8a229767ab4ee64
author
bnema · · 2026-10-03 19:23 UTC
committer
GitHub · · 2026-10-03 19:23 UTC
parent
ff7108c
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
50 files · +1118 -155
M docs/config.md +4 -1
@@ -55,6 +55,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
5555 | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` |
5656 | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` |
5757 | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` |
58+| `output.<name>.transform` | `normal` | `normal`, `90`, `180`, `270`, `flipped`, `flipped-90`, `flipped-180`, `flipped-270`; 90/270 swap the output's width and height |
5859 | `output.<name>.primary` | `off` | Gets focus and pointer at startup |
5960 | `output.<name>.right-of` / `.left-of` / `.above` / `.below` | automatic | Connector to place the output against, e.g. `DP-1` |
6061 | `output.<name>.offset` | `0` | Logical px along the shared edge, −65536 to 65536; see [Output placement](#output-placement) |
@@ -85,13 +86,15 @@ output.DP-2.offset = 360 # DP-2 starts 360 px lower than DP-1's top
8586 └────────────┘
8687 ```
8788
89+Placement, offsets and monitor navigation use the rotated size: a 2560x1440 output with `transform = 90` is 1440 wide and 2560 tall at scale 1. Rotated outputs always compose, with no direct scanout or overlay planes.
90+
8891 Rules:
8992
9093 - `offset` moves down for `right-of`/`left-of` and right for `above`/`below`; negative moves the other way.
9194 - Sizes are logical: the layout follows scale changes on its own.
9295 - An output whose reference is not connected is placed automatically until the reference returns.
9396 - Cycles and invalid values are ignored with a warning in the log.
94-- Positions set by kanshi, wdisplays or wlr-randr win until the next config reload.
97+- Positions and transforms set by kanshi, wdisplays or wlr-randr win until the next config reload.
9598 - `focus-monitor-*`, `move-workspace-to-monitor-*` and column moves past the edge go to the output on that side.
9699
97100 Two rows of three, with DP-1 as the base:
@@ -55,6 +55,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
55 | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` |55 | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` |
56 | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` |56 | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` |
57 | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` |57 | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` |
58+| `output.<name>.transform` | `normal` | `normal`, `90`, `180`, `270`, `flipped`, `flipped-90`, `flipped-180`, `flipped-270`; 90/270 swap the output's width and height |
58 | `output.<name>.primary` | `off` | Gets focus and pointer at startup |59 | `output.<name>.primary` | `off` | Gets focus and pointer at startup |
59 | `output.<name>.right-of` / `.left-of` / `.above` / `.below` | automatic | Connector to place the output against, e.g. `DP-1` |60 | `output.<name>.right-of` / `.left-of` / `.above` / `.below` | automatic | Connector to place the output against, e.g. `DP-1` |
60 | `output.<name>.offset` | `0` | Logical px along the shared edge, −65536 to 65536; see [Output placement](#output-placement) |61 | `output.<name>.offset` | `0` | Logical px along the shared edge, −65536 to 65536; see [Output placement](#output-placement) |
@@ -85,13 +86,15 @@ output.DP-2.offset = 360 # DP-2 starts 360 px lower than DP-1's top
85 └────────────┘86 └────────────┘
86 ```87 ```
87 88
89+Placement, offsets and monitor navigation use the rotated size: a 2560x1440 output with `transform = 90` is 1440 wide and 2560 tall at scale 1. Rotated outputs always compose, with no direct scanout or overlay planes.
90+
88 Rules:91 Rules:
89 92
90 - `offset` moves down for `right-of`/`left-of` and right for `above`/`below`; negative moves the other way.93 - `offset` moves down for `right-of`/`left-of` and right for `above`/`below`; negative moves the other way.
91 - Sizes are logical: the layout follows scale changes on its own.94 - Sizes are logical: the layout follows scale changes on its own.
92 - An output whose reference is not connected is placed automatically until the reference returns.95 - An output whose reference is not connected is placed automatically until the reference returns.
93 - Cycles and invalid values are ignored with a warning in the log.96 - Cycles and invalid values are ignored with a warning in the log.
94-- Positions set by kanshi, wdisplays or wlr-randr win until the next config reload.97+- Positions and transforms set by kanshi, wdisplays or wlr-randr win until the next config reload.
95 - `focus-monitor-*`, `move-workspace-to-monitor-*` and column moves past the edge go to the output on that side.98 - `focus-monitor-*`, `move-workspace-to-monitor-*` and column moves past the edge go to the output on that side.
96 99
97 Two rows of three, with DP-1 as the base:100 Two rows of three, with DP-1 as the base:
M internal/adapters/config/config.go +35 -1
@@ -457,6 +457,17 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
457457 entry().Scale = s
458458 continue
459459 }
460+ if base, ok := strings.CutSuffix(name, ".transform"); ok {
461+ name = base
462+ t, err := parseTransform(value)
463+ if err != nil {
464+ warn("%s: %v", key, err)
465+ continue
466+ }
467+ override()
468+ entry().Transform = t
469+ continue
470+ }
460471 o := ports.OutputConfig{Name: name}
461472 if value == "off" {
462473 o.Off = true
@@ -468,7 +479,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
468479 }
469480 override()
470481 e := entry()
471- o.Scale, o.Primary, o.HDR, o.SDRBrightness, o.Anchor = e.Scale, e.Primary, e.HDR, e.SDRBrightness, e.Anchor
482+ o.Scale, o.Transform, o.Primary, o.HDR, o.SDRBrightness, o.Anchor = e.Scale, e.Transform, e.Primary, e.HDR, e.SDRBrightness, e.Anchor
472483 *e = o
473484 continue
474485 }
@@ -1142,6 +1153,29 @@ func keysym(k string) (string, error) {
11421153 return k, nil
11431154 }
11441155
1156+// parseTransform accepts the wl_output.transform names.
1157+func parseTransform(v string) (ports.BufferTransform, error) {
1158+ switch v {
1159+ case "normal":
1160+ return 0, nil
1161+ case "90":
1162+ return 1, nil
1163+ case "180":
1164+ return 2, nil
1165+ case "270":
1166+ return 3, nil
1167+ case "flipped":
1168+ return 4, nil
1169+ case "flipped-90":
1170+ return 5, nil
1171+ case "flipped-180":
1172+ return 6, nil
1173+ case "flipped-270":
1174+ return 7, nil
1175+ }
1176+ return 0, fmt.Errorf("invalid transform %q (normal, 90, 180, 270, flipped, flipped-90, flipped-180, flipped-270)", v)
1177+}
1178+
11451179 // parseScale accepts a decimal (1.5) or a fraction (4/3) between 1 and 4.
11461180 func parseScale(v string) (float64, error) {
11471181 var s float64
@@ -457,6 +457,17 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
457 entry().Scale = s457 entry().Scale = s
458 continue458 continue
459 }459 }
460+ if base, ok := strings.CutSuffix(name, ".transform"); ok {
461+ name = base
462+ t, err := parseTransform(value)
463+ if err != nil {
464+ warn("%s: %v", key, err)
465+ continue
466+ }
467+ override()
468+ entry().Transform = t
469+ continue
470+ }
460 o := ports.OutputConfig{Name: name}471 o := ports.OutputConfig{Name: name}
461 if value == "off" {472 if value == "off" {
462 o.Off = true473 o.Off = true
@@ -468,7 +479,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
468 }479 }
469 override()480 override()
470 e := entry()481 e := entry()
471- o.Scale, o.Primary, o.HDR, o.SDRBrightness, o.Anchor = e.Scale, e.Primary, e.HDR, e.SDRBrightness, e.Anchor482+ o.Scale, o.Transform, o.Primary, o.HDR, o.SDRBrightness, o.Anchor = e.Scale, e.Transform, e.Primary, e.HDR, e.SDRBrightness, e.Anchor
472 *e = o483 *e = o
473 continue484 continue
474 }485 }
@@ -1142,6 +1153,29 @@ func keysym(k string) (string, error) {
1142 return k, nil1153 return k, nil
1143 }1154 }
1144 1155
1156+// parseTransform accepts the wl_output.transform names.
1157+func parseTransform(v string) (ports.BufferTransform, error) {
1158+ switch v {
1159+ case "normal":
1160+ return 0, nil
1161+ case "90":
1162+ return 1, nil
1163+ case "180":
1164+ return 2, nil
1165+ case "270":
1166+ return 3, nil
1167+ case "flipped":
1168+ return 4, nil
1169+ case "flipped-90":
1170+ return 5, nil
1171+ case "flipped-180":
1172+ return 6, nil
1173+ case "flipped-270":
1174+ return 7, nil
1175+ }
1176+ return 0, fmt.Errorf("invalid transform %q (normal, 90, 180, 270, flipped, flipped-90, flipped-180, flipped-270)", v)
1177+}
1178+
1145 // parseScale accepts a decimal (1.5) or a fraction (4/3) between 1 and 4.1179 // parseScale accepts a decimal (1.5) or a fraction (4/3) between 1 and 4.
1146 func parseScale(v string) (float64, error) {1180 func parseScale(v string) (float64, error) {
1147 var s float641181 var s float64
M internal/adapters/config/config_test.go +32 -0
@@ -1,6 +1,7 @@
11 package config
22
33 import (
4+ "fmt"
45 "os"
56 "path/filepath"
67 "reflect"
@@ -640,6 +641,37 @@ output.DP-5.offset = 7
640641 }
641642 }
642643
644+func TestOutputTransform(t *testing.T) {
645+ names := []string{"normal", "90", "180", "270", "flipped", "flipped-90", "flipped-180", "flipped-270"}
646+ var text strings.Builder
647+ for i, n := range names {
648+ fmt.Fprintf(&text, "output.O%d.transform = %s\n", i, n)
649+ }
650+ c, w := parseString(t, text.String())
651+ if len(w) != 0 || len(c.Outputs) != len(names) {
652+ t.Fatalf("%v %+v", w, c.Outputs)
653+ }
654+ for i, o := range c.Outputs {
655+ if o.Transform != ports.BufferTransform(i) || !o.ScaleOnly {
656+ t.Fatalf("%s: %+v", names[i], o)
657+ }
658+ }
659+
660+ c, w = parseString(t, "output.DP-1.transform = 45\n")
661+ if len(w) != 1 || !strings.Contains(w[0].Msg, "invalid transform") {
662+ t.Fatalf("warnings %v", w)
663+ }
664+ if len(c.Outputs) != 0 {
665+ t.Fatalf("%+v", c.Outputs)
666+ }
667+
668+ // A later mode line keeps the transform.
669+ c, w = parseString(t, "output.DP-1.transform = 90\noutput.DP-1 = 1920x1080\n")
670+ if len(w) != 0 || len(c.Outputs) != 1 || c.Outputs[0].Transform != 1 || c.Outputs[0].ScaleOnly {
671+ t.Fatalf("%v %+v", w, c.Outputs)
672+ }
673+}
674+
643675 func TestOutputRelationOverride(t *testing.T) {
644676 c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.below = DP-3\n")
645677 if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "overrides line 1") {
@@ -1,6 +1,7 @@
1 package config1 package config
2 2
3 import (3 import (
4+ "fmt"
4 "os"5 "os"
5 "path/filepath"6 "path/filepath"
6 "reflect"7 "reflect"
@@ -640,6 +641,37 @@ output.DP-5.offset = 7
640 }641 }
641 }642 }
642 643
644+func TestOutputTransform(t *testing.T) {
645+ names := []string{"normal", "90", "180", "270", "flipped", "flipped-90", "flipped-180", "flipped-270"}
646+ var text strings.Builder
647+ for i, n := range names {
648+ fmt.Fprintf(&text, "output.O%d.transform = %s\n", i, n)
649+ }
650+ c, w := parseString(t, text.String())
651+ if len(w) != 0 || len(c.Outputs) != len(names) {
652+ t.Fatalf("%v %+v", w, c.Outputs)
653+ }
654+ for i, o := range c.Outputs {
655+ if o.Transform != ports.BufferTransform(i) || !o.ScaleOnly {
656+ t.Fatalf("%s: %+v", names[i], o)
657+ }
658+ }
659+
660+ c, w = parseString(t, "output.DP-1.transform = 45\n")
661+ if len(w) != 1 || !strings.Contains(w[0].Msg, "invalid transform") {
662+ t.Fatalf("warnings %v", w)
663+ }
664+ if len(c.Outputs) != 0 {
665+ t.Fatalf("%+v", c.Outputs)
666+ }
667+
668+ // A later mode line keeps the transform.
669+ c, w = parseString(t, "output.DP-1.transform = 90\noutput.DP-1 = 1920x1080\n")
670+ if len(w) != 0 || len(c.Outputs) != 1 || c.Outputs[0].Transform != 1 || c.Outputs[0].ScaleOnly {
671+ t.Fatalf("%v %+v", w, c.Outputs)
672+ }
673+}
674+
643 func TestOutputRelationOverride(t *testing.T) {675 func TestOutputRelationOverride(t *testing.T) {
644 c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.below = DP-3\n")676 c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.below = DP-3\n")
645 if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "overrides line 1") {677 if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "overrides line 1") {
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/adapters/headlessinput/input.go +2 -1
@@ -161,7 +161,8 @@ func RunSecure(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap,
161161 y, ey := strconv.ParseFloat(fields[2], 64)
162162 if ex == nil && ey == nil && !math.IsNaN(x) && !math.IsNaN(y) && !math.IsInf(x, 0) && !math.IsInf(y, 0) {
163163 if o, ok := layout.At(x, y); ok && moveCursor != nil {
164- moveCursor(o.Info.Name, (x-float64(o.X))*o.Scale, (y-float64(o.Y))*o.Scale)
164+ tx, ty := o.ToTarget(x, y)
165+ moveCursor(o.Info.Name, tx, ty)
165166 }
166167 if err := sendPointer(ports.PointerMotion{X: x, Y: y, TimeMsec: now}); err != nil {
167168 return err
@@ -161,7 +161,8 @@ func RunSecure(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap,
161 y, ey := strconv.ParseFloat(fields[2], 64)161 y, ey := strconv.ParseFloat(fields[2], 64)
162 if ex == nil && ey == nil && !math.IsNaN(x) && !math.IsNaN(y) && !math.IsInf(x, 0) && !math.IsInf(y, 0) {162 if ex == nil && ey == nil && !math.IsNaN(x) && !math.IsNaN(y) && !math.IsInf(x, 0) && !math.IsInf(y, 0) {
163 if o, ok := layout.At(x, y); ok && moveCursor != nil {163 if o, ok := layout.At(x, y); ok && moveCursor != nil {
164- moveCursor(o.Info.Name, (x-float64(o.X))*o.Scale, (y-float64(o.Y))*o.Scale)164+ tx, ty := o.ToTarget(x, y)
165+ moveCursor(o.Info.Name, tx, ty)
165 }166 }
166 if err := sendPointer(ports.PointerMotion{X: x, Y: y, TimeMsec: now}); err != nil {167 if err := sendPointer(ports.PointerMotion{X: x, Y: y, TimeMsec: now}); err != nil {
167 return err168 return err
M internal/adapters/headlessinput/input_test.go +30 -0
@@ -105,3 +105,33 @@ func TestPointerScript(t *testing.T) {
105105 t.Fatal(u)
106106 }
107107 }
108+
109+// The cursor is placed in target pixels: after the output transform.
110+func TestMoveCursorTarget(t *testing.T) {
111+ km, err := xkb.New(xkb.RMLVO{Layout: "us"})
112+ if err != nil {
113+ t.Skip(err)
114+ }
115+ layouts := make(chan ports.Layout)
116+ script := make(chan string)
117+ input := make(chan ports.InputEvent, 4)
118+ type pos struct {
119+ out string
120+ x, y float64
121+ }
122+ moved := make(chan pos, 1)
123+ done := make(chan error, 1)
124+ go func() {
125+ done <- Run(context.Background(), km, nil, script, input, layouts, func(o string, x, y float64) { moved <- pos{o, x, y} }, logging.For(context.Background(), "input"))
126+ }()
127+ // Unbuffered: the layout is taken before the move is read.
128+ layouts <- ports.Layout{{Info: ports.OutputInfo{Name: "A", Width: 200, Height: 100}, Scale: 1, Transform: 1, Width: 100, Height: 200}}
129+ script <- "move 10 20"
130+ if got, want := <-moved, (pos{"A", 20, 90}); got != want {
131+ t.Fatalf("got %+v want %+v", got, want)
132+ }
133+ close(script)
134+ if err := <-done; err != nil {
135+ t.Fatal(err)
136+ }
137+}
@@ -105,3 +105,33 @@ func TestPointerScript(t *testing.T) {
105 t.Fatal(u)105 t.Fatal(u)
106 }106 }
107 }107 }
108+
109+// The cursor is placed in target pixels: after the output transform.
110+func TestMoveCursorTarget(t *testing.T) {
111+ km, err := xkb.New(xkb.RMLVO{Layout: "us"})
112+ if err != nil {
113+ t.Skip(err)
114+ }
115+ layouts := make(chan ports.Layout)
116+ script := make(chan string)
117+ input := make(chan ports.InputEvent, 4)
118+ type pos struct {
119+ out string
120+ x, y float64
121+ }
122+ moved := make(chan pos, 1)
123+ done := make(chan error, 1)
124+ go func() {
125+ done <- Run(context.Background(), km, nil, script, input, layouts, func(o string, x, y float64) { moved <- pos{o, x, y} }, logging.For(context.Background(), "input"))
126+ }()
127+ // Unbuffered: the layout is taken before the move is read.
128+ layouts <- ports.Layout{{Info: ports.OutputInfo{Name: "A", Width: 200, Height: 100}, Scale: 1, Transform: 1, Width: 100, Height: 200}}
129+ script <- "move 10 20"
130+ if got, want := <-moved, (pos{"A", 20, 90}); got != want {
131+ t.Fatalf("got %+v want %+v", got, want)
132+ }
133+ close(script)
134+ if err := <-done; err != nil {
135+ t.Fatal(err)
136+ }
137+}
M internal/adapters/libinput/libinput.go +4 -2
@@ -775,10 +775,12 @@ func (p *pointer) bounds() ports.Rect {
775775 return r
776776 }
777777
778-// moved reports the physical position on the output under the pointer;
778+// moved reports the target position (physical pixels after the output
779+// transform) on the output under the pointer;
779780 // motion tells the device moved it.
780781 func (p *pointer) moved(move func(output string, x, y float64, motion bool), motion bool) {
781782 if o, ok := p.layout.At(p.x, p.y); ok && move != nil {
782- move(o.Info.Name, (p.x-float64(o.X))*o.Scale, (p.y-float64(o.Y))*o.Scale, motion)
783+ tx, ty := o.ToTarget(p.x, p.y)
784+ move(o.Info.Name, tx, ty, motion)
783785 }
784786 }
@@ -775,10 +775,12 @@ func (p *pointer) bounds() ports.Rect {
775 return r775 return r
776 }776 }
777 777
778-// moved reports the physical position on the output under the pointer;778+// moved reports the target position (physical pixels after the output
779+// transform) on the output under the pointer;
779 // motion tells the device moved it.780 // motion tells the device moved it.
780 func (p *pointer) moved(move func(output string, x, y float64, motion bool), motion bool) {781 func (p *pointer) moved(move func(output string, x, y float64, motion bool), motion bool) {
781 if o, ok := p.layout.At(p.x, p.y); ok && move != nil {782 if o, ok := p.layout.At(p.x, p.y); ok && move != nil {
782- move(o.Info.Name, (p.x-float64(o.X))*o.Scale, (p.y-float64(o.Y))*o.Scale, motion)783+ tx, ty := o.ToTarget(p.x, p.y)
784+ move(o.Info.Name, tx, ty, motion)
783 }785 }
784 }786 }
M internal/adapters/libinput/libinput_test.go +12 -0
@@ -337,3 +337,15 @@ func TestKeyboardProducerQuarantinesBeforeHotkeysAndLogging(t *testing.T) {
337337 t.Fatalf("quarantined release logged: %s", &buf)
338338 }
339339 }
340+
341+// The cursor is placed in target pixels: after the output transform.
342+func TestPointerMovedTransform(t *testing.T) {
343+ p := newPointer(ports.Layout{{Info: ports.OutputInfo{Name: "A", Width: 200, Height: 100}, Scale: 1, Transform: 1, Width: 100, Height: 200}})
344+ p.set(10, 20)
345+ var out string
346+ var px, py float64
347+ p.moved(func(o string, x, y float64, _ bool) { out, px, py = o, x, y }, true)
348+ if out != "A" || px != 20 || py != 90 {
349+ t.Fatal(out, px, py)
350+ }
351+}
@@ -337,3 +337,15 @@ func TestKeyboardProducerQuarantinesBeforeHotkeysAndLogging(t *testing.T) {
337 t.Fatalf("quarantined release logged: %s", &buf)337 t.Fatalf("quarantined release logged: %s", &buf)
338 }338 }
339 }339 }
340+
341+// The cursor is placed in target pixels: after the output transform.
342+func TestPointerMovedTransform(t *testing.T) {
343+ p := newPointer(ports.Layout{{Info: ports.OutputInfo{Name: "A", Width: 200, Height: 100}, Scale: 1, Transform: 1, Width: 100, Height: 200}})
344+ p.set(10, 20)
345+ var out string
346+ var px, py float64
347+ p.moved(func(o string, x, y float64, _ bool) { out, px, py = o, x, y }, true)
348+ if out != "A" || px != 20 || py != 90 {
349+ t.Fatal(out, px, py)
350+ }
351+}
M internal/adapters/vulkan/compose.go +2 -4
@@ -332,10 +332,8 @@ func (r *Renderer) contentDraw(rect, full image.Rectangle, w, h int, source [4]f
332332 source = [4]float32{0, 0, float32(w), float32(h)}
333333 }
334334 // The crop in the surface's axes: its sides swap for a rotation.
335- cw, ch := float64(source[2]), float64(source[3])
336- if t.Rotated() {
337- cw, ch = ch, cw
338- }
335+ iw, ih := t.Size(int(source[2]), int(source[3]))
336+ cw, ch := float64(iw), float64(ih)
339337 // src maps a target point to buffer pixels: affine, so its value at
340338 // the target origin and its steps along x and y define it.
341339 src := func(x, y float64) (float64, float64) {
@@ -332,10 +332,8 @@ func (r *Renderer) contentDraw(rect, full image.Rectangle, w, h int, source [4]f
332 source = [4]float32{0, 0, float32(w), float32(h)}332 source = [4]float32{0, 0, float32(w), float32(h)}
333 }333 }
334 // The crop in the surface's axes: its sides swap for a rotation.334 // The crop in the surface's axes: its sides swap for a rotation.
335- cw, ch := float64(source[2]), float64(source[3])335+ iw, ih := t.Size(int(source[2]), int(source[3]))
336- if t.Rotated() {336+ cw, ch := float64(iw), float64(ih)
337- cw, ch = ch, cw
338- }
339 // src maps a target point to buffer pixels: affine, so its value at337 // src maps a target point to buffer pixels: affine, so its value at
340 // the target origin and its steps along x and y define it.338 // the target origin and its steps along x and y define it.
341 src := func(x, y float64) (float64, float64) {339 src := func(x, y float64) (float64, float64) {
M internal/adapters/vulkan/damage_test.go +12 -6
@@ -27,16 +27,22 @@ func damagedContent(t *testing.T, f *os.File, base ports.SurfaceContent, seq uin
2727 // (render, skip, render).
2828 func TestRendererDamageMatchesFullRedraw(t *testing.T) {
2929 for _, tr := range []ports.BufferTransform{0, 1, 6} {
30- t.Run(fmt.Sprintf("transform %d", tr), func(t *testing.T) { damageMatchesFullRedraw(t, tr) })
30+ t.Run(fmt.Sprintf("transform %d", tr), func(t *testing.T) { damageMatchesFullRedraw(t, tr, 0) })
31+ }
32+ // A rotated or flipped output composes the scene into its target.
33+ for _, out := range []ports.BufferTransform{1, 6} {
34+ t.Run(fmt.Sprintf("output transform %d", out), func(t *testing.T) { damageMatchesFullRedraw(t, 0, out) })
3135 }
3236 }
3337
3438 // damageMatchesFullRedraw checks partial frames of a window whose buffer
35-// carries transform tr against full redraws.
36-func damageMatchesFullRedraw(t *testing.T, tr ports.BufferTransform) {
39+// carries transform tr against full redraws, on an output with transform
40+// out (its scene is 64×48; a rotated output's target is 48×64).
41+func damageMatchesFullRedraw(t *testing.T, tr, out ports.BufferTransform) {
3742 const w, h = 32, 32
43+ tw, th := out.Size(64, 48)
3844 newR := func() *Renderer {
39- r, err := New(64, 48)
45+ r, err := New(tw, th)
4046 if err != nil {
4147 t.Skipf("Vulkan unavailable: %v", err)
4248 }
@@ -63,7 +69,7 @@ func damageMatchesFullRedraw(t *testing.T, tr ports.BufferTransform) {
6369 base := shmContent(t, w, h, w*4, pixels)
6470 base.ID, base.Transform = 1, tr
6571 f := base.SHM.File
66- scene := ports.Scene{Seq: 5, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: w, H: h}}}}
72+ scene := ports.Scene{Seq: 5, Transform: out, OutputWidth: 64, OutputHeight: 48, Scale: 1, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: w, H: h}}}}
6773 hist := []ports.SeqDamage{{Seq: 1, Full: true}}
6874 c := damagedContent(t, f, *base, 1, pixels, hist)
6975 step := func(i int, c ports.SurfaceContent) {
@@ -73,7 +79,7 @@ func damageMatchesFullRedraw(t *testing.T, tr ports.BufferTransform) {
7379 if err := render(damaged, scene, contents); err != nil {
7480 t.Fatal(err)
7581 }
76- if err := render(full, ports.Scene{Background: scene.Background, Windows: scene.Windows}, contents); err != nil {
82+ if err := render(full, ports.Scene{Transform: out, OutputWidth: 64, OutputHeight: 48, Scale: 1, Background: scene.Background, Windows: scene.Windows}, contents); err != nil {
7783 t.Fatal(err)
7884 }
7985 if a, b := readPixels(t, damaged), readPixels(t, full); !bytes.Equal(a.Pix, b.Pix) {
@@ -27,16 +27,22 @@ func damagedContent(t *testing.T, f *os.File, base ports.SurfaceContent, seq uin
27 // (render, skip, render).27 // (render, skip, render).
28 func TestRendererDamageMatchesFullRedraw(t *testing.T) {28 func TestRendererDamageMatchesFullRedraw(t *testing.T) {
29 for _, tr := range []ports.BufferTransform{0, 1, 6} {29 for _, tr := range []ports.BufferTransform{0, 1, 6} {
30- t.Run(fmt.Sprintf("transform %d", tr), func(t *testing.T) { damageMatchesFullRedraw(t, tr) })30+ t.Run(fmt.Sprintf("transform %d", tr), func(t *testing.T) { damageMatchesFullRedraw(t, tr, 0) })
31+ }
32+ // A rotated or flipped output composes the scene into its target.
33+ for _, out := range []ports.BufferTransform{1, 6} {
34+ t.Run(fmt.Sprintf("output transform %d", out), func(t *testing.T) { damageMatchesFullRedraw(t, 0, out) })
31 }35 }
32 }36 }
33 37
34 // damageMatchesFullRedraw checks partial frames of a window whose buffer38 // damageMatchesFullRedraw checks partial frames of a window whose buffer
35-// carries transform tr against full redraws.39+// carries transform tr against full redraws, on an output with transform
36-func damageMatchesFullRedraw(t *testing.T, tr ports.BufferTransform) {40+// out (its scene is 64×48; a rotated output's target is 48×64).
41+func damageMatchesFullRedraw(t *testing.T, tr, out ports.BufferTransform) {
37 const w, h = 32, 3242 const w, h = 32, 32
43+ tw, th := out.Size(64, 48)
38 newR := func() *Renderer {44 newR := func() *Renderer {
39- r, err := New(64, 48)45+ r, err := New(tw, th)
40 if err != nil {46 if err != nil {
41 t.Skipf("Vulkan unavailable: %v", err)47 t.Skipf("Vulkan unavailable: %v", err)
42 }48 }
@@ -63,7 +69,7 @@ func damageMatchesFullRedraw(t *testing.T, tr ports.BufferTransform) {
63 base := shmContent(t, w, h, w*4, pixels)69 base := shmContent(t, w, h, w*4, pixels)
64 base.ID, base.Transform = 1, tr70 base.ID, base.Transform = 1, tr
65 f := base.SHM.File71 f := base.SHM.File
66- scene := ports.Scene{Seq: 5, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: w, H: h}}}}72+ scene := ports.Scene{Seq: 5, Transform: out, OutputWidth: 64, OutputHeight: 48, Scale: 1, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: w, H: h}}}}
67 hist := []ports.SeqDamage{{Seq: 1, Full: true}}73 hist := []ports.SeqDamage{{Seq: 1, Full: true}}
68 c := damagedContent(t, f, *base, 1, pixels, hist)74 c := damagedContent(t, f, *base, 1, pixels, hist)
69 step := func(i int, c ports.SurfaceContent) {75 step := func(i int, c ports.SurfaceContent) {
@@ -73,7 +79,7 @@ func damageMatchesFullRedraw(t *testing.T, tr ports.BufferTransform) {
73 if err := render(damaged, scene, contents); err != nil {79 if err := render(damaged, scene, contents); err != nil {
74 t.Fatal(err)80 t.Fatal(err)
75 }81 }
76- if err := render(full, ports.Scene{Background: scene.Background, Windows: scene.Windows}, contents); err != nil {82+ if err := render(full, ports.Scene{Transform: out, OutputWidth: 64, OutputHeight: 48, Scale: 1, Background: scene.Background, Windows: scene.Windows}, contents); err != nil {
77 t.Fatal(err)83 t.Fatal(err)
78 }84 }
79 if a, b := readPixels(t, damaged), readPixels(t, full); !bytes.Equal(a.Pix, b.Pix) {85 if a, b := readPixels(t, damaged), readPixels(t, full); !bytes.Equal(a.Pix, b.Pix) {
M internal/adapters/vulkan/frame.go +8 -2
@@ -176,7 +176,12 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
176176 if hdr {
177177 tg = &r.hdrOwn
178178 }
179- dmg := r.frameDamage(tg, s, image.Rect(0, 0, r.width, r.height))
179+ // Bounds, damage and redrawn pixels are in scene space: the target's
180+ // size, swapped for a rotated output. orient maps the draws to the target
181+ // below. A transform change always comes with a new scene Seq, so a held
182+ // target is never reused across transforms.
183+ sw, sh := r.sceneSize(s.Transform)
184+ dmg := r.frameDamage(tg, s, image.Rect(0, 0, sw, sh))
180185 ds := r.draws(s, contents, dmg)
181186 dmg.finish()
182187 r.dropPools()
@@ -192,8 +197,9 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
192197 r.scratchDraws = ds
193198 r.redrawn += dmg.area.Dx() * dmg.area.Dy()
194199 } else {
195- r.redrawn += r.width * r.height
200+ r.redrawn += sw * sh
196201 }
202+ r.orient(ds, s.Transform)
197203 d := r.dd
198204 cmd := slot.cmd
199205 if err := checked("vkResetCommandBuffer", d.ResetCommandBuffer(cmd, 0)); err != nil {
@@ -176,7 +176,12 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
176 if hdr {176 if hdr {
177 tg = &r.hdrOwn177 tg = &r.hdrOwn
178 }178 }
179- dmg := r.frameDamage(tg, s, image.Rect(0, 0, r.width, r.height))179+ // Bounds, damage and redrawn pixels are in scene space: the target's
180+ // size, swapped for a rotated output. orient maps the draws to the target
181+ // below. A transform change always comes with a new scene Seq, so a held
182+ // target is never reused across transforms.
183+ sw, sh := r.sceneSize(s.Transform)
184+ dmg := r.frameDamage(tg, s, image.Rect(0, 0, sw, sh))
180 ds := r.draws(s, contents, dmg)185 ds := r.draws(s, contents, dmg)
181 dmg.finish()186 dmg.finish()
182 r.dropPools()187 r.dropPools()
@@ -192,8 +197,9 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
192 r.scratchDraws = ds197 r.scratchDraws = ds
193 r.redrawn += dmg.area.Dx() * dmg.area.Dy()198 r.redrawn += dmg.area.Dx() * dmg.area.Dy()
194 } else {199 } else {
195- r.redrawn += r.width * r.height200+ r.redrawn += sw * sh
196 }201 }
202+ r.orient(ds, s.Transform)
197 d := r.dd203 d := r.dd
198 cmd := slot.cmd204 cmd := slot.cmd
199 if err := checked("vkResetCommandBuffer", d.ResetCommandBuffer(cmd, 0)); err != nil {205 if err := checked("vkResetCommandBuffer", d.ResetCommandBuffer(cmd, 0)); err != nil {
A internal/adapters/vulkan/orient.go +61 -0
@@ -0,0 +1,61 @@
1+package vulkan
2+
3+import (
4+ "image"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// sceneSize is the size of a scene in scene-physical pixels: the target's,
10+// swapped when the output transform t rotates by 90 or 270 degrees.
11+func (r *Renderer) sceneSize(t ports.BufferTransform) (int, int) {
12+ return t.Size(r.width, r.height)
13+}
14+
15+// orient maps draws built in scene space to the target, in place and without
16+// allocating: the target holds the scene transformed by t, like a client
17+// buffer holds its surface under wl_surface.set_buffer_transform. Each rect
18+// goes through t.RectToBuffer (integer rects stay integer under these eight
19+// isometries, so the rounding is exact) and each source map is composed with
20+// the affine target-to-scene map, the inverse transform. The vertex shader's
21+// target size (misc[2:4]) stays the target's.
22+//
23+// Pixel centres map to pixel centres under these isometries, so a draw
24+// flagged flagExact still reads texelFetch(floor(src)) on the right texel.
25+//
26+// It runs once per frame, after damage clipping, and before any consumer of
27+// the target: HDR composition (hdrOwn is target-sized and recordHDR reads the
28+// already-oriented target) and the capture and Pixels paths stay unchanged.
29+func (r *Renderer) orient(ds []draw, t ports.BufferTransform) {
30+ if t == 0 {
31+ return
32+ }
33+ sw, sh := r.sceneSize(t)
34+ inv := t.Invert()
35+ tw, th := float64(r.width), float64(r.height)
36+ // scene = f0 + x*fx + y*fy for a target point (x, y).
37+ f0x, f0y := inv.ToBuffer(0, 0, tw, th)
38+ fxx, fxy := inv.ToBuffer(1, 0, tw, th)
39+ fyx, fyy := inv.ToBuffer(0, 1, tw, th)
40+ fxx, fxy = fxx-f0x, fxy-f0y
41+ fyx, fyy = fyx-f0x, fyy-f0y
42+ for i := range ds {
43+ pc := &ds[i].pc
44+ rect := t.RectToBuffer(image.Rect(int(pc.rect[0]), int(pc.rect[1]), int(pc.rect[2]), int(pc.rect[3])), sw, sh)
45+ pc.rect = [4]int32{int32(rect.Min.X), int32(rect.Min.Y), int32(rect.Max.X), int32(rect.Max.Y)}
46+ if pc.misc[0] == modeSolid {
47+ continue
48+ }
49+ m0x, m0y := float64(pc.mapv[0]), float64(pc.mapv[1])
50+ mxx, mxy := float64(pc.mapv[2]), float64(pc.mapv[3])
51+ myx, myy := float64(pc.mapy[0]), float64(pc.mapy[1])
52+ pc.mapv = [4]float32{
53+ float32(m0x + f0x*mxx + f0y*myx),
54+ float32(m0y + f0x*mxy + f0y*myy),
55+ float32(fxx*mxx + fxy*myx),
56+ float32(fxx*mxy + fxy*myy),
57+ }
58+ pc.mapy[0] = float32(fyx*mxx + fyy*myx)
59+ pc.mapy[1] = float32(fyx*mxy + fyy*myy)
60+ }
61+}
@@ -0,0 +1,61 @@
1+package vulkan
2+
3+import (
4+ "image"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// sceneSize is the size of a scene in scene-physical pixels: the target's,
10+// swapped when the output transform t rotates by 90 or 270 degrees.
11+func (r *Renderer) sceneSize(t ports.BufferTransform) (int, int) {
12+ return t.Size(r.width, r.height)
13+}
14+
15+// orient maps draws built in scene space to the target, in place and without
16+// allocating: the target holds the scene transformed by t, like a client
17+// buffer holds its surface under wl_surface.set_buffer_transform. Each rect
18+// goes through t.RectToBuffer (integer rects stay integer under these eight
19+// isometries, so the rounding is exact) and each source map is composed with
20+// the affine target-to-scene map, the inverse transform. The vertex shader's
21+// target size (misc[2:4]) stays the target's.
22+//
23+// Pixel centres map to pixel centres under these isometries, so a draw
24+// flagged flagExact still reads texelFetch(floor(src)) on the right texel.
25+//
26+// It runs once per frame, after damage clipping, and before any consumer of
27+// the target: HDR composition (hdrOwn is target-sized and recordHDR reads the
28+// already-oriented target) and the capture and Pixels paths stay unchanged.
29+func (r *Renderer) orient(ds []draw, t ports.BufferTransform) {
30+ if t == 0 {
31+ return
32+ }
33+ sw, sh := r.sceneSize(t)
34+ inv := t.Invert()
35+ tw, th := float64(r.width), float64(r.height)
36+ // scene = f0 + x*fx + y*fy for a target point (x, y).
37+ f0x, f0y := inv.ToBuffer(0, 0, tw, th)
38+ fxx, fxy := inv.ToBuffer(1, 0, tw, th)
39+ fyx, fyy := inv.ToBuffer(0, 1, tw, th)
40+ fxx, fxy = fxx-f0x, fxy-f0y
41+ fyx, fyy = fyx-f0x, fyy-f0y
42+ for i := range ds {
43+ pc := &ds[i].pc
44+ rect := t.RectToBuffer(image.Rect(int(pc.rect[0]), int(pc.rect[1]), int(pc.rect[2]), int(pc.rect[3])), sw, sh)
45+ pc.rect = [4]int32{int32(rect.Min.X), int32(rect.Min.Y), int32(rect.Max.X), int32(rect.Max.Y)}
46+ if pc.misc[0] == modeSolid {
47+ continue
48+ }
49+ m0x, m0y := float64(pc.mapv[0]), float64(pc.mapv[1])
50+ mxx, mxy := float64(pc.mapv[2]), float64(pc.mapv[3])
51+ myx, myy := float64(pc.mapy[0]), float64(pc.mapy[1])
52+ pc.mapv = [4]float32{
53+ float32(m0x + f0x*mxx + f0y*myx),
54+ float32(m0y + f0x*mxy + f0y*myy),
55+ float32(fxx*mxx + fxy*myx),
56+ float32(fxx*mxy + fxy*myy),
57+ }
58+ pc.mapy[0] = float32(fyx*mxx + fyy*myx)
59+ pc.mapy[1] = float32(fyx*mxy + fyy*myy)
60+ }
61+}
A internal/adapters/vulkan/output_transform_test.go +49 -0
@@ -0,0 +1,49 @@
1+package vulkan
2+
3+import (
4+ "image/color"
5+ "math"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// The target of a rotated output holds the scene transformed by ToBuffer:
12+// every scene pixel of a window lands on the target pixel ToBuffer names,
13+// for all eight transforms, and the rest of the target stays background.
14+func TestRendererOutputTransform(t *testing.T) {
15+ const tw, th = 6, 4
16+ r, err := New(tw, th)
17+ if err != nil {
18+ t.Skipf("Vulkan unavailable: %v", err)
19+ }
20+ defer r.Close()
21+ red, green := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 255, 0, 255}
22+ px := []byte{0, 0, 255, 255, 0, 255, 0, 255} // BGRA: red, green
23+ win := ports.Rect{X: 1, Y: 0, W: 2, H: 1}
24+ for tr := ports.BufferTransform(0); tr < 8; tr++ {
25+ sw, sh := r.sceneSize(tr)
26+ c := shmContent(t, 2, 1, 8, px)
27+ scene := ports.Scene{Transform: tr, Scale: 1, OutputWidth: sw, OutputHeight: sh, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: win}}}
28+ if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil {
29+ t.Fatal(err)
30+ }
31+ img := readPixels(t, r)
32+ want := map[[2]int]color.RGBA{}
33+ for i, col := range []color.RGBA{red, green} {
34+ bx, by := tr.ToBuffer(float64(win.X+i)+0.5, 0.5, float64(sw), float64(sh))
35+ want[[2]int{int(math.Floor(bx)), int(math.Floor(by))}] = col
36+ }
37+ for y := range th {
38+ for x := range tw {
39+ w, ok := want[[2]int{x, y}]
40+ if !ok {
41+ w = color.RGBA{0, 0, 0, 255}
42+ }
43+ if got := img.RGBAAt(x, y); !near(got, w, 2) {
44+ t.Errorf("transform %d: target (%d,%d) = %v, want %v", tr, x, y, got, w)
45+ }
46+ }
47+ }
48+ }
49+}
@@ -0,0 +1,49 @@
1+package vulkan
2+
3+import (
4+ "image/color"
5+ "math"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// The target of a rotated output holds the scene transformed by ToBuffer:
12+// every scene pixel of a window lands on the target pixel ToBuffer names,
13+// for all eight transforms, and the rest of the target stays background.
14+func TestRendererOutputTransform(t *testing.T) {
15+ const tw, th = 6, 4
16+ r, err := New(tw, th)
17+ if err != nil {
18+ t.Skipf("Vulkan unavailable: %v", err)
19+ }
20+ defer r.Close()
21+ red, green := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 255, 0, 255}
22+ px := []byte{0, 0, 255, 255, 0, 255, 0, 255} // BGRA: red, green
23+ win := ports.Rect{X: 1, Y: 0, W: 2, H: 1}
24+ for tr := ports.BufferTransform(0); tr < 8; tr++ {
25+ sw, sh := r.sceneSize(tr)
26+ c := shmContent(t, 2, 1, 8, px)
27+ scene := ports.Scene{Transform: tr, Scale: 1, OutputWidth: sw, OutputHeight: sh, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: win}}}
28+ if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil {
29+ t.Fatal(err)
30+ }
31+ img := readPixels(t, r)
32+ want := map[[2]int]color.RGBA{}
33+ for i, col := range []color.RGBA{red, green} {
34+ bx, by := tr.ToBuffer(float64(win.X+i)+0.5, 0.5, float64(sw), float64(sh))
35+ want[[2]int{int(math.Floor(bx)), int(math.Floor(by))}] = col
36+ }
37+ for y := range th {
38+ for x := range tw {
39+ w, ok := want[[2]int{x, y}]
40+ if !ok {
41+ w = color.RGBA{0, 0, 0, 255}
42+ }
43+ if got := img.RGBAAt(x, y); !near(got, w, 2) {
44+ t.Errorf("transform %d: target (%d,%d) = %v, want %v", tr, x, y, got, w)
45+ }
46+ }
47+ }
48+ }
49+}
M internal/adapters/vulkan/scene.go +6 -5
@@ -15,7 +15,7 @@ type sceneWalk struct {
1515 s ports.Scene
1616 contents map[ports.WindowID]ports.SurfaceContent
1717 dmg *damageRegion
18- // scale maps the scene's logical pixels to the target's physical ones.
18+ // scale maps the scene's logical pixels to scene-physical ones.
1919 scale float64
2020 // zoom shrinks the surfaces of the window being placed: an overview
2121 // preview (ports.SceneWindow.Preview); 1 otherwise.
@@ -30,7 +30,10 @@ type sceneWalk struct {
3030 // draws walks the scene into quads in paint order.
3131 func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, dmg *damageRegion) []draw {
3232 clear(r.scratchDraws) // release references from longer earlier scenes
33- w := &sceneWalk{r: r, s: s, contents: contents, dmg: dmg, scale: s.Scale, zoom: 1, bounds: image.Rect(0, 0, r.width, r.height), draws: r.scratchDraws[:0]}
33+ // The walk stays in scene-physical space (the target's size, swapped for
34+ // a rotated output); orient maps the draws to the target afterwards.
35+ sw, sh := r.sceneSize(s.Transform)
36+ w := &sceneWalk{r: r, s: s, contents: contents, dmg: dmg, scale: s.Scale, zoom: 1, bounds: image.Rect(0, 0, sw, sh), draws: r.scratchDraws[:0]}
3437 r.scratchCovers = r.scratchCovers[:0]
3538 if w.scale <= 0 {
3639 w.scale = 1
@@ -323,9 +326,7 @@ func (w *sceneWalk) surfaceRects(content *ports.SurfaceContent, x, y float64, cl
323326 if content.Source[2] > 0 {
324327 sourceW, sourceH = int(content.Source[2]), int(content.Source[3])
325328 }
326- if content.Transform.Rotated() {
327- sourceW, sourceH = sourceH, sourceW
328- }
329+ sourceW, sourceH = content.Transform.Size(sourceW, sourceH)
329330 if near(full.Dx(), sourceW) && near(full.Dy(), sourceH) {
330331 full.Max = full.Min.Add(image.Pt(sourceW, sourceH))
331332 }
@@ -15,7 +15,7 @@ type sceneWalk struct {
15 s ports.Scene15 s ports.Scene
16 contents map[ports.WindowID]ports.SurfaceContent16 contents map[ports.WindowID]ports.SurfaceContent
17 dmg *damageRegion17 dmg *damageRegion
18- // scale maps the scene's logical pixels to the target's physical ones.18+ // scale maps the scene's logical pixels to scene-physical ones.
19 scale float6419 scale float64
20 // zoom shrinks the surfaces of the window being placed: an overview20 // zoom shrinks the surfaces of the window being placed: an overview
21 // preview (ports.SceneWindow.Preview); 1 otherwise.21 // preview (ports.SceneWindow.Preview); 1 otherwise.
@@ -30,7 +30,10 @@ type sceneWalk struct {
30 // draws walks the scene into quads in paint order.30 // draws walks the scene into quads in paint order.
31 func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, dmg *damageRegion) []draw {31 func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, dmg *damageRegion) []draw {
32 clear(r.scratchDraws) // release references from longer earlier scenes32 clear(r.scratchDraws) // release references from longer earlier scenes
33- w := &sceneWalk{r: r, s: s, contents: contents, dmg: dmg, scale: s.Scale, zoom: 1, bounds: image.Rect(0, 0, r.width, r.height), draws: r.scratchDraws[:0]}33+ // The walk stays in scene-physical space (the target's size, swapped for
34+ // a rotated output); orient maps the draws to the target afterwards.
35+ sw, sh := r.sceneSize(s.Transform)
36+ w := &sceneWalk{r: r, s: s, contents: contents, dmg: dmg, scale: s.Scale, zoom: 1, bounds: image.Rect(0, 0, sw, sh), draws: r.scratchDraws[:0]}
34 r.scratchCovers = r.scratchCovers[:0]37 r.scratchCovers = r.scratchCovers[:0]
35 if w.scale <= 0 {38 if w.scale <= 0 {
36 w.scale = 139 w.scale = 1
@@ -323,9 +326,7 @@ func (w *sceneWalk) surfaceRects(content *ports.SurfaceContent, x, y float64, cl
323 if content.Source[2] > 0 {326 if content.Source[2] > 0 {
324 sourceW, sourceH = int(content.Source[2]), int(content.Source[3])327 sourceW, sourceH = int(content.Source[2]), int(content.Source[3])
325 }328 }
326- if content.Transform.Rotated() {329+ sourceW, sourceH = content.Transform.Size(sourceW, sourceH)
327- sourceW, sourceH = sourceH, sourceW
328- }
329 if near(full.Dx(), sourceW) && near(full.Dy(), sourceH) {330 if near(full.Dx(), sourceW) && near(full.Dy(), sourceH) {
330 full.Max = full.Min.Add(image.Pt(sourceW, sourceH))331 full.Max = full.Min.Add(image.Pt(sourceW, sourceH))
331 }332 }
M internal/adapters/vulkan/scene_perf_test.go +33 -20
@@ -8,27 +8,40 @@ import (
88 "github.com/bnema/neferwl/internal/ports"
99 )
1010
11-// TestRenderSteadyStateAllocations measures the cost of a small stable scene.
11+// TestRenderSteadyStateAllocations measures the cost of a small stable scene,
12+// on a normal and on a rotated output (orient works in place).
1213 func TestRenderSteadyStateAllocations(t *testing.T) {
13- r, err := New(32, 32)
14- if err != nil {
15- t.Skipf("Vulkan unavailable: %v", err)
16- }
17- defer r.Close()
18- scene := ports.Scene{Seq: 1, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 32, H: 32}}}}
19- content := solidContent(t, 8, 8, color.RGBA{R: 255, A: 255})
20- content.ID, content.Surface, content.Seq, content.Version = 1, 1, 1, 1
21- contents := map[ports.WindowID]ports.SurfaceContent{1: content}
22- frame := func() {
23- if err := render(r, scene, contents); err != nil {
24- t.Fatal(err)
25- }
26- }
27- frame()
28- if allocs := testing.AllocsPerRun(20, frame); allocs > 13 {
29- t.Errorf("small steady-state Render: %.1f allocs/frame, want <=13", allocs)
30- } else {
31- t.Logf("small steady-state Render: %.1f allocs/frame", allocs)
14+ for _, tc := range []struct {
15+ name string
16+ transform ports.BufferTransform
17+ tw, th int
18+ sceneW, sceneH int
19+ }{
20+ {"transform 0", 0, 32, 32, 32, 32},
21+ {"transform 1", 1, 32, 24, 24, 32},
22+ } {
23+ t.Run(tc.name, func(t *testing.T) {
24+ r, err := New(tc.tw, tc.th)
25+ if err != nil {
26+ t.Skipf("Vulkan unavailable: %v", err)
27+ }
28+ defer r.Close()
29+ scene := ports.Scene{Seq: 1, Transform: tc.transform, Scale: 1, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: tc.sceneW, H: tc.sceneH}}}}
30+ content := solidContent(t, 8, 8, color.RGBA{R: 255, A: 255})
31+ content.ID, content.Surface, content.Seq, content.Version = 1, 1, 1, 1
32+ contents := map[ports.WindowID]ports.SurfaceContent{1: content}
33+ frame := func() {
34+ if err := render(r, scene, contents); err != nil {
35+ t.Fatal(err)
36+ }
37+ }
38+ frame()
39+ if allocs := testing.AllocsPerRun(20, frame); allocs > 13 {
40+ t.Errorf("small steady-state Render: %.1f allocs/frame, want <=13", allocs)
41+ } else {
42+ t.Logf("small steady-state Render: %.1f allocs/frame", allocs)
43+ }
44+ })
3245 }
3346 }
3447
@@ -8,27 +8,40 @@ import (
8 "github.com/bnema/neferwl/internal/ports"8 "github.com/bnema/neferwl/internal/ports"
9 )9 )
10 10
11-// TestRenderSteadyStateAllocations measures the cost of a small stable scene.11+// TestRenderSteadyStateAllocations measures the cost of a small stable scene,
12+// on a normal and on a rotated output (orient works in place).
12 func TestRenderSteadyStateAllocations(t *testing.T) {13 func TestRenderSteadyStateAllocations(t *testing.T) {
13- r, err := New(32, 32)14+ for _, tc := range []struct {
14- if err != nil {15+ name string
15- t.Skipf("Vulkan unavailable: %v", err)16+ transform ports.BufferTransform
16- }17+ tw, th int
17- defer r.Close()18+ sceneW, sceneH int
18- scene := ports.Scene{Seq: 1, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 32, H: 32}}}}19+ }{
19- content := solidContent(t, 8, 8, color.RGBA{R: 255, A: 255})20+ {"transform 0", 0, 32, 32, 32, 32},
20- content.ID, content.Surface, content.Seq, content.Version = 1, 1, 1, 121+ {"transform 1", 1, 32, 24, 24, 32},
21- contents := map[ports.WindowID]ports.SurfaceContent{1: content}22+ } {
22- frame := func() {23+ t.Run(tc.name, func(t *testing.T) {
23- if err := render(r, scene, contents); err != nil {24+ r, err := New(tc.tw, tc.th)
24- t.Fatal(err)25+ if err != nil {
25- }26+ t.Skipf("Vulkan unavailable: %v", err)
26- }27+ }
27- frame()28+ defer r.Close()
28- if allocs := testing.AllocsPerRun(20, frame); allocs > 13 {29+ scene := ports.Scene{Seq: 1, Transform: tc.transform, Scale: 1, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: tc.sceneW, H: tc.sceneH}}}}
29- t.Errorf("small steady-state Render: %.1f allocs/frame, want <=13", allocs)30+ content := solidContent(t, 8, 8, color.RGBA{R: 255, A: 255})
30- } else {31+ content.ID, content.Surface, content.Seq, content.Version = 1, 1, 1, 1
31- t.Logf("small steady-state Render: %.1f allocs/frame", allocs)32+ contents := map[ports.WindowID]ports.SurfaceContent{1: content}
33+ frame := func() {
34+ if err := render(r, scene, contents); err != nil {
35+ t.Fatal(err)
36+ }
37+ }
38+ frame()
39+ if allocs := testing.AllocsPerRun(20, frame); allocs > 13 {
40+ t.Errorf("small steady-state Render: %.1f allocs/frame, want <=13", allocs)
41+ } else {
42+ t.Logf("small steady-state Render: %.1f allocs/frame", allocs)
43+ }
44+ })
32 }45 }
33 }46 }
34 47
M internal/adapters/vulkan/transform_test.go +1 -4
@@ -37,10 +37,7 @@ func TestRendererBufferTransform(t *testing.T) {
3737 c.Source = [4]float32{1, 0, 2, 2}
3838 bx0, bw = 1, 2
3939 }
40- sw, sh := bw, bh
41- if tr.Rotated() {
42- sw, sh = bh, bw
43- }
40+ sw, sh := tr.Size(bw, bh)
4441 c.Transform, c.LogicalW, c.LogicalH = tr, sw, sh
4542 scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: sw, H: sh}}}}
4643 if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil {
@@ -37,10 +37,7 @@ func TestRendererBufferTransform(t *testing.T) {
37 c.Source = [4]float32{1, 0, 2, 2}37 c.Source = [4]float32{1, 0, 2, 2}
38 bx0, bw = 1, 238 bx0, bw = 1, 2
39 }39 }
40- sw, sh := bw, bh40+ sw, sh := tr.Size(bw, bh)
41- if tr.Rotated() {
42- sw, sh = bh, bw
43- }
44 c.Transform, c.LogicalW, c.LogicalH = tr, sw, sh41 c.Transform, c.LogicalW, c.LogicalH = tr, sw, sh
45 scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: sw, H: sh}}}}42 scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: sw, H: sh}}}}
46 if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil {43 if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: *c}); err != nil {
M internal/adapters/wayland/imagecopy.go +7 -1
@@ -601,6 +601,12 @@ func (f *captureExtFrame) attempt() {
601601 return
602602 }
603603 tag, ok := c.tag(hidden)
604+ // On-screen frames are target pixels, which the output holds under its
605+ // transform; off-screen child images are unrotated.
606+ transform := uint32(0)
607+ if !hidden {
608+ transform = uint32(o.place.Transform)
609+ }
604610 buf, isBuf := c.s.buffers[f.buf.GetResource()].(*buffer)
605611 switch {
606612 case !isBuf:
@@ -621,7 +627,7 @@ func (f *captureExtFrame) attempt() {
621627 f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
622628 return
623629 }
624- f.res.SendTransform(0)
630+ f.res.SendTransform(transform)
625631 f.res.SendDamage(0, 0, int32(region.Dx()), int32(region.Dy()))
626632 hi, lo, ns := captureTime(done.Time)
627633 f.res.SendPresentationTime(hi, lo, ns)
@@ -601,6 +601,12 @@ func (f *captureExtFrame) attempt() {
601 return601 return
602 }602 }
603 tag, ok := c.tag(hidden)603 tag, ok := c.tag(hidden)
604+ // On-screen frames are target pixels, which the output holds under its
605+ // transform; off-screen child images are unrotated.
606+ transform := uint32(0)
607+ if !hidden {
608+ transform = uint32(o.place.Transform)
609+ }
604 buf, isBuf := c.s.buffers[f.buf.GetResource()].(*buffer)610 buf, isBuf := c.s.buffers[f.buf.GetResource()].(*buffer)
605 switch {611 switch {
606 case !isBuf:612 case !isBuf:
@@ -621,7 +627,7 @@ func (f *captureExtFrame) attempt() {
621 f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))627 f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
622 return628 return
623 }629 }
624- f.res.SendTransform(0)630+ f.res.SendTransform(transform)
625 f.res.SendDamage(0, 0, int32(region.Dx()), int32(region.Dy()))631 f.res.SendDamage(0, 0, int32(region.Dx()), int32(region.Dy()))
626 hi, lo, ns := captureTime(done.Time)632 hi, lo, ns := captureTime(done.Time)
627 f.res.SendPresentationTime(hi, lo, ns)633 f.res.SendPresentationTime(hi, lo, ns)
M internal/adapters/wayland/outputconfiguration.go +2 -3
@@ -159,9 +159,8 @@ func (h *headConfiguration) SetTransform(r *wlr.ZwlrOutputConfigurationHeadV1, v
159159 r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidTransform), "invalid transform")
160160 return
161161 }
162- if v != 0 {
163- h.unsupported = true
164- }
162+ t := ports.BufferTransform(v)
163+ h.change.Transform = &t
165164 }
166165 func (h *headConfiguration) SetScale(r *wlr.ZwlrOutputConfigurationHeadV1, v server.Fixed) {
167166 if !h.once(&h.scaleSet, r) {
@@ -159,9 +159,8 @@ func (h *headConfiguration) SetTransform(r *wlr.ZwlrOutputConfigurationHeadV1, v
159 r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidTransform), "invalid transform")159 r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidTransform), "invalid transform")
160 return160 return
161 }161 }
162- if v != 0 {162+ t := ports.BufferTransform(v)
163- h.unsupported = true163+ h.change.Transform = &t
164- }
165 }164 }
166 func (h *headConfiguration) SetScale(r *wlr.ZwlrOutputConfigurationHeadV1, v server.Fixed) {165 func (h *headConfiguration) SetScale(r *wlr.ZwlrOutputConfigurationHeadV1, v server.Fixed) {
167 if !h.once(&h.scaleSet, r) {166 if !h.once(&h.scaleSet, r) {
M internal/adapters/wayland/outputmanagement.go +3 -1
@@ -169,8 +169,10 @@ func (m *outputManager) sendState(h *managementHead, info ports.OutputHead) {
169169 if place.Info.Name != "" {
170170 h.res.SendPosition(int32(place.X), int32(place.Y))
171171 h.res.SendScale(server.FixedFromFloat(place.Scale))
172+ h.res.SendTransform(int32(place.Transform))
173+ } else {
174+ h.res.SendTransform(0)
172175 }
173- h.res.SendTransform(0)
174176 }
175177 m.places[info.Info.Name] = m.s.placementFor(info.Info.Name)
176178 }
@@ -169,8 +169,10 @@ func (m *outputManager) sendState(h *managementHead, info ports.OutputHead) {
169 if place.Info.Name != "" {169 if place.Info.Name != "" {
170 h.res.SendPosition(int32(place.X), int32(place.Y))170 h.res.SendPosition(int32(place.X), int32(place.Y))
171 h.res.SendScale(server.FixedFromFloat(place.Scale))171 h.res.SendScale(server.FixedFromFloat(place.Scale))
172+ h.res.SendTransform(int32(place.Transform))
173+ } else {
174+ h.res.SendTransform(0)
172 }175 }
173- h.res.SendTransform(0)
174 }176 }
175 m.places[info.Info.Name] = m.s.placementFor(info.Info.Name)177 m.places[info.Info.Name] = m.s.placementFor(info.Info.Name)
176 }178 }
M internal/adapters/wayland/outputmanagement_test.go +79 -3
@@ -243,9 +243,6 @@ func TestOutputManagementUnsupportedSettings(t *testing.T) {
243243 {"custom mode", func(t *testing.T, c *wlturbo.Display, ch uint32) {
244244 requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetCustomMode, int32(800), int32(600), int32(60000))
245245 }},
246- {"transform", func(t *testing.T, c *wlturbo.Display, ch uint32) {
247- requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetTransform, int32(1))
248- }},
249246 {"adaptive sync off", func(t *testing.T, c *wlturbo.Display, ch uint32) {
250247 requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetAdaptiveSync, uint32(0))
251248 }},
@@ -288,6 +285,85 @@ func TestOutputManagementUnsupportedSettings(t *testing.T) {
288285 }
289286 }
290287
288+// set_transform reaches the backend, and heads report the placement's
289+// transform, at bind time and when it changes.
290+func TestOutputManagementTransform(t *testing.T) {
291+ s, _, apply, replies, dir := outputTestServer(t)
292+ heads := testHead()
293+ if !s.display.Do(func() {
294+ s.setOutputHeads(heads)
295+ s.setOutputs(ports.SetOutputs{Outputs: ports.Layout{{Info: heads.Heads[0].Info, Scale: 1, Transform: 1}}})
296+ }) {
297+ t.Fatal("display stopped")
298+ }
299+ c := protocolClient(t, s, dir)
300+ id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
301+ p := &managementEvents{events: make(chan [2]uint32, 32), headEvents: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}
302+ p.SetID(id)
303+ registerWireProxy(c, p)
304+ if err := c.Roundtrip(); err != nil {
305+ t.Fatal(err)
306+ }
307+ var head, serial [2]uint32
308+ for ev := recvManagement(t, p.events); ; ev = recvManagement(t, p.events) {
309+ if ev[0] == wlr.ZwlrOutputManagerV1EventHead {
310+ head = ev
311+ }
312+ if ev[0] == wlr.ZwlrOutputManagerV1EventDone {
313+ serial = ev
314+ break
315+ }
316+ }
317+ transform := func() (uint32, bool) {
318+ v, ok := uint32(0), false
319+ for len(p.headEvents) > 0 {
320+ if ev := <-p.headEvents; ev[0] == wlr.ZwlrOutputHeadV1EventTransform {
321+ v, ok = ev[1], true
322+ }
323+ }
324+ return v, ok
325+ }
326+ if v, ok := transform(); !ok || v != 1 {
327+ t.Fatalf("initial transform: %d, %v", v, ok)
328+ }
329+ configID := c.AllocateID()
330+ configProxy := &managementEvents{events: make(chan [2]uint32, 8), client: c}
331+ configProxy.SetID(configID)
332+ registerWireProxy(c, configProxy)
333+ requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, serial[1])
334+ chID := c.AllocateID()
335+ registerProtocol(t, c, chID)
336+ requestProtocol(t, c, configID, wlr.ZwlrOutputConfigurationV1RequestEnableHead, chID, head[1])
337+ requestProtocol(t, c, chID, wlr.ZwlrOutputConfigurationHeadV1RequestSetTransform, int32(3))
338+ requestProtocol(t, c, configID, wlr.ZwlrOutputConfigurationV1RequestApply)
339+ if err := c.Roundtrip(); err != nil {
340+ t.Fatal(err)
341+ }
342+ var req ports.OutputApply
343+ select {
344+ case req = <-apply:
345+ case <-time.After(time.Second):
346+ t.Fatal("no apply request")
347+ }
348+ if len(req.Heads) != 1 || req.Heads[0].Transform == nil || *req.Heads[0].Transform != 3 {
349+ t.Fatalf("apply: %+v", req)
350+ }
351+ replies <- ports.OutputApplied{ID: req.ID}
352+ waitManagementEvent(t, c, configProxy.events, uint32(wlr.ZwlrOutputConfigurationV1EventSucceeded))
353+ if !s.display.Do(func() {
354+ s.setOutputs(ports.SetOutputs{Outputs: ports.Layout{{Info: heads.Heads[0].Info, Scale: 1, Transform: 3}}})
355+ s.setOutputHeads(heads)
356+ }) {
357+ t.Fatal("display stopped")
358+ }
359+ if err := c.Roundtrip(); err != nil {
360+ t.Fatal(err)
361+ }
362+ if v, ok := transform(); !ok || v != 3 {
363+ t.Fatalf("updated transform: %d, %v", v, ok)
364+ }
365+}
366+
291367 // Each case uses a new connection because wl_display.error terminates that client.
292368 func TestOutputManagementProtocolErrors(t *testing.T) {
293369 const (
@@ -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 }},
@@ -288,6 +285,85 @@ func TestOutputManagementUnsupportedSettings(t *testing.T) {
288 }285 }
289 }286 }
290 287
288+// set_transform reaches the backend, and heads report the placement's
289+// transform, at bind time and when it changes.
290+func TestOutputManagementTransform(t *testing.T) {
291+ s, _, apply, replies, dir := outputTestServer(t)
292+ heads := testHead()
293+ if !s.display.Do(func() {
294+ s.setOutputHeads(heads)
295+ s.setOutputs(ports.SetOutputs{Outputs: ports.Layout{{Info: heads.Heads[0].Info, Scale: 1, Transform: 1}}})
296+ }) {
297+ t.Fatal("display stopped")
298+ }
299+ c := protocolClient(t, s, dir)
300+ id := bindVersion(t, c, "zwlr_output_manager_v1", 4)
301+ p := &managementEvents{events: make(chan [2]uint32, 32), headEvents: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c}
302+ p.SetID(id)
303+ registerWireProxy(c, p)
304+ if err := c.Roundtrip(); err != nil {
305+ t.Fatal(err)
306+ }
307+ var head, serial [2]uint32
308+ for ev := recvManagement(t, p.events); ; ev = recvManagement(t, p.events) {
309+ if ev[0] == wlr.ZwlrOutputManagerV1EventHead {
310+ head = ev
311+ }
312+ if ev[0] == wlr.ZwlrOutputManagerV1EventDone {
313+ serial = ev
314+ break
315+ }
316+ }
317+ transform := func() (uint32, bool) {
318+ v, ok := uint32(0), false
319+ for len(p.headEvents) > 0 {
320+ if ev := <-p.headEvents; ev[0] == wlr.ZwlrOutputHeadV1EventTransform {
321+ v, ok = ev[1], true
322+ }
323+ }
324+ return v, ok
325+ }
326+ if v, ok := transform(); !ok || v != 1 {
327+ t.Fatalf("initial transform: %d, %v", v, ok)
328+ }
329+ configID := c.AllocateID()
330+ configProxy := &managementEvents{events: make(chan [2]uint32, 8), client: c}
331+ configProxy.SetID(configID)
332+ registerWireProxy(c, configProxy)
333+ requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, serial[1])
334+ chID := c.AllocateID()
335+ registerProtocol(t, c, chID)
336+ requestProtocol(t, c, configID, wlr.ZwlrOutputConfigurationV1RequestEnableHead, chID, head[1])
337+ requestProtocol(t, c, chID, wlr.ZwlrOutputConfigurationHeadV1RequestSetTransform, int32(3))
338+ requestProtocol(t, c, configID, wlr.ZwlrOutputConfigurationV1RequestApply)
339+ if err := c.Roundtrip(); err != nil {
340+ t.Fatal(err)
341+ }
342+ var req ports.OutputApply
343+ select {
344+ case req = <-apply:
345+ case <-time.After(time.Second):
346+ t.Fatal("no apply request")
347+ }
348+ if len(req.Heads) != 1 || req.Heads[0].Transform == nil || *req.Heads[0].Transform != 3 {
349+ t.Fatalf("apply: %+v", req)
350+ }
351+ replies <- ports.OutputApplied{ID: req.ID}
352+ waitManagementEvent(t, c, configProxy.events, uint32(wlr.ZwlrOutputConfigurationV1EventSucceeded))
353+ if !s.display.Do(func() {
354+ s.setOutputs(ports.SetOutputs{Outputs: ports.Layout{{Info: heads.Heads[0].Info, Scale: 1, Transform: 3}}})
355+ s.setOutputHeads(heads)
356+ }) {
357+ t.Fatal("display stopped")
358+ }
359+ if err := c.Roundtrip(); err != nil {
360+ t.Fatal(err)
361+ }
362+ if v, ok := transform(); !ok || v != 3 {
363+ t.Fatalf("updated transform: %d, %v", v, ok)
364+ }
365+}
366+
291 // Each case uses a new connection because wl_display.error terminates that client.367 // Each case uses a new connection because wl_display.error terminates that client.
292 func TestOutputManagementProtocolErrors(t *testing.T) {368 func TestOutputManagementProtocolErrors(t *testing.T) {
293 const (369 const (
M internal/adapters/wayland/outputs.go +3 -1
@@ -65,7 +65,7 @@ func (o *output) sendAll(r *wayland.Output) {
6565 if refresh == 0 {
6666 refresh = 60000
6767 }
68- r.SendGeometry(int32(o.place.X), int32(o.place.Y), int32(i.PhysicalW), int32(i.PhysicalH), 0, make, model, 0)
68+ r.SendGeometry(int32(o.place.X), int32(o.place.Y), int32(i.PhysicalW), int32(i.PhysicalH), 0, make, model, int32(o.place.Transform))
6969 // The mode stays physical; the scale tells clients how to divide it.
7070 r.SendMode(3, int32(i.Width), int32(i.Height), int32(refresh))
7171 if r.Version() >= 2 {
@@ -209,6 +209,8 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
209209 continue
210210 }
211211 scaleChanged := o.place.Scale != p.Scale
212+ // A 90° to 270° change keeps the logical size: lock surfaces are
213+ // logical-sized and need no reconfigure then.
212214 logicalChanged := o.place.Width != p.Width || o.place.Height != p.Height
213215 o.place = p
214216 if logicalChanged {
@@ -65,7 +65,7 @@ func (o *output) sendAll(r *wayland.Output) {
65 if refresh == 0 {65 if refresh == 0 {
66 refresh = 6000066 refresh = 60000
67 }67 }
68- r.SendGeometry(int32(o.place.X), int32(o.place.Y), int32(i.PhysicalW), int32(i.PhysicalH), 0, make, model, 0)68+ r.SendGeometry(int32(o.place.X), int32(o.place.Y), int32(i.PhysicalW), int32(i.PhysicalH), 0, make, model, int32(o.place.Transform))
69 // The mode stays physical; the scale tells clients how to divide it.69 // The mode stays physical; the scale tells clients how to divide it.
70 r.SendMode(3, int32(i.Width), int32(i.Height), int32(refresh))70 r.SendMode(3, int32(i.Width), int32(i.Height), int32(refresh))
71 if r.Version() >= 2 {71 if r.Version() >= 2 {
@@ -209,6 +209,8 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
209 continue209 continue
210 }210 }
211 scaleChanged := o.place.Scale != p.Scale211 scaleChanged := o.place.Scale != p.Scale
212+ // A 90° to 270° change keeps the logical size: lock surfaces are
213+ // logical-sized and need no reconfigure then.
212 logicalChanged := o.place.Width != p.Width || o.place.Height != p.Height214 logicalChanged := o.place.Width != p.Width || o.place.Height != p.Height
213 o.place = p215 o.place = p
214 if logicalChanged {216 if logicalChanged {
M internal/adapters/wayland/scale.go +15 -8
@@ -6,6 +6,7 @@ import (
66 "github.com/bnema/go-wayland-bindings/server/fractionalscale"
77 "github.com/bnema/go-wayland-bindings/server/viewporter"
88 "github.com/bnema/go-wayland-bindings/server/wayland"
9+ "github.com/bnema/neferwl/internal/ports"
910 "github.com/bnema/purego-libwayland/server"
1011 )
1112
@@ -76,6 +77,17 @@ func (surf *surface) sendScale() {
7677 if o != nil {
7778 scale = o.place.Scale
7879 }
80+ // The hint lets the client render in the output's orientation (rotated
81+ // outputs are always composed). Normal is the protocol default, so it is
82+ // sent only on change; cursor buffers must stay untransformed.
83+ var t ports.BufferTransform
84+ if o != nil {
85+ t = o.place.Transform
86+ }
87+ if surf.wl.Version() >= 6 && surf.kind != roleCursor && t != surf.prefTransform {
88+ surf.prefTransform = t
89+ surf.wl.SendPreferredBufferTransform(uint32(t))
90+ }
7991 if scale == surf.scale {
8092 return
8193 }
@@ -225,10 +237,8 @@ func (s *surface) source(bw, bh int) ([4]float32, bool) {
225237 v := s.committedViewport.src
226238 scale := float64(max(s.bufferScale, 1))
227239 // Transformed buffer size, in the surface's axes.
228- tw, th := float64(bw), float64(bh)
229- if s.transform.Rotated() {
230- tw, th = th, tw
231- }
240+ iw, ih := s.transformedSize(bw, bh)
241+ tw, th := float64(iw), float64(ih)
232242 x0, y0 := float64(v[0])*scale/256, float64(v[1])*scale/256
233243 x1, y1 := x0+float64(v[2])*scale/256, y0+float64(v[3])*scale/256
234244 // validateViewport tolerates a rounding overshoot: sample inside the buffer.
@@ -241,10 +251,7 @@ func (s *surface) source(bw, bh int) ([4]float32, bool) {
241251 // transformedSize is the buffer size in the surface's axes: width and height
242252 // swap for a 90° or 270° buffer transform.
243253 func (s *surface) transformedSize(bw, bh int) (int, int) {
244- if s.transform.Rotated() {
245- return bh, bw
246- }
247- return bw, bh
254+ return s.transform.Size(bw, bh)
248255 }
249256
250257 func (s *surface) validateViewport(bw, bh int) bool {
@@ -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,17 @@ func (surf *surface) sendScale() {
76 if o != nil {77 if o != nil {
77 scale = o.place.Scale78 scale = o.place.Scale
78 }79 }
80+ // The hint lets the client render in the output's orientation (rotated
81+ // outputs are always composed). Normal is the protocol default, so it is
82+ // sent only on change; cursor buffers must stay untransformed.
83+ var t ports.BufferTransform
84+ if o != nil {
85+ t = o.place.Transform
86+ }
87+ if surf.wl.Version() >= 6 && surf.kind != roleCursor && t != surf.prefTransform {
88+ surf.prefTransform = t
89+ surf.wl.SendPreferredBufferTransform(uint32(t))
90+ }
79 if scale == surf.scale {91 if scale == surf.scale {
80 return92 return
81 }93 }
@@ -225,10 +237,8 @@ func (s *surface) source(bw, bh int) ([4]float32, bool) {
225 v := s.committedViewport.src237 v := s.committedViewport.src
226 scale := float64(max(s.bufferScale, 1))238 scale := float64(max(s.bufferScale, 1))
227 // Transformed buffer size, in the surface's axes.239 // Transformed buffer size, in the surface's axes.
228- tw, th := float64(bw), float64(bh)240+ iw, ih := s.transformedSize(bw, bh)
229- if s.transform.Rotated() {241+ tw, th := float64(iw), float64(ih)
230- tw, th = th, tw
231- }
232 x0, y0 := float64(v[0])*scale/256, float64(v[1])*scale/256242 x0, y0 := float64(v[0])*scale/256, float64(v[1])*scale/256
233 x1, y1 := x0+float64(v[2])*scale/256, y0+float64(v[3])*scale/256243 x1, y1 := x0+float64(v[2])*scale/256, y0+float64(v[3])*scale/256
234 // validateViewport tolerates a rounding overshoot: sample inside the buffer.244 // validateViewport tolerates a rounding overshoot: sample inside the buffer.
@@ -241,10 +251,7 @@ func (s *surface) source(bw, bh int) ([4]float32, bool) {
241 // transformedSize is the buffer size in the surface's axes: width and height251 // transformedSize is the buffer size in the surface's axes: width and height
242 // swap for a 90° or 270° buffer transform.252 // swap for a 90° or 270° buffer transform.
243 func (s *surface) transformedSize(bw, bh int) (int, int) {253 func (s *surface) transformedSize(bw, bh int) (int, int) {
244- if s.transform.Rotated() {254+ return s.transform.Size(bw, bh)
245- return bh, bw
246- }
247- return bw, bh
248 }255 }
249 256
250 func (s *surface) validateViewport(bw, bh int) bool {257 func (s *surface) validateViewport(bw, bh int) bool {
M internal/adapters/wayland/scale_test.go +89 -0
@@ -2,6 +2,7 @@ package wayland
22
33 import (
44 "context"
5+ "slices"
56 "testing"
67 "time"
78
@@ -38,6 +39,18 @@ func (p *surfaceScaleProxy) Dispatch(e *wlturbo.Event) {
3839 }
3940 }
4041
42+// surfaceTransformProxy records wl_surface.preferred_buffer_transform (opcode 3).
43+type surfaceTransformProxy struct {
44+ wlturbo.BaseProxy
45+ transforms chan uint32
46+}
47+
48+func (p *surfaceTransformProxy) Dispatch(e *wlturbo.Event) {
49+ if e.Opcode == 3 {
50+ p.transforms <- e.Uint32()
51+ }
52+}
53+
4154 func contentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, chan ports.SurfaceContent, string) {
4255 t.Helper()
4356 dir := t.TempDir()
@@ -55,6 +68,82 @@ func contentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.Cl
5568 return s, events, commands, contents, dir
5669 }
5770
71+// A v6 surface learns the transform of its output when it differs from
72+// normal (the protocol default), and again only when it changes. A cursor
73+// surface never does: cursor buffers must stay untransformed.
74+func TestPreferredBufferTransform(t *testing.T) {
75+ s, _, _, _, dir := contentServer(t)
76+ info := testOutputs[0].Info
77+ setOutputs := func(tr ports.BufferTransform, scale float64) {
78+ if !s.display.Do(func() {
79+ s.setOutputs(ports.SetOutputs{Outputs: ports.Layout{{Info: info, Width: info.Height, Height: info.Width, Scale: scale, Transform: tr}}})
80+ }) {
81+ t.Fatal("display stopped")
82+ }
83+ }
84+ newSurface := func(c *wlturbo.Display, comp uint32) (uint32, func() []uint32) {
85+ surf := c.AllocateID()
86+ p := &surfaceTransformProxy{transforms: make(chan uint32, 8)}
87+ p.SetID(surf)
88+ registerWireProxy(c, p)
89+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
90+ return surf, func() []uint32 {
91+ if err := c.Roundtrip(); err != nil {
92+ t.Fatal(err)
93+ }
94+ var got []uint32
95+ for len(p.transforms) > 0 {
96+ got = append(got, <-p.transforms)
97+ }
98+ return got
99+ }
100+ }
101+ setOutputs(0, 1)
102+ c := protocolClient(t, s, dir)
103+ g, _ := c.Registry().FindGlobal("wl_compositor")
104+ comp, err := bindWireID(c, g.Name, g.Interface, 6)
105+ if err != nil {
106+ t.Fatal(err)
107+ }
108+ seat := bindProtocol(t, c, "wl_seat")
109+ registerProtocol(t, c, seat)
110+ pointer := c.AllocateID()
111+ registerProtocol(t, c, pointer)
112+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
113+
114+ _, normal := newSurface(c, comp)
115+ cursor, cursorSeen := newSurface(c, comp)
116+ if got := normal(); len(got) != 0 {
117+ t.Fatalf("normal output sent transforms %v", got)
118+ }
119+ cursorSeen()
120+ requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), cursor, int32(0), int32(0))
121+
122+ setOutputs(1, 1)
123+ if got := normal(); !slices.Equal(got, []uint32{1}) {
124+ t.Fatalf("rotated transforms %v, want [1]", got)
125+ }
126+ if got := cursorSeen(); len(got) != 0 {
127+ t.Fatalf("cursor surface got transforms %v", got)
128+ }
129+ // A scale change alone does not repeat the transform.
130+ setOutputs(1, 2)
131+ if got := normal(); len(got) != 0 {
132+ t.Fatalf("unchanged transform resent: %v", got)
133+ }
134+ setOutputs(3, 2)
135+ if got := normal(); !slices.Equal(got, []uint32{3}) {
136+ t.Fatalf("changed transforms %v, want [3]", got)
137+ }
138+ setOutputs(0, 2)
139+ if got := normal(); !slices.Equal(got, []uint32{0}) {
140+ t.Fatalf("back to normal: %v, want [0]", got)
141+ }
142+ if got := cursorSeen(); len(got) != 0 {
143+ t.Fatalf("cursor surface got transforms %v", got)
144+ }
145+}
146+
58147 func TestFractionalScaleAndViewport(t *testing.T) {
59148 s, events, commands, contents, dir := contentServer(t)
60149 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,82 @@ func contentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.Cl
55 return s, events, commands, contents, dir68 return s, events, commands, contents, dir
56 }69 }
57 70
71+// A v6 surface learns the transform of its output when it differs from
72+// normal (the protocol default), and again only when it changes. A cursor
73+// surface never does: cursor buffers must stay untransformed.
74+func TestPreferredBufferTransform(t *testing.T) {
75+ s, _, _, _, dir := contentServer(t)
76+ info := testOutputs[0].Info
77+ setOutputs := func(tr ports.BufferTransform, scale float64) {
78+ if !s.display.Do(func() {
79+ s.setOutputs(ports.SetOutputs{Outputs: ports.Layout{{Info: info, Width: info.Height, Height: info.Width, Scale: scale, Transform: tr}}})
80+ }) {
81+ t.Fatal("display stopped")
82+ }
83+ }
84+ newSurface := func(c *wlturbo.Display, comp uint32) (uint32, func() []uint32) {
85+ surf := c.AllocateID()
86+ p := &surfaceTransformProxy{transforms: make(chan uint32, 8)}
87+ p.SetID(surf)
88+ registerWireProxy(c, p)
89+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
90+ return surf, func() []uint32 {
91+ if err := c.Roundtrip(); err != nil {
92+ t.Fatal(err)
93+ }
94+ var got []uint32
95+ for len(p.transforms) > 0 {
96+ got = append(got, <-p.transforms)
97+ }
98+ return got
99+ }
100+ }
101+ setOutputs(0, 1)
102+ c := protocolClient(t, s, dir)
103+ g, _ := c.Registry().FindGlobal("wl_compositor")
104+ comp, err := bindWireID(c, g.Name, g.Interface, 6)
105+ if err != nil {
106+ t.Fatal(err)
107+ }
108+ seat := bindProtocol(t, c, "wl_seat")
109+ registerProtocol(t, c, seat)
110+ pointer := c.AllocateID()
111+ registerProtocol(t, c, pointer)
112+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
113+
114+ _, normal := newSurface(c, comp)
115+ cursor, cursorSeen := newSurface(c, comp)
116+ if got := normal(); len(got) != 0 {
117+ t.Fatalf("normal output sent transforms %v", got)
118+ }
119+ cursorSeen()
120+ requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), cursor, int32(0), int32(0))
121+
122+ setOutputs(1, 1)
123+ if got := normal(); !slices.Equal(got, []uint32{1}) {
124+ t.Fatalf("rotated transforms %v, want [1]", got)
125+ }
126+ if got := cursorSeen(); len(got) != 0 {
127+ t.Fatalf("cursor surface got transforms %v", got)
128+ }
129+ // A scale change alone does not repeat the transform.
130+ setOutputs(1, 2)
131+ if got := normal(); len(got) != 0 {
132+ t.Fatalf("unchanged transform resent: %v", got)
133+ }
134+ setOutputs(3, 2)
135+ if got := normal(); !slices.Equal(got, []uint32{3}) {
136+ t.Fatalf("changed transforms %v, want [3]", got)
137+ }
138+ setOutputs(0, 2)
139+ if got := normal(); !slices.Equal(got, []uint32{0}) {
140+ t.Fatalf("back to normal: %v, want [0]", got)
141+ }
142+ if got := cursorSeen(); len(got) != 0 {
143+ t.Fatalf("cursor surface got transforms %v", got)
144+ }
145+}
146+
58 func TestFractionalScaleAndViewport(t *testing.T) {147 func TestFractionalScaleAndViewport(t *testing.T) {
59 s, events, commands, contents, dir := contentServer(t)148 s, events, commands, contents, dir := contentServer(t)
60 c := protocolClient(t, s, dir)149 c := protocolClient(t, s, dir)
M internal/adapters/wayland/screencopy.go +7 -3
@@ -19,8 +19,9 @@ import (
1919 // release its slot: only the backend completion does.
2020 const maxCaptureInflight = ports.MaxCaptureInflight
2121
22-// The renderer's scene and wl_output modes are already in physical orientation;
23-// this compositor does not advertise rotated wl_output transforms.
22+// captureRegion maps a logical rectangle to target pixels: scaled to the
23+// scene-physical space, then through the output transform into the target
24+// (the framebuffer, mode size) that captures read.
2425 func captureRegion(o *output, logical image.Rectangle) image.Rectangle {
2526 if o == nil {
2627 return image.Rectangle{}
@@ -29,9 +30,11 @@ func captureRegion(o *output, logical image.Rectangle) image.Rectangle {
2930 if scale <= 0 {
3031 scale = 1
3132 }
33+ sw, sh := o.place.Transform.Size(o.place.Info.Width, o.place.Info.Height)
3234 p := image.Rect(int(math.Floor(float64(logical.Min.X)*scale)), int(math.Floor(float64(logical.Min.Y)*scale)), int(math.Ceil(float64(logical.Max.X)*scale)), int(math.Ceil(float64(logical.Max.Y)*scale)))
33- return p.Intersect(image.Rect(0, 0, o.place.Info.Width, o.place.Info.Height))
35+ return o.place.Transform.RectToBuffer(p, sw, sh).Intersect(image.Rect(0, 0, o.place.Info.Width, o.place.Info.Height))
3436 }
37+
3538 func captureTime(t time.Time) (uint32, uint32, uint32) {
3639 sec := uint64(t.Unix())
3740 return uint32(sec >> 32), uint32(sec), uint32(t.Nanosecond())
@@ -154,6 +157,7 @@ func logicalRegion(o *output, phys image.Rectangle) ports.Rect {
154157 if phys == image.Rect(0, 0, o.place.Info.Width, o.place.Info.Height) {
155158 return ports.Rect{}
156159 }
160+ phys = o.place.Transform.Invert().RectToBuffer(phys, o.place.Info.Width, o.place.Info.Height)
157161 x0, y0 := int(math.Floor(float64(phys.Min.X)/scale)), int(math.Floor(float64(phys.Min.Y)/scale))
158162 x1, y1 := int(math.Ceil(float64(phys.Max.X)/scale)), int(math.Ceil(float64(phys.Max.Y)/scale))
159163 return ports.Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}
@@ -19,8 +19,9 @@ import (
19 // release its slot: only the backend completion does.19 // release its slot: only the backend completion does.
20 const maxCaptureInflight = ports.MaxCaptureInflight20 const maxCaptureInflight = ports.MaxCaptureInflight
21 21
22-// The renderer's scene and wl_output modes are already in physical orientation;22+// captureRegion maps a logical rectangle to target pixels: scaled to the
23-// this compositor does not advertise rotated wl_output transforms.23+// scene-physical space, then through the output transform into the target
24+// (the framebuffer, mode size) that captures read.
24 func captureRegion(o *output, logical image.Rectangle) image.Rectangle {25 func captureRegion(o *output, logical image.Rectangle) image.Rectangle {
25 if o == nil {26 if o == nil {
26 return image.Rectangle{}27 return image.Rectangle{}
@@ -29,9 +30,11 @@ func captureRegion(o *output, logical image.Rectangle) image.Rectangle {
29 if scale <= 0 {30 if scale <= 0 {
30 scale = 131 scale = 1
31 }32 }
33+ sw, sh := o.place.Transform.Size(o.place.Info.Width, o.place.Info.Height)
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))
34 }36 }
37+
35 func captureTime(t time.Time) (uint32, uint32, uint32) {38 func captureTime(t time.Time) (uint32, uint32, uint32) {
36 sec := uint64(t.Unix())39 sec := uint64(t.Unix())
37 return uint32(sec >> 32), uint32(sec), uint32(t.Nanosecond())40 return uint32(sec >> 32), uint32(sec), uint32(t.Nanosecond())
@@ -154,6 +157,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) {157 if phys == image.Rect(0, 0, o.place.Info.Width, o.place.Info.Height) {
155 return ports.Rect{}158 return ports.Rect{}
156 }159 }
160+ 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))161 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))162 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}163 return ports.Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}
M internal/adapters/wayland/screencopy_test.go +16 -0
@@ -39,6 +39,22 @@ func TestCaptureRegion(t *testing.T) {
3939 }
4040 }
4141 }
42+
43+// Regions are logical; a rotated output holds them in its target under the
44+// transform, and logicalRegion maps them back.
45+func TestCaptureRegionTransform(t *testing.T) {
46+ o := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Width: 200, Height: 100}, Scale: 1, Transform: 1, Width: 100, Height: 200}}
47+ got := captureRegion(o, image.Rect(0, 0, 10, 20))
48+ if want := image.Rect(0, 90, 20, 100); got != want {
49+ t.Fatalf("captureRegion %v, want %v", got, want)
50+ }
51+ if back := logicalRegion(o, got); back != (ports.Rect{X: 0, Y: 0, W: 10, H: 20}) {
52+ t.Fatalf("logicalRegion %+v", back)
53+ }
54+ if full := logicalRegion(o, image.Rect(0, 0, 200, 100)); full != (ports.Rect{}) {
55+ t.Fatalf("full target %+v", full)
56+ }
57+}
4258 func TestScreencopyProtocol(t *testing.T) {
4359 dir := t.TempDir()
4460 requests := make(chan ports.CaptureRequest, 1)
@@ -39,6 +39,22 @@ func TestCaptureRegion(t *testing.T) {
39 }39 }
40 }40 }
41 }41 }
42+
43+// Regions are logical; a rotated output holds them in its target under the
44+// transform, and logicalRegion maps them back.
45+func TestCaptureRegionTransform(t *testing.T) {
46+ o := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Width: 200, Height: 100}, Scale: 1, Transform: 1, Width: 100, Height: 200}}
47+ got := captureRegion(o, image.Rect(0, 0, 10, 20))
48+ if want := image.Rect(0, 90, 20, 100); got != want {
49+ t.Fatalf("captureRegion %v, want %v", got, want)
50+ }
51+ if back := logicalRegion(o, got); back != (ports.Rect{X: 0, Y: 0, W: 10, H: 20}) {
52+ t.Fatalf("logicalRegion %+v", back)
53+ }
54+ if full := logicalRegion(o, image.Rect(0, 0, 200, 100)); full != (ports.Rect{}) {
55+ t.Fatalf("full target %+v", full)
56+ }
57+}
42 func TestScreencopyProtocol(t *testing.T) {58 func TestScreencopyProtocol(t *testing.T) {
43 dir := t.TempDir()59 dir := t.TempDir()
44 requests := make(chan ports.CaptureRequest, 1)60 requests := make(chan ports.CaptureRequest, 1)
M internal/adapters/wayland/surface.go +3 -0
@@ -55,6 +55,9 @@ type surface struct {
5555 // on is the output the surface entered; scale is the last scale sent.
5656 on *output
5757 scale float64
58+ // prefTransform is the last preferred buffer transform sent (wl_surface
59+ // v6+); zero (normal) is the protocol default.
60+ prefTransform ports.BufferTransform
5861 // content is the last content built from this surface's own buffer;
5962 // has is false while no buffer is attached.
6063 inputAll bool
@@ -55,6 +55,9 @@ type surface struct {
55 // on is the output the surface entered; scale is the last scale sent.55 // on is the output the surface entered; scale is the last scale sent.
56 on *output56 on *output
57 scale float6457 scale float64
58+ // prefTransform is the last preferred buffer transform sent (wl_surface
59+ // v6+); zero (normal) is the protocol default.
60+ prefTransform ports.BufferTransform
58 // content is the last content built from this surface's own buffer;61 // content is the last content built from this surface's own buffer;
59 // has is false while no buffer is attached.62 // has is false while no buffer is attached.
60 inputAll bool63 inputAll bool
M internal/app/cursor.go +31 -2
@@ -67,8 +67,37 @@ 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 || len(img.Pixels) < img.W*img.H*4 {
85+ return img
86+ }
87+ dw, dh := t.Size(img.W, img.H)
88+ out := make([]byte, dw*dh*4)
89+ for y := range img.H {
90+ for x := range img.W {
91+ bx, by := t.ToBuffer(float64(x)+.5, float64(y)+.5, float64(img.W), float64(img.H))
92+ src := (y*img.W + x) * 4
93+ copy(out[(int(by)*dw+int(bx))*4:], img.Pixels[src:src+4])
94+ }
95+ }
96+ hx, hy := t.ToBuffer(float64(img.HotX)+.5, float64(img.HotY)+.5, float64(img.W), float64(img.H))
97+ return ports.CursorImage{W: dw, H: dh, HotX: int(hx), HotY: int(hy), Pixels: out}
98+}
99+
100+func loadCursorUpright(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error) {
72101 scale = max(scale, 1)
73102 if c.Hidden {
74103 return ports.CursorImage{}, nil
@@ -67,8 +67,37 @@ 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 || len(img.Pixels) < img.W*img.H*4 {
85+ return img
86+ }
87+ dw, dh := t.Size(img.W, img.H)
88+ out := make([]byte, dw*dh*4)
89+ for y := range img.H {
90+ for x := range img.W {
91+ bx, by := t.ToBuffer(float64(x)+.5, float64(y)+.5, float64(img.W), float64(img.H))
92+ src := (y*img.W + x) * 4
93+ copy(out[(int(by)*dw+int(bx))*4:], img.Pixels[src:src+4])
94+ }
95+ }
96+ hx, hy := t.ToBuffer(float64(img.HotX)+.5, float64(img.HotY)+.5, float64(img.W), float64(img.H))
97+ return ports.CursorImage{W: dw, H: dh, HotX: int(hx), HotY: int(hy), Pixels: out}
98+}
99+
100+func loadCursorUpright(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error) {
72 scale = max(scale, 1)101 scale = max(scale, 1)
73 if c.Hidden {102 if c.Hidden {
74 return ports.CursorImage{}, nil103 return ports.CursorImage{}, nil
M internal/app/cursor_test.go +53 -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,54 @@ 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+ // Pixels shorter than W*H*4 are returned as they are.
163+ short := ports.CursorImage{W: 3, H: 2, Pixels: px[:8]}
164+ if got := rotateCursor(short, 1); got.W != 3 || got.H != 2 || len(got.Pixels) != 8 {
165+ t.Fatalf("short pixels: %+v", got)
166+ }
167+ // 90 degrees: the top left pixel goes to the bottom left.
168+ if got := rotateCursor(ports.CursorImage{W: 3, H: 2, Pixels: px}, 1); got.Pixels[(2*2+0)*4] != 0 {
169+ t.Fatalf("90: top-left pixel at %v", got.Pixels)
170+ }
171+}
@@ -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,54 @@ 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+ // Pixels shorter than W*H*4 are returned as they are.
163+ short := ports.CursorImage{W: 3, H: 2, Pixels: px[:8]}
164+ if got := rotateCursor(short, 1); got.W != 3 || got.H != 2 || len(got.Pixels) != 8 {
165+ t.Fatalf("short pixels: %+v", got)
166+ }
167+ // 90 degrees: the top left pixel goes to the bottom left.
168+ if got := rotateCursor(ports.CursorImage{W: 3, H: 2, Pixels: px}, 1); got.Pixels[(2*2+0)*4] != 0 {
169+ t.Fatalf("90: top-left pixel at %v", got.Pixels)
170+ }
171+}
M internal/app/outputmanagement.go +11 -1
@@ -115,6 +115,9 @@ func (o *outputOverrides) validate(req ports.OutputApply) error {
115115 if change.Scale < 0 || change.Scale > 4 || (change.Scale > 0 && change.Scale < 1) || math.IsNaN(change.Scale) || math.IsInf(change.Scale, 0) {
116116 return fmt.Errorf("invalid scale on %s", change.Name)
117117 }
118+ if change.Transform != nil && *change.Transform > 7 {
119+ return fmt.Errorf("invalid transform on %s", change.Name)
120+ }
118121 if o.headless && (change.Enabled != current.Enabled || change.Mode != nil && (current.Current == nil || change.Mode.Width != current.Current.Width || change.Mode.Height != current.Current.Height || change.Mode.RefreshMilli != current.Current.RefreshMilli)) {
119122 return fmt.Errorf("headless mode and enabled state are fixed")
120123 }
@@ -147,6 +150,9 @@ func (o *outputOverrides) apply(req ports.OutputApply) (ports.Config, error) {
147150 if h.Scale > 0 {
148151 entry.Scale = h.Scale
149152 }
153+ if h.Transform != nil {
154+ entry.Transform = *h.Transform
155+ }
150156 if h.Pos != nil {
151157 entry.Pos = &image.Point{X: h.Pos.X, Y: h.Pos.Y}
152158 }
@@ -280,7 +286,11 @@ func (a *outputApply) finished(err error) {
280286 }
281287 a.req = nil
282288 for _, h := range req.Heads {
283- a.log.Info().Str("output", h.Name).Bool("enabled", h.Enabled).Float64("scale", h.Scale).Msg("output configuration applied")
289+ e := a.log.Info().Str("output", h.Name).Bool("enabled", h.Enabled).Float64("scale", h.Scale)
290+ if h.Transform != nil {
291+ e = e.Uint8("transform", uint8(*h.Transform))
292+ }
293+ e.Msg("output configuration applied")
284294 }
285295 a.reply(req.ID, nil)
286296 }
@@ -115,6 +115,9 @@ func (o *outputOverrides) validate(req ports.OutputApply) error {
115 if change.Scale < 0 || change.Scale > 4 || (change.Scale > 0 && change.Scale < 1) || math.IsNaN(change.Scale) || math.IsInf(change.Scale, 0) {115 if change.Scale < 0 || change.Scale > 4 || (change.Scale > 0 && change.Scale < 1) || math.IsNaN(change.Scale) || math.IsInf(change.Scale, 0) {
116 return fmt.Errorf("invalid scale on %s", change.Name)116 return fmt.Errorf("invalid scale on %s", change.Name)
117 }117 }
118+ if change.Transform != nil && *change.Transform > 7 {
119+ return fmt.Errorf("invalid transform on %s", change.Name)
120+ }
118 if o.headless && (change.Enabled != current.Enabled || change.Mode != nil && (current.Current == nil || change.Mode.Width != current.Current.Width || change.Mode.Height != current.Current.Height || change.Mode.RefreshMilli != current.Current.RefreshMilli)) {121 if o.headless && (change.Enabled != current.Enabled || change.Mode != nil && (current.Current == nil || change.Mode.Width != current.Current.Width || change.Mode.Height != current.Current.Height || change.Mode.RefreshMilli != current.Current.RefreshMilli)) {
119 return fmt.Errorf("headless mode and enabled state are fixed")122 return fmt.Errorf("headless mode and enabled state are fixed")
120 }123 }
@@ -147,6 +150,9 @@ func (o *outputOverrides) apply(req ports.OutputApply) (ports.Config, error) {
147 if h.Scale > 0 {150 if h.Scale > 0 {
148 entry.Scale = h.Scale151 entry.Scale = h.Scale
149 }152 }
153+ if h.Transform != nil {
154+ entry.Transform = *h.Transform
155+ }
150 if h.Pos != nil {156 if h.Pos != nil {
151 entry.Pos = &image.Point{X: h.Pos.X, Y: h.Pos.Y}157 entry.Pos = &image.Point{X: h.Pos.X, Y: h.Pos.Y}
152 }158 }
@@ -280,7 +286,11 @@ func (a *outputApply) finished(err error) {
280 }286 }
281 a.req = nil287 a.req = nil
282 for _, h := range req.Heads {288 for _, h := range req.Heads {
283- a.log.Info().Str("output", h.Name).Bool("enabled", h.Enabled).Float64("scale", h.Scale).Msg("output configuration applied")289+ e := a.log.Info().Str("output", h.Name).Bool("enabled", h.Enabled).Float64("scale", h.Scale)
290+ if h.Transform != nil {
291+ e = e.Uint8("transform", uint8(*h.Transform))
292+ }
293+ e.Msg("output configuration applied")
284 }294 }
285 a.reply(req.ID, nil)295 a.reply(req.ID, nil)
286 }296 }
M internal/app/outputmanagement_test.go +45 -0
@@ -307,6 +307,51 @@ func TestReloadScaleOnlyKeepsOverrides(t *testing.T) {
307307 }
308308 }
309309
310+// A transform change takes the full reload path; a scale change alone does not.
311+func TestScaleChangesTransform(t *testing.T) {
312+ old := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
313+ cur := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2, Transform: 1}}}
314+ if _, only := scaleChanges(old, cur); only {
315+ t.Fatal("a transform change must take the full reload path")
316+ }
317+ cur.Outputs[0].Transform = 0
318+ cur.Outputs[0].Scale = 1.5
319+ if _, only := scaleChanges(old, cur); !only {
320+ t.Fatal("a scale-only change stays a scale change")
321+ }
322+}
323+
324+// A runtime transform lands in the effective configuration and survives a
325+// reload that only changes file scales.
326+func TestOutputApplyTransform(t *testing.T) {
327+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
328+ file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
329+ state := newOutputOverrides(file, false)
330+ state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
331+ tr := ports.BufferTransform(3)
332+ cfg, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Transform: &tr}}})
333+ if err != nil {
334+ t.Fatal(err)
335+ }
336+ if len(cfg.Outputs) != 1 || cfg.Outputs[0].Transform != 3 {
337+ t.Fatalf("applied transform: %+v", cfg.Outputs)
338+ }
339+ scaled := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}}
340+ cfg = state.reload(scaled)
341+ if cfg.Outputs[0].Transform != 3 || cfg.Outputs[0].Scale != 1.5 {
342+ t.Fatalf("scale reload lost the runtime transform: %+v", cfg.Outputs)
343+ }
344+ // Without a transform in the change, the current one stays.
345+ cfg, err = state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true}}})
346+ if err != nil || cfg.Outputs[0].Transform != 3 {
347+ t.Fatalf("unset transform changed: %+v, %v", cfg.Outputs, err)
348+ }
349+ bad := ports.BufferTransform(8)
350+ if _, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Transform: &bad}}}); err == nil {
351+ t.Fatal("transform 8 accepted")
352+ }
353+}
354+
310355 // A runtime position never replaces a file relation: it only takes
311356 // precedence over it in core, and the relation is followed again once the
312357 // override is dropped.
@@ -307,6 +307,51 @@ func TestReloadScaleOnlyKeepsOverrides(t *testing.T) {
307 }307 }
308 }308 }
309 309
310+// A transform change takes the full reload path; a scale change alone does not.
311+func TestScaleChangesTransform(t *testing.T) {
312+ old := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
313+ cur := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2, Transform: 1}}}
314+ if _, only := scaleChanges(old, cur); only {
315+ t.Fatal("a transform change must take the full reload path")
316+ }
317+ cur.Outputs[0].Transform = 0
318+ cur.Outputs[0].Scale = 1.5
319+ if _, only := scaleChanges(old, cur); !only {
320+ t.Fatal("a scale-only change stays a scale change")
321+ }
322+}
323+
324+// A runtime transform lands in the effective configuration and survives a
325+// reload that only changes file scales.
326+func TestOutputApplyTransform(t *testing.T) {
327+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
328+ file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
329+ state := newOutputOverrides(file, false)
330+ state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
331+ tr := ports.BufferTransform(3)
332+ cfg, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Transform: &tr}}})
333+ if err != nil {
334+ t.Fatal(err)
335+ }
336+ if len(cfg.Outputs) != 1 || cfg.Outputs[0].Transform != 3 {
337+ t.Fatalf("applied transform: %+v", cfg.Outputs)
338+ }
339+ scaled := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}}
340+ cfg = state.reload(scaled)
341+ if cfg.Outputs[0].Transform != 3 || cfg.Outputs[0].Scale != 1.5 {
342+ t.Fatalf("scale reload lost the runtime transform: %+v", cfg.Outputs)
343+ }
344+ // Without a transform in the change, the current one stays.
345+ cfg, err = state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true}}})
346+ if err != nil || cfg.Outputs[0].Transform != 3 {
347+ t.Fatalf("unset transform changed: %+v, %v", cfg.Outputs, err)
348+ }
349+ bad := ports.BufferTransform(8)
350+ if _, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Transform: &bad}}}); err == nil {
351+ t.Fatal("transform 8 accepted")
352+ }
353+}
354+
310 // A runtime position never replaces a file relation: it only takes355 // A runtime position never replaces a file relation: it only takes
311 // precedence over it in core, and the relation is followed again once the356 // precedence over it in core, and the relation is followed again once the
312 // override is dropped.357 // override is dropped.
M internal/app/outputs.go +2 -1
@@ -414,7 +414,8 @@ func (c *cursors) set(output string, cur cursor) {
414414 }
415415 }
416416
417-// move places the cursor on output at physical (x, y). motion is false
417+// move places the cursor on output at target pixels (x, y): physical, after
418+// the output transform. motion is false
418419 // when the pointer is only placed again (layout or constraint change): it
419420 // neither shows an idle-hidden cursor nor delays the hide.
420421 func (c *cursors) move(output string, x, y float64, motion bool) {
@@ -414,7 +414,8 @@ func (c *cursors) set(output string, cur cursor) {
414 }414 }
415 }415 }
416 416
417-// move places the cursor on output at physical (x, y). motion is false417+// move places the cursor on output at target pixels (x, y): physical, after
418+// the output transform. motion is false
418 // when the pointer is only placed again (layout or constraint change): it419 // when the pointer is only placed again (layout or constraint change): it
419 // neither shows an idle-hidden cursor nor delays the hide.420 // neither shows an idle-hidden cursor nor delays the hide.
420 func (c *cursors) move(output string, x, y float64, motion bool) {421 func (c *cursors) move(output string, x, y float64, motion bool) {
M internal/core/core.go +10 -7
@@ -282,7 +282,7 @@ func (c *Core) apply(cfg ports.Config) error {
282282 for _, s := range c.screens {
283283 c.settings(s.mon)
284284 }
285- c.applyConfigScales()
285+ c.applyOutputConfig()
286286 return nil
287287 }
288288
@@ -549,7 +549,7 @@ func (c *Core) publish(ctx context.Context) error {
549549 if frame != (Rect{W: o.W, H: o.H}) {
550550 clip = frame
551551 }
552- scene := ports.Scene{Security: c.security, Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)}
552+ scene := ports.Scene{Security: c.security, Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)}
553553 layout := sc.mon.Layout()
554554 var real map[WindowID]Placement
555555 if sc.mon.ov.open {
@@ -909,12 +909,12 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
909909 if c.security.Protected {
910910 return c.lockHit(x, y)
911911 }
912- o, ok := c.layout().At(x, y)
913- if !ok {
912+ i := c.screenAt(x, y)
913+ if i < 0 {
914914 return 0, 0, 0
915915 }
916- lx, ly := x-float64(o.X), y-float64(o.Y)
917- sc := c.screens[c.screenIndex(o.Info.Name)]
916+ sc := c.screens[i]
917+ lx, ly := x-float64(sc.x), y-float64(sc.y)
918918 // Layer popups are over everything; window popups are over the windows
919919 // only, under the top and overlay layers (as drawn).
920920 if id, px, py := c.popupAt(sc, lx, ly, true); id != 0 {
@@ -1184,6 +1184,7 @@ func (c *Core) Run(ctx context.Context) error {
11841184 if c.layerChanged {
11851185 c.setLayers(c.allLayers())
11861186 }
1187+ c.cursorX, c.cursorY = c.clampPointer(c.cursorX, c.cursorY, c.cursorX, c.cursorY)
11871188 case ports.OutputRemoved:
11881189 // A drop target may be on the output going away.
11891190 c.abortDrag()
@@ -1191,7 +1192,7 @@ func (c *Core) Run(ctx context.Context) error {
11911192 if c.layerChanged {
11921193 c.setLayers(c.allLayers())
11931194 }
1194- c.cursorX, c.cursorY = c.layout().Clamp(c.cursorX, c.cursorY, c.cursorX, c.cursorY)
1195+ c.cursorX, c.cursorY = c.clampPointer(c.cursorX, c.cursorY, c.cursorX, c.cursorY)
11951196 }
11961197 if c.workspaceVisible(ctx, false) != nil {
11971198 return nil
@@ -1214,6 +1215,8 @@ func (c *Core) Run(ctx context.Context) error {
12141215 }
12151216 continue
12161217 }
1218+ // A rotation can shrink the layout under a still pointer.
1219+ c.cursorX, c.cursorY = c.clampPointer(c.cursorX, c.cursorY, c.cursorX, c.cursorY)
12171220 if c.workspaceVisible(ctx, false) != nil {
12181221 return nil
12191222 }
@@ -282,7 +282,7 @@ func (c *Core) apply(cfg ports.Config) error {
282 for _, s := range c.screens {282 for _, s := range c.screens {
283 c.settings(s.mon)283 c.settings(s.mon)
284 }284 }
285- c.applyConfigScales()285+ c.applyOutputConfig()
286 return nil286 return nil
287 }287 }
288 288
@@ -549,7 +549,7 @@ func (c *Core) publish(ctx context.Context) error {
549 if frame != (Rect{W: o.W, H: o.H}) {549 if frame != (Rect{W: o.W, H: o.H}) {
550 clip = frame550 clip = frame
551 }551 }
552- scene := ports.Scene{Security: c.security, Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)}552+ scene := ports.Scene{Security: c.security, Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)}
553 layout := sc.mon.Layout()553 layout := sc.mon.Layout()
554 var real map[WindowID]Placement554 var real map[WindowID]Placement
555 if sc.mon.ov.open {555 if sc.mon.ov.open {
@@ -909,12 +909,12 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
909 if c.security.Protected {909 if c.security.Protected {
910 return c.lockHit(x, y)910 return c.lockHit(x, y)
911 }911 }
912- o, ok := c.layout().At(x, y)912+ i := c.screenAt(x, y)
913- if !ok {913+ if i < 0 {
914 return 0, 0, 0914 return 0, 0, 0
915 }915 }
916- lx, ly := x-float64(o.X), y-float64(o.Y)916+ sc := c.screens[i]
917- sc := c.screens[c.screenIndex(o.Info.Name)]917+ lx, ly := x-float64(sc.x), y-float64(sc.y)
918 // Layer popups are over everything; window popups are over the windows918 // Layer popups are over everything; window popups are over the windows
919 // only, under the top and overlay layers (as drawn).919 // only, under the top and overlay layers (as drawn).
920 if id, px, py := c.popupAt(sc, lx, ly, true); id != 0 {920 if id, px, py := c.popupAt(sc, lx, ly, true); id != 0 {
@@ -1184,6 +1184,7 @@ func (c *Core) Run(ctx context.Context) error {
1184 if c.layerChanged {1184 if c.layerChanged {
1185 c.setLayers(c.allLayers())1185 c.setLayers(c.allLayers())
1186 }1186 }
1187+ c.cursorX, c.cursorY = c.clampPointer(c.cursorX, c.cursorY, c.cursorX, c.cursorY)
1187 case ports.OutputRemoved:1188 case ports.OutputRemoved:
1188 // A drop target may be on the output going away.1189 // A drop target may be on the output going away.
1189 c.abortDrag()1190 c.abortDrag()
@@ -1191,7 +1192,7 @@ func (c *Core) Run(ctx context.Context) error {
1191 if c.layerChanged {1192 if c.layerChanged {
1192 c.setLayers(c.allLayers())1193 c.setLayers(c.allLayers())
1193 }1194 }
1194- c.cursorX, c.cursorY = c.layout().Clamp(c.cursorX, c.cursorY, c.cursorX, c.cursorY)1195+ c.cursorX, c.cursorY = c.clampPointer(c.cursorX, c.cursorY, c.cursorX, c.cursorY)
1195 }1196 }
1196 if c.workspaceVisible(ctx, false) != nil {1197 if c.workspaceVisible(ctx, false) != nil {
1197 return nil1198 return nil
@@ -1214,6 +1215,8 @@ func (c *Core) Run(ctx context.Context) error {
1214 }1215 }
1215 continue1216 continue
1216 }1217 }
1218+ // A rotation can shrink the layout under a still pointer.
1219+ c.cursorX, c.cursorY = c.clampPointer(c.cursorX, c.cursorY, c.cursorX, c.cursorY)
1217 if c.workspaceVisible(ctx, false) != nil {1220 if c.workspaceVisible(ctx, false) != nil {
1218 return nil1221 return nil
1219 }1222 }
M internal/core/drag.go +9 -8
@@ -269,13 +269,13 @@ func (c *Core) abortDrag() {
269269
270270 // dropAt is the drop target under the global point x, y.
271271 func (c *Core) dropAt(id WindowID, x, y float64) dropTarget {
272- o, ok := c.layout().At(x, y)
273- if !ok {
272+ i := c.screenAt(x, y)
273+ if i < 0 {
274274 return dropTarget{}
275275 }
276- sc := c.screens[c.screenIndex(o.Info.Name)]
276+ sc := c.screens[i]
277277 m := sc.mon
278- lx, ly := int(x)-o.X, int(y)-o.Y
278+ lx, ly := int(x)-sc.x, int(y)-sc.y
279279 w := m.Current()
280280 if m.ov.open || w.pinned() || !m.frameHas(float64(lx), float64(ly)) {
281281 return dropTarget{}
@@ -514,15 +514,16 @@ func (c *Core) drop(id WindowID, t dropTarget) {
514514 // tile drag moves the pointer against its usable edge. It reports whether
515515 // the view moved.
516516 func (c *Core) edgeScroll(timeMsec uint32) bool {
517- o, ok := c.layout().At(c.cursorX, c.cursorY)
518- if !ok || timeMsec-c.drag.scrolled < edgeScrollMsec && c.drag.scrolled != 0 {
517+ i := c.screenAt(c.cursorX, c.cursorY)
518+ if i < 0 || timeMsec-c.drag.scrolled < edgeScrollMsec && c.drag.scrolled != 0 {
519519 return false
520520 }
521- w := c.screens[c.screenIndex(o.Info.Name)].mon.Current()
521+ sc := c.screens[i]
522+ w := sc.mon.Current()
522523 if w.Overflow == OverflowFixed || len(w.Columns) == 0 || w.pinned() {
523524 return false
524525 }
525- lx := int(c.cursorX) - o.X
526+ lx := int(c.cursorX) - sc.x
526527 dir := 0
527528 switch {
528529 case lx < w.Usable.X+edgeScroll:
@@ -269,13 +269,13 @@ func (c *Core) abortDrag() {
269 269
270 // dropAt is the drop target under the global point x, y.270 // dropAt is the drop target under the global point x, y.
271 func (c *Core) dropAt(id WindowID, x, y float64) dropTarget {271 func (c *Core) dropAt(id WindowID, x, y float64) dropTarget {
272- o, ok := c.layout().At(x, y)272+ i := c.screenAt(x, y)
273- if !ok {273+ if i < 0 {
274 return dropTarget{}274 return dropTarget{}
275 }275 }
276- sc := c.screens[c.screenIndex(o.Info.Name)]276+ sc := c.screens[i]
277 m := sc.mon277 m := sc.mon
278- lx, ly := int(x)-o.X, int(y)-o.Y278+ lx, ly := int(x)-sc.x, int(y)-sc.y
279 w := m.Current()279 w := m.Current()
280 if m.ov.open || w.pinned() || !m.frameHas(float64(lx), float64(ly)) {280 if m.ov.open || w.pinned() || !m.frameHas(float64(lx), float64(ly)) {
281 return dropTarget{}281 return dropTarget{}
@@ -514,15 +514,16 @@ func (c *Core) drop(id WindowID, t dropTarget) {
514 // tile drag moves the pointer against its usable edge. It reports whether514 // tile drag moves the pointer against its usable edge. It reports whether
515 // the view moved.515 // the view moved.
516 func (c *Core) edgeScroll(timeMsec uint32) bool {516 func (c *Core) edgeScroll(timeMsec uint32) bool {
517- o, ok := c.layout().At(c.cursorX, c.cursorY)517+ i := c.screenAt(c.cursorX, c.cursorY)
518- if !ok || timeMsec-c.drag.scrolled < edgeScrollMsec && c.drag.scrolled != 0 {518+ if i < 0 || timeMsec-c.drag.scrolled < edgeScrollMsec && c.drag.scrolled != 0 {
519 return false519 return false
520 }520 }
521- w := c.screens[c.screenIndex(o.Info.Name)].mon.Current()521+ sc := c.screens[i]
522+ w := sc.mon.Current()
522 if w.Overflow == OverflowFixed || len(w.Columns) == 0 || w.pinned() {523 if w.Overflow == OverflowFixed || len(w.Columns) == 0 || w.pinned() {
523 return false524 return false
524 }525 }
525- lx := int(c.cursorX) - o.X526+ lx := int(c.cursorX) - sc.x
526 dir := 0527 dir := 0
527 switch {528 switch {
528 case lx < w.Usable.X+edgeScroll:529 case lx < w.Usable.X+edgeScroll:
M internal/core/input.go +6 -5
@@ -21,17 +21,18 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
2121 c.motionMsec = v.TimeMsec
2222 // A locked pointer stays still; relative motion still flows.
2323 if c.constraint.Mode != ports.ConstraintLock {
24- c.cursorX, c.cursorY = c.constraint.Clamp(c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y))
24+ c.cursorX, c.cursorY = c.constraint.Clamp(c.clampPointer(c.cursorX, c.cursorY, v.X, v.Y))
2525 }
2626 // The focused screen follows the pointer, so new windows
2727 // and launchers open where the user is.
2828 // Only a pointer entering another output switches: keyboard
2929 // moves to another screen stick until then. Not mid-drag of a
3030 // client; a window drag (drag.go) follows the pointer.
31- if o, ok := c.layout().At(c.cursorX, c.cursorY); ok && o.Info.Name != c.pointerOutput && c.grab == 0 {
32- c.pointerOutput = o.Info.Name
33- if o.Info.Name != c.cur().name() {
34- c.focusScreen = c.screenIndex(o.Info.Name)
31+ if i := c.screenAt(c.cursorX, c.cursorY); i >= 0 && c.screens[i].name() != c.pointerOutput && c.grab == 0 {
32+ name := c.screens[i].name()
33+ c.pointerOutput = name
34+ if name != c.cur().name() {
35+ c.focusScreen = i
3536 if err := c.publish(ctx); err != nil {
3637 return err
3738 }
@@ -21,17 +21,18 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
21 c.motionMsec = v.TimeMsec21 c.motionMsec = v.TimeMsec
22 // A locked pointer stays still; relative motion still flows.22 // A locked pointer stays still; relative motion still flows.
23 if c.constraint.Mode != ports.ConstraintLock {23 if c.constraint.Mode != ports.ConstraintLock {
24- c.cursorX, c.cursorY = c.constraint.Clamp(c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y))24+ c.cursorX, c.cursorY = c.constraint.Clamp(c.clampPointer(c.cursorX, c.cursorY, v.X, v.Y))
25 }25 }
26 // The focused screen follows the pointer, so new windows26 // The focused screen follows the pointer, so new windows
27 // and launchers open where the user is.27 // and launchers open where the user is.
28 // Only a pointer entering another output switches: keyboard28 // Only a pointer entering another output switches: keyboard
29 // moves to another screen stick until then. Not mid-drag of a29 // moves to another screen stick until then. Not mid-drag of a
30 // client; a window drag (drag.go) follows the pointer.30 // client; a window drag (drag.go) follows the pointer.
31- if o, ok := c.layout().At(c.cursorX, c.cursorY); ok && o.Info.Name != c.pointerOutput && c.grab == 0 {31+ if i := c.screenAt(c.cursorX, c.cursorY); i >= 0 && c.screens[i].name() != c.pointerOutput && c.grab == 0 {
32- c.pointerOutput = o.Info.Name32+ name := c.screens[i].name()
33- if o.Info.Name != c.cur().name() {33+ c.pointerOutput = name
34- c.focusScreen = c.screenIndex(o.Info.Name)34+ if name != c.cur().name() {
35+ c.focusScreen = i
35 if err := c.publish(ctx); err != nil {36 if err := c.publish(ctx); err != nil {
36 return err37 return err
37 }38 }
M internal/core/overview.go +3 -4
@@ -835,16 +835,15 @@ func (m *Monitor) overviewAt(x, y float64) WindowID {
835835 // overviewClick closes the overview on the preview under the pointer, on
836836 // the output it shows on. picked is false when no preview is there.
837837 func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) {
838- o, ok := c.layout().At(c.cursorX, c.cursorY)
839- if !ok {
838+ i := c.screenAt(c.cursorX, c.cursorY)
839+ if i < 0 {
840840 return false, nil
841841 }
842- i := c.screenIndex(o.Info.Name)
843842 sc := c.screens[i]
844843 if !sc.mon.ov.open {
845844 return false, nil
846845 }
847- id := sc.mon.overviewAt(c.cursorX-float64(o.X), c.cursorY-float64(o.Y))
846+ id := sc.mon.overviewAt(c.cursorX-float64(sc.x), c.cursorY-float64(sc.y))
848847 if id == 0 {
849848 return false, nil
850849 }
@@ -835,16 +835,15 @@ func (m *Monitor) overviewAt(x, y float64) WindowID {
835 // overviewClick closes the overview on the preview under the pointer, on835 // overviewClick closes the overview on the preview under the pointer, on
836 // the output it shows on. picked is false when no preview is there.836 // the output it shows on. picked is false when no preview is there.
837 func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) {837 func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) {
838- o, ok := c.layout().At(c.cursorX, c.cursorY)838+ i := c.screenAt(c.cursorX, c.cursorY)
839- if !ok {839+ if i < 0 {
840 return false, nil840 return false, nil
841 }841 }
842- i := c.screenIndex(o.Info.Name)
843 sc := c.screens[i]842 sc := c.screens[i]
844 if !sc.mon.ov.open {843 if !sc.mon.ov.open {
845 return false, nil844 return false, nil
846 }845 }
847- id := sc.mon.overviewAt(c.cursorX-float64(o.X), c.cursorY-float64(o.Y))846+ id := sc.mon.overviewAt(c.cursorX-float64(sc.x), c.cursorY-float64(sc.y))
848 if id == 0 {847 if id == 0 {
849 return false, nil848 return false, nil
850 }849 }
A internal/core/screen_geometry_test.go +49 -0
@@ -0,0 +1,49 @@
1+package core
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func geometryTiles() []tile {
10+ return []tile{{"A", 0, 0, 200, 100}, {"B", 200, 0, 400, 200}}
11+}
12+
13+func TestScreenAt(t *testing.T) {
14+ c := layoutCore(0, geometryTiles()...)
15+ for _, tc := range []struct {
16+ x, y float64
17+ want int
18+ }{
19+ {199, 50, 0}, {200, 50, 1}, {250, 150, 1}, {100, 150, -1}, {-1, 0, -1},
20+ } {
21+ assert.Equal(t, tc.want, c.screenAt(tc.x, tc.y), "(%v,%v)", tc.x, tc.y)
22+ }
23+
24+ p := layoutCore(0, tile{"", 0, 0, 200, 100})
25+ assert.Equal(t, -1, p.screenAt(10, 10), "a placeholder is never returned")
26+}
27+
28+func TestClampPointer(t *testing.T) {
29+ c := layoutCore(0, geometryTiles()...)
30+ x, y := c.clampPointer(0, 0, 100, 50)
31+ assert.Equal(t, [2]float64{100, 50}, [2]float64{x, y})
32+ x, y = c.clampPointer(100, 50, 100, 150)
33+ assert.Equal(t, [2]float64{100, 99}, [2]float64{x, y})
34+ x, y = c.clampPointer(-5, -5, -10, -10)
35+ assert.Equal(t, [2]float64{0, 0}, [2]float64{x, y})
36+
37+ none := layoutCore(0, tile{"", 0, 0, 200, 100})
38+ x, y = none.clampPointer(0, 0, 500, 500)
39+ assert.Equal(t, [2]float64{500, 500}, [2]float64{x, y})
40+}
41+
42+func TestScreenAtAllocations(t *testing.T) {
43+ c := layoutCore(0, geometryTiles()...)
44+ n := testing.AllocsPerRun(100, func() {
45+ c.screenAt(250, 150)
46+ c.clampPointer(100, 50, 100, 150)
47+ })
48+ assert.Zero(t, n)
49+}
@@ -0,0 +1,49 @@
1+package core
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func geometryTiles() []tile {
10+ return []tile{{"A", 0, 0, 200, 100}, {"B", 200, 0, 400, 200}}
11+}
12+
13+func TestScreenAt(t *testing.T) {
14+ c := layoutCore(0, geometryTiles()...)
15+ for _, tc := range []struct {
16+ x, y float64
17+ want int
18+ }{
19+ {199, 50, 0}, {200, 50, 1}, {250, 150, 1}, {100, 150, -1}, {-1, 0, -1},
20+ } {
21+ assert.Equal(t, tc.want, c.screenAt(tc.x, tc.y), "(%v,%v)", tc.x, tc.y)
22+ }
23+
24+ p := layoutCore(0, tile{"", 0, 0, 200, 100})
25+ assert.Equal(t, -1, p.screenAt(10, 10), "a placeholder is never returned")
26+}
27+
28+func TestClampPointer(t *testing.T) {
29+ c := layoutCore(0, geometryTiles()...)
30+ x, y := c.clampPointer(0, 0, 100, 50)
31+ assert.Equal(t, [2]float64{100, 50}, [2]float64{x, y})
32+ x, y = c.clampPointer(100, 50, 100, 150)
33+ assert.Equal(t, [2]float64{100, 99}, [2]float64{x, y})
34+ x, y = c.clampPointer(-5, -5, -10, -10)
35+ assert.Equal(t, [2]float64{0, 0}, [2]float64{x, y})
36+
37+ none := layoutCore(0, tile{"", 0, 0, 200, 100})
38+ x, y = none.clampPointer(0, 0, 500, 500)
39+ assert.Equal(t, [2]float64{500, 500}, [2]float64{x, y})
40+}
41+
42+func TestScreenAtAllocations(t *testing.T) {
43+ c := layoutCore(0, geometryTiles()...)
44+ n := testing.AllocsPerRun(100, func() {
45+ c.screenAt(250, 150)
46+ c.clampPointer(100, 50, 100, 150)
47+ })
48+ assert.Zero(t, n)
49+}
M internal/core/screens.go +83 -19
@@ -13,11 +13,14 @@ type screen struct {
1313 info ports.OutputInfo
1414 mon *Monitor
1515 scale, cfgScale float64
16- primary bool
17- x, y int // logical origin in the global layout
18- off bool // turned off by a client (output power management)
19- layers []ports.LayerSurface
20- placed []ports.SceneLayer
16+ // transform is how the physical target holds the screen's logical
17+ // output (output.<name>.transform); 90/270 swap width and height.
18+ transform ports.BufferTransform
19+ primary bool
20+ x, y int // logical origin in the global layout
21+ off bool // turned off by a client (output power management)
22+ layers []ports.LayerSurface
23+ placed []ports.SceneLayer
2124 // capture holds the attached layers of the capture exclusion: shown over
2225 // a fullscreen window (capture.go).
2326 capture map[WindowID]bool
@@ -29,15 +32,28 @@ type screen struct {
2932
3033 func (s *screen) name() string { return s.info.Name }
3134
32-func (s *screen) placement() ports.OutputPlacement {
35+// modeSize is the physical mode size in the screen's logical orientation:
36+// width and height swap for a 90/270 transform.
37+func (s *screen) modeSize() (w, h int) {
38+ return s.transform.Size(s.info.Width, s.info.Height)
39+}
40+
41+// rect is the screen's place in the global logical layout.
42+func (s *screen) rect() Rect {
3343 o := s.mon.Output()
34- return ports.OutputPlacement{Info: s.info, X: s.x, Y: s.y, Width: o.W, Height: o.H, Scale: s.scale, Primary: s.primary}
44+ return Rect{X: s.x, Y: s.y, W: o.W, H: o.H}
45+}
46+
47+func (s *screen) placement() ports.OutputPlacement {
48+ r := s.rect()
49+ return ports.OutputPlacement{Info: s.info, X: r.X, Y: r.Y, Width: r.W, Height: r.H, Scale: s.scale, Transform: s.transform, Primary: s.primary}
3550 }
3651
3752 // setScale resizes the logical output; layer placement follows.
3853 func (s *screen) setScale(v float64) {
3954 s.scale = SnapScale(v)
40- s.mon.SetOutput(logical(s.info.Width, s.scale), logical(s.info.Height, s.scale))
55+ w, h := s.modeSize()
56+ s.mon.SetOutput(logical(w, s.scale), logical(h, s.scale))
4157 s.arrange()
4258 }
4359
@@ -63,8 +79,8 @@ func (c *Core) order() {
6379 c.focusScreen = slices.Index(c.screens, focused)
6480 items := make([]placeItem, len(c.screens))
6581 for i, s := range c.screens {
66- o := s.mon.Output()
67- items[i].name, items[i].w, items[i].h = s.name(), o.W, o.H
82+ r := s.rect()
83+ items[i].name, items[i].w, items[i].h = s.name(), r.W, r.H
6884 if cfg, ok := c.outputConfig(s.name()); ok {
6985 items[i].pos, items[i].anchor = cfg.Pos, cfg.Anchor
7086 }
@@ -107,6 +123,39 @@ func (c *Core) screenIndex(name string) int {
107123 return slices.IndexFunc(c.screens, func(s *screen) bool { return s.name() == name })
108124 }
109125
126+// screenAt is the index of the first connected screen containing the global
127+// point, or -1. It does not allocate.
128+func (c *Core) screenAt(x, y float64) int {
129+ for i, s := range c.screens {
130+ if s.name() == "" {
131+ continue
132+ }
133+ r := s.rect()
134+ if x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) {
135+ return i
136+ }
137+ }
138+ return -1
139+}
140+
141+// clampPointer keeps the pointer on a screen: a point outside every screen
142+// stays on the screen under (fromX, fromY), or the first one, clamped to its
143+// edges.
144+func (c *Core) clampPointer(fromX, fromY, x, y float64) (float64, float64) {
145+ i := c.screenAt(x, y)
146+ if i >= 0 {
147+ return x, y
148+ }
149+ if i = c.screenAt(fromX, fromY); i < 0 {
150+ i = slices.IndexFunc(c.screens, func(s *screen) bool { return s.name() != "" })
151+ if i < 0 {
152+ return x, y
153+ }
154+ }
155+ r := c.screens[i].rect()
156+ return min(max(x, float64(r.X)), float64(r.X+r.W)-1), min(max(y, float64(r.Y)), float64(r.Y+r.H)-1)
157+}
158+
110159 // screenOf returns the screen and workspace holding the window.
111160 func (c *Core) screenOf(id WindowID) (*screen, *Workspace) {
112161 for _, s := range c.screens {
@@ -154,14 +203,27 @@ func (c *Core) configScale(name string) float64 {
154203 return SnapScale(v)
155204 }
156205
157-// applyConfigScales picks up output.<name>.scale when it changed in the
158-// config, replacing any live scale-up/scale-down adjustment.
159-func (c *Core) applyConfigScales() {
206+// configTransform is output.<name>.transform, else 0.
207+func (c *Core) configTransform(name string) ports.BufferTransform {
208+ o, _ := c.outputConfig(name)
209+ return o.Transform
210+}
211+
212+// applyOutputConfig picks up output.<name>.scale and .transform when they
213+// changed in the config. A new scale replaces any live scale-up/scale-down
214+// adjustment; a transform alone keeps it.
215+func (c *Core) applyOutputConfig() {
160216 for _, s := range c.screens {
161- if v := c.configScale(s.name()); v != s.cfgScale {
162- s.cfgScale = v
163- s.setScale(v)
217+ v, t := c.configScale(s.name()), c.configTransform(s.name())
218+ if v == s.cfgScale && t == s.transform {
219+ continue
220+ }
221+ scale := s.scale
222+ if v != s.cfgScale {
223+ scale = v
164224 }
225+ s.cfgScale, s.transform = v, t
226+ s.setScale(scale)
165227 }
166228 c.order()
167229 }
@@ -178,6 +240,7 @@ func (c *Core) addScreen(info ports.OutputInfo) {
178240 s.mon.Key = info.Key()
179241 c.settings(s.mon)
180242 }
243+ s.transform = c.configTransform(info.Name)
181244 s.setScale(s.scale)
182245 c.order()
183246 return
@@ -195,6 +258,7 @@ func (c *Core) addScreen(info ports.OutputInfo) {
195258 s.mon.Name, s.mon.Key = info.Name, info.Key()
196259 c.settings(s.mon)
197260 s.cfgScale = c.configScale(info.Name)
261+ s.transform = c.configTransform(info.Name)
198262 s.setScale(s.cfgScale)
199263 s.primary = c.isPrimary(info.Name)
200264 c.order()
@@ -429,11 +493,11 @@ func (c *Core) neighbor(d direction) int {
429493 }
430494 // span is the extent of s along the move (lo, hi) and across it.
431495 span := func(s *screen) (lo, hi, clo, chi int) {
432- o := s.mon.Output()
496+ r := s.rect()
433497 if d == dirUp || d == dirDown {
434- return s.y, s.y + o.H, s.x, s.x + o.W
498+ return r.Y, r.Y + r.H, r.X, r.X + r.W
435499 }
436- return s.x, s.x + o.W, s.y, s.y + o.H
500+ return r.X, r.X + r.W, r.Y, r.Y + r.H
437501 }
438502 lo, hi, clo, chi := span(c.cur())
439503 pick := func(fallback bool) int {
@@ -13,11 +13,14 @@ type screen struct {
13 info ports.OutputInfo13 info ports.OutputInfo
14 mon *Monitor14 mon *Monitor
15 scale, cfgScale float6415 scale, cfgScale float64
16- primary bool16+ // transform is how the physical target holds the screen's logical
17- x, y int // logical origin in the global layout17+ // output (output.<name>.transform); 90/270 swap width and height.
18- off bool // turned off by a client (output power management)18+ transform ports.BufferTransform
19- layers []ports.LayerSurface19+ primary bool
20- placed []ports.SceneLayer20+ x, y int // logical origin in the global layout
21+ off bool // turned off by a client (output power management)
22+ layers []ports.LayerSurface
23+ placed []ports.SceneLayer
21 // capture holds the attached layers of the capture exclusion: shown over24 // capture holds the attached layers of the capture exclusion: shown over
22 // a fullscreen window (capture.go).25 // a fullscreen window (capture.go).
23 capture map[WindowID]bool26 capture map[WindowID]bool
@@ -29,15 +32,28 @@ type screen struct {
29 32
30 func (s *screen) name() string { return s.info.Name }33 func (s *screen) name() string { return s.info.Name }
31 34
32-func (s *screen) placement() ports.OutputPlacement {35+// modeSize is the physical mode size in the screen's logical orientation:
36+// width and height swap for a 90/270 transform.
37+func (s *screen) modeSize() (w, h int) {
38+ return s.transform.Size(s.info.Width, s.info.Height)
39+}
40+
41+// rect is the screen's place in the global logical layout.
42+func (s *screen) rect() Rect {
33 o := s.mon.Output()43 o := s.mon.Output()
34- return ports.OutputPlacement{Info: s.info, X: s.x, Y: s.y, Width: o.W, Height: o.H, Scale: s.scale, Primary: s.primary}44+ return Rect{X: s.x, Y: s.y, W: o.W, H: o.H}
45+}
46+
47+func (s *screen) placement() ports.OutputPlacement {
48+ r := s.rect()
49+ return ports.OutputPlacement{Info: s.info, X: r.X, Y: r.Y, Width: r.W, Height: r.H, Scale: s.scale, Transform: s.transform, Primary: s.primary}
35 }50 }
36 51
37 // setScale resizes the logical output; layer placement follows.52 // setScale resizes the logical output; layer placement follows.
38 func (s *screen) setScale(v float64) {53 func (s *screen) setScale(v float64) {
39 s.scale = SnapScale(v)54 s.scale = SnapScale(v)
40- s.mon.SetOutput(logical(s.info.Width, s.scale), logical(s.info.Height, s.scale))55+ w, h := s.modeSize()
56+ s.mon.SetOutput(logical(w, s.scale), logical(h, s.scale))
41 s.arrange()57 s.arrange()
42 }58 }
43 59
@@ -63,8 +79,8 @@ func (c *Core) order() {
63 c.focusScreen = slices.Index(c.screens, focused)79 c.focusScreen = slices.Index(c.screens, focused)
64 items := make([]placeItem, len(c.screens))80 items := make([]placeItem, len(c.screens))
65 for i, s := range c.screens {81 for i, s := range c.screens {
66- o := s.mon.Output()82+ r := s.rect()
67- items[i].name, items[i].w, items[i].h = s.name(), o.W, o.H83+ items[i].name, items[i].w, items[i].h = s.name(), r.W, r.H
68 if cfg, ok := c.outputConfig(s.name()); ok {84 if cfg, ok := c.outputConfig(s.name()); ok {
69 items[i].pos, items[i].anchor = cfg.Pos, cfg.Anchor85 items[i].pos, items[i].anchor = cfg.Pos, cfg.Anchor
70 }86 }
@@ -107,6 +123,39 @@ func (c *Core) screenIndex(name string) int {
107 return slices.IndexFunc(c.screens, func(s *screen) bool { return s.name() == name })123 return slices.IndexFunc(c.screens, func(s *screen) bool { return s.name() == name })
108 }124 }
109 125
126+// screenAt is the index of the first connected screen containing the global
127+// point, or -1. It does not allocate.
128+func (c *Core) screenAt(x, y float64) int {
129+ for i, s := range c.screens {
130+ if s.name() == "" {
131+ continue
132+ }
133+ r := s.rect()
134+ if x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) {
135+ return i
136+ }
137+ }
138+ return -1
139+}
140+
141+// clampPointer keeps the pointer on a screen: a point outside every screen
142+// stays on the screen under (fromX, fromY), or the first one, clamped to its
143+// edges.
144+func (c *Core) clampPointer(fromX, fromY, x, y float64) (float64, float64) {
145+ i := c.screenAt(x, y)
146+ if i >= 0 {
147+ return x, y
148+ }
149+ if i = c.screenAt(fromX, fromY); i < 0 {
150+ i = slices.IndexFunc(c.screens, func(s *screen) bool { return s.name() != "" })
151+ if i < 0 {
152+ return x, y
153+ }
154+ }
155+ r := c.screens[i].rect()
156+ return min(max(x, float64(r.X)), float64(r.X+r.W)-1), min(max(y, float64(r.Y)), float64(r.Y+r.H)-1)
157+}
158+
110 // screenOf returns the screen and workspace holding the window.159 // screenOf returns the screen and workspace holding the window.
111 func (c *Core) screenOf(id WindowID) (*screen, *Workspace) {160 func (c *Core) screenOf(id WindowID) (*screen, *Workspace) {
112 for _, s := range c.screens {161 for _, s := range c.screens {
@@ -154,14 +203,27 @@ func (c *Core) configScale(name string) float64 {
154 return SnapScale(v)203 return SnapScale(v)
155 }204 }
156 205
157-// applyConfigScales picks up output.<name>.scale when it changed in the206+// configTransform is output.<name>.transform, else 0.
158-// config, replacing any live scale-up/scale-down adjustment.207+func (c *Core) configTransform(name string) ports.BufferTransform {
159-func (c *Core) applyConfigScales() {208+ o, _ := c.outputConfig(name)
209+ return o.Transform
210+}
211+
212+// applyOutputConfig picks up output.<name>.scale and .transform when they
213+// changed in the config. A new scale replaces any live scale-up/scale-down
214+// adjustment; a transform alone keeps it.
215+func (c *Core) applyOutputConfig() {
160 for _, s := range c.screens {216 for _, s := range c.screens {
161- if v := c.configScale(s.name()); v != s.cfgScale {217+ v, t := c.configScale(s.name()), c.configTransform(s.name())
162- s.cfgScale = v218+ if v == s.cfgScale && t == s.transform {
163- s.setScale(v)219+ continue
220+ }
221+ scale := s.scale
222+ if v != s.cfgScale {
223+ scale = v
164 }224 }
225+ s.cfgScale, s.transform = v, t
226+ s.setScale(scale)
165 }227 }
166 c.order()228 c.order()
167 }229 }
@@ -178,6 +240,7 @@ func (c *Core) addScreen(info ports.OutputInfo) {
178 s.mon.Key = info.Key()240 s.mon.Key = info.Key()
179 c.settings(s.mon)241 c.settings(s.mon)
180 }242 }
243+ s.transform = c.configTransform(info.Name)
181 s.setScale(s.scale)244 s.setScale(s.scale)
182 c.order()245 c.order()
183 return246 return
@@ -195,6 +258,7 @@ func (c *Core) addScreen(info ports.OutputInfo) {
195 s.mon.Name, s.mon.Key = info.Name, info.Key()258 s.mon.Name, s.mon.Key = info.Name, info.Key()
196 c.settings(s.mon)259 c.settings(s.mon)
197 s.cfgScale = c.configScale(info.Name)260 s.cfgScale = c.configScale(info.Name)
261+ s.transform = c.configTransform(info.Name)
198 s.setScale(s.cfgScale)262 s.setScale(s.cfgScale)
199 s.primary = c.isPrimary(info.Name)263 s.primary = c.isPrimary(info.Name)
200 c.order()264 c.order()
@@ -429,11 +493,11 @@ func (c *Core) neighbor(d direction) int {
429 }493 }
430 // span is the extent of s along the move (lo, hi) and across it.494 // span is the extent of s along the move (lo, hi) and across it.
431 span := func(s *screen) (lo, hi, clo, chi int) {495 span := func(s *screen) (lo, hi, clo, chi int) {
432- o := s.mon.Output()496+ r := s.rect()
433 if d == dirUp || d == dirDown {497 if d == dirUp || d == dirDown {
434- return s.y, s.y + o.H, s.x, s.x + o.W498+ return r.Y, r.Y + r.H, r.X, r.X + r.W
435 }499 }
436- return s.x, s.x + o.W, s.y, s.y + o.H500+ return r.X, r.X + r.W, r.Y, r.Y + r.H
437 }501 }
438 lo, hi, clo, chi := span(c.cur())502 lo, hi, clo, chi := span(c.cur())
439 pick := func(fallback bool) int {503 pick := func(fallback bool) int {
M internal/core/screens_test.go +134 -0
@@ -1409,3 +1409,137 @@ func TestPoweredOffScreenStaysNeighbor(t *testing.T) {
14091409 t.Fatalf("focus-monitor-right: %s, want the powered-off DP-2", got)
14101410 }
14111411 }
1412+
1413+func TestRotatedOutputLayout(t *testing.T) {
1414+ r := startMulti(t, func(c *ports.Config) {
1415+ c.Outputs = []ports.OutputConfig{{Name: left.Name, Transform: 1}}
1416+ }, left, right)
1417+ out := lastOutputs(t, r.commands)
1418+ if len(out.Outputs) != 2 {
1419+ t.Fatalf("%+v", out.Outputs)
1420+ }
1421+ if o := out.Outputs[0]; o.Info.Name != "DP-1" || o.Width != 100 || o.Height != 200 || o.Transform != 1 {
1422+ t.Fatalf("DP-1: %+v", o)
1423+ }
1424+ if o := out.Outputs[1]; o.Info.Name != "DP-2" || o.X != 100 || o.Width != 400 || o.Height != 200 || o.Transform != 0 {
1425+ t.Fatalf("DP-2: %+v", o)
1426+ }
1427+ r.mapWindow(t, 1)
1428+ set := r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1429+ if len(set) != 2 || set[0].Output != "DP-1" || set[1].Output != "DP-2" {
1430+ t.Fatalf("scenes: %+v", set)
1431+ }
1432+ for _, s := range set {
1433+ switch s.Output {
1434+ case "DP-1":
1435+ if s.OutputWidth != 100 || s.OutputHeight != 200 || s.Transform != 1 {
1436+ t.Fatalf("DP-1 scene: %dx%d t=%d", s.OutputWidth, s.OutputHeight, s.Transform)
1437+ }
1438+ case "DP-2":
1439+ if s.OutputWidth != 400 || s.OutputHeight != 200 || s.Transform != 0 {
1440+ t.Fatalf("DP-2 scene: %dx%d t=%d", s.OutputWidth, s.OutputHeight, s.Transform)
1441+ }
1442+ }
1443+ }
1444+}
1445+
1446+func TestRotatedOutputNeighbor(t *testing.T) {
1447+ r := startMulti(t, func(c *ports.Config) {
1448+ c.Outputs = []ports.OutputConfig{
1449+ {Name: "DP-1", Transform: 1},
1450+ {Name: "DP-2", Anchor: ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-1"}},
1451+ }
1452+ }, left, right)
1453+ out := lastOutputs(t, r.commands)
1454+ if o := out.Outputs[1]; o.Info.Name != "DP-2" || o.Y != 200 {
1455+ t.Fatalf("DP-2: %+v", o)
1456+ }
1457+ r.mapWindow(t, 1)
1458+ r.key(t, "Down", ports.ModAlt|ports.ModCtrl)
1459+ if got := lastOutputs(t, r.commands).Focused; got != "DP-2" {
1460+ t.Fatalf("focus-monitor-down: %s", got)
1461+ }
1462+}
1463+
1464+func TestTransformReload(t *testing.T) {
1465+ r := startMulti(t, func(c *ports.Config) {
1466+ c.Binds["Alt+equal"] = "scale-up"
1467+ c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2"}}
1468+ }, left, right)
1469+ // Scale DP-2 (focus follows the key to it) with the bind first.
1470+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1471+ set := r.key(t, "equal", ports.ModAlt)
1472+ scale := set[1].Scale
1473+ if scale == 1 {
1474+ t.Fatal("scale-up did not change the scale")
1475+ }
1476+ if out := lastOutputs(t, r.commands); out.Outputs[0].Width != 200 || out.Outputs[1].X != 200 {
1477+ t.Fatalf("%+v", out.Outputs)
1478+ }
1479+
1480+ r.cfg.Outputs[0].Transform = 1
1481+ r.reload <- ports.ConfigChanged{Config: r.cfg}
1482+ set = receive(t, r.scenes)
1483+ out := lastOutputs(t, r.commands)
1484+ if o := out.Outputs[0]; o.Width != 100 || o.Height != 200 || o.Transform != 1 {
1485+ t.Fatalf("DP-1: %+v", o)
1486+ }
1487+ if o := out.Outputs[1]; o.X != 100 {
1488+ t.Fatalf("DP-2 not re-placed: %+v", o)
1489+ }
1490+ if set[1].Scale != scale || out.Outputs[1].Scale != scale {
1491+ t.Fatalf("scale %v changed to %v by a transform reload", scale, set[1].Scale)
1492+ }
1493+
1494+ // A transform change on the scaled output itself keeps its live scale.
1495+ r.cfg.Outputs[1].Transform = 1
1496+ r.reload <- ports.ConfigChanged{Config: r.cfg}
1497+ set = receive(t, r.scenes)
1498+ if set[1].Scale != scale || set[1].Transform != 1 {
1499+ t.Fatalf("scale %v transform %d", set[1].Scale, set[1].Transform)
1500+ }
1501+ // Back to normal.
1502+ r.cfg.Outputs[0].Transform = 0
1503+ r.reload <- ports.ConfigChanged{Config: r.cfg}
1504+ _ = receive(t, r.scenes)
1505+ if o := lastOutputs(t, r.commands).Outputs[0]; o.Width != 200 || o.Transform != 0 {
1506+ t.Fatalf("DP-1: %+v", o)
1507+ }
1508+}
1509+
1510+// A reload that shrinks the layout under a still pointer brings it back onto
1511+// an output.
1512+func TestTransformReloadClampsPointer(t *testing.T) {
1513+ r := startMulti(t, func(c *ports.Config) {
1514+ c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2"}}
1515+ }, left, right)
1516+ r.mapWindow(t, 1)
1517+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1518+ r.mapWindow(t, 2)
1519+ // DP-2 spans x 200..600: (550, 50) is (350, 50) on window 2.
1520+ for len(r.commands) > 0 {
1521+ <-r.commands
1522+ }
1523+ r.input <- ports.PointerMotion{X: 550, Y: 50}
1524+ if v := command(t, r.commands); v != (ports.PointerFocus{ID: 2, X: 350, Y: 50}) {
1525+ t.Fatal(v)
1526+ }
1527+ // Rotating DP-1 moves DP-2 to x 100..500: 550 is off every output.
1528+ r.cfg.Outputs[0].Transform = 1
1529+ r.reload <- ports.ConfigChanged{Config: r.cfg}
1530+ receive(t, r.scenes)
1531+ for len(r.commands) > 0 {
1532+ <-r.commands
1533+ }
1534+ // A rehit (here from a swipe end) points at what lies under the pointer:
1535+ // clamped onto DP-1's right edge, window 1, not nothing.
1536+ r.input <- ports.SwipeEnd{}
1537+ for {
1538+ if v, ok := command(t, r.commands).(ports.PointerFocus); ok {
1539+ if v.ID != 1 {
1540+ t.Fatalf("pointer after reload: %+v", v)
1541+ }
1542+ break
1543+ }
1544+ }
1545+}
@@ -1409,3 +1409,137 @@ func TestPoweredOffScreenStaysNeighbor(t *testing.T) {
1409 t.Fatalf("focus-monitor-right: %s, want the powered-off DP-2", got)1409 t.Fatalf("focus-monitor-right: %s, want the powered-off DP-2", got)
1410 }1410 }
1411 }1411 }
1412+
1413+func TestRotatedOutputLayout(t *testing.T) {
1414+ r := startMulti(t, func(c *ports.Config) {
1415+ c.Outputs = []ports.OutputConfig{{Name: left.Name, Transform: 1}}
1416+ }, left, right)
1417+ out := lastOutputs(t, r.commands)
1418+ if len(out.Outputs) != 2 {
1419+ t.Fatalf("%+v", out.Outputs)
1420+ }
1421+ if o := out.Outputs[0]; o.Info.Name != "DP-1" || o.Width != 100 || o.Height != 200 || o.Transform != 1 {
1422+ t.Fatalf("DP-1: %+v", o)
1423+ }
1424+ if o := out.Outputs[1]; o.Info.Name != "DP-2" || o.X != 100 || o.Width != 400 || o.Height != 200 || o.Transform != 0 {
1425+ t.Fatalf("DP-2: %+v", o)
1426+ }
1427+ r.mapWindow(t, 1)
1428+ set := r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1429+ if len(set) != 2 || set[0].Output != "DP-1" || set[1].Output != "DP-2" {
1430+ t.Fatalf("scenes: %+v", set)
1431+ }
1432+ for _, s := range set {
1433+ switch s.Output {
1434+ case "DP-1":
1435+ if s.OutputWidth != 100 || s.OutputHeight != 200 || s.Transform != 1 {
1436+ t.Fatalf("DP-1 scene: %dx%d t=%d", s.OutputWidth, s.OutputHeight, s.Transform)
1437+ }
1438+ case "DP-2":
1439+ if s.OutputWidth != 400 || s.OutputHeight != 200 || s.Transform != 0 {
1440+ t.Fatalf("DP-2 scene: %dx%d t=%d", s.OutputWidth, s.OutputHeight, s.Transform)
1441+ }
1442+ }
1443+ }
1444+}
1445+
1446+func TestRotatedOutputNeighbor(t *testing.T) {
1447+ r := startMulti(t, func(c *ports.Config) {
1448+ c.Outputs = []ports.OutputConfig{
1449+ {Name: "DP-1", Transform: 1},
1450+ {Name: "DP-2", Anchor: ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-1"}},
1451+ }
1452+ }, left, right)
1453+ out := lastOutputs(t, r.commands)
1454+ if o := out.Outputs[1]; o.Info.Name != "DP-2" || o.Y != 200 {
1455+ t.Fatalf("DP-2: %+v", o)
1456+ }
1457+ r.mapWindow(t, 1)
1458+ r.key(t, "Down", ports.ModAlt|ports.ModCtrl)
1459+ if got := lastOutputs(t, r.commands).Focused; got != "DP-2" {
1460+ t.Fatalf("focus-monitor-down: %s", got)
1461+ }
1462+}
1463+
1464+func TestTransformReload(t *testing.T) {
1465+ r := startMulti(t, func(c *ports.Config) {
1466+ c.Binds["Alt+equal"] = "scale-up"
1467+ c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2"}}
1468+ }, left, right)
1469+ // Scale DP-2 (focus follows the key to it) with the bind first.
1470+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1471+ set := r.key(t, "equal", ports.ModAlt)
1472+ scale := set[1].Scale
1473+ if scale == 1 {
1474+ t.Fatal("scale-up did not change the scale")
1475+ }
1476+ if out := lastOutputs(t, r.commands); out.Outputs[0].Width != 200 || out.Outputs[1].X != 200 {
1477+ t.Fatalf("%+v", out.Outputs)
1478+ }
1479+
1480+ r.cfg.Outputs[0].Transform = 1
1481+ r.reload <- ports.ConfigChanged{Config: r.cfg}
1482+ set = receive(t, r.scenes)
1483+ out := lastOutputs(t, r.commands)
1484+ if o := out.Outputs[0]; o.Width != 100 || o.Height != 200 || o.Transform != 1 {
1485+ t.Fatalf("DP-1: %+v", o)
1486+ }
1487+ if o := out.Outputs[1]; o.X != 100 {
1488+ t.Fatalf("DP-2 not re-placed: %+v", o)
1489+ }
1490+ if set[1].Scale != scale || out.Outputs[1].Scale != scale {
1491+ t.Fatalf("scale %v changed to %v by a transform reload", scale, set[1].Scale)
1492+ }
1493+
1494+ // A transform change on the scaled output itself keeps its live scale.
1495+ r.cfg.Outputs[1].Transform = 1
1496+ r.reload <- ports.ConfigChanged{Config: r.cfg}
1497+ set = receive(t, r.scenes)
1498+ if set[1].Scale != scale || set[1].Transform != 1 {
1499+ t.Fatalf("scale %v transform %d", set[1].Scale, set[1].Transform)
1500+ }
1501+ // Back to normal.
1502+ r.cfg.Outputs[0].Transform = 0
1503+ r.reload <- ports.ConfigChanged{Config: r.cfg}
1504+ _ = receive(t, r.scenes)
1505+ if o := lastOutputs(t, r.commands).Outputs[0]; o.Width != 200 || o.Transform != 0 {
1506+ t.Fatalf("DP-1: %+v", o)
1507+ }
1508+}
1509+
1510+// A reload that shrinks the layout under a still pointer brings it back onto
1511+// an output.
1512+func TestTransformReloadClampsPointer(t *testing.T) {
1513+ r := startMulti(t, func(c *ports.Config) {
1514+ c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2"}}
1515+ }, left, right)
1516+ r.mapWindow(t, 1)
1517+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1518+ r.mapWindow(t, 2)
1519+ // DP-2 spans x 200..600: (550, 50) is (350, 50) on window 2.
1520+ for len(r.commands) > 0 {
1521+ <-r.commands
1522+ }
1523+ r.input <- ports.PointerMotion{X: 550, Y: 50}
1524+ if v := command(t, r.commands); v != (ports.PointerFocus{ID: 2, X: 350, Y: 50}) {
1525+ t.Fatal(v)
1526+ }
1527+ // Rotating DP-1 moves DP-2 to x 100..500: 550 is off every output.
1528+ r.cfg.Outputs[0].Transform = 1
1529+ r.reload <- ports.ConfigChanged{Config: r.cfg}
1530+ receive(t, r.scenes)
1531+ for len(r.commands) > 0 {
1532+ <-r.commands
1533+ }
1534+ // A rehit (here from a swipe end) points at what lies under the pointer:
1535+ // clamped onto DP-1's right edge, window 1, not nothing.
1536+ r.input <- ports.SwipeEnd{}
1537+ for {
1538+ if v, ok := command(t, r.commands).(ports.PointerFocus); ok {
1539+ if v.ID != 1 {
1540+ t.Fatalf("pointer after reload: %+v", v)
1541+ }
1542+ break
1543+ }
1544+ }
1545+}
M internal/core/security.go +6 -9
@@ -166,16 +166,13 @@ func (c *Core) lockKeyboardFocus() WindowID {
166166 }
167167
168168 func (c *Core) lockHit(x, y float64) (WindowID, float64, float64) {
169- o, ok := c.layout().At(x, y)
170- if !ok {
171- return 0, 0, 0
172- }
173- i := c.screenIndex(o.Info.Name)
169+ i := c.screenAt(x, y)
174170 if i < 0 {
175171 return 0, 0, 0
176172 }
177- if s, ok := c.lockSurface(c.screens[i]); ok {
178- return s.ID, x - float64(o.X), y - float64(o.Y)
173+ sc := c.screens[i]
174+ if s, ok := c.lockSurface(sc); ok {
175+ return s.ID, x - float64(sc.x), y - float64(sc.y)
179176 }
180177 return 0, 0, 0
181178 }
@@ -189,7 +186,7 @@ func (c *Core) publishProtected(ctx context.Context) error {
189186 for _, sc := range c.screens {
190187 c.seq++
191188 o := sc.mon.Output()
192- scene := ports.Scene{Security: c.security, Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Off: sc.off, Background: "#000000"}
189+ scene := ports.Scene{Security: c.security, Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: "#000000"}
193190 if s, ok := c.lockSurface(sc); ok {
194191 scene.Windows = []ports.SceneWindow{{ID: s.ID, Rect: Rect{W: o.W, H: o.H}, Fullscreen: true, Focused: s.ID == focus}}
195192 }
@@ -253,7 +250,7 @@ func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error {
253250 }
254251 case ports.PointerMotion:
255252 c.motionMsec = v.TimeMsec
256- c.cursorX, c.cursorY = c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y)
253+ c.cursorX, c.cursorY = c.clampPointer(c.cursorX, c.cursorY, v.X, v.Y)
257254 id, x, y := c.lockHit(c.cursorX, c.cursorY)
258255 if id != c.pointer {
259256 c.pointer = id
@@ -166,16 +166,13 @@ func (c *Core) lockKeyboardFocus() WindowID {
166 }166 }
167 167
168 func (c *Core) lockHit(x, y float64) (WindowID, float64, float64) {168 func (c *Core) lockHit(x, y float64) (WindowID, float64, float64) {
169- o, ok := c.layout().At(x, y)169+ i := c.screenAt(x, y)
170- if !ok {
171- return 0, 0, 0
172- }
173- i := c.screenIndex(o.Info.Name)
174 if i < 0 {170 if i < 0 {
175 return 0, 0, 0171 return 0, 0, 0
176 }172 }
177- if s, ok := c.lockSurface(c.screens[i]); ok {173+ sc := c.screens[i]
178- return s.ID, x - float64(o.X), y - float64(o.Y)174+ if s, ok := c.lockSurface(sc); ok {
175+ return s.ID, x - float64(sc.x), y - float64(sc.y)
179 }176 }
180 return 0, 0, 0177 return 0, 0, 0
181 }178 }
@@ -189,7 +186,7 @@ func (c *Core) publishProtected(ctx context.Context) error {
189 for _, sc := range c.screens {186 for _, sc := range c.screens {
190 c.seq++187 c.seq++
191 o := sc.mon.Output()188 o := sc.mon.Output()
192- scene := ports.Scene{Security: c.security, Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Off: sc.off, Background: "#000000"}189+ scene := ports.Scene{Security: c.security, Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: "#000000"}
193 if s, ok := c.lockSurface(sc); ok {190 if s, ok := c.lockSurface(sc); ok {
194 scene.Windows = []ports.SceneWindow{{ID: s.ID, Rect: Rect{W: o.W, H: o.H}, Fullscreen: true, Focused: s.ID == focus}}191 scene.Windows = []ports.SceneWindow{{ID: s.ID, Rect: Rect{W: o.W, H: o.H}, Fullscreen: true, Focused: s.ID == focus}}
195 }192 }
@@ -253,7 +250,7 @@ func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error {
253 }250 }
254 case ports.PointerMotion:251 case ports.PointerMotion:
255 c.motionMsec = v.TimeMsec252 c.motionMsec = v.TimeMsec
256- c.cursorX, c.cursorY = c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y)253+ c.cursorX, c.cursorY = c.clampPointer(c.cursorX, c.cursorY, v.X, v.Y)
257 id, x, y := c.lockHit(c.cursorX, c.cursorY)254 id, x, y := c.lockHit(c.cursorX, c.cursorY)
258 if id != c.pointer {255 if id != c.pointer {
259 c.pointer = id256 c.pointer = id
M internal/ports/client_commands.go +16 -1
@@ -48,11 +48,15 @@ type ConfigureWindow struct {
4848 func (ConfigureWindow) clientCommand() {}
4949
5050 // OutputPlacement is one output in the global layout. X, Y, Width and
51-// Height are logical; physical = logical × Scale.
51+// Height are logical (after Transform: they swap for 90/270);
52+// physical = logical × Scale. Info.Width and Info.Height are the physical
53+// mode, the untransformed target.
5254 type OutputPlacement struct {
5355 Info OutputInfo
5456 X, Y, Width, Height int
5557 Scale float64
58+ // Transform is how the target holds the scene (wl_output.transform).
59+ Transform BufferTransform
5660 // Primary is output.<name>.primary: the pointer starts on it.
5761 Primary bool
5862 }
@@ -62,6 +66,17 @@ func (o OutputPlacement) Contains(x, y float64) bool {
6266 return x >= float64(o.X) && x < float64(o.X+o.Width) && y >= float64(o.Y) && y < float64(o.Y+o.Height)
6367 }
6468
69+// ToTarget maps a global logical point on the output to target pixels.
70+func (o OutputPlacement) ToTarget(x, y float64) (float64, float64) {
71+ s := o.Scale
72+ if s <= 0 {
73+ s = 1
74+ }
75+ px, py := (x-float64(o.X))*s, (y-float64(o.Y))*s
76+ sw, sh := o.Transform.Size(o.Info.Width, o.Info.Height)
77+ return o.Transform.ToBuffer(px, py, float64(sw), float64(sh))
78+}
79+
6580 // Layout is the global arrangement of outputs in logical pixels.
6681 type Layout []OutputPlacement
6782
@@ -48,11 +48,15 @@ type ConfigureWindow struct {
48 func (ConfigureWindow) clientCommand() {}48 func (ConfigureWindow) clientCommand() {}
49 49
50 // OutputPlacement is one output in the global layout. X, Y, Width and50 // OutputPlacement is one output in the global layout. X, Y, Width and
51-// Height are logical; physical = logical × Scale.51+// Height are logical (after Transform: they swap for 90/270);
52+// physical = logical × Scale. Info.Width and Info.Height are the physical
53+// mode, the untransformed target.
52 type OutputPlacement struct {54 type OutputPlacement struct {
53 Info OutputInfo55 Info OutputInfo
54 X, Y, Width, Height int56 X, Y, Width, Height int
55 Scale float6457 Scale float64
58+ // Transform is how the target holds the scene (wl_output.transform).
59+ Transform BufferTransform
56 // Primary is output.<name>.primary: the pointer starts on it.60 // Primary is output.<name>.primary: the pointer starts on it.
57 Primary bool61 Primary bool
58 }62 }
@@ -62,6 +66,17 @@ func (o OutputPlacement) Contains(x, y float64) bool {
62 return x >= float64(o.X) && x < float64(o.X+o.Width) && y >= float64(o.Y) && y < float64(o.Y+o.Height)66 return x >= float64(o.X) && x < float64(o.X+o.Width) && y >= float64(o.Y) && y < float64(o.Y+o.Height)
63 }67 }
64 68
69+// ToTarget maps a global logical point on the output to target pixels.
70+func (o OutputPlacement) ToTarget(x, y float64) (float64, float64) {
71+ s := o.Scale
72+ if s <= 0 {
73+ s = 1
74+ }
75+ px, py := (x-float64(o.X))*s, (y-float64(o.Y))*s
76+ sw, sh := o.Transform.Size(o.Info.Width, o.Info.Height)
77+ return o.Transform.ToBuffer(px, py, float64(sw), float64(sh))
78+}
79+
65 // Layout is the global arrangement of outputs in logical pixels.80 // Layout is the global arrangement of outputs in logical pixels.
66 type Layout []OutputPlacement81 type Layout []OutputPlacement
67 82
M internal/ports/config.go +4 -1
@@ -17,7 +17,8 @@ const DefaultSDRBrightness = 203
1717 // everything placed so far (never left of 0), at y=0, in config order, then in
1818 // connection order.
1919 // Scale is the output scale (0 means 1); layout works in logical pixels,
20-// physical = logical × Scale.
20+// physical = logical × Scale. Placement and offsets use the transformed
21+// logical size.
2122 // Primary gets the focus and the pointer at startup, wherever it is placed.
2223 // ScaleOnly marks an entry set only by output.<name> subkeys: it
2324 // does not select the connector.
@@ -26,6 +27,8 @@ type OutputConfig struct {
2627 Mode string
2728 Off bool
2829 Scale float64
30+ // Transform is output.<name>.transform; logical size swaps for 90/270.
31+ Transform BufferTransform
2932 // Pos is an explicit logical placement set at runtime; nil uses Anchor, else automatic layout.
3033 Pos *image.Point
3134 // Anchor places the output against another connected one.
@@ -17,7 +17,8 @@ const DefaultSDRBrightness = 203
17 // everything placed so far (never left of 0), at y=0, in config order, then in17 // everything placed so far (never left of 0), at y=0, in config order, then in
18 // connection order.18 // connection order.
19 // Scale is the output scale (0 means 1); layout works in logical pixels,19 // Scale is the output scale (0 means 1); layout works in logical pixels,
20-// physical = logical × Scale.20+// physical = logical × Scale. Placement and offsets use the transformed
21+// logical size.
21 // Primary gets the focus and the pointer at startup, wherever it is placed.22 // Primary gets the focus and the pointer at startup, wherever it is placed.
22 // ScaleOnly marks an entry set only by output.<name> subkeys: it23 // ScaleOnly marks an entry set only by output.<name> subkeys: it
23 // does not select the connector.24 // does not select the connector.
@@ -26,6 +27,8 @@ type OutputConfig struct {
26 Mode string27 Mode string
27 Off bool28 Off bool
28 Scale float6429 Scale float64
30+ // Transform is output.<name>.transform; logical size swaps for 90/270.
31+ Transform BufferTransform
29 // Pos is an explicit logical placement set at runtime; nil uses Anchor, else automatic layout.32 // Pos is an explicit logical placement set at runtime; nil uses Anchor, else automatic layout.
30 Pos *image.Point33 Pos *image.Point
31 // Anchor places the output against another connected one.34 // Anchor places the output against another connected one.
M internal/ports/output_events.go +2 -0
@@ -53,6 +53,8 @@ type HeadChange struct {
5353 Mode *OutputMode
5454 Pos *image.Point
5555 Scale float64
56+ // Transform is nil to leave the transform unchanged.
57+ Transform *BufferTransform
5658 }
5759
5860 // OutputApply asks the app owner to validate or apply a runtime configuration.
@@ -53,6 +53,8 @@ type HeadChange struct {
53 Mode *OutputMode53 Mode *OutputMode
54 Pos *image.Point54 Pos *image.Point
55 Scale float6455 Scale float64
56+ // Transform is nil to leave the transform unchanged.
57+ Transform *BufferTransform
56 }58 }
57 59
58 // OutputApply asks the app owner to validate or apply a runtime configuration.60 // OutputApply asks the app owner to validate or apply a runtime configuration.
M internal/ports/scene.go +35 -1
@@ -1,6 +1,10 @@
11 package ports
22
3-import "os"
3+import (
4+ "image"
5+ "math"
6+ "os"
7+)
48
59 // Scene carries core → renderer immutable snapshots with fresh Windows slices,
610 // one per output. Rects and the output size are logical and local to the
@@ -14,6 +18,10 @@ type Scene struct {
1418 Seq uint64
1519 OutputWidth, OutputHeight int
1620 Scale float64
21+ // Transform is how the output target holds this scene
22+ // (wl_output.transform); 0 for capture child scenes. OutputWidth and
23+ // OutputHeight are already in the transformed (logical) orientation.
24+ Transform BufferTransform
1725 // Off turns the display off (output power management): nothing is
1826 // drawn until a scene without it.
1927 Off bool
@@ -202,6 +210,15 @@ type BufferTransform uint8
202210 // Rotated reports whether width and height swap between buffer and surface.
203211 func (t BufferTransform) Rotated() bool { return t&1 != 0 }
204212
213+// Size returns the size of a w×h space seen through t: width and height swap
214+// when t is Rotated.
215+func (t BufferTransform) Size(w, h int) (int, int) {
216+ if t.Rotated() {
217+ return h, w
218+ }
219+ return w, h
220+}
221+
205222 // ToBuffer maps a point of a w×h surface (logical, before scale) to the
206223 // same point of its buffer, in the buffer's untransformed w'×h' axes, where
207224 // w' and h' swap when the transform is Rotated.
@@ -220,6 +237,23 @@ func (t BufferTransform) ToBuffer(x, y, w, h float64) (float64, float64) {
220237 return x, y
221238 }
222239
240+// Invert is the transform that maps target points back: for a w×h scene and
241+// its w'×h' target, t.Invert().ToBuffer(t.ToBuffer(p, w, h), w', h') == p.
242+func (t BufferTransform) Invert() BufferTransform {
243+ if t&1 != 0 && t&4 == 0 {
244+ return t ^ 2
245+ }
246+ return t
247+}
248+
249+// RectToBuffer maps a rectangle of a w×h surface to the buffer. Integer
250+// rectangles stay integer under these transforms; rounding absorbs float noise.
251+func (t BufferTransform) RectToBuffer(r image.Rectangle, w, h int) image.Rectangle {
252+ ax, ay := t.ToBuffer(float64(r.Min.X), float64(r.Min.Y), float64(w), float64(h))
253+ bx, by := t.ToBuffer(float64(r.Max.X), float64(r.Max.Y), float64(w), float64(h))
254+ return image.Rect(int(math.Round(ax)), int(math.Round(ay)), int(math.Round(bx)), int(math.Round(by)))
255+}
256+
223257 // SeqDamage is what content Seq changed from the one before: Rects in
224258 // root buffer pixels, or everything when Full.
225259 type SeqDamage struct {
@@ -1,6 +1,10 @@
1 package ports1 package ports
2 2
3-import "os"3+import (
4+ "image"
5+ "math"
6+ "os"
7+)
4 8
5 // Scene carries core → renderer immutable snapshots with fresh Windows slices,9 // Scene carries core → renderer immutable snapshots with fresh Windows slices,
6 // one per output. Rects and the output size are logical and local to the10 // one per output. Rects and the output size are logical and local to the
@@ -14,6 +18,10 @@ type Scene struct {
14 Seq uint6418 Seq uint64
15 OutputWidth, OutputHeight int19 OutputWidth, OutputHeight int
16 Scale float6420 Scale float64
21+ // Transform is how the output target holds this scene
22+ // (wl_output.transform); 0 for capture child scenes. OutputWidth and
23+ // OutputHeight are already in the transformed (logical) orientation.
24+ Transform BufferTransform
17 // Off turns the display off (output power management): nothing is25 // Off turns the display off (output power management): nothing is
18 // drawn until a scene without it.26 // drawn until a scene without it.
19 Off bool27 Off bool
@@ -202,6 +210,15 @@ type BufferTransform uint8
202 // Rotated reports whether width and height swap between buffer and surface.210 // Rotated reports whether width and height swap between buffer and surface.
203 func (t BufferTransform) Rotated() bool { return t&1 != 0 }211 func (t BufferTransform) Rotated() bool { return t&1 != 0 }
204 212
213+// Size returns the size of a w×h space seen through t: width and height swap
214+// when t is Rotated.
215+func (t BufferTransform) Size(w, h int) (int, int) {
216+ if t.Rotated() {
217+ return h, w
218+ }
219+ return w, h
220+}
221+
205 // ToBuffer maps a point of a w×h surface (logical, before scale) to the222 // ToBuffer maps a point of a w×h surface (logical, before scale) to the
206 // same point of its buffer, in the buffer's untransformed w'×h' axes, where223 // same point of its buffer, in the buffer's untransformed w'×h' axes, where
207 // w' and h' swap when the transform is Rotated.224 // w' and h' swap when the transform is Rotated.
@@ -220,6 +237,23 @@ func (t BufferTransform) ToBuffer(x, y, w, h float64) (float64, float64) {
220 return x, y237 return x, y
221 }238 }
222 239
240+// Invert is the transform that maps target points back: for a w×h scene and
241+// its w'×h' target, t.Invert().ToBuffer(t.ToBuffer(p, w, h), w', h') == p.
242+func (t BufferTransform) Invert() BufferTransform {
243+ if t&1 != 0 && t&4 == 0 {
244+ return t ^ 2
245+ }
246+ return t
247+}
248+
249+// RectToBuffer maps a rectangle of a w×h surface to the buffer. Integer
250+// rectangles stay integer under these transforms; rounding absorbs float noise.
251+func (t BufferTransform) RectToBuffer(r image.Rectangle, w, h int) image.Rectangle {
252+ ax, ay := t.ToBuffer(float64(r.Min.X), float64(r.Min.Y), float64(w), float64(h))
253+ bx, by := t.ToBuffer(float64(r.Max.X), float64(r.Max.Y), float64(w), float64(h))
254+ return image.Rect(int(math.Round(ax)), int(math.Round(ay)), int(math.Round(bx)), int(math.Round(by)))
255+}
256+
223 // SeqDamage is what content Seq changed from the one before: Rects in257 // SeqDamage is what content Seq changed from the one before: Rects in
224 // root buffer pixels, or everything when Full.258 // root buffer pixels, or everything when Full.
225 type SeqDamage struct {259 type SeqDamage struct {
M internal/ports/transform_test.go +63 -1
@@ -1,6 +1,11 @@
11 package ports
22
3-import "testing"
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+)
49
510 // ToBuffer matches wl_output.transform: the buffer holds the surface rotated
611 // counter-clockwise by the transform (after a flip around the vertical axis
@@ -34,3 +39,60 @@ func TestBufferTransformToBuffer(t *testing.T) {
3439 }
3540 }
3641 }
42+
43+func TestBufferTransformInvert(t *testing.T) {
44+ const w, h = 7.0, 3.0
45+ for tr := BufferTransform(0); tr < 8; tr++ {
46+ assert.Equal(t, tr, tr.Invert().Invert(), "transform %d", tr)
47+ bw, bh := w, h
48+ if tr.Rotated() {
49+ bw, bh = h, w
50+ }
51+ for _, p := range [][2]float64{{0, 0}, {1.5, 1}, {6.25, 2.5}} {
52+ bx, by := tr.ToBuffer(p[0], p[1], w, h)
53+ x, y := tr.Invert().ToBuffer(bx, by, bw, bh)
54+ assert.InDelta(t, p[0], x, 1e-9, "transform %d point %v", tr, p)
55+ assert.InDelta(t, p[1], y, 1e-9, "transform %d point %v", tr, p)
56+ }
57+ }
58+}
59+
60+// RectToBuffer is the bounding box of the rect's corners under ToBuffer.
61+func TestRectToBuffer(t *testing.T) {
62+ const w, h = 7, 3
63+ r := image.Rect(1, 0, 3, 2)
64+ assert.Equal(t, image.Rect(0, 5, 1, 7), BufferTransform(1).RectToBuffer(image.Rect(0, 0, 2, 1), w, h))
65+ for tr := BufferTransform(0); tr < 8; tr++ {
66+ got := tr.RectToBuffer(r, w, h)
67+ x0, y0 := tr.ToBuffer(float64(r.Min.X), float64(r.Min.Y), w, h)
68+ x1, y1 := tr.ToBuffer(float64(r.Max.X), float64(r.Max.Y), w, h)
69+ x2, y2 := tr.ToBuffer(float64(r.Min.X), float64(r.Max.Y), w, h)
70+ x3, y3 := tr.ToBuffer(float64(r.Max.X), float64(r.Min.Y), w, h)
71+ want := image.Rect(int(min(x0, x1, x2, x3)), int(min(y0, y1, y2, y3)), int(max(x0, x1, x2, x3)), int(max(y0, y1, y2, y3)))
72+ assert.Equal(t, want, got, "transform %d", tr)
73+ assert.Equal(t, r.Dx()*r.Dy(), got.Dx()*got.Dy(), "transform %d area", tr)
74+ }
75+}
76+
77+func TestBufferTransformSize(t *testing.T) {
78+ for tr := BufferTransform(0); tr < 8; tr++ {
79+ w, h := tr.Size(7, 3)
80+ if tr.Rotated() {
81+ assert.Equal(t, [2]int{3, 7}, [2]int{w, h}, "transform %d", tr)
82+ } else {
83+ assert.Equal(t, [2]int{7, 3}, [2]int{w, h}, "transform %d", tr)
84+ }
85+ }
86+}
87+
88+func TestOutputPlacementToTarget(t *testing.T) {
89+ o := OutputPlacement{Info: OutputInfo{Width: 2560, Height: 1440}, Scale: 1, X: 100, Transform: 1}
90+ x, y := o.ToTarget(100, 0)
91+ wx, wy := BufferTransform(1).ToBuffer(0, 0, 1440, 2560)
92+ assert.Equal(t, [2]float64{wx, wy}, [2]float64{x, y})
93+ assert.Equal(t, [2]float64{0, 1440}, [2]float64{x, y})
94+
95+ o.Transform = 0
96+ x, y = o.ToTarget(150, 20)
97+ assert.Equal(t, [2]float64{50, 20}, [2]float64{x, y})
98+}
@@ -1,6 +1,11 @@
1 package ports1 package ports
2 2
3-import "testing"3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+)
4 9
5 // ToBuffer matches wl_output.transform: the buffer holds the surface rotated10 // ToBuffer matches wl_output.transform: the buffer holds the surface rotated
6 // counter-clockwise by the transform (after a flip around the vertical axis11 // counter-clockwise by the transform (after a flip around the vertical axis
@@ -34,3 +39,60 @@ func TestBufferTransformToBuffer(t *testing.T) {
34 }39 }
35 }40 }
36 }41 }
42+
43+func TestBufferTransformInvert(t *testing.T) {
44+ const w, h = 7.0, 3.0
45+ for tr := BufferTransform(0); tr < 8; tr++ {
46+ assert.Equal(t, tr, tr.Invert().Invert(), "transform %d", tr)
47+ bw, bh := w, h
48+ if tr.Rotated() {
49+ bw, bh = h, w
50+ }
51+ for _, p := range [][2]float64{{0, 0}, {1.5, 1}, {6.25, 2.5}} {
52+ bx, by := tr.ToBuffer(p[0], p[1], w, h)
53+ x, y := tr.Invert().ToBuffer(bx, by, bw, bh)
54+ assert.InDelta(t, p[0], x, 1e-9, "transform %d point %v", tr, p)
55+ assert.InDelta(t, p[1], y, 1e-9, "transform %d point %v", tr, p)
56+ }
57+ }
58+}
59+
60+// RectToBuffer is the bounding box of the rect's corners under ToBuffer.
61+func TestRectToBuffer(t *testing.T) {
62+ const w, h = 7, 3
63+ r := image.Rect(1, 0, 3, 2)
64+ assert.Equal(t, image.Rect(0, 5, 1, 7), BufferTransform(1).RectToBuffer(image.Rect(0, 0, 2, 1), w, h))
65+ for tr := BufferTransform(0); tr < 8; tr++ {
66+ got := tr.RectToBuffer(r, w, h)
67+ x0, y0 := tr.ToBuffer(float64(r.Min.X), float64(r.Min.Y), w, h)
68+ x1, y1 := tr.ToBuffer(float64(r.Max.X), float64(r.Max.Y), w, h)
69+ x2, y2 := tr.ToBuffer(float64(r.Min.X), float64(r.Max.Y), w, h)
70+ x3, y3 := tr.ToBuffer(float64(r.Max.X), float64(r.Min.Y), w, h)
71+ want := image.Rect(int(min(x0, x1, x2, x3)), int(min(y0, y1, y2, y3)), int(max(x0, x1, x2, x3)), int(max(y0, y1, y2, y3)))
72+ assert.Equal(t, want, got, "transform %d", tr)
73+ assert.Equal(t, r.Dx()*r.Dy(), got.Dx()*got.Dy(), "transform %d area", tr)
74+ }
75+}
76+
77+func TestBufferTransformSize(t *testing.T) {
78+ for tr := BufferTransform(0); tr < 8; tr++ {
79+ w, h := tr.Size(7, 3)
80+ if tr.Rotated() {
81+ assert.Equal(t, [2]int{3, 7}, [2]int{w, h}, "transform %d", tr)
82+ } else {
83+ assert.Equal(t, [2]int{7, 3}, [2]int{w, h}, "transform %d", tr)
84+ }
85+ }
86+}
87+
88+func TestOutputPlacementToTarget(t *testing.T) {
89+ o := OutputPlacement{Info: OutputInfo{Width: 2560, Height: 1440}, Scale: 1, X: 100, Transform: 1}
90+ x, y := o.ToTarget(100, 0)
91+ wx, wy := BufferTransform(1).ToBuffer(0, 0, 1440, 2560)
92+ assert.Equal(t, [2]float64{wx, wy}, [2]float64{x, y})
93+ assert.Equal(t, [2]float64{0, 1440}, [2]float64{x, y})
94+
95+ o.Transform = 0
96+ x, y = o.ToTarget(150, 20)
97+ assert.Equal(t, [2]float64{50, 20}, [2]float64{x, y})
98+}

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 26

Tags 10

↑↓ moveenter openesc close