v0.1.0 47aaa0f

input.go RawHistoryFolder

1//go:build linux
2
3package wayland
4
5import (
6	"fmt"
7	"os"
8	"time"
9
10	"github.com/bnema/nefergui/internal/keyboard"
11	"github.com/bnema/wlturbo/protocol/core"
12	"github.com/bnema/wlturbo/wl"
13)
14
15// InputReset tells the runtime to synthesize pointer leave, release all buttons,
16// keyboard focus-out and repeat cancellation before processing later inputs.
17const InputReset = "reset"
18
19// Input is the platform-to-UI port. wl_surface local fixed coordinates are
20// already logical pixels and must not be divided by buffer scale.
21type Input struct {
22	Kind                             string // motion, press, release, axis, leave, key, focus-in, focus-out, reset
23	X, Y, DX, DY                     float64
24	Button, Serial, Axis, AxisSource uint32
25	AxisStop                         bool
26	Enter                            bool   // motion from wl_pointer.enter; Serial is the enter serial
27	PointerGen                       uint64 // wl_pointer generation that sent Enter
28	Value120                         int32
29	Key                              keyboard.Key
30}
31
32// InputAdapter is owned by the session loop. Never call ApplyInput on the reader.
33// The session loop also owns the repeat timer (Tick and NextRepeat).
34type InputAdapter struct {
35	Keyboard *keyboard.Keyboard
36	serial   uint32
37	focused  bool
38}
39
40// resetHeld drops held keys, repeat and compose state after InputReset while
41// keeping keyboard focus, so later key events still apply without a new enter.
42func (a *InputAdapter) resetHeld() {
43	if a != nil && a.Keyboard != nil {
44		a.Keyboard.Focus(a.focused)
45	}
46}
47
48func NewInputAdapter(locale string) (*InputAdapter, error) {
49	k, err := keyboard.New(locale)
50	if err != nil {
51		return nil, err
52	}
53	k.Focus(false)
54	return &InputAdapter{Keyboard: k}, nil
55}
56func (a *InputAdapter) Close() {
57	if a != nil && a.Keyboard != nil {
58		a.Keyboard.Close()
59	}
60}
61func (a *InputAdapter) SelectionSerial() uint32 { return a.serial }
62func (a *InputAdapter) Tick(now time.Time) (Input, bool) {
63	key, ok := a.Keyboard.Tick(now)
64	return Input{Kind: "key", Key: key}, ok
65}
66func (a *InputAdapter) NextRepeat() (time.Time, bool) { return a.Keyboard.NextRepeat() }
67
68// InputAdapter returns the loop-owned interpreter; the reader never calls it.
69func (w *Window) InputAdapter() *InputAdapter { return w.inputAdapter }
70
71// ApplyInput consumes the raw event including its keymap FD, on every path.
72func (a *InputAdapter) ApplyInput(ev Event, now time.Time) ([]Input, error) {
73	switch ev.Kind {
74	case InputFrame:
75		return ev.Inputs, nil
76	case InputKeymap:
77		if ev.FD < 0 {
78			return nil, fmt.Errorf("missing keymap fd")
79		}
80		if ev.Format != 1 || ev.Size == 0 || ev.Size > 16<<20 {
81			CloseEventFD(ev)
82			return nil, fmt.Errorf("unsupported keymap format or size")
83		}
84		return nil, a.Keyboard.ReplaceFD(ev.FD, int(ev.Size))
85	case InputFocusIn:
86		a.Keyboard.Focus(true)
87		a.focused = true
88		a.serial = ev.Serial
89		return []Input{{Kind: "focus-in", Serial: ev.Serial}}, nil
90	case InputFocusOut:
91		a.Keyboard.Focus(false)
92		a.focused = false
93		a.serial = 0
94		return []Input{{Kind: "focus-out"}}, nil
95	case InputKey:
96		if ev.Pressed && a.Keyboard != nil {
97			a.serial = ev.Serial
98		}
99		key, err := a.Keyboard.Event(ev.Code, ev.Pressed, now)
100		if err != nil || key.Physical == 0 {
101			return nil, err
102		}
103		return []Input{{Kind: "key", Key: key, Serial: ev.Serial}}, nil
104	case InputModifiers:
105		return nil, a.Keyboard.Mask(ev.Mods[0], ev.Mods[1], ev.Mods[2], 0, 0, ev.Mods[3])
106	case InputRepeatInfo:
107		a.Keyboard.RepeatInfo(int(ev.Rate), time.Duration(ev.Delay)*time.Millisecond)
108		return nil, nil
109	}
110	return nil, fmt.Errorf("not an input event: %d", ev.Kind)
111}
112
113// CloseEventFD releases a keymap event not delivered to the loop, including FD 0.
114func CloseEventFD(ev Event) {
115	if ev.Kind == InputKeymap && ev.FD >= 0 {
116		_ = os.NewFile(uintptr(ev.FD), "keymap").Close()
117	}
118}
119
120// BindSeat binds the optional initial seat. The reader posts capability changes
121// and global removal; ApplySeat runs exclusively on the session loop.
122func (w *Window) BindSeat() error {
123	seat := core.NewSeat(w.Display.Context())
124	g, ok := w.Display.Registry().FindGlobal(core.SeatInterface)
125	if !ok {
126		w.Display.Context().Unregister(seat)
127		return nil
128	}
129	version, err := w.Display.Registry().BindNegotiated(core.SeatInterface, 9, seat)
130	if err != nil {
131		w.Display.Context().Unregister(seat)
132		return err
133	}
134	w.Seat, w.seatName, w.seatVersion = seat, g.Name, version
135	seat.OnCapabilities(func(c uint32) { w.post(Event{Kind: SeatCapabilities, Capabilities: c}) })
136	w.Display.Registry().AddGlobalRemoveHandler(seatRemoved{w: w, name: g.Name})
137	return nil
138}
139
140type seatRemoved struct {
141	w    *Window
142	name uint32
143}
144
145func (r seatRemoved) HandleRegistryGlobalRemove(e wl.RegistryGlobalRemoveEvent) {
146	if e.Name == r.name {
147		r.w.post(Event{Kind: SeatRemoved})
148	}
149}
150func (w *Window) ApplySeat(ev Event) error {
151	switch ev.Kind {
152	case SeatRemoved:
153		if w.WLKeyboard != nil {
154			w.post(Event{Kind: InputFocusOut})
155		}
156		return w.releaseSeat()
157	case SeatCapabilities:
158		if w.Seat == nil {
159			return nil
160		}
161		if ev.Capabilities&1 == 0 && w.Pointer != nil {
162			w.dropCursorDevice()
163			w.releaseDevice(w.Pointer)
164			w.Pointer = nil
165		}
166		if ev.Capabilities&2 == 0 && w.WLKeyboard != nil {
167			w.releaseDevice(w.WLKeyboard)
168			w.WLKeyboard = nil
169			w.post(Event{Kind: InputFocusOut})
170		}
171		if ev.Capabilities&1 != 0 && w.Pointer == nil {
172			p, err := w.Seat.GetPointer()
173			if err != nil {
174				return err
175			}
176			w.Pointer = p
177			w.watchPointer(p)
178		}
179		if ev.Capabilities&2 != 0 && w.WLKeyboard == nil {
180			k, err := w.Seat.GetKeyboard()
181			if err != nil {
182				return err
183			}
184			w.WLKeyboard = k
185			w.watchKeyboard(k)
186		}
187		return nil
188	}
189	return fmt.Errorf("not a seat event: %d", ev.Kind)
190}
191
192// releaseDevice destroys a wl_pointer or wl_keyboard. Their release request
193// exists since seat version 3; older seats only forget the proxy locally.
194func (w *Window) releaseDevice(p interface {
195	wl.Proxy
196	Release() error
197}) {
198	if w.seatVersion >= 3 {
199		_ = p.Release()
200	} else if c := p.Context(); c != nil {
201		c.Unregister(p)
202	}
203}
204func (w *Window) releaseSeat() error {
205	if w.Pointer != nil {
206		w.dropCursorDevice()
207		w.releaseDevice(w.Pointer)
208		w.Pointer = nil
209	}
210	if w.WLKeyboard != nil {
211		w.releaseDevice(w.WLKeyboard)
212		w.WLKeyboard = nil
213	}
214	if w.Seat != nil {
215		if w.seatVersion >= 5 {
216			_ = w.Seat.Release()
217		} else {
218			w.Display.Context().Unregister(w.Seat)
219		}
220		w.Seat = nil
221	}
222	return nil
223}
224func (w *Window) watchPointer(p *core.Pointer) {
225	var frame []Input // only accessed by the reader
226	var x, y float64
227	var inside bool
228	var source uint32
229	// The generation tells a late enter from a released pointer apart from
230	// the current one; object IDs can be reused.
231	w.pointerGen++
232	surfaceID, version, gen := w.Surface.ID(), w.seatVersion, w.pointerGen
233	flush := func() {
234		if len(frame) > 0 {
235			w.post(Event{Kind: InputFrame, Inputs: frame})
236			frame = nil
237		}
238		source = 0
239	}
240	add := func(i Input) {
241		i.AxisSource = source
242		frame = append(frame, i)
243		if version < 5 {
244			flush()
245		}
246	}
247	p.OnEnter(func(serial uint32, id uint32, px, py wl.Fixed) {
248		if id != surfaceID {
249			return
250		}
251		inside = true
252		x, y = px.Float64(), py.Float64()
253		add(Input{Kind: "motion", X: x, Y: y, Enter: true, Serial: serial, PointerGen: gen})
254	})
255	p.OnLeave(func(_ uint32, id uint32) {
256		if id != surfaceID {
257			return
258		}
259		inside = false
260		add(Input{Kind: "leave"})
261	})
262	p.OnMotion(func(_ uint32, px, py wl.Fixed) {
263		if inside {
264			x, y = px.Float64(), py.Float64()
265			add(Input{Kind: "motion", X: x, Y: y})
266		}
267	})
268	p.OnButton(func(serial, _, button, state uint32) {
269		if !inside && state == 1 {
270			return
271		}
272		kind := "release"
273		if state == 1 {
274			kind = "press"
275		}
276		add(Input{Kind: kind, X: x, Y: y, Button: button, Serial: serial})
277	})
278	p.OnAxisSource(func(s uint32) { source = s })
279	p.OnAxis(func(_, axis uint32, v wl.Fixed) {
280		if inside {
281			i := Input{Kind: "axis", X: x, Y: y, Axis: axis}
282			if axis == 0 {
283				i.DY = v.Float64()
284			} else {
285				i.DX = v.Float64()
286			}
287			add(i)
288		}
289	})
290	p.OnAxisValue120(func(axis uint32, v int32) {
291		if inside {
292			add(Input{Kind: "axis", X: x, Y: y, Axis: axis, Value120: v})
293		}
294	})
295	p.OnAxisStop(func(_, axis uint32) {
296		if inside {
297			add(Input{Kind: "axis", X: x, Y: y, Axis: axis, AxisStop: true})
298		}
299	})
300	p.OnFrame(flush)
301}
302func (w *Window) watchKeyboard(k *core.Keyboard) {
303	surfaceID := w.Surface.ID()
304	k.OnKeymap(func(format uint32, fd *wl.OwnedFD, size uint32) {
305		n, err := fd.Take()
306		if err != nil {
307			w.post(Event{Kind: TransportError, Err: err})
308			return
309		}
310		w.postFD(Event{Kind: InputKeymap, FD: n, Format: format, Size: size})
311	})
312	k.OnEnter(func(serial, id uint32, _ []byte) {
313		if id == surfaceID {
314			w.post(Event{Kind: InputFocusIn, Serial: serial})
315		}
316	})
317	k.OnLeave(func(_, id uint32) {
318		if id == surfaceID {
319			w.post(Event{Kind: InputFocusOut})
320		}
321	})
322	k.OnKey(func(serial, _, key, state uint32) {
323		w.post(Event{Kind: InputKey, Serial: serial, Code: key, Pressed: state == 1})
324	})
325	k.OnModifiers(func(_, depressed, latched, locked, group uint32) {
326		w.post(Event{Kind: InputModifiers, Mods: [4]uint32{depressed, latched, locked, group}})
327	})
328	k.OnRepeatInfo(func(rate, delay int32) { w.post(Event{Kind: InputRepeatInfo, Rate: rate, Delay: delay}) })
329}
330
331// InputEvent is a loop-owned input value. Runtime integration drains these later.
332type InputEvent = Input
333
334// DrainInput appends pending input in order to dst and clears the queue.
335// The queue holds at most 4096 events. Motion is evicted first, compatible
336// adjacent axes are merged; if neither is possible it emits InputReset and
337// starts a new queue. InputOverflow counts discarded events. Call on the Apply loop.
338func (w *Window) DrainInput(dst []InputEvent) []InputEvent {
339	dst = append(dst, w.inputQueue...)
340	clear(w.inputQueue)
341	w.inputQueue = w.inputQueue[:0]
342	return dst
343}
344
345func mergeAxis(dst *Input, src Input) bool {
346	if dst.Kind != "axis" || src.Kind != "axis" || dst.Axis != src.Axis ||
347		dst.AxisSource != src.AxisSource || dst.AxisStop || src.AxisStop {
348		return false
349	}
350	dst.DX += src.DX
351	dst.DY += src.DY
352	dst.Value120 += src.Value120
353	dst.X, dst.Y = src.X, src.Y
354	return true
355}
356
357func (w *Window) queueInput(inputs []Input) {
358	for _, input := range inputs {
359		if len(w.inputQueue) == 4096 {
360			index := -1
361			for i, v := range w.inputQueue {
362				// An enter motion carries the serial cursor shapes need.
363				if v.Kind == "motion" && !v.Enter {
364					index = i
365					break
366				}
367			}
368			if index < 0 {
369				// Adjacent axes on the same axis/source can be combined without
370				// crossing a button or key event or losing scroll distance.
371				for i := 0; i+1 < len(w.inputQueue); i++ {
372					if mergeAxis(&w.inputQueue[i], w.inputQueue[i+1]) {
373						index = i + 1
374						break
375					}
376				}
377			}
378			if index >= 0 {
379				if w.inputQueue[index].Kind == "motion" {
380					w.InputOverflow++
381				}
382				copy(w.inputQueue[index:], w.inputQueue[index+1:])
383				w.inputQueue = w.inputQueue[:len(w.inputQueue)-1]
384			} else {
385				// A previous reset is still pending if the consumer has not
386				// drained yet; keep one marker across successive overflows.
387				dropped := len(w.inputQueue)
388				if w.inputQueue[0].Kind == InputReset {
389					dropped--
390				}
391				w.InputOverflow += uint64(dropped)
392				// Keep the latest discarded enter or leave: it still decides
393				// whether the pointer is inside, and an enter carries the serial
394				// cursor shapes need.
395				var focus *Input
396				for i := range w.inputQueue {
397					if v := w.inputQueue[i]; v.Enter || v.Kind == "leave" {
398						focus = &v
399					}
400				}
401				clear(w.inputQueue)
402				w.inputQueue = w.inputQueue[:0]
403				w.inputQueue = append(w.inputQueue, Input{Kind: InputReset})
404				if focus != nil {
405					w.inputQueue = append(w.inputQueue, *focus)
406				}
407				// Discarded presses must not keep repeating on the loop.
408				w.inputAdapter.resetHeld()
409			}
410		}
411		w.inputQueue = append(w.inputQueue, input)
412	}
413}

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 1

Tags 12

↑↓ moveenter openesc close