detached 0a85a4e

merge: app-cursors (cursor-shape-v1, client cursor surfaces)

commit
0a85a4e32a0afd9c5e12a097a06dd314c53eb2b5
author
brice · · 2026-09-25 09:56 UTC
merge of
296cfb3 + efe52a3 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
19 files · +782 -50
M internal/adapters/drm/cursor.go +11 -1
@@ -187,13 +187,23 @@ func (c *Cursor) position() (int, int) {
187187 func (c *Cursor) Limit() int { return c.size }
188188
189189 // SetImage shows premultiplied ARGB8888 pixels (w×h, w*4 per row) with the
190-// click point at (hotX, hotY). Larger images are cropped to the plane.
190+// click point at (hotX, hotY). Larger images are cropped to the plane; an
191+// empty image hides the cursor (a client asked for none).
191192 func (c *Cursor) SetImage(pixels []byte, w, h, hotX, hotY int) error {
192193 c.io.Lock()
193194 defer c.io.Unlock()
194195 if c.buf == nil {
195196 return nil
196197 }
198+ if w <= 0 || h <= 0 || len(pixels) < w*h*4 {
199+ wasShown := c.shown
200+ c.shown = false
201+ if !wasShown || c.hidden {
202+ return nil
203+ }
204+ v := modeCursor2{flags: cursorBO, crtcID: c.crtc}
205+ return c.plane.Set(&v)
206+ }
197207 pitch := int(c.buf.pitch)
198208 clear(c.buf.mem)
199209 for y := range min(h, c.size) {
@@ -187,13 +187,23 @@ func (c *Cursor) position() (int, int) {
187 func (c *Cursor) Limit() int { return c.size }187 func (c *Cursor) Limit() int { return c.size }
188 188
189 // SetImage shows premultiplied ARGB8888 pixels (w×h, w*4 per row) with the189 // SetImage shows premultiplied ARGB8888 pixels (w×h, w*4 per row) with the
190-// click point at (hotX, hotY). Larger images are cropped to the plane.190+// click point at (hotX, hotY). Larger images are cropped to the plane; an
191+// empty image hides the cursor (a client asked for none).
191 func (c *Cursor) SetImage(pixels []byte, w, h, hotX, hotY int) error {192 func (c *Cursor) SetImage(pixels []byte, w, h, hotX, hotY int) error {
192 c.io.Lock()193 c.io.Lock()
193 defer c.io.Unlock()194 defer c.io.Unlock()
194 if c.buf == nil {195 if c.buf == nil {
195 return nil196 return nil
196 }197 }
198+ if w <= 0 || h <= 0 || len(pixels) < w*h*4 {
199+ wasShown := c.shown
200+ c.shown = false
201+ if !wasShown || c.hidden {
202+ return nil
203+ }
204+ v := modeCursor2{flags: cursorBO, crtcID: c.crtc}
205+ return c.plane.Set(&v)
206+ }
197 pitch := int(c.buf.pitch)207 pitch := int(c.buf.pitch)
198 clear(c.buf.mem)208 clear(c.buf.mem)
199 for y := range min(h, c.size) {209 for y := range min(h, c.size) {
M internal/adapters/drm/cursor_test.go +25 -0
@@ -168,3 +168,28 @@ func TestCursorHideDropsQueuedMove(t *testing.T) {
168168 t.Fatalf("show: %+v", v)
169169 }
170170 }
171+
172+// An empty image takes the buffer off the plane; the next image shows it.
173+func TestCursorEmptyImageHides(t *testing.T) {
174+ c, plane := testCursor(t)
175+ c.buf.mem = make([]byte, 64*64*4)
176+ c.buf.pitch = 64 * 4
177+ var sets []modeCursor2
178+ plane.EXPECT().Set(mock.Anything).RunAndReturn(func(v *modeCursor2) error { sets = append(sets, *v); return nil })
179+ if err := c.SetImage(nil, 0, 0, 0, 0); err != nil {
180+ t.Fatal(err)
181+ }
182+ if len(sets) != 1 || sets[0].flags != cursorBO || sets[0].handle != 0 || c.shown {
183+ t.Fatalf("hide: %+v shown=%v", sets, c.shown)
184+ }
185+ // Still hidden: no second ioctl.
186+ if err := c.SetImage(nil, 0, 0, 0, 0); err != nil || len(sets) != 1 {
187+ t.Fatalf("second hide: %+v %v", sets, err)
188+ }
189+ if err := c.SetImage(make([]byte, 4), 1, 1, 0, 0); err != nil {
190+ t.Fatal(err)
191+ }
192+ if len(sets) != 2 || sets[1].flags != cursorBO|cursorMove || !c.shown {
193+ t.Fatalf("show: %+v", sets)
194+ }
195+}
@@ -168,3 +168,28 @@ func TestCursorHideDropsQueuedMove(t *testing.T) {
168 t.Fatalf("show: %+v", v)168 t.Fatalf("show: %+v", v)
169 }169 }
170 }170 }
171+
172+// An empty image takes the buffer off the plane; the next image shows it.
173+func TestCursorEmptyImageHides(t *testing.T) {
174+ c, plane := testCursor(t)
175+ c.buf.mem = make([]byte, 64*64*4)
176+ c.buf.pitch = 64 * 4
177+ var sets []modeCursor2
178+ plane.EXPECT().Set(mock.Anything).RunAndReturn(func(v *modeCursor2) error { sets = append(sets, *v); return nil })
179+ if err := c.SetImage(nil, 0, 0, 0, 0); err != nil {
180+ t.Fatal(err)
181+ }
182+ if len(sets) != 1 || sets[0].flags != cursorBO || sets[0].handle != 0 || c.shown {
183+ t.Fatalf("hide: %+v shown=%v", sets, c.shown)
184+ }
185+ // Still hidden: no second ioctl.
186+ if err := c.SetImage(nil, 0, 0, 0, 0); err != nil || len(sets) != 1 {
187+ t.Fatalf("second hide: %+v %v", sets, err)
188+ }
189+ if err := c.SetImage(make([]byte, 4), 1, 1, 0, 0); err != nil {
190+ t.Fatal(err)
191+ }
192+ if len(sets) != 2 || sets[1].flags != cursorBO|cursorMove || !c.shown {
193+ t.Fatalf("show: %+v", sets)
194+ }
195+}
M internal/adapters/drm/output.go +17 -9
@@ -31,9 +31,9 @@ type Output struct {
3131 log zerowrap.Logger
3232 }
3333
34-// CursorLoader returns the cursor image for an output scale, at most limit
35-// pixels on a side.
36-type CursorLoader func(scale float64, limit int) (ports.CursorImage, error)
34+// CursorLoader returns the image of a cursor at an output scale, at most
35+// limit pixels on a side; an empty image hides the cursor.
36+type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error)
3737
3838 // newOutput allocates buffers and the cursor for a connector on crtc.
3939 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {
@@ -117,7 +117,7 @@ func (o *Output) Close() {
117117 }
118118
119119 // Run renders scenes and flips until ctx ends. active reports seat enable/disable.
120-func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent) error {
120+func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange) error {
121121 r, err := newRenderer(o.Width(), o.Height())
122122 if err != nil {
123123 return fmt.Errorf("create renderer: %w", err)
@@ -138,6 +138,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
138138 surfaces := make(map[ports.WindowID]ports.SurfaceContent)
139139 var scene ports.Scene
140140 haveScene, dirty := false, false
141+ var want ports.CursorChange
141142 cursorScale := -1.0 // not loaded yet
142143 frame := 0
143144 stats := time.NewTicker(10 * time.Second)
@@ -174,9 +175,16 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
174175 case s := <-scenes:
175176 if o.cursor != nil && loadCursor != nil && s.Scale != cursorScale {
176177 cursorScale = s.Scale
177- o.setCursor(loadCursor, s.Scale)
178+ o.setCursor(loadCursor, want, s.Scale)
178179 }
179180 scene, haveScene, dirty = s, true, true
181+ case c := <-cursor:
182+ want = c
183+ // Before the first scene the scale is unknown: loaded then.
184+ if o.cursor != nil && loadCursor != nil && cursorScale > 0 {
185+ o.setCursor(loadCursor, want, cursorScale)
186+ }
187+ continue
180188 case c := <-contents:
181189 if c.Empty() {
182190 delete(surfaces, c.ID)
@@ -222,9 +230,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
222230 }
223231 }
224232
225-// setCursor loads the theme cursor for scale onto the cursor plane.
226-func (o *Output) setCursor(load CursorLoader, scale float64) {
227- img, err := load(scale, o.cursor.Limit())
233+// setCursor loads a cursor for scale onto the cursor plane.
234+func (o *Output) setCursor(load CursorLoader, c ports.CursorChange, scale float64) {
235+ img, err := load(c, scale, o.cursor.Limit())
228236 if err == nil {
229237 err = o.cursor.SetImage(img.Pixels, img.W, img.H, img.HotX, img.HotY)
230238 }
@@ -232,5 +240,5 @@ func (o *Output) setCursor(load CursorLoader, scale float64) {
232240 o.log.Warn().Err(err).Msg("cursor")
233241 return
234242 }
235- o.log.Info().Float64("scale", scale).Int("w", img.W).Int("h", img.H).Msg("cursor image")
243+ o.log.Debug().Float64("scale", scale).Str("shape", c.Shape).Bool("client", c.Image != nil).Int("w", img.W).Int("h", img.H).Msg("cursor image")
236244 }
@@ -31,9 +31,9 @@ type Output struct {
31 log zerowrap.Logger31 log zerowrap.Logger
32 }32 }
33 33
34-// CursorLoader returns the cursor image for an output scale, at most limit34+// CursorLoader returns the image of a cursor at an output scale, at most
35-// pixels on a side.35+// limit pixels on a side; an empty image hides the cursor.
36-type CursorLoader func(scale float64, limit int) (ports.CursorImage, error)36+type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error)
37 37
38 // newOutput allocates buffers and the cursor for a connector on crtc.38 // newOutput allocates buffers and the cursor for a connector on crtc.
39 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {39 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {
@@ -117,7 +117,7 @@ func (o *Output) Close() {
117 }117 }
118 118
119 // Run renders scenes and flips until ctx ends. active reports seat enable/disable.119 // Run renders scenes and flips until ctx ends. active reports seat enable/disable.
120-func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent) error {120+func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange) error {
121 r, err := newRenderer(o.Width(), o.Height())121 r, err := newRenderer(o.Width(), o.Height())
122 if err != nil {122 if err != nil {
123 return fmt.Errorf("create renderer: %w", err)123 return fmt.Errorf("create renderer: %w", err)
@@ -138,6 +138,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
138 surfaces := make(map[ports.WindowID]ports.SurfaceContent)138 surfaces := make(map[ports.WindowID]ports.SurfaceContent)
139 var scene ports.Scene139 var scene ports.Scene
140 haveScene, dirty := false, false140 haveScene, dirty := false, false
141+ var want ports.CursorChange
141 cursorScale := -1.0 // not loaded yet142 cursorScale := -1.0 // not loaded yet
142 frame := 0143 frame := 0
143 stats := time.NewTicker(10 * time.Second)144 stats := time.NewTicker(10 * time.Second)
@@ -174,9 +175,16 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
174 case s := <-scenes:175 case s := <-scenes:
175 if o.cursor != nil && loadCursor != nil && s.Scale != cursorScale {176 if o.cursor != nil && loadCursor != nil && s.Scale != cursorScale {
176 cursorScale = s.Scale177 cursorScale = s.Scale
177- o.setCursor(loadCursor, s.Scale)178+ o.setCursor(loadCursor, want, s.Scale)
178 }179 }
179 scene, haveScene, dirty = s, true, true180 scene, haveScene, dirty = s, true, true
181+ case c := <-cursor:
182+ want = c
183+ // Before the first scene the scale is unknown: loaded then.
184+ if o.cursor != nil && loadCursor != nil && cursorScale > 0 {
185+ o.setCursor(loadCursor, want, cursorScale)
186+ }
187+ continue
180 case c := <-contents:188 case c := <-contents:
181 if c.Empty() {189 if c.Empty() {
182 delete(surfaces, c.ID)190 delete(surfaces, c.ID)
@@ -222,9 +230,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
222 }230 }
223 }231 }
224 232
225-// setCursor loads the theme cursor for scale onto the cursor plane.233+// setCursor loads a cursor for scale onto the cursor plane.
226-func (o *Output) setCursor(load CursorLoader, scale float64) {234+func (o *Output) setCursor(load CursorLoader, c ports.CursorChange, scale float64) {
227- img, err := load(scale, o.cursor.Limit())235+ img, err := load(c, scale, o.cursor.Limit())
228 if err == nil {236 if err == nil {
229 err = o.cursor.SetImage(img.Pixels, img.W, img.H, img.HotX, img.HotY)237 err = o.cursor.SetImage(img.Pixels, img.W, img.H, img.HotX, img.HotY)
230 }238 }
@@ -232,5 +240,5 @@ func (o *Output) setCursor(load CursorLoader, scale float64) {
232 o.log.Warn().Err(err).Msg("cursor")240 o.log.Warn().Err(err).Msg("cursor")
233 return241 return
234 }242 }
235- o.log.Info().Float64("scale", scale).Int("w", img.W).Int("h", img.H).Msg("cursor image")243+ o.log.Debug().Float64("scale", scale).Str("shape", c.Shape).Bool("client", c.Image != nil).Int("w", img.W).Int("h", img.H).Msg("cursor image")
236 }244 }
M internal/adapters/headless/output.go +7 -3
@@ -17,14 +17,14 @@ type Options struct {
1717 // Cursor, when set, is drawn into screenshots with the image from
1818 // LoadCursor at the scene scale.
1919 Cursor *Cursor
20- LoadCursor func(scale float64, limit int) (ports.CursorImage, error)
20+ LoadCursor func(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error)
2121 Width, Height int
2222 ScreenshotDir string
2323 Log zerowrap.Logger
2424 NewRenderer func(w, h int) (ports.Renderer, error)
2525 }
2626
27-func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent) error {
27+func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange) error {
2828 r, err := opts.NewRenderer(opts.Width, opts.Height)
2929 if err != nil {
3030 return fmt.Errorf("create renderer: %w", err)
@@ -34,6 +34,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
3434 var scene ports.Scene
3535 haveScene, dirty := false, false
3636 frame := 0
37+ var want ports.CursorChange
3738 cursorScale := -1.0 // not loaded yet
3839 update := func(c ports.SurfaceContent) {
3940 dirty = dirty || scene.Shows(c.ID)
@@ -62,6 +63,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
6263 continue
6364 }
6465 update(c)
66+ case c := <-cursor:
67+ // The cursor is only drawn into screenshots: no new frame.
68+ want, cursorScale = c, -1
6569 }
6670 // Drain all queued updates before presenting one frame.
6771 drain:
@@ -89,7 +93,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
8993 dirty = false
9094 if opts.Cursor != nil && opts.LoadCursor != nil && scene.Scale != cursorScale {
9195 cursorScale = scene.Scale
92- if img, err := opts.LoadCursor(scene.Scale, 256); err == nil {
96+ if img, err := opts.LoadCursor(want, scene.Scale, 256); err == nil {
9397 opts.Cursor.set(img)
9498 } else {
9599 opts.Log.Warn().Err(err).Msg("cursor")
@@ -17,14 +17,14 @@ type Options struct {
17 // Cursor, when set, is drawn into screenshots with the image from17 // Cursor, when set, is drawn into screenshots with the image from
18 // LoadCursor at the scene scale.18 // LoadCursor at the scene scale.
19 Cursor *Cursor19 Cursor *Cursor
20- LoadCursor func(scale float64, limit int) (ports.CursorImage, error)20+ LoadCursor func(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error)
21 Width, Height int21 Width, Height int
22 ScreenshotDir string22 ScreenshotDir string
23 Log zerowrap.Logger23 Log zerowrap.Logger
24 NewRenderer func(w, h int) (ports.Renderer, error)24 NewRenderer func(w, h int) (ports.Renderer, error)
25 }25 }
26 26
27-func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent) error {27+func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange) error {
28 r, err := opts.NewRenderer(opts.Width, opts.Height)28 r, err := opts.NewRenderer(opts.Width, opts.Height)
29 if err != nil {29 if err != nil {
30 return fmt.Errorf("create renderer: %w", err)30 return fmt.Errorf("create renderer: %w", err)
@@ -34,6 +34,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
34 var scene ports.Scene34 var scene ports.Scene
35 haveScene, dirty := false, false35 haveScene, dirty := false, false
36 frame := 036 frame := 0
37+ var want ports.CursorChange
37 cursorScale := -1.0 // not loaded yet38 cursorScale := -1.0 // not loaded yet
38 update := func(c ports.SurfaceContent) {39 update := func(c ports.SurfaceContent) {
39 dirty = dirty || scene.Shows(c.ID)40 dirty = dirty || scene.Shows(c.ID)
@@ -62,6 +63,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
62 continue63 continue
63 }64 }
64 update(c)65 update(c)
66+ case c := <-cursor:
67+ // The cursor is only drawn into screenshots: no new frame.
68+ want, cursorScale = c, -1
65 }69 }
66 // Drain all queued updates before presenting one frame.70 // Drain all queued updates before presenting one frame.
67 drain:71 drain:
@@ -89,7 +93,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
89 dirty = false93 dirty = false
90 if opts.Cursor != nil && opts.LoadCursor != nil && scene.Scale != cursorScale {94 if opts.Cursor != nil && opts.LoadCursor != nil && scene.Scale != cursorScale {
91 cursorScale = scene.Scale95 cursorScale = scene.Scale
92- if img, err := opts.LoadCursor(scene.Scale, 256); err == nil {96+ if img, err := opts.LoadCursor(want, scene.Scale, 256); err == nil {
93 opts.Cursor.set(img)97 opts.Cursor.set(img)
94 } else {98 } else {
95 opts.Log.Warn().Err(err).Msg("cursor")99 opts.Log.Warn().Err(err).Msg("cursor")
M internal/adapters/headless/output_test.go +5 -5
@@ -76,7 +76,7 @@ func TestRun(t *testing.T) {
7676 ctx, cancel := context.WithCancel(context.Background())
7777 done := make(chan error, 1)
7878 go func() {
79- done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents)
79+ done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil)
8080 }()
8181 waitFrames(t, f, 1)
8282 if s, c := f.snapshot(); len(s) != 1 || s[0].Seq != 2 || len(c[0]) != 2 {
@@ -115,7 +115,7 @@ func TestRunSkipsContentNotShown(t *testing.T) {
115115 ctx, cancel := context.WithCancel(context.Background())
116116 done := make(chan error, 1)
117117 go func() {
118- done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents)
118+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil)
119119 }()
120120 waitFrames(t, f, 1)
121121 contents <- ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}}
@@ -139,11 +139,11 @@ func TestErrors(t *testing.T) {
139139 expected := errors.New("failure")
140140 scenes := make(chan ports.Scene, 1)
141141 scenes <- ports.Scene{}
142- if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return nil, expected }}, scenes, nil); !errors.Is(err, expected) {
142+ if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return nil, expected }}, scenes, nil, nil); !errors.Is(err, expected) {
143143 t.Fatal(err)
144144 }
145145 r, _ := recordingRenderer(t, expected)
146- if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil); !errors.Is(err, expected) {
146+ if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil); !errors.Is(err, expected) {
147147 t.Fatal(err)
148148 }
149149 }
@@ -184,7 +184,7 @@ func TestRunPassesLayerContent(t *testing.T) {
184184 ctx, cancel := context.WithCancel(context.Background())
185185 done := make(chan error, 1)
186186 go func() {
187- done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents)
187+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil)
188188 }()
189189 deadline := time.After(3 * time.Second)
190190 for {
@@ -76,7 +76,7 @@ func TestRun(t *testing.T) {
76 ctx, cancel := context.WithCancel(context.Background())76 ctx, cancel := context.WithCancel(context.Background())
77 done := make(chan error, 1)77 done := make(chan error, 1)
78 go func() {78 go func() {
79- done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents)79+ done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil)
80 }()80 }()
81 waitFrames(t, f, 1)81 waitFrames(t, f, 1)
82 if s, c := f.snapshot(); len(s) != 1 || s[0].Seq != 2 || len(c[0]) != 2 {82 if s, c := f.snapshot(); len(s) != 1 || s[0].Seq != 2 || len(c[0]) != 2 {
@@ -115,7 +115,7 @@ func TestRunSkipsContentNotShown(t *testing.T) {
115 ctx, cancel := context.WithCancel(context.Background())115 ctx, cancel := context.WithCancel(context.Background())
116 done := make(chan error, 1)116 done := make(chan error, 1)
117 go func() {117 go func() {
118- done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents)118+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil)
119 }()119 }()
120 waitFrames(t, f, 1)120 waitFrames(t, f, 1)
121 contents <- ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}}121 contents <- ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}}
@@ -139,11 +139,11 @@ func TestErrors(t *testing.T) {
139 expected := errors.New("failure")139 expected := errors.New("failure")
140 scenes := make(chan ports.Scene, 1)140 scenes := make(chan ports.Scene, 1)
141 scenes <- ports.Scene{}141 scenes <- ports.Scene{}
142- if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return nil, expected }}, scenes, nil); !errors.Is(err, expected) {142+ if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return nil, expected }}, scenes, nil, nil); !errors.Is(err, expected) {
143 t.Fatal(err)143 t.Fatal(err)
144 }144 }
145 r, _ := recordingRenderer(t, expected)145 r, _ := recordingRenderer(t, expected)
146- if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil); !errors.Is(err, expected) {146+ if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil); !errors.Is(err, expected) {
147 t.Fatal(err)147 t.Fatal(err)
148 }148 }
149 }149 }
@@ -184,7 +184,7 @@ func TestRunPassesLayerContent(t *testing.T) {
184 ctx, cancel := context.WithCancel(context.Background())184 ctx, cancel := context.WithCancel(context.Background())
185 done := make(chan error, 1)185 done := make(chan error, 1)
186 go func() {186 go func() {
187- done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents)187+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil)
188 }()188 }()
189 deadline := time.After(3 * time.Second)189 deadline := time.After(3 * time.Second)
190 for {190 for {
A internal/adapters/wayland/cursor.go +171 -0
@@ -0,0 +1,171 @@
1+package wayland
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/nefertty/internal/ports"
7+ "github.com/bnema/purego-libwayland/protocol/cursorshape"
8+ "github.com/bnema/purego-libwayland/protocol/tabletv2"
9+ "github.com/bnema/purego-libwayland/protocol/wayland"
10+ "github.com/bnema/purego-libwayland/server"
11+)
12+
13+// The client under the pointer picks the cursor: a theme shape
14+// (wp_cursor_shape_v1), its own surface (wl_pointer.set_cursor) or none.
15+// Changes go to the outputs latest first; a pointer focus change resets
16+// the cursor to the arrow until the new client sets one.
17+
18+// maxCursorSide bounds client cursor images read from shm.
19+const maxCursorSide = 256
20+
21+// cursorShapes are the CSS names of wp_cursor_shape_device_v1 shapes, by
22+// enum value.
23+var cursorShapes = [...]string{
24+ 1: "default", "context-menu", "help", "pointer", "progress", "wait", "cell",
25+ "crosshair", "text", "vertical-text", "alias", "copy", "move", "no-drop",
26+ "not-allowed", "grab", "grabbing", "e-resize", "n-resize", "ne-resize",
27+ "nw-resize", "s-resize", "se-resize", "sw-resize", "w-resize", "ew-resize",
28+ "ns-resize", "nesw-resize", "nwse-resize", "col-resize", "row-resize",
29+ "all-scroll", "zoom-in", "zoom-out", "dnd-ask", "all-resize",
30+}
31+
32+func registerCursorShape(d *server.Display, s *Server) error {
33+ return cursorshape.NewWpCursorShapeManagerV1Global(d, 2, func(c server.Client, v, id uint32) {
34+ _, _ = cursorshape.NewWpCursorShapeManagerV1(c, int32(v), id, cursorShapeManager{s})
35+ })
36+}
37+
38+type cursorShapeManager struct{ server *Server }
39+
40+func (cursorShapeManager) Destroy(r *cursorshape.WpCursorShapeManagerV1) { r.Destroy() }
41+func (m cursorShapeManager) GetPointer(r *cursorshape.WpCursorShapeManagerV1, id uint32, _ *wayland.Pointer) {
42+ _, _ = cursorshape.NewWpCursorShapeDeviceV1(r.Client(), r.Version(), id, cursorShapeDevice(m))
43+}
44+
45+// GetTabletToolV2 is unreachable: nefertty has no tablet global.
46+func (cursorShapeManager) GetTabletToolV2(*cursorshape.WpCursorShapeManagerV1, uint32, *tabletv2.ZwpTabletToolV2) {
47+}
48+
49+type cursorShapeDevice struct{ server *Server }
50+
51+func (cursorShapeDevice) Destroy(r *cursorshape.WpCursorShapeDeviceV1) { r.Destroy() }
52+func (d cursorShapeDevice) SetShape(r *cursorshape.WpCursorShapeDeviceV1, _ uint32, shape uint32) {
53+ last := cursorshape.WpCursorShapeDeviceV1ShapeZoomOut
54+ if r.Version() >= 2 {
55+ last = cursorshape.WpCursorShapeDeviceV1ShapeAllResize
56+ }
57+ if shape < uint32(cursorshape.WpCursorShapeDeviceV1ShapeDefault) || shape > uint32(last) {
58+ r.PostError(uint32(cursorshape.WpCursorShapeDeviceV1ErrorInvalidShape), "invalid shape")
59+ return
60+ }
61+ s := d.server
62+ if !s.hasPointerFocus(r.Client()) {
63+ return
64+ }
65+ s.cursorSurface = nil
66+ s.setCursor(ports.CursorChange{Shape: cursorShapes[shape]})
67+}
68+
69+type pointer struct{ server *Server }
70+
71+func (p pointer) SetCursor(r *wayland.Pointer, _ uint32, surf *wayland.Surface, hotX, hotY int32) {
72+ s := p.server
73+ var state *surface
74+ if surf != nil {
75+ state = s.surfaces[surf.Resource]
76+ if state == nil {
77+ return
78+ }
79+ if state.kind != roleNone && state.kind != roleCursor {
80+ r.PostError(uint32(wayland.PointerErrorRole), "surface already has another role")
81+ return
82+ }
83+ state.kind = roleCursor
84+ state.role = func(bool) {
85+ if s.cursorSurface == state {
86+ s.setCursor(state.cursorImage())
87+ }
88+ }
89+ }
90+ if !s.hasPointerFocus(r.Client()) {
91+ return
92+ }
93+ if state == nil {
94+ s.cursorSurface = nil
95+ s.setCursor(ports.CursorChange{Hidden: true})
96+ return
97+ }
98+ s.cursorSurface, state.hotX, state.hotY = state, int(hotX), int(hotY)
99+ s.setCursor(state.cursorImage())
100+}
101+func (pointer) Release(r *wayland.Pointer) { r.Destroy() }
102+
103+// hasPointerFocus reports whether the client owns the window under the pointer.
104+func (s *Server) hasPointerFocus(c server.Client) bool {
105+ w := s.windows[s.pointerFocus]
106+ return w != nil && w.mapped && w.xdg.resource.Client() == c
107+}
108+
109+// cursorImage copies the committed shm buffer of a cursor surface. No
110+// buffer hides the cursor; an unreadable one falls back to the arrow.
111+func (s *surface) cursorImage() ports.CursorChange {
112+ if s.current == nil {
113+ return ports.CursorChange{Hidden: true}
114+ }
115+ b, ok := s.server.buffers[s.current.Resource].(*buffer)
116+ if !ok || b.width > maxCursorSide || b.height > maxCursorSide {
117+ return ports.CursorChange{}
118+ }
119+ img := ports.CursorImage{W: b.width, H: b.height, Pixels: make([]byte, b.width*b.height*4)}
120+ for y := range b.height {
121+ row := img.Pixels[y*b.width*4 : (y+1)*b.width*4]
122+ if _, err := b.pool.file.ReadAt(row, int64(b.offset+y*b.stride)); err != nil {
123+ return ports.CursorChange{}
124+ }
125+ if b.format == uint32(wayland.ShmFormatXrgb8888) {
126+ for i := 3; i < len(row); i += 4 {
127+ row[i] = 255
128+ }
129+ }
130+ }
131+ scale := max(s.bufferScale, 1)
132+ img.HotX, img.HotY = min(max(s.hotX*scale, 0), b.width-1), min(max(s.hotY*scale, 0), b.height-1)
133+ return ports.CursorChange{Image: &img, Scale: scale}
134+}
135+
136+// setCursor queues a cursor change; only the latest is delivered.
137+func (s *Server) setCursor(c ports.CursorChange) {
138+ if s.channels.Cursors == nil {
139+ return
140+ }
141+ s.log.Debug().Str("shape", c.Shape).Bool("client", c.Image != nil).Bool("hidden", c.Hidden).Msg("cursor")
142+ s.cursorMu.Lock()
143+ s.cursorLatest, s.cursorQueued = c, true
144+ s.cursorMu.Unlock()
145+ select {
146+ case s.cursorReady <- struct{}{}:
147+ default:
148+ }
149+}
150+
151+func (s *Server) forwardCursors(ctx context.Context) {
152+ for {
153+ select {
154+ case <-ctx.Done():
155+ return
156+ case <-s.cursorReady:
157+ }
158+ s.cursorMu.Lock()
159+ c, ok := s.cursorLatest, s.cursorQueued
160+ s.cursorQueued = false
161+ s.cursorMu.Unlock()
162+ if !ok {
163+ continue
164+ }
165+ select {
166+ case <-ctx.Done():
167+ return
168+ case s.channels.Cursors <- c:
169+ }
170+ }
171+}
@@ -0,0 +1,171 @@
1+package wayland
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/nefertty/internal/ports"
7+ "github.com/bnema/purego-libwayland/protocol/cursorshape"
8+ "github.com/bnema/purego-libwayland/protocol/tabletv2"
9+ "github.com/bnema/purego-libwayland/protocol/wayland"
10+ "github.com/bnema/purego-libwayland/server"
11+)
12+
13+// The client under the pointer picks the cursor: a theme shape
14+// (wp_cursor_shape_v1), its own surface (wl_pointer.set_cursor) or none.
15+// Changes go to the outputs latest first; a pointer focus change resets
16+// the cursor to the arrow until the new client sets one.
17+
18+// maxCursorSide bounds client cursor images read from shm.
19+const maxCursorSide = 256
20+
21+// cursorShapes are the CSS names of wp_cursor_shape_device_v1 shapes, by
22+// enum value.
23+var cursorShapes = [...]string{
24+ 1: "default", "context-menu", "help", "pointer", "progress", "wait", "cell",
25+ "crosshair", "text", "vertical-text", "alias", "copy", "move", "no-drop",
26+ "not-allowed", "grab", "grabbing", "e-resize", "n-resize", "ne-resize",
27+ "nw-resize", "s-resize", "se-resize", "sw-resize", "w-resize", "ew-resize",
28+ "ns-resize", "nesw-resize", "nwse-resize", "col-resize", "row-resize",
29+ "all-scroll", "zoom-in", "zoom-out", "dnd-ask", "all-resize",
30+}
31+
32+func registerCursorShape(d *server.Display, s *Server) error {
33+ return cursorshape.NewWpCursorShapeManagerV1Global(d, 2, func(c server.Client, v, id uint32) {
34+ _, _ = cursorshape.NewWpCursorShapeManagerV1(c, int32(v), id, cursorShapeManager{s})
35+ })
36+}
37+
38+type cursorShapeManager struct{ server *Server }
39+
40+func (cursorShapeManager) Destroy(r *cursorshape.WpCursorShapeManagerV1) { r.Destroy() }
41+func (m cursorShapeManager) GetPointer(r *cursorshape.WpCursorShapeManagerV1, id uint32, _ *wayland.Pointer) {
42+ _, _ = cursorshape.NewWpCursorShapeDeviceV1(r.Client(), r.Version(), id, cursorShapeDevice(m))
43+}
44+
45+// GetTabletToolV2 is unreachable: nefertty has no tablet global.
46+func (cursorShapeManager) GetTabletToolV2(*cursorshape.WpCursorShapeManagerV1, uint32, *tabletv2.ZwpTabletToolV2) {
47+}
48+
49+type cursorShapeDevice struct{ server *Server }
50+
51+func (cursorShapeDevice) Destroy(r *cursorshape.WpCursorShapeDeviceV1) { r.Destroy() }
52+func (d cursorShapeDevice) SetShape(r *cursorshape.WpCursorShapeDeviceV1, _ uint32, shape uint32) {
53+ last := cursorshape.WpCursorShapeDeviceV1ShapeZoomOut
54+ if r.Version() >= 2 {
55+ last = cursorshape.WpCursorShapeDeviceV1ShapeAllResize
56+ }
57+ if shape < uint32(cursorshape.WpCursorShapeDeviceV1ShapeDefault) || shape > uint32(last) {
58+ r.PostError(uint32(cursorshape.WpCursorShapeDeviceV1ErrorInvalidShape), "invalid shape")
59+ return
60+ }
61+ s := d.server
62+ if !s.hasPointerFocus(r.Client()) {
63+ return
64+ }
65+ s.cursorSurface = nil
66+ s.setCursor(ports.CursorChange{Shape: cursorShapes[shape]})
67+}
68+
69+type pointer struct{ server *Server }
70+
71+func (p pointer) SetCursor(r *wayland.Pointer, _ uint32, surf *wayland.Surface, hotX, hotY int32) {
72+ s := p.server
73+ var state *surface
74+ if surf != nil {
75+ state = s.surfaces[surf.Resource]
76+ if state == nil {
77+ return
78+ }
79+ if state.kind != roleNone && state.kind != roleCursor {
80+ r.PostError(uint32(wayland.PointerErrorRole), "surface already has another role")
81+ return
82+ }
83+ state.kind = roleCursor
84+ state.role = func(bool) {
85+ if s.cursorSurface == state {
86+ s.setCursor(state.cursorImage())
87+ }
88+ }
89+ }
90+ if !s.hasPointerFocus(r.Client()) {
91+ return
92+ }
93+ if state == nil {
94+ s.cursorSurface = nil
95+ s.setCursor(ports.CursorChange{Hidden: true})
96+ return
97+ }
98+ s.cursorSurface, state.hotX, state.hotY = state, int(hotX), int(hotY)
99+ s.setCursor(state.cursorImage())
100+}
101+func (pointer) Release(r *wayland.Pointer) { r.Destroy() }
102+
103+// hasPointerFocus reports whether the client owns the window under the pointer.
104+func (s *Server) hasPointerFocus(c server.Client) bool {
105+ w := s.windows[s.pointerFocus]
106+ return w != nil && w.mapped && w.xdg.resource.Client() == c
107+}
108+
109+// cursorImage copies the committed shm buffer of a cursor surface. No
110+// buffer hides the cursor; an unreadable one falls back to the arrow.
111+func (s *surface) cursorImage() ports.CursorChange {
112+ if s.current == nil {
113+ return ports.CursorChange{Hidden: true}
114+ }
115+ b, ok := s.server.buffers[s.current.Resource].(*buffer)
116+ if !ok || b.width > maxCursorSide || b.height > maxCursorSide {
117+ return ports.CursorChange{}
118+ }
119+ img := ports.CursorImage{W: b.width, H: b.height, Pixels: make([]byte, b.width*b.height*4)}
120+ for y := range b.height {
121+ row := img.Pixels[y*b.width*4 : (y+1)*b.width*4]
122+ if _, err := b.pool.file.ReadAt(row, int64(b.offset+y*b.stride)); err != nil {
123+ return ports.CursorChange{}
124+ }
125+ if b.format == uint32(wayland.ShmFormatXrgb8888) {
126+ for i := 3; i < len(row); i += 4 {
127+ row[i] = 255
128+ }
129+ }
130+ }
131+ scale := max(s.bufferScale, 1)
132+ img.HotX, img.HotY = min(max(s.hotX*scale, 0), b.width-1), min(max(s.hotY*scale, 0), b.height-1)
133+ return ports.CursorChange{Image: &img, Scale: scale}
134+}
135+
136+// setCursor queues a cursor change; only the latest is delivered.
137+func (s *Server) setCursor(c ports.CursorChange) {
138+ if s.channels.Cursors == nil {
139+ return
140+ }
141+ s.log.Debug().Str("shape", c.Shape).Bool("client", c.Image != nil).Bool("hidden", c.Hidden).Msg("cursor")
142+ s.cursorMu.Lock()
143+ s.cursorLatest, s.cursorQueued = c, true
144+ s.cursorMu.Unlock()
145+ select {
146+ case s.cursorReady <- struct{}{}:
147+ default:
148+ }
149+}
150+
151+func (s *Server) forwardCursors(ctx context.Context) {
152+ for {
153+ select {
154+ case <-ctx.Done():
155+ return
156+ case <-s.cursorReady:
157+ }
158+ s.cursorMu.Lock()
159+ c, ok := s.cursorLatest, s.cursorQueued
160+ s.cursorQueued = false
161+ s.cursorMu.Unlock()
162+ if !ok {
163+ continue
164+ }
165+ select {
166+ case <-ctx.Done():
167+ return
168+ case s.channels.Cursors <- c:
169+ }
170+ }
171+}
A internal/adapters/wayland/cursor_test.go +207 -0
@@ -0,0 +1,207 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/nefertty/internal/logging"
9+ "github.com/bnema/nefertty/internal/ports"
10+ "github.com/bnema/purego-libwayland/protocol/cursorshape"
11+ "github.com/bnema/purego-libwayland/protocol/wayland"
12+ "golang.org/x/sys/unix"
13+)
14+
15+func cursorServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, chan ports.CursorChange, string) {
16+ t.Helper()
17+ dir := t.TempDir()
18+ events := make(chan ports.ClientEvent, 16)
19+ commands := make(chan ports.ClientCommand, 16)
20+ cursors := make(chan ports.CursorChange, 16)
21+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Commands: commands, Cursors: cursors}, logging.For(context.Background(), "wayland"))
22+ if err != nil {
23+ t.Fatal(err)
24+ }
25+ ctx, cancel := context.WithCancel(context.Background())
26+ done := make(chan error, 1)
27+ go func() { done <- s.Run(ctx) }()
28+ t.Cleanup(func() { cancel(); <-done })
29+ return s, events, commands, cursors, dir
30+}
31+
32+// nextCursor returns the latest cursor change sent within a short wait.
33+func nextCursor(t *testing.T, cursors <-chan ports.CursorChange) ports.CursorChange {
34+ t.Helper()
35+ var c ports.CursorChange
36+ select {
37+ case c = <-cursors:
38+ case <-time.After(2 * time.Second):
39+ t.Fatal("no cursor change")
40+ }
41+ for {
42+ select {
43+ case c = <-cursors:
44+ case <-time.After(50 * time.Millisecond):
45+ return c
46+ }
47+ }
48+}
49+
50+func TestCursorShapeFollowsPointerFocus(t *testing.T) {
51+ s, events, commands, cursors, dir := cursorServer(t)
52+ c := protocolClient(t, s, dir)
53+ seat := bindProtocol(t, c, "wl_seat")
54+ registerProtocol(t, c, seat)
55+ pointer := c.AllocateID()
56+ registerProtocol(t, c, pointer)
57+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
58+ manager := bindProtocol(t, c, "wp_cursor_shape_manager_v1")
59+ device := c.AllocateID()
60+ registerProtocol(t, c, device)
61+ requestProtocol(t, c, manager, cursorshape.WpCursorShapeManagerV1RequestGetPointer, device, pointer)
62+ w := toplevelMapper(t, c, events)()
63+
64+ // Without pointer focus the request is ignored.
65+ requestProtocol(t, c, device, cursorshape.WpCursorShapeDeviceV1RequestSetShape, uint32(1), uint32(cursorshape.WpCursorShapeDeviceV1ShapeText))
66+ if err := c.Roundtrip(); err != nil {
67+ t.Fatal(err)
68+ }
69+ select {
70+ case got := <-cursors:
71+ t.Fatalf("cursor change without focus: %+v", got)
72+ case <-time.After(50 * time.Millisecond):
73+ }
74+
75+ commands <- ports.PointerFocus{ID: w.ID, X: 0, Y: 0}
76+ if got := nextCursor(t, cursors); got != (ports.CursorChange{}) {
77+ t.Fatalf("enter: %+v, want the arrow", got)
78+ }
79+ requestProtocol(t, c, device, cursorshape.WpCursorShapeDeviceV1RequestSetShape, uint32(2), uint32(cursorshape.WpCursorShapeDeviceV1ShapePointer))
80+ if err := c.Roundtrip(); err != nil {
81+ t.Fatal(err)
82+ }
83+ if got := nextCursor(t, cursors); got.Shape != "pointer" {
84+ t.Fatalf("shape: %+v", got)
85+ }
86+ commands <- ports.PointerFocus{ID: 0}
87+ if got := nextCursor(t, cursors); got != (ports.CursorChange{}) {
88+ t.Fatalf("leave: %+v, want the arrow", got)
89+ }
90+}
91+
92+func TestCursorShapeNames(t *testing.T) {
93+ for shape, want := range map[cursorshape.WpCursorShapeDeviceV1Shape]string{
94+ cursorshape.WpCursorShapeDeviceV1ShapeDefault: "default",
95+ cursorshape.WpCursorShapeDeviceV1ShapeNwseResize: "nwse-resize",
96+ cursorshape.WpCursorShapeDeviceV1ShapeZoomOut: "zoom-out",
97+ cursorshape.WpCursorShapeDeviceV1ShapeAllResize: "all-resize",
98+ cursorshape.WpCursorShapeDeviceV1ShapeVerticalText: "vertical-text",
99+ } {
100+ if got := cursorShapes[shape]; got != want {
101+ t.Errorf("shape %d = %q, want %q", shape, got, want)
102+ }
103+ }
104+}
105+
106+func TestCursorShapeInvalid(t *testing.T) {
107+ s, _, _, _, dir := cursorServer(t)
108+ c := protocolClient(t, s, dir)
109+ seat := bindProtocol(t, c, "wl_seat")
110+ registerProtocol(t, c, seat)
111+ pointer := c.AllocateID()
112+ registerProtocol(t, c, pointer)
113+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
114+ manager := bindProtocol(t, c, "wp_cursor_shape_manager_v1")
115+ device := c.AllocateID()
116+ registerProtocol(t, c, device)
117+ requestProtocol(t, c, manager, cursorshape.WpCursorShapeManagerV1RequestGetPointer, device, pointer)
118+ // Version 1 ends at zoom_out.
119+ requestProtocol(t, c, device, cursorshape.WpCursorShapeDeviceV1RequestSetShape, uint32(1), uint32(cursorshape.WpCursorShapeDeviceV1ShapeDndAsk))
120+ expectProtocolError(t, c, device, uint32(cursorshape.WpCursorShapeDeviceV1ErrorInvalidShape))
121+}
122+
123+func TestSetCursorSurface(t *testing.T) {
124+ s, events, commands, cursors, dir := cursorServer(t)
125+ c := protocolClient(t, s, dir)
126+ seat := bindProtocol(t, c, "wl_seat")
127+ registerProtocol(t, c, seat)
128+ pointer := c.AllocateID()
129+ registerProtocol(t, c, pointer)
130+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
131+ w := toplevelMapper(t, c, events)()
132+ commands <- ports.PointerFocus{ID: w.ID, X: 0, Y: 0}
133+ nextCursor(t, cursors)
134+
135+ comp := bindProtocol(t, c, "wl_compositor")
136+ shm := bindProtocol(t, c, "wl_shm")
137+ registerProtocol(t, c, shm)
138+ fd, err := unix.MemfdCreate("cursor", unix.MFD_CLOEXEC)
139+ if err != nil {
140+ t.Fatal(err)
141+ }
142+ defer unix.Close(fd)
143+ // 2x1 ARGB: opaque blue, then transparent; stride padded to 12.
144+ if _, err := unix.Pwrite(fd, []byte{255, 0, 0, 255, 0, 0, 0, 0, 9, 9, 9, 9}, 0); err != nil {
145+ t.Fatal(err)
146+ }
147+ pool, buf := c.AllocateID(), c.AllocateID()
148+ registerProtocol(t, c, pool)
149+ registerProtocol(t, c, buf)
150+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(12)); err != nil {
151+ t.Fatal(err)
152+ }
153+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(2), int32(1), int32(12), uint32(wayland.ShmFormatArgb8888))
154+ surf := c.AllocateID()
155+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
156+ registerProtocol(t, c, surf)
157+
158+ // A cursor surface without a buffer hides the cursor.
159+ requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), surf, int32(1), int32(0))
160+ if err := c.Roundtrip(); err != nil {
161+ t.Fatal(err)
162+ }
163+ if got := nextCursor(t, cursors); !got.Hidden {
164+ t.Fatalf("empty surface: %+v", got)
165+ }
166+ // A commit updates the image.
167+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
168+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
169+ if err := c.Roundtrip(); err != nil {
170+ t.Fatal(err)
171+ }
172+ got := nextCursor(t, cursors)
173+ if got.Image == nil || got.Scale != 1 || got.Image.W != 2 || got.Image.H != 1 || got.Image.HotX != 1 || string(got.Image.Pixels) != string([]byte{255, 0, 0, 255, 0, 0, 0, 0}) {
174+ t.Fatalf("image: %+v %+v", got, got.Image)
175+ }
176+ // A null surface hides the cursor.
177+ requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), uint32(0), int32(0), int32(0))
178+ if err := c.Roundtrip(); err != nil {
179+ t.Fatal(err)
180+ }
181+ if got := nextCursor(t, cursors); !got.Hidden {
182+ t.Fatalf("null surface: %+v", got)
183+ }
184+}
185+
186+func TestSetCursorRoleError(t *testing.T) {
187+ s, events, _, _, dir := cursorServer(t)
188+ c := protocolClient(t, s, dir)
189+ seat := bindProtocol(t, c, "wl_seat")
190+ registerProtocol(t, c, seat)
191+ pointer := c.AllocateID()
192+ registerProtocol(t, c, pointer)
193+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
194+ toplevelMapper(t, c, events)()
195+ // Surface 3 of the mapper's connection is the toplevel's wl_surface:
196+ // find it through the server instead of guessing ids.
197+ var surf uint32
198+ s.display.Do(func() {
199+ for _, st := range s.surfaces {
200+ if st.kind == roleXDG {
201+ surf = st.wl.ID()
202+ }
203+ }
204+ })
205+ requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), surf, int32(0), int32(0))
206+ expectProtocolError(t, c, pointer, uint32(wayland.PointerErrorRole))
207+}
@@ -0,0 +1,207 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/nefertty/internal/logging"
9+ "github.com/bnema/nefertty/internal/ports"
10+ "github.com/bnema/purego-libwayland/protocol/cursorshape"
11+ "github.com/bnema/purego-libwayland/protocol/wayland"
12+ "golang.org/x/sys/unix"
13+)
14+
15+func cursorServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, chan ports.CursorChange, string) {
16+ t.Helper()
17+ dir := t.TempDir()
18+ events := make(chan ports.ClientEvent, 16)
19+ commands := make(chan ports.ClientCommand, 16)
20+ cursors := make(chan ports.CursorChange, 16)
21+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Commands: commands, Cursors: cursors}, logging.For(context.Background(), "wayland"))
22+ if err != nil {
23+ t.Fatal(err)
24+ }
25+ ctx, cancel := context.WithCancel(context.Background())
26+ done := make(chan error, 1)
27+ go func() { done <- s.Run(ctx) }()
28+ t.Cleanup(func() { cancel(); <-done })
29+ return s, events, commands, cursors, dir
30+}
31+
32+// nextCursor returns the latest cursor change sent within a short wait.
33+func nextCursor(t *testing.T, cursors <-chan ports.CursorChange) ports.CursorChange {
34+ t.Helper()
35+ var c ports.CursorChange
36+ select {
37+ case c = <-cursors:
38+ case <-time.After(2 * time.Second):
39+ t.Fatal("no cursor change")
40+ }
41+ for {
42+ select {
43+ case c = <-cursors:
44+ case <-time.After(50 * time.Millisecond):
45+ return c
46+ }
47+ }
48+}
49+
50+func TestCursorShapeFollowsPointerFocus(t *testing.T) {
51+ s, events, commands, cursors, dir := cursorServer(t)
52+ c := protocolClient(t, s, dir)
53+ seat := bindProtocol(t, c, "wl_seat")
54+ registerProtocol(t, c, seat)
55+ pointer := c.AllocateID()
56+ registerProtocol(t, c, pointer)
57+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
58+ manager := bindProtocol(t, c, "wp_cursor_shape_manager_v1")
59+ device := c.AllocateID()
60+ registerProtocol(t, c, device)
61+ requestProtocol(t, c, manager, cursorshape.WpCursorShapeManagerV1RequestGetPointer, device, pointer)
62+ w := toplevelMapper(t, c, events)()
63+
64+ // Without pointer focus the request is ignored.
65+ requestProtocol(t, c, device, cursorshape.WpCursorShapeDeviceV1RequestSetShape, uint32(1), uint32(cursorshape.WpCursorShapeDeviceV1ShapeText))
66+ if err := c.Roundtrip(); err != nil {
67+ t.Fatal(err)
68+ }
69+ select {
70+ case got := <-cursors:
71+ t.Fatalf("cursor change without focus: %+v", got)
72+ case <-time.After(50 * time.Millisecond):
73+ }
74+
75+ commands <- ports.PointerFocus{ID: w.ID, X: 0, Y: 0}
76+ if got := nextCursor(t, cursors); got != (ports.CursorChange{}) {
77+ t.Fatalf("enter: %+v, want the arrow", got)
78+ }
79+ requestProtocol(t, c, device, cursorshape.WpCursorShapeDeviceV1RequestSetShape, uint32(2), uint32(cursorshape.WpCursorShapeDeviceV1ShapePointer))
80+ if err := c.Roundtrip(); err != nil {
81+ t.Fatal(err)
82+ }
83+ if got := nextCursor(t, cursors); got.Shape != "pointer" {
84+ t.Fatalf("shape: %+v", got)
85+ }
86+ commands <- ports.PointerFocus{ID: 0}
87+ if got := nextCursor(t, cursors); got != (ports.CursorChange{}) {
88+ t.Fatalf("leave: %+v, want the arrow", got)
89+ }
90+}
91+
92+func TestCursorShapeNames(t *testing.T) {
93+ for shape, want := range map[cursorshape.WpCursorShapeDeviceV1Shape]string{
94+ cursorshape.WpCursorShapeDeviceV1ShapeDefault: "default",
95+ cursorshape.WpCursorShapeDeviceV1ShapeNwseResize: "nwse-resize",
96+ cursorshape.WpCursorShapeDeviceV1ShapeZoomOut: "zoom-out",
97+ cursorshape.WpCursorShapeDeviceV1ShapeAllResize: "all-resize",
98+ cursorshape.WpCursorShapeDeviceV1ShapeVerticalText: "vertical-text",
99+ } {
100+ if got := cursorShapes[shape]; got != want {
101+ t.Errorf("shape %d = %q, want %q", shape, got, want)
102+ }
103+ }
104+}
105+
106+func TestCursorShapeInvalid(t *testing.T) {
107+ s, _, _, _, dir := cursorServer(t)
108+ c := protocolClient(t, s, dir)
109+ seat := bindProtocol(t, c, "wl_seat")
110+ registerProtocol(t, c, seat)
111+ pointer := c.AllocateID()
112+ registerProtocol(t, c, pointer)
113+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
114+ manager := bindProtocol(t, c, "wp_cursor_shape_manager_v1")
115+ device := c.AllocateID()
116+ registerProtocol(t, c, device)
117+ requestProtocol(t, c, manager, cursorshape.WpCursorShapeManagerV1RequestGetPointer, device, pointer)
118+ // Version 1 ends at zoom_out.
119+ requestProtocol(t, c, device, cursorshape.WpCursorShapeDeviceV1RequestSetShape, uint32(1), uint32(cursorshape.WpCursorShapeDeviceV1ShapeDndAsk))
120+ expectProtocolError(t, c, device, uint32(cursorshape.WpCursorShapeDeviceV1ErrorInvalidShape))
121+}
122+
123+func TestSetCursorSurface(t *testing.T) {
124+ s, events, commands, cursors, dir := cursorServer(t)
125+ c := protocolClient(t, s, dir)
126+ seat := bindProtocol(t, c, "wl_seat")
127+ registerProtocol(t, c, seat)
128+ pointer := c.AllocateID()
129+ registerProtocol(t, c, pointer)
130+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
131+ w := toplevelMapper(t, c, events)()
132+ commands <- ports.PointerFocus{ID: w.ID, X: 0, Y: 0}
133+ nextCursor(t, cursors)
134+
135+ comp := bindProtocol(t, c, "wl_compositor")
136+ shm := bindProtocol(t, c, "wl_shm")
137+ registerProtocol(t, c, shm)
138+ fd, err := unix.MemfdCreate("cursor", unix.MFD_CLOEXEC)
139+ if err != nil {
140+ t.Fatal(err)
141+ }
142+ defer unix.Close(fd)
143+ // 2x1 ARGB: opaque blue, then transparent; stride padded to 12.
144+ if _, err := unix.Pwrite(fd, []byte{255, 0, 0, 255, 0, 0, 0, 0, 9, 9, 9, 9}, 0); err != nil {
145+ t.Fatal(err)
146+ }
147+ pool, buf := c.AllocateID(), c.AllocateID()
148+ registerProtocol(t, c, pool)
149+ registerProtocol(t, c, buf)
150+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(12)); err != nil {
151+ t.Fatal(err)
152+ }
153+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(2), int32(1), int32(12), uint32(wayland.ShmFormatArgb8888))
154+ surf := c.AllocateID()
155+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
156+ registerProtocol(t, c, surf)
157+
158+ // A cursor surface without a buffer hides the cursor.
159+ requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), surf, int32(1), int32(0))
160+ if err := c.Roundtrip(); err != nil {
161+ t.Fatal(err)
162+ }
163+ if got := nextCursor(t, cursors); !got.Hidden {
164+ t.Fatalf("empty surface: %+v", got)
165+ }
166+ // A commit updates the image.
167+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
168+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
169+ if err := c.Roundtrip(); err != nil {
170+ t.Fatal(err)
171+ }
172+ got := nextCursor(t, cursors)
173+ if got.Image == nil || got.Scale != 1 || got.Image.W != 2 || got.Image.H != 1 || got.Image.HotX != 1 || string(got.Image.Pixels) != string([]byte{255, 0, 0, 255, 0, 0, 0, 0}) {
174+ t.Fatalf("image: %+v %+v", got, got.Image)
175+ }
176+ // A null surface hides the cursor.
177+ requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), uint32(0), int32(0), int32(0))
178+ if err := c.Roundtrip(); err != nil {
179+ t.Fatal(err)
180+ }
181+ if got := nextCursor(t, cursors); !got.Hidden {
182+ t.Fatalf("null surface: %+v", got)
183+ }
184+}
185+
186+func TestSetCursorRoleError(t *testing.T) {
187+ s, events, _, _, dir := cursorServer(t)
188+ c := protocolClient(t, s, dir)
189+ seat := bindProtocol(t, c, "wl_seat")
190+ registerProtocol(t, c, seat)
191+ pointer := c.AllocateID()
192+ registerProtocol(t, c, pointer)
193+ requestProtocol(t, c, seat, wayland.SeatRequestGetPointer, pointer)
194+ toplevelMapper(t, c, events)()
195+ // Surface 3 of the mapper's connection is the toplevel's wl_surface:
196+ // find it through the server instead of guessing ids.
197+ var surf uint32
198+ s.display.Do(func() {
199+ for _, st := range s.surfaces {
200+ if st.kind == roleXDG {
201+ surf = st.wl.ID()
202+ }
203+ }
204+ })
205+ requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), surf, int32(0), int32(0))
206+ expectProtocolError(t, c, pointer, uint32(wayland.PointerErrorRole))
207+}
M internal/adapters/wayland/globals.go +2 -8
@@ -14,6 +14,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
1414 func() error { return registerXDGOutput(d, s) },
1515 func() error { return registerDecoration(d, s) },
1616 func() error { return registerScale(d, s) },
17+ func() error { return registerCursorShape(d, s) },
1718 func() error { return registerDMABuf(d, s, o.DMABuf) },
1819 func() error {
1920 return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) })
@@ -322,7 +323,7 @@ func (b *buffer) content(id ports.WindowID) (ports.SurfaceContent, bool) {
322323 type seat struct{ server *Server }
323324
324325 func (h seat) GetPointer(r *wayland.Seat, id uint32) {
325- p, err := wayland.NewPointer(r.Client(), r.Version(), id, pointer{})
326+ p, err := wayland.NewPointer(r.Client(), r.Version(), id, pointer(h))
326327 if err != nil {
327328 return
328329 }
@@ -388,13 +389,6 @@ func (seat) GetTouch(r *wayland.Seat, id uint32) {
388389 }
389390 func (seat) Release(*wayland.Seat) {}
390391
391-type pointer struct{}
392-
393-// Cursor surfaces are accepted but rendering cursors is not implemented yet.
394-
395-func (pointer) SetCursor(*wayland.Pointer, uint32, *wayland.Surface, int32, int32) {}
396-func (pointer) Release(*wayland.Pointer) {}
397-
398392 type keyboard struct{}
399393
400394 func (keyboard) Release(*wayland.Keyboard) {}
@@ -14,6 +14,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
14 func() error { return registerXDGOutput(d, s) },14 func() error { return registerXDGOutput(d, s) },
15 func() error { return registerDecoration(d, s) },15 func() error { return registerDecoration(d, s) },
16 func() error { return registerScale(d, s) },16 func() error { return registerScale(d, s) },
17+ func() error { return registerCursorShape(d, s) },
17 func() error { return registerDMABuf(d, s, o.DMABuf) },18 func() error { return registerDMABuf(d, s, o.DMABuf) },
18 func() error {19 func() error {
19 return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) })20 return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) })
@@ -322,7 +323,7 @@ func (b *buffer) content(id ports.WindowID) (ports.SurfaceContent, bool) {
322 type seat struct{ server *Server }323 type seat struct{ server *Server }
323 324
324 func (h seat) GetPointer(r *wayland.Seat, id uint32) {325 func (h seat) GetPointer(r *wayland.Seat, id uint32) {
325- p, err := wayland.NewPointer(r.Client(), r.Version(), id, pointer{})326+ p, err := wayland.NewPointer(r.Client(), r.Version(), id, pointer(h))
326 if err != nil {327 if err != nil {
327 return328 return
328 }329 }
@@ -388,13 +389,6 @@ func (seat) GetTouch(r *wayland.Seat, id uint32) {
388 }389 }
389 func (seat) Release(*wayland.Seat) {}390 func (seat) Release(*wayland.Seat) {}
390 391
391-type pointer struct{}
392-
393-// Cursor surfaces are accepted but rendering cursors is not implemented yet.
394-
395-func (pointer) SetCursor(*wayland.Pointer, uint32, *wayland.Surface, int32, int32) {}
396-func (pointer) Release(*wayland.Pointer) {}
397-
398 type keyboard struct{}392 type keyboard struct{}
399 393
400 func (keyboard) Release(*wayland.Keyboard) {}394 func (keyboard) Release(*wayland.Keyboard) {}
M internal/adapters/wayland/server.go +15 -2
@@ -32,6 +32,8 @@ type Channels struct {
3232 Events chan<- ports.ClientEvent
3333 Contents chan<- ports.SurfaceContent
3434 Commands <-chan ports.ClientCommand
35+ // Cursors receives the cursor the client under the pointer asks for.
36+ Cursors chan<- ports.CursorChange
3537 }
3638 type Server struct {
3739 display *server.Display
@@ -79,6 +81,13 @@ type Server struct {
7981 outputs []*output
8082 focusedOutput string
8183 fractions map[*surface]*fractionalscale.WpFractionalScaleV1
84+ // cursorSurface is the wl_pointer.set_cursor surface in use.
85+ cursorSurface *surface
86+ // cursorMu guards only the latest cursor change for forwardCursors.
87+ cursorMu sync.Mutex
88+ cursorLatest ports.CursorChange
89+ cursorQueued bool
90+ cursorReady chan struct{}
8291 }
8392
8493 func removeItem[T comparable](list []T, v T) []T {
@@ -112,7 +121,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
112121 d.Close()
113122 return nil, err
114123 }
115- s := &Server{display: d, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
124+ s := &Server{display: d, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
116125 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
117126 if opts.Keymap != "" {
118127 fd, size, e := keymapFile(opts.Keymap)
@@ -147,8 +156,9 @@ func (s *Server) Run(ctx context.Context) error {
147156 s.started = time.Now()
148157 s.ctx = ctx
149158 var wg sync.WaitGroup
150- wg.Add(4)
159+ wg.Add(5)
151160 go func() { defer wg.Done(); s.forward(ctx) }()
161+ go func() { defer wg.Done(); s.forwardCursors(ctx) }()
152162 go func() { defer wg.Done(); s.forwardContents(ctx) }()
153163 go func() {
154164 defer wg.Done()
@@ -574,6 +584,9 @@ func (s *Server) changePointerFocus(id ports.WindowID, x, y float64) {
574584 if id == s.pointerFocus {
575585 return
576586 }
587+ // The new client sets its own cursor on enter; until then, the arrow.
588+ s.cursorSurface = nil
589+ s.setCursor(ports.CursorChange{})
577590 if old := s.windows[s.pointerFocus]; old != nil {
578591 if surface := old.xdg.surfaceResource(); surface != nil && surface.Resource.Alive() {
579592 for _, p := range s.windowPointers(old) {
@@ -32,6 +32,8 @@ type Channels struct {
32 Events chan<- ports.ClientEvent32 Events chan<- ports.ClientEvent
33 Contents chan<- ports.SurfaceContent33 Contents chan<- ports.SurfaceContent
34 Commands <-chan ports.ClientCommand34 Commands <-chan ports.ClientCommand
35+ // Cursors receives the cursor the client under the pointer asks for.
36+ Cursors chan<- ports.CursorChange
35 }37 }
36 type Server struct {38 type Server struct {
37 display *server.Display39 display *server.Display
@@ -79,6 +81,13 @@ type Server struct {
79 outputs []*output81 outputs []*output
80 focusedOutput string82 focusedOutput string
81 fractions map[*surface]*fractionalscale.WpFractionalScaleV183 fractions map[*surface]*fractionalscale.WpFractionalScaleV1
84+ // cursorSurface is the wl_pointer.set_cursor surface in use.
85+ cursorSurface *surface
86+ // cursorMu guards only the latest cursor change for forwardCursors.
87+ cursorMu sync.Mutex
88+ cursorLatest ports.CursorChange
89+ cursorQueued bool
90+ cursorReady chan struct{}
82 }91 }
83 92
84 func removeItem[T comparable](list []T, v T) []T {93 func removeItem[T comparable](list []T, v T) []T {
@@ -112,7 +121,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
112 d.Close()121 d.Close()
113 return nil, err122 return nil, err
114 }123 }
115- s := &Server{display: d, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}124+ s := &Server{display: d, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
116 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}125 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
117 if opts.Keymap != "" {126 if opts.Keymap != "" {
118 fd, size, e := keymapFile(opts.Keymap)127 fd, size, e := keymapFile(opts.Keymap)
@@ -147,8 +156,9 @@ func (s *Server) Run(ctx context.Context) error {
147 s.started = time.Now()156 s.started = time.Now()
148 s.ctx = ctx157 s.ctx = ctx
149 var wg sync.WaitGroup158 var wg sync.WaitGroup
150- wg.Add(4)159+ wg.Add(5)
151 go func() { defer wg.Done(); s.forward(ctx) }()160 go func() { defer wg.Done(); s.forward(ctx) }()
161+ go func() { defer wg.Done(); s.forwardCursors(ctx) }()
152 go func() { defer wg.Done(); s.forwardContents(ctx) }()162 go func() { defer wg.Done(); s.forwardContents(ctx) }()
153 go func() {163 go func() {
154 defer wg.Done()164 defer wg.Done()
@@ -574,6 +584,9 @@ func (s *Server) changePointerFocus(id ports.WindowID, x, y float64) {
574 if id == s.pointerFocus {584 if id == s.pointerFocus {
575 return585 return
576 }586 }
587+ // The new client sets its own cursor on enter; until then, the arrow.
588+ s.cursorSurface = nil
589+ s.setCursor(ports.CursorChange{})
577 if old := s.windows[s.pointerFocus]; old != nil {590 if old := s.windows[s.pointerFocus]; old != nil {
578 if surface := old.xdg.surfaceResource(); surface != nil && surface.Resource.Alive() {591 if surface := old.xdg.surfaceResource(); surface != nil && surface.Resource.Alive() {
579 for _, p := range s.windowPointers(old) {592 for _, p := range s.windowPointers(old) {
M internal/adapters/wayland/surface.go +8 -0
@@ -14,6 +14,7 @@ const (
1414 roleXDG
1515 roleSubsurface
1616 roleLayer
17+ roleCursor
1718 )
1819
1920 type surface struct {
@@ -31,6 +32,8 @@ type surface struct {
3132 callbacks []*wayland.Callback
3233 role func(bool)
3334 destroyed bool
35+ // hotX, hotY is the click point of a cursor surface (logical).
36+ hotX, hotY int
3437 // on is the output the surface entered; scale is the last scale sent.
3538 on *output
3639 scale float64
@@ -132,6 +135,11 @@ func (s *surface) detach() {
132135
133136 func (s *surface) Destroy(*wayland.Surface) {
134137 s.destroyed = true
138+ if s.server.cursorSurface == s {
139+ // The pointer keeps no cursor until the client sets another.
140+ s.server.cursorSurface = nil
141+ s.server.setCursor(ports.CursorChange{Hidden: true})
142+ }
135143 s.detach()
136144 for _, ch := range s.sub.children {
137145 ch.sub.parent = nil
@@ -14,6 +14,7 @@ const (
14 roleXDG14 roleXDG
15 roleSubsurface15 roleSubsurface
16 roleLayer16 roleLayer
17+ roleCursor
17 )18 )
18 19
19 type surface struct {20 type surface struct {
@@ -31,6 +32,8 @@ type surface struct {
31 callbacks []*wayland.Callback32 callbacks []*wayland.Callback
32 role func(bool)33 role func(bool)
33 destroyed bool34 destroyed bool
35+ // hotX, hotY is the click point of a cursor surface (logical).
36+ hotX, hotY int
34 // on is the output the surface entered; scale is the last scale sent.37 // on is the output the surface entered; scale is the last scale sent.
35 on *output38 on *output
36 scale float6439 scale float64
@@ -132,6 +135,11 @@ func (s *surface) detach() {
132 135
133 func (s *surface) Destroy(*wayland.Surface) {136 func (s *surface) Destroy(*wayland.Surface) {
134 s.destroyed = true137 s.destroyed = true
138+ if s.server.cursorSurface == s {
139+ // The pointer keeps no cursor until the client sets another.
140+ s.server.cursorSurface = nil
141+ s.server.setCursor(ports.CursorChange{Hidden: true})
142+ }
135 s.detach()143 s.detach()
136 for _, ch := range s.sub.children {144 for _, ch := range s.sub.children {
137 ch.sub.parent = nil145 ch.sub.parent = nil
M internal/app/app.go +4 -3
@@ -78,6 +78,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
7878 configErrors := make(chan error, 8)
7979 renderScenes := make(chan []ports.Scene, 1)
8080 contents := make(chan ports.SurfaceContent, 64)
81+ cursorChanges := make(chan ports.CursorChange, 1)
8182 ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}
8283 c, err := core.New(opts.Config, ch)
8384 if err != nil {
@@ -92,7 +93,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
9293 // Every output renders on the same GPU: its formats are the clients'.
9394 dmabuf := vulkan.Probe()
9495 log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf")
95- server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents}, logging.For(ctx, "wayland"))
96+ server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents, Cursors: cursorChanges}, logging.For(ctx, "wayland"))
9697 if err != nil {
9798 km.Close()
9899 return err
@@ -193,11 +194,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
193194 if hw != nil {
194195 done <- safe("output", func() error {
195196 want := func() drm.Want { return wantFromConfig(opts.Config.Outputs) }
196- return hw.runOutputs(ctx, want, output, renderScenes, contents, curs, newRenderer, logging.For(ctx, "drm"))
197+ return hw.runOutputs(ctx, want, output, renderScenes, contents, cursorChanges, curs, newRenderer, logging.For(ctx, "drm"))
197198 })
198199 return
199200 }
200- done <- runHeadless(ctx, sizes, opts.ScreenshotDir, output, renderScenes, contents, curs, newRenderer, logging.For(ctx, "render"))
201+ done <- runHeadless(ctx, sizes, opts.ScreenshotDir, output, renderScenes, contents, cursorChanges, curs, newRenderer, logging.For(ctx, "render"))
201202 }()
202203
203204 if hw != nil {
@@ -78,6 +78,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
78 configErrors := make(chan error, 8)78 configErrors := make(chan error, 8)
79 renderScenes := make(chan []ports.Scene, 1)79 renderScenes := make(chan []ports.Scene, 1)
80 contents := make(chan ports.SurfaceContent, 64)80 contents := make(chan ports.SurfaceContent, 64)
81+ cursorChanges := make(chan ports.CursorChange, 1)
81 ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}82 ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}
82 c, err := core.New(opts.Config, ch)83 c, err := core.New(opts.Config, ch)
83 if err != nil {84 if err != nil {
@@ -92,7 +93,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
92 // Every output renders on the same GPU: its formats are the clients'.93 // Every output renders on the same GPU: its formats are the clients'.
93 dmabuf := vulkan.Probe()94 dmabuf := vulkan.Probe()
94 log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf")95 log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf")
95- server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents}, logging.For(ctx, "wayland"))96+ server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents, Cursors: cursorChanges}, logging.For(ctx, "wayland"))
96 if err != nil {97 if err != nil {
97 km.Close()98 km.Close()
98 return err99 return err
@@ -193,11 +194,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
193 if hw != nil {194 if hw != nil {
194 done <- safe("output", func() error {195 done <- safe("output", func() error {
195 want := func() drm.Want { return wantFromConfig(opts.Config.Outputs) }196 want := func() drm.Want { return wantFromConfig(opts.Config.Outputs) }
196- return hw.runOutputs(ctx, want, output, renderScenes, contents, curs, newRenderer, logging.For(ctx, "drm"))197+ return hw.runOutputs(ctx, want, output, renderScenes, contents, cursorChanges, curs, newRenderer, logging.For(ctx, "drm"))
197 })198 })
198 return199 return
199 }200 }
200- done <- runHeadless(ctx, sizes, opts.ScreenshotDir, output, renderScenes, contents, curs, newRenderer, logging.For(ctx, "render"))201+ done <- runHeadless(ctx, sizes, opts.ScreenshotDir, output, renderScenes, contents, cursorChanges, curs, newRenderer, logging.For(ctx, "render"))
201 }()202 }()
202 203
203 if hw != nil {204 if hw != nil {
M internal/app/cursor.go +87 -6
@@ -4,6 +4,7 @@ import (
44 "math"
55 "os"
66 "strconv"
7+ "sync"
78
89 "github.com/bnema/nefertty/internal/adapters/xcursor"
910 "github.com/bnema/nefertty/internal/ports"
@@ -17,14 +18,94 @@ func cursorSize() int {
1718 return 24
1819 }
1920
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)
21+// legacyCursorNames are the X11 names of CSS cursors for themes without
22+// the CSS ones.
23+var legacyCursorNames = map[string][]string{
24+ "default": {"left_ptr"},
25+ "pointer": {"hand2", "hand1"},
26+ "text": {"xterm"},
27+ "wait": {"watch"},
28+ "progress": {"left_ptr_watch"},
29+ "crosshair": {"cross"},
30+ "move": {"fleur"},
31+ "grab": {"openhand"},
32+ "grabbing": {"closedhand"},
33+ "not-allowed": {"crossed_circle"},
34+ "help": {"question_arrow"},
35+ "ew-resize": {"sb_h_double_arrow"},
36+ "ns-resize": {"sb_v_double_arrow"},
37+ "col-resize": {"sb_h_double_arrow"},
38+ "row-resize": {"sb_v_double_arrow"},
39+ "e-resize": {"right_side"},
40+ "w-resize": {"left_side"},
41+ "n-resize": {"top_side"},
42+ "s-resize": {"bottom_side"},
43+ "ne-resize": {"top_right_corner"},
44+ "nw-resize": {"top_left_corner"},
45+ "se-resize": {"bottom_right_corner"},
46+ "sw-resize": {"bottom_left_corner"},
47+ "nesw-resize": {"fd_double_arrow"},
48+ "nwse-resize": {"bd_double_arrow"},
49+ "all-scroll": {"fleur"},
50+ "alias": {"link"},
51+ "cell": {"plus"},
52+ "no-drop": {"crossed_circle"},
53+}
54+
55+// themeCursors caches loaded theme cursors: clients change shapes on every
56+// hover and each output loads them on its render goroutine.
57+var themeCursors = struct {
58+ sync.Mutex
59+ m map[themeKey]ports.CursorImage
60+}{m: map[themeKey]ports.CursorImage{}}
61+
62+type themeKey struct {
63+ shape string
64+ size, limit int
65+}
66+
67+// loadCursor returns the cursor a client asked for at an output scale, at
68+// most limit physical pixels on a side: a XCURSOR_THEME shape at
69+// XCURSOR_SIZE × scale, the client image resized to the output scale, or
70+// an empty image when hidden. Unknown shapes fall back to the arrow.
71+func loadCursor(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error) {
72+ scale = max(scale, 1)
73+ if c.Hidden {
74+ return ports.CursorImage{}, nil
75+ }
76+ if c.Image != nil {
77+ img := xcursor.Image{W: c.Image.W, H: c.Image.H, HotX: c.Image.HotX, HotY: c.Image.HotY, Pixels: c.Image.Pixels}
78+ f := scale / float64(max(c.Scale, 1))
79+ w, h := int(math.Round(float64(img.W)*f)), int(math.Round(float64(img.H)*f))
80+ if big := max(w, h); big > limit {
81+ w, h = w*limit/big, h*limit/big
82+ }
83+ img = img.Scale(max(w, 1), max(h, 1))
84+ return ports.CursorImage{W: img.W, H: img.H, HotX: img.HotX, HotY: img.HotY, Pixels: img.Pixels}, nil
85+ }
86+ shape := c.Shape
87+ if shape == "" {
88+ shape = "default"
89+ }
90+ size := int(math.Round(float64(cursorSize()) * scale))
91+ key := themeKey{shape, size, limit}
92+ themeCursors.Lock()
93+ defer themeCursors.Unlock()
94+ if img, ok := themeCursors.m[key]; ok {
95+ return img, nil
96+ }
97+ // The shape is looked up through the whole theme chain before the
98+ // arrow: a theme may override only the arrow and inherit the rest.
99+ dirs, theme := xcursor.SearchPath(), os.Getenv("XCURSOR_THEME")
100+ img, err := xcursor.Load(dirs, theme, append([]string{shape}, legacyCursorNames[shape]...), size)
101+ if err != nil && shape != "default" {
102+ img, err = xcursor.Load(dirs, theme, []string{"default", "left_ptr"}, size)
103+ }
25104 if err != nil {
26105 return ports.CursorImage{}, err
27106 }
28107 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
108+ out := ports.CursorImage{W: img.W, H: img.H, HotX: img.HotX, HotY: img.HotY, Pixels: img.Pixels}
109+ themeCursors.m[key] = out
110+ return out, nil
30111 }
@@ -4,6 +4,7 @@ import (
4 "math"4 "math"
5 "os"5 "os"
6 "strconv"6 "strconv"
7+ "sync"
7 8
8 "github.com/bnema/nefertty/internal/adapters/xcursor"9 "github.com/bnema/nefertty/internal/adapters/xcursor"
9 "github.com/bnema/nefertty/internal/ports"10 "github.com/bnema/nefertty/internal/ports"
@@ -17,14 +18,94 @@ func cursorSize() int {
17 return 2418 return 24
18 }19 }
19 20
20-// loadCursor loads the XCURSOR_THEME arrow at XCURSOR_SIZE × scale physical21+// legacyCursorNames are the X11 names of CSS cursors for themes without
21-// pixels, capped at limit pixels on a side.22+// the CSS ones.
22-func loadCursor(scale float64, limit int) (ports.CursorImage, error) {23+var legacyCursorNames = map[string][]string{
23- size := int(math.Round(float64(cursorSize()) * max(scale, 1)))24+ "default": {"left_ptr"},
24- img, err := xcursor.Load(xcursor.SearchPath(), os.Getenv("XCURSOR_THEME"), []string{"default", "left_ptr"}, size)25+ "pointer": {"hand2", "hand1"},
26+ "text": {"xterm"},
27+ "wait": {"watch"},
28+ "progress": {"left_ptr_watch"},
29+ "crosshair": {"cross"},
30+ "move": {"fleur"},
31+ "grab": {"openhand"},
32+ "grabbing": {"closedhand"},
33+ "not-allowed": {"crossed_circle"},
34+ "help": {"question_arrow"},
35+ "ew-resize": {"sb_h_double_arrow"},
36+ "ns-resize": {"sb_v_double_arrow"},
37+ "col-resize": {"sb_h_double_arrow"},
38+ "row-resize": {"sb_v_double_arrow"},
39+ "e-resize": {"right_side"},
40+ "w-resize": {"left_side"},
41+ "n-resize": {"top_side"},
42+ "s-resize": {"bottom_side"},
43+ "ne-resize": {"top_right_corner"},
44+ "nw-resize": {"top_left_corner"},
45+ "se-resize": {"bottom_right_corner"},
46+ "sw-resize": {"bottom_left_corner"},
47+ "nesw-resize": {"fd_double_arrow"},
48+ "nwse-resize": {"bd_double_arrow"},
49+ "all-scroll": {"fleur"},
50+ "alias": {"link"},
51+ "cell": {"plus"},
52+ "no-drop": {"crossed_circle"},
53+}
54+
55+// themeCursors caches loaded theme cursors: clients change shapes on every
56+// hover and each output loads them on its render goroutine.
57+var themeCursors = struct {
58+ sync.Mutex
59+ m map[themeKey]ports.CursorImage
60+}{m: map[themeKey]ports.CursorImage{}}
61+
62+type themeKey struct {
63+ shape string
64+ size, limit int
65+}
66+
67+// loadCursor returns the cursor a client asked for at an output scale, at
68+// most limit physical pixels on a side: a XCURSOR_THEME shape at
69+// XCURSOR_SIZE × scale, the client image resized to the output scale, or
70+// an empty image when hidden. Unknown shapes fall back to the arrow.
71+func loadCursor(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error) {
72+ scale = max(scale, 1)
73+ if c.Hidden {
74+ return ports.CursorImage{}, nil
75+ }
76+ if c.Image != nil {
77+ img := xcursor.Image{W: c.Image.W, H: c.Image.H, HotX: c.Image.HotX, HotY: c.Image.HotY, Pixels: c.Image.Pixels}
78+ f := scale / float64(max(c.Scale, 1))
79+ w, h := int(math.Round(float64(img.W)*f)), int(math.Round(float64(img.H)*f))
80+ if big := max(w, h); big > limit {
81+ w, h = w*limit/big, h*limit/big
82+ }
83+ img = img.Scale(max(w, 1), max(h, 1))
84+ return ports.CursorImage{W: img.W, H: img.H, HotX: img.HotX, HotY: img.HotY, Pixels: img.Pixels}, nil
85+ }
86+ shape := c.Shape
87+ if shape == "" {
88+ shape = "default"
89+ }
90+ size := int(math.Round(float64(cursorSize()) * scale))
91+ key := themeKey{shape, size, limit}
92+ themeCursors.Lock()
93+ defer themeCursors.Unlock()
94+ if img, ok := themeCursors.m[key]; ok {
95+ return img, nil
96+ }
97+ // The shape is looked up through the whole theme chain before the
98+ // arrow: a theme may override only the arrow and inherit the rest.
99+ dirs, theme := xcursor.SearchPath(), os.Getenv("XCURSOR_THEME")
100+ img, err := xcursor.Load(dirs, theme, append([]string{shape}, legacyCursorNames[shape]...), size)
101+ if err != nil && shape != "default" {
102+ img, err = xcursor.Load(dirs, theme, []string{"default", "left_ptr"}, size)
103+ }
25 if err != nil {104 if err != nil {
26 return ports.CursorImage{}, err105 return ports.CursorImage{}, err
27 }106 }
28 img = img.ForSize(size, limit)107 img = img.ForSize(size, limit)
29- return ports.CursorImage{W: img.W, H: img.H, HotX: img.HotX, HotY: img.HotY, Pixels: img.Pixels}, nil108+ out := ports.CursorImage{W: img.W, H: img.H, HotX: img.HotX, HotY: img.HotY, Pixels: img.Pixels}
109+ themeCursors.m[key] = out
110+ return out, nil
30 }111 }
A internal/app/cursor_test.go +125 -0
@@ -0,0 +1,125 @@
1+package app
2+
3+import (
4+ "encoding/binary"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/nefertty/internal/ports"
10+)
11+
12+// themeCursor writes a one-image Xcursor file of side size under
13+// dir/theme/cursors/name, every pixel set to px.
14+func themeCursor(t *testing.T, dir, name string, size int, px byte) {
15+ t.Helper()
16+ le := binary.LittleEndian
17+ out := le.AppendUint32([]byte("Xcur"), 16)
18+ out = le.AppendUint32(out, 0x10000)
19+ out = le.AppendUint32(out, 1)
20+ for _, v := range []int{0xfffd0002, size, 28, 36, 0xfffd0002, size, 1, size, size, 0, 0, 0} {
21+ out = le.AppendUint32(out, uint32(v))
22+ }
23+ for range size * size * 4 {
24+ out = append(out, px)
25+ }
26+ path := filepath.Join(dir, "theme", "cursors", name)
27+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
28+ t.Fatal(err)
29+ }
30+ if err := os.WriteFile(path, out, 0o644); err != nil {
31+ t.Fatal(err)
32+ }
33+}
34+
35+// resetThemeCursors empties the theme cache now and after the test.
36+func resetThemeCursors(t *testing.T) {
37+ reset := func() {
38+ themeCursors.Lock()
39+ clear(themeCursors.m)
40+ themeCursors.Unlock()
41+ }
42+ reset()
43+ t.Cleanup(reset)
44+}
45+
46+func TestLoadCursorShapes(t *testing.T) {
47+ dir := t.TempDir()
48+ t.Setenv("XCURSOR_PATH", dir)
49+ t.Setenv("XCURSOR_THEME", "theme")
50+ t.Setenv("XCURSOR_SIZE", "24")
51+ resetThemeCursors(t)
52+ themeCursor(t, dir, "left_ptr", 24, 1)
53+ themeCursor(t, dir, "hand2", 24, 2)
54+ for _, tc := range []struct {
55+ change ports.CursorChange
56+ px byte
57+ }{
58+ {ports.CursorChange{}, 1},
59+ // Themes with only X11 names still get the hand.
60+ {ports.CursorChange{Shape: "pointer"}, 2},
61+ // Unknown shapes fall back to the arrow.
62+ {ports.CursorChange{Shape: "zoom-in"}, 1},
63+ } {
64+ img, err := loadCursor(tc.change, 2, 256)
65+ if err != nil {
66+ t.Fatal(tc.change, err)
67+ }
68+ if img.W != 48 || img.Pixels[0] != tc.px {
69+ t.Errorf("%+v: %dx%d px %d, want 48 wide px %d", tc.change, img.W, img.H, img.Pixels[0], tc.px)
70+ }
71+ }
72+ // Cached: a removed theme file still loads.
73+ if err := os.RemoveAll(dir); err != nil {
74+ t.Fatal(err)
75+ }
76+ if img, err := loadCursor(ports.CursorChange{Shape: "pointer"}, 2, 256); err != nil || img.Pixels[0] != 2 {
77+ t.Fatalf("cached: %v", err)
78+ }
79+ if img, err := loadCursor(ports.CursorChange{Hidden: true}, 1, 256); err != nil || img.W != 0 {
80+ t.Fatalf("hidden: %+v %v", img, err)
81+ }
82+}
83+
84+// A client cursor drawn at buffer scale 2 is shown at the output scale.
85+func TestLoadCursorClientImage(t *testing.T) {
86+ src := &ports.CursorImage{W: 4, H: 2, HotX: 2, HotY: 1, Pixels: make([]byte, 4*2*4)}
87+ img, err := loadCursor(ports.CursorChange{Image: src, Scale: 2}, 1, 256)
88+ if err != nil || img.W != 2 || img.H != 1 || img.HotX != 1 || img.HotY != 0 {
89+ t.Fatalf("%+v %v", img, err)
90+ }
91+ img, err = loadCursor(ports.CursorChange{Image: src, Scale: 2}, 2, 256)
92+ if err != nil || img.W != 4 || img.HotX != 2 {
93+ t.Fatalf("%+v %v", img, err)
94+ }
95+ // Bounded by the cursor plane.
96+ img, err = loadCursor(ports.CursorChange{Image: src, Scale: 1}, 4, 8)
97+ if err != nil || img.W != 8 || img.H != 4 {
98+ t.Fatalf("%+v %v", img, err)
99+ }
100+}
101+
102+// A theme that only overrides the arrow still gets its parent's shapes.
103+func TestLoadCursorInheritedShape(t *testing.T) {
104+ dir := t.TempDir()
105+ resetThemeCursors(t)
106+ t.Setenv("XCURSOR_PATH", dir)
107+ t.Setenv("XCURSOR_THEME", "child")
108+ t.Setenv("XCURSOR_SIZE", "24")
109+ themeCursor(t, dir, "left_ptr", 24, 1) // in "theme", the parent
110+ if err := os.MkdirAll(filepath.Join(dir, "child", "cursors"), 0o755); err != nil {
111+ t.Fatal(err)
112+ }
113+ if err := os.WriteFile(filepath.Join(dir, "child", "index.theme"), []byte("[Icon Theme]\nInherits=theme\n"), 0o644); err != nil {
114+ t.Fatal(err)
115+ }
116+ child := filepath.Join(dir, "child", "cursors", "default")
117+ themeCursor(t, dir, "text", 24, 3)
118+ if err := os.Rename(filepath.Join(dir, "theme", "cursors", "left_ptr"), child); err != nil {
119+ t.Fatal(err)
120+ }
121+ img, err := loadCursor(ports.CursorChange{Shape: "text"}, 1, 256)
122+ if err != nil || img.Pixels[0] != 3 {
123+ t.Fatalf("got arrow instead of the inherited text cursor: %v", err)
124+ }
125+}
@@ -0,0 +1,125 @@
1+package app
2+
3+import (
4+ "encoding/binary"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/nefertty/internal/ports"
10+)
11+
12+// themeCursor writes a one-image Xcursor file of side size under
13+// dir/theme/cursors/name, every pixel set to px.
14+func themeCursor(t *testing.T, dir, name string, size int, px byte) {
15+ t.Helper()
16+ le := binary.LittleEndian
17+ out := le.AppendUint32([]byte("Xcur"), 16)
18+ out = le.AppendUint32(out, 0x10000)
19+ out = le.AppendUint32(out, 1)
20+ for _, v := range []int{0xfffd0002, size, 28, 36, 0xfffd0002, size, 1, size, size, 0, 0, 0} {
21+ out = le.AppendUint32(out, uint32(v))
22+ }
23+ for range size * size * 4 {
24+ out = append(out, px)
25+ }
26+ path := filepath.Join(dir, "theme", "cursors", name)
27+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
28+ t.Fatal(err)
29+ }
30+ if err := os.WriteFile(path, out, 0o644); err != nil {
31+ t.Fatal(err)
32+ }
33+}
34+
35+// resetThemeCursors empties the theme cache now and after the test.
36+func resetThemeCursors(t *testing.T) {
37+ reset := func() {
38+ themeCursors.Lock()
39+ clear(themeCursors.m)
40+ themeCursors.Unlock()
41+ }
42+ reset()
43+ t.Cleanup(reset)
44+}
45+
46+func TestLoadCursorShapes(t *testing.T) {
47+ dir := t.TempDir()
48+ t.Setenv("XCURSOR_PATH", dir)
49+ t.Setenv("XCURSOR_THEME", "theme")
50+ t.Setenv("XCURSOR_SIZE", "24")
51+ resetThemeCursors(t)
52+ themeCursor(t, dir, "left_ptr", 24, 1)
53+ themeCursor(t, dir, "hand2", 24, 2)
54+ for _, tc := range []struct {
55+ change ports.CursorChange
56+ px byte
57+ }{
58+ {ports.CursorChange{}, 1},
59+ // Themes with only X11 names still get the hand.
60+ {ports.CursorChange{Shape: "pointer"}, 2},
61+ // Unknown shapes fall back to the arrow.
62+ {ports.CursorChange{Shape: "zoom-in"}, 1},
63+ } {
64+ img, err := loadCursor(tc.change, 2, 256)
65+ if err != nil {
66+ t.Fatal(tc.change, err)
67+ }
68+ if img.W != 48 || img.Pixels[0] != tc.px {
69+ t.Errorf("%+v: %dx%d px %d, want 48 wide px %d", tc.change, img.W, img.H, img.Pixels[0], tc.px)
70+ }
71+ }
72+ // Cached: a removed theme file still loads.
73+ if err := os.RemoveAll(dir); err != nil {
74+ t.Fatal(err)
75+ }
76+ if img, err := loadCursor(ports.CursorChange{Shape: "pointer"}, 2, 256); err != nil || img.Pixels[0] != 2 {
77+ t.Fatalf("cached: %v", err)
78+ }
79+ if img, err := loadCursor(ports.CursorChange{Hidden: true}, 1, 256); err != nil || img.W != 0 {
80+ t.Fatalf("hidden: %+v %v", img, err)
81+ }
82+}
83+
84+// A client cursor drawn at buffer scale 2 is shown at the output scale.
85+func TestLoadCursorClientImage(t *testing.T) {
86+ src := &ports.CursorImage{W: 4, H: 2, HotX: 2, HotY: 1, Pixels: make([]byte, 4*2*4)}
87+ img, err := loadCursor(ports.CursorChange{Image: src, Scale: 2}, 1, 256)
88+ if err != nil || img.W != 2 || img.H != 1 || img.HotX != 1 || img.HotY != 0 {
89+ t.Fatalf("%+v %v", img, err)
90+ }
91+ img, err = loadCursor(ports.CursorChange{Image: src, Scale: 2}, 2, 256)
92+ if err != nil || img.W != 4 || img.HotX != 2 {
93+ t.Fatalf("%+v %v", img, err)
94+ }
95+ // Bounded by the cursor plane.
96+ img, err = loadCursor(ports.CursorChange{Image: src, Scale: 1}, 4, 8)
97+ if err != nil || img.W != 8 || img.H != 4 {
98+ t.Fatalf("%+v %v", img, err)
99+ }
100+}
101+
102+// A theme that only overrides the arrow still gets its parent's shapes.
103+func TestLoadCursorInheritedShape(t *testing.T) {
104+ dir := t.TempDir()
105+ resetThemeCursors(t)
106+ t.Setenv("XCURSOR_PATH", dir)
107+ t.Setenv("XCURSOR_THEME", "child")
108+ t.Setenv("XCURSOR_SIZE", "24")
109+ themeCursor(t, dir, "left_ptr", 24, 1) // in "theme", the parent
110+ if err := os.MkdirAll(filepath.Join(dir, "child", "cursors"), 0o755); err != nil {
111+ t.Fatal(err)
112+ }
113+ if err := os.WriteFile(filepath.Join(dir, "child", "index.theme"), []byte("[Icon Theme]\nInherits=theme\n"), 0o644); err != nil {
114+ t.Fatal(err)
115+ }
116+ child := filepath.Join(dir, "child", "cursors", "default")
117+ themeCursor(t, dir, "text", 24, 3)
118+ if err := os.Rename(filepath.Join(dir, "theme", "cursors", "left_ptr"), child); err != nil {
119+ t.Fatal(err)
120+ }
121+ img, err := loadCursor(ports.CursorChange{Shape: "text"}, 1, 256)
122+ if err != nil || img.Pixels[0] != 3 {
123+ t.Fatalf("got arrow instead of the inherited text cursor: %v", err)
124+ }
125+}
M internal/app/drm.go +5 -3
@@ -108,7 +108,7 @@ func (b *drmBackend) close() {
108108 // one flip reader per card, and a udev watcher that rescans connectors on
109109 // hotplug. Core learns about outputs through events. It returns when ctx
110110 // ends, after every output is closed.
111-func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
111+func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
112112 ctx, cancel := context.WithCancel(ctx)
113113 defer cancel()
114114 var readers sync.WaitGroup
@@ -176,11 +176,11 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event
176176 curs.set(name, cur)
177177 }
178178 active := b.seat.Subscribe()
179- set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent) error {
179+ set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange) error {
180180 defer b.seat.Unsubscribe(active)
181181 return safe("output "+name, func() error {
182182 defer o.Close()
183- return o.Run(octx, newRenderer, loadCursor, active, sc, cc)
183+ return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu)
184184 })
185185 })
186186 send(ports.OutputAdded{Info: o.Info()})
@@ -205,6 +205,8 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event
205205 set.scenes(s)
206206 case c := <-contents:
207207 set.content(c)
208+ case c := <-cursorChanges:
209+ set.setCursor(c)
208210 case name := <-set.stopped:
209211 err := set.finish(name)
210212 if ctx.Err() != nil {
@@ -108,7 +108,7 @@ func (b *drmBackend) close() {
108 // one flip reader per card, and a udev watcher that rescans connectors on108 // one flip reader per card, and a udev watcher that rescans connectors on
109 // hotplug. Core learns about outputs through events. It returns when ctx109 // hotplug. Core learns about outputs through events. It returns when ctx
110 // ends, after every output is closed.110 // ends, after every output is closed.
111-func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {111+func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
112 ctx, cancel := context.WithCancel(ctx)112 ctx, cancel := context.WithCancel(ctx)
113 defer cancel()113 defer cancel()
114 var readers sync.WaitGroup114 var readers sync.WaitGroup
@@ -176,11 +176,11 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event
176 curs.set(name, cur)176 curs.set(name, cur)
177 }177 }
178 active := b.seat.Subscribe()178 active := b.seat.Subscribe()
179- set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent) error {179+ set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange) error {
180 defer b.seat.Unsubscribe(active)180 defer b.seat.Unsubscribe(active)
181 return safe("output "+name, func() error {181 return safe("output "+name, func() error {
182 defer o.Close()182 defer o.Close()
183- return o.Run(octx, newRenderer, loadCursor, active, sc, cc)183+ return o.Run(octx, newRenderer, loadCursor, active, sc, cc, cu)
184 })184 })
185 })185 })
186 send(ports.OutputAdded{Info: o.Info()})186 send(ports.OutputAdded{Info: o.Info()})
@@ -205,6 +205,8 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event
205 set.scenes(s)205 set.scenes(s)
206 case c := <-contents:206 case c := <-contents:
207 set.content(c)207 set.content(c)
208+ case c := <-cursorChanges:
209+ set.setCursor(c)
208 case name := <-set.stopped:210 case name := <-set.stopped:
209 err := set.finish(name)211 err := set.finish(name)
210 if ctx.Err() != nil {212 if ctx.Err() != nil {
M internal/app/headless.go +5 -3
@@ -13,7 +13,7 @@ import (
1313
1414 // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ...
1515 // With several outputs, screenshots go to a subdirectory per output.
16-func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
16+func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
1717 set := newOutputSet()
1818 for i, size := range sizes {
1919 name := fmt.Sprintf("HEADLESS-%d", i+1)
@@ -27,8 +27,8 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<
2727 cur := &headless.Cursor{}
2828 curs.set(name, cur)
2929 opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, Log: log, NewRenderer: newRenderer}
30- set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent) error {
31- return headless.Run(octx, opts, sc, cc)
30+ set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange) error {
31+ return headless.Run(octx, opts, sc, cc, cu)
3232 })
3333 select {
3434 case events <- ports.OutputAdded{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1]}}:
@@ -46,6 +46,8 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<
4646 set.scenes(s)
4747 case c := <-contents:
4848 set.content(c)
49+ case c := <-cursorChanges:
50+ set.setCursor(c)
4951 case name := <-set.stopped:
5052 // A headless output only stops on error: the run ends.
5153 err := set.finish(name)
@@ -13,7 +13,7 @@ import (
13 13
14 // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ...14 // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ...
15 // With several outputs, screenshots go to a subdirectory per output.15 // With several outputs, screenshots go to a subdirectory per output.
16-func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {16+func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
17 set := newOutputSet()17 set := newOutputSet()
18 for i, size := range sizes {18 for i, size := range sizes {
19 name := fmt.Sprintf("HEADLESS-%d", i+1)19 name := fmt.Sprintf("HEADLESS-%d", i+1)
@@ -27,8 +27,8 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<
27 cur := &headless.Cursor{}27 cur := &headless.Cursor{}
28 curs.set(name, cur)28 curs.set(name, cur)
29 opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, Log: log, NewRenderer: newRenderer}29 opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, Log: log, NewRenderer: newRenderer}
30- set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent) error {30+ set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange) error {
31- return headless.Run(octx, opts, sc, cc)31+ return headless.Run(octx, opts, sc, cc, cu)
32 })32 })
33 select {33 select {
34 case events <- ports.OutputAdded{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1]}}:34 case events <- ports.OutputAdded{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1]}}:
@@ -46,6 +46,8 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<
46 set.scenes(s)46 set.scenes(s)
47 case c := <-contents:47 case c := <-contents:
48 set.content(c)48 set.content(c)
49+ case c := <-cursorChanges:
50+ set.setCursor(c)
49 case name := <-set.stopped:51 case name := <-set.stopped:
50 // A headless output only stops on error: the run ends.52 // A headless output only stops on error: the run ends.
51 err := set.finish(name)53 err := set.finish(name)
M internal/app/outputs.go +19 -4
@@ -8,8 +8,8 @@ import (
88 )
99
1010 // outputRun drives one output until ctx ends: it renders the scenes and
11-// surface contents it receives.
12-type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent) error
11+// surface contents it receives, with the cursor the client asks for.
12+type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange) error
1313
1414 // outputSet owns the running outputs and feeds them. Core sends one scene
1515 // per output; each goes to its output, latest first. Surface contents go to
@@ -19,6 +19,7 @@ type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-c
1919 type outputSet struct {
2020 outs map[string]*runningOutput
2121 latest map[ports.WindowID]ports.SurfaceContent
22+ cursor ports.CursorChange
2223 stopped chan string
2324 quit chan struct{} // closed by wait: nobody reads stopped any more
2425 }
@@ -26,6 +27,7 @@ type outputSet struct {
2627 type runningOutput struct {
2728 scenes chan ports.Scene
2829 contents chan ports.SurfaceContent
30+ cursor chan ports.CursorChange
2931 ctx context.Context
3032 stop context.CancelFunc
3133 err error
@@ -39,21 +41,34 @@ func newOutputSet() *outputSet {
3941 // start runs an output in its own goroutine and replays the window contents.
4042 func (s *outputSet) start(ctx context.Context, name string, run outputRun) {
4143 octx, stop := context.WithCancel(ctx)
42- r := &runningOutput{scenes: make(chan ports.Scene, 1), contents: make(chan ports.SurfaceContent, 64), ctx: octx, stop: stop, done: make(chan struct{})}
44+ r := &runningOutput{scenes: make(chan ports.Scene, 1), contents: make(chan ports.SurfaceContent, 64), cursor: make(chan ports.CursorChange, 1), ctx: octx, stop: stop, done: make(chan struct{})}
4345 s.outs[name] = r
4446 go func() {
45- r.err = run(octx, r.scenes, r.contents)
47+ r.err = run(octx, r.scenes, r.contents, r.cursor)
4648 close(r.done)
4749 select {
4850 case s.stopped <- name:
4951 case <-s.quit:
5052 }
5153 }()
54+ r.cursor <- s.cursor
5255 for _, c := range s.latest {
5356 r.send(c)
5457 }
5558 }
5659
60+// setCursor gives every output the latest cursor, replacing an unread one.
61+func (s *outputSet) setCursor(c ports.CursorChange) {
62+ s.cursor = c
63+ for _, r := range s.outs {
64+ select {
65+ case <-r.cursor:
66+ default:
67+ }
68+ r.cursor <- c
69+ }
70+}
71+
5772 // send hands content to the output unless it has stopped.
5873 func (r *runningOutput) send(c ports.SurfaceContent) {
5974 select {
@@ -8,8 +8,8 @@ import (
8 )8 )
9 9
10 // outputRun drives one output until ctx ends: it renders the scenes and10 // outputRun drives one output until ctx ends: it renders the scenes and
11-// surface contents it receives.11+// surface contents it receives, with the cursor the client asks for.
12-type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent) error12+type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange) error
13 13
14 // outputSet owns the running outputs and feeds them. Core sends one scene14 // outputSet owns the running outputs and feeds them. Core sends one scene
15 // per output; each goes to its output, latest first. Surface contents go to15 // per output; each goes to its output, latest first. Surface contents go to
@@ -19,6 +19,7 @@ type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-c
19 type outputSet struct {19 type outputSet struct {
20 outs map[string]*runningOutput20 outs map[string]*runningOutput
21 latest map[ports.WindowID]ports.SurfaceContent21 latest map[ports.WindowID]ports.SurfaceContent
22+ cursor ports.CursorChange
22 stopped chan string23 stopped chan string
23 quit chan struct{} // closed by wait: nobody reads stopped any more24 quit chan struct{} // closed by wait: nobody reads stopped any more
24 }25 }
@@ -26,6 +27,7 @@ type outputSet struct {
26 type runningOutput struct {27 type runningOutput struct {
27 scenes chan ports.Scene28 scenes chan ports.Scene
28 contents chan ports.SurfaceContent29 contents chan ports.SurfaceContent
30+ cursor chan ports.CursorChange
29 ctx context.Context31 ctx context.Context
30 stop context.CancelFunc32 stop context.CancelFunc
31 err error33 err error
@@ -39,21 +41,34 @@ func newOutputSet() *outputSet {
39 // start runs an output in its own goroutine and replays the window contents.41 // start runs an output in its own goroutine and replays the window contents.
40 func (s *outputSet) start(ctx context.Context, name string, run outputRun) {42 func (s *outputSet) start(ctx context.Context, name string, run outputRun) {
41 octx, stop := context.WithCancel(ctx)43 octx, stop := context.WithCancel(ctx)
42- r := &runningOutput{scenes: make(chan ports.Scene, 1), contents: make(chan ports.SurfaceContent, 64), ctx: octx, stop: stop, done: make(chan struct{})}44+ r := &runningOutput{scenes: make(chan ports.Scene, 1), contents: make(chan ports.SurfaceContent, 64), cursor: make(chan ports.CursorChange, 1), ctx: octx, stop: stop, done: make(chan struct{})}
43 s.outs[name] = r45 s.outs[name] = r
44 go func() {46 go func() {
45- r.err = run(octx, r.scenes, r.contents)47+ r.err = run(octx, r.scenes, r.contents, r.cursor)
46 close(r.done)48 close(r.done)
47 select {49 select {
48 case s.stopped <- name:50 case s.stopped <- name:
49 case <-s.quit:51 case <-s.quit:
50 }52 }
51 }()53 }()
54+ r.cursor <- s.cursor
52 for _, c := range s.latest {55 for _, c := range s.latest {
53 r.send(c)56 r.send(c)
54 }57 }
55 }58 }
56 59
60+// setCursor gives every output the latest cursor, replacing an unread one.
61+func (s *outputSet) setCursor(c ports.CursorChange) {
62+ s.cursor = c
63+ for _, r := range s.outs {
64+ select {
65+ case <-r.cursor:
66+ default:
67+ }
68+ r.cursor <- c
69+ }
70+}
71+
57 // send hands content to the output unless it has stopped.72 // send hands content to the output unless it has stopped.
58 func (r *runningOutput) send(c ports.SurfaceContent) {73 func (r *runningOutput) send(c ports.SurfaceContent) {
59 select {74 select {
A internal/app/outputs_test.go +54 -0
@@ -0,0 +1,54 @@
1+package app
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/nefertty/internal/ports"
9+)
10+
11+// Every output gets the latest cursor, and a new output starts with it.
12+func TestOutputSetCursor(t *testing.T) {
13+ ctx, cancel := context.WithCancel(context.Background())
14+ defer cancel()
15+ set := newOutputSet()
16+ got := map[string]chan ports.CursorChange{}
17+ run := func(name string) outputRun {
18+ got[name] = make(chan ports.CursorChange, 8)
19+ out := got[name]
20+ return func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange) error {
21+ for {
22+ select {
23+ case <-ctx.Done():
24+ return nil
25+ case c := <-cursor:
26+ out <- c
27+ }
28+ }
29+ }
30+ }
31+ expect := func(name string, want ports.CursorChange) {
32+ t.Helper()
33+ select {
34+ case c := <-got[name]:
35+ if c.Shape != want.Shape || c.Hidden != want.Hidden {
36+ t.Fatalf("%s: %+v, want %+v", name, c, want)
37+ }
38+ case <-time.After(2 * time.Second):
39+ t.Fatalf("%s: no cursor", name)
40+ }
41+ }
42+ set.start(ctx, "A", run("A"))
43+ expect("A", ports.CursorChange{})
44+ set.setCursor(ports.CursorChange{Shape: "text"})
45+ expect("A", ports.CursorChange{Shape: "text"})
46+ set.start(ctx, "B", run("B"))
47+ expect("B", ports.CursorChange{Shape: "text"})
48+ set.setCursor(ports.CursorChange{Hidden: true})
49+ expect("A", ports.CursorChange{Hidden: true})
50+ expect("B", ports.CursorChange{Hidden: true})
51+ if err := set.wait(); err != nil {
52+ t.Fatal(err)
53+ }
54+}
@@ -0,0 +1,54 @@
1+package app
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/nefertty/internal/ports"
9+)
10+
11+// Every output gets the latest cursor, and a new output starts with it.
12+func TestOutputSetCursor(t *testing.T) {
13+ ctx, cancel := context.WithCancel(context.Background())
14+ defer cancel()
15+ set := newOutputSet()
16+ got := map[string]chan ports.CursorChange{}
17+ run := func(name string) outputRun {
18+ got[name] = make(chan ports.CursorChange, 8)
19+ out := got[name]
20+ return func(ctx context.Context, _ <-chan ports.Scene, _ <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange) error {
21+ for {
22+ select {
23+ case <-ctx.Done():
24+ return nil
25+ case c := <-cursor:
26+ out <- c
27+ }
28+ }
29+ }
30+ }
31+ expect := func(name string, want ports.CursorChange) {
32+ t.Helper()
33+ select {
34+ case c := <-got[name]:
35+ if c.Shape != want.Shape || c.Hidden != want.Hidden {
36+ t.Fatalf("%s: %+v, want %+v", name, c, want)
37+ }
38+ case <-time.After(2 * time.Second):
39+ t.Fatalf("%s: no cursor", name)
40+ }
41+ }
42+ set.start(ctx, "A", run("A"))
43+ expect("A", ports.CursorChange{})
44+ set.setCursor(ports.CursorChange{Shape: "text"})
45+ expect("A", ports.CursorChange{Shape: "text"})
46+ set.start(ctx, "B", run("B"))
47+ expect("B", ports.CursorChange{Shape: "text"})
48+ set.setCursor(ports.CursorChange{Hidden: true})
49+ expect("A", ports.CursorChange{Hidden: true})
50+ expect("B", ports.CursorChange{Hidden: true})
51+ if err := set.wait(); err != nil {
52+ t.Fatal(err)
53+ }
54+}
M internal/core/slots_test.go +3 -3
@@ -159,9 +159,9 @@ func TestSlotsSpawnAtStartAndFillInOrder(t *testing.T) {
159159
160160 func TestSlotRefilledOnlyWhenShown(t *testing.T) {
161161 r := startSlots(t)
162- code, foot := receive(t, r.spawn), receive(t, r.spawn)
163- r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
164- r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
162+ // Wait for both windows: a one-window scene from before the unmap
163+ // would let the key below race it.
164+ _, foot := r.fill(t)
165165 r.client <- ports.WindowUnmapped{ID: 3} // foot exits
166166 sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
167167 noSpawn(t, r.spawn) // nothing relaunches on its own
@@ -159,9 +159,9 @@ func TestSlotsSpawnAtStartAndFillInOrder(t *testing.T) {
159 159
160 func TestSlotRefilledOnlyWhenShown(t *testing.T) {160 func TestSlotRefilledOnlyWhenShown(t *testing.T) {
161 r := startSlots(t)161 r := startSlots(t)
162- code, foot := receive(t, r.spawn), receive(t, r.spawn)162+ // Wait for both windows: a one-window scene from before the unmap
163- r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}163+ // would let the key below race it.
164- r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}164+ _, foot := r.fill(t)
165 r.client <- ports.WindowUnmapped{ID: 3} // foot exits165 r.client <- ports.WindowUnmapped{ID: 3} // foot exits
166 sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })166 sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
167 noSpawn(t, r.spawn) // nothing relaunches on its own167 noSpawn(t, r.spawn) // nothing relaunches on its own
M internal/ports/messages.go +12 -0
@@ -304,6 +304,18 @@ type CursorImage struct {
304304 Pixels []byte
305305 }
306306
307+// CursorChange carries wayland → outputs the cursor the focused client asks
308+// for: a CSS cursor name (wp_cursor_shape_v1), its own image, or none.
309+// The zero value is the default arrow.
310+type CursorChange struct {
311+ // Shape is a CSS cursor name such as "pointer" or "text".
312+ Shape string
313+ // Image is a client cursor at Scale buffer pixels per logical pixel.
314+ Image *CursorImage
315+ Scale int
316+ Hidden bool
317+}
318+
307319 // SpawnRequest carries core → launcher process arguments.
308320 type SpawnRequest struct {
309321 Argv []string
@@ -304,6 +304,18 @@ type CursorImage struct {
304 Pixels []byte304 Pixels []byte
305 }305 }
306 306
307+// CursorChange carries wayland → outputs the cursor the focused client asks
308+// for: a CSS cursor name (wp_cursor_shape_v1), its own image, or none.
309+// The zero value is the default arrow.
310+type CursorChange struct {
311+ // Shape is a CSS cursor name such as "pointer" or "text".
312+ Shape string
313+ // Image is a client cursor at Scale buffer pixels per logical pixel.
314+ Image *CursorImage
315+ Scale int
316+ Hidden bool
317+}
318+
307 // SpawnRequest carries core → launcher process arguments.319 // SpawnRequest carries core → launcher process arguments.
308 type SpawnRequest struct {320 type SpawnRequest struct {
309 Argv []string321 Argv []string

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