v0.2.0 13c6a90

xcursor.go RawHistoryFolder

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}

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 25

Tags 10

↑↓ moveenter openesc close