detached 1c416bd

fix(wayland): restore the real modifier state after hint clicks

commit
1c416bd20d4ba09107f03a250b5289c25c2ec335
author
brice · · 2026-10-19 15:32 UTC
parent
61fd749
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +58 -8
M internal/adapters/wayland/keyboard_test.go +49 -6
@@ -267,13 +267,28 @@ func TestSetModifiersOverrideAndRestore(t *testing.T) {
267267 set := make(chan struct{})
268268 s.display.Do(func() { s.seat.modState = ports.ModState{Locked: capsLocked}; id = s.seat.focused; close(set) })
269269 <-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
270274 wait := func() {
271275 t.Helper()
272- deadline := time.Now().Add(time.Second)
273- for len(commands) > 0 && time.Now().Before(deadline) {
274- time.Sleep(5 * time.Millisecond)
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)
275291 }
276- time.Sleep(20 * time.Millisecond)
277292 roundtrip(t, target)
278293 }
279294 proxy.depress, proxy.locked = nil, nil
@@ -287,14 +302,42 @@ func TestSetModifiersOverrideAndRestore(t *testing.T) {
287302 if !slices.Equal(proxy.depress, []uint32{xkbControl | xkbShift}) || !slices.Equal(proxy.locked, []uint32{capsLocked}) {
288303 t.Fatalf("override depressed %v locked %v", proxy.depress, proxy.locked)
289304 }
290- commands <- ports.SetModifiers{ID: id, Restore: true}
305+ done := make(chan ports.ModState, 1)
306+ commands <- ports.SetModifiers{ID: id, Restore: true, State: ports.ModState{Locked: capsLocked}}
291307 wait()
292308 if !slices.Equal(proxy.depress, []uint32{xkbControl | xkbShift, 0}) || !slices.Equal(proxy.locked, []uint32{capsLocked, capsLocked}) {
293309 t.Fatalf("restore depressed %v locked %v", proxy.depress, proxy.locked)
294310 }
295- done := make(chan ports.ModState, 1)
296311 s.display.Do(func() { done <- s.seat.modState })
297312 if m := <-done; m != (ports.ModState{Locked: capsLocked}) {
298313 t.Fatalf("seat state changed: %+v", m)
299314 }
300315 }
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+}
@@ -267,13 +267,28 @@ func TestSetModifiersOverrideAndRestore(t *testing.T) {
267 set := make(chan struct{})267 set := make(chan struct{})
268 s.display.Do(func() { s.seat.modState = ports.ModState{Locked: capsLocked}; id = s.seat.focused; close(set) })268 s.display.Do(func() { s.seat.modState = ports.ModState{Locked: capsLocked}; id = s.seat.focused; close(set) })
269 <-set269 <-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
270 wait := func() {274 wait := func() {
271 t.Helper()275 t.Helper()
272- deadline := time.Now().Add(time.Second)276+ pending = !pending
273- for len(commands) > 0 && time.Now().Before(deadline) {277+ commands <- ports.SlotsPending{Pending: pending}
274- time.Sleep(5 * time.Millisecond)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)
275 }291 }
276- time.Sleep(20 * time.Millisecond)
277 roundtrip(t, target)292 roundtrip(t, target)
278 }293 }
279 proxy.depress, proxy.locked = nil, nil294 proxy.depress, proxy.locked = nil, nil
@@ -287,14 +302,42 @@ func TestSetModifiersOverrideAndRestore(t *testing.T) {
287 if !slices.Equal(proxy.depress, []uint32{xkbControl | xkbShift}) || !slices.Equal(proxy.locked, []uint32{capsLocked}) {302 if !slices.Equal(proxy.depress, []uint32{xkbControl | xkbShift}) || !slices.Equal(proxy.locked, []uint32{capsLocked}) {
288 t.Fatalf("override depressed %v locked %v", proxy.depress, proxy.locked)303 t.Fatalf("override depressed %v locked %v", proxy.depress, proxy.locked)
289 }304 }
290- commands <- ports.SetModifiers{ID: id, Restore: true}305+ done := make(chan ports.ModState, 1)
306+ commands <- ports.SetModifiers{ID: id, Restore: true, State: ports.ModState{Locked: capsLocked}}
291 wait()307 wait()
292 if !slices.Equal(proxy.depress, []uint32{xkbControl | xkbShift, 0}) || !slices.Equal(proxy.locked, []uint32{capsLocked, capsLocked}) {308 if !slices.Equal(proxy.depress, []uint32{xkbControl | xkbShift, 0}) || !slices.Equal(proxy.locked, []uint32{capsLocked, capsLocked}) {
293 t.Fatalf("restore depressed %v locked %v", proxy.depress, proxy.locked)309 t.Fatalf("restore depressed %v locked %v", proxy.depress, proxy.locked)
294 }310 }
295- done := make(chan ports.ModState, 1)
296 s.display.Do(func() { done <- s.seat.modState })311 s.display.Do(func() { done <- s.seat.modState })
297 if m := <-done; m != (ports.ModState{Locked: capsLocked}) {312 if m := <-done; m != (ports.ModState{Locked: capsLocked}) {
298 t.Fatalf("seat state changed: %+v", m)313 t.Fatalf("seat state changed: %+v", m)
299 }314 }
300 }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 +6 -1
@@ -249,7 +249,7 @@ func depressedMask(m ports.Mods) uint32 {
249249 // setModifiers overrides, then restores, the modifiers the focused window's
250250 // keyboards (or the input method grab) see. The override replaces the
251251 // depressed modifiers and keeps latched, locked and group; the seat's real
252-// modState is never changed by it.
252+// modState is only replaced by a Restore, with the state core carries.
253253 func (s *Server) setModifiers(c ports.SetModifiers) {
254254 _, keyboards := s.focusTarget(c.ID)
255255 if c.ID != s.seat.focused || len(keyboards) == 0 {
@@ -258,6 +258,11 @@ func (s *Server) setModifiers(c ports.SetModifiers) {
258258 }
259259 s.useKeymap(nil)
260260 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+ }
261266 m := s.seat.modState
262267 if !c.Restore {
263268 m.Depressed = depressedMask(c.Mods)
@@ -249,7 +249,7 @@ func depressedMask(m ports.Mods) uint32 {
249 // setModifiers overrides, then restores, the modifiers the focused window's249 // setModifiers overrides, then restores, the modifiers the focused window's
250 // keyboards (or the input method grab) see. The override replaces the250 // keyboards (or the input method grab) see. The override replaces the
251 // depressed modifiers and keeps latched, locked and group; the seat's real251 // depressed modifiers and keeps latched, locked and group; the seat's real
252-// modState is never changed by it.252+// modState is only replaced by a Restore, with the state core carries.
253 func (s *Server) setModifiers(c ports.SetModifiers) {253 func (s *Server) setModifiers(c ports.SetModifiers) {
254 _, keyboards := s.focusTarget(c.ID)254 _, keyboards := s.focusTarget(c.ID)
255 if c.ID != s.seat.focused || len(keyboards) == 0 {255 if c.ID != s.seat.focused || len(keyboards) == 0 {
@@ -258,6 +258,11 @@ func (s *Server) setModifiers(c ports.SetModifiers) {
258 }258 }
259 s.useKeymap(nil)259 s.useKeymap(nil)
260 s.serial++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+ }
261 m := s.seat.modState266 m := s.seat.modState
262 if !c.Restore {267 if !c.Restore {
263 m.Depressed = depressedMask(c.Mods)268 m.Depressed = depressedMask(c.Mods)
M internal/ports/client_commands.go +3 -1
@@ -293,11 +293,13 @@ type SpawnRequest struct {
293293 // SetModifiers carries core → wayland a temporary modifier override for the
294294 // focused window's keyboards: Mods are sent as depressed modifiers (mapped
295295 // to the current keymap's masks), latched/locked/group kept from the real
296-// state. Mods 0 with Restore true sends the real modifier state again.
296+// state. Mods 0 with Restore true sends the real modifier state again: State
297+// is that state as core last saw it, which the seat adopts.
297298 type SetModifiers struct {
298299 ID WindowID
299300 Mods Mods
300301 Restore bool
302+ State ModState
301303 }
302304
303305 func (SetModifiers) clientCommand() {}
@@ -293,11 +293,13 @@ type SpawnRequest struct {
293 // SetModifiers carries core → wayland a temporary modifier override for the293 // SetModifiers carries core → wayland a temporary modifier override for the
294 // focused window's keyboards: Mods are sent as depressed modifiers (mapped294 // focused window's keyboards: Mods are sent as depressed modifiers (mapped
295 // to the current keymap's masks), latched/locked/group kept from the real295 // to the current keymap's masks), latched/locked/group kept from the real
296-// state. Mods 0 with Restore true sends the real modifier state again.296+// state. Mods 0 with Restore true sends the real modifier state again: State
297+// is that state as core last saw it, which the seat adopts.
297 type SetModifiers struct {298 type SetModifiers struct {
298 ID WindowID299 ID WindowID
299 Mods Mods300 Mods Mods
300 Restore bool301 Restore bool
302+ State ModState
301 }303 }
302 304
303 func (SetModifiers) clientCommand() {}305 func (SetModifiers) clientCommand() {}

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