1// Package xcursor loads cursor images from Xcursor themes in pure Go.
2//
3// File format: "Xcur" header, a table of contents, then image chunks of
4// premultiplied ARGB8888 pixels at one or more nominal sizes.
5package xcursor
6
7import (
8 "bufio"
9 "encoding/binary"
10 "errors"
11 "fmt"
12 "os"
13 "path/filepath"
14 "strings"
15)
16
17// Image is one cursor frame: premultiplied ARGB8888, little-endian (B,G,R,A
18// bytes), W*4 bytes per row. HotX/HotY is the click point.
19// Nominal is the theme size the frame was made for (XCURSOR_SIZE units); the
20// image itself may be larger (Breeze draws 32px for nominal 24).
21type Image struct {
22 W, H int
23 HotX, HotY int
24 Nominal int
25 Pixels []byte
26}
27
28const (
29 chunkImage = 0xfffd0002
30 maxSide = 1024
31)
32
33// Decode returns the frame whose nominal size is the smallest one >= size,
34// else the largest. Animated cursors return their first frame.
35func Decode(data []byte, size int) (Image, error) {
36 le := binary.LittleEndian
37 if len(data) < 16 || string(data[:4]) != "Xcur" {
38 return Image{}, errors.New("not an Xcursor file")
39 }
40 header, n := le.Uint32(data[4:]), le.Uint32(data[12:])
41 if header < 16 || uint64(header)+uint64(n)*12 > uint64(len(data)) {
42 return Image{}, errors.New("truncated table of contents")
43 }
44 best, bestSize := uint32(0), uint32(0)
45 for i := uint32(0); i < n; i++ {
46 e := data[header+i*12:]
47 if le.Uint32(e) != chunkImage {
48 continue
49 }
50 nominal, pos := le.Uint32(e[4:]), le.Uint32(e[8:])
51 // Prefer the smallest nominal >= size; below size, the largest.
52 better := bestSize == 0 ||
53 (nominal >= uint32(size) && (bestSize < uint32(size) || nominal < bestSize)) ||
54 (nominal < uint32(size) && bestSize < uint32(size) && nominal > bestSize)
55 if better {
56 best, bestSize = pos, nominal
57 }
58 }
59 if bestSize == 0 {
60 return Image{}, errors.New("no image chunk")
61 }
62 if uint64(best)+36 > uint64(len(data)) {
63 return Image{}, errors.New("truncated image header")
64 }
65 h := data[best:]
66 // The chunk header must repeat its table-of-contents entry.
67 if le.Uint32(h) != 36 || le.Uint32(h[4:]) != chunkImage || le.Uint32(h[8:]) != bestSize {
68 return Image{}, errors.New("image chunk does not match its table entry")
69 }
70 w, ht := le.Uint32(h[16:]), le.Uint32(h[20:])
71 if w == 0 || ht == 0 || w > maxSide || ht > maxSide {
72 return Image{}, fmt.Errorf("bad image size %dx%d", w, ht)
73 }
74 px := uint64(w) * uint64(ht) * 4
75 if uint64(best)+36+px > uint64(len(data)) {
76 return Image{}, errors.New("truncated pixels")
77 }
78 img := Image{Nominal: int(bestSize), W: int(w), H: int(ht), HotX: int(min(le.Uint32(h[24:]), w-1)), HotY: int(min(le.Uint32(h[28:]), ht-1))}
79 img.Pixels = append([]byte(nil), h[36:36+px]...)
80 return img, nil
81}
82
83// Scale resizes to w×h by averaging source pixels (box filter); averaging is
84// correct on premultiplied alpha.
85func (img Image) Scale(w, h int) Image {
86 if w <= 0 || h <= 0 || (w == img.W && h == img.H) {
87 return img
88 }
89 out := Image{Nominal: img.Nominal * w / img.W, W: w, H: h, HotX: img.HotX * w / img.W, HotY: img.HotY * h / img.H, Pixels: make([]byte, w*h*4)}
90 for y := 0; y < h; y++ {
91 y0, y1 := y*img.H/h, max((y+1)*img.H/h, y*img.H/h+1)
92 for x := 0; x < w; x++ {
93 x0, x1 := x*img.W/w, max((x+1)*img.W/w, x*img.W/w+1)
94 var sum [4]int
95 for sy := y0; sy < y1; sy++ {
96 for sx := x0; sx < x1; sx++ {
97 p := img.Pixels[(sy*img.W+sx)*4:]
98 for c := range 4 {
99 sum[c] += int(p[c])
100 }
101 }
102 }
103 n := (y1 - y0) * (x1 - x0)
104 for c := range 4 {
105 out.Pixels[(y*w+x)*4+c] = byte(sum[c] / n)
106 }
107 }
108 }
109 return out
110}
111
112// ForSize scales the image so its nominal size becomes size, keeping the
113// theme's proportions, and never beyond limit pixels on a side.
114func (img Image) ForSize(size, limit int) Image {
115 nominal := max(img.Nominal, 1)
116 w, h := img.W*size/nominal, img.H*size/nominal
117 if big := max(w, h); big > limit {
118 w, h = w*limit/big, h*limit/big
119 }
120 return img.Scale(max(w, 1), max(h, 1))
121}
122
123// SearchPath is XCURSOR_PATH or the usual icon directories.
124func SearchPath() []string {
125 if p := os.Getenv("XCURSOR_PATH"); p != "" {
126 return filepath.SplitList(p)
127 }
128 var dirs []string
129 if home, err := os.UserHomeDir(); err == nil {
130 data := os.Getenv("XDG_DATA_HOME")
131 if data == "" {
132 data = filepath.Join(home, ".local", "share")
133 }
134 dirs = append(dirs, filepath.Join(data, "icons"), filepath.Join(home, ".icons"))
135 }
136 return append(dirs, "/usr/share/icons", "/usr/share/pixmaps")
137}
138
139// Load finds the named cursor in theme (following Inherits), falling back to
140// the "default" and Adwaita themes, and decodes it at size.
141func Load(dirs []string, theme string, names []string, size int) (Image, error) {
142 seen := map[string]bool{}
143 queue := []string{theme, "default", "Adwaita"}
144 for len(queue) > 0 && len(seen) < 32 {
145 t := queue[0]
146 queue = queue[1:]
147 if t == "" || seen[t] {
148 continue
149 }
150 seen[t] = true
151 for _, dir := range dirs {
152 for _, name := range names {
153 data, err := os.ReadFile(filepath.Join(dir, t, "cursors", name))
154 if err != nil {
155 continue
156 }
157 if img, err := Decode(data, size); err == nil {
158 return img, nil
159 }
160 }
161 }
162 queue = append(inherits(dirs, t), queue...)
163 }
164 return Image{}, fmt.Errorf("cursor %v not found in theme %q", names, theme)
165}
166
167// inherits reads Inherits= from the first index.theme of the theme.
168func inherits(dirs []string, theme string) []string {
169 for _, dir := range dirs {
170 f, err := os.Open(filepath.Join(dir, theme, "index.theme"))
171 if err != nil {
172 continue
173 }
174 defer f.Close()
175 s := bufio.NewScanner(f)
176 for s.Scan() {
177 if k, v, ok := strings.Cut(s.Text(), "="); ok && strings.TrimSpace(k) == "Inherits" {
178 return strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ';' || r == ' ' })
179 }
180 }
181 return nil
182 }
183 return nil
184}