feat(config): output.<name>.transform
- commit
- c1a5a0281ba16ac1bfe91c21979e9b29bedf24ff
- author
- brice · · 2026-10-03 18:38 UTC
- parent
- b5d00e8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +172 -5
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/app/outputmanagement_test.go +13 -0
| @@ -286,6 +286,19 @@ func TestOutputApplyAnsweredAtOnce(t *testing.T) { | |||
| 286 | 286 | ||
| 287 | 287 | // A reload that only changes output scales (a saved scale bind) keeps the | |
| 288 | 288 | // runtime overrides; any other change drops them. | |
| 289 | + | func TestScaleChangesTransform(t *testing.T) { | |
| 290 | + | old := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}} | |
| 291 | + | cur := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2, Transform: 1}}} | |
| 292 | + | if _, only := scaleChanges(old, cur); only { | |
| 293 | + | t.Fatal("a transform change must take the full reload path") | |
| 294 | + | } | |
| 295 | + | cur.Outputs[0].Transform = 0 | |
| 296 | + | cur.Outputs[0].Scale = 1.5 | |
| 297 | + | if _, only := scaleChanges(old, cur); !only { | |
| 298 | + | t.Fatal("a scale-only change stays a scale change") | |
| 299 | + | } | |
| 300 | + | } | |
| 301 | + | ||
| 289 | 302 | func TestReloadScaleOnlyKeepsOverrides(t *testing.T) { | |
| 290 | 303 | mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000} | |
| 291 | 304 | file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}} | |
| @@ -286,6 +286,19 @@ func TestOutputApplyAnsweredAtOnce(t *testing.T) { | |||||
| 286 | 286 | ||||
| 287 | // A reload that only changes output scales (a saved scale bind) keeps the | 287 | // A reload that only changes output scales (a saved scale bind) keeps the | ||
| 288 | // runtime overrides; any other change drops them. | 288 | // runtime overrides; any other change drops them. | ||
| 289 | + | func TestScaleChangesTransform(t *testing.T) { | |||
| 290 | + | old := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}} | |||
| 291 | + | cur := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2, Transform: 1}}} | |||
| 292 | + | if _, only := scaleChanges(old, cur); only { | |||
| 293 | + | t.Fatal("a transform change must take the full reload path") | |||
| 294 | + | } | |||
| 295 | + | cur.Outputs[0].Transform = 0 | |||
| 296 | + | cur.Outputs[0].Scale = 1.5 | |||
| 297 | + | if _, only := scaleChanges(old, cur); !only { | |||
| 298 | + | t.Fatal("a scale-only change stays a scale change") | |||
| 299 | + | } | |||
| 300 | + | } | |||
| 301 | + | ||||
| 289 | func TestReloadScaleOnlyKeepsOverrides(t *testing.T) { | 302 | func TestReloadScaleOnlyKeepsOverrides(t *testing.T) { | ||
| 290 | mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000} | 303 | mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000} | ||
| 291 | file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}} | 304 | file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}} | ||
M internal/ports/client_commands.go +19 -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,20 @@ 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 := float64(o.Info.Width), float64(o.Info.Height) | |
| 77 | + | if o.Transform.Rotated() { | |
| 78 | + | sw, sh = sh, sw | |
| 79 | + | } | |
| 80 | + | return o.Transform.ToBuffer(px, py, sw, sh) | |
| 81 | + | } | |
| 82 | + | ||
| 65 | 83 | // Layout is the global arrangement of outputs in logical pixels. | |
| 66 | 84 | type Layout []OutputPlacement | |
| 67 | 85 | ||
| @@ -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,20 @@ 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 := float64(o.Info.Width), float64(o.Info.Height) | |||
| 77 | + | if o.Transform.Rotated() { | |||
| 78 | + | sw, sh = sh, sw | |||
| 79 | + | } | |||
| 80 | + | return o.Transform.ToBuffer(px, py, sw, sh) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 65 | // Layout is the global arrangement of outputs in logical pixels. | 83 | // Layout is the global arrangement of outputs in logical pixels. | ||
| 66 | type Layout []OutputPlacement | 84 | type Layout []OutputPlacement | ||
| 67 | 85 | ||||
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 +26 -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 | |
| @@ -220,6 +228,23 @@ func (t BufferTransform) ToBuffer(x, y, w, h float64) (float64, float64) { | |||
| 220 | 228 | return x, y | |
| 221 | 229 | } | |
| 222 | 230 | ||
| 231 | + | // Invert is the transform that maps target points back: for a w×h scene and | |
| 232 | + | // its w'×h' target, t.Invert().ToBuffer(t.ToBuffer(p, w, h), w', h') == p. | |
| 233 | + | func (t BufferTransform) Invert() BufferTransform { | |
| 234 | + | if t&1 != 0 && t&4 == 0 { | |
| 235 | + | return t ^ 2 | |
| 236 | + | } | |
| 237 | + | return t | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // RectToBuffer maps a rectangle of a w×h surface to the buffer. Integer | |
| 241 | + | // rectangles stay integer under these transforms; rounding absorbs float noise. | |
| 242 | + | func (t BufferTransform) RectToBuffer(r image.Rectangle, w, h int) image.Rectangle { | |
| 243 | + | ax, ay := t.ToBuffer(float64(r.Min.X), float64(r.Min.Y), float64(w), float64(h)) | |
| 244 | + | bx, by := t.ToBuffer(float64(r.Max.X), float64(r.Max.Y), float64(w), float64(h)) | |
| 245 | + | return image.Rect(int(math.Round(ax)), int(math.Round(ay)), int(math.Round(bx)), int(math.Round(by))) | |
| 246 | + | } | |
| 247 | + | ||
| 223 | 248 | // SeqDamage is what content Seq changed from the one before: Rects in | |
| 224 | 249 | // root buffer pixels, or everything when Full. | |
| 225 | 250 | 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 | ||
| @@ -220,6 +228,23 @@ func (t BufferTransform) ToBuffer(x, y, w, h float64) (float64, float64) { | |||||
| 220 | return x, y | 228 | return x, y | ||
| 221 | } | 229 | } | ||
| 222 | 230 | ||||
| 231 | + | // Invert is the transform that maps target points back: for a w×h scene and | |||
| 232 | + | // its w'×h' target, t.Invert().ToBuffer(t.ToBuffer(p, w, h), w', h') == p. | |||
| 233 | + | func (t BufferTransform) Invert() BufferTransform { | |||
| 234 | + | if t&1 != 0 && t&4 == 0 { | |||
| 235 | + | return t ^ 2 | |||
| 236 | + | } | |||
| 237 | + | return t | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | // RectToBuffer maps a rectangle of a w×h surface to the buffer. Integer | |||
| 241 | + | // rectangles stay integer under these transforms; rounding absorbs float noise. | |||
| 242 | + | func (t BufferTransform) RectToBuffer(r image.Rectangle, w, h int) image.Rectangle { | |||
| 243 | + | ax, ay := t.ToBuffer(float64(r.Min.X), float64(r.Min.Y), float64(w), float64(h)) | |||
| 244 | + | bx, by := t.ToBuffer(float64(r.Max.X), float64(r.Max.Y), float64(w), float64(h)) | |||
| 245 | + | return image.Rect(int(math.Round(ax)), int(math.Round(ay)), int(math.Round(bx)), int(math.Round(by))) | |||
| 246 | + | } | |||
| 247 | + | ||||
| 223 | // SeqDamage is what content Seq changed from the one before: Rects in | 248 | // SeqDamage is what content Seq changed from the one before: Rects in | ||
| 224 | // root buffer pixels, or everything when Full. | 249 | // root buffer pixels, or everything when Full. | ||
| 225 | type SeqDamage struct { | 250 | type SeqDamage struct { | ||
M internal/ports/transform_test.go +41 -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,38 @@ 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 | + | func TestRectToBuffer(t *testing.T) { | |
| 61 | + | r := image.Rect(0, 0, 2, 1) | |
| 62 | + | assert.Equal(t, image.Rect(0, 5, 1, 7), BufferTransform(1).RectToBuffer(r, 7, 3)) | |
| 63 | + | assert.Equal(t, r, BufferTransform(0).RectToBuffer(r, 7, 3)) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestOutputPlacementToTarget(t *testing.T) { | |
| 67 | + | o := OutputPlacement{Info: OutputInfo{Width: 2560, Height: 1440}, Scale: 1, X: 100, Transform: 1} | |
| 68 | + | x, y := o.ToTarget(100, 0) | |
| 69 | + | wx, wy := BufferTransform(1).ToBuffer(0, 0, 1440, 2560) | |
| 70 | + | assert.Equal(t, [2]float64{wx, wy}, [2]float64{x, y}) | |
| 71 | + | assert.Equal(t, [2]float64{0, 1440}, [2]float64{x, y}) | |
| 72 | + | ||
| 73 | + | o.Transform = 0 | |
| 74 | + | x, y = o.ToTarget(150, 20) | |
| 75 | + | assert.Equal(t, [2]float64{50, 20}, [2]float64{x, y}) | |
| 76 | + | } | |
| @@ -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,38 @@ 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 | + | func TestRectToBuffer(t *testing.T) { | |||
| 61 | + | r := image.Rect(0, 0, 2, 1) | |||
| 62 | + | assert.Equal(t, image.Rect(0, 5, 1, 7), BufferTransform(1).RectToBuffer(r, 7, 3)) | |||
| 63 | + | assert.Equal(t, r, BufferTransform(0).RectToBuffer(r, 7, 3)) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestOutputPlacementToTarget(t *testing.T) { | |||
| 67 | + | o := OutputPlacement{Info: OutputInfo{Width: 2560, Height: 1440}, Scale: 1, X: 100, Transform: 1} | |||
| 68 | + | x, y := o.ToTarget(100, 0) | |||
| 69 | + | wx, wy := BufferTransform(1).ToBuffer(0, 0, 1440, 2560) | |||
| 70 | + | assert.Equal(t, [2]float64{wx, wy}, [2]float64{x, y}) | |||
| 71 | + | assert.Equal(t, [2]float64{0, 1440}, [2]float64{x, y}) | |||
| 72 | + | ||||
| 73 | + | o.Transform = 0 | |||
| 74 | + | x, y = o.ToTarget(150, 20) | |||
| 75 | + | assert.Equal(t, [2]float64{50, 20}, [2]float64{x, y}) | |||
| 76 | + | } | |||