detached c8e5e67

feat(cursor): Xcursor theme cursor on the DRM cursor plane, scaled per output

A pure-Go Xcursor loader reads XCURSOR_THEME (following Inherits, falling back to default and Adwaita) at XCURSOR_SIZE × output scale. On DRM the arrow sits on the hardware cursor plane and the input goroutine moves it as soon as libinput reports motion, without waiting for core or a frame. Headless draws the same cursor into screenshots.
commit
c8e5e67d526defc6c0fc6dd579b19a894576b7ce
author
brice · · 2026-09-24 15:19 UTC
parent
e0e771c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +628 -11
A internal/adapters/drm/cursor.go +116 -0
@@ -0,0 +1,116 @@
1+package drm
2+
3+import (
4+ "sync"
5+ "unsafe"
6+)
7+
8+// Hardware cursor through the legacy cursor ioctls: the GPU overlays a small
9+// buffer at a position, so moving it never re-renders the frame.
10+
11+const (
12+ ioctlGetCap = 0xC010640C // DRM_IOWR('d', 0x0C, struct drm_get_cap)
13+ ioctlCursor2 = 0xC02464BB // DRM_IOWR('d', 0xBB, struct drm_mode_cursor2)
14+ capCursorW = 0x8
15+ capCursorH = 0x9
16+ cursorBO = 0x01
17+ cursorMove = 0x02
18+)
19+
20+type getCap struct{ capability, value uint64 }
21+
22+type modeCursor2 struct {
23+ flags, crtcID uint32
24+ x, y int32
25+ width, height uint32
26+ handle uint32
27+ hotX, hotY int32
28+}
29+
30+// Cursor is the hardware cursor of one output. Move is safe to call from the
31+// input goroutine while the output goroutine changes the image.
32+type Cursor struct {
33+ mu sync.Mutex
34+ fd int
35+ crtc uint32
36+ buf *dumbBuffer
37+ size int // buffer side
38+ hotX, hotY int
39+ x, y int // physical position of the hotspot
40+ shown bool
41+}
42+
43+// newCursor allocates a cursor buffer at the driver's preferred size (64 when
44+// the driver does not say).
45+func newCursor(fd int, crtc uint32) (*Cursor, error) {
46+ size := 64
47+ for _, c := range []uint64{capCursorW, capCursorH} {
48+ v := getCap{capability: c}
49+ if ioctl(fd, ioctlGetCap, unsafe.Pointer(&v)) == nil && v.value >= 32 && v.value <= 512 {
50+ size = max(size, int(v.value))
51+ }
52+ }
53+ buf, err := newDumb(fd, size, size)
54+ if err != nil {
55+ return nil, err
56+ }
57+ return &Cursor{fd: fd, crtc: crtc, buf: buf, size: size}, nil
58+}
59+
60+// Limit is the largest image side the cursor plane accepts.
61+func (c *Cursor) Limit() int { return c.size }
62+
63+// SetImage shows premultiplied ARGB8888 pixels (w×h, w*4 per row) with the
64+// click point at (hotX, hotY). Larger images are cropped to the plane.
65+func (c *Cursor) SetImage(pixels []byte, w, h, hotX, hotY int) error {
66+ c.mu.Lock()
67+ defer c.mu.Unlock()
68+ pitch := int(c.buf.pitch)
69+ clear(c.buf.mem)
70+ for y := range min(h, c.size) {
71+ row := pixels[y*w*4:]
72+ copy(c.buf.mem[y*pitch:y*pitch+min(w, c.size)*4], row[:min(w, c.size)*4])
73+ }
74+ c.hotX, c.hotY, c.shown = hotX, hotY, true
75+ return c.apply()
76+}
77+
78+// apply (re)attaches the buffer at the current position.
79+func (c *Cursor) apply() error {
80+ if !c.shown {
81+ return nil
82+ }
83+ 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)}
84+ return ioctl(c.fd, ioctlCursor2, unsafe.Pointer(&v))
85+}
86+
87+// Move places the hotspot at physical (x, y). Errors while another session
88+// owns the card (VT switched away) are ignored; Reapply restores the cursor.
89+func (c *Cursor) Move(x, y float64) {
90+ c.mu.Lock()
91+ defer c.mu.Unlock()
92+ c.x, c.y = int(x), int(y)
93+ if !c.shown {
94+ return
95+ }
96+ v := modeCursor2{flags: cursorMove, crtcID: c.crtc, x: int32(c.x - c.hotX), y: int32(c.y - c.hotY)}
97+ _ = ioctl(c.fd, ioctlCursor2, unsafe.Pointer(&v))
98+}
99+
100+// Reapply shows the cursor again after a modeset (VT switch back).
101+func (c *Cursor) Reapply() error {
102+ c.mu.Lock()
103+ defer c.mu.Unlock()
104+ return c.apply()
105+}
106+
107+// close hides the cursor and frees its buffer.
108+func (c *Cursor) close() {
109+ c.mu.Lock()
110+ defer c.mu.Unlock()
111+ // Best effort: at exit the card may already belong to another session.
112+ v := modeCursor2{flags: cursorBO, crtcID: c.crtc}
113+ _ = ioctl(c.fd, ioctlCursor2, unsafe.Pointer(&v))
114+ c.buf.destroy(c.fd)
115+ c.shown = false
116+}
@@ -0,0 +1,116 @@
1+package drm
2+
3+import (
4+ "sync"
5+ "unsafe"
6+)
7+
8+// Hardware cursor through the legacy cursor ioctls: the GPU overlays a small
9+// buffer at a position, so moving it never re-renders the frame.
10+
11+const (
12+ ioctlGetCap = 0xC010640C // DRM_IOWR('d', 0x0C, struct drm_get_cap)
13+ ioctlCursor2 = 0xC02464BB // DRM_IOWR('d', 0xBB, struct drm_mode_cursor2)
14+ capCursorW = 0x8
15+ capCursorH = 0x9
16+ cursorBO = 0x01
17+ cursorMove = 0x02
18+)
19+
20+type getCap struct{ capability, value uint64 }
21+
22+type modeCursor2 struct {
23+ flags, crtcID uint32
24+ x, y int32
25+ width, height uint32
26+ handle uint32
27+ hotX, hotY int32
28+}
29+
30+// Cursor is the hardware cursor of one output. Move is safe to call from the
31+// input goroutine while the output goroutine changes the image.
32+type Cursor struct {
33+ mu sync.Mutex
34+ fd int
35+ crtc uint32
36+ buf *dumbBuffer
37+ size int // buffer side
38+ hotX, hotY int
39+ x, y int // physical position of the hotspot
40+ shown bool
41+}
42+
43+// newCursor allocates a cursor buffer at the driver's preferred size (64 when
44+// the driver does not say).
45+func newCursor(fd int, crtc uint32) (*Cursor, error) {
46+ size := 64
47+ for _, c := range []uint64{capCursorW, capCursorH} {
48+ v := getCap{capability: c}
49+ if ioctl(fd, ioctlGetCap, unsafe.Pointer(&v)) == nil && v.value >= 32 && v.value <= 512 {
50+ size = max(size, int(v.value))
51+ }
52+ }
53+ buf, err := newDumb(fd, size, size)
54+ if err != nil {
55+ return nil, err
56+ }
57+ return &Cursor{fd: fd, crtc: crtc, buf: buf, size: size}, nil
58+}
59+
60+// Limit is the largest image side the cursor plane accepts.
61+func (c *Cursor) Limit() int { return c.size }
62+
63+// SetImage shows premultiplied ARGB8888 pixels (w×h, w*4 per row) with the
64+// click point at (hotX, hotY). Larger images are cropped to the plane.
65+func (c *Cursor) SetImage(pixels []byte, w, h, hotX, hotY int) error {
66+ c.mu.Lock()
67+ defer c.mu.Unlock()
68+ pitch := int(c.buf.pitch)
69+ clear(c.buf.mem)
70+ for y := range min(h, c.size) {
71+ row := pixels[y*w*4:]
72+ copy(c.buf.mem[y*pitch:y*pitch+min(w, c.size)*4], row[:min(w, c.size)*4])
73+ }
74+ c.hotX, c.hotY, c.shown = hotX, hotY, true
75+ return c.apply()
76+}
77+
78+// apply (re)attaches the buffer at the current position.
79+func (c *Cursor) apply() error {
80+ if !c.shown {
81+ return nil
82+ }
83+ 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)}
84+ return ioctl(c.fd, ioctlCursor2, unsafe.Pointer(&v))
85+}
86+
87+// Move places the hotspot at physical (x, y). Errors while another session
88+// owns the card (VT switched away) are ignored; Reapply restores the cursor.
89+func (c *Cursor) Move(x, y float64) {
90+ c.mu.Lock()
91+ defer c.mu.Unlock()
92+ c.x, c.y = int(x), int(y)
93+ if !c.shown {
94+ return
95+ }
96+ v := modeCursor2{flags: cursorMove, crtcID: c.crtc, x: int32(c.x - c.hotX), y: int32(c.y - c.hotY)}
97+ _ = ioctl(c.fd, ioctlCursor2, unsafe.Pointer(&v))
98+}
99+
100+// Reapply shows the cursor again after a modeset (VT switch back).
101+func (c *Cursor) Reapply() error {
102+ c.mu.Lock()
103+ defer c.mu.Unlock()
104+ return c.apply()
105+}
106+
107+// close hides the cursor and frees its buffer.
108+func (c *Cursor) close() {
109+ c.mu.Lock()
110+ defer c.mu.Unlock()
111+ // Best effort: at exit the card may already belong to another session.
112+ v := modeCursor2{flags: cursorBO, crtcID: c.crtc}
113+ _ = ioctl(c.fd, ioctlCursor2, unsafe.Pointer(&v))
114+ c.buf.destroy(c.fd)
115+ c.shown = false
116+}
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 := 0.0
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 := 0.0
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 +55 -0
@@ -0,0 +1,55 @@
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+ d[0] = byte(int(s[2]) + int(d[0])*(255-a)/255)
51+ d[1] = byte(int(s[1]) + int(d[1])*(255-a)/255)
52+ d[2] = byte(int(s[0]) + int(d[2])*(255-a)/255)
53+ }
54+ }
55+}
@@ -0,0 +1,55 @@
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+ d[0] = byte(int(s[2]) + int(d[0])*(255-a)/255)
51+ d[1] = byte(int(s[1]) + int(d[1])*(255-a)/255)
52+ d[2] = byte(int(s[0]) + int(d[2])*(255-a)/255)
53+ }
54+ }
55+}
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 := 0.0
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 := 0.0
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 +182 -0
@@ -0,0 +1,182 @@
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+ w, ht := le.Uint32(h[16:]), le.Uint32(h[20:])
67+ if w == 0 || ht == 0 || w > maxSide || ht > maxSide {
68+ return Image{}, fmt.Errorf("bad image size %dx%d", w, ht)
69+ }
70+ px := uint64(w) * uint64(ht) * 4
71+ if uint64(best)+36+px > uint64(len(data)) {
72+ return Image{}, errors.New("truncated pixels")
73+ }
74+ 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))}
75+ img.Pixels = append([]byte(nil), h[36:36+px]...)
76+ return img, nil
77+}
78+
79+// Scale resizes to w×h by averaging source pixels (box filter); averaging is
80+// correct on premultiplied alpha.
81+func (img Image) Scale(w, h int) Image {
82+ if w <= 0 || h <= 0 || (w == img.W && h == img.H) {
83+ return img
84+ }
85+ 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)}
86+ for y := 0; y < h; y++ {
87+ y0, y1 := y*img.H/h, max((y+1)*img.H/h, y*img.H/h+1)
88+ for x := 0; x < w; x++ {
89+ x0, x1 := x*img.W/w, max((x+1)*img.W/w, x*img.W/w+1)
90+ var sum [4]int
91+ for sy := y0; sy < y1; sy++ {
92+ for sx := x0; sx < x1; sx++ {
93+ p := img.Pixels[(sy*img.W+sx)*4:]
94+ for c := range 4 {
95+ sum[c] += int(p[c])
96+ }
97+ }
98+ }
99+ n := (y1 - y0) * (x1 - x0)
100+ for c := range 4 {
101+ out.Pixels[(y*w+x)*4+c] = byte(sum[c] / n)
102+ }
103+ }
104+ }
105+ return out
106+}
107+
108+// ForSize scales the image so its nominal size becomes size, keeping the
109+// theme's proportions, and never beyond limit pixels on a side.
110+func (img Image) ForSize(size, limit int) Image {
111+ nominal := max(img.Nominal, 1)
112+ w, h := img.W*size/nominal, img.H*size/nominal
113+ if big := max(w, h); big > limit {
114+ w, h = w*limit/big, h*limit/big
115+ }
116+ return img.Scale(max(w, 1), max(h, 1))
117+}
118+
119+// SearchPath is XCURSOR_PATH or the usual icon directories.
120+func SearchPath() []string {
121+ if p := os.Getenv("XCURSOR_PATH"); p != "" {
122+ return filepath.SplitList(p)
123+ }
124+ var dirs []string
125+ if home, err := os.UserHomeDir(); err == nil {
126+ data := os.Getenv("XDG_DATA_HOME")
127+ if data == "" {
128+ data = filepath.Join(home, ".local", "share")
129+ }
130+ dirs = append(dirs, filepath.Join(data, "icons"), filepath.Join(home, ".icons"))
131+ }
132+ return append(dirs, "/usr/share/icons", "/usr/share/pixmaps")
133+}
134+
135+// Load finds the named cursor in theme (following Inherits), falling back to
136+// the "default" and Adwaita themes, and decodes it at size.
137+func Load(dirs []string, theme string, names []string, size int) (Image, error) {
138+ seen := map[string]bool{}
139+ queue := []string{theme, "default", "Adwaita"}
140+ for len(queue) > 0 && len(seen) < 32 {
141+ t := queue[0]
142+ queue = queue[1:]
143+ if t == "" || seen[t] {
144+ continue
145+ }
146+ seen[t] = true
147+ for _, dir := range dirs {
148+ for _, name := range names {
149+ data, err := os.ReadFile(filepath.Join(dir, t, "cursors", name))
150+ if err != nil {
151+ continue
152+ }
153+ if img, err := Decode(data, size); err == nil {
154+ return img, nil
155+ }
156+ }
157+ }
158+ queue = append(inherits(dirs, t), queue...)
159+ }
160+ return Image{}, fmt.Errorf("cursor %v not found in theme %q", names, theme)
161+}
162+
163+// inherits reads Inherits= from the first index.theme of the theme.
164+func inherits(dirs []string, theme string) []string {
165+ for _, dir := range dirs {
166+ f, err := os.Open(filepath.Join(dir, theme, "index.theme"))
167+ if err != nil {
168+ continue
169+ }
170+ defer f.Close()
171+ s := bufio.NewScanner(f)
172+ for s.Scan() {
173+ if v, ok := strings.CutPrefix(strings.TrimSpace(s.Text()), "Inherits"); ok {
174+ if v, ok := strings.CutPrefix(strings.TrimSpace(v), "="); ok {
175+ return strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ';' || r == ' ' })
176+ }
177+ }
178+ }
179+ return nil
180+ }
181+ return nil
182+}
@@ -0,0 +1,182 @@
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+ w, ht := le.Uint32(h[16:]), le.Uint32(h[20:])
67+ if w == 0 || ht == 0 || w > maxSide || ht > maxSide {
68+ return Image{}, fmt.Errorf("bad image size %dx%d", w, ht)
69+ }
70+ px := uint64(w) * uint64(ht) * 4
71+ if uint64(best)+36+px > uint64(len(data)) {
72+ return Image{}, errors.New("truncated pixels")
73+ }
74+ 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))}
75+ img.Pixels = append([]byte(nil), h[36:36+px]...)
76+ return img, nil
77+}
78+
79+// Scale resizes to w×h by averaging source pixels (box filter); averaging is
80+// correct on premultiplied alpha.
81+func (img Image) Scale(w, h int) Image {
82+ if w <= 0 || h <= 0 || (w == img.W && h == img.H) {
83+ return img
84+ }
85+ 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)}
86+ for y := 0; y < h; y++ {
87+ y0, y1 := y*img.H/h, max((y+1)*img.H/h, y*img.H/h+1)
88+ for x := 0; x < w; x++ {
89+ x0, x1 := x*img.W/w, max((x+1)*img.W/w, x*img.W/w+1)
90+ var sum [4]int
91+ for sy := y0; sy < y1; sy++ {
92+ for sx := x0; sx < x1; sx++ {
93+ p := img.Pixels[(sy*img.W+sx)*4:]
94+ for c := range 4 {
95+ sum[c] += int(p[c])
96+ }
97+ }
98+ }
99+ n := (y1 - y0) * (x1 - x0)
100+ for c := range 4 {
101+ out.Pixels[(y*w+x)*4+c] = byte(sum[c] / n)
102+ }
103+ }
104+ }
105+ return out
106+}
107+
108+// ForSize scales the image so its nominal size becomes size, keeping the
109+// theme's proportions, and never beyond limit pixels on a side.
110+func (img Image) ForSize(size, limit int) Image {
111+ nominal := max(img.Nominal, 1)
112+ w, h := img.W*size/nominal, img.H*size/nominal
113+ if big := max(w, h); big > limit {
114+ w, h = w*limit/big, h*limit/big
115+ }
116+ return img.Scale(max(w, 1), max(h, 1))
117+}
118+
119+// SearchPath is XCURSOR_PATH or the usual icon directories.
120+func SearchPath() []string {
121+ if p := os.Getenv("XCURSOR_PATH"); p != "" {
122+ return filepath.SplitList(p)
123+ }
124+ var dirs []string
125+ if home, err := os.UserHomeDir(); err == nil {
126+ data := os.Getenv("XDG_DATA_HOME")
127+ if data == "" {
128+ data = filepath.Join(home, ".local", "share")
129+ }
130+ dirs = append(dirs, filepath.Join(data, "icons"), filepath.Join(home, ".icons"))
131+ }
132+ return append(dirs, "/usr/share/icons", "/usr/share/pixmaps")
133+}
134+
135+// Load finds the named cursor in theme (following Inherits), falling back to
136+// the "default" and Adwaita themes, and decodes it at size.
137+func Load(dirs []string, theme string, names []string, size int) (Image, error) {
138+ seen := map[string]bool{}
139+ queue := []string{theme, "default", "Adwaita"}
140+ for len(queue) > 0 && len(seen) < 32 {
141+ t := queue[0]
142+ queue = queue[1:]
143+ if t == "" || seen[t] {
144+ continue
145+ }
146+ seen[t] = true
147+ for _, dir := range dirs {
148+ for _, name := range names {
149+ data, err := os.ReadFile(filepath.Join(dir, t, "cursors", name))
150+ if err != nil {
151+ continue
152+ }
153+ if img, err := Decode(data, size); err == nil {
154+ return img, nil
155+ }
156+ }
157+ }
158+ queue = append(inherits(dirs, t), queue...)
159+ }
160+ return Image{}, fmt.Errorf("cursor %v not found in theme %q", names, theme)
161+}
162+
163+// inherits reads Inherits= from the first index.theme of the theme.
164+func inherits(dirs []string, theme string) []string {
165+ for _, dir := range dirs {
166+ f, err := os.Open(filepath.Join(dir, theme, "index.theme"))
167+ if err != nil {
168+ continue
169+ }
170+ defer f.Close()
171+ s := bufio.NewScanner(f)
172+ for s.Scan() {
173+ if v, ok := strings.CutPrefix(strings.TrimSpace(s.Text()), "Inherits"); ok {
174+ if v, ok := strings.CutPrefix(strings.TrimSpace(v), "="); ok {
175+ return strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ';' || r == ' ' })
176+ }
177+ }
178+ }
179+ return nil
180+ }
181+ return nil
182+}
A internal/adapters/xcursor/xcursor_test.go +103 -0
@@ -0,0 +1,103 @@
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+}
@@ -0,0 +1,103 @@
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+}
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