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 Unverified
B5690EEEBB952194The 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`). | |||
| 55 | 55 | | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` | | |
| 56 | 56 | | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` | | |
| 57 | 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 | 59 | | `output.<name>.primary` | `off` | Gets focus and pointer at startup | | |
| 59 | 60 | | `output.<name>.right-of` / `.left-of` / `.above` / `.below` | automatic | Connector to place the output against, e.g. `DP-1` | | |
| 60 | 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 | 91 | Rules: | |
| 89 | 92 | ||
| 90 | 93 | - `offset` moves down for `right-of`/`left-of` and right for `above`/`below`; negative moves the other way. | |
| 91 | 94 | - Sizes are logical: the layout follows scale changes on its own. | |
| 92 | 95 | - An output whose reference is not connected is placed automatically until the reference returns. | |
| 93 | 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 | 98 | - `focus-monitor-*`, `move-workspace-to-monitor-*` and column moves past the edge go to the output on that side. | |
| 96 | 99 | ||
| 97 | 100 | 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) { | |||
| 457 | 457 | entry().Scale = s | |
| 458 | 458 | 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 | 471 | o := ports.OutputConfig{Name: name} | |
| 461 | 472 | if value == "off" { | |
| 462 | 473 | o.Off = true | |
| @@ -468,7 +479,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 468 | 479 | } | |
| 469 | 480 | override() | |
| 470 | 481 | 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 | |
| 472 | 483 | *e = o | |
| 473 | 484 | continue | |
| 474 | 485 | } | |
| @@ -1142,6 +1153,29 @@ func keysym(k string) (string, error) { | |||
| 1142 | 1153 | 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 | 1179 | // parseScale accepts a decimal (1.5) or a fraction (4/3) between 1 and 4. | |
| 1146 | 1180 | func parseScale(v string) (float64, error) { | |
| 1147 | 1181 | var s float64 | |
| @@ -457,6 +457,17 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||||
| 457 | entry().Scale = s | 457 | entry().Scale = s | ||
| 458 | continue | 458 | 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 = true | 473 | 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.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 |
| 472 | *e = o | 483 | *e = o | ||
| 473 | continue | 484 | continue | ||
| 474 | } | 485 | } | ||
| @@ -1142,6 +1153,29 @@ func keysym(k string) (string, error) { | |||||
| 1142 | return k, nil | 1153 | 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 float64 | 1181 | var s float64 | ||
M internal/adapters/config/config_test.go +32 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package config | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | "os" | |
| 5 | 6 | "path/filepath" | |
| 6 | 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 | 675 | func TestOutputRelationOverride(t *testing.T) { | |
| 644 | 676 | c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.below = DP-3\n") | |
| 645 | 677 | if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "overrides line 1") { | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package config | 1 | 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) { | |||
| 237 | 237 | o, _, _ := testOutput(t) | |
| 238 | 238 | o.cursor.off = true | |
| 239 | 239 | loaded := false | |
| 240 | - | o.setCursor(nil, func(ports.CursorChange, float64, int) (ports.CursorImage, error) { | |
| 240 | + | o.setCursor(nil, func(ports.CursorChange, float64, ports.BufferTransform, int) (ports.CursorImage, error) { | |
| 241 | 241 | loaded = true | |
| 242 | 242 | return ports.CursorImage{}, nil | |
| 243 | - | }, ports.CursorChange{}, 1) | |
| 243 | + | }, ports.CursorChange{}, 1, 0) | |
| 244 | 244 | if loaded || o.cursor.desired().on { | |
| 245 | 245 | t.Fatal("cursor used while off") | |
| 246 | 246 | } | |
| @@ -272,7 +272,7 @@ func TestRunReportsSeenAfterCursorCommit(t *testing.T) { | |||
| 272 | 272 | k.EXPECT().createBlob(mock.Anything).Return(99, nil) | |
| 273 | 273 | k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe() | |
| 274 | 274 | k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe() | |
| 275 | - | load := func(ports.CursorChange, float64, int) (ports.CursorImage, error) { | |
| 275 | + | load := func(ports.CursorChange, float64, ports.BufferTransform, int) (ports.CursorImage, error) { | |
| 276 | 276 | return ports.CursorImage{Pixels: []byte{1, 2, 3, 4}, W: 1, H: 1}, nil | |
| 277 | 277 | } | |
| 278 | 278 | last := func(kind uint64) (commitRec, bool) { | |
| @@ -237,10 +237,10 @@ func TestCursorOffSkipsLoads(t *testing.T) { | |||||
| 237 | o, _, _ := testOutput(t) | 237 | o, _, _ := testOutput(t) | ||
| 238 | o.cursor.off = true | 238 | o.cursor.off = true | ||
| 239 | loaded := false | 239 | loaded := false | ||
| 240 | - | o.setCursor(nil, func(ports.CursorChange, float64, int) (ports.CursorImage, error) { | 240 | + | o.setCursor(nil, func(ports.CursorChange, float64, ports.BufferTransform, int) (ports.CursorImage, error) { |
| 241 | loaded = true | 241 | loaded = true | ||
| 242 | return ports.CursorImage{}, nil | 242 | return ports.CursorImage{}, nil | ||
| 243 | - | }, ports.CursorChange{}, 1) | 243 | + | }, ports.CursorChange{}, 1, 0) |
| 244 | if loaded || o.cursor.desired().on { | 244 | if loaded || o.cursor.desired().on { | ||
| 245 | t.Fatal("cursor used while off") | 245 | t.Fatal("cursor used while off") | ||
| 246 | } | 246 | } | ||
| @@ -272,7 +272,7 @@ func TestRunReportsSeenAfterCursorCommit(t *testing.T) { | |||||
| 272 | k.EXPECT().createBlob(mock.Anything).Return(99, nil) | 272 | k.EXPECT().createBlob(mock.Anything).Return(99, nil) | ||
| 273 | k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe() | 273 | k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe() | ||
| 274 | k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe() | 274 | k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe() | ||
| 275 | - | load := func(ports.CursorChange, float64, int) (ports.CursorImage, error) { | 275 | + | load := func(ports.CursorChange, float64, ports.BufferTransform, int) (ports.CursorImage, error) { |
| 276 | return ports.CursorImage{Pixels: []byte{1, 2, 3, 4}, W: 1, H: 1}, nil | 276 | return ports.CursorImage{Pixels: []byte{1, 2, 3, 4}, W: 1, H: 1}, nil | ||
| 277 | } | 277 | } | ||
| 278 | last := func(kind uint64) (commitRec, bool) { | 278 | last := func(kind uint64) (commitRec, bool) { | ||
M internal/adapters/drm/output.go +16 -13
| @@ -156,9 +156,10 @@ type Output struct { | |||
| 156 | 156 | // Ready reports the result of the first modeset exactly once. | |
| 157 | 157 | func (o *Output) Ready() <-chan error { return o.ready } | |
| 158 | 158 | ||
| 159 | - | // CursorLoader returns the image of a cursor at an output scale, at most | |
| 160 | - | // limit pixels on a side; an empty image hides the cursor. | |
| 161 | - | type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error) | |
| 159 | + | // CursorLoader returns the image of a cursor at an output scale, already | |
| 160 | + | // rotated by the output transform t (the cursor plane is not rotated by the | |
| 161 | + | // hardware), at most limit pixels on a side; an empty image hides the cursor. | |
| 162 | + | type CursorLoader func(c ports.CursorChange, scale float64, t ports.BufferTransform, limit int) (ports.CursorImage, error) | |
| 162 | 163 | ||
| 163 | 164 | // newOutput reads the CRTC's planes and properties for a connector on crtc. | |
| 164 | 165 | func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) { | |
| @@ -930,6 +931,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 930 | 931 | haveScene, dirty := false, false | |
| 931 | 932 | var want ports.CursorChange | |
| 932 | 933 | cursorScale := -1.0 // not loaded yet | |
| 934 | + | var cursorTransform ports.BufferTransform | |
| 933 | 935 | frame := 0 | |
| 934 | 936 | var requestStorage [capture.MaxRequests]ports.CaptureRequest | |
| 935 | 937 | requests := requestStorage[:0] | |
| @@ -1000,7 +1002,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1000 | 1002 | for { | |
| 1001 | 1003 | if o.observeSecurity() || haveScene && !o.sceneCurrent(scene) { | |
| 1002 | 1004 | scene, haveScene, dirty = ports.Scene{}, false, false | |
| 1003 | - | want, cursorScale = ports.CursorChange{}, -1 | |
| 1005 | + | want, cursorScale, cursorTransform = ports.CursorChange{}, -1, 0 | |
| 1004 | 1006 | if o.cursor != nil { | |
| 1005 | 1007 | o.cursor.image, o.cursor.later = false, nil | |
| 1006 | 1008 | } | |
| @@ -1230,9 +1232,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1230 | 1232 | o.invalidateSecurity() | |
| 1231 | 1233 | } | |
| 1232 | 1234 | } | |
| 1233 | - | if !o.protected && o.cursor != nil && loadCursor != nil && s.Scale != cursorScale { | |
| 1234 | - | cursorScale = s.Scale | |
| 1235 | - | o.setCursor(r, loadCursor, want, s.Scale) | |
| 1235 | + | if !o.protected && o.cursor != nil && loadCursor != nil && (s.Scale != cursorScale || s.Transform != cursorTransform) { | |
| 1236 | + | cursorScale, cursorTransform = s.Scale, s.Transform | |
| 1237 | + | o.setCursor(r, loadCursor, want, s.Scale, s.Transform) | |
| 1236 | 1238 | stateDirty = true | |
| 1237 | 1239 | } | |
| 1238 | 1240 | scene, haveScene, dirty = s, true, true | |
| @@ -1245,7 +1247,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1245 | 1247 | want = c | |
| 1246 | 1248 | // Before the first scene the scale is unknown: loaded then. | |
| 1247 | 1249 | if o.cursor != nil && loadCursor != nil && cursorScale > 0 { | |
| 1248 | - | o.setCursor(r, loadCursor, want, cursorScale) | |
| 1250 | + | o.setCursor(r, loadCursor, want, cursorScale, cursorTransform) | |
| 1249 | 1251 | stateDirty = true | |
| 1250 | 1252 | } | |
| 1251 | 1253 | case c := <-contents: | |
| @@ -1462,25 +1464,26 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool { | |||
| 1462 | 1464 | return true | |
| 1463 | 1465 | } | |
| 1464 | 1466 | ||
| 1465 | - | // setCursor loads a cursor for scale into the cursor images. | |
| 1466 | - | func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorChange, scale float64) { | |
| 1467 | + | // setCursor loads a cursor for scale and output transform t into the cursor | |
| 1468 | + | // images. | |
| 1469 | + | func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorChange, scale float64, t ports.BufferTransform) { | |
| 1467 | 1470 | o.observeSecurity() | |
| 1468 | 1471 | if o.protected || o.cursor.off { | |
| 1469 | 1472 | return | |
| 1470 | 1473 | } | |
| 1471 | - | img, err := load(c, scale, o.cursor.Limit()) | |
| 1474 | + | img, err := load(c, scale, t, o.cursor.Limit()) | |
| 1472 | 1475 | if err == nil { | |
| 1473 | 1476 | err = o.cursor.setImage(r, img.Pixels, img.W, img.H, img.HotX, img.HotY) | |
| 1474 | 1477 | } | |
| 1475 | 1478 | if err != nil { | |
| 1476 | - | o.log.Warn().Err(err).Msg("cursor") | |
| 1479 | + | o.log.Warn().Err(err).Float64("scale", scale).Int("transform", int(t)).Msg("cursor") | |
| 1477 | 1480 | return | |
| 1478 | 1481 | } | |
| 1479 | 1482 | if c.Image != nil { | |
| 1480 | 1483 | // Client cursors (games) reload on every change: too many to log. | |
| 1481 | 1484 | return | |
| 1482 | 1485 | } | |
| 1483 | - | o.log.Debug().Float64("scale", scale).Str("shape", c.Shape).Int("w", img.W).Int("h", img.H).Msg("cursor image") | |
| 1486 | + | o.log.Debug().Float64("scale", scale).Int("transform", int(t)).Str("shape", c.Shape).Int("w", img.W).Int("h", img.H).Msg("cursor image") | |
| 1484 | 1487 | } | |
| 1485 | 1488 | ||
| 1486 | 1489 | // refused reports a modeset error meaning KMS rejects the images. | |
| @@ -156,9 +156,10 @@ type Output struct { | |||||
| 156 | // Ready reports the result of the first modeset exactly once. | 156 | // Ready reports the result of the first modeset exactly once. | ||
| 157 | func (o *Output) Ready() <-chan error { return o.ready } | 157 | func (o *Output) Ready() <-chan error { return o.ready } | ||
| 158 | 158 | ||||
| 159 | - | // CursorLoader returns the image of a cursor at an output scale, at most | 159 | + | // CursorLoader returns the image of a cursor at an output scale, already |
| 160 | - | // limit pixels on a side; an empty image hides the cursor. | 160 | + | // rotated by the output transform t (the cursor plane is not rotated by the |
| 161 | - | type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error) | 161 | + | // hardware), at most limit pixels on a side; an empty image hides the cursor. |
| 162 | + | type CursorLoader func(c ports.CursorChange, scale float64, t ports.BufferTransform, limit int) (ports.CursorImage, error) | |||
| 162 | 163 | ||||
| 163 | // newOutput reads the CRTC's planes and properties for a connector on crtc. | 164 | // newOutput reads the CRTC's planes and properties for a connector on crtc. | ||
| 164 | func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) { | 165 | func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) { | ||
| @@ -930,6 +931,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 930 | haveScene, dirty := false, false | 931 | haveScene, dirty := false, false | ||
| 931 | var want ports.CursorChange | 932 | var want ports.CursorChange | ||
| 932 | cursorScale := -1.0 // not loaded yet | 933 | cursorScale := -1.0 // not loaded yet | ||
| 934 | + | var cursorTransform ports.BufferTransform | |||
| 933 | frame := 0 | 935 | frame := 0 | ||
| 934 | var requestStorage [capture.MaxRequests]ports.CaptureRequest | 936 | var requestStorage [capture.MaxRequests]ports.CaptureRequest | ||
| 935 | requests := requestStorage[:0] | 937 | requests := requestStorage[:0] | ||
| @@ -1000,7 +1002,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1000 | for { | 1002 | for { | ||
| 1001 | if o.observeSecurity() || haveScene && !o.sceneCurrent(scene) { | 1003 | if o.observeSecurity() || haveScene && !o.sceneCurrent(scene) { | ||
| 1002 | scene, haveScene, dirty = ports.Scene{}, false, false | 1004 | scene, haveScene, dirty = ports.Scene{}, false, false | ||
| 1003 | - | want, cursorScale = ports.CursorChange{}, -1 | 1005 | + | want, cursorScale, cursorTransform = ports.CursorChange{}, -1, 0 |
| 1004 | if o.cursor != nil { | 1006 | if o.cursor != nil { | ||
| 1005 | o.cursor.image, o.cursor.later = false, nil | 1007 | o.cursor.image, o.cursor.later = false, nil | ||
| 1006 | } | 1008 | } | ||
| @@ -1230,9 +1232,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1230 | o.invalidateSecurity() | 1232 | o.invalidateSecurity() | ||
| 1231 | } | 1233 | } | ||
| 1232 | } | 1234 | } | ||
| 1233 | - | if !o.protected && o.cursor != nil && loadCursor != nil && s.Scale != cursorScale { | 1235 | + | if !o.protected && o.cursor != nil && loadCursor != nil && (s.Scale != cursorScale || s.Transform != cursorTransform) { |
| 1234 | - | cursorScale = s.Scale | 1236 | + | cursorScale, cursorTransform = s.Scale, s.Transform |
| 1235 | - | o.setCursor(r, loadCursor, want, s.Scale) | 1237 | + | o.setCursor(r, loadCursor, want, s.Scale, s.Transform) |
| 1236 | stateDirty = true | 1238 | stateDirty = true | ||
| 1237 | } | 1239 | } | ||
| 1238 | scene, haveScene, dirty = s, true, true | 1240 | scene, haveScene, dirty = s, true, true | ||
| @@ -1245,7 +1247,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1245 | want = c | 1247 | want = c | ||
| 1246 | // Before the first scene the scale is unknown: loaded then. | 1248 | // Before the first scene the scale is unknown: loaded then. | ||
| 1247 | if o.cursor != nil && loadCursor != nil && cursorScale > 0 { | 1249 | if o.cursor != nil && loadCursor != nil && cursorScale > 0 { | ||
| 1248 | - | o.setCursor(r, loadCursor, want, cursorScale) | 1250 | + | o.setCursor(r, loadCursor, want, cursorScale, cursorTransform) |
| 1249 | stateDirty = true | 1251 | stateDirty = true | ||
| 1250 | } | 1252 | } | ||
| 1251 | case c := <-contents: | 1253 | case c := <-contents: | ||
| @@ -1462,25 +1464,26 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool { | |||||
| 1462 | return true | 1464 | return true | ||
| 1463 | } | 1465 | } | ||
| 1464 | 1466 | ||||
| 1465 | - | // setCursor loads a cursor for scale into the cursor images. | 1467 | + | // setCursor loads a cursor for scale and output transform t into the cursor |
| 1466 | - | func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorChange, scale float64) { | 1468 | + | // images. |
| 1469 | + | func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorChange, scale float64, t ports.BufferTransform) { | |||
| 1467 | o.observeSecurity() | 1470 | o.observeSecurity() | ||
| 1468 | if o.protected || o.cursor.off { | 1471 | if o.protected || o.cursor.off { | ||
| 1469 | return | 1472 | return | ||
| 1470 | } | 1473 | } | ||
| 1471 | - | img, err := load(c, scale, o.cursor.Limit()) | 1474 | + | img, err := load(c, scale, t, o.cursor.Limit()) |
| 1472 | if err == nil { | 1475 | if err == nil { | ||
| 1473 | err = o.cursor.setImage(r, img.Pixels, img.W, img.H, img.HotX, img.HotY) | 1476 | err = o.cursor.setImage(r, img.Pixels, img.W, img.H, img.HotX, img.HotY) | ||
| 1474 | } | 1477 | } | ||
| 1475 | if err != nil { | 1478 | if err != nil { | ||
| 1476 | - | o.log.Warn().Err(err).Msg("cursor") | 1479 | + | o.log.Warn().Err(err).Float64("scale", scale).Int("transform", int(t)).Msg("cursor") |
| 1477 | return | 1480 | return | ||
| 1478 | } | 1481 | } | ||
| 1479 | if c.Image != nil { | 1482 | if c.Image != nil { | ||
| 1480 | // Client cursors (games) reload on every change: too many to log. | 1483 | // Client cursors (games) reload on every change: too many to log. | ||
| 1481 | return | 1484 | return | ||
| 1482 | } | 1485 | } | ||
| 1483 | - | o.log.Debug().Float64("scale", scale).Str("shape", c.Shape).Int("w", img.W).Int("h", img.H).Msg("cursor image") | 1486 | + | o.log.Debug().Float64("scale", scale).Int("transform", int(t)).Str("shape", c.Shape).Int("w", img.W).Int("h", img.H).Msg("cursor image") |
| 1484 | } | 1487 | } | ||
| 1485 | 1488 | ||||
| 1486 | // refused reports a modeset error meaning KMS rejects the images. | 1489 | // refused reports a modeset error meaning KMS rejects the images. | ||
M internal/adapters/drm/overlay.go +3 -0
| @@ -58,6 +58,9 @@ type overlayWin struct { | |||
| 58 | 58 | // dmabuf at integer physical coordinates, drawn 1:1, with no other window | |
| 59 | 59 | // or layer above it. reason is why none. | |
| 60 | 60 | func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (ports.SceneWindow, ports.SurfaceContent, string) { | |
| 61 | + | if s.Transform != 0 { | |
| 62 | + | return ports.SceneWindow{}, ports.SurfaceContent{}, "output_transform" | |
| 63 | + | } | |
| 61 | 64 | scale := s.Scale | |
| 62 | 65 | if scale <= 0 { | |
| 63 | 66 | scale = 1 | |
| @@ -58,6 +58,9 @@ type overlayWin struct { | |||||
| 58 | // dmabuf at integer physical coordinates, drawn 1:1, with no other window | 58 | // dmabuf at integer physical coordinates, drawn 1:1, with no other window | ||
| 59 | // or layer above it. reason is why none. | 59 | // or layer above it. reason is why none. | ||
| 60 | func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (ports.SceneWindow, ports.SurfaceContent, string) { | 60 | func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (ports.SceneWindow, ports.SurfaceContent, string) { | ||
| 61 | + | if s.Transform != 0 { | |||
| 62 | + | return ports.SceneWindow{}, ports.SurfaceContent{}, "output_transform" | |||
| 63 | + | } | |||
| 61 | scale := s.Scale | 64 | scale := s.Scale | ||
| 62 | if scale <= 0 { | 65 | if scale <= 0 { | ||
| 63 | scale = 1 | 66 | scale = 1 | ||
M internal/adapters/drm/overlay_test.go +6 -0
| @@ -68,6 +68,12 @@ func TestOverlayCandidate(t *testing.T) { | |||
| 68 | 68 | if w, _, reason := overlayCandidate(s, c); reason != "" || w.ID != 2 { | |
| 69 | 69 | t.Fatalf("candidate %v %q", w.ID, reason) | |
| 70 | 70 | } | |
| 71 | + | // A rotated output composes: planes are not rotated. | |
| 72 | + | rs, rc := overlayScene() | |
| 73 | + | rs.Transform = 1 | |
| 74 | + | if _, _, reason := overlayCandidate(rs, rc); reason != "output_transform" { | |
| 75 | + | t.Fatalf("rotated output reason %q", reason) | |
| 76 | + | } | |
| 71 | 77 | c[2] = ports.SurfaceContent{ID: 2, Width: 100, Height: 100, Opaque: true, DMABuf: &ports.DMABuf{Format: fourccNV12}} | |
| 72 | 78 | if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" { | |
| 73 | 79 | t.Fatalf("YUV overlay reason %q", reason) | |
| @@ -68,6 +68,12 @@ func TestOverlayCandidate(t *testing.T) { | |||||
| 68 | if w, _, reason := overlayCandidate(s, c); reason != "" || w.ID != 2 { | 68 | if w, _, reason := overlayCandidate(s, c); reason != "" || w.ID != 2 { | ||
| 69 | t.Fatalf("candidate %v %q", w.ID, reason) | 69 | t.Fatalf("candidate %v %q", w.ID, reason) | ||
| 70 | } | 70 | } | ||
| 71 | + | // A rotated output composes: planes are not rotated. | |||
| 72 | + | rs, rc := overlayScene() | |||
| 73 | + | rs.Transform = 1 | |||
| 74 | + | if _, _, reason := overlayCandidate(rs, rc); reason != "output_transform" { | |||
| 75 | + | t.Fatalf("rotated output reason %q", reason) | |||
| 76 | + | } | |||
| 71 | c[2] = ports.SurfaceContent{ID: 2, Width: 100, Height: 100, Opaque: true, DMABuf: &ports.DMABuf{Format: fourccNV12}} | 77 | c[2] = ports.SurfaceContent{ID: 2, Width: 100, Height: 100, Opaque: true, DMABuf: &ports.DMABuf{Format: fourccNV12}} | ||
| 72 | if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" { | 78 | if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" { | ||
| 73 | t.Fatalf("YUV overlay reason %q", reason) | 79 | t.Fatalf("YUV overlay reason %q", reason) | ||
M internal/adapters/drm/scanout.go +4 -0
| @@ -86,6 +86,10 @@ func fullscreenShown(s *ports.Scene) bool { | |||
| 86 | 86 | // directly, or a reason why it cannot. w, h are the output's physical size. | |
| 87 | 87 | // The plane's formats are checked by scanoutFB. | |
| 88 | 88 | func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int) (ports.SurfaceContent, string) { | |
| 89 | + | if s.Transform != 0 { | |
| 90 | + | // The scanout buffer would have to be pre-rotated: rotated outputs compose. | |
| 91 | + | return ports.SurfaceContent{}, "output_transform" | |
| 92 | + | } | |
| 89 | 93 | if s.WorkspaceClip != (ports.Rect{}) && s.WorkspaceClip != (ports.Rect{W: s.OutputWidth, H: s.OutputHeight}) { | |
| 90 | 94 | return ports.SurfaceContent{}, "workspace_clip" | |
| 91 | 95 | } | |
| @@ -86,6 +86,10 @@ func fullscreenShown(s *ports.Scene) bool { | |||||
| 86 | // directly, or a reason why it cannot. w, h are the output's physical size. | 86 | // directly, or a reason why it cannot. w, h are the output's physical size. | ||
| 87 | // The plane's formats are checked by scanoutFB. | 87 | // The plane's formats are checked by scanoutFB. | ||
| 88 | func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int) (ports.SurfaceContent, string) { | 88 | func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int) (ports.SurfaceContent, string) { | ||
| 89 | + | if s.Transform != 0 { | |||
| 90 | + | // The scanout buffer would have to be pre-rotated: rotated outputs compose. | |||
| 91 | + | return ports.SurfaceContent{}, "output_transform" | |||
| 92 | + | } | |||
| 89 | if s.WorkspaceClip != (ports.Rect{}) && s.WorkspaceClip != (ports.Rect{W: s.OutputWidth, H: s.OutputHeight}) { | 93 | if s.WorkspaceClip != (ports.Rect{}) && s.WorkspaceClip != (ports.Rect{W: s.OutputWidth, H: s.OutputHeight}) { | ||
| 90 | return ports.SurfaceContent{}, "workspace_clip" | 94 | return ports.SurfaceContent{}, "workspace_clip" | ||
| 91 | } | 95 | } | ||
M internal/adapters/drm/scanout_test.go +1 -0
| @@ -25,6 +25,7 @@ func TestScanoutCandidate(t *testing.T) { | |||
| 25 | 25 | s.Windows = append(s.Windows, ports.SceneWindow{ID: 2, Hidden: true}) | |
| 26 | 26 | }, nil, ""}, | |
| 27 | 27 | {"no window", func(s *ports.Scene) { s.Windows = nil }, nil, "no_fullscreen"}, | |
| 28 | + | {"rotated output", func(s *ports.Scene) { s.Transform = 1 }, nil, "output_transform"}, | |
| 28 | 29 | {"dimmed", func(s *ports.Scene) { s.Windows[0].Dim = 0.5 }, nil, "dim"}, | |
| 29 | 30 | {"faded", nil, func(c *ports.SurfaceContent) { c.Fade = 0.5 }, "fade"}, | |
| 30 | 31 | {"tiled", func(s *ports.Scene) { s.Windows[0].Fullscreen = false }, nil, "other_windows"}, | |
| @@ -25,6 +25,7 @@ func TestScanoutCandidate(t *testing.T) { | |||||
| 25 | s.Windows = append(s.Windows, ports.SceneWindow{ID: 2, Hidden: true}) | 25 | s.Windows = append(s.Windows, ports.SceneWindow{ID: 2, Hidden: true}) | ||
| 26 | }, nil, ""}, | 26 | }, nil, ""}, | ||
| 27 | {"no window", func(s *ports.Scene) { s.Windows = nil }, nil, "no_fullscreen"}, | 27 | {"no window", func(s *ports.Scene) { s.Windows = nil }, nil, "no_fullscreen"}, | ||
| 28 | + | {"rotated output", func(s *ports.Scene) { s.Transform = 1 }, nil, "output_transform"}, | |||
| 28 | {"dimmed", func(s *ports.Scene) { s.Windows[0].Dim = 0.5 }, nil, "dim"}, | 29 | {"dimmed", func(s *ports.Scene) { s.Windows[0].Dim = 0.5 }, nil, "dim"}, | ||
| 29 | {"faded", nil, func(c *ports.SurfaceContent) { c.Fade = 0.5 }, "fade"}, | 30 | {"faded", nil, func(c *ports.SurfaceContent) { c.Fade = 0.5 }, "fade"}, | ||
| 30 | {"tiled", func(s *ports.Scene) { s.Windows[0].Fullscreen = false }, nil, "other_windows"}, | 31 | {"tiled", func(s *ports.Scene) { s.Windows[0].Fullscreen = false }, nil, "other_windows"}, | ||
M internal/adapters/headless/output.go +8 -6
| @@ -28,9 +28,10 @@ type Options struct { | |||
| 28 | 28 | Instance ports.OutputInstance | |
| 29 | 29 | SecurityEvents chan<- ports.SecurityBackendEvent | |
| 30 | 30 | // Cursor, when set, is drawn into screenshots with the image from | |
| 31 | - | // LoadCursor at the scene scale. | |
| 31 | + | // LoadCursor at the scene scale and transform. The cursor is drawn in | |
| 32 | + | // target space: the image comes rotated and Move takes target pixels. | |
| 32 | 33 | Cursor *Cursor | |
| 33 | - | LoadCursor func(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error) | |
| 34 | + | LoadCursor func(c ports.CursorChange, scale float64, t ports.BufferTransform, limit int) (ports.CursorImage, error) | |
| 34 | 35 | Width, Height int | |
| 35 | 36 | ScreenshotDir string | |
| 36 | 37 | HDR bool // virtual HDR output for protocol testing only | |
| @@ -120,6 +121,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 120 | 121 | }() | |
| 121 | 122 | var want ports.CursorChange | |
| 122 | 123 | cursorScale := -1.0 // not loaded yet | |
| 124 | + | var cursorTransform ports.BufferTransform | |
| 123 | 125 | seen := map[ports.WindowID]uint64{} | |
| 124 | 126 | // reports holds what the channel could not take yet, retried soon. | |
| 125 | 127 | var reports presented.Queue | |
| @@ -369,7 +371,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 369 | 371 | return err | |
| 370 | 372 | } | |
| 371 | 373 | // The cursor is only drawn into screenshots: no new frame. | |
| 372 | - | want, cursorScale = c, -1 | |
| 374 | + | want, cursorScale, cursorTransform = c, -1, 0 | |
| 373 | 375 | } | |
| 374 | 376 | // Drain all queued updates before presenting one frame. | |
| 375 | 377 | drain: | |
| @@ -426,9 +428,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 426 | 428 | continue | |
| 427 | 429 | } | |
| 428 | 430 | dirty = false | |
| 429 | - | if !opts.protected() && opts.Cursor != nil && opts.LoadCursor != nil && scene.Scale != cursorScale { | |
| 430 | - | cursorScale = scene.Scale | |
| 431 | - | if img, err := opts.LoadCursor(want, scene.Scale, 256); err == nil { | |
| 431 | + | if !opts.protected() && opts.Cursor != nil && opts.LoadCursor != nil && (scene.Scale != cursorScale || scene.Transform != cursorTransform) { | |
| 432 | + | cursorScale, cursorTransform = scene.Scale, scene.Transform | |
| 433 | + | if img, err := opts.LoadCursor(want, scene.Scale, scene.Transform, 256); err == nil { | |
| 432 | 434 | if !opts.protected() { | |
| 433 | 435 | opts.Cursor.set(img) | |
| 434 | 436 | } | |
| @@ -28,9 +28,10 @@ type Options struct { | |||||
| 28 | Instance ports.OutputInstance | 28 | Instance ports.OutputInstance | ||
| 29 | SecurityEvents chan<- ports.SecurityBackendEvent | 29 | SecurityEvents chan<- ports.SecurityBackendEvent | ||
| 30 | // Cursor, when set, is drawn into screenshots with the image from | 30 | // Cursor, when set, is drawn into screenshots with the image from | ||
| 31 | - | // LoadCursor at the scene scale. | 31 | + | // LoadCursor at the scene scale and transform. The cursor is drawn in |
| 32 | + | // target space: the image comes rotated and Move takes target pixels. | |||
| 32 | Cursor *Cursor | 33 | Cursor *Cursor | ||
| 33 | - | LoadCursor func(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error) | 34 | + | LoadCursor func(c ports.CursorChange, scale float64, t ports.BufferTransform, limit int) (ports.CursorImage, error) |
| 34 | Width, Height int | 35 | Width, Height int | ||
| 35 | ScreenshotDir string | 36 | ScreenshotDir string | ||
| 36 | HDR bool // virtual HDR output for protocol testing only | 37 | HDR bool // virtual HDR output for protocol testing only | ||
| @@ -120,6 +121,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 120 | }() | 121 | }() | ||
| 121 | var want ports.CursorChange | 122 | var want ports.CursorChange | ||
| 122 | cursorScale := -1.0 // not loaded yet | 123 | cursorScale := -1.0 // not loaded yet | ||
| 124 | + | var cursorTransform ports.BufferTransform | |||
| 123 | seen := map[ports.WindowID]uint64{} | 125 | seen := map[ports.WindowID]uint64{} | ||
| 124 | // reports holds what the channel could not take yet, retried soon. | 126 | // reports holds what the channel could not take yet, retried soon. | ||
| 125 | var reports presented.Queue | 127 | var reports presented.Queue | ||
| @@ -369,7 +371,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 369 | return err | 371 | return err | ||
| 370 | } | 372 | } | ||
| 371 | // The cursor is only drawn into screenshots: no new frame. | 373 | // The cursor is only drawn into screenshots: no new frame. | ||
| 372 | - | want, cursorScale = c, -1 | 374 | + | want, cursorScale, cursorTransform = c, -1, 0 |
| 373 | } | 375 | } | ||
| 374 | // Drain all queued updates before presenting one frame. | 376 | // Drain all queued updates before presenting one frame. | ||
| 375 | drain: | 377 | drain: | ||
| @@ -426,9 +428,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 426 | continue | 428 | continue | ||
| 427 | } | 429 | } | ||
| 428 | dirty = false | 430 | dirty = false | ||
| 429 | - | if !opts.protected() && opts.Cursor != nil && opts.LoadCursor != nil && scene.Scale != cursorScale { | 431 | + | if !opts.protected() && opts.Cursor != nil && opts.LoadCursor != nil && (scene.Scale != cursorScale || scene.Transform != cursorTransform) { |
| 430 | - | cursorScale = scene.Scale | 432 | + | cursorScale, cursorTransform = scene.Scale, scene.Transform |
| 431 | - | if img, err := opts.LoadCursor(want, scene.Scale, 256); err == nil { | 433 | + | if img, err := opts.LoadCursor(want, scene.Scale, scene.Transform, 256); err == nil { |
| 432 | if !opts.protected() { | 434 | if !opts.protected() { | ||
| 433 | opts.Cursor.set(img) | 435 | opts.Cursor.set(img) | ||
| 434 | } | 436 | } | ||
M internal/adapters/headlessinput/input.go +2 -1
| @@ -161,7 +161,8 @@ func RunSecure(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, | |||
| 161 | 161 | y, ey := strconv.ParseFloat(fields[2], 64) | |
| 162 | 162 | if ex == nil && ey == nil && !math.IsNaN(x) && !math.IsNaN(y) && !math.IsInf(x, 0) && !math.IsInf(y, 0) { | |
| 163 | 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 | 167 | if err := sendPointer(ports.PointerMotion{X: x, Y: y, TimeMsec: now}); err != nil { | |
| 167 | 168 | 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 err | 168 | return err | ||
M internal/adapters/headlessinput/input_test.go +30 -0
| @@ -105,3 +105,33 @@ func TestPointerScript(t *testing.T) { | |||
| 105 | 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 | + | } | |
| @@ -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 { | |||
| 775 | 775 | 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 | 780 | // motion tells the device moved it. | |
| 780 | 781 | func (p *pointer) moved(move func(output string, x, y float64, motion bool), motion bool) { | |
| 781 | 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 | } | |
| @@ -775,10 +775,12 @@ func (p *pointer) bounds() ports.Rect { | |||||
| 775 | return r | 775 | 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) { | |||
| 337 | 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 | + | } | |
| @@ -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 | |||
| 332 | 332 | source = [4]float32{0, 0, float32(w), float32(h)} | |
| 333 | 333 | } | |
| 334 | 334 | // 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) | |
| 339 | 337 | // src maps a target point to buffer pixels: affine, so its value at | |
| 340 | 338 | // the target origin and its steps along x and y define it. | |
| 341 | 339 | 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 at | 337 | // 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 | |||
| 27 | 27 | // (render, skip, render). | |
| 28 | 28 | func TestRendererDamageMatchesFullRedraw(t *testing.T) { | |
| 29 | 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 | 38 | // 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) { | |
| 37 | 42 | const w, h = 32, 32 | |
| 43 | + | tw, th := out.Size(64, 48) | |
| 38 | 44 | newR := func() *Renderer { | |
| 39 | - | r, err := New(64, 48) | |
| 45 | + | r, err := New(tw, th) | |
| 40 | 46 | if err != nil { | |
| 41 | 47 | t.Skipf("Vulkan unavailable: %v", err) | |
| 42 | 48 | } | |
| @@ -63,7 +69,7 @@ func damageMatchesFullRedraw(t *testing.T, tr ports.BufferTransform) { | |||
| 63 | 69 | base := shmContent(t, w, h, w*4, pixels) | |
| 64 | 70 | base.ID, base.Transform = 1, tr | |
| 65 | 71 | 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 | 73 | hist := []ports.SeqDamage{{Seq: 1, Full: true}} | |
| 68 | 74 | c := damagedContent(t, f, *base, 1, pixels, hist) | |
| 69 | 75 | step := func(i int, c ports.SurfaceContent) { | |
| @@ -73,7 +79,7 @@ func damageMatchesFullRedraw(t *testing.T, tr ports.BufferTransform) { | |||
| 73 | 79 | if err := render(damaged, scene, contents); err != nil { | |
| 74 | 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 | 83 | t.Fatal(err) | |
| 78 | 84 | } | |
| 79 | 85 | 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 buffer | 38 | // 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, 32 | 42 | 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, tr | 70 | base.ID, base.Transform = 1, tr | ||
| 65 | f := base.SHM.File | 71 | 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 | |||
| 176 | 176 | if hdr { | |
| 177 | 177 | 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 | 185 | ds := r.draws(s, contents, dmg) | |
| 181 | 186 | dmg.finish() | |
| 182 | 187 | r.dropPools() | |
| @@ -192,8 +197,9 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 192 | 197 | r.scratchDraws = ds | |
| 193 | 198 | r.redrawn += dmg.area.Dx() * dmg.area.Dy() | |
| 194 | 199 | } else { | |
| 195 | - | r.redrawn += r.width * r.height | |
| 200 | + | r.redrawn += sw * sh | |
| 196 | 201 | } | |
| 202 | + | r.orient(ds, s.Transform) | |
| 197 | 203 | d := r.dd | |
| 198 | 204 | cmd := slot.cmd | |
| 199 | 205 | 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.hdrOwn | 177 | 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 = ds | 197 | 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.height | 200 | + | r.redrawn += sw * sh |
| 196 | } | 201 | } | ||
| 202 | + | r.orient(ds, s.Transform) | |||
| 197 | d := r.dd | 203 | d := r.dd | ||
| 198 | cmd := slot.cmd | 204 | 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 { | |||
| 15 | 15 | s ports.Scene | |
| 16 | 16 | contents map[ports.WindowID]ports.SurfaceContent | |
| 17 | 17 | 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 | 19 | scale float64 | |
| 20 | 20 | // zoom shrinks the surfaces of the window being placed: an overview | |
| 21 | 21 | // preview (ports.SceneWindow.Preview); 1 otherwise. | |
| @@ -30,7 +30,10 @@ type sceneWalk struct { | |||
| 30 | 30 | // draws walks the scene into quads in paint order. | |
| 31 | 31 | func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, dmg *damageRegion) []draw { | |
| 32 | 32 | 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 | 37 | r.scratchCovers = r.scratchCovers[:0] | |
| 35 | 38 | if w.scale <= 0 { | |
| 36 | 39 | w.scale = 1 | |
| @@ -323,9 +326,7 @@ func (w *sceneWalk) surfaceRects(content *ports.SurfaceContent, x, y float64, cl | |||
| 323 | 326 | if content.Source[2] > 0 { | |
| 324 | 327 | sourceW, sourceH = int(content.Source[2]), int(content.Source[3]) | |
| 325 | 328 | } | |
| 326 | - | if content.Transform.Rotated() { | |
| 327 | - | sourceW, sourceH = sourceH, sourceW | |
| 328 | - | } | |
| 329 | + | sourceW, sourceH = content.Transform.Size(sourceW, sourceH) | |
| 329 | 330 | if near(full.Dx(), sourceW) && near(full.Dy(), sourceH) { | |
| 330 | 331 | full.Max = full.Min.Add(image.Pt(sourceW, sourceH)) | |
| 331 | 332 | } | |
| @@ -15,7 +15,7 @@ type sceneWalk struct { | |||||
| 15 | s ports.Scene | 15 | s ports.Scene | ||
| 16 | contents map[ports.WindowID]ports.SurfaceContent | 16 | contents map[ports.WindowID]ports.SurfaceContent | ||
| 17 | dmg *damageRegion | 17 | 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 float64 | 19 | scale float64 | ||
| 20 | // zoom shrinks the surfaces of the window being placed: an overview | 20 | // 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 scenes | 32 | 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 = 1 | 39 | 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 ( | |||
| 8 | 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 | 13 | 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 | + | }) | |
| 32 | 45 | } | |
| 33 | 46 | } | |
| 34 | 47 | ||
| @@ -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, 1 | 21 | + | {"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) { | |||
| 37 | 37 | c.Source = [4]float32{1, 0, 2, 2} | |
| 38 | 38 | bx0, bw = 1, 2 | |
| 39 | 39 | } | |
| 40 | - | sw, sh := bw, bh | |
| 41 | - | if tr.Rotated() { | |
| 42 | - | sw, sh = bh, bw | |
| 43 | - | } | |
| 40 | + | sw, sh := tr.Size(bw, bh) | |
| 44 | 41 | c.Transform, c.LogicalW, c.LogicalH = tr, sw, sh | |
| 45 | 42 | scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: sw, H: sh}}}} | |
| 46 | 43 | 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, 2 | 38 | bx0, bw = 1, 2 | ||
| 39 | } | 39 | } | ||
| 40 | - | sw, sh := bw, bh | 40 | + | 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, sh | 41 | 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() { | |||
| 601 | 601 | return | |
| 602 | 602 | } | |
| 603 | 603 | tag, ok := c.tag(hidden) | |
| 604 | + | // On-screen frames are target pixels, which the output holds under its | |
| 605 | + | // transform; off-screen child images are unrotated. | |
| 606 | + | transform := uint32(0) | |
| 607 | + | if !hidden { | |
| 608 | + | transform = uint32(o.place.Transform) | |
| 609 | + | } | |
| 604 | 610 | buf, isBuf := c.s.buffers[f.buf.GetResource()].(*buffer) | |
| 605 | 611 | switch { | |
| 606 | 612 | case !isBuf: | |
| @@ -621,7 +627,7 @@ func (f *captureExtFrame) attempt() { | |||
| 621 | 627 | f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown)) | |
| 622 | 628 | return | |
| 623 | 629 | } | |
| 624 | - | f.res.SendTransform(0) | |
| 630 | + | f.res.SendTransform(transform) | |
| 625 | 631 | f.res.SendDamage(0, 0, int32(region.Dx()), int32(region.Dy())) | |
| 626 | 632 | hi, lo, ns := captureTime(done.Time) | |
| 627 | 633 | f.res.SendPresentationTime(hi, lo, ns) | |
| @@ -601,6 +601,12 @@ func (f *captureExtFrame) attempt() { | |||||
| 601 | return | 601 | return | ||
| 602 | } | 602 | } | ||
| 603 | tag, ok := c.tag(hidden) | 603 | tag, ok := c.tag(hidden) | ||
| 604 | + | // On-screen frames are target pixels, which the output holds under its | |||
| 605 | + | // transform; off-screen child images are unrotated. | |||
| 606 | + | transform := uint32(0) | |||
| 607 | + | if !hidden { | |||
| 608 | + | transform = uint32(o.place.Transform) | |||
| 609 | + | } | |||
| 604 | buf, isBuf := c.s.buffers[f.buf.GetResource()].(*buffer) | 610 | buf, isBuf := c.s.buffers[f.buf.GetResource()].(*buffer) | ||
| 605 | switch { | 611 | switch { | ||
| 606 | case !isBuf: | 612 | case !isBuf: | ||
| @@ -621,7 +627,7 @@ func (f *captureExtFrame) attempt() { | |||||
| 621 | f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown)) | 627 | f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown)) | ||
| 622 | return | 628 | return | ||
| 623 | } | 629 | } | ||
| 624 | - | f.res.SendTransform(0) | 630 | + | f.res.SendTransform(transform) |
| 625 | f.res.SendDamage(0, 0, int32(region.Dx()), int32(region.Dy())) | 631 | f.res.SendDamage(0, 0, int32(region.Dx()), int32(region.Dy())) | ||
| 626 | hi, lo, ns := captureTime(done.Time) | 632 | hi, lo, ns := captureTime(done.Time) | ||
| 627 | f.res.SendPresentationTime(hi, lo, ns) | 633 | f.res.SendPresentationTime(hi, lo, ns) | ||
M internal/adapters/wayland/outputconfiguration.go +2 -3
| @@ -159,9 +159,8 @@ func (h *headConfiguration) SetTransform(r *wlr.ZwlrOutputConfigurationHeadV1, v | |||
| 159 | 159 | r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidTransform), "invalid transform") | |
| 160 | 160 | return | |
| 161 | 161 | } | |
| 162 | - | if v != 0 { | |
| 163 | - | h.unsupported = true | |
| 164 | - | } | |
| 162 | + | t := ports.BufferTransform(v) | |
| 163 | + | h.change.Transform = &t | |
| 165 | 164 | } | |
| 166 | 165 | func (h *headConfiguration) SetScale(r *wlr.ZwlrOutputConfigurationHeadV1, v server.Fixed) { | |
| 167 | 166 | if !h.once(&h.scaleSet, r) { | |
| @@ -159,9 +159,8 @@ func (h *headConfiguration) SetTransform(r *wlr.ZwlrOutputConfigurationHeadV1, v | |||||
| 159 | r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidTransform), "invalid transform") | 159 | r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidTransform), "invalid transform") | ||
| 160 | return | 160 | return | ||
| 161 | } | 161 | } | ||
| 162 | - | if v != 0 { | 162 | + | t := ports.BufferTransform(v) |
| 163 | - | h.unsupported = true | 163 | + | h.change.Transform = &t |
| 164 | - | } | |||
| 165 | } | 164 | } | ||
| 166 | func (h *headConfiguration) SetScale(r *wlr.ZwlrOutputConfigurationHeadV1, v server.Fixed) { | 165 | func (h *headConfiguration) SetScale(r *wlr.ZwlrOutputConfigurationHeadV1, v server.Fixed) { | ||
| 167 | if !h.once(&h.scaleSet, r) { | 166 | if !h.once(&h.scaleSet, r) { | ||
M internal/adapters/wayland/outputmanagement.go +3 -1
| @@ -169,8 +169,10 @@ func (m *outputManager) sendState(h *managementHead, info ports.OutputHead) { | |||
| 169 | 169 | if place.Info.Name != "" { | |
| 170 | 170 | h.res.SendPosition(int32(place.X), int32(place.Y)) | |
| 171 | 171 | h.res.SendScale(server.FixedFromFloat(place.Scale)) | |
| 172 | + | h.res.SendTransform(int32(place.Transform)) | |
| 173 | + | } else { | |
| 174 | + | h.res.SendTransform(0) | |
| 172 | 175 | } | |
| 173 | - | h.res.SendTransform(0) | |
| 174 | 176 | } | |
| 175 | 177 | m.places[info.Info.Name] = m.s.placementFor(info.Info.Name) | |
| 176 | 178 | } | |
| @@ -169,8 +169,10 @@ func (m *outputManager) sendState(h *managementHead, info ports.OutputHead) { | |||||
| 169 | if place.Info.Name != "" { | 169 | if place.Info.Name != "" { | ||
| 170 | h.res.SendPosition(int32(place.X), int32(place.Y)) | 170 | h.res.SendPosition(int32(place.X), int32(place.Y)) | ||
| 171 | h.res.SendScale(server.FixedFromFloat(place.Scale)) | 171 | h.res.SendScale(server.FixedFromFloat(place.Scale)) | ||
| 172 | + | h.res.SendTransform(int32(place.Transform)) | |||
| 173 | + | } else { | |||
| 174 | + | h.res.SendTransform(0) | |||
| 172 | } | 175 | } | ||
| 173 | - | h.res.SendTransform(0) | |||
| 174 | } | 176 | } | ||
| 175 | m.places[info.Info.Name] = m.s.placementFor(info.Info.Name) | 177 | m.places[info.Info.Name] = m.s.placementFor(info.Info.Name) | ||
| 176 | } | 178 | } | ||
M internal/adapters/wayland/outputmanagement_test.go +79 -3
| @@ -243,9 +243,6 @@ func TestOutputManagementUnsupportedSettings(t *testing.T) { | |||
| 243 | 243 | {"custom mode", func(t *testing.T, c *wlturbo.Display, ch uint32) { | |
| 244 | 244 | requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetCustomMode, int32(800), int32(600), int32(60000)) | |
| 245 | 245 | }}, | |
| 246 | - | {"transform", func(t *testing.T, c *wlturbo.Display, ch uint32) { | |
| 247 | - | requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetTransform, int32(1)) | |
| 248 | - | }}, | |
| 249 | 246 | {"adaptive sync off", func(t *testing.T, c *wlturbo.Display, ch uint32) { | |
| 250 | 247 | requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetAdaptiveSync, uint32(0)) | |
| 251 | 248 | }}, | |
| @@ -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 | 367 | // Each case uses a new connection because wl_display.error terminates that client. | |
| 292 | 368 | func TestOutputManagementProtocolErrors(t *testing.T) { | |
| 293 | 369 | 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) { | |||
| 65 | 65 | if refresh == 0 { | |
| 66 | 66 | refresh = 60000 | |
| 67 | 67 | } | |
| 68 | - | r.SendGeometry(int32(o.place.X), int32(o.place.Y), int32(i.PhysicalW), int32(i.PhysicalH), 0, make, model, 0) | |
| 68 | + | r.SendGeometry(int32(o.place.X), int32(o.place.Y), int32(i.PhysicalW), int32(i.PhysicalH), 0, make, model, int32(o.place.Transform)) | |
| 69 | 69 | // The mode stays physical; the scale tells clients how to divide it. | |
| 70 | 70 | r.SendMode(3, int32(i.Width), int32(i.Height), int32(refresh)) | |
| 71 | 71 | if r.Version() >= 2 { | |
| @@ -209,6 +209,8 @@ func (s *Server) setOutputs(c ports.SetOutputs) { | |||
| 209 | 209 | continue | |
| 210 | 210 | } | |
| 211 | 211 | scaleChanged := o.place.Scale != p.Scale | |
| 212 | + | // A 90° to 270° change keeps the logical size: lock surfaces are | |
| 213 | + | // logical-sized and need no reconfigure then. | |
| 212 | 214 | logicalChanged := o.place.Width != p.Width || o.place.Height != p.Height | |
| 213 | 215 | o.place = p | |
| 214 | 216 | if logicalChanged { | |
| @@ -65,7 +65,7 @@ func (o *output) sendAll(r *wayland.Output) { | |||||
| 65 | if refresh == 0 { | 65 | if refresh == 0 { | ||
| 66 | refresh = 60000 | 66 | refresh = 60000 | ||
| 67 | } | 67 | } | ||
| 68 | - | r.SendGeometry(int32(o.place.X), int32(o.place.Y), int32(i.PhysicalW), int32(i.PhysicalH), 0, make, model, 0) | 68 | + | r.SendGeometry(int32(o.place.X), int32(o.place.Y), int32(i.PhysicalW), int32(i.PhysicalH), 0, make, model, int32(o.place.Transform)) |
| 69 | // The mode stays physical; the scale tells clients how to divide it. | 69 | // The mode stays physical; the scale tells clients how to divide it. | ||
| 70 | r.SendMode(3, int32(i.Width), int32(i.Height), int32(refresh)) | 70 | r.SendMode(3, int32(i.Width), int32(i.Height), int32(refresh)) | ||
| 71 | if r.Version() >= 2 { | 71 | if r.Version() >= 2 { | ||
| @@ -209,6 +209,8 @@ func (s *Server) setOutputs(c ports.SetOutputs) { | |||||
| 209 | continue | 209 | continue | ||
| 210 | } | 210 | } | ||
| 211 | scaleChanged := o.place.Scale != p.Scale | 211 | scaleChanged := o.place.Scale != p.Scale | ||
| 212 | + | // A 90° to 270° change keeps the logical size: lock surfaces are | |||
| 213 | + | // logical-sized and need no reconfigure then. | |||
| 212 | logicalChanged := o.place.Width != p.Width || o.place.Height != p.Height | 214 | logicalChanged := o.place.Width != p.Width || o.place.Height != p.Height | ||
| 213 | o.place = p | 215 | o.place = p | ||
| 214 | if logicalChanged { | 216 | if logicalChanged { | ||
M internal/adapters/wayland/scale.go +15 -8
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "github.com/bnema/go-wayland-bindings/server/fractionalscale" | |
| 7 | 7 | "github.com/bnema/go-wayland-bindings/server/viewporter" | |
| 8 | 8 | "github.com/bnema/go-wayland-bindings/server/wayland" | |
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |
| 9 | 10 | "github.com/bnema/purego-libwayland/server" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| @@ -76,6 +77,17 @@ func (surf *surface) sendScale() { | |||
| 76 | 77 | if o != nil { | |
| 77 | 78 | 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 | 91 | if scale == surf.scale { | |
| 80 | 92 | return | |
| 81 | 93 | } | |
| @@ -225,10 +237,8 @@ func (s *surface) source(bw, bh int) ([4]float32, bool) { | |||
| 225 | 237 | v := s.committedViewport.src | |
| 226 | 238 | scale := float64(max(s.bufferScale, 1)) | |
| 227 | 239 | // 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) | |
| 232 | 242 | x0, y0 := float64(v[0])*scale/256, float64(v[1])*scale/256 | |
| 233 | 243 | x1, y1 := x0+float64(v[2])*scale/256, y0+float64(v[3])*scale/256 | |
| 234 | 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 | 251 | // transformedSize is the buffer size in the surface's axes: width and height | |
| 242 | 252 | // swap for a 90° or 270° buffer transform. | |
| 243 | 253 | 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) | |
| 248 | 255 | } | |
| 249 | 256 | ||
| 250 | 257 | 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.Scale | 78 | 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 | return | 92 | return | ||
| 81 | } | 93 | } | ||
| @@ -225,10 +237,8 @@ func (s *surface) source(bw, bh int) ([4]float32, bool) { | |||||
| 225 | v := s.committedViewport.src | 237 | 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/256 | 242 | 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/256 | 243 | 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 height | 251 | // 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "slices" | |
| 5 | 6 | "testing" | |
| 6 | 7 | "time" | |
| 7 | 8 | ||
| @@ -38,6 +39,18 @@ func (p *surfaceScaleProxy) Dispatch(e *wlturbo.Event) { | |||
| 38 | 39 | } | |
| 39 | 40 | } | |
| 40 | 41 | ||
| 42 | + | // surfaceTransformProxy records wl_surface.preferred_buffer_transform (opcode 3). | |
| 43 | + | type surfaceTransformProxy struct { | |
| 44 | + | wlturbo.BaseProxy | |
| 45 | + | transforms chan uint32 | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (p *surfaceTransformProxy) Dispatch(e *wlturbo.Event) { | |
| 49 | + | if e.Opcode == 3 { | |
| 50 | + | p.transforms <- e.Uint32() | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 41 | 54 | func contentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, chan ports.SurfaceContent, string) { | |
| 42 | 55 | t.Helper() | |
| 43 | 56 | dir := t.TempDir() | |
| @@ -55,6 +68,82 @@ func contentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.Cl | |||
| 55 | 68 | return s, events, commands, contents, dir | |
| 56 | 69 | } | |
| 57 | 70 | ||
| 71 | + | // A v6 surface learns the transform of its output 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 | 147 | func TestFractionalScaleAndViewport(t *testing.T) { | |
| 59 | 148 | s, events, commands, contents, dir := contentServer(t) | |
| 60 | 149 | 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, dir | 68 | 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 ( | |||
| 19 | 19 | // release its slot: only the backend completion does. | |
| 20 | 20 | const maxCaptureInflight = ports.MaxCaptureInflight | |
| 21 | 21 | ||
| 22 | - | // The renderer's scene and wl_output modes are already in physical orientation; | |
| 23 | - | // this compositor does not advertise rotated wl_output transforms. | |
| 22 | + | // captureRegion maps a logical rectangle to target pixels: scaled to the | |
| 23 | + | // scene-physical space, then through the output transform into the target | |
| 24 | + | // (the framebuffer, mode size) that captures read. | |
| 24 | 25 | func captureRegion(o *output, logical image.Rectangle) image.Rectangle { | |
| 25 | 26 | if o == nil { | |
| 26 | 27 | return image.Rectangle{} | |
| @@ -29,9 +30,11 @@ func captureRegion(o *output, logical image.Rectangle) image.Rectangle { | |||
| 29 | 30 | if scale <= 0 { | |
| 30 | 31 | scale = 1 | |
| 31 | 32 | } | |
| 33 | + | sw, sh := o.place.Transform.Size(o.place.Info.Width, o.place.Info.Height) | |
| 32 | 34 | p := image.Rect(int(math.Floor(float64(logical.Min.X)*scale)), int(math.Floor(float64(logical.Min.Y)*scale)), int(math.Ceil(float64(logical.Max.X)*scale)), int(math.Ceil(float64(logical.Max.Y)*scale))) | |
| 33 | - | return p.Intersect(image.Rect(0, 0, o.place.Info.Width, o.place.Info.Height)) | |
| 35 | + | return o.place.Transform.RectToBuffer(p, sw, sh).Intersect(image.Rect(0, 0, o.place.Info.Width, o.place.Info.Height)) | |
| 34 | 36 | } | |
| 37 | + | ||
| 35 | 38 | func captureTime(t time.Time) (uint32, uint32, uint32) { | |
| 36 | 39 | sec := uint64(t.Unix()) | |
| 37 | 40 | return uint32(sec >> 32), uint32(sec), uint32(t.Nanosecond()) | |
| @@ -154,6 +157,7 @@ func logicalRegion(o *output, phys image.Rectangle) ports.Rect { | |||
| 154 | 157 | if phys == image.Rect(0, 0, o.place.Info.Width, o.place.Info.Height) { | |
| 155 | 158 | return ports.Rect{} | |
| 156 | 159 | } | |
| 160 | + | phys = o.place.Transform.Invert().RectToBuffer(phys, o.place.Info.Width, o.place.Info.Height) | |
| 157 | 161 | x0, y0 := int(math.Floor(float64(phys.Min.X)/scale)), int(math.Floor(float64(phys.Min.Y)/scale)) | |
| 158 | 162 | x1, y1 := int(math.Ceil(float64(phys.Max.X)/scale)), int(math.Ceil(float64(phys.Max.Y)/scale)) | |
| 159 | 163 | return ports.Rect{X: x0, Y: y0, W: x1 - x0, H: y1 - y0} | |
| @@ -19,8 +19,9 @@ import ( | |||||
| 19 | // release its slot: only the backend completion does. | 19 | // release its slot: only the backend completion does. | ||
| 20 | const maxCaptureInflight = ports.MaxCaptureInflight | 20 | const maxCaptureInflight = ports.MaxCaptureInflight | ||
| 21 | 21 | ||||
| 22 | - | // The renderer's scene and wl_output modes are already in physical orientation; | 22 | + | // captureRegion maps a logical rectangle to target pixels: scaled to the |
| 23 | - | // this compositor does not advertise rotated wl_output transforms. | 23 | + | // scene-physical space, then through the output transform into the target |
| 24 | + | // (the framebuffer, mode size) that captures read. | |||
| 24 | func captureRegion(o *output, logical image.Rectangle) image.Rectangle { | 25 | func captureRegion(o *output, logical image.Rectangle) image.Rectangle { | ||
| 25 | if o == nil { | 26 | if o == nil { | ||
| 26 | return image.Rectangle{} | 27 | return image.Rectangle{} | ||
| @@ -29,9 +30,11 @@ func captureRegion(o *output, logical image.Rectangle) image.Rectangle { | |||||
| 29 | if scale <= 0 { | 30 | if scale <= 0 { | ||
| 30 | scale = 1 | 31 | scale = 1 | ||
| 31 | } | 32 | } | ||
| 33 | + | sw, sh := 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) { | |||
| 39 | 39 | } | |
| 40 | 40 | } | |
| 41 | 41 | } | |
| 42 | + | ||
| 43 | + | // Regions are logical; a rotated output holds them in its target under the | |
| 44 | + | // transform, and logicalRegion maps them back. | |
| 45 | + | func TestCaptureRegionTransform(t *testing.T) { | |
| 46 | + | o := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Width: 200, Height: 100}, Scale: 1, Transform: 1, Width: 100, Height: 200}} | |
| 47 | + | got := captureRegion(o, image.Rect(0, 0, 10, 20)) | |
| 48 | + | if want := image.Rect(0, 90, 20, 100); got != want { | |
| 49 | + | t.Fatalf("captureRegion %v, want %v", got, want) | |
| 50 | + | } | |
| 51 | + | if back := logicalRegion(o, got); back != (ports.Rect{X: 0, Y: 0, W: 10, H: 20}) { | |
| 52 | + | t.Fatalf("logicalRegion %+v", back) | |
| 53 | + | } | |
| 54 | + | if full := logicalRegion(o, image.Rect(0, 0, 200, 100)); full != (ports.Rect{}) { | |
| 55 | + | t.Fatalf("full target %+v", full) | |
| 56 | + | } | |
| 57 | + | } | |
| 42 | 58 | func TestScreencopyProtocol(t *testing.T) { | |
| 43 | 59 | dir := t.TempDir() | |
| 44 | 60 | requests := make(chan ports.CaptureRequest, 1) | |
| @@ -39,6 +39,22 @@ func TestCaptureRegion(t *testing.T) { | |||||
| 39 | } | 39 | } | ||
| 40 | } | 40 | } | ||
| 41 | } | 41 | } | ||
| 42 | + | ||||
| 43 | + | // Regions are logical; a rotated output holds them in its target under the | |||
| 44 | + | // transform, and logicalRegion maps them back. | |||
| 45 | + | func TestCaptureRegionTransform(t *testing.T) { | |||
| 46 | + | o := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Width: 200, Height: 100}, Scale: 1, Transform: 1, Width: 100, Height: 200}} | |||
| 47 | + | got := captureRegion(o, image.Rect(0, 0, 10, 20)) | |||
| 48 | + | if want := image.Rect(0, 90, 20, 100); got != want { | |||
| 49 | + | t.Fatalf("captureRegion %v, want %v", got, want) | |||
| 50 | + | } | |||
| 51 | + | if back := logicalRegion(o, got); back != (ports.Rect{X: 0, Y: 0, W: 10, H: 20}) { | |||
| 52 | + | t.Fatalf("logicalRegion %+v", back) | |||
| 53 | + | } | |||
| 54 | + | if full := logicalRegion(o, image.Rect(0, 0, 200, 100)); full != (ports.Rect{}) { | |||
| 55 | + | t.Fatalf("full target %+v", full) | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 42 | func TestScreencopyProtocol(t *testing.T) { | 58 | func TestScreencopyProtocol(t *testing.T) { | ||
| 43 | dir := t.TempDir() | 59 | dir := t.TempDir() | ||
| 44 | requests := make(chan ports.CaptureRequest, 1) | 60 | requests := make(chan ports.CaptureRequest, 1) | ||
M internal/adapters/wayland/surface.go +3 -0
| @@ -55,6 +55,9 @@ type surface struct { | |||
| 55 | 55 | // on is the output the surface entered; scale is the last scale sent. | |
| 56 | 56 | on *output | |
| 57 | 57 | scale float64 | |
| 58 | + | // prefTransform is the last preferred buffer transform sent (wl_surface | |
| 59 | + | // v6+); zero (normal) is the protocol default. | |
| 60 | + | prefTransform ports.BufferTransform | |
| 58 | 61 | // content is the last content built from this surface's own buffer; | |
| 59 | 62 | // has is false while no buffer is attached. | |
| 60 | 63 | 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 *output | 56 | on *output | ||
| 57 | scale float64 | 57 | 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 bool | 63 | inputAll bool | ||
M internal/app/cursor.go +31 -2
| @@ -67,8 +67,37 @@ type themeKey struct { | |||
| 67 | 67 | // loadCursor returns the cursor a client asked for at an output scale, at | |
| 68 | 68 | // most limit physical pixels on a side: a XCURSOR_THEME shape at | |
| 69 | 69 | // XCURSOR_SIZE × scale, the client image resized to the output scale, or | |
| 70 | - | // an empty image when hidden. Unknown shapes fall back to the arrow. | |
| 71 | - | func loadCursor(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error) { | |
| 70 | + | // an empty image when hidden, then rotated by the output transform t (the | |
| 71 | + | // cursor plane and the headless cursor are drawn in target space). Unknown | |
| 72 | + | // shapes fall back to the arrow. | |
| 73 | + | func loadCursor(c ports.CursorChange, scale float64, t ports.BufferTransform, limit int) (ports.CursorImage, error) { | |
| 74 | + | img, err := loadCursorUpright(c, scale, limit) | |
| 75 | + | if err != nil { | |
| 76 | + | return img, err | |
| 77 | + | } | |
| 78 | + | return rotateCursor(img, t), nil | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // rotateCursor returns img as an output with transform t holds it, hotspot | |
| 82 | + | // included. It never modifies img (theme images are cached). | |
| 83 | + | func rotateCursor(img ports.CursorImage, t ports.BufferTransform) ports.CursorImage { | |
| 84 | + | if t == 0 || img.W <= 0 || img.H <= 0 || 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 | 101 | scale = max(scale, 1) | |
| 73 | 102 | if c.Hidden { | |
| 74 | 103 | return ports.CursorImage{}, nil | |
| @@ -67,8 +67,37 @@ type themeKey struct { | |||||
| 67 | // loadCursor returns the cursor a client asked for at an output scale, at | 67 | // loadCursor returns the cursor a client asked for at an output scale, at | ||
| 68 | // most limit physical pixels on a side: a XCURSOR_THEME shape at | 68 | // most limit physical pixels on a side: a XCURSOR_THEME shape at | ||
| 69 | // XCURSOR_SIZE × scale, the client image resized to the output scale, or | 69 | // XCURSOR_SIZE × scale, the client image resized to the output scale, or | ||
| 70 | - | // an empty image when hidden. Unknown shapes fall back to the arrow. | 70 | + | // an empty image when hidden, then rotated by the output transform t (the |
| 71 | - | func loadCursor(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error) { | 71 | + | // cursor plane and the headless cursor are drawn in target space). Unknown |
| 72 | + | // shapes fall back to the arrow. | |||
| 73 | + | func loadCursor(c ports.CursorChange, scale float64, t ports.BufferTransform, limit int) (ports.CursorImage, error) { | |||
| 74 | + | img, err := loadCursorUpright(c, scale, limit) | |||
| 75 | + | if err != nil { | |||
| 76 | + | return img, err | |||
| 77 | + | } | |||
| 78 | + | return rotateCursor(img, t), nil | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // rotateCursor returns img as an output with transform t holds it, hotspot | |||
| 82 | + | // included. It never modifies img (theme images are cached). | |||
| 83 | + | func rotateCursor(img ports.CursorImage, t ports.BufferTransform) ports.CursorImage { | |||
| 84 | + | if t == 0 || img.W <= 0 || img.H <= 0 || 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{}, nil | 103 | return ports.CursorImage{}, nil | ||
M internal/app/cursor_test.go +53 -7
| @@ -61,7 +61,7 @@ func TestLoadCursorShapes(t *testing.T) { | |||
| 61 | 61 | // Unknown shapes fall back to the arrow. | |
| 62 | 62 | {ports.CursorChange{Shape: "zoom-in"}, 1}, | |
| 63 | 63 | } { | |
| 64 | - | img, err := loadCursor(tc.change, 2, 256) | |
| 64 | + | img, err := loadCursor(tc.change, 2, 0, 256) | |
| 65 | 65 | if err != nil { | |
| 66 | 66 | t.Fatal(tc.change, err) | |
| 67 | 67 | } | |
| @@ -73,10 +73,10 @@ func TestLoadCursorShapes(t *testing.T) { | |||
| 73 | 73 | if err := os.RemoveAll(dir); err != nil { | |
| 74 | 74 | t.Fatal(err) | |
| 75 | 75 | } | |
| 76 | - | if img, err := loadCursor(ports.CursorChange{Shape: "pointer"}, 2, 256); err != nil || img.Pixels[0] != 2 { | |
| 76 | + | if img, err := loadCursor(ports.CursorChange{Shape: "pointer"}, 2, 0, 256); err != nil || img.Pixels[0] != 2 { | |
| 77 | 77 | t.Fatalf("cached: %v", err) | |
| 78 | 78 | } | |
| 79 | - | if img, err := loadCursor(ports.CursorChange{Hidden: true}, 1, 256); err != nil || img.W != 0 { | |
| 79 | + | if img, err := loadCursor(ports.CursorChange{Hidden: true}, 1, 0, 256); err != nil || img.W != 0 { | |
| 80 | 80 | t.Fatalf("hidden: %+v %v", img, err) | |
| 81 | 81 | } | |
| 82 | 82 | } | |
| @@ -84,16 +84,16 @@ func TestLoadCursorShapes(t *testing.T) { | |||
| 84 | 84 | // A client cursor drawn at buffer scale 2 is shown at the output scale. | |
| 85 | 85 | func TestLoadCursorClientImage(t *testing.T) { | |
| 86 | 86 | src := &ports.CursorImage{W: 4, H: 2, HotX: 2, HotY: 1, Pixels: make([]byte, 4*2*4)} | |
| 87 | - | img, err := loadCursor(ports.CursorChange{Image: src, Scale: 2}, 1, 256) | |
| 87 | + | img, err := loadCursor(ports.CursorChange{Image: src, Scale: 2}, 1, 0, 256) | |
| 88 | 88 | if err != nil || img.W != 2 || img.H != 1 || img.HotX != 1 || img.HotY != 0 { | |
| 89 | 89 | t.Fatalf("%+v %v", img, err) | |
| 90 | 90 | } | |
| 91 | - | img, err = loadCursor(ports.CursorChange{Image: src, Scale: 2}, 2, 256) | |
| 91 | + | img, err = loadCursor(ports.CursorChange{Image: src, Scale: 2}, 2, 0, 256) | |
| 92 | 92 | if err != nil || img.W != 4 || img.HotX != 2 { | |
| 93 | 93 | t.Fatalf("%+v %v", img, err) | |
| 94 | 94 | } | |
| 95 | 95 | // Bounded by the cursor plane. | |
| 96 | - | img, err = loadCursor(ports.CursorChange{Image: src, Scale: 1}, 4, 8) | |
| 96 | + | img, err = loadCursor(ports.CursorChange{Image: src, Scale: 1}, 4, 0, 8) | |
| 97 | 97 | if err != nil || img.W != 8 || img.H != 4 { | |
| 98 | 98 | t.Fatalf("%+v %v", img, err) | |
| 99 | 99 | } | |
| @@ -118,8 +118,54 @@ func TestLoadCursorInheritedShape(t *testing.T) { | |||
| 118 | 118 | if err := os.Rename(filepath.Join(dir, "theme", "cursors", "left_ptr"), child); err != nil { | |
| 119 | 119 | t.Fatal(err) | |
| 120 | 120 | } | |
| 121 | - | img, err := loadCursor(ports.CursorChange{Shape: "text"}, 1, 256) | |
| 121 | + | img, err := loadCursor(ports.CursorChange{Shape: "text"}, 1, 0, 256) | |
| 122 | 122 | if err != nil || img.Pixels[0] != 3 { | |
| 123 | 123 | t.Fatalf("got arrow instead of the inherited text cursor: %v", err) | |
| 124 | 124 | } | |
| 125 | 125 | } | |
| 126 | + | ||
| 127 | + | // A rotated output holds the cursor image like any buffer: pixels and | |
| 128 | + | // hotspot follow BufferTransform.ToBuffer. | |
| 129 | + | func TestRotateCursor(t *testing.T) { | |
| 130 | + | // 3×2, pixel i is {i, 0, 0, 255}. | |
| 131 | + | px := make([]byte, 3*2*4) | |
| 132 | + | for i := range 6 { | |
| 133 | + | copy(px[i*4:], []byte{byte(i), 0, 0, 255}) | |
| 134 | + | } | |
| 135 | + | img := ports.CursorImage{W: 3, H: 2, Pixels: px} | |
| 136 | + | if got := rotateCursor(img, 0); &got.Pixels[0] != &img.Pixels[0] || got.W != 3 || got.H != 2 { | |
| 137 | + | t.Fatalf("identity: %+v", got) | |
| 138 | + | } | |
| 139 | + | got := rotateCursor(img, 1) | |
| 140 | + | if got.W != 2 || got.H != 3 || got.HotX != 0 || got.HotY != 2 { | |
| 141 | + | t.Fatalf("90: %dx%d hot (%d,%d)", got.W, got.H, got.HotX, got.HotY) | |
| 142 | + | } | |
| 143 | + | if &img.Pixels[0] == &got.Pixels[0] || img.Pixels[0] != 0 || img.W != 3 { | |
| 144 | + | t.Fatal("source modified") | |
| 145 | + | } | |
| 146 | + | for tr := ports.BufferTransform(1); tr < 8; tr++ { | |
| 147 | + | img.HotX, img.HotY = 2, 1 | |
| 148 | + | got := rotateCursor(img, tr) | |
| 149 | + | for y := range 2 { | |
| 150 | + | for x := range 3 { | |
| 151 | + | bx, by := tr.ToBuffer(float64(x)+.5, float64(y)+.5, 3, 2) | |
| 152 | + | if v := got.Pixels[(int(by)*got.W+int(bx))*4]; v != byte(y*3+x) { | |
| 153 | + | t.Fatalf("transform %d: (%d,%d) landed with value %d", tr, x, y, v) | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | // The hotspot pixel keeps its value. | |
| 158 | + | if v := got.Pixels[(got.HotY*got.W+got.HotX)*4]; v != 5 { | |
| 159 | + | t.Fatalf("transform %d: hotspot (%d,%d) on pixel %d, want 5", tr, got.HotX, got.HotY, v) | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | // 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 { | |||
| 115 | 115 | if change.Scale < 0 || change.Scale > 4 || (change.Scale > 0 && change.Scale < 1) || math.IsNaN(change.Scale) || math.IsInf(change.Scale, 0) { | |
| 116 | 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 | 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 | 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 | 150 | if h.Scale > 0 { | |
| 148 | 151 | entry.Scale = h.Scale | |
| 149 | 152 | } | |
| 153 | + | if h.Transform != nil { | |
| 154 | + | entry.Transform = *h.Transform | |
| 155 | + | } | |
| 150 | 156 | if h.Pos != nil { | |
| 151 | 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 | 287 | a.req = nil | |
| 282 | 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 | 295 | a.reply(req.ID, nil) | |
| 286 | 296 | } | |
| @@ -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.Scale | 151 | 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 = nil | 287 | 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) { | |||
| 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 | 355 | // A runtime position never replaces a file relation: it only takes | |
| 311 | 356 | // precedence over it in core, and the relation is followed again once the | |
| 312 | 357 | // 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 takes | 355 | // A runtime position never replaces a file relation: it only takes | ||
| 311 | // precedence over it in core, and the relation is followed again once the | 356 | // 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) { | |||
| 414 | 414 | } | |
| 415 | 415 | } | |
| 416 | 416 | ||
| 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 | |
| 418 | 419 | // when the pointer is only placed again (layout or constraint change): it | |
| 419 | 420 | // neither shows an idle-hidden cursor nor delays the hide. | |
| 420 | 421 | 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 false | 417 | + | // 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): it | 419 | // 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 { | |||
| 282 | 282 | for _, s := range c.screens { | |
| 283 | 283 | c.settings(s.mon) | |
| 284 | 284 | } | |
| 285 | - | c.applyConfigScales() | |
| 285 | + | c.applyOutputConfig() | |
| 286 | 286 | return nil | |
| 287 | 287 | } | |
| 288 | 288 | ||
| @@ -549,7 +549,7 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 549 | 549 | if frame != (Rect{W: o.W, H: o.H}) { | |
| 550 | 550 | 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 | 553 | layout := sc.mon.Layout() | |
| 554 | 554 | var real map[WindowID]Placement | |
| 555 | 555 | if sc.mon.ov.open { | |
| @@ -909,12 +909,12 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||
| 909 | 909 | if c.security.Protected { | |
| 910 | 910 | return c.lockHit(x, y) | |
| 911 | 911 | } | |
| 912 | - | o, ok := c.layout().At(x, y) | |
| 913 | - | if !ok { | |
| 912 | + | i := c.screenAt(x, y) | |
| 913 | + | if i < 0 { | |
| 914 | 914 | return 0, 0, 0 | |
| 915 | 915 | } | |
| 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) | |
| 918 | 918 | // Layer popups are over everything; window popups are over the windows | |
| 919 | 919 | // only, under the top and overlay layers (as drawn). | |
| 920 | 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 | 1184 | if c.layerChanged { | |
| 1185 | 1185 | c.setLayers(c.allLayers()) | |
| 1186 | 1186 | } | |
| 1187 | + | c.cursorX, c.cursorY = c.clampPointer(c.cursorX, c.cursorY, c.cursorX, c.cursorY) | |
| 1187 | 1188 | case ports.OutputRemoved: | |
| 1188 | 1189 | // A drop target may be on the output going away. | |
| 1189 | 1190 | c.abortDrag() | |
| @@ -1191,7 +1192,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1191 | 1192 | if c.layerChanged { | |
| 1192 | 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 | 1197 | if c.workspaceVisible(ctx, false) != nil { | |
| 1197 | 1198 | return nil | |
| @@ -1214,6 +1215,8 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1214 | 1215 | } | |
| 1215 | 1216 | 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 | 1220 | if c.workspaceVisible(ctx, false) != nil { | |
| 1218 | 1221 | return nil | |
| 1219 | 1222 | } | |
| @@ -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 nil | 286 | 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 = frame | 550 | 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]Placement | 554 | 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, 0 | 914 | 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 windows | 918 | // 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 nil | 1198 | return nil | ||
| @@ -1214,6 +1215,8 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1214 | } | 1215 | } | ||
| 1215 | continue | 1216 | 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 nil | 1221 | return nil | ||
| 1219 | } | 1222 | } | ||
M internal/core/drag.go +9 -8
| @@ -269,13 +269,13 @@ func (c *Core) abortDrag() { | |||
| 269 | 269 | ||
| 270 | 270 | // dropAt is the drop target under the global point x, y. | |
| 271 | 271 | 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 { | |
| 274 | 274 | return dropTarget{} | |
| 275 | 275 | } | |
| 276 | - | sc := c.screens[c.screenIndex(o.Info.Name)] | |
| 276 | + | sc := c.screens[i] | |
| 277 | 277 | 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 | |
| 279 | 279 | w := m.Current() | |
| 280 | 280 | if m.ov.open || w.pinned() || !m.frameHas(float64(lx), float64(ly)) { | |
| 281 | 281 | return dropTarget{} | |
| @@ -514,15 +514,16 @@ func (c *Core) drop(id WindowID, t dropTarget) { | |||
| 514 | 514 | // tile drag moves the pointer against its usable edge. It reports whether | |
| 515 | 515 | // the view moved. | |
| 516 | 516 | 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 { | |
| 519 | 519 | 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 | 523 | if w.Overflow == OverflowFixed || len(w.Columns) == 0 || w.pinned() { | |
| 523 | 524 | return false | |
| 524 | 525 | } | |
| 525 | - | lx := int(c.cursorX) - o.X | |
| 526 | + | lx := int(c.cursorX) - sc.x | |
| 526 | 527 | dir := 0 | |
| 527 | 528 | switch { | |
| 528 | 529 | 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.mon | 277 | 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 |
| 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 whether | 514 | // 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 false | 519 | 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 false | 524 | return false | ||
| 524 | } | 525 | } | ||
| 525 | - | lx := int(c.cursorX) - o.X | 526 | + | lx := int(c.cursorX) - sc.x |
| 526 | dir := 0 | 527 | 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 { | |||
| 21 | 21 | c.motionMsec = v.TimeMsec | |
| 22 | 22 | // A locked pointer stays still; relative motion still flows. | |
| 23 | 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 | 26 | // The focused screen follows the pointer, so new windows | |
| 27 | 27 | // and launchers open where the user is. | |
| 28 | 28 | // Only a pointer entering another output switches: keyboard | |
| 29 | 29 | // moves to another screen stick until then. Not mid-drag of a | |
| 30 | 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 { | |
| 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 | |
| 35 | 36 | if err := c.publish(ctx); err != nil { | |
| 36 | 37 | return err | |
| 37 | 38 | } | |
| @@ -21,17 +21,18 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||||
| 21 | c.motionMsec = v.TimeMsec | 21 | 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 windows | 26 | // 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: keyboard | 28 | // Only a pointer entering another output switches: keyboard | ||
| 29 | // moves to another screen stick until then. Not mid-drag of a | 29 | // 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.Name | 32 | + | 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 err | 37 | return err | ||
| 37 | } | 38 | } | ||
M internal/core/overview.go +3 -4
| @@ -835,16 +835,15 @@ func (m *Monitor) overviewAt(x, y float64) WindowID { | |||
| 835 | 835 | // overviewClick closes the overview on the preview under the pointer, on | |
| 836 | 836 | // the output it shows on. picked is false when no preview is there. | |
| 837 | 837 | 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 { | |
| 840 | 840 | return false, nil | |
| 841 | 841 | } | |
| 842 | - | i := c.screenIndex(o.Info.Name) | |
| 843 | 842 | sc := c.screens[i] | |
| 844 | 843 | if !sc.mon.ov.open { | |
| 845 | 844 | 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 | 847 | if id == 0 { | |
| 849 | 848 | return false, nil | |
| 850 | 849 | } | |
| @@ -835,16 +835,15 @@ func (m *Monitor) overviewAt(x, y float64) WindowID { | |||||
| 835 | // overviewClick closes the overview on the preview under the pointer, on | 835 | // 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, nil | 840 | 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, nil | 844 | 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, nil | 848 | 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 { | |||
| 13 | 13 | info ports.OutputInfo | |
| 14 | 14 | mon *Monitor | |
| 15 | 15 | 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 | |
| 21 | 24 | // capture holds the attached layers of the capture exclusion: shown over | |
| 22 | 25 | // a fullscreen window (capture.go). | |
| 23 | 26 | capture map[WindowID]bool | |
| @@ -29,15 +32,28 @@ type screen struct { | |||
| 29 | 32 | ||
| 30 | 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 | 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 | 52 | // setScale resizes the logical output; layer placement follows. | |
| 38 | 53 | func (s *screen) setScale(v float64) { | |
| 39 | 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 | 57 | s.arrange() | |
| 42 | 58 | } | |
| 43 | 59 | ||
| @@ -63,8 +79,8 @@ func (c *Core) order() { | |||
| 63 | 79 | c.focusScreen = slices.Index(c.screens, focused) | |
| 64 | 80 | items := make([]placeItem, len(c.screens)) | |
| 65 | 81 | 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 | |
| 68 | 84 | if cfg, ok := c.outputConfig(s.name()); ok { | |
| 69 | 85 | items[i].pos, items[i].anchor = cfg.Pos, cfg.Anchor | |
| 70 | 86 | } | |
| @@ -107,6 +123,39 @@ func (c *Core) screenIndex(name string) int { | |||
| 107 | 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 | 159 | // screenOf returns the screen and workspace holding the window. | |
| 111 | 160 | func (c *Core) screenOf(id WindowID) (*screen, *Workspace) { | |
| 112 | 161 | for _, s := range c.screens { | |
| @@ -154,14 +203,27 @@ func (c *Core) configScale(name string) float64 { | |||
| 154 | 203 | return SnapScale(v) | |
| 155 | 204 | } | |
| 156 | 205 | ||
| 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() { | |
| 160 | 216 | 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 | |
| 164 | 224 | } | |
| 225 | + | s.cfgScale, s.transform = v, t | |
| 226 | + | s.setScale(scale) | |
| 165 | 227 | } | |
| 166 | 228 | c.order() | |
| 167 | 229 | } | |
| @@ -178,6 +240,7 @@ func (c *Core) addScreen(info ports.OutputInfo) { | |||
| 178 | 240 | s.mon.Key = info.Key() | |
| 179 | 241 | c.settings(s.mon) | |
| 180 | 242 | } | |
| 243 | + | s.transform = c.configTransform(info.Name) | |
| 181 | 244 | s.setScale(s.scale) | |
| 182 | 245 | c.order() | |
| 183 | 246 | return | |
| @@ -195,6 +258,7 @@ func (c *Core) addScreen(info ports.OutputInfo) { | |||
| 195 | 258 | s.mon.Name, s.mon.Key = info.Name, info.Key() | |
| 196 | 259 | c.settings(s.mon) | |
| 197 | 260 | s.cfgScale = c.configScale(info.Name) | |
| 261 | + | s.transform = c.configTransform(info.Name) | |
| 198 | 262 | s.setScale(s.cfgScale) | |
| 199 | 263 | s.primary = c.isPrimary(info.Name) | |
| 200 | 264 | c.order() | |
| @@ -429,11 +493,11 @@ func (c *Core) neighbor(d direction) int { | |||
| 429 | 493 | } | |
| 430 | 494 | // span is the extent of s along the move (lo, hi) and across it. | |
| 431 | 495 | span := func(s *screen) (lo, hi, clo, chi int) { | |
| 432 | - | o := s.mon.Output() | |
| 496 | + | r := s.rect() | |
| 433 | 497 | 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 | |
| 435 | 499 | } | |
| 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 | |
| 437 | 501 | } | |
| 438 | 502 | lo, hi, clo, chi := span(c.cur()) | |
| 439 | 503 | pick := func(fallback bool) int { | |
| @@ -13,11 +13,14 @@ type screen struct { | |||||
| 13 | info ports.OutputInfo | 13 | info ports.OutputInfo | ||
| 14 | mon *Monitor | 14 | mon *Monitor | ||
| 15 | scale, cfgScale float64 | 15 | scale, cfgScale float64 | ||
| 16 | - | primary bool | 16 | + | // transform is how the physical target holds the screen's logical |
| 17 | - | x, y int // logical origin in the global layout | 17 | + | // 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.LayerSurface | 19 | + | primary bool |
| 20 | - | placed []ports.SceneLayer | 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 | |||
| 21 | // capture holds the attached layers of the capture exclusion: shown over | 24 | // 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]bool | 26 | 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.H | 83 | + | 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.Anchor | 85 | 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 the | 206 | + | // 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 = v | 218 | + | 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 | return | 246 | 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.W | 498 | + | 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.H | 500 | + | 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) { | |||
| 1409 | 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 | + | } | |
| @@ -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 { | |||
| 166 | 166 | } | |
| 167 | 167 | ||
| 168 | 168 | 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) | |
| 174 | 170 | if i < 0 { | |
| 175 | 171 | return 0, 0, 0 | |
| 176 | 172 | } | |
| 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) | |
| 179 | 176 | } | |
| 180 | 177 | return 0, 0, 0 | |
| 181 | 178 | } | |
| @@ -189,7 +186,7 @@ func (c *Core) publishProtected(ctx context.Context) error { | |||
| 189 | 186 | for _, sc := range c.screens { | |
| 190 | 187 | c.seq++ | |
| 191 | 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 | 190 | if s, ok := c.lockSurface(sc); ok { | |
| 194 | 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 | 251 | case ports.PointerMotion: | |
| 255 | 252 | 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 | 254 | id, x, y := c.lockHit(c.cursorX, c.cursorY) | |
| 258 | 255 | if id != c.pointer { | |
| 259 | 256 | 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, 0 | 171 | 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, 0 | 177 | 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.TimeMsec | 252 | 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 = id | 256 | c.pointer = id | ||
M internal/ports/client_commands.go +16 -1
| @@ -48,11 +48,15 @@ type ConfigureWindow struct { | |||
| 48 | 48 | func (ConfigureWindow) clientCommand() {} | |
| 49 | 49 | ||
| 50 | 50 | // 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 | 54 | type OutputPlacement struct { | |
| 53 | 55 | Info OutputInfo | |
| 54 | 56 | X, Y, Width, Height int | |
| 55 | 57 | Scale float64 | |
| 58 | + | // Transform is how the target holds the scene (wl_output.transform). | |
| 59 | + | Transform BufferTransform | |
| 56 | 60 | // Primary is output.<name>.primary: the pointer starts on it. | |
| 57 | 61 | Primary bool | |
| 58 | 62 | } | |
| @@ -62,6 +66,17 @@ func (o OutputPlacement) Contains(x, y float64) bool { | |||
| 62 | 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 | 80 | // Layout is the global arrangement of outputs in logical pixels. | |
| 66 | 81 | type Layout []OutputPlacement | |
| 67 | 82 | ||
| @@ -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 and | 50 | // 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 OutputInfo | 55 | Info OutputInfo | ||
| 54 | X, Y, Width, Height int | 56 | X, Y, Width, Height int | ||
| 55 | Scale float64 | 57 | 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 bool | 61 | 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 []OutputPlacement | 81 | type Layout []OutputPlacement | ||
| 67 | 82 | ||||
M internal/ports/config.go +4 -1
| @@ -17,7 +17,8 @@ const DefaultSDRBrightness = 203 | |||
| 17 | 17 | // everything placed so far (never left of 0), at y=0, in config order, then in | |
| 18 | 18 | // connection order. | |
| 19 | 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 | 22 | // Primary gets the focus and the pointer at startup, wherever it is placed. | |
| 22 | 23 | // ScaleOnly marks an entry set only by output.<name> subkeys: it | |
| 23 | 24 | // does not select the connector. | |
| @@ -26,6 +27,8 @@ type OutputConfig struct { | |||
| 26 | 27 | Mode string | |
| 27 | 28 | Off bool | |
| 28 | 29 | Scale float64 | |
| 30 | + | // Transform is output.<name>.transform; logical size swaps for 90/270. | |
| 31 | + | Transform BufferTransform | |
| 29 | 32 | // Pos is an explicit logical placement set at runtime; nil uses Anchor, else automatic layout. | |
| 30 | 33 | Pos *image.Point | |
| 31 | 34 | // 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 in | 17 | // 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: it | 23 | // 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 string | 27 | Mode string | ||
| 27 | Off bool | 28 | Off bool | ||
| 28 | Scale float64 | 29 | 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.Point | 33 | 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 { | |||
| 53 | 53 | Mode *OutputMode | |
| 54 | 54 | Pos *image.Point | |
| 55 | 55 | Scale float64 | |
| 56 | + | // Transform is nil to leave the transform unchanged. | |
| 57 | + | Transform *BufferTransform | |
| 56 | 58 | } | |
| 57 | 59 | ||
| 58 | 60 | // OutputApply asks the app owner to validate or apply a runtime configuration. | |
| @@ -53,6 +53,8 @@ type HeadChange struct { | |||||
| 53 | Mode *OutputMode | 53 | Mode *OutputMode | ||
| 54 | Pos *image.Point | 54 | Pos *image.Point | ||
| 55 | Scale float64 | 55 | 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 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | - | import "os" | |
| 3 | + | import ( | |
| 4 | + | "image" | |
| 5 | + | "math" | |
| 6 | + | "os" | |
| 7 | + | ) | |
| 4 | 8 | ||
| 5 | 9 | // Scene carries core → renderer immutable snapshots with fresh Windows slices, | |
| 6 | 10 | // one per output. Rects and the output size are logical and local to the | |
| @@ -14,6 +18,10 @@ type Scene struct { | |||
| 14 | 18 | Seq uint64 | |
| 15 | 19 | OutputWidth, OutputHeight int | |
| 16 | 20 | 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 | 25 | // Off turns the display off (output power management): nothing is | |
| 18 | 26 | // drawn until a scene without it. | |
| 19 | 27 | Off bool | |
| @@ -202,6 +210,15 @@ type BufferTransform uint8 | |||
| 202 | 210 | // Rotated reports whether width and height swap between buffer and surface. | |
| 203 | 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 | 222 | // ToBuffer maps a point of a w×h surface (logical, before scale) to the | |
| 206 | 223 | // same point of its buffer, in the buffer's untransformed w'×h' axes, where | |
| 207 | 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 | 237 | 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 | 257 | // SeqDamage is what content Seq changed from the one before: Rects in | |
| 224 | 258 | // root buffer pixels, or everything when Full. | |
| 225 | 259 | type SeqDamage struct { | |
| @@ -1,6 +1,10 @@ | |||||
| 1 | package ports | 1 | 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 the | 10 | // one per output. Rects and the output size are logical and local to the | ||
| @@ -14,6 +18,10 @@ type Scene struct { | |||||
| 14 | Seq uint64 | 18 | Seq uint64 | ||
| 15 | OutputWidth, OutputHeight int | 19 | OutputWidth, OutputHeight int | ||
| 16 | Scale float64 | 20 | 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 is | 25 | // 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 bool | 27 | 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 the | 222 | // 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, where | 223 | // 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, y | 237 | 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 in | 257 | // 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 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | - | import "testing" | |
| 3 | + | import ( | |
| 4 | + | "image" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | ) | |
| 4 | 9 | ||
| 5 | 10 | // ToBuffer matches wl_output.transform: the buffer holds the surface rotated | |
| 6 | 11 | // 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 | + | } | |
| @@ -1,6 +1,11 @@ | |||||
| 1 | package ports | 1 | 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 rotated | 10 | // ToBuffer matches wl_output.transform: the buffer holds the surface rotated | ||
| 6 | // counter-clockwise by the transform (after a flip around the vertical axis | 11 | // 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 | + | } | |||