1package bar
2
3import (
4 "slices"
5 "time"
6
7 "github.com/bnema/neferbar/internal/layout"
8 "github.com/bnema/neferbar/internal/module"
9 "github.com/bnema/neferbar/internal/popup"
10)
11
12// popupPoll is how long the loop waits before it asks again for a popup frame
13// that is waiting for the GPU.
14const popupPoll = 2 * time.Millisecond
15
16// updatePopupStyle writes the stylesheet of the popups when the bar's colors,
17// font or size changed. Without a stylesheet no popup opens.
18func (b *Bar) updatePopupStyle() {
19 if b.pop == nil {
20 return
21 }
22 css := popup.CSS(popup.Style{
23 Font: b.cfg.Bar.Font, Size: b.cfg.Bar.Size * b.cfg.Bar.Scale,
24 FG: b.fg, BG: b.bg, Accent: b.pal[b.cfg.Bar.Accent&15],
25 })
26 if css == b.popCSS {
27 return
28 }
29 path, err := popup.WriteCSS(css)
30 if err != nil {
31 b.log.Warn("popups are off: cannot write their stylesheet", "err", err)
32 b.pop.SetStyles("")
33 b.popCSS = ""
34 return
35 }
36 b.popCSS = css
37 b.pop.SetStyles(path)
38}
39
40// closePopup dismisses the tooltip or menu being shown.
41func (b *Bar) closePopup() { b.pop.Close() }
42
43// closeTooltip dismisses the popup when it is a tooltip; a menu stays.
44func (b *Bar) closeTooltip() {
45 if b.pop.Kind() == popup.KindTooltip {
46 b.pop.Close()
47 }
48}
49
50// stepPopup runs after every wake-up of the loop: it starts the GPU warm-up,
51// carries out what the modules asked for, and draws the popup.
52func (b *Bar) stepPopup() {
53 if b.pop == nil {
54 return
55 }
56 b.popC = nil
57 if !b.warmed && b.fb != nil && b.popCSS != "" && b.anyInteractive() {
58 b.warmed = true
59 b.pop.Warm(b.fb)
60 }
61 for _, m := range b.mods {
62 b.drainControl(m)
63 }
64 if b.pop.OwnerRestarted() {
65 b.closePopup()
66 }
67 if !b.pop.Active() {
68 return
69 }
70 b.pop.Draw()
71 if b.pop.Pending() {
72 b.popC = time.After(popupPoll)
73 }
74}
75
76func (b *Bar) anyInteractive() bool {
77 for _, m := range b.mods {
78 if m.Interactive {
79 return true
80 }
81 }
82 return false
83}
84
85// drainControl takes the control lines module m printed since the last
86// wake-up and carries them out. A script that prints a tooltip on every
87// mouse move would otherwise open and close a popup (a renderer, a surface)
88// for each one: of the tooltip and close lines of one wake-up only the last
89// counts.
90func (b *Bar) drainControl(m *module.Module) {
91 raw, ok := m.TakeControl()
92 if !ok {
93 return
94 }
95 b.ctlBuf = b.ctlBuf[:0]
96 for ; ok; raw, ok = m.TakeControl() {
97 ctrl, err := module.ParseControl(raw)
98 if err != nil {
99 b.badControl(m, err)
100 continue
101 }
102 b.ctlBuf = append(b.ctlBuf, ctrl)
103 }
104 b.ctlBuf = coalesceControl(b.ctlBuf)
105 for i := range b.ctlBuf {
106 b.control(m, b.ctlBuf[i])
107 }
108 clear(b.ctlBuf) // keeps no parsed menu or table alive until the next wake-up
109}
110
111// badControl logs an invalid control line, once per module.
112func (b *Bar) badControl(m *module.Module, err error) {
113 if b.badCtl[m] {
114 return
115 }
116 if b.badCtl == nil {
117 b.badCtl = map[*module.Module]bool{}
118 }
119 b.badCtl[m] = true
120 b.log.Warn("module sent an invalid control line; ignored (shown once per module)", "module", m.Name, "err", err)
121}
122
123// coalesceControl keeps, in order, every menu line and only the last of the
124// tooltip and close lines. It works in place.
125func coalesceControl(cs []module.Control) []module.Control {
126 last := -1
127 for i, c := range cs {
128 if c.Type != module.ControlMenu {
129 last = i
130 }
131 }
132 i := 0
133 return slices.DeleteFunc(cs, func(c module.Control) bool {
134 drop := c.Type != module.ControlMenu && i != last
135 i++
136 return drop
137 })
138}
139
140// tooltipInterval is the shortest time between two tooltips opened for one
141// module.
142const tooltipInterval = 250 * time.Millisecond
143
144// tooltipLimiter rate-limits the tooltips of each module.
145type tooltipLimiter struct {
146 last map[*module.Module]time.Time
147}
148
149// allow reports whether m may open a tooltip at now, and counts it if so.
150func (l *tooltipLimiter) allow(m *module.Module, now time.Time) bool {
151 if t, ok := l.last[m]; ok && now.Sub(t) < tooltipInterval {
152 return false
153 }
154 if l.last == nil {
155 l.last = map[*module.Module]time.Time{}
156 }
157 l.last[m] = now
158 return true
159}
160
161// forget drops what is kept for modules that are not in keep.
162func (l *tooltipLimiter) forget(keep []*module.Module) {
163 for m := range l.last {
164 if !slices.Contains(keep, m) {
165 delete(l.last, m)
166 }
167 }
168}
169
170// forgetModules drops the per-module state of modules that were stopped.
171func (b *Bar) forgetModules(keep []*module.Module) {
172 for m := range b.badCtl {
173 if !slices.Contains(keep, m) {
174 delete(b.badCtl, m)
175 }
176 }
177 b.tips.forget(keep)
178}
179
180// control handles one control line of module m.
181func (b *Bar) control(m *module.Module, ctrl module.Control) {
182 if !m.Interactive {
183 return // only modules that asked for the pointer may open popups
184 }
185 switch ctrl.Type {
186 case module.ControlClose:
187 if b.pop.Kind() == popup.KindTooltip && b.pop.Owner() == m {
188 b.pop.Close()
189 }
190 case module.ControlTooltip:
191 // Only for the module under the pointer, and never over a menu.
192 if m != b.ptr.hover || b.pop.Kind() == popup.KindMenu {
193 return
194 }
195 if b.pop.ShowsTooltip(m, ctrl) || !b.tips.allow(m, time.Now()) {
196 return // already shown, or the script asks too often
197 }
198 b.openPopup(m, popup.KindTooltip, ctrl, 0)
199 case module.ControlMenu:
200 serial, ok := b.ptr.takeMenuPress(m, ctrl.Click, time.Now())
201 if !ok {
202 b.log.Debug("menu dropped: it does not answer the latest press", "module", m.Name, "click", ctrl.Click)
203 return
204 }
205 b.openPopup(m, popup.KindMenu, ctrl, serial)
206 }
207}
208
209// openPopup shows ctrl as a popup of module m, anchored to the cells it names.
210func (b *Bar) openPopup(m *module.Module, kind popup.Kind, ctrl module.Control, serial uint32) {
211 if !b.configured || b.fb == nil || b.face == nil || b.lay == nil {
212 return
213 }
214 b.spanBuf = b.lay.Spans(b.spanBuf[:0])
215 var span layout.Span
216 for _, s := range b.spanBuf {
217 if s.M == m {
218 span = s
219 }
220 }
221 if span.M == nil {
222 return // the module shows nothing now
223 }
224 _, h, scale := b.surf.Size()
225 anchor, ok := popup.Anchor(span.Start, span.Width, ctrl.Col, ctrl.Width, b.face.CellW, scale, h)
226 if !ok {
227 b.log.Debug("popup dropped: its column is outside the module", "module", m.Name, "col", ctrl.Col)
228 return
229 }
230 err := b.pop.Open(popup.Request{
231 Kind: kind, Owner: m, Ctrl: ctrl, Anchor: anchor, Parent: b.surf,
232 BottomBar: b.cfg.Bar.Position == "bottom", Feedback: b.fb, Serial: serial,
233 })
234 if err != nil {
235 b.log.Warn("cannot open a popup", "module", m.Name, "err", err)
236 }
237}