detached c1a5a02

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

commit
c1a5a0281ba16ac1bfe91c21979e9b29bedf24ff
author
brice · · 2026-10-03 18:38 UTC
parent
b5d00e8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
457457 entry().Scale = s
458458 continue
459459 }
460+ if base, ok := strings.CutSuffix(name, ".transform"); ok {
461+ name = base
462+ t, err := parseTransform(value)
463+ if err != nil {
464+ warn("%s: %v", key, err)
465+ continue
466+ }
467+ override()
468+ entry().Transform = t
469+ continue
470+ }
460471 o := ports.OutputConfig{Name: name}
461472 if value == "off" {
462473 o.Off = true
@@ -468,7 +479,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
468479 }
469480 override()
470481 e := entry()
471- o.Scale, o.Primary, o.HDR, o.SDRBrightness, o.Anchor = e.Scale, e.Primary, e.HDR, e.SDRBrightness, e.Anchor
482+ o.Scale, o.Transform, o.Primary, o.HDR, o.SDRBrightness, o.Anchor = e.Scale, e.Transform, e.Primary, e.HDR, e.SDRBrightness, e.Anchor
472483 *e = o
473484 continue
474485 }
@@ -1142,6 +1153,29 @@ func keysym(k string) (string, error) {
11421153 return k, nil
11431154 }
11441155
1156+// parseTransform accepts the wl_output.transform names.
1157+func parseTransform(v string) (ports.BufferTransform, error) {
1158+ switch v {
1159+ case "normal":
1160+ return 0, nil
1161+ case "90":
1162+ return 1, nil
1163+ case "180":
1164+ return 2, nil
1165+ case "270":
1166+ return 3, nil
1167+ case "flipped":
1168+ return 4, nil
1169+ case "flipped-90":
1170+ return 5, nil
1171+ case "flipped-180":
1172+ return 6, nil
1173+ case "flipped-270":
1174+ return 7, nil
1175+ }
1176+ return 0, fmt.Errorf("invalid transform %q (normal, 90, 180, 270, flipped, flipped-90, flipped-180, flipped-270)", v)
1177+}
1178+
11451179 // parseScale accepts a decimal (1.5) or a fraction (4/3) between 1 and 4.
11461180 func parseScale(v string) (float64, error) {
11471181 var s float64
@@ -457,6 +457,17 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
457 entry().Scale = s457 entry().Scale = s
458 continue458 continue
459 }459 }
460+ if base, ok := strings.CutSuffix(name, ".transform"); ok {
461+ name = base
462+ t, err := parseTransform(value)
463+ if err != nil {
464+ warn("%s: %v", key, err)
465+ continue
466+ }
467+ override()
468+ entry().Transform = t
469+ continue
470+ }
460 o := ports.OutputConfig{Name: name}471 o := ports.OutputConfig{Name: name}
461 if value == "off" {472 if value == "off" {
462 o.Off = true473 o.Off = true
@@ -468,7 +479,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
468 }479 }
469 override()480 override()
470 e := entry()481 e := entry()
471- o.Scale, o.Primary, o.HDR, o.SDRBrightness, o.Anchor = e.Scale, e.Primary, e.HDR, e.SDRBrightness, e.Anchor482+ o.Scale, o.Transform, o.Primary, o.HDR, o.SDRBrightness, o.Anchor = e.Scale, e.Transform, e.Primary, e.HDR, e.SDRBrightness, e.Anchor
472 *e = o483 *e = o
473 continue484 continue
474 }485 }
@@ -1142,6 +1153,29 @@ func keysym(k string) (string, error) {
1142 return k, nil1153 return k, nil
1143 }1154 }
1144 1155
1156+// parseTransform accepts the wl_output.transform names.
1157+func parseTransform(v string) (ports.BufferTransform, error) {
1158+ switch v {
1159+ case "normal":
1160+ return 0, nil
1161+ case "90":
1162+ return 1, nil
1163+ case "180":
1164+ return 2, nil
1165+ case "270":
1166+ return 3, nil
1167+ case "flipped":
1168+ return 4, nil
1169+ case "flipped-90":
1170+ return 5, nil
1171+ case "flipped-180":
1172+ return 6, nil
1173+ case "flipped-270":
1174+ return 7, nil
1175+ }
1176+ return 0, fmt.Errorf("invalid transform %q (normal, 90, 180, 270, flipped, flipped-90, flipped-180, flipped-270)", v)
1177+}
1178+
1145 // parseScale accepts a decimal (1.5) or a fraction (4/3) between 1 and 4.1179 // parseScale accepts a decimal (1.5) or a fraction (4/3) between 1 and 4.
1146 func parseScale(v string) (float64, error) {1180 func parseScale(v string) (float64, error) {
1147 var s float641181 var s float64
M internal/adapters/config/config_test.go +32 -0
@@ -1,6 +1,7 @@
11 package config
22
33 import (
4+ "fmt"
45 "os"
56 "path/filepath"
67 "reflect"
@@ -640,6 +641,37 @@ output.DP-5.offset = 7
640641 }
641642 }
642643
644+func TestOutputTransform(t *testing.T) {
645+ names := []string{"normal", "90", "180", "270", "flipped", "flipped-90", "flipped-180", "flipped-270"}
646+ var text strings.Builder
647+ for i, n := range names {
648+ fmt.Fprintf(&text, "output.O%d.transform = %s\n", i, n)
649+ }
650+ c, w := parseString(t, text.String())
651+ if len(w) != 0 || len(c.Outputs) != len(names) {
652+ t.Fatalf("%v %+v", w, c.Outputs)
653+ }
654+ for i, o := range c.Outputs {
655+ if o.Transform != ports.BufferTransform(i) || !o.ScaleOnly {
656+ t.Fatalf("%s: %+v", names[i], o)
657+ }
658+ }
659+
660+ c, w = parseString(t, "output.DP-1.transform = 45\n")
661+ if len(w) != 1 || !strings.Contains(w[0].Msg, "invalid transform") {
662+ t.Fatalf("warnings %v", w)
663+ }
664+ if len(c.Outputs) != 0 {
665+ t.Fatalf("%+v", c.Outputs)
666+ }
667+
668+ // A later mode line keeps the transform.
669+ c, w = parseString(t, "output.DP-1.transform = 90\noutput.DP-1 = 1920x1080\n")
670+ if len(w) != 0 || len(c.Outputs) != 1 || c.Outputs[0].Transform != 1 || c.Outputs[0].ScaleOnly {
671+ t.Fatalf("%v %+v", w, c.Outputs)
672+ }
673+}
674+
643675 func TestOutputRelationOverride(t *testing.T) {
644676 c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.below = DP-3\n")
645677 if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "overrides line 1") {
@@ -1,6 +1,7 @@
1 package config1 package config
2 2
3 import (3 import (
4+ "fmt"
4 "os"5 "os"
5 "path/filepath"6 "path/filepath"
6 "reflect"7 "reflect"
@@ -640,6 +641,37 @@ output.DP-5.offset = 7
640 }641 }
641 }642 }
642 643
644+func TestOutputTransform(t *testing.T) {
645+ names := []string{"normal", "90", "180", "270", "flipped", "flipped-90", "flipped-180", "flipped-270"}
646+ var text strings.Builder
647+ for i, n := range names {
648+ fmt.Fprintf(&text, "output.O%d.transform = %s\n", i, n)
649+ }
650+ c, w := parseString(t, text.String())
651+ if len(w) != 0 || len(c.Outputs) != len(names) {
652+ t.Fatalf("%v %+v", w, c.Outputs)
653+ }
654+ for i, o := range c.Outputs {
655+ if o.Transform != ports.BufferTransform(i) || !o.ScaleOnly {
656+ t.Fatalf("%s: %+v", names[i], o)
657+ }
658+ }
659+
660+ c, w = parseString(t, "output.DP-1.transform = 45\n")
661+ if len(w) != 1 || !strings.Contains(w[0].Msg, "invalid transform") {
662+ t.Fatalf("warnings %v", w)
663+ }
664+ if len(c.Outputs) != 0 {
665+ t.Fatalf("%+v", c.Outputs)
666+ }
667+
668+ // A later mode line keeps the transform.
669+ c, w = parseString(t, "output.DP-1.transform = 90\noutput.DP-1 = 1920x1080\n")
670+ if len(w) != 0 || len(c.Outputs) != 1 || c.Outputs[0].Transform != 1 || c.Outputs[0].ScaleOnly {
671+ t.Fatalf("%v %+v", w, c.Outputs)
672+ }
673+}
674+
643 func TestOutputRelationOverride(t *testing.T) {675 func TestOutputRelationOverride(t *testing.T) {
644 c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.below = DP-3\n")676 c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.below = DP-3\n")
645 if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "overrides line 1") {677 if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "overrides line 1") {
M internal/app/outputmanagement_test.go +13 -0
@@ -286,6 +286,19 @@ func TestOutputApplyAnsweredAtOnce(t *testing.T) {
286286
287287 // A reload that only changes output scales (a saved scale bind) keeps the
288288 // 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+
289302 func TestReloadScaleOnlyKeepsOverrides(t *testing.T) {
290303 mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
291304 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 the287 // 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 {
4848 func (ConfigureWindow) clientCommand() {}
4949
5050 // OutputPlacement is one output in the global layout. X, Y, Width and
51-// Height are logical; physical = logical × Scale.
51+// Height are logical (after Transform: they swap for 90/270);
52+// physical = logical × Scale. Info.Width and Info.Height are the physical
53+// mode, the untransformed target.
5254 type OutputPlacement struct {
5355 Info OutputInfo
5456 X, Y, Width, Height int
5557 Scale float64
58+ // Transform is how the target holds the scene (wl_output.transform).
59+ Transform BufferTransform
5660 // Primary is output.<name>.primary: the pointer starts on it.
5761 Primary bool
5862 }
@@ -62,6 +66,20 @@ func (o OutputPlacement) Contains(x, y float64) bool {
6266 return x >= float64(o.X) && x < float64(o.X+o.Width) && y >= float64(o.Y) && y < float64(o.Y+o.Height)
6367 }
6468
69+// ToTarget maps a global logical point on the output to target pixels.
70+func (o OutputPlacement) ToTarget(x, y float64) (float64, float64) {
71+ s := o.Scale
72+ if s <= 0 {
73+ s = 1
74+ }
75+ px, py := (x-float64(o.X))*s, (y-float64(o.Y))*s
76+ sw, sh := 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+
6583 // Layout is the global arrangement of outputs in logical pixels.
6684 type Layout []OutputPlacement
6785
@@ -48,11 +48,15 @@ type ConfigureWindow struct {
48 func (ConfigureWindow) clientCommand() {}48 func (ConfigureWindow) clientCommand() {}
49 49
50 // OutputPlacement is one output in the global layout. X, Y, Width and50 // OutputPlacement is one output in the global layout. X, Y, Width and
51-// Height are logical; physical = logical × Scale.51+// Height are logical (after Transform: they swap for 90/270);
52+// physical = logical × Scale. Info.Width and Info.Height are the physical
53+// mode, the untransformed target.
52 type OutputPlacement struct {54 type OutputPlacement struct {
53 Info OutputInfo55 Info OutputInfo
54 X, Y, Width, Height int56 X, Y, Width, Height int
55 Scale float6457 Scale float64
58+ // Transform is how the target holds the scene (wl_output.transform).
59+ Transform BufferTransform
56 // Primary is output.<name>.primary: the pointer starts on it.60 // Primary is output.<name>.primary: the pointer starts on it.
57 Primary bool61 Primary bool
58 }62 }
@@ -62,6 +66,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 []OutputPlacement84 type Layout []OutputPlacement
67 85
M internal/ports/config.go +4 -1
@@ -17,7 +17,8 @@ const DefaultSDRBrightness = 203
1717 // everything placed so far (never left of 0), at y=0, in config order, then in
1818 // connection order.
1919 // Scale is the output scale (0 means 1); layout works in logical pixels,
20-// physical = logical × Scale.
20+// physical = logical × Scale. Placement and offsets use the transformed
21+// logical size.
2122 // Primary gets the focus and the pointer at startup, wherever it is placed.
2223 // ScaleOnly marks an entry set only by output.<name> subkeys: it
2324 // does not select the connector.
@@ -26,6 +27,8 @@ type OutputConfig struct {
2627 Mode string
2728 Off bool
2829 Scale float64
30+ // Transform is output.<name>.transform; logical size swaps for 90/270.
31+ Transform BufferTransform
2932 // Pos is an explicit logical placement set at runtime; nil uses Anchor, else automatic layout.
3033 Pos *image.Point
3134 // Anchor places the output against another connected one.
@@ -17,7 +17,8 @@ const DefaultSDRBrightness = 203
17 // everything placed so far (never left of 0), at y=0, in config order, then in17 // everything placed so far (never left of 0), at y=0, in config order, then in
18 // connection order.18 // connection order.
19 // Scale is the output scale (0 means 1); layout works in logical pixels,19 // Scale is the output scale (0 means 1); layout works in logical pixels,
20-// physical = logical × Scale.20+// physical = logical × Scale. Placement and offsets use the transformed
21+// logical size.
21 // Primary gets the focus and the pointer at startup, wherever it is placed.22 // Primary gets the focus and the pointer at startup, wherever it is placed.
22 // ScaleOnly marks an entry set only by output.<name> subkeys: it23 // ScaleOnly marks an entry set only by output.<name> subkeys: it
23 // does not select the connector.24 // does not select the connector.
@@ -26,6 +27,8 @@ type OutputConfig struct {
26 Mode string27 Mode string
27 Off bool28 Off bool
28 Scale float6429 Scale float64
30+ // Transform is output.<name>.transform; logical size swaps for 90/270.
31+ Transform BufferTransform
29 // Pos is an explicit logical placement set at runtime; nil uses Anchor, else automatic layout.32 // Pos is an explicit logical placement set at runtime; nil uses Anchor, else automatic layout.
30 Pos *image.Point33 Pos *image.Point
31 // Anchor places the output against another connected one.34 // Anchor places the output against another connected one.
M internal/ports/output_events.go +2 -0
@@ -53,6 +53,8 @@ type HeadChange struct {
5353 Mode *OutputMode
5454 Pos *image.Point
5555 Scale float64
56+ // Transform is nil to leave the transform unchanged.
57+ Transform *BufferTransform
5658 }
5759
5860 // OutputApply asks the app owner to validate or apply a runtime configuration.
@@ -53,6 +53,8 @@ type HeadChange struct {
53 Mode *OutputMode53 Mode *OutputMode
54 Pos *image.Point54 Pos *image.Point
55 Scale float6455 Scale float64
56+ // Transform is nil to leave the transform unchanged.
57+ Transform *BufferTransform
56 }58 }
57 59
58 // OutputApply asks the app owner to validate or apply a runtime configuration.60 // OutputApply asks the app owner to validate or apply a runtime configuration.
M internal/ports/scene.go +26 -1
@@ -1,6 +1,10 @@
11 package ports
22
3-import "os"
3+import (
4+ "image"
5+ "math"
6+ "os"
7+)
48
59 // Scene carries core → renderer immutable snapshots with fresh Windows slices,
610 // one per output. Rects and the output size are logical and local to the
@@ -14,6 +18,10 @@ type Scene struct {
1418 Seq uint64
1519 OutputWidth, OutputHeight int
1620 Scale float64
21+ // Transform is how the output target holds this scene
22+ // (wl_output.transform); 0 for capture child scenes. OutputWidth and
23+ // OutputHeight are already in the transformed (logical) orientation.
24+ Transform BufferTransform
1725 // Off turns the display off (output power management): nothing is
1826 // drawn until a scene without it.
1927 Off bool
@@ -220,6 +228,23 @@ func (t BufferTransform) ToBuffer(x, y, w, h float64) (float64, float64) {
220228 return x, y
221229 }
222230
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+
223248 // SeqDamage is what content Seq changed from the one before: Rects in
224249 // root buffer pixels, or everything when Full.
225250 type SeqDamage struct {
@@ -1,6 +1,10 @@
1 package ports1 package ports
2 2
3-import "os"3+import (
4+ "image"
5+ "math"
6+ "os"
7+)
4 8
5 // Scene carries core → renderer immutable snapshots with fresh Windows slices,9 // Scene carries core → renderer immutable snapshots with fresh Windows slices,
6 // one per output. Rects and the output size are logical and local to the10 // one per output. Rects and the output size are logical and local to the
@@ -14,6 +18,10 @@ type Scene struct {
14 Seq uint6418 Seq uint64
15 OutputWidth, OutputHeight int19 OutputWidth, OutputHeight int
16 Scale float6420 Scale float64
21+ // Transform is how the output target holds this scene
22+ // (wl_output.transform); 0 for capture child scenes. OutputWidth and
23+ // OutputHeight are already in the transformed (logical) orientation.
24+ Transform BufferTransform
17 // Off turns the display off (output power management): nothing is25 // Off turns the display off (output power management): nothing is
18 // drawn until a scene without it.26 // drawn until a scene without it.
19 Off bool27 Off bool
@@ -220,6 +228,23 @@ func (t BufferTransform) ToBuffer(x, y, w, h float64) (float64, float64) {
220 return x, y228 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 in248 // 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 @@
11 package ports
22
3-import "testing"
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+)
49
510 // ToBuffer matches wl_output.transform: the buffer holds the surface rotated
611 // counter-clockwise by the transform (after a flip around the vertical axis
@@ -34,3 +39,38 @@ func TestBufferTransformToBuffer(t *testing.T) {
3439 }
3540 }
3641 }
42+
43+func TestBufferTransformInvert(t *testing.T) {
44+ const w, h = 7.0, 3.0
45+ for tr := BufferTransform(0); tr < 8; tr++ {
46+ assert.Equal(t, tr, tr.Invert().Invert(), "transform %d", tr)
47+ bw, bh := w, h
48+ if tr.Rotated() {
49+ bw, bh = h, w
50+ }
51+ for _, p := range [][2]float64{{0, 0}, {1.5, 1}, {6.25, 2.5}} {
52+ bx, by := tr.ToBuffer(p[0], p[1], w, h)
53+ x, y := tr.Invert().ToBuffer(bx, by, bw, bh)
54+ assert.InDelta(t, p[0], x, 1e-9, "transform %d point %v", tr, p)
55+ assert.InDelta(t, p[1], y, 1e-9, "transform %d point %v", tr, p)
56+ }
57+ }
58+}
59+
60+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 ports1 package ports
2 2
3-import "testing"3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+)
4 9
5 // ToBuffer matches wl_output.transform: the buffer holds the surface rotated10 // ToBuffer matches wl_output.transform: the buffer holds the surface rotated
6 // counter-clockwise by the transform (after a flip around the vertical axis11 // counter-clockwise by the transform (after a flip around the vertical axis
@@ -34,3 +39,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+}

Keyboard shortcuts

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

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

Branches 23

Tags 10

↑↓ moveenter openesc close