merge: cursor (Xcursor theme, hardware cursor plane, scaled)
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 { | |||
| 33 | 33 | flipStart time.Time | |
| 34 | 34 | flips int | |
| 35 | 35 | monitor Monitor | |
| 36 | + | cursor *Cursor | |
| 36 | 37 | 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 | 44 | // Info describes the chosen output for clients (wl_output, xdg-output). | |
| 40 | 45 | type Info struct { | |
| 41 | 46 | Name string | |
| @@ -91,10 +96,16 @@ func Open(fd int, card string, want Want, log zerowrap.Logger) (*Output, error) | |||
| 91 | 96 | 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 | 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 | 103 | 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 | 109 | // Info returns the chosen connector, monitor and mode. | |
| 99 | 110 | func (o *Output) Info() Info { | |
| 100 | 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 | 140 | // Close restores the CRTC state found at startup and frees buffers. | |
| 130 | 141 | func (o *Output) Close() { | |
| 142 | + | if o.cursor != nil { | |
| 143 | + | o.cursor.close() | |
| 144 | + | } | |
| 131 | 145 | if o.saved.crtcID != 0 { | |
| 132 | 146 | ids := []uint32{o.conn.id} | |
| 133 | 147 | s := o.saved | |
| @@ -143,7 +157,7 @@ func (o *Output) Close() { | |||
| 143 | 157 | } | |
| 144 | 158 | ||
| 145 | 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 | 161 | r, err := newRenderer(o.Width(), o.Height()) | |
| 148 | 162 | if err != nil { | |
| 149 | 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 | 176 | surfaces := make(map[ports.WindowID]ports.SurfaceContent) | |
| 163 | 177 | var scene ports.Scene | |
| 164 | 178 | haveScene, dirty, enabled := false, false, true | |
| 179 | + | cursorScale := -1.0 // not loaded yet | |
| 165 | 180 | frame := 0 | |
| 166 | 181 | stats := time.NewTicker(10 * time.Second) | |
| 167 | 182 | defer stats.Stop() | |
| @@ -179,6 +194,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer, | |||
| 179 | 194 | return err | |
| 180 | 195 | } | |
| 181 | 196 | 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 | 203 | enabled = on | |
| 184 | 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 | 212 | o.log.Info().Dur("flip_ms", d).Msg("slow flip") | |
| 193 | 213 | } | |
| 194 | 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 | 219 | scene, haveScene, dirty = s, true, true | |
| 196 | 220 | case c := <-contents: | |
| 197 | 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 | 272 | func (o *Output) readEvents(ctx context.Context, events chan<- int, errs chan<- error) { | |
| 236 | 273 | buf := make([]byte, 1024) | |
| 237 | 274 | for ctx.Err() == nil { | |
| @@ -33,9 +33,14 @@ type Output struct { | |||||
| 33 | flipStart time.Time | 33 | flipStart time.Time | ||
| 34 | flips int | 34 | flips int | ||
| 35 | monitor Monitor | 35 | monitor Monitor | ||
| 36 | + | cursor *Cursor | |||
| 36 | log zerowrap.Logger | 37 | 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 string | 46 | Name string | ||
| @@ -91,10 +96,16 @@ func Open(fd int, card string, want Want, log zerowrap.Logger) (*Output, error) | |||||
| 91 | return nil, err | 96 | 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, nil | 103 | 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.saved | 147 | 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.Scene | 177 | var scene ports.Scene | ||
| 164 | haveScene, dirty, enabled := false, false, true | 178 | haveScene, dirty, enabled := false, false, true | ||
| 179 | + | cursorScale := -1.0 // not loaded yet | |||
| 165 | frame := 0 | 180 | 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 err | 194 | return err | ||
| 180 | } | 195 | } | ||
| 181 | dirty = haveScene | 196 | 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 = on | 203 | 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, true | 219 | 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 { | |||
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | 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 | 27 | Width, Height int | |
| 24 | 28 | ScreenshotDir string | |
| 25 | 29 | Log zerowrap.Logger | |
| @@ -36,6 +40,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 36 | 40 | var scene ports.Scene | |
| 37 | 41 | haveScene := false | |
| 38 | 42 | frame := 0 | |
| 43 | + | cursorScale := -1.0 // not loaded yet | |
| 39 | 44 | update := func(c ports.SurfaceContent) { | |
| 40 | 45 | if c.Pixels == nil { | |
| 41 | 46 | delete(surfaces, c.ID) | |
| @@ -86,16 +91,28 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 86 | 91 | if !haveScene { | |
| 87 | 92 | 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 | 102 | start := time.Now() | |
| 90 | 103 | if err := r.Render(scene, surfaces); err != nil { | |
| 91 | 104 | return fmt.Errorf("render frame: %w", err) | |
| 92 | 105 | } | |
| 93 | 106 | frame++ | |
| 94 | 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 | 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 | 116 | return fmt.Errorf("latest screenshot: %w", err) | |
| 100 | 117 | } | |
| 101 | 118 | } | |
| @@ -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 int | 27 | Width, Height int | ||
| 24 | ScreenshotDir string | 28 | ScreenshotDir string | ||
| 25 | Log zerowrap.Logger | 29 | Log zerowrap.Logger | ||
| @@ -36,6 +40,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 36 | var scene ports.Scene | 40 | var scene ports.Scene | ||
| 37 | haveScene := false | 41 | haveScene := false | ||
| 38 | frame := 0 | 42 | 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 | continue | 92 | 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 ( | |||
| 16 | 16 | ||
| 17 | 17 | // Run owns the keymap and converts script lines into input events. A keymap | |
| 18 | 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 | 21 | defer func() { km.Close() }() | |
| 21 | 22 | emit := func(code uint32, down bool) error { | |
| 22 | 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 | 109 | x, ex := strconv.ParseFloat(fields[1], 64) | |
| 109 | 110 | y, ey := strconv.ParseFloat(fields[2], 64) | |
| 110 | 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 | 115 | if err := sendPointer(ports.PointerMotion{X: x, Y: y, TimeMsec: now}); err != nil { | |
| 112 | 116 | return err | |
| 113 | 117 | } | |
| @@ -16,7 +16,8 @@ import ( | |||||
| 16 | 16 | ||||
| 17 | // Run owns the keymap and converts script lines into input events. A keymap | 17 | // 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 err | 116 | return err | ||
| 113 | } | 117 | } | ||
M internal/adapters/headlessinput/input_test.go +2 -2
| @@ -31,7 +31,7 @@ func TestScript(t *testing.T) { | |||
| 31 | 31 | script <- tt.line | |
| 32 | 32 | close(script) | |
| 33 | 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 | 35 | t.Fatal(err) | |
| 36 | 36 | } | |
| 37 | 37 | if len(input) != len(tt.names) { | |
| @@ -67,7 +67,7 @@ func TestPointerScript(t *testing.T) { | |||
| 67 | 67 | } | |
| 68 | 68 | close(script) | |
| 69 | 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 | 71 | t.Fatal(err) | |
| 72 | 72 | } | |
| 73 | 73 | if len(input) != 5 { | |
| @@ -31,7 +31,7 @@ func TestScript(t *testing.T) { | |||||
| 31 | script <- tt.line | 31 | 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 { | |||
| 142 | 142 | Keymaps <-chan *xkb.Keymap | |
| 143 | 143 | Width, Height int | |
| 144 | 144 | 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 | |
| 146 | 149 | } | |
| 147 | 150 | ||
| 148 | 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 | 175 | p := pointer{w: float64(opts.Width), h: float64(opts.Height)} | |
| 173 | 176 | p.x, p.y = p.w/2, p.h/2 | |
| 177 | + | if opts.MoveCursor != nil { | |
| 178 | + | opts.MoveCursor(p.x, p.y) | |
| 179 | + | } | |
| 174 | 180 | fd := getFD(li) | |
| 175 | 181 | for ctx.Err() == nil { | |
| 176 | 182 | select { | |
| @@ -242,11 +248,17 @@ func translate(ev uintptr, opts Options, p *pointer) (ports.InputEvent, error) { | |||
| 242 | 248 | case evPointerMotion: | |
| 243 | 249 | pe := pointerEvent(ev) | |
| 244 | 250 | x, y := p.move(pointerDX(pe), pointerDY(pe)) | |
| 251 | + | if opts.MoveCursor != nil { | |
| 252 | + | opts.MoveCursor(x, y) | |
| 253 | + | } | |
| 245 | 254 | log.Debug().Float64("x", x).Float64("y", y).Msg("pointer") | |
| 246 | 255 | return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil | |
| 247 | 256 | case evPointerAbs: | |
| 248 | 257 | pe := pointerEvent(ev) | |
| 249 | 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 | 262 | return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil | |
| 251 | 263 | case evPointerButton: | |
| 252 | 264 | pe := pointerEvent(ev) | |
| @@ -142,7 +142,10 @@ type Options struct { | |||||
| 142 | Keymaps <-chan *xkb.Keymap | 142 | Keymaps <-chan *xkb.Keymap | ||
| 143 | Width, Height int | 143 | Width, Height int | ||
| 144 | Active <-chan bool | 144 | 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 | |||
| 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/2 | 176 | 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}, nil | 255 | 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}, nil | 262 | 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) | |||
| 126 | 126 | relayConfig(ctx, opts.Config, watched, configChanges, keymaps, commands, logging.For(ctx, "config")) | |
| 127 | 127 | }() | |
| 128 | 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 | 132 | go func() { | |
| 130 | 133 | defer workers.Done() | |
| 131 | 134 | if hw != nil { | |
| 132 | 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 | 142 | 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 | 146 | if opts.Script != nil { | |
| 140 | 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 | 177 | return nil, err | |
| 171 | 178 | } | |
| 172 | 179 | return r, nil | |
| 173 | - | }, hw.outputActive, renderScenes, contents) | |
| 180 | + | }, loadCursor, hw.outputActive, renderScenes, contents) | |
| 174 | 181 | }) | |
| 175 | 182 | 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 | 185 | r, err := vulkan.New(w, h) | |
| 179 | 186 | if err != nil { | |
| 180 | 187 | 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 | return | 142 | 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, err | 177 | return nil, err | ||
| 171 | } | 178 | } | ||
| 172 | return r, nil | 179 | return r, nil | ||
| 173 | - | }, hw.outputActive, renderScenes, contents) | 180 | + | }, loadCursor, hw.outputActive, renderScenes, contents) |
| 174 | }) | 181 | }) | ||
| 175 | return | 182 | 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, err | 187 | 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 { | |||
| 206 | 206 | ||
| 207 | 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 | 216 | // SpawnRequest carries core → launcher process arguments. | |
| 210 | 217 | type SpawnRequest struct{ Argv []string } | |
| 211 | 218 | ||
| @@ -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 | ||||