1package app
2
3import (
4 "encoding/binary"
5 "os"
6 "path/filepath"
7 "testing"
8
9 "github.com/bnema/neferwl/internal/ports"
10)
11
12// themeCursor writes a one-image Xcursor file of side size under
13// dir/theme/cursors/name, every pixel set to px.
14func themeCursor(t *testing.T, dir, name string, size int, px byte) {
15 t.Helper()
16 le := binary.LittleEndian
17 out := le.AppendUint32([]byte("Xcur"), 16)
18 out = le.AppendUint32(out, 0x10000)
19 out = le.AppendUint32(out, 1)
20 for _, v := range []int{0xfffd0002, size, 28, 36, 0xfffd0002, size, 1, size, size, 0, 0, 0} {
21 out = le.AppendUint32(out, uint32(v))
22 }
23 for range size * size * 4 {
24 out = append(out, px)
25 }
26 path := filepath.Join(dir, "theme", "cursors", name)
27 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
28 t.Fatal(err)
29 }
30 if err := os.WriteFile(path, out, 0o644); err != nil {
31 t.Fatal(err)
32 }
33}
34
35// resetThemeCursors empties the theme cache now and after the test.
36func resetThemeCursors(t *testing.T) {
37 reset := func() {
38 themeCursors.Lock()
39 clear(themeCursors.m)
40 themeCursors.Unlock()
41 }
42 reset()
43 t.Cleanup(reset)
44}
45
46func TestLoadCursorShapes(t *testing.T) {
47 dir := t.TempDir()
48 t.Setenv("XCURSOR_PATH", dir)
49 t.Setenv("XCURSOR_THEME", "theme")
50 t.Setenv("XCURSOR_SIZE", "24")
51 resetThemeCursors(t)
52 themeCursor(t, dir, "left_ptr", 24, 1)
53 themeCursor(t, dir, "hand2", 24, 2)
54 for _, tc := range []struct {
55 change ports.CursorChange
56 px byte
57 }{
58 {ports.CursorChange{}, 1},
59 // Themes with only X11 names still get the hand.
60 {ports.CursorChange{Shape: "pointer"}, 2},
61 // Unknown shapes fall back to the arrow.
62 {ports.CursorChange{Shape: "zoom-in"}, 1},
63 } {
64 img, err := loadCursor(tc.change, 2, 0, 256)
65 if err != nil {
66 t.Fatal(tc.change, err)
67 }
68 if img.W != 48 || img.Pixels[0] != tc.px {
69 t.Errorf("%+v: %dx%d px %d, want 48 wide px %d", tc.change, img.W, img.H, img.Pixels[0], tc.px)
70 }
71 }
72 // Cached: a removed theme file still loads.
73 if err := os.RemoveAll(dir); err != nil {
74 t.Fatal(err)
75 }
76 if img, err := loadCursor(ports.CursorChange{Shape: "pointer"}, 2, 0, 256); err != nil || img.Pixels[0] != 2 {
77 t.Fatalf("cached: %v", err)
78 }
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)
81 }
82}
83
84// A client cursor drawn at buffer scale 2 is shown at the output scale.
85func TestLoadCursorClientImage(t *testing.T) {
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, 0, 256)
88 if err != nil || img.W != 2 || img.H != 1 || img.HotX != 1 || img.HotY != 0 {
89 t.Fatalf("%+v %v", img, err)
90 }
91 img, err = loadCursor(ports.CursorChange{Image: src, Scale: 2}, 2, 0, 256)
92 if err != nil || img.W != 4 || img.HotX != 2 {
93 t.Fatalf("%+v %v", img, err)
94 }
95 // Bounded by the cursor plane.
96 img, err = loadCursor(ports.CursorChange{Image: src, Scale: 1}, 4, 0, 8)
97 if err != nil || img.W != 8 || img.H != 4 {
98 t.Fatalf("%+v %v", img, err)
99 }
100}
101
102// A theme that only overrides the arrow still gets its parent's shapes.
103func TestLoadCursorInheritedShape(t *testing.T) {
104 dir := t.TempDir()
105 resetThemeCursors(t)
106 t.Setenv("XCURSOR_PATH", dir)
107 t.Setenv("XCURSOR_THEME", "child")
108 t.Setenv("XCURSOR_SIZE", "24")
109 themeCursor(t, dir, "left_ptr", 24, 1) // in "theme", the parent
110 if err := os.MkdirAll(filepath.Join(dir, "child", "cursors"), 0o755); err != nil {
111 t.Fatal(err)
112 }
113 if err := os.WriteFile(filepath.Join(dir, "child", "index.theme"), []byte("[Icon Theme]\nInherits=theme\n"), 0o644); err != nil {
114 t.Fatal(err)
115 }
116 child := filepath.Join(dir, "child", "cursors", "default")
117 themeCursor(t, dir, "text", 24, 3)
118 if err := os.Rename(filepath.Join(dir, "theme", "cursors", "left_ptr"), child); err != nil {
119 t.Fatal(err)
120 }
121 img, err := loadCursor(ports.CursorChange{Shape: "text"}, 1, 0, 256)
122 if err != nil || img.Pixels[0] != 3 {
123 t.Fatalf("got arrow instead of the inherited text cursor: %v", err)
124 }
125}
126
127// A rotated output holds the cursor image like any buffer: pixels and
128// hotspot follow BufferTransform.ToBuffer.
129func 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}