merge: app-cursors (cursor-shape-v1, client cursor surfaces)
19 files · +782 -50
M internal/adapters/drm/cursor.go +11 -1
| @@ -187,13 +187,23 @@ func (c *Cursor) position() (int, int) { | |||
| 187 | 187 | func (c *Cursor) Limit() int { return c.size } | |
| 188 | 188 | ||
| 189 | 189 | // 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 | 192 | func (c *Cursor) SetImage(pixels []byte, w, h, hotX, hotY int) error { | |
| 192 | 193 | c.io.Lock() | |
| 193 | 194 | defer c.io.Unlock() | |
| 194 | 195 | if c.buf == nil { | |
| 195 | 196 | 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 | 207 | pitch := int(c.buf.pitch) | |
| 198 | 208 | clear(c.buf.mem) | |
| 199 | 209 | 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 the | 189 | // 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 nil | 196 | 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) { | |||
| 168 | 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 | + | } | |
| @@ -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 { | |||
| 31 | 31 | log zerowrap.Logger | |
| 32 | 32 | } | |
| 33 | 33 | ||
| 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) | |
| 37 | 37 | ||
| 38 | 38 | // newOutput allocates buffers and the cursor for a connector on crtc. | |
| 39 | 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 | 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 | 121 | r, err := newRenderer(o.Width(), o.Height()) | |
| 122 | 122 | if err != nil { | |
| 123 | 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 | 138 | surfaces := make(map[ports.WindowID]ports.SurfaceContent) | |
| 139 | 139 | var scene ports.Scene | |
| 140 | 140 | haveScene, dirty := false, false | |
| 141 | + | var want ports.CursorChange | |
| 141 | 142 | cursorScale := -1.0 // not loaded yet | |
| 142 | 143 | frame := 0 | |
| 143 | 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 | 175 | case s := <-scenes: | |
| 175 | 176 | if o.cursor != nil && loadCursor != nil && s.Scale != cursorScale { | |
| 176 | 177 | cursorScale = s.Scale | |
| 177 | - | o.setCursor(loadCursor, s.Scale) | |
| 178 | + | o.setCursor(loadCursor, want, s.Scale) | |
| 178 | 179 | } | |
| 179 | 180 | 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 | 188 | case c := <-contents: | |
| 181 | 189 | if c.Empty() { | |
| 182 | 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. | |
| 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()) | |
| 228 | 236 | if err == nil { | |
| 229 | 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 | 240 | o.log.Warn().Err(err).Msg("cursor") | |
| 233 | 241 | 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 | } | |
| @@ -31,9 +31,9 @@ type Output struct { | |||||
| 31 | log zerowrap.Logger | 31 | log zerowrap.Logger | ||
| 32 | } | 32 | } | ||
| 33 | 33 | ||||
| 34 | - | // CursorLoader returns the cursor image for an output scale, at most limit | 34 | + | // 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.Scene | 139 | var scene ports.Scene | ||
| 140 | haveScene, dirty := false, false | 140 | haveScene, dirty := false, false | ||
| 141 | + | var want ports.CursorChange | |||
| 141 | cursorScale := -1.0 // not loaded yet | 142 | cursorScale := -1.0 // not loaded yet | ||
| 142 | frame := 0 | 143 | 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.Scale | 177 | cursorScale = s.Scale | ||
| 177 | - | o.setCursor(loadCursor, s.Scale) | 178 | + | o.setCursor(loadCursor, want, s.Scale) |
| 178 | } | 179 | } | ||
| 179 | scene, haveScene, dirty = s, true, true | 180 | 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 | return | 241 | 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 { | |||
| 17 | 17 | // Cursor, when set, is drawn into screenshots with the image from | |
| 18 | 18 | // LoadCursor at the scene scale. | |
| 19 | 19 | 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 | 21 | Width, Height int | |
| 22 | 22 | ScreenshotDir string | |
| 23 | 23 | Log zerowrap.Logger | |
| 24 | 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 | 28 | r, err := opts.NewRenderer(opts.Width, opts.Height) | |
| 29 | 29 | if err != nil { | |
| 30 | 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 | 34 | var scene ports.Scene | |
| 35 | 35 | haveScene, dirty := false, false | |
| 36 | 36 | frame := 0 | |
| 37 | + | var want ports.CursorChange | |
| 37 | 38 | cursorScale := -1.0 // not loaded yet | |
| 38 | 39 | update := func(c ports.SurfaceContent) { | |
| 39 | 40 | dirty = dirty || scene.Shows(c.ID) | |
| @@ -62,6 +63,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 62 | 63 | continue | |
| 63 | 64 | } | |
| 64 | 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 | 70 | // Drain all queued updates before presenting one frame. | |
| 67 | 71 | drain: | |
| @@ -89,7 +93,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 89 | 93 | dirty = false | |
| 90 | 94 | if opts.Cursor != nil && opts.LoadCursor != nil && scene.Scale != cursorScale { | |
| 91 | 95 | 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 | 97 | opts.Cursor.set(img) | |
| 94 | 98 | } else { | |
| 95 | 99 | 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 from | 17 | // 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 *Cursor | 19 | 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 int | 21 | Width, Height int | ||
| 22 | ScreenshotDir string | 22 | ScreenshotDir string | ||
| 23 | Log zerowrap.Logger | 23 | 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.Scene | 34 | var scene ports.Scene | ||
| 35 | haveScene, dirty := false, false | 35 | haveScene, dirty := false, false | ||
| 36 | frame := 0 | 36 | frame := 0 | ||
| 37 | + | var want ports.CursorChange | |||
| 37 | cursorScale := -1.0 // not loaded yet | 38 | 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 | continue | 63 | 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 = false | 93 | 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.Scale | 95 | 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) { | |||
| 76 | 76 | ctx, cancel := context.WithCancel(context.Background()) | |
| 77 | 77 | done := make(chan error, 1) | |
| 78 | 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 | 81 | waitFrames(t, f, 1) | |
| 82 | 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 | 115 | ctx, cancel := context.WithCancel(context.Background()) | |
| 116 | 116 | done := make(chan error, 1) | |
| 117 | 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 | 120 | waitFrames(t, f, 1) | |
| 121 | 121 | contents <- ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}} | |
| @@ -139,11 +139,11 @@ func TestErrors(t *testing.T) { | |||
| 139 | 139 | expected := errors.New("failure") | |
| 140 | 140 | scenes := make(chan ports.Scene, 1) | |
| 141 | 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 | 143 | t.Fatal(err) | |
| 144 | 144 | } | |
| 145 | 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 | 147 | t.Fatal(err) | |
| 148 | 148 | } | |
| 149 | 149 | } | |
| @@ -184,7 +184,7 @@ func TestRunPassesLayerContent(t *testing.T) { | |||
| 184 | 184 | ctx, cancel := context.WithCancel(context.Background()) | |
| 185 | 185 | done := make(chan error, 1) | |
| 186 | 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 | 189 | deadline := time.After(3 * time.Second) | |
| 190 | 190 | 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 { | |||
| 14 | 14 | func() error { return registerXDGOutput(d, s) }, | |
| 15 | 15 | func() error { return registerDecoration(d, s) }, | |
| 16 | 16 | func() error { return registerScale(d, s) }, | |
| 17 | + | func() error { return registerCursorShape(d, s) }, | |
| 17 | 18 | func() error { return registerDMABuf(d, s, o.DMABuf) }, | |
| 18 | 19 | func() error { | |
| 19 | 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 | 323 | type seat struct{ server *Server } | |
| 323 | 324 | ||
| 324 | 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 | 327 | if err != nil { | |
| 327 | 328 | return | |
| 328 | 329 | } | |
| @@ -388,13 +389,6 @@ func (seat) GetTouch(r *wayland.Seat, id uint32) { | |||
| 388 | 389 | } | |
| 389 | 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 | 392 | type keyboard struct{} | |
| 399 | 393 | ||
| 400 | 394 | 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 | return | 328 | 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 { | |||
| 32 | 32 | Events chan<- ports.ClientEvent | |
| 33 | 33 | Contents chan<- ports.SurfaceContent | |
| 34 | 34 | Commands <-chan ports.ClientCommand | |
| 35 | + | // Cursors receives the cursor the client under the pointer asks for. | |
| 36 | + | Cursors chan<- ports.CursorChange | |
| 35 | 37 | } | |
| 36 | 38 | type Server struct { | |
| 37 | 39 | display *server.Display | |
| @@ -79,6 +81,13 @@ type Server struct { | |||
| 79 | 81 | outputs []*output | |
| 80 | 82 | focusedOutput string | |
| 81 | 83 | 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 | 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 | 121 | d.Close() | |
| 113 | 122 | 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 | 125 | s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{} | |
| 117 | 126 | if opts.Keymap != "" { | |
| 118 | 127 | fd, size, e := keymapFile(opts.Keymap) | |
| @@ -147,8 +156,9 @@ func (s *Server) Run(ctx context.Context) error { | |||
| 147 | 156 | s.started = time.Now() | |
| 148 | 157 | s.ctx = ctx | |
| 149 | 158 | var wg sync.WaitGroup | |
| 150 | - | wg.Add(4) | |
| 159 | + | wg.Add(5) | |
| 151 | 160 | go func() { defer wg.Done(); s.forward(ctx) }() | |
| 161 | + | go func() { defer wg.Done(); s.forwardCursors(ctx) }() | |
| 152 | 162 | go func() { defer wg.Done(); s.forwardContents(ctx) }() | |
| 153 | 163 | go func() { | |
| 154 | 164 | defer wg.Done() | |
| @@ -574,6 +584,9 @@ func (s *Server) changePointerFocus(id ports.WindowID, x, y float64) { | |||
| 574 | 584 | if id == s.pointerFocus { | |
| 575 | 585 | 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 | 590 | if old := s.windows[s.pointerFocus]; old != nil { | |
| 578 | 591 | if surface := old.xdg.surfaceResource(); surface != nil && surface.Resource.Alive() { | |
| 579 | 592 | for _, p := range s.windowPointers(old) { | |
| @@ -32,6 +32,8 @@ type Channels struct { | |||||
| 32 | Events chan<- ports.ClientEvent | 32 | Events chan<- ports.ClientEvent | ||
| 33 | Contents chan<- ports.SurfaceContent | 33 | Contents chan<- ports.SurfaceContent | ||
| 34 | Commands <-chan ports.ClientCommand | 34 | 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.Display | 39 | display *server.Display | ||
| @@ -79,6 +81,13 @@ type Server struct { | |||||
| 79 | outputs []*output | 81 | outputs []*output | ||
| 80 | focusedOutput string | 82 | focusedOutput string | ||
| 81 | fractions map[*surface]*fractionalscale.WpFractionalScaleV1 | 83 | 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, err | 122 | 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 = ctx | 157 | s.ctx = ctx | ||
| 149 | var wg sync.WaitGroup | 158 | 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 | return | 585 | 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 ( | |||
| 14 | 14 | roleXDG | |
| 15 | 15 | roleSubsurface | |
| 16 | 16 | roleLayer | |
| 17 | + | roleCursor | |
| 17 | 18 | ) | |
| 18 | 19 | ||
| 19 | 20 | type surface struct { | |
| @@ -31,6 +32,8 @@ type surface struct { | |||
| 31 | 32 | callbacks []*wayland.Callback | |
| 32 | 33 | role func(bool) | |
| 33 | 34 | destroyed bool | |
| 35 | + | // hotX, hotY is the click point of a cursor surface (logical). | |
| 36 | + | hotX, hotY int | |
| 34 | 37 | // on is the output the surface entered; scale is the last scale sent. | |
| 35 | 38 | on *output | |
| 36 | 39 | scale float64 | |
| @@ -132,6 +135,11 @@ func (s *surface) detach() { | |||
| 132 | 135 | ||
| 133 | 136 | func (s *surface) Destroy(*wayland.Surface) { | |
| 134 | 137 | 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 | 143 | s.detach() | |
| 136 | 144 | for _, ch := range s.sub.children { | |
| 137 | 145 | ch.sub.parent = nil | |
| @@ -14,6 +14,7 @@ const ( | |||||
| 14 | roleXDG | 14 | roleXDG | ||
| 15 | roleSubsurface | 15 | roleSubsurface | ||
| 16 | roleLayer | 16 | 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.Callback | 32 | callbacks []*wayland.Callback | ||
| 32 | role func(bool) | 33 | role func(bool) | ||
| 33 | destroyed bool | 34 | 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 *output | 38 | on *output | ||
| 36 | scale float64 | 39 | 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 = true | 137 | 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 = nil | 145 | 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) | |||
| 78 | 78 | configErrors := make(chan error, 8) | |
| 79 | 79 | renderScenes := make(chan []ports.Scene, 1) | |
| 80 | 80 | contents := make(chan ports.SurfaceContent, 64) | |
| 81 | + | cursorChanges := make(chan ports.CursorChange, 1) | |
| 81 | 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 | 83 | c, err := core.New(opts.Config, ch) | |
| 83 | 84 | if err != nil { | |
| @@ -92,7 +93,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 92 | 93 | // Every output renders on the same GPU: its formats are the clients'. | |
| 93 | 94 | dmabuf := vulkan.Probe() | |
| 94 | 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 | 97 | if err != nil { | |
| 97 | 98 | km.Close() | |
| 98 | 99 | return err | |
| @@ -193,11 +194,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 193 | 194 | if hw != nil { | |
| 194 | 195 | done <- safe("output", func() error { | |
| 195 | 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 | 199 | 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 | 204 | 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 err | 99 | 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 | return | 199 | 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 ( | |||
| 4 | 4 | "math" | |
| 5 | 5 | "os" | |
| 6 | 6 | "strconv" | |
| 7 | + | "sync" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/nefertty/internal/adapters/xcursor" | |
| 9 | 10 | "github.com/bnema/nefertty/internal/ports" | |
| @@ -17,14 +18,94 @@ func cursorSize() int { | |||
| 17 | 18 | return 24 | |
| 18 | 19 | } | |
| 19 | 20 | ||
| 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 | + | } | |
| 25 | 104 | if err != nil { | |
| 26 | 105 | return ports.CursorImage{}, err | |
| 27 | 106 | } | |
| 28 | 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}, 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 | |
| 30 | 111 | } | |
| @@ -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 24 | 18 | return 24 | ||
| 18 | } | 19 | } | ||
| 19 | 20 | ||||
| 20 | - | // loadCursor loads the XCURSOR_THEME arrow at XCURSOR_SIZE × scale physical | 21 | + | // 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{}, err | 105 | 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}, 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 | |||
| 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() { | |||
| 108 | 108 | // one flip reader per card, and a udev watcher that rescans connectors on | |
| 109 | 109 | // hotplug. Core learns about outputs through events. It returns when ctx | |
| 110 | 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 | 112 | ctx, cancel := context.WithCancel(ctx) | |
| 113 | 113 | defer cancel() | |
| 114 | 114 | var readers sync.WaitGroup | |
| @@ -176,11 +176,11 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event | |||
| 176 | 176 | curs.set(name, cur) | |
| 177 | 177 | } | |
| 178 | 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 | 180 | defer b.seat.Unsubscribe(active) | |
| 181 | 181 | return safe("output "+name, func() error { | |
| 182 | 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 | 186 | send(ports.OutputAdded{Info: o.Info()}) | |
| @@ -205,6 +205,8 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event | |||
| 205 | 205 | set.scenes(s) | |
| 206 | 206 | case c := <-contents: | |
| 207 | 207 | set.content(c) | |
| 208 | + | case c := <-cursorChanges: | |
| 209 | + | set.setCursor(c) | |
| 208 | 210 | case name := <-set.stopped: | |
| 209 | 211 | err := set.finish(name) | |
| 210 | 212 | 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 on | 108 | // one flip reader per card, and a udev watcher that rescans connectors on | ||
| 109 | // hotplug. Core learns about outputs through events. It returns when ctx | 109 | // 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.WaitGroup | 114 | 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 ( | |||
| 13 | 13 | ||
| 14 | 14 | // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ... | |
| 15 | 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 | 17 | set := newOutputSet() | |
| 18 | 18 | for i, size := range sizes { | |
| 19 | 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 | 27 | cur := &headless.Cursor{} | |
| 28 | 28 | curs.set(name, cur) | |
| 29 | 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 { | |
| 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) | |
| 32 | 32 | }) | |
| 33 | 33 | select { | |
| 34 | 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 | 46 | set.scenes(s) | |
| 47 | 47 | case c := <-contents: | |
| 48 | 48 | set.content(c) | |
| 49 | + | case c := <-cursorChanges: | |
| 50 | + | set.setCursor(c) | |
| 49 | 51 | case name := <-set.stopped: | |
| 50 | 52 | // A headless output only stops on error: the run ends. | |
| 51 | 53 | 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 ( | |||
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | // 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 | |
| 13 | 13 | ||
| 14 | 14 | // outputSet owns the running outputs and feeds them. Core sends one scene | |
| 15 | 15 | // 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 | 19 | type outputSet struct { | |
| 20 | 20 | outs map[string]*runningOutput | |
| 21 | 21 | latest map[ports.WindowID]ports.SurfaceContent | |
| 22 | + | cursor ports.CursorChange | |
| 22 | 23 | stopped chan string | |
| 23 | 24 | quit chan struct{} // closed by wait: nobody reads stopped any more | |
| 24 | 25 | } | |
| @@ -26,6 +27,7 @@ type outputSet struct { | |||
| 26 | 27 | type runningOutput struct { | |
| 27 | 28 | scenes chan ports.Scene | |
| 28 | 29 | contents chan ports.SurfaceContent | |
| 30 | + | cursor chan ports.CursorChange | |
| 29 | 31 | ctx context.Context | |
| 30 | 32 | stop context.CancelFunc | |
| 31 | 33 | err error | |
| @@ -39,21 +41,34 @@ func newOutputSet() *outputSet { | |||
| 39 | 41 | // start runs an output in its own goroutine and replays the window contents. | |
| 40 | 42 | func (s *outputSet) start(ctx context.Context, name string, run outputRun) { | |
| 41 | 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 | 45 | s.outs[name] = r | |
| 44 | 46 | go func() { | |
| 45 | - | r.err = run(octx, r.scenes, r.contents) | |
| 47 | + | r.err = run(octx, r.scenes, r.contents, r.cursor) | |
| 46 | 48 | close(r.done) | |
| 47 | 49 | select { | |
| 48 | 50 | case s.stopped <- name: | |
| 49 | 51 | case <-s.quit: | |
| 50 | 52 | } | |
| 51 | 53 | }() | |
| 54 | + | r.cursor <- s.cursor | |
| 52 | 55 | for _, c := range s.latest { | |
| 53 | 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 | 72 | // send hands content to the output unless it has stopped. | |
| 58 | 73 | func (r *runningOutput) send(c ports.SurfaceContent) { | |
| 59 | 74 | select { | |
| @@ -8,8 +8,8 @@ import ( | |||||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | // outputRun drives one output until ctx ends: it renders the scenes and | 10 | // 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) error | 12 | + | 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 scene | 14 | // 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 to | 15 | // 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]*runningOutput | 20 | outs map[string]*runningOutput | ||
| 21 | latest map[ports.WindowID]ports.SurfaceContent | 21 | latest map[ports.WindowID]ports.SurfaceContent | ||
| 22 | + | cursor ports.CursorChange | |||
| 22 | stopped chan string | 23 | stopped chan string | ||
| 23 | quit chan struct{} // closed by wait: nobody reads stopped any more | 24 | 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.Scene | 28 | scenes chan ports.Scene | ||
| 28 | contents chan ports.SurfaceContent | 29 | contents chan ports.SurfaceContent | ||
| 30 | + | cursor chan ports.CursorChange | |||
| 29 | ctx context.Context | 31 | ctx context.Context | ||
| 30 | stop context.CancelFunc | 32 | stop context.CancelFunc | ||
| 31 | err error | 33 | 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] = r | 45 | 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) { | |||
| 159 | 159 | ||
| 160 | 160 | func TestSlotRefilledOnlyWhenShown(t *testing.T) { | |
| 161 | 161 | 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) | |
| 165 | 165 | r.client <- ports.WindowUnmapped{ID: 3} // foot exits | |
| 166 | 166 | sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 }) | |
| 167 | 167 | 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 exits | 165 | 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 own | 167 | noSpawn(t, r.spawn) // nothing relaunches on its own | ||
M internal/ports/messages.go +12 -0
| @@ -304,6 +304,18 @@ type CursorImage struct { | |||
| 304 | 304 | 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 | 319 | // SpawnRequest carries core → launcher process arguments. | |
| 308 | 320 | type SpawnRequest struct { | |
| 309 | 321 | Argv []string | |
| @@ -304,6 +304,18 @@ type CursorImage struct { | |||||
| 304 | Pixels []byte | 304 | 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 []string | 321 | Argv []string | ||