detached 2082615

feat(wayland): apply temporary modifier overrides for hint clicks

commit
208261521ee7d4dbbad6cddb5c16bd6613f0e7cd
author
brice · · 2026-10-19 16:03 UTC
committer
brice · · 2026-10-19 17:36 UTC
parent
6319bfa
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +146 -3
M internal/adapters/wayland/keyboard_test.go +88 -1
@@ -130,13 +130,14 @@ type eventsProxy struct {
130130 opcodes []uint16
131131 keymaps []string
132132 locked []uint32 // locked modifiers of each modifiers event
133+ depress []uint32 // depressed modifiers of each modifiers event
133134 }
134135
135136 func (p *eventsProxy) Dispatch(e *wlturbo.Event) {
136137 p.opcodes = append(p.opcodes, e.Opcode)
137138 if e.Opcode == uint16(wayland.KeyboardEventModifiers) {
138139 e.Uint32() // serial
139- e.Uint32() // depressed
140+ p.depress = append(p.depress, e.Uint32())
140141 e.Uint32() // latched
141142 p.locked = append(p.locked, e.Uint32())
142143 return
@@ -254,3 +255,89 @@ func TestFootKeyboard(t *testing.T) {
254255 }
255256 }
256257 }
258+
259+// SetModifiers sends the requested depressed mask with the real latched and
260+// locked state, and Restore sends the real state back; the seat's own state
261+// is untouched. A window that is not focused is ignored.
262+func TestSetModifiersOverrideAndRestore(t *testing.T) {
263+ s, events, commands, dir := keyboardServer(t)
264+ target, proxy := focusedTarget(t, s, events, commands, dir)
265+ const capsLocked = 2
266+ var id ports.WindowID
267+ set := make(chan struct{})
268+ s.display.Do(func() { s.seat.modState = ports.ModState{Locked: capsLocked}; id = s.seat.focused; close(set) })
269+ <-set
270+ // A marker command applied on the display means every command sent before
271+ // it was applied (they apply in order); the roundtrip then delivers the
272+ // events they sent.
273+ var pending bool
274+ wait := func() {
275+ t.Helper()
276+ pending = !pending
277+ commands <- ports.SlotsPending{Pending: pending}
278+ deadline := time.Now().Add(2 * time.Second)
279+ for {
280+ var now bool
281+ if !s.display.Do(func() { now = s.slotsPending }) {
282+ t.Fatal("display stopped")
283+ }
284+ if now == pending {
285+ break
286+ }
287+ if time.Now().After(deadline) {
288+ t.Fatal("commands not applied")
289+ }
290+ time.Sleep(time.Millisecond)
291+ }
292+ roundtrip(t, target)
293+ }
294+ proxy.depress, proxy.locked = nil, nil
295+ commands <- ports.SetModifiers{ID: id + 100, Mods: ports.ModCtrl}
296+ wait()
297+ if len(proxy.depress) != 0 {
298+ t.Fatalf("unfocused window got modifiers %v", proxy.depress)
299+ }
300+ commands <- ports.SetModifiers{ID: id, Mods: ports.ModCtrl | ports.ModShift}
301+ wait()
302+ if !slices.Equal(proxy.depress, []uint32{xkbControl | xkbShift}) || !slices.Equal(proxy.locked, []uint32{capsLocked}) {
303+ t.Fatalf("override depressed %v locked %v", proxy.depress, proxy.locked)
304+ }
305+ done := make(chan ports.ModState, 1)
306+ commands <- ports.SetModifiers{ID: id, Restore: true, State: ports.ModState{Locked: capsLocked}}
307+ wait()
308+ if !slices.Equal(proxy.depress, []uint32{xkbControl | xkbShift, 0}) || !slices.Equal(proxy.locked, []uint32{capsLocked, capsLocked}) {
309+ t.Fatalf("restore depressed %v locked %v", proxy.depress, proxy.locked)
310+ }
311+ s.display.Do(func() { done <- s.seat.modState })
312+ if m := <-done; m != (ports.ModState{Locked: capsLocked}) {
313+ t.Fatalf("seat state changed: %+v", m)
314+ }
315+}
316+
317+// Restore carries the real state core saw, which swallowed keys (a latch a
318+// hint label key consumed) never gave the seat: it is sent and adopted.
319+func TestSetModifiersRestoreAdoptsCoreState(t *testing.T) {
320+ s, events, commands, dir := keyboardServer(t)
321+ target, proxy := focusedTarget(t, s, events, commands, dir)
322+ const shiftLatched, capsLocked = 1, 2
323+ var id ports.WindowID
324+ set := make(chan struct{})
325+ s.display.Do(func() { s.seat.modState = ports.ModState{Latched: shiftLatched}; id = s.seat.focused; close(set) })
326+ <-set
327+ proxy.depress, proxy.locked = nil, nil
328+ want := ports.ModState{Locked: capsLocked} // the latch was consumed
329+ commands <- ports.SetModifiers{ID: id, Restore: true, State: want}
330+ var got ports.ModState
331+ deadline := time.Now().Add(2 * time.Second)
332+ for got != want {
333+ if time.Now().After(deadline) {
334+ t.Fatalf("seat state %+v, want %+v", got, want)
335+ }
336+ s.display.Do(func() { got = s.seat.modState })
337+ time.Sleep(time.Millisecond)
338+ }
339+ roundtrip(t, target)
340+ if !slices.Equal(proxy.locked, []uint32{capsLocked}) || !slices.Equal(proxy.depress, []uint32{0}) {
341+ t.Fatalf("restore depressed %v locked %v", proxy.depress, proxy.locked)
342+ }
343+}
@@ -130,13 +130,14 @@ type eventsProxy struct {
130 opcodes []uint16130 opcodes []uint16
131 keymaps []string131 keymaps []string
132 locked []uint32 // locked modifiers of each modifiers event132 locked []uint32 // locked modifiers of each modifiers event
133+ depress []uint32 // depressed modifiers of each modifiers event
133 }134 }
134 135
135 func (p *eventsProxy) Dispatch(e *wlturbo.Event) {136 func (p *eventsProxy) Dispatch(e *wlturbo.Event) {
136 p.opcodes = append(p.opcodes, e.Opcode)137 p.opcodes = append(p.opcodes, e.Opcode)
137 if e.Opcode == uint16(wayland.KeyboardEventModifiers) {138 if e.Opcode == uint16(wayland.KeyboardEventModifiers) {
138 e.Uint32() // serial139 e.Uint32() // serial
139- e.Uint32() // depressed140+ p.depress = append(p.depress, e.Uint32())
140 e.Uint32() // latched141 e.Uint32() // latched
141 p.locked = append(p.locked, e.Uint32())142 p.locked = append(p.locked, e.Uint32())
142 return143 return
@@ -254,3 +255,89 @@ func TestFootKeyboard(t *testing.T) {
254 }255 }
255 }256 }
256 }257 }
258+
259+// SetModifiers sends the requested depressed mask with the real latched and
260+// locked state, and Restore sends the real state back; the seat's own state
261+// is untouched. A window that is not focused is ignored.
262+func TestSetModifiersOverrideAndRestore(t *testing.T) {
263+ s, events, commands, dir := keyboardServer(t)
264+ target, proxy := focusedTarget(t, s, events, commands, dir)
265+ const capsLocked = 2
266+ var id ports.WindowID
267+ set := make(chan struct{})
268+ s.display.Do(func() { s.seat.modState = ports.ModState{Locked: capsLocked}; id = s.seat.focused; close(set) })
269+ <-set
270+ // A marker command applied on the display means every command sent before
271+ // it was applied (they apply in order); the roundtrip then delivers the
272+ // events they sent.
273+ var pending bool
274+ wait := func() {
275+ t.Helper()
276+ pending = !pending
277+ commands <- ports.SlotsPending{Pending: pending}
278+ deadline := time.Now().Add(2 * time.Second)
279+ for {
280+ var now bool
281+ if !s.display.Do(func() { now = s.slotsPending }) {
282+ t.Fatal("display stopped")
283+ }
284+ if now == pending {
285+ break
286+ }
287+ if time.Now().After(deadline) {
288+ t.Fatal("commands not applied")
289+ }
290+ time.Sleep(time.Millisecond)
291+ }
292+ roundtrip(t, target)
293+ }
294+ proxy.depress, proxy.locked = nil, nil
295+ commands <- ports.SetModifiers{ID: id + 100, Mods: ports.ModCtrl}
296+ wait()
297+ if len(proxy.depress) != 0 {
298+ t.Fatalf("unfocused window got modifiers %v", proxy.depress)
299+ }
300+ commands <- ports.SetModifiers{ID: id, Mods: ports.ModCtrl | ports.ModShift}
301+ wait()
302+ if !slices.Equal(proxy.depress, []uint32{xkbControl | xkbShift}) || !slices.Equal(proxy.locked, []uint32{capsLocked}) {
303+ t.Fatalf("override depressed %v locked %v", proxy.depress, proxy.locked)
304+ }
305+ done := make(chan ports.ModState, 1)
306+ commands <- ports.SetModifiers{ID: id, Restore: true, State: ports.ModState{Locked: capsLocked}}
307+ wait()
308+ if !slices.Equal(proxy.depress, []uint32{xkbControl | xkbShift, 0}) || !slices.Equal(proxy.locked, []uint32{capsLocked, capsLocked}) {
309+ t.Fatalf("restore depressed %v locked %v", proxy.depress, proxy.locked)
310+ }
311+ s.display.Do(func() { done <- s.seat.modState })
312+ if m := <-done; m != (ports.ModState{Locked: capsLocked}) {
313+ t.Fatalf("seat state changed: %+v", m)
314+ }
315+}
316+
317+// Restore carries the real state core saw, which swallowed keys (a latch a
318+// hint label key consumed) never gave the seat: it is sent and adopted.
319+func TestSetModifiersRestoreAdoptsCoreState(t *testing.T) {
320+ s, events, commands, dir := keyboardServer(t)
321+ target, proxy := focusedTarget(t, s, events, commands, dir)
322+ const shiftLatched, capsLocked = 1, 2
323+ var id ports.WindowID
324+ set := make(chan struct{})
325+ s.display.Do(func() { s.seat.modState = ports.ModState{Latched: shiftLatched}; id = s.seat.focused; close(set) })
326+ <-set
327+ proxy.depress, proxy.locked = nil, nil
328+ want := ports.ModState{Locked: capsLocked} // the latch was consumed
329+ commands <- ports.SetModifiers{ID: id, Restore: true, State: want}
330+ var got ports.ModState
331+ deadline := time.Now().Add(2 * time.Second)
332+ for got != want {
333+ if time.Now().After(deadline) {
334+ t.Fatalf("seat state %+v, want %+v", got, want)
335+ }
336+ s.display.Do(func() { got = s.seat.modState })
337+ time.Sleep(time.Millisecond)
338+ }
339+ roundtrip(t, target)
340+ if !slices.Equal(proxy.locked, []uint32{capsLocked}) || !slices.Equal(proxy.depress, []uint32{0}) {
341+ t.Fatalf("restore depressed %v locked %v", proxy.depress, proxy.locked)
342+ }
343+}
M internal/adapters/wayland/seat.go +56 -0
@@ -83,6 +83,8 @@ func (s *Server) applyInput(cmd ports.ClientCommand) {
8383 target = c.ID
8484 case ports.ForwardKey:
8585 target = c.ID
86+ case ports.SetModifiers:
87+ target = c.ID
8688 }
8789 if target != 0 && !s.lockInputTarget(target) {
8890 return
@@ -177,6 +179,8 @@ func (s *Server) applyInput(cmd ports.ClientCommand) {
177179 if s.seat.focused != c.ID {
178180 s.changeFocus(c.ID)
179181 }
182+ case ports.SetModifiers:
183+ s.setModifiers(c)
180184 case ports.ForwardKey:
181185 if !(s.security != nil && s.security.Snapshot().Protected) && s.grabKey(c) {
182186 return
@@ -219,6 +223,58 @@ func (s *Server) applyInput(cmd ports.ClientCommand) {
219223 }
220224 }
221225
226+// xkb modifier masks of the default keymaps: Shift, Control, Mod1 (Alt) and
227+// Mod4 (Super). The server does not parse the keymap for its modifier indices.
228+const (
229+ xkbShift = 1 << 0
230+ xkbControl = 1 << 2
231+ xkbMod1 = 1 << 3
232+ xkbMod4 = 1 << 6
233+)
234+
235+// depressedMask maps core modifier flags to xkb depressed masks.
236+func depressedMask(m ports.Mods) uint32 {
237+ var d uint32
238+ for _, f := range []struct {
239+ flag ports.Mods
240+ mask uint32
241+ }{{ports.ModShift, xkbShift}, {ports.ModCtrl, xkbControl}, {ports.ModAlt, xkbMod1}, {ports.ModSuper, xkbMod4}} {
242+ if m&f.flag != 0 {
243+ d |= f.mask
244+ }
245+ }
246+ return d
247+}
248+
249+// setModifiers overrides, then restores, the modifiers the focused window's
250+// keyboards (or the input method grab) see. The override replaces the
251+// depressed modifiers and keeps latched, locked and group; the seat's real
252+// modState is only replaced by a Restore, with the state core carries.
253+func (s *Server) setModifiers(c ports.SetModifiers) {
254+ _, keyboards := s.focusTarget(c.ID)
255+ if c.ID != s.seat.focused || len(keyboards) == 0 {
256+ s.log.Debug().Uint64("id", uint64(c.ID)).Msg("ignored set modifiers")
257+ return
258+ }
259+ s.useKeymap(nil)
260+ s.serial++
261+ if c.Restore {
262+ // Swallowed keys never reached the seat: core's snapshot is the
263+ // real state.
264+ s.seat.modState = c.State
265+ }
266+ m := s.seat.modState
267+ if !c.Restore {
268+ m.Depressed = depressedMask(c.Mods)
269+ }
270+ for _, k := range keyboards {
271+ k.SendModifiers(s.serial, m.Depressed, m.Latched, m.Locked, m.Group)
272+ }
273+ if g := s.grab(); g != nil {
274+ g.res.SendModifiers(s.serial, m.Depressed, m.Latched, m.Locked, m.Group)
275+ }
276+}
277+
222278 func (s *Server) windowKeyboards(w *window) []*wayland.Keyboard {
223279 if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() {
224280 return nil
@@ -83,6 +83,8 @@ func (s *Server) applyInput(cmd ports.ClientCommand) {
83 target = c.ID83 target = c.ID
84 case ports.ForwardKey:84 case ports.ForwardKey:
85 target = c.ID85 target = c.ID
86+ case ports.SetModifiers:
87+ target = c.ID
86 }88 }
87 if target != 0 && !s.lockInputTarget(target) {89 if target != 0 && !s.lockInputTarget(target) {
88 return90 return
@@ -177,6 +179,8 @@ func (s *Server) applyInput(cmd ports.ClientCommand) {
177 if s.seat.focused != c.ID {179 if s.seat.focused != c.ID {
178 s.changeFocus(c.ID)180 s.changeFocus(c.ID)
179 }181 }
182+ case ports.SetModifiers:
183+ s.setModifiers(c)
180 case ports.ForwardKey:184 case ports.ForwardKey:
181 if !(s.security != nil && s.security.Snapshot().Protected) && s.grabKey(c) {185 if !(s.security != nil && s.security.Snapshot().Protected) && s.grabKey(c) {
182 return186 return
@@ -219,6 +223,58 @@ func (s *Server) applyInput(cmd ports.ClientCommand) {
219 }223 }
220 }224 }
221 225
226+// xkb modifier masks of the default keymaps: Shift, Control, Mod1 (Alt) and
227+// Mod4 (Super). The server does not parse the keymap for its modifier indices.
228+const (
229+ xkbShift = 1 << 0
230+ xkbControl = 1 << 2
231+ xkbMod1 = 1 << 3
232+ xkbMod4 = 1 << 6
233+)
234+
235+// depressedMask maps core modifier flags to xkb depressed masks.
236+func depressedMask(m ports.Mods) uint32 {
237+ var d uint32
238+ for _, f := range []struct {
239+ flag ports.Mods
240+ mask uint32
241+ }{{ports.ModShift, xkbShift}, {ports.ModCtrl, xkbControl}, {ports.ModAlt, xkbMod1}, {ports.ModSuper, xkbMod4}} {
242+ if m&f.flag != 0 {
243+ d |= f.mask
244+ }
245+ }
246+ return d
247+}
248+
249+// setModifiers overrides, then restores, the modifiers the focused window's
250+// keyboards (or the input method grab) see. The override replaces the
251+// depressed modifiers and keeps latched, locked and group; the seat's real
252+// modState is only replaced by a Restore, with the state core carries.
253+func (s *Server) setModifiers(c ports.SetModifiers) {
254+ _, keyboards := s.focusTarget(c.ID)
255+ if c.ID != s.seat.focused || len(keyboards) == 0 {
256+ s.log.Debug().Uint64("id", uint64(c.ID)).Msg("ignored set modifiers")
257+ return
258+ }
259+ s.useKeymap(nil)
260+ s.serial++
261+ if c.Restore {
262+ // Swallowed keys never reached the seat: core's snapshot is the
263+ // real state.
264+ s.seat.modState = c.State
265+ }
266+ m := s.seat.modState
267+ if !c.Restore {
268+ m.Depressed = depressedMask(c.Mods)
269+ }
270+ for _, k := range keyboards {
271+ k.SendModifiers(s.serial, m.Depressed, m.Latched, m.Locked, m.Group)
272+ }
273+ if g := s.grab(); g != nil {
274+ g.res.SendModifiers(s.serial, m.Depressed, m.Latched, m.Locked, m.Group)
275+ }
276+}
277+
222 func (s *Server) windowKeyboards(w *window) []*wayland.Keyboard {278 func (s *Server) windowKeyboards(w *window) []*wayland.Keyboard {
223 if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() {279 if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() {
224 return nil280 return nil
M internal/adapters/wayland/server.go +2 -2
@@ -752,13 +752,13 @@ func (s *Server) apply(cmd ports.ClientCommand) {
752752 // Only input/focus commands require an owner envelope. Config relay
753753 // also produces SetKeymap and other epoch-independent commands.
754754 switch cmd.(type) {
755- case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey,
755+ case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers,
756756 ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
757757 return
758758 }
759759 }
760760 switch c := cmd.(type) {
761- case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey:
761+ case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers:
762762 s.applyInput(c)
763763 case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
764764 s.applyDrag(c)
@@ -752,13 +752,13 @@ func (s *Server) apply(cmd ports.ClientCommand) {
752 // Only input/focus commands require an owner envelope. Config relay752 // Only input/focus commands require an owner envelope. Config relay
753 // also produces SetKeymap and other epoch-independent commands.753 // also produces SetKeymap and other epoch-independent commands.
754 switch cmd.(type) {754 switch cmd.(type) {
755- case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey,755+ case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers,
756 ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:756 ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
757 return757 return
758 }758 }
759 }759 }
760 switch c := cmd.(type) {760 switch c := cmd.(type) {
761- case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey:761+ case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers:
762 s.applyInput(c)762 s.applyInput(c)
763 case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:763 case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel:
764 s.applyDrag(c)764 s.applyDrag(c)

Keyboard shortcuts

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

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

Branches 23

Tags 10

↑↓ moveenter openesc close