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}