feat/hints a471fa4

seat.go RawHistoryFolder

1package wayland
2
3import (
4	"encoding/binary"
5	"fmt"
6	"time"
7
8	"github.com/bnema/go-wayland-bindings/server/inputtimestamps"
9	"github.com/bnema/go-wayland-bindings/server/wayland"
10	"github.com/bnema/neferwl/internal/ports"
11	"github.com/bnema/purego-libwayland/server"
12	"golang.org/x/sys/unix"
13)
14
15// seatState is the wl_seat state: who has keyboard and pointer focus, the
16// bound keyboards and pointers, the keymap they carry, and the last press.
17// It belongs to the display goroutine.
18type seatState struct {
19	focused      ports.WindowID
20	pointerFocus ports.WindowID
21	// pointerX, pointerY is the pointer as core placed it in pointerFocus;
22	// pointerSurface and surfacePoint turn it into surface coordinates.
23	pointerX, pointerY float64
24	// enters holds the serial of the enter event each wl_pointer received
25	// for pointerFocus; a pointer warp must name its pointer's serial.
26	enters map[*server.Resource]uint32
27	// gesture is the touchpad gesture sent to pointerFocus's client.
28	gesture     *activeGesture
29	wheelRest   [2]int32   // v120 not yet sent as axis_discrete, per axis
30	wheelHeld   [2]float64 // axis value held back with it for pre-v8 clients
31	pointers    map[server.Client][]*wayland.Pointer
32	keyboards   map[server.Client][]*wayland.Keyboard
33	modState    ports.ModState
34	heldKeys    map[uint32]bool
35	grabKeys    map[uint32]bool // pressed through the input method grab
36	keymapFD    int
37	keymapSize  uint32
38	keymapText  string           // the seat keymap, to compare virtual keymaps with
39	keymapOwner *virtualKeyboard // nil: keyboards carry the seat keymap
40	repeatRate  int
41	repeatDelay int
42	// press is the serial of the last button or key press, sent to
43	// pressClient: popup grabs must come from it.
44	press       uint32
45	pressClient server.Client
46	// grabSerial is the serial of the press that started the implicit
47	// grab (core marks it), grabButton its button: the only serial
48	// start_drag accepts (wlr_seat_validate_pointer_grab_serial). The
49	// grab's release clears it; core may consume that release (a window
50	// drag), and then the next grab press replaces it.
51	grabSerial uint32
52	grabButton uint32
53	// pressAt and focusAt date the last press and keyboard focus change,
54	// for xdg-activation tokens.
55	pressAt, focusAt time.Time
56	// keyStamps and pointerStamps hold the zwp_input_timestamps_v1 objects
57	// subscribed to each device; the lists are replaced, never mutated.
58	keyStamps, pointerStamps map[*server.Resource][]*inputtimestamps.ZwpInputTimestampsV1
59}
60
61// applyInput delivers a core input command to the focused client.
62func (s *Server) applyInput(cmd ports.ClientCommand) {
63	if s.security != nil && s.security.Snapshot().Protected {
64		var target ports.WindowID
65		switch c := cmd.(type) {
66		case ports.SetKeymap:
67			// Configuration updates do not authorize normal-client input.
68		case ports.PointerFocus:
69			target = c.ID
70		case ports.PointerMotionTo:
71			target = c.ID
72		case ports.PointerButtonTo:
73			target = c.ID
74		case ports.PointerAxisTo:
75			target = c.ID
76		case ports.GestureBeginTo:
77			target = c.ID
78		case ports.GestureUpdateTo:
79			target = c.ID
80		case ports.GestureEndTo:
81			target = c.ID
82		case ports.FocusWindow:
83			target = c.ID
84		case ports.ForwardKey:
85			target = c.ID
86		case ports.SetModifiers:
87			target = c.ID
88		}
89		if target != 0 && !s.lockInputTarget(target) {
90			return
91		}
92	}
93	switch c := cmd.(type) {
94	case ports.PointerFocus:
95		s.changePointerFocus(c.ID, c.X, c.Y)
96	case ports.PointerMotionTo:
97		if l := s.lockSurfaces[c.ID]; l != nil && s.lockInputTarget(c.ID) && c.ID == s.seat.pointerFocus {
98			s.seat.pointerX, s.seat.pointerY = c.X, c.Y
99			for _, p := range s.clientPointers(l.resource.Client()) {
100				s.stampPointer(p, c.Time)
101				p.SendMotion(wireMsec(c.Time), server.FixedFromFloat(c.X), server.FixedFromFloat(c.Y))
102				pointerFrame(p)
103			}
104			return
105		}
106		if l := s.layers[c.ID]; l != nil && c.ID == s.seat.pointerFocus {
107			s.seat.pointerX, s.seat.pointerY = c.X, c.Y
108			for _, p := range s.layerPointers(l) {
109				s.stampPointer(p, c.Time)
110				p.SendMotion(wireMsec(c.Time), server.FixedFromFloat(c.X), server.FixedFromFloat(c.Y))
111				pointerFrame(p)
112			}
113			return
114		}
115		if w := s.windows[c.ID]; w != nil && c.ID == s.seat.pointerFocus {
116			if s.relativeMotion(w, c) && s.locked(w) {
117				for _, p := range s.windowPointers(w) {
118					pointerFrame(p)
119				}
120			}
121			if s.locked(w) {
122				// Only relative motion reaches a locked pointer.
123				return
124			}
125			x, y := w.surfacePoint(c.X, c.Y)
126			s.seat.pointerX, s.seat.pointerY = c.X, c.Y
127			// A constraint waits for the pointer to enter its region.
128			if s.constraint == nil {
129				s.updateConstraint()
130			}
131			for _, p := range s.windowPointers(w) {
132				s.stampPointer(p, c.Time)
133				p.SendMotion(wireMsec(c.Time), server.FixedFromFloat(x), server.FixedFromFloat(y))
134				pointerFrame(p)
135			}
136		}
137	case ports.PointerButtonTo:
138		// Releases may target the implicit-grab window after focus has moved.
139		if c.Pressed && c.Grab || !c.Pressed && c.Button == s.seat.grabButton {
140			// A new grab, or the end of this one even when its window is
141			// gone.
142			s.seat.grabSerial, s.seat.grabButton = 0, 0
143		}
144		if client, pointers, ok := s.pointerTarget(c.ID); ok {
145			s.serial++
146			state := uint32(0)
147			if c.Pressed {
148				state = 1
149				s.seat.press, s.seat.pressClient, s.seat.pressAt = s.serial, client, time.Now()
150				if c.Grab {
151					s.seat.grabSerial, s.seat.grabButton = s.serial, c.Button
152				}
153			}
154			for _, p := range pointers {
155				s.stampPointer(p, c.Time)
156				p.SendButton(s.serial, wireMsec(c.Time), c.Button, state)
157				pointerFrame(p)
158			}
159		}
160	case ports.PointerAxisTo:
161		// Scroll applies to the entered surface only.
162		if c.ID != s.seat.pointerFocus {
163			return
164		}
165		steps, values := s.seat.wheelSteps(c.Axis)
166		_, pointers, _ := s.pointerTarget(c.ID)
167		for _, p := range pointers {
168			s.sendAxis(p, c.Axis, steps, values)
169		}
170	case ports.GestureBeginTo:
171		s.beginGesture(c)
172	case ports.GestureUpdateTo:
173		s.updateGesture(c)
174	case ports.GestureEndTo:
175		s.endGesture(c)
176	case ports.SetKeymap:
177		s.setKeymap(c)
178	case ports.FocusWindow:
179		if s.seat.focused != c.ID {
180			s.changeFocus(c.ID)
181		}
182	case ports.SetModifiers:
183		s.setModifiers(c)
184	case ports.ForwardKey:
185		if !(s.security != nil && s.security.Snapshot().Protected) && s.grabKey(c) {
186			return
187		}
188		_, keyboards := s.focusTarget(c.ID)
189		if c.ID != s.seat.focused || len(keyboards) == 0 {
190			s.log.Debug().Uint64("id", uint64(c.ID)).Msg("ignored forward key")
191			return
192		}
193		s.useKeymap(nil)
194		if s.seat.heldKeys == nil {
195			s.seat.heldKeys = make(map[uint32]bool)
196		}
197		if c.Key.Pressed {
198			s.seat.heldKeys[c.Key.Keycode] = true
199		} else {
200			delete(s.seat.heldKeys, c.Key.Keycode)
201		}
202		s.serial++
203		state := uint32(0)
204		if c.Key.Pressed {
205			state = 1
206		}
207		if c.Key.Pressed {
208			if surf, _ := s.focusTarget(c.ID); surf != nil {
209				s.seat.press, s.seat.pressClient, s.seat.pressAt = s.serial, surf.Client(), time.Now()
210			}
211		}
212		for _, k := range keyboards {
213			s.stampKeyboard(k, c.Key.Time)
214			k.SendKey(s.serial, wireMsec(c.Key.Time), c.Key.Keycode, state)
215		}
216		if c.Key.State != s.seat.modState {
217			s.seat.modState = c.Key.State
218			s.serial++
219			for _, k := range keyboards {
220				s.sendModifiers(k)
221			}
222		}
223	}
224}
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.
228const (
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.
236func 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.
253func (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
278func (s *Server) windowKeyboards(w *window) []*wayland.Keyboard {
279	if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() {
280		return nil
281	}
282	return s.clientKeyboards(w.xdg.resource.Client())
283}
284
285// clientKeyboards returns c's wl_keyboards without allocating. The list holds
286// only live resources (OnDestroy removes a keyboard when it dies) and is
287// replaced, never mutated, so callers may iterate it while sending.
288//
289// This relies on purego-libwayland: destroyed() sets gone and runs OnDestroy
290// synchronously on the display goroutine, and PostEvent never re-enters Go, so
291// a list ranged over while sending is never changed under the caller.
292func (s *Server) clientKeyboards(c server.Client) []*wayland.Keyboard {
293	return s.seat.keyboards[c]
294}
295
296// focusTarget resolves a window or layer ID to its wl_surface and keyboards.
297func (s *Server) focusTarget(id ports.WindowID) (*wayland.Surface, []*wayland.Keyboard) {
298	if l := s.lockSurfaces[id]; l != nil && !l.closed && l.surface.wl.Alive() {
299		// Outgoing leaves also resolve a role whose OnDestroy resource is
300		// already dead. Admission is enforced before entering or delivering.
301		return l.surface.wl, s.clientKeyboards(l.surface.wl.Client())
302	}
303	if w := s.windows[id]; w != nil && w.mapped {
304		if surf := w.xdg.surfaceResource(); surf != nil {
305			return surf, s.windowKeyboards(w)
306		}
307	}
308	if l := s.layers[id]; l != nil && l.mapped && l.resource.Resource.Alive() {
309		if surf := l.surfaceResource(); surf != nil {
310			return surf, s.clientKeyboards(l.resource.Client())
311		}
312	}
313	return nil, nil
314}
315
316// pointerTarget resolves a mapped window or layer ID to its client and
317// live pointers.
318func (s *Server) pointerTarget(id ports.WindowID) (server.Client, []*wayland.Pointer, bool) {
319	if l := s.lockSurfaces[id]; l != nil && s.lockInputTarget(id) {
320		return l.resource.Client(), s.clientPointers(l.resource.Client()), true
321	}
322	if w := s.windows[id]; w != nil && w.mapped && w.xdg.resource.Resource.Alive() {
323		return w.xdg.resource.Client(), s.windowPointers(w), true
324	}
325	if l := s.layers[id]; l != nil && l.mapped && l.resource.Resource.Alive() {
326		return l.resource.Client(), s.layerPointers(l), true
327	}
328	return server.Client{}, nil, false
329}
330
331// pointerSurface is the wl_surface of a mapped window or layer and the
332// point in its surface coordinates.
333func (s *Server) pointerSurface(id ports.WindowID, x, y float64) (*wayland.Surface, []*wayland.Pointer, float64, float64) {
334	if l := s.lockSurfaces[id]; l != nil && !l.closed && l.surface.wl.Alive() {
335		return l.surface.wl, s.clientPointers(l.surface.wl.Client()), x, y
336	}
337	if w := s.windows[id]; w != nil {
338		if surf := w.xdg.surfaceResource(); surf != nil && surf.Resource.Alive() {
339			x, y := w.surfacePoint(x, y)
340			return surf, s.windowPointers(w), x, y
341		}
342	}
343	if l := s.layers[id]; l != nil && l.mapped {
344		if surf := l.surfaceResource(); surf != nil {
345			return surf, s.layerPointers(l), x, y
346		}
347	}
348	return nil, nil, 0, 0
349}
350
351// keymapFile writes a sealed memfd holding the NUL-terminated keymap.
352func keymapFile(keymap string) (int, uint32, error) {
353	if uint64(len(keymap))+1 > uint64(^uint32(0)) {
354		return -1, 0, fmt.Errorf("keymap too large")
355	}
356	fd, err := unix.MemfdCreate("neferwl-keymap", unix.MFD_CLOEXEC|unix.MFD_ALLOW_SEALING)
357	if err != nil {
358		return -1, 0, err
359	}
360	n, err := unix.Write(fd, append([]byte(keymap), 0))
361	if err == nil && n != len(keymap)+1 {
362		err = fmt.Errorf("short keymap write")
363	}
364	if err == nil {
365		_, err = unix.FcntlInt(uintptr(fd), unix.F_ADD_SEALS, unix.F_SEAL_SHRINK|unix.F_SEAL_GROW|unix.F_SEAL_WRITE|unix.F_SEAL_SEAL)
366	}
367	if err != nil {
368		unix.Close(fd)
369		return -1, 0, err
370	}
371	return fd, uint32(n), nil
372}
373
374// setKeymap sends repeat info, and a new keymap when one is given, to every keyboard.
375// A new keymap resets held keys and modifiers: the focused client gets leave, then
376// enter with no keys.
377func (s *Server) setKeymap(c ports.SetKeymap) {
378	s.seat.repeatRate, s.seat.repeatDelay = c.RepeatRate, c.RepeatDelay
379	if c.Keymap == "" {
380		for _, list := range s.seat.keyboards {
381			for _, k := range list {
382				if k.Version() >= 4 {
383					k.SendRepeatInfo(int32(s.seat.repeatRate), int32(s.seat.repeatDelay))
384				}
385			}
386		}
387		s.sendGrabKeymap(false)
388		s.log.Info().Int("rate", s.seat.repeatRate).Int("delay", s.seat.repeatDelay).Msg("repeat updated")
389		return
390	}
391	fd, size, err := keymapFile(c.Keymap)
392	if err != nil {
393		s.log.Warn().Err(err).Msg("keymap update failed")
394		return
395	}
396	if s.seat.keymapFD >= 0 {
397		unix.Close(s.seat.keymapFD)
398	}
399	s.seat.keymapFD, s.seat.keymapSize, s.seat.keymapText = fd, size, c.Keymap
400	s.seat.keymapOwner = nil
401	focused := s.seat.focused
402	s.changeFocus(0)
403	s.seat.heldKeys, s.seat.grabKeys = nil, nil
404	s.seat.modState = ports.ModState{}
405	for _, list := range s.seat.keyboards {
406		for _, k := range list {
407			k.SendKeymap(uint32(wayland.KeyboardKeymapFormatXkbV1), s.seat.keymapFD, s.seat.keymapSize)
408			if k.Version() >= 4 {
409				k.SendRepeatInfo(int32(s.seat.repeatRate), int32(s.seat.repeatDelay))
410			}
411		}
412	}
413	s.sendGrabKeymap(true)
414	s.changeFocus(focused)
415	s.log.Info().Uint32("size", size).Int("rate", s.seat.repeatRate).Int("delay", s.seat.repeatDelay).Msg("keymap updated")
416}
417
418func (s *Server) sendModifiers(k *wayland.Keyboard) {
419	m := s.seat.modState
420	k.SendModifiers(s.serial, m.Depressed, m.Latched, m.Locked, m.Group)
421}
422
423func (s *Server) changeFocus(id ports.WindowID) {
424	if s.security != nil && s.security.Snapshot().Protected && id != 0 && !s.lockInputTarget(id) {
425		return
426	}
427	// Enter carries seat held keys and modifiers: they need the seat keymap.
428	s.useKeymap(nil)
429	old := s.focusClient()
430	defer func() {
431		// Tokens of a client losing the focus die with it.
432		if now := s.focusClient(); now != old {
433			s.dropTokens(old)
434			s.seat.focusAt = time.Now()
435		}
436	}()
437	if surf, keyboards := s.focusTarget(s.seat.focused); surf != nil {
438		for _, k := range keyboards {
439			s.serial++
440			k.SendLeave(s.serial, surf)
441		}
442	}
443	s.seat.focused = 0
444	if surf, keyboards := s.focusTarget(id); surf != nil {
445		s.seat.focused = id
446		s.focusSelections()
447		for _, k := range keyboards {
448			s.serial++
449			var keys []byte
450			for code := range s.seat.heldKeys {
451				keys = binary.LittleEndian.AppendUint32(keys, code)
452			}
453			k.SendEnter(s.serial, surf, keys)
454			s.sendModifiers(k)
455		}
456	}
457	s.textInputFocus()
458	s.log.Debug().Uint64("id", uint64(s.seat.focused)).Msg("keyboard focus")
459	s.updateConstraint()
460}
461
462// wheelSteps adds wheel v120 to the rest per axis and returns the whole
463// detents for axis_discrete (seat before v8). High-resolution wheels send
464// fractions of 120; a direction change drops the rest, as wlroots does.
465// wheelHeld keeps the axis value of the frames without a step; values is
466// what a pre-v8 client gets with a step: the held value plus this frame's.
467func (st *seatState) wheelSteps(a ports.PointerAxis) (steps [2]int32, values [2]float64) {
468	if a.Source != ports.AxisWheel {
469		return steps, values
470	}
471	for i, ax := range [2]ports.ScrollAxis{a.Vertical, a.Horizontal} {
472		if !ax.Set || ax.V120 == 0 {
473			continue
474		}
475		if (st.wheelRest[i] < 0) != (ax.V120 < 0) {
476			st.wheelRest[i], st.wheelHeld[i] = 0, 0
477		}
478		st.wheelRest[i] += ax.V120
479		st.wheelHeld[i] += ax.Value
480		steps[i] = st.wheelRest[i] / 120
481		st.wheelRest[i] -= steps[i] * 120
482		if steps[i] != 0 {
483			values[i], st.wheelHeld[i] = st.wheelHeld[i], 0
484		}
485	}
486	return steps, values
487}
488
489// sendAxis sends one scroll frame, each event gated by the pointer version.
490// steps are whole wheel detents for axis_discrete. A pre-v8 client gets a
491// wheel axis only with a step, carrying values (all the value since the
492// last step), so it never counts smooth and discrete scroll.
493func (s *Server) sendAxis(p *wayland.Pointer, a ports.PointerAxis, steps [2]int32, values [2]float64) {
494	v := p.Version()
495	axes := [2]ports.ScrollAxis{a.Vertical, a.Horizontal}
496	for i := range axes {
497		if v < 8 && a.Source == ports.AxisWheel && axes[i].V120 != 0 && steps[i] == 0 {
498			axes[i].Set = false
499		}
500	}
501	if !axes[0].Set && !axes[1].Set {
502		return
503	}
504	if v >= 5 {
505		p.SendAxisSource(uint32(a.Source))
506	}
507	for axis, ax := range axes {
508		if !ax.Set {
509			continue
510		}
511		if ax.Stop {
512			if v >= 5 {
513				s.stampPointer(p, a.Time)
514				p.SendAxisStop(wireMsec(a.Time), uint32(axis))
515			}
516			continue
517		}
518		value := ax.Value
519		if a.Source == ports.AxisWheel && ax.V120 != 0 {
520			switch {
521			case v >= 8:
522				p.SendAxisValue120(uint32(axis), ax.V120)
523			default:
524				value = values[axis]
525				if v >= 5 {
526					p.SendAxisDiscrete(uint32(axis), steps[axis])
527				}
528			}
529		}
530		s.stampPointer(p, a.Time)
531		p.SendAxis(wireMsec(a.Time), uint32(axis), server.FixedFromFloat(value))
532	}
533	pointerFrame(p)
534}
535
536// wireMsec is a device timestamp as the 32-bit millisecond time of wl_pointer
537// and wl_keyboard events (wrapping, like the protocol).
538func wireMsec(t time.Duration) uint32 { return uint32(t / time.Millisecond) }
539
540func pointerFrame(p *wayland.Pointer) {
541	if p.Version() >= 5 {
542		p.SendFrame()
543	}
544}
545func (s *Server) windowPointers(w *window) []*wayland.Pointer {
546	if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() {
547		return nil
548	}
549	return s.clientPointers(w.xdg.resource.Client())
550}
551
552func (s *Server) layerPointers(l *layerSurface) []*wayland.Pointer {
553	if l == nil || !l.mapped || !l.resource.Resource.Alive() {
554		return nil
555	}
556	return s.clientPointers(l.resource.Client())
557}
558
559// clientPointers returns c's wl_pointers without allocating. The list holds
560// only live resources (OnDestroy removes a pointer when it dies) and is
561// replaced, never mutated, so callers may iterate it while sending. See
562// clientKeyboards for the libwayland invariant this relies on.
563func (s *Server) clientPointers(c server.Client) []*wayland.Pointer {
564	return s.seat.pointers[c]
565}
566func (s *Server) changePointerFocus(id ports.WindowID, x, y float64) {
567	if s.security != nil && s.security.Snapshot().Protected && id != 0 && !s.lockInputTarget(id) {
568		return
569	}
570	if id == s.seat.pointerFocus {
571		return
572	}
573	s.seat.wheelRest, s.seat.wheelHeld = [2]int32{}, [2]float64{}
574	// A gesture does not follow the pointer to another surface.
575	s.cancelGesture()
576	// The new client sets its own cursor on enter; until then, the arrow.
577	s.cursorSurface = nil
578	s.setCursor(ports.CursorChange{})
579	if surface, pointers, _, _ := s.pointerSurface(s.seat.pointerFocus, 0, 0); surface != nil {
580		for _, p := range pointers {
581			s.serial++
582			p.SendLeave(s.serial, surface)
583			pointerFrame(p)
584		}
585	}
586	s.seat.pointerFocus = 0
587	clear(s.seat.enters)
588	if surface, pointers, sx, sy := s.pointerSurface(id, x, y); surface != nil {
589		s.seat.pointerFocus, s.seat.pointerX, s.seat.pointerY = id, x, y
590		for _, p := range pointers {
591			s.serial++
592			s.seat.enters[p.Resource] = s.serial
593			p.SendEnter(s.serial, surface, server.FixedFromFloat(sx), server.FixedFromFloat(sy))
594			pointerFrame(p)
595		}
596	}
597	s.updateConstraint()
598}

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