detached b47c444

merge: cursor (Xcursor theme, hardware cursor plane, scaled)

commit
b47c444bb88a96cf1a3fa02240741fdaaaf29c31
author
brice · · 2026-09-24 15:25 UTC
merge of
e0e771c + 19bbdfa · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +675 -11
A internal/adapters/drm/cursor.go +134 -0
@@ -0,0 +1,134 @@
1+package drm
2+
3+import (
4+ "fmt"
5+ "sync"
6+ "unsafe"
7+)
8+
9+// Hardware cursor through the legacy cursor ioctls: the GPU overlays a small
10+// buffer at a position, so moving it never re-renders the frame.
11+
12+const (
13+ ioctlGetCap = 0xC010640C // DRM_IOWR('d', 0x0C, struct drm_get_cap)
14+ ioctlCursor2 = 0xC02464BB // DRM_IOWR('d', 0xBB, struct drm_mode_cursor2)
15+ capCursorW = 0x8
16+ capCursorH = 0x9
17+ cursorBO = 0x01
18+ cursorMove = 0x02
19+)
20+
21+type getCap struct{ capability, value uint64 }
22+
23+type modeCursor2 struct {
24+ flags, crtcID uint32
25+ x, y int32
26+ width, height uint32
27+ handle uint32
28+ hotX, hotY int32
29+}
30+
31+// Cursor is the hardware cursor of one output. Move is safe to call from the
32+// input goroutine while the output goroutine changes the image.
33+type Cursor struct {
34+ mu sync.Mutex
35+ fd int
36+ crtc uint32
37+ buf *dumbBuffer
38+ size int // buffer side
39+ hotX, hotY int
40+ x, y int // physical position of the hotspot
41+ shown bool
42+}
43+
44+// newCursor allocates a cursor buffer at the driver's preferred size (64 when
45+// the driver does not say).
46+func newCursor(fd int, crtc uint32) (*Cursor, error) {
47+ // The plane is square: the smaller of the width and height caps.
48+ size := 0
49+ for _, c := range []uint64{capCursorW, capCursorH} {
50+ v := getCap{capability: c}
51+ if ioctl(fd, ioctlGetCap, unsafe.Pointer(&v)) == nil && v.value >= 16 && v.value <= 512 {
52+ if size == 0 || int(v.value) < size {
53+ size = int(v.value)
54+ }
55+ }
56+ }
57+ if size == 0 {
58+ size = 64
59+ }
60+ buf, err := newDumb(fd, size, size)
61+ if err != nil {
62+ return nil, err
63+ }
64+ // The cursor ioctl passes no pitch: drivers assume width*4.
65+ if int(buf.pitch) != size*4 {
66+ buf.destroy(fd)
67+ return nil, fmt.Errorf("cursor buffer pitch %d, want %d", buf.pitch, size*4)
68+ }
69+ return &Cursor{fd: fd, crtc: crtc, buf: buf, size: size}, nil
70+}
71+
72+// Limit is the largest image side the cursor plane accepts.
73+func (c *Cursor) Limit() int { return c.size }
74+
75+// SetImage shows premultiplied ARGB8888 pixels (w×h, w*4 per row) with the
76+// click point at (hotX, hotY). Larger images are cropped to the plane.
77+func (c *Cursor) SetImage(pixels []byte, w, h, hotX, hotY int) error {
78+ c.mu.Lock()
79+ defer c.mu.Unlock()
80+ if c.buf == nil {
81+ return nil
82+ }
83+ pitch := int(c.buf.pitch)
84+ clear(c.buf.mem)
85+ for y := range min(h, c.size) {
86+ row := pixels[y*w*4:]
87+ copy(c.buf.mem[y*pitch:y*pitch+min(w, c.size)*4], row[:min(w, c.size)*4])
88+ }
89+ c.hotX, c.hotY, c.shown = hotX, hotY, true
90+ return c.apply()
91+}
92+
93+// apply (re)attaches the buffer at the current position.
94+func (c *Cursor) apply() error {
95+ if !c.shown || c.buf == nil {
96+ return nil
97+ }
98+ v := modeCursor2{flags: cursorBO | cursorMove, crtcID: c.crtc, x: int32(c.x - c.hotX), y: int32(c.y - c.hotY), width: uint32(c.size), height: uint32(c.size), handle: c.buf.handle, hotX: int32(c.hotX), hotY: int32(c.hotY)}
99+ return ioctl(c.fd, ioctlCursor2, unsafe.Pointer(&v))
100+}
101+
102+// Move places the hotspot at physical (x, y). Errors while another session
103+// owns the card (VT switched away) are ignored; Reapply restores the cursor.
104+func (c *Cursor) Move(x, y float64) {
105+ c.mu.Lock()
106+ defer c.mu.Unlock()
107+ c.x, c.y = int(x), int(y)
108+ if !c.shown || c.buf == nil {
109+ return
110+ }
111+ v := modeCursor2{flags: cursorMove, crtcID: c.crtc, x: int32(c.x - c.hotX), y: int32(c.y - c.hotY)}
112+ _ = ioctl(c.fd, ioctlCursor2, unsafe.Pointer(&v))
113+}
114+
115+// Reapply shows the cursor again after a modeset (VT switch back).
116+func (c *Cursor) Reapply() error {
117+ c.mu.Lock()
118+ defer c.mu.Unlock()
119+ return c.apply()
120+}
121+
122+// close hides the cursor and frees its buffer.
123+func (c *Cursor) close() {
124+ c.mu.Lock()
125+ defer c.mu.Unlock()
126+ if c.buf == nil {
127+ return
128+ }
129+ // Best effort: at exit the card may already belong to another session.
130+ v := modeCursor2{flags: cursorBO, crtcID: c.crtc}
131+ _ = ioctl(c.fd, ioctlCursor2, unsafe.Pointer(&v))
132+ c.buf.destroy(c.fd)
133+ c.buf, c.shown = nil, false
134+}
@@ -0,0 +1,134 @@
1+package drm
2+
3+import (
4+ "fmt"
5+ "sync"
6+ "unsafe"
7+)
8+
9+// Hardware cursor through the legacy cursor ioctls: the GPU overlays a small
10+// buffer at a position, so moving it never re-renders the frame.
11+
12+const (
13+ ioctlGetCap = 0xC010640C // DRM_IOWR('d', 0x0C, struct drm_get_cap)
14+ ioctlCursor2 = 0xC02464BB // DRM_IOWR('d', 0xBB, struct drm_mode_cursor2)
15+ capCursorW = 0x8
16+ capCursorH = 0x9
17+ cursorBO = 0x01
18+ cursorMove = 0x02
19+)
20+
21+type getCap struct{ capability, value uint64 }
22+
23+type modeCursor2 struct {
24+ flags, crtcID uint32
25+ x, y int32
26+ width, height uint32
27+ handle uint32
28+ hotX, hotY int32
29+}
30+
31+// Cursor is the hardware cursor of one output. Move is safe to call from the
32+// input goroutine while the output goroutine changes the image.
33+type Cursor struct {
34+ mu sync.Mutex
35+ fd int
36+ crtc uint32
37+ buf *dumbBuffer
38+ size int // buffer side
39+ hotX, hotY int
40+ x, y int // physical position of the hotspot
41+ shown bool
42+}
43+
44+// newCursor allocates a cursor buffer at the driver's preferred size (64 when
45+// the driver does not say).
46+func newCursor(fd int, crtc uint32) (*Cursor, error) {
47+ // The plane is square: the smaller of the width and height caps.
48+ size := 0
49+ for _, c := range []uint64{capCursorW, capCursorH} {
50+ v := getCap{capability: c}
51+ if ioctl(fd, ioctlGetCap, unsafe.Pointer(&v)) == nil && v.value >= 16 && v.value <= 512 {
52+ if size == 0 || int(v.value) < size {
53+ size = int(v.value)
54+ }
55+ }
56+ }
57+ if size == 0 {
58+ size = 64
59+ }
60+ buf, err := newDumb(fd, size, size)
61+ if err != nil {
62+ return nil, err
63+ }
64+ // The cursor ioctl passes no pitch: drivers assume width*4.
65+ if int(buf.pitch) != size*4 {
66+ buf.destroy(fd)
67+ return nil, fmt.Errorf("cursor buffer pitch %d, want %d", buf.pitch, size*4)
68+ }
69+ return &Cursor{fd: fd, crtc: crtc, buf: buf, size: size}, nil
70+}
71+
72+// Limit is the largest image side the cursor plane accepts.
73+func (c *Cursor) Limit() int { return c.size }
74+
75+// SetImage shows premultiplied ARGB8888 pixels (w×h, w*4 per row) with the
76+// click point at (hotX, hotY). Larger images are cropped to the plane.
77+func (c *Cursor) SetImage(pixels []byte, w, h, hotX, hotY int) error {
78+ c.mu.Lock()
79+ defer c.mu.Unlock()
80+ if c.buf == nil {
81+ return nil
82+ }
83+ pitch := int(c.buf.pitch)
84+ clear(c.buf.mem)
85+ for y := range min(h, c.size) {
86+ row := pixels[y*w*4:]
87+ copy(c.buf.mem[y*pitch:y*pitch+min(w, c.size)*4], row[:min(w, c.size)*4])
88+ }
89+ c.hotX, c.hotY, c.shown = hotX, hotY, true
90+ return c.apply()
91+}
92+
93+// apply (re)attaches the buffer at the current position.
94+func (c *Cursor) apply() error {
95+ if !c.shown || c.buf == nil {
96+ return nil
97+ }
98+ v := modeCursor2{flags: cursorBO | cursorMove, crtcID: c.crtc, x: int32(c.x - c.hotX), y: int32(c.y - c.hotY), width: uint32(c.size), height: uint32(c.size), handle: c.buf.handle, hotX: int32(c.hotX), hotY: int32(c.hotY)}
99+ return ioctl(c.fd, ioctlCursor2, unsafe.Pointer(&v))
100+}
101+
102+// Move places the hotspot at physical (x, y). Errors while another session
103+// owns the card (VT switched away) are ignored; Reapply restores the cursor.
104+func (c *Cursor) Move(x, y float64) {
105+ c.mu.Lock()
106+ defer c.mu.Unlock()
107+ c.x, c.y = int(x), int(y)
108+ if !c.shown || c.buf == nil {
109+ return
110+ }
111+ v := modeCursor2{flags: cursorMove, crtcID: c.crtc, x: int32(c.x - c.hotX), y: int32(c.y - c.hotY)}
112+ _ = ioctl(c.fd, ioctlCursor2, unsafe.Pointer(&v))
113+}
114+
115+// Reapply shows the cursor again after a modeset (VT switch back).
116+func (c *Cursor) Reapply() error {
117+ c.mu.Lock()
118+ defer c.mu.Unlock()
119+ return c.apply()
120+}
121+
122+// close hides the cursor and frees its buffer.
123+func (c *Cursor) close() {
124+ c.mu.Lock()
125+ defer c.mu.Unlock()
126+ if c.buf == nil {
127+ return
128+ }
129+ // Best effort: at exit the card may already belong to another session.
130+ v := modeCursor2{flags: cursorBO, crtcID: c.crtc}
131+ _ = ioctl(c.fd, ioctlCursor2, unsafe.Pointer(&v))
132+ c.buf.destroy(c.fd)
133+ c.buf, c.shown = nil, false
134+}
A internal/adapters/drm/cursor_test.go +16 -0
@@ -0,0 +1,16 @@
1+package drm
2+
3+import (
4+ "testing"
5+ "unsafe"
6+)
7+
8+// The ioctl numbers encode the struct size; they must match the kernel ABI.
9+func TestCursorABI(t *testing.T) {
10+ if s := unsafe.Sizeof(modeCursor2{}); s != 36 || ioctlCursor2>>16&0x3fff != 36 {
11+ t.Fatalf("drm_mode_cursor2 size %d", s)
12+ }
13+ if s := unsafe.Sizeof(getCap{}); s != 16 || ioctlGetCap>>16&0x3fff != 16 {
14+ t.Fatalf("drm_get_cap size %d", s)
15+ }
16+}
@@ -0,0 +1,16 @@
1+package drm
2+
3+import (
4+ "testing"
5+ "unsafe"
6+)
7+
8+// The ioctl numbers encode the struct size; they must match the kernel ABI.
9+func TestCursorABI(t *testing.T) {
10+ if s := unsafe.Sizeof(modeCursor2{}); s != 36 || ioctlCursor2>>16&0x3fff != 36 {
11+ t.Fatalf("drm_mode_cursor2 size %d", s)
12+ }
13+ if s := unsafe.Sizeof(getCap{}); s != 16 || ioctlGetCap>>16&0x3fff != 16 {
14+ t.Fatalf("drm_get_cap size %d", s)
15+ }
16+}
M internal/adapters/drm/output.go +38 -1
@@ -33,9 +33,14 @@ type Output struct {
3333 flipStart time.Time
3434 flips int
3535 monitor Monitor
36+ cursor *Cursor
3637 log zerowrap.Logger
3738 }
3839
40+// CursorLoader returns the cursor image for an output scale, at most limit
41+// pixels on a side.
42+type CursorLoader func(scale float64, limit int) (ports.CursorImage, error)
43+
3944 // Info describes the chosen output for clients (wl_output, xdg-output).
4045 type Info struct {
4146 Name string
@@ -91,10 +96,16 @@ func Open(fd int, card string, want Want, log zerowrap.Logger) (*Output, error)
9196 return nil, err
9297 }
9398 }
99+ if o.cursor, err = newCursor(fd, crtc); err != nil {
100+ log.Warn().Err(err).Msg("no hardware cursor")
101+ }
94102 log.Info().Str("card", card).Str("connector", c.name).Str("mode", mode.String()).Str("make", o.monitor.Make).Str("model", o.monitor.Model).Uint32("crtc", crtc).Msg("output")
95103 return o, nil
96104 }
97105
106+// Cursor is the hardware cursor, or nil when the driver has none.
107+func (o *Output) Cursor() *Cursor { return o.cursor }
108+
98109 // Info returns the chosen connector, monitor and mode.
99110 func (o *Output) Info() Info {
100111 return Info{Name: o.conn.name, Monitor: o.monitor, Width: o.Width(), Height: o.Height(), RefreshMilli: o.mode.refreshMilli(), PhysicalW: o.conn.mmW, PhysicalH: o.conn.mmH}
@@ -128,6 +139,9 @@ func (o *Output) present(r Renderer) error {
128139
129140 // Close restores the CRTC state found at startup and frees buffers.
130141 func (o *Output) Close() {
142+ if o.cursor != nil {
143+ o.cursor.close()
144+ }
131145 if o.saved.crtcID != 0 {
132146 ids := []uint32{o.conn.id}
133147 s := o.saved
@@ -143,7 +157,7 @@ func (o *Output) Close() {
143157 }
144158
145159 // Run renders scenes and flips until ctx ends. active reports seat enable/disable.
146-func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer, error), active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent) error {
160+func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent) error {
147161 r, err := newRenderer(o.Width(), o.Height())
148162 if err != nil {
149163 return fmt.Errorf("create renderer: %w", err)
@@ -162,6 +176,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer,
162176 surfaces := make(map[ports.WindowID]ports.SurfaceContent)
163177 var scene ports.Scene
164178 haveScene, dirty, enabled := false, false, true
179+ cursorScale := -1.0 // not loaded yet
165180 frame := 0
166181 stats := time.NewTicker(10 * time.Second)
167182 defer stats.Stop()
@@ -179,6 +194,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer,
179194 return err
180195 }
181196 dirty = haveScene
197+ if o.cursor != nil {
198+ if err := o.cursor.Reapply(); err != nil {
199+ o.log.Warn().Err(err).Msg("cursor")
200+ }
201+ }
182202 }
183203 enabled = on
184204 o.log.Info().Bool("enabled", on).Msg("output")
@@ -192,6 +212,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer,
192212 o.log.Info().Dur("flip_ms", d).Msg("slow flip")
193213 }
194214 case s := <-scenes:
215+ if o.cursor != nil && loadCursor != nil && s.Scale != cursorScale {
216+ cursorScale = s.Scale
217+ o.setCursor(loadCursor, s.Scale)
218+ }
195219 scene, haveScene, dirty = s, true, true
196220 case c := <-contents:
197221 if c.Pixels == nil {
@@ -232,6 +256,19 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer,
232256 }
233257 }
234258
259+// setCursor loads the theme cursor for scale onto the cursor plane.
260+func (o *Output) setCursor(load CursorLoader, scale float64) {
261+ img, err := load(scale, o.cursor.Limit())
262+ if err == nil {
263+ err = o.cursor.SetImage(img.Pixels, img.W, img.H, img.HotX, img.HotY)
264+ }
265+ if err != nil {
266+ o.log.Warn().Err(err).Msg("cursor")
267+ return
268+ }
269+ o.log.Info().Float64("scale", scale).Int("w", img.W).Int("h", img.H).Msg("cursor image")
270+}
271+
235272 func (o *Output) readEvents(ctx context.Context, events chan<- int, errs chan<- error) {
236273 buf := make([]byte, 1024)
237274 for ctx.Err() == nil {
@@ -33,9 +33,14 @@ type Output struct {
33 flipStart time.Time33 flipStart time.Time
34 flips int34 flips int
35 monitor Monitor35 monitor Monitor
36+ cursor *Cursor
36 log zerowrap.Logger37 log zerowrap.Logger
37 }38 }
38 39
40+// CursorLoader returns the cursor image for an output scale, at most limit
41+// pixels on a side.
42+type CursorLoader func(scale float64, limit int) (ports.CursorImage, error)
43+
39 // Info describes the chosen output for clients (wl_output, xdg-output).44 // Info describes the chosen output for clients (wl_output, xdg-output).
40 type Info struct {45 type Info struct {
41 Name string46 Name string
@@ -91,10 +96,16 @@ func Open(fd int, card string, want Want, log zerowrap.Logger) (*Output, error)
91 return nil, err96 return nil, err
92 }97 }
93 }98 }
99+ if o.cursor, err = newCursor(fd, crtc); err != nil {
100+ log.Warn().Err(err).Msg("no hardware cursor")
101+ }
94 log.Info().Str("card", card).Str("connector", c.name).Str("mode", mode.String()).Str("make", o.monitor.Make).Str("model", o.monitor.Model).Uint32("crtc", crtc).Msg("output")102 log.Info().Str("card", card).Str("connector", c.name).Str("mode", mode.String()).Str("make", o.monitor.Make).Str("model", o.monitor.Model).Uint32("crtc", crtc).Msg("output")
95 return o, nil103 return o, nil
96 }104 }
97 105
106+// Cursor is the hardware cursor, or nil when the driver has none.
107+func (o *Output) Cursor() *Cursor { return o.cursor }
108+
98 // Info returns the chosen connector, monitor and mode.109 // Info returns the chosen connector, monitor and mode.
99 func (o *Output) Info() Info {110 func (o *Output) Info() Info {
100 return Info{Name: o.conn.name, Monitor: o.monitor, Width: o.Width(), Height: o.Height(), RefreshMilli: o.mode.refreshMilli(), PhysicalW: o.conn.mmW, PhysicalH: o.conn.mmH}111 return Info{Name: o.conn.name, Monitor: o.monitor, Width: o.Width(), Height: o.Height(), RefreshMilli: o.mode.refreshMilli(), PhysicalW: o.conn.mmW, PhysicalH: o.conn.mmH}
@@ -128,6 +139,9 @@ func (o *Output) present(r Renderer) error {
128 139
129 // Close restores the CRTC state found at startup and frees buffers.140 // Close restores the CRTC state found at startup and frees buffers.
130 func (o *Output) Close() {141 func (o *Output) Close() {
142+ if o.cursor != nil {
143+ o.cursor.close()
144+ }
131 if o.saved.crtcID != 0 {145 if o.saved.crtcID != 0 {
132 ids := []uint32{o.conn.id}146 ids := []uint32{o.conn.id}
133 s := o.saved147 s := o.saved
@@ -143,7 +157,7 @@ func (o *Output) Close() {
143 }157 }
144 158
145 // Run renders scenes and flips until ctx ends. active reports seat enable/disable.159 // Run renders scenes and flips until ctx ends. active reports seat enable/disable.
146-func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer, error), active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent) error {160+func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent) error {
147 r, err := newRenderer(o.Width(), o.Height())161 r, err := newRenderer(o.Width(), o.Height())
148 if err != nil {162 if err != nil {
149 return fmt.Errorf("create renderer: %w", err)163 return fmt.Errorf("create renderer: %w", err)
@@ -162,6 +176,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer,
162 surfaces := make(map[ports.WindowID]ports.SurfaceContent)176 surfaces := make(map[ports.WindowID]ports.SurfaceContent)
163 var scene ports.Scene177 var scene ports.Scene
164 haveScene, dirty, enabled := false, false, true178 haveScene, dirty, enabled := false, false, true
179+ cursorScale := -1.0 // not loaded yet
165 frame := 0180 frame := 0
166 stats := time.NewTicker(10 * time.Second)181 stats := time.NewTicker(10 * time.Second)
167 defer stats.Stop()182 defer stats.Stop()
@@ -179,6 +194,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer,
179 return err194 return err
180 }195 }
181 dirty = haveScene196 dirty = haveScene
197+ if o.cursor != nil {
198+ if err := o.cursor.Reapply(); err != nil {
199+ o.log.Warn().Err(err).Msg("cursor")
200+ }
201+ }
182 }202 }
183 enabled = on203 enabled = on
184 o.log.Info().Bool("enabled", on).Msg("output")204 o.log.Info().Bool("enabled", on).Msg("output")
@@ -192,6 +212,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer,
192 o.log.Info().Dur("flip_ms", d).Msg("slow flip")212 o.log.Info().Dur("flip_ms", d).Msg("slow flip")
193 }213 }
194 case s := <-scenes:214 case s := <-scenes:
215+ if o.cursor != nil && loadCursor != nil && s.Scale != cursorScale {
216+ cursorScale = s.Scale
217+ o.setCursor(loadCursor, s.Scale)
218+ }
195 scene, haveScene, dirty = s, true, true219 scene, haveScene, dirty = s, true, true
196 case c := <-contents:220 case c := <-contents:
197 if c.Pixels == nil {221 if c.Pixels == nil {
@@ -232,6 +256,19 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer,
232 }256 }
233 }257 }
234 258
259+// setCursor loads the theme cursor for scale onto the cursor plane.
260+func (o *Output) setCursor(load CursorLoader, scale float64) {
261+ img, err := load(scale, o.cursor.Limit())
262+ if err == nil {
263+ err = o.cursor.SetImage(img.Pixels, img.W, img.H, img.HotX, img.HotY)
264+ }
265+ if err != nil {
266+ o.log.Warn().Err(err).Msg("cursor")
267+ return
268+ }
269+ o.log.Info().Float64("scale", scale).Int("w", img.W).Int("h", img.H).Msg("cursor image")
270+}
271+
235 func (o *Output) readEvents(ctx context.Context, events chan<- int, errs chan<- error) {272 func (o *Output) readEvents(ctx context.Context, events chan<- int, errs chan<- error) {
236 buf := make([]byte, 1024)273 buf := make([]byte, 1024)
237 for ctx.Err() == nil {274 for ctx.Err() == nil {
A internal/adapters/headless/cursor.go +56 -0
@@ -0,0 +1,56 @@
1+package headless
2+
3+import (
4+ "image"
5+ "sync"
6+
7+ "github.com/bnema/nefertty/internal/ports"
8+)
9+
10+// Cursor is the software cursor drawn into screenshots; it plays the role of
11+// the DRM cursor plane. Move is called from the input goroutine.
12+type Cursor struct {
13+ mu sync.Mutex
14+ x, y float64
15+ img ports.CursorImage
16+}
17+
18+func (c *Cursor) Move(x, y float64) {
19+ c.mu.Lock()
20+ c.x, c.y = x, y
21+ c.mu.Unlock()
22+}
23+
24+func (c *Cursor) set(img ports.CursorImage) {
25+ c.mu.Lock()
26+ c.img = img
27+ c.mu.Unlock()
28+}
29+
30+// draw blends the premultiplied cursor over dst with its hotspot at (x, y).
31+func (c *Cursor) draw(dst *image.RGBA) {
32+ c.mu.Lock()
33+ defer c.mu.Unlock()
34+ img := c.img
35+ ox, oy := int(c.x)-img.HotX, int(c.y)-img.HotY
36+ b := dst.Bounds()
37+ for y := range img.H {
38+ for x := range img.W {
39+ dx, dy := ox+x, oy+y
40+ if !image.Pt(dx, dy).In(b) {
41+ continue
42+ }
43+ s := img.Pixels[(y*img.W+x)*4:]
44+ a := int(s[3])
45+ if a == 0 {
46+ continue
47+ }
48+ d := dst.Pix[dst.PixOffset(dx, dy):]
49+ // Source is premultiplied B,G,R,A; dst is straight R,G,B,A (opaque).
50+ // min guards against themes that are not really premultiplied.
51+ d[0] = byte(min(255, int(s[2])+int(d[0])*(255-a)/255))
52+ d[1] = byte(min(255, int(s[1])+int(d[1])*(255-a)/255))
53+ d[2] = byte(min(255, int(s[0])+int(d[2])*(255-a)/255))
54+ }
55+ }
56+}
@@ -0,0 +1,56 @@
1+package headless
2+
3+import (
4+ "image"
5+ "sync"
6+
7+ "github.com/bnema/nefertty/internal/ports"
8+)
9+
10+// Cursor is the software cursor drawn into screenshots; it plays the role of
11+// the DRM cursor plane. Move is called from the input goroutine.
12+type Cursor struct {
13+ mu sync.Mutex
14+ x, y float64
15+ img ports.CursorImage
16+}
17+
18+func (c *Cursor) Move(x, y float64) {
19+ c.mu.Lock()
20+ c.x, c.y = x, y
21+ c.mu.Unlock()
22+}
23+
24+func (c *Cursor) set(img ports.CursorImage) {
25+ c.mu.Lock()
26+ c.img = img
27+ c.mu.Unlock()
28+}
29+
30+// draw blends the premultiplied cursor over dst with its hotspot at (x, y).
31+func (c *Cursor) draw(dst *image.RGBA) {
32+ c.mu.Lock()
33+ defer c.mu.Unlock()
34+ img := c.img
35+ ox, oy := int(c.x)-img.HotX, int(c.y)-img.HotY
36+ b := dst.Bounds()
37+ for y := range img.H {
38+ for x := range img.W {
39+ dx, dy := ox+x, oy+y
40+ if !image.Pt(dx, dy).In(b) {
41+ continue
42+ }
43+ s := img.Pixels[(y*img.W+x)*4:]
44+ a := int(s[3])
45+ if a == 0 {
46+ continue
47+ }
48+ d := dst.Pix[dst.PixOffset(dx, dy):]
49+ // Source is premultiplied B,G,R,A; dst is straight R,G,B,A (opaque).
50+ // min guards against themes that are not really premultiplied.
51+ d[0] = byte(min(255, int(s[2])+int(d[0])*(255-a)/255))
52+ d[1] = byte(min(255, int(s[1])+int(d[1])*(255-a)/255))
53+ d[2] = byte(min(255, int(s[0])+int(d[2])*(255-a)/255))
54+ }
55+ }
56+}
A internal/adapters/headless/cursor_test.go +31 -0
@@ -0,0 +1,31 @@
1+package headless
2+
3+import (
4+ "image"
5+ "image/color"
6+ "testing"
7+
8+ "github.com/bnema/nefertty/internal/ports"
9+)
10+
11+func TestCursorDraw(t *testing.T) {
12+ dst := image.NewRGBA(image.Rect(0, 0, 10, 10))
13+ for i := range dst.Pix {
14+ dst.Pix[i] = 255
15+ }
16+ // 2x2: opaque red, transparent, half black, opaque blue; hotspot (1,1).
17+ c := &Cursor{}
18+ c.set(ports.CursorImage{W: 2, H: 2, HotX: 1, HotY: 1, Pixels: []byte{0, 0, 255, 255, 0, 0, 0, 0, 0, 0, 0, 128, 255, 0, 0, 255}})
19+ c.Move(5, 5)
20+ c.draw(dst)
21+ for p, want := range map[image.Point]color.RGBA{{4, 4}: {255, 0, 0, 255}, {5, 4}: {255, 255, 255, 255}, {4, 5}: {127, 127, 127, 255}, {5, 5}: {0, 0, 255, 255}} {
22+ if got := dst.RGBAAt(p.X, p.Y); got != want {
23+ t.Errorf("%v = %v, want %v", p, got, want)
24+ }
25+ }
26+ // Off the edge: clipped, no panic.
27+ c.Move(0, 0)
28+ c.draw(dst)
29+ c.Move(100, -3)
30+ c.draw(dst)
31+}
@@ -0,0 +1,31 @@
1+package headless
2+
3+import (
4+ "image"
5+ "image/color"
6+ "testing"
7+
8+ "github.com/bnema/nefertty/internal/ports"
9+)
10+
11+func TestCursorDraw(t *testing.T) {
12+ dst := image.NewRGBA(image.Rect(0, 0, 10, 10))
13+ for i := range dst.Pix {
14+ dst.Pix[i] = 255
15+ }
16+ // 2x2: opaque red, transparent, half black, opaque blue; hotspot (1,1).
17+ c := &Cursor{}
18+ c.set(ports.CursorImage{W: 2, H: 2, HotX: 1, HotY: 1, Pixels: []byte{0, 0, 255, 255, 0, 0, 0, 0, 0, 0, 0, 128, 255, 0, 0, 255}})
19+ c.Move(5, 5)
20+ c.draw(dst)
21+ for p, want := range map[image.Point]color.RGBA{{4, 4}: {255, 0, 0, 255}, {5, 4}: {255, 255, 255, 255}, {4, 5}: {127, 127, 127, 255}, {5, 5}: {0, 0, 255, 255}} {
22+ if got := dst.RGBAAt(p.X, p.Y); got != want {
23+ t.Errorf("%v = %v, want %v", p, got, want)
24+ }
25+ }
26+ // Off the edge: clipped, no panic.
27+ c.Move(0, 0)
28+ c.draw(dst)
29+ c.Move(100, -3)
30+ c.draw(dst)
31+}
M internal/adapters/headless/output.go +19 -2
@@ -20,6 +20,10 @@ type Renderer interface {
2020 }
2121
2222 type Options struct {
23+ // Cursor, when set, is drawn into screenshots with the image from
24+ // LoadCursor at the scene scale.
25+ Cursor *Cursor
26+ LoadCursor func(scale float64, limit int) (ports.CursorImage, error)
2327 Width, Height int
2428 ScreenshotDir string
2529 Log zerowrap.Logger
@@ -36,6 +40,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
3640 var scene ports.Scene
3741 haveScene := false
3842 frame := 0
43+ cursorScale := -1.0 // not loaded yet
3944 update := func(c ports.SurfaceContent) {
4045 if c.Pixels == nil {
4146 delete(surfaces, c.ID)
@@ -86,16 +91,28 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
8691 if !haveScene {
8792 continue
8893 }
94+ if opts.Cursor != nil && opts.LoadCursor != nil && scene.Scale != cursorScale {
95+ cursorScale = scene.Scale
96+ if img, err := opts.LoadCursor(scene.Scale, 256); err == nil {
97+ opts.Cursor.set(img)
98+ } else {
99+ opts.Log.Warn().Err(err).Msg("cursor")
100+ }
101+ }
89102 start := time.Now()
90103 if err := r.Render(scene, surfaces); err != nil {
91104 return fmt.Errorf("render frame: %w", err)
92105 }
93106 frame++
94107 if opts.ScreenshotDir != "" {
95- if err := writePNG(filepath.Join(opts.ScreenshotDir, fmt.Sprintf("frame-%06d.png", frame)), r.Pixels()); err != nil {
108+ shot := r.Pixels()
109+ if opts.Cursor != nil {
110+ opts.Cursor.draw(shot)
111+ }
112+ if err := writePNG(filepath.Join(opts.ScreenshotDir, fmt.Sprintf("frame-%06d.png", frame)), shot); err != nil {
96113 return fmt.Errorf("screenshot: %w", err)
97114 }
98- if err := writePNG(filepath.Join(opts.ScreenshotDir, "latest.png"), r.Pixels()); err != nil {
115+ if err := writePNG(filepath.Join(opts.ScreenshotDir, "latest.png"), shot); err != nil {
99116 return fmt.Errorf("latest screenshot: %w", err)
100117 }
101118 }
@@ -20,6 +20,10 @@ type Renderer interface {
20 }20 }
21 21
22 type Options struct {22 type Options struct {
23+ // Cursor, when set, is drawn into screenshots with the image from
24+ // LoadCursor at the scene scale.
25+ Cursor *Cursor
26+ LoadCursor func(scale float64, limit int) (ports.CursorImage, error)
23 Width, Height int27 Width, Height int
24 ScreenshotDir string28 ScreenshotDir string
25 Log zerowrap.Logger29 Log zerowrap.Logger
@@ -36,6 +40,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
36 var scene ports.Scene40 var scene ports.Scene
37 haveScene := false41 haveScene := false
38 frame := 042 frame := 0
43+ cursorScale := -1.0 // not loaded yet
39 update := func(c ports.SurfaceContent) {44 update := func(c ports.SurfaceContent) {
40 if c.Pixels == nil {45 if c.Pixels == nil {
41 delete(surfaces, c.ID)46 delete(surfaces, c.ID)
@@ -86,16 +91,28 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
86 if !haveScene {91 if !haveScene {
87 continue92 continue
88 }93 }
94+ if opts.Cursor != nil && opts.LoadCursor != nil && scene.Scale != cursorScale {
95+ cursorScale = scene.Scale
96+ if img, err := opts.LoadCursor(scene.Scale, 256); err == nil {
97+ opts.Cursor.set(img)
98+ } else {
99+ opts.Log.Warn().Err(err).Msg("cursor")
100+ }
101+ }
89 start := time.Now()102 start := time.Now()
90 if err := r.Render(scene, surfaces); err != nil {103 if err := r.Render(scene, surfaces); err != nil {
91 return fmt.Errorf("render frame: %w", err)104 return fmt.Errorf("render frame: %w", err)
92 }105 }
93 frame++106 frame++
94 if opts.ScreenshotDir != "" {107 if opts.ScreenshotDir != "" {
95- if err := writePNG(filepath.Join(opts.ScreenshotDir, fmt.Sprintf("frame-%06d.png", frame)), r.Pixels()); err != nil {108+ shot := r.Pixels()
109+ if opts.Cursor != nil {
110+ opts.Cursor.draw(shot)
111+ }
112+ if err := writePNG(filepath.Join(opts.ScreenshotDir, fmt.Sprintf("frame-%06d.png", frame)), shot); err != nil {
96 return fmt.Errorf("screenshot: %w", err)113 return fmt.Errorf("screenshot: %w", err)
97 }114 }
98- if err := writePNG(filepath.Join(opts.ScreenshotDir, "latest.png"), r.Pixels()); err != nil {115+ if err := writePNG(filepath.Join(opts.ScreenshotDir, "latest.png"), shot); err != nil {
99 return fmt.Errorf("latest screenshot: %w", err)116 return fmt.Errorf("latest screenshot: %w", err)
100 }117 }
101 }118 }
M internal/adapters/headlessinput/input.go +5 -1
@@ -16,7 +16,8 @@ import (
1616
1717 // Run owns the keymap and converts script lines into input events. A keymap
1818 // received on keymaps replaces the current one, which is closed.
19-func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script <-chan string, input chan<- ports.InputEvent, log zerowrap.Logger) error {
19+// moveCursor, when set, places the software cursor on every scripted move.
20+func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script <-chan string, input chan<- ports.InputEvent, moveCursor func(x, y float64), log zerowrap.Logger) error {
2021 defer func() { km.Close() }()
2122 emit := func(code uint32, down bool) error {
2223 ev := km.Key(code, down, uint32(time.Now().UnixMilli()))
@@ -108,6 +109,9 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
108109 x, ex := strconv.ParseFloat(fields[1], 64)
109110 y, ey := strconv.ParseFloat(fields[2], 64)
110111 if ex == nil && ey == nil && !math.IsNaN(x) && !math.IsNaN(y) && !math.IsInf(x, 0) && !math.IsInf(y, 0) {
112+ if moveCursor != nil {
113+ moveCursor(x, y)
114+ }
111115 if err := sendPointer(ports.PointerMotion{X: x, Y: y, TimeMsec: now}); err != nil {
112116 return err
113117 }
@@ -16,7 +16,8 @@ import (
16 16
17 // Run owns the keymap and converts script lines into input events. A keymap17 // Run owns the keymap and converts script lines into input events. A keymap
18 // received on keymaps replaces the current one, which is closed.18 // received on keymaps replaces the current one, which is closed.
19-func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script <-chan string, input chan<- ports.InputEvent, log zerowrap.Logger) error {19+// moveCursor, when set, places the software cursor on every scripted move.
20+func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script <-chan string, input chan<- ports.InputEvent, moveCursor func(x, y float64), log zerowrap.Logger) error {
20 defer func() { km.Close() }()21 defer func() { km.Close() }()
21 emit := func(code uint32, down bool) error {22 emit := func(code uint32, down bool) error {
22 ev := km.Key(code, down, uint32(time.Now().UnixMilli()))23 ev := km.Key(code, down, uint32(time.Now().UnixMilli()))
@@ -108,6 +109,9 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
108 x, ex := strconv.ParseFloat(fields[1], 64)109 x, ex := strconv.ParseFloat(fields[1], 64)
109 y, ey := strconv.ParseFloat(fields[2], 64)110 y, ey := strconv.ParseFloat(fields[2], 64)
110 if ex == nil && ey == nil && !math.IsNaN(x) && !math.IsNaN(y) && !math.IsInf(x, 0) && !math.IsInf(y, 0) {111 if ex == nil && ey == nil && !math.IsNaN(x) && !math.IsNaN(y) && !math.IsInf(x, 0) && !math.IsInf(y, 0) {
112+ if moveCursor != nil {
113+ moveCursor(x, y)
114+ }
111 if err := sendPointer(ports.PointerMotion{X: x, Y: y, TimeMsec: now}); err != nil {115 if err := sendPointer(ports.PointerMotion{X: x, Y: y, TimeMsec: now}); err != nil {
112 return err116 return err
113 }117 }
M internal/adapters/headlessinput/input_test.go +2 -2
@@ -31,7 +31,7 @@ func TestScript(t *testing.T) {
3131 script <- tt.line
3232 close(script)
3333 input := make(chan ports.InputEvent, 32)
34- if err := Run(context.Background(), km, nil, script, input, logging.For(context.Background(), "input")); err != nil {
34+ if err := Run(context.Background(), km, nil, script, input, nil, logging.For(context.Background(), "input")); err != nil {
3535 t.Fatal(err)
3636 }
3737 if len(input) != len(tt.names) {
@@ -67,7 +67,7 @@ func TestPointerScript(t *testing.T) {
6767 }
6868 close(script)
6969 input := make(chan ports.InputEvent, 8)
70- if err := Run(context.Background(), km, nil, script, input, logging.For(context.Background(), "input")); err != nil {
70+ if err := Run(context.Background(), km, nil, script, input, nil, logging.For(context.Background(), "input")); err != nil {
7171 t.Fatal(err)
7272 }
7373 if len(input) != 5 {
@@ -31,7 +31,7 @@ func TestScript(t *testing.T) {
31 script <- tt.line31 script <- tt.line
32 close(script)32 close(script)
33 input := make(chan ports.InputEvent, 32)33 input := make(chan ports.InputEvent, 32)
34- if err := Run(context.Background(), km, nil, script, input, logging.For(context.Background(), "input")); err != nil {34+ if err := Run(context.Background(), km, nil, script, input, nil, logging.For(context.Background(), "input")); err != nil {
35 t.Fatal(err)35 t.Fatal(err)
36 }36 }
37 if len(input) != len(tt.names) {37 if len(input) != len(tt.names) {
@@ -67,7 +67,7 @@ func TestPointerScript(t *testing.T) {
67 }67 }
68 close(script)68 close(script)
69 input := make(chan ports.InputEvent, 8)69 input := make(chan ports.InputEvent, 8)
70- if err := Run(context.Background(), km, nil, script, input, logging.For(context.Background(), "input")); err != nil {70+ if err := Run(context.Background(), km, nil, script, input, nil, logging.For(context.Background(), "input")); err != nil {
71 t.Fatal(err)71 t.Fatal(err)
72 }72 }
73 if len(input) != 5 {73 if len(input) != 5 {
M internal/adapters/libinput/libinput.go +13 -1
@@ -142,7 +142,10 @@ type Options struct {
142142 Keymaps <-chan *xkb.Keymap
143143 Width, Height int
144144 Active <-chan bool
145- Log zerowrap.Logger
145+ // MoveCursor, when set, places the hardware cursor at the new physical
146+ // position as soon as motion is read, before core sees the event.
147+ MoveCursor func(x, y float64)
148+ Log zerowrap.Logger
146149 }
147150
148151 // Run owns libinput and the keymap, converting device events into input events.
@@ -171,6 +174,9 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
171174 }
172175 p := pointer{w: float64(opts.Width), h: float64(opts.Height)}
173176 p.x, p.y = p.w/2, p.h/2
177+ if opts.MoveCursor != nil {
178+ opts.MoveCursor(p.x, p.y)
179+ }
174180 fd := getFD(li)
175181 for ctx.Err() == nil {
176182 select {
@@ -242,11 +248,17 @@ func translate(ev uintptr, opts Options, p *pointer) (ports.InputEvent, error) {
242248 case evPointerMotion:
243249 pe := pointerEvent(ev)
244250 x, y := p.move(pointerDX(pe), pointerDY(pe))
251+ if opts.MoveCursor != nil {
252+ opts.MoveCursor(x, y)
253+ }
245254 log.Debug().Float64("x", x).Float64("y", y).Msg("pointer")
246255 return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil
247256 case evPointerAbs:
248257 pe := pointerEvent(ev)
249258 x, y := p.set(pointerAbsX(pe, uint32(p.w)), pointerAbsY(pe, uint32(p.h)))
259+ if opts.MoveCursor != nil {
260+ opts.MoveCursor(x, y)
261+ }
250262 return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil
251263 case evPointerButton:
252264 pe := pointerEvent(ev)
@@ -142,7 +142,10 @@ type Options struct {
142 Keymaps <-chan *xkb.Keymap142 Keymaps <-chan *xkb.Keymap
143 Width, Height int143 Width, Height int
144 Active <-chan bool144 Active <-chan bool
145- Log zerowrap.Logger145+ // MoveCursor, when set, places the hardware cursor at the new physical
146+ // position as soon as motion is read, before core sees the event.
147+ MoveCursor func(x, y float64)
148+ Log zerowrap.Logger
146 }149 }
147 150
148 // Run owns libinput and the keymap, converting device events into input events.151 // Run owns libinput and the keymap, converting device events into input events.
@@ -171,6 +174,9 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
171 }174 }
172 p := pointer{w: float64(opts.Width), h: float64(opts.Height)}175 p := pointer{w: float64(opts.Width), h: float64(opts.Height)}
173 p.x, p.y = p.w/2, p.h/2176 p.x, p.y = p.w/2, p.h/2
177+ if opts.MoveCursor != nil {
178+ opts.MoveCursor(p.x, p.y)
179+ }
174 fd := getFD(li)180 fd := getFD(li)
175 for ctx.Err() == nil {181 for ctx.Err() == nil {
176 select {182 select {
@@ -242,11 +248,17 @@ func translate(ev uintptr, opts Options, p *pointer) (ports.InputEvent, error) {
242 case evPointerMotion:248 case evPointerMotion:
243 pe := pointerEvent(ev)249 pe := pointerEvent(ev)
244 x, y := p.move(pointerDX(pe), pointerDY(pe))250 x, y := p.move(pointerDX(pe), pointerDY(pe))
251+ if opts.MoveCursor != nil {
252+ opts.MoveCursor(x, y)
253+ }
245 log.Debug().Float64("x", x).Float64("y", y).Msg("pointer")254 log.Debug().Float64("x", x).Float64("y", y).Msg("pointer")
246 return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil255 return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil
247 case evPointerAbs:256 case evPointerAbs:
248 pe := pointerEvent(ev)257 pe := pointerEvent(ev)
249 x, y := p.set(pointerAbsX(pe, uint32(p.w)), pointerAbsY(pe, uint32(p.h)))258 x, y := p.set(pointerAbsX(pe, uint32(p.w)), pointerAbsY(pe, uint32(p.h)))
259+ if opts.MoveCursor != nil {
260+ opts.MoveCursor(x, y)
261+ }
250 return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil262 return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil
251 case evPointerButton:263 case evPointerButton:
252 pe := pointerEvent(ev)264 pe := pointerEvent(ev)
A internal/adapters/xcursor/xcursor.go +184 -0
@@ -0,0 +1,184 @@
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.
5+package xcursor
6+
7+import (
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).
21+type Image struct {
22+ W, H int
23+ HotX, HotY int
24+ Nominal int
25+ Pixels []byte
26+}
27+
28+const (
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.
35+func 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.
85+func (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.
114+func (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.
124+func 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.
141+func 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.
168+func 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+}
@@ -0,0 +1,184 @@
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.
5+package xcursor
6+
7+import (
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).
21+type Image struct {
22+ W, H int
23+ HotX, HotY int
24+ Nominal int
25+ Pixels []byte
26+}
27+
28+const (
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.
35+func 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.
85+func (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.
114+func (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.
124+func 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.
141+func 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.
168+func 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+}
A internal/adapters/xcursor/xcursor_test.go +129 -0
@@ -0,0 +1,129 @@
1+package xcursor
2+
3+import (
4+ "encoding/binary"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+)
9+
10+// file builds an Xcursor file with one square image per size; the hotspot is
11+// size/8 and every pixel is opaque white.
12+func file(sizes ...int) []byte {
13+ le := binary.LittleEndian
14+ out := le.AppendUint32([]byte("Xcur"), 16)
15+ out = le.AppendUint32(out, 0x10000)
16+ out = le.AppendUint32(out, uint32(len(sizes)))
17+ pos := 16 + 12*len(sizes)
18+ for _, s := range sizes {
19+ out = le.AppendUint32(out, chunkImage)
20+ out = le.AppendUint32(out, uint32(s))
21+ out = le.AppendUint32(out, uint32(pos))
22+ pos += 36 + s*s*4
23+ }
24+ for _, s := range sizes {
25+ for _, v := range []int{36, chunkImage, s, 1, s, s, s / 8, s / 8, 0} {
26+ out = le.AppendUint32(out, uint32(v))
27+ }
28+ for range s * s {
29+ out = append(out, 255, 255, 255, 255)
30+ }
31+ }
32+ return out
33+}
34+
35+func TestDecodePicksSize(t *testing.T) {
36+ data := file(24, 36, 48)
37+ for _, tc := range []struct{ want, got int }{{24, 24}, {30, 36}, {36, 36}, {40, 48}, {96, 48}, {1, 24}} {
38+ img, err := Decode(data, tc.want)
39+ if err != nil || img.W != tc.got || img.HotX != tc.got/8 || len(img.Pixels) != tc.got*tc.got*4 {
40+ t.Errorf("size %d: %+v %v", tc.want, img.W, err)
41+ }
42+ }
43+}
44+
45+func TestDecodeRejectsBadFiles(t *testing.T) {
46+ good := file(24)
47+ for name, data := range map[string][]byte{
48+ "empty": nil,
49+ "magic": append([]byte("Xcux"), good[4:]...),
50+ "toc": good[:20],
51+ "pixels": good[:len(good)-4],
52+ "no images": file(),
53+ } {
54+ if _, err := Decode(data, 24); err == nil {
55+ t.Errorf("%s: no error", name)
56+ }
57+ }
58+ // A huge declared size must not allocate.
59+ huge := file(24)
60+ binary.LittleEndian.PutUint32(huge[16+12+16:], 1<<20)
61+ if _, err := Decode(huge, 24); err == nil {
62+ t.Error("huge image accepted")
63+ }
64+}
65+
66+func TestScale(t *testing.T) {
67+ img, _ := Decode(file(48), 48)
68+ s := img.Scale(36, 36)
69+ if s.W != 36 || s.H != 36 || s.HotX != 4 || len(s.Pixels) != 36*36*4 || s.Pixels[0] != 255 {
70+ t.Fatalf("%+v", s.W)
71+ }
72+}
73+
74+func TestLoadFollowsInherits(t *testing.T) {
75+ dir := t.TempDir()
76+ must := func(err error) {
77+ t.Helper()
78+ if err != nil {
79+ t.Fatal(err)
80+ }
81+ }
82+ must(os.MkdirAll(filepath.Join(dir, "child"), 0o755))
83+ must(os.WriteFile(filepath.Join(dir, "child", "index.theme"), []byte("[Icon Theme]\nInherits=parent\n"), 0o644))
84+ must(os.MkdirAll(filepath.Join(dir, "parent", "cursors"), 0o755))
85+ must(os.WriteFile(filepath.Join(dir, "parent", "cursors", "left_ptr"), file(24), 0o644))
86+ img, err := Load([]string{dir}, "child", []string{"default", "left_ptr"}, 24)
87+ if err != nil || img.W != 24 {
88+ t.Fatal(img.W, err)
89+ }
90+ if _, err := Load([]string{dir}, "child", []string{"nope"}, 24); err == nil {
91+ t.Fatal("missing cursor found")
92+ }
93+}
94+
95+func TestSystemTheme(t *testing.T) {
96+ img, err := Load(SearchPath(), os.Getenv("XCURSOR_THEME"), []string{"default", "left_ptr"}, 24)
97+ if err != nil {
98+ t.Skip("no cursor theme installed")
99+ }
100+ if img.W < 16 || img.W > 64 {
101+ t.Fatal(img.W)
102+ }
103+}
104+
105+func FuzzDecode(f *testing.F) {
106+ f.Add(file(24, 48), 24)
107+ f.Add(file(32), 64)
108+ f.Fuzz(func(t *testing.T, data []byte, size int) {
109+ img, err := Decode(data, size)
110+ if err == nil && (len(img.Pixels) != img.W*img.H*4 || img.HotX >= img.W || img.HotY >= img.H) {
111+ t.Fatalf("%dx%d hot %d,%d len %d", img.W, img.H, img.HotX, img.HotY, len(img.Pixels))
112+ }
113+ })
114+}
115+
116+func TestLoadInheritsCycle(t *testing.T) {
117+ dir := t.TempDir()
118+ for theme, parent := range map[string]string{"a": "b", "b": "a"} {
119+ if err := os.MkdirAll(filepath.Join(dir, theme), 0o755); err != nil {
120+ t.Fatal(err)
121+ }
122+ if err := os.WriteFile(filepath.Join(dir, theme, "index.theme"), []byte("[Icon Theme]\nInherits="+parent+"\n"), 0o644); err != nil {
123+ t.Fatal(err)
124+ }
125+ }
126+ if _, err := Load([]string{dir}, "a", []string{"default"}, 24); err == nil {
127+ t.Fatal("found a cursor in an empty cycle")
128+ }
129+}
@@ -0,0 +1,129 @@
1+package xcursor
2+
3+import (
4+ "encoding/binary"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+)
9+
10+// file builds an Xcursor file with one square image per size; the hotspot is
11+// size/8 and every pixel is opaque white.
12+func file(sizes ...int) []byte {
13+ le := binary.LittleEndian
14+ out := le.AppendUint32([]byte("Xcur"), 16)
15+ out = le.AppendUint32(out, 0x10000)
16+ out = le.AppendUint32(out, uint32(len(sizes)))
17+ pos := 16 + 12*len(sizes)
18+ for _, s := range sizes {
19+ out = le.AppendUint32(out, chunkImage)
20+ out = le.AppendUint32(out, uint32(s))
21+ out = le.AppendUint32(out, uint32(pos))
22+ pos += 36 + s*s*4
23+ }
24+ for _, s := range sizes {
25+ for _, v := range []int{36, chunkImage, s, 1, s, s, s / 8, s / 8, 0} {
26+ out = le.AppendUint32(out, uint32(v))
27+ }
28+ for range s * s {
29+ out = append(out, 255, 255, 255, 255)
30+ }
31+ }
32+ return out
33+}
34+
35+func TestDecodePicksSize(t *testing.T) {
36+ data := file(24, 36, 48)
37+ for _, tc := range []struct{ want, got int }{{24, 24}, {30, 36}, {36, 36}, {40, 48}, {96, 48}, {1, 24}} {
38+ img, err := Decode(data, tc.want)
39+ if err != nil || img.W != tc.got || img.HotX != tc.got/8 || len(img.Pixels) != tc.got*tc.got*4 {
40+ t.Errorf("size %d: %+v %v", tc.want, img.W, err)
41+ }
42+ }
43+}
44+
45+func TestDecodeRejectsBadFiles(t *testing.T) {
46+ good := file(24)
47+ for name, data := range map[string][]byte{
48+ "empty": nil,
49+ "magic": append([]byte("Xcux"), good[4:]...),
50+ "toc": good[:20],
51+ "pixels": good[:len(good)-4],
52+ "no images": file(),
53+ } {
54+ if _, err := Decode(data, 24); err == nil {
55+ t.Errorf("%s: no error", name)
56+ }
57+ }
58+ // A huge declared size must not allocate.
59+ huge := file(24)
60+ binary.LittleEndian.PutUint32(huge[16+12+16:], 1<<20)
61+ if _, err := Decode(huge, 24); err == nil {
62+ t.Error("huge image accepted")
63+ }
64+}
65+
66+func TestScale(t *testing.T) {
67+ img, _ := Decode(file(48), 48)
68+ s := img.Scale(36, 36)
69+ if s.W != 36 || s.H != 36 || s.HotX != 4 || len(s.Pixels) != 36*36*4 || s.Pixels[0] != 255 {
70+ t.Fatalf("%+v", s.W)
71+ }
72+}
73+
74+func TestLoadFollowsInherits(t *testing.T) {
75+ dir := t.TempDir()
76+ must := func(err error) {
77+ t.Helper()
78+ if err != nil {
79+ t.Fatal(err)
80+ }
81+ }
82+ must(os.MkdirAll(filepath.Join(dir, "child"), 0o755))
83+ must(os.WriteFile(filepath.Join(dir, "child", "index.theme"), []byte("[Icon Theme]\nInherits=parent\n"), 0o644))
84+ must(os.MkdirAll(filepath.Join(dir, "parent", "cursors"), 0o755))
85+ must(os.WriteFile(filepath.Join(dir, "parent", "cursors", "left_ptr"), file(24), 0o644))
86+ img, err := Load([]string{dir}, "child", []string{"default", "left_ptr"}, 24)
87+ if err != nil || img.W != 24 {
88+ t.Fatal(img.W, err)
89+ }
90+ if _, err := Load([]string{dir}, "child", []string{"nope"}, 24); err == nil {
91+ t.Fatal("missing cursor found")
92+ }
93+}
94+
95+func TestSystemTheme(t *testing.T) {
96+ img, err := Load(SearchPath(), os.Getenv("XCURSOR_THEME"), []string{"default", "left_ptr"}, 24)
97+ if err != nil {
98+ t.Skip("no cursor theme installed")
99+ }
100+ if img.W < 16 || img.W > 64 {
101+ t.Fatal(img.W)
102+ }
103+}
104+
105+func FuzzDecode(f *testing.F) {
106+ f.Add(file(24, 48), 24)
107+ f.Add(file(32), 64)
108+ f.Fuzz(func(t *testing.T, data []byte, size int) {
109+ img, err := Decode(data, size)
110+ if err == nil && (len(img.Pixels) != img.W*img.H*4 || img.HotX >= img.W || img.HotY >= img.H) {
111+ t.Fatalf("%dx%d hot %d,%d len %d", img.W, img.H, img.HotX, img.HotY, len(img.Pixels))
112+ }
113+ })
114+}
115+
116+func TestLoadInheritsCycle(t *testing.T) {
117+ dir := t.TempDir()
118+ for theme, parent := range map[string]string{"a": "b", "b": "a"} {
119+ if err := os.MkdirAll(filepath.Join(dir, theme), 0o755); err != nil {
120+ t.Fatal(err)
121+ }
122+ if err := os.WriteFile(filepath.Join(dir, theme, "index.theme"), []byte("[Icon Theme]\nInherits="+parent+"\n"), 0o644); err != nil {
123+ t.Fatal(err)
124+ }
125+ }
126+ if _, err := Load([]string{dir}, "a", []string{"default"}, 24); err == nil {
127+ t.Fatal("found a cursor in an empty cycle")
128+ }
129+}
M internal/app/app.go +11 -4
@@ -126,15 +126,22 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
126126 relayConfig(ctx, opts.Config, watched, configChanges, keymaps, commands, logging.For(ctx, "config"))
127127 }()
128128 script := make(chan string)
129+ // The software cursor starts centred, like libinput's pointer.
130+ softCursor := &headless.Cursor{}
131+ softCursor.Move(float64(width)/2, float64(height)/2)
129132 go func() {
130133 defer workers.Done()
131134 if hw != nil {
132135 done <- safe("input", func() error {
133- return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Width: width, Height: height, Active: hw.inputActive, Log: logging.For(ctx, "input")}, input)
136+ var move func(x, y float64)
137+ if cur := hw.out.Cursor(); cur != nil {
138+ move = cur.Move
139+ }
140+ return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Width: width, Height: height, Active: hw.inputActive, MoveCursor: move, Log: logging.For(ctx, "input")}, input)
134141 })
135142 return
136143 }
137- _ = headlessinput.Run(ctx, km, keymaps, script, input, logging.For(ctx, "input"))
144+ _ = headlessinput.Run(ctx, km, keymaps, script, input, softCursor.Move, logging.For(ctx, "input"))
138145 }()
139146 if opts.Script != nil {
140147 go func() { <-ctx.Done(); _ = opts.Script.Close() }()
@@ -170,11 +177,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
170177 return nil, err
171178 }
172179 return r, nil
173- }, hw.outputActive, renderScenes, contents)
180+ }, loadCursor, hw.outputActive, renderScenes, contents)
174181 })
175182 return
176183 }
177- done <- headless.Run(ctx, headless.Options{Width: width, Height: height, ScreenshotDir: opts.ScreenshotDir, Log: logging.For(ctx, "render"), NewRenderer: func(w, h int) (headless.Renderer, error) {
184+ done <- headless.Run(ctx, headless.Options{Cursor: softCursor, LoadCursor: loadCursor, Width: width, Height: height, ScreenshotDir: opts.ScreenshotDir, Log: logging.For(ctx, "render"), NewRenderer: func(w, h int) (headless.Renderer, error) {
178185 r, err := vulkan.New(w, h)
179186 if err != nil {
180187 return nil, err
@@ -126,15 +126,22 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
126 relayConfig(ctx, opts.Config, watched, configChanges, keymaps, commands, logging.For(ctx, "config"))126 relayConfig(ctx, opts.Config, watched, configChanges, keymaps, commands, logging.For(ctx, "config"))
127 }()127 }()
128 script := make(chan string)128 script := make(chan string)
129+ // The software cursor starts centred, like libinput's pointer.
130+ softCursor := &headless.Cursor{}
131+ softCursor.Move(float64(width)/2, float64(height)/2)
129 go func() {132 go func() {
130 defer workers.Done()133 defer workers.Done()
131 if hw != nil {134 if hw != nil {
132 done <- safe("input", func() error {135 done <- safe("input", func() error {
133- return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Width: width, Height: height, Active: hw.inputActive, Log: logging.For(ctx, "input")}, input)136+ var move func(x, y float64)
137+ if cur := hw.out.Cursor(); cur != nil {
138+ move = cur.Move
139+ }
140+ return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Width: width, Height: height, Active: hw.inputActive, MoveCursor: move, Log: logging.For(ctx, "input")}, input)
134 })141 })
135 return142 return
136 }143 }
137- _ = headlessinput.Run(ctx, km, keymaps, script, input, logging.For(ctx, "input"))144+ _ = headlessinput.Run(ctx, km, keymaps, script, input, softCursor.Move, logging.For(ctx, "input"))
138 }()145 }()
139 if opts.Script != nil {146 if opts.Script != nil {
140 go func() { <-ctx.Done(); _ = opts.Script.Close() }()147 go func() { <-ctx.Done(); _ = opts.Script.Close() }()
@@ -170,11 +177,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
170 return nil, err177 return nil, err
171 }178 }
172 return r, nil179 return r, nil
173- }, hw.outputActive, renderScenes, contents)180+ }, loadCursor, hw.outputActive, renderScenes, contents)
174 })181 })
175 return182 return
176 }183 }
177- done <- headless.Run(ctx, headless.Options{Width: width, Height: height, ScreenshotDir: opts.ScreenshotDir, Log: logging.For(ctx, "render"), NewRenderer: func(w, h int) (headless.Renderer, error) {184+ done <- headless.Run(ctx, headless.Options{Cursor: softCursor, LoadCursor: loadCursor, Width: width, Height: height, ScreenshotDir: opts.ScreenshotDir, Log: logging.For(ctx, "render"), NewRenderer: func(w, h int) (headless.Renderer, error) {
178 r, err := vulkan.New(w, h)185 r, err := vulkan.New(w, h)
179 if err != nil {186 if err != nil {
180 return nil, err187 return nil, err
A internal/app/cursor.go +30 -0
@@ -0,0 +1,30 @@
1+package app
2+
3+import (
4+ "math"
5+ "os"
6+ "strconv"
7+
8+ "github.com/bnema/nefertty/internal/adapters/xcursor"
9+ "github.com/bnema/nefertty/internal/ports"
10+)
11+
12+// cursorSize is XCURSOR_SIZE (logical pixels), 24 by default.
13+func cursorSize() int {
14+ if n, err := strconv.Atoi(os.Getenv("XCURSOR_SIZE")); err == nil && n >= 8 && n <= 256 {
15+ return n
16+ }
17+ return 24
18+}
19+
20+// loadCursor loads the XCURSOR_THEME arrow at XCURSOR_SIZE × scale physical
21+// pixels, capped at limit pixels on a side.
22+func loadCursor(scale float64, limit int) (ports.CursorImage, error) {
23+ size := int(math.Round(float64(cursorSize()) * max(scale, 1)))
24+ img, err := xcursor.Load(xcursor.SearchPath(), os.Getenv("XCURSOR_THEME"), []string{"default", "left_ptr"}, size)
25+ if err != nil {
26+ return ports.CursorImage{}, err
27+ }
28+ img = img.ForSize(size, limit)
29+ return ports.CursorImage{W: img.W, H: img.H, HotX: img.HotX, HotY: img.HotY, Pixels: img.Pixels}, nil
30+}
@@ -0,0 +1,30 @@
1+package app
2+
3+import (
4+ "math"
5+ "os"
6+ "strconv"
7+
8+ "github.com/bnema/nefertty/internal/adapters/xcursor"
9+ "github.com/bnema/nefertty/internal/ports"
10+)
11+
12+// cursorSize is XCURSOR_SIZE (logical pixels), 24 by default.
13+func cursorSize() int {
14+ if n, err := strconv.Atoi(os.Getenv("XCURSOR_SIZE")); err == nil && n >= 8 && n <= 256 {
15+ return n
16+ }
17+ return 24
18+}
19+
20+// loadCursor loads the XCURSOR_THEME arrow at XCURSOR_SIZE × scale physical
21+// pixels, capped at limit pixels on a side.
22+func loadCursor(scale float64, limit int) (ports.CursorImage, error) {
23+ size := int(math.Round(float64(cursorSize()) * max(scale, 1)))
24+ img, err := xcursor.Load(xcursor.SearchPath(), os.Getenv("XCURSOR_THEME"), []string{"default", "left_ptr"}, size)
25+ if err != nil {
26+ return ports.CursorImage{}, err
27+ }
28+ img = img.ForSize(size, limit)
29+ return ports.CursorImage{W: img.W, H: img.H, HotX: img.HotX, HotY: img.HotY, Pixels: img.Pixels}, nil
30+}
M internal/ports/messages.go +7 -0
@@ -206,6 +206,13 @@ type SetKeymap struct {
206206
207207 func (SetKeymap) clientCommand() {}
208208
209+// CursorImage is a cursor frame in premultiplied B8G8R8A8, W*4 bytes per
210+// row; (HotX, HotY) is the click point. Sizes are physical pixels.
211+type CursorImage struct {
212+ W, H, HotX, HotY int
213+ Pixels []byte
214+}
215+
209216 // SpawnRequest carries core → launcher process arguments.
210217 type SpawnRequest struct{ Argv []string }
211218
@@ -206,6 +206,13 @@ type SetKeymap struct {
206 206
207 func (SetKeymap) clientCommand() {}207 func (SetKeymap) clientCommand() {}
208 208
209+// CursorImage is a cursor frame in premultiplied B8G8R8A8, W*4 bytes per
210+// row; (HotX, HotY) is the click point. Sizes are physical pixels.
211+type CursorImage struct {
212+ W, H, HotX, HotY int
213+ Pixels []byte
214+}
215+
209 // SpawnRequest carries core → launcher process arguments.216 // SpawnRequest carries core → launcher process arguments.
210 type SpawnRequest struct{ Argv []string }217 type SpawnRequest struct{ Argv []string }
211 218

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