detached dbc10e2

feat(wayland): apply compositor cursor overrides over client cursors

commit
dbc10e2093b2ac0bb0454ec41021a0b00ed12d48
author
brice · · 2026-10-21 05:18 UTC
committer
brice · · 2026-10-21 07:11 UTC
parent
a8dbdb2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +81 -3
M internal/adapters/wayland/cursor.go +26 -2
@@ -138,9 +138,33 @@ func (s *surface) cursorImage() ports.CursorChange {
138138 return ports.CursorChange{Image: &img, Scale: scale}
139139 }
140140
141-// setCursor queues a cursor change; only the latest is delivered.
141+// setCursor remembers the client cursor, forwarding it unless overridden.
142142 func (s *Server) setCursor(c ports.CursorChange) {
143- if s.protected() || s.channels.Cursors == nil {
143+ if s.protected() {
144+ return
145+ }
146+ s.cursorClient = c
147+ if s.cursorOverride == "" {
148+ s.queueCursor(c)
149+ }
150+}
151+
152+func (s *Server) setCursorOverride(shape string) {
153+ if s.protected() || shape == s.cursorOverride {
154+ return
155+ }
156+ s.cursorOverride = shape
157+ s.log.Debug().Str("shape", shape).Msg("cursor override")
158+ if shape == "" {
159+ s.queueCursor(s.cursorClient)
160+ } else {
161+ s.queueCursor(ports.CursorChange{Shape: shape})
162+ }
163+}
164+
165+// queueCursor queues a cursor change; only the latest is delivered.
166+func (s *Server) queueCursor(c ports.CursorChange) {
167+ if s.channels.Cursors == nil {
144168 return
145169 }
146170 s.cursorMu.Lock()
@@ -138,9 +138,33 @@ func (s *surface) cursorImage() ports.CursorChange {
138 return ports.CursorChange{Image: &img, Scale: scale}138 return ports.CursorChange{Image: &img, Scale: scale}
139 }139 }
140 140
141-// setCursor queues a cursor change; only the latest is delivered.141+// setCursor remembers the client cursor, forwarding it unless overridden.
142 func (s *Server) setCursor(c ports.CursorChange) {142 func (s *Server) setCursor(c ports.CursorChange) {
143- if s.protected() || s.channels.Cursors == nil {143+ if s.protected() {
144+ return
145+ }
146+ s.cursorClient = c
147+ if s.cursorOverride == "" {
148+ s.queueCursor(c)
149+ }
150+}
151+
152+func (s *Server) setCursorOverride(shape string) {
153+ if s.protected() || shape == s.cursorOverride {
154+ return
155+ }
156+ s.cursorOverride = shape
157+ s.log.Debug().Str("shape", shape).Msg("cursor override")
158+ if shape == "" {
159+ s.queueCursor(s.cursorClient)
160+ } else {
161+ s.queueCursor(ports.CursorChange{Shape: shape})
162+ }
163+}
164+
165+// queueCursor queues a cursor change; only the latest is delivered.
166+func (s *Server) queueCursor(c ports.CursorChange) {
167+ if s.channels.Cursors == nil {
144 return168 return
145 }169 }
146 s.cursorMu.Lock()170 s.cursorMu.Lock()
M internal/adapters/wayland/cursor_test.go +48 -0
@@ -83,6 +83,23 @@ func TestCursorShapeFollowsPointerFocus(t *testing.T) {
8383 if got := nextCursor(t, cursors); got.Shape != "pointer" {
8484 t.Fatalf("shape: %+v", got)
8585 }
86+ commands <- ports.CursorOverride{Shape: "ew-resize"}
87+ if got := nextCursor(t, cursors); got != (ports.CursorChange{Shape: "ew-resize"}) {
88+ t.Fatalf("override: %+v", got)
89+ }
90+ requestProtocol(t, c, device, cursorshape.WpCursorShapeDeviceV1RequestSetShape, uint32(3), uint32(cursorshape.WpCursorShapeDeviceV1ShapeText))
91+ if err := c.Roundtrip(); err != nil {
92+ t.Fatal(err)
93+ }
94+ select {
95+ case got := <-cursors:
96+ t.Fatalf("client cursor forwarded during override: %+v", got)
97+ case <-time.After(50 * time.Millisecond):
98+ }
99+ commands <- ports.CursorOverride{}
100+ if got := nextCursor(t, cursors); got != (ports.CursorChange{Shape: "text"}) {
101+ t.Fatalf("restored client cursor: %+v", got)
102+ }
86103 commands <- ports.PointerFocus{ID: 0}
87104 if got := nextCursor(t, cursors); got != (ports.CursorChange{}) {
88105 t.Fatalf("leave: %+v, want the arrow", got)
@@ -205,3 +222,34 @@ func TestSetCursorRoleError(t *testing.T) {
205222 requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), surf, int32(0), int32(0))
206223 expectProtocolError(t, c, pointer, uint32(wayland.PointerErrorRole))
207224 }
225+
226+func TestCursorOverrideProtection(t *testing.T) {
227+ s, _, commands, cursors, _ := cursorServer(t)
228+ state := installSecurity(t, s)
229+ commands <- ports.CursorOverride{Shape: "ns-resize"}
230+ if got := nextCursor(t, cursors); got.Shape != "ns-resize" {
231+ t.Fatalf("override: %+v", got)
232+ }
233+ protect(t, s, state, 1, true)
234+ if !s.display.Do(func() {
235+ s.resetProtectionInput()
236+ s.apply(ports.SecurityCommand{State: *state.Load(), Command: ports.CursorOverride{Shape: "ew-resize"}})
237+ if s.cursorOverride != "" {
238+ t.Errorf("protected override: %q", s.cursorOverride)
239+ }
240+ }) {
241+ t.Fatal("display stopped")
242+ }
243+ select {
244+ case got := <-cursors:
245+ t.Fatalf("protected cursor forwarded: %+v", got)
246+ case <-time.After(50 * time.Millisecond):
247+ }
248+ protect(t, s, state, 2, false)
249+ if !s.display.Do(func() { s.setCursor(ports.CursorChange{Hidden: true}) }) {
250+ t.Fatal("display stopped")
251+ }
252+ if got := nextCursor(t, cursors); got != (ports.CursorChange{Hidden: true}) {
253+ t.Fatalf("override survived protection: %+v", got)
254+ }
255+}
@@ -83,6 +83,23 @@ func TestCursorShapeFollowsPointerFocus(t *testing.T) {
83 if got := nextCursor(t, cursors); got.Shape != "pointer" {83 if got := nextCursor(t, cursors); got.Shape != "pointer" {
84 t.Fatalf("shape: %+v", got)84 t.Fatalf("shape: %+v", got)
85 }85 }
86+ commands <- ports.CursorOverride{Shape: "ew-resize"}
87+ if got := nextCursor(t, cursors); got != (ports.CursorChange{Shape: "ew-resize"}) {
88+ t.Fatalf("override: %+v", got)
89+ }
90+ requestProtocol(t, c, device, cursorshape.WpCursorShapeDeviceV1RequestSetShape, uint32(3), uint32(cursorshape.WpCursorShapeDeviceV1ShapeText))
91+ if err := c.Roundtrip(); err != nil {
92+ t.Fatal(err)
93+ }
94+ select {
95+ case got := <-cursors:
96+ t.Fatalf("client cursor forwarded during override: %+v", got)
97+ case <-time.After(50 * time.Millisecond):
98+ }
99+ commands <- ports.CursorOverride{}
100+ if got := nextCursor(t, cursors); got != (ports.CursorChange{Shape: "text"}) {
101+ t.Fatalf("restored client cursor: %+v", got)
102+ }
86 commands <- ports.PointerFocus{ID: 0}103 commands <- ports.PointerFocus{ID: 0}
87 if got := nextCursor(t, cursors); got != (ports.CursorChange{}) {104 if got := nextCursor(t, cursors); got != (ports.CursorChange{}) {
88 t.Fatalf("leave: %+v, want the arrow", got)105 t.Fatalf("leave: %+v, want the arrow", got)
@@ -205,3 +222,34 @@ func TestSetCursorRoleError(t *testing.T) {
205 requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), surf, int32(0), int32(0))222 requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), surf, int32(0), int32(0))
206 expectProtocolError(t, c, pointer, uint32(wayland.PointerErrorRole))223 expectProtocolError(t, c, pointer, uint32(wayland.PointerErrorRole))
207 }224 }
225+
226+func TestCursorOverrideProtection(t *testing.T) {
227+ s, _, commands, cursors, _ := cursorServer(t)
228+ state := installSecurity(t, s)
229+ commands <- ports.CursorOverride{Shape: "ns-resize"}
230+ if got := nextCursor(t, cursors); got.Shape != "ns-resize" {
231+ t.Fatalf("override: %+v", got)
232+ }
233+ protect(t, s, state, 1, true)
234+ if !s.display.Do(func() {
235+ s.resetProtectionInput()
236+ s.apply(ports.SecurityCommand{State: *state.Load(), Command: ports.CursorOverride{Shape: "ew-resize"}})
237+ if s.cursorOverride != "" {
238+ t.Errorf("protected override: %q", s.cursorOverride)
239+ }
240+ }) {
241+ t.Fatal("display stopped")
242+ }
243+ select {
244+ case got := <-cursors:
245+ t.Fatalf("protected cursor forwarded: %+v", got)
246+ case <-time.After(50 * time.Millisecond):
247+ }
248+ protect(t, s, state, 2, false)
249+ if !s.display.Do(func() { s.setCursor(ports.CursorChange{Hidden: true}) }) {
250+ t.Fatal("display stopped")
251+ }
252+ if got := nextCursor(t, cursors); got != (ports.CursorChange{Hidden: true}) {
253+ t.Fatalf("override survived protection: %+v", got)
254+ }
255+}
M internal/adapters/wayland/server.go +6 -1
@@ -288,6 +288,9 @@ type Server struct {
288288 fractions map[*surface]*fractionalscale.WpFractionalScaleV1
289289 // cursorSurface is the wl_pointer.set_cursor surface in use.
290290 cursorSurface *surface
291+ // cursorClient and cursorOverride belong to the display goroutine.
292+ cursorClient ports.CursorChange
293+ cursorOverride string
291294 // cursorMu guards only the latest cursor change for forwardCursors.
292295 cursorMu sync.Mutex
293296 cursorLatest ports.CursorChange
@@ -751,12 +754,14 @@ func (s *Server) apply(cmd ports.ClientCommand) {
751754 // Only input/focus commands require an owner envelope. Config relay
752755 // also produces SetKeymap and other epoch-independent commands.
753756 switch cmd.(type) {
754- case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers,
757+ case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers, ports.CursorOverride,
755758 ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
756759 return
757760 }
758761 }
759762 switch c := cmd.(type) {
763+ case ports.CursorOverride:
764+ s.setCursorOverride(c.Shape)
760765 case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers:
761766 s.applyInput(c)
762767 case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
@@ -288,6 +288,9 @@ type Server struct {
288 fractions map[*surface]*fractionalscale.WpFractionalScaleV1288 fractions map[*surface]*fractionalscale.WpFractionalScaleV1
289 // cursorSurface is the wl_pointer.set_cursor surface in use.289 // cursorSurface is the wl_pointer.set_cursor surface in use.
290 cursorSurface *surface290 cursorSurface *surface
291+ // cursorClient and cursorOverride belong to the display goroutine.
292+ cursorClient ports.CursorChange
293+ cursorOverride string
291 // cursorMu guards only the latest cursor change for forwardCursors.294 // cursorMu guards only the latest cursor change for forwardCursors.
292 cursorMu sync.Mutex295 cursorMu sync.Mutex
293 cursorLatest ports.CursorChange296 cursorLatest ports.CursorChange
@@ -751,12 +754,14 @@ func (s *Server) apply(cmd ports.ClientCommand) {
751 // Only input/focus commands require an owner envelope. Config relay754 // Only input/focus commands require an owner envelope. Config relay
752 // also produces SetKeymap and other epoch-independent commands.755 // also produces SetKeymap and other epoch-independent commands.
753 switch cmd.(type) {756 switch cmd.(type) {
754- case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers,757+ case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers, ports.CursorOverride,
755 ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:758 ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
756 return759 return
757 }760 }
758 }761 }
759 switch c := cmd.(type) {762 switch c := cmd.(type) {
763+ case ports.CursorOverride:
764+ s.setCursorOverride(c.Shape)
760 case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers:765 case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers:
761 s.applyInput(c)766 s.applyInput(c)
762 case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:767 case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
M internal/adapters/wayland/session_lock.go +1 -0
@@ -138,6 +138,7 @@ func (l *sessionLock) orphan() {
138138 }
139139
140140 func (s *Server) resetProtectionInput() {
141+ s.cursorOverride = ""
141142 s.dndCancelAll()
142143 s.changeFocus(0)
143144 s.changePointerFocus(0, 0, 0)
@@ -138,6 +138,7 @@ func (l *sessionLock) orphan() {
138 }138 }
139 139
140 func (s *Server) resetProtectionInput() {140 func (s *Server) resetProtectionInput() {
141+ s.cursorOverride = ""
141 s.dndCancelAll()142 s.dndCancelAll()
142 s.changeFocus(0)143 s.changeFocus(0)
143 s.changePointerFocus(0, 0, 0)144 s.changePointerFocus(0, 0, 0)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close