v0.3.0 da0887c

host.go RawHistoryFolder

1package popup
2
3import (
4	"errors"
5	"fmt"
6	"log/slog"
7	"slices"
8	"strconv"
9	"time"
10
11	"github.com/bnema/neferclient"
12	"github.com/bnema/nefergui"
13
14	"github.com/bnema/neferbar/internal/module"
15)
16
17// FDBase is added to the id of a popup renderer's buffers when their release
18// descriptors are watched: the bar's own slots use small ids, and
19// Conn.WatchFD reports the same id for both through Handler.FDReady.
20const FDBase = 1 << 48
21
22// Kind is what a popup shows.
23type Kind uint8
24
25const (
26	KindTooltip Kind = iota + 1
27	KindMenu
28)
29
30// guiMods are the modifier bits nefergui knows; neferclient uses the same order.
31const guiMods = neferclient.ModShift | neferclient.ModCtrl | neferclient.ModAlt | neferclient.ModSuper |
32	neferclient.ModCapsLock | neferclient.ModNumLock
33
34// Request asks the host to open a popup.
35type Request struct {
36	Kind  Kind
37	Owner *module.Module
38	// Ctrl is the control line: its title and body for a tooltip, its items
39	// and token for a menu.
40	Ctrl module.Control
41	// Anchor is the part of Parent the popup attaches to, in logical pixels.
42	Anchor    neferclient.Rect
43	Parent    *neferclient.Surface
44	BottomBar bool
45	// Feedback gives the GPU and the formats of the renderer.
46	Feedback *neferclient.Feedback
47	// Serial is the pointer serial of the press that opened a menu; the menu
48	// grabs the keyboard and the pointer with it.
49	Serial uint32
50}
51
52// watch is a release descriptor being watched.
53type watch struct {
54	fd     int
55	buffer uint64
56}
57
58// Host owns at most one open popup. Everything runs on the bar's loop
59// goroutine, like the Handler methods that feed it.
60type Host struct {
61	conn *neferclient.Conn
62	seat *neferclient.Seat
63	log  *slog.Logger
64
65	styles string // path of the stylesheet; "" disables popups
66
67	warm chan struct{} // closed when the warm-up renderer is gone; nil before Warm
68
69	surf *neferclient.Surface
70	sid  neferclient.SurfaceID
71	r    *nefergui.Renderer
72	out  nefergui.Output
73
74	render func(*nefergui.Output) (bool, error) // Render with the model's view
75	tip    *tooltipModel
76	menu   *menuModel
77
78	kind      Kind
79	tipCtl    module.Control // what the open tooltip shows
80	owner     *module.Module
81	ownerGen  uint64
82	anchor    neferclient.Rect
83	bottomBar bool
84
85	watched     []watch
86	damage      []neferclient.Rect
87	configured  bool
88	canPresent  bool
89	haveAcquire bool
90	inside      bool // the pointer is over the popup
91	cursor      neferclient.CursorShape
92}
93
94// NewHost makes a host for conn.
95func NewHost(conn *neferclient.Conn, log *slog.Logger) *Host {
96	return &Host{conn: conn, seat: conn.Seat(), log: log, cursor: neferclient.CursorDefault}
97}
98
99// SetStyles sets the stylesheet path (see WriteCSS). An empty path disables
100// popups. An open popup keeps the sheet it was built with.
101func (h *Host) SetStyles(path string) { h.styles = path }
102
103func formats(fb *neferclient.Feedback) []nefergui.Format {
104	out := make([]nefergui.Format, len(fb.Formats))
105	for i, f := range fb.Formats {
106		out[i] = nefergui.Format{FourCC: f.FourCC, Modifier: f.Modifier}
107	}
108	return out
109}
110
111// Warm loads the GPU libraries on another goroutine so the first popup opens
112// quickly: the first renderer of a process takes about half a second, later
113// ones a fraction of that. It does nothing when called again.
114func (h *Host) Warm(fb *neferclient.Feedback) {
115	if h.warm != nil || fb == nil {
116		return
117	}
118	h.warm = make(chan struct{})
119	cfg := nefergui.RendererConfig{MainDevice: fb.MainDevice, Formats: formats(fb)}
120	done := h.warm
121	go func() {
122		defer close(done)
123		r, err := nefergui.NewRenderer(cfg)
124		if err != nil {
125			h.log.Debug("popup warm-up failed", "err", err)
126			return
127		}
128		_ = r.Close()
129	}()
130}
131
132// WaitTimeout waits up to d for the warm-up renderer to be gone and reports
133// whether it is. The bar calls it before it tears down its own GPU state, and
134// goes on when the GPU libraries are stuck.
135func (h *Host) WaitTimeout(d time.Duration) bool {
136	if h == nil || h.warm == nil {
137		return true
138	}
139	t := time.NewTimer(d)
140	defer t.Stop()
141	select {
142	case <-h.warm:
143		return true
144	case <-t.C:
145		return false
146	}
147}
148
149// waitWarm decides whether Open of a popup of the given kind goes on while the
150// warm-up renderer may still be loading the GPU libraries. A tooltip does not
151// wait: it is skipped, since a tooltip that shows up half a second late is
152// worse than none (false). A menu was asked for with a click and waits (true).
153func (h *Host) waitWarm(kind Kind) (proceed bool) {
154	if h.warm == nil {
155		return true
156	}
157	select {
158	case <-h.warm:
159		return true
160	default:
161	}
162	if kind == KindTooltip {
163		return false
164	}
165	<-h.warm // two renderers must not start at once
166	return true
167}
168
169// sameTooltip reports whether two tooltip controls show the same thing:
170// showing the same one again is pointless.
171func sameTooltip(a, b module.Control) bool {
172	return a.Title == b.Title && a.Body == b.Body && a.Col == b.Col && a.Width == b.Width &&
173		slices.EqualFunc(a.Rows, b.Rows, slices.Equal[[]string])
174}
175
176// ShowsTooltip reports whether the open popup is a tooltip of owner that shows
177// exactly what c says.
178func (h *Host) ShowsTooltip(owner *module.Module, c module.Control) bool {
179	return h != nil && h.r != nil && h.showsTooltip(owner, c)
180}
181
182func (h *Host) showsTooltip(owner *module.Module, c module.Control) bool {
183	return h.kind == KindTooltip && h.owner == owner && sameTooltip(h.tipCtl, c)
184}
185
186// Active reports whether a popup is open.
187func (h *Host) Active() bool { return h != nil && h.r != nil }
188
189// Kind is the kind of the open popup, 0 when none.
190func (h *Host) Kind() Kind {
191	if h == nil || h.r == nil {
192		return 0
193	}
194	return h.kind
195}
196
197// Owner is the module the open popup belongs to, nil when none.
198func (h *Host) Owner() *module.Module {
199	if h == nil || h.r == nil {
200		return nil
201	}
202	return h.owner
203}
204
205// OwnerRestarted reports whether the script behind the popup started again
206// since the popup opened.
207func (h *Host) OwnerRestarted() bool {
208	return h.owner != nil && h.owner.Gen() != h.ownerGen
209}
210
211// Is reports whether id is the open popup's surface.
212func (h *Host) Is(id neferclient.SurfaceID) bool { return h != nil && h.r != nil && id == h.sid }
213
214// Wake is signaled when the popup renderer needs a Render from another
215// goroutine; nil when no popup is open.
216func (h *Host) Wake() <-chan struct{} {
217	if h == nil || h.r == nil {
218		return nil
219	}
220	return h.r.Wake()
221}
222
223// Pending reports a built frame waiting for the GPU: call Draw again soon.
224func (h *Host) Pending() bool { return h != nil && h.r != nil && h.r.Pending() }
225
226// measure sizes the model of the open popup.
227func (h *Host) measure(r *nefergui.Renderer) (w, ht int32, scroll bool, err error) {
228	var fw, fh float64
229	switch h.kind {
230	case KindTooltip:
231		fw, fh, err = r.Measure(h.tip, tooltipView, MaxTooltipWidth)
232	default:
233		h.menu.scroll = false
234		fw, fh, err = r.Measure(h.menu, menuView, MaxMenuWidth)
235		if err == nil && fh > MaxHeight {
236			h.menu.scroll, h.menu.scrollH = true, menuContentHeight
237			fw, fh, err = r.Measure(h.menu, menuView, MaxMenuWidth)
238		}
239	}
240	if err != nil {
241		return 0, 0, false, err
242	}
243	w, ht, scroll = Clamp(fw, fh)
244	return w, ht, scroll, nil
245}
246
247// Open shows a popup, replacing any open one. It can take a while the first
248// time (the GPU libraries load) and allocates freely: popups are rare.
249func (h *Host) Open(req Request) error {
250	h.Close()
251	switch {
252	case h.styles == "":
253		return errors.New("popup: no stylesheet")
254	case req.Parent == nil || req.Feedback == nil || req.Owner == nil:
255		return errors.New("popup: incomplete request")
256	case req.Kind != KindTooltip && req.Kind != KindMenu:
257		return fmt.Errorf("popup: unknown kind %d", req.Kind)
258	}
259	if !h.waitWarm(req.Kind) {
260		return nil // the GPU libraries are still loading: skip this tooltip
261	}
262	r, err := nefergui.NewRenderer(nefergui.RendererConfig{MainDevice: req.Feedback.MainDevice, Formats: formats(req.Feedback), Styles: h.styles})
263	if err != nil {
264		return fmt.Errorf("popup: %w", err)
265	}
266	h.r, h.kind = r, req.Kind
267	h.tipCtl = req.Ctrl
268	switch req.Kind {
269	case KindTooltip:
270		h.tip = newTooltip(req.Ctrl.Title, req.Ctrl.Body, req.Ctrl.Rows)
271		h.render = func(out *nefergui.Output) (bool, error) { return r.Render(out, h.tip, tooltipView) }
272	default:
273		h.menu = newMenu(req.Ctrl.Items, req.Ctrl.Click)
274		h.render = func(out *nefergui.Output) (bool, error) { return r.Render(out, h.menu, menuView) }
275	}
276	w, ht, _, err := h.measure(r)
277	if err != nil {
278		h.reset()
279		_ = r.Close()
280		return fmt.Errorf("popup: %w", err)
281	}
282	h.anchor, h.bottomBar = req.Anchor, req.BottomBar
283	h.owner, h.ownerGen = req.Owner, req.Owner.Gen()
284	grab := req.Kind == KindMenu
285	surf, err := h.conn.NewPopup(neferclient.PopupConfig{
286		Parent: req.Parent, Place: Placement(req.Anchor, req.BottomBar, w, ht), Grab: grab, Serial: req.Serial,
287	})
288	if err != nil {
289		h.reset()
290		_ = r.Close()
291		return fmt.Errorf("popup: %w", err)
292	}
293	h.surf, h.sid = surf, surf.ID()
294	if req.Kind == KindTooltip {
295		// A tooltip is never clicked: the pointer goes through it.
296		if err = surf.SetInputRegion([]neferclient.Rect{}); err != nil {
297			h.Close()
298			return fmt.Errorf("popup: %w", err)
299		}
300	}
301	return nil
302}
303
304// reset forgets everything about the open popup; the caller has closed its
305// renderer and surface.
306func (h *Host) reset() {
307	*h = Host{conn: h.conn, seat: h.seat, log: h.log, styles: h.styles, warm: h.warm, cursor: neferclient.CursorDefault}
308}
309
310// Close dismisses the open popup. A menu that was not used tells its script
311// ("menu-closed"). It does nothing when no popup is open.
312func (h *Host) Close() { h.end(true) }
313
314func (h *Host) end(notify bool) {
315	if h == nil || h.r == nil {
316		return
317	}
318	for _, w := range h.watched {
319		_ = h.conn.UnwatchFD(w.fd)
320	}
321	if h.surf != nil {
322		if err := h.surf.Close(); err != nil {
323			h.log.Warn("popup: cannot close the surface", "err", err)
324		}
325	}
326	_ = h.r.Close()
327	if notify && h.menu != nil && h.owner != nil && !h.OwnerRestarted() {
328		h.owner.Send(strconv.AppendUint([]byte("menu-closed "), uint64(h.menu.token), 10))
329	}
330	h.reset()
331}
332
333// fail logs err and closes the popup: a broken popup never takes the bar down.
334func (h *Host) fail(what string, err error) {
335	h.log.Warn("popup: "+what, "err", err)
336	h.Close()
337}
338
339// PopupDone is the compositor dismissing the popup (outside click, denied
340// grab, gone parent).
341func (h *Host) PopupDone() { h.Close() }
342
343// Configure follows the size the compositor gave the popup.
344func (h *Host) Configure() {
345	if h.r == nil {
346		return
347	}
348	if !h.configured { // later configures must not lift the frame-callback gate
349		h.canPresent = true
350	}
351	h.configured = true
352	h.resize()
353}
354
355// Scale follows the popup's preferred scale.
356func (h *Host) Scale() { h.resize() }
357
358func (h *Host) resize() {
359	if h.r == nil {
360		return
361	}
362	w, ht, scale := h.surf.Size()
363	h.r.Resize(int(w), int(ht), scale)
364}
365
366// Frame allows the next Present.
367func (h *Host) Frame() {
368	if h.r != nil {
369		h.canPresent = true
370	}
371}
372
373// FDReady hands a buffer back once the compositor released it; id is the
374// watched id, FDBase plus the renderer's buffer.
375func (h *Host) FDReady(id uint64) {
376	if h.r == nil || id < FDBase {
377		return
378	}
379	if err := h.r.Released(id - FDBase); err != nil {
380		h.fail("release buffer", err)
381	}
382}
383
384// Pointer feeds a pointer event over the popup to its renderer.
385func (h *Host) Pointer(ev *neferclient.PointerEvent) {
386	if h.r == nil {
387		return
388	}
389	in := nefergui.Input{X: ev.X, Y: ev.Y}
390	switch ev.Kind {
391	case neferclient.PointerEnter:
392		h.inside = true
393		in.Kind = nefergui.InputPointerMotion
394		h.applyCursor() // the shape is only valid after the enter
395	case neferclient.PointerMotion:
396		in.Kind = nefergui.InputPointerMotion
397	case neferclient.PointerLeave:
398		h.inside = false
399		in.Kind = nefergui.InputPointerLeave
400	case neferclient.PointerButton:
401		in.Kind, in.Button = nefergui.InputPointerRelease, ev.Button
402		if ev.Pressed {
403			in.Kind = nefergui.InputPointerPress
404		}
405	case neferclient.PointerAxis:
406		in.Kind, in.DX, in.DY = nefergui.InputPointerAxis, ev.DX, ev.DY
407	default:
408		return
409	}
410	h.r.Input(&in)
411}
412
413// Key feeds a key event to the popup. A menu maps its navigation keys first.
414func (h *Host) Key(ev *neferclient.KeyEvent) {
415	if h.r == nil {
416		return
417	}
418	if h.menu != nil {
419		switch act := mapKey(ev.Keysym, h.menu.inSubmenu()); act {
420		case keyNext, keyPrevious:
421			if ev.Pressed { // the repeats of a held arrow walk on
422				mods := nefergui.Modifiers(0)
423				if act == keyPrevious {
424					mods = nefergui.ModShift
425				}
426				h.r.Input(&nefergui.Input{Kind: nefergui.InputKey, Keysym: keysymTab, Pressed: true, Modifiers: mods})
427			}
428			return
429		case keyBack:
430			if ev.Pressed && !ev.Repeat && h.menu.pop() {
431				h.r.Invalidate()
432			}
433			return
434		case keyClose:
435			if ev.Pressed {
436				h.Close()
437			}
438			return
439		}
440	}
441	h.r.Input(&nefergui.Input{
442		Kind:      nefergui.InputKey,
443		Keysym:    ev.Keysym,
444		Text:      ev.Text, // valid only during the call, as for Input
445		Pressed:   ev.Pressed,
446		Repeat:    ev.Repeat,
447		Modifiers: nefergui.Modifiers(ev.Modifiers & guiMods),
448	})
449}
450
451// KeyboardFocus tells the renderer whether the popup has the keyboard.
452func (h *Host) KeyboardFocus(focused bool) {
453	if h.r == nil {
454		return
455	}
456	kind := nefergui.InputFocusOut
457	if focused {
458		kind = nefergui.InputFocusIn
459	}
460	h.r.Input(&nefergui.Input{Kind: kind})
461}
462
463// Draw renders and presents the popup when something changed, and carries out
464// what the user chose in a menu.
465func (h *Host) Draw() {
466	if h.r == nil {
467		return
468	}
469	if h.menu != nil && h.menu.changed {
470		h.levelChanged()
471	}
472	if h.r == nil || !h.configured || !h.canPresent || h.surf.Feedback() == nil {
473		return
474	}
475	ok, err := h.render(&h.out)
476	if err != nil {
477		h.fail("render", err)
478		return
479	}
480	if h.menu != nil && h.menu.chosen >= 0 {
481		// Chosen during the build: tell the script and go, without
482		// "menu-closed" (the choice ends the menu).
483		line := append([]byte("menu-activate "), strconv.FormatUint(uint64(h.menu.token), 10)...)
484		line = append(line, ' ')
485		line = strconv.AppendInt(line, int64(h.menu.chosen), 10)
486		if !h.owner.Send(line) {
487			h.log.Debug("popup: the choice was not delivered: the script's input queue is full or closed", "module", h.owner.Name)
488		}
489		h.end(false)
490		return
491	}
492	if ok {
493		if err = h.present(); err != nil {
494			h.fail("present", err)
495			return
496		}
497		h.applyCursor()
498	}
499	if h.menu != nil && h.menu.changed {
500		h.levelChanged()
501	}
502}
503
504// levelChanged resizes the popup for the level of the menu now shown.
505func (h *Host) levelChanged() {
506	h.menu.changed = false
507	w, ht, _, err := h.measure(h.r)
508	if err != nil {
509		h.fail("measure", err)
510		return
511	}
512	if err = h.surf.Reposition(Placement(h.anchor, h.bottomBar, w, ht)); err != nil {
513		// Without xdg_wm_base 3 the popup keeps its size: a level taller than
514		// it scrolls inside it.
515		h.log.Debug("popup: cannot resize the menu", "err", err)
516		_, cur, _ := h.surf.Size()
517		h.menu.fitTo(ht, cur)
518	}
519	h.r.Invalidate()
520}
521
522// present hands the frame in h.out to the compositor, as nefergui's demo does.
523func (h *Host) present() error {
524	out := &h.out
525	// A resize retired buffers: stop watching and destroy them first.
526	for _, rt := range out.Retired {
527		if err := h.conn.UnwatchFD(rt.ReleaseFD); err != nil {
528			return fmt.Errorf("unwatch retired buffer %d: %w", rt.Buffer, err)
529		}
530		for i, w := range h.watched {
531			if w.fd == rt.ReleaseFD {
532				h.watched = append(h.watched[:i], h.watched[i+1:]...)
533				break
534			}
535		}
536		if err := h.surf.DestroyBuffer(rt.Buffer); err != nil {
537			return fmt.Errorf("destroy retired buffer %d: %w", rt.Buffer, err)
538		}
539		// The release timeline of a buffer is imported under the buffer's id.
540		if err := h.surf.DestroyTimeline(rt.Buffer); err != nil {
541			return fmt.Errorf("destroy retired timeline %d: %w", rt.Buffer, err)
542		}
543	}
544	if out.NewBuffer {
545		if out.Buffer >= FDBase {
546			return fmt.Errorf("buffer id %d is out of range", out.Buffer)
547		}
548		buf := neferclient.Buffer{
549			Width: out.Width, Height: out.Height, FourCC: out.FourCC, Modifier: out.Modifier,
550			PlaneCount: out.PlaneCount,
551		}
552		for i, p := range out.Planes {
553			buf.Planes[i] = neferclient.Plane{FD: p.FD, Offset: p.Offset, Stride: p.Stride}
554		}
555		if err := h.surf.ImportBuffer(out.Buffer, &buf); err != nil {
556			return fmt.Errorf("import buffer: %w", err)
557		}
558		// The release eventfd is stable per buffer: watch it once.
559		if err := h.conn.WatchFD(out.ReleaseFD, FDBase|out.Buffer); err != nil {
560			return fmt.Errorf("watch release fd: %w", err)
561		}
562		h.watched = append(h.watched, watch{fd: out.ReleaseFD, buffer: out.Buffer})
563	}
564	if out.NewTimelines {
565		if !h.haveAcquire { // the acquire timeline is shared by every buffer
566			if err := h.surf.ImportTimeline(out.Acquire.ID, out.Acquire.FD); err != nil {
567				return fmt.Errorf("import acquire timeline: %w", err)
568			}
569			h.haveAcquire = true
570		}
571		if err := h.surf.ImportTimeline(out.Release.ID, out.Release.FD); err != nil {
572			return fmt.Errorf("import release timeline: %w", err)
573		}
574	}
575	h.damage = h.damage[:0]
576	for _, r := range out.Damage {
577		h.damage = append(h.damage, neferclient.Rect{X: r.X, Y: r.Y, Width: r.Width, Height: r.Height})
578	}
579	err := h.surf.Present(&neferclient.Present{
580		Buffer:          out.Buffer,
581		AcquireTimeline: out.Acquire.ID,
582		ReleaseTimeline: out.Release.ID,
583		AcquirePoint:    out.AcquirePoint,
584		ReleasePoint:    out.ReleasePoint,
585		Damage:          h.damage,
586		Opaque:          true, // XRGB8888: RendererConfig.Transparent is false
587	})
588	if err != nil {
589		return fmt.Errorf("present: %w", err)
590	}
591	h.canPresent = false // until Frame
592	return nil
593}
594
595// applyCursor shows the shape the hovered element asked for, while the
596// pointer is over the popup.
597func (h *Host) applyCursor() {
598	switch h.out.Cursor {
599	case nefergui.CursorPointer:
600		h.cursor = neferclient.CursorPointer
601	case nefergui.CursorText:
602		h.cursor = neferclient.CursorText
603	case nefergui.CursorNotAllowed:
604		h.cursor = neferclient.CursorNotAllowed
605	default:
606		h.cursor = neferclient.CursorDefault
607	}
608	if !h.inside {
609		return
610	}
611	if err := h.seat.SetCursor(h.cursor); err != nil {
612		h.log.Debug("popup: cannot set the cursor", "err", err)
613	}
614}

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 5

Tags 4

↑↓ moveenter openesc close